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
341 lines
18 KiB
JavaScript
341 lines
18 KiB
JavaScript
// Drives uiAudit.js over the dealer's surfaces at every viewport worth caring about.
|
|
//
|
|
// node tests/holdem/runUiAudit.js [baseUrl]
|
|
//
|
|
// Needs Playwright available (`npx playwright install chromium` once). It is
|
|
// deliberately NOT wired into the unit-test suite or CI, because that suite is
|
|
// dependency-free and runs in a second — this one needs a browser and a running
|
|
// site. Run it after any change that adds or resizes a control.
|
|
//
|
|
// No Playwright to hand? Open the page, paste tests/holdem/uiAudit.js into devtools,
|
|
// and call LayoutAudit.runAll(). Same checks, one viewport.
|
|
const path = require("path");
|
|
|
|
const BASE = process.argv[2] || "http://localhost:5299";
|
|
const AUDIT = path.join(__dirname, "uiAudit.js");
|
|
|
|
// Portrait phones, tablets, desktops, then the same phones on their side — landscape
|
|
// is where this layout has broken most often, because the table is sized from width
|
|
// and the action bar is fixed to a very short viewport.
|
|
const VIEWPORTS = [
|
|
[360, 640, "Galaxy S8"], [375, 667, "iPhone SE"], [390, 844, "iPhone 14"],
|
|
[412, 915, "Pixel 7"], [430, 932, "iPhone Pro Max"],
|
|
[768, 1024, "iPad"], [834, 1112, "iPad Air"], [1024, 1366, "iPad Pro"],
|
|
[1280, 900, "desktop"], [1600, 1000, "wide desktop"],
|
|
[640, 360, "S8 landscape"], [667, 375, "SE landscape"], [844, 390, "iPhone landscape"],
|
|
];
|
|
|
|
const OVERLAYS = [
|
|
["rulings", () => document.getElementById("openRulingsBtn").click()],
|
|
["seat menu", () => SeatMenu.open(HoldemState.load(), {}, 0)],
|
|
["card picker", () => { document.getElementById("cardPickerOverlay").hidden = false; }],
|
|
["add chips", () => AddChips.open(HoldemState.load(), 0, () => {})],
|
|
["pot wizard", () => document.getElementById("resolvePotBtn").click()],
|
|
];
|
|
|
|
// A table part-way through a real night: long names, uneven stacks, a hand log.
|
|
//
|
|
// Two of them. The sweep ran on one table shape for its whole life — five players at a
|
|
// nine-handed table — and how much room the felt has depends on how many people are
|
|
// sitting down more than on anything a viewport can tell you. A full table is where the
|
|
// seats crowd, and it was never once looked at: measured when it finally was, seventeen
|
|
// of fifty-four tables had one player's name or stack painted under another.
|
|
const PACKED = [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
|
["Bob", 250], ["Dee", 600], ["Sam", 900], ["Priya", 1100], ["Jonathan", 480],
|
|
["Al", 2000], ["Katarzyna", 340], ["Ted", 760], ["Wes", 1300]];
|
|
|
|
const TABLES = [
|
|
["half full", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
|
["Bob", 250], ["Dee", 600]], false],
|
|
["packed", 12, PACKED],
|
|
// The same twelve, every one of them all-in for a different amount. This is the
|
|
// shape that makes a pot layer per player, and the sweep had never reached it:
|
|
// every table here was driven with Call and Check, so the side-pot breakdown — the
|
|
// one thing on the bar whose height depends on the table — was empty at all 294
|
|
// audits. Measured when it finally was: the bar took 411px of a 640px phone and
|
|
// left 96px of felt for twelve seats.
|
|
["all in", 12, PACKED, false, 12],
|
|
// The half-full table again, with the board turned on and five cards on it. The
|
|
// board is furniture in the middle of the felt that nothing else was ever measured
|
|
// against — and where the middle cannot hold it, it moves to a strip above the
|
|
// table and takes the felt's height with it, which is the seats' problem too.
|
|
//
|
|
// Deliberately the same five players in the same nine chairs as the shape above, so
|
|
// the only thing that differs between them is the board. A seventh player would have
|
|
// told us that a seven-handed table crowds, which is true, already known, and not
|
|
// what this row is here to find out.
|
|
["board", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
|
["Bob", 250], ["Dee", 600]], true],
|
|
];
|
|
|
|
function seedTable([seats, players, board]) {
|
|
const state = HoldemState.createDefault();
|
|
HoldemState.resizeSeats(state, seats);
|
|
players.forEach(([name, cash], i) => {
|
|
state.seats[i].name = name;
|
|
state.seats[i].inHand = true;
|
|
state.seats[i].buyIns = cash;
|
|
state.seats[i].stack = cash;
|
|
});
|
|
state.smallBlind = 5;
|
|
state.bigBlind = 10;
|
|
for (let hand = 1; hand <= 4; hand++) {
|
|
state.handHistory.push({
|
|
handNumber: hand, timestamp: Date.now(), pot: 120,
|
|
payouts: [{ id: state.seats[hand % players.length].id, label: "P", amount: 120 }],
|
|
ledger: state.seats.slice(0, players.length)
|
|
.map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
|
|
});
|
|
}
|
|
state.handNumber = 5;
|
|
if (board) {
|
|
state.trackBoard = true;
|
|
state.board = ["KD", "9C", "4S", "AH", "2H"];
|
|
}
|
|
HoldemState.save(state);
|
|
}
|
|
|
|
async function main() {
|
|
let chromium;
|
|
try {
|
|
({ chromium } = require("playwright"));
|
|
} catch {
|
|
console.error("This script needs Playwright: npm i -D playwright && npx playwright install chromium");
|
|
console.error("Or paste tests/holdem/uiAudit.js into devtools and call LayoutAudit.runAll().");
|
|
process.exit(2);
|
|
}
|
|
|
|
const browser = await chromium.launch();
|
|
const failures = [];
|
|
const warnings = [];
|
|
let checks = 0;
|
|
|
|
// Three passes over the same surfaces.
|
|
//
|
|
// A mouse and a thumb, because the 44px rule stands itself down where `hover: hover`
|
|
// says there's a pointer, and a headless desktop browser is a pointer — so a single
|
|
// pass has never once measured a touch target, however many viewports it visited.
|
|
//
|
|
// And a thumb on a phone whose owner has turned the text up. Every length in this
|
|
// stylesheet that isn't a structural clearance is in `rem`, which is the right way
|
|
// round and means the whole page follows the browser's font size — including out of
|
|
// the side of the window, which is what it was doing: at 20px, which is the 125%
|
|
// an Android text-size slider gives you, "Resolve Pot" ran 23px off the right of a
|
|
// 360px phone and took the document's scroll width with it. Every check in the
|
|
// audit already knew how to catch that; not one of them had ever been run at a size
|
|
// anybody actually sets.
|
|
//
|
|
// One shape it is not held to, and the arithmetic for why. Twelve players all-in on
|
|
// a 360x640 phone is the tallest the action bar ever gets — the status block and the
|
|
// pot breakdown both grow with the text — and at 20px it stands 283px of the 640,
|
|
// which with the header leaves the felt 290x163 where it had 296x262. Twelve pucks
|
|
// 44px wide (the floor a thumb needs; there is nothing narrower to go to) and 62px
|
|
// tall do not fit around an ellipse 163px deep: two per side is all the height there
|
|
// is, and the sweep reports it as seats 9, 10 and 11 written across each other.
|
|
//
|
|
// That is a geometry with no answer rather than a fault with a fix, so it is
|
|
// reported as a note every run instead of a failure every run. A sweep that is
|
|
// always red stops being read — the same reasoning as the 30% furniture warning.
|
|
const CROWDED_BEYOND_HELP = "all in";
|
|
const LARGE_TEXT_PX = 20;
|
|
const PHONES = VIEWPORTS.filter(([w, h]) => Math.min(w, h) <= 430);
|
|
const PASSES = [
|
|
{ hand: "mouse", touch: false, viewports: VIEWPORTS },
|
|
{ hand: "touch", touch: true, viewports: VIEWPORTS },
|
|
{ hand: "large text", touch: true, viewports: PHONES, rootPx: LARGE_TEXT_PX, soft: CROWDED_BEYOND_HELP },
|
|
];
|
|
|
|
for (const { hand, touch, viewports, rootPx, soft } of PASSES) {
|
|
const context = await browser.newContext(touch
|
|
? { hasTouch: true, isMobile: true, deviceScaleFactor: 3 }
|
|
: {});
|
|
// Before any of the page's own scripts, so its listener is registered first and
|
|
// the size is in place before main.js measures anything. A stylesheet rather
|
|
// than an inline style because that is what a browser font-size setting is: the
|
|
// root's used size, from first paint, not something applied afterwards.
|
|
if (rootPx) {
|
|
await context.addInitScript((px) => {
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const style = document.createElement("style");
|
|
style.textContent = `html { font-size: ${px}px }`;
|
|
document.head.appendChild(style);
|
|
}, { once: true });
|
|
}, rootPx);
|
|
}
|
|
const page = await context.newPage();
|
|
|
|
for (const [width, height, rawLabel] of viewports) {
|
|
await page.setViewportSize({ width, height });
|
|
|
|
for (const [shape, seats, players, board, shoves] of TABLES) {
|
|
const label = `${rawLabel} · ${shape} · ${hand}`;
|
|
// Measured either way; where the shape has no answer at this text size,
|
|
// said in the notes rather than counted against the run.
|
|
const record = (where, offenders) => {
|
|
if (!offenders.length) return;
|
|
if (shape === soft) warnings.push(`${where}: ${offenders.join("; ")}`);
|
|
else failures.push({ where, offenders });
|
|
};
|
|
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
|
await page.evaluate(seedTable, [seats, players, board]);
|
|
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
|
await page.click("#nextHandBtn");
|
|
await page.waitForTimeout(300);
|
|
// Deal it, and get chips in front of a few players. The sweep used to
|
|
// audit a table that had never had a bet on it, so the bets layer was
|
|
// empty at every viewport it visited and nothing it drew was ever looked
|
|
// at — which is a third of what is on the felt during a hand.
|
|
await page.evaluate(() => {
|
|
const deal = [...document.querySelectorAll("#actionBar button")]
|
|
.find(b => /post blinds/i.test(b.textContent));
|
|
if (deal) deal.click();
|
|
});
|
|
await page.waitForTimeout(220);
|
|
// Everyone's whole stack in, which is what stacks up the side pots.
|
|
// There is no one-tap shove: a player who can't cover the bet gets
|
|
// "Call all-in", and anyone else has to open the raise pad, take its
|
|
// All-in preset and confirm.
|
|
for (let i = 0; i < (shoves || 0); i++) {
|
|
const step = await page.evaluate(() => {
|
|
const find = re => [...document.querySelectorAll("#actionBar button")]
|
|
.find(b => !b.disabled && re.test(b.textContent.trim()));
|
|
const short = find(/^Call all-in/);
|
|
if (short) { short.click(); return "done"; }
|
|
const open = find(/^Raise|^Bet/);
|
|
if (!open) return null;
|
|
open.click();
|
|
return "pad";
|
|
});
|
|
if (!step) break;
|
|
await page.waitForTimeout(120);
|
|
if (step === "pad") {
|
|
await page.evaluate(() => {
|
|
const find = re => [...document.querySelectorAll("#actionBar button")]
|
|
.find(b => !b.disabled && re.test(b.textContent.trim()));
|
|
const max = find(/^All-in/);
|
|
if (max) max.click();
|
|
const go = find(/^Raise to|^Bet/);
|
|
if (go) go.click();
|
|
});
|
|
await page.waitForTimeout(120);
|
|
}
|
|
}
|
|
for (let i = 0; i < (shoves ? 0 : 3); i++) {
|
|
const acted = await page.evaluate(() => {
|
|
const btn = [...document.querySelectorAll("#actionBar button")]
|
|
.find(b => /^(Call|Check)/.test(b.textContent.trim()) && !b.disabled);
|
|
if (!btn) return false;
|
|
btn.click();
|
|
return true;
|
|
});
|
|
if (!acted) break;
|
|
await page.waitForTimeout(120);
|
|
}
|
|
if (board) {
|
|
await page.evaluate(() => {
|
|
const s = HoldemState.load();
|
|
s.street = "river";
|
|
s.board = ["KD", "9C", "4S", "AH", "2H"];
|
|
s.seats.filter(p => p.inHand).forEach((p, i) => {
|
|
p.cards = [["AS", "KC"], ["AC", "3D"], ["9H", "9S"],
|
|
["7C", "5D"], ["JD", "TS"]][i] || ["", ""];
|
|
});
|
|
HoldemState.save(s);
|
|
});
|
|
await page.reload();
|
|
await page.waitForTimeout(300);
|
|
}
|
|
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
|
|
await page.addScriptTag({ path: AUDIT });
|
|
|
|
const table = await page.evaluate(() => LayoutAudit.runAll());
|
|
checks++;
|
|
record(`table · ${label} ${width}x${height}`, table.failed.flatMap(f => f.offenders));
|
|
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
|
|
|
|
// The raise pad, which is part of the bar rather than an overlay and so
|
|
// was never once measured by any of this. It is the tallest thing the
|
|
// bar ever holds and the only place in the tool a dealer types an
|
|
// amount, and it had a 36px-tall field on every phone in the list: the
|
|
// `hover: none` rule that sizes fields for a thumb names the settings
|
|
// panels and the seat form, and the one field on the thumb surface was
|
|
// not in the list. Nothing here could see that, because nothing here
|
|
// ever opened it.
|
|
if (await page.evaluate(() => {
|
|
const btn = [...document.querySelectorAll("#actionBar button")]
|
|
.find(b => /^Raise|^Bet/.test(b.textContent.trim()) && !b.disabled);
|
|
if (!btn) return false;
|
|
btn.click();
|
|
return true;
|
|
})) {
|
|
await page.waitForTimeout(200);
|
|
checks++;
|
|
const pad = await page.evaluate(() => LayoutAudit.runAll({
|
|
occlusionSelector: "#actionBar button, #actionBar input",
|
|
}));
|
|
record(`raise pad · ${label} ${width}x${height}`,
|
|
pad.failed.flatMap(f => f.offenders));
|
|
await page.evaluate(() => {
|
|
const cancel = [...document.querySelectorAll("#actionBar button")]
|
|
.find(b => /^Cancel/.test(b.textContent.trim()));
|
|
if (cancel) cancel.click();
|
|
});
|
|
await page.waitForTimeout(150);
|
|
}
|
|
|
|
// The overlays don't change with the table's shape, so they are only
|
|
// opened over the half-full one — twice the viewports is enough time
|
|
// spent already.
|
|
if (shape !== TABLES[0][0]) continue;
|
|
|
|
for (const [name, open] of OVERLAYS) {
|
|
await page.evaluate(open);
|
|
await page.waitForTimeout(180);
|
|
checks++;
|
|
const result = await page.evaluate(() => {
|
|
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
|
|
if (!overlay) return { offenders: [] };
|
|
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
|
|
});
|
|
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
|
|
record(`${name} · ${label} ${width}x${height}`, result.offenders);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [width, height, rawLabel] of viewports.filter((_, i) => i % 4 === 0)) {
|
|
const label = `${rawLabel} · ${hand}`;
|
|
await page.setViewportSize({ width, height });
|
|
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
|
|
await page.evaluate(() => {
|
|
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
|
|
if (button) button.click();
|
|
});
|
|
await page.addScriptTag({ path: AUDIT });
|
|
checks++;
|
|
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
|
|
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
|
|
result.warnings.forEach(w => warnings.push(`quick split · ${label}: ${w}`));
|
|
}
|
|
|
|
await context.close();
|
|
}
|
|
|
|
await browser.close();
|
|
|
|
console.log(`${checks} audits across ${VIEWPORTS.length} viewports — with a mouse, with a thumb, ` +
|
|
`and with a thumb at ${LARGE_TEXT_PX}px of root text on ${PHONES.length} phones`);
|
|
warnings.forEach(w => console.log(` note: ${w}`));
|
|
if (failures.length === 0) {
|
|
console.log("clean");
|
|
return;
|
|
}
|
|
failures.forEach(f => {
|
|
console.log(`\nFAIL ${f.where}`);
|
|
f.offenders.forEach(o => console.log(` ${o}`));
|
|
});
|
|
process.exit(1);
|
|
}
|
|
|
|
main();
|