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>
This commit is contained in:
Trey t
2026-04-11 19:33:50 -05:00
parent 3b8a8a7f1a
commit 143e356b75
8 changed files with 645 additions and 32 deletions

View File

@@ -8,6 +8,7 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
case typingEsToEn = "typing_es_to_en"
case handwritingEnToEs = "hw_en_to_es"
case handwritingEsToEn = "hw_es_to_en"
case completeSentenceES = "complete_sentence_es"
var id: String { rawValue }
@@ -19,6 +20,7 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
case .typingEsToEn: "Fill in the Blank: ES → EN"
case .handwritingEnToEs: "Handwriting: EN → ES"
case .handwritingEsToEn: "Handwriting: ES → EN"
case .completeSentenceES: "Complete the Sentence"
}
}
@@ -27,6 +29,7 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
case .mcEnToEs, .mcEsToEn: "list.bullet"
case .typingEnToEs, .typingEsToEn: "keyboard"
case .handwritingEnToEs, .handwritingEsToEn: "pencil.and.outline"
case .completeSentenceES: "text.badge.checkmark"
}
}
@@ -38,6 +41,7 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
case .typingEsToEn: "See Spanish, type the English word"
case .handwritingEnToEs: "See English, handwrite the Spanish word"
case .handwritingEsToEn: "See Spanish, handwrite the English word"
case .completeSentenceES: "Read a Spanish sentence and pick the missing word"
}
}
@@ -45,20 +49,20 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
var promptLanguage: String {
switch self {
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: "English"
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: "Spanish"
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES: "Spanish"
}
}
var answerLanguage: String {
switch self {
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: "Spanish"
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: "Spanish"
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: "English"
}
}
var isMultipleChoice: Bool {
switch self {
case .mcEnToEs, .mcEsToEn: true
case .mcEnToEs, .mcEsToEn, .completeSentenceES: true
case .typingEnToEs, .typingEsToEn, .handwritingEnToEs, .handwritingEsToEn: false
}
}
@@ -70,16 +74,20 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
}
}
var isCompleteSentence: Bool {
self == .completeSentenceES
}
func prompt(for card: VocabCard) -> String {
switch self {
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: card.back
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: card.front
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES: card.front
}
}
func answer(for card: VocabCard) -> String {
switch self {
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: card.front
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: card.front
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: card.back
}
}

View File

