Advertisement

← poker-tutor

Sources/PokerTests/AppStateTests.swift

30.4 KB Download file
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)
            }
        }
    }
}