// Taking the table off this device. // // A night lives in one browser's localStorage and nowhere else. A dealer whose phone // dies, or who wants to carry on from the tablet after the break, loses the stacks, // the buy-ins, where the button is and the whole hand log — and the CSV export can't // bring any of that back, because it records what happened rather than where the // table stands. const test = require("node:test"); const { describe } = require("node:test"); const assert = require("node:assert"); const { HoldemState, Betting, makeTable } = require("./load"); const aNight = () => { const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 1 }); ["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].buyIns = 200; }); state.seats[2].stack = 340; state.notes = "Cal owes the bar $12"; Betting.beginHand(state); HoldemState.recordHand(state, [{ id: state.seats[2].id, label: "Cal", amount: 60 }]); return state; }; describe("a table written out and read back", () => { test("comes back the same table", () => { const before = aNight(); const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table; assert.deepEqual(after.seats.map(p => p.name), before.seats.map(p => p.name)); assert.deepEqual(after.seats.map(p => p.stack), before.seats.map(p => p.stack)); assert.deepEqual(after.seats.map(p => p.buyIns), before.seats.map(p => p.buyIns)); assert.equal(after.dealerSeatIndex, before.dealerSeatIndex); assert.equal(after.handNumber, before.handNumber); assert.equal(after.notes, before.notes); assert.equal(after.handHistory.length, before.handHistory.length); }); test("keeps the things a CSV can't carry", () => { // The whole reason this exists: an export records what happened, not where the // table stands. const before = aNight(); const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table; assert.equal(after.bettingOpen, before.bettingOpen, "a hand in progress is still in progress"); assert.deepEqual(after.postedBlinds, before.postedBlinds, "so the next rotation is right"); assert.equal(after.street, before.street); assert.deepEqual(after.seats.map(p => p.committed), before.seats.map(p => p.committed)); assert.deepEqual(after.chipLegend, before.chipLegend); }); test("the standings and the bank check agree across the trip", () => { const before = aNight(); const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table; assert.deepEqual(HoldemState.sessionStandings(after), HoldemState.sessionStandings(before)); assert.deepEqual(HoldemState.bankReconciliation(after), HoldemState.bankReconciliation(before)); }); test("it can be dealt from straight away", () => { const before = aNight(); HoldemState.startNextHand(before); const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table; Betting.beginHand(after); assert.equal(after.bettingOpen, true); assert.ok(after.actingSeatIndex !== null, "somebody is on the clock"); }); }); describe("what it refuses", () => { const refuses = (text, because) => { const result = HoldemState.fromBackup(text); assert.ok(result.error, because); assert.equal(result.table, undefined, "and never hands back half a table"); }; test("something that isn't JSON", () => { refuses("not a file", "unreadable"); refuses("", "empty"); }); test("JSON that isn't a table", () => { refuses('{"hello":"world"}', "no seats in it"); refuses("[1,2,3]", "an array of nothing"); refuses("null", "null"); }); test("a save from something else that happens to be JSON", () => { refuses('{"kind":"some.other.tool","table":{"seats":[]}}', "wrong kind"); }); test("a file from a newer version, rather than guessing at it", () => { // Loading it risks reading a field whose meaning has changed, and the dealer // would find out from the money. const backup = JSON.parse(HoldemState.toBackup(aNight())); backup.version = HoldemState.BACKUP_VERSION + 1; const result = HoldemState.fromBackup(JSON.stringify(backup)); assert.match(result.error, /newer version/i); }); }); describe("what it accepts anyway", () => { test("a bare table, with no envelope around it", () => { // Somebody who copied the storage value out by hand. The envelope is a // convenience, not a password. const state = aNight(); const result = HoldemState.fromBackup(JSON.stringify(state)); assert.ok(!result.error); assert.deepEqual(result.table.seats.map(p => p.name), state.seats.map(p => p.name)); }); test("a table from before fields that exist now, migrated on the way in", () => { // Same repairs a save from an older version gets, because it is the same // function — a second, looser path for imports is how a format grows two // meanings of the same field. const state = aNight(); const raw = JSON.parse(JSON.stringify(state)); delete raw.deadButton; delete raw.postedBlinds; delete raw.dealtLastHand; delete raw.discarded; raw.seats.forEach(p => { delete p.buyIns; }); const { table, error } = HoldemState.fromBackup(JSON.stringify({ kind: HoldemState.BACKUP_KIND, version: 1, table: raw, })); assert.ok(!error); assert.equal(table.deadButton, false); assert.deepEqual(table.dealtLastHand, []); assert.equal(table.discarded, 0); assert.equal(table.seats[0].buyIns, 0); }); test("the time it was saved comes back with it", () => { const result = HoldemState.fromBackup(HoldemState.toBackup(aNight())); assert.ok(result.savedAt, "so the dealer can tell two files apart"); assert.ok(!Number.isNaN(new Date(result.savedAt).getTime())); }); }); describe("the file itself", () => { test("is named with the date and time, so two saves in a night are told apart", () => { const at = new Date(2026, 8, 2, 21, 5); assert.equal(HoldemState.backupFileName(at), "holdem-table-2026-09-02-2105.json"); }); test("says what it is, for anyone finding it in a downloads folder", () => { const parsed = JSON.parse(HoldemState.toBackup(aNight())); assert.equal(parsed.kind, "casino.holdem.table"); assert.equal(parsed.version, 1); assert.ok(parsed.table.seats.length > 0); }); });