Examples shorter than 4 words (like pronunciation guides "discutir(dees-koo-teer)") are now rejected by both isBlankResolvable and buildQuestion. The engine only picks real multi-word sentences for the quiz prompt. Every card already has at least one real sentence alongside its phonetic entries, so no data regeneration is needed — the filter alone fixes the issue. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
290 lines
11 KiB
Swift
290 lines
11 KiB
Swift
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)
|
|
}
|
|
}
|