diff --git a/packages/chess/src/modifiers/custom/library.test.ts b/packages/chess/src/modifiers/custom/library.test.ts new file mode 100644 index 0000000..141cafe --- /dev/null +++ b/packages/chess/src/modifiers/custom/library.test.ts @@ -0,0 +1,205 @@ +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { asCustomModifierId, type CustomModifierDescriptor } from "./types.js"; +import { + __test__, + loadCustomModifierLibrary, + removeFromCustomModifierLibrary, + saveToCustomModifierLibrary, + setCustomModifierStarred, +} from "./library.js"; + +// Same Map-backed Storage shim used by the T1 library tests +// (see ../library.test.ts) — happy-dom's bundled localStorage doesn't +// survive every destructuring pattern reliably across vitest workers. +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, + }); +}); + +beforeEach(() => { + localStorage.clear(); +}); + +function descriptor( + overrides: Partial = {}, +): CustomModifierDescriptor { + return { + type: "data", + id: asCustomModifierId(`custom:${Math.random().toString(36).slice(2, 10)}`), + name: "Test", + description: "", + version: 1, + primitives: [], + targetAttrs: [], + uiForm: "primitive-composer", + source: "custom", + ...overrides, + }; +} + +describe("custom modifier library — basic round-trip", () => { + it("loadCustomModifierLibrary returns [] when storage is empty", () => { + expect(loadCustomModifierLibrary()).toEqual([]); + }); + + it("loadCustomModifierLibrary returns [] on malformed JSON", () => { + localStorage.setItem(__test__.STORAGE_KEY, "{not json["); + expect(loadCustomModifierLibrary()).toEqual([]); + }); + + it("save then load returns the entry", () => { + const desc = descriptor({ name: "Roundtrip" }); + const saved = saveToCustomModifierLibrary(desc); + expect(saved.ok).toBe(true); + + const loaded = loadCustomModifierLibrary(); + expect(loaded).toHaveLength(1); + expect(loaded[0]!.id).toBe(desc.id); + expect(loaded[0]!.descriptor.name).toBe("Roundtrip"); + expect(loaded[0]!.starred).toBe(false); + expect(typeof loaded[0]!.updatedAt).toBe("number"); + }); + + it("auto-populates createdAt on first save when absent", () => { + const desc = descriptor(); + expect(desc.createdAt).toBeUndefined(); + const saved = saveToCustomModifierLibrary(desc); + expect(saved.ok).toBe(true); + if (!saved.ok) return; + expect(typeof saved.entry.descriptor.createdAt).toBe("number"); + }); + + it("preserves createdAt on subsequent saves", async () => { + const id = asCustomModifierId("custom:stable"); + const first = saveToCustomModifierLibrary(descriptor({ id, name: "A" })); + expect(first.ok).toBe(true); + if (!first.ok) return; + const originalCreatedAt = first.entry.descriptor.createdAt; + expect(originalCreatedAt).toBeDefined(); + + // Wait a tick so updatedAt definitely changes. + await new Promise((r) => setTimeout(r, 2)); + const second = saveToCustomModifierLibrary( + descriptor({ id, name: "B" }), + ); + expect(second.ok).toBe(true); + if (!second.ok) return; + expect(second.entry.descriptor.createdAt).toBe(originalCreatedAt); + expect(second.entry.descriptor.name).toBe("B"); + }); +}); + +describe("custom modifier library — update, remove, star", () => { + it("update by id replaces the descriptor in place", () => { + const id = asCustomModifierId("custom:update"); + saveToCustomModifierLibrary(descriptor({ id, name: "v1" })); + saveToCustomModifierLibrary(descriptor({ id, name: "v2" })); + + const loaded = loadCustomModifierLibrary(); + expect(loaded).toHaveLength(1); + expect(loaded[0]!.descriptor.name).toBe("v2"); + }); + + it("removeFromCustomModifierLibrary deletes by id and returns true", () => { + const id = asCustomModifierId("custom:remove"); + saveToCustomModifierLibrary(descriptor({ id })); + expect(removeFromCustomModifierLibrary(id)).toBe(true); + expect(loadCustomModifierLibrary()).toEqual([]); + }); + + it("removeFromCustomModifierLibrary returns false when id absent", () => { + expect( + removeFromCustomModifierLibrary(asCustomModifierId("custom:nope")), + ).toBe(false); + }); + + it("setCustomModifierStarred toggles the flag", () => { + const id = asCustomModifierId("custom:star"); + saveToCustomModifierLibrary(descriptor({ id })); + setCustomModifierStarred(id, true); + expect(loadCustomModifierLibrary()[0]!.starred).toBe(true); + setCustomModifierStarred(id, false); + expect(loadCustomModifierLibrary()[0]!.starred).toBe(false); + }); +}); + +describe("custom modifier library — capacity and validation", () => { + it("rejects descriptors that fail T19 validation", () => { + // Empty name fails the validator's name-length rule. + const bad = descriptor({ name: "" }); + const result = saveToCustomModifierLibrary(bad); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors).toBeDefined(); + expect(result.errors!.some((e) => e.code === "descriptor.name.length")).toBe( + true, + ); + }); + + it("evicts the oldest non-starred entry when at capacity", async () => { + // Fill with MAX_ENTRIES non-starred entries with strictly-increasing + // updatedAt timestamps. + for (let i = 0; i < __test__.MAX_ENTRIES; i += 1) { + saveToCustomModifierLibrary( + descriptor({ + id: asCustomModifierId(`custom:fill-${i}`), + name: `fill-${i}`, + }), + ); + // Tiny stagger so updatedAt timestamps differ. + await new Promise((r) => setTimeout(r, 1)); + } + expect(loadCustomModifierLibrary()).toHaveLength(__test__.MAX_ENTRIES); + + // Add one more — the oldest (fill-0) should be evicted. + const overflow = descriptor({ + id: asCustomModifierId("custom:overflow"), + name: "overflow", + }); + const result = saveToCustomModifierLibrary(overflow); + expect(result.ok).toBe(true); + + const loaded = loadCustomModifierLibrary(); + expect(loaded).toHaveLength(__test__.MAX_ENTRIES); + expect(loaded.find((e) => e.descriptor.name === "fill-0")).toBeUndefined(); + expect(loaded.find((e) => e.descriptor.name === "overflow")).toBeDefined(); + }); + + it("refuses save when the library is full of starred entries", async () => { + for (let i = 0; i < __test__.MAX_ENTRIES; i += 1) { + const id = asCustomModifierId(`custom:starred-${i}`); + saveToCustomModifierLibrary(descriptor({ id, name: `s-${i}` })); + setCustomModifierStarred(id, true); + await new Promise((r) => setTimeout(r, 1)); + } + const result = saveToCustomModifierLibrary( + descriptor({ id: asCustomModifierId("custom:rejected") }), + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toMatch(/library full/i); + }); +}); diff --git a/packages/chess/src/modifiers/custom/library.ts b/packages/chess/src/modifiers/custom/library.ts new file mode 100644 index 0000000..c8e4de1 --- /dev/null +++ b/packages/chess/src/modifiers/custom/library.ts @@ -0,0 +1,189 @@ +/** + * Custom modifier library — localStorage-backed store of user-authored + * custom modifier descriptors (T21). + * + * Mirrors the T1 modifier-profile library at `../library.ts`: + * - Same MAX_ENTRIES (20) capacity rule with starred-aware FIFO eviction. + * - Same `loadLibrary` / `saveToLibrary` / `deleteFromLibrary` / + * `setStarred` / `duplicateEntry` API surface. + * - Different storage key: `houserules:custom-modifiers:v1`. + * + * On `saveToLibrary` we run T19's validator (`validateCustomDescriptor`) + * before persisting. Invalid descriptors are rejected with the validator's + * error list so the caller can surface inline editor feedback. The library + * never persists a structurally-broken descriptor. + * + * `createdAt` on the descriptor is auto-populated on first save (when the + * descriptor doesn't already carry one); subsequent saves preserve the + * original timestamp so library order stays stable. + */ +import { + parseCustomModifierDescriptor, + safeParseCustomModifierDescriptor, +} from "./schema.js"; +import type { CustomModifierDescriptor, CustomModifierId } from "./types.js"; +import { + validateCustomDescriptor, + type ValidationError, +} from "./validate.js"; + +const STORAGE_KEY = "houserules:custom-modifiers:v1"; +const MAX_ENTRIES = 20; + +export interface SavedCustomModifier { + readonly id: CustomModifierId; + readonly descriptor: CustomModifierDescriptor; + readonly starred: boolean; + readonly updatedAt: number; +} + +export type SaveResult = + | { ok: true; entry: SavedCustomModifier } + | { ok: false; reason: string; errors?: ValidationError[] }; + +/** + * Read every saved custom modifier from storage. Empty/missing/corrupt + * → `[]`. Per-entry shape failures are silently dropped so a single + * bad row never blocks the whole library. + */ +export function loadCustomModifierLibrary(): SavedCustomModifier[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw === null) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter(isSavedCustomModifier); + } catch { + return []; + } +} + +/** Replace the entire library array on disk. Best-effort on quota error. */ +function writeLibrary(entries: readonly SavedCustomModifier[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); + } catch { + /* quota exceeded / storage disabled — best effort */ + } +} + +/** + * Save a new descriptor or update an existing one (matched by descriptor + * id). Runs T19's structural+semantic validator first; rejects with + * the error list on failure. Auto-populates `descriptor.createdAt` on + * first save and preserves it on subsequent saves. + * + * Capacity: when adding a NEW descriptor would exceed MAX_ENTRIES, the + * oldest non-starred entry is evicted. If every entry is starred, the + * save is refused with a human-friendly reason. + */ +export function saveToCustomModifierLibrary( + descriptor: CustomModifierDescriptor, +): SaveResult { + const validation = validateCustomDescriptor(descriptor); + if (!validation.ok) { + return { + ok: false, + reason: "Descriptor failed validation", + errors: validation.errors, + }; + } + + const library = loadCustomModifierLibrary(); + const existingIdx = library.findIndex((e) => e.id === descriptor.id); + const now = Date.now(); + + // Preserve the original createdAt if this is an update; populate it + // for first-time saves. + const existingCreatedAt = + existingIdx >= 0 ? library[existingIdx]?.descriptor.createdAt : undefined; + const createdAt = + descriptor.createdAt ?? existingCreatedAt ?? now; + const stampedDescriptor: CustomModifierDescriptor = { + ...descriptor, + createdAt, + }; + + const entry: SavedCustomModifier = { + id: descriptor.id, + descriptor: stampedDescriptor, + starred: existingIdx >= 0 ? library[existingIdx]!.starred : false, + updatedAt: now, + }; + + if (existingIdx >= 0) { + const updated = library.slice(); + updated[existingIdx] = entry; + writeLibrary(updated); + return { ok: true, entry }; + } + + if (library.length >= MAX_ENTRIES) { + 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 custom modifiers). Unstar one to make room, or delete an entry.", + }; + } + const oldest = evictable[0]!; + const pruned = library.filter((e) => e.id !== oldest.id); + pruned.push(entry); + writeLibrary(pruned); + return { ok: true, entry }; + } + + writeLibrary([...library, entry]); + return { ok: true, entry }; +} + +/** Remove a custom modifier by id. No-op if the id is unknown. Returns true if removed. */ +export function removeFromCustomModifierLibrary( + id: CustomModifierId, +): boolean { + const library = loadCustomModifierLibrary(); + const next = library.filter((e) => e.id !== id); + if (next.length === library.length) return false; + writeLibrary(next); + return true; +} + +/** Toggle the starred flag on a saved custom modifier. */ +export function setCustomModifierStarred( + id: CustomModifierId, + starred: boolean, +): void { + const library = loadCustomModifierLibrary(); + const idx = library.findIndex((e) => e.id === id); + if (idx < 0) return; + const updated = library.slice(); + updated[idx] = { ...library[idx]!, starred, updatedAt: Date.now() }; + writeLibrary(updated); +} + +// ── Internal helpers ────────────────────────────────────────────────── + +function isSavedCustomModifier(value: unknown): value is SavedCustomModifier { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + if ( + typeof v["id"] !== "string" || + typeof v["starred"] !== "boolean" || + typeof v["updatedAt"] !== "number" + ) { + return false; + } + // Validate the nested descriptor structurally via Zod. + const parseResult = safeParseCustomModifierDescriptor(v["descriptor"]); + if (!parseResult.success) return false; + // Discard the parse output — we keep the original raw shape so the + // entry round-trips identically (createdAt preserved, etc.). + void parseCustomModifierDescriptor; + return true; +} + +// Exported for tests only. +export const __test__ = { STORAGE_KEY, MAX_ENTRIES };