diff --git a/AGENTS.md b/AGENTS.md index ea48326..321a156 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,5 +117,6 @@ Replace these examples with exact repository paths: ## Local specialization +- FlashcardKit's canonical logging emoji is ๐Ÿƒ. Package-owned session logs use subsystem `com.thatfactory.flashcardkit` and never include prompts, answers, asset references, or card identifiers. - Do not depend on LexiconKit; consumers map between the two domains. - Keep session mechanics deterministic for identical cards, configuration, and seed. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d578c2..1451d2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,5 +6,6 @@ All notable changes to FlashcardKit are documented here. ### Added +- Added deterministic finite three-choice sessions with seeded ordering, distinct distractors, explicit expiry, answer evaluation, progress, typed validation, and stale-round protection. - Added validated, persistence-friendly `Flashcard` and `FlashcardContent` public models for text and opaque host-owned asset references. - Bootstrapped the Swift package, DocC catalog, CI/CD workflows, shared AgentGuidelines integration, and repository policy. diff --git a/README.md b/README.md index 75e5757..1d63c76 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,21 @@ let card = Flashcard( prompt: try FlashcardContent(text: "der Hund"), answer: try FlashcardContent(text: "dog") ) + +var session = try ThreeChoiceSession( + cards: cards, + configuration: ThreeChoiceSessionConfiguration(seed: 42, roundCount: 5) +) + +if let round = session.currentRound { + let evaluation = try session.submit( + .selection(choiceID: round.choices[0].id), + forRoundID: round.id + ) +} ``` -Presentation, timers, persistence frameworks, image resolution, vocabulary acquisition, and spaced repetition remain outside the package boundary. +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. ## Documentation diff --git a/Sources/FlashcardKit/DeterministicRandom.swift b/Sources/FlashcardKit/DeterministicRandom.swift new file mode 100644 index 0000000..5fc9a01 --- /dev/null +++ b/Sources/FlashcardKit/DeterministicRandom.swift @@ -0,0 +1,26 @@ +struct DeterministicRandom: Sendable { + private var state: UInt64 + + init(seed: UInt64) { + state = seed + } + + mutating func shuffle(_ values: inout [Element]) { + guard values.count > 1 else { return } + for index in stride(from: values.count - 1, through: 1, by: -1) { + values.swapAt(index, randomIndex(upperBound: index + 1)) + } + } + + private mutating func randomIndex(upperBound: Int) -> Int { + Int(next() % UInt64(upperBound)) + } + + private mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var value = state + value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9 + value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB + return value ^ (value >> 31) + } +} diff --git a/Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md b/Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md index 528206c..2bc89ae 100644 --- a/Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md +++ b/Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md @@ -15,3 +15,18 @@ let card = Flashcard( ``` 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. + +```swift +var session = try ThreeChoiceSession( + cards: cards, + configuration: ThreeChoiceSessionConfiguration(seed: 42) +) + +if let round = session.currentRound { + let evaluation = try session.submit(.expired, forRoundID: round.id) +} +``` + +FlashcardKit uses a stable internal random algorithm. Identical card sets, configuration, and seed reproduce shuffled plans independently of the host collection's input order; source ordering intentionally preserves input order. diff --git a/Sources/FlashcardKit/FlashcardLogging.swift b/Sources/FlashcardKit/FlashcardLogging.swift new file mode 100644 index 0000000..342faf4 --- /dev/null +++ b/Sources/FlashcardKit/FlashcardLogging.swift @@ -0,0 +1,40 @@ +import AppLogger + +enum FlashcardLogging { + static let emoji = "๐Ÿƒ" + static let subsystem = "com.thatfactory.flashcardkit" + + static func sessionCreated(rounds: Int) { + log("session created | rounds=\(rounds)") + } + + static func sessionRejected(reason: String) { + log(level: .error, "session rejected | reason=\(reason)") + } + + static func responseAccepted(outcome: ThreeChoiceEvaluation.Outcome, completed: Int, total: Int) { + log("response accepted | outcome=\(outcome.token), completed=\(completed), total=\(total)") + } + + static func responseRejected(reason: String) { + log(level: .error, "response rejected | reason=\(reason)") + } + + static func formatted(_ message: String) -> String { + "\(emoji) \(message)" + } + + private static func log(level: AppLogLevel = .debug, _ message: String) { + AppLogger(subsystem: subsystem, category: "session").log(level: level, formatted(message)) + } +} + +extension ThreeChoiceEvaluation.Outcome { + var token: String { + switch self { + case .correct: "correct" + case .incorrect: "incorrect" + case .expired: "expired" + } + } +} diff --git a/Sources/FlashcardKit/ThreeChoiceModels.swift b/Sources/FlashcardKit/ThreeChoiceModels.swift new file mode 100644 index 0000000..58177a1 --- /dev/null +++ b/Sources/FlashcardKit/ThreeChoiceModels.swift @@ -0,0 +1,134 @@ +import Foundation + +/// Controls how prompt cards are ordered when a session plan is created. +public enum ThreeChoiceRoundOrdering: Codable, Sendable, Hashable { + /// Preserve the order supplied by the host. + case source + + /// Canonicalize cards by identity, then shuffle them using the configured seed. + case shuffled +} + +/// Configuration used to create a deterministic three-choice session. +public struct ThreeChoiceSessionConfiguration: Codable, Sendable, Hashable { + /// Seed used for prompt, distractor, and choice ordering. + public let seed: UInt64 + + /// Number of rounds to play, or `nil` to use every card once. + public let roundCount: Int? + + /// Prompt ordering policy. + public let ordering: ThreeChoiceRoundOrdering + + /// Creates a session configuration. + /// + /// - Parameters: + /// - seed: Seed used by FlashcardKit's stable deterministic random algorithm. + /// - roundCount: Positive round count, or `nil` to use every card once. + /// - ordering: Prompt ordering policy. + public init( + seed: UInt64, + roundCount: Int? = nil, + ordering: ThreeChoiceRoundOrdering = .shuffled + ) { + self.seed = seed + self.roundCount = roundCount + self.ordering = ordering + } +} + +/// One visible answer choice in a three-choice round. +public struct ThreeChoice: Identifiable, Codable, Sendable, Hashable { + /// Deterministic identifier scoped to the containing round. + public let id: Int + + /// Content presented as the possible answer. + public let content: FlashcardContent +} + +/// A presentation-ready prompt and its three possible answers. +public struct ThreeChoiceRound: Identifiable, Codable, Sendable, Hashable { + /// Deterministic zero-based position in the session. + public let id: Int + + /// Stable identity of the card being recalled. + public let cardID: UUID + + /// Content presented for recall. + public let prompt: FlashcardContent + + /// Three distinct possible answers in deterministic order. + public let choices: [ThreeChoice] +} + +/// A host-submitted response to the currently visible round. +public enum ThreeChoiceResponse: Sendable, Hashable { + /// The player selected a visible choice. + case selection(choiceID: Int) + + /// The host-owned answer window expired. + case expired +} + +/// The deterministic outcome of one accepted response. +public struct ThreeChoiceEvaluation: Codable, Sendable, Hashable { + /// Possible outcomes for an accepted response. + public enum Outcome: Codable, Sendable, Hashable { + /// The selected choice matched the answer. + case correct + + /// The selected choice did not match the answer. + case incorrect + + /// The host reported that its answer window expired. + case expired + } + + /// Identity of the evaluated round. + public let roundID: Int + + /// Result of the submitted response. + public let outcome: Outcome + + /// Selected choice identity, or `nil` for expiry. + public let selectedChoiceID: Int? + + /// Choice identity containing the correct answer. + public let correctChoiceID: Int +} + +/// Finite session progress after zero or more accepted responses. +public struct ThreeChoiceProgress: Codable, Sendable, Hashable { + /// Number of rounds already evaluated. + public let completedRounds: Int + + /// Total number of planned rounds. + public let totalRounds: Int +} + +/// Validation and submission failures from a three-choice session. +public enum ThreeChoiceSessionError: Error, Sendable, Equatable { + /// More than one card used the same stable identity. + case duplicateCardID(UUID) + + /// Fewer than three cards were supplied. + case insufficientCards(minimum: Int, actual: Int) + + /// The supplied cards contain fewer than three distinct visible answers. + case insufficientDistinctAnswers(minimum: Int, actual: Int) + + /// The requested round count was not positive. + case invalidRoundCount(Int) + + /// The requested round count exceeded the supplied card count. + case roundCountExceedsAvailableCards(requested: Int, available: Int) + + /// A response was submitted after the final round. + case sessionComplete + + /// A response targeted a round other than the current round. + case staleRound(expected: Int, received: Int) + + /// The selected choice does not belong to the current round. + case invalidChoice(Int) +} diff --git a/Sources/FlashcardKit/ThreeChoiceSession.swift b/Sources/FlashcardKit/ThreeChoiceSession.swift new file mode 100644 index 0000000..3318eb2 --- /dev/null +++ b/Sources/FlashcardKit/ThreeChoiceSession.swift @@ -0,0 +1,181 @@ +import Foundation + +/// A deterministic finite recall session with exactly three answers per round. +public struct ThreeChoiceSession: Sendable { + private struct PlannedRound: Sendable { + let correctChoiceID: Int + let round: ThreeChoiceRound + } + + private let plan: [PlannedRound] + private var currentIndex = 0 + + /// The round awaiting a response, or `nil` after completion. + public var currentRound: ThreeChoiceRound? { + guard plan.indices.contains(currentIndex) else { return nil } + return plan[currentIndex].round + } + + /// Current completion counts for the finite session. + public var progress: ThreeChoiceProgress { + ThreeChoiceProgress(completedRounds: currentIndex, totalRounds: plan.count) + } + + /// Whether every planned round has received an accepted response. + public var isComplete: Bool { + currentIndex == plan.count + } + + /// Creates a complete deterministic round plan from host-owned cards. + /// + /// - Parameters: + /// - cards: Cards eligible for the session. + /// - configuration: Seed, round count, and prompt ordering policy. + /// - Throws: ``ThreeChoiceSessionError`` when cards or configuration cannot produce every requested round. + public init(cards: [Flashcard], configuration: ThreeChoiceSessionConfiguration) throws { + guard cards.count >= 3 else { + FlashcardLogging.sessionRejected(reason: "insufficient-cards") + throw ThreeChoiceSessionError.insufficientCards(minimum: 3, actual: cards.count) + } + + var cardIDs = Set() + for card in cards where !cardIDs.insert(card.id).inserted { + FlashcardLogging.sessionRejected(reason: "duplicate-card-id") + throw ThreeChoiceSessionError.duplicateCardID(card.id) + } + + let distinctAnswerCount = Set(cards.map(\.answer)).count + guard distinctAnswerCount >= 3 else { + FlashcardLogging.sessionRejected(reason: "insufficient-distinct-answers") + throw ThreeChoiceSessionError.insufficientDistinctAnswers( + minimum: 3, + actual: distinctAnswerCount + ) + } + + let roundCount = try Self.roundCount(for: cards.count, requested: configuration.roundCount) + var random = DeterministicRandom(seed: configuration.seed) + let orderedCards = Self.orderedCards(cards, ordering: configuration.ordering, random: &random) + plan = Self.makePlan( + promptCards: Array(orderedCards.prefix(roundCount)), + allCards: orderedCards, + random: &random + ) + FlashcardLogging.sessionCreated(rounds: plan.count) + } + + /// Evaluates a response for the exact current round and advances after success. + /// + /// - Parameters: + /// - response: A selected choice or host-reported expiry. + /// - roundID: Identity of the round to which the response belongs. + /// - Returns: The accepted response outcome and answer key. + /// - Throws: ``ThreeChoiceSessionError`` when the session is complete, the round is stale, or a choice is unknown. Rejected responses do not advance progress. + public mutating func submit( + _ response: ThreeChoiceResponse, + forRoundID roundID: Int + ) throws -> ThreeChoiceEvaluation { + guard let plannedRound = plan[safe: currentIndex] else { + FlashcardLogging.responseRejected(reason: "session-complete") + throw ThreeChoiceSessionError.sessionComplete + } + guard plannedRound.round.id == roundID else { + FlashcardLogging.responseRejected(reason: "stale-round") + throw ThreeChoiceSessionError.staleRound( + expected: plannedRound.round.id, + received: roundID + ) + } + + let selectedChoiceID: Int? + let outcome: ThreeChoiceEvaluation.Outcome + switch response { + case .selection(let choiceID): + guard plannedRound.round.choices.contains(where: { $0.id == choiceID }) else { + FlashcardLogging.responseRejected(reason: "invalid-choice") + throw ThreeChoiceSessionError.invalidChoice(choiceID) + } + selectedChoiceID = choiceID + outcome = choiceID == plannedRound.correctChoiceID ? .correct : .incorrect + case .expired: + selectedChoiceID = nil + outcome = .expired + } + + currentIndex += 1 + let evaluation = ThreeChoiceEvaluation( + roundID: roundID, + outcome: outcome, + selectedChoiceID: selectedChoiceID, + correctChoiceID: plannedRound.correctChoiceID + ) + FlashcardLogging.responseAccepted( + outcome: outcome, + completed: currentIndex, + total: plan.count + ) + return evaluation + } + + private static func roundCount(for available: Int, requested: Int?) throws -> Int { + guard let requested else { return available } + guard requested > 0 else { + FlashcardLogging.sessionRejected(reason: "invalid-round-count") + throw ThreeChoiceSessionError.invalidRoundCount(requested) + } + guard requested <= available else { + FlashcardLogging.sessionRejected(reason: "round-count-exceeds-cards") + throw ThreeChoiceSessionError.roundCountExceedsAvailableCards( + requested: requested, + available: available + ) + } + return requested + } + + private static func orderedCards( + _ cards: [Flashcard], + ordering: ThreeChoiceRoundOrdering, + random: inout DeterministicRandom + ) -> [Flashcard] { + guard ordering == .shuffled else { return cards } + var ordered = cards.sorted { $0.id.uuidString < $1.id.uuidString } + random.shuffle(&ordered) + return ordered + } + + private static func makePlan( + promptCards: [Flashcard], + allCards: [Flashcard], + random: inout DeterministicRandom + ) -> [PlannedRound] { + promptCards.enumerated().map { roundID, card in + var seenAnswers = Set([card.answer]) + var distractors = allCards.compactMap { candidate -> FlashcardContent? in + guard seenAnswers.insert(candidate.answer).inserted else { return nil } + return candidate.answer + } + random.shuffle(&distractors) + + var answers = [card.answer] + distractors.prefix(2) + random.shuffle(&answers) + let correctChoiceID = answers.firstIndex(of: card.answer)! + let choices = answers.enumerated().map { ThreeChoice(id: $0, content: $1) } + return PlannedRound( + correctChoiceID: correctChoiceID, + round: ThreeChoiceRound( + id: roundID, + cardID: card.id, + prompt: card.prompt, + choices: choices + ) + ) + } + } +} + +extension Collection { + fileprivate subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Tests/FlashcardKitTests/FlashcardLoggingTests.swift b/Tests/FlashcardKitTests/FlashcardLoggingTests.swift new file mode 100644 index 0000000..78b8e39 --- /dev/null +++ b/Tests/FlashcardKitTests/FlashcardLoggingTests.swift @@ -0,0 +1,33 @@ +import Testing + +@testable import FlashcardKit + +@Suite("Flashcard logging") +struct FlashcardLoggingTests { + @Test("Messages use the package identity and expose no card content") + func packageIdentity() { + #expect(FlashcardLogging.subsystem == "com.thatfactory.flashcardkit") + #expect(FlashcardLogging.formatted("session created | rounds=3") == "๐Ÿƒ session created | rounds=3") + } + + @Test( + "Evaluation outcomes have stable privacy-safe tokens", + arguments: [ + OutcomeToken(outcome: .correct, token: "correct"), + OutcomeToken(outcome: .incorrect, token: "incorrect"), + OutcomeToken(outcome: .expired, token: "expired"), + ] + ) + func outcomeTokens(example: OutcomeToken) { + #expect(example.outcome.token == example.token) + } +} + +extension FlashcardLoggingTests { + struct OutcomeToken: Sendable, CustomTestStringConvertible { + let outcome: ThreeChoiceEvaluation.Outcome + let token: String + + var testDescription: String { token } + } +} diff --git a/Tests/FlashcardKitTests/ThreeChoiceSessionTests.swift b/Tests/FlashcardKitTests/ThreeChoiceSessionTests.swift new file mode 100644 index 0000000..1c9cc19 --- /dev/null +++ b/Tests/FlashcardKitTests/ThreeChoiceSessionTests.swift @@ -0,0 +1,233 @@ +import Foundation +import Testing + +@testable import FlashcardKit + +@Suite("Three-choice sessions") +struct ThreeChoiceSessionTests { + @Test("A fixed seed creates a stable three-choice plan") + func fixedSeedFixture() throws { + var session = try ThreeChoiceSession( + cards: cards, + configuration: ThreeChoiceSessionConfiguration(seed: 42, ordering: .shuffled) + ) + + var rounds: [ThreeChoiceRound] = [] + while let round = session.currentRound { + rounds.append(round) + _ = try session.submit(.expired, forRoundID: round.id) + } + + #expect(rounds.map(\.cardID) == [cardID(3), cardID(1), cardID(4), cardID(2)]) + #expect( + rounds.map { $0.choices.map(\.content.text) } == [ + ["dog", "book", "cat"], + ["house", "cat", "book"], + ["cat", "book", "dog"], + ["house", "cat", "dog"], + ]) + } + + @Test("Shuffled sessions ignore source collection order") + func shuffledInputOrderIndependence() throws { + let configuration = ThreeChoiceSessionConfiguration(seed: 7) + let original = try ThreeChoiceSession(cards: cards, configuration: configuration) + let reversed = try ThreeChoiceSession(cards: cards.reversed(), configuration: configuration) + + #expect(original.currentRound == reversed.currentRound) + } + + @Test("Source ordering preserves caller order and limits rounds") + func sourceOrdering() throws { + let session = try ThreeChoiceSession( + cards: cards, + configuration: ThreeChoiceSessionConfiguration( + seed: 1, + roundCount: 2, + ordering: .source + ) + ) + + #expect(session.currentRound?.cardID == cardID(1)) + #expect(session.progress == ThreeChoiceProgress(completedRounds: 0, totalRounds: 2)) + } + + @Test("Every round contains exactly three distinct answers") + func distinctAnswers() throws { + let duplicateDefinitionCards = try [ + card(1, prompt: "Haus", answer: "house"), + card(2, prompt: "Gebรคude", answer: "house"), + card(3, prompt: "Hund", answer: "dog"), + card(4, prompt: "Katze", answer: "cat"), + ] + var session = try ThreeChoiceSession( + cards: duplicateDefinitionCards, + configuration: ThreeChoiceSessionConfiguration(seed: 12, ordering: .source) + ) + + while let round = session.currentRound { + #expect(round.choices.count == 3) + #expect(Set(round.choices.map(\.content)).count == 3) + _ = try session.submit(.expired, forRoundID: round.id) + } + } + + @Test("Selections and expiry evaluate and advance") + func evaluationAndProgress() throws { + var session = try ThreeChoiceSession( + cards: cards, + configuration: ThreeChoiceSessionConfiguration(seed: 3, roundCount: 3) + ) + + let first = try #require(session.currentRound) + let firstCorrectID = try correctChoiceID(in: first, cards: cards) + let correct = try session.submit(.selection(choiceID: firstCorrectID), forRoundID: first.id) + #expect(correct.outcome == .correct) + #expect(correct.correctChoiceID == firstCorrectID) + + let second = try #require(session.currentRound) + let secondCorrectID = try correctChoiceID(in: second, cards: cards) + let incorrectID = try #require(second.choices.first { $0.id != secondCorrectID }?.id) + let incorrect = try session.submit(.selection(choiceID: incorrectID), forRoundID: second.id) + #expect(incorrect.outcome == .incorrect) + #expect(incorrect.selectedChoiceID == incorrectID) + + let third = try #require(session.currentRound) + let expired = try session.submit(.expired, forRoundID: third.id) + #expect(expired.outcome == .expired) + #expect(expired.selectedChoiceID == nil) + #expect(session.progress == ThreeChoiceProgress(completedRounds: 3, totalRounds: 3)) + #expect(session.isComplete) + #expect(session.currentRound == nil) + } + + @Test("Rejected responses preserve session state") + func rejectedResponsesDoNotAdvance() throws { + var session = try ThreeChoiceSession( + cards: cards, + configuration: ThreeChoiceSessionConfiguration(seed: 5) + ) + let round = try #require(session.currentRound) + let progress = session.progress + + #expect(throws: ThreeChoiceSessionError.staleRound(expected: round.id, received: 99)) { + try session.submit(.expired, forRoundID: 99) + } + #expect(session.currentRound == round) + #expect(session.progress == progress) + + #expect(throws: ThreeChoiceSessionError.invalidChoice(99)) { + try session.submit(.selection(choiceID: 99), forRoundID: round.id) + } + #expect(session.currentRound == round) + #expect(session.progress == progress) + } + + @Test("Submission after completion fails without changing progress") + func submissionAfterCompletion() throws { + var session = try ThreeChoiceSession( + cards: cards, + configuration: ThreeChoiceSessionConfiguration(seed: 8, roundCount: 1) + ) + let round = try #require(session.currentRound) + _ = try session.submit(.expired, forRoundID: round.id) + + #expect(throws: ThreeChoiceSessionError.sessionComplete) { + try session.submit(.expired, forRoundID: round.id) + } + #expect(session.progress == ThreeChoiceProgress(completedRounds: 1, totalRounds: 1)) + } + + @Test("Identical sessions replay the same response script") + func deterministicReplay() throws { + let configuration = ThreeChoiceSessionConfiguration(seed: 99) + var first = try ThreeChoiceSession(cards: cards, configuration: configuration) + var second = try ThreeChoiceSession(cards: cards, configuration: configuration) + + while let firstRound = first.currentRound, let secondRound = second.currentRound { + #expect(firstRound == secondRound) + let choiceID = firstRound.choices[1].id + let firstEvaluation = try first.submit(.selection(choiceID: choiceID), forRoundID: firstRound.id) + let secondEvaluation = try second.submit(.selection(choiceID: choiceID), forRoundID: secondRound.id) + #expect(firstEvaluation == secondEvaluation) + #expect(first.progress == second.progress) + } + + #expect(first.isComplete) + #expect(second.isComplete) + } + + @Test("Session values satisfy their declared protocols") + func protocolConformance() throws { + let session = try ThreeChoiceSession( + cards: cards, + configuration: ThreeChoiceSessionConfiguration(seed: 1) + ) + let round = try #require(session.currentRound) + let response = ThreeChoiceResponse.expired + + requireSendable(session) + requireValueContract(round) + requireValueContract(round.choices[0]) + requireValueContract(session.progress) + requireSendableAndHashable(response) + } + + @Test("Public session values survive Codable round trips") + func codableRoundTrips() throws { + let configuration = ThreeChoiceSessionConfiguration(seed: 44, roundCount: 1) + var session = try ThreeChoiceSession(cards: cards, configuration: configuration) + let round = try #require(session.currentRound) + let evaluation = try session.submit(.expired, forRoundID: round.id) + + try expectRoundTrip(configuration) + try expectRoundTrip(round) + try expectRoundTrip(evaluation) + try expectRoundTrip(session.progress) + } + + private var cards: [Flashcard] { + get throws { + try [ + card(1, prompt: "Haus", answer: "house"), + card(2, prompt: "Hund", answer: "dog"), + card(3, prompt: "Katze", answer: "cat"), + card(4, prompt: "Buch", answer: "book"), + ] + } + } + + private func card(_ id: Int, prompt: String, answer: String) throws -> Flashcard { + Flashcard( + id: cardID(id), + prompt: try FlashcardContent(text: prompt), + answer: try FlashcardContent(text: answer) + ) + } + + private func cardID(_ value: Int) -> UUID { + UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", value))! + } + + private func correctChoiceID(in round: ThreeChoiceRound, cards: [Flashcard]) throws -> Int { + let answer = try #require(cards.first { $0.id == round.cardID }?.answer) + return try #require(round.choices.first { $0.content == answer }?.id) + } + + private func requireSendable(_ value: Value) { + _ = value + } + + private func requireValueContract(_ value: Value) { + _ = value + } + + private func requireSendableAndHashable(_ value: Value) { + _ = value + } + + private func expectRoundTrip(_ value: Value) throws { + let data = try JSONEncoder().encode(value) + #expect(try JSONDecoder().decode(Value.self, from: data) == value) + } +} diff --git a/Tests/FlashcardKitTests/ThreeChoiceSessionValidationTests.swift b/Tests/FlashcardKitTests/ThreeChoiceSessionValidationTests.swift new file mode 100644 index 0000000..fb226eb --- /dev/null +++ b/Tests/FlashcardKitTests/ThreeChoiceSessionValidationTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing + +@testable import FlashcardKit + +@Suite("Three-choice session validation") +struct ThreeChoiceSessionValidationTests { + @Test("At least three cards are required") + func insufficientCards() throws { + let cards = try [card(1, "one"), card(2, "two")] + + #expect(throws: ThreeChoiceSessionError.insufficientCards(minimum: 3, actual: 2)) { + try ThreeChoiceSession( + cards: cards, + configuration: ThreeChoiceSessionConfiguration(seed: 1) + ) + } + } + + @Test("At least three distinct visible answers are required") + func insufficientDistinctAnswers() throws { + let cards = try [card(1, "same"), card(2, "same"), card(3, "other")] + + #expect( + throws: ThreeChoiceSessionError.insufficientDistinctAnswers(minimum: 3, actual: 2) + ) { + try ThreeChoiceSession( + cards: cards, + configuration: ThreeChoiceSessionConfiguration(seed: 1) + ) + } + } + + @Test("Card identities must be unique") + func duplicateCardIdentity() throws { + let cards = try [card(1, "one"), card(1, "two"), card(3, "three")] + + #expect(throws: ThreeChoiceSessionError.duplicateCardID(cardID(1))) { + try ThreeChoiceSession( + cards: cards, + configuration: ThreeChoiceSessionConfiguration(seed: 1) + ) + } + } + + @Test( + "Round count must fit the source cards", + arguments: [ + RoundCountExample( + roundCount: 0, + error: .invalidRoundCount(0) + ), + RoundCountExample( + roundCount: -1, + error: .invalidRoundCount(-1) + ), + RoundCountExample( + roundCount: 4, + error: .roundCountExceedsAvailableCards(requested: 4, available: 3) + ), + ] + ) + func invalidRoundCount(example: RoundCountExample) throws { + let cards = try [card(1, "one"), card(2, "two"), card(3, "three")] + + #expect(throws: example.error) { + try ThreeChoiceSession( + cards: cards, + configuration: ThreeChoiceSessionConfiguration( + seed: 1, + roundCount: example.roundCount + ) + ) + } + } + + private func card(_ id: Int, _ answer: String) throws -> Flashcard { + Flashcard( + id: cardID(id), + prompt: try FlashcardContent(text: "prompt-\(id)"), + answer: try FlashcardContent(text: answer) + ) + } + + private func cardID(_ value: Int) -> UUID { + UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", value))! + } +} + +extension ThreeChoiceSessionValidationTests { + struct RoundCountExample: Sendable, CustomTestStringConvertible { + let roundCount: Int + let error: ThreeChoiceSessionError + + var testDescription: String { "roundCount=\(roundCount)" } + } +}