Files
initiative/apps/web/src/__tests__/export-import.test.ts
Lukas 94e1806112
All checks were successful
CI / check (push) Successful in 2m18s
CI / build-image (push) Successful in 17s
Add combatant side assignment for encounter difficulty
Combatants can now be assigned to party or enemy side via a toggle
in the difficulty breakdown panel. Party-side NPCs subtract their XP
from the encounter total, letting allied NPCs reduce difficulty.
PCs default to party, non-PCs to enemy — users who don't use sides
see no change. Side persists across reload and export/import.

Closes #22

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:15:12 +02:00

310 lines
7.8 KiB
TypeScript

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