Files
jheapsandClaude Opus 5 e2bb935b43 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
2026-09-09 04:31:45 -06:00

277 lines
10 KiB
JavaScript

// 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);
});
});