/** * A drag writes the model on every pointer move but tells the editor once per frame (#213): the * model event rebuilds panels and pictures, and a mouse reports faster than the screen draws. */ import type { Application } from 'pixi.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { GraphAsset } from '../../src/core/model/Asset'; import { FramePreview } from '../../src/editor/framePreview'; import { GraphDrags } from '../../src/editor/graph/graphDrags'; import type { GraphCanvasHost } from '../../src/editor/graph/graphHost'; import type { EditorState } from '../../src/editor/state'; import { initRenderLoop, resetRenderLoop } from '../../src/ui/render'; /** One frame of the render loop, the way the ticker runs it. */ function frames(): () => void { const added: Array<() => void> = []; const app = { render(): void {}, ticker: { add: (fn: () => void) => added.push(fn), remove: () => undefined, }, }; initRenderLoop(app as unknown as Application); return () => added.forEach((fn) => fn()); } function fakeState() { return { selectedAnnotationIds: [] as string[], selectedNodeIds: ['n1'], preview: vi.fn(), execute: vi.fn(() => true), }; } describe('FramePreview', () => { beforeEach(() => resetRenderLoop()); it('turns any number of requests in one frame into a single preview', () => { const tick = frames(); const state = fakeState(); const preview = new FramePreview(state, 'drag'); for (let i = 0; i < 5; i++) preview.request(); expect(state.preview).not.toHaveBeenCalled(); tick(); expect(state.preview).toHaveBeenCalledTimes(1); tick(); expect(state.preview).toHaveBeenCalledTimes(1); }); it('drops the waiting preview on cancel', () => { const tick = frames(); const state = fakeState(); const preview = new FramePreview(state, 'drag'); preview.request(); preview.cancel(); tick(); expect(state.preview).not.toHaveBeenCalled(); }); it('keeps two gestures of the same name apart', () => { const tick = frames(); const a = fakeState(); const b = fakeState(); new FramePreview(a, 'drag').request(); new FramePreview(b, 'drag').request(); tick(); expect(a.preview).toHaveBeenCalledTimes(1); expect(b.preview).toHaveBeenCalledTimes(1); }); }); describe('dragging a node in the graph', () => { beforeEach(() => resetRenderLoop()); function rig() { const state = fakeState(); const asset = { id: 'b', graph: { nodes: [{ id: 'n1', type: 'Add', x: 0, y: 0, params: {} }], edges: [] }, } as unknown as GraphAsset; const host = { state: state as unknown as EditorState, assetId: () => 'b', asset: () => asset, nodeBoxes: () => [{ id: 'n1', x: 0, y: 0, w: 100, h: 40 }], toGraph: (p: { x: number; y: number }) => ({ x: p.x, y: p.y }), zoom: () => 1, snapping: () => false, } as unknown as GraphCanvasHost; return { state, node: asset.graph.nodes[0]!, drags: new GraphDrags(host) }; } it('moves the node with every pointer move and previews once a frame', () => { const tick = frames(); const { state, node, drags } = rig(); drags.grabNodes({ x: 10, y: 10 }); for (let i = 1; i <= 5; i++) { drags.move({ x: 10 + i * 10, y: 10 }); expect(node.x).toBe(i * 10); } expect(state.preview).not.toHaveBeenCalled(); tick(); expect(state.preview).toHaveBeenCalledTimes(1); }); it('leaves nothing for the frame after the release: the command has the last word', () => { const tick = frames(); const { state, node, drags } = rig(); drags.grabNodes({ x: 10, y: 10 }); drags.move({ x: 50, y: 30 }); drags.releaseNodes(); expect(state.execute).toHaveBeenCalledTimes(1); expect(node.x).toBe(0); tick(); expect(state.preview).not.toHaveBeenCalled(); }); });