import Foundation
import PokerCore
import PokerAI
import PokerCoach
import PokerSession
import TestKit

/// The ladder's job is to move somebody only when the hands say so. Almost everything below is
/// therefore about what it refuses to do: it will not move anyone on a tournament, it will not
/// bounce a player who is sitting on a bar up and down for ever, and it will not carry a rung
/// forward in memory that the saved hands cannot account for.

// MARK: - Building hands that rate exactly what we want

/// One decision in a spot with a single leak in it, so the arithmetic below is not competing with
/// any other row of the scorecard. `bet-sizing` is the only leak living in the flop-decision spot.
private func decision(mistake: Bool, cost: Double) -> VerdictRecord {
    VerdictRecord(street: "flop",
                  spot: SpotKind.flopDecision.name,
                  leak: mistake ? ErrorKind.wrongSize.name : "",
                  severity: mistake ? Severity.moderate.rawValue : 0,
                  costInBB: mistake ? cost : 0,
                  headline: "h", detail: "d", taken: "bet", best: "bet",
                  spoke: mistake, confidence: 0.9)
}

/// `count` hands at a rung, built to rate exactly `rating` once there are enough of them.
///
/// The rating is `100 - costPer100 * 5.5` with no habit penalty, so the cost is worked backwards
/// from the number wanted. A fifth of the hands carry the mistake, which is under the quarter this
/// leak allows — deliberately, because a confirmed habit would add a penalty on top and the point
/// of these hands is that the rating is exactly what the test asked for.
private func hands(at tier: String, rating: Int, count: Int = LadderStanding.sampleSize,
                   from: Int = 1, session: String = "s") -> [HandRecord] {
    let mistakes = max(1, count / 5)
    // The inverse of SkillRating's own arithmetic, so a fixture asking for a rating of 60 gets
    // hands that actually rate 60. It was written out by hand with the old constants and quietly
    // stopped agreeing the day the scale was re-anchored on tournament play — which broke a dozen
    // ladder tests that were all correct.
    let costPer100 = SkillRating.costFor(rating: rating)
    let each = costPer100 * Double(count) / 100 / Double(mistakes)
    return (0..<count).map { index in
        HandRecord(sessionID: session,
                   sessionStarted: Date(timeIntervalSince1970: 1_780_000_000),
                   playedAt: Date(timeIntervalSince1970: 1_780_000_000 + Double(from + index) * 60),
                   handNumber: from + index,
                   mode: "Tournament",
                   bigBlind: 100,
                   position: "BB",
                   holeCards: "Ah Kd",
                   board: "2c 7h Js",
                   netChips: 0,
                   wentToShowdown: false,
                   sawFlop: true,
                   putMoneyInPreflop: true,
                   raisedPreflop: false,
                   verdicts: [decision(mistake: index < mistakes, cost: each)],
                   tableSize: 9,
                   tier: tier)
    }
}

