diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 787d8c5..a68e329 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -571,4 +571,14 @@ document.addEventListener("DOMContentLoaded", () => { if ((state.handHistory || []).length === 0) return; HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state)); }); + + // The load happens on the first line of this file, long before there's a Toast to + // show it in, so this is checked here rather than wired as a callback. The standing + // warning in the Session panel doesn't need it — that reads the flag every render — + // but an empty table is a thing a dealer needs told, not left to notice. + if (HoldemState.loadFailed) { + Toast.show( + "The saved table couldn't be read, so this one started empty.", + { duration: 12000 }); + } }); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 8d57b9e..068e8f0 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -761,11 +761,24 @@ const Render = { renderStorageWarning() { const host = document.getElementById("storageWarning"); if (!host) return; - host.hidden = !HoldemState.saveFailed; + host.hidden = !HoldemState.saveFailed && !HoldemState.loadFailed; if (host.hidden) return; - host.textContent = - "This table isn't being saved — the browser refused. It'll keep working, " + - "but closing or refreshing this tab loses the session. Export CSV below keeps a copy."; + + // Both can be true at once — a browser having trouble with storage tends to + // have it in both directions — and the one that explains an empty table goes + // first, because that's the question the dealer is standing there asking. + const lines = []; + if (HoldemState.loadFailed) { + lines.push( + "The saved table couldn't be read, so this one started empty. " + + "The unreadable copy has been kept rather than written over."); + } + if (HoldemState.saveFailed) { + lines.push( + "This table isn't being saved — the browser refused. It'll keep working, " + + "but closing or refreshing this tab loses the session. Export CSV below keeps a copy."); + } + host.textContent = lines.join(" "); }, // The last step the standings stopped short of. Six signed numbers at 2am is not diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index b1abcdd..97d2484 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -81,12 +81,45 @@ const HoldemState = { }; }, + // Where an unreadable save is kept instead of being written over. + RECOVERY_KEY: "casino.holdem.state.v1.unreadable", + + // True when there was a saved table and it couldn't be read. Same pair as + // saveFailed above, for the same reason: the UI has to be able to say so. + loadFailed: false, + onLoadFailed: null, + + // A saved table that won't parse becomes an empty table, which is the only thing + // it can become — but it used to become one silently, and then the first edit + // saved over the top of it. A dealer mid-session saw an empty felt with no idea + // whether they'd mis-tapped Reset or the browser had lost the night, and whatever + // a person might have picked out of the raw JSON was gone a tap later. + // + // So the unreadable value is moved aside first. It is not offered as a restore — + // half a JSON document isn't a table, and pretending otherwise would be worse than + // an empty one — but it stops being destroyed, and somebody who wants the + // standings out of it still can. + failedLoad(raw) { + if (!this.loadFailed) { + this.loadFailed = true; + try { + if (raw) localStorage.setItem(this.RECOVERY_KEY, raw); + } catch { + // No room to keep it. Nothing more to try, and the warning below is + // the part that actually matters. + } + if (typeof this.onLoadFailed === "function") this.onLoadFailed(); + } + return this.createDefault(); + }, + load() { + let raw = null; try { - const raw = localStorage.getItem(this.STORAGE_KEY); + raw = localStorage.getItem(this.STORAGE_KEY); if (!raw) return this.createDefault(); const parsed = JSON.parse(raw); - if (!parsed || !Array.isArray(parsed.seats)) return this.createDefault(); + if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw); if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1; if (!Array.isArray(parsed.handHistory)) parsed.handHistory = []; @@ -118,7 +151,8 @@ const HoldemState = { return parsed; } catch { - return this.createDefault(); + // Malformed JSON — a truncated write, another tab, a browser hiccup. + return this.failedLoad(raw); } }, diff --git a/tests/holdem/loadFailure.test.js b/tests/holdem/loadFailure.test.js new file mode 100644 index 0000000..a338ddf --- /dev/null +++ b/tests/holdem/loadFailure.test.js @@ -0,0 +1,114 @@ +// What happens when the saved table can't be read. +// +// An unreadable save can only become an empty table. What it must not do is become +// one silently and then get written over by the first edit. +const test = require("node:test"); +const assert = require("node:assert"); +const { HoldemState } = require("./load"); + +function withStored(value, { readOnly = false } = {}) { + const store = new Map(); + if (value !== undefined) store.set(HoldemState.STORAGE_KEY, value); + globalThis.localStorage = { + getItem: key => (store.has(key) ? store.get(key) : null), + setItem(key, val) { + if (readOnly) throw new Error("QuotaExceededError"); + store.set(key, val); + }, + removeItem: key => store.delete(key), + }; + return store; +} + +test.beforeEach(() => { + HoldemState.loadFailed = false; + HoldemState.onLoadFailed = null; +}); + +test.after(() => { + delete globalThis.localStorage; + HoldemState.loadFailed = false; + HoldemState.onLoadFailed = null; +}); + +test("nothing stored is a new table, not a failure", () => { + withStored(undefined); + const state = HoldemState.load(); + + assert.equal(HoldemState.loadFailed, false); + assert.ok(Array.isArray(state.seats)); +}); + +test("a good save loads and reports nothing", () => { + const store = withStored(undefined); + const original = HoldemState.createDefault(); + original.handNumber = 34; + HoldemState.save(original); + + const state = HoldemState.load(); + assert.equal(HoldemState.loadFailed, false); + assert.equal(state.handNumber, 34); + assert.equal(store.has(HoldemState.RECOVERY_KEY), false); +}); + +test("a truncated save is reported rather than silently becoming an empty table", () => { + const good = JSON.stringify(HoldemState.createDefault()); + withStored(good.slice(0, Math.floor(good.length * 0.7))); + + const state = HoldemState.load(); + assert.equal(HoldemState.loadFailed, true); + assert.ok(Array.isArray(state.seats), "it still hands back a usable table"); +}); + +test("valid JSON of the wrong shape counts as unreadable too", () => { + withStored(JSON.stringify({ hello: "world" })); + + HoldemState.load(); + assert.equal(HoldemState.loadFailed, true); +}); + +test("the unreadable copy is kept instead of being written over", () => { + // This is the half that matters: whatever a person could have picked out of the + // raw JSON used to be gone the moment the dealer touched a seat. + const broken = '{"seats":[{"name":"Ann","stack":250},{"name":"Ben"'; + const store = withStored(broken); + + HoldemState.load(); + + assert.equal(store.get(HoldemState.RECOVERY_KEY), broken); +}); + +test("a later save doesn't touch the copy that was set aside", () => { + const broken = '{"seats":[{"name":"Ann"'; + const store = withStored(broken); + + const state = HoldemState.load(); + state.handNumber = 2; + HoldemState.save(state); + + assert.equal(store.get(HoldemState.RECOVERY_KEY), broken); + assert.notEqual(store.get(HoldemState.STORAGE_KEY), broken); +}); + +test("the listener fires once, not on every read", () => { + withStored("{not json"); + let told = 0; + HoldemState.onLoadFailed = () => { told++; }; + + HoldemState.load(); + HoldemState.load(); + HoldemState.load(); + + assert.equal(told, 1); +}); + +test("no room to keep the copy is not a crash", () => { + // Storage that refuses writes is exactly the storage most likely to hand back a + // half-written value in the first place. + withStored("{not json", { readOnly: true }); + + let state; + assert.doesNotThrow(() => { state = HoldemState.load(); }); + assert.ok(Array.isArray(state.seats)); + assert.equal(HoldemState.loadFailed, true); +}); diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js index 4ecee0d..27d2d03 100644 --- a/tests/holdem/uiAudit.js +++ b/tests/holdem/uiAudit.js @@ -180,6 +180,12 @@ const LayoutAudit = { collapsedColumns() { const offenders = []; [...document.querySelectorAll("[class*='Row'], [class*='row']")].forEach(row => { + // Nothing inside something the page has hidden. A hidden element's children + // all measure zero wide, so the standings header — which is hidden along + // with its table until somebody has bought in — was reported as three + // collapsed columns on every empty table. A column nobody can see hasn't + // collapsed; it isn't there. + if (row.closest("[hidden]")) return; if (getComputedStyle(row).display !== "grid") return; [...row.children].forEach(cell => { if (getComputedStyle(cell).display === "none") return;