Every length in this stylesheet that isn't a structural clearance is in `rem`. That is the right way round — it means the page follows the browser's font size, which is what a phone's text-size slider changes — and it means there was a whole axis of behaviour that 320 audits had never once touched, because every one of them ran at the default 16px. The sweep gets a third pass: the same surfaces on the eight phone-shaped viewports at 20px of root text, which is 125% on an Android slider. Everything it found on its first run was caught by a check that had been sitting there for months with nothing to say. The page slid sideways. Three header buttons that will not break and will not shrink — `white-space: nowrap` and a `min-width: auto` floor apiece — were told `flex-wrap: nowrap`, so when they stopped fitting they had nowhere to go: "Resolve Pot" ended 23px past the right edge of a 360px window and put a horizontal scrollbar under the whole document; at 24px, 60px past the edge of a 390px one. They take a second row now on the day they need one, and still fit on the first and still grow into it at any ordinary size. Figures painted over the seat next door. Which of the two type steps an amount got was decided by counting characters — eight for one, ten for the other — which is a guess at how wide a figure is that happens to hold at one text size and one puck width. At 20px on a 44px puck, "$1,200" is six characters, took no step at all, and wanted 38px of the 34 it had. Render.fitAmounts measures now, in the batched passes fitSeatNames already uses, and takes the separators out before it takes a step of type: "$1200" at full size beats "$1,200" at three-quarters of it. And the pucks grew, which the ring cannot answer. The row heights are px tokens so an amount stepping its type down shortens its digits and not its row — but a token is a floor, not a ceiling. At 125% the puck went from 55px tall to 65 and twelve of them came down on each other, because at the 44px floor a thumb needs there is nowhere narrower to search. The two sizes a puck is written at are capped at exactly the sizes they have always been drawn at, so nothing changes for anybody at an ordinary text size — and the felt is a diagram, so the rule that already governed a puck's width now governs its height. Roomy pucks are exempt; they have the room. The same figure is in Standings in full, at whatever size its owner asked for. Two smaller things on the way. The landscape rail opened at its top, so its overflow came off the bottom — where Cancel and Raise to are; it opens at the end now, which is the same reason it is anchored low at all. And the check that called those buttons unreachable was wrong: the rail scrolls on purpose, and a sweep that cries wolf is a sweep nobody reads. 418 audits, clean. One shape it is not held to, with the arithmetic in runUiAudit.js: twelve players all-in on a 360x640 phone leaves the felt 290x163 once the bar has taken 283px of the 640, and twelve 44x62 pucks do not fit around an ellipse 163px deep. Reported as a note every run rather than a failure every run. 1026 unit tests. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
358 lines
18 KiB
JavaScript
358 lines
18 KiB
JavaScript
// What the ring is allowed to be sized from.
|
|
//
|
|
// resolveSeatLayout buys its answer with ten forced layouts — ten candidate widths, each
|
|
// applied to the felt and read back — and caches it against this signature. So the
|
|
// signature has to move whenever a puck's height can, and must not move otherwise: one
|
|
// that changed on every action would throw the cache away on every tap, which is the
|
|
// whole cost it exists to avoid.
|
|
//
|
|
// It used to name four things: every player's name, the dealer's seat, whether anybody
|
|
// had hole cards, and whether anybody had chips out. Three of those flip during an
|
|
// ordinary hand. A dealer calling a bet paid ten forced layouts for it, and so did the
|
|
// next street closing, and so did editing a name.
|
|
//
|
|
// None of them can change a puck's height any more. The stylesheet holds three rows open
|
|
// — name, amount, stack — at a height that does not depend on what is written in them:
|
|
// the name gets one line whatever it says, the amount pill keeps its row when it is
|
|
// empty, and the dealer's chip changes a padding rather than a line count. What is left
|
|
// is the hole-card row, which still collapses, because whether a table tracks hole cards
|
|
// is something it decides once rather than something that changes under a thumb.
|
|
//
|
|
// The other half of that bargain is in renderSeats: it measures the finished pucks
|
|
// against the box the search settled on and asks again if they disagree. That is what
|
|
// makes a wrong assumption here a stale frame rather than a stale session — and it is
|
|
// what catches the first render of all, where the pucks are built empty and the search
|
|
// that used to run against them was measuring nine blank boxes.
|
|
const test = require("node:test");
|
|
const { describe } = require("node:test");
|
|
const assert = require("node:assert");
|
|
const { Render, HoldemState, makeTable } = require("./load");
|
|
|
|
// seatShapeSignature reads Render.state, so a table has to be hung on it first.
|
|
const signatureOf = state => {
|
|
const before = Render.state;
|
|
Render.state = state;
|
|
try { return Render.seatShapeSignature(); } finally { Render.state = before; }
|
|
};
|
|
|
|
const table = () => makeTable({ seatCount: 6, stack: 200 });
|
|
|
|
describe("the signature the ring layout is cached against", () => {
|
|
test("moves when a hole card is entered", () => {
|
|
// The one row still allowed to collapse, and the one thing still worth
|
|
// re-measuring for: a puck with cards on it is about 15px taller.
|
|
const state = table();
|
|
const none = signatureOf(state);
|
|
state.seats[0].cards = ["AS", "KC"];
|
|
assert.notEqual(signatureOf(state), none);
|
|
});
|
|
|
|
test("but not again for the second player to be dealt one", () => {
|
|
// The point of a boolean over the table. The ring is sized from the tallest puck
|
|
// on it, so once anybody has cards the answer cannot change.
|
|
const state = table();
|
|
state.seats[0].cards = ["AS", "KC"];
|
|
const first = signatureOf(state);
|
|
state.seats[1].cards = ["2H", "7D"];
|
|
assert.equal(signatureOf(state), first);
|
|
});
|
|
|
|
test("and it stands still while the chips move", () => {
|
|
// The expensive one. Somebody has money out for most of every hand and which
|
|
// seats it is changes on every action, so this used to be a ten-layout re-search
|
|
// twice a hand — and the pucks it was re-measuring for are the same height now
|
|
// whether there is an amount in them or not.
|
|
const state = table();
|
|
const quiet = signatureOf(state);
|
|
state.seats[1].committed = 10;
|
|
assert.equal(signatureOf(state), quiet,
|
|
"the amount pill holds its row whether or not it has an amount in it");
|
|
state.seats[2].committed = 250;
|
|
assert.equal(signatureOf(state), quiet);
|
|
});
|
|
|
|
test("nor when an amount merely changes size", () => {
|
|
const state = table();
|
|
state.seats[1].committed = 10;
|
|
const small = signatureOf(state);
|
|
state.seats[1].committed = 999999;
|
|
assert.equal(signatureOf(state), small,
|
|
"a wider number is the amount fitter's problem, not the ring's");
|
|
});
|
|
|
|
test("and it stands still for a rename", () => {
|
|
// Renaming used to be able to wrap a name onto a second line. A name gets one
|
|
// line now, and one that does not fit drops a word or steps its type down — see
|
|
// fitSeatNames. Neither moves the row it sits in.
|
|
const state = table();
|
|
const base = signatureOf(state);
|
|
state.seats[0].name = "Bartholomew Fairweather";
|
|
assert.equal(signatureOf(state), base);
|
|
});
|
|
|
|
test("and for the button moving round the table", () => {
|
|
// The chip in the puck's corner takes 22px off the name's line. That is a
|
|
// narrower name, not a taller puck, so the ring it sits on is the same ring.
|
|
const state = table();
|
|
const base = signatureOf(state);
|
|
state.dealerSeatIndex = (state.dealerSeatIndex + 1) % state.seats.length;
|
|
assert.equal(signatureOf(state), base);
|
|
});
|
|
});
|
|
|
|
describe("a table nobody has sat down at", () => {
|
|
test("carries no flag at all", () => {
|
|
// The empty-table case is the one the collapse is most visible on, and it is
|
|
// also the one where a stray default could quietly keep the row open forever.
|
|
const state = HoldemState.createDefault();
|
|
HoldemState.resizeSeats(state, 9);
|
|
assert.equal(signatureOf(state), "",
|
|
"an untouched table should carry nothing");
|
|
});
|
|
|
|
test("and neither does one with an ante on it", () => {
|
|
// computeContribution answers for a seat that has never been given a stack — the
|
|
// table's base pot stands in for it — so a table dealt with "everyone antes 5"
|
|
// has amounts on its pucks before anybody acts. That used to have to be in here.
|
|
// The pucks are the same height with it as without, so it does not any more.
|
|
const state = HoldemState.createDefault();
|
|
HoldemState.resizeSeats(state, 6);
|
|
state.basePotAmount = 5;
|
|
state.seats.forEach(p => { p.inHand = true; });
|
|
assert.equal(signatureOf(state), "");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// The other half: the stylesheet has to actually hold those rows open, or the signature
|
|
// above is a promise about a puck that doesn't exist.
|
|
//
|
|
// Read as text, the way the seat sheet's rules and the reduced-motion rules are. What
|
|
// height a row is, is a decision that lives in the file — the browser is where it is
|
|
// proved, and runUiAudit.js is what proves it there.
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const CSS = fs.readFileSync(
|
|
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
|
|
|
const ruleFor = selector => {
|
|
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const found = CSS.match(new RegExp(`(^|[},])\\s*${escaped}\\s*\\{([^}]*)\\}`, "m"));
|
|
return found ? found[2] : null;
|
|
};
|
|
|
|
describe("every puck on the ring is the same puck", () => {
|
|
test("each of the three rows is a row high whether or not it says anything", () => {
|
|
// A blank label had no height at all, so the one puck with nobody in it was the
|
|
// one puck a different size from the rest — and a seat that had put chips in was
|
|
// a different size again from one that hadn't.
|
|
["name", "total", "stack"].forEach(row => {
|
|
const selector = `.seatPuck${row[0].toUpperCase()}${row.slice(1)}`;
|
|
const body = ruleFor(selector) || "";
|
|
assert.match(body, /min-height:\s*var\(--seatRow(Name|Money)\)/,
|
|
`${selector} does not hold its row open`);
|
|
});
|
|
});
|
|
|
|
test("and the row heights are tokens, not ems", () => {
|
|
// fitAmount steps a tournament stack's type down so every digit stays on the
|
|
// puck, and .puckNameTight does the same to a name with no word to drop. In ems
|
|
// each of those would have taken its row down with it, and the puck with it.
|
|
["--seatRowName", "--seatRowMoney"].forEach(token =>
|
|
assert.match(CSS, new RegExp(`\\${token}:\\s*\\d+px`), `${token} is not a fixed height`));
|
|
});
|
|
|
|
test("the amount pill hides its paint rather than giving up its place", () => {
|
|
// Collapsing it is what made a puck grow 17px when its player called and shrink
|
|
// again when the street closed. Painting an empty pill is what collapsing it was
|
|
// introduced to stop — an 8x11px green lozenge on seven of nine seats, standing
|
|
// for nothing. Neither, now: the row stays and the pill is not drawn.
|
|
assert.match(ruleFor(".seatPuckTotal:empty") || "", /visibility:\s*hidden/);
|
|
assert.doesNotMatch(ruleFor(".seatPuckTotal:empty") || "", /display:\s*none/);
|
|
});
|
|
|
|
test("but the hole-card row still collapses, because a table decides that once", () => {
|
|
// Holding 15px open on nine seats all night for a row most tables never fill is
|
|
// the crowding this is meant to avoid, and it is the one thing left that the
|
|
// signature above still watches for.
|
|
assert.match(ruleFor(".seatPuckCards:empty") || "", /display:\s*none/);
|
|
});
|
|
|
|
test("an empty pill sits on its top edge, not on a baseline it hasn't got", () => {
|
|
// An inline-block sits on its last line's baseline, and an empty one has no last
|
|
// line — so it sits on its bottom edge instead and the line box comes out 2px
|
|
// shorter than the same pill with a figure in it. That put the seats with money
|
|
// out 2px below everyone else: the one difference this row exists to remove,
|
|
// reappearing as its own mirror image.
|
|
assert.match(ruleFor(".seatPuckTotal") || "", /vertical-align:\s*top/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("and the ring checks its own measurement afterwards", () => {
|
|
// The signature above is an assumption about what can change a puck. This is what
|
|
// happens when the assumption is wrong, and it is also what happens on the first
|
|
// render of all: buildSeatNodes puts nine empty pucks on the felt and the search runs
|
|
// before anything is written into them, so it was measuring nine blank boxes and
|
|
// caching the answer under a key naming a table it had never seen.
|
|
//
|
|
// That used to be permanent. The comment where it was cached said "let the resize
|
|
// that follows do this properly" — and nothing resizes a phone. A table opened
|
|
// upright kept a ring measured for empty seats until the dealer turned the phone
|
|
// sideways and back, which is the only thing that ever changed the key.
|
|
const withBox = (box, run) => {
|
|
const before = Render.measuredSeatBox;
|
|
Render.measuredSeatBox = () => box;
|
|
try { return run(); } finally { Render.measuredSeatBox = before; }
|
|
};
|
|
|
|
test("a layout that measured nothing is always asked again", () => {
|
|
// No box on the answer means the search never had a puck in front of it.
|
|
assert.equal(withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved(null)), true);
|
|
assert.equal(withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved(undefined)), true);
|
|
});
|
|
|
|
test("one that measured the puck that is now on the felt is not", () => {
|
|
assert.equal(
|
|
withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved({ width: 72, height: 56 })),
|
|
false);
|
|
});
|
|
|
|
test("and a pixel of difference is a rounding artefact, not a table that moved", () => {
|
|
// The two measurements are taken at different moments. Without the slack this
|
|
// would ask the search to run again on every render for the rest of the session,
|
|
// which is ten forced layouts a tap — the exact cost the cache exists to avoid.
|
|
assert.equal(
|
|
withBox({ width: 72.4, height: 56.4 }, () => Render.seatBoxMoved({ width: 72, height: 56 })),
|
|
false);
|
|
assert.equal(
|
|
withBox({ width: 72, height: 58 }, () => Render.seatBoxMoved({ width: 72, height: 56 })),
|
|
true, "two pixels taller is a puck that has changed shape");
|
|
});
|
|
|
|
test("nothing on the felt to measure is not a reason to ask again", () => {
|
|
// Between a table being resized and the new pucks being built there is a moment
|
|
// with no seats at all. Asking again there would clear a good answer and replace
|
|
// it with one measured from nothing.
|
|
assert.equal(withBox(null, () => Render.seatBoxMoved({ width: 72, height: 56 })), false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("how much room a figure has on the puck it is written on", () => {
|
|
// The two steps of type an amount comes down used to be chosen by counting
|
|
// characters: eight for the first, ten for the second. That is a guess at how wide a
|
|
// figure is, and it happens to hold at one text size and one puck width.
|
|
//
|
|
// Turn a phone's text up — 20px of root text is what an Android slider gives you at
|
|
// 125%, and every size in the stylesheet is in `rem` on purpose — and it stops
|
|
// holding. Measured at 360x640 twelve-handed: the puck is at its 44px floor, which
|
|
// is 34px of usable width, and "$1,200" is six characters, took no step at all, and
|
|
// wanted 38. The sweep caught it as a stack figure painted over the seat next door
|
|
// at every phone in the list, and as "ALL IN" doing the same on the all-in table.
|
|
//
|
|
// So it is measured. A cell is a stand-in for the real one: what it wants against
|
|
// what it has, and a class list to write the answer into.
|
|
const cell = (figure, wants, has) => ({
|
|
dataset: { figure },
|
|
textContent: figure,
|
|
classes: new Set(),
|
|
classList: {
|
|
add(name) { this.owner.classes.add(name); },
|
|
remove(name) { this.owner.classes.delete(name); },
|
|
},
|
|
// What the text wants is proportional to how many characters are in it and how
|
|
// far down the ladder it has come — which is what a browser would tell us.
|
|
get scrollWidth() {
|
|
const step = this.classes.has("puckAmountHuge") ? 0.7
|
|
: this.classes.has("puckAmountLong") ? 0.85 : 1;
|
|
return Math.round((wants * this.textContent.length / figure.length) * step);
|
|
},
|
|
clientWidth: has,
|
|
});
|
|
|
|
const make = (...args) => {
|
|
const c = cell(...args);
|
|
c.classList.owner = c;
|
|
return c;
|
|
};
|
|
|
|
const fit = cells => {
|
|
const before = Render.overflowing;
|
|
Render.overflowing = el => el.scrollWidth > el.clientWidth + 1;
|
|
try { Render.fitAmounts(cells); } finally { Render.overflowing = before; }
|
|
};
|
|
|
|
test("a figure that fits is left at the size it is meant to be read at", () => {
|
|
const c = make("$600", 30, 34);
|
|
fit([c]);
|
|
assert.equal(c.textContent, "$600");
|
|
assert.deepEqual([...c.classes], []);
|
|
});
|
|
|
|
test("one that doesn't gives up its separators first", () => {
|
|
// Grouping is a reading aid for a column of figures. This is one number on a
|
|
// 44px puck, and a step of type costs more than the commas do.
|
|
const c = make("$1,200", 38, 34);
|
|
fit([c]);
|
|
assert.equal(c.textContent, "$1200");
|
|
assert.deepEqual([...c.classes], [], "no type step needed once the commas are out");
|
|
});
|
|
|
|
test("then a step of type, and then another", () => {
|
|
const c = make("$1,234,567", 90, 34);
|
|
fit([c]);
|
|
assert.equal(c.textContent, "$1234567");
|
|
assert.deepEqual([...c.classes], ["puckAmountHuge"]);
|
|
});
|
|
|
|
test("and only ever one step at a time is worn", () => {
|
|
// The two rules have the same specificity, so a puck wearing both would be sized
|
|
// by whichever the stylesheet happens to mention last.
|
|
const c = make("$1,234,567", 45, 34);
|
|
fit([c]);
|
|
assert.equal(c.classes.has("puckAmountHuge") && c.classes.has("puckAmountLong"), false);
|
|
});
|
|
|
|
test("a figure with no separators to give goes straight to the type", () => {
|
|
// "ALL IN" is the one thing on this row that isn't a number, and it is the widest
|
|
// — six characters and a space, at 700 weight with its letters spread apart.
|
|
const c = make("ALL IN", 39, 34);
|
|
fit([c]);
|
|
assert.equal(c.textContent, "ALL IN");
|
|
assert.deepEqual([...c.classes], ["puckAmountLong"]);
|
|
});
|
|
|
|
test("a puck that went small can come back", () => {
|
|
// Everything is measured from the figure and the size it is meant to be read at,
|
|
// not from whatever the last render left behind. Otherwise this is a ratchet: one
|
|
// crowded frame and the seat is small for the rest of the night.
|
|
const c = make("$600", 30, 34);
|
|
c.classes.add("puckAmountHuge");
|
|
c.textContent = "$600";
|
|
fit([c]);
|
|
assert.deepEqual([...c.classes], []);
|
|
});
|
|
|
|
test("and the grouped figure is what it comes back to", () => {
|
|
const c = make("$1,200", 38, 34);
|
|
fit([c]);
|
|
assert.equal(c.textContent, "$1200");
|
|
c.clientWidth = 96;
|
|
fit([c]);
|
|
assert.equal(c.textContent, "$1,200", "a wider puck should get its separators back");
|
|
});
|
|
|
|
test("an empty row is not an amount", () => {
|
|
// The pill keeps its place on a seat with nothing in the pot — that is what makes
|
|
// every puck on the ring the same puck — and there is nothing there to fit.
|
|
const c = make("", 0, 34);
|
|
assert.doesNotThrow(() => fit([c]));
|
|
assert.deepEqual([...c.classes], []);
|
|
});
|
|
});
|