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>
167 lines
6.9 KiB
Swift
167 lines
6.9 KiB
Swift
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)
|
|
}
|
|
}
|