public func ladderTests(_ t: TestRun) {
    cashLadderTests(t)

    t.suite("the rungs of the ladder") { t in

        t.test("every rung seats a full table") {
            for tier in LadderTier.all {
                t.equal(tier.roster.count, TableSession.seatsAtTable - 1,
                        "\(tier.name) seats \(tier.roster.count) opponents, not eight")
            }
            t.equal(LadderTier.all.map(\.rung), Array(0..<LadderTier.all.count))
            t.equal(Set(LadderTier.all.map(\.id)).count, LadderTier.all.count, "two rungs share an id")
        }

        t.test("a rung is defined by who is at it, not by the dial") {
            // Style beat skill by an order of magnitude in the shootouts, so the thing that has to
            // change on the way up is the cast. Counted here: the players who give money away
            // leave, and the ones who take it arrive.
            func recreational(_ tier: LadderTier) -> Int {
                tier.roster.filter { [.callingStation, .loosePassive, .maniac].contains($0) }.count
            }
            func regulars(_ tier: LadderTier) -> Int {
                tier.roster.filter {
                    [.tightAggressive, .looseAggressive, .solidRegular, .trickyRegular].contains($0)
                }.count
            }
            for (lower, upper) in zip(LadderTier.all, LadderTier.all.dropFirst()) {
                t.expect(recreational(upper) <= recreational(lower),
                         "\(upper.name) has more loose players than \(lower.name)")
                t.expect(regulars(upper) > regulars(lower),
                         "\(upper.name) has no more competent players than \(lower.name)")
                t.expect(upper.skill > lower.skill, "\(upper.name) is not a harder dial")
            }
            t.equal(recreational(LadderTier.all[0]), 7, "the bottom rung should be nearly all of them")
            t.equal(recreational(LadderTier.all.last!), 0, "the top rung should have none of them")
        }

        t.test("the bars never touch") {
            // The whole anti-yo-yo guarantee lives in this gap. If the rating that promotes you off
            // a rung were close to the one that sends you back down to it, a player sitting on the
            // bar would be carried up and down for ever.
            for tier in LadderTier.all.dropFirst() {
                guard let down = tier.demotionRating,
                      let up = tier.previous?.promotionRating else {
                    t.expect(false, "\(tier.name) has no bars"); continue
                }
                t.expect(up - down >= 20,
                         "\(tier.name) drops at \(down) and \(tier.previous!.name) leaves at \(up)")
            }
            t.equal(LadderTier.all.first?.demotionRating, nil, "nobody falls off the bottom")
            t.equal(LadderTier.all.last?.promotionRating, nil, "there is nothing above the top")
        }

        t.test("everybody starts at the bottom until the hands say otherwise") {
            t.equal(LadderStanding(of: []).tier.id, LadderTier.bottom.id)
            // Forty good hands is an evening, not a placement — the rating off that few is held
            // near the middle on purpose and reading a rung off it would be reading a number the
            // hands do not support.
            let evening = hands(at: "kitchen", rating: 90, count: 40).map { record -> HandRecord in
                var copy = record; copy.tier = nil; return copy
            }
            t.equal(LadderStanding(of: evening).tier.id, LadderTier.bottom.id)
        }

        t.test("a long history places you where it says you are") {
            let history = hands(at: "kitchen", rating: 70, count: 220).map { record -> HandRecord in
                var copy = record; copy.tier = nil; return copy
            }
            let placed = LadderStanding(of: history)
            // Compared against the rating those hands actually produce rather than a number typed
            // in here. The literal used to be 70 and "regulars", which stopped being true the day
            // the rating was re-anchored on tournament play — the test was pinning the old scale
            // rather than the behaviour it was written for.
            let measured = LeakScorecard(of: history).rating.value
            t.equal(placed.tier.id, LadderTier.entry(forRating: measured).id,
                    "\(measured) placed at \(placed.tier.id)")
            t.equal(placed.handsAtTier, 0, "none of those hands were played on the ladder")
        }

        // The ladder has to be climbable, and every rung has to be somewhere somebody can arrive.
        // A rung nobody can reach is a rung that should not exist, and re-anchoring the rating is
        // exactly the change that would quietly create one.
        t.test("every rung can be arrived at, and the bars only go one way") {
            var last = LadderTier.entry(forRating: 0).rung
            for rating in 0...100 {
                let rung = LadderTier.entry(forRating: rating).rung
                t.expect(rung >= last, "rating \(rating) placed lower than \(rating - 1) did")
                last = rung
            }
            let reached = Set((0...100).map { LadderTier.entry(forRating: $0).id })
            t.equal(reached.count, LadderTier.all.count,
                    "only \(reached.count) of \(LadderTier.all.count) rungs can be arrived at")

            // And the top of the ladder has to be beyond the best player this repo can measure —
            // 62, a scripted player with no deliberate flaw — or there is nothing left to climb.
            t.equal(LadderTier.entry(forRating: 62).id, LadderTier.all.last?.id,
                    "a player with no deliberate flaw should be at the top table")
            t.equal(LadderTier.entry(forRating: 9).id, LadderTier.bottom.id,
                    "the worst scripted player belongs at the kitchen table")
        }

        t.test("arranged hands cannot place anybody, however many of them there are") {
            // The seat and the stacks were chosen in an assessment, and a drill chose the whole
            // situation, so neither is a result. Counting them towards the two hundred while the
            // rating refuses to count them is the worst of both: an evening of drilling opened the
            // placement door on a number built from almost no real hands.
            func arranged(_ kind: String, drill: String?) -> [HandRecord] {
                hands(at: "kitchen", rating: 90, count: 220).map { record -> HandRecord in
                    var copy = record
                    copy.tier = nil
                    copy.kind = kind
                    copy.drill = drill
                    return copy
                }
            }
            let drilled = arranged(HandRecord.drillKind, drill: "bet-sizing")
            t.equal(LadderStanding(of: drilled).tier.id, LadderTier.bottom.id,
                    "220 hands of drilling placed somebody up the ladder")
            let assessed = arranged(HandRecord.assessmentKind, drill: nil)
            t.equal(LadderStanding(of: assessed).tier.id, LadderTier.bottom.id,
                    "an assessment placed somebody up the ladder")

            // The same hands played for real do place them, which is what makes the two lines above
            // a statement about arranged hands rather than about the fixture.
            let real = hands(at: "kitchen", rating: 90, count: 220).map { record -> HandRecord in
                var copy = record; copy.tier = nil; return copy
            }
            t.equal(LadderStanding(of: real).tier.id, LadderTier.entry(forRating: 90).id)
            t.expect(LadderTier.entry(forRating: 90).id != LadderTier.bottom.id)

            // And a real history is not held down by arranged hands sitting alongside it.
            t.equal(LadderStanding(of: drilled + real).tier.id, LadderTier.entry(forRating: 90).id)
        }
    }

    t.suite("moving up and down the ladder") { t in

        t.test("a tournament cannot move anybody") {
            // Forty hands played brilliantly. This is the case the whole sample size exists for.
            let standing = LadderStanding(of: hands(at: "kitchen", rating: 95, count: 40))
            t.equal(standing.move, .held)
            t.equal(standing.tier.id, "kitchen")
            t.equal(standing.handsToGo, 160)
            t.expect(!standing.enoughHands)
            t.expect(standing.resultsLine.contains("160 to go"), standing.resultsLine)
        }

        // These read the bars off the ladder rather than naming them. They used to name them, and
        // when the rating was re-anchored on tournament play every one of them failed while testing
        // exactly the right thing — the numbers had moved underneath, not the behaviour.
        t.test("promotion fires once the sample is there") {
            let bar = LadderTier.bottom.promotionRating!
            let short = LadderStanding(of: hands(at: "kitchen", rating: bar, count: 199))
            t.equal(short.move, .held, "199 hands is not 200")

            let full = LadderStanding(of: hands(at: "kitchen", rating: bar, count: 200))
            t.equal(full.rating?.value, bar, "the test hands did not rate what they were built to")
            t.equal(full.move, .promoted)
            t.equal(full.settled.id, "kitchen")
            t.equal(full.tier.rung, 1)
        }

        t.test("just under the bar is not a promotion") {
            let bar = LadderTier.bottom.promotionRating!
            let under = LadderStanding(of: hands(at: "kitchen", rating: bar - 1, count: 240))
            t.equal(under.rating?.value, bar - 1)
            t.equal(under.move, .held)
            t.equal(under.tier.id, "kitchen")

            let over = LadderStanding(of: hands(at: "kitchen", rating: bar, count: 240))
            t.equal(over.move, .promoted, "the bar is meant to be inclusive")
        }

        t.test("demotion fires when results fall away") {
            let casino = LadderTier.all.first { $0.id == "casino-low" }!
            let standing = LadderStanding(of: hands(at: "casino-low",
                                                    rating: casino.demotionRating! - 1, count: 260))
            t.equal(standing.move, .demoted)
            t.equal(standing.settled.id, "casino-low")
            t.equal(standing.tier.rung, casino.rung - 1)
            t.expect(standing.resultsLine.contains("go back a rung"), standing.resultsLine)
        }

        t.test("nobody falls off the bottom or climbs past the top") {
            let floor = LadderStanding(of: hands(at: "kitchen", rating: 2, count: 300))
            t.equal(floor.move, .held)
            t.equal(floor.tier.id, "kitchen")

            let ceiling = LadderStanding(of: hands(at: "tough", rating: 99, count: 300))
            t.equal(ceiling.move, .held)
            t.equal(ceiling.tier.id, "tough")
            t.expect(ceiling.requirementLine.contains("top of the ladder"), ceiling.requirementLine)
        }

        t.test("a move resets the sample") {
            // Promoted off two hundred good hands, then one bad tournament at the new table. The
            // bad tournament must not send them straight back.
            var history = hands(at: "kitchen", rating: 70, count: 200, from: 1)
            history += hands(at: "home-game", rating: 5, count: 40, from: 300)
            let standing = LadderStanding(of: history)
            t.equal(standing.settled.id, "home-game", "the rung follows the hands most recently played")
            t.equal(standing.handsAtTier, 40, "the hands at the old table are behind them")
            t.equal(standing.move, .held)
            t.equal(standing.tier.id, "home-game")
        }

        t.test("a player wobbling on noise is never bounced") {
            // Sessions that alternate between a rating well past the bar this rung leaves at and
            // one under the bar it drops at. Judged one session at a time the player would be
            // promoted and demoted on alternate evenings for ever. Judged on two hundred hands they
            // are what they actually are: somewhere in the middle, and staying.
            //
            // Both ends are read off the rung rather than named, and the wobble is centred on the
            // midpoint between its two bars, so this keeps testing the hysteresis rather than
            // whatever the numbers happened to be the day it was written.
            let rung = LadderTier.all.first { $0.id == "home-game" }!
            let up = rung.promotionRating!, down = rung.demotionRating!
            let middle = (up + down) / 2
            let good = middle + (up - down), bad = middle - (up - down)
            var history: [HandRecord] = []
            var seen: Set<String> = []
            for session in 0..<12 {
                history += hands(at: "home-game", rating: session.isMultiple(of: 2) ? good : bad,
                                 count: 40, from: 1 + session * 40, session: "s\(session)")
                let standing = LadderStanding(of: history)
                seen.insert(standing.tier.id)
                t.equal(standing.move, .held,
                        "session \(session) moved them: \(standing.resultsLine)")
            }
            t.equal(seen, ["home-game"], "the player was carried off the rung by noise")

            // And the pooled rating really is between the two bars, which is why it held.
            let settled = LadderStanding(of: history)
            t.expect(((down + 1)...(up - 1)).contains(settled.rating?.value ?? 0),
                     "these hands rate \(settled.rating?.value ?? -1), which is not between the bars")
        }

        t.test("a demotion is not undone by the sample that caused it") {
            // Dropped from the casino table for a rating just under its bar. The same rating at the
            // rung below is nowhere near what it takes to come back up, which is the twenty points
            // between the two bars doing exactly the job they are there for.
            let casino = LadderTier.all.first { $0.id == "casino-low" }!
            let below = LadderTier.all.first { $0.rung == casino.rung - 1 }!
            let dropping = casino.demotionRating! - 1
            t.lessThan(dropping, below.promotionRating!,
                       "the bars overlap, so a demotion would promote straight back")

            let dropped = LadderStanding(of: hands(at: casino.id, rating: dropping, count: 200))
            t.equal(dropped.tier.id, below.id)
            let after = LadderStanding(of: hands(at: below.id, rating: dropping, count: 200))
            t.equal(after.move, .held, "the same results promoted them straight back up")
            t.equal(after.tier.id, below.id)
        }

        t.test("only the hands since the last move are counted") {
            var history = hands(at: "kitchen", rating: 95, count: 200, from: 1)
            history += hands(at: "home-game", rating: 30, count: 200, from: 300)
            let standing = LadderStanding(of: history)
            t.equal(standing.handsAtTier, 200)
            t.equal(standing.sample, 200)
            t.equal(standing.rating?.value, 30, "the old rung's hands leaked into the new one")
        }

        t.test("the sample looks back a set distance and no further") {
            // A terrible first four hundred hands followed by two hundred good ones. Beyond the
            // window the old ones are let go, so a fixed habit stops being held against them.
            var history = hands(at: "home-game", rating: 5, count: 400, from: 1)
            history += hands(at: "home-game", rating: 80, count: 400, from: 500)
            let standing = LadderStanding(of: history)
            t.equal(standing.handsAtTier, 800)
            t.equal(standing.sample, LadderStanding.window)
            t.equal(standing.rating?.value, 80)
            t.equal(standing.move, .promoted)
        }

        t.test("hands recorded at a rung this build no longer has are not guessed at") {
            let history = hands(at: "a-rung-that-was-deleted", rating: 90, count: 300)
            let standing = LadderStanding(of: history)
            // Those hands are no longer evidence of anything on the ladder, so the player is
            // placed off what they are worth rather than left holding a rung that does not exist.
            t.equal(standing.tier.id, LadderTier.entry(forRating: 90).id,
                    "an unknown rung should fall back to a placement, not crash or promote")
            t.equal(standing.handsAtTier, 0)
            t.equal(standing.move, .held)
            t.expect(standing.rating == nil, "there is no sample at a rung this build does not have")
        }

        t.test("assessment hands are not results") {
            // The assessment arranges the seat and the stacks; what it wins or loses is not a
            // result and it is not played at a rung, so it can never move anybody.
            let arranged = hands(at: "kitchen", rating: 95, count: 300).map { record -> HandRecord in
                var copy = record
                copy.tier = nil
                copy.kind = HandRecord.assessmentKind
                return copy
            }
            let standing = LadderStanding(of: arranged)
            t.equal(standing.handsAtTier, 0)
            t.equal(standing.move, .held)
        }
    }

    t.suite("the ladder at the table") { t in

        t.test("a sitting is played at a rung and says so in the file") {
            let session = TableSession(seed: 31, history: .inMemory())
            session.start(.tournament)
            t.equal(session.tier?.id, "kitchen", "a player with no history starts at the bottom")
            t.equal(session.tableDifficulty, LadderTier.bottom.skill)

            var guardRail = 0
            while session.isPlaying, session.history.hands.count < 6 {
                guardRail += 1
                precondition(guardRail < 20_000, "session failed to progress")
                if let legal = session.legal {
                    session.act(legal.canCheck ? .check : .fold)
                } else if session.review != nil, session.engine?.isComplete == true {
                    session.continueAfterReview()
                } else {
                    break
                }
            }
            t.expect(session.history.hands.count >= 1, "no hands were written down")
            for hand in session.history.hands {
                t.equal(hand.tier, "kitchen", "hand \(hand.handNumber) does not say where it was played")
            }
        }

        t.test("the people at the table are the rung's people") {
            let session = TableSession(seed: 77, history: .inMemory())
            session.start(.tournament)
            let seated = session.opponents.values.map(\.personality).sorted { $0.rawValue < $1.rawValue }
            let expected = LadderTier.bottom.roster.sorted { $0.rawValue < $1.rawValue }
            t.equal(seated, expected, "the table is not the room the rung describes")
            t.greaterThan(session.opponents.count, 0, "nobody sat down, so nothing below was checked")
            for opponent in session.opponents.values {
                t.expect(opponent.personality.skillRange.contains(opponent.skill),
                         "\(opponent.id) is playing outside the band their style exists in")
            }
        }

        t.test("an assessment is not played on the ladder") {
            let session = TableSession(seed: 44, history: .inMemory())
            session.startAssessment(hands: 4)
            t.equal(session.tier, nil)
        }

        t.test("the ladder is where it was left after a restart") {
            let url = FileManager.default.temporaryDirectory
                .appendingPathComponent("poker-ladder-\(UUID().uuidString).jsonl")
            defer { try? FileManager.default.removeItem(at: url) }

            let writing = HistoryStore(url: url)
            for record in hands(at: "casino-low", rating: 40, count: 210) { writing.append(record) }
            let before = LadderStanding(of: writing.hands(in: .allTime))

            // A second store opening the same file is what the next launch does.
            let reading = HistoryStore(url: url)
            let after = LadderStanding(of: reading.hands(in: .allTime))
            t.equal(after.settled.id, before.settled.id)
            t.equal(after.tier.id, "casino-low", "the rung did not survive the restart")
            t.equal(after.handsAtTier, before.handsAtTier)
            t.equal(after.rating?.value, before.rating?.value)
            t.equal(after.move, .held)
        }
    }

    t.suite("what the ladder says out loud") { t in

        t.test("it never claims more than the hands support") {
            let fresh = LadderStanding(of: [])
            t.expect(fresh.resultsLine.contains("not played a hand"), fresh.resultsLine)
            t.expect(fresh.requirementLine.contains("200"), fresh.requirementLine)

            let part = LadderStanding(of: hands(at: "kitchen", rating: 80, count: 120))
            t.expect(part.resultsLine.contains("120 hands"), part.resultsLine)
            t.expect(part.resultsLine.contains("80 to go"), part.resultsLine)
        }

        t.test("a rung lists who is in the room") {
            let kitchen = LadderTier.bottom
            t.equal(kitchen.cast.reduce(0) { $0 + $1.count }, 8, "the cast does not add up to the table")
            t.expect(kitchen.castLine.contains("3 calling stations"), kitchen.castLine)
            t.expect(kitchen.castLine.contains("1 nit"), kitchen.castLine)
            t.expect(kitchen.castLine.hasSuffix("."), kitchen.castLine)
        }

        t.test("the demotion is said out loud rather than sprung on anybody") {
            let casino = LadderTier.all.first { $0.id == "casino-low" }!
            let safe = (casino.promotionRating! + casino.demotionRating!) / 2
            let standing = LadderStanding(of: hands(at: "casino-low", rating: safe, count: 210))
            t.equal(standing.move, .held)
            let warning = standing.demotionLine ?? ""
            t.expect(warning.contains("\(casino.demotionRating!)"), warning)
            t.expect(warning.contains("Friday night home game"), warning)
        }
    }
}


