Files
JoshHeaps.Net/tests/holdem/potBreakdownRoom.test.js
jheapsandClaude Opus 5 7216f3da4d Stop a wrapped label costing twelve seats their room
The sweep had carried the same residual for rounds: on the two smallest
phones with twelve players, seats painting over each other's names and
stacks. It read like a geometry problem. It was a wrapped label.

"ALL IN" is the only thing that ever lands on the stack line with a space in
it. A stack figure has no spaces, so it can only overflow, and the
puckAmountLong/Huge sizes exist to stop that; nothing had asked what the line
does when it can break. Measured at 360x640 with twelve players all-in, it
wanted 36px and had 34, broke onto a second line, and an 11px line became 22.

That is not a typography complaint, which is why it survived: it looks fine.
The puck went from 55px tall to 66, and puck height is what the ring is short
of -- at the ends of an ellipse consecutive seats are separated vertically, so
a puck 11px taller is a puck 11px further into its neighbour. The layout
search then did exactly what it is written to do: tried every width it has,
found none that cleared, and fell back to the narrowest, which is also the one
that wraps hardest.

So the rule every design system gives a badge applies here too: one line,
always. Not wrapping is only half of it -- a line that cannot wrap and still
does not fit paints over the seat next door instead -- so on the tight tier
the mark gives up its letter-spacing, 3.6px of the 36 it wanted, and keeps the
warm colour and the bold weight that do the same job.

Halving the overlap did not clear it, and the rest of the shortage was not on
the felt. The sweep had been saying so in a warning rather than a failure: at
360x640 the bar was 267px of a 640px window, 42%, against its own "persistent
chrome under 30%". Two things in it were being shown when they could not be
used. The side-pot list is decision support, and during a run-out there is no
decision to support -- it collapses to the main pot and one line for the rest.
The status row said the player count twice in the same breath, as a chip and
again inside the run-out sentence beside it.

Measured: bar 267 -> 249 on a Galaxy S8 and 267 -> 227 on an iPhone SE, felt
243 -> 283 there, worst seat overlap 34px -> 4px. Across the sweep, overlap
offender lines 33 -> 9, chips behind seats 30 -> 5, failing audits 6 -> 5, and
the iPhone SE's seat-text overlap gone entirely. No new failures.

894 tests pass, 9 of them new. Seven fault injections are each caught.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 05:01:56 -06:00

230 lines
11 KiB
JavaScript

// The bar's pot list is not allowed to grow with the table.
//
// #actionBar is fixed over the felt, so every pixel it takes is a pixel the table
// loses — and this list took one row per all-in with no ceiling. Measured on a 360x640
// phone, five players idle: bar 151px. Six all-in: 303px. Twelve all-in: 411px, which
// is 64% of the screen, leaving 96px of felt to draw twelve seats in. A fixed overlay
// whose height depends on the state is a fault whatever it is showing, so the cap is
// checked here rather than left to whoever next adds an all-in to the audit sweep.
//
// The two ends survive because they are the two that are live. Side pots are paid from
// the newest one back down to the main pot, so the top layer is the next thing that
// happens and the main pot is the last, and those are the two a dealer says out loud
// while the chips are stacked — "main pot 7,200, side pot 4,500". The layers between
// are the middle of that count-down, wanted at showdown, which is the pot wizard, which
// passes no cap and still lists all of them.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render, Money, PotCalculator } = require("./load");
const nameFor = id => `P${id}`;
// The same stub potBreakdown.test.js uses — enough of an element to say what the row
// is made of, and no jsdom in a suite that runs in a second.
const fakeDom = () => {
const make = () => ({
className: "", textContent: "", title: "", children: [],
classList: { add() {} },
appendChild(child) { this.children.push(child); return child; },
});
global.document = { createElement: make };
return () => { delete global.document; };
};
// A table where everyone is all-in for a different amount, which is the shape that
// makes the most pot layers — the worst case and the one that was measured. `count`
// players ladder into `count - 1` layers: two of them share a single undivided pot and
// the breakdown declines to draw at all, which is why nothing below three appears here.
const laddered = count => PotCalculator.layers(
Array.from({ length: count }, (_, i) => ({
id: `s${i}`, contribution: 100 + i * 130, folded: false,
})));
const build = (pots, options) => {
const undo = fakeDom();
try {
const wrap = Render.buildPotBreakdown(pots, [], nameFor, null, options);
return wrap.children.map(row => ({
className: row.className,
label: row.children[0].textContent,
amount: row.children[1].textContent,
who: row.children[2].textContent,
title: row.children[2].title,
}));
} finally { undo(); }
};
describe("the bar's copy of the pot list", () => {
test("stops growing once there are more pots than it has room for", () => {
// The property that matters: rows are a function of the cap, not of the table.
// Twelve-handed and six-handed have to render the same number of lines.
for (let players = 3; players <= 12; players++) {
const rows = build(laddered(players), { maxLayers: 4 });
assert.ok(rows.length <= 4,
`${players} all-in drew ${rows.length} rows; the bar is sized for 4`);
}
});
test("and the number of rows never depends on how many people are all-in", () => {
const wide = [8, 9, 10, 11, 12].map(n => build(laddered(n), { maxLayers: 4 }).length);
assert.deepEqual(wide, [3, 3, 3, 3, 3],
"past the cap every table should draw main, the elision and the top pot");
});
test("keeps the two pots a dealer actually announces", () => {
const pots = laddered(12);
const rows = build(pots, { maxLayers: 4 });
assert.equal(rows[0].label, pots[0].label, "the main pot is the last one paid");
assert.equal(rows[rows.length - 1].label, pots[pots.length - 1].label,
"the newest side pot is the first one paid");
});
test("and leaves them in the order the pots were made", () => {
const rows = build(laddered(12), { maxLayers: 4 });
assert.match(rows[1].className, /potLayerMore/,
"the elision belongs where the gap is, not appended after the list");
});
});
describe("the money in the rows that were elided", () => {
test("is still on the screen, added up", () => {
// The one thing a summary row must not do is lose chips. Whatever is drawn has
// to account for every pot, or a dealer counting the announcement against the
// chips in the middle finds a shortfall the tool caused.
for (let players = 3; players <= 12; players++) {
const pots = laddered(players);
const total = pots.reduce((sum, pot) => sum + pot.amount, 0);
const drawn = build(pots, { maxLayers: 4 })
.reduce((sum, row) => sum + Number(row.amount.replace(/[^0-9.]/g, "")), 0);
assert.equal(drawn, total,
`${players} all-in: rows add to ${drawn}, the pots add to ${total}`);
}
});
test("and says how many it stands for", () => {
const rows = build(laddered(12), { maxLayers: 4 });
const pots = laddered(12);
assert.equal(rows[1].label, `${pots.length - 2} more side pots`);
});
test("with the list itself in a title, for anyone with a pointer", () => {
const pots = laddered(12);
const rows = build(pots, { maxLayers: 4 });
pots.slice(1, -1).forEach(pot => {
assert.ok(rows[1].title.includes(pot.label),
`${pot.label} vanished without trace`);
assert.ok(rows[1].title.includes(Money.format(pot.amount)),
`${pot.label}'s amount vanished without trace`);
});
});
test("and points at where the whole list is", () => {
// The title is a pointer affordance, so it cannot be the answer on a phone. The
// visible text has to say where the rest lives.
assert.match(build(laddered(12), { maxLayers: 4 })[1].who, /resolve/i);
});
});
describe("when eliding would not buy anything", () => {
test("a list that fits is left alone", () => {
for (let players = 3; players <= 5; players++) {
const pots = laddered(players);
assert.ok(pots.length <= 4, "this loop is meant to cover the fitting sizes");
const rows = build(pots, { maxLayers: 4 });
assert.equal(rows.length, pots.length);
assert.deepEqual(rows.map(r => r.label), pots.map(p => p.label));
}
});
test("and a single middle layer is not replaced by a line saying there is one", () => {
// At exactly the cap the middle is one row, and swapping one row for one row is
// pure indirection: the dealer loses an amount and gains a sentence.
const pots = laddered(5);
assert.equal(pots.length, 4, "five all-in ladder into exactly the cap");
const rows = build(pots, { maxLayers: 4 });
assert.equal(rows.length, 4);
assert.ok(!rows.some(r => /potLayerMore/.test(r.className)));
});
});
describe("the wizard, which has room", () => {
test("still gets every layer", () => {
// Same builder, no cap — this is the announcement read out before anybody shows
// a hand, and it is in a dialog that scrolls. Capping it there would hide the
// pots at the moment they are being paid.
const pots = laddered(12);
const rows = build(pots, undefined);
assert.equal(rows.length, pots.length);
assert.ok(!rows.some(r => /potLayerMore/.test(r.className)));
});
});
// ---- And smaller still once nobody can act -----------------------------------
//
// The list is decision support. A player working out whether to call is owed the side
// pot they would be calling into, and that is worth three rows of a fixed bar.
//
// Once everyone is all-in there is no decision left to support: the only thing that
// happens for the rest of the hand is the dealer turning cards. The list went on
// standing there anyway, and it is not free — measured at 360x640 with twelve players
// all-in, the bar was 267px of a 640px window. That is 42%, against the sweep's own
// "persistent chrome under 30%", and the felt pays it on the exact table shape that has
// the most seats to fit into what is left.
//
// So during a run-out it collapses to the main pot and one line for everything else.
// The main pot stays because it is the number a dealer says out loud; the layering is
// what the pot wizard is for, and the summary row has always said so in as many words.
describe("the list during a run-out, when nobody has a decision left", () => {
test("is two rows however many people are all-in", () => {
const rows = [3, 6, 9, 12].map(n => build(laddered(n), { maxLayers: 4, summarise: true }).length);
assert.deepEqual(rows, [2, 2, 2, 2]);
});
test("and that is fewer than the same table gets while the betting is live", () => {
// The point of the change, stated as a comparison rather than a constant, so it
// still means something if the ordinary cap is ever retuned.
const live = build(laddered(12), { maxLayers: 4 }).length;
const runout = build(laddered(12), { maxLayers: 4, summarise: true }).length;
assert.ok(runout < live, `a run-out should be shorter than live betting (${runout} vs ${live})`);
});
test("still names the main pot, which is the one said out loud", () => {
const pots = laddered(12);
const rows = build(pots, { maxLayers: 4, summarise: true });
assert.equal(rows[0].label, pots[0].label);
assert.equal(rows[0].amount, Money.format(pots[0].amount));
});
test("and still accounts for every chip", () => {
// The same rule as the elision above, and the one a summary row must not break:
// a dealer counting the announcement against the chips in the middle must not
// find a shortfall the tool invented.
for (let players = 3; players <= 12; players++) {
const pots = laddered(players);
const total = pots.reduce((sum, pot) => sum + pot.amount, 0);
const drawn = build(pots, { maxLayers: 4, summarise: true })
.reduce((sum, row) => sum + Number(row.amount.replace(/[^0-9.]/g, "")), 0);
assert.equal(drawn, total,
`${players} all-in: rows add to ${drawn}, the pots add to ${total}`);
}
});
test("says how many side pots it stands for, and counts them properly at one", () => {
const many = build(laddered(12), { maxLayers: 4, summarise: true });
assert.equal(many[1].label, `${laddered(12).length - 1} side pots`);
// Three players ladder into two pots: a main and exactly one side.
const one = build(laddered(3), { maxLayers: 4, summarise: true });
assert.equal(one[1].label, "1 side pot", "a single side pot is not '1 side pots'");
});
test("and points at where the full list actually is", () => {
const rows = build(laddered(12), { maxLayers: 4, summarise: true });
assert.match(rows[1].who, /resolve/,
"the row that hides the layers has to say where they went");
assert.match(rows[1].title, /Side pot/,
"and a pointer still gets the full list without resolving anything");
});
});