From b39dcb4965d78a3cfa670ad997006c727d8071cb Mon Sep 17 00:00:00 2001 From: Project7 Date: Thu, 28 May 2026 09:55:02 +0000 Subject: [PATCH] [#232] Show actionable errors for invalid cartoon cuts.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CartoonPreview and CutListPanel now show an "Invalid cuts file" error with the exact validation message plus actionable guidance: the file must follow the OWS v1 schema, and the user should ask Claude to regenerate it using the v1 cuts schema. Missing files (404) still show "No cuts yet" — invalid files (400) are no longer silently treated as no cuts. No destructive reset action added (guidance-only recovery, per MVP safety requirement). Tests: frontend invalid-schema and invalid-JSON error display with v1 guidance, 404 still shows No cuts; backend route returns 404 for missing, 400 with validation error for wrong schema, 400 for malformed JSON. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/routes/stories.test.ts | 35 +++++++++++++++++++++ app/web/components/CartoonPreview.tsx | 8 +++-- app/web/components/CutListPanel.test.tsx | 32 +++++++++++++++++++ app/web/components/CutListPanel.tsx | 8 +++-- app/web/components/preview-routing.test.tsx | 19 +++++++++-- 5 files changed, 95 insertions(+), 7 deletions(-) diff --git a/app/routes/stories.test.ts b/app/routes/stories.test.ts index b4402a1..7450c74 100644 --- a/app/routes/stories.test.ts +++ b/app/routes/stories.test.ts @@ -231,6 +231,41 @@ describe("POST /upload-clean/:cutId route", () => { expect(res.status).toBe(404); }); + it("GET cuts returns 404 when cuts file is missing", async () => { + const storyDir = path.join(tmpDir, "no-cuts-story"); + fs.mkdirSync(storyDir, { recursive: true }); + + const res = await app.request("/api/stories/no-cuts-story/cuts/plot-01"); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toContain("not found"); + }); + + it("GET cuts returns 400 with validation error for invalid schema", async () => { + const storyDir = path.join(tmpDir, "bad-cuts-story"); + fs.mkdirSync(storyDir, { recursive: true }); + fs.writeFileSync( + path.join(storyDir, "plot-01.cuts.json"), + JSON.stringify({ version: 1, plotFile: "plot-01", cuts: [{ id: "c01", shot: "wide" }] }), + ); + + const res = await app.request("/api/stories/bad-cuts-story/cuts/plot-01"); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("invalid"); + }); + + it("GET cuts returns 400 for malformed JSON", async () => { + const storyDir = path.join(tmpDir, "malformed-cuts-story"); + fs.mkdirSync(storyDir, { recursive: true }); + fs.writeFileSync(path.join(storyDir, "plot-01.cuts.json"), "{ not valid json"); + + const res = await app.request("/api/stories/malformed-cuts-story/cuts/plot-01"); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("invalid JSON"); + }); + it("detects Korean language from structure.md title without .story.json language", async () => { const storyDir = path.join(tmpDir, "korean-story"); fs.mkdirSync(storyDir, { recursive: true }); diff --git a/app/web/components/CartoonPreview.tsx b/app/web/components/CartoonPreview.tsx index 559a70f..81b03d2 100644 --- a/app/web/components/CartoonPreview.tsx +++ b/app/web/components/CartoonPreview.tsx @@ -200,8 +200,12 @@ export function CartoonPreview({ storyName, fileName, authFetch }: CartoonPrevie if (error) { return ( -
-

{error}

+
+

Invalid cuts file

+

{error}

+

+ {plotFile}.cuts.json must follow the OWS v1 schema. Ask Claude to regenerate it using the v1 cuts schema shown in the cartoon writing instructions. +

diff --git a/app/web/components/CutListPanel.test.tsx b/app/web/components/CutListPanel.test.tsx index 880209a..0e6b915 100644 --- a/app/web/components/CutListPanel.test.tsx +++ b/app/web/components/CutListPanel.test.tsx @@ -276,4 +276,36 @@ describe("CutListPanel", () => { expect(screen.getByText("Retry")).toBeInTheDocument(); }); }); + + it("shows actionable v1 schema guidance for invalid cuts (wrong schema)", async () => { + const authFetch = mockAuthFetch({ ok: false, status: 400, data: { error: "plot-01.cuts.json is invalid: Cut 0 has invalid shotType" } }); + render(); + + await waitFor(() => { + expect(screen.getByTestId("cuts-error")).toBeInTheDocument(); + expect(screen.getByText("Invalid cuts file")).toBeInTheDocument(); + expect(screen.getByText(/invalid shotType/)).toBeInTheDocument(); + expect(screen.getByText(/OWS v1 schema/)).toBeInTheDocument(); + }); + }); + + it("shows actionable error for invalid JSON", async () => { + const authFetch = mockAuthFetch({ ok: false, status: 400, data: { error: "plot-01.cuts.json contains invalid JSON" } }); + render(); + + await waitFor(() => { + expect(screen.getByText(/contains invalid JSON/)).toBeInTheDocument(); + expect(screen.getByText(/OWS v1 schema/)).toBeInTheDocument(); + }); + }); + + it("missing cuts file (404) shows No cuts, not an error", async () => { + const authFetch = mockAuthFetch({ ok: false, status: 404, data: { error: "Cuts file not found" } }); + render(); + + await waitFor(() => { + expect(screen.getByText("No cuts yet")).toBeInTheDocument(); + }); + expect(screen.queryByTestId("cuts-error")).not.toBeInTheDocument(); + }); }); diff --git a/app/web/components/CutListPanel.tsx b/app/web/components/CutListPanel.tsx index 856e2cc..447c47d 100644 --- a/app/web/components/CutListPanel.tsx +++ b/app/web/components/CutListPanel.tsx @@ -267,8 +267,12 @@ export function CutListPanel({ storyName, fileName, authFetch, language }: CutLi if (error) { return ( -
-

{error}

+
+

Invalid cuts file

+

{error}

+

+ {plotFile}.cuts.json must follow the OWS v1 schema. Ask Claude to regenerate it using the v1 cuts schema from the cartoon writing instructions. +

); diff --git a/app/web/components/preview-routing.test.tsx b/app/web/components/preview-routing.test.tsx index be2a36a..5b7f04d 100644 --- a/app/web/components/preview-routing.test.tsx +++ b/app/web/components/preview-routing.test.tsx @@ -53,16 +53,29 @@ describe("CartoonPreview", () => { }); }); - it("shows error state on fetch failure", async () => { - const authFetch = mockAuthFetch({ ok: false, status: 400, data: { error: "Invalid JSON" } }); + it("shows actionable v1 schema error for invalid cuts", async () => { + const authFetch = mockAuthFetch({ ok: false, status: 400, data: { error: "plot-01.cuts.json is invalid: Cut 0 missing numeric id" } }); render(); await waitFor(() => { - expect(screen.getByText("Invalid JSON")).toBeInTheDocument(); + expect(screen.getByTestId("cuts-error")).toBeInTheDocument(); + expect(screen.getByText("Invalid cuts file")).toBeInTheDocument(); + expect(screen.getByText(/missing numeric id/)).toBeInTheDocument(); + expect(screen.getByText(/OWS v1 schema/)).toBeInTheDocument(); expect(screen.getByText("Retry")).toBeInTheDocument(); }); }); + it("missing cuts (404) shows No cuts, not an error", async () => { + const authFetch = mockAuthFetch({ ok: false, status: 404, data: { error: "Cuts file not found" } }); + render(); + + await waitFor(() => { + expect(screen.getByText("No cuts yet")).toBeInTheDocument(); + }); + expect(screen.queryByTestId("cuts-error")).not.toBeInTheDocument(); + }); + it("renders cut with final image", async () => { const cutsData = { version: 1,