Compare commits
110
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 | ||
|
|
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
|
||||
@@ -408,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
|
||||
@@ -433,6 +603,10 @@ 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.
|
||||
@@ -475,6 +649,7 @@ const Betting = {
|
||||
state.currentBet = 0;
|
||||
state.actingSeatIndex = null;
|
||||
state.postedBlinds = null;
|
||||
state.postedStraddles = [];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -505,17 +680,71 @@ 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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -598,6 +989,72 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
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.
|
||||
@@ -629,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,
|
||||
@@ -100,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.
|
||||
@@ -164,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;
|
||||
@@ -188,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
|
||||
@@ -196,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
|
||||
@@ -215,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,
|
||||
@@ -274,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
|
||||
@@ -565,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 = ["", ""];
|
||||
@@ -577,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).
|
||||
@@ -584,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) => ({
|
||||
@@ -603,6 +846,7 @@ const HoldemState = {
|
||||
handNumber: state.handNumber,
|
||||
timestamp: Date.now(),
|
||||
pot,
|
||||
rake: taken,
|
||||
payouts,
|
||||
ledger,
|
||||
});
|
||||
@@ -638,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
|
||||
@@ -649,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;
|
||||
@@ -659,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 —
|
||||
@@ -683,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,
|
||||
};
|
||||
@@ -715,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
|
||||
@@ -734,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,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,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
@@ -0,0 +1,167 @@
|
||||
// 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. Order of arrival, because it is the only ordering anybody has
|
||||
// ever agreed to.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState } = require("./load");
|
||||
|
||||
const table = (seated = 2, seats = 4) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, seats);
|
||||
["Ann", "Ben", "Cal", "Dee"].slice(0, seated).forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].stack = 200;
|
||||
state.seats[i].buyIns = 200;
|
||||
});
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("keeping the list", () => {
|
||||
test("names go on in the order they arrive", () => {
|
||||
const state = table();
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
HoldemState.addToWaitingList(state, "Sam");
|
||||
assert.deepEqual(state.waitingList.map(row => row.name), ["Priya", "Sam"]);
|
||||
});
|
||||
|
||||
test("whitespace is trimmed and nothing empty gets on", () => {
|
||||
const state = table();
|
||||
assert.ok(HoldemState.addToWaitingList(state, " Priya "));
|
||||
assert.equal(HoldemState.addToWaitingList(state, " "), null);
|
||||
assert.equal(HoldemState.addToWaitingList(state, ""), null);
|
||||
assert.deepEqual(state.waitingList.map(row => row.name), ["Priya"]);
|
||||
});
|
||||
|
||||
test("two people with the same name are two people", () => {
|
||||
// Home games have two Mikes. The list is keyed by id, not by name.
|
||||
const state = table();
|
||||
HoldemState.addToWaitingList(state, "Mike");
|
||||
HoldemState.addToWaitingList(state, "Mike");
|
||||
assert.equal(state.waitingList.length, 2);
|
||||
assert.notEqual(state.waitingList[0].id, state.waitingList[1].id);
|
||||
|
||||
HoldemState.removeFromWaitingList(state, state.waitingList[0].id);
|
||||
assert.equal(state.waitingList.length, 1, "only one of them leaves");
|
||||
});
|
||||
|
||||
test("taking somebody off gives them back, so the toast can name them", () => {
|
||||
const state = table();
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
const gone = HoldemState.removeFromWaitingList(state, state.waitingList[0].id);
|
||||
assert.equal(gone.name, "Priya");
|
||||
assert.deepEqual(state.waitingList, []);
|
||||
assert.equal(HoldemState.removeFromWaitingList(state, "not-a-real-id"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("which chairs are open", () => {
|
||||
test("the ones with nobody in them, in seat order", () => {
|
||||
assert.deepEqual(HoldemState.openSeats(table(2, 4)), [2, 3]);
|
||||
assert.deepEqual(HoldemState.openSeats(table(4, 4)), []);
|
||||
});
|
||||
|
||||
test("a chair with chips or a buy-in on it is not open", () => {
|
||||
// Somebody who has stepped away from the table still owns their seat.
|
||||
const state = table(2, 4);
|
||||
state.seats[2].stack = 120;
|
||||
state.seats[3].buyIns = 100;
|
||||
assert.deepEqual(HoldemState.openSeats(state), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sitting somebody down", () => {
|
||||
test("their name goes on the chair and they come off the list", () => {
|
||||
const state = table();
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
HoldemState.addToWaitingList(state, "Sam");
|
||||
const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 2);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(state.seats[2].name, "Priya");
|
||||
assert.deepEqual(state.waitingList.map(row => row.name), ["Sam"]);
|
||||
});
|
||||
|
||||
test("with nothing in front of them, because they haven't bought in yet", () => {
|
||||
// Guessing a stack would put chips on the table nobody has paid for, which is
|
||||
// the one thing the bank check exists to catch.
|
||||
const state = table();
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 2);
|
||||
|
||||
assert.equal(state.seats[2].stack, null);
|
||||
assert.equal(state.seats[2].buyIns, 0);
|
||||
assert.equal(state.seats[2].inHand, false, "they're in the next hand, not this one");
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("never into a chair somebody is already in", () => {
|
||||
const state = table();
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 0);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /Ann is already/);
|
||||
assert.equal(state.waitingList.length, 1, "and they keep their place in the queue");
|
||||
});
|
||||
|
||||
test("nor from a name that has already been seated", () => {
|
||||
const state = table();
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
const id = state.waitingList[0].id;
|
||||
HoldemState.seatFromWaitingList(state, id, 2);
|
||||
const again = HoldemState.seatFromWaitingList(state, id, 3);
|
||||
assert.equal(again.ok, false);
|
||||
assert.equal(state.seats[3].name, "", "the second chair is untouched");
|
||||
});
|
||||
|
||||
test("a seat that doesn't exist is refused rather than invented", () => {
|
||||
const state = table();
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
assert.equal(HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 99).ok, false);
|
||||
assert.equal(state.waitingList.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a seat opening up", () => {
|
||||
test("cashing somebody out is what makes room for the next one", () => {
|
||||
const state = table(4, 4);
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
assert.deepEqual(HoldemState.openSeats(state), [], "the table is full");
|
||||
|
||||
HoldemState.cashOut(state, 1);
|
||||
assert.deepEqual(HoldemState.openSeats(state), [1]);
|
||||
|
||||
const seated = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 1);
|
||||
assert.equal(seated.ok, true);
|
||||
assert.equal(state.seats[1].name, "Priya");
|
||||
// Ben's night is still on the record, and the money still adds up.
|
||||
assert.equal(state.cashedOut.length, 1);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a saved table remembers", () => {
|
||||
test("the list survives a reload", () => {
|
||||
const state = table();
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
const migrated = HoldemState.migrate(JSON.parse(JSON.stringify(state)));
|
||||
assert.deepEqual(migrated.waitingList.map(row => row.name), ["Priya"]);
|
||||
});
|
||||
|
||||
test("a table saved before the list existed has nobody waiting", () => {
|
||||
const parsed = JSON.parse(JSON.stringify(table()));
|
||||
delete parsed.waitingList;
|
||||
assert.deepEqual(HoldemState.migrate(parsed).waitingList, []);
|
||||
});
|
||||
|
||||
test("junk in a saved list is cleaned up rather than trusted", () => {
|
||||
const parsed = JSON.parse(JSON.stringify(table()));
|
||||
parsed.waitingList = [{ name: " Priya " }, { name: "" }, null, "Sam"];
|
||||
const migrated = HoldemState.migrate(parsed);
|
||||
assert.deepEqual(migrated.waitingList.map(row => row.name), ["Priya", "Sam"]);
|
||||
assert.ok(migrated.waitingList.every(row => row.id), "and everybody gets an id");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
// When the tool asks the phone to stay awake, and — the part that actually matters —
|
||||
// when it lets go again.
|
||||
//
|
||||
// A dealer's phone lies face-up on the felt all night. Left alone it dims and locks
|
||||
// between the flop and the turn, every hand, and a dozen rounds of making the next step
|
||||
// readable at a glance count for nothing behind a lock screen.
|
||||
//
|
||||
// The risk runs the other way too, and it is the one worth testing. A wake lock that is
|
||||
// taken and not dropped is a phone that burns its battery through a break, a re-buy, and
|
||||
// the twenty minutes everyone spends arguing about pizza — and the dealer has no way to
|
||||
// tell that is what is happening. So the rule is narrow: held only while betting is
|
||||
// actually open, and only while the dealer wants it.
|
||||
//
|
||||
// The browser side of this is a stand-in below rather than a real navigator, which lets
|
||||
// the tests ask the questions a real table asks: what happens when the hand ends inside
|
||||
// the request, what happens when the phone takes the lock back on its own, and what
|
||||
// happens when the browser simply says no.
|
||||
const test = require("node:test");
|
||||
const { describe, beforeEach, afterEach } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { WakeLock, HoldemState, makeTable } = require("./load");
|
||||
|
||||
// A stand-in for navigator.wakeLock that records what was asked of it. `deny` makes
|
||||
// every request reject, the way a phone in battery saver does.
|
||||
const fakeWakeLock = ({ deny = false } = {}) => {
|
||||
const api = {
|
||||
requests: 0,
|
||||
released: 0,
|
||||
live: [],
|
||||
deny,
|
||||
wakeLock: {
|
||||
request: async () => {
|
||||
api.requests += 1;
|
||||
if (api.deny) throw new Error("denied");
|
||||
const listeners = [];
|
||||
const sentinel = {
|
||||
released: false,
|
||||
addEventListener: (name, fn) => { if (name === "release") listeners.push(fn); },
|
||||
release: async () => {
|
||||
if (sentinel.released) return;
|
||||
sentinel.released = true;
|
||||
api.released += 1;
|
||||
listeners.forEach(fn => fn());
|
||||
},
|
||||
// What the browser does on its own when the tab stops being visible.
|
||||
dropFromOutside: () => {
|
||||
sentinel.released = true;
|
||||
listeners.forEach(fn => fn());
|
||||
},
|
||||
};
|
||||
api.live.push(sentinel);
|
||||
return sentinel;
|
||||
},
|
||||
},
|
||||
};
|
||||
return api;
|
||||
};
|
||||
|
||||
// Settles the promises `sync()` deliberately doesn't return — it is called from
|
||||
// renderAll, which is synchronous, so the acquiring happens off to one side.
|
||||
const settle = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
// Node has had its own `globalThis.navigator` since v21, defined as a getter — so a
|
||||
// plain assignment to it does nothing at all and throws nothing either. Every test in
|
||||
// here that thought it had installed a fake would have been quietly measuring Node's
|
||||
// real navigator, which has no wakeLock, and passing for the wrong reason.
|
||||
const setNavigator = value => {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value, configurable: true, writable: true,
|
||||
});
|
||||
};
|
||||
|
||||
let realNavigator;
|
||||
|
||||
beforeEach(() => {
|
||||
realNavigator = globalThis.navigator;
|
||||
WakeLock.sentinel = null;
|
||||
WakeLock.wanted = false;
|
||||
WakeLock.pending = false;
|
||||
WakeLock.told = true; // The toast is DOM; it is checked in the browser.
|
||||
WakeLock.hooks = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setNavigator(realNavigator);
|
||||
});
|
||||
|
||||
const useFake = api => {
|
||||
setNavigator(api);
|
||||
return api;
|
||||
};
|
||||
|
||||
// A table mid-hand, which is the only state that wants the screen.
|
||||
const dealtIn = () => {
|
||||
const state = makeTable({ seatCount: 4, stack: 200 });
|
||||
state.bettingOpen = true;
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("the rule about when a table needs the screen", () => {
|
||||
test("a hand in progress does", () => {
|
||||
assert.equal(WakeLock.shouldHold(dealtIn()), true);
|
||||
});
|
||||
|
||||
test("a table between hands does not", () => {
|
||||
const state = dealtIn();
|
||||
state.bettingOpen = false;
|
||||
assert.equal(WakeLock.shouldHold(state), false);
|
||||
});
|
||||
|
||||
test("a dealer who turned it off does not, mid-hand or otherwise", () => {
|
||||
const state = dealtIn();
|
||||
state.keepScreenAwake = false;
|
||||
assert.equal(WakeLock.shouldHold(state), false);
|
||||
});
|
||||
|
||||
test("no table at all does not", () => {
|
||||
assert.equal(WakeLock.shouldHold(null), false);
|
||||
assert.equal(WakeLock.shouldHold(undefined), false);
|
||||
});
|
||||
|
||||
test("a table that has never heard of the setting still does", () => {
|
||||
// Every saved table from before this existed. The migration fills it in, but the
|
||||
// rule itself has to read a missing value as yes or the first hand after an
|
||||
// update runs without it.
|
||||
const state = dealtIn();
|
||||
delete state.keepScreenAwake;
|
||||
assert.equal(WakeLock.shouldHold(state), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("taking the lock and giving it back", () => {
|
||||
test("a hand starting takes one", async () => {
|
||||
const api = useFake(fakeWakeLock());
|
||||
WakeLock.sync(dealtIn());
|
||||
await settle();
|
||||
assert.equal(api.requests, 1);
|
||||
assert.ok(WakeLock.sentinel);
|
||||
});
|
||||
|
||||
test("the hand ending gives it back", async () => {
|
||||
const api = useFake(fakeWakeLock());
|
||||
const state = dealtIn();
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
|
||||
state.bettingOpen = false;
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
|
||||
assert.equal(api.released, 1);
|
||||
assert.equal(WakeLock.sentinel, null);
|
||||
});
|
||||
|
||||
test("turning the setting off mid-hand gives it back", async () => {
|
||||
const api = useFake(fakeWakeLock());
|
||||
const state = dealtIn();
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
|
||||
state.keepScreenAwake = false;
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
|
||||
assert.equal(api.released, 1);
|
||||
});
|
||||
|
||||
test("a whole street of renders takes exactly one", async () => {
|
||||
// sync() runs off renderAll, which fires many times a second while a street is
|
||||
// being bet. Every one of those must be a no-op, or the tool leaks a sentinel
|
||||
// per render and the screen stays lit long after the hand is over.
|
||||
const api = useFake(fakeWakeLock());
|
||||
const state = dealtIn();
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
}
|
||||
assert.equal(api.requests, 1);
|
||||
assert.equal(api.released, 0);
|
||||
});
|
||||
|
||||
test("two syncs in the same tick still take exactly one", async () => {
|
||||
// The `pending` guard. Without it both calls get past the "already holding one?"
|
||||
// check, and the second sentinel is never stored — so nothing can release it and
|
||||
// the screen stays awake for the rest of the night.
|
||||
const api = useFake(fakeWakeLock());
|
||||
const state = dealtIn();
|
||||
WakeLock.sync(state);
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(api.requests, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the awkward moments", () => {
|
||||
test("a hand that ends while the request is in flight leaves nothing held", async () => {
|
||||
// A fold can end the hand between asking for the lock and being given it. The
|
||||
// sentinel that arrives afterwards belongs to a hand that no longer exists.
|
||||
const api = useFake(fakeWakeLock());
|
||||
const state = dealtIn();
|
||||
WakeLock.sync(state);
|
||||
|
||||
state.bettingOpen = false;
|
||||
WakeLock.sync(state);
|
||||
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
assert.equal(WakeLock.sentinel, null, "nothing should still be held");
|
||||
assert.equal(api.released, 1, "the one that arrived late should have been let go");
|
||||
});
|
||||
|
||||
test("the browser taking it back leaves us able to take it again", async () => {
|
||||
// What happens every time the dealer switches apps to check a rule. The browser
|
||||
// releases the lock itself and never gives it back; if we go on believing we
|
||||
// hold one, the screen sleeps for the rest of the night.
|
||||
const api = useFake(fakeWakeLock());
|
||||
const state = dealtIn();
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
|
||||
api.live[0].dropFromOutside();
|
||||
assert.equal(WakeLock.sentinel, null, "the release listener should have cleared it");
|
||||
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
assert.equal(api.requests, 2, "coming back to the tool should take a fresh one");
|
||||
});
|
||||
|
||||
test("a browser that says no is not an error", async () => {
|
||||
const api = useFake(fakeWakeLock({ deny: true }));
|
||||
const state = dealtIn();
|
||||
await assert.doesNotReject(async () => {
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
});
|
||||
assert.equal(api.requests, 1);
|
||||
assert.equal(WakeLock.sentinel, null);
|
||||
});
|
||||
|
||||
test("a browser without the API is left alone entirely", async () => {
|
||||
useFake({});
|
||||
assert.equal(WakeLock.supported(), false);
|
||||
const state = dealtIn();
|
||||
await assert.doesNotReject(async () => {
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
});
|
||||
assert.equal(WakeLock.wanted, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a saved table remembers about the screen", () => {
|
||||
test("a new table keeps the screen on", () => {
|
||||
assert.equal(HoldemState.createDefault().keepScreenAwake, true);
|
||||
});
|
||||
|
||||
test("a table saved before the setting existed keeps the screen on", () => {
|
||||
const saved = HoldemState.createDefault();
|
||||
delete saved.keepScreenAwake;
|
||||
assert.equal(HoldemState.migrate(saved).keepScreenAwake, true);
|
||||
});
|
||||
|
||||
test("a dealer who turned it off still has it off after a reload", () => {
|
||||
const saved = HoldemState.createDefault();
|
||||
saved.keepScreenAwake = false;
|
||||
assert.equal(HoldemState.migrate(saved).keepScreenAwake, false);
|
||||
});
|
||||
|
||||
test("junk in that field is read as on rather than trusted", () => {
|
||||
const saved = HoldemState.createDefault();
|
||||
saved.keepScreenAwake = "yes please";
|
||||
assert.equal(HoldemState.migrate(saved).keepScreenAwake, true);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user