From 1402c7094b35d7a05c11653bd0b00e9e49afb868 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:30:02 -0600 Subject: [PATCH] feat(engine): modifier-profile library persistence (v1) --- packages/chess/src/modifiers/library.test.ts | 261 +++++++++++++++++++ packages/chess/src/modifiers/library.ts | 188 +++++++++++++ 2 files changed, 449 insertions(+) create mode 100644 packages/chess/src/modifiers/library.test.ts create mode 100644 packages/chess/src/modifiers/library.ts diff --git a/packages/chess/src/modifiers/library.test.ts b/packages/chess/src/modifiers/library.test.ts new file mode 100644 index 0000000..f29bb6d --- /dev/null +++ b/packages/chess/src/modifiers/library.test.ts @@ -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(); + 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 { + 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(); + 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")); diff --git a/packages/chess/src/modifiers/library.ts b/packages/chess/src/modifiers/library.ts new file mode 100644 index 0000000..855b97d --- /dev/null +++ b/packages/chess/src/modifiers/library.ts @@ -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; + 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 };