@@ -255,14 +255,18 @@ actor DataLoader {
// Parse example sentences
var exES: [String] = []
var exEN: [String] = []
var exBlanks: [String] = []
if let examples = cardDict["examples"] as? [[String: String]] {
for ex in examples {
if let es = ex["es"] { exES.append(es) }
if let en = ex["en"] { exEN.append(en) }
if let es = ex["es"] {
exES.append(es)
exEN.append(ex["en"] ?? "")
exBlanks.append(ex["blank"] ?? "")
}
}
}
let card = VocabCard(front: front, back: back, deckId: deckId, examplesES: exES, examplesEN: exEN)
let card = VocabCard(front: front, back: back, deckId: deckId, examplesES: exES, examplesEN: exEN, examplesBlanks: exBlanks)
card.deck = deck
context.insert(card)
cardCount += 1

View File

@@ -19,6 +19,7 @@ struct CourseQuizView: View {
@State private var correctCount = 0
@State private var missedItems: [MissedCourseItem] = []
@State private var isAdvancing = false
@State private var sentenceQuestion: SentenceQuizEngine.Question?
// Per-question state
@State private var userAnswer = ""
@@ -61,25 +62,29 @@ struct CourseQuizView: View {
.padding(.horizontal)
// Prompt
VStack(spacing: 8) {
Text(quizType.promptLanguage)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.textCase(.uppercase)
if quizType.isCompleteSentence, let question = sentenceQuestion {
sentencePrompt(question: question)
} else {
VStack(spacing: 8) {
Text(quizType.promptLanguage)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.textCase(.uppercase)
Text(quizType.prompt(for: card))
.font(.title.weight(.bold))
.multilineTextAlignment(.center)
Text(quizType.prompt(for: card))
.font(.title.weight(.bold))
.multilineTextAlignment(.center)
if quizType.promptLanguage == "Spanish" {
Button { speechService.speak(card.front) } label: {
Image(systemName: "speaker.wave.2")
.font(.title3)
if quizType.promptLanguage == "Spanish" {
Button { speechService.speak(card.front) } label: {
Image(systemName: "speaker.wave.2")
.font(.title3)
}
.tint(.secondary)
}
.tint(.secondary)
}
.padding(.top, 8)
}
.padding(.top, 8)
// Answer area
if quizType.isMultipleChoice {
@@ -112,15 +117,48 @@ struct CourseQuizView: View {
}
}
.onAppear {
shuffledCards = cards.shuffled()
let pool: [VocabCard]
if quizType.isCompleteSentence {
pool = cards.filter { SentenceQuizEngine.hasValidSentence(for: $0) }
} else {
pool = cards
}
shuffledCards = pool.shuffled()
prepareQuestion()
}
}
// MARK: - Complete the Sentence
private func sentencePrompt(question: SentenceQuizEngine.Question) -> some View {
VStack(spacing: 12) {
Text("Complete the Sentence")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.textCase(.uppercase)
Text(question.displayTemplate)
.font(.title2.weight(.semibold))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal)
if !question.sentenceEN.isEmpty {
Text(question.sentenceEN)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
}
.padding(.top, 8)
}
// MARK: - Multiple Choice
private func multipleChoiceArea(card: VocabCard) -> some View {
VStack(spacing: 10) {
let correct = correctAnswer(for: card)
return VStack(spacing: 10) {
ForEach(Array(options.enumerated()), id: \.offset) { index, option in
Button {
guard !isAnswered else { return }
@@ -132,7 +170,7 @@ struct CourseQuizView: View {
.font(.body.weight(.medium))
Spacer()
if isAnswered {
if option == quizType.answer(for: card) {
if option.caseInsensitiveCompare(correct) == .orderedSame {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
} else if index == selectedOption {
@@ -147,7 +185,7 @@ struct CourseQuizView: View {
}
.tint(mcTint(index: index, option: option, card: card))
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.opacity(isAnswered && option != quizType.answer(for: card) && index != selectedOption ? 0.4 : 1)
.opacity(isAnswered && option.caseInsensitiveCompare(correct) != .orderedSame && index != selectedOption ? 0.4 : 1)
.disabled(isAnswered)
}
}
@@ -156,11 +194,18 @@ struct CourseQuizView: View {
private func mcTint(index: Int, option: String, card: VocabCard) -> Color {
guard isAnswered else { return .primary }
if option == quizType.answer(for: card) { return .green }
if option.caseInsensitiveCompare(correctAnswer(for: card)) == .orderedSame { return .green }
if index == selectedOption { return .red }
return .secondary
}
private func correctAnswer(for card: VocabCard) -> String {
if quizType.isCompleteSentence, let blank = sentenceQuestion?.blankWord {
return blank
}
return quizType.answer(for: card)
}
// MARK: - Handwriting
private func handwritingArea(card: VocabCard) -> some View {
@@ -418,6 +463,12 @@ struct CourseQuizView: View {
selectedOption = nil
userAnswer = ""
if quizType.isCompleteSentence {
sentenceQuestion = SentenceQuizEngine.buildQuestion(for: card)
} else {
sentenceQuestion = nil
}
if quizType.isMultipleChoice {
options = generateOptions(for: card)
} else {
@@ -436,6 +487,7 @@ struct CourseQuizView: View {
hwDrawing = PKDrawing()
hwRecognizedText = ""
isRecognizing = false
sentenceQuestion = nil
}
private func submitHandwriting(card: VocabCard) {
@@ -453,11 +505,11 @@ struct CourseQuizView: View {
}
private func generateOptions(for card: VocabCard) -> [String] {
let correct = quizType.answer(for: card)
let correct = correctAnswer(for: card)
var distractors: [String] = []
var seen: Set<String> = [correct.lowercased()]
// Pull distractors from all cards in the set
// Pull distractors from all cards in the set using each card's own front
for other in shuffledCards.shuffled() {
let ans = quizType.answer(for: other)
let lower = ans.lowercased()
@@ -474,7 +526,7 @@ struct CourseQuizView: View {
}
private func checkMCAnswer(_ selected: String, card: VocabCard) {
let correct = quizType.answer(for: card)
let correct = correctAnswer(for: card)
isCorrect = selected.compare(correct, options: .caseInsensitive, locale: Locale(identifier: "es")) == .orderedSame
recordAnswer(card: card)
}

View File

@@ -3,11 +3,15 @@ import PackageDescription
let package = Package(
name: "SharedModels",
platforms: [.iOS(.v18)],
platforms: [.iOS(.v18), .macOS(.v14)],
products: [
.library(name: "SharedModels", targets: ["SharedModels"]),
],
targets: [
.target(name: "SharedModels"),
.testTarget(
name: "SharedModelsTests",
dependencies: ["SharedModels"]
),
]
)

View File

@@ -0,0 +1,106 @@
import Foundation
/// Pure logic for the Complete the Sentence quiz type.
///
/// Given a `VocabCard` with example sentences, the engine determines whether a
/// blankable question can be produced and builds the `Question` used by the UI.
/// No SwiftUI dependency exists in SharedModels so it can be unit-tested in
/// isolation and reused by other surfaces.
public struct SentenceQuizEngine {
public struct Question: Equatable, Sendable {
public let sentenceES: String
public let sentenceEN: String
/// The exact substring in `sentenceES` that was blanked (original casing preserved).
public let blankWord: String
/// `sentenceES` with `blankWord` replaced by a visible blank marker.
public let displayTemplate: String
/// Index into the card's `examplesES` that this question was built from.
public let exampleIndex: Int
public init(sentenceES: String, sentenceEN: String, blankWord: String, displayTemplate: String, exampleIndex: Int) {
self.sentenceES = sentenceES
self.sentenceEN = sentenceEN
self.blankWord = blankWord
self.displayTemplate = displayTemplate
self.exampleIndex = exampleIndex
}
}
/// Marker string substituted into `displayTemplate` in place of the blank word.
public static let blankMarker = "_____"
/// True when the card has at least one example sentence where a blank can be determined,
/// either via a stored `examplesBlanks` entry or by substring-matching `card.front`.
public static func hasValidSentence(for card: VocabCard) -> Bool {
guard !card.examplesES.isEmpty else { return false }
for i in card.examplesES.indices {
if isBlankResolvable(card: card, exampleIndex: i) {
return true
}
}
return false
}
/// Returns the set of example indices that can produce a valid blank.
public static func resolvableIndices(for card: VocabCard) -> [Int] {
card.examplesES.indices.filter { isBlankResolvable(card: card, exampleIndex: $0) }
}
/// Builds a question from the card by picking a random resolvable example.
/// Returns nil if no example qualifies.
public static func buildQuestion(for card: VocabCard) -> Question? {
let candidates = resolvableIndices(for: card)
guard let pick = candidates.randomElement() else { return nil }
return buildQuestion(for: card, exampleIndex: pick)
}
/// Deterministic variant builds a question from a specific example index.
/// Returns nil if that example doesn't contain a resolvable blank.
public static func buildQuestion(for card: VocabCard, exampleIndex: Int) -> Question? {
guard exampleIndex >= 0, exampleIndex < card.examplesES.count else { return nil }
let sentence = card.examplesES[exampleIndex]
let sentenceEN = exampleIndex < card.examplesEN.count ? card.examplesEN[exampleIndex] : ""
let storedBlank = exampleIndex < card.examplesBlanks.count ? card.examplesBlanks[exampleIndex] : ""
// Prefer the stored blank if present and actually appears in the sentence.
if !storedBlank.isEmpty, let range = sentence.range(of: storedBlank, options: .caseInsensitive) {
return makeQuestion(sentence: sentence, sentenceEN: sentenceEN, range: range, exampleIndex: exampleIndex)
}
// Fall back to substring match on card.front.
if !card.front.isEmpty, let range = sentence.range(of: card.front, options: .caseInsensitive) {
return makeQuestion(sentence: sentence, sentenceEN: sentenceEN, range: range, exampleIndex: exampleIndex)
}
return nil
}
// MARK: - Private
private static func isBlankResolvable(card: VocabCard, exampleIndex: Int) -> Bool {
let sentence = card.examplesES[exampleIndex]
let storedBlank = exampleIndex < card.examplesBlanks.count ? card.examplesBlanks[exampleIndex] : ""
if !storedBlank.isEmpty, sentence.range(of: storedBlank, options: .caseInsensitive) != nil {
return true
}
if !card.front.isEmpty, sentence.range(of: card.front, options: .caseInsensitive) != nil {
return true
}
return false
}
private static func makeQuestion(sentence: String, sentenceEN: String, range: Range<String.Index>, exampleIndex: Int) -> Question {
let blankWord = String(sentence[range])
var template = sentence
template.replaceSubrange(range, with: blankMarker)
return Question(
sentenceES: sentence,
sentenceEN: sentenceEN,
blankWord: blankWord,
displayTemplate: template,
exampleIndex: exampleIndex
)
}
}

View File

@@ -8,6 +8,9 @@ public final class VocabCard {
public var deckId: String = ""
public var examplesES: [String] = []
public var examplesEN: [String] = []
/// Per-example blank word for Complete the Sentence quiz. Index-aligned with `examplesES`.
/// Empty string at a given index means "fall back to substring-matching card.front".
public var examplesBlanks: [String] = []
public var deck: CourseDeck?
@@ -18,11 +21,12 @@ public final class VocabCard {
public var dueDate: Date = Date()
public var lastReviewDate: Date?
public init(front: String, back: String, deckId: String, examplesES: [String] = [], examplesEN: [String] = []) {
public init(front: String, back: String, deckId: String, examplesES: [String] = [], examplesEN: [String] = [], examplesBlanks: [String] = []) {
self.front = front
self.back = back
self.deckId = deckId
self.examplesES = examplesES
self.examplesEN = examplesEN
self.examplesBlanks = examplesBlanks
}
}

View File

@@ -0,0 +1,166 @@
import Testing
import Foundation
@testable import SharedModels
/// Invariants that the shipped `course_data.json` must satisfy for the
/// Complete the Sentence quiz to work for every card in every course.
///
/// These tests read the repo's `course_data.json` from a fixed relative path.
/// They act as the pass/fail oracle for the content gap-fill work: they fail
/// before the gap-fill pass is complete and pass once every card has at least
/// three examples and at least one of them yields a resolvable blank.
@Suite("Content coverage — course_data.json")
struct ContentCoverageTests {
// Repo-relative path from this test file to the bundled data file.
// SharedModels/Tests/SharedModelsTests/ContentCoverageTests.swift
// ../../../../Conjuga/course_data.json
private static let courseDataPath: String = {
let here = URL(fileURLWithPath: #filePath)
return here
.deletingLastPathComponent() // SharedModelsTests
.deletingLastPathComponent() // Tests
.deletingLastPathComponent() // SharedModels
.deletingLastPathComponent() // Conjuga (repo package parent)
.appendingPathComponent("Conjuga/course_data.json")
.path
}()
struct CardRef {
let courseName: String
let weekNumber: Int
let deckTitle: String
let front: String
let back: String
let examples: [[String: String]]
}
/// Load every card in course_data.json.
static func loadAllCards() throws -> [CardRef] {
let url = URL(fileURLWithPath: courseDataPath)
let data = try Data(contentsOf: url)
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let courses = json["courses"] as? [[String: Any]] else {
Issue.record("course_data.json is not in the expected shape")
return []
}
var cards: [CardRef] = []
for course in courses {
let cname = course["course"] as? String ?? "<unknown>"
let weeks = course["weeks"] as? [[String: Any]] ?? []
for week in weeks {
let wnum = week["week"] as? Int ?? -1
let decks = week["decks"] as? [[String: Any]] ?? []
for deck in decks {
let title = deck["title"] as? String ?? "<unknown>"
let rawCards = deck["cards"] as? [[String: Any]] ?? []
for raw in rawCards {
let front = raw["front"] as? String ?? ""
let back = raw["back"] as? String ?? ""
let examples = (raw["examples"] as? [[String: String]]) ?? []
cards.append(CardRef(
courseName: cname,
weekNumber: wnum,
deckTitle: title,
front: front,
back: back,
examples: examples
))
}
}
}
}
return cards
}
private static func vocabCard(from ref: CardRef) -> VocabCard {
var exES: [String] = []
var exEN: [String] = []
var exBlanks: [String] = []
for ex in ref.examples {
if let es = ex["es"] {
exES.append(es)
exEN.append(ex["en"] ?? "")
exBlanks.append(ex["blank"] ?? "")
}
}
return VocabCard(
front: ref.front,
back: ref.back,
deckId: "\(ref.courseName)_w\(ref.weekNumber)_\(ref.deckTitle)",
examplesES: exES,
examplesEN: exEN,
examplesBlanks: exBlanks
)
}
@Test("course_data.json exists and parses")
func fileExists() throws {
let cards = try Self.loadAllCards()
#expect(cards.count > 0, "Expected at least one card in course_data.json")
}
@Test("Every card has at least three example sentences")
func everyCardHasThreeExamples() throws {
let cards = try Self.loadAllCards()
var failures: [String] = []
for ref in cards {
if ref.examples.count < 3 {
failures.append("[\(ref.courseName) / w\(ref.weekNumber) / \(ref.deckTitle)] '\(ref.front)' has \(ref.examples.count) examples")
}
}
if !failures.isEmpty {
let head = Array(failures.prefix(10)).joined(separator: "\n")
Issue.record("\(failures.count) cards have fewer than 3 examples. First 10:\n\(head)")
}
#expect(failures.isEmpty)
}
@Test("Every card yields a resolvable SentenceQuizEngine question")
func everyCardHasBlankableSentence() throws {
let cards = try Self.loadAllCards()
var failures: [String] = []
for ref in cards {
let card = Self.vocabCard(from: ref)
if !SentenceQuizEngine.hasValidSentence(for: card) {
failures.append("[\(ref.courseName) / w\(ref.weekNumber) / \(ref.deckTitle)] '\(ref.front)'")
}
}
if !failures.isEmpty {
let head = Array(failures.prefix(15)).joined(separator: "\n")
Issue.record("\(failures.count) cards have no resolvable sentence for Complete the Sentence. First 15:\n\(head)")
}
#expect(failures.isEmpty)
}
@Test("Every generated question has a non-empty blank word and display template")
func questionIntegrity() throws {
let cards = try Self.loadAllCards()
var failures: [String] = []
for ref in cards {
let card = Self.vocabCard(from: ref)
// Try to build a question from each resolvable index deterministically
for idx in SentenceQuizEngine.resolvableIndices(for: card) {
guard let q = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: idx) else {
failures.append("[\(ref.courseName) / '\(ref.front)'] index \(idx) returned nil despite being resolvable")
continue
}
if q.blankWord.isEmpty {
failures.append("[\(ref.courseName) / '\(ref.front)'] index \(idx) has empty blankWord")
}
if !q.displayTemplate.contains(SentenceQuizEngine.blankMarker) {
failures.append("[\(ref.courseName) / '\(ref.front)'] index \(idx) display template missing blank marker")
}
if q.displayTemplate == q.sentenceES {
failures.append("[\(ref.courseName) / '\(ref.front)'] index \(idx) display template unchanged from sentence")
}
}
}
if !failures.isEmpty {
let head = Array(failures.prefix(10)).joined(separator: "\n")
Issue.record("\(failures.count) question integrity failures. First 10:\n\(head)")
}
#expect(failures.isEmpty)
}
}

View File

@@ -0,0 +1,269 @@
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)
}
}