diff --git a/tests/holdem/feltShape.test.js b/tests/holdem/feltShape.test.js new file mode 100644 index 0000000..d9c9bf9 --- /dev/null +++ b/tests/holdem/feltShape.test.js @@ -0,0 +1,165 @@ +// What shape the felt ends up, which is a question about which rule wins. +// +// feltRoom.test.js covers how much room there is. This covers what the stylesheet does +// with it, and it exists because of a bug that no amount of reading caught: the upright +// oval for phones was written into the `max-width: 700px` block, and the `max-width: +// 900px` block below it set the same custom property back to 1. Both queries match a +// phone, the two declarations have identical specificity, and the later one wins. The +// rule was correct, the arithmetic was correct, and the property was 1 on every phone +// on the site. It measured as a no-op and would have shipped as one. +// +// So this walks the media queries the way the cascade does — in source order, keeping +// the last one that matches — instead of trusting that a rule written for a viewport is +// the rule that viewport gets. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const fs = require("fs"); +const path = require("path"); + +const CSS = fs.readFileSync( + path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8"); + +// Comments carry example numbers and at least one worked-through `--feltAspect: 1`, so +// they come out before anything here counts declarations. +const SOURCE = CSS.replace(/\/\*[\s\S]*?\*\//g, ""); + +// --- just enough of a media query engine to answer "does this viewport match?" ------- +// +// Only the forms this stylesheet actually uses: width and height bounds, orientation, +// and `and` between them. Anything else is refused loudly rather than guessed at, so a +// new kind of query can't quietly start matching everything. +const matches = (condition, view) => { + if (!condition) return true; + return condition.split(/\s+and\s+/).every(part => { + const m = part.trim().match(/^\(\s*([a-z-]+)\s*:\s*([^)]+?)\s*\)$/); + assert.ok(m, `feltShape can't read the query "${part}" — teach it or simplify the CSS`); + const [, feature, raw] = m; + const px = parseFloat(raw); + switch (feature) { + case "min-width": return view.width >= px; + case "max-width": return view.width <= px; + case "min-height": return view.height >= px; + case "max-height": return view.height <= px; + case "orientation": + return raw === (view.width > view.height ? "landscape" : "portrait"); + default: + assert.fail(`feltShape doesn't know the media feature "${feature}"`); + } + }); +}; + +// Every style rule in the file, in source order, with the media condition it sits +// under. Written as a brace walk rather than a regex because a regex for `max-height` +// also finds it in `@media (orientation: landscape) and (max-height: 560px)`, and a +// media feature is not a declaration. +const rules = () => { + const out = []; + const open = []; + let prelude = "", i = 0; + while (i < SOURCE.length) { + const c = SOURCE[i]; + if (c === "{") { + const head = prelude.trim(); + prelude = ""; + if (head.startsWith("@")) { open.push(head); i++; continue; } + let depth = 1, j = i + 1; + while (j < SOURCE.length && depth > 0) { + if (SOURCE[j] === "{") depth++; + else if (SOURCE[j] === "}") depth--; + j++; + } + const media = open.filter(h => h.startsWith("@media")) + .map(h => h.replace(/^@media\s*/, "")).join(" and "); + out.push({ selector: head, condition: media || null, body: SOURCE.slice(i + 1, j - 1) }); + i = j; + continue; + } + if (c === "}") { open.pop(); prelude = ""; i++; continue; } + prelude += c; + i++; + } + return out; +}; + +const ALL_RULES = rules(); + +// The cascade, for equal-specificity declarations of one property on one selector: the +// last one whose query matches is the one the viewport gets. +const winnerAt = (selector, prop, view) => { + const winning = ALL_RULES + .filter(r => r.selector === selector && matches(r.condition, view)) + .map(r => r.body.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`))) + .filter(Boolean) + .pop(); + assert.ok(winning, `nothing sets ${prop} on ${selector} at ${view.width}x${view.height}`); + return winning[1].trim(); +}; + +const PHONES = [ + { width: 360, height: 640, name: "Galaxy S8" }, + { width: 375, height: 667, name: "iPhone SE" }, + { width: 390, height: 844, name: "iPhone 14" }, + { width: 412, height: 915, name: "Pixel 7" }, + { width: 430, height: 932, name: "Pro Max" }, +]; + +// --------------------------------------------------------------------------- + +describe("the shape a phone gets", () => { + test("is an oval standing up, on every one of them", () => { + PHONES.forEach(phone => { + const aspect = parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", phone)); + assert.ok(aspect > 1.2, + `${phone.name} lands on --feltAspect: ${aspect}. A portrait phone is ` + + `tall and this is what uses it; anything at or under 1 is the square ` + + `felt that threw the room away.`); + }); + }); + + test("and a wide screen still gets one lying down", () => { + // The desktop felt is the shape it always was. Whatever the phones do, this is + // the one a poker table looks like when there is room for it. + assert.equal(parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", { width: 1280, height: 900 })), 0.72); + }); + + test("with the middle widths in between left round", () => { + // 701-900px is one column and wide enough that neither oval is called for. It + // has to be written as a range: a bare max-width would match the phones too and, + // sitting lower in the file, would win there. + [{ width: 768, height: 1024 }, { width: 900, height: 700 }].forEach(view => { + assert.equal(parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", view)), 1); + }); + }); +}); + +describe("what stops the oval", () => { + const phoneRule = winnerAt("#pokerTableWrap", "max-height", { width: 390, height: 844 }); + + test("is the room and nothing but the room", () => { + // The oval is a preference and the room is a fact. Whatever shape is asked for + // above, the felt gives way to the measurement rather than the other way round, + // because the thing on the other side of the cap is a seat behind the action bar. + assert.equal(phoneRule, "var(--feltRoomPx, 100vh)"); + }); + + test("and it is not floored, however tempting that is", () => { + // A floor at the desktop oval — max(var(--feltRoomPx), 72cqw) — was written here + // and measured out again. Twelve players all in with nine side pots is the + // tallest the bar gets, 341px, and at 360x640 that leaves 142px of room. Floored + // to 242 the felt keeps its shape and hides five of the twelve seats behind the + // bar. Unfloored at 142 it is flat and all twelve are visible. + // + // The slit it was defending against wasn't reachable: that came from forcing + // --feltRoomPx to 10px by hand, and the real floor is FELT_FLOOR_PX at 96. + assert.doesNotMatch(phoneRule, /max\(/, + "a floor here trades a flat table for a covered seat, which is the worse of " + + "the two — see the comment above the rule"); + }); + + test("and the width is let go of, so the height can be the one that binds", () => { + // The wide layout sizes the felt from its width and caps that. On a phone the + // width is the column and the height is what's scarce, so the cap moves. + assert.equal(winnerAt("#pokerTableWrap", "max-width", { width: 390, height: 844 }), "none"); + }); +});