Advertisement

← poker-tutor

Sources/PokerTests/ScorecardTests.swift

38.5 KB Download file
import Foundation
import PokerCore
import PokerCoach
import PokerSession
import PokerUI
import TestKit

/// The scorecard's whole job is to be believed, so what is tested here is mostly what it refuses to
/// say: it will not rank a mistake seen twice above one seen forty times, it will not print a cost
/// it cannot work out, and it will not hand a player who is doing fine a list of things to worry
/// about.

/// One decision, as the coach would have written it down. `spot` and `leak` are stored the way the
/// real file stores them — the printed names — because that is what the scorecard has to read back.
private func decision(_ spot: SpotKind, _ error: ErrorKind?, cost: Double = 1.0,
                      confidence: Double? = 0.9, severity: Int = 3,
                      spoke: Bool = true) -> VerdictRecord {
    VerdictRecord(street: "flop", spot: spot.name, leak: error?.name ?? "",
                  severity: error == nil ? 0 : severity, costInBB: error == nil ? 0 : cost,
                  headline: "h", detail: "d", taken: "call", best: "fold",
                  spoke: error == nil ? false : spoke, confidence: confidence)
}

private func hand(session: String = "s1", number: Int, position: String = "BB",
                  tableSize: Int? = 9, net: Int = -100, kind: String? = nil,
                  _ verdicts: [VerdictRecord] = []) -> HandRecord {
    HandRecord(sessionID: session,
               sessionStarted: Date(timeIntervalSince1970: 1_780_000_000),
               playedAt: Date(timeIntervalSince1970: 1_780_000_000 + Double(number) * 60),
               handNumber: number,
               mode: "Tournament",
               bigBlind: 100,
               position: position,
               holeCards: "Ah Kd",
               board: "2c 7h Js",
               netChips: net,
               wentToShowdown: false,
               sawFlop: true,
               putMoneyInPreflop: true,
               raisedPreflop: false,
               verdicts: verdicts,
               tableSize: tableSize,
               kind: kind)
}

/// `count` hands in the given spot, `wrong` of them mistakes, each one costing `cost`.
///
/// `kind` is what makes a hand an arranged one — an assessment hand, where the seat and the chips
/// in front of everybody were chosen rather than dealt.
private func run(_ spot: SpotKind, _ error: ErrorKind, chances: Int, wrong: Int,
                 cost: Double = 1.0, from: Int = 1, position: String = "BB",
                 session: String = "s1", kind: String? = nil,
                 severity: Int = 3, spoke: Bool = true) -> [HandRecord] {
    (0..<chances).map { index in
        hand(session: session, number: from + index, position: position, kind: kind,
             [decision(spot, index < wrong ? error : nil, cost: cost,
                       severity: severity, spoke: spoke)])
    }
}

