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")
        }
    }
}
