Files
Spanish/Conjuga/SharedModels/Tests/SharedModelsTests/SentenceQuizEngineTests.swift
Trey t 143e356b75 Complete the Sentence quiz type: engine, UI, tests
Add a new Complete the Sentence quiz type that renders a Spanish example
sentence from the card with the target word blanked out and asks the
student to pick the missing word from 4 choices (other cards' fronts
from the same week's pool).

Core logic lives in SharedModels/SentenceQuizEngine as pure functions
over VocabCard, covered by 18 Swift Testing tests. CourseQuizView calls
the engine, pre-filters the card pool to cards that can produce a
resolvable blank, and reuses the existing MC rendering via a new
correctAnswer(for:) helper.

VocabCard gains examplesBlanks (parallel array to examplesES) so content
can explicitly tag the blanked substring; DataLoader reads an optional
"blank" key on each example. Additive schema change, CloudKit-safe
default.

Also adds ContentCoverageTests that parse the repo's course_data.json
and assert every card has >=3 examples and yields a resolvable question.
These tests currently fail: 1,117 cards still need sentences. They are
the oracle for the gap-fill pass that follows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:33:50 -05:00

270 lines
9.7 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 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)
}
}