74 lines
56 KiB
Plaintext
74 lines
56 KiB
Plaintext
[2026-05-14T20:08:19.009Z] RESULT
|
|
============================================================
|
|
{
|
|
"success": true,
|
|
"output": "Now I have the full picture. Here's the complete coverage analysis:\n\n---\n\n## Test Coverage Analysis\n\n**Status: 0% coverage.** No test files, no test runner configured. Recommend adding **Vitest** (already using Vite) + **@testing-library/react**.\n\n---\n\n## Gaps by File\n\n### 1. `src/canvas/types.ts` — Type guards missing\n\nNo runtime type guards exist. Narrowing `Card` by `kind` is done ad-hoc throughout. If the pattern grows, untested narrowing logic will spread.\n\n**Gap:** No `isNoteCard` / `isTerminalCard` etc. helpers, and no tests for the discriminated union invariants.\n\n```ts\n// src/canvas/types.test.ts\nimport { describe, it, expect } from \"vitest\";\nimport type { Card, NoteCard, TerminalCard, AppCard, ThumbnailCard } from \"./types\";\n\nfunction isNoteCard(c: Card): c is NoteCard { return c.kind === \"note\"; }\nfunction isTerminalCard(c: Card): c is TerminalCard { return c.kind === \"terminal\"; }\nfunction isAppCard(c: Card): c is AppCard { return c.kind === \"app\"; }\nfunction isThumbnailCard(c: Card): c is ThumbnailCard { return c.kind === \"thumbnail\"; }\n\ndescribe(\"Card type narrowing\", () => {\n const note: NoteCard = { id: \"1\", kind: \"note\", x:0,y:0,width:100,height:100,z:0, text:\"hi\" };\n const term: TerminalCard = { id: \"2\", kind: \"terminal\", x:0,y:0,width:100,height:100,z:0, ptyId:\"p1\" };\n const app: AppCard = { id: \"3\", kind: \"app\", x:0,y:0,width:100,height:100,z:0, xWindowId:42, command:\"bash\" };\n const thumb: ThumbnailCard = { id: \"4\", kind: \"thumbnail\", x:0,y:0,width:100,height:100,z:0, refCardId:\"1\", label:\"Note\" };\n\n it(\"isNoteCard is true only for note\", () => {\n expect(isNoteCard(note)).toBe(true);\n expect(isNoteCard(term)).toBe(false);\n });\n\n it(\"AppCard optional title defaults to undefined\", () => {\n expect(app.title).toBeUndefined();\n });\n\n it(\"ThumbnailCard refCardId references a valid CardId shape\", () => {\n expect(typeof thumb.refCardId).toBe(\"string\");\n });\n\n // Edge: empty string is a valid CardId (no validation enforced in types)\n it(\"empty string is accepted as CardId\", () => {\n const c: Card = { ...note, id: \"\" };\n expect(c.id).toBe(\"\");\n });\n});\n```\n\n---\n\n### 2. `NoteCardView.tsx` — `parseTransformScale` (private, untested)\n\nThis is the most testable pure function in the codebase and has zero coverage.\n\n```ts\n// src/canvas/cards/NoteCardView.test.tsx\nimport { describe, it, expect, vi } from \"vitest\";\nimport { render, fireEvent } from \"@testing-library/react\";\nimport { NoteCardView } from \"./NoteCardView\";\nimport type { NoteCard } from \"../types\";\n\nconst baseCard: NoteCard = { id:\"c1\", kind:\"note\", x:50, y:80, width:200, height:150, z:3, text:\"hello\" };\n\n// --- parseTransformScale (tested via drag behavior) ---\ndescribe(\"parseTransformScale (via drag move)\", () => {\n it(\"returns 1 when no canvas-world ancestor present\", () => {\n // No .canvas-world wrapper → scale defaults to 1, movement is 1:1\n const onUpdate = vi.fn();\n const { getByText } = render(<NoteCardView card={baseCard} onUpdate={onUpdate} />);\n const header = getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(header, { clientX: 10, clientY: 20 });\n expect(onUpdate).toHaveBeenCalledWith({ x: 60, y: 100 }); // 50+10, 80+20\n });\n\n it(\"divides movement by canvas-world scale\", () => {\n // Wrap in a .canvas-world div with scale(2)\n const onUpdate = vi.fn();\n const { getByText, container } = render(\n <div className=\"canvas-world\" style={{ transform: \"translate(0px,0px) scale(2)\" }}>\n <NoteCardView card={baseCard} onUpdate={onUpdate} />\n </div>\n );\n const header = getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(header, { clientX: 20, clientY: 40 });\n expect(onUpdate).toHaveBeenCalledWith({ x: 60, y: 100 }); // 20/2=10, 40/2=20\n });\n\n // Edge cases for parseTransformScale\n it(\"returns 1 for malformed transform string\", () => {\n const onUpdate = vi.fn();\n const { getByText } = render(\n <div className=\"canvas-world\" style={{ transform: \"rotate(45deg)\" }}>\n <NoteCardView card={baseCard} onUpdate={onUpdate} />\n </div>\n );\n const header = getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(header, { clientX: 10, clientY: 0 });\n expect(onUpdate).toHaveBeenCalledWith({ x: 60, y: 80 }); // scale=1 fallback\n });\n});\n\n// --- Drag initiation ---\ndescribe(\"NoteCardView drag\", () => {\n it(\"ignores non-left-button pointer down\", () => {\n const onUpdate = vi.fn();\n const { getByText } = render(<NoteCardView card={baseCard} onUpdate={onUpdate} />);\n const header = getByText(\"note\");\n fireEvent.pointerDown(header, { button: 1, clientX: 0, clientY: 0 }); // middle\n fireEvent.pointerMove(header, { clientX: 50, clientY: 50 });\n expect(onUpdate).not.toHaveBeenCalled();\n });\n\n it(\"stops propagation on header pointer down\", () => {\n const parentHandler = vi.fn();\n const { getByText } = render(\n <div onPointerDown={parentHandler}>\n <NoteCardView card={baseCard} onUpdate={vi.fn()} />\n </div>\n );\n fireEvent.pointerDown(getByText(\"note\"), { button: 0 });\n expect(parentHandler).not.toHaveBeenCalled();\n });\n\n it(\"pointer move without prior down does nothing\", () => {\n const onUpdate = vi.fn();\n const { getByText } = render(<NoteCardView card={baseCard} onUpdate={onUpdate} />);\n fireEvent.pointerMove(getByText(\"note\"));\n expect(onUpdate).not.toHaveBeenCalled();\n });\n\n it(\"clears drag state on pointer up\", () => {\n const onUpdate = vi.fn();\n const { getByText } = render(<NoteCardView card={baseCard} onUpdate={onUpdate} />);\n const header = getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerUp(header);\n fireEvent.pointerMove(header, { clientX: 100, clientY: 100 });\n // After pointer up, move should not trigger update\n expect(onUpdate).not.toHaveBeenCalled();\n });\n\n it(\"handles pointer cancel same as pointer up\", () => {\n const onUpdate = vi.fn();\n const { getByText } = render(<NoteCardView card={baseCard} onUpdate={onUpdate} />);\n const header = getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerCancel(header);\n fireEvent.pointerMove(header, { clientX: 50, clientY: 50 });\n expect(onUpdate).not.toHaveBeenCalled();\n });\n});\n\n// --- Textarea ---\ndescribe(\"NoteCardView textarea\", () => {\n it(\"calls onUpdate with new text on change\", () => {\n const onUpdate = vi.fn();\n const { getByRole } = render(<NoteCardView card={baseCard} onUpdate={onUpdate} />);\n fireEvent.change(getByRole(\"textbox\"), { target: { value: \"updated\" } });\n expect(onUpdate).toHaveBeenCalledWith({ text: \"updated\" });\n });\n\n it(\"stops propagation on textarea pointer down (prevents canvas pan)\", () => {\n const parentHandler = vi.fn();\n const { getByRole } = render(\n <div onPointerDown={parentHandler}>\n <NoteCardView card={baseCard} onUpdate={vi.fn()} />\n </div>\n );\n fireEvent.pointerDown(getByRole(\"textbox\"));\n expect(parentHandler).not.toHaveBeenCalled();\n });\n\n it(\"renders current card text in textarea\", () => {\n const { getByRole } = render(<NoteCardView card={{ ...baseCard, text: \"my note\" }} onUpdate={vi.fn()} />);\n expect((getByRole(\"textbox\") as HTMLTextAreaElement).value).toBe(\"my note\");\n });\n});\n\n// --- Layout/style ---\ndescribe(\"NoteCardView positioning\", () => {\n it(\"applies card position and dimensions as inline style\", () => {\n const { container } = render(<NoteCardView card={baseCard} onUpdate={vi.fn()} />);\n const el = container.firstChild as HTMLElement;\n expect(el.style.left).toBe(\"50px\");\n expect(el.style.top).toBe(\"80px\");\n expect(el.style.width).toBe(\"200px\");\n expect(el.style.height).toBe(\"150px\");\n expect(el.style.zIndex).toBe(\"3\");\n });\n});\n```\n\n---\n\n### 3. `Canvas.tsx` — Zoom logic\n\nThe zoom math (`Math.exp`, clamping, origin-preserving transform) has no tests. This is the most logic-heavy code in the project.\n\n```ts\n// src/canvas/Canvas.test.tsx\nimport { describe, it, expect, vi } from \"vitest\";\nimport { render, fireEvent, act } from \"@testing-library/react\";\nimport { Canvas } from \"./Canvas\";\nimport type { Card } from \"./types\";\n\nconst notes: Card[] = [\n { id:\"n1\", kind:\"note\", x:0, y:0, width:100, height:80, z:0, text:\"A\" },\n { id:\"n2\", kind:\"note\", x:200, y:200, width:100, height:80, z:1, text:\"B\" },\n];\n\n// Helper: fire a wheel event with Ctrl held\nfunction ctrlWheel(el: Element, deltaY: number) {\n fireEvent.wheel(el, { ctrlKey: true, deltaY, clientX: 0, clientY: 0 });\n}\n\ndescribe(\"Canvas zoom (onWheel)\", () => {\n it(\"ignores wheel without Ctrl/Meta key\", () => {\n const { getByText } = render(<Canvas initialCards={notes} />);\n const hud = getByText(/100%/); // initial scale\n fireEvent.wheel(document.querySelector(\".canvas-container\")!, { ctrlKey: false, deltaY: -100 });\n expect(getByText(/100%/)).toBeTruthy(); // unchanged\n });\n\n it(\"zooms in on negative deltaY with Ctrl\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n ctrlWheel(container, -100);\n // scale should be > 100%\n const hud = document.querySelector(\".canvas-hud\")!;\n const pct = parseInt(hud.querySelectorAll(\"span\")[2].textContent!);\n expect(pct).toBeGreaterThan(100);\n });\n\n it(\"zooms out on positive deltaY with Ctrl\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n ctrlWheel(container, 100);\n const pct = parseInt(document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\")[2].textContent!);\n expect(pct).toBeLessThan(100);\n });\n\n it(\"clamps scale at MIN_SCALE (0.1 = 10%)\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n // Zoom way out\n for (let i = 0; i < 50; i++) ctrlWheel(container, 9999);\n const pct = parseInt(document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\")[2].textContent!);\n expect(pct).toBe(10);\n });\n\n it(\"clamps scale at MAX_SCALE (4 = 400%)\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n for (let i = 0; i < 50; i++) ctrlWheel(container, -9999);\n const pct = parseInt(document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\")[2].textContent!);\n expect(pct).toBe(400);\n });\n});\n\n// --- Panning ---\ndescribe(\"Canvas pan (pointer events)\", () => {\n it(\"pans on middle-button drag\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n fireEvent.pointerDown(container, { button: 1, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(container, { clientX: 50, clientY: 30 });\n fireEvent.pointerUp(container, { button: 1 });\n const spans = document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\");\n expect(spans[0].textContent).toBe(\"x 50\");\n expect(spans[1].textContent).toBe(\"y 30\");\n });\n\n it(\"does not pan on plain left-button drag (no space)\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n fireEvent.pointerDown(container, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(container, { clientX: 50, clientY: 30 });\n const spans = document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\");\n expect(spans[0].textContent).toBe(\"x 0\");\n });\n\n it(\"pans on left-button + space\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n fireEvent.keyDown(window, { code: \"Space\" });\n fireEvent.pointerDown(container, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(container, { clientX: 40, clientY: 0 });\n const spans = document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\");\n expect(spans[0].textContent).toBe(\"x 40\");\n });\n\n it(\"stops panning after pointer up\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n fireEvent.pointerDown(container, { button: 1, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(container, { clientX: 50, clientY: 0 });\n fireEvent.pointerUp(container);\n fireEvent.pointerMove(container, { clientX: 200, clientY: 0 }); // should not update\n expect(document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\")[0].textContent).toBe(\"x 50\");\n });\n\n it(\"handles pointer cancel same as pointer up\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n fireEvent.pointerDown(container, { button: 1, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(container, { clientX: 30, clientY: 0 });\n fireEvent.pointerCancel(container);\n fireEvent.pointerMove(container, { clientX: 200, clientY: 0 });\n expect(document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\")[0].textContent).toBe(\"x 30\");\n });\n});\n\n// --- Space key ---\ndescribe(\"Canvas space key\", () => {\n it(\"adds pan-mode class on space down\", () => {\n render(<Canvas initialCards={notes} />);\n fireEvent.keyDown(window, { code: \"Space\" });\n expect(document.querySelector(\".canvas-container\")!.classList).toContain(\"pan-mode\");\n });\n\n it(\"removes pan-mode class on space up\", () => {\n render(<Canvas initialCards={notes} />);\n fireEvent.keyDown(window, { code: \"Space\" });\n fireEvent.keyUp(window, { code: \"Space\" });\n expect(document.querySelector(\".canvas-container\")!.classList).not.toContain(\"pan-mode\");\n });\n\n it(\"ignores repeated space keydown events\", () => {\n render(<Canvas initialCards={notes} />);\n fireEvent.keyDown(window, { code: \"Space\", repeat: true }); // should not crash or double-set\n // No assertion needed — just must not throw\n });\n\n it(\"removes key listeners on unmount\", () => {\n const { unmount } = render(<Canvas initialCards={notes} />);\n const spy = vi.spyOn(window, \"removeEventListener\");\n unmount();\n expect(spy).toHaveBeenCalledWith(\"keydown\", expect.any(Function));\n expect(spy).toHaveBeenCalledWith(\"keyup\", expect.any(Function));\n });\n});\n\n// --- Card rendering ---\ndescribe(\"Canvas card rendering\", () => {\n it(\"renders only note cards (other kinds return null)\", () => {\n const mixed: Card[] = [\n ...notes,\n { id:\"t1\", kind:\"terminal\", x:0,y:0,width:100,height:100,z:0, ptyId:\"p1\" },\n { id:\"a1\", kind:\"app\", x:0,y:0,width:100,height:100,z:0, xWindowId:1, command:\"xterm\" },\n ];\n const { getAllByRole } = render(<Canvas initialCards={mixed} />);\n // Only 2 textareas from note cards\n expect(getAllByRole(\"textbox\").length).toBe(2);\n });\n\n it(\"updateCard mutates only the targeted card\", () => {\n const { getAllByRole } = render(<Canvas initialCards={notes} />);\n const [ta1] = getAllByRole(\"textbox\") as HTMLTextAreaElement[];\n fireEvent.change(ta1, { target: { value: \"changed\" } });\n const [ta1After, ta2After] = getAllByRole(\"textbox\") as HTMLTextAreaElement[];\n expect(ta1After.value).toBe(\"changed\");\n expect(ta2After.value).toBe(\"B\"); // unchanged\n });\n\n it(\"renders empty canvas with no cards\", () => {\n const { container } = render(<Canvas initialCards={[]} />);\n expect(container.querySelectorAll(\".card\").length).toBe(0);\n });\n});\n\n// --- HUD ---\ndescribe(\"Canvas HUD\", () => {\n it(\"shows initial viewport at 0,0 100%\", () => {\n render(<Canvas initialCards={[]} />);\n const spans = document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\");\n expect(spans[0].textContent).toBe(\"x 0\");\n expect(spans[1].textContent).toBe(\"y 0\");\n expect(spans[2].textContent).toBe(\"100%\");\n });\n});\n```\n\n---\n\n### 4. Integration gaps\n\n```ts\n// src/canvas/Canvas.integration.test.tsx\nimport { describe, it, expect } from \"vitest\";\nimport { render, fireEvent } from \"@testing-library/react\";\nimport { Canvas } from \"./canvas/Canvas\";\nimport type { Card } from \"./canvas/types\";\n\ndescribe(\"Card drag within zoomed canvas\", () => {\n it(\"card position update accounts for canvas scale\", () => {\n // TODO: render Canvas at scale 2, drag NoteCard, verify onUpdate receives halved delta\n // Requires exposing viewport state or reading DOM transforms\n });\n\n it(\"dragging one card does not move others\", () => {\n const cards: Card[] = [\n { id:\"a\", kind:\"note\", x:0, y:0, width:100, height:80, z:0, text:\"A\" },\n { id:\"b\", kind:\"note\", x:300,y:300, width:100, height:80, z:1, text:\"B\" },\n ];\n const { getAllByText } = render(<Canvas initialCards={cards} />);\n const headerA = getAllByText(\"note\")[0];\n fireEvent.pointerDown(headerA, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(headerA, { clientX: 50, clientY: 50 });\n // Card B position style should be unchanged at left:300px\n const allCards = document.querySelectorAll(\".card\");\n expect((allCards[1] as HTMLElement).style.left).toBe(\"300px\");\n });\n});\n\ndescribe(\"Pan + zoom composition\", () => {\n it(\"zoom after pan preserves pan offset proportionally\", () => {\n // TODO: pan to (100,0), then zoom — verify the world-origin point\n // stays anchored at the zoom focus point, not reset\n });\n});\n```\n\n---\n\n## Summary of Gaps\n\n| Area | Gap | Severity |\n|------|-----|----------|\n| `parseTransformScale` | Pure function, entirely untested | High |\n| Zoom clamping (MIN/MAX) | Boundary values unchecked | High |\n| Zoom math | Origin-preserving transform not verified | High |\n| Space + pan mode | Key event → class toggle not tested | Medium |\n| Event listener cleanup | Memory leak risk on unmount | Medium |\n| `updateCard` isolation | Other cards mutated is possible bug | Medium |\n| Non-note card kinds | `null` render path not tested | Medium |\n| Type guards | No guards exist; narrowing is ad-hoc | Low |\n| Integration: drag in zoomed canvas | scale division never verified end-to-end | High |\n\n**Recommended setup to add:**\n```bash\nnpm i -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom\n```\n\nThen in `vite.config.ts` add `test: { environment: \"jsdom\", globals: true }`.\n",
|
|
"parsedOutput": {
|
|
"sections": [
|
|
{
|
|
"title": "Test Coverage Analysis",
|
|
"content": "\n**Status: 0% coverage.** No test files, no test runner configured. Recommend adding **Vitest** (already using Vite) + **@testing-library/react**.\n\n---\n\n",
|
|
"level": 2
|
|
},
|
|
{
|
|
"title": "Gaps by File",
|
|
"content": "\n",
|
|
"level": 2
|
|
},
|
|
{
|
|
"title": "1. `src/canvas/types.ts` — Type guards missing",
|
|
"content": "\nNo runtime type guards exist. Narrowing `Card` by `kind` is done ad-hoc throughout. If the pattern grows, untested narrowing logic will spread.\n\n**Gap:** No `isNoteCard` / `isTerminalCard` etc. helpers, and no tests for the discriminated union invariants.\n\n```ts\n// src/canvas/types.test.ts\nimport { describe, it, expect } from \"vitest\";\nimport type { Card, NoteCard, TerminalCard, AppCard, ThumbnailCard } from \"./types\";\n\nfunction isNoteCard(c: Card): c is NoteCard { return c.kind === \"note\"; }\nfunction isTerminalCard(c: Card): c is TerminalCard { return c.kind === \"terminal\"; }\nfunction isAppCard(c: Card): c is AppCard { return c.kind === \"app\"; }\nfunction isThumbnailCard(c: Card): c is ThumbnailCard { return c.kind === \"thumbnail\"; }\n\ndescribe(\"Card type narrowing\", () => {\n const note: NoteCard = { id: \"1\", kind: \"note\", x:0,y:0,width:100,height:100,z:0, text:\"hi\" };\n const term: TerminalCard = { id: \"2\", kind: \"terminal\", x:0,y:0,width:100,height:100,z:0, ptyId:\"p1\" };\n const app: AppCard = { id: \"3\", kind: \"app\", x:0,y:0,width:100,height:100,z:0, xWindowId:42, command:\"bash\" };\n const thumb: ThumbnailCard = { id: \"4\", kind: \"thumbnail\", x:0,y:0,width:100,height:100,z:0, refCardId:\"1\", label:\"Note\" };\n\n it(\"isNoteCard is true only for note\", () => {\n expect(isNoteCard(note)).toBe(true);\n expect(isNoteCard(term)).toBe(false);\n });\n\n it(\"AppCard optional title defaults to undefined\", () => {\n expect(app.title).toBeUndefined();\n });\n\n it(\"ThumbnailCard refCardId references a valid CardId shape\", () => {\n expect(typeof thumb.refCardId).toBe(\"string\");\n });\n\n // Edge: empty string is a valid CardId (no validation enforced in types)\n it(\"empty string is accepted as CardId\", () => {\n const c: Card = { ...note, id: \"\" };\n expect(c.id).toBe(\"\");\n });\n});\n```\n\n---\n\n",
|
|
"level": 3
|
|
},
|
|
{
|
|
"title": "2. `NoteCardView.tsx` — `parseTransformScale` (private, untested)",
|
|
"content": "\nThis is the most testable pure function in the codebase and has zero coverage.\n\n```ts\n// src/canvas/cards/NoteCardView.test.tsx\nimport { describe, it, expect, vi } from \"vitest\";\nimport { render, fireEvent } from \"@testing-library/react\";\nimport { NoteCardView } from \"./NoteCardView\";\nimport type { NoteCard } from \"../types\";\n\nconst baseCard: NoteCard = { id:\"c1\", kind:\"note\", x:50, y:80, width:200, height:150, z:3, text:\"hello\" };\n\n// --- parseTransformScale (tested via drag behavior) ---\ndescribe(\"parseTransformScale (via drag move)\", () => {\n it(\"returns 1 when no canvas-world ancestor present\", () => {\n // No .canvas-world wrapper → scale defaults to 1, movement is 1:1\n const onUpdate = vi.fn();\n const { getByText } = render(<NoteCardView card={baseCard} onUpdate={onUpdate} />);\n const header = getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(header, { clientX: 10, clientY: 20 });\n expect(onUpdate).toHaveBeenCalledWith({ x: 60, y: 100 }); // 50+10, 80+20\n });\n\n it(\"divides movement by canvas-world scale\", () => {\n // Wrap in a .canvas-world div with scale(2)\n const onUpdate = vi.fn();\n const { getByText, container } = render(\n <div className=\"canvas-world\" style={{ transform: \"translate(0px,0px) scale(2)\" }}>\n <NoteCardView card={baseCard} onUpdate={onUpdate} />\n </div>\n );\n const header = getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(header, { clientX: 20, clientY: 40 });\n expect(onUpdate).toHaveBeenCalledWith({ x: 60, y: 100 }); // 20/2=10, 40/2=20\n });\n\n // Edge cases for parseTransformScale\n it(\"returns 1 for malformed transform string\", () => {\n const onUpdate = vi.fn();\n const { getByText } = render(\n <div className=\"canvas-world\" style={{ transform: \"rotate(45deg)\" }}>\n <NoteCardView card={baseCard} onUpdate={onUpdate} />\n </div>\n );\n const header = getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(header, { clientX: 10, clientY: 0 });\n expect(onUpdate).toHaveBeenCalledWith({ x: 60, y: 80 }); // scale=1 fallback\n });\n});\n\n// --- Drag initiation ---\ndescribe(\"NoteCardView drag\", () => {\n it(\"ignores non-left-button pointer down\", () => {\n const onUpdate = vi.fn();\n const { getByText } = render(<NoteCardView card={baseCard} onUpdate={onUpdate} />);\n const header = getByText(\"note\");\n fireEvent.pointerDown(header, { button: 1, clientX: 0, clientY: 0 }); // middle\n fireEvent.pointerMove(header, { clientX: 50, clientY: 50 });\n expect(onUpdate).not.toHaveBeenCalled();\n });\n\n it(\"stops propagation on header pointer down\", () => {\n const parentHandler = vi.fn();\n const { getByText } = render(\n <div onPointerDown={parentHandler}>\n <NoteCardView card={baseCard} onUpdate={vi.fn()} />\n </div>\n );\n fireEvent.pointerDown(getByText(\"note\"), { button: 0 });\n expect(parentHandler).not.toHaveBeenCalled();\n });\n\n it(\"pointer move without prior down does nothing\", () => {\n const onUpdate = vi.fn();\n const { getByText } = render(<NoteCardView card={baseCard} onUpdate={onUpdate} />);\n fireEvent.pointerMove(getByText(\"note\"));\n expect(onUpdate).not.toHaveBeenCalled();\n });\n\n it(\"clears drag state on pointer up\", () => {\n const onUpdate = vi.fn();\n const { getByText } = render(<NoteCardView card={baseCard} onUpdate={onUpdate} />);\n const header = getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerUp(header);\n fireEvent.pointerMove(header, { clientX: 100, clientY: 100 });\n // After pointer up, move should not trigger update\n expect(onUpdate).not.toHaveBeenCalled();\n });\n\n it(\"handles pointer cancel same as pointer up\", () => {\n const onUpdate = vi.fn();\n const { getByText } = render(<NoteCardView card={baseCard} onUpdate={onUpdate} />);\n const header = getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerCancel(header);\n fireEvent.pointerMove(header, { clientX: 50, clientY: 50 });\n expect(onUpdate).not.toHaveBeenCalled();\n });\n});\n\n// --- Textarea ---\ndescribe(\"NoteCardView textarea\", () => {\n it(\"calls onUpdate with new text on change\", () => {\n const onUpdate = vi.fn();\n const { getByRole } = render(<NoteCardView card={baseCard} onUpdate={onUpdate} />);\n fireEvent.change(getByRole(\"textbox\"), { target: { value: \"updated\" } });\n expect(onUpdate).toHaveBeenCalledWith({ text: \"updated\" });\n });\n\n it(\"stops propagation on textarea pointer down (prevents canvas pan)\", () => {\n const parentHandler = vi.fn();\n const { getByRole } = render(\n <div onPointerDown={parentHandler}>\n <NoteCardView card={baseCard} onUpdate={vi.fn()} />\n </div>\n );\n fireEvent.pointerDown(getByRole(\"textbox\"));\n expect(parentHandler).not.toHaveBeenCalled();\n });\n\n it(\"renders current card text in textarea\", () => {\n const { getByRole } = render(<NoteCardView card={{ ...baseCard, text: \"my note\" }} onUpdate={vi.fn()} />);\n expect((getByRole(\"textbox\") as HTMLTextAreaElement).value).toBe(\"my note\");\n });\n});\n\n// --- Layout/style ---\ndescribe(\"NoteCardView positioning\", () => {\n it(\"applies card position and dimensions as inline style\", () => {\n const { container } = render(<NoteCardView card={baseCard} onUpdate={vi.fn()} />);\n const el = container.firstChild as HTMLElement;\n expect(el.style.left).toBe(\"50px\");\n expect(el.style.top).toBe(\"80px\");\n expect(el.style.width).toBe(\"200px\");\n expect(el.style.height).toBe(\"150px\");\n expect(el.style.zIndex).toBe(\"3\");\n });\n});\n```\n\n---\n\n",
|
|
"level": 3
|
|
},
|
|
{
|
|
"title": "3. `Canvas.tsx` — Zoom logic",
|
|
"content": "\nThe zoom math (`Math.exp`, clamping, origin-preserving transform) has no tests. This is the most logic-heavy code in the project.\n\n```ts\n// src/canvas/Canvas.test.tsx\nimport { describe, it, expect, vi } from \"vitest\";\nimport { render, fireEvent, act } from \"@testing-library/react\";\nimport { Canvas } from \"./Canvas\";\nimport type { Card } from \"./types\";\n\nconst notes: Card[] = [\n { id:\"n1\", kind:\"note\", x:0, y:0, width:100, height:80, z:0, text:\"A\" },\n { id:\"n2\", kind:\"note\", x:200, y:200, width:100, height:80, z:1, text:\"B\" },\n];\n\n// Helper: fire a wheel event with Ctrl held\nfunction ctrlWheel(el: Element, deltaY: number) {\n fireEvent.wheel(el, { ctrlKey: true, deltaY, clientX: 0, clientY: 0 });\n}\n\ndescribe(\"Canvas zoom (onWheel)\", () => {\n it(\"ignores wheel without Ctrl/Meta key\", () => {\n const { getByText } = render(<Canvas initialCards={notes} />);\n const hud = getByText(/100%/); // initial scale\n fireEvent.wheel(document.querySelector(\".canvas-container\")!, { ctrlKey: false, deltaY: -100 });\n expect(getByText(/100%/)).toBeTruthy(); // unchanged\n });\n\n it(\"zooms in on negative deltaY with Ctrl\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n ctrlWheel(container, -100);\n // scale should be > 100%\n const hud = document.querySelector(\".canvas-hud\")!;\n const pct = parseInt(hud.querySelectorAll(\"span\")[2].textContent!);\n expect(pct).toBeGreaterThan(100);\n });\n\n it(\"zooms out on positive deltaY with Ctrl\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n ctrlWheel(container, 100);\n const pct = parseInt(document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\")[2].textContent!);\n expect(pct).toBeLessThan(100);\n });\n\n it(\"clamps scale at MIN_SCALE (0.1 = 10%)\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n // Zoom way out\n for (let i = 0; i < 50; i++) ctrlWheel(container, 9999);\n const pct = parseInt(document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\")[2].textContent!);\n expect(pct).toBe(10);\n });\n\n it(\"clamps scale at MAX_SCALE (4 = 400%)\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n for (let i = 0; i < 50; i++) ctrlWheel(container, -9999);\n const pct = parseInt(document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\")[2].textContent!);\n expect(pct).toBe(400);\n });\n});\n\n// --- Panning ---\ndescribe(\"Canvas pan (pointer events)\", () => {\n it(\"pans on middle-button drag\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n fireEvent.pointerDown(container, { button: 1, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(container, { clientX: 50, clientY: 30 });\n fireEvent.pointerUp(container, { button: 1 });\n const spans = document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\");\n expect(spans[0].textContent).toBe(\"x 50\");\n expect(spans[1].textContent).toBe(\"y 30\");\n });\n\n it(\"does not pan on plain left-button drag (no space)\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n fireEvent.pointerDown(container, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(container, { clientX: 50, clientY: 30 });\n const spans = document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\");\n expect(spans[0].textContent).toBe(\"x 0\");\n });\n\n it(\"pans on left-button + space\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n fireEvent.keyDown(window, { code: \"Space\" });\n fireEvent.pointerDown(container, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(container, { clientX: 40, clientY: 0 });\n const spans = document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\");\n expect(spans[0].textContent).toBe(\"x 40\");\n });\n\n it(\"stops panning after pointer up\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n fireEvent.pointerDown(container, { button: 1, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(container, { clientX: 50, clientY: 0 });\n fireEvent.pointerUp(container);\n fireEvent.pointerMove(container, { clientX: 200, clientY: 0 }); // should not update\n expect(document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\")[0].textContent).toBe(\"x 50\");\n });\n\n it(\"handles pointer cancel same as pointer up\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n fireEvent.pointerDown(container, { button: 1, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(container, { clientX: 30, clientY: 0 });\n fireEvent.pointerCancel(container);\n fireEvent.pointerMove(container, { clientX: 200, clientY: 0 });\n expect(document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\")[0].textContent).toBe(\"x 30\");\n });\n});\n\n// --- Space key ---\ndescribe(\"Canvas space key\", () => {\n it(\"adds pan-mode class on space down\", () => {\n render(<Canvas initialCards={notes} />);\n fireEvent.keyDown(window, { code: \"Space\" });\n expect(document.querySelector(\".canvas-container\")!.classList).toContain(\"pan-mode\");\n });\n\n it(\"removes pan-mode class on space up\", () => {\n render(<Canvas initialCards={notes} />);\n fireEvent.keyDown(window, { code: \"Space\" });\n fireEvent.keyUp(window, { code: \"Space\" });\n expect(document.querySelector(\".canvas-container\")!.classList).not.toContain(\"pan-mode\");\n });\n\n it(\"ignores repeated space keydown events\", () => {\n render(<Canvas initialCards={notes} />);\n fireEvent.keyDown(window, { code: \"Space\", repeat: true }); // should not crash or double-set\n // No assertion needed — just must not throw\n });\n\n it(\"removes key listeners on unmount\", () => {\n const { unmount } = render(<Canvas initialCards={notes} />);\n const spy = vi.spyOn(window, \"removeEventListener\");\n unmount();\n expect(spy).toHaveBeenCalledWith(\"keydown\", expect.any(Function));\n expect(spy).toHaveBeenCalledWith(\"keyup\", expect.any(Function));\n });\n});\n\n// --- Card rendering ---\ndescribe(\"Canvas card rendering\", () => {\n it(\"renders only note cards (other kinds return null)\", () => {\n const mixed: Card[] = [\n ...notes,\n { id:\"t1\", kind:\"terminal\", x:0,y:0,width:100,height:100,z:0, ptyId:\"p1\" },\n { id:\"a1\", kind:\"app\", x:0,y:0,width:100,height:100,z:0, xWindowId:1, command:\"xterm\" },\n ];\n const { getAllByRole } = render(<Canvas initialCards={mixed} />);\n // Only 2 textareas from note cards\n expect(getAllByRole(\"textbox\").length).toBe(2);\n });\n\n it(\"updateCard mutates only the targeted card\", () => {\n const { getAllByRole } = render(<Canvas initialCards={notes} />);\n const [ta1] = getAllByRole(\"textbox\") as HTMLTextAreaElement[];\n fireEvent.change(ta1, { target: { value: \"changed\" } });\n const [ta1After, ta2After] = getAllByRole(\"textbox\") as HTMLTextAreaElement[];\n expect(ta1After.value).toBe(\"changed\");\n expect(ta2After.value).toBe(\"B\"); // unchanged\n });\n\n it(\"renders empty canvas with no cards\", () => {\n const { container } = render(<Canvas initialCards={[]} />);\n expect(container.querySelectorAll(\".card\").length).toBe(0);\n });\n});\n\n// --- HUD ---\ndescribe(\"Canvas HUD\", () => {\n it(\"shows initial viewport at 0,0 100%\", () => {\n render(<Canvas initialCards={[]} />);\n const spans = document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\");\n expect(spans[0].textContent).toBe(\"x 0\");\n expect(spans[1].textContent).toBe(\"y 0\");\n expect(spans[2].textContent).toBe(\"100%\");\n });\n});\n```\n\n---\n\n",
|
|
"level": 3
|
|
},
|
|
{
|
|
"title": "4. Integration gaps",
|
|
"content": "\n```ts\n// src/canvas/Canvas.integration.test.tsx\nimport { describe, it, expect } from \"vitest\";\nimport { render, fireEvent } from \"@testing-library/react\";\nimport { Canvas } from \"./canvas/Canvas\";\nimport type { Card } from \"./canvas/types\";\n\ndescribe(\"Card drag within zoomed canvas\", () => {\n it(\"card position update accounts for canvas scale\", () => {\n // TODO: render Canvas at scale 2, drag NoteCard, verify onUpdate receives halved delta\n // Requires exposing viewport state or reading DOM transforms\n });\n\n it(\"dragging one card does not move others\", () => {\n const cards: Card[] = [\n { id:\"a\", kind:\"note\", x:0, y:0, width:100, height:80, z:0, text:\"A\" },\n { id:\"b\", kind:\"note\", x:300,y:300, width:100, height:80, z:1, text:\"B\" },\n ];\n const { getAllByText } = render(<Canvas initialCards={cards} />);\n const headerA = getAllByText(\"note\")[0];\n fireEvent.pointerDown(headerA, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(headerA, { clientX: 50, clientY: 50 });\n // Card B position style should be unchanged at left:300px\n const allCards = document.querySelectorAll(\".card\");\n expect((allCards[1] as HTMLElement).style.left).toBe(\"300px\");\n });\n});\n\ndescribe(\"Pan + zoom composition\", () => {\n it(\"zoom after pan preserves pan offset proportionally\", () => {\n // TODO: pan to (100,0), then zoom — verify the world-origin point\n // stays anchored at the zoom focus point, not reset\n });\n});\n```\n\n---\n\n",
|
|
"level": 3
|
|
},
|
|
{
|
|
"title": "Summary of Gaps",
|
|
"content": "| Area | Gap | Severity |\n|------|-----|----------|\n| `parseTransformScale` | Pure function, entirely untested | High |\n| Zoom clamping (MIN/MAX) | Boundary values unchecked | High |\n| Zoom math | Origin-preserving transform not verified | High |\n| Space + pan mode | Key event → class toggle not tested | Medium |\n| Event listener cleanup | Memory leak risk on unmount | Medium |\n| `updateCard` isolation | Other cards mutated is possible bug | Medium |\n| Non-note card kinds | `null` render path not tested | Medium |\n| Type guards | No guards exist; narrowing is ad-hoc | Low |\n| Integration: drag in zoomed canvas | scale division never verified end-to-end | High |\n\n**Recommended setup to add:**\n```bash\nnpm i -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom\n```\n\nThen in `vite.config.ts` add `test: { environment: \"jsdom\", globals: true }`.",
|
|
"level": 2
|
|
}
|
|
],
|
|
"codeBlocks": [
|
|
{
|
|
"language": "ts",
|
|
"code": "// src/canvas/types.test.ts\nimport { describe, it, expect } from \"vitest\";\nimport type { Card, NoteCard, TerminalCard, AppCard, ThumbnailCard } from \"./types\";\n\nfunction isNoteCard(c: Card): c is NoteCard { return c.kind === \"note\"; }\nfunction isTerminalCard(c: Card): c is TerminalCard { return c.kind === \"terminal\"; }\nfunction isAppCard(c: Card): c is AppCard { return c.kind === \"app\"; }\nfunction isThumbnailCard(c: Card): c is ThumbnailCard { return c.kind === \"thumbnail\"; }\n\ndescribe(\"Card type narrowing\", () => {\n const note: NoteCard = { id: \"1\", kind: \"note\", x:0,y:0,width:100,height:100,z:0, text:\"hi\" };\n const term: TerminalCard = { id: \"2\", kind: \"terminal\", x:0,y:0,width:100,height:100,z:0, ptyId:\"p1\" };\n const app: AppCard = { id: \"3\", kind: \"app\", x:0,y:0,width:100,height:100,z:0, xWindowId:42, command:\"bash\" };\n const thumb: ThumbnailCard = { id: \"4\", kind: \"thumbnail\", x:0,y:0,width:100,height:100,z:0, refCardId:\"1\", label:\"Note\" };\n\n it(\"isNoteCard is true only for note\", () => {\n expect(isNoteCard(note)).toBe(true);\n expect(isNoteCard(term)).toBe(false);\n });\n\n it(\"AppCard optional title defaults to undefined\", () => {\n expect(app.title).toBeUndefined();\n });\n\n it(\"ThumbnailCard refCardId references a valid CardId shape\", () => {\n expect(typeof thumb.refCardId).toBe(\"string\");\n });\n\n // Edge: empty string is a valid CardId (no validation enforced in types)\n it(\"empty string is accepted as CardId\", () => {\n const c: Card = { ...note, id: \"\" };\n expect(c.id).toBe(\"\");\n });\n});"
|
|
},
|
|
{
|
|
"language": "ts",
|
|
"code": "// src/canvas/cards/NoteCardView.test.tsx\nimport { describe, it, expect, vi } from \"vitest\";\nimport { render, fireEvent } from \"@testing-library/react\";\nimport { NoteCardView } from \"./NoteCardView\";\nimport type { NoteCard } from \"../types\";\n\nconst baseCard: NoteCard = { id:\"c1\", kind:\"note\", x:50, y:80, width:200, height:150, z:3, text:\"hello\" };\n\n// --- parseTransformScale (tested via drag behavior) ---\ndescribe(\"parseTransformScale (via drag move)\", () => {\n it(\"returns 1 when no canvas-world ancestor present\", () => {\n // No .canvas-world wrapper → scale defaults to 1, movement is 1:1\n const onUpdate = vi.fn();\n const { getByText } = render(<NoteCardView card={baseCard} onUpdate={onUpdate} />);\n const header = getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(header, { clientX: 10, clientY: 20 });\n expect(onUpdate).toHaveBeenCalledWith({ x: 60, y: 100 }); // 50+10, 80+20\n });\n\n it(\"divides movement by canvas-world scale\", () => {\n // Wrap in a .canvas-world div with scale(2)\n const onUpdate = vi.fn();\n const { getByText, container } = render(\n <div className=\"canvas-world\" style={{ transform: \"translate(0px,0px) scale(2)\" }}>\n <NoteCardView card={baseCard} onUpdate={onUpdate} />\n </div>\n );\n const header = getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(header, { clientX: 20, clientY: 40 });\n expect(onUpdate).toHaveBeenCalledWith({ x: 60, y: 100 }); // 20/2=10, 40/2=20\n });\n\n // Edge cases for parseTransformScale\n it(\"returns 1 for malformed transform string\", () => {\n const onUpdate = vi.fn();\n const { getByText } = render(\n <div className=\"canvas-world\" style={{ transform: \"rotate(45deg)\" }}>\n <NoteCardView card={baseCard} onUpdate={onUpdate} />\n </div>\n );\n const header = getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(header, { clientX: 10, clientY: 0 });\n expect(onUpdate).toHaveBeenCalledWith({ x: 60, y: 80 }); // scale=1 fallback\n });\n});\n\n// --- Drag initiation ---\ndescribe(\"NoteCardView drag\", () => {\n it(\"ignores non-left-button pointer down\", () => {\n const onUpdate = vi.fn();\n const { getByText } = render(<NoteCardView card={baseCard} onUpdate={onUpdate} />);\n const header = getByText(\"note\");\n fireEvent.pointerDown(header, { button: 1, clientX: 0, clientY: 0 }); // middle\n fireEvent.pointerMove(header, { clientX: 50, clientY: 50 });\n expect(onUpdate).not.toHaveBeenCalled();\n });\n\n it(\"stops propagation on header pointer down\", () => {\n const parentHandler = vi.fn();\n const { getByText } = render(\n <div onPointerDown={parentHandler}>\n <NoteCardView card={baseCard} onUpdate={vi.fn()} />\n </div>\n );\n fireEvent.pointerDown(getByText(\"note\"), { button: 0 });\n expect(parentHandler).not.toHaveBeenCalled();\n });\n\n it(\"pointer move without prior down does nothing\", () => {\n const onUpdate = vi.fn();\n const { getByText } = render(<NoteCardView card={baseCard} onUpdate={onUpdate} />);\n fireEvent.pointerMove(getByText(\"note\"));\n expect(onUpdate).not.toHaveBeenCalled();\n });\n\n it(\"clears drag state on pointer up\", () => {\n const onUpdate = vi.fn();\n const { getByText } = render(<NoteCardView card={baseCard} onUpdate={onUpdate} />);\n const header = getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerUp(header);\n fireEvent.pointerMove(header, { clientX: 100, clientY: 100 });\n // After pointer up, move should not trigger update\n expect(onUpdate).not.toHaveBeenCalled();\n });\n\n it(\"handles pointer cancel same as pointer up\", () => {\n const onUpdate = vi.fn();\n const { getByText } = render(<NoteCardView card={baseCard} onUpdate={onUpdate} />);\n const header = getByText(\"note\");\n fireEvent.pointerDown(header, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerCancel(header);\n fireEvent.pointerMove(header, { clientX: 50, clientY: 50 });\n expect(onUpdate).not.toHaveBeenCalled();\n });\n});\n\n// --- Textarea ---\ndescribe(\"NoteCardView textarea\", () => {\n it(\"calls onUpdate with new text on change\", () => {\n const onUpdate = vi.fn();\n const { getByRole } = render(<NoteCardView card={baseCard} onUpdate={onUpdate} />);\n fireEvent.change(getByRole(\"textbox\"), { target: { value: \"updated\" } });\n expect(onUpdate).toHaveBeenCalledWith({ text: \"updated\" });\n });\n\n it(\"stops propagation on textarea pointer down (prevents canvas pan)\", () => {\n const parentHandler = vi.fn();\n const { getByRole } = render(\n <div onPointerDown={parentHandler}>\n <NoteCardView card={baseCard} onUpdate={vi.fn()} />\n </div>\n );\n fireEvent.pointerDown(getByRole(\"textbox\"));\n expect(parentHandler).not.toHaveBeenCalled();\n });\n\n it(\"renders current card text in textarea\", () => {\n const { getByRole } = render(<NoteCardView card={{ ...baseCard, text: \"my note\" }} onUpdate={vi.fn()} />);\n expect((getByRole(\"textbox\") as HTMLTextAreaElement).value).toBe(\"my note\");\n });\n});\n\n// --- Layout/style ---\ndescribe(\"NoteCardView positioning\", () => {\n it(\"applies card position and dimensions as inline style\", () => {\n const { container } = render(<NoteCardView card={baseCard} onUpdate={vi.fn()} />);\n const el = container.firstChild as HTMLElement;\n expect(el.style.left).toBe(\"50px\");\n expect(el.style.top).toBe(\"80px\");\n expect(el.style.width).toBe(\"200px\");\n expect(el.style.height).toBe(\"150px\");\n expect(el.style.zIndex).toBe(\"3\");\n });\n});"
|
|
},
|
|
{
|
|
"language": "ts",
|
|
"code": "// src/canvas/Canvas.test.tsx\nimport { describe, it, expect, vi } from \"vitest\";\nimport { render, fireEvent, act } from \"@testing-library/react\";\nimport { Canvas } from \"./Canvas\";\nimport type { Card } from \"./types\";\n\nconst notes: Card[] = [\n { id:\"n1\", kind:\"note\", x:0, y:0, width:100, height:80, z:0, text:\"A\" },\n { id:\"n2\", kind:\"note\", x:200, y:200, width:100, height:80, z:1, text:\"B\" },\n];\n\n// Helper: fire a wheel event with Ctrl held\nfunction ctrlWheel(el: Element, deltaY: number) {\n fireEvent.wheel(el, { ctrlKey: true, deltaY, clientX: 0, clientY: 0 });\n}\n\ndescribe(\"Canvas zoom (onWheel)\", () => {\n it(\"ignores wheel without Ctrl/Meta key\", () => {\n const { getByText } = render(<Canvas initialCards={notes} />);\n const hud = getByText(/100%/); // initial scale\n fireEvent.wheel(document.querySelector(\".canvas-container\")!, { ctrlKey: false, deltaY: -100 });\n expect(getByText(/100%/)).toBeTruthy(); // unchanged\n });\n\n it(\"zooms in on negative deltaY with Ctrl\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n ctrlWheel(container, -100);\n // scale should be > 100%\n const hud = document.querySelector(\".canvas-hud\")!;\n const pct = parseInt(hud.querySelectorAll(\"span\")[2].textContent!);\n expect(pct).toBeGreaterThan(100);\n });\n\n it(\"zooms out on positive deltaY with Ctrl\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n ctrlWheel(container, 100);\n const pct = parseInt(document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\")[2].textContent!);\n expect(pct).toBeLessThan(100);\n });\n\n it(\"clamps scale at MIN_SCALE (0.1 = 10%)\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n // Zoom way out\n for (let i = 0; i < 50; i++) ctrlWheel(container, 9999);\n const pct = parseInt(document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\")[2].textContent!);\n expect(pct).toBe(10);\n });\n\n it(\"clamps scale at MAX_SCALE (4 = 400%)\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n for (let i = 0; i < 50; i++) ctrlWheel(container, -9999);\n const pct = parseInt(document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\")[2].textContent!);\n expect(pct).toBe(400);\n });\n});\n\n// --- Panning ---\ndescribe(\"Canvas pan (pointer events)\", () => {\n it(\"pans on middle-button drag\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n fireEvent.pointerDown(container, { button: 1, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(container, { clientX: 50, clientY: 30 });\n fireEvent.pointerUp(container, { button: 1 });\n const spans = document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\");\n expect(spans[0].textContent).toBe(\"x 50\");\n expect(spans[1].textContent).toBe(\"y 30\");\n });\n\n it(\"does not pan on plain left-button drag (no space)\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n fireEvent.pointerDown(container, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(container, { clientX: 50, clientY: 30 });\n const spans = document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\");\n expect(spans[0].textContent).toBe(\"x 0\");\n });\n\n it(\"pans on left-button + space\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n fireEvent.keyDown(window, { code: \"Space\" });\n fireEvent.pointerDown(container, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(container, { clientX: 40, clientY: 0 });\n const spans = document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\");\n expect(spans[0].textContent).toBe(\"x 40\");\n });\n\n it(\"stops panning after pointer up\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n fireEvent.pointerDown(container, { button: 1, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(container, { clientX: 50, clientY: 0 });\n fireEvent.pointerUp(container);\n fireEvent.pointerMove(container, { clientX: 200, clientY: 0 }); // should not update\n expect(document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\")[0].textContent).toBe(\"x 50\");\n });\n\n it(\"handles pointer cancel same as pointer up\", () => {\n render(<Canvas initialCards={notes} />);\n const container = document.querySelector(\".canvas-container\")!;\n fireEvent.pointerDown(container, { button: 1, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(container, { clientX: 30, clientY: 0 });\n fireEvent.pointerCancel(container);\n fireEvent.pointerMove(container, { clientX: 200, clientY: 0 });\n expect(document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\")[0].textContent).toBe(\"x 30\");\n });\n});\n\n// --- Space key ---\ndescribe(\"Canvas space key\", () => {\n it(\"adds pan-mode class on space down\", () => {\n render(<Canvas initialCards={notes} />);\n fireEvent.keyDown(window, { code: \"Space\" });\n expect(document.querySelector(\".canvas-container\")!.classList).toContain(\"pan-mode\");\n });\n\n it(\"removes pan-mode class on space up\", () => {\n render(<Canvas initialCards={notes} />);\n fireEvent.keyDown(window, { code: \"Space\" });\n fireEvent.keyUp(window, { code: \"Space\" });\n expect(document.querySelector(\".canvas-container\")!.classList).not.toContain(\"pan-mode\");\n });\n\n it(\"ignores repeated space keydown events\", () => {\n render(<Canvas initialCards={notes} />);\n fireEvent.keyDown(window, { code: \"Space\", repeat: true }); // should not crash or double-set\n // No assertion needed — just must not throw\n });\n\n it(\"removes key listeners on unmount\", () => {\n const { unmount } = render(<Canvas initialCards={notes} />);\n const spy = vi.spyOn(window, \"removeEventListener\");\n unmount();\n expect(spy).toHaveBeenCalledWith(\"keydown\", expect.any(Function));\n expect(spy).toHaveBeenCalledWith(\"keyup\", expect.any(Function));\n });\n});\n\n// --- Card rendering ---\ndescribe(\"Canvas card rendering\", () => {\n it(\"renders only note cards (other kinds return null)\", () => {\n const mixed: Card[] = [\n ...notes,\n { id:\"t1\", kind:\"terminal\", x:0,y:0,width:100,height:100,z:0, ptyId:\"p1\" },\n { id:\"a1\", kind:\"app\", x:0,y:0,width:100,height:100,z:0, xWindowId:1, command:\"xterm\" },\n ];\n const { getAllByRole } = render(<Canvas initialCards={mixed} />);\n // Only 2 textareas from note cards\n expect(getAllByRole(\"textbox\").length).toBe(2);\n });\n\n it(\"updateCard mutates only the targeted card\", () => {\n const { getAllByRole } = render(<Canvas initialCards={notes} />);\n const [ta1] = getAllByRole(\"textbox\") as HTMLTextAreaElement[];\n fireEvent.change(ta1, { target: { value: \"changed\" } });\n const [ta1After, ta2After] = getAllByRole(\"textbox\") as HTMLTextAreaElement[];\n expect(ta1After.value).toBe(\"changed\");\n expect(ta2After.value).toBe(\"B\"); // unchanged\n });\n\n it(\"renders empty canvas with no cards\", () => {\n const { container } = render(<Canvas initialCards={[]} />);\n expect(container.querySelectorAll(\".card\").length).toBe(0);\n });\n});\n\n// --- HUD ---\ndescribe(\"Canvas HUD\", () => {\n it(\"shows initial viewport at 0,0 100%\", () => {\n render(<Canvas initialCards={[]} />);\n const spans = document.querySelector(\".canvas-hud\")!.querySelectorAll(\"span\");\n expect(spans[0].textContent).toBe(\"x 0\");\n expect(spans[1].textContent).toBe(\"y 0\");\n expect(spans[2].textContent).toBe(\"100%\");\n });\n});"
|
|
},
|
|
{
|
|
"language": "ts",
|
|
"code": "// src/canvas/Canvas.integration.test.tsx\nimport { describe, it, expect } from \"vitest\";\nimport { render, fireEvent } from \"@testing-library/react\";\nimport { Canvas } from \"./canvas/Canvas\";\nimport type { Card } from \"./canvas/types\";\n\ndescribe(\"Card drag within zoomed canvas\", () => {\n it(\"card position update accounts for canvas scale\", () => {\n // TODO: render Canvas at scale 2, drag NoteCard, verify onUpdate receives halved delta\n // Requires exposing viewport state or reading DOM transforms\n });\n\n it(\"dragging one card does not move others\", () => {\n const cards: Card[] = [\n { id:\"a\", kind:\"note\", x:0, y:0, width:100, height:80, z:0, text:\"A\" },\n { id:\"b\", kind:\"note\", x:300,y:300, width:100, height:80, z:1, text:\"B\" },\n ];\n const { getAllByText } = render(<Canvas initialCards={cards} />);\n const headerA = getAllByText(\"note\")[0];\n fireEvent.pointerDown(headerA, { button: 0, clientX: 0, clientY: 0 });\n fireEvent.pointerMove(headerA, { clientX: 50, clientY: 50 });\n // Card B position style should be unchanged at left:300px\n const allCards = document.querySelectorAll(\".card\");\n expect((allCards[1] as HTMLElement).style.left).toBe(\"300px\");\n });\n});\n\ndescribe(\"Pan + zoom composition\", () => {\n it(\"zoom after pan preserves pan offset proportionally\", () => {\n // TODO: pan to (100,0), then zoom — verify the world-origin point\n // stays anchored at the zoom focus point, not reset\n });\n});"
|
|
},
|
|
{
|
|
"language": "bash",
|
|
"code": "npm i -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom"
|
|
}
|
|
]
|
|
},
|
|
"durationMs": 160112,
|
|
"model": "sonnet",
|
|
"sandboxMode": "permissive",
|
|
"workerType": "testgaps",
|
|
"timestamp": "2026-05-14T20:08:19.008Z",
|
|
"executionId": "testgaps_1778789138896_o4akik"
|
|
}
|