From bb82b7f699b6e5678a430a6b893e52cc727a1bde Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Mon, 20 Jul 2026 01:19:20 -0500 Subject: [PATCH] fix: tests --- TODO.md | 17 +- .../desktop-session.invoke.test.ts | 68 +- .../src/lib/persistence/desktop-session.ts | 21 + apps/web/src/lib/tests/Canvas.history.test.ts | 497 ++++--------- .../web/src/lib/tests/Canvas.keyboard.test.ts | 658 +++++++++--------- 5 files changed, 562 insertions(+), 699 deletions(-) diff --git a/TODO.md b/TODO.md index d962621..49728e3 100644 --- a/TODO.md +++ b/TODO.md @@ -190,18 +190,18 @@ Blocked by: V2-20, V2-22 Acceptance criteria: -- [ ] Start from a clean desktop build and a fresh Codex context with only the +- [x] Start from a clean desktop build and a fresh Codex context with only the packaged skill installed. After launch, disconnect the network; the rest of the session requires no account, server, browser automation, raw document edit, or unbundled repository instruction. -- [ ] The user creates or selects a local `.inkfinite` document and gives Codex +- [x] The user creates or selects a local `.inkfinite` document and gives Codex a short desktop-application brief. The target wireframe exercises named semantic roles, text, connections, at least two layers, and one locked or `agent_editable: false` element. -- [ ] Codex discovers the open session, inspects its heads, queries only the +- [x] Codex discovers the open session, inspects its heads, queries only the relevant records, and describes a small first change before proposing it. The first durable agent action uses `app propose`, not direct apply. -- [ ] The user rejects one proposal and confirms that the snapshot and heads do +- [x] The user rejects one proposal and confirms that the snapshot and heads do not change, then requests a revision and reviews its ghost preview and created, changed, and deleted IDs. - [ ] The user partially accepts a proposal whose operations can remain valid @@ -258,8 +258,7 @@ Acceptance criteria: ### QA -- [x] Expose agent-editable state in the UI. -- [x] Make Save As open the native dialog and persist to the selected path. -- We don't expose dirty when creating a new board -- [x] Make New Board create and persist the selected `.inkfinite` file. -- [x] Recover app-managed drafts after a crash leaves the lock sidecar behind. +- [x] The desktop kept an expired proposal visible even though the backend had + discarded it, leaving Accept and Reject unable to complete. The desktop + now clears the review when its deadline passes and confirms that the + document did not change. diff --git a/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts b/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts index 7ea6bd0..faecbab 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts @@ -1,5 +1,5 @@ import type { DesktopFileOps } from '@inkfinite/core'; -import type { DocumentSnapshot } from '@inkfinite/bindings'; +import type { DocumentSnapshot, Proposal } from '@inkfinite/bindings'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const tauri = vi.hoisted(() => ({ invoke: vi.fn(), listen: vi.fn(async () => () => undefined) })); @@ -128,4 +128,70 @@ describe('Tauri desktop session command boundary', () => { expect(tauri.invoke).toHaveBeenCalledWith('create_document', expect.any(Object)); expect(tauri.invoke).toHaveBeenCalledWith('save_as', expect.any(Object)); }); + + it('clears a live proposal when its review window expires', async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + try { + const files = fileOps(); + const document = snapshot('board:proposal-expiry'); + tauri.invoke.mockImplementation(async (command: string) => { + if (command !== 'create_document') throw new Error(`Unexpected command: ${command}`); + return { + session_id: 'session:1', + status: { + session_id: 'session:1', + path: '/tmp/Untitled.inkfinite', + actor_id: 'actor:desktop', + snapshot: document, + dirty: false, + lock_held: true, + recovery_available: false, + can_undo: false, + can_redo: false, + sync: { status: 'disabled' } + } + } satisfies SessionOpened; + }); + + const repo = createDesktopSessionRepo(files.ops); + await repo.createBoard('Untitled'); + const updates: Array<{ proposal: Proposal | null; message?: string }> = []; + repo.subscribeProposal((update) => updates.push(update)); + const listenCalls = tauri.listen.mock.calls as unknown as Array< + [string, (event: { payload: { session_id?: string; proposal: Proposal } }) => void] + >; + const proposalListener = listenCalls.find(([event]) => event === 'inkfinite-proposal')?.[1]; + expect(proposalListener).toBeTypeOf('function'); + + const proposal: Proposal = { + id: 'proposal:1', + transaction: { + id: 'transaction:1', + actor_id: 'actor:desktop', + origin: 'agent', + base_heads: ['head:1'], + description: 'Preview expiry', + operations: [], + timestamp: 1_000 + }, + preview: { created: [], changed: [], deleted: [] }, + affected_regions: [], + warnings: [], + expires_at: 2_000 + }; + proposalListener?.({ payload: { session_id: 'session:1', proposal } }); + expect(repo.getProposal()?.id).toBe('proposal:1'); + + vi.advanceTimersByTime(1_000); + + expect(repo.getProposal()).toBeNull(); + expect(updates.at(-1)).toEqual({ + proposal: null, + message: 'The proposal expired without changing the document.' + }); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/apps/desktop/src/lib/persistence/desktop-session.ts b/apps/desktop/src/lib/persistence/desktop-session.ts index 0bcee7e..0bf9bbe 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.ts @@ -250,6 +250,7 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: let currentStatus: SessionStatus | null = null; let currentIsDraft = false; let currentProposal: Proposal | null = null; + let proposalExpiryTimer: ReturnType | null = null; const proposalListeners = new Set<(update: ProposalUpdate) => void>(); const liveUnlisteners: Array<() => void> = []; const boardFiles = new Map(); @@ -260,8 +261,26 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: type LiveSyncEvent = { session_id?: string | null; sync: SessionSync }; function notifyProposal(update: ProposalUpdate) { + if (proposalExpiryTimer) clearTimeout(proposalExpiryTimer); + proposalExpiryTimer = null; currentProposal = update.proposal; for (const listener of proposalListeners) listener(update); + if (update.proposal) { + const delay = update.proposal.expires_at - Date.now(); + if (delay <= 0) { + notifyProposal({ proposal: null, message: 'The proposal expired without changing the document.' }); + } else { + proposalExpiryTimer = setTimeout(() => { + notifyProposal({ proposal: null, message: 'The proposal expired without changing the document.' }); + }, delay); + } + } + } + + function clearExpiredProposal(proposalId: string): boolean { + if (currentProposal?.id !== proposalId || Date.now() < currentProposal.expires_at) return false; + notifyProposal({ proposal: null, message: 'The proposal expired without changing the document.' }); + return true; } function eventBelongsToCurrentSession(sessionId?: string | null): boolean { @@ -613,6 +632,7 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: async function acceptProposal(proposalId: string, operationPositions?: number[]): Promise { if (!currentStatus) throw new Error('No board loaded'); + if (clearExpiredProposal(proposalId)) return; const result = await api.acceptProposal({ session_id: currentStatus.session_id, proposal_id: proposalId, @@ -625,6 +645,7 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: async function rejectProposal(proposalId: string): Promise { if (!currentStatus) throw new Error('No board loaded'); + if (clearExpiredProposal(proposalId)) return; await api.rejectProposal({ session_id: currentStatus.session_id, proposal_id: proposalId }); notifyProposal({ proposal: null }); } diff --git a/apps/web/src/lib/tests/Canvas.history.test.ts b/apps/web/src/lib/tests/Canvas.history.test.ts index 84263b8..6f7db80 100644 --- a/apps/web/src/lib/tests/Canvas.history.test.ts +++ b/apps/web/src/lib/tests/Canvas.history.test.ts @@ -1,366 +1,153 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render } from "vitest-browser-svelte"; - -const actionHandlers: Array<(action: any) => void> = []; -const coreMocks = vi.hoisted(() => ({ sinkEnqueueSpy: vi.fn(), storeInstances: [] as any[] })); -vi.mock("$editor/input", () => { - return { - createInputAdapter: vi.fn((config) => { - actionHandlers.push(config.onAction); - return { dispose: vi.fn() }; - }), - }; +import type { Action, Store } from '@inkfinite/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; + +const actionHandlers: Array<(action: Action) => void> = []; +const testState = vi.hoisted(() => ({ sinkEnqueueSpy: vi.fn(), storeInstances: [] as Store[] })); +vi.mock('$editor/input', () => { + return { + createInputAdapter: vi.fn((config) => { + actionHandlers.push(config.onAction); + return { dispose: vi.fn() }; + }) + }; }); -vi.mock( - "$editor/status", - () => ({ - createStatusStore: () => ({ - get: () => ({ backend: "indexeddb", state: "saved", pendingWrites: 0 }), - subscribe: () => () => {}, - update: () => {}, - }), - createSnapStore: () => ({ - get: () => ({ snapEnabled: false, gridEnabled: true, gridSize: 25 }), - subscribe: () => () => {}, - update: () => {}, - set: () => {}, - }), - createBrushStore: () => ({ - get: () => ({ - size: 16, - thinning: 0.5, - smoothing: 0.5, - streamline: 0.5, - simulatePressure: true, - color: "#88c0d0", - }), - subscribe: () => () => {}, - update: () => {}, - set: () => {}, - }), - }), -); +vi.mock('$editor/status', () => ({ + createStatusStore: () => ({ + get: () => ({ backend: 'indexeddb', state: 'saved', pendingWrites: 0 }), + subscribe: () => () => {}, + update: () => {} + }), + createSnapStore: () => ({ + get: () => ({ snapEnabled: false, gridEnabled: true, gridSize: 25 }), + subscribe: () => () => {}, + update: () => {}, + set: () => {} + }), + createBrushStore: () => ({ + get: () => ({ + size: 16, + thinning: 0.5, + smoothing: 0.5, + streamline: 0.5, + simulatePressure: true, + color: '#88c0d0' + }), + subscribe: () => () => {}, + update: () => {}, + set: () => {} + }) +})); -vi.mock("@inkfinite/renderer", () => { - return { createRenderer: vi.fn(() => ({ dispose: vi.fn(), markDirty: vi.fn() })) }; +vi.mock('@inkfinite/renderer', () => { + return { + createRenderer: vi.fn((_canvas, store) => { + testState.storeInstances.push(store); + return { dispose: vi.fn(), markDirty: vi.fn() }; + }) + }; }); const createDoc = () => ({ - pages: { "page:1": { id: "page:1", name: "Page 1", shapeIds: [] } }, - shapes: {}, - bindings: {}, - order: { pageIds: ["page:1"], shapeOrder: { "page:1": [] } }, + pages: { 'page:1': { id: 'page:1', name: 'Page 1', shapeIds: ['shape:1'] } }, + shapes: { + 'shape:1': { + id: 'shape:1', + type: 'rect' as const, + pageId: 'page:1', + x: 0, + y: 0, + rot: 0, + props: { w: 20, h: 20, fill: '#000', stroke: '#000', radius: 0 } + } + }, + bindings: {}, + order: { pageIds: ['page:1'], shapeOrder: { 'page:1': ['shape:1'] } } }); -vi.mock("$lib/persistence/database", () => ({ InkfiniteDB: class {} })); - -vi.mock("$lib/persistence/repository", () => ({ - createDexieDocRepo: vi.fn(() => ({ - listBoards: vi.fn(async () => [{ id: "board:1", name: "Board 1", createdAt: 0, updatedAt: 0 }]), - createBoard: vi.fn(async () => "board:new"), - openBoard: vi.fn(async () => {}), - renameBoard: vi.fn(), - deleteBoard: vi.fn(), - loadDoc: vi.fn(async () => createDoc()), - applyDocPatch: vi.fn(), - exportBoard: vi.fn(async () => ({ - board: { id: "board:1", name: "", createdAt: 0, updatedAt: 0 }, - doc: createDoc(), - order: { pageIds: [], shapeOrder: {} }, - })), - importBoard: vi.fn(async () => "board:new"), - })), - createPersistenceSink: vi.fn(() => ({ - enqueueDocPatch: coreMocks.sinkEnqueueSpy, - flush: vi.fn(), - })), +vi.mock('$lib/persistence/database', () => ({ InkfiniteDB: class {} })); + +vi.mock('$lib/persistence/repository', () => ({ + createDexieDocRepo: vi.fn(() => ({ + listBoards: vi.fn(async () => [ + { id: 'board:1', name: 'Board 1', createdAt: 0, updatedAt: 0 } + ]), + createBoard: vi.fn(async () => 'board:new'), + openBoard: vi.fn(async () => {}), + renameBoard: vi.fn(), + deleteBoard: vi.fn(), + loadDoc: vi.fn(async () => createDoc()), + applyDocPatch: vi.fn(), + exportBoard: vi.fn(async () => ({ + board: { id: 'board:1', name: '', createdAt: 0, updatedAt: 0 }, + doc: createDoc(), + order: { pageIds: [], shapeOrder: {} } + })), + importBoard: vi.fn(async () => 'board:new') + })), + createPersistenceSink: vi.fn(() => ({ + enqueueDocPatch: testState.sinkEnqueueSpy, + flush: vi.fn() + })) })); - -vi.mock("@inkfinite/core", async () => { - const actual = await vi.importActual("@inkfinite/core"); - const { sinkEnqueueSpy, storeInstances } = coreMocks; - - class BaseTool { - constructor(readonly id: string) {} - onEnter(state: any) { - return state; - } - onExit(state: any) { - return state; - } - onAction(state: any) { - return state; - } - getHandleAtPoint() { - return null; - } - getActiveHandle() { - return null; - } - } - - class MockStore { - state: any; - private readonly options?: any; - private readonly subscribers: Array<(state: any) => void> = []; - readonly commands: any[] = []; - private historyState = { undoStack: [] as any[], redoStack: [] as any[] }; - - constructor(initialState?: any, options?: any) { - this.state = initialState - ?? { - doc: createDoc(), - ui: { currentPageId: null, selectionIds: [], toolId: "select" }, - camera: { x: 0, y: 0, zoom: 1 }, - }; - this.options = options; - storeInstances.push(this); - } - - getState() { - return this.state; - } - - setState(updater: (state: any) => any) { - this.state = updater(this.state); - for (const listener of this.subscribers) { - listener(this.state); - } - } - - subscribe(listener: (state: any) => void) { - this.subscribers.push(listener); - listener(this.state); - return () => {}; - } - - executeCommand(command: any) { - this.commands.push(command); - const before = this.state; - const after = command.do(before); - this.state = after; - this.historyState.undoStack.push({ command, timestamp: Date.now() }); - this.historyState.redoStack = []; - this.options?.onHistoryEvent?.({ - op: "do", - commandId: Date.now(), - command, - kind: command.kind, - beforeState: before, - afterState: after, - }); - } - - undo() { - const entry = this.historyState.undoStack.pop(); - if (!entry) return false; - this.historyState.redoStack.push(entry); - return true; - } - - redo() { - const entry = this.historyState.redoStack.pop(); - if (!entry) return false; - this.historyState.undoStack.push(entry); - return true; - } - - getHistory() { - return this.historyState; - } - - canUndo() { - return this.historyState.undoStack.length > 0; - } - - canRedo() { - return this.historyState.redoStack.length > 0; - } - } - - const routeAction = vi.fn((state: any, action: any) => { - if (action.type === "pointer-down") { - const shapeId = `shape:${Date.now()}`; - const currentPage = state.doc.pages["page:1"]; - return { - ...state, - doc: { - ...state.doc, - shapes: { - ...state.doc.shapes, - [shapeId]: { - id: shapeId, - type: "rect", - pageId: "page:1", - x: 0, - y: 0, - rot: 0, - props: { w: 10, h: 10, fill: "#000", stroke: "#000", radius: 0 }, - }, - }, - pages: { ...state.doc.pages, "page:1": { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] } }, - }, - }; - } - return state; - }); - - const EditorState = { - create: () => ({ - doc: createDoc(), - ui: { currentPageId: null, selectionIds: [], toolId: "select" }, - camera: { x: 0, y: 0, zoom: 1 }, - }), - clone: (state: any) => structuredClone(state), - }; - - class SnapshotCommand { - constructor( - readonly name: string, - readonly kind: string, - private readonly before: any, - private readonly after: any, - ) {} - do() { - return structuredClone(this.after); - } - undo() { - return structuredClone(this.before); - } - } - - return { - ...actual, - ArrowTool: class extends BaseTool { - constructor() { - super("arrow"); - } - }, - EllipseTool: class extends BaseTool { - constructor() { - super("ellipse"); - } - }, - LineTool: class extends BaseTool { - constructor() { - super("line"); - } - }, - RectTool: class extends BaseTool { - constructor() { - super("rect"); - } - }, - SelectTool: class extends BaseTool { - constructor() { - super("select"); - } - }, - TextTool: class extends BaseTool { - constructor() { - super("text"); - } - }, - Store: MockStore, - EditorState, - SnapshotCommand, - Camera: { - pan(camera: { x: number; y: number; zoom: number }, delta: { x: number; y: number }) { - return { ...camera, x: camera.x - delta.x, y: camera.y - delta.y }; - }, - }, - ShapeRecord: { clone: (shape: any) => ({ ...shape }) }, - createToolMap: (toolList: any[]) => new Map(toolList.map((tool) => [tool.id, tool])), - routeAction, - switchTool: (state: any, toolId: string) => ({ ...state, ui: { ...state.ui, toolId } }), - CursorStore: class { - updateCursor() {} - subscribe() { - return () => {}; - } - getState() { - return { cursorWorld: { x: 0, y: 0 }, lastMoveAt: Date.now() }; - } - }, - buildStatusBarVM: () => ({ - cursorWorld: { x: 0, y: 0 }, - toolId: "select", - mode: "idle", - selection: { count: 0 }, - snap: { enabled: false }, - persistence: { backend: "indexeddb", state: "saved" }, - }), - getSelectedShapes: () => [], - getShapesOnCurrentPage: () => [], - shapeBounds: () => ({ min: { x: 0, y: 0 }, max: { x: 0, y: 0 } }), - diffDoc: vi.fn(() => ({})), - exportToSVG: vi.fn(() => ""), - exportViewportToPNG: vi.fn(() => Promise.resolve(new Blob())), - exportSelectionToPNG: vi.fn(() => Promise.resolve(new Blob())), - __storeInstances: storeInstances, - __sinkEnqueueSpy: sinkEnqueueSpy, - }; -}); - -import * as InkfiniteCore from "@inkfinite/core"; -import Canvas from "$editor/canvas/Canvas.svelte"; -import { createTestPlatformAdapter } from "./test-platform"; -const { sinkEnqueueSpy, storeInstances } = coreMocks; - -describe("Canvas history integration", () => { - beforeEach(() => { - cleanup(); - actionHandlers.length = 0; - storeInstances.length = 0; - sinkEnqueueSpy.mockClear(); - }); - - it("wraps pointer actions in SnapshotCommands and enqueues persistence", async () => { - render(Canvas, { platform: createTestPlatformAdapter() }); - - await vi.waitFor(() => { - expect(actionHandlers.length).toBeGreaterThan(0); - }); - await vi.waitFor(() => { - expect(storeInstances.at(-1)?.getState().ui.currentPageId).toBe("page:1"); - }); - const handler = actionHandlers.at(-1); - expect(handler).toBeTypeOf("function"); - - handler?.({ - type: "pointer-down", - screen: { x: 0, y: 0 }, - world: { x: 0, y: 0 }, - button: 0, - buttons: { left: true, middle: false, right: false }, - modifiers: { ctrl: false, shift: false, alt: false, meta: false }, - timestamp: Date.now(), - }); - - handler?.({ - type: "pointer-move", - screen: { x: 10, y: 10 }, - world: { x: 10, y: 10 }, - buttons: { left: true, middle: false, right: false }, - modifiers: { ctrl: false, shift: false, alt: false, meta: false }, - timestamp: Date.now(), - }); - - handler?.({ - type: "pointer-up", - screen: { x: 10, y: 10 }, - world: { x: 10, y: 10 }, - button: 0, - buttons: { left: false, middle: false, right: false }, - modifiers: { ctrl: false, shift: false, alt: false, meta: false }, - timestamp: Date.now(), - }); - - const stores = (InkfiniteCore as any).__storeInstances as Array<{ commands: any[] }>; - expect(stores.at(-1)?.commands).toHaveLength(1); - expect(stores.at(-1)?.commands[0].kind).toBe("doc"); - expect(sinkEnqueueSpy).toHaveBeenCalledTimes(1); - }); +import Canvas from '$editor/canvas/Canvas.svelte'; +import { createTestPlatformAdapter } from './test-platform'; +const { sinkEnqueueSpy, storeInstances } = testState; + +describe('Canvas history integration', () => { + beforeEach(() => { + cleanup(); + actionHandlers.length = 0; + storeInstances.length = 0; + sinkEnqueueSpy.mockClear(); + }); + + it('wraps pointer actions in SnapshotCommands and enqueues persistence', async () => { + render(Canvas, { platform: createTestPlatformAdapter() }); + + await vi.waitFor(() => { + expect(actionHandlers.length).toBeGreaterThan(0); + }); + await vi.waitFor(() => { + expect(storeInstances.at(-1)?.getState().ui.currentPageId).toBe('page:1'); + }); + const handler = actionHandlers.at(-1); + expect(handler).toBeTypeOf('function'); + + handler?.({ + type: 'pointer-down', + screen: { x: 10, y: 10 }, + world: { x: 10, y: 10 }, + button: 0, + buttons: { left: true, middle: false, right: false }, + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, + timestamp: Date.now() + }); + + handler?.({ + type: 'pointer-move', + screen: { x: 20, y: 20 }, + world: { x: 20, y: 20 }, + buttons: { left: true, middle: false, right: false }, + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, + timestamp: Date.now() + }); + + handler?.({ + type: 'pointer-up', + screen: { x: 20, y: 20 }, + world: { x: 20, y: 20 }, + button: 0, + buttons: { left: false, middle: false, right: false }, + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, + timestamp: Date.now() + }); + + const history = storeInstances.at(-1)?.getHistory(); + expect(history?.undoStack).toHaveLength(1); + expect(history?.undoStack[0].command.kind).toBe('doc'); + expect(sinkEnqueueSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/web/src/lib/tests/Canvas.keyboard.test.ts b/apps/web/src/lib/tests/Canvas.keyboard.test.ts index 6ecd9ce..4d8f797 100644 --- a/apps/web/src/lib/tests/Canvas.keyboard.test.ts +++ b/apps/web/src/lib/tests/Canvas.keyboard.test.ts @@ -1,357 +1,347 @@ -import type { Action, Command, Store } from "@inkfinite/core"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render } from "vitest-browser-svelte"; +import { Store, type Action } from '@inkfinite/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; const actionHandlers: Array<(action: Action) => void> = []; -const coreMocks = vi.hoisted(() => ({ storeInstances: [] as Store[], executeCommandSpy: vi.fn() })); - -async function selectShapeAt(handler: (action: Action) => void, position: { x: number; y: number }) { - const timestamp = Date.now(); - handler({ - type: "pointer-down", - button: 0, - buttons: { left: true, middle: false, right: false }, - world: position, - screen: position, - modifiers: { ctrl: false, shift: false, alt: false, meta: false }, - timestamp, - }); - handler({ - type: "pointer-up", - button: 0, - buttons: { left: false, middle: false, right: false }, - world: position, - screen: position, - modifiers: { ctrl: false, shift: false, alt: false, meta: false }, - timestamp: timestamp + 16, - }); - await Promise.resolve(); +const coreMocks = vi.hoisted(() => ({ storeInstances: [] as Store[] })); + +async function selectShapeAt( + handler: (action: Action) => void, + position: { x: number; y: number } +) { + const timestamp = Date.now(); + handler({ + type: 'pointer-down', + button: 0, + buttons: { left: true, middle: false, right: false }, + world: position, + screen: position, + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, + timestamp + }); + handler({ + type: 'pointer-up', + button: 0, + buttons: { left: false, middle: false, right: false }, + world: position, + screen: position, + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, + timestamp: timestamp + 16 + }); + await Promise.resolve(); } async function selectDefaultShape(handler: (action: Action) => void) { - await selectShapeAt(handler, { x: 110, y: 110 }); + await selectShapeAt(handler, { x: 110, y: 110 }); } async function selectSecondaryShape(handler: (action: Action) => void) { - await selectShapeAt(handler, { x: 210, y: 210 }); + await selectShapeAt(handler, { x: 210, y: 210 }); } async function waitForDocumentReady() { - await vi.waitFor(() => { - const store = coreMocks.storeInstances.at(-1); - expect(store).toBeTruthy(); - const pages = Object.keys(store!.getState().doc.pages); - expect(pages.length).toBeGreaterThan(0); - }); + await vi.waitFor(() => { + const store = coreMocks.storeInstances.at(-1); + expect(store).toBeTruthy(); + const state = store!.getState(); + expect(state.ui.currentPageId).toBe('page:1'); + expect(state.doc.shapes['shape:1']).toBeTruthy(); + }); } -vi.mock("$editor/input", () => { - return { - createInputAdapter: vi.fn((config) => { - actionHandlers.push(config.onAction); - return { dispose: vi.fn() }; - }), - }; +vi.mock('$editor/input', () => { + return { + createInputAdapter: vi.fn((config) => { + actionHandlers.push(config.onAction); + return { dispose: vi.fn() }; + }) + }; }); -vi.mock( - "$editor/status", - () => ({ - createStatusStore: () => ({ - get: () => ({ backend: "indexeddb", state: "saved", pendingWrites: 0 }), - subscribe: () => () => {}, - update: () => {}, - }), - createSnapStore: () => ({ - get: () => ({ snapEnabled: false, gridEnabled: true, gridSize: 25 }), - subscribe: () => () => {}, - update: () => {}, - set: () => {}, - }), - createBrushStore: () => ({ - get: () => ({ - size: 16, - thinning: 0.5, - smoothing: 0.5, - streamline: 0.5, - simulatePressure: true, - color: "#88c0d0", - }), - subscribe: () => () => {}, - update: () => {}, - set: () => {}, - }), - }), -); - -vi.mock("@inkfinite/renderer", () => { - return { createRenderer: vi.fn(() => ({ dispose: vi.fn(), markDirty: vi.fn() })) }; -}); - -vi.mock("$lib/persistence/database", () => ({ InkfiniteDB: class {} })); - -vi.mock("$lib/persistence/repository", () => ({ - createDexieDocRepo: vi.fn(() => ({ - listBoards: async () => [{ id: "board-1", name: "Test Board", createdAt: 0, updatedAt: 0 }], - createBoard: async () => "board-1", - openBoard: async () => {}, - renameBoard: async () => {}, - deleteBoard: async () => {}, - loadDoc: async () => ({ - pages: { "page:1": { id: "page:1", name: "Page 1", shapeIds: ["shape:1", "shape:2"] } }, - shapes: { - "shape:1": { - id: "shape:1", - type: "rect", - pageId: "page:1", - x: 100, - y: 100, - rot: 0, - props: { w: 50, h: 50, fill: "#ff0000", stroke: "#000000", radius: 0 }, - }, - "shape:2": { - id: "shape:2", - type: "ellipse", - pageId: "page:1", - x: 200, - y: 200, - rot: 0, - props: { w: 40, h: 40, fill: "#00ff00", stroke: "#000000" }, - }, - }, - bindings: {}, - order: { pageIds: ["page:1"] }, - }), - applyDocPatch: async () => {}, - exportBoard: async () => ({ - board: { id: "board-1", name: "Test Board", createdAt: 0, updatedAt: 0 }, - doc: { pages: {}, shapes: {}, bindings: {} }, - order: { pageIds: [], shapeOrder: {} }, - }), - importBoard: async () => "board-1", - })), - createPersistenceSink: vi.fn(() => ({ enqueueDocPatch: vi.fn(), flush: vi.fn() })), +vi.mock('$editor/status', () => ({ + createStatusStore: () => ({ + get: () => ({ backend: 'indexeddb', state: 'saved', pendingWrites: 0 }), + subscribe: () => () => {}, + update: () => {} + }), + createSnapStore: () => ({ + get: () => ({ snapEnabled: false, gridEnabled: true, gridSize: 25 }), + subscribe: () => () => {}, + update: () => {}, + set: () => {} + }), + createBrushStore: () => ({ + get: () => ({ + size: 16, + thinning: 0.5, + smoothing: 0.5, + streamline: 0.5, + simulatePressure: true, + color: '#88c0d0' + }), + subscribe: () => () => {}, + update: () => {}, + set: () => {} + }) })); -vi.mock("@inkfinite/core", async () => { - const actual = await vi.importActual("@inkfinite/core"); - const { executeCommandSpy } = coreMocks; - - class MockStore extends actual.Store { - constructor(...args: ConstructorParameters) { - super(...args); - coreMocks.storeInstances.push(this as unknown as Store); - } - - executeCommand(command: unknown) { - executeCommandSpy(command); - return super.executeCommand(command as Command); - } - } - - return { - ...actual, - Store: MockStore, - }; +vi.mock('@inkfinite/renderer', () => { + return { + createRenderer: vi.fn((_canvas, store) => { + coreMocks.storeInstances.push(store); + return { dispose: vi.fn(), markDirty: vi.fn() }; + }) + }; }); -import Canvas from "$editor/canvas/Canvas.svelte"; -import { createTestPlatformAdapter } from "./test-platform"; +vi.mock('$lib/persistence/database', () => ({ InkfiniteDB: class {} })); + +vi.mock('$lib/persistence/repository', () => ({ + createDexieDocRepo: vi.fn(() => ({ + listBoards: async () => [ + { id: 'board-1', name: 'Test Board', createdAt: 0, updatedAt: 0 } + ], + createBoard: async () => 'board-1', + openBoard: async () => {}, + renameBoard: async () => {}, + deleteBoard: async () => {}, + loadDoc: async () => ({ + pages: { + 'page:1': { id: 'page:1', name: 'Page 1', shapeIds: ['shape:1', 'shape:2'] } + }, + shapes: { + 'shape:1': { + id: 'shape:1', + type: 'rect', + pageId: 'page:1', + x: 100, + y: 100, + rot: 0, + props: { w: 50, h: 50, fill: '#ff0000', stroke: '#000000', radius: 0 } + }, + 'shape:2': { + id: 'shape:2', + type: 'ellipse', + pageId: 'page:1', + x: 200, + y: 200, + rot: 0, + props: { w: 40, h: 40, fill: '#00ff00', stroke: '#000000' } + } + }, + bindings: {}, + order: { pageIds: ['page:1'] } + }), + applyDocPatch: async () => {}, + exportBoard: async () => ({ + board: { id: 'board-1', name: 'Test Board', createdAt: 0, updatedAt: 0 }, + doc: { pages: {}, shapes: {}, bindings: {} }, + order: { pageIds: [], shapeOrder: {} } + }), + importBoard: async () => 'board-1' + })), + createPersistenceSink: vi.fn(() => ({ enqueueDocPatch: vi.fn(), flush: vi.fn() })) +})); + +import Canvas from '$editor/canvas/Canvas.svelte'; +import { createTestPlatformAdapter } from './test-platform'; +const executeCommandSpy = vi.spyOn(Store.prototype, 'executeCommand'); const renderCanvas = () => render(Canvas, { platform: createTestPlatformAdapter() }); -describe("Canvas keyboard shortcuts", () => { - beforeEach(() => { - cleanup(); - actionHandlers.length = 0; - coreMocks.executeCommandSpy.mockClear(); - }); - - it("should handle space key for panning mode", async () => { - renderCanvas(); - await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); - coreMocks.executeCommandSpy.mockClear(); - - const handler = actionHandlers[0]; - - handler({ - type: "key-down", - key: " ", - code: "Space", - modifiers: { ctrl: false, shift: false, alt: false, meta: false }, - repeat: false, - timestamp: Date.now(), - }); - - expect(coreMocks.executeCommandSpy).not.toHaveBeenCalled(); - - handler({ - type: "key-up", - key: " ", - code: "Space", - modifiers: { ctrl: false, shift: false, alt: false, meta: false }, - timestamp: Date.now(), - }); - - expect(coreMocks.executeCommandSpy).not.toHaveBeenCalled(); - }); - - it("should nudge selected shapes with arrow keys", async () => { - renderCanvas(); - await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); - await waitForDocumentReady(); - - const handler = actionHandlers[0]; - await selectDefaultShape(handler); - - coreMocks.executeCommandSpy.mockClear(); - - handler({ - type: "key-down", - key: "ArrowRight", - code: "ArrowRight", - modifiers: { ctrl: false, shift: false, alt: false, meta: false }, - repeat: false, - timestamp: Date.now(), - }); - - await vi.waitFor(() => { - const calls = coreMocks.executeCommandSpy.mock.calls; - const nudgeCalls = calls.filter((call) => call[0]?.name === "Nudge"); - expect(nudgeCalls.length).toBeGreaterThan(0); - }); - }); - - it("should nudge by 10px with shift modifier", async () => { - renderCanvas(); - await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); - await waitForDocumentReady(); - - const handler = actionHandlers[0]; - await selectDefaultShape(handler); - coreMocks.executeCommandSpy.mockClear(); - - handler({ - type: "key-down", - key: "ArrowDown", - code: "ArrowDown", - modifiers: { ctrl: false, shift: true, alt: false, meta: false }, - repeat: false, - timestamp: Date.now(), - }); - - await vi.waitFor(() => { - const calls = coreMocks.executeCommandSpy.mock.calls; - const nudgeCalls = calls.filter((call) => call[0]?.name === "Nudge"); - expect(nudgeCalls.length).toBeGreaterThan(0); - }); - }); - - it("should duplicate selected shapes with Cmd/Ctrl+D", async () => { - renderCanvas(); - await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); - await waitForDocumentReady(); - - const handler = actionHandlers[0]; - await selectDefaultShape(handler); - coreMocks.executeCommandSpy.mockClear(); - - const isMac = navigator.userAgent.toUpperCase().includes("MAC"); - handler({ - type: "key-down", - key: "d", - code: "KeyD", - modifiers: { ctrl: !isMac, shift: false, alt: false, meta: isMac }, - repeat: false, - timestamp: Date.now(), - }); - - await vi.waitFor(() => { - const calls = coreMocks.executeCommandSpy.mock.calls; - const duplicateCalls = calls.filter((call) => call[0]?.name === "Duplicate"); - expect(duplicateCalls.length).toBeGreaterThan(0); - }); - }); - - it("should bring shapes forward with Cmd/Ctrl+]", async () => { - renderCanvas(); - await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); - await waitForDocumentReady(); - - const handler = actionHandlers[0]; - await selectDefaultShape(handler); - coreMocks.executeCommandSpy.mockClear(); - - const isMac = navigator.userAgent.toUpperCase().includes("MAC"); - handler({ - type: "key-down", - key: "]", - code: "BracketRight", - modifiers: { ctrl: !isMac, shift: false, alt: false, meta: isMac }, - repeat: false, - timestamp: Date.now(), - }); - - await vi.waitFor(() => { - const calls = coreMocks.executeCommandSpy.mock.calls; - const bringForwardCalls = calls.filter((call) => call[0]?.name === "Bring Forward"); - expect(bringForwardCalls.length).toBeGreaterThan(0); - }); - }); - - it("should send shapes backward with Cmd/Ctrl+[", async () => { - renderCanvas(); - await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); - await waitForDocumentReady(); - - const handler = actionHandlers[0]; - await selectSecondaryShape(handler); - coreMocks.executeCommandSpy.mockClear(); - - const isMac = navigator.userAgent.toUpperCase().includes("MAC"); - handler({ - type: "key-down", - key: "[", - code: "BracketLeft", - modifiers: { ctrl: !isMac, shift: false, alt: false, meta: isMac }, - repeat: false, - timestamp: Date.now(), - }); - - await vi.waitFor(() => { - const calls = coreMocks.executeCommandSpy.mock.calls; - const sendBackwardCalls = calls.filter((call) => call[0]?.name === "Send Backward"); - expect(sendBackwardCalls.length).toBeGreaterThan(0); - }); - }); - - it("should not process tool actions while space is held", async () => { - renderCanvas(); - await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); - await waitForDocumentReady(); - - const handler = actionHandlers[0]; - await selectDefaultShape(handler); - coreMocks.executeCommandSpy.mockClear(); - - handler({ - type: "key-down", - key: " ", - code: "Space", - modifiers: { ctrl: false, shift: false, alt: false, meta: false }, - repeat: false, - timestamp: Date.now(), - }); - - handler({ - type: "key-down", - key: "ArrowRight", - code: "ArrowRight", - modifiers: { ctrl: false, shift: false, alt: false, meta: false }, - repeat: false, - timestamp: Date.now(), - }); - - expect(coreMocks.executeCommandSpy).not.toHaveBeenCalled(); - }); +describe('Canvas keyboard shortcuts', () => { + beforeEach(() => { + cleanup(); + actionHandlers.length = 0; + coreMocks.storeInstances.length = 0; + executeCommandSpy.mockClear(); + }); + + it('should handle space key for panning mode', async () => { + renderCanvas(); + await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); + executeCommandSpy.mockClear(); + + const handler = actionHandlers[0]; + + handler({ + type: 'key-down', + key: ' ', + code: 'Space', + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, + repeat: false, + timestamp: Date.now() + }); + + expect(executeCommandSpy).not.toHaveBeenCalled(); + + handler({ + type: 'key-up', + key: ' ', + code: 'Space', + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, + timestamp: Date.now() + }); + + expect(executeCommandSpy).not.toHaveBeenCalled(); + }); + + it('should nudge selected shapes with arrow keys', async () => { + renderCanvas(); + await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); + await waitForDocumentReady(); + + const handler = actionHandlers[0]; + await selectDefaultShape(handler); + + executeCommandSpy.mockClear(); + + handler({ + type: 'key-down', + key: 'ArrowRight', + code: 'ArrowRight', + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, + repeat: false, + timestamp: Date.now() + }); + + await vi.waitFor(() => { + const calls = executeCommandSpy.mock.calls; + const nudgeCalls = calls.filter((call) => call[0]?.name === 'Nudge'); + expect(nudgeCalls.length).toBeGreaterThan(0); + }); + }); + + it('should nudge by 10px with shift modifier', async () => { + renderCanvas(); + await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); + await waitForDocumentReady(); + + const handler = actionHandlers[0]; + await selectDefaultShape(handler); + executeCommandSpy.mockClear(); + + handler({ + type: 'key-down', + key: 'ArrowDown', + code: 'ArrowDown', + modifiers: { ctrl: false, shift: true, alt: false, meta: false }, + repeat: false, + timestamp: Date.now() + }); + + await vi.waitFor(() => { + const calls = executeCommandSpy.mock.calls; + const nudgeCalls = calls.filter((call) => call[0]?.name === 'Nudge'); + expect(nudgeCalls.length).toBeGreaterThan(0); + }); + }); + + it('should duplicate selected shapes with Cmd/Ctrl+D', async () => { + renderCanvas(); + await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); + await waitForDocumentReady(); + + const handler = actionHandlers[0]; + await selectDefaultShape(handler); + executeCommandSpy.mockClear(); + + const isMac = navigator.userAgent.toUpperCase().includes('MAC'); + handler({ + type: 'key-down', + key: 'd', + code: 'KeyD', + modifiers: { ctrl: !isMac, shift: false, alt: false, meta: isMac }, + repeat: false, + timestamp: Date.now() + }); + + await vi.waitFor(() => { + const calls = executeCommandSpy.mock.calls; + const duplicateCalls = calls.filter((call) => call[0]?.name === 'Duplicate'); + expect(duplicateCalls.length).toBeGreaterThan(0); + }); + }); + + it('should bring shapes forward with Cmd/Ctrl+]', async () => { + renderCanvas(); + await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); + await waitForDocumentReady(); + + const handler = actionHandlers[0]; + await selectDefaultShape(handler); + executeCommandSpy.mockClear(); + + const isMac = navigator.userAgent.toUpperCase().includes('MAC'); + handler({ + type: 'key-down', + key: ']', + code: 'BracketRight', + modifiers: { ctrl: !isMac, shift: false, alt: false, meta: isMac }, + repeat: false, + timestamp: Date.now() + }); + + await vi.waitFor(() => { + const calls = executeCommandSpy.mock.calls; + const bringForwardCalls = calls.filter((call) => call[0]?.name === 'Bring Forward'); + expect(bringForwardCalls.length).toBeGreaterThan(0); + }); + }); + + it('should send shapes backward with Cmd/Ctrl+[', async () => { + renderCanvas(); + await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); + await waitForDocumentReady(); + + const handler = actionHandlers[0]; + await selectSecondaryShape(handler); + executeCommandSpy.mockClear(); + + const isMac = navigator.userAgent.toUpperCase().includes('MAC'); + handler({ + type: 'key-down', + key: '[', + code: 'BracketLeft', + modifiers: { ctrl: !isMac, shift: false, alt: false, meta: isMac }, + repeat: false, + timestamp: Date.now() + }); + + await vi.waitFor(() => { + const calls = executeCommandSpy.mock.calls; + const sendBackwardCalls = calls.filter((call) => call[0]?.name === 'Send Backward'); + expect(sendBackwardCalls.length).toBeGreaterThan(0); + }); + }); + + it('should not process tool actions while space is held', async () => { + renderCanvas(); + await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); + await waitForDocumentReady(); + + const handler = actionHandlers[0]; + await selectDefaultShape(handler); + executeCommandSpy.mockClear(); + + handler({ + type: 'key-down', + key: ' ', + code: 'Space', + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, + repeat: false, + timestamp: Date.now() + }); + + handler({ + type: 'key-down', + key: 'ArrowRight', + code: 'ArrowRight', + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, + repeat: false, + timestamp: Date.now() + }); + + expect(executeCommandSpy).not.toHaveBeenCalled(); + }); }); -- 2.51.2