crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

XDLimitsTests.swift (7292B)


      1 import Foundation
      2 import Testing
      3 
      4 @testable import Crossmate
      5 
      6 /// Adversarial boundary coverage for the parser's pre-allocation budgets:
      7 /// grid geometry, clue count, per-clue text/metadata/answer lengths, accepted
      8 /// answers, and rebus expansions. Each limit is tested from both sides — the
      9 /// hostile case fails closed, the maximum legitimate case still parses.
     10 @Suite("XD parsing limits")
     11 struct XDLimitsTests {
     12 
     13     /// Parses the source, returning the ParseError it threw (nil on success
     14     /// or on a non-ParseError failure).
     15     private func parseError(from source: String) -> XD.ParseError? {
     16         do {
     17             _ = try XD.parse(source)
     18             return nil
     19         } catch let error as XD.ParseError {
     20             return error
     21         } catch {
     22             return nil
     23         }
     24     }
     25 
     26     // MARK: - Grid geometry
     27 
     28     @Test("A grid wider than the maximum is rejected before allocation")
     29     func gridWiderThanMaximumIsRejected() {
     30         let row = String(repeating: "A", count: XD.maxGridDimension + 1)
     31         let source = """
     32         Title: Wide
     33 
     34 
     35         \(row)
     36 
     37 
     38         A1. Never reached
     39         """
     40 
     41         guard case .some(.gridTooLarge) = parseError(from: source) else {
     42             Issue.record("expected gridTooLarge")
     43             return
     44         }
     45     }
     46 
     47     @Test("A grid taller than the maximum is rejected before allocation")
     48     func gridTallerThanMaximumIsRejected() {
     49         let rows = Array(repeating: "A", count: XD.maxGridDimension + 1).joined(separator: "\n")
     50         let source = """
     51         Title: Tall
     52 
     53 
     54         \(rows)
     55 
     56 
     57         D1. Never reached
     58         """
     59 
     60         guard case .some(.gridTooLarge) = parseError(from: source) else {
     61             Issue.record("expected gridTooLarge")
     62             return
     63         }
     64     }
     65 
     66     @Test("A grid at the maximum width still parses")
     67     func gridAtMaximumWidthParses() throws {
     68         let row = String(repeating: "A", count: XD.maxGridDimension)
     69         let source = """
     70         Title: Widest
     71 
     72 
     73         \(row)
     74 
     75 
     76         A1. All As
     77         """
     78 
     79         let xd = try XD.parse(source)
     80         #expect(xd.width == XD.maxGridDimension)
     81         #expect(xd.height == 1)
     82     }
     83 
     84     @Test("A grid at the maximum height still parses")
     85     func gridAtMaximumHeightParses() throws {
     86         let rows = Array(repeating: "A", count: XD.maxGridDimension).joined(separator: "\n")
     87         let source = """
     88         Title: Tallest
     89 
     90 
     91         \(rows)
     92 
     93 
     94         D1. All As
     95         """
     96 
     97         let xd = try XD.parse(source)
     98         #expect(xd.width == 1)
     99         #expect(xd.height == XD.maxGridDimension)
    100     }
    101 
    102     @Test("A maximum-width all-open row with an answer-less clue fails closed")
    103     func answerlessOpenRowFailsClosed() {
    104         // The audit's quadratic shape: one long open row whose only clue has
    105         // no answer. The word-scoped passes visit the row once and the missing
    106         // inferred solutions reject it promptly.
    107         let row = String(repeating: ".", count: XD.maxGridDimension)
    108         let source = """
    109         Title: Open Row
    110 
    111 
    112         \(row)
    113 
    114 
    115         A1. No answer given
    116         """
    117 
    118         guard case .some(.missingInferredSolution) = parseError(from: source) else {
    119             Issue.record("expected missingInferredSolution")
    120             return
    121         }
    122     }
    123 
    124     // MARK: - Clue budgets
    125 
    126     @Test("More distinct clues than the maximum are rejected")
    127     func tooManyDistinctCluesAreRejected() {
    128         let clues = (1...(XD.maxClueCount + 1))
    129             .map { "A\($0). x" }
    130             .joined(separator: "\n")
    131         let source = """
    132         Title: Cluepocalypse
    133 
    134 
    135         A
    136 
    137 
    138         \(clues)
    139         """
    140 
    141         guard case .some(.tooManyClues) = parseError(from: source) else {
    142             Issue.record("expected tooManyClues")
    143             return
    144         }
    145     }
    146 
    147     @Test("A clue count at the maximum still parses")
    148     func clueCountAtMaximumParses() throws {
    149         let clues = (1...XD.maxClueCount)
    150             .map { "A\($0). x" }
    151             .joined(separator: "\n")
    152         let source = """
    153         Title: Many Clues
    154 
    155 
    156         A
    157 
    158 
    159         \(clues)
    160         """
    161 
    162         let xd = try XD.parse(source)
    163         #expect(xd.acrossClues.count == XD.maxClueCount)
    164     }
    165 
    166     @Test("An over-long clue metadata value is rejected")
    167     func overLongClueMetadataValueIsRejected() {
    168         let value = String(repeating: "x", count: XD.maxClueTextLength + 1)
    169         let source = """
    170         Title: Long Metadata
    171 
    172 
    173         A
    174 
    175 
    176         A1. Letter ~ A
    177         A1 ^Note: \(value)
    178         """
    179 
    180         guard case .some(.malformedClue) = parseError(from: source) else {
    181             Issue.record("expected malformedClue")
    182             return
    183         }
    184     }
    185 
    186     @Test("An over-long answer reading is rejected")
    187     func overLongAnswerReadingIsRejected() {
    188         let answer = String(repeating: "A", count: XD.maxClueTextLength + 1)
    189         let source = """
    190         Title: Long Answer
    191 
    192 
    193         A
    194 
    195 
    196         A1. Letter ~ \(answer)
    197         """
    198 
    199         guard case .some(.malformedClue) = parseError(from: source) else {
    200             Issue.record("expected malformedClue")
    201             return
    202         }
    203     }
    204 
    205     @Test("More accepted answers than the maximum are rejected")
    206     func tooManyAcceptedAnswersAreRejected() {
    207         let tokens = (1...(XD.maxAcceptedAnswersPerClue + 1))
    208             .map { "B\($0)" }
    209             .joined(separator: " ")
    210         let source = """
    211         Title: Accepting
    212 
    213 
    214         A
    215 
    216 
    217         A1. Letter ~ A
    218         A1 ^Accept: \(tokens)
    219         """
    220 
    221         guard case .some(.tooManyAcceptedAnswers) = parseError(from: source) else {
    222             Issue.record("expected tooManyAcceptedAnswers")
    223             return
    224         }
    225     }
    226 
    227     @Test("An accepted-answer count at the maximum still parses")
    228     func acceptedAnswersAtMaximumParse() throws {
    229         let tokens = (1...XD.maxAcceptedAnswersPerClue)
    230             .map { "B\($0)" }
    231             .joined(separator: " ")
    232         let source = """
    233         Title: Accepting
    234 
    235 
    236         A
    237 
    238 
    239         A1. Letter ~ A
    240         A1 ^Accept: \(tokens)
    241         """
    242 
    243         let xd = try XD.parse(source)
    244         #expect(xd.acrossClues.first?.acceptedAnswers.count == XD.maxAcceptedAnswersPerClue)
    245     }
    246 
    247     // MARK: - Rebus budget
    248 
    249     @Test("An over-long rebus expansion fails closed at its grid cell")
    250     func overLongRebusValueFailsClosed() {
    251         let value = String(repeating: "A", count: XD.maxRebusValueLength + 1)
    252         let source = """
    253         Title: Big Rebus
    254         Rebus: 1=\(value)
    255 
    256 
    257         1
    258 
    259 
    260         A1. Never reached
    261         """
    262 
    263         guard case .some(.unknownGridCharacter) = parseError(from: source) else {
    264             Issue.record("expected unknownGridCharacter")
    265             return
    266         }
    267     }
    268 
    269     @Test("A rebus expansion at the maximum length still parses")
    270     func rebusValueAtMaximumParses() throws {
    271         let value = String(repeating: "A", count: XD.maxRebusValueLength)
    272         let source = """
    273         Title: Big Rebus
    274         Rebus: 1=\(value)
    275 
    276 
    277         1
    278 
    279 
    280         A1. Long fill ~ \(value)
    281         """
    282 
    283         let xd = try XD.parse(source)
    284         guard case .open(let solution?, _, _) = xd.cells[0][0] else {
    285             Issue.record("expected an open cell with a solution")
    286             return
    287         }
    288         #expect(solution.count == XD.maxRebusValueLength)
    289     }
    290 }