Files
JoshHeaps.Net/tests/holdem/whatTheUndoPillSays.test.js
T
jheapsandClaude Opus 5 e36e172670 Let the undo pill finish its own sentence
The bar's undo is a fixed-width pill because its text changes on every tap and
a control that resizes under a thumb reads as the interface twitching. On a
phone that leaves the label 78px, so the label is three elements and
fitUndoLabel drops the actor whole rather than letting an ellipsis land
wherever it lands. That machinery was right. The labels fed to it were not.

Two rules follow from "the actor is the half that goes", and six of the
fifteen labels broke one.

The act has to name the action on its own, because it is what survives. Four
labels put a verb in the actor's slot — "dealing" + "the hand", "dealing" +
"everybody in", "calling" + "the straddle off" — so dropping the lead left the
pill reading "↶ the hand" after every deal of the night, and "↶ the straddle
off". Neither names anything. Those are re-split: "the deal", "everybody dealt
in", "the straddle called off".

The act has to fit, because whatever is left is ellipsised. Measured at
390x844 and 360x640 with a thumb: "hand killed on the clock" wanted 125px of
78, "check on the clock" 99, "waiting for the blind" 103, "posting to come in"
97 — all four came out cut mid-phrase, "↶ waiting for th…". The qualifier goes
into a new optional `long`, which is the whole sentence, and the verb stays on
the pill.

Every short form is a substring of its long form, which is what Label in Name
asks of a control that has shortened its own name — and it is what lets `long`
be the accessible name rather than only a title a phone has no way to show.
Before this the name was whatever was visible: "↶ hand killed U", arrow and
keycap included. Both are aria-hidden now.

Measured after, on both phone sizes: nothing ellipsised, and every visible
form contained in the name. One exception, stated in the code: a five-figure
raise. "raise to $12,500" is 86px of 78 and the number is the whole of what it
says, so there is no qualifier to move — the leading digits survive, the
amount is on that player's seat puck, and the whole of it is on the name.

Three comments carrying the old 73px figure are re-measured while I was here.

1353 tests pass, 11 of them new — 3 fail against the old labels. The sweep is
clean across 428 audits; contrast unchanged.

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

193 lines
9.9 KiB
JavaScript

