Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f841ccf425 | ||
|
|
6a59b0d5ba | ||
|
|
e36e172670 | ||
|
|
6992833821 | ||
|
|
023228efc0 | ||
|
|
f86e50268e | ||
|
|
4778852436 | ||
|
|
3458a6d144 | ||
|
|
e9ec4afafe | ||
|
|
53c99b2d91 | ||
|
|
2e372d4677 | ||
|
|
c1d0105d12 | ||
|
|
2d80edd513 | ||
|
|
de4331de72 | ||
|
|
e0536de14d | ||
|
|
b096f054f1 | ||
|
|
49c2d745eb | ||
|
|
382238e708 | ||
|
|
8da48e02c8 | ||
|
|
71c198cf2e | ||
|
|
9af65a7b82 | ||
|
|
37abc72de1 | ||
|
|
8136025544 | ||
|
|
d4c39f209a | ||
|
|
42b36a1464 | ||
|
|
ab04c43ffd | ||
|
|
4a2ec1295b | ||
|
|
3326557043 | ||
|
|
edf159325b | ||
|
|
6e7b047d3e | ||
|
|
171e878262 | ||
|
|
0f7213ec2d | ||
|
|
853fcedea0 | ||
|
|
295d9eff4a | ||
|
|
fafbd8df32 | ||
|
|
0d878a176d | ||
|
|
7216f3da4d | ||
|
|
e2bb935b43 | ||
|
|
75ec028c3e | ||
|
|
e90bcccada | ||
|
|
14fb68dc04 | ||
|
|
4a1cb842cb | ||
|
|
e7ddff2fed | ||
|
|
f8ed4abc85 | ||
|
|
942919f3f7 | ||
|
|
02c2a034cb | ||
|
|
cd2db6346e | ||
|
|
6eb97c8a35 | ||
|
|
bd8ee11f77 | ||
|
|
13d704301d | ||
|
|
7bd205a91a | ||
|
|
f07144acd8 | ||
|
|
fbc4bd9a3e | ||
|
|
cd57c16050 | ||
|
|
2a080eab6a | ||
|
|
f93e0935ef | ||
|
|
190ccebf6c | ||
|
|
c052ee32c8 | ||
|
|
1f3dda180f | ||
|
|
3ffce9d7c1 | ||
|
|
5d5093f3cc | ||
|
|
aaf96543a8 | ||
|
|
2e9c55af21 | ||
|
|
8b43614b1c | ||
|
|
42b9e83e8f | ||
|
|
419c1de2a0 | ||
|
|
470fbc0d26 | ||
|
|
c4e842f3e3 | ||
|
|
1d9353c94e | ||
|
|
8616636212 | ||
|
|
6174981cdc | ||
|
|
4db6f6e4d3 | ||
|
|
0ad5592687 | ||
|
|
1c2e716508 | ||
|
|
236c52567e | ||
|
|
1689ecb4f0 | ||
|
|
b8655be258 | ||
|
|
55eb569d3b | ||
|
|
7e420ce747 | ||
|
|
86886ac126 | ||
|
|
c36d7f60bd | ||
|
|
f966d8c340 | ||
|
|
15cec18256 | ||
|
|
0e2821257f | ||
|
|
9c8d981e2d | ||
|
|
c19575ab10 | ||
|
|
907e0165db | ||
|
|
8aa90e90a4 | ||
|
|
86e1570618 | ||
|
|
dc5db16ffd | ||
|
|
6df400322e | ||
|
|
b9b2237dbe | ||
|
|
ca9ac7b6af | ||
|
|
fc866597eb | ||
|
|
80dae56f0d | ||
|
|
b97453fba1 | ||
|
|
6ca99fde6b | ||
|
|
419852a1f4 | ||
|
|
8a2fc3e540 | ||
|
|
b64da7a787 | ||
|
|
3be93cdadf | ||
|
|
40fa16920b | ||
|
|
09eac536b5 | ||
|
|
8efc14f61f | ||
|
|
c70cb150db | ||
|
|
e9bc9afcdb | ||
|
|
9f3d0dce4b | ||
|
|
01f5139740 | ||
|
|
b2262f8604 | ||
|
|
6ad9923aae | ||
|
|
168e8a380c | ||
|
|
edc08d7213 |
+3
-1
@@ -366,4 +366,6 @@ MigrationBackup/
|
||||
FodyWeavers.xsd
|
||||
|
||||
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
|
||||
native/**/build/
|
||||
native/**/build/
|
||||
# Playwright MCP scratch output
|
||||
.playwright-mcp/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -52,8 +53,49 @@ if (!app.Environment.IsDevelopment())
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// One spelling of the casino URLs, because the Hold'em tool's offline support depends
|
||||
// on it and nothing warns you when it breaks.
|
||||
//
|
||||
// Razor Pages routing is case-insensitive, so /Casino/TexasHoldem and
|
||||
// /casino/texasholdem both serve the dealer tool and look identical. A service worker's
|
||||
// scope is not: the spec matches it as a plain string prefix, so a worker scoped to
|
||||
// "/casino/" does not control a page at "/Casino/TexasHoldem". Measured, that meant the
|
||||
// capital-C spelling registered the worker, activated it, logged nothing — and then left
|
||||
// the cache completely empty, because the page was never controlled and the precache
|
||||
// handshake it waited on could not resolve. A dealer who bookmarked that URL had a tool
|
||||
// that looked loaded and had nothing stored for the night the signal went.
|
||||
//
|
||||
// So one spelling wins and the other is redirected to it, permanently, before anything
|
||||
// else in the pipeline gets a look.
|
||||
//
|
||||
// Deliberately scoped to /casino rather than applied site-wide. Every file under
|
||||
// wwwroot/casino and wwwroot/images/casino is already lowercase, so lowercasing a path
|
||||
// here can never miss a real file on a case-sensitive filesystem — a guarantee that does
|
||||
// not hold for the rest of the site, and one that /chessHub in particular would break.
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var path = context.Request.Path.Value;
|
||||
if (path is not null
|
||||
&& path.StartsWith("/casino", StringComparison.OrdinalIgnoreCase)
|
||||
&& path.Any(char.IsUpper))
|
||||
{
|
||||
context.Response.Redirect(path.ToLowerInvariant() + context.Request.QueryString, permanent: true);
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
// .webmanifest isn't in the default MIME map, and an unknown extension isn't served
|
||||
// with the wrong type — it isn't served at all. Without this the Hold'em tool's
|
||||
// manifest 404s, and a 404 manifest is exactly the silent failure that stops a browser
|
||||
// ever offering "Add to Home Screen": nothing is logged and no prompt appears.
|
||||
var contentTypes = new FileExtensionContentTypeProvider();
|
||||
contentTypes.Mappings[".webmanifest"] = "application/manifest+json";
|
||||
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
ContentTypeProvider = contentTypes,
|
||||
OnPrepareResponse = ctx =>
|
||||
{
|
||||
ctx.Context.Response.Headers.Append("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "Texas Hold'em Dealer",
|
||||
"short_name": "Hold'em",
|
||||
"description": "Seat your players, track the action, split the pot. Works with no signal.",
|
||||
"start_url": "/casino/texasholdem",
|
||||
"scope": "/casino/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#141414",
|
||||
"theme_color": "#141414",
|
||||
"categories": ["utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/casino/holdem-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/images/casino/holdem-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/images/casino/holdem-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Keeps the dealer tool working when the network doesn't.
|
||||
//
|
||||
// A home game is the worst network in the world: somebody's back room, one bar of
|
||||
// signal, a router two floors away. The tool already keeps the whole night in
|
||||
// localStorage, so the *table* survives anything — but the page didn't. Reload with no
|
||||
// signal, or open it fresh when the wifi has dropped, and a dealer holding a phone in
|
||||
// one hand and a deck in the other got a browser error page with nine players waiting.
|
||||
//
|
||||
// This is the one thing standing between "a website about poker" and "a tool you can
|
||||
// rely on at a table", so it is worth the moving part.
|
||||
//
|
||||
// Scope is /casino/ rather than /casino/texasholdem/, because a scope has to be a
|
||||
// prefix of the page it controls and "/casino/texasholdem" does not start with
|
||||
// "/casino/texasholdem/". Anything in that scope this worker has not been told about
|
||||
// goes straight to the network, so the other casino pages behave exactly as they did.
|
||||
|
||||
// Bumped whenever the shape of what is cached changes. The asset *version* rides in
|
||||
// the URLs themselves (?v=1.0.8 from _Layout), so a normal release makes new keys
|
||||
// without touching this.
|
||||
const CACHE = "holdem-shell-v1";
|
||||
|
||||
// What this worker will keep. Anything else in scope — a form post, an API call, a
|
||||
// page it was never told about — goes straight to the network and behaves exactly as
|
||||
// it did before the worker existed.
|
||||
const CACHEABLE = new Set(["script", "style", "image", "font", "manifest"]);
|
||||
|
||||
// The page hands over the exact list of URLs it was built from — see registerWorker()
|
||||
// in main.js. Deriving it from the document instead of keeping a list here is the
|
||||
// whole trick: a hand-written manifest of files drifts from the markup the first time
|
||||
// somebody adds a script tag, and the failure mode is a tool that is offline-capable
|
||||
// right up until the release that quietly broke it.
|
||||
async function precache(urls) {
|
||||
const cache = await caches.open(CACHE);
|
||||
// Individually rather than cache.addAll, which rejects the whole batch if any one
|
||||
// URL 404s — one stale entry in the list would leave the dealer with nothing.
|
||||
await Promise.all(urls.map(async url => {
|
||||
try {
|
||||
const res = await fetch(url, { cache: "reload" });
|
||||
if (res.ok) await cache.put(url, res);
|
||||
} catch {
|
||||
// Offline while installing. The runtime handler below picks these up on
|
||||
// the next online visit.
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// No install handler, and deliberately no skipWaiting(). A new worker taking over a
|
||||
// live page can hand it assets from a different release than the ones it started with,
|
||||
// and this page loads seventeen separate scripts that expect to agree with each other.
|
||||
// Waiting costs a dealer nothing — the update lands the next time they open the tool —
|
||||
// and it cannot leave them with a half-swapped app in the middle of a hand.
|
||||
|
||||
self.addEventListener("activate", event => {
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys
|
||||
.filter(key => key.startsWith("holdem-shell-") && key !== CACHE)
|
||||
.map(key => caches.delete(key)));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener("message", event => {
|
||||
if (event.data && event.data.type === "precache" && Array.isArray(event.data.urls)) {
|
||||
event.waitUntil(precache(event.data.urls));
|
||||
}
|
||||
});
|
||||
|
||||
// Only GETs, and only same-origin. A cross-origin request has opaque responses that
|
||||
// can't be inspected, and caching one would mean serving a failure forever.
|
||||
function handles(request) {
|
||||
if (request.method !== "GET") return false;
|
||||
const url = new URL(request.url);
|
||||
return url.origin === self.location.origin;
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", event => {
|
||||
const { request } = event;
|
||||
if (!handles(request)) return;
|
||||
|
||||
// The page itself: network first, so an online dealer always gets the current
|
||||
// build, and the cached copy is there the moment they aren't.
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith((async () => {
|
||||
try {
|
||||
const fresh = await fetch(request);
|
||||
if (fresh.ok) (await caches.open(CACHE)).put(request, fresh.clone());
|
||||
return fresh;
|
||||
} catch {
|
||||
const cached = await caches.match(request, { ignoreSearch: true });
|
||||
if (cached) return cached;
|
||||
throw new Error("offline and this page was never cached");
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
|
||||
// Assets: stale while revalidate. The cached copy answers immediately — which is
|
||||
// the whole point, a dealer opening this between hands should not wait for a
|
||||
// network — and a fresh copy is fetched in the background for next time.
|
||||
//
|
||||
// Cache-first would be the obvious choice, on the reasoning that these URLs carry
|
||||
// ?v= from the layout and so a hit is always the right file. They don't: the
|
||||
// version renders empty (see the note in TexasHoldem.cshtml), so every release
|
||||
// reuses the same URLs. Cache-first against unversioned URLs is how a tool gets
|
||||
// frozen on a build from three months ago with no way for anyone to tell. This
|
||||
// strategy needs no version to be correct: worst case a dealer is one launch
|
||||
// behind, which for a tool that runs entirely in the browser is nothing.
|
||||
if (!CACHEABLE.has(request.destination)) return;
|
||||
|
||||
event.respondWith((async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
const fresh = fetch(request).then(res => {
|
||||
if (res.ok) cache.put(request, res.clone());
|
||||
return res;
|
||||
}).catch(() => null);
|
||||
|
||||
if (cached) return cached;
|
||||
const res = await fresh;
|
||||
if (res) return res;
|
||||
throw new Error("offline and not cached: " + request.url);
|
||||
})());
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,15 @@
|
||||
/* Quick Split is phone-first: sized for one thumb at the table, then allowed to
|
||||
breathe on a laptop. Palette matches the dealer table so the two read as one tool. */
|
||||
|
||||
/* And matches it in the half a stylesheet does not paint, for the same reason: this
|
||||
page is #0a0a0a and had never told the browser so, which leaves the scrollbars, the
|
||||
caret, the selection and the autofill wash drawn for a light page. It has no
|
||||
checkboxes to go white, but a dealer who opens it from the felt should not find a
|
||||
light scrollbar down the side of the darker of the two pages. */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background: radial-gradient(ellipse at 50% -10%, #1c1c1c 0%, #101010 55%, #0a0a0a 100%);
|
||||
min-height: 100vh;
|
||||
@@ -143,12 +152,18 @@ button,
|
||||
}
|
||||
|
||||
.unevenCurrency {
|
||||
color: #777;
|
||||
/* Was #777 — 4.11:1 on this background, under the 4.5:1 small text needs. It sits
|
||||
against every amount a dealer types, so it is read constantly. */
|
||||
color: #8f8f8f;
|
||||
}
|
||||
|
||||
/* 37px tall, on the field a dealer types every amount into. The touch rule has been
|
||||
44px everywhere else since the round that set it; this page never got measured for
|
||||
it, because the audit only ran the target check when asked and nothing asked. */
|
||||
.unevenAmount {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 46px;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
@@ -178,13 +193,17 @@ button,
|
||||
color: #ffd6d6;
|
||||
}
|
||||
|
||||
/* 30px across, and it deletes a player's entry — the one control on the row where a
|
||||
near miss costs something. The amount field beside it gives up the 16px. */
|
||||
.unevenRemoveBtn {
|
||||
flex: none;
|
||||
width: 30px;
|
||||
height: 44px;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #6a6a6a;
|
||||
/* Was #6a6a6a — 3.66:1, the worst on the page, on a control that deletes a
|
||||
player's entry. A destructive action should not be the faintest thing in view. */
|
||||
color: #9a9a9a;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -209,11 +228,86 @@ button,
|
||||
.unevenPot {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
/* Wraps so a chop opens underneath its own pot rather than squeezing in beside the
|
||||
eligible-players list and pushing it off the row. */
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
padding: 0.3rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.unevenChopToggle {
|
||||
flex: none;
|
||||
order: 3;
|
||||
/* 44px on touch: it moves money, like everything else that does. */
|
||||
min-height: 32px;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border: 1px solid rgba(216, 180, 106, 0.45);
|
||||
border-radius: 999px;
|
||||
background: rgba(216, 180, 106, 0.1);
|
||||
color: #e3c887;
|
||||
font: inherit;
|
||||
font-size: 0.74rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.unevenChopToggle:hover {
|
||||
background: rgba(216, 180, 106, 0.18);
|
||||
}
|
||||
|
||||
.unevenChopToggle:focus-visible {
|
||||
outline: 2px solid #d8b46a;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.unevenChopDetail {
|
||||
order: 4;
|
||||
flex: 0 0 100%;
|
||||
/* Or the padding is added on top of the 100% basis and the panel sits wider than
|
||||
the row it belongs to — measured 328px inside a 309px row on a phone. */
|
||||
box-sizing: border-box;
|
||||
margin: 0.15rem 0 0.2rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.unevenChopWays {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.unevenChopWaysLabel {
|
||||
color: #cfcfcf;
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.unevenChopEach {
|
||||
margin: 0;
|
||||
color: #f2f2f2;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.unevenChopNote {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #9a9a9a;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.unevenChopDust {
|
||||
color: #e0b169;
|
||||
}
|
||||
|
||||
@media (max-width: 700px), (pointer: coarse) {
|
||||
.unevenChopToggle {
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.unevenPot + .unevenPot {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
@@ -432,20 +526,30 @@ button,
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* As wide as the three things in it, and no wider.
|
||||
This sits in the same wrapping flex row as the chips, where it has never once fitted
|
||||
on the first line of a phone. `flex: 1 1 auto` then handed it the whole of the second
|
||||
line: measured on an iPhone, a 358px bar with 252px of nothing in the middle and the
|
||||
− and + pushed 304px apart — the two ends of a control a thumb is meant to work with
|
||||
one hand. 136px now, its two ends 121px apart. */
|
||||
.waysStepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 150px;
|
||||
flex: 0 0 auto;
|
||||
background: #191919;
|
||||
border: 1px solid #333;
|
||||
border-radius: 11px;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
|
||||
/* 44 both ways, not 40x44. It had been 40px wide since the steppers were written, and
|
||||
the sweep never said so because the only two steppers on the site are on this screen
|
||||
and this screen was only ever audited in its all-in mode, where neither is on display
|
||||
until a side pot has more than two contenders. The first sweep that opened the even
|
||||
mode reported `hittable 39x43` on both ends, on every touch viewport. */
|
||||
.stepBtn {
|
||||
width: 40px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
@@ -457,17 +561,26 @@ button,
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* The floor and the ceiling look like the floor and the ceiling. */
|
||||
.stepBtn:disabled {
|
||||
opacity: 0.32;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.stepBtn:hover {
|
||||
.stepBtn:not(:disabled):hover {
|
||||
background: #333;
|
||||
}
|
||||
}
|
||||
|
||||
/* The count itself, now that no chip beside it is also holding one — so it is painted
|
||||
the way a selected chip is painted, because that is what it is standing in for. */
|
||||
.waysReadout {
|
||||
flex: 1;
|
||||
min-width: 2.5ch;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 0.8rem;
|
||||
color: #f0d9a8;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -510,7 +623,9 @@ button,
|
||||
|
||||
.splitFootnote {
|
||||
margin-top: 1.5rem;
|
||||
color: #6f6f6f;
|
||||
/* Was #6f6f6f — 3.94:1. It carries the instruction for what to do with the pot
|
||||
list, which is not a decorative line. */
|
||||
color: #8d8d8d;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,17 @@ const AddChips = {
|
||||
];
|
||||
},
|
||||
|
||||
// Choosing a preset redraws the three of them so the chosen one can light up, which
|
||||
// meant the button the dealer had just pressed was deleted while it held focus —
|
||||
// and a deleted element doesn't hand focus on, it drops it on the body. Measured:
|
||||
// Space on "Buy-in" left focus nowhere, inside a modal, with the amount changed and
|
||||
// nothing to say so. See Modal.preservingFocus; the key is the amount, because that
|
||||
// is what makes one of these three buttons itself.
|
||||
render() {
|
||||
Modal.preservingFocus(document.getElementById("addChipsSheet"), () => this.paint());
|
||||
},
|
||||
|
||||
paint() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
document.getElementById("addChipsTitle").textContent =
|
||||
`Add chips — ${HoldemState.displayName(player, this.seatIndex)}`;
|
||||
@@ -59,6 +69,7 @@ const AddChips = {
|
||||
btn.type = "button";
|
||||
btn.className = "addChipsQuickBtn";
|
||||
btn.classList.toggle("selected", Number(this.draft) === option.value);
|
||||
btn.dataset.focusKey = `quick-${option.value}`;
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "addChipsQuickLabel";
|
||||
|
||||
@@ -13,6 +13,15 @@
|
||||
const Betting = {
|
||||
STREETS: ["preflop", "flop", "turn", "river"],
|
||||
|
||||
// How many community cards are face up once a street has been dealt. The flop is
|
||||
// three at once and everything after it is one at a time, which is why this is a
|
||||
// table rather than an index.
|
||||
BOARD_CARDS: { preflop: 0, flop: 3, turn: 4, river: 5 },
|
||||
|
||||
boardCardsFor(street) {
|
||||
return this.BOARD_CARDS[street] ?? 0;
|
||||
},
|
||||
|
||||
streetLabel(street) {
|
||||
return { preflop: "Pre-flop", flop: "Flop", turn: "Turn", river: "River" }[street] || "Pre-flop";
|
||||
},
|
||||
@@ -93,9 +102,60 @@ const Betting = {
|
||||
satOut,
|
||||
smallBlind: this.round(state.smallBlind || 0),
|
||||
bigBlind: this.round(state.bigBlind || 0),
|
||||
// What the straddles will be if the hand is dealt as it stands, and how
|
||||
// many more this table could take.
|
||||
straddles: this.straddleSeats(state),
|
||||
maxStraddles: this.maxStraddles(state),
|
||||
};
|
||||
},
|
||||
|
||||
// ---- Straddles ----
|
||||
//
|
||||
// A straddle is a blind raise put out before the cards, by the player left of the
|
||||
// big blind, for twice it. It buys the last word before the flop: everyone else
|
||||
// acts first, and the straddler still has the option when it comes back round even
|
||||
// if nobody raised. Re-straddles carry on left, each doubling again.
|
||||
//
|
||||
// Home games run them constantly and a dealer has to get three things right at
|
||||
// once — the amount, who is first to act, and what the minimum raise becomes. The
|
||||
// tool used to do none of them, so a dealer's only way through was to deal, have
|
||||
// the straddler "raise" to $4, and hand the action to the wrong player.
|
||||
|
||||
// How many straddles this table could take. Everyone but the two blinds can put
|
||||
// one out; heads-up that is nobody, which is the right answer — the button is
|
||||
// already the small blind and there is no seat left of the big blind to straddle
|
||||
// from.
|
||||
maxStraddles(state) {
|
||||
return Math.max(0, this.liveSeats(this.tableAsDealt(state)).length - 2);
|
||||
},
|
||||
|
||||
// Who straddles and for how much, walking left from the big blind and doubling.
|
||||
// Asked of the table as it will be dealt, so a seat about to be sat out for having
|
||||
// no chips is never handed a straddle it can't post.
|
||||
straddleSeats(state, count = state.straddleCount) {
|
||||
const wanted = Math.min(Math.max(0, Number(count) || 0), this.maxStraddles(state));
|
||||
if (wanted <= 0) return [];
|
||||
|
||||
const dealt = this.tableAsDealt(state);
|
||||
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
|
||||
const asDealt = buttonIndex === dealt.dealerSeatIndex
|
||||
? dealt
|
||||
: { ...dealt, dealerSeatIndex: buttonIndex };
|
||||
const { bbIndex } = HoldemState.getBlindSeats(asDealt);
|
||||
if (bbIndex === null) return [];
|
||||
|
||||
const straddles = [];
|
||||
let index = bbIndex;
|
||||
let amount = this.round((Number(state.bigBlind) || 0) * 2);
|
||||
for (let n = 0; n < wanted; n++) {
|
||||
index = this.nextToAct(asDealt, index);
|
||||
if (index === null || index === bbIndex) break;
|
||||
straddles.push({ index, amount });
|
||||
amount = this.round(amount * 2);
|
||||
}
|
||||
return straddles;
|
||||
},
|
||||
|
||||
// Does handing this seat chips also put them back in the game?
|
||||
//
|
||||
// At a real table those are one event, and here they were two: a busted player got
|
||||
@@ -276,6 +336,11 @@ const Betting = {
|
||||
|
||||
if (newBet > previousBet) {
|
||||
const increase = this.round(newBet - previousBet);
|
||||
// Aggression, whether or not it was a full raise — a short all-in doesn't
|
||||
// reopen the betting, but the player who made it is still the last one who
|
||||
// put money in, and at the showdown they are still the one who shows first.
|
||||
state.streetAggressorIndex = index;
|
||||
state.handAggressorIndex = index;
|
||||
// What a full raise would have had to be: at least as much again as the
|
||||
// last one, or the big blind if there hasn't been a raise yet.
|
||||
const fullRaise = this.round(state.lastRaiseSize || state.bigBlind || 0);
|
||||
@@ -337,6 +402,10 @@ const Betting = {
|
||||
|
||||
state.currentBet = 0;
|
||||
state.lastRaiseSize = 0;
|
||||
// Whoever led this street stops being the answer the moment the next one opens.
|
||||
// The hand's aggressor is deliberately left alone: it's the fallback for a
|
||||
// river nobody could bet on.
|
||||
state.streetAggressorIndex = null;
|
||||
|
||||
const nextStreetIndex = this.STREETS.indexOf(state.street || "preflop") + 1;
|
||||
state.street = this.STREETS[nextStreetIndex] || "river";
|
||||
@@ -399,6 +468,116 @@ const Betting = {
|
||||
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
|
||||
},
|
||||
|
||||
// Are the blinds actually down? Moving the button or naming a blind means two
|
||||
// completely different things depending on the answer, and `onlyBlindsIn` alone
|
||||
// cannot tell them apart: a table sitting in setup, with no cards out and nothing
|
||||
// committed, satisfies it just as well as a hand that has only just been dealt.
|
||||
//
|
||||
// With a hand open, the blinds are chips on the felt. Re-aiming them has to take
|
||||
// those chips back and post them again from the new positions, or the badges and
|
||||
// the money disagree.
|
||||
//
|
||||
// Before the deal it is a marker being slid round a table with nothing in the
|
||||
// middle. Re-posting there would *deal the hand* — betting opened, blinds taken off
|
||||
// two stacks, action on the seat after the big blind — which is not what a dealer
|
||||
// means by "she's the big blind this one". Setting the positions is how a dealer
|
||||
// gets ready to deal; the cards come out when they press the button, and not a
|
||||
// moment before.
|
||||
blindsArePosted(state) {
|
||||
return !!state.bettingOpen && this.onlyBlindsIn(state);
|
||||
},
|
||||
|
||||
// Chips the pot has been priced with but nobody has actually paid.
|
||||
//
|
||||
// Two ways a contribution gets into this tool. Tapping the action moves chips: the
|
||||
// bet leaves the stack, sits in front of the player, and is swept into their hand
|
||||
// total at the end of the street. The other way is the "without live betting" panel
|
||||
// — a base pot amount, or chips counted out on a seat — where the dealer is
|
||||
// recording a hand they are watching rather than driving, and nothing ever leaves
|
||||
// anybody's stack.
|
||||
//
|
||||
// The pot wizard prices both the same way, and awarding it credits the winner. So
|
||||
// in that second mode the table simply grew: measured at a three-handed table with
|
||||
// a $50 base pot, paying the pot took the chips on the table from $600 to $750 and
|
||||
// left the bank check blaming a rebuy that never happened.
|
||||
//
|
||||
// So before the pot is pushed, whatever was assumed rather than taken comes off the
|
||||
// stacks that assumed it. Chips already committed or swept are left alone; they
|
||||
// have been paid once already, and taking them twice is the same bug the other way
|
||||
// round.
|
||||
collectAssumedContributions(state) {
|
||||
const taken = [];
|
||||
state.seats.forEach((player, index) => {
|
||||
const priced = HoldemState.computeContribution(player, state.chipLegend, state.basePotAmount);
|
||||
const alreadyPaid = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
|
||||
const owed = this.round(priced - alreadyPaid);
|
||||
if (owed <= this.EPSILON_CHIPS) return;
|
||||
|
||||
// Never more than they are holding. A seat priced above its own stack is a
|
||||
// dealer's typo or a player who is short, and inventing the difference is
|
||||
// what this whole function exists to stop.
|
||||
const moved = this.round(Math.min(owed, this.stackOf(state, player)));
|
||||
if (moved <= this.EPSILON_CHIPS) return;
|
||||
|
||||
player.stack = this.round(this.stackOf(state, player) - moved);
|
||||
// Recorded where a swept bet lives, so the money is in exactly the place
|
||||
// the rest of the tool looks for chips that are in the middle this hand.
|
||||
player.amountOverride = this.round((Number(player.amountOverride) || 0) + moved);
|
||||
taken.push({ index, name: HoldemState.displayName(player, index), amount: moved });
|
||||
});
|
||||
return { taken, total: this.round(taken.reduce((sum, row) => sum + row.amount, 0)) };
|
||||
},
|
||||
|
||||
// A misdeal: the hand never happened, so every chip goes back where it came from.
|
||||
//
|
||||
// The rulings sheet in this tool has told dealers to redeal since the day it was
|
||||
// written — first card to the wrong seat, a seat entitled to a hand dealt out, one
|
||||
// of the first two cards exposed — and the tool had no way to do it. The only
|
||||
// control that ended a live hand was Next Hand, which says out loud that it takes
|
||||
// the money off the table without paying anybody. That is right for an abandoned
|
||||
// pot and wrong for a misdeal, where the blinds and any calls belong to the people
|
||||
// who put them in.
|
||||
//
|
||||
// Undo covers the first second of it — "↶ dealing the hand" is one tap — but only
|
||||
// while the action history is still there. It lives in memory, so a dealer who
|
||||
// refreshes the page mid-hand, or who is three calls in, has nothing.
|
||||
//
|
||||
// What is deliberately kept: the button, the blinds, the straddle and anyone
|
||||
// posting in. A misdeal is the *same* hand dealt again, not the next one.
|
||||
takeHandBack(state) {
|
||||
const returned = [];
|
||||
state.seats.forEach((player, index) => {
|
||||
const back = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
|
||||
if (back > 0) {
|
||||
player.stack = this.round(this.stackOf(state, player) + back);
|
||||
returned.push({ index, name: HoldemState.displayName(player, index), amount: back });
|
||||
}
|
||||
player.committed = 0;
|
||||
player.amountOverride = null;
|
||||
player.hasActed = false;
|
||||
player.folded = false;
|
||||
player.cards = ["", ""];
|
||||
});
|
||||
|
||||
state.bettingOpen = false;
|
||||
state.street = "preflop";
|
||||
state.currentBet = 0;
|
||||
state.lastRaiseSize = 0;
|
||||
state.actingSeatIndex = null;
|
||||
state.streetAggressorIndex = null;
|
||||
state.handAggressorIndex = null;
|
||||
HoldemState.clearBoard(state);
|
||||
// Nothing was posted, as far as the next rotation is concerned: the hand this
|
||||
// would have counted as is being dealt again from the same button.
|
||||
state.postedBlinds = null;
|
||||
state.postedStraddles = [];
|
||||
|
||||
return {
|
||||
returned,
|
||||
total: this.round(returned.reduce((sum, row) => sum + row.amount, 0)),
|
||||
};
|
||||
},
|
||||
|
||||
// Pulls the blinds back off the table and posts them again from wherever the
|
||||
// button and the blind assignment now say they belong. Refunding first is what
|
||||
// keeps the chip count straight when the button gets passed round three or four
|
||||
@@ -424,6 +603,15 @@ const Betting = {
|
||||
state.street = "preflop";
|
||||
state.currentBet = 0;
|
||||
state.lastRaiseSize = 0;
|
||||
// A fresh deal starts on an empty felt. Dealing without moving the button —
|
||||
// after a misdeal, or a table set up and dealt twice — would otherwise leave
|
||||
// the last hand's board sitting there being ranked against the new cards.
|
||||
HoldemState.clearBoard(state);
|
||||
// Posting a blind isn't aggression — a hand limped and checked all the way
|
||||
// down has no aggressor at all, which is exactly the case the showdown rule
|
||||
// sends to the seat left of the button.
|
||||
state.streetAggressorIndex = null;
|
||||
state.handAggressorIndex = null;
|
||||
|
||||
// Deal around anyone with no chips, before anything else reads the table.
|
||||
//
|
||||
@@ -461,6 +649,7 @@ const Betting = {
|
||||
state.currentBet = 0;
|
||||
state.actingSeatIndex = null;
|
||||
state.postedBlinds = null;
|
||||
state.postedStraddles = [];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -491,17 +680,110 @@ const Betting = {
|
||||
// from one who has just walked up and had the blinds go past them.
|
||||
state.dealtLastHand = state.seats.filter(p => p.inHand).map(p => p.id);
|
||||
|
||||
state.currentBet = this.round(Math.max(
|
||||
sbIndex === null ? 0 : this.committed(state.seats[sbIndex]),
|
||||
this.committed(state.seats[bbIndex])));
|
||||
state.lastRaiseSize = bb || 0;
|
||||
// Straddles go out after the blinds and before the cards, each one left of the
|
||||
// last. Posted here rather than left to the dealer to fake as a raise, because
|
||||
// a raise from that seat would hand the action to the wrong player.
|
||||
const straddles = this.straddleSeats(state);
|
||||
straddles.forEach(({ index, amount }) => this.putIn(state, index, amount));
|
||||
state.postedStraddles = straddles.map(({ index }) => index);
|
||||
|
||||
state.currentBet = this.round(state.seats.reduce(
|
||||
(most, player) => Math.max(most, this.committed(player)), 0));
|
||||
// A straddle is a blind raise, so what has to be matched to raise again is the
|
||||
// straddle itself, not the big blind underneath it: $2 blinds with a $4
|
||||
// straddle make the next raise $8, the same way the $2 blind makes it $4.
|
||||
// Left alone, the minimum would come out at $6 and the dealer would be talked
|
||||
// out of it by every player at the table.
|
||||
state.lastRaiseSize = straddles.length > 0 ? state.currentBet : (bb || 0);
|
||||
|
||||
// Heads-up the button is the small blind and acts first before the flop; with
|
||||
// three or more it's the seat after the big blind. Either way, a blind big
|
||||
// enough to put that player all-in leaves them nothing to decide.
|
||||
//
|
||||
// A straddle moves that seat along: first to act is left of the last straddler,
|
||||
// and the straddler keeps the option, which falls out of nobody having acted
|
||||
// yet.
|
||||
const lastPosted = straddles.length > 0
|
||||
? straddles[straddles.length - 1].index
|
||||
: bbIndex;
|
||||
state.actingSeatIndex = headsUp
|
||||
? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex))
|
||||
: this.nextToAct(state, bbIndex);
|
||||
: this.nextToAct(state, lastPosted);
|
||||
},
|
||||
|
||||
// What a pot-sized raise actually is, as a "raise to" total.
|
||||
//
|
||||
// Not "the bet plus the pot as it stands". The raiser calls first, and their call
|
||||
// is in the pot before the raise is measured off it, so the pot being matched is
|
||||
// the one that exists after the call:
|
||||
//
|
||||
// raise to = the current bet + (pot + what they owe) × fraction
|
||||
//
|
||||
// Pre-flop at 1/2 that gives $7, not $5: call the $2, which makes the pot $5, then
|
||||
// raise $5. Leaving the call out understates every one of these, and players say
|
||||
// "pot" out loud meaning a definite number.
|
||||
//
|
||||
// Facing no bet this is a plain pot-sized bet — toCall is zero and currentBet is
|
||||
// zero, so it comes out as the pot, which is the right answer there too.
|
||||
// Rounded up to something the table's chips can actually build. Half the pot is
|
||||
// constantly a half-chip amount — pre-flop at 1/2 it is $4.50, and the smallest
|
||||
// chip in the default legend is a dollar, so the button offered a number the
|
||||
// dealer would have to round themselves every single time.
|
||||
//
|
||||
// Only when the legend describes this game. A smallest chip *larger* than the big
|
||||
// blind means the denominations were never set for these stakes, and rounding a
|
||||
// 5c/10c table up to whole dollars would be the tool doing real damage on the
|
||||
// strength of a field somebody forgot to fill in. Left exact there, and the wrong
|
||||
// legend stays visible rather than being quietly applied.
|
||||
potRaiseTo(state, index, fraction = 1) {
|
||||
const player = state.seats[index];
|
||||
if (!player) return 0;
|
||||
const potAfterCall = this.round(this.potSoFar(state) + this.toCall(state, player));
|
||||
const exact = this.round((state.currentBet || 0) + potAfterCall * fraction);
|
||||
|
||||
const step = Money.fromCents(Money.chipStepCents(state.chipLegend));
|
||||
const bigBlind = Number(state.bigBlind) || 0;
|
||||
if (step <= 0 || (bigBlind > 0 && step > bigBlind)) return exact;
|
||||
return Money.upToChip(exact, state.chipLegend);
|
||||
},
|
||||
|
||||
// Who turns their hand over first.
|
||||
//
|
||||
// A call the dealer makes at every showdown that isn't a fold, and one nobody at
|
||||
// the table volunteers for — everyone would rather see a hand than show one. The
|
||||
// tool knew who last raised and who sits left of the button and said neither, so
|
||||
// the dealer was working it out from memory while five people waited.
|
||||
//
|
||||
// Three cases, in the order the rule asks them:
|
||||
// * somebody bet or raised on the final round — they show first;
|
||||
// * the round was checked through — first live player left of the button;
|
||||
// * nobody could bet, because the chips were already in — the last player who
|
||||
// did put money in, on whichever round that was.
|
||||
//
|
||||
// Returns null when there's nothing to call: one player left, or none.
|
||||
showdownOrder(state) {
|
||||
const live = this.liveSeats(state);
|
||||
if (live.length < 2) return null;
|
||||
|
||||
const seatOf = index =>
|
||||
Number.isInteger(index) && live.some(s => s.index === index) ? index : null;
|
||||
|
||||
const streetAggressor = seatOf(state.streetAggressorIndex);
|
||||
if (streetAggressor !== null) {
|
||||
return { index: streetAggressor, reason: "aggressor" };
|
||||
}
|
||||
|
||||
// Nobody could have bet this round, so "no bet on the final round" isn't a
|
||||
// choice anybody made and the seat left of the button hasn't earned the
|
||||
// obligation. It falls to whoever last put money in.
|
||||
if (!this.canStillBet(state)) {
|
||||
const handAggressor = seatOf(state.handAggressorIndex);
|
||||
if (handAggressor !== null) return { index: handAggressor, reason: "allInAggressor" };
|
||||
}
|
||||
|
||||
const order = HoldemState.liveOrderFromButton(state)
|
||||
.filter(index => live.some(s => s.index === index));
|
||||
return order.length > 0 ? { index: order[0], reason: "leftOfButton" } : null;
|
||||
},
|
||||
|
||||
// Winners get their share put back in front of them.
|
||||
|
||||
@@ -2,90 +2,351 @@
|
||||
//
|
||||
// Typing "AH" into a two-character box is fine at a desk and miserable at a table:
|
||||
// it needs a keyboard, it accepts nonsense like "ZZ", and it happily lets the same
|
||||
// card land in two players' hands. The picker is one tap per card, greys out cards
|
||||
// already dealt somewhere else at the table, and stores exactly the same two-character
|
||||
// string the rest of the tool has always read.
|
||||
// card land in two players' hands. The picker greys out cards already dealt somewhere
|
||||
// else at the table and stores exactly the same two-character string the rest of the
|
||||
// tool has always read.
|
||||
//
|
||||
// It used to put all fifty-two on the screen at once, four suits across and thirteen
|
||||
// ranks down, and that grid is 646px tall. Measured with the board tracked and seven
|
||||
// cards out:
|
||||
//
|
||||
// 640x360 816px of content in a 317px sheet — 40 of the 52 below the fold
|
||||
// 667x375 816 in 330 — 40 below
|
||||
// 844x390 774 in 343 — 36 below
|
||||
// 375x667 816 in 587 — 20 below
|
||||
// 1280x900 774 in 775 — none
|
||||
//
|
||||
// So on every phone the tool is actually used on, entering the flop began with a
|
||||
// scroll, and a dealer looking for the nine of diamonds was reading down a column.
|
||||
// Fifty-two 44px targets do not fit a phone in any arrangement: thirteen across is
|
||||
// 572px of grid before gaps, which is wider than any phone in portrait.
|
||||
//
|
||||
// A card is not one choice out of fifty-two. It is two: a suit and a rank. So the sheet
|
||||
// asks them in that order — four suits on a row, then the thirteen ranks of the one
|
||||
// chosen — which is seventeen targets instead of fifty-two, all of them on the screen
|
||||
// at once on every phone, and none of them smaller than they were. The suit stays put
|
||||
// between picks, so a second card of the same suit is still a single tap.
|
||||
const CardPicker = {
|
||||
RANKS: ["A", "K", "Q", "J", "T", "9", "8", "7", "6", "5", "4", "3", "2"],
|
||||
SUITS: [
|
||||
{ code: "S", symbol: "♠", red: false },
|
||||
{ code: "H", symbol: "♥", red: true },
|
||||
{ code: "D", symbol: "♦", red: true },
|
||||
{ code: "C", symbol: "♣", red: false },
|
||||
{ code: "S", symbol: "♠", name: "Spades", red: false },
|
||||
{ code: "H", symbol: "♥", name: "Hearts", red: true },
|
||||
{ code: "D", symbol: "♦", name: "Diamonds", red: true },
|
||||
{ code: "C", symbol: "♣", name: "Clubs", red: false },
|
||||
],
|
||||
|
||||
// Which card in the grid the arrows are holding, as an index into the thirteen
|
||||
// ranks on show. Kept across a redraw so the grid doesn't send the dealer back to
|
||||
// the ace every time they pick something — and kept across a change of suit, which
|
||||
// is the same rank in a different suit rather than a new place to be.
|
||||
cursor: 0,
|
||||
|
||||
// Which suit's thirteen are on the grid. Deliberately not reset when the sheet
|
||||
// opens: a dealer entering a flush board, or two suited hole cards, picks the suit
|
||||
// once. Where the slot being filled already holds a card, that card's suit wins —
|
||||
// the sheet should open showing what it is about to replace.
|
||||
suit: "S",
|
||||
|
||||
state: null,
|
||||
hooks: null,
|
||||
// Which set of slots is being filled: a seat's two hole cards, or the five
|
||||
// community cards. The grid, the duplicate check and the tap-to-fill behaviour are
|
||||
// the same job either way — what changes is where the card is written and how many
|
||||
// slots there are to walk along.
|
||||
target: null,
|
||||
seatIndex: null,
|
||||
cardIndex: 0,
|
||||
|
||||
open(state, hooks, seatIndex, cardIndex) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.target = "seat";
|
||||
this.seatIndex = seatIndex;
|
||||
this.cardIndex = cardIndex;
|
||||
this.cursor = 0;
|
||||
this.aimSuit();
|
||||
this.render();
|
||||
document.getElementById("cardPickerOverlay").hidden = false;
|
||||
},
|
||||
|
||||
close() {
|
||||
document.getElementById("cardPickerOverlay").hidden = true;
|
||||
// The board. `limit` is how many of the five are actually out — three on the flop,
|
||||
// four on the turn — so the sheet never offers a slot for a card the dealer has not
|
||||
// turned over yet.
|
||||
openBoard(state, hooks, cardIndex, limit = 5) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.target = "board";
|
||||
this.seatIndex = null;
|
||||
this.boardLimit = Math.max(1, Math.min(5, limit));
|
||||
this.cardIndex = Math.min(cardIndex, this.boardLimit - 1);
|
||||
this.cursor = 0;
|
||||
this.aimSuit();
|
||||
this.render();
|
||||
document.getElementById("cardPickerOverlay").hidden = false;
|
||||
},
|
||||
|
||||
// Every card sitting in front of anyone else. The slot being edited is excluded,
|
||||
// so re-picking the card that's already there isn't treated as a duplicate.
|
||||
onBoard() {
|
||||
return this.target === "board";
|
||||
},
|
||||
|
||||
// The slots the sheet is filling, whichever set that is.
|
||||
slotValues() {
|
||||
return this.onBoard()
|
||||
? (this.state.board || []).slice(0, this.boardLimit)
|
||||
: this.state.seats[this.seatIndex].cards;
|
||||
},
|
||||
|
||||
write(index, card) {
|
||||
if (this.onBoard()) {
|
||||
if (!Array.isArray(this.state.board)) this.state.board = ["", "", "", "", ""];
|
||||
this.state.board[index] = card;
|
||||
return;
|
||||
}
|
||||
this.state.seats[this.seatIndex].cards[index] = card;
|
||||
},
|
||||
|
||||
// Why the sheet is closing, because one caller needs to tell the difference.
|
||||
//
|
||||
// "filled" is the sheet finishing its own job — the last empty slot it was offering
|
||||
// got a card. "dismissed" is the dealer leaving: Done, Escape, or a tap on the scrim.
|
||||
// A single seat's cards don't care which. A showdown does: it walks the players one
|
||||
// at a time, and the two answers are "next player" and "stop asking".
|
||||
//
|
||||
// The hooks are dropped before the callback runs, so a caller that opens the sheet
|
||||
// again from inside `onDone` installs its own and this close can't fire twice.
|
||||
close(reason = "dismissed") {
|
||||
document.getElementById("cardPickerOverlay").hidden = true;
|
||||
const done = this.hooks && this.hooks.onDone;
|
||||
this.hooks = null;
|
||||
if (done) done(reason);
|
||||
},
|
||||
|
||||
// Every card already dealt somewhere — in front of a player or face up in the
|
||||
// middle. The slot being edited is excluded, so re-picking the card that's already
|
||||
// there isn't treated as a duplicate.
|
||||
//
|
||||
// The board belongs in here as much as the hands do, and in both directions: the
|
||||
// seven of clubs cannot be on the flop and in somebody's hand, whichever of the two
|
||||
// the dealer is filling in at the time.
|
||||
cardsInUse() {
|
||||
const used = new Map();
|
||||
this.state.seats.forEach((player, seat) => {
|
||||
(player.cards || []).forEach((card, slot) => {
|
||||
if (!card) return;
|
||||
if (seat === this.seatIndex && slot === this.cardIndex) return;
|
||||
if (!this.onBoard() && seat === this.seatIndex && slot === this.cardIndex) return;
|
||||
used.set(card.toUpperCase(), HoldemState.displayName(player, seat));
|
||||
});
|
||||
});
|
||||
(this.state.board || []).forEach((card, slot) => {
|
||||
if (!card) return;
|
||||
if (this.onBoard() && slot === this.cardIndex) return;
|
||||
used.set(card.toUpperCase(), "the board");
|
||||
});
|
||||
return used;
|
||||
},
|
||||
|
||||
assign(card) {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
player.cards[this.cardIndex] = card;
|
||||
this.write(this.cardIndex, card);
|
||||
this.hooks.onPick(this.seatIndex, this.cardIndex, card);
|
||||
|
||||
// Straight on to the second card — a dealer picking hole cards is always
|
||||
// picking two, and making them re-open the picker doubles the taps.
|
||||
if (this.cardIndex === 0 && !player.cards[1]) {
|
||||
this.cardIndex = 1;
|
||||
// Straight on to the next empty slot — a dealer picking hole cards is always
|
||||
// picking two and a flop is always three, and making them re-open the picker
|
||||
// between each one multiplies the taps by the only thing the sheet is for.
|
||||
const values = this.slotValues();
|
||||
const next = values.findIndex((value, i) => i > this.cardIndex && !value);
|
||||
if (next !== -1) {
|
||||
this.cardIndex = next;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
this.close("filled");
|
||||
},
|
||||
|
||||
clearSlot() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
player.cards[this.cardIndex] = "";
|
||||
this.write(this.cardIndex, "");
|
||||
this.hooks.onPick(this.seatIndex, this.cardIndex, "");
|
||||
this.render();
|
||||
},
|
||||
|
||||
render() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
const used = this.cardsInUse();
|
||||
// What this slot is called out loud: the flop's three cards, then the turn, then
|
||||
// the river — which is what a dealer is holding when they open this, rather than
|
||||
// "card 4".
|
||||
boardSlotLabel(slot) {
|
||||
return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`;
|
||||
},
|
||||
|
||||
document.getElementById("cardPickerTitle").textContent =
|
||||
`${HoldemState.displayName(player, this.seatIndex)} — card ${this.cardIndex + 1}`;
|
||||
// Every card in the deck, ace down to two, each rank's four suits together. The
|
||||
// grid shows one suit's worth of this at a time — see gridCards — and the duplicate
|
||||
// check reads the whole of it, so the order is written once here rather than
|
||||
// rebuilt by two nested loops that have to agree.
|
||||
deck() {
|
||||
const cards = [];
|
||||
this.RANKS.forEach(rank => {
|
||||
this.SUITS.forEach(suit => cards.push({ rank, suit, card: `${rank}${suit.code}` }));
|
||||
});
|
||||
return cards;
|
||||
},
|
||||
|
||||
suitOf(code) {
|
||||
return this.SUITS.find(suit => suit.code === code) || this.SUITS[0];
|
||||
},
|
||||
|
||||
// The thirteen on the grid: one suit's worth of deck(), in deck order, so the ranks
|
||||
// read A down to 2 exactly as they always have.
|
||||
gridCards() {
|
||||
return this.deck().filter(entry => entry.suit.code === this.suit);
|
||||
},
|
||||
|
||||
// Open on the card that is already there. A slot holding the nine of diamonds
|
||||
// should show the diamonds with the nine under the cursor — the dealer came here to
|
||||
// change that card, and hunting for where it lives is work the sheet can do. An
|
||||
// empty slot changes nothing, which is what keeps the suit across a flush board.
|
||||
aimSuit() {
|
||||
const current = (this.slotValues()[this.cardIndex] || "").toUpperCase();
|
||||
if (!/^[AKQJT2-9][SHDC]$/.test(current)) return;
|
||||
this.suit = current.slice(1);
|
||||
const rank = this.RANKS.indexOf(current.slice(0, 1));
|
||||
if (rank >= 0) this.cursor = rank;
|
||||
},
|
||||
|
||||
// Changing suit is not moving: the cursor is a rank, and the same rank in another
|
||||
// suit is the card directly beside the one you were on. Coming back with the cursor
|
||||
// reset to the ace would undo the only thing the dealer had told the sheet.
|
||||
setSuit(code, { focus = false } = {}) {
|
||||
if (this.suit === code) return;
|
||||
this.suit = code;
|
||||
this.render();
|
||||
if (focus) {
|
||||
const chosen = document.querySelector(`#cardPickerSuits [data-suit="${code}"]`);
|
||||
if (chosen) chosen.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
|
||||
// Arrow keys along the suits, the way the APG's radio group asks for: one Tab stop
|
||||
// for the set, arrows to move within it, and moving is choosing.
|
||||
onSuitKey(event) {
|
||||
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
|
||||
if (step === undefined) return;
|
||||
event.preventDefault();
|
||||
const at = this.SUITS.findIndex(suit => suit.code === this.suit);
|
||||
const to = (at + step + this.SUITS.length) % this.SUITS.length;
|
||||
this.setSuit(this.SUITS[to].code, { focus: true });
|
||||
},
|
||||
|
||||
// How far Down and Up move, asked of the grid rather than assumed. The row is as
|
||||
// many ranks as the width holds — five on a 360px phone, nine on a phone held
|
||||
// sideways — so a constant here would be a lie on most screens, and the last time
|
||||
// this number disagreed with the layout the cursor slid into a column nobody asked
|
||||
// for. Outside a browser there is nothing to measure and nothing to move.
|
||||
gridStride() {
|
||||
const grid = typeof document !== "undefined" && document.getElementById("cardPickerGrid");
|
||||
if (!grid || typeof getComputedStyle !== "function") return this.RANKS.length;
|
||||
const columns = getComputedStyle(grid).gridTemplateColumns.split(/\s+/).filter(Boolean).length;
|
||||
return columns > 0 ? columns : this.RANKS.length;
|
||||
},
|
||||
|
||||
// Where the arrows can go: one card, in the direction asked for, and nowhere if
|
||||
// that runs off the end of the grid. Wrapping would put the dealer on a different
|
||||
// rank without them asking to move rank.
|
||||
//
|
||||
// This used to hunt past dealt cards, because a `disabled` button cannot take focus
|
||||
// — and hunting did the very thing the no-wrap rule was written to prevent. Right
|
||||
// from A♠ with the ace of hearts dealt landed on A♦, so the column silently became
|
||||
// the one to its right, and the next press of Down walked K♦ instead of K♥. A dealt
|
||||
// card is `aria-disabled` now, so it holds focus like every other cell and the grid
|
||||
// tells the truth about its own shape; the APG asks for exactly that inside a
|
||||
// composite widget, and pressing one is answered rather than ignored.
|
||||
nextCursor(from, step) {
|
||||
const count = document.querySelectorAll("#cardPickerGrid .pickerCard").length;
|
||||
const to = from + step;
|
||||
return to >= 0 && to < count ? to : from;
|
||||
},
|
||||
|
||||
// One Tab stop for fifty-two cards.
|
||||
//
|
||||
// Every card used to be its own stop, which made this sheet fifty-six of them:
|
||||
// reaching the seven of clubs meant something like forty presses of the only key
|
||||
// that moves at all without a mouse. The APG's answer for a set of controls like
|
||||
// this is the roving tabindex — one of them is in the Tab order at a time, and the
|
||||
// arrows move between them — so the grid now costs one press to enter and one to
|
||||
// leave, and the arrows bring the row into view on the way, which forty Tab presses
|
||||
// through a scrolling sheet never reliably did.
|
||||
//
|
||||
// No new role is claimed for the container. They are buttons, they announce as
|
||||
// buttons, and the only thing that changed is how far away they are.
|
||||
aimCursor(index, { focus = false } = {}) {
|
||||
const buttons = [...document.querySelectorAll("#cardPickerGrid .pickerCard")];
|
||||
if (!buttons.length) return;
|
||||
this.cursor = Math.max(0, Math.min(buttons.length - 1, index));
|
||||
buttons.forEach((btn, i) => {
|
||||
btn.tabIndex = i === this.cursor ? 0 : -1;
|
||||
});
|
||||
if (focus) buttons[this.cursor].focus({ preventScroll: false });
|
||||
},
|
||||
|
||||
onGridKey(event) {
|
||||
const stride = this.gridStride();
|
||||
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: stride, ArrowUp: -stride }[event.key];
|
||||
if (step !== undefined) {
|
||||
event.preventDefault();
|
||||
this.aimCursor(this.nextCursor(this.cursor, step), { focus: true });
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Home" && event.key !== "End") return;
|
||||
event.preventDefault();
|
||||
const count = document.querySelectorAll("#cardPickerGrid .pickerCard").length;
|
||||
this.aimCursor(event.key === "Home" ? this.nextCursor(-1, 1) : this.nextCursor(count, -1), { focus: true });
|
||||
},
|
||||
|
||||
// A redraw is not a reason to lose your place. Every button in here is thrown away
|
||||
// and rebuilt on each one, so whatever held focus was being deleted out from under
|
||||
// the dealer — see Modal.preservingFocus. The card just picked is the one key that
|
||||
// can't come back, because it is a dealt card now: the slot it went into is the
|
||||
// useful place to be instead.
|
||||
render() {
|
||||
const sheet = document.getElementById("cardPickerSheet");
|
||||
Modal.preservingFocus(sheet, () => this.paint(), () => {
|
||||
const slot = sheet.querySelector("#cardPickerSlots .pickerSlot.active");
|
||||
if (slot) slot.focus({ preventScroll: true });
|
||||
});
|
||||
},
|
||||
|
||||
paint() {
|
||||
const used = this.cardsInUse();
|
||||
const values = this.slotValues();
|
||||
|
||||
// Whatever the line was answering has been answered. Cleared here rather than
|
||||
// on a timer, because a paint is exactly the set of things that make the last
|
||||
// answer stale — a card went in, or the sheet moved to a different slot — and
|
||||
// nothing else in this sheet redraws.
|
||||
const note = document.getElementById("cardPickerNote");
|
||||
if (note) note.textContent = "";
|
||||
|
||||
document.getElementById("cardPickerTitle").textContent = this.onBoard()
|
||||
? `Board — ${this.boardSlotLabel(this.cardIndex).toLowerCase()}`
|
||||
: `${HoldemState.displayName(this.state.seats[this.seatIndex], this.seatIndex)} — card ${this.cardIndex + 1}`;
|
||||
|
||||
// Which slot the next tap fills, switchable without closing the sheet.
|
||||
const slots = document.getElementById("cardPickerSlots");
|
||||
slots.innerHTML = "";
|
||||
[0, 1].forEach(slot => {
|
||||
values.forEach((_, slot) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "pickerSlot";
|
||||
btn.classList.toggle("active", slot === this.cardIndex);
|
||||
const value = player.cards[slot] || "";
|
||||
const value = values[slot] || "";
|
||||
btn.textContent = value || "—";
|
||||
// What it is called out loud. On the eye it is a card or an em dash, and an
|
||||
// em dash is what a screen reader was being given as the whole name of the
|
||||
// control: "dash, button", twice, above fifty-two more. The slot knows which
|
||||
// slot it is and what is in it, so it says both.
|
||||
const which = this.onBoard() ? this.boardSlotLabel(slot) : `Card ${slot + 1}`;
|
||||
btn.setAttribute("aria-label", value ? `${which}: ${value}` : `${which}: empty`);
|
||||
if (this.onBoard()) btn.title = which;
|
||||
btn.classList.toggle("cardRed", /[HD]$/i.test(value));
|
||||
// The name this control answers to after a redraw. A slot is always the
|
||||
// same slot — flop 2 stays flop 2 — so its number is the whole of it.
|
||||
btn.dataset.focusKey = `slot-${slot}`;
|
||||
btn.addEventListener("click", () => {
|
||||
this.cardIndex = slot;
|
||||
this.render();
|
||||
@@ -93,36 +354,78 @@ const CardPicker = {
|
||||
slots.appendChild(btn);
|
||||
});
|
||||
|
||||
// The first question. Four of them, always in the same four places, so the one
|
||||
// the dealer wants is somewhere their thumb already knows.
|
||||
const suits = document.getElementById("cardPickerSuits");
|
||||
suits.innerHTML = "";
|
||||
this.SUITS.forEach(suit => {
|
||||
const chosen = suit.code === this.suit;
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "pickerSuitTab";
|
||||
btn.classList.toggle("cardRed", suit.red);
|
||||
btn.classList.toggle("chosen", chosen);
|
||||
btn.textContent = suit.symbol;
|
||||
btn.setAttribute("role", "radio");
|
||||
btn.setAttribute("aria-checked", chosen ? "true" : "false");
|
||||
// The glyph is the whole of the control on the eye, and "♦, radio button"
|
||||
// is not a suit out loud. The word is.
|
||||
btn.setAttribute("aria-label", suit.name);
|
||||
btn.dataset.suit = suit.code;
|
||||
// One Tab stop for the four, like the grid below it has one for thirteen.
|
||||
btn.tabIndex = chosen ? 0 : -1;
|
||||
btn.dataset.focusKey = `suit-${suit.code}`;
|
||||
btn.addEventListener("click", () => this.setSuit(suit.code, { focus: true }));
|
||||
btn.addEventListener("keydown", event => this.onSuitKey(event));
|
||||
suits.appendChild(btn);
|
||||
});
|
||||
|
||||
const gridLabel = document.getElementById("cardPickerGridLabel");
|
||||
if (gridLabel) gridLabel.textContent = `${this.suitOf(this.suit).name} — pick a rank`;
|
||||
|
||||
const grid = document.getElementById("cardPickerGrid");
|
||||
grid.innerHTML = "";
|
||||
this.RANKS.forEach(rank => {
|
||||
this.SUITS.forEach(suit => {
|
||||
const card = `${rank}${suit.code}`;
|
||||
const takenBy = used.get(card);
|
||||
this.gridCards().forEach(({ rank, suit, card }) => {
|
||||
const takenBy = used.get(card);
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "pickerCard";
|
||||
btn.classList.toggle("cardRed", suit.red);
|
||||
btn.disabled = !!takenBy;
|
||||
if (takenBy) btn.title = `Already dealt to ${takenBy}`;
|
||||
if ((player.cards[this.cardIndex] || "").toUpperCase() === card) {
|
||||
btn.classList.add("selected");
|
||||
}
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "pickerCard";
|
||||
btn.classList.toggle("cardRed", suit.red);
|
||||
btn.dataset.focusKey = card;
|
||||
// Named in the sentence rather than left as "this one", because the sentence
|
||||
// is read above the grid and the card that was pressed can be eight rows
|
||||
// down it. Short by measurement, not by taste — see .pickerNote.
|
||||
const label = `${rank}${suit.symbol}`;
|
||||
Unavailable.set(btn, !!takenBy, takenBy === "the board"
|
||||
? `${label} — face up on the board`
|
||||
: `${label} — dealt to ${takenBy}`);
|
||||
// Still the tooltip, and now also the accessible description — `title` only
|
||||
// reaches a screen reader on an element it is allowed to land on, which is
|
||||
// the other half of what `disabled` was costing here.
|
||||
if (takenBy) btn.title = Unavailable.reason(btn);
|
||||
else btn.removeAttribute("title");
|
||||
if ((values[this.cardIndex] || "").toUpperCase() === card) {
|
||||
btn.classList.add("selected");
|
||||
}
|
||||
|
||||
const rankEl = document.createElement("span");
|
||||
rankEl.className = "pickerRank";
|
||||
rankEl.textContent = rank;
|
||||
const suitEl = document.createElement("span");
|
||||
suitEl.className = "pickerSuit";
|
||||
suitEl.textContent = suit.symbol;
|
||||
btn.appendChild(rankEl);
|
||||
btn.appendChild(suitEl);
|
||||
const rankEl = document.createElement("span");
|
||||
rankEl.className = "pickerRank";
|
||||
rankEl.textContent = rank;
|
||||
const suitEl = document.createElement("span");
|
||||
suitEl.className = "pickerSuit";
|
||||
suitEl.textContent = suit.symbol;
|
||||
btn.appendChild(rankEl);
|
||||
btn.appendChild(suitEl);
|
||||
|
||||
btn.addEventListener("click", () => this.assign(card));
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
btn.addEventListener("click", () => this.assign(card));
|
||||
btn.addEventListener("keydown", event => this.onGridKey(event));
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
|
||||
// Hand the Tab order back exactly one of them. Done after the grid is built,
|
||||
// because which card can hold the cursor depends on which are dealt already.
|
||||
this.aimCursor(this.cursor);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,20 @@
|
||||
// (in practice: Undo). Only one is ever on screen; a second replaces the first.
|
||||
const Toast = {
|
||||
DEFAULT_DURATION_MS: 8000,
|
||||
|
||||
// What the clock is set back to when the dealer lets go of a toast.
|
||||
//
|
||||
// Pausing while it is touched or focused is only half the job. Let go at 7.9
|
||||
// seconds of 8 and the message goes while the thumb is still travelling: the
|
||||
// timer that matters once somebody has reached for a toast is the time to act
|
||||
// on it, not what happened to be left of the time to notice it.
|
||||
RESUME_MS: 2500,
|
||||
|
||||
timer: null,
|
||||
endsAt: 0,
|
||||
remaining: 0,
|
||||
// Where focus was standing when this arrived.
|
||||
returnFocusTo: null,
|
||||
|
||||
show(message, options = {}) {
|
||||
const host = document.getElementById("toastHost");
|
||||
@@ -17,7 +30,11 @@ const Toast = {
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "toast";
|
||||
toast.setAttribute("role", "status");
|
||||
// No role on the toast itself. #toastHost is the live region, and it is in the
|
||||
// markup — which is what makes it observed. A region that arrives already
|
||||
// carrying its text has not been watched by anything yet, and is routinely
|
||||
// never spoken at all; one nested inside a region that is watched only buys a
|
||||
// second reading of the same sentence.
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "toastText";
|
||||
@@ -29,6 +46,12 @@ const Toast = {
|
||||
action.type = "button";
|
||||
action.className = "toastAction";
|
||||
action.textContent = options.actionLabel;
|
||||
// Which keyboard shortcut, if any, presses this. Measured on a desktop,
|
||||
// this button is the 67th of the page's 68 tab stops, and it is gone in
|
||||
// eight seconds: reaching it by Tab is not a thing anybody can do. Only
|
||||
// the toasts that say so opt in — "Turn off" and "Reload" are not things
|
||||
// a stray keypress should be able to reach.
|
||||
if (options.shortcut) action.dataset.shortcut = options.shortcut;
|
||||
action.addEventListener("click", () => {
|
||||
this.dismiss();
|
||||
options.onAction();
|
||||
@@ -44,14 +67,79 @@ const Toast = {
|
||||
close.addEventListener("click", () => this.dismiss());
|
||||
toast.appendChild(close);
|
||||
|
||||
// Read after dismiss() above has had its chance to put focus back, so that a
|
||||
// toast replacing another remembers where the dealer actually is.
|
||||
const active = document.activeElement;
|
||||
this.returnFocusTo = active && active !== document.body ? active : null;
|
||||
|
||||
// A message with a button in it is on a timer the reader has to beat, which is
|
||||
// what WCAG's Timing Adjustable is about. Touching it or tabbing into it stops
|
||||
// the clock; letting go starts it again.
|
||||
toast.addEventListener("pointerenter", () => this.hold());
|
||||
toast.addEventListener("focusin", event => {
|
||||
// Where focus came *from* beats where it was when this appeared: a dealer
|
||||
// who moves on and then tabs back into the toast should be put back where
|
||||
// they had got to, not where they were eight seconds ago.
|
||||
if (event.relatedTarget && !toast.contains(event.relatedTarget)) {
|
||||
this.returnFocusTo = event.relatedTarget;
|
||||
}
|
||||
this.hold();
|
||||
});
|
||||
["pointerleave", "focusout"].forEach(type =>
|
||||
toast.addEventListener(type, () => this.resume()));
|
||||
|
||||
host.appendChild(toast);
|
||||
// Next frame, so the entrance transition actually runs.
|
||||
requestAnimationFrame(() => toast.classList.add("toastVisible"));
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add("toastVisible");
|
||||
this.publishHeight();
|
||||
});
|
||||
|
||||
this.timer = setTimeout(
|
||||
() => this.dismiss(),
|
||||
options.duration || this.DEFAULT_DURATION_MS
|
||||
);
|
||||
this.arm(options.duration || this.DEFAULT_DURATION_MS);
|
||||
},
|
||||
|
||||
arm(ms) {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.endsAt = Date.now() + ms;
|
||||
this.timer = setTimeout(() => this.dismiss(), ms);
|
||||
},
|
||||
|
||||
// Is the dealer holding this open — a finger on it, or the keyboard inside it?
|
||||
heldOpen() {
|
||||
if (typeof document === "undefined") return false;
|
||||
const host = document.getElementById("toastHost");
|
||||
const toast = host && host.firstElementChild;
|
||||
if (!toast) return false;
|
||||
if (document.activeElement && toast.contains(document.activeElement)) return true;
|
||||
// :hover rather than a flag of our own, so a pointer that left the window
|
||||
// without a pointerleave — or one that never sent pointerenter because the
|
||||
// toast appeared underneath it — is answered by the same question either way.
|
||||
try {
|
||||
return typeof toast.matches === "function" && toast.matches(":hover");
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
hold() {
|
||||
if (!this.timer) return;
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.remaining = Math.max(0, this.endsAt - Date.now());
|
||||
},
|
||||
|
||||
resume() {
|
||||
// `focusout` arrives before the new focus lands, so "is anything in here still
|
||||
// focused?" has no true answer until the next tick. pointerleave is deferred
|
||||
// with it rather than against it: leaving with the keyboard still inside must
|
||||
// not restart the clock.
|
||||
setTimeout(() => {
|
||||
if (this.timer || this.heldOpen()) return;
|
||||
if (typeof document === "undefined") return;
|
||||
const host = document.getElementById("toastHost");
|
||||
if (!host || !host.firstElementChild) return;
|
||||
this.arm(Math.max(this.remaining, this.RESUME_MS));
|
||||
}, 0);
|
||||
},
|
||||
|
||||
dismiss() {
|
||||
@@ -59,8 +147,43 @@ const Toast = {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.remaining = 0;
|
||||
// The same guard publishHeight below already carries, for the same reason: the
|
||||
// bar dismisses a toast on the way into every action it commits, and that path
|
||||
// is otherwise plain bookkeeping that answers without a document.
|
||||
if (typeof document === "undefined") return;
|
||||
const host = document.getElementById("toastHost");
|
||||
if (host) host.innerHTML = "";
|
||||
if (host) {
|
||||
// Never leave the keyboard standing on nothing. A control removed while it
|
||||
// has focus hands focus to <body>, and on this page that is 66 tab stops
|
||||
// back from where the dealer was — so whoever pressed Undo, or ✕, or was
|
||||
// simply reading when a second toast arrived, goes back where they were.
|
||||
const active = document.activeElement;
|
||||
const goBack = host.contains(active) ? this.returnFocusTo : null;
|
||||
host.innerHTML = "";
|
||||
if (goBack && goBack.isConnected && typeof goBack.focus === "function") {
|
||||
goBack.focus();
|
||||
}
|
||||
}
|
||||
this.returnFocusTo = null;
|
||||
this.publishHeight();
|
||||
},
|
||||
|
||||
// How much of the bottom of the window this is standing on.
|
||||
//
|
||||
// The toast is fixed, so it is not in the flow and nothing scrolling a control into
|
||||
// view knows it is there — measured on the desktop sweep, tabbing to Common rulings
|
||||
// while a toast was up left the focus ring entirely behind it. The stylesheet adds
|
||||
// this to the bar's own height to work out where a focused control has to stop, so
|
||||
// it is published rather than guessed: the toast is one line or two depending on the
|
||||
// message, and on a narrow screen the same message is taller again.
|
||||
publishHeight() {
|
||||
if (typeof document === "undefined" || !document.documentElement) return;
|
||||
const host = document.getElementById("toastHost");
|
||||
const height = host && host.firstElementChild
|
||||
? Math.round(host.getBoundingClientRect().height)
|
||||
: 0;
|
||||
document.documentElement.style.setProperty("--toastHeight", `${height}px`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -110,6 +233,80 @@ const Dialog = {
|
||||
},
|
||||
};
|
||||
|
||||
// A control that is on the screen and cannot act, and the reason it can't.
|
||||
//
|
||||
// `disabled` is the wrong attribute the moment there is something to say. It takes the
|
||||
// control out of the tab order, so a screen reader never lands on it and never hears
|
||||
// the `title` hung off it; and it eats the tap on a touch screen, where there is no
|
||||
// hover to show a `title` to begin with. Measured on an iPhone with three hands dealt:
|
||||
// seven of the card picker's fifty-two cards were dead, none of them could hold focus,
|
||||
// and tapping one changed nothing whatsoever — no toast, no text, not a byte of DOM.
|
||||
// The tool's answer to "who has the ace of spades?" existed, and was reachable only
|
||||
// with a mouse.
|
||||
//
|
||||
// `aria-disabled` announces the same state and leaves the control reachable, so the
|
||||
// press arrives and can be answered instead. Nothing enforces it for you — that is the
|
||||
// whole point — so a press is intercepted once, centrally, in `answerPress` below.
|
||||
const Unavailable = {
|
||||
// Which control is being told off, if any. A press can land on the text inside a
|
||||
// button rather than the button, so the answer is looked for upwards.
|
||||
from(target) {
|
||||
if (!target || typeof target.closest !== "function") return null;
|
||||
return target.closest('[aria-disabled="true"]');
|
||||
},
|
||||
|
||||
reason(el) {
|
||||
return (el && el.dataset && el.dataset.whyNot) || "";
|
||||
},
|
||||
|
||||
blocked(el) {
|
||||
return !!el && el.getAttribute("aria-disabled") === "true";
|
||||
},
|
||||
|
||||
// Both directions in one call, because every caller works out the condition anyway
|
||||
// and the two halves have to stay in step — a control left marked after the reason
|
||||
// stopped being true is worse than one that was never marked.
|
||||
set(el, blocked, reason) {
|
||||
if (!el) return;
|
||||
if (!blocked) {
|
||||
el.removeAttribute("aria-disabled");
|
||||
delete el.dataset.whyNot;
|
||||
return;
|
||||
}
|
||||
el.setAttribute("aria-disabled", "true");
|
||||
el.dataset.whyNot = reason || "";
|
||||
},
|
||||
|
||||
// Where the answer is written.
|
||||
//
|
||||
// The toast is fixed to the bottom of the window, and a sheet that stands 88% of
|
||||
// the screen tall is sitting on top of it — so a control inside one answers inside
|
||||
// it instead. A sheet opts in by naming its own line: `data-why-note="someId"`.
|
||||
// Anything else falls through to the toast, which is where the table says things.
|
||||
say(el, why) {
|
||||
const host = el && typeof el.closest === "function" ? el.closest("[data-why-note]") : null;
|
||||
const note = host ? document.getElementById(host.dataset.whyNote) : null;
|
||||
if (note) {
|
||||
note.textContent = why;
|
||||
return;
|
||||
}
|
||||
Toast.show(why);
|
||||
},
|
||||
|
||||
// One listener for the whole table, in the capture phase so it arrives before the
|
||||
// control's own handler and can stop it. Registered once, by main.js.
|
||||
listen(root) {
|
||||
root.addEventListener("click", event => {
|
||||
const el = this.from(event.target);
|
||||
const why = this.reason(el);
|
||||
if (!why) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.say(el, why);
|
||||
}, true);
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { Toast, Dialog };
|
||||
module.exports = { Toast, Dialog, Unavailable };
|
||||
}
|
||||
|
||||
@@ -20,7 +20,11 @@ const HandHistoryExport = {
|
||||
// who owes what — doesn't work without cleaning every cell first. The commas were
|
||||
// quoted correctly, so the file parsed; it just wouldn't add up.
|
||||
toCsv(handHistory, standings = [], { notes = "" } = {}) {
|
||||
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
|
||||
// The rake gets its own column rather than being folded into the pot: the two
|
||||
// numbers answer different questions — what was in the middle, and what the
|
||||
// house took out of it — and a night's worth of the second one is what the box
|
||||
// is counted against.
|
||||
const rows = [["Hand #", "Time", "Pot", "House take", "Payouts"]];
|
||||
// handHistory is stored newest-first; the export reads better oldest-first.
|
||||
[...handHistory].reverse().forEach(entry => {
|
||||
const payouts = (entry.payouts || [])
|
||||
@@ -30,6 +34,7 @@ const HandHistoryExport = {
|
||||
entry.handNumber,
|
||||
this.formatTimestamp(entry.timestamp),
|
||||
Money.forSheet(entry.pot),
|
||||
Money.forSheet(entry.rake || 0),
|
||||
payouts,
|
||||
]);
|
||||
});
|
||||
@@ -49,7 +54,10 @@ const HandHistoryExport = {
|
||||
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down", "Notes"]);
|
||||
standings.forEach(row => {
|
||||
rows.push([
|
||||
row.name,
|
||||
// Somebody who has gone home is marked in the file the same way the
|
||||
// panel marks them, because this is the copy that gets read at the
|
||||
// end of the night when nobody remembers who has already been paid.
|
||||
row.cashedOut ? `${row.name} (paid out)` : row.name,
|
||||
Money.forSheet(row.in),
|
||||
Money.forSheet(row.won),
|
||||
Money.forSheet(row.net),
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// What beats what.
|
||||
//
|
||||
// The dealer has always been the one who decides who won: the pot wizard asks "who won
|
||||
// the hand?" and pays whoever gets ticked. That is the right default — most hands are
|
||||
// won without a showdown, and at a home game the table reads the cards out loud between
|
||||
// them. But when the board is on the felt and the hands are on their backs, the tool
|
||||
// knows the answer, and reading it off a screen beats three people counting kickers.
|
||||
//
|
||||
// Cards are the same two-character strings the rest of the tool has always stored:
|
||||
// a rank from `RANKS` and a suit from S, H, D, C. Case is not significant.
|
||||
//
|
||||
// Nothing in here touches the DOM, the table, or money. It compares cards, and the pot
|
||||
// wizard decides what to do about it.
|
||||
const HandRank = {
|
||||
RANKS: "23456789TJQKA",
|
||||
|
||||
// High to low. The number is the hand's category and nothing else — kickers and
|
||||
// the rest of the tie-breaking live in the `tiebreak` array beside it.
|
||||
STRAIGHT_FLUSH: 8,
|
||||
FOUR_OF_A_KIND: 7,
|
||||
FULL_HOUSE: 6,
|
||||
FLUSH: 5,
|
||||
STRAIGHT: 4,
|
||||
THREE_OF_A_KIND: 3,
|
||||
TWO_PAIR: 2,
|
||||
ONE_PAIR: 1,
|
||||
HIGH_CARD: 0,
|
||||
|
||||
// 2 through 14. Null for anything that isn't a card, which is how a half-filled
|
||||
// slot gets told apart from a bad one.
|
||||
rankValue(card) {
|
||||
if (typeof card !== "string" || card.length < 2) return null;
|
||||
const index = this.RANKS.indexOf(card[0].toUpperCase());
|
||||
return index < 0 ? null : index + 2;
|
||||
},
|
||||
|
||||
suitOf(card) {
|
||||
if (typeof card !== "string" || card.length < 2) return null;
|
||||
const suit = card[card.length - 1].toUpperCase();
|
||||
return "SHDC".includes(suit) ? suit : null;
|
||||
},
|
||||
|
||||
isCard(card) {
|
||||
return this.rankValue(card) !== null && this.suitOf(card) !== null;
|
||||
},
|
||||
|
||||
// Singular and plural, for reading a hand out loud.
|
||||
NAMES: {
|
||||
14: ["ace", "aces"], 13: ["king", "kings"], 12: ["queen", "queens"],
|
||||
11: ["jack", "jacks"], 10: ["ten", "tens"], 9: ["nine", "nines"],
|
||||
8: ["eight", "eights"], 7: ["seven", "sevens"], 6: ["six", "sixes"],
|
||||
5: ["five", "fives"], 4: ["four", "fours"], 3: ["three", "threes"],
|
||||
2: ["two", "twos"],
|
||||
},
|
||||
|
||||
name(rank, plural = false) {
|
||||
const pair = this.NAMES[rank];
|
||||
return pair ? pair[plural ? 1 : 0] : "?";
|
||||
},
|
||||
|
||||
// ---- The five-card hand -------------------------------------------------------
|
||||
|
||||
// Exactly five cards, ranked. Everything else is built on this.
|
||||
//
|
||||
// Five rather than seven because five is the hand a player actually holds, and it
|
||||
// is small enough to check by hand — which is the whole argument for doing it this
|
||||
// way. The seven-card version below tries all twenty-one five-card hands and keeps
|
||||
// the best, which is a hand of poker's own definition of "your best five" written
|
||||
// out literally. A dealer resolves one pot a hand with at most a dozen players in
|
||||
// it; two hundred and fifty comparisons of five cards is not a cost worth trading
|
||||
// a subtler algorithm's correctness for.
|
||||
evaluate5(cards) {
|
||||
if (!Array.isArray(cards) || cards.length !== 5) return null;
|
||||
if (!cards.every(card => this.isCard(card))) return null;
|
||||
|
||||
const ranks = cards.map(card => this.rankValue(card)).sort((a, b) => b - a);
|
||||
const suits = cards.map(card => this.suitOf(card));
|
||||
const flush = suits.every(suit => suit === suits[0]);
|
||||
|
||||
// How many of each rank, ordered by how many first and by rank second — so
|
||||
// the front of this list is always the rank that decides the hand.
|
||||
const counts = new Map();
|
||||
ranks.forEach(rank => counts.set(rank, (counts.get(rank) || 0) + 1));
|
||||
const groups = [...counts.entries()]
|
||||
.sort((a, b) => (b[1] - a[1]) || (b[0] - a[0]));
|
||||
const shape = groups.map(([, count]) => count);
|
||||
const byGroup = groups.map(([rank]) => rank);
|
||||
|
||||
const straightHigh = this.straightHigh(ranks);
|
||||
|
||||
const made = (category, tiebreak) => ({ category, tiebreak, cards: [...cards] });
|
||||
|
||||
if (flush && straightHigh) return made(this.STRAIGHT_FLUSH, [straightHigh]);
|
||||
if (shape[0] === 4) return made(this.FOUR_OF_A_KIND, byGroup);
|
||||
if (shape[0] === 3 && shape[1] === 2) return made(this.FULL_HOUSE, byGroup);
|
||||
if (flush) return made(this.FLUSH, ranks);
|
||||
if (straightHigh) return made(this.STRAIGHT, [straightHigh]);
|
||||
if (shape[0] === 3) return made(this.THREE_OF_A_KIND, byGroup);
|
||||
if (shape[0] === 2 && shape[1] === 2) return made(this.TWO_PAIR, byGroup);
|
||||
if (shape[0] === 2) return made(this.ONE_PAIR, byGroup);
|
||||
return made(this.HIGH_CARD, ranks);
|
||||
},
|
||||
|
||||
// The top card of the straight these five ranks make, or null.
|
||||
//
|
||||
// The wheel is the exception the rest of the ordering has to be told about: A-2-3-4-5
|
||||
// is a straight, and it is the *lowest* one, so its ace counts as five rather than
|
||||
// as fourteen. Getting that wrong is how A-2-3-4-5 comes to beat 10-J-Q-K-A.
|
||||
straightHigh(sortedDesc) {
|
||||
const distinct = [...new Set(sortedDesc)];
|
||||
if (distinct.length !== 5) return null;
|
||||
if (distinct[0] - distinct[4] === 4) return distinct[0];
|
||||
// Ace, five, four, three, two.
|
||||
if (distinct[0] === 14 && distinct[1] === 5 && distinct[4] === 2) return 5;
|
||||
return null;
|
||||
},
|
||||
|
||||
// ---- The best five out of however many ----------------------------------------
|
||||
|
||||
// The best five-card hand available from five, six or seven cards — hole cards and
|
||||
// board together, which is what Hold'em asks for. Returns null if there aren't five
|
||||
// real cards, which is the ordinary state of a hand still being dealt.
|
||||
best(cards) {
|
||||
const real = (cards || []).filter(card => this.isCard(card));
|
||||
if (real.length < 5) return null;
|
||||
// A card in two places at once is a mis-entry, not a hand — say so rather than
|
||||
// ranking a board that has the same king on it twice.
|
||||
if (new Set(real.map(card => card.toUpperCase())).size !== real.length) return null;
|
||||
|
||||
let best = null;
|
||||
this.eachFive(real, five => {
|
||||
const hand = this.evaluate5(five);
|
||||
if (!best || this.compare(hand, best) > 0) best = hand;
|
||||
});
|
||||
return best;
|
||||
},
|
||||
|
||||
// Every five-card subset, without building an array of them.
|
||||
eachFive(cards, visit) {
|
||||
const n = cards.length;
|
||||
for (let a = 0; a < n - 4; a++) {
|
||||
for (let b = a + 1; b < n - 3; b++) {
|
||||
for (let c = b + 1; c < n - 2; c++) {
|
||||
for (let d = c + 1; d < n - 1; d++) {
|
||||
for (let e = d + 1; e < n; e++) {
|
||||
visit([cards[a], cards[b], cards[c], cards[d], cards[e]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Positive if `a` wins, negative if `b` does, zero for a chop. A chop is a real
|
||||
// answer here rather than a rounding of one: two players playing the same board
|
||||
// split the pot, and the fifth card decides it far more often than a table expects.
|
||||
compare(a, b) {
|
||||
if (!a && !b) return 0;
|
||||
if (!a) return -1;
|
||||
if (!b) return 1;
|
||||
if (a.category !== b.category) return a.category - b.category;
|
||||
const length = Math.max(a.tiebreak.length, b.tiebreak.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const diff = (a.tiebreak[i] || 0) - (b.tiebreak[i] || 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
|
||||
// ---- Saying it out loud -------------------------------------------------------
|
||||
|
||||
// What a dealer says when they turn the hand over. Named to the level that settles
|
||||
// it and no further: "two pair, aces and eights" is the call, and the kicker only
|
||||
// gets a mention where the tool has had to use one to break a tie.
|
||||
describe(hand) {
|
||||
if (!hand) return "";
|
||||
const [first, second] = hand.tiebreak;
|
||||
switch (hand.category) {
|
||||
case this.STRAIGHT_FLUSH:
|
||||
return first === 14 ? "Royal flush" : `Straight flush, ${this.name(first)} high`;
|
||||
case this.FOUR_OF_A_KIND:
|
||||
return `Four of a kind, ${this.name(first, true)}`;
|
||||
case this.FULL_HOUSE:
|
||||
return `Full house, ${this.name(first, true)} full of ${this.name(second, true)}`;
|
||||
case this.FLUSH:
|
||||
return `Flush, ${this.name(first)} high`;
|
||||
case this.STRAIGHT:
|
||||
return `Straight, ${this.name(first)} high`;
|
||||
case this.THREE_OF_A_KIND:
|
||||
return `Three of a kind, ${this.name(first, true)}`;
|
||||
case this.TWO_PAIR:
|
||||
return `Two pair, ${this.name(first, true)} and ${this.name(second, true)}`;
|
||||
case this.ONE_PAIR:
|
||||
return `Pair of ${this.name(first, true)}`;
|
||||
default:
|
||||
return `${this.name(first).charAt(0).toUpperCase()}${this.name(first).slice(1)} high`;
|
||||
}
|
||||
},
|
||||
|
||||
// The same call with the article a table would put in front of it. Not derivable
|
||||
// from the first letter: it is "a full house" and "a flush", but "four of a kind"
|
||||
// and "two pair" take none at all, and "an ace high" is not something anybody says.
|
||||
// So it belongs to the category, like the name does.
|
||||
ARTICLED: {
|
||||
8: hand => (hand.tiebreak[0] === 14 ? "a royal flush" : "a"),
|
||||
7: () => null,
|
||||
6: () => "a",
|
||||
5: () => "a",
|
||||
4: () => "a",
|
||||
3: () => null,
|
||||
2: () => null,
|
||||
1: () => "a",
|
||||
0: () => null,
|
||||
},
|
||||
|
||||
// "wins with X" — the phrase, ready to drop into a sentence.
|
||||
phrase(hand) {
|
||||
if (!hand) return "the best hand";
|
||||
const described = this.describe(hand);
|
||||
const article = (this.ARTICLED[hand.category] || (() => null))(hand);
|
||||
if (article === null) return described.toLowerCase();
|
||||
if (article !== "a") return article;
|
||||
return `a ${described.toLowerCase()}`;
|
||||
},
|
||||
|
||||
// ---- The showdown -------------------------------------------------------------
|
||||
|
||||
// Rank a set of players against a board.
|
||||
//
|
||||
// `players` is [{ id, cards }, ...] — the two in front of each of them. Every player
|
||||
// is returned whether or not they could be ranked, in the order they were given, so
|
||||
// the caller can show a row for each and say which ones it could not read. `place`
|
||||
// is 1 for the winner and shared by a chop; a player whose cards aren't known has a
|
||||
// null hand and no place.
|
||||
//
|
||||
// `complete` is the question the pot wizard actually asks: can this be trusted to
|
||||
// pay the table. It is only true when the board is all five cards and every single
|
||||
// player handed in has two of their own — one unknown hand and the tool cannot say
|
||||
// who won, only who is winning so far, which is not the same thing and must not be
|
||||
// dressed up as it.
|
||||
showdown(board, players) {
|
||||
const boardCards = (board || []).filter(card => this.isCard(card));
|
||||
const boardComplete = boardCards.length === 5;
|
||||
|
||||
const ranked = (players || []).map(player => {
|
||||
const hole = (player.cards || []).filter(card => this.isCard(card));
|
||||
const hand = hole.length === 2 && boardComplete
|
||||
? this.best([...hole, ...boardCards])
|
||||
: null;
|
||||
return { id: player.id, cards: hole, hand, place: null };
|
||||
});
|
||||
|
||||
const readable = ranked.filter(row => row.hand);
|
||||
// A duplicated card anywhere means somebody has been mis-entered, and the
|
||||
// ranking would be of a deck that doesn't exist.
|
||||
const all = [...boardCards, ...ranked.flatMap(row => row.cards)].map(c => c.toUpperCase());
|
||||
const duplicated = new Set(all).size !== all.length;
|
||||
|
||||
const sorted = [...readable].sort((a, b) => this.compare(b.hand, a.hand));
|
||||
let place = 0;
|
||||
sorted.forEach((row, i) => {
|
||||
if (i === 0 || this.compare(row.hand, sorted[i - 1].hand) !== 0) place = i + 1;
|
||||
row.place = place;
|
||||
});
|
||||
|
||||
const winners = sorted.filter(row => row.place === 1);
|
||||
const complete = boardComplete && !duplicated &&
|
||||
ranked.length > 0 && readable.length === ranked.length;
|
||||
|
||||
return {
|
||||
players: ranked,
|
||||
winners: complete ? winners.map(row => row.id) : [],
|
||||
boardComplete,
|
||||
duplicated,
|
||||
complete,
|
||||
// Who the tool could not read, so the dialog can name them rather than
|
||||
// just declining to help.
|
||||
unknown: ranked.filter(row => !row.hand).map(row => row.id),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = HandRank;
|
||||
}
|
||||
@@ -5,6 +5,39 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// `hidden` attribute they all already toggle.
|
||||
Modal.mount();
|
||||
|
||||
// A press on a control that can't act is answered rather than swallowed. One
|
||||
// listener for the whole table, because the alternative is remembering to guard
|
||||
// every click handler that might one day be attached to a dimmed control — and the
|
||||
// one that gets forgotten is a dead tap with no explanation, which is the bug this
|
||||
// is here to end. See Unavailable in feedback.js.
|
||||
Unavailable.listen(document);
|
||||
|
||||
// ---- A scroll is not an edit ----
|
||||
//
|
||||
// A focused `<input type="number">` is a spinbutton, and a browser lets the mouse
|
||||
// wheel drive it. Every other way of driving one is deliberate: the arrow keys, the
|
||||
// little stepper arrows, typing. Scrolling is the one actuation nobody means — the
|
||||
// dealer is reaching for another panel and the cursor happens to be over a field
|
||||
// they touched a moment ago.
|
||||
//
|
||||
// Measured on the running page, at 1280x900 with the sidebar open. Focus the small
|
||||
// blind, scroll: $5 becomes $5.50. Focus a seat's stack, scroll: $500 becomes $501.
|
||||
// Both are written straight through to the saved table, because these fields save on
|
||||
// `input` like everything else here. Nothing is announced, nothing looks wrong, and
|
||||
// the tool has invented a chip.
|
||||
//
|
||||
// Blur rather than preventDefault. Cancelling the event would stop the value moving
|
||||
// and stop the page moving with it, which turns a silent bug into a sidebar that
|
||||
// mysteriously will not scroll. Dropping focus lets the scroll do what the dealer
|
||||
// asked for and leaves the number alone — and losing focus on a field you have just
|
||||
// scrolled away from is honest rather than surprising.
|
||||
//
|
||||
// Captured at the document, so it covers the fields the seat editor and the chip
|
||||
// legend build on the fly as well as the ones in the markup.
|
||||
document.addEventListener("wheel", event => {
|
||||
if (Money.wheelWouldEdit(event.target, document.activeElement)) event.target.blur();
|
||||
}, { passive: true });
|
||||
|
||||
// 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 —
|
||||
// reassigning the variable would leave the renderer and the betting bar pointing at
|
||||
@@ -28,6 +61,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
Toast.show(message, {
|
||||
actionLabel: "Undo",
|
||||
shortcut: "undo",
|
||||
onAction: () => {
|
||||
restoreState(snapshot);
|
||||
Toast.show("Undone.", { duration: 2500 });
|
||||
@@ -56,7 +90,84 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
},
|
||||
|
||||
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
|
||||
|
||||
// The waiting list, from the Session panel and from an empty chair's menu.
|
||||
// The state change goes inside runUndoable, not before it: the snapshot is
|
||||
// taken as the first thing that happens, so anything done ahead of it is
|
||||
// already baked into what Undo would restore.
|
||||
seatWaiting: (id, index) => {
|
||||
const entry = (state.waitingList || []).find(row => row.id === id);
|
||||
const seat = state.seats[index];
|
||||
if (!entry || !seat) {
|
||||
Toast.show("They aren't on the list any more.", { duration: 3000 });
|
||||
hooks.rerender();
|
||||
return;
|
||||
}
|
||||
const occupied = HoldemState.seatInPlay(seat);
|
||||
if (occupied) {
|
||||
Toast.show(`${HoldemState.displayName(seat, index)} is already in that seat.`, { duration: 3500 });
|
||||
hooks.rerender();
|
||||
return;
|
||||
}
|
||||
runUndoable(`${entry.name} takes seat ${index + 1}. Add their chips when they buy in.`,
|
||||
() => HoldemState.seatFromWaitingList(state, id, index));
|
||||
},
|
||||
|
||||
dropWaiting: id => {
|
||||
const entry = (state.waitingList || []).find(row => row.id === id);
|
||||
if (!entry) return;
|
||||
runUndoable(`${entry.name} taken off the waiting list.`,
|
||||
() => HoldemState.removeFromWaitingList(state, id));
|
||||
},
|
||||
onGiveButton: index => seatMenuHooks.makeDealer(index),
|
||||
|
||||
// Removing a chip colour, which is only sometimes a small thing.
|
||||
//
|
||||
// A colour nobody has counted is a row in a legend, and taking it out should
|
||||
// cost a click and nothing else. A colour seats are holding is money: the
|
||||
// legend is what prices their chips, so the row leaving takes their chips off
|
||||
// the table with it — and it cannot be put back, because a colour added again
|
||||
// is a new one and the counts belong to the old.
|
||||
//
|
||||
// So the friction is on the second case only. A confirmation that fires every
|
||||
// time is one nobody reads by the third night, and the one that matters here
|
||||
// would go the same way as the four before it that didn't.
|
||||
removeChipColor: async id => {
|
||||
const usage = HoldemState.chipColorUsage(state, id);
|
||||
if (!usage.chip) return;
|
||||
const name = Number(usage.chip.value) > 0
|
||||
? `${Money.format(usage.chip.value)} chip`
|
||||
: "chip colour";
|
||||
|
||||
if (usage.total !== 0) {
|
||||
const seatCount = `${usage.seats.length} ${usage.seats.length === 1 ? "seat" : "seats"}`;
|
||||
const held = usage.seats.map(seat => `${seat.name} ${seat.count}`).join(", ");
|
||||
const ok = await Dialog.confirm({
|
||||
// Both directions are named rather than only the loss. Going up is
|
||||
// the odd one — seats falling back to the table's standing ante once
|
||||
// their chips are gone — and a dealer who is told the total will move
|
||||
// and then finds it moved the other way has been told something false.
|
||||
title: usage.total > 0
|
||||
? `Remove the ${name} and ${Money.format(usage.total)} with it?`
|
||||
: `Remove the ${name} and put the table up ${Money.format(-usage.total)}?`,
|
||||
message:
|
||||
`${usage.chips} of them are counted onto ${seatCount} — ${held} — and the table ` +
|
||||
`${usage.total > 0 ? "loses" : "gains"} ${Money.format(Math.abs(usage.total))} ` +
|
||||
`when the colour goes. Undo will put it back; adding the colour again will not.`,
|
||||
confirmLabel: "Remove it",
|
||||
danger: usage.total > 0,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
runUndoable(
|
||||
usage.total > 0
|
||||
? `${name} removed — ${Money.format(usage.total)} off the table.`
|
||||
: usage.total < 0
|
||||
? `${name} removed — table up ${Money.format(-usage.total)}.`
|
||||
: `${name} removed.`,
|
||||
() => HoldemState.removeChipColor(state, id));
|
||||
},
|
||||
};
|
||||
|
||||
const seatMenuHooks = {
|
||||
@@ -96,6 +207,39 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
},
|
||||
|
||||
// Somebody is done for the night. The dealer counts their chips out, the chair
|
||||
// opens, and both halves of what happened stay on the record — the cash the box
|
||||
// took from them, and the chips it handed back. Wiping the seat by hand did
|
||||
// neither: it left the bank check declaring their whole stack missing for the
|
||||
// rest of the night.
|
||||
cashOut: async index => {
|
||||
const allowed = HoldemState.canCashOut(state, index);
|
||||
if (!allowed.ok) {
|
||||
Toast.show(allowed.reason, { duration: 4000 });
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = HoldemState.cashOutPreview(state, index);
|
||||
const result = preview.net === 0
|
||||
? "even for the night"
|
||||
: preview.net > 0
|
||||
? `up ${Money.format(preview.net)}`
|
||||
: `down ${Money.format(Math.abs(preview.net))}`;
|
||||
const chips = preview.breakdown ? ` — ${preview.breakdown}` : "";
|
||||
|
||||
const ok = await Dialog.confirm({
|
||||
title: `Cash out ${preview.name}?`,
|
||||
message: `Count out ${Money.format(preview.chips)}${chips}. They bought in for ` +
|
||||
`${Money.format(preview.boughtIn)}, so they leave ${result}. The chair opens up, and the ` +
|
||||
`night's record keeps both their buy-in and this payout so the bank still balances.`,
|
||||
confirmLabel: `Pay ${Money.format(preview.chips)}`,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
runUndoable(`${preview.name} cashed out for ${Money.format(preview.chips)}.`,
|
||||
() => HoldemState.cashOut(state, index));
|
||||
},
|
||||
|
||||
// Two players change chairs. Undoable like every other seat action, because a
|
||||
// swap is easy to aim wrong and the seats look alike at a glance.
|
||||
swapSeats: (from, to) => {
|
||||
@@ -110,12 +254,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
},
|
||||
|
||||
makeDealer: index => {
|
||||
// Moving the button to where it already is is not a move. Saying so costs a
|
||||
// toast nobody asked for, and mid-hand it would take the blinds back off the
|
||||
// felt and re-post the same chips to say nothing happened.
|
||||
if (index === state.dealerSeatIndex) return;
|
||||
|
||||
// Before the deal there's nothing posted, so this is just moving a marker.
|
||||
// After it, the blinds have to follow the button or the badges and the
|
||||
// money disagree — but that's a real thing happening to real chips, and
|
||||
// the dealer gets told rather than finding out later.
|
||||
const willRepost = state.bettingOpen && Betting.onlyBlindsIn(state) &&
|
||||
HoldemState.unresolvedContributions(state) > Betting.EPSILON_CHIPS;
|
||||
const willRepost = Betting.blindsArePosted(state);
|
||||
|
||||
seatAction(index, willRepost
|
||||
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
|
||||
@@ -142,18 +290,29 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (partner === index || partner === null) partner = Betting.nextLiveIndex(state, index);
|
||||
|
||||
const label = which === "sb" ? "small blind" : "big blind";
|
||||
seatAction(index, `${nameOf(index)} posts the ${label}.`, () => {
|
||||
// Only a hand that is already open has blinds to move. Before the deal this
|
||||
// is aiming the next hand, and re-posting would have dealt it — the dealer
|
||||
// says who the blind is well before they say "cards".
|
||||
const posted = Betting.blindsArePosted(state);
|
||||
seatAction(index, posted
|
||||
? `${nameOf(index)} posts the ${label}.`
|
||||
: `${nameOf(index)} has the ${label} next hand.`, () => {
|
||||
state.blindOverride = which === "sb"
|
||||
? { sbIndex: index, bbIndex: partner }
|
||||
: { sbIndex: partner, bbIndex: index };
|
||||
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||
if (posted) Betting.repostBlinds(state);
|
||||
});
|
||||
},
|
||||
|
||||
clearBlindOverride: () => seatAction(state.dealerSeatIndex, "Blinds follow the button again.", () => {
|
||||
state.blindOverride = null;
|
||||
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||
}),
|
||||
clearBlindOverride: () => {
|
||||
const posted = Betting.blindsArePosted(state);
|
||||
seatAction(state.dealerSeatIndex, posted
|
||||
? "Blinds follow the button again — taken back and re-posted."
|
||||
: "Blinds follow the button again.", () => {
|
||||
state.blindOverride = null;
|
||||
if (posted) Betting.repostBlinds(state);
|
||||
});
|
||||
},
|
||||
|
||||
editSeat: index => Render.toggleSeatEditor(index),
|
||||
|
||||
@@ -185,6 +344,27 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
Render.mount(state, hooks);
|
||||
|
||||
// Table Setup used to ship open, so the tool's first screen was its most crowded
|
||||
// one: seat count, blind direction, dedicated dealer, both blinds, starting stack
|
||||
// and two sub-panels, all under a felt the dealer had come here to look at. It is
|
||||
// the right panel to have open on a table nobody has touched, and the wrong one to
|
||||
// have open on the fourth hand of a night.
|
||||
//
|
||||
// That was half the answer. The other half was what the panel held while it was
|
||||
// open — 886px of it, on a 667px phone — and it has since given three switches and
|
||||
// the paragraphs explaining them to "While you deal". The panel this opens is 529px
|
||||
// now, and the two figures its own summary glances at are the first two rows of it.
|
||||
//
|
||||
// Decided once, here, rather than on every render — a panel that reopened itself
|
||||
// whenever the table emptied would fight the dealer who had just shut it.
|
||||
(() => {
|
||||
const panel = document.getElementById("tableSetupPanel");
|
||||
if (!panel) return;
|
||||
const fresh = (Number(state.handNumber) || 1) === 1 &&
|
||||
!state.seats.some(seat => HoldemState.seatInPlay(seat));
|
||||
panel.open = fresh;
|
||||
})();
|
||||
|
||||
const openPotWizard = () => {
|
||||
// Chips still in front of players belong in the pot being priced, so bank them
|
||||
// before the wizard reads anyone's contribution.
|
||||
@@ -192,11 +372,20 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
|
||||
PotWizard.open(state, payouts => {
|
||||
PotWizard.open(state, (payouts, { rake = 0 } = {}) => {
|
||||
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
||||
runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => {
|
||||
const withRake = rake > 0 ? `${paid}, house ${Money.format(rake)}` : paid;
|
||||
runUndoable(withRake ? `Paid ${withRake}.` : "Hand closed with nothing to pay out.", () => {
|
||||
if (rake > 0) {
|
||||
state.rakeTaken = Money.round((Number(state.rakeTaken) || 0) + rake);
|
||||
}
|
||||
// Anything the pot was priced with but nobody actually paid — the
|
||||
// base pot and counted chips of the no-live-betting flow — comes off
|
||||
// the stacks that owe it before the winner is paid. Otherwise the
|
||||
// table gains the whole pot out of nowhere.
|
||||
Betting.collectAssumedContributions(state);
|
||||
if (payouts.length > 0) {
|
||||
HoldemState.recordHand(state, payouts);
|
||||
HoldemState.recordHand(state, payouts, rake);
|
||||
// Winnings go straight back in front of the player, so the next
|
||||
// hand's stacks are already right.
|
||||
Betting.creditWinnings(state, payouts);
|
||||
@@ -219,14 +408,30 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
save: hooks.save,
|
||||
rerender: hooks.rerender,
|
||||
resolvePot: openPotWizard,
|
||||
keepStorage: () => askToKeepStorage(),
|
||||
|
||||
beginAction: label => {
|
||||
actionHistory.push({ label, snapshot: snapshotState() });
|
||||
// The first thing a dealer does on a table nobody is sitting at. Opens the
|
||||
// seat editor on the first free chair — the same panel tapping the seat gives
|
||||
// them, so the button teaches the gesture rather than replacing it.
|
||||
seatFirstPlayer: () => {
|
||||
const index = state.seats.findIndex(seat => !HoldemState.seatInPlay(seat));
|
||||
Render.toggleSeatEditor(index < 0 ? 0 : index);
|
||||
const panel = document.getElementById("seatEditorPanel");
|
||||
Render.bringIntoView(panel, { block: "nearest" });
|
||||
},
|
||||
|
||||
// `parts` is the same name split into the actor and the act — see
|
||||
// ActionBar.undoParts. It rides along with the label rather than replacing it,
|
||||
// because the toast below wants the sentence and the pill wants the halves.
|
||||
beginAction: (label, parts) => {
|
||||
actionHistory.push({ label, parts, snapshot: snapshotState() });
|
||||
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
|
||||
},
|
||||
|
||||
lastActionLabel: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].label : null),
|
||||
|
||||
lastActionParts: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].parts : null),
|
||||
|
||||
undoLast: () => {
|
||||
const step = actionHistory.pop();
|
||||
if (!step) return;
|
||||
@@ -249,6 +454,24 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
// The toast's Undo, pressed the same way the bar's is: by pressing the button that
|
||||
// is actually on the screen, not by running a second copy of what it does.
|
||||
//
|
||||
// It has to be reachable. Every whole-step undo — next hand, resolve pot, a seat
|
||||
// edited — clears the per-action history on its way past, so the bar has no pill to
|
||||
// press and this button is the only way back. Measured on a desktop, it is the 67th
|
||||
// of the page's 68 tab stops and it is gone in eight seconds, so U was the key the
|
||||
// sidebar legend advertised and nothing answered.
|
||||
//
|
||||
// Only a button that named this shortcut, so the wake lock's "Turn off" and the
|
||||
// other-tab warning's "Reload" are not things a stray U can reach.
|
||||
const pressToastAction = shortcut => {
|
||||
const btn = document.querySelector(`#toastHost .toastAction[data-shortcut="${shortcut}"]`);
|
||||
if (!btn) return false;
|
||||
btn.click();
|
||||
return true;
|
||||
};
|
||||
|
||||
const quickRaiseButton = label =>
|
||||
[...document.querySelectorAll("#actionBar .raiseQuickBtn")]
|
||||
.find(b => b.querySelector(".actionBtnLabel").textContent === label);
|
||||
@@ -269,7 +492,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// Dealing is an advance too — Enter should carry a dealer through setup, deal,
|
||||
// streets and showdown without reaching for the trackpad.
|
||||
advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance") || pressInBar(".actionDeal"),
|
||||
undo: () => pressInBar(".actionUndo"),
|
||||
// The toast first, because it is the more recent of the two. They can both be
|
||||
// up at once — a seat edited mid-hand leaves the bar's pill alone — and the one
|
||||
// the dealer just caused is the one on the screen saying what it will take back.
|
||||
undo: () => pressToastAction("undo") || pressInBar(".actionUndo"),
|
||||
cancel: () => pressInBar(".raiseCancel"),
|
||||
});
|
||||
|
||||
@@ -289,6 +515,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
Toast.show(message, {
|
||||
actionLabel: "Undo",
|
||||
shortcut: "undo",
|
||||
onAction: () => {
|
||||
restoreState(snapshot);
|
||||
Toast.show("Undone.", { duration: 2500 });
|
||||
@@ -300,12 +527,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// in progress. Shrinking the table throws these away permanently, and it also
|
||||
// orphans them in the session standings, where they keep their winnings but lose
|
||||
// the stack and buy-in the settle-up is worked out from.
|
||||
const seatHasSomeoneIn = player =>
|
||||
!!(player.name && player.name.trim()) ||
|
||||
player.inHand ||
|
||||
(Number(player.stack) || 0) > 0 ||
|
||||
(Number(player.buyIns) || 0) > 0 ||
|
||||
(Number(player.amountOverride) || 0) > 0;
|
||||
// One question, asked in one place. This predicate existed here, in the seat menu,
|
||||
// in the action bar and twice in the state module, in four slightly different
|
||||
// spellings — and a chair that counts as occupied for the waiting list but empty
|
||||
// for the seat count is how somebody's chips get thrown away.
|
||||
const seatHasSomeoneIn = player => HoldemState.seatInPlay(player);
|
||||
|
||||
document.getElementById("seatCountInput").addEventListener("change", async e => {
|
||||
const requested = Math.max(2, Math.min(24, Math.floor(Number(e.target.value)) || state.seatCount));
|
||||
@@ -337,7 +563,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
|
||||
document.getElementById("basePotInput").addEventListener("input", e => {
|
||||
state.basePotAmount = e.target.value === "" ? null : Number(e.target.value);
|
||||
state.basePotAmount = Money.readAmount(e.target.value);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
});
|
||||
@@ -349,9 +575,24 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
Render.renderTableHint();
|
||||
});
|
||||
|
||||
document.getElementById("trackBoardToggle").addEventListener("change", e => {
|
||||
state.trackBoard = e.target.checked;
|
||||
// Turning it off leaves whatever was entered in place rather than wiping it —
|
||||
// a dealer who taps this by mistake mid-hand can tap it back and the flop is
|
||||
// still there. The next hand clears it the way it clears everything else.
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
});
|
||||
|
||||
document.getElementById("tableNotes").addEventListener("input", e => {
|
||||
state.notes = e.target.value;
|
||||
hooks.save(state);
|
||||
// The one field on the page that must not go through renderAll: it would write
|
||||
// the textarea's own value back into it on every keystroke and put the caret at
|
||||
// the end. The panel's shut-state summary still has to keep up, though, and it
|
||||
// is the only thing on the page that reads the notes — so it gets called on its
|
||||
// own. It touches the six <span>s in the sidebar headers and nothing else.
|
||||
Render.renderPanelGlances();
|
||||
});
|
||||
|
||||
document.getElementById("addChipColorBtn").addEventListener("click", () => {
|
||||
@@ -364,12 +605,69 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
Render.renderAll();
|
||||
});
|
||||
|
||||
// A misdeal. Everything in the middle goes back to whoever put it there, and the
|
||||
// same button deals the same hand again.
|
||||
// The seats holding the table's starting stack, written down as cash taken.
|
||||
document.getElementById("bankRecordBuyInsBtn").addEventListener("click", async () => {
|
||||
const starting = HoldemState.seatsOwingCash(state).filter(row => row.fromStartingStack);
|
||||
if (starting.length === 0) return;
|
||||
const total = Money.round(starting.reduce((sum, row) => sum + row.chips, 0));
|
||||
|
||||
const ok = await Dialog.confirm({
|
||||
title: `Write down ${Money.format(total)} taken?`,
|
||||
message: `${starting.map(row => `${row.name} ${Money.format(row.chips)}`).join(", ")} — the starting stack ` +
|
||||
`each of them is playing with. Only do this if the cash is actually in the box.`,
|
||||
confirmLabel: "Write it down",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
runUndoable(`${Money.format(total)} recorded from ${starting.length} ${starting.length === 1 ? "player" : "players"}.`,
|
||||
() => HoldemState.recordStartingBuyIns(state));
|
||||
});
|
||||
|
||||
document.getElementById("waitingListForm").addEventListener("submit", e => {
|
||||
e.preventDefault();
|
||||
const field = document.getElementById("waitingNameInput");
|
||||
const name = field.value.trim();
|
||||
if (!name) return;
|
||||
field.value = "";
|
||||
runUndoable(`${name} added to the waiting list.`, () => HoldemState.addToWaitingList(state, name));
|
||||
field.focus();
|
||||
});
|
||||
|
||||
document.getElementById("misdealBtn").addEventListener("click", async () => {
|
||||
const inPlay = HoldemState.unresolvedContributions(state);
|
||||
const acted = state.seats.filter(p => p.hasActed).length;
|
||||
|
||||
const ok = await Dialog.confirm({
|
||||
title: "Misdeal — take the hand back?",
|
||||
message: inPlay > Betting.EPSILON_CHIPS
|
||||
? `${Money.format(inPlay)} goes back to the players who put it in, cards are cleared, and the same ` +
|
||||
`button deals again.${acted > 1 ? " Note that most rooms let a deal stand once two players have acted." : ""}`
|
||||
: "Cards are cleared and the same button deals again. Nothing has been put in yet, so no chips move.",
|
||||
confirmLabel: "Take it back",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
// The label is both the toast a dealer reads now and the words on the undo
|
||||
// control afterwards, so it says what happened to the money rather than naming
|
||||
// the ruling: "the misdeal" told a table nothing about where their chips went.
|
||||
const label = inPlay > Betting.EPSILON_CHIPS
|
||||
? `Misdeal — ${Money.format(inPlay)} returned, same button deals again.`
|
||||
: "Misdeal — nothing was in yet, same button deals again.";
|
||||
runUndoable(label, () => Betting.takeHandBack(state));
|
||||
});
|
||||
|
||||
document.getElementById("nextHandBtn").addEventListener("click", async () => {
|
||||
// Moving on wipes every seat's contribution, so an unresolved pot is simply
|
||||
// destroyed — measured at a four-handed table, pressing this with $8 in the
|
||||
// middle took the table from 800 chips to 792, and the toast said nothing
|
||||
// about it. Chips only ever leave this tool because a dealer meant them to.
|
||||
const unresolved = HoldemState.unresolvedContributions(state);
|
||||
// Asked before the mutation, because after it there is never a hand out. This is
|
||||
// the difference between the two things this one button does: end the hand that
|
||||
// is running, or pass the button along on a hand nobody played.
|
||||
const wasLive = !!state.bettingOpen;
|
||||
|
||||
if (unresolved > Betting.EPSILON_CHIPS) {
|
||||
const ok = await Dialog.confirm({
|
||||
@@ -398,7 +696,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
? ` ${Money.format(unresolved)} taken off the table.`
|
||||
: "";
|
||||
|
||||
runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => {
|
||||
// The toast said "Next hand — ... Seat the table, then deal." every time, including
|
||||
// the press that followed the pot wizard's own "Start Next Hand" — two lines that
|
||||
// read as a confirmation of the thing the dealer had already done, when what had
|
||||
// actually happened was a second rotation. It now reports which of the two it was,
|
||||
// and the second line is gone: the bar is already holding a green "Post blinds &
|
||||
// deal", so the toast was spending a line telling a dealer to press it.
|
||||
const done = wasLive
|
||||
? `Next hand — ${where}.${discarded}`
|
||||
: `Button moved on, no hand dealt — ${where}.${discarded}`;
|
||||
|
||||
runUndoable(done, () => {
|
||||
// Written down as well as announced. The toast is a moment; the bank check
|
||||
// is asked at cash-out, and until now it saw only that the chips were gone
|
||||
// and reported them as missing.
|
||||
@@ -448,6 +756,70 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Focus that lands underneath something fixed is moved out from under it.
|
||||
//
|
||||
// scroll-padding-bottom in the stylesheet is half of WCAG 2.4.11 and only half. It
|
||||
// works when the browser has to scroll to reach the control at all — then the
|
||||
// padding tells it where to stop. A control the browser already considers visible is
|
||||
// one it will not scroll to at all, and a fixed overlay sitting on top of it does
|
||||
// not change that: measured on a 1280x900 desktop, tabbing to Common rulings while a
|
||||
// toast was up left it 100% covered, and scrollIntoView({block:"nearest"}) moved
|
||||
// nothing, because "nearest" was already satisfied.
|
||||
//
|
||||
// So the overlap is worked out here and the page is scrolled by exactly it. Named
|
||||
// overlays rather than every fixed element on the page, because this runs on every
|
||||
// focus change and walking the whole DOM to find three known things would be work
|
||||
// done ten times a second to reach the same answer. Anything inside one of them is
|
||||
// left alone — the bar's own buttons are meant to be on top of the page.
|
||||
const FIXED_OVERLAYS = "#actionBar, #toastHost, #seatEditorPanel";
|
||||
document.addEventListener("focusin", event => {
|
||||
const el = event.target;
|
||||
if (!el || typeof el.getBoundingClientRect !== "function") return;
|
||||
if (el.closest && el.closest(FIXED_OVERLAYS)) return;
|
||||
|
||||
const box = el.getBoundingClientRect();
|
||||
if (box.width === 0 || box.height === 0) return;
|
||||
|
||||
let lift = 0;
|
||||
document.querySelectorAll(FIXED_OVERLAYS).forEach(node => {
|
||||
const style = getComputedStyle(node);
|
||||
if (node.hidden || style.visibility === "hidden" || style.display === "none") return;
|
||||
if (style.position !== "fixed") return;
|
||||
const over = node.getBoundingClientRect();
|
||||
if (over.width === 0 || over.height === 0) return;
|
||||
// Only what is actually in the way: an overlay off to one side, like the
|
||||
// rail on a phone held sideways, is not covering a control in the middle.
|
||||
if (box.left >= over.right || box.right <= over.left) return;
|
||||
if (box.bottom <= over.top || box.top >= over.bottom) return;
|
||||
lift = Math.max(lift, box.bottom - over.top + 8);
|
||||
});
|
||||
|
||||
// Instant rather than smooth. This is the page getting out of the way of
|
||||
// something the dealer has already arrived at, and animating it would put the
|
||||
// control they are typing into in motion under them.
|
||||
if (lift > 0) window.scrollBy({ top: lift, behavior: "auto" });
|
||||
|
||||
// Scrolling is not always enough. The page only has as much room below its
|
||||
// content as the padding under the app gives it, and that is sized for the bar
|
||||
// — the toast stands on top of the bar, so a control at the very end of the page
|
||||
// can be lifted clear of one and not the other. Measured at 1280x900: 94px of
|
||||
// lift wanted, 26px of page to do it in.
|
||||
//
|
||||
// Sizing the padding for the toast as well would fix it by making the page grow
|
||||
// and shrink by 64px every time anything is announced, which is a worse thing to
|
||||
// do to a dealer than the fault. So the toast gives way instead: it is an
|
||||
// eight-second message about something that has already happened, and it is
|
||||
// standing on a control somebody has just navigated to. Only after the scroll
|
||||
// has been tried, so a toast that is not in the way is left alone.
|
||||
const toast = document.querySelector("#toastHost .toast");
|
||||
if (!toast) return;
|
||||
const after = el.getBoundingClientRect();
|
||||
const over = toast.getBoundingClientRect();
|
||||
const stillCovered = after.left < over.right && after.right > over.left &&
|
||||
after.bottom > over.top && after.top < over.bottom;
|
||||
if (stillCovered) Toast.dismiss();
|
||||
});
|
||||
|
||||
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
|
||||
|
||||
[
|
||||
@@ -456,14 +828,57 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
["defaultStackInput", "defaultStack"],
|
||||
].forEach(([id, key]) => {
|
||||
document.getElementById(id).addEventListener("input", e => {
|
||||
state[key] = e.target.value === "" ? null : Number(e.target.value);
|
||||
// Money.readAmount rather than Number(): these boxes are type="text" now, so
|
||||
// a half-typed "0." or a stray letter reaches here instead of being silently
|
||||
// eaten by the control, and NaN must not become the blind.
|
||||
state[key] = Money.readAmount(e.target.value);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
});
|
||||
});
|
||||
|
||||
// The house's cut. Kept as a group rather than three loose fields, because
|
||||
// migrate() guarantees the shape and half a rake object would be a table nobody
|
||||
// could read back.
|
||||
const readRake = () => {
|
||||
const percent = Number(document.getElementById("rakePercentInput").value);
|
||||
const cap = Number(document.getElementById("rakeCapInput").value);
|
||||
state.rake = {
|
||||
percent: Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0,
|
||||
cap: Number.isFinite(cap) && cap > 0 ? Money.round(cap) : null,
|
||||
noFlopNoDrop: document.getElementById("rakeNoFlopInput").checked,
|
||||
};
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
};
|
||||
["rakePercentInput", "rakeCapInput"].forEach(id => {
|
||||
document.getElementById(id).addEventListener("input", readRake);
|
||||
});
|
||||
document.getElementById("rakeNoFlopInput").addEventListener("change", readRake);
|
||||
|
||||
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
|
||||
|
||||
// The two ways out of the sheet that every other overlay in the tool already had.
|
||||
//
|
||||
// Modal.mount() covers the six whose ids end in "Overlay"; this one cannot join
|
||||
// them, because above 700px it is not a sheet at all — it is the sidebar panel the
|
||||
// seat's fields live in, and a dialog is the wrong thing for Modal to make of it.
|
||||
// So the two behaviours it was missing are wired here against the same state the
|
||||
// scrim is drawn from.
|
||||
document.getElementById("seatSheetScrim")
|
||||
.addEventListener("click", () => Render.closeSeatEditor());
|
||||
|
||||
// Not in capture, and behind Modal's own check: a card picker opened *from* the
|
||||
// sheet is on top of it, and Escape there means "stop picking a card", not "throw
|
||||
// the seat away". Modal stops that event before this listener ever sees it; the
|
||||
// guard is for the case where it hasn't, and costs nothing.
|
||||
document.addEventListener("keydown", event => {
|
||||
if (event.key !== "Escape") return;
|
||||
if (Modal.open.length > 0) return;
|
||||
if (!document.body.classList.contains("seatSheetOpen")) return;
|
||||
Render.closeSeatEditor();
|
||||
});
|
||||
|
||||
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
|
||||
document.getElementById("addChipsCancelBtn").addEventListener("click", () => AddChips.close());
|
||||
document.getElementById("addChipsOverlay").addEventListener("click", e => {
|
||||
@@ -516,56 +931,32 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
}
|
||||
|
||||
// An index across the top of the rulings sheet, one chip per ruling.
|
||||
// The sheet opens on twelve shut rulings, and it opens that way every time.
|
||||
//
|
||||
// This is opened mid-argument with players waiting, and it was nine rulings of
|
||||
// prose with no way in: reaching the last one meant scrolling 1.6 screens on a
|
||||
// desktop, 2.1 on a phone and 3.5 on a phone held sideways. A dealer looking for
|
||||
// the odd-chip rule had to read past seven others to find it.
|
||||
//
|
||||
// Built from the sections rather than written out beside them, so a ruling added
|
||||
// later turns up in the index without anyone having to remember to add it twice.
|
||||
const buildRulingsIndex = () => {
|
||||
const index = document.getElementById("rulingsIndex");
|
||||
if (!index || index.childElementCount > 0) return;
|
||||
[...rulingsOverlay.querySelectorAll(".ruling")].forEach((section, i) => {
|
||||
const heading = section.querySelector("h3");
|
||||
if (!heading) return;
|
||||
if (!section.id) section.id = `ruling${i + 1}`;
|
||||
|
||||
const jump = document.createElement("button");
|
||||
jump.type = "button";
|
||||
jump.className = "rulingsIndexBtn";
|
||||
// A short label where the ruling gives one. The headings are sentences —
|
||||
// "A short all-in doesn't reopen the betting" — and nine of those stacked
|
||||
// one per row made the index 342px tall on a phone, which is most of the
|
||||
// sheet before the first ruling. "Short all-in" says the same thing to
|
||||
// somebody who is looking for it, and three fit on a line.
|
||||
jump.textContent = section.dataset.short || heading.textContent;
|
||||
// The full heading is still what it announces, so the short label never
|
||||
// costs a screen reader the sentence.
|
||||
if (section.dataset.short) jump.setAttribute("aria-label", heading.textContent);
|
||||
jump.addEventListener("click", () => {
|
||||
section.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
// The heading is what the dealer is now reading, so that is where the
|
||||
// keyboard and a screen reader should be too.
|
||||
heading.setAttribute("tabindex", "-1");
|
||||
heading.focus({ preventScroll: true });
|
||||
});
|
||||
index.appendChild(jump);
|
||||
});
|
||||
// There was an index of chips here that this built from the headings; the headings
|
||||
// are the index now, so there is nothing to build. What is left is the other half
|
||||
// of the job: a dealer who read the odd-chip rule an hour ago should not find it
|
||||
// still hanging open under the one they came back for. <details> keeps its state,
|
||||
// so closing the sheet is what forgets it.
|
||||
const openRulings = () => {
|
||||
rulingsOverlay.querySelectorAll(".ruling[open]").forEach(r => { r.open = false; });
|
||||
rulingsOverlay.hidden = false;
|
||||
};
|
||||
|
||||
document.getElementById("openRulingsBtn").addEventListener("click", () => {
|
||||
buildRulingsIndex();
|
||||
rulingsOverlay.hidden = false;
|
||||
});
|
||||
document.getElementById("openRulingsBtn").addEventListener("click", openRulings);
|
||||
document.getElementById("closeRulingsBtn").addEventListener("click", closeRulings);
|
||||
rulingsOverlay.addEventListener("click", e => {
|
||||
if (e.target === rulingsOverlay) closeRulings();
|
||||
});
|
||||
|
||||
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
|
||||
document.getElementById("seatMenuCloseBtn").addEventListener("click", () => SeatMenu.close());
|
||||
// The button at the thumb end is the sheet's Back while the sheet has an inside to
|
||||
// go back to. Opening the move picker used to be one-way: it replaced the action
|
||||
// list, and the only control left threw the whole menu away.
|
||||
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => {
|
||||
if (SeatMenu.mode === "move") SeatMenu.closeMovePicker();
|
||||
else SeatMenu.close();
|
||||
});
|
||||
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
|
||||
if (e.target.id === "seatMenuOverlay") SeatMenu.close();
|
||||
});
|
||||
@@ -591,15 +982,99 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (!ok) return;
|
||||
|
||||
HoldemState.clearHistory(state);
|
||||
Render.historyFilter = "";
|
||||
document.getElementById("handHistoryFilter").value = "";
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
Toast.show("Hand history cleared.", { duration: 2500 });
|
||||
});
|
||||
|
||||
document.getElementById("saveTableBtn").addEventListener("click", () => {
|
||||
const blob = new Blob([HoldemState.toBackup(state)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = HoldemState.backupFileName();
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
Toast.show(`Table saved as ${link.download}.`, { duration: 3500 });
|
||||
});
|
||||
|
||||
document.getElementById("loadTableBtn").addEventListener("click", () => {
|
||||
document.getElementById("loadTableInput").click();
|
||||
});
|
||||
|
||||
document.getElementById("loadTableInput").addEventListener("change", async e => {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
// Cleared straight away so picking the same file twice still fires a change.
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
|
||||
let text;
|
||||
try {
|
||||
text = await file.text();
|
||||
} catch {
|
||||
Toast.show("That file couldn't be read.", { duration: 4000 });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = HoldemState.fromBackup(text);
|
||||
if (result.error) {
|
||||
Toast.show(result.error, { duration: 5000 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Loading replaces everything: stacks, buy-ins, the button, the log. Naming
|
||||
// what is about to be lost is the difference between a dealer choosing that
|
||||
// and discovering it — and the current table might be the live one.
|
||||
const here = HoldemState.sessionStandings(state).length;
|
||||
const incoming = HoldemState.sessionStandings(result.table).length;
|
||||
const when = result.savedAt ? new Date(result.savedAt) : null;
|
||||
const saidWhen = when && !Number.isNaN(when.getTime())
|
||||
? ` It was saved ${when.toLocaleString()}.`
|
||||
: "";
|
||||
const ok = await Dialog.confirm({
|
||||
title: "Replace this table?",
|
||||
message:
|
||||
`The file has a table with ${incoming} ${incoming === 1 ? "player" : "players"} in it.${saidWhen} ` +
|
||||
`Loading it replaces the table on screen — ${here} ${here === 1 ? "player" : "players"}, ` +
|
||||
`${(state.handHistory || []).length} logged ${(state.handHistory || []).length === 1 ? "hand" : "hands"}. ` +
|
||||
`Undo will bring it back for a few seconds; after that it is gone.`,
|
||||
confirmLabel: "Load it",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
runUndoable("loading a saved table", () => {
|
||||
// Replace the contents rather than the reference: everything on the page
|
||||
// holds a pointer to this object.
|
||||
Object.keys(state).forEach(key => { delete state[key]; });
|
||||
Object.assign(state, result.table);
|
||||
});
|
||||
});
|
||||
|
||||
// The filter is how the dealer looks at the log, not something about the table, so
|
||||
// it lives on the view: it isn't saved, isn't undoable, and doesn't reach the other
|
||||
// tab. Clearing the history clears it too, since there is nothing left to filter.
|
||||
document.getElementById("handHistoryFilter").addEventListener("input", e => {
|
||||
Render.historyFilter = e.target.value;
|
||||
Render.renderHandHistory();
|
||||
});
|
||||
|
||||
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
|
||||
if ((state.handHistory || []).length === 0) return;
|
||||
// Always the whole night. A filter is for reading the panel; an export that
|
||||
// quietly dropped the hands scrolled past would be a record with holes in it,
|
||||
// and nothing on the CSV would say so.
|
||||
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state),
|
||||
{ notes: state.notes });
|
||||
if (Render.historyFilter) {
|
||||
Toast.show(`Exported all ${state.handHistory.length} hands, not just the ${
|
||||
state.handHistory.filter(h => Render.handMatchesFilter(h, Render.historyFilter)).length} shown.`,
|
||||
{ duration: 3500 });
|
||||
}
|
||||
});
|
||||
|
||||
// The load happens on the first line of this file, long before there's a Toast to
|
||||
@@ -611,4 +1086,164 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
"The saved table couldn't be read, so this one started empty.",
|
||||
{ duration: 12000 });
|
||||
}
|
||||
|
||||
// ---- Keeping the screen on ----
|
||||
//
|
||||
// The lock itself is taken and dropped in renderAll, because that is the one place
|
||||
// every start and end of a hand already passes through. All that lives here is the
|
||||
// dealer's say in it: the toggle, and the way out offered by the toast the first
|
||||
// time it takes hold.
|
||||
WakeLock.hooks = {
|
||||
turnOff: () => {
|
||||
state.keepScreenAwake = false;
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
Toast.show("The screen will sleep as usual. Table Setup has it if you want it back.");
|
||||
},
|
||||
};
|
||||
WakeLock.start(() => state);
|
||||
|
||||
document.getElementById("keepScreenAwakeToggle").addEventListener("change", e => {
|
||||
state.keepScreenAwake = e.target.checked;
|
||||
hooks.save(state);
|
||||
// renderAll does the acquiring and releasing, so turning this off mid-hand
|
||||
// drops the lock in the same tick rather than at the end of the hand.
|
||||
Render.renderAll();
|
||||
});
|
||||
|
||||
// ---- Asking the browser to keep the night ----
|
||||
//
|
||||
// Reported rather than announced. A dealer opening Save & reset is already thinking
|
||||
// about losing the table, and that is the only moment this is worth a sentence.
|
||||
const showStorageDurability = async () => {
|
||||
const hint = document.getElementById("storageDurabilityHint");
|
||||
const button = document.getElementById("keepStorageBtn");
|
||||
if (!hint || !button) return;
|
||||
|
||||
if (!HoldemState.supportsDurableStorage()) {
|
||||
// Nothing to say and nothing to offer. A browser this old still keeps the
|
||||
// table exactly as well as it did yesterday.
|
||||
hint.hidden = true;
|
||||
button.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const durable = await HoldemState.isDurable();
|
||||
hint.hidden = false;
|
||||
hint.textContent = durable
|
||||
? "This browser has promised to keep it: the table survives the phone running short on space."
|
||||
: "This browser may clear the table on its own if the phone runs short on space.";
|
||||
button.hidden = durable;
|
||||
};
|
||||
|
||||
// Fires on the first deal of every session, which costs nothing after the first
|
||||
// time it is granted — persisted() is checked before persist() is asked for.
|
||||
const askToKeepStorage = async () => {
|
||||
await HoldemState.requestDurable();
|
||||
showStorageDurability();
|
||||
};
|
||||
|
||||
document.getElementById("keepStorageBtn").addEventListener("click", async () => {
|
||||
const granted = await HoldemState.requestDurable();
|
||||
showStorageDurability();
|
||||
// Said either way, because a button that sometimes does nothing visible is a
|
||||
// button a dealer presses four more times.
|
||||
Toast.show(granted
|
||||
? "This browser will keep the table now."
|
||||
: "This browser wouldn't promise. Save the table to a file if tonight matters.");
|
||||
});
|
||||
|
||||
showStorageDurability();
|
||||
|
||||
// ---- Working with no signal ----
|
||||
//
|
||||
// The night already survives anything — it lives in localStorage — but the page
|
||||
// did not. Reload in a back room with one bar of signal and a dealer with nine
|
||||
// players waiting got a browser error page. The worker below keeps the tool itself
|
||||
// on the phone; see wwwroot/casino/sw.js for what it does and doesn't cache.
|
||||
//
|
||||
// The list of files to keep is read off this document rather than written down in
|
||||
// the worker. Every stylesheet and script the page actually loaded is right here in
|
||||
// the DOM, already carrying the ?v= release string from _Layout — so the list
|
||||
// cannot drift from the markup, which is the way a hand-kept one always fails: it
|
||||
// stays right until somebody adds a script tag, and then the tool is quietly
|
||||
// offline-broken until a dealer finds out the hard way.
|
||||
const MANIFEST_URL = "/casino/holdem.webmanifest";
|
||||
|
||||
// The icons are the one part of the shell the document doesn't mention: the browser
|
||||
// reads them out of the manifest itself, so no <link> or <script> in the DOM points
|
||||
// at them and the sweep above misses all four. An installed app that was launched
|
||||
// with no signal then had no icon to show for itself.
|
||||
//
|
||||
// Read out of the manifest rather than typed out here, for the same reason the rest
|
||||
// of the list is read out of the document — a copy of a list is a list that is one
|
||||
// edit away from being wrong, and this particular wrongness is invisible until
|
||||
// somebody is standing in a back room with no signal.
|
||||
const manifestIcons = async () => {
|
||||
try {
|
||||
// The cache before the network, which is backwards from how the rest of this
|
||||
// worker thinks, and right here. Offline, a plain fetch for the manifest goes
|
||||
// to the network, fails, and logs ERR_INTERNET_DISCONNECTED to the console on
|
||||
// every single launch — noise on the one screen where a real error needs to
|
||||
// be visible, in service of re-reading a list of four icon paths that has not
|
||||
// changed since the manifest was written and is already cached anyway.
|
||||
const res = (typeof caches !== "undefined" && await caches.match(MANIFEST_URL))
|
||||
|| await fetch(MANIFEST_URL);
|
||||
if (!res || !res.ok) return [];
|
||||
const manifest = await res.json();
|
||||
return (manifest.icons || [])
|
||||
.map(icon => icon && icon.src)
|
||||
.filter(Boolean)
|
||||
.map(src => new URL(src, location.origin).href);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const registerWorker = async () => {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
const shell = [
|
||||
location.pathname,
|
||||
MANIFEST_URL,
|
||||
...await manifestIcons(),
|
||||
// Stylesheets, plus the icons the document points at itself — iOS reads
|
||||
// none of the manifest's icons and only <link rel="apple-touch-icon">, so
|
||||
// an installed iPhone launched with no signal needs this one specifically.
|
||||
...[...document.querySelectorAll(
|
||||
'link[rel="stylesheet"][href], link[rel="apple-touch-icon"][href], link[rel="icon"][href]'
|
||||
)].map(el => el.href),
|
||||
...[...document.querySelectorAll("script[src]")].map(el => el.src),
|
||||
].filter(url => new URL(url, location.origin).origin === location.origin);
|
||||
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register("/casino/sw.js", { scope: "/casino/" });
|
||||
|
||||
// Whichever worker this registration has, at whatever stage it has reached.
|
||||
//
|
||||
// This used to fall back on navigator.serviceWorker.ready, which resolves
|
||||
// with the registration *controlling this page* — and so never resolves at
|
||||
// all on a page outside the worker's scope. That is not hypothetical: it is
|
||||
// exactly what /Casino/TexasHoldem did before the redirect in Program.cs,
|
||||
// and the symptom was the worst kind. The registration succeeded, the worker
|
||||
// activated, nothing was logged, and the cache stayed completely empty — a
|
||||
// dealer who had "loaded the tool for tonight" had loaded nothing.
|
||||
const worker = reg.installing || reg.waiting || reg.active;
|
||||
if (!worker) return;
|
||||
|
||||
const send = () => worker.postMessage({ type: "precache", urls: shell });
|
||||
if (worker.state === "activated") send();
|
||||
else worker.addEventListener("statechange", () => {
|
||||
if (worker.state === "activated") send();
|
||||
});
|
||||
} catch {
|
||||
// No worker means no offline, and nothing else. Every other part of the
|
||||
// tool works exactly as it did, so this is not worth a toast at a table.
|
||||
}
|
||||
};
|
||||
|
||||
// After load, not during it: registering competes with the page's own scripts for
|
||||
// the network, and the first paint matters more than the second visit does.
|
||||
if (document.readyState === "complete") registerWorker();
|
||||
else window.addEventListener("load", registerWorker, { once: true });
|
||||
});
|
||||
|
||||
@@ -14,12 +14,24 @@
|
||||
const Modal = {
|
||||
// `:not([hidden])` matters — a sheet that is itself hidden inside an open overlay
|
||||
// must not be a Tab stop.
|
||||
//
|
||||
// `details > summary` is here because a summary is focusable without anyone giving
|
||||
// it a tabindex, and leaving it out was not a small omission. The rulings sheet is
|
||||
// twelve summaries and one Done button; this list saw one control in it, so the Tab
|
||||
// trap below decided Done was both the first stop and the last and bounced every
|
||||
// press straight back to it. Measured: eighty Tab presses, one element ever
|
||||
// reached. Twelve rulings, none of them openable without a mouse.
|
||||
//
|
||||
// `:not([tabindex="-1"])` belongs on every line, not only the last. A control taken
|
||||
// out of the Tab order still matches `button` or `input` — and a roving tabindex,
|
||||
// which is what the card grid runs on now, is fifty-one of them at a time.
|
||||
FOCUSABLE: [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
'input:not([disabled]):not([type="hidden"])',
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
'a[href]:not([tabindex="-1"])',
|
||||
'button:not([disabled]):not([tabindex="-1"])',
|
||||
'input:not([disabled]):not([type="hidden"]):not([tabindex="-1"])',
|
||||
'select:not([disabled]):not([tabindex="-1"])',
|
||||
'textarea:not([disabled]):not([tabindex="-1"])',
|
||||
'details > summary:not([tabindex="-1"])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(","),
|
||||
|
||||
@@ -63,11 +75,22 @@ const Modal = {
|
||||
|
||||
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.
|
||||
// Where the sheet puts the dealer when it opens.
|
||||
//
|
||||
// This used to be "the first control", full stop — one answer to a question six
|
||||
// dialogs answer differently, and on the card picker the first control is Clear.
|
||||
// The sheet for writing a card down opened on the button that rubs one out. The
|
||||
// APG's rule is the opposite of that: where an action is hard to take back, land
|
||||
// on the least destructive thing in the dialog.
|
||||
//
|
||||
// So a sheet names its own landing spot with `data-modal-focus`, and on all five
|
||||
// that name one it is the heading. That is Roselli's advice for a dialog with
|
||||
// more in it than a sentence — give the reader a chance to orient — and here it
|
||||
// has a second use, because these headings say *which*: which seat, which card,
|
||||
// which player's chips. A sheet that names nothing still gets the first control,
|
||||
// and a sheet with no controls at all still gets focus on itself.
|
||||
const focusable = this.focusableIn(overlay);
|
||||
const target = focusable[0];
|
||||
const target = overlay.querySelector("[data-modal-focus]") || focusable[0];
|
||||
if (target) {
|
||||
target.focus({ preventScroll: true });
|
||||
} else if (sheet) {
|
||||
@@ -93,6 +116,39 @@ const Modal = {
|
||||
}
|
||||
},
|
||||
|
||||
// Rebuild part of a sheet without losing the dealer's place in it.
|
||||
//
|
||||
// Emptying the element that currently holds focus does not move focus — it destroys
|
||||
// it, and the browser drops the remains on `<body>`. Measured, on three sheets that
|
||||
// redraw themselves after every tap: choosing a buy-in preset, picking a card,
|
||||
// switching which slot is being filled. All three left focus on the document body,
|
||||
// which inside a modal is nowhere: the next Tab had to be rescued by the wrap above.
|
||||
//
|
||||
// Controls that get rebuilt say who they are with `data-focus-key`, and whoever held
|
||||
// focus is handed back the control with the same name. When that name is gone the
|
||||
// caller is asked instead, because only it knows where the dealer should be next —
|
||||
// the card just picked is a dealt card now, and dealt cards are disabled.
|
||||
preservingFocus(root, rebuild, whenGone) {
|
||||
const active = document.activeElement;
|
||||
const inside = !!(active && root.contains(active));
|
||||
const key = inside ? active.dataset.focusKey : undefined;
|
||||
|
||||
rebuild();
|
||||
|
||||
// Nothing was taken from under the dealer: either they weren't in this sheet, or
|
||||
// the control they were standing on is still standing, and still has them. Only
|
||||
// a rebuild that destroyed the focused element has anything to put right, which
|
||||
// is why Clear — a button in the header that no redraw touches — keeps focus
|
||||
// through a redraw it asked for.
|
||||
if (!inside || active.isConnected) return;
|
||||
|
||||
const again = key === undefined ? null
|
||||
: [...root.querySelectorAll("[data-focus-key]")]
|
||||
.find(el => el.dataset.focusKey === key && !el.disabled);
|
||||
if (again) again.focus({ preventScroll: true });
|
||||
else if (whenGone) whenGone();
|
||||
},
|
||||
|
||||
topmost() {
|
||||
return this.open.length ? this.open[this.open.length - 1] : null;
|
||||
},
|
||||
@@ -130,12 +186,20 @@ const Modal = {
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (event.shiftKey && (active === first || !top.overlay.contains(active))) {
|
||||
// Where the dealer stands *in the ring*, which is a different question from
|
||||
// where they are on the screen. Three things inside these sheets are on purpose
|
||||
// not in the ring: the heading a sheet lands on, every card in the grid bar the
|
||||
// one the arrows are holding, and — for a moment after a rebuild — nothing at
|
||||
// all, because focus has been dropped on the body. -1 covers all three, and the
|
||||
// wrap has to work from there or the first Shift+Tab off a heading walks out of
|
||||
// the dialog it was supposed to be held inside.
|
||||
const at = focusable.indexOf(document.activeElement);
|
||||
|
||||
if (event.shiftKey && at <= 0) {
|
||||
event.preventDefault();
|
||||
last.focus({ preventScroll: true });
|
||||
} else if (!event.shiftKey && (active === last || !top.overlay.contains(active))) {
|
||||
} else if (!event.shiftKey && (at === -1 || at === focusable.length - 1)) {
|
||||
event.preventDefault();
|
||||
first.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
@@ -17,6 +17,31 @@ const Money = {
|
||||
return Math.round((Number(amount) || 0) * 100);
|
||||
},
|
||||
|
||||
// What a typed-in box is worth, or null if it isn't worth anything yet.
|
||||
//
|
||||
// The numeric fields are type="text" now, which is the whole point — the browser
|
||||
// hands over exactly what was typed instead of silently dropping the characters it
|
||||
// dislikes. The cost of that is that what was typed might not be a number, and
|
||||
// `Number("abc")` is NaN, which would go into state, get saved, and come back as
|
||||
// null on reload having taken the blind with it.
|
||||
//
|
||||
// So the reading is done in one place: empty is null, anything non-finite is null,
|
||||
// and a real number comes back rounded to the cent the rest of the tool works in.
|
||||
// Null rather than 0 because the fields it reads distinguish them — an unset
|
||||
// starting stack falls back to the table default, a zero one does not.
|
||||
//
|
||||
// Deliberately not clamped to >= 0 here. A negative blind is a different mistake
|
||||
// from a mistyped one, and the callers that care already clamp (the rake to 0..100,
|
||||
// the seat count to 2..24); folding it in would silently turn a typed "-5" into
|
||||
// something the dealer did not ask for.
|
||||
readAmount(text) {
|
||||
if (text == null) return null;
|
||||
const trimmed = String(text).trim();
|
||||
if (trimmed === "") return null;
|
||||
const value = Number(trimmed);
|
||||
return Number.isFinite(value) ? this.round(value) : null;
|
||||
},
|
||||
|
||||
fromCents(cents) {
|
||||
return cents / 100;
|
||||
},
|
||||
@@ -63,6 +88,44 @@ const Money = {
|
||||
// Anything the smallest chip can't make comes back as `remainder` rather than being
|
||||
// rounded away: a 50c share in a game whose smallest chip is a dollar is a real
|
||||
// problem for the dealer to solve, not a rounding error to hide.
|
||||
// The smallest chip the table has said it plays with, in cents. Zero when no
|
||||
// denominations are set, which means the tool has been told nothing about what
|
||||
// chips exist and shouldn't pretend otherwise.
|
||||
chipStepCents(legend) {
|
||||
const values = (legend || [])
|
||||
.map(chip => this.toCents(chip.value))
|
||||
.filter(cents => cents > 0);
|
||||
return values.length > 0 ? Math.min(...values) : 0;
|
||||
},
|
||||
|
||||
// Rounds an amount up to something the table can actually build out of its chips.
|
||||
//
|
||||
// Up rather than down or nearest: these are bet sizes, and a pot-sized bet that
|
||||
// has been shaved is no longer a pot-sized bet. Rounding up also can't drop an
|
||||
// amount below a legal minimum, which rounding the other way can.
|
||||
upToChip(amount, legend) {
|
||||
const step = this.chipStepCents(legend);
|
||||
if (step <= 0) return this.round(amount);
|
||||
const cents = this.toCents(amount);
|
||||
if (cents <= 0) return this.round(amount);
|
||||
return this.fromCents(Math.ceil(cents / step) * step);
|
||||
},
|
||||
|
||||
// And down, for money coming *out* of a pot rather than going into it.
|
||||
//
|
||||
// The rake is the one amount in this tool that leaves the table, so the direction
|
||||
// of the rounding is a decision about whose money it is. Down: a house that can't
|
||||
// make 40c out of its chips takes nothing rather than a dollar, and the players
|
||||
// keep the difference. Being wrong in the players' favour is the only way to be
|
||||
// wrong about this that nobody has to argue about.
|
||||
downToChip(amount, legend) {
|
||||
const step = this.chipStepCents(legend);
|
||||
if (step <= 0) return this.round(amount);
|
||||
const cents = this.toCents(amount);
|
||||
if (cents <= 0) return 0;
|
||||
return this.fromCents(Math.floor(cents / step) * step);
|
||||
},
|
||||
|
||||
chipBreakdown(amount, legend) {
|
||||
let cents = this.toCents(amount);
|
||||
if (cents <= 0) return { chips: [], remainder: 0 };
|
||||
@@ -111,11 +174,49 @@ const Money = {
|
||||
//
|
||||
// Only number boxes. A name or a note is a thing you edit, and selecting all of
|
||||
// it on focus would be the same mistake pointing the other way.
|
||||
//
|
||||
// What makes a box a number box is now the inputmode rather than the type: nothing
|
||||
// in the tool is type="number" any more (see the comment above the blinds in
|
||||
// TexasHoldem.cshtml). Both inputmodes count. "numeric" is the seat count and the
|
||||
// chip count — whole-number boxes that open pre-filled exactly like the money ones,
|
||||
// and which this used to catch through their old type. Reading only "decimal" would
|
||||
// have quietly dropped them, so it reads both.
|
||||
selectsOnFocus(field) {
|
||||
if (!field || field.tagName !== "INPUT") return false;
|
||||
const type = String(field.type || "").toLowerCase();
|
||||
if (type === "number") return true;
|
||||
return type === "text" && String(field.inputMode || "").toLowerCase() === "decimal";
|
||||
if (type !== "text") return false;
|
||||
const mode = String(field.inputMode || "").toLowerCase();
|
||||
return mode === "decimal" || mode === "numeric";
|
||||
},
|
||||
|
||||
// Would a wheel over this field change what it says?
|
||||
//
|
||||
// True only for a focused `<input type="number">`, which a browser treats as a
|
||||
// spinbutton and lets the wheel drive. Every other way of driving one is deliberate
|
||||
// — the arrow keys, the stepper arrows, typing — and all of them are left alone.
|
||||
// Scrolling is the one nobody means: the dealer is reaching for another panel and
|
||||
// the cursor happens to still be over a field they touched a moment ago.
|
||||
//
|
||||
// A predicate rather than a listener so the rule can be tested without a browser,
|
||||
// the same way selectsOnFocus is. main.js does the blurring.
|
||||
//
|
||||
// As of the move off type="number" this answers false for every field in the tool,
|
||||
// and that is the point rather than a bug: a text box with an inputmode is not a
|
||||
// spinbutton, so a wheel over it cannot change it and there is nothing left to
|
||||
// defend. Kept, tests and all, because it is the guard that makes that safe to rely
|
||||
// on — reintroduce a type="number" anywhere and this starts firing again on its own.
|
||||
wheelWouldEdit(field, activeElement) {
|
||||
// The tag check is belt and braces and known to be so: nothing but an <input>
|
||||
// carries type="number", so the line below already excludes every textarea,
|
||||
// select and button on the page. Deleting it passes every test in this file,
|
||||
// which was checked rather than assumed. It stays for symmetry with
|
||||
// selectsOnFocus above, not because it is load-bearing.
|
||||
if (!field || field.tagName !== "INPUT") return false;
|
||||
if (String(field.type || "").toLowerCase() !== "number") return false;
|
||||
// An unfocused spinbutton ignores the wheel, so there is nothing to defend and
|
||||
// blurring something that isn't focused would be a no-op with a side effect.
|
||||
return activeElement === field;
|
||||
},
|
||||
|
||||
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
|
||||
|
||||
@@ -24,6 +24,32 @@ const PotCalculator = {
|
||||
return entries.filter(e => !e.folded && e.remaining > this.EPSILON);
|
||||
},
|
||||
|
||||
// The most this player can be handed if they win the round outright.
|
||||
//
|
||||
// resolveRound caps the pool at the winner's own remaining contribution and then
|
||||
// takes min(remaining, cap) from every entry, dead money included — so the ceiling
|
||||
// is that sum. It is not the player's own contribution, which is what the wizard
|
||||
// used to print beside each name under the words "up to": with three players in
|
||||
// for $250 each the rows all read "up to $250" while the prompt above them said
|
||||
// $765 was in play, and $765 is what the winner was actually paid. A dealer who
|
||||
// trusted the row would have short-paid by two thirds.
|
||||
ceilingFor(entries, id) {
|
||||
const winner = entries.find(e => e.id === id);
|
||||
if (!winner || winner.remaining <= this.EPSILON) return 0;
|
||||
return Money.round(entries.reduce(
|
||||
(sum, e) => sum + Math.min(e.remaining, winner.remaining), 0));
|
||||
},
|
||||
|
||||
// The same number, but only when it is worth saying: a ceiling equal to everything
|
||||
// on the table is the prompt's own figure repeated once per row, which is ink that
|
||||
// carries no data. It appears exactly when somebody is in for less than the rest —
|
||||
// which is the one time a dealer needs to be told, because it is the hand where
|
||||
// picking that winner leaves a side pot still to award.
|
||||
bindingCeilingFor(entries, id) {
|
||||
const ceiling = this.ceilingFor(entries, id);
|
||||
return ceiling < this.totalPot(entries) - this.EPSILON ? ceiling : null;
|
||||
},
|
||||
|
||||
// Splits the money on the table into the main pot and any side pots, with who can
|
||||
// win each. Read-only — this is what a dealer says out loud before the showdown
|
||||
// ("main pot four-eighty, side pot ninety between seats 3 and 5"), which is the
|
||||
|
||||
@@ -76,6 +76,57 @@ const PotWizard = {
|
||||
},
|
||||
|
||||
renderStep() {
|
||||
this.renderStepInto();
|
||||
this.markOverflow();
|
||||
},
|
||||
|
||||
// Say when the list runs on past the bottom of the dialog.
|
||||
//
|
||||
// The body scrolls and the footer stays put, which is the right shape and was as far
|
||||
// as it went: nothing on screen said there was anything below. Measured on a Galaxy
|
||||
// S8, a three-way pot showed two names and cut the third off on a row boundary, just
|
||||
// under a hairline that reads as the end of the list. A dealer would have ticked the
|
||||
// two they could see. On the dialog that pays the table, a list that silently
|
||||
// continues is not a small thing.
|
||||
markOverflow() {
|
||||
const body = document.getElementById("potWizardBody");
|
||||
const modal = document.getElementById("potWizardModal");
|
||||
if (!body || !modal) return;
|
||||
|
||||
const update = () => {
|
||||
const below = body.scrollHeight - body.clientHeight - body.scrollTop;
|
||||
modal.classList.toggle("potWizardMoreBelow", below > 1);
|
||||
};
|
||||
if (!this.watchingOverflow) {
|
||||
body.addEventListener("scroll", update, { passive: true });
|
||||
window.addEventListener("resize", update);
|
||||
this.watchingOverflow = true;
|
||||
}
|
||||
update();
|
||||
// Again once the browser has laid the step out — on the first open the body has
|
||||
// no height yet, and an answer taken then is always "nothing below".
|
||||
requestAnimationFrame(update);
|
||||
},
|
||||
|
||||
// Confirming a round replaces the whole step, buttons and all, and the button it
|
||||
// replaces first is the Confirm the dealer just pressed — so on a table with side
|
||||
// pots, paying the main pot dropped focus on the body and the next Tab had to start
|
||||
// over. There is no keeping them where they were here: the control is gone and the
|
||||
// question has changed. The new question is where they should be, so focus lands on
|
||||
// the sentence asking it — "that side pot still leaves $240 unassigned, who's next
|
||||
// best?" — rather than on the button that answers it.
|
||||
renderStepInto() {
|
||||
const modal = document.getElementById("potWizardModal");
|
||||
Modal.preservingFocus(modal, () => this.paintStep(), () => {
|
||||
const asking = modal.querySelector("#potWizardBody .potWizardPrompt")
|
||||
|| document.getElementById("potWizardTitle");
|
||||
if (!asking) return;
|
||||
asking.tabIndex = -1;
|
||||
asking.focus({ preventScroll: true });
|
||||
});
|
||||
},
|
||||
|
||||
paintStep() {
|
||||
// Auto-resolve any round where only one player is left standing — nothing to ask.
|
||||
let contenders = PotCalculator.getContenders(this.entries);
|
||||
while (contenders.length === 1) {
|
||||
@@ -87,6 +138,11 @@ const PotWizard = {
|
||||
|
||||
const body = document.getElementById("potWizardBody");
|
||||
body.innerHTML = "";
|
||||
// The footer is a sibling of the body, outside the scroll — see the note in
|
||||
// TexasHoldem.cshtml. Cleared alongside it so a step can never inherit the
|
||||
// previous step's buttons.
|
||||
const footer = this.footer();
|
||||
footer.innerHTML = "";
|
||||
if (this.log.length === 0) body.appendChild(this.renderBreakdown());
|
||||
body.appendChild(this.renderLog());
|
||||
|
||||
@@ -119,13 +175,25 @@ const PotWizard = {
|
||||
const round = document.createElement("div");
|
||||
round.className = "potWizardRound";
|
||||
|
||||
const showdown = this.readShowdown(contenders);
|
||||
const decided = showdown && showdown.complete ? new Set(showdown.winners) : null;
|
||||
const handById = new Map((showdown ? showdown.players : []).map(p => [p.id, p.hand]));
|
||||
|
||||
const prompt = document.createElement("p");
|
||||
prompt.className = "potWizardPrompt";
|
||||
prompt.innerHTML = this.log.length === 0
|
||||
? `Who won the hand? <strong>${Money.format(remaining)}</strong> is in play.`
|
||||
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> unassigned — who's next best?`;
|
||||
prompt.innerHTML = decided
|
||||
// It has read the hands, so it stops asking a question it knows the answer
|
||||
// to. The list stays — the dealer confirms, and a hand entered wrong is
|
||||
// corrected by unticking rather than by starting over.
|
||||
? `<strong>${Money.format(remaining)}</strong> in play.`
|
||||
: this.log.length === 0
|
||||
? `Who won the hand? <strong>${Money.format(remaining)}</strong> is in play.`
|
||||
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> unassigned — who's next best?`;
|
||||
round.appendChild(prompt);
|
||||
|
||||
const verdict = this.renderVerdict(showdown, contenders);
|
||||
if (verdict) round.appendChild(verdict);
|
||||
|
||||
const choices = document.createElement("div");
|
||||
choices.className = "potWizardChoices";
|
||||
contenders.forEach(c => {
|
||||
@@ -135,6 +203,10 @@ const PotWizard = {
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.value = c.id;
|
||||
if (decided && decided.has(c.id)) {
|
||||
checkbox.checked = true;
|
||||
label.classList.add("chosen");
|
||||
}
|
||||
// The row is the target, not the box — so the row has to look chosen too.
|
||||
checkbox.addEventListener("change", () => {
|
||||
label.classList.toggle("chosen", checkbox.checked);
|
||||
@@ -149,12 +221,34 @@ const PotWizard = {
|
||||
name.textContent = this.nameById.get(c.id);
|
||||
text.appendChild(name);
|
||||
|
||||
const upTo = document.createElement("span");
|
||||
upTo.className = "potWizardChoiceUpTo";
|
||||
upTo.textContent = `up to ${Money.format(c.remaining)}`;
|
||||
text.appendChild(upTo);
|
||||
// How this hand was read, under the name — on every row rather than only
|
||||
// the winning one, because a dealer checking the tool's answer is reading
|
||||
// the losers' hands, and a row that says nothing is a row they have to work
|
||||
// out for themselves. It takes the line the ceiling used to have; the
|
||||
// ceiling moves out beside the name, which is where the phone layout had
|
||||
// already put it to save exactly this line.
|
||||
const read = handById.get(c.id);
|
||||
if (read) {
|
||||
const ranked = document.createElement("span");
|
||||
ranked.className = "potWizardChoiceHand";
|
||||
ranked.textContent = HandRank.describe(read);
|
||||
text.appendChild(ranked);
|
||||
}
|
||||
label.appendChild(text);
|
||||
|
||||
// What this row costs if it is the one ticked — but only on the rows where
|
||||
// that is less than the figure in the prompt above. On a pot everybody
|
||||
// covered it was the same number three times, and it was the wrong number:
|
||||
// each player's own stake rather than what winning pays them. See
|
||||
// PotCalculator.bindingCeilingFor.
|
||||
const ceiling = PotCalculator.bindingCeilingFor(this.entries, c.id);
|
||||
if (ceiling !== null) {
|
||||
const upTo = document.createElement("span");
|
||||
upTo.className = "potWizardChoiceUpTo";
|
||||
upTo.textContent = `up to ${Money.format(ceiling)}`;
|
||||
label.appendChild(upTo);
|
||||
}
|
||||
|
||||
// Cards sit apart from the name rather than inside it, so a dealer reading
|
||||
// a row across the table sees the hand, not a sentence.
|
||||
const cards = this.cardsById.get(c.id) || [];
|
||||
@@ -182,7 +276,11 @@ const PotWizard = {
|
||||
|
||||
const confirmBtn = document.createElement("button");
|
||||
confirmBtn.className = "holdemBtn holdemBtnAccent";
|
||||
confirmBtn.textContent = "Confirm Winner(s)";
|
||||
confirmBtn.textContent = decided
|
||||
? (showdown.winners.length === 1
|
||||
? `Pay ${this.nameById.get(showdown.winners[0])}`
|
||||
: "Split the pot")
|
||||
: "Confirm Winner(s)";
|
||||
confirmBtn.addEventListener("click", () => {
|
||||
const selected = Array.from(choices.querySelectorAll("input:checked")).map(i => i.value);
|
||||
if (selected.length === 0) return;
|
||||
@@ -191,9 +289,91 @@ const PotWizard = {
|
||||
this.entries = result.updatedEntries;
|
||||
this.renderStep();
|
||||
});
|
||||
round.appendChild(confirmBtn);
|
||||
|
||||
body.appendChild(round);
|
||||
footer.appendChild(confirmBtn);
|
||||
},
|
||||
|
||||
// How the tool reads the hands still in for this round of the pot.
|
||||
//
|
||||
// Asked per round rather than once for the whole hand, because a side pot has its
|
||||
// own contenders: the player who won the main pot is out of the question for the
|
||||
// next one, and the best hand among who is left is a different answer.
|
||||
//
|
||||
// Only when the dealer asked to track the board — everything here is off by default
|
||||
// and the wizard behaves exactly as it always has.
|
||||
readShowdown(contenders) {
|
||||
if (!this.state.trackBoard || typeof HandRank === "undefined") return null;
|
||||
return HandRank.showdown(this.state.board, contenders.map(c => ({
|
||||
id: c.id,
|
||||
cards: this.cardsById.get(c.id) || [],
|
||||
})));
|
||||
},
|
||||
|
||||
// What the winners would actually be paid if this round were confirmed now.
|
||||
//
|
||||
// Worked out from the same function that does the paying rather than from a second
|
||||
// one that agrees with it today — a preview that can drift from the payout is worse
|
||||
// than no preview, because it is the number the dealer says out loud.
|
||||
previewPayout(winnerIds) {
|
||||
try {
|
||||
const result = PotCalculator.resolveRound(this.entries, this.orderFromButton(winnerIds));
|
||||
return result.perWinner || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// "Alexandra wins $25 with a full house, kings full of threes."
|
||||
//
|
||||
// The whole point of the feature, in one line: who won and how much. It goes above
|
||||
// the list rather than replacing it, because the dealer still confirms — the tool
|
||||
// reads the cards, a person still says the pot is good.
|
||||
renderVerdict(showdown, contenders) {
|
||||
if (!showdown) return null;
|
||||
|
||||
const line = document.createElement("p");
|
||||
line.className = "potWizardVerdict";
|
||||
|
||||
if (!showdown.boardComplete) {
|
||||
line.classList.add("potWizardVerdictUnsure");
|
||||
line.textContent = "The board isn't all five cards yet, so pick the winner yourself — or fill it in on the felt and this will read it.";
|
||||
return line;
|
||||
}
|
||||
if (showdown.duplicated) {
|
||||
line.classList.add("potWizardVerdictUnsure");
|
||||
line.textContent = "The same card is in two places, so this can't be read. Check the cards on the felt.";
|
||||
return line;
|
||||
}
|
||||
if (!showdown.complete) {
|
||||
const missing = showdown.unknown.map(id => this.nameById.get(id)).filter(Boolean);
|
||||
line.classList.add("potWizardVerdictUnsure");
|
||||
line.textContent = missing.length
|
||||
? `${this.listOf(missing)} ${missing.length === 1 ? "hasn't" : "haven't"} turned over, so who won is still yours to say.`
|
||||
: "Not enough cards to read this one.";
|
||||
return line;
|
||||
}
|
||||
|
||||
const paid = this.previewPayout(showdown.winners);
|
||||
const amount = paid.length ? Money.format(paid[0].amount) : "";
|
||||
const names = showdown.winners.map(id => this.nameById.get(id)).filter(Boolean);
|
||||
const hand = showdown.players.find(p => p.id === showdown.winners[0]);
|
||||
const described = HandRank.phrase(hand && hand.hand);
|
||||
|
||||
line.classList.add("potWizardVerdictSure");
|
||||
line.textContent = names.length === 1
|
||||
? `${names[0]} wins ${amount} with ${described}.`
|
||||
: `${this.listOf(names)} split it, ${amount} each, with ${described}.`;
|
||||
return line;
|
||||
},
|
||||
|
||||
listOf(names) {
|
||||
if (names.length <= 1) return names[0] || "";
|
||||
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
||||
},
|
||||
|
||||
// The row of buttons that never scrolls away.
|
||||
footer() {
|
||||
return document.getElementById("potWizardFooter");
|
||||
},
|
||||
|
||||
recordRound(result) {
|
||||
@@ -232,6 +412,24 @@ const PotWizard = {
|
||||
return wrap;
|
||||
},
|
||||
|
||||
// What the house takes out of this pot, and what is left to push.
|
||||
//
|
||||
// Worked out here rather than at the end so the dealer sees it before anything
|
||||
// moves: the summary is the last chance to disagree with the arithmetic, and a
|
||||
// rake that only appeared in the hand log afterwards would be the tool taking
|
||||
// money quietly.
|
||||
rakeSplit() {
|
||||
const gross = Array.from(this.totals.entries()).map(([id, amount]) => ({
|
||||
id,
|
||||
label: this.playerLabelById.get(id),
|
||||
amount,
|
||||
}));
|
||||
const pot = gross.reduce((sum, p) => sum + p.amount, 0);
|
||||
const sawFlop = (this.state.street || "preflop") !== "preflop";
|
||||
const rake = HoldemState.rakeOn(this.state, pot, sawFlop);
|
||||
return { pot, sawFlop, ...HoldemState.applyRake(gross, rake) };
|
||||
},
|
||||
|
||||
renderSummary() {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "potWizardSummary";
|
||||
@@ -240,6 +438,20 @@ const PotWizard = {
|
||||
heading.textContent = "Final Payouts";
|
||||
wrap.appendChild(heading);
|
||||
|
||||
const split = this.rakeSplit();
|
||||
const rakeSet = Number((this.state.rake || {}).percent) > 0;
|
||||
if (rakeSet && this.totals.size > 0) {
|
||||
const line = document.createElement("p");
|
||||
line.className = "potWizardRake";
|
||||
const cap = Number(this.state.rake.cap) > 0 ? `, capped at ${Money.format(this.state.rake.cap)}` : "";
|
||||
line.textContent = split.rake > 0
|
||||
? `House takes ${Money.format(split.rake)} of ${Money.format(split.pot)} — ${this.state.rake.percent}%${cap}.`
|
||||
: split.sawFlop
|
||||
? `No rake on this one — ${this.state.rake.percent}% of ${Money.format(split.pot)} doesn't make a chip the table has.`
|
||||
: "No flop, no drop — nothing taken from a pot that ended before the flop.";
|
||||
wrap.appendChild(line);
|
||||
}
|
||||
|
||||
if (this.totals.size === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "fieldHint";
|
||||
@@ -248,7 +460,7 @@ const PotWizard = {
|
||||
} else {
|
||||
const legend = (this.state && this.state.chipLegend) || [];
|
||||
const list = document.createElement("ul");
|
||||
for (const [id, amount] of this.totals.entries()) {
|
||||
for (const { id, amount } of split.payouts) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
|
||||
|
||||
@@ -275,13 +487,12 @@ const PotWizard = {
|
||||
applyBtn.className = "holdemBtn holdemBtnAccent";
|
||||
applyBtn.textContent = "Start Next Hand";
|
||||
applyBtn.addEventListener("click", () => {
|
||||
const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
|
||||
id,
|
||||
label: this.playerLabelById.get(id),
|
||||
amount,
|
||||
}));
|
||||
// Recomputed rather than reused from the render: the same numbers, but
|
||||
// taken at the moment the dealer commits rather than when they last
|
||||
// looked at the screen.
|
||||
const paid = this.rakeSplit();
|
||||
this.close();
|
||||
this.onFinished(payouts);
|
||||
this.onFinished(paid.payouts, { rake: paid.rake, pot: paid.pot });
|
||||
});
|
||||
actions.appendChild(applyBtn);
|
||||
|
||||
@@ -300,12 +511,10 @@ const PotWizard = {
|
||||
const note = document.createElement("p");
|
||||
note.className = "fieldHint potWizardPendingNote";
|
||||
note.textContent = "Nothing has been paid out yet. Start Next Hand pushes the pot and logs the hand.";
|
||||
wrap.appendChild(actions);
|
||||
wrap.appendChild(note);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
wrap.appendChild(actions);
|
||||
this.footer().appendChild(actions);
|
||||
return wrap;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -21,6 +21,27 @@ const QuickSplit = {
|
||||
mode: "even",
|
||||
entries: [],
|
||||
|
||||
// The first count the preset chips don't cover. Below it the chips are the control;
|
||||
// at it and above, one chip opens a stepper and the stepper is the control. The
|
||||
// number is never stated by two of them at once — it used to be stated by three,
|
||||
// with chip "2" lit gold, "2 ways" in a bar under it, and "Each of 2 players gets"
|
||||
// in the panel above.
|
||||
overflowFrom() {
|
||||
return this.WAY_CHOICES[this.WAY_CHOICES.length - 1] + 1;
|
||||
},
|
||||
|
||||
clampWays(count) {
|
||||
return Math.max(this.WAY_CHOICES[0], Math.min(this.MAX_WAYS, count));
|
||||
},
|
||||
|
||||
// A pot is chopped between the players who can win it, and there is no such thing
|
||||
// as chopping it between more of them than there are. This used to read `Math.max`,
|
||||
// which let a side pot only two players were eligible for be split ten ways at $12
|
||||
// each — an answer to a question nobody at the table could have asked.
|
||||
chopCeiling(contenderCount) {
|
||||
return Math.max(2, Math.min(contenderCount, this.MAX_WAYS));
|
||||
},
|
||||
|
||||
mount() {
|
||||
this.potInput = document.getElementById("potInput");
|
||||
this.clearPotBtn = document.getElementById("clearPotBtn");
|
||||
@@ -86,10 +107,11 @@ const QuickSplit = {
|
||||
renderWayChoices() {
|
||||
const row = document.getElementById("waysChoices");
|
||||
row.innerHTML = "";
|
||||
const beyond = this.ways >= this.overflowFrom();
|
||||
|
||||
this.WAY_CHOICES.forEach(count => {
|
||||
row.appendChild(
|
||||
this.makeChoiceButton(String(count), this.ways === count, () => {
|
||||
this.makeChoiceButton(String(count), !beyond && this.ways === count, () => {
|
||||
this.ways = count;
|
||||
this.renderWayChoices();
|
||||
this.update();
|
||||
@@ -97,34 +119,65 @@ const QuickSplit = {
|
||||
);
|
||||
});
|
||||
|
||||
// Beyond a five-way chop it's rare enough to hide behind a stepper rather than
|
||||
// spend a row of buttons on it.
|
||||
const more = document.createElement("div");
|
||||
more.className = "waysStepper";
|
||||
// Beyond a five-way chop it's rare enough not to be worth a chip each. One more
|
||||
// chip is the door to the rest; it reads "6+" whatever the count behind it is,
|
||||
// because the stepper is where the count is written. Tapping it while it is
|
||||
// already open does nothing — it is the fifth option of a group, not a toggle.
|
||||
const door = this.makeChoiceButton(`${this.overflowFrom()}+`, beyond, () => {
|
||||
if (beyond) return;
|
||||
this.ways = this.overflowFrom();
|
||||
this.renderWayChoices();
|
||||
this.update();
|
||||
});
|
||||
door.setAttribute("aria-label", `${this.overflowFrom()} or more`);
|
||||
row.appendChild(door);
|
||||
|
||||
const minus = this.makeStepButton("−", () => this.stepWays(-1));
|
||||
const plus = this.makeStepButton("+", () => this.stepWays(1));
|
||||
const readout = document.createElement("span");
|
||||
readout.className = "waysReadout";
|
||||
readout.textContent = `${this.ways} ways`;
|
||||
|
||||
more.appendChild(minus);
|
||||
more.appendChild(readout);
|
||||
more.appendChild(plus);
|
||||
row.appendChild(more);
|
||||
if (beyond) row.appendChild(this.buildWaysStepper());
|
||||
},
|
||||
|
||||
makeStepButton(label, onPick) {
|
||||
// Six to twelve, which is exactly what the door promised. The floor is 6 rather
|
||||
// than 2 because the chips below it are one tap away and already on screen — and
|
||||
// because stepping down out of the stepper would pull the stepper out from under
|
||||
// the finger that was pressing it.
|
||||
buildWaysStepper() {
|
||||
const stepper = document.createElement("div");
|
||||
stepper.className = "waysStepper";
|
||||
stepper.setAttribute("role", "group");
|
||||
stepper.setAttribute("aria-label", "How many ways");
|
||||
|
||||
stepper.appendChild(this.makeStepButton("−", () => this.stepWays(-1), {
|
||||
label: "One fewer way",
|
||||
disabled: this.ways <= this.overflowFrom(),
|
||||
}));
|
||||
|
||||
const readout = document.createElement("span");
|
||||
readout.className = "waysReadout";
|
||||
readout.textContent = String(this.ways);
|
||||
stepper.appendChild(readout);
|
||||
|
||||
stepper.appendChild(this.makeStepButton("+", () => this.stepWays(1), {
|
||||
label: "One more way",
|
||||
disabled: this.ways >= this.MAX_WAYS,
|
||||
}));
|
||||
|
||||
return stepper;
|
||||
},
|
||||
|
||||
makeStepButton(label, onPick, options) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "stepBtn";
|
||||
btn.textContent = label;
|
||||
if (options && options.label) btn.setAttribute("aria-label", options.label);
|
||||
// A step that can't happen says so. Both ends of both steppers on this screen
|
||||
// used to look exactly like the middle and swallow the tap in silence.
|
||||
btn.disabled = !!(options && options.disabled);
|
||||
btn.addEventListener("click", onPick);
|
||||
return btn;
|
||||
},
|
||||
|
||||
stepWays(delta) {
|
||||
this.ways = Math.max(2, Math.min(this.MAX_WAYS, this.ways + delta));
|
||||
this.ways = this.clampWays(this.ways + delta);
|
||||
this.renderWayChoices();
|
||||
this.update();
|
||||
},
|
||||
@@ -197,6 +250,86 @@ const QuickSplit = {
|
||||
});
|
||||
},
|
||||
|
||||
// Chopping one of the pots listed above, without leaving the screen that worked it
|
||||
// out. Starts closed and at as many ways as there are players who can win the pot,
|
||||
// which is the common case; the stepper covers a chop between fewer of them, which
|
||||
// is the other one.
|
||||
//
|
||||
// The per-pot ways live on the row rather than in `this.ways`, so opening a chop on
|
||||
// the main pot doesn't change what the side pot below it is saying.
|
||||
addChopControl(row, amount, contenderCount) {
|
||||
const ceiling = this.chopCeiling(contenderCount);
|
||||
let ways = ceiling;
|
||||
let open = false;
|
||||
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "unevenChopToggle";
|
||||
|
||||
const detail = document.createElement("div");
|
||||
detail.className = "unevenChopDetail";
|
||||
detail.hidden = true;
|
||||
|
||||
const draw = () => {
|
||||
toggle.textContent = open ? "Hide chop" : "Chop";
|
||||
toggle.setAttribute("aria-expanded", String(open));
|
||||
detail.hidden = !open;
|
||||
if (!open) return;
|
||||
|
||||
const result = SplitCalculator.split(amount, ways, this.chip);
|
||||
detail.innerHTML = "";
|
||||
|
||||
// Two eligible players is two ways and nothing else, so the stepper is left
|
||||
// out rather than shown with both ends dead — which is the common shape, a
|
||||
// side pot usually coming down to the two who could still call.
|
||||
if (ceiling > 2) {
|
||||
const stepper = document.createElement("div");
|
||||
stepper.className = "unevenChopWays";
|
||||
stepper.appendChild(this.makeStepButton("−", () => {
|
||||
if (ways > 2) { ways -= 1; draw(); }
|
||||
}, { label: "One fewer way", disabled: ways <= 2 }));
|
||||
const label = document.createElement("span");
|
||||
label.className = "unevenChopWaysLabel";
|
||||
label.textContent = `${ways} ways`;
|
||||
stepper.appendChild(label);
|
||||
stepper.appendChild(this.makeStepButton("+", () => {
|
||||
if (ways < ceiling) { ways += 1; draw(); }
|
||||
}, { label: "One more way", disabled: ways >= ceiling }));
|
||||
detail.appendChild(stepper);
|
||||
}
|
||||
|
||||
const each = document.createElement("p");
|
||||
each.className = "unevenChopEach";
|
||||
each.textContent = `${Money.format(result.each)} each`;
|
||||
detail.appendChild(each);
|
||||
|
||||
// Same wording as the main split, because it is the same rule and a dealer
|
||||
// shouldn't have to notice they're reading a different screen.
|
||||
if (result.valid && result.oddChips > 0) {
|
||||
const odd = document.createElement("p");
|
||||
odd.className = "unevenChopNote";
|
||||
odd.textContent = result.oddChips === 1
|
||||
? `plus one odd ${Money.format(result.chip)} chip to the first player left of the button`
|
||||
: `plus ${result.oddChips} odd ${Money.format(result.chip)} chips — one each to the ` +
|
||||
`first ${result.oddChips} players left of the button`;
|
||||
detail.appendChild(odd);
|
||||
}
|
||||
if (result.valid && result.dust > 0) {
|
||||
const dust = document.createElement("p");
|
||||
dust.className = "unevenChopNote unevenChopDust";
|
||||
dust.textContent =
|
||||
`${Money.format(result.dust)} doesn't divide into ${Money.format(result.chip)} chips — ` +
|
||||
`pick a smaller denomination.`;
|
||||
detail.appendChild(dust);
|
||||
}
|
||||
};
|
||||
|
||||
toggle.addEventListener("click", () => { open = !open; draw(); });
|
||||
draw();
|
||||
row.appendChild(toggle);
|
||||
row.appendChild(detail);
|
||||
},
|
||||
|
||||
updateUneven() {
|
||||
const players = this.entries
|
||||
.map((entry, index) => ({ id: `P${index + 1}`, contribution: Number(entry.amount) || 0, folded: entry.folded }))
|
||||
@@ -218,7 +351,7 @@ const QuickSplit = {
|
||||
}
|
||||
empty.hidden = true;
|
||||
|
||||
const addRow = (label, amount, who, className) => {
|
||||
const addRow = (label, amount, who, className, chopWays) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = `unevenPot ${className || ""}`;
|
||||
const name = document.createElement("span");
|
||||
@@ -234,9 +367,19 @@ const QuickSplit = {
|
||||
row.appendChild(value);
|
||||
row.appendChild(eligible);
|
||||
host.appendChild(row);
|
||||
|
||||
// Two players tying for a side pot is ordinary, and working out each one's
|
||||
// share meant copying the amount into the other mode by hand — retyping a
|
||||
// number the screen had just worked out, which is a transcription error
|
||||
// waiting to happen with money on it. The chop happens here instead.
|
||||
//
|
||||
// Only offered where it means something: a pot with at least two people
|
||||
// who can win it. Not the uncalled row, which goes back to one player, and
|
||||
// not the total.
|
||||
if (chopWays >= 2) this.addChopControl(row, amount, chopWays);
|
||||
};
|
||||
|
||||
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", ")));
|
||||
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", "), "", pot.eligible.length));
|
||||
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${ret.id}`, "unevenPotReturn"));
|
||||
|
||||
const total = players.reduce((sum, p) => sum + p.contribution, 0);
|
||||
@@ -284,4 +427,10 @@ const QuickSplit = {
|
||||
},
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => QuickSplit.mount());
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("DOMContentLoaded", () => QuickSplit.mount());
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = QuickSplit;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,26 @@
|
||||
// The long-press menu on a seat. Everything a dealer does to one player mid-hand,
|
||||
// one press and one tap away, without opening the full editor and without hunting
|
||||
// for whose turn it is in the action bar.
|
||||
//
|
||||
// It grew to eleven of those, all drawn the same: eleven full-width bars filling 85%
|
||||
// of every screen it was measured on, scrolling on all of them, with Cancel below the
|
||||
// fold on all of them too — 252px of it hidden on a 360px phone, 448px in landscape.
|
||||
// Apple's action-sheet guidance puts the comfortable number at three to five and says
|
||||
// not to let one scroll at all, and a dealer folding a player mid-hand is reading
|
||||
// eleven choices to find the one they came for.
|
||||
//
|
||||
// The eleven have not been cut — every one of them is the only route to something, and
|
||||
// "Blinds follow the button again" is the only route in the whole tool to clearing an
|
||||
// override. They are sorted by *when* instead. A seat action is either something you
|
||||
// do while the cards are in the air, or housekeeping between hands, and those two want
|
||||
// different targets: the first wants a bar you can hit without looking, the second
|
||||
// wants to be findable and out of the way. So the hand keeps the full-width bars and
|
||||
// everything else goes to a two-column grid underneath it.
|
||||
//
|
||||
// The captions are not decoration. A half-width tile reading "Small blind" is a label,
|
||||
// and the puck already wears one of those; under "Give this seat" it is a verb again.
|
||||
// That is what lets three of the longest labels here lose ten characters each and
|
||||
// still fit a half tile on one line, which is the height the caption pays back.
|
||||
const SeatMenu = {
|
||||
state: null,
|
||||
hooks: null,
|
||||
@@ -8,6 +28,14 @@ const SeatMenu = {
|
||||
|
||||
mode: null,
|
||||
|
||||
// The two groups that sit under the hand, in the order they are drawn. `hand` has
|
||||
// no caption because it needs none — it is the top of the sheet, and its actions
|
||||
// are the two the sheet is coloured for.
|
||||
GROUPS: [
|
||||
{ name: "player", caption: "This player" },
|
||||
{ name: "position", caption: "Give this seat" },
|
||||
],
|
||||
|
||||
open(state, hooks, seatIndex) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
@@ -26,29 +54,68 @@ const SeatMenu = {
|
||||
|
||||
// Only the actions that make sense for this seat right now — a folded player has
|
||||
// nothing to go all-in with, and an empty chair has nothing to fold.
|
||||
//
|
||||
// `group` is when you do it, not what it touches: "hand" is what a dealer reaches
|
||||
// for with cards on the table, and it is the only group drawn at full width.
|
||||
actionsFor(player, index) {
|
||||
const actions = [];
|
||||
const dealtIn = player.inHand;
|
||||
const live = dealtIn && !player.folded;
|
||||
|
||||
if (live) {
|
||||
actions.push({ label: "Fold", className: "menuDanger", run: () => this.hooks.fold(index) });
|
||||
actions.push({ label: "All-in", className: "menuAccent", run: () => this.hooks.allIn(index) });
|
||||
actions.push({ group: "hand", label: "Fold", className: "menuDanger", run: () => this.hooks.fold(index) });
|
||||
actions.push({ group: "hand", label: "All-in", className: "menuAccent", run: () => this.hooks.allIn(index) });
|
||||
} else if (dealtIn && player.folded) {
|
||||
actions.push({ label: "Un-fold", run: () => this.hooks.unfold(index) });
|
||||
actions.push({ group: "hand", label: "Un-fold", run: () => this.hooks.unfold(index) });
|
||||
}
|
||||
|
||||
// An empty chair with people waiting: the dealer taps the chair somebody is
|
||||
// about to sit in, which is what their hand is doing anyway. Only the front of
|
||||
// the queue is offered, for the same reason the panel only offers them — a list
|
||||
// that isn't kept in order is just a pile of names.
|
||||
//
|
||||
// In the hand group rather than with the housekeeping: the queue moves when a
|
||||
// seat opens, which is between two hands with people watching, not later.
|
||||
const waiting = (this.state.waitingList || [])[0];
|
||||
const emptyChair = !HoldemState.seatInPlay(player);
|
||||
if (waiting && emptyChair) {
|
||||
actions.push({
|
||||
group: "hand",
|
||||
label: `Seat ${waiting.name}`,
|
||||
className: "menuAccent",
|
||||
run: () => this.hooks.seatWaiting(waiting.id, index),
|
||||
});
|
||||
}
|
||||
|
||||
// The most common interruption in a cash game, and until now it meant opening
|
||||
// the editor and retyping a stack from memory.
|
||||
actions.push({ label: "Add chips…", run: () => this.hooks.addChips(index) });
|
||||
actions.push({ group: "player", label: "Add chips…", run: () => this.hooks.addChips(index) });
|
||||
|
||||
actions.push({
|
||||
group: "player",
|
||||
label: dealtIn ? "Sit out" : "Deal in",
|
||||
run: () => this.hooks.toggleDealtIn(index),
|
||||
});
|
||||
|
||||
// Leaving for the night, which is a different thing from sitting out a hand:
|
||||
// the chips go back over the rail and the chair opens. Offered only for a seat
|
||||
// that has somebody in it, since an empty chair has nobody to pay.
|
||||
if (HoldemState.seatInPlay(player)) {
|
||||
actions.push({ group: "player", label: "Cash out…", run: () => this.hooks.cashOut(index) });
|
||||
}
|
||||
|
||||
// Changing chairs was drag-and-drop only, and HTML5 drag events are never
|
||||
// generated from a touch screen — so on the device this tool is mostly used on,
|
||||
// the "drag to swap seats" the hint advertised did nothing at all. This is the
|
||||
// same move, by the route a thumb can actually take.
|
||||
if (this.state.seats.length > 1) {
|
||||
actions.push({ group: "player", label: "Move to…", run: () => this.openMovePicker() });
|
||||
}
|
||||
|
||||
actions.push({ group: "player", label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
||||
|
||||
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
|
||||
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
|
||||
actions.push({ group: "position", label: "The button", run: () => this.hooks.makeDealer(index) });
|
||||
}
|
||||
|
||||
// Naming a blind by hand. Only offered while nothing but the blinds are in —
|
||||
@@ -59,25 +126,25 @@ const SeatMenu = {
|
||||
if (dealtIn && Betting.onlyBlindsIn(this.state)) {
|
||||
const blinds = HoldemState.getBlindSeats(this.state);
|
||||
if (blinds.sbIndex !== index) {
|
||||
actions.push({ label: "Make small blind", run: () => this.hooks.assignBlind(index, "sb") });
|
||||
actions.push({ group: "position", label: "Small blind", run: () => this.hooks.assignBlind(index, "sb") });
|
||||
}
|
||||
if (blinds.bbIndex !== index) {
|
||||
actions.push({ label: "Make big blind", run: () => this.hooks.assignBlind(index, "bb") });
|
||||
actions.push({ group: "position", label: "Big blind", run: () => this.hooks.assignBlind(index, "bb") });
|
||||
}
|
||||
// Not about this seat at all — it puts the whole table back on automatic —
|
||||
// so it spans the grid instead of sitting in it as a fourth peer. It is
|
||||
// still here because this menu is the only place in the tool that offers
|
||||
// it, and it belongs beside the two overrides that caused it.
|
||||
if (this.state.blindOverride) {
|
||||
actions.push({ label: "Blinds follow the button again", run: () => this.hooks.clearBlindOverride() });
|
||||
actions.push({
|
||||
group: "position",
|
||||
label: "Blinds follow the button again",
|
||||
wide: true,
|
||||
run: () => this.hooks.clearBlindOverride(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Changing chairs was drag-and-drop only, and HTML5 drag events are never
|
||||
// generated from a touch screen — so on the device this tool is mostly used on,
|
||||
// the "drag to swap seats" the hint advertised did nothing at all. This is the
|
||||
// same move, by the route a thumb can actually take.
|
||||
if (this.state.seats.length > 1) {
|
||||
actions.push({ label: "Move to…", run: () => this.openMovePicker() });
|
||||
}
|
||||
|
||||
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
||||
return actions;
|
||||
},
|
||||
|
||||
@@ -95,48 +162,117 @@ const SeatMenu = {
|
||||
this.render();
|
||||
},
|
||||
|
||||
closeMovePicker() {
|
||||
this.mode = null;
|
||||
this.render();
|
||||
},
|
||||
|
||||
button(label, className) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = className;
|
||||
btn.textContent = label;
|
||||
return btn;
|
||||
},
|
||||
|
||||
// A row of tiles. Two columns on a phone: a seat action's label is a verb and a
|
||||
// short one, and stacking nine of them full width was most of the height the sheet
|
||||
// was spending.
|
||||
grid(extra) {
|
||||
const el = document.createElement("div");
|
||||
el.className = `seatMenuGrid ${extra || ""}`.trim();
|
||||
return el;
|
||||
},
|
||||
|
||||
// The two tiers, as boxes rather than as a run of siblings.
|
||||
//
|
||||
// Upright they are what they always were: one column, in the order they are
|
||||
// written. The wrappers exist for the phone on its side, where the sheet has 306px
|
||||
// of height and 616 of width and the tiers stop being two halves of a column and
|
||||
// become two columns — see the `max-height: 560px` block in holdem.css. That is
|
||||
// only expressible if each tier is one box the layout can address; as five loose
|
||||
// children they can only ever stack.
|
||||
tier(name) {
|
||||
const el = document.createElement("div");
|
||||
el.className = name;
|
||||
return el;
|
||||
},
|
||||
|
||||
caption(text) {
|
||||
const el = document.createElement("p");
|
||||
el.className = "seatMenuCaption";
|
||||
el.textContent = text;
|
||||
return el;
|
||||
},
|
||||
|
||||
// Stepping into the move picker throws the whole list away, and the first thing it
|
||||
// throws away is the "Move this seat" the dealer just pressed — which left focus on
|
||||
// the body, inside a sheet that had just changed into a different sheet. The
|
||||
// heading is where to be: in move mode it has stopped saying who this is and
|
||||
// started saying what is being asked — "Move Alexandra to…".
|
||||
render() {
|
||||
Modal.preservingFocus(document.getElementById("seatMenuOverlay"), () => this.paint(), () => {
|
||||
const title = document.getElementById("seatMenuTitle");
|
||||
if (title) title.focus({ preventScroll: true });
|
||||
});
|
||||
},
|
||||
|
||||
paint() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
const title = document.getElementById("seatMenuTitle");
|
||||
const subtitle = document.getElementById("seatMenuSubtitle");
|
||||
const list = document.getElementById("seatMenuActions");
|
||||
const cancel = document.getElementById("seatMenuCancelBtn");
|
||||
list.innerHTML = "";
|
||||
|
||||
if (this.mode === "move") {
|
||||
title.textContent = `Move ${HoldemState.displayName(player, this.seatIndex)} to…`;
|
||||
subtitle.textContent = "Their chips, cards and bet go with them. The button and the blinds stay on the chair.";
|
||||
// Opening the picker used to be a one-way door: it replaced the sheet, and
|
||||
// the only button left closed the whole menu. The sheet's own back is the
|
||||
// button already at the thumb end, so it says Back while there is somewhere
|
||||
// to go back to.
|
||||
cancel.textContent = "Back";
|
||||
const keeping = this.tier("seatMenuKeeping");
|
||||
// Chairs, not verbs: "Seat 3 — Alexandra" sets 116px where "Sit out" sets
|
||||
// about 50, so this grid asks for a wider column than the tiles do.
|
||||
const grid = this.grid("seatMenuGridNames");
|
||||
this.destinationsFor(this.seatIndex).forEach(({ player: other, seat }) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "seatMenuBtn";
|
||||
// displayName falls back to "Seat 5" for a chair with nobody in it,
|
||||
// which reads as "Seat 5 — Seat 5". An empty chair says it's empty.
|
||||
const named = (other.name || "").trim();
|
||||
btn.textContent = named ? `Seat ${seat + 1} — ${named}` : `Seat ${seat + 1} — empty`;
|
||||
const btn = this.button(
|
||||
named ? `Seat ${seat + 1} — ${named}` : `Seat ${seat + 1} — empty`,
|
||||
"seatMenuBtn menuTile");
|
||||
btn.addEventListener("click", () => {
|
||||
const from = this.seatIndex;
|
||||
this.close();
|
||||
this.hooks.swapSeats(from, seat);
|
||||
});
|
||||
list.appendChild(btn);
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
keeping.appendChild(grid);
|
||||
list.appendChild(keeping);
|
||||
return;
|
||||
}
|
||||
|
||||
cancel.textContent = "Cancel";
|
||||
title.textContent = HoldemState.displayName(player, this.seatIndex);
|
||||
|
||||
const stack = Betting.stackOf(this.state, player);
|
||||
const owed = Betting.toCall(this.state, player);
|
||||
const bits = [];
|
||||
// Which chair this is leads the line. It came off the puck's face this round —
|
||||
// see HoldemState.seatHeading — and this is the other place it went. It goes in
|
||||
// the subtitle rather than the heading because the heading is a name and names
|
||||
// are long: this header is stuck to the top of the sheet that scrolls, and a
|
||||
// second line of h2 is height every action below it pays for.
|
||||
const bits = [`Seat ${this.seatIndex + 1}`];
|
||||
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
|
||||
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
|
||||
subtitle.textContent = bits.join(" · ");
|
||||
|
||||
this.actionsFor(player, this.seatIndex).forEach(action => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `seatMenuBtn ${action.className || ""}`;
|
||||
btn.textContent = action.label;
|
||||
const actions = this.actionsFor(player, this.seatIndex);
|
||||
const wire = (btn, action) => {
|
||||
btn.addEventListener("click", () => {
|
||||
// "Move to…" stays on the sheet and swaps it for the seat list; every
|
||||
// other action is the end of the interaction.
|
||||
@@ -147,8 +283,37 @@ const SeatMenu = {
|
||||
this.close();
|
||||
action.run();
|
||||
});
|
||||
list.appendChild(btn);
|
||||
};
|
||||
|
||||
const hand = this.tier("seatMenuHand");
|
||||
actions.filter(a => a.group === "hand").forEach(action => {
|
||||
const btn = this.button(action.label, `seatMenuBtn ${action.className || ""}`.trim());
|
||||
wire(btn, action);
|
||||
hand.appendChild(btn);
|
||||
});
|
||||
// An empty tier is left out rather than drawn empty — a folded player in a game
|
||||
// with nobody waiting has nothing in the air to do, and the sideways layout
|
||||
// reads the tier's absence to know it has one column rather than two.
|
||||
if (hand.childElementCount) list.appendChild(hand);
|
||||
|
||||
const keeping = this.tier("seatMenuKeeping");
|
||||
this.GROUPS.forEach(({ name, caption }) => {
|
||||
const inGroup = actions.filter(a => a.group === name);
|
||||
// A caption for nothing is worse than no caption. A group empties often —
|
||||
// past the blinds there is nothing to give a seat but the button.
|
||||
if (inGroup.length === 0) return;
|
||||
keeping.appendChild(this.caption(caption));
|
||||
const grid = this.grid();
|
||||
inGroup.forEach(action => {
|
||||
const btn = this.button(action.label,
|
||||
`seatMenuBtn menuTile ${action.wide ? "menuTileWide" : ""} ${action.className || ""}`
|
||||
.replace(/\s+/g, " ").trim());
|
||||
wire(btn, action);
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
keeping.appendChild(grid);
|
||||
});
|
||||
if (keeping.childElementCount) list.appendChild(keeping);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ const Shortcuts = {
|
||||
{ keys: ["C"], action: "checkOrCall", what: "Check, or call the bet" },
|
||||
{ keys: ["R"], action: "raise", what: "Open the amount pad to bet or raise" },
|
||||
{ keys: ["A"], action: "allIn", what: "Put this player all-in" },
|
||||
{ keys: ["U"], action: "undo", what: "Take back the last action" },
|
||||
// Both undos, because there are two and a dealer has no reason to know that:
|
||||
// the bar's pill takes back a bet, and the toast's takes back a whole step.
|
||||
{ keys: ["U"], action: "undo", what: "Take back the last thing you did" },
|
||||
{ keys: ["Space", "Enter"], action: "advance", what: "Deal, or move to the next street" },
|
||||
{ keys: ["Esc"], action: "cancel", what: "Close the amount pad without betting" },
|
||||
],
|
||||
|
||||
@@ -58,6 +58,19 @@ const HoldemState = {
|
||||
bigBlind: 2,
|
||||
defaultStack: null,
|
||||
street: "preflop",
|
||||
// The community cards, in the order they hit the felt: three, then one,
|
||||
// then one. Empty strings for the ones that haven't been turned yet, the
|
||||
// same way a seat's two hole cards are stored.
|
||||
board: ["", "", "", "", ""],
|
||||
// Whether the dealer wants to be asked for them at all. Off is the tool as
|
||||
// it has always been: no board on the felt, and the pot wizard asks who
|
||||
// won. On, it reads the showdown and answers that question itself.
|
||||
trackBoard: false,
|
||||
// Whether to stop the screen sleeping while a hand is live. On, because a
|
||||
// dealer who has to wake their phone between the flop and the turn is the
|
||||
// single most common way this tool gets in the way of the game it is meant
|
||||
// to be running. Only ever held while betting is open — see wakeLock.js.
|
||||
keepScreenAwake: true,
|
||||
currentBet: 0,
|
||||
lastRaiseSize: 0,
|
||||
actingSeatIndex: null,
|
||||
@@ -86,6 +99,12 @@ const HoldemState = {
|
||||
// seating as it stands, because by then the people it needs to know about
|
||||
// are exactly the ones who have got up and gone.
|
||||
postedBlinds: null,
|
||||
// Who last bet or raised, on this street and anywhere in the hand. Kept
|
||||
// apart because the showdown rule needs both: normally the aggressor of the
|
||||
// final betting round shows first, but a round nobody could bet in — chips
|
||||
// already all-in — falls back to the last one who could.
|
||||
streetAggressorIndex: null,
|
||||
handAggressorIndex: null,
|
||||
// Player ids that were dealt in the last hand actually dealt. What makes a
|
||||
// seat recognisable as having *just* joined — new to the table, or back
|
||||
// from sitting out — which is the whole question behind owing a blind.
|
||||
@@ -94,6 +113,29 @@ const HoldemState = {
|
||||
// the big blind rather than waiting for it. Cleared every hand: it's a
|
||||
// decision about one deal, not a standing arrangement.
|
||||
postingIn: [],
|
||||
// How many straddles are on for the hand about to be dealt. A straddle is
|
||||
// announced hand by hand, so this is cleared with every other one-deal
|
||||
// decision rather than standing until somebody turns it off.
|
||||
straddleCount: 0,
|
||||
// What the house takes, if it takes anything. Percent zero is a game that
|
||||
// doesn't rake, which is nearly all of them and the default.
|
||||
rake: { percent: 0, cap: null, noFlopNoDrop: true },
|
||||
// What it has taken so far tonight. A session total, not a per-hand one:
|
||||
// it is the number the box is checked against at the end.
|
||||
rakeTaken: 0,
|
||||
// People who have taken their chips and gone. Kept for the night rather
|
||||
// than forgotten with the seat: the box took their cash and paid it back
|
||||
// out, and both halves have to stay on the record or the bank check reads
|
||||
// as a shortfall for the rest of the evening.
|
||||
cashedOut: [],
|
||||
// People standing behind the chairs. A dealer at a full table keeps this
|
||||
// list on a napkin; keeping it here means the tool can offer the next seat
|
||||
// that opens to whoever has been waiting longest.
|
||||
waitingList: [],
|
||||
// Which seats actually put a straddle out when the hand was dealt, so the
|
||||
// felt can badge them. Read off the deal rather than worked out again
|
||||
// later, the same way the posted blinds are.
|
||||
postedStraddles: [],
|
||||
// Set when somebody leaves out of the blinds and the rotation has to hold
|
||||
// to the seats to keep the big blind moving one player at a time. The
|
||||
// button then sits on the chair they vacated, and no small blind is posted.
|
||||
@@ -158,13 +200,18 @@ const HoldemState = {
|
||||
return this.createDefault();
|
||||
},
|
||||
|
||||
load() {
|
||||
let raw = null;
|
||||
try {
|
||||
raw = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (!raw) return this.createDefault();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
|
||||
// Everything a table has to be put through before it can be trusted, whichever
|
||||
// direction it arrived from.
|
||||
//
|
||||
// Pulled out of load() so a table read from a file goes through exactly the same
|
||||
// checks as one read from storage. A second, looser path for imports is how a
|
||||
// format grows two meanings of the same field.
|
||||
//
|
||||
// Returns null for anything that isn't a table at all; every other repair is
|
||||
// silent and forward, the way a save from an older version is treated.
|
||||
migrate(parsed) {
|
||||
if (!parsed || !Array.isArray(parsed.seats)) return null;
|
||||
{
|
||||
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
|
||||
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
|
||||
if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0;
|
||||
@@ -173,7 +220,8 @@ const HoldemState = {
|
||||
// them in rather than making the dealer reset a table mid-session.
|
||||
const defaults = this.createDefault();
|
||||
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize",
|
||||
"actingSeatIndex", "blindOverride", "postedBlinds"]
|
||||
"actingSeatIndex", "blindOverride", "postedBlinds",
|
||||
"streetAggressorIndex", "handAggressorIndex"]
|
||||
.forEach(key => {
|
||||
if (parsed[key] === undefined) parsed[key] = defaults[key];
|
||||
});
|
||||
@@ -181,6 +229,17 @@ const HoldemState = {
|
||||
// A table saved before the dead button existed has neither flag. False is
|
||||
// the right guess for both: it means the rotation carries on the ordinary
|
||||
// way, which is what that table was already doing.
|
||||
// A table saved before the board existed has no board and was not tracking
|
||||
// one. Off is also the right guess for a dealer who never asked for it.
|
||||
parsed.trackBoard = parsed.trackBoard === true;
|
||||
parsed.board = this.normaliseBoard(parsed.board);
|
||||
|
||||
// Note the direction: `!== false` rather than `=== true`. A table saved
|
||||
// before this setting existed has no opinion about it, and the useful guess
|
||||
// for a dealer who has never been asked is the helpful one. Only somebody
|
||||
// who has actually turned it off gets it off.
|
||||
parsed.keepScreenAwake = parsed.keepScreenAwake !== false;
|
||||
|
||||
parsed.deadButton = parsed.deadButton === true;
|
||||
parsed.deadSmallBlind = parsed.deadSmallBlind === true;
|
||||
// An old save has no record of who was dealt last hand. Empty is the
|
||||
@@ -189,6 +248,41 @@ const HoldemState = {
|
||||
// asking the whole table to post.
|
||||
if (!Array.isArray(parsed.dealtLastHand)) parsed.dealtLastHand = [];
|
||||
if (!Array.isArray(parsed.postingIn)) parsed.postingIn = [];
|
||||
// None on is the right reading of a save from before straddles existed:
|
||||
// whatever is in front of those players is already in their committed
|
||||
// chips, and nothing is owed on top of it.
|
||||
if (!(Number(parsed.straddleCount) > 0)) parsed.straddleCount = 0;
|
||||
// A table saved before the house took anything takes nothing, which is
|
||||
// also the right answer for one that never will.
|
||||
const rake = parsed.rake && typeof parsed.rake === "object" ? parsed.rake : {};
|
||||
parsed.rake = {
|
||||
percent: Math.min(100, Math.max(0, Number(rake.percent) || 0)),
|
||||
cap: Number(rake.cap) > 0 ? Money.round(Number(rake.cap)) : null,
|
||||
noFlopNoDrop: rake.noFlopNoDrop !== false,
|
||||
};
|
||||
parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0));
|
||||
parsed.waitingList = Array.isArray(parsed.waitingList)
|
||||
? parsed.waitingList
|
||||
.map(row => (typeof row === "string" ? { id: null, name: row } : row))
|
||||
.filter(row => row && String(row.name || "").trim())
|
||||
.map(row => ({
|
||||
id: row.id || this.makeId("waiting"),
|
||||
name: String(row.name).trim().slice(0, 24),
|
||||
addedAt: Number(row.addedAt) || 0,
|
||||
}))
|
||||
: [];
|
||||
parsed.cashedOut = Array.isArray(parsed.cashedOut)
|
||||
? parsed.cashedOut
|
||||
.filter(row => row && row.id)
|
||||
.map(row => ({
|
||||
id: row.id,
|
||||
name: String(row.name || "").slice(0, 40),
|
||||
buyIns: Math.max(0, Money.round(Number(row.buyIns) || 0)),
|
||||
chips: Math.max(0, Money.round(Number(row.chips) || 0)),
|
||||
at: Number(row.at) || 0,
|
||||
}))
|
||||
: [];
|
||||
if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = [];
|
||||
|
||||
// A table saved before dealing and betting were separate has no flag, and
|
||||
// guessing false would show "deal the hand" over a hand already in
|
||||
@@ -208,12 +302,127 @@ const HoldemState = {
|
||||
});
|
||||
|
||||
return parsed;
|
||||
}
|
||||
},
|
||||
|
||||
load() {
|
||||
let raw = null;
|
||||
try {
|
||||
raw = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (!raw) return this.createDefault();
|
||||
const migrated = this.migrate(JSON.parse(raw));
|
||||
if (!migrated) return this.failedLoad(raw);
|
||||
return migrated;
|
||||
} catch {
|
||||
// Malformed JSON — a truncated write, another tab, a browser hiccup.
|
||||
return this.failedLoad(raw);
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Taking the table off this device ------------------------------------
|
||||
//
|
||||
// A night lives in one browser's localStorage and nowhere else. A dealer whose
|
||||
// phone dies, or who wants to carry on from the tablet after the break, loses the
|
||||
// stacks, the buy-ins, where the button is and the whole hand log — and the CSV
|
||||
// export can't bring any of that back, because it records what happened rather
|
||||
// than where the table stands.
|
||||
//
|
||||
// So the table can be written out as a file and read back in. Same shape that goes
|
||||
// into storage, wrapped in enough to recognise it later.
|
||||
// ---- Asking the browser to actually keep it -------------------------------
|
||||
//
|
||||
// "The night lives in this browser and nowhere else" is truer than it sounds.
|
||||
// localStorage is *evictable*: when a device runs short, the browser clears
|
||||
// site data to make room, and it starts with the sites it thinks nobody cares
|
||||
// about. Safari goes further and deletes script-created storage after seven days
|
||||
// without a visit — so a table saved at last month's game can simply not be there
|
||||
// at this one, with nothing having gone wrong and nobody having deleted anything.
|
||||
//
|
||||
// `persist()` is the way to say this data is not that. Granted, the storage can
|
||||
// only be cleared by the person whose browser it is. Chromium and Safari decide
|
||||
// silently on how much the site has been used and whether it's installed; Firefox
|
||||
// asks. All three want the request to come from something the dealer did, which is
|
||||
// why this fires on the first hand dealt rather than on page load.
|
||||
supportsDurableStorage() {
|
||||
return typeof navigator !== "undefined"
|
||||
&& !!navigator.storage
|
||||
&& typeof navigator.storage.persist === "function";
|
||||
},
|
||||
|
||||
async isDurable() {
|
||||
if (!this.supportsDurableStorage()) return false;
|
||||
try {
|
||||
return await navigator.storage.persisted();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// Returns whether the browser said yes. A no is not a failure worth reporting at a
|
||||
// table — the tool behaves identically either way, and the only honest difference
|
||||
// is how confident the Save & reset panel is allowed to sound.
|
||||
async requestDurable() {
|
||||
if (!this.supportsDurableStorage()) return false;
|
||||
try {
|
||||
if (await navigator.storage.persisted()) return true;
|
||||
return await navigator.storage.persist();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
BACKUP_KIND: "casino.holdem.table",
|
||||
BACKUP_VERSION: 1,
|
||||
|
||||
toBackup(state) {
|
||||
return JSON.stringify({
|
||||
kind: this.BACKUP_KIND,
|
||||
version: this.BACKUP_VERSION,
|
||||
savedAt: new Date().toISOString(),
|
||||
table: state,
|
||||
}, null, 2);
|
||||
},
|
||||
|
||||
// Reads a file back. Returns { table } on success, or { error } with something a
|
||||
// dealer can act on — never a half-loaded table, because the failure mode there is
|
||||
// a night that looks restored and isn't.
|
||||
fromBackup(text) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return { error: "That file isn't a saved table — it couldn't be read as JSON." };
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return { error: "That file isn't a saved table." };
|
||||
}
|
||||
// A bare table, from someone who copied the storage value out by hand, is
|
||||
// still a table. The envelope is a convenience, not a password.
|
||||
const table = parsed.kind === this.BACKUP_KIND || parsed.table ? parsed.table : parsed;
|
||||
if (parsed.kind && parsed.kind !== this.BACKUP_KIND) {
|
||||
return { error: "That file is a save from something else, not a Hold'em table." };
|
||||
}
|
||||
const migrated = this.migrate(table);
|
||||
if (!migrated) {
|
||||
return { error: "That file doesn't have a table in it — no seats were found." };
|
||||
}
|
||||
if (parsed.version > this.BACKUP_VERSION) {
|
||||
// Newer than this build understands. Loading it anyway risks reading a
|
||||
// field that has changed meaning, and the dealer would find out from the
|
||||
// money, so say so instead.
|
||||
return { error: "That table was saved by a newer version of this tool. Update, then load it." };
|
||||
}
|
||||
return { table: migrated, savedAt: parsed.savedAt || null };
|
||||
},
|
||||
|
||||
// What the file is called. Dated, because a dealer who saves twice in a night
|
||||
// should not have to guess which is which.
|
||||
backupFileName(now = new Date()) {
|
||||
const pad = n => String(n).padStart(2, "0");
|
||||
return `holdem-table-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
|
||||
`-${pad(now.getHours())}${pad(now.getMinutes())}.json`;
|
||||
},
|
||||
|
||||
// True once a save has failed. The UI reads this to keep saying so, because a
|
||||
// toast is a moment and this is a fact about the rest of the night.
|
||||
saveFailed: false,
|
||||
@@ -267,6 +476,23 @@ const HoldemState = {
|
||||
return player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`;
|
||||
},
|
||||
|
||||
// The same seat, named and numbered, for the two sheets that are about one seat.
|
||||
//
|
||||
// The chair number used to be a grey digit in the corner of the puck, and the corner
|
||||
// of the puck is where the blind badge lands and where a long name reaches: measured
|
||||
// across 348 seats on 36 tables, 117 of them had it printed under something else —
|
||||
// every seat posting a blind, and one in seven with it sitting on the first letter of
|
||||
// the player's own name. The felt has no free corner to move it to, so it stopped
|
||||
// being on the felt. These two headings are where a dealer goes when the question is
|
||||
// about one seat, and they answer it now.
|
||||
//
|
||||
// An unnamed seat is already called "Seat 5" by displayName, so it doesn't say it
|
||||
// twice.
|
||||
seatHeading(player, seatIndex) {
|
||||
const name = player && player.name ? player.name.trim() : "";
|
||||
return name ? `${name} · Seat ${seatIndex + 1}` : `Seat ${seatIndex + 1}`;
|
||||
},
|
||||
|
||||
// Contribution the table calculator will treat this player as having put in.
|
||||
//
|
||||
// Chips already pushed into the middle count whether or not the player is still
|
||||
@@ -558,6 +784,11 @@ const HoldemState = {
|
||||
// Posting to come in is a decision about one deal. Carrying it forward would
|
||||
// charge somebody an extra big blind every hand for the rest of the night.
|
||||
state.postingIn = [];
|
||||
// So is a straddle. It is announced hand by hand at a table, and a straddle
|
||||
// that stayed on would take chips off somebody who never said the word.
|
||||
state.straddleCount = 0;
|
||||
state.postedStraddles = [];
|
||||
this.clearBoard(state);
|
||||
state.seats.forEach(p => {
|
||||
p.folded = false;
|
||||
p.cards = ["", ""];
|
||||
@@ -570,6 +801,21 @@ const HoldemState = {
|
||||
this.ensureButtonOnPlayer(state);
|
||||
},
|
||||
|
||||
// Exactly five slots of either a two-character card or an empty string, whatever
|
||||
// arrived. A board read off a file is the one that has been somewhere this tool
|
||||
// cannot see.
|
||||
normaliseBoard(board) {
|
||||
const source = Array.isArray(board) ? board : [];
|
||||
return Array.from({ length: 5 }, (_, i) => {
|
||||
const card = typeof source[i] === "string" ? source[i].trim().toUpperCase() : "";
|
||||
return /^[23456789TJQKA][SHDC]$/.test(card) ? card : "";
|
||||
});
|
||||
},
|
||||
|
||||
clearBoard(state) {
|
||||
state.board = ["", "", "", "", ""];
|
||||
},
|
||||
|
||||
// Appends a resolved-pot record to the running session log a dealer can point to
|
||||
// for chip-count disputes, then advances the hand counter. `payouts` is
|
||||
// [{ id, label, amount }, ...] — who got paid what out of this hand's pot(s).
|
||||
@@ -577,11 +823,15 @@ const HoldemState = {
|
||||
// Must be called before startNextHand() clears the seats: the per-player `ledger`
|
||||
// it stores alongside the payouts is what session standings are summed from later,
|
||||
// and it needs this hand's contributions while they're still on the table.
|
||||
recordHand(state, payouts) {
|
||||
recordHand(state, payouts, rake = 0) {
|
||||
if (typeof state.handNumber !== "number") state.handNumber = 1;
|
||||
if (!Array.isArray(state.handHistory)) state.handHistory = [];
|
||||
|
||||
const pot = payouts.reduce((sum, p) => sum + p.amount, 0);
|
||||
// The pot as it was, house cut included — the number the table saw in the
|
||||
// middle. What each player took is separately recorded and is net of it, so
|
||||
// the two together say where every chip went.
|
||||
const taken = Money.round(Math.max(0, Number(rake) || 0));
|
||||
const pot = Money.round(payouts.reduce((sum, p) => sum + p.amount, 0) + taken);
|
||||
const wonById = new Map(payouts.map(p => [p.id, p.amount]));
|
||||
const ledger = state.seats
|
||||
.map((player, index) => ({
|
||||
@@ -596,6 +846,7 @@ const HoldemState = {
|
||||
handNumber: state.handNumber,
|
||||
timestamp: Date.now(),
|
||||
pot,
|
||||
rake: taken,
|
||||
payouts,
|
||||
ledger,
|
||||
});
|
||||
@@ -631,6 +882,15 @@ const HoldemState = {
|
||||
// Cash bought in and chips currently in front don't come from the hand log at
|
||||
// all, so they're read off the seat rather than summed — a rebuy isn't a hand.
|
||||
const seatById = new Map(state.seats.map(p => [p.id, p]));
|
||||
// And for anyone who has gone home, off the record their cash-out left behind.
|
||||
// Without this they read as a player who bought in for nothing and is holding
|
||||
// nothing, in the very list the dealer settles the night from.
|
||||
const goneById = new Map((state.cashedOut || []).map(row => [row.id, row]));
|
||||
|
||||
goneById.forEach((row, id) => {
|
||||
if (totals.has(id)) return;
|
||||
totals.set(id, { id, name: row.name, in: 0, won: 0 });
|
||||
});
|
||||
|
||||
// Anyone who has money in the game belongs in the settle-up, whether or not
|
||||
// they've been in a logged hand yet. Building the list from the hand log alone
|
||||
@@ -642,7 +902,7 @@ const HoldemState = {
|
||||
if (totals.has(player.id)) return;
|
||||
const hasMoneyInPlay =
|
||||
(Number(player.buyIns) || 0) > 0 ||
|
||||
(Number(player.stack) || 0) > 0 ||
|
||||
this.chipsHeldBy(state, player) > 0 ||
|
||||
(Number(player.amountOverride) || 0) > 0 ||
|
||||
(Number(player.committed) || 0) > 0;
|
||||
if (!hasMoneyInPlay) return;
|
||||
@@ -652,8 +912,16 @@ const HoldemState = {
|
||||
return Array.from(totals.values())
|
||||
.map(t => {
|
||||
const seat = seatById.get(t.id);
|
||||
const boughtIn = seat ? Money.round(Number(seat.buyIns) || 0) : 0;
|
||||
const chipsNow = seat ? Money.round(Number(seat.stack) || 0) : 0;
|
||||
const gone = goneById.get(t.id);
|
||||
const boughtIn = seat
|
||||
? Money.round(Number(seat.buyIns) || 0)
|
||||
: (gone ? gone.buyIns : 0);
|
||||
// What a departed player is "holding" is what the box handed them on
|
||||
// the way out. It is no longer on the table, so it is marked as paid
|
||||
// rather than counted again at the end of the night.
|
||||
const chipsNow = seat
|
||||
? this.chipsHeldBy(state, seat)
|
||||
: (gone ? gone.chips : 0);
|
||||
// Chips already pushed into the hand in progress are still this
|
||||
// player's money until it's won. Leaving them out makes the cash
|
||||
// position disagree with the hand log for as long as a hand is open —
|
||||
@@ -676,6 +944,18 @@ const HoldemState = {
|
||||
net: Money.round(t.won - t.in),
|
||||
boughtIn,
|
||||
chipsNow,
|
||||
// Everything still theirs: the stack in front of them plus whatever
|
||||
// is out in the middle for the hand in progress. This is the number
|
||||
// a banker counts out when somebody leaves, and it was the one
|
||||
// figure the standings never showed — the column called "Cash out"
|
||||
// held the difference against their buy-in, so a player up $140 on
|
||||
// a $340 stack read as "$140" at the exact moment cash changed
|
||||
// hands.
|
||||
chipsInFront: Money.round(chipsNow + inFrontOfThem),
|
||||
// Set once they have taken their chips and left, so the panel and
|
||||
// the export can say so rather than showing a player still sitting
|
||||
// there with a stack.
|
||||
cashedOut: !!gone && !seat,
|
||||
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
|
||||
notes,
|
||||
};
|
||||
@@ -708,12 +988,351 @@ const HoldemState = {
|
||||
//
|
||||
// Chips in play counts everything a player could hand back: what's in front of
|
||||
// them and what's in the middle waiting to be won.
|
||||
// ---- The people standing behind the chairs ----
|
||||
//
|
||||
// A full home game always has a couple of them, and the list lives on a napkin or
|
||||
// in the dealer's head — which is where the argument comes from an hour later about
|
||||
// who said they were next. Kept in order of arrival, because that is the only
|
||||
// ordering anybody has ever agreed to.
|
||||
|
||||
addToWaitingList(state, name) {
|
||||
const trimmed = String(name || "").trim().slice(0, 24);
|
||||
if (!trimmed) return null;
|
||||
if (!Array.isArray(state.waitingList)) state.waitingList = [];
|
||||
const entry = { id: this.makeId("waiting"), name: trimmed, addedAt: Date.now() };
|
||||
state.waitingList.push(entry);
|
||||
return entry;
|
||||
},
|
||||
|
||||
removeFromWaitingList(state, id) {
|
||||
if (!Array.isArray(state.waitingList)) return null;
|
||||
const index = state.waitingList.findIndex(row => row.id === id);
|
||||
if (index < 0) return null;
|
||||
return state.waitingList.splice(index, 1)[0];
|
||||
},
|
||||
|
||||
// Chairs with nobody in them, in seat order. What "a seat has opened" means, and
|
||||
// the same question the deal prompt answers when it counts the players.
|
||||
openSeats(state) {
|
||||
return state.seats
|
||||
.map((player, index) => ({ player, index }))
|
||||
.filter(({ player }) => !this.seatInPlay(player))
|
||||
.map(({ index }) => index);
|
||||
},
|
||||
|
||||
// Sit the next person down. Their name goes on the chair and nothing else does:
|
||||
// they haven't bought in yet, and guessing a stack for them would put chips on the
|
||||
// table that nobody has paid for — which is the one thing the bank check exists to
|
||||
// catch.
|
||||
seatFromWaitingList(state, id, index) {
|
||||
const seat = state.seats[index];
|
||||
if (!seat) return { ok: false, reason: "There's no such seat." };
|
||||
if (this.seatInPlay(seat)) {
|
||||
return { ok: false, reason: `${this.displayName(seat, index)} is already in that seat.` };
|
||||
}
|
||||
const entry = this.removeFromWaitingList(state, id);
|
||||
if (!entry) return { ok: false, reason: "They aren't on the list any more." };
|
||||
|
||||
seat.name = entry.name;
|
||||
// Not dealt in and holding nothing: the next thing that happens is the dealer
|
||||
// taking their money, and Add chips is what says so.
|
||||
seat.inHand = false;
|
||||
return { ok: true, name: entry.name, index };
|
||||
},
|
||||
|
||||
// ---- Somebody leaving with their chips ----
|
||||
//
|
||||
// The most ordinary thing that happens at a home game after midnight, and the tool
|
||||
// had nothing for it. A dealer's only way to free the chair was to wipe the seat by
|
||||
// hand, and then the bank check — which adds up cash taken against chips on the
|
||||
// table — declared the departing player's whole stack missing: "someone has paid
|
||||
// for chips they haven't been given", for the rest of the night. Wiping their
|
||||
// buy-in as well silenced it, at the price of losing their night from the standings
|
||||
// and the file the dealer hands over.
|
||||
//
|
||||
// Cashing out is two facts, and both have to survive the empty chair: the box took
|
||||
// their cash, and the box paid their chips back out.
|
||||
|
||||
// Can this seat be cashed out right now?
|
||||
canCashOut(state, index) {
|
||||
const player = state.seats[index];
|
||||
if (!player) return { ok: false, reason: "There's nobody in that seat." };
|
||||
const inPot = Money.round((Number(player.committed) || 0) + (Number(player.amountOverride) || 0));
|
||||
if (inPot > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
// Paying someone out with money still in the middle would either take
|
||||
// chips out of a pot other people can win, or hand them chips twice.
|
||||
reason: `${this.displayName(player, index)} has ${Money.format(inPot)} in the pot. Finish the hand first.`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
// What the dealer counts out, and what the seat's night comes to.
|
||||
cashOutPreview(state, index) {
|
||||
const player = state.seats[index];
|
||||
if (!player) return null;
|
||||
// Betting owns the answer to "what is this seat holding" — including the rule
|
||||
// that a seat which has never been given a stack is holding the table's
|
||||
// starting one. Asking it rather than working it out again here: the first
|
||||
// draft of this did, read `Number(null)` as a perfectly good zero, and offered
|
||||
// to pay a player nothing.
|
||||
const chips = Money.round(Math.max(0, Betting.stackOf(state, player)));
|
||||
const boughtIn = Money.round(Math.max(0, Number(player.buyIns) || 0));
|
||||
return {
|
||||
index,
|
||||
name: this.displayName(player, index),
|
||||
chips,
|
||||
boughtIn,
|
||||
// Their result for the night, in the direction a banker says it: positive
|
||||
// means the box is paying out more than it took from them.
|
||||
net: Money.round(chips - boughtIn),
|
||||
breakdown: Money.formatChipBreakdown(chips, state.chipLegend),
|
||||
};
|
||||
},
|
||||
|
||||
// Hand them their chips and free the chair, keeping both halves on the record.
|
||||
cashOut(state, index) {
|
||||
const allowed = this.canCashOut(state, index);
|
||||
if (!allowed.ok) return allowed;
|
||||
|
||||
const preview = this.cashOutPreview(state, index);
|
||||
const leavingId = state.seats[index].id;
|
||||
if (!Array.isArray(state.cashedOut)) state.cashedOut = [];
|
||||
state.cashedOut.push({
|
||||
id: leavingId,
|
||||
name: preview.name,
|
||||
buyIns: preview.boughtIn,
|
||||
chips: preview.chips,
|
||||
at: Date.now(),
|
||||
});
|
||||
|
||||
// The chair opens for the next player. A fresh id, because the person who had
|
||||
// it has gone and their night is now filed under the old one — reusing it would
|
||||
// merge two people's results the moment somebody new sits down.
|
||||
const seat = this.makePlayer();
|
||||
state.seats[index] = seat;
|
||||
// Anyone owed a hand by this seat is no longer owed one.
|
||||
state.postingIn = (state.postingIn || []).filter(id => id !== leavingId);
|
||||
return { ok: true, ...preview };
|
||||
},
|
||||
|
||||
// ---- The house's cut ----
|
||||
//
|
||||
// A game that takes a rake or a time charge has chips leaving the table every hand,
|
||||
// and until now the bank check called them missing: it added up the buy-ins,
|
||||
// added up the chips on the felt, and told the dealer somebody had been paid for
|
||||
// chips they never got. The house's cut is the one thing on this table that is
|
||||
// supposed to go and not come back, so it is counted rather than mourned.
|
||||
//
|
||||
// Percentage with a cap, which is what nearly every room and home game runs. Zero
|
||||
// percent is a game that doesn't rake, which is the default and the common case.
|
||||
|
||||
// What the house takes from a pot of this size.
|
||||
//
|
||||
// "No flop, no drop" is the rule almost everyone plays: a hand that ends before the
|
||||
// flop pays nothing, because the players never got a hand out of it. It is on by
|
||||
// default and can be turned off for a house that rakes every pot.
|
||||
rakeOn(state, pot, sawFlop = true) {
|
||||
const rake = state.rake || {};
|
||||
const percent = Number(rake.percent) || 0;
|
||||
const amount = Money.round(Number(pot) || 0);
|
||||
if (percent <= 0 || amount <= 0) return 0;
|
||||
if (rake.noFlopNoDrop !== false && !sawFlop) return 0;
|
||||
|
||||
const uncapped = (amount * percent) / 100;
|
||||
const cap = Number(rake.cap);
|
||||
const capped = cap > 0 ? Math.min(uncapped, cap) : uncapped;
|
||||
// Never more than the pot, however the numbers were typed in.
|
||||
return Money.downToChip(Math.min(capped, amount), state.chipLegend);
|
||||
},
|
||||
|
||||
// The same payouts with the house's cut taken out of them.
|
||||
//
|
||||
// Off the top of the whole pot, then shared out in proportion — which for the hand
|
||||
// a dealer actually deals, with one winner, is just "winner gets the pot less the
|
||||
// rake". Split pots take it proportionally, and whatever the rounding leaves over
|
||||
// goes back to the winners in turn rather than to the house: the house asked for a
|
||||
// number, and it gets that number, not that number plus dust.
|
||||
applyRake(payouts, rake) {
|
||||
const total = payouts.reduce((sum, p) => sum + p.amount, 0);
|
||||
const taken = Money.round(Math.min(Math.max(0, rake), total));
|
||||
if (taken <= 0) return { payouts: payouts.map(p => ({ ...p })), rake: 0 };
|
||||
|
||||
const kept = Money.toCents(total) - Money.toCents(taken);
|
||||
const shares = payouts.map(p => {
|
||||
const exact = (Money.toCents(p.amount) * kept) / Money.toCents(total);
|
||||
return { payout: p, cents: Math.floor(exact) };
|
||||
});
|
||||
|
||||
// Floor everybody, then hand the leftover cents out one at a time, biggest
|
||||
// share first — the same shape as the odd-chip rule the split calculator uses.
|
||||
let spare = kept - shares.reduce((sum, s) => sum + s.cents, 0);
|
||||
[...shares]
|
||||
.sort((a, b) => b.payout.amount - a.payout.amount)
|
||||
.forEach(share => { if (spare > 0) { share.cents += 1; spare -= 1; } });
|
||||
|
||||
return {
|
||||
payouts: shares.map(share => ({ ...share.payout, amount: Money.fromCents(share.cents) })),
|
||||
rake: taken,
|
||||
};
|
||||
},
|
||||
|
||||
// Is anybody actually in this chair? A seat with a name on it, chips in front of
|
||||
// it, cash against it or a hand dealt to it. Empty chairs are not players, which
|
||||
// matters the moment the table's starting stack starts standing in for a seat that
|
||||
// has never been given one — every empty chair would otherwise look like somebody
|
||||
// holding two hundred dollars.
|
||||
seatInPlay(player) {
|
||||
if (!player) return false;
|
||||
return !!(player.name || "").trim() ||
|
||||
(Number(player.buyIns) || 0) > 0 ||
|
||||
(Number(player.stack) || 0) > 0 ||
|
||||
(Number(player.committed) || 0) > 0 ||
|
||||
(Number(player.amountOverride) || 0) > 0 ||
|
||||
player.inHand === true;
|
||||
},
|
||||
|
||||
// What a seat is holding, the way the felt and the betting see it — which includes
|
||||
// the table's starting stack standing in for a seat that has never been given one.
|
||||
//
|
||||
// This is the number the money side of the tool was missing. "Starting stack" is
|
||||
// offered in the setup panel as the ordinary way to open a table, and everything a
|
||||
// dealer looks at — the puck, the bar, what a player can bet — reads it. The bank
|
||||
// check and the standings read `seat.stack` directly instead, which is null for
|
||||
// exactly those seats: measured on a three-handed table with a $200 starting stack
|
||||
// and nobody's own stack set, the felt played for $600 while the bank check said
|
||||
// "no chips in play, balanced" and the standings listed nobody at all.
|
||||
chipsHeldBy(state, player) {
|
||||
if (!this.seatInPlay(player)) return 0;
|
||||
return Math.max(0, Money.round(Betting.stackOf(state, player)));
|
||||
},
|
||||
|
||||
// What a chip colour is holding up.
|
||||
//
|
||||
// Removing one is not a change to a legend. computeContribution() prices a seat by
|
||||
// running the legend over that seat's chip counts, so a colour that leaves the
|
||||
// legend takes every chip counted in it off the table. Measured on a three-handed
|
||||
// table counting real chips: deleting the $500 row took $22,500 of $26,874 with it,
|
||||
// on one click of a 24px button, with nothing said and nothing shown.
|
||||
//
|
||||
// It does not come back, either. A colour added again is a new colour with a new
|
||||
// id, while the counts stay keyed to the one that left — so "put it back" restores
|
||||
// the row and none of the money.
|
||||
//
|
||||
// Hence this: the cost is answerable before the click rather than discovered at the
|
||||
// end of the night, when the box is short and nobody knows why.
|
||||
// The number here is the *difference*, asked by pricing every seat twice — once with
|
||||
// the colour and once without — rather than by multiplying the chips by their value.
|
||||
//
|
||||
// Those two are not the same, and the multiplication is the wrong one, because
|
||||
// computeContribution() does not always reach the chips. A seat with a typed amount
|
||||
// is priced from that and its chip counts are ignored, so a colour it holds is worth
|
||||
// nothing to it; and a seat whose chips all leave falls through to the table's base
|
||||
// pot — the standing ante — rather than to zero, so it loses less than its chips
|
||||
// were worth.
|
||||
// Both would put a number in the confirmation that the table then failed to match,
|
||||
// which is worse than no number at all — this is a warning whose whole job is to be
|
||||
// the truth.
|
||||
chipColorUsage(state, chipId) {
|
||||
const chip = (state.chipLegend || []).find(entry => entry.id === chipId);
|
||||
if (!chip) return { chip: null, total: 0, chips: 0, seats: [] };
|
||||
|
||||
const legend = state.chipLegend || [];
|
||||
const without = legend.filter(entry => entry.id !== chipId);
|
||||
|
||||
const seats = state.seats
|
||||
.map((player, index) => {
|
||||
const before = this.computeContribution(player, legend, state.basePotAmount);
|
||||
const after = this.computeContribution(player, without, state.basePotAmount);
|
||||
return {
|
||||
index,
|
||||
name: this.displayName(player, index),
|
||||
count: Number((player.chipCounts || {})[chipId]) || 0,
|
||||
amount: Money.round(before - after),
|
||||
};
|
||||
})
|
||||
// Signed, and not filtered to losses, because removing a colour can put the
|
||||
// table *up*. A seat whose chips are worth less than the table's standing
|
||||
// ante falls through to the ante once they are gone: found by the randomised
|
||||
// check at 14 chips of a $1 colour against a $200 base pot, where deleting it
|
||||
// moved the table by +186. Rare, but a warning that said "nothing changes"
|
||||
// there would be as wrong as one that named the wrong number.
|
||||
.filter(row => row.amount !== 0);
|
||||
|
||||
return {
|
||||
chip,
|
||||
seats,
|
||||
chips: seats.reduce((sum, row) => sum + row.count, 0),
|
||||
total: Money.round(seats.reduce((sum, row) => sum + row.amount, 0)),
|
||||
};
|
||||
},
|
||||
|
||||
// Take a colour out, and take its counts with it.
|
||||
//
|
||||
// Leaving them behind is what the old code did — the seats kept counts against an
|
||||
// id no legend entry answers to any more, so the file carried money that nothing
|
||||
// could price and nothing could show. Clearing them makes the saved table say what
|
||||
// the screen says. The usage is measured before anything moves, so a caller can
|
||||
// report what it cost.
|
||||
removeChipColor(state, chipId) {
|
||||
const usage = this.chipColorUsage(state, chipId);
|
||||
if (!usage.chip) return usage;
|
||||
|
||||
state.chipLegend = (state.chipLegend || []).filter(entry => entry.id !== chipId);
|
||||
state.seats.forEach(player => {
|
||||
if (player.chipCounts && chipId in player.chipCounts) delete player.chipCounts[chipId];
|
||||
});
|
||||
return usage;
|
||||
},
|
||||
|
||||
// Seats playing with chips the box has no cash against.
|
||||
//
|
||||
// Two kinds, and they are not the same problem. A seat holding the table's starting
|
||||
// stack has never been given chips of its own — the dealer set "everyone starts
|
||||
// with 200" and sat people down, which is the ordinary way to open a table and the
|
||||
// reason this whole gap existed. A seat with its own stack and no buy-in is a
|
||||
// rebuy somebody forgot to write down, and only the dealer knows what it was.
|
||||
seatsOwingCash(state) {
|
||||
return state.seats
|
||||
.map((player, index) => ({ player, index }))
|
||||
.filter(({ player }) => this.chipsHeldBy(state, player) > 0 &&
|
||||
!((Number(player.buyIns) || 0) > 0))
|
||||
.map(({ player, index }) => ({
|
||||
index,
|
||||
name: this.displayName(player, index),
|
||||
chips: this.chipsHeldBy(state, player),
|
||||
// True when they are holding the table's starting stack rather than one
|
||||
// the dealer typed for them.
|
||||
fromStartingStack: player.stack === null || player.stack === undefined ||
|
||||
String(player.stack).trim() === "",
|
||||
}));
|
||||
},
|
||||
|
||||
// Write down the cash for the seats that are holding the table's starting stack.
|
||||
// Only those: what somebody else's unrecorded rebuy was is a question only the
|
||||
// dealer can answer, and guessing it would be the tool inventing money.
|
||||
recordStartingBuyIns(state) {
|
||||
const owed = this.seatsOwingCash(state).filter(row => row.fromStartingStack);
|
||||
owed.forEach(row => { state.seats[row.index].buyIns = row.chips; });
|
||||
return {
|
||||
seats: owed.length,
|
||||
total: Money.round(owed.reduce((sum, row) => sum + row.chips, 0)),
|
||||
};
|
||||
},
|
||||
|
||||
bankReconciliation(state) {
|
||||
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
|
||||
const gone = Array.isArray(state.cashedOut) ? state.cashedOut : [];
|
||||
// Cash the box took from people who have since left counts as taken: it is in
|
||||
// the box, and forgetting it with the seat is what used to make a cash-out read
|
||||
// as a shortfall all night.
|
||||
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0) +
|
||||
gone.reduce((sum, row) => sum + Math.max(0, Number(row.buyIns) || 0), 0);
|
||||
const chipsInPlay = state.seats.reduce(
|
||||
(sum, p) =>
|
||||
sum +
|
||||
Math.max(0, Number(p.stack) || 0) +
|
||||
this.chipsHeldBy(state, p) +
|
||||
Math.max(0, Number(p.committed) || 0) +
|
||||
Math.max(0, Number(p.amountOverride) || 0),
|
||||
0
|
||||
@@ -727,11 +1346,21 @@ const HoldemState = {
|
||||
// hunting for a miscount that never happened.
|
||||
const discarded = Math.max(0, Money.round(Number(state.discarded) || 0));
|
||||
|
||||
const difference = Money.round(chipsInPlay + discarded - cashIn);
|
||||
// Chips the house has taken. Off the table for good, like the discards, and
|
||||
// for a better reason: the box is supposed to have them.
|
||||
const raked = Math.max(0, Money.round(Number(state.rakeTaken) || 0));
|
||||
|
||||
// Chips handed back over the rail. Off the table like the other two, and the
|
||||
// one the dealer can point at when somebody asks where the money went.
|
||||
const paidOut = Money.round(gone.reduce((sum, row) => sum + Math.max(0, Number(row.chips) || 0), 0));
|
||||
|
||||
const difference = Money.round(chipsInPlay + discarded + raked + paidOut - cashIn);
|
||||
return {
|
||||
cashIn: Money.round(cashIn),
|
||||
chipsInPlay: Money.round(chipsInPlay),
|
||||
discarded,
|
||||
raked,
|
||||
paidOut,
|
||||
difference,
|
||||
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
|
||||
balanced: Math.abs(difference) <= Money.EPSILON,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Keeps the screen on while a hand is live.
|
||||
//
|
||||
// The dealer's phone spends the night face-up on the felt with nine people round it,
|
||||
// and the one thing it reliably does between the flop and the turn is dim, then lock.
|
||||
// Every hand, all night. A dozen rounds of work have gone into making the next step
|
||||
// readable at a glance, and none of it survives a black screen and a passcode with
|
||||
// four people waiting to know whether the bet is called.
|
||||
//
|
||||
// Held only while betting is actually open. A phone left on the table through a break,
|
||||
// a re-buy, or the twenty minutes where everyone is arguing about pizza sleeps exactly
|
||||
// like any other phone — which is the difference between a tool that respects a battery
|
||||
// and one a dealer stops trusting with their evening.
|
||||
|
||||
const WakeLock = {
|
||||
// The browser's promise to stay awake. Null whenever we aren't holding one, which
|
||||
// includes every moment the browser has taken it back on us — see `start()`.
|
||||
sentinel: null,
|
||||
|
||||
// What the last sync decided we want. Kept separate from `sentinel` because
|
||||
// requesting one is asynchronous: a hand can be dealt and folded out inside the
|
||||
// await, and the answer to "should we still be holding this" has to be the current
|
||||
// one rather than the one that was true when the request went out.
|
||||
wanted: false,
|
||||
|
||||
// One request at a time. `sync()` runs off renderAll, which fires many times a
|
||||
// second during a busy street; without this, two overlapping requests both get
|
||||
// past the "already holding one?" check and the second sentinel leaks — the screen
|
||||
// then stays awake after the hand ends, with nothing left holding a reference to
|
||||
// turn it off.
|
||||
pending: false,
|
||||
|
||||
// Whether the dealer has been told this is happening. Once per page load, not once
|
||||
// per hand: the same toast at the top of all forty hands of a session would be the
|
||||
// single most annoying thing the tool does.
|
||||
told: false,
|
||||
|
||||
// Set by main.js so the toast's "Turn off" can actually turn it off.
|
||||
hooks: null,
|
||||
|
||||
supported() {
|
||||
return typeof navigator !== "undefined" && "wakeLock" in navigator;
|
||||
},
|
||||
|
||||
// The whole decision, with no browser in it, so it can be tested as what it is:
|
||||
// a rule about when a poker table needs the screen.
|
||||
shouldHold(state) {
|
||||
if (!state) return false;
|
||||
if (state.keepScreenAwake === false) return false;
|
||||
return state.bettingOpen === true;
|
||||
},
|
||||
|
||||
// Called after anything that could start or end a hand. Safe to call constantly.
|
||||
sync(state) {
|
||||
this.wanted = this.supported() && this.shouldHold(state);
|
||||
if (this.wanted) this.acquire();
|
||||
else this.release();
|
||||
},
|
||||
|
||||
async acquire() {
|
||||
if (this.sentinel || this.pending) return;
|
||||
this.pending = true;
|
||||
try {
|
||||
const sentinel = await navigator.wakeLock.request("screen");
|
||||
// The hand can have ended while that was in flight. Letting this one go
|
||||
// immediately is the only correct answer; holding it "just until the next
|
||||
// sync" is how a phone stays lit on an empty table.
|
||||
if (!this.wanted) {
|
||||
try { await sentinel.release(); } catch { /* already gone */ }
|
||||
return;
|
||||
}
|
||||
this.sentinel = sentinel;
|
||||
// The browser releases this itself when the tab is hidden, and tells us so.
|
||||
// Without this listener our `sentinel` would be a stale object and the
|
||||
// re-acquire in `start()` would decline to do anything.
|
||||
sentinel.addEventListener("release", () => {
|
||||
if (this.sentinel === sentinel) this.sentinel = null;
|
||||
});
|
||||
this.tell();
|
||||
} catch {
|
||||
// Battery saver, a hidden tab, a browser that simply says no. All of these
|
||||
// are the browser doing its job, and none of them is worth an error at a
|
||||
// poker table — the tool works exactly as it did before, the screen just
|
||||
// dims the way the phone's own settings say it should.
|
||||
} finally {
|
||||
this.pending = false;
|
||||
}
|
||||
},
|
||||
|
||||
async release() {
|
||||
const sentinel = this.sentinel;
|
||||
this.sentinel = null;
|
||||
if (!sentinel) return;
|
||||
try { await sentinel.release(); } catch { /* already released */ }
|
||||
},
|
||||
|
||||
// Said once, at the moment it first becomes true, with the way out attached.
|
||||
//
|
||||
// The alternative was a permanent indicator in the header, and the header is the
|
||||
// one part of this page that has been fought over for crowding more than any other.
|
||||
// A dealer needs to know their phone is behaving unusually exactly once; after that
|
||||
// it is a setting, and settings live in the sidebar.
|
||||
tell() {
|
||||
if (this.told) return;
|
||||
this.told = true;
|
||||
if (typeof Toast === "undefined") return;
|
||||
Toast.show("Keeping the screen on while a hand is live.", {
|
||||
actionLabel: "Turn off",
|
||||
onAction: () => { if (this.hooks && this.hooks.turnOff) this.hooks.turnOff(); },
|
||||
});
|
||||
},
|
||||
|
||||
// A screen wake lock is not something you take once. The browser drops it whenever
|
||||
// the document stops being visible — switching apps to check a rule, the phone
|
||||
// locking on its own before the first hand was dealt, a notification pulled down —
|
||||
// and it never comes back on its own. A tool that requests it at the start of the
|
||||
// hand and assumes it still has it is a tool that works until the first interruption.
|
||||
start(getState) {
|
||||
if (!this.supported()) return;
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
this.sync(getState());
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = WakeLock;
|
||||
+1025
-6
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
// Saying who the blind is, before saying "cards".
|
||||
//
|
||||
// A dealer sets the table up out loud — button here, she's the small blind, he's the
|
||||
// big blind — and only then deals. The seat menu offers "Make small blind" for exactly
|
||||
// that, and it was answering by dealing the hand: betting opened, two stacks docked,
|
||||
// the action bar up and waiting on the seat after the big blind. The dealer had said
|
||||
// where the blinds go, and the tool heard "deal".
|
||||
//
|
||||
// The condition it was asking is `onlyBlindsIn`, which means "nothing has happened in
|
||||
// this hand except the blinds" — true of a hand one second old, and equally true of a
|
||||
// table in setup with no cards out and nothing committed. Those two need telling apart,
|
||||
// which is what `blindsArePosted` is for.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
describe("before the deal", () => {
|
||||
test("the blinds are not down, however much the table looks ready", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
assert.equal(state.bettingOpen, false);
|
||||
// The old condition, on its own, says yes here — this is the whole bug.
|
||||
assert.equal(Betting.onlyBlindsIn(state), true);
|
||||
assert.equal(Betting.blindsArePosted(state), false);
|
||||
});
|
||||
|
||||
test("a table straight out of setup, with nobody seated, is the same answer", () => {
|
||||
assert.equal(Betting.blindsArePosted(HoldemState.createDefault()), false);
|
||||
});
|
||||
|
||||
test("re-posting would have dealt the hand, which is why it is guarded", () => {
|
||||
// Not a rule, a demonstration: repostBlinds ends in beginHand. Calling it in
|
||||
// setup is the deal, and nothing about naming a blind should reach it.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
Betting.repostBlinds(state);
|
||||
assert.equal(state.bettingOpen, true, "chips off two stacks and the action bar up");
|
||||
assert.notEqual(state.actingSeatIndex, null);
|
||||
});
|
||||
|
||||
test("naming a blind moves a badge and no chips", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
// What the seat menu does: pin the override, repost only if the blinds are down.
|
||||
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
|
||||
|
||||
assert.equal(state.bettingOpen, false, "still setup — the dealer hasn't dealt");
|
||||
assert.equal(state.actingSeatIndex, null, "nobody is being asked to act");
|
||||
assert.deepEqual(state.seats.map(p => Betting.committed(p)), [0, 0, 0, 0, 0, 0]);
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
|
||||
// And the badges say what the dealer just said.
|
||||
const blinds = HoldemState.getBlindSeats(state);
|
||||
assert.equal(blinds.sbIndex, 3);
|
||||
assert.equal(blinds.bbIndex, 0);
|
||||
});
|
||||
|
||||
test("and the hand that follows posts from where they were aimed", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
|
||||
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.equal(Betting.committed(state.seats[3]), 1);
|
||||
assert.equal(Betting.committed(state.seats[0]), 2);
|
||||
});
|
||||
|
||||
test("giving the button away is a marker moving too", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
const willRepost = Betting.blindsArePosted(state);
|
||||
state.dealerSeatIndex = 2;
|
||||
if (willRepost) Betting.repostBlinds(state);
|
||||
|
||||
assert.equal(willRepost, false);
|
||||
assert.equal(state.bettingOpen, false);
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("once the hand is dealt", () => {
|
||||
test("the blinds are down, and re-aiming them moves real chips", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
const before = chipsOnTable(state);
|
||||
Betting.beginHand(state);
|
||||
assert.equal(Betting.blindsArePosted(state), true);
|
||||
assert.equal(Betting.committed(state.seats[1]), 1);
|
||||
|
||||
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
|
||||
|
||||
assert.equal(Betting.committed(state.seats[1]), 0, "the old small blind got their chip back");
|
||||
assert.equal(Betting.committed(state.seats[3]), 1);
|
||||
assert.equal(Betting.committed(state.seats[0]), 2);
|
||||
assert.equal(chipsOnTable(state), before, "no chips created or lost in the shuffle");
|
||||
});
|
||||
|
||||
test("but not once somebody has answered them", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, state.actingSeatIndex);
|
||||
assert.equal(Betting.blindsArePosted(state), false, "there is a call to unpick now");
|
||||
});
|
||||
|
||||
test("nor on a later street", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
state.street = "flop";
|
||||
assert.equal(Betting.blindsArePosted(state), false);
|
||||
});
|
||||
|
||||
test("and a misdealt hand goes back to being a table in setup", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(Betting.blindsArePosted(state), false, "nothing is posted until the dealer deals again");
|
||||
});
|
||||
|
||||
test("as does a hand that has been paid out", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.sweepToHandTotals(state);
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(Betting.blindsArePosted(state), false);
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,23 @@
|
||||
// bet at, so the test that matters most is the last one: the seats it marks are
|
||||
// exactly the seats the betting round refuses to ask for another chip.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
// The declarations inside one rule, by its exact selector.
|
||||
const ruleBody = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
if (at < 0) return null;
|
||||
const open = CSS.indexOf("{", at);
|
||||
return CSS.slice(open + 1, CSS.indexOf("}", open));
|
||||
};
|
||||
|
||||
test("a player who moved all-in is marked", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[3].stack = 12;
|
||||
@@ -96,3 +110,64 @@ test("marking nobody doesn't move a chip", () => {
|
||||
state.seats.forEach(player => Betting.isAllIn(state, player));
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
|
||||
// ---- And the shape of the mark, which is the felt's problem ------------------
|
||||
//
|
||||
// "ALL IN" is the only thing that ever lands on the stack line with a space in it, and
|
||||
// a space is somewhere to break. Measured at 360x640 with twelve players all-in, it
|
||||
// wanted 36px and had 34: it broke onto a second line, and an 11px line became 22.
|
||||
//
|
||||
// That is not a typography complaint. The puck went from 55px tall to 66, and puck
|
||||
// height is what the seat ring is short of — at the ends of an ellipse the seats are
|
||||
// separated vertically, so 11px taller is 11px further into the seat next door. The
|
||||
// layout search then tried every width it has, found none that cleared, and fell back
|
||||
// to the narrowest, which is also the one that wraps hardest.
|
||||
//
|
||||
// So the rule every design system gives a badge applies here too: one line, always.
|
||||
describe("the mark is one line, because the ring cannot afford two", () => {
|
||||
test("a stack line can never wrap", () => {
|
||||
const body = ruleBody(".seatPuckStack");
|
||||
assert.ok(body, "the .seatPuckStack rule should exist");
|
||||
assert.match(body, /white-space:\s*nowrap/,
|
||||
"the stack line must not be allowed to wrap — a second line is 11px the ring hasn't got");
|
||||
});
|
||||
|
||||
test("and on the tightest pucks it gives up its letter-spacing to fit", () => {
|
||||
// Not wrapping alone only converts a wrap into a spill: a line that cannot wrap
|
||||
// and still doesn't fit paints over the seat beside it instead. The spacing is
|
||||
// 3.6px of the 36 it wanted, and it is the one of the mark's three signals that
|
||||
// can go — the warm colour and the bold weight stay.
|
||||
const base = ruleBody(".seatPuckStack.seatPuckAllIn");
|
||||
const tight = ruleBody(".seatsTight .seatPuckStack.seatPuckAllIn");
|
||||
assert.ok(base, "the base all-in rule should exist");
|
||||
assert.ok(tight, "the tight-tier all-in rule should exist");
|
||||
|
||||
const spacingOf = body => {
|
||||
const match = body.match(/letter-spacing:\s*([\d.]+)em/);
|
||||
return match ? Number(match[1]) : null;
|
||||
};
|
||||
const wide = spacingOf(base);
|
||||
const narrow = spacingOf(tight);
|
||||
assert.ok(wide !== null && narrow !== null, "both rules should set letter-spacing in em");
|
||||
assert.ok(narrow < wide,
|
||||
`the tight tier should space the mark more tightly than the ordinary one (${narrow} vs ${wide})`);
|
||||
|
||||
// The arithmetic that has to hold for it to fit at all: six characters at 10px
|
||||
// with 4px of padding either side and a 1px border, inside a 44px puck.
|
||||
const CONTENT_PX = 44 - 2 * 4 - 2 * 1;
|
||||
const inkAt = spacing => 32.4 + spacing * 10 * "ALL IN".length;
|
||||
assert.ok(inkAt(narrow) <= CONTENT_PX,
|
||||
`"ALL IN" must fit the narrowest puck: ${inkAt(narrow).toFixed(1)}px into ${CONTENT_PX}px`);
|
||||
assert.ok(inkAt(wide) > CONTENT_PX,
|
||||
"the ordinary spacing is what didn't fit — if it does now, this test is measuring the wrong thing");
|
||||
});
|
||||
|
||||
test("the two signals that survive are the ones doing the work", () => {
|
||||
// The spacing exists so "ALL IN" doesn't read as money at a glance. Dropping it
|
||||
// on the narrow pucks is only safe while the colour and the weight remain, so
|
||||
// they are pinned here rather than left to be tidied away later.
|
||||
const base = ruleBody(".seatPuckStack.seatPuckAllIn");
|
||||
assert.match(base, /color:\s*#ffcf6b/i, "the warm colour separates it from a grey stack");
|
||||
assert.match(base, /font-weight:\s*700/, "and the weight does the rest");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// What the tool says out loud between taps.
|
||||
//
|
||||
// The action bar carried `aria-live="polite"` on the element whose entire contents
|
||||
// render() rebuilds every action, so a screen reader read the whole bar after every
|
||||
// single tap. Measured, after one:
|
||||
//
|
||||
// "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U
|
||||
// Fold Call $10 Raise"
|
||||
//
|
||||
// Nothing in that is wrong. It is just unusable at the speed a hand is dealt, and it
|
||||
// is the documented anti-pattern: a live region has to be a stable element whose text
|
||||
// changes, not a container that gets replaced.
|
||||
//
|
||||
// What a dealer running this by ear needs is who is on the clock and what it costs
|
||||
// them. These are the sentences.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { ActionBar, Betting, HoldemState, makeTable } = require("./load");
|
||||
|
||||
const barOn = (state) => {
|
||||
ActionBar.state = state;
|
||||
ActionBar.lastAnnouncement = undefined;
|
||||
return ActionBar;
|
||||
};
|
||||
|
||||
describe("before the cards are out", () => {
|
||||
test("a table that could be dealt says so", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
assert.equal(barOn(state).announcement(), "Ready to deal.");
|
||||
});
|
||||
|
||||
test("and an empty table says what it is, rather than nothing", () => {
|
||||
// This used to announce "" — silence being the honest answer when the bar was
|
||||
// hidden on a table nobody was sitting at. The bar isn't hidden any more: an
|
||||
// empty table is the one state a dealer meets before they know how any of this
|
||||
// works, so it says so and offers the next step.
|
||||
const state = HoldemState.createDefault();
|
||||
assert.equal(barOn(state).announcement(), "Nobody's at the table yet.");
|
||||
});
|
||||
|
||||
test("and a table with one named player has moved on from that", () => {
|
||||
const state = HoldemState.createDefault();
|
||||
state.seats[0].name = "Ann";
|
||||
assert.notEqual(barOn(state).announcement(), "Nobody's at the table yet.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("while a hand is running", () => {
|
||||
const dealt = () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
|
||||
state.seats.forEach((p, i) => { p.name = ["Ann", "Ben", "Cal", "Dee"][i] || ""; });
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
test("names who is on the clock, what it costs, and the pot", () => {
|
||||
const state = dealt();
|
||||
const said = barOn(state).announcement();
|
||||
assert.match(said, /to act/);
|
||||
assert.match(said, /\$2 to call/);
|
||||
assert.match(said, /Pot \$3\./);
|
||||
});
|
||||
|
||||
test("it is the seat that is actually acting, not the one after it", () => {
|
||||
const state = dealt();
|
||||
const name = HoldemState.displayName(state.seats[state.actingSeatIndex], state.actingSeatIndex);
|
||||
assert.ok(barOn(state).announcement().startsWith(name + " to act"));
|
||||
});
|
||||
|
||||
test("says checked to them rather than $0 to call", () => {
|
||||
// A number nobody owes is a number a dealer has to stop and parse.
|
||||
const state = dealt();
|
||||
state.currentBet = 0;
|
||||
assert.match(barOn(state).announcement(), /checked to them/);
|
||||
assert.doesNotMatch(barOn(state).announcement(), /\$0 to call/);
|
||||
});
|
||||
|
||||
test("a street with nobody left to act says the street is done", () => {
|
||||
const state = dealt();
|
||||
state.actingSeatIndex = null;
|
||||
state.street = "flop";
|
||||
assert.equal(barOn(state).announcement(), "Flop betting is done.");
|
||||
});
|
||||
|
||||
test("and a finished hand points at the pot", () => {
|
||||
const state = dealt();
|
||||
state.seats.forEach((p, i) => { if (i !== 0) p.folded = true; });
|
||||
assert.equal(Betting.isHandOver(state), true);
|
||||
assert.equal(barOn(state).announcement(), "Hand over. Resolve the pot.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("how often it speaks", () => {
|
||||
// A live region that is handed the same sentence twice reads it out twice, and
|
||||
// render() runs for a great many reasons that have nothing to do with whose turn
|
||||
// it is — a resize, a panel opening, a note being typed.
|
||||
const stubTarget = () => {
|
||||
const el = { textContent: "" };
|
||||
global.document = { getElementById: id => (id === "actionAnnouncer" ? el : null) };
|
||||
return el;
|
||||
};
|
||||
|
||||
test("says nothing new when nothing changed", () => {
|
||||
const el = stubTarget();
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
|
||||
Betting.beginHand(state);
|
||||
const bar = barOn(state);
|
||||
|
||||
bar.announce();
|
||||
const first = el.textContent;
|
||||
assert.ok(first.length > 0);
|
||||
|
||||
el.textContent = "CLEARED"; // if announce() writes again, this comes back
|
||||
bar.announce();
|
||||
assert.equal(el.textContent, "CLEARED", "the same sentence was not written twice");
|
||||
delete global.document;
|
||||
});
|
||||
|
||||
test("but does when the action moves", () => {
|
||||
const el = stubTarget();
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
|
||||
Betting.beginHand(state);
|
||||
const bar = barOn(state);
|
||||
|
||||
bar.announce();
|
||||
const first = el.textContent;
|
||||
Betting.call(state, state.actingSeatIndex);
|
||||
bar.announce();
|
||||
|
||||
assert.notEqual(el.textContent, first);
|
||||
assert.match(el.textContent, /to act/);
|
||||
delete global.document;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// Perceptual contrast (APCA), and how much of it each piece of text needs.
|
||||
//
|
||||
// The audit already had a contrast check and it passed, which is why this exists. That
|
||||
// check measures WCAG 2 contrast ratio, and WCAG 2's own authors say it is the wrong
|
||||
// instrument for this page: the ratio is a luminance gap, and a luminance gap
|
||||
// systematically *overstates* readability once one of the two colours is near black.
|
||||
// This table's page is #141414 and its panels are darker still, so every reading it
|
||||
// takes is on the side of the algorithm where it is least reliable. Text can clear
|
||||
// 4.5:1 and still read as a smudge.
|
||||
//
|
||||
// APCA asks the other question — can text of *this size and weight* be read on this
|
||||
// background — and answers it on a scale where a given number means the same
|
||||
// readability at either polarity. It replaces nothing: the WCAG check stays, because
|
||||
// that is the number an accessibility audit will ask for. This is the one that says
|
||||
// whether a dealer can read it in a dim room.
|
||||
//
|
||||
// Constants are 0.98G-4g, and the implementation is checked against the eight
|
||||
// published reference pairs in apcaMath.test.js. An unverified contrast checker is
|
||||
// worse than none, because it launders a guess into a number.
|
||||
|
||||
const MAIN_TRC = 2.4;
|
||||
const R_CO = 0.2126729, G_CO = 0.7151522, B_CO = 0.0721750;
|
||||
const NORM_BG = 0.56, NORM_TXT = 0.57, REV_TXT = 0.62, REV_BG = 0.65;
|
||||
const BLK_THRS = 0.022, BLK_CLMP = 1.414;
|
||||
const SCALE_BOW = 1.14, SCALE_WOB = 1.14;
|
||||
const LO_BOW_OFFSET = 0.027, LO_WOB_OFFSET = 0.027;
|
||||
const DELTA_Y_MIN = 0.0005, LO_CLIP = 0.001;
|
||||
|
||||
// Screen luminance. Note this is a straight exponent, not the sRGB piecewise curve —
|
||||
// APCA specifies the simple 2.4 power and the two are not interchangeable here.
|
||||
function sRGBtoY(rgb) {
|
||||
const s = c => Math.pow(c / 255.0, MAIN_TRC);
|
||||
return R_CO * s(rgb[0]) + G_CO * s(rgb[1]) + B_CO * s(rgb[2]);
|
||||
}
|
||||
|
||||
// Signed: positive is dark text on light, negative is light text on dark. The sign
|
||||
// carries the polarity, and polarity is not cosmetic — the same luminance pair reads
|
||||
// differently each way round, which is the whole reason this exists.
|
||||
function contrast(txtY, bgY) {
|
||||
txtY = txtY > BLK_THRS ? txtY : txtY + Math.pow(BLK_THRS - txtY, BLK_CLMP);
|
||||
bgY = bgY > BLK_THRS ? bgY : bgY + Math.pow(BLK_THRS - bgY, BLK_CLMP);
|
||||
|
||||
if (Math.abs(bgY - txtY) < DELTA_Y_MIN) return 0.0;
|
||||
|
||||
let sapc, out;
|
||||
if (bgY > txtY) {
|
||||
sapc = (Math.pow(bgY, NORM_BG) - Math.pow(txtY, NORM_TXT)) * SCALE_BOW;
|
||||
out = sapc < LO_CLIP ? 0.0 : sapc - LO_BOW_OFFSET;
|
||||
} else {
|
||||
sapc = (Math.pow(bgY, REV_BG) - Math.pow(txtY, REV_TXT)) * SCALE_WOB;
|
||||
out = sapc > -LO_CLIP ? 0.0 : sapc + LO_WOB_OFFSET;
|
||||
}
|
||||
return out * 100;
|
||||
}
|
||||
|
||||
function lc(textRgb, bgRgb) {
|
||||
return contrast(sRGBtoY(textRgb), sRGBtoY(bgRgb));
|
||||
}
|
||||
|
||||
// How much contrast this text needs, from the published APCA levels:
|
||||
//
|
||||
// Lc 90 fluent body text; and the floor for anything at 14px/400 or 12px/400
|
||||
// Lc 75 the minimum for a column of body text (18px/400, 16px/500, 14px/700)
|
||||
// Lc 60 the minimum for content text that isn't a column or block
|
||||
// Lc 45 larger, heavier text — headlines at 36px/400 or 24px/700
|
||||
// Lc 30 the absolute floor, and only for genuinely spot-read text
|
||||
//
|
||||
// Interpolating between those anchors rather than inventing a curve. Weight is folded
|
||||
// in as an effective size because that is how the published table behaves: bolder
|
||||
// strokes buy roughly the same readability as bigger ones, and 14px/700 sitting at the
|
||||
// same Lc 75 as 18px/400 is the anchor that fixes the exchange rate.
|
||||
function requiredLc(px, weight = 400) {
|
||||
const heavier = Math.max(0, (Number(weight) || 400) - 400) / 300; // 400..700 -> 0..1
|
||||
const effective = px * (1 + 0.28 * heavier); // 14px/700 ~= 18px/400
|
||||
|
||||
const anchors = [
|
||||
[12, 90],
|
||||
[14, 90],
|
||||
[15, 82],
|
||||
[18, 75],
|
||||
[24, 60],
|
||||
[36, 45],
|
||||
];
|
||||
if (effective <= anchors[0][0]) return anchors[0][1];
|
||||
const last = anchors[anchors.length - 1];
|
||||
if (effective >= last[0]) return last[1];
|
||||
for (let i = 1; i < anchors.length; i++) {
|
||||
const [x0, y0] = anchors[i - 1];
|
||||
const [x1, y1] = anchors[i];
|
||||
if (effective <= x1) {
|
||||
const t = (effective - x0) / (x1 - x0);
|
||||
return y0 + t * (y1 - y0);
|
||||
}
|
||||
}
|
||||
return last[1];
|
||||
}
|
||||
|
||||
module.exports = { sRGBtoY, contrast, lc, requiredLc };
|
||||
@@ -0,0 +1,141 @@
|
||||
// The contrast arithmetic, checked against the numbers its authors publish.
|
||||
//
|
||||
// A contrast checker that is wrong is worse than not having one, because it launders a
|
||||
// guess into a number and then a stylesheet gets tuned to it. The eight pairs below are
|
||||
// the reference values published with APCA 0.98G-4g; if the implementation drifts from
|
||||
// them by so much as a rounding change, these fail and nothing downstream is believed.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { lc, contrast, sRGBtoY, requiredLc } = require("./apca");
|
||||
|
||||
const hex = h => {
|
||||
h = h.replace("#", "");
|
||||
if (h.length === 3) h = h.split("").map(c => c + c).join("");
|
||||
return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16));
|
||||
};
|
||||
|
||||
describe("the published reference pairs", () => {
|
||||
// Text first, background second, exactly as the reference table lists them.
|
||||
const CASES = [
|
||||
["#888", "#fff", 63.056469930209424],
|
||||
["#fff", "#888", -68.54146436644962],
|
||||
["#000", "#aaa", 58.146262578561334],
|
||||
["#aaa", "#000", -56.24113336839742],
|
||||
["#123", "#def", 91.66830811481631],
|
||||
["#def", "#123", -93.06770049484275],
|
||||
["#123", "#444", 8.32326136957393],
|
||||
["#444", "#123", -7.526878460278154],
|
||||
];
|
||||
|
||||
CASES.forEach(([text, bg, expected]) => {
|
||||
test(`${text} on ${bg} is Lc ${expected.toFixed(2)}`, () => {
|
||||
assert.ok(Math.abs(lc(hex(text), hex(bg)) - expected) < 1e-9,
|
||||
`got ${lc(hex(text), hex(bg))}, want ${expected}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("polarity", () => {
|
||||
test("is signed, and the sign is not decoration", () => {
|
||||
// Dark text on light is positive, light text on dark negative. The two are not
|
||||
// mirror images — swapping them changes the magnitude as well as the sign,
|
||||
// which is the entire reason this is used instead of a ratio.
|
||||
const dark = lc(hex("#888"), hex("#fff"));
|
||||
const light = lc(hex("#fff"), hex("#888"));
|
||||
assert.ok(dark > 0 && light < 0);
|
||||
assert.ok(Math.abs(Math.abs(dark) - Math.abs(light)) > 5,
|
||||
"the same pair reads differently each way round");
|
||||
});
|
||||
|
||||
test("a colour on itself is nothing at all", () => {
|
||||
assert.equal(lc(hex("#2c2c2c"), hex("#2c2c2c")), 0);
|
||||
});
|
||||
|
||||
test("and so is a difference too small to see", () => {
|
||||
// Below deltaYmin the answer is zero rather than a tiny number, so a rounding
|
||||
// artefact can never be reported as contrast.
|
||||
assert.equal(lc([44, 44, 44], [44, 44, 45]), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("luminance", () => {
|
||||
test("is the straight 2.4 power, not the sRGB piecewise curve", () => {
|
||||
// Worth pinning: the two are close enough to look right and different enough to
|
||||
// move a borderline reading. Mid grey under the piecewise curve is ~0.2159.
|
||||
assert.ok(Math.abs(sRGBtoY([128, 128, 128]) - Math.pow(128 / 255, 2.4)) < 1e-6);
|
||||
});
|
||||
|
||||
test("white is as close to 1 as the published coefficients get", () => {
|
||||
// 1.0000001, not 1. The three coefficients are given to seven places and sum to
|
||||
// a hair over one, and that is the specification rather than a mistake to
|
||||
// correct — the reference pairs above only come out exact with these values.
|
||||
// Asserted loosely on purpose: tightening this to 1e-12 fails on correct code.
|
||||
assert.ok(Math.abs(sRGBtoY([255, 255, 255]) - 1) < 1e-6);
|
||||
assert.equal(sRGBtoY([0, 0, 0]), 0);
|
||||
});
|
||||
|
||||
test("green carries most of it", () => {
|
||||
// Not arbitrary: it's why gold on green was the worst pairing on the felt.
|
||||
assert.ok(sRGBtoY([0, 255, 0]) > sRGBtoY([255, 0, 0]) + sRGBtoY([0, 0, 255]));
|
||||
});
|
||||
|
||||
test("contrast() takes luminances, lc() takes colours", () => {
|
||||
assert.equal(contrast(sRGBtoY(hex("#888")), sRGBtoY(hex("#fff"))), lc(hex("#888"), hex("#fff")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("how much contrast a given piece of text needs", () => {
|
||||
test("the published anchors land where they should", () => {
|
||||
assert.equal(Math.round(requiredLc(12, 400)), 90);
|
||||
assert.equal(Math.round(requiredLc(14, 400)), 90);
|
||||
assert.equal(Math.round(requiredLc(18, 400)), 75);
|
||||
assert.equal(Math.round(requiredLc(24, 400)), 60);
|
||||
assert.equal(Math.round(requiredLc(36, 400)), 45);
|
||||
});
|
||||
|
||||
test("smaller text asks for more, never less", () => {
|
||||
for (let px = 9; px < 40; px++) {
|
||||
assert.ok(requiredLc(px, 400) >= requiredLc(px + 1, 400),
|
||||
`${px}px should ask for at least as much as ${px + 1}px`);
|
||||
}
|
||||
});
|
||||
|
||||
test("weight buys size, at the rate the published table sets", () => {
|
||||
// 14px/700 and 18px/400 sit at the same Lc 75 in the table, and that pair is
|
||||
// what fixes the exchange rate between weight and size here.
|
||||
assert.ok(Math.abs(requiredLc(14, 700) - requiredLc(18, 400)) < 3);
|
||||
assert.ok(requiredLc(14, 700) < requiredLc(14, 400), "bold asks for less than regular");
|
||||
});
|
||||
|
||||
test("it never runs off either end", () => {
|
||||
assert.equal(requiredLc(4, 400), 90);
|
||||
assert.equal(requiredLc(200, 900), 45);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the page's own colours measure", () => {
|
||||
// These are the readings the round was built on. Pinned so that a future change to
|
||||
// the tokens has to look at them rather than move them by accident.
|
||||
const PANEL = hex("#1a1a1a");
|
||||
|
||||
test("the greys these tokens replaced were below the floor", () => {
|
||||
assert.ok(Math.abs(lc(hex("#9a9a9a"), PANEL)) < 50, "the old muted grey");
|
||||
assert.ok(Math.abs(lc(hex("#8a8a8a"), PANEL)) < 40, "the old dim grey");
|
||||
});
|
||||
|
||||
test("and the ones that replaced them clear it", () => {
|
||||
assert.ok(Math.abs(lc(hex("#c8c8c8"), PANEL)) >= 60, "muted");
|
||||
assert.ok(Math.abs(lc(hex("#b4b4b4"), PANEL)) >= 58, "dim");
|
||||
});
|
||||
|
||||
test("the three tiers are still three tiers", () => {
|
||||
// The worry with lifting muted and dim is that they collapse into --text and the
|
||||
// page flattens. They don't: the spread is wider than it was.
|
||||
const text = Math.abs(lc(hex("#eeeeee"), PANEL));
|
||||
const muted = Math.abs(lc(hex("#c8c8c8"), PANEL));
|
||||
const dim = Math.abs(lc(hex("#b4b4b4"), PANEL));
|
||||
assert.ok(text > muted && muted > dim, "still ordered");
|
||||
assert.ok(text - dim > 30, "and still a real spread");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
// Chips the pot was priced with that nobody actually paid.
|
||||
//
|
||||
// There are two ways a contribution gets into this tool. Tapping the action moves
|
||||
// chips: the bet leaves the stack, sits in front of the player, and is swept into their
|
||||
// hand total at the end of the street. The other is the "without live betting" panel —
|
||||
// a base pot amount, or chips counted out on a seat — where the dealer is recording a
|
||||
// hand they are watching rather than driving, and nothing ever leaves a stack.
|
||||
//
|
||||
// The pot wizard prices both the same way and awarding it credits the winner, so in
|
||||
// that second mode the table simply grew.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, Betting, HoldemState, chipsOnTable } = require("./load");
|
||||
|
||||
const basePotTable = (base = 50, stack = 200) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 4);
|
||||
state.basePotAmount = base;
|
||||
["Ann", "Ben", "Cal"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].stack = stack;
|
||||
state.seats[i].buyIns = stack;
|
||||
});
|
||||
return state;
|
||||
};
|
||||
|
||||
const pricedPot = (state) => state.seats.reduce(
|
||||
(sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0);
|
||||
|
||||
describe("what nobody paid", () => {
|
||||
test("comes off the stacks that owe it", () => {
|
||||
const state = basePotTable();
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
assert.equal(collected.total, 150);
|
||||
assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben", "Cal"]);
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [150, 150, 150]);
|
||||
});
|
||||
|
||||
test("and lands where the rest of the tool looks for chips in the middle", () => {
|
||||
const state = basePotTable();
|
||||
Betting.collectAssumedContributions(state);
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.amountOverride), [50, 50, 50]);
|
||||
assert.equal(HoldemState.unresolvedContributions(state), 150);
|
||||
});
|
||||
|
||||
test("chips counted out on a seat are money too", () => {
|
||||
const state = basePotTable(0);
|
||||
state.seats[0].chipCounts = { red: 4 }; // four $5 chips
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
assert.equal(collected.total, 20);
|
||||
assert.equal(state.seats[0].stack, 180);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what has already been paid", () => {
|
||||
test("a live bet is left exactly alone", () => {
|
||||
const state = basePotTable(0);
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
Betting.beginHand(state);
|
||||
const before = state.seats.map(p => Betting.stackOf(state, p));
|
||||
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
assert.equal(collected.total, 0, "the blinds are already off their stacks");
|
||||
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
|
||||
});
|
||||
|
||||
test("nor is a swept street taken a second time", () => {
|
||||
const state = basePotTable(0);
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
Betting.beginHand(state);
|
||||
Betting.sweepToHandTotals(state);
|
||||
const before = state.seats.map(p => Betting.stackOf(state, p));
|
||||
|
||||
assert.equal(Betting.collectAssumedContributions(state).total, 0);
|
||||
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
|
||||
});
|
||||
|
||||
test("a base pot on top of a live bet only collects the difference", () => {
|
||||
// The base pot says "assume everyone is in for fifty". Somebody who has already
|
||||
// put twenty in owes thirty of it, not fifty.
|
||||
const state = basePotTable(50);
|
||||
state.seats[0].committed = 20;
|
||||
state.seats[0].stack = 180;
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
const ann = collected.taken.find(row => row.name === "Ann");
|
||||
assert.equal(ann.amount, 30);
|
||||
assert.equal(state.seats[0].stack, 150, "fifty in the middle, either way");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it will not do", () => {
|
||||
test("take more than a player is holding", () => {
|
||||
const state = basePotTable(50);
|
||||
state.seats[1].stack = 20;
|
||||
Betting.collectAssumedContributions(state);
|
||||
assert.equal(state.seats[1].stack, 0, "all of it, and no further");
|
||||
assert.equal(state.seats[1].amountOverride, 20);
|
||||
});
|
||||
|
||||
test("touch an empty chair", () => {
|
||||
const state = basePotTable();
|
||||
const before = { ...state.seats[3] };
|
||||
Betting.collectAssumedContributions(state);
|
||||
assert.equal(state.seats[3].stack, before.stack);
|
||||
assert.equal(state.seats[3].amountOverride, before.amountOverride);
|
||||
});
|
||||
|
||||
test("or a seat that isn't in the hand", () => {
|
||||
// The base pot stands in for "everyone put the same in", and somebody sitting
|
||||
// out did not.
|
||||
const state = basePotTable();
|
||||
state.seats[2].inHand = false;
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben"]);
|
||||
assert.equal(state.seats[2].stack, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the whole hand", () => {
|
||||
test("conserves the chips on the table, which it did not before", () => {
|
||||
const state = basePotTable();
|
||||
const total = chipsOnTable(state);
|
||||
const pot = pricedPot(state);
|
||||
|
||||
Betting.collectAssumedContributions(state);
|
||||
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), total, "six hundred before, six hundred after");
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [300, 150, 150]);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("and the bank check stays balanced all the way through", () => {
|
||||
const state = basePotTable();
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
Betting.collectAssumedContributions(state);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true, "chips are in the middle, not gone");
|
||||
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("a live-betting hand is unchanged by any of this", () => {
|
||||
const state = basePotTable(0);
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
const total = chipsOnTable(state);
|
||||
Betting.beginHand(state);
|
||||
[3, 0, 1, 2].forEach(() => { if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); });
|
||||
Betting.sweepToHandTotals(state);
|
||||
const pot = HoldemState.unresolvedContributions(state);
|
||||
|
||||
Betting.collectAssumedContributions(state);
|
||||
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), total);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("collecting twice takes nothing the second time", () => {
|
||||
// The pot wizard can be opened, cancelled and opened again.
|
||||
const state = basePotTable();
|
||||
Betting.collectAssumedContributions(state);
|
||||
const stacks = state.seats.map(p => p.stack);
|
||||
const again = Betting.collectAssumedContributions(state);
|
||||
assert.equal(again.total, 0);
|
||||
assert.deepEqual(state.seats.map(p => p.stack), stacks);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// The sums the contrast check runs on, tested away from a browser.
|
||||
//
|
||||
// The check itself needs a page to look at, but the arithmetic underneath it doesn't —
|
||||
// and the arithmetic is the part that decides whether a real fault is reported or
|
||||
// waved through. Faded text used to be filed as "not checked" rather than measured;
|
||||
// this is the compositing that replaced that.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const LayoutAudit = require("./uiAudit");
|
||||
|
||||
const WHITE = { r: 255, g: 255, b: 255, a: 1 };
|
||||
const BLACK = { r: 0, g: 0, b: 0, a: 1 };
|
||||
const PAGE = { r: 28, g: 28, b: 28, a: 1 }; // the felt page behind everything
|
||||
const PUCK = { r: 44, g: 44, b: 44, a: 1 }; // a seat's own background
|
||||
|
||||
describe("the ratio itself", () => {
|
||||
test("black on white is the full 21:1, either way round", () => {
|
||||
assert.ok(Math.abs(LayoutAudit.contrastRatio(BLACK, WHITE) - 21) < 0.01);
|
||||
assert.ok(Math.abs(LayoutAudit.contrastRatio(WHITE, BLACK) - 21) < 0.01);
|
||||
});
|
||||
|
||||
test("a colour against itself is 1:1", () => {
|
||||
assert.ok(Math.abs(LayoutAudit.contrastRatio(PUCK, PUCK) - 1) < 0.001);
|
||||
});
|
||||
});
|
||||
|
||||
describe("text on a box that is being faded", () => {
|
||||
// opacity on a box fades the box and its text together onto whatever is behind
|
||||
// it — so a dimmed seat is not grey text on a dark puck, it is a faded puck and
|
||||
// faded text both landing on the page.
|
||||
const fade = alpha => ({ alpha, behind: PAGE });
|
||||
|
||||
test("at full strength nothing is composited", () => {
|
||||
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 1, behind: PAGE });
|
||||
assert.deepEqual(painted.fg, WHITE);
|
||||
assert.deepEqual(painted.bg, PUCK);
|
||||
});
|
||||
|
||||
test("fading moves both the text and its background, not just the text", () => {
|
||||
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
|
||||
assert.ok(painted.fg.r < WHITE.r, "the text dims");
|
||||
assert.ok(painted.bg.r < PUCK.r, "and so does what it sits on");
|
||||
assert.ok(painted.bg.r > PAGE.r, "but not all the way to the page");
|
||||
});
|
||||
|
||||
test("which is what stops a fade being reported as a fault it isn't", () => {
|
||||
// Dimming light text on a dark puck over a dark page costs contrast, but far
|
||||
// less than measuring the faded text against the *unfaded* puck would claim.
|
||||
const honest = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
|
||||
const wrong = { fg: LayoutAudit.over({ ...WHITE, a: 0.7 }, PUCK), bg: PUCK };
|
||||
assert.ok(LayoutAudit.contrastRatio(honest.fg, honest.bg) >
|
||||
LayoutAudit.contrastRatio(wrong.fg, wrong.bg));
|
||||
});
|
||||
|
||||
test("a fade all the way out lands everything on the page behind", () => {
|
||||
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0));
|
||||
assert.deepEqual(painted.fg, PAGE);
|
||||
assert.deepEqual(painted.bg, PAGE);
|
||||
});
|
||||
|
||||
test("with nothing known to be behind it, the fade is left alone", () => {
|
||||
// Better to measure the unfaded pair than to invent a backdrop.
|
||||
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 0.5, behind: null });
|
||||
assert.deepEqual(painted.fg, WHITE);
|
||||
assert.deepEqual(painted.bg, PUCK);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a fade makes small grey text worse, which is the fault it went looking for", () => {
|
||||
// Deliberately not asserting a particular CSS colour here: the page's own greys are
|
||||
// measured in the browser, against the real gradients, where they live. What this
|
||||
// pins is the direction — that dimming a box costs its text contrast, which is why
|
||||
// filing faded text as "not checked" hid faults rather than avoiding false ones.
|
||||
test("the same grey scores lower once its box is dimmed", () => {
|
||||
const grey = LayoutAudit.parseColour("rgb(138, 138, 138)");
|
||||
const solid = LayoutAudit.asPainted(grey, PUCK, { alpha: 1, behind: PAGE });
|
||||
const dimmed = LayoutAudit.asPainted(grey, PUCK, { alpha: 0.7, behind: PAGE });
|
||||
assert.ok(LayoutAudit.contrastRatio(dimmed.fg, dimmed.bg) <
|
||||
LayoutAudit.contrastRatio(solid.fg, solid.bg));
|
||||
});
|
||||
|
||||
test("and a lighter grey scores higher at the same fade", () => {
|
||||
const at = colour => {
|
||||
const painted = LayoutAudit.asPainted(LayoutAudit.parseColour(colour), PUCK, { alpha: 0.7, behind: PAGE });
|
||||
return LayoutAudit.contrastRatio(painted.fg, painted.bg);
|
||||
};
|
||||
assert.ok(at("rgb(174, 174, 174)") > at("rgb(138, 138, 138)"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading a colour off the page", () => {
|
||||
test("rgb and rgba both come back as numbers", () => {
|
||||
assert.deepEqual(LayoutAudit.parseColour("rgb(1, 2, 3)"), { r: 1, g: 2, b: 3, a: 1 });
|
||||
assert.deepEqual(LayoutAudit.parseColour("rgba(1, 2, 3, 0.5)"), { r: 1, g: 2, b: 3, a: 0.5 });
|
||||
});
|
||||
|
||||
test("a gradient gives up every stop, because text could be over any of them", () => {
|
||||
const stops = LayoutAudit.gradientStops("linear-gradient(rgb(203, 170, 240), rgb(169, 127, 214))");
|
||||
assert.equal(stops.length, 2);
|
||||
assert.deepEqual(stops[0], { r: 203, g: 170, b: 240, a: 1 });
|
||||
assert.deepEqual(stops[1], { r: 169, g: 127, b: 214, a: 1 });
|
||||
});
|
||||
|
||||
test("no background image, no stops", () => {
|
||||
assert.deepEqual(LayoutAudit.gradientStops("none"), []);
|
||||
});
|
||||
});
|
||||
@@ -167,3 +167,58 @@ describe("does the cash box cover the chips on the table", () => {
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the number the banker counts out", () => {
|
||||
// The standings had two signed columns and neither was an amount to pay. A player
|
||||
// up $140 on a $340 stack read "$140" under a heading saying "Cash out", at the
|
||||
// exact moment somebody was counting money onto the table.
|
||||
test("chips in front is what they are holding, not what they are up", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 340 });
|
||||
state.seats[0].name = "Ann";
|
||||
state.seats[0].buyIns = 200;
|
||||
state.seats[1].buyIns = 200;
|
||||
state.seats[1].stack = 60;
|
||||
|
||||
const rows = HoldemState.sessionStandings(state);
|
||||
const ann = rows.find(r => r.name === "Ann");
|
||||
assert.equal(ann.chipsInFront, 340, "the cash to count out");
|
||||
assert.equal(ann.cashPosition, 140, "and separately, that they are up $140");
|
||||
});
|
||||
|
||||
test("a player who is down still has chips to be handed", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 60 });
|
||||
state.seats[0].name = "Cal";
|
||||
state.seats.forEach(p => { p.buyIns = 200; });
|
||||
|
||||
const cal = HoldemState.sessionStandings(state).find(r => r.name === "Cal");
|
||||
assert.equal(cal.chipsInFront, 60, "they leave with $60 of chips");
|
||||
assert.equal(cal.cashPosition, -140, "having lost $140 of the $200 they brought");
|
||||
});
|
||||
|
||||
test("chips still out in the middle are counted as theirs", () => {
|
||||
// Cashing out mid-hand isn't a thing, but the panel is read mid-hand constantly
|
||||
// and a stack that drops by the size of a bet looks like money going missing.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 200 });
|
||||
state.seats[0].name = "Dee";
|
||||
state.seats.forEach(p => { p.buyIns = 200; });
|
||||
Betting.beginHand(state);
|
||||
Betting.raiseTo(state, 0, 50);
|
||||
|
||||
const dee = HoldemState.sessionStandings(state).find(r => r.name === "Dee");
|
||||
assert.equal(dee.chipsInFront, 200, "stack plus what's in the middle for them");
|
||||
assert.equal(dee.cashPosition, 0, "so they are still level");
|
||||
});
|
||||
|
||||
test("chips and up/down always differ by exactly the buy-in", () => {
|
||||
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3], stack: 100 });
|
||||
[340, 150, 60, 250].forEach((chips, i) => {
|
||||
state.seats[i].stack = chips;
|
||||
state.seats[i].buyIns = 200 + i * 50;
|
||||
});
|
||||
|
||||
HoldemState.sessionStandings(state).forEach(row => {
|
||||
assert.equal(Money.round(row.chipsInFront - row.boughtIn), row.cashPosition,
|
||||
`${row.name}'s two columns should tell one story`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
// Where a player's bet sits on the felt.
|
||||
//
|
||||
// The chips were the last thing on the table nobody had measured. They were laid out on
|
||||
// a ring of their own at a fixed 27/44 of the seat's reach — a fraction picked when the
|
||||
// seat ring was itself fixed — and the choice between that ring and clinging to the
|
||||
// puck was made by asking how wide the *window* was. Neither of those is a question
|
||||
// about the felt. A phone held sideways is 844px across and gives the felt 117px of
|
||||
// height: it passed the width test, took the ring, and every chip on it was painted
|
||||
// behind a seat. The seats carry `z-index: 1` and the bets layer carries none, so a
|
||||
// chip under a puck is not dimmed or half-hidden — it is gone, and with it the only
|
||||
// number saying what that player has in.
|
||||
//
|
||||
// Everything below is geometry, so it runs without a browser. The felts are the ones
|
||||
// measured on real screens: 692x117 is a phone on its side, 300x216 a phone upright.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render } = require("./load");
|
||||
|
||||
const RADII = { rx: 44, ry: 42 };
|
||||
const PUCK = { width: 96, height: 64 };
|
||||
const CHIP = { width: 44, height: 22 };
|
||||
|
||||
const felt = (width, height) => ({ width, height });
|
||||
const spacingOn = (box, puck = PUCK, chip = CHIP) =>
|
||||
({ box, puck, chip, clearance: Render.BET_CLEARANCE_PX });
|
||||
|
||||
// A chip's centre in pixels on a given felt.
|
||||
const chipPixels = (index, count, box, spacing, radii = RADII) => {
|
||||
const pos = Render.betPosition(index, count, box.width / box.height, radii, spacing);
|
||||
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
|
||||
};
|
||||
|
||||
const seatPixels = (index, count, box, radii = RADII) => {
|
||||
const pos = Render.seatPosition(index, count, box.width / box.height, radii);
|
||||
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
|
||||
};
|
||||
|
||||
// Two axis-aligned boxes, by their centres. The same test the seats settle among
|
||||
// themselves — see seatFootprint.test.js — because it is the same question.
|
||||
const boxesOverlap = (a, b, sizeA, sizeB) =>
|
||||
Math.abs(a.x - b.x) < (sizeA.width + sizeB.width) / 2 - 0.5 &&
|
||||
Math.abs(a.y - b.y) < (sizeA.height + sizeB.height) / 2 - 0.5;
|
||||
|
||||
describe("reaching the edge of a box", () => {
|
||||
test("straight out the side is half the width, straight up half the height", () => {
|
||||
assert.equal(Render.edgeReach(96, 64, 1, 0), 48);
|
||||
assert.equal(Render.edgeReach(96, 64, 0, -1), 32);
|
||||
assert.equal(Render.edgeReach(96, 64, -1, 0), 48);
|
||||
});
|
||||
|
||||
test("a corner is whichever edge the ray meets first", () => {
|
||||
// 45° out of a 96x64 box: the top and bottom are nearer than the sides, so the
|
||||
// ray leaves through one of those. A single radius — half the diagonal, 57.7 —
|
||||
// would put a chip 25px further out than it needs to be, which on a 117px felt
|
||||
// is the difference between clear of the puck and off the table.
|
||||
const diagonal = Render.edgeReach(96, 64, Math.SQRT1_2, Math.SQRT1_2);
|
||||
assert.ok(Math.abs(diagonal - 32 / Math.SQRT1_2) < 1e-9, `got ${diagonal}`);
|
||||
assert.ok(diagonal < 48, "and never further than the wider half-edge");
|
||||
});
|
||||
|
||||
test("a square is the same in every direction it is asked about", () => {
|
||||
const straight = Render.edgeReach(50, 50, 0, 1);
|
||||
assert.equal(Render.edgeReach(50, 50, 1, 0), straight);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getting one box clear of another", () => {
|
||||
const PUCK96 = { width: 96, height: 88 };
|
||||
const CHIP50 = { width: 50, height: 21 };
|
||||
|
||||
// Travel `t` along `u` from the puck's centre, then ask whether the two boxes are
|
||||
// apart. This is the question clearanceReach is supposed to answer, asked the long
|
||||
// way round so the answer can be checked rather than restated.
|
||||
const apart = (t, ux, uy) =>
|
||||
Math.abs(t * ux) >= (PUCK96.width + CHIP50.width) / 2 - 1e-9 ||
|
||||
Math.abs(t * uy) >= (PUCK96.height + CHIP50.height) / 2 - 1e-9;
|
||||
|
||||
test("straight up and down it is what adding the two half-heights gives", () => {
|
||||
assert.equal(Render.clearanceReach(PUCK96, CHIP50, 0, -1), (88 + 21) / 2);
|
||||
assert.equal(Render.clearanceReach(PUCK96, CHIP50, 1, 0), (96 + 50) / 2);
|
||||
});
|
||||
|
||||
test("on a diagonal it is more than the two reaches added together", () => {
|
||||
// The bug this replaced. Two axis-aligned boxes are clear when they are apart on
|
||||
// one axis, and the travel that achieves that is the reach of the box you get by
|
||||
// adding their widths and their heights — which is never less, and on a diagonal
|
||||
// is more, than adding the two boxes' own reaches. At 768x1024 the difference was
|
||||
// 11px, and 11px is how far inside its own player's puck the chip came to rest.
|
||||
const dirs = [[0.83, 0.56], [0.6, 0.8], [0.9, 0.44], [Math.SQRT1_2, Math.SQRT1_2]];
|
||||
let shortfalls = 0;
|
||||
dirs.forEach(([ux, uy]) => {
|
||||
const summed = Render.edgeReach(PUCK96.width, PUCK96.height, ux, uy) +
|
||||
Render.edgeReach(CHIP50.width, CHIP50.height, ux, uy);
|
||||
const proper = Render.clearanceReach(PUCK96, CHIP50, ux, uy);
|
||||
assert.ok(proper >= summed - 1e-9, `${ux},${uy}: ${proper} < ${summed}`);
|
||||
if (proper <= summed + 1e-9) return;
|
||||
// Where they differ, the old answer was not merely smaller — it was wrong:
|
||||
// travelling that far leaves the two boxes still on top of each other.
|
||||
shortfalls++;
|
||||
assert.ok(!apart(summed, ux, uy),
|
||||
`${ux},${uy}: adding the reaches gave ${summed.toFixed(1)}, which is enough after all`);
|
||||
});
|
||||
assert.ok(shortfalls >= 2, "expected the two to actually differ on some of these");
|
||||
});
|
||||
|
||||
test("and it is exactly enough — a hair less is not", () => {
|
||||
const dirs = [[0, -1], [1, 0], [0.83, 0.56], [0.6, -0.8], [-0.9, 0.44]];
|
||||
dirs.forEach(([ux, uy]) => {
|
||||
const t = Render.clearanceReach(PUCK96, CHIP50, ux, uy);
|
||||
assert.ok(apart(t, ux, uy), `${ux},${uy}: ${t}px still overlaps`);
|
||||
assert.ok(!apart(t - 0.5, ux, uy), `${ux},${uy}: ${t}px is further than needed`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a chip on the ring", () => {
|
||||
test("with nothing measured it keeps the proportions the ring always used", () => {
|
||||
// The unit suite and the very first render both call it this way. 27/44 and
|
||||
// 25/42 of the seat's own reach, so the chips move with the ring rather than
|
||||
// with the felt.
|
||||
const seat = Render.seatPosition(3, 9, 1.4, RADII);
|
||||
const chip = Render.betPosition(3, 9, 1.4, RADII, null);
|
||||
assert.ok(Math.abs((chip.left - 50) - (seat.left - 50) * (27 / 44)) < 1e-9);
|
||||
assert.ok(Math.abs((chip.top - 50) - (seat.top - 50) * (25 / 42)) < 1e-9);
|
||||
});
|
||||
|
||||
test("measured, it sits just clear of its own puck rather than at a fixed fraction", () => {
|
||||
const box = felt(560, 400);
|
||||
const spacing = spacingOn(box);
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const seat = seatPixels(i, 9, box);
|
||||
const chip = chipPixels(i, 9, box, spacing);
|
||||
assert.ok(!boxesOverlap(seat, chip, PUCK, CHIP),
|
||||
`seat ${i + 1}'s own chip is under its own puck`);
|
||||
}
|
||||
});
|
||||
|
||||
test("and never wanders into the pot badge in the middle", () => {
|
||||
// The floor: 38% of the way out. Without it a chip walked in past a puck that
|
||||
// is 96px wide on a felt whose reach is 90px would land on the pot total, which
|
||||
// is the same fault at the other end of the spoke.
|
||||
const box = felt(300, 216);
|
||||
const spacing = spacingOn(box, { width: 200, height: 150 });
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const seat = seatPixels(i, 6, box);
|
||||
const chip = chipPixels(i, 6, box, spacing);
|
||||
const seatReach = Math.hypot(seat.x - box.width / 2, seat.y - box.height / 2);
|
||||
const chipReach = Math.hypot(chip.x - box.width / 2, chip.y - box.height / 2);
|
||||
assert.ok(chipReach >= seatReach * 0.38 - 0.5,
|
||||
`chip ${i + 1} is ${chipReach.toFixed(1)}px out of a possible ${seatReach.toFixed(1)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a seat sitting dead centre gets its chip put somewhere, not NaN", () => {
|
||||
const box = felt(400, 300);
|
||||
const pos = Render.betPosition(0, 1, box.width / box.height, { rx: 0, ry: 0 }, spacingOn(box));
|
||||
assert.ok(Number.isFinite(pos.left) && Number.isFinite(pos.top), JSON.stringify(pos));
|
||||
});
|
||||
});
|
||||
|
||||
describe("deciding whether a ring of chips fits at all", () => {
|
||||
// The shape the layout works in: one entry per chip on the felt, carrying its own
|
||||
// measured size, because a chip is as wide as the number printed on it.
|
||||
const tableOf = (count, chip = CHIP) => {
|
||||
Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) };
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
({ index: i, width: chip.width, height: chip.height }));
|
||||
};
|
||||
|
||||
test("a desktop felt has room for one", () => {
|
||||
const box = felt(700, 500);
|
||||
const all = tableOf(9);
|
||||
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), true);
|
||||
});
|
||||
|
||||
test("a phone on its side has not, whatever the window's width says about it", () => {
|
||||
// 692x117, measured on an 844px-wide phone in landscape — a viewport that sails
|
||||
// past a `max-width: 700px` test. The ring is a slot: the 38% floor puts a chip
|
||||
// on the seat across the table from it.
|
||||
const box = felt(692, 117);
|
||||
const all = tableOf(9);
|
||||
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), false,
|
||||
"this is the felt where every chip was painted behind a puck");
|
||||
});
|
||||
|
||||
test("its answer is the one the geometry gives", () => {
|
||||
// Not a restatement of the two cases above: whatever it returns, it has to agree
|
||||
// with laying the chips out and measuring them against every puck on the felt.
|
||||
const shapes = [felt(700, 500), felt(692, 117), felt(300, 216), felt(1000, 620), felt(480, 300)];
|
||||
shapes.forEach(box => {
|
||||
const all = tableOf(9);
|
||||
const spacing = spacingOn(box);
|
||||
const aspect = box.width / box.height;
|
||||
const clear = all.every(({ index: i }) => {
|
||||
const chip = chipPixels(i, 9, box, spacing);
|
||||
return all.every(({ index: j }) => !boxesOverlap(chip, seatPixels(j, 9, box), CHIP, PUCK));
|
||||
});
|
||||
assert.equal(Render.chipsClearTheSeats(all, aspect, RADII, spacing), clear,
|
||||
`${box.width}x${box.height}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("each chip is asked about at its own size", () => {
|
||||
// A chip is as wide as the number on it: at 768x1024, four players' chips were
|
||||
// 40px and one holding a four-figure bet was 47. Answering the question once, at
|
||||
// the widest, told the whole felt it had no room for a ring — and dropped all
|
||||
// five chips onto their own players' stacks to solve a problem four of them
|
||||
// did not have.
|
||||
// The felt and the pucks are the iPad's own, measured: 672x672, pucks 96x88.
|
||||
const box = felt(672, 672);
|
||||
const narrow = { width: 40, height: 20 };
|
||||
const huge = { width: 420, height: 380 };
|
||||
const spacing = { box, puck: { width: 96, height: 88 }, clearance: Render.BET_CLEARANCE_PX };
|
||||
const aspect = box.width / box.height;
|
||||
|
||||
assert.equal(Render.chipsClearTheSeats(tableOf(9, narrow), aspect, RADII, spacing), true,
|
||||
"a felt this size has room for a ring of ordinary chips");
|
||||
assert.equal(Render.chipsClearTheSeats(tableOf(9, huge), aspect, RADII, spacing), false,
|
||||
"and none at all for a ring of chips that size");
|
||||
|
||||
// Now one of each. The answer has to come from the sizes in the list, not from
|
||||
// one size standing in for all of them: with one huge chip the ring is off, and
|
||||
// taking that chip out of the question puts it back.
|
||||
const mixed = tableOf(9, narrow);
|
||||
mixed[0] = { index: 0, ...huge };
|
||||
assert.equal(Render.chipsClearTheSeats(mixed, aspect, RADII, spacing), false);
|
||||
assert.equal(Render.chipsClearTheSeats(mixed.slice(1), aspect, RADII, spacing), true,
|
||||
"the eight ordinary ones were never the problem");
|
||||
});
|
||||
|
||||
test("and at the chips against each other", () => {
|
||||
// The ring is a smaller circle than the seats' one and holds the same number of
|
||||
// things, so it is the tighter fit of the two. Measured on a Pixel 7 with twelve
|
||||
// players, two $10s on the ring sat on top of each other while every puck on the
|
||||
// felt was clear — so a check that only looks at the pucks says yes to a layout
|
||||
// that hides one player's bet behind another's.
|
||||
// The Pixel 7's own numbers, measured: a 300x336 felt on a ring of 44 by 40.84,
|
||||
// pucks 44x55, a pot badge 64x53, and the seven players who had put chips in.
|
||||
const box = felt(300, 336);
|
||||
const radii = { rx: 44, ry: 40.84 };
|
||||
const puck = { width: 44, height: 55 };
|
||||
const pot = { x: 150, y: 168, width: 64, height: 53 };
|
||||
const spacing = { box, puck, pot, clearance: Render.BET_CLEARANCE_PX };
|
||||
const aspect = box.width / box.height;
|
||||
|
||||
Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) };
|
||||
const bettors = [2, 3, 4, 5, 6, 7, 8];
|
||||
const all = bettors.map(index => ({ index, width: index === 2 ? 44.1 : 49.7, height: 21 }));
|
||||
|
||||
const chips = all.map(entry => ({
|
||||
...entry,
|
||||
...chipPixels(entry.index, 12, box,
|
||||
{ ...spacing, chip: { width: entry.width, height: entry.height } }, radii),
|
||||
}));
|
||||
const seats = [...Array(12).keys()].map(i => seatPixels(i, 12, box, radii));
|
||||
|
||||
assert.equal(chips.some(chip => seats.some(seat => boxesOverlap(chip, seat, chip, puck))), false,
|
||||
"this table is chosen so the pucks are not the problem");
|
||||
assert.equal(chips.some(chip => boxesOverlap(chip, pot, chip, pot)), false,
|
||||
"nor the pot");
|
||||
assert.ok(chips.some((chip, i) => chips.some((other, j) => i !== j &&
|
||||
boxesOverlap(chip, other, chip, other))), "and so the chips are");
|
||||
|
||||
assert.equal(Render.chipsClearTheSeats(all, aspect, radii, spacing), false);
|
||||
});
|
||||
|
||||
test("it looks at every puck, not just the chip's own", () => {
|
||||
// The rule this replaced walked each chip in until it was clear of its *own*
|
||||
// seat and stopped there, which says nothing about the eight other pucks on the
|
||||
// felt. 560x190 is a felt where that rule is satisfied and the ring is still no
|
||||
// good: every chip clears the puck it belongs to, and one of them is sitting on
|
||||
// a stranger's.
|
||||
const box = felt(560, 190);
|
||||
const all = tableOf(9);
|
||||
const spacing = spacingOn(box);
|
||||
const chips = all.map(({ index: i }) => chipPixels(i, 9, box, spacing));
|
||||
|
||||
all.forEach(({ index: i }) => assert.ok(!boxesOverlap(chips[i], seatPixels(i, 9, box), CHIP, PUCK),
|
||||
`chip ${i + 1} is under its own puck, so this felt does not make the point`));
|
||||
assert.ok(all.some(({ index: i }) => all.some(({ index: j }) => i !== j &&
|
||||
boxesOverlap(chips[i], seatPixels(j, 9, box), CHIP, PUCK))),
|
||||
"expected a chip on somebody else's puck — what the old rule could not see");
|
||||
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacing), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a chip clinging to its puck", () => {
|
||||
// The fallback when there is no room for a ring: the chip hangs off the puck on the
|
||||
// side facing the empty middle of the felt.
|
||||
//
|
||||
// The felts the app actually draws, with the puck size each one settles on — read
|
||||
// off the running site at each viewport rather than guessed here, because the tight
|
||||
// felts shrink the puck too, and a test that holds the desktop's 96x64 puck against
|
||||
// a 528x83 felt is describing a table the app never draws.
|
||||
const REAL = [
|
||||
["Galaxy S8 on its side", felt(528, 83), { width: 44, height: 55 }],
|
||||
["iPhone on its side", felt(692, 113), { width: 72, height: 55 }],
|
||||
["Galaxy S8 upright, six-handed", felt(248, 280), { width: 78, height: 88 }],
|
||||
["Galaxy S8 upright, packed", felt(248, 280), { width: 44, height: 55 }],
|
||||
];
|
||||
// Chips are 21x17 everywhere they are anchored — the amount is the only thing on
|
||||
// one at that size.
|
||||
const TIGHT = { puck: { width: 44, height: 55 }, chip: { width: 21, height: 17 } };
|
||||
|
||||
// What each chip is told to do, worked out for the whole table at once.
|
||||
const placeAll = (count, box, puck, chip = TIGHT.chip, radii = RADII) => {
|
||||
Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) };
|
||||
const indices = [...Array(count).keys()];
|
||||
const entries = indices.map(i => ({ index: i, width: chip.width, height: chip.height }));
|
||||
const spacing = { box, puck, clearance: Render.BET_CLEARANCE_PX };
|
||||
const offs = Render.anchoredChipTravel(entries, count, box.width / box.height, RADII, spacing);
|
||||
return indices.map(i => {
|
||||
const seat = Render.seatPosition(i, count, box.width / box.height, radii);
|
||||
const at = { x: (seat.left / 100) * box.width, y: (seat.top / 100) * box.height };
|
||||
const off = offs[i];
|
||||
return { seat: at, chip: { x: at.x + off.x, y: at.y + off.y }, off, puck };
|
||||
});
|
||||
};
|
||||
|
||||
test("on its own, a chip asks to sit just clear of its puck", () => {
|
||||
const box = felt(692, 113);
|
||||
const puck = { width: 72, height: 55 };
|
||||
const seat = Render.seatPosition(0, 9, box.width / box.height, RADII);
|
||||
const off = Render.anchoredChipOffset(seat, puck, TIGHT.chip, box, RADII);
|
||||
const travel = Math.hypot(off.x, off.y);
|
||||
const ux = off.x / travel, uy = off.y / travel;
|
||||
assert.ok(Math.abs(travel -
|
||||
(Render.clearanceReach(puck, TIGHT.chip, ux, uy) + Render.BET_CLEARANCE_PX)) < 1e-6, `travelled ${travel}`);
|
||||
});
|
||||
|
||||
test("and gets it, where nothing else is in the way", () => {
|
||||
// 692x113 is the roomier of the two landscape felts. Every chip clears its own
|
||||
// puck and none of them has to give ground.
|
||||
placeAll(9, felt(692, 113), { width: 72, height: 55 }).forEach((row, i) => {
|
||||
assert.ok(!boxesOverlap(row.seat, row.chip, row.puck, TIGHT.chip),
|
||||
`seat ${i + 1}'s chip is inside its own puck, and nothing made it go there`);
|
||||
});
|
||||
});
|
||||
|
||||
test("it goes off the rim, not down the spoke", () => {
|
||||
// On a felt 692x113 the two are nowhere near the same direction. A seat on the
|
||||
// top rail has a spoke pointing diagonally inward — which along a slot this flat
|
||||
// runs sideways into the next seat — while off the rim is very nearly straight
|
||||
// down, into the empty middle. This is the seam that put two chips on top of
|
||||
// each other on a Galaxy S8.
|
||||
const box = felt(692, 113);
|
||||
const rows = placeAll(9, box, { width: 72, height: 55 });
|
||||
const top = rows.find(r => r.seat.y < box.height * 0.3 && r.seat.x > box.width * 0.7);
|
||||
assert.ok(top, "expected a seat up on the right of the top rail");
|
||||
assert.ok(Math.abs(top.off.y) > Math.abs(top.off.x) * 2,
|
||||
`chip went ${top.off.x.toFixed(1)},${top.off.y.toFixed(1)} — that is along the rail, not off it`);
|
||||
assert.ok(top.off.y > 0, "and downward, toward the middle of the felt");
|
||||
});
|
||||
|
||||
test("no two chips on a full table land on each other", () => {
|
||||
// Four sides gave three seams. There are no sides now, and this is what that is
|
||||
// worth — on every felt the app actually draws, at every table size.
|
||||
REAL.forEach(([name, box, puck]) => {
|
||||
[6, 9, 12].forEach(count => {
|
||||
const rows = placeAll(count, box, puck);
|
||||
for (let a = 0; a < rows.length; a++) {
|
||||
for (let b = a + 1; b < rows.length; b++) {
|
||||
assert.ok(!boxesOverlap(rows[a].chip, rows[b].chip, TIGHT.chip, TIGHT.chip),
|
||||
`${name}, ${count} seats: chips ${a + 1} and ${b + 1} overlap`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("a chip only gives ground when it is standing on something", () => {
|
||||
// The point of pulling chips back one at a time rather than by a rule that
|
||||
// applies to all of them. Stopping every chip at the felt's midline, or scaling
|
||||
// them all by one factor, takes travel from the seats at the ends of a long
|
||||
// rail — measured at 692x113, that parked two chips on their own player's stack
|
||||
// to solve a collision that did not exist.
|
||||
//
|
||||
// So: every chip either got everything it asked for, or putting the last step
|
||||
// back reintroduces a collision it is part of. The step goes back for all of
|
||||
// them at once, because a facing pair gives ground together and the pass that
|
||||
// finally separates such a pair moves both ends of it.
|
||||
const step = Render.CHIP_PULLBACK_STEP_PX;
|
||||
REAL.forEach(([name, box, puck]) => {
|
||||
[6, 9, 12].forEach(count => {
|
||||
const rows = placeAll(count, box, puck).map(row => {
|
||||
const travel = Math.hypot(row.off.x, row.off.y);
|
||||
const dir = travel < 1e-6 ? { x: 0, y: 0 } : { x: row.off.x / travel, y: row.off.y / travel };
|
||||
const asked = travel < 1e-6 ? 0 :
|
||||
Render.clearanceReach(puck, TIGHT.chip, dir.x, dir.y) +
|
||||
Render.BET_CLEARANCE_PX;
|
||||
const back = Math.min(asked, travel + step);
|
||||
return { ...row, travel, asked,
|
||||
undone: { x: row.seat.x + dir.x * back, y: row.seat.y + dir.y * back } };
|
||||
});
|
||||
|
||||
rows.forEach((row, i) => {
|
||||
if (row.travel < 1e-6 || row.travel >= row.asked - 1e-6) return;
|
||||
const onSomething = rows.some((other, j) => j !== i && (
|
||||
boxesOverlap(row.undone, other.undone, TIGHT.chip, TIGHT.chip) ||
|
||||
boxesOverlap(row.undone, other.seat, TIGHT.chip, puck)));
|
||||
assert.ok(onSomething,
|
||||
`${name}, ${count} seats: chip ${i + 1} stopped ${(row.asked - row.travel).toFixed(1)}px short of nothing`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("nobody is pushed further out than they asked to go", () => {
|
||||
// The pull-back only ever takes travel away. A chip pushed past its clearance
|
||||
// would be floating out on the felt with nothing to say whose it is.
|
||||
REAL.forEach(([name, box, puck]) => {
|
||||
placeAll(12, box, puck).forEach((row, i) => {
|
||||
const travel = Math.hypot(row.off.x, row.off.y);
|
||||
if (travel < 1e-6) return;
|
||||
const ux = row.off.x / travel, uy = row.off.y / travel;
|
||||
const asked = Render.clearanceReach(puck, TIGHT.chip, ux, uy) +
|
||||
Render.BET_CLEARANCE_PX;
|
||||
assert.ok(travel <= asked + 0.5,
|
||||
`${name}: chip ${i + 1} went ${travel.toFixed(1)}px when ${asked.toFixed(1)} was asked for`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("the direction turns smoothly all the way round the ring", () => {
|
||||
// The property a set of discrete sides cannot have, and the reason there are
|
||||
// none: neighbouring seats get neighbouring directions, with no step anywhere.
|
||||
const box = felt(560, 400);
|
||||
const angles = Array.from({ length: 24 }, (_, i) => {
|
||||
const seat = Render.seatPosition(i, 24, box.width / box.height, RADII);
|
||||
const off = Render.anchoredChipOffset(seat, PUCK, CHIP, box, RADII);
|
||||
return Math.atan2(off.y, off.x);
|
||||
});
|
||||
angles.forEach((angle, i) => {
|
||||
const next = angles[(i + 1) % angles.length];
|
||||
let turn = Math.abs(next - angle);
|
||||
if (turn > Math.PI) turn = 2 * Math.PI - turn;
|
||||
assert.ok(turn < Math.PI / 4,
|
||||
`between seats ${i + 1} and ${i + 2} the chip jumps ${(turn * 180 / Math.PI).toFixed(0)}°`);
|
||||
});
|
||||
});
|
||||
|
||||
test("it stops short of the pot total as well", () => {
|
||||
// The other thing in the middle of the felt, and the one number on the table
|
||||
// that belongs to everybody. A seat on the middle of a rail sends its chip
|
||||
// straight at it: on a Galaxy S8 on its side, seat 1's $10 was sitting on
|
||||
// "Pot $65". The badge is measured rather than assumed to be at the centre —
|
||||
// it lives on the table, not on either overlay, and the two are not concentric.
|
||||
const box = felt(528, 83);
|
||||
const pot = { x: 264, y: 40, width: 96, height: 34 };
|
||||
Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) };
|
||||
const indices = [...Array(12).keys()];
|
||||
const entries = indices.map(i =>
|
||||
({ index: i, width: TIGHT.chip.width, height: TIGHT.chip.height }));
|
||||
const spacing = { box, puck: TIGHT.puck, clearance: Render.BET_CLEARANCE_PX, pot };
|
||||
const offs = Render.anchoredChipTravel(entries, 12, box.width / box.height, RADII, spacing);
|
||||
|
||||
indices.forEach(i => {
|
||||
const seat = Render.seatPosition(i, 12, box.width / box.height, RADII);
|
||||
const chip = { x: (seat.left / 100) * box.width + offs[i].x,
|
||||
y: (seat.top / 100) * box.height + offs[i].y };
|
||||
assert.ok(!boxesOverlap(chip, pot, TIGHT.chip, pot),
|
||||
`chip ${i + 1} is on the pot total`);
|
||||
});
|
||||
});
|
||||
|
||||
test("a seat with no rim to be off still gets an answer", () => {
|
||||
const box = felt(400, 300);
|
||||
const off = Render.anchoredChipOffset({ left: 50, top: 50 }, PUCK, CHIP, box, RADII);
|
||||
assert.ok(Number.isFinite(off.x) && Number.isFinite(off.y), JSON.stringify(off));
|
||||
assert.ok(off.y < 0, "upward, which is where a puck's own writing is not");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// The community cards, as a thing the table remembers.
|
||||
//
|
||||
// The board is the one part of a hand this tool never had a place for: hole cards go on
|
||||
// a seat, chips go in the middle, and the five cards everybody plays lived only on the
|
||||
// felt in front of the dealer. It is opt-in, because most nights nobody wants to enter
|
||||
// cards at all — and everything that reads it has to behave exactly as it always did
|
||||
// when it is off.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable } = require("./load");
|
||||
|
||||
const dealt = (over = {}) => {
|
||||
const state = { ...makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2 }), ...over };
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("a new table", () => {
|
||||
test("has five empty slots and is not tracking them", () => {
|
||||
const state = HoldemState.createDefault();
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
assert.equal(state.trackBoard, false,
|
||||
"off by default: a dealer who never asked for this should not be asked for cards");
|
||||
});
|
||||
});
|
||||
|
||||
describe("how many cards are out", () => {
|
||||
test("none, three, four, five", () => {
|
||||
assert.equal(Betting.boardCardsFor("preflop"), 0);
|
||||
assert.equal(Betting.boardCardsFor("flop"), 3);
|
||||
assert.equal(Betting.boardCardsFor("turn"), 4);
|
||||
assert.equal(Betting.boardCardsFor("river"), 5);
|
||||
});
|
||||
|
||||
test("and nothing at all for a street that isn't one", () => {
|
||||
assert.equal(Betting.boardCardsFor("showdown"), 0);
|
||||
assert.equal(Betting.boardCardsFor(undefined), 0);
|
||||
});
|
||||
|
||||
test("it matches the streets the betting engine actually deals", () => {
|
||||
// If a street is ever added, this is the table that has to hear about it —
|
||||
// a street with no entry silently draws no cards.
|
||||
Betting.STREETS.forEach(street => {
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(Betting.BOARD_CARDS, street),
|
||||
`${street} has no card count`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearing it", () => {
|
||||
test("the next hand takes the board with it", () => {
|
||||
const state = dealt();
|
||||
state.board = ["AH", "KD", "9C", "4S", "2H"];
|
||||
HoldemState.startNextHand(state);
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("so does a misdeal", () => {
|
||||
const state = dealt();
|
||||
state.board = ["AH", "KD", "9C", "", ""];
|
||||
Betting.takeHandBack(state);
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("and so does dealing a hand, however the table got here", () => {
|
||||
// Dealing twice off the same button — after a misdeal, or a table set up and
|
||||
// then dealt again — would otherwise rank the last hand's board against this
|
||||
// hand's cards, and it would look exactly like a real answer.
|
||||
const state = dealt();
|
||||
state.board = ["AH", "KD", "9C", "4S", "2H"];
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("but turning the tracking off does not", () => {
|
||||
// A dealer who taps the toggle mid-hand can tap it back; wiping the flop on
|
||||
// the way past would cost them cards they had already entered.
|
||||
const state = dealt({ trackBoard: true });
|
||||
state.board = ["AH", "KD", "9C", "", ""];
|
||||
state.trackBoard = false;
|
||||
assert.deepEqual(state.board, ["AH", "KD", "9C", "", ""]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a board off a file", () => {
|
||||
const migrated = board => HoldemState.migrate({
|
||||
seats: [HoldemState.makePlayer()], board,
|
||||
}).board;
|
||||
|
||||
test("a table saved before boards existed gets an empty one, not tracked", () => {
|
||||
const parsed = HoldemState.migrate({ seats: [HoldemState.makePlayer()] });
|
||||
assert.deepEqual(parsed.board, ["", "", "", "", ""]);
|
||||
assert.equal(parsed.trackBoard, false);
|
||||
});
|
||||
|
||||
test("it is always five slots, however many arrived", () => {
|
||||
assert.equal(migrated(["AH"]).length, 5);
|
||||
assert.equal(migrated(["AH", "KD", "9C", "4S", "2H", "3D", "5S"]).length, 5);
|
||||
assert.equal(migrated("not an array").length, 5);
|
||||
assert.equal(migrated(null).length, 5);
|
||||
});
|
||||
|
||||
test("anything that isn't a card becomes an empty slot", () => {
|
||||
assert.deepEqual(migrated(["AH", "ZZ", 7, null, "10H"]), ["AH", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("and a real one keeps its place, upper-cased", () => {
|
||||
assert.deepEqual(migrated(["ah", " kd ", "9c", "", ""]), ["AH", "KD", "9C", "", ""]);
|
||||
});
|
||||
|
||||
test("tracking is only on if the file said so, in so many words", () => {
|
||||
const on = HoldemState.migrate({ seats: [HoldemState.makePlayer()], trackBoard: true });
|
||||
const truthy = HoldemState.migrate({ seats: [HoldemState.makePlayer()], trackBoard: "yes" });
|
||||
assert.equal(on.trackBoard, true);
|
||||
assert.equal(truthy.trackBoard, false, "a string is a file that has been edited, not a yes");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// Calling the clock on a player.
|
||||
//
|
||||
// The bar already had a stopwatch saying how long somebody had been thinking. This is
|
||||
// what a dealer does about it: a procedure with a shape — a set time, a countdown said
|
||||
// out loud, and a ruling at the end that depends on what the player was facing.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { ActionBar, Betting, makeTable } = require("./load");
|
||||
|
||||
// The bar rebuilds the DOM when a clock starts; none of the rules below need it.
|
||||
const onTable = (state) => {
|
||||
ActionBar.state = state;
|
||||
ActionBar.render = () => {};
|
||||
ActionBar.stopClock();
|
||||
return ActionBar;
|
||||
};
|
||||
|
||||
const dealt = () => {
|
||||
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("starting one", () => {
|
||||
test("it runs on the player who is actually on the clock", () => {
|
||||
const state = dealt();
|
||||
const bar = onTable(state);
|
||||
assert.equal(bar.clockRunning(), false, "nothing runs until it is called");
|
||||
bar.callClock();
|
||||
assert.equal(bar.clockRunning(), true);
|
||||
assert.equal(bar.clockSeatIndex, state.actingSeatIndex);
|
||||
});
|
||||
|
||||
test("with nobody to act there is nothing to call", () => {
|
||||
const state = dealt();
|
||||
state.actingSeatIndex = null;
|
||||
const bar = onTable(state);
|
||||
bar.callClock();
|
||||
assert.equal(bar.clockRunning(), false);
|
||||
assert.equal(bar.clockLeft(), null);
|
||||
});
|
||||
|
||||
test("the dealer can put it away again", () => {
|
||||
const bar = onTable(dealt());
|
||||
bar.callClock();
|
||||
bar.stopClock();
|
||||
assert.equal(bar.clockRunning(), false);
|
||||
assert.equal(bar.clockLeft(), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the count", () => {
|
||||
const at = (bar, seconds) => bar.clockLeft(bar.clockStartedAt + seconds * 1000);
|
||||
|
||||
test("is the player's own time and then a countdown", () => {
|
||||
const bar = onTable(dealt());
|
||||
bar.callClock();
|
||||
assert.equal(at(bar, 0), bar.CLOCK_THINK_S + bar.CLOCK_COUNTDOWN_S);
|
||||
assert.equal(at(bar, 30), bar.CLOCK_COUNTDOWN_S, "thirty gone, the countdown starts");
|
||||
assert.equal(at(bar, 35), 5);
|
||||
});
|
||||
|
||||
test("stops at zero rather than going negative", () => {
|
||||
const bar = onTable(dealt());
|
||||
bar.callClock();
|
||||
assert.equal(at(bar, 40), 0);
|
||||
assert.equal(at(bar, 600), 0, "ten minutes later it is still just up");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what happens at zero", () => {
|
||||
test("facing a bet, the hand is dead", () => {
|
||||
const state = dealt();
|
||||
const bar = onTable(state);
|
||||
// Whoever is up pre-flop is facing the big blind.
|
||||
const ruling = bar.clockRuling();
|
||||
assert.equal(ruling.verb, "fold");
|
||||
assert.match(ruling.label, /hand is dead/);
|
||||
assert.match(ruling.say, /facing \$2/);
|
||||
});
|
||||
|
||||
test("facing nothing, it is checked — slowness doesn't fold a free hand", () => {
|
||||
const state = dealt();
|
||||
// Everyone calls round to the big blind, who has nothing to call.
|
||||
[3, 4, 5, 0, 1].forEach(seat => Betting.call(state, seat));
|
||||
const bar = onTable(state);
|
||||
assert.equal(state.actingSeatIndex, 2, "the big blind is up");
|
||||
const ruling = bar.clockRuling();
|
||||
assert.equal(ruling.verb, "check");
|
||||
assert.match(ruling.label, /check for/i);
|
||||
});
|
||||
|
||||
test("the ruling names the player, because the dealer has to say it out loud", () => {
|
||||
const state = dealt();
|
||||
state.seats[state.actingSeatIndex].name = "Priya";
|
||||
const ruling = onTable(state).clockRuling();
|
||||
assert.match(ruling.label, /Priya/);
|
||||
assert.match(ruling.say, /Priya/);
|
||||
});
|
||||
|
||||
test("with nobody to act there is no ruling to make", () => {
|
||||
const state = dealt();
|
||||
state.actingSeatIndex = null;
|
||||
assert.equal(onTable(state).clockRuling(), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what ends it", () => {
|
||||
test("the player acting", () => {
|
||||
const state = dealt();
|
||||
const bar = onTable(state);
|
||||
bar.callClock();
|
||||
const wasOn = state.actingSeatIndex;
|
||||
Betting.call(state, wasOn);
|
||||
assert.notEqual(state.actingSeatIndex, wasOn, "the action has moved");
|
||||
assert.equal(bar.clockRunning(), false, "so the clock is no longer this player's");
|
||||
});
|
||||
|
||||
test("and it doesn't follow the action to the next player", () => {
|
||||
// A clock is called on one decision. Left running it would put a countdown on
|
||||
// somebody nobody asked about.
|
||||
const state = dealt();
|
||||
const bar = onTable(state);
|
||||
bar.callClock();
|
||||
Betting.call(state, state.actingSeatIndex);
|
||||
assert.equal(bar.clockLeft(), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
// Somebody leaving with their chips.
|
||||
//
|
||||
// The most ordinary thing that happens at a home game after midnight, and the tool had
|
||||
// nothing for it. Freeing the chair meant wiping the seat by hand, and then the bank
|
||||
// check — cash taken against chips on the table — declared the departing player's whole
|
||||
// stack missing for the rest of the night. Wiping their buy-in too silenced it, at the
|
||||
// price of losing their night from the standings and the file the dealer hands over.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, HoldemState, Betting, makeTable } = require("./load");
|
||||
|
||||
const table = () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
|
||||
["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].buyIns = 200;
|
||||
});
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("counting them out", () => {
|
||||
test("the preview is what the dealer's hands follow", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
const preview = HoldemState.cashOutPreview(state, 0);
|
||||
assert.equal(preview.name, "Ann");
|
||||
assert.equal(preview.chips, 350, "what they are holding");
|
||||
assert.equal(preview.boughtIn, 200);
|
||||
assert.equal(preview.net, 150, "up for the night");
|
||||
assert.match(preview.breakdown, /\d+×\$\d+/, "and the chips that make it");
|
||||
});
|
||||
|
||||
test("a player who is down reads as down, not as a smaller stack", () => {
|
||||
const state = table();
|
||||
state.seats[1].stack = 60;
|
||||
assert.equal(HoldemState.cashOutPreview(state, 1).net, -140);
|
||||
});
|
||||
|
||||
test("a seat that never set a stack is holding the table's starting stack", () => {
|
||||
const state = table();
|
||||
state.defaultStack = 200;
|
||||
state.seats[2].stack = null;
|
||||
assert.equal(HoldemState.cashOutPreview(state, 2).chips, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it refuses", () => {
|
||||
test("paying somebody with money still in the middle", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
const bb = state.seats.findIndex(p => (Number(p.committed) || 0) > 0);
|
||||
const refusal = HoldemState.canCashOut(state, bb);
|
||||
assert.equal(refusal.ok, false);
|
||||
assert.match(refusal.reason, /in the pot/);
|
||||
// And the refusal is the whole story: nothing moved.
|
||||
assert.equal(HoldemState.cashOut(state, bb).ok, false);
|
||||
assert.equal((state.cashedOut || []).length, 0);
|
||||
assert.ok(state.seats[bb].name, "the seat is untouched");
|
||||
});
|
||||
|
||||
test("an empty chair, which has nobody to pay", () => {
|
||||
const state = table();
|
||||
const refusal = HoldemState.canCashOut(state, 99);
|
||||
assert.equal(refusal.ok, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the chair afterwards", () => {
|
||||
test("is empty and ready for the next player", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
HoldemState.cashOut(state, 0);
|
||||
const seat = state.seats[0];
|
||||
assert.equal(seat.name, "");
|
||||
assert.equal(seat.stack, null);
|
||||
assert.equal(seat.buyIns, 0);
|
||||
assert.equal(seat.inHand, false);
|
||||
});
|
||||
|
||||
test("belongs to somebody new, so two people's nights can't merge", () => {
|
||||
const state = table();
|
||||
const wasId = state.seats[0].id;
|
||||
HoldemState.cashOut(state, 0);
|
||||
assert.notEqual(state.seats[0].id, wasId);
|
||||
});
|
||||
|
||||
test("and isn't owed the blind the person before them was posting", () => {
|
||||
const state = table();
|
||||
state.postingIn = [state.seats[0].id, state.seats[1].id];
|
||||
HoldemState.cashOut(state, 0);
|
||||
assert.deepEqual(state.postingIn, [state.seats[1].id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bank check", () => {
|
||||
test("balanced before, balanced after — the chips went somewhere known", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50;
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
|
||||
HoldemState.cashOut(state, 0);
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.paidOut, 350);
|
||||
assert.equal(bank.cashIn, 800, "their buy-in is still cash the box took");
|
||||
assert.equal(bank.chipsInPlay, 450);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
|
||||
test("this is the fault it fixes: wiping the seat by hand loses $350", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50; // so the table starts out balanced
|
||||
// What a dealer had to do before: empty the chair themselves.
|
||||
state.seats[0].name = "";
|
||||
state.seats[0].stack = null;
|
||||
state.seats[0].inHand = false;
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, false);
|
||||
assert.equal(bank.difference, -350, "the tool called it missing money");
|
||||
});
|
||||
|
||||
test("chips that really are missing still show as missing", () => {
|
||||
// Counting a cash-out must not become a way of swallowing a genuine shortfall.
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50; // balanced to start with
|
||||
HoldemState.cashOut(state, 0);
|
||||
state.seats[2].stack = 150; // and now fifty chips have gone astray
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, false);
|
||||
assert.equal(bank.difference, -50);
|
||||
});
|
||||
|
||||
test("a rake and a cash-out are counted apart", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.rakeTaken = 12;
|
||||
state.seats[1].stack = 38; // the rake came out of Ben's stack
|
||||
HoldemState.cashOut(state, 0);
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.raked, 12);
|
||||
assert.equal(bank.paidOut, 350);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the record of their night", () => {
|
||||
test("they stay in the standings with what they bought and what they left with", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
|
||||
HoldemState.cashOut(state, 0);
|
||||
|
||||
const ann = HoldemState.sessionStandings(state).find(row => row.name === "Ann");
|
||||
assert.ok(ann, "still on the list");
|
||||
assert.equal(ann.boughtIn, 200);
|
||||
assert.equal(ann.chipsInFront, 350, "what the box handed over");
|
||||
assert.equal(ann.cashPosition, 150);
|
||||
assert.equal(ann.cashedOut, true, "and marked, so nobody pays them twice");
|
||||
});
|
||||
|
||||
test("somebody still sitting there is not marked", () => {
|
||||
const state = table();
|
||||
HoldemState.cashOut(state, 0);
|
||||
const ben = HoldemState.sessionStandings(state).find(row => row.name === "Ben");
|
||||
assert.equal(ben.cashedOut, false);
|
||||
});
|
||||
|
||||
test("two people leaving are both kept", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50;
|
||||
HoldemState.cashOut(state, 0);
|
||||
HoldemState.cashOut(state, 1);
|
||||
assert.equal(state.cashedOut.length, 2);
|
||||
assert.equal(HoldemState.bankReconciliation(state).paidOut, 400);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("a table saved before any of this existed has nobody cashed out", () => {
|
||||
const parsed = JSON.parse(JSON.stringify(table()));
|
||||
delete parsed.cashedOut;
|
||||
const migrated = HoldemState.migrate(parsed);
|
||||
assert.deepEqual(migrated.cashedOut, []);
|
||||
assert.equal(HoldemState.bankReconciliation(migrated).paidOut, 0);
|
||||
});
|
||||
|
||||
test("nonsense in a saved record is cleaned up rather than trusted", () => {
|
||||
const parsed = JSON.parse(JSON.stringify(table()));
|
||||
parsed.cashedOut = [
|
||||
{ id: "a", name: "Ann", buyIns: -5, chips: "350", at: "nope" },
|
||||
{ name: "no id" },
|
||||
null,
|
||||
];
|
||||
const migrated = HoldemState.migrate(parsed);
|
||||
assert.equal(migrated.cashedOut.length, 1);
|
||||
assert.equal(migrated.cashedOut[0].buyIns, 0);
|
||||
assert.equal(migrated.cashedOut[0].chips, 350);
|
||||
assert.equal(migrated.cashedOut[0].at, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the table carries on", () => {
|
||||
test("the next hand deals round the empty chair", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
HoldemState.cashOut(state, 0);
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.seats[0].inHand, false, "nobody is in the empty chair");
|
||||
assert.equal(Betting.liveSeats(state).length, 3);
|
||||
assert.ok(state.actingSeatIndex !== null, "somebody is on the clock");
|
||||
});
|
||||
|
||||
test("and the money still adds up once it has", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50;
|
||||
HoldemState.cashOut(state, 0);
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(Money.round(bank.chipsInPlay + bank.paidOut), bank.cashIn);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
// Taking a chip colour out of the legend.
|
||||
//
|
||||
// This was one click of an unlabelled 24px button, and it was not a change to a legend.
|
||||
// computeContribution() prices a seat by running the legend over that seat's chip
|
||||
// counts, so a colour that leaves takes every chip counted in it off the table.
|
||||
// Measured in a browser on a three-handed table: deleting the $500 row took $22,500 of
|
||||
// $26,874 with it, with no confirmation, nothing said afterwards, and no way back —
|
||||
// a colour added again is a new colour with a new id, while the counts stay keyed to
|
||||
// the one that left. The counts were left behind too, so the saved file carried money
|
||||
// that nothing could price and nothing could show.
|
||||
//
|
||||
// What is tested here is mostly the number in the warning, because a warning that
|
||||
// overstates is worse than none: it is the one thing a dealer would have to trust at
|
||||
// the exact moment they are about to lose track of the box.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Money } = require("./load");
|
||||
|
||||
const tableWith = ({ counts = [], overrides = [], basePot = null } = {}) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
state.basePotAmount = basePot;
|
||||
["Christopher", "Mike from work", "Alexandra"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].buyIns = 500;
|
||||
state.seats[i].chipCounts = {};
|
||||
});
|
||||
counts.forEach(([seat, chipIndex, n]) => {
|
||||
state.seats[seat].chipCounts[state.chipLegend[chipIndex].id] = n;
|
||||
});
|
||||
overrides.forEach(([seat, amount]) => { state.seats[seat].amountOverride = amount; });
|
||||
return state;
|
||||
};
|
||||
|
||||
const onTable = state => state.seats.reduce(
|
||||
(sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what a chip colour is holding up", () => {
|
||||
test("is the money, named seat by seat", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4], [1, 2, 10]] }); // the $25 chip
|
||||
const usage = HoldemState.chipColorUsage(state, state.chipLegend[2].id);
|
||||
|
||||
assert.equal(usage.total, 350);
|
||||
assert.equal(usage.chips, 14);
|
||||
assert.deepEqual(usage.seats.map(s => [s.name, s.count, s.amount]), [
|
||||
["Christopher", 4, 100],
|
||||
["Mike from work", 10, 250],
|
||||
]);
|
||||
});
|
||||
|
||||
test("is nothing at all for a colour nobody counted", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4]] });
|
||||
const usage = HoldemState.chipColorUsage(state, state.chipLegend[4].id);
|
||||
assert.equal(usage.total, 0);
|
||||
assert.deepEqual(usage.seats, []);
|
||||
assert.ok(usage.chip, "the colour itself is still found, so it can be named");
|
||||
});
|
||||
|
||||
test("and nothing for a colour that isn't there", () => {
|
||||
const state = tableWith();
|
||||
assert.deepEqual(HoldemState.chipColorUsage(state, "no-such-chip"),
|
||||
{ chip: null, total: 0, chips: 0, seats: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the number in the warning is the number the table loses", () => {
|
||||
// The whole point. Multiplying the chips by their value would have been the obvious
|
||||
// way to work this out and it is wrong in both directions — the two tests below are
|
||||
// the two ways it is wrong.
|
||||
const dropFromRemoving = (state, chipId) => {
|
||||
const before = onTable(state);
|
||||
HoldemState.removeChipColor(state, chipId);
|
||||
return Money.round(before - onTable(state));
|
||||
};
|
||||
|
||||
test("a seat with a typed amount ignores its chips, so its colour is worth nothing", () => {
|
||||
// computeContribution() reads amountOverride first and never reaches the counts.
|
||||
// Christopher is holding ten $25 chips on paper and priced at a flat $90.
|
||||
const state = tableWith({ counts: [[0, 2, 10]], overrides: [[0, 90]] });
|
||||
const chipId = state.chipLegend[2].id;
|
||||
|
||||
const usage = HoldemState.chipColorUsage(state, chipId);
|
||||
assert.equal(usage.total, 0, "$250 of chips, but the seat is not priced from them");
|
||||
assert.equal(dropFromRemoving(state, chipId), 0);
|
||||
});
|
||||
|
||||
test("a seat whose chips all leave falls back to the base pot, not to zero", () => {
|
||||
// The $25s are everything Christopher has counted, so taking them away drops him
|
||||
// to the table's standing ante — a loss of 50, not of 250.
|
||||
const state = tableWith({ counts: [[0, 2, 10]], basePot: 200 });
|
||||
const chipId = state.chipLegend[2].id;
|
||||
|
||||
const usage = HoldemState.chipColorUsage(state, chipId);
|
||||
assert.equal(usage.total, 50, "250 of chips, but only 50 of it actually leaves");
|
||||
assert.equal(dropFromRemoving(state, chipId), 50);
|
||||
});
|
||||
|
||||
test("and across a spread of tables the two always agree", () => {
|
||||
function rng(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
const random = rng(20260906);
|
||||
let withMoney = 0;
|
||||
|
||||
for (let table = 0; table < 2000; table++) {
|
||||
const counts = [];
|
||||
for (let seat = 0; seat < 3; seat++) {
|
||||
for (let chip = 0; chip < 5; chip++) {
|
||||
if (random() < 0.5) counts.push([seat, chip, 1 + Math.floor(random() * 20)]);
|
||||
}
|
||||
}
|
||||
const overrides = [];
|
||||
for (let seat = 0; seat < 3; seat++) {
|
||||
if (random() < 0.25) overrides.push([seat, Math.floor(random() * 400)]);
|
||||
}
|
||||
const basePot = random() < 0.5 ? 200 : null;
|
||||
|
||||
const chipIndex = Math.floor(random() * 5);
|
||||
const stated = tableWith({ counts, overrides, basePot });
|
||||
const chipId = stated.chipLegend[chipIndex].id;
|
||||
const claimed = HoldemState.chipColorUsage(stated, chipId).total;
|
||||
|
||||
const actual = dropFromRemoving(tableWith({ counts, overrides, basePot }), chipId);
|
||||
if (claimed !== 0) withMoney++;
|
||||
assert.equal(claimed, actual,
|
||||
`table ${table}: warned ${claimed}, table moved ${actual}\n` +
|
||||
JSON.stringify({ counts, overrides, basePot, chipIndex }));
|
||||
}
|
||||
|
||||
// Guards the generator: shapes that never put money on a colour would make every
|
||||
// assertion above compare zero with zero.
|
||||
assert.ok(withMoney > 500, `only ${withMoney} of 2000 tables had money on the colour`);
|
||||
});
|
||||
|
||||
test("and it can be a gain, which is said as one", () => {
|
||||
// Fourteen $1 chips against a $200 standing ante: once the colour goes, the seat
|
||||
// stops being priced from its chips and falls through to the ante, so the table
|
||||
// goes *up* by 186. Found by the randomised check above, not by hand.
|
||||
const state = tableWith({ counts: [[1, 0, 14]], basePot: 200 });
|
||||
const usage = HoldemState.chipColorUsage(state, state.chipLegend[0].id);
|
||||
assert.equal(usage.total, -186);
|
||||
assert.equal(dropFromRemoving(state, state.chipLegend[0].id), -186);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removing it", () => {
|
||||
test("takes the counts with it, so nothing is left priced by a colour that is gone", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4], [1, 2, 10], [1, 0, 3]] });
|
||||
const chipId = state.chipLegend[2].id;
|
||||
|
||||
HoldemState.removeChipColor(state, chipId);
|
||||
|
||||
assert.equal(state.chipLegend.filter(c => c.id === chipId).length, 0);
|
||||
state.seats.forEach(player => assert.ok(!(chipId in (player.chipCounts || {})),
|
||||
"a count against a colour no legend entry answers to is money nothing can show"));
|
||||
// The other colour on that seat is untouched.
|
||||
assert.equal(state.seats[1].chipCounts[state.chipLegend[0].id], 3);
|
||||
});
|
||||
|
||||
test("reports what it cost, measured before anything moved", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4]] });
|
||||
const usage = HoldemState.removeChipColor(state, state.chipLegend[2].id);
|
||||
assert.equal(usage.total, 100);
|
||||
assert.equal(usage.seats.length, 1);
|
||||
});
|
||||
|
||||
test("and a colour that isn't there changes nothing", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4]] });
|
||||
const before = onTable(state);
|
||||
const usage = HoldemState.removeChipColor(state, "no-such-chip");
|
||||
assert.equal(usage.chip, null);
|
||||
assert.equal(state.chipLegend.length, 5);
|
||||
assert.equal(onTable(state), before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
// The stylesheet has to keep picking from the scales.
|
||||
//
|
||||
// Before the token block went in, holdem.css used 42 different spacing values, 31 font
|
||||
// sizes, 14 border-radii and 139 colours, and defined three custom properties. None of
|
||||
// that was anybody's decision — each number was chosen at the moment it was needed, and
|
||||
// the result read as crowded even where nothing overlapped, because a 0.42rem gap next
|
||||
// to a 0.45rem gap next to a 0.5rem gap gives the eye no rhythm to lock onto.
|
||||
//
|
||||
// A one-off cleanup would last until the next `padding: 0.35rem`. So the scales are
|
||||
// checked here instead: a new value has to be a step on one of them, or an exception
|
||||
// with a reason written next to it.
|
||||
//
|
||||
// This reads the stylesheet as text rather than through a CSS parser, on purpose — the
|
||||
// rest of this suite has no dependencies and runs in a second, and a regex over
|
||||
// declarations is enough to catch the thing that actually happens, which is somebody
|
||||
// (me) typing a number instead of reaching for a token.
|
||||
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_PATH = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css");
|
||||
const css = fs.readFileSync(CSS_PATH, "utf8");
|
||||
|
||||
// Comments carry measurements and rationale — "measured at 342px tall on a phone" —
|
||||
// and none of that is a declaration. Strip them before looking for values.
|
||||
const declarations = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
|
||||
// Everything after the token block. The :root block is where the raw values are
|
||||
// supposed to be.
|
||||
const body = declarations.slice(declarations.indexOf("#potBadgeAmount,"));
|
||||
|
||||
const valuesIn = (properties, source = body) => {
|
||||
const found = [];
|
||||
// The lookbehind rejects a hyphen as well as a word character, so `scroll-padding`
|
||||
// isn't read as `padding`. It caught the pot wizard's scroll-padding the moment
|
||||
// that went in — correctly by its own rules, wrongly in substance: those two values
|
||||
// are the measured heights of the dialog's sticky header and footer, a clearance
|
||||
// like the action bar's, not a step in the spacing rhythm.
|
||||
const re = new RegExp(`(?<![\\w-])(${properties.join("|")})\\s*:([^;}]*)`, "g");
|
||||
let m;
|
||||
while ((m = re.exec(source)) !== null) {
|
||||
const [, prop, value] = m;
|
||||
// calc() and env() do their own arithmetic on tokens; the tokens inside them
|
||||
// are still checked, because the regex below looks at bare numbers only.
|
||||
(value.match(/(?<![\w-])[0-9.]+(rem|px|em)\b/g) || []).forEach(v => found.push({ prop, value: v }));
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
describe("the spacing scale", () => {
|
||||
// Hairlines and structural clearances, each one deliberate:
|
||||
// 0px/1px/3px/4px/25px — borders, hairline offsets, and the 25px of room a seat
|
||||
// puck's name gives the dealer button beside it: the chip's
|
||||
// own 22px plus the 3px it is inset by, which theRail's
|
||||
// "as wide as the chip it stands in for" holds it to.
|
||||
// 5rem/9rem — how far the page has to scroll past the fixed action
|
||||
// bar. Not rhythm; clearance for a thing of a known size.
|
||||
const ALLOWED = new Set(["0px", "1px", "2px", "3px", "4px", "25px", "5rem", "9rem"]);
|
||||
|
||||
test("every gap, margin and padding comes off the scale", () => {
|
||||
const offenders = valuesIn(
|
||||
["padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
|
||||
"margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
|
||||
"gap", "row-gap", "column-gap"])
|
||||
.filter(v => !ALLOWED.has(v.value));
|
||||
|
||||
assert.deepEqual(offenders, [],
|
||||
"use var(--sp-0 … --sp-6), or add the value to ALLOWED with a reason");
|
||||
});
|
||||
|
||||
test("and the scale is the one the tokens define", () => {
|
||||
["--sp-0", "--sp-1", "--sp-2", "--sp-3", "--sp-4", "--sp-5", "--sp-6"]
|
||||
.forEach(token => assert.match(css, new RegExp(`\\${token}:`), `${token} is not defined`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the type scale", () => {
|
||||
// 16px — the size below which iOS zooms the page when a field is focused, so
|
||||
// every text input states it. Not a typographic choice.
|
||||
// 1.05em — a relative bump inside a component.
|
||||
// 9px — the floor under the seat puck's shrink-to-fit applied to a name. It is a
|
||||
// limit rather than a size: past it the word stops being legible from
|
||||
// across a table and an honest ellipsis is the better answer.
|
||||
// 10px, 12px — the ceilings on a seat puck's two sizes, which are the one place in
|
||||
// this file where type does not follow the browser's font size. Each is
|
||||
// exactly the size that row has always been drawn at, so a cap only ever
|
||||
// engages when a phone's text is turned up; see the #seatsLayer token block
|
||||
// for why a ring whose pucks are already at the 44px floor cannot answer a
|
||||
// taller row any other way. Limits rather than sizes.
|
||||
const ALLOWED = new Set(["16px", "1.05em", "9px", "10px", "12px"]);
|
||||
|
||||
// The seat puck's two sizes are custom properties that feed `font-size` through
|
||||
// `var()`, so the sweep above walks straight past them — which is exactly where a
|
||||
// raw number would end up hiding. They are held to the same scale.
|
||||
const TYPE_TOKENS = ["--seatNameSize", "--seatAmountSize"];
|
||||
|
||||
test("every font-size comes off the scale", () => {
|
||||
const offenders = valuesIn(["font-size", ...TYPE_TOKENS])
|
||||
.filter(v => !ALLOWED.has(v.value));
|
||||
assert.deepEqual(offenders, [],
|
||||
"use var(--fs-2xs … --fs-2xl), or add the value to ALLOWED with a reason");
|
||||
});
|
||||
|
||||
test("and the scale is the one the tokens define", () => {
|
||||
["--fs-2xs", "--fs-xs", "--fs-sm", "--fs-base", "--fs-md", "--fs-lg", "--fs-xl", "--fs-2xl"]
|
||||
.forEach(token => assert.match(css, new RegExp(`\\${token}:`), `${token} is not defined`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the corner scale", () => {
|
||||
test("every border-radius comes off it", () => {
|
||||
const offenders = valuesIn(["border-radius"]).filter(v => v.value !== "2px");
|
||||
assert.deepEqual(offenders, [], "use var(--r-sm | --r-md | --r-lg | --r-pill)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the tokens themselves", () => {
|
||||
test("every var() the stylesheet reaches for exists", () => {
|
||||
const used = new Set((css.match(/var\((--[a-zA-Z0-9-]+)/g) || []).map(s => s.slice(4)));
|
||||
// Published from JS at runtime rather than declared here — see actionBar.js for
|
||||
// the first two, render.js for the seat width and the chip's denomination colour,
|
||||
// which is picked per chip from the amount on it, and feedback.js for the
|
||||
// toast's height, which is one line or two depending on what is being said.
|
||||
// The seat sheet's height is the same idea as the action bar's, and for the same
|
||||
// reason: two things fixed to the bottom of a phone, and the toast has to clear
|
||||
// whichever is taller. It changes when a section of the sheet is unfolded, so it
|
||||
// is measured rather than written down — see Render.watchSeatSheetHeight.
|
||||
const fromScript = new Set([
|
||||
"--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
|
||||
"--toastHeight", "--seatSheetHeight",
|
||||
]);
|
||||
// Defined by the site's own stylesheet, not this one.
|
||||
const fromSite = new Set(["--font-mono"]);
|
||||
const missing = [...used].filter(t =>
|
||||
!fromScript.has(t) && !fromSite.has(t) && !new RegExp(`\\${t}\\s*:`).test(css));
|
||||
assert.deepEqual(missing, []);
|
||||
});
|
||||
|
||||
test("and the dark surfaces stay off pure black", () => {
|
||||
// Shadows are invisible on #000, so depth has to come from a lighter surface —
|
||||
// and white text on true black is the pairing that smears on an OLED phone,
|
||||
// which is what this tool is used on.
|
||||
const surfaces = [...css.matchAll(/--surface-\d+:\s*(#[0-9a-fA-F]{3,8})/g)].map(m => m[1].toLowerCase());
|
||||
assert.ok(surfaces.length >= 4, "the surface ramp should have four steps");
|
||||
surfaces.forEach(hex => {
|
||||
assert.notEqual(hex, "#000");
|
||||
assert.notEqual(hex, "#000000");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("numbers that change while the dealer watches", () => {
|
||||
test("are set in tabular figures", () => {
|
||||
// A "1" is narrower than an "8" in a proportional face, so a pot ticking from
|
||||
// $18 to $21 shuffles its digits sideways and the eye reads movement where
|
||||
// there is only arithmetic.
|
||||
assert.match(css, /font-variant-numeric:\s*tabular-nums/);
|
||||
["#potBadgeAmount", ".seatPuckStack", ".actionPot", ".standingsRow"]
|
||||
.forEach(sel => assert.ok(css.includes(sel),
|
||||
`${sel} should be in the tabular-figures list`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("what an open chair is allowed to weigh", () => {
|
||||
// An empty seat used to be drawn as a whole puck of near-black on the green: the same
|
||||
// box a seated player gets, at the same weight, saying nothing. Five players
|
||||
// nine-handed is four of them, and they read as holes punched in the felt rather than
|
||||
// as chairs nobody is in — the eye had to sort the loud empty boxes from the loud full
|
||||
// ones before it could find who was in the hand.
|
||||
//
|
||||
// The wash the box carried was there for one reason: the chair number's contrast
|
||||
// cannot depend on whether its chair happens to sit over the brown rim, the dark green
|
||||
// or the light, which is about Lc 5 across one ring. So the wash moved off the chair
|
||||
// and under the number, which is the only thing an open chair has to say.
|
||||
|
||||
test("the chair is quieter than the player who could sit in it", () => {
|
||||
const empty = css.match(/\.seat\.seatEmpty \{([^}]*)\}/);
|
||||
const seat = css.match(/\n\.seat \{([^}]*)\}/);
|
||||
assert.ok(empty && seat);
|
||||
assert.match(seat[1], /background:\s*linear-gradient/,
|
||||
"a taken seat is a puck with a ground of its own");
|
||||
const alpha = empty[1].match(/background:\s*rgba\([^)]*?([0-9.]+)\s*\)/);
|
||||
assert.ok(alpha, "an open chair should still be washed, only faintly");
|
||||
assert.ok(Number(alpha[1]) <= 0.35,
|
||||
`an open chair at ${alpha[1]} reads as a hole in the felt, not a chair`);
|
||||
});
|
||||
|
||||
test("and it still says it is open", () => {
|
||||
const empty = css.match(/\.seat\.seatEmpty \{([^}]*)\}/)[1];
|
||||
assert.match(empty, /border-style:\s*dashed/,
|
||||
"the dashes are what carry the meaning; they always were");
|
||||
});
|
||||
|
||||
test("the number keeps a ground of its own, because it is the part that is read", () => {
|
||||
const rule = css.match(/\.seatEmpty \.seatChairNumber \{([^}]*)\}/);
|
||||
assert.ok(rule, "no ground under the one thing an open chair says");
|
||||
assert.match(rule[1], /background:\s*rgba/);
|
||||
assert.match(rule[1], /border-radius:\s*var\(--r-pill\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("controls that must not resize under a thumb", () => {
|
||||
test("the undo pill has a fixed width, not a content width", () => {
|
||||
// It names the last action, so its label changes on every tap. Measured across
|
||||
// one hand before this: 148, 110, 109, 108, 109, 110, 120.
|
||||
//
|
||||
// A length at every breakpoint, never a percentage: `min(10rem, 34%)` was tried
|
||||
// and the percentage resolves against the row it sits in, which is sized to its
|
||||
// own contents — the two chase each other, and the sweep caught the pill drawn
|
||||
// 38px wide at ten viewports. A phone gets a narrower one through a media query
|
||||
// instead, which is still one width at a time.
|
||||
const rules = [...css.matchAll(/\.actionUndo\s*\{([^}]*)\}/g)].map(m => m[1]);
|
||||
assert.ok(rules.length, ".actionUndo rule not found");
|
||||
rules.filter(body => /(?<![\w-])width:/.test(body)).forEach(body =>
|
||||
assert.match(body, /(?<![\w-])width:\s*([0-9.]+rem|100%)/,
|
||||
"a content width would let the pill resize under the dealer's thumb"));
|
||||
assert.ok(rules.some(body => /(?<![\w-])width:\s*[0-9.]+rem/.test(body)),
|
||||
"the pill should still state a fixed width somewhere");
|
||||
});
|
||||
|
||||
test("and its label is the part that gives, never the keycap", () => {
|
||||
const rule = css.match(/\.actionUndoLabel\s*\{[^}]*\}/);
|
||||
assert.ok(rule, ".actionUndoLabel rule not found");
|
||||
assert.match(rule[0], /overflow:\s*hidden/);
|
||||
assert.match(css.match(/\.actionUndoKey\s*\{[^}]*\}/)[0], /flex:\s*none/);
|
||||
});
|
||||
|
||||
test("and within the label, the act outlives the actor", () => {
|
||||
// Three items, not one string: the arrow, who it was, and what they did. Cut as
|
||||
// one string, "↶ Mike from work's fold" reads "↶ Mike from wor…" on a phone —
|
||||
// everything except the thing you would be taking back. The actor is dropped
|
||||
// whole instead, so the act is what survives, and only the act may ellipsize.
|
||||
const lead = css.match(/\.actionUndoGlyph,\s*\n\.actionUndoLead\s*\{([^}]*)\}/);
|
||||
assert.ok(lead, "the arrow and the actor should be fixed items");
|
||||
assert.match(lead[1], /flex:\s*none/);
|
||||
assert.match(css, /\.actionUndoLead\[hidden\]\s*\{[^}]*display:\s*none/);
|
||||
const act = css.match(/\.actionUndoAct\s*\{([^}]*)\}/);
|
||||
assert.ok(act, ".actionUndoAct rule not found");
|
||||
assert.match(act[1], /text-overflow:\s*ellipsis/);
|
||||
assert.match(act[1], /min-width:\s*0/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
// How tall the felt is allowed to be.
|
||||
//
|
||||
// The bar is fixed to the bottom of the screen and grows with the hand, so the table
|
||||
// has to be told what is left. Everything below is the arithmetic that decides it —
|
||||
// the part that can be wrong without anyone noticing until a seat is behind the Fold
|
||||
// button.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { ActionBar } = require("./load");
|
||||
|
||||
// window height, bar height, and how far down the page the felt starts.
|
||||
const room = (windowHeight, barHeight, top) => ActionBar.feltRoomFor(windowHeight, barHeight, top);
|
||||
|
||||
describe("the room left for the felt", () => {
|
||||
test("is what the window has after the bar, the header and the bottom seat", () => {
|
||||
const reserved = ActionBar.SEAT_OVERHANG_PX + ActionBar.FELT_GAP_PX;
|
||||
assert.equal(room(900, 127, 146), 900 - 127 - 146 - reserved);
|
||||
});
|
||||
|
||||
test("shrinks when the bar grows mid-hand", () => {
|
||||
// Side pots make the bar taller exactly when the dealer most needs to see who
|
||||
// is still in.
|
||||
const quiet = room(900, 117, 146);
|
||||
const sidePots = room(900, 172, 146);
|
||||
assert.equal(quiet - sidePots, 55);
|
||||
});
|
||||
|
||||
test("leaves the bottom seat's overhang out of it", () => {
|
||||
// The seat pucks hang below the felt's edge; the felt has to stop short by that
|
||||
// much or the ring ends up in the bar even though the felt doesn't.
|
||||
assert.ok(ActionBar.SEAT_OVERHANG_PX > 30, "half a puck");
|
||||
assert.ok(room(900, 127, 146) < 900 - 127 - 146);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a window too short to give what was asked", () => {
|
||||
// A phone on its side: 390px tall, 127px of that is the bar and 119px is
|
||||
// everything above the felt. What's left is under 100.
|
||||
const landscape = () => room(390, 127, 119);
|
||||
|
||||
test("gets the measurement, not a comfortable number", () => {
|
||||
// The floor used to be 220px, which this window hasn't got. Holding out for it
|
||||
// put the bottom row of seats 26px inside the action bar — the exact fault the
|
||||
// measurement exists to prevent.
|
||||
assert.ok(landscape() < 150, `${landscape()}px`);
|
||||
assert.ok(landscape() + 119 + 127 <= 390, "the felt still ends above the bar");
|
||||
});
|
||||
|
||||
test("still draws a table rather than a line", () => {
|
||||
assert.equal(ActionBar.feltRoomFor(200, 160, 100), ActionBar.FELT_FLOOR_PX);
|
||||
assert.equal(ActionBar.feltRoomFor(0, 0, 0), ActionBar.FELT_FLOOR_PX);
|
||||
});
|
||||
|
||||
test("never returns something a browser can't lay out", () => {
|
||||
[[390, 300, 200], [100, 90, 90], [-10, 0, 0]].forEach(([h, bar, top]) => {
|
||||
const answer = ActionBar.feltRoomFor(h, bar, top);
|
||||
assert.ok(Number.isFinite(answer) && answer > 0, `${h}/${bar}/${top} gave ${answer}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a window with room to spare", () => {
|
||||
test("hands over everything that's left, floor or no floor", () => {
|
||||
const reserved = ActionBar.SEAT_OVERHANG_PX + ActionBar.FELT_GAP_PX;
|
||||
assert.equal(room(1000, 127, 146), 1000 - 127 - 146 - reserved);
|
||||
assert.equal(room(1366, 120, 206), 1366 - 120 - 206 - reserved);
|
||||
});
|
||||
|
||||
test("gives a whole number of pixels", () => {
|
||||
assert.equal(room(844.4, 127.6, 223.2) % 1, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the felt changes size, so does everything on it", () => {
|
||||
// The felt is published as a custom property and the seats follow it on their own,
|
||||
// because they are placed in percentages of it. A bet chip anchored inside a seat
|
||||
// is not: it is placed in pixels off that seat's centre, worked out against the felt
|
||||
// as it was, and nothing re-ran that when the felt moved.
|
||||
//
|
||||
// Measured at 640x360 with five players, on a felt that lost 23px of height: the
|
||||
// chips were positioned for the old one and painted on the new, and seat 3's $5 came
|
||||
// to rest on seat 4's puck. The seat pucks had the same problem — they are searched
|
||||
// for a width the felt no longer has — and two pairs of them were overlapping.
|
||||
//
|
||||
// A side pot appearing does this too: it is 55px off the felt, mid-hand.
|
||||
const fakeDom = (windowHeight = 900) => {
|
||||
const root = { style: { values: {},
|
||||
setProperty(k, v) { this.values[k] = v; },
|
||||
getPropertyValue(k) { return this.values[k] || ""; } } };
|
||||
global.document = {
|
||||
documentElement: root,
|
||||
getElementById: id => (id === "pokerTableWrap"
|
||||
? { getBoundingClientRect: () => ({ top: 146 }) }
|
||||
: null),
|
||||
};
|
||||
global.window = { innerHeight: windowHeight, scrollY: 0 };
|
||||
return root;
|
||||
};
|
||||
|
||||
const spyRender = () => {
|
||||
const calls = { density: 0, seats: 0 };
|
||||
global.Render = {
|
||||
state: {},
|
||||
updateSeatDensity() { calls.density++; },
|
||||
renderSeats() { calls.seats++; },
|
||||
};
|
||||
return calls;
|
||||
};
|
||||
|
||||
test.afterEach(() => {
|
||||
delete global.document;
|
||||
delete global.window;
|
||||
delete global.Render;
|
||||
});
|
||||
|
||||
test("a felt that changed height lays the seats out again", () => {
|
||||
fakeDom();
|
||||
const calls = spyRender();
|
||||
ActionBar.publishFeltRoom(117);
|
||||
assert.equal(calls.seats, 1, "first publish sets a height it did not have");
|
||||
|
||||
ActionBar.publishFeltRoom(172);
|
||||
assert.equal(calls.seats, 2, "and a side pot grew the bar by 55px");
|
||||
});
|
||||
|
||||
test("a felt that did not change is left alone", () => {
|
||||
fakeDom();
|
||||
const calls = spyRender();
|
||||
ActionBar.publishFeltRoom(117);
|
||||
ActionBar.publishFeltRoom(117);
|
||||
ActionBar.publishFeltRoom(117);
|
||||
assert.equal(calls.seats, 1,
|
||||
"re-laying the table on every resize tick would be work for nothing");
|
||||
assert.equal(calls.density, 3, "the cheap re-measure still runs each time");
|
||||
});
|
||||
|
||||
test("it publishes the height whether or not anything is listening", () => {
|
||||
const root = fakeDom();
|
||||
delete global.Render;
|
||||
assert.doesNotThrow(() => ActionBar.publishFeltRoom(117));
|
||||
assert.match(root.style.getPropertyValue("--feltRoomPx"), /^\d+px$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
// 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.match(phoneRule, /^var\(--feltRoomPx,/,
|
||||
"something other than the measured room decides how tall the felt may be");
|
||||
});
|
||||
|
||||
test("and what it falls back on is a near guess rather than the whole window", () => {
|
||||
// The fallback is only ever used for the frame before actionBar.js has measured
|
||||
// anything — but that frame is one a dealer sees, because the scripts run at the
|
||||
// end of the body. `100vh` was what stood here, and it says the whole window is
|
||||
// free: measured at 360x640 nine-handed, the table came up 336x504 and dropped to
|
||||
// 336x374 as soon as the bar was measured. See whatTheFeltIsToldFirst.
|
||||
assert.doesNotMatch(phoneRule, /var\(--feltRoomPx,\s*100vh\)/);
|
||||
assert.match(phoneRule, /var\(--actionBarHeight, 9rem\)/);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
// Finding one hand in a night of them.
|
||||
//
|
||||
// What the log is actually opened for is a single hand — the one somebody at the rail
|
||||
// is arguing about. A night runs to sixty or more, five at a time in the panel, and
|
||||
// there was no way to ask for one: the dealer scrolled and read every row.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render } = require("./load");
|
||||
|
||||
const hand = (handNumber, pot, payouts, ledger = []) => ({
|
||||
handNumber, pot, timestamp: Date.now(), payouts, ledger,
|
||||
});
|
||||
|
||||
// A night as the tool records it: labels carry the cards the wizard was shown.
|
||||
const NIGHT = [
|
||||
hand(1, 40, [{ label: "Ann (A♠ K♠)", amount: 40 }], [{ name: "Ann", in: 20 }, { name: "Ben", in: 20 }]),
|
||||
hand(2, 190, [{ label: "Fay", amount: 190 }], [{ name: "Fay", in: 95 }, { name: "Gus", in: 95 }]),
|
||||
hand(12, 19, [{ label: "Ben", amount: 19 }], [{ name: "Ben", in: 10 }, { name: "Ann", in: 9 }]),
|
||||
hand(120, 76, [{ label: "Cal", amount: 38 }, { label: "Dee", amount: 38 }], [{ name: "Cal" }, { name: "Dee" }]),
|
||||
];
|
||||
|
||||
const found = query => NIGHT.filter(h => Render.handMatchesFilter(h, query)).map(h => h.handNumber);
|
||||
|
||||
describe("no filter", () => {
|
||||
test("shows the whole night", () => {
|
||||
assert.deepEqual(found(""), [1, 2, 12, 120]);
|
||||
assert.deepEqual(found(" "), [1, 2, 12, 120]);
|
||||
assert.deepEqual(found(null), [1, 2, 12, 120]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("by name", () => {
|
||||
test("finds the hand somebody won", () => {
|
||||
assert.deepEqual(found("Fay"), [2]);
|
||||
});
|
||||
|
||||
test("case doesn't matter — nobody types a capital at 2am", () => {
|
||||
assert.deepEqual(found("fay"), [2]);
|
||||
assert.deepEqual(found("FAY"), [2]);
|
||||
});
|
||||
|
||||
test("a name inside a label with cards on it still matches", () => {
|
||||
// The wizard writes the label as "Ann (A♠ K♠)", so the name is a substring
|
||||
// rather than the whole cell.
|
||||
assert.deepEqual(found("ann"), [1]);
|
||||
});
|
||||
|
||||
test("matches what the row shows, not everyone who was in the hand", () => {
|
||||
// Gus is in hand 2's ledger — he paid for it — but the row names the winner,
|
||||
// and searching text the panel doesn't display is a filter that looks broken
|
||||
// even when it's right. Measured on a seven-handed night where nobody folded,
|
||||
// matching the ledger returned 60 of 60 for every name at the table.
|
||||
assert.deepEqual(found("gus"), []);
|
||||
});
|
||||
|
||||
test("a split pot is found by either winner", () => {
|
||||
assert.deepEqual(found("cal"), [120]);
|
||||
assert.deepEqual(found("dee"), [120]);
|
||||
});
|
||||
|
||||
test("a name nobody has finds nothing rather than everything", () => {
|
||||
assert.deepEqual(found("zed"), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("by amount", () => {
|
||||
test("finds a pot", () => {
|
||||
assert.deepEqual(found("190"), [2]);
|
||||
});
|
||||
|
||||
test("a dollar sign and commas are ignored", () => {
|
||||
assert.deepEqual(found("$190"), [2]);
|
||||
assert.deepEqual(found("$1,90"), [2], "typed badly, still found");
|
||||
});
|
||||
|
||||
test("finds a payout that isn't the whole pot", () => {
|
||||
assert.deepEqual(found("38"), [120], "half of a chopped $76");
|
||||
});
|
||||
|
||||
test("a partial amount narrows rather than missing", () => {
|
||||
// Half remembering "something around forty" should still get there.
|
||||
assert.deepEqual(found("4"), [1], "$40");
|
||||
assert.deepEqual(found("19"), [2, 12], "the $190 pot and the $19 one");
|
||||
});
|
||||
|
||||
test("a bare number is an exact hand number, not a prefix of one", () => {
|
||||
// Amounts match on their leading digits, because half a remembered figure is
|
||||
// still a lead. Hand numbers don't: typing 12 to mean the hand is a dealer who
|
||||
// knows which hand they want, and dragging 120 and 128 along with it is noise.
|
||||
// "#12" is there for when they do want the prefix.
|
||||
assert.ok(!found("12").includes(120));
|
||||
});
|
||||
});
|
||||
|
||||
describe("by hand number", () => {
|
||||
test("a bare number matches the hand number as well as amounts", () => {
|
||||
assert.ok(found("12").includes(12));
|
||||
});
|
||||
|
||||
test("a hash pins it to the hand number and nothing else", () => {
|
||||
assert.deepEqual(found("#12"), [12, 120], "12 and 120 both start with 12");
|
||||
assert.deepEqual(found("#2"), [2], "and not the $190 pot of hand 2's neighbours");
|
||||
});
|
||||
|
||||
test("a hash with a number nobody played finds nothing", () => {
|
||||
assert.deepEqual(found("#7"), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("odd input can't break it", () => {
|
||||
test("punctuation on its own doesn't match everything", () => {
|
||||
assert.deepEqual(found("$"), [], "no digits and no letters left to match on");
|
||||
assert.deepEqual(found("#"), [1, 2, 12, 120], "an empty hand number is a prefix of them all");
|
||||
});
|
||||
|
||||
test("an entry with pieces missing doesn't throw", () => {
|
||||
const broken = { handNumber: 5, pot: 10 };
|
||||
assert.doesNotThrow(() => Render.handMatchesFilter(broken, "ann"));
|
||||
assert.equal(Render.handMatchesFilter(broken, "ann"), false);
|
||||
assert.equal(Render.handMatchesFilter(broken, "10"), true, "the pot is still findable");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
// What beats what.
|
||||
//
|
||||
// This is the one module in the tool that can pay the wrong person without anybody
|
||||
// noticing. A pot wizard that asks "who won?" is only ever as wrong as the dealer
|
||||
// ticking the box; one that answers the question itself is wrong on its own account,
|
||||
// silently, and a table that has come to trust it will not check. So the ordering is
|
||||
// tested from both ends: every category against every other, the traps that a
|
||||
// hand-rolled evaluator gets wrong (the wheel, the counterfeited kicker, the board
|
||||
// that plays), and then a lot of random seven-card hands against a second
|
||||
// implementation written a different way.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HandRank } = require("./load");
|
||||
|
||||
const best = (...cards) => HandRank.best(cards.flat());
|
||||
const beats = (a, b) => HandRank.compare(a, b) > 0;
|
||||
const chops = (a, b) => HandRank.compare(a, b) === 0;
|
||||
|
||||
// A hand written as one string, for the tests that list a lot of them.
|
||||
const hand = str => str.trim().split(/\s+/);
|
||||
|
||||
describe("the five-card hand", () => {
|
||||
const of = str => HandRank.evaluate5(hand(str));
|
||||
|
||||
test("each category is recognised", () => {
|
||||
const cases = [
|
||||
["AS KS QS JS TS", HandRank.STRAIGHT_FLUSH, "Royal flush"],
|
||||
["9H 8H 7H 6H 5H", HandRank.STRAIGHT_FLUSH, "Straight flush, nine high"],
|
||||
["7C 7D 7H 7S 2C", HandRank.FOUR_OF_A_KIND, "Four of a kind, sevens"],
|
||||
["KC KD KH 3S 3C", HandRank.FULL_HOUSE, "Full house, kings full of threes"],
|
||||
["AD JD 8D 5D 2D", HandRank.FLUSH, "Flush, ace high"],
|
||||
["9C 8D 7H 6S 5C", HandRank.STRAIGHT, "Straight, nine high"],
|
||||
["QC QD QH 7S 2C", HandRank.THREE_OF_A_KIND, "Three of a kind, queens"],
|
||||
["AC AD 8H 8S 4C", HandRank.TWO_PAIR, "Two pair, aces and eights"],
|
||||
["QC QD 9H 5S 2C", HandRank.ONE_PAIR, "Pair of queens"],
|
||||
["AC JD 8H 5S 2C", HandRank.HIGH_CARD, "Ace high"],
|
||||
];
|
||||
cases.forEach(([cards, category, described]) => {
|
||||
const ranked = of(cards);
|
||||
assert.equal(ranked.category, category, cards);
|
||||
assert.equal(HandRank.describe(ranked), described, cards);
|
||||
});
|
||||
});
|
||||
|
||||
test("the categories are in the right order", () => {
|
||||
// Listed weakest first, then every pair checked both ways round. The order
|
||||
// itself is the thing being asserted, so it is written out rather than derived
|
||||
// from the constants it is meant to be checking.
|
||||
const ladder = [
|
||||
"AC JD 8H 5S 2C", // ace high
|
||||
"2C 2D 8H 5S 3C", // a pair of deuces
|
||||
"AC AD 8H 8S 4C", // two pair
|
||||
"3C 3D 3H 7S 2C", // trips
|
||||
"9C 8D 7H 6S 5C", // straight
|
||||
"AD JD 8D 5D 2D", // flush
|
||||
"2C 2D 2H 3S 3C", // full house
|
||||
"7C 7D 7H 7S 2C", // quads
|
||||
"9H 8H 7H 6H 5H", // straight flush
|
||||
];
|
||||
for (let low = 0; low < ladder.length; low++) {
|
||||
for (let high = low + 1; high < ladder.length; high++) {
|
||||
assert.ok(beats(of(ladder[high]), of(ladder[low])),
|
||||
`${ladder[high]} should beat ${ladder[low]}`);
|
||||
assert.ok(!beats(of(ladder[low]), of(ladder[high])),
|
||||
`${ladder[low]} should not beat ${ladder[high]}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the same hand in different suits is a chop", () => {
|
||||
assert.ok(chops(of("AC KD 8H 5S 2C"), of("AS KH 8D 5C 2H")));
|
||||
assert.ok(chops(of("QC QD 9H 5S 2C"), of("QH QS 9D 5C 2H")));
|
||||
});
|
||||
|
||||
test("nothing but five real cards is a hand", () => {
|
||||
assert.equal(HandRank.evaluate5(hand("AC KD 8H 5S")), null, "four");
|
||||
assert.equal(HandRank.evaluate5(hand("AC KD 8H 5S 2C 3D")), null, "six");
|
||||
assert.equal(HandRank.evaluate5(["AC", "KD", "8H", "5S", "ZZ"]), null, "nonsense");
|
||||
assert.equal(HandRank.evaluate5(["AC", "KD", "8H", "5S", ""]), null, "an empty slot");
|
||||
assert.equal(HandRank.evaluate5(null), null);
|
||||
});
|
||||
|
||||
test("case does not matter, because the picker is not the only way cards get in", () => {
|
||||
assert.ok(chops(of("ac kd 8h 5s 2c"), of("AC KD 8H 5S 2C")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the wheel", () => {
|
||||
const of = str => HandRank.evaluate5(hand(str));
|
||||
|
||||
// The straight that runs through the ace at the bottom. An evaluator that scores a
|
||||
// straight by its top card and lets the ace keep its usual value makes A-2-3-4-5
|
||||
// the best straight there is instead of the worst — which pays the wrong player
|
||||
// every time it comes up against a broadway.
|
||||
test("A-2-3-4-5 is a straight", () => {
|
||||
assert.equal(of("AC 2D 3H 4S 5C").category, HandRank.STRAIGHT);
|
||||
assert.equal(HandRank.describe(of("AC 2D 3H 4S 5C")), "Straight, five high");
|
||||
});
|
||||
|
||||
test("and it is the lowest one", () => {
|
||||
assert.ok(beats(of("6C 5D 4H 3S 2C"), of("AC 2D 3H 4S 5C")),
|
||||
"six high beats the wheel");
|
||||
assert.ok(beats(of("AC KD QH JS TC"), of("AC 2D 3H 4S 5C")),
|
||||
"broadway beats the wheel");
|
||||
});
|
||||
|
||||
test("the same goes for a steel wheel", () => {
|
||||
const steel = of("AH 2H 3H 4H 5H");
|
||||
assert.equal(steel.category, HandRank.STRAIGHT_FLUSH);
|
||||
assert.equal(HandRank.describe(steel), "Straight flush, five high");
|
||||
assert.ok(beats(of("6H 5H 4H 3H 2H"), steel));
|
||||
assert.ok(beats(of("AS KS QS JS TS"), steel));
|
||||
});
|
||||
|
||||
test("an ace with a four-card run either side of it is not a straight", () => {
|
||||
// K-A-2-3-4 does not wrap.
|
||||
assert.equal(of("KC AD 2H 3S 4C").category, HandRank.HIGH_CARD);
|
||||
assert.equal(of("QC KD AH 2S 3C").category, HandRank.HIGH_CARD);
|
||||
});
|
||||
});
|
||||
|
||||
describe("kickers", () => {
|
||||
const of = str => HandRank.evaluate5(hand(str));
|
||||
|
||||
test("the fifth card settles two pair", () => {
|
||||
assert.ok(beats(of("AC AD 8H 8S KC"), of("AH AS 8D 8C QC")));
|
||||
assert.ok(chops(of("AC AD 8H 8S KC"), of("AH AS 8D 8C KD")));
|
||||
});
|
||||
|
||||
test("and a pair, three kickers deep", () => {
|
||||
assert.ok(beats(of("9C 9D AH 7S 2C"), of("9H 9S KD 7C 2D")), "first kicker");
|
||||
assert.ok(beats(of("9C 9D AH 7S 2C"), of("9H 9S AD 6C 2D")), "second");
|
||||
assert.ok(beats(of("9C 9D AH 7S 3C"), of("9H 9S AD 7C 2D")), "third");
|
||||
});
|
||||
|
||||
test("the higher pair beats the better kicker", () => {
|
||||
assert.ok(beats(of("TC TD 3H 2S 4C"), of("9C 9D AH KS QC")));
|
||||
});
|
||||
|
||||
test("two pair is read by its higher pair first", () => {
|
||||
assert.ok(beats(of("KC KD 2H 2S 3C"), of("QC QD JH JS AC")));
|
||||
});
|
||||
|
||||
test("a full house is read by its trips first", () => {
|
||||
assert.ok(beats(of("3C 3D 3H 2S 2C"), of("2D 2H 2C AS AD")));
|
||||
});
|
||||
|
||||
test("a flush is read all the way down", () => {
|
||||
assert.ok(beats(of("AD KD 8D 5D 3D"), of("AH KH 8H 5H 2H")));
|
||||
assert.ok(chops(of("AD KD 8D 5D 3D"), of("AH KH 8H 5H 3H")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the best five out of seven", () => {
|
||||
test("it finds the flush the two hole cards do not both belong to", () => {
|
||||
const ranked = best(["AH", "2C"], ["KH", "QH", "7H", "3H", "9S"]);
|
||||
assert.equal(ranked.category, HandRank.FLUSH);
|
||||
assert.equal(HandRank.describe(ranked), "Flush, ace high");
|
||||
});
|
||||
|
||||
test("it plays the board when the board is better than anything you hold", () => {
|
||||
// Nobody can beat a royal on the board; both hands are the board itself.
|
||||
const board = ["AS", "KS", "QS", "JS", "TS"];
|
||||
const one = best(["2C", "3D"], board);
|
||||
const two = best(["7H", "8H"], board);
|
||||
assert.equal(HandRank.describe(one), "Royal flush");
|
||||
assert.ok(chops(one, two), "the board plays, so it is a chop");
|
||||
});
|
||||
|
||||
test("it does not use three hole cards, because there are only two", () => {
|
||||
const ranked = best(["AC", "AD"], ["AH", "KS", "KD", "2C", "3H"]);
|
||||
assert.equal(HandRank.describe(ranked), "Full house, aces full of kings");
|
||||
});
|
||||
|
||||
test("a counterfeited two pair falls back on the board", () => {
|
||||
// Holding 6-5 on a board of 8-8-K-K-A: the player's two pair is counterfeited —
|
||||
// the best five are the board's two pair with the ace, and the sixes never play.
|
||||
const ranked = best(["6C", "5D"], ["8H", "8S", "KC", "KD", "AC"]);
|
||||
assert.equal(HandRank.describe(ranked), "Two pair, kings and eights");
|
||||
assert.deepEqual([...ranked.cards].sort(), ["8H", "8S", "AC", "KC", "KD"].sort());
|
||||
});
|
||||
|
||||
test("five, six and seven cards are all rankable; four is not", () => {
|
||||
assert.ok(best(["AC", "KD"], ["QH", "JS", "TC"]), "five");
|
||||
assert.ok(best(["AC", "KD"], ["QH", "JS", "TC", "2D"]), "six");
|
||||
assert.ok(best(["AC", "KD"], ["QH", "JS", "TC", "2D", "3H"]), "seven");
|
||||
assert.equal(best(["AC", "KD"], ["QH", "JS"]), null, "four");
|
||||
});
|
||||
|
||||
test("the same card twice is not a hand", () => {
|
||||
// A mis-entry, not a monster. Ranking it would mean ranking a deck that does
|
||||
// not exist, and the answer would look exactly as confident as a real one.
|
||||
assert.equal(best(["AC", "AC"], ["KH", "QS", "JD", "2C", "3H"]), null);
|
||||
assert.equal(best(["AC", "KD"], ["KD", "QS", "JD", "2C", "3H"]), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the showdown", () => {
|
||||
const board = ["AH", "KD", "9C", "4S", "2H"];
|
||||
|
||||
test("it names one winner and says how it read every hand", () => {
|
||||
const result = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["AS", "KC"] }, // two pair, aces and kings
|
||||
{ id: "b", cards: ["9H", "9S"] }, // trips
|
||||
{ id: "c", cards: ["AC", "3D"] }, // a pair of aces
|
||||
]);
|
||||
assert.equal(result.complete, true);
|
||||
assert.deepEqual(result.winners, ["b"]);
|
||||
assert.deepEqual(result.players.map(p => p.place), [2, 1, 3]);
|
||||
assert.deepEqual(result.players.map(p => HandRank.describe(p.hand)), [
|
||||
"Two pair, aces and kings",
|
||||
"Three of a kind, nines",
|
||||
"Pair of aces",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a chop comes back as two winners on the same place", () => {
|
||||
const result = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["AS", "QC"] },
|
||||
{ id: "b", cards: ["AD", "QH"] },
|
||||
{ id: "c", cards: ["7H", "5S"] },
|
||||
]);
|
||||
assert.deepEqual(result.winners.sort(), ["a", "b"]);
|
||||
assert.deepEqual(result.players.map(p => p.place), [1, 1, 3],
|
||||
"and the player behind them is third, not second");
|
||||
});
|
||||
|
||||
test("an incomplete board is not a showdown", () => {
|
||||
const result = HandRank.showdown(["AH", "KD", "9C"], [
|
||||
{ id: "a", cards: ["AS", "KC"] },
|
||||
{ id: "b", cards: ["9H", "9S"] },
|
||||
]);
|
||||
assert.equal(result.boardComplete, false);
|
||||
assert.equal(result.complete, false);
|
||||
assert.deepEqual(result.winners, [], "it must not name one");
|
||||
});
|
||||
|
||||
test("one unknown hand and it will not say who won", () => {
|
||||
// Who is *ahead* and who *won* are different claims, and the second one is the
|
||||
// only one that can pay somebody. A player who never turned their cards over
|
||||
// could have anything.
|
||||
const result = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["AS", "KC"] },
|
||||
{ id: "b", cards: ["", ""] },
|
||||
]);
|
||||
assert.equal(result.complete, false);
|
||||
assert.deepEqual(result.winners, []);
|
||||
assert.deepEqual(result.unknown, ["b"]);
|
||||
assert.ok(result.players[0].hand, "but it still reads the hands it can");
|
||||
assert.equal(result.players[1].hand, null);
|
||||
});
|
||||
|
||||
test("a card in two places stops it dead", () => {
|
||||
const result = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["AH", "KC"] }, // the ace of hearts is on the board
|
||||
{ id: "b", cards: ["9H", "9S"] },
|
||||
]);
|
||||
assert.equal(result.duplicated, true);
|
||||
assert.equal(result.complete, false);
|
||||
assert.deepEqual(result.winners, []);
|
||||
});
|
||||
|
||||
test("no players at all is not a showdown either", () => {
|
||||
const result = HandRank.showdown(board, []);
|
||||
assert.equal(result.complete, false);
|
||||
assert.deepEqual(result.winners, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("against a second opinion", () => {
|
||||
// The strongest check available without a poker library: a completely separate
|
||||
// ranking, written the other way round — every hand turned into a sortable string
|
||||
// by counting ranks, with no shared helper between it and the module. Where the two
|
||||
// disagree, one of them is wrong and the test says which hand it was.
|
||||
const VALUE = { T: 10, J: 11, Q: 12, K: 13, A: 14 };
|
||||
const val = card => VALUE[card[0]] || Number(card[0]);
|
||||
|
||||
function reference5(cards) {
|
||||
const ranks = cards.map(val).sort((x, y) => y - x);
|
||||
const suited = new Set(cards.map(c => c[1])).size === 1;
|
||||
|
||||
const tally = {};
|
||||
ranks.forEach(r => { tally[r] = (tally[r] || 0) + 1; });
|
||||
const bySize = Object.keys(tally)
|
||||
.map(Number)
|
||||
.sort((x, y) => tally[y] - tally[x] || y - x);
|
||||
const sizes = bySize.map(r => tally[r]).join("");
|
||||
|
||||
const unique = [...new Set(ranks)];
|
||||
let run = null;
|
||||
if (unique.length === 5) {
|
||||
if (unique[0] - unique[4] === 4) run = unique[0];
|
||||
else if (unique.join(",") === "14,5,4,3,2") run = 5;
|
||||
}
|
||||
|
||||
// [category, ...tiebreak], the same shape, arrived at from the group sizes
|
||||
// rather than from a chain of ifs about shape[0] and shape[1].
|
||||
if (suited && run !== null) return [8, run];
|
||||
if (sizes === "41") return [7, ...bySize];
|
||||
if (sizes === "32") return [6, ...bySize];
|
||||
if (suited) return [5, ...ranks];
|
||||
if (run !== null) return [4, run];
|
||||
if (sizes === "311") return [3, ...bySize];
|
||||
if (sizes === "221") return [2, ...bySize];
|
||||
if (sizes === "2111") return [1, ...bySize];
|
||||
return [0, ...ranks];
|
||||
}
|
||||
|
||||
const worse = (a, b) => {
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) < (b[i] || 0);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
function referenceBest(cards) {
|
||||
let top = null;
|
||||
for (let a = 0; a < 3; a++) for (let b = a + 1; b < 4; b++)
|
||||
for (let c = b + 1; c < 5; c++) for (let d = c + 1; d < 6; d++)
|
||||
for (let e = d + 1; e < 7; e++) {
|
||||
const score = reference5([cards[a], cards[b], cards[c], cards[d], cards[e]]);
|
||||
if (!top || worse(top, score)) top = score;
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
// A deck, and a deterministic shuffle — a failure has to be reproducible or it is
|
||||
// just an anecdote about one CI run.
|
||||
const DECK = [];
|
||||
"23456789TJQKA".split("").forEach(r => "SHDC".split("").forEach(s => DECK.push(r + s)));
|
||||
|
||||
let seed = 20260904;
|
||||
const random = () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
|
||||
test("twenty thousand random seven-card hands rank the same way", () => {
|
||||
const deck = [...DECK];
|
||||
for (let round = 0; round < 20000; round++) {
|
||||
// Seven off the top of a shuffle of only what it needs.
|
||||
const drawn = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const pick = i + Math.floor(random() * (deck.length - i));
|
||||
[deck[i], deck[pick]] = [deck[pick], deck[i]];
|
||||
drawn.push(deck[i]);
|
||||
}
|
||||
const mine = HandRank.best(drawn);
|
||||
const theirs = referenceBest(drawn);
|
||||
assert.deepEqual([mine.category, ...mine.tiebreak], theirs,
|
||||
`disagreed on ${drawn.join(" ")}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("and four thousand random pairs of hands are ordered the same way", () => {
|
||||
// The categories agreeing is not the same as the comparison agreeing: this is
|
||||
// the half that pays somebody.
|
||||
const deck = [...DECK];
|
||||
for (let round = 0; round < 4000; round++) {
|
||||
const drawn = [];
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const pick = i + Math.floor(random() * (deck.length - i));
|
||||
[deck[i], deck[pick]] = [deck[pick], deck[i]];
|
||||
drawn.push(deck[i]);
|
||||
}
|
||||
const board = drawn.slice(0, 5);
|
||||
const one = [...board, drawn[5], drawn[6]];
|
||||
const two = [...board, drawn[7], drawn[8]];
|
||||
|
||||
const mine = Math.sign(HandRank.compare(HandRank.best(one), HandRank.best(two)));
|
||||
const a = referenceBest(one), b = referenceBest(two);
|
||||
const theirs = worse(a, b) ? -1 : worse(b, a) ? 1 : 0;
|
||||
assert.equal(mine, theirs,
|
||||
`board ${board.join(" ")}: ${drawn[5]}${drawn[6]} vs ${drawn[7]}${drawn[8]}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("saying it in a sentence", () => {
|
||||
// The verdict line reads "Alexandra wins $25 with ___", so the phrase has to carry
|
||||
// its own article. Which article is not something the first letter can answer: it
|
||||
// is "a full house" and "a flush", but "four of a kind" and "two pair" take none,
|
||||
// and nobody has ever said "an ace high".
|
||||
test("every category takes the article a table would give it", () => {
|
||||
const said = str => HandRank.phrase(HandRank.evaluate5(hand(str)));
|
||||
assert.deepEqual([
|
||||
said("AS KS QS JS TS"),
|
||||
said("9H 8H 7H 6H 5H"),
|
||||
said("7C 7D 7H 7S 2C"),
|
||||
said("KC KD KH 3S 3C"),
|
||||
said("AD JD 8D 5D 2D"),
|
||||
said("9C 8D 7H 6S 5C"),
|
||||
said("QC QD QH 7S 2C"),
|
||||
said("AC AD 8H 8S 4C"),
|
||||
said("QC QD 9H 5S 2C"),
|
||||
said("AC JD 8H 5S 2C"),
|
||||
], [
|
||||
"a royal flush",
|
||||
"a straight flush, nine high",
|
||||
"four of a kind, sevens",
|
||||
"a full house, kings full of threes",
|
||||
"a flush, ace high",
|
||||
"a straight, nine high",
|
||||
"three of a kind, queens",
|
||||
"two pair, aces and eights",
|
||||
"a pair of queens",
|
||||
"ace high",
|
||||
]);
|
||||
});
|
||||
|
||||
test("with nothing to say it says something", () => {
|
||||
assert.equal(HandRank.phrase(null), "the best hand");
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ function load(name) {
|
||||
}
|
||||
|
||||
const Money = load("money");
|
||||
const HandRank = load("handRank");
|
||||
const PotCalculator = load("potCalculator");
|
||||
const SplitCalculator = load("splitCalculator");
|
||||
const Betting = load("betting");
|
||||
@@ -29,6 +30,44 @@ globalThis.Render = Render;
|
||||
const HoldemState = require(path.join(SRC, "state.js"));
|
||||
globalThis.HoldemState = HoldemState;
|
||||
|
||||
// actionBar.js after state, since its own module-level code reaches for neither but
|
||||
// its methods reach for both. Loaded last so nothing it touches is still undefined.
|
||||
const ActionBar = load("actionBar");
|
||||
|
||||
// The picker's slot bookkeeping — which slots it is filling, what is already dealt —
|
||||
// is plain state work and testable here. Only render() and open() touch the DOM.
|
||||
const CardPicker = load("cardPicker");
|
||||
|
||||
// The wake lock's rule about when a table needs the screen is plain state work too.
|
||||
// Its acquire/release bookkeeping is testable here as well, against a stand-in for
|
||||
// navigator.wakeLock — see wakeLock.test.js.
|
||||
const WakeLock = load("wakeLock");
|
||||
|
||||
// feedback.js exports two namespaces and a third. Two of them answer here: Unavailable,
|
||||
// which is nearly all logic (see theUnansweredPress), and Toast, whose timer and focus
|
||||
// bookkeeping ask little enough of a document that a stand-in can answer (see
|
||||
// theWayBack). All three are also set as globals, the way the browser hands them to
|
||||
// render.js and cardPicker.js.
|
||||
const { Toast, Dialog, Unavailable } = require(path.join(SRC, "feedback.js"));
|
||||
globalThis.Toast = Toast;
|
||||
globalThis.Dialog = Dialog;
|
||||
globalThis.Unavailable = Unavailable;
|
||||
|
||||
// Modal is DOM behaviour through and through, but two pieces of it answer here: the
|
||||
// list of what counts as a Tab stop, which is a string, and preservingFocus, which asks
|
||||
// so little of a document that a stand-in can answer it. See theKeyboardInASheet.
|
||||
const Modal = load("modal");
|
||||
|
||||
// Quick Split draws itself entirely from the DOM, but the arithmetic that decides what
|
||||
// its two steppers are allowed to reach — and how many ways a pot may be chopped — is
|
||||
// plain and answers here. See theWaysControl.
|
||||
const QuickSplit = load("quickSplit");
|
||||
|
||||
// The seat menu's list — which actions a seat is offered and which group each one
|
||||
// lands in — is decided before anything is drawn, so actionsFor() answers here. Only
|
||||
// render() and open() touch the DOM.
|
||||
const SeatMenu = load("seatMenu");
|
||||
|
||||
// ---- Table builders ----------------------------------------------------------
|
||||
|
||||
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
|
||||
@@ -74,6 +113,7 @@ function contributions(state) {
|
||||
|
||||
module.exports = {
|
||||
Money,
|
||||
HandRank,
|
||||
Render,
|
||||
HandHistoryExport,
|
||||
PotCalculator,
|
||||
@@ -81,6 +121,14 @@ module.exports = {
|
||||
SettleUp,
|
||||
Betting,
|
||||
HoldemState,
|
||||
ActionBar,
|
||||
CardPicker,
|
||||
WakeLock,
|
||||
SeatMenu,
|
||||
Modal,
|
||||
QuickSplit,
|
||||
Toast,
|
||||
Unavailable,
|
||||
makeTable,
|
||||
chipsOnTable,
|
||||
contributions,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// Taking a hand back.
|
||||
//
|
||||
// The rulings sheet in this tool has told dealers to redeal since the day it was
|
||||
// written — first card to the wrong seat, a seat entitled to a hand dealt out, one of
|
||||
// the first two cards exposed — and the tool had no way to do it. The only control that
|
||||
// ended a live hand was Next Hand, which says out loud that it takes the money off the
|
||||
// table without paying anybody. That is right for an abandoned pot and wrong for a
|
||||
// misdeal, where the blinds and the calls belong to the people who put them in.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
const dealt = (extra = {}) => {
|
||||
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10, dealerSeatIndex: 0, ...extra });
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("the money", () => {
|
||||
test("goes back to the people who put it in", () => {
|
||||
const state = dealt();
|
||||
const before = state.seats.map(p => Betting.stackOf(state, p) + Betting.committed(p));
|
||||
Betting.takeHandBack(state);
|
||||
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
|
||||
});
|
||||
|
||||
test("including calls and raises, not just the blinds", () => {
|
||||
const state = dealt();
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 40);
|
||||
Betting.call(state, state.actingSeatIndex);
|
||||
assert.ok(Betting.potSoFar(state) > 50, "there is real money in the middle");
|
||||
|
||||
const taken = Betting.takeHandBack(state);
|
||||
assert.equal(taken.total, 95, "blinds and both wagers");
|
||||
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), [200, 200, 200, 200, 200, 200]);
|
||||
});
|
||||
|
||||
test("and across streets, once chips have been swept into hand totals", () => {
|
||||
const state = dealt();
|
||||
[3, 4, 5, 0, 1, 2].forEach(seat => {
|
||||
if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
|
||||
});
|
||||
Betting.closeStreet(state);
|
||||
assert.ok(state.seats.some(p => (Number(p.amountOverride) || 0) > 0), "the flop's money is banked");
|
||||
|
||||
Betting.takeHandBack(state);
|
||||
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), [200, 200, 200, 200, 200, 200]);
|
||||
assert.deepEqual(state.seats.map(p => p.amountOverride), [null, null, null, null, null, null]);
|
||||
});
|
||||
|
||||
test("the table has exactly the chips it started with", () => {
|
||||
const fresh = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10 });
|
||||
const total = chipsOnTable(fresh);
|
||||
const state = dealt();
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 40);
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(chipsOnTable(state), total);
|
||||
});
|
||||
|
||||
test("nothing is thrown away — the discard pile doesn't move", () => {
|
||||
const state = dealt();
|
||||
state.discarded = 8;
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(state.discarded, 8, "a misdeal is not an abandoned pot");
|
||||
});
|
||||
|
||||
test("it says what went back and to whom", () => {
|
||||
const state = dealt();
|
||||
const taken = Betting.takeHandBack(state);
|
||||
assert.equal(taken.returned.length, 2, "the two blinds");
|
||||
assert.deepEqual(taken.returned.map(r => r.amount).sort((a, b) => a - b), [5, 10]);
|
||||
assert.ok(taken.returned.every(r => r.name), "named, because the dealer says it out loud");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the table afterwards", () => {
|
||||
test("is waiting to deal the same hand again", () => {
|
||||
const state = dealt();
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(state.bettingOpen, false, "the dealer says when");
|
||||
assert.equal(state.street, "preflop");
|
||||
assert.equal(state.actingSeatIndex, null);
|
||||
assert.equal(state.currentBet, 0);
|
||||
});
|
||||
|
||||
test("keeps the button where it was — a misdeal is not the next hand", () => {
|
||||
const state = dealt({ dealerSeatIndex: 3 });
|
||||
const handNumber = state.handNumber;
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(state.dealerSeatIndex, 3);
|
||||
assert.equal(state.handNumber, handNumber, "no hand has been played");
|
||||
assert.equal((state.handHistory || []).length, 0, "and none is logged");
|
||||
});
|
||||
|
||||
test("keeps the straddle, which was announced and still stands", () => {
|
||||
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10 });
|
||||
state.straddleCount = 1;
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(state.postedStraddles, [3]);
|
||||
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(state.straddleCount, 1, "still on for the redeal");
|
||||
assert.deepEqual(state.postedStraddles, [], "but nothing is out there yet");
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(state.postedStraddles, [3], "and it goes out again");
|
||||
});
|
||||
|
||||
test("clears the cards and the folds, because none of it happened", () => {
|
||||
const state = dealt();
|
||||
state.seats[3].cards = ["As", "Kd"];
|
||||
Betting.fold(state, 3);
|
||||
Betting.takeHandBack(state);
|
||||
assert.deepEqual(state.seats[3].cards, ["", ""]);
|
||||
assert.equal(state.seats[3].folded, false);
|
||||
assert.ok(state.seats.every(p => !p.hasActed));
|
||||
});
|
||||
|
||||
test("deals again cleanly, with the same blinds on the same players", () => {
|
||||
const state = dealt();
|
||||
const first = state.seats.map(p => Betting.committed(p));
|
||||
Betting.takeHandBack(state);
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(state.seats.map(p => Betting.committed(p)), first);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bank still balances", () => {
|
||||
test("through a misdeal and the redeal after it", () => {
|
||||
const state = dealt();
|
||||
state.seats.forEach(p => { p.buyIns = 200; });
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 60);
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
|
||||
Betting.beginHand(state);
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, true);
|
||||
assert.equal(bank.cashIn, 1200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("taking back a hand nobody had money in", () => {
|
||||
test("is harmless: no blinds, nothing moves", () => {
|
||||
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 0, bigBlind: 0 });
|
||||
Betting.beginHand(state);
|
||||
const taken = Betting.takeHandBack(state);
|
||||
assert.equal(taken.total, 0);
|
||||
assert.deepEqual(taken.returned, []);
|
||||
assert.equal(state.bettingOpen, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// The half of the interface the stylesheet does not draw.
|
||||
//
|
||||
// The browser draws the checkboxes, the menu a <select> opens, the scrollbars, the
|
||||
// caret, the spellcheck underline and the autofill wash, and it draws all of them for a
|
||||
// light page unless the page says otherwise. This one never did: measured,
|
||||
// `color-scheme: normal` on a #141414 ground, with three checkboxes in the Table Setup
|
||||
// panel rendering as bright white squares — the loudest objects on the screen, brighter
|
||||
// than the gold — and two more in the seat sheet doing the same over the felt.
|
||||
//
|
||||
// The comment on the pot wizard's hand-painted checkbox called it "the last raw browser
|
||||
// control in the tool". Six others were still the platform's, and three of those were
|
||||
// still 13x13 against a 24px floor this stylesheet had already written down once and
|
||||
// applied to exactly one box.
|
||||
//
|
||||
// Painting is the expensive way to fix that, and the pot wizard's box shows the bill:
|
||||
// a tick drawn as a `background` on a pseudo-element is not a colour forced-colors mode
|
||||
// knows how to replace, so it simply disappears — no visible checked state at all, on
|
||||
// the dialog that pays the table. Declaring the scheme fixes six controls natively and
|
||||
// costs nothing; the seventh stays painted on purpose and pays its own way.
|
||||
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");
|
||||
|
||||
const ruleFor = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
|
||||
return CSS.slice(at, CSS.indexOf("}", at));
|
||||
};
|
||||
|
||||
// Every block of a given at-rule, braces balanced, joined.
|
||||
//
|
||||
// This used to take the first one and stop, which held for exactly as long as there was
|
||||
// one of them. A second forced-colors block was added nearer the top of the file for a
|
||||
// control that dropped out of the OS palette, and the bill-paying test below started
|
||||
// reading that block instead and reporting the pot wizard's answers missing — a false
|
||||
// failure about a rule nobody had touched. There is no reason a stylesheet should
|
||||
// answer a media query in one place, so this asks about all of them.
|
||||
const atRule = query => {
|
||||
const open = `@media ${query} {`;
|
||||
const blocks = [];
|
||||
for (let at = CSS.indexOf(open); at >= 0; at = CSS.indexOf(open, at + 1)) {
|
||||
let depth = 0;
|
||||
for (let i = CSS.indexOf("{", at); i < CSS.length; i++) {
|
||||
if (CSS[i] === "{") depth++;
|
||||
else if (CSS[i] === "}" && --depth === 0) { blocks.push(CSS.slice(at, i + 1)); break; }
|
||||
}
|
||||
assert.ok(blocks.length && depth === 0, `\`${open}\` never closes`);
|
||||
}
|
||||
assert.ok(blocks.length, `there is no \`@media ${query}\` block`);
|
||||
return blocks.join("\n");
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the page says what colour it is", () => {
|
||||
const root = ruleFor(":root");
|
||||
|
||||
test("dark, to the browser and not only to the eye", () => {
|
||||
assert.match(root, /color-scheme:\s*dark/);
|
||||
});
|
||||
|
||||
// The checked colour is the green this tool already uses for yes, so the one
|
||||
// painted checkbox and the six native ones agree rather than nearly agree.
|
||||
test("and the checked colour is the tool's own green, not the platform's blue", () => {
|
||||
assert.match(root, /--accent-on:\s*#1e8449/);
|
||||
assert.match(root, /accent-color:\s*var\(--accent-on\)/);
|
||||
assert.match(ruleFor(".potWizardChoice input:checked"), /background:\s*#1e8449/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a checkbox is one size", () => {
|
||||
test("every one of them, not the one that was noticed", () => {
|
||||
assert.match(ruleFor('input[type="checkbox"]'), /width:\s*24px/);
|
||||
assert.match(ruleFor('input[type="checkbox"]'), /height:\s*24px/);
|
||||
});
|
||||
|
||||
test("and no box carries a size of its own any more", () => {
|
||||
assert.ok(!/#dedicatedDealerToggle\s*\{/.test(CSS),
|
||||
"one checkbox is still sized by id, which is how five got left behind");
|
||||
assert.ok(!/width:\s*22px/.test(ruleFor(".potWizardChoice input")),
|
||||
"the painted box restates its size instead of taking the one above");
|
||||
});
|
||||
|
||||
// A stated width is only a suggestion to a flex item. The coarse-pointer block had
|
||||
// already been bitten by this and said so; the mouse case had not.
|
||||
test("a swatch in a crowded row keeps the width it was given", () => {
|
||||
assert.match(ruleFor(".chipColorInput"), /flex:\s*none/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what painting a control costs, and who pays it", () => {
|
||||
// If a future round paints another one, this is the bill it has to settle too.
|
||||
test("nothing gives up the platform's drawing without answering forced colours", () => {
|
||||
const forced = atRule("(forced-colors: active)");
|
||||
const painted = [...CSS.matchAll(/\n([^\n{}]+)\s*\{[^}]*appearance:\s*none/g)]
|
||||
.map(m => m[1].trim());
|
||||
assert.ok(painted.length > 0, "nothing is painted, so this test is measuring nothing");
|
||||
painted.forEach(selector => {
|
||||
assert.ok(forced.includes(selector),
|
||||
`\`${selector}\` drops the platform's control but has no forced-colors answer`);
|
||||
});
|
||||
});
|
||||
|
||||
// A tick drawn as a background is gone in forced-colors mode; a system keyword is
|
||||
// the only colour guaranteed to survive, because it *is* the palette.
|
||||
test("the tick that is drawn rather than typed survives the OS repainting it", () => {
|
||||
const forced = atRule("(forced-colors: active)");
|
||||
assert.match(forced, /\.potWizardChoice input::before\s*\{[^}]*background:\s*CanvasText/);
|
||||
});
|
||||
|
||||
test("and so does the row's own chosen state, which is a gradient", () => {
|
||||
const forced = atRule("(forced-colors: active)");
|
||||
assert.match(forced, /\.potWizardChoice\.chosen\s*\{[^}]*outline:\s*2px solid Highlight/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the other page of the same tool", () => {
|
||||
const QUICKSPLIT = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "quicksplit.css"), "utf8");
|
||||
|
||||
// It is the darker of the two pages and is opened from the felt mid-hand. It has no
|
||||
// checkboxes to go white, but scrollbars, caret and selection were still drawn for
|
||||
// a light page.
|
||||
test("Quick Split says what colour it is too", () => {
|
||||
assert.match(QUICKSPLIT, /:root\s*\{[^}]*color-scheme:\s*dark/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// One question, one answer.
|
||||
//
|
||||
// The round before this found the bank check and the standings reading `seat.stack`
|
||||
// where the felt reads the table's starting stack — two parts of the tool disagreeing
|
||||
// about how much money is on the table. This is the sweep that followed: everywhere
|
||||
// else the same two questions get asked, asked the same way.
|
||||
//
|
||||
// "What is this seat holding?" -> HoldemState.chipsHeldBy
|
||||
// "Is anybody in this chair?" -> HoldemState.seatInPlay
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Betting, SettleUp } = require("./load");
|
||||
|
||||
const openedOnTheDefault = (stack = 200) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 6);
|
||||
state.defaultStack = stack;
|
||||
["Ann", "Ben", "Cal"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].buyIns = stack;
|
||||
});
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("what a seat is holding", () => {
|
||||
test("is the same number the felt plays with", () => {
|
||||
const state = openedOnTheDefault();
|
||||
state.seats.forEach((player, i) => {
|
||||
if (i > 2) return;
|
||||
assert.equal(HoldemState.chipsHeldBy(state, player), Betting.stackOf(state, player));
|
||||
});
|
||||
});
|
||||
|
||||
test("and is nothing at all for an empty chair", () => {
|
||||
const state = openedOnTheDefault();
|
||||
// Betting's own answer for an empty chair is the table default, because it is
|
||||
// only ever asked about seats that are in a hand. The money side has to ask
|
||||
// about all ten chairs, so it checks whether anybody is in them first.
|
||||
assert.equal(Betting.stackOf(state, state.seats[5]), 200);
|
||||
assert.equal(HoldemState.chipsHeldBy(state, state.seats[5]), 0);
|
||||
});
|
||||
|
||||
test("counts a stack the dealer typed over the table's default", () => {
|
||||
const state = openedOnTheDefault();
|
||||
state.seats[0].stack = 75;
|
||||
assert.equal(HoldemState.chipsHeldBy(state, state.seats[0]), 75);
|
||||
});
|
||||
});
|
||||
|
||||
describe("is anybody in this chair", () => {
|
||||
const empty = () => HoldemState.createDefault().seats[0];
|
||||
|
||||
test("an untouched chair is empty", () => {
|
||||
assert.equal(HoldemState.seatInPlay(empty()), false);
|
||||
assert.equal(HoldemState.seatInPlay(null), false);
|
||||
});
|
||||
|
||||
test("a name, chips, cash, a bet or a dealt hand all count", () => {
|
||||
[
|
||||
["a name", p => { p.name = "Ann"; }],
|
||||
["chips", p => { p.stack = 50; }],
|
||||
["cash taken", p => { p.buyIns = 100; }],
|
||||
["a bet in front of them", p => { p.committed = 5; }],
|
||||
["money already swept in", p => { p.amountOverride = 20; }],
|
||||
["a hand dealt to them", p => { p.inHand = true; }],
|
||||
].forEach(([what, mark]) => {
|
||||
const seat = empty();
|
||||
mark(seat);
|
||||
assert.equal(HoldemState.seatInPlay(seat), true, what);
|
||||
});
|
||||
});
|
||||
|
||||
test("whitespace is not a name", () => {
|
||||
const seat = empty();
|
||||
seat.name = " ";
|
||||
assert.equal(HoldemState.seatInPlay(seat), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the parts that used to disagree", () => {
|
||||
test("the open seats and the seat count see the same empty chairs", () => {
|
||||
const state = openedOnTheDefault();
|
||||
// Three players, six chairs.
|
||||
assert.deepEqual(HoldemState.openSeats(state), [3, 4, 5]);
|
||||
const occupied = state.seats.filter(p => HoldemState.seatInPlay(p)).length;
|
||||
assert.equal(occupied + HoldemState.openSeats(state).length, state.seats.length);
|
||||
});
|
||||
|
||||
test("a chair holding only a dealt hand is not offered to the waiting list", () => {
|
||||
// The old test looked at name, buy-ins and stack. A seat marked in-hand with
|
||||
// none of those would have been handed to the next person in the queue, on top
|
||||
// of whoever was already playing it.
|
||||
const state = openedOnTheDefault();
|
||||
state.seats[4].inHand = true;
|
||||
assert.deepEqual(HoldemState.openSeats(state), [3, 5]);
|
||||
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 4);
|
||||
assert.equal(result.ok, false);
|
||||
});
|
||||
|
||||
test("the bank, the standings and the settle-up all price the night the same way", () => {
|
||||
const state = openedOnTheDefault();
|
||||
state.seats[0].stack = 320; // Ann is up
|
||||
state.seats[1].stack = 80; // Ben is down
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
const standings = HoldemState.sessionStandings(state);
|
||||
const chipsFromStandings = standings
|
||||
.filter(row => !row.cashedOut)
|
||||
.reduce((sum, row) => sum + row.chipsInFront, 0);
|
||||
|
||||
assert.equal(bank.chipsInPlay, chipsFromStandings, "same chips on the table");
|
||||
assert.equal(bank.balanced, true);
|
||||
|
||||
// And the payments the dealer is told to make add up to the same money.
|
||||
const plan = SettleUp.plan(standings);
|
||||
const owed = standings.reduce((sum, row) => sum + Math.max(0, row.cashPosition), 0);
|
||||
const paid = plan.payments.reduce((sum, row) => sum + row.amount, 0);
|
||||
assert.equal(paid + (plan.shortBy || 0) - (plan.overBy || 0), owed);
|
||||
});
|
||||
|
||||
test("a player still holding the table's starting stack is in all three", () => {
|
||||
const state = openedOnTheDefault();
|
||||
const standings = HoldemState.sessionStandings(state);
|
||||
assert.equal(standings.length, 3);
|
||||
assert.deepEqual(standings.map(row => row.chipsInFront), [200, 200, 200]);
|
||||
assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600);
|
||||
assert.deepEqual(SettleUp.plan(standings).payments, [], "everyone is even, so nobody pays anybody");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
// 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), {});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
// The number the pot wizard prints beside a name under the words "up to".
|
||||
//
|
||||
// It was the player's own remaining contribution. That is not what winning pays them,
|
||||
// and on the screen that moves the money the gap was large and in the wrong direction:
|
||||
// three players in for $250 each showed "up to $250" on all three rows while the prompt
|
||||
// directly above said "$765 is in play" — and $765 is what the winner was actually
|
||||
// handed. A dealer who trusted the row would have short-paid by two thirds. With a real
|
||||
// side pot it was worse in a more confusing way: the breakdown box at the top of the
|
||||
// same dialog announced "Main pot $765 · all 3", and Bob's row three lines under it
|
||||
// said "up to $250".
|
||||
//
|
||||
// The ceiling is now what resolveRound will actually pay, and the test that matters is
|
||||
// that it stays that: every case below checks the label against the function that moves
|
||||
// the chips rather than against a number written out by hand.
|
||||
//
|
||||
// It is also printed only where it binds. A ceiling equal to everything on the table is
|
||||
// the prompt's own figure repeated once per row — redundant data-ink, in Tufte's sense
|
||||
// — and the rows it does appear on are exactly the hands where picking that winner
|
||||
// leaves a side pot still to award, which is the one time a dealer needs telling.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { PotCalculator, Money } = require("./load");
|
||||
|
||||
const p = (id, contribution, folded = false) => ({ id, contribution, folded });
|
||||
const entriesOf = (...players) => PotCalculator.buildEntries(players);
|
||||
|
||||
// What the wizard would hand this player if their row were the one ticked.
|
||||
const paysOut = (entries, id) => PotCalculator.resolveRound(entries, [id]).payoutPool;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what a row promises is what the round pays", () => {
|
||||
test("a pot everybody covered pays the whole thing, dead blinds included", () => {
|
||||
// Three in for $250, two blinds folded before the raise.
|
||||
const entries = entriesOf(p("chris", 250), p("bob", 250), p("dee", 250),
|
||||
p("sb", 5, true), p("bb", 10, true));
|
||||
for (const id of ["chris", "bob", "dee"]) {
|
||||
assert.equal(PotCalculator.ceilingFor(entries, id), 765);
|
||||
assert.equal(PotCalculator.ceilingFor(entries, id), paysOut(entries, id));
|
||||
}
|
||||
});
|
||||
|
||||
test("the short all-in's ceiling is the main pot, not their own stake", () => {
|
||||
// Bob is all in for 250; Christopher and Dee go on to 600.
|
||||
const entries = entriesOf(p("chris", 600), p("bob", 250), p("dee", 600),
|
||||
p("sb", 5, true), p("bb", 10, true));
|
||||
assert.equal(PotCalculator.ceilingFor(entries, "bob"), 765);
|
||||
assert.equal(PotCalculator.ceilingFor(entries, "chris"), 1465);
|
||||
assert.equal(PotCalculator.ceilingFor(entries, "dee"), 1465);
|
||||
});
|
||||
|
||||
// The property, over every shape the wizard can reach: a ceiling that disagreed
|
||||
// with the payout would be the same defect again under a different number.
|
||||
test("across every shape of pot, the ceiling is the payout", () => {
|
||||
const shapes = [
|
||||
[p("a", 10), p("b", 10)],
|
||||
[p("a", 10), p("b", 25), p("c", 25)],
|
||||
[p("a", 5), p("b", 50), p("c", 120), p("d", 120)],
|
||||
[p("a", 33.33), p("b", 66.67), p("c", 100)],
|
||||
[p("a", 20), p("b", 20), p("folder", 20, true)],
|
||||
[p("a", 1), p("b", 2), p("c", 3), p("d", 4), p("e", 5)],
|
||||
[p("short", 0.05), p("deep", 1000)],
|
||||
];
|
||||
shapes.forEach((shape, n) => {
|
||||
const entries = entriesOf(...shape);
|
||||
PotCalculator.getContenders(entries).forEach(c => {
|
||||
assert.equal(PotCalculator.ceilingFor(entries, c.id), paysOut(entries, c.id),
|
||||
`shape ${n}: ${c.id}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("a second round asks about what is left, not what was there", () => {
|
||||
const entries = entriesOf(p("chris", 600), p("bob", 250), p("dee", 600));
|
||||
const after = PotCalculator.resolveRound(entries, ["bob"]).updatedEntries;
|
||||
// Bob took $750 of the $1,450; $700 is left between the two deep stacks.
|
||||
assert.equal(PotCalculator.totalPot(after), 700);
|
||||
assert.equal(PotCalculator.ceilingFor(after, "chris"), 700);
|
||||
assert.equal(PotCalculator.ceilingFor(after, "dee"), 700);
|
||||
});
|
||||
|
||||
test("somebody already paid out has nothing left to be owed", () => {
|
||||
const entries = entriesOf(p("a", 100), p("b", 100));
|
||||
const after = PotCalculator.resolveRound(entries, ["a"]).updatedEntries;
|
||||
assert.equal(PotCalculator.ceilingFor(after, "a"), 0);
|
||||
});
|
||||
|
||||
test("a name nobody put chips in for is worth nothing rather than an error", () => {
|
||||
assert.equal(PotCalculator.ceilingFor(entriesOf(p("a", 10)), "ghost"), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("and it is only said where it binds", () => {
|
||||
test("a pot everybody covered says its figure once, in the prompt", () => {
|
||||
const entries = entriesOf(p("chris", 250), p("bob", 250), p("dee", 250),
|
||||
p("sb", 5, true), p("bb", 10, true));
|
||||
["chris", "bob", "dee"].forEach(id =>
|
||||
assert.equal(PotCalculator.bindingCeilingFor(entries, id), null));
|
||||
});
|
||||
|
||||
test("only the player who is in for less carries a number", () => {
|
||||
const entries = entriesOf(p("chris", 600), p("bob", 250), p("dee", 600),
|
||||
p("sb", 5, true), p("bb", 10, true));
|
||||
assert.equal(PotCalculator.bindingCeilingFor(entries, "bob"), 765);
|
||||
assert.equal(PotCalculator.bindingCeilingFor(entries, "chris"), null);
|
||||
assert.equal(PotCalculator.bindingCeilingFor(entries, "dee"), null);
|
||||
});
|
||||
|
||||
// Two short stacks at different heights: each is capped by the one above, so both
|
||||
// rows have something to say and they do not say the same thing.
|
||||
test("two all-ins at different heights each carry their own", () => {
|
||||
const entries = entriesOf(p("small", 50), p("middle", 120), p("big", 400));
|
||||
assert.equal(PotCalculator.bindingCeilingFor(entries, "small"), 150);
|
||||
assert.equal(PotCalculator.bindingCeilingFor(entries, "middle"), 290);
|
||||
assert.equal(PotCalculator.bindingCeilingFor(entries, "big"), null);
|
||||
});
|
||||
|
||||
test("the deepest player is never the one capped", () => {
|
||||
const entries = entriesOf(p("a", 10), p("b", 50), p("c", 900));
|
||||
assert.equal(PotCalculator.bindingCeilingFor(entries, "c"), null);
|
||||
assert.equal(PotCalculator.ceilingFor(entries, "c"), PotCalculator.totalPot(entries));
|
||||
});
|
||||
|
||||
// Dead money is owed to whoever wins the layer it fell in, so it lifts every
|
||||
// ceiling — including the short stack's, which is the whole reason Bob's row reads
|
||||
// $765 rather than $750.
|
||||
test("a folded player's chips lift the ceilings rather than sitting outside them", () => {
|
||||
const live = entriesOf(p("a", 100), p("b", 100));
|
||||
const withDead = entriesOf(p("a", 100), p("b", 100), p("folder", 40, true));
|
||||
assert.equal(PotCalculator.ceilingFor(live, "a"), 200);
|
||||
assert.equal(PotCalculator.ceilingFor(withDead, "a"), 240);
|
||||
});
|
||||
|
||||
test("money in cents does not drift", () => {
|
||||
const entries = entriesOf(p("a", 0.05), p("b", 0.05), p("c", 0.05));
|
||||
assert.equal(PotCalculator.ceilingFor(entries, "a"), Money.round(0.15));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the wizard asks the calculator rather than reaching for the stake", () => {
|
||||
const SRC = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "potWizard.js"), "utf8");
|
||||
|
||||
test("the row's number comes from bindingCeilingFor", () => {
|
||||
assert.match(SRC, /PotCalculator\.bindingCeilingFor\(this\.entries, c\.id\)/);
|
||||
});
|
||||
|
||||
test("and nothing prints a contender's own stake under those words any more", () => {
|
||||
assert.ok(!/up to \$\{Money\.format\(c\.remaining\)\}/.test(SRC),
|
||||
"the wizard still prints the player's own contribution as their ceiling");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// Just enough PNG to read pixels back out of a screenshot.
|
||||
//
|
||||
// The contrast audit needs to know what colour is actually behind a piece of text, and
|
||||
// the only honest answer is the one the browser painted. Walking the DOM and
|
||||
// compositing `background-color` up the ancestors gets it wrong wherever a gradient,
|
||||
// a shadow or a border is doing the painting: tried that first, and it reported the
|
||||
// gold dealer button as black-on-black at Lc 0.4 — a fault that does not exist, on an
|
||||
// element anyone can see is legible. Sampling the screenshot cannot make that mistake.
|
||||
//
|
||||
// zlib is in Node, so this needs no dependency. Screenshots are 8-bit and
|
||||
// non-interlaced; anything else throws rather than returning quietly wrong pixels.
|
||||
const zlib = require("node:zlib");
|
||||
|
||||
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
function paeth(a, b, c) {
|
||||
const p = a + b - c;
|
||||
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
||||
if (pa <= pb && pa <= pc) return a;
|
||||
return pb <= pc ? b : c;
|
||||
}
|
||||
|
||||
function decode(buffer) {
|
||||
if (!buffer.subarray(0, 8).equals(SIGNATURE)) throw new Error("not a PNG");
|
||||
|
||||
let width = 0, height = 0, depth = 0, colorType = 0, interlace = 0;
|
||||
const idat = [];
|
||||
|
||||
let at = 8;
|
||||
while (at < buffer.length) {
|
||||
const length = buffer.readUInt32BE(at);
|
||||
const type = buffer.toString("ascii", at + 4, at + 8);
|
||||
const data = buffer.subarray(at + 8, at + 8 + length);
|
||||
at += 12 + length;
|
||||
|
||||
if (type === "IHDR") {
|
||||
width = data.readUInt32BE(0);
|
||||
height = data.readUInt32BE(4);
|
||||
depth = data[8];
|
||||
colorType = data[9];
|
||||
interlace = data[12];
|
||||
} else if (type === "IDAT") {
|
||||
idat.push(data);
|
||||
} else if (type === "IEND") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (depth !== 8) throw new Error(`unsupported bit depth ${depth}`);
|
||||
if (interlace !== 0) throw new Error("interlaced PNG not supported");
|
||||
if (colorType !== 2 && colorType !== 6) throw new Error(`unsupported colour type ${colorType}`);
|
||||
|
||||
const channels = colorType === 6 ? 4 : 3;
|
||||
const raw = zlib.inflateSync(Buffer.concat(idat));
|
||||
const stride = width * channels;
|
||||
const out = Buffer.alloc(width * height * 4);
|
||||
|
||||
let prior = Buffer.alloc(stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const filter = raw[y * (stride + 1)];
|
||||
const line = Buffer.from(raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)));
|
||||
|
||||
for (let i = 0; i < stride; i++) {
|
||||
const left = i >= channels ? line[i - channels] : 0;
|
||||
const up = prior[i];
|
||||
const upLeft = i >= channels ? prior[i - channels] : 0;
|
||||
switch (filter) {
|
||||
case 0: break;
|
||||
case 1: line[i] = (line[i] + left) & 0xff; break;
|
||||
case 2: line[i] = (line[i] + up) & 0xff; break;
|
||||
case 3: line[i] = (line[i] + ((left + up) >> 1)) & 0xff; break;
|
||||
case 4: line[i] = (line[i] + paeth(left, up, upLeft)) & 0xff; break;
|
||||
default: throw new Error(`unknown filter ${filter} on row ${y}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
const from = x * channels;
|
||||
const to = (y * width + x) * 4;
|
||||
out[to] = line[from];
|
||||
out[to + 1] = line[from + 1];
|
||||
out[to + 2] = line[from + 2];
|
||||
out[to + 3] = channels === 4 ? line[from + 3] : 255;
|
||||
}
|
||||
prior = line;
|
||||
}
|
||||
|
||||
return { width, height, data: out };
|
||||
}
|
||||
|
||||
// Nearest pixel, clamped. A rect can sit a hair outside the shot after rounding, and
|
||||
// clamping is the right answer there rather than an exception or a black pixel.
|
||||
function pixelAt(image, x, y) {
|
||||
const px = Math.min(image.width - 1, Math.max(0, Math.round(x)));
|
||||
const py = Math.min(image.height - 1, Math.max(0, Math.round(y)));
|
||||
const at = (py * image.width + px) * 4;
|
||||
return [image.data[at], image.data[at + 1], image.data[at + 2]];
|
||||
}
|
||||
|
||||
module.exports = { decode, pixelAt };
|
||||
@@ -0,0 +1,178 @@
|
||||
// The screenshot reader, tested by building PNGs whose pixels are already known.
|
||||
//
|
||||
// This matters more than it looks. If a filter is decoded wrong the pixels are still
|
||||
// plausible colours — slightly off greys and greens, not obvious garbage — and the
|
||||
// contrast audit would report confident, wrong numbers about a page nobody would think
|
||||
// to re-check. So every filter type gets encoded here and read back.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const zlib = require("node:zlib");
|
||||
const { decode, pixelAt } = require("./png");
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Int32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const head = Buffer.alloc(8);
|
||||
head.writeUInt32BE(data.length, 0);
|
||||
head.write(type, 4, "ascii");
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(Buffer.concat([Buffer.from(type, "ascii"), data])), 0);
|
||||
return Buffer.concat([head, data, crc]);
|
||||
}
|
||||
|
||||
function paeth(a, b, c) {
|
||||
const p = a + b - c;
|
||||
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
||||
if (pa <= pb && pa <= pc) return a;
|
||||
return pb <= pc ? b : c;
|
||||
}
|
||||
|
||||
// pixels: [y][x] = [r,g,b(,a)]. filter is applied to every row, which is legal PNG and
|
||||
// is exactly how each decoder branch gets exercised.
|
||||
function encode(pixels, filter = 0, channels = 4) {
|
||||
const height = pixels.length, width = pixels[0].length;
|
||||
const stride = width * channels;
|
||||
const rows = [];
|
||||
let prior = Buffer.alloc(stride);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const raw = Buffer.alloc(stride);
|
||||
for (let x = 0; x < width; x++) {
|
||||
for (let c = 0; c < channels; c++) {
|
||||
raw[x * channels + c] = pixels[y][x][c] !== undefined ? pixels[y][x][c] : 255;
|
||||
}
|
||||
}
|
||||
const filtered = Buffer.alloc(stride);
|
||||
for (let i = 0; i < stride; i++) {
|
||||
const left = i >= channels ? raw[i - channels] : 0;
|
||||
const up = prior[i];
|
||||
const upLeft = i >= channels ? prior[i - channels] : 0;
|
||||
switch (filter) {
|
||||
case 0: filtered[i] = raw[i]; break;
|
||||
case 1: filtered[i] = (raw[i] - left) & 0xff; break;
|
||||
case 2: filtered[i] = (raw[i] - up) & 0xff; break;
|
||||
case 3: filtered[i] = (raw[i] - ((left + up) >> 1)) & 0xff; break;
|
||||
case 4: filtered[i] = (raw[i] - paeth(left, up, upLeft)) & 0xff; break;
|
||||
default: throw new Error("bad filter");
|
||||
}
|
||||
}
|
||||
rows.push(Buffer.concat([Buffer.from([filter]), filtered]));
|
||||
prior = raw;
|
||||
}
|
||||
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = channels === 4 ? 6 : 2;
|
||||
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", zlib.deflateSync(Buffer.concat(rows))),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// Deliberately awkward: neighbouring values that differ, so a filter decoded with the
|
||||
// wrong neighbour produces a different answer rather than the same one by luck.
|
||||
const PIXELS = [
|
||||
[[20, 30, 40], [200, 10, 90], [18, 18, 18], [255, 255, 255]],
|
||||
[[0, 0, 0], [28, 108, 60], [108, 68, 39], [1, 2, 3]],
|
||||
[[240, 217, 168], [47, 122, 77], [200, 200, 200], [17, 17, 17]],
|
||||
];
|
||||
|
||||
describe("every filter type reads back exactly", () => {
|
||||
[0, 1, 2, 3, 4].forEach(filter => {
|
||||
test(`filter ${filter}`, () => {
|
||||
const image = decode(encode(PIXELS, filter));
|
||||
assert.equal(image.width, 4);
|
||||
assert.equal(image.height, 3);
|
||||
for (let y = 0; y < PIXELS.length; y++) {
|
||||
for (let x = 0; x < PIXELS[y].length; x++) {
|
||||
assert.deepEqual(pixelAt(image, x, y), PIXELS[y][x],
|
||||
`pixel ${x},${y} under filter ${filter}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("and a three-channel image reads back too", () => {
|
||||
const image = decode(encode(PIXELS, 4, 3));
|
||||
assert.deepEqual(pixelAt(image, 1, 2), [47, 122, 77]);
|
||||
});
|
||||
|
||||
test("including where Paeth has to choose between close neighbours", () => {
|
||||
// Written first to catch paeth(left, up, ...) decoded as paeth(up, left, ...),
|
||||
// on the assumption that the two differ on a tie. They don't, anywhere: the
|
||||
// predictor is symmetric in its first two arguments, checked over all 16,777,216
|
||||
// byte triples. The reason is that a tie between pa and pb requires
|
||||
// left + up == 2 * upLeft, and that is exactly the condition making pc zero — so
|
||||
// every tie is resolved by returning upLeft, whichever order the other two
|
||||
// arrived in. Swapping them is a no-op and no test can catch it, which is worth
|
||||
// knowing rather than papering over with a fixture that appears to.
|
||||
//
|
||||
// What this does catch is the mistake that is actually available: reading the
|
||||
// wrong neighbour. The values sit close together so a predictor that reaches one
|
||||
// pixel off produces a different answer rather than the same one by luck.
|
||||
const CLOSE = [
|
||||
[[100, 10, 20], [110, 40, 25]],
|
||||
[[90, 70, 30], [55, 95, 35]],
|
||||
];
|
||||
const image = decode(encode(CLOSE, 4));
|
||||
assert.deepEqual(pixelAt(image, 1, 1), [55, 95, 35]);
|
||||
assert.deepEqual(pixelAt(image, 0, 1), [90, 70, 30]);
|
||||
assert.deepEqual(pixelAt(image, 1, 0), [110, 40, 25]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sampling", () => {
|
||||
const image = decode(encode(PIXELS, 0));
|
||||
|
||||
test("rounds to the nearest pixel", () => {
|
||||
assert.deepEqual(pixelAt(image, 0.4, 0.4), [20, 30, 40]);
|
||||
assert.deepEqual(pixelAt(image, 0.6, 0.4), [200, 10, 90]);
|
||||
});
|
||||
|
||||
test("clamps rather than throwing or reading black", () => {
|
||||
// A rect can sit a hair outside the shot once it's been rounded, and a silent
|
||||
// black pixel there would read as a contrast failure that isn't one.
|
||||
assert.deepEqual(pixelAt(image, -5, -5), [20, 30, 40]);
|
||||
assert.deepEqual(pixelAt(image, 999, 999), [17, 17, 17]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it refuses", () => {
|
||||
test("something that isn't a PNG at all", () => {
|
||||
assert.throws(() => decode(Buffer.from("not a png at all, honestly")), /not a PNG/);
|
||||
});
|
||||
|
||||
test("an interlaced one, rather than returning scrambled pixels", () => {
|
||||
const png = encode(PIXELS, 0);
|
||||
// Byte 12 of the IHDR data: 8 signature + 8 chunk header + 12 = interlace flag.
|
||||
const broken = Buffer.from(png);
|
||||
broken[8 + 8 + 12] = 1;
|
||||
assert.throws(() => decode(broken), /interlaced/);
|
||||
});
|
||||
|
||||
test("a colour type it cannot read", () => {
|
||||
const broken = Buffer.from(encode(PIXELS, 0));
|
||||
broken[8 + 8 + 9] = 3; // palette
|
||||
assert.throws(() => decode(broken), /colour type/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
// Who can win each pot, said in the fewest names that answer it.
|
||||
//
|
||||
// The breakdown used to list every eligible player for every pot. Pot eligibility is
|
||||
// nested — potCalculator.test.js checks that across 20,000 tables — so the main pot's
|
||||
// line was every name at the table and each side pot repeated it minus one. The lines
|
||||
// that were too long to fit were exactly the ones nobody needed spelled out, and the
|
||||
// short ones at the bottom were the side pots a dealer actually announces by name.
|
||||
//
|
||||
// Measured on an iPhone 14 before this: one all-in five-handed already clipped the main
|
||||
// pot, and twelve-handed the first row wanted 521px of a 254px slot, with no count and
|
||||
// no title — a dealer reading "Alexandra…" could not tell whether three people or
|
||||
// eleven were eligible, which is the number the showdown turns on.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render } = require("./load");
|
||||
|
||||
// Names are "P<id>" so a test reads as ids and the joining is still exercised.
|
||||
const nameFor = id => `P${id}`;
|
||||
const say = (eligible, everyone) => Render.describeEligible(eligible, everyone, nameFor);
|
||||
|
||||
describe("the eligible line", () => {
|
||||
test("says so plainly when the whole table can win it", () => {
|
||||
// The main pot, every hand. Twelve names to say "nobody is excluded yet" was
|
||||
// the single longest line in the component and the least informative.
|
||||
assert.equal(say(["a", "b", "c"], ["a", "b", "c"]), "all 3");
|
||||
assert.equal(say(["a"], ["a"]), "all 1");
|
||||
});
|
||||
|
||||
test("names who is out when that is the shorter list", () => {
|
||||
const table = ["a", "b", "c", "d", "e", "f", "g", "h"];
|
||||
assert.equal(say(["b", "c", "d", "e", "f", "g", "h"], table), "7 in, not Pa");
|
||||
assert.equal(say(["c", "d", "e", "f", "g", "h"], table), "6 in, not Pa and Pb");
|
||||
assert.equal(say(["d", "e", "f", "g", "h"], table), "5 in, not Pa, Pb and Pc");
|
||||
});
|
||||
|
||||
test("names who is in when that is the shorter list", () => {
|
||||
// The far side pots, which are the ones a dealer announces by name: "side pot
|
||||
// is 80 between Ben and Carlos".
|
||||
const table = ["a", "b", "c", "d", "e", "f", "g", "h"];
|
||||
assert.equal(say(["g", "h"], table), "2 in, Pg and Ph");
|
||||
assert.equal(say(["h"], table), "1 in, Ph");
|
||||
assert.equal(say(["f", "g", "h"], table), "3 in, Pf, Pg and Ph");
|
||||
});
|
||||
|
||||
test("and when the two are the same length, says who is in", () => {
|
||||
// A tie is not a reason to make the dealer do the subtraction: "who can win
|
||||
// this" is the question, so answer it directly when it costs nothing.
|
||||
const table = ["a", "b", "c", "d"];
|
||||
assert.equal(say(["c", "d"], table), "2 in, Pc and Pd");
|
||||
});
|
||||
|
||||
test("leads with the count, because it is the tail that gets cut off", () => {
|
||||
// .potLayerWho is right-aligned with an ellipsis, so a line too long for a phone
|
||||
// loses its end. Every form has to begin with the number of hands to ask for.
|
||||
const table = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"];
|
||||
[table, ["c", "d", "e", "f", "g", "h", "i", "j", "k", "l"], ["k", "l"]]
|
||||
.forEach(eligible => assert.match(say(eligible, table), /^(all \d+|\d+ in,)/,
|
||||
`"${say(eligible, table)}" buries the count where a narrow screen can eat it`));
|
||||
});
|
||||
|
||||
test("never spells out more than half the table", () => {
|
||||
// The point of picking the shorter side. Nested eligibility makes the two lists
|
||||
// complements, so this is a guarantee rather than a hope.
|
||||
const table = Array.from({ length: 12 }, (_, i) => `s${i}`);
|
||||
for (let inCount = 1; inCount <= 12; inCount++) {
|
||||
const line = say(table.slice(0, inCount), table);
|
||||
const named = (line.match(/P/g) || []).length;
|
||||
assert.ok(named <= 6, `${inCount} of 12 named ${named} people: "${line}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// A stub rather than a DOM: the file has no jsdom and does not want one. Enough of an
|
||||
// element to answer what the row is made of.
|
||||
const fakeDom = () => {
|
||||
const make = () => ({
|
||||
className: "", textContent: "", title: "", children: [],
|
||||
classList: { add() {} },
|
||||
appendChild(child) { this.children.push(child); return child; },
|
||||
});
|
||||
global.document = { createElement: make };
|
||||
return () => { delete global.document; };
|
||||
};
|
||||
|
||||
describe("the row it builds", () => {
|
||||
const pots = [
|
||||
{ label: "Main pot", amount: 300, eligible: ["a", "b", "c", "d"] },
|
||||
{ label: "Side pot 1", amount: 150, eligible: ["c", "d"] },
|
||||
];
|
||||
const textOf = row => row.children.map(c => c.textContent);
|
||||
|
||||
test("carries the full list in a title, for anyone with a pointer", () => {
|
||||
const undo = fakeDom();
|
||||
try {
|
||||
const wrap = Render.buildPotBreakdown(pots, [], nameFor);
|
||||
const who = wrap.children.map(row => row.children[2]);
|
||||
assert.equal(who[0].title, "Pa, Pb, Pc, Pd");
|
||||
assert.equal(who[1].title, "Pc, Pd");
|
||||
// The visible text is the short form; the title is the long one. A tooltip
|
||||
// is not a mechanism a keyboard reaches, which is why it is the extra and
|
||||
// not the answer.
|
||||
assert.equal(who[0].textContent, "all 4");
|
||||
assert.equal(who[1].textContent, "2 in, Pc and Pd");
|
||||
} finally { undo(); }
|
||||
});
|
||||
|
||||
test("puts the label, the money and the people in that order", () => {
|
||||
const undo = fakeDom();
|
||||
try {
|
||||
const wrap = Render.buildPotBreakdown(pots, [], nameFor);
|
||||
assert.deepEqual(textOf(wrap.children[0]), ["Main pot", "$300", "all 4"]);
|
||||
} finally { undo(); }
|
||||
});
|
||||
|
||||
test("and an uncalled bet still reads as money going back", () => {
|
||||
// Not an eligibility question — nobody is competing for it — so it keeps its
|
||||
// own wording and gets no title to expand.
|
||||
const undo = fakeDom();
|
||||
try {
|
||||
const wrap = Render.buildPotBreakdown(pots, [{ id: "a", amount: 25 }], nameFor);
|
||||
const row = wrap.children[2];
|
||||
assert.deepEqual(textOf(row), ["Uncalled", "$25", "back to Pa"]);
|
||||
assert.equal(row.children[2].title, "");
|
||||
} finally { undo(); }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
// The bar's pot list is not allowed to grow with the table.
|
||||
//
|
||||
// #actionBar is fixed over the felt, so every pixel it takes is a pixel the table
|
||||
// loses — and this list took one row per all-in with no ceiling. Measured on a 360x640
|
||||
// phone, five players idle: bar 151px. Six all-in: 303px. Twelve all-in: 411px, which
|
||||
// is 64% of the screen, leaving 96px of felt to draw twelve seats in. A fixed overlay
|
||||
// whose height depends on the state is a fault whatever it is showing, so the cap is
|
||||
// checked here rather than left to whoever next adds an all-in to the audit sweep.
|
||||
//
|
||||
// The two ends survive because they are the two that are live. Side pots are paid from
|
||||
// the newest one back down to the main pot, so the top layer is the next thing that
|
||||
// happens and the main pot is the last, and those are the two a dealer says out loud
|
||||
// while the chips are stacked — "main pot 7,200, side pot 4,500". The layers between
|
||||
// are the middle of that count-down, wanted at showdown, which is the pot wizard, which
|
||||
// passes no cap and still lists all of them.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render, Money, PotCalculator } = require("./load");
|
||||
|
||||
const nameFor = id => `P${id}`;
|
||||
|
||||
// The same stub potBreakdown.test.js uses — enough of an element to say what the row
|
||||
// is made of, and no jsdom in a suite that runs in a second.
|
||||
const fakeDom = () => {
|
||||
const make = () => ({
|
||||
className: "", textContent: "", title: "", children: [],
|
||||
classList: { add() {} },
|
||||
appendChild(child) { this.children.push(child); return child; },
|
||||
});
|
||||
global.document = { createElement: make };
|
||||
return () => { delete global.document; };
|
||||
};
|
||||
|
||||
// A table where everyone is all-in for a different amount, which is the shape that
|
||||
// makes the most pot layers — the worst case and the one that was measured. `count`
|
||||
// players ladder into `count - 1` layers: two of them share a single undivided pot and
|
||||
// the breakdown declines to draw at all, which is why nothing below three appears here.
|
||||
const laddered = count => PotCalculator.layers(
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `s${i}`, contribution: 100 + i * 130, folded: false,
|
||||
})));
|
||||
|
||||
const build = (pots, options) => {
|
||||
const undo = fakeDom();
|
||||
try {
|
||||
const wrap = Render.buildPotBreakdown(pots, [], nameFor, null, options);
|
||||
return wrap.children.map(row => ({
|
||||
className: row.className,
|
||||
label: row.children[0].textContent,
|
||||
amount: row.children[1].textContent,
|
||||
who: row.children[2].textContent,
|
||||
title: row.children[2].title,
|
||||
}));
|
||||
} finally { undo(); }
|
||||
};
|
||||
|
||||
describe("the bar's copy of the pot list", () => {
|
||||
test("stops growing once there are more pots than it has room for", () => {
|
||||
// The property that matters: rows are a function of the cap, not of the table.
|
||||
// Twelve-handed and six-handed have to render the same number of lines.
|
||||
for (let players = 3; players <= 12; players++) {
|
||||
const rows = build(laddered(players), { maxLayers: 4 });
|
||||
assert.ok(rows.length <= 4,
|
||||
`${players} all-in drew ${rows.length} rows; the bar is sized for 4`);
|
||||
}
|
||||
});
|
||||
|
||||
test("and the number of rows never depends on how many people are all-in", () => {
|
||||
const wide = [8, 9, 10, 11, 12].map(n => build(laddered(n), { maxLayers: 4 }).length);
|
||||
assert.deepEqual(wide, [3, 3, 3, 3, 3],
|
||||
"past the cap every table should draw main, the elision and the top pot");
|
||||
});
|
||||
|
||||
test("keeps the two pots a dealer actually announces", () => {
|
||||
const pots = laddered(12);
|
||||
const rows = build(pots, { maxLayers: 4 });
|
||||
assert.equal(rows[0].label, pots[0].label, "the main pot is the last one paid");
|
||||
assert.equal(rows[rows.length - 1].label, pots[pots.length - 1].label,
|
||||
"the newest side pot is the first one paid");
|
||||
});
|
||||
|
||||
test("and leaves them in the order the pots were made", () => {
|
||||
const rows = build(laddered(12), { maxLayers: 4 });
|
||||
assert.match(rows[1].className, /potLayerMore/,
|
||||
"the elision belongs where the gap is, not appended after the list");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the money in the rows that were elided", () => {
|
||||
test("is still on the screen, added up", () => {
|
||||
// The one thing a summary row must not do is lose chips. Whatever is drawn has
|
||||
// to account for every pot, or a dealer counting the announcement against the
|
||||
// chips in the middle finds a shortfall the tool caused.
|
||||
for (let players = 3; players <= 12; players++) {
|
||||
const pots = laddered(players);
|
||||
const total = pots.reduce((sum, pot) => sum + pot.amount, 0);
|
||||
const drawn = build(pots, { maxLayers: 4 })
|
||||
.reduce((sum, row) => sum + Number(row.amount.replace(/[^0-9.]/g, "")), 0);
|
||||
assert.equal(drawn, total,
|
||||
`${players} all-in: rows add to ${drawn}, the pots add to ${total}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("and says how many it stands for", () => {
|
||||
const rows = build(laddered(12), { maxLayers: 4 });
|
||||
const pots = laddered(12);
|
||||
assert.equal(rows[1].label, `${pots.length - 2} more side pots`);
|
||||
});
|
||||
|
||||
test("with the list itself in a title, for anyone with a pointer", () => {
|
||||
const pots = laddered(12);
|
||||
const rows = build(pots, { maxLayers: 4 });
|
||||
pots.slice(1, -1).forEach(pot => {
|
||||
assert.ok(rows[1].title.includes(pot.label),
|
||||
`${pot.label} vanished without trace`);
|
||||
assert.ok(rows[1].title.includes(Money.format(pot.amount)),
|
||||
`${pot.label}'s amount vanished without trace`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and points at where the whole list is", () => {
|
||||
// The title is a pointer affordance, so it cannot be the answer on a phone. The
|
||||
// visible text has to say where the rest lives.
|
||||
assert.match(build(laddered(12), { maxLayers: 4 })[1].who, /resolve/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when eliding would not buy anything", () => {
|
||||
test("a list that fits is left alone", () => {
|
||||
for (let players = 3; players <= 5; players++) {
|
||||
const pots = laddered(players);
|
||||
assert.ok(pots.length <= 4, "this loop is meant to cover the fitting sizes");
|
||||
const rows = build(pots, { maxLayers: 4 });
|
||||
assert.equal(rows.length, pots.length);
|
||||
assert.deepEqual(rows.map(r => r.label), pots.map(p => p.label));
|
||||
}
|
||||
});
|
||||
|
||||
test("and a single middle layer is not replaced by a line saying there is one", () => {
|
||||
// At exactly the cap the middle is one row, and swapping one row for one row is
|
||||
// pure indirection: the dealer loses an amount and gains a sentence.
|
||||
const pots = laddered(5);
|
||||
assert.equal(pots.length, 4, "five all-in ladder into exactly the cap");
|
||||
const rows = build(pots, { maxLayers: 4 });
|
||||
assert.equal(rows.length, 4);
|
||||
assert.ok(!rows.some(r => /potLayerMore/.test(r.className)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the wizard, which has room", () => {
|
||||
test("still gets every layer", () => {
|
||||
// Same builder, no cap — this is the announcement read out before anybody shows
|
||||
// a hand, and it is in a dialog that scrolls. Capping it there would hide the
|
||||
// pots at the moment they are being paid.
|
||||
const pots = laddered(12);
|
||||
const rows = build(pots, undefined);
|
||||
assert.equal(rows.length, pots.length);
|
||||
assert.ok(!rows.some(r => /potLayerMore/.test(r.className)));
|
||||
});
|
||||
});
|
||||
|
||||
// ---- And smaller still once nobody can act -----------------------------------
|
||||
//
|
||||
// The list is decision support. A player working out whether to call is owed the side
|
||||
// pot they would be calling into, and that is worth three rows of a fixed bar.
|
||||
//
|
||||
// Once everyone is all-in there is no decision left to support: the only thing that
|
||||
// happens for the rest of the hand is the dealer turning cards. The list went on
|
||||
// standing there anyway, and it is not free — measured at 360x640 with twelve players
|
||||
// all-in, the bar was 267px of a 640px window. That is 42%, against the sweep's own
|
||||
// "persistent chrome under 30%", and the felt pays it on the exact table shape that has
|
||||
// the most seats to fit into what is left.
|
||||
//
|
||||
// So during a run-out it collapses to the main pot and one line for everything else.
|
||||
// The main pot stays because it is the number a dealer says out loud; the layering is
|
||||
// what the pot wizard is for, and the summary row has always said so in as many words.
|
||||
describe("the list during a run-out, when nobody has a decision left", () => {
|
||||
test("is two rows however many people are all-in", () => {
|
||||
const rows = [3, 6, 9, 12].map(n => build(laddered(n), { maxLayers: 4, summarise: true }).length);
|
||||
assert.deepEqual(rows, [2, 2, 2, 2]);
|
||||
});
|
||||
|
||||
test("and that is fewer than the same table gets while the betting is live", () => {
|
||||
// The point of the change, stated as a comparison rather than a constant, so it
|
||||
// still means something if the ordinary cap is ever retuned.
|
||||
const live = build(laddered(12), { maxLayers: 4 }).length;
|
||||
const runout = build(laddered(12), { maxLayers: 4, summarise: true }).length;
|
||||
assert.ok(runout < live, `a run-out should be shorter than live betting (${runout} vs ${live})`);
|
||||
});
|
||||
|
||||
test("still names the main pot, which is the one said out loud", () => {
|
||||
const pots = laddered(12);
|
||||
const rows = build(pots, { maxLayers: 4, summarise: true });
|
||||
assert.equal(rows[0].label, pots[0].label);
|
||||
assert.equal(rows[0].amount, Money.format(pots[0].amount));
|
||||
});
|
||||
|
||||
test("and still accounts for every chip", () => {
|
||||
// The same rule as the elision above, and the one a summary row must not break:
|
||||
// a dealer counting the announcement against the chips in the middle must not
|
||||
// find a shortfall the tool invented.
|
||||
for (let players = 3; players <= 12; players++) {
|
||||
const pots = laddered(players);
|
||||
const total = pots.reduce((sum, pot) => sum + pot.amount, 0);
|
||||
const drawn = build(pots, { maxLayers: 4, summarise: true })
|
||||
.reduce((sum, row) => sum + Number(row.amount.replace(/[^0-9.]/g, "")), 0);
|
||||
assert.equal(drawn, total,
|
||||
`${players} all-in: rows add to ${drawn}, the pots add to ${total}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("says how many side pots it stands for, and counts them properly at one", () => {
|
||||
const many = build(laddered(12), { maxLayers: 4, summarise: true });
|
||||
assert.equal(many[1].label, `${laddered(12).length - 1} side pots`);
|
||||
|
||||
// Three players ladder into two pots: a main and exactly one side.
|
||||
const one = build(laddered(3), { maxLayers: 4, summarise: true });
|
||||
assert.equal(one[1].label, "1 side pot", "a single side pot is not '1 side pots'");
|
||||
});
|
||||
|
||||
test("and points at where the full list actually is", () => {
|
||||
const rows = build(laddered(12), { maxLayers: 4, summarise: true });
|
||||
assert.match(rows[1].who, /resolve/,
|
||||
"the row that hides the layers has to say where they went");
|
||||
assert.match(rows[1].title, /Side pot/,
|
||||
"and a pointer still gets the full list without resolving anything");
|
||||
});
|
||||
});
|
||||
@@ -122,3 +122,63 @@ describe("PotCalculator.resolveRound", () => {
|
||||
assert.throws(() => PotCalculator.resolveRound(entries, ["ghost"]), /No valid winners/);
|
||||
});
|
||||
});
|
||||
|
||||
// The pots come out of layers() in one order and with one relationship between them,
|
||||
// and the action bar's wording now depends on both. If eligibility were not nested,
|
||||
// "8 in, not Mike and Bob" would be a lie — there would be someone eligible for a side
|
||||
// pot who was never eligible for the main one, and naming the complement would hide
|
||||
// them. It is a property of how the layers are built rather than an accident of the
|
||||
// examples above, so it is checked as one.
|
||||
describe("pot eligibility is nested", () => {
|
||||
function rng(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
test("every side pot's players are a strict subset of the pot below it", () => {
|
||||
const random = rng(20260905);
|
||||
let multiPotTables = 0;
|
||||
let pairs = 0;
|
||||
|
||||
for (let table = 0; table < 20000; table++) {
|
||||
const n = 2 + Math.floor(random() * 11);
|
||||
const players = Array.from({ length: n }, (_, i) => p(
|
||||
`p${i}`,
|
||||
// A sixth of the table never puts anything in, which is a table mid-deal.
|
||||
random() < 1 / 6 ? 0 : (1 + Math.floor(random() * 8)) * 25,
|
||||
random() < 0.2));
|
||||
|
||||
const layers = PotCalculator.layers(players);
|
||||
if (layers.length > 1) multiPotTables++;
|
||||
|
||||
for (let i = 1; i < layers.length; i++) {
|
||||
const below = layers[i - 1].eligible;
|
||||
const belowSet = new Set(below);
|
||||
const here = layers[i].eligible;
|
||||
pairs++;
|
||||
assert.ok(here.every(id => belowSet.has(id)),
|
||||
`table ${table}: ${layers[i].label} has a player ${layers[i - 1].label} does not\n` +
|
||||
JSON.stringify(players));
|
||||
assert.ok(here.length < below.length,
|
||||
`table ${table}: ${layers[i].label} is no smaller than ${layers[i - 1].label} — ` +
|
||||
`layers with the same eligibility are supposed to be merged into one pot\n` +
|
||||
JSON.stringify(players));
|
||||
}
|
||||
}
|
||||
|
||||
// Guards the generator rather than the code: a shape that stopped producing side
|
||||
// pots would make every assertion above vacuous and the test would still pass.
|
||||
assert.ok(multiPotTables > 10000, `only ${multiPotTables} tables had a side pot`);
|
||||
assert.ok(pairs > 20000, `only ${pairs} adjacent pairs to check`);
|
||||
});
|
||||
|
||||
test("so the main pot is everyone still live, and it is the widest", () => {
|
||||
const layers = PotCalculator.layers([p("a", 100), p("b", 40), p("c", 100), p("d", 25)]);
|
||||
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c", "d"]);
|
||||
layers.slice(1).forEach(layer =>
|
||||
assert.ok(layer.eligible.length < layers[0].eligible.length));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// What a pot-sized raise actually is.
|
||||
//
|
||||
// The raise pad worked it out as "the current bet plus the pot as it stands", which
|
||||
// leaves out the fact that the raiser calls first and their call is in the pot before
|
||||
// the raise is measured off it. Every quick amount came out short — pre-flop at 1/2 it
|
||||
// offered "Pot $5" where a pot raise is $7. Players say "pot" out loud meaning a
|
||||
// definite number, and a dealer tapping the button that claims to be it should record
|
||||
// what was actually said.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, Money, makeTable } = require("./load");
|
||||
|
||||
describe("pre-flop at 1/2", () => {
|
||||
const dealt = () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
test("a pot raise from the first player to act is $7", () => {
|
||||
// Call the $2, which makes the pot $5, then raise $5.
|
||||
const state = dealt();
|
||||
assert.equal(Betting.potSoFar(state), 3, "the blinds");
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7);
|
||||
});
|
||||
|
||||
test("half pot is $5 — $4.50 rounded up to something the chips can make", () => {
|
||||
// The old arithmetic gave $3.50, which clamped up to the minimum and put the
|
||||
// same $4 on two buttons side by side. The honest half-pot is $4.50, and the
|
||||
// smallest chip in the default legend is a dollar, so the button would have
|
||||
// offered an amount the dealer had to round themselves every single time.
|
||||
const state = dealt();
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 5);
|
||||
assert.notEqual(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5),
|
||||
Betting.minRaiseTo(state), "the two quick buttons say different things");
|
||||
});
|
||||
|
||||
test("the small blind owes less, so their pot raise is smaller", () => {
|
||||
// They have $1 in already: call $1, pot becomes $4, raise $4, total $6.
|
||||
const state = dealt();
|
||||
const { sbIndex } = require("./load").HoldemState.getBlindSeats(state);
|
||||
assert.equal(Betting.toCall(state, state.seats[sbIndex]), 1);
|
||||
assert.equal(Betting.potRaiseTo(state, sbIndex), 6);
|
||||
});
|
||||
|
||||
test("the big blind owes nothing, so it is just the pot", () => {
|
||||
const state = dealt();
|
||||
const { bbIndex } = require("./load").HoldemState.getBlindSeats(state);
|
||||
assert.equal(Betting.toCall(state, state.seats[bbIndex]), 0);
|
||||
assert.equal(Betting.potRaiseTo(state, bbIndex), 5, "the $2 bet plus the $3 pot");
|
||||
});
|
||||
});
|
||||
|
||||
describe("after the flop", () => {
|
||||
const flopWithBet = () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 500, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
|
||||
Betting.closeStreet(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
test("a pot raise facing a bet is three times the bet plus the dead money", () => {
|
||||
const state = flopWithBet();
|
||||
const potBefore = Betting.potSoFar(state); // $6 carried over
|
||||
const bettor = state.actingSeatIndex;
|
||||
Betting.raiseTo(state, bettor, 10);
|
||||
|
||||
const raiser = state.actingSeatIndex;
|
||||
// Call $10, pot becomes $26, raise $26 → $36. Which is 3×10 + 6.
|
||||
assert.equal(Betting.potRaiseTo(state, raiser), 36);
|
||||
assert.equal(Betting.potRaiseTo(state, raiser), 3 * 10 + potBefore);
|
||||
});
|
||||
|
||||
test("half pot facing that bet is $23", () => {
|
||||
const state = flopWithBet();
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 10);
|
||||
// Call $10, pot becomes $26, raise $13 → $23.
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 23);
|
||||
});
|
||||
|
||||
test("facing no bet at all it is a plain pot-sized bet", () => {
|
||||
const state = flopWithBet();
|
||||
assert.equal(state.currentBet, 0, "nobody has bet the flop");
|
||||
assert.equal(Betting.toCall(state, state.seats[state.actingSeatIndex]), 0);
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), Betting.potSoFar(state));
|
||||
});
|
||||
});
|
||||
|
||||
describe("it stays sane at the edges", () => {
|
||||
test("a seat that doesn't exist gets zero rather than throwing", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
assert.equal(Betting.potRaiseTo(state, 99), 0);
|
||||
});
|
||||
|
||||
test("the amount is always at least a call", () => {
|
||||
// A raise-to below the current bet would be nonsense to offer. Every fraction
|
||||
// from a tenth up lands at or above what the player owes.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
|
||||
Betting.beginHand(state);
|
||||
const index = state.actingSeatIndex;
|
||||
[0.1, 0.25, 0.5, 0.75, 1].forEach(fraction => {
|
||||
assert.ok(Betting.potRaiseTo(state, index, fraction) >= state.currentBet,
|
||||
`${fraction} of the pot should still cover the bet`);
|
||||
});
|
||||
});
|
||||
|
||||
test("cent-sized blinds don't drift", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 20, smallBlind: 0.05, bigBlind: 0.1 });
|
||||
Betting.beginHand(state);
|
||||
// Pot $0.15, owes $0.10: call to $0.25, raise $0.25 → $0.35.
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the amounts are ones the table can actually build", () => {
|
||||
const CENTS = [{ value: 0.25 }, { value: 0.05 }, { value: 0.01 }];
|
||||
const DOLLARS = [{ value: 1 }, { value: 5 }, { value: 25 }, { value: 100 }];
|
||||
|
||||
const table = (legend, smallBlind, bigBlind, stack = 200) => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack, smallBlind, bigBlind });
|
||||
state.chipLegend = legend;
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
test("a half-pot that lands on a half chip is rounded up, not offered as it is", () => {
|
||||
const state = table(DOLLARS, 1, 2);
|
||||
// Honestly $4.50. No dollar chip makes fifty cents.
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 5);
|
||||
assert.equal(Money.chipBreakdown(5, DOLLARS).remainder, 0);
|
||||
});
|
||||
|
||||
test("up rather than down — a shaved pot bet is not a pot bet", () => {
|
||||
const state = table(DOLLARS, 1, 2);
|
||||
assert.ok(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5) > 4.5);
|
||||
});
|
||||
|
||||
test("an amount the chips already make is left exactly alone", () => {
|
||||
const state = table(DOLLARS, 1, 2);
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7, "the pot raise was already whole");
|
||||
});
|
||||
|
||||
test("a cent table keeps its cents", () => {
|
||||
const state = table(CENTS, 0.05, 0.1);
|
||||
// Pot $0.15, owes $0.10: call to $0.25, raise $0.25 → $0.35, which cent chips make.
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
|
||||
assert.equal(Money.chipBreakdown(0.35, CENTS).remainder, 0);
|
||||
});
|
||||
|
||||
test("a legend that plainly isn't for these stakes is not applied", () => {
|
||||
// Dollar chips on a 5c/10c game means somebody never filled the legend in.
|
||||
// Rounding those blinds up to whole dollars would be the tool doing real
|
||||
// damage on the strength of a stale field, so the amounts stay exact and the
|
||||
// wrong legend stays visible.
|
||||
const state = table(DOLLARS, 0.05, 0.1);
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
|
||||
});
|
||||
|
||||
test("a table with no legend at all gets exact amounts", () => {
|
||||
const state = table([], 1, 2);
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 4.5);
|
||||
});
|
||||
|
||||
test("every fraction comes out buildable, and never shaved", () => {
|
||||
const state = table(DOLLARS, 1, 2);
|
||||
const index = state.actingSeatIndex;
|
||||
const potAfterCall = Betting.potSoFar(state) + Betting.toCall(state, state.seats[index]);
|
||||
|
||||
[0.1, 0.25, 0.5, 0.75, 1].forEach(fraction => {
|
||||
const value = Betting.potRaiseTo(state, index, fraction);
|
||||
const exact = state.currentBet + potAfterCall * fraction;
|
||||
assert.ok(value >= exact, `${fraction} of the pot should never be rounded down`);
|
||||
assert.ok(value - exact < 1, `${fraction} of the pot should round by less than a chip`);
|
||||
assert.equal(Money.chipBreakdown(value, DOLLARS).remainder, 0,
|
||||
`${fraction} of the pot should be buildable from the table's chips`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
// The house's cut.
|
||||
//
|
||||
// A game that rakes has chips leaving the table every hand, and the bank check used to
|
||||
// call them missing: buy-ins in, chips on the felt, and a dealer told somebody had been
|
||||
// paid for chips they never got. The cut is the one thing here that is supposed to go
|
||||
// and not come back.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, HoldemState, Betting, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
const raking = (rake, extra = {}) => {
|
||||
const state = makeTable({ seatCount: 6, stack: 200, ...extra });
|
||||
state.rake = { percent: 0, cap: null, noFlopNoDrop: true, ...rake };
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("what the house takes", () => {
|
||||
test("nothing, unless somebody says otherwise", () => {
|
||||
const state = makeTable({ seatCount: 6 });
|
||||
assert.equal(state.rake.percent, 0);
|
||||
assert.equal(HoldemState.rakeOn(state, 100), 0);
|
||||
});
|
||||
|
||||
test("a percentage of the pot", () => {
|
||||
assert.equal(HoldemState.rakeOn(raking({ percent: 5 }), 100), 5);
|
||||
assert.equal(HoldemState.rakeOn(raking({ percent: 10 }), 60), 6);
|
||||
});
|
||||
|
||||
test("no more than the cap", () => {
|
||||
const state = raking({ percent: 5, cap: 5 });
|
||||
assert.equal(HoldemState.rakeOn(state, 100), 5);
|
||||
assert.equal(HoldemState.rakeOn(state, 400), 5, "still five on a pot four times the size");
|
||||
assert.equal(HoldemState.rakeOn(state, 40), 2, "under the cap it is just the percentage");
|
||||
});
|
||||
|
||||
test("no flop, no drop", () => {
|
||||
const state = raking({ percent: 5 });
|
||||
assert.equal(HoldemState.rakeOn(state, 100, false), 0, "hand ended before the flop");
|
||||
assert.equal(HoldemState.rakeOn(state, 100, true), 5);
|
||||
});
|
||||
|
||||
test("unless the house rakes every pot", () => {
|
||||
const state = raking({ percent: 5, noFlopNoDrop: false });
|
||||
assert.equal(HoldemState.rakeOn(state, 100, false), 5);
|
||||
});
|
||||
|
||||
test("nothing it can't make out of the table's chips, rounded the players' way", () => {
|
||||
// 5% of $30 is $1.50, and this table's smallest chip is a dollar.
|
||||
const state = raking({ percent: 5 });
|
||||
assert.equal(HoldemState.rakeOn(state, 30), 1);
|
||||
// 5% of $10 is 50c — under a chip, so the house takes nothing at all.
|
||||
assert.equal(HoldemState.rakeOn(state, 10), 0);
|
||||
});
|
||||
|
||||
test("never more than the pot, whatever gets typed in", () => {
|
||||
assert.equal(HoldemState.rakeOn(raking({ percent: 100 }), 20), 20);
|
||||
assert.equal(HoldemState.rakeOn(raking({ percent: 5, cap: 500 }), 20), 1);
|
||||
});
|
||||
|
||||
test("nothing out of an empty pot", () => {
|
||||
assert.equal(HoldemState.rakeOn(raking({ percent: 5 }), 0), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("taking it out of the pot", () => {
|
||||
const winner = amount => [{ id: "a", label: "Ann", amount }];
|
||||
|
||||
test("one winner just gets the pot less the cut", () => {
|
||||
const { payouts, rake } = HoldemState.applyRake(winner(100), 5);
|
||||
assert.equal(rake, 5);
|
||||
assert.deepEqual(payouts.map(p => p.amount), [95]);
|
||||
});
|
||||
|
||||
test("a split pot shares what's left, in proportion", () => {
|
||||
const { payouts } = HoldemState.applyRake(
|
||||
[{ id: "a", label: "Ann", amount: 60 }, { id: "b", label: "Ben", amount: 40 }], 10);
|
||||
assert.deepEqual(payouts.map(p => p.amount), [54, 36]);
|
||||
});
|
||||
|
||||
test("the rounding leftovers go back to the players, not to the house", () => {
|
||||
// $100 split three ways less $7: 93 doesn't divide evenly, and every cent of
|
||||
// it has to land on somebody.
|
||||
const { payouts, rake } = HoldemState.applyRake([
|
||||
{ id: "a", label: "Ann", amount: 34 },
|
||||
{ id: "b", label: "Ben", amount: 33 },
|
||||
{ id: "c", label: "Cal", amount: 33 },
|
||||
], 7);
|
||||
assert.equal(rake, 7);
|
||||
assert.equal(Money.round(payouts.reduce((sum, p) => sum + p.amount, 0)), 93);
|
||||
});
|
||||
|
||||
test("what goes out plus what the house keeps is what was in the middle", () => {
|
||||
[[100, 5], [37.5, 1.25], [1000, 0], [9, 9]].forEach(([pot, rake]) => {
|
||||
const result = HoldemState.applyRake(winner(pot), rake);
|
||||
assert.equal(Money.round(result.payouts[0].amount + result.rake), pot, `${pot} less ${rake}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("asking for more than the pot takes the pot, not more", () => {
|
||||
const { payouts, rake } = HoldemState.applyRake(winner(20), 50);
|
||||
assert.equal(rake, 20);
|
||||
assert.deepEqual(payouts.map(p => p.amount), [0]);
|
||||
});
|
||||
|
||||
test("no rake, no change — the same payouts come back", () => {
|
||||
const { payouts, rake } = HoldemState.applyRake(winner(80), 0);
|
||||
assert.equal(rake, 0);
|
||||
assert.deepEqual(payouts.map(p => p.amount), [80]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the box is checked against", () => {
|
||||
test("raked chips are gone but not missing", () => {
|
||||
const state = raking({ percent: 5 });
|
||||
state.seats.forEach(p => { p.buyIns = 200; });
|
||||
const before = HoldemState.bankReconciliation(state);
|
||||
assert.equal(before.balanced, true);
|
||||
|
||||
// The house takes $10 out of the middle: somebody's stack is $10 lighter.
|
||||
state.seats[0].stack = 190;
|
||||
state.rakeTaken = 10;
|
||||
const after = HoldemState.bankReconciliation(state);
|
||||
assert.equal(after.raked, 10);
|
||||
assert.equal(after.balanced, true, "the check still balances");
|
||||
});
|
||||
|
||||
test("and are told apart from the ones that went astray", () => {
|
||||
const state = raking({ percent: 5 });
|
||||
state.seats.forEach(p => { p.buyIns = 200; });
|
||||
state.seats[0].stack = 180;
|
||||
state.rakeTaken = 10;
|
||||
const check = HoldemState.bankReconciliation(state);
|
||||
assert.equal(check.raked, 10);
|
||||
assert.equal(check.difference, -10, "ten dollars really are missing");
|
||||
assert.equal(check.balanced, false);
|
||||
});
|
||||
|
||||
test("a table that has never raked reads exactly as it did before", () => {
|
||||
const state = makeTable({ seatCount: 6 });
|
||||
state.seats.forEach(p => { p.buyIns = 100; p.stack = 100; });
|
||||
const check = HoldemState.bankReconciliation(state);
|
||||
assert.equal(check.raked, 0);
|
||||
assert.equal(check.balanced, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the record of it", () => {
|
||||
test("the hand log keeps the pot as the table saw it, and the cut beside it", () => {
|
||||
const state = raking({ percent: 5 });
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 95 }], 5);
|
||||
const [entry] = state.handHistory;
|
||||
assert.equal(entry.pot, 100, "the pot in the middle");
|
||||
assert.equal(entry.rake, 5);
|
||||
assert.equal(entry.payouts[0].amount, 95, "what the winner actually took");
|
||||
});
|
||||
|
||||
test("a hand with no rake records a zero rather than nothing", () => {
|
||||
const state = makeTable({ seatCount: 6 });
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 40 }]);
|
||||
assert.equal(state.handHistory[0].rake, 0);
|
||||
assert.equal(state.handHistory[0].pot, 40);
|
||||
});
|
||||
|
||||
test("a table saved before the house took anything takes nothing", () => {
|
||||
const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
|
||||
delete parsed.rake;
|
||||
delete parsed.rakeTaken;
|
||||
const migrated = HoldemState.migrate(parsed);
|
||||
assert.equal(migrated.rake.percent, 0);
|
||||
assert.equal(migrated.rake.noFlopNoDrop, true);
|
||||
assert.equal(migrated.rakeTaken, 0);
|
||||
});
|
||||
|
||||
test("nonsense in a saved rake is cleaned up rather than trusted", () => {
|
||||
const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
|
||||
parsed.rake = { percent: -5, cap: -1, noFlopNoDrop: false };
|
||||
parsed.rakeTaken = -20;
|
||||
const migrated = HoldemState.migrate(parsed);
|
||||
assert.equal(migrated.rake.percent, 0);
|
||||
assert.equal(migrated.rake.cap, null);
|
||||
assert.equal(migrated.rake.noFlopNoDrop, false, "that one is a real choice");
|
||||
assert.equal(migrated.rakeTaken, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the file the dealer hands over", () => {
|
||||
const { HandHistoryExport } = require("./load");
|
||||
|
||||
test("has a column for what the house took", () => {
|
||||
const state = raking({ percent: 5 });
|
||||
HoldemState.recordHand(state, [{ id: "a", label: "Ann", amount: 95 }], 5);
|
||||
HoldemState.recordHand(state, [{ id: "b", label: "Ben", amount: 40 }], 0);
|
||||
|
||||
// Oldest hand first, and money as bare numbers so a spreadsheet can total it.
|
||||
const csv = HandHistoryExport.toCsv(state.handHistory);
|
||||
const [header, raked, unraked] = csv.split("\n");
|
||||
assert.match(header, /Pot,House take,Payouts/);
|
||||
assert.match(raked, /,100\.00,5\.00,/, "the raked hand says how much was taken");
|
||||
assert.match(unraked, /,40\.00,0\.00,/, "and the unraked one says nothing was");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a raked hand, end to end", () => {
|
||||
test("the chips add up: stacks plus the house's cut equal what was bought in", () => {
|
||||
// 5/10 blinds, so six callers make a pot big enough for 5% to be a whole chip.
|
||||
// At 1/2 the same hand makes $12, 5% of which is 60c and rounds away to
|
||||
// nothing — which is the rule working, not a hand worth testing the bank on.
|
||||
const state = raking({ percent: 5, cap: 5 }, { smallBlind: 5, bigBlind: 10 });
|
||||
state.seats.forEach(p => { p.buyIns = 200; });
|
||||
Betting.beginHand(state);
|
||||
// Everyone calls the big blind, then it is checked down.
|
||||
state.seats.forEach((p, i) => { if (p.inHand) Betting.putIn(state, i, Betting.toCall(state, p)); });
|
||||
Betting.sweepToHandTotals(state);
|
||||
|
||||
const pot = state.seats.reduce((sum, p) => sum + (Number(p.amountOverride) || 0), 0);
|
||||
const rake = HoldemState.rakeOn(state, pot, true);
|
||||
assert.equal(pot, 60);
|
||||
assert.equal(rake, 3);
|
||||
|
||||
const { payouts } = HoldemState.applyRake([{ id: state.seats[0].id, label: "P1", amount: pot }], rake);
|
||||
state.rakeTaken = rake;
|
||||
HoldemState.recordHand(state, payouts, rake);
|
||||
Betting.creditWinnings(state, payouts);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(Money.round(chipsOnTable(state) + state.rakeTaken), 1200);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
// Reading the showdown: the picker that takes the cards in, and the pot maths that
|
||||
// pays somebody on the strength of them.
|
||||
//
|
||||
// The evaluator is tested to death next door in handRank.test.js. What is tested here
|
||||
// is the join — the two places where a correct ranking can still pay the wrong player:
|
||||
// a side pot, where the question is asked again of a smaller field, and a card entered
|
||||
// in two places, where the ranking is of a deck that does not exist.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { CardPicker, HandRank, HoldemState, PotCalculator, Money, makeTable } = require("./load");
|
||||
|
||||
describe("the picker, filling the board", () => {
|
||||
const table = (board = ["", "", "", "", ""]) => {
|
||||
const state = makeTable({ seatCount: 4, stack: 100 });
|
||||
state.board = board;
|
||||
return state;
|
||||
};
|
||||
|
||||
// The sheet's own drawing is the DOM's business; the slot bookkeeping under it is
|
||||
// not, and that is what these ask about.
|
||||
const headless = () => { CardPicker.render = () => {}; CardPicker.close = () => {}; };
|
||||
|
||||
const onBoard = (state, cardIndex = 0, limit = 5) => {
|
||||
headless();
|
||||
CardPicker.state = state;
|
||||
CardPicker.hooks = { onPick: () => {} };
|
||||
CardPicker.target = "board";
|
||||
CardPicker.boardLimit = limit;
|
||||
CardPicker.cardIndex = cardIndex;
|
||||
return CardPicker;
|
||||
};
|
||||
|
||||
const onSeat = (state, seatIndex, cardIndex = 0) => {
|
||||
headless();
|
||||
CardPicker.state = state;
|
||||
CardPicker.hooks = { onPick: () => {} };
|
||||
CardPicker.target = "seat";
|
||||
CardPicker.seatIndex = seatIndex;
|
||||
CardPicker.cardIndex = cardIndex;
|
||||
return CardPicker;
|
||||
};
|
||||
|
||||
test("it only offers the slots that have actually been turned", () => {
|
||||
const state = table();
|
||||
assert.equal(onBoard(state, 0, 3).slotValues().length, 3, "the flop is three");
|
||||
assert.equal(onBoard(state, 0, 4).slotValues().length, 4, "the turn makes four");
|
||||
assert.equal(onBoard(state, 0, 5).slotValues().length, 5);
|
||||
});
|
||||
|
||||
test("a card goes into the board, not into a seat", () => {
|
||||
const state = table();
|
||||
onBoard(state, 1, 3).assign("KD");
|
||||
assert.deepEqual(state.board, ["", "KD", "", "", ""]);
|
||||
assert.deepEqual(state.seats[0].cards, ["", ""]);
|
||||
});
|
||||
|
||||
test("it walks on to the next empty slot, so a flop is three taps", () => {
|
||||
const state = table();
|
||||
const picker = onBoard(state, 0, 3);
|
||||
picker.assign("KD");
|
||||
assert.equal(picker.cardIndex, 1);
|
||||
picker.assign("9C");
|
||||
assert.equal(picker.cardIndex, 2);
|
||||
picker.assign("4S");
|
||||
assert.deepEqual(state.board.slice(0, 3), ["KD", "9C", "4S"]);
|
||||
});
|
||||
|
||||
test("and it does not walk past the slots it was given", () => {
|
||||
// Filling the turn must not run on to the river: that card is still in the deck.
|
||||
const state = table(["KD", "9C", "4S", "", ""]);
|
||||
const picker = onBoard(state, 3, 4);
|
||||
picker.assign("AH");
|
||||
assert.deepEqual(state.board, ["KD", "9C", "4S", "AH", ""]);
|
||||
});
|
||||
|
||||
test("clearing a slot empties that one and nothing else", () => {
|
||||
const state = table(["KD", "9C", "4S", "", ""]);
|
||||
onBoard(state, 1, 3).clearSlot();
|
||||
assert.deepEqual(state.board, ["KD", "", "4S", "", ""]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a card cannot be in two places", () => {
|
||||
const table = () => {
|
||||
const state = makeTable({ seatCount: 4, stack: 100 });
|
||||
state.board = ["KD", "9C", "4S", "", ""];
|
||||
state.seats[0].cards = ["AH", "AS"];
|
||||
state.seats[1].cards = ["", ""];
|
||||
return state;
|
||||
};
|
||||
|
||||
const picker = (state, setup) => {
|
||||
CardPicker.state = state;
|
||||
CardPicker.hooks = { onPick: () => {} };
|
||||
setup();
|
||||
return CardPicker;
|
||||
};
|
||||
|
||||
test("the board's cards are unavailable to a hand", () => {
|
||||
// The half of this that did not exist before: the picker knew about every
|
||||
// player's cards and nothing about the middle of the table, so the king of
|
||||
// diamonds on the flop could be dealt to somebody as well.
|
||||
const state = table();
|
||||
const used = picker(state, () => {
|
||||
CardPicker.target = "seat";
|
||||
CardPicker.seatIndex = 1;
|
||||
CardPicker.cardIndex = 0;
|
||||
}).cardsInUse();
|
||||
assert.equal(used.get("KD"), "the board");
|
||||
assert.equal(used.get("9C"), "the board");
|
||||
assert.ok(used.has("AH"), "and the other players' are still in there");
|
||||
});
|
||||
|
||||
test("and a hand's cards are unavailable to the board", () => {
|
||||
const state = table();
|
||||
const used = picker(state, () => {
|
||||
CardPicker.target = "board";
|
||||
CardPicker.boardLimit = 5;
|
||||
CardPicker.cardIndex = 3;
|
||||
}).cardsInUse();
|
||||
assert.ok(used.has("AH"), "the ace in seat 1's hand");
|
||||
assert.ok(used.has("KD"), "and the rest of the board");
|
||||
});
|
||||
|
||||
test("but the slot being edited is not competing with itself", () => {
|
||||
const state = table();
|
||||
const used = picker(state, () => {
|
||||
CardPicker.target = "board";
|
||||
CardPicker.boardLimit = 3;
|
||||
CardPicker.cardIndex = 0;
|
||||
}).cardsInUse();
|
||||
assert.equal(used.has("KD"), false, "re-picking what is already there is not a duplicate");
|
||||
assert.equal(used.has("9C"), true);
|
||||
});
|
||||
|
||||
test("the same goes for a seat's own slot", () => {
|
||||
const state = table();
|
||||
const used = picker(state, () => {
|
||||
CardPicker.target = "seat";
|
||||
CardPicker.seatIndex = 0;
|
||||
CardPicker.cardIndex = 0;
|
||||
}).cardsInUse();
|
||||
assert.equal(used.has("AH"), false);
|
||||
assert.equal(used.has("AS"), true, "the player's other card still is");
|
||||
});
|
||||
});
|
||||
|
||||
describe("who wins a side pot", () => {
|
||||
// The case a single ranking of the whole table gets wrong. The best hand takes the
|
||||
// main pot and is then out of the question: the side pot goes to the best of who is
|
||||
// left, which is a different answer, and it is the answer the wizard has to ask for
|
||||
// again each round rather than reading off one list.
|
||||
//
|
||||
// Three players, and the short stack has the best hand of the three.
|
||||
const board = ["KD", "9C", "4S", "AH", "2H"];
|
||||
const hands = {
|
||||
short: ["9H", "9S"], // trips — the best hand at the table
|
||||
big: ["AS", "KC"], // two pair, aces and kings
|
||||
other: ["AC", "3D"], // a pair of aces
|
||||
};
|
||||
|
||||
const entries = () => PotCalculator.buildEntries([
|
||||
{ id: "short", contribution: 20, folded: false },
|
||||
{ id: "big", contribution: 100, folded: false },
|
||||
{ id: "other", contribution: 100, folded: false },
|
||||
]);
|
||||
|
||||
// What the wizard does: read the hands still in for *this* round.
|
||||
const readRound = state => HandRank.showdown(board,
|
||||
PotCalculator.getContenders(state).map(c => ({ id: c.id, cards: hands[c.id] })));
|
||||
|
||||
test("the short stack takes the main pot with the best hand", () => {
|
||||
const round = readRound(entries());
|
||||
assert.equal(round.complete, true);
|
||||
assert.deepEqual(round.winners, ["short"]);
|
||||
});
|
||||
|
||||
test("and the side pot goes to the best of the two who are left", () => {
|
||||
const first = PotCalculator.resolveRound(entries(), ["short"]);
|
||||
// 20 from each of three: the short stack is paid 60 and is out of it.
|
||||
assert.equal(Money.round(first.perWinner[0].amount), 60);
|
||||
|
||||
const round = readRound(first.updatedEntries);
|
||||
assert.deepEqual(round.winners, ["big"],
|
||||
"not the trips, which are no longer eligible for this money");
|
||||
assert.equal(round.players.length, 2, "and the short stack is not even offered");
|
||||
|
||||
const second = PotCalculator.resolveRound(first.updatedEntries, ["big"]);
|
||||
assert.equal(Money.round(second.perWinner[0].amount), 160);
|
||||
});
|
||||
|
||||
test("every chip is accounted for across the two rounds", () => {
|
||||
const first = PotCalculator.resolveRound(entries(), ["short"]);
|
||||
const second = PotCalculator.resolveRound(first.updatedEntries, ["big"]);
|
||||
assert.equal(Money.round(first.payoutPool + second.payoutPool), 220);
|
||||
assert.equal(PotCalculator.getContenders(second.updatedEntries).length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it refuses to answer", () => {
|
||||
const board = ["KD", "9C", "4S", "AH", "2H"];
|
||||
|
||||
test("a folded player's cards are not in the running", () => {
|
||||
// They are not a contender, so they are never handed in — which means a hand
|
||||
// that would have won cannot take a pot its owner threw away.
|
||||
const table = PotCalculator.buildEntries([
|
||||
{ id: "live", contribution: 50, folded: false },
|
||||
{ id: "mucked", contribution: 50, folded: true },
|
||||
]);
|
||||
const contenders = PotCalculator.getContenders(table);
|
||||
assert.deepEqual(contenders.map(c => c.id), ["live"]);
|
||||
|
||||
const round = HandRank.showdown(board, contenders.map(c => ({ id: c.id, cards: ["7C", "5D"] })));
|
||||
assert.deepEqual(round.winners, ["live"], "even holding nothing");
|
||||
});
|
||||
|
||||
test("one hand not turned over and it hands the question back", () => {
|
||||
const round = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["9H", "9S"] },
|
||||
{ id: "b", cards: ["", ""] },
|
||||
]);
|
||||
assert.equal(round.complete, false);
|
||||
assert.deepEqual(round.winners, []);
|
||||
assert.deepEqual(round.unknown, ["b"]);
|
||||
});
|
||||
|
||||
test("a board still being dealt is not a showdown", () => {
|
||||
const round = HandRank.showdown(["KD", "9C", "4S", "", ""], [
|
||||
{ id: "a", cards: ["9H", "9S"] },
|
||||
{ id: "b", cards: ["AS", "KC"] },
|
||||
]);
|
||||
assert.equal(round.complete, false);
|
||||
assert.deepEqual(round.winners, []);
|
||||
});
|
||||
|
||||
test("a card entered twice stops it rather than guessing", () => {
|
||||
// The failure mode that matters: a mis-typed card produces a hand that ranks
|
||||
// perfectly well and is not the one on the table. Refusing is the only honest
|
||||
// answer, and the dealer picks the winner the way they always could.
|
||||
const round = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["9H", "9S"] },
|
||||
{ id: "b", cards: ["KD", "QC"] }, // the king of diamonds is on the board
|
||||
]);
|
||||
assert.equal(round.duplicated, true);
|
||||
assert.equal(round.complete, false);
|
||||
assert.deepEqual(round.winners, []);
|
||||
});
|
||||
|
||||
test("and two players holding the same card is caught as well", () => {
|
||||
const round = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["QH", "QS"] },
|
||||
{ id: "b", cards: ["QH", "JC"] },
|
||||
]);
|
||||
assert.equal(round.duplicated, true);
|
||||
assert.equal(round.complete, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a table that never asked for any of this", () => {
|
||||
test("has no board, tracks nothing, and reads no showdown", () => {
|
||||
const state = HoldemState.createDefault();
|
||||
assert.equal(state.trackBoard, false);
|
||||
// Which is what the wizard checks before it reads anything at all — see
|
||||
// readShowdown() in potWizard.js. With it off, an empty board would decline
|
||||
// anyway, so the tool cannot start answering a question nobody asked.
|
||||
const round = HandRank.showdown(state.board, [
|
||||
{ id: "a", cards: ["9H", "9S"] },
|
||||
{ id: "b", cards: ["AS", "KC"] },
|
||||
]);
|
||||
assert.equal(round.complete, false);
|
||||
assert.deepEqual(round.winners, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
// Motion, for the people who asked for less of it.
|
||||
//
|
||||
// The stylesheet had honoured `prefers-reduced-motion` for a while, and all three places
|
||||
// it did were the wrong size of thing: a 2px press-down on a button, a checkbox tick,
|
||||
// and the acting seat's pulse. What none of them covered was motion that moves something
|
||||
// across the screen — and for anyone with a vestibular disorder that is the part that
|
||||
// matters. A button dipping under a thumb is not what makes people ill.
|
||||
//
|
||||
// Four things were doing it. A seat editor that slides the full height of itself up from
|
||||
// off-screen, every time a dealer taps a chair. A toast that slides in from below. And
|
||||
// three `scrollIntoView({ behavior: "smooth" })` calls, which are the most provocative of
|
||||
// the lot, because what moves is the whole viewport rather than one control.
|
||||
//
|
||||
// The scripts are checked here against a stand-in for matchMedia; the stylesheet is
|
||||
// checked against its own text, the way the seat footprint rules already are.
|
||||
const test = require("node:test");
|
||||
const { describe, afterEach } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Render } = require("./load");
|
||||
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
// ---- The scripts ------------------------------------------------------------
|
||||
|
||||
// Node has no matchMedia at all, so this installs one rather than replacing one. It is
|
||||
// defined as a plain value here for the same reason `navigator` had to be defined with
|
||||
// defineProperty in wakeLock.test.js: assigning to a global the runtime owns can be a
|
||||
// silent no-op, and a test that thinks it installed a fake and didn't passes for the
|
||||
// wrong reason.
|
||||
const setReducedMotion = wanted => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
value: query => ({
|
||||
matches: wanted && /prefers-reduced-motion:\s*reduce/.test(query),
|
||||
media: query,
|
||||
}),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const unsetMatchMedia = () => { delete globalThis.matchMedia; };
|
||||
|
||||
// An element that records what it was asked to do, and nothing else.
|
||||
const fakeElement = () => {
|
||||
const calls = [];
|
||||
return { calls, scrollIntoView(options) { calls.push(options); } };
|
||||
};
|
||||
|
||||
afterEach(unsetMatchMedia);
|
||||
|
||||
describe("what the tool reads off the setting", () => {
|
||||
test("a browser asking for less motion is heard", () => {
|
||||
setReducedMotion(true);
|
||||
assert.equal(Render.prefersReducedMotion(), true);
|
||||
});
|
||||
|
||||
test("and one that isn't, isn't", () => {
|
||||
setReducedMotion(false);
|
||||
assert.equal(Render.prefersReducedMotion(), false);
|
||||
});
|
||||
|
||||
test("a browser too old to have the question is not assumed to be answering it", () => {
|
||||
// No matchMedia at all. The safe reading is "no preference stated", which leaves
|
||||
// the tool behaving exactly as it did — not "reduce", which would quietly take
|
||||
// the animation away from everybody on an old browser.
|
||||
unsetMatchMedia();
|
||||
assert.equal(Render.prefersReducedMotion(), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bringing something into view", () => {
|
||||
test("scrolls smoothly when nobody has asked otherwise", () => {
|
||||
setReducedMotion(false);
|
||||
const el = fakeElement();
|
||||
Render.bringIntoView(el, { block: "center" });
|
||||
assert.deepEqual(el.calls, [{ block: "center", behavior: "smooth" }]);
|
||||
});
|
||||
|
||||
test("and jumps when they have", () => {
|
||||
setReducedMotion(true);
|
||||
const el = fakeElement();
|
||||
Render.bringIntoView(el, { block: "center" });
|
||||
assert.deepEqual(el.calls, [{ block: "center", behavior: "auto" }]);
|
||||
});
|
||||
|
||||
test("the caller still chooses where it lands, only not how it gets there", () => {
|
||||
// Each of the three callers wants a different alignment — the raise field
|
||||
// centred, the seat sheet nearest, the sidebar section at the top — and the
|
||||
// helper must not flatten that into one answer.
|
||||
setReducedMotion(true);
|
||||
for (const block of ["center", "nearest", "start"]) {
|
||||
const el = fakeElement();
|
||||
Render.bringIntoView(el, { block });
|
||||
assert.equal(el.calls[0].block, block);
|
||||
}
|
||||
});
|
||||
|
||||
test("a caller that never chose gets a plain scroll rather than an exception", () => {
|
||||
setReducedMotion(false);
|
||||
const el = fakeElement();
|
||||
Render.bringIntoView(el);
|
||||
assert.equal(el.calls[0].behavior, "smooth");
|
||||
});
|
||||
|
||||
test("nothing to scroll is not an error", () => {
|
||||
// main.js reaches for #seatEditorPanel by id and passes whatever it finds.
|
||||
setReducedMotion(true);
|
||||
assert.doesNotThrow(() => Render.bringIntoView(null));
|
||||
assert.doesNotThrow(() => Render.bringIntoView(undefined));
|
||||
assert.doesNotThrow(() => Render.bringIntoView({}));
|
||||
});
|
||||
|
||||
test("the setting is re-read every time, not remembered from page load", () => {
|
||||
// A dealer who turns reduced motion on mid-session should be taken seriously
|
||||
// without reloading the table — and the table is a thing they cannot afford to
|
||||
// reload lightly.
|
||||
setReducedMotion(false);
|
||||
const el = fakeElement();
|
||||
Render.bringIntoView(el, { block: "start" });
|
||||
|
||||
setReducedMotion(true);
|
||||
Render.bringIntoView(el, { block: "start" });
|
||||
|
||||
assert.deepEqual(el.calls.map(c => c.behavior), ["smooth", "auto"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- The stylesheet ---------------------------------------------------------
|
||||
|
||||
// Everything inside the @media block whose condition matches, concatenated — there is
|
||||
// more than one reduced-motion block in the file and they govern different things.
|
||||
const reducedMotionBlocks = (extraCondition = null) => {
|
||||
const found = [];
|
||||
const needle = "@media ";
|
||||
for (let at = CSS.indexOf(needle); at >= 0; at = CSS.indexOf(needle, at + 1)) {
|
||||
const open = CSS.indexOf("{", at);
|
||||
const condition = CSS.slice(at + needle.length, open).trim();
|
||||
if (!/prefers-reduced-motion:\s*reduce/.test(condition)) continue;
|
||||
if (extraCondition && !condition.includes(extraCondition)) continue;
|
||||
let depth = 0;
|
||||
let i = open;
|
||||
for (; i < CSS.length; i++) {
|
||||
if (CSS[i] === "{") depth++;
|
||||
else if (CSS[i] === "}" && --depth === 0) break;
|
||||
}
|
||||
found.push(CSS.slice(open + 1, i));
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
describe("the stylesheet stops the things that actually travel", () => {
|
||||
test("there is more than one reduced-motion block, and one of them is for the sheet", () => {
|
||||
assert.ok(reducedMotionBlocks().length >= 2,
|
||||
"the press-down block alone was never the whole job");
|
||||
assert.equal(reducedMotionBlocks("max-width").length, 1,
|
||||
"the seat sheet's override has to carry the same width condition as the rule it beats");
|
||||
});
|
||||
|
||||
test("the toast keeps its fade and loses its travel", () => {
|
||||
const all = reducedMotionBlocks().join("\n");
|
||||
const toast = all.slice(all.indexOf(".toast"));
|
||||
assert.match(toast, /transform:\s*none/,
|
||||
"a toast sliding up from the bottom edge is exactly the motion being asked about");
|
||||
// The guidance is not "delete the animation": something that appears already in
|
||||
// place reads as a rendering glitch rather than as news. Opacity survives.
|
||||
assert.match(toast, /transition:\s*opacity/,
|
||||
"the fade has to stay, or the toast stops reading as something that just happened");
|
||||
});
|
||||
|
||||
test("the seat sheet stops sliding, and stops being painted after it has gone", () => {
|
||||
const [sheet] = reducedMotionBlocks("max-width");
|
||||
assert.ok(sheet, "the sheet override should exist");
|
||||
assert.doesNotMatch(sheet, /transition:[^;]*transform/,
|
||||
"the transform transition is the slide, and it is the largest movement here");
|
||||
// The delay is what kept the slide-out on screen for its own quarter second.
|
||||
// With the slide gone it would leave a sheet that has already jumped away still
|
||||
// painted, which is a flicker rather than a courtesy.
|
||||
const flips = sheet.match(/transition:\s*visibility\s+0s[^;]*/g) || [];
|
||||
assert.equal(flips.length, 2,
|
||||
"both directions need saying: the rule that puts the sheet away and the one that opens it");
|
||||
assert.ok(flips.every(rule => /^transition:\s*visibility\s+0s(\s+linear\s+0s)?\s*$/.test(rule.trim())),
|
||||
`visibility has to flip at once in both directions once there is no slide to cover: ${flips}`);
|
||||
assert.doesNotMatch(sheet, /linear\s+0\.\d+s/,
|
||||
"no leftover delay measured against an animation that no longer runs");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- And the same rule, applied to whatever the file grows next --------------
|
||||
//
|
||||
// The three checks above name the things that were wrong. This one is the general
|
||||
// version, and it is the one that will still be doing work in a year: it scans the
|
||||
// stylesheet for every `:hover` and `:active` transform there is and insists each one
|
||||
// has an off switch.
|
||||
//
|
||||
// Written because the existing block's membership had gone wrong in a way no
|
||||
// enumerated test would have caught. It held nine `:active` selectors and looked
|
||||
// deliberate, but it had been written to stop a *stuck press state* when a tap turns
|
||||
// into a scroll — reaching for the reduced-motion query as the nearest proxy — and
|
||||
// solving one problem with another's tool is how it came to cover `.dealerChip` and
|
||||
// `.raiseQuickBtn` while missing `.actionBtn`, the class on every button in the action
|
||||
// bar, and every `:hover` in the file.
|
||||
describe("every hover and press in the file has an off switch", () => {
|
||||
// Selectors that move something under the pointer.
|
||||
//
|
||||
// Parsed as blocks rather than scanned line by line, and that distinction is not
|
||||
// pedantry — the line-by-line version was written first and a fault injection walked
|
||||
// straight past it. A rule written on one line puts its selector and its declaration
|
||||
// in the same string, so neither "this line opens a rule" nor "this line is a
|
||||
// transform" matched, and the scan reported a clean file while the lift it was meant
|
||||
// to find sat in plain sight.
|
||||
//
|
||||
// The pattern matches innermost blocks only: a selector cannot contain braces, so an
|
||||
// @media wrapper's body never matches and its contents are reached individually.
|
||||
const pointerTransforms = () => {
|
||||
const withoutComments = CSS.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
const found = [];
|
||||
for (const [, selector, body] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
const name = selector.trim();
|
||||
if (name.startsWith("@")) continue;
|
||||
if (!name.includes(":hover") && !name.includes(":active")) continue;
|
||||
const moves = body.match(/(^|[;{\s])transform\s*:\s*([^;]+)/);
|
||||
if (!moves || /^none\b/.test(moves[2].trim())) continue;
|
||||
// Split the group. A comma-separated rule is nine separate promises, and
|
||||
// checking the joined string against the reduce block is a test that can only
|
||||
// pass if both lists happen to be written in the same order — which is to say
|
||||
// a test that passes by luck. It also hid three selectors outright: the
|
||||
// line-scanning version kept only the last line of a multi-line group, so
|
||||
// .toastAction, .toastClose and .potWizardChoice were never looked at.
|
||||
for (const one of name.split(",")) {
|
||||
const selector = one.trim().replace(/\s+/g, " ");
|
||||
if (!selector.includes(":hover") && !selector.includes(":active")) continue;
|
||||
found.push({ selector, declaration: moves[2].trim() });
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
const reduceText = () => reducedMotionBlocks().join("\n");
|
||||
|
||||
test("there are some to check, or this test is measuring nothing", () => {
|
||||
// The guard the accent check taught us to write: a scan that finds nothing
|
||||
// passes just as green as a scan that finds everything covered.
|
||||
assert.ok(pointerTransforms().length >= 4,
|
||||
"the file should still have hover/press transforms in it");
|
||||
});
|
||||
|
||||
test("and every one of them is turned off under reduced motion", () => {
|
||||
const text = reduceText();
|
||||
// A selector counts as covered when it appears in a reduce block, comma-separated
|
||||
// list and all. Compared with whitespace flattened, because the reduce block puts
|
||||
// each selector on its own line and the rule it answers may not.
|
||||
const flat = text.replace(/\s+/g, " ");
|
||||
const missing = pointerTransforms()
|
||||
.filter(rule => !flat.includes(rule.selector))
|
||||
.map(rule => `${rule.selector} { transform: ${rule.declaration} }`);
|
||||
assert.deepEqual(missing, [],
|
||||
`these move under the pointer with no reduced-motion escape:\n ${missing.join("\n ")}`);
|
||||
});
|
||||
|
||||
test("including the one a dealer touches most", () => {
|
||||
// Named on its own as well as caught by the scan, because this is the specific
|
||||
// omission that prompted the scan: Fold, Call, Raise and Post blinds & deal all
|
||||
// carry .actionBtn, and it was the one control missing from a list of nine.
|
||||
assert.match(reduceText(), /\.actionBtn:active/,
|
||||
"the action bar's own buttons are the most-pressed thing in the tool");
|
||||
});
|
||||
|
||||
test("a looping animation only runs where motion is welcome", () => {
|
||||
// The acting seat breathes. An infinite animation is the one kind that cannot be
|
||||
// waited out, so it has to be inside a no-preference block rather than merely
|
||||
// overridden in a reduce one.
|
||||
const noPreference = CSS.indexOf("@media (prefers-reduced-motion: no-preference)");
|
||||
assert.ok(noPreference > 0, "the pulse should sit behind a no-preference query");
|
||||
const infinite = [...CSS.matchAll(/animation:[^;]*infinite/g)].map(m => m.index);
|
||||
assert.ok(infinite.length > 0, "there should still be a looping animation to check");
|
||||
assert.ok(infinite.every(at => at > noPreference),
|
||||
"every looping animation has to sit inside the no-preference block");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
// Reads every piece of text on the dealer's page and asks whether it can actually be
|
||||
// read, at the size and weight it is actually drawn.
|
||||
//
|
||||
// node tests/holdem/runContrastAudit.js [baseUrl]
|
||||
//
|
||||
// Needs Playwright and a running site, like runUiAudit.js, and is deliberately kept
|
||||
// out of the unit suite for the same reason.
|
||||
//
|
||||
// Why this exists next to the contrast check already in uiAudit.js: that one measures
|
||||
// WCAG 2 contrast ratio and the page passes it. WCAG 2's ratio is a luminance gap, and
|
||||
// a luminance gap overstates readability once one colour is near black — which is
|
||||
// every reading taken on a page whose background is #141414. APCA asks the question
|
||||
// the ratio can't. Both numbers are reported, because "passes AA, fails APCA" is the
|
||||
// interesting row and hiding either number would hide it.
|
||||
//
|
||||
// How the background is obtained matters more than the arithmetic. The first version
|
||||
// walked the DOM compositing `background-color` up the ancestors, and reported the gold
|
||||
// dealer button as black text on a black background at Lc 0.4 — a fault that does not
|
||||
// exist on an element anyone can see is legible, because a gradient was doing the
|
||||
// painting and `background-color` knew nothing about it. So: paint the page with its
|
||||
// text made transparent, screenshot it, and read the background out of the pixels. The
|
||||
// text's own client rects come from a Range, not the element box, so a label inside a
|
||||
// wide row is sampled where the glyphs are rather than wherever the row happens to end.
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { lc, requiredLc } = require("./apca");
|
||||
const { decode, pixelAt } = require("./png");
|
||||
|
||||
const BASE = process.argv[2] || "http://localhost:5199";
|
||||
|
||||
const VIEWPORTS = [
|
||||
[390, 844, "iPhone 14"],
|
||||
[768, 1024, "iPad"],
|
||||
[1280, 900, "desktop"],
|
||||
];
|
||||
|
||||
// WCAG 2 relative luminance — the piecewise curve, which is not the one APCA uses.
|
||||
function wcagLuminance([r, g, b]) {
|
||||
const f = c => {
|
||||
c /= 255;
|
||||
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
}
|
||||
|
||||
function wcagRatio(fg, bg) {
|
||||
const a = wcagLuminance(fg), b = wcagLuminance(bg);
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
}
|
||||
|
||||
function seedTable() {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
[["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||
["Bob", 250], ["Dee", 600]].forEach(([name, cash], i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].buyIns = cash;
|
||||
state.seats[i].stack = cash;
|
||||
});
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
state.handNumber = 5;
|
||||
state.handHistory.push({
|
||||
handNumber: 4, timestamp: Date.now(), pot: 120,
|
||||
payouts: [{ id: state.seats[0].id, label: "Christopher", amount: 120 }],
|
||||
ledger: state.seats.slice(0, 5).map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
|
||||
});
|
||||
HoldemState.save(state);
|
||||
}
|
||||
|
||||
// Every text node that is actually painted, with the rect its glyphs occupy.
|
||||
function collectText() {
|
||||
const out = [];
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
const text = node.textContent.trim();
|
||||
if (!text) continue;
|
||||
const el = node.parentElement;
|
||||
if (!el || el.closest("[hidden]")) continue;
|
||||
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.visibility === "hidden" || cs.display === "none") continue;
|
||||
|
||||
// Anything faded is faded onto what is behind it, and the screenshot already
|
||||
// shows that — but fully transparent text is not text.
|
||||
let opacity = 1;
|
||||
for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
|
||||
opacity *= parseFloat(getComputedStyle(n).opacity);
|
||||
}
|
||||
if (opacity < 0.05) continue;
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
const rects = [...range.getClientRects()].filter(r => r.width > 1 && r.height > 1);
|
||||
if (!rects.length) continue;
|
||||
const r = rects[0];
|
||||
if (r.bottom < 0 || r.top > innerHeight || r.right < 0 || r.left > innerWidth) continue;
|
||||
|
||||
const m = cs.color.match(/[\d.]+/g);
|
||||
if (!m) continue;
|
||||
|
||||
out.push({
|
||||
text: text.slice(0, 40),
|
||||
color: [+m[0], +m[1], +m[2]],
|
||||
alpha: m[3] !== undefined ? +m[3] : 1,
|
||||
opacity,
|
||||
px: parseFloat(cs.fontSize),
|
||||
weight: +cs.fontWeight || 400,
|
||||
cls: (el.className && el.className.toString().trim().split(/\s+/)[0]) || el.tagName.toLowerCase(),
|
||||
tag: el.tagName.toLowerCase(),
|
||||
disabled: !!el.closest(":disabled, [disabled], [aria-disabled='true']"),
|
||||
// Sample points along the glyph run, avoiding the very edges where a
|
||||
// border or the next box over would be sampled instead.
|
||||
points: [0.25, 0.5, 0.75].map(t => [r.left + r.width * t, r.top + r.height / 2]),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const HIDE_TEXT = `*, *::before, *::after {
|
||||
color: transparent !important;
|
||||
text-shadow: none !important;
|
||||
-webkit-text-stroke-color: transparent !important;
|
||||
caret-color: transparent !important;
|
||||
}`;
|
||||
|
||||
async function main() {
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = require("playwright"));
|
||||
} catch {
|
||||
console.error("This script needs Playwright: npm i -D playwright && npx playwright install chromium");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const shotDir = fs.mkdtempSync(path.join(os.tmpdir(), "holdem-contrast-"));
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ deviceScaleFactor: 1 });
|
||||
const page = await context.newPage();
|
||||
|
||||
const rows = [];
|
||||
|
||||
for (const [width, height, label] of VIEWPORTS) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(`${BASE}/casino/texasholdem`);
|
||||
await page.evaluate(seedTable);
|
||||
await page.goto(`${BASE}/casino/texasholdem`);
|
||||
await page.click("#nextHandBtn");
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => {
|
||||
const deal = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /post blinds/i.test(b.textContent));
|
||||
if (deal) deal.click();
|
||||
});
|
||||
await page.waitForTimeout(250);
|
||||
// Open every panel, or the sidebar's hint text — the smallest, faintest text on
|
||||
// the page, and so the most likely to be the thing worth finding — is never
|
||||
// measured at all.
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll("details").forEach(d => { d.open = true; });
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const items = await page.evaluate(collectText);
|
||||
|
||||
const shot = path.join(shotDir, `${width}x${height}.png`);
|
||||
await page.addStyleTag({ content: HIDE_TEXT });
|
||||
await page.waitForTimeout(120);
|
||||
await page.screenshot({ path: shot });
|
||||
// Undo, so the next viewport starts from a page that still has its text.
|
||||
await page.evaluate(() => {
|
||||
const tags = [...document.querySelectorAll("style")];
|
||||
const injected = tags[tags.length - 1];
|
||||
if (injected && injected.textContent.includes("caret-color: transparent")) injected.remove();
|
||||
});
|
||||
|
||||
const image = decode(fs.readFileSync(shot));
|
||||
|
||||
for (const item of items) {
|
||||
// Most common sampled colour along the run: a median would invent a colour
|
||||
// that is on no pixel, and the mode lands on the flat background wherever
|
||||
// there is one.
|
||||
const tally = new Map();
|
||||
for (const [x, y] of item.points) {
|
||||
const key = pixelAt(image, x, y).join(",");
|
||||
tally.set(key, (tally.get(key) || 0) + 1);
|
||||
}
|
||||
const bg = [...tally.entries()].sort((a, b) => b[1] - a[1])[0][0]
|
||||
.split(",").map(Number);
|
||||
|
||||
// The text's painted colour: its own alpha, and any inherited opacity, both
|
||||
// land it on the background behind it.
|
||||
const a = item.alpha * item.opacity;
|
||||
const fg = item.color.map((c, i) => c * a + bg[i] * (1 - a));
|
||||
|
||||
const measured = Math.abs(lc(fg, bg));
|
||||
const needed = requiredLc(item.px, item.weight);
|
||||
rows.push({
|
||||
viewport: label,
|
||||
...item,
|
||||
bg, fg: fg.map(Math.round),
|
||||
lc: +measured.toFixed(1),
|
||||
need: +needed.toFixed(0),
|
||||
short: +(needed - measured).toFixed(1),
|
||||
wcag: +wcagRatio(fg, bg).toFixed(2),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
fs.rmSync(shotDir, { recursive: true, force: true });
|
||||
|
||||
// One row per distinct piece of text, worst viewport kept.
|
||||
const byKey = new Map();
|
||||
for (const row of rows) {
|
||||
const key = `${row.cls}|${row.px}|${row.weight}|${row.text}`;
|
||||
const prev = byKey.get(key);
|
||||
if (!prev || row.short > prev.short) byKey.set(key, row);
|
||||
}
|
||||
|
||||
const all = [...byKey.values()].sort((a, b) => b.short - a.short);
|
||||
const live = all.filter(r => !r.disabled);
|
||||
const failing = live.filter(r => r.short > 0);
|
||||
const passesAaFailsApca = failing.filter(r => r.wcag >= 4.5);
|
||||
|
||||
// Two numbers, because they answer different questions and only one of them is a
|
||||
// target anyone can hit here.
|
||||
//
|
||||
// The strict count is every piece of text below the published level for the size and
|
||||
// weight it is drawn at. APCA asks Lc 90 of anything at 12px/400 or below, and this
|
||||
// page is full of 10px and 11px labels that live inside a 58px seat puck. Reaching
|
||||
// Lc 90 there means white on black for micro-labels and a flat, shoutier page; the
|
||||
// honest way to clear it is bigger type, and bigger type is a layout change that
|
||||
// belongs to its own round. So the strict count is reported and not chased.
|
||||
//
|
||||
// The floors are what this page holds itself to: nothing below Lc 60, which is the
|
||||
// published minimum for content text that is not a column, and Lc 75 for a column of
|
||||
// body text. Those are checkable and they are the numbers that moved.
|
||||
const FLOOR = 60, BODY_FLOOR = 75;
|
||||
const belowFloor = live.filter(r => r.lc < FLOOR);
|
||||
const belowBody = live.filter(r => r.lc < BODY_FLOOR);
|
||||
const aaFails = live.filter(r => r.wcag < 4.5);
|
||||
const worst = live.reduce((min, r) => Math.min(min, r.lc), Infinity);
|
||||
|
||||
console.log(`${rows.length} readings, ${all.length} distinct pieces of text`);
|
||||
console.log(`worst contrast anywhere on the page: Lc ${worst.toFixed(1)}`);
|
||||
console.log(`below the Lc ${FLOOR} floor: ${belowFloor.length}`);
|
||||
console.log(`below Lc ${BODY_FLOOR}: ${belowBody.length}`);
|
||||
console.log(`still failing WCAG AA 4.5:1: ${aaFails.length}`);
|
||||
console.log(`below the strict APCA level for their size/weight: ${failing.length}`
|
||||
+ ` (${passesAaFailsApca.length} of them pass AA)\n`);
|
||||
|
||||
if (belowFloor.length) {
|
||||
console.log(`=== below the Lc ${FLOOR} floor ===`);
|
||||
belowFloor.sort((a, b) => a.lc - b.lc).forEach(r =>
|
||||
console.log(` Lc ${String(r.lc).padStart(5)} ${r.px}px/${r.weight} ${r.cls} "${r.text}"`));
|
||||
console.log("");
|
||||
}
|
||||
if (aaFails.length) {
|
||||
console.log("=== still failing WCAG AA ===");
|
||||
aaFails.sort((a, b) => a.wcag - b.wcag).forEach(r =>
|
||||
console.log(` ${String(r.wcag).padStart(5)}:1 ${r.px}px/${r.weight} ${r.cls} "${r.text}"`));
|
||||
console.log("");
|
||||
}
|
||||
|
||||
const show = r => ` Lc ${String(r.lc).padStart(5)} (needs ${String(r.need).padStart(2)},`
|
||||
+ ` short ${String(r.short).padStart(5)}) WCAG ${String(r.wcag).padStart(5)}:1`
|
||||
+ ` ${r.px}px/${r.weight} ${r.cls.padEnd(22)} rgb(${r.fg.join(",")}) on rgb(${r.bg.join(",")})`
|
||||
+ ` "${r.text}"`;
|
||||
|
||||
console.log("=== worst 30 ===");
|
||||
failing.slice(0, 30).forEach(r => console.log(show(r)));
|
||||
|
||||
const byClass = new Map();
|
||||
failing.forEach(r => {
|
||||
const cur = byClass.get(r.cls);
|
||||
if (!cur || r.short > cur.short) byClass.set(r.cls, r);
|
||||
});
|
||||
console.log(`\n=== ${byClass.size} distinct classes below level ===`);
|
||||
[...byClass.values()].sort((a, b) => b.short - a.short)
|
||||
.forEach(r => console.log(` ${String(r.short).padStart(5)} short ${r.px}px/${r.weight} ${r.cls}`));
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
+325
-52
@@ -28,32 +28,88 @@ const VIEWPORTS = [
|
||||
const OVERLAYS = [
|
||||
["rulings", () => document.getElementById("openRulingsBtn").click()],
|
||||
["seat menu", () => SeatMenu.open(HoldemState.load(), {}, 0)],
|
||||
["card picker", () => { document.getElementById("cardPickerOverlay").hidden = false; }],
|
||||
// Opened the way a dealer opens it, with cards already out. This used to un-hide the
|
||||
// overlay and nothing else, so the grid inside it was whatever `paint()` had last
|
||||
// drawn — which on a freshly loaded page is nothing at all. For the whole life of
|
||||
// this sweep the card picker was audited as an empty sheet: no slots, no fifty-two
|
||||
// targets, no dealt cards, none of the three things it is made of.
|
||||
["card picker", () => {
|
||||
const state = HoldemState.load();
|
||||
const playing = state.seats.filter(seat => seat.inHand);
|
||||
if (playing[0]) playing[0].cards = ["AS", "KH"];
|
||||
if (playing[1]) playing[1].cards = ["QD", "QC"];
|
||||
state.board = ["7C", "2H", "TS", "", ""];
|
||||
const empty = playing[2] || playing[0];
|
||||
CardPicker.open(state, { onPick: () => {}, onDone: () => {} }, state.seats.indexOf(empty), 0);
|
||||
// And with its answer showing. The line that says where a dealt card went is the
|
||||
// one piece of this sheet that is empty until something is pressed, so pressing
|
||||
// one is the only way the sweep ever sees it holding text.
|
||||
const out = document.querySelector('#cardPickerGrid .pickerCard[aria-disabled="true"]');
|
||||
if (out) out.click();
|
||||
}],
|
||||
["add chips", () => AddChips.open(HoldemState.load(), 0, () => {})],
|
||||
["pot wizard", () => document.getElementById("resolvePotBtn").click()],
|
||||
];
|
||||
|
||||
// A table part-way through a real night: long names, uneven stacks, a hand log.
|
||||
function seedTable() {
|
||||
//
|
||||
// Two of them. The sweep ran on one table shape for its whole life — five players at a
|
||||
// nine-handed table — and how much room the felt has depends on how many people are
|
||||
// sitting down more than on anything a viewport can tell you. A full table is where the
|
||||
// seats crowd, and it was never once looked at: measured when it finally was, seventeen
|
||||
// of fifty-four tables had one player's name or stack painted under another.
|
||||
const PACKED = [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||
["Bob", 250], ["Dee", 600], ["Sam", 900], ["Priya", 1100], ["Jonathan", 480],
|
||||
["Al", 2000], ["Katarzyna", 340], ["Ted", 760], ["Wes", 1300]];
|
||||
|
||||
const TABLES = [
|
||||
["half full", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||
["Bob", 250], ["Dee", 600]], false],
|
||||
["packed", 12, PACKED],
|
||||
// The same twelve, every one of them all-in for a different amount. This is the
|
||||
// shape that makes a pot layer per player, and the sweep had never reached it:
|
||||
// every table here was driven with Call and Check, so the side-pot breakdown — the
|
||||
// one thing on the bar whose height depends on the table — was empty at all 294
|
||||
// audits. Measured when it finally was: the bar took 411px of a 640px phone and
|
||||
// left 96px of felt for twelve seats.
|
||||
["all in", 12, PACKED, false, 12],
|
||||
// The half-full table again, with the board turned on and five cards on it. The
|
||||
// board is furniture in the middle of the felt that nothing else was ever measured
|
||||
// against — and where the middle cannot hold it, it moves to a strip above the
|
||||
// table and takes the felt's height with it, which is the seats' problem too.
|
||||
//
|
||||
// Deliberately the same five players in the same nine chairs as the shape above, so
|
||||
// the only thing that differs between them is the board. A seventh player would have
|
||||
// told us that a seven-handed table crowds, which is true, already known, and not
|
||||
// what this row is here to find out.
|
||||
["board", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||
["Bob", 250], ["Dee", 600]], true],
|
||||
];
|
||||
|
||||
function seedTable([seats, players, board]) {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
[["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], ["Bob", 250], ["Dee", 600]]
|
||||
.forEach(([name, cash], i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].buyIns = cash;
|
||||
state.seats[i].stack = cash;
|
||||
});
|
||||
HoldemState.resizeSeats(state, seats);
|
||||
players.forEach(([name, cash], i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].buyIns = cash;
|
||||
state.seats[i].stack = cash;
|
||||
});
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
for (let hand = 1; hand <= 4; hand++) {
|
||||
state.handHistory.push({
|
||||
handNumber: hand, timestamp: Date.now(), pot: 120,
|
||||
payouts: [{ id: state.seats[hand % 5].id, label: "P", amount: 120 }],
|
||||
ledger: state.seats.slice(0, 5).map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
|
||||
payouts: [{ id: state.seats[hand % players.length].id, label: "P", amount: 120 }],
|
||||
ledger: state.seats.slice(0, players.length)
|
||||
.map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
|
||||
});
|
||||
}
|
||||
state.handNumber = 5;
|
||||
if (board) {
|
||||
state.trackBoard = true;
|
||||
state.board = ["KD", "9C", "4S", "AH", "2H"];
|
||||
}
|
||||
HoldemState.save(state);
|
||||
}
|
||||
|
||||
@@ -68,57 +124,274 @@ async function main() {
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const failures = [];
|
||||
const warnings = [];
|
||||
let checks = 0;
|
||||
|
||||
for (const [width, height, label] of VIEWPORTS) {
|
||||
await page.setViewportSize({ width, height });
|
||||
// Three passes over the same surfaces.
|
||||
//
|
||||
// A mouse and a thumb, because the 44px rule stands itself down where `hover: hover`
|
||||
// says there's a pointer, and a headless desktop browser is a pointer — so a single
|
||||
// pass has never once measured a touch target, however many viewports it visited.
|
||||
//
|
||||
// And a thumb on a phone whose owner has turned the text up. Every length in this
|
||||
// stylesheet that isn't a structural clearance is in `rem`, which is the right way
|
||||
// round and means the whole page follows the browser's font size — including out of
|
||||
// the side of the window, which is what it was doing: at 20px, which is the 125%
|
||||
// an Android text-size slider gives you, "Resolve Pot" ran 23px off the right of a
|
||||
// 360px phone and took the document's scroll width with it. Every check in the
|
||||
// audit already knew how to catch that; not one of them had ever been run at a size
|
||||
// anybody actually sets.
|
||||
//
|
||||
// One shape it is not held to, and the arithmetic for why. Twelve players all-in on
|
||||
// a 360x640 phone is the tallest the action bar ever gets — the status block and the
|
||||
// pot breakdown both grow with the text — and at 20px it stands 283px of the 640,
|
||||
// which with the header leaves the felt 290x163 where it had 296x262. Twelve pucks
|
||||
// 44px wide (the floor a thumb needs; there is nothing narrower to go to) and 62px
|
||||
// tall do not fit around an ellipse 163px deep: two per side is all the height there
|
||||
// is, and the sweep reports it as seats 9, 10 and 11 written across each other.
|
||||
//
|
||||
// That is a geometry with no answer rather than a fault with a fix, so it is
|
||||
// reported as a note every run instead of a failure every run. A sweep that is
|
||||
// always red stops being read — the same reasoning as the 30% furniture warning.
|
||||
//
|
||||
// The packed table is the same arithmetic and was only ever passing by luck.
|
||||
// Measured on the same 360x640 phone at 20px, with nobody all-in: the felt is
|
||||
// 330x294, the pucks are 44x63, and five of them sit down each side — 315px of puck
|
||||
// into 294px of felt, with the pitch between neighbours at 35px and 51px against a
|
||||
// puck 63px tall. They overlap by construction, worst case 12px, and they always
|
||||
// have. What kept the sweep green was the hit-test probes landing in the gaps
|
||||
// between the glyphs; a change anywhere that moves the ring by three pixels moves
|
||||
// the probes onto the overlap and the run goes red for something nothing in the
|
||||
// change touched. Twice this round, from a stylesheet reset and from a sidebar
|
||||
// panel getting shorter.
|
||||
//
|
||||
// So both twelve-handed shapes are notes at this text size rather than failures,
|
||||
// for the same stated reason. Not an exemption for the felt in general: at 16px the
|
||||
// same table is held to the same standard as everything else, and the two passes
|
||||
// above it are unchanged.
|
||||
const CROWDED_BEYOND_HELP = ["all in", "packed"];
|
||||
const LARGE_TEXT_PX = 20;
|
||||
const PHONES = VIEWPORTS.filter(([w, h]) => Math.min(w, h) <= 430);
|
||||
const PASSES = [
|
||||
{ hand: "mouse", touch: false, viewports: VIEWPORTS },
|
||||
{ hand: "touch", touch: true, viewports: VIEWPORTS },
|
||||
{ hand: "large text", touch: true, viewports: PHONES, rootPx: LARGE_TEXT_PX, soft: CROWDED_BEYOND_HELP },
|
||||
];
|
||||
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.evaluate(seedTable);
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.click("#nextHandBtn");
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
|
||||
const table = await page.evaluate(() => LayoutAudit.runAll());
|
||||
checks++;
|
||||
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
|
||||
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
|
||||
|
||||
for (const [name, open] of OVERLAYS) {
|
||||
await page.evaluate(open);
|
||||
await page.waitForTimeout(180);
|
||||
checks++;
|
||||
const result = await page.evaluate(() => {
|
||||
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
|
||||
if (!overlay) return { offenders: [] };
|
||||
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
|
||||
});
|
||||
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
|
||||
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
|
||||
for (const { hand, touch, viewports, rootPx, soft } of PASSES) {
|
||||
const context = await browser.newContext(touch
|
||||
? { hasTouch: true, isMobile: true, deviceScaleFactor: 3 }
|
||||
: {});
|
||||
// Before any of the page's own scripts, so its listener is registered first and
|
||||
// the size is in place before main.js measures anything. A stylesheet rather
|
||||
// than an inline style because that is what a browser font-size setting is: the
|
||||
// root's used size, from first paint, not something applied afterwards.
|
||||
if (rootPx) {
|
||||
await context.addInitScript((px) => {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `html { font-size: ${px}px }`;
|
||||
document.head.appendChild(style);
|
||||
}, { once: true });
|
||||
}, rootPx);
|
||||
}
|
||||
}
|
||||
const page = await context.newPage();
|
||||
|
||||
for (const [width, height, label] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
|
||||
await page.evaluate(() => {
|
||||
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
|
||||
if (button) button.click();
|
||||
});
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
checks++;
|
||||
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
|
||||
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
|
||||
for (const [width, height, rawLabel] of viewports) {
|
||||
await page.setViewportSize({ width, height });
|
||||
|
||||
for (const [shape, seats, players, board, shoves] of TABLES) {
|
||||
const label = `${rawLabel} · ${shape} · ${hand}`;
|
||||
// Measured either way; where the shape has no answer at this text size,
|
||||
// said in the notes rather than counted against the run.
|
||||
const record = (where, offenders) => {
|
||||
if (!offenders.length) return;
|
||||
if (soft && soft.includes(shape)) warnings.push(`${where}: ${offenders.join("; ")}`);
|
||||
else failures.push({ where, offenders });
|
||||
};
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.evaluate(seedTable, [seats, players, board]);
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.click("#nextHandBtn");
|
||||
await page.waitForTimeout(300);
|
||||
// Deal it, and get chips in front of a few players. The sweep used to
|
||||
// audit a table that had never had a bet on it, so the bets layer was
|
||||
// empty at every viewport it visited and nothing it drew was ever looked
|
||||
// at — which is a third of what is on the felt during a hand.
|
||||
await page.evaluate(() => {
|
||||
const deal = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /post blinds/i.test(b.textContent));
|
||||
if (deal) deal.click();
|
||||
});
|
||||
await page.waitForTimeout(220);
|
||||
// Everyone's whole stack in, which is what stacks up the side pots.
|
||||
// There is no one-tap shove: a player who can't cover the bet gets
|
||||
// "Call all-in", and anyone else has to open the raise pad, take its
|
||||
// All-in preset and confirm.
|
||||
for (let i = 0; i < (shoves || 0); i++) {
|
||||
const step = await page.evaluate(() => {
|
||||
const find = re => [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => !b.disabled && re.test(b.textContent.trim()));
|
||||
const short = find(/^Call all-in/);
|
||||
if (short) { short.click(); return "done"; }
|
||||
const open = find(/^Raise|^Bet/);
|
||||
if (!open) return null;
|
||||
open.click();
|
||||
return "pad";
|
||||
});
|
||||
if (!step) break;
|
||||
await page.waitForTimeout(120);
|
||||
if (step === "pad") {
|
||||
await page.evaluate(() => {
|
||||
const find = re => [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => !b.disabled && re.test(b.textContent.trim()));
|
||||
const max = find(/^All-in/);
|
||||
if (max) max.click();
|
||||
const go = find(/^Raise to|^Bet/);
|
||||
if (go) go.click();
|
||||
});
|
||||
await page.waitForTimeout(120);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < (shoves ? 0 : 3); i++) {
|
||||
const acted = await page.evaluate(() => {
|
||||
const btn = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /^(Call|Check)/.test(b.textContent.trim()) && !b.disabled);
|
||||
if (!btn) return false;
|
||||
btn.click();
|
||||
return true;
|
||||
});
|
||||
if (!acted) break;
|
||||
await page.waitForTimeout(120);
|
||||
}
|
||||
if (board) {
|
||||
await page.evaluate(() => {
|
||||
const s = HoldemState.load();
|
||||
s.street = "river";
|
||||
s.board = ["KD", "9C", "4S", "AH", "2H"];
|
||||
s.seats.filter(p => p.inHand).forEach((p, i) => {
|
||||
p.cards = [["AS", "KC"], ["AC", "3D"], ["9H", "9S"],
|
||||
["7C", "5D"], ["JD", "TS"]][i] || ["", ""];
|
||||
});
|
||||
HoldemState.save(s);
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
|
||||
const table = await page.evaluate(() => LayoutAudit.runAll());
|
||||
checks++;
|
||||
record(`table · ${label} ${width}x${height}`, table.failed.flatMap(f => f.offenders));
|
||||
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
|
||||
|
||||
// The raise pad, which is part of the bar rather than an overlay and so
|
||||
// was never once measured by any of this. It is the tallest thing the
|
||||
// bar ever holds and the only place in the tool a dealer types an
|
||||
// amount, and it had a 36px-tall field on every phone in the list: the
|
||||
// `hover: none` rule that sizes fields for a thumb names the settings
|
||||
// panels and the seat form, and the one field on the thumb surface was
|
||||
// not in the list. Nothing here could see that, because nothing here
|
||||
// ever opened it.
|
||||
if (await page.evaluate(() => {
|
||||
const btn = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /^Raise|^Bet/.test(b.textContent.trim()) && !b.disabled);
|
||||
if (!btn) return false;
|
||||
btn.click();
|
||||
return true;
|
||||
})) {
|
||||
await page.waitForTimeout(200);
|
||||
checks++;
|
||||
const pad = await page.evaluate(() => LayoutAudit.runAll({
|
||||
occlusionSelector: "#actionBar button, #actionBar input",
|
||||
}));
|
||||
record(`raise pad · ${label} ${width}x${height}`,
|
||||
pad.failed.flatMap(f => f.offenders));
|
||||
await page.evaluate(() => {
|
||||
const cancel = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /^Cancel/.test(b.textContent.trim()));
|
||||
if (cancel) cancel.click();
|
||||
});
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
// The overlays don't change with the table's shape, so they are only
|
||||
// opened over the half-full one — twice the viewports is enough time
|
||||
// spent already.
|
||||
if (shape !== TABLES[0][0]) continue;
|
||||
|
||||
// The whole audit, not the one check.
|
||||
//
|
||||
// This ran `occlusion()` and nothing else — one rule of twenty-five — so
|
||||
// for the sweep's entire life the five sheets a dealer spends half the
|
||||
// night in had never been measured for touch targets, contrast, control
|
||||
// names, text spill or anything else. The three rules that made that a
|
||||
// reasonable thing to do are modal-aware now: two of them ask about the
|
||||
// fixed bar, which is not a question while a scrim is covering it, and
|
||||
// the third called every card below the fold of a sheet that scrolls an
|
||||
// unreachable button.
|
||||
for (const [name, open] of OVERLAYS) {
|
||||
await page.evaluate(open);
|
||||
await page.waitForTimeout(180);
|
||||
checks++;
|
||||
const result = await page.evaluate(() => {
|
||||
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
|
||||
const sheet = LayoutAudit.runAll({
|
||||
occlusionSelector: overlay
|
||||
? `#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`
|
||||
: undefined,
|
||||
});
|
||||
return { offenders: sheet.failed.flatMap(f => f.offenders), warnings: sheet.warnings };
|
||||
});
|
||||
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
|
||||
record(`${name} · ${label} ${width}x${height}`, result.offenders);
|
||||
result.warnings.forEach(w => warnings.push(`${name} · ${label}: ${w}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [width, height, rawLabel] of viewports.filter((_, i) => i % 4 === 0)) {
|
||||
const label = `${rawLabel} · ${hand}`;
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
|
||||
// Both halves of the screen. The sweep used to switch straight to the all-in
|
||||
// mode and audit only that, so the mode the screen opens in — the common one,
|
||||
// a pot and a number of ways — was never looked at once, which is how a bar
|
||||
// with 252px of nothing in it lasted this long. The ways stepper only exists
|
||||
// past five, so the even pass opens it before measuring.
|
||||
const modes = [
|
||||
["even", () => {
|
||||
const beyond = [...document.querySelectorAll("#waysChoices .choiceChip")]
|
||||
.find(b => /\+$/.test(b.textContent.trim()));
|
||||
if (beyond) beyond.click();
|
||||
}],
|
||||
["all-in", () => {
|
||||
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
|
||||
if (button) button.click();
|
||||
}],
|
||||
];
|
||||
|
||||
for (const [mode, arrange] of modes) {
|
||||
await page.evaluate(arrange);
|
||||
await page.waitForTimeout(120);
|
||||
checks++;
|
||||
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
|
||||
if (!result.clean) failures.push({ where: `quick split · ${mode} · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
|
||||
result.warnings.forEach(w => warnings.push(`quick split · ${mode} · ${label}: ${w}`));
|
||||
}
|
||||
}
|
||||
|
||||
await context.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log(`${checks} audits across ${VIEWPORTS.length} viewports`);
|
||||
console.log(`${checks} audits across ${VIEWPORTS.length} viewports — with a mouse, with a thumb, ` +
|
||||
`and with a thumb at ${LARGE_TEXT_PX}px of root text on ${PHONES.length} phones`);
|
||||
warnings.forEach(w => console.log(` note: ${w}`));
|
||||
if (failures.length === 0) {
|
||||
console.log("clean");
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// Whether two seats can end up wanting the same pixels.
|
||||
//
|
||||
// The felt used to decide this from one number: the straight-line distance between the
|
||||
// closest two *neighbouring* seats, judged against a fraction of a puck's width. Both
|
||||
// halves of that were wrong, and the running page showed it. Measured across nine
|
||||
// viewports and six table sizes:
|
||||
//
|
||||
// seventeen of fifty-four tables had one seat's name or stack painted under another
|
||||
// ten of them had the collision between two seats that are not neighbours
|
||||
//
|
||||
// The second number is the interesting one. On a phone held sideways the felt is 528px
|
||||
// wide and 87 tall, the ring is very nearly a straight line, and the seat that lands on
|
||||
// top of you is the one across the table — seats 1 and 7, overlapping by 68px, while
|
||||
// every neighbouring pair was fine and the check said the table was clean.
|
||||
//
|
||||
// The first is what a distance test can't fix even looking at every pair: distance has
|
||||
// one number in it, and a puck is half again as wide as it is tall. 84px between centres
|
||||
// is generous side by side and nowhere near enough on the diagonal.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Render } = require("./load");
|
||||
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
describe("two boxes on a ring", () => {
|
||||
// A phone held sideways, once the action bar has taken its share: a felt far wider
|
||||
// than it is tall, on which the ring is very nearly a straight line.
|
||||
const felt = { width: 400, height: 70 };
|
||||
const flat = { rx: 44, ry: 42 };
|
||||
|
||||
test("a pair that is not neighbours still counts", () => {
|
||||
// Eight seats, a 48x60 puck. Every neighbouring pair is clear. Seats 1 and 5 are
|
||||
// on top of each other, and so are 2 and 4, and 6 and 8 — the seat that lands on
|
||||
// you here is the one across the table, because the rim doubles back at the ends
|
||||
// of a shape this flat and a long walk apart is no distance at all.
|
||||
assert.equal(Render.seatsCollide(8, felt, flat, 48, 60), true);
|
||||
|
||||
const neighbours = (count, w, h) => {
|
||||
const points = Render.ringPositions(count, flat.rx, flat.ry, felt.width / felt.height)
|
||||
.map(s => ({ x: (s.left / 100) * felt.width, y: (s.top / 100) * felt.height }));
|
||||
return points.some((point, i) => {
|
||||
const next = points[(i + 1) % points.length];
|
||||
return Math.abs(point.x - next.x) < w && Math.abs(point.y - next.y) < h;
|
||||
});
|
||||
};
|
||||
assert.equal(neighbours(8, 48 + Render.SEAT_CLEARANCE_PX, 60 + Render.SEAT_CLEARANCE_PX), false,
|
||||
"no neighbouring pair overlaps, which is exactly why this had to look at all of them");
|
||||
});
|
||||
|
||||
test("and the answer is the box, not a radius", () => {
|
||||
// Two seats 60px apart across and 35px down, with a 64x40 puck. The distance
|
||||
// between them is 69px, so the test this replaces — "are these two further apart
|
||||
// than a puck is wide?" — called it clear. The boxes overlap by 4px by 5px.
|
||||
const boxes = { width: 200, height: 100 };
|
||||
const twoSeats = (dx, dy) => {
|
||||
const original = Render.ringPositions;
|
||||
Render.ringPositions = () => [
|
||||
{ left: 50 - (dx / 2 / boxes.width) * 100, top: 50 - (dy / 2 / boxes.height) * 100 },
|
||||
{ left: 50 + (dx / 2 / boxes.width) * 100, top: 50 + (dy / 2 / boxes.height) * 100 },
|
||||
];
|
||||
try {
|
||||
return Render.seatsCollide(2, boxes, flat, 64, 40, 0);
|
||||
} finally {
|
||||
Render.ringPositions = original;
|
||||
}
|
||||
};
|
||||
assert.ok(Math.hypot(60, 35) > 64, "and a distance test would have passed it");
|
||||
assert.equal(twoSeats(60, 35), true, "60 across and 35 down, with a 64x40 puck");
|
||||
assert.equal(twoSeats(60, 45), false, "far enough apart vertically");
|
||||
assert.equal(twoSeats(66, 10), false, "far enough apart horizontally");
|
||||
});
|
||||
|
||||
test("with daylight, so rounded corners don't read as touching", () => {
|
||||
assert.ok(Render.SEAT_CLEARANCE_PX > 0);
|
||||
});
|
||||
|
||||
test("a table nobody is sitting at can't collide with itself", () => {
|
||||
assert.equal(Render.seatsCollide(1, felt, flat, 96, 60), false);
|
||||
assert.equal(Render.seatsCollide(9, null, flat, 96, 60), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the widths the felt may choose from", () => {
|
||||
test("run widest first, because the widest that fits is the one wanted", () => {
|
||||
const widths = Render.seatWidthCandidates();
|
||||
widths.slice(1).forEach((w, i) => assert.ok(w < widths[i], `${widths[i]} then ${w}`));
|
||||
});
|
||||
|
||||
test("and span the whole declared range", () => {
|
||||
const widths = Render.seatWidthCandidates();
|
||||
assert.equal(widths[0], Render.SEAT_WIDTH_MAX);
|
||||
assert.equal(widths[widths.length - 1], Render.SEAT_WIDTH_MIN);
|
||||
});
|
||||
|
||||
test("the floor is still a name, not a smudge", () => {
|
||||
// At 44px the tight layout shows about five characters and an ellipsis. Below
|
||||
// that a puck stops telling the dealer who is sitting there, which is the one
|
||||
// thing on the felt it exists to say.
|
||||
assert.ok(Render.SEAT_WIDTH_MIN >= 44);
|
||||
assert.ok(Render.SEAT_TIGHT_BELOW_PX > Render.SEAT_WIDTH_MIN);
|
||||
assert.ok(Render.SEAT_TIGHT_BELOW_PX < Render.SEAT_WIDTH_MAX);
|
||||
});
|
||||
|
||||
test("and there are few enough of them to measure after every tap", () => {
|
||||
// Each one costs a forced layout. Ten is the budget; the cache carries the rest.
|
||||
assert.ok(Render.seatWidthCandidates().length <= 12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the stylesheet has to keep holding up", () => {
|
||||
test("the puck's width comes from the property, not from a breakpoint", () => {
|
||||
// Three media queries used to declare it — 78px, 64px and 62px — three guesses
|
||||
// at a number that depends on how many people are sitting down, which no media
|
||||
// query can see. The same 390px phone has room for 90px at four seats and 61 at
|
||||
// nine.
|
||||
const declarations = CSS.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
const seatRules = [...declarations.matchAll(/(^|[,}])\s*(\.seat)\s*\{([^}]*)\}/g)]
|
||||
.map(m => m[3]);
|
||||
const hardWidths = seatRules
|
||||
.flatMap(body => body.match(/(?<![\w-])width:\s*[^;]+/g) || [])
|
||||
.filter(decl => !decl.includes("var(--seatPuckW"));
|
||||
assert.deepEqual(hardWidths, []);
|
||||
});
|
||||
|
||||
test("the puck measures what it says it measures", () => {
|
||||
// As content-box a 64px puck came out 74 across once its padding and border were
|
||||
// counted, and the search compares that number against distances on the felt.
|
||||
const rule = CSS.match(/\n\.seat \{[^}]*\}/);
|
||||
assert.ok(rule, ".seat rule not found");
|
||||
assert.match(rule[0], /box-sizing:\s*border-box/);
|
||||
});
|
||||
|
||||
test("and a blind badge never reaches sideways off its own puck", () => {
|
||||
// This is what makes the footprint the puck and nothing more. The badge's
|
||||
// remaining overhang is vertical, and it points away from the table at both ends
|
||||
// — `top` on the upper arc, `bottom` on the lower — so two seats facing each
|
||||
// other across the felt wear their badges back to back.
|
||||
//
|
||||
// It was not always so: at `left: -6px` the small blind hung off the puck's left
|
||||
// corner, and on an iPhone Pro Max at six seats — pucks not touching at all —
|
||||
// "SB" was painted across the next player's $1,137.
|
||||
const badges = [...CSS.matchAll(/\.(sb|bb|str)Badge\s*\{([^}]*)\}/g)];
|
||||
assert.equal(badges.length, 3, "expected all three blind badges");
|
||||
badges.forEach(([, which, body]) => {
|
||||
const left = body.match(/(?<![\w-])left:\s*(-?[\d.]+)px/);
|
||||
assert.ok(left, `.${which}Badge should place itself horizontally`);
|
||||
assert.ok(Number(left[1]) >= 0,
|
||||
`.${which}Badge sits at ${left[1]}px, off the left of its own puck`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and all three sit in the same place, so there is one spot to look at", () => {
|
||||
// Two badges in two positions means the dealer's eye has to find which of them
|
||||
// this seat is using before it can read which blind it is, forty times a night.
|
||||
// A seat only ever wears one: the blinds are different players by construction,
|
||||
// and the straddle chain starts left of the big blind and stops short of the
|
||||
// small one.
|
||||
const lefts = [...CSS.matchAll(/\.(sb|bb|str)Badge\s*\{([^}]*)\}/g)]
|
||||
.map(([, , body]) => (body.match(/(?<![\w-])left:\s*(-?[\d.]+px)/) || [])[1]);
|
||||
assert.equal(new Set(lefts).size, 1, `badges sit at ${lefts.join(", ")}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the blind badge counts", () => {
|
||||
// It didn't, for a while, and the reasoning had one hole in it. The badge hangs 9px
|
||||
// off the puck and always points away from the table — `top: -9px` on the upper arc,
|
||||
// `bottom: -9px` on the lower — so two seats facing each other across the felt wear
|
||||
// them back to back and it costs nothing. That much is true.
|
||||
//
|
||||
// What was also claimed is that two seats on the *same* arc "wear them at the same
|
||||
// height, offset by exactly as much as the pucks are". True of a straight rail,
|
||||
// false of a ring: neighbours on a curve sit at different heights, so the badge
|
||||
// pointing away from the table on one of them points at the corner of the other.
|
||||
// Measured on the running page, three tables in the sweep had a player's stack
|
||||
// painted under the next player's SB or BB — and the badge outranks everything else
|
||||
// on the felt, so what the dealer reads in that spot is the wrong number entirely.
|
||||
test("a seat needs room for the badge it might be wearing", () => {
|
||||
const puck = { width: 64, height: 40 };
|
||||
assert.deepEqual(Render.seatFootprint(puck),
|
||||
{ width: 64, height: 40 + Render.SEAT_BADGE_OVERHANG_PX });
|
||||
});
|
||||
|
||||
test("sideways it costs nothing, because the badge doesn't go sideways", () => {
|
||||
// It sits inside the puck's own width — see "all three sit in the same place"
|
||||
// above, which holds the stylesheet to it.
|
||||
assert.equal(Render.seatFootprint({ width: 64, height: 40 }).width, 64);
|
||||
});
|
||||
|
||||
test("the number here is the number the stylesheet draws", () => {
|
||||
// Two files have to agree about one distance: holdem.css hangs the badge off the
|
||||
// puck by it, render.js charges every chair in the ring for it. They are wired
|
||||
// together by nothing but this test. Drift either way is silent — too small and
|
||||
// badges land on neighbours again, too large and every puck on a nine-handed
|
||||
// phone gives up a size step for clearance nothing needs.
|
||||
const top = CSS.match(/\.blindBadge \{[^}]*?(?<![\w-])top:\s*(-?[\d.]+)px/);
|
||||
const bottom = CSS.match(/\.seat\.seatBelowCentre \.blindBadge \{[^}]*?(?<![\w-])bottom:\s*(-?[\d.]+)px/);
|
||||
assert.ok(top && bottom, "the badge should hang off both arcs");
|
||||
assert.equal(Math.abs(Number(top[1])), Render.SEAT_BADGE_OVERHANG_PX);
|
||||
assert.equal(Math.abs(Number(bottom[1])), Render.SEAT_BADGE_OVERHANG_PX,
|
||||
"the same distance at both ends, or one arc is charged for the other's badge");
|
||||
});
|
||||
|
||||
test("and the dealer chip shares that edge on exactly the same terms", () => {
|
||||
// It moved out of the puck's top-right corner and onto the rim beside the badges,
|
||||
// which is only free while it hangs off by the same 5px they do. A pixel more and
|
||||
// the ring is charging every chair for a distance it isn't measuring.
|
||||
const top = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])top:\s*(-?[\d.]+)px/);
|
||||
const bottom = CSS.match(/\.seat\.seatBelowCentre \.dealerChip \{[^}]*?(?<![\w-])bottom:\s*(-?[\d.]+)px/);
|
||||
assert.ok(top && bottom, "the chip should hang off both arcs, like the badge");
|
||||
assert.equal(Math.abs(Number(top[1])), Render.SEAT_BADGE_OVERHANG_PX);
|
||||
assert.equal(Math.abs(Number(bottom[1])), Render.SEAT_BADGE_OVERHANG_PX);
|
||||
});
|
||||
|
||||
test("and it stays inside the puck's own width, where the badges are", () => {
|
||||
// Sideways is the direction that lands on the seat next door. This chip has been
|
||||
// there: at -10px it took the centre of seat 1's taps and gave the button to seat
|
||||
// 9. `right` is a positive inset for the same reason `left: 2px` is.
|
||||
const inset = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])right:\s*(-?[\d.]+)px/);
|
||||
assert.ok(inset, "the chip should be inset from the right edge");
|
||||
assert.ok(Number(inset[1]) >= 0, "a negative inset hangs it over the neighbour");
|
||||
});
|
||||
|
||||
test("and it is short enough to clear the name it sits above", () => {
|
||||
// The tight ring is the close one: 1px of border, 3px of padding and 4px of
|
||||
// margin put the name's top 8px below the puck's, and the chip reaches
|
||||
// height - overhang below it.
|
||||
const height = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])height:\s*([\d.]+)px/);
|
||||
assert.ok(height, "the chip should have a height to check");
|
||||
assert.ok(Number(height[1]) - Render.SEAT_BADGE_OVERHANG_PX <= 8,
|
||||
"the chip dips into the name row on the tight ring");
|
||||
});
|
||||
|
||||
test("and it is counted once, not once per seat", () => {
|
||||
// seatsCollide is given one box and compares centres against it, so the
|
||||
// separation it asks for is one puck plus one badge. That is exactly right for
|
||||
// two seats on the same arc each wearing one, and generous for the far commoner
|
||||
// case where only one of the two is in the blinds at all. Counting it on both
|
||||
// sides is what made a landscape table unsatisfiable the first time this was
|
||||
// tried, back when that felt was 87px tall.
|
||||
const puck = { width: 64, height: 40 };
|
||||
const grew = Render.seatFootprint(puck).height - puck.height;
|
||||
assert.equal(grew, Render.SEAT_BADGE_OVERHANG_PX);
|
||||
assert.ok(grew < 2 * Render.SEAT_BADGE_OVERHANG_PX);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with no browser to measure", () => {
|
||||
test("nothing here throws", () => {
|
||||
assert.equal(Render.measuredSeatBox(), null);
|
||||
assert.equal(Render.seatFootprint(null), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
// What the long-press menu offers, and in what shape.
|
||||
//
|
||||
// It had grown to eleven actions drawn as eleven identical full-width bars. Measured
|
||||
// on the fullest menu the tool can build — a live seat that isn't the dealer, blinds
|
||||
// still the only money in, an override already set — the sheet came to 796px and took
|
||||
// 85% of every viewport in the sweep. It scrolled on all four, and Cancel was off the
|
||||
// bottom of the screen on all four: 79px past it on a 390px phone, 252px on a 360px
|
||||
// one, 448px in landscape. Apple puts the comfortable number for an action sheet at
|
||||
// three to five and says not to let one scroll at all.
|
||||
//
|
||||
// Nothing was cut — every one of the eleven is the only route to something, and
|
||||
// "Blinds follow the button again" is the only route in the whole tool to clearing an
|
||||
// override. They were sorted by *when* instead: what you tap with cards in the air
|
||||
// keeps the full-width bars, and the housekeeping goes to a two-column grid under a
|
||||
// caption. Same fullest menu afterwards: 650px, 77%, no scroll, and the way out is in
|
||||
// a header that can't scroll away.
|
||||
//
|
||||
// Two of these are answered by running the code — which actions a seat is offered,
|
||||
// and which group each lands in. The rest are layout decisions, checked as text
|
||||
// against the markup and the stylesheet.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Betting, HoldemState, SeatMenu, makeTable } = require("./load");
|
||||
|
||||
const PAGE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
const sheetMarkup = () => {
|
||||
const at = PAGE.indexOf(`<div id="seatMenuOverlay"`);
|
||||
assert.ok(at >= 0, "there is no seat menu overlay");
|
||||
return PAGE.slice(at, PAGE.indexOf("</div>\n\n", at));
|
||||
};
|
||||
|
||||
// The rule block for a selector, so a test asks about one rule rather than about
|
||||
// whether a string happens to appear anywhere in 6000 lines.
|
||||
const ruleFor = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
|
||||
return CSS.slice(at, CSS.indexOf("}", at));
|
||||
};
|
||||
|
||||
// The fullest menu the tool can build, aimed at seat 3.
|
||||
function fullestMenu() {
|
||||
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3, 4], stack: 500, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
state.blindOverride = { sbIndex: 1, bbIndex: 2 };
|
||||
state.waitingList = [{ id: "w1", name: "Nina" }];
|
||||
SeatMenu.state = state;
|
||||
SeatMenu.hooks = {};
|
||||
return { state, actions: SeatMenu.actionsFor(state.seats[3], 3) };
|
||||
}
|
||||
|
||||
const labels = actions => actions.map(a => a.label);
|
||||
const inGroup = (actions, group) => labels(actions.filter(a => a.group === group));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("everything the menu used to offer, it still offers", () => {
|
||||
test("all eleven are there on the fullest menu", () => {
|
||||
const { actions } = fullestMenu();
|
||||
assert.deepEqual(labels(actions).sort(), [
|
||||
"Add chips…",
|
||||
"All-in",
|
||||
"Big blind",
|
||||
"Blinds follow the button again",
|
||||
"Cash out…",
|
||||
"Edit seat…",
|
||||
"Fold",
|
||||
"Move to…",
|
||||
"Sit out",
|
||||
"Small blind",
|
||||
"The button",
|
||||
]);
|
||||
});
|
||||
|
||||
// The three that lost words lost them to the caption above them, not to brevity
|
||||
// for its own sake: "Give this seat" is what makes "Small blind" a verb, and a
|
||||
// verb that short is what fits a half-width tile on one line.
|
||||
test("the three that were shortened are the three the caption speaks for", () => {
|
||||
const { actions } = fullestMenu();
|
||||
assert.deepEqual(inGroup(actions, "position"), [
|
||||
"The button", "Small blind", "Big blind", "Blinds follow the button again",
|
||||
]);
|
||||
});
|
||||
|
||||
// This menu is the only place in the tool that offers it. If it ever stops being
|
||||
// offered here, a dealer who names a blind by hand can never take it back.
|
||||
test("the only way out of a blind override is still here", () => {
|
||||
const { actions } = fullestMenu();
|
||||
assert.ok(labels(actions).includes("Blinds follow the button again"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("a group is when you do it, not what it touches", () => {
|
||||
test("the hand keeps only what a dealer reaches for with cards on the table", () => {
|
||||
const { actions } = fullestMenu();
|
||||
assert.deepEqual(inGroup(actions, "hand"), ["Fold", "All-in"]);
|
||||
});
|
||||
|
||||
test("a folded player's one move is in the hand too", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 });
|
||||
Betting.beginHand(state);
|
||||
state.seats[2].folded = true;
|
||||
SeatMenu.state = state;
|
||||
SeatMenu.hooks = {};
|
||||
const actions = SeatMenu.actionsFor(state.seats[2], 2);
|
||||
assert.deepEqual(inGroup(actions, "hand"), ["Un-fold"]);
|
||||
});
|
||||
|
||||
// Seating the front of the queue is not housekeeping. A chair opens between two
|
||||
// hands with people watching it, and the dealer fills it then or not at all.
|
||||
test("seating the next player off the rail is a hand action", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 });
|
||||
// makeTable names every chair; an open one has nobody on it.
|
||||
state.seats[4].name = "";
|
||||
state.waitingList = [{ id: "w1", name: "Nina" }];
|
||||
SeatMenu.state = state;
|
||||
SeatMenu.hooks = {};
|
||||
const actions = SeatMenu.actionsFor(state.seats[4], 4);
|
||||
assert.deepEqual(inGroup(actions, "hand"), ["Seat Nina"]);
|
||||
});
|
||||
|
||||
test("every action lands in a group the sheet knows how to draw", () => {
|
||||
const { actions } = fullestMenu();
|
||||
const known = ["hand", ...SeatMenu.GROUPS.map(g => g.name)];
|
||||
actions.forEach(action => {
|
||||
assert.ok(known.includes(action.group),
|
||||
`"${action.label}" is in group "${action.group}", which nothing draws`);
|
||||
});
|
||||
});
|
||||
|
||||
// A caption for nothing is worse than no caption, and this group empties often:
|
||||
// past the blinds there is nothing to give a seat but the button.
|
||||
test("a group that has nothing to say is empty rather than thin", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 3);
|
||||
SeatMenu.state = state;
|
||||
SeatMenu.hooks = {};
|
||||
const actions = SeatMenu.actionsFor(state.seats[3], 3);
|
||||
assert.deepEqual(inGroup(actions, "position"), ["The button"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the one action here that is not about this seat", () => {
|
||||
// It puts the whole table's blinds back on automatic. Spanning the grid is what
|
||||
// stops it reading as a fourth position to give.
|
||||
test("the override reset spans the grid and nothing else does", () => {
|
||||
const { actions } = fullestMenu();
|
||||
assert.deepEqual(labels(actions.filter(a => a.wide)),
|
||||
["Blinds follow the button again"]);
|
||||
});
|
||||
|
||||
test("and it is drawn lighter than the Cancel underneath it", () => {
|
||||
const rule = ruleFor(".seatMenuBtn.menuTileWide");
|
||||
assert.match(rule, /grid-column:\s*1 \/ -1/);
|
||||
assert.match(rule, /border-style:\s*dashed/);
|
||||
// Lighter, not smaller. A thumb does not care how rare an action is, so it
|
||||
// keeps the 44px every other tile has rather than shrinking out of reach.
|
||||
assert.ok(!/min-height/.test(rule),
|
||||
"the wide row sets its own height, which takes it under the touch floor");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sheet a dealer reads it in", () => {
|
||||
test("the header is stuck to the top, so whose menu this is cannot scroll away", () => {
|
||||
const rule = ruleFor(".seatMenuHeader");
|
||||
assert.match(rule, /position:\s*sticky/);
|
||||
assert.match(rule, /top:\s*0/);
|
||||
// A sticky header inside a padded scroller shows the list sliding through the
|
||||
// gap either side of it unless it reaches the sheet's own edges.
|
||||
assert.match(rule, /margin:\s*0 calc\(var\(--sp-4\) \* -1\)/);
|
||||
assert.match(rule, /background:/);
|
||||
});
|
||||
|
||||
test("and the sheet keeps no padding above it to slide through", () => {
|
||||
assert.match(ruleFor("#seatMenuSheet"), /padding:\s*0 var\(--sp-4\)/);
|
||||
});
|
||||
|
||||
// NN/g is explicit that a grab handle is not a close affordance. This sheet is the
|
||||
// one that can outgrow the screen, so it is also the one that most needs a way out
|
||||
// in the part that cannot leave.
|
||||
test("there is a real close button, not just a grip", () => {
|
||||
const markup = sheetMarkup();
|
||||
assert.match(markup, /id="seatMenuCloseBtn"/);
|
||||
assert.match(markup, /aria-label="Close this menu"/);
|
||||
});
|
||||
|
||||
// Modal focuses a dialog's first control, and the first control used to be Fold.
|
||||
// The APG says to keep initial focus off the destructive choice; a close button at
|
||||
// the top of the markup is what puts it there without a special case.
|
||||
test("the first control in the sheet is the one that does nothing", () => {
|
||||
const markup = sheetMarkup();
|
||||
assert.ok(markup.indexOf("seatMenuCloseBtn") < markup.indexOf("seatMenuActions"),
|
||||
"the actions come before the close button, so focus opens on Fold again");
|
||||
});
|
||||
|
||||
// Escape finds the first [data-modal-close] in the overlay. That has to be the one
|
||||
// that shuts the sheet, not the one at the foot that steps back inside it.
|
||||
test("Escape closes the sheet rather than stepping back inside it", () => {
|
||||
const markup = sheetMarkup();
|
||||
const closer = markup.indexOf("data-modal-close");
|
||||
assert.ok(closer >= 0 && closer < markup.indexOf("seatMenuActions"),
|
||||
"Escape does not reach a closer above the action list");
|
||||
assert.ok(!/id="seatMenuCancelBtn"[^>]*data-modal-close/.test(markup),
|
||||
"the button at the foot still answers Escape, so Escape only goes back");
|
||||
});
|
||||
});
|
||||
|
||||
describe("two columns, and what they are for", () => {
|
||||
test("the housekeeping half is a grid", () => {
|
||||
assert.match(ruleFor(".seatMenuGrid"), /grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/);
|
||||
});
|
||||
|
||||
// Half the width and a step quieter, but not below the floor a fingertip needs.
|
||||
test("a tile is quieter than a bar without being smaller than a thumb", () => {
|
||||
const rule = ruleFor(".seatMenuBtn.menuTile");
|
||||
assert.match(rule, /min-height:\s*44px/);
|
||||
assert.match(rule, /font-size:\s*var\(--fs-base\)/);
|
||||
});
|
||||
|
||||
test("a bar is still a bar", () => {
|
||||
assert.match(ruleFor(".seatMenuBtn"), /min-height:\s*52px/);
|
||||
});
|
||||
|
||||
// The dashed row and the Cancel under it are both full width. Two rows of the same
|
||||
// size touching, one of which changes the table and one of which changes nothing,
|
||||
// is a mistake waiting at the end of a scroll.
|
||||
test("the last action and the way out are not two rows touching", () => {
|
||||
assert.match(ruleFor("#seatMenuCancelBtn"), /margin-top:\s*var\(--sp-3\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the move picker is no longer a one-way door", () => {
|
||||
const MAIN = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "main.js"), "utf8");
|
||||
|
||||
// Opening it used to replace the whole sheet, and the only control left closed the
|
||||
// menu outright — a dealer who opened the seat list by mistake lost their place.
|
||||
test("the button at the foot goes back while there is an inside to go back to", () => {
|
||||
const wiring = MAIN.slice(MAIN.indexOf('getElementById("seatMenuCancelBtn")'));
|
||||
const body = wiring.slice(0, wiring.indexOf("});"));
|
||||
assert.match(body, /SeatMenu\.mode === "move"/);
|
||||
assert.match(body, /SeatMenu\.closeMovePicker\(\)/);
|
||||
assert.match(body, /SeatMenu\.close\(\)/);
|
||||
});
|
||||
|
||||
test("and the menu knows how to come back", () => {
|
||||
assert.equal(typeof SeatMenu.closeMovePicker, "function");
|
||||
SeatMenu.mode = "move";
|
||||
SeatMenu.state = { seats: [] };
|
||||
SeatMenu.seatIndex = 0;
|
||||
// render() needs a document, so the mode change is checked where it is made.
|
||||
assert.match(SeatMenu.closeMovePicker.toString(), /this\.mode = null/);
|
||||
});
|
||||
|
||||
test("every other chair is a destination, including the empty ones", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
SeatMenu.state = state;
|
||||
const seats = SeatMenu.destinationsFor(2).map(d => d.seat);
|
||||
assert.deepEqual(seats, [0, 1, 3, 4, 5]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
// What the ring is allowed to be sized from.
|
||||
//
|
||||
// resolveSeatLayout buys its answer with ten forced layouts — ten candidate widths, each
|
||||
// applied to the felt and read back — and caches it against this signature. So the
|
||||
// signature has to move whenever a puck's height can, and must not move otherwise: one
|
||||
// that changed on every action would throw the cache away on every tap, which is the
|
||||
// whole cost it exists to avoid.
|
||||
//
|
||||
// It used to name four things: every player's name, the dealer's seat, whether anybody
|
||||
// had hole cards, and whether anybody had chips out. Three of those flip during an
|
||||
// ordinary hand. A dealer calling a bet paid ten forced layouts for it, and so did the
|
||||
// next street closing, and so did editing a name.
|
||||
//
|
||||
// None of them can change a puck's height any more. The stylesheet holds three rows open
|
||||
// — name, amount, stack — at a height that does not depend on what is written in them:
|
||||
// the name gets one line whatever it says, the amount pill keeps its row when it is
|
||||
// empty, and the dealer's chip changes a padding rather than a line count. What is left
|
||||
// is the hole-card row, which still collapses, because whether a table tracks hole cards
|
||||
// is something it decides once rather than something that changes under a thumb.
|
||||
//
|
||||
// The other half of that bargain is in renderSeats: it measures the finished pucks
|
||||
// against the box the search settled on and asks again if they disagree. That is what
|
||||
// makes a wrong assumption here a stale frame rather than a stale session — and it is
|
||||
// what catches the first render of all, where the pucks are built empty and the search
|
||||
// that used to run against them was measuring nine blank boxes.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render, HoldemState, makeTable } = require("./load");
|
||||
|
||||
// seatShapeSignature reads Render.state, so a table has to be hung on it first.
|
||||
const signatureOf = state => {
|
||||
const before = Render.state;
|
||||
Render.state = state;
|
||||
try { return Render.seatShapeSignature(); } finally { Render.state = before; }
|
||||
};
|
||||
|
||||
const table = () => makeTable({ seatCount: 6, stack: 200 });
|
||||
|
||||
describe("the signature the ring layout is cached against", () => {
|
||||
test("moves when a hole card is entered", () => {
|
||||
// The one row still allowed to collapse, and the one thing still worth
|
||||
// re-measuring for: a puck with cards on it is about 15px taller.
|
||||
const state = table();
|
||||
const none = signatureOf(state);
|
||||
state.seats[0].cards = ["AS", "KC"];
|
||||
assert.notEqual(signatureOf(state), none);
|
||||
});
|
||||
|
||||
test("but not again for the second player to be dealt one", () => {
|
||||
// The point of a boolean over the table. The ring is sized from the tallest puck
|
||||
// on it, so once anybody has cards the answer cannot change.
|
||||
const state = table();
|
||||
state.seats[0].cards = ["AS", "KC"];
|
||||
const first = signatureOf(state);
|
||||
state.seats[1].cards = ["2H", "7D"];
|
||||
assert.equal(signatureOf(state), first);
|
||||
});
|
||||
|
||||
test("and it stands still while the chips move", () => {
|
||||
// The expensive one. Somebody has money out for most of every hand and which
|
||||
// seats it is changes on every action, so this used to be a ten-layout re-search
|
||||
// twice a hand — and the pucks it was re-measuring for are the same height now
|
||||
// whether there is an amount in them or not.
|
||||
const state = table();
|
||||
const quiet = signatureOf(state);
|
||||
state.seats[1].committed = 10;
|
||||
assert.equal(signatureOf(state), quiet,
|
||||
"the amount pill holds its row whether or not it has an amount in it");
|
||||
state.seats[2].committed = 250;
|
||||
assert.equal(signatureOf(state), quiet);
|
||||
});
|
||||
|
||||
test("nor when an amount merely changes size", () => {
|
||||
const state = table();
|
||||
state.seats[1].committed = 10;
|
||||
const small = signatureOf(state);
|
||||
state.seats[1].committed = 999999;
|
||||
assert.equal(signatureOf(state), small,
|
||||
"a wider number is the amount fitter's problem, not the ring's");
|
||||
});
|
||||
|
||||
test("and it stands still for a rename", () => {
|
||||
// Renaming used to be able to wrap a name onto a second line. A name gets one
|
||||
// line now, and one that does not fit drops a word or steps its type down — see
|
||||
// fitSeatNames. Neither moves the row it sits in.
|
||||
const state = table();
|
||||
const base = signatureOf(state);
|
||||
state.seats[0].name = "Bartholomew Fairweather";
|
||||
assert.equal(signatureOf(state), base);
|
||||
});
|
||||
|
||||
test("and for the button moving round the table", () => {
|
||||
// The chip in the puck's corner takes 22px off the name's line. That is a
|
||||
// narrower name, not a taller puck, so the ring it sits on is the same ring.
|
||||
const state = table();
|
||||
const base = signatureOf(state);
|
||||
state.dealerSeatIndex = (state.dealerSeatIndex + 1) % state.seats.length;
|
||||
assert.equal(signatureOf(state), base);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a table nobody has sat down at", () => {
|
||||
test("carries no flag at all", () => {
|
||||
// The empty-table case is the one the collapse is most visible on, and it is
|
||||
// also the one where a stray default could quietly keep the row open forever.
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
assert.equal(signatureOf(state), "",
|
||||
"an untouched table should carry nothing");
|
||||
});
|
||||
|
||||
test("and neither does one with an ante on it", () => {
|
||||
// computeContribution answers for a seat that has never been given a stack — the
|
||||
// table's base pot stands in for it — so a table dealt with "everyone antes 5"
|
||||
// has amounts on its pucks before anybody acts. That used to have to be in here.
|
||||
// The pucks are the same height with it as without, so it does not any more.
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 6);
|
||||
state.basePotAmount = 5;
|
||||
state.seats.forEach(p => { p.inHand = true; });
|
||||
assert.equal(signatureOf(state), "");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The other half: the stylesheet has to actually hold those rows open, or the signature
|
||||
// above is a promise about a puck that doesn't exist.
|
||||
//
|
||||
// Read as text, the way the seat sheet's rules and the reduced-motion rules are. What
|
||||
// height a row is, is a decision that lives in the file — the browser is where it is
|
||||
// proved, and runUiAudit.js is what proves it there.
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
const ruleFor = selector => {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const found = CSS.match(new RegExp(`(^|[},])\\s*${escaped}\\s*\\{([^}]*)\\}`, "m"));
|
||||
return found ? found[2] : null;
|
||||
};
|
||||
|
||||
describe("every puck on the ring is the same puck", () => {
|
||||
test("each of the three rows is a row high whether or not it says anything", () => {
|
||||
// A blank label had no height at all, so the one puck with nobody in it was the
|
||||
// one puck a different size from the rest — and a seat that had put chips in was
|
||||
// a different size again from one that hadn't.
|
||||
["name", "total", "stack"].forEach(row => {
|
||||
const selector = `.seatPuck${row[0].toUpperCase()}${row.slice(1)}`;
|
||||
const body = ruleFor(selector) || "";
|
||||
assert.match(body, /min-height:\s*var\(--seatRow(Name|Money)\)/,
|
||||
`${selector} does not hold its row open`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and the row heights are tokens, not ems", () => {
|
||||
// fitAmount steps a tournament stack's type down so every digit stays on the
|
||||
// puck, and .puckNameTight does the same to a name with no word to drop. In ems
|
||||
// each of those would have taken its row down with it, and the puck with it.
|
||||
["--seatRowName", "--seatRowMoney"].forEach(token =>
|
||||
assert.match(CSS, new RegExp(`\\${token}:\\s*\\d+px`), `${token} is not a fixed height`));
|
||||
});
|
||||
|
||||
test("the amount pill hides its paint rather than giving up its place", () => {
|
||||
// Collapsing it is what made a puck grow 17px when its player called and shrink
|
||||
// again when the street closed. Painting an empty pill is what collapsing it was
|
||||
// introduced to stop — an 8x11px green lozenge on seven of nine seats, standing
|
||||
// for nothing. Neither, now: the row stays and the pill is not drawn.
|
||||
assert.match(ruleFor(".seatPuckTotal:empty") || "", /visibility:\s*hidden/);
|
||||
assert.doesNotMatch(ruleFor(".seatPuckTotal:empty") || "", /display:\s*none/);
|
||||
});
|
||||
|
||||
test("but the hole-card row still collapses, because a table decides that once", () => {
|
||||
// Holding 15px open on nine seats all night for a row most tables never fill is
|
||||
// the crowding this is meant to avoid, and it is the one thing left that the
|
||||
// signature above still watches for.
|
||||
assert.match(ruleFor(".seatPuckCards:empty") || "", /display:\s*none/);
|
||||
});
|
||||
|
||||
test("an empty pill sits on its top edge, not on a baseline it hasn't got", () => {
|
||||
// An inline-block sits on its last line's baseline, and an empty one has no last
|
||||
// line — so it sits on its bottom edge instead and the line box comes out 2px
|
||||
// shorter than the same pill with a figure in it. That put the seats with money
|
||||
// out 2px below everyone else: the one difference this row exists to remove,
|
||||
// reappearing as its own mirror image.
|
||||
assert.match(ruleFor(".seatPuckTotal") || "", /vertical-align:\s*top/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("and the ring checks its own measurement afterwards", () => {
|
||||
// The signature above is an assumption about what can change a puck. This is what
|
||||
// happens when the assumption is wrong, and it is also what happens on the first
|
||||
// render of all: buildSeatNodes puts nine empty pucks on the felt and the search runs
|
||||
// before anything is written into them, so it was measuring nine blank boxes and
|
||||
// caching the answer under a key naming a table it had never seen.
|
||||
//
|
||||
// That used to be permanent. The comment where it was cached said "let the resize
|
||||
// that follows do this properly" — and nothing resizes a phone. A table opened
|
||||
// upright kept a ring measured for empty seats until the dealer turned the phone
|
||||
// sideways and back, which is the only thing that ever changed the key.
|
||||
const withBox = (box, run) => {
|
||||
const before = Render.measuredSeatBox;
|
||||
Render.measuredSeatBox = () => box;
|
||||
try { return run(); } finally { Render.measuredSeatBox = before; }
|
||||
};
|
||||
|
||||
test("a layout that measured nothing is always asked again", () => {
|
||||
// No box on the answer means the search never had a puck in front of it.
|
||||
assert.equal(withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved(null)), true);
|
||||
assert.equal(withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved(undefined)), true);
|
||||
});
|
||||
|
||||
test("one that measured the puck that is now on the felt is not", () => {
|
||||
assert.equal(
|
||||
withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved({ width: 72, height: 56 })),
|
||||
false);
|
||||
});
|
||||
|
||||
test("and a pixel of difference is a rounding artefact, not a table that moved", () => {
|
||||
// The two measurements are taken at different moments. Without the slack this
|
||||
// would ask the search to run again on every render for the rest of the session,
|
||||
// which is ten forced layouts a tap — the exact cost the cache exists to avoid.
|
||||
assert.equal(
|
||||
withBox({ width: 72.4, height: 56.4 }, () => Render.seatBoxMoved({ width: 72, height: 56 })),
|
||||
false);
|
||||
assert.equal(
|
||||
withBox({ width: 72, height: 58 }, () => Render.seatBoxMoved({ width: 72, height: 56 })),
|
||||
true, "two pixels taller is a puck that has changed shape");
|
||||
});
|
||||
|
||||
test("nothing on the felt to measure is not a reason to ask again", () => {
|
||||
// Between a table being resized and the new pucks being built there is a moment
|
||||
// with no seats at all. Asking again there would clear a good answer and replace
|
||||
// it with one measured from nothing.
|
||||
assert.equal(withBox(null, () => Render.seatBoxMoved({ width: 72, height: 56 })), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("how much room a figure has on the puck it is written on", () => {
|
||||
// The two steps of type an amount comes down used to be chosen by counting
|
||||
// characters: eight for the first, ten for the second. That is a guess at how wide a
|
||||
// figure is, and it happens to hold at one text size and one puck width.
|
||||
//
|
||||
// Turn a phone's text up — 20px of root text is what an Android slider gives you at
|
||||
// 125%, and every size in the stylesheet is in `rem` on purpose — and it stops
|
||||
// holding. Measured at 360x640 twelve-handed: the puck is at its 44px floor, which
|
||||
// is 34px of usable width, and "$1,200" is six characters, took no step at all, and
|
||||
// wanted 38. The sweep caught it as a stack figure painted over the seat next door
|
||||
// at every phone in the list, and as "ALL IN" doing the same on the all-in table.
|
||||
//
|
||||
// So it is measured. A cell is a stand-in for the real one: what it wants against
|
||||
// what it has, and a class list to write the answer into.
|
||||
const cell = (figure, wants, has) => ({
|
||||
dataset: { figure },
|
||||
textContent: figure,
|
||||
classes: new Set(),
|
||||
classList: {
|
||||
add(name) { this.owner.classes.add(name); },
|
||||
remove(name) { this.owner.classes.delete(name); },
|
||||
},
|
||||
// What the text wants is proportional to how many characters are in it and how
|
||||
// far down the ladder it has come — which is what a browser would tell us.
|
||||
get scrollWidth() {
|
||||
const step = this.classes.has("puckAmountHuge") ? 0.7
|
||||
: this.classes.has("puckAmountLong") ? 0.85 : 1;
|
||||
return Math.round((wants * this.textContent.length / figure.length) * step);
|
||||
},
|
||||
clientWidth: has,
|
||||
});
|
||||
|
||||
const make = (...args) => {
|
||||
const c = cell(...args);
|
||||
c.classList.owner = c;
|
||||
return c;
|
||||
};
|
||||
|
||||
const fit = cells => {
|
||||
const before = Render.overflowing;
|
||||
Render.overflowing = el => el.scrollWidth > el.clientWidth + 1;
|
||||
try { Render.fitAmounts(cells); } finally { Render.overflowing = before; }
|
||||
};
|
||||
|
||||
test("a figure that fits is left at the size it is meant to be read at", () => {
|
||||
const c = make("$600", 30, 34);
|
||||
fit([c]);
|
||||
assert.equal(c.textContent, "$600");
|
||||
assert.deepEqual([...c.classes], []);
|
||||
});
|
||||
|
||||
test("one that doesn't gives up its separators first", () => {
|
||||
// Grouping is a reading aid for a column of figures. This is one number on a
|
||||
// 44px puck, and a step of type costs more than the commas do.
|
||||
const c = make("$1,200", 38, 34);
|
||||
fit([c]);
|
||||
assert.equal(c.textContent, "$1200");
|
||||
assert.deepEqual([...c.classes], [], "no type step needed once the commas are out");
|
||||
});
|
||||
|
||||
test("then a step of type, and then another", () => {
|
||||
const c = make("$1,234,567", 90, 34);
|
||||
fit([c]);
|
||||
assert.equal(c.textContent, "$1234567");
|
||||
assert.deepEqual([...c.classes], ["puckAmountHuge"]);
|
||||
});
|
||||
|
||||
test("and only ever one step at a time is worn", () => {
|
||||
// The two rules have the same specificity, so a puck wearing both would be sized
|
||||
// by whichever the stylesheet happens to mention last.
|
||||
const c = make("$1,234,567", 45, 34);
|
||||
fit([c]);
|
||||
assert.equal(c.classes.has("puckAmountHuge") && c.classes.has("puckAmountLong"), false);
|
||||
});
|
||||
|
||||
test("a figure with no separators to give goes straight to the type", () => {
|
||||
// "ALL IN" is the one thing on this row that isn't a number, and it is the widest
|
||||
// — six characters and a space, at 700 weight with its letters spread apart.
|
||||
const c = make("ALL IN", 39, 34);
|
||||
fit([c]);
|
||||
assert.equal(c.textContent, "ALL IN");
|
||||
assert.deepEqual([...c.classes], ["puckAmountLong"]);
|
||||
});
|
||||
|
||||
test("a puck that went small can come back", () => {
|
||||
// Everything is measured from the figure and the size it is meant to be read at,
|
||||
// not from whatever the last render left behind. Otherwise this is a ratchet: one
|
||||
// crowded frame and the seat is small for the rest of the night.
|
||||
const c = make("$600", 30, 34);
|
||||
c.classes.add("puckAmountHuge");
|
||||
c.textContent = "$600";
|
||||
fit([c]);
|
||||
assert.deepEqual([...c.classes], []);
|
||||
});
|
||||
|
||||
test("and the grouped figure is what it comes back to", () => {
|
||||
const c = make("$1,200", 38, 34);
|
||||
fit([c]);
|
||||
assert.equal(c.textContent, "$1200");
|
||||
c.clientWidth = 96;
|
||||
fit([c]);
|
||||
assert.equal(c.textContent, "$1,200", "a wider puck should get its separators back");
|
||||
});
|
||||
|
||||
test("an empty row is not an amount", () => {
|
||||
// The pill keeps its place on a seat with nothing in the pot — that is what makes
|
||||
// every puck on the ring the same puck — and there is nothing there to fit.
|
||||
const c = make("", 0, 34);
|
||||
assert.doesNotThrow(() => fit([c]));
|
||||
assert.deepEqual([...c.classes], []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
// Where the seats sit on the rim.
|
||||
//
|
||||
// The ring used to space seats by angle, which is only the same as spacing them evenly
|
||||
// when the felt is a circle. On a phone held sideways the felt is 601x126, and equal
|
||||
// angles put four seats down each steep end, 20px apart, with pucks 72px tall — so a
|
||||
// seat's centre belonged to its neighbour and tapping a player got the wrong one.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render } = require("./load");
|
||||
|
||||
// The seats as a dealer sees them: pixels on a felt of a given size.
|
||||
const ringPixels = (count, width, height, rx = 44, ry = 42) => {
|
||||
const aspect = width / height;
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const pos = Render.positionOnRing(i, count, rx, ry, aspect);
|
||||
return { x: (pos.left / 100) * width, y: (pos.top / 100) * height };
|
||||
});
|
||||
};
|
||||
|
||||
const gaps = (points) => points.map((p, i) => {
|
||||
const next = points[(i + 1) % points.length];
|
||||
return Math.hypot(next.x - p.x, next.y - p.y);
|
||||
});
|
||||
|
||||
// What the ring did before: one turn cut into equal angles.
|
||||
const byAngle = (index, count, rx, ry) => {
|
||||
const radians = ((-90 + (360 / count) * index) * Math.PI) / 180;
|
||||
return { left: 50 + rx * Math.cos(radians), top: 50 + ry * Math.sin(radians) };
|
||||
};
|
||||
|
||||
describe("a round table", () => {
|
||||
test("still spaces seats by angle, because on a circle that is the same thing", () => {
|
||||
for (let seat = 0; seat < 9; seat++) {
|
||||
const now = Render.positionOnRing(seat, 9, 40, 40, 1);
|
||||
const before = byAngle(seat, 9, 40, 40);
|
||||
assert.ok(Math.abs(now.left - before.left) < 0.2, `seat ${seat} left`);
|
||||
assert.ok(Math.abs(now.top - before.top) < 0.2, `seat ${seat} top`);
|
||||
}
|
||||
});
|
||||
|
||||
test("seat one is at the top, whatever shape the felt is", () => {
|
||||
[[600, 600], [601, 126], [351, 351], [949, 683]].forEach(([w, h]) => {
|
||||
const first = Render.positionOnRing(0, 10, 44, 42, w / h);
|
||||
assert.ok(Math.abs(first.left - 50) < 0.01, `${w}x${h} left`);
|
||||
assert.ok(first.top < 50, `${w}x${h} is above the middle`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a felt wider than it is tall", () => {
|
||||
// 640x360 landscape: the size the audit was failing at.
|
||||
const wide = () => ringPixels(10, 601, 126);
|
||||
|
||||
const byAnglePixels = () => Array.from({ length: 10 }, (_, i) => {
|
||||
const pos = byAngle(i, 10, 44, 42);
|
||||
return { x: (pos.left / 100) * 601, y: (pos.top / 100) * 126 };
|
||||
});
|
||||
|
||||
// The two seats at the right-hand end, which is where they crowd.
|
||||
const endPairApart = (seats) => {
|
||||
const rightmost = [...seats].sort((a, b) => b.x - a.x).slice(0, 2);
|
||||
return Math.abs(rightmost[0].y - rightmost[1].y);
|
||||
};
|
||||
|
||||
test("pulls the pair at each end apart", () => {
|
||||
// They still share the end — ten seats on a rim this flat have nowhere else to
|
||||
// be — but they stop sitting on top of each other.
|
||||
const now = endPairApart(wide());
|
||||
const before = endPairApart(byAnglePixels());
|
||||
assert.ok(now > before * 1.4, `${now.toFixed(1)}px now, ${before.toFixed(1)}px before`);
|
||||
});
|
||||
|
||||
test("nearly doubles the room between the closest two neighbours", () => {
|
||||
// Not equal spacing in a straight line — at the ends the rim turns back on
|
||||
// itself, so the same walk along it covers less ground across. Even there the
|
||||
// gap goes from a third of a puck to most of one.
|
||||
const now = Math.min(...gaps(wide()));
|
||||
const before = Math.min(...gaps(byAnglePixels()));
|
||||
assert.ok(now > before * 1.7, `${now.toFixed(1)}px now, ${before.toFixed(1)}px before`);
|
||||
assert.ok(now > 55, `${now.toFixed(1)}px`);
|
||||
});
|
||||
|
||||
test("never sits two seats on the same spot", () => {
|
||||
const seats = wide();
|
||||
seats.forEach((seat, i) => {
|
||||
seats.slice(i + 1).forEach((other, j) => {
|
||||
const apart = Math.hypot(seat.x - other.x, seat.y - other.y);
|
||||
assert.ok(apart > 50, `seats ${i + 1} and ${i + j + 2} are ${apart.toFixed(1)}px apart`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("how close the closest two seats come", () => {
|
||||
// What decides whether the pucks shrink. Rim length per seat was the first answer
|
||||
// and it is the wrong question: it measures the walk between two seats, not the
|
||||
// gap. On a long oval the rim doubles back at the ends, so two seats a long walk
|
||||
// apart end up stacked one above the other.
|
||||
const gap = (count, width, height) => Render.closestSeatGapPx(count, { width, height });
|
||||
|
||||
test("a flat table no longer stacks two seats at each end", () => {
|
||||
// 616x96: the landscape felt on a 640x360 phone once the bar has grown. On an
|
||||
// ellipse that flat the end pair came out 25px apart with 64px pucks. The rim
|
||||
// is a stadium at this shape, so an end holds one seat.
|
||||
assert.ok(gap(6, 616, 96) > 70, `${gap(6, 616, 96).toFixed(0)}px apart`);
|
||||
assert.ok(gap(12, 616, 96) > 40, `${gap(12, 616, 96).toFixed(0)}px apart at twelve seats`);
|
||||
});
|
||||
|
||||
test("an ellipse's ends are still closer than its rim suggests, which is why the shape changes", () => {
|
||||
// Same felt, forced through the ellipse the tool uses for rounder tables.
|
||||
const asEllipse = Render.ringAngles(6, 44, 42, 616 / 96).map(angle => ({
|
||||
x: ((50 + 44 * Math.cos(angle)) / 100) * 616,
|
||||
y: ((50 + 42 * Math.sin(angle)) / 100) * 96,
|
||||
}));
|
||||
const closest = asEllipse.reduce((least, point, i) => {
|
||||
const next = asEllipse[(i + 1) % asEllipse.length];
|
||||
return Math.min(least, Math.hypot(point.x - next.x, point.y - next.y));
|
||||
}, Infinity);
|
||||
assert.ok(closest < 70, `${closest.toFixed(0)}px`);
|
||||
assert.ok(gap(6, 616, 96) > closest * 1.3,
|
||||
`stadium ${gap(6, 616, 96).toFixed(0)}px against ellipse ${closest.toFixed(0)}px`);
|
||||
});
|
||||
|
||||
test("a round table's neighbours are about as far apart as the rim says", () => {
|
||||
const rim = (Math.PI * 2 * 0.43 * 400) / 9;
|
||||
assert.ok(Math.abs(gap(9, 400, 400) - rim) < rim * 0.15);
|
||||
});
|
||||
|
||||
test("more seats, less room, in that order", () => {
|
||||
assert.ok(gap(12, 400, 400) < gap(9, 400, 400));
|
||||
assert.ok(gap(9, 400, 400) < gap(6, 400, 400));
|
||||
assert.ok(gap(6, 400, 400) < gap(6, 800, 800), "and a bigger table gives more");
|
||||
});
|
||||
|
||||
test("nothing to measure between fewer than two seats", () => {
|
||||
assert.equal(gap(1, 400, 400), Infinity);
|
||||
assert.equal(gap(9, 0, 0), Infinity);
|
||||
});
|
||||
});
|
||||
|
||||
describe("which rim a table gets", () => {
|
||||
// An ellipse until the table is much wider than tall, then a stadium: two rails
|
||||
// and a half-round cap at each end, which is how a real poker table is built.
|
||||
const shape = (width, height) => {
|
||||
const aspect = width / height;
|
||||
return 44 * aspect > 42 * Render.STADIUM_RATIO ? "stadium" : "ellipse";
|
||||
};
|
||||
|
||||
test("every layout the tool had keeps the ellipse", () => {
|
||||
assert.equal(shape(844, 608), "ellipse", "desktop");
|
||||
assert.equal(shape(600, 600), "ellipse", "tablet");
|
||||
assert.equal(shape(278, 314), "ellipse", "phone upright");
|
||||
assert.equal(shape(336, 240), "ellipse", "short phone upright");
|
||||
});
|
||||
|
||||
test("a phone on its side gets the stadium", () => {
|
||||
assert.equal(shape(528, 73), "stadium");
|
||||
assert.equal(shape(780, 113), "stadium");
|
||||
});
|
||||
|
||||
test("the cap holds one seat instead of stacking two", () => {
|
||||
// Six seats on a 616x96 rim: the walk between neighbours has to be longer than
|
||||
// the cap, or two of them end up on the same half-circle.
|
||||
const halfWidth = 44 * (616 / 96), halfHeight = 42;
|
||||
const perimeter = 4 * (halfWidth - halfHeight) + 2 * Math.PI * halfHeight;
|
||||
assert.ok(perimeter / 6 > Math.PI * halfHeight, "one seat per cap at six-handed");
|
||||
});
|
||||
|
||||
test("seat one is still at the top centre, and the ring still mirrors", () => {
|
||||
const seats = Array.from({ length: 8 }, (_, i) => Render.positionOnRing(i, 8, 44, 42, 616 / 96));
|
||||
assert.ok(Math.abs(seats[0].left - 50) < 0.01);
|
||||
assert.ok(seats[0].top < 50);
|
||||
for (let seat = 1; seat < 4; seat++) {
|
||||
const mirrored = seats[8 - seat];
|
||||
assert.ok(Math.abs((seats[seat].left - 50) + (mirrored.left - 50)) < 0.01, `seat ${seat} across`);
|
||||
assert.ok(Math.abs(seats[seat].top - mirrored.top) < 0.01, `seat ${seat} down`);
|
||||
}
|
||||
});
|
||||
|
||||
test("nobody is drawn off the felt", () => {
|
||||
Array.from({ length: 12 }, (_, i) => Render.positionOnRing(i, 12, 44, 42, 616 / 96)).forEach((seat, i) => {
|
||||
assert.ok(seat.left >= 5 && seat.left <= 95, `seat ${i + 1} across at ${seat.left.toFixed(1)}%`);
|
||||
assert.ok(seat.top >= 7 && seat.top <= 93, `seat ${i + 1} down at ${seat.top.toFixed(1)}%`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the shape of the ring", () => {
|
||||
test("is a mirror image left to right", () => {
|
||||
const seats = ringPixels(10, 601, 126);
|
||||
for (let seat = 1; seat < 5; seat++) {
|
||||
const mirrored = seats[10 - seat];
|
||||
assert.ok(Math.abs((seats[seat].x - 300.5) + (mirrored.x - 300.5)) < 0.5, `seat ${seat} across`);
|
||||
assert.ok(Math.abs(seats[seat].y - mirrored.y) < 0.5, `seat ${seat} down`);
|
||||
}
|
||||
});
|
||||
|
||||
test("holds for the smallest and largest tables the seat count allows", () => {
|
||||
[2, 3, 4, 9, 12, 24].forEach(count => {
|
||||
const seats = ringPixels(count, 601, 126);
|
||||
assert.equal(seats.length, count);
|
||||
seats.forEach((seat, i) => {
|
||||
assert.ok(Number.isFinite(seat.x) && Number.isFinite(seat.y), `${count}-handed seat ${i + 1}`);
|
||||
assert.ok(seat.x >= 0 && seat.x <= 601, `${count}-handed seat ${i + 1} is on the felt`);
|
||||
});
|
||||
assert.ok(Math.min(...gaps(seats)) > 0, `${count}-handed seats are distinct`);
|
||||
});
|
||||
});
|
||||
|
||||
test("shares the rim out equally on a round table at any size", () => {
|
||||
[3, 6, 9, 10, 24].forEach(count => {
|
||||
const spacing = gaps(ringPixels(count, 500, 500, 40, 40));
|
||||
const shortest = Math.min(...spacing);
|
||||
const longest = Math.max(...spacing);
|
||||
assert.ok(longest - shortest < 0.5,
|
||||
`${count}-handed gaps ran ${shortest.toFixed(1)}-${longest.toFixed(1)}px`);
|
||||
});
|
||||
});
|
||||
|
||||
test("asked for no seats, offers no positions", () => {
|
||||
assert.deepEqual(Render.ringAngles(0, 44, 42, 1), []);
|
||||
});
|
||||
|
||||
test("puts each bet between its own player and the pot", () => {
|
||||
// Where a dealer pushes the chips: on that player's line to the middle, short
|
||||
// of the seat.
|
||||
const bearing = (point) => Math.atan2(point.top - 50, point.left - 50);
|
||||
const outFromMiddle = (point) => Math.hypot(point.left - 50, point.top - 50);
|
||||
|
||||
[[601, 126], [810, 583], [351, 351]].forEach(([w, h]) => {
|
||||
for (let seat = 0; seat < 9; seat++) {
|
||||
const player = Render.seatPosition(seat, 9, w / h);
|
||||
const bet = Render.betPosition(seat, 9, w / h);
|
||||
const offBy = Math.abs(bearing(bet) - bearing(player)) * (180 / Math.PI);
|
||||
assert.ok(offBy < 1, `${w}x${h} seat ${seat + 1} is ${offBy.toFixed(1)}° off its spoke`);
|
||||
assert.ok(outFromMiddle(bet) < outFromMiddle(player), `${w}x${h} seat ${seat + 1} is short of the seat`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
// What the seat sheet shows before it is asked.
|
||||
//
|
||||
// The sheet is the second-most-used surface in the tool — every buy-in, every rename,
|
||||
// every correction goes through it — and it was the least finished. Seven things were
|
||||
// stacked flat at one weight and all open at once: a name, two status boxes, two
|
||||
// hole-card slots, a stack, a five-box chip counter with an override and a total, and a
|
||||
// list of custom fields. Measured on a 390px phone it came to 594px, and the three
|
||||
// controls a dealer touches on nearly every visit were 132px of that — 22%. The chip
|
||||
// counter alone was 174px, the tallest single thing in the sheet, and its five number
|
||||
// boxes were told apart by five coloured dots and a `title` attribute, which is a
|
||||
// tooltip, which is nothing at all on a phone.
|
||||
//
|
||||
// Two of these describe layout decisions and are checked as text — the markup for what
|
||||
// sits in front of a dealer at rest, the stylesheet for the scrim and the order two
|
||||
// bottom-anchored things stack in. The third describes behaviour and is run: which
|
||||
// sections stand open is a question about the seat, and the answer is computed.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Render, makeTable } = require("./load");
|
||||
|
||||
const PAGE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
const template = id => {
|
||||
const at = PAGE.indexOf(`<template id="${id}">`);
|
||||
assert.ok(at >= 0, `there is no <template id="${id}">`);
|
||||
return PAGE.slice(at, PAGE.indexOf("</template>", at));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what the sheet puts in front of a dealer at rest", () => {
|
||||
const seatForm = () => template("seatEditorTemplate");
|
||||
|
||||
// Everything the form holds that is *not* inside one of the folded sections. The
|
||||
// <details> blocks are stripped whole, so what is left is the flat part.
|
||||
const alwaysVisible = () => seatForm().replace(/<details[\s\S]*?<\/details>/g, "");
|
||||
|
||||
test("the flat part is the name, what the seat is doing and the stack, and nothing else", () => {
|
||||
const flat = alwaysVisible();
|
||||
["seatNameInput", "seatState", "seatStateOption", "stackInput", "seatBuyIns"]
|
||||
.forEach(cls => assert.ok(flat.includes(cls),
|
||||
`${cls} is what a dealer opens a seat for — it cannot be behind a disclosure`));
|
||||
|
||||
// And the four that used to be up here with them. Each is a job most tables do
|
||||
// once a night or never, and between them they were two thirds of the sheet.
|
||||
["cardSlot", "chipCounts", "amountOverrideInput", "customFields", "addFieldBtn"]
|
||||
.forEach(cls => assert.ok(!flat.includes(cls),
|
||||
`${cls} is open at rest again — it belongs inside a folded section`));
|
||||
});
|
||||
|
||||
test("every folded section says what it holds and what is in it", () => {
|
||||
const sections = [...seatForm().matchAll(/<details class="([^"]*seatSubPanel[^"]*)">([\s\S]*?)<\/details>/g)];
|
||||
assert.equal(sections.length, 3, "the sheet folds cards, contribution and notes");
|
||||
|
||||
sections.forEach(([, className, markup]) => {
|
||||
const summary = markup.match(/<summary>([\s\S]*?)<\/summary>/);
|
||||
assert.ok(summary, `${className} has no summary to press`);
|
||||
// A name a dealer would recognise, and a place for the one fact that says
|
||||
// whether opening it is worth the tap.
|
||||
assert.match(summary[1], /<span class="seatSubPanelName">[^<]+<\/span>/,
|
||||
`${className}'s summary has no name in it`);
|
||||
assert.match(summary[1], /class="seatGlance/,
|
||||
`${className} says nothing while it is shut`);
|
||||
});
|
||||
});
|
||||
|
||||
test("the chip counter's values are text, not a tooltip", () => {
|
||||
// Five identical number boxes, each worth a different amount, told apart by a
|
||||
// coloured dot and a `title`. A title is a hover, and there is no hover here.
|
||||
// It also meant the wrapping <label> had no text in it, so the boxes had no
|
||||
// accessible name either — five unnamed spinbuttons in a row.
|
||||
assert.match(template("chipCountTemplate"), /class="chipCountValue"/,
|
||||
"the chip's value has to be in the row, where it can be read and announced");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sheet is in front of the table, and says so", () => {
|
||||
test("there is a scrim, and it is furniture the desktop panel does not get", () => {
|
||||
assert.match(PAGE, /id="seatSheetScrim"/, "the sheet has nothing behind it");
|
||||
// Above 700px this element is not a scrim for anything — the seat form is a
|
||||
// panel in the sidebar column, with the whole page still live around it.
|
||||
const off = CSS.match(/\.seatSheetGrip,[\s\S]{0,120}?#seatSheetScrim\s*\{\s*display:\s*none/);
|
||||
assert.ok(off, "the scrim has to be off by default, with the sheet's other furniture");
|
||||
});
|
||||
|
||||
test("and it is not called an Overlay, because Modal would adopt it", () => {
|
||||
// Modal.mount() watches every id ending in "Overlay" and makes a dialog of it.
|
||||
// This one is a sidebar panel two thirds of the time.
|
||||
assert.ok(!/id="seatSheet[A-Za-z]*Overlay"/.test(PAGE));
|
||||
});
|
||||
|
||||
test("and it is put away with visibility, not with opacity", () => {
|
||||
// The same lesson the sheet above it already learned about transforms, and the
|
||||
// layout sweep caught it the same way: a fixed, full-window box at `opacity: 0`
|
||||
// is invisible and still entirely there. bottomFurniture() read it as furniture
|
||||
// standing 640px tall at the bottom of a 640px phone, in every state and on
|
||||
// every screen, and asked scroll-padding to reserve the whole window for it.
|
||||
const phone = CSS.slice(CSS.indexOf("@media (max-width: 700px)"));
|
||||
const rule = phone.match(/#seatSheetScrim\s*\{[^}]*\}/);
|
||||
assert.ok(rule, "the phone block should make a scrim of it");
|
||||
assert.match(rule[0], /visibility:\s*hidden/,
|
||||
"opacity alone leaves a full-window fixed element in the layout and the a11y tree");
|
||||
});
|
||||
|
||||
test("the scrim stops where the sheet stops when a phone is on its side", () => {
|
||||
// Fold, Call and Raise are a rail down the right-hand edge in landscape, and the
|
||||
// sheet is deliberately kept clear of it. A scrim that reached across would dim
|
||||
// the one part of the screen the sheet was making room for — and swallow its
|
||||
// taps, which is worse.
|
||||
const landscape = CSS.slice(CSS.indexOf("@media (orientation: landscape) and (max-height: 560px) and (max-width: 900px)"));
|
||||
const rule = landscape.match(/#seatSheetScrim\s*\{[^}]*\}/);
|
||||
assert.ok(rule, "the landscape block never tells the scrim about the rail");
|
||||
assert.match(rule[0], /right:\s*var\(--actionRailW\)/);
|
||||
});
|
||||
|
||||
test("the toast steps over the sheet, and the rule that says so comes last", () => {
|
||||
// Two things are fixed to the bottom of a phone and the toast is the one that
|
||||
// gives way. `body.hasActionBar #toastHost` and `body.seatSheetOpen #toastHost`
|
||||
// have identical specificity, so this is decided by source order and by nothing
|
||||
// else — a copy written above the other would look right and do nothing.
|
||||
const bar = CSS.indexOf("body.hasActionBar #toastHost");
|
||||
const sheet = CSS.indexOf("body.seatSheetOpen #toastHost");
|
||||
assert.ok(bar > 0 && sheet > 0, "both rules should exist");
|
||||
assert.ok(sheet > bar,
|
||||
"at equal specificity the later rule wins, and the sheet is the taller of the two");
|
||||
// Measured, not assumed: the height changes the moment a section is unfolded.
|
||||
assert.match(CSS.slice(sheet, sheet + 240), /var\(--seatSheetHeight/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The form the disclosures are rendered into, reduced to the two things
|
||||
// renderSeatDisclosures asks of it: find a section, find its glance.
|
||||
const fakeForm = () => {
|
||||
const sections = {};
|
||||
for (const name of ["cardsPanel", "contributionPanel", "notesPanel"]) {
|
||||
sections[`.${name}`] = {
|
||||
open: false,
|
||||
glance: null,
|
||||
querySelector(selector) {
|
||||
assert.equal(selector, ".seatGlance");
|
||||
return { set textContent(text) { sections[`.${name}`].glance = text; } };
|
||||
},
|
||||
};
|
||||
}
|
||||
return { querySelector: selector => sections[selector] || null, sections };
|
||||
};
|
||||
|
||||
const disclose = (state, player) => {
|
||||
const form = fakeForm();
|
||||
Render.state = state;
|
||||
Render.renderSeatDisclosures(form, player);
|
||||
return form.sections;
|
||||
};
|
||||
|
||||
describe("which sections stand open is a question about the seat", () => {
|
||||
test("a seat with nothing on it opens nothing", () => {
|
||||
const state = makeTable({ seatCount: 4, stack: 200 });
|
||||
const sections = disclose(state, state.seats[0]);
|
||||
assert.deepEqual(
|
||||
Object.values(sections).map(s => s.open), [false, false, false],
|
||||
"an empty seat's sheet is three lines and the three fields above them");
|
||||
});
|
||||
|
||||
test("a seat holding cards opens its cards, and names them while shut", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.seats[0].cards = ["as", "kd"];
|
||||
const sections = disclose(state, state.seats[0]);
|
||||
assert.equal(sections[".cardsPanel"].open, true);
|
||||
// Whatever formatCard makes of them — the point is that both are in it.
|
||||
assert.match(sections[".cardsPanel"].glance, /A/);
|
||||
assert.match(sections[".cardsPanel"].glance, /K/);
|
||||
});
|
||||
|
||||
test("a table that asked to be shown the cards opens them before the first one", () => {
|
||||
// Every seat at such a table is about to have two. Opening the slots after the
|
||||
// cards arrive would be opening them one tap too late, every hand.
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.trackBoard = true;
|
||||
assert.equal(disclose(state, state.seats[0])[".cardsPanel"].open, true);
|
||||
});
|
||||
|
||||
test("a seat with chips counted opens the count, with the total on the row", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
const fiver = state.chipLegend.find(chip => chip.value === 5);
|
||||
state.seats[0].chipCounts = { [fiver.id]: 4 };
|
||||
const sections = disclose(state, state.seats[0]);
|
||||
assert.equal(sections[".contributionPanel"].open, true);
|
||||
assert.equal(sections[".contributionPanel"].glance, "$20");
|
||||
});
|
||||
|
||||
test("a typed amount counts as something on the seat too", () => {
|
||||
// Overriding the total is the other way of answering the same question, and a
|
||||
// dealer who typed a number has to be able to see it without hunting.
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.seats[0].amountOverride = 60;
|
||||
assert.equal(disclose(state, state.seats[0])[".contributionPanel"].open, true);
|
||||
});
|
||||
|
||||
test("and a zero someone actually typed is not the same as never having typed one", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.seats[0].amountOverride = 0;
|
||||
assert.equal(disclose(state, state.seats[0])[".contributionPanel"].open, true,
|
||||
"0 is a value; only null means the field was left alone");
|
||||
});
|
||||
|
||||
test("a seat with a note on it opens the notes and counts them", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.seats[0].customFields = [{ label: "Owes", value: "$40" }, { label: "", value: "" }];
|
||||
const sections = disclose(state, state.seats[0]);
|
||||
assert.equal(sections[".notesPanel"].open, true);
|
||||
assert.equal(sections[".notesPanel"].glance, "1 note",
|
||||
"an empty row the dealer has not filled in yet is not a note");
|
||||
});
|
||||
|
||||
test("a section is only ever pushed open, never pulled shut", () => {
|
||||
// This runs again after every keystroke in the sheet. A dealer who opened the
|
||||
// chip counter to start counting would have it shut under them on the first
|
||||
// digit if this ever wrote `open = false`.
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
const form = fakeForm();
|
||||
form.sections[".notesPanel"].open = true;
|
||||
Render.state = state;
|
||||
Render.renderSeatDisclosures(form, state.seats[0]);
|
||||
assert.equal(form.sections[".notesPanel"].open, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// The seat holding the dealer button has to still be a seat you can tap.
|
||||
//
|
||||
// On a fresh table the button is on seat 1, and seat 1 is where the first player goes.
|
||||
// Measured on a 390px phone, a 72x46 puck: the dealer chip's invisible tap pad ran from
|
||||
// the puck's own centre to its right edge and from its top to 3px off its bottom. The
|
||||
// chip's click handler calls stopPropagation, so every tap that landed there reached
|
||||
// nothing at all — the seat holding the button kept 52% of its own face, 130 of 276
|
||||
// sampled points going to a chip whose only answer was "give the button to the seat
|
||||
// that has it". Seating the first player, on the seat a dealer reaches for first, did
|
||||
// nothing. Every other seat kept 97%, and the button moves every hand, so the dead puck
|
||||
// moved around the table with it.
|
||||
//
|
||||
// Three things had to agree for that to happen and they are the three checked here: the
|
||||
// pad exists, the chip under it is live, and its handler eats the event. The pad is gone
|
||||
// (nothing needs it — no finger can reach any chip), the chip that has the button is
|
||||
// inert to the pointer, and the handler bails rather than swallowing a tap it has no
|
||||
// answer for.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const src = (...parts) => fs.readFileSync(path.join(__dirname, "..", "..", "JoshHeaps.Net", ...parts), "utf8");
|
||||
|
||||
const CSS = src("wwwroot", "css", "casino", "holdem.css");
|
||||
const RENDER = src("wwwroot", "js", "casino", "holdem", "render.js");
|
||||
const MAIN = src("wwwroot", "js", "casino", "holdem", "main.js");
|
||||
const AUDIT = fs.readFileSync(path.join(__dirname, "uiAudit.js"), "utf8");
|
||||
|
||||
// The body of every rule written at exactly this selector, concatenated. Every one of
|
||||
// them, because the phone block redeclared the chip's pad under the same name and it
|
||||
// was the redeclared copy — 44px centred on the disc — that reached furthest in.
|
||||
const rulesFor = selector => {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(`(^|[},])\\s*${escaped}\\s*\\{([^}]*)\\}`, "gm");
|
||||
const bodies = [];
|
||||
let found;
|
||||
while ((found = re.exec(CSS)) !== null) bodies.push(found[2]);
|
||||
return bodies;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the seat under the dealer button is still a seat", () => {
|
||||
test("the chip has no invisible pad reaching back over its own puck", () => {
|
||||
// Not "a smaller pad". A fixed pad cannot be safe at every puck size: the seat
|
||||
// search hands out pucks from 48px wide to 100-odd depending on the screen and
|
||||
// the number of chairs, and any pad big enough to matter on the large one
|
||||
// covers the middle of the small one.
|
||||
assert.equal(rulesFor(".dealerChip::after").length, 0,
|
||||
"the dealer chip grew a tap pad again — see the note above .dealerChip in holdem.css");
|
||||
});
|
||||
|
||||
test("the chip that has the button takes no taps", () => {
|
||||
const bodies = rulesFor(".dealerChip.active");
|
||||
assert.ok(bodies.length > 0, ".dealerChip.active has no rule at all");
|
||||
assert.ok(bodies.some(body => /pointer-events:\s*none/.test(body)),
|
||||
"the seat holding the button has a live control sitting on it");
|
||||
});
|
||||
|
||||
test("the other chips stay inert until a pointer that can hover arrives", () => {
|
||||
// This is what makes the pad unnecessary rather than merely unwanted. On touch
|
||||
// there is no hover, so no ghost chip is reachable by a finger and the only live
|
||||
// chip is the inert one above. Nothing a thumb can press is 22px.
|
||||
const ghost = rulesFor(".dealerChip:not(.active)");
|
||||
assert.ok(ghost.some(body => /pointer-events:\s*none/.test(body)),
|
||||
"a chip nobody can see is taking taps");
|
||||
|
||||
const hover = CSS.slice(CSS.indexOf("@media (hover: hover)"));
|
||||
const revealed = hover.match(/\.seat:hover\s+\.dealerChip:not\(\.active\)\s*\{([^}]*)\}/);
|
||||
assert.ok(revealed, "hovering a seat no longer offers to put the button there");
|
||||
assert.match(revealed[1], /pointer-events:\s*auto/,
|
||||
"the mouse path to moving the button is gone");
|
||||
});
|
||||
|
||||
test("and the handler does not swallow a tap it has no answer for", () => {
|
||||
// stopPropagation is what turned a pointless control into a hole. It still runs
|
||||
// for the eight seats the button could move to; the ninth returns first.
|
||||
const handler = RENDER.slice(RENDER.indexOf('querySelector(".dealerChip").addEventListener'));
|
||||
const body = handler.slice(0, handler.indexOf("});"));
|
||||
const guard = body.indexOf("dealerSeatIndex");
|
||||
const stop = body.indexOf("stopPropagation");
|
||||
assert.ok(guard >= 0, "the chip's click handler does not check whose button it is");
|
||||
assert.ok(stop >= 0, "the chip stopped guarding the seat behind it from the other chips");
|
||||
assert.ok(guard < stop,
|
||||
"the guard has to come first, or the tap is eaten before anyone decides it meant nothing");
|
||||
});
|
||||
|
||||
test("moving the button to where it already is is not a move", () => {
|
||||
// The keyboard can still reach the chip, and the same no-op would take the
|
||||
// blinds off the felt and re-post the same chips to announce that nothing
|
||||
// happened. The seat menu has always hidden "Give the button" on the seat that
|
||||
// holds it; this is the same rule where the money is.
|
||||
const from = MAIN.indexOf("makeDealer: index =>");
|
||||
assert.ok(from >= 0, "makeDealer is gone or renamed");
|
||||
const body = MAIN.slice(from, MAIN.indexOf("blindsArePosted", from));
|
||||
assert.match(body, /if\s*\(\s*index\s*===\s*state\.dealerSeatIndex\s*\)\s*return/,
|
||||
"makeDealer will re-post the blinds to move the button nowhere");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sweep can see this kind of failure now", () => {
|
||||
test("a control inside a seat counts as a rival for the seat's tap", () => {
|
||||
// The layout sweep already had a check named "a seat's centre belongs to that
|
||||
// seat", written after the same chip's pad grew *outward* and started taking
|
||||
// taps from the seat next door. It ran clean through all of this: it waved
|
||||
// through anything the seat contained, and the chip is inside the seat. The
|
||||
// check that existed for this exact bug could not see the second half of it.
|
||||
const from = AUDIT.indexOf("seatOwnership()");
|
||||
assert.ok(from >= 0, "the seat-ownership check is gone");
|
||||
const body = AUDIT.slice(from, AUDIT.indexOf("oneDealerButton()", from));
|
||||
|
||||
assert.match(body, /seat\.contains\(rival\)/,
|
||||
"a seat's own control is being waved through again");
|
||||
assert.doesNotMatch(body, /if\s*\(\s*!hit\s*\|\|\s*seat\.contains\(hit\)/,
|
||||
"the early return that hid this is back: it exempts every descendant, chip included");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// How wide a seat puck is allowed to get, and on which screens.
|
||||
//
|
||||
// The felt grows with the window and the pucks did not: 96px was the only ceiling there
|
||||
// was, so nine-handed the seats covered 19% of the felt on a phone, 11% on a 1280px
|
||||
// desktop and 8% at 1440px and beyond — with the stack figure a dealer reads sitting at
|
||||
// 10px on a 2560px monitor, the same 10px as on a 360px phone.
|
||||
//
|
||||
// Two things stop the fix from costing more than it buys, and both are checked here.
|
||||
//
|
||||
// Every candidate width is a forced layout, and raising the ceiling for everybody took
|
||||
// the layout pass from 24ms to 73ms on a twelve-handed desktop — so a screen that cannot
|
||||
// use the extra widths must not measure them. And a 144px puck on an iPhone's 278px ring
|
||||
// is half the width of the table: the first version of this let phones through, and
|
||||
// six-handed all six seats sat over the rail by 15px against 5px before, reading as a
|
||||
// stack of cards rather than a table with people around it.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render } = require("./load");
|
||||
|
||||
// Ring boxes read off the running page, not invented. #seatsLayer, nine-handed, at the
|
||||
// viewports the sweep visits.
|
||||
const RINGS = {
|
||||
"Galaxy S8": { width: 233, height: 282 },
|
||||
"iPhone 14": { width: 263, height: 470 },
|
||||
"Pro Max": { width: 303, height: 535 },
|
||||
"phone sideways": { width: 565, height: 257 },
|
||||
iPad: { width: 689, height: 689 },
|
||||
desktop: { width: 857, height: 617 },
|
||||
"desktop 1440": { width: 992, height: 714 },
|
||||
};
|
||||
const SMALL = ["Galaxy S8", "iPhone 14", "Pro Max", "phone sideways"];
|
||||
const LARGE = ["iPad", "desktop", "desktop 1440"];
|
||||
|
||||
describe("which felts may have wider pucks at all", () => {
|
||||
test("no phone does, held either way up", () => {
|
||||
// The guarantee the phones are owed: they measure the widths they always
|
||||
// measured and settle on the puck they always settled on.
|
||||
SMALL.forEach(name => {
|
||||
assert.ok(Render.roomyWidthCeiling(RINGS[name]) <= Render.SEAT_WIDTH_MAX,
|
||||
`${name} would be allowed a ${Render.roomyWidthCeiling(RINGS[name])}px puck`);
|
||||
[2, 4, 6, 9, 12].forEach(count => assert.equal(
|
||||
Render.roomForWiderPucks(count, RINGS[name]), false,
|
||||
`${name} ${count}-handed`));
|
||||
});
|
||||
});
|
||||
|
||||
test("and every tablet and desktop does", () => {
|
||||
LARGE.forEach(name => {
|
||||
assert.ok(Render.roomyWidthCeiling(RINGS[name]) > Render.SEAT_WIDTH_MAX, name);
|
||||
});
|
||||
});
|
||||
|
||||
test("with a comfortable margin either side of the line", () => {
|
||||
// The rule is a fifth of the shorter span, and the point of five is that it is
|
||||
// not near either group. If a change to the felt ever moves these together,
|
||||
// this says so before the phones quietly start paying for it.
|
||||
const biggestSmall = Math.max(...SMALL.map(n => Math.min(RINGS[n].width, RINGS[n].height)));
|
||||
const smallestLarge = Math.min(...LARGE.map(n => Math.min(RINGS[n].width, RINGS[n].height)));
|
||||
assert.ok(smallestLarge > biggestSmall * 1.8,
|
||||
`${biggestSmall}px and ${smallestLarge}px are too close for one divisor to separate`);
|
||||
// Every divisor that keeps the phones out is at least the widest phone span over
|
||||
// the ordinary ceiling; every one that lets the tablets in is below the narrowest
|
||||
// large span over the same. 303/96 = 3.2 and 617/96 = 6.4, and five is between.
|
||||
const floor = biggestSmall / Render.SEAT_WIDTH_MAX;
|
||||
const cap = smallestLarge / Render.SEAT_WIDTH_MAX;
|
||||
assert.ok(Render.SEAT_ROOMY_SPAN_RATIO >= floor,
|
||||
`${Render.SEAT_ROOMY_SPAN_RATIO} lets a phone through; it has to be at least ${floor.toFixed(2)}`);
|
||||
assert.ok(Render.SEAT_ROOMY_SPAN_RATIO < cap,
|
||||
`${Render.SEAT_ROOMY_SPAN_RATIO} shuts the tablets out; it has to be under ${cap.toFixed(2)}`);
|
||||
});
|
||||
|
||||
test("and a felt with no size at all is not one of them", () => {
|
||||
assert.equal(Render.roomForWiderPucks(9, null), false);
|
||||
assert.equal(Render.roomForWiderPucks(9, { width: 0, height: 0 }), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the widths a felt actually measures", () => {
|
||||
const widthsFor = (name, count) => Render.seatWidthCandidates(count, RINGS[name]);
|
||||
|
||||
test("a phone measures exactly the ten it measured before", () => {
|
||||
const expected = [];
|
||||
for (let w = Render.SEAT_WIDTH_MAX; w > Render.SEAT_WIDTH_MIN; w -= Render.SEAT_WIDTH_STEP) {
|
||||
expected.push(w);
|
||||
}
|
||||
expected.push(Render.SEAT_WIDTH_MIN);
|
||||
SMALL.forEach(name => [6, 9, 12].forEach(count =>
|
||||
assert.deepEqual(widthsFor(name, count), expected, `${name} ${count}-handed`)));
|
||||
});
|
||||
|
||||
test("a desktop measures a handful more, and no more than a handful", () => {
|
||||
const extra = widthsFor("desktop 1440", 9).length - widthsFor("Galaxy S8", 9).length;
|
||||
assert.ok(extra > 0, "the wider widths never got offered");
|
||||
assert.ok(extra <= 6, `${extra} extra forced layouts is more than this is worth`);
|
||||
});
|
||||
|
||||
test("never wider than the felt's own ceiling", () => {
|
||||
Object.keys(RINGS).forEach(name => [2, 6, 9, 12].forEach(count => {
|
||||
const widest = Math.max(...widthsFor(name, count));
|
||||
assert.ok(widest <= Math.max(Render.SEAT_WIDTH_MAX, Render.roomyWidthCeiling(RINGS[name])),
|
||||
`${name} ${count}-handed offered ${widest}px`);
|
||||
assert.ok(widest <= Render.SEAT_WIDTH_ROOMY_MAX);
|
||||
}));
|
||||
});
|
||||
|
||||
test("always widest first, always down to the floor", () => {
|
||||
// The search takes the first width that fits, so the order is the whole
|
||||
// algorithm — and the floor has to be on the list or a crowded table has
|
||||
// nothing left to fall back to.
|
||||
Object.keys(RINGS).forEach(name => [2, 9, 12].forEach(count => {
|
||||
const widths = widthsFor(name, count);
|
||||
widths.forEach((w, i) => {
|
||||
if (i === 0) return;
|
||||
assert.ok(w < widths[i - 1], `${name}: ${widths.join(", ")} is not descending`);
|
||||
});
|
||||
assert.equal(widths[widths.length - 1], Render.SEAT_WIDTH_MIN);
|
||||
}));
|
||||
});
|
||||
|
||||
test("and a crowded table on a big felt is told no like any other", () => {
|
||||
// Room on the felt is necessary, not sufficient: the seats still have to clear
|
||||
// each other. Enough of them on any ring and the wider widths stop being
|
||||
// offered, which is the arithmetic doing the refusing rather than the
|
||||
// measurement having to.
|
||||
const crowded = [24, 30, 40].map(count => Render.roomForWiderPucks(count, RINGS.desktop));
|
||||
assert.deepEqual(crowded, [false, false, false]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
// Sitting people down, which is the first thing anybody does with the tool.
|
||||
//
|
||||
// Naming a chair and dealing that chair in were two separate acts and the second was
|
||||
// easy to miss. A dealer setting up typed six names, got six seats that still weren't
|
||||
// in the hand, and the action bar hid itself because by its reckoning nobody was
|
||||
// playing: six people at the table and a blank screen with no way forward.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Betting, ActionBar, makeTable } = require("./load");
|
||||
|
||||
const emptyTable = (seatCount = 10) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, seatCount);
|
||||
return state;
|
||||
};
|
||||
|
||||
const bar = state => Object.assign(Object.create(ActionBar), { state });
|
||||
|
||||
describe("the bar knows the difference between an empty table and a named one", () => {
|
||||
test("a table nobody has touched gets no bar", () => {
|
||||
assert.equal(bar(emptyTable()).isRunning(), false, "an empty felt stays uncluttered");
|
||||
});
|
||||
|
||||
test("a table with names on it gets one, dealt in or not", () => {
|
||||
const state = emptyTable();
|
||||
state.seats[0].name = "Ann";
|
||||
state.seats[1].name = "Ben";
|
||||
|
||||
assert.equal(Betting.liveSeats(state).length, 0, "nobody is in the hand");
|
||||
assert.equal(bar(state).isRunning(), true, "but there are people at the table to say something to");
|
||||
});
|
||||
|
||||
test("whitespace isn't a player", () => {
|
||||
const state = emptyTable();
|
||||
state.seats[0].name = " ";
|
||||
assert.equal(bar(state).isRunning(), false);
|
||||
});
|
||||
|
||||
test("a table that is playing still gets one", () => {
|
||||
assert.equal(bar(makeTable({ seatCount: 6, seated: [0, 1] })).isRunning(), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("naming an empty chair sits somebody down", () => {
|
||||
// What the seat editor's name field does, as the rule it applies rather than
|
||||
// through the DOM: a chair with no name and nobody in it is empty, and putting a
|
||||
// name on it is somebody arriving.
|
||||
const nameSeat = (player, name) => {
|
||||
const wasEmpty = !player.name.trim() && !player.inHand;
|
||||
player.name = name;
|
||||
if (wasEmpty && player.name.trim()) { player.inHand = true; player.folded = false; }
|
||||
};
|
||||
|
||||
test("an empty chair gets dealt in", () => {
|
||||
const state = emptyTable();
|
||||
nameSeat(state.seats[0], "Ann");
|
||||
assert.equal(state.seats[0].inHand, true);
|
||||
assert.equal(state.seats[0].folded, false);
|
||||
});
|
||||
|
||||
test("six names is six players, not six benched seats", () => {
|
||||
const state = emptyTable();
|
||||
state.defaultStack = 200; // a table that has told the tool what a buy-in is
|
||||
["Ann", "Ben", "Cal", "Dee", "Eve", "Fay"].forEach((name, i) => nameSeat(state.seats[i], name));
|
||||
|
||||
assert.equal(state.seats.filter(p => p.inHand).length, 6);
|
||||
assert.equal(bar(state).isRunning(), true);
|
||||
assert.ok(Betting.blindPreview(state), "and there's a hand to deal");
|
||||
});
|
||||
|
||||
test("renaming somebody who is sitting out doesn't deal them back in", () => {
|
||||
// A correction, not an arrival. Dealing them in over a typo would be the tool
|
||||
// overruling a dealer who sat them out on purpose.
|
||||
const state = emptyTable();
|
||||
state.seats[0].name = "Ann";
|
||||
state.seats[0].inHand = false;
|
||||
|
||||
nameSeat(state.seats[0], "Anne");
|
||||
assert.equal(state.seats[0].inHand, false, "still sitting out, now spelled right");
|
||||
});
|
||||
|
||||
test("clearing a name doesn't sit anybody down", () => {
|
||||
const state = emptyTable();
|
||||
nameSeat(state.seats[0], "Ann");
|
||||
nameSeat(state.seats[0], "");
|
||||
assert.equal(state.seats[0].name, "");
|
||||
});
|
||||
|
||||
test("a chair already in the hand is left alone", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0] });
|
||||
state.seats[0].folded = true;
|
||||
nameSeat(state.seats[0], "Ann");
|
||||
assert.equal(state.seats[0].folded, true, "mid-hand a fold is not undone by typing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the way back from a table where everybody is sat out", () => {
|
||||
test("there are named seats to offer", () => {
|
||||
// Can't be reached by naming any more, but a table where everyone was sat out
|
||||
// — or a save from before naming seated people — still lands here.
|
||||
const state = emptyTable();
|
||||
["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; });
|
||||
|
||||
const benched = state.seats
|
||||
.map((player, index) => ({ player, index }))
|
||||
.filter(({ player }) => (player.name || "").trim() && !player.inHand)
|
||||
.map(({ index }) => index);
|
||||
|
||||
assert.deepEqual(benched, [0, 1, 2]);
|
||||
assert.equal(Betting.blindPreview(state), null, "no hand to deal until they're in");
|
||||
});
|
||||
|
||||
test("dealing them all in makes a hand possible", () => {
|
||||
const state = emptyTable();
|
||||
["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].stack = 100; });
|
||||
|
||||
state.seats.forEach(player => {
|
||||
if ((player.name || "").trim()) { player.inHand = true; player.folded = false; }
|
||||
});
|
||||
|
||||
assert.equal(Betting.liveSeats(state).length, 3);
|
||||
assert.ok(Betting.blindPreview(state), "the table can be dealt");
|
||||
});
|
||||
|
||||
test("an unnamed chair is never swept in by it", () => {
|
||||
const state = emptyTable();
|
||||
state.seats[0].name = "Ann";
|
||||
state.seats.forEach(player => {
|
||||
if ((player.name || "").trim()) { player.inHand = true; }
|
||||
});
|
||||
assert.equal(state.seats.filter(p => p.inHand).length, 1, "empty chairs stay empty");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a table where nobody has bought in", () => {
|
||||
// Different from a table that has busted out, and the difference is the whole
|
||||
// point: one needs a starting stack named, the other needs a rebuy. Telling a
|
||||
// dealer their brand-new players have "no chips left" sends them hunting for the
|
||||
// wrong thing.
|
||||
const neverBoughtIn = state => state.seats.every(p =>
|
||||
!(Number(p.buyIns) > 0) && !(Number(p.stack) > 0));
|
||||
|
||||
test("six freshly named players have never bought in", () => {
|
||||
const state = emptyTable();
|
||||
["Ann", "Ben", "Cal", "Dee", "Eve", "Fay"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
});
|
||||
|
||||
assert.equal(neverBoughtIn(state), true);
|
||||
assert.equal(Betting.blindPreview(state), null, "and there is no hand to deal");
|
||||
});
|
||||
|
||||
test("a table that has busted out is not the same thing", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 200 });
|
||||
state.seats.forEach(p => { p.buyIns = 200; p.stack = 0; });
|
||||
|
||||
assert.equal(neverBoughtIn(state), false, "they had chips — they lost them");
|
||||
});
|
||||
|
||||
test("one player still holding chips is not a never-bought-in table either", () => {
|
||||
const state = emptyTable();
|
||||
["Ann", "Ben"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].inHand = true; });
|
||||
state.seats[0].stack = 50;
|
||||
|
||||
assert.equal(neverBoughtIn(state), false);
|
||||
});
|
||||
|
||||
test("naming a starting stack is enough to deal", () => {
|
||||
// The one field the message now points at. A seat with no stack of its own
|
||||
// falls back to the table default, so setting it seats everybody's chips at
|
||||
// once — which is why it's the right thing to send the dealer to.
|
||||
const state = emptyTable();
|
||||
["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].inHand = true; });
|
||||
assert.equal(Betting.blindPreview(state), null);
|
||||
|
||||
state.defaultStack = 200;
|
||||
assert.ok(Betting.blindPreview(state), "one number and the table can play");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// How wide the ring of seats is allowed to be.
|
||||
//
|
||||
// It sat at a flat 44% by 42% of the seat layer at every size — two numbers picked
|
||||
// against a phone, then asked to hold for a 754x543 felt on a desktop and a 686x141
|
||||
// one on a phone held sideways. A fixed pair can only be right for one ratio of puck
|
||||
// to felt, so measured on the running page:
|
||||
//
|
||||
// 1280x900, nine-handed five of nine pucks past the table's outer edge, worst 49px
|
||||
// 844x390, six-handed all six, worst 20px
|
||||
// 430x932, six-handed two, worst 12px
|
||||
//
|
||||
// Seats floating off the felt onto the page read as the table being too small for the
|
||||
// game rather than as players at a rail, and it was a different amount of wrong on
|
||||
// every screen — which is what made it look like a mistake instead of a choice.
|
||||
//
|
||||
// The radius is derived from what has to fit now. Whether it *actually* fits is a
|
||||
// question about a laid-out browser, and it is checked there: uiAudit's "seats and
|
||||
// chips stay on the table" runs at every viewport in the sweep. What is checked here
|
||||
// is the contract that function has to honour, including the two ways it is allowed
|
||||
// to give up.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render } = require("./load");
|
||||
|
||||
describe("with no browser to measure", () => {
|
||||
test("it falls back to the ring the tool has always used", () => {
|
||||
// Every call from this suite has no DOM, and so does the very first render,
|
||||
// before the felt has been laid out. Neither should throw or return nonsense.
|
||||
assert.deepEqual(Render.seatRingRadii(), { rx: Render.RING_MAX_RX, ry: Render.RING_MAX_RY });
|
||||
});
|
||||
|
||||
test("so seatPosition still answers", () => {
|
||||
const pos = Render.seatPosition(0, 9, 1);
|
||||
assert.ok(Number.isFinite(pos.left) && Number.isFinite(pos.top));
|
||||
});
|
||||
|
||||
test("and nothing is measured for the puck height either", () => {
|
||||
assert.equal(Render.tooTallForTheTable(), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ceiling and the floor", () => {
|
||||
test("the old ring is the widest it can ever be", () => {
|
||||
// Nothing gets looser than it was; this can only pull seats in.
|
||||
assert.ok(Render.RING_MAX_RX <= 44);
|
||||
assert.ok(Render.RING_MAX_RY <= 42);
|
||||
});
|
||||
|
||||
test("and it never collapses the ring into the pot", () => {
|
||||
// A puck wider than the table it sits on has no honest answer. Overhanging is
|
||||
// the lesser evil — seats stacked on the pot would be unreadable, and the
|
||||
// density rule shrinks the pucks before it ever gets this far.
|
||||
assert.ok(Render.RING_MIN_R > 0);
|
||||
assert.ok(Render.RING_MIN_R < Render.RING_MAX_RY);
|
||||
});
|
||||
|
||||
test("with a margin that keeps the outermost seat off the rail", () => {
|
||||
assert.ok(Render.SEAT_EDGE_MARGIN_PX > 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ring the radii describe", () => {
|
||||
const spread = (radii, count = 9, aspect = 1.4) =>
|
||||
Render.ringPositions(count, radii.rx, radii.ry, aspect);
|
||||
|
||||
test("a smaller radius puts every seat nearer the middle", () => {
|
||||
const wide = spread({ rx: 44, ry: 42 });
|
||||
const tight = spread({ rx: 30, ry: 28 });
|
||||
wide.forEach((seat, i) => {
|
||||
const wideOut = Math.hypot(seat.left - 50, seat.top - 50);
|
||||
const tightOut = Math.hypot(tight[i].left - 50, tight[i].top - 50);
|
||||
assert.ok(tightOut < wideOut, `seat ${i + 1} should have come in`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and the seats stay evenly spread however far in they are", () => {
|
||||
// Pulling the ring in must not bunch them: the spacing rule is arc length, and
|
||||
// shrinking an ellipse uniformly keeps every gap in proportion.
|
||||
const gaps = ring => ring.map((p, i) => {
|
||||
const next = ring[(i + 1) % ring.length];
|
||||
return Math.hypot(p.left - next.left, p.top - next.top);
|
||||
});
|
||||
const tight = gaps(spread({ rx: 30, ry: 28 }));
|
||||
const smallest = Math.min(...tight), largest = Math.max(...tight);
|
||||
assert.ok(largest / smallest < 1.35, `gaps ranged ${smallest.toFixed(1)}..${largest.toFixed(1)}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bets follow the seats", () => {
|
||||
test("a chip sits on its own player's spoke, proportionally in", () => {
|
||||
// The chip is a fraction of the seat's reach rather than of the felt, so when
|
||||
// the ring pulls in the chips come with it instead of being left by the rail.
|
||||
const radii = { rx: 30, ry: 28 };
|
||||
const seat = Render.seatPosition(2, 8, 1.4, radii);
|
||||
const bet = Render.betPosition(2, 8, 1.4, radii);
|
||||
const seatOut = Math.hypot(seat.left - 50, seat.top - 50);
|
||||
const betOut = Math.hypot(bet.left - 50, bet.top - 50);
|
||||
assert.ok(betOut < seatOut, "the chip is nearer the pot than the player is");
|
||||
assert.ok(betOut > seatOut * 0.4, "but still out on the player's side of the felt");
|
||||
});
|
||||
|
||||
test("and it is the same spoke, not just the same distance", () => {
|
||||
const radii = { rx: 30, ry: 28 };
|
||||
const seat = Render.seatPosition(5, 9, 1.2, radii);
|
||||
const bet = Render.betPosition(5, 9, 1.2, radii);
|
||||
const angleOf = p => Math.atan2(p.top - 50, p.left - 50);
|
||||
assert.ok(Math.abs(angleOf(seat) - angleOf(bet)) < 0.01);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// Which fields replace their contents when a dealer reaches for them.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money } = require("./load");
|
||||
|
||||
@@ -51,3 +52,68 @@ test("nothing at all is not a field", () => {
|
||||
test("a field with no type stated is a text box, so it does not", () => {
|
||||
assert.equal(Money.selectsOnFocus({ tagName: "INPUT" }), false);
|
||||
});
|
||||
|
||||
// ---- A scroll is not an edit ------------------------------------------------
|
||||
//
|
||||
// A focused `<input type="number">` is a spinbutton, and the browser lets the mouse
|
||||
// wheel drive it. Measured on the running page at 1280x900 with the sidebar open: focus
|
||||
// the small blind and scroll, $5 becomes $5.50. Focus a seat's stack and scroll, $500
|
||||
// becomes $501. Both go straight through to the saved table, because these fields save
|
||||
// on `input` like everything else here.
|
||||
//
|
||||
// Nothing is announced and nothing looks wrong. The tool has invented a chip, and the
|
||||
// gesture that did it was a dealer reaching for another panel.
|
||||
//
|
||||
// The rule is narrow on purpose. Every other way of driving a spinbutton is deliberate —
|
||||
// the arrow keys, the stepper arrows, typing — and none of them is touched. Scrolling is
|
||||
// the one actuation nobody means.
|
||||
describe("a wheel over a field the dealer is in", () => {
|
||||
const box = (type, tag = "INPUT") => ({ tagName: tag, type });
|
||||
|
||||
test("a focused number box would be edited by it", () => {
|
||||
const stack = box("number");
|
||||
assert.equal(Money.wheelWouldEdit(stack, stack), true);
|
||||
});
|
||||
|
||||
test("the same box, not focused, would not", () => {
|
||||
// An unfocused spinbutton ignores the wheel. There is nothing to defend, and
|
||||
// blurring something that is not focused is a no-op with a side effect.
|
||||
const stack = box("number");
|
||||
assert.equal(Money.wheelWouldEdit(stack, null), false);
|
||||
assert.equal(Money.wheelWouldEdit(stack, box("number")), false);
|
||||
});
|
||||
|
||||
test("the amount boxes that already moved off type=number are safe as they are", () => {
|
||||
// Add chips, the raise pad and Quick Split use text + inputmode="decimal", which
|
||||
// is not a spinbutton and cannot be scrolled into a different number.
|
||||
const amount = { tagName: "INPUT", type: "text", inputMode: "decimal" };
|
||||
assert.equal(Money.wheelWouldEdit(amount, amount), false);
|
||||
// Still an amount field for the other rule, though — the two questions are
|
||||
// different and this one must not quietly answer both.
|
||||
assert.equal(Money.selectsOnFocus(amount), true);
|
||||
});
|
||||
|
||||
test("a name, a note and a table are left alone", () => {
|
||||
const name = { tagName: "INPUT", type: "text" };
|
||||
const notes = { tagName: "TEXTAREA", type: "textarea" };
|
||||
assert.equal(Money.wheelWouldEdit(name, name), false);
|
||||
assert.equal(Money.wheelWouldEdit(notes, notes), false);
|
||||
assert.equal(Money.wheelWouldEdit(null, null), false);
|
||||
assert.equal(Money.wheelWouldEdit(undefined, undefined), false);
|
||||
});
|
||||
|
||||
test("the type is read case-insensitively, the way an attribute comes back", () => {
|
||||
const shouty = box("NUMBER");
|
||||
assert.equal(Money.wheelWouldEdit(shouty, shouty), true);
|
||||
});
|
||||
|
||||
test("a select is a spinbutton to nobody", () => {
|
||||
// The page has one <select> — which way the blinds move — and it was the obvious
|
||||
// next suspect. It isn't one: focused, hovered and scrolled 300px in Chromium it
|
||||
// stays on "clockwise", because a select only takes the wheel while its dropdown
|
||||
// is open. Checked rather than assumed, and left out rather than defended
|
||||
// against, so this rule keeps meaning exactly one thing.
|
||||
const select = { tagName: "SELECT", type: "select-one" };
|
||||
assert.equal(Money.wheelWouldEdit(select, select), false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Who the tool asks to turn over, and in what order.
|
||||
//
|
||||
// The board half of the card feature always worked: every street, a sheet asks which
|
||||
// cards came off the deck. The hands half never did. A dealer entered five community
|
||||
// cards, pressed the showdown button, and the pot wizard told them "Chris, Mike and
|
||||
// Alex haven't turned over, so who won is still yours to say" — with nothing in the
|
||||
// sheet to turn anybody over with. The only way in was to close the wizard, tap a seat
|
||||
// puck, find the card slots in the seat editor, and repeat it for each player, with the
|
||||
// pot sitting on the table.
|
||||
//
|
||||
// So the showdown button now asks, the same way the street button does. This file is
|
||||
// the list it asks from. The sheet itself is DOM and is checked in the browser; the
|
||||
// question of who is on the list, and in which order, is state and lives here.
|
||||
//
|
||||
// Three rules hold it together:
|
||||
//
|
||||
// Only when asked for. The setting is off by default and the list is empty when it
|
||||
// is off, so a table that never wanted this never sees a sheet.
|
||||
//
|
||||
// Only what isn't known. A hand already entered is an answer already given, and
|
||||
// asking again offers to overwrite it.
|
||||
//
|
||||
// In the order the cards actually come over — the showdown order the action bar
|
||||
// already announces, then round the table from there. A dealer looking at the seat
|
||||
// the bar just named should not be asked about the one behind them.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render, Betting, HoldemState, makeTable } = require("./load");
|
||||
|
||||
// A hand played to the river with everyone still in, which is the only shape that
|
||||
// has a showdown to ask about.
|
||||
const atShowdown = ({ seats = 4, tracking = true } = {}) => {
|
||||
const state = makeTable({ seatCount: seats, stack: 500, smallBlind: 5, bigBlind: 10 });
|
||||
Betting.beginHand(state);
|
||||
state.street = "river";
|
||||
state.board = ["2C", "7D", "9S", "JH", "3S"];
|
||||
state.trackBoard = tracking;
|
||||
state.seats.forEach(player => { player.hasActed = true; });
|
||||
return state;
|
||||
};
|
||||
|
||||
const queue = state => Render.showdownCardQueue(state);
|
||||
|
||||
describe("only when the dealer asked to be asked", () => {
|
||||
test("the setting off means nobody is asked anything", () => {
|
||||
assert.deepStrictEqual(queue(atShowdown({ tracking: false })), []);
|
||||
});
|
||||
|
||||
test("the setting on asks everyone still in", () => {
|
||||
assert.strictEqual(queue(atShowdown()).length, 4);
|
||||
});
|
||||
|
||||
test("a table nobody is sitting at is not a showdown", () => {
|
||||
const state = atShowdown();
|
||||
state.seats.forEach(player => { player.inHand = false; });
|
||||
assert.deepStrictEqual(queue(state), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a hand won without a showdown", () => {
|
||||
// Everyone else folded, so there is one hand left and it never comes over. The
|
||||
// bar says "Award pot to Chris" here, not "Showdown", and asking Chris to turn
|
||||
// over a hand nobody paid to see is asking for a card the tool will never use.
|
||||
test("one player left is asked for nothing", () => {
|
||||
const state = atShowdown();
|
||||
[1, 2, 3].forEach(i => { state.seats[i].folded = true; });
|
||||
assert.deepStrictEqual(queue(state), []);
|
||||
});
|
||||
|
||||
test("two left is a showdown, however it was arrived at", () => {
|
||||
const state = atShowdown();
|
||||
[2, 3].forEach(i => { state.seats[i].folded = true; });
|
||||
assert.strictEqual(queue(state).length, 2);
|
||||
});
|
||||
|
||||
// Everybody all-in on the flop is still a showdown: the cards go on their backs,
|
||||
// the board runs out, and the hands are read exactly as they would have been.
|
||||
test("all-in with the betting finished is still asked about", () => {
|
||||
const state = atShowdown();
|
||||
state.seats.forEach(player => { player.stack = 0; });
|
||||
assert.ok(!Betting.canStillBet(state));
|
||||
assert.strictEqual(queue(state).length, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("only the hands it doesn't already have", () => {
|
||||
test("a hand already entered is not asked for again", () => {
|
||||
const state = atShowdown();
|
||||
state.seats[1].cards = ["AS", "AH"];
|
||||
assert.ok(!queue(state).includes(1));
|
||||
});
|
||||
|
||||
test("half a hand entered is still asked for", () => {
|
||||
const state = atShowdown();
|
||||
state.seats[1].cards = ["AS", ""];
|
||||
assert.ok(queue(state).includes(1));
|
||||
});
|
||||
|
||||
test("every hand entered means the sheet never opens", () => {
|
||||
const state = atShowdown();
|
||||
const hands = [["AS", "AH"], ["KS", "KH"], ["QS", "QH"], ["9H", "9D"]];
|
||||
state.seats.forEach((player, i) => { player.cards = hands[i]; });
|
||||
assert.deepStrictEqual(queue(state), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("in the order the cards come over", () => {
|
||||
// The bar already says "Mike shows first — last to bet or raise" out loud. The
|
||||
// sheet asking about somebody else first would be the tool contradicting itself
|
||||
// in the same breath.
|
||||
test("it starts with whoever the bar says shows first", () => {
|
||||
const state = atShowdown();
|
||||
state.streetAggressorIndex = 2;
|
||||
assert.strictEqual(Betting.showdownOrder(state).index, 2);
|
||||
assert.strictEqual(queue(state)[0], 2);
|
||||
});
|
||||
|
||||
test("and then goes round the table from there", () => {
|
||||
const state = atShowdown();
|
||||
state.streetAggressorIndex = 2;
|
||||
assert.deepStrictEqual(queue(state), [2, 3, 0, 1]);
|
||||
});
|
||||
|
||||
test("nobody checked the river, so it starts left of the button", () => {
|
||||
const state = atShowdown();
|
||||
state.streetAggressorIndex = null;
|
||||
state.handAggressorIndex = null;
|
||||
const first = Betting.showdownOrder(state);
|
||||
assert.strictEqual(first.reason, "leftOfButton");
|
||||
assert.strictEqual(queue(state)[0], first.index);
|
||||
});
|
||||
|
||||
test("a player already entered drops out of the order without moving the rest", () => {
|
||||
const state = atShowdown();
|
||||
state.streetAggressorIndex = 2;
|
||||
state.seats[3].cards = ["KS", "KH"];
|
||||
assert.deepStrictEqual(queue(state), [2, 0, 1]);
|
||||
});
|
||||
|
||||
test("everyone still in is asked exactly once", () => {
|
||||
const state = atShowdown({ seats: 9 });
|
||||
const asked = queue(state);
|
||||
assert.strictEqual(new Set(asked).size, asked.length);
|
||||
assert.strictEqual(asked.length, Betting.liveSeats(state).length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
// Who turns their hand over first.
|
||||
//
|
||||
// A call the dealer makes at every showdown that isn't a fold, and one nobody at the
|
||||
// table volunteers for — everyone would rather see a hand than show one. The tool knew
|
||||
// who last raised and who sits left of the button and said neither.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Betting, makeTable } = require("./load");
|
||||
|
||||
const NAMES = ["Ann", "Ben", "Cal", "Dee"];
|
||||
const table = (dealerSeatIndex = 0, stack = 200) => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack, dealerSeatIndex });
|
||||
NAMES.forEach((name, i) => { state.seats[i].name = name; });
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
const call = state => {
|
||||
const answer = Betting.showdownOrder(state);
|
||||
return answer && { who: state.seats[answer.index].name, reason: answer.reason };
|
||||
};
|
||||
|
||||
// Everybody left just calls whatever is in front of them, then the street closes.
|
||||
const checkRound = state => {
|
||||
let guard = 0;
|
||||
while (state.actingSeatIndex !== null && guard++ < 12) {
|
||||
const index = state.actingSeatIndex;
|
||||
if (Betting.toCall(state, state.seats[index]) > 0) Betting.call(state, index);
|
||||
else Betting.check(state, index);
|
||||
}
|
||||
Betting.closeStreet(state);
|
||||
};
|
||||
|
||||
describe("a bet on the river", () => {
|
||||
test("the last player to raise shows first", () => {
|
||||
const state = table(0); // blinds Ben and Cal
|
||||
checkRound(state); // pre-flop
|
||||
checkRound(state); // flop
|
||||
checkRound(state); // turn
|
||||
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 20);
|
||||
const raiser = state.seats.findIndex(p => Betting.committed(p) === 20);
|
||||
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
|
||||
|
||||
assert.deepEqual(call(state), { who: state.seats[raiser].name, reason: "aggressor" });
|
||||
});
|
||||
|
||||
test("a re-raise moves it to the last one, not the first", () => {
|
||||
const state = table(0);
|
||||
checkRound(state); checkRound(state); checkRound(state);
|
||||
|
||||
const first = state.actingSeatIndex;
|
||||
Betting.raiseTo(state, first, 20);
|
||||
const second = state.actingSeatIndex;
|
||||
Betting.raiseTo(state, second, 60);
|
||||
while (state.actingSeatIndex !== null) {
|
||||
const i = state.actingSeatIndex;
|
||||
if (Betting.toCall(state, state.seats[i]) > 0) Betting.call(state, i); else Betting.check(state, i);
|
||||
}
|
||||
|
||||
assert.equal(call(state).who, state.seats[second].name);
|
||||
});
|
||||
|
||||
test("a short all-in still shows first, even though it doesn't reopen the betting", () => {
|
||||
// The rule about reopening is about who may raise. It says nothing about who
|
||||
// shows, and the player who shoved is still the last one who put money in.
|
||||
const state = table(0);
|
||||
checkRound(state); checkRound(state); checkRound(state);
|
||||
|
||||
const bettor = state.actingSeatIndex;
|
||||
Betting.raiseTo(state, bettor, 40);
|
||||
const shortStack = state.actingSeatIndex;
|
||||
// Over the $40 but only by $15, where a full raise would need $40 more. Going
|
||||
// *under* the bet would be a short call, not a short raise, and wouldn't make
|
||||
// them the aggressor at all.
|
||||
state.seats[shortStack].stack = 55;
|
||||
Betting.allIn(state, shortStack);
|
||||
assert.equal(Betting.committed(state.seats[shortStack]), 55, "raised, if not by enough");
|
||||
while (state.actingSeatIndex !== null) {
|
||||
const i = state.actingSeatIndex;
|
||||
if (Betting.toCall(state, state.seats[i]) > 0) Betting.call(state, i); else Betting.check(state, i);
|
||||
}
|
||||
|
||||
assert.equal(call(state).who, state.seats[shortStack].name);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a river checked through", () => {
|
||||
test("the first live player left of the button shows first", () => {
|
||||
const state = table(0); // button Ann
|
||||
checkRound(state); checkRound(state); checkRound(state); checkRound(state);
|
||||
|
||||
assert.deepEqual(call(state), { who: "Ben", reason: "leftOfButton" });
|
||||
});
|
||||
|
||||
test("an earlier street's raiser doesn't carry over to the river", () => {
|
||||
// The rule is about the *final* round. Leading the flop and then checking the
|
||||
// river doesn't oblige anyone.
|
||||
const state = table(0);
|
||||
checkRound(state); // pre-flop
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 20); // leads the flop
|
||||
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
|
||||
Betting.closeStreet(state);
|
||||
checkRound(state); // turn
|
||||
checkRound(state); // river
|
||||
|
||||
assert.equal(call(state).reason, "leftOfButton");
|
||||
});
|
||||
|
||||
test("it skips a folded seat rather than naming somebody who isn't in the hand", () => {
|
||||
const state = table(0); // button Ann, so Ben would be first
|
||||
checkRound(state); checkRound(state); checkRound(state);
|
||||
Betting.fold(state, 1); // Ben gets out on the river
|
||||
while (state.actingSeatIndex !== null) {
|
||||
const i = state.actingSeatIndex;
|
||||
if (Betting.toCall(state, state.seats[i]) > 0) Betting.call(state, i); else Betting.check(state, i);
|
||||
}
|
||||
Betting.closeStreet(state);
|
||||
|
||||
assert.equal(call(state).who, "Cal", "the next one round who is still in");
|
||||
});
|
||||
});
|
||||
|
||||
describe("everyone all-in before the river", () => {
|
||||
test("the last player who could put money in shows first", () => {
|
||||
// There was no final round to lead, so "checked round" isn't a choice anybody
|
||||
// made and the seat left of the button hasn't earned the obligation.
|
||||
const state = table(0, 30);
|
||||
checkRound(state); // pre-flop
|
||||
|
||||
const shover = state.actingSeatIndex;
|
||||
Betting.allIn(state, shover);
|
||||
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
|
||||
Betting.closeStreet(state);
|
||||
|
||||
assert.equal(Betting.canStillBet(state), false, "nobody has chips left to bet");
|
||||
assert.deepEqual(call(state), { who: state.seats[shover].name, reason: "allInAggressor" });
|
||||
});
|
||||
|
||||
test("a hand where everyone got in for the blind and nobody raised falls to the button", () => {
|
||||
// Two-dollar stacks against a two-dollar blind: everyone is all-in by calling,
|
||||
// and calling is not aggression. Nobody chose to lead, so nobody is on the hook
|
||||
// and it goes back to the seat left of the button.
|
||||
const state = table(0, 2);
|
||||
checkRound(state);
|
||||
|
||||
assert.equal(Betting.canStillBet(state), false, "nobody has a chip behind");
|
||||
assert.equal(state.handAggressorIndex, null, "and nobody ever raised");
|
||||
const answer = call(state);
|
||||
assert.ok(answer, "there is still a showdown to order");
|
||||
assert.equal(answer.reason, "leftOfButton");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when there is nothing to call", () => {
|
||||
test("one player left isn't a showdown", () => {
|
||||
const state = table(0);
|
||||
[1, 2, 3].forEach(i => Betting.fold(state, i));
|
||||
assert.equal(Betting.showdownOrder(state), null);
|
||||
});
|
||||
|
||||
test("an aggressor who has since folded doesn't get named", () => {
|
||||
// Can't happen in play — you don't fold after raising unopposed — but the
|
||||
// index is a number on the state and a reset table could hold a stale one.
|
||||
const state = table(0);
|
||||
checkRound(state); checkRound(state); checkRound(state); checkRound(state);
|
||||
state.streetAggressorIndex = 1;
|
||||
state.seats[1].folded = true;
|
||||
|
||||
const answer = call(state);
|
||||
assert.notEqual(answer.who, "Ben");
|
||||
assert.equal(answer.reason, "leftOfButton");
|
||||
});
|
||||
|
||||
test("an index pointing at no seat at all is ignored", () => {
|
||||
const state = table(0);
|
||||
checkRound(state); checkRound(state); checkRound(state); checkRound(state);
|
||||
state.streetAggressorIndex = 99;
|
||||
assert.equal(call(state).reason, "leftOfButton");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the tracking resets when it should", () => {
|
||||
test("a new hand starts with nobody on the hook", () => {
|
||||
const state = table(0);
|
||||
checkRound(state); checkRound(state); checkRound(state);
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 20);
|
||||
assert.notEqual(state.handAggressorIndex, null);
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
assert.equal(state.streetAggressorIndex, null);
|
||||
assert.equal(state.handAggressorIndex, null, "last night's raise isn't this hand's business");
|
||||
});
|
||||
|
||||
test("posting a blind is not aggression", () => {
|
||||
const state = table(0);
|
||||
assert.equal(state.streetAggressorIndex, null);
|
||||
assert.equal(state.handAggressorIndex, null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
// What the sidebar is a list of.
|
||||
//
|
||||
// The panels were Table Setup, Session, Chip Legend, Keyboard and Table Notes, and
|
||||
// "Session" was a container word rather than a name. It held the standings, a waiting
|
||||
// list, the hand log and the save/load/reset buttons — a topic, a task, a log and three
|
||||
// utilities under one label, which is the "don't mix apples and oranges" failure: a
|
||||
// navigation layer with more than one logic in it. Measured at 1206px and 103 visible
|
||||
// elements, against 555 and 29 for the next biggest, and "The table itself" sat 1052px
|
||||
// down inside it, so reaching Reset table meant opening a panel called Session and
|
||||
// scrolling past the money and the log.
|
||||
//
|
||||
// This is a markup test because that is where the fault lived. Nothing was broken —
|
||||
// every control worked and every id resolved — so nothing failed. What was wrong was
|
||||
// which panel a thing was in and what that panel was called.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const PAGE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
|
||||
// A sidebar panel never contains another one — Table Setup's sub-panels carry a
|
||||
// different class — so splitting on the opening tag is enough to say which panel any
|
||||
// piece of markup is in, without parsing the document.
|
||||
const panels = () => PAGE
|
||||
.split(/<details class="sidebarPanel"/)
|
||||
.slice(1)
|
||||
.map(chunk => {
|
||||
const name = chunk.match(/<summary>\s*(?:<h2>)?([^<]+)/);
|
||||
return { name: name ? name[1].trim() : "(no summary)", markup: chunk };
|
||||
});
|
||||
|
||||
const panelHolding = id => {
|
||||
const found = panels().filter(p => p.markup.includes(`id="${id}"`));
|
||||
assert.equal(found.length, 1, `${id} is in ${found.length} panels, expected exactly 1`);
|
||||
return found[0].name;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the sidebar's names", () => {
|
||||
test("say what is inside them", () => {
|
||||
// Every one of these is a thing a dealer goes looking for. None of them is a
|
||||
// word that could hold anything — which is the test "Session" fails.
|
||||
assert.deepEqual(panels().map(p => p.name), [
|
||||
"Table Setup",
|
||||
"Standings",
|
||||
"Waiting list",
|
||||
"Hands",
|
||||
"Chip Legend",
|
||||
"Keyboard",
|
||||
"Table Notes",
|
||||
"Save & reset",
|
||||
]);
|
||||
});
|
||||
|
||||
test("and each one is a heading, not only a button", () => {
|
||||
// A <summary> is announced as a button. The sidebar's names are the whole of its
|
||||
// navigation, so without a heading inside, none of them is reachable by anyone
|
||||
// moving through the page by heading — and splitting Session removed the four
|
||||
// subheads that used to be the only headings down here.
|
||||
panels().forEach(p => assert.match(p.markup, /<summary><h2>/,
|
||||
`the ${p.name} panel's name is not a heading`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("where things live", () => {
|
||||
test("the money is together, and the warning about losing it is with it", () => {
|
||||
assert.equal(panelHolding("standingsList"), "Standings");
|
||||
assert.equal(panelHolding("settleUp"), "Standings");
|
||||
assert.equal(panelHolding("bankReconcile"), "Standings");
|
||||
// renderStorageWarning() says this "sits at the top of the panel a dealer opens
|
||||
// to check the money". That panel is now called Standings.
|
||||
assert.equal(panelHolding("storageWarning"), "Standings");
|
||||
});
|
||||
|
||||
test("the queue for a seat is its own panel", () => {
|
||||
assert.equal(panelHolding("waitingList"), "Waiting list");
|
||||
assert.equal(panelHolding("waitingListForm"), "Waiting list");
|
||||
});
|
||||
|
||||
test("the log and the two ways out of it are together", () => {
|
||||
assert.equal(panelHolding("handHistoryList"), "Hands");
|
||||
assert.equal(panelHolding("handHistoryFilter"), "Hands");
|
||||
assert.equal(panelHolding("exportHistoryBtn"), "Hands");
|
||||
assert.equal(panelHolding("clearHistoryBtn"), "Hands");
|
||||
});
|
||||
|
||||
test("and the file is last, with the danger on the label", () => {
|
||||
assert.equal(panelHolding("saveTableBtn"), "Save & reset");
|
||||
assert.equal(panelHolding("loadTableBtn"), "Save & reset");
|
||||
assert.equal(panelHolding("resetTableBtn"), "Save & reset");
|
||||
assert.equal(panels()[panels().length - 1].name, "Save & reset",
|
||||
"the panel that ends the night belongs at the end, away from the working ones");
|
||||
});
|
||||
});
|
||||
|
||||
describe("no panel goes back to being a grab-bag", () => {
|
||||
// Not a size limit — Table Setup is legitimately long and Chip Legend legitimately
|
||||
// repeats a row per chip. What this counts is *subjects*: a panel that needs its own
|
||||
// headings to say what its parts are is a panel holding more than one thing, which
|
||||
// is what Session was and how it got that way.
|
||||
test("at most one subject needs a heading inside a panel", () => {
|
||||
panels().forEach(p => {
|
||||
const subheads = [...p.markup.matchAll(/<h3 class="panelSubhead">([^<]+)</g)]
|
||||
.map(m => m[1].trim());
|
||||
assert.ok(subheads.length <= 1,
|
||||
`${p.name} has ${subheads.length} sections inside it (${subheads.join(", ")}) — ` +
|
||||
`if a panel needs headings to list its parts, its name is not doing the work`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and the ones that remain are a detail of their panel, not a second subject", () => {
|
||||
// Settle up is the standings once the night ends; Stacks in chips is the chip
|
||||
// legend counted out. Both are the same subject at a different moment, which is
|
||||
// the case a subhead is for.
|
||||
const kept = panels().flatMap(p =>
|
||||
[...p.markup.matchAll(/<h3 class="panelSubhead">([^<]+)</g)]
|
||||
.map(m => `${p.name}: ${m[1].trim()}`));
|
||||
assert.deepEqual(kept, ["Standings: Settle up", "Chip Legend: Stacks in chips"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// The toast that outlived the thing it was talking about.
|
||||
//
|
||||
// Toasts are news about the last action, and they carry an Undo. Take another action
|
||||
// and both of those go wrong at once: the sentence describes a step that is now two
|
||||
// steps back, and the Undo behind it restores a snapshot from before everything since.
|
||||
// "Next hand — button on seat 2. [Undo]" left up while a hand is dealt and three
|
||||
// players call is an offer to move the button, wired to a button that deletes the hand.
|
||||
//
|
||||
// It is also simply in the way. The toast is a fixed strip near the bottom of the
|
||||
// screen; on a 360x640 phone there is nowhere for it to sit that is clear of both the
|
||||
// felt and the action bar, so a stale one covers seats and controls for eight seconds.
|
||||
//
|
||||
// So the bar drops it as it takes the next action. These tests hold that funnel shut:
|
||||
// every action a dealer can take from the bar goes through `commit`, and `commit`
|
||||
// clears the toast before it does anything else.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { ActionBar, Betting, makeTable } = require("./load");
|
||||
|
||||
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
|
||||
const barSource = fs.readFileSync(path.join(SRC, "actionBar.js"), "utf8");
|
||||
|
||||
// A bar sat at a real table, with the toast replaced by something that keeps a log
|
||||
// and the re-render stubbed out — none of these rules need the DOM back.
|
||||
function onTable() {
|
||||
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0 });
|
||||
const log = [];
|
||||
globalThis.Toast = {
|
||||
onScreen: null,
|
||||
show(message) { this.onScreen = message; log.push(`show ${message}`); },
|
||||
dismiss() { this.onScreen = null; log.push("dismiss"); },
|
||||
};
|
||||
ActionBar.state = state;
|
||||
ActionBar.render = () => {};
|
||||
ActionBar.hooks = {
|
||||
beginAction: label => log.push(`begin ${label}`),
|
||||
save: () => log.push("save"),
|
||||
rerender: () => log.push("rerender"),
|
||||
};
|
||||
return { bar: ActionBar, state, log };
|
||||
}
|
||||
|
||||
test.afterEach(() => { delete globalThis.Toast; });
|
||||
|
||||
describe("a toast does not outlive its own news", () => {
|
||||
test("the next action from the bar clears it", () => {
|
||||
const { bar, state, log } = onTable();
|
||||
Toast.show("Next hand — button on seat 2.");
|
||||
|
||||
bar.commit("dealing the hand", () => Betting.beginHand(state));
|
||||
|
||||
assert.equal(Toast.onScreen, null, "the hand is dealt; the sentence about seating the table is not news any more");
|
||||
assert.ok(log.indexOf("dismiss") < log.indexOf("begin dealing the hand"),
|
||||
`the toast goes before the action is recorded, so nothing shown during the action is swept up with it: ${log.join(", ")}`);
|
||||
});
|
||||
|
||||
test("a toast raised by the action itself survives", () => {
|
||||
const { bar, log } = onTable();
|
||||
bar.commit("something that talks back", () => Toast.show("Bob is all in."));
|
||||
|
||||
assert.equal(Toast.onScreen, "Bob is all in.",
|
||||
"commit clears what was there when it started, not what its own work puts up");
|
||||
assert.deepEqual(log, ["dismiss", "begin something that talks back", "show Bob is all in.", "save", "rerender"]);
|
||||
});
|
||||
|
||||
test("with no toast on screen it is still just one dismiss", () => {
|
||||
const { bar, log } = onTable();
|
||||
bar.commit("a fold", () => {});
|
||||
assert.equal(log.filter(l => l === "dismiss").length, 1);
|
||||
});
|
||||
|
||||
test("a page with no Toast at all does not throw", () => {
|
||||
const { bar, state } = onTable();
|
||||
delete globalThis.Toast;
|
||||
assert.doesNotThrow(() => bar.commit("dealing the hand", () => Betting.beginHand(state)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("every bar action really does go through that funnel", () => {
|
||||
// The dismiss is worth nothing if some button reaches past `commit` to mutate the
|
||||
// table itself. Actions are written as `this.commit(...)`; a `Betting.` call made
|
||||
// straight from a click handler would be one that skips the clean-up — and the
|
||||
// undo history with it, which is the older reason this funnel exists.
|
||||
test("no click handler calls the betting engine directly", () => {
|
||||
const strays = barSource
|
||||
.split("\n")
|
||||
.map((line, i) => [i + 1, line])
|
||||
.filter(([, line]) => /=>\s*Betting\.(fold|check|call|allIn|raiseTo|beginHand|closeStreet)\(/.test(line))
|
||||
.filter(([, line]) => !/this\.commit\(/.test(line))
|
||||
.map(([n, line]) => `actionBar.js:${n}: ${line.trim()}`);
|
||||
|
||||
assert.deepEqual(strays, [], "these change the table without going through commit");
|
||||
});
|
||||
|
||||
test("commit is what clears the toast, not each button", () => {
|
||||
const dismissals = barSource.split("\n").filter(l => /Toast\.dismiss\(\)/.test(l)).length;
|
||||
// Two: this one, and the raise pad — which opens without committing anything,
|
||||
// and needs the strip clear because the pad makes the bar tall enough to push
|
||||
// a toast up over the seats.
|
||||
assert.equal(dismissals, 2,
|
||||
"a third dismissal means a button is doing by hand what commit already does");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
// The chips the felt plays with are the chips the box is checked against.
|
||||
//
|
||||
// "Starting stack" is offered in the setup panel as the ordinary way to open a table —
|
||||
// "seats without their own stack start here" — and everything a dealer looks at reads
|
||||
// it: the puck, the action bar, what a player is allowed to bet. The money side did
|
||||
// not. It read `seat.stack` directly, which is null for exactly those seats, so a
|
||||
// three-handed table playing for $600 reported no chips in play, a balanced bank and a
|
||||
// standings list with nobody on it.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, HoldemState, Betting } = require("./load");
|
||||
|
||||
const openedWithStartingStack = (stack = 200, names = ["Ann", "Ben", "Cal"]) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 6);
|
||||
state.defaultStack = stack;
|
||||
names.forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
});
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("what the table is playing for", () => {
|
||||
test("is what the bank counts", () => {
|
||||
const state = openedWithStartingStack();
|
||||
// What the felt says each of them has.
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => Betting.stackOf(state, p)), [200, 200, 200]);
|
||||
assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600);
|
||||
});
|
||||
|
||||
test("and the box being empty is a problem it now reports", () => {
|
||||
const bank = HoldemState.bankReconciliation(openedWithStartingStack());
|
||||
assert.equal(bank.cashIn, 0);
|
||||
assert.equal(bank.difference, 600);
|
||||
assert.equal(bank.balanced, false, "six hundred dollars of chips nobody paid for");
|
||||
});
|
||||
|
||||
test("empty chairs are not people holding two hundred dollars", () => {
|
||||
// The starting stack stands in for a seat that hasn't been given one, and every
|
||||
// empty chair is such a seat. Counting them would invent $600 of chips at an
|
||||
// empty table.
|
||||
const state = openedWithStartingStack();
|
||||
assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600, "three players, not six chairs");
|
||||
|
||||
const empty = HoldemState.createDefault();
|
||||
empty.defaultStack = 200;
|
||||
assert.equal(HoldemState.bankReconciliation(empty).chipsInPlay, 0);
|
||||
assert.equal(HoldemState.bankReconciliation(empty).balanced, true);
|
||||
});
|
||||
|
||||
test("once the cash is written down it balances", () => {
|
||||
const state = openedWithStartingStack();
|
||||
state.seats.slice(0, 3).forEach(p => { p.buyIns = 200; });
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("a table where everyone has their own stack is unchanged", () => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 4);
|
||||
["Ann", "Ben"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].stack = 150;
|
||||
state.seats[i].buyIns = 150;
|
||||
});
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.chipsInPlay, 300);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("who is on the settle-up list", () => {
|
||||
test("everyone holding chips, including the ones holding the table's own stack", () => {
|
||||
const rows = HoldemState.sessionStandings(openedWithStartingStack());
|
||||
assert.deepEqual(rows.map(r => r.name), ["Ann", "Ben", "Cal"]);
|
||||
assert.deepEqual(rows.map(r => r.chipsInFront), [200, 200, 200]);
|
||||
});
|
||||
|
||||
test("with nothing bought in yet, which is the thing to notice", () => {
|
||||
const rows = HoldemState.sessionStandings(openedWithStartingStack());
|
||||
assert.deepEqual(rows.map(r => r.boughtIn), [0, 0, 0]);
|
||||
assert.deepEqual(rows.map(r => r.cashPosition), [200, 200, 200]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("naming the seats the cash is missing for", () => {
|
||||
test("says who, and which kind of missing it is", () => {
|
||||
const state = openedWithStartingStack();
|
||||
state.seats[2].stack = 320; // Cal typed his own: an unrecorded rebuy
|
||||
|
||||
const owing = HoldemState.seatsOwingCash(state);
|
||||
assert.deepEqual(owing.map(row => row.name), ["Ann", "Ben", "Cal"]);
|
||||
assert.deepEqual(owing.map(row => row.fromStartingStack), [true, true, false]);
|
||||
assert.deepEqual(owing.map(row => row.chips), [200, 200, 320]);
|
||||
});
|
||||
|
||||
test("a seat whose buy-in is written down isn't on the list", () => {
|
||||
const state = openedWithStartingStack();
|
||||
state.seats[0].buyIns = 200;
|
||||
assert.deepEqual(HoldemState.seatsOwingCash(state).map(row => row.name), ["Ben", "Cal"]);
|
||||
});
|
||||
|
||||
test("and a table where nothing is owed has nobody on it", () => {
|
||||
const state = openedWithStartingStack();
|
||||
state.seats.slice(0, 3).forEach(p => { p.buyIns = 200; });
|
||||
assert.deepEqual(HoldemState.seatsOwingCash(state), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writing the starting stacks down", () => {
|
||||
test("records exactly what those seats are holding", () => {
|
||||
const state = openedWithStartingStack();
|
||||
const done = HoldemState.recordStartingBuyIns(state);
|
||||
assert.equal(done.seats, 3);
|
||||
assert.equal(done.total, 600);
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.buyIns), [200, 200, 200]);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("and leaves somebody else's forgotten rebuy alone", () => {
|
||||
// Only the dealer knows what that was. Guessing would be the tool inventing
|
||||
// money, which is the fault this whole round is about.
|
||||
const state = openedWithStartingStack();
|
||||
state.seats[2].stack = 320;
|
||||
HoldemState.recordStartingBuyIns(state);
|
||||
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.buyIns), [200, 200, 0]);
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, false);
|
||||
assert.equal(bank.difference, 320, "still exactly Cal's chips, and still reported");
|
||||
});
|
||||
|
||||
test("does nothing on a table that doesn't need it", () => {
|
||||
const state = openedWithStartingStack();
|
||||
state.seats.slice(0, 3).forEach(p => { p.buyIns = 200; });
|
||||
const done = HoldemState.recordStartingBuyIns(state);
|
||||
assert.equal(done.seats, 0);
|
||||
assert.equal(done.total, 0);
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.buyIns), [200, 200, 200]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("through a hand", () => {
|
||||
test("the money adds up from the deal to the payout", () => {
|
||||
const state = openedWithStartingStack(200, ["Ann", "Ben", "Cal", "Dee"]);
|
||||
HoldemState.recordStartingBuyIns(state);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true, "blinds are still on the table");
|
||||
|
||||
[3, 0, 1].forEach(() => { if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); });
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
|
||||
const pot = Betting.potSoFar(state);
|
||||
Betting.sweepToHandTotals(state);
|
||||
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, true);
|
||||
assert.equal(bank.chipsInPlay, 800, "four buy-ins of two hundred, wherever they have moved to");
|
||||
assert.ok(state.seats[0].stack > 200, "and the winner is holding more than they sat down with");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
// The straddle: a blind raise put out before the cards by the player left of the big
|
||||
// blind, for twice it.
|
||||
//
|
||||
// Home games run them constantly, and it is three rules at once — the amount, who is
|
||||
// first to act, and what the minimum raise becomes. Faking one by dealing and having
|
||||
// the straddler "raise" gets the last of those right and the other two wrong.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
// Six seats, everyone in, button on seat 0: small blind seat 1, big blind seat 2,
|
||||
// straddle seat 3.
|
||||
const table = (straddles = 0, extra = {}) => {
|
||||
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0, ...extra });
|
||||
state.straddleCount = straddles;
|
||||
return state;
|
||||
};
|
||||
|
||||
const dealt = (straddles = 0, extra = {}) => {
|
||||
const state = table(straddles, extra);
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("what goes in the middle", () => {
|
||||
test("the seat left of the big blind puts out twice it", () => {
|
||||
const state = dealt(1);
|
||||
assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 0, 0]);
|
||||
});
|
||||
|
||||
test("a re-straddle doubles again, one seat further round", () => {
|
||||
const state = dealt(2);
|
||||
assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 8, 0]);
|
||||
});
|
||||
|
||||
test("none of it comes from anywhere but the straddler's stack", () => {
|
||||
const before = chipsOnTable(table(2));
|
||||
assert.equal(chipsOnTable(dealt(2)), before);
|
||||
});
|
||||
|
||||
test("the felt is told who straddled, not left to work it out", () => {
|
||||
assert.deepEqual(dealt(2).postedStraddles, [3, 4]);
|
||||
assert.deepEqual(dealt(0).postedStraddles, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("who is on the clock", () => {
|
||||
test("action starts left of the straddler, not left of the big blind", () => {
|
||||
assert.equal(dealt(0).actingSeatIndex, 3, "without a straddle it is the seat after the big blind");
|
||||
assert.equal(dealt(1).actingSeatIndex, 4);
|
||||
assert.equal(dealt(2).actingSeatIndex, 5);
|
||||
});
|
||||
|
||||
test("the straddler still has the option when it comes back to them", () => {
|
||||
const state = dealt(1);
|
||||
// Everyone calls the straddle round to the big blind.
|
||||
[4, 5, 0, 1, 2].forEach(seat => {
|
||||
assert.equal(state.actingSeatIndex, seat, `expected seat ${seat} to be up`);
|
||||
Betting.call(state, seat);
|
||||
});
|
||||
assert.equal(state.actingSeatIndex, 3, "back to the straddler");
|
||||
assert.equal(Betting.toCall(state, state.seats[3]), 0, "nothing to call");
|
||||
assert.equal(Betting.isStreetComplete(state), false, "and the street isn't over until they say so");
|
||||
});
|
||||
|
||||
test("the straddler checking closes it", () => {
|
||||
const state = dealt(1);
|
||||
[4, 5, 0, 1, 2].forEach(seat => Betting.call(state, seat));
|
||||
Betting.check(state, 3);
|
||||
assert.equal(Betting.isStreetComplete(state), true);
|
||||
assert.equal(state.actingSeatIndex, null);
|
||||
});
|
||||
|
||||
test("the big blind acts in turn, not last", () => {
|
||||
const state = dealt(1);
|
||||
Betting.call(state, 4);
|
||||
Betting.call(state, 5);
|
||||
Betting.call(state, 0);
|
||||
Betting.call(state, 1);
|
||||
assert.equal(state.actingSeatIndex, 2, "the big blind is up before the straddler");
|
||||
assert.equal(Betting.toCall(state, state.seats[2]), 2, "and owes the difference to the straddle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it costs to raise", () => {
|
||||
test("the minimum raise is twice the straddle, not the straddle plus a blind", () => {
|
||||
// $2 blinds, $4 straddle: the next raise is to $8. Measured off the big blind
|
||||
// instead it comes out at $6, and every player at the table would argue.
|
||||
assert.equal(Betting.minRaiseTo(dealt(1)), 8);
|
||||
assert.equal(Betting.minRaiseTo(dealt(2)), 16);
|
||||
assert.equal(Betting.minRaiseTo(dealt(0)), 4);
|
||||
});
|
||||
|
||||
test("what the table owes is measured off the straddle", () => {
|
||||
const state = dealt(1);
|
||||
assert.equal(state.currentBet, 4);
|
||||
assert.equal(Betting.toCall(state, state.seats[4]), 4, "a fresh player calls the straddle");
|
||||
assert.equal(Betting.toCall(state, state.seats[1]), 3, "the small blind owes the rest");
|
||||
assert.equal(Betting.toCall(state, state.seats[2]), 2, "so does the big blind");
|
||||
assert.equal(Betting.toCall(state, state.seats[3]), 0, "the straddler is already good");
|
||||
});
|
||||
|
||||
test("a raise over the straddle is a raise, and the straddler can still re-raise", () => {
|
||||
const state = dealt(1);
|
||||
Betting.raiseTo(state, 4, 12);
|
||||
assert.equal(state.currentBet, 12);
|
||||
assert.equal(Betting.minRaiseTo(state), 20, "twice again over a raise of eight");
|
||||
assert.equal(state.seats[3].hasActed, false, "the straddler has not acted yet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("how many a table can take", () => {
|
||||
test("everyone but the two blinds", () => {
|
||||
assert.equal(Betting.maxStraddles(table(0)), 4);
|
||||
assert.equal(Betting.maxStraddles(table(0, { seatCount: 3, seated: [0, 1, 2] })), 1);
|
||||
});
|
||||
|
||||
test("heads-up, none — there is no seat left of the big blind to straddle from", () => {
|
||||
const heads = table(1, { seatCount: 6, seated: [0, 1] });
|
||||
assert.equal(Betting.maxStraddles(heads), 0);
|
||||
assert.deepEqual(Betting.straddleSeats(heads), []);
|
||||
Betting.beginHand(heads);
|
||||
assert.deepEqual(heads.seats.map(p => p.committed), [1, 2, 0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
test("asking for more than the table has stops at the last seat", () => {
|
||||
const state = table(0, { seatCount: 4, seated: [0, 1, 2, 3] });
|
||||
state.straddleCount = 99;
|
||||
assert.equal(Betting.straddleSeats(state).length, 2);
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(state.seats.map(p => p.committed), [8, 1, 2, 4]);
|
||||
assert.equal(state.actingSeatIndex, 1, "round to the small blind, who is first to act");
|
||||
});
|
||||
|
||||
test("a seat with no chips is never handed one", () => {
|
||||
const state = table(1);
|
||||
state.seats[3].stack = 0;
|
||||
// Seat 3 is sat out for the deal, so the straddle passes to seat 4.
|
||||
assert.deepEqual(Betting.straddleSeats(state), [{ index: 4, amount: 4 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it doesn't outlive", () => {
|
||||
test("the hand it was called for", () => {
|
||||
const state = dealt(1);
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(state.straddleCount, 0, "nobody straddles again by accident");
|
||||
assert.deepEqual(state.postedStraddles, []);
|
||||
});
|
||||
|
||||
test("but it survives the button being pushed around before the deal", () => {
|
||||
// repostBlinds takes every posted chip back and deals again; a straddle that
|
||||
// vanished there would have been paid once and never returned.
|
||||
const state = dealt(1);
|
||||
const before = chipsOnTable(state);
|
||||
Betting.repostBlinds(state);
|
||||
assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 0, 0]);
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
|
||||
test("a table saved before straddles existed reads as having none", () => {
|
||||
const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
|
||||
delete parsed.straddleCount;
|
||||
delete parsed.postedStraddles;
|
||||
const migrated = HoldemState.migrate(parsed);
|
||||
assert.equal(migrated.straddleCount, 0);
|
||||
assert.deepEqual(migrated.postedStraddles, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the dealer is shown before dealing", () => {
|
||||
test("the straddles that are coming, and how many more it could take", () => {
|
||||
const preview = Betting.blindPreview(table(1));
|
||||
assert.deepEqual(preview.straddles, [{ index: 3, amount: 4 }]);
|
||||
assert.equal(preview.maxStraddles, 4);
|
||||
});
|
||||
|
||||
test("and the preview matches what dealing actually does", () => {
|
||||
const state = table(2);
|
||||
const preview = Betting.blindPreview(state);
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(preview.straddles.map(s => s.index), state.postedStraddles);
|
||||
preview.straddles.forEach(({ index, amount }) => {
|
||||
assert.equal(state.seats[index].committed, amount, `seat ${index + 1}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// Taking the table off this device.
|
||||
//
|
||||
// A night lives in one browser's localStorage and nowhere else. A dealer whose phone
|
||||
// dies, or who wants to carry on from the tablet after the break, loses the stacks,
|
||||
// the buy-ins, where the button is and the whole hand log — and the CSV export can't
|
||||
// bring any of that back, because it records what happened rather than where the
|
||||
// table stands.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Betting, makeTable } = require("./load");
|
||||
|
||||
const aNight = () => {
|
||||
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 1 });
|
||||
["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].buyIns = 200;
|
||||
});
|
||||
state.seats[2].stack = 340;
|
||||
state.notes = "Cal owes the bar $12";
|
||||
Betting.beginHand(state);
|
||||
HoldemState.recordHand(state, [{ id: state.seats[2].id, label: "Cal", amount: 60 }]);
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("a table written out and read back", () => {
|
||||
test("comes back the same table", () => {
|
||||
const before = aNight();
|
||||
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
|
||||
|
||||
assert.deepEqual(after.seats.map(p => p.name), before.seats.map(p => p.name));
|
||||
assert.deepEqual(after.seats.map(p => p.stack), before.seats.map(p => p.stack));
|
||||
assert.deepEqual(after.seats.map(p => p.buyIns), before.seats.map(p => p.buyIns));
|
||||
assert.equal(after.dealerSeatIndex, before.dealerSeatIndex);
|
||||
assert.equal(after.handNumber, before.handNumber);
|
||||
assert.equal(after.notes, before.notes);
|
||||
assert.equal(after.handHistory.length, before.handHistory.length);
|
||||
});
|
||||
|
||||
test("keeps the things a CSV can't carry", () => {
|
||||
// The whole reason this exists: an export records what happened, not where the
|
||||
// table stands.
|
||||
const before = aNight();
|
||||
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
|
||||
|
||||
assert.equal(after.bettingOpen, before.bettingOpen, "a hand in progress is still in progress");
|
||||
assert.deepEqual(after.postedBlinds, before.postedBlinds, "so the next rotation is right");
|
||||
assert.equal(after.street, before.street);
|
||||
assert.deepEqual(after.seats.map(p => p.committed), before.seats.map(p => p.committed));
|
||||
assert.deepEqual(after.chipLegend, before.chipLegend);
|
||||
});
|
||||
|
||||
test("the standings and the bank check agree across the trip", () => {
|
||||
const before = aNight();
|
||||
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
|
||||
|
||||
assert.deepEqual(HoldemState.sessionStandings(after), HoldemState.sessionStandings(before));
|
||||
assert.deepEqual(HoldemState.bankReconciliation(after), HoldemState.bankReconciliation(before));
|
||||
});
|
||||
|
||||
test("it can be dealt from straight away", () => {
|
||||
const before = aNight();
|
||||
HoldemState.startNextHand(before);
|
||||
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
|
||||
|
||||
Betting.beginHand(after);
|
||||
assert.equal(after.bettingOpen, true);
|
||||
assert.ok(after.actingSeatIndex !== null, "somebody is on the clock");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it refuses", () => {
|
||||
const refuses = (text, because) => {
|
||||
const result = HoldemState.fromBackup(text);
|
||||
assert.ok(result.error, because);
|
||||
assert.equal(result.table, undefined, "and never hands back half a table");
|
||||
};
|
||||
|
||||
test("something that isn't JSON", () => {
|
||||
refuses("not a file", "unreadable");
|
||||
refuses("", "empty");
|
||||
});
|
||||
|
||||
test("JSON that isn't a table", () => {
|
||||
refuses('{"hello":"world"}', "no seats in it");
|
||||
refuses("[1,2,3]", "an array of nothing");
|
||||
refuses("null", "null");
|
||||
});
|
||||
|
||||
test("a save from something else that happens to be JSON", () => {
|
||||
refuses('{"kind":"some.other.tool","table":{"seats":[]}}', "wrong kind");
|
||||
});
|
||||
|
||||
test("a file from a newer version, rather than guessing at it", () => {
|
||||
// Loading it risks reading a field whose meaning has changed, and the dealer
|
||||
// would find out from the money.
|
||||
const backup = JSON.parse(HoldemState.toBackup(aNight()));
|
||||
backup.version = HoldemState.BACKUP_VERSION + 1;
|
||||
const result = HoldemState.fromBackup(JSON.stringify(backup));
|
||||
assert.match(result.error, /newer version/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it accepts anyway", () => {
|
||||
test("a bare table, with no envelope around it", () => {
|
||||
// Somebody who copied the storage value out by hand. The envelope is a
|
||||
// convenience, not a password.
|
||||
const state = aNight();
|
||||
const result = HoldemState.fromBackup(JSON.stringify(state));
|
||||
assert.ok(!result.error);
|
||||
assert.deepEqual(result.table.seats.map(p => p.name), state.seats.map(p => p.name));
|
||||
});
|
||||
|
||||
test("a table from before fields that exist now, migrated on the way in", () => {
|
||||
// Same repairs a save from an older version gets, because it is the same
|
||||
// function — a second, looser path for imports is how a format grows two
|
||||
// meanings of the same field.
|
||||
const state = aNight();
|
||||
const raw = JSON.parse(JSON.stringify(state));
|
||||
delete raw.deadButton;
|
||||
delete raw.postedBlinds;
|
||||
delete raw.dealtLastHand;
|
||||
delete raw.discarded;
|
||||
raw.seats.forEach(p => { delete p.buyIns; });
|
||||
|
||||
const { table, error } = HoldemState.fromBackup(JSON.stringify({
|
||||
kind: HoldemState.BACKUP_KIND, version: 1, table: raw,
|
||||
}));
|
||||
assert.ok(!error);
|
||||
assert.equal(table.deadButton, false);
|
||||
assert.deepEqual(table.dealtLastHand, []);
|
||||
assert.equal(table.discarded, 0);
|
||||
assert.equal(table.seats[0].buyIns, 0);
|
||||
});
|
||||
|
||||
test("the time it was saved comes back with it", () => {
|
||||
const result = HoldemState.fromBackup(HoldemState.toBackup(aNight()));
|
||||
assert.ok(result.savedAt, "so the dealer can tell two files apart");
|
||||
assert.ok(!Number.isNaN(new Date(result.savedAt).getTime()));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the file itself", () => {
|
||||
test("is named with the date and time, so two saves in a night are told apart", () => {
|
||||
const at = new Date(2026, 8, 2, 21, 5);
|
||||
assert.equal(HoldemState.backupFileName(at), "holdem-table-2026-09-02-2105.json");
|
||||
});
|
||||
|
||||
test("says what it is, for anyone finding it in a downloads folder", () => {
|
||||
const parsed = JSON.parse(HoldemState.toBackup(aNight()));
|
||||
assert.equal(parsed.kind, "casino.holdem.table");
|
||||
assert.equal(parsed.version, 1);
|
||||
assert.ok(parsed.table.seats.length > 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
// The five cards in the middle of the felt, and the sheet of glass over them.
|
||||
//
|
||||
// Two faults, one on top of the other, and the sweep could see neither.
|
||||
//
|
||||
// #seatsLayer is a transparent `inset: 0` sibling of the table that holds the seat
|
||||
// pucks. The pucks ring the felt; the middle of it is empty layer — and an element with
|
||||
// no background still hit-tests. So every tap aimed at the community cards landed on the
|
||||
// layer instead. Measured at 1280x900 with the board on and five cards out: all five
|
||||
// hit-tested to `div#seatsLayer`, on every screen wide enough to keep the board in the
|
||||
// middle rather than on the strip. Each one is a button that opens the picker for that
|
||||
// card, and not one of them could be pressed. #betsLayer, the other sheet over the felt,
|
||||
// has been `pointer-events: none` since it was written.
|
||||
//
|
||||
// Underneath that, the cards were a little over half the size they were meant to be.
|
||||
// `placeBoard` measures how much middle the felt has and writes it to the centre's
|
||||
// `max-width` — but a max-width caps a box, it does not claim anything, and the centre
|
||||
// is a flex column that sizes to its content. So with 463px of measured room it went on
|
||||
// being 136px wide, the board row inside it is `width: 100%` of that, and five cards
|
||||
// sharing 136px come to rest on their own `min-width`: 24px, against a 44px floor this
|
||||
// sweep holds every other control on the felt to. On the strip, where the row simply
|
||||
// gets the width of the page, the same cards have always drawn 44.
|
||||
//
|
||||
// Neither was visible to a scripted probe — `.click()` on a node skips hit-testing
|
||||
// entirely — and the sweep's occlusion check had only ever been shown three selectors'
|
||||
// worth of the page: the action bar, the header, and the seats.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
const AUDIT = fs.readFileSync(path.join(__dirname, "uiAudit.js"), "utf8");
|
||||
const DRIVER = fs.readFileSync(path.join(__dirname, "runUiAudit.js"), "utf8");
|
||||
const src = name => fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
|
||||
|
||||
const ruleFor = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
|
||||
return CSS.slice(at, CSS.indexOf("}", at));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the sheet over the felt", () => {
|
||||
test("it does not take taps meant for what is under it", () => {
|
||||
assert.match(ruleFor("#seatsLayer"), /pointer-events:\s*none/);
|
||||
});
|
||||
|
||||
// Which is only safe because the thing in it that *is* a control says so itself.
|
||||
test("but a seat still does", () => {
|
||||
assert.match(ruleFor(".seat"), /pointer-events:\s*auto/);
|
||||
});
|
||||
|
||||
// The two chips that want to stay untouchable set their own value and are inside a
|
||||
// seat, so they override what they inherit rather than depending on the layer.
|
||||
test("and the two pucks that are markers rather than controls still are", () => {
|
||||
assert.match(ruleFor(".dealerChip:not(.active)"), /pointer-events:\s*none/);
|
||||
assert.match(ruleFor(".dealerChip.active"), /pointer-events:\s*none/);
|
||||
});
|
||||
|
||||
test("the other sheet is unchanged, and was always right", () => {
|
||||
assert.match(ruleFor("#betsLayer"), /pointer-events:\s*none/);
|
||||
});
|
||||
|
||||
test("what it measured is written beside the rule", () => {
|
||||
const at = CSS.indexOf("/* A transparent sheet over the whole felt");
|
||||
assert.ok(at >= 0, "the rule carries no note saying why");
|
||||
const note = CSS.slice(at, CSS.indexOf("*/", at));
|
||||
assert.match(note, /1280x900/);
|
||||
assert.match(note, /seatsLayer/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("how wide the middle actually is", () => {
|
||||
test("the room it measured is claimed, not only capped", () => {
|
||||
const render = src("render");
|
||||
const at = render.indexOf("placeBoard(row, strip) {");
|
||||
assert.ok(at >= 0, "there is no placeBoard");
|
||||
const body = render.slice(at, render.indexOf("\n },", at));
|
||||
assert.match(body, /centre\.style\.width = `\$\{Math\.round\(width\)\}px`/,
|
||||
"the centre is still given a ceiling and no floor, so it shrink-wraps");
|
||||
assert.match(body, /centre\.style\.maxWidth = `\$\{Math\.round\(width\)\}px`/);
|
||||
});
|
||||
|
||||
// Both come off together when the board leaves the felt, or the centre would keep a
|
||||
// width measured for a board that is no longer in it.
|
||||
test("and both are given back when the board moves to the strip", () => {
|
||||
const render = src("render");
|
||||
const at = render.indexOf("placeBoard(row, strip) {");
|
||||
const body = render.slice(at, render.indexOf("} else {", at));
|
||||
assert.match(body, /centre\.style\.width = ""/);
|
||||
assert.match(body, /centre\.style\.maxWidth = ""/);
|
||||
});
|
||||
|
||||
// The floor is what it always said it was; what changed is that the cards can now
|
||||
// reach past it to the ceiling.
|
||||
test("a card is still allowed to grow to a thumb", () => {
|
||||
const rule = ruleFor(".boardCard");
|
||||
assert.match(rule, /max-width:\s*44px/);
|
||||
assert.match(rule, /min-height:\s*44px/);
|
||||
assert.match(rule, /flex:\s*1 1 0/);
|
||||
});
|
||||
|
||||
test("what it measured is written beside the code", () => {
|
||||
const render = src("render");
|
||||
const at = render.indexOf("// Both, and the width is the half that was missing.");
|
||||
assert.ok(at >= 0, "the fix carries no note saying why");
|
||||
const note = render.slice(at, at + 1400);
|
||||
assert.match(note, /463px/);
|
||||
assert.match(note, /24px/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("why the sweep could not see either of them", () => {
|
||||
// Three selectors: the bar, the header and the seats. Nothing else on the page had
|
||||
// ever been asked whether something was sitting on top of it.
|
||||
test("the occlusion check looks at every control now, not three groups", () => {
|
||||
const at = AUDIT.indexOf("occlusion(selector = null) {");
|
||||
assert.ok(at >= 0, "occlusion still hard-codes its own list");
|
||||
assert.match(AUDIT.slice(at, at + 200), /selector = selector \|\| this\.CONTROL_SELECTOR/);
|
||||
});
|
||||
|
||||
// Widening it put three kinds of non-fault in front of the rule for the first time.
|
||||
test("a control that opted out of the pointer is not reported as covered", () => {
|
||||
const at = AUDIT.indexOf("occlusion(selector = null) {");
|
||||
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
assert.match(body, /getComputedStyle\(el\)\.pointerEvents === "none"/);
|
||||
});
|
||||
|
||||
test("nor is one inside a disclosure nobody has opened", () => {
|
||||
const at = AUDIT.indexOf("occlusion(selector = null) {");
|
||||
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
assert.match(body, /el\.checkVisibility/,
|
||||
"a shut <details> still hands out a real rect, and a size check lets it through");
|
||||
});
|
||||
|
||||
test("and flow content under a fixed layer is said rather than failed", () => {
|
||||
const at = AUDIT.indexOf("occlusion(selector = null) {");
|
||||
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
assert.match(body, /scrolls clear of it/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rest of what the sweep had never run", () => {
|
||||
// It opened five sheets and asked one question of them.
|
||||
test("an overlay gets the whole audit, not the occlusion check alone", () => {
|
||||
const at = DRIVER.indexOf("for (const [name, open] of OVERLAYS) {");
|
||||
assert.ok(at >= 0);
|
||||
const body = DRIVER.slice(at, DRIVER.indexOf("record(`${name}", at));
|
||||
assert.match(body, /LayoutAudit\.runAll\(/);
|
||||
assert.ok(!/return LayoutAudit\.occlusion\(/.test(body),
|
||||
"the overlay pass still runs one rule");
|
||||
});
|
||||
|
||||
// Two of the rules that made running only one of them reasonable ask about the
|
||||
// fixed bar, which is not a question while a scrim is covering the page.
|
||||
test("the two questions about the fixed bar stand down while a sheet is open", () => {
|
||||
["focusHasRoom() {", "furnitureBudget() {"].forEach(signature => {
|
||||
const at = AUDIT.indexOf(signature);
|
||||
assert.ok(at >= 0, `there is no \`${signature}\``);
|
||||
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
assert.match(body, /if \(this\.modalScope\(\)\) \{/,
|
||||
`${signature} still reads a full-screen scrim as furniture`);
|
||||
});
|
||||
});
|
||||
|
||||
// And the third called the bottom four rows of a sheet that scrolls unreachable.
|
||||
test("a dialog's buttons below the fold are only a fault if nothing scrolls to them", () => {
|
||||
const at = AUDIT.indexOf("dialogActionsReachable() {");
|
||||
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
assert.match(body, /if \(this\.reachableByScrolling\(button\)\) return;/);
|
||||
});
|
||||
|
||||
// The helper all three of those lean on had a blind spot of its own: it asked
|
||||
// whether the element had left the box entirely, which is true of something well
|
||||
// below the fold and false of something hanging eight pixels over the edge.
|
||||
test("and reachability counts a control that is only part-way out", () => {
|
||||
const at = AUDIT.indexOf("reachableByScrolling(el) {");
|
||||
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
assert.match(body, /const below = rect\.bottom - box\.bottom/);
|
||||
assert.match(body, /const above = box\.top - rect\.top/);
|
||||
// The comment above the fix quotes the old test, so the code is read on its own.
|
||||
const code = body.split("\n").filter(line => !/^\s*\/\//.test(line)).join("\n");
|
||||
assert.ok(!/rect\.top >= box\.bottom/.test(code),
|
||||
"it still asks whether the element has left the box entirely");
|
||||
});
|
||||
});
|
||||
|
||||
describe("two controls that were a glyph and nothing else", () => {
|
||||
test("the pot wizard's close is named for what leaving does", () => {
|
||||
const markup = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const at = markup.indexOf('id="closePotWizardBtn"');
|
||||
assert.ok(at >= 0);
|
||||
assert.match(markup.slice(at, markup.indexOf(">", at)), /aria-label="Close without paying the pot"/);
|
||||
});
|
||||
|
||||
// Named from the label the dealer typed, and kept up with it — the same shape the
|
||||
// chip legend's remove button already used, in a column of identical crosses.
|
||||
test("and a custom field's remove says which field", () => {
|
||||
const render = src("render");
|
||||
const at = render.indexOf("renderCustomFields(form, player, index) {");
|
||||
const body = render.slice(at, render.indexOf("\n },", at));
|
||||
assert.match(body, /removeBtn\.setAttribute\("aria-label"/);
|
||||
assert.match(body, /labelInput\.value\.trim\(\)/);
|
||||
assert.match(body, /nameRemove\(\);/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// The one mark on a puck that isn't about this hand.
|
||||
//
|
||||
// A seat's chair number was a grey digit in the top-left corner, and the top-left corner
|
||||
// is where the blind badge drops off the rim and where a long centred name reaches.
|
||||
// Measured across 348 seats on 36 tables: 117 of them had the number printed under
|
||||
// something else. All 66 seats posting a blind had it covered outright — the badge is
|
||||
// 22px wide over a 6px digit, at z-index 4 — and 51 more had it sitting on the first
|
||||
// letter of the player's own name, the whole glyph in the worst case, on a 54px puck.
|
||||
//
|
||||
// There is nowhere on the felt to move it to. The button's chip owns the right-hand end
|
||||
// of the top rim. Bets land on the rim between a seat and the pot, which is the bottom
|
||||
// edge for the seats above centre and the top edge for the ones below. The middle of the
|
||||
// puck is three rows — name, bet, stack — and all three are about this hand. So the mark
|
||||
// that isn't gave way, and the two sheets that are about one seat say which seat.
|
||||
//
|
||||
// The sweep had never mentioned any of it, because the check that asks whether a puck
|
||||
// covers itself up only knew about two of the three things a puck wears, and only ever
|
||||
// tested them against writing.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { HoldemState, makeTable } = require("./load");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
const AUDIT = fs.readFileSync(path.join(__dirname, "uiAudit.js"), "utf8");
|
||||
const src = name => fs.readFileSync(
|
||||
path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
|
||||
|
||||
const ruleFor = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
|
||||
return CSS.slice(at, CSS.indexOf("}", at));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("a chair with somebody in it", () => {
|
||||
test("does not draw its number on the felt at all", () => {
|
||||
assert.match(ruleFor(".seat:not(.seatEmpty) .seatChairNumber"), /display:\s*none/);
|
||||
});
|
||||
|
||||
// Where the number is the thing being looked for rather than a footnote on somebody
|
||||
// else's name — which chair is open — it is still large and centred and washed.
|
||||
test("and an open one still wears its number, large and in the middle", () => {
|
||||
const rule = ruleFor(".seatEmpty .seatChairNumber");
|
||||
assert.match(rule, /top:\s*50%/);
|
||||
assert.match(rule, /left:\s*50%/);
|
||||
assert.match(rule, /background:/);
|
||||
});
|
||||
|
||||
// Removing it from the felt is only defensible because the felt is not the only
|
||||
// place it appears. A seat nobody has named says "Seat 5" as its name already.
|
||||
test("a seat with no name typed in still says which seat it is", () => {
|
||||
const state = makeTable({ seatCount: 6 });
|
||||
state.seats[4].name = "";
|
||||
assert.equal(HoldemState.displayName(state.seats[4], 4), "Seat 5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the two sheets that are about one seat", () => {
|
||||
test("name the chair as well as the player", () => {
|
||||
assert.equal(HoldemState.seatHeading({ name: "Alexandra" }, 2), "Alexandra · Seat 3");
|
||||
});
|
||||
|
||||
test("and don't say it twice when the name is already the number", () => {
|
||||
assert.equal(HoldemState.seatHeading({ name: "" }, 4), "Seat 5");
|
||||
assert.equal(HoldemState.seatHeading({ name: " " }, 4), "Seat 5");
|
||||
assert.equal(HoldemState.seatHeading({}, 0), "Seat 1");
|
||||
assert.equal(HoldemState.seatHeading(null, 0), "Seat 1");
|
||||
});
|
||||
|
||||
test("a typed name keeps its own spacing off the ends", () => {
|
||||
assert.equal(HoldemState.seatHeading({ name: " Mike from work " }, 1), "Mike from work · Seat 2");
|
||||
});
|
||||
|
||||
test("the seat editor's heading is the one that says it", () => {
|
||||
const render = src("render");
|
||||
const at = render.indexOf("renderSeatEditor() {");
|
||||
assert.ok(at >= 0);
|
||||
const body = render.slice(at, render.indexOf("\n },", at));
|
||||
assert.match(body, /title\.textContent = HoldemState\.seatHeading\(player, index\)/);
|
||||
});
|
||||
|
||||
// Not the heading here: this header is stuck to the top of the sheet that scrolls,
|
||||
// and a name long enough to wrap would cost every action below it a line.
|
||||
test("the seat menu says it on its first line instead, and says it first", () => {
|
||||
const menu = src("seatMenu");
|
||||
const at = menu.indexOf("const stack = Betting.stackOf(this.state, player);");
|
||||
assert.ok(at >= 0);
|
||||
const body = menu.slice(at, menu.indexOf("subtitle.textContent", at));
|
||||
assert.match(body, /const bits = \[`Seat \$\{this\.seatIndex \+ 1\}`\]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sweep can see it now", () => {
|
||||
const check = () => {
|
||||
const at = AUDIT.indexOf(" seatLabelsClear() {");
|
||||
assert.ok(at >= 0, "the check that asks whether a puck covers itself up is gone");
|
||||
return AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
};
|
||||
|
||||
// The mark that was sitting on names was neither of the two this check knew about.
|
||||
test("the chair number counts as something a puck wears", () => {
|
||||
assert.match(check(), /const MARKS = "\.dealerChip, \.blindBadge, \.seatChairNumber"/);
|
||||
});
|
||||
|
||||
// And a badge painted over the number is a puck covering itself up, which is the
|
||||
// whole subject of this check, and was invisible to it.
|
||||
test("and the marks are held against each other, not only against the writing", () => {
|
||||
const body = check();
|
||||
assert.match(body, /marks\.forEach\(\(a, i\) => marks\.slice\(i \+ 1\)/);
|
||||
assert.match(body, /px across/);
|
||||
});
|
||||
|
||||
test("a mark that isn't drawn isn't an offender", () => {
|
||||
const body = check();
|
||||
assert.match(body, /style\.display === "none"/);
|
||||
assert.match(body, /parseFloat\(style\.opacity\) < 0\.05/);
|
||||
// An empty badge slot has a box and no text; three of them sit on every puck.
|
||||
assert.match(body, /if \(!\(mark\.textContent \|\| ""\)\.trim\(\)\) return null;/);
|
||||
});
|
||||
|
||||
test("and the check says what it now covers", () => {
|
||||
assert.match(AUDIT, /name: "nothing a seat wears covers its name or another mark"/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// The panel named after a legend that never showed one.
|
||||
//
|
||||
// Measured on an iPhone: 793px, of which 420 were the colour form — five rows of
|
||||
// swatch, value box and a filled red delete block, then an add button and a paragraph
|
||||
// of instructions. Under all of it sat "Stacks in chips", which is the thing a dealer
|
||||
// opens this panel during a game to read: the fewest chips that make each stack, for
|
||||
// colouring up or counting one out. Setting the colours is something you do once, on
|
||||
// the first night, and it was sitting on top of the thing you do at every colour-up.
|
||||
//
|
||||
// So the order is the other way round. The legend itself — five colours and five
|
||||
// amounts, which is what the panel is named for and what it had never actually
|
||||
// displayed, the values being locked inside number inputs — leads, in two lines. The
|
||||
// colour-up list follows. The form goes behind a disclosure, which is the shape Table
|
||||
// Setup already uses twice. Panel: 793px to 441px.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
const RENDER = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "render.js"), "utf8");
|
||||
const DRIVER = fs.readFileSync(path.join(__dirname, "runUiAudit.js"), "utf8");
|
||||
|
||||
const panel = () => {
|
||||
const at = PAGE.indexOf('<summary><h2>Chip Legend</h2>');
|
||||
assert.ok(at >= 0, "there is no Chip Legend panel");
|
||||
return PAGE.slice(at, PAGE.indexOf('<details class="sidebarPanel" id="keyboardPanel">', at));
|
||||
};
|
||||
|
||||
const ruleFor = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
|
||||
return CSS.slice(at, CSS.indexOf("}", at));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what the panel leads with", () => {
|
||||
test("the legend, then the stacks, then the form", () => {
|
||||
const markup = panel();
|
||||
const strip = markup.indexOf('id="chipLegendStrip"');
|
||||
const stacks = markup.indexOf('id="colourUp"');
|
||||
const form = markup.indexOf('id="chipLegendList"');
|
||||
assert.ok(strip >= 0, "the panel still doesn't show the legend it is named for");
|
||||
assert.ok(strip < stacks, "the legend is below the colour-up list");
|
||||
assert.ok(stacks < form, "the form a dealer fills in once is above the list they read all night");
|
||||
});
|
||||
|
||||
test("and the form is behind a disclosure, shut", () => {
|
||||
const markup = panel();
|
||||
const at = markup.indexOf('<details class="subPanel">');
|
||||
assert.ok(at >= 0, "the colour form is not behind a disclosure");
|
||||
assert.ok(!/<details class="subPanel"[^>]*\bopen\b/.test(markup));
|
||||
assert.ok(at < markup.indexOf('id="chipLegendList"'), "the disclosure doesn't contain the form");
|
||||
assert.match(markup.slice(at, at + 200), /<summary>Colours and values<\/summary>/);
|
||||
});
|
||||
|
||||
// The add button and the instructions belong to the form, not to the panel.
|
||||
test("the instructions went with it", () => {
|
||||
const markup = panel();
|
||||
const sub = markup.indexOf('<details class="subPanel">');
|
||||
assert.ok(markup.indexOf('id="addChipColorBtn"') > sub);
|
||||
assert.ok(markup.indexOf("Give each chip color a value") > sub);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the legend line", () => {
|
||||
test("is built from the same list the form edits", () => {
|
||||
const at = RENDER.indexOf("renderChipLegendStrip() {");
|
||||
assert.ok(at >= 0, "nothing draws the legend");
|
||||
const body = RENDER.slice(at, RENDER.indexOf("\n },", at));
|
||||
assert.match(body, /this\.state\.chipLegend/);
|
||||
assert.match(body, /Money\.format/);
|
||||
assert.match(body, /chipSwatch/);
|
||||
});
|
||||
|
||||
test("and is drawn whenever the list is", () => {
|
||||
const at = RENDER.indexOf("renderChipLegend() {");
|
||||
assert.ok(at >= 0);
|
||||
assert.match(RENDER.slice(at, at + 160), /this\.renderChipLegendStrip\(\);/);
|
||||
});
|
||||
|
||||
// Typing in the form is the one moment the line above it goes stale. Only the
|
||||
// strip is redrawn — redrawing the list would write the field's own value back
|
||||
// into it mid-keystroke, which is why everything else here is called one at a time.
|
||||
test("it keeps up with the field being typed into, and nothing else does", () => {
|
||||
// Anchored inside renderChipLegend: the seat editor's custom fields have
|
||||
// listeners spelled the same way, earlier in the file.
|
||||
const from = RENDER.indexOf("renderChipLegend() {");
|
||||
assert.ok(from >= 0);
|
||||
["colorInput.addEventListener(\"input\"", "valueInput.addEventListener(\"input\""].forEach(hook => {
|
||||
const at = RENDER.indexOf(hook, from);
|
||||
assert.ok(at >= 0, `there is no ${hook}`);
|
||||
const body = RENDER.slice(at, RENDER.indexOf("});", at));
|
||||
assert.match(body, /this\.renderChipLegendStrip\(\);/, `${hook} leaves the legend line stale`);
|
||||
assert.ok(!/this\.renderChipLegend\(\)/.test(body),
|
||||
"redrawing the whole list would rewrite the field being typed in");
|
||||
});
|
||||
});
|
||||
|
||||
test("it wraps rather than running off the side of a 320px sidebar", () => {
|
||||
const rule = ruleFor(".chipLegendStrip");
|
||||
assert.match(rule, /display:\s*flex/);
|
||||
assert.match(rule, /flex-wrap:\s*wrap/);
|
||||
assert.match(rule, /list-style:\s*none/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("five ways to delete something", () => {
|
||||
// The target stays the size the touch rules give it. Only the paint changes: height
|
||||
// is not what makes a destructive control safe, and these were the loudest thing in
|
||||
// the panel — one filled red block per row, brighter than the values beside them.
|
||||
test("the delete buttons are quiet without being small", () => {
|
||||
const rule = ruleFor("#chipLegendList .removeChipColorBtn");
|
||||
assert.match(rule, /background:\s*none/);
|
||||
assert.ok(!/min-height/.test(rule), "de-emphasis is a colour change, never a height change");
|
||||
assert.ok(!/width/.test(rule));
|
||||
});
|
||||
|
||||
// The dialog that asks "reset the table?" is also holdemBtnDanger, and it is meant
|
||||
// to be loud.
|
||||
test("and the confirm dialog's own danger button is untouched", () => {
|
||||
const at = CSS.indexOf("\n.holdemBtnDanger {");
|
||||
assert.ok(at >= 0);
|
||||
assert.match(CSS.slice(at, CSS.indexOf("}", at)), /background:\s*linear-gradient/);
|
||||
});
|
||||
|
||||
test("the red comes back on the way to pressing one", () => {
|
||||
assert.match(CSS, /#chipLegendList \.removeChipColorBtn:hover \{[^}]*color:\s*#ffd6d6/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the list that runs into its own footnote", () => {
|
||||
test("the colour-up note is spaced off the last stack", () => {
|
||||
assert.match(ruleFor("#colourUp .colourUpList + .fieldHint"), /margin-top:\s*var\(--sp-\d\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the sweep is allowed to shrug at", () => {
|
||||
// Both twelve-handed shapes, for one stated reason, and only at 20px text.
|
||||
test("both crowded shapes are notes, not failures, at large text", () => {
|
||||
const at = DRIVER.indexOf("const CROWDED_BEYOND_HELP");
|
||||
assert.ok(at >= 0);
|
||||
assert.match(DRIVER.slice(at, DRIVER.indexOf(";", at)), /\["all in", "packed"\]/);
|
||||
assert.match(DRIVER, /soft && soft\.includes\(shape\)/);
|
||||
});
|
||||
|
||||
// The arithmetic has to be written down, or the next round reads this as the sweep
|
||||
// being softened to make something pass.
|
||||
test("and the arithmetic for why is written down beside it", () => {
|
||||
const at = DRIVER.indexOf("// The packed table is the same arithmetic");
|
||||
assert.ok(at >= 0, "the packed shape is exempt with no reason given");
|
||||
const why = DRIVER.slice(at, DRIVER.indexOf("const CROWDED_BEYOND_HELP", at));
|
||||
assert.match(why, /315px of puck[\s/]*\/\/?\s*into 294px of felt/);
|
||||
assert.match(why, /44x63/);
|
||||
});
|
||||
|
||||
// Only at 20px. The same table at the browser's normal size is held to the same
|
||||
// standard as everything else on the felt.
|
||||
test("the other two passes are not softened", () => {
|
||||
assert.match(DRIVER, /\{ hand: "mouse", touch: false, viewports: VIEWPORTS \}/);
|
||||
assert.match(DRIVER, /\{ hand: "touch", touch: true, viewports: VIEWPORTS \}/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
// Typing 0.25 into a box and getting 0.25.
|
||||
//
|
||||
// The six setup fields are wired `input` -> save -> Render.renderAll(), and renderAll
|
||||
// comes back round through renderSetupFields and assigns `.value` to the very box being
|
||||
// typed in. The decimal point is where that turns into a wrong number rather than a
|
||||
// wasted render, because a `type="number"` reports a half-typed "0." as "0" — so the
|
||||
// write-back erased the point, and assigning `.value` to a number input parks the caret
|
||||
// at position 0, so every digit after it landed at the front.
|
||||
//
|
||||
// Measured in Chrome against the page as it stood, keystroke by keystroke:
|
||||
//
|
||||
// small blind "0.25" 0 -> 0 -> 20 -> 250 a table playing 250/500
|
||||
// big blind "0.50" 0 -> 0 -> 50 -> 500
|
||||
// start stack "12.50" 1 -> 12 -> 12 -> 512 -> 5012
|
||||
// rake percent "2.5" 2 -> 2 -> 52 a 52% rake
|
||||
// rake cap "7.50" 7 -> 7 -> 57 -> 507
|
||||
// base pot "0.75" 0 -> 0 -> 70 -> 750
|
||||
//
|
||||
// Nothing warned: every one of those is a valid number, so no validation could have
|
||||
// caught it, and the dealer's own table is the only place it shows.
|
||||
//
|
||||
// Two rules hold it shut, and they are separate on purpose. One stops a render writing
|
||||
// over somebody's half-finished typing at all. The other takes the fields off
|
||||
// `type="number"`, which is what made a half-finished number unreadable in the first
|
||||
// place — and is what GOV.UK's design system concluded after user testing, for this and
|
||||
// four other reasons.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Render, Money } = require("./load");
|
||||
|
||||
const MARKUP = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
|
||||
// A stand-in for one form control, and a document that knows which one has focus.
|
||||
const field = (value = "") => ({ value, checked: false });
|
||||
const withFocus = focused => {
|
||||
global.document = { activeElement: focused || null };
|
||||
};
|
||||
|
||||
test.afterEach(() => { delete global.document; });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("a render does not type for you", () => {
|
||||
test("the box somebody is standing in is left exactly as they left it", () => {
|
||||
// The whole fix in one assertion. "0." is not a number and state has read it as
|
||||
// 0; writing that 0 back is what ate the point.
|
||||
const box = field("0.");
|
||||
withFocus(box);
|
||||
|
||||
Render.setField(box, 0);
|
||||
|
||||
assert.equal(box.value, "0.",
|
||||
"the render wrote over a half-typed number, which is how 0.25 became 250");
|
||||
});
|
||||
|
||||
test("and the box nobody is in is brought up to date", () => {
|
||||
const box = field("2");
|
||||
withFocus(null);
|
||||
|
||||
Render.setField(box, 5);
|
||||
|
||||
assert.equal(box.value, "5");
|
||||
});
|
||||
|
||||
test("a value that has not changed is not written at all", () => {
|
||||
// Assigning an identical value still moves the caret in some engines, so this
|
||||
// is part of the guard rather than a saving.
|
||||
const box = field("5");
|
||||
let writes = 0;
|
||||
Object.defineProperty(box, "value", {
|
||||
get: () => "5",
|
||||
set: () => { writes += 1; },
|
||||
});
|
||||
withFocus(null);
|
||||
|
||||
Render.setField(box, 5);
|
||||
|
||||
assert.equal(writes, 0, "an unchanged value was assigned anyway");
|
||||
});
|
||||
|
||||
test("null and undefined clear the box rather than spelling themselves out", () => {
|
||||
withFocus(null);
|
||||
const a = field("9");
|
||||
const b = field("9");
|
||||
|
||||
Render.setField(a, null);
|
||||
Render.setField(b, undefined);
|
||||
|
||||
assert.equal(a.value, "");
|
||||
assert.equal(b.value, "", "an unset blind would have read the word 'undefined'");
|
||||
});
|
||||
|
||||
test("and a box that isn't there is not an error", () => {
|
||||
withFocus(null);
|
||||
assert.doesNotThrow(() => Render.setField(null, 5));
|
||||
assert.doesNotThrow(() => Render.setToggle(null, true));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the same rule for a switch", () => {
|
||||
test("a checkbox somebody is on is not flipped under them", () => {
|
||||
const box = field();
|
||||
box.checked = true;
|
||||
withFocus(box);
|
||||
|
||||
Render.setToggle(box, false);
|
||||
|
||||
assert.equal(box.checked, true,
|
||||
"a render flipped a switch the dealer had their finger on");
|
||||
});
|
||||
|
||||
test("and one nobody is on keeps up with the state", () => {
|
||||
const box = field();
|
||||
box.checked = true;
|
||||
withFocus(null);
|
||||
|
||||
Render.setToggle(box, false);
|
||||
|
||||
assert.equal(box.checked, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a typed-in box is worth", () => {
|
||||
// The fields are type="text" now, so what arrives is what was typed — including
|
||||
// things that are not numbers. Number("abc") is NaN, and NaN must not become a blind.
|
||||
test("a number is worth that number", () => {
|
||||
assert.equal(Money.readAmount("0.25"), 0.25);
|
||||
assert.equal(Money.readAmount("12.50"), 12.5);
|
||||
assert.equal(Money.readAmount("200"), 200);
|
||||
});
|
||||
|
||||
test("nothing typed yet is not zero", () => {
|
||||
// Null and 0 mean different things to the fields that use this: an unset
|
||||
// starting stack falls back to the table default, a zero one does not.
|
||||
assert.equal(Money.readAmount(""), null);
|
||||
assert.equal(Money.readAmount(" "), null);
|
||||
assert.equal(Money.readAmount(null), null);
|
||||
assert.equal(Money.readAmount(undefined), null);
|
||||
});
|
||||
|
||||
test("and neither is something that is not a number", () => {
|
||||
["abc", "1.2.3", "$5", "--", "1,5"].forEach(text => {
|
||||
assert.equal(Money.readAmount(text), null,
|
||||
`"${text}" came back as something other than null`);
|
||||
});
|
||||
});
|
||||
|
||||
test("infinity is not a stack", () => {
|
||||
assert.equal(Money.readAmount("Infinity"), null);
|
||||
assert.equal(Money.readAmount("1e999"), null);
|
||||
});
|
||||
|
||||
test("a half-typed number is read as what it is so far", () => {
|
||||
// "0." arrives here now instead of being flattened to "0" by the control. It is
|
||||
// worth 0 at this instant, and the next keystroke will say otherwise — what
|
||||
// matters is that the box still reads "0." when it does.
|
||||
assert.equal(Money.readAmount("0."), 0);
|
||||
assert.equal(Money.readAmount(".5"), 0.5);
|
||||
});
|
||||
|
||||
test("and it comes back rounded to the cent the tool works in", () => {
|
||||
assert.equal(Money.readAmount("0.005"), 0.01);
|
||||
assert.equal(Money.readAmount("1.004"), 1);
|
||||
});
|
||||
|
||||
test("a negative is left alone rather than quietly clamped", () => {
|
||||
// A typed "-5" is a different mistake from a mistyped one, and the callers that
|
||||
// care clamp for themselves. Folding it in here would change what the dealer
|
||||
// asked for without saying so.
|
||||
assert.equal(Money.readAmount("-5"), -5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the boxes themselves", () => {
|
||||
const inputs = MARKUP.match(/<input\b[^>]*>/g) || [];
|
||||
const attr = (tag, name) => {
|
||||
const m = tag.match(new RegExp(`${name}="([^"]*)"`));
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
const named = tag => attr(tag, "id") || attr(tag, "class") || tag.slice(0, 40);
|
||||
|
||||
test("none of them is a type=number any more", () => {
|
||||
const spinners = inputs.filter(t => attr(t, "type") === "number");
|
||||
assert.deepEqual(spinners.map(named), [],
|
||||
"a type=number is back: it reports a half-typed '0.' as '0', which is the " +
|
||||
"read that let a render write the decimal point away");
|
||||
});
|
||||
|
||||
test("every box that takes digits says which keypad it wants", () => {
|
||||
// The digits fields are the ones this round converted. Identified by name rather
|
||||
// than by type, because their type is exactly what no longer distinguishes them.
|
||||
const DIGITS = [
|
||||
"seatCountInput", "smallBlindInput", "bigBlindInput", "defaultStackInput",
|
||||
"rakePercentInput", "rakeCapInput", "basePotInput",
|
||||
"stackInput", "amountOverrideInput", "chipValueInput", "chipCountInput",
|
||||
"addChipsInput",
|
||||
];
|
||||
DIGITS.forEach(name => {
|
||||
const tag = inputs.find(t => named(t) === name);
|
||||
assert.ok(tag, `${name} is gone from the markup`);
|
||||
const mode = attr(tag, "inputmode");
|
||||
assert.ok(mode === "decimal" || mode === "numeric",
|
||||
`${name} has inputmode="${mode}" — without one a phone offers a ` +
|
||||
`full keyboard for a number, which is the slowest thing on the page`);
|
||||
});
|
||||
});
|
||||
|
||||
test("the money ones ask for the separator, the counting ones do not", () => {
|
||||
// "decimal" carries the reader's decimal separator; "numeric" is bare digits.
|
||||
// A blind needs the point. A seat count would only be a way to type a wrong one.
|
||||
const modeOf = name => attr(inputs.find(t => named(t) === name), "inputmode");
|
||||
["smallBlindInput", "bigBlindInput", "defaultStackInput", "rakeCapInput",
|
||||
"basePotInput", "stackInput", "chipValueInput"].forEach(name => {
|
||||
assert.equal(modeOf(name), "decimal", `${name} cannot be given cents`);
|
||||
});
|
||||
["seatCountInput", "chipCountInput"].forEach(name => {
|
||||
assert.equal(modeOf(name), "numeric", `${name} counts things, not money`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and none of them invites the browser to fill it in", () => {
|
||||
// A saved address offering itself as a small blind is the browser being helpful
|
||||
// about the one field on the page it cannot know anything about.
|
||||
["smallBlindInput", "bigBlindInput", "defaultStackInput", "seatCountInput",
|
||||
"rakePercentInput", "rakeCapInput", "basePotInput"].forEach(name => {
|
||||
const tag = inputs.find(t => named(t) === name);
|
||||
assert.equal(attr(tag, "autocomplete"), "off", `${name} may be autofilled`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("what still counts as a number box", () => {
|
||||
// Money.selectsOnFocus decides whether tapping a pre-filled amount box selects what
|
||||
// is in it. It used to recognise those boxes by type="number"; there are none left.
|
||||
test("a text box with a decimal keypad is one", () => {
|
||||
assert.equal(Money.selectsOnFocus(
|
||||
{ tagName: "INPUT", type: "text", inputMode: "decimal" }), true);
|
||||
});
|
||||
|
||||
test("and so is one with a numeric keypad", () => {
|
||||
// The seat count and the chip count. Both open pre-filled, both were caught by
|
||||
// the old type check, and reading only "decimal" would have dropped them.
|
||||
assert.equal(Money.selectsOnFocus(
|
||||
{ tagName: "INPUT", type: "text", inputMode: "numeric" }), true);
|
||||
});
|
||||
|
||||
test("a plain text box is not", () => {
|
||||
assert.equal(Money.selectsOnFocus(
|
||||
{ tagName: "INPUT", type: "text", inputMode: "" }), false,
|
||||
"a player's name would be wiped by the first keystroke after a tap");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a wheel has nothing left to spin", () => {
|
||||
test("no field in the tool is a spinbutton any more", () => {
|
||||
// wheelWouldEdit answers false for every field now, which is the point rather
|
||||
// than a regression: a text box is not a spinbutton, so a wheel over it cannot
|
||||
// change it. The guard stays because it is what makes that safe to rely on.
|
||||
assert.equal(Money.wheelWouldEdit(
|
||||
{ tagName: "INPUT", type: "text", inputMode: "decimal" },
|
||||
{ tagName: "INPUT", type: "text", inputMode: "decimal" }), false);
|
||||
});
|
||||
|
||||
test("but it would still catch one if somebody added it back", () => {
|
||||
const spinner = { tagName: "INPUT", type: "number" };
|
||||
assert.equal(Money.wheelWouldEdit(spinner, spinner), true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
// Fifty-two cards on a phone.
|
||||
//
|
||||
// The picker put the whole deck on the screen at once — four suits across, thirteen
|
||||
// ranks down — and that grid is 646px tall. Measured with the board tracked and seven
|
||||
// cards already out:
|
||||
//
|
||||
// 640x360 816px of content in a 317px sheet, 40 of the 52 below the fold
|
||||
// 667x375 816 in 330, 40 below
|
||||
// 844x390 774 in 343, 36 below
|
||||
// 375x667 816 in 587, 20 below
|
||||
// 1280x900 774 in 775, none
|
||||
//
|
||||
// So on every phone the tool is actually used on, entering the flop began with a
|
||||
// scroll, and a dealer looking for the nine of diamonds read down a column to find it.
|
||||
// There is no arrangement that fixes that: fifty-two targets at the 44px this sweep
|
||||
// holds everything else to need 572px of width before gaps if they are thirteen across,
|
||||
// which is wider than any phone in portrait, and thirteen rows deep if they are four.
|
||||
//
|
||||
// A card is not one choice out of fifty-two. It is two — a suit and a rank — and that
|
||||
// is how a dealer says it out loud. So the sheet asks them in that order: four suits on
|
||||
// a row, then the thirteen ranks of the one chosen. Seventeen targets instead of
|
||||
// fifty-two, none of them smaller than they were, and measured afterwards at eight
|
||||
// viewports from 360x640 to 1280x900: nothing below the fold, nothing scrolling, every
|
||||
// button still at least 44x44 and still hit-testing as itself.
|
||||
//
|
||||
// The second tap is not a second cost in the usual case. The suit stays where it was
|
||||
// put, so a second card of the same suit is one tap — and picking one card used to be
|
||||
// one tap plus a scroll through thirteen rows.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { CardPicker } = require("./load");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
const SRC = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "cardPicker.js"), "utf8");
|
||||
|
||||
const ruleFor = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
|
||||
return CSS.slice(at, CSS.indexOf("}", at));
|
||||
};
|
||||
|
||||
const methodBody = name => {
|
||||
const at = SRC.indexOf(`\n ${name}`);
|
||||
assert.ok(at >= 0, `cardPicker has no ${name}`);
|
||||
return SRC.slice(at, SRC.indexOf("\n },", at));
|
||||
};
|
||||
|
||||
// The picker reaches for the global document in two places. Neither is exercised here —
|
||||
// these tests ask what it decides, not what it draws — but aimSuit reads the slot it is
|
||||
// about to fill, so it needs the state and nothing else.
|
||||
function aimedAt(board, cardIndex, { suit = "S", cursor = 0 } = {}) {
|
||||
CardPicker.state = { board: board.slice(), seats: [] };
|
||||
CardPicker.target = "board";
|
||||
CardPicker.boardLimit = board.length;
|
||||
CardPicker.cardIndex = cardIndex;
|
||||
CardPicker.suit = suit;
|
||||
CardPicker.cursor = cursor;
|
||||
CardPicker.aimSuit();
|
||||
return { suit: CardPicker.suit, cursor: CardPicker.cursor };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the deck is still the deck", () => {
|
||||
test("all fifty-two of it, ace down to two", () => {
|
||||
const deck = CardPicker.deck();
|
||||
assert.equal(deck.length, 52);
|
||||
assert.equal(new Set(deck.map(entry => entry.card)).size, 52);
|
||||
assert.equal(deck[0].card, "AS");
|
||||
assert.equal(deck[51].card, "2C");
|
||||
});
|
||||
|
||||
// Which is what the duplicate check reads, and why splitting the *grid* by suit
|
||||
// cannot let the same card be dealt twice.
|
||||
test("and a card already out is still a card already out, in any suit", () => {
|
||||
CardPicker.state = {
|
||||
seats: [{ name: "Mike", cards: ["AS", "KH"] }],
|
||||
board: ["7D", "", "", "", ""],
|
||||
};
|
||||
CardPicker.target = "board";
|
||||
CardPicker.boardLimit = 5;
|
||||
CardPicker.cardIndex = 1;
|
||||
const used = CardPicker.cardsInUse();
|
||||
assert.equal(used.get("AS"), "Mike");
|
||||
assert.equal(used.get("KH"), "Mike");
|
||||
assert.equal(used.get("7D"), "the board");
|
||||
assert.equal(used.size, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("one suit at a time", () => {
|
||||
test("the grid is thirteen ranks of the chosen suit, in deck order", () => {
|
||||
CardPicker.suit = "D";
|
||||
const cards = CardPicker.gridCards();
|
||||
assert.equal(cards.length, 13);
|
||||
assert.deepEqual(cards.map(entry => entry.card).slice(0, 3), ["AD", "KD", "QD"]);
|
||||
assert.equal(cards[12].card, "2D");
|
||||
assert.ok(cards.every(entry => entry.suit.code === "D"));
|
||||
});
|
||||
|
||||
test("every suit gets the same thirteen", () => {
|
||||
CardPicker.SUITS.forEach(suit => {
|
||||
CardPicker.suit = suit.code;
|
||||
assert.deepEqual(CardPicker.gridCards().map(entry => entry.rank), CardPicker.RANKS);
|
||||
});
|
||||
});
|
||||
|
||||
// The glyph is the whole of the control on the eye, and "♦, radio button" is not a
|
||||
// suit out loud.
|
||||
test("and a suit has a name to be called by", () => {
|
||||
assert.deepEqual(CardPicker.SUITS.map(suit => suit.name),
|
||||
["Spades", "Hearts", "Diamonds", "Clubs"]);
|
||||
assert.match(methodBody("paint()"), /aria-label", suit\.name/);
|
||||
});
|
||||
|
||||
test("an unknown suit code falls back rather than emptying the grid", () => {
|
||||
assert.equal(CardPicker.suitOf("Z").code, "S");
|
||||
assert.equal(CardPicker.suitOf("H").symbol, "♥");
|
||||
});
|
||||
});
|
||||
|
||||
describe("where the sheet opens", () => {
|
||||
// The dealer came here to change that card. Hunting for where it lives is work the
|
||||
// sheet can do.
|
||||
test("on the suit of the card already in the slot, with it under the cursor", () => {
|
||||
assert.deepEqual(aimedAt(["7H", "2D", "TS", "", ""], 0), { suit: "H", cursor: 7 });
|
||||
assert.deepEqual(aimedAt(["7H", "2D", "TS", "", ""], 1), { suit: "D", cursor: 12 });
|
||||
assert.deepEqual(aimedAt(["7H", "2D", "TS", "", ""], 2), { suit: "S", cursor: 4 });
|
||||
});
|
||||
|
||||
// Which is what makes a flush board, or two suited hole cards, one tap each after
|
||||
// the first.
|
||||
test("and an empty slot leaves the suit where the dealer put it", () => {
|
||||
assert.deepEqual(aimedAt(["7H", "", "", "", ""], 1, { suit: "H", cursor: 7 }),
|
||||
{ suit: "H", cursor: 7 });
|
||||
});
|
||||
|
||||
test("junk in a slot is left alone rather than read as a suit", () => {
|
||||
assert.deepEqual(aimedAt(["ZZ", "", "", "", ""], 0, { suit: "C", cursor: 3 }),
|
||||
{ suit: "C", cursor: 3 });
|
||||
assert.deepEqual(aimedAt(["", "", "", "", ""], 0, { suit: "C", cursor: 3 }),
|
||||
{ suit: "C", cursor: 3 });
|
||||
});
|
||||
|
||||
test("both ways in ask the question before they draw", () => {
|
||||
["open(state, hooks, seatIndex, cardIndex) {", "openBoard(state, hooks, cardIndex, limit = 5) {"]
|
||||
.forEach(signature => {
|
||||
const at = SRC.indexOf(signature);
|
||||
assert.ok(at >= 0, `there is no \`${signature}\``);
|
||||
assert.match(SRC.slice(at, SRC.indexOf("this.render();", at)), /this\.aimSuit\(\);/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("changing suit is not moving", () => {
|
||||
// The cursor is a rank. The same rank in another suit is the card beside the one you
|
||||
// were on, so coming back to the ace would undo the only thing the dealer had said.
|
||||
test("the rank under the cursor survives the change", () => {
|
||||
const body = methodBody("setSuit(code, { focus = false } = {}) {");
|
||||
assert.ok(!/cursor/.test(body), "setSuit still has an opinion about where the cursor is");
|
||||
});
|
||||
|
||||
test("and asking for the suit already showing redraws nothing", () => {
|
||||
const body = methodBody("setSuit(code, { focus = false } = {}) {");
|
||||
assert.match(body, /if \(this\.suit === code\) return;/);
|
||||
});
|
||||
|
||||
// The APG's radio group: one Tab stop for the set, arrows to move within it, and
|
||||
// moving is choosing. Wrapping is right here and wrong in the grid below — four
|
||||
// suits are a ring with no ends, thirteen ranks have two.
|
||||
test("the arrows run round the four suits", () => {
|
||||
const body = methodBody("onSuitKey(event)");
|
||||
assert.match(body, /ArrowRight: 1.*ArrowDown: 1.*ArrowLeft: -1.*ArrowUp: -1/s);
|
||||
assert.match(body, /% this\.SUITS\.length/);
|
||||
assert.match(body, /event\.preventDefault\(\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the markup and the stylesheet have to agree about", () => {
|
||||
// Not a tablist: the grid below is not another panel of content, it is the same
|
||||
// thirteen controls filtered, and single-select filtering is what a radio group is.
|
||||
test("the suits are a radio group with a name", () => {
|
||||
const at = PAGE.indexOf('id="cardPickerSuits"');
|
||||
assert.ok(at >= 0, "there is no suit row");
|
||||
const tag = PAGE.slice(PAGE.lastIndexOf("<", at), PAGE.indexOf(">", at));
|
||||
assert.match(tag, /role="radiogroup"/);
|
||||
assert.match(tag, /aria-label="Suit"/);
|
||||
});
|
||||
|
||||
test("each suit says whether it is the chosen one, and only one is a Tab stop", () => {
|
||||
const body = methodBody("paint()");
|
||||
assert.match(body, /setAttribute\("role", "radio"\)/);
|
||||
assert.match(body, /aria-checked", chosen \? "true" : "false"/);
|
||||
assert.match(body, /tabIndex = chosen \? 0 : -1/);
|
||||
});
|
||||
|
||||
test("and the grid says which suit's thirteen these are", () => {
|
||||
const at = PAGE.indexOf('id="cardPickerGrid"');
|
||||
assert.ok(at >= 0);
|
||||
assert.match(PAGE.slice(at, PAGE.indexOf(">", at)), /aria-labelledby="cardPickerGridLabel"/);
|
||||
assert.match(methodBody("paint()"), /gridLabel\.textContent = `\$\{this\.suitOf\(this\.suit\)\.name\}/);
|
||||
});
|
||||
|
||||
// A row of four, in the same four places every time, so the one the dealer wants is
|
||||
// somewhere their thumb already knows.
|
||||
test("the suit row is four fixed columns, not as many as fit", () => {
|
||||
assert.match(ruleFor("#cardPickerSuits"), /grid-template-columns:\s*repeat\(4, minmax\(0, 1fr\)\)/);
|
||||
});
|
||||
|
||||
test("a suit is a target before it is a glyph", () => {
|
||||
assert.match(ruleFor(".pickerSuitTab"), /min-height:\s*48px/);
|
||||
});
|
||||
|
||||
// Drawing the three unchosen suits in one grey made a dealer reaching for the hearts
|
||||
// read four shapes to find them, which is the search this sheet exists to remove.
|
||||
test("red is red whether or not it is the one chosen", () => {
|
||||
assert.match(ruleFor(".pickerSuitTab.cardRed"), /color:\s*#e07b7b/);
|
||||
assert.match(ruleFor(".pickerSuitTab.chosen"), /background:\s*#fdfdfd/);
|
||||
assert.match(ruleFor(".pickerSuitTab.cardRed.chosen"), /color:\s*#b32020/);
|
||||
});
|
||||
|
||||
// Five across on a 360px phone, seven at 460, more on a phone held sideways.
|
||||
test("the ranks take as many columns as the row holds", () => {
|
||||
assert.match(ruleFor("#cardPickerGrid"),
|
||||
/grid-template-columns:\s*repeat\(auto-fill, minmax\(3\.25rem, 1fr\)\)/);
|
||||
});
|
||||
|
||||
// Which is why the arrows cannot assume a number any more.
|
||||
test("so the arrows read the column count back off the layout", () => {
|
||||
assert.match(methodBody("onGridKey(event)"), /const stride = this\.gridStride\(\)/);
|
||||
assert.match(methodBody("gridStride()"), /getComputedStyle\(grid\)\.gridTemplateColumns/);
|
||||
});
|
||||
|
||||
test("what was measured is written beside the code", () => {
|
||||
const note = SRC.slice(0, SRC.indexOf("const CardPicker = {"));
|
||||
assert.match(note, /646px/);
|
||||
assert.match(note, /40 of the 52/);
|
||||
assert.match(note, /572px/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
// Forty-four pixels, meaning forty-four pixels.
|
||||
//
|
||||
// Every input and select a dealer touches has a rule asking for `min-height: 44px`, and
|
||||
// not one of them was 44px tall. Form controls are content-box by default, so 44 was
|
||||
// being read as the height of the text inside and 8px of padding top and bottom plus a
|
||||
// 1px border each side went on the outside of it. Measured on an iPhone: all fifteen
|
||||
// settings fields in the sidebar stood at 62px, and the raise pad's amount box at 66px
|
||||
// with the text turned up.
|
||||
//
|
||||
// Nothing looked wrong, which is why it lasted eleven rounds. 62px clears a 44px floor,
|
||||
// so the sweep — which asks whether a target is big enough, never whether it is the size
|
||||
// it was asked to be — had nothing to say about it. The cost is only visible in the
|
||||
// aggregate: fifteen fields at 18px over is 180px of sidebar, and the raise field's 10px
|
||||
// is 10px of felt, because the action bar's height is published into --feltRoomPx and
|
||||
// the table is laid out from what is left.
|
||||
//
|
||||
// This is the third time this stylesheet has been caught by content-box — the sidebar's
|
||||
// panel headers and the rulings sheet's summaries are the other two, and both carry a
|
||||
// note saying so. The note is in the stylesheet; the list of what it covers is here.
|
||||
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");
|
||||
|
||||
// The rule, the selectors it is written against, and the note above it saying why.
|
||||
const boxSizing = () => {
|
||||
const note = CSS.indexOf("/* A field is as tall as it says it is.");
|
||||
assert.ok(note >= 0, "nothing in the stylesheet asks a field to be the height it states");
|
||||
const at = CSS.indexOf("box-sizing: border-box;", note);
|
||||
assert.ok(at >= 0);
|
||||
return CSS.slice(note, CSS.indexOf("}", at));
|
||||
};
|
||||
|
||||
// Every selector anywhere in the stylesheet that gives a typing field a 44px floor,
|
||||
// wherever the media query that holds it happens to live. Checkboxes and colour swatches
|
||||
// are left out: they are sized by an explicit width and height rather than by a floor
|
||||
// their padding can push past, which is the whole of the bug this guards.
|
||||
const touchSized = () => {
|
||||
const found = [];
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const hit = CSS.indexOf("min-height: 44px", from);
|
||||
if (hit === -1) break;
|
||||
from = hit + 1;
|
||||
const opens = CSS.lastIndexOf("{", hit);
|
||||
const prior = Math.max(CSS.lastIndexOf("}", opens), CSS.lastIndexOf("{", opens - 1),
|
||||
CSS.lastIndexOf("*/", opens));
|
||||
CSS.slice(prior + 1, opens).split(",")
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.forEach(selector => found.push(selector));
|
||||
}
|
||||
return found.filter(selector =>
|
||||
/\b(input|select|textarea)\b|\.raiseInput/.test(selector) &&
|
||||
!/\[type="(checkbox|color)"\]/.test(selector));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("a field is the height it says it is", () => {
|
||||
const rule = boxSizing();
|
||||
|
||||
// Every field, not a list of the surfaces fields sit on. The list version of this
|
||||
// rule was written first and had already missed one — see the note.
|
||||
test("said about the elements, so nothing can sit outside the list", () => {
|
||||
assert.match(rule, /(^|\n)input,\s*\n\s*select,\s*\n\s*textarea \{/);
|
||||
});
|
||||
|
||||
// The one field whose height is not only its own: it sets the action bar's height,
|
||||
// the bar's height is published into --feltRoomPx, and the felt is laid out from
|
||||
// what is left. Taking 10px off the bar gave them to the table.
|
||||
test("the felt's stake in it is written down", () => {
|
||||
assert.match(rule, /--feltRoomPx/);
|
||||
assert.match(rule, /248px to[\s*]*238/);
|
||||
});
|
||||
|
||||
test("and so is the measurement that found it", () => {
|
||||
assert.match(rule, /62px/);
|
||||
assert.match(rule, /fifteen settings fields/);
|
||||
});
|
||||
|
||||
// Two other rules in this file already carry a content-box note; this is the third
|
||||
// place it has bitten, and the note is what stops it being a fourth.
|
||||
test("and what it is about, so the next one is recognised", () => {
|
||||
assert.match(rule, /content-box/);
|
||||
assert.match(rule, /padding/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nothing asks for a height it will not get", () => {
|
||||
// A durable guard rather than a snapshot. If a later round gives a field a 44px
|
||||
// floor, that floor is only the height the author meant while this rule reaches the
|
||||
// field — and nothing else in the suite would notice if it stopped.
|
||||
test("every field given a touch height is measured the way that height assumes", () => {
|
||||
const fields = touchSized();
|
||||
assert.ok(fields.length >= 4, `only ${fields.length} field rules found — the scrape has drifted`);
|
||||
// The rule names the element types themselves, so the only way a field escapes
|
||||
// is a later round narrowing it back to a list of places.
|
||||
const rule = boxSizing();
|
||||
fields.forEach(selector => {
|
||||
const kind = (selector.match(/\b(input|select|textarea)\b/) || [])[1]
|
||||
|| (/\.raiseInput/.test(selector) ? "input" : null);
|
||||
assert.ok(kind && new RegExp(`(^|\\n|,\\s*)${kind}\\b`).test(rule),
|
||||
`\`${selector}\` is given a touch height that box-sizing no longer reaches`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
// What Table Setup holds while it is open.
|
||||
//
|
||||
// It 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, before this: 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 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 explain them.
|
||||
// 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.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Render, makeTable } = require("./load");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
const MAIN = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "main.js"), "utf8");
|
||||
|
||||
// The Table Setup panel, from its opening tag to the panel after it.
|
||||
const setupPanel = () => {
|
||||
const at = PAGE.indexOf('<details class="sidebarPanel" id="tableSetupPanel">');
|
||||
assert.ok(at >= 0, "there is no Table Setup panel");
|
||||
const end = PAGE.indexOf('<details class="sidebarPanel"', at + 1);
|
||||
assert.ok(end > at, "the panel after Table Setup has gone");
|
||||
return PAGE.slice(at, end);
|
||||
};
|
||||
|
||||
// What the panel shows without anything being opened: everything before the first
|
||||
// nested disclosure.
|
||||
const alwaysShowing = () => {
|
||||
const panel = setupPanel();
|
||||
const firstSub = panel.indexOf('<details class="subPanel">');
|
||||
assert.ok(firstSub >= 0, "the panel has no nested disclosures at all");
|
||||
return panel.slice(0, firstSub);
|
||||
};
|
||||
|
||||
const ruleFor = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
|
||||
return CSS.slice(at, CSS.indexOf("}", at));
|
||||
};
|
||||
|
||||
const glance = state => (Render.panelGlances(state).glanceWhileYouDeal || {}).text;
|
||||
|
||||
const table = () => makeTable({ seatCount: 9, seated: [], stack: 0 });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what the open panel leads with", () => {
|
||||
// The two the summary already glances at. They were first and seventh.
|
||||
test("the seats and the blinds, in that order, above everything else", () => {
|
||||
const body = alwaysShowing();
|
||||
const seats = body.indexOf('id="seatCountInput"');
|
||||
const small = body.indexOf('id="smallBlindInput"');
|
||||
const big = body.indexOf('id="bigBlindInput"');
|
||||
const stack = body.indexOf('id="defaultStackInput"');
|
||||
assert.ok(seats >= 0 && small >= 0 && big >= 0 && stack >= 0,
|
||||
"one of the four numbers is no longer in the part of the panel that shows");
|
||||
assert.ok(seats < small, "the seat count is below the blinds");
|
||||
assert.ok(small < big && big < stack, "the blinds and the stack have swapped");
|
||||
});
|
||||
|
||||
test("and nothing in it is a switch about how the tool behaves", () => {
|
||||
const body = alwaysShowing();
|
||||
["dedicatedDealerToggle", "trackBoardToggle", "keepScreenAwakeToggle"].forEach(id => {
|
||||
assert.ok(!body.includes(id), `${id} is still on the panel's first screen`);
|
||||
});
|
||||
});
|
||||
|
||||
// One line, and it is about the boxes directly above it.
|
||||
test("one paragraph survives, the short one", () => {
|
||||
const hints = alwaysShowing().match(/<p class="fieldHint">/g) || [];
|
||||
assert.equal(hints.length, 1, "the panel's first screen is back to being prose");
|
||||
assert.match(alwaysShowing(), /Blinds post themselves each hand/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("where the three switches went", () => {
|
||||
const dealing = () => {
|
||||
const panel = setupPanel();
|
||||
const at = panel.indexOf("<summary>While you deal");
|
||||
assert.ok(at >= 0, 'there is no "While you deal" disclosure');
|
||||
return panel.slice(panel.lastIndexOf("<details", at), panel.indexOf("</details>", at));
|
||||
};
|
||||
|
||||
test("all three of them, with the paragraphs that explain them", () => {
|
||||
const body = dealing();
|
||||
["dedicatedDealerToggle", "trackBoardToggle", "keepScreenAwakeToggle"].forEach(id => {
|
||||
assert.ok(body.includes(id), `${id} did not come with the rest`);
|
||||
});
|
||||
assert.match(body, /no seat shows the dealer button/);
|
||||
assert.match(body, /a sheet asks which cards came off the deck/);
|
||||
assert.match(body, /Stops the phone dimming and locking/);
|
||||
});
|
||||
|
||||
// The same shape House take and Without live betting already had. A disclosure that
|
||||
// ships open is a disclosure that has saved nobody anything.
|
||||
test("it is shut, like the two money ones beside it", () => {
|
||||
const panel = setupPanel();
|
||||
assert.ok(!/<details class="subPanel"[^>]*\bopen\b/.test(panel),
|
||||
"a nested disclosure in this panel ships open");
|
||||
});
|
||||
|
||||
test("and it sits above them, because it is not about money", () => {
|
||||
const panel = setupPanel();
|
||||
assert.ok(panel.indexOf("While you deal") < panel.indexOf("<summary>House take"));
|
||||
assert.ok(panel.indexOf("<summary>House take") < panel.indexOf("<summary>Without live betting"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the glance on a shut disclosure", () => {
|
||||
test("silence when all three are where they ship", () => {
|
||||
const state = table();
|
||||
assert.equal(glance(state), "");
|
||||
});
|
||||
|
||||
test("and a name when one of them is not", () => {
|
||||
const state = table();
|
||||
state.trackBoard = true;
|
||||
assert.equal(glance(state), "Asking for cards");
|
||||
|
||||
const other = table();
|
||||
other.dedicatedDealer = true;
|
||||
assert.equal(glance(other), "House dealer");
|
||||
});
|
||||
|
||||
// The only one that ships on, so it is its absence that is worth saying.
|
||||
test("the screen setting speaks by going quiet, not by being on", () => {
|
||||
const on = table();
|
||||
on.keepScreenAwake = true;
|
||||
assert.equal(glance(on), "");
|
||||
|
||||
const off = table();
|
||||
off.keepScreenAwake = false;
|
||||
assert.equal(glance(off), "Screen may sleep");
|
||||
});
|
||||
|
||||
// Joined, the three run to 49 characters, and this row is 44px of a 320px column
|
||||
// with a label already on it. "House dealer · Asking for c…" has told nobody
|
||||
// anything; the one most likely to be the surprise has.
|
||||
test("one of them, never a list of three", () => {
|
||||
const state = table();
|
||||
state.trackBoard = true;
|
||||
state.dedicatedDealer = true;
|
||||
state.keepScreenAwake = false;
|
||||
assert.equal(glance(state), "Asking for cards");
|
||||
assert.ok(glance(state).length <= 24);
|
||||
});
|
||||
|
||||
test("it is a row this sidebar knows how to write", () => {
|
||||
assert.ok(Render.PANEL_GLANCES.includes("glanceWhileYouDeal"),
|
||||
"the glance is computed and never written, so the row keeps its old text all night");
|
||||
const panel = setupPanel();
|
||||
const at = panel.indexOf('id="glanceWhileYouDeal"');
|
||||
assert.ok(at >= 0, "there is nowhere to write it");
|
||||
assert.match(panel.slice(panel.lastIndexOf("<span", at), at + 40), /class="panelGlance subPanelGlance"/);
|
||||
assert.match(panel.slice(at, panel.indexOf(">", at)), /hidden/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what had to be true for it to hang there", () => {
|
||||
// Without this the span anchors to whichever ancestor is positioned, which is the
|
||||
// sidebar — the glance would appear at the top right of the whole column.
|
||||
test("the summary it hangs off is a positioned box", () => {
|
||||
assert.match(ruleFor(".subPanel > summary"), /position:\s*relative/);
|
||||
});
|
||||
|
||||
// A footnote on a line that is itself a footnote. At the weight of "Off by $40" the
|
||||
// eye stops on a row saying nothing has gone wrong.
|
||||
test("and it is drawn a step quieter than the panel headings' own", () => {
|
||||
const sub = ruleFor(".subPanelGlance");
|
||||
assert.match(sub, /font-size:\s*var\(--fs-xs\)/);
|
||||
assert.match(sub, /color:\s*var\(--text-dim\)/);
|
||||
assert.match(ruleFor(".panelGlance"), /font-size:\s*var\(--fs-sm\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the panel still opens itself only when it should", () => {
|
||||
test("on a table nobody has touched, and not on the fourth hand", () => {
|
||||
const at = MAIN.indexOf('document.getElementById("tableSetupPanel")');
|
||||
assert.ok(at >= 0, "nothing decides whether the panel starts open");
|
||||
const block = MAIN.slice(at, at + 400);
|
||||
assert.match(block, /Number\(state\.handNumber\) \|\| 1\) === 1/);
|
||||
assert.match(block, /!state\.seats\.some\(seat => HoldemState\.seatInPlay\(seat\)\)/);
|
||||
assert.match(block, /panel\.open = fresh/);
|
||||
});
|
||||
|
||||
test("what was measured is written beside the decision", () => {
|
||||
const at = MAIN.indexOf("Table Setup used to ship open");
|
||||
assert.ok(at >= 0);
|
||||
const note = MAIN.slice(at, at + 1200);
|
||||
assert.match(note, /886px/);
|
||||
assert.match(note, /529px/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
// What stands between the top of a phone and the felt.
|
||||
//
|
||||
// The header was 120px of a 390x844 phone — 16% of the window, spent before the table
|
||||
// started — and only 46 of that was a control. The other 74 were a 22px page title with
|
||||
// a gold rule under it, a back link, and a gold "Hand #1" pill: three things read once,
|
||||
// above a screen whose subject is the green table underneath.
|
||||
//
|
||||
// A phone held sideways had already solved this. The landscape block clips the title and
|
||||
// puts everything on one row, because at 390px tall there was no other option. Upright
|
||||
// never got the same answer, and upright is how this tool is held: measured after, the
|
||||
// header is 44px and the felt went from 496px to 570.
|
||||
//
|
||||
// Two claims are checked here. The stylesheet's, as text — the same way the seat sheet's
|
||||
// and the reduced-motion rules are — because "which row is this on" is a layout decision
|
||||
// that lives in the file. And the action bar's, by running it: the hand number had to go
|
||||
// somewhere, and where it went is a rendering question.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { ActionBar, makeTable } = require("./load");
|
||||
|
||||
const PAGE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
// Every @media block written at exactly this condition, concatenated.
|
||||
//
|
||||
// All of them, not the first one — `@media (max-width: 700px)` is opened twice in this
|
||||
// file, the second time to hold the phone layout proper, and a helper that stopped at
|
||||
// the first found a block with none of these rules in it and reported them missing.
|
||||
const mediaBlock = condition => {
|
||||
const found = [];
|
||||
const needle = "@media ";
|
||||
for (let at = CSS.indexOf(needle); at >= 0; at = CSS.indexOf(needle, at + 1)) {
|
||||
const open = CSS.indexOf("{", at);
|
||||
if (CSS.slice(at + needle.length, open).trim() !== condition) continue;
|
||||
let depth = 0;
|
||||
for (let i = open; i < CSS.length; i++) {
|
||||
if (CSS[i] === "{") depth++;
|
||||
else if (CSS[i] === "}" && --depth === 0) { found.push(CSS.slice(open + 1, i)); break; }
|
||||
}
|
||||
}
|
||||
assert.ok(found.length > 0, `no @media block written as "${condition}"`);
|
||||
return found.join("\n");
|
||||
};
|
||||
|
||||
const ruleIn = (block, selector) => {
|
||||
const re = new RegExp(`(^|[},])\\s*${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`, "m");
|
||||
const found = block.match(re);
|
||||
return found ? found[2] : null;
|
||||
};
|
||||
|
||||
const PHONE = () => mediaBlock("(max-width: 700px)");
|
||||
// The header's sideways rules are in the plain landscape query, not the one that also
|
||||
// caps the width — that later block is the seat sheet's and the action rail's. Both
|
||||
// exist, and picking the wrong one finds a block with no header in it at all.
|
||||
const LANDSCAPE = () => mediaBlock("(orientation: landscape) and (max-height: 560px)");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the header is one row on a phone", () => {
|
||||
test("upright gets the shape a phone on its side already had", () => {
|
||||
// These four together are the whole change: don't wrap, drop the word beside the
|
||||
// arrow, clip the title, and let the buttons size themselves in what is left.
|
||||
const phone = PHONE();
|
||||
assert.match(ruleIn(phone, "#holdemHeader") || "", /flex-wrap:\s*nowrap/,
|
||||
"a wrapping header is a two-row header");
|
||||
assert.match(ruleIn(phone, "#holdemHeaderLeft") || "", /display:\s*contents/,
|
||||
"the arrow has to be a flex item of the header, not a block inside a block");
|
||||
assert.ok(ruleIn(phone, ".backLinkWord"), "the word beside the arrow is not dropped");
|
||||
assert.ok(ruleIn(phone, "#holdemHeader h1"), "the title is not addressed on a phone");
|
||||
});
|
||||
|
||||
test("both narrow layouts clip the title rather than removing it", () => {
|
||||
// `display: none` would take the page's only <h1> off the accessibility tree, and
|
||||
// a page whose heading disappears below 700px is a page with no heading on the
|
||||
// device it is mostly read on.
|
||||
[["upright", PHONE()], ["sideways", LANDSCAPE()]].forEach(([which, block]) => {
|
||||
const rule = ruleIn(block, "#holdemHeader h1");
|
||||
assert.ok(rule, `${which} does not address the title`);
|
||||
assert.doesNotMatch(rule, /display:\s*none/,
|
||||
`${which} removes the page's heading instead of hiding it`);
|
||||
assert.match(rule, /clip-path:\s*inset\(50%\)/,
|
||||
`${which} should clip the heading the way .visuallyHidden does`);
|
||||
});
|
||||
});
|
||||
|
||||
test("the actions row stops claiming a row of its own", () => {
|
||||
// `width: 100%` is what made it a second row: a flex child at full width cannot
|
||||
// share a line with anything, whatever the wrap setting says.
|
||||
const rule = ruleIn(PHONE(), "#holdemHeaderActions") || "";
|
||||
assert.doesNotMatch(rule, /width:\s*100%/);
|
||||
assert.match(rule, /flex:\s*1/, "it should take the width the title gave up");
|
||||
});
|
||||
|
||||
test("and the buttons grow into that width without ever shrinking below their labels", () => {
|
||||
// `flex: 1 1 0` would divide the row into equal shares: at 360px the row is 269
|
||||
// wide, so mid-hand that is 87px each and "Next Hand ⟳" needs 89 — 73 of text and
|
||||
// 16 of padding. These are `white-space: nowrap`, so what gives is the text.
|
||||
const rule = ruleIn(PHONE(), "#holdemHeaderActions .holdemBtn") || "";
|
||||
assert.match(rule, /flex:\s*1\s+1\s+auto/, "an auto basis makes the label the floor");
|
||||
assert.match(rule, /white-space:\s*nowrap/);
|
||||
assert.match(rule, /min-height:\s*46px/, "across is the only direction that tightens");
|
||||
});
|
||||
|
||||
test("the arrow keeps a thumb-sized box once it loses its word", () => {
|
||||
// The word used to carry the width, and the padding only had to make up the
|
||||
// height. With the word gone the arrow is the whole target, and the first
|
||||
// attempt at 32px was measured by the layout sweep at 31x43 on every touch
|
||||
// viewport it runs — 56 scenarios, one control.
|
||||
[["upright", PHONE()], ["sideways", LANDSCAPE()]].forEach(([which, block]) => {
|
||||
const rule = ruleIn(block, ".holdemBackLink") || "";
|
||||
assert.match(rule, /min-width:\s*44px/, `${which}'s back arrow is under the touch floor`);
|
||||
// The negative margins that gave the hit area back to a two-row header would
|
||||
// now pull the arrow out of a row it is the first item of.
|
||||
assert.match(rule, /margin:\s*0/);
|
||||
});
|
||||
});
|
||||
|
||||
test("and it still says where it goes when the word is gone", () => {
|
||||
// An "←" on its own is announced as "left arrow" and names nothing. The label
|
||||
// contains the visible text on the sizes that still show it, which is what
|
||||
// WCAG 2.5.3 asks of a name that says more than the label does.
|
||||
const link = PAGE.match(/<a href="\/casino"[^>]*>/);
|
||||
assert.ok(link, "the back link is gone");
|
||||
const label = link[0].match(/aria-label="([^"]+)"/);
|
||||
assert.ok(label, "the back link has no accessible name for when the word is hidden");
|
||||
assert.match(label[1], /Casino/,
|
||||
"the visible word has to appear in the spoken name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the hand number found somewhere to live", () => {
|
||||
test("it is not a pill beside the page title any more", () => {
|
||||
assert.ok(!PAGE.includes("handCounterBadge"),
|
||||
"a counter read after the fact does not earn a line of a phone's header");
|
||||
});
|
||||
|
||||
test("it is in the row that already says what hand you are in", () => {
|
||||
// The street, the pot and the player count are the other three facts about the
|
||||
// current hand, and they are drawn together. This one costs nothing there: the
|
||||
// row was on the screen already.
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"),
|
||||
"utf8");
|
||||
// Sliced from the top of renderStatus to the first mention of the street, so
|
||||
// finding the hand chip inside that slice is the ordering claim as well as the
|
||||
// presence one: it is appended before the street or it is not in here.
|
||||
const status = source.slice(source.indexOf("renderStatus()"), source.indexOf("actionStreet"));
|
||||
assert.match(status, /actionHand/,
|
||||
"the status row does not say which hand this is, or says it after the street");
|
||||
});
|
||||
|
||||
test("a table that has never dealt says #1, not #0 and not #undefined", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
delete state.handNumber;
|
||||
assert.equal(ActionBar.handLabel(state), "#1");
|
||||
assert.equal(ActionBar.handLabel({ handNumber: 0 }), "#1");
|
||||
assert.equal(ActionBar.handLabel({ handNumber: 12 }), "#12");
|
||||
assert.equal(ActionBar.handLabel(null), "#1");
|
||||
});
|
||||
|
||||
test("it is drawn quieter than the street beside it", () => {
|
||||
// The street is read every deal; this is read when somebody asks about a hand
|
||||
// that is already over. Same row, different job, and the colour says so.
|
||||
const rule = CSS.match(/\.actionHand\s*\{([^}]*)\}/);
|
||||
assert.ok(rule, ".actionHand has no rule");
|
||||
assert.match(rule[1], /color:\s*var\(--text-dim\)/);
|
||||
// A number that ticks over while the dealer is looking at it.
|
||||
assert.match(rule[1], /font-variant-numeric:\s*tabular-nums/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,413 @@
|
||||
// Where a sheet puts the dealer, and whether they can get anywhere from there.
|
||||
//
|
||||
// Modal exists because six overlays were each missing the same three things, and it
|
||||
// fixed all six at once by answering one question the same way everywhere: focus the
|
||||
// first control, and hold Tab between the first and the last. Measured this round, that
|
||||
// single answer had gone wrong in three separate directions.
|
||||
//
|
||||
// It could not see a <summary>. The rulings sheet is twelve of them and one Done
|
||||
// button, so the trap decided Done was both ends of the ring and bounced every press
|
||||
// back to it: eighty Tab presses, one element ever reached, twelve rulings that could
|
||||
// not be opened without a mouse.
|
||||
//
|
||||
// "The first control" was the card picker's Clear. The sheet a dealer opens to write a
|
||||
// card down opened on the button that rubs one out — the one thing the APG says to keep
|
||||
// a dialog off. And "the first control" is not an answer six dialogs share: Roselli's
|
||||
// rule is that a dialog long enough to scroll should land on its heading so the reader
|
||||
// can orient, and a short question with one irreversible answer should land on the
|
||||
// answer that leaves them no worse off.
|
||||
//
|
||||
// And a sheet that redraws itself was deleting the control that held focus. Choosing a
|
||||
// buy-in, picking a card, switching slots, confirming a side pot: all four left focus on
|
||||
// the document body, which inside a modal is nowhere at all.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Modal, CardPicker } = require("./load");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
const src = name => fs.readFileSync(
|
||||
path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
|
||||
|
||||
// The markup of one overlay, from its opening div to the next one's.
|
||||
const OVERLAY_ORDER = ["addChipsOverlay", "rulingsOverlay", "seatMenuOverlay",
|
||||
"cardPickerOverlay", "confirmOverlay", "potWizardOverlay"];
|
||||
const overlay = id => {
|
||||
const at = PAGE.indexOf(`<div id="${id}"`);
|
||||
assert.ok(at >= 0, `there is no ${id}`);
|
||||
const ends = OVERLAY_ORDER
|
||||
.map(other => PAGE.indexOf(`<div id="${other}"`, at + 1))
|
||||
.filter(i => i > at);
|
||||
return PAGE.slice(at, ends.length ? Math.min(...ends) : PAGE.length);
|
||||
};
|
||||
|
||||
// A document small enough to answer the handful of questions these functions ask of
|
||||
// one, and honest about the two that matter: a detached node knows it is detached, and
|
||||
// a disabled button refuses focus.
|
||||
function fakeSheet(specs) {
|
||||
const doc = { activeElement: null };
|
||||
const nodes = specs.map(spec => ({
|
||||
dataset: spec.key === undefined ? {} : { focusKey: spec.key },
|
||||
disabled: !!spec.disabled,
|
||||
isConnected: true,
|
||||
tabIndex: 0,
|
||||
label: spec.key || spec.label,
|
||||
focus() {
|
||||
if (this.disabled) return;
|
||||
doc.activeElement = this;
|
||||
},
|
||||
}));
|
||||
const root = {
|
||||
nodes,
|
||||
contains: el => nodes.includes(el),
|
||||
querySelectorAll: () => nodes.filter(n => n.dataset.focusKey !== undefined),
|
||||
};
|
||||
return { doc, root, nodes };
|
||||
}
|
||||
|
||||
// preservingFocus and the picker's cursor both reach for the global document. They are
|
||||
// given one for the length of a test and it is taken away again afterwards.
|
||||
function withDocument(doc, body) {
|
||||
const had = Object.prototype.hasOwnProperty.call(globalThis, "document");
|
||||
const before = globalThis.document;
|
||||
globalThis.document = doc;
|
||||
try {
|
||||
return body();
|
||||
} finally {
|
||||
if (had) globalThis.document = before;
|
||||
else delete globalThis.document;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what counts as somewhere you can Tab to", () => {
|
||||
const selectors = Modal.FOCUSABLE.split(",");
|
||||
|
||||
// The whole of the rulings sheet hung on this one omission.
|
||||
test("a summary is a control, because the browser already treats it as one", () => {
|
||||
assert.ok(selectors.some(s => /details\s*>\s*summary/.test(s)),
|
||||
"a <details> summary is focusable without a tabindex, and this list can't see it");
|
||||
});
|
||||
|
||||
// Left off, a card taken out of the Tab order still matches `button`, and the grid
|
||||
// goes back to being fifty-two stops with a roving tabindex painted on top.
|
||||
test("every line excludes what has been taken out of the Tab order, not just the last", () => {
|
||||
selectors.forEach(selector => {
|
||||
assert.ok(/:not\(\[tabindex="-1"\]\)/.test(selector) || /^\s*\[tabindex\]/.test(selector),
|
||||
`\`${selector.trim()}\` still matches controls that are not Tab stops`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and the sheet itself is still off the list", () => {
|
||||
assert.ok(/\[tabindex\]:not\(\[tabindex="-1"\]\)/.test(Modal.FOCUSABLE));
|
||||
});
|
||||
});
|
||||
|
||||
describe("where each sheet lands the dealer", () => {
|
||||
const LANDS_ON_ITS_HEADING = [
|
||||
["cardPickerOverlay", "cardPickerTitle"],
|
||||
["addChipsOverlay", "addChipsTitle"],
|
||||
["rulingsOverlay", "rulingsTitle"],
|
||||
["potWizardOverlay", "potWizardTitle"],
|
||||
["seatMenuOverlay", "seatMenuTitle"],
|
||||
];
|
||||
|
||||
LANDS_ON_ITS_HEADING.forEach(([id, heading]) => {
|
||||
test(`${id} opens on the line that says which`, () => {
|
||||
const markup = overlay(id);
|
||||
const tag = markup.match(new RegExp(`<h2 id="${heading}"[^>]*>`));
|
||||
assert.ok(tag, `${heading} is not an h2 in ${id}`);
|
||||
assert.match(tag[0], /data-modal-focus/);
|
||||
assert.match(tag[0], /tabindex="-1"/,
|
||||
"a heading that is a Tab stop is a Tab stop the dealer has to press past");
|
||||
});
|
||||
});
|
||||
|
||||
// The APG's own example of when to land on a control rather than a heading, and the
|
||||
// reason: one of the two answers cannot be taken back.
|
||||
test("the confirm dialog is the exception, and it lands on Cancel", () => {
|
||||
const markup = overlay("confirmOverlay");
|
||||
const cancel = markup.match(/<button id="confirmCancelBtn"[^>]*>/);
|
||||
assert.ok(cancel);
|
||||
assert.match(cancel[0], /data-modal-focus/);
|
||||
assert.ok(!/<button id="confirmAcceptBtn"[^>]*data-modal-focus/.test(markup),
|
||||
"the dialog lands on the answer that cannot be taken back");
|
||||
});
|
||||
|
||||
test("no sheet names two landing spots, which would be a coin toss", () => {
|
||||
OVERLAY_ORDER.forEach(id => {
|
||||
const found = (overlay(id).match(/data-modal-focus/g) || []).length;
|
||||
assert.equal(found, 1, `${id} names ${found} landing spots`);
|
||||
});
|
||||
});
|
||||
|
||||
test("the card picker no longer opens on Clear", () => {
|
||||
const markup = overlay("cardPickerOverlay");
|
||||
assert.ok(markup.indexOf('id="cardPickerTitle"') < markup.indexOf('id="cardPickerClearBtn"'),
|
||||
"the heading has moved below the button it was there to keep focus off");
|
||||
assert.ok(!/id="cardPickerClearBtn"[^>]*data-modal-focus/.test(markup));
|
||||
});
|
||||
|
||||
// The landing spot is deliberately outside the ring, and a wrap that only knows
|
||||
// "is this the first one" walks straight out of the dialog on the first Shift+Tab.
|
||||
test("the trap wraps from outside the ring as well as from its ends", () => {
|
||||
const modal = src("modal");
|
||||
const at = modal.indexOf("const at = focusable.indexOf(");
|
||||
assert.ok(at >= 0, "the Tab trap no longer asks where the dealer is in the ring");
|
||||
const body = modal.slice(at, modal.indexOf("\n },", at));
|
||||
assert.match(body, /shiftKey && at <= 0/);
|
||||
assert.match(body, /at === -1/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a redraw that does not drop the dealer", () => {
|
||||
test("focus comes back to the control with the same name", () => {
|
||||
const { doc, root, nodes } = fakeSheet([
|
||||
{ key: "quick-500" }, { key: "quick-1000" }, { key: "quick-2000" }]);
|
||||
doc.activeElement = nodes[1];
|
||||
|
||||
withDocument(doc, () => Modal.preservingFocus(root, () => {
|
||||
// What a rebuild does: the old buttons go, identical new ones arrive.
|
||||
nodes.forEach(n => { n.isConnected = false; });
|
||||
root.nodes = root.querySelectorAll = null;
|
||||
const rebuilt = fakeSheet([{ key: "quick-500" }, { key: "quick-1000" }, { key: "quick-2000" }]);
|
||||
rebuilt.nodes.forEach(n => { n.focus = () => { doc.activeElement = n; }; });
|
||||
root.querySelectorAll = () => rebuilt.nodes;
|
||||
root.contains = el => rebuilt.nodes.includes(el);
|
||||
}));
|
||||
|
||||
assert.equal(doc.activeElement.dataset.focusKey, "quick-1000");
|
||||
});
|
||||
|
||||
// The card just picked is a dealt card now, and a dealt card is a disabled button.
|
||||
// There is no coming back to it, so the caller is asked where to go instead.
|
||||
test("a name that comes back disabled hands the decision to the caller", () => {
|
||||
const { doc, root, nodes } = fakeSheet([{ key: "AS" }, { key: "KH" }]);
|
||||
doc.activeElement = nodes[0];
|
||||
let asked = false;
|
||||
|
||||
withDocument(doc, () => Modal.preservingFocus(root, () => {
|
||||
nodes[0].isConnected = false;
|
||||
nodes[0].disabled = true;
|
||||
}, () => { asked = true; }));
|
||||
|
||||
assert.ok(asked, "focus was left on the body of a modal");
|
||||
});
|
||||
|
||||
test("and a name that is simply gone asks too", () => {
|
||||
const { doc, root, nodes } = fakeSheet([{ key: "confirm" }]);
|
||||
doc.activeElement = nodes[0];
|
||||
let asked = false;
|
||||
|
||||
withDocument(doc, () => Modal.preservingFocus(root, () => {
|
||||
nodes[0].isConnected = false;
|
||||
root.querySelectorAll = () => [];
|
||||
}, () => { asked = true; }));
|
||||
|
||||
assert.ok(asked);
|
||||
});
|
||||
|
||||
// Clear is in the header. Nothing rebuilds it, so nothing should move it — a redraw
|
||||
// the dealer asked for is not a reason to take them somewhere else.
|
||||
test("a control that survived the redraw is left exactly where it was", () => {
|
||||
const { doc, root, nodes } = fakeSheet([{ label: "Clear" }, { key: "AS" }]);
|
||||
doc.activeElement = nodes[0];
|
||||
let asked = false;
|
||||
|
||||
withDocument(doc, () => Modal.preservingFocus(root, () => {
|
||||
nodes[1].isConnected = false;
|
||||
}, () => { asked = true; }));
|
||||
|
||||
assert.equal(doc.activeElement, nodes[0]);
|
||||
assert.ok(!asked);
|
||||
});
|
||||
|
||||
test("and a dealer who was never in this sheet is not dragged into it", () => {
|
||||
const { doc, root } = fakeSheet([{ key: "AS" }]);
|
||||
const elsewhere = { dataset: {}, isConnected: true };
|
||||
doc.activeElement = elsewhere;
|
||||
let asked = false;
|
||||
|
||||
withDocument(doc, () => Modal.preservingFocus(root, () => {}, () => { asked = true; }));
|
||||
|
||||
assert.equal(doc.activeElement, elsewhere);
|
||||
assert.ok(!asked);
|
||||
});
|
||||
|
||||
// Four sheets redraw themselves on a tap. All four go through this now, and each
|
||||
// one hands over the piece of itself it is about to throw away.
|
||||
[["addChips", "render()", "addChipsSheet"],
|
||||
["cardPicker", "render()", "cardPickerSheet"],
|
||||
["seatMenu", "render()", "seatMenuOverlay"],
|
||||
["potWizard", "renderStepInto()", "potWizardModal"]].forEach(([module, method, root]) => {
|
||||
test(`${module}'s ${method} redraws through it`, () => {
|
||||
const text = src(module);
|
||||
const at = text.indexOf(`\n ${method} {`);
|
||||
assert.ok(at >= 0, `${module} has no ${method}`);
|
||||
const body = text.slice(at, text.indexOf("\n },", at));
|
||||
assert.match(body, /Modal\.preservingFocus\(/,
|
||||
`${module} rebuilds its controls without saying what to do about focus`);
|
||||
assert.ok(body.includes(`"${root}"`),
|
||||
`${module} hands preservingFocus something other than ${root}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("thirteen cards, one Tab stop", () => {
|
||||
test("the deck is written once, ace down to two", () => {
|
||||
const deck = CardPicker.deck();
|
||||
assert.equal(deck.length, 52);
|
||||
assert.equal(new Set(deck.map(c => c.card)).size, 52);
|
||||
assert.deepEqual(deck.slice(0, 4).map(c => c.card), ["AS", "AH", "AD", "AC"]);
|
||||
assert.equal(deck[4].card, "KS");
|
||||
assert.equal(deck[51].card, "2C");
|
||||
});
|
||||
|
||||
// A dealt card is `aria-disabled` rather than `disabled`, so it holds focus like any
|
||||
// other cell — which is what lets the arrows tell the truth about the grid, and what
|
||||
// lets pressing one be answered instead of ignored. See theUnansweredPress.
|
||||
const grid = keys => keys.map(key => ({
|
||||
dataset: { focusKey: key.replace("!", "") },
|
||||
getAttribute: name => (name === "aria-disabled" && key.includes("!") ? "true" : null),
|
||||
tabIndex: 0,
|
||||
focus() {},
|
||||
}));
|
||||
|
||||
const withGrid = (cards, body) => {
|
||||
const nodes = grid(cards);
|
||||
return withDocument({ querySelectorAll: () => nodes }, () => body(nodes));
|
||||
};
|
||||
|
||||
test("a step is one card, whether or not that card is already out", () => {
|
||||
// The seven is dealt. Stepping along from the eight lands on it and says so,
|
||||
// rather than quietly arriving at the six.
|
||||
withGrid(["8C", "7C!", "6C"], () => {
|
||||
assert.equal(CardPicker.nextCursor(0, 1), 1);
|
||||
});
|
||||
});
|
||||
|
||||
// The whole reason the skipping had to go. Hunting for the next card that could take
|
||||
// focus did the exact thing the no-wrap rule below was written to prevent: it moved
|
||||
// the dealer sideways without being asked. Right from the ace over a dealt king
|
||||
// landed on the queen, so the column silently became the one to its right, and the
|
||||
// next press of Down walked a rank nobody had asked for.
|
||||
test("and it never slides the dealer into a column they didn't ask for", () => {
|
||||
withGrid(["AS", "KS!", "QS", "JS", "TS", "9S", "8S", "7S"], () => {
|
||||
const afterRight = CardPicker.nextCursor(0, 1);
|
||||
assert.equal(afterRight, 1, "a dealt king is still the cell to the right of the ace");
|
||||
assert.equal(CardPicker.nextCursor(afterRight, 4), 5,
|
||||
"and down a row of four from it is the nine, four ranks along");
|
||||
});
|
||||
});
|
||||
|
||||
// Wrapping would answer "move me down a row" by moving the dealer to the other end
|
||||
// of the grid, which is not what they asked and not where they would look.
|
||||
test("the edge of the grid is the edge, not a way round to the other side", () => {
|
||||
withGrid(["AS", "KS", "QS", "JS"], () => {
|
||||
assert.equal(CardPicker.nextCursor(3, 1), 3);
|
||||
assert.equal(CardPicker.nextCursor(0, -1), 0);
|
||||
});
|
||||
});
|
||||
|
||||
test("only one card is in the Tab order at a time", () => {
|
||||
withGrid(["AS", "KS", "QS", "JS"], nodes => {
|
||||
CardPicker.aimCursor(2);
|
||||
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, 0, -1]);
|
||||
});
|
||||
});
|
||||
|
||||
// Which happens after every pick: the cursor is sitting on a card that has just
|
||||
// become a dealt card. It stays there, because that cell is still a cell and its
|
||||
// neighbours are still where they were a moment ago.
|
||||
test("a cursor left on a dealt card stays on it", () => {
|
||||
withGrid(["AS!", "KS", "QS", "JS"], nodes => {
|
||||
CardPicker.aimCursor(0);
|
||||
assert.equal(CardPicker.cursor, 0);
|
||||
assert.deepEqual(nodes.map(n => n.tabIndex), [0, -1, -1, -1]);
|
||||
});
|
||||
});
|
||||
|
||||
test("and a grid with nothing left in it still has somewhere to put the cursor", () => {
|
||||
withGrid(["5S!", "4S!", "3S!", "2S!"], nodes => {
|
||||
CardPicker.aimCursor(3);
|
||||
assert.equal(CardPicker.cursor, 3);
|
||||
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, -1, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
// An index from outside the grid is clamped rather than wrapped, which is what
|
||||
// Home and End lean on: they ask for one card past either end and take what they
|
||||
// are given.
|
||||
test("Home and End ask past the ends and land on them", () => {
|
||||
withGrid(["AS", "KS", "QS", "JS"], () => {
|
||||
assert.equal(CardPicker.nextCursor(-1, 1), 0);
|
||||
assert.equal(CardPicker.nextCursor(4, -1), 3);
|
||||
});
|
||||
});
|
||||
|
||||
// It used to step by `SUITS.length`, because the grid was four suits wide at every
|
||||
// size. The grid is thirteen ranks of one suit now and takes as many columns as the
|
||||
// width holds — five on a 360px phone, seven at 460 — so a constant would be wrong
|
||||
// on most screens, which is the thing that slid the cursor into the wrong column
|
||||
// the last time these two disagreed. It asks the layout instead.
|
||||
test("the arrows step by the row the grid actually has, not by a guess", () => {
|
||||
const picker = src("cardPicker");
|
||||
const at = picker.indexOf("onGridKey(event)");
|
||||
assert.ok(at >= 0, "the grid has no arrow keys");
|
||||
const body = picker.slice(at, picker.indexOf("\n },", at));
|
||||
assert.match(body, /const stride = this\.gridStride\(\)/);
|
||||
assert.match(body, /ArrowRight: 1.*ArrowLeft: -1.*ArrowDown: stride.*ArrowUp: -stride/s);
|
||||
assert.match(body, /"Home"/);
|
||||
assert.match(body, /"End"/);
|
||||
});
|
||||
|
||||
test("and the row it actually has is read off the grid", () => {
|
||||
const picker = src("cardPicker");
|
||||
const at = picker.indexOf("gridStride() {");
|
||||
assert.ok(at >= 0, "there is no gridStride");
|
||||
const body = picker.slice(at, picker.indexOf("\n },", at));
|
||||
assert.match(body, /getComputedStyle\(grid\)\.gridTemplateColumns/);
|
||||
// Outside a browser there is nothing to measure, and thirteen ranks in one row
|
||||
// is the shape that makes Up and Down do nothing rather than something wrong.
|
||||
assert.match(body, /return this\.RANKS\.length/);
|
||||
});
|
||||
|
||||
test("a fresh sheet starts at the top of the deck", () => {
|
||||
const picker = src("cardPicker");
|
||||
["open(state, hooks, seatIndex, cardIndex) {", "openBoard(state, hooks, cardIndex, limit = 5) {"]
|
||||
.forEach(signature => {
|
||||
const at = picker.indexOf(signature);
|
||||
assert.ok(at >= 0, `there is no \`${signature}\``);
|
||||
assert.match(picker.slice(at, picker.indexOf("this.render();", at)), /this\.cursor = 0;/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("seeing where you are", () => {
|
||||
// Until a summary could be Tabbed to at all, there was no way to notice that
|
||||
// twenty of them had no ring.
|
||||
test("a summary gets the tool's own focus ring, not the browser's", () => {
|
||||
const at = CSS.indexOf("---------- Keyboard focus ----------");
|
||||
assert.ok(at >= 0);
|
||||
const block = CSS.slice(at, CSS.indexOf("outline: 2px solid #7fc4ff", at));
|
||||
assert.match(block, /(^|\n)summary:focus-visible,/);
|
||||
});
|
||||
|
||||
// Only script ever focuses these, so `:focus` would ring the title of every sheet
|
||||
// on a phone that has no keyboard to ring it for.
|
||||
test("the landing heading rings for a keyboard and stays quiet for a thumb", () => {
|
||||
const at = CSS.indexOf('h2[data-modal-focus]:focus-visible {');
|
||||
assert.ok(at >= 0, "the heading a sheet lands on shows nothing when it takes focus");
|
||||
const rule = CSS.slice(at, CSS.indexOf("}", at));
|
||||
assert.match(rule, /outline: 2px solid #7fc4ff/);
|
||||
// These headings run to the edge of their sheet; an outward ring is clipped.
|
||||
assert.match(rule, /outline-offset: -2px/);
|
||||
assert.ok(!/\[data-modal-focus\]:focus\b(?!-)/.test(CSS),
|
||||
"a plain :focus rule would ring the title on every tap");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,992 @@
|
||||
// A phone held sideways, and the three pieces of arithmetic that make the table fit on
|
||||
// one.
|
||||
//
|
||||
// The layout audit is what proved this was broken and what proves it is fixed — it
|
||||
// measures the drawn page, which is the only place a layout is really true. What is
|
||||
// here is the part underneath that can be wrong on its own: which shape the bar is in,
|
||||
// how much of the bottom that leaves spoken for, how a name is shortened when it will
|
||||
// not fit, and how the ring is pulled back in when a puck lands past the rail.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { ActionBar, Render } = require("./load");
|
||||
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
const CSS_JS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"), "utf8");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("which shape the bar is in", () => {
|
||||
// The layout is chosen by a media query — orientation, height and width together.
|
||||
// The one thing that must not happen is a second copy of that query in JavaScript,
|
||||
// because the first thing anybody changes is the breakpoint, in one of the two
|
||||
// places. So the stylesheet declares which layout it picked and actionBar.js reads
|
||||
// the answer back.
|
||||
const withComputed = (value, run) => {
|
||||
const host = { id: "actionBar" };
|
||||
global.getComputedStyle = el => ({
|
||||
getPropertyValue: prop => (el === host && prop === "--actionBarLayout" ? value : ""),
|
||||
});
|
||||
try { return run(host); } finally { delete global.getComputedStyle; }
|
||||
};
|
||||
|
||||
test("the stylesheet says, and this reads", () => {
|
||||
assert.equal(withComputed(" rail ", host => ActionBar.isRail(host)), true);
|
||||
assert.equal(withComputed(" bar ", host => ActionBar.isRail(host)), false);
|
||||
});
|
||||
|
||||
test("anything it does not recognise is the bar it has always been", () => {
|
||||
assert.equal(withComputed("", host => ActionBar.isRail(host)), false);
|
||||
assert.equal(withComputed("sidebar", host => ActionBar.isRail(host)), false);
|
||||
});
|
||||
|
||||
test("and with no browser at all, likewise", () => {
|
||||
assert.equal(ActionBar.isRail(null), false);
|
||||
assert.equal(ActionBar.isRail({ id: "actionBar" }), false,
|
||||
"no getComputedStyle to ask — this runs in the unit suite too");
|
||||
});
|
||||
|
||||
test("the stylesheet declares both halves of it", () => {
|
||||
// If either of these goes, isRail() quietly answers "bar" forever and the felt
|
||||
// is measured against a bar that is not underneath it.
|
||||
assert.match(CSS, /#actionBar\s*\{[^}]*--actionBarLayout:\s*bar/,
|
||||
"the default, on the base rule");
|
||||
assert.match(CSS, /--actionBarLayout:\s*rail/,
|
||||
"and the rail, inside the landscape query");
|
||||
});
|
||||
|
||||
test("the rail is scoped to a phone, not to any short window", () => {
|
||||
// Above 900px the sidebar is already a right-hand column and a rail would land
|
||||
// on top of it.
|
||||
const query = CSS.match(/@media \(orientation: landscape\)[^{]*\{[\s\S]{0,400}?--actionBarLayout: rail/);
|
||||
assert.ok(query, "the rail lives in a landscape media query");
|
||||
assert.match(query[0], /max-width:\s*900px/);
|
||||
assert.match(query[0], /max-height:\s*560px/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the bar lines up with the table", () => {
|
||||
// On a desktop the bar was centred on the window and the table on its own column,
|
||||
// and the sidebar takes 320px out of the right of that — so the two things a dealer
|
||||
// is looking at were 172px apart, at 960px wide and at 1600px and everywhere in
|
||||
// between, because the offset is half the sidebar and the sidebar is a constant.
|
||||
//
|
||||
// The rule that fixes it is four numbers that have to keep agreeing with four other
|
||||
// numbers, none of them anywhere near each other in the file. That is what this
|
||||
// block is for: the arithmetic is checked on the running page by measurement (bar
|
||||
// centre against table-column centre, which came out 0px off at every width), and
|
||||
// the numbers it depends on are checked here so they cannot drift apart afterwards.
|
||||
const rule = CSS.match(/@media \(min-width: 901px\) \{\s*#actionBar \{([^}]*)\}/);
|
||||
|
||||
test("there is a rule, and it is scoped above the one-column breakpoint", () => {
|
||||
// Below 901px the sidebar is not a column at all — it is stacked under the table
|
||||
// — so there is nothing to lean away from and the bar is centred as it was.
|
||||
assert.ok(rule, "no min-width: 901px rule for #actionBar");
|
||||
assert.match(CSS, /@media \(max-width: 900px\)[\s\S]{0,200}#holdemLayout \{\s*grid-template-columns: 1fr;/,
|
||||
"the one-column layout should start exactly where this rule stops");
|
||||
});
|
||||
|
||||
test("it leans away from the sidebar by the sidebar's own width", () => {
|
||||
assert.match(rule[1], /left:\s*var\(--sp-6\)/);
|
||||
assert.match(rule[1], /right:\s*calc\(var\(--sidebarW\)\s*\+\s*var\(--sp-5\)\s*\+\s*var\(--sp-6\)\)/);
|
||||
});
|
||||
|
||||
test("and the sidebar's width is one number, not two", () => {
|
||||
// The grid draws the column and the bar leans away from it. Written out twice,
|
||||
// the day somebody widens the sidebar is the day the bar goes crooked and no
|
||||
// test says so.
|
||||
assert.match(CSS, /--sidebarW:\s*\d+px/, "the token should be declared");
|
||||
assert.match(CSS, /grid-template-columns:\s*1fr var\(--sidebarW\)/,
|
||||
"the grid should draw the column from the token");
|
||||
});
|
||||
|
||||
test("no 100vw anywhere in it", () => {
|
||||
// The bar's own centring comment warns about this: on a page with a scrollbar
|
||||
// 100vw is wider than the viewport, and the overflow drags the table sideways.
|
||||
// The offsets are exact without it — see the derivation in the stylesheet.
|
||||
assert.equal(/100vw/.test(rule[1]), false, rule[1]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the button seat's name", () => {
|
||||
// The button seat's name is the same name in the same box as every other seat's.
|
||||
//
|
||||
// Three attempts said otherwise, in this order: a float, a float with a first-line
|
||||
// indent, and `padding-right: 25px` — the dealer chip's width plus its inset, taken
|
||||
// off the name because the chip sat inside the puck's top-right corner. All three
|
||||
// were answers to a chip that had no business being in the writing, and the price
|
||||
// was a quarter of the name's line on whichever seat currently held the button.
|
||||
// Measured with the sweep's twelve names: 7 of 12 fitted on the button seat at
|
||||
// 9-handed on an iPhone against 11 anywhere else, and 1 of 12 against 7 at
|
||||
// 12-handed on a Galaxy S8.
|
||||
const name = CSS.match(/\n\.seatPuckName \{([^}]*)\}/);
|
||||
|
||||
test("nothing reserves a gutter inside the name any more", () => {
|
||||
assert.equal(/\.seat\.seatHasButton \.seatPuckName/.test(CSS), false,
|
||||
"the button seat's name should be the same box as every other seat's");
|
||||
});
|
||||
|
||||
test("the float that used to do this is gone, not merely unused", () => {
|
||||
// Left in place with a one-line name it would clip the name to nothing: the word
|
||||
// cannot fit beside the float, so it moves to a second line, and there is no
|
||||
// second line. Measured — "Christopher" drew as an empty label.
|
||||
assert.equal(/\.seatPuckName::before/.test(CSS), false,
|
||||
"a float and one line of name cannot both be true");
|
||||
});
|
||||
|
||||
test("and no rule anywhere gives a puck's name a second line", () => {
|
||||
// This is what every puck being the same height rests on. A name is a label, and
|
||||
// the ring is sized against the tallest puck on it, so one name wrapping is one
|
||||
// seat setting how far apart all of them have to sit.
|
||||
assert.ok(name, "the name should have a base rule");
|
||||
assert.match(name[1], /white-space:\s*nowrap/);
|
||||
});
|
||||
|
||||
test("and it is one line by not wrapping, not by hiding what wrapped", () => {
|
||||
// A `-webkit-box` clamped to one line hides the lines past the first; it does not
|
||||
// hide the part of the second one that the row's own min-height leaves uncovered.
|
||||
// The row is 15px and .puckNameTight's line box is 12.24, so 2.76px of the next
|
||||
// line showed — "Christ…" with the ascenders of "opher" underneath it.
|
||||
assert.equal(/(?<![\w-])line-clamp:/.test(CSS), false,
|
||||
"a clamp and a fixed row height only agree while the type never moves");
|
||||
assert.match(name[1], /text-overflow:\s*ellipsis/);
|
||||
});
|
||||
|
||||
test("the box a name is measured in is the box it is drawn in", () => {
|
||||
// What the clamp cost besides the stray line: a name that wrapped reported
|
||||
// scrollWidth == clientWidth, so fitSeatNames() could only ever see the overflow
|
||||
// as height. `nowrap` puts the whole name on one line whether it fits or not,
|
||||
// which is what makes the width worth reading.
|
||||
assert.match(name[1], /overflow:\s*hidden/);
|
||||
assert.equal(/overflow-wrap:\s*break-word/.test(name[1]), false,
|
||||
"nothing to break a word onto");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the table is sized against the bar, not against a pad over it", () => {
|
||||
// The raise pad is 57px taller than the row it replaces, and the felt was sized
|
||||
// against it. Measured with five players nine-handed, pressing Raise:
|
||||
//
|
||||
// 1280x900 felt 738x532 -> 669x481, pucks 120 -> 109, worst seat moved 73px
|
||||
// 360x640 felt 283x329 -> 283x279, pucks 44 -> 48, worst seat moved 50px
|
||||
//
|
||||
// The whole table jumped, re-ran the ten-candidate width search, and undid all of it
|
||||
// when the dealer pressed Raise to. Every raise, all night, at the one moment they
|
||||
// are reading the felt to decide what to type.
|
||||
//
|
||||
// A phone held sideways already scored 0px, because the rail is a column down the
|
||||
// side and its height never was the table's business. This is the upright layout
|
||||
// being held to what the sideways one already does.
|
||||
test.afterEach(() => { ActionBar.raiseOpen = false; delete ActionBar.barHeightAtRest; });
|
||||
|
||||
test("a closed bar is measured, and remembered", () => {
|
||||
ActionBar.raiseOpen = false;
|
||||
assert.equal(ActionBar.restingBarHeight(151), 151);
|
||||
assert.equal(ActionBar.barHeightAtRest, 151);
|
||||
});
|
||||
|
||||
test("and an open pad gets the height the bar had before it opened", () => {
|
||||
ActionBar.raiseOpen = false;
|
||||
ActionBar.restingBarHeight(151);
|
||||
ActionBar.raiseOpen = true;
|
||||
assert.equal(ActionBar.restingBarHeight(208), 151,
|
||||
"the felt would resize under the dealer's thumb mid-raise");
|
||||
});
|
||||
|
||||
test("and gets it back the moment the pad closes", () => {
|
||||
ActionBar.raiseOpen = false;
|
||||
ActionBar.restingBarHeight(151);
|
||||
ActionBar.raiseOpen = true;
|
||||
ActionBar.restingBarHeight(208);
|
||||
ActionBar.raiseOpen = false;
|
||||
assert.equal(ActionBar.restingBarHeight(172), 172,
|
||||
"a bar that really did grow — a side pot to show — still takes its room");
|
||||
});
|
||||
|
||||
test("a pad open before anything was ever measured takes what it can see", () => {
|
||||
// Nothing has been remembered yet, so the measurement is the only answer there
|
||||
// is. Better a felt sized for the pad than one sized for undefined.
|
||||
ActionBar.raiseOpen = true;
|
||||
delete ActionBar.barHeightAtRest;
|
||||
assert.equal(ActionBar.restingBarHeight(208), 208);
|
||||
});
|
||||
|
||||
test("the bar that is actually covering the window is still reported honestly", () => {
|
||||
// Only the felt's question is answered from the resting height. The toast has to
|
||||
// clear whatever is really there, and so does the page's scroll padding, so
|
||||
// --actionBarHeight stays a measurement.
|
||||
const publish = CSS_JS.slice(CSS_JS.indexOf("const publish = () => {"));
|
||||
const body = publish.slice(0, publish.indexOf("};"));
|
||||
assert.match(body, /--actionBarHeight[^\n]*Math\.round\(height\)/,
|
||||
"--actionBarHeight should still be the measured height");
|
||||
assert.match(body, /publishFeltRoom\(.*restingBarHeight\(height\)/,
|
||||
"the felt should be the one asking for the resting height");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the status block is the same number of lines whoever is acting", () => {
|
||||
// The bar is fixed to the bottom, so a line added to this block comes straight off
|
||||
// the felt. The block wraps, and flex settles its line breaks before it decides what
|
||||
// to shrink — so a name too long for the space left beside the clock and the undo
|
||||
// pill did not give way, it pushed them onto a third line.
|
||||
//
|
||||
// Measured at 360x640 mid-street: "Dee · $600 behind" left the table 283x329 and
|
||||
// "Christopher · $1,200 behind" made it 283x310, with every seat moving 24px between
|
||||
// one player and the next. Whose turn it was decided how big the table was.
|
||||
const acting = CSS.match(/\.actionActing \{([^}]*)\}/);
|
||||
const who = CSS.match(/\n\.actionWho \{([^}]*)\}/);
|
||||
const name = CSS.match(/\.actionWhoName \{([^}]*)\}/);
|
||||
const stack = CSS.match(/\.actionWhoStack \{([^}]*)\}/);
|
||||
|
||||
test("the acting row takes a line of its own, once there is somebody on it", () => {
|
||||
assert.ok(acting, ".actionActing rule not found");
|
||||
assert.match(acting[1], /flex:\s*0 1 auto/,
|
||||
"the pill on its own should not claim a line");
|
||||
const withActor = CSS.match(/\.actionActing:has\(\.actionWho\) \{([^}]*)\}/);
|
||||
assert.ok(withActor, "nothing gives the acting row its own line");
|
||||
assert.match(withActor[1], /flex:\s*1 1 100%/,
|
||||
"without a full basis it shares whatever line the wrap left it");
|
||||
});
|
||||
|
||||
test("and only then — a run-out has nobody to act and cannot spare the line", () => {
|
||||
// While a board is running out there is nobody to act, so this row is the undo
|
||||
// pill alone. Claiming a line for it is 22px off the felt on the twelve-handed
|
||||
// 360x640 table where the pucks are already at their 44px floor, and the sweep
|
||||
// caught that as a seat's "ALL IN" going back under the blind badge next door.
|
||||
assert.doesNotMatch(acting[1], /flex:\s*1 1 100%/);
|
||||
});
|
||||
|
||||
test("and it shrinks rather than wrapping", () => {
|
||||
// Left wrapping, this row reproduced the fault one level down: the undo pill is
|
||||
// a fixed width, so a long name pushed it onto a line of its own and the block
|
||||
// was 84px for some players and 66 for others.
|
||||
assert.match(acting[1], /flex-wrap:\s*nowrap/);
|
||||
});
|
||||
|
||||
test("the name is what gives way", () => {
|
||||
// `min-width` on a flex item defaults to `auto` — "never smaller than your own
|
||||
// content" — which is why the ellipsis this was written with never once appeared.
|
||||
assert.ok(who && name, "the actor should be a name and an amount, not one string");
|
||||
assert.match(who[1], /min-width:\s*0/);
|
||||
assert.match(name[1], /min-width:\s*0/);
|
||||
assert.match(name[1], /text-overflow:\s*ellipsis/);
|
||||
});
|
||||
|
||||
test("and the stack is what does not", () => {
|
||||
// The same rule the seat pucks keep: a number read wrong is a worse failure than
|
||||
// a name read short, so the stack keeps every digit.
|
||||
assert.ok(stack, ".actionWhoStack rule not found");
|
||||
assert.match(stack[1], /flex:\s*none/);
|
||||
});
|
||||
|
||||
test("except in the rail, which is a column and has no line to shrink into", () => {
|
||||
// 190px wide: every one of these readings is on its own line there regardless,
|
||||
// and squeezing a name into a fifth of the column would be shrinking to fit a
|
||||
// row that isn't there.
|
||||
const rules = [...CSS.matchAll(/\.actionActing \{([^}]*)\}/g)].map(m => m[1]);
|
||||
assert.equal(rules.length, 2,
|
||||
"one rule for the bar and one for the rail — no more, no fewer");
|
||||
assert.ok(rules.some(body => /flex-wrap:\s*wrap\s*;/.test(body)),
|
||||
"the rail should say what it wants instead");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("and the name is the half that is still there at the end of it", () => {
|
||||
// Shrinking the name had no floor under it. Flex takes the only item that can give
|
||||
// way and takes it all the way, so with the clock button on the line there was
|
||||
// nothing left to give: measured with five players nine-handed, mid-street, forcing
|
||||
// the clock button on —
|
||||
//
|
||||
// 360x640 "Christopher" wanted 62px and was drawn 0. "Alexandra" 54 -> 0.
|
||||
// 390x844 "Christopher" wanted 62px and was drawn 0. "Alexandra" 54 -> 0.
|
||||
// 430x932 "Christopher" 62 -> 31.
|
||||
//
|
||||
// What the bar read at the first two was "· $1,200 behind" — the separator, the
|
||||
// figure, and no player. The ellipsis the rule was written around never appeared,
|
||||
// because there were no characters left to put one after.
|
||||
//
|
||||
// The rule itself is kept: a number read wrong is worse than a name read short, so
|
||||
// the stack is never truncated. It is taken off the line whole instead. Measured
|
||||
// again afterwards, with the same clock button on the same line:
|
||||
//
|
||||
// 360x640 "Christopher" 62 -> 44 ("Christo…"), "Alexandra" 54 -> 44
|
||||
// 390x844 both whole
|
||||
// 430x932 both whole
|
||||
//
|
||||
// and 0px of seat movement across the thirty-second mark at every one of them.
|
||||
const withLine = (name, stack, run) => {
|
||||
const el = (extra) => Object.assign({
|
||||
hidden: false, scrollWidth: 0, clientWidth: 0,
|
||||
}, extra);
|
||||
const nameEl = el(name);
|
||||
const stackEl = el(stack);
|
||||
global.document = {
|
||||
querySelector: sel => (sel.endsWith(".actionWhoName") ? nameEl
|
||||
: sel.endsWith(".actionWhoStack") ? stackEl : null),
|
||||
};
|
||||
try { run(nameEl, stackEl); } finally { delete global.document; }
|
||||
};
|
||||
|
||||
test("a name that fits keeps the stack beside it", () => {
|
||||
withLine({ scrollWidth: 54, clientWidth: 54 }, {}, (nameEl, stackEl) => {
|
||||
ActionBar.fitActingLine();
|
||||
assert.equal(stackEl.hidden, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("a name being cut takes the stack off the line first", () => {
|
||||
withLine({ scrollWidth: 62, clientWidth: 0 }, {}, (nameEl, stackEl) => {
|
||||
ActionBar.fitActingLine();
|
||||
assert.equal(stackEl.hidden, true,
|
||||
"the stack is on that player's own seat puck; the name is only here");
|
||||
});
|
||||
});
|
||||
|
||||
test("and a stack already off it is put back before anything is measured", () => {
|
||||
// Otherwise the answer is a property of whatever the last render left behind
|
||||
// rather than of the line: a name that fits only *because* the stack is missing
|
||||
// would keep it missing for the rest of the night.
|
||||
withLine({ scrollWidth: 54, clientWidth: 54 }, { hidden: true }, (nameEl, stackEl) => {
|
||||
ActionBar.fitActingLine();
|
||||
assert.equal(stackEl.hidden, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("a hair of rounding is not a cut", () => {
|
||||
withLine({ scrollWidth: 55, clientWidth: 54 }, {}, (nameEl, stackEl) => {
|
||||
ActionBar.fitActingLine();
|
||||
assert.equal(stackEl.hidden, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("and a bar with nobody acting on it is left alone", () => {
|
||||
global.document = { querySelector: () => null };
|
||||
try { assert.doesNotThrow(() => ActionBar.fitActingLine()); }
|
||||
finally { delete global.document; }
|
||||
});
|
||||
|
||||
test("the stylesheet can actually take it off the line", () => {
|
||||
// `hidden` is a UA-stylesheet default, and .actionWhoStack sits inside a flex
|
||||
// container. Said out loud here so a later `display:` on the class can't quietly
|
||||
// put the figure back.
|
||||
assert.match(CSS, /\.actionWhoStack\[hidden\]\s*\{[^}]*display:\s*none/);
|
||||
assert.match(CSS, /\.actionTank\[hidden\]\s*\{[^}]*display:\s*none/);
|
||||
});
|
||||
|
||||
test("the space before the separator is in the stylesheet, not in the string", () => {
|
||||
// These are two flex items, and a flex item's own leading whitespace is collapsed
|
||||
// at its edge — so ` · $1,200 behind` drew as `Alexandra· $1,500 behind` from the
|
||||
// day the sentence was split in two.
|
||||
const who = CSS.match(/\n\.actionWho \{([^}]*)\}/);
|
||||
assert.match(who[1], /gap:\s*var\(--sp-1\)/);
|
||||
assert.match(CSS_JS, /whoStack\.textContent = `· \$\{/,
|
||||
"a leading space here is invisible and would double the gap if it weren't");
|
||||
});
|
||||
|
||||
test("and the fit is taken after the timer has decided whether it is on the line", () => {
|
||||
const render = CSS_JS.slice(CSS_JS.indexOf(" render() {"));
|
||||
const body = render.slice(0, render.indexOf("\n },"));
|
||||
const tank = body.indexOf("this.syncTankTimer()");
|
||||
const fit = body.indexOf("this.fitActingLine()");
|
||||
assert.ok(tank >= 0 && fit >= 0, "render() should do both");
|
||||
assert.ok(fit > tank,
|
||||
"measured before the stopwatch is hidden, the name is measured against 50px it has");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what the undo pill says it would take back", () => {
|
||||
// The pill is a fixed width — it names the last action, so its text changes on every
|
||||
// tap, and a control that resizes under a thumb reads as the interface twitching —
|
||||
// and on a phone that width leaves the label 73px. Measured across one hand, four of
|
||||
// the five labels a hand produces wanted more:
|
||||
//
|
||||
// ↶ dealing the hand 104 ↶ Christopher's fold 109
|
||||
// ↶ Mike from work's fold 128 ↶ Alexandra's fold 100
|
||||
// ↶ Dee's fold 69 (the only one that fitted)
|
||||
//
|
||||
// So the safety net spent most of a night reading "↶ dealing …", which does not say
|
||||
// what it is a net for. Cut as one string it says the wrong half: "↶ Mike from wor…"
|
||||
// names everybody except the action.
|
||||
//
|
||||
// Two halves, and the actor is the one that goes — whole, not shortened. Measured
|
||||
// again afterwards, nothing is cut at any viewport in the sweep: a phone reads
|
||||
// "↶ the hand", "↶ Dee's fold", "↶ raise to $20", "↶ call"; a desktop and the
|
||||
// landscape rail keep the actor wherever it fits.
|
||||
|
||||
test("a label handed over in halves knows which is which", () => {
|
||||
const step = ActionBar.undoParts({ lead: "Christopher's", act: "fold" });
|
||||
assert.equal(step.lead, "Christopher's");
|
||||
assert.equal(step.act, "fold");
|
||||
assert.equal(step.long, "Christopher's fold", "the sentence the toast and the tooltip use");
|
||||
});
|
||||
|
||||
test("one with no actor is all act", () => {
|
||||
// "the straddle" belongs to the table rather than to a player.
|
||||
const step = ActionBar.undoParts({ lead: "", act: "the straddle" });
|
||||
assert.equal(step.lead, "");
|
||||
assert.equal(step.long, "the straddle", "no stray space in front of it");
|
||||
});
|
||||
|
||||
test("and a plain string still works, with nothing to drop", () => {
|
||||
// Every caller passes halves today. This is what keeps a caller that doesn't
|
||||
// from drawing "[object Object]" on the one control a dealer reaches for when
|
||||
// they have made a mistake.
|
||||
const step = ActionBar.undoParts("something nobody split");
|
||||
assert.equal(step.lead, "");
|
||||
assert.equal(step.act, "something nobody split");
|
||||
assert.equal(step.long, "something nobody split");
|
||||
});
|
||||
|
||||
test("the sentence is what goes into the history, and the halves ride with it", () => {
|
||||
const seen = [];
|
||||
const before = ActionBar.hooks;
|
||||
ActionBar.hooks = {
|
||||
beginAction: (label, parts) => seen.push({ label, parts }),
|
||||
save: () => {}, rerender: () => {},
|
||||
};
|
||||
try {
|
||||
ActionBar.commit({ lead: "Dee's", act: "call" }, () => {});
|
||||
} finally { ActionBar.hooks = before; }
|
||||
assert.equal(seen.length, 1);
|
||||
assert.equal(seen[0].label, "Dee's call", "the toast says the whole thing");
|
||||
assert.deepEqual(
|
||||
{ lead: seen[0].parts.lead, act: seen[0].parts.act },
|
||||
{ lead: "Dee's", act: "call" });
|
||||
});
|
||||
|
||||
test("and the halves are read back from the history, not remembered here", () => {
|
||||
// Undo pops the history. A copy kept on this object would name the step that was
|
||||
// just taken back rather than the one now on top of the pile.
|
||||
const before = ActionBar.hooks;
|
||||
ActionBar.hooks = {
|
||||
lastActionLabel: () => "Dee's call",
|
||||
lastActionParts: () => ({ lead: "Dee's", act: "call" }),
|
||||
};
|
||||
try {
|
||||
assert.deepEqual(ActionBar.undoStep(), { lead: "Dee's", act: "call" });
|
||||
} finally { ActionBar.hooks = before; }
|
||||
});
|
||||
|
||||
test("a caller that offers no halves gets the label whole", () => {
|
||||
const before = ActionBar.hooks;
|
||||
ActionBar.hooks = { lastActionLabel: () => "dealing the hand" };
|
||||
try {
|
||||
assert.deepEqual(ActionBar.undoStep(), { lead: "", act: "dealing the hand" });
|
||||
} finally { ActionBar.hooks = before; }
|
||||
});
|
||||
|
||||
const withPill = (act, run) => {
|
||||
const lead = { hidden: false, className: "actionUndoLead" };
|
||||
const label = {
|
||||
className: "actionUndoLabel",
|
||||
querySelector: sel => (sel === ".actionUndoLead" ? lead
|
||||
: sel === ".actionUndoAct" ? act : null),
|
||||
};
|
||||
global.document = {
|
||||
querySelector: sel => (sel.endsWith(".actionUndoLabel") ? label : null),
|
||||
};
|
||||
try { run(lead); } finally { delete global.document; }
|
||||
};
|
||||
|
||||
test("an act that fits keeps the actor beside it", () => {
|
||||
withPill({ scrollWidth: 19, clientWidth: 40 }, lead => {
|
||||
ActionBar.fitUndoLabel();
|
||||
assert.equal(lead.hidden, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("an act being cut drops the actor whole", () => {
|
||||
withPill({ scrollWidth: 63, clientWidth: 40 }, lead => {
|
||||
ActionBar.fitUndoLabel();
|
||||
assert.equal(lead.hidden, true,
|
||||
"\"Christo…'s fold\" is a worse sentence than \"fold\"");
|
||||
});
|
||||
});
|
||||
|
||||
test("and it is the act that is asked, never the label", () => {
|
||||
// The label cannot overflow: the act inside it is the item with `overflow:
|
||||
// hidden`, so it swallows the excess and the row measures as though everything
|
||||
// fitted. Asking the label was the first way this was written, and it drew
|
||||
// "↶ dealing the hand" with "the hand" quietly cut off inside it.
|
||||
const fit = CSS_JS.slice(CSS_JS.indexOf(" fitUndoLabel() {"));
|
||||
const body = fit.slice(0, fit.indexOf("\n },"));
|
||||
assert.match(body, /act\.scrollWidth > act\.clientWidth/);
|
||||
assert.doesNotMatch(body, /label\.scrollWidth/);
|
||||
});
|
||||
|
||||
test("an actor already dropped is put back before anything is measured", () => {
|
||||
withPill({ scrollWidth: 19, clientWidth: 40 }, lead => {
|
||||
lead.hidden = true;
|
||||
ActionBar.fitUndoLabel();
|
||||
assert.equal(lead.hidden, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("and a bar with nothing to take back is left alone", () => {
|
||||
global.document = { querySelector: () => null };
|
||||
try { assert.doesNotThrow(() => ActionBar.fitUndoLabel()); }
|
||||
finally { delete global.document; }
|
||||
});
|
||||
|
||||
test("the keycap goes where there is no keyboard to press it", () => {
|
||||
// Every other control on the bar had dropped its cap on a phone since the rule
|
||||
// was written; this one is a different class and had been left out of it, so the
|
||||
// narrowest label on the bar was the only one still spending 22px on a shortcut
|
||||
// nothing could press.
|
||||
const rule = CSS.match(/@media \(hover: none\) \{\s*\.actionKey,\s*\.actionUndoKey \{([^}]*)\}/);
|
||||
assert.ok(rule, ".actionUndoKey should be hidden alongside .actionKey on touch");
|
||||
assert.match(rule[1], /display:\s*none/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("a stopwatch that is only there when it is saying something", () => {
|
||||
// 50px of a 349px line, from the first second of every decision, reading "0:07" in
|
||||
// grey. It is a prompt rather than a readout — nothing happens when it grows — and a
|
||||
// prompt that is always on screen is not a prompt. It arrives when the button that
|
||||
// acts on it arrives, which is the same threshold, and the room goes to the name.
|
||||
const withClock = ({ seat = 0, started = null, unknown = null, clockAt = null }, run) => {
|
||||
const saved = {
|
||||
state: ActionBar.state, tankSeatIndex: ActionBar.tankSeatIndex,
|
||||
tankStartedAt: ActionBar.tankStartedAt, tankUnknownFor: ActionBar.tankUnknownFor,
|
||||
clockSeatIndex: ActionBar.clockSeatIndex, clockStartedAt: ActionBar.clockStartedAt,
|
||||
};
|
||||
ActionBar.state = { actingSeatIndex: seat };
|
||||
ActionBar.tankSeatIndex = seat;
|
||||
ActionBar.tankStartedAt = started;
|
||||
ActionBar.tankUnknownFor = unknown;
|
||||
ActionBar.clockSeatIndex = clockAt === null ? null : seat;
|
||||
ActionBar.clockStartedAt = clockAt;
|
||||
try { run(); } finally { Object.assign(ActionBar, saved); }
|
||||
};
|
||||
|
||||
test("a decision that has only just landed on somebody shows neither", () => {
|
||||
withClock({ started: Date.now() - 3000 }, () =>
|
||||
assert.equal(ActionBar.tankWorthShowing(), false));
|
||||
});
|
||||
|
||||
test("and both arrive together, at the threshold the stopwatch already marked", () => {
|
||||
withClock({ started: Date.now() - (ActionBar.NUDGE_AFTER_S * 1000) }, () =>
|
||||
assert.equal(ActionBar.tankWorthShowing(), true));
|
||||
});
|
||||
|
||||
test("a clock already running is always worth watching", () => {
|
||||
// The readout is a countdown by then, and the button beside it says "Stop the
|
||||
// clock" — there is no second at which either of those is not the point.
|
||||
withClock({ started: Date.now(), clockAt: Date.now() }, () =>
|
||||
assert.equal(ActionBar.tankWorthShowing(), true));
|
||||
});
|
||||
|
||||
test("so is the seat whose wait cannot be known", () => {
|
||||
// The action was already on them when the page loaded. The dash and the sentence
|
||||
// behind it are the answer to "why is there no time on this one", so the answer
|
||||
// has to be visible for the question to get asked.
|
||||
withClock({ started: Date.now(), unknown: 0 }, () =>
|
||||
assert.equal(ActionBar.tankWorthShowing(), true));
|
||||
});
|
||||
|
||||
test("and with nothing to time from at all, nothing is shown", () => {
|
||||
withClock({ started: null }, () =>
|
||||
assert.equal(ActionBar.tankWorthShowing(), false));
|
||||
});
|
||||
|
||||
test("it is built hidden, so a fresh decision never flashes one up", () => {
|
||||
assert.match(CSS_JS, /tank\.hidden = true;/);
|
||||
});
|
||||
|
||||
test("and the tick re-fits the line only when one of them actually moved", () => {
|
||||
// The line is measured by forcing a layout, and this runs once a second all
|
||||
// night. Nothing has changed on the seconds in between.
|
||||
const paint = CSS_JS.slice(CSS_JS.indexOf(" paintTank() {"));
|
||||
const body = paint.slice(0, paint.indexOf("\n },"));
|
||||
assert.match(body, /if \(el\.hidden !== wasTank \|\| \(nudge && nudge\.hidden !== wasNudge\)\) this\.fitActingLine\(\)/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("how much of the bottom is spoken for", () => {
|
||||
// feltRoomFor asks one question — what is under the felt — and gets two different
|
||||
// answers depending on the layout. Under the bar it is the bar. Under the rail it is
|
||||
// the table hint and the two links, because the bar is off to the side.
|
||||
const fakeSection = (wrapBottom, sectionBottom) => {
|
||||
const wrap = {
|
||||
getBoundingClientRect: () => ({ bottom: wrapBottom }),
|
||||
parentElement: { getBoundingClientRect: () => ({ bottom: sectionBottom }) },
|
||||
};
|
||||
global.document = { getElementById: id => (id === "pokerTableWrap" ? wrap : null) };
|
||||
};
|
||||
|
||||
test.afterEach(() => { delete global.document; });
|
||||
|
||||
test("it is the gap under the felt, links and all", () => {
|
||||
fakeSection(300, 342);
|
||||
assert.equal(ActionBar.roomBelowFelt(), 42);
|
||||
});
|
||||
|
||||
test("a difference, not a height — so it does not chase the felt it is sizing", () => {
|
||||
// The wrap's own height comes from --feltRoomPx, which is what this feeds. Read
|
||||
// as "how tall is everything under the felt" off the section's height it would
|
||||
// move every time the felt did, and settle nowhere.
|
||||
fakeSection(300, 342);
|
||||
const first = ActionBar.roomBelowFelt();
|
||||
fakeSection(500, 542); // the felt grew by 200; what is under it did not
|
||||
assert.equal(ActionBar.roomBelowFelt(), first);
|
||||
});
|
||||
|
||||
test("nothing under the felt is nothing to reserve", () => {
|
||||
fakeSection(300, 300);
|
||||
assert.equal(ActionBar.roomBelowFelt(), 0);
|
||||
});
|
||||
|
||||
test("and a section that measures shorter than its own child is not negative room", () => {
|
||||
fakeSection(300, 288);
|
||||
assert.equal(ActionBar.roomBelowFelt(), 0);
|
||||
});
|
||||
|
||||
test("with no table on the page it asks for nothing", () => {
|
||||
global.document = { getElementById: () => null };
|
||||
assert.equal(ActionBar.roomBelowFelt(), 0);
|
||||
});
|
||||
|
||||
test("the felt gets the height the bar used to take", () => {
|
||||
// The whole point, in one line. At 640x360 the bar was 205px of a 360px window
|
||||
// and the felt got 85. Beside the table instead, the same window gives the felt
|
||||
// everything but the header and the links.
|
||||
const asABar = ActionBar.feltRoomFor(360, 205, 70);
|
||||
const asARail = ActionBar.feltRoomFor(360, 30, 70);
|
||||
assert.ok(asARail > asABar * 2, `${asABar}px became ${asARail}px`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("a name that will not fit", () => {
|
||||
// Shortened at a word, not chopped in the middle of one. A puck is 34px of text on
|
||||
// a nine-handed phone, and what the ellipsis produced for the four names on the test
|
||||
// table was "Christo…", "Mike / from…", "Alexan…". Only one of those is throwing
|
||||
// away something the dealer does not need: he is Mike, and the rest of it is how
|
||||
// this table tells him from the other Mike.
|
||||
//
|
||||
// The label element is faked here rather than measured. What is under test is the
|
||||
// ladder — which words go, in what order, and when it stops — and the browser's part
|
||||
// is one boolean per pass.
|
||||
const label = (text, fits) => {
|
||||
const classes = new Set();
|
||||
const el = {
|
||||
textContent: text,
|
||||
title: "",
|
||||
// The step of type a name takes when it has no word left to drop, held as
|
||||
// the same set of strings the real element's classList is.
|
||||
classList: {
|
||||
add: name => classes.add(name),
|
||||
remove: name => classes.delete(name),
|
||||
contains: name => classes.has(name),
|
||||
},
|
||||
// The browser's answer, standing in for a real one: this name fits if
|
||||
// `fits` says the version currently written to it does.
|
||||
get scrollWidth() { return fits(this.textContent) ? 10 : 100; },
|
||||
get clientWidth() { return 10; },
|
||||
get scrollHeight() { return 10; },
|
||||
get clientHeight() { return 10; },
|
||||
querySelector: () => el,
|
||||
};
|
||||
return el;
|
||||
};
|
||||
|
||||
const layer = (labels) => ({
|
||||
children: labels,
|
||||
childElementCount: labels.length,
|
||||
});
|
||||
|
||||
const seatsNamed = (...names) => {
|
||||
Render.state = { seats: names.map(name => ({ name })) };
|
||||
};
|
||||
|
||||
test.afterEach(() => { Render.state = null; });
|
||||
|
||||
test("trailing words go until it fits", () => {
|
||||
const el = label("Christopher John Robinson", text => text === "Christopher John");
|
||||
seatsNamed("Christopher John Robinson");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Christopher John");
|
||||
});
|
||||
|
||||
test("and it will go all the way to the first word", () => {
|
||||
const el = label("Christopher John Robinson", text => text === "Christopher");
|
||||
seatsNamed("Christopher John Robinson");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Christopher");
|
||||
});
|
||||
|
||||
test("a name never ends on a word a name cannot end on", () => {
|
||||
// Dropping "work" off "Mike from work" leaves "Mike from", which is not a
|
||||
// shorter name — it is a name that looks cut off, and it reads worse than the
|
||||
// ellipsis this was meant to replace. He is Mike.
|
||||
const el = label("Mike from work", () => false);
|
||||
seatsNamed("Mike from work");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Mike");
|
||||
});
|
||||
|
||||
test("and it takes a run of them in one go", () => {
|
||||
const el = label("Al with the hat", () => false);
|
||||
seatsNamed("Al with the hat");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Al");
|
||||
});
|
||||
|
||||
test("but a joining word inside a name is nobody's business", () => {
|
||||
// The list is closed and small on purpose: it is not trying to understand names,
|
||||
// only the handful of English joining words people label each other with at a
|
||||
// home game. Anything it doesn't know stays, which is the safe way to be wrong —
|
||||
// a name that keeps a word it could have dropped is merely long.
|
||||
const el = label("Katarzyna Nowak Kowalska", text => text === "Katarzyna Nowak");
|
||||
seatsNamed("Katarzyna Nowak Kowalska");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Katarzyna Nowak");
|
||||
});
|
||||
|
||||
test("dropping never goes below the first word", () => {
|
||||
// Every word after the first being a joining word is not a real name, but it is
|
||||
// typeable, and it must not come out empty.
|
||||
assert.equal(Render.dropAWord(["Mike", "from", "of", "the"], 4), 1);
|
||||
assert.equal(Render.dropAWord(["from", "the"], 2), 1);
|
||||
assert.equal(Render.dropAWord(["Mike"], 1), 1, "one word has nothing to drop");
|
||||
});
|
||||
|
||||
test("a single word steps its type down rather than being cut", () => {
|
||||
// There is no honest way to shorten "Christopher" at a word, and "Christo…" is a
|
||||
// name only until a Christine sits down. The amounts on the same puck have never
|
||||
// taken that trade — fitAmount shrinks a tournament stack rather than rounding it
|
||||
// — and a misread name is the same kind of failure as a misread stack.
|
||||
const el = label("Christopher", () => false);
|
||||
seatsNamed("Christopher");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Christopher", "never cut mid-word here");
|
||||
assert.ok(el.classList.contains("puckNameTight"), "and never left overflowing either");
|
||||
});
|
||||
|
||||
test("a name that fits is not touched", () => {
|
||||
const el = label("Mike from work", () => true);
|
||||
seatsNamed("Mike from work");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Mike from work");
|
||||
assert.ok(!el.classList.contains("puckNameTight"), "nothing to shrink for");
|
||||
});
|
||||
|
||||
test("a name shortened until it fits is not also shrunk", () => {
|
||||
// The step of type is the last resort, not a second helping. "Mike" fits, so it
|
||||
// is read at the size everything else on the felt is.
|
||||
const el = label("Mike from work", text => text === "Mike");
|
||||
seatsNamed("Mike from work");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Mike");
|
||||
assert.ok(!el.classList.contains("puckNameTight"));
|
||||
});
|
||||
|
||||
test("the step comes off again when the puck it was for is gone", () => {
|
||||
// Otherwise it is a ratchet: a name that had to shrink on a nine-handed phone
|
||||
// would still be shrunk on the same table six-handed, or on a desktop, because
|
||||
// nothing ever measured it at the size it was supposed to be read at.
|
||||
const el = label("Christopher", () => false);
|
||||
seatsNamed("Christopher");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.ok(el.classList.contains("puckNameTight"));
|
||||
|
||||
const roomy = label("Christopher", () => true);
|
||||
roomy.classList.add("puckNameTight");
|
||||
Render.fitSeatNames(layer([roomy]));
|
||||
assert.ok(!roomy.classList.contains("puckNameTight"));
|
||||
});
|
||||
|
||||
test("the whole name is on the tooltip whether it was shortened or not", () => {
|
||||
// A label clamped to two lines was already hiding some of it before any of this.
|
||||
const shortened = label("Mike from work", text => text === "Mike");
|
||||
const untouched = label("Bob", () => true);
|
||||
seatsNamed("Mike from work", "Bob");
|
||||
Render.fitSeatNames(layer([shortened, untouched]));
|
||||
assert.equal(shortened.title, "Mike from work");
|
||||
assert.equal(untouched.title, "Bob");
|
||||
});
|
||||
|
||||
test("every seat is written to before any of them is measured", () => {
|
||||
// Three passes over all the seats, not a loop per seat: each pass writes to
|
||||
// every puck and then reads back from every puck, so the browser lays the felt
|
||||
// out three times whatever the table's size. Interleaved, nine seats would cost
|
||||
// nine layouts, and this runs on every render.
|
||||
const reads = [];
|
||||
const watched = (text, fits) => {
|
||||
const el = label(text, fits);
|
||||
Object.defineProperty(el, "scrollWidth", {
|
||||
get() { reads.push(this.textContent); return fits(this.textContent) ? 10 : 100; },
|
||||
});
|
||||
return el;
|
||||
};
|
||||
const a = watched("Christopher John Robinson", t => t === "Christopher");
|
||||
const b = watched("Katarzyna Nowak Kowalska", t => t === "Katarzyna");
|
||||
seatsNamed("Christopher John Robinson", "Katarzyna Nowak Kowalska");
|
||||
Render.fitSeatNames(layer([a, b]));
|
||||
|
||||
// Both names are read at each length before either is written down again.
|
||||
assert.deepEqual(reads.slice(0, 2),
|
||||
["Christopher John Robinson", "Katarzyna Nowak Kowalska"]);
|
||||
assert.deepEqual(reads.slice(2, 4), ["Christopher John", "Katarzyna Nowak"]);
|
||||
assert.equal(a.textContent, "Christopher");
|
||||
assert.equal(b.textContent, "Katarzyna");
|
||||
});
|
||||
|
||||
test("an empty chair has no name to shorten", () => {
|
||||
const el = label("", () => false);
|
||||
seatsNamed("");
|
||||
assert.doesNotThrow(() => Render.fitSeatNames(layer([el])));
|
||||
});
|
||||
|
||||
test("and with no layer there is nothing to do", () => {
|
||||
assert.doesNotThrow(() => Render.fitSeatNames(null));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("pulling the ring back onto the table", () => {
|
||||
// seatRingRadii works out the widest ring whose outermost puck still finishes inside
|
||||
// the rail, and it is right about that — but it is right about a puck it measured
|
||||
// before the ring moved, before the width search settled, and before a name was
|
||||
// shortened or wrapped to a second line. Each of those changes a puck's height by up
|
||||
// to 15px after the arithmetic is done.
|
||||
//
|
||||
// Measured on the running page, four of the thirteen viewports finished with the top
|
||||
// seat 5-6px past the rail. Not a wrong formula: a stale input to a right one. So
|
||||
// the ring is settled the way the audit judges it, by looking at where the pucks
|
||||
// actually landed.
|
||||
//
|
||||
// The felt is 400x400 at (100, 100) throughout. Seats are placed by writing a
|
||||
// percentage into style.left/top, which this reads back to work out where the puck
|
||||
// would be — the same relationship the browser has, without a browser.
|
||||
const felt = { left: 100, top: 100, right: 500, bottom: 500, width: 400, height: 400 };
|
||||
|
||||
const seat = (puckW, puckH, box) => ({
|
||||
style: { left: "", top: "" },
|
||||
classList: { toggle() {} },
|
||||
getBoundingClientRect() {
|
||||
// Whatever percentage was last written, resolved against the ring box.
|
||||
const cx = box.left + (parseFloat(this.style.left) / 100) * box.width;
|
||||
const cy = box.top + (parseFloat(this.style.top) / 100) * box.height;
|
||||
return {
|
||||
left: cx - puckW / 2, right: cx + puckW / 2,
|
||||
top: cy - puckH / 2, bottom: cy + puckH / 2,
|
||||
width: puckW, height: puckH,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// The ring box the percentages resolve against, deliberately the same as the felt so
|
||||
// the arithmetic in the test stays readable.
|
||||
const ringBox = { ...felt };
|
||||
|
||||
const setUp = (count, puckW, puckH) => {
|
||||
const seats = Array.from({ length: count }, () => seat(puckW, puckH, ringBox));
|
||||
Render.state = { seats: seats.map(() => ({ name: "" })), seatCount: count };
|
||||
global.document = { getElementById: id => (id === "pokerTable"
|
||||
? { getBoundingClientRect: () => felt }
|
||||
: { getBoundingClientRect: () => ringBox }) };
|
||||
// ringBox() reads #seatsLayer; the fake above hands the same box back for it.
|
||||
return { children: seats, childElementCount: seats.length };
|
||||
};
|
||||
|
||||
const place = (layer, radii) => {
|
||||
for (let i = 0; i < layer.childElementCount; i++) {
|
||||
Render.placeSeat(layer.children[i], i, 1, radii);
|
||||
}
|
||||
};
|
||||
|
||||
const worstOverhang = layer => {
|
||||
let over = 0;
|
||||
for (let i = 0; i < layer.childElementCount; i++) {
|
||||
const b = layer.children[i].getBoundingClientRect();
|
||||
over = Math.max(over, felt.left - b.left, b.right - felt.right,
|
||||
felt.top - b.top, b.bottom - felt.bottom);
|
||||
}
|
||||
return over;
|
||||
};
|
||||
|
||||
test.afterEach(() => { Render.state = null; delete global.document; });
|
||||
|
||||
test("a ring already inside the rail is left exactly where it is", () => {
|
||||
const layer = setUp(6, 60, 50);
|
||||
const radii = { rx: 30, ry: 30 };
|
||||
place(layer, radii);
|
||||
assert.deepEqual(Render.settleSeatsOnTable(layer, 1, radii), radii);
|
||||
});
|
||||
|
||||
test("a puck past the rail brings the whole ring in", () => {
|
||||
// 44% of 400 puts a seat's centre 176px from the middle; half a 60px puck is
|
||||
// another 30, against a felt whose half-height is 200. Six pixels over, at the
|
||||
// top and the bottom and both sides — which is the size the running page was
|
||||
// actually wrong by.
|
||||
const layer = setUp(4, 60, 60);
|
||||
const radii = { rx: 44, ry: 44 };
|
||||
place(layer, radii);
|
||||
assert.equal(Math.round(worstOverhang(layer)), 6, "the fault is there to fix");
|
||||
|
||||
const settled = Render.settleSeatsOnTable(layer, 1, radii);
|
||||
assert.ok(settled.ry < radii.ry, "the ring came in");
|
||||
assert.ok(worstOverhang(layer) <= Render.SEAT_SETTLE_SLACK_PX,
|
||||
`still ${worstOverhang(layer).toFixed(1)}px over`);
|
||||
});
|
||||
|
||||
test("it comes in by what it was over, not by a step it guessed", () => {
|
||||
const layer = setUp(4, 60, 60);
|
||||
const radii = { rx: 44, ry: 44 };
|
||||
place(layer, radii);
|
||||
const over = worstOverhang(layer);
|
||||
const settled = Render.settleSeatsOnTable(layer, 1, radii);
|
||||
// Six pixels of a 400px felt is 1.5% of the ring, so 44 becomes 42.5 — the
|
||||
// measurement, not a step size that would have to be tuned per screen.
|
||||
assert.equal(settled.ry.toFixed(2), (44 - (over / felt.height) * 100).toFixed(2));
|
||||
assert.equal(settled.ry, 42.5);
|
||||
});
|
||||
|
||||
test("and it stops at the floor rather than collapsing into the pot", () => {
|
||||
// A puck taller than the felt has an overhang no ring can fix. Pulling in
|
||||
// forever would put every seat on top of the pot badge, which is a worse table
|
||||
// than one with a puck touching the rail.
|
||||
const layer = setUp(4, 60, 600);
|
||||
const radii = { rx: 40, ry: 40 };
|
||||
place(layer, radii);
|
||||
const settled = Render.settleSeatsOnTable(layer, 1, radii);
|
||||
assert.equal(settled.ry, Render.RING_MIN_R);
|
||||
assert.ok(settled.ry > 0);
|
||||
});
|
||||
|
||||
test("a table with nothing laid out yet is handed back untouched", () => {
|
||||
Render.state = { seats: [], seatCount: 0 };
|
||||
global.document = { getElementById: () => null };
|
||||
const radii = { rx: 44, ry: 42 };
|
||||
assert.deepEqual(Render.settleSeatsOnTable({ children: [], childElementCount: 0 }, 1, radii), radii);
|
||||
});
|
||||
|
||||
test("it is bounded — this runs after every tap a dealer makes", () => {
|
||||
assert.ok(Render.SEAT_SETTLE_PASSES <= 2,
|
||||
"each pass is a forced layout of the whole felt");
|
||||
assert.ok(Render.SEAT_NAME_PASSES <= 3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
// The sheet a dealer opens mid-argument.
|
||||
//
|
||||
// It held twelve rulings with all their prose showing — 2,480px of it on a 390px
|
||||
// phone, of which 1,754 was below the fold — and a row of twelve chips across the top
|
||||
// to jump between them. The chips were an index of the twelve headings printed directly
|
||||
// above the same twelve headings: the same list twice, 172px of navigation before the
|
||||
// first word, and the dealer who used one landed in the middle of a scroll with eleven
|
||||
// rulings they hadn't asked for still wrapped around them.
|
||||
//
|
||||
// The headings are the index now. NN/g's rule for this shape — a longer list of
|
||||
// independent topics somebody opens one of — is an accordion, shut by default, and
|
||||
// half of these headings answer the question without being opened at all: "A short
|
||||
// all-in doesn't reopen the betting" is the ruling, not the topic. Measured after:
|
||||
// 29px below the fold on an iPhone with all twelve rows on screen at once, 51px on a
|
||||
// desktop. Opening the longest of them leaves 244px.
|
||||
//
|
||||
// These are layout and markup decisions, so they are checked as text — except the one
|
||||
// about what the sheet does when it is opened again, which is behaviour and lives in
|
||||
// main.js.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
const MAIN = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "main.js"), "utf8");
|
||||
|
||||
const sheet = () => {
|
||||
const at = PAGE.indexOf('<div id="rulingsOverlay"');
|
||||
assert.ok(at >= 0, "there is no rulings overlay");
|
||||
return PAGE.slice(at, PAGE.indexOf('<div id="seatMenuOverlay"', at));
|
||||
};
|
||||
|
||||
const ruleFor = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
|
||||
return CSS.slice(at, CSS.indexOf("}", at));
|
||||
};
|
||||
|
||||
// Every @media block written at exactly this condition, concatenated — the same helper
|
||||
// theHeader.test.js uses, and for the same reason: this condition is opened more than
|
||||
// once in the file, and a helper that stops at the first finds a block with none of
|
||||
// these rules in it and reports them missing.
|
||||
const mediaBlock = condition => {
|
||||
const found = [];
|
||||
const needle = "@media ";
|
||||
for (let at = CSS.indexOf(needle); at >= 0; at = CSS.indexOf(needle, at + 1)) {
|
||||
const open = CSS.indexOf("{", at);
|
||||
if (CSS.slice(at + needle.length, open).trim() !== condition) continue;
|
||||
let depth = 0;
|
||||
for (let i = open; i < CSS.length; i++) {
|
||||
if (CSS[i] === "{") depth++;
|
||||
else if (CSS[i] === "}" && --depth === 0) { found.push(CSS.slice(open + 1, i)); break; }
|
||||
}
|
||||
}
|
||||
assert.ok(found.length > 0, `no @media block written as "${condition}"`);
|
||||
return found.join("\n");
|
||||
};
|
||||
|
||||
const ruleIn = (block, selector) => {
|
||||
const re = new RegExp(`(^|[},])\\s*${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`, "m");
|
||||
const found = block.match(re);
|
||||
assert.ok(found, `there is no \`${selector}\` rule in that block`);
|
||||
return found[2];
|
||||
};
|
||||
|
||||
const SIDEWAYS = () => mediaBlock("(orientation: landscape) and (max-height: 560px)");
|
||||
|
||||
// Each ruling as its own slice of markup.
|
||||
const rulings = () => {
|
||||
const parts = sheet().split('<details class="ruling"');
|
||||
return parts.slice(1).map(p => p.slice(0, p.indexOf("</details>")));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("twelve rulings, shut", () => {
|
||||
test("every one of them is a disclosure, and there are still twelve", () => {
|
||||
assert.equal(rulings().length, 12);
|
||||
});
|
||||
|
||||
// NN/g is explicit that opening the first one by default costs the reader the
|
||||
// glance at the whole list, which on this sheet is the entire point.
|
||||
test("none of them is open in the markup", () => {
|
||||
assert.ok(!/<details class="ruling"[^>]*\bopen\b/.test(sheet()),
|
||||
"a ruling starts open, so the sheet no longer opens on its own contents");
|
||||
});
|
||||
|
||||
// Native exclusive accordions close the open one when another is opened, which
|
||||
// shifts everything below it under the finger that is reaching. That is the
|
||||
// disorientation the accordion is here to remove, not add.
|
||||
test("opening one does not close another", () => {
|
||||
assert.ok(!/<details class="ruling"[^>]*\bname=/.test(sheet()),
|
||||
"the rulings are an exclusive accordion, so content moves when one opens");
|
||||
});
|
||||
|
||||
test("the name of a ruling is the control that opens it", () => {
|
||||
rulings().forEach((r, i) => {
|
||||
assert.match(r, /<summary><h3>.+<\/h3><\/summary>/,
|
||||
`ruling ${i + 1} does not carry its heading in its summary`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the index that was above them is gone, not merely hidden", () => {
|
||||
test("nothing builds a chip row any more", () => {
|
||||
assert.ok(!/rulingsIndex/.test(PAGE), "the index nav is still in the markup");
|
||||
assert.ok(!/buildRulingsIndex|rulingsIndexBtn/.test(MAIN), "main.js still builds the index");
|
||||
assert.ok(!/rulingsIndexBtn/.test(CSS), "the index chips still have styles");
|
||||
});
|
||||
|
||||
// The short labels existed only to fit three chips on a line. With the full
|
||||
// headings doing the work there is nothing left to read them.
|
||||
test("and the short labels it read are gone with it", () => {
|
||||
assert.ok(!/data-short=/.test(PAGE));
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a summary has to be to work", () => {
|
||||
// A <summary> is announced as a button and not as a heading, so without this the
|
||||
// twelve names — the whole of this sheet's navigation — are invisible to anybody
|
||||
// moving through it by heading. Same fix as the sidebar's panel names.
|
||||
test("the heading survives being a control", () => {
|
||||
assert.match(ruleFor(".ruling > summary > h3"), /display:\s*inline/);
|
||||
assert.match(ruleFor(".ruling > summary > h3"), /font:\s*inherit/);
|
||||
});
|
||||
|
||||
// A <summary> is content-box, so a min-height is *added* to its padding — the trap
|
||||
// the sidebar's headers were in, one stylesheet section over.
|
||||
test("a thumb gets its 44px, counted the way the box actually counts", () => {
|
||||
const rule = ruleFor(".ruling > summary");
|
||||
assert.match(rule, /box-sizing:\s*border-box/);
|
||||
assert.match(rule, /min-height:\s*44px/);
|
||||
});
|
||||
|
||||
test("the accent belongs to the ruling being read, not to all twelve", () => {
|
||||
assert.match(ruleFor(".ruling > summary"), /color:\s*var\(--text\)/);
|
||||
assert.match(ruleFor(".ruling[open] > summary"), /color:\s*var\(--gold\)/);
|
||||
});
|
||||
|
||||
test("and so does the gold bar down the side", () => {
|
||||
assert.match(ruleFor(".ruling[open]"), /border-left:\s*2px solid/);
|
||||
assert.ok(!/border-left/.test(ruleFor(".ruling")),
|
||||
"every ruling wears the open one's marker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading one of them", () => {
|
||||
// Five of the twelve are two paragraphs and they ran into each other: the sections
|
||||
// were spaced from one another, but nothing separated the halves of one ruling.
|
||||
test("two paragraphs of a ruling are two paragraphs", () => {
|
||||
assert.match(ruleFor(".ruling p + p"), /margin-top:\s*var\(--sp-\d\)/);
|
||||
});
|
||||
|
||||
// <details> keeps its state, so without this a dealer who read the odd-chip rule an
|
||||
// hour ago finds it still hanging open under the one they came back for.
|
||||
test("the sheet opens shut, however it was left", () => {
|
||||
const at = MAIN.indexOf("const openRulings");
|
||||
assert.ok(at >= 0, "nothing owns what the sheet looks like when it opens");
|
||||
const body = MAIN.slice(at, MAIN.indexOf("};", at));
|
||||
assert.match(body, /\.ruling\[open\]/);
|
||||
assert.match(body, /open = false/);
|
||||
});
|
||||
|
||||
test("and that is what the button calls", () => {
|
||||
assert.match(MAIN, /getElementById\("openRulingsBtn"\)\.addEventListener\("click", openRulings\)/);
|
||||
});
|
||||
});
|
||||
|
||||
// The dealer who has read the answer has to be able to leave without scrolling back
|
||||
// past it. Measured before this: on a 360x640 phone the twelve shut rulings are 791px of
|
||||
// a 549px sheet, so at the bottom of that scroll Done is 221px above the sheet's top
|
||||
// edge; on a 390x844 phone with one ruling opened — the only reason anybody is in here —
|
||||
// it is 262px. The seat menu's sheet had already been given the fix and this one, the
|
||||
// longer of the two, had not.
|
||||
describe("the way out does not scroll away", () => {
|
||||
test("the header is stuck to the top of the sheet's own scrollport", () => {
|
||||
const rule = ruleFor(".rulingsHeader");
|
||||
assert.match(rule, /position:\s*sticky/);
|
||||
assert.match(rule, /top:\s*0/);
|
||||
// Out to the sheet's edges and back, or the list shows through either side of it.
|
||||
assert.match(rule, /margin:\s*0 calc\(var\(--sp-4\) \* -1\)/);
|
||||
assert.match(rule, /background:\s*var\(--surface-2\)/);
|
||||
});
|
||||
|
||||
test("and nothing is left above it for the list to scroll out through", () => {
|
||||
assert.match(ruleFor("#rulingsSheet"), /padding:\s*0 var\(--sp-4\)/,
|
||||
"the sheet keeps a top padding, which is a gap above a sticky header");
|
||||
// The grip was the one thing up there. It belongs inside the header now.
|
||||
const header = sheet().match(/<div class="rulingsHeader">[\s\S]*?<\/div>\s*<\/div>/);
|
||||
assert.ok(header, "the header is not in the markup where it was");
|
||||
assert.match(header[0], /seatSheetGrip/,
|
||||
"the grip is still outside the header, so it scrolls away above it");
|
||||
assert.ok(ruleFor(".rulingsHeader .seatSheetGrip"),
|
||||
"the grip has no rule placing it inside a two-column header");
|
||||
});
|
||||
|
||||
test("the scroll inside the sheet stays inside it", () => {
|
||||
assert.match(ruleFor("#rulingsSheet"), /overscroll-behavior:\s*contain/);
|
||||
});
|
||||
});
|
||||
|
||||
// A phone held sideways is 844x390: no height and swimming in width. Measured before
|
||||
// this, the bottom sheet was 460px wide in the middle of that window — 384px of it
|
||||
// unused either side — holding 754px of rulings in a 334px scrollport, 111px of which
|
||||
// went on a title and a caveat that stand still. Four of the twelve names on screen.
|
||||
//
|
||||
// Material's rule for a bottom sheet in landscape is to stop being one, and this tool
|
||||
// already does exactly that under this query for the action bar. So the sheet takes the
|
||||
// window and the width pays for the height: two columns of six, all twelve names on
|
||||
// screen at 844x390 with nothing to scroll at all.
|
||||
describe("a phone on its side gets the whole window", () => {
|
||||
test("the twelve are six and six in the markup", () => {
|
||||
const columns = sheet().split('<div class="rulingsColumn">').slice(1);
|
||||
assert.equal(columns.length, 2, "there are not exactly two columns to stand side by side");
|
||||
columns.forEach((c, i) => {
|
||||
const count = (c.slice(0, c.indexOf("\n </div>")).match(/<details class="ruling"/g) || []).length;
|
||||
assert.equal(count, 6, `column ${i + 1} holds ${count} rulings rather than six`);
|
||||
});
|
||||
});
|
||||
|
||||
test("but everywhere else they are still one column", () => {
|
||||
// `display: contents` hands the rulings straight to the list, so every layout
|
||||
// that isn't landscape sees exactly the markup it saw before the wrappers.
|
||||
assert.match(ruleFor(".rulingsColumn"), /display:\s*contents/);
|
||||
assert.match(ruleIn(SIDEWAYS(), ".rulingsColumn"), /display:\s*block/);
|
||||
});
|
||||
|
||||
test("sideways the sheet stops being a sheet and becomes the screen", () => {
|
||||
const block = SIDEWAYS();
|
||||
assert.match(ruleIn(block, "#rulingsOverlay"), /align-items:\s*stretch/,
|
||||
"the sheet is still anchored to the bottom edge, so it is still short");
|
||||
const sheetRule = ruleIn(block, "#rulingsSheet");
|
||||
assert.match(sheetRule, /height:\s*100%/);
|
||||
assert.match(sheetRule, /max-height:\s*none/);
|
||||
// Capped rather than unbounded: this query also matches a short desktop window,
|
||||
// and two columns of a 1400px one would be 680px lines of body text.
|
||||
assert.match(sheetRule, /max-width:\s*62rem/);
|
||||
assert.match(sheetRule, /overflow:\s*hidden/,
|
||||
"the sheet scrolls as well as the list, which is two scrollbars for one list");
|
||||
});
|
||||
|
||||
test("the title, the caveat and the way out share one line", () => {
|
||||
// That line is the whole of the 111px this screen was spending on them, and the
|
||||
// sticky positioning goes with the box being dropped: nothing above the list
|
||||
// scrolls here, so nothing has to stick.
|
||||
const block = SIDEWAYS();
|
||||
assert.match(ruleIn(block, ".rulingsHeader"), /display:\s*contents/);
|
||||
assert.match(ruleIn(block, ".rulingsHeader .seatSheetGrip"), /display:\s*none/,
|
||||
"a grab handle on a sheet that is not a sheet any more");
|
||||
assert.match(ruleIn(block, "#rulingsSheet"), /grid-template-areas:\s*\n?\s*"title intro done"/);
|
||||
});
|
||||
|
||||
test("the two columns are two flows, not one flow wrapped", () => {
|
||||
// A grid with twelve children of its own pairs them into six shared rows, and
|
||||
// opening the third ruling on the left pushes the ninth down the right-hand side
|
||||
// to keep the row square. Two wrappers means a column only ever moves itself.
|
||||
const rule = ruleIn(SIDEWAYS(), ".rulingsList");
|
||||
assert.match(rule, /grid-template-columns:\s*1fr 1fr/);
|
||||
assert.match(rule, /overflow-y:\s*auto/);
|
||||
assert.match(rule, /min-height:\s*0/,
|
||||
"a grid item's floor is its content, so the list would push the sheet open");
|
||||
});
|
||||
|
||||
test("and the gutter is reserved before it is needed", () => {
|
||||
// It arrives the moment a ruling is opened. Without the reservation it takes 8px
|
||||
// off the tracks and slides the right-hand column sideways under the eye that is
|
||||
// reading it.
|
||||
assert.match(ruleIn(SIDEWAYS(), ".rulingsList"), /scrollbar-gutter:\s*stable/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
// What a seat is doing, asked once.
|
||||
//
|
||||
// The seat editor had two checkboxes, "In hand" and "Fold". A seat is really in one of
|
||||
// three states — sitting out, in the hand, folded — and "folded" means nothing while
|
||||
// the seat is out, so two boxes offered a fourth corner that has no meaning. The code
|
||||
// that stopped a dealer reaching it `disabled` the Fold box whenever In hand was clear.
|
||||
//
|
||||
// Measured: that left the label at opacity 1 and colour rgb(170,170,170) — identical to
|
||||
// a live one — with a `title` of "Folded" on it. So pressing Fold on a seat that was
|
||||
// sitting out gave no movement and no reason, which is the exact failure round 16 went
|
||||
// through the whole tool to remove. It hid from the sweep rule that finds those because
|
||||
// the title sat on the <label> rather than on the control the rule inspects.
|
||||
//
|
||||
// Three named states cannot contradict each other and none of them is ever unavailable,
|
||||
// so there is nothing left to dim.
|
||||
//
|
||||
// The second half of this round is where that form appears at all. The phone block's
|
||||
// own comment has always said why the editor becomes a bottom sheet — "the seat editor
|
||||
// lives far enough down the page that tapping a seat looks like it did nothing" — but
|
||||
// the sheet was scoped to 700px while #holdemLayout goes to one column at 900. The two
|
||||
// tiers in between had precisely the fault the sheet was written to fix. Measured,
|
||||
// tapping a seat:
|
||||
//
|
||||
// 844x390 the editor drew in the flow at y=369 in a 390px window: 21px of 401 on
|
||||
// screen, 5% of it, at scrollY 0 with nothing scrolling it into view.
|
||||
// 768x1024 31% visible, the rest below the fold, and again no scroll.
|
||||
//
|
||||
// Afterwards every viewport from 360x640 to 900x500 shows 97-99% of the sheet, and
|
||||
// above 900 the editor is a static block at the top of a sidebar that is beside the
|
||||
// table rather than below it — 100% visible there too.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Render, makeTable } = require("./load");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
const seatForm = () => {
|
||||
const at = PAGE.indexOf('<template id="seatEditorTemplate">');
|
||||
assert.ok(at >= 0, "there is no seat editor template");
|
||||
return PAGE.slice(at, PAGE.indexOf("</template>", at));
|
||||
};
|
||||
|
||||
const ruleFor = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
|
||||
return CSS.slice(at, CSS.indexOf("}", at));
|
||||
};
|
||||
|
||||
// Everything inside every block with this query, braces balanced and joined. Every one
|
||||
// of them: this stylesheet opens `@media (hover: none)` twice, and a helper that stops
|
||||
// at the first has twice caught a test asserting about the wrong half of the file.
|
||||
function blockAt(query) {
|
||||
const found = [];
|
||||
for (let at = CSS.indexOf(query); at >= 0; at = CSS.indexOf(query, at + 1)) {
|
||||
let depth = 0;
|
||||
for (let i = at + query.length - 1; i < CSS.length; i++) {
|
||||
if (CSS[i] === "{") depth++;
|
||||
else if (CSS[i] === "}" && --depth === 0) { found.push(CSS.slice(at, i + 1)); break; }
|
||||
}
|
||||
}
|
||||
assert.ok(found.length, `there is no \`${query}\` block`);
|
||||
return found.join("\n");
|
||||
}
|
||||
|
||||
// The three buttons, and enough of a form to hand them back.
|
||||
function fakeForm() {
|
||||
const options = Render.SEAT_STATES.map(state => ({
|
||||
dataset: { seatState: state.key },
|
||||
attrs: {},
|
||||
tabIndex: 0,
|
||||
handlers: {},
|
||||
setAttribute(name, value) { this.attrs[name] = value; },
|
||||
getAttribute(name) { return this.attrs[name]; },
|
||||
addEventListener(type, fn) { this.handlers[type] = fn; },
|
||||
focus() { options.focused = this.dataset.seatState; },
|
||||
}));
|
||||
return {
|
||||
options,
|
||||
querySelectorAll: selector => {
|
||||
assert.equal(selector, ".seatStateOption");
|
||||
return options;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const checked = form => form.options.filter(o => o.getAttribute("aria-checked") === "true")
|
||||
.map(o => o.dataset.seatState);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("three states, not two booleans", () => {
|
||||
test("the markup asks one question with three answers", () => {
|
||||
const form = seatForm();
|
||||
const at = form.indexOf('class="seatState"');
|
||||
assert.ok(at >= 0, "the seat form has no state control");
|
||||
const group = form.slice(form.lastIndexOf("<div", at), form.indexOf("</div>", at));
|
||||
assert.match(group, /role="radiogroup"/);
|
||||
assert.match(group, /aria-label="This seat"/);
|
||||
["out", "in", "folded"].forEach(key => {
|
||||
assert.match(group, new RegExp(`data-seat-state="${key}"`), `there is no "${key}" answer`);
|
||||
});
|
||||
assert.equal((group.match(/role="radio"/g) || []).length, 3);
|
||||
});
|
||||
|
||||
test("and the two checkboxes are gone from the page and the stylesheet", () => {
|
||||
["inHandCheckbox", "foldCheckbox", "inHandToggle", "foldToggle", "seatCardToggles"]
|
||||
.forEach(name => {
|
||||
assert.ok(!PAGE.includes(name), `${name} is still in the markup`);
|
||||
assert.ok(!CSS.includes(name), `${name} is still in the stylesheet`);
|
||||
});
|
||||
});
|
||||
|
||||
// The pair (out, folded) is the one the old control could be asked for.
|
||||
test("every state names both halves, and none of them is the meaningless pair", () => {
|
||||
assert.deepEqual(Render.SEAT_STATES.map(s => s.key), ["out", "in", "folded"]);
|
||||
Render.SEAT_STATES.forEach(state => {
|
||||
assert.equal(typeof state.inHand, "boolean");
|
||||
assert.equal(typeof state.folded, "boolean");
|
||||
assert.ok(!(state.folded && !state.inHand),
|
||||
`"${state.key}" is folded and not in the hand, which means nothing`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and a seat reads back as the state it was put in", () => {
|
||||
Render.SEAT_STATES.forEach(state => {
|
||||
assert.equal(Render.seatStateOf({ inHand: state.inHand, folded: state.folded }), state.key);
|
||||
});
|
||||
// Including a seat saved before any of this, where folded was left set on a
|
||||
// chair that had sat out.
|
||||
assert.equal(Render.seatStateOf({ inHand: false, folded: true }), "out");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the control draws", () => {
|
||||
test("exactly one answer is marked, and it is the seat's", () => {
|
||||
const form = fakeForm();
|
||||
Render.paintSeatState(form, { inHand: true, folded: true });
|
||||
assert.deepEqual(checked(form), ["folded"]);
|
||||
Render.paintSeatState(form, { inHand: false, folded: false });
|
||||
assert.deepEqual(checked(form), ["out"]);
|
||||
Render.paintSeatState(form, { inHand: true, folded: false });
|
||||
assert.deepEqual(checked(form), ["in"]);
|
||||
});
|
||||
|
||||
// One stop for the group, arrows inside it — the APG's radio group, and the same
|
||||
// shape the card picker's suits use.
|
||||
test("and exactly one of the three is a Tab stop", () => {
|
||||
const form = fakeForm();
|
||||
Render.paintSeatState(form, { inHand: true, folded: false });
|
||||
assert.deepEqual(form.options.map(o => o.tabIndex), [-1, 0, -1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what pressing one does", () => {
|
||||
const wired = player => {
|
||||
const form = fakeForm();
|
||||
const commits = [];
|
||||
const realCommit = Render.commitSeatChange;
|
||||
Render.commitSeatChange = () => commits.push(true);
|
||||
try {
|
||||
Render.wireSeatState(form, player, 0);
|
||||
} finally {
|
||||
Render.commitSeatChange = realCommit;
|
||||
}
|
||||
return { form, commits, press: key => {
|
||||
const btn = form.options.find(o => o.dataset.seatState === key);
|
||||
const again = Render.commitSeatChange;
|
||||
Render.commitSeatChange = () => commits.push(true);
|
||||
try { btn.handlers.click(); } finally { Render.commitSeatChange = again; }
|
||||
} };
|
||||
};
|
||||
|
||||
test("it writes both halves of the seat, not one", () => {
|
||||
const player = { inHand: true, folded: false };
|
||||
const { press } = wired(player);
|
||||
press("folded");
|
||||
assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: true, folded: true });
|
||||
press("out");
|
||||
assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: false, folded: false });
|
||||
press("in");
|
||||
assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: true, folded: false });
|
||||
});
|
||||
|
||||
test("and says so, so the table redraws", () => {
|
||||
const player = { inHand: true, folded: false };
|
||||
const { commits, press } = wired(player);
|
||||
const before = commits.length;
|
||||
press("out");
|
||||
assert.ok(commits.length > before, "nothing was committed, so the felt still shows the old state");
|
||||
});
|
||||
|
||||
// Typing a name into an empty chair seats somebody. The control has to say so —
|
||||
// it is the only thing on the form that shows it.
|
||||
test("naming an empty chair repaints the control rather than leaving it stale", () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(ROOT, "wwwroot", "js", "casino", "holdem", "render.js"), "utf8");
|
||||
const at = src.indexOf("const wasEmpty = !player.name.trim()");
|
||||
assert.ok(at >= 0);
|
||||
assert.match(src.slice(at, at + 400), /this\.paintSeatState\(form, player\);/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("how it is drawn", () => {
|
||||
// The answer moves between the three as the hand goes on, and a segment that
|
||||
// resizes under the thumb reads as the control flinching.
|
||||
test("three equal columns, whatever the words in them", () => {
|
||||
assert.match(ruleFor(".seatState"), /grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\)/);
|
||||
});
|
||||
|
||||
test("the chosen one is the one that looks pressed", () => {
|
||||
assert.match(ruleFor('.seatStateOption[aria-checked="true"]'), /background:\s*var\(--surface-3\)/);
|
||||
assert.match(ruleFor('.seatStateOption[aria-checked="true"]'), /color:\s*var\(--text-bright\)/);
|
||||
});
|
||||
|
||||
// The same red the seat menu's own Fold wears, and only when it is the answer: a red
|
||||
// segment sitting unpressed beside two grey ones reads as a warning about the seat.
|
||||
test("folded is red, and only once it is chosen", () => {
|
||||
const rule = ruleFor('.seatStateOption[data-seat-state="folded"][aria-checked="true"]');
|
||||
assert.match(rule, /#3d1f1f/);
|
||||
assert.ok(!/\n\.seatStateOption\[data-seat-state="folded"\] \{/.test(CSS),
|
||||
"an unchosen Folded segment is painted red");
|
||||
});
|
||||
|
||||
test("and a thumb gets its 44px wherever there is no pointer", () => {
|
||||
assert.match(blockAt("@media (hover: none) {"), /\.seatStateOption \{\s*min-height: 44px;/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("where the form appears at all", () => {
|
||||
// The sheet exists because a stacked sidebar puts the editor far below the felt.
|
||||
// That is true at every width where the layout is one column.
|
||||
test("the sheet is scoped to the breakpoint that stacks the layout", () => {
|
||||
const oneColumn = blockAt("@media (max-width: 900px) {");
|
||||
assert.match(oneColumn, /#holdemLayout \{\s*grid-template-columns: 1fr;/,
|
||||
"900px is no longer where the sidebar stops being a column beside the table");
|
||||
const sheet = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"));
|
||||
const query = sheet.slice(sheet.indexOf("@media"), sheet.indexOf("{", sheet.indexOf("@media")) + 1);
|
||||
assert.equal(query.trim(), "@media (max-width: 900px) {");
|
||||
});
|
||||
|
||||
test("and it is a sheet, fixed to the bottom, not a block in the flow", () => {
|
||||
const sheet = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"));
|
||||
const at = sheet.indexOf(" #seatEditorPanel {");
|
||||
assert.ok(at >= 0, "the sheet block no longer positions the panel");
|
||||
const rule = sheet.slice(at, sheet.indexOf("}", at));
|
||||
assert.match(rule, /position:\s*fixed/);
|
||||
assert.match(rule, /bottom:\s*0/);
|
||||
assert.match(rule, /max-height:\s*74vh/);
|
||||
});
|
||||
|
||||
test("what was measured is written beside the query", () => {
|
||||
const note = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"),
|
||||
CSS.indexOf("@media (max-width: 900px) {", CSS.indexOf("---- Seat editor as a bottom sheet ----")));
|
||||
assert.match(note, /844x390/);
|
||||
assert.match(note, /768x1024/);
|
||||
assert.match(note, /5% of it/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
// The seat menu on a phone held sideways.
|
||||
//
|
||||
// The tool learned two rounds ago that a landscape phone is short of height and
|
||||
// swimming in width: the action bar stops being a bar across the bottom and becomes a
|
||||
// rail down the right. The sheets were never told. Measured on the fullest menu the
|
||||
// tool can build — twelve seated, somebody on the waiting list, blinds still the only
|
||||
// money in:
|
||||
//
|
||||
// 640x360 545px of content in a 306px sheet. Cash out…, Move to…, Edit seat…,
|
||||
// Small blind, Big blind and Cancel all below the fold, Cancel by 224px.
|
||||
// 667x375 545 in 319, Cancel 211px under.
|
||||
// 844x390 529 in 332, Cancel 183px under.
|
||||
//
|
||||
// And on all three the sheet was 420px wide inside a window 640 to 844 wide. Two
|
||||
// hundred and forty-seven pixels of width held empty on the one screen shape whose
|
||||
// whole problem is height, while the dealer scrolled a menu to find the way out of it.
|
||||
//
|
||||
// The two tiers this menu has been sorted into since round 7 — what you press with
|
||||
// cards in the air, and housekeeping between hands — stop being the top and bottom of
|
||||
// a column and become the right and left of a row. Fold and All-in land under the same
|
||||
// thumb the rail puts Fold, Call and Raise under, with Cancel beneath them. Measured
|
||||
// after: 297px in a 298px sheet at 640x360 and at 667x375, 281 in 282 at 844x390.
|
||||
// Nothing below the fold on any of them, and the move picker's twelve chairs and its
|
||||
// Back went from four rows and a scroll to three rows and none.
|
||||
//
|
||||
// Upright nothing moved at all: 545px before, 545px after, the same two columns.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Betting, HoldemState, SeatMenu, makeTable } = require("./load");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
const ruleFor = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
|
||||
return CSS.slice(at, CSS.indexOf("}", at));
|
||||
};
|
||||
|
||||
// Everything inside one @media block, braces balanced, so a test asks what that block
|
||||
// says rather than whether a string appears anywhere in six thousand lines.
|
||||
const SHORT = "@media (max-height: 560px) and (min-width: 600px) {";
|
||||
function shortViewportBlock() {
|
||||
const at = CSS.indexOf(SHORT);
|
||||
assert.ok(at >= 0, "there is no short-viewport block for the sheets");
|
||||
let depth = 0;
|
||||
for (let i = at + SHORT.length - 1; i < CSS.length; i++) {
|
||||
if (CSS[i] === "{") depth++;
|
||||
else if (CSS[i] === "}" && --depth === 0) return CSS.slice(at, i + 1);
|
||||
}
|
||||
assert.fail("the short-viewport block is never closed");
|
||||
}
|
||||
|
||||
// A document small enough to answer what paint() asks of one. It builds elements,
|
||||
// appends them and reads their labels back; nothing here lays anything out, which is
|
||||
// the stylesheet's half of the job and is checked as text below.
|
||||
function fakeDom() {
|
||||
const element = tag => ({
|
||||
tag,
|
||||
className: "",
|
||||
type: "",
|
||||
textContent: "",
|
||||
children: [],
|
||||
appendChild(child) { this.children.push(child); return child; },
|
||||
addEventListener() {},
|
||||
setAttribute() {},
|
||||
focus() {},
|
||||
get childElementCount() { return this.children.length; },
|
||||
set innerHTML(value) { assert.equal(value, "", "only clearing is modelled"); this.children = []; },
|
||||
get innerHTML() { return ""; },
|
||||
});
|
||||
const byId = {};
|
||||
["seatMenuTitle", "seatMenuSubtitle", "seatMenuActions", "seatMenuCancelBtn",
|
||||
"seatMenuOverlay"].forEach(id => { byId[id] = element("div"); });
|
||||
return {
|
||||
byId,
|
||||
doc: {
|
||||
createElement: element,
|
||||
getElementById: id => byId[id] || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withDocument(doc, body) {
|
||||
const had = Object.prototype.hasOwnProperty.call(globalThis, "document");
|
||||
const before = globalThis.document;
|
||||
globalThis.document = doc;
|
||||
try { return body(); } finally {
|
||||
if (had) globalThis.document = before; else delete globalThis.document;
|
||||
}
|
||||
}
|
||||
|
||||
// The fullest menu the tool can build, aimed at a live seat that isn't the dealer with
|
||||
// the blinds still the only money in and an override already set.
|
||||
function painted(seatIndex = 3, { move = false, seated = [0, 1, 2, 3, 4], waiting = true } = {}) {
|
||||
const state = makeTable({ seatCount: 9, seated, stack: 500, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
state.blindOverride = { sbIndex: 1, bbIndex: 2 };
|
||||
if (waiting) state.waitingList = [{ id: "w1", name: "Nina" }];
|
||||
const { doc, byId } = fakeDom();
|
||||
return withDocument(doc, () => {
|
||||
SeatMenu.state = state;
|
||||
SeatMenu.hooks = {};
|
||||
SeatMenu.seatIndex = seatIndex;
|
||||
SeatMenu.mode = move ? "move" : null;
|
||||
SeatMenu.paint();
|
||||
return { list: byId.seatMenuActions, cancel: byId.seatMenuCancelBtn, state };
|
||||
});
|
||||
}
|
||||
|
||||
const classes = el => el.className.split(/\s+/).filter(Boolean);
|
||||
const labels = el => el.children.map(child => child.textContent);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the two tiers are boxes, not a run of siblings", () => {
|
||||
// Five loose children can only ever stack. Two boxes can be placed.
|
||||
test("the sheet's actions are exactly the hand and the housekeeping", () => {
|
||||
const { list } = painted();
|
||||
assert.deepEqual(list.children.map(child => child.className),
|
||||
["seatMenuHand", "seatMenuKeeping"]);
|
||||
});
|
||||
|
||||
test("the hand tier holds the bars a dealer presses with cards in the air", () => {
|
||||
const { list } = painted();
|
||||
const hand = list.children[0];
|
||||
assert.deepEqual(labels(hand), ["Fold", "All-in"]);
|
||||
hand.children.forEach(btn => {
|
||||
assert.ok(classes(btn).includes("seatMenuBtn"));
|
||||
assert.ok(!classes(btn).includes("menuTile"),
|
||||
"a bar is not a tile — the whole point of the tier is that it is a bar");
|
||||
});
|
||||
});
|
||||
|
||||
test("and the housekeeping tier holds its captions and grids in order", () => {
|
||||
const { list } = painted();
|
||||
const keeping = list.children[1];
|
||||
assert.deepEqual(keeping.children.map(child => child.className),
|
||||
["seatMenuCaption", "seatMenuGrid", "seatMenuCaption", "seatMenuGrid"]);
|
||||
assert.deepEqual(keeping.children.map(child => child.tag), ["p", "div", "p", "div"]);
|
||||
});
|
||||
|
||||
// The sideways layout reads the tier's absence to know it has one column rather
|
||||
// than two, so an empty tier has to be absent rather than empty.
|
||||
test("a tier with nothing in it is left out rather than drawn empty", () => {
|
||||
// A chair nobody is sitting in, with nobody waiting to take it: there is
|
||||
// nothing at all to do to it while the cards are in the air.
|
||||
const { list } = painted(7, { seated: [0, 1, 2], waiting: false });
|
||||
assert.deepEqual(list.children.map(child => child.className), ["seatMenuKeeping"],
|
||||
"an empty chair still drew a hand tier with nothing in it");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the move picker", () => {
|
||||
test("its chairs go in the housekeeping tier too, so the sheet has one column", () => {
|
||||
const { list } = painted(3, { move: true });
|
||||
assert.deepEqual(list.children.map(child => child.className), ["seatMenuKeeping"]);
|
||||
});
|
||||
|
||||
// "Seat 3 — Alexandra" sets 116px against "Sit out"'s 50, so this grid asks for a
|
||||
// different column from the one the tiles use.
|
||||
test("and they ask for a column of their own width", () => {
|
||||
const { list } = painted(3, { move: true });
|
||||
const grid = list.children[0].children[0];
|
||||
assert.deepEqual(classes(grid), ["seatMenuGrid", "seatMenuGridNames"]);
|
||||
});
|
||||
|
||||
test("the way back out is still the sheet's own foot button", () => {
|
||||
const { cancel } = painted(3, { move: true });
|
||||
assert.equal(cancel.textContent, "Back");
|
||||
assert.equal(painted(3).cancel.textContent, "Cancel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("upright, nothing moved", () => {
|
||||
// The wrappers gap exactly as the loose children used to, so the sheet a dealer
|
||||
// holding a phone the usual way sees is the one it has been since round 7.
|
||||
test("both tiers are the same column the sheet already was", () => {
|
||||
const rule = ruleFor(".seatMenuHand,\n.seatMenuKeeping");
|
||||
assert.match(rule, /flex-direction:\s*column/);
|
||||
assert.match(rule, /gap:\s*var\(--sp-2\)/);
|
||||
assert.match(rule, /min-width:\s*0/);
|
||||
});
|
||||
|
||||
test("and the grid is still two columns wherever the screen is not short", () => {
|
||||
assert.match(ruleFor(".seatMenuGrid"), /grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the short screen does instead", () => {
|
||||
const block = shortViewportBlock();
|
||||
|
||||
// Height and width, not orientation: a short desktop window has the same shortage
|
||||
// and the same answer, and a narrow one has neither the width to spend nor a second
|
||||
// column to spend it on.
|
||||
test("it is scoped on the shortage rather than on which way up the phone is", () => {
|
||||
assert.ok(!/orientation/.test(SHORT), "orientation says nothing about how much height there is");
|
||||
});
|
||||
|
||||
test("the sheet takes the width it has been leaving empty", () => {
|
||||
assert.match(block, /max-width:\s*min\(100% - var\(--sp-5\), 46rem\)/);
|
||||
});
|
||||
|
||||
test("the tiers become the two columns, with the way out under the thumb", () => {
|
||||
assert.match(block, /"header\s+header"/);
|
||||
assert.match(block, /"keeping hand"/);
|
||||
assert.match(block, /"keeping cancel"/);
|
||||
});
|
||||
|
||||
test("which means the box that held them stands aside", () => {
|
||||
const at = block.indexOf("#seatMenuActions {");
|
||||
assert.ok(at >= 0, "#seatMenuActions is not addressed here");
|
||||
assert.match(block.slice(at, block.indexOf("}", at)), /display:\s*contents/);
|
||||
});
|
||||
|
||||
// A column reserved for nothing would narrow the names for no reason.
|
||||
test("a sheet with no hand tier is one column, not two", () => {
|
||||
const at = block.indexOf("#seatMenuSheet:not(:has(.seatMenuHand))");
|
||||
assert.ok(at >= 0, "the move picker still reserves a column for a tier it hasn't got");
|
||||
const rule = block.slice(at, block.indexOf("}", at));
|
||||
assert.match(rule, /grid-template-columns:\s*minmax\(0, 1fr\)/);
|
||||
assert.match(rule, /"keeping"/);
|
||||
});
|
||||
|
||||
test("the tiles spread into as many columns as the width holds", () => {
|
||||
assert.match(block, /repeat\(auto-fill, minmax\(var\(--tileMin\), 1fr\)\)/);
|
||||
});
|
||||
|
||||
// auto-fit throws away the tracks nothing sits in, so a group of two stretched to
|
||||
// half the sheet each while the group above it was a third — two grids in a column
|
||||
// lining up with nothing.
|
||||
test("and they line up with each other, group to group", () => {
|
||||
// The comment above the rule says the word it is here to rule out, so the code
|
||||
// is read with the comments stripped out of it.
|
||||
const code = block.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
assert.ok(!/auto-fit/.test(code),
|
||||
"auto-fit collapses the empty tracks and the grids stop sharing column edges");
|
||||
});
|
||||
|
||||
// The left column is the taller of the two, and stretching left Cancel a 74px
|
||||
// button on one phone and a 48px one on the next.
|
||||
test("nothing grows into the slack the taller column leaves", () => {
|
||||
assert.match(block, /align-items:\s*start/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the numbers the columns are chosen from", () => {
|
||||
// The floor is what the longest of these labels needs, not a round figure that
|
||||
// happened to work on the phone it was measured on.
|
||||
test("a tile's narrowest is a token the grid of names can raise", () => {
|
||||
assert.match(ruleFor(".seatMenuGrid"), /--tileMin:\s*7\.5rem/);
|
||||
const block = shortViewportBlock();
|
||||
const at = block.indexOf(".seatMenuGrid.seatMenuGridNames");
|
||||
assert.ok(at >= 0, "the chairs never ask for more room than a verb");
|
||||
assert.match(block.slice(at, block.indexOf("}", at)), /--tileMin:\s*8\.25rem/);
|
||||
});
|
||||
|
||||
// Two words. Every pixel over that comes out of the left column, where it is a
|
||||
// whole extra column of tiles.
|
||||
test("the hand column is sized for Fold and All-in and no wider", () => {
|
||||
const block = shortViewportBlock();
|
||||
assert.match(block, /grid-template-columns:\s*minmax\(0, 1fr\) minmax\(8rem, 10rem\)/);
|
||||
});
|
||||
|
||||
test("what was measured is written beside the rules", () => {
|
||||
const block = shortViewportBlock();
|
||||
const note = CSS.slice(CSS.indexOf("A phone on its side: the seat menu goes beside itself"),
|
||||
CSS.indexOf(SHORT));
|
||||
assert.match(note, /640x360/);
|
||||
assert.match(note, /545px/);
|
||||
assert.match(note, /224px/);
|
||||
assert.match(block, /10rem it\s+gets 397 and fits three/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
// A control that can't act, pressed on the device this tool is for.
|
||||
//
|
||||
// Measured on an iPhone, three hands dealt, the card picker open on an empty seat:
|
||||
// seven of the fifty-two cards were greyed out, none of them could take focus, and
|
||||
// tapping one changed nothing whatsoever — no toast, no text, not a byte of DOM. The
|
||||
// tool did have an answer to "who has the ace of spades?"; it was a `title`, and a
|
||||
// `title` needs a mouse. The same held in the header, where a new dealer's very first
|
||||
// screen is two dimmed buttons, and on Export CSV, which was greyed out with no
|
||||
// explanation written anywhere at all.
|
||||
//
|
||||
// `disabled` is the wrong attribute whenever there is something to say. It takes the
|
||||
// control out of the tab order, so a screen reader never lands on it and never hears
|
||||
// the description; and it eats the press on a touch screen, where the hover that would
|
||||
// have shown the `title` does not exist. NN/g's rule for the case is explicit — on a
|
||||
// touch interface, a press on a disabled control should raise the explanation — and
|
||||
// WCAG 3.3.1 wants the instruction reachable at all. `aria-disabled` announces the same
|
||||
// state and leaves the control reachable, so the press arrives and is answered.
|
||||
//
|
||||
// It also fixed something nobody had noticed. The picker's arrow keys hunted past dealt
|
||||
// cards, because a `disabled` button cannot hold focus — and hunting did the exact thing
|
||||
// the grid's no-wrap rule was written to prevent: Right from the ace of spades over a
|
||||
// dealt ace of hearts landed on the ace of diamonds, so the column silently shifted and
|
||||
// the next press of Down walked a different suit. A cell that can hold focus needs no
|
||||
// hunting, so that code is gone and the grid tells the truth about its own shape.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Unavailable } = require("./load");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
const MARKUP = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const src = name => fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
|
||||
|
||||
const ruleFor = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
|
||||
return CSS.slice(at, CSS.indexOf("}", at));
|
||||
};
|
||||
|
||||
// Enough of an element for a module that only ever reads three attributes off one.
|
||||
const element = (attrs = {}) => ({
|
||||
dataset: {},
|
||||
attrs: { ...attrs },
|
||||
setAttribute(name, value) { this.attrs[name] = String(value); },
|
||||
getAttribute(name) { return name in this.attrs ? this.attrs[name] : null; },
|
||||
removeAttribute(name) { delete this.attrs[name]; },
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("marking a control that cannot act", () => {
|
||||
test("it says so in the tree, and carries the reason with it", () => {
|
||||
const btn = element();
|
||||
Unavailable.set(btn, true, "Nothing to resolve yet — seat a player first.");
|
||||
assert.equal(btn.getAttribute("aria-disabled"), "true");
|
||||
assert.equal(Unavailable.reason(btn), "Nothing to resolve yet — seat a player first.");
|
||||
assert.ok(Unavailable.blocked(btn));
|
||||
});
|
||||
|
||||
// The state and the reason are set and cleared in one call on purpose. A control
|
||||
// left marked after the reason stopped being true is worse than one never marked:
|
||||
// it is a live control that refuses to work and explains why in the past tense.
|
||||
test("and both halves come off together", () => {
|
||||
const btn = element();
|
||||
Unavailable.set(btn, true, "Nothing to resolve yet.");
|
||||
Unavailable.set(btn, false, "Nothing to resolve yet.");
|
||||
assert.equal(btn.getAttribute("aria-disabled"), null);
|
||||
assert.equal(Unavailable.reason(btn), "");
|
||||
assert.ok(!Unavailable.blocked(btn));
|
||||
});
|
||||
|
||||
test("a control nobody marked is not blocked and has nothing to say", () => {
|
||||
assert.ok(!Unavailable.blocked(element()));
|
||||
assert.equal(Unavailable.reason(element()), "");
|
||||
assert.equal(Unavailable.reason(null), "");
|
||||
assert.ok(!Unavailable.blocked(null));
|
||||
});
|
||||
|
||||
// `aria-disabled="false"` is a real thing to write and it means enabled. Reading the
|
||||
// attribute's presence rather than its value would have got that backwards.
|
||||
test("and one marked false is enabled, which is what the attribute means", () => {
|
||||
const btn = element({ "aria-disabled": "false" });
|
||||
assert.ok(!Unavailable.blocked(btn));
|
||||
});
|
||||
});
|
||||
|
||||
describe("what happens when it is pressed", () => {
|
||||
// The press can land on a span inside the button rather than the button, which is
|
||||
// most of what the picker's cards are made of.
|
||||
test("the answer is looked for upwards from whatever was hit", () => {
|
||||
const btn = element();
|
||||
Unavailable.set(btn, true, "A♠ — dealt to Maya");
|
||||
const rank = { closest: selector => (selector === '[aria-disabled="true"]' ? btn : null) };
|
||||
assert.equal(Unavailable.reason(Unavailable.from(rank)), "A♠ — dealt to Maya");
|
||||
});
|
||||
|
||||
test("a press on nothing in particular finds nothing", () => {
|
||||
assert.equal(Unavailable.from(null), null);
|
||||
assert.equal(Unavailable.from({}), null);
|
||||
});
|
||||
|
||||
// Nothing about `aria-disabled` stops a click reaching its own handler — that is the
|
||||
// trade for staying reachable. One listener does the stopping, in the capture phase,
|
||||
// so a control's own handler never runs and never has to remember to check.
|
||||
test("the press is stopped before the control's own handler sees it", () => {
|
||||
const listener = src("feedback").slice(src("feedback").indexOf("listen(root) {"));
|
||||
assert.match(listener, /addEventListener\("click"[\s\S]*?\}, true\)/,
|
||||
"the listener is not in the capture phase, so it cannot stop anything");
|
||||
assert.match(listener, /event\.preventDefault\(\)/);
|
||||
assert.match(listener, /event\.stopPropagation\(\)/);
|
||||
});
|
||||
|
||||
test("and it is wired once, for the whole table", () => {
|
||||
const main = src("main");
|
||||
assert.match(main, /Unavailable\.listen\(document\)/);
|
||||
assert.equal((main.match(/Unavailable\.listen\(/g) || []).length, 1);
|
||||
});
|
||||
|
||||
// A control with nothing to say is not a control this is about — the seat sheet's
|
||||
// own greyed-out fields, say. Swallowing its press would be a new dead end.
|
||||
test("a marked control with no reason is left entirely alone", () => {
|
||||
const btn = element();
|
||||
Unavailable.set(btn, true, "");
|
||||
assert.equal(Unavailable.reason(btn), "");
|
||||
});
|
||||
});
|
||||
|
||||
describe("where the answer is written", () => {
|
||||
// The toast is fixed to the bottom of the window at z-index 80; the card picker's
|
||||
// scrim is 95 and its sheet stands 88vh tall. A toast raised for a control inside
|
||||
// that sheet would be behind it, or over the cards if it were not.
|
||||
test("a sheet that covers the toast says it itself", () => {
|
||||
assert.match(MARKUP, /id="cardPickerSheet"[^>]*data-why-note="cardPickerNote"/);
|
||||
assert.match(MARKUP, /id="cardPickerNote"[^>]*role="status"/);
|
||||
});
|
||||
|
||||
test("and everything else falls through to the toast", () => {
|
||||
const say = src("feedback").slice(src("feedback").indexOf("say(el, why) {"));
|
||||
assert.match(say, /closest\("\[data-why-note\]"\)/);
|
||||
assert.match(say, /Toast\.show\(why\)/);
|
||||
});
|
||||
|
||||
// Cleared on the next paint rather than on a timer: a paint is exactly the set of
|
||||
// things that make the last answer stale, and nothing else in the sheet redraws.
|
||||
test("the line is cleared by the next thing that changes the sheet", () => {
|
||||
const picker = src("cardPicker");
|
||||
const at = picker.indexOf("paint() {");
|
||||
assert.ok(at >= 0);
|
||||
const opening = picker.slice(at, picker.indexOf("cardPickerTitle", at));
|
||||
assert.match(opening, /getElementById\("cardPickerNote"\)/);
|
||||
assert.match(opening, /note\.textContent = ""/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the line holds its place", () => {
|
||||
// Filling it must not move the grid. It sits between the slots and the cards, so a
|
||||
// line that appeared from nothing would shove fifty-two targets down the screen
|
||||
// under a thumb already travelling towards one of them.
|
||||
test("it is one line tall whether or not it is saying anything", () => {
|
||||
const rule = ruleFor(".pickerNote");
|
||||
assert.match(rule, /min-height:\s*1\.35em/);
|
||||
assert.match(rule, /line-height:\s*1\.35/);
|
||||
});
|
||||
|
||||
// Measured: the note's box is 318px on a Galaxy S8 at 20px of root text, and a name
|
||||
// is whatever somebody typed. Twenty-four capital Ws draw 434px in it. No wording
|
||||
// is safe, so the box is what holds.
|
||||
test("and it cannot become two, whatever the name is", () => {
|
||||
const rule = ruleFor(".pickerNote");
|
||||
assert.match(rule, /white-space:\s*nowrap/);
|
||||
assert.match(rule, /overflow:\s*hidden/);
|
||||
assert.match(rule, /text-overflow:\s*ellipsis/);
|
||||
});
|
||||
|
||||
test("what it measured is written beside the rule", () => {
|
||||
const at = CSS.indexOf("/* Which card, and who has it.");
|
||||
assert.ok(at >= 0, "the rule carries no note saying why");
|
||||
const note = CSS.slice(at, CSS.indexOf("*/", at));
|
||||
assert.match(note, /318px/);
|
||||
assert.match(note, /434px/);
|
||||
});
|
||||
|
||||
// Which is the half that survives the clipping, and the half that does not.
|
||||
test("the card and where it went come before the name", () => {
|
||||
const picker = src("cardPicker");
|
||||
assert.match(picker, /\$\{label\} — face up on the board/);
|
||||
assert.match(picker, /\$\{label\} — dealt to \$\{takenBy\}/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dimmed looks dimmed, however it was said", () => {
|
||||
test("a button marked either way is painted the same", () => {
|
||||
const at = CSS.indexOf("\n.holdemBtn:disabled,");
|
||||
assert.ok(at >= 0, "the two states are painted by separate rules, so they can drift apart");
|
||||
assert.match(CSS.slice(at, CSS.indexOf("}", at)), /\.holdemBtn\[aria-disabled="true"\]/);
|
||||
});
|
||||
|
||||
test("and so is a card", () => {
|
||||
const at = CSS.indexOf("\n.pickerCard:disabled,");
|
||||
assert.ok(at >= 0);
|
||||
assert.match(CSS.slice(at, CSS.indexOf("}", at)), /\.pickerCard\[aria-disabled="true"\]/);
|
||||
});
|
||||
|
||||
// A press animation on a control that will not act is a lie told in 40ms.
|
||||
test("neither one presses down under a finger that can't use it", () => {
|
||||
[".holdemBtn:active", ".pickerCard:active", ".pickerCard:hover"].forEach(base => {
|
||||
const found = CSS.includes(`${base}:not(:disabled):not([aria-disabled="true"])`);
|
||||
assert.ok(found, `\`${base}\` still animates for a control marked aria-disabled`);
|
||||
});
|
||||
});
|
||||
|
||||
// `disabled` is a state the OS palette knows and draws in GrayText by itself.
|
||||
// `aria-disabled` is a word in the accessibility tree and nothing else, so in
|
||||
// forced-colors mode a dealt card came out looking exactly like a live one.
|
||||
test("and the OS is told about it too, since it only knows the other one", () => {
|
||||
// Whichever of the file's forced-colors blocks it landed in — there is more than
|
||||
// one of them, and the stylesheet is free to answer a query wherever it likes.
|
||||
const open = "@media (forced-colors: active) {";
|
||||
const blocks = [];
|
||||
for (let at = CSS.indexOf(open); at >= 0; at = CSS.indexOf(open, at + 1)) {
|
||||
blocks.push(CSS.slice(at, CSS.indexOf("\n}", at)));
|
||||
}
|
||||
assert.ok(blocks.length, "nothing in the file answers forced-colors at all");
|
||||
const answering = blocks.filter(b => /\[aria-disabled="true"\]/.test(b));
|
||||
assert.equal(answering.length, 1, "the dimmed state has no forced-colors answer");
|
||||
assert.match(answering[0], /\.pickerCard\[aria-disabled="true"\]/);
|
||||
assert.match(answering[0], /\.holdemBtn\[aria-disabled="true"\]/);
|
||||
assert.match(answering[0], /color:\s*GrayText/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the three controls that were dead ends", () => {
|
||||
test("the header's two say why, and are reachable enough to say it", () => {
|
||||
const render = src("render");
|
||||
const at = render.indexOf("renderHeaderActions() {");
|
||||
const body = render.slice(at, render.indexOf("\n },", at));
|
||||
assert.match(body, /Unavailable\.set\(button, nothingToRunYet, why\)/);
|
||||
assert.ok(!/button\.disabled = /.test(body), "one of them is still natively disabled");
|
||||
assert.match(body, /Nothing to resolve yet/);
|
||||
assert.match(body, /Nothing to move on from/);
|
||||
});
|
||||
|
||||
test("Export CSV has a reason at all, which it never did", () => {
|
||||
const render = src("render");
|
||||
const at = render.indexOf('getElementById("exportHistoryBtn")');
|
||||
assert.ok(at >= 0);
|
||||
const around = render.slice(at - 200, at + 260);
|
||||
assert.match(around, /Unavailable\.set\(/);
|
||||
assert.match(around, /No hands to export yet/);
|
||||
});
|
||||
|
||||
// Found the same afternoon, in the same sheet, by running the whole audit over an
|
||||
// open overlay for the first time: the two slot buttons above the grid are drawn as
|
||||
// a card or an em dash, and the em dash was the entire name a screen reader was
|
||||
// given. "Dash, button", twice, on the sheet where a dealer writes a card down.
|
||||
test("and the slots above it are called something", () => {
|
||||
const picker = src("cardPicker");
|
||||
const at = picker.indexOf("btn.className = \"pickerSlot\"");
|
||||
const body = picker.slice(at, picker.indexOf("slots.appendChild(btn)", at));
|
||||
assert.match(body, /this\.boardSlotLabel\(slot\) : `Card \$\{slot \+ 1\}`/,
|
||||
"a seat's slots still have no name of their own");
|
||||
assert.match(body, /setAttribute\("aria-label", value \? .* : .*empty.*\)/,
|
||||
"an empty slot does not say that it is empty");
|
||||
});
|
||||
|
||||
test("and a dealt card names itself rather than saying \"this one\"", () => {
|
||||
const picker = src("cardPicker");
|
||||
const at = picker.indexOf("const takenBy = used.get(card)");
|
||||
const body = picker.slice(at, picker.indexOf("grid.appendChild(btn)", at));
|
||||
assert.match(body, /Unavailable\.set\(btn, !!takenBy/);
|
||||
assert.ok(!/btn\.disabled = /.test(body), "the cards are still natively disabled");
|
||||
// The `title` is the same sentence, and now reaches a screen reader because the
|
||||
// element it hangs off can be landed on.
|
||||
assert.match(body, /btn\.title = Unavailable\.reason\(btn\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
// Undo, and whether a dealer can actually get to it.
|
||||
//
|
||||
// This tool prefers undo to confirmation — NN/g's own preference, and `runUndoable`
|
||||
// says so in as many words: "it now just happens, and the toast offers the way back for
|
||||
// the few seconds anyone would notice a misclick." That makes the toast's Undo the
|
||||
// safety net under the biggest things the tool does. Three ways it wasn't one:
|
||||
//
|
||||
// * Every whole-step undo clears the per-action history on its way past, so the bar
|
||||
// has no pill left and this button is the only way back. Measured on a desktop it
|
||||
// is the 67th of the page's 68 tab stops, and it is gone in eight seconds. `U` —
|
||||
// the key the sidebar legend advertises as taking back the last action — found
|
||||
// nothing at all and did nothing.
|
||||
// * The eight seconds ran whatever the dealer was doing. A cursor resting on the
|
||||
// toast did not stop it; nor did tabbing onto the button itself. Measured: focus
|
||||
// the Undo, wait 8.6 seconds, and the toast is gone and focus has been handed to
|
||||
// <body> — the offer withdrawn and the dealer's place in the page with it.
|
||||
// * Each toast carried its own role="status" inside #toastHost, which is already a
|
||||
// live region in the markup. A region nested in a region, announced twice.
|
||||
//
|
||||
// The timing rules here are the usual ones: three to four seconds for a plain
|
||||
// confirmation, six to ten for anything carrying a button, pause on hover and on
|
||||
// keyboard focus, and always a manual way out — which is what WCAG 2.2.1 asks of
|
||||
// content that disappears on a clock.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Toast } = require("./load");
|
||||
|
||||
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
|
||||
const MAIN = fs.readFileSync(path.join(SRC, "main.js"), "utf8");
|
||||
const FEEDBACK = fs.readFileSync(path.join(SRC, "feedback.js"), "utf8");
|
||||
const SHORTCUTS = fs.readFileSync(path.join(SRC, "shortcuts.js"), "utf8");
|
||||
const PAGE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enough of a document for a toast: a host it can be put into, elements that can hold
|
||||
// listeners and focus, and a `:hover` that a test can turn on.
|
||||
|
||||
let active = null;
|
||||
|
||||
const node = (tag, className) => {
|
||||
const self = {
|
||||
tagName: String(tag).toUpperCase(),
|
||||
className: className || "",
|
||||
textContent: "",
|
||||
dataset: {},
|
||||
children: [],
|
||||
parent: null,
|
||||
isConnected: true,
|
||||
hovered: false,
|
||||
listeners: {},
|
||||
attrs: {},
|
||||
classList: { add() {} },
|
||||
appendChild(child) { child.parent = self; self.children.push(child); return child; },
|
||||
addEventListener(type, fn) { (self.listeners[type] = self.listeners[type] || []).push(fn); },
|
||||
setAttribute(name, value) { self.attrs[name] = String(value); },
|
||||
getAttribute(name) { return name in self.attrs ? self.attrs[name] : null; },
|
||||
contains(other) {
|
||||
for (let n = other; n; n = n.parent) if (n === self) return true;
|
||||
return false;
|
||||
},
|
||||
matches(selector) { return selector === ":hover" ? self.hovered : false; },
|
||||
focus() { active = self; },
|
||||
getBoundingClientRect: () => ({ height: 44 }),
|
||||
// Fire one of the listeners the toast hung on itself.
|
||||
fire(type, event) { (self.listeners[type] || []).forEach(fn => fn(event || {})); },
|
||||
find(className) {
|
||||
if (self.className === className) return self;
|
||||
for (const child of self.children) {
|
||||
const hit = child.find(className);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(self, "firstElementChild", { get: () => self.children[0] || null });
|
||||
Object.defineProperty(self, "innerHTML", {
|
||||
get: () => "",
|
||||
set(value) {
|
||||
if (value !== "") return;
|
||||
self.children.forEach(child => { child.parent = null; child.isConnected = false; });
|
||||
self.children.length = 0;
|
||||
},
|
||||
});
|
||||
return self;
|
||||
};
|
||||
|
||||
let host;
|
||||
const openDocument = () => {
|
||||
host = node("div", "toastHost");
|
||||
host.id = "toastHost";
|
||||
active = null;
|
||||
global.document = {
|
||||
getElementById: id => (id === "toastHost" ? host : null),
|
||||
createElement: tag => node(tag),
|
||||
documentElement: { style: { setProperty() {} } },
|
||||
};
|
||||
Object.defineProperty(global.document, "activeElement", { get: () => active });
|
||||
global.requestAnimationFrame = fn => fn();
|
||||
};
|
||||
|
||||
const toastOnScreen = () => host.firstElementChild;
|
||||
const undoButton = () => toastOnScreen() && toastOnScreen().find("toastAction");
|
||||
|
||||
// The toast's own timers are the thing under test, so they are driven rather than
|
||||
// waited on. `resume` defers to the next tick on purpose — see feedback.js — so every
|
||||
// release is followed by a tick of 1 to let that land.
|
||||
const settle = t => t.mock.timers.tick(1);
|
||||
|
||||
const show = (options = {}) => Toast.show(options.message || "Button moved on.", options);
|
||||
|
||||
test.beforeEach(t => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
|
||||
openDocument();
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
Toast.dismiss();
|
||||
delete global.document;
|
||||
delete global.requestAnimationFrame;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("a toast nobody touches", () => {
|
||||
test("stays for the time it was given, and then goes", t => {
|
||||
show({ duration: 5000 });
|
||||
t.mock.timers.tick(4900);
|
||||
assert.ok(toastOnScreen(), "it left early");
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(toastOnScreen(), null, "it outstayed its duration");
|
||||
});
|
||||
|
||||
test("and the default is the one a message with a button in it gets", () => {
|
||||
// Three to four seconds for a plain confirmation, six to ten for anything
|
||||
// carrying a button. Every plain one here names its own shorter duration; this
|
||||
// default is what the undoable steps and the wake lock fall back to.
|
||||
assert.ok(Toast.DEFAULT_DURATION_MS >= 6000 && Toast.DEFAULT_DURATION_MS <= 10000,
|
||||
`${Toast.DEFAULT_DURATION_MS}ms is outside the range for a toast with an action in it`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a toast the dealer has reached for", () => {
|
||||
test("a finger on it stops the clock", t => {
|
||||
show({ duration: 8000 });
|
||||
t.mock.timers.tick(1000);
|
||||
const toast = toastOnScreen();
|
||||
toast.hovered = true;
|
||||
toast.fire("pointerenter");
|
||||
|
||||
t.mock.timers.tick(30000);
|
||||
assert.ok(toastOnScreen(), "eight seconds ran out under a finger that was holding it");
|
||||
});
|
||||
|
||||
test("and so does tabbing onto the button", t => {
|
||||
show({ duration: 8000, actionLabel: "Undo", onAction: () => {} });
|
||||
const toast = toastOnScreen();
|
||||
undoButton().focus();
|
||||
toast.fire("focusin", { relatedTarget: null });
|
||||
|
||||
t.mock.timers.tick(30000);
|
||||
assert.ok(toastOnScreen(), "the offer was withdrawn while the keyboard was standing on it");
|
||||
assert.equal(active, undoButton(), "and focus went with it");
|
||||
});
|
||||
|
||||
test("letting go starts it again", t => {
|
||||
show({ duration: 8000 });
|
||||
const toast = toastOnScreen();
|
||||
toast.hovered = true;
|
||||
toast.fire("pointerenter");
|
||||
t.mock.timers.tick(30000);
|
||||
|
||||
toast.hovered = false;
|
||||
toast.fire("pointerleave");
|
||||
settle(t);
|
||||
t.mock.timers.tick(7000);
|
||||
assert.ok(toastOnScreen(), "it left before the time it still had");
|
||||
t.mock.timers.tick(1200);
|
||||
assert.equal(toastOnScreen(), null, "letting go did not restart the clock at all");
|
||||
});
|
||||
|
||||
test("and letting go late still leaves time to act", t => {
|
||||
// The half of this that pausing alone doesn't fix: touch it at 7.9 seconds of
|
||||
// 8, let go, and what is left of the clock to *notice* the message is 100ms —
|
||||
// gone while the thumb is still travelling to Undo.
|
||||
show({ duration: 8000 });
|
||||
const toast = toastOnScreen();
|
||||
t.mock.timers.tick(7900);
|
||||
toast.hovered = true;
|
||||
toast.fire("pointerenter");
|
||||
t.mock.timers.tick(3000);
|
||||
toast.hovered = false;
|
||||
toast.fire("pointerleave");
|
||||
settle(t);
|
||||
|
||||
t.mock.timers.tick(Toast.RESUME_MS - 500);
|
||||
assert.ok(toastOnScreen(), "it went in the 100ms that was left rather than the time to act");
|
||||
t.mock.timers.tick(700);
|
||||
assert.equal(toastOnScreen(), null, "and then it never went at all");
|
||||
});
|
||||
|
||||
test("a cursor leaving while the keyboard is still inside does not restart it", t => {
|
||||
// Two ways to hold it open, one timer. The pointer leaving is not an answer to
|
||||
// "is anybody still using this?"
|
||||
show({ duration: 8000, actionLabel: "Undo", onAction: () => {} });
|
||||
const toast = toastOnScreen();
|
||||
toast.hovered = true;
|
||||
toast.fire("pointerenter");
|
||||
undoButton().focus();
|
||||
toast.fire("focusin", { relatedTarget: null });
|
||||
|
||||
toast.hovered = false;
|
||||
toast.fire("pointerleave");
|
||||
settle(t);
|
||||
t.mock.timers.tick(30000);
|
||||
assert.ok(toastOnScreen(), "the clock restarted under a focused button");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what happens to focus when it goes", () => {
|
||||
test("the dealer is put back where they had got to", t => {
|
||||
const elsewhere = node("button");
|
||||
show({ actionLabel: "Undo", onAction: () => {} });
|
||||
const toast = toastOnScreen();
|
||||
|
||||
elsewhere.focus();
|
||||
undoButton().focus();
|
||||
toast.fire("focusin", { relatedTarget: elsewhere });
|
||||
Toast.dismiss();
|
||||
settle(t);
|
||||
|
||||
assert.equal(active, elsewhere,
|
||||
"focus was left on a button that no longer exists, which hands it to <body>");
|
||||
});
|
||||
|
||||
test("and not somewhere they left eight seconds ago", t => {
|
||||
// Captured when focus crosses into the toast, not when the toast appeared: a
|
||||
// dealer who carries on working and then tabs back should land where they got
|
||||
// to, not where they were when the message arrived.
|
||||
const whenItArrived = node("button");
|
||||
const whereTheyGotTo = node("button");
|
||||
whenItArrived.focus();
|
||||
show({ actionLabel: "Undo", onAction: () => {} });
|
||||
const toast = toastOnScreen();
|
||||
|
||||
whereTheyGotTo.focus();
|
||||
undoButton().focus();
|
||||
toast.fire("focusin", { relatedTarget: whereTheyGotTo });
|
||||
Toast.dismiss();
|
||||
settle(t);
|
||||
|
||||
assert.equal(active, whereTheyGotTo);
|
||||
});
|
||||
|
||||
test("focus is left alone when it was never in the toast", () => {
|
||||
const elsewhere = node("button");
|
||||
show({ actionLabel: "Undo", onAction: () => {} });
|
||||
elsewhere.focus();
|
||||
|
||||
Toast.dismiss();
|
||||
assert.equal(active, elsewhere, "dismissing a toast nobody was in moved the dealer");
|
||||
});
|
||||
|
||||
test("and a control that has gone since is not reached for", () => {
|
||||
const gone = node("button");
|
||||
show({ actionLabel: "Undo", onAction: () => {} });
|
||||
const toast = toastOnScreen();
|
||||
gone.focus();
|
||||
undoButton().focus();
|
||||
toast.fire("focusin", { relatedTarget: gone });
|
||||
// A re-render between the toast going up and coming down. This happens on every
|
||||
// undo: restoreState rebuilds the table.
|
||||
gone.isConnected = false;
|
||||
|
||||
assert.doesNotThrow(() => Toast.dismiss());
|
||||
});
|
||||
});
|
||||
|
||||
describe("the key that reaches it", () => {
|
||||
test("a toast says which shortcut presses its button", () => {
|
||||
show({ actionLabel: "Undo", shortcut: "undo", onAction: () => {} });
|
||||
assert.equal(undoButton().dataset.shortcut, "undo");
|
||||
});
|
||||
|
||||
test("and one that named none is not reachable by a stray keypress", () => {
|
||||
// "Turn off" on the wake lock and "Reload" on the other-tab warning both live
|
||||
// in this same strip. Neither is a thing U should be able to do.
|
||||
show({ actionLabel: "Turn off", onAction: () => {} });
|
||||
assert.equal(undoButton().dataset.shortcut, undefined);
|
||||
});
|
||||
|
||||
test("both of the undoable paths opt in", () => {
|
||||
// runUndoable, for whole steps, and seatAction, for a gesture on a seat.
|
||||
const offers = MAIN.split('actionLabel: "Undo"').length - 1;
|
||||
const named = MAIN.split('shortcut: "undo"').length - 1;
|
||||
assert.equal(offers, 2, "the number of Undo toasts has changed");
|
||||
assert.equal(named, offers, "an Undo the keyboard cannot reach");
|
||||
});
|
||||
|
||||
test("the key presses the button rather than running its own copy of it", () => {
|
||||
// The house rule for every other shortcut in this file: press what is on the
|
||||
// screen, so there is no second code path to drift out of step with it.
|
||||
assert.match(MAIN, /#toastHost \.toastAction\[data-shortcut=/);
|
||||
assert.match(MAIN, /undo: \(\) => pressToastAction\("undo"\) \|\| pressInBar\("\.actionUndo"\)/,
|
||||
"the toast is the more recent of the two and has to be asked first");
|
||||
});
|
||||
|
||||
test("and the legend covers both undos, because a dealer has no reason to know there are two", () => {
|
||||
const line = SHORTCUTS.match(/keys: \["U"\][^}]*what: "([^"]+)"/);
|
||||
assert.ok(line, "the U shortcut has no legend line");
|
||||
assert.doesNotMatch(line[1], /last action$/,
|
||||
"the legend still promises only the bar's pill");
|
||||
});
|
||||
});
|
||||
|
||||
describe("one live region, and it is in the markup", () => {
|
||||
test("the host is the region", () => {
|
||||
assert.match(PAGE, /id="toastHost"[^>]*aria-live="polite"/);
|
||||
});
|
||||
|
||||
test("and a toast does not bring a second one with it", () => {
|
||||
// A region that arrives already carrying its text has not been watched by
|
||||
// anything yet, which is the documented reason such a message is never spoken.
|
||||
// The host is in the markup precisely so that it has been.
|
||||
assert.ok(!/toast\.setAttribute\("role"/.test(FEEDBACK),
|
||||
"each toast is its own live region again, nested inside the one that works");
|
||||
show({});
|
||||
assert.equal(toastOnScreen().getAttribute("role"), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
// One number, one control, and a ceiling that matches the table.
|
||||
//
|
||||
// "Split between" on Quick Split was two controls for one value sitting on top of each
|
||||
// other: a row of chips 2 3 4 5, and under it a bar reading "2 ways" with a − and a +.
|
||||
// Measured on an iPhone: 134px with its label, against 74px for "Smallest chip in play"
|
||||
// right below it, which offers five choices in one row. And the number was written down
|
||||
// three times at once — chip "2" lit gold, "2 ways" in the bar, and "Each of 2 players
|
||||
// gets" in the result panel above. Two of those three were controls.
|
||||
//
|
||||
// The bar was never meant to be a bar. `.waysStepper` asked for `min-width: 150px` in a
|
||||
// wrapping flex row, so on any phone it wrapped to a line of its own, and `flex: 1 1
|
||||
// auto` then gave it all 358px of that line: 252px of empty readout with the − and the
|
||||
// + pushed 304px apart. One-handed, on a screen 390px wide, they cannot both be reached.
|
||||
//
|
||||
// So the chips get a fifth chip. It reads "6+" whatever is behind it, because behind it
|
||||
// is where the count is written, and the stepper only exists once you are through the
|
||||
// door. Under six ways — every chop a home game will ever run — the field is one row.
|
||||
//
|
||||
// Two more things went with it. Neither stepper on this screen ever disabled an end, so
|
||||
// − at the floor and + at the ceiling looked live and swallowed the tap; NN/G's stepper
|
||||
// guidelines ask for exactly the opposite. And the per-pot chop capped its + with
|
||||
// `Math.max(contenderCount, MAX_WAYS)`, which is always 12: a side pot that only P1 and
|
||||
// P2 were eligible for would happily be chopped ten ways at $12 each.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { QuickSplit } = require("./load");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "quicksplit.css"), "utf8");
|
||||
const SRC = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "quickSplit.js"), "utf8");
|
||||
|
||||
const ruleFor = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
|
||||
return CSS.slice(at, CSS.indexOf("}", at));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("where the chips stop and the stepper starts", () => {
|
||||
test("the door is the first count the chips don't cover", () => {
|
||||
assert.deepEqual(QuickSplit.WAY_CHOICES, [2, 3, 4, 5]);
|
||||
assert.equal(QuickSplit.overflowFrom(), 6);
|
||||
});
|
||||
|
||||
// Derived rather than written down twice: adding a chip has to move the door.
|
||||
test("and it moves if a chip is added or taken away", () => {
|
||||
const was = QuickSplit.WAY_CHOICES;
|
||||
try {
|
||||
QuickSplit.WAY_CHOICES = [2, 3];
|
||||
assert.equal(QuickSplit.overflowFrom(), 4);
|
||||
} finally {
|
||||
QuickSplit.WAY_CHOICES = was;
|
||||
}
|
||||
});
|
||||
|
||||
test("the stepper's own range is what the door promised", () => {
|
||||
assert.equal(QuickSplit.clampWays(QuickSplit.overflowFrom() - 1), 5);
|
||||
assert.equal(QuickSplit.clampWays(QuickSplit.MAX_WAYS + 4), QuickSplit.MAX_WAYS);
|
||||
assert.equal(QuickSplit.clampWays(0), QuickSplit.WAY_CHOICES[0]);
|
||||
assert.equal(QuickSplit.clampWays(-3), 2);
|
||||
});
|
||||
|
||||
test("the door is drawn once, from the same number", () => {
|
||||
const at = SRC.indexOf("renderWayChoices() {");
|
||||
const body = SRC.slice(at, SRC.indexOf("\n },", at));
|
||||
assert.match(body, /`\$\{this\.overflowFrom\(\)\}\+`/,
|
||||
"the chip's label is typed in rather than taken from where the chips stop");
|
||||
assert.match(body, /aria-label/, "\"6+\" is not a name a screen reader can read out");
|
||||
});
|
||||
|
||||
// The chips and the stepper are one group: when the count is past the chips, no
|
||||
// chip is lit, and the door is.
|
||||
test("nothing is lit in two places at once", () => {
|
||||
const at = SRC.indexOf("renderWayChoices() {");
|
||||
const body = SRC.slice(at, SRC.indexOf("\n },", at));
|
||||
assert.match(body, /const beyond = this\.ways >= this\.overflowFrom\(\)/);
|
||||
assert.match(body, /!beyond && this\.ways === count/,
|
||||
"a preset chip can still light up while the stepper holds a different number");
|
||||
assert.match(body, /if \(beyond\) row\.appendChild\(this\.buildWaysStepper\(\)\)/,
|
||||
"the stepper is drawn even when the chips are the control");
|
||||
});
|
||||
|
||||
test("and the count is written in one place, without a unit the label already gave", () => {
|
||||
const at = SRC.indexOf("buildWaysStepper() {");
|
||||
assert.ok(at >= 0);
|
||||
const body = SRC.slice(at, SRC.indexOf("\n },", at));
|
||||
assert.match(body, /readout\.textContent = String\(this\.ways\)/);
|
||||
assert.ok(!/\$\{this\.ways\} ways/.test(body), "the stepper is spelling the count out again");
|
||||
});
|
||||
});
|
||||
|
||||
describe("both ends of both steppers", () => {
|
||||
test("a step that cannot happen says so", () => {
|
||||
const at = SRC.indexOf("makeStepButton(label, onPick, options) {");
|
||||
assert.ok(at >= 0, "the step buttons still take no disabled state");
|
||||
assert.match(SRC.slice(at, SRC.indexOf("\n },", at)), /btn\.disabled = /);
|
||||
});
|
||||
|
||||
test("the ways stepper disables its floor and its ceiling", () => {
|
||||
const at = SRC.indexOf("buildWaysStepper() {");
|
||||
const body = SRC.slice(at, SRC.indexOf("\n },", at));
|
||||
assert.match(body, /disabled: this\.ways <= this\.overflowFrom\(\)/);
|
||||
assert.match(body, /disabled: this\.ways >= this\.MAX_WAYS/);
|
||||
});
|
||||
|
||||
test("so does the per-pot chop", () => {
|
||||
const at = SRC.indexOf("addChopControl(row, amount, contenderCount) {");
|
||||
const body = SRC.slice(at, SRC.indexOf("\n updateUneven()", at));
|
||||
assert.match(body, /disabled: ways <= 2/);
|
||||
assert.match(body, /disabled: ways >= ceiling/);
|
||||
});
|
||||
|
||||
// 40x44 until the sweep first opened the even mode and read back `hittable 39x43`
|
||||
// on every touch viewport there is.
|
||||
test("and both ends are a fingertip wide, not just a fingertip tall", () => {
|
||||
const rule = ruleFor(".stepBtn");
|
||||
const px = axis => Number((rule.match(new RegExp(`${axis}:\\s*(\\d+)px`)) || [])[1]);
|
||||
assert.ok(px("width") >= 44, `a step button is ${px("width")}px wide`);
|
||||
assert.ok(px("height") >= 44, `a step button is ${px("height")}px tall`);
|
||||
});
|
||||
|
||||
// Disabled has to look disabled, or it is the same silent tap with extra steps.
|
||||
test("and a disabled end is painted as one", () => {
|
||||
assert.match(ruleFor(".stepBtn:disabled"), /opacity:\s*0\.\d+/);
|
||||
assert.match(CSS, /\.stepBtn:not\(:disabled\):hover/,
|
||||
"a dead button still lights up under the mouse");
|
||||
});
|
||||
});
|
||||
|
||||
describe("how many ways a pot can go", () => {
|
||||
// The whole of the bug: `Math.max` where `Math.min` was meant.
|
||||
test("never more ways than there are players who can win it", () => {
|
||||
assert.equal(QuickSplit.chopCeiling(2), 2);
|
||||
assert.equal(QuickSplit.chopCeiling(3), 3);
|
||||
assert.equal(QuickSplit.chopCeiling(9), 9);
|
||||
});
|
||||
|
||||
test("and never past the screen's own ceiling", () => {
|
||||
assert.equal(QuickSplit.chopCeiling(20), QuickSplit.MAX_WAYS);
|
||||
});
|
||||
|
||||
// A pot is only offered a chop at two contenders or more, but the floor is stated
|
||||
// here too so that a caller that gets it wrong can't produce a one-way split.
|
||||
test("a chop is at least two ways", () => {
|
||||
assert.equal(QuickSplit.chopCeiling(1), 2);
|
||||
assert.equal(QuickSplit.chopCeiling(0), 2);
|
||||
});
|
||||
|
||||
test("the chop's + is held to it", () => {
|
||||
const at = SRC.indexOf("addChopControl(row, amount, contenderCount) {");
|
||||
const body = SRC.slice(at, SRC.indexOf("\n updateUneven()", at));
|
||||
assert.match(body, /const ceiling = this\.chopCeiling\(contenderCount\)/);
|
||||
assert.match(body, /if \(ways < ceiling\)/);
|
||||
assert.ok(!/Math\.max\(contenderCount/.test(body), "the old ceiling is still there");
|
||||
});
|
||||
|
||||
// Two eligible players is two ways and nothing else. A stepper with both ends dead
|
||||
// is furniture.
|
||||
test("a heads-up pot is offered no stepper at all", () => {
|
||||
const at = SRC.indexOf("addChopControl(row, amount, contenderCount) {");
|
||||
const body = SRC.slice(at, SRC.indexOf("\n updateUneven()", at));
|
||||
assert.match(body, /if \(ceiling > 2\) \{/);
|
||||
assert.ok(body.indexOf("if (ceiling > 2) {") < body.indexOf('className = "unevenChopWays"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bar that was never meant to be a bar", () => {
|
||||
test("the stepper is as wide as what is in it", () => {
|
||||
const rule = ruleFor(".waysStepper");
|
||||
assert.match(rule, /flex:\s*0 0 auto/);
|
||||
assert.ok(!/min-width/.test(rule),
|
||||
"a min-width in a wrapping row is what handed it the whole second line");
|
||||
});
|
||||
|
||||
test("and the readout no longer pushes the two buttons apart", () => {
|
||||
const rule = ruleFor(".waysReadout");
|
||||
assert.ok(!/flex:\s*1(\s|;)/.test(rule));
|
||||
assert.match(rule, /min-width:\s*[\d.]+ch/);
|
||||
assert.match(rule, /tabular-nums/);
|
||||
});
|
||||
|
||||
// It is standing in for a selected chip, so it is painted like one.
|
||||
test("the count is painted like the chip it replaces", () => {
|
||||
const chip = ruleFor(".choiceChip.selected");
|
||||
// Not `border-color`, which comes first in the rule and is a different gold.
|
||||
const colour = (chip.match(/(?:^|[^-\w])color:\s*(#[0-9a-f]{3,6})/i) || [])[1];
|
||||
assert.ok(colour, "the selected chip has no colour to match");
|
||||
assert.match(ruleFor(".waysReadout"), new RegExp(`color:\\s*${colour}`, "i"));
|
||||
});
|
||||
|
||||
// The measurement that found it, kept where the next round will read it.
|
||||
test("what it measured is written beside the rule", () => {
|
||||
const at = CSS.indexOf("/* As wide as the three things in it");
|
||||
assert.ok(at >= 0, "the rule carries no note saying why");
|
||||
const note = CSS.slice(at, CSS.indexOf("*/", at));
|
||||
assert.match(note, /304px/);
|
||||
assert.match(note, /252px/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the sweep looks at", () => {
|
||||
const DRIVER = fs.readFileSync(path.join(__dirname, "runUiAudit.js"), "utf8");
|
||||
|
||||
// It went straight to the all-in mode, so the mode the screen opens in was audited
|
||||
// zero times in fourteen rounds.
|
||||
test("both modes of the screen, not just the second one", () => {
|
||||
const at = DRIVER.indexOf("const modes = [");
|
||||
assert.ok(at >= 0, "the quick split pass still audits one mode");
|
||||
const body = DRIVER.slice(at, DRIVER.indexOf("];", at));
|
||||
assert.match(body, /"even"/);
|
||||
assert.match(body, /"all-in"/);
|
||||
assert.match(DRIVER, /quick split · \$\{mode\}/, "a failure won't say which mode it was in");
|
||||
});
|
||||
|
||||
test("with the stepper open, since it isn't there otherwise", () => {
|
||||
const at = DRIVER.indexOf('["even", () => {');
|
||||
assert.ok(at >= 0);
|
||||
assert.match(DRIVER.slice(at, DRIVER.indexOf("}]", at)), /#waysChoices \.choiceChip/);
|
||||
});
|
||||
});
|
||||
+1131
-26
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user