← poker-tutor
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")
}
}
}