← poker-tutor
import Foundation
import PokerCore
import PokerAI
import PokerCoach
import PokerSession
import TestKit
/// A drill is a promise that something has been fixed, so almost everything worth testing here is
/// about what it refuses to say: it will not clear on one good decision, it will not clear on a
/// round number of hands, it will not quietly forget a habit that came back, and it will not let
/// practice at one situation count as evidence about how somebody plays.
/// Plays a drill headlessly, driving `TableSession` exactly as a person would — which is possible
/// only because none of this lives in the app layer, and is what makes it the test that matters.
func driveDrill(_ session: TableSession, player: PlantedPlayer, seed: UInt64,
stoppingAfter limit: Int = .max) {
var rng = SeededRandom(seed: seed &* 11 &+ 5)
var steps = 0
while session.isPlaying, (session.drill?.handsPlayed ?? 0) < limit {
steps += 1
precondition(steps < 80_000, "the drill did not terminate")
if session.heroToAct, let view = session.engine?.view(for: session.heroSeat) {
session.act(player.decide(view, &rng))
} else if session.engine?.isComplete == true {
session.continueAfterReview()
} else {
session.finishHandNow()
}
}
}
/// A day, in seconds, so the scheduling tests read as days rather than as arithmetic.
let day: TimeInterval = 24 * 60 * 60
let start = Date(timeIntervalSince1970: 1_785_000_000)
/// Drill hands as they would have been written down: one decision each, in the situation the habit
/// lives in, `wrong` of them going wrong.
func drilled(_ leak: Leak, hands: Int, wrong: Int, session: String,
retest: Bool = false, on when: Date = start,
from number: Int = 1) -> [HandRecord] {
(0..<hands).map { index in
let mistake = index < wrong
let verdict = VerdictRecord(street: "flop", spot: leak.spot.name,
leak: mistake ? leak.error.name : "",
severity: mistake ? 3 : 0,
costInBB: mistake ? 1.5 : 0,
headline: "h", detail: "d", taken: "fold", best: "call",
spoke: mistake, confidence: 0.9)
return HandRecord(sessionID: session,
sessionStarted: when,
playedAt: when.addingTimeInterval(Double(index) * 90),
handNumber: number + index,
mode: retest ? "Re-test" : "Drill",
bigBlind: 100,
position: "BB",
holeCards: "Ah Kd",
board: "2c 7h Js",
netChips: -100,
wentToShowdown: false,
sawFlop: true,
putMoneyInPreflop: true,
raisedPreflop: false,
verdicts: [verdict],
tableSize: 9,
kind: retest ? HandRecord.retestKind : HandRecord.drillKind,
setup: "big-blind",
drill: leak.id)
}
}
/// Ordinary hands, so a scorecard has something that is not a drill to count.
private func ordinary(_ leak: Leak, hands: Int, wrong: Int) -> [HandRecord] {
(0..<hands).map { index in
let mistake = index < wrong
let verdict = VerdictRecord(street: "flop", spot: leak.spot.name,
leak: mistake ? leak.error.name : "",
severity: mistake ? 3 : 0,
costInBB: mistake ? 1.5 : 0,
headline: "h", detail: "d", taken: "fold", best: "call",
spoke: mistake, confidence: 0.9)
return HandRecord(sessionID: "ordinary",
sessionStarted: start,
playedAt: start.addingTimeInterval(Double(index) * 90),
handNumber: 500 + index,
mode: "Tournament", bigBlind: 100, position: "BB",
holeCards: "Ah Kd", board: "2c 7h Js", netChips: -100,
wentToShowdown: false, sawFlop: true, putMoneyInPreflop: true,
raisedPreflop: false, verdicts: [verdict], tableSize: 9)
}
}
/// Every sentence a drill can put in front of the player, in every state it can be in.
///
/// Collected here rather than in the glossary suite because the fixtures that get a drill into each
/// of those states live here. The glossary suite walks the result and fails if any of it uses a
/// poker word with nothing behind it — the standing rule, enforced rather than remembered.
func drillCopy() -> [String] {
var copy: [String] = []
for leak in LeakCatalogue.all {
for retest in [false, true] {
var run = DrillRun(leak: leak, retest: retest)
copy += [run.purposeLine, run.progressLine, run.targetLine,
run.outcomeTitle, run.outcomeLine]
// Part way through, then all the way to whatever ending it has.
for hand in drilled(leak, hands: 6, wrong: 1, session: "copy", retest: retest) {
run.observe(hand)
}
copy += [run.progressLine, run.targetLine, run.outcomeLine]
for hand in drilled(leak, hands: 40, wrong: 0, session: "copy", retest: retest,
from: 100) {
run.observe(hand)
}
copy += [run.outcomeTitle, run.outcomeLine, run.targetLine]
// And the ending nobody wants: it kept happening.
var stuck = DrillRun(leak: leak, retest: retest)
for hand in drilled(leak, hands: DrillRule.handLimit, wrong: DrillRule.handLimit,
session: "copy", retest: retest) {
stuck.observe(hand)
}
copy += [stuck.outcomeTitle, stuck.outcomeLine, stuck.targetLine]
}
// Every stage of the diary, said out loud.
let cleared = drilled(leak, hands: 60, wrong: 0, session: "d1")
let failedCheck = drilled(leak, hands: 40, wrong: 20, session: "r1", retest: true,
on: start.addingTimeInterval(day * 6), from: 500)
let half = drilled(leak, hands: 3, wrong: 1, session: "d2")
let hopeless = drilled(leak, hands: DrillRule.handLimit, wrong: DrillRule.handLimit,
session: "d3")
// A sitting that barely produced the situation, and a check that was started and walked
// away from: two states with sentences of their own, so two more states to sweep.
let barren = drilled(leak, hands: 4, wrong: 1, session: "d4")
let abandonedCheck = drilled(leak, hands: 5, wrong: 1, session: "r2", retest: true,
on: start.addingTimeInterval(day * 6), from: 900)
for (records, when) in [(cleared, start.addingTimeInterval(day)),
(cleared, start.addingTimeInterval(day * 40)),
(cleared + failedCheck, start.addingTimeInterval(day * 7)),
(half, start), (hopeless, start), (barren, start),
(cleared + abandonedCheck, start.addingTimeInterval(day * 7))] {
let book = DrillBook(of: records, now: when)
guard let standing = book.standing(of: leak.id) else { continue }
copy += [standing.line(now: when), standing.badge(now: when)]
}
}
return copy
}
/// Never raises an unopened pot from the last two seats, whatever is in front of them.
let neverAttacksTheBlinds = PlantedPlayer(name: "never attacks the blinds",
leakID: "passed-steals") { view, rng in
if view.street == .preflop, view.isUnopenedPot,
view.position == .button || view.position == .cutoff {
return view.legal.canCheck ? .check : .fold
}
return PlantedPlayer.solid(view, &rng)
}
public func drillTests(_ t: TestRun) {
let bigBlind = LeakCatalogue.named("bb-overfold")!
let limping = LeakCatalogue.named("limping")!
/// The habit the arithmetic fixtures below are written against: 18% over 25 chances.
///
/// They used to use the big blind one, whose numbers were re-derived from a sweep and are now
/// 30% over 50 — see `LeakCatalogue`. Nothing in these tests is about the big blind; they are
/// about when a drill clears, when a check fails and what the screens say about both, and every
/// one of them was written against 18% over 25. Repointing them at a habit that still carries
/// those numbers keeps the arithmetic they were checking intact rather than rewriting a dozen
/// fixtures to a new bar and losing the cases they were built to cover. The drills that sit a
/// real player at a real table still drill the big blind.
let paced = LeakCatalogue.named("fold-to-raise")!
t.suite("when a drill is finished") { t in
// The ending is the whole design. A drill that stopped after a round number of hands would
// be handing out all-clears off however many chances the deal happened to produce, which is
// the one thing a coach must never do.
t.test("one good decision clears nothing") {
t.expect(!DrillRule.clears(paced, chances: 1, mistakes: 0))
t.expect(!DrillRule.clears(paced, chances: 4, mistakes: 0))
}
t.test("a clean run still cannot clear before there have been enough chances") {
// The rate alone would let all of these through — they are nought out of a handful.
// The floor is what stops them, because a rate off eleven decisions is noise wearing a
// percentage sign.
let floor = DrillRule.floor(for: paced)
t.expect(!DrillRule.clears(paced, chances: floor - 1, mistakes: 0),
"the floor on chances is not being applied")
t.expect(DrillRule.clears(paced, chances: floor, mistakes: 0))
t.greaterThan(floor, DrillRule.minimumChances - 1)
}
t.test("it is not a fixed count — each habit has its own number") {
let strict = DrillRule.chancesStillNeeded(limping, chances: 0, mistakes: 0)
let looser = DrillRule.chancesStillNeeded(LeakCatalogue.named("fold-to-3bet")!,
chances: 0, mistakes: 0)
t.greaterThan(strict, looser,
"the catalogue takes \(limping.minimumSample) chances to call limping in "
+ "and 15 to call folding to every re-raise, so the drills for them "
+ "cannot both finish at the same number")
}
t.test("getting one wrong pushes the finish line out") {
let clean = DrillRule.chancesStillNeeded(paced, chances: 30, mistakes: 0)
let slipped = DrillRule.chancesStillNeeded(paced, chances: 30, mistakes: 5)
t.equal(clean, 0, "thirty clean chances should already have done it")
t.greaterThan(slipped, 0, "a slip has to cost something or the drill means nothing")
}
t.test("a rate under the bar still does not clear until it can be shown") {
// Two of fourteen is fourteen per cent, already under the eighteen a steady player
// sits at — and this habit takes 25 chances to call, so it takes 25 to clear.
t.expect(!DrillRule.clears(paced, chances: 14, mistakes: 2))
t.expect(DrillRule.clears(paced, chances: 100, mistakes: 14))
}
t.test("a habit that keeps happening does not clear on time served") {
t.expect(!DrillRule.clears(paced, chances: 60, mistakes: 20))
t.greaterThan(DrillRule.chancesStillNeeded(paced, chances: 60, mistakes: 20), 40,
"a fifth of them going wrong should be a long way from cleared")
}
t.test("how many more it wants is worked out, not searched for") {
// It used to count up one chance at a time until `clears` agreed, which terminated
// only because every rate in the catalogue happens to be 8% or more. A habit written
// with a rate of nought — a spot a competent player never gets wrong, which is a
// reasonable thing for somebody to add — would have spun for ever, and it would have
// done it inside a view body: this is read while the band above the felt is drawn.
//
// The two halves of the claim: the number it gives always clears, and one fewer never
// does. That is what makes it the same number the drill actually ends on.
var checked = 0
for leak in LeakCatalogue.all {
for mistakes in [0, 1, 3, 9] {
for chances in [0, 5, 20, 60] {
let more = DrillRule.chancesStillNeeded(leak, chances: chances,
mistakes: mistakes)
checked += 1
t.expect(DrillRule.clears(leak, chances: chances + more, mistakes: mistakes),
"\(leak.id): \(chances) chances plus \(more) more, \(mistakes) "
+ "wrong, still does not clear")
guard more > 0 else { continue }
t.expect(!DrillRule.clears(leak, chances: chances + more - 1,
mistakes: mistakes),
"\(leak.id): it asked for one more chance than it needed")
}
}
}
t.greaterThan(checked, 200, "only \(checked) combinations were tried")
let impossible = Leak(id: "never-wrong", spot: .riverDecision, error: .missedValue,
name: "A habit nobody is allowed to have",
explanation: "e", acceptableRate: 0, minimumSample: 12,
drillHint: "h")
t.equal(DrillRule.chancesStillNeeded(impossible, chances: 3, mistakes: 1),
DrillRule.mostChancesAskedFor,
"a habit that can never clear has to answer rather than spin")
}
}
t.suite("what the drill report is allowed to say") { t in
// The arithmetic here has always been careful. The sentences describing it were not, and a
// sentence that overstates what the numbers support is the product defect, not the typo:
// this whole app is the claim that it tells somebody the truth about their game.
t.test("a clean run is never reported as a clean sweep") {
// The one the drill's own doc comment warned about: nought out of twelve is not nought
// per cent of the time, and printing the raw rate handed that safeguard straight back.
//
// Drilled on a habit that really does clear on the bare floor. It used to be the river
// one, until that habit's sample was re-derived from a sweep and went to 25 — see
// `LeakCatalogue`. Two of the eighteen sit on the floor now; this is one of them.
let river = LeakCatalogue.named("no-shove")!
var run = DrillRun(leak: river)
for hand in drilled(river, hands: 12, wrong: 0, session: "clean") { run.observe(hand) }
t.expect(run.cleared, "twelve clean chances should clear this one")
t.equal(run.chances, 12)
t.equal(run.mistakes, 0)
t.greaterThan(run.countedPercent, 0,
"a clean run came out at \(run.countedPercent)%, which is the certainty "
+ "claim the imaginary slip exists to prevent")
t.expect(!run.outcomeLine.contains("— 0%"),
"the report printed the raw rate: \"\(run.outcomeLine)\"")
t.expect(run.outcomeLine.contains("\(run.countedPercent)%"),
"the report has to show the number the rule judged: \"\(run.outcomeLine)\"")
t.expect(run.outcomeLine.contains("slip"),
"the player has to be told the slip is added in: \"\(run.outcomeLine)\"")
}
t.test("a spotless record is never told too many have gone wrong") {
// The banner said "Too many have gone wrong for this sitting to clear it" on hand one
// of a turn drill, before a single decision had been made. `reachableToday` fails for
// two quite different reasons — too many slips, or the situation not coming up often
// enough per hand — and one sentence was carrying both. The turn habit takes 250
// chances and ninety hands here produce five to thirteen, so it is the deal every time.
for leak in LeakCatalogue.all {
var run = DrillRun(leak: leak)
t.expect(!run.targetLine.contains("gone wrong"),
"\(leak.id) blamed the player before a hand was played: "
+ "\"\(run.targetLine)\"")
// And twenty hands in with nothing wrong, which is the general case rather than
// the opening one: two chances in twenty hands is far too thin a deal to finish on
// and there is still nothing to blame anybody for.
for hand in drilled(leak, hands: 20, wrong: 0, session: "clean-banner") {
run.observe(hand)
}
t.expect(run.mistakes == 0, "this fixture is meant to be spotless")
t.expect(!run.targetLine.contains("gone wrong"),
"\(leak.id) blamed the player off \(run.chances) chances and no mistakes: "
+ "\"\(run.targetLine)\"")
}
// And where slips really are what is in the way, the old sentence is still the one
// that gets said — the fix is telling the two apart, not going quiet about both.
let turn = LeakCatalogue.named("fold-to-3bet")!
var slipped = DrillRun(leak: turn)
for hand in drilled(turn, hands: 60, wrong: 30, session: "slipped") {
slipped.observe(hand)
}
t.expect(!slipped.reachableToday, "this fixture is meant to be past saving today")
t.expect(slipped.targetLine.contains("gone wrong"),
"thirty slips in sixty chances is the player's doing and the banner should "
+ "say so: \"\(slipped.targetLine)\"")
}
t.test("clearing on the floor is called a good sign rather than a finished job") {
// Two of the eighteen habits clear on the bare twelve, which is where a number stops
// being noise and nowhere near where it settles anything.
let river = LeakCatalogue.named("no-shove")!
t.equal(DrillRule.floor(for: river), DrillRule.minimumChances,
"this habit is meant to be one of the ones that clears on the floor")
var run = DrillRun(leak: river)
for hand in drilled(river, hands: 12, wrong: 0, session: "floor") { run.observe(hand) }
t.expect(!run.outcomeLine.contains("mean something"),
"twelve chances do not make a number mean something: \"\(run.outcomeLine)\"")
t.expect(run.outcomeLine.contains("fewest chances this one can clear on"),
"it should say what the sample actually is: \"\(run.outcomeLine)\"")
t.expect(run.outcomeLine.contains("check"),
"the next word has to come from the check: \"\(run.outcomeLine)\"")
}
t.test("a check that held up does not claim more than twelve chances can pay for") {
var run = DrillRun(leak: paced, retest: true)
for hand in drilled(paced, hands: DrillRule.retestChances, wrong: 0,
session: "held", retest: true) {
run.observe(hand)
}
t.expect(run.passed)
t.equal(run.outcomeTitle, "It held up",
"\"It stuck\" is a settled fact off twelve chances that one slip cannot fail")
}
t.test("a check is judged on the number it prints") {
// It used to be judged on the raw rate and print the smoothed one, so a check could
// read "It held up — 2 of 12 went wrong ... which makes it 21%, against the 18% a
// steady player sits at" with the bar past the mark. Two of twelve is not a pass.
var run = DrillRun(leak: paced, retest: true)
for hand in drilled(paced, hands: 12, wrong: 2, session: "two", retest: true) {
run.observe(hand)
}
t.equal(run.chances, 12)
t.equal(run.mistakes, 2)
t.greaterThan(run.countedPercent, run.acceptablePercent,
"this fixture is meant to sit above the bar")
t.expect(run.failed, "it printed \(run.countedPercent)% against a \(run.acceptablePercent)% "
+ "bar and called it a pass: \"\(run.outcomeLine)\"")
}
t.test("nothing that held up prints a number above the bar without saying why") {
// Every habit, both endings, the whole catalogue. A check that passes either came in
// at or under the rate it prints, or was the one slip that is forgiven outright — and
// where it was the slip, the sentence beside the number has to say so, because
// otherwise the reader is looking at 14% against an 8% bar under the word "held up".
var checked = 0
for leak in LeakCatalogue.all {
for wrong in 0...3 {
var run = DrillRun(leak: leak, retest: true)
for hand in drilled(leak, hands: DrillRule.retestChances, wrong: wrong,
session: "sweep-\(leak.id)-\(wrong)", retest: true) {
run.observe(hand)
}
guard run.passed else { continue }
checked += 1
if run.countedRate > leak.acceptableRate {
t.lessThan(run.mistakes, DrillRule.retestMistakes,
"\(leak.id) passed a check at \(run.countedPercent)% against "
+ "\(run.acceptablePercent)% on \(run.mistakes) slips")
t.expect(run.outcomeLine.contains("One slip does not fail a check"),
"\(leak.id) passed on the forgiven slip and did not say so: "
+ "\"\(run.outcomeLine)\"")
}
}
}
t.greaterThan(checked, 0, "the sweep never found a check that passed")
}
t.test("the app has one name for the check") {
// "Re-test" turned up in exactly one sentence, at the moment somebody is being told
// they left one unfinished. Every screen calls it a check.
var run = DrillRun(leak: paced, retest: true)
for hand in drilled(paced, hands: 4, wrong: 0, session: "part", retest: true) {
run.observe(hand)
}
t.expect(run.settledAt == nil, "four chances cannot settle a check")
for line in [run.outcomeLine, run.progressLine, run.targetLine, run.purposeLine] {
t.expect(!line.lowercased().contains("re-test"),
"a second name for the same thing: \"\(line)\"")
}
}
}
t.suite("aiming a drill at one habit") { t in
t.test("it picks the arrangement most likely to produce the situation") {
let wanted: [SpotKind: String] = [
.bigBlindDefence: "big-blind",
.shortStackShove: "short-stack",
.facingRiverBet: "bet-at-you",
.facingReraise: "facing-heat",
.riverDecision: "deep-and-sticky",
]
for (spot, setup) in wanted {
t.equal(DrillPlanner.next(for: spot).id, setup,
"a drill at \(spot.name) should arrange the table for it")
}
}
t.test("every habit in the catalogue can actually be drilled") {
for leak in LeakCatalogue.all {
t.expect(!DrillPlanner.setups(for: leak).isEmpty,
"nothing arranges the table for \(leak.name)")
}
}
t.test("a habit whose whole point is the seat is drilled from that seat") {
// The situation is not enough to pick an arrangement by. Three habits live in "opening
// the pot" and two of them want opposite ends of the table, so ranking on the situation
// alone sat the player first to speak for "not attacking the blinds" — a seat with no
// blinds in front of it to attack — while the brief a few lines above the felt said
// "seats 9 and 8, the button and the cutoff".
let steals = LeakCatalogue.named("passed-steals")!
let tooMany = LeakCatalogue.named("too-many-hands")!
t.equal(DrillPlanner.next(for: steals).seat, .button,
"the drill for not attacking the blinds is not in the last seat")
t.equal(DrillPlanner.next(for: tooMany).seat, .underTheGun,
"the drill for playing too many hands is not in the first seat")
t.notEqual(DrillPlanner.next(for: steals).id, DrillPlanner.next(for: tooMany).id,
"two habits that want opposite ends of the table got the same arrangement")
}
t.test("a pinned arrangement is the only one, so the yield cannot wander off it") {
// Dropping an arrangement that stops producing the situation is right for a habit that
// any seat can show. It is wrong for one that only exists in a seat: switching would
// put the player back where the mistake cannot happen and count their correct folds as
// clean chances.
let steals = LeakCatalogue.named("passed-steals")!
var yield = SetupYield()
for _ in 0..<20 { yield.saw("last-seats", spots: []) }
t.equal(DrillPlanner.next(for: steals, yield: yield).id, "last-seats")
t.equal(DrillPlanner.setups(for: steals).count, 1)
}
t.test("every arrangement a habit asks for is one that exists and produces its situation") {
for leak in LeakCatalogue.all {
guard let id = leak.drillSetup else { continue }
guard let setup = AssessmentPlanner.setup(id: id) else {
t.expect(false, "\(leak.id) asks for an arrangement called '\(id)', "
+ "which is not one of them")
continue
}
t.greaterThan(setup.expected[leak.spot] ?? 0, 0,
"\(leak.id) is drilled at '\(id)', which does not produce "
+ "\(leak.spot.name) at all")
}
}
t.test("the sheet promises the seat the table then deals") {
// The brief and the table used to ask two different questions and print two different
// answers on the same screen.
let steals = LeakCatalogue.named("passed-steals")!
let session = TableSession(seed: 31, history: .inMemory())
session.startDrill(steals)
driveDrill(session, player: .control, seed: 31, stoppingAfter: 6)
t.greaterThan(session.history.hands.count, 0, "the drill wrote nothing down")
for hand in session.history.hands {
t.equal(hand.setup, DrillPlanner.next(for: steals).id,
"the brief said one arrangement and the table dealt another")
t.equal(hand.position, Position.button.rawValue,
"hand \(hand.handNumber) was played from \(hand.position)")
}
}
t.test("an arrangement that stops producing the situation is dropped") {
// The same lesson the assessment learned the expensive way: a setup can promise river
// decisions and never get anybody as far as the river, and a planner that took the
// promise at face value would deal that hand for ever.
var yield = SetupYield()
let first = DrillPlanner.next(for: .riverDecision)
for _ in 0..<8 { yield.saw(first.id, spots: []) }
t.notEqual(DrillPlanner.next(for: .riverDecision, yield: yield).id, first.id,
"it kept arranging a table that was not producing the situation")
}
}
t.suite("a drill at the table") { t in
t.test("every hand is written down as a drill hand naming the habit") {
let session = TableSession(seed: 404, history: .inMemory())
session.startDrill(bigBlind)
driveDrill(session, player: .control, seed: 404, stoppingAfter: 12)
let hands = session.history.hands
t.greaterThan(hands.count, 0, "the drill wrote nothing down")
for hand in hands {
t.expect(hand.isDrill, "hand \(hand.handNumber) was not marked as a drill hand")
t.equal(hand.drill, bigBlind.id)
t.equal(hand.tier, nil, "an arranged hand must never count towards the ladder")
t.expect(hand.setup != nil, "the file should say how the table was arranged")
}
}
t.test("it keeps putting the player in the one situation") {
let session = TableSession(seed: 909, history: .inMemory())
session.startDrill(bigBlind)
driveDrill(session, player: .control, seed: 909, stoppingAfter: 30)
guard let run = session.drill else { return t.expect(false, "no drill") }
t.greaterThan(Double(run.chances) / Double(max(1, run.handsPlayed)), 0.4,
"\(run.chances) chances in \(run.handsPlayed) hands is not a drill, it "
+ "is a game")
}
t.test("it clears for a player without the habit and refuses to for one with it") {
// Never off one seed. A single drill swings on whether the deal happened to put a
// raise in front of the player, and this measures a rate.
var cleanCleared = 0
var flawedCleared = 0
for seed: UInt64 in [11, 2_020, 30_303, 404_040] {
let clean = TableSession(seed: seed, history: .inMemory())
clean.startDrill(bigBlind)
driveDrill(clean, player: .control, seed: seed)
if clean.drill?.cleared == true { cleanCleared += 1 }
let flawed = TableSession(seed: seed, history: .inMemory())
flawed.startDrill(bigBlind)
driveDrill(flawed, player: .foldsBigBlind, seed: seed)
if flawed.drill?.cleared == true { flawedCleared += 1 }
}
t.equal(flawedCleared, 0,
"a player who folds every big blind cleared the drill for it \(flawedCleared) "
+ "times out of four")
t.greaterThan(cleanCleared, 2,
"a sound player only cleared \(cleanCleared) of four, which makes the "
+ "drill unfinishable rather than demanding")
}
t.test("each habit in the opening spot is drilled where it can actually show itself") {
// The three habits that share "opening the pot" are the ones the arrangement decides,
// so all three are measured rather than argued about. Six seeds, the full ninety
// hands, sound player against the matching flawed one, counting sittings cleared:
//
// habit arrangement sound flawed at the other end
// too many hands early-seat 6/6 0/6 0/6 and 1/6
// limping last-seats 5/6 1/6 6/6 and 3/6
// not attacking last-seats 5/6 3/6 both clear on folds
//
// The right-hand column is why these are pinned. Ranked on the situation alone, all
// three went to the first seat to speak — where every hand is an opening chance and
// almost none of them is worth raising, so twenty-odd correct folds finish the drill
// and finish it for the flawed player too.
//
// "Not attacking the blinds" at 5 against 3 is honest rather than good, and it is the
// weakest of the three on purpose: the habit can only show itself in the third or so of
// unopened pots that were worth raising, while `minimumSample` and `acceptableRate` are
// defined over all of them. Sharpening that means re-deriving both numbers for all
// eighteen habits from a sweep, which is a measurement project rather than a fix. What
// is guarded here is that each drill is finishable and that none of them is blind.
let seeds: [UInt64] = [11, 2_020, 30_303, 404_040, 77, 512]
let pairs: [(String, PlantedPlayer)] = [
("too-many-hands", .playsEverything),
("limping", .limpsEverything),
("passed-steals", neverAttacksTheBlinds),
]
for (id, player) in pairs {
guard let leak = LeakCatalogue.named(id) else {
t.expect(false, "no habit called \(id)")
continue
}
var cleanCleared = 0
var flawedCleared = 0
for seed in seeds {
let clean = TableSession(seed: seed, history: .inMemory())
clean.startDrill(leak)
driveDrill(clean, player: .control, seed: seed)
if clean.drill?.cleared == true { cleanCleared += 1 }
let flawed = TableSession(seed: seed, history: .inMemory())
flawed.startDrill(leak)
driveDrill(flawed, player: player, seed: seed)
if flawed.drill?.cleared == true { flawedCleared += 1 }
}
t.greaterThan(cleanCleared, 3,
"a sound player cleared the \(id) drill \(cleanCleared) of "
+ "\(seeds.count) times, which makes it unfinishable rather than "
+ "demanding")
t.lessThan(flawedCleared, cleanCleared,
"a player who \(player.name) cleared the \(id) drill \(flawedCleared) "
+ "times against the sound player's \(cleanCleared) — it is not seeing "
+ "the habit at all")
}
}
t.test("a drill that did not clear says so rather than letting anybody off") {
let session = TableSession(seed: 55, history: .inMemory())
session.startDrill(bigBlind)
driveDrill(session, player: .foldsBigBlind, seed: 55)
guard let run = session.drill else { return t.expect(false, "no drill") }
t.expect(!run.cleared)
t.expect(run.ranOut, "it should have run its full length before giving up")
t.equal(run.outcomeTitle, "Not cleared")
t.expect(run.outcomeLine.contains("not fixed"),
"the report has to say it out loud: \"\(run.outcomeLine)\"")
}
t.test("a sitting that barely produced the situation settles nothing either way") {
// Measured rather than supposed. A full ninety-hand drill at the turn habit produced
// five to thirteen turn decisions across six seeds (11, 2020, 30303, 404040, 77, 512),
// against a habit that takes a great many more before a rate off them means anything —
// see the numbers on `missed-turn-value` in the catalogue. The ending used to read
// "ninety hands and it is still happening, so this one is not fixed" off six chances,
// which is an accusation wearing a measurement's clothes. Two endings, and this is the
// second one.
let turn = LeakCatalogue.named("missed-turn-value")!
let session = TableSession(seed: 11, history: .inMemory())
session.startDrill(turn)
driveDrill(session, player: .control, seed: 11)
guard let run = session.drill else { return t.expect(false, "no drill") }
t.expect(run.ranOut, "the sitting was meant to run its full length")
t.expect(!run.cleared)
t.lessThan(run.chances, DrillRule.floor(for: turn),
"this fixture only means anything while the sitting cannot reach the "
+ "chances the habit needs; it got \(run.chances)")
t.expect(run.tooFewChances)
t.equal(run.outcomeTitle, "Not enough to say")
t.expect(!run.outcomeLine.contains("not fixed"),
"it called a habit unfixed off \(run.chances) chances: \"\(run.outcomeLine)\"")
t.expect(run.outcomeLine.contains("settled it either way"),
"it does not say what it could not do: \"\(run.outcomeLine)\"")
// And the row on the scorecard afterwards says the same thing rather than the other
// one, since it is built from the same hands.
let now = session.history.hands.map(\.playedAt).max() ?? Date()
guard let standing = DrillBook(of: session.history.hands, now: now)
.standing(of: turn.id) else {
return t.expect(false, "the drill was not recorded")
}
t.equal(standing.stage, .stalled)
t.equal(standing.badge(now: now), "drilled, not measured")
t.expect(standing.line(now: now).contains("settled nothing either way"),
"the row still accuses them: \"\(standing.line(now: now))\"")
// A drill that did get its chances and kept going wrong still says so — that sentence
// is the one this must not have replaced.
let honest = DrillRun.resuming(from: drilled(bigBlind, hands: DrillRule.handLimit,
wrong: 40, session: "stuck"),
leak: bigBlind)
t.expect(!honest.tooFewChances)
t.expect(honest.outcomeLine.contains("not fixed"),
"the plain ending went missing: \"\(honest.outcomeLine)\"")
}
t.test("a check at the table asks its twelve questions and then answers") {
let session = TableSession(seed: 61, history: .inMemory())
session.startDrill(bigBlind, retest: true)
driveDrill(session, player: .foldsBigBlind, seed: 61)
guard let run = session.drill else { return t.expect(false, "no check") }
t.expect(run.isRetest)
t.expect(run.failed, "a player who folds every big blind passed the check")
t.equal(run.outcomeTitle, "It came back")
t.equal(run.chances, DrillRule.retestChances,
"a check that ran on past its own length is not a check")
t.greaterThan(session.history.hands.count, 0, "the check wrote nothing down")
for hand in session.history.hands {
t.expect(hand.isRetest, "hand \(hand.handNumber) was not marked as a check")
t.equal(hand.drill, bigBlind.id)
}
}
t.test("stopping half way costs nothing") {
// The same rule as everywhere else here: there is no second copy of the progress,
// because the hands are the record.
let store = HistoryStore.inMemory()
let session = TableSession(seed: 7, history: store)
session.startDrill(bigBlind)
driveDrill(session, player: .control, seed: 7, stoppingAfter: 10)
guard let stopped = session.drill else { return t.expect(false, "no drill") }
let id = session.sessionID
let rebuilt = DrillRun.resuming(from: store.hands(in: .session(id)), leak: bigBlind)
t.equal(rebuilt.handsPlayed, stopped.handsPlayed, "the hands played came back wrong")
t.equal(rebuilt.chances, stopped.chances, "the chances came back wrong")
t.equal(rebuilt.mistakes, stopped.mistakes, "the mistakes came back wrong")
// And a fresh session, the way the next launch would build one, carries it on.
let next = TableSession(seed: 7, history: store)
t.expect(next.resumeDrill(bigBlind, sessionID: id), "it would not pick the drill back up")
t.equal(next.drill?.chances, stopped.chances)
t.expect(next.isPlaying, "resuming should deal the next hand")
}
t.test("restarting a drill and then going back to the first one is still two attempts") {
// A sitting is an attempt, and which sitting a hand belongs to is written on the hand.
// Grouping by what happens to sit next to what in the file turned one drill that was
// restarted and then picked back up into three attempts, the last of them holding only
// the handful played after the restart — so the book reported ten chances as five while
// `resumeDrill` was about to load all ten off the same sitting.
let firstHalf = drilled(bigBlind, hands: 5, wrong: 1, session: "s1", on: start)
let restart = drilled(bigBlind, hands: 5, wrong: 0, session: "s2",
on: start.addingTimeInterval(day), from: 100)
let backToTheFirst = drilled(bigBlind, hands: 5, wrong: 1, session: "s1",
on: start.addingTimeInterval(day * 2), from: 200)
let book = DrillBook(of: firstHalf + restart + backToTheFirst,
now: start.addingTimeInterval(day * 3))
guard let standing = book.standing(of: bigBlind.id) else {
return t.expect(false, "the drill was not recorded at all")
}
t.equal(standing.stage, .running)
t.equal(standing.sessionID, "s1", "the sitting the player was last in is the open one")
t.equal(standing.hands, 10, "the resumed sitting was counted as a third, shorter attempt")
t.equal(standing.mistakes, 2, "half the sitting's slips went missing")
// And the book now agrees with what resuming that sitting would actually load.
let resumed = DrillRun.resuming(from: (firstHalf + backToTheFirst), leak: bigBlind)
t.equal(standing.hands, resumed.handsPlayed,
"the book and the resume disagree about how far along the drill is")
t.equal(standing.chances, resumed.chances)
// Nothing changes for the ordinary case, where a sitting's hands are all together.
let tidy = DrillBook(of: firstHalf + restart, now: start.addingTimeInterval(day * 3))
t.equal(tidy.standing(of: bigBlind.id)?.sessionID, "s2")
t.equal(tidy.standing(of: bigBlind.id)?.hands, 5)
}
t.test("the half-done drill offered on the home screen is the one you were in") {
// `all` is sorted by habit id so that everything built on it comes out in the same
// order every launch, and taking the first half-done drill off that list offered
// whichever habit happened to sort earliest in the alphabet. Somebody who stopped part
// way through the limping drill this evening was offered "carry on" on a big blind
// drill they had walked away from last month.
t.lessThan(bigBlind.id, limping.id,
"this test only means anything while the older one sorts first")
let lastMonth = drilled(bigBlind, hands: 6, wrong: 1, session: "old", on: start)
let tonight = drilled(limping, hands: 6, wrong: 1, session: "new",
on: start.addingTimeInterval(day * 30), from: 400)
let book = DrillBook(of: lastMonth + tonight,
now: start.addingTimeInterval(day * 30 + 3600))
t.equal(book.standing(of: bigBlind.id)?.stage, .running)
t.equal(book.standing(of: limping.id)?.stage, .running)
t.equal(book.unfinished?.id, limping.id,
"it offered the drill that sorts first rather than the one just walked away from")
}
}
t.suite("checking back days later") { t in
// Everything here is answered against a clock that is passed in, so a fortnight of
// scheduling is tested in a millisecond rather than waited out.
let cleared = drilled(paced, hands: 30, wrong: 0, session: "d1", on: start)
t.test("a cleared drill puts a check in the diary rather than closing the matter") {
let soon = DrillBook(of: cleared, now: start.addingTimeInterval(day))
guard let standing = soon.standing(of: paced.id) else {
return t.expect(false, "the drill was not recorded at all")
}
t.equal(standing.stage, .holding)
t.expect(standing.clearedOn != nil)
t.expect(standing.dueOn != nil, "a cleared drill with no check owed is a forgotten one")
t.expect(soon.dueNow.isEmpty, "it should not be due the next morning")
}
t.test("the check comes due on its day and not before") {
let dayBefore = DrillBook(of: cleared,
now: start.addingTimeInterval(day * (DrillRule.firstGapInDays - 1)))
t.equal(dayBefore.standing(of: paced.id)?.stage, .holding)
let dayAfter = DrillBook(of: cleared,
now: start.addingTimeInterval(day * (DrillRule.firstGapInDays + 1)))
t.equal(dayAfter.standing(of: paced.id)?.stage, .due)
t.equal(dayAfter.dueNow.map(\.id), [paced.id],
"a check that is due has to turn up where the player will see it")
}
t.test("it survives the app being closed, because the hands do") {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("poker-drills-\(UUID().uuidString).jsonl")
defer { try? FileManager.default.removeItem(at: url) }
let writing = HistoryStore(url: url)
for hand in cleared { writing.append(hand) }
// A second store opening the same file is what the next launch does.
let reading = HistoryStore(url: url)
let later = start.addingTimeInterval(day * (DrillRule.firstGapInDays + 1))
let book = DrillBook(of: reading.hands, now: later)
t.equal(book.standing(of: paced.id)?.stage, .due,
"the check did not survive a restart")
t.equal(book.standing(of: paced.id)?.clearedOn,
DrillBook(of: cleared, now: later).standing(of: paced.id)?.clearedOn,
"the date it cleared on came back different")
}
t.test("a check that held up buys a longer gap before the next one") {
let checkDay = start.addingTimeInterval(day * 6)
let passed = drilled(paced, hands: 12, wrong: 0, session: "r1", retest: true,
on: checkDay, from: 100)
let book = DrillBook(of: cleared + passed, now: checkDay.addingTimeInterval(3600))
guard let standing = book.standing(of: paced.id) else {
return t.expect(false, "no standing")
}
t.equal(standing.stage, .holding)
t.equal(standing.checksPassed, 1)
guard let due = standing.dueOn, let checked = standing.checkedOn else {
return t.expect(false, "a passed check left nothing in the diary")
}
t.close(due.timeIntervalSince(checked) / day, DrillRule.firstGapInDays * 2, 0.01,
"the gap should widen once it has held up")
}
t.test("one slip does not fail a check") {
let checkDay = start.addingTimeInterval(day * 6)
let nearly = drilled(paced, hands: 12, wrong: 1, session: "r1", retest: true,
on: checkDay, from: 100)
let book = DrillBook(of: cleared + nearly, now: checkDay.addingTimeInterval(3600))
t.equal(book.standing(of: paced.id)?.stage, .holding,
"one bad decision is a bad decision, not a habit coming back")
}
t.test("a check walked away from is still owed") {
let checkDay = start.addingTimeInterval(day * 6)
let abandoned = drilled(paced, hands: 4, wrong: 0, session: "r1", retest: true,
on: checkDay, from: 100)
let book = DrillBook(of: cleared + abandoned, now: checkDay.addingTimeInterval(3600))
t.equal(book.standing(of: paced.id)?.stage, .due,
"an unfinished check must not count as a passed one")
}
t.test("a check walked away from starts again, and the screen says so") {
// The rule stands: a check that could be restarted after three bad answers is a check
// anybody can pass by quitting, and the whole point of it is that it cannot be steered.
// What was missing is that the player was never told. They stopped at eight of twelve,
// came back to a card offering "Check it — 12 chances", and the eight they had played
// were quietly asked again.
let checkDay = start.addingTimeInterval(day * 6)
let now = checkDay.addingTimeInterval(3600)
let abandoned = drilled(paced, hands: 8, wrong: 1, session: "r1", retest: true,
on: checkDay, from: 100)
let book = DrillBook(of: cleared + abandoned, now: now)
guard let standing = book.standing(of: paced.id) else {
return t.expect(false, "no standing")
}
t.equal(standing.stage, .due)
t.expect(standing.sessionID != nil, "the abandoned sitting was forgotten entirely")
let line = standing.line(now: now)
t.expect(line.contains("stopped 8 chances in"),
"it does not say how far they got: \"\(line)\"")
t.expect(line.contains("begins again"),
"it does not say the check starts over: \"\(line)\"")
// And a check that is owed because the days have passed says none of that, because
// none of it happened.
let waited = DrillBook(of: cleared, now: start.addingTimeInterval(day * 30))
guard let plain = waited.standing(of: paced.id) else {
return t.expect(false, "no standing")
}
t.equal(plain.stage, .due)
t.expect(!plain.line(now: start.addingTimeInterval(day * 30)).contains("begins again"),
"a check nobody has started was described as one they abandoned")
}
}
t.suite("a habit that comes back") { t in
let cleared = drilled(paced, hands: 30, wrong: 0, session: "d1", on: start)
let checkDay = start.addingTimeInterval(day * 6)
let failed = drilled(paced, hands: 12, wrong: 5, session: "r1", retest: true,
on: checkDay, from: 100)
let now = checkDay.addingTimeInterval(3600)
t.test("a failed check reopens it rather than quietly forgetting it") {
let book = DrillBook(of: cleared + failed, now: now)
guard let standing = book.standing(of: paced.id) else {
return t.expect(false, "no standing")
}
t.equal(standing.stage, .reopened)
t.equal(book.reopened.map(\.id), [paced.id])
t.expect(standing.line(now: now).contains("come back"),
"the row has to say what happened: \"\(standing.line(now: now))\"")
}
t.test("a row put back by a check shows the check's numbers, not the empty ones beside it") {
// Drills are read over everything ever played and the scorecard follows the scope bar,
// so pointing the screen at one sitting put a row under the heading "what is costing
// you" whose own sentence read "This has not come up yet", with a note underneath
// saying "Only 0 chances so far" and an empty bar. The app telling somebody a habit is
// costing them money and, one line down, that it has never seen it happen.
let elsewhere = ordinary(limping, hands: 40, wrong: 0)
let book = DrillBook(of: cleared + failed, now: now)
let card = LeakScorecard(of: elsewhere, drills: book)
guard let row = card.costing.first(where: { $0.id == paced.id }) else {
return t.expect(false, "the reopened habit is not on the list at all")
}
t.equal(row.chances, 0, "these hands were never in that situation, which is the point")
t.expect(row.frequencyLine.contains("5 of 12"),
"the row does not print the check's numbers: \"\(row.frequencyLine)\"")
t.expect(!row.frequencyLine.contains("has not come up yet"),
"\"costing you\" over \"has not come up yet\": \"\(row.frequencyLine)\"")
t.equal(row.sampleNote, nil,
"the small-sample note is measuring the wrong sample: \"\(row.sampleNote ?? "")\"")
// The check's own rate, which is the one with the forgiven slip in it. This assertion
// used to read 5/12 and it was wrong in the same way the drill report was before commit
// 3c9120c: the check passes or fails on the smoothed number, so printing the raw one
// beside the verdict is two versions of one measurement. It is 6/14 now.
t.close(row.rate, 6.0 / 14.0, 0.001, "the bar was empty on a row that names a habit")
t.expect(row.frequencyLine.contains("One slip is always added"),
"the row prints a percentage worked out differently from the fraction beside "
+ "it, and says nothing about why: \"\(row.frequencyLine)\"")
// And a row with real evidence of its own still reads off the hands in view.
let seen = ordinary(paced, hands: 40, wrong: 12)
let plenty = LeakScorecard(of: seen, drills: book)
guard let full = plenty.costing.first(where: { $0.id == paced.id }) else {
return t.expect(false, "no row")
}
t.equal(full.reopenedCheck, nil, "40 chances in view do not need the check to speak for them")
t.expect(full.frequencyLine.contains("12 times out of 40"), full.frequencyLine)
}
t.test("a check failed by one slip prints the number it was failed on") {
// The narrow case the split hid in, and the one that mattered most: two slips in twelve
// is 17% raw and 21% once the forgiven slip is counted, against a bar of 18%. So the
// check fails, the row goes under "what is costing you" — and the raw version printed
// 17% beside it and drew a green bar stopping short of the mark, on the same screen
// that tells the reader a bar stopping short of the mark is a part of their game that
// is working. Six of the eighteen habits have a bar inside that window.
let narrow = drilled(paced, hands: 12, wrong: 2, session: "r2", retest: true,
on: checkDay, from: 300)
let book = DrillBook(of: cleared + narrow, now: now)
guard let standing = book.standing(of: paced.id) else {
return t.expect(false, "no standing")
}
t.equal(standing.stage, .reopened, "2 of 12 is 21% with the slip in it, past an 18% bar")
let card = LeakScorecard(of: ordinary(limping, hands: 40, wrong: 0), drills: book)
guard let row = card.costing.first(where: { $0.id == paced.id }) else {
return t.expect(false, "the reopened habit is not on the list at all")
}
t.expect(row.percent >= row.acceptablePercent,
"a row on the costing list printed \(row.percent)% against a bar of "
+ "\(row.acceptablePercent)%, which reads as better than a steady player")
t.expect(row.frequencyLine.contains("2 of 12"), row.frequencyLine)
t.expect(row.frequencyLine.contains("21%"),
"the fraction and the percentage are different arithmetic and the row has to "
+ "say so: \"\(row.frequencyLine)\"")
}
t.test("and it goes back on the scorecard, whatever the recent hands say") {
// The ordinary hands below are clean, so nothing in them would put this habit on the
// list. The check is the evidence, and it is evidence in exactly the same situation.
let hands = ordinary(paced, hands: 40, wrong: 0) + cleared + failed
let book = DrillBook(of: hands, now: now)
let card = LeakScorecard(of: hands, drills: book)
t.expect(card.costing.map(\.id).contains(paced.id),
"a habit that came back is not on the list: \(card.costing.map(\.id))")
t.equal(card.reopened.map(\.id), [paced.id])
}
t.test("drilling it again clears it again") {
let secondGo = drilled(paced, hands: 30, wrong: 0, session: "d2",
on: checkDay.addingTimeInterval(day), from: 200)
let book = DrillBook(of: cleared + failed + secondGo,
now: checkDay.addingTimeInterval(day * 2))
t.equal(book.standing(of: paced.id)?.stage, .holding)
t.expect(book.reopened.isEmpty)
}
}
t.suite("practice is not evidence") { t in
t.test("drill hands stay out of the leak scorecard") {
// Otherwise anybody could drill a habit off their own record without playing a single
// hand of poker differently.
let hands = ordinary(paced, hands: 30, wrong: 20)
+ drilled(paced, hands: 40, wrong: 0, session: "d1")
let card = LeakScorecard(of: hands)
t.equal(card.hands, 30, "drill hands were counted as hands played")
let entry = card.allEntries.first { $0.id == paced.id }
t.equal(entry?.chances, 30, "forty drill decisions leaked into the count")
t.equal(entry?.times, 20)
}
t.test("drill hands stay out of the win rate, and the screen says so") {
let hands = ordinary(paced, hands: 10, wrong: 2)
+ drilled(paced, hands: 12, wrong: 0, session: "d1")
let analysis = GameAnalysis(of: hands)
t.equal(analysis.hands, 10)
t.equal(analysis.drillHands, 12)
guard let note = analysis.arrangedNote else {
return t.expect(false, "nothing explains where the missing hands went")
}
t.expect(note.contains("drill"), "the note does not mention the drill hands")
}
t.test("a hand written down before drills existed still reads") {
// The rule this whole file lives under: a new field is optional, or one launch of a
// new build deletes every hand anybody ever played.
let old = #"{"sessionID":"s1","sessionStarted":"2026-01-01T10:00:00Z","playedAt":"2026-01-01T10:01:00Z","handNumber":1,"mode":"Tournament","bigBlind":100,"position":"BB","holeCards":"Ah Kd","board":"","netChips":-100,"wentToShowdown":false,"sawFlop":false,"putMoneyInPreflop":false,"raisedPreflop":false,"verdicts":[]}"#
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
guard let record = try? decoder.decode(HandRecord.self, from: Data(old.utf8)) else {
return t.expect(false, "a hand with no drill field stopped decoding")
}
t.equal(record.drill, nil)
t.expect(!record.isDrill)
t.expect(!record.isArranged)
}
}
}