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 algo ahora." ], examplesEN: ["", "", ""] ) #expect(SentenceQuizEngine.hasValidSentence(for: card)) #expect(SentenceQuizEngine.resolvableIndices(for: card) == [2]) } @Test("Phonetic glosses are rejected (too few words)") func phoneticGlossRejected() { let card = VocabCard( front: "discutir", back: "to discuss", deckId: "d", examplesES: [ "discutir(dees-koo-teer)", "INTRANSITIVE VERB", "Los amigos van a discutir el tema." ], examplesEN: ["", "", "The friends are going to discuss the topic."] ) // Only index 2 is a real sentence (≥4 words AND contains the target) #expect(SentenceQuizEngine.hasValidSentence(for: card)) #expect(SentenceQuizEngine.resolvableIndices(for: card) == [2]) // Phonetic entry at index 0 returns nil #expect(SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0) == nil) } // 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 amiga, ¿cómo estás hoy?"], examplesEN: ["Hello friend, how are you today?"] ) let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0) #expect(question?.blankWord == "Hola") #expect(question?.displayTemplate == "_____ amiga, ¿cómo estás hoy?") } @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 (no "comer") "Los niños van al parque.", // unresolvable "Quiero comer algo rico ahora.", // resolvable (substring, ≥4 words) "El perro come su comida diaria." // unresolvable — "come" but not "comer" ], examplesEN: ["", "", "", ""] ) // Only index 2 is resolvable (contains "comer" literally and has ≥4 words) 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 mucho pan.", "Tú comes en casa."], examplesEN: ["I eat a lot of bread.", "You eat at home."], 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 todo el día."], examplesEN: ["The dog barks all day."] ) let q = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0) #expect(q?.displayTemplate.contains(SentenceQuizEngine.blankMarker) == true) } }