// 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"); }); });