import Foundation
import TestKit
import PokerCore
import PokerUI
import PokerSession

/// The text-size setting, tested where it actually lives: `TextScale` does the multiplying and the
/// remembering, and `Theme` is a one-line pass-through to it that the screens read as they draw.
/// Nothing here goes near SwiftUI — the point is that the arithmetic and the restart both hold.
public func textSizeTests(_ t: TestRun) {
    t.suite("text size") { t in

        t.test("normal is the size everything was drawn at before") {
            t.equal(TextScale.normal.multiplier, 1)
            t.equal(TextScale.normal.size(13), 13)
            t.equal(TextScale.normal.size(20), 20)
        }

        t.test("each step is bigger than the one before it") {
            let sizes = TextScale.allCases.map { $0.size(13) }
            t.equal(sizes.count, 4, "four steps, named for humans")
            for (smaller, larger) in zip(sizes, sizes.dropFirst()) {
                t.expect(larger > smaller, "\(larger) should be larger than \(smaller)")
            }
            t.equal(TextScale.allCases.map(\.name),
                    ["Small", "Normal", "Large", "Extra large"])
        }

        t.test("the coach's write-ups grow by something worth having") {
            // The whole point of the setting: a paragraph of feedback set at 13 has to come out
            // noticeably larger, not a point larger.
            t.expect(TextScale.extraLarge.size(13) >= 17,
                     "extra large left the coach's text at \(TextScale.extraLarge.size(13))")
            t.expect(TextScale.small.size(13) < 13)
        }

        t.test("the walk-through is bigger than ordinary text at every setting") {
            // It is the one screen somebody reads BEFORE they could have found the text-size
            // setting, so it cannot lean on that setting existing. Ken read it on a phone for the
            // first time on 2026-08-22 and it was too small; if anybody ever "tidies" the intro
            // back down to the ordinary body size, this fails rather than shipping.
            for scale in TextScale.allCases {
                let ordinary = scale.size(14)
                let intro = scale.size(TextScale.introBase(14))
                t.expect(intro > ordinary,
                         "at \(scale.name) the intro is \(intro) against ordinary text at \(ordinary)")
            }
        }

        t.test("the intro grows with the setting as well as starting bigger") {
            // Starting bigger is not a substitute for the setting — somebody who turns the type up
            // has to see the intro grow too, which is exactly what a hard-coded size would break.
            let sizes = TextScale.allCases.map { $0.size(TextScale.introBase(14)) }
            for (smaller, larger) in zip(sizes, sizes.dropFirst()) {
                t.expect(larger > smaller, "\(larger) should be larger than \(smaller)")
            }
        }

        t.test("text with a fixed-size box around it stops growing at large") {
            // Seat names, chip captions and the buttons under the felt. They still grow — just not
            // past Large, because past Large the table stops fitting on the screen.
            t.equal(TextScale.extraLarge.fittedSize(12), TextScale.large.fittedSize(12))
            t.expect(TextScale.extraLarge.fittedSize(12) > TextScale.normal.fittedSize(12))
            t.expect(TextScale.extraLarge.fittedSize(12) < TextScale.extraLarge.size(12),
                     "the cap did nothing")
            // Below the cap the two agree, so capping never shrinks anything.
            for scale in [TextScale.small, .normal, .large] {
                t.equal(scale.fittedSize(11), scale.size(11))
            }
        }

        // Ken, 2026-08-02: "the text on the table screen should track settings". It used to stop at
        // Large, because the seat boxes were a fixed width and the writing spilled out of them past
        // that. The boxes and the ring they sit on now carry the setting too, so the words can grow
        // the whole way — and this is the arithmetic that says the felt still fits when they do.
        t.test("the felt still holds nine seats at every text size") {
            // Real sizes: the smallest iPhone still supported, a current one, the smallest the Mac
            // window can be dragged to (PokerTutorApp sets minWidth 900, minHeight 640), and the
            // size it opens at.
            let screens = [CGSize(width: 375, height: 667), CGSize(width: 402, height: 874),
                           CGSize(width: 430, height: 932),
                           CGSize(width: 900, height: 640), CGSize(width: 1240, height: 820)]
            for scale in TextScale.allCases {
                autoreleasepool {
                    for screen in screens {
                        let metrics = Metrics(width: screen.width, height: screen.height,
                                              textScale: scale)
                        let ring = FeltRing(size: metrics.feltArea,
                                            seatWidth: metrics.seatWidth,
                                            seatHeight: metrics.ringSeatHeight,
                                            compact: metrics.compact)
                        for count in 2...9 {
                            let points = ring.points(of: count)
                            t.equal(points.count, count)
                            // The first point is the player's, whose cards and name sit in a row
                            // under the felt rather than on the ring — so the boxes to check are
                            // the opponents'. Neighbours are the tightest pair on an ellipse, but
                            // check them all: a squat window pinches the top and bottom rather
                            // than the sides.
                            for (index, first) in points.enumerated().dropFirst() {
                                for second in points[(index + 1)...] {
                                    // Seat boxes are rectangles, so two of them clear each other
                                    // when they are a full box apart on EITHER axis — seats stacked
                                    // up the side of the felt need height between them, not width.
                                    let dx = abs(first.x - second.x), dy = abs(first.y - second.y)
                                    t.expect(dx + 0.5 >= ring.seatWidth || dy + 0.5 >= ring.seatHeight,
                                             "\(scale.name), \(count) handed at \(Int(screen.width))×\(Int(screen.height)): "
                                             + "two seats overlap — \(Int(dx)) apart across and \(Int(dy)) down, "
                                             + "and a box is \(Int(ring.seatWidth))×\(Int(ring.seatHeight))")
                                }
                                // And nothing may sit off the edge of the window it is drawn in.
                                t.expect(first.x - ring.seatWidth / 2 >= -0.5
                                         && first.x + ring.seatWidth / 2 <= ring.size.width + 0.5,
                                         "\(scale.name), \(count) handed: a seat runs off the side")
                                t.expect(first.y - ring.seatHeight / 2 >= -0.5
                                         && first.y + ring.seatHeight / 2 <= ring.size.height + 0.5,
                                         "\(scale.name), \(count) handed: a seat runs off the top or bottom")
                            }
                        }
                    }
                }
            }
        }

        t.test("the table's words grow the whole way, like the rest of the app") {
            // The seat boxes and the ring are measured off the same setting the writing is, so a
            // capped table would show up here as a box that stopped growing before the type did.
            let large = Metrics(width: 1240, height: 820, textScale: .large)
            let extra = Metrics(width: 1240, height: 820, textScale: .extraLarge)
            t.expect(extra.seatWidth > large.seatWidth,
                     "a seat box stopped growing at Large: \(extra.seatWidth) vs \(large.seatWidth)")
            t.expect(extra.ringSeatHeight > large.ringSeatHeight,
                     "a seat box stopped growing taller at Large")
            t.expect(extra.titleSize > large.titleSize, "a screen title stopped growing at Large")
        }

        // Ken, 2026-08-02: "I'd like the font for the coach to be even a little bigger."
        t.test("the coach is set a step above the app's ordinary writing") {
            for base in [11, 12, 13, 14] as [CGFloat] {
                t.expect(TextScale.coachBase(base) > base,
                         "the coach's \(base) came out at \(TextScale.coachBase(base))")
            }
            // The step is on the base size, so it survives the player's own setting rather than
            // washing out at the ends. A paragraph of feedback at Extra large is the case that
            // matters: it is the biggest the writing ever gets and the reason the dial exists.
            for scale in TextScale.allCases {
                t.expect(scale.size(TextScale.coachBase(13)) > scale.size(13),
                         "at \(scale.name) the coach was no larger than ordinary text")
            }
            t.expect(TextScale.extraLarge.size(TextScale.coachBase(13)) >= 20,
                     "the coach's writing tops out at "
                     + "\(TextScale.extraLarge.size(TextScale.coachBase(13)))")
        }

        t.test("sizes land on whole points") {
            for scale in TextScale.allCases {
                for base in [9, 10, 11, 12, 13, 15, 20, 26] as [CGFloat] {
                    t.equal(scale.size(base), scale.size(base).rounded())
                    t.expect(scale.size(base) >= 7, "type became unreadably small at \(base)")
                }
            }
        }

        t.test("the choice survives a restart") {
            let defaults = scratchDefaults()
            defer { wipe(defaults) }

            // A fresh install has never been told, and reads as Normal.
            t.equal(TextScale.stored(in: defaults), .normal)

            for scale in TextScale.allCases {
                TextScale.store(scale, in: defaults)
                // Reading it back through a second handle on the same storage is what a relaunch
                // does: nothing is kept in memory between the two.
                t.equal(TextScale.stored(in: scratchDefaults()), scale,
                        "\(scale.name) was not remembered")
                t.equal(TextScale.stored(in: scratchDefaults()).multiplier, scale.multiplier)
            }
        }

        t.test("a value it does not recognise reads as normal") {
            let defaults = scratchDefaults()
            defer { wipe(defaults) }
            defaults.set("enormous", forKey: "PokerTutor.textSize")
            t.equal(TextScale.stored(in: defaults), .normal, "an unknown setting must not stop it drawing")
        }
    }
}

