Advertisement

← poker-tutor

Sources/PokerTests/HistoryTests.swift

16.9 KB Download file
import Foundation
import PokerCore
import PokerCoach
import PokerSession
import TestKit

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

private func flagged(_ street: String, _ leak: String, cost: Double) -> VerdictRecord {
    VerdictRecord(street: street, spot: "a spot", leak: leak, severity: 3, costInBB: cost,
                  headline: "h", detail: "d", taken: "call", best: "fold", spoke: true)
}

func historyTests(_ t: TestRun) {
    t.suite("keeping a history") { t in

        t.test("hands survive being written down and read back") {
            let url = FileManager.default.temporaryDirectory
                .appendingPathComponent("poker-history-\(UUID().uuidString).jsonl")
            defer { try? FileManager.default.removeItem(at: url) }

            let writing = HistoryStore(url: url)
            writing.append(record(hand: 1, net: 500, verdicts: [flagged("flop", "calling too wide", cost: 2.5)]))
            writing.append(record(hand: 2, net: -300))
            t.equal(writing.hands.count, 2)

            // A second store opening the same file is what happens on the next launch.
            let reading = HistoryStore(url: url)
            t.equal(reading.hands.count, 2, "the history did not survive")
            t.equal(reading.hands.map(\.netChips), [500, -300])
            t.equal(reading.hands[0].flagged.count, 1)
            t.equal(reading.hands[0].flagged.first?.leak, "calling too wide")
            t.close(reading.hands[0].costInBB, 2.5, 0.001)
            t.expect(!reading.hands[0].playedWell)
            t.expect(reading.hands[1].playedWell, "a hand with no verdicts was played well")
            t.equal(reading.hands[0].cards.count, 2, "the cards did not round-trip")
        }

        t.test("a damaged last line loses one hand, not the history") {
            let url = FileManager.default.temporaryDirectory
                .appendingPathComponent("poker-history-\(UUID().uuidString).jsonl")
            defer { try? FileManager.default.removeItem(at: url) }

            let writing = HistoryStore(url: url)
            writing.append(record(hand: 1, net: 100))
            writing.append(record(hand: 2, net: 200))
            // Simulate a write cut off half way — a crash, or a phone running out of battery.
            if let handle = try? FileHandle(forWritingTo: url) {
                _ = try? handle.seekToEnd()
                try? handle.write(contentsOf: Data(#"{"sessionID":"s1","handNu"#.utf8))
                try? handle.close()
            }

            let reading = HistoryStore(url: url)
            t.equal(reading.hands.count, 2, "a torn line took the whole history with it")

            // And the hand played after the crash survives too, which it did not before. The torn
            // line ends without a newline, so appending straight onto the end fused the next hand
            // into the fragment and produced one line that decoded as neither — a crash costing two
            // hands, the second of them played after the thing that lost it had already happened.
            // In the one file format chosen specifically to survive being cut off half way.
            reading.append(record(hand: 3, net: 300))
            let afterwards = HistoryStore(url: url)
            t.equal(afterwards.hands.count, 3,
                    "the hand played after the crash was eaten by the torn line before it")
            t.equal(afterwards.hands.map(\.netChips), [100, 200, 300])
        }

        t.test("sittings are grouped, newest first") {
            let store = HistoryStore.inMemory()
            store.append(record(session: "old", hand: 1, net: 100))
            store.append(record(session: "old", hand: 2, net: -50))
            store.append(record(session: "new", hand: 1, net: 900))

            let sessions = store.sessions()
            t.equal(sessions.count, 2)
            t.equal(sessions.first?.id, "new", "the most recent sitting should be first")
            t.equal(sessions.first?.hands, 1)
            t.equal(sessions.last?.netChips, 50)
            t.equal(store.hands(in: .session("old")).count, 2)
            t.equal(store.hands(in: .allTime).count, 3)
        }

        t.test("clearing empties the file as well as the memory") {
            let folder = FileManager.default.temporaryDirectory
                .appendingPathComponent("poker-reset-\(UUID().uuidString)", isDirectory: true)
            try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
            defer { try? FileManager.default.removeItem(at: folder) }

            let url = folder.appendingPathComponent("hands.jsonl")
            let store = HistoryStore(url: url)
            store.append(record(hand: 1, net: 100))
            store.append(record(hand: 2, net: -40))
            t.expect(!store.hasBackup, "nothing has been cleared yet")

            store.clear()
            t.equal(store.hands.count, 0)
            t.equal(HistoryStore(url: url).hands.count, 0, "the file outlived the clear")
            t.equal(store.sessions().count, 0)
        }

        t.test("a reset keeps the old history on disk") {
            // Resetting is one tap away from a year of hands, so the file is moved aside rather
            // than deleted.
            let folder = FileManager.default.temporaryDirectory
                .appendingPathComponent("poker-reset-\(UUID().uuidString)", isDirectory: true)
            try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
            defer { try? FileManager.default.removeItem(at: folder) }

            let url = folder.appendingPathComponent("hands.jsonl")
            let store = HistoryStore(url: url)
            store.append(record(hand: 1, net: 100))
            store.append(record(hand: 2, net: -40))
            store.clear()

            t.expect(store.hasBackup, "the cleared history was not kept")
            guard let backup = store.backupURL else {
                return t.expect(false, "no backup location")
            }
            t.equal(HistoryStore(url: backup).hands.count, 2, "the backup lost the hands")

            // Playing on after a reset must not resurrect the old hands, and a second reset
            // replaces the backup rather than piling them up.
            store.append(record(session: "s2", hand: 1, net: 700))
            t.equal(store.hands.count, 1)
            t.equal(HistoryStore(url: url).hands.count, 1, "the reset file did not start clean")
            store.clear()
            t.equal(HistoryStore(url: backup).hands.count, 1, "the second reset kept the wrong backup")
        }

        t.test("clearing an empty history does nothing at all") {
            let store = HistoryStore.inMemory()
            store.clear()
            t.equal(store.hands.count, 0)
            t.expect(!store.hasBackup, "a history that never existed left a backup")
        }
    }

    t.suite("reading a game") { t in

        t.test("the headline numbers") {
            let hands = [
                record(hand: 1, net: 400, vpip: true, pfr: true, sawFlop: true, showdown: true),
                record(hand: 2, net: -200, vpip: true, pfr: false, sawFlop: true, showdown: false),
                record(hand: 3, net: -100, vpip: false, pfr: false, sawFlop: false, showdown: false),
                record(hand: 4, net: 0, vpip: false, pfr: false, sawFlop: false, showdown: false),
            ]
            let analysis = GameAnalysis(of: hands)

            t.equal(analysis.hands, 4)
            t.equal(analysis.netChips, 100)
            t.close(analysis.netBB, 1.0, 0.001, "100 chips at a 100 big blind")
            t.equal(analysis.vpip.percent, 50)
            t.equal(analysis.pfr.percent, 25)
            // Showdown rates are measured against the hands that saw a flop, not against all of them.
            t.equal(analysis.wentToShowdown.of, 2)
            t.equal(analysis.wentToShowdown.percent, 50)
            t.equal(analysis.wonAtShowdown.percent, 100)
            t.close(analysis.bbPer100, 25.0, 0.001)
        }

        t.test("small samples are marked as such") {
            let few = GameAnalysis(of: (1...20).map { record(hand: $0, net: 100) })
            t.expect(!few.enoughHands, "20 hands is not a read on anyone")
            t.expect(!few.vpip.trustworthy)

            let many = GameAnalysis(of: (1...250).map { record(hand: $0, net: 10) })
            t.expect(many.enoughHands)
            t.expect(many.vpip.trustworthy)
        }

        t.test("nothing divides by zero on an empty history") {
            let empty = GameAnalysis.empty
            t.equal(empty.hands, 0)
            t.close(empty.bbPer100, 0, 0.0001)
            t.close(empty.costPer100, 0, 0.0001)
            t.equal(empty.vpip.percent, 0)
            t.equal(empty.wonAtShowdown.percent, 0)
            t.expect(empty.best == nil)
            t.expect(empty.byStreet.isEmpty)
        }

        // Which habit cost what is the leak scorecard's question and is covered in its own suite.
        // This screen only breaks the money down by round of betting, which is the one thing the
        // scorecard does not do.
        t.test("costs are grouped by round of betting") {
            let hands = [
                record(hand: 1, net: -300, verdicts: [flagged("flop", "calling too wide", cost: 3),
                                                      flagged("river", "paying off", cost: 6)]),
                record(hand: 2, net: -100, verdicts: [flagged("flop", "calling too wide", cost: 2)]),
                record(hand: 3, net: 50, verdicts: [
                    VerdictRecord(street: "turn", spot: "s", leak: "quiet one", severity: 1,
                                  costInBB: 9, headline: "h", detail: "d", taken: "check",
                                  best: "bet", spoke: false),
                ]),
            ]
            let analysis = GameAnalysis(of: hands)

            t.equal(analysis.flaggedDecisions, 3, "a verdict the coach kept to itself was counted")
            t.close(analysis.costInBB, 11, 0.001)
            t.equal(analysis.byStreet.map(\.street), ["flop", "river"], "streets run in playing order")
            t.equal(analysis.byStreet.first { $0.street == "flop" }?.times, 2)
            t.close(analysis.byStreet.first { $0.street == "flop" }?.costBB ?? 0, 5, 0.001)
            t.close(analysis.byStreet.first { $0.street == "river" }?.costBB ?? 0, 6, 0.001)
            t.expect(!analysis.byStreet.contains { $0.street == "turn" },
                     "a verdict the coach kept to itself was priced")
        }

        t.test("seats are reported in table order, and the best and worst hands surface") {
            let hands = [
                record(hand: 1, position: "BB", net: -500),
                record(hand: 2, position: "BTN", net: 900),
                record(hand: 3, position: "UTG", net: -100),
            ]
            let analysis = GameAnalysis(of: hands)
            t.equal(analysis.byPosition.map(\.position), ["BB", "UTG", "BTN"],
                    "positions should run the way they sit, not alphabetically")
            t.equal(analysis.best?.netChips, 900)
            t.equal(analysis.worst?.netChips, -500)
            t.close(analysis.byPosition.first { $0.position == "BTN" }?.bbPer100 ?? 0, 900, 0.001)
        }

        t.test("an arranged hand carries a name the list can show") {
            // The Hands list marks arranged hands on the row rather than only once the hand is
            // opened: after a ninety-hand drill the list is ninety rows that look like ordinary
            // poker, and they are the reason the count on the scope bar and the hands tile on My
            // game are two different numbers. The row prints `mode`, so every arranged hand has to
            // have one and it has to say which kind it was.
            let store = HistoryStore.inMemory()
            let session = TableSession(seed: 909, history: store)
            session.startDrill(LeakCatalogue.named("bb-overfold")!)
            var steps = 0
            while session.isPlaying, store.hands.count < 4 {
                steps += 1
                precondition(steps < 20_000)
                if let legal = session.legal {
                    session.act(legal.canCheck ? .check : .fold)
                } else if session.engine?.isComplete == true {
                    session.continueAfterReview()
                } else {
                    break
                }
            }
            t.greaterThan(store.hands.count, 0, "the drill wrote nothing down")
            for hand in store.hands {
                t.expect(hand.isArranged, "a drill hand was not marked as arranged")
                t.expect(!hand.mode.isEmpty, "an arranged hand with no name to show")
                t.expect(hand.mode != "Tournament",
                         "a drill hand calls itself \"\(hand.mode)\", so the tag on the row would "
                         + "be indistinguishable from an ordinary hand")
            }
        }

        t.test("a played session writes itself down") {
            let store = HistoryStore.inMemory()
            let session = TableSession(seed: 55, history: store)
            session.start(.tournament)

            var guardRail = 0
            while session.isPlaying, session.handNumber <= 15 {
                guardRail += 1
                precondition(guardRail < 20_000)
                if let legal = session.legal {
                    session.act(legal.canCheck ? .check : .fold)
                } else if session.review != nil, session.engine?.isComplete == true {
                    if session.handNumber >= 15 { break }
                    session.continueAfterReview()
                } else {
                    break
                }
            }

            t.expect(store.hands.count >= 10, "only \(store.hands.count) hands were recorded")
            t.equal(Set(store.hands.map(\.sessionID)).count, 1, "one sitting, one id")
            t.equal(store.hands.map(\.handNumber), Array(1...store.hands.count))
            t.expect(store.hands.allSatisfy { $0.bigBlind > 0 }, "a hand was recorded with no blind")
            t.expect(store.hands.allSatisfy { !$0.position.isEmpty }, "a hand was recorded with no seat")
            t.expect(store.hands.allSatisfy { $0.cards.count == 2 }, "hole cards did not survive")

            // Folding everything must never look like playing everything.
            let analysis = GameAnalysis(of: store.hands)
            t.equal(analysis.pfr.percent, 0, "a player who never raised was recorded as raising")
            t.lessThan(analysis.vpip.percent, 40, "folding every hand should not read as a loose player")

            // Sitting down again is a new sitting, and the old one is still there. A sitting only
            // appears in the list once it has a finished hand in it — an abandoned table is not a
            // session anyone wants to see listed.
            let firstSitting = session.sessionID
            session.start(.tournament)
            t.expect(session.sessionID != firstSitting, "sitting down again reused the old sitting")
            t.equal(store.sessions().count, 1, "an empty new sitting should not be listed yet")

            guardRail = 0
            while session.isPlaying, session.handNumber <= 3 {
                guardRail += 1
                precondition(guardRail < 20_000)
                if let legal = session.legal {
                    session.act(legal.canCheck ? .check : .fold)
                } else if session.review != nil, session.engine?.isComplete == true {
                    if session.handNumber >= 3 { break }
                    session.continueAfterReview()
                } else {
                    break
                }
            }
            t.equal(store.sessions().count, 2, "the second sitting was never recorded")
            t.equal(store.sessions().first?.id, session.sessionID, "newest sitting should be first")
        }
    }
}