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) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-27 15:42:50 +01:00
parent 209df13c32
commit f4355a8675
6 changed files with 105 additions and 8 deletions

View File

@@ -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

View File

@@ -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(

View File

@@ -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],
);

View File

@@ -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<ExportMethodDialogProps>) {
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({
<X className="h-4 w-4" />
</Button>
</div>
<div className="mb-3">
<Input
type="text"
value={filename}
onChange={(e) => setFilename(e.target.value)}
placeholder="Filename (optional)"
/>
</div>
<label className="mb-4 flex items-center gap-2 text-sm">
<input
type="checkbox"
@@ -53,7 +64,7 @@ export function ExportMethodDialog({
type="button"
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
onClick={() => {
onDownload(includeHistory);
onDownload(includeHistory, filename);
handleClose();
}}
>

View File

@@ -84,12 +84,18 @@ export function bundleToJson(bundle: ExportBundle): string {
return JSON.stringify(bundle, null, 2);
}
export function triggerDownload(bundle: ExportBundle): void {
export function resolveFilename(name?: string): string {
const base =
name?.trim() ||
`initiative-export-${new Date().toISOString().slice(0, 10)}`;
return base.endsWith(".json") ? base : `${base}.json`;
}
export function triggerDownload(bundle: ExportBundle, name?: string): void {
const blob = new Blob([bundleToJson(bundle)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const date = new Date().toISOString().slice(0, 10);
const filename = `initiative-export-${date}.json`;
const filename = resolveFilename(name);
const anchor = document.createElement("a");
anchor.href = url;

View File

@@ -73,7 +73,7 @@ A DM accidentally selects a wrong file (a non-JSON file, a corrupted export, or
- **FR-001**: The system MUST provide an export action accessible from the UI that lets the user download the application state as a `.json` file or copy it to the clipboard.
- **FR-002**: The exported data MUST contain the current encounter (combatants and turn/round state) and player character templates. Undo/redo stacks MUST be includable via a user option (default: excluded).
- **FR-003**: The exported file MUST use a human-readable filename that includes context (e.g., date or encounter info).
- **FR-003**: The exported file MUST use a human-readable default filename that includes the date. The user MAY optionally specify a custom filename; the `.json` extension is appended automatically if not provided.
- **FR-004**: The system MUST provide an import action accessible from the UI that lets the user choose between uploading a `.json` file or pasting from the clipboard.
- **FR-005**: On import, the system MUST replace the current encounter, undo/redo history, and player characters with the imported data.
- **FR-006**: The system MUST show a confirmation dialog before importing if the current encounter is non-empty (has at least one combatant).