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