Persistent player character templates (name, AC, HP, color, icon) with full CRUD, bestiary-style search to add PCs to encounters with pre-filled stats, and color/icon visual distinction in combatant rows. Also stops the stat block panel from auto-opening when adding a creature. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
232 lines
4.9 KiB
TypeScript
232 lines
4.9 KiB
TypeScript
import type { PlayerCharacter } from "@initiative/domain";
|
|
import { playerCharacterId } from "@initiative/domain";
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
loadPlayerCharacters,
|
|
savePlayerCharacters,
|
|
} from "../player-character-storage.js";
|
|
|
|
const STORAGE_KEY = "initiative:player-characters";
|
|
|
|
function createMockLocalStorage() {
|
|
const store = new Map<string, string>();
|
|
return {
|
|
getItem: (key: string) => store.get(key) ?? null,
|
|
setItem: (key: string, value: string) => store.set(key, value),
|
|
removeItem: (key: string) => store.delete(key),
|
|
clear: () => store.clear(),
|
|
get length() {
|
|
return store.size;
|
|
},
|
|
key: (_index: number) => null,
|
|
store,
|
|
};
|
|
}
|
|
|
|
function makePC(overrides?: Partial<PlayerCharacter>): PlayerCharacter {
|
|
return {
|
|
id: playerCharacterId("pc-1"),
|
|
name: "Aragorn",
|
|
ac: 16,
|
|
maxHp: 120,
|
|
color: "green",
|
|
icon: "sword",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("player-character-storage", () => {
|
|
let mockStorage: ReturnType<typeof createMockLocalStorage>;
|
|
|
|
beforeEach(() => {
|
|
mockStorage = createMockLocalStorage();
|
|
Object.defineProperty(globalThis, "localStorage", {
|
|
value: mockStorage,
|
|
writable: true,
|
|
});
|
|
});
|
|
|
|
describe("round-trip save/load", () => {
|
|
it("saves and loads a single character", () => {
|
|
const pc = makePC();
|
|
savePlayerCharacters([pc]);
|
|
const loaded = loadPlayerCharacters();
|
|
expect(loaded).toEqual([pc]);
|
|
});
|
|
|
|
it("saves and loads multiple characters", () => {
|
|
const pcs = [
|
|
makePC({ id: playerCharacterId("pc-1"), name: "Aragorn" }),
|
|
makePC({
|
|
id: playerCharacterId("pc-2"),
|
|
name: "Legolas",
|
|
ac: 14,
|
|
maxHp: 90,
|
|
color: "blue",
|
|
icon: "eye",
|
|
}),
|
|
];
|
|
savePlayerCharacters(pcs);
|
|
const loaded = loadPlayerCharacters();
|
|
expect(loaded).toEqual(pcs);
|
|
});
|
|
});
|
|
|
|
describe("empty storage", () => {
|
|
it("returns empty array when no data exists", () => {
|
|
expect(loadPlayerCharacters()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("corrupt JSON", () => {
|
|
it("returns empty array for invalid JSON", () => {
|
|
mockStorage.setItem(STORAGE_KEY, "not-json{{{");
|
|
expect(loadPlayerCharacters()).toEqual([]);
|
|
});
|
|
|
|
it("returns empty array for non-array JSON", () => {
|
|
mockStorage.setItem(STORAGE_KEY, '{"foo": "bar"}');
|
|
expect(loadPlayerCharacters()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("per-character validation", () => {
|
|
it("discards character with missing name", () => {
|
|
mockStorage.setItem(
|
|
STORAGE_KEY,
|
|
JSON.stringify([
|
|
{ id: "pc-1", ac: 10, maxHp: 50, color: "blue", icon: "sword" },
|
|
]),
|
|
);
|
|
expect(loadPlayerCharacters()).toEqual([]);
|
|
});
|
|
|
|
it("discards character with empty name", () => {
|
|
mockStorage.setItem(
|
|
STORAGE_KEY,
|
|
JSON.stringify([
|
|
{
|
|
id: "pc-1",
|
|
name: "",
|
|
ac: 10,
|
|
maxHp: 50,
|
|
color: "blue",
|
|
icon: "sword",
|
|
},
|
|
]),
|
|
);
|
|
expect(loadPlayerCharacters()).toEqual([]);
|
|
});
|
|
|
|
it("discards character with invalid color", () => {
|
|
mockStorage.setItem(
|
|
STORAGE_KEY,
|
|
JSON.stringify([
|
|
{
|
|
id: "pc-1",
|
|
name: "Test",
|
|
ac: 10,
|
|
maxHp: 50,
|
|
color: "neon",
|
|
icon: "sword",
|
|
},
|
|
]),
|
|
);
|
|
expect(loadPlayerCharacters()).toEqual([]);
|
|
});
|
|
|
|
it("discards character with invalid icon", () => {
|
|
mockStorage.setItem(
|
|
STORAGE_KEY,
|
|
JSON.stringify([
|
|
{
|
|
id: "pc-1",
|
|
name: "Test",
|
|
ac: 10,
|
|
maxHp: 50,
|
|
color: "blue",
|
|
icon: "banana",
|
|
},
|
|
]),
|
|
);
|
|
expect(loadPlayerCharacters()).toEqual([]);
|
|
});
|
|
|
|
it("discards character with negative AC", () => {
|
|
mockStorage.setItem(
|
|
STORAGE_KEY,
|
|
JSON.stringify([
|
|
{
|
|
id: "pc-1",
|
|
name: "Test",
|
|
ac: -1,
|
|
maxHp: 50,
|
|
color: "blue",
|
|
icon: "sword",
|
|
},
|
|
]),
|
|
);
|
|
expect(loadPlayerCharacters()).toEqual([]);
|
|
});
|
|
|
|
it("discards character with maxHp of 0", () => {
|
|
mockStorage.setItem(
|
|
STORAGE_KEY,
|
|
JSON.stringify([
|
|
{
|
|
id: "pc-1",
|
|
name: "Test",
|
|
ac: 10,
|
|
maxHp: 0,
|
|
color: "blue",
|
|
icon: "sword",
|
|
},
|
|
]),
|
|
);
|
|
expect(loadPlayerCharacters()).toEqual([]);
|
|
});
|
|
|
|
it("keeps valid characters and discards invalid ones", () => {
|
|
mockStorage.setItem(
|
|
STORAGE_KEY,
|
|
JSON.stringify([
|
|
{
|
|
id: "pc-1",
|
|
name: "Valid",
|
|
ac: 10,
|
|
maxHp: 50,
|
|
color: "blue",
|
|
icon: "sword",
|
|
},
|
|
{
|
|
id: "pc-2",
|
|
name: "",
|
|
ac: 10,
|
|
maxHp: 50,
|
|
color: "blue",
|
|
icon: "sword",
|
|
},
|
|
]),
|
|
);
|
|
const loaded = loadPlayerCharacters();
|
|
expect(loaded).toHaveLength(1);
|
|
expect(loaded[0].name).toBe("Valid");
|
|
});
|
|
});
|
|
|
|
describe("storage errors", () => {
|
|
it("save silently catches errors", () => {
|
|
Object.defineProperty(globalThis, "localStorage", {
|
|
value: {
|
|
setItem: () => {
|
|
throw new Error("QuotaExceeded");
|
|
},
|
|
getItem: () => null,
|
|
},
|
|
writable: true,
|
|
});
|
|
expect(() => savePlayerCharacters([makePC()])).not.toThrow();
|
|
});
|
|
});
|
|
});
|