Complete the Sentence quiz type: engine, UI, tests

Add a new Complete the Sentence quiz type that renders a Spanish example
sentence from the card with the target word blanked out and asks the
student to pick the missing word from 4 choices (other cards' fronts
from the same week's pool).

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-11 19:33:50 -05:00
parent 3b8a8a7f1a
commit 143e356b75
8 changed files with 645 additions and 32 deletions

View File

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