rather than on the control the rule inspects.
+//
+// Three named states cannot contradict each other and none of them is ever unavailable,
+// so there is nothing left to dim.
+//
+// The second half of this round is where that form appears at all. The phone block's
+// own comment has always said why the editor becomes a bottom sheet — "the seat editor
+// lives far enough down the page that tapping a seat looks like it did nothing" — but
+// the sheet was scoped to 700px while #holdemLayout goes to one column at 900. The two
+// tiers in between had precisely the fault the sheet was written to fix. Measured,
+// tapping a seat:
+//
+// 844x390 the editor drew in the flow at y=369 in a 390px window: 21px of 401 on
+// screen, 5% of it, at scrollY 0 with nothing scrolling it into view.
+// 768x1024 31% visible, the rest below the fold, and again no scroll.
+//
+// Afterwards every viewport from 360x640 to 900x500 shows 97-99% of the sheet, and
+// above 900 the editor is a static block at the top of a sidebar that is beside the
+// table rather than below it — 100% visible there too.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const fs = require("fs");
+const path = require("path");
+const { Render, makeTable } = require("./load");
+
+const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
+const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
+const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
+
+const seatForm = () => {
+ const at = PAGE.indexOf('');
+ assert.ok(at >= 0, "there is no seat editor template");
+ return PAGE.slice(at, PAGE.indexOf(" ", at));
+};
+
+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 every block with this query, braces balanced and joined. Every one
+// of them: this stylesheet opens `@media (hover: none)` twice, and a helper that stops
+// at the first has twice caught a test asserting about the wrong half of the file.
+function blockAt(query) {
+ const found = [];
+ for (let at = CSS.indexOf(query); at >= 0; at = CSS.indexOf(query, at + 1)) {
+ let depth = 0;
+ for (let i = at + query.length - 1; i < CSS.length; i++) {
+ if (CSS[i] === "{") depth++;
+ else if (CSS[i] === "}" && --depth === 0) { found.push(CSS.slice(at, i + 1)); break; }
+ }
+ }
+ assert.ok(found.length, `there is no \`${query}\` block`);
+ return found.join("\n");
+}
+
+// The three buttons, and enough of a form to hand them back.
+function fakeForm() {
+ const options = Render.SEAT_STATES.map(state => ({
+ dataset: { seatState: state.key },
+ attrs: {},
+ tabIndex: 0,
+ handlers: {},
+ setAttribute(name, value) { this.attrs[name] = value; },
+ getAttribute(name) { return this.attrs[name]; },
+ addEventListener(type, fn) { this.handlers[type] = fn; },
+ focus() { options.focused = this.dataset.seatState; },
+ }));
+ return {
+ options,
+ querySelectorAll: selector => {
+ assert.equal(selector, ".seatStateOption");
+ return options;
+ },
+ };
+}
+
+const checked = form => form.options.filter(o => o.getAttribute("aria-checked") === "true")
+ .map(o => o.dataset.seatState);
+
+// ---------------------------------------------------------------------------
+
+describe("three states, not two booleans", () => {
+ test("the markup asks one question with three answers", () => {
+ const form = seatForm();
+ const at = form.indexOf('class="seatState"');
+ assert.ok(at >= 0, "the seat form has no state control");
+ const group = form.slice(form.lastIndexOf("", at));
+ assert.match(group, /role="radiogroup"/);
+ assert.match(group, /aria-label="This seat"/);
+ ["out", "in", "folded"].forEach(key => {
+ assert.match(group, new RegExp(`data-seat-state="${key}"`), `there is no "${key}" answer`);
+ });
+ assert.equal((group.match(/role="radio"/g) || []).length, 3);
+ });
+
+ test("and the two checkboxes are gone from the page and the stylesheet", () => {
+ ["inHandCheckbox", "foldCheckbox", "inHandToggle", "foldToggle", "seatCardToggles"]
+ .forEach(name => {
+ assert.ok(!PAGE.includes(name), `${name} is still in the markup`);
+ assert.ok(!CSS.includes(name), `${name} is still in the stylesheet`);
+ });
+ });
+
+ // The pair (out, folded) is the one the old control could be asked for.
+ test("every state names both halves, and none of them is the meaningless pair", () => {
+ assert.deepEqual(Render.SEAT_STATES.map(s => s.key), ["out", "in", "folded"]);
+ Render.SEAT_STATES.forEach(state => {
+ assert.equal(typeof state.inHand, "boolean");
+ assert.equal(typeof state.folded, "boolean");
+ assert.ok(!(state.folded && !state.inHand),
+ `"${state.key}" is folded and not in the hand, which means nothing`);
+ });
+ });
+
+ test("and a seat reads back as the state it was put in", () => {
+ Render.SEAT_STATES.forEach(state => {
+ assert.equal(Render.seatStateOf({ inHand: state.inHand, folded: state.folded }), state.key);
+ });
+ // Including a seat saved before any of this, where folded was left set on a
+ // chair that had sat out.
+ assert.equal(Render.seatStateOf({ inHand: false, folded: true }), "out");
+ });
+});
+
+describe("what the control draws", () => {
+ test("exactly one answer is marked, and it is the seat's", () => {
+ const form = fakeForm();
+ Render.paintSeatState(form, { inHand: true, folded: true });
+ assert.deepEqual(checked(form), ["folded"]);
+ Render.paintSeatState(form, { inHand: false, folded: false });
+ assert.deepEqual(checked(form), ["out"]);
+ Render.paintSeatState(form, { inHand: true, folded: false });
+ assert.deepEqual(checked(form), ["in"]);
+ });
+
+ // One stop for the group, arrows inside it — the APG's radio group, and the same
+ // shape the card picker's suits use.
+ test("and exactly one of the three is a Tab stop", () => {
+ const form = fakeForm();
+ Render.paintSeatState(form, { inHand: true, folded: false });
+ assert.deepEqual(form.options.map(o => o.tabIndex), [-1, 0, -1]);
+ });
+});
+
+describe("what pressing one does", () => {
+ const wired = player => {
+ const form = fakeForm();
+ const commits = [];
+ const realCommit = Render.commitSeatChange;
+ Render.commitSeatChange = () => commits.push(true);
+ try {
+ Render.wireSeatState(form, player, 0);
+ } finally {
+ Render.commitSeatChange = realCommit;
+ }
+ return { form, commits, press: key => {
+ const btn = form.options.find(o => o.dataset.seatState === key);
+ const again = Render.commitSeatChange;
+ Render.commitSeatChange = () => commits.push(true);
+ try { btn.handlers.click(); } finally { Render.commitSeatChange = again; }
+ } };
+ };
+
+ test("it writes both halves of the seat, not one", () => {
+ const player = { inHand: true, folded: false };
+ const { press } = wired(player);
+ press("folded");
+ assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: true, folded: true });
+ press("out");
+ assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: false, folded: false });
+ press("in");
+ assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: true, folded: false });
+ });
+
+ test("and says so, so the table redraws", () => {
+ const player = { inHand: true, folded: false };
+ const { commits, press } = wired(player);
+ const before = commits.length;
+ press("out");
+ assert.ok(commits.length > before, "nothing was committed, so the felt still shows the old state");
+ });
+
+ // Typing a name into an empty chair seats somebody. The control has to say so —
+ // it is the only thing on the form that shows it.
+ test("naming an empty chair repaints the control rather than leaving it stale", () => {
+ const src = fs.readFileSync(
+ path.join(ROOT, "wwwroot", "js", "casino", "holdem", "render.js"), "utf8");
+ const at = src.indexOf("const wasEmpty = !player.name.trim()");
+ assert.ok(at >= 0);
+ assert.match(src.slice(at, at + 400), /this\.paintSeatState\(form, player\);/);
+ });
+});
+
+describe("how it is drawn", () => {
+ // The answer moves between the three as the hand goes on, and a segment that
+ // resizes under the thumb reads as the control flinching.
+ test("three equal columns, whatever the words in them", () => {
+ assert.match(ruleFor(".seatState"), /grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\)/);
+ });
+
+ test("the chosen one is the one that looks pressed", () => {
+ assert.match(ruleFor('.seatStateOption[aria-checked="true"]'), /background:\s*var\(--surface-3\)/);
+ assert.match(ruleFor('.seatStateOption[aria-checked="true"]'), /color:\s*var\(--text-bright\)/);
+ });
+
+ // The same red the seat menu's own Fold wears, and only when it is the answer: a red
+ // segment sitting unpressed beside two grey ones reads as a warning about the seat.
+ test("folded is red, and only once it is chosen", () => {
+ const rule = ruleFor('.seatStateOption[data-seat-state="folded"][aria-checked="true"]');
+ assert.match(rule, /#3d1f1f/);
+ assert.ok(!/\n\.seatStateOption\[data-seat-state="folded"\] \{/.test(CSS),
+ "an unchosen Folded segment is painted red");
+ });
+
+ test("and a thumb gets its 44px wherever there is no pointer", () => {
+ assert.match(blockAt("@media (hover: none) {"), /\.seatStateOption \{\s*min-height: 44px;/);
+ });
+});
+
+describe("where the form appears at all", () => {
+ // The sheet exists because a stacked sidebar puts the editor far below the felt.
+ // That is true at every width where the layout is one column.
+ test("the sheet is scoped to the breakpoint that stacks the layout", () => {
+ const oneColumn = blockAt("@media (max-width: 900px) {");
+ assert.match(oneColumn, /#holdemLayout \{\s*grid-template-columns: 1fr;/,
+ "900px is no longer where the sidebar stops being a column beside the table");
+ const sheet = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"));
+ const query = sheet.slice(sheet.indexOf("@media"), sheet.indexOf("{", sheet.indexOf("@media")) + 1);
+ assert.equal(query.trim(), "@media (max-width: 900px) {");
+ });
+
+ test("and it is a sheet, fixed to the bottom, not a block in the flow", () => {
+ const sheet = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"));
+ const at = sheet.indexOf(" #seatEditorPanel {");
+ assert.ok(at >= 0, "the sheet block no longer positions the panel");
+ const rule = sheet.slice(at, sheet.indexOf("}", at));
+ assert.match(rule, /position:\s*fixed/);
+ assert.match(rule, /bottom:\s*0/);
+ assert.match(rule, /max-height:\s*74vh/);
+ });
+
+ test("what was measured is written beside the query", () => {
+ const note = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"),
+ CSS.indexOf("@media (max-width: 900px) {", CSS.indexOf("---- Seat editor as a bottom sheet ----")));
+ assert.match(note, /844x390/);
+ assert.match(note, /768x1024/);
+ assert.match(note, /5% of it/);
+ });
+});