Files
JoshHeaps.Net/tests/holdem/theRail.test.js
T
jheapsandClaude Opus 5 37abc72de1 Take the dealer button out of the writing and put it on the rim
A puck can say four things about where a seat sits in the hand: small blind,
big blind, straddle, button. Three of them were pills hanging off the top-left
edge. The fourth was a 22px disc inside the top-right corner, on a layer above
the name — so the dealer's eye had two places to look for one class of fact,
and the corner one was charging the name 25px of its line to be there.

What that cost, measured across the sweep's tables with the twelve packed names:

  Galaxy S8  9-handed   name box  56px   11 of 12 fit — 7 on the button seat
  Galaxy S8 12-handed   name box  34px    7 of 12 fit — 1 on the button seat
  iPhone 14  9-handed   name box  68px   11 of 12 fit — 7 on the button seat
  desktop    9-handed   name box 105px   12 of 12 fit — 11 on the button seat

Four names in twelve, on a phone, cut on one seat and whole on the other eight.
And the button is the one marker on the felt that moves every hand, so that is
not four unlucky players — it is every player in turn, once a round, all night.

The chip now hangs off the same edge as the badges, at the same 5px, at the
other end of it. That distance is not free — SEAT_BADGE_OVERHANG_PX charges
every chair on the ring for it — but it is already spent, and it is charged
once per seat rather than once per marker, which stays right precisely because
the two markers share an edge and sit at opposite ends of it. So the ring does
not move at all. 12px is what fits between -5px and the top of the name on the
tight ring, and it is enough: a finger never reaches this chip anyway — on
touch the ghosts are pointer-events: none and the live one is inert, so giving
the button is the seat's long-press menu, and what is left is a mouse on a
ghost that only appears under the pointer.

Round where the badges are pills, and on the right where they are on the left,
because the button's gold and the big blind's now share one edge: what tells
them apart is the shape and the side, not the colour.

--

The other half of this was a name that could not be trusted to be one line.

The label was a `-webkit-box` clamped to one line, which hides the lines past
the first — but not the part of the second one that the row's own min-height
leaves uncovered. The row is a px token, 15px, "12px of name at 1.2", while
.puckNameTight steps the name down to 10.2px, whose line box is 12.24. That
leaves 2.76px. Measured on a 390px phone: "Christopher" on the button seat drew
as "Christ…" with the ascenders of "opher" showing underneath it as a stray
mark, which reads as a rendering fault on the first seat a dealer looks at.

`white-space: nowrap` with `text-overflow: ellipsis` has no second line to
leak. It also makes the box measure honestly, which the clamp did not: a name
that wrapped reported scrollWidth == clientWidth, so fitSeatNames() had only
ever been seeing the overflow as height — the right answer to the wrong
question, and only right while the row and the line agreed.

1046 tests pass; the 418-audit sweep is clean.

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

993 lines
49 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 button seat's name is the same name in the same box as every other seat's.
//
// Three attempts said otherwise, in this order: a float, a float with a first-line
// indent, and `padding-right: 25px` — the dealer chip's width plus its inset, taken
// off the name because the chip sat inside the puck's top-right corner. All three
// were answers to a chip that had no business being in the writing, and the price
// was a quarter of the name's line on whichever seat currently held the button.
// Measured with the sweep's twelve names: 7 of 12 fitted on the button seat at
// 9-handed on an iPhone against 11 anywhere else, and 1 of 12 against 7 at
// 12-handed on a Galaxy S8.
const name = CSS.match(/\n\.seatPuckName \{([^}]*)\}/);
test("nothing reserves a gutter inside the name any more", () => {
assert.equal(/\.seat\.seatHasButton \.seatPuckName/.test(CSS), false,
"the button seat's name should be the same box as every other seat's");
});
test("the float that used to do this is gone, not merely unused", () => {
// Left in place with a one-line name it would clip the name to nothing: the word
// cannot fit beside the float, so it moves to a second line, and there is no
// second line. 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.
assert.ok(name, "the name should have a base rule");
assert.match(name[1], /white-space:\s*nowrap/);
});
test("and it is one line by not wrapping, not by hiding what wrapped", () => {
// A `-webkit-box` clamped to one line hides the lines past the first; it does not
// hide the part of the second one that the row's own min-height leaves uncovered.
// The row is 15px and .puckNameTight's line box is 12.24, so 2.76px of the next
// line showed — "Christ…" with the ascenders of "opher" underneath it.
assert.equal(/(?<![\w-])line-clamp:/.test(CSS), false,
"a clamp and a fixed row height only agree while the type never moves");
assert.match(name[1], /text-overflow:\s*ellipsis/);
});
test("the box a name is measured in is the box it is drawn in", () => {
// What the clamp cost besides the stray line: a name that wrapped reported
// scrollWidth == clientWidth, so fitSeatNames() could only ever see the overflow
// as height. `nowrap` puts the whole name on one line whether it fits or not,
// which is what makes the width worth reading.
assert.match(name[1], /overflow:\s*hidden/);
assert.equal(/overflow-wrap:\s*break-word/.test(name[1]), false,
"nothing to break a word onto");
});
});
// ---------------------------------------------------------------------------
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);
});
});