// The seat menu on a phone held sideways. // // The tool learned two rounds ago that a landscape phone is short of height and // swimming in width: the action bar stops being a bar across the bottom and becomes a // rail down the right. The sheets were never told. Measured on the fullest menu the // tool can build — twelve seated, somebody on the waiting list, blinds still the only // money in: // // 640x360 545px of content in a 306px sheet. Cash out…, Move to…, Edit seat…, // Small blind, Big blind and Cancel all below the fold, Cancel by 224px. // 667x375 545 in 319, Cancel 211px under. // 844x390 529 in 332, Cancel 183px under. // // And on all three the sheet was 420px wide inside a window 640 to 844 wide. Two // hundred and forty-seven pixels of width held empty on the one screen shape whose // whole problem is height, while the dealer scrolled a menu to find the way out of it. // // The two tiers this menu has been sorted into since round 7 — what you press with // cards in the air, and housekeeping between hands — stop being the top and bottom of // a column and become the right and left of a row. Fold and All-in land under the same // thumb the rail puts Fold, Call and Raise under, with Cancel beneath them. Measured // after: 297px in a 298px sheet at 640x360 and at 667x375, 281 in 282 at 844x390. // Nothing below the fold on any of them, and the move picker's twelve chairs and its // Back went from four rows and a scroll to three rows and none. // // Upright nothing moved at all: 545px before, 545px after, the same two columns. const test = require("node:test"); const { describe } = require("node:test"); const assert = require("node:assert"); const fs = require("fs"); const path = require("path"); const { Betting, HoldemState, SeatMenu, makeTable } = require("./load"); const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net"); const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8"); const ruleFor = selector => { const at = CSS.indexOf(`\n${selector} {`); assert.ok(at >= 0, `there is no \`${selector}\` rule`); return CSS.slice(at, CSS.indexOf("}", at)); }; // Everything inside one @media block, braces balanced, so a test asks what that block // says rather than whether a string appears anywhere in six thousand lines. const SHORT = "@media (max-height: 560px) and (min-width: 600px) {"; function shortViewportBlock() { const at = CSS.indexOf(SHORT); assert.ok(at >= 0, "there is no short-viewport block for the sheets"); let depth = 0; for (let i = at + SHORT.length - 1; i < CSS.length; i++) { if (CSS[i] === "{") depth++; else if (CSS[i] === "}" && --depth === 0) return CSS.slice(at, i + 1); } assert.fail("the short-viewport block is never closed"); } // A document small enough to answer what paint() asks of one. It builds elements, // appends them and reads their labels back; nothing here lays anything out, which is // the stylesheet's half of the job and is checked as text below. function fakeDom() { const element = tag => ({ tag, className: "", type: "", textContent: "", children: [], appendChild(child) { this.children.push(child); return child; }, addEventListener() {}, setAttribute() {}, focus() {}, get childElementCount() { return this.children.length; }, set innerHTML(value) { assert.equal(value, "", "only clearing is modelled"); this.children = []; }, get innerHTML() { return ""; }, }); const byId = {}; ["seatMenuTitle", "seatMenuSubtitle", "seatMenuActions", "seatMenuCancelBtn", "seatMenuOverlay"].forEach(id => { byId[id] = element("div"); }); return { byId, doc: { createElement: element, getElementById: id => byId[id] || null, }, }; } function withDocument(doc, body) { const had = Object.prototype.hasOwnProperty.call(globalThis, "document"); const before = globalThis.document; globalThis.document = doc; try { return body(); } finally { if (had) globalThis.document = before; else delete globalThis.document; } } // The fullest menu the tool can build, aimed at a live seat that isn't the dealer with // the blinds still the only money in and an override already set. function painted(seatIndex = 3, { move = false, seated = [0, 1, 2, 3, 4], waiting = true } = {}) { const state = makeTable({ seatCount: 9, seated, stack: 500, dealerSeatIndex: 0 }); Betting.beginHand(state); state.blindOverride = { sbIndex: 1, bbIndex: 2 }; if (waiting) state.waitingList = [{ id: "w1", name: "Nina" }]; const { doc, byId } = fakeDom(); return withDocument(doc, () => { SeatMenu.state = state; SeatMenu.hooks = {}; SeatMenu.seatIndex = seatIndex; SeatMenu.mode = move ? "move" : null; SeatMenu.paint(); return { list: byId.seatMenuActions, cancel: byId.seatMenuCancelBtn, state }; }); } const classes = el => el.className.split(/\s+/).filter(Boolean); const labels = el => el.children.map(child => child.textContent); // --------------------------------------------------------------------------- describe("the two tiers are boxes, not a run of siblings", () => { // Five loose children can only ever stack. Two boxes can be placed. test("the sheet's actions are exactly the hand and the housekeeping", () => { const { list } = painted(); assert.deepEqual(list.children.map(child => child.className), ["seatMenuHand", "seatMenuKeeping"]); }); test("the hand tier holds the bars a dealer presses with cards in the air", () => { const { list } = painted(); const hand = list.children[0]; assert.deepEqual(labels(hand), ["Fold", "All-in"]); hand.children.forEach(btn => { assert.ok(classes(btn).includes("seatMenuBtn")); assert.ok(!classes(btn).includes("menuTile"), "a bar is not a tile — the whole point of the tier is that it is a bar"); }); }); test("and the housekeeping tier holds its captions and grids in order", () => { const { list } = painted(); const keeping = list.children[1]; assert.deepEqual(keeping.children.map(child => child.className), ["seatMenuCaption", "seatMenuGrid", "seatMenuCaption", "seatMenuGrid"]); assert.deepEqual(keeping.children.map(child => child.tag), ["p", "div", "p", "div"]); }); // The sideways layout reads the tier's absence to know it has one column rather // than two, so an empty tier has to be absent rather than empty. test("a tier with nothing in it is left out rather than drawn empty", () => { // A chair nobody is sitting in, with nobody waiting to take it: there is // nothing at all to do to it while the cards are in the air. const { list } = painted(7, { seated: [0, 1, 2], waiting: false }); assert.deepEqual(list.children.map(child => child.className), ["seatMenuKeeping"], "an empty chair still drew a hand tier with nothing in it"); }); }); describe("the move picker", () => { test("its chairs go in the housekeeping tier too, so the sheet has one column", () => { const { list } = painted(3, { move: true }); assert.deepEqual(list.children.map(child => child.className), ["seatMenuKeeping"]); }); // "Seat 3 — Alexandra" sets 116px against "Sit out"'s 50, so this grid asks for a // different column from the one the tiles use. test("and they ask for a column of their own width", () => { const { list } = painted(3, { move: true }); const grid = list.children[0].children[0]; assert.deepEqual(classes(grid), ["seatMenuGrid", "seatMenuGridNames"]); }); test("the way back out is still the sheet's own foot button", () => { const { cancel } = painted(3, { move: true }); assert.equal(cancel.textContent, "Back"); assert.equal(painted(3).cancel.textContent, "Cancel"); }); }); describe("upright, nothing moved", () => { // The wrappers gap exactly as the loose children used to, so the sheet a dealer // holding a phone the usual way sees is the one it has been since round 7. test("both tiers are the same column the sheet already was", () => { const rule = ruleFor(".seatMenuHand,\n.seatMenuKeeping"); assert.match(rule, /flex-direction:\s*column/); assert.match(rule, /gap:\s*var\(--sp-2\)/); assert.match(rule, /min-width:\s*0/); }); test("and the grid is still two columns wherever the screen is not short", () => { assert.match(ruleFor(".seatMenuGrid"), /grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/); }); }); describe("what the short screen does instead", () => { const block = shortViewportBlock(); // Height and width, not orientation: a short desktop window has the same shortage // and the same answer, and a narrow one has neither the width to spend nor a second // column to spend it on. test("it is scoped on the shortage rather than on which way up the phone is", () => { assert.ok(!/orientation/.test(SHORT), "orientation says nothing about how much height there is"); }); test("the sheet takes the width it has been leaving empty", () => { assert.match(block, /max-width:\s*min\(100% - var\(--sp-5\), 46rem\)/); }); test("the tiers become the two columns, with the way out under the thumb", () => { assert.match(block, /"header\s+header"/); assert.match(block, /"keeping hand"/); assert.match(block, /"keeping cancel"/); }); test("which means the box that held them stands aside", () => { const at = block.indexOf("#seatMenuActions {"); assert.ok(at >= 0, "#seatMenuActions is not addressed here"); assert.match(block.slice(at, block.indexOf("}", at)), /display:\s*contents/); }); // A column reserved for nothing would narrow the names for no reason. test("a sheet with no hand tier is one column, not two", () => { const at = block.indexOf("#seatMenuSheet:not(:has(.seatMenuHand))"); assert.ok(at >= 0, "the move picker still reserves a column for a tier it hasn't got"); const rule = block.slice(at, block.indexOf("}", at)); assert.match(rule, /grid-template-columns:\s*minmax\(0, 1fr\)/); assert.match(rule, /"keeping"/); }); test("the tiles spread into as many columns as the width holds", () => { assert.match(block, /repeat\(auto-fill, minmax\(var\(--tileMin\), 1fr\)\)/); }); // auto-fit throws away the tracks nothing sits in, so a group of two stretched to // half the sheet each while the group above it was a third — two grids in a column // lining up with nothing. test("and they line up with each other, group to group", () => { // The comment above the rule says the word it is here to rule out, so the code // is read with the comments stripped out of it. const code = block.replace(/\/\*[\s\S]*?\*\//g, ""); assert.ok(!/auto-fit/.test(code), "auto-fit collapses the empty tracks and the grids stop sharing column edges"); }); // The left column is the taller of the two, and stretching left Cancel a 74px // button on one phone and a 48px one on the next. test("nothing grows into the slack the taller column leaves", () => { assert.match(block, /align-items:\s*start/); }); }); describe("the numbers the columns are chosen from", () => { // The floor is what the longest of these labels needs, not a round figure that // happened to work on the phone it was measured on. test("a tile's narrowest is a token the grid of names can raise", () => { assert.match(ruleFor(".seatMenuGrid"), /--tileMin:\s*7\.5rem/); const block = shortViewportBlock(); const at = block.indexOf(".seatMenuGrid.seatMenuGridNames"); assert.ok(at >= 0, "the chairs never ask for more room than a verb"); assert.match(block.slice(at, block.indexOf("}", at)), /--tileMin:\s*8\.25rem/); }); // Two words. Every pixel over that comes out of the left column, where it is a // whole extra column of tiles. test("the hand column is sized for Fold and All-in and no wider", () => { const block = shortViewportBlock(); assert.match(block, /grid-template-columns:\s*minmax\(0, 1fr\) minmax\(8rem, 10rem\)/); }); test("what was measured is written beside the rules", () => { const block = shortViewportBlock(); const note = CSS.slice(CSS.indexOf("A phone on its side: the seat menu goes beside itself"), CSS.indexOf(SHORT)); assert.match(note, /640x360/); assert.match(note, /545px/); assert.match(note, /224px/); assert.match(block, /10rem it\s+gets 397 and fits three/); }); });