diff --git a/AgentGuidelines/.agents/skills/agent-guidelines-audit/SKILL.md b/AgentGuidelines/.agents/skills/agent-guidelines-audit/SKILL.md index 75510ae..ead9b72 100644 --- a/AgentGuidelines/.agents/skills/agent-guidelines-audit/SKILL.md +++ b/AgentGuidelines/.agents/skills/agent-guidelines-audit/SKILL.md @@ -31,6 +31,7 @@ Review the actual change rather than only checking whether files exist: - Check tests for the required framework, mirrored paths, shared tags, Given/When/Then structure, deterministic seams, and coverage of changed behavior and failure paths. - Trace every new or changed stateful, asynchronous, fallible, or lifecycle-oriented behavior and verify that its owning artifact emits privacy-safe AppLogger events for the meaningful success, failure, cancellation, recovery, and state-transition outcomes needed to diagnose it. Dependency declaration and target linkage alone do not establish logging coverage. Accept silence for pure values or utilities only when there is no meaningful event boundary and the implementation handoff records that deliberate decision. - Check logging ownership, subsystem, categories, emoji, privacy, severity, metadata stability, noise controls, and focused formatter or sink tests when logging changed. +- For every in-scope package that emits logs, resolve its canonical emoji from local instructions or documentation and the package list on the current `main` branch of the [ThatFactory Swift Package Collection](https://github.com/thatfactory/swift-package-collection). Enumerate the registered package entries and require each canonical emoji to identify exactly one package, treating visually identical presentation-selector variants as a collision. Confirm the package documentation, package-local logging gateway, and registry entry use the same emoji. When creating a package or assigning or changing its emoji, verify the intended emoji is unused before accepting it and require the registry update in the publication work. A collision, a missing registry entry for a published package, an unreachable current registry, or disagreement among these sources blocks audit completion and release readiness. - For every Apple-platform application or Swift package in scope, except the AppLogger provider repository itself, verify integration with the shared [Logging guide](../../../Guidelines/Logging.md): confirm the AppLogger dependency is declared, the `AppLogger` library product is linked to every target that emits diagnostics, and any new project has it available in its primary runtime target before its first log call. Search the actual package or Xcode dependency graph rather than relying on an `import` alone, and treat `print`, direct `Logger` instances, or duplicate logging backends as incomplete integration when they emit project diagnostics. When implementation is authorized, add or repair the dependency and target linkage and migrate affected calls while preserving the guide's ownership, subsystem, category, emoji, privacy, severity, and noise rules; report an exact blocker when target or platform constraints make safe integration ambiguous. - Inspect dependency manifests, resolver or lock files, Xcode package references, vendored source or binary frameworks, and equivalent dependency declarations. Compare the change with the baseline and identify every new third-party dependency or expansion of an existing third-party dependency into a new target or runtime role. Apply the shared [external dependency policy](../../../Guidelines/Development.md#external-dependencies): require explicit repository-owner approval before the dependency is introduced and require the durable exception record in repository documentation. Do not infer approval merely from an execution plan, pull-request description, implementation convenience, package popularity, or the dependency already appearing in the diff. Treat an unapproved or undocumented third-party dependency as a blocker to completion. Do not flag Apple system frameworks, the Swift standard library, ThatFactory-owned packages, or guideline-mandated tooling used only for its documented tooling role. If a newly resolved transitive third-party package will be linked into or shipped with the product, verify that its owning direct dependency is covered by an approved exception rather than dismissing it solely because it is transitive. - Search dependency manifests, generated directories, project files, and documentation for CocoaPods or Carthage adoption. The shared [external dependency policy](../../../Guidelines/Development.md#external-dependencies) forbids both without an exception path and requires Swift Package Manager for package dependencies. When implementation is authorized, remove newly introduced adoption and its generated or configuration files; report pre-existing adoption as a completion blocker when safe migration is outside the task scope. diff --git a/AgentGuidelines/CHANGELOG.md b/AgentGuidelines/CHANGELOG.md index dcb012e..d82dd17 100644 --- a/AgentGuidelines/CHANGELOG.md +++ b/AgentGuidelines/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project are documented in this file. +## [0.0.34] - 2026-09-20 + +### Changed + +- Required every ThatFactory package emoji to be unique in the Swift Package Collection and synchronized across package documentation, the logging gateway, and the collection registry. +- Extended the completion audit to block package publication when emoji uniqueness cannot be verified or the registered and emitted identities disagree. + ## [0.0.33] - 2026-09-19 ### Added diff --git a/AgentGuidelines/Guidelines/Packages.md b/AgentGuidelines/Guidelines/Packages.md index c33cfbb..2de2bec 100644 --- a/AgentGuidelines/Guidelines/Packages.md +++ b/AgentGuidelines/Guidelines/Packages.md @@ -114,6 +114,10 @@ Accept a deviation only when the nearest applicable `AGENTS.md`, or durable docu Packages own any diagnostics emitted by their implementation. Follow the shared [logging guide](Logging.md) for AppLogger usage, subsystem identity, package emoji prefixes, domain-owned categories, concise messages, privacy, and test coverage. A consuming application must not reproduce package-internal logs. +Before assigning or changing a package's canonical emoji, inspect the package list on the current `main` branch of the [ThatFactory Swift Package Collection](https://github.com/thatfactory/swift-package-collection) and confirm that no other package uses it. Each package emoji must be unique across ThatFactory so a log prefix identifies one package unambiguously. Treat visually identical emoji spellings that differ only by presentation selectors as the same emoji; do not use an encoding variation to create an apparent distinction. + +Declare the selected emoji in the package's local instructions or documentation, use that exact emoji in its package-local logging gateway, and add or update the matching Swift Package Collection entry as part of the package's publication work. An existing collision, a missing registry entry for a published package, or disagreement among the registry, documentation, and emitted prefix blocks release readiness until reconciled. + ## Development workflow 1. Read the package's local `AGENTS.md`, README, DocC, and public API before changing behavior. diff --git a/AgentGuidelines/README.md b/AgentGuidelines/README.md index cec2d20..caab867 100644 --- a/AgentGuidelines/README.md +++ b/AgentGuidelines/README.md @@ -91,7 +91,7 @@ From the consumer repository root, install a tagged release: git subtree add \ --prefix=AgentGuidelines \ https://github.com/thatfactory/agent-guidelines.git \ - 0.0.33 \ + 0.0.34 \ --squash ``` @@ -147,7 +147,7 @@ Review the target release's changelog, then pull it deliberately: git subtree pull \ --prefix=AgentGuidelines \ https://github.com/thatfactory/agent-guidelines.git \ - 0.0.33 \ + 0.0.34 \ --squash ``` diff --git a/AgentGuidelines/Scripts/validate_guidelines.swift b/AgentGuidelines/Scripts/validate_guidelines.swift index 39b58d6..7c2dddf 100755 --- a/AgentGuidelines/Scripts/validate_guidelines.swift +++ b/AgentGuidelines/Scripts/validate_guidelines.swift @@ -576,6 +576,14 @@ func validatePackageCompilerSettingsGuideline(_ errors: inout [String]) { for feature in packageUpcomingFeatures where !contents.contains(".enableUpcomingFeature(\"\(feature)\")") { errors.append("Guidelines/Packages.md: missing required SwiftPM upcoming feature '\(feature)'") } + let emojiRequired = [ + "Each package emoji must be unique across ThatFactory": "uniqueness policy", + "visually identical emoji spellings": "presentation-selector collision policy", + "matching Swift Package Collection entry": "registry synchronization", + ] + for (value, description) in emojiRequired where !contents.contains(value) { + errors.append("Guidelines/Packages.md: missing package emoji \(description): '\(value)'") + } } /// Validates the shared App Store metadata workflow. @@ -679,6 +687,10 @@ func validateAuditSkill(_ errors: inout [String]) { "AppLogger": "AppLogger integration audit", "Logging.md": "shared Logging guide reference", "Dependency declaration and target linkage alone": "lifecycle observability coverage audit", + "Enumerate the registered package entries": "package emoji registry enumeration", + "visually identical presentation-selector variants as a collision": + "package emoji presentation-selector collision audit", + "blocks audit completion and release readiness": "package emoji audit stopping rule", "## Audit documentation consistency": "documentation drift audit", "Known stale documentation blocks completion": "stale documentation stopping rule", "## Audit documentation formatting": "documentation formatting audit", diff --git a/AgentGuidelines/Tests/run_tests.swift b/AgentGuidelines/Tests/run_tests.swift index 847eeab..57fb159 100755 --- a/AgentGuidelines/Tests/run_tests.swift +++ b/AgentGuidelines/Tests/run_tests.swift @@ -290,6 +290,25 @@ let tests: [(String, () throws -> Void)] = [ } } ), + ( + "repository validator rejects missing package emoji uniqueness policy", + { + try withTemporaryDirectory { temporary in + let fixture = temporary.appendingPathComponent("repository") + try copyRepositoryFixture(to: fixture) + let guideline = fixture.appendingPathComponent("Guidelines/Packages.md") + var contents = try String(contentsOf: guideline, encoding: .utf8) + contents = contents.replacingOccurrences( + of: "Each package emoji must be unique across ThatFactory", + with: "Package emojis should be recognizable" + ) + try write(contents, to: guideline) + let result = try run([fixture.appendingPathComponent("Scripts/validate_guidelines.swift").path]) + try require(!result.succeeded, "missing package emoji uniqueness policy unexpectedly passed") + try require(result.output.contains("missing package emoji uniqueness policy"), result.output) + } + } + ), ( "repository validator rejects missing package audit section", { @@ -326,6 +345,25 @@ let tests: [(String, () throws -> Void)] = [ } } ), + ( + "repository validator rejects package emoji audit drift", + { + try withTemporaryDirectory { temporary in + let fixture = temporary.appendingPathComponent("repository") + try copyRepositoryFixture(to: fixture) + let skill = fixture.appendingPathComponent(".agents/skills/agent-guidelines-audit/SKILL.md") + var contents = try String(contentsOf: skill, encoding: .utf8) + contents = contents.replacingOccurrences( + of: "Enumerate the registered package entries", + with: "Inspect the package registry" + ) + try write(contents, to: skill) + let result = try run([fixture.appendingPathComponent("Scripts/validate_guidelines.swift").path]) + try require(!result.succeeded, "package emoji audit drift unexpectedly passed") + try require(result.output.contains("missing package emoji registry enumeration"), result.output) + } + } + ), ( "repository validator rejects missing observability adoption guidance", { diff --git a/AgentGuidelines/VERSION b/AgentGuidelines/VERSION index cd9d21e..bb951c8 100644 --- a/AgentGuidelines/VERSION +++ b/AgentGuidelines/VERSION @@ -1 +1 @@ -0.0.33 +0.0.34 diff --git a/README.md b/README.md index 1d7a5d2..eb88b29 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ # FlashcardKit -FlashcardKit is a reusable, UI-agnostic domain package for prompt-and-answer recall activities. Its persistence-friendly card values support text and opaque host-owned asset references without taking ownership of presentation or media resolution. +FlashcardKit is a reusable, UI-agnostic domain package for prompt-and-answer recall activities. Its persistence-friendly card values support one or more ordered recall stages containing text and opaque host-owned asset references without taking ownership of presentation or media resolution. ```swift let card = Flashcard( @@ -20,6 +20,22 @@ let card = Flashcard( answer: try FlashcardContent(text: "dog") ) +let stagedCard = try Flashcard( + id: UUID(), + stages: [ + FlashcardStage( + id: FlashcardStageID(rawValue: "definition"), + prompt: try FlashcardContent(text: "das Haus"), + answer: try FlashcardContent(text: "house") + ), + FlashcardStage( + id: FlashcardStageID(rawValue: "article"), + prompt: try FlashcardContent(text: "Haus"), + answer: try FlashcardContent(text: "das") + ), + ] +) + var session = try ThreeChoiceSession( cards: cards, configuration: ThreeChoiceSessionConfiguration(seed: 42, roundCount: 5) @@ -33,6 +49,8 @@ if let round = session.currentRound { } ``` +The simple initializer remains the shortest path for one-stage cards. Stage-aware hosts can provide a nonempty ordered sequence with stable, unique stage identifiers. The current `ThreeChoiceSession` continues to use each card's first stage; progressive stage scheduling is a separate API step. + The session plan is reproducible for identical cards, configuration, and seed. Each round exposes one correct answer and two distinct distractors; a host can submit a selected choice or explicit expiry. Presentation, timers, persistence frameworks, image resolution, vocabulary acquisition, and spaced repetition remain outside the package boundary. ## Installation diff --git a/Sources/FlashcardKit/Flashcard.swift b/Sources/FlashcardKit/Flashcard.swift index eb6f9c0..931dfe5 100644 --- a/Sources/FlashcardKit/Flashcard.swift +++ b/Sources/FlashcardKit/Flashcard.swift @@ -5,11 +5,22 @@ public struct Flashcard: Identifiable, Codable, Sendable, Hashable { /// The stable identity supplied by the host. public let id: UUID - /// Content presented for recall. - public let prompt: FlashcardContent + /// Ordered recall stages authored by the host. + public let stages: [FlashcardStage] - /// Content revealed or evaluated as the answer. - public let answer: FlashcardContent + /// Content presented by the first stage. + /// + /// This compatibility property preserves the simple-card API. Stage-aware consumers should use ``stages``. + public var prompt: FlashcardContent { + stages[0].prompt + } + + /// Content evaluated as the first stage's answer. + /// + /// This compatibility property preserves the simple-card API. Stage-aware consumers should use ``stages``. + public var answer: FlashcardContent { + stages[0].answer + } /// Creates a flashcard from validated prompt and answer content. /// @@ -19,7 +30,61 @@ public struct Flashcard: Identifiable, Codable, Sendable, Hashable { /// - answer: Content revealed or evaluated as the answer. public init(id: UUID, prompt: FlashcardContent, answer: FlashcardContent) { self.id = id - self.prompt = prompt - self.answer = answer + stages = [FlashcardStage(id: .primary, prompt: prompt, answer: answer)] + } + + /// Creates a flashcard from one or more ordered recall stages. + /// + /// - Parameters: + /// - id: Stable host-owned identity. + /// - stages: Nonempty ordered stages with unique host-authored identities. + /// - Throws: ``FlashcardError`` when no stages are supplied or stage identities are duplicated. + public init(id: UUID, stages: [FlashcardStage]) throws { + guard !stages.isEmpty else { + throw FlashcardError.missingStages + } + + var stageIDs = Set() + for stage in stages where !stageIDs.insert(stage.id).inserted { + throw FlashcardError.duplicateStageID(stage.id) + } + + self.id = id + self.stages = stages + } + + /// Decodes current staged cards and cards encoded by the original simple-card model. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let id = try container.decode(UUID.self, forKey: .id) + if let stages = try container.decodeIfPresent([FlashcardStage].self, forKey: .stages) { + try self.init(id: id, stages: stages) + } else { + self.init( + id: id, + prompt: try container.decode(FlashcardContent.self, forKey: .prompt), + answer: try container.decode(FlashcardContent.self, forKey: .answer) + ) + } + } + + /// Encodes staged cards while retaining the original representation for simple-card readers. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(stages, forKey: .stages) + if stages.count == 1 { + try container.encode(prompt, forKey: .prompt) + try container.encode(answer, forKey: .answer) + } + } + + // MARK: - Private + + private enum CodingKeys: String, CodingKey { + case answer + case id + case prompt + case stages } } diff --git a/Sources/FlashcardKit/FlashcardError.swift b/Sources/FlashcardKit/FlashcardError.swift new file mode 100644 index 0000000..759e09e --- /dev/null +++ b/Sources/FlashcardKit/FlashcardError.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Validation failures produced while creating a flashcard. +public enum FlashcardError: Error, Sendable, Equatable { + /// More than one stage used the same host-authored identity. + case duplicateStageID(FlashcardStageID) + + /// No recall stage was supplied. + case missingStages +} diff --git a/Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md b/Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md index 2bc89ae..f8c51ad 100644 --- a/Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md +++ b/Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md @@ -4,7 +4,7 @@ Build deterministic, UI-agnostic recall activities from host-owned prompt-and-an ## Overview -Use ``Flashcard`` to give recall activities stable host-owned identity, prompt content, and answer content. ``FlashcardContent`` can carry text, an opaque host-owned asset reference, or both, while preserving a nonempty representation invariant across creation and decoding. +Use ``Flashcard`` to give recall activities stable host-owned identity and one or more ordered ``FlashcardStage`` values. Each stage has a host-authored ``FlashcardStageID``, prompt content, and answer content. ``FlashcardContent`` can carry text, an opaque host-owned asset reference, or both, while preserving a nonempty representation invariant across creation and decoding. ```swift let card = Flashcard( @@ -14,6 +14,28 @@ let card = Flashcard( ) ``` +The simple initializer constructs one stage with ``FlashcardStageID/primary``. Stage-aware hosts can create a card from a nonempty sequence whose identifiers are unique within that card: + +```swift +let stagedCard = try Flashcard( + id: UUID(), + stages: [ + FlashcardStage( + id: FlashcardStageID(rawValue: "definition"), + prompt: try FlashcardContent(text: "das Haus"), + answer: try FlashcardContent(text: "house") + ), + FlashcardStage( + id: FlashcardStageID(rawValue: "article"), + prompt: try FlashcardContent(text: "Haus"), + answer: try FlashcardContent(text: "das") + ), + ] +) +``` + +Stage order is significant and preserved by `Codable`. ``FlashcardError`` reports missing stages and duplicate stage identifiers. Current ``ThreeChoiceSession`` behavior remains one-stage-compatible by reading each card's first stage; stage progression is intentionally separate from the card value model. + The package does not resolve asset references or own presentation, persistence frameworks, vocabulary acquisition, or scheduling policy. Use ``ThreeChoiceSession`` to create a finite seeded plan with one correct answer and two distinct distractors per round. The host owns any timer and submits either ``ThreeChoiceResponse/selection(choiceID:)`` or ``ThreeChoiceResponse/expired`` against the exact visible round identity. diff --git a/Sources/FlashcardKit/FlashcardStage.swift b/Sources/FlashcardKit/FlashcardStage.swift new file mode 100644 index 0000000..12579f6 --- /dev/null +++ b/Sources/FlashcardKit/FlashcardStage.swift @@ -0,0 +1,29 @@ +import Foundation + +/// One host-authored step in a flashcard's ordered recall sequence. +public struct FlashcardStage: Identifiable, Codable, Sendable, Hashable { + /// Stable stage identity supplied by the host. + public let id: FlashcardStageID + + /// Content presented for this recall stage. + public let prompt: FlashcardContent + + /// Content revealed or evaluated as this stage's answer. + public let answer: FlashcardContent + + /// Creates an ordered recall stage from validated prompt and answer content. + /// + /// - Parameters: + /// - id: Stable host-authored identity unique within its card. + /// - prompt: Content presented for recall. + /// - answer: Content revealed or evaluated as the answer. + public init( + id: FlashcardStageID, + prompt: FlashcardContent, + answer: FlashcardContent + ) { + self.id = id + self.prompt = prompt + self.answer = answer + } +} diff --git a/Sources/FlashcardKit/FlashcardStageID.swift b/Sources/FlashcardKit/FlashcardStageID.swift new file mode 100644 index 0000000..2637cf2 --- /dev/null +++ b/Sources/FlashcardKit/FlashcardStageID.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Stable host-authored identity for one recall stage. +public struct FlashcardStageID: RawRepresentable, Codable, Sendable, Hashable { + /// Conventional identity used by the simple-card compatibility initializer. + public static let primary = Self(rawValue: "primary") + + /// Host-authored stable value. + public let rawValue: String + + /// Creates a stage identity from a host-owned stable value. + /// + /// - Parameter rawValue: Stable value unique within its card. + public init(rawValue: String) { + self.rawValue = rawValue + } +} diff --git a/Tests/FlashcardKitTests/FlashcardModelTests.swift b/Tests/FlashcardKitTests/FlashcardModelTests.swift index 6397844..1e0e094 100644 --- a/Tests/FlashcardKitTests/FlashcardModelTests.swift +++ b/Tests/FlashcardKitTests/FlashcardModelTests.swift @@ -5,6 +5,59 @@ import Testing @Suite("Flashcard models") struct FlashcardModelTests { + @Test("Simple-card initializer creates one primary stage") + func simpleCardCompatibility() throws { + let prompt = try FlashcardContent(text: "Haus") + let answer = try FlashcardContent(text: "house") + + let card = Flashcard(id: UUID(), prompt: prompt, answer: answer) + + #expect(card.stages == [FlashcardStage(id: .primary, prompt: prompt, answer: answer)]) + #expect(card.prompt == prompt) + #expect(card.answer == answer) + } + + @Test("Multi-stage cards preserve significant stage order through Codable") + func orderedStagesRoundTrip() throws { + let definition = FlashcardStage( + id: FlashcardStageID(rawValue: "definition"), + prompt: try FlashcardContent(text: "Haus"), + answer: try FlashcardContent(text: "house") + ) + let article = FlashcardStage( + id: FlashcardStageID(rawValue: "article"), + prompt: try FlashcardContent(text: "Haus"), + answer: try FlashcardContent(text: "das") + ) + let card = try Flashcard(id: UUID(), stages: [definition, article]) + + let encoded = try JSONEncoder().encode(card) + let decoded = try JSONDecoder().decode(Flashcard.self, from: encoded) + + #expect(decoded == card) + #expect(decoded.stages.map(\.id.rawValue) == ["definition", "article"]) + #expect(decoded.prompt == definition.prompt) + #expect(decoded.answer == definition.answer) + } + + @Test("Cards reject an empty stage sequence") + func emptyStages() { + #expect(throws: FlashcardError.missingStages) { + try Flashcard(id: UUID(), stages: []) + } + } + + @Test("Cards reject duplicate stage identities") + func duplicateStageIDs() throws { + let identity = FlashcardStageID(rawValue: "definition") + let content = try FlashcardContent(text: "Haus") + let stage = FlashcardStage(id: identity, prompt: content, answer: content) + + #expect(throws: FlashcardError.duplicateStageID(identity)) { + try Flashcard(id: UUID(), stages: [stage, stage]) + } + } + @Test("Content supports text, assets, and their combination") func representations() throws { #expect(try FlashcardContent(text: "Haus").text == "Haus") @@ -40,9 +93,13 @@ struct FlashcardModelTests { let encoded = try JSONEncoder().encode(card) let decoded = try JSONDecoder().decode(Flashcard.self, from: encoded) + let legacyDecoded = try JSONDecoder().decode(LegacyFlashcard.self, from: encoded) #expect(decoded == card) #expect(Set([card, decoded]).count == 1) + #expect(legacyDecoded.id == card.id) + #expect(legacyDecoded.prompt == card.prompt) + #expect(legacyDecoded.answer == card.answer) } @Test("Decoding enforces content invariants") @@ -52,13 +109,64 @@ struct FlashcardModelTests { } } + @Test("Decoding accepts the original simple-card representation") + func legacyCardDecoding() throws { + let legacy = LegacyFlashcard( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + prompt: try FlashcardContent(text: "Haus"), + answer: try FlashcardContent(text: "house") + ) + + let decoded = try JSONDecoder().decode( + Flashcard.self, + from: JSONEncoder().encode(legacy) + ) + + #expect(decoded.id == legacy.id) + #expect(decoded.stages.count == 1) + #expect(decoded.stages[0].id == .primary) + #expect(decoded.prompt == legacy.prompt) + #expect(decoded.answer == legacy.answer) + } + + @Test("Decoding rejects an empty stage sequence") + func emptyStagesDecoding() throws { + let payload = StagedFlashcardPayload(id: UUID(), stages: []) + + #expect(throws: FlashcardError.missingStages) { + try JSONDecoder().decode( + Flashcard.self, + from: JSONEncoder().encode(payload) + ) + } + } + + @Test("Decoding rejects duplicate stage identities") + func duplicateStageIDsDecoding() throws { + let identity = FlashcardStageID(rawValue: "definition") + let content = try FlashcardContent(text: "Haus") + let stage = FlashcardStage(id: identity, prompt: content, answer: content) + let payload = StagedFlashcardPayload(id: UUID(), stages: [stage, stage]) + + #expect(throws: FlashcardError.duplicateStageID(identity)) { + try JSONDecoder().decode( + Flashcard.self, + from: JSONEncoder().encode(payload) + ) + } + } + @Test("Public models satisfy persistence and concurrency contracts") func protocolConformance() throws { let content = try FlashcardContent(text: "Haus") let card = Flashcard(id: UUID(), prompt: content, answer: content) + let stage = FlashcardStage(id: .primary, prompt: content, answer: content) + let stageID = FlashcardStageID(rawValue: "definition") requireValueContract(content) requireValueContract(card) + requireValueContract(stage) + requireValueContract(stageID) } private func requireValueContract(_ value: Value) { @@ -67,6 +175,17 @@ struct FlashcardModelTests { } extension FlashcardModelTests { + struct LegacyFlashcard: Codable { + let id: UUID + let prompt: FlashcardContent + let answer: FlashcardContent + } + + struct StagedFlashcardPayload: Codable { + let id: UUID + let stages: [FlashcardStage] + } + struct InvalidContent: Sendable, CustomTestStringConvertible { let text: String? let assetReference: String?