// The pill that names what Undo would take back, and whether it manages to.
//
// The bar's undo is a standing control rather than a toast per action, and it is a fixed
// width on purpose: its text changes on every tap, and a control that resizes under a
// thumb reads as the interface twitching. Measured on a phone that leaves the label
// 78px, which is not enough for any label with an actor on it — so the label is three
// elements (arrow, actor, act) and `ActionBar.fitUndoLabel` drops the actor whole rather
// than letting an ellipsis land wherever it lands.
//
// That machinery was right and the labels being fed to it were not. Two rules follow
// from "the actor is the half that goes", and six of the fifteen labels broke one:
//
// The act has to name the action on its own. Four labels put a *verb* in the actor's
// slot — "dealing" + "the hand", "dealing" + "everybody in", "calling" + "the straddle
// off" — so dropping the lead left the pill reading "↶ the hand", which is what it
// said after every deal of the night, and "↶ the straddle off". Neither names an
// action.
//
// The act has to fit, because whatever is left is ellipsised. Measured at 390x844 and
// 360x640 with a thumb: "hand killed on the clock" wanted 125px of 78, "check on the
// clock" 99, "waiting for the blind" 103, "posting to come in" 97. All four came out
// cut mid-phrase — "↶ waiting for th…".
//
// Both are fixed by writing the labels differently rather than by changing the fitting:
// the qualifier moves into `long`, which is the whole sentence, and the verb stays on
// the pill. Every short form is a substring of its long form, which is what Label in
// Name asks of a control that has shortened its own name — and it is what lets `long`
// be the accessible name rather than only a `title` nothing on a phone can reach.
//
// Measured after, on both phone sizes: nothing ellipsised, and every visible form
// contained in the name. The one exception is stated below.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { ActionBar } = require("./load");
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
const BAR = fs.readFileSync(path.join(SRC, "actionBar.js"), "utf8");
// Every act the bar can put on the pill, with what the pill shows once the actor has
// been dropped and the whole sentence it belongs to.
//
// This table is the rule written down. A new action has to appear here before its act is
// allowed, which is the point: the two things that go wrong — an act that needs its lead
// to mean anything, and an act too long for the box — are both invisible until somebody
// reads the pill on a phone.
const ACTS = {
"fold": { shows: "↶ Dee's fold", long: "Dee's fold" },
"check": { shows: "↶ Dee's check", long: "Dee's check" },
"call": { shows: "↶ Dee's call", long: "Dee's call" },
"all-in": { shows: "↶ Dee's all-in", long: "Dee's all-in" },
"hand killed": { shows: "↶ hand killed", long: "Dee's hand killed on the clock" },
"waiting": { shows: "↶ Dee waiting", long: "Dee waiting for the blind" },
"posting": { shows: "↶ Dee posting", long: "Dee posting to come in" },
"the deal": { shows: "↶ the deal", long: "the deal" },
"dealt in": { shows: "↶ dealt in", long: "everybody dealt in" },
"straddle": { shows: "↶ the straddle", long: "the straddle" },
"re-straddle": { shows: "↶ the re-straddle", long: "the re-straddle" },
"called off": { shows: "↶ called off", long: "the straddle called off" },
};
// Acts that carry an amount, and so cannot be shortened without losing the number that
// is the whole of what they say. "raise to $12,500" is 86px of 78 and does ellipsise —
// accepted, because the leading digits survive, the amount is on that player's own seat
// puck, and the whole of it is on the button's name. Below five figures they all fit:
// "raise to $1,200" is 79.
const AMOUNTS = ["raise to ", "bet of "];
// One street name per act, filled in by the caller.
const TEMPLATED = ["the ${street}"];
// The longest non-amount act that fits, in characters. A proxy for the 78px measured on
// the two phones in the sweep — "re-straddle" and "hand killed" are 11 and both fit;
// "forced check" is 12 and 68px, the widest that was tried and cleared.
const BUDGET = 12;
// Every `act:` in the bar, pulled out of the source rather than listed by hand — a new
// one that nobody adds to ACTS above has to fail this.
const actsInSource = () => {
const found = new Set();
BAR.split("\n").forEach(line => {
let at = line.indexOf("act:");
while (at >= 0) {
const rest = line.slice(at + 4);
// Quoted and back-ticked tokens on that property, minus anything that is
// the right-hand side of a comparison — `expired.verb === "fold" ? …` picks
// the act, it is not one.
const tokens = rest.match(/(["`])((?:\\.|(?!\1)[^\\])*)\1/g) || [];
tokens.forEach(raw => {
const before = rest.slice(0, rest.indexOf(raw));
if (/[=!]==\s*$/.test(before)) return;
found.add(raw.slice(1, -1));
});
at = line.indexOf("act:", at + 4);
}
});
return [...found];
};
// ---------------------------------------------------------------------------
describe("what the pill is allowed to say", () => {
test("every act in the bar is one this file has looked at", () => {
const known = new Set([...Object.keys(ACTS), ...TEMPLATED]);
const stray = actsInSource().filter(a =>
!known.has(a) && !AMOUNTS.some(prefix => a.startsWith(prefix)));
assert.deepEqual(stray, [],
"a new act, which has to be checked against a 78px box before it is trusted");
});
test("and every act this file has looked at is still in the bar", () => {
const inSource = new Set(actsInSource());
const gone = Object.keys(ACTS).filter(a => !inSource.has(a));
assert.deepEqual(gone, [], "this table describes labels the bar no longer produces");
});
test("none of them needs its actor to mean anything", () => {
// The failure this is here for: "the hand", which is what the pill read after
// every deal, and "the straddle off". An act is what survives, so an act that
// only parses as the tail of a phrase survives as nothing.
Object.entries(ACTS).forEach(([act, { shows }]) => {
assert.ok(shows.includes(act), `"${shows}" does not contain "${act}"`);
assert.doesNotMatch(act, /^(the hand|everybody in|the straddle off)$/,
`"${act}" is one of the acts that read as nothing on their own`);
});
});
test("and none of them is too long for the box", () => {
Object.keys(ACTS).forEach(act => {
assert.ok(act.length <= BUDGET,
`"${act}" is ${act.length} characters; over ${BUDGET} it ellipsises on a phone`);
});
});
test("the amounts are the exception, and they are the only one", () => {
const amounts = actsInSource().filter(a => AMOUNTS.some(p => a.startsWith(p)));
assert.ok(amounts.length > 0, "the raise labels have moved and this exemption is stale");
amounts.forEach(a => assert.ok(!(a in ACTS),
`"${a}" is listed as both an amount and a fixed act`));
});
});
describe("the whole sentence is kept where the short one cannot go", () => {
test("a label may spell itself out at length", () => {
const parts = ActionBar.undoParts({
lead: "Dee's", act: "hand killed", long: "Dee's hand killed on the clock",
});
assert.equal(parts.act, "hand killed", "the pill gets the short one");
assert.equal(parts.long, "Dee's hand killed on the clock", "the name gets the whole one");
});
test("and without one it is still built from the halves", () => {
assert.equal(ActionBar.undoParts({ lead: "Dee's", act: "fold" }).long, "Dee's fold");
assert.equal(ActionBar.undoParts({ act: "the deal" }).long, "the deal",
"an act with no actor should not be given a leading space");
});
test("a plain string still works, with nothing to drop", () => {
const parts = ActionBar.undoParts("something older");
assert.deepEqual(parts, { lead: "", act: "something older", long: "something older" });
});
test("every short form is inside the sentence it came from", () => {
// Label in Name: a speech user saying the words they can see has to hit the
// control, so the accessible name cannot replace the visible text with
// different words.
Object.entries(ACTS).forEach(([act, { long }]) => {
assert.ok(long.includes(act), `the name "${long}" does not contain "${act}"`);
});
});
});
describe("what the pill is called out loud", () => {
test("the sentence is the accessible name, not only a hover", () => {
// Before this the name was whatever text was visible — on a phone, "↶ hand
// killed U" — and the whole of it lived in a `title`, which a touch screen has
// no way to show and a screen reader is not obliged to read.
assert.match(BAR, /undo\.setAttribute\("aria-label", `Undo \$\{undoLabel\}`\)/);
assert.match(BAR, /undo\.title = `Undo \$\{undoLabel\}`/);
});
test("and the decoration stays out of it", () => {
const undoBlock = BAR.slice(BAR.indexOf('undo.className = "actionUndo"'),
BAR.indexOf("acting.appendChild(undo)"));
assert.match(undoBlock, /glyph\.setAttribute\("aria-hidden", "true"\)/,
'the arrow is read as part of the name — "left arrow over hand killed"');
assert.match(undoBlock, /key\.setAttribute\("aria-hidden", "true"\)/,
"the keycap is read as part of the name");
});
});