diff --git a/CLAUDE.md b/CLAUDE.md index 5fa5aca..39441cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,7 @@ docs/agents/ RPI skill artifacts (research reports, plans) - **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks. - **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`. - **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs`). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs). +- **Export format compatibility** — When changing `Encounter`, `Combatant`, `PlayerCharacter`, or `UndoRedoState` types, verify that previously exported JSON files (version 1) still import correctly. If not, bump the `ExportBundle` version and add migration logic in `validateImportBundle()`. - **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process. ## Self-Review Checklist diff --git a/apps/web/src/__tests__/export-import.test.ts b/apps/web/src/__tests__/export-import.test.ts index 6022783..35284ab 100644 --- a/apps/web/src/__tests__/export-import.test.ts +++ b/apps/web/src/__tests__/export-import.test.ts @@ -9,10 +9,13 @@ import { import { describe, expect, it } from "vitest"; import { assembleExportBundle, + bundleToJson, + resolveFilename, validateImportBundle, } from "../persistence/export-import.js"; const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/; +const DEFAULT_FILENAME_RE = /^initiative-export-\d{4}-\d{2}-\d{2}\.json$/; const encounter: Encounter = { combatants: [ @@ -110,6 +113,82 @@ describe("assembleExportBundle", () => { }); }); +describe("assembleExportBundle with includeHistory", () => { + it("excludes undo/redo stacks when includeHistory is false", () => { + const bundle = assembleExportBundle( + encounter, + undoRedoState, + playerCharacters, + false, + ); + expect(bundle.undoStack).toHaveLength(0); + expect(bundle.redoStack).toHaveLength(0); + }); + + it("includes undo/redo stacks when includeHistory is true", () => { + const bundle = assembleExportBundle( + encounter, + undoRedoState, + playerCharacters, + true, + ); + expect(bundle.undoStack).toEqual(undoRedoState.undoStack); + expect(bundle.redoStack).toEqual(undoRedoState.redoStack); + }); + + it("includes undo/redo stacks by default", () => { + const bundle = assembleExportBundle( + encounter, + undoRedoState, + playerCharacters, + ); + expect(bundle.undoStack).toEqual(undoRedoState.undoStack); + }); +}); + +describe("bundleToJson", () => { + it("produces valid JSON that round-trips through validateImportBundle", () => { + const bundle = assembleExportBundle( + encounter, + undoRedoState, + playerCharacters, + ); + const json = bundleToJson(bundle); + const parsed: unknown = JSON.parse(json); + const result = validateImportBundle(parsed); + expect(typeof result).toBe("object"); + }); +}); + +describe("resolveFilename", () => { + it("uses date-based default when no name provided", () => { + const result = resolveFilename(); + expect(result).toMatch(DEFAULT_FILENAME_RE); + }); + + it("uses date-based default for empty string", () => { + const result = resolveFilename(""); + expect(result).toMatch(DEFAULT_FILENAME_RE); + }); + + it("uses date-based default for whitespace-only string", () => { + const result = resolveFilename(" "); + expect(result).toMatch(DEFAULT_FILENAME_RE); + }); + + it("appends .json to a custom name", () => { + expect(resolveFilename("my-encounter")).toBe("my-encounter.json"); + }); + + it("does not double-append .json", () => { + expect(resolveFilename("my-encounter.json")).toBe("my-encounter.json"); + }); + + it("trims whitespace from custom name", () => { + expect(resolveFilename(" my-encounter ")).toBe("my-encounter.json"); + }); +}); + describe("round-trip: export then import", () => { it("produces identical state after round-trip", () => { const bundle = assembleExportBundle( diff --git a/apps/web/src/components/action-bar.tsx b/apps/web/src/components/action-bar.tsx index d30cda9..f0bec89 100644 --- a/apps/web/src/components/action-bar.tsx +++ b/apps/web/src/components/action-bar.tsx @@ -459,14 +459,14 @@ export function ActionBar({ >(null); const handleExportDownload = useCallback( - (includeHistory: boolean) => { + (includeHistory: boolean, filename: string) => { const bundle = assembleExportBundle( encounter, undoRedoState, playerCharacters, includeHistory, ); - triggerDownload(bundle); + triggerDownload(bundle, filename); }, [encounter, undoRedoState, playerCharacters], ); diff --git a/apps/web/src/components/export-method-dialog.tsx b/apps/web/src/components/export-method-dialog.tsx index e177000..3ecfe90 100644 --- a/apps/web/src/components/export-method-dialog.tsx +++ b/apps/web/src/components/export-method-dialog.tsx @@ -2,10 +2,11 @@ import { Check, ClipboardCopy, Download, X } from "lucide-react"; import { useCallback, useState } from "react"; import { Button } from "./ui/button.js"; import { Dialog } from "./ui/dialog.js"; +import { Input } from "./ui/input.js"; interface ExportMethodDialogProps { open: boolean; - onDownload: (includeHistory: boolean) => void; + onDownload: (includeHistory: boolean, filename: string) => void; onCopyToClipboard: (includeHistory: boolean) => void; onClose: () => void; } @@ -17,10 +18,12 @@ export function ExportMethodDialog({ onClose, }: Readonly) { const [includeHistory, setIncludeHistory] = useState(false); + const [filename, setFilename] = useState(""); const [copied, setCopied] = useState(false); const handleClose = useCallback(() => { setIncludeHistory(false); + setFilename(""); setCopied(false); onClose(); }, [onClose]); @@ -39,6 +42,14 @@ export function ExportMethodDialog({ +
+ setFilename(e.target.value)} + placeholder="Filename (optional)" + /> +