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