import { combatantId, type Encounter, type ExportBundle, type PlayerCharacter, playerCharacterId, type UndoRedoState, } from "@initiative/domain"; import { describe, expect, it } from "vitest"; import { assembleExportBundle, bundleToJson, resolveFilename, validateImportBundle, } from "../persistence/export-import.js"; const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/; const DEFAULT_FILENAME_RE = /^initiative-export-\d{4}-\d{2}-\d{2}\.json$/; const encounter: Encounter = { combatants: [ { id: combatantId("c-1"), name: "Goblin", initiative: 15, maxHp: 7, currentHp: 7, ac: 15, }, { id: combatantId("c-2"), name: "Aria", initiative: 18, maxHp: 45, currentHp: 40, ac: 16, color: "blue", icon: "sword", playerCharacterId: playerCharacterId("pc-1"), }, ], activeIndex: 0, roundNumber: 2, }; const undoRedoState: UndoRedoState = { undoStack: [ { combatants: [{ id: combatantId("c-1"), name: "Goblin", initiative: 15 }], activeIndex: 0, roundNumber: 1, }, ], redoStack: [], }; const playerCharacters: PlayerCharacter[] = [ { id: playerCharacterId("pc-1"), name: "Aria", ac: 16, maxHp: 45, color: "blue", icon: "sword", }, ]; describe("assembleExportBundle", () => { it("returns a bundle with version 1", () => { const bundle = assembleExportBundle( encounter, undoRedoState, playerCharacters, ); expect(bundle.version).toBe(1); }); it("includes an ISO timestamp", () => { const bundle = assembleExportBundle( encounter, undoRedoState, playerCharacters, ); expect(bundle.exportedAt).toMatch(ISO_TIMESTAMP_RE); }); it("includes the encounter", () => { const bundle = assembleExportBundle( encounter, undoRedoState, playerCharacters, ); expect(bundle.encounter).toEqual(encounter); }); it("includes undo and redo stacks", () => { const bundle = assembleExportBundle( encounter, undoRedoState, playerCharacters, ); expect(bundle.undoStack).toEqual(undoRedoState.undoStack); expect(bundle.redoStack).toEqual(undoRedoState.redoStack); }); it("includes player characters", () => { const bundle = assembleExportBundle( encounter, undoRedoState, playerCharacters, ); expect(bundle.playerCharacters).toEqual(playerCharacters); }); }); describe("assembleExportBundle with includeHistory", () => { it("excludes undo/redo stacks when includeHistory is false", () => { const bundle = assembleExportBundle( encounter, undoRedoState, playerCharacters, false, ); expect(bundle.undoStack).toHaveLength(0); expect(bundle.redoStack).toHaveLength(0); }); it("includes undo/redo stacks when includeHistory is true", () => { const bundle = assembleExportBundle( encounter, undoRedoState, playerCharacters, true, ); expect(bundle.undoStack).toEqual(undoRedoState.undoStack); expect(bundle.redoStack).toEqual(undoRedoState.redoStack); }); it("includes undo/redo stacks by default", () => { const bundle = assembleExportBundle( encounter, undoRedoState, playerCharacters, ); expect(bundle.undoStack).toEqual(undoRedoState.undoStack); }); }); describe("bundleToJson", () => { it("produces valid JSON that round-trips through validateImportBundle", () => { const bundle = assembleExportBundle( encounter, undoRedoState, playerCharacters, ); const json = bundleToJson(bundle); const parsed: unknown = JSON.parse(json); const result = validateImportBundle(parsed); expect(typeof result).toBe("object"); }); }); describe("resolveFilename", () => { it("uses date-based default when no name provided", () => { const result = resolveFilename(); expect(result).toMatch(DEFAULT_FILENAME_RE); }); it("uses date-based default for empty string", () => { const result = resolveFilename(""); expect(result).toMatch(DEFAULT_FILENAME_RE); }); it("uses date-based default for whitespace-only string", () => { const result = resolveFilename(" "); expect(result).toMatch(DEFAULT_FILENAME_RE); }); it("appends .json to a custom name", () => { expect(resolveFilename("my-encounter")).toBe("my-encounter.json"); }); it("does not double-append .json", () => { expect(resolveFilename("my-encounter.json")).toBe("my-encounter.json"); }); it("trims whitespace from custom name", () => { expect(resolveFilename(" my-encounter ")).toBe("my-encounter.json"); }); }); describe("round-trip: export then import", () => { it("produces identical state after round-trip", () => { const bundle = assembleExportBundle( encounter, undoRedoState, playerCharacters, ); const serialized = JSON.parse(JSON.stringify(bundle)); const result = validateImportBundle(serialized); expect(typeof result).toBe("object"); const imported = result as ExportBundle; expect(imported.version).toBe(bundle.version); expect(imported.encounter).toEqual(bundle.encounter); expect(imported.undoStack).toEqual(bundle.undoStack); expect(imported.redoStack).toEqual(bundle.redoStack); expect(imported.playerCharacters).toEqual(bundle.playerCharacters); }); it("round-trips a combatant with cr field", () => { const encounterWithCr: Encounter = { combatants: [ { id: combatantId("c-1"), name: "Custom Thug", cr: "2", }, ], activeIndex: 0, roundNumber: 1, }; const emptyUndoRedo: UndoRedoState = { undoStack: [], redoStack: [], }; const bundle = assembleExportBundle(encounterWithCr, emptyUndoRedo, []); const serialized = JSON.parse(JSON.stringify(bundle)); const result = validateImportBundle(serialized); expect(typeof result).toBe("object"); const imported = result as ExportBundle; expect(imported.encounter.combatants[0].cr).toBe("2"); }); it("round-trips a combatant with side field", () => { const encounterWithSide: Encounter = { combatants: [ { id: combatantId("c-1"), name: "Allied Guard", cr: "2", side: "party", }, { id: combatantId("c-2"), name: "Goblin", side: "enemy", }, ], activeIndex: 0, roundNumber: 1, }; const emptyUndoRedo: UndoRedoState = { undoStack: [], redoStack: [], }; const bundle = assembleExportBundle(encounterWithSide, emptyUndoRedo, []); const serialized = JSON.parse(JSON.stringify(bundle)); const result = validateImportBundle(serialized); expect(typeof result).toBe("object"); const imported = result as ExportBundle; expect(imported.encounter.combatants[0].side).toBe("party"); expect(imported.encounter.combatants[1].side).toBe("enemy"); }); it("round-trips a combatant without side field as undefined", () => { const encounterNoSide: Encounter = { combatants: [{ id: combatantId("c-1"), name: "Custom" }], activeIndex: 0, roundNumber: 1, }; const emptyUndoRedo: UndoRedoState = { undoStack: [], redoStack: [], }; const bundle = assembleExportBundle(encounterNoSide, emptyUndoRedo, []); const serialized = JSON.parse(JSON.stringify(bundle)); const result = validateImportBundle(serialized); expect(typeof result).toBe("object"); const imported = result as ExportBundle; expect(imported.encounter.combatants[0].side).toBeUndefined(); }); it("round-trips an empty encounter", () => { const emptyEncounter: Encounter = { combatants: [], activeIndex: 0, roundNumber: 1, }; const emptyUndoRedo: UndoRedoState = { undoStack: [], redoStack: [], }; const bundle = assembleExportBundle(emptyEncounter, emptyUndoRedo, []); const serialized = JSON.parse(JSON.stringify(bundle)); const result = validateImportBundle(serialized); expect(typeof result).toBe("object"); const imported = result as ExportBundle; expect(imported.encounter.combatants).toHaveLength(0); expect(imported.undoStack).toHaveLength(0); expect(imported.redoStack).toHaveLength(0); expect(imported.playerCharacters).toHaveLength(0); }); });