From f4355a867574f58a4923509f3ee18db8f9d0cdb9 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 27 Mar 2026 15:42:50 +0100 Subject: [PATCH] Add optional export filename, tests for post-implement features Add optional filename field to export dialog with automatic .json extension handling. Extract resolveFilename() for testability. Add tests for includeHistory flag, bundleToJson, and filename resolution. Add export format compatibility note to CLAUDE.md. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 + apps/web/src/__tests__/export-import.test.ts | 79 +++++++++++++++++++ apps/web/src/components/action-bar.tsx | 4 +- .../src/components/export-method-dialog.tsx | 15 +++- apps/web/src/persistence/export-import.ts | 12 ++- specs/007-json-import-export/spec.md | 2 +- 6 files changed, 105 insertions(+), 8 deletions(-) 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)" + /> +