/// Somewhere to write that is not the player's own settings.
private func scratchDefaults() -> UserDefaults {
    UserDefaults(suiteName: "PokerTutor.tests.textSize") ?? .standard
}

private func wipe(_ defaults: UserDefaults) {
    defaults.removePersistentDomain(forName: "PokerTutor.tests.textSize")
}

/// The deck's colours.
///
/// Ken, 2026-08-22: "hard to see the difference between the black suit." Clubs and spades share a
/// colour in a traditional deck and very nearly share a shape at the size a phone draws them.
///
/// What is tested here is the thing that actually matters — that no two suits come out the same —
/// rather than which colours were picked, which is taste and will change.
public func cardColourTests(_ t: TestRun) {
    t.suite("telling the suits apart") { t in

        /// Roughly how far apart two colours look. Not a perceptual model — enough to catch two
        /// suits that were given the same paint, or near enough to it to fail on a phone in
        /// daylight.
        func distance(_ a: Palette.Components, _ b: Palette.Components) -> Double {
            let dr = a.red - b.red, dg = a.green - b.green, db = a.blue - b.blue
            return (dr * dr + dg * dg + db * db).squareRoot()
        }

        t.test("four colours means four different colours, in both palettes") {
            for palette in Palette.allCases {
                let colours = Suit.allCases.map {
                    palette.suitComponents($0, fourColour: true)
                }
                for i in colours.indices {
                    for j in colours.indices where j > i {
                        t.greaterThan(distance(colours[i], colours[j]), 0.25,
                                      "\(palette.name): \(Suit.allCases[i]) and "
                                      + "\(Suit.allCases[j]) are nearly the same colour")
                    }
                }
            }
        }

        t.test("diamonds and clubs are not both dark and cool") {
            // Ken, 2026-08-22: "Diamond color looked too much like a club." Diamonds were blue and
            // clubs green — two dark, cool colours being read on a green felt, which is the worst
            // backdrop available for separating them. Distance alone did not catch it, so this
            // asks the question that did: the two must not sit in the same half of the wheel.
            for palette in Palette.allCases {
                let diamonds = palette.suitComponents(.diamonds, fourColour: true)
                let clubs = palette.suitComponents(.clubs, fourColour: true)
                t.greaterThan(distance(diamonds, clubs), 0.45,
                              "\(palette.name): diamonds and clubs are too close")
                // One of them has to be plainly warmer than the other. Red-minus-blue is a crude
                // warmth, and crude is enough to catch two cool colours side by side.
                let diamondWarmth = diamonds.red - diamonds.blue
                let clubWarmth = clubs.red - clubs.blue
                t.greaterThan(abs(diamondWarmth - clubWarmth), 0.35,
                              "\(palette.name): diamonds and clubs are both the same temperature, "
                              + "which is how blue and green got shipped")
            }
        }

        t.test("clubs and spades are the pair this exists to separate") {
            // The complaint, as an assertion. If a later tidy-up collapses these two back onto one
            // colour while four-colour is on, that is the whole setting undone.
            for palette in Palette.allCases {
                let clubs = palette.suitComponents(.clubs, fourColour: true)
                let spades = palette.suitComponents(.spades, fourColour: true)
                t.greaterThan(distance(clubs, spades), 0.3,
                              "\(palette.name): clubs and spades still look alike")
            }
        }

        t.test("the colour-blind palette does not put clubs next to hearts in green and red") {
            // Green clubs against red hearts is the standard four-colour deck and the one pairing
            // a red-green blind reader cannot use. Whatever the colour-blind scheme picks, those
            // two have to be further apart than the pair it replaced.
            let clubs = Palette.colourBlind.suitComponents(.clubs, fourColour: true)
            let hearts = Palette.colourBlind.suitComponents(.hearts, fourColour: true)
            t.greaterThan(distance(clubs, hearts), 0.4,
                          "the colour-blind deck put clubs and hearts too close together")
            // And it must not simply be the classic scheme wearing a different name.
            t.expect(clubs != Palette.classic.suitComponents(.clubs, fourColour: true),
                     "the colour-blind palette is using the classic green for clubs")
        }

        t.test("the traditional deck is still the traditional deck") {
            // Somebody who turns four-colour off has asked for red and black, and should get
            // exactly that — not a softened version of the new scheme.
            for palette in Palette.allCases {
                let hearts = palette.suitComponents(.hearts, fourColour: false)
                let diamonds = palette.suitComponents(.diamonds, fourColour: false)
                let clubs = palette.suitComponents(.clubs, fourColour: false)
                let spades = palette.suitComponents(.spades, fourColour: false)
                t.equal(hearts, diamonds, "\(palette.name): the two red suits are not both red")
                t.equal(clubs, spades, "\(palette.name): the two black suits are not both black")
            }
        }

        t.test("spades stay black whichever scheme is on") {
            // The point is to move the three suits that were sharing two colours, not to repaint
            // the deck into something nobody recognises.
            for palette in Palette.allCases {
                t.equal(palette.suitComponents(.spades, fourColour: true),
                        palette.suitComponents(.spades, fourColour: false),
                        "\(palette.name) repainted the spades")
            }
        }

        t.test("the choice survives a restart, and a bad value does not stop the cards drawing") {
            let defaults = UserDefaults(suiteName: "cards-\(UUID().uuidString)")!
            t.equal(CardColours.stored(in: defaults), .four, "four is the default")
            CardColours.store(.two, in: defaults)
            t.equal(CardColours.stored(in: defaults), .two)
            defaults.set("technicolour", forKey: CardColours.storageKey)
            t.equal(CardColours.stored(in: defaults), .four,
                    "an unrecognised value has to fall back rather than refuse to draw a card")
        }
    }
}

