← poker-tutor
import Foundation
import PokerCore
import PokerAI
import PokerCoach
import PokerSession
import PokerUI
import TestKit
/// What the app does to a table when the player is looking at something else.
///
/// These drive `AppState` itself rather than the session under it, which is the point: everything
/// tested here is a decision the window makes — whether the opponents are still acting, what a
/// finished hand is allowed to open, which sitting a button on Home is about — and all of it used
/// to be reachable only by playing with a mouse. There is no clock: `stepOpponent` is the dealer's
/// own work with the waiting taken out, so a hand plays out here exactly as it does on screen,
/// only faster.
/// An app around a table that writes nothing to disk, with the walk-through out of the way.
@MainActor
private func readyApp(seed: UInt64, variant: AppVariant = .pokerTutor) -> AppState {
let session = TableSession(seed: seed, history: .inMemory(), variant: variant)
// The same pacing the window uses: the opponents wait to be stepped rather than playing
// themselves out inside one call.
session.pacedByCaller = true
let app = AppState(session: session)
app.dismissSheet()
return app
}
/// Plays until a hand finishes while the player is on another screen, which is what the timer used
/// to do behind their back. Returns false if that never happened in the hands allowed.
///
/// `flagged` asks for the harder case: a hand the coach has something to say about, which is the
/// one that used to raise the review sheet over whatever screen the player had gone to. A hand
/// played well takes the other path — the thumbs up on the felt, and then the next hand — and both
/// of them are things done to somebody who is watching.
@MainActor
private func playUntilAHandEndsAway(_ app: AppState, player: PlantedPlayer = .control,
rng: inout SeededRandom, hands: Int,
flagged: Bool = false) -> Bool {
for _ in 0..<hands {
var steps = 0
while app.session.isPlaying, !app.handComplete {
steps += 1
precondition(steps < 4_000, "the hand did not finish")
if app.session.heroToAct, let view = app.session.engine?.view(for: app.session.heroSeat) {
// It is their turn, so they are back at the table.
app.screen = .table
app.act(player.decide(view, &rng))
} else {
// Nothing for them to do: they have gone to look at their own numbers.
app.screen = .leaks
guard app.stepOpponent() else { break }
}
}
if app.handComplete, app.screen != .table,
!flagged || app.session.review?.playedWell == false { return true }
guard app.session.isPlaying else { return false }
app.screen = .table
app.continueAfterReview()
}
return false
}
/// Plays a whole arranged sitting out, dealing on after each hand the way the "Next hand" button
/// does.
@MainActor
private func playOut(_ app: AppState, rng: inout SeededRandom, hands: Int = 200) {
var dealt = 0
while app.session.isPlaying, dealt < hands {
if app.handComplete {
dealt += 1
app.continueAfterReview()
continue
}
if app.session.heroToAct, let view = app.session.engine?.view(for: app.session.heroSeat) {
app.act(PlantedPlayer.solid(view, &rng))
} else if !app.stepOpponent() {
break
}
}
}
func appStateTests(_ t: TestRun) {
t.suite("the table when the player is not at it") { t in
t.test("a hand that ends while they are on another screen opens nothing") {
// The first-session bug: call a bet, click "My game" while the opponents are still
// acting, and the review opened over the stats with a "Next hand" button that dealt at
// a table nobody was looking at.
MainActor.assumeIsolated {
let app = readyApp(seed: 31)
var rng = SeededRandom(seed: 5)
app.start(.tournament)
t.equal(app.screen, Screen.table)
// A player with a planted flaw, so the hand that finishes while they are away is
// one the coach wants to stop them over. That is the one that used to raise a
// sheet over the screen they had gone to.
guard playUntilAHandEndsAway(app, player: .playsEverything, rng: &rng,
hands: 120, flagged: true) else {
return t.expect(false, "no flagged hand finished while the player was away, so "
+ "this test measured nothing")
}
t.expect(app.handComplete, "the hand did not actually finish")
t.equal(app.session.review?.playedWell, false,
"the fixture stopped being a hand the coach had something to say about")
t.equal(app.openSheet, nil,
"a sheet opened over \(app.screen.rawValue): \(app.openSheet ?? "")")
t.expect(!app.thumbsUp, "the table congratulated somebody who had left it")
// Coming back finds the same hand, still finished — and now the review it was
// always going to get, because there is no longer a button to ask for it.
//
// Ken, 2026-08-22: "in some cases, I need to press next hand, sometimes I don't."
// The table used to hand back a bar with Review and Next hand on it, which is the
// inconsistency. The principle it was protecting is unchanged and still tested
// above: nothing happens to a table nobody is looking at. What happens on ARRIVAL
// is the thing that changed.
let handNumber = app.session.handNumber
app.screen = .table
t.expect(app.handComplete, "the finished hand was lost on the way back")
t.equal(app.openSheet, "review",
"coming back to a hand worth reviewing has to open the review — there is "
+ "no button left to open it with")
t.equal(app.session.handNumber, handNumber,
"coming back to the table dealt a hand before they had read the review")
}
}
t.test("a finished hand is never left with nothing about to happen") {
// Ken, 2026-08-22: "Saw the river and then the game locked. Not dealing another hand."
//
// The cause was one line: `act` finishes by calling `runOpponents`, which began by
// cancelling `dealer` — and on a hand that ended on the player's own action, `dealer`
// was by then holding the timer that deals the next one. A hand that ended on an
// opponent's action was inside the dealer task instead, so nothing cancelled it. That
// is the same fault as the original complaint about pressing Next hand: the button was
// the escape hatch, and removing it turned an inconsistency into a lock.
//
// The invariant, rather than the one arrangement that exposed it: after every hand
// that finishes while somebody is sitting at the table, exactly one of three things is
// true — a review is open, the next hand is on a timer, or the sitting is over. Never
// none of them.
MainActor.assumeIsolated {
// Several seeds, because the failing arrangement is narrow: the player has to be
// still IN the hand and their own action has to be the one that ends it. Folding
// takes a different path out of `act` that never reaches the cancelling line, and
// a hand the coach speaks about is carried by the review instead. One seed can
// easily play thirty hands without ever producing the case.
var endedOnTheirOwnAction = 0
for seed in [77, 12, 903, 41, 5150, 66, 208, 1492] as [UInt64] {
let app = readyApp(seed: seed)
var rng = SeededRandom(seed: seed &* 7 &+ 1)
let player = PlantedPlayer.control
app.start(.tournament)
var handsSeen = 0
var steps = 0
while handsSeen < 40, steps < 60_000, app.session.isPlaying {
steps += 1
if app.handComplete {
handsSeen += 1
let reviewOpen = app.openSheet == "review"
t.expect(reviewOpen || app.dealerIsRunning || !app.session.isPlaying,
"seed \(seed) hand \(app.session.handNumber) finished with no "
+ "review open, no next hand scheduled, and the sitting still "
+ "going — the table is stranded and there is no button left "
+ "to rescue it")
if reviewOpen { app.dismissSheet(); app.sheetDismissed() }
else { app.continueAfterReview() }
} else if app.session.heroToAct,
let view = app.session.engine?.view(for: app.session.heroSeat) {
let inHandBefore = app.session.heroInHand
app.act(player.decide(view, &rng))
if app.handComplete, inHandBefore, app.session.heroInHand {
endedOnTheirOwnAction += 1
}
} else if !app.stepOpponent() {
break
}
}
}
// Without this the test can pass by never producing the case it exists for, which
// is exactly how the first version of it passed against the broken code.
t.greaterThan(endedOnTheirOwnAction, 0,
"no hand ended on the player's own action while they were still in "
+ "it, so this measured nothing")
}
}
t.test("nothing from the last hand is still on screen during this one") {
// Ken, 2026-08-22: "a text box turned up toward the top of the screen giving my advice
// about position and it is did not go away in the next hand." That one was a glossary
// definition living in the drill banner, which is view state and fixed there.
//
// This is the same fault in the thing the app state owns: a card held up to be read.
// Both belong to the hand they came from, and a card from the last hand is a card that
// is no longer on the table.
MainActor.assumeIsolated {
let app = readyApp(seed: 21)
var rng = SeededRandom(seed: 3)
app.start(.tournament)
var steps = 0
while app.session.isPlaying, !app.handComplete, steps < 4_000 {
steps += 1
if app.session.heroToAct,
let view = app.session.engine?.view(for: app.session.heroSeat) {
app.act(PlantedPlayer.control.decide(view, &rng))
} else if !app.stepOpponent() { break }
}
guard let card = app.heroCards.first else {
return t.expect(false, "no cards were dealt, so this measured nothing")
}
app.magnify(card)
t.equal(app.magnified, card, "holding a card up did nothing")
app.continueAfterReview()
t.equal(app.magnified, nil,
"a card from the last hand is still being held up during this one")
// And leaving the table entirely puts it away too.
app.magnify(card)
app.leaveTable()
t.equal(app.magnified, nil, "a card was still held up after leaving the table")
}
}
t.test("the app deals a different tournament every time it opens") {
// Ken, 2026-08-24: "I dont like that it seems like the same hand is often shown when I
// start playing. Are the hands randomized in the tournement?" They were not.
//
// `TableSession`'s seed defaults to a fixed number so that every measurement in this
// project can be repeated — and the app had never passed one of its own, so it inherited
// it and dealt the same tournament on every launch: same hole cards, same board, same
// eight opponents in the same seats.
MainActor.assumeIsolated {
let seeds = (0..<200).map { _ in AppState.freshSeed() }
t.equal(Set(seeds).count, seeds.count, "the app's seeds repeat")
t.expect(!seeds.contains(TableSession.defaultSeed),
"the app is using the seed reserved for reproducible measurements")
}
}
t.test("the same seed still deals the same cards, which the measurements need") {
// The other half, and the reason the default did not simply become random: a shootout
// or a sweep that cannot be repeated measures nothing. Randomness belongs to the app
// and to nothing else.
MainActor.assumeIsolated {
func firstCards(_ seed: UInt64) -> String {
let session = TableSession(seed: seed, history: .inMemory())
session.start(.tournament)
return session.heroCards.map { "\($0)" }.joined(separator: " ")
}
t.equal(firstCards(TableSession.defaultSeed), firstCards(TableSession.defaultSeed),
"the same seed dealt two different hands")
t.notEqual(firstCards(TableSession.defaultSeed), firstCards(987_654_321),
"two different seeds dealt the same hand")
}
}
t.test("closing the review deals on, however it was closed") {
// The whole of Ken's complaint on 2026-08-22: "in some cases, I need to press next
// hand, sometimes I don't." The review's own button used to be the only thing that
// dealt the next hand, so swiping the sheet away instead left the table stopped.
//
// `sheetDismissed` is what every way out of a sheet now runs through — the button, a
// swipe, or the system taking it down. Closing the review IS dealing on.
MainActor.assumeIsolated {
let app = readyApp(seed: 31)
var rng = SeededRandom(seed: 5)
app.start(.tournament)
guard playUntilAHandEndsAway(app, player: .playsEverything, rng: &rng,
hands: 120, flagged: true) else {
return t.expect(false, "no flagged hand finished, so this measured nothing")
}
app.screen = .table
t.equal(app.openSheet, "review", "the review did not open by itself")
let handNumber = app.session.handNumber
// Swiped away rather than tapped — the path that used to strand the table.
app.dismissSheet()
app.sheetDismissed()
t.equal(app.openSheet, nil)
t.greaterThan(app.session.handNumber, handNumber,
"closing the review left the table sitting there")
}
}
t.test("a hand nobody is looking at is never dealt on") {
// The other side of it, and the reason `sheetDismissed` is fenced rather than
// unconditional: dealing on is what happens when somebody CLOSES a review at the
// table. A sheet closing anywhere else must not reach across and deal a hand.
MainActor.assumeIsolated {
let app = readyApp(seed: 31)
var rng = SeededRandom(seed: 5)
app.start(.tournament)
guard playUntilAHandEndsAway(app, player: .playsEverything, rng: &rng,
hands: 120, flagged: true) else {
return t.expect(false, "no flagged hand finished, so this measured nothing")
}
// Still away from the table, on whatever screen they wandered to.
t.notEqual(app.screen, Screen.table)
let handNumber = app.session.handNumber
app.sheetDismissed()
t.equal(app.session.handNumber, handNumber,
"a sheet closing elsewhere dealt a hand at a table nobody was at")
}
}
t.test("a hand that ends at the table still gets its answer") {
// The other half, and the reason the guard above is a guard rather than a silence: at
// the table a finished hand still either raises the review or shows the thumbs up.
MainActor.assumeIsolated {
let app = readyApp(seed: 31)
var rng = SeededRandom(seed: 5)
app.start(.tournament)
var answered = 0
for _ in 0..<12 {
var steps = 0
while app.session.isPlaying, !app.handComplete {
steps += 1
precondition(steps < 4_000, "the hand did not finish")
if app.session.heroToAct,
let view = app.session.engine?.view(for: app.session.heroSeat) {
app.act(PlantedPlayer.solid(view, &rng))
} else if !app.stepOpponent() {
break
}
}
guard app.handComplete else { break }
t.equal(app.screen, Screen.table)
if app.openSheet == "review" || app.thumbsUp { answered += 1 }
app.dismissSheet()
app.continueAfterReview()
}
t.greaterThan(answered, 0,
"twelve hands finished at the table and not one of them was reviewed "
+ "or approved, so the guard has silenced the coach altogether")
}
}
t.test("leaving the table is still leaving it") {
MainActor.assumeIsolated {
let app = readyApp(seed: 77)
app.start(.tournament)
t.expect(app.session.isPlaying)
app.leaveTable()
t.equal(app.screen, Screen.home)
t.expect(!app.session.isPlaying, "the Leave button only paused the sitting")
}
}
t.test("walking away from a tournament pauses it and keeps it") {
// Pausing rather than discarding. Somebody who glances at the Hands list forty hands
// into a tournament still has the tournament — and the eight people at it stop acting
// until there is somebody to watch them.
MainActor.assumeIsolated {
let app = readyApp(seed: 909)
var rng = SeededRandom(seed: 3)
app.start(.tournament)
var steps = 0
while app.session.isPlaying, !app.session.opponentToAct, app.session.heroToAct,
let view = app.session.engine?.view(for: app.session.heroSeat) {
steps += 1
precondition(steps < 200, "never got to a point where an opponent was to act")
app.act(PlantedPlayer.solid(view, &rng))
}
t.expect(app.dealerIsRunning, "the fixture never put the opponents on the clock")
let hand = app.session.handNumber
app.screen = .history
t.expect(!app.dealerIsRunning,
"the opponents carried on acting with nobody looking at the table")
t.expect(app.session.isPlaying, "looking at the hand list put the sitting down")
app.screen = .table
t.expect(app.dealerIsRunning, "coming back to the table left it frozen")
t.equal(app.session.handNumber, hand,
"coming back to the table dealt a new hand over the one in progress")
}
}
}
t.suite("the assessment card on Home") { t in
t.test("the report says the sitting is over the one time it opens by itself") {
// The sitting runs out of hands, the felt goes away and the report opens over Home.
// Without a word about why, that read as being thrown out of the game. The report
// opened at that moment says the hands are done; the same report opened later from
// "Read the report" keeps the plain title, because by then nothing just ended.
MainActor.assumeIsolated {
let app = readyApp(seed: 4_242)
var rng = SeededRandom(seed: 11)
app.beginAssessment()
playOut(app, rng: &rng)
guard let finished = app.finishedAssessment else {
return t.expect(false, "the sitting never ran to the end")
}
t.equal(app.screen, .home, "the felt did not give way to Home")
t.expect(app.assessmentJustFinished, "the report did not know the sitting had just ended")
t.expect(finished.playedOn != nil, "the report does not know when it was played")
app.dismissSheet()
t.expect(!app.assessmentJustFinished)
app.showAssessmentReport()
t.expect(!app.assessmentJustFinished, "reopened later, the report still said the hands had just finished")
t.expect(Copy.assessmentJustFinishedTitle(hands: finished.hands).contains("\(finished.hands)"))
}
}
t.test("the three hand counts on Home add up") {
// 271 hands saved, 199 watched by the rating, 40 in the assessment: three numbers that
// read as one thing three ways. Practice hands — drills and re-checks — are kept out
// of the rating on purpose; the panel that counts everything says so in the same
// breath, and the two must agree with the scorecard's own count.
MainActor.assumeIsolated {
let history = HistoryStore.inMemory()
var number = 0
func add(_ kind: String?, _ count: Int) {
for _ in 0..<count {
number += 1
history.append(HandRecord(
sessionID: "s1", sessionStarted: Date(timeIntervalSince1970: 1_780_000_000),
playedAt: Date(timeIntervalSince1970: 1_780_000_000 + Double(number) * 60),
handNumber: number, mode: "Tournament", bigBlind: 100, position: "BTN",
holeCards: "Ah Kd", board: "", netChips: 0, wentToShowdown: false,
sawFlop: false, putMoneyInPreflop: true, raisedPreflop: false,
verdicts: [], kind: kind))
}
}
add(nil, 159)
add(HandRecord.assessmentKind, 40)
add(HandRecord.drillKind, 60)
add(HandRecord.retestKind, 12)
let app = AppState(session: TableSession(seed: 5, history: history))
t.equal(history.hands.count, 271)
t.equal(app.playedHands, 199, "assessment hands count as played")
t.equal(app.practiceHands, 72, "drills and re-checks are the practice hands")
t.equal(app.playedHands + app.practiceHands, history.hands.count)
t.equal(app.playedHands, app.rating.handsBehindIt,
"the rating watches \(app.rating.handsBehindIt) hands but Home says \(app.playedHands) were played")
let title = Copy.handsSavedTitle(saved: history.hands.count,
played: app.playedHands, practice: app.practiceHands)
for n in ["271", "199", "72"] { t.expect(title.contains(n), "\(title) is missing \(n)") }
t.expect(Copy.ratingHandsWatched(199).contains("practice"),
"the rating card does not say practice hands are left out")
}
}
t.test("the report it offers is the sitting whose headline it is showing") {
// The card shows the last finished sitting's headline and has one button that opens a
// report. While a second sitting was on the table, that button opened the second one —
// so the card read "40 hands, 2 habits worth naming" beside a report headed "You
// stopped 7 hands into 40".
MainActor.assumeIsolated {
let app = readyApp(seed: 4_242)
var rng = SeededRandom(seed: 11)
app.beginAssessment()
playOut(app, rng: &rng)
guard let finished = app.finishedAssessment else {
return t.expect(false, "the sitting never ran to the end")
}
t.equal(finished.hands, AssessmentRun.standardLength)
app.dismissSheet()
// A second sitting, walked away from by going to Home rather than by leaving.
app.beginAssessment()
var dealt = 0
while app.session.isPlaying, dealt < 6 {
if app.handComplete { dealt += 1; app.continueAfterReview(); continue }
if app.session.heroToAct,
let view = app.session.engine?.view(for: app.session.heroSeat) {
app.act(PlantedPlayer.solid(view, &rng))
} else if !app.stepOpponent() {
break
}
}
app.screen = .home
t.expect(app.assessmentInProgress, "the second sitting was not still on the table")
t.equal(app.finishedAssessment?.hands, AssessmentRun.standardLength)
t.equal(app.assessmentReport?.hands, AssessmentRun.standardLength,
"Home offered the report of the sitting still being played while showing "
+ "the finished one's headline")
}
}
}
// The two store screenshots that need a game behind them are played by the app itself from a
// fixed seed, for both products. This is what stops the screenshot script from quietly
// capturing an empty felt: the seed that stops producing the scene fails here first.
t.suite("the scenes the screenshots are taken of") { t in
t.test("the felt is reached with a decision on it") {
MainActor.assumeIsolated {
let app = readyApp(seed: DemoScene.seed)
t.expect(app.arrangeDemo(.table), "the seed never dealt a flop worth photographing")
t.equal(app.screen, .table)
t.equal(app.session.mode, .tournament)
t.expect(app.session.heroToAct, "it is not the player's turn")
t.equal(app.session.engine?.street, .flop)
t.equal(app.heroCards.count, 2)
t.expect(app.openSheet == nil, "\(app.openSheet ?? "") is covering the felt")
t.expect(app.session.legal?.canBetOrRaise == true, "the action bar has nothing to offer")
}
}
t.test("the review is reached over a hand with something to say") {
MainActor.assumeIsolated {
let app = readyApp(seed: DemoScene.seed)
t.expect(app.arrangeDemo(.review), "the seed never produced a hand the coach spoke about")
t.equal(app.screen, .table)
t.equal(app.openSheet, "review")
t.expect(app.handComplete, "the hand is not over")
t.equal(app.session.review?.playedWell, false)
}
}
// The cash product's shots are cut from the same two scenes, played at a cash table: the
// felt at fixed-limit, where the bet is written on the button, and the review at no-limit.
t.test("the cash product's felt is reached at a fixed-limit cash table") {
MainActor.assumeIsolated {
let app = readyApp(seed: DemoScene.seed, variant: .pokerTutorCash)
t.expect(app.arrangeDemo(.table), "the seed never dealt a cash flop worth photographing")
t.equal(app.screen, .table)
t.equal(app.session.mode, .cash)
t.equal(app.session.cashSetup, DemoScene.table.cashSetup())
t.equal(app.session.cashSetup.structure, .fixedLimit)
t.expect(app.session.heroToAct, "it is not the player's turn")
t.equal(app.session.engine?.street, .flop)
t.expect(app.openSheet == nil, "\(app.openSheet ?? "") is covering the felt")
t.expect(app.session.legal?.canBetOrRaise == true, "the action bar has nothing to offer")
t.expect(app.session.legal?.sizeIsFixed == true, "a fixed-limit bet should have one size")
}
}
t.test("the cash product's review is reached over a no-limit cash hand") {
MainActor.assumeIsolated {
let app = readyApp(seed: DemoScene.seed, variant: .pokerTutorCash)
t.expect(app.arrangeDemo(.review), "the seed never produced a cash hand the coach spoke about")
t.equal(app.openSheet, "review")
t.equal(app.session.mode, .cash)
t.equal(app.session.cashSetup.structure, .noLimit)
t.equal(app.session.review?.playedWell, false)
}
}
}
}