import Testing @testable import SharedModels @Suite("SentenceQuizEngine") struct SentenceQuizEngineTests { // MARK: - hasValidSentence @Test("No examples returns false") func noExamples() { let card = VocabCard(front: "comer", back: "to eat", deckId: "d", examplesES: [], examplesEN: []) #expect(SentenceQuizEngine.hasValidSentence(for: card) == false) } @Test("Example containing target word returns true via substring fallback") func substringMatch() { let card = VocabCard( front: "manzana", back: "apple", deckId: "d", examplesES: ["Yo como una manzana roja."], examplesEN: ["I eat a red apple."] ) #expect(SentenceQuizEngine.hasValidSentence(for: card)) } @Test("Example whose stored blank appears returns true even if target word is missing") func storedBlankMatch() { let card = VocabCard( front: "comer", back: "to eat", deckId: "d", examplesES: ["Yo como manzanas todos los días."], examplesEN: ["I eat apples every day."], examplesBlanks: ["como"] ) #expect(SentenceQuizEngine.hasValidSentence(for: card)) } @Test("Example with neither stored blank nor substring match returns false for that example") func neitherMatches() { let card = VocabCard( front: "comer", back: "to eat", deckId: "d", examplesES: ["Ella prepara la cena."], examplesEN: ["She prepares dinner."] ) #expect(SentenceQuizEngine.hasValidSentence(for: card) == false) } @Test("At least one resolvable example across many makes the card valid") func oneOfManyResolves() { let card = VocabCard( front: "comer", back: "to eat", deckId: "d", examplesES: [ "Ella prepara la cena.", "Los niños van al parque.", "Quiero comer ahora." ], examplesEN: ["", "", ""] ) #expect(SentenceQuizEngine.hasValidSentence(for: card)) #expect(SentenceQuizEngine.resolvableIndices(for: card) == [2]) } // MARK: - buildQuestion (deterministic) @Test("Builds question from substring match, preserves original casing") func buildFromSubstring() { let card = VocabCard( front: "manzana", back: "apple", deckId: "d", examplesES: ["Yo como una manzana roja."], examplesEN: ["I eat a red apple."] ) let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0) #expect(question != nil) #expect(question?.sentenceES == "Yo como una manzana roja.") #expect(question?.sentenceEN == "I eat a red apple.") #expect(question?.blankWord == "manzana") #expect(question?.displayTemplate == "Yo como una _____ roja.") #expect(question?.exampleIndex == 0) } @Test("Builds question from stored blank when provided") func buildFromStoredBlank() { let card = VocabCard( front: "comer", back: "to eat", deckId: "d", examplesES: ["Yo como manzanas todos los días."], examplesEN: ["I eat apples every day."], examplesBlanks: ["como"] ) let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0) #expect(question?.blankWord == "como") #expect(question?.displayTemplate == "Yo _____ manzanas todos los días.") } @Test("Stored blank takes precedence over substring match") func storedBlankWins() { // Card teaches "manzana" (would substring-match), but the stored blank is the verb "como" let card = VocabCard( front: "manzana", back: "apple", deckId: "d", examplesES: ["Yo como una manzana."], examplesEN: ["I eat an apple."], examplesBlanks: ["como"] ) let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0) #expect(question?.blankWord == "como") #expect(question?.displayTemplate == "Yo _____ una manzana.") } @Test("Falls back to substring match when stored blank is empty") func fallbackWhenStoredBlankEmpty() { let card = VocabCard( front: "manzana", back: "apple", deckId: "d", examplesES: ["Yo como una manzana."], examplesEN: ["I eat an apple."], examplesBlanks: [""] ) let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0) #expect(question?.blankWord == "manzana") } @Test("Falls back to substring match when stored blank doesn't actually appear in the sentence") func fallbackWhenStoredBlankMissing() { let card = VocabCard( front: "manzana", back: "apple", deckId: "d", examplesES: ["Yo como una manzana."], examplesEN: ["I eat an apple."], examplesBlanks: ["nonexistent"] ) let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0) #expect(question?.blankWord == "manzana") } @Test("Preserves original capitalization when blanking (substring is case-insensitive)") func preservesCapitalization() { let card = VocabCard( front: "hola", back: "hello", deckId: "d", examplesES: ["Hola, ¿cómo estás?"], examplesEN: ["Hello, how are you?"] ) let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0) #expect(question?.blankWord == "Hola") #expect(question?.displayTemplate == "_____, ¿cómo estás?") } @Test("Blanks phrase cards when target front contains spaces") func phraseCardBlank() { let card = VocabCard( front: "¿cómo estás?", back: "how are you?", deckId: "d", examplesES: ["Hola amiga, ¿cómo estás? Estoy bien."], examplesEN: ["Hi friend, how are you? I am well."] ) let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0) #expect(question?.blankWord == "¿cómo estás?") #expect(question?.displayTemplate == "Hola amiga, _____ Estoy bien.") } @Test("Returns nil when the example has no resolvable blank") func unresolvableExampleReturnsNil() { let card = VocabCard( front: "comer", back: "to eat", deckId: "d", examplesES: ["Ella prepara la cena."], examplesEN: ["She prepares dinner."] ) let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0) #expect(question == nil) } @Test("Returns nil when example index is out of range") func outOfRangeIndexReturnsNil() { let card = VocabCard( front: "comer", back: "to eat", deckId: "d", examplesES: ["Yo como."], examplesEN: [""] ) #expect(SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 5) == nil) #expect(SentenceQuizEngine.buildQuestion(for: card, exampleIndex: -1) == nil) } // MARK: - buildQuestion (random) @Test("Random buildQuestion always picks a resolvable example") func randomPickIsResolvable() { let card = VocabCard( front: "comer", back: "to eat", deckId: "d", examplesES: [ "Ella prepara la cena.", // unresolvable "Los niños van al parque.", // unresolvable "Quiero comer ahora.", // resolvable (substring) "El perro come su comida." // unresolvable — note "come" is a substring but "comer" is not ], examplesEN: ["", "", "", ""] ) // Only index 2 is resolvable (contains "comer" literally) for _ in 0..<25 { let q = SentenceQuizEngine.buildQuestion(for: card) #expect(q?.exampleIndex == 2) } } @Test("Random buildQuestion returns nil when no examples resolve") func randomNilWhenNothingResolves() { let card = VocabCard( front: "comer", back: "to eat", deckId: "d", examplesES: ["Ella prepara la cena."], examplesEN: [""] ) #expect(SentenceQuizEngine.buildQuestion(for: card) == nil) } // MARK: - Array alignment edge cases @Test("examplesBlanks shorter than examplesES is handled gracefully") func blanksArrayShorterThanExamples() { let card = VocabCard( front: "comer", back: "to eat", deckId: "d", examplesES: ["Yo como.", "Tú comes."], examplesEN: ["I eat.", "You eat."], examplesBlanks: ["como"] // only covers index 0 ) // Index 0: stored blank match let q0 = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0) #expect(q0?.blankWord == "como") // Index 1: no stored blank, "comer" doesn't appear literally → unresolvable let q1 = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 1) #expect(q1 == nil) } @Test("Display template uses the engine's blank marker constant") func blankMarkerConstant() { let card = VocabCard( front: "perro", back: "dog", deckId: "d", examplesES: ["El perro ladra."], examplesEN: ["The dog barks."] ) let q = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0) #expect(q?.displayTemplate.contains(SentenceQuizEngine.blankMarker) == true) } }