Be there when the signal, the screen and the storage are not
Three failures that only ever happen at a table, none of which show up on a desk with a good connection and a plugged-in laptop. The offline promise depended on how the URL was spelled. Razor Pages routing is case-insensitive, so /Casino/TexasHoldem serves the identical page; a service worker's scope is matched as a plain string prefix, so a worker scoped to /casino/ does not control it. The registration succeeded, the worker activated, nothing was logged, and the cache stayed completely empty -- because the precache list was handed to navigator.serviceWorker.ready, which resolves with the registration controlling the page and so never resolves at all outside its scope. A dealer who opened that URL to load the tool for the night had loaded nothing. One spelling wins now, via a 301 scoped to /casino rather than applied site-wide: every file under wwwroot/casino and wwwroot/images/casino is already lowercase, so lowercasing there cannot miss a real file on a case-sensitive filesystem. That guarantee does not hold for the rest of the site, and /chessHub would break. The handshake is fixed rather than left to be saved by the redirect -- it takes the worker off the registration itself. The manifest icons were never cached, because the shell list is read off the DOM and the DOM never mentions them. They are read out of the manifest now, cache-first so an offline launch stops logging ERR_INTERNET_DISCONNECTED, and the link sweep picks up apple-touch-icon, which is the only one iOS reads. The screen slept mid-hand. A wake lock is held while betting is open and dropped the moment it is not: through a whole street of renders it takes exactly one, two syncs in a tick take exactly one, a hand that ends inside the request leaves nothing held, and the browser taking it back leaves us able to take it again. Announced once with a Turn off button rather than a permanent header indicator; a setting after that. And localStorage is evictable, which the Save & reset panel now says out loud. persist() is asked for on Post blinds & deal, because every browser wants that request to come from something the dealer did. 885 tests pass, 18 of them new. Five fault injections into the wake lock are each caught. One of the new tests was green for the wrong reason first: globalThis.navigator = fake does nothing, because Node has defined navigator as a getter since v21. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -621,6 +621,117 @@ still names whoever didn't turn over; closing it leaves **Showdown — resolve p
|
||||
bar; and pressing it again resumes at the player the dealer stopped on, because the list
|
||||
is computed from the cards on the table rather than remembered from the last attempt.
|
||||
|
||||
## The failures that only happen at the table
|
||||
|
||||
Everything above this line is about a tool being easy to read and quick to press. This
|
||||
section is about it being *there* — the three ways it could let a dealer down on the
|
||||
night that no amount of layout work would have caught, because none of them show up on a
|
||||
developer's desk with a good connection and a plugged-in laptop.
|
||||
|
||||
### One spelling of the URL, because the offline promise depends on it
|
||||
|
||||
The manifest says "Works with no signal", and it did — at `/casino/texasholdem`.
|
||||
|
||||
Razor Pages routing is case-insensitive, so `/Casino/TexasHoldem` serves the identical
|
||||
page. 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 on
|
||||
the capital-C spelling, with the network cut:
|
||||
|
||||
| | registration | controlling | cached | offline |
|
||||
|---|---|---|---|---|
|
||||
| `/casino/texasholdem` | created | yes | 25 entries | loads |
|
||||
| `/Casino/TexasHoldem` | created, active | **no** | **nothing at all** | `ERR_INTERNET_DISCONNECTED` |
|
||||
|
||||
The empty cache is the part that matters, and it took a second look to understand. The
|
||||
registration *succeeded*. The worker *activated*. Nothing was logged. But `registerWorker`
|
||||
handed its precache list to `navigator.serviceWorker.ready`, and `ready` resolves with the
|
||||
registration **controlling this page** — so on a page outside the scope it never resolves,
|
||||
the message is never sent, and not one file is stored. A dealer who had opened that URL to
|
||||
"load the tool for tonight" had loaded nothing, and had no way to find out until the
|
||||
signal went.
|
||||
|
||||
So one spelling wins: a permanent redirect in `Program.cs`, scoped to `/casino` rather
|
||||
than applied site-wide, because every file under `wwwroot/casino` and
|
||||
`wwwroot/images/casino` is already lowercase and so lowercasing a path there cannot miss a
|
||||
real file on a case-sensitive filesystem. That guarantee does not hold for the rest of the
|
||||
site, and `/chessHub` in particular would break.
|
||||
|
||||
The precache handshake was fixed too, rather than left to be saved by the redirect. It
|
||||
now takes the worker off the registration itself — `installing || waiting || active` —
|
||||
and waits for `activated` on that. A tool whose offline support silently depends on being
|
||||
in scope is one URL away from having none.
|
||||
|
||||
### And what the document never mentions
|
||||
|
||||
The shell list is read off the DOM on purpose, so it can't drift from the markup. The
|
||||
icons are the one part of the shell the DOM doesn't mention: the browser reads them out of
|
||||
the manifest, so no tag in the document points at them and the sweep missed all four. An
|
||||
installed app launched with no signal had no icon to show for itself.
|
||||
|
||||
They're read out of the manifest now — not typed into a list here, for exactly the same
|
||||
reason. iOS is the exception: it reads none of the manifest's icons and only
|
||||
`<link rel="apple-touch-icon">`, which *is* in the document, so the sweep picks up the
|
||||
stylesheet, icon and apple-touch links together. Twenty-five entries, all four icons.
|
||||
|
||||
Reading the manifest offline is deliberately cache-first, which is backwards from how the
|
||||
rest of the worker thinks. A plain `fetch` for it failed on every offline launch and
|
||||
logged `ERR_INTERNET_DISCONNECTED` — noise on the one screen where a real error has to be
|
||||
visible, in exchange for re-reading four paths that were already cached.
|
||||
|
||||
### A lock is easy to take and easy to leave holding
|
||||
|
||||
A dealer's phone lies face-up on the felt all night, and dims and locks between the flop
|
||||
and the turn every hand. The Screen Wake Lock API fixes that in about four lines, and the
|
||||
risk runs entirely the other way: a lock that is taken and *not* dropped burns the battery
|
||||
through a break, a re-buy, and twenty minutes of arguing about pizza, with nothing on
|
||||
screen to say why.
|
||||
|
||||
So the rule is narrow — held only while betting is actually open — and the tests are
|
||||
mostly about letting go:
|
||||
|
||||
- **A whole street of renders takes exactly one.** `sync()` runs off `renderAll`, which
|
||||
fires many times a second while a street is being bet.
|
||||
- **Two syncs in the same tick take exactly one.** Without the `pending` guard both get
|
||||
past the "already holding one?" check and the second sentinel is never stored, so
|
||||
nothing can release it.
|
||||
- **A hand that ends inside the request leaves nothing held.** A fold can end the hand
|
||||
between asking for a lock and being handed one, and the sentinel that arrives afterwards
|
||||
belongs to a hand that no longer exists.
|
||||
- **The browser taking it back leaves us able to take it again.** This is not an edge
|
||||
case: the browser releases the lock itself every time the document stops being visible,
|
||||
and never gives it back. A tool that takes one at the start of the hand and assumes it
|
||||
still has it works until the dealer checks a rule on their phone.
|
||||
|
||||
#### A fake that wasn't installed, and four tests that agreed
|
||||
|
||||
The first run of these had eight failures and, worse, one pass: "a browser without the API
|
||||
is left alone entirely" was green from the start.
|
||||
|
||||
`globalThis.navigator = fake` does nothing. Node has had its own `navigator` since v21,
|
||||
defined as a getter, so the assignment silently fails — no error, no effect. Every test in
|
||||
the file was measuring Node's real navigator, which has no `wakeLock`, so the acquire
|
||||
tests failed honestly and the unsupported-browser test passed for precisely the wrong
|
||||
reason. `Object.defineProperty` installs it properly.
|
||||
|
||||
Then five faults were injected and all five were caught: dropping the `pending` guard,
|
||||
keeping a lock the hand no longer wants, ignoring the browser's own release, holding it
|
||||
between hands, and reading a missing `keepScreenAwake` as off instead of on.
|
||||
|
||||
### Asking the browser to keep the night
|
||||
|
||||
"The night lives in this browser and nowhere else" was always true and always missing its
|
||||
second half. `localStorage` is evictable: a browser is free to clear site data when the
|
||||
device runs short, and Safari deletes script-created storage after seven days without a
|
||||
visit. 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.
|
||||
|
||||
`navigator.storage.persist()` is the way to say this data is not that, and every browser
|
||||
wants the request to come from something the dealer did — so it fires on **Post blinds &
|
||||
deal**, the moment this stops being a setup screen and starts being a night somebody could
|
||||
lose. Chromium and Safari answer silently on their own engagement heuristics; Firefox
|
||||
asks. A no is not an error worth reporting at a table, so the only thing that changes is
|
||||
how confident Save & reset is allowed to sound, and whether it offers the ask as a button.
|
||||
|
||||
## Why the hand evaluator is tested the way it is
|
||||
|
||||
`handRank.js` is the only module here that can pay the wrong person without anybody
|
||||
|
||||
@@ -38,6 +38,11 @@ const ActionBar = load("actionBar");
|
||||
// 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");
|
||||
|
||||
// ---- Table builders ----------------------------------------------------------
|
||||
|
||||
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
|
||||
@@ -93,6 +98,7 @@ module.exports = {
|
||||
HoldemState,
|
||||
ActionBar,
|
||||
CardPicker,
|
||||
WakeLock,
|
||||
makeTable,
|
||||
chipsOnTable,
|
||||
contributions,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user