// What the ring is allowed to be sized from. // // resolveSeatLayout buys its answer with ten forced layouts — ten candidate widths, each // applied to the felt and read back — and caches it against this signature. So the // signature has to move whenever a puck's height can, and must not move otherwise: one // that changed on every action would throw the cache away on every tap, which is the // whole cost it exists to avoid. // // It used to name four things: every player's name, the dealer's seat, whether anybody // had hole cards, and whether anybody had chips out. Three of those flip during an // ordinary hand. A dealer calling a bet paid ten forced layouts for it, and so did the // next street closing, and so did editing a name. // // None of them can change a puck's height any more. The stylesheet holds three rows open // — name, amount, stack — at a height that does not depend on what is written in them: // the name gets one line whatever it says, the amount pill keeps its row when it is // empty, and the dealer's chip changes a padding rather than a line count. What is left // is the hole-card row, which still collapses, because whether a table tracks hole cards // is something it decides once rather than something that changes under a thumb. // // The other half of that bargain is in renderSeats: it measures the finished pucks // against the box the search settled on and asks again if they disagree. That is what // makes a wrong assumption here a stale frame rather than a stale session — and it is // what catches the first render of all, where the pucks are built empty and the search // that used to run against them was measuring nine blank boxes. const test = require("node:test"); const { describe } = require("node:test"); const assert = require("node:assert"); const { Render, HoldemState, makeTable } = require("./load"); // seatShapeSignature reads Render.state, so a table has to be hung on it first. const signatureOf = state => { const before = Render.state; Render.state = state; try { return Render.seatShapeSignature(); } finally { Render.state = before; } }; const table = () => makeTable({ seatCount: 6, stack: 200 }); describe("the signature the ring layout is cached against", () => { test("moves when a hole card is entered", () => { // The one row still allowed to collapse, and the one thing still worth // re-measuring for: a puck with cards on it is about 15px taller. const state = table(); const none = signatureOf(state); state.seats[0].cards = ["AS", "KC"]; assert.notEqual(signatureOf(state), none); }); test("but not again for the second player to be dealt one", () => { // The point of a boolean over the table. The ring is sized from the tallest puck // on it, so once anybody has cards the answer cannot change. const state = table(); state.seats[0].cards = ["AS", "KC"]; const first = signatureOf(state); state.seats[1].cards = ["2H", "7D"]; assert.equal(signatureOf(state), first); }); test("and it stands still while the chips move", () => { // The expensive one. Somebody has money out for most of every hand and which // seats it is changes on every action, so this used to be a ten-layout re-search // twice a hand — and the pucks it was re-measuring for are the same height now // whether there is an amount in them or not. const state = table(); const quiet = signatureOf(state); state.seats[1].committed = 10; assert.equal(signatureOf(state), quiet, "the amount pill holds its row whether or not it has an amount in it"); state.seats[2].committed = 250; assert.equal(signatureOf(state), quiet); }); test("nor when an amount merely changes size", () => { const state = table(); state.seats[1].committed = 10; const small = signatureOf(state); state.seats[1].committed = 999999; assert.equal(signatureOf(state), small, "a wider number is the amount fitter's problem, not the ring's"); }); test("and it stands still for a rename", () => { // Renaming used to be able to wrap a name onto a second line. A name gets one // line now, and one that does not fit drops a word or steps its type down — see // fitSeatNames. Neither moves the row it sits in. const state = table(); const base = signatureOf(state); state.seats[0].name = "Bartholomew Fairweather"; assert.equal(signatureOf(state), base); }); test("and for the button moving round the table", () => { // The chip in the puck's corner takes 22px off the name's line. That is a // narrower name, not a taller puck, so the ring it sits on is the same ring. const state = table(); const base = signatureOf(state); state.dealerSeatIndex = (state.dealerSeatIndex + 1) % state.seats.length; assert.equal(signatureOf(state), base); }); }); describe("a table nobody has sat down at", () => { test("carries no flag at all", () => { // The empty-table case is the one the collapse is most visible on, and it is // also the one where a stray default could quietly keep the row open forever. const state = HoldemState.createDefault(); HoldemState.resizeSeats(state, 9); assert.equal(signatureOf(state), "", "an untouched table should carry nothing"); }); test("and neither does one with an ante on it", () => { // computeContribution answers for a seat that has never been given a stack — the // table's base pot stands in for it — so a table dealt with "everyone antes 5" // has amounts on its pucks before anybody acts. That used to have to be in here. // The pucks are the same height with it as without, so it does not any more. const state = HoldemState.createDefault(); HoldemState.resizeSeats(state, 6); state.basePotAmount = 5; state.seats.forEach(p => { p.inHand = true; }); assert.equal(signatureOf(state), ""); }); }); // --------------------------------------------------------------------------- // The other half: the stylesheet has to actually hold those rows open, or the signature // above is a promise about a puck that doesn't exist. // // Read as text, the way the seat sheet's rules and the reduced-motion rules are. What // height a row is, is a decision that lives in the file — the browser is where it is // proved, and runUiAudit.js is what proves it there. const fs = require("fs"); const path = require("path"); const CSS = fs.readFileSync( path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8"); const ruleFor = selector => { const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const found = CSS.match(new RegExp(`(^|[},])\\s*${escaped}\\s*\\{([^}]*)\\}`, "m")); return found ? found[2] : null; }; describe("every puck on the ring is the same puck", () => { test("each of the three rows is a row high whether or not it says anything", () => { // A blank label had no height at all, so the one puck with nobody in it was the // one puck a different size from the rest — and a seat that had put chips in was // a different size again from one that hadn't. ["name", "total", "stack"].forEach(row => { const selector = `.seatPuck${row[0].toUpperCase()}${row.slice(1)}`; const body = ruleFor(selector) || ""; assert.match(body, /min-height:\s*var\(--seatRow(Name|Money)\)/, `${selector} does not hold its row open`); }); }); test("and the row heights are tokens, not ems", () => { // fitAmount steps a tournament stack's type down so every digit stays on the // puck, and .puckNameTight does the same to a name with no word to drop. In ems // each of those would have taken its row down with it, and the puck with it. ["--seatRowName", "--seatRowMoney"].forEach(token => assert.match(CSS, new RegExp(`\\${token}:\\s*\\d+px`), `${token} is not a fixed height`)); }); test("the amount pill hides its paint rather than giving up its place", () => { // Collapsing it is what made a puck grow 17px when its player called and shrink // again when the street closed. Painting an empty pill is what collapsing it was // introduced to stop — an 8x11px green lozenge on seven of nine seats, standing // for nothing. Neither, now: the row stays and the pill is not drawn. assert.match(ruleFor(".seatPuckTotal:empty") || "", /visibility:\s*hidden/); assert.doesNotMatch(ruleFor(".seatPuckTotal:empty") || "", /display:\s*none/); }); test("but the hole-card row still collapses, because a table decides that once", () => { // Holding 15px open on nine seats all night for a row most tables never fill is // the crowding this is meant to avoid, and it is the one thing left that the // signature above still watches for. assert.match(ruleFor(".seatPuckCards:empty") || "", /display:\s*none/); }); test("an empty pill sits on its top edge, not on a baseline it hasn't got", () => { // An inline-block sits on its last line's baseline, and an empty one has no last // line — so it sits on its bottom edge instead and the line box comes out 2px // shorter than the same pill with a figure in it. That put the seats with money // out 2px below everyone else: the one difference this row exists to remove, // reappearing as its own mirror image. assert.match(ruleFor(".seatPuckTotal") || "", /vertical-align:\s*top/); }); }); // --------------------------------------------------------------------------- describe("and the ring checks its own measurement afterwards", () => { // The signature above is an assumption about what can change a puck. This is what // happens when the assumption is wrong, and it is also what happens on the first // render of all: buildSeatNodes puts nine empty pucks on the felt and the search runs // before anything is written into them, so it was measuring nine blank boxes and // caching the answer under a key naming a table it had never seen. // // That used to be permanent. The comment where it was cached said "let the resize // that follows do this properly" — and nothing resizes a phone. A table opened // upright kept a ring measured for empty seats until the dealer turned the phone // sideways and back, which is the only thing that ever changed the key. const withBox = (box, run) => { const before = Render.measuredSeatBox; Render.measuredSeatBox = () => box; try { return run(); } finally { Render.measuredSeatBox = before; } }; test("a layout that measured nothing is always asked again", () => { // No box on the answer means the search never had a puck in front of it. assert.equal(withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved(null)), true); assert.equal(withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved(undefined)), true); }); test("one that measured the puck that is now on the felt is not", () => { assert.equal( withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved({ width: 72, height: 56 })), false); }); test("and a pixel of difference is a rounding artefact, not a table that moved", () => { // The two measurements are taken at different moments. Without the slack this // would ask the search to run again on every render for the rest of the session, // which is ten forced layouts a tap — the exact cost the cache exists to avoid. assert.equal( withBox({ width: 72.4, height: 56.4 }, () => Render.seatBoxMoved({ width: 72, height: 56 })), false); assert.equal( withBox({ width: 72, height: 58 }, () => Render.seatBoxMoved({ width: 72, height: 56 })), true, "two pixels taller is a puck that has changed shape"); }); test("nothing on the felt to measure is not a reason to ask again", () => { // Between a table being resized and the new pucks being built there is a moment // with no seats at all. Asking again there would clear a good answer and replace // it with one measured from nothing. assert.equal(withBox(null, () => Render.seatBoxMoved({ width: 72, height: 56 })), false); }); }); // --------------------------------------------------------------------------- describe("how much room a figure has on the puck it is written on", () => { // The two steps of type an amount comes down used to be chosen by counting // characters: eight for the first, ten for the second. That is a guess at how wide a // figure is, and it happens to hold at one text size and one puck width. // // Turn a phone's text up — 20px of root text is what an Android slider gives you at // 125%, and every size in the stylesheet is in `rem` on purpose — and it stops // holding. Measured at 360x640 twelve-handed: the puck is at its 44px floor, which // is 34px of usable width, and "$1,200" is six characters, took no step at all, and // wanted 38. The sweep caught it as a stack figure painted over the seat next door // at every phone in the list, and as "ALL IN" doing the same on the all-in table. // // So it is measured. A cell is a stand-in for the real one: what it wants against // what it has, and a class list to write the answer into. const cell = (figure, wants, has) => ({ dataset: { figure }, textContent: figure, classes: new Set(), classList: { add(name) { this.owner.classes.add(name); }, remove(name) { this.owner.classes.delete(name); }, }, // What the text wants is proportional to how many characters are in it and how // far down the ladder it has come — which is what a browser would tell us. get scrollWidth() { const step = this.classes.has("puckAmountHuge") ? 0.7 : this.classes.has("puckAmountLong") ? 0.85 : 1; return Math.round((wants * this.textContent.length / figure.length) * step); }, clientWidth: has, }); const make = (...args) => { const c = cell(...args); c.classList.owner = c; return c; }; const fit = cells => { const before = Render.overflowing; Render.overflowing = el => el.scrollWidth > el.clientWidth + 1; try { Render.fitAmounts(cells); } finally { Render.overflowing = before; } }; test("a figure that fits is left at the size it is meant to be read at", () => { const c = make("$600", 30, 34); fit([c]); assert.equal(c.textContent, "$600"); assert.deepEqual([...c.classes], []); }); test("one that doesn't gives up its separators first", () => { // Grouping is a reading aid for a column of figures. This is one number on a // 44px puck, and a step of type costs more than the commas do. const c = make("$1,200", 38, 34); fit([c]); assert.equal(c.textContent, "$1200"); assert.deepEqual([...c.classes], [], "no type step needed once the commas are out"); }); test("then a step of type, and then another", () => { const c = make("$1,234,567", 90, 34); fit([c]); assert.equal(c.textContent, "$1234567"); assert.deepEqual([...c.classes], ["puckAmountHuge"]); }); test("and only ever one step at a time is worn", () => { // The two rules have the same specificity, so a puck wearing both would be sized // by whichever the stylesheet happens to mention last. const c = make("$1,234,567", 45, 34); fit([c]); assert.equal(c.classes.has("puckAmountHuge") && c.classes.has("puckAmountLong"), false); }); test("a figure with no separators to give goes straight to the type", () => { // "ALL IN" is the one thing on this row that isn't a number, and it is the widest // — six characters and a space, at 700 weight with its letters spread apart. const c = make("ALL IN", 39, 34); fit([c]); assert.equal(c.textContent, "ALL IN"); assert.deepEqual([...c.classes], ["puckAmountLong"]); }); test("a puck that went small can come back", () => { // Everything is measured from the figure and the size it is meant to be read at, // not from whatever the last render left behind. Otherwise this is a ratchet: one // crowded frame and the seat is small for the rest of the night. const c = make("$600", 30, 34); c.classes.add("puckAmountHuge"); c.textContent = "$600"; fit([c]); assert.deepEqual([...c.classes], []); }); test("and the grouped figure is what it comes back to", () => { const c = make("$1,200", 38, 34); fit([c]); assert.equal(c.textContent, "$1200"); c.clientWidth = 96; fit([c]); assert.equal(c.textContent, "$1,200", "a wider puck should get its separators back"); }); test("an empty row is not an amount", () => { // The pill keeps its place on a seat with nothing in the pot — that is what makes // every puck on the ring the same puck — and there is nothing there to fit. const c = make("", 0, 34); assert.doesNotThrow(() => fit([c])); assert.deepEqual([...c.classes], []); }); });