Files
Spanish/Conjuga/SharedModels/Sources/SharedModels/SentenceQuizEngine.swift
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

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