The 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. The CSV export can't bring any of it back: it records what happened, not where the table stands. The table can now be written to a file and read back. Round-tripped through the real UI, a hand in progress comes back mid-hand — blinds posted, the same seat on the clock, the button where it was. Loading replaces everything, so it asks first and names both sides rather than saying "are you sure": how many players are in the file and when it was saved, against how many players and how many logged hands are about to go. It goes through the same undoable path as the header buttons, so the toast offers the way back, and the confirm says so instead of overstating it. Reading a file goes through exactly the same migration a save from storage does — load() had that logic inline and it is now a function both call. A second, looser path for imports is how a format grows two meanings of the same field. What it refuses, each with something a dealer can act on and never a half-loaded table: anything that isn't JSON, JSON with no seats in it, a save from some other tool that happens to carry a `kind`, and a file from a newer version — that last one because loading it risks reading a field whose meaning has changed, and the dealer would find out from the money. What it accepts anyway: a bare table with no envelope, for somebody who copied the storage value out by hand. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
156 lines
6.6 KiB
JavaScript
156 lines
6.6 KiB
JavaScript
// 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);
|
|
});
|
|
});
|