/// The cash ladder is a ladder of stakes. Same machinery, different rungs.
func cashLadderTests(_ t: TestRun) {
    t.suite("the ladder of stakes") { t in

        t.test("four stakes, climbing, each with its own room") {
            let rungs = LadderTier.cashRungs
            t.equal(rungs.count, 4)
            t.equal(rungs.map(\.rung), [0, 1, 2, 3])
            for (lower, upper) in zip(rungs, rungs.dropFirst()) {
                t.lessThan(lower.stakes!.bigBlind, upper.stakes!.bigBlind, "stakes should climb")
                t.lessThan(lower.skill, upper.skill + 1, "the rooms should not get softer going up")
            }
            t.equal(rungs.map { $0.stakes!.bigBlind }, CashSetup.stakesOnOffer.map(\.bigBlind),
                    "every stake on offer is a rung, and every rung is on offer")
            for rung in rungs { t.expect(rung.stakes != nil) }
            for rung in LadderTier.tournamentRungs { t.expect(rung.stakes == nil) }
        }

        t.test("no rung on either ladder shares an id with any other") {
            let ids = (LadderTier.tournamentRungs + LadderTier.cashRungs).map(\.id)
            t.equal(Set(ids).count, ids.count)
            for rung in LadderTier.cashRungs {
                t.equal(LadderTier.named(rung.id)?.id, rung.id, "a cash rung resolves from the tournament product too")
                t.equal(LadderTier.cash(forStakes: rung.stakes!)?.id, rung.id)
            }
        }

        t.test("the product picks the ladder") {
            t.equal(LadderTier.all.map(\.id), LadderTier.tournamentRungs.map(\.id))
            withCashProduct {
                t.equal(LadderTier.all.map(\.id), LadderTier.cashRungs.map(\.id))
                t.equal(LadderTier.bottom.id, "cash-25-50")
                let reached = Set((0...100).map { LadderTier.entry(forRating: $0).id })
                t.equal(reached.count, 4, "every stake can be arrived at by some rating")
                t.equal(LadderTier.entry(forRating: 0).id, "cash-25-50")
                t.equal(LadderTier.entry(forRating: 100).id, "cash-500-1000")
            }
        }

        t.test("a cash session sits at the stakes the player chose, and the hand says so") {
            withCashProduct {
                let session = TableSession(seed: 4, history: .inMemory(), variant: .pokerTutorCash)
                session.cashSetup = CashSetup(stakes: BlindLevel(smallBlind: 100, bigBlind: 200), structure: .noLimit)
                session.start(.cash)
                t.equal(session.tier?.id, "cash-100-200")
                t.equal(session.tableDifficulty, LadderTier.cashRungs[2].skill)
                var steps = 0
                while session.history.hands.isEmpty, steps < 5_000 {
                    steps += 1
                    if session.heroToAct { session.act(session.legal!.canCheck ? .check : .fold) }
                    else if session.engine?.isComplete == true { session.continueAfterReview() }
                    else { session.stepOpponent() }
                }
                t.equal(session.history.hands.first?.tier, "cash-100-200")
            }
        }

        t.test("the standing counts only hands on its own ladder") {
            withCashProduct {
                // A hand written at a tournament rung is not a hand at any stake.
                let tournamentHands = (0..<250).map { i in
                    HandRecord(sessionID: "t", sessionStarted: Date(), playedAt: Date(), handNumber: i,
                               mode: "Tournament", bigBlind: 100, position: "BTN", holeCards: "Ah Kd",
                               board: "", netChips: 0, wentToShowdown: false, sawFlop: false,
                               putMoneyInPreflop: false, raisedPreflop: false, verdicts: [], tier: "kitchen")
                }
                let standing = LadderStanding(of: tournamentHands)
                t.equal(standing.tier.id, "cash-25-50", "tournament hands should not place anyone on the stakes ladder")
                t.equal(standing.handsAtTier, 0)
                t.equal(standing.placeLine, "You play 25/50.")
            }
        }

        t.test("a cash player is told their win rate beside the rating that moves them") {
            withCashProduct {
                let hands = (0..<60).map { i in
                    HandRecord(sessionID: "c", sessionStarted: Date(), playedAt: Date(), handNumber: i,
                               mode: "Cash game", bigBlind: 100, position: "BTN", holeCards: "Ah Kd",
                               board: "", netChips: i % 2 == 0 ? 300 : -100, wentToShowdown: false,
                               sawFlop: false, putMoneyInPreflop: false, raisedPreflop: false,
                               verdicts: [], tier: "cash-50-100")
                }
                let standing = LadderStanding(of: hands)
                t.equal(standing.settled.id, "cash-50-100")
                t.expect(standing.isCash)
                t.close(standing.winRatePer100, 100, 1, "sixty hands, +1 big blind a hand on average")
                t.equal(standing.winRateLine, "Win rate there: +100 big blinds for every 100 hands, over 60 hands.")
                t.expect(standing.requirementLine.contains("50/100") && standing.requirementLine.contains("100/200"),
                         "the requirement should name the stakes: \(standing.requirementLine)")
                t.expect(!standing.resultsLine.contains("tournament"),
                         "a cash player should not be told about tournaments: \(standing.resultsLine)")
            }
        }
    }
}
