Table Setup is the one panel the tool opens by itself, on a table nobody has set up yet, so it is most of what a dealer sees the first time they arrive. Measured on a fresh table: the panel stood 886px on a 667px phone, and the whole page 2076. The order was the problem more than the length. The two figures the panel's own summary glances at — "10 seats · $1/$2" — were the seat count at the top and the blinds 218px further down, under three switches and the three paragraphs explaining them: a dedicated dealer, a card sheet at every street, and whether the phone is allowed to sleep. A dealer setting up a $1/$2 game read all of that on the way to the two boxes they came for. The three have gone into "While you deal", with the paragraphs. That is not a new idea in this panel — House take and Without live betting were already nested disclosures — it is the same idea applied to the things that were not about money. Afterwards the panel is 529px, the page 1719 on the same phone and 1275 on a 1280x900 desktop, and the first two rows are the seats and the blinds. A switch nobody can see is a switch nobody remembers turning on, so the disclosure carries a glance of its own: "Asking for cards", "House dealer", or "Screen may sleep" when one of the three is away from where it ships, and silence when none of them is. One of them, not a list: joined they run to 49 characters into a row 44px tall with a label already on it, and "House dealer · Asking for c…" has told nobody anything. 1291 tests pass; the sweep is clean at 428 audits across 13 viewports. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
219 lines
10 KiB
JavaScript
219 lines
10 KiB
JavaScript
// What a shut sidebar panel says about itself.
|
||
//
|
||
// The sidebar is eight <details>, every one exactly 60px tall with nothing in it but
|
||
// its own name — 480px of a phone's screen and 480px of a 660px desktop column, saying
|
||
// "Standings", "Hands", "Waiting list" and nothing else. Whether the box balances,
|
||
// whether anyone is waiting for a seat, how many hands have been played: the page had
|
||
// all three worked out and charged a tap for each of them, on rows two thirds empty.
|
||
//
|
||
// So each one now carries the fact you would have opened it for. Two rules hold this
|
||
// together, and both are checked here.
|
||
//
|
||
// A glance appears only when it has something a dealer would act on. Eight rows that
|
||
// always say something are eight rows that say nothing again, and a row reading
|
||
// "Nobody waiting" all night is the clutter this went in to remove.
|
||
//
|
||
// And a glance never says something the panel under it would contradict. It is the
|
||
// panel's own sentence with the explanation taken off, so it has to be read off the
|
||
// same state — never off the panel's DOM, which for a shut <details> isn't rendered
|
||
// at all.
|
||
const test = require("node:test");
|
||
const { describe } = require("node:test");
|
||
const assert = require("node:assert");
|
||
const { Render, HoldemState, makeTable } = require("./load");
|
||
|
||
const glances = state => Render.panelGlances(state);
|
||
const textOf = (state, id) => (glances(state)[id] || {}).text;
|
||
|
||
// A table part-way through a night: money on it, so the bank check has an opinion.
|
||
const night = () => {
|
||
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3, 4], stack: 200,
|
||
smallBlind: 5, bigBlind: 10 });
|
||
state.seats.slice(0, 5).forEach(p => { p.buyIns = 200; });
|
||
return state;
|
||
};
|
||
|
||
describe("the rows that always have something to say", () => {
|
||
test("the table's own shape and stakes", () => {
|
||
assert.equal(textOf(night(), "glanceTableSetup"), "9 seats · $5/$10");
|
||
});
|
||
|
||
test("and the stakes drop out rather than reading as free", () => {
|
||
// A table set up with no blinds yet is a real state — it is what the panel
|
||
// opens on — and "9 seats · $0/$0" would be a claim about the game rather
|
||
// than an admission that nobody has said yet.
|
||
const state = night();
|
||
state.smallBlind = 0;
|
||
state.bigBlind = 0;
|
||
assert.equal(textOf(state, "glanceTableSetup"), "9 seats");
|
||
});
|
||
|
||
test("the chip values, as a range rather than a count of rows", () => {
|
||
// "Are the values right for tonight's set" is the question this answers.
|
||
// Five colours is the answer to a different one.
|
||
assert.equal(textOf(night(), "glanceChips"), "5 colours · $1–$500");
|
||
});
|
||
|
||
test("with a chip nobody has priced yet left out of the range", () => {
|
||
// A blank row in the legend is a chip the dealer hasn't got to, not a
|
||
// zero-dollar chip — and letting it in would read the range as starting at $0.
|
||
const state = night();
|
||
state.chipLegend.push({ id: "pink", color: "#ff88aa", value: 0 });
|
||
assert.equal(textOf(state, "glanceChips"), "5 colours · $1–$500");
|
||
});
|
||
|
||
test("and one chip is a value, not a range of one", () => {
|
||
const state = night();
|
||
state.chipLegend = [{ id: "red", color: "#c0392b", value: 5 }];
|
||
assert.equal(textOf(state, "glanceChips"), "1 colour · $5");
|
||
});
|
||
});
|
||
|
||
describe("the bank check, which is the one worth a colour", () => {
|
||
test("says so when the cash covers the chips", () => {
|
||
const state = night();
|
||
const { text, tone } = glances(state).glanceStandings;
|
||
assert.equal(text, "Balanced");
|
||
assert.equal(tone, "ok");
|
||
});
|
||
|
||
test("and names the gap when it doesn't", () => {
|
||
// Chips on the table that nobody paid for. The glance is the alarm and the
|
||
// amount; which seat is holding them is a paragraph, and that is the half
|
||
// that stays behind the tap.
|
||
const state = night();
|
||
state.seats[0].buyIns = 100;
|
||
const { text, tone } = glances(state).glanceStandings;
|
||
assert.equal(text, "Off by $100");
|
||
assert.equal(tone, "warn");
|
||
});
|
||
|
||
test("the same way round when the cash is the side that's over", () => {
|
||
// One word covers both signs on purpose: the number and the alarm are what a
|
||
// dealer acts on, and "short" against "over" is a direction they would have to
|
||
// hold in their head to read. The panel says which way in a sentence.
|
||
const state = night();
|
||
state.seats[0].buyIns = 400;
|
||
const { text, tone } = glances(state).glanceStandings;
|
||
assert.equal(text, "Off by $200");
|
||
assert.equal(tone, "warn");
|
||
});
|
||
|
||
test("and stays quiet over a table nobody has bought into", () => {
|
||
// The panel hides its own check here for the same reason. A glance reading
|
||
// "Balanced" over an empty table is answering for a count never taken.
|
||
const state = makeTable({ seatCount: 9, seated: [], smallBlind: 5, bigBlind: 10 });
|
||
state.seats.forEach(p => { p.stack = null; p.buyIns = 0; });
|
||
assert.equal(textOf(state, "glanceStandings"), "");
|
||
assert.equal(glances(state).glanceStandings.tone, "");
|
||
});
|
||
|
||
test("and agrees with the sentence the panel would print", () => {
|
||
// The two are read off one call apiece and must never disagree — a glance
|
||
// saying "Balanced" over a panel saying "$40 more in chips than cash taken"
|
||
// is worse than no glance at all.
|
||
[0, 100, 400].forEach(buyIn => {
|
||
const state = night();
|
||
state.seats[0].buyIns = buyIn;
|
||
const bank = HoldemState.bankReconciliation(state);
|
||
const { text, tone } = glances(state).glanceStandings;
|
||
assert.equal(tone === "ok", bank.balanced, `buy-in ${buyIn}`);
|
||
if (!bank.balanced) {
|
||
assert.ok(text.includes(String(Math.abs(bank.difference))),
|
||
`${text} doesn't carry the panel's own ${bank.difference}`);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("the rows that stay quiet until they have news", () => {
|
||
test("nobody waiting, no note, no hands played yet", () => {
|
||
const state = night();
|
||
assert.equal(textOf(state, "glanceWaiting"), "");
|
||
assert.equal(textOf(state, "glanceHands"), "");
|
||
assert.equal(textOf(state, "glanceNotes"), "");
|
||
});
|
||
|
||
test("and speak up the moment they do", () => {
|
||
const state = night();
|
||
state.waitingList = [{ id: "w1", name: "Nadia" }, { id: "w2", name: "Owen" }];
|
||
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] }];
|
||
assert.equal(textOf(state, "glanceWaiting"), "2 waiting");
|
||
assert.equal(textOf(state, "glanceHands"), "1 hand");
|
||
});
|
||
|
||
test("counting one of something as one of it", () => {
|
||
const state = night();
|
||
state.waitingList = [{ id: "w1", name: "Nadia" }];
|
||
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] },
|
||
{ handNumber: 2, pot: 80, payouts: [], ledger: [] }];
|
||
assert.equal(textOf(state, "glanceWaiting"), "1 waiting");
|
||
assert.equal(textOf(state, "glanceHands"), "2 hands");
|
||
});
|
||
|
||
test("and a note shows which note it is, not that there is one", () => {
|
||
// "Has notes" costs a tap to learn anything, which is the whole failure this
|
||
// is fixing. The text is what identifies it; the row's own width ellipsises.
|
||
const state = night();
|
||
state.notes = "Sam owes Dee $20 from last night";
|
||
assert.equal(textOf(state, "glanceNotes"), "Sam owes Dee $20 from last night");
|
||
});
|
||
|
||
test("reading past the blank lines a note gets typed under", () => {
|
||
// Notes get written as a list — a heading, a blank line, then the thing. The
|
||
// first line of the string is regularly not the first line of the note.
|
||
const state = night();
|
||
state.notes = "\n \n Sam owes Dee $20\nand Ted owes Al $5";
|
||
assert.equal(textOf(state, "glanceNotes"), "Sam owes Dee $20");
|
||
});
|
||
|
||
test("and a note of nothing but whitespace is no note", () => {
|
||
const state = night();
|
||
state.notes = "\n\n \t \n";
|
||
assert.equal(textOf(state, "glanceNotes"), "");
|
||
});
|
||
});
|
||
|
||
describe("what the row is allowed to be", () => {
|
||
const ROWS = ["glanceTableSetup", "glanceWhileYouDeal", "glanceStandings",
|
||
"glanceWaiting", "glanceHands", "glanceChips", "glanceNotes"];
|
||
|
||
test("every glance the sidebar renders is one this knows about", () => {
|
||
// The list drives the DOM writes, so a glance computed and never listed is a
|
||
// row that silently keeps its old text for the rest of the night.
|
||
const state = night();
|
||
assert.deepEqual(Object.keys(glances(state)).sort(), [...ROWS].sort());
|
||
assert.deepEqual([...Render.PANEL_GLANCES].sort(), [...ROWS].sort());
|
||
});
|
||
|
||
test("and none of them is a paragraph", () => {
|
||
// The row is 44px of a 320px column with a label already on it. Anything
|
||
// long enough to need the panel's room belongs in the panel.
|
||
const state = night();
|
||
state.waitingList = [{ id: "w1", name: "Nadia" }];
|
||
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] }];
|
||
ROWS.filter(id => id !== "glanceNotes").forEach(id => {
|
||
const text = textOf(state, id);
|
||
assert.ok(text.length <= 24, `${id} is ${text.length} chars: "${text}"`);
|
||
assert.ok(!text.includes("\n"), `${id} has a line break in it`);
|
||
});
|
||
});
|
||
|
||
test("including a note, which is trimmed to one line and left to the ellipsis", () => {
|
||
// The one glance whose length isn't ours to choose. It must still be a single
|
||
// line — a second one would make the row taller, and the row is what holds the
|
||
// sidebar to eight equal slabs.
|
||
const state = night();
|
||
state.notes = "x".repeat(400) + "\nsecond line";
|
||
const text = textOf(state, "glanceNotes");
|
||
assert.ok(!text.includes("\n"));
|
||
assert.equal(text, "x".repeat(400));
|
||
});
|
||
|
||
test("and a state that isn't there yet asks for nothing", () => {
|
||
// renderAll runs before the first load has finished on a cold open.
|
||
assert.deepEqual(Render.panelGlances(null), {});
|
||
assert.deepEqual(Render.panelGlances(undefined), {});
|
||
});
|
||
});
|