Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions ComputerSolitaire/Game/GamePersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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()
}
Expand Down
31 changes: 22 additions & 9 deletions ComputerSolitaire/Game/GameSession.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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] = []

Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -201,7 +213,7 @@ final class SolitaireViewModel {
pendingAutoMove = nil
movesCount = 0
score = 0
gameStartedAt = .now
gameStartedAt = dateProvider.now
hasAppliedTimeBonus = false
finalElapsedSeconds = nil
pauseStartedAt = nil
Expand All @@ -218,15 +230,15 @@ final class SolitaireViewModel {
}

func redeal() {
finalizeCurrentGameIfNeeded(didWin: isWin, endedAt: .now)
finalizeCurrentGameIfNeeded(didWin: isWin, endedAt: dateProvider.now)
clearHint()
state = redealState
selection = nil
isDragging = false
pendingAutoMove = nil
movesCount = 0
score = 0
gameStartedAt = .now
gameStartedAt = dateProvider.now
hasAppliedTimeBonus = false
finalElapsedSeconds = nil
pauseStartedAt = nil
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
58 changes: 58 additions & 0 deletions ComputerSolitaireTests/AutoFinishPlannerTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
115 changes: 115 additions & 0 deletions ComputerSolitaireTests/AutoMoveAdvisorCoverageTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading