// 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"); // --------------------------------------------------------------------------- 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. Keeping the name out // from under it with `padding-right` takes the chip's width off *every* line of the // name, and the chip is on one of them: measured at 1280x900, the name box is 78px, // "Christopher" is 66 and fits, and on the seat holding the button it was cut to 56 // and broke as "Christop / her". The button moves every hand, so that is a player // whose name reads properly for four hands and comes apart on the fifth. const roomy = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/); const float = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName::before \{([^}]*)\}/); const base = CSS.match(/\n\.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/); test("a ring with room for two lines indents only the line beside the chip", () => { assert.ok(roomy && float, "the roomy regime should use a float"); assert.match(float[1], /float:\s*right/); assert.match(roomy[1], /padding-right:\s*0/); }); test("the float is exactly one line tall", () => { // Tall enough to cover the chip's reach into the name, and no taller: a float // that spills into the second line indents that one too, which puts the whole // problem back. assert.match(float[1], /height:\s*1\.2em/); }); test("break-word stands down where the float is doing the work", () => { // It is what stops the float working — it breaks a word to fit the short line // rather than letting it move past the float to the long one. assert.match(roomy[1], /overflow-wrap:\s*normal/); }); test("a crowded ring keeps the padding, because it has only one line", () => { // `.seatsTight .seatPuckName` is `line-clamp: 1` on purpose, and one line has // nowhere to move to — the only way to keep it off the chip is to shorten it. // Trying the float on both cost a whole sweep to find out: the name dropped onto // a second line that the chip's lower half was still sitting on, and the audit // called it at every viewport in the list. assert.ok(base, "the base rule should still reserve the chip's width"); assert.match(base[1], /padding-right:\s*22px/); assert.match(CSS, /\.seatsTight \.seatPuckName \{[^}]*line-clamp:\s*1/); }); test("the float is as wide as the chip it stands in for", () => { const chip = CSS.match(/\n\.dealerChip \{[^}]*?(? { // 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 el = { textContent: text, title: "", // 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 is left to the ellipsis it always had", () => { // There is no honest way to shorten "Christopher" that a dealer glancing across // a table would read faster than "Christo…". const el = label("Christopher", () => false); seatsNamed("Christopher"); Render.fitSeatNames(layer([el])); assert.equal(el.textContent, "Christopher", "never cut mid-word here"); }); 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"); }); 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); }); });