public func scorecardTests(_ t: TestRun) {

    t.suite("the leak scorecard") { t in

        t.test("leaks rank by what they have cost, worst first") {
            // Two real habits, both well past their minimum sample. The cheap one happens more
            // often, which is exactly the case where a screen ranking by frequency would mislead.
            var hands = run(.facingContinuationBet, .foldedTooMuch, chances: 60, wrong: 30,
                            cost: 0.4, from: 1)
            // 180 river chances rather than 60: the river-call habit takes 150 before it can be
            // named at all, because a player with nothing wrong is still touching 29% with a
            // hundred chances behind him — see the sweep in `LeakCatalogue`. The fixture moved with
            // the number; what it is testing is unchanged.
            hands += run(.facingRiverBet, .calledTooWide, chances: 180, wrong: 60, cost: 3.0, from: 100)
            let card = LeakScorecard(of: hands)

            t.equal(card.costing.map(\.id), ["river-hero-call", "fold-to-cbet"],
                    "the expensive habit should lead, not the frequent one")
            t.close(card.costing[0].costInBB ?? 0, 180, 0.001)
            t.close(card.costing[1].costInBB ?? 0, 12, 0.001)
            t.equal(card.entries.first?.id, "river-hero-call")
            // Ranked by money, but still reported per hundred hands, which is how it is read.
            t.close(card.costing[0].costPer100 ?? 0, 75, 0.01, "240 hands, 180 big blinds")
        }

        t.test("a mistake seen twice is never ranked above a habit seen forty times") {
            // The trap: two enormous mistakes in three chances look like the worst thing in the
            // game if cost is all that decides the order.
            var hands = run(.openingChance, .limpedInstead, chances: 60, wrong: 24, cost: 0.5, from: 1)
            hands += run(.facingReraise, .foldedTooMuch, chances: 3, wrong: 2, cost: 40, from: 100)
            let card = LeakScorecard(of: hands)

            t.equal(card.costing.map(\.id), ["limping"], "a leak seen twice was called a habit")
            t.equal(card.watching.map(\.id), ["fold-to-3bet"])
            t.equal(card.entries.first?.id, "limping", "the thin leak outranked the real one")
            t.expect(card.entries.firstIndex { $0.id == "limping" }!
                     < card.entries.firstIndex { $0.id == "fold-to-3bet" }!)
        }

        t.test("small samples are labelled rather than reported as fact") {
            let thin = LeakScorecard(of: run(.facingReraise, .foldedTooMuch, chances: 6, wrong: 4))
            guard let entry = thin.entries.first(where: { $0.id == "fold-to-3bet" }) else {
                return t.expect(false, "the leak was not measured at all")
            }
            t.equal(entry.standing, .watching)
            t.expect(!entry.enoughChances, "6 chances is not a read on anyone")
            t.expect(entry.sampleNote != nil, "a thin sample went unlabelled")
            t.expect(entry.sampleNote?.contains("15") == true,
                     "the note should say how many chances it takes: \(entry.sampleNote ?? "—")")
            t.expect(thin.costing.isEmpty, "a leak off 6 chances was reported as a finding")

            // The same rate, past the minimum sample, is a finding.
            let solid = LeakScorecard(of: run(.facingReraise, .foldedTooMuch, chances: 30, wrong: 20))
            guard let confirmed = solid.entries.first(where: { $0.id == "fold-to-3bet" }) else {
                return t.expect(false, "the leak was not measured at all")
            }
            t.equal(confirmed.standing, .costing)
            t.expect(confirmed.enoughChances)
            t.expect(confirmed.sampleNote == nil, "a full sample was still hedged")
        }

        t.test("a cost off some of the instances says which some") {
            // Half the instances carry a price and half do not, which is the ordinary case. The
            // money on the row is the priced half and nothing else, and beside "20 times out of 40
            // chances" a reader will divide it by 20 and be wrong by a factor of two. Erring low is
            // not the same thing as being clear about it.
            var hands = run(.facingRiverBet, .calledTooWide, chances: 20, wrong: 10, cost: 3.0)
            hands += run(.facingRiverBet, .calledTooWide, chances: 20, wrong: 10, cost: 0, from: 100)
            guard let entry = LeakScorecard(of: hands).entries
                .first(where: { $0.id == "river-hero-call" }) else {
                return t.expect(false, "the leak was not measured at all")
            }
            t.equal(entry.times, 20)
            t.equal(entry.pricedTimes, 10, "only half of them were recorded with a price")
            t.close(entry.costInBB ?? 0, 30, 0.001, "the money is the priced half")
            guard let note = entry.pricedNote else {
                return t.expect(false, "the row printed a cost off half the instances in silence")
            }
            t.expect(note.contains("10 of the 20"), "it has to say which some: \"\(note)\"")
        }

        t.test("a cost that cannot be worked out is not invented") {
            // Every instance recorded without a price. The count is real; the money is not known,
            // and a nought here would read as "this one is free".
            let hands = run(.facingContinuationBet, .foldedTooMuch, chances: 30, wrong: 15, cost: 0)
            let card = LeakScorecard(of: hands)
            guard let entry = card.entries.first(where: { $0.id == "fold-to-cbet" }) else {
                return t.expect(false, "the leak was not measured at all")
            }
            t.equal(entry.times, 15, "the frequency is still measured")
            t.expect(entry.costInBB == nil, "a cost was invented from nothing")
            t.expect(entry.costPer100 == nil)
            t.expect(!entry.costIsKnown)
            t.expect(entry.costLine.contains("not being guessed at"),
                     "the row should say the cost is unknown: \(entry.costLine)")
            t.equal(entry.standing, .costing, "not knowing the cost does not make it acceptable")
            t.expect(card.costPer100 == nil, "the headline invented a total")
            t.expect(entry.pricedNote == nil,
                     "there is no cost on this row, so there is nothing to qualify")

            // Priced habits rank above unpriced ones rather than an unpriced one being treated as
            // free and dropped to the bottom of the list.
            var mixed = hands
            mixed += run(.facingRiverBet, .calledTooWide, chances: 180, wrong: 60, cost: 2, from: 100)
            let ranked = LeakScorecard(of: mixed)
            t.equal(ranked.costing.map(\.id), ["river-hero-call", "fold-to-cbet"])
        }

        t.test("what a habit costs is priced over ordinary hands, not arranged ones") {
            // The one money figure the card puts in front of a player — "about 8.0 for every 100
            // hands you play" — used to be divided by a hand count that had the assessment in it.
            // On a fresh install that is the whole of the count: tutorial, Home, forty arranged
            // hands, report, "See my game". An assessment sits somebody in the big blind and on a
            // short stack far more often than a real table does, so what those hands cost says more
            // about the arrangement than about their game. How often the habit happened in them is
            // still fair, and is still counted.
            let arranged = LeakScorecard(of: run(.facingContinuationBet, .foldedTooMuch,
                                                 chances: 40, wrong: 20, cost: 1.5,
                                                 kind: HandRecord.assessmentKind))
            t.equal(arranged.hands, 40)
            t.equal(arranged.dealtHands, 0)
            t.equal(arranged.arrangedHands, 40)
            guard let only = arranged.costing.first(where: { $0.id == "fold-to-cbet" }) else {
                return t.expect(false, "the rate stopped being measured over arranged hands")
            }
            t.equal(only.times, 20, "the frequency is the fair half and is still counted")
            t.equal(only.chances, 40)
            t.expect(only.costInBB == nil, "a price was worked out from nothing but arranged hands")
            t.expect(only.costPer100 == nil)
            t.expect(!only.costIsKnown)
            t.expect(only.costLine.contains("arranged"),
                     "the row has to say why the money is blank: \(only.costLine)")
            t.expect(arranged.costPer100 == nil, "the headline totalled up arranged money")
            t.expect(!arranged.summary.contains("big blinds every 100"),
                     "the summary quoted a per-100 cost off arranged hands: \(arranged.summary)")
            t.expect(arranged.summary.contains("worth working on"),
                     "the habits still have to be named: \(arranged.summary)")

            // Mixed, which is what an ordinary history looks like a week later. The money is the
            // dealt instances over the dealt hands; both halves of the division move together.
            var both = run(.facingContinuationBet, .foldedTooMuch, chances: 40, wrong: 20,
                           cost: 1.5, from: 1, kind: HandRecord.assessmentKind)
            both += run(.facingContinuationBet, .foldedTooMuch, chances: 60, wrong: 15,
                        cost: 2.0, from: 100)
            let card = LeakScorecard(of: both)
            t.equal(card.hands, 100)
            t.equal(card.dealtHands, 60)
            t.equal(card.arrangedHands, 40)
            guard let entry = card.costing.first(where: { $0.id == "fold-to-cbet" }) else {
                return t.expect(false, "the leak was not measured at all")
            }
            t.equal(entry.times, 35, "every instance counts towards how often it happens")
            t.equal(entry.arrangedTimes, 20)
            t.equal(entry.pricedTimes, 15, "only the dealt instances carry money")
            t.close(entry.costInBB ?? 0, 30, 0.001, "the 20 arranged instances got into the money")
            // 30 big blinds over the 60 hands that were dealt. Over all 100 it would read 60.0,
            // which is the same money spread over hands that never paid it.
            t.close(entry.costPer100 ?? 0, 50, 0.001)
            t.equal(entry.costHands, 60)
            t.equal(entry.arrangedHands, 40)
            t.close(card.costPer100 ?? 0, 50, 0.001)
            t.expect(entry.costLine.contains("60 hands here that were dealt"),
                     "the row does not say which hands the money is over: \(entry.costLine)")
            guard let note = entry.pricedNote else {
                return t.expect(false, "a cost off 15 of 35 instances went out unqualified")
            }
            t.expect(note.contains("15 of the 35"), note)
            t.expect(note.contains("20 of them happened in arranged hands"), note)

            // The rating is the one thing that still counts arranged hands on both sides of the
            // division, which is deliberate: it is a 0-100 scale rather than a claim about what an
            // evening costs, and a hand somebody was put into still says how well they played it.
            t.close(card.profile.totalCostInBB, 60, 0.001,
                    "the rating stopped counting the hands the assessment measured")
            t.equal(card.profile.handsPlayed, 100)
        }

        t.test("a player with nothing much wrong is told so") {
            let clean = LeakScorecard(of: (1...300).map { index in
                hand(number: index, [decision(.openingChance, nil)])
            })
            t.expect(clean.costing.isEmpty)
            t.expect(clean.watching.isEmpty)
            t.expect(clean.summary.contains("Nothing is costing you much yet")
                     || clean.summary.contains("Nothing has gone wrong"),
                     "the quiet state manufactured something: \(clean.summary)")
            t.expect(clean.entries.contains { $0.id == "limping" },
                     "the chances were counted even though nothing went wrong")
            t.expect(clean.allEntries.count == LeakCatalogue.nameable.count,
                     "the full list should still show everything, including what is going right")
            t.expect(!clean.allEntries.contains { !$0.leak.canBeNamed },
                     "a habit the app cannot measure has no business on a list of habits")
        }

        t.test("no hands at all says so and divides by nothing") {
            let empty = LeakScorecard.empty
            t.equal(empty.hands, 0)
            t.expect(empty.entries.isEmpty)
            t.expect(empty.costing.isEmpty)
            t.expect(empty.costPer100 == nil)
            t.expect(empty.sampleNote == nil, "there is nothing to caveat when there is nothing")
            t.expect(empty.summary.contains("No hands"))
            t.equal(empty.allEntries.count, LeakCatalogue.nameable.count)
            t.close(empty.allEntries[0].rate, 0, 0.0001)
        }

        t.test("a habit that has never come up is left blank rather than drawn as clean") {
            // "Everything measured" draws a row for every habit in the catalogue, and the ones that
            // have never come up used to get the same short green bar as a habit measured and going
            // well — directly above a sentence saying a short bar means that part of your game is
            // working. The card is what the row keys on, so the card is where this is guarded: an
            // unmeasured habit has no chances behind it and a rate of nought, and those two facts
            // together are what "draw no bar" means.
            let card = LeakScorecard(of: run(.bigBlindDefence, .foldedTooMuch, chances: 30, wrong: 12))
            let untouched = card.allEntries.filter { $0.chances == 0 }
            t.greaterThan(untouched.count, 5,
                          "only \(untouched.count) habits went unmeasured in a card built from one "
                          + "situation, so this fixture is not testing what it says it is")
            for entry in untouched {
                t.close(entry.rate, 0, 0.0001)
                t.equal(entry.times, 0)
                t.expect(entry.standing == .fine)
            }
            t.expect(Copy.barMarkNote.contains("no bar"),
                     "the note under the bars does not say what a missing bar means: "
                     + "\"\(Copy.barMarkNote)\"")
        }

        t.test("a sitting made of drill hands says so instead of saying nothing was played") {
            // The scope bar lists every sitting, drill sittings included, and every drill hand is
            // dropped on the way into this card. So pointing the bar at one produced "No hands here
            // yet. Play a few and this fills in on its own." under a heading reading "45 hands" —
            // the screen calling its own heading a liar.
            let drills = run(.bigBlindDefence, .foldedTooMuch, chances: 45, wrong: 20,
                             session: "drill-night", kind: HandRecord.drillKind)
            let card = LeakScorecard(of: drills)
            t.equal(card.hands, 0, "drill hands are meant to be kept off this card")
            t.equal(card.drillHands, 45)
            t.expect(!card.summary.contains("No hands here yet"),
                     "45 drill hands were reported as nothing at all: \"\(card.summary)\"")
            t.expect(card.summary.contains("practice"),
                     "it does not say what those hands were: \"\(card.summary)\"")
            t.expect(card.summary.contains("check"),
                     "it does not say what does count: \"\(card.summary)\"")
            // A check sitting is the same answer, and an empty history is still the empty one.
            let checks = run(.bigBlindDefence, .foldedTooMuch, chances: 12, wrong: 3,
                             session: "check-night", kind: HandRecord.retestKind)
            t.equal(LeakScorecard(of: checks).drillHands, 12)
            t.expect(!LeakScorecard(of: checks).summary.contains("No hands here yet"))
            t.expect(LeakScorecard(of: []).summary.contains("No hands here yet"),
                     "an empty history has to keep the empty sentence")
        }

        t.test("under a couple of hundred hands the whole card is caveated") {
            let few = LeakScorecard(of: run(.openingChance, .limpedInstead, chances: 40, wrong: 20))
            t.expect(!few.enoughHands)
            t.expect(few.sampleNote != nil, "40 hands went uncaveated")
            let many = LeakScorecard(of: run(.openingChance, .limpedInstead, chances: 250, wrong: 100))
            t.expect(many.enoughHands)
            t.expect(many.sampleNote == nil)
        }

        t.test("scoping to a sitting matches scoping the hands underneath it") {
            let store = HistoryStore.inMemory()
            for record in run(.facingContinuationBet, .foldedTooMuch, chances: 40, wrong: 25,
                              cost: 1.5, from: 1, session: "monday") {
                store.append(record)
            }
            for record in run(.openingChance, .limpedInstead, chances: 40, wrong: 2,
                              cost: 0.5, from: 1, session: "tuesday") {
                store.append(record)
            }

            let monday = LeakScorecard(of: store.hands(in: .session("monday")))
            let tuesday = LeakScorecard(of: store.hands(in: .session("tuesday")))
            let everything = LeakScorecard(of: store.hands(in: .allTime))

            t.equal(monday.hands, 40)
            t.equal(everything.hands, 80)
            t.equal(monday.costing.map(\.id), ["fold-to-cbet"])
            t.expect(tuesday.costing.isEmpty, "a good evening should not inherit a bad one")
            t.expect(!tuesday.entries.contains { $0.id == "fold-to-cbet" },
                     "hands from another sitting leaked into this one")
            // Scoping is nothing more than which hands go in, which is what makes the numbers
            // comparable between one evening and the next.
            t.equal(everything.costing.map(\.id), monday.costing.map(\.id))
            t.close(everything.entries.first { $0.id == "fold-to-cbet" }?.costInBB ?? 0,
                    monday.entries.first { $0.id == "fold-to-cbet" }?.costInBB ?? -1, 0.001)
            t.expect(LeakScorecard(of: store.hands(in: .session("monday"))) == monday,
                     "the same hands gave two different answers")
        }

        t.test("a leak that lives in one seat says which seat") {
            var hands = run(.bigBlindDefence, .foldedTooMuch, chances: 30, wrong: 20,
                            from: 1, position: "BB")
            hands += run(.bigBlindDefence, .foldedTooMuch, chances: 4, wrong: 0,
                         from: 100, position: "CO")
            let card = LeakScorecard(of: hands)
            guard let entry = card.entries.first(where: { $0.id == "bb-overfold" }) else {
                return t.expect(false, "the leak was not measured at all")
            }
            t.equal(entry.worstSeat?.number, 2, "the big blind is seat 2 of 9")
            t.equal(entry.worstSeat?.seatPhrase, "seat 2 of 9")
            t.expect(entry.seatLine?.contains("seat 2 of 9 (big blind)") == true,
                     "the row should name the seat by number: \(entry.seatLine ?? "—")")

            // Spread evenly across seats, there is no pattern to report and none is claimed.
            var spread = run(.facingRiverBet, .calledTooWide, chances: 20, wrong: 6,
                             from: 1, position: "BTN")
            spread += run(.facingRiverBet, .calledTooWide, chances: 20, wrong: 6,
                          from: 100, position: "CO")
            let noPattern = LeakScorecard(of: spread)
            t.expect(noPattern.entries.first { $0.id == "river-hero-call" }?.worstSeat == nil,
                     "a pattern was claimed where the mistakes were spread evenly")
        }

        t.test("what the coach was unsure about is not counted against the player") {
            // Below the confidence bar the coach says nothing at the table, and it must not file
            // the same decision away on the scorecard either.
            let unsure = (1...30).map { index in
                hand(number: index, [decision(.facingRiverBet, .calledTooWide,
                                              confidence: 0.3, spoke: false)])
            }
            let card = LeakScorecard(of: unsure)
            let entry = card.entries.first { $0.id == "river-hero-call" }
            t.equal(entry?.chances, 30, "the spot still came up 30 times")
            t.equal(entry?.times, 0, "a decision the coach was unsure about was counted anyway")
            t.expect(card.costing.isEmpty)
        }

        t.test("hands written down before confidence was kept still count where the coach spoke") {
            // The old file has no confidence field at all. Speaking is itself proof the coach was
            // over the line, so those count; anything it kept to itself cannot be shown to have
            // been confident and does not.
            let old = (1...30).map { index in
                hand(number: index, [decision(.facingRiverBet, .calledTooWide, cost: 2,
                                              confidence: nil, spoke: index <= 20)])
            }
            let card = LeakScorecard(of: old)
            let entry = card.entries.first { $0.id == "river-hero-call" }
            t.equal(entry?.chances, 30)
            t.equal(entry?.times, 20)
            t.close(entry?.costInBB ?? 0, 40, 0.001)
        }

        t.test("a hand written down by an older version still reads") {
            // The one that turns a schema mistake into silent data loss: `HistoryStore.load` skips
            // any line it cannot decode, so a new field that is not optional deletes the history.
            let url = FileManager.default.temporaryDirectory
                .appendingPathComponent("poker-old-\(UUID().uuidString).jsonl")
            defer { try? FileManager.default.removeItem(at: url) }
            let line = """
            {"sessionID":"old","sessionStarted":"2026-01-02T10:00:00Z","playedAt":"2026-01-02T10:01:00Z",\
            "handNumber":1,"mode":"Tournament","bigBlind":100,"position":"BB","holeCards":"Ah Kd",\
            "board":"2c 7h Js","netChips":-300,"wentToShowdown":false,"sawFlop":true,\
            "putMoneyInPreflop":true,"raisedPreflop":false,"verdicts":[{"street":"river",\
            "spot":"facing a river bet","leak":"calling too wide","severity":3,"costInBB":2.5,\
            "headline":"h","detail":"d","taken":"call","best":"fold","spoke":true}]}
            """
            try? (line + "\n").write(to: url, atomically: true, encoding: .utf8)

            let store = HistoryStore(url: url)
            t.equal(store.hands.count, 1, "a hand with no confidence and no table size was thrown away")
            guard let verdict = store.hands.first?.verdicts.first else {
                return t.expect(false, "the verdict did not survive")
            }
            t.expect(verdict.confidence == nil)
            t.expect(verdict.countsTowardsLeak, "the coach spoke, so it counts")
            t.equal(verdict.spotKind, .facingRiverBet, "the spot did not read back")
            t.equal(verdict.errorKind, .calledTooWide)
            t.equal(store.hands.first?.seatsAtTable, 9)
        }

        t.test("a mistake written down under its old name still counts") {
            // The name of an error is what goes into the file, so rewording one — "calling too
            // wide" became "calling with too many hands" when the poker word came out — is a change
            // to the format whether or not it was meant as one. A hand whose error no longer
            // resolves keeps loading and silently stops counting towards anything, which is the
            // worst of both outcomes: no error message and a scorecard that quietly shrinks.
            for old in ["calling too wide", "moving in too wide"] {
                t.expect(ErrorKind.recorded(old) != nil,
                         "\"\(old)\" is in people's history files and no longer reads back")
            }
            t.equal(ErrorKind.recorded("calling too wide"), .calledTooWide)
            t.equal(ErrorKind.recorded("moving in too wide"), .shovedTooWide)

            // And the whole way through: an old line lands on the scorecard as the same habit.
            let hands = (1...180).map { number in
                hand(number: number, [VerdictRecord(
                    street: "river", spot: SpotKind.facingRiverBet.name,
                    leak: number <= 72 ? "calling too wide" : "",
                    severity: number <= 72 ? 3 : 0, costInBB: number <= 72 ? 2 : 0,
                    headline: "h", detail: "d", taken: "call", best: "fold",
                    spoke: number <= 72, confidence: 0.9)])
            }
            let card = LeakScorecard(of: hands)
            t.equal(card.costing.map(\.id), ["river-hero-call"],
                    "hands recorded under the old name dropped off the scorecard")
        }

        t.test("what the scorecard writes stays in plain English") {
            // The standing rule, enforced rather than remembered: every sentence this screen puts
            // in front of somebody either avoids poker words or uses ones with a definition behind
            // them, which the player can tap.
            var copy: [String] = []
            let cards = [
                LeakScorecard(of: []),
                LeakScorecard(of: run(.bigBlindDefence, .foldedTooMuch, chances: 6, wrong: 4)),
                LeakScorecard(of: run(.bigBlindDefence, .foldedTooMuch, chances: 40, wrong: 25, cost: 2)),
                LeakScorecard(of: run(.openingChance, .limpedInstead, chances: 300, wrong: 90, cost: 0)),
                LeakScorecard(of: run(.bigBlindDefence, .foldedTooMuch, chances: 20, wrong: 9,
                                      session: "drills", kind: HandRecord.drillKind)),
            ]
            for card in cards {
                copy += [card.summary]
                copy += [card.sampleNote].compactMap { $0 }
                for entry in card.entries {
                    copy += [entry.frequencyLine, entry.costLine]
                    copy += [entry.sampleNote, entry.seatLine].compactMap { $0 }
                }
            }
            t.greaterThan(copy.count, 8, "only \(copy.count) sentences came off these scorecards, "
                          + "which is too few to be checking anything")
            for line in copy {
                let jargon = Glossary.unexplained(in: line)
                t.expect(jargon.isEmpty, "\"\(line)\" uses \(jargon.joined(separator: ", "))")
            }
        }

        t.test("a played session fills the scorecard in") {
            // Through the session rather than through hand-built records: the point of reading the
            // history is that it is what the game actually wrote down.
            let store = HistoryStore.inMemory()
            let session = TableSession(seed: 91, history: store)
            session.start(.tournament)

            var guardRail = 0
            while session.isPlaying, session.handNumber <= 25 {
                guardRail += 1
                precondition(guardRail < 20_000)
                if let legal = session.legal {
                    session.act(.fold)                        // folds everything, blind included
                    _ = legal
                } else if session.review != nil, session.engine?.isComplete == true {
                    if session.handNumber >= 25 { break }
                    session.continueAfterReview()
                } else {
                    break
                }
            }

            let card = LeakScorecard(of: store.hands)
            t.equal(card.hands, store.hands.count)
            t.expect(card.entries.contains { $0.chances > 0 },
                     "a played session left the scorecard with nothing measured")
            t.expect(store.hands.flatMap(\.verdicts).contains { $0.confidence != nil },
                     "the coach's confidence is not being written down")
            // The scorecard survives the app being closed: a second store reading the same hands
            // has to produce the same card.
            t.expect(LeakScorecard(of: store.hands) == card)
        }

        t.test("the money is added up in catalogue order, not in dictionary order") {
            // Nothing that decides anything may read a dictionary in its own order — Swift shuffles
            // that order on every launch, so anything downstream answers differently each run from
            // the same hands. Adding doubles is not associative, so summing the per-habit costs
            // while walking a dictionary made the total depend on the launch, and that total is the
            // rating, and the rating is what the ladder promotes on.
            //
            // The costs below are chosen so the order is visible rather than theoretical: the gap
            // between neighbouring doubles at 1e16 is 2, so 1e16 + 1 + 1 comes to 1e16 while
            // 1 + 1 + 1e16 comes to 1e16 + 2. Catalogue order puts the big one first.
            let first = LeakCatalogue.all[0]
            // Six small ones rather than two, because dictionary order is drawn afresh each launch:
            // the bug only shows when the big cost is not drawn first, so the more habits carrying
            // a cost the likelier any one run is to catch it.
            let later = LeakCatalogue.all.filter { $0.spot != first.spot }.prefix(6)
            t.equal(later.count, 6, "the catalogue is too small for this test to mean anything")
            var hands = run(first.spot, first.error, chances: 40, wrong: 12, cost: 1e16 / 12)
            var number = 500
            for leak in later {
                hands += run(leak.spot, leak.error, chances: 40, wrong: 1, cost: 1.0, from: number)
                number += 100
            }
            let total = LeakScorecard(of: hands).profile.totalCostInBB
            t.equal(total, 1e16, "the total is \(total), which is what dictionary order gives")
            // Same hands, shuffled into a different order on disk: the answer cannot move.
            t.equal(LeakScorecard(of: hands.reversed()).profile.totalCostInBB, total)
        }
    }
}

