Make the overlays usable from a keyboard #47
@@ -138,7 +138,7 @@
|
|||||||
<details class="sidebarPanel">
|
<details class="sidebarPanel">
|
||||||
<summary>Table Notes</summary>
|
<summary>Table Notes</summary>
|
||||||
<div class="panelBody">
|
<div class="panelBody">
|
||||||
<textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -198,8 +198,8 @@
|
|||||||
|
|
||||||
<template id="chipLegendRowTemplate">
|
<template id="chipLegendRowTemplate">
|
||||||
<div class="chipLegendRow">
|
<div class="chipLegendRow">
|
||||||
<input type="color" class="chipColorInput" />
|
<input type="color" class="chipColorInput" aria-label="Chip colour" />
|
||||||
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" />
|
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" aria-label="Chip value" />
|
||||||
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeChipColorBtn">×</button>
|
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeChipColorBtn">×</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="addChipsActions">
|
<div class="addChipsActions">
|
||||||
<button id="addChipsConfirmBtn" class="holdemBtn holdemBtnAccent" type="button">Add to stack</button>
|
<button id="addChipsConfirmBtn" class="holdemBtn holdemBtnAccent" type="button">Add to stack</button>
|
||||||
<button id="addChipsCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
<button id="addChipsCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,7 +263,7 @@
|
|||||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||||
<div class="rulingsHeader">
|
<div class="rulingsHeader">
|
||||||
<h2 id="rulingsTitle">Common rulings</h2>
|
<h2 id="rulingsTitle">Common rulings</h2>
|
||||||
<button id="closeRulingsBtn" class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
<button id="closeRulingsBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="rulingsIntro">
|
<p class="rulingsIntro">
|
||||||
@@ -314,7 +314,7 @@
|
|||||||
<span id="seatMenuSubtitle"></span>
|
<span id="seatMenuSubtitle"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="seatMenuActions"></div>
|
<div id="seatMenuActions"></div>
|
||||||
<button id="seatMenuCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
<button id="seatMenuCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@
|
|||||||
<div class="cardPickerHeader">
|
<div class="cardPickerHeader">
|
||||||
<h2 id="cardPickerTitle"></h2>
|
<h2 id="cardPickerTitle"></h2>
|
||||||
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
|
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
|
||||||
<button id="cardPickerCloseBtn" class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
<button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="cardPickerSlots"></div>
|
<div id="cardPickerSlots"></div>
|
||||||
<div id="cardPickerGrid"></div>
|
<div id="cardPickerGrid"></div>
|
||||||
@@ -340,17 +340,17 @@
|
|||||||
<h2 id="confirmTitle"></h2>
|
<h2 id="confirmTitle"></h2>
|
||||||
<p id="confirmMessage"></p>
|
<p id="confirmMessage"></p>
|
||||||
<div class="confirmActions">
|
<div class="confirmActions">
|
||||||
<button id="confirmCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
<button id="confirmCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||||
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
|
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="potWizardOverlay" hidden>
|
<div id="potWizardOverlay" hidden>
|
||||||
<div id="potWizardModal">
|
<div id="potWizardModal" role="dialog" aria-labelledby="potWizardTitle">
|
||||||
<div class="potWizardHeader">
|
<div class="potWizardHeader">
|
||||||
<h2>Resolve the Pot</h2>
|
<h2 id="potWizardTitle">Resolve the Pot</h2>
|
||||||
<button id="closePotWizardBtn" class="holdemBtn holdemBtnGhost">✕</button>
|
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="potWizardBody"></div>
|
<div id="potWizardBody"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -374,5 +374,6 @@
|
|||||||
<script src="~/js/casino/holdem/shortcuts.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/shortcuts.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
<script src="~/js/casino/holdem/modal.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,7 +219,11 @@ button,
|
|||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
/* Named rather than `all`, so the focus ring isn't animated. With `all` the
|
||||||
|
outline faded in over 150ms, which on keyboard navigation reads as the ring
|
||||||
|
lagging a step behind the Tab key. Nothing else here changes. */
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease,
|
||||||
|
box-shadow 0.15s ease, color 0.15s ease, opacity 0.15s ease, filter 0.15s ease;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2690,3 +2694,41 @@ body.hasActionBar #toastHost {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Keyboard focus ----------
|
||||||
|
Three focus rules existed for the whole tool, so tabbing through it relied on
|
||||||
|
whatever ring the browser draws by default — which on these dark, custom-painted
|
||||||
|
controls is close to invisible. `:focus-visible` rather than `:focus` keeps it to
|
||||||
|
keyboard use: a dealer tapping Fold shouldn't be left with a ring on it. */
|
||||||
|
.holdemBtn:focus-visible,
|
||||||
|
.linkBtn:focus-visible,
|
||||||
|
.seatMenuBtn:focus-visible,
|
||||||
|
.seatSheetClose:focus-visible,
|
||||||
|
.pickerCard:focus-visible,
|
||||||
|
.actionBtn:focus-visible,
|
||||||
|
.raiseQuickBtn:focus-visible,
|
||||||
|
.actionUndo:focus-visible,
|
||||||
|
.toastAction:focus-visible,
|
||||||
|
.toastClose:focus-visible,
|
||||||
|
.potWizardChoice:focus-visible,
|
||||||
|
.cardSlot:focus-visible,
|
||||||
|
.subPanel summary:focus-visible,
|
||||||
|
.seat:focus-visible,
|
||||||
|
button:focus-visible,
|
||||||
|
[role="button"]:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: 2px solid #7fc4ff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
/* The ring has to sit above the neighbouring seat that overlaps this one. */
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A sheet with nothing focusable in it takes focus itself so its title is read out.
|
||||||
|
That shouldn't paint a ring around the whole panel. */
|
||||||
|
[role="dialog"]:focus,
|
||||||
|
[role="alertdialog"]:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,11 +39,16 @@ const Betting = {
|
|||||||
return this.round((Number(player.amountOverride) || 0) + this.committed(player));
|
return this.round((Number(player.amountOverride) || 0) + this.committed(player));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Never below zero. A seat can't have negative chips in front of it, and a typo in
|
||||||
|
// the stack field used to come back as-is — which then flowed into putIn(), where
|
||||||
|
// `Math.min(amount, -50)` moves a negative amount and *adds* chips to the table.
|
||||||
stackOf(state, player) {
|
stackOf(state, player) {
|
||||||
const own = player.stack;
|
const own = player.stack;
|
||||||
if (own !== null && own !== undefined && String(own).trim() !== "") return this.round(own);
|
if (own !== null && own !== undefined && String(own).trim() !== "") {
|
||||||
|
return Math.max(0, this.round(own));
|
||||||
|
}
|
||||||
const fallback = Number(state.defaultStack);
|
const fallback = Number(state.defaultStack);
|
||||||
return Number.isNaN(fallback) ? 0 : this.round(fallback);
|
return Number.isNaN(fallback) ? 0 : Math.max(0, this.round(fallback));
|
||||||
},
|
},
|
||||||
|
|
||||||
toCall(state, player) {
|
toCall(state, player) {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const state = HoldemState.load();
|
const state = HoldemState.load();
|
||||||
|
|
||||||
|
// Focus, Tab-trapping and Escape for every overlay, wired once by watching the
|
||||||
|
// `hidden` attribute they all already toggle.
|
||||||
|
Modal.mount();
|
||||||
|
|
||||||
// Undo works by swapping the whole table back to a snapshot. `state` is handed out
|
// Undo works by swapping the whole table back to a snapshot. `state` is handed out
|
||||||
// by reference to every module at mount time, so it has to be refilled in place —
|
// by reference to every module at mount time, so it has to be refilled in place —
|
||||||
// reassigning the variable would leave the renderer and the betting bar pointing at
|
// reassigning the variable would leave the renderer and the betting bar pointing at
|
||||||
@@ -369,6 +373,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (e.key === "Enter") AddChips.confirm();
|
if (e.key === "Enter") AddChips.confirm();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Escape is Modal's job now, for this sheet and the five others that never had it.
|
||||||
const rulingsOverlay = document.getElementById("rulingsOverlay");
|
const rulingsOverlay = document.getElementById("rulingsOverlay");
|
||||||
const closeRulings = () => { rulingsOverlay.hidden = true; };
|
const closeRulings = () => { rulingsOverlay.hidden = true; };
|
||||||
document.getElementById("openRulingsBtn").addEventListener("click", () => { rulingsOverlay.hidden = false; });
|
document.getElementById("openRulingsBtn").addEventListener("click", () => { rulingsOverlay.hidden = false; });
|
||||||
@@ -376,9 +381,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
rulingsOverlay.addEventListener("click", e => {
|
rulingsOverlay.addEventListener("click", e => {
|
||||||
if (e.target === rulingsOverlay) closeRulings();
|
if (e.target === rulingsOverlay) closeRulings();
|
||||||
});
|
});
|
||||||
document.addEventListener("keydown", e => {
|
|
||||||
if (e.key === "Escape" && !rulingsOverlay.hidden) closeRulings();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
|
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
|
||||||
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
|
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Keyboard and screen-reader behaviour for every overlay in the tool, in one place.
|
||||||
|
//
|
||||||
|
// There are six of them — seat menu, card picker, add chips, rulings, pot wizard,
|
||||||
|
// confirm — opened and closed by five different modules, and each one was missing
|
||||||
|
// the same three things: focus never moved into the dialog, Tab walked straight out
|
||||||
|
// of it into the page behind the scrim, and Escape did nothing (only the rulings
|
||||||
|
// sheet handled it). Measured before this: 0 of 5 moved focus in, 5 of 5 leaked Tab,
|
||||||
|
// 1 of 5 closed on Escape.
|
||||||
|
//
|
||||||
|
// Rather than patch that into five modules, this watches the `hidden` attribute every
|
||||||
|
// overlay already toggles. Nothing else had to change, and an overlay added later
|
||||||
|
// gets the same behaviour without anyone remembering to wire it up — which is the
|
||||||
|
// failure this is fixing in the first place.
|
||||||
|
const Modal = {
|
||||||
|
// `:not([hidden])` matters — a sheet that is itself hidden inside an open overlay
|
||||||
|
// must not be a Tab stop.
|
||||||
|
FOCUSABLE: [
|
||||||
|
"a[href]",
|
||||||
|
"button:not([disabled])",
|
||||||
|
'input:not([disabled]):not([type="hidden"])',
|
||||||
|
"select:not([disabled])",
|
||||||
|
"textarea:not([disabled])",
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(","),
|
||||||
|
|
||||||
|
open: [],
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
document.querySelectorAll('[id$="Overlay"]').forEach(overlay => this.watch(overlay));
|
||||||
|
// Capture phase: the dialog gets first refusal on Escape and Tab, before any
|
||||||
|
// table-level shortcut sees them.
|
||||||
|
document.addEventListener("keydown", e => this.onKeyDown(e), true);
|
||||||
|
},
|
||||||
|
|
||||||
|
watch(overlay) {
|
||||||
|
const sync = () => {
|
||||||
|
const isOpen = !overlay.hidden;
|
||||||
|
const known = this.open.some(entry => entry.overlay === overlay);
|
||||||
|
if (isOpen && !known) this.onOpened(overlay);
|
||||||
|
else if (!isOpen && known) this.onClosed(overlay);
|
||||||
|
};
|
||||||
|
new MutationObserver(sync).observe(overlay, { attributes: true, attributeFilter: ["hidden"] });
|
||||||
|
sync();
|
||||||
|
},
|
||||||
|
|
||||||
|
sheetOf(overlay) {
|
||||||
|
return overlay.querySelector('[role="dialog"], [role="alertdialog"]') || overlay.firstElementChild || overlay;
|
||||||
|
},
|
||||||
|
|
||||||
|
focusableIn(overlay) {
|
||||||
|
return [...overlay.querySelectorAll(this.FOCUSABLE)].filter(el => {
|
||||||
|
if (el.closest("[hidden]")) return false;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return rect.width > 0 && rect.height > 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onOpened(overlay) {
|
||||||
|
const sheet = this.sheetOf(overlay);
|
||||||
|
// aria-modal is only true while the thing is actually open, so it's set here
|
||||||
|
// rather than baked into the markup.
|
||||||
|
if (sheet) sheet.setAttribute("aria-modal", "true");
|
||||||
|
|
||||||
|
this.open.push({ overlay, returnFocus: document.activeElement });
|
||||||
|
|
||||||
|
// Focus the first real control. A dialog that leaves focus on the button
|
||||||
|
// behind it reads, to a screen reader, as though nothing happened at all.
|
||||||
|
// Content-only sheets get focus on the sheet so the title is announced.
|
||||||
|
const focusable = this.focusableIn(overlay);
|
||||||
|
const target = focusable[0];
|
||||||
|
if (target) {
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
|
} else if (sheet) {
|
||||||
|
if (!sheet.hasAttribute("tabindex")) sheet.setAttribute("tabindex", "-1");
|
||||||
|
sheet.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onClosed(overlay) {
|
||||||
|
const index = this.open.findIndex(entry => entry.overlay === overlay);
|
||||||
|
if (index === -1) return;
|
||||||
|
const [entry] = this.open.splice(index, 1);
|
||||||
|
|
||||||
|
const sheet = this.sheetOf(overlay);
|
||||||
|
if (sheet) sheet.removeAttribute("aria-modal");
|
||||||
|
|
||||||
|
// Put the dealer back where they were. Without this, closing the pot wizard
|
||||||
|
// drops focus to the top of the document and the next Tab starts from the
|
||||||
|
// page header.
|
||||||
|
const back = entry.returnFocus;
|
||||||
|
if (back && back.isConnected && typeof back.focus === "function") {
|
||||||
|
back.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
topmost() {
|
||||||
|
return this.open.length ? this.open[this.open.length - 1] : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Escape presses the dialog's own cancel/close button rather than just hiding the
|
||||||
|
// overlay, so whatever that button does — resolving the confirm promise, clearing
|
||||||
|
// the add-chips draft — still happens. Only if there isn't one does it hide the
|
||||||
|
// overlay directly.
|
||||||
|
dismiss(overlay) {
|
||||||
|
const closer = overlay.querySelector("[data-modal-close]");
|
||||||
|
if (closer) closer.click();
|
||||||
|
else overlay.hidden = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
onKeyDown(event) {
|
||||||
|
const top = this.topmost();
|
||||||
|
if (!top) return;
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.dismiss(top.overlay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
|
||||||
|
// Keep Tab inside the dialog. Everything behind the scrim is unreachable by
|
||||||
|
// mouse, so letting the keyboard wander back there is a trap with no way out.
|
||||||
|
const focusable = this.focusableIn(top.overlay);
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
const active = document.activeElement;
|
||||||
|
|
||||||
|
if (event.shiftKey && (active === first || !top.overlay.contains(active))) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus({ preventScroll: true });
|
||||||
|
} else if (!event.shiftKey && (active === last || !top.overlay.contains(active))) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = Modal;
|
||||||
|
}
|
||||||
@@ -32,14 +32,12 @@ const Shortcuts = {
|
|||||||
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
||||||
},
|
},
|
||||||
|
|
||||||
// An overlay owns the keyboard while it's up: the confirm dialog already binds
|
// An overlay owns the keyboard while it's up. Asking the document which overlays
|
||||||
// Enter and Escape, and the card picker and seat menu are modal.
|
// are open, rather than listing them, means a sheet added later can't quietly
|
||||||
|
// start letting "f" fold the table behind it — the same reason Modal watches for
|
||||||
|
// them instead of being told about each one.
|
||||||
overlayOpen() {
|
overlayOpen() {
|
||||||
return ["confirmOverlay", "potWizardOverlay", "cardPickerOverlay", "seatMenuOverlay", "rulingsOverlay", "addChipsOverlay"]
|
return [...document.querySelectorAll('[id$="Overlay"]')].some(el => !el.hidden);
|
||||||
.some(id => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
return el && !el.hidden;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onKey(e) {
|
onKey(e) {
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// Values a dealer can actually produce with a typo, and the ones a corrupted save
|
||||||
|
// can produce on its own. None of these should be able to move chips.
|
||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||||
|
|
||||||
|
describe("a stack can never go negative", () => {
|
||||||
|
test("a mistyped negative stack reads as empty, not as owing chips", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||||
|
state.seats[0].stack = -50;
|
||||||
|
assert.equal(Betting.stackOf(state, state.seats[0]), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a negative stack can't be bet with", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||||
|
state.seats[0].stack = -50;
|
||||||
|
const before = state.seats.reduce((s, p) => s + Math.max(0, Number(p.stack) || 0), 0);
|
||||||
|
|
||||||
|
const moved = Betting.putIn(state, 0, 25);
|
||||||
|
|
||||||
|
assert.equal(moved, 0, "nothing moves out of an empty stack");
|
||||||
|
assert.ok(Betting.stackOf(state, state.seats[0]) >= 0);
|
||||||
|
assert.equal(state.seats[0].committed, 0, "and nothing appears in front of them");
|
||||||
|
assert.ok(state.seats.reduce((s, p) => s + Math.max(0, Number(p.stack) || 0), 0) <= before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a negative table default doesn't hand everyone a negative stack", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||||
|
state.seats.forEach(p => { p.stack = null; });
|
||||||
|
state.defaultStack = -100;
|
||||||
|
state.seats.forEach(p => assert.equal(Betting.stackOf(state, p), 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a negative stack doesn't make a player look like they can still bet", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||||
|
state.seats[0].stack = -50;
|
||||||
|
assert.ok(!Betting.seatsWithChips(state).some(s => s.index === 0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("blinds that can't be posted", () => {
|
||||||
|
test("negative blinds post nothing rather than paying players to sit down", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||||
|
state.smallBlind = -5;
|
||||||
|
state.bigBlind = -10;
|
||||||
|
const before = chipsOnTable(state);
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
state.seats.forEach(p => assert.equal(p.committed, 0));
|
||||||
|
assert.equal(chipsOnTable(state), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unreadable blind doesn't wedge the hand", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||||
|
state.bigBlind = NaN;
|
||||||
|
state.smallBlind = 1;
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.ok(Number.isFinite(state.currentBet), "the bet to match is still a number");
|
||||||
|
assert.ok(!Number.isNaN(Betting.potSoFar(state)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blinds nobody can cover put those players all-in and move on", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 10 });
|
||||||
|
state.smallBlind = 500;
|
||||||
|
state.bigBlind = 1000;
|
||||||
|
const before = chipsOnTable(state);
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(chipsOnTable(state), before, "no chips invented covering a blind");
|
||||||
|
state.seats.forEach(p => assert.ok((Number(p.stack) || 0) >= 0));
|
||||||
|
assert.ok(state.actingSeatIndex === null || state.seats[state.actingSeatIndex].inHand);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a saved table that comes back damaged", () => {
|
||||||
|
test("unreadable storage falls back to a fresh table", () => {
|
||||||
|
// load() has to survive whatever is in localStorage, because the alternative
|
||||||
|
// is a dealer opening the tool mid-session to a blank screen.
|
||||||
|
const original = globalThis.localStorage;
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: () => "{not json at all",
|
||||||
|
setItem: () => {},
|
||||||
|
removeItem: () => {},
|
||||||
|
};
|
||||||
|
const state = HoldemState.load();
|
||||||
|
globalThis.localStorage = original;
|
||||||
|
|
||||||
|
assert.equal(state.seats.length, HoldemState.DEFAULT_SEAT_COUNT);
|
||||||
|
assert.equal(state.handNumber, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a save missing its seats is treated as no save at all", () => {
|
||||||
|
const original = globalThis.localStorage;
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: () => JSON.stringify({ handNumber: 7, smallBlind: 3 }),
|
||||||
|
setItem: () => {},
|
||||||
|
removeItem: () => {},
|
||||||
|
};
|
||||||
|
const state = HoldemState.load();
|
||||||
|
globalThis.localStorage = original;
|
||||||
|
|
||||||
|
assert.ok(Array.isArray(state.seats) && state.seats.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a save from before a field existed gets the field, not undefined", () => {
|
||||||
|
const original = globalThis.localStorage;
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: () => JSON.stringify({
|
||||||
|
seats: [{ id: "a", name: "Ann", inHand: true }, { id: "b", name: "Ben", inHand: true }],
|
||||||
|
handNumber: 4,
|
||||||
|
}),
|
||||||
|
setItem: () => {},
|
||||||
|
removeItem: () => {},
|
||||||
|
};
|
||||||
|
const state = HoldemState.load();
|
||||||
|
globalThis.localStorage = original;
|
||||||
|
|
||||||
|
assert.equal(state.blindOverride, null);
|
||||||
|
assert.equal(state.street, "preflop");
|
||||||
|
state.seats.forEach(p => {
|
||||||
|
assert.equal(p.committed, 0);
|
||||||
|
assert.equal(p.hasActed, false);
|
||||||
|
assert.equal(p.buyIns, 0);
|
||||||
|
});
|
||||||
|
// And it has to be playable, not just parseable.
|
||||||
|
assert.doesNotThrow(() => Betting.beginHand(state));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("storage that refuses to be written doesn't take the table down", () => {
|
||||||
|
const original = globalThis.localStorage;
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => { throw new Error("QuotaExceededError"); },
|
||||||
|
removeItem: () => {},
|
||||||
|
};
|
||||||
|
const state = HoldemState.createDefault();
|
||||||
|
assert.doesNotThrow(() => HoldemState.save(state), "a full disk must not break the hand in progress");
|
||||||
|
globalThis.localStorage = original;
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user