From 143e356b75ca9114253a12854e5123f906a40e3e Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 11 Apr 2026 19:33:50 -0500 Subject: [PATCH] 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) --- Conjuga/Conjuga/Models/QuizType.swift | 18 +- Conjuga/Conjuga/Services/DataLoader.swift | 10 +- .../Conjuga/Views/Course/CourseQuizView.swift | 96 +++++-- Conjuga/SharedModels/Package.swift | 6 +- .../SharedModels/SentenceQuizEngine.swift | 106 +++++++ .../Sources/SharedModels/VocabCard.swift | 6 +- .../ContentCoverageTests.swift | 166 +++++++++++ .../SentenceQuizEngineTests.swift | 269 ++++++++++++++++++ 8 files changed, 645 insertions(+), 32 deletions(-) create mode 100644 Conjuga/SharedModels/Sources/SharedModels/SentenceQuizEngine.swift create mode 100644 Conjuga/SharedModels/Tests/SharedModelsTests/ContentCoverageTests.swift create mode 100644 Conjuga/SharedModels/Tests/SharedModelsTests/SentenceQuizEngineTests.swift diff --git a/Conjuga/Conjuga/Models/QuizType.swift b/Conjuga/Conjuga/Models/QuizType.swift index 68aae5e..afd4958 100644 --- a/Conjuga/Conjuga/Models/QuizType.swift +++ b/Conjuga/Conjuga/Models/QuizType.swift @@ -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 } } diff --git a/Conjuga/Conjuga/Services/DataLoader.swift b/Conjuga/Conjuga/Services/DataLoader.swift index 01d4189..577bc3d 100644 --- a/Conjuga/Conjuga/Services/DataLoader.swift +++ b/Conjuga/Conjuga/Services/DataLoader.swift @@ -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 diff --git a/Conjuga/Conjuga/Views/Course/CourseQuizView.swift b/Conjuga/Conjuga/Views/Course/CourseQuizView.swift index 830122a..a977ced 100644 --- a/Conjuga/Conjuga/Views/Course/CourseQuizView.swift +++ b/Conjuga/Conjuga/Views/Course/CourseQuizView.swift @@ -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 = [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) } diff --git a/Conjuga/SharedModels/Package.swift b/Conjuga/SharedModels/Package.swift index f03ca6e..6afc003 100644 --- a/Conjuga/SharedModels/Package.swift +++ b/Conjuga/SharedModels/Package.swift @@ -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"] + ), ] ) diff --git a/Conjuga/SharedModels/Sources/SharedModels/SentenceQuizEngine.swift b/Conjuga/SharedModels/Sources/SharedModels/SentenceQuizEngine.swift new file mode 100644 index 0000000..8f185a7 --- /dev/null +++ b/Conjuga/SharedModels/Sources/SharedModels/SentenceQuizEngine.swift @@ -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, 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 + ) + } +} diff --git a/Conjuga/SharedModels/Sources/SharedModels/VocabCard.swift b/Conjuga/SharedModels/Sources/SharedModels/VocabCard.swift index 388ace8..ddaf65b 100644 --- a/Conjuga/SharedModels/Sources/SharedModels/VocabCard.swift +++ b/Conjuga/SharedModels/Sources/SharedModels/VocabCard.swift @@ -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 } } diff --git a/Conjuga/SharedModels/Tests/SharedModelsTests/ContentCoverageTests.swift b/Conjuga/SharedModels/Tests/SharedModelsTests/ContentCoverageTests.swift new file mode 100644 index 0000000..fbdb02f --- /dev/null +++ b/Conjuga/SharedModels/Tests/SharedModelsTests/ContentCoverageTests.swift @@ -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 ?? "" + 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 ?? "" + 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) + } +} diff --git a/Conjuga/SharedModels/Tests/SharedModelsTests/SentenceQuizEngineTests.swift b/Conjuga/SharedModels/Tests/SharedModelsTests/SentenceQuizEngineTests.swift new file mode 100644 index 0000000..8421eab --- /dev/null +++ b/Conjuga/SharedModels/Tests/SharedModelsTests/SentenceQuizEngineTests.swift @@ -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) + } +}