Files
infinite/.claude-flow/logs/headless/testgaps_1778787967872_nkg5mv_result.log
2026-05-14 21:08:13 +00:00

155 lines
42 KiB
Plaintext

[2026-05-14T19:49:06.743Z] RESULT
============================================================
{
"success": true,
"output": "Now I have everything I need. Here's the complete test coverage analysis:\n\n---\n\n## Test Coverage Analysis: `infinite` project\n\n**Current coverage: 0%** — no test framework is installed and no test files exist.\n\n---\n\n### Recommended setup\n\nAdd **Vitest** + **React Testing Library** (compatible with Vite, no ejection needed):\n\n```bash\nnpm install -D vitest @testing-library/react @testing-library/user-event \\\n @testing-library/jest-dom jsdom @vitest/coverage-v8\n```\n\nAdd to `vite.config.ts`:\n```ts\ntest: {\n environment: \"jsdom\",\n setupFiles: [\"./tests/setup.ts\"],\n globals: true,\n}\n```\n\n---\n\n## Gap 1 — `parseTransformScale` (pure function, highest ROI)\n\n**Location:** `src/canvas/cards/NoteCardView.tsx:62` — module-private, exercised only through drag behavior.\n\nUntested cases:\n\n| Input | Expected | Risk |\n|---|---|---|\n| `\"scale(2)\"` | 2 | baseline |\n| `\"scale(0.5)\"` | 0.5 | sub-1 zoom |\n| `\"translate(10px,20px) scale(1.5)\"` | 1.5 | compound transform |\n| `\"\"` (empty string) | 1 | fallback |\n| `\"rotate(45deg)\"` (no scale) | 1 | fallback |\n| `\"scale(NaN)\"` | NaN | edge — could cause infinite card position |\n\n```ts\n// tests/canvas/cards/parseTransformScale.test.ts\nimport { describe, it, expect } from \"vitest\";\n\n// Re-export needed — or test via NoteCardView drag behavior\n// Option: move parseTransformScale to src/canvas/utils.ts and export\n\ndescribe(\"parseTransformScale\", () => {\n it(\"parses a simple scale transform\", () => {\n expect(parseTransformScale(\"scale(2)\")).toBe(2);\n });\n\n it(\"parses sub-unity scale\", () => {\n expect(parseTransformScale(\"scale(0.5)\")).toBe(0.5);\n });\n\n it(\"parses scale from a compound transform string\", () => {\n expect(parseTransformScale(\"translate(10px, 20px) scale(1.5)\")).toBe(1.5);\n });\n\n it(\"returns 1 when no scale token is present\", () => {\n expect(parseTransformScale(\"rotate(45deg)\")).toBe(1);\n });\n\n it(\"returns 1 for empty string\", () => {\n expect(parseTransformScale(\"\")).toBe(1);\n });\n});\n```\n\n**Recommendation:** move `parseTransformScale` to `src/canvas/utils.ts` and export it so it's directly testable without going through the DOM.\n\n---\n\n## Gap 2 — Zoom math in `Canvas.onWheel` (`src/canvas/Canvas.tsx:38`)\n\nThree untested behaviors:\n\n### 2a — Scale clamping\n\n```ts\n// tests/canvas/Canvas.test.tsx\nit(\"does not zoom below MIN_SCALE (0.1)\", async () => {\n const { container } = render(<Canvas initialCards={[]} />);\n const user = userEvent.setup();\n\n // Simulate many scroll-up events to try to zoom out past the limit\n for (let i = 0; i < 200; i++) {\n fireEvent.wheel(container.firstChild!, {\n deltaY: 500, ctrlKey: true,\n });\n }\n\n expect(screen.getByText(/\\d+%/)).toHaveTextContent(\"10%\");\n});\n\nit(\"does not zoom above MAX_SCALE (4)\", async () => {\n // ... same pattern with deltaY: -500 (scroll up = zoom in)\n expect(screen.getByText(/\\d+%/)).toHaveTextContent(\"400%\");\n});\n```\n\n### 2b — Zoom only fires with Ctrl/Meta key\n\n```ts\nit(\"ignores wheel events without ctrlKey or metaKey\", () => {\n render(<Canvas initialCards={[]} />);\n fireEvent.wheel(screen.getByRole(\"...\"), { deltaY: -100 }); // no ctrlKey\n expect(screen.getByText(/\\d+%/)).toHaveTextContent(\"100%\"); // unchanged\n});\n```\n\n### 2c — Zoom-to-cursor math (viewport pivot)\n\n```ts\nit(\"zooms toward the cursor position, keeping the point under cursor stable\", () => {\n // Render at known position, fire wheel at cursor (mx=100, my=100)\n // Before: vp = {x:0, y:0, scale:1}\n // After zoom in: the world point under (100,100) should remain there\n // i.e. new_vp.x + 100 * new_scale === old_vp.x + 100 * old_scale (approx)\n});\n```\n\n---\n\n## Gap 3 — Pan initiation logic (`src/canvas/Canvas.tsx:55`)\n\nThe `onPointerDown` guard has three branches, only tested by integration:\n\n```ts\n// tests/canvas/Canvas.test.tsx\ndescribe(\"pan initiation\", () => {\n it(\"starts pan on middle-click (button=1) regardless of space key\", () => {\n render(<Canvas initialCards={[]} />);\n const canvas = screen.getByTestId(\"canvas-container\");\n fireEvent.pointerDown(canvas, { button: 1, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(canvas, { clientX: 50, clientY: 30 });\n // HUD should show x 50, y 30\n expect(screen.getByText(\"x 50\")).toBeInTheDocument();\n });\n\n it(\"starts pan on left-click when space is held\", async () => {\n render(<Canvas initialCards={[]} />);\n fireEvent.keyDown(window, { code: \"Space\" });\n const canvas = screen.getByTestId(\"canvas-container\");\n fireEvent.pointerDown(canvas, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(canvas, { clientX: 40, clientY: 20 });\n expect(screen.getByText(\"x 40\")).toBeInTheDocument();\n });\n\n it(\"does NOT pan on left-click without space held\", () => {\n render(<Canvas initialCards={[]} />);\n const canvas = screen.getByTestId(\"canvas-container\");\n fireEvent.pointerDown(canvas, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(canvas, { clientX: 50, clientY: 50 });\n expect(screen.getByText(\"x 0\")).toBeInTheDocument(); // unchanged\n });\n\n it(\"does NOT pan on right-click (button=2)\", () => {\n // same pattern with button: 2\n });\n});\n```\n\n---\n\n## Gap 4 — Space key state (`src/canvas/Canvas.tsx:23`)\n\n```ts\ndescribe(\"space key\", () => {\n it(\"sets pan-mode class when space is held\", () => {\n render(<Canvas initialCards={[]} />);\n fireEvent.keyDown(window, { code: \"Space\" });\n expect(screen.getByTestId(\"canvas-container\")).toHaveClass(\"pan-mode\");\n });\n\n it(\"removes pan-mode class when space is released\", () => {\n render(<Canvas initialCards={[]} />);\n fireEvent.keyDown(window, { code: \"Space\" });\n fireEvent.keyUp(window, { code: \"Space\" });\n expect(screen.getByTestId(\"canvas-container\")).not.toHaveClass(\"pan-mode\");\n });\n\n it(\"ignores key repeat events (e.repeat=true)\", () => {\n // Should not double-set spaceHeld — no observable effect here,\n // but guards against future state bugs\n render(<Canvas initialCards={[]} />);\n fireEvent.keyDown(window, { code: \"Space\", repeat: true });\n // State is already false so class should not appear (tests idempotency)\n expect(screen.getByTestId(\"canvas-container\")).not.toHaveClass(\"pan-mode\");\n });\n\n it(\"cleans up key listeners on unmount\", () => {\n const { unmount } = render(<Canvas initialCards={[]} />);\n const removeSpy = vi.spyOn(window, \"removeEventListener\");\n unmount();\n expect(removeSpy).toHaveBeenCalledWith(\"keydown\", expect.any(Function));\n expect(removeSpy).toHaveBeenCalledWith(\"keyup\", expect.any(Function));\n });\n});\n```\n\n---\n\n## Gap 5 — `updateCard` (`src/canvas/Canvas.tsx:77`)\n\n```ts\ndescribe(\"updateCard\", () => {\n it(\"patches only the target card, leaving others unchanged\", () => {\n const cards: Card[] = [\n { id: \"a\", kind: \"note\", x: 0, y: 0, width: 100, height: 100, z: 0, text: \"A\" },\n { id: \"b\", kind: \"note\", x: 0, y: 0, width: 100, height: 100, z: 0, text: \"B\" },\n ];\n render(<Canvas initialCards={cards} />);\n // Simulate NoteCardView calling onUpdate for card \"a\"\n const [textareaA] = screen.getAllByRole(\"textbox\");\n await userEvent.clear(textareaA);\n await userEvent.type(textareaA, \"updated\");\n expect(screen.getAllByRole(\"textbox\")[0]).toHaveValue(\"updated\");\n expect(screen.getAllByRole(\"textbox\")[1]).toHaveValue(\"B\"); // unchanged\n });\n});\n```\n\n---\n\n## Gap 6 — Card rendering dispatch (`src/canvas/Canvas.tsx:102`)\n\n```ts\ndescribe(\"card kind rendering\", () => {\n it(\"renders NoteCardView for note cards\", () => {\n const card: Card = { id: \"1\", kind: \"note\", x:0, y:0, width:100, height:100, z:0, text:\"hi\" };\n render(<Canvas initialCards={[card]} />);\n expect(screen.getByRole(\"textbox\")).toBeInTheDocument();\n });\n\n it(\"renders nothing for terminal cards (not yet implemented)\", () => {\n const card: Card = { id: \"1\", kind: \"terminal\", x:0, y:0, width:100, height:100, z:0, ptyId:\"pty1\" };\n render(<Canvas initialCards={[card]} />);\n expect(screen.queryByRole(\"textbox\")).toBeNull();\n });\n\n it(\"renders nothing for app cards\", () => {\n const card: Card = { id: \"1\", kind: \"app\", x:0, y:0, width:100, height:100, z:0, xWindowId:42, command:\"xterm\" };\n render(<Canvas initialCards={[card]} />);\n expect(screen.queryByRole(\"textbox\")).toBeNull();\n });\n\n it(\"renders nothing for thumbnail cards\", () => {\n const card: Card = { id: \"1\", kind: \"thumbnail\", x:0, y:0, width:100, height:100, z:0, refCardId:\"other\", label:\"thumb\" };\n render(<Canvas initialCards={[card]} />);\n expect(screen.queryByRole(\"textbox\")).toBeNull();\n });\n});\n```\n\n---\n\n## Gap 7 — `NoteCardView` drag with scale compensation (`NoteCardView.tsx:21`)\n\n```ts\n// tests/canvas/cards/NoteCardView.test.tsx\ndescribe(\"drag with scale compensation\", () => {\n it(\"divides pixel delta by canvas scale when dragging\", () => {\n const onUpdate = vi.fn();\n const card: NoteCard = { id:\"1\", kind:\"note\", x:100, y:100, width:200, height:150, z:0, text:\"\" };\n render(\n // Wrap in a div that mimics .canvas-world with scale(2)\n <div className=\"canvas-world\" style={{ transform: \"translate(0px,0px) scale(2)\" }}>\n <NoteCardView card={card} onUpdate={onUpdate} />\n </div>\n );\n const header = screen.getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(header, { clientX: 100, clientY: 60 });\n // At scale 2: 100px drag → 50 world units\n expect(onUpdate).toHaveBeenCalledWith({ x: 150, y: 130 }); // 100+50, 100+30\n });\n\n it(\"uses scale 1 when .canvas-world ancestor is absent\", () => {\n const onUpdate = vi.fn();\n const card: NoteCard = { id:\"1\", kind:\"note\", x:0, y:0, width:200, height:150, z:0, text:\"\" };\n render(<NoteCardView card={card} onUpdate={onUpdate} />);\n const header = screen.getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(header, { clientX: 50, clientY: 40 });\n expect(onUpdate).toHaveBeenCalledWith({ x: 50, y: 40 }); // no scale division\n });\n\n it(\"ignores non-left-button pointer down on header\", () => {\n const onUpdate = vi.fn();\n const card: NoteCard = { id:\"1\", kind:\"note\", x:0, y:0, width:200, height:150, z:0, text:\"\" };\n render(<NoteCardView card={card} onUpdate={onUpdate} />);\n const header = screen.getByText(\"note\");\n fireEvent.pointerDown(header, { button: 2, clientX: 0, clientY: 0 }); // right click\n fireEvent.pointerMove(header, { clientX: 50, clientY: 50 });\n expect(onUpdate).not.toHaveBeenCalled();\n });\n});\n```\n\n---\n\n## Gap 8 — `NoteCardView` event propagation (`NoteCardView.tsx:14,54`)\n\n```ts\ndescribe(\"event propagation stoppage\", () => {\n it(\"header pointerDown stops propagation (prevents canvas from starting pan)\", () => {\n const canvasHandler = vi.fn();\n render(\n <div onPointerDown={canvasHandler}>\n <NoteCardView card={...} onUpdate={vi.fn()} />\n </div>\n );\n fireEvent.pointerDown(screen.getByText(\"note\"), { button: 0 });\n expect(canvasHandler).not.toHaveBeenCalled();\n });\n\n it(\"textarea pointerDown stops propagation\", () => {\n const canvasHandler = vi.fn();\n render(\n <div onPointerDown={canvasHandler}>\n <NoteCardView card={...} onUpdate={vi.fn()} />\n </div>\n );\n fireEvent.pointerDown(screen.getByRole(\"textbox\"));\n expect(canvasHandler).not.toHaveBeenCalled();\n });\n});\n```\n\n---\n\n## Gap 9 — Missing error handling tests\n\n| Location | Unchecked scenario | Risk |\n|---|---|---|\n| `Canvas.tsx:42` `containerRef.current!` | ref not yet attached at mount | crashes in strict-mode double-invoke |\n| `NoteCardView.tsx:23` `worldEl` null check | `closest(\".canvas-world\")` returns null — handled, but the scale-1 fallback is never asserted | silent wrong behavior |\n| `Canvas.tsx:46` `Math.exp(-e.deltaY * ZOOM_SENSITIVITY)` | `deltaY = 0` → factor=1, scale unchanged | no crash, but worth asserting no-op |\n| `Canvas.tsx:78` `updateCard` with unknown `id` | no matching card — map returns unchanged array | silent no-op, never tested |\n\n```ts\nit(\"updateCard with unknown id leaves cards array unchanged\", () => {\n // Trigger via a hypothetical future API or test internal via rendering\n // No crash, no card mutation\n});\n\nit(\"wheel with deltaY=0 does not change the viewport scale\", () => {\n render(<Canvas initialCards={[]} />);\n fireEvent.wheel(..., { deltaY: 0, ctrlKey: true });\n expect(screen.getByText(/\\d+%/)).toHaveTextContent(\"100%\");\n});\n```\n\n---\n\n## Gap 10 — Integration gaps\n\n```ts\n// tests/integration/Canvas.integration.test.tsx\n\nit(\"dragging a card while space is held does not pan the canvas\", () => {\n // Space held = pan mode, but header pointerDown stops propagation\n // Net result: card drags, canvas does NOT pan\n});\n\nit(\"releasing pointer outside the window ends the drag cleanly (pointerCancel)\", () => {\n // onPointerCancel is wired to onPointerUp — test that panState clears\n});\n\nit(\"HUD displays live viewport coordinates during pan\", () => {\n // Pan 80px right, 40px down — HUD shows \"x 80\" \"y 40\"\n});\n\nit(\"multiple cards each maintain independent drag state\", () => {\n // Drag card A then card B — positions are independent\n});\n```\n\n---\n\n## Priority order\n\n| Priority | Gap | Why |\n|---|---|---|\n| 1 | `parseTransformScale` unit tests | Pure function, highest ROI, reveals need to export from `utils.ts` |\n| 2 | Scale clamping (MIN/MAX) | Prevents silent UX regression on zoom |\n| 3 | Pan initiation branches | Core interaction, 3 branches, all uncovered |\n| 4 | Card rendering dispatch | Guards future card kind additions from silent breakage |\n| 5 | Scale-compensated drag | Subtle math, easy to regress |\n| 6 | Event propagation | Prevents pan/drag interference bugs |\n| 7 | Keyboard listener cleanup | Memory leak guard |\n| 8 | Integration scenarios | End-to-end confidence |\n",
"parsedOutput": {
"sections": [
{
"title": "Test Coverage Analysis: `infinite` project",
"content": "\n**Current coverage: 0%** — no test framework is installed and no test files exist.\n\n---\n\n",
"level": 2
},
{
"title": "Recommended setup",
"content": "\nAdd **Vitest** + **React Testing Library** (compatible with Vite, no ejection needed):\n\n```bash\nnpm install -D vitest @testing-library/react @testing-library/user-event \\\n @testing-library/jest-dom jsdom @vitest/coverage-v8\n```\n\nAdd to `vite.config.ts`:\n```ts\ntest: {\n environment: \"jsdom\",\n setupFiles: [\"./tests/setup.ts\"],\n globals: true,\n}\n```\n\n---\n\n",
"level": 3
},
{
"title": "Gap 1 — `parseTransformScale` (pure function, highest ROI)",
"content": "\n**Location:** `src/canvas/cards/NoteCardView.tsx:62` — module-private, exercised only through drag behavior.\n\nUntested cases:\n\n| Input | Expected | Risk |\n|---|---|---|\n| `\"scale(2)\"` | 2 | baseline |\n| `\"scale(0.5)\"` | 0.5 | sub-1 zoom |\n| `\"translate(10px,20px) scale(1.5)\"` | 1.5 | compound transform |\n| `\"\"` (empty string) | 1 | fallback |\n| `\"rotate(45deg)\"` (no scale) | 1 | fallback |\n| `\"scale(NaN)\"` | NaN | edge — could cause infinite card position |\n\n```ts\n// tests/canvas/cards/parseTransformScale.test.ts\nimport { describe, it, expect } from \"vitest\";\n\n// Re-export needed — or test via NoteCardView drag behavior\n// Option: move parseTransformScale to src/canvas/utils.ts and export\n\ndescribe(\"parseTransformScale\", () => {\n it(\"parses a simple scale transform\", () => {\n expect(parseTransformScale(\"scale(2)\")).toBe(2);\n });\n\n it(\"parses sub-unity scale\", () => {\n expect(parseTransformScale(\"scale(0.5)\")).toBe(0.5);\n });\n\n it(\"parses scale from a compound transform string\", () => {\n expect(parseTransformScale(\"translate(10px, 20px) scale(1.5)\")).toBe(1.5);\n });\n\n it(\"returns 1 when no scale token is present\", () => {\n expect(parseTransformScale(\"rotate(45deg)\")).toBe(1);\n });\n\n it(\"returns 1 for empty string\", () => {\n expect(parseTransformScale(\"\")).toBe(1);\n });\n});\n```\n\n**Recommendation:** move `parseTransformScale` to `src/canvas/utils.ts` and export it so it's directly testable without going through the DOM.\n\n---\n\n",
"level": 2
},
{
"title": "Gap 2 — Zoom math in `Canvas.onWheel` (`src/canvas/Canvas.tsx:38`)",
"content": "\nThree untested behaviors:\n\n",
"level": 2
},
{
"title": "2a — Scale clamping",
"content": "\n```ts\n// tests/canvas/Canvas.test.tsx\nit(\"does not zoom below MIN_SCALE (0.1)\", async () => {\n const { container } = render(<Canvas initialCards={[]} />);\n const user = userEvent.setup();\n\n // Simulate many scroll-up events to try to zoom out past the limit\n for (let i = 0; i < 200; i++) {\n fireEvent.wheel(container.firstChild!, {\n deltaY: 500, ctrlKey: true,\n });\n }\n\n expect(screen.getByText(/\\d+%/)).toHaveTextContent(\"10%\");\n});\n\nit(\"does not zoom above MAX_SCALE (4)\", async () => {\n // ... same pattern with deltaY: -500 (scroll up = zoom in)\n expect(screen.getByText(/\\d+%/)).toHaveTextContent(\"400%\");\n});\n```\n\n",
"level": 3
},
{
"title": "2b — Zoom only fires with Ctrl/Meta key",
"content": "\n```ts\nit(\"ignores wheel events without ctrlKey or metaKey\", () => {\n render(<Canvas initialCards={[]} />);\n fireEvent.wheel(screen.getByRole(\"...\"), { deltaY: -100 }); // no ctrlKey\n expect(screen.getByText(/\\d+%/)).toHaveTextContent(\"100%\"); // unchanged\n});\n```\n\n",
"level": 3
},
{
"title": "2c — Zoom-to-cursor math (viewport pivot)",
"content": "\n```ts\nit(\"zooms toward the cursor position, keeping the point under cursor stable\", () => {\n // Render at known position, fire wheel at cursor (mx=100, my=100)\n // Before: vp = {x:0, y:0, scale:1}\n // After zoom in: the world point under (100,100) should remain there\n // i.e. new_vp.x + 100 * new_scale === old_vp.x + 100 * old_scale (approx)\n});\n```\n\n---\n\n",
"level": 3
},
{
"title": "Gap 3 — Pan initiation logic (`src/canvas/Canvas.tsx:55`)",
"content": "\nThe `onPointerDown` guard has three branches, only tested by integration:\n\n```ts\n// tests/canvas/Canvas.test.tsx\ndescribe(\"pan initiation\", () => {\n it(\"starts pan on middle-click (button=1) regardless of space key\", () => {\n render(<Canvas initialCards={[]} />);\n const canvas = screen.getByTestId(\"canvas-container\");\n fireEvent.pointerDown(canvas, { button: 1, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(canvas, { clientX: 50, clientY: 30 });\n // HUD should show x 50, y 30\n expect(screen.getByText(\"x 50\")).toBeInTheDocument();\n });\n\n it(\"starts pan on left-click when space is held\", async () => {\n render(<Canvas initialCards={[]} />);\n fireEvent.keyDown(window, { code: \"Space\" });\n const canvas = screen.getByTestId(\"canvas-container\");\n fireEvent.pointerDown(canvas, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(canvas, { clientX: 40, clientY: 20 });\n expect(screen.getByText(\"x 40\")).toBeInTheDocument();\n });\n\n it(\"does NOT pan on left-click without space held\", () => {\n render(<Canvas initialCards={[]} />);\n const canvas = screen.getByTestId(\"canvas-container\");\n fireEvent.pointerDown(canvas, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(canvas, { clientX: 50, clientY: 50 });\n expect(screen.getByText(\"x 0\")).toBeInTheDocument(); // unchanged\n });\n\n it(\"does NOT pan on right-click (button=2)\", () => {\n // same pattern with button: 2\n });\n});\n```\n\n---\n\n",
"level": 2
},
{
"title": "Gap 4 — Space key state (`src/canvas/Canvas.tsx:23`)",
"content": "\n```ts\ndescribe(\"space key\", () => {\n it(\"sets pan-mode class when space is held\", () => {\n render(<Canvas initialCards={[]} />);\n fireEvent.keyDown(window, { code: \"Space\" });\n expect(screen.getByTestId(\"canvas-container\")).toHaveClass(\"pan-mode\");\n });\n\n it(\"removes pan-mode class when space is released\", () => {\n render(<Canvas initialCards={[]} />);\n fireEvent.keyDown(window, { code: \"Space\" });\n fireEvent.keyUp(window, { code: \"Space\" });\n expect(screen.getByTestId(\"canvas-container\")).not.toHaveClass(\"pan-mode\");\n });\n\n it(\"ignores key repeat events (e.repeat=true)\", () => {\n // Should not double-set spaceHeld — no observable effect here,\n // but guards against future state bugs\n render(<Canvas initialCards={[]} />);\n fireEvent.keyDown(window, { code: \"Space\", repeat: true });\n // State is already false so class should not appear (tests idempotency)\n expect(screen.getByTestId(\"canvas-container\")).not.toHaveClass(\"pan-mode\");\n });\n\n it(\"cleans up key listeners on unmount\", () => {\n const { unmount } = render(<Canvas initialCards={[]} />);\n const removeSpy = vi.spyOn(window, \"removeEventListener\");\n unmount();\n expect(removeSpy).toHaveBeenCalledWith(\"keydown\", expect.any(Function));\n expect(removeSpy).toHaveBeenCalledWith(\"keyup\", expect.any(Function));\n });\n});\n```\n\n---\n\n",
"level": 2
},
{
"title": "Gap 5 — `updateCard` (`src/canvas/Canvas.tsx:77`)",
"content": "\n```ts\ndescribe(\"updateCard\", () => {\n it(\"patches only the target card, leaving others unchanged\", () => {\n const cards: Card[] = [\n { id: \"a\", kind: \"note\", x: 0, y: 0, width: 100, height: 100, z: 0, text: \"A\" },\n { id: \"b\", kind: \"note\", x: 0, y: 0, width: 100, height: 100, z: 0, text: \"B\" },\n ];\n render(<Canvas initialCards={cards} />);\n // Simulate NoteCardView calling onUpdate for card \"a\"\n const [textareaA] = screen.getAllByRole(\"textbox\");\n await userEvent.clear(textareaA);\n await userEvent.type(textareaA, \"updated\");\n expect(screen.getAllByRole(\"textbox\")[0]).toHaveValue(\"updated\");\n expect(screen.getAllByRole(\"textbox\")[1]).toHaveValue(\"B\"); // unchanged\n });\n});\n```\n\n---\n\n",
"level": 2
},
{
"title": "Gap 6 — Card rendering dispatch (`src/canvas/Canvas.tsx:102`)",
"content": "\n```ts\ndescribe(\"card kind rendering\", () => {\n it(\"renders NoteCardView for note cards\", () => {\n const card: Card = { id: \"1\", kind: \"note\", x:0, y:0, width:100, height:100, z:0, text:\"hi\" };\n render(<Canvas initialCards={[card]} />);\n expect(screen.getByRole(\"textbox\")).toBeInTheDocument();\n });\n\n it(\"renders nothing for terminal cards (not yet implemented)\", () => {\n const card: Card = { id: \"1\", kind: \"terminal\", x:0, y:0, width:100, height:100, z:0, ptyId:\"pty1\" };\n render(<Canvas initialCards={[card]} />);\n expect(screen.queryByRole(\"textbox\")).toBeNull();\n });\n\n it(\"renders nothing for app cards\", () => {\n const card: Card = { id: \"1\", kind: \"app\", x:0, y:0, width:100, height:100, z:0, xWindowId:42, command:\"xterm\" };\n render(<Canvas initialCards={[card]} />);\n expect(screen.queryByRole(\"textbox\")).toBeNull();\n });\n\n it(\"renders nothing for thumbnail cards\", () => {\n const card: Card = { id: \"1\", kind: \"thumbnail\", x:0, y:0, width:100, height:100, z:0, refCardId:\"other\", label:\"thumb\" };\n render(<Canvas initialCards={[card]} />);\n expect(screen.queryByRole(\"textbox\")).toBeNull();\n });\n});\n```\n\n---\n\n",
"level": 2
},
{
"title": "Gap 7 — `NoteCardView` drag with scale compensation (`NoteCardView.tsx:21`)",
"content": "\n```ts\n// tests/canvas/cards/NoteCardView.test.tsx\ndescribe(\"drag with scale compensation\", () => {\n it(\"divides pixel delta by canvas scale when dragging\", () => {\n const onUpdate = vi.fn();\n const card: NoteCard = { id:\"1\", kind:\"note\", x:100, y:100, width:200, height:150, z:0, text:\"\" };\n render(\n // Wrap in a div that mimics .canvas-world with scale(2)\n <div className=\"canvas-world\" style={{ transform: \"translate(0px,0px) scale(2)\" }}>\n <NoteCardView card={card} onUpdate={onUpdate} />\n </div>\n );\n const header = screen.getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(header, { clientX: 100, clientY: 60 });\n // At scale 2: 100px drag → 50 world units\n expect(onUpdate).toHaveBeenCalledWith({ x: 150, y: 130 }); // 100+50, 100+30\n });\n\n it(\"uses scale 1 when .canvas-world ancestor is absent\", () => {\n const onUpdate = vi.fn();\n const card: NoteCard = { id:\"1\", kind:\"note\", x:0, y:0, width:200, height:150, z:0, text:\"\" };\n render(<NoteCardView card={card} onUpdate={onUpdate} />);\n const header = screen.getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(header, { clientX: 50, clientY: 40 });\n expect(onUpdate).toHaveBeenCalledWith({ x: 50, y: 40 }); // no scale division\n });\n\n it(\"ignores non-left-button pointer down on header\", () => {\n const onUpdate = vi.fn();\n const card: NoteCard = { id:\"1\", kind:\"note\", x:0, y:0, width:200, height:150, z:0, text:\"\" };\n render(<NoteCardView card={card} onUpdate={onUpdate} />);\n const header = screen.getByText(\"note\");\n fireEvent.pointerDown(header, { button: 2, clientX: 0, clientY: 0 }); // right click\n fireEvent.pointerMove(header, { clientX: 50, clientY: 50 });\n expect(onUpdate).not.toHaveBeenCalled();\n });\n});\n```\n\n---\n\n",
"level": 2
},
{
"title": "Gap 8 — `NoteCardView` event propagation (`NoteCardView.tsx:14,54`)",
"content": "\n```ts\ndescribe(\"event propagation stoppage\", () => {\n it(\"header pointerDown stops propagation (prevents canvas from starting pan)\", () => {\n const canvasHandler = vi.fn();\n render(\n <div onPointerDown={canvasHandler}>\n <NoteCardView card={...} onUpdate={vi.fn()} />\n </div>\n );\n fireEvent.pointerDown(screen.getByText(\"note\"), { button: 0 });\n expect(canvasHandler).not.toHaveBeenCalled();\n });\n\n it(\"textarea pointerDown stops propagation\", () => {\n const canvasHandler = vi.fn();\n render(\n <div onPointerDown={canvasHandler}>\n <NoteCardView card={...} onUpdate={vi.fn()} />\n </div>\n );\n fireEvent.pointerDown(screen.getByRole(\"textbox\"));\n expect(canvasHandler).not.toHaveBeenCalled();\n });\n});\n```\n\n---\n\n",
"level": 2
},
{
"title": "Gap 9 — Missing error handling tests",
"content": "\n| Location | Unchecked scenario | Risk |\n|---|---|---|\n| `Canvas.tsx:42` `containerRef.current!` | ref not yet attached at mount | crashes in strict-mode double-invoke |\n| `NoteCardView.tsx:23` `worldEl` null check | `closest(\".canvas-world\")` returns null — handled, but the scale-1 fallback is never asserted | silent wrong behavior |\n| `Canvas.tsx:46` `Math.exp(-e.deltaY * ZOOM_SENSITIVITY)` | `deltaY = 0` → factor=1, scale unchanged | no crash, but worth asserting no-op |\n| `Canvas.tsx:78` `updateCard` with unknown `id` | no matching card — map returns unchanged array | silent no-op, never tested |\n\n```ts\nit(\"updateCard with unknown id leaves cards array unchanged\", () => {\n // Trigger via a hypothetical future API or test internal via rendering\n // No crash, no card mutation\n});\n\nit(\"wheel with deltaY=0 does not change the viewport scale\", () => {\n render(<Canvas initialCards={[]} />);\n fireEvent.wheel(..., { deltaY: 0, ctrlKey: true });\n expect(screen.getByText(/\\d+%/)).toHaveTextContent(\"100%\");\n});\n```\n\n---\n\n",
"level": 2
},
{
"title": "Gap 10 — Integration gaps",
"content": "\n```ts\n// tests/integration/Canvas.integration.test.tsx\n\nit(\"dragging a card while space is held does not pan the canvas\", () => {\n // Space held = pan mode, but header pointerDown stops propagation\n // Net result: card drags, canvas does NOT pan\n});\n\nit(\"releasing pointer outside the window ends the drag cleanly (pointerCancel)\", () => {\n // onPointerCancel is wired to onPointerUp — test that panState clears\n});\n\nit(\"HUD displays live viewport coordinates during pan\", () => {\n // Pan 80px right, 40px down — HUD shows \"x 80\" \"y 40\"\n});\n\nit(\"multiple cards each maintain independent drag state\", () => {\n // Drag card A then card B — positions are independent\n});\n```\n\n---\n\n",
"level": 2
},
{
"title": "Priority order",
"content": "| Priority | Gap | Why |\n|---|---|---|\n| 1 | `parseTransformScale` unit tests | Pure function, highest ROI, reveals need to export from `utils.ts` |\n| 2 | Scale clamping (MIN/MAX) | Prevents silent UX regression on zoom |\n| 3 | Pan initiation branches | Core interaction, 3 branches, all uncovered |\n| 4 | Card rendering dispatch | Guards future card kind additions from silent breakage |\n| 5 | Scale-compensated drag | Subtle math, easy to regress |\n| 6 | Event propagation | Prevents pan/drag interference bugs |\n| 7 | Keyboard listener cleanup | Memory leak guard |\n| 8 | Integration scenarios | End-to-end confidence |",
"level": 2
}
],
"codeBlocks": [
{
"language": "bash",
"code": "npm install -D vitest @testing-library/react @testing-library/user-event \\\n @testing-library/jest-dom jsdom @vitest/coverage-v8"
},
{
"language": "ts",
"code": "test: {\n environment: \"jsdom\",\n setupFiles: [\"./tests/setup.ts\"],\n globals: true,\n}"
},
{
"language": "ts",
"code": "// tests/canvas/cards/parseTransformScale.test.ts\nimport { describe, it, expect } from \"vitest\";\n\n// Re-export needed — or test via NoteCardView drag behavior\n// Option: move parseTransformScale to src/canvas/utils.ts and export\n\ndescribe(\"parseTransformScale\", () => {\n it(\"parses a simple scale transform\", () => {\n expect(parseTransformScale(\"scale(2)\")).toBe(2);\n });\n\n it(\"parses sub-unity scale\", () => {\n expect(parseTransformScale(\"scale(0.5)\")).toBe(0.5);\n });\n\n it(\"parses scale from a compound transform string\", () => {\n expect(parseTransformScale(\"translate(10px, 20px) scale(1.5)\")).toBe(1.5);\n });\n\n it(\"returns 1 when no scale token is present\", () => {\n expect(parseTransformScale(\"rotate(45deg)\")).toBe(1);\n });\n\n it(\"returns 1 for empty string\", () => {\n expect(parseTransformScale(\"\")).toBe(1);\n });\n});"
},
{
"language": "ts",
"code": "// tests/canvas/Canvas.test.tsx\nit(\"does not zoom below MIN_SCALE (0.1)\", async () => {\n const { container } = render(<Canvas initialCards={[]} />);\n const user = userEvent.setup();\n\n // Simulate many scroll-up events to try to zoom out past the limit\n for (let i = 0; i < 200; i++) {\n fireEvent.wheel(container.firstChild!, {\n deltaY: 500, ctrlKey: true,\n });\n }\n\n expect(screen.getByText(/\\d+%/)).toHaveTextContent(\"10%\");\n});\n\nit(\"does not zoom above MAX_SCALE (4)\", async () => {\n // ... same pattern with deltaY: -500 (scroll up = zoom in)\n expect(screen.getByText(/\\d+%/)).toHaveTextContent(\"400%\");\n});"
},
{
"language": "ts",
"code": "it(\"ignores wheel events without ctrlKey or metaKey\", () => {\n render(<Canvas initialCards={[]} />);\n fireEvent.wheel(screen.getByRole(\"...\"), { deltaY: -100 }); // no ctrlKey\n expect(screen.getByText(/\\d+%/)).toHaveTextContent(\"100%\"); // unchanged\n});"
},
{
"language": "ts",
"code": "it(\"zooms toward the cursor position, keeping the point under cursor stable\", () => {\n // Render at known position, fire wheel at cursor (mx=100, my=100)\n // Before: vp = {x:0, y:0, scale:1}\n // After zoom in: the world point under (100,100) should remain there\n // i.e. new_vp.x + 100 * new_scale === old_vp.x + 100 * old_scale (approx)\n});"
},
{
"language": "ts",
"code": "// tests/canvas/Canvas.test.tsx\ndescribe(\"pan initiation\", () => {\n it(\"starts pan on middle-click (button=1) regardless of space key\", () => {\n render(<Canvas initialCards={[]} />);\n const canvas = screen.getByTestId(\"canvas-container\");\n fireEvent.pointerDown(canvas, { button: 1, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(canvas, { clientX: 50, clientY: 30 });\n // HUD should show x 50, y 30\n expect(screen.getByText(\"x 50\")).toBeInTheDocument();\n });\n\n it(\"starts pan on left-click when space is held\", async () => {\n render(<Canvas initialCards={[]} />);\n fireEvent.keyDown(window, { code: \"Space\" });\n const canvas = screen.getByTestId(\"canvas-container\");\n fireEvent.pointerDown(canvas, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(canvas, { clientX: 40, clientY: 20 });\n expect(screen.getByText(\"x 40\")).toBeInTheDocument();\n });\n\n it(\"does NOT pan on left-click without space held\", () => {\n render(<Canvas initialCards={[]} />);\n const canvas = screen.getByTestId(\"canvas-container\");\n fireEvent.pointerDown(canvas, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(canvas, { clientX: 50, clientY: 50 });\n expect(screen.getByText(\"x 0\")).toBeInTheDocument(); // unchanged\n });\n\n it(\"does NOT pan on right-click (button=2)\", () => {\n // same pattern with button: 2\n });\n});"
},
{
"language": "ts",
"code": "describe(\"space key\", () => {\n it(\"sets pan-mode class when space is held\", () => {\n render(<Canvas initialCards={[]} />);\n fireEvent.keyDown(window, { code: \"Space\" });\n expect(screen.getByTestId(\"canvas-container\")).toHaveClass(\"pan-mode\");\n });\n\n it(\"removes pan-mode class when space is released\", () => {\n render(<Canvas initialCards={[]} />);\n fireEvent.keyDown(window, { code: \"Space\" });\n fireEvent.keyUp(window, { code: \"Space\" });\n expect(screen.getByTestId(\"canvas-container\")).not.toHaveClass(\"pan-mode\");\n });\n\n it(\"ignores key repeat events (e.repeat=true)\", () => {\n // Should not double-set spaceHeld — no observable effect here,\n // but guards against future state bugs\n render(<Canvas initialCards={[]} />);\n fireEvent.keyDown(window, { code: \"Space\", repeat: true });\n // State is already false so class should not appear (tests idempotency)\n expect(screen.getByTestId(\"canvas-container\")).not.toHaveClass(\"pan-mode\");\n });\n\n it(\"cleans up key listeners on unmount\", () => {\n const { unmount } = render(<Canvas initialCards={[]} />);\n const removeSpy = vi.spyOn(window, \"removeEventListener\");\n unmount();\n expect(removeSpy).toHaveBeenCalledWith(\"keydown\", expect.any(Function));\n expect(removeSpy).toHaveBeenCalledWith(\"keyup\", expect.any(Function));\n });\n});"
},
{
"language": "ts",
"code": "describe(\"updateCard\", () => {\n it(\"patches only the target card, leaving others unchanged\", () => {\n const cards: Card[] = [\n { id: \"a\", kind: \"note\", x: 0, y: 0, width: 100, height: 100, z: 0, text: \"A\" },\n { id: \"b\", kind: \"note\", x: 0, y: 0, width: 100, height: 100, z: 0, text: \"B\" },\n ];\n render(<Canvas initialCards={cards} />);\n // Simulate NoteCardView calling onUpdate for card \"a\"\n const [textareaA] = screen.getAllByRole(\"textbox\");\n await userEvent.clear(textareaA);\n await userEvent.type(textareaA, \"updated\");\n expect(screen.getAllByRole(\"textbox\")[0]).toHaveValue(\"updated\");\n expect(screen.getAllByRole(\"textbox\")[1]).toHaveValue(\"B\"); // unchanged\n });\n});"
},
{
"language": "ts",
"code": "describe(\"card kind rendering\", () => {\n it(\"renders NoteCardView for note cards\", () => {\n const card: Card = { id: \"1\", kind: \"note\", x:0, y:0, width:100, height:100, z:0, text:\"hi\" };\n render(<Canvas initialCards={[card]} />);\n expect(screen.getByRole(\"textbox\")).toBeInTheDocument();\n });\n\n it(\"renders nothing for terminal cards (not yet implemented)\", () => {\n const card: Card = { id: \"1\", kind: \"terminal\", x:0, y:0, width:100, height:100, z:0, ptyId:\"pty1\" };\n render(<Canvas initialCards={[card]} />);\n expect(screen.queryByRole(\"textbox\")).toBeNull();\n });\n\n it(\"renders nothing for app cards\", () => {\n const card: Card = { id: \"1\", kind: \"app\", x:0, y:0, width:100, height:100, z:0, xWindowId:42, command:\"xterm\" };\n render(<Canvas initialCards={[card]} />);\n expect(screen.queryByRole(\"textbox\")).toBeNull();\n });\n\n it(\"renders nothing for thumbnail cards\", () => {\n const card: Card = { id: \"1\", kind: \"thumbnail\", x:0, y:0, width:100, height:100, z:0, refCardId:\"other\", label:\"thumb\" };\n render(<Canvas initialCards={[card]} />);\n expect(screen.queryByRole(\"textbox\")).toBeNull();\n });\n});"
},
{
"language": "ts",
"code": "// tests/canvas/cards/NoteCardView.test.tsx\ndescribe(\"drag with scale compensation\", () => {\n it(\"divides pixel delta by canvas scale when dragging\", () => {\n const onUpdate = vi.fn();\n const card: NoteCard = { id:\"1\", kind:\"note\", x:100, y:100, width:200, height:150, z:0, text:\"\" };\n render(\n // Wrap in a div that mimics .canvas-world with scale(2)\n <div className=\"canvas-world\" style={{ transform: \"translate(0px,0px) scale(2)\" }}>\n <NoteCardView card={card} onUpdate={onUpdate} />\n </div>\n );\n const header = screen.getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(header, { clientX: 100, clientY: 60 });\n // At scale 2: 100px drag → 50 world units\n expect(onUpdate).toHaveBeenCalledWith({ x: 150, y: 130 }); // 100+50, 100+30\n });\n\n it(\"uses scale 1 when .canvas-world ancestor is absent\", () => {\n const onUpdate = vi.fn();\n const card: NoteCard = { id:\"1\", kind:\"note\", x:0, y:0, width:200, height:150, z:0, text:\"\" };\n render(<NoteCardView card={card} onUpdate={onUpdate} />);\n const header = screen.getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(header, { clientX: 50, clientY: 40 });\n expect(onUpdate).toHaveBeenCalledWith({ x: 50, y: 40 }); // no scale division\n });\n\n it(\"ignores non-left-button pointer down on header\", () => {\n const onUpdate = vi.fn();\n const card: NoteCard = { id:\"1\", kind:\"note\", x:0, y:0, width:200, height:150, z:0, text:\"\" };\n render(<NoteCardView card={card} onUpdate={onUpdate} />);\n const header = screen.getByText(\"note\");\n fireEvent.pointerDown(header, { button: 2, clientX: 0, clientY: 0 }); // right click\n fireEvent.pointerMove(header, { clientX: 50, clientY: 50 });\n expect(onUpdate).not.toHaveBeenCalled();\n });\n});"
},
{
"language": "ts",
"code": "describe(\"event propagation stoppage\", () => {\n it(\"header pointerDown stops propagation (prevents canvas from starting pan)\", () => {\n const canvasHandler = vi.fn();\n render(\n <div onPointerDown={canvasHandler}>\n <NoteCardView card={...} onUpdate={vi.fn()} />\n </div>\n );\n fireEvent.pointerDown(screen.getByText(\"note\"), { button: 0 });\n expect(canvasHandler).not.toHaveBeenCalled();\n });\n\n it(\"textarea pointerDown stops propagation\", () => {\n const canvasHandler = vi.fn();\n render(\n <div onPointerDown={canvasHandler}>\n <NoteCardView card={...} onUpdate={vi.fn()} />\n </div>\n );\n fireEvent.pointerDown(screen.getByRole(\"textbox\"));\n expect(canvasHandler).not.toHaveBeenCalled();\n });\n});"
},
{
"language": "ts",
"code": "it(\"updateCard with unknown id leaves cards array unchanged\", () => {\n // Trigger via a hypothetical future API or test internal via rendering\n // No crash, no card mutation\n});\n\nit(\"wheel with deltaY=0 does not change the viewport scale\", () => {\n render(<Canvas initialCards={[]} />);\n fireEvent.wheel(..., { deltaY: 0, ctrlKey: true });\n expect(screen.getByText(/\\d+%/)).toHaveTextContent(\"100%\");\n});"
},
{
"language": "ts",
"code": "// tests/integration/Canvas.integration.test.tsx\n\nit(\"dragging a card while space is held does not pan the canvas\", () => {\n // Space held = pan mode, but header pointerDown stops propagation\n // Net result: card drags, canvas does NOT pan\n});\n\nit(\"releasing pointer outside the window ends the drag cleanly (pointerCancel)\", () => {\n // onPointerCancel is wired to onPointerUp — test that panState clears\n});\n\nit(\"HUD displays live viewport coordinates during pan\", () => {\n // Pan 80px right, 40px down — HUD shows \"x 80\" \"y 40\"\n});\n\nit(\"multiple cards each maintain independent drag state\", () => {\n // Drag card A then card B — positions are independent\n});"
}
]
},
"durationMs": 178870,
"model": "sonnet",
"sandboxMode": "permissive",
"workerType": "testgaps",
"timestamp": "2026-05-14T19:49:06.742Z",
"executionId": "testgaps_1778787967872_nkg5mv"
}