← poker-tutor
import Foundation
import TestKit
import PokerUI
import PokerCoach
/// The basics course. Ken, 2026-08-02: "One area that I don't get is how to determine the size of
/// the bet. Lets create a little focused tutorial on some basic concepts... And lets come up with
/// tutorials that drill each concept."
///
/// The writing itself is swept for jargon and banned phrasing by the glossary suite, which reads
/// `Copy.prose` — `Basics.allText` is in there, so nothing below repeats that. What is here is the
/// part a sweep cannot see: that every lesson is actually complete, and that "Practise this" leads
/// somewhere real rather than to a button that deals ordinary hands and calls it a lesson.
public func basicsTests(_ t: TestRun) {
t.suite("the basics") { t in
t.test("every lesson answers a question and answers it first") {
t.greaterThan(Basics.lessons.count, 7, "a course this short is not worth a screen")
t.greaterThan(Basics.cashLessons.count, 7, "the cash course is too short for a screen")
for lesson in Basics.tournamentLessons + Basics.cashLessons + Basics.limitLessons {
t.expect(lesson.question.hasSuffix("?"),
"\(lesson.id) is titled \"\(lesson.question)\", which is not a question")
t.expect(!lesson.short.isEmpty, "\(lesson.id) has no short answer")
t.expect(lesson.short.count < 160,
"\(lesson.id)'s short answer runs to \(lesson.short.count) characters, "
+ "which is no longer the short answer")
t.greaterThan(lesson.paragraphs.count, 1, "\(lesson.id) barely explains itself")
for para in lesson.paragraphs {
t.expect(!para.isEmpty, "\(lesson.id) has an empty paragraph")
}
}
}
t.test("being told a bet was the wrong size leads to the lesson about bet sizes") {
// Ken, 2026-08-22: "when you tell someone that their bet is not right, I find that i
// dont undersand why." This is that complaint, as an assertion.
//
// It matters most here of all the habits. `bet-sizing` is deliberately unnameable —
// the coach may price one bad size but is never allowed to say somebody HAS the habit,
// because a sweep could not separate a player who does it every time from one who
// never does. So the review is the only place the rule can ever be explained, and
// before this it was the one place that did not explain it.
let lesson = Basics.lesson(spot: .flopDecision, error: .wrongSize)
t.equal(lesson?.id, "bet-size", "the bet-sizing verdict found no lesson to explain it")
t.expect(lesson?.worked != nil,
"the bet-size lesson lost the arithmetic, which is the part that answers why")
}
t.test("every habit with a lesson resolves to a lesson that teaches that habit") {
// Not to a PARTICULAR lesson: more than one can teach the same habit — "How much
// should I bet?" and "Why bet the same size with everything?" both belong to
// bet-sizing. The lookup picks one, and what has to hold is that the one it picks is
// actually about the habit asked for, not merely that some lesson came back.
for lesson in Basics.lessons {
guard let habit = lesson.practises else { continue }
let found = Basics.lesson(forLeak: habit)
t.expect(found != nil, "habit \(habit) has lessons but resolves to none")
t.equal(found?.practises, habit,
"asking for \(habit) returned a lesson about \(found?.practises ?? "nothing")")
}
}
t.test("a habit is reachable from the spot and mistake a verdict actually carries") {
// A lesson linked to a habit no verdict can produce is a link nobody ever follows.
// This walks in from the verdict end, which is the end a player arrives from.
for leak in LeakCatalogue.all {
guard Basics.lesson(forLeak: leak.id) != nil else { continue }
t.equal(Basics.lesson(spot: leak.spot, error: leak.error)?.practises, leak.id,
"\(leak.id) has a lesson that its own spot and mistake cannot reach")
}
}
t.test("a decision with no lesson behind it says nothing rather than guessing") {
// Most habits have no lesson written yet, and the review has to stay quiet about those
// rather than offering the nearest one. A wrong explanation is worse than none.
let unexplained = LeakCatalogue.all.filter {
Basics.lesson(spot: $0.spot, error: $0.error) == nil
}
t.greaterThan(unexplained.count, 0,
"if every habit has a lesson, this test has stopped measuring anything")
}
t.test("no two lessons share an identifier") {
t.equal(Set(Basics.lessons.map(\.id)).count, Basics.lessons.count)
}
// A rule of thumb without the arithmetic is the thing being complained about, not the fix:
// "bet two-thirds of the pot" is exactly as useless as saying nothing to somebody who has
// never seen it done. Every lesson that hands out a number has to show it working.
t.test("the lessons that give a number show the number working") {
let mustWork = ["bet-size", "the-price", "open-size", "stack-depth"]
for id in mustWork {
guard let lesson = Basics.lessons.first(where: { $0.id == id }) else {
t.expect(false, "the course has lost its \(id) lesson"); continue
}
guard let worked = lesson.worked else {
t.expect(false, "\(id) gives a rule of thumb and never works it through")
continue
}
t.expect(worked.contains(where: \.isNumber),
"\(id)'s worked example contains no numbers")
}
}
t.test("the cash course teaches the cash habits and drops the short stack") {
let ids = Basics.cashLessons.map(\.id)
t.expect(!ids.contains("stack-depth"), "nobody at a cash table is fifteen big blinds deep")
for id in ["money-behind", "one-pair-stack", "re-raised-pots"] {
t.expect(ids.contains(id), "the cash course is missing \(id)")
}
t.equal(Set(ids).count, ids.count, "two cash lessons share an identifier")
let cashKnown = Set(LeakCatalogue.cash.map(\.id))
for lesson in Basics.cashLessons {
guard let target = lesson.practises else { continue }
t.expect(cashKnown.contains(target),
"\(lesson.id) offers practice at \"\(target)\", which the cash coach cannot drill")
}
withCashProduct {
t.equal(Basics.lessons.map(\.id), ids, "the cash product should read the cash course")
t.equal(Basics.lesson(forLeak: "stacked-off-one-pair")?.id, "one-pair-stack")
}
}
// A lesson that works a number through and gets it wrong is worse than one that gives no
// number at all, and nothing read the arithmetic until the pot-odds lesson was found saying
// "one in five" about a price that is one in six and "half the time" about a pot-sized bet
// that needs one time in three — in all three courses, four lessons from one that had the
// same sum right. This reads every "pay X to win Y" and checks the fraction and the
// percentage printed after it against X / (X + Y).
t.test("every price a lesson works through is the price it says") {
let words: [String: Int] = ["two": 2, "three": 3, "four": 4, "five": 5, "six": 6,
"seven": 7, "eight": 8, "nine": 9, "ten": 10, "eleven": 11,
"twelve": 12]
var pricesChecked = 0
let courses = Basics.tournamentLessons + Basics.cashLessons + Basics.limitLessons
let corpus = courses.flatMap(\.allText) + Glossary.terms.map(\.definition)
for text in corpus {
let scanner = text as NSString
let pattern = #"(\d[\d,]*) to win (\d[\d,]*)"#
let regex = try! NSRegularExpression(pattern: pattern)
for match in regex.matches(in: text, range: NSRange(location: 0, length: scanner.length)) {
let pay = Int(scanner.substring(with: match.range(at: 1)).replacingOccurrences(of: ",", with: ""))!
let win = Int(scanner.substring(with: match.range(at: 2)).replacingOccurrences(of: ",", with: ""))!
let share = Double(pay) / Double(pay + win)
let tailStart = match.range.location + match.range.length
let tail = scanner.substring(from: tailStart).prefix(140).lowercased()
if let f = try! NSRegularExpression(pattern: #"(one|two) (?:time|times)? ?in (\w+)"#)
.firstMatch(in: tail, range: NSRange(location: 0, length: (tail as NSString).length)) {
let ns = tail as NSString
let numerator = ns.substring(with: f.range(at: 1)) == "one" ? 1 : 2
let denominatorWord = ns.substring(with: f.range(at: 2))
if let denominator = words[denominatorWord] ?? Int(denominatorWord) {
let said = Double(numerator) / Double(denominator)
t.close(said, share, 0.02,
"pay \(pay) to win \(win) is \(Int((share * 100).rounded()))%, not "
+ "\(numerator) in \(denominator): \(text.prefix(80))")
pricesChecked += 1
}
}
if let p = try! NSRegularExpression(pattern: #"about (\d+)%"#)
.firstMatch(in: tail, range: NSRange(location: 0, length: (tail as NSString).length)) {
let percent = Int((tail as NSString).substring(with: p.range(at: 1)))!
t.close(Double(percent) / 100, share, 0.015,
"pay \(pay) to win \(win) is \(Int((share * 100).rounded()))%, not \(percent)%")
pricesChecked += 1
}
}
}
t.greaterThan(pricesChecked, 5, "the courses work through at least six prices; "
+ "only \(pricesChecked) were found to check")
}
t.test("practising a concept leads to a drill that exists") {
let known = Set(LeakCatalogue.all.map(\.id))
for lesson in Basics.lessons {
guard let target = lesson.practises else { continue }
t.expect(known.contains(target),
"\(lesson.id) offers practice at \"\(target)\", which is not a habit the "
+ "app can drill — the button would open nothing")
}
}
// The point of the request was practice, so a course where most of it is reading has
// quietly not been built. This is the assertion that says so out loud.
t.test("most of the course can actually be practised") {
let practisable = Basics.lessons.filter { $0.practises != nil }
t.greaterThan(practisable.count * 2, Basics.lessons.count,
"only \(practisable.count) of \(Basics.lessons.count) lessons can be "
+ "practised, so this is a reading list rather than a course")
// And the one the whole thing was asked for has to be among them.
t.expect(Basics.lessons.first { $0.id == "bet-size" }?.practises != nil,
"the bet-size lesson cannot be practised, which is the one it was asked for")
}
// Ken, 2026-08-02: "How do I get to the drills? Is there a way to navigate to them?"
// There was not — every door was behind a habit the coach had confirmed, which takes
// hundreds of hands. This is the assertion that a person with an empty history can still
// reach one, and it is written against the catalogue rather than the screens so it keeps
// holding if the screens are rearranged.
t.test("somebody who has played nothing can still find a drill") {
let drillable = LeakCatalogue.all.filter(\.canBeNamed)
t.greaterThan(drillable.count, 8,
"only \(drillable.count) habits can be drilled, so a list of them is thin")
for leak in drillable {
t.expect(!leak.name.isEmpty, "\(leak.id) would show a blank row")
t.expect(!leak.drillHint.isEmpty,
"\(leak.id) says nothing about what it deals, so choosing it is a guess")
}
// The practice list is grouped by when in the hand the situation happens. A habit whose
// spot is in none of those groups would be built and then be invisible.
let grouped: Set<SpotKind> = [.openingChance, .facingRaise, .facingReraise,
.bigBlindDefence, .shortStackShove, .continuationBet,
.facingContinuationBet, .flopDecision, .turnDecision,
.riverDecision, .facingRiverBet]
for leak in drillable {
t.expect(grouped.contains(leak.spot),
"\(leak.id) happens at \(leak.spot), which no group on the practice list "
+ "covers — it would never appear")
}
}
// Ken, 2026-08-02: "Lets make the drills their own menu item. I dont want people to have
// to dig for it." It is a tab now rather than two taps inside Settings — which puts the
// bar at seven, and seven only fits because every short name is brief. This is the check
// that says so, since the failure is a truncated word nobody notices in a screenshot.
t.test("the screen you were last on comes back") {
for screen in Screen.allCases {
t.equal(Screen(rawValue: screen.rawValue), screen,
"\"\(screen.rawValue)\" does not round-trip through its own raw value")
}
// The table is the exception, on purpose: reopening onto a felt nobody is sitting at
// would claim a tournament is in progress when it is not.
t.equal(Screen(rawValue: "My game"), Screen.leaks)
t.equal(Screen(rawValue: "Drills"), Screen.practice)
}
t.test("every screen fits on a phone's tab bar") {
let names = Screen.allCases.map(\.shortName)
t.equal(Set(names).count, names.count, "two screens share a tab label")
t.lessThan(names.count, 8,
"\(names.count) tabs is more than a phone can split without truncating")
for name in names {
t.expect(!name.isEmpty, "a tab has no label")
t.lessThan(name.count, 8,
"\"\(name)\" is \(name.count) characters, which truncates on a phone "
+ "at the largest text size")
}
t.expect(names.contains("Drills"), "the drills lost their own place on the bar")
}
// Caught on a store screenshot reading "1 hands watched". The first hand of a first
// sitting is the most-seen state this app has and it was the one written ungrammatically.
t.test("one of something is never called ones") {
t.equal(Copy.hands(1), "1 hand")
t.equal(Copy.hands(0), "0 hands")
t.equal(Copy.hands(2), "2 hands")
t.equal(Copy.sittings(1), "1 sitting")
t.equal(Copy.chances(1), "1 chance")
t.equal(Copy.times(1), "once")
t.equal(Copy.times(3), "3 times")
// And nothing the player reads may say it the broken way. This walks the copy the
// screens actually render, which is the same list the jargon sweep uses.
// "271 hands saved" is fine: the 1 has to be the whole number, not the end of one.
for line in Copy.prose {
t.expect(line.range(of: #"(^|[^0-9])1 (hands|sittings|chances|times)\b"#,
options: .regularExpression) == nil,
"a screen says \"\(line)\"")
}
}
t.test("a drill a lesson points at deals that lesson's situation") {
// Each drill arranges a table aimed at one spot. A lesson about where you sit that
// borrowed a river drill would read well and teach the wrong thing.
let expected: [String: SpotKind] = [
"bet-size": .flopDecision,
"open-size": .openingChance,
"position": .openingChance,
"which-hands": .openingChance,
"raise-or-fold": .openingChance,
"stack-depth": .shortStackShove,
"flop-bet": .continuationBet,
]
for (lessonID, spot) in expected {
guard let lesson = Basics.lessons.first(where: { $0.id == lessonID }),
let target = lesson.practises,
let leak = LeakCatalogue.all.first(where: { $0.id == target }) else {
t.expect(false, "\(lessonID) no longer points anywhere"); continue
}
t.equal(leak.spot, spot,
"\(lessonID) practises \(target), which deals \(leak.spot) rather than \(spot)")
}
}
}
}