/// The free tier: one habit in full, the rest blurred, one drill. Ken's rule was "the most
/// common, not the most expensive", and every refusal the scorecard makes still holds — the free
/// row is never a habit the hands cannot support.
public func freeViewTests(_ t: TestRun) {

    t.suite("what a free player is shown") { t in

        t.test("the most common habit is shown, not the most expensive") {
            // A cheap habit seen 30 times against an expensive one seen 6 times, both past their
            // floors. The card ranks by money; the free tier does not.
            var hands = run(.openingChance, .limpedInstead, chances: 60, wrong: 30, cost: 0.4, from: 1)
            hands += run(.facingReraise, .foldedTooMuch, chances: 15, wrong: 6, cost: 40, from: 100)
            let card = LeakScorecard(of: hands)
            t.equal(card.entries.first?.id, "fold-to-3bet", "the card itself leads with the money")

            let view = FreeView(of: card, entitlement: .trial(handsLeft: 40))
            t.equal(view.visibleLeakID, "limping")
            t.equal(view.basis, .named)
            t.equal(view.hiddenCount, 1)
            t.expect(view.isBlurred(leakID: "fold-to-3bet"))
            t.expect(!view.isBlurred(leakID: "limping"))
            t.expect(view.mayDrill(leakID: "limping"))
            t.expect(!view.mayDrill(leakID: "fold-to-3bet"))
        }

        t.test("a habit under its floor is never the visible one while another has cleared its floor") {
            // The thin one happened MORE often per hand than the real one has chances, but it is
            // still under its own floor, so it is only being watched.
            var hands = run(.openingChance, .limpedInstead, chances: 60, wrong: 24, cost: 0.5, from: 1)
            hands += run(.facingReraise, .foldedTooMuch, chances: 3, wrong: 2, cost: 40, from: 100)
            let card = LeakScorecard(of: hands)
            let view = FreeView(of: card, entitlement: .trial(handsLeft: 10))
            t.equal(view.visibleLeakID, "limping")
            t.equal(view.basis, .named)
            t.equal(view.hiddenCount, 1, "the watched habit is on the card, blurred")
        }

        t.test("with nothing proved yet, the most common watched habit is shown and labelled so") {
            var hands = run(.facingReraise, .foldedTooMuch, chances: 6, wrong: 4, cost: 2, from: 1)
            hands += run(.openingChance, .limpedInstead, chances: 8, wrong: 2, cost: 0.5, from: 100)
            let card = LeakScorecard(of: hands)
            t.expect(card.costing.isEmpty, "nothing here should have cleared a floor")
            let view = FreeView(of: card, entitlement: .trial(handsLeft: 10))
            t.equal(view.visibleLeakID, "fold-to-3bet")
            t.equal(view.basis, .watching)
        }

        t.test("a tie in how often goes to the one that happens more per chance") {
            var hands = run(.openingChance, .limpedInstead, chances: 60, wrong: 20, cost: 0.4, from: 1)
            hands += run(.facingReraise, .foldedTooMuch, chances: 30, wrong: 20, cost: 1, from: 100)
            let view = FreeView(of: LeakScorecard(of: hands), entitlement: .trial(handsLeft: 1))
            t.equal(view.visibleLeakID, "fold-to-3bet")
        }

        t.test("blurred rows still count") {
            var hands = run(.openingChance, .limpedInstead, chances: 60, wrong: 30, cost: 0.4, from: 1)
            hands += run(.facingReraise, .foldedTooMuch, chances: 15, wrong: 6, cost: 40, from: 100)
            hands += run(.bigBlindDefence, .foldedTooMuch, chances: 4, wrong: 2, cost: 1, from: 200)
            let card = LeakScorecard(of: hands)
            let view = FreeView(of: card, entitlement: .lapsed)
            t.equal(card.costing.count + card.watching.count, 3)
            t.equal(view.hiddenCount, 2)
            // The rating and the total cost are not a habit reveal; they are unchanged.
            t.close(card.profile.totalCostInBB, 12 + 240 + 2, 0.001)
        }

        t.test("with no habit at all, nothing is shown and nothing is hidden") {
            let view = FreeView(of: LeakScorecard(of: []), entitlement: .trial(handsLeft: 100))
            t.equal(view.visibleLeakID, nil)
            t.equal(view.hiddenCount, 0)
            t.expect(view.isRestricted)
            t.expect(!view.mayDrill(leakID: "limping"), "with nothing shown, no drill is open")
        }

        t.test("a subscriber has nothing hidden") {
            var hands = run(.openingChance, .limpedInstead, chances: 60, wrong: 30, cost: 0.4, from: 1)
            hands += run(.facingReraise, .foldedTooMuch, chances: 15, wrong: 6, cost: 40, from: 100)
            let view = FreeView(of: LeakScorecard(of: hands), entitlement: .subscribed)
            t.equal(view, .open)
            t.expect(!view.isBlurred(leakID: "fold-to-3bet"))
            t.expect(view.mayDrill(leakID: "fold-to-3bet"))
            t.expect(view.mayDrill(leakID: "anything-at-all"))
        }
    }
}