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