// The one control in the header that is named for a hand and never starts one. // // "Next hand" names three different actions in this tool. The action bar's green button // deals one. The pot wizard finishes on "Start Next Hand" and hands back a table ready // to deal. And the header shortcut — which, between hands, deals nothing: it moves the // button on a second time, so the seat that was about to pay the big blind is skipped // and the next one pays it instead. // // Measured before this changed: settle a pot through the wizard, and the table is on // hand 6 with the button on seat 2 and the bar offering "Post blinds & deal". Press the // header's "Next Hand ⟳" — no confirmation, because there is nothing in the middle to // warn about — and the table is still on hand 6, with the button on seat 3. The toast // then opened with the same two words the wizard's button had just used, which reads as // a confirmation of the thing that was already done rather than a report of a second // rotation. // // The mechanism is right and stays: a dealer does pass the button along on a hand nobody // played, and state.startNextHand says so in as many words. What was wrong was the name. // A command is named for the state the system moves into — NN/g's Button States, "Show // Tab Bar" against "Hide Tab Bar" — and between hands the state this moves into is a // moved button, not a new hand. // // So the label is read off the hand: "Next Hand ⟳" while one is out, "Move Button ⟳" // when none is. Both claims are checked here, along with the trap underneath them — the // title is cached once from the markup for the dimmed-reason machinery to borrow and // hand back, and a title that depends on the hand has to be written past that cache on // every render or it freezes on whichever state happened to be true at load. const test = require("node:test"); const { describe } = require("node:test"); const assert = require("node:assert"); const { Render, HoldemState, Betting, makeTable } = require("./load"); // Stand-ins for the three header buttons. Enough of an element for Unavailable and for // renderHeaderActions: attributes, a dataset, text, and a hidden flag. const element = title => { const attributes = title ? { title } : {}; return { hidden: false, textContent: "", dataset: {}, getAttribute: name => (name in attributes ? attributes[name] : null), setAttribute: (name, value) => { attributes[name] = String(value); }, removeAttribute: name => { delete attributes[name]; }, }; }; // The header as the page ships it, with the markup's own title on the shortcut. const header = () => { const buttons = { misdealBtn: element("Take this hand back"), nextHandBtn: element("Pass the button on again without dealing — for a hand nobody played"), resolvePotBtn: element(""), }; global.document = { getElementById: id => buttons[id] || null }; return buttons; }; const renderOn = state => { const before = Render.state; Render.state = state; try { Render.renderHeaderActions(); } finally { Render.state = before; } }; const table = () => makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 5, bigBlind: 10 }); test.afterEach(() => { delete global.document; }); describe("what the shortcut calls itself", () => { test("between hands it offers to move the button, because that is all it does", () => { const buttons = header(); renderOn(table()); assert.equal(buttons.nextHandBtn.textContent, "Move Button ⟳"); assert.match(buttons.nextHandBtn.getAttribute("title"), /without dealing/, "the title has to say the thing the label can't fit"); }); test("and with a hand out it offers the next one, because that is what it moves to", () => { const buttons = header(); const state = table(); Betting.beginHand(state); state.bettingOpen = true; renderOn(state); assert.equal(buttons.nextHandBtn.textContent, "Next Hand ⟳"); assert.match(buttons.nextHandBtn.getAttribute("title"), /End this hand/); }); test("the name follows the table rather than sticking where it started", () => { // The failure this guards is a one-way trip: the label is right on the state the // page loaded in and wrong for the rest of the night. const buttons = header(); const state = table(); renderOn(state); const betweenHands = buttons.nextHandBtn.textContent; Betting.beginHand(state); state.bettingOpen = true; renderOn(state); const midHand = buttons.nextHandBtn.textContent; HoldemState.startNextHand(state); renderOn(state); assert.notEqual(betweenHands, midHand, "the label never moved off the loaded state"); assert.equal(buttons.nextHandBtn.textContent, betweenHands, "and it never came back"); }); test("including the title, which the dimmed-reason cache would otherwise freeze", () => { // renderHeaderActions fills dataset.titleWhenUsable once, from the markup, so the // sentence hung off a dimmed control can be handed back when it is usable again. // A state-dependent title has to be written into that slot on every render — this // is the assertion that the write happens before the borrow, not after it. const buttons = header(); const state = table(); renderOn(state); const betweenHands = buttons.nextHandBtn.getAttribute("title"); Betting.beginHand(state); state.bettingOpen = true; renderOn(state); assert.notEqual(buttons.nextHandBtn.getAttribute("title"), betweenHands); assert.match(buttons.nextHandBtn.getAttribute("title"), /End this hand/); }); test("and an empty table still gets the reason it can't be pressed", () => { // Naming the two states must not cost the third one its sentence: with nobody // seated there is no button to move, and that is what the title says. const buttons = header(); // A table as the tool first opens it: chairs, and nobody named in any of them. renderOn(HoldemState.createDefault()); assert.match(buttons.nextHandBtn.getAttribute("title"), /seat a player first/); // And seating the table hands the state's own sentence back. renderOn(table()); assert.match(buttons.nextHandBtn.getAttribute("title"), /without dealing/); }); test("Misdeal still comes and goes with the hand", () => { const buttons = header(); const state = table(); renderOn(state); assert.equal(buttons.misdealBtn.hidden, true, "there is nothing to take back yet"); Betting.beginHand(state); state.bettingOpen = true; renderOn(state); assert.equal(buttons.misdealBtn.hidden, false); }); }); describe("what it does when there is no hand to end", () => { test("the button moves on and the hand number doesn't", () => { // This is the behaviour the label now names, pinned so the name stays true. const state = table(); state.handNumber = 6; state.dealerSeatIndex = 1; HoldemState.startNextHand(state); assert.equal(state.dealerSeatIndex, 2, "the button passed to the next occupied seat"); assert.equal(state.handNumber, 6, "no hand was dealt, so none was counted"); assert.equal(state.bettingOpen, false); assert.equal(state.postedBlinds, null, "nothing is posted until the dealer says so"); }); test("so pressing it twice skips a seat's big blind", () => { // Not a bug — a dealer passing the button along twice means it — but it is the // reason the control cannot be called "Next Hand" while it is doing it. const state = table(); const first = HoldemState.getBlindSeats({ ...state }); HoldemState.startNextHand(state); const second = HoldemState.getBlindSeats(state); HoldemState.startNextHand(state); const third = HoldemState.getBlindSeats(state); assert.notEqual(second.bbIndex, first.bbIndex); assert.notEqual(third.bbIndex, second.bbIndex, "the seat that was about to pay the big blind never did"); }); });