← poker-tutor
import Foundation
import PokerCore
import PokerAI
import PokerCoach
import PokerSession
import PokerUI
import TestKit
/// One ordinary hand, with a mistake on it where a cost is given. Enough to build a `GameAnalysis`
/// off, which is all the copy tests below need.
private func analysisHand(number: Int, cost: Double) -> HandRecord {
HandRecord(sessionID: "s1",
sessionStarted: Date(timeIntervalSince1970: 1_780_000_000),
playedAt: Date(timeIntervalSince1970: 1_780_000_000 + Double(number) * 60),
handNumber: number,
mode: "Tournament",
bigBlind: 100,
position: "BTN",
holeCards: "Ah Kd",
board: "2c 7h Js",
netChips: -100,
wentToShowdown: false,
sawFlop: true,
putMoneyInPreflop: true,
raisedPreflop: false,
verdicts: cost > 0
? [VerdictRecord(street: "flop", spot: SpotKind.facingContinuationBet.name,
leak: ErrorKind.foldedTooMuch.name, severity: 3, costInBB: cost,
headline: "h", detail: "d", taken: "fold", best: "call",
spoke: true, confidence: 0.9)]
: [])
}
/// One decision, written down the way the file writes it. `severity` and `spoke` are what separate
/// a decision the coach stopped the game over from one it counted in silence.
private func copyVerdict(severity: Int, spoke: Bool) -> VerdictRecord {
VerdictRecord(street: "preflop", spot: SpotKind.openingChance.name,
leak: ErrorKind.limpedInstead.name, severity: severity, costInBB: 0.35,
headline: "h", detail: "d", taken: "call", best: "raise",
spoke: spoke, confidence: 0.9)
}
/// The glossary is the one place the app is allowed to use a poker word, so it is the place that
/// has to be checked: every word the screens put in front of somebody has to resolve to something,
/// no definition may be empty, and no definition may lean on a word the player would have to look
/// up somewhere else. The last one is the whole point — a definition that needs a definition has
/// taught nobody anything.
public func glossaryTests(_ t: TestRun) {
/// Either the whole label is an entry, or the label contains at least one word that can be
/// tapped. Both count as explained; a label that is neither is jargon with nothing behind it.
func explained(_ label: String) -> Bool {
Glossary.look(up: label) != nil || !Glossary.mentions(in: label).isEmpty
}
t.suite("poker words") { t in
t.test("the list is well formed") {
t.expect(Glossary.terms.count >= 60, "only \(Glossary.terms.count) words defined")
t.equal(Set(Glossary.terms.map(\.id)).count, Glossary.terms.count, "two entries share a headword")
t.equal(Glossary.alphabetical.map(\.id), Glossary.terms.map(\.id).sorted())
for term in Glossary.terms {
t.expect(!term.definition.isEmpty, "\(term.term) has no definition")
t.expect(!term.term.isEmpty)
}
}
t.test("a definition is one or two short sentences") {
for term in Glossary.terms {
let sentences = term.definition.split(whereSeparator: { $0 == "." || $0 == "?" })
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
t.expect(sentences.count <= 2, "\(term.term) runs to \(sentences.count) sentences")
t.expect(term.definition.count <= 260, "\(term.term) is \(term.definition.count) characters")
t.expect(term.definition.hasSuffix("."), "\(term.term) does not finish its sentence")
}
}
t.test("no definition explains a word with itself") {
for term in Glossary.terms {
let used = Glossary.mentions(in: term.definition).map(\.term.id)
t.expect(!used.contains(term.id),
"the definition of \(term.term) uses \(term.term)")
}
}
t.test("no definition uses jargon that is not itself defined") {
for term in Glossary.terms {
let jargon = Glossary.unexplained(in: term.definition)
t.expect(jargon.isEmpty, "\(term.term) leans on \(jargon.joined(separator: ", "))")
}
}
t.test("the words that keep coming back are watched for") {
// Each of these got through a sweep that was looking for exactly it. "late-position"
// survived the run that took "range" and "shove" out of the drills, because the
// watchlist matches whole phrases and "in position" does not match inside it; "wide"
// and "deep" survived because they read as ordinary English while meaning "with too
// many hands" and "with a lot of chips". Watching for them is what stops the next one.
for phrase in ["late-position raise", "big blinds deep", "calling raises too wide",
"from early position"] {
t.expect(!Glossary.unexplained(in: phrase).isEmpty,
"\"\(phrase)\" would go in front of a player unchallenged")
}
// And it still leaves ordinary writing alone.
for phrase in ["a wider choice of hands", "the late seats, 8 and 9",
"he deepened the pot odds"] {
t.equal(Glossary.unexplained(in: phrase), [],
"\"\(phrase)\" is not jargon and was flagged anyway")
}
}
t.test("the banned words are not defined back in") {
// Defining "fold equity" would make it legal to write it. The words the app exists to
// do without stay out of the glossary as well as out of the copy.
for word in ["range", "ranges", "shove", "shoving", "in position", "fold equity",
"orbit", "initiative", "polarised", "heads-up", "equity"] {
t.equal(Glossary.look(up: word), nil, "\(word) has an entry, which permits its use")
}
}
}
t.suite("poker words on the screens") { t in
t.test("every word a screen labels by name resolves") {
for key in Glossary.screenTerms {
t.expect(Glossary.look(up: key) != nil,
"the screens show \(key) as a tappable word and there is no entry for it")
}
}
t.test("every seat name the player is shown resolves") {
for seat in Position.allCases {
t.expect(explained(seat.fullName), "no entry for \(seat.fullName)")
}
}
t.test("every round of betting resolves") {
for street in Street.allCases {
t.expect(explained(street.name), "no entry for \(street.name)")
}
}
t.test("every hand and draw the felt names resolves") {
for made in MadeCategory.allCases {
t.expect(explained(made.name), "no entry for \(made.name)")
}
for draw: DrawKind in [.none, .backdoor, .gutshot, .overcards, .openEnded, .flushDraw, .combo] {
t.expect(explained(draw.name), "no entry for \(draw.name)")
}
}
t.test("every playing style on the ladder resolves") {
for style in Personality.allCases {
t.expect(explained(style.name), "no entry for \(style.name)")
}
}
t.test("the coach's own copy stays clear of jargon with nothing behind it") {
// This is the standing rule, enforced rather than remembered. It has already caught
// "range", "shove" and "heads-up" sitting in the drills.
var copy: [String] = []
for leak in LeakCatalogue.tournament + LeakCatalogue.cashOnly + LeakCatalogue.limitOnly {
copy += [leak.name, leak.explanation, leak.drillHint]
}
copy += AssessmentPlanner.cashSetups.map(\.watching)
copy += LadderTier.cashRungs.flatMap { [$0.name, $0.blurb, $0.castLine] }
copy += Basics.cashLessons.flatMap(\.allText)
withCashProduct {
let standing = LadderStanding(of: [])
copy += [standing.placeLine, standing.resultsLine, standing.requirementLine]
copy += [standing.demotionLine].compactMap { $0 }
}
// The assessment writes more prose than anything else in the app, and it writes it
// about statistics, which is exactly where jargon creeps back in.
let sitting = AssessmentReport(of: [])
copy += [sitting.headline, sitting.ratingLine, sitting.precisionNote,
sitting.steeringNote, sitting.fairnessNote, sitting.quietNote]
copy += sitting.areas.map(\.line)
copy += AssessmentPlanner.setups.map(\.watching)
copy += AssessmentPlanner.setups.map { $0.introduction(atTableOf: 9) }
copy += AssessmentPlanner.setups.map { $0.summary(atTableOf: 9) }
copy += TableLean.allCases.map(\.phrase)
// The ladder describes rooms full of people, which is the other place a poker word
// creeps in — every style at the table has a name and half of them are jargon.
copy += LadderTier.all.map(\.name)
copy += LadderTier.all.map(\.blurb)
copy += LadderTier.all.map(\.castLine)
for tier in LadderTier.all {
let standing = LadderStanding(of: [])
copy += [standing.placeLine, standing.resultsLine, standing.requirementLine,
standing.meaningNote, standing.scopeNote, tier.name]
copy += [standing.demotionLine].compactMap { $0 }
}
// The drills write a paragraph about one habit at a time and then a diary entry about
// it days later, in both of which a poker word would be the natural thing to reach for.
copy += drillCopy()
copy += Personality.allCases.map { $0.counted(3) }
copy += SpotKind.allCases.map(\.name)
copy += ErrorKind.allCases.map(\.name)
copy += Severity.allCases.map(\.name)
copy += MadeCategory.allCases.map(\.name)
copy += Personality.allCases.map(\.name)
for count in [2, 6, 9] {
copy += Position.allCases.map { $0.actOrderNote(inTableOf: count) }
copy += Position.allCases.map { $0.described(inTableOf: count) }
}
t.greaterThan(copy.count, 100,
"only \(copy.count) lines of the coach's copy were gathered, which is "
+ "too few to be checking anything")
for line in copy {
let jargon = Glossary.unexplained(in: line)
t.expect(jargon.isEmpty, "\"\(line)\" uses \(jargon.joined(separator: ", "))")
}
}
t.test("the screens' own writing stays clear of jargon with nothing behind it") {
// The hole this closes. Everything above walks the model layer — the leak catalogue,
// the assessment, the ladder, the drills — and every word of it passed while "in this
// range" sat in a `Text(...)` on the My game screen, put there by the very commit that
// took the word out of everything a test could see. Most of the app's writing is on the
// screens; until `Copy` existed, none of it was readable by anything but a person.
t.greaterThan(Copy.prose.count, 120,
"only \(Copy.prose.count) sentences came off the screens, so either the "
+ "list stopped being filled in or most of the writing has slipped back "
+ "into the views")
// Settings describes its own choices, and those two lists were already readable from
// outside a screen, so they are swept here rather than copied into `Copy`.
var screens = Copy.prose
screens += Palette.allCases.flatMap { [$0.name, $0.detail] }
screens += TextScale.allCases.flatMap { [$0.name, $0.detail] }
for line in screens {
t.expect(!line.isEmpty, "an empty line on the list is one nobody is checking")
let jargon = Glossary.unexplained(in: line)
t.expect(jargon.isEmpty, "a screen says \"\(line)\" — \(jargon.joined(separator: ", "))")
}
}
t.test("what the coach says about a hand is read by the same two sweeps") {
// The hole both sweeps above had, and it was the worst one to have: everything they
// walk is the model layer's writing — the leak catalogue, the assessment, the ladder,
// the drills, and now the screens — while the coach's verdict on a hand, which the
// README calls the reason the app exists, was written in `Explain` and in the `reason`
// strings in `DecisionEvaluator` and read by nothing. A banned construction lived
// there, in print, through the whole commit whose purpose was removing them.
//
// It cannot be swept statically: every one of those sentences is built by interpolating
// cards, seats, percentages and a reason into a template. So it is swept by playing
// poker — a few planted players, whose flaws between them reach every branch — and
// reading what the coach actually said.
var spoken: [String] = []
var kinds: Set<String> = []
for player in [PlantedPlayer.foldsBigBlind, .limpsEverything, .playsEverything,
.neverValueBetsRiver, .foldsToEveryFlopBet, .control] {
let coach = runPlanted(player, hands: 150, seed: 808)
for review in coach.reviews {
for verdict in review.verdicts where !verdict.headline.isEmpty {
spoken += [verdict.headline, verdict.detail]
kinds.insert(verdict.error?.name ?? "")
}
}
}
t.greaterThan(kinds.count, 5,
"only \(kinds.count) kinds of mistake were explained, so most of the "
+ "coach's writing was never reached: \(kinds.sorted())")
t.greaterThan(spoken.count, 200,
"only \(spoken.count) lines came back from the coach, which is not a sweep")
for line in Set(spoken) {
let broken = BannedWriting.offences(in: line)
t.expect(broken.isEmpty, "the coach says \"\(line)\" — \(broken.joined(separator: ", "))")
let jargon = Glossary.unexplained(in: line)
t.expect(jargon.isEmpty,
"the coach says \"\(line)\" — \(jargon.joined(separator: ", "))")
// "An open" is the trade's noun for a raise nobody raised in front of, and it lived
// in two of these sentences. It cannot go on the glossary watchlist: "an open-ended
// straight draw" is a real entry the coach also says, and a whole-phrase matcher
// cannot tell the two apart. The trailing space can — a hyphen is not a space.
t.expect(!line.lowercased().contains("an open "),
"the coach says \"\(line)\" — \"an open\" is a raise in plain English")
}
}
t.test("the phrases the owner has banned outright stay out of the writing") {
// These are turns of phrase rather than vocabulary: writing habits ruled out of
// everything this app puts on a page. Checked over the screens, the
// coach, the leak catalogue and the ladder in one pass, because a ban held in one file
// and forgotten in the next is not a ban.
var everything = Copy.prose
everything += (LeakCatalogue.tournament + LeakCatalogue.cashOnly + LeakCatalogue.limitOnly).flatMap { [$0.name, $0.explanation, $0.drillHint] }
everything += (LadderTier.tournamentRungs + LadderTier.cashRungs).flatMap { [$0.name, $0.blurb, $0.castLine] }
everything += (AssessmentPlanner.tournamentSetups + AssessmentPlanner.cashSetups).map(\.watching)
everything += Basics.cashLessons.flatMap(\.allText)
everything += drillCopy()
everything += Glossary.terms.map(\.definition)
everything += Palette.allCases.map(\.detail) + TextScale.allCases.map(\.detail)
t.greaterThan(everything.count, 200, "not enough writing was gathered to be a sweep")
for line in everything {
let broken = BannedWriting.offences(in: line)
t.expect(broken.isEmpty,
"\"\(line)\" breaks \(broken.joined(separator: ", "))")
}
}
t.test("the same ban holds in the code's own comments") {
// The rule covers everything the owner writes, comments included. This reads the
// sources off disk rather than guessing, so it only means anything when the suite is
// run from a build made in the repository — which is how it is always run. If the
// checkout has moved, the test says so and fails rather than passing on an empty sweep.
let files = BannedWriting.swiftFiles()
t.greaterThan(files.count, 30,
"found \(files.count) source files to read; the sources are not where "
+ "this build says they are, so nothing was checked")
var commentLines = 0
for file in files {
guard let text = try? String(contentsOf: file, encoding: .utf8) else {
t.expect(false, "could not read \(file.lastPathComponent)")
continue
}
for (number, line) in text.split(separator: "\n", omittingEmptySubsequences: false).enumerated() {
guard let comment = BannedWriting.comment(in: String(line)) else { continue }
commentLines += 1
let broken = BannedWriting.offences(in: comment)
t.expect(broken.isEmpty,
"\(file.lastPathComponent):\(number + 1) breaks "
+ "\(broken.joined(separator: ", ")): \"\(comment)\"")
}
}
t.greaterThan(commentLines, 500,
"only \(commentLines) comment lines were read, which is not this codebase")
}
}
t.suite("marking up a paragraph") { t in
t.test("looking a word up forgives how it was written") {
t.equal(Glossary.look(up: "The Button")?.id, "button")
t.equal(Glossary.look(up: "button")?.id, "button")
t.equal(Glossary.look(up: " BTN ")?.id, "button")
t.equal(Glossary.look(up: "the dealer")?.id, "button")
t.equal(Glossary.look(up: "WTSD")?.id, "went to showdown")
t.equal(Glossary.look(up: "a set")?.id, "set")
t.equal(Glossary.look(up: "somethingelse"), nil)
}
t.test("only the first appearance of a word is marked") {
let text = "Fold the big blind, defend the big blind, think about the big blind."
let marks = Glossary.mentions(in: text).filter { $0.term.id == "big blind" }
t.equal(marks.count, 1, "a paragraph with every occurrence underlined is unreadable")
t.equal(text.distance(from: text.startIndex, to: marks[0].range.lowerBound), 9,
"the first one is the one that carries the definition")
}
t.test("the longest word wins where two start together") {
let marks = Glossary.mentions(in: "The big blind is already in.")
t.equal(marks.map(\.term.id), ["big blind"], "\"blind\" should not win inside \"big blind\"")
}
t.test("whole words only") {
t.equal(Glossary.mentions(in: "Nothing definite about that.").count, 0,
"\"nit\" lit up inside another word")
t.equal(Glossary.mentions(in: "He is a nit.").map(\.term.id), ["nit"])
t.equal(Glossary.mentions(in: "Antelopes are not antes.").map(\.term.id), ["ante"])
}
t.test("ordinary English is left alone") {
// "Set", "turn" and "pot" are in the glossary and are deliberately not marked up in
// prose: underlining them would mark sentences that were never about poker.
t.equal(Glossary.mentions(in: "Set the pot to one side and turn it over.").count, 0)
t.expect(Glossary.look(up: "set") != nil, "it should still be there to browse")
}
t.test("marking up never changes a word of the writing") {
// The screens rebuild the paragraph from these ranges. If they can overlap or run out
// of order, the player reads a sentence with a piece missing or repeated.
for text in LeakCatalogue.all.map(\.explanation) + LeakCatalogue.all.map(\.drillHint) {
var rebuilt = ""
var cursor = text.startIndex
for mention in Glossary.mentions(in: text) {
t.expect(mention.range.lowerBound >= cursor, "marks overlap in \"\(text)\"")
rebuilt += text[cursor..<mention.range.lowerBound]
rebuilt += text[mention.range]
cursor = mention.range.upperBound
}
rebuilt += text[cursor...]
t.equal(rebuilt, text)
}
}
t.test("the coach's write-ups offer a definition where there is one to offer") {
// Most of this copy is already plain English and marks up nothing at all, which is the
// right answer — the glossary is for the words that could not be avoided, not an excuse
// to put them back. So this checks the markup fires where a real poker word is used and
// stays out of the way everywhere else.
func marks(_ id: String) -> [String] {
guard let leak = LeakCatalogue.all.first(where: { $0.id == id }) else { return [] }
return Glossary.mentions(in: leak.explanation).map(\.term.id)
}
t.expect(marks("no-shove").contains("big blind"), "\"big blinds\" should be tappable")
t.expect(marks("no-shove").contains("stack"))
t.expect(marks("overbluffing").contains("bluff"))
t.equal(marks("station-flop"), [], "plain English should be left alone")
let withWords = LeakCatalogue.all.filter { !Glossary.mentions(in: $0.explanation).isEmpty }
t.expect(withWords.count >= 5,
"only \(withWords.count) of \(LeakCatalogue.all.count) leak write-ups offered a definition")
}
t.test("a definition that leans on another entry can be followed") {
// The definitions lean on each other — "trips" is explained against "a set", the
// continuation bet against "the flop" — and until the cards marked up their own text a
// tap in the one place somebody came to look a word up dead-ended there. This asserts
// there is really something to follow, which is what the screens now render.
let leaning = Glossary.terms.filter { !Glossary.mentions(in: $0.definition).isEmpty }
t.greaterThan(leaning.count, 20,
"only \(leaning.count) definitions mention another entry, so either the "
+ "markup stopped working or the definitions stopped leaning")
// One hop, and the hop lands somewhere real.
for term in leaning {
for mention in Glossary.mentions(in: term.definition) {
t.expect(Glossary.term(forLink: mention.term.link) != nil,
"\(term.term) offers \(mention.term.term) and the tap goes nowhere")
}
}
}
t.test("a tap can never leave the app") {
for term in Glossary.terms {
t.equal(term.link.scheme, "pokerword", "\(term.term) links out to \(term.link)")
t.equal(Glossary.term(forLink: term.link)?.id, term.id)
}
t.equal(Glossary.term(forLink: URL(string: "https://example.com")!), nil)
}
}
t.suite("what the screens promise") { t in
// Sentences whose job is to describe what a button does or what a number is. A sweep can
// only say they use no jargon; these say they are true.
t.test("start over says everything it takes away") {
// The ladder standing, the drill diary and how far through an assessment you got are
// all counted back off the hands. There is no second copy of any of them, which is
// what makes them survive the app being closed and what makes clearing the hands clear
// them. The warning used to say "resets your rating" and stop, which described about a
// third of it.
for line in [Copy.resetWarning, Copy.handsAcrossSittings(hands: 900, sittings: 14)] {
t.expect(line.contains("ladder"),
"it does not say the ladder goes back to the bottom: \"\(line)\"")
t.expect(line.lowercased().contains("check"),
"it does not say the waiting drill checks are cancelled: \"\(line)\"")
t.expect(line.contains("counted back off the hands"),
"it does not say why either of those happens: \"\(line)\"")
}
}
t.test("the My game opening does not answer the question the scope bar asks") {
// It sits one line above a control that can be set to a single sitting, and it used to
// end "every hand you have ever played is counted here" — which is false whenever that
// control is set to anything else, and was contradicted a few lines further down by
// the subtitle that had it right.
t.expect(!Copy.myGameIntro.contains("ever played is counted"),
"the opening is claiming to count hands the scope bar decides: "
+ "\"\(Copy.myGameIntro)\"")
t.expect(Copy.myGameIntro.contains("kept"),
"it should still say the hands are kept: \"\(Copy.myGameIntro)\"")
t.expect(Copy.costingYouSubtitle.contains("whichever sitting you are looking at"),
"the sentence that answers what is counted has changed: "
+ "\"\(Copy.costingYouSubtitle)\"")
}
t.test("a bar that was never drawn is not a bar that came up clean") {
// "Everything measured" lists every habit in the catalogue, most of which have never
// come up. Those rows draw no bar at all now, and the note under them has to say what
// that means — before, they drew the same short green bar as a habit measured and
// going well, directly above a sentence saying a short bar means it is working.
t.expect(Copy.barMarkNote.contains("no bar at all"),
"nothing explains an empty row: \"\(Copy.barMarkNote)\"")
t.expect(Copy.barMarkNote.contains("has not come up yet"),
"it does not say why the row is empty: \"\(Copy.barMarkNote)\"")
t.expect(Copy.prose.contains(Copy.barMarkNote),
"a line the sweeps cannot see is a line nobody is checking")
}
t.test("the by-seat line is true at every table size, heads-up included") {
// It used to open "Seat 1 puts in the small blind", which is right at three or more and
// wrong two-handed — where the dealer posts the small blind and the other seat is
// numbered 1. Every tournament finishes two-handed, so the table under this sentence
// produces those rows on the way past.
t.expect(!Copy.bySeatSubtitle.contains("Seat 1"),
"the sentence is back to naming seat 1: \"\(Copy.bySeatSubtitle)\"")
t.expect(Copy.bySeatSubtitle.contains("highest number is the dealer"))
// And the claim it does make holds everywhere.
for players in 2...9 {
let numbers = Position.allCases.compactMap { $0.number(inTableOf: players) }
t.equal(numbers.max(), players, "seats at a table of \(players)")
t.equal(Position.button.number(inTableOf: players), players,
"the dealer should be the highest number at a table of \(players)")
}
}
t.test("no hands is not a result of nought") {
// Nought big blinds per hundred is something somebody earned; no hands at all is not.
let empty = GameAnalysis.empty
t.equal(empty.hands, 0)
t.equal(empty.netChipsText, "—")
t.equal(empty.bbPer100Text, "—")
t.equal(empty.costPer100Text, "—")
// The caveat that explains the dashes used to be written and then rendered inside a
// branch that only ran once there were hands, so it could never appear beside them.
t.equal(Copy.statisticsFootnote(hands: 0, enough: false), Copy.statisticsEmpty)
t.expect(Copy.statisticsEmpty.contains("dashes"),
"the empty line has to explain the dashes it sits under")
// These two were equality checks until the footnote started carrying a second sentence
// about which decisions the money above counts. The branch is still what is being
// checked; it is the opening of the line rather than the whole of it now.
t.expect(Copy.statisticsFootnote(hands: 60, enough: false)
.hasPrefix(Copy.statisticsThin(60)))
t.expect(Copy.statisticsFootnote(hands: 900, enough: true)
.hasPrefix(Copy.statisticsSettled))
}
t.test("forty arranged hands are not \"nothing has been played\"") {
// The first thing anybody does on a fresh install is the forty-hand assessment, and
// every number in this panel deliberately leaves arranged hands out. So it counted
// nought hands and said "nothing has been played in what you are looking at" to
// somebody who had just played forty, three inches under a list of named habits.
let arranged = Copy.statisticsFootnote(hands: 0, arranged: 40, enough: false)
t.expect(arranged != Copy.statisticsEmpty,
"an assessment-only history was told nothing had been played")
t.expect(arranged.contains("40"), "it should say how many were arranged: \(arranged)")
t.expect(arranged.contains("arranged"))
// And the genuinely empty case still says the empty thing.
t.equal(Copy.statisticsFootnote(hands: 0, arranged: 0, enough: false),
Copy.statisticsEmpty)
for line in [arranged, Copy.statisticsAllArranged(1)] {
t.expect(Glossary.unexplained(in: line).isEmpty, "\(line)")
t.expect(BannedWriting.offences(in: line).isEmpty, "\(line)")
}
}
t.test("the two money-per-100 figures on My game say which is which") {
// One tile counts the decisions the coach spoke about; the habits at the top of the
// same screen also count the cheap ones it stays quiet on, so their total is always
// larger and the gap grows with exactly the habits the scorecard exists to catch.
let footnote = Copy.statisticsFootnote(hands: 900, enough: true)
t.expect(footnote.contains(Copy.flaggedCostNote),
"nothing on the screen says the two totals count different decisions")
t.expect(Copy.prose.contains(Copy.flaggedCostNote),
"a line the sweeps cannot see is a line nobody is checking")
}
t.test("a cost per 100 hands is not printed off three hands") {
// Three hands and one two-big-blind mistake is 66.7 per hundred, which is arithmetic
// rather than a measurement. Home prints this one with nothing under it to hedge it.
let thin = GameAnalysis(of: (1...3).map { number in
analysisHand(number: number, cost: number == 1 ? 2.0 : 0)
})
t.equal(thin.hands, 3)
t.expect(thin.costPer100 > 60, "the raw number is what it is: \(thin.costPer100)")
t.equal(thin.costPer100Firm, "—", "66.7 bb went out in the same type as a real result")
t.expect(Copy.sittingLine(hands: 3, flagged: 1).contains("Too few hands"),
"the dash was left unexplained: \(Copy.sittingLine(hands: 3, flagged: 1))")
// Past the floor it is printed, and the panel stops apologising for it.
let enough = GameAnalysis(of: (1...30).map { number in
analysisHand(number: number, cost: number == 1 ? 2.0 : 0)
})
t.equal(enough.costPer100Firm, enough.costPer100Text)
t.expect(!Copy.sittingLine(hands: 30, flagged: 1).contains("Too few hands"))
}
t.test("a hand the scorecard counted is never called clean") {
// The two bars differ on purpose: the coach stops the game only for a mistake worth
// stopping it over, while the scorecard counts a third of a big blind made forty times
// an hour. That gap is where the app used to contradict itself. For a player whose
// named habit was one of the cheap ones — limping, playing too many hands — every
// screen they opened to check the coach's work said there was nothing there.
let quiet = analysisHand(number: 1, cost: 0)
let counted = HandRecord(sessionID: quiet.sessionID,
sessionStarted: quiet.sessionStarted,
playedAt: quiet.playedAt,
handNumber: 1,
mode: quiet.mode,
bigBlind: quiet.bigBlind,
position: quiet.position,
holeCards: quiet.holeCards,
board: quiet.board,
netChips: quiet.netChips,
wentToShowdown: false,
sawFlop: true,
putMoneyInPreflop: true,
raisedPreflop: false,
verdicts: [copyVerdict(severity: Severity.slight.rawValue,
spoke: false)])
t.equal(counted.flagged.count, 0, "the coach was right to stay quiet about this one")
t.expect(counted.playedWell, "`playedWell` still means only that the coach kept quiet")
t.equal(counted.counted.count, 1, "the scorecard counts this decision towards a habit")
t.expect(!counted.nothingCounted)
// The hand-level wording asks the second question rather than the first.
t.expect(Copy.handBadge(flagged: 0, counted: 1) != "clean",
"the list called a counted hand clean: \(Copy.handBadge(flagged: 0, counted: 1))")
t.equal(Copy.handBadge(flagged: 0, counted: 0), "clean")
guard let summary = Copy.handSummary(flagged: 0, counted: 1) else {
return t.expect(false, "a counted hand was given no line at all")
}
t.expect(summary != Copy.nothingToFlagShort,
"the detail claimed nothing was there: \(summary)")
t.expect(summary.contains("counted"), summary)
t.equal(Copy.handSummary(flagged: 0, counted: 0), Copy.nothingToFlagShort)
t.expect(Copy.handSummary(flagged: 2, counted: 2) == nil,
"the write-ups say it better than a summary would")
// And the three screens ask it through one rule rather than three.
t.equal(Copy.decisionNote(spoke: false, countsTowardsLeak: true), .counted)
t.equal(Copy.decisionNote(spoke: false, countsTowardsLeak: false), .tooClose)
t.equal(Copy.decisionNote(spoke: true, countsTowardsLeak: true), .flagged)
// A decision under the counting bar is still one nobody should be stopped over, and
// the replay is still allowed to say so.
let tooClose = copyVerdict(severity: Severity.fine.rawValue, spoke: false)
t.expect(!tooClose.countsTowardsLeak)
t.equal(Copy.decisionNote(spoke: tooClose.spoke,
countsTowardsLeak: tooClose.countsTowardsLeak), .tooClose)
for line in [summary, Copy.countedTowards("Limping in instead of raising"),
Copy.countedTowardsUnnamed, Copy.handBadge(flagged: 0, counted: 3)] {
t.expect(Glossary.unexplained(in: line).isEmpty, "\(line)")
t.expect(BannedWriting.offences(in: line).isEmpty, "\(line)")
}
}
t.test("a check that held up says when the next one is") {
t.expect(Copy.checkHeldBlurb.contains("doubles"),
"the gap has just doubled and the player has no other way of knowing it")
t.expect(Copy.prose.contains(Copy.checkHeldBlurb),
"a line the sweeps cannot see is a line nobody is checking")
}
}
}