Advertisement

← poker-tutor

Sources/PokerTests/DrillTests.swift

61.3 KB Download file
import Foundation
import PokerCore
import PokerAI
import PokerCoach
import PokerSession
import TestKit

/// A drill is a promise that something has been fixed, so almost everything worth testing here is
/// about what it refuses to say: it will not clear on one good decision, it will not clear on a
/// round number of hands, it will not quietly forget a habit that came back, and it will not let
/// practice at one situation count as evidence about how somebody plays.

/// Plays a drill headlessly, driving `TableSession` exactly as a person would — which is possible
/// only because none of this lives in the app layer, and is what makes it the test that matters.
func driveDrill(_ session: TableSession, player: PlantedPlayer, seed: UInt64,
                stoppingAfter limit: Int = .max) {
    var rng = SeededRandom(seed: seed &* 11 &+ 5)
    var steps = 0
    while session.isPlaying, (session.drill?.handsPlayed ?? 0) < limit {
        steps += 1
        precondition(steps < 80_000, "the drill did not terminate")
        if session.heroToAct, let view = session.engine?.view(for: session.heroSeat) {
            session.act(player.decide(view, &rng))
        } else if session.engine?.isComplete == true {
            session.continueAfterReview()
        } else {
            session.finishHandNow()
        }
    }
}

/// A day, in seconds, so the scheduling tests read as days rather than as arithmetic.
let day: TimeInterval = 24 * 60 * 60
let start = Date(timeIntervalSince1970: 1_785_000_000)

/// Drill hands as they would have been written down: one decision each, in the situation the habit
/// lives in, `wrong` of them going wrong.
func drilled(_ leak: Leak, hands: Int, wrong: Int, session: String,
                     retest: Bool = false, on when: Date = start,
                     from number: Int = 1) -> [HandRecord] {
    (0..<hands).map { index in
        let mistake = index < wrong
        let verdict = VerdictRecord(street: "flop", spot: leak.spot.name,
                                    leak: mistake ? leak.error.name : "",
                                    severity: mistake ? 3 : 0,
                                    costInBB: mistake ? 1.5 : 0,
                                    headline: "h", detail: "d", taken: "fold", best: "call",
                                    spoke: mistake, confidence: 0.9)
        return HandRecord(sessionID: session,
                          sessionStarted: when,
                          playedAt: when.addingTimeInterval(Double(index) * 90),
                          handNumber: number + index,
                          mode: retest ? "Re-test" : "Drill",
                          bigBlind: 100,
                          position: "BB",
                          holeCards: "Ah Kd",
                          board: "2c 7h Js",
                          netChips: -100,
                          wentToShowdown: false,
                          sawFlop: true,
                          putMoneyInPreflop: true,
                          raisedPreflop: false,
                          verdicts: [verdict],
                          tableSize: 9,
                          kind: retest ? HandRecord.retestKind : HandRecord.drillKind,
                          setup: "big-blind",
                          drill: leak.id)
    }
}

/// Ordinary hands, so a scorecard has something that is not a drill to count.
private func ordinary(_ leak: Leak, hands: Int, wrong: Int) -> [HandRecord] {
    (0..<hands).map { index in
        let mistake = index < wrong
        let verdict = VerdictRecord(street: "flop", spot: leak.spot.name,
                                    leak: mistake ? leak.error.name : "",
                                    severity: mistake ? 3 : 0,
                                    costInBB: mistake ? 1.5 : 0,
                                    headline: "h", detail: "d", taken: "fold", best: "call",
                                    spoke: mistake, confidence: 0.9)
        return HandRecord(sessionID: "ordinary",
                          sessionStarted: start,
                          playedAt: start.addingTimeInterval(Double(index) * 90),
                          handNumber: 500 + index,
                          mode: "Tournament", bigBlind: 100, position: "BB",
                          holeCards: "Ah Kd", board: "2c 7h Js", netChips: -100,
                          wentToShowdown: false, sawFlop: true, putMoneyInPreflop: true,
                          raisedPreflop: false, verdicts: [verdict], tableSize: 9)
    }
}

/// Every sentence a drill can put in front of the player, in every state it can be in.
///
/// Collected here rather than in the glossary suite because the fixtures that get a drill into each
/// of those states live here. The glossary suite walks the result and fails if any of it uses a
/// poker word with nothing behind it — the standing rule, enforced rather than remembered.
func drillCopy() -> [String] {
    var copy: [String] = []
    for leak in LeakCatalogue.all {
        for retest in [false, true] {
            var run = DrillRun(leak: leak, retest: retest)
            copy += [run.purposeLine, run.progressLine, run.targetLine,
                     run.outcomeTitle, run.outcomeLine]
            // Part way through, then all the way to whatever ending it has.
            for hand in drilled(leak, hands: 6, wrong: 1, session: "copy", retest: retest) {
                run.observe(hand)
            }
            copy += [run.progressLine, run.targetLine, run.outcomeLine]
            for hand in drilled(leak, hands: 40, wrong: 0, session: "copy", retest: retest,
                                from: 100) {
                run.observe(hand)
            }
            copy += [run.outcomeTitle, run.outcomeLine, run.targetLine]

            // And the ending nobody wants: it kept happening.
            var stuck = DrillRun(leak: leak, retest: retest)
            for hand in drilled(leak, hands: DrillRule.handLimit, wrong: DrillRule.handLimit,
                                session: "copy", retest: retest) {
                stuck.observe(hand)
            }
            copy += [stuck.outcomeTitle, stuck.outcomeLine, stuck.targetLine]
        }

        // Every stage of the diary, said out loud.
        let cleared = drilled(leak, hands: 60, wrong: 0, session: "d1")
        let failedCheck = drilled(leak, hands: 40, wrong: 20, session: "r1", retest: true,
                                  on: start.addingTimeInterval(day * 6), from: 500)
        let half = drilled(leak, hands: 3, wrong: 1, session: "d2")
        let hopeless = drilled(leak, hands: DrillRule.handLimit, wrong: DrillRule.handLimit,
                               session: "d3")
        // A sitting that barely produced the situation, and a check that was started and walked
        // away from: two states with sentences of their own, so two more states to sweep.
        let barren = drilled(leak, hands: 4, wrong: 1, session: "d4")
        let abandonedCheck = drilled(leak, hands: 5, wrong: 1, session: "r2", retest: true,
                                     on: start.addingTimeInterval(day * 6), from: 900)
        for (records, when) in [(cleared, start.addingTimeInterval(day)),
                                (cleared, start.addingTimeInterval(day * 40)),
                                (cleared + failedCheck, start.addingTimeInterval(day * 7)),
                                (half, start), (hopeless, start), (barren, start),
                                (cleared + abandonedCheck, start.addingTimeInterval(day * 7))] {
            let book = DrillBook(of: records, now: when)
            guard let standing = book.standing(of: leak.id) else { continue }
            copy += [standing.line(now: when), standing.badge(now: when)]
        }
    }
    return copy
}

