diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index ea2314b..37a7ce8 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -580,7 +580,17 @@ -
+@* No aria-live here any more. This element's entire contents are rebuilt on every + action, so a live region on it announced the whole bar every time — measured, after + one tap: "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U + Fold Call $10 Raise". That is what a screen reader read out after every single fold. + A live region has to be a stable element whose *text* changes; the one below is. *@ + + +@* Whose turn it is, in a sentence. Empty in the markup and only ever written to, so + assistive tech is already watching it when the first update lands — a live region + created at the moment it has something to say is one the screen reader misses. *@ + @@ -607,8 +617,31 @@ @section Styles { + + @* Installable, and works with no signal — see the service worker registration at the + foot of main.js. A dealer running a night in somebody's back room is on the worst + network there is, and the tool being a *website* was the last thing that could + take it away from them mid-game. *@ + + @* iOS reads none of the manifest's icons and only this. *@ + + + + + @* Paints the phone's chrome the same near-black as the felt's ground, so an + installed copy doesn't sit under a white status bar. *@ + } +@* A note for whoever reads the ?v= below and assumes it works: it doesn't. ViewData + ["cssVersion"] is assigned in _Layout's own code block, which runs after these + sections are handed their values, so every one of these renders as a bare "?v=" — + on this page and on Chess, Particles and Memory Lane too. It is inert rather than + broken: Program.cs sends `Cache-Control: no-store` on every static file, so nothing + is being browser-cached for a version string to bust in the first place. + Left alone deliberately. Fixing it is a one-line change to a file every page on the + site shares, and the service worker was the reason to care — that now uses stale- + while-revalidate, which needs no version to stay current. *@ @section Scripts { diff --git a/JoshHeaps.Net/Program.cs b/JoshHeaps.Net/Program.cs index cd006d7..15e164a 100644 --- a/JoshHeaps.Net/Program.cs +++ b/JoshHeaps.Net/Program.cs @@ -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,16 @@ if (!app.Environment.IsDevelopment()) app.UseHttpsRedirection(); +// .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"); diff --git a/JoshHeaps.Net/wwwroot/casino/holdem.webmanifest b/JoshHeaps.Net/wwwroot/casino/holdem.webmanifest new file mode 100644 index 0000000..8128c45 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/casino/holdem.webmanifest @@ -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" + } + ] +} diff --git a/JoshHeaps.Net/wwwroot/casino/sw.js b/JoshHeaps.Net/wwwroot/casino/sw.js new file mode 100644 index 0000000..1d2e0b5 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/casino/sw.js @@ -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); + })()); +}); diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index feec269..499b7c8 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -90,6 +90,21 @@ input[type="number"] { font-variant-numeric: tabular-nums; } +/* Read aloud, never drawn. `display: none` and `visibility: hidden` both take an + element out of the accessibility tree along with the layout, which would silence the + live region entirely — so it stays in the tree at 1px, clipped. */ +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + /* Hold the scrollbar's width whether or not there is one to show. A panel opening far enough to make the page scrollable used to narrow the viewport by the scrollbar's width, which re-centres the felt and nudges every line of text left. No effect on diff --git a/JoshHeaps.Net/wwwroot/images/casino/holdem-192.png b/JoshHeaps.Net/wwwroot/images/casino/holdem-192.png new file mode 100644 index 0000000..79c951e Binary files /dev/null and b/JoshHeaps.Net/wwwroot/images/casino/holdem-192.png differ diff --git a/JoshHeaps.Net/wwwroot/images/casino/holdem-512.png b/JoshHeaps.Net/wwwroot/images/casino/holdem-512.png new file mode 100644 index 0000000..7faac39 Binary files /dev/null and b/JoshHeaps.Net/wwwroot/images/casino/holdem-512.png differ diff --git a/JoshHeaps.Net/wwwroot/images/casino/holdem-apple-touch.png b/JoshHeaps.Net/wwwroot/images/casino/holdem-apple-touch.png new file mode 100644 index 0000000..a0898b1 Binary files /dev/null and b/JoshHeaps.Net/wwwroot/images/casino/holdem-apple-touch.png differ diff --git a/JoshHeaps.Net/wwwroot/images/casino/holdem-maskable-512.png b/JoshHeaps.Net/wwwroot/images/casino/holdem-maskable-512.png new file mode 100644 index 0000000..c92edfd Binary files /dev/null and b/JoshHeaps.Net/wwwroot/images/casino/holdem-maskable-512.png differ diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 05957cc..d635143 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -142,6 +142,52 @@ const ActionBar = { } this.syncTankTimer(); + this.announce(); + }, + + // One sentence, for anyone running this by ear. + // + // The bar used to carry aria-live itself, and because render() rebuilds the whole + // thing every action, a screen reader read the lot out after every tap — street, + // pot, player count, who's short, the clock, the undo label and all three buttons. + // Nothing in that is wrong; it is just unusable at the speed a hand is dealt. + // + // What a dealer actually needs from the tool between taps is who is on the clock + // and what it costs them. That is what this says, into a small element that exists + // from page load and only ever has its text replaced. + // + // Guarded on the sentence itself rather than on the state, because render() is + // called for a great many reasons that don't change whose turn it is — resizing the + // window, opening a panel, editing a note — and a live region that repeats itself + // is read out again every time. + announce() { + const target = document.getElementById("actionAnnouncer"); + if (!target) return; + + const said = this.announcement(); + if (said === this.lastAnnouncement) return; + this.lastAnnouncement = said; + target.textContent = said; + }, + + announcement() { + const state = this.state; + if (!state.bettingOpen) { + const preview = Betting.blindPreview(state); + return preview ? "Ready to deal." : ""; + } + if (Betting.isHandOver(state)) return "Hand over. Resolve the pot."; + + const index = state.actingSeatIndex; + if (index === null || index === undefined) { + return `${Betting.streetLabel(state.street)} betting is done.`; + } + + const player = state.seats[index]; + const name = HoldemState.displayName(player, index); + const owed = Betting.toCall(state, player); + const cost = owed > 0 ? `${Money.format(owed)} to call` : "checked to them"; + return `${name} to act, ${cost}. Pot ${Money.format(Betting.potSoFar(state))}.`; }, renderStatus() { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index f5b0a34..cb60916 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -863,4 +863,51 @@ document.addEventListener("DOMContentLoaded", () => { "The saved table couldn't be read, so this one started empty.", { duration: 12000 }); } + + // ---- 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 registerWorker = () => { + if (!("serviceWorker" in navigator)) return; + + const shell = [ + location.pathname, + "/casino/holdem.webmanifest", + ...[...document.querySelectorAll('link[rel="stylesheet"][href]')].map(el => el.href), + ...[...document.querySelectorAll("script[src]")].map(el => el.src), + ].filter(url => new URL(url, location.origin).origin === location.origin); + + navigator.serviceWorker.register("/casino/sw.js", { scope: "/casino/" }) + .then(reg => { + // The worker that will answer for this page — `controller` is still null + // on the very first visit, before activate has claimed it. + const worker = reg.active || navigator.serviceWorker.controller; + if (worker) { + worker.postMessage({ type: "precache", urls: shell }); + return; + } + navigator.serviceWorker.ready.then(ready => { + if (ready.active) ready.active.postMessage({ type: "precache", urls: shell }); + }); + }) + .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 }); }); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 15d5daf..38f561c 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -386,11 +386,53 @@ const Render = { return HoldemState.displayName(player, seatIndex); }, + // Builds the pucks once and keeps them. Everything below this line updates the + // nodes that are already on the felt. + // + // A seat's node belongs to its index for as long as the table is that size, so the + // listeners can close over the index the way they always did. Only a change in the + // number of chairs throws the row away. + buildSeatNodes(layer, count) { + const template = document.getElementById("seatTemplate"); + const nodes = []; + for (let index = 0; index < count; index++) { + const node = template.content.firstElementChild.cloneNode(true); + node.dataset.seatIndex = String(index); + node.querySelector(".seatChairNumber").textContent = index + 1; + + // Tapping the puck is the same act as "Give the button" in the seat menu, + // so it goes through the same hook — otherwise this one path would move + // the button without moving the blinds that follow it. + node.querySelector(".dealerChip").addEventListener("click", e => { + e.stopPropagation(); + this.hooks.onGiveButton(index); + }); + + this.wireSeatGestures(node, index); + this.wireDragAndDrop(node, index); + nodes.push(node); + } + layer.replaceChildren(...nodes); + }, + + // Drawing the seats used to mean throwing all of them away and building them again: + // `innerHTML = ""`, then a template clone, three sets of event listeners and a + // dozen attributes per chair, on every render. Nine seats is ninety nodes, and + // renderAll() runs after every single tap a dealer makes. + // + // Measured on a nine-handed table: renderAll was 12.4ms, of which this function was + // 7.3. That is most of a frame gone before the browser has drawn anything, on a + // desktop — a phone is several times slower, which is where the tool actually runs + // and where a dealer's tap felt like it landed a moment late. + // + // Nothing about what gets drawn changes here. The pucks are built once and then + // updated in place, which is what the rest of this function was already doing to + // their contents anyway. renderSeats() { const layer = document.getElementById("seatsLayer"); - layer.innerHTML = ""; + const count = this.state.seats.length; + if (layer.childElementCount !== count) this.buildSeatNodes(layer, count); this.updateSeatDensity(); - const template = document.getElementById("seatTemplate"); const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state); // A button on an empty chair normally means the table has drifted and wants // fixing. Once it's the dead button it means the opposite — it's exactly where @@ -400,11 +442,11 @@ const Render = { !this.state.seats[dealerIndex].inHand; this.state.seats.forEach((player, index) => { - const node = template.content.firstElementChild.cloneNode(true); + const node = layer.children[index]; + if (!node) return; const pos = this.seatPosition(index, this.state.seatCount); node.style.left = `${pos.left}%`; node.style.top = `${pos.top}%`; - node.dataset.seatIndex = String(index); // Which side of the seat the pot is on, which is the side its bet chip // arrives from — the chip sits on the same spoke, closer in. For a seat on @@ -416,7 +458,8 @@ const Render = { // telling the dealer who the big blind is was simply gone. node.classList.toggle("seatBelowCentre", pos.top > 50); - node.querySelector(".seatChairNumber").textContent = index + 1; + // The chair number is set once, in buildSeatNodes — a seat's index is the + // one thing about it that cannot change while the node exists. const dealerChip = node.querySelector(".dealerChip"); const hasButton = index === dealerIndex; @@ -440,13 +483,6 @@ const Render = { : `Give the dealer button to ${who}`; dealerChip.setAttribute("aria-label", chipLabel); dealerChip.title = chipLabel; - // Tapping the puck is the same act as "Give the button" in the seat menu, - // so it goes through the same hook — otherwise this one path would move - // the button without moving the blinds that follow it. - dealerChip.addEventListener("click", e => { - e.stopPropagation(); - this.hooks.onGiveButton(index); - }); const sbBadge = node.querySelector(".sbBadge"); const bbBadge = node.querySelector(".bbBadge"); @@ -471,11 +507,6 @@ const Render = { this.updatePuckContent(node, player, index); node.classList.toggle("seatSelected", index === this.selectedSeatIndex); - - this.wireSeatGestures(node, index); - this.wireDragAndDrop(node, index); - - layer.appendChild(node); }); this.renderBets(); diff --git a/tests/holdem/announcing.test.js b/tests/holdem/announcing.test.js new file mode 100644 index 0000000..88f7929 --- /dev/null +++ b/tests/holdem/announcing.test.js @@ -0,0 +1,125 @@ +// 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 nothing at all", () => { + const state = HoldemState.createDefault(); + assert.equal(barOn(state).announcement(), ""); + }); +}); + +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; + }); +});