Files
JoshHeaps.Net/tests/holdem/theButtonThatDoesNotDeal.test.js
jheapsandClaude Opus 5 f86e50268e Stop calling it the next hand when it deals no hand
"Next hand" named three things: the bar's green button, the pot wizard's
last button, and the header shortcut — the only one of the three that
never starts a hand. Between hands it moves the button a second time, so
the seat about to pay the big blind is skipped, and the toast opened with
the words the wizard had just used, which read as a confirmation of what
was already done.

Measured: settle through the wizard and the table is on hand 6 with the
button on seat 2; press the header's Next Hand and it is still hand 6,
button on seat 3, no confirmation asked.

The mechanism stays — a dealer does pass the button along on a hand
nobody played. The name changes. The label and its title are now read off
whether a hand is out: "Next Hand ⟳" while one is, "Move Button ⟳" when
none is, written past the dimmed-reason cache each render so they follow
the table instead of freezing on the state the page loaded in. The toast
says which of the two happened, on one line: the second line told the
dealer to press the green button the bar is already holding.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-14 04:17:32 -06:00

185 lines
8.2 KiB
JavaScript

// 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");
});
});