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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user