feat(engine): modifier-profile library persistence (v1)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 22:30:02 -06:00
commit 1402c7094b
No known key found for this signature in database
2 changed files with 449 additions and 0 deletions

View file

@ -0,0 +1,261 @@
import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest";
import {
loadLibrary,
saveToLibrary,
deleteFromLibrary,
setStarred,
duplicateEntry,
makeId,
__test__,
type SavedModifierProfile,
} from "./library.js";
import type { ModifierProfile } from "./types.js";
// happy-dom provides a localStorage object but its methods are
// bound to a prototype that doesn't survive certain destructuring
// patterns; we install a simple Map-backed shim unconditionally so
// the tests have predictable behavior.
beforeAll(() => {
const store = new Map<string, string>();
const shim: Storage = {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(k: string) {
return store.get(k) ?? null;
},
setItem(k: string, v: string) {
store.set(k, v);
},
removeItem(k: string) {
store.delete(k);
},
key(i: number) {
return [...store.keys()][i] ?? null;
},
};
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: shim,
});
});
// Clear between tests so each one starts fresh.
beforeEach(() => {
localStorage.clear();
});
const emptyProfile: ModifierProfile = {
id: "test",
name: "Test Profile",
description: "",
perType: [],
perInstance: [],
version: 1,
source: "custom",
};
function seed(entry: Partial<SavedModifierProfile> = {}): SavedModifierProfile {
return {
id: makeId(),
name: "Test",
profile: emptyProfile,
starred: false,
updatedAt: Date.now(),
...entry,
};
}
describe("loadLibrary()", () => {
it("returns [] when no key is set", () => {
expect(loadLibrary()).toEqual([]);
});
it("returns [] when storage contains malformed JSON", () => {
localStorage.setItem(__test__.STORAGE_KEY, "not json");
expect(loadLibrary()).toEqual([]);
});
it("filters out entries that fail shape validation", () => {
const validEntry: SavedModifierProfile = {
id: "ok",
name: "ok",
profile: emptyProfile,
starred: false,
updatedAt: 0,
};
localStorage.setItem(
__test__.STORAGE_KEY,
JSON.stringify([
validEntry,
{ not: "a valid entry" },
]),
);
const library = loadLibrary();
expect(library).toHaveLength(1);
expect(library[0]?.id).toBe("ok");
});
it("filters out entries with invalid nested profile", () => {
localStorage.setItem(
__test__.STORAGE_KEY,
JSON.stringify([
{
id: "bad-profile",
name: "bad",
profile: { version: 99, source: "unknown" },
starred: false,
updatedAt: 0,
},
]),
);
expect(loadLibrary()).toHaveLength(0);
});
});
describe("saveToLibrary()", () => {
it("appends a new entry", () => {
const result = saveToLibrary(seed({ name: "First" }));
expect(result.ok).toBe(true);
const library = loadLibrary();
expect(library).toHaveLength(1);
expect(library[0]?.name).toBe("First");
});
it("updates in place when id matches", () => {
const entry = seed({ name: "Original" });
saveToLibrary(entry);
saveToLibrary({ ...entry, name: "Renamed" });
const library = loadLibrary();
expect(library).toHaveLength(1);
expect(library[0]?.name).toBe("Renamed");
});
it("evicts the oldest non-starred when MAX_ENTRIES is hit", () => {
// Seed with MAX_ENTRIES entries, incrementing updatedAt.
for (let i = 0; i < __test__.MAX_ENTRIES; i++) {
saveToLibrary(seed({ name: `E${String(i)}`, updatedAt: i }));
}
expect(loadLibrary()).toHaveLength(__test__.MAX_ENTRIES);
// Save one more — oldest (E0) should be evicted.
saveToLibrary(seed({ name: "newest", updatedAt: 9999 }));
const library = loadLibrary();
expect(library).toHaveLength(__test__.MAX_ENTRIES);
expect(library.map((e) => e.name)).not.toContain("E0");
expect(library.some((e) => e.name === "newest")).toBe(true);
});
it("refuses save when every entry is starred and library is full", () => {
for (let i = 0; i < __test__.MAX_ENTRIES; i++) {
saveToLibrary(seed({ name: `E${String(i)}`, starred: true }));
}
const result = saveToLibrary(seed({ name: "newest" }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toMatch(/unstar/i);
}
expect(loadLibrary()).toHaveLength(__test__.MAX_ENTRIES);
});
});
describe("deleteFromLibrary()", () => {
it("removes the matching entry", () => {
const entry = seed();
saveToLibrary(entry);
deleteFromLibrary(entry.id);
expect(loadLibrary()).toHaveLength(0);
});
it("is a no-op for unknown id", () => {
saveToLibrary(seed());
deleteFromLibrary("not-real");
expect(loadLibrary()).toHaveLength(1);
});
});
describe("setStarred()", () => {
it("toggles starred and updates updatedAt", () => {
const entry = seed({ starred: false, updatedAt: 100 });
saveToLibrary(entry);
const before = Date.now();
setStarred(entry.id, true);
const library = loadLibrary();
expect(library[0]?.starred).toBe(true);
expect(library[0]?.updatedAt).toBeGreaterThanOrEqual(before);
});
it("is a no-op for unknown id", () => {
saveToLibrary(seed());
setStarred("not-real", true);
expect(loadLibrary()[0]?.starred).toBe(false);
});
});
describe("duplicateEntry()", () => {
it("creates a copy with a new id and '(copy)' name suffix", () => {
const entry = seed({ name: "Original" });
saveToLibrary(entry);
const newId = duplicateEntry(entry.id);
expect(newId).toBeDefined();
expect(newId).not.toBe(entry.id);
const library = loadLibrary();
expect(library).toHaveLength(2);
const copy = library.find((e) => e.id === newId);
expect(copy?.name).toBe("Original (copy)");
expect(copy?.starred).toBe(false);
});
it("preserves the profile on duplication", () => {
const profileWithData: ModifierProfile = {
id: "p1",
name: "Buff Profile",
description: "has modifiers",
perType: [{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 2 }],
perInstance: [],
version: 1,
source: "custom",
};
const entry = seed({ name: "Source", profile: profileWithData });
saveToLibrary(entry);
const newId = duplicateEntry(entry.id);
const library = loadLibrary();
const copy = library.find((e) => e.id === newId);
expect(copy?.profile).toEqual(profileWithData);
});
it("returns undefined for unknown id", () => {
expect(duplicateEntry("not-real")).toBeUndefined();
});
});
describe("makeId()", () => {
it("produces unique ids", () => {
const ids = new Set<string>();
for (let i = 0; i < 100; i++) ids.add(makeId());
expect(ids.size).toBe(100);
});
it("falls back when crypto.randomUUID is unavailable", () => {
const original = crypto.randomUUID;
// @ts-expect-error — intentional override for test
crypto.randomUUID = undefined;
try {
const id = makeId();
expect(id).toMatch(/^layout-/);
} finally {
crypto.randomUUID = original;
}
});
});
// Silence React testing-library warnings if this file runs in a mixed env.
vi.mock("react", async () => await vi.importActual("react"));

View file

@ -0,0 +1,188 @@
/**
* Modifier-profile library — localStorage-backed store of user-authored
* modifier profiles.
*
* Each entry:
* - `id` — local-only UUID. Used to identify the entry for
* update/delete/star operations.
* - `name` — user-provided label, shown in the library drawer.
* - `profile` — the full ModifierProfile.
* - `starred` — true when the user has pinned this profile. Starred
* entries are exempt from FIFO eviction and sort first.
* - `updatedAt` — unix ms of last write. Drives display order for
* non-starred entries (newest first) and FIFO eviction (oldest
* non-starred entry is removed when capacity is hit).
*
* Capacity: MAX_ENTRIES (20). When exceeded, the oldest non-starred
* entry is evicted. If every entry is starred, we refuse the save
* and the caller surfaces a "library full — unstar something" error.
*
* Storage key is versioned (`houserules:modifier-profiles:v1`). A schema
* bump would ship a new key + migration; v1 entries are kept on best-
* effort and re-hydrated read-only if they can't be migrated.
*/
import { parseModifierProfile } from "./schema.js";
import type { ModifierProfile } from "./types.js";
const STORAGE_KEY = "houserules:modifier-profiles:v1";
const MAX_ENTRIES = 20;
export interface SavedModifierProfile {
readonly id: string;
readonly name: string;
readonly profile: ModifierProfile;
readonly starred: boolean;
readonly updatedAt: number;
}
/**
* Read every saved modifier profile from storage. Returns an empty array on
* empty/missing/corrupt storage — silently discarding unparseable
* data is preferable to blocking the UI.
*/
export function loadLibrary(): SavedModifierProfile[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
// Shallow shape validation — anything that fails is dropped.
return parsed.filter(isSavedModifierProfile);
} catch {
return [];
}
}
/** Write the full library array back to storage. */
function writeLibrary(entries: SavedModifierProfile[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
} catch {
/* quota exceeded / storage disabled — best effort */
}
}
/**
* Save a new modifier profile or update an existing one (matched by `id`).
*
* Returns `{ ok: true }` on success. Returns `{ ok: false, reason }`
* when the library is full of starred entries — caller surfaces a
* message telling the user to unstar something.
*/
export function saveToLibrary(
entry: SavedModifierProfile,
): { ok: true } | { ok: false; reason: string } {
const library = loadLibrary();
const existingIdx = library.findIndex((e) => e.id === entry.id);
if (existingIdx >= 0) {
// Update in place — no capacity check needed.
library[existingIdx] = entry;
writeLibrary(library);
return { ok: true };
}
// New entry — enforce capacity.
if (library.length >= MAX_ENTRIES) {
// Find the oldest non-starred entry and evict it.
const evictable = library
.filter((e) => !e.starred)
.sort((a, b) => a.updatedAt - b.updatedAt);
if (evictable.length === 0) {
return {
ok: false,
reason:
"Library full (20 profiles). Unstar one to make room, or delete an entry.",
};
}
const oldestNonStarred = evictable[0]!;
const pruned = library.filter((e) => e.id !== oldestNonStarred.id);
pruned.push(entry);
writeLibrary(pruned);
return { ok: true };
}
library.push(entry);
writeLibrary(library);
return { ok: true };
}
/** Remove a modifier profile by id. No-op if the id is unknown. */
export function deleteFromLibrary(id: string): void {
const library = loadLibrary();
writeLibrary(library.filter((e) => e.id !== id));
}
/** Toggle the starred flag on a modifier profile. */
export function setStarred(id: string, starred: boolean): void {
const library = loadLibrary();
const idx = library.findIndex((e) => e.id === id);
if (idx < 0) return;
const updated: SavedModifierProfile = {
...library[idx]!,
starred,
updatedAt: Date.now(),
};
library[idx] = updated;
writeLibrary(library);
}
/**
* Duplicate a library entry. The copy gets a fresh id, "(copy)"
* appended to the name, and starred=false regardless of the
* original's state. Returns the new entry's id so the caller can
* select it.
*/
export function duplicateEntry(id: string): string | undefined {
const library = loadLibrary();
const entry = library.find((e) => e.id === id);
if (entry === undefined) return undefined;
const newId = makeId();
const copy: SavedModifierProfile = {
id: newId,
name: `${entry.name} (copy)`,
profile: entry.profile,
starred: false,
updatedAt: Date.now(),
};
const result = saveToLibrary(copy);
if (!result.ok) return undefined;
return newId;
}
/** Generate a local-only id for a library entry. */
export function makeId(): string {
// crypto.randomUUID is available in every browser we target (and
// in Node 19+). Fall back to a Math.random-based id only on
// ancient runtimes.
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `layout-${Math.random().toString(36).slice(2, 12)}`;
}
// ── Internal helpers ──────────────────────────────────────────────────
function isSavedModifierProfile(value: unknown): value is SavedModifierProfile {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
if (
typeof v["id"] !== "string" ||
typeof v["name"] !== "string" ||
typeof v["starred"] !== "boolean" ||
typeof v["updatedAt"] !== "number"
) {
return false;
}
// Validate the nested profile using the Zod schema.
try {
parseModifierProfile(v["profile"]);
return true;
} catch {
return false;
}
}
// Exported for tests only. Prefer the high-level helpers above.
export const __test__ = { STORAGE_KEY, MAX_ENTRIES };