← poker-tutor
import Foundation
import PokerCore
import PokerSession
import PokerUI
import TestKit
/// Numbering the seats: the small blind is 1, the button is last, and the number is the thing the
/// player reads. The numbering is display only, so all of this is about what someone is told —
/// nothing here should ever be able to change how a hand is played.
public func seatNumberTests(_ t: TestRun) {
t.suite("seat numbers") { t in
t.test("nine handed reads the way the table sits") {
let nine = Position.names(forPlayerCount: 9)
t.equal(nine.map { $0.number(inTableOf: 9) },
[1, 2, 3, 4, 5, 6, 7, 8, 9],
"seats should count round from the small blind")
t.equal(Position.smallBlind.number(inTableOf: 9), 1)
t.equal(Position.underTheGun.number(inTableOf: 9), 3)
t.equal(Position.cutoff.number(inTableOf: 9), 8)
t.equal(Position.button.number(inTableOf: 9), 9)
}
t.test("every table size numbers 1 through however many are sitting") {
for count in 2...9 {
let numbers = Position.names(forPlayerCount: count)
.compactMap { $0.number(inTableOf: count) }
.sorted()
t.equal(numbers, Array(1...count), "\(count) handed")
t.equal(Set(numbers).count, count, "\(count) handed: two seats shared a number")
}
}
t.test("the small blind is always 1 and the button is always last") {
for count in 2...9 {
t.equal(Position.button.number(inTableOf: count), count,
"\(count) handed: the button has to be the last number")
guard count > 2 else { continue }
t.equal(Position.smallBlind.number(inTableOf: count), 1, "\(count) handed")
t.equal(Position.bigBlind.number(inTableOf: count), 2, "\(count) handed")
}
}
t.test("the numbering follows the order the seats are named in") {
// The one place the two can disagree is heads-up, and that is deliberate — see below.
for count in 3...9 {
let named = Position.names(forPlayerCount: count)
let byNumber = named.sorted { ($0.number(inTableOf: count) ?? 0) < ($1.number(inTableOf: count) ?? 0) }
t.equal(byNumber, named, "\(count) handed: numbering and naming disagree")
}
}
t.test("heads-up numbers the button last rather than following the blind") {
// Two-handed the button posts the small blind. Numbering by the blind would make the
// button seat 1, which would mean the number went down as the seat got better — the
// opposite of what it means at every other table size.
t.equal(Position.bigBlind.number(inTableOf: 2), 1)
t.equal(Position.button.number(inTableOf: 2), 2)
t.equal(Position.smallBlind.number(inTableOf: 2), nil,
"nobody is called the small blind heads-up")
}
t.test("a seat that is not at the table has no number") {
t.equal(Position.lojack.number(inTableOf: 6), nil, "six handed has no lojack")
t.equal(Position.underTheGun.number(inTableOf: 3), nil, "three handed is blinds and button")
t.equal(Position.button.number(inTableOf: 1), nil)
// There are nine seat names, so a tenth seat cannot be numbered without inventing one.
// Saying nothing is better than saying "seat 9 of 10" and being wrong about it.
t.equal(Position.button.number(inTableOf: 10), nil, "ten seats, nine names")
t.equal(Position.button.badge(inTableOf: 10), "BTN")
}
t.test("the same name is a different number at a different table size") {
// Which is the whole reason a saved hand has to remember how many were dealt in.
t.equal(Position.cutoff.number(inTableOf: 9), 8)
t.equal(Position.cutoff.number(inTableOf: 6), 5)
t.notEqual(Position.cutoff.seatPhrase(inTableOf: 9), Position.cutoff.seatPhrase(inTableOf: 6))
}
t.test("what the player actually reads") {
t.equal(Position.underTheGunPlus1.seatPhrase(inTableOf: 9), "seat 4 of 9")
t.equal(Position.underTheGunPlus1.described(inTableOf: 9), "seat 4 of 9 (under the gun + 1)")
t.equal(Position.button.badge(inTableOf: 9), "9 BTN")
// A seat that does not exist at that size falls back to the name rather than to "seat 0".
t.equal(Position.lojack.seatPhrase(inTableOf: 6), "lojack")
t.equal(Position.lojack.badge(inTableOf: 6), "LJ")
}
t.test("the note says how many people are still to speak") {
t.expect(Position.underTheGun.actOrderNote(inTableOf: 9).contains("6 players act after you"),
"under the gun nine handed has six behind: \(Position.underTheGun.actOrderNote(inTableOf: 9))")
t.expect(Position.cutoff.actOrderNote(inTableOf: 9).hasPrefix("One player acts after you"),
"the cutoff has exactly one behind it")
t.expect(Position.button.actOrderNote(inTableOf: 9).contains("Nobody acts after you"),
"the button has nobody behind it")
t.expect(Position.smallBlind.actOrderNote(inTableOf: 9).contains("before you had seen your cards"),
"the blinds should say why they lose")
t.expect(!Position.button.actOrderNote(inTableOf: 9).isEmpty)
t.equal(Position.lojack.actOrderNote(inTableOf: 6), "",
"a seat that is not at the table says nothing rather than something wrong")
}
t.test("the engine still names seats by case, whatever the numbering says") {
// The numbering is display only. If this ever fails, the ranges have started reading it.
let e = HandEngine(seats: (0..<9).map { SeatState(seat: $0, playerID: "p\($0)", stack: 1000) },
config: HandConfig(level: BlindLevel(smallBlind: 25, bigBlind: 50),
buttonSeat: 0, seed: 7))
let map = e.positions()
t.equal(map[0], .button)
t.equal(map[0]?.number(inTableOf: map.count), 9)
t.equal(map[1], .smallBlind)
t.equal(map[1]?.number(inTableOf: map.count), 1)
}
}
t.suite("the table picture") { t in
// The drawing cannot be tested, but the arithmetic under it can, and it is the part that
// goes wrong quietly: nine seats crowded onto a phone-sized ring either overlap each other
// or hang off the edge of the space the diagram said it needed.
t.test("seats never collide, at any table size or text size") {
for side in [CGFloat(96), 148, 204] {
for count in 2...9 {
let ring = SeatRing(side: side, count: count)
let points = ring.points
t.equal(points.count, count, "\(count) handed at \(side)")
for (a, first) in points.enumerated() {
for second in points[(a + 1)...] {
let gap = hypot(first.x - second.x, first.y - second.y)
t.expect(gap >= ring.footprint,
"\(count) handed at \(side): two seats are \(gap) apart and need \(ring.footprint)")
}
}
}
}
}
t.test("no seat hangs off the edge of the picture") {
for side in [CGFloat(96), 148, 204] {
let ring = SeatRing(side: side, count: 9)
// The badge on the dealer and the blinds sticks out further than the counter does.
let reach = ring.footprint / 2 + ring.dot * 0.2
for point in ring.points {
t.expect(abs(point.x) + reach <= ring.side / 2 + 0.5,
"a seat runs \(abs(point.x) + reach) wide of a \(ring.side / 2) half-width")
t.expect(abs(point.y) + reach <= ring.height / 2 + 0.5,
"a seat runs \(abs(point.y) + reach) below a \(ring.height / 2) half-height")
}
}
}
t.test("seat 1 sits at the top and the numbers run clockwise") {
let ring = SeatRing(side: 148, count: 9)
let points = ring.points
t.close(Double(points[0].x), 0, 0.001, "seat 1 belongs at the top")
t.lessThan(Double(points[0].y), 0.0)
t.greaterThan(Double(points[1].x), 0.0, "seat 2 is the next one clockwise")
t.lessThan(Double(points[8].x), 0.0, "the dealer sits back the other way from seat 1")
}
}
t.suite("seat numbers in the history") { t in
func hand(position: String, tableSize: Int?, net: Int = 0) -> HandRecord {
HandRecord(sessionID: "s", sessionStarted: Date(timeIntervalSince1970: 1_780_000_000),
playedAt: Date(timeIntervalSince1970: 1_780_000_000), handNumber: 1,
mode: "Tournament", bigBlind: 100, position: position,
holeCards: "Ah Kd", board: "", netChips: net, wentToShowdown: false,
sawFlop: false, putMoneyInPreflop: false, raisedPreflop: false,
verdicts: [], tableSize: tableSize)
}
t.test("a hand written down before seats were numbered still reads") {
// The field is optional for exactly this reason: a required one would make the loader
// drop every line ever written, silently.
let old = hand(position: "CO", tableSize: nil)
t.equal(old.seatsAtTable, 9, "an unrecorded table size reads as the full table the app deals")
t.equal(old.seatPhrase, "seat 8 of 9")
}
t.test("a short table numbers its own seats") {
t.equal(hand(position: "CO", tableSize: 6).seatPhrase, "seat 5 of 6")
t.equal(hand(position: "BTN", tableSize: 6).seatDescription, "seat 6 of 6 (the button)")
}
t.test("an unrecognised seat falls back to what was written down") {
let odd = hand(position: "MP2", tableSize: 9)
t.equal(odd.seat, nil)
t.equal(odd.seatPhrase, "MP2", "a renamed seat should still print something true")
}
t.test("a saved hand carries its table size to disk and back") {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("poker-seats-\(UUID().uuidString).jsonl")
defer { try? FileManager.default.removeItem(at: url) }
let store = HistoryStore(url: url)
store.append(hand(position: "CO", tableSize: 6))
t.equal(HistoryStore(url: url).hands.first?.tableSize, 6)
// And a line from before the field existed still loads.
let older = #"{"sessionID":"s","sessionStarted":"2026-06-01T00:00:00Z","playedAt":"2026-06-01T00:00:00Z","handNumber":2,"mode":"Tournament","bigBlind":100,"position":"BTN","holeCards":"Ah Kd","board":"","netChips":0,"wentToShowdown":false,"sawFlop":false,"putMoneyInPreflop":false,"raisedPreflop":false,"verdicts":[]}"#
try! (older + "\n").data(using: .utf8)!.write(to: url, options: .atomic)
let reread = HistoryStore(url: url)
t.equal(reread.hands.count, 1, "an older line was thrown away")
t.equal(reread.hands.first?.seatPhrase, "seat 9 of 9")
}
t.test("the by-seat breakdown leads with the number") {
let analysis = GameAnalysis(of: [
hand(position: "BB", tableSize: 9, net: -500),
hand(position: "CO", tableSize: 6, net: 300),
hand(position: "CO", tableSize: 6, net: 300),
hand(position: "CO", tableSize: 9, net: 300),
hand(position: "BTN", tableSize: 9, net: 900),
])
let byName = Dictionary(uniqueKeysWithValues: analysis.byPosition.map { ($0.position, $0) })
t.equal(byName["BB"]?.seatNumber, 2)
t.equal(byName["BB"]?.seatPhrase, "seat 2 of 9")
t.equal(byName["BTN"]?.seatNumber, 9)
t.equal(byName["CO"]?.tableSize, 6, "the size those hands were mostly played at")
t.equal(byName["CO"]?.seatPhrase, "seat 5 of 6")
t.equal(byName["CO"]?.fullName, "cutoff", "the old name stays alongside the number")
t.expect(byName["BB"]?.actOrderNote.isEmpty == false, "a row should explain itself")
t.equal(analysis.byPosition.map(\.position), ["BB", "CO", "BTN"],
"rows still run the way the seats sit")
}
t.test("a played session writes down how many were dealt in") {
let store = HistoryStore.inMemory()
let session = TableSession(seed: 91, history: store)
session.start(.tournament)
var guardRail = 0
while session.isPlaying, session.handNumber <= 6 {
guardRail += 1
precondition(guardRail < 20_000)
if let legal = session.legal {
session.act(legal.canCheck ? .check : .fold)
} else if session.review != nil, session.engine?.isComplete == true {
if session.handNumber >= 6 { break }
session.continueAfterReview()
} else {
break
}
}
t.expect(!store.hands.isEmpty, "no hands were recorded")
t.expect(store.hands.allSatisfy { ($0.tableSize ?? 0) >= 2 },
"a hand was recorded without the table size")
t.expect(store.hands.allSatisfy { $0.seat?.number(inTableOf: $0.seatsAtTable) != nil },
"a recorded seat could not be numbered")
t.equal(store.hands.first?.tableSize, 9, "tables are nine handed")
}
}
}