// What the long-press menu offers, and in what shape. // // It had grown to eleven actions drawn as eleven identical full-width bars. Measured // on the fullest menu the tool can build — a live seat that isn't the dealer, blinds // still the only money in, an override already set — the sheet came to 796px and took // 85% of every viewport in the sweep. It scrolled on all four, and Cancel was off the // bottom of the screen on all four: 79px past it on a 390px phone, 252px on a 360px // one, 448px in landscape. Apple puts the comfortable number for an action sheet at // three to five and says not to let one scroll at all. // // Nothing was cut — every one of the eleven is the only route to something, and // "Blinds follow the button again" is the only route in the whole tool to clearing an // override. They were sorted by *when* instead: what you tap with cards in the air // keeps the full-width bars, and the housekeeping goes to a two-column grid under a // caption. Same fullest menu afterwards: 650px, 77%, no scroll, and the way out is in // a header that can't scroll away. // // Two of these are answered by running the code — which actions a seat is offered, // and which group each lands in. The rest are layout decisions, checked as text // against the markup and the stylesheet. 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 PAGE = fs.readFileSync( path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8"); const CSS = fs.readFileSync( path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8"); const sheetMarkup = () => { const at = PAGE.indexOf(`
= 0, "there is no seat menu overlay"); return PAGE.slice(at, PAGE.indexOf("
\n\n", at)); }; // The rule block for a selector, so a test asks about one rule rather than about // whether a string happens to appear anywhere in 6000 lines. 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)); }; // The fullest menu the tool can build, aimed at seat 3. function fullestMenu() { const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3, 4], stack: 500, dealerSeatIndex: 0 }); Betting.beginHand(state); state.blindOverride = { sbIndex: 1, bbIndex: 2 }; state.waitingList = [{ id: "w1", name: "Nina" }]; SeatMenu.state = state; SeatMenu.hooks = {}; return { state, actions: SeatMenu.actionsFor(state.seats[3], 3) }; } const labels = actions => actions.map(a => a.label); const inGroup = (actions, group) => labels(actions.filter(a => a.group === group)); // --------------------------------------------------------------------------- describe("everything the menu used to offer, it still offers", () => { test("all eleven are there on the fullest menu", () => { const { actions } = fullestMenu(); assert.deepEqual(labels(actions).sort(), [ "Add chips…", "All-in", "Big blind", "Blinds follow the button again", "Cash out…", "Edit seat…", "Fold", "Move to…", "Sit out", "Small blind", "The button", ]); }); // The three that lost words lost them to the caption above them, not to brevity // for its own sake: "Give this seat" is what makes "Small blind" a verb, and a // verb that short is what fits a half-width tile on one line. test("the three that were shortened are the three the caption speaks for", () => { const { actions } = fullestMenu(); assert.deepEqual(inGroup(actions, "position"), [ "The button", "Small blind", "Big blind", "Blinds follow the button again", ]); }); // This menu is the only place in the tool that offers it. If it ever stops being // offered here, a dealer who names a blind by hand can never take it back. test("the only way out of a blind override is still here", () => { const { actions } = fullestMenu(); assert.ok(labels(actions).includes("Blinds follow the button again")); }); }); describe("a group is when you do it, not what it touches", () => { test("the hand keeps only what a dealer reaches for with cards on the table", () => { const { actions } = fullestMenu(); assert.deepEqual(inGroup(actions, "hand"), ["Fold", "All-in"]); }); test("a folded player's one move is in the hand too", () => { const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 }); Betting.beginHand(state); state.seats[2].folded = true; SeatMenu.state = state; SeatMenu.hooks = {}; const actions = SeatMenu.actionsFor(state.seats[2], 2); assert.deepEqual(inGroup(actions, "hand"), ["Un-fold"]); }); // Seating the front of the queue is not housekeeping. A chair opens between two // hands with people watching it, and the dealer fills it then or not at all. test("seating the next player off the rail is a hand action", () => { const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 }); // makeTable names every chair; an open one has nobody on it. state.seats[4].name = ""; state.waitingList = [{ id: "w1", name: "Nina" }]; SeatMenu.state = state; SeatMenu.hooks = {}; const actions = SeatMenu.actionsFor(state.seats[4], 4); assert.deepEqual(inGroup(actions, "hand"), ["Seat Nina"]); }); test("every action lands in a group the sheet knows how to draw", () => { const { actions } = fullestMenu(); const known = ["hand", ...SeatMenu.GROUPS.map(g => g.name)]; actions.forEach(action => { assert.ok(known.includes(action.group), `"${action.label}" is in group "${action.group}", which nothing draws`); }); }); // A caption for nothing is worse than no caption, and this group empties often: // past the blinds there is nothing to give a seat but the button. test("a group that has nothing to say is empty rather than thin", () => { const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 0 }); Betting.beginHand(state); Betting.call(state, 3); SeatMenu.state = state; SeatMenu.hooks = {}; const actions = SeatMenu.actionsFor(state.seats[3], 3); assert.deepEqual(inGroup(actions, "position"), ["The button"]); }); }); describe("the one action here that is not about this seat", () => { // It puts the whole table's blinds back on automatic. Spanning the grid is what // stops it reading as a fourth position to give. test("the override reset spans the grid and nothing else does", () => { const { actions } = fullestMenu(); assert.deepEqual(labels(actions.filter(a => a.wide)), ["Blinds follow the button again"]); }); test("and it is drawn lighter than the Cancel underneath it", () => { const rule = ruleFor(".seatMenuBtn.menuTileWide"); assert.match(rule, /grid-column:\s*1 \/ -1/); assert.match(rule, /border-style:\s*dashed/); // Lighter, not smaller. A thumb does not care how rare an action is, so it // keeps the 44px every other tile has rather than shrinking out of reach. assert.ok(!/min-height/.test(rule), "the wide row sets its own height, which takes it under the touch floor"); }); }); describe("the sheet a dealer reads it in", () => { test("the header is stuck to the top, so whose menu this is cannot scroll away", () => { const rule = ruleFor(".seatMenuHeader"); assert.match(rule, /position:\s*sticky/); assert.match(rule, /top:\s*0/); // A sticky header inside a padded scroller shows the list sliding through the // gap either side of it unless it reaches the sheet's own edges. assert.match(rule, /margin:\s*0 calc\(var\(--sp-4\) \* -1\)/); assert.match(rule, /background:/); }); test("and the sheet keeps no padding above it to slide through", () => { assert.match(ruleFor("#seatMenuSheet"), /padding:\s*0 var\(--sp-4\)/); }); // NN/g is explicit that a grab handle is not a close affordance. This sheet is the // one that can outgrow the screen, so it is also the one that most needs a way out // in the part that cannot leave. test("there is a real close button, not just a grip", () => { const markup = sheetMarkup(); assert.match(markup, /id="seatMenuCloseBtn"/); assert.match(markup, /aria-label="Close this menu"/); }); // Modal focuses a dialog's first control, and the first control used to be Fold. // The APG says to keep initial focus off the destructive choice; a close button at // the top of the markup is what puts it there without a special case. test("the first control in the sheet is the one that does nothing", () => { const markup = sheetMarkup(); assert.ok(markup.indexOf("seatMenuCloseBtn") < markup.indexOf("seatMenuActions"), "the actions come before the close button, so focus opens on Fold again"); }); // Escape finds the first [data-modal-close] in the overlay. That has to be the one // that shuts the sheet, not the one at the foot that steps back inside it. test("Escape closes the sheet rather than stepping back inside it", () => { const markup = sheetMarkup(); const closer = markup.indexOf("data-modal-close"); assert.ok(closer >= 0 && closer < markup.indexOf("seatMenuActions"), "Escape does not reach a closer above the action list"); assert.ok(!/id="seatMenuCancelBtn"[^>]*data-modal-close/.test(markup), "the button at the foot still answers Escape, so Escape only goes back"); }); }); describe("two columns, and what they are for", () => { test("the housekeeping half is a grid", () => { assert.match(ruleFor(".seatMenuGrid"), /grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/); }); // Half the width and a step quieter, but not below the floor a fingertip needs. test("a tile is quieter than a bar without being smaller than a thumb", () => { const rule = ruleFor(".seatMenuBtn.menuTile"); assert.match(rule, /min-height:\s*44px/); assert.match(rule, /font-size:\s*var\(--fs-base\)/); }); test("a bar is still a bar", () => { assert.match(ruleFor(".seatMenuBtn"), /min-height:\s*52px/); }); // The dashed row and the Cancel under it are both full width. Two rows of the same // size touching, one of which changes the table and one of which changes nothing, // is a mistake waiting at the end of a scroll. test("the last action and the way out are not two rows touching", () => { assert.match(ruleFor("#seatMenuCancelBtn"), /margin-top:\s*var\(--sp-3\)/); }); }); describe("the move picker is no longer a one-way door", () => { const MAIN = fs.readFileSync( path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "main.js"), "utf8"); // Opening it used to replace the whole sheet, and the only control left closed the // menu outright — a dealer who opened the seat list by mistake lost their place. test("the button at the foot goes back while there is an inside to go back to", () => { const wiring = MAIN.slice(MAIN.indexOf('getElementById("seatMenuCancelBtn")')); const body = wiring.slice(0, wiring.indexOf("});")); assert.match(body, /SeatMenu\.mode === "move"/); assert.match(body, /SeatMenu\.closeMovePicker\(\)/); assert.match(body, /SeatMenu\.close\(\)/); }); test("and the menu knows how to come back", () => { assert.equal(typeof SeatMenu.closeMovePicker, "function"); SeatMenu.mode = "move"; SeatMenu.state = { seats: [] }; SeatMenu.seatIndex = 0; // render() needs a document, so the mode change is checked where it is made. assert.match(SeatMenu.closeMovePicker.toString(), /this\.mode = null/); }); test("every other chair is a destination, including the empty ones", () => { const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 }); SeatMenu.state = state; const seats = SeatMenu.destinationsFor(2).map(d => d.seat); assert.deepEqual(seats, [0, 1, 3, 4, 5]); }); });