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>
107 lines
4.7 KiB
Swift
107 lines
4.7 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|