Files
JoshHeaps.Net/tests/holdem/theRail.test.js
T
jheapsandClaude Opus 5 8136025544 Say what the undo would take back, and let the empty chairs be empty
Two things on the felt and the bar that were spending weight on the wrong
half of what they had to say.

The undo pill names the last action, and on a phone it had stopped doing
it. It is a fixed width — its text changes on every tap, and a control
that resizes under a thumb reads as the interface twitching — and that
width leaves the label 73px. Measured across one hand, four of the five
labels a hand produces wanted more:

  ↶ dealing the hand        104     ↶ Christopher's fold      109
  ↶ Mike from work's fold   128     ↶ Alexandra's fold        100
  ↶ Dee's fold               69  (the only one that fitted)

so the safety net spent most of a night reading "↶ dealing …". Cut as one
string it keeps the wrong half: "↶ Mike from wor…" names everybody except
the action. So an action's name is handed to commit() in two halves now —
who it was, and what they did — and the actor comes off whole when the
act cannot fit beside it. The whole sentence stays on the button's title
and aria-label, which is what the guidance asks of a control that has had
to shorten its own name.

Two things fell out of it. The label cannot overflow — the act inside it
is the item with `overflow: hidden`, so it swallows the excess and the row
measures as though everything fitted — which is why the fit asks the act
and not the label; asked the label, "↶ dealing the hand" kept its actor
and quietly cut "the hand" off inside it. And the pill was still printing
a keycap on phones: every other control on the bar has dropped its cap
under `hover: none` since that rule was written, but this one is a
different class and had been left out of it, so the narrowest label on the
bar was the only one spending 22px on a shortcut nothing could press.
That 22px is exactly what "raise to $1,200" needed to keep its digits.

Nothing is cut now at any viewport in the sweep: a phone reads "↶ the
hand", "↶ Dee's fold", "↶ raise to $20", "↶ call"; a desktop and the
landscape rail keep the actor wherever it fits.

And the empty chairs. An open seat was drawn as a whole puck of near-black
on the green — the same box a seated player gets, at the same weight,
saying nothing. Five players nine-handed is four of them, and on the felt
they read as holes punched in the table: the eye had to sort the loud
empty boxes from the loud full ones before it could find who was in the
hand. The wash was there for one reason, which is that the chair number's
contrast cannot depend on whether its chair sits over the rim, the dark
green or the light — about Lc 5 across one ring. So the wash moved off the
chair and under the number, which is the only thing an open chair has to
say. The dashes still say "open", the box a thumb lands on has not moved a
pixel, and the felt gets four boxes' worth of quiet back.

1039 unit tests; 418 layout audits across 13 viewports, clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-10 14:13:25 -06:00

982 lines
48 KiB
JavaScript

// A phone held sideways, and the three pieces of arithmetic that make the table fit on
// one.
//
// The layout audit is what proved this was broken and what proves it is fixed — it
// measures the drawn page, which is the only place a layout is really true. What is
// here is the part underneath that can be wrong on its own: which shape the bar is in,
// how much of the bottom that leaves spoken for, how a name is shortened when it will
// not fit, and how the ring is pulled back in when a puck lands past the rail.
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, Render } = require("./load");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
const CSS_JS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"), "utf8");
// ---------------------------------------------------------------------------
describe("which shape the bar is in", () => {
// The layout is chosen by a media query — orientation, height and width together.
// The one thing that must not happen is a second copy of that query in JavaScript,
// because the first thing anybody changes is the breakpoint, in one of the two
// places. So the stylesheet declares which layout it picked and actionBar.js reads
// the answer back.
const withComputed = (value, run) => {
const host = { id: "actionBar" };
global.getComputedStyle = el => ({
getPropertyValue: prop => (el === host && prop === "--actionBarLayout" ? value : ""),
});
try { return run(host); } finally { delete global.getComputedStyle; }
};
test("the stylesheet says, and this reads", () => {
assert.equal(withComputed(" rail ", host => ActionBar.isRail(host)), true);
assert.equal(withComputed(" bar ", host => ActionBar.isRail(host)), false);
});
test("anything it does not recognise is the bar it has always been", () => {
assert.equal(withComputed("", host => ActionBar.isRail(host)), false);
assert.equal(withComputed("sidebar", host => ActionBar.isRail(host)), false);
});
test("and with no browser at all, likewise", () => {
assert.equal(ActionBar.isRail(null), false);
assert.equal(ActionBar.isRail({ id: "actionBar" }), false,
"no getComputedStyle to ask — this runs in the unit suite too");
});
test("the stylesheet declares both halves of it", () => {
// If either of these goes, isRail() quietly answers "bar" forever and the felt
// is measured against a bar that is not underneath it.
assert.match(CSS, /#actionBar\s*\{[^}]*--actionBarLayout:\s*bar/,
"the default, on the base rule");
assert.match(CSS, /--actionBarLayout:\s*rail/,
"and the rail, inside the landscape query");
});
test("the rail is scoped to a phone, not to any short window", () => {
// Above 900px the sidebar is already a right-hand column and a rail would land
// on top of it.
const query = CSS.match(/@media \(orientation: landscape\)[^{]*\{[\s\S]{0,400}?--actionBarLayout: rail/);
assert.ok(query, "the rail lives in a landscape media query");
assert.match(query[0], /max-width:\s*900px/);
assert.match(query[0], /max-height:\s*560px/);
});
});
// ---------------------------------------------------------------------------
describe("the bar lines up with the table", () => {
// On a desktop the bar was centred on the window and the table on its own column,
// and the sidebar takes 320px out of the right of that — so the two things a dealer
// is looking at were 172px apart, at 960px wide and at 1600px and everywhere in
// between, because the offset is half the sidebar and the sidebar is a constant.
//
// The rule that fixes it is four numbers that have to keep agreeing with four other
// numbers, none of them anywhere near each other in the file. That is what this
// block is for: the arithmetic is checked on the running page by measurement (bar
// centre against table-column centre, which came out 0px off at every width), and
// the numbers it depends on are checked here so they cannot drift apart afterwards.
const rule = CSS.match(/@media \(min-width: 901px\) \{\s*#actionBar \{([^}]*)\}/);
test("there is a rule, and it is scoped above the one-column breakpoint", () => {
// Below 901px the sidebar is not a column at all — it is stacked under the table
// — so there is nothing to lean away from and the bar is centred as it was.
assert.ok(rule, "no min-width: 901px rule for #actionBar");
assert.match(CSS, /@media \(max-width: 900px\)[\s\S]{0,200}#holdemLayout \{\s*grid-template-columns: 1fr;/,
"the one-column layout should start exactly where this rule stops");
});
test("it leans away from the sidebar by the sidebar's own width", () => {
assert.match(rule[1], /left:\s*var\(--sp-6\)/);
assert.match(rule[1], /right:\s*calc\(var\(--sidebarW\)\s*\+\s*var\(--sp-5\)\s*\+\s*var\(--sp-6\)\)/);
});
test("and the sidebar's width is one number, not two", () => {
// The grid draws the column and the bar leans away from it. Written out twice,
// the day somebody widens the sidebar is the day the bar goes crooked and no
// test says so.
assert.match(CSS, /--sidebarW:\s*\d+px/, "the token should be declared");
assert.match(CSS, /grid-template-columns:\s*1fr var\(--sidebarW\)/,
"the grid should draw the column from the token");
});
test("no 100vw anywhere in it", () => {
// The bar's own centring comment warns about this: on a page with a scrollbar
// 100vw is wider than the viewport, and the overflow drags the table sideways.
// The offsets are exact without it — see the derivation in the stylesheet.
assert.equal(/100vw/.test(rule[1]), false, rule[1]);
});
});
// ---------------------------------------------------------------------------
describe("the button seat's name", () => {
// The dealer button's chip sits in the puck's top-right corner, and the name has to
// keep out from under it. `padding-right` takes the chip's width off the name's line,
// which is the whole of the answer now that there is only ever one line.
//
// A float used to do this on the roomier rings — three rules of it, indenting the
// first line so a name too long to fit beside the chip could drop below it onto a
// second. That only worked because there was a second line. There isn't: see the row
// heights in holdem.css, and the fitSeatNames tests below for what happens instead to
// a name that does not fit.
const base = CSS.match(/\n\.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/);
test("the chip's width comes off the name, at every size", () => {
assert.ok(base, "the base rule should reserve the chip's width");
assert.match(base[1], /padding-right:\s*25px/);
});
test("and it is as wide as the chip it stands in for", () => {
const chip = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])width:\s*(\d+)px/);
const inset = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])right:\s*(\d+)px/);
const pad = base[1].match(/padding-right:\s*(\d+)px/);
assert.ok(chip && inset && pad);
assert.equal(Number(pad[1]), Number(chip[1]) + Number(inset[1]),
"the padding should cover the chip and the gap it is inset by");
});
test("the float that used to do this is gone, not merely unused", () => {
// Left in place with a one-line clamp it would clip the name to nothing: the
// word cannot fit beside the float, so it moves to a second line, and the second
// line is what the clamp hides. Measured — "Christopher" drew as an empty label.
assert.equal(/\.seatPuckName::before/.test(CSS), false,
"a float and one line of name cannot both be true");
});
test("and no rule anywhere gives a puck's name a second line", () => {
// This is what every puck being the same height rests on. A name is a label, and
// the ring is sized against the tallest puck on it, so one name wrapping is one
// seat setting how far apart all of them have to sit.
const clamps = CSS.match(/(?<![\w-])line-clamp:\s*(\d+)/g) || [];
assert.ok(clamps.length > 0, "the name should still be clamped to something");
clamps.forEach(clamp => assert.match(clamp, /:\s*1$/, clamp));
});
});
// ---------------------------------------------------------------------------
describe("the table is sized against the bar, not against a pad over it", () => {
// The raise pad is 57px taller than the row it replaces, and the felt was sized
// against it. Measured with five players nine-handed, pressing Raise:
//
// 1280x900 felt 738x532 -> 669x481, pucks 120 -> 109, worst seat moved 73px
// 360x640 felt 283x329 -> 283x279, pucks 44 -> 48, worst seat moved 50px
//
// The whole table jumped, re-ran the ten-candidate width search, and undid all of it
// when the dealer pressed Raise to. Every raise, all night, at the one moment they
// are reading the felt to decide what to type.
//
// A phone held sideways already scored 0px, because the rail is a column down the
// side and its height never was the table's business. This is the upright layout
// being held to what the sideways one already does.
test.afterEach(() => { ActionBar.raiseOpen = false; delete ActionBar.barHeightAtRest; });
test("a closed bar is measured, and remembered", () => {
ActionBar.raiseOpen = false;
assert.equal(ActionBar.restingBarHeight(151), 151);
assert.equal(ActionBar.barHeightAtRest, 151);
});
test("and an open pad gets the height the bar had before it opened", () => {
ActionBar.raiseOpen = false;
ActionBar.restingBarHeight(151);
ActionBar.raiseOpen = true;
assert.equal(ActionBar.restingBarHeight(208), 151,
"the felt would resize under the dealer's thumb mid-raise");
});
test("and gets it back the moment the pad closes", () => {
ActionBar.raiseOpen = false;
ActionBar.restingBarHeight(151);
ActionBar.raiseOpen = true;
ActionBar.restingBarHeight(208);
ActionBar.raiseOpen = false;
assert.equal(ActionBar.restingBarHeight(172), 172,
"a bar that really did grow — a side pot to show — still takes its room");
});
test("a pad open before anything was ever measured takes what it can see", () => {
// Nothing has been remembered yet, so the measurement is the only answer there
// is. Better a felt sized for the pad than one sized for undefined.
ActionBar.raiseOpen = true;
delete ActionBar.barHeightAtRest;
assert.equal(ActionBar.restingBarHeight(208), 208);
});
test("the bar that is actually covering the window is still reported honestly", () => {
// Only the felt's question is answered from the resting height. The toast has to
// clear whatever is really there, and so does the page's scroll padding, so
// --actionBarHeight stays a measurement.
const publish = CSS_JS.slice(CSS_JS.indexOf("const publish = () => {"));
const body = publish.slice(0, publish.indexOf("};"));
assert.match(body, /--actionBarHeight[^\n]*Math\.round\(height\)/,
"--actionBarHeight should still be the measured height");
assert.match(body, /publishFeltRoom\(.*restingBarHeight\(height\)/,
"the felt should be the one asking for the resting height");
});
});
// ---------------------------------------------------------------------------
describe("the status block is the same number of lines whoever is acting", () => {
// The bar is fixed to the bottom, so a line added to this block comes straight off
// the felt. The block wraps, and flex settles its line breaks before it decides what
// to shrink — so a name too long for the space left beside the clock and the undo
// pill did not give way, it pushed them onto a third line.
//
// Measured at 360x640 mid-street: "Dee · $600 behind" left the table 283x329 and
// "Christopher · $1,200 behind" made it 283x310, with every seat moving 24px between
// one player and the next. Whose turn it was decided how big the table was.
const acting = CSS.match(/\.actionActing \{([^}]*)\}/);
const who = CSS.match(/\n\.actionWho \{([^}]*)\}/);
const name = CSS.match(/\.actionWhoName \{([^}]*)\}/);
const stack = CSS.match(/\.actionWhoStack \{([^}]*)\}/);
test("the acting row takes a line of its own, once there is somebody on it", () => {
assert.ok(acting, ".actionActing rule not found");
assert.match(acting[1], /flex:\s*0 1 auto/,
"the pill on its own should not claim a line");
const withActor = CSS.match(/\.actionActing:has\(\.actionWho\) \{([^}]*)\}/);
assert.ok(withActor, "nothing gives the acting row its own line");
assert.match(withActor[1], /flex:\s*1 1 100%/,
"without a full basis it shares whatever line the wrap left it");
});
test("and only then — a run-out has nobody to act and cannot spare the line", () => {
// While a board is running out there is nobody to act, so this row is the undo
// pill alone. Claiming a line for it is 22px off the felt on the twelve-handed
// 360x640 table where the pucks are already at their 44px floor, and the sweep
// caught that as a seat's "ALL IN" going back under the blind badge next door.
assert.doesNotMatch(acting[1], /flex:\s*1 1 100%/);
});
test("and it shrinks rather than wrapping", () => {
// Left wrapping, this row reproduced the fault one level down: the undo pill is
// a fixed width, so a long name pushed it onto a line of its own and the block
// was 84px for some players and 66 for others.
assert.match(acting[1], /flex-wrap:\s*nowrap/);
});
test("the name is what gives way", () => {
// `min-width` on a flex item defaults to `auto` — "never smaller than your own
// content" — which is why the ellipsis this was written with never once appeared.
assert.ok(who && name, "the actor should be a name and an amount, not one string");
assert.match(who[1], /min-width:\s*0/);
assert.match(name[1], /min-width:\s*0/);
assert.match(name[1], /text-overflow:\s*ellipsis/);
});
test("and the stack is what does not", () => {
// The same rule the seat pucks keep: a number read wrong is a worse failure than
// a name read short, so the stack keeps every digit.
assert.ok(stack, ".actionWhoStack rule not found");
assert.match(stack[1], /flex:\s*none/);
});
test("except in the rail, which is a column and has no line to shrink into", () => {
// 190px wide: every one of these readings is on its own line there regardless,
// and squeezing a name into a fifth of the column would be shrinking to fit a
// row that isn't there.
const rules = [...CSS.matchAll(/\.actionActing \{([^}]*)\}/g)].map(m => m[1]);
assert.equal(rules.length, 2,
"one rule for the bar and one for the rail — no more, no fewer");
assert.ok(rules.some(body => /flex-wrap:\s*wrap\s*;/.test(body)),
"the rail should say what it wants instead");
});
});
// ---------------------------------------------------------------------------
describe("and the name is the half that is still there at the end of it", () => {
// Shrinking the name had no floor under it. Flex takes the only item that can give
// way and takes it all the way, so with the clock button on the line there was
// nothing left to give: measured with five players nine-handed, mid-street, forcing
// the clock button on —
//
// 360x640 "Christopher" wanted 62px and was drawn 0. "Alexandra" 54 -> 0.
// 390x844 "Christopher" wanted 62px and was drawn 0. "Alexandra" 54 -> 0.
// 430x932 "Christopher" 62 -> 31.
//
// What the bar read at the first two was "· $1,200 behind" — the separator, the
// figure, and no player. The ellipsis the rule was written around never appeared,
// because there were no characters left to put one after.
//
// The rule itself is kept: a number read wrong is worse than a name read short, so
// the stack is never truncated. It is taken off the line whole instead. Measured
// again afterwards, with the same clock button on the same line:
//
// 360x640 "Christopher" 62 -> 44 ("Christo…"), "Alexandra" 54 -> 44
// 390x844 both whole
// 430x932 both whole
//
// and 0px of seat movement across the thirty-second mark at every one of them.
const withLine = (name, stack, run) => {
const el = (extra) => Object.assign({
hidden: false, scrollWidth: 0, clientWidth: 0,
}, extra);
const nameEl = el(name);
const stackEl = el(stack);
global.document = {
querySelector: sel => (sel.endsWith(".actionWhoName") ? nameEl
: sel.endsWith(".actionWhoStack") ? stackEl : null),
};
try { run(nameEl, stackEl); } finally { delete global.document; }
};
test("a name that fits keeps the stack beside it", () => {
withLine({ scrollWidth: 54, clientWidth: 54 }, {}, (nameEl, stackEl) => {
ActionBar.fitActingLine();
assert.equal(stackEl.hidden, false);
});
});
test("a name being cut takes the stack off the line first", () => {
withLine({ scrollWidth: 62, clientWidth: 0 }, {}, (nameEl, stackEl) => {
ActionBar.fitActingLine();
assert.equal(stackEl.hidden, true,
"the stack is on that player's own seat puck; the name is only here");
});
});
test("and a stack already off it is put back before anything is measured", () => {
// Otherwise the answer is a property of whatever the last render left behind
// rather than of the line: a name that fits only *because* the stack is missing
// would keep it missing for the rest of the night.
withLine({ scrollWidth: 54, clientWidth: 54 }, { hidden: true }, (nameEl, stackEl) => {
ActionBar.fitActingLine();
assert.equal(stackEl.hidden, false);
});
});
test("a hair of rounding is not a cut", () => {
withLine({ scrollWidth: 55, clientWidth: 54 }, {}, (nameEl, stackEl) => {
ActionBar.fitActingLine();
assert.equal(stackEl.hidden, false);
});
});
test("and a bar with nobody acting on it is left alone", () => {
global.document = { querySelector: () => null };
try { assert.doesNotThrow(() => ActionBar.fitActingLine()); }
finally { delete global.document; }
});
test("the stylesheet can actually take it off the line", () => {
// `hidden` is a UA-stylesheet default, and .actionWhoStack sits inside a flex
// container. Said out loud here so a later `display:` on the class can't quietly
// put the figure back.
assert.match(CSS, /\.actionWhoStack\[hidden\]\s*\{[^}]*display:\s*none/);
assert.match(CSS, /\.actionTank\[hidden\]\s*\{[^}]*display:\s*none/);
});
test("the space before the separator is in the stylesheet, not in the string", () => {
// These are two flex items, and a flex item's own leading whitespace is collapsed
// at its edge — so ` · $1,200 behind` drew as `Alexandra· $1,500 behind` from the
// day the sentence was split in two.
const who = CSS.match(/\n\.actionWho \{([^}]*)\}/);
assert.match(who[1], /gap:\s*var\(--sp-1\)/);
assert.match(CSS_JS, /whoStack\.textContent = `· \$\{/,
"a leading space here is invisible and would double the gap if it weren't");
});
test("and the fit is taken after the timer has decided whether it is on the line", () => {
const render = CSS_JS.slice(CSS_JS.indexOf(" render() {"));
const body = render.slice(0, render.indexOf("\n },"));
const tank = body.indexOf("this.syncTankTimer()");
const fit = body.indexOf("this.fitActingLine()");
assert.ok(tank >= 0 && fit >= 0, "render() should do both");
assert.ok(fit > tank,
"measured before the stopwatch is hidden, the name is measured against 50px it has");
});
});
// ---------------------------------------------------------------------------
describe("what the undo pill says it would take back", () => {
// The pill is a fixed width — it names the last action, so its text changes on every
// tap, and a control that resizes under a thumb reads as the interface twitching —
// and on a phone that width leaves the label 73px. Measured across one hand, four of
// the five labels a hand produces wanted more:
//
// ↶ dealing the hand 104 ↶ Christopher's fold 109
// ↶ Mike from work's fold 128 ↶ Alexandra's fold 100
// ↶ Dee's fold 69 (the only one that fitted)
//
// So the safety net spent most of a night reading "↶ dealing …", which does not say
// what it is a net for. Cut as one string it says the wrong half: "↶ Mike from wor…"
// names everybody except the action.
//
// Two halves, and the actor is the one that goes — whole, not shortened. Measured
// again afterwards, nothing is cut at any viewport in the sweep: a phone reads
// "↶ the hand", "↶ Dee's fold", "↶ raise to $20", "↶ call"; a desktop and the
// landscape rail keep the actor wherever it fits.
test("a label handed over in halves knows which is which", () => {
const step = ActionBar.undoParts({ lead: "Christopher's", act: "fold" });
assert.equal(step.lead, "Christopher's");
assert.equal(step.act, "fold");
assert.equal(step.long, "Christopher's fold", "the sentence the toast and the tooltip use");
});
test("one with no actor is all act", () => {
// "the straddle" belongs to the table rather than to a player.
const step = ActionBar.undoParts({ lead: "", act: "the straddle" });
assert.equal(step.lead, "");
assert.equal(step.long, "the straddle", "no stray space in front of it");
});
test("and a plain string still works, with nothing to drop", () => {
// Every caller passes halves today. This is what keeps a caller that doesn't
// from drawing "[object Object]" on the one control a dealer reaches for when
// they have made a mistake.
const step = ActionBar.undoParts("something nobody split");
assert.equal(step.lead, "");
assert.equal(step.act, "something nobody split");
assert.equal(step.long, "something nobody split");
});
test("the sentence is what goes into the history, and the halves ride with it", () => {
const seen = [];
const before = ActionBar.hooks;
ActionBar.hooks = {
beginAction: (label, parts) => seen.push({ label, parts }),
save: () => {}, rerender: () => {},
};
try {
ActionBar.commit({ lead: "Dee's", act: "call" }, () => {});
} finally { ActionBar.hooks = before; }
assert.equal(seen.length, 1);
assert.equal(seen[0].label, "Dee's call", "the toast says the whole thing");
assert.deepEqual(
{ lead: seen[0].parts.lead, act: seen[0].parts.act },
{ lead: "Dee's", act: "call" });
});
test("and the halves are read back from the history, not remembered here", () => {
// Undo pops the history. A copy kept on this object would name the step that was
// just taken back rather than the one now on top of the pile.
const before = ActionBar.hooks;
ActionBar.hooks = {
lastActionLabel: () => "Dee's call",
lastActionParts: () => ({ lead: "Dee's", act: "call" }),
};
try {
assert.deepEqual(ActionBar.undoStep(), { lead: "Dee's", act: "call" });
} finally { ActionBar.hooks = before; }
});
test("a caller that offers no halves gets the label whole", () => {
const before = ActionBar.hooks;
ActionBar.hooks = { lastActionLabel: () => "dealing the hand" };
try {
assert.deepEqual(ActionBar.undoStep(), { lead: "", act: "dealing the hand" });
} finally { ActionBar.hooks = before; }
});
const withPill = (act, run) => {
const lead = { hidden: false, className: "actionUndoLead" };
const label = {
className: "actionUndoLabel",
querySelector: sel => (sel === ".actionUndoLead" ? lead
: sel === ".actionUndoAct" ? act : null),
};
global.document = {
querySelector: sel => (sel.endsWith(".actionUndoLabel") ? label : null),
};
try { run(lead); } finally { delete global.document; }
};
test("an act that fits keeps the actor beside it", () => {
withPill({ scrollWidth: 19, clientWidth: 40 }, lead => {
ActionBar.fitUndoLabel();
assert.equal(lead.hidden, false);
});
});
test("an act being cut drops the actor whole", () => {
withPill({ scrollWidth: 63, clientWidth: 40 }, lead => {
ActionBar.fitUndoLabel();
assert.equal(lead.hidden, true,
"\"Christo…'s fold\" is a worse sentence than \"fold\"");
});
});
test("and it is the act that is asked, never the label", () => {
// The label cannot overflow: the act inside it is the item with `overflow:
// hidden`, so it swallows the excess and the row measures as though everything
// fitted. Asking the label was the first way this was written, and it drew
// "↶ dealing the hand" with "the hand" quietly cut off inside it.
const fit = CSS_JS.slice(CSS_JS.indexOf(" fitUndoLabel() {"));
const body = fit.slice(0, fit.indexOf("\n },"));
assert.match(body, /act\.scrollWidth > act\.clientWidth/);
assert.doesNotMatch(body, /label\.scrollWidth/);
});
test("an actor already dropped is put back before anything is measured", () => {
withPill({ scrollWidth: 19, clientWidth: 40 }, lead => {
lead.hidden = true;
ActionBar.fitUndoLabel();
assert.equal(lead.hidden, false);
});
});
test("and a bar with nothing to take back is left alone", () => {
global.document = { querySelector: () => null };
try { assert.doesNotThrow(() => ActionBar.fitUndoLabel()); }
finally { delete global.document; }
});
test("the keycap goes where there is no keyboard to press it", () => {
// Every other control on the bar had dropped its cap on a phone since the rule
// was written; this one is a different class and had been left out of it, so the
// narrowest label on the bar was the only one still spending 22px on a shortcut
// nothing could press.
const rule = CSS.match(/@media \(hover: none\) \{\s*\.actionKey,\s*\.actionUndoKey \{([^}]*)\}/);
assert.ok(rule, ".actionUndoKey should be hidden alongside .actionKey on touch");
assert.match(rule[1], /display:\s*none/);
});
});
// ---------------------------------------------------------------------------
describe("a stopwatch that is only there when it is saying something", () => {
// 50px of a 349px line, from the first second of every decision, reading "0:07" in
// grey. It is a prompt rather than a readout — nothing happens when it grows — and a
// prompt that is always on screen is not a prompt. It arrives when the button that
// acts on it arrives, which is the same threshold, and the room goes to the name.
const withClock = ({ seat = 0, started = null, unknown = null, clockAt = null }, run) => {
const saved = {
state: ActionBar.state, tankSeatIndex: ActionBar.tankSeatIndex,
tankStartedAt: ActionBar.tankStartedAt, tankUnknownFor: ActionBar.tankUnknownFor,
clockSeatIndex: ActionBar.clockSeatIndex, clockStartedAt: ActionBar.clockStartedAt,
};
ActionBar.state = { actingSeatIndex: seat };
ActionBar.tankSeatIndex = seat;
ActionBar.tankStartedAt = started;
ActionBar.tankUnknownFor = unknown;
ActionBar.clockSeatIndex = clockAt === null ? null : seat;
ActionBar.clockStartedAt = clockAt;
try { run(); } finally { Object.assign(ActionBar, saved); }
};
test("a decision that has only just landed on somebody shows neither", () => {
withClock({ started: Date.now() - 3000 }, () =>
assert.equal(ActionBar.tankWorthShowing(), false));
});
test("and both arrive together, at the threshold the stopwatch already marked", () => {
withClock({ started: Date.now() - (ActionBar.NUDGE_AFTER_S * 1000) }, () =>
assert.equal(ActionBar.tankWorthShowing(), true));
});
test("a clock already running is always worth watching", () => {
// The readout is a countdown by then, and the button beside it says "Stop the
// clock" — there is no second at which either of those is not the point.
withClock({ started: Date.now(), clockAt: Date.now() }, () =>
assert.equal(ActionBar.tankWorthShowing(), true));
});
test("so is the seat whose wait cannot be known", () => {
// The action was already on them when the page loaded. The dash and the sentence
// behind it are the answer to "why is there no time on this one", so the answer
// has to be visible for the question to get asked.
withClock({ started: Date.now(), unknown: 0 }, () =>
assert.equal(ActionBar.tankWorthShowing(), true));
});
test("and with nothing to time from at all, nothing is shown", () => {
withClock({ started: null }, () =>
assert.equal(ActionBar.tankWorthShowing(), false));
});
test("it is built hidden, so a fresh decision never flashes one up", () => {
assert.match(CSS_JS, /tank\.hidden = true;/);
});
test("and the tick re-fits the line only when one of them actually moved", () => {
// The line is measured by forcing a layout, and this runs once a second all
// night. Nothing has changed on the seconds in between.
const paint = CSS_JS.slice(CSS_JS.indexOf(" paintTank() {"));
const body = paint.slice(0, paint.indexOf("\n },"));
assert.match(body, /if \(el\.hidden !== wasTank \|\| \(nudge && nudge\.hidden !== wasNudge\)\) this\.fitActingLine\(\)/);
});
});
// ---------------------------------------------------------------------------
describe("how much of the bottom is spoken for", () => {
// feltRoomFor asks one question — what is under the felt — and gets two different
// answers depending on the layout. Under the bar it is the bar. Under the rail it is
// the table hint and the two links, because the bar is off to the side.
const fakeSection = (wrapBottom, sectionBottom) => {
const wrap = {
getBoundingClientRect: () => ({ bottom: wrapBottom }),
parentElement: { getBoundingClientRect: () => ({ bottom: sectionBottom }) },
};
global.document = { getElementById: id => (id === "pokerTableWrap" ? wrap : null) };
};
test.afterEach(() => { delete global.document; });
test("it is the gap under the felt, links and all", () => {
fakeSection(300, 342);
assert.equal(ActionBar.roomBelowFelt(), 42);
});
test("a difference, not a height — so it does not chase the felt it is sizing", () => {
// The wrap's own height comes from --feltRoomPx, which is what this feeds. Read
// as "how tall is everything under the felt" off the section's height it would
// move every time the felt did, and settle nowhere.
fakeSection(300, 342);
const first = ActionBar.roomBelowFelt();
fakeSection(500, 542); // the felt grew by 200; what is under it did not
assert.equal(ActionBar.roomBelowFelt(), first);
});
test("nothing under the felt is nothing to reserve", () => {
fakeSection(300, 300);
assert.equal(ActionBar.roomBelowFelt(), 0);
});
test("and a section that measures shorter than its own child is not negative room", () => {
fakeSection(300, 288);
assert.equal(ActionBar.roomBelowFelt(), 0);
});
test("with no table on the page it asks for nothing", () => {
global.document = { getElementById: () => null };
assert.equal(ActionBar.roomBelowFelt(), 0);
});
test("the felt gets the height the bar used to take", () => {
// The whole point, in one line. At 640x360 the bar was 205px of a 360px window
// and the felt got 85. Beside the table instead, the same window gives the felt
// everything but the header and the links.
const asABar = ActionBar.feltRoomFor(360, 205, 70);
const asARail = ActionBar.feltRoomFor(360, 30, 70);
assert.ok(asARail > asABar * 2, `${asABar}px became ${asARail}px`);
});
});
// ---------------------------------------------------------------------------
describe("a name that will not fit", () => {
// Shortened at a word, not chopped in the middle of one. A puck is 34px of text on
// a nine-handed phone, and what the ellipsis produced for the four names on the test
// table was "Christo…", "Mike / from…", "Alexan…". Only one of those is throwing
// away something the dealer does not need: he is Mike, and the rest of it is how
// this table tells him from the other Mike.
//
// The label element is faked here rather than measured. What is under test is the
// ladder — which words go, in what order, and when it stops — and the browser's part
// is one boolean per pass.
const label = (text, fits) => {
const classes = new Set();
const el = {
textContent: text,
title: "",
// The step of type a name takes when it has no word left to drop, held as
// the same set of strings the real element's classList is.
classList: {
add: name => classes.add(name),
remove: name => classes.delete(name),
contains: name => classes.has(name),
},
// The browser's answer, standing in for a real one: this name fits if
// `fits` says the version currently written to it does.
get scrollWidth() { return fits(this.textContent) ? 10 : 100; },
get clientWidth() { return 10; },
get scrollHeight() { return 10; },
get clientHeight() { return 10; },
querySelector: () => el,
};
return el;
};
const layer = (labels) => ({
children: labels,
childElementCount: labels.length,
});
const seatsNamed = (...names) => {
Render.state = { seats: names.map(name => ({ name })) };
};
test.afterEach(() => { Render.state = null; });
test("trailing words go until it fits", () => {
const el = label("Christopher John Robinson", text => text === "Christopher John");
seatsNamed("Christopher John Robinson");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Christopher John");
});
test("and it will go all the way to the first word", () => {
const el = label("Christopher John Robinson", text => text === "Christopher");
seatsNamed("Christopher John Robinson");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Christopher");
});
test("a name never ends on a word a name cannot end on", () => {
// Dropping "work" off "Mike from work" leaves "Mike from", which is not a
// shorter name — it is a name that looks cut off, and it reads worse than the
// ellipsis this was meant to replace. He is Mike.
const el = label("Mike from work", () => false);
seatsNamed("Mike from work");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Mike");
});
test("and it takes a run of them in one go", () => {
const el = label("Al with the hat", () => false);
seatsNamed("Al with the hat");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Al");
});
test("but a joining word inside a name is nobody's business", () => {
// The list is closed and small on purpose: it is not trying to understand names,
// only the handful of English joining words people label each other with at a
// home game. Anything it doesn't know stays, which is the safe way to be wrong —
// a name that keeps a word it could have dropped is merely long.
const el = label("Katarzyna Nowak Kowalska", text => text === "Katarzyna Nowak");
seatsNamed("Katarzyna Nowak Kowalska");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Katarzyna Nowak");
});
test("dropping never goes below the first word", () => {
// Every word after the first being a joining word is not a real name, but it is
// typeable, and it must not come out empty.
assert.equal(Render.dropAWord(["Mike", "from", "of", "the"], 4), 1);
assert.equal(Render.dropAWord(["from", "the"], 2), 1);
assert.equal(Render.dropAWord(["Mike"], 1), 1, "one word has nothing to drop");
});
test("a single word steps its type down rather than being cut", () => {
// There is no honest way to shorten "Christopher" at a word, and "Christo…" is a
// name only until a Christine sits down. The amounts on the same puck have never
// taken that trade — fitAmount shrinks a tournament stack rather than rounding it
// — and a misread name is the same kind of failure as a misread stack.
const el = label("Christopher", () => false);
seatsNamed("Christopher");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Christopher", "never cut mid-word here");
assert.ok(el.classList.contains("puckNameTight"), "and never left overflowing either");
});
test("a name that fits is not touched", () => {
const el = label("Mike from work", () => true);
seatsNamed("Mike from work");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Mike from work");
assert.ok(!el.classList.contains("puckNameTight"), "nothing to shrink for");
});
test("a name shortened until it fits is not also shrunk", () => {
// The step of type is the last resort, not a second helping. "Mike" fits, so it
// is read at the size everything else on the felt is.
const el = label("Mike from work", text => text === "Mike");
seatsNamed("Mike from work");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Mike");
assert.ok(!el.classList.contains("puckNameTight"));
});
test("the step comes off again when the puck it was for is gone", () => {
// Otherwise it is a ratchet: a name that had to shrink on a nine-handed phone
// would still be shrunk on the same table six-handed, or on a desktop, because
// nothing ever measured it at the size it was supposed to be read at.
const el = label("Christopher", () => false);
seatsNamed("Christopher");
Render.fitSeatNames(layer([el]));
assert.ok(el.classList.contains("puckNameTight"));
const roomy = label("Christopher", () => true);
roomy.classList.add("puckNameTight");
Render.fitSeatNames(layer([roomy]));
assert.ok(!roomy.classList.contains("puckNameTight"));
});
test("the whole name is on the tooltip whether it was shortened or not", () => {
// A label clamped to two lines was already hiding some of it before any of this.
const shortened = label("Mike from work", text => text === "Mike");
const untouched = label("Bob", () => true);
seatsNamed("Mike from work", "Bob");
Render.fitSeatNames(layer([shortened, untouched]));
assert.equal(shortened.title, "Mike from work");
assert.equal(untouched.title, "Bob");
});
test("every seat is written to before any of them is measured", () => {
// Three passes over all the seats, not a loop per seat: each pass writes to
// every puck and then reads back from every puck, so the browser lays the felt
// out three times whatever the table's size. Interleaved, nine seats would cost
// nine layouts, and this runs on every render.
const reads = [];
const watched = (text, fits) => {
const el = label(text, fits);
Object.defineProperty(el, "scrollWidth", {
get() { reads.push(this.textContent); return fits(this.textContent) ? 10 : 100; },
});
return el;
};
const a = watched("Christopher John Robinson", t => t === "Christopher");
const b = watched("Katarzyna Nowak Kowalska", t => t === "Katarzyna");
seatsNamed("Christopher John Robinson", "Katarzyna Nowak Kowalska");
Render.fitSeatNames(layer([a, b]));
// Both names are read at each length before either is written down again.
assert.deepEqual(reads.slice(0, 2),
["Christopher John Robinson", "Katarzyna Nowak Kowalska"]);
assert.deepEqual(reads.slice(2, 4), ["Christopher John", "Katarzyna Nowak"]);
assert.equal(a.textContent, "Christopher");
assert.equal(b.textContent, "Katarzyna");
});
test("an empty chair has no name to shorten", () => {
const el = label("", () => false);
seatsNamed("");
assert.doesNotThrow(() => Render.fitSeatNames(layer([el])));
});
test("and with no layer there is nothing to do", () => {
assert.doesNotThrow(() => Render.fitSeatNames(null));
});
});
// ---------------------------------------------------------------------------
describe("pulling the ring back onto the table", () => {
// seatRingRadii works out the widest ring whose outermost puck still finishes inside
// the rail, and it is right about that — but it is right about a puck it measured
// before the ring moved, before the width search settled, and before a name was
// shortened or wrapped to a second line. Each of those changes a puck's height by up
// to 15px after the arithmetic is done.
//
// Measured on the running page, four of the thirteen viewports finished with the top
// seat 5-6px past the rail. Not a wrong formula: a stale input to a right one. So
// the ring is settled the way the audit judges it, by looking at where the pucks
// actually landed.
//
// The felt is 400x400 at (100, 100) throughout. Seats are placed by writing a
// percentage into style.left/top, which this reads back to work out where the puck
// would be — the same relationship the browser has, without a browser.
const felt = { left: 100, top: 100, right: 500, bottom: 500, width: 400, height: 400 };
const seat = (puckW, puckH, box) => ({
style: { left: "", top: "" },
classList: { toggle() {} },
getBoundingClientRect() {
// Whatever percentage was last written, resolved against the ring box.
const cx = box.left + (parseFloat(this.style.left) / 100) * box.width;
const cy = box.top + (parseFloat(this.style.top) / 100) * box.height;
return {
left: cx - puckW / 2, right: cx + puckW / 2,
top: cy - puckH / 2, bottom: cy + puckH / 2,
width: puckW, height: puckH,
};
},
});
// The ring box the percentages resolve against, deliberately the same as the felt so
// the arithmetic in the test stays readable.
const ringBox = { ...felt };
const setUp = (count, puckW, puckH) => {
const seats = Array.from({ length: count }, () => seat(puckW, puckH, ringBox));
Render.state = { seats: seats.map(() => ({ name: "" })), seatCount: count };
global.document = { getElementById: id => (id === "pokerTable"
? { getBoundingClientRect: () => felt }
: { getBoundingClientRect: () => ringBox }) };
// ringBox() reads #seatsLayer; the fake above hands the same box back for it.
return { children: seats, childElementCount: seats.length };
};
const place = (layer, radii) => {
for (let i = 0; i < layer.childElementCount; i++) {
Render.placeSeat(layer.children[i], i, 1, radii);
}
};
const worstOverhang = layer => {
let over = 0;
for (let i = 0; i < layer.childElementCount; i++) {
const b = layer.children[i].getBoundingClientRect();
over = Math.max(over, felt.left - b.left, b.right - felt.right,
felt.top - b.top, b.bottom - felt.bottom);
}
return over;
};
test.afterEach(() => { Render.state = null; delete global.document; });
test("a ring already inside the rail is left exactly where it is", () => {
const layer = setUp(6, 60, 50);
const radii = { rx: 30, ry: 30 };
place(layer, radii);
assert.deepEqual(Render.settleSeatsOnTable(layer, 1, radii), radii);
});
test("a puck past the rail brings the whole ring in", () => {
// 44% of 400 puts a seat's centre 176px from the middle; half a 60px puck is
// another 30, against a felt whose half-height is 200. Six pixels over, at the
// top and the bottom and both sides — which is the size the running page was
// actually wrong by.
const layer = setUp(4, 60, 60);
const radii = { rx: 44, ry: 44 };
place(layer, radii);
assert.equal(Math.round(worstOverhang(layer)), 6, "the fault is there to fix");
const settled = Render.settleSeatsOnTable(layer, 1, radii);
assert.ok(settled.ry < radii.ry, "the ring came in");
assert.ok(worstOverhang(layer) <= Render.SEAT_SETTLE_SLACK_PX,
`still ${worstOverhang(layer).toFixed(1)}px over`);
});
test("it comes in by what it was over, not by a step it guessed", () => {
const layer = setUp(4, 60, 60);
const radii = { rx: 44, ry: 44 };
place(layer, radii);
const over = worstOverhang(layer);
const settled = Render.settleSeatsOnTable(layer, 1, radii);
// Six pixels of a 400px felt is 1.5% of the ring, so 44 becomes 42.5 — the
// measurement, not a step size that would have to be tuned per screen.
assert.equal(settled.ry.toFixed(2), (44 - (over / felt.height) * 100).toFixed(2));
assert.equal(settled.ry, 42.5);
});
test("and it stops at the floor rather than collapsing into the pot", () => {
// A puck taller than the felt has an overhang no ring can fix. Pulling in
// forever would put every seat on top of the pot badge, which is a worse table
// than one with a puck touching the rail.
const layer = setUp(4, 60, 600);
const radii = { rx: 40, ry: 40 };
place(layer, radii);
const settled = Render.settleSeatsOnTable(layer, 1, radii);
assert.equal(settled.ry, Render.RING_MIN_R);
assert.ok(settled.ry > 0);
});
test("a table with nothing laid out yet is handed back untouched", () => {
Render.state = { seats: [], seatCount: 0 };
global.document = { getElementById: () => null };
const radii = { rx: 44, ry: 42 };
assert.deepEqual(Render.settleSeatsOnTable({ children: [], childElementCount: 0 }, 1, radii), radii);
});
test("it is bounded — this runs after every tap a dealer makes", () => {
assert.ok(Render.SEAT_SETTLE_PASSES <= 2,
"each pass is a forced layout of the whole felt");
assert.ok(Render.SEAT_NAME_PASSES <= 3);
});
});