Compare commits

...

13 Commits

Author SHA1 Message Date
Trey t
a51d2abd47 Defer AVSpeechSynthesisVoice init to first speak() call
AVSpeechSynthesisVoice(language:) triggers a malloc double-free on
iOS 26 simulators when deserializing voice metadata during app launch.
Move voice resolution from init() to first speak() so the framework
call happens after the app is fully initialized.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:52:56 -05:00
Trey t
2a062cf484 Bump courseDataVersion to 4 for sentence gap-fill re-seed
Existing installs will delete and re-seed all VocabCard/CourseDeck data
on next launch, picking up the ~6,300 new example sentences and blank
fields added for the Complete the Sentence quiz type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:47:31 -05:00
Trey t
02e8d5141a Complete the Sentence: fill sentences for final batches 26-29
290 cards: Intermediate III tail (48), Advanced I (95), Advanced II
(147). All 8 courses now have complete sentence coverage for the
Complete the Sentence quiz type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:43:49 -05:00
Trey t
cd67f32302 Complete the Sentence: fill sentences for Intermediate III batches 23-25
300 cards across Intermediate III Spanish Through Stories weeks 1-6.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:34:19 -05:00
Trey t
79d9b7cb1d Complete the Sentence: fill sentences for Intermediate II batches 20-22
219 cards: Intermediate II batches 20, 21, 22 — clothing, business,
beliefs, citizenship, addictions, authority, rest. Intermediate II
now 100% complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:27:51 -05:00
Trey t
d666d0991a Complete the Sentence: fill sentences for batches 17-19
248 cards: Intermediate I finishing (148 cards across batches 17-18)
and Intermediate II batch 19 (100 cards).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:22:08 -05:00
Trey t
4e575a22c8 Complete the Sentence: fill sentences for batches 14-16
294 cards: Beginner III Conversation finishing (194 cards) and
Intermediate I batch 16 (100 cards).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:15:01 -05:00
Trey t
d538123251 Complete the Sentence: fill sentences for batches 11-13
289 cards: Beginner II batches 11-12 finishing (200 cards) and Beginner
III Conversation batch 13 (100 cards).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:09:23 -05:00
Trey t
54c1b05411 Complete the Sentence: fill sentences for Beginner II batches 8-10
300 cards across Beginner II weeks 1-5: body parts, animals, verbos
como gustar, food, clothing, city expansions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:04:07 -05:00
Trey t
99fc3c91f5 Complete the Sentence: fill sentences for Beginner I batches 5-7/7
233 cards across Beginner I weeks 4-8: Adjectives, Family reversed,
stem-changing verbs, reflexives, Daily Routine, City, Time/Seasons,
tener idioms, Hobbies. Finishes Beginner I content (508 cards total).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:58:45 -05:00
Trey t
ca7640b100 Complete the Sentence: fill sentences for Beginner I batches 2-4/7
300 cards across Beginner I weeks 2-4: Adjectives, Numbers, Professions,
House, -AR/-ER/-IR verbs, Family. Each card now has at least 3 example
sentences with stored blank fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:53:37 -05:00
Trey t
719134c6c7 Complete the Sentence: fill sentences for Beginner I batch 1/7
100 cards from Beginner I — Greetings / Basic Verbs / early decks.
Each card now has at least 3 example sentences with a stored `blank`
field identifying the exact substring to hide in the quiz.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:47:48 -05:00
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
10 changed files with 661 additions and 36 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

@@ -123,7 +123,7 @@ actor DataLoader {
/// Re-seed course data if the version has changed (e.g. examples were added).
/// Call this on every launch it checks a version key and only re-seeds when needed.
static func refreshCourseDataIfNeeded(container: ModelContainer) async {
let currentVersion = 3 // Bump this whenever course_data.json changes
let currentVersion = 4 // Bump this whenever course_data.json changes
let key = "courseDataVersion"
let shared = UserDefaults.standard
@@ -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

@@ -4,14 +4,20 @@ import AVFoundation
@MainActor
final class SpeechService {
private let synthesizer = AVSpeechSynthesizer()
private let spanishVoice: AVSpeechSynthesisVoice?
private var spanishVoice: AVSpeechSynthesisVoice?
private var voiceResolved = false
private var audioSessionConfigured = false
init() {
spanishVoice = AVSpeechSynthesisVoice(language: "es-ES")
// AVSpeechSynthesisVoice can trigger a malloc double-free on
// iOS 26 simulators when deserializing voice metadata. Defer
// voice resolution to first use so the crash doesn't happen
// during app launch.
spanishVoice = nil
}
func speak(_ text: String) {
resolveVoiceIfNeeded()
configureAudioSession()
synthesizer.stopSpeaking(at: .immediate)
let utterance = AVSpeechUtterance(string: text)
@@ -27,6 +33,12 @@ final class SpeechService {
synthesizer.stopSpeaking(at: .immediate)
}
private func resolveVoiceIfNeeded() {
guard !voiceResolved else { return }
voiceResolved = true
spanishVoice = AVSpeechSynthesisVoice(language: "es-ES")
}
private func configureAudioSession() {
guard !audioSessionConfigured else { return }
do {

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)
}

File diff suppressed because one or more lines are too long

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)
}
}