Export and import encounter, undo/redo history, and player characters as a downloadable .json file. Export/import actions are in the action bar overflow menu. Import validates using existing rehydration functions and shows a confirmation dialog when replacing a non-empty encounter. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
250 lines
6.9 KiB
TypeScript
250 lines
6.9 KiB
TypeScript
import type { ExportBundle } from "@initiative/domain";
|
|
import { describe, expect, it } from "vitest";
|
|
import { validateImportBundle } from "../persistence/export-import.js";
|
|
|
|
function validBundle(): Record<string, unknown> {
|
|
return {
|
|
version: 1,
|
|
exportedAt: "2026-03-27T12:00:00.000Z",
|
|
encounter: {
|
|
combatants: [{ id: "c-1", name: "Goblin", initiative: 15 }],
|
|
activeIndex: 0,
|
|
roundNumber: 1,
|
|
},
|
|
undoStack: [],
|
|
redoStack: [],
|
|
playerCharacters: [],
|
|
};
|
|
}
|
|
|
|
describe("validateImportBundle", () => {
|
|
it("accepts a valid bundle", () => {
|
|
const result = validateImportBundle(validBundle());
|
|
expect(typeof result).toBe("object");
|
|
const bundle = result as ExportBundle;
|
|
expect(bundle.version).toBe(1);
|
|
expect(bundle.encounter.combatants).toHaveLength(1);
|
|
expect(bundle.encounter.combatants[0].name).toBe("Goblin");
|
|
});
|
|
|
|
it("accepts a valid bundle with empty encounter", () => {
|
|
const input = {
|
|
...validBundle(),
|
|
encounter: { combatants: [], activeIndex: 0, roundNumber: 1 },
|
|
};
|
|
const result = validateImportBundle(input);
|
|
expect(typeof result).toBe("object");
|
|
const bundle = result as ExportBundle;
|
|
expect(bundle.encounter.combatants).toHaveLength(0);
|
|
});
|
|
|
|
it("accepts a bundle with undo/redo stacks", () => {
|
|
const enc = {
|
|
combatants: [{ id: "c-1", name: "Orc" }],
|
|
activeIndex: 0,
|
|
roundNumber: 1,
|
|
};
|
|
const input = {
|
|
...validBundle(),
|
|
undoStack: [enc],
|
|
redoStack: [enc],
|
|
};
|
|
const result = validateImportBundle(input);
|
|
expect(typeof result).toBe("object");
|
|
const bundle = result as ExportBundle;
|
|
expect(bundle.undoStack).toHaveLength(1);
|
|
expect(bundle.redoStack).toHaveLength(1);
|
|
});
|
|
|
|
it("accepts a bundle with player characters", () => {
|
|
const input = {
|
|
...validBundle(),
|
|
playerCharacters: [
|
|
{
|
|
id: "pc-1",
|
|
name: "Aria",
|
|
ac: 16,
|
|
maxHp: 45,
|
|
color: "blue",
|
|
icon: "sword",
|
|
},
|
|
],
|
|
};
|
|
const result = validateImportBundle(input);
|
|
expect(typeof result).toBe("object");
|
|
const bundle = result as ExportBundle;
|
|
expect(bundle.playerCharacters).toHaveLength(1);
|
|
expect(bundle.playerCharacters[0].name).toBe("Aria");
|
|
});
|
|
|
|
it("rejects non-object input", () => {
|
|
expect(validateImportBundle(null)).toBe("Invalid file format");
|
|
expect(validateImportBundle(42)).toBe("Invalid file format");
|
|
expect(validateImportBundle("string")).toBe("Invalid file format");
|
|
expect(validateImportBundle([])).toBe("Invalid file format");
|
|
expect(validateImportBundle(undefined)).toBe("Invalid file format");
|
|
});
|
|
|
|
it("rejects missing version field", () => {
|
|
const input = validBundle();
|
|
delete input.version;
|
|
expect(validateImportBundle(input)).toBe("Invalid file format");
|
|
});
|
|
|
|
it("rejects version 0 or negative", () => {
|
|
expect(validateImportBundle({ ...validBundle(), version: 0 })).toBe(
|
|
"Invalid file format",
|
|
);
|
|
expect(validateImportBundle({ ...validBundle(), version: -1 })).toBe(
|
|
"Invalid file format",
|
|
);
|
|
});
|
|
|
|
it("rejects unknown version", () => {
|
|
expect(validateImportBundle({ ...validBundle(), version: 99 })).toBe(
|
|
"Invalid file format",
|
|
);
|
|
});
|
|
|
|
it("rejects missing encounter field", () => {
|
|
const input = validBundle();
|
|
delete input.encounter;
|
|
expect(validateImportBundle(input)).toBe("Invalid encounter data");
|
|
});
|
|
|
|
it("rejects invalid encounter data", () => {
|
|
expect(
|
|
validateImportBundle({ ...validBundle(), encounter: "not an object" }),
|
|
).toBe("Invalid encounter data");
|
|
});
|
|
|
|
it("rejects missing undoStack", () => {
|
|
const input = validBundle();
|
|
delete input.undoStack;
|
|
expect(validateImportBundle(input)).toBe("Invalid file format");
|
|
});
|
|
|
|
it("rejects missing redoStack", () => {
|
|
const input = validBundle();
|
|
delete input.redoStack;
|
|
expect(validateImportBundle(input)).toBe("Invalid file format");
|
|
});
|
|
|
|
it("rejects missing playerCharacters", () => {
|
|
const input = validBundle();
|
|
delete input.playerCharacters;
|
|
expect(validateImportBundle(input)).toBe("Invalid file format");
|
|
});
|
|
|
|
it("rejects non-string exportedAt", () => {
|
|
expect(validateImportBundle({ ...validBundle(), exportedAt: 12345 })).toBe(
|
|
"Invalid file format",
|
|
);
|
|
});
|
|
|
|
it("drops invalid entries from undo stack", () => {
|
|
const valid = {
|
|
combatants: [{ id: "c-1", name: "Orc" }],
|
|
activeIndex: 0,
|
|
roundNumber: 1,
|
|
};
|
|
const input = {
|
|
...validBundle(),
|
|
undoStack: [valid, "invalid", { bad: true }, valid],
|
|
};
|
|
const result = validateImportBundle(input);
|
|
expect(typeof result).toBe("object");
|
|
const bundle = result as ExportBundle;
|
|
expect(bundle.undoStack).toHaveLength(2);
|
|
});
|
|
|
|
it("drops invalid player characters", () => {
|
|
const input = {
|
|
...validBundle(),
|
|
playerCharacters: [
|
|
{ id: "pc-1", name: "Valid", ac: 10, maxHp: 20 },
|
|
{ id: "", name: "Bad ID" },
|
|
"not an object",
|
|
{ id: "pc-3", name: "Also Valid", ac: 15, maxHp: 30 },
|
|
],
|
|
};
|
|
const result = validateImportBundle(input);
|
|
expect(typeof result).toBe("object");
|
|
const bundle = result as ExportBundle;
|
|
expect(bundle.playerCharacters).toHaveLength(2);
|
|
});
|
|
|
|
it("rejects JSON array instead of object", () => {
|
|
expect(validateImportBundle([1, 2, 3])).toBe("Invalid file format");
|
|
});
|
|
|
|
it("rejects encounter that fails rehydration (missing combatant fields)", () => {
|
|
const input = {
|
|
...validBundle(),
|
|
encounter: {
|
|
combatants: [{ noId: true }],
|
|
activeIndex: 0,
|
|
roundNumber: 1,
|
|
},
|
|
};
|
|
expect(validateImportBundle(input)).toBe("Invalid encounter data");
|
|
});
|
|
|
|
it("strips invalid color/icon from player characters but keeps the character", () => {
|
|
const input = {
|
|
...validBundle(),
|
|
playerCharacters: [
|
|
{
|
|
id: "pc-1",
|
|
name: "Test",
|
|
ac: 10,
|
|
maxHp: 20,
|
|
color: "neon-pink",
|
|
icon: "bazooka",
|
|
},
|
|
],
|
|
};
|
|
const result = validateImportBundle(input);
|
|
// rehydrateCharacter rejects characters with invalid color/icon members
|
|
// that are not in the valid sets, so this character is dropped
|
|
expect(typeof result).toBe("object");
|
|
const bundle = result as ExportBundle;
|
|
expect(bundle.playerCharacters).toHaveLength(0);
|
|
});
|
|
|
|
it("keeps player characters with valid optional color and icon", () => {
|
|
const input = {
|
|
...validBundle(),
|
|
playerCharacters: [
|
|
{
|
|
id: "pc-1",
|
|
name: "Aria",
|
|
ac: 16,
|
|
maxHp: 45,
|
|
color: "blue",
|
|
icon: "sword",
|
|
},
|
|
],
|
|
};
|
|
const result = validateImportBundle(input);
|
|
expect(typeof result).toBe("object");
|
|
const bundle = result as ExportBundle;
|
|
expect(bundle.playerCharacters).toHaveLength(1);
|
|
expect(bundle.playerCharacters[0].color).toBe("blue");
|
|
expect(bundle.playerCharacters[0].icon).toBe("sword");
|
|
});
|
|
|
|
it("ignores unknown extra fields on the bundle", () => {
|
|
const input = {
|
|
...validBundle(),
|
|
unknownField: "should be ignored",
|
|
anotherExtra: 42,
|
|
};
|
|
const result = validateImportBundle(input);
|
|
expect(typeof result).toBe("object");
|
|
const bundle = result as ExportBundle;
|
|
expect(bundle.version).toBe(1);
|
|
expect("unknownField" in bundle).toBe(false);
|
|
});
|
|
});
|