From f5cec84552084f712b98e497b7162414ef8282e6 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 23 Feb 2026 00:53:04 -0800 Subject: [PATCH 1/4] Add comprehensive unnit tests Introduce a comprehensive suite of unit tests under ComputerSolitaireTests. Adds tests for AutoFinishPlanner, AutoMoveAdvisor, HintAdvisor, MoveEvaluationRanking, GameRules, Scoring, SavedGamePayload sanitization, GamePersistenceStore (using an in-memory SwiftData context), GameStatisticsStore, and core SolitaireViewModel behaviors (drawing, recycling, drag/drop, undo, pause/resume, and auto-finish). Also includes TestSupport helpers (TestCards, GameStateFixtures, DateFixtures, and test assertions) to build deterministic board states and fixtures for the tests. --- .../AutoFinishPlannerTests.swift | 58 +++++ .../AutoMoveAdvisorCoverageTests.swift | 115 +++++++++ .../GamePersistenceStoreTests.swift | 76 ++++++ ComputerSolitaireTests/GameRulesTests.swift | 95 ++++++++ .../GameStatisticsStoreTests.swift | 108 +++++++++ .../HintAdvisorCoverageTests.swift | 49 ++++ .../MoveEvaluationRankingTests.swift | 60 +++++ .../SavedGamePayloadSanitizationTests.swift | 87 +++++++ ComputerSolitaireTests/ScoringTests.swift | 41 ++++ .../SolitaireViewModelCoreTests.swift | 224 ++++++++++++++++++ ComputerSolitaireTests/TestSupport.swift | 115 +++++++++ 11 files changed, 1028 insertions(+) create mode 100644 ComputerSolitaireTests/AutoFinishPlannerTests.swift create mode 100644 ComputerSolitaireTests/AutoMoveAdvisorCoverageTests.swift create mode 100644 ComputerSolitaireTests/GamePersistenceStoreTests.swift create mode 100644 ComputerSolitaireTests/GameRulesTests.swift create mode 100644 ComputerSolitaireTests/GameStatisticsStoreTests.swift create mode 100644 ComputerSolitaireTests/HintAdvisorCoverageTests.swift create mode 100644 ComputerSolitaireTests/MoveEvaluationRankingTests.swift create mode 100644 ComputerSolitaireTests/SavedGamePayloadSanitizationTests.swift create mode 100644 ComputerSolitaireTests/ScoringTests.swift create mode 100644 ComputerSolitaireTests/SolitaireViewModelCoreTests.swift create mode 100644 ComputerSolitaireTests/TestSupport.swift diff --git a/ComputerSolitaireTests/AutoFinishPlannerTests.swift b/ComputerSolitaireTests/AutoFinishPlannerTests.swift new file mode 100644 index 0000000..954f96b --- /dev/null +++ b/ComputerSolitaireTests/AutoFinishPlannerTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class AutoFinishPlannerTests: XCTestCase { + func testCanAutoFinishRejectsNonCandidateStates() { + var state = GameStateFixtures.almostWonForAutoFinish() + state.stock = [TestCards.make(.spades, .ace, isFaceUp: false)] + XCTAssertFalse(AutoFinishPlanner.canAutoFinish(in: state)) + + state = GameStateFixtures.almostWonForAutoFinish() + state.stock = [] + state.waste = [TestCards.make(.spades, .ace, isFaceUp: true)] + XCTAssertFalse(AutoFinishPlanner.canAutoFinish(in: state)) + + state = GameStateFixtures.almostWonForAutoFinish() + state.tableau[0][0].isFaceUp = false + XCTAssertFalse(AutoFinishPlanner.canAutoFinish(in: state)) + } + + func testNextAutoFinishMoveReturnsDeterministicFirstMove() { + let state = GameStateFixtures.almostWonForAutoFinish() + + let move = AutoFinishPlanner.nextAutoFinishMove(in: state) + XCTAssertNotNil(move) + XCTAssertEqual(move?.destination, .foundation(0)) + + if case .tableau(let pile, let index) = move?.selection.source { + XCTAssertEqual(pile, 0) + XCTAssertEqual(index, 0) + } else { + XCTFail("Expected tableau source") + } + } + + func testCanAutoFinishSucceedsForSimpleAlmostWonBoard() { + XCTAssertTrue(AutoFinishPlanner.canAutoFinish(in: GameStateFixtures.almostWonForAutoFinish())) + } + + func testCanAutoFinishReturnsFalseWhenNoProgressMoveExists() { + var foundations = Array(repeating: [Card](), count: 4) + foundations[0] = Rank.allCases + .filter { $0 != .king } + .map { TestCards.make(.spades, $0, isFaceUp: true) } + + // King of hearts cannot go to any foundation here. + let blocked = GameState( + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: foundations, + tableau: [[TestCards.make(.hearts, .king, isFaceUp: true)], [], [], [], [], [], []] + ) + + XCTAssertNil(AutoFinishPlanner.nextAutoFinishMove(in: blocked)) + XCTAssertFalse(AutoFinishPlanner.canAutoFinish(in: blocked)) + } +} diff --git a/ComputerSolitaireTests/AutoMoveAdvisorCoverageTests.swift b/ComputerSolitaireTests/AutoMoveAdvisorCoverageTests.swift new file mode 100644 index 0000000..7a4ea26 --- /dev/null +++ b/ComputerSolitaireTests/AutoMoveAdvisorCoverageTests.swift @@ -0,0 +1,115 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class AutoMoveAdvisorCoverageTests: XCTestCase { + func testCandidateSelectionsIncludesWasteFoundationAndValidTableauRuns() { + let wasteCard = TestCards.make(.spades, .ace, isFaceUp: true) + let foundationCard = TestCards.make(.hearts, .ace, isFaceUp: true) + let tableauRun = [ + TestCards.make(.clubs, .seven, isFaceUp: true), + TestCards.make(.hearts, .six, isFaceUp: true), + TestCards.make(.clubs, .five, isFaceUp: true) + ] + let state = GameState( + stock: [], + waste: [wasteCard], + wasteDrawCount: 1, + foundations: [[foundationCard], [], [], []], + tableau: [tableauRun, [], [], [], [], [], []] + ) + + let selections = AutoMoveAdvisor.candidateSelections(in: state) + XCTAssertTrue(selections.contains(where: { $0.source == .waste })) + XCTAssertTrue(selections.contains(where: { $0.source == .foundation(pile: 0) })) + XCTAssertTrue( + selections.contains( + where: { + if case .tableau(let pile, let index) = $0.source { + return pile == 0 && index == 0 + } + return false + } + ) + ) + } + + func testLegalDestinationsRejectsRedundantKingTransferBetweenEmptyColumns() { + let kingSpades = TestCards.make(.spades, .king, isFaceUp: true) + let state = GameState( + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: [[kingSpades], [], [], [], [], [], []] + ) + let selection = Selection(source: .tableau(pile: 0, index: 0), cards: [kingSpades]) + + let destinations = AutoMoveAdvisor.legalDestinations(for: selection, in: state) + XCTAssertFalse(destinations.contains(.tableau(1))) + } + + func testBestDestinationMovesWasteAceToFoundation() { + let aceSpades = TestCards.make(.spades, .ace, isFaceUp: true) + let state = GameState( + stock: [], + waste: [aceSpades], + wasteDrawCount: 1, + foundations: Array(repeating: [], count: 4), + tableau: Array(repeating: [], count: 7) + ) + let selection = Selection(source: .waste, cards: [aceSpades]) + + XCTAssertEqual( + AutoMoveAdvisor.bestDestination( + for: selection, + in: state, + stockDrawCount: DrawMode.three.rawValue + ), + .foundation(0) + ) + } + + func testBestAdvisableDestinationRejectsFoundationToFoundationAndNonMatchingSelections() { + let aceSpades = TestCards.make(.spades, .ace, isFaceUp: true) + let twoSpades = TestCards.make(.spades, .two, isFaceUp: true) + let state = GameState( + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: [[aceSpades], [twoSpades], [], []], + tableau: Array(repeating: [], count: 7) + ) + let badSelection = Selection(source: .foundation(pile: 0), cards: [twoSpades]) + + XCTAssertNil( + AutoMoveAdvisor.bestAdvisableDestination( + for: badSelection, + in: state, + stockDrawCount: DrawMode.three.rawValue + ) + ) + } + + func testBestMoveEvaluationProvidesPositiveMobilityForUsefulMove() { + let sixClubs = TestCards.make(.clubs, .six, isFaceUp: true) + let fiveHearts = TestCards.make(.hearts, .five, isFaceUp: true) + let state = GameState( + stock: [], + waste: [fiveHearts], + wasteDrawCount: 1, + foundations: Array(repeating: [], count: 4), + tableau: [[sixClubs], [], [], [], [], [], []] + ) + let selection = Selection(source: .waste, cards: [fiveHearts]) + + let evaluation = AutoMoveAdvisor.bestMoveEvaluation( + for: selection, + in: state, + stockDrawCount: DrawMode.three.rawValue + ) + XCTAssertNotNil(evaluation) + XCTAssertEqual(evaluation?.destination, .tableau(0)) + XCTAssertGreaterThanOrEqual(evaluation?.resultingMobility ?? -1, 0) + } +} diff --git a/ComputerSolitaireTests/GamePersistenceStoreTests.swift b/ComputerSolitaireTests/GamePersistenceStoreTests.swift new file mode 100644 index 0000000..5e9e20c --- /dev/null +++ b/ComputerSolitaireTests/GamePersistenceStoreTests.swift @@ -0,0 +1,76 @@ +import SwiftData +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class GamePersistenceStoreTests: XCTestCase { + func testLoadReturnsNilWhenNoSavedRecord() throws { + let context = try makeInMemoryContext() + XCTAssertNil(GamePersistence.load(from: context)) + } + + func testSaveThenLoadRoundTrip() throws { + let context = try makeInMemoryContext() + let state = GameStateFixtures.validPersistenceState() + let payload = SavedGamePayload( + savedAt: DateFixtures.reference, + state: state, + movesCount: 12, + score: 345, + gameStartedAt: DateFixtures.plus(-120), + pauseStartedAt: nil, + hasAppliedTimeBonus: false, + finalElapsedSeconds: nil, + stockDrawCount: DrawMode.three.rawValue, + scoringDrawCount: DrawMode.three.rawValue, + history: [], + redealState: state, + hasStartedTrackedGame: true, + isCurrentGameFinalized: false, + hintRequestsInCurrentGame: 1, + undosUsedInCurrentGame: 2, + usedRedealInCurrentGame: false + ) + + try GamePersistence.save(payload, in: context) + let loaded = GamePersistence.load(from: context) + + XCTAssertNotNil(loaded) + XCTAssertEqual(loaded?.movesCount, 12) + XCTAssertEqual(loaded?.score, 345) + XCTAssertEqual(loaded?.state, state) + } + + func testSaveOverwritesExistingRecord() throws { + let context = try makeInMemoryContext() + let state = GameStateFixtures.validPersistenceState() + let first = SavedGamePayload(state: state, movesCount: 1, score: 10, stockDrawCount: DrawMode.three.rawValue, history: []) + let second = SavedGamePayload(state: state, movesCount: 9, score: 90, stockDrawCount: DrawMode.one.rawValue, history: []) + + try GamePersistence.save(first, in: context) + try GamePersistence.save(second, in: context) + + let loaded = GamePersistence.load(from: context) + XCTAssertEqual(loaded?.movesCount, 9) + XCTAssertEqual(loaded?.score, 90) + XCTAssertEqual(loaded?.stockDrawCount, DrawMode.one.rawValue) + } + + func testSaveThrowsForInvalidPayload() throws { + let context = try makeInMemoryContext() + let invalid = SavedGamePayload( + state: GameStateFixtures.emptyBoard(), + movesCount: 0, + stockDrawCount: DrawMode.three.rawValue, + history: [] + ) + + XCTAssertThrowsError(try GamePersistence.save(invalid, in: context)) + } + + private func makeInMemoryContext() throws -> ModelContext { + let configuration = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try ModelContainer(for: SavedGameRecord.self, configurations: configuration) + return ModelContext(container) + } +} diff --git a/ComputerSolitaireTests/GameRulesTests.swift b/ComputerSolitaireTests/GameRulesTests.swift new file mode 100644 index 0000000..6dcda6d --- /dev/null +++ b/ComputerSolitaireTests/GameRulesTests.swift @@ -0,0 +1,95 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class GameRulesTests: XCTestCase { + func testCanMoveToFoundationRequiresAceOnEmptyFoundation() { + XCTAssertTrue( + GameRules.canMoveToFoundation( + card: TestCards.make(.spades, .ace), + foundation: [] + ) + ) + XCTAssertFalse( + GameRules.canMoveToFoundation( + card: TestCards.make(.spades, .two), + foundation: [] + ) + ) + } + + func testCanMoveToFoundationRequiresSameSuitAndAscendingRank() { + let foundation = [ + TestCards.make(.hearts, .ace), + TestCards.make(.hearts, .two), + TestCards.make(.hearts, .three) + ] + + XCTAssertTrue( + GameRules.canMoveToFoundation( + card: TestCards.make(.hearts, .four), + foundation: foundation + ) + ) + XCTAssertFalse( + GameRules.canMoveToFoundation( + card: TestCards.make(.spades, .four), + foundation: foundation + ) + ) + XCTAssertFalse( + GameRules.canMoveToFoundation( + card: TestCards.make(.hearts, .five), + foundation: foundation + ) + ) + } + + func testCanMoveToTableauRequiresKingOnEmptyPile() { + XCTAssertTrue( + GameRules.canMoveToTableau( + card: TestCards.make(.clubs, .king), + destinationPile: [] + ) + ) + XCTAssertFalse( + GameRules.canMoveToTableau( + card: TestCards.make(.clubs, .queen), + destinationPile: [] + ) + ) + } + + func testCanMoveToTableauRequiresAlternatingColorAndDescendingRank() { + let destination = [TestCards.make(.spades, .seven, isFaceUp: true)] + + XCTAssertTrue( + GameRules.canMoveToTableau( + card: TestCards.make(.hearts, .six), + destinationPile: destination + ) + ) + XCTAssertFalse( + GameRules.canMoveToTableau( + card: TestCards.make(.clubs, .six), + destinationPile: destination + ) + ) + XCTAssertFalse( + GameRules.canMoveToTableau( + card: TestCards.make(.hearts, .five), + destinationPile: destination + ) + ) + } + + func testCanMoveToTableauRejectsFaceDownDestinationTopCard() { + let destination = [TestCards.make(.spades, .seven, isFaceUp: false)] + XCTAssertFalse( + GameRules.canMoveToTableau( + card: TestCards.make(.hearts, .six), + destinationPile: destination + ) + ) + } +} diff --git a/ComputerSolitaireTests/GameStatisticsStoreTests.swift b/ComputerSolitaireTests/GameStatisticsStoreTests.swift new file mode 100644 index 0000000..25ea457 --- /dev/null +++ b/ComputerSolitaireTests/GameStatisticsStoreTests.swift @@ -0,0 +1,108 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class GameStatisticsStoreTests: XCTestCase { + func testRecordCompletedGameUpdatesBestTimeAndHighScoreByDrawMode() { + var stats = GameStatistics() + + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 200, + finalScore: 300, + drawCount: DrawMode.three.rawValue, + hintsUsedInGame: 0, + undosUsedInGame: 0, + usedRedealInGame: false + ) + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 150, + finalScore: 250, + drawCount: DrawMode.one.rawValue, + hintsUsedInGame: 1, + undosUsedInGame: 0, + usedRedealInGame: false + ) + + XCTAssertEqual(stats.gamesPlayed, 2) + XCTAssertEqual(stats.gamesWon, 2) + XCTAssertEqual(stats.bestTimeSeconds, 150) + XCTAssertEqual(stats.highScoreDrawThree, 300) + XCTAssertEqual(stats.highScoreDrawOne, 250) + XCTAssertEqual(stats.cleanWins, 1) + } + + func testRecordCompletedGameUsesOverflowSafeCounters() { + var stats = GameStatistics( + gamesPlayed: Int.max, + gamesWon: Int.max, + totalTimeSeconds: Int.max, + cleanWins: Int.max + ) + + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: Int.max, + finalScore: 100, + drawCount: DrawMode.three.rawValue, + hintsUsedInGame: 0, + undosUsedInGame: 0, + usedRedealInGame: false + ) + + XCTAssertEqual(stats.gamesPlayed, Int.max) + XCTAssertEqual(stats.gamesWon, Int.max) + XCTAssertEqual(stats.totalTimeSeconds, Int.max) + XCTAssertEqual(stats.cleanWins, Int.max) + } + + func testStatisticsStoreMarkTrackingStartedAndReset() { + let defaults = makeIsolatedDefaults() + defer { defaults.removePersistentDomain(forName: defaultsSuiteName) } + + GameStatisticsStore.markTrackingStarted(userDefaults: defaults, at: DateFixtures.reference) + let marked = GameStatisticsStore.load(userDefaults: defaults) + XCTAssertEqual(marked.trackedSince, DateFixtures.reference) + + GameStatisticsStore.markTrackingStarted(userDefaults: defaults, at: DateFixtures.plus(60)) + let notOverwritten = GameStatisticsStore.load(userDefaults: defaults) + XCTAssertEqual(notOverwritten.trackedSince, DateFixtures.reference) + + GameStatisticsStore.reset(userDefaults: defaults, at: DateFixtures.plus(120)) + let reset = GameStatisticsStore.load(userDefaults: defaults) + XCTAssertEqual(reset.trackedSince, DateFixtures.plus(120)) + XCTAssertEqual(reset.gamesPlayed, 0) + XCTAssertEqual(reset.gamesWon, 0) + } + + func testStatisticsStoreUpdatePersistsMutation() { + let defaults = makeIsolatedDefaults() + defer { defaults.removePersistentDomain(forName: defaultsSuiteName) } + + GameStatisticsStore.update(userDefaults: defaults) { stats in + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 123, + finalScore: 456, + drawCount: DrawMode.three.rawValue, + hintsUsedInGame: 0, + undosUsedInGame: 0, + usedRedealInGame: false + ) + } + + let loaded = GameStatisticsStore.load(userDefaults: defaults) + XCTAssertEqual(loaded.gamesPlayed, 1) + XCTAssertEqual(loaded.gamesWon, 1) + XCTAssertEqual(loaded.bestTimeSeconds, 123) + } + + private let defaultsSuiteName = "ComputerSolitaire.GameStatisticsStoreTests" + + private func makeIsolatedDefaults() -> UserDefaults { + let defaults = UserDefaults(suiteName: defaultsSuiteName)! + defaults.removePersistentDomain(forName: defaultsSuiteName) + return defaults + } +} diff --git a/ComputerSolitaireTests/HintAdvisorCoverageTests.swift b/ComputerSolitaireTests/HintAdvisorCoverageTests.swift new file mode 100644 index 0000000..2b67476 --- /dev/null +++ b/ComputerSolitaireTests/HintAdvisorCoverageTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class HintAdvisorCoverageTests: XCTestCase { + func testBestHintPrefersMoveWhenAvailable() { + let aceSpades = TestCards.make(.spades, .ace, isFaceUp: true) + let state = GameState( + stock: [], + waste: [aceSpades], + wasteDrawCount: 1, + foundations: Array(repeating: [], count: 4), + tableau: Array(repeating: [], count: 7) + ) + + let hint = HintAdvisor.bestHint(in: state, stockDrawCount: DrawMode.three.rawValue) + guard case .move(let move)? = hint else { + return XCTFail("Expected move hint") + } + XCTAssertEqual(move.selection.source, .waste) + XCTAssertEqual(move.destination, .foundation(0)) + } + + func testBestHintReturnsStockTapWhenFutureMoveAppearsAfterDraw() { + let fiveHearts = TestCards.make(.hearts, .five, isFaceUp: false) + let sixClubs = TestCards.make(.clubs, .six, isFaceUp: true) + let state = GameState( + stock: [fiveHearts], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: [[sixClubs], [], [], [], [], [], []] + ) + + let hint = HintAdvisor.bestHint(in: state, stockDrawCount: DrawMode.three.rawValue) + XCTAssertEqual(hint, .stockTap) + } + + func testBestHintReturnsNilWhenNoMoveAndNoStockCycle() { + let state = GameState( + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: [[TestCards.make(.clubs, .six, isFaceUp: true)], [], [], [], [], [], []] + ) + XCTAssertNil(HintAdvisor.bestHint(in: state, stockDrawCount: DrawMode.three.rawValue)) + } +} diff --git a/ComputerSolitaireTests/MoveEvaluationRankingTests.swift b/ComputerSolitaireTests/MoveEvaluationRankingTests.swift new file mode 100644 index 0000000..2692ff6 --- /dev/null +++ b/ComputerSolitaireTests/MoveEvaluationRankingTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class MoveEvaluationRankingTests: XCTestCase { + func testRankingPrefersRevealBeforeOtherSignals() { + let reveal = evaluation(destination: .tableau(0), revealsFaceDownCard: true) + let noReveal = evaluation( + destination: .foundation(0), + revealsFaceDownCard: false, + foundationProgressDelta: 1, + mobilityDelta: 10 + ) + + XCTAssertTrue(MoveEvaluationRanking.isBetter(reveal, than: noReveal)) + XCTAssertFalse(MoveEvaluationRanking.isBetter(noReveal, than: reveal)) + } + + func testRankingThenPrefersFoundationProgressAndMobility() { + let betterFoundation = evaluation(destination: .foundation(0), foundationProgressDelta: 2) + let weakerFoundation = evaluation(destination: .foundation(1), foundationProgressDelta: 1) + XCTAssertTrue(MoveEvaluationRanking.isBetter(betterFoundation, than: weakerFoundation)) + + let betterMobility = evaluation(destination: .tableau(0), mobilityDelta: 2) + let weakerMobility = evaluation(destination: .tableau(1), mobilityDelta: 1) + XCTAssertTrue(MoveEvaluationRanking.isBetter(betterMobility, than: weakerMobility)) + } + + func testRankingFallsBackToDeterministicDestinationOrder() { + let foundation0 = evaluation(destination: .foundation(0)) + let foundation1 = evaluation(destination: .foundation(1)) + let tableau0 = evaluation(destination: .tableau(0)) + + XCTAssertTrue(MoveEvaluationRanking.isBetter(foundation0, than: foundation1)) + XCTAssertTrue(MoveEvaluationRanking.isBetter(foundation1, than: tableau0)) + XCTAssertFalse(MoveEvaluationRanking.isBetter(tableau0, than: foundation0)) + } + + private func evaluation( + destination: Destination, + revealsFaceDownCard: Bool = false, + clearsSourcePile: Bool = false, + emptyTableauDelta: Int = 0, + foundationProgressDelta: Int = 0, + mobilityDelta: Int = 0, + resultingMobility: Int = 0, + destinationPriority: Int = 0 + ) -> MoveEvaluation { + MoveEvaluation( + destination: destination, + revealsFaceDownCard: revealsFaceDownCard, + clearsSourcePile: clearsSourcePile, + emptyTableauDelta: emptyTableauDelta, + foundationProgressDelta: foundationProgressDelta, + mobilityDelta: mobilityDelta, + resultingMobility: resultingMobility, + destinationPriority: destinationPriority + ) + } +} diff --git a/ComputerSolitaireTests/SavedGamePayloadSanitizationTests.swift b/ComputerSolitaireTests/SavedGamePayloadSanitizationTests.swift new file mode 100644 index 0000000..b0ad578 --- /dev/null +++ b/ComputerSolitaireTests/SavedGamePayloadSanitizationTests.swift @@ -0,0 +1,87 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class SavedGamePayloadSanitizationTests: XCTestCase { + func testSanitizedForRestoreRejectsUnsupportedSchema() { + let payload = makePayload(schemaVersion: 999, state: GameStateFixtures.validPersistenceState()) + XCTAssertNil(payload.sanitizedForRestore()) + } + + func testSanitizedForRestoreRejectsInvalidStateShape() { + let payload = makePayload(state: GameStateFixtures.emptyBoard()) + XCTAssertNil(payload.sanitizedForRestore()) + } + + func testSanitizedForRestoreClampsDrawModesCountsAndHistory() { + let validState = GameStateFixtures.validPersistenceState() + let validSnapshot = GameSnapshot( + state: validState, + movesCount: 2, + score: -50, + hasAppliedTimeBonus: false, + undoContext: nil + ) + + let payload = SavedGamePayload( + savedAt: Date(), + state: validState, + movesCount: -7, + score: -99, + gameStartedAt: Date(), + pauseStartedAt: nil, + hasAppliedTimeBonus: false, + finalElapsedSeconds: -40, + stockDrawCount: 999, + scoringDrawCount: -1, + history: [validSnapshot], + redealState: validState, + hasStartedTrackedGame: false, + isCurrentGameFinalized: true, + hintRequestsInCurrentGame: -3, + undosUsedInCurrentGame: -5, + usedRedealInCurrentGame: true + ) + + let sanitized = payload.sanitizedForRestore() + XCTAssertNotNil(sanitized) + XCTAssertEqual(sanitized?.stockDrawCount, DrawMode.three.rawValue) + XCTAssertEqual(sanitized?.scoringDrawCount, DrawMode.three.rawValue) + XCTAssertEqual(sanitized?.movesCount, 0) + XCTAssertEqual(sanitized?.score, 0) + XCTAssertLessThanOrEqual(sanitized?.state.wasteDrawCount ?? 0, sanitized?.state.waste.count ?? 0) + XCTAssertEqual(sanitized?.history.count, 1) + XCTAssertTrue((sanitized?.history.allSatisfy { $0.score >= 0 }) ?? false) + XCTAssertNotNil(sanitized?.redealState) + XCTAssertFalse(sanitized?.isCurrentGameFinalized ?? true) + XCTAssertEqual(sanitized?.hintRequestsInCurrentGame, 0) + XCTAssertEqual(sanitized?.undosUsedInCurrentGame, 0) + XCTAssertFalse(sanitized?.usedRedealInCurrentGame ?? true) + } + + private func makePayload( + schemaVersion: Int = SavedGamePayload.currentSchemaVersion, + state: GameState + ) -> SavedGamePayload { + SavedGamePayload( + schemaVersion: schemaVersion, + savedAt: DateFixtures.reference, + state: state, + movesCount: 0, + score: 0, + gameStartedAt: DateFixtures.reference, + pauseStartedAt: nil, + hasAppliedTimeBonus: false, + finalElapsedSeconds: nil, + stockDrawCount: DrawMode.three.rawValue, + scoringDrawCount: DrawMode.three.rawValue, + history: [], + redealState: state, + hasStartedTrackedGame: true, + isCurrentGameFinalized: false, + hintRequestsInCurrentGame: 0, + undosUsedInCurrentGame: 0, + usedRedealInCurrentGame: false + ) + } +} diff --git a/ComputerSolitaireTests/ScoringTests.swift b/ComputerSolitaireTests/ScoringTests.swift new file mode 100644 index 0000000..f7d0497 --- /dev/null +++ b/ComputerSolitaireTests/ScoringTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class ScoringTests: XCTestCase { + func testScoringDeltaValuesMatchRules() { + XCTAssertEqual(Scoring.delta(for: .wasteToTableau), 5) + XCTAssertEqual(Scoring.delta(for: .wasteToFoundation), 10) + XCTAssertEqual(Scoring.delta(for: .tableauToFoundation), 10) + XCTAssertEqual(Scoring.delta(for: .turnOverTableauCard), 5) + XCTAssertEqual(Scoring.delta(for: .foundationToTableau), -15) + XCTAssertEqual(Scoring.delta(for: .recycleWasteInDrawOne), -100) + } + + func testApplyingScoreClampsAtMinimumZero() { + XCTAssertEqual(Scoring.applying(.foundationToTableau, to: 10), 0) + XCTAssertEqual(Scoring.applying(.recycleWasteInDrawOne, to: 99), 0) + XCTAssertEqual(Scoring.applying(.wasteToFoundation, to: 0), 10) + } + + func testTimeBonusUsesConfiguredLossRate() { + XCTAssertEqual( + Scoring.timeBonus(elapsedSeconds: 10, maxBonus: 100, pointsLostPerSecond: 2), + 80 + ) + } + + func testTimeBonusHandlesBoundaryInputs() { + XCTAssertEqual(Scoring.timeBonus(elapsedSeconds: -1, maxBonus: 100), 100) + XCTAssertEqual(Scoring.timeBonus(elapsedSeconds: 0, maxBonus: 100), 100) + XCTAssertEqual(Scoring.timeBonus(elapsedSeconds: 1_000, maxBonus: 100), 0) + XCTAssertEqual(Scoring.timeBonus(elapsedSeconds: 100, maxBonus: -5), 0) + XCTAssertEqual(Scoring.timeBonus(elapsedSeconds: 100, maxBonus: 40, pointsLostPerSecond: 0), 40) + } + + func testTimedMaxBonusUsesDrawMode() { + XCTAssertEqual(Scoring.timedMaxBonus(for: DrawMode.one.rawValue), Scoring.timedMaxBonusDrawOne) + XCTAssertEqual(Scoring.timedMaxBonus(for: DrawMode.three.rawValue), Scoring.timedMaxBonusDrawThree) + XCTAssertEqual(Scoring.timedMaxBonus(for: 999), Scoring.timedMaxBonusDrawThree) + } +} diff --git a/ComputerSolitaireTests/SolitaireViewModelCoreTests.swift b/ComputerSolitaireTests/SolitaireViewModelCoreTests.swift new file mode 100644 index 0000000..c6ffd1a --- /dev/null +++ b/ComputerSolitaireTests/SolitaireViewModelCoreTests.swift @@ -0,0 +1,224 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class SolitaireViewModelCoreTests: XCTestCase { + private static var retainedViewModels: [SolitaireViewModel] = [] + + func testNewGameResetsCoreStateAndAppliesDrawMode() { + let viewModel = makeViewModel() + viewModel.newGame(drawMode: .one) + + XCTAssertEqual(viewModel.movesCount, 0) + XCTAssertEqual(viewModel.score, 0) + XCTAssertNil(viewModel.selection) + XCTAssertFalse(viewModel.isDragging) + XCTAssertNil(viewModel.pendingAutoMove) + XCTAssertEqual(viewModel.stockDrawCount, DrawMode.one.rawValue) + XCTAssertEqual(viewModel.visibleWasteCards().count, 0) + XCTAssertTrue(viewModel.isClockAdvancing) + } + + func testHandleStockTapDrawsCardsFromStock() { + let state = makeValidState( + stock: [ + TestCards.make(.clubs, .ace, isFaceUp: false), + TestCards.make(.diamonds, .ace, isFaceUp: false), + TestCards.make(.hearts, .ace, isFaceUp: false) + ], + waste: [], + foundations: Array(repeating: [], count: 4), + tableau: Array(repeating: [], count: 7) + ) + let viewModel = makeViewModel(restoring: payload(state: state, stockDrawCount: DrawMode.three.rawValue)) + + let stockBefore = viewModel.state.stock.count + viewModel.handleStockTap() + + XCTAssertEqual(viewModel.state.stock.count, stockBefore - 3) + XCTAssertEqual(viewModel.state.wasteDrawCount, 3) + XCTAssertGreaterThanOrEqual(viewModel.state.waste.count, 3) + XCTAssertTrue(viewModel.visibleWasteCards().allSatisfy(\.isFaceUp)) + XCTAssertEqual(viewModel.state.wasteDrawCount, 3) + XCTAssertEqual(viewModel.movesCount, 1) + XCTAssertEqual(viewModel.visibleWasteCards().count, min(3, viewModel.state.waste.count)) + } + + func testHandleStockTapRecyclesWasteWhenStockEmpty() { + var state = GameStateFixtures.validPersistenceState() + state.waste = state.stock.map { card in + var faceUp = card + faceUp.isFaceUp = true + return faceUp + } + state.stock = [] + state.wasteDrawCount = min(3, state.waste.count) + let viewModel = makeViewModel(restoring: payload(state: state, stockDrawCount: DrawMode.one.rawValue)) + + viewModel.handleStockTap() + + XCTAssertGreaterThan(viewModel.state.stock.count, 0) + XCTAssertEqual(viewModel.state.waste.count, 0) + XCTAssertEqual(viewModel.state.wasteDrawCount, 0) + XCTAssertEqual(viewModel.movesCount, 1) + XCTAssertEqual(viewModel.score, 0, "Recycle in draw-one clamps at minimum score") + XCTAssertTrue(viewModel.state.stock.allSatisfy { !$0.isFaceUp }) + } + + func testStartDragCanDropAndHandleDropMoveWasteToFoundation() { + let aceSpades = TestCards.make(.spades, .ace, isFaceUp: true) + let state = makeValidState( + stock: [], + waste: [aceSpades], + foundations: Array(repeating: [], count: 4), + tableau: Array(repeating: [], count: 7), + wasteDrawCount: 1 + ) + let viewModel = makeViewModel(restoring: payload(state: state, stockDrawCount: DrawMode.three.rawValue)) + + XCTAssertTrue(viewModel.startDragFromWaste()) + XCTAssertTrue(viewModel.canDrop(to: .foundation(0))) + XCTAssertTrue(viewModel.handleDrop(to: .foundation(0))) + + XCTAssertEqual(viewModel.state.waste.count, 0) + XCTAssertEqual(viewModel.state.foundations[0].last?.id, aceSpades.id) + XCTAssertEqual(viewModel.movesCount, 1) + XCTAssertEqual(viewModel.score, Scoring.delta(for: .wasteToFoundation)) + XCTAssertNil(viewModel.selection) + XCTAssertFalse(viewModel.isDragging) + } + + func testUndoRestoresPriorSnapshotAfterMove() { + let aceSpades = TestCards.make(.spades, .ace, isFaceUp: true) + let state = makeValidState( + stock: [], + waste: [aceSpades], + foundations: Array(repeating: [], count: 4), + tableau: Array(repeating: [], count: 7), + wasteDrawCount: 1 + ) + let viewModel = makeViewModel(restoring: payload(state: state, stockDrawCount: DrawMode.three.rawValue)) + + XCTAssertTrue(viewModel.startDragFromWaste()) + XCTAssertTrue(viewModel.handleDrop(to: .foundation(0))) + XCTAssertEqual(viewModel.movesCount, 1) + + viewModel.undo() + + XCTAssertEqual(viewModel.movesCount, 0) + XCTAssertEqual(viewModel.score, 0) + XCTAssertEqual(viewModel.state.waste.last?.id, aceSpades.id) + XCTAssertTrue(viewModel.state.foundations[0].isEmpty) + } + + func testPauseResumeAndElapsedTimeAccounting() { + let viewModel = makeViewModel() + viewModel.newGame(drawMode: .three) + + let start = DateFixtures.reference + let restore = payload( + state: viewModel.state, + savedAt: DateFixtures.plus(260), + stockDrawCount: DrawMode.three.rawValue, + gameStartedAt: start, + pauseStartedAt: nil + ) + XCTAssertTrue(viewModel.restore(from: restore)) + + let firstElapsed = viewModel.elapsedActiveSeconds(at: DateFixtures.plus(50)) + XCTAssertTrue(viewModel.pauseTimeScoring(at: DateFixtures.plus(100))) + let pausedElapsed = viewModel.elapsedActiveSeconds(at: DateFixtures.plus(180)) + XCTAssertTrue(viewModel.resumeTimeScoring(at: DateFixtures.plus(200))) + let resumedElapsed = viewModel.elapsedActiveSeconds(at: DateFixtures.plus(260)) + XCTAssertGreaterThanOrEqual(firstElapsed, 0) + XCTAssertEqual(pausedElapsed, viewModel.elapsedActiveSeconds(at: DateFixtures.plus(190))) + XCTAssertGreaterThanOrEqual(resumedElapsed, pausedElapsed) + XCTAssertGreaterThanOrEqual(viewModel.displayScore(at: DateFixtures.plus(260)), 0) + } + + func testQueueNextAutoFinishMoveSetsPendingMove() { + let viewModel = makeViewModel( + restoring: payload( + state: GameStateFixtures.almostWonForAutoFinish(), + stockDrawCount: DrawMode.three.rawValue + ) + ) + + XCTAssertTrue(viewModel.isAutoFinishAvailable) + XCTAssertTrue(viewModel.queueNextAutoFinishMove()) + XCTAssertNotNil(viewModel.pendingAutoMove) + } + + private func makeViewModel(restoring payload: SavedGamePayload? = nil) -> SolitaireViewModel { + let viewModel = SolitaireViewModel() + if let payload { + XCTAssertTrue(viewModel.restore(from: payload)) + } + Self.retainedViewModels.append(viewModel) + return viewModel + } + + private func payload( + state: GameState, + savedAt: Date = DateFixtures.reference, + stockDrawCount: Int, + gameStartedAt: Date = DateFixtures.reference, + pauseStartedAt: Date? = nil + ) -> SavedGamePayload { + SavedGamePayload( + savedAt: savedAt, + state: state, + movesCount: 0, + score: 0, + gameStartedAt: gameStartedAt, + pauseStartedAt: pauseStartedAt, + hasAppliedTimeBonus: false, + finalElapsedSeconds: nil, + stockDrawCount: stockDrawCount, + scoringDrawCount: stockDrawCount, + history: [], + redealState: state, + hasStartedTrackedGame: true, + isCurrentGameFinalized: false, + hintRequestsInCurrentGame: 0, + undosUsedInCurrentGame: 0, + usedRedealInCurrentGame: false + ) + } + + private func makeValidState( + stock: [Card], + waste: [Card], + foundations: [[Card]], + tableau: [[Card]], + wasteDrawCount: Int = 0 + ) -> GameState { + var usedBySuitRank = Set(stock.map { "\($0.suit)-\($0.rank.rawValue)" }) + for card in waste { + usedBySuitRank.insert("\(card.suit)-\(card.rank.rawValue)") + } + for pile in foundations { + for card in pile { + usedBySuitRank.insert("\(card.suit)-\(card.rank.rawValue)") + } + } + for pile in tableau { + for card in pile { + usedBySuitRank.insert("\(card.suit)-\(card.rank.rawValue)") + } + } + + let filler = TestCards.fullDeck(faceUp: false).filter { + !usedBySuitRank.contains("\($0.suit)-\($0.rank.rawValue)") + } + let finalStock = stock + filler + + return GameState( + stock: finalStock, + waste: waste, + wasteDrawCount: min(max(0, wasteDrawCount), waste.count), + foundations: foundations, + tableau: tableau + ) + } +} diff --git a/ComputerSolitaireTests/TestSupport.swift b/ComputerSolitaireTests/TestSupport.swift new file mode 100644 index 0000000..df2492b --- /dev/null +++ b/ComputerSolitaireTests/TestSupport.swift @@ -0,0 +1,115 @@ +import Foundation +import XCTest +@testable import Computer_Solitaire + +@MainActor +enum TestCards { + static func make( + _ suit: Suit, + _ rank: Rank, + isFaceUp: Bool = true, + id: UUID = UUID() + ) -> Card { + Card(id: id, suit: suit, rank: rank, isFaceUp: isFaceUp) + } + + static func fullDeck(faceUp: Bool = false) -> [Card] { + Suit.allCases.flatMap { suit in + Rank.allCases.map { rank in + Card(suit: suit, rank: rank, isFaceUp: faceUp) + } + } + } +} + +@MainActor +enum GameStateFixtures { + static func emptyBoard() -> GameState { + GameState( + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: Array(repeating: [], count: 7) + ) + } + + static func validPersistenceState( + stockDrawCount: Int = DrawMode.three.rawValue, + wasteDrawCount: Int = 0 + ) -> GameState { + var deck = TestCards.fullDeck(faceUp: false) + let stock = Array(deck.prefix(24)) + deck.removeFirst(24) + + var tableau: [[Card]] = Array(repeating: [], count: 7) + var index = 0 + for pileIndex in 0..<7 { + let count = pileIndex + 1 + var pile = Array(deck[index..<(index + count)]) + index += count + for cardIndex in pile.indices { + pile[cardIndex].isFaceUp = cardIndex == pile.count - 1 + } + tableau[pileIndex] = pile + } + + return GameState( + stock: stock, + waste: [], + wasteDrawCount: min(max(0, wasteDrawCount), 0), + foundations: Array(repeating: [], count: 4), + tableau: tableau + ) + } + + static func almostWonForAutoFinish() -> GameState { + var foundations = Array(repeating: [Card](), count: 4) + for (foundationIndex, suit) in Suit.allCases.enumerated() { + foundations[foundationIndex] = Rank.allCases + .filter { $0 != .king } + .map { rank in + TestCards.make(suit, rank, isFaceUp: true) + } + } + + return GameState( + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: foundations, + tableau: [ + [TestCards.make(.spades, .king, isFaceUp: true)], + [TestCards.make(.hearts, .king, isFaceUp: true)], + [TestCards.make(.diamonds, .king, isFaceUp: true)], + [TestCards.make(.clubs, .king, isFaceUp: true)], + [], + [], + [] + ] + ) + } +} + +@MainActor +enum DateFixtures { + static let reference = Date(timeIntervalSince1970: 1_700_000_000) + + static func plus(_ seconds: TimeInterval, from date: Date = reference) -> Date { + date.addingTimeInterval(seconds) + } +} + +@MainActor +enum TestAssertions { + static func assertSingleVisibleWasteCard( + _ viewModel: SolitaireViewModel, + expected: Card, + file: StaticString = #filePath, + line: UInt = #line + ) { + let visible = viewModel.visibleWasteCards() + XCTAssertEqual(visible.count, 1, file: file, line: line) + XCTAssertEqual(visible.first?.id, expected.id, file: file, line: line) + } +} From bc101c2c5d4aee9f2c976438c04ecc7f14dccba6 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 23 Feb 2026 00:54:46 -0800 Subject: [PATCH 2/4] Add comprehensive unit tests --- ComputerSolitaireTests/TestSupport.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/ComputerSolitaireTests/TestSupport.swift b/ComputerSolitaireTests/TestSupport.swift index df2492b..32dcbff 100644 --- a/ComputerSolitaireTests/TestSupport.swift +++ b/ComputerSolitaireTests/TestSupport.swift @@ -113,3 +113,4 @@ enum TestAssertions { XCTAssertEqual(visible.first?.id, expected.id, file: file, line: line) } } + From a0b2aa3186c94985018f379510e3a182cf375ceb Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 23 Feb 2026 00:54:51 -0800 Subject: [PATCH 3/4] Add comprehensive unit tests --- ComputerSolitaireTests/TestSupport.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/ComputerSolitaireTests/TestSupport.swift b/ComputerSolitaireTests/TestSupport.swift index 32dcbff..df2492b 100644 --- a/ComputerSolitaireTests/TestSupport.swift +++ b/ComputerSolitaireTests/TestSupport.swift @@ -113,4 +113,3 @@ enum TestAssertions { XCTAssertEqual(visible.first?.id, expected.id, file: file, line: line) } } - From 00d77d94fa666d1ba676375816d9adc12f10be0e Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 23 Feb 2026 01:08:03 -0800 Subject: [PATCH 4/4] Make time source injectable for deterministic tests Introduce a DateProviding protocol with SystemDateProvider and TestDateProvider, and inject it into SolitaireViewModel so all time reads use dateProvider.now. Add sanitizedForRestore(at:) to SavedGamePayload and thread an explicit now through GamePersistence.load/save to make persistence timestamp sanitization deterministic. Update SavedGameRecord insertion/updatedAt to use the provided now. Adjust unit tests to use TestDateProvider and update time-based assertions accordingly. --- ComputerSolitaire/Game/GamePersistence.swift | 22 +++++++------ ComputerSolitaire/Game/GameSession.swift | 31 ++++++++++++------ .../SolitaireViewModelCoreTests.swift | 32 +++++++++++-------- ComputerSolitaireTests/TestSupport.swift | 9 ++++++ 4 files changed, 63 insertions(+), 31 deletions(-) diff --git a/ComputerSolitaire/Game/GamePersistence.swift b/ComputerSolitaire/Game/GamePersistence.swift index e63a2b7..1dbc8b6 100644 --- a/ComputerSolitaire/Game/GamePersistence.swift +++ b/ComputerSolitaire/Game/GamePersistence.swift @@ -132,17 +132,21 @@ struct SavedGamePayload: Codable { } func sanitizedForRestore() -> SavedGamePayload? { + sanitizedForRestore(at: .now) + } + + func sanitizedForRestore(at now: Date) -> SavedGamePayload? { guard schemaVersion == Self.currentSchemaVersion else { return nil } guard state.isValidForPersistence else { return nil } let sanitizedStockDrawCount = DrawMode(rawValue: stockDrawCount)?.rawValue ?? DrawMode.three.rawValue let sanitizedMovesCount = max(0, movesCount) let sanitizedScore = Scoring.clamped(score) - let sanitizedSavedAt = min(savedAt, .now) - let sanitizedStartedAt = min(gameStartedAt, .now) + let sanitizedSavedAt = min(savedAt, now) + let sanitizedStartedAt = min(gameStartedAt, now) let sanitizedScoringDrawCount = DrawMode(rawValue: scoringDrawCount)?.rawValue ?? sanitizedStockDrawCount let sanitizedPauseStartedAt = pauseStartedAt - .map { min($0, .now) } + .map { min($0, now) } .flatMap { $0 >= sanitizedStartedAt ? $0 : nil } let sanitizedFinalElapsedSeconds: Int? = { guard hasAppliedTimeBonus else { return nil } @@ -209,27 +213,27 @@ enum GamePersistenceError: Error { } enum GamePersistence { - static func load(from modelContext: ModelContext) -> SavedGamePayload? { + static func load(from modelContext: ModelContext, now: Date = .now) -> SavedGamePayload? { do { guard let record = try fetchCurrentRecord(in: modelContext) else { return nil } let payload = try JSONDecoder().decode(SavedGamePayload.self, from: record.snapshotData) - return payload.sanitizedForRestore() + return payload.sanitizedForRestore(at: now) } catch { return nil } } - static func save(_ payload: SavedGamePayload, in modelContext: ModelContext) throws { - guard let sanitizedPayload = payload.sanitizedForRestore() else { + static func save(_ payload: SavedGamePayload, in modelContext: ModelContext, now: Date = .now) throws { + guard let sanitizedPayload = payload.sanitizedForRestore(at: now) else { throw GamePersistenceError.invalidPayload } let data = try JSONEncoder().encode(sanitizedPayload) if let record = try fetchCurrentRecord(in: modelContext) { record.snapshotData = data - record.updatedAt = .now + record.updatedAt = now } else { - modelContext.insert(SavedGameRecord(snapshotData: data)) + modelContext.insert(SavedGameRecord(snapshotData: data, updatedAt: now)) } try modelContext.save() } diff --git a/ComputerSolitaire/Game/GameSession.swift b/ComputerSolitaire/Game/GameSession.swift index f30fcf3..4d148ce 100644 --- a/ComputerSolitaire/Game/GameSession.swift +++ b/ComputerSolitaire/Game/GameSession.swift @@ -1,6 +1,16 @@ import Foundation import Observation +protocol DateProviding { + var now: Date { get } +} + +struct SystemDateProvider: DateProviding { + var now: Date { + Date() + } +} + @Observable final class SolitaireViewModel { static let maxUndoHistoryCount = 200 @@ -34,6 +44,7 @@ final class SolitaireViewModel { private var hintRequestsInCurrentGame: Int = 0 private var undosUsedInCurrentGame: Int = 0 private var usedRedealInCurrentGame = false + private let dateProvider: any DateProviding private var history: [GameSnapshot] = [] @@ -43,8 +54,9 @@ final class SolitaireViewModel { let destination: Destination } - init() { - let startedAt = Date() + init(dateProvider: any DateProviding = SystemDateProvider()) { + self.dateProvider = dateProvider + let startedAt = dateProvider.now let initialState = GameState.newGame() state = initialState isAutoFinishAvailable = AutoFinishPlanner.canAutoFinish(in: initialState) @@ -191,7 +203,7 @@ final class SolitaireViewModel { } func newGame(drawMode: DrawMode = .three) { - finalizeCurrentGameIfNeeded(didWin: isWin, endedAt: .now) + finalizeCurrentGameIfNeeded(didWin: isWin, endedAt: dateProvider.now) clearHint() let initialState = GameState.newGame() state = initialState @@ -201,7 +213,7 @@ final class SolitaireViewModel { pendingAutoMove = nil movesCount = 0 score = 0 - gameStartedAt = .now + gameStartedAt = dateProvider.now hasAppliedTimeBonus = false finalElapsedSeconds = nil pauseStartedAt = nil @@ -218,7 +230,7 @@ final class SolitaireViewModel { } func redeal() { - finalizeCurrentGameIfNeeded(didWin: isWin, endedAt: .now) + finalizeCurrentGameIfNeeded(didWin: isWin, endedAt: dateProvider.now) clearHint() state = redealState selection = nil @@ -226,7 +238,7 @@ final class SolitaireViewModel { pendingAutoMove = nil movesCount = 0 score = 0 - gameStartedAt = .now + gameStartedAt = dateProvider.now hasAppliedTimeBonus = false finalElapsedSeconds = nil pauseStartedAt = nil @@ -304,9 +316,10 @@ final class SolitaireViewModel { @discardableResult func restore(from payload: SavedGamePayload) -> Bool { - guard let sanitizedPayload = payload.sanitizedForRestore() else { return false } + let now = dateProvider.now + guard let sanitizedPayload = payload.sanitizedForRestore(at: now) else { return false } clearHint() - let offlineDurationSinceSave = max(0, Date().timeIntervalSince(sanitizedPayload.savedAt)) + let offlineDurationSinceSave = max(0, now.timeIntervalSince(sanitizedPayload.savedAt)) state = sanitizedPayload.state movesCount = sanitizedPayload.movesCount score = sanitizedPayload.score @@ -714,7 +727,7 @@ private extension SolitaireViewModel { func applyTimeBonusIfWon() { guard isWin, !hasAppliedTimeBonus else { return } - let endedAt = Date() + let endedAt = dateProvider.now let elapsedSeconds = elapsedActiveSeconds(at: endedAt) let maxBonus = Scoring.timedMaxBonus(for: scoringDrawCount) let bonus = Scoring.timeBonus( diff --git a/ComputerSolitaireTests/SolitaireViewModelCoreTests.swift b/ComputerSolitaireTests/SolitaireViewModelCoreTests.swift index c6ffd1a..331b299 100644 --- a/ComputerSolitaireTests/SolitaireViewModelCoreTests.swift +++ b/ComputerSolitaireTests/SolitaireViewModelCoreTests.swift @@ -112,28 +112,31 @@ final class SolitaireViewModelCoreTests: XCTestCase { } func testPauseResumeAndElapsedTimeAccounting() { - let viewModel = makeViewModel() + let clock = TestDateProvider(now: DateFixtures.reference) + let viewModel = makeViewModel(dateProvider: clock) viewModel.newGame(drawMode: .three) - let start = DateFixtures.reference + let start = DateFixtures.plus(-600) + clock.now = DateFixtures.plus(120) let restore = payload( state: viewModel.state, - savedAt: DateFixtures.plus(260), + savedAt: DateFixtures.plus(100), stockDrawCount: DrawMode.three.rawValue, gameStartedAt: start, pauseStartedAt: nil ) XCTAssertTrue(viewModel.restore(from: restore)) - let firstElapsed = viewModel.elapsedActiveSeconds(at: DateFixtures.plus(50)) - XCTAssertTrue(viewModel.pauseTimeScoring(at: DateFixtures.plus(100))) - let pausedElapsed = viewModel.elapsedActiveSeconds(at: DateFixtures.plus(180)) + // elapsed at restore should match active play time at save. + // start=-600, savedAt=100 => 700 seconds of active time. + XCTAssertEqual(viewModel.elapsedActiveSeconds(at: clock.now), 700) + + XCTAssertTrue(viewModel.pauseTimeScoring(at: DateFixtures.plus(130))) + XCTAssertEqual(viewModel.elapsedActiveSeconds(at: DateFixtures.plus(180)), 710) + XCTAssertTrue(viewModel.resumeTimeScoring(at: DateFixtures.plus(200))) - let resumedElapsed = viewModel.elapsedActiveSeconds(at: DateFixtures.plus(260)) - XCTAssertGreaterThanOrEqual(firstElapsed, 0) - XCTAssertEqual(pausedElapsed, viewModel.elapsedActiveSeconds(at: DateFixtures.plus(190))) - XCTAssertGreaterThanOrEqual(resumedElapsed, pausedElapsed) - XCTAssertGreaterThanOrEqual(viewModel.displayScore(at: DateFixtures.plus(260)), 0) + XCTAssertEqual(viewModel.elapsedActiveSeconds(at: DateFixtures.plus(260)), 770) + XCTAssertEqual(viewModel.displayScore(at: DateFixtures.plus(260)), Scoring.timedMaxBonusDrawThree - 770) } func testQueueNextAutoFinishMoveSetsPendingMove() { @@ -149,8 +152,11 @@ final class SolitaireViewModelCoreTests: XCTestCase { XCTAssertNotNil(viewModel.pendingAutoMove) } - private func makeViewModel(restoring payload: SavedGamePayload? = nil) -> SolitaireViewModel { - let viewModel = SolitaireViewModel() + private func makeViewModel( + dateProvider: any DateProviding = SystemDateProvider(), + restoring payload: SavedGamePayload? = nil + ) -> SolitaireViewModel { + let viewModel = SolitaireViewModel(dateProvider: dateProvider) if let payload { XCTAssertTrue(viewModel.restore(from: payload)) } diff --git a/ComputerSolitaireTests/TestSupport.swift b/ComputerSolitaireTests/TestSupport.swift index df2492b..fa905a5 100644 --- a/ComputerSolitaireTests/TestSupport.swift +++ b/ComputerSolitaireTests/TestSupport.swift @@ -113,3 +113,12 @@ enum TestAssertions { XCTAssertEqual(visible.first?.id, expected.id, file: file, line: line) } } + +@MainActor +final class TestDateProvider: DateProviding { + var now: Date + + init(now: Date) { + self.now = now + } +}