/// The board in the middle of the felt.
///
/// Ken, 2026-08-22: "can we make the flop cards a little bigger?" Bigger has a ceiling, and it is
/// not taste: the board sits in the middle and nine seats sit on a ring around it, so a board that
/// grows far enough collides with the two at the sides. This is that ceiling, checked rather than
/// eyeballed on one phone.
public func boardSizeTests(_ t: TestRun) {
    t.suite("the board in the middle") { t in

        /// Every screen the app can actually be run on: the smallest iPhone still supported, two
        /// current ones, the smallest the Mac window can be dragged to, and the size it opens at.
        let screens = [CGSize(width: 375, height: 667), CGSize(width: 402, height: 874),
                       CGSize(width: 430, height: 932),
                       CGSize(width: 900, height: 640), CGSize(width: 1240, height: 820)]

        t.test("the board never runs into the seats beside it") {
            for scale in TextScale.allCases {
                for screen in screens {
                    let metrics = Metrics(width: screen.width, height: screen.height,
                                          textScale: scale)
                    t.greaterThan(metrics.boardClearance, 4,
                                  "\(scale.name) at \(Int(screen.width))×\(Int(screen.height)): the "
                                  + "board is \(Int(metrics.boardWidth)) wide and comes within "
                                  + "\(Int(metrics.boardClearance)) points of the side seats")
                }
            }
        }

        t.test("the board fits across the felt with room around it") {
            for scale in TextScale.allCases {
                for screen in screens {
                    let metrics = Metrics(width: screen.width, height: screen.height,
                                          textScale: scale)
                    t.expect(metrics.boardWidth < metrics.feltArea.width - 20,
                             "\(scale.name) at \(Int(screen.width))×\(Int(screen.height)): a board "
                             + "\(Int(metrics.boardWidth)) wide in a felt "
                             + "\(Int(metrics.feltArea.width)) across")
                }
            }
        }

        t.test("the board is as large as the smallest phone can hold") {
            // The ceiling, written down so the next request for bigger cards gets an answer rather
            // than a redraw. On the smallest iPhone still supported the seats close in on the
            // middle, and a board much past this one overlaps them — 46 points, which would match
            // the player's own cards, already does.
            let smallest = Metrics(width: 375, height: 667, textScale: .normal)
            t.greaterThan(smallest.boardCard, 38,
                          "the board went back to being the smallest thing on the felt")
            t.lessThan(smallest.boardClearance, 30,
                       "there are \(Int(smallest.boardClearance)) points of felt going spare "
                       + "beside the board, so it could be bigger than \(Int(smallest.boardCard))")
        }

        t.test("a board card is bigger than an opponent's, which nobody has to read") {
            for screen in screens {
                let metrics = Metrics(width: screen.width, height: screen.height, textScale: .normal)
                t.greaterThan(metrics.boardCard, metrics.opponentCard)
            }
        }
    }
}

