diff --git a/README.md b/README.md index eb88b29..2b1118f 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,29 @@ 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 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. + +Use `ProgressiveFlashcardSession` when a selected card must advance through every ordered stage independently of how the host evaluates it: + +```swift +var progressiveSession = try ProgressiveFlashcardSession( + cards: cards, + configuration: ProgressiveFlashcardSessionConfiguration( + seed: 42, + cardCount: 5 + ) +) + +if let attempt = progressiveSession.currentAttempt { + // The host decides how this stage is evaluated. + let evaluation = try progressiveSession.submit( + .correct, + forAttemptID: attempt.id + ) +} +``` + +Selection is canonicalized by card identity and then seeded-shuffled. Every selected card begins at stage zero. Correct outcomes promote or complete a card; incorrect and expired outcomes retain the current stage. Unfinished cards requeue at the tail, except that a sole unfinished card necessarily repeats immediately. Attempt IDs increase monotonically within the session, and `generatedAttempts` grows with retries and promotions rather than describing a fixed total. Three-choice construction and pronunciation evaluation remain outside the progression engine. 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. diff --git a/Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md b/Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md index f8c51ad..5047a14 100644 --- a/Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md +++ b/Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md @@ -34,7 +34,31 @@ let stagedCard = try Flashcard( ) ``` -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. +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. + +Use ``ProgressiveFlashcardSession`` to select a deterministic subset of cards and advance each card through all of its ordered stages without coupling progression to the host's response mechanism: + +```swift +var progressiveSession = try ProgressiveFlashcardSession( + cards: cards, + configuration: ProgressiveFlashcardSessionConfiguration( + seed: 42, + cardCount: 5 + ) +) + +if let attempt = progressiveSession.currentAttempt { + // The host decides how this stage is evaluated. + let evaluation = try progressiveSession.submit( + .correct, + forAttemptID: attempt.id + ) +} +``` + +The session canonicalizes cards by identity before seeded selection, begins each selected card at stage zero, and gives every presentation opportunity a monotonic session-scoped attempt identity. Correct outcomes promote a card or complete its final stage; incorrect and expired outcomes retain the same stage. Every unfinished card is appended to the queue tail, so it reappears after other unfinished cards. A sole unfinished card necessarily repeats immediately. ``ProgressiveFlashcardProgress/generatedAttempts`` grows as retries and promotions issue new attempts and is not a fixed session total. + +``ThreeChoiceSession`` remains a separate finite stage-zero engine. Progressive sessions consume only host-decided correct, incorrect, or expired outcomes; they do not construct choices, evaluate pronunciation, own timers, or assign scores. The package does not resolve asset references or own presentation, persistence frameworks, vocabulary acquisition, or scheduling policy. diff --git a/Sources/FlashcardKit/FlashcardLogging.swift b/Sources/FlashcardKit/FlashcardLogging.swift index 342faf4..f0d712f 100644 --- a/Sources/FlashcardKit/FlashcardLogging.swift +++ b/Sources/FlashcardKit/FlashcardLogging.swift @@ -20,6 +20,65 @@ enum FlashcardLogging { log(level: .error, "response rejected | reason=\(reason)") } + static func progressiveSessionCreated(cards: Int) { + log(progressiveSessionCreatedMessage(cards: cards)) + } + + static func progressiveSessionRejected(reason: String) { + log(level: .error, progressiveSessionRejectedMessage(reason: reason)) + } + + static func progressiveAttemptAccepted( + outcome: ProgressiveFlashcardEvaluation.Outcome, + transition: ProgressiveFlashcardEvaluation.Transition, + completedCards: Int, + selectedCards: Int, + generatedAttempts: Int + ) { + log( + progressiveAttemptAcceptedMessage( + outcome: outcome, + transition: transition, + completedCards: completedCards, + selectedCards: selectedCards, + generatedAttempts: generatedAttempts + )) + } + + static func progressiveAttemptRejected(reason: String) { + log(level: .error, progressiveAttemptRejectedMessage(reason: reason)) + } + + static func progressiveSessionCompleted(cards: Int, attempts: Int) { + log(progressiveSessionCompletedMessage(cards: cards, attempts: attempts)) + } + + static func progressiveAttemptAcceptedMessage( + outcome: ProgressiveFlashcardEvaluation.Outcome, + transition: ProgressiveFlashcardEvaluation.Transition, + completedCards: Int, + selectedCards: Int, + generatedAttempts: Int + ) -> String { + "progressive attempt accepted | outcome=\(outcome.token), transition=\(transition.token), completed=\(completedCards), selected=\(selectedCards), attempts=\(generatedAttempts)" + } + + static func progressiveAttemptRejectedMessage(reason: String) -> String { + "progressive attempt rejected | reason=\(reason)" + } + + static func progressiveSessionCompletedMessage(cards: Int, attempts: Int) -> String { + "progressive session completed | cards=\(cards), attempts=\(attempts)" + } + + static func progressiveSessionCreatedMessage(cards: Int) -> String { + "progressive session created | cards=\(cards)" + } + + static func progressiveSessionRejectedMessage(reason: String) -> String { + "progressive session rejected | reason=\(reason)" + } + static func formatted(_ message: String) -> String { "\(emoji) \(message)" } @@ -29,6 +88,26 @@ enum FlashcardLogging { } } +extension ProgressiveFlashcardEvaluation.Outcome { + var token: String { + switch self { + case .correct: "correct" + case .expired: "expired" + case .incorrect: "incorrect" + } + } +} + +extension ProgressiveFlashcardEvaluation.Transition { + var token: String { + switch self { + case .completed: "completed" + case .promoted: "promoted" + case .retained: "retained" + } + } +} + extension ThreeChoiceEvaluation.Outcome { var token: String { switch self { diff --git a/Sources/FlashcardKit/ProgressiveFlashcardAttempt.swift b/Sources/FlashcardKit/ProgressiveFlashcardAttempt.swift new file mode 100644 index 0000000..eb9db6b --- /dev/null +++ b/Sources/FlashcardKit/ProgressiveFlashcardAttempt.swift @@ -0,0 +1,16 @@ +public import Foundation + +/// One session-scoped opportunity to exercise a card's current stage. +public struct ProgressiveFlashcardAttempt: Identifiable, Codable, Sendable, Hashable { + /// Monotonic zero-based identity scoped to the containing session. + public let id: Int + + /// Stable host-authored card identity. + public let cardID: UUID + + /// Zero-based position of the stage in the card's ordered stages. + public let stageIndex: Int + + /// Exact stage currently being exercised. + public let stage: FlashcardStage +} diff --git a/Sources/FlashcardKit/ProgressiveFlashcardEvaluation.swift b/Sources/FlashcardKit/ProgressiveFlashcardEvaluation.swift new file mode 100644 index 0000000..934ec93 --- /dev/null +++ b/Sources/FlashcardKit/ProgressiveFlashcardEvaluation.swift @@ -0,0 +1,43 @@ +public import Foundation + +/// Progression result for one accepted host-evaluated attempt. +public struct ProgressiveFlashcardEvaluation: Codable, Sendable, Hashable { + /// Response-mode-agnostic outcomes accepted by the progression engine. + public enum Outcome: Codable, Sendable, Hashable { + /// The host determined that the response was correct. + case correct + + /// The host determined that the response window expired. + case expired + + /// The host determined that the response was incorrect. + case incorrect + } + + /// Queue and stage transition produced by an accepted outcome. + public enum Transition: Codable, Sendable, Hashable { + /// A correct response completed the card's final stage. + case completed + + /// The card advanced and was requeued at the supplied next stage. + case promoted(to: FlashcardStageID) + + /// The card remained on the same stage and was requeued. + case retained + } + + /// Identity of the evaluated attempt. + public let attemptID: Int + + /// Stable identity of the evaluated card. + public let cardID: UUID + + /// Stable identity of the evaluated stage. + public let stageID: FlashcardStageID + + /// Host-decided outcome consumed by the progression engine. + public let outcome: Outcome + + /// Resulting card progression and queue transition. + public let transition: Transition +} diff --git a/Sources/FlashcardKit/ProgressiveFlashcardProgress.swift b/Sources/FlashcardKit/ProgressiveFlashcardProgress.swift new file mode 100644 index 0000000..d7075cd --- /dev/null +++ b/Sources/FlashcardKit/ProgressiveFlashcardProgress.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Aggregate progress for a progressive flashcard session. +public struct ProgressiveFlashcardProgress: Codable, Sendable, Hashable { + /// Distinct selected cards that completed every stage. + public let completedCards: Int + + /// Attempts issued so far, including the currently outstanding attempt. + public let generatedAttempts: Int + + /// Distinct cards selected when the session was created. + public let selectedCards: Int +} diff --git a/Sources/FlashcardKit/ProgressiveFlashcardSession.swift b/Sources/FlashcardKit/ProgressiveFlashcardSession.swift new file mode 100644 index 0000000..e141004 --- /dev/null +++ b/Sources/FlashcardKit/ProgressiveFlashcardSession.swift @@ -0,0 +1,161 @@ +import Foundation + +/// A deterministic session that advances cards through ordered recall stages. +public struct ProgressiveFlashcardSession: Sendable { + private struct PendingCard: Sendable { + let card: Flashcard + var stageIndex: Int + } + + private var completedCardCount = 0 + private var currentAttemptID: Int? + private var nextAttemptID: Int + private var queue: [PendingCard] + private let selectedCardCount: Int + + /// The attempt awaiting a host-decided outcome, or `nil` after completion. + public var currentAttempt: ProgressiveFlashcardAttempt? { + guard let currentAttemptID, let pending = queue.first else { + return nil + } + return ProgressiveFlashcardAttempt( + id: currentAttemptID, + cardID: pending.card.id, + stageIndex: pending.stageIndex, + stage: pending.card.stages[pending.stageIndex] + ) + } + + /// Current card completion and generated-attempt counts. + public var progress: ProgressiveFlashcardProgress { + ProgressiveFlashcardProgress( + completedCards: completedCardCount, + generatedAttempts: nextAttemptID, + selectedCards: selectedCardCount + ) + } + + /// Whether every selected card completed its final stage. + public var isComplete: Bool { + completedCardCount == selectedCardCount + } + + /// Creates a deterministic progressive session from host-owned cards. + /// + /// - Parameters: + /// - cards: Cards eligible for deterministic selection. + /// - configuration: Seed and distinct-card selection count. + /// - Throws: ``ProgressiveFlashcardSessionError`` when the source cards or configuration are invalid. + public init( + cards: [Flashcard], + configuration: ProgressiveFlashcardSessionConfiguration + ) throws { + guard configuration.cardCount > 0 else { + FlashcardLogging.progressiveSessionRejected(reason: "invalid-card-count") + throw ProgressiveFlashcardSessionError.invalidCardCount(configuration.cardCount) + } + + var cardIDs = Set() + for card in cards where !cardIDs.insert(card.id).inserted { + FlashcardLogging.progressiveSessionRejected(reason: "duplicate-card-id") + throw ProgressiveFlashcardSessionError.duplicateCardID(card.id) + } + + guard configuration.cardCount <= cards.count else { + FlashcardLogging.progressiveSessionRejected(reason: "card-count-exceeds-cards") + throw ProgressiveFlashcardSessionError.cardCountExceedsAvailableCards( + requested: configuration.cardCount, + available: cards.count + ) + } + + var orderedCards = cards.sorted { $0.id.uuidString < $1.id.uuidString } + var random = DeterministicRandom(seed: configuration.seed) + random.shuffle(&orderedCards) + let selectedCards = orderedCards.prefix(configuration.cardCount) + + queue = selectedCards.map { PendingCard(card: $0, stageIndex: 0) } + currentAttemptID = 0 + nextAttemptID = 1 + selectedCardCount = configuration.cardCount + FlashcardLogging.progressiveSessionCreated(cards: selectedCardCount) + } + + /// Applies one host-decided outcome to the exact current attempt. + /// + /// - Parameters: + /// - outcome: Correct, incorrect, or expired result decided by the host's evaluation mechanism. + /// - attemptID: Identity of the attempt that produced the outcome. + /// - Returns: The accepted outcome and resulting stage transition. + /// - Throws: ``ProgressiveFlashcardSessionError`` when the session is complete or the attempt identity is invalid or stale. Rejected submissions do not mutate the session. + public mutating func submit( + _ outcome: ProgressiveFlashcardEvaluation.Outcome, + forAttemptID attemptID: Int + ) throws -> ProgressiveFlashcardEvaluation { + guard let currentAttemptID else { + FlashcardLogging.progressiveAttemptRejected(reason: "session-complete") + throw ProgressiveFlashcardSessionError.sessionComplete + } + guard attemptID >= 0, attemptID < nextAttemptID else { + FlashcardLogging.progressiveAttemptRejected(reason: "invalid-attempt") + throw ProgressiveFlashcardSessionError.invalidAttempt(attemptID) + } + guard attemptID == currentAttemptID else { + FlashcardLogging.progressiveAttemptRejected(reason: "stale-attempt") + throw ProgressiveFlashcardSessionError.staleAttempt( + expected: currentAttemptID, + received: attemptID + ) + } + + var pending = queue.removeFirst() + let evaluatedStage = pending.card.stages[pending.stageIndex] + let transition: ProgressiveFlashcardEvaluation.Transition + switch outcome { + case .correct where pending.card.stages.indices.contains(pending.stageIndex + 1): + pending.stageIndex += 1 + queue.append(pending) + transition = .promoted(to: pending.card.stages[pending.stageIndex].id) + case .correct: + completedCardCount += 1 + transition = .completed + case .expired, .incorrect: + queue.append(pending) + transition = .retained + } + + issueNextAttempt() + let evaluation = ProgressiveFlashcardEvaluation( + attemptID: attemptID, + cardID: pending.card.id, + stageID: evaluatedStage.id, + outcome: outcome, + transition: transition + ) + FlashcardLogging.progressiveAttemptAccepted( + outcome: outcome, + transition: transition, + completedCards: completedCardCount, + selectedCards: selectedCardCount, + generatedAttempts: nextAttemptID + ) + if isComplete { + FlashcardLogging.progressiveSessionCompleted( + cards: selectedCardCount, + attempts: nextAttemptID + ) + } + return evaluation + } + + // MARK: - Private + + private mutating func issueNextAttempt() { + guard !queue.isEmpty else { + currentAttemptID = nil + return + } + currentAttemptID = nextAttemptID + nextAttemptID += 1 + } +} diff --git a/Sources/FlashcardKit/ProgressiveFlashcardSessionConfiguration.swift b/Sources/FlashcardKit/ProgressiveFlashcardSessionConfiguration.swift new file mode 100644 index 0000000..69628d4 --- /dev/null +++ b/Sources/FlashcardKit/ProgressiveFlashcardSessionConfiguration.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Configuration used to create a deterministic progressive flashcard session. +public struct ProgressiveFlashcardSessionConfiguration: Codable, Sendable, Hashable { + /// Number of distinct cards selected for this session. + public let cardCount: Int + + /// Seed used for deterministic card selection and initial queue order. + public let seed: UInt64 + + /// Creates a progressive session configuration. + /// + /// - Parameters: + /// - seed: Seed used by FlashcardKit's stable deterministic random algorithm. + /// - cardCount: Positive number of distinct cards to select. + public init(seed: UInt64, cardCount: Int) { + self.cardCount = cardCount + self.seed = seed + } +} diff --git a/Sources/FlashcardKit/ProgressiveFlashcardSessionError.swift b/Sources/FlashcardKit/ProgressiveFlashcardSessionError.swift new file mode 100644 index 0000000..0e4ba5d --- /dev/null +++ b/Sources/FlashcardKit/ProgressiveFlashcardSessionError.swift @@ -0,0 +1,22 @@ +public import Foundation + +/// Validation and submission failures from a progressive flashcard session. +public enum ProgressiveFlashcardSessionError: Error, Sendable, Equatable { + /// The requested card count exceeded the supplied cards. + case cardCountExceedsAvailableCards(requested: Int, available: Int) + + /// More than one source card used the same stable identity. + case duplicateCardID(UUID) + + /// The submitted attempt identity was negative or had not been issued. + case invalidAttempt(Int) + + /// The requested card count was not positive. + case invalidCardCount(Int) + + /// An outcome was submitted after every selected card completed. + case sessionComplete + + /// An outcome targeted an earlier attempt instead of the current attempt. + case staleAttempt(expected: Int, received: Int) +} diff --git a/Tests/FlashcardKitTests/FlashcardLoggingTests.swift b/Tests/FlashcardKitTests/FlashcardLoggingTests.swift index 78b8e39..d8fa9be 100644 --- a/Tests/FlashcardKitTests/FlashcardLoggingTests.swift +++ b/Tests/FlashcardKitTests/FlashcardLoggingTests.swift @@ -21,6 +21,69 @@ struct FlashcardLoggingTests { func outcomeTokens(example: OutcomeToken) { #expect(example.outcome.token == example.token) } + + @Test( + "Progressive outcomes have stable privacy-safe tokens", + arguments: [ + ProgressiveOutcomeToken(outcome: .correct, token: "correct"), + ProgressiveOutcomeToken(outcome: .expired, token: "expired"), + ProgressiveOutcomeToken(outcome: .incorrect, token: "incorrect"), + ] + ) + func progressiveOutcomeTokens(example: ProgressiveOutcomeToken) { + #expect(example.outcome.token == example.token) + } + + @Test( + "Progressive transitions have stable privacy-safe tokens", + arguments: [ + ProgressiveTransitionToken(transition: .completed, token: "completed"), + ProgressiveTransitionToken( + transition: .promoted(to: FlashcardStageID(rawValue: "private-stage")), + token: "promoted" + ), + ProgressiveTransitionToken(transition: .retained, token: "retained"), + ] + ) + func progressiveTransitionTokens(example: ProgressiveTransitionToken) { + #expect(example.transition.token == example.token) + } + + @Test("Progressive messages expose only aggregate state") + func progressiveMessagePrivacy() { + let accepted = FlashcardLogging.progressiveAttemptAcceptedMessage( + outcome: .correct, + transition: .promoted(to: FlashcardStageID(rawValue: "private-stage")), + completedCards: 1, + selectedCards: 3, + generatedAttempts: 4 + ) + let messages = [ + accepted, + FlashcardLogging.progressiveAttemptRejectedMessage(reason: "stale-attempt"), + FlashcardLogging.progressiveSessionCompletedMessage(cards: 3, attempts: 7), + FlashcardLogging.progressiveSessionCreatedMessage(cards: 3), + FlashcardLogging.progressiveSessionRejectedMessage(reason: "invalid-card-count"), + ] + + #expect( + FlashcardLogging.formatted(accepted) + == "🃏 progressive attempt accepted | outcome=correct, transition=promoted, completed=1, selected=3, attempts=4" + ) + #expect(messages[1] == "progressive attempt rejected | reason=stale-attempt") + #expect(messages[2] == "progressive session completed | cards=3, attempts=7") + #expect(messages[3] == "progressive session created | cards=3") + #expect(messages[4] == "progressive session rejected | reason=invalid-card-count") + for forbiddenValue in [ + "private-stage", + "00000000-0000-0000-0000-000000000001", + "prompt", + "answer", + "asset-reference", + ] { + #expect(messages.allSatisfy { !$0.contains(forbiddenValue) }) + } + } } extension FlashcardLoggingTests { @@ -30,4 +93,18 @@ extension FlashcardLoggingTests { var testDescription: String { token } } + + struct ProgressiveOutcomeToken: Sendable, CustomTestStringConvertible { + let outcome: ProgressiveFlashcardEvaluation.Outcome + let token: String + + var testDescription: String { token } + } + + struct ProgressiveTransitionToken: Sendable, CustomTestStringConvertible { + let transition: ProgressiveFlashcardEvaluation.Transition + let token: String + + var testDescription: String { token } + } } diff --git a/Tests/FlashcardKitTests/ProgressiveFlashcardSessionTests.swift b/Tests/FlashcardKitTests/ProgressiveFlashcardSessionTests.swift new file mode 100644 index 0000000..1c91fcb --- /dev/null +++ b/Tests/FlashcardKitTests/ProgressiveFlashcardSessionTests.swift @@ -0,0 +1,229 @@ +import Foundation +import Testing + +@testable import FlashcardKit + +@Suite("Progressive flashcard sessions") +struct ProgressiveFlashcardSessionTests { + @Test("Seeded selection is distinct and independent of input order") + func deterministicSelection() throws { + let cards = try ProgressiveFlashcardTestFixtures.cards + let configuration = ProgressiveFlashcardSessionConfiguration(seed: 42, cardCount: 3) + var original = try ProgressiveFlashcardSession(cards: cards, configuration: configuration) + var reversed = try ProgressiveFlashcardSession( + cards: cards.reversed(), + configuration: configuration + ) + + var originalOrder: [UUID] = [] + var reversedOrder: [UUID] = [] + #expect( + original.progress + == ProgressiveFlashcardProgress( + completedCards: 0, + generatedAttempts: 1, + selectedCards: configuration.cardCount + ) + ) + for _ in 0..(_ 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/ProgressiveFlashcardSessionValidationTests.swift b/Tests/FlashcardKitTests/ProgressiveFlashcardSessionValidationTests.swift new file mode 100644 index 0000000..a52b9f2 --- /dev/null +++ b/Tests/FlashcardKitTests/ProgressiveFlashcardSessionValidationTests.swift @@ -0,0 +1,115 @@ +import Testing + +@testable import FlashcardKit + +@Suite("Progressive flashcard session validation") +struct ProgressiveFlashcardSessionValidationTests { + @Test( + "Card count must be positive", + arguments: [0, -1] + ) + func invalidCardCount(cardCount: Int) throws { + #expect(throws: ProgressiveFlashcardSessionError.invalidCardCount(cardCount)) { + try ProgressiveFlashcardSession( + cards: [ProgressiveFlashcardTestFixtures.card(1)], + configuration: ProgressiveFlashcardSessionConfiguration( + seed: 1, + cardCount: cardCount + ) + ) + } + } + + @Test("Card count cannot exceed the source collection") + func excessiveCardCount() throws { + #expect( + throws: ProgressiveFlashcardSessionError.cardCountExceedsAvailableCards( + requested: 2, + available: 1 + ) + ) { + try ProgressiveFlashcardSession( + cards: [ProgressiveFlashcardTestFixtures.card(1)], + configuration: ProgressiveFlashcardSessionConfiguration(seed: 1, cardCount: 2) + ) + } + } + + @Test("Source card identities must be unique before selection") + func duplicateCardIDs() throws { + let card = try ProgressiveFlashcardTestFixtures.card(1) + + #expect(throws: ProgressiveFlashcardSessionError.duplicateCardID(card.id)) { + try ProgressiveFlashcardSession( + cards: [card, card], + configuration: ProgressiveFlashcardSessionConfiguration(seed: 1, cardCount: 1) + ) + } + } + + @Test("One selected card is valid") + func oneCardSession() throws { + let session = try ProgressiveFlashcardSession( + cards: [ProgressiveFlashcardTestFixtures.card(1)], + configuration: ProgressiveFlashcardSessionConfiguration(seed: 1, cardCount: 1) + ) + + #expect(session.currentAttempt?.id == 0) + #expect(session.currentAttempt?.stageIndex == 0) + #expect(session.progress.generatedAttempts == 1) + } + + @Test("Stale attempts fail without mutation") + func staleAttempt() throws { + var session = try ProgressiveFlashcardSession( + cards: [ProgressiveFlashcardTestFixtures.card(1)], + configuration: ProgressiveFlashcardSessionConfiguration(seed: 1, cardCount: 1) + ) + let first = try #require(session.currentAttempt) + _ = try session.submit(.expired, forAttemptID: first.id) + let current = session.currentAttempt + let progress = session.progress + + #expect(throws: ProgressiveFlashcardSessionError.staleAttempt(expected: 1, received: 0)) { + try session.submit(.correct, forAttemptID: first.id) + } + + #expect(session.currentAttempt == current) + #expect(session.progress == progress) + } + + @Test(arguments: [-1, 1]) + func invalidAttemptsFailWithoutMutation(attemptID: Int) throws { + var session = try ProgressiveFlashcardSession( + cards: [ProgressiveFlashcardTestFixtures.card(1)], + configuration: ProgressiveFlashcardSessionConfiguration(seed: 1, cardCount: 1) + ) + let current = session.currentAttempt + let progress = session.progress + + #expect(throws: ProgressiveFlashcardSessionError.invalidAttempt(attemptID)) { + try session.submit(.correct, forAttemptID: attemptID) + } + + #expect(session.currentAttempt == current) + #expect(session.progress == progress) + } + + @Test("Submission after completion fails without mutation") + func submissionAfterCompletion() throws { + var session = try ProgressiveFlashcardSession( + cards: [ProgressiveFlashcardTestFixtures.card(1, stageCount: 1)], + configuration: ProgressiveFlashcardSessionConfiguration(seed: 1, cardCount: 1) + ) + let attempt = try #require(session.currentAttempt) + _ = try session.submit(.correct, forAttemptID: attempt.id) + let progress = session.progress + + #expect(throws: ProgressiveFlashcardSessionError.sessionComplete) { + try session.submit(.correct, forAttemptID: attempt.id) + } + + #expect(session.currentAttempt == nil) + #expect(session.progress == progress) + } +} diff --git a/Tests/FlashcardKitTests/ProgressiveFlashcardTestFixtures.swift b/Tests/FlashcardKitTests/ProgressiveFlashcardTestFixtures.swift new file mode 100644 index 0000000..f72ef6d --- /dev/null +++ b/Tests/FlashcardKitTests/ProgressiveFlashcardTestFixtures.swift @@ -0,0 +1,32 @@ +import Foundation + +@testable import FlashcardKit + +/// Provides deterministic staged cards for progressive-session tests. +enum ProgressiveFlashcardTestFixtures { + static var cards: [Flashcard] { + get throws { + try (1...4).map { try card($0) } + } + } + + static func card( + _ value: Int, + stageCount: Int = 2 + ) throws -> Flashcard { + try Flashcard( + id: cardID(value), + stages: (0.. UUID { + UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", value))! + } +}