/// Never raises an unopened pot from the last two seats, whatever is in front of them.
let neverAttacksTheBlinds = PlantedPlayer(name: "never attacks the blinds",
                                          leakID: "passed-steals") { view, rng in
    if view.street == .preflop, view.isUnopenedPot,
       view.position == .button || view.position == .cutoff {
        return view.legal.canCheck ? .check : .fold
    }
    return PlantedPlayer.solid(view, &rng)
}

public func drillTests(_ t: TestRun) {

    let bigBlind = LeakCatalogue.named("bb-overfold")!
    let limping = LeakCatalogue.named("limping")!
    /// The habit the arithmetic fixtures below are written against: 18% over 25 chances.
    ///
    /// They used to use the big blind one, whose numbers were re-derived from a sweep and are now
    /// 30% over 50 — see `LeakCatalogue`. Nothing in these tests is about the big blind; they are
    /// about when a drill clears, when a check fails and what the screens say about both, and every
    /// one of them was written against 18% over 25. Repointing them at a habit that still carries
    /// those numbers keeps the arithmetic they were checking intact rather than rewriting a dozen
    /// fixtures to a new bar and losing the cases they were built to cover. The drills that sit a
    /// real player at a real table still drill the big blind.
    let paced = LeakCatalogue.named("fold-to-raise")!

    t.suite("when a drill is finished") { t in

        // The ending is the whole design. A drill that stopped after a round number of hands would
        // be handing out all-clears off however many chances the deal happened to produce, which is
        // the one thing a coach must never do.

        t.test("one good decision clears nothing") {
            t.expect(!DrillRule.clears(paced, chances: 1, mistakes: 0))
            t.expect(!DrillRule.clears(paced, chances: 4, mistakes: 0))
        }

        t.test("a clean run still cannot clear before there have been enough chances") {
            // The rate alone would let all of these through — they are nought out of a handful.
            // The floor is what stops them, because a rate off eleven decisions is noise wearing a
            // percentage sign.
            let floor = DrillRule.floor(for: paced)
            t.expect(!DrillRule.clears(paced, chances: floor - 1, mistakes: 0),
                     "the floor on chances is not being applied")
            t.expect(DrillRule.clears(paced, chances: floor, mistakes: 0))
            t.greaterThan(floor, DrillRule.minimumChances - 1)
        }

        t.test("it is not a fixed count — each habit has its own number") {
            let strict = DrillRule.chancesStillNeeded(limping, chances: 0, mistakes: 0)
            let looser = DrillRule.chancesStillNeeded(LeakCatalogue.named("fold-to-3bet")!,
                                                      chances: 0, mistakes: 0)
            t.greaterThan(strict, looser,
                          "the catalogue takes \(limping.minimumSample) chances to call limping in "
                          + "and 15 to call folding to every re-raise, so the drills for them "
                          + "cannot both finish at the same number")
        }

        t.test("getting one wrong pushes the finish line out") {
            let clean = DrillRule.chancesStillNeeded(paced, chances: 30, mistakes: 0)
            let slipped = DrillRule.chancesStillNeeded(paced, chances: 30, mistakes: 5)
            t.equal(clean, 0, "thirty clean chances should already have done it")
            t.greaterThan(slipped, 0, "a slip has to cost something or the drill means nothing")
        }

        t.test("a rate under the bar still does not clear until it can be shown") {
            // Two of fourteen is fourteen per cent, already under the eighteen a steady player
            // sits at — and this habit takes 25 chances to call, so it takes 25 to clear.
            t.expect(!DrillRule.clears(paced, chances: 14, mistakes: 2))
            t.expect(DrillRule.clears(paced, chances: 100, mistakes: 14))
        }

        t.test("a habit that keeps happening does not clear on time served") {
            t.expect(!DrillRule.clears(paced, chances: 60, mistakes: 20))
            t.greaterThan(DrillRule.chancesStillNeeded(paced, chances: 60, mistakes: 20), 40,
                          "a fifth of them going wrong should be a long way from cleared")
        }

        t.test("how many more it wants is worked out, not searched for") {
            // It used to count up one chance at a time until `clears` agreed, which terminated
            // only because every rate in the catalogue happens to be 8% or more. A habit written
            // with a rate of nought — a spot a competent player never gets wrong, which is a
            // reasonable thing for somebody to add — would have spun for ever, and it would have
            // done it inside a view body: this is read while the band above the felt is drawn.
            //
            // The two halves of the claim: the number it gives always clears, and one fewer never
            // does. That is what makes it the same number the drill actually ends on.
            var checked = 0
            for leak in LeakCatalogue.all {
                for mistakes in [0, 1, 3, 9] {
                    for chances in [0, 5, 20, 60] {
                        let more = DrillRule.chancesStillNeeded(leak, chances: chances,
                                                                mistakes: mistakes)
                        checked += 1
                        t.expect(DrillRule.clears(leak, chances: chances + more, mistakes: mistakes),
                                 "\(leak.id): \(chances) chances plus \(more) more, \(mistakes) "
                                 + "wrong, still does not clear")
                        guard more > 0 else { continue }
                        t.expect(!DrillRule.clears(leak, chances: chances + more - 1,
                                                   mistakes: mistakes),
                                 "\(leak.id): it asked for one more chance than it needed")
                    }
                }
            }
            t.greaterThan(checked, 200, "only \(checked) combinations were tried")

            let impossible = Leak(id: "never-wrong", spot: .riverDecision, error: .missedValue,
                                  name: "A habit nobody is allowed to have",
                                  explanation: "e", acceptableRate: 0, minimumSample: 12,
                                  drillHint: "h")
            t.equal(DrillRule.chancesStillNeeded(impossible, chances: 3, mistakes: 1),
                    DrillRule.mostChancesAskedFor,
                    "a habit that can never clear has to answer rather than spin")
        }
    }

    t.suite("what the drill report is allowed to say") { t in

        // The arithmetic here has always been careful. The sentences describing it were not, and a
        // sentence that overstates what the numbers support is the product defect, not the typo:
        // this whole app is the claim that it tells somebody the truth about their game.

        t.test("a clean run is never reported as a clean sweep") {
            // The one the drill's own doc comment warned about: nought out of twelve is not nought
            // per cent of the time, and printing the raw rate handed that safeguard straight back.
            //
            // Drilled on a habit that really does clear on the bare floor. It used to be the river
            // one, until that habit's sample was re-derived from a sweep and went to 25 — see
            // `LeakCatalogue`. Two of the eighteen sit on the floor now; this is one of them.
            let river = LeakCatalogue.named("no-shove")!
            var run = DrillRun(leak: river)
            for hand in drilled(river, hands: 12, wrong: 0, session: "clean") { run.observe(hand) }
            t.expect(run.cleared, "twelve clean chances should clear this one")
            t.equal(run.chances, 12)
            t.equal(run.mistakes, 0)
            t.greaterThan(run.countedPercent, 0,
                          "a clean run came out at \(run.countedPercent)%, which is the certainty "
                          + "claim the imaginary slip exists to prevent")
            t.expect(!run.outcomeLine.contains("— 0%"),
                     "the report printed the raw rate: \"\(run.outcomeLine)\"")
            t.expect(run.outcomeLine.contains("\(run.countedPercent)%"),
                     "the report has to show the number the rule judged: \"\(run.outcomeLine)\"")
            t.expect(run.outcomeLine.contains("slip"),
                     "the player has to be told the slip is added in: \"\(run.outcomeLine)\"")
        }

        t.test("a spotless record is never told too many have gone wrong") {
            // The banner said "Too many have gone wrong for this sitting to clear it" on hand one
            // of a turn drill, before a single decision had been made. `reachableToday` fails for
            // two quite different reasons — too many slips, or the situation not coming up often
            // enough per hand — and one sentence was carrying both. The turn habit takes 250
            // chances and ninety hands here produce five to thirteen, so it is the deal every time.
            for leak in LeakCatalogue.all {
                var run = DrillRun(leak: leak)
                t.expect(!run.targetLine.contains("gone wrong"),
                         "\(leak.id) blamed the player before a hand was played: "
                         + "\"\(run.targetLine)\"")
                // And twenty hands in with nothing wrong, which is the general case rather than
                // the opening one: two chances in twenty hands is far too thin a deal to finish on
                // and there is still nothing to blame anybody for.
                for hand in drilled(leak, hands: 20, wrong: 0, session: "clean-banner") {
                    run.observe(hand)
                }
                t.expect(run.mistakes == 0, "this fixture is meant to be spotless")
                t.expect(!run.targetLine.contains("gone wrong"),
                         "\(leak.id) blamed the player off \(run.chances) chances and no mistakes: "
                         + "\"\(run.targetLine)\"")
            }
            // And where slips really are what is in the way, the old sentence is still the one
            // that gets said — the fix is telling the two apart, not going quiet about both.
            let turn = LeakCatalogue.named("fold-to-3bet")!
            var slipped = DrillRun(leak: turn)
            for hand in drilled(turn, hands: 60, wrong: 30, session: "slipped") {
                slipped.observe(hand)
            }
            t.expect(!slipped.reachableToday, "this fixture is meant to be past saving today")
            t.expect(slipped.targetLine.contains("gone wrong"),
                     "thirty slips in sixty chances is the player's doing and the banner should "
                     + "say so: \"\(slipped.targetLine)\"")
        }

        t.test("clearing on the floor is called a good sign rather than a finished job") {
            // Two of the eighteen habits clear on the bare twelve, which is where a number stops
            // being noise and nowhere near where it settles anything.
            let river = LeakCatalogue.named("no-shove")!
            t.equal(DrillRule.floor(for: river), DrillRule.minimumChances,
                    "this habit is meant to be one of the ones that clears on the floor")
            var run = DrillRun(leak: river)
            for hand in drilled(river, hands: 12, wrong: 0, session: "floor") { run.observe(hand) }
            t.expect(!run.outcomeLine.contains("mean something"),
                     "twelve chances do not make a number mean something: \"\(run.outcomeLine)\"")
            t.expect(run.outcomeLine.contains("fewest chances this one can clear on"),
                     "it should say what the sample actually is: \"\(run.outcomeLine)\"")
            t.expect(run.outcomeLine.contains("check"),
                     "the next word has to come from the check: \"\(run.outcomeLine)\"")
        }

        t.test("a check that held up does not claim more than twelve chances can pay for") {
            var run = DrillRun(leak: paced, retest: true)
            for hand in drilled(paced, hands: DrillRule.retestChances, wrong: 0,
                                session: "held", retest: true) {
                run.observe(hand)
            }
            t.expect(run.passed)
            t.equal(run.outcomeTitle, "It held up",
                    "\"It stuck\" is a settled fact off twelve chances that one slip cannot fail")
        }

        t.test("a check is judged on the number it prints") {
            // It used to be judged on the raw rate and print the smoothed one, so a check could
            // read "It held up — 2 of 12 went wrong ... which makes it 21%, against the 18% a
            // steady player sits at" with the bar past the mark. Two of twelve is not a pass.
            var run = DrillRun(leak: paced, retest: true)
            for hand in drilled(paced, hands: 12, wrong: 2, session: "two", retest: true) {
                run.observe(hand)
            }
            t.equal(run.chances, 12)
            t.equal(run.mistakes, 2)
            t.greaterThan(run.countedPercent, run.acceptablePercent,
                          "this fixture is meant to sit above the bar")
            t.expect(run.failed, "it printed \(run.countedPercent)% against a \(run.acceptablePercent)% "
                     + "bar and called it a pass: \"\(run.outcomeLine)\"")
        }

        t.test("nothing that held up prints a number above the bar without saying why") {
            // Every habit, both endings, the whole catalogue. A check that passes either came in
            // at or under the rate it prints, or was the one slip that is forgiven outright — and
            // where it was the slip, the sentence beside the number has to say so, because
            // otherwise the reader is looking at 14% against an 8% bar under the word "held up".
            var checked = 0
            for leak in LeakCatalogue.all {
                for wrong in 0...3 {
                    var run = DrillRun(leak: leak, retest: true)
                    for hand in drilled(leak, hands: DrillRule.retestChances, wrong: wrong,
                                        session: "sweep-\(leak.id)-\(wrong)", retest: true) {
                        run.observe(hand)
                    }
                    guard run.passed else { continue }
                    checked += 1
                    if run.countedRate > leak.acceptableRate {
                        t.lessThan(run.mistakes, DrillRule.retestMistakes,
                                   "\(leak.id) passed a check at \(run.countedPercent)% against "
                                   + "\(run.acceptablePercent)% on \(run.mistakes) slips")
                        t.expect(run.outcomeLine.contains("One slip does not fail a check"),
                                 "\(leak.id) passed on the forgiven slip and did not say so: "
                                 + "\"\(run.outcomeLine)\"")
                    }
                }
            }
            t.greaterThan(checked, 0, "the sweep never found a check that passed")
        }

        t.test("the app has one name for the check") {
            // "Re-test" turned up in exactly one sentence, at the moment somebody is being told
            // they left one unfinished. Every screen calls it a check.
            var run = DrillRun(leak: paced, retest: true)
            for hand in drilled(paced, hands: 4, wrong: 0, session: "part", retest: true) {
                run.observe(hand)
            }
            t.expect(run.settledAt == nil, "four chances cannot settle a check")
            for line in [run.outcomeLine, run.progressLine, run.targetLine, run.purposeLine] {
                t.expect(!line.lowercased().contains("re-test"),
                         "a second name for the same thing: \"\(line)\"")
            }
        }
    }

    t.suite("aiming a drill at one habit") { t in

        t.test("it picks the arrangement most likely to produce the situation") {
            let wanted: [SpotKind: String] = [
                .bigBlindDefence: "big-blind",
                .shortStackShove: "short-stack",
                .facingRiverBet: "bet-at-you",
                .facingReraise: "facing-heat",
                .riverDecision: "deep-and-sticky",
            ]
            for (spot, setup) in wanted {
                t.equal(DrillPlanner.next(for: spot).id, setup,
                        "a drill at \(spot.name) should arrange the table for it")
            }
        }

        t.test("every habit in the catalogue can actually be drilled") {
            for leak in LeakCatalogue.all {
                t.expect(!DrillPlanner.setups(for: leak).isEmpty,
                         "nothing arranges the table for \(leak.name)")
            }
        }

        t.test("a habit whose whole point is the seat is drilled from that seat") {
            // The situation is not enough to pick an arrangement by. Three habits live in "opening
            // the pot" and two of them want opposite ends of the table, so ranking on the situation
            // alone sat the player first to speak for "not attacking the blinds" — a seat with no
            // blinds in front of it to attack — while the brief a few lines above the felt said
            // "seats 9 and 8, the button and the cutoff".
            let steals = LeakCatalogue.named("passed-steals")!
            let tooMany = LeakCatalogue.named("too-many-hands")!
            t.equal(DrillPlanner.next(for: steals).seat, .button,
                    "the drill for not attacking the blinds is not in the last seat")
            t.equal(DrillPlanner.next(for: tooMany).seat, .underTheGun,
                    "the drill for playing too many hands is not in the first seat")
            t.notEqual(DrillPlanner.next(for: steals).id, DrillPlanner.next(for: tooMany).id,
                       "two habits that want opposite ends of the table got the same arrangement")
        }

        t.test("a pinned arrangement is the only one, so the yield cannot wander off it") {
            // Dropping an arrangement that stops producing the situation is right for a habit that
            // any seat can show. It is wrong for one that only exists in a seat: switching would
            // put the player back where the mistake cannot happen and count their correct folds as
            // clean chances.
            let steals = LeakCatalogue.named("passed-steals")!
            var yield = SetupYield()
            for _ in 0..<20 { yield.saw("last-seats", spots: []) }
            t.equal(DrillPlanner.next(for: steals, yield: yield).id, "last-seats")
            t.equal(DrillPlanner.setups(for: steals).count, 1)
        }

        t.test("every arrangement a habit asks for is one that exists and produces its situation") {
            for leak in LeakCatalogue.all {
                guard let id = leak.drillSetup else { continue }
                guard let setup = AssessmentPlanner.setup(id: id) else {
                    t.expect(false, "\(leak.id) asks for an arrangement called '\(id)', "
                             + "which is not one of them")
                    continue
                }
                t.greaterThan(setup.expected[leak.spot] ?? 0, 0,
                              "\(leak.id) is drilled at '\(id)', which does not produce "
                              + "\(leak.spot.name) at all")
            }
        }

        t.test("the sheet promises the seat the table then deals") {
            // The brief and the table used to ask two different questions and print two different
            // answers on the same screen.
            let steals = LeakCatalogue.named("passed-steals")!
            let session = TableSession(seed: 31, history: .inMemory())
            session.startDrill(steals)
            driveDrill(session, player: .control, seed: 31, stoppingAfter: 6)
            t.greaterThan(session.history.hands.count, 0, "the drill wrote nothing down")
            for hand in session.history.hands {
                t.equal(hand.setup, DrillPlanner.next(for: steals).id,
                        "the brief said one arrangement and the table dealt another")
                t.equal(hand.position, Position.button.rawValue,
                        "hand \(hand.handNumber) was played from \(hand.position)")
            }
        }

        t.test("an arrangement that stops producing the situation is dropped") {
            // The same lesson the assessment learned the expensive way: a setup can promise river
            // decisions and never get anybody as far as the river, and a planner that took the
            // promise at face value would deal that hand for ever.
            var yield = SetupYield()
            let first = DrillPlanner.next(for: .riverDecision)
            for _ in 0..<8 { yield.saw(first.id, spots: []) }
            t.notEqual(DrillPlanner.next(for: .riverDecision, yield: yield).id, first.id,
                       "it kept arranging a table that was not producing the situation")
        }
    }

    t.suite("a drill at the table") { t in

        t.test("every hand is written down as a drill hand naming the habit") {
            let session = TableSession(seed: 404, history: .inMemory())
            session.startDrill(bigBlind)
            driveDrill(session, player: .control, seed: 404, stoppingAfter: 12)
            let hands = session.history.hands
            t.greaterThan(hands.count, 0, "the drill wrote nothing down")
            for hand in hands {
                t.expect(hand.isDrill, "hand \(hand.handNumber) was not marked as a drill hand")
                t.equal(hand.drill, bigBlind.id)
                t.equal(hand.tier, nil, "an arranged hand must never count towards the ladder")
                t.expect(hand.setup != nil, "the file should say how the table was arranged")
            }
        }

        t.test("it keeps putting the player in the one situation") {
            let session = TableSession(seed: 909, history: .inMemory())
            session.startDrill(bigBlind)
            driveDrill(session, player: .control, seed: 909, stoppingAfter: 30)
            guard let run = session.drill else { return t.expect(false, "no drill") }
            t.greaterThan(Double(run.chances) / Double(max(1, run.handsPlayed)), 0.4,
                          "\(run.chances) chances in \(run.handsPlayed) hands is not a drill, it "
                          + "is a game")
        }

        t.test("it clears for a player without the habit and refuses to for one with it") {
            // Never off one seed. A single drill swings on whether the deal happened to put a
            // raise in front of the player, and this measures a rate.
            var cleanCleared = 0
            var flawedCleared = 0
            for seed: UInt64 in [11, 2_020, 30_303, 404_040] {
                let clean = TableSession(seed: seed, history: .inMemory())
                clean.startDrill(bigBlind)
                driveDrill(clean, player: .control, seed: seed)
                if clean.drill?.cleared == true { cleanCleared += 1 }

                let flawed = TableSession(seed: seed, history: .inMemory())
                flawed.startDrill(bigBlind)
                driveDrill(flawed, player: .foldsBigBlind, seed: seed)
                if flawed.drill?.cleared == true { flawedCleared += 1 }
            }
            t.equal(flawedCleared, 0,
                    "a player who folds every big blind cleared the drill for it \(flawedCleared) "
                    + "times out of four")
            t.greaterThan(cleanCleared, 2,
                          "a sound player only cleared \(cleanCleared) of four, which makes the "
                          + "drill unfinishable rather than demanding")
        }

        t.test("each habit in the opening spot is drilled where it can actually show itself") {
            // The three habits that share "opening the pot" are the ones the arrangement decides,
            // so all three are measured rather than argued about. Six seeds, the full ninety
            // hands, sound player against the matching flawed one, counting sittings cleared:
            //
            //   habit             arrangement    sound    flawed    at the other end
            //   too many hands    early-seat      6/6      0/6       0/6 and 1/6
            //   limping           last-seats      5/6      1/6       6/6 and 3/6
            //   not attacking     last-seats      5/6      3/6       both clear on folds
            //
            // The right-hand column is why these are pinned. Ranked on the situation alone, all
            // three went to the first seat to speak — where every hand is an opening chance and
            // almost none of them is worth raising, so twenty-odd correct folds finish the drill
            // and finish it for the flawed player too.
            //
            // "Not attacking the blinds" at 5 against 3 is honest rather than good, and it is the
            // weakest of the three on purpose: the habit can only show itself in the third or so of
            // unopened pots that were worth raising, while `minimumSample` and `acceptableRate` are
            // defined over all of them. Sharpening that means re-deriving both numbers for all
            // eighteen habits from a sweep, which is a measurement project rather than a fix. What
            // is guarded here is that each drill is finishable and that none of them is blind.
            let seeds: [UInt64] = [11, 2_020, 30_303, 404_040, 77, 512]
            let pairs: [(String, PlantedPlayer)] = [
                ("too-many-hands", .playsEverything),
                ("limping", .limpsEverything),
                ("passed-steals", neverAttacksTheBlinds),
            ]
            for (id, player) in pairs {
                guard let leak = LeakCatalogue.named(id) else {
                    t.expect(false, "no habit called \(id)")
                    continue
                }
                var cleanCleared = 0
                var flawedCleared = 0
                for seed in seeds {
                    let clean = TableSession(seed: seed, history: .inMemory())
                    clean.startDrill(leak)
                    driveDrill(clean, player: .control, seed: seed)
                    if clean.drill?.cleared == true { cleanCleared += 1 }

                    let flawed = TableSession(seed: seed, history: .inMemory())
                    flawed.startDrill(leak)
                    driveDrill(flawed, player: player, seed: seed)
                    if flawed.drill?.cleared == true { flawedCleared += 1 }
                }
                t.greaterThan(cleanCleared, 3,
                              "a sound player cleared the \(id) drill \(cleanCleared) of "
                              + "\(seeds.count) times, which makes it unfinishable rather than "
                              + "demanding")
                t.lessThan(flawedCleared, cleanCleared,
                           "a player who \(player.name) cleared the \(id) drill \(flawedCleared) "
                           + "times against the sound player's \(cleanCleared) — it is not seeing "
                           + "the habit at all")
            }
        }

        t.test("a drill that did not clear says so rather than letting anybody off") {
            let session = TableSession(seed: 55, history: .inMemory())
            session.startDrill(bigBlind)
            driveDrill(session, player: .foldsBigBlind, seed: 55)
            guard let run = session.drill else { return t.expect(false, "no drill") }
            t.expect(!run.cleared)
            t.expect(run.ranOut, "it should have run its full length before giving up")
            t.equal(run.outcomeTitle, "Not cleared")
            t.expect(run.outcomeLine.contains("not fixed"),
                     "the report has to say it out loud: \"\(run.outcomeLine)\"")
        }

        t.test("a sitting that barely produced the situation settles nothing either way") {
            // Measured rather than supposed. A full ninety-hand drill at the turn habit produced
            // five to thirteen turn decisions across six seeds (11, 2020, 30303, 404040, 77, 512),
            // against a habit that takes a great many more before a rate off them means anything —
            // see the numbers on `missed-turn-value` in the catalogue. The ending used to read
            // "ninety hands and it is still happening, so this one is not fixed" off six chances,
            // which is an accusation wearing a measurement's clothes. Two endings, and this is the
            // second one.
            let turn = LeakCatalogue.named("missed-turn-value")!
            let session = TableSession(seed: 11, history: .inMemory())
            session.startDrill(turn)
            driveDrill(session, player: .control, seed: 11)
            guard let run = session.drill else { return t.expect(false, "no drill") }
            t.expect(run.ranOut, "the sitting was meant to run its full length")
            t.expect(!run.cleared)
            t.lessThan(run.chances, DrillRule.floor(for: turn),
                       "this fixture only means anything while the sitting cannot reach the "
                       + "chances the habit needs; it got \(run.chances)")
            t.expect(run.tooFewChances)
            t.equal(run.outcomeTitle, "Not enough to say")
            t.expect(!run.outcomeLine.contains("not fixed"),
                     "it called a habit unfixed off \(run.chances) chances: \"\(run.outcomeLine)\"")
            t.expect(run.outcomeLine.contains("settled it either way"),
                     "it does not say what it could not do: \"\(run.outcomeLine)\"")

            // And the row on the scorecard afterwards says the same thing rather than the other
            // one, since it is built from the same hands.
            let now = session.history.hands.map(\.playedAt).max() ?? Date()
            guard let standing = DrillBook(of: session.history.hands, now: now)
                .standing(of: turn.id) else {
                return t.expect(false, "the drill was not recorded")
            }
            t.equal(standing.stage, .stalled)
            t.equal(standing.badge(now: now), "drilled, not measured")
            t.expect(standing.line(now: now).contains("settled nothing either way"),
                     "the row still accuses them: \"\(standing.line(now: now))\"")

            // A drill that did get its chances and kept going wrong still says so — that sentence
            // is the one this must not have replaced.
            let honest = DrillRun.resuming(from: drilled(bigBlind, hands: DrillRule.handLimit,
                                                         wrong: 40, session: "stuck"),
                                           leak: bigBlind)
            t.expect(!honest.tooFewChances)
            t.expect(honest.outcomeLine.contains("not fixed"),
                     "the plain ending went missing: \"\(honest.outcomeLine)\"")
        }

        t.test("a check at the table asks its twelve questions and then answers") {
            let session = TableSession(seed: 61, history: .inMemory())
            session.startDrill(bigBlind, retest: true)
            driveDrill(session, player: .foldsBigBlind, seed: 61)
            guard let run = session.drill else { return t.expect(false, "no check") }
            t.expect(run.isRetest)
            t.expect(run.failed, "a player who folds every big blind passed the check")
            t.equal(run.outcomeTitle, "It came back")
            t.equal(run.chances, DrillRule.retestChances,
                    "a check that ran on past its own length is not a check")
            t.greaterThan(session.history.hands.count, 0, "the check wrote nothing down")
            for hand in session.history.hands {
                t.expect(hand.isRetest, "hand \(hand.handNumber) was not marked as a check")
                t.equal(hand.drill, bigBlind.id)
            }
        }

        t.test("stopping half way costs nothing") {
            // The same rule as everywhere else here: there is no second copy of the progress,
            // because the hands are the record.
            let store = HistoryStore.inMemory()
            let session = TableSession(seed: 7, history: store)
            session.startDrill(bigBlind)
            driveDrill(session, player: .control, seed: 7, stoppingAfter: 10)
            guard let stopped = session.drill else { return t.expect(false, "no drill") }
            let id = session.sessionID

            let rebuilt = DrillRun.resuming(from: store.hands(in: .session(id)), leak: bigBlind)
            t.equal(rebuilt.handsPlayed, stopped.handsPlayed, "the hands played came back wrong")
            t.equal(rebuilt.chances, stopped.chances, "the chances came back wrong")
            t.equal(rebuilt.mistakes, stopped.mistakes, "the mistakes came back wrong")

            // And a fresh session, the way the next launch would build one, carries it on.
            let next = TableSession(seed: 7, history: store)
            t.expect(next.resumeDrill(bigBlind, sessionID: id), "it would not pick the drill back up")
            t.equal(next.drill?.chances, stopped.chances)
            t.expect(next.isPlaying, "resuming should deal the next hand")
        }

        t.test("restarting a drill and then going back to the first one is still two attempts") {
            // A sitting is an attempt, and which sitting a hand belongs to is written on the hand.
            // Grouping by what happens to sit next to what in the file turned one drill that was
            // restarted and then picked back up into three attempts, the last of them holding only
            // the handful played after the restart — so the book reported ten chances as five while
            // `resumeDrill` was about to load all ten off the same sitting.
            let firstHalf = drilled(bigBlind, hands: 5, wrong: 1, session: "s1", on: start)
            let restart = drilled(bigBlind, hands: 5, wrong: 0, session: "s2",
                                  on: start.addingTimeInterval(day), from: 100)
            let backToTheFirst = drilled(bigBlind, hands: 5, wrong: 1, session: "s1",
                                         on: start.addingTimeInterval(day * 2), from: 200)

            let book = DrillBook(of: firstHalf + restart + backToTheFirst,
                                 now: start.addingTimeInterval(day * 3))
            guard let standing = book.standing(of: bigBlind.id) else {
                return t.expect(false, "the drill was not recorded at all")
            }
            t.equal(standing.stage, .running)
            t.equal(standing.sessionID, "s1", "the sitting the player was last in is the open one")
            t.equal(standing.hands, 10, "the resumed sitting was counted as a third, shorter attempt")
            t.equal(standing.mistakes, 2, "half the sitting's slips went missing")

            // And the book now agrees with what resuming that sitting would actually load.
            let resumed = DrillRun.resuming(from: (firstHalf + backToTheFirst), leak: bigBlind)
            t.equal(standing.hands, resumed.handsPlayed,
                    "the book and the resume disagree about how far along the drill is")
            t.equal(standing.chances, resumed.chances)

            // Nothing changes for the ordinary case, where a sitting's hands are all together.
            let tidy = DrillBook(of: firstHalf + restart, now: start.addingTimeInterval(day * 3))
            t.equal(tidy.standing(of: bigBlind.id)?.sessionID, "s2")
            t.equal(tidy.standing(of: bigBlind.id)?.hands, 5)
        }

        t.test("the half-done drill offered on the home screen is the one you were in") {
            // `all` is sorted by habit id so that everything built on it comes out in the same
            // order every launch, and taking the first half-done drill off that list offered
            // whichever habit happened to sort earliest in the alphabet. Somebody who stopped part
            // way through the limping drill this evening was offered "carry on" on a big blind
            // drill they had walked away from last month.
            t.lessThan(bigBlind.id, limping.id,
                       "this test only means anything while the older one sorts first")
            let lastMonth = drilled(bigBlind, hands: 6, wrong: 1, session: "old", on: start)
            let tonight = drilled(limping, hands: 6, wrong: 1, session: "new",
                                  on: start.addingTimeInterval(day * 30), from: 400)
            let book = DrillBook(of: lastMonth + tonight,
                                 now: start.addingTimeInterval(day * 30 + 3600))
            t.equal(book.standing(of: bigBlind.id)?.stage, .running)
            t.equal(book.standing(of: limping.id)?.stage, .running)
            t.equal(book.unfinished?.id, limping.id,
                    "it offered the drill that sorts first rather than the one just walked away from")
        }
    }

    t.suite("checking back days later") { t in

        // Everything here is answered against a clock that is passed in, so a fortnight of
        // scheduling is tested in a millisecond rather than waited out.

        let cleared = drilled(paced, hands: 30, wrong: 0, session: "d1", on: start)

        t.test("a cleared drill puts a check in the diary rather than closing the matter") {
            let soon = DrillBook(of: cleared, now: start.addingTimeInterval(day))
            guard let standing = soon.standing(of: paced.id) else {
                return t.expect(false, "the drill was not recorded at all")
            }
            t.equal(standing.stage, .holding)
            t.expect(standing.clearedOn != nil)
            t.expect(standing.dueOn != nil, "a cleared drill with no check owed is a forgotten one")
            t.expect(soon.dueNow.isEmpty, "it should not be due the next morning")
        }

        t.test("the check comes due on its day and not before") {
            let dayBefore = DrillBook(of: cleared,
                                      now: start.addingTimeInterval(day * (DrillRule.firstGapInDays - 1)))
            t.equal(dayBefore.standing(of: paced.id)?.stage, .holding)
            let dayAfter = DrillBook(of: cleared,
                                     now: start.addingTimeInterval(day * (DrillRule.firstGapInDays + 1)))
            t.equal(dayAfter.standing(of: paced.id)?.stage, .due)
            t.equal(dayAfter.dueNow.map(\.id), [paced.id],
                    "a check that is due has to turn up where the player will see it")
        }

        t.test("it survives the app being closed, because the hands do") {
            let url = FileManager.default.temporaryDirectory
                .appendingPathComponent("poker-drills-\(UUID().uuidString).jsonl")
            defer { try? FileManager.default.removeItem(at: url) }

            let writing = HistoryStore(url: url)
            for hand in cleared { writing.append(hand) }

            // A second store opening the same file is what the next launch does.
            let reading = HistoryStore(url: url)
            let later = start.addingTimeInterval(day * (DrillRule.firstGapInDays + 1))
            let book = DrillBook(of: reading.hands, now: later)
            t.equal(book.standing(of: paced.id)?.stage, .due,
                    "the check did not survive a restart")
            t.equal(book.standing(of: paced.id)?.clearedOn,
                    DrillBook(of: cleared, now: later).standing(of: paced.id)?.clearedOn,
                    "the date it cleared on came back different")
        }

        t.test("a check that held up buys a longer gap before the next one") {
            let checkDay = start.addingTimeInterval(day * 6)
            let passed = drilled(paced, hands: 12, wrong: 0, session: "r1", retest: true,
                                 on: checkDay, from: 100)
            let book = DrillBook(of: cleared + passed, now: checkDay.addingTimeInterval(3600))
            guard let standing = book.standing(of: paced.id) else {
                return t.expect(false, "no standing")
            }
            t.equal(standing.stage, .holding)
            t.equal(standing.checksPassed, 1)
            guard let due = standing.dueOn, let checked = standing.checkedOn else {
                return t.expect(false, "a passed check left nothing in the diary")
            }
            t.close(due.timeIntervalSince(checked) / day, DrillRule.firstGapInDays * 2, 0.01,
                    "the gap should widen once it has held up")
        }

        t.test("one slip does not fail a check") {
            let checkDay = start.addingTimeInterval(day * 6)
            let nearly = drilled(paced, hands: 12, wrong: 1, session: "r1", retest: true,
                                 on: checkDay, from: 100)
            let book = DrillBook(of: cleared + nearly, now: checkDay.addingTimeInterval(3600))
            t.equal(book.standing(of: paced.id)?.stage, .holding,
                    "one bad decision is a bad decision, not a habit coming back")
        }

        t.test("a check walked away from is still owed") {
            let checkDay = start.addingTimeInterval(day * 6)
            let abandoned = drilled(paced, hands: 4, wrong: 0, session: "r1", retest: true,
                                    on: checkDay, from: 100)
            let book = DrillBook(of: cleared + abandoned, now: checkDay.addingTimeInterval(3600))
            t.equal(book.standing(of: paced.id)?.stage, .due,
                    "an unfinished check must not count as a passed one")
        }

        t.test("a check walked away from starts again, and the screen says so") {
            // The rule stands: a check that could be restarted after three bad answers is a check
            // anybody can pass by quitting, and the whole point of it is that it cannot be steered.
            // What was missing is that the player was never told. They stopped at eight of twelve,
            // came back to a card offering "Check it — 12 chances", and the eight they had played
            // were quietly asked again.
            let checkDay = start.addingTimeInterval(day * 6)
            let now = checkDay.addingTimeInterval(3600)
            let abandoned = drilled(paced, hands: 8, wrong: 1, session: "r1", retest: true,
                                    on: checkDay, from: 100)
            let book = DrillBook(of: cleared + abandoned, now: now)
            guard let standing = book.standing(of: paced.id) else {
                return t.expect(false, "no standing")
            }
            t.equal(standing.stage, .due)
            t.expect(standing.sessionID != nil, "the abandoned sitting was forgotten entirely")
            let line = standing.line(now: now)
            t.expect(line.contains("stopped 8 chances in"),
                     "it does not say how far they got: \"\(line)\"")
            t.expect(line.contains("begins again"),
                     "it does not say the check starts over: \"\(line)\"")
            // And a check that is owed because the days have passed says none of that, because
            // none of it happened.
            let waited = DrillBook(of: cleared, now: start.addingTimeInterval(day * 30))
            guard let plain = waited.standing(of: paced.id) else {
                return t.expect(false, "no standing")
            }
            t.equal(plain.stage, .due)
            t.expect(!plain.line(now: start.addingTimeInterval(day * 30)).contains("begins again"),
                     "a check nobody has started was described as one they abandoned")
        }
    }

    t.suite("a habit that comes back") { t in

        let cleared = drilled(paced, hands: 30, wrong: 0, session: "d1", on: start)
        let checkDay = start.addingTimeInterval(day * 6)
        let failed = drilled(paced, hands: 12, wrong: 5, session: "r1", retest: true,
                             on: checkDay, from: 100)
        let now = checkDay.addingTimeInterval(3600)

        t.test("a failed check reopens it rather than quietly forgetting it") {
            let book = DrillBook(of: cleared + failed, now: now)
            guard let standing = book.standing(of: paced.id) else {
                return t.expect(false, "no standing")
            }
            t.equal(standing.stage, .reopened)
            t.equal(book.reopened.map(\.id), [paced.id])
            t.expect(standing.line(now: now).contains("come back"),
                     "the row has to say what happened: \"\(standing.line(now: now))\"")
        }

        t.test("a row put back by a check shows the check's numbers, not the empty ones beside it") {
            // Drills are read over everything ever played and the scorecard follows the scope bar,
            // so pointing the screen at one sitting put a row under the heading "what is costing
            // you" whose own sentence read "This has not come up yet", with a note underneath
            // saying "Only 0 chances so far" and an empty bar. The app telling somebody a habit is
            // costing them money and, one line down, that it has never seen it happen.
            let elsewhere = ordinary(limping, hands: 40, wrong: 0)
            let book = DrillBook(of: cleared + failed, now: now)
            let card = LeakScorecard(of: elsewhere, drills: book)
            guard let row = card.costing.first(where: { $0.id == paced.id }) else {
                return t.expect(false, "the reopened habit is not on the list at all")
            }
            t.equal(row.chances, 0, "these hands were never in that situation, which is the point")
            t.expect(row.frequencyLine.contains("5 of 12"),
                     "the row does not print the check's numbers: \"\(row.frequencyLine)\"")
            t.expect(!row.frequencyLine.contains("has not come up yet"),
                     "\"costing you\" over \"has not come up yet\": \"\(row.frequencyLine)\"")
            t.equal(row.sampleNote, nil,
                    "the small-sample note is measuring the wrong sample: \"\(row.sampleNote ?? "")\"")
            // The check's own rate, which is the one with the forgiven slip in it. This assertion
            // used to read 5/12 and it was wrong in the same way the drill report was before commit
            // 3c9120c: the check passes or fails on the smoothed number, so printing the raw one
            // beside the verdict is two versions of one measurement. It is 6/14 now.
            t.close(row.rate, 6.0 / 14.0, 0.001, "the bar was empty on a row that names a habit")
            t.expect(row.frequencyLine.contains("One slip is always added"),
                     "the row prints a percentage worked out differently from the fraction beside "
                     + "it, and says nothing about why: \"\(row.frequencyLine)\"")

            // And a row with real evidence of its own still reads off the hands in view.
            let seen = ordinary(paced, hands: 40, wrong: 12)
            let plenty = LeakScorecard(of: seen, drills: book)
            guard let full = plenty.costing.first(where: { $0.id == paced.id }) else {
                return t.expect(false, "no row")
            }
            t.equal(full.reopenedCheck, nil, "40 chances in view do not need the check to speak for them")
            t.expect(full.frequencyLine.contains("12 times out of 40"), full.frequencyLine)
        }

        t.test("a check failed by one slip prints the number it was failed on") {
            // The narrow case the split hid in, and the one that mattered most: two slips in twelve
            // is 17% raw and 21% once the forgiven slip is counted, against a bar of 18%. So the
            // check fails, the row goes under "what is costing you" — and the raw version printed
            // 17% beside it and drew a green bar stopping short of the mark, on the same screen
            // that tells the reader a bar stopping short of the mark is a part of their game that
            // is working. Six of the eighteen habits have a bar inside that window.
            let narrow = drilled(paced, hands: 12, wrong: 2, session: "r2", retest: true,
                                 on: checkDay, from: 300)
            let book = DrillBook(of: cleared + narrow, now: now)
            guard let standing = book.standing(of: paced.id) else {
                return t.expect(false, "no standing")
            }
            t.equal(standing.stage, .reopened, "2 of 12 is 21% with the slip in it, past an 18% bar")
            let card = LeakScorecard(of: ordinary(limping, hands: 40, wrong: 0), drills: book)
            guard let row = card.costing.first(where: { $0.id == paced.id }) else {
                return t.expect(false, "the reopened habit is not on the list at all")
            }
            t.expect(row.percent >= row.acceptablePercent,
                     "a row on the costing list printed \(row.percent)% against a bar of "
                     + "\(row.acceptablePercent)%, which reads as better than a steady player")
            t.expect(row.frequencyLine.contains("2 of 12"), row.frequencyLine)
            t.expect(row.frequencyLine.contains("21%"),
                     "the fraction and the percentage are different arithmetic and the row has to "
                     + "say so: \"\(row.frequencyLine)\"")
        }

        t.test("and it goes back on the scorecard, whatever the recent hands say") {
            // The ordinary hands below are clean, so nothing in them would put this habit on the
            // list. The check is the evidence, and it is evidence in exactly the same situation.
            let hands = ordinary(paced, hands: 40, wrong: 0) + cleared + failed
            let book = DrillBook(of: hands, now: now)
            let card = LeakScorecard(of: hands, drills: book)
            t.expect(card.costing.map(\.id).contains(paced.id),
                     "a habit that came back is not on the list: \(card.costing.map(\.id))")
            t.equal(card.reopened.map(\.id), [paced.id])
        }

        t.test("drilling it again clears it again") {
            let secondGo = drilled(paced, hands: 30, wrong: 0, session: "d2",
                                   on: checkDay.addingTimeInterval(day), from: 200)
            let book = DrillBook(of: cleared + failed + secondGo,
                                 now: checkDay.addingTimeInterval(day * 2))
            t.equal(book.standing(of: paced.id)?.stage, .holding)
            t.expect(book.reopened.isEmpty)
        }
    }

    t.suite("practice is not evidence") { t in

        t.test("drill hands stay out of the leak scorecard") {
            // Otherwise anybody could drill a habit off their own record without playing a single
            // hand of poker differently.
            let hands = ordinary(paced, hands: 30, wrong: 20)
                + drilled(paced, hands: 40, wrong: 0, session: "d1")
            let card = LeakScorecard(of: hands)
            t.equal(card.hands, 30, "drill hands were counted as hands played")
            let entry = card.allEntries.first { $0.id == paced.id }
            t.equal(entry?.chances, 30, "forty drill decisions leaked into the count")
            t.equal(entry?.times, 20)
        }

        t.test("drill hands stay out of the win rate, and the screen says so") {
            let hands = ordinary(paced, hands: 10, wrong: 2)
                + drilled(paced, hands: 12, wrong: 0, session: "d1")
            let analysis = GameAnalysis(of: hands)
            t.equal(analysis.hands, 10)
            t.equal(analysis.drillHands, 12)
            guard let note = analysis.arrangedNote else {
                return t.expect(false, "nothing explains where the missing hands went")
            }
            t.expect(note.contains("drill"), "the note does not mention the drill hands")
        }

        t.test("a hand written down before drills existed still reads") {
            // The rule this whole file lives under: a new field is optional, or one launch of a
            // new build deletes every hand anybody ever played.
            let old = #"{"sessionID":"s1","sessionStarted":"2026-01-01T10:00:00Z","playedAt":"2026-01-01T10:01:00Z","handNumber":1,"mode":"Tournament","bigBlind":100,"position":"BB","holeCards":"Ah Kd","board":"","netChips":-100,"wentToShowdown":false,"sawFlop":false,"putMoneyInPreflop":false,"raisedPreflop":false,"verdicts":[]}"#
            let decoder = JSONDecoder()
            decoder.dateDecodingStrategy = .iso8601
            guard let record = try? decoder.decode(HandRecord.self, from: Data(old.utf8)) else {
                return t.expect(false, "a hand with no drill field stopped decoding")
            }
            t.equal(record.drill, nil)
            t.expect(!record.isDrill)
            t.expect(!record.isArranged)
        }
    }
}