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