/// The by-seat table on My game.
///
/// Ken, 2026-08-23: "on the my game screen, the text scrolls too far over to the right and cant be
/// fully read." Four columns with widths of their own, each multiplied by the text-size setting,
/// added up to more than a phone has — and only once the type was turned up, which is precisely
/// what somebody who finds a table of numbers hard to read does first.
///
/// This is the fourth layout fault in a row that the suite could not see. The difference is that
/// this one is arithmetic rather than rendering, so it does not have to stay invisible.
public func statTableTests(_ t: TestRun) {
    t.suite("the by-seat table fits the screen") { t in

        let screens = [CGSize(width: 375, height: 667), CGSize(width: 402, height: 874),
                       CGSize(width: 430, height: 932),
                       CGSize(width: 900, height: 640), CGSize(width: 1240, height: 820)]

        t.test("there is room for a seat's name beside its numbers, at every text size") {
            // "Seat 2 of 9" over "Big blind" is the widest thing the seat column has to hold. At
            // Extra large that line is set at about 17 points, so it wants a little over 100 to
            // say itself without breaking up — 90 was the first number tried here and it was not
            // enough for the type size the person reading it had chosen.
            for scale in TextScale.allCases {
                for screen in screens {
                    let metrics = Metrics(width: screen.width, height: screen.height,
                                          textScale: scale)
                    t.greaterThan(metrics.statNameWidth, 100,
                                  "\(scale.name) at \(Int(screen.width)) wide: the numbers take "
                                  + "\(Int(metrics.statFixedColumns)) of "
                                  + "\(Int(metrics.panelContentWidth)), leaving "
                                  + "\(Int(metrics.statNameWidth)) for the seat")
                }
            }
        }

        t.test("the numbers stop growing before they eat the row") {
            // The columns are capped at Large for the same reason the seat boxes on the felt are:
            // past that they take the space the words need. Extra large must not cost the seat
            // column anything the setting below it did not already.
            for screen in screens {
                let large = Metrics(width: screen.width, height: screen.height, textScale: .large)
                let extra = Metrics(width: screen.width, height: screen.height,
                                    textScale: .extraLarge)
                t.equal(extra.statFixedColumns, large.statFixedColumns,
                        "at \(Int(screen.width)) wide the number columns kept growing past Large")
            }
        }

        t.test("bigger type never leaves less room than smaller type did") {
            // The failure this replaces was monotonic and quiet: every step up took a little more
            // until the row ran off the side. If a future column ever does that again, it fails
            // here first.
            for screen in screens {
                let widths = TextScale.allCases.map {
                    Metrics(width: screen.width, height: screen.height, textScale: $0).statNameWidth
                }
                for (bigger, smaller) in zip(widths.dropFirst(), widths) {
                    t.expect(bigger <= smaller + 0.5,
                             "at \(Int(screen.width)) wide the seat column grew with the type, "
                             + "which means something else shrank")
                }
                t.greaterThan(widths.last ?? 0, 100,
                              "at \(Int(screen.width)) wide the largest type leaves "
                              + "\(Int(widths.last ?? 0)) for the seat name")
            }
        }
    }
}

public func twoCardTests(_ t: TestRun) {
    t.suite("two cards in a hand") { t in

        let screens = [CGSize(width: 375, height: 667), CGSize(width: 402, height: 874),
                       CGSize(width: 430, height: 932),
                       CGSize(width: 900, height: 640), CGSize(width: 1240, height: 820)]

        func everywhere(_ body: (Metrics, String) -> Void) {
            for scale in TextScale.allCases {
                for screen in screens {
                    let metrics = Metrics(width: screen.width, height: screen.height, textScale: scale)
                    body(metrics, "\(scale.name) at \(Int(screen.width))×\(Int(screen.height))")
                }
            }
        }

        t.test("the player is dealt two cards, and the row is measured for two") {
            let metrics = Metrics(width: 375, height: 667, textScale: .normal)
            t.equal(metrics.heroCardCount, 2)
            t.equal(metrics.heroCardFrames.count, 2)
        }

        t.test("the player's two cards sit side by side and never overlap") {
            // Ken, 2026-09-10: "I like the way the cards are presented in the PLO app. Can we have
            // holdem match it?" The PLO row was built after a fanned hand proved unreadable on a
            // phone; here every card is whole, at every size, and this is the check that says so.
            everywhere { metrics, place in
                let frames = metrics.heroCardFrames
                t.equal(frames.count, metrics.heroCardCount, "\(place): not every card is drawn")
                for (first, second) in zip(frames, frames.dropFirst()) {
                    t.expect(second.minX - first.maxX >= Metrics.cardGap - 0.5,
                             "\(place): two of the player's cards overlap — one ends at "
                             + "\(Int(first.maxX)) and the next starts at \(Int(second.minX))")
                }
                t.close(Double(metrics.heroHandWidth), Double(frames.last!.maxX), 0.5,
                        "\(place): the hand's width is not the width of its cards")
            }
        }

        t.test("the player's cards are big enough to read on the smallest phone") {
            for scale in TextScale.allCases {
                let phone = Metrics(width: 375, height: 667, textScale: scale)
                t.expect(phone.heroCard >= 36, "\(scale.name): the player's cards are \(Int(phone.heroCard)) tall")
            }
            // The cards were 46 on a phone in the seat box; moving them to a row of their own is
            // no reason to make them smaller.
            t.greaterThan(Metrics(width: 375, height: 667, textScale: .normal).heroCard, 45,
                          "the player's cards on a phone are smaller than they were")
        }

        t.test("the player's row fits across the screen") {
            everywhere { metrics, place in
                t.expect(metrics.heroRowWidth <= metrics.width - 8 + 0.5,
                         "\(place): the player's row is \(Int(metrics.heroRowWidth)) wide on a "
                         + "screen \(Int(metrics.width)) across")
            }
        }

        t.test("the player's row sits clear of the ring of seats and of the board") {
            everywhere { metrics, place in
                let ring = FeltRing(size: metrics.feltArea, seatWidth: metrics.seatWidth,
                                    seatHeight: metrics.ringSeatHeight, compact: metrics.compact)
                for count in 2...9 {
                    t.expect(ring.holds(count),
                             "\(place), \(count) handed: a seat runs into a neighbour or off the felt")
                    t.expect(metrics.lowestSeatBottom(of: count) <= metrics.feltArea.height + 0.5,
                             "\(place), \(count) handed: the lowest seat reaches "
                             + "\(Int(metrics.lowestSeatBottom(of: count))) and the player's row "
                             + "starts at \(Int(metrics.feltArea.height))")
                }
                t.expect(metrics.boardBottom < metrics.feltArea.height,
                         "\(place): the board and the pot reach \(Int(metrics.boardBottom)) and the "
                         + "player's row starts at \(Int(metrics.feltArea.height))")
                t.expect(metrics.heroRowHeight >= metrics.heroCard + 8,
                         "\(place): the player's row is shorter than the cards in it")
            }
        }

        t.test("an opponent's two cards fit across their seat, backs or faces") {
            everywhere { metrics, place in
                let backs = metrics.cardRowWidth(count: metrics.heroCardCount, height: metrics.opponentCard)
                let faces = metrics.cardRowWidth(count: metrics.heroCardCount, height: metrics.opponentRevealCard)
                t.expect(max(backs, faces) <= metrics.seatWidth + 0.5,
                         "\(place): an opponent's cards are \(Int(max(backs, faces))) wide in a seat "
                         + "\(Int(metrics.seatWidth)) wide")
            }
        }
    }
}

// MARK: - The button and the blinds

public func tableMarkerTests(_ t: TestRun) {
    t.suite("the button and the blinds on the felt") { t in

        let screens = [CGSize(width: 375, height: 667), CGSize(width: 402, height: 874),
                       CGSize(width: 430, height: 932),
                       CGSize(width: 900, height: 640), CGSize(width: 1240, height: 820)]

        func everywhere(_ body: (Metrics, String) -> Void) {
            for scale in TextScale.allCases {
                for screen in screens {
                    let metrics = Metrics(width: screen.width, height: screen.height, textScale: scale)
                    body(metrics, "\(scale.name) at \(Int(screen.width))×\(Int(screen.height))")
                }
            }
        }

        /// At least this much felt between a marker and any box or the board, so it reads as a
        /// thing on the table rather than part of a seat. Read off `Metrics` so the layout and
        /// the test cannot disagree about it; the number itself is pinned below.
        let clearance = Metrics(width: 375, height: 667, textScale: .normal).markerClearance

        func inside(_ rect: CGRect, _ area: CGSize) -> Bool {
            rect.minX >= -0.5 && rect.minY >= -0.5
                && rect.maxX <= area.width + 0.5 && rect.maxY <= area.height + 0.5
        }

        func gap(_ a: CGRect, _ b: CGRect) -> CGFloat {
            let dx = max(0, max(b.minX - a.maxX, a.minX - b.maxX))
            let dy = max(0, max(b.minY - a.maxY, a.minY - b.maxY))
            return (dx * dx + dy * dy).squareRoot()
        }

        t.test("every seat's marker sits on the felt, off its seat box, and off the board") {
            // Ken, 2026-09-11: "It is hard to see who has the button at any given time."
            everywhere { metrics, place in
                for count in 2...TableSession.seatsAtTable {
                    for seat in 1..<count {
                        let marks = count == 2 ? 2 : 1
                        let marker = metrics.markerFrame(seatsFromHero: seat, of: count, marks: marks)
                        let box = metrics.seatBox(seatsFromHero: seat, of: count)
                        t.expect(inside(marker, metrics.feltArea),
                                 "\(place), \(count) handed, seat \(seat): the marker is off the felt")
                        t.expect(gap(marker, box) >= clearance - 0.5,
                                 "\(place), \(count) handed, seat \(seat): the marker is "
                                 + "\(Int(gap(marker, box))) points off its seat box")
                        t.expect(gap(marker, metrics.boardBlock) >= clearance - 0.5,
                                 "\(place), \(count) handed, seat \(seat): the marker is over the board")
                        for other in 1..<count where other != seat {
                            let theirs = metrics.seatBox(seatsFromHero: other, of: count)
                            t.expect(gap(marker, theirs) >= clearance - 0.5,
                                     "\(place), \(count) handed, seat \(seat): the marker is "
                                     + "\(Int(gap(marker, theirs))) points off seat \(other)'s box")
                        }
                    }
                }
            }
        }

        t.test("the player's own marker sits at the empty bottom of the ring, clear of the corner seats") {
            everywhere { metrics, place in
                for count in 2...TableSession.seatsAtTable {
                    let marker = metrics.markerFrame(seatsFromHero: 0, of: count, marks: count == 2 ? 2 : 1)
                    t.expect(inside(marker, metrics.feltArea),
                             "\(place), \(count) handed: the player's marker is off the felt")
                    t.expect(gap(marker, metrics.boardBlock) >= clearance - 0.5,
                             "\(place), \(count) handed: the player's marker is over the board")
                    for seat in 1..<count {
                        let box = metrics.seatBox(seatsFromHero: seat, of: count)
                        t.expect(gap(marker, box) >= clearance - 0.5,
                                 "\(place), \(count) handed: the player's marker is "
                                 + "\(Int(gap(marker, box))) points off seat \(seat)'s box")
                    }
                }
            }
        }

        t.test("the discs are the size of a chip, not a card") {
            t.expect(clearance >= 4, "markers may sit \(clearance) points off a seat, which is touching")
            everywhere { metrics, place in
                t.lessThan(metrics.dealerDisc, metrics.seatWidth * 0.35,
                           "\(place): a \(Int(metrics.dealerDisc))-point button on a \(Int(metrics.seatWidth))-point seat")
                t.lessThan(metrics.blindMarker, metrics.dealerDisc,
                           "\(place): the blind chips are not smaller than the button")
                t.greaterThan(metrics.blindMarker, 12, "\(place): the blind chips are too small to read")
            }
        }

        t.test("which seats carry which marker") {
            t.equal(TableMark.marks(for: .button, headsUp: false), [.dealer])
            t.equal(TableMark.marks(for: .smallBlind, headsUp: false), [.smallBlind])
            t.equal(TableMark.marks(for: .bigBlind, headsUp: false), [.bigBlind])
            t.equal(TableMark.marks(for: .cutoff, headsUp: false), [])
            // Heads-up the button posts the small blind and the engine names that seat the button.
            t.equal(TableMark.marks(for: .button, headsUp: true), [.dealer, .smallBlind])
            t.equal(TableMark.marks(for: .bigBlind, headsUp: true), [.bigBlind])
        }
    }
}
