From af71a49b09ea1621415496a21bc7a4bd18f42b00 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 3 Jul 2026 09:50:03 +0200 Subject: [PATCH] test(commands): cover viewport command flow --- commands/dispatcher.test.ts | 18 ++++++++++ commands/viewport.test.ts | 44 ++++++++++++++++++++++++ input/viewport.test.ts | 68 +++++++++++++++++++++++++++++++++++++ package.json | 3 +- 4 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 commands/dispatcher.test.ts create mode 100644 commands/viewport.test.ts create mode 100644 input/viewport.test.ts diff --git a/commands/dispatcher.test.ts b/commands/dispatcher.test.ts new file mode 100644 index 0000000..efdd166 --- /dev/null +++ b/commands/dispatcher.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import { createInitialAppState } from "@editor/initial-state"; +import { createAppStore } from "@editor/store"; +import { createCommandRegistry } from "./registry"; +import { viewportPanCommand } from "./viewport"; + +describe("command dispatcher", () => { + test("throws for unknown commands", () => { + const store = createAppStore(createInitialAppState("Test"), createCommandRegistry([])); + expect(() => store.dispatch("missing", undefined)).toThrow("Unknown command: missing"); + }); + + test("dispatch applies command result to store", () => { + const store = createAppStore(createInitialAppState("Test"), createCommandRegistry([viewportPanCommand])); + store.dispatch("viewport.pan", { delta: { x: 3, y: 7 } }); + expect(store.getState().editor.viewport.center).toEqual({ x: 3, y: 7 }); + }); +}); diff --git a/commands/viewport.test.ts b/commands/viewport.test.ts new file mode 100644 index 0000000..fdca045 --- /dev/null +++ b/commands/viewport.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { createInitialAppState } from "@editor/initial-state"; +import { + viewportPanCommand, + viewportResetCommand, + viewportSetSizeCommand, + viewportSetZoomCommand, + viewportZoomAroundPointCommand, +} from "./viewport"; + +const context = () => ({ state: createInitialAppState("Test") }); + +describe("viewport commands", () => { + test("pans viewport center", () => { + const next = viewportPanCommand.execute(context(), { delta: { x: 10, y: -5 } }); + expect(next.editor.viewport.center).toEqual({ x: 10, y: -5 }); + }); + + test("sets zoom with minimum clamp", () => { + const next = viewportSetZoomCommand.execute(context(), { zoom: -1 }); + expect(next.editor.viewport.zoom).toBe(0.01); + }); + + test("sets non-negative viewport size", () => { + const next = viewportSetSizeCommand.execute(context(), { w: 800, h: -1 }); + expect(next.editor.viewport.size).toEqual({ w: 800, h: 0 }); + }); + + test("zooms around a viewport point", () => { + const state = viewportSetSizeCommand.execute(context(), { w: 100, h: 100 }); + const next = viewportZoomAroundPointCommand.execute({ state }, { zoom: 2, point: { x: 100, y: 50 } }); + + expect(next.editor.viewport.zoom).toBe(2); + expect(next.editor.viewport.center).toEqual({ x: 25, y: 0 }); + }); + + test("resets viewport but preserves size", () => { + const sized = viewportSetSizeCommand.execute(context(), { w: 640, h: 480 }); + const panned = viewportPanCommand.execute({ state: sized }, { delta: { x: 4, y: 8 } }); + const next = viewportResetCommand.execute({ state: panned }, undefined); + + expect(next.editor.viewport).toEqual({ center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 640, h: 480 } }); + }); +}); diff --git a/input/viewport.test.ts b/input/viewport.test.ts new file mode 100644 index 0000000..760cdd6 --- /dev/null +++ b/input/viewport.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { createViewportPointerPanHandler, handleViewportWheel } from "./viewport"; + +const ignoredState = undefined as never; + +describe("viewport input", () => { + test("wheel dispatches zoom around point", () => { + const dispatched: unknown[] = []; + const consumed = handleViewportWheel({ + event: { position: { x: 10, y: 20 }, delta: { x: 0, y: -100 }, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false }, + globalConsumer: () => false, + currentZoom: 1, + dispatch: (commandId, payload) => { + dispatched.push({ commandId, payload }); + return ignoredState; + }, + }); + + expect(consumed).toBe(true); + expect(dispatched[0]).toEqual({ commandId: "viewport.zoomAroundPoint", payload: { zoom: Math.exp(0.1), point: { x: 10, y: 20 } } }); + }); + + test("global wheel consumer prevents command dispatch", () => { + let called = false; + const consumed = handleViewportWheel({ + event: { position: { x: 0, y: 0 }, delta: { x: 0, y: 1 }, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false }, + globalConsumer: () => true, + currentZoom: 1, + dispatch: () => { + called = true; + return ignoredState; + }, + }); + + expect(consumed).toBe(true); + expect(called).toBe(false); + }); + + test("middle mouse drag dispatches viewport pan", () => { + const dispatched: unknown[] = []; + const pan = createViewportPointerPanHandler({ + globalConsumer: () => false, + getCurrentZoom: () => 2, + dispatch: (commandId, payload) => { + dispatched.push({ commandId, payload }); + return ignoredState; + }, + }); + + expect(pan.pointerDown(basePointer({ buttons: 4, position: { x: 10, y: 10 } }))).toBe(true); + expect(pan.pointerMove(basePointer({ buttons: 4, position: { x: 14, y: 6 } }))).toBe(true); + expect(dispatched[0]).toEqual({ commandId: "viewport.pan", payload: { delta: { x: -2, y: 2 } } }); + }); +}); + +function basePointer(overrides: Partial["pointerDown"]>[0]> = {}) { + return { + pointerId: 1, + pointerType: "mouse" as const, + position: { x: 0, y: 0 }, + buttons: 0, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + ...overrides, + }; +} diff --git a/package.json b/package.json index b7835b1..73f2d58 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "bun --hot index.ts", "start": "NODE_ENV=production bun index.ts", "build": "bun run build.ts", - "lint": "eslint ." + "lint": "eslint .", + "test": "bun test" }, "dependencies": { "@radix-ui/react-slot": "^1.2.3",