// 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(/(? { // 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); }); });