Stop a chip colour taking the table's money with it, quietly
Deleting a chip colour was one click of an unlabelled 24px button, and it was never a change to a legend. computeContribution() prices a seat by running the legend over that seat's chip counts, so a colour that leaves takes every chip counted in it off the table. Measured in a browser on a three-handed table: deleting the $500 row took $22,500 of $26,874 with it. No confirmation, nothing said afterwards, and no way back — a colour added again is a new colour with a new id, while the counts stay keyed to the one that left. The counts were left behind too, so the saved file carried money nothing could price and nothing could show. Now: a colour nobody has counted still goes on one click, and one that is holding money asks first, naming the amount, the chips and the seats, and saying that Undo will put it back while adding the colour again will not. Conditional on purpose — a confirmation that fires every time is one nobody reads by the third night, and four others already compete for that attention here. Removal goes through runUndoable, and the counts go with the colour so the file matches the screen. The number in that warning is the difference between pricing every seat with the colour and without it, not chips times value. The obvious arithmetic is wrong both ways: a seat with a typed amount is never priced from its counts, and a seat whose chips all leave falls through to the table's standing ante rather than to zero. A randomised check over 2,000 tables asserts the warning equals what the table then does, and it found a third case — fourteen $1 chips against a $200 ante, where removing the colour puts the table UP $186. The wording says so in that direction too. The × also had no accessible name, so uiAudit.js gained controlNames(): every control needs a name containing a letter or a digit, resolved the way the accessibility tree resolves it. A symbol is not a name. Taking the new aria-label off again makes it report all five buttons. Audit unchanged at 2 failing configurations and 3 offenders, both pre-existing. 805 unit tests pass. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -26,6 +26,7 @@ other in the page, in the load order from `TexasHoldem.cshtml`.
|
||||
| `feltShape.test.js` | What shape the felt is at each phone size, resolved through the media queries in source order (see below) |
|
||||
| `potBreakdown.test.js` | How each pot says who can win it, and the nesting property that wording rests on (see below) |
|
||||
| `sidebarPanels.test.js` | What the sidebar's panels are called and what lives in each (see below) |
|
||||
| `chipLegend.test.js` | Removing a chip colour, and whether the warning's number is the one the table loses (see below) |
|
||||
| `invariants.test.js` | 1000 randomised hands per property (see below) |
|
||||
|
||||
## The layout audit
|
||||
@@ -245,6 +246,52 @@ The panels are split on the opening tag rather than parsed, which is sound only
|
||||
sidebar panel never contains another one; Table Setup's sub-panels carry a different
|
||||
class. That assumption is worth knowing about if one ever does.
|
||||
|
||||
## A warning is only worth having if its number is right
|
||||
|
||||
`chipLegend.test.js` covers removing a chip colour, which was one click of an unlabelled
|
||||
24px button and was never a change to a legend. `computeContribution()` prices a seat by
|
||||
running the legend over that seat's chip counts, so a colour that leaves takes every chip
|
||||
counted in it off the table. Measured in a browser on a three-handed table: deleting the
|
||||
$500 row took **$22,500 of $26,874** with it, with no confirmation, nothing said
|
||||
afterwards, and no way back — a colour added again is a new colour with a new id, while
|
||||
the counts stay keyed to the one that left.
|
||||
|
||||
Most of the file is about the number in the warning rather than the removal, because a
|
||||
warning that overstates is worse than none. The obvious way to work it out — chips times
|
||||
value — is wrong in **both** directions:
|
||||
|
||||
- a seat with a typed amount is priced from that and its counts are never read, so a
|
||||
colour it holds is worth nothing to it;
|
||||
- a seat whose chips all leave falls through to the table's standing ante rather than to
|
||||
zero, so it loses less than its chips were worth.
|
||||
|
||||
So the figure is the difference between pricing every seat with the colour and without
|
||||
it, and a randomised check across 2,000 tables asserts that what the warning claims is
|
||||
exactly what the table then does. That check found a third case nobody would have written
|
||||
by hand: fourteen $1 chips against a $200 ante, where removing the colour puts the table
|
||||
**up** $186. The wording says so in that direction too.
|
||||
|
||||
The friction is conditional on purpose. A colour nobody has counted deletes on one click;
|
||||
only one that is holding money asks. A confirmation that fires every time is one nobody
|
||||
reads by the third night, and this is a tool where four other confirmations already
|
||||
compete for that attention.
|
||||
|
||||
## Every control says what it is
|
||||
|
||||
`uiAudit.js` gained `controlNames()`, which is the general form of the bug above: the ×
|
||||
had no accessible name, so it was announced as a button and nothing else — five of them
|
||||
in a column, identical, each deleting a different colour.
|
||||
|
||||
It treats a symbol as no name even though `×` is technically text. The rule is that a
|
||||
name has to contain a letter or a digit, which is the difference between "×" and "Undo".
|
||||
Names are resolved the way the accessibility tree does it — `aria-label`, then
|
||||
`aria-labelledby`, then a field's wrapping or pointing `<label>`, then placeholder, then
|
||||
text content, then an image's `alt`, then `title` — and anything under `aria-hidden` is
|
||||
skipped rather than judged, since it is not in the tree to begin with.
|
||||
|
||||
Removing the one `aria-label` this round added makes it report all five buttons, which is
|
||||
how it was checked.
|
||||
|
||||
## Why the hand evaluator is tested the way it is
|
||||
|
||||
`handRank.js` is the only module here that can pay the wrong person without anybody
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Taking a chip colour out of the legend.
|
||||
//
|
||||
// This was one click of an unlabelled 24px button, and it was not a change to a legend.
|
||||
// computeContribution() prices a seat by running the legend over that seat's chip
|
||||
// counts, so a colour that leaves takes every chip counted in it off the table.
|
||||
// Measured in a browser on a three-handed table: deleting the $500 row took $22,500 of
|
||||
// $26,874 with it, with no confirmation, nothing said afterwards, and no way back —
|
||||
// a colour added again is a new colour with a new id, while the counts stay keyed to
|
||||
// the one that left. The counts were left behind too, so the saved file carried money
|
||||
// that nothing could price and nothing could show.
|
||||
//
|
||||
// What is tested here is mostly the number in the warning, because a warning that
|
||||
// overstates is worse than none: it is the one thing a dealer would have to trust at
|
||||
// the exact moment they are about to lose track of the box.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Money } = require("./load");
|
||||
|
||||
const tableWith = ({ counts = [], overrides = [], basePot = null } = {}) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
state.basePotAmount = basePot;
|
||||
["Christopher", "Mike from work", "Alexandra"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].buyIns = 500;
|
||||
state.seats[i].chipCounts = {};
|
||||
});
|
||||
counts.forEach(([seat, chipIndex, n]) => {
|
||||
state.seats[seat].chipCounts[state.chipLegend[chipIndex].id] = n;
|
||||
});
|
||||
overrides.forEach(([seat, amount]) => { state.seats[seat].amountOverride = amount; });
|
||||
return state;
|
||||
};
|
||||
|
||||
const onTable = state => state.seats.reduce(
|
||||
(sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what a chip colour is holding up", () => {
|
||||
test("is the money, named seat by seat", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4], [1, 2, 10]] }); // the $25 chip
|
||||
const usage = HoldemState.chipColorUsage(state, state.chipLegend[2].id);
|
||||
|
||||
assert.equal(usage.total, 350);
|
||||
assert.equal(usage.chips, 14);
|
||||
assert.deepEqual(usage.seats.map(s => [s.name, s.count, s.amount]), [
|
||||
["Christopher", 4, 100],
|
||||
["Mike from work", 10, 250],
|
||||
]);
|
||||
});
|
||||
|
||||
test("is nothing at all for a colour nobody counted", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4]] });
|
||||
const usage = HoldemState.chipColorUsage(state, state.chipLegend[4].id);
|
||||
assert.equal(usage.total, 0);
|
||||
assert.deepEqual(usage.seats, []);
|
||||
assert.ok(usage.chip, "the colour itself is still found, so it can be named");
|
||||
});
|
||||
|
||||
test("and nothing for a colour that isn't there", () => {
|
||||
const state = tableWith();
|
||||
assert.deepEqual(HoldemState.chipColorUsage(state, "no-such-chip"),
|
||||
{ chip: null, total: 0, chips: 0, seats: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the number in the warning is the number the table loses", () => {
|
||||
// The whole point. Multiplying the chips by their value would have been the obvious
|
||||
// way to work this out and it is wrong in both directions — the two tests below are
|
||||
// the two ways it is wrong.
|
||||
const dropFromRemoving = (state, chipId) => {
|
||||
const before = onTable(state);
|
||||
HoldemState.removeChipColor(state, chipId);
|
||||
return Money.round(before - onTable(state));
|
||||
};
|
||||
|
||||
test("a seat with a typed amount ignores its chips, so its colour is worth nothing", () => {
|
||||
// computeContribution() reads amountOverride first and never reaches the counts.
|
||||
// Christopher is holding ten $25 chips on paper and priced at a flat $90.
|
||||
const state = tableWith({ counts: [[0, 2, 10]], overrides: [[0, 90]] });
|
||||
const chipId = state.chipLegend[2].id;
|
||||
|
||||
const usage = HoldemState.chipColorUsage(state, chipId);
|
||||
assert.equal(usage.total, 0, "$250 of chips, but the seat is not priced from them");
|
||||
assert.equal(dropFromRemoving(state, chipId), 0);
|
||||
});
|
||||
|
||||
test("a seat whose chips all leave falls back to the base pot, not to zero", () => {
|
||||
// The $25s are everything Christopher has counted, so taking them away drops him
|
||||
// to the table's standing ante — a loss of 50, not of 250.
|
||||
const state = tableWith({ counts: [[0, 2, 10]], basePot: 200 });
|
||||
const chipId = state.chipLegend[2].id;
|
||||
|
||||
const usage = HoldemState.chipColorUsage(state, chipId);
|
||||
assert.equal(usage.total, 50, "250 of chips, but only 50 of it actually leaves");
|
||||
assert.equal(dropFromRemoving(state, chipId), 50);
|
||||
});
|
||||
|
||||
test("and across a spread of tables the two always agree", () => {
|
||||
function rng(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
const random = rng(20260906);
|
||||
let withMoney = 0;
|
||||
|
||||
for (let table = 0; table < 2000; table++) {
|
||||
const counts = [];
|
||||
for (let seat = 0; seat < 3; seat++) {
|
||||
for (let chip = 0; chip < 5; chip++) {
|
||||
if (random() < 0.5) counts.push([seat, chip, 1 + Math.floor(random() * 20)]);
|
||||
}
|
||||
}
|
||||
const overrides = [];
|
||||
for (let seat = 0; seat < 3; seat++) {
|
||||
if (random() < 0.25) overrides.push([seat, Math.floor(random() * 400)]);
|
||||
}
|
||||
const basePot = random() < 0.5 ? 200 : null;
|
||||
|
||||
const chipIndex = Math.floor(random() * 5);
|
||||
const stated = tableWith({ counts, overrides, basePot });
|
||||
const chipId = stated.chipLegend[chipIndex].id;
|
||||
const claimed = HoldemState.chipColorUsage(stated, chipId).total;
|
||||
|
||||
const actual = dropFromRemoving(tableWith({ counts, overrides, basePot }), chipId);
|
||||
if (claimed !== 0) withMoney++;
|
||||
assert.equal(claimed, actual,
|
||||
`table ${table}: warned ${claimed}, table moved ${actual}\n` +
|
||||
JSON.stringify({ counts, overrides, basePot, chipIndex }));
|
||||
}
|
||||
|
||||
// Guards the generator: shapes that never put money on a colour would make every
|
||||
// assertion above compare zero with zero.
|
||||
assert.ok(withMoney > 500, `only ${withMoney} of 2000 tables had money on the colour`);
|
||||
});
|
||||
|
||||
test("and it can be a gain, which is said as one", () => {
|
||||
// Fourteen $1 chips against a $200 standing ante: once the colour goes, the seat
|
||||
// stops being priced from its chips and falls through to the ante, so the table
|
||||
// goes *up* by 186. Found by the randomised check above, not by hand.
|
||||
const state = tableWith({ counts: [[1, 0, 14]], basePot: 200 });
|
||||
const usage = HoldemState.chipColorUsage(state, state.chipLegend[0].id);
|
||||
assert.equal(usage.total, -186);
|
||||
assert.equal(dropFromRemoving(state, state.chipLegend[0].id), -186);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removing it", () => {
|
||||
test("takes the counts with it, so nothing is left priced by a colour that is gone", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4], [1, 2, 10], [1, 0, 3]] });
|
||||
const chipId = state.chipLegend[2].id;
|
||||
|
||||
HoldemState.removeChipColor(state, chipId);
|
||||
|
||||
assert.equal(state.chipLegend.filter(c => c.id === chipId).length, 0);
|
||||
state.seats.forEach(player => assert.ok(!(chipId in (player.chipCounts || {})),
|
||||
"a count against a colour no legend entry answers to is money nothing can show"));
|
||||
// The other colour on that seat is untouched.
|
||||
assert.equal(state.seats[1].chipCounts[state.chipLegend[0].id], 3);
|
||||
});
|
||||
|
||||
test("reports what it cost, measured before anything moved", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4]] });
|
||||
const usage = HoldemState.removeChipColor(state, state.chipLegend[2].id);
|
||||
assert.equal(usage.total, 100);
|
||||
assert.equal(usage.seats.length, 1);
|
||||
});
|
||||
|
||||
test("and a colour that isn't there changes nothing", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4]] });
|
||||
const before = onTable(state);
|
||||
const usage = HoldemState.removeChipColor(state, "no-such-chip");
|
||||
assert.equal(usage.chip, null);
|
||||
assert.equal(state.chipLegend.length, 5);
|
||||
assert.equal(onTable(state), before);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,70 @@ const LayoutAudit = {
|
||||
});
|
||||
},
|
||||
|
||||
// ---- Every control says what it is ----
|
||||
|
||||
// WCAG 4.1.2 wants a name on every control, and empty button names are among the
|
||||
// most common failures on the web — WebAIM's 2025 sweep found them on a bit over a
|
||||
// quarter of home pages. This tool had one: the × that removes a chip colour, five
|
||||
// of them in a column, identical, each deleting a different colour, and one click
|
||||
// of the wrong one took $22,500 off a table.
|
||||
//
|
||||
// A symbol counts as no name here even though it is technically text. "×" is
|
||||
// announced as "times" and names nothing — the rule is that a name has to contain a
|
||||
// letter or a digit, which is the difference between "×" and "Undo" or "+ Add chip
|
||||
// color". Anything with aria-hidden is not in the tree at all and is skipped rather
|
||||
// than judged.
|
||||
controlNames() {
|
||||
const named = el => {
|
||||
const aria = (el.getAttribute("aria-label") || "").trim();
|
||||
if (aria) return aria;
|
||||
|
||||
const by = el.getAttribute("aria-labelledby");
|
||||
if (by) {
|
||||
const text = by.split(/\s+/)
|
||||
.map(id => document.getElementById(id))
|
||||
.filter(Boolean)
|
||||
.map(node => (node.textContent || "").trim())
|
||||
.join(" ")
|
||||
.trim();
|
||||
if (text) return text;
|
||||
}
|
||||
|
||||
// A field's name usually comes from its label rather than from itself.
|
||||
if (/^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName)) {
|
||||
const wrapping = el.closest("label");
|
||||
const pointing = el.id ? document.querySelector(`label[for="${CSS.escape(el.id)}"]`) : null;
|
||||
const text = [wrapping, pointing]
|
||||
.filter(Boolean)
|
||||
.map(node => (node.textContent || "").trim())
|
||||
.join(" ")
|
||||
.trim();
|
||||
if (text) return text;
|
||||
const placeholder = (el.getAttribute("placeholder") || "").trim();
|
||||
if (placeholder) return placeholder;
|
||||
}
|
||||
|
||||
const own = (el.textContent || "").trim();
|
||||
if (own) return own;
|
||||
|
||||
const image = el.querySelector("img[alt]");
|
||||
if (image) return (image.getAttribute("alt") || "").trim();
|
||||
|
||||
return (el.getAttribute("title") || "").trim();
|
||||
};
|
||||
|
||||
const offenders = this.visibleControls()
|
||||
.filter(el => !el.closest("[aria-hidden='true']"))
|
||||
// A seat puck is in CONTROL_SELECTOR for the touch checks; its own name is
|
||||
// the player's, and an empty chair is deliberately unnamed.
|
||||
.filter(el => !el.classList.contains("seat"))
|
||||
.map(el => ({ el, name: named(el) }))
|
||||
.filter(({ name }) => !/[\p{L}\p{N}]/u.test(name))
|
||||
.map(({ el, name }) => `${this.describe(el)} :: ${name ? `named only "${name}"` : "no accessible name"}`);
|
||||
|
||||
return { name: "every control has a name a screen reader can read", offenders };
|
||||
},
|
||||
|
||||
// ---- Touch (see the mobile round) ----
|
||||
|
||||
// Without touch-action: manipulation a mobile browser holds every tap for ~300ms
|
||||
@@ -1080,6 +1144,7 @@ const LayoutAudit = {
|
||||
const checks = [
|
||||
this.seatsOnTheTable(options.seatSlack),
|
||||
this.dialogActionsReachable(),
|
||||
this.controlNames(),
|
||||
this.touchAction(),
|
||||
this.tapHighlight(),
|
||||
this.textSelectable(),
|
||||
|
||||
Reference in New Issue
Block a user