diff --git a/ComputerSolitaire/Game/Canfield/CanfieldPlanner.swift b/ComputerSolitaire/Game/Canfield/CanfieldPlanner.swift index 820be79..8350bd9 100644 --- a/ComputerSolitaire/Game/Canfield/CanfieldPlanner.swift +++ b/ComputerSolitaire/Game/Canfield/CanfieldPlanner.swift @@ -211,6 +211,11 @@ nonisolated private extension CanfieldPlanner { var best: (index: Int, score: Int, depth: Int)? while let entry = heap.pop() { + if Task.isCancelled { + wasTruncated = true + break + } + let nodeIndex = entry.index let node = nodes[nodeIndex] diff --git a/ComputerSolitaire/Game/FortyThieves/FortyThievesPlanner.swift b/ComputerSolitaire/Game/FortyThieves/FortyThievesPlanner.swift index a30d86f..b8f81aa 100644 --- a/ComputerSolitaire/Game/FortyThieves/FortyThievesPlanner.swift +++ b/ComputerSolitaire/Game/FortyThieves/FortyThievesPlanner.swift @@ -177,6 +177,11 @@ nonisolated private extension FortyThievesPlanner { var best: (index: Int, score: Int, depth: Int)? while let entry = heap.pop() { + if Task.isCancelled { + wasTruncated = true + break + } + let nodeIndex = entry.index let node = nodes[nodeIndex] diff --git a/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift b/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift index 3d15a2e..d6483a1 100644 --- a/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift +++ b/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift @@ -53,6 +53,10 @@ nonisolated enum FreeCellSolver { var expansions = 0 while let entry = heap.pop() { + if Task.isCancelled { + return nil + } + let nodeIndex = entry.index let board = nodes[nodeIndex].board diff --git a/ComputerSolitaire/Game/Golf/GolfPlanner.swift b/ComputerSolitaire/Game/Golf/GolfPlanner.swift index 11d73ce..2dc8abd 100644 --- a/ComputerSolitaire/Game/Golf/GolfPlanner.swift +++ b/ComputerSolitaire/Game/Golf/GolfPlanner.swift @@ -386,6 +386,11 @@ nonisolated private extension GolfPlanner { var best: (index: Int32, remaining: Int, depth: UInt16)? while let nodeIndex = pending.popLast() { + if Task.isCancelled { + wasTruncated = true + break + } + let node = nodes[Int(nodeIndex)] let board = Board(code: node.code) let remaining = board.remainingCount diff --git a/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift b/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift index 7e59b27..1e3e64e 100644 --- a/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift +++ b/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift @@ -39,6 +39,10 @@ nonisolated enum KlondikePlanner { var best: (index: Int, score: Int, depth: Int)? while let entry = heap.pop() { + if Task.isCancelled { + break + } + let nodeIndex = entry.index let node = nodes[nodeIndex] diff --git a/ComputerSolitaire/Game/Pyramid/PyramidPlanner.swift b/ComputerSolitaire/Game/Pyramid/PyramidPlanner.swift index 68a1027..e47f632 100644 --- a/ComputerSolitaire/Game/Pyramid/PyramidPlanner.swift +++ b/ComputerSolitaire/Game/Pyramid/PyramidPlanner.swift @@ -538,6 +538,11 @@ nonisolated private extension PyramidPlanner { var wasTruncated = false while let entry = heap.pop() { + if Task.isCancelled { + wasTruncated = true + break + } + let nodeIndex = entry.index let node = nodes[nodeIndex] @@ -592,6 +597,11 @@ nonisolated private extension PyramidPlanner { var best: (index: Int, remaining: Int, depth: Int)? while let entry = heap.pop() { + if Task.isCancelled { + wasTruncated = true + break + } + let nodeIndex = entry.index let node = nodes[nodeIndex] let remaining = node.board.remainingCount diff --git a/ComputerSolitaire/Game/Scorpion/ScorpionPlanner.swift b/ComputerSolitaire/Game/Scorpion/ScorpionPlanner.swift index a9eb3d2..79b0005 100644 --- a/ComputerSolitaire/Game/Scorpion/ScorpionPlanner.swift +++ b/ComputerSolitaire/Game/Scorpion/ScorpionPlanner.swift @@ -133,6 +133,11 @@ nonisolated private extension ScorpionPlanner { var best: (index: Int, score: Int, depth: Int)? while let entry = heap.pop() { + if Task.isCancelled { + wasTruncated = true + break + } + let nodeIndex = entry.index let node = nodes[nodeIndex] diff --git a/ComputerSolitaire/Game/Shared/GameSession.swift b/ComputerSolitaire/Game/Shared/GameSession.swift index 6aeb843..2fbdcd5 100644 --- a/ComputerSolitaire/Game/Shared/GameSession.swift +++ b/ComputerSolitaire/Game/Shared/GameSession.swift @@ -29,6 +29,7 @@ final class SolitaireViewModel { } private var selectedCardIDs: Set = [] private(set) var activeHint: HintAdvisor.Hint? + private(set) var isSearchingForHint = false private(set) var hintWiggleToken = UUID() private var hintAutoClearToken = UUID() var isDragging: Bool = false @@ -81,7 +82,9 @@ final class SolitaireViewModel { /// Internal so variant session extensions (Golf's hole advance) share the /// injected clock. let dateProvider: any DateProviding - @ObservationIgnored private let hintPlanner = HintPlanner() + @ObservationIgnored private let hintSearch: any HintSearching + @ObservationIgnored private var hintSearchTask: Task? + @ObservationIgnored private var hintSearchID: UUID? private var history: [GameSnapshot] = [] @@ -93,9 +96,11 @@ final class SolitaireViewModel { init( dateProvider: any DateProviding = SystemDateProvider(), - variant: GameVariant = .klondike + variant: GameVariant = .klondike, + hintSearch: any HintSearching = HintSearchWorker() ) { self.dateProvider = dateProvider + self.hintSearch = hintSearch let startedAt = dateProvider.now let initialState = GameState.newGame(variant: variant) state = initialState @@ -107,6 +112,10 @@ final class SolitaireViewModel { GameStatisticsStore.markTrackingStarted(for: gameMode, at: startedAt) } + deinit { + hintSearchTask?.cancel() + } + var gameVariant: GameVariant { state.variant } @@ -179,30 +188,69 @@ final class SolitaireViewModel { pauseStartedAt == nil && finalElapsedSeconds == nil } - func requestHint() { + @discardableResult + func requestHint() -> Task? { guard !isWin else { clearHint() - return + return nil } + guard !isDragging, pendingAutoMove == nil else { return nil } + if let hintSearchTask { return hintSearchTask } - guard let hint = hintPlanner.bestHint(in: state, stockDrawCount: stockDrawCount) else { - clearHint() - // The cheap availability check can't know the planner would come up empty - // (e.g. a dead stock cycle); now that the full search has, keep the button - // honest until the next state change re-evaluates it. - isHintAvailable = false - HapticManager.shared.play(.invalidDrop) - return + clearHint() + let snapshot = state + let drawCount = stockDrawCount + let requestID = UUID() + hintSearchID = requestID + isSearchingForHint = true + let task = Task { [weak self, hintSearch] in + let hint: HintAdvisor.Hint? + do { + hint = try await hintSearch.bestHint(in: snapshot, stockDrawCount: drawCount) + } catch { + self?.finishHintSearch(requestID) + return + } + + guard let self, self.hintSearchID == requestID else { return } + self.finishHintSearch(requestID) + guard !Task.isCancelled, + self.state == snapshot, + self.stockDrawCount == drawCount, + !self.isWin, !self.isDragging, self.pendingAutoMove == nil else { return } + + guard let hint else { + // Only a completed search of the current position may disable hints. + self.isHintAvailable = false + HapticManager.shared.play(.invalidDrop) + return + } + self.activeHint = hint + self.hintWiggleToken = UUID() + self.scheduleHintAutoClear(for: hint) + self.hintRequestsInCurrentGame += 1 + HapticManager.shared.play(.hintFound) } + hintSearchTask = task + return task + } + + private func finishHintSearch(_ requestID: UUID) { + guard hintSearchID == requestID else { return } + hintSearchTask = nil + hintSearchID = nil + isSearchingForHint = false + } - activeHint = hint - hintWiggleToken = UUID() - scheduleHintAutoClear(for: hint) - hintRequestsInCurrentGame += 1 - HapticManager.shared.play(.hintFound) + private func cancelHintSearch() { + hintSearchTask?.cancel() + hintSearchTask = nil + hintSearchID = nil + isSearchingForHint = false } func clearHint() { + cancelHintSearch() hintAutoClearToken = UUID() activeHint = nil } @@ -224,6 +272,7 @@ final class SolitaireViewModel { } func resetStatisticsTracking() { + cancelHintSearch() hasStartedTrackedGame = false isCurrentGameFinalized = true hintRequestsInCurrentGame = 0 @@ -265,6 +314,7 @@ final class SolitaireViewModel { @discardableResult func pauseTimeScoring(at date: Date = .now) -> Bool { + cancelHintSearch() guard !hasAppliedTimeBonus else { return false } guard pauseStartedAt == nil else { return false } pauseStartedAt = date diff --git a/ComputerSolitaire/Game/Shared/HintSearchWorker.swift b/ComputerSolitaire/Game/Shared/HintSearchWorker.swift new file mode 100644 index 0000000..fe32d46 --- /dev/null +++ b/ComputerSolitaire/Game/Shared/HintSearchWorker.swift @@ -0,0 +1,18 @@ +import Foundation + +nonisolated protocol HintSearching: Sendable { + func bestHint(in state: GameState, stockDrawCount: Int) async throws -> HintAdvisor.Hint? +} + +/// Owns one session's mutable plan caches. Searching never suspends inside +/// this actor, so subsequent requests cannot interleave with cache updates. +actor HintSearchWorker: HintSearching { + private let planner = HintPlanner() + + func bestHint(in state: GameState, stockDrawCount: Int) throws -> HintAdvisor.Hint? { + try Task.checkCancellation() + let hint = planner.bestHint(in: state, stockDrawCount: stockDrawCount) + try Task.checkCancellation() + return hint + } +} diff --git a/ComputerSolitaire/Game/Spider/SpiderPlanner.swift b/ComputerSolitaire/Game/Spider/SpiderPlanner.swift index 07259cd..072e963 100644 --- a/ComputerSolitaire/Game/Spider/SpiderPlanner.swift +++ b/ComputerSolitaire/Game/Spider/SpiderPlanner.swift @@ -154,6 +154,11 @@ nonisolated private extension SpiderPlanner { var best: (index: Int, score: Int, depth: Int)? while let entry = heap.pop() { + if Task.isCancelled { + wasTruncated = true + break + } + let nodeIndex = entry.index let node = nodes[nodeIndex] diff --git a/ComputerSolitaire/Game/TriPeaks/TriPeaksPlanner.swift b/ComputerSolitaire/Game/TriPeaks/TriPeaksPlanner.swift index 28a05f5..31829f3 100644 --- a/ComputerSolitaire/Game/TriPeaks/TriPeaksPlanner.swift +++ b/ComputerSolitaire/Game/TriPeaks/TriPeaksPlanner.swift @@ -352,6 +352,11 @@ nonisolated private extension TriPeaksPlanner { var best: (index: Int, remaining: Int, depth: Int)? while let nodeIndex = pending.popLast() { + if Task.isCancelled { + wasTruncated = true + break + } + let node = nodes[nodeIndex] let remaining = node.board.remainingCount diff --git a/ComputerSolitaire/Game/Yukon/YukonPlanner.swift b/ComputerSolitaire/Game/Yukon/YukonPlanner.swift index b49b940..f0aab66 100644 --- a/ComputerSolitaire/Game/Yukon/YukonPlanner.swift +++ b/ComputerSolitaire/Game/Yukon/YukonPlanner.swift @@ -132,6 +132,11 @@ nonisolated private extension YukonPlanner { var best: (index: Int, score: Int, depth: Int)? while let entry = heap.pop() { + if Task.isCancelled { + wasTruncated = true + break + } + let nodeIndex = entry.index let node = nodes[nodeIndex] diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index a0bf059..0b91866 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -567,6 +567,10 @@ struct ContentView: View { } } } + .onChange(of: viewModel.hintWiggleToken) { _, _ in + // Search completion can arrive after the board's pending autosave. + scheduleAutosave() + } .onChange(of: viewModel.pendingAutoMove?.id) { _, _ in processPendingAutoMoveIfPossible() queueAutoFinishStepIfPossible() @@ -597,6 +601,7 @@ struct ContentView: View { initializeGameIfNeeded() } .onDisappear { + viewModel.clearHint() persistGameNow() } #if os(macOS) @@ -1099,6 +1104,7 @@ struct ContentView: View { private var isHintDisabled: Bool { viewModel.isWin + || viewModel.isSearchingForHint || isUndoAnimating || isDroppingCards || isReturningDrag @@ -1291,6 +1297,7 @@ struct ContentView: View { /// Clears in-flight drag/drop/undo/draw animation state so stale animation /// completions cannot mutate the game that replaces the current one. private func resetTransientBoardState() { + viewModel.clearHint() drag.reset() overlayTilt = 0 dragReturnOffset = .zero @@ -1319,6 +1326,7 @@ struct ContentView: View { private func startAutoFinish() { guard !isAutoFinishDisabled else { return } + viewModel.clearHint() HapticManager.shared.play(.autoFinishStart) isAutoFinishing = true queueAutoFinishStepIfPossible() diff --git a/ComputerSolitaireTests/Shared/HintSearchTests.swift b/ComputerSolitaireTests/Shared/HintSearchTests.swift new file mode 100644 index 0000000..da17d1a --- /dev/null +++ b/ComputerSolitaireTests/Shared/HintSearchTests.swift @@ -0,0 +1,323 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class HintSearchTests: XCTestCase { + private var savedStatistics: [String: Data] = [:] + + override func setUp() async throws { + for mode in GameMode.allCases { + let key = GameStatisticsStore.defaultsKey(for: mode) + savedStatistics[key] = UserDefaults.standard.data(forKey: key) + UserDefaults.standard.removeObject(forKey: key) + } + } + + override func tearDown() async throws { + for mode in GameMode.allCases { + let key = GameStatisticsStore.defaultsKey(for: mode) + if let data = savedStatistics[key] { + UserDefaults.standard.set(data, forKey: key) + } else { + UserDefaults.standard.removeObject(forKey: key) + } + } + savedStatistics = [:] + } + + func testSearchSuspendsWithoutBlockingGameplayAndCoalescesRequests() async throws { + let search = ControlledHintSearch() + let model = SolitaireViewModel(hintSearch: search) + let originalState = model.state + let task = try XCTUnwrap(model.requestHint()) + await search.waitForRequests(1) + let duplicate = try XCTUnwrap(model.requestHint()) + + XCTAssertTrue(model.isSearchingForHint) + XCTAssertNil(model.activeHint) + XCTAssertEqual(model.persistencePayload().hintRequestsInCurrentGame, 0) + let requests = await search.requests + XCTAssertEqual(requests.count, 1) + XCTAssertEqual(requests.first?.state, originalState) + XCTAssertEqual(requests.first?.drawCount, 3) + + // This main-actor action completes while the search is still suspended. + model.drawFromStock() + XCTAssertNotEqual(model.state, originalState) + XCTAssertFalse(model.isSearchingForHint) + XCTAssertTrue(task.isCancelled) + await search.finish(0, with: .success(.stockTap)) + await task.value + await duplicate.value + XCTAssertNil(model.activeHint) + XCTAssertEqual(model.persistencePayload().hintRequestsInCurrentGame, 0) + } + + func testCompletedHintIsPublishedAndCountedOnce() async throws { + let search = ControlledHintSearch() + let model = SolitaireViewModel(hintSearch: search) + let token = model.hintWiggleToken + let task = try XCTUnwrap(model.requestHint()) + await search.waitForRequests(1) + await search.finish(0, with: .success(.stockTap)) + await task.value + + XCTAssertEqual(model.activeHint, .stockTap) + XCTAssertNotEqual(model.hintWiggleToken, token) + XCTAssertFalse(model.isSearchingForHint) + XCTAssertEqual(model.persistencePayload().hintRequestsInCurrentGame, 1) + } + + func testOldCompletionCannotFinishOrDisableNewRequest() async throws { + for oldResult: Result in [ + .success(.stockTap), .success(nil), .failure(CancellationError()) + ] { + let search = ControlledHintSearch() + let model = SolitaireViewModel(hintSearch: search) + let oldTask = try XCTUnwrap(model.requestHint()) + await search.waitForRequests(1) + model.clearHint() + let newTask = try XCTUnwrap(model.requestHint()) + await search.waitForRequests(2) + await search.finish(0, with: oldResult) + await oldTask.value + + XCTAssertTrue(model.isSearchingForHint) + XCTAssertTrue(model.isHintAvailable) + XCTAssertNil(model.activeHint) + XCTAssertEqual(model.persistencePayload().hintRequestsInCurrentGame, 0) + + await search.finish(1, with: .success(.stockTap)) + await newTask.value + XCTAssertEqual(model.activeHint, .stockTap) + XCTAssertFalse(model.isSearchingForHint) + XCTAssertEqual(model.persistencePayload().hintRequestsInCurrentGame, 1) + } + } + + func testEmptySearchDisablesHintsOnlyForUnchangedBoard() async throws { + for changeBoard in [false, true] { + let search = ControlledHintSearch() + let model = SolitaireViewModel(hintSearch: search) + let task = try XCTUnwrap(model.requestHint()) + await search.waitForRequests(1) + if changeBoard { + // Bypass normal mutation methods to exercise the snapshot guard. + model.state.stock.swapAt(0, 1) + } + await search.finish(0, with: .success(nil)) + await task.value + XCTAssertEqual(model.isHintAvailable, changeBoard) + XCTAssertFalse(model.isSearchingForHint) + XCTAssertEqual(model.persistencePayload().hintRequestsInCurrentGame, 0) + } + } + + func testChangedSnapshotRejectsSuccessfulHint() async throws { + let search = ControlledHintSearch() + let model = SolitaireViewModel(hintSearch: search) + let task = try XCTUnwrap(model.requestHint()) + await search.waitForRequests(1) + model.state.stock.swapAt(0, 1) + await search.finish(0, with: .success(.stockTap)) + await task.value + XCTAssertNil(model.activeHint) + XCTAssertFalse(model.isSearchingForHint) + XCTAssertEqual(model.persistencePayload().hintRequestsInCurrentGame, 0) + } + + func testOldResultCannotReplaceAlreadyPublishedNewHint() async throws { + let search = ControlledHintSearch() + let model = SolitaireViewModel(hintSearch: search) + let oldTask = try XCTUnwrap(model.requestHint()) + await search.waitForRequests(1) + model.clearHint() + let newTask = try XCTUnwrap(model.requestHint()) + await search.waitForRequests(2) + await search.finish(1, with: .success(.stockTap)) + await newTask.value + let token = model.hintWiggleToken + + await search.finish(0, with: .success(nil)) + await oldTask.value + XCTAssertEqual(model.activeHint, .stockTap) + XCTAssertEqual(model.hintWiggleToken, token) + XCTAssertTrue(model.isHintAvailable) + XCTAssertEqual(model.persistencePayload().hintRequestsInCurrentGame, 1) + } + + func testSessionTransitionsCancelPendingHints() async throws { + let transitions: [(SolitaireViewModel) -> Void] = [ + { $0.newGame() }, + { $0.redeal() }, + { $0.undo() }, + { _ = $0.restore(from: $0.persistencePayload()) }, + { $0.activateGame(.freecell, restoringFrom: nil) }, + { _ = $0.pauseTimeScoring() }, + { $0.resetStatisticsTracking() }, + { _ = $0.startDragFromTableau(pileIndex: 0, cardIndex: 0) } + ] + for transition in transitions { + let search = ControlledHintSearch() + let model = SolitaireViewModel(hintSearch: search) + model.drawFromStock() // Gives undo a history entry. + let task = try XCTUnwrap(model.requestHint()) + await search.waitForRequests(1) + transition(model) + XCTAssertTrue(task.isCancelled) + XCTAssertFalse(model.isSearchingForHint) + await search.finish(0, with: .success(.stockTap)) + await task.value + XCTAssertNil(model.activeHint) + XCTAssertEqual(model.persistencePayload().hintRequestsInCurrentGame, 0) + } + } + + func testCancellationDoesNotDisableHintsAndAllowsRetry() async throws { + let search = ControlledHintSearch() + let model = SolitaireViewModel(hintSearch: search) + let task = try XCTUnwrap(model.requestHint()) + await search.waitForRequests(1) + await search.finish(0, with: .failure(CancellationError())) + await task.value + XCTAssertFalse(model.isSearchingForHint) + XCTAssertTrue(model.isHintAvailable) + + let retry = try XCTUnwrap(model.requestHint()) + await search.waitForRequests(2) + await search.finish(1, with: .success(.stockTap)) + await retry.value + XCTAssertEqual(model.activeHint, .stockTap) + } + + func testPendingSearchDoesNotRetainSession() async throws { + let search = ControlledHintSearch() + var model: SolitaireViewModel? = SolitaireViewModel(hintSearch: search) + weak let weakModel = model + let task = try XCTUnwrap(model?.requestHint()) + await search.waitForRequests(1) + model = nil + XCTAssertNil(weakModel) + XCTAssertTrue(task.isCancelled) + await search.finish(0, with: .success(.stockTap)) + await task.value + } + + func testRealWorkerReturnsLegalHint() async throws { + let ace = TestCards.make(.spades, .ace, isFaceUp: true) + let state = GameState( + stock: [], waste: [ace], wasteDrawCount: 1, + foundations: Array(repeating: [], count: 4), + tableau: Array(repeating: [], count: 7) + ) + let hint = try await HintSearchWorker().bestHint(in: state, stockDrawCount: 3) + guard case .move(let move) = hint else { return XCTFail("Expected an ace move") } + XCTAssertEqual(move.selection.cards, [ace]) + XCTAssertEqual(move.destination, .foundation(0)) + } + + func testAllPlannersStopWithoutClaimingExhaustiveSearchWhenCancelled() async { + let states = Dictionary(uniqueKeysWithValues: GameVariant.allCases.map { + ($0, GameState.newGame(variant: $0)) + }) + let task = Task { + XCTAssertTrue(Task.isCancelled) + XCTAssertNil(KlondikePlanner.bestHint(in: states[.klondike]!, stockDrawCount: 3)) + XCTAssertNil(FreeCellSolver.solve(states[.freecell]!)) + if case .noProgress(searchWasExhaustive: false) = YukonPlanner.bestLine(in: states[.yukon]!) { + // Cancellation must never be reported as proof that no move exists. + } else { + XCTFail("Yukon search did not stop on cancellation") + } + if case .noProgress(searchWasExhaustive: false) = SpiderPlanner.bestLine(in: states[.spider]!) { + // Cancellation must never be reported as proof that no move exists. + } else { + XCTFail("Spider search did not stop on cancellation") + } + if case .noProgress(searchWasExhaustive: false) = PyramidPlanner.bestLine(in: states[.pyramid]!) { + // Cancellation must never be reported as proof that no move exists. + } else { + XCTFail("Pyramid search did not stop on cancellation") + } + if case .noProgress(searchWasExhaustive: false) = TriPeaksPlanner.bestLine(in: states[.tripeaks]!) { + // Cancellation must never be reported as proof that no move exists. + } else { + XCTFail("TriPeaks search did not stop on cancellation") + } + if case .noProgress(searchWasExhaustive: false) = GolfPlanner.bestLine(in: states[.golf]!) { + // Cancellation must never be reported as proof that no move exists. + } else { + XCTFail("Golf search did not stop on cancellation") + } + if case .noProgress(searchWasExhaustive: false) = FortyThievesPlanner.bestLine(in: states[.fortyThieves]!) { + // Cancellation must never be reported as proof that no move exists. + } else { + XCTFail("FortyThieves search did not stop on cancellation") + } + if case .noProgress(searchWasExhaustive: false) = ScorpionPlanner.bestLine(in: states[.scorpion]!) { + // Cancellation must never be reported as proof that no move exists. + } else { + XCTFail("Scorpion search did not stop on cancellation") + } + if case .noProgress(searchWasExhaustive: false) = CanfieldPlanner.bestLine(in: states[.canfield]!) { + // Cancellation must never be reported as proof that no move exists. + } else { + XCTFail("Canfield search did not stop on cancellation") + } + } + task.cancel() + await task.value + } + + func testCancelledWorkerRejectsResult() async { + let state = GameState.newGame() + // Inherits the main actor, so cancellation happens before the body starts. + let task = Task { + try await HintSearchWorker().bestHint(in: state, stockDrawCount: 3) + } + task.cancel() + do { + _ = try await task.value + XCTFail("A cancelled worker must throw") + } catch { + XCTAssertTrue(error is CancellationError) + } + } +} + +/// Deliberately ignores cancellation to exercise late and out-of-order results. +private actor ControlledHintSearch: HintSearching { + struct Request: Sendable { + let state: GameState + let drawCount: Int + } + private(set) var requests: [Request] = [] + private var pending: [Int: CheckedContinuation] = [:] + private var waiters: [(count: Int, continuation: CheckedContinuation)] = [] + + func bestHint(in state: GameState, stockDrawCount: Int) async throws -> HintAdvisor.Hint? { + try await withCheckedThrowingContinuation { continuation in + let index = requests.count + requests.append(Request(state: state, drawCount: stockDrawCount)) + pending[index] = continuation + let ready = waiters.filter { $0.count <= requests.count } + waiters.removeAll { $0.count <= requests.count } + for waiter in ready { waiter.continuation.resume() } + } + } + + func waitForRequests(_ count: Int) async { + guard requests.count < count else { return } + await withCheckedContinuation { waiters.append((count, $0)) } + } + + func finish(_ index: Int, with result: Result) { + guard let continuation = pending.removeValue(forKey: index) else { + preconditionFailure("No pending request at index \(index)") + } + switch result { + case .success(let hint): continuation.resume(returning: hint) + case .failure(let error): continuation.resume(throwing: error) + } + } +}