import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; import ts from 'typescript'; // Execute the production controller; only the browser event/capture boundary is doubled. const source = await readFile(new URL('../src/input/PointerController.ts', import.meta.url), 'utf8'); const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ES2020 }, }).outputText; const { PointerController } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`); class SceneElement { listeners = new Map(); captured = new Set(); classes = new Set(); classList = { add: name => this.classes.add(name), remove: name => this.classes.delete(name) }; captureFails = false; releaseFails = false; addEventListener(type, listener, options) { const listeners = this.listeners.get(type) ?? []; listeners.push({ listener, options }); this.listeners.set(type, listeners); } removeEventListener(type, listener) { this.listeners.set(type, (this.listeners.get(type) ?? []).filter(entry => entry.listener !== listener)); } rect = { left: 30, top: 20, width: 200, height: 100 }; getBoundingClientRect() { return this.rect; } setPointerCapture(id) { if (this.captureFails) throw new Error('Pointer no longer active'); this.captured.add(id); } hasPointerCapture(id) { return this.captured.has(id); } releasePointerCapture(id) { this.captured.delete(id); if (this.releaseFails) throw new Error('Pointer already released by the browser'); this.dispatch('lostpointercapture', { pointerId: id }); } dispatch(type, init = {}) { const event = { type, pointerId: 1, pointerType: 'touch', clientX: 0, clientY: 0, button: 0, deltaY: 0, deltaMode: 0, ctrlKey: false, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; }, ...init, }; for (const { listener } of [...(this.listeners.get(type) ?? [])]) listener(event); return event; } } function setup(initialZoom = 1) { const element = new SceneElement(); const state = { zoom: initialZoom, enabled: true, interactions: 0, zooms: [], anchors: [], views: [] }; const controller = new PointerController( element, (x, y) => state.views.push([x, y]), () => { state.interactions++; }, { getZoom: () => state.zoom, setZoom: (zoom, from, to) => { state.zoom = zoom; state.zooms.push(zoom); state.anchors.push({from, to}); }, isEnabled: () => state.enabled, }, ); return { element, state, controller }; } function startPinch(element, distance = 100) { element.dispatch('pointerdown', { pointerId: 1, clientX: 0 }); element.dispatch('pointerdown', { pointerId: 2, clientX: distance }); } test('legacy three-argument constructor keeps normalized one-pointer view drag', () => { const element = new SceneElement(), views = []; let interactions = 0; const controller = new PointerController(element, (x, y) => views.push([x, y]), () => interactions++); element.dispatch('pointerdown', { clientX: 40, clientY: 20 }); element.dispatch('pointermove', { clientX: 60, clientY: 30 }); element.dispatch('pointermove', { clientX: 80, clientY: 40 }); assert.deepEqual(views, [[0.1, -0.1], [0.1, -0.1]]); assert.equal(interactions, 1); assert.equal(element.classes.has('is-dragging'), true); element.dispatch('pointerup'); assert.equal(element.classes.has('is-dragging'), false); assert.equal(element.captured.size, 0); controller.dispose(); }); test('wheel up enlarges the shared zoom and wheel down reduces it within 1–3', () => { const { element, state } = setup(1.5); assert.equal(element.dispatch('wheel', { deltaY: -100 }).defaultPrevented, true); assert.ok(state.zoom > 1.5, 'wheel up increases shared zoom'); element.dispatch('wheel', { deltaY: 100 }); assert.ok(Math.abs(state.zoom - 1.5) < 1e-12); element.dispatch('wheel', { deltaY: -100000 }); assert.equal(state.zoom, 3); element.dispatch('wheel', { deltaY: 100000 }); assert.equal(state.zoom, 1); assert.deepEqual(state.views, [], 'wheel must not move the camera'); }); test('wheel reads current shared zoom after a slider changes it', () => { const { element, state } = setup(); state.zoom = 2.5; element.dispatch('wheel', { deltaY: -1 }); assert.ok(state.zoom > 2.5 && state.zoom < 3); }); test('ctrl-wheel stays available for browser accessibility zoom', () => { const { element, state } = setup(2); const event = element.dispatch('wheel', { deltaY: -100, ctrlKey: true }); assert.equal(state.zoom, 2); assert.equal(state.interactions, 0); assert.equal(event.defaultPrevented, false); }); test('scene wheel is non-passive, normalizes line/page units, and does not bind the drawer', () => { const pixel = setup(1.5), line = setup(1.5), page = setup(1.5); assert.equal(pixel.element.listeners.get('wheel')?.[0]?.options?.passive, false); pixel.element.dispatch('wheel', { deltaY: -16 }); line.element.dispatch('wheel', { deltaY: -1, deltaMode: 1 }); assert.equal(pixel.state.zoom, line.state.zoom); pixel.state.zoom = 1.5; pixel.element.dispatch('wheel', { deltaY: -100 }); page.element.dispatch('wheel', { deltaY: -1, deltaMode: 2 }); assert.equal(pixel.state.zoom, page.state.zoom); const drawer = new SceneElement(); assert.equal(drawer.dispatch('wheel', { deltaY: 100 }).defaultPrevented, false); assert.equal(drawer.listeners.size, 0); }); test('two touch pointers pinch shared zoom proportionally without view drag', () => { const { element, state } = setup(); startPinch(element); element.dispatch('pointermove', { pointerId: 2, clientX: 200 }); assert.equal(state.zoom, 2); element.dispatch('pointermove', { pointerId: 2, clientX: 150 }); assert.equal(state.zoom, 1.5); assert.deepEqual(state.views, []); assert.equal(state.interactions, 1, 'pinching also dismisses the gesture hint'); }); test('pinch limits have no dead zone when reversing direction', () => { const { element, state } = setup(2); startPinch(element); element.dispatch('pointermove', { pointerId: 2, clientX: 500 }); assert.equal(state.zoom, 3); element.dispatch('pointermove', { pointerId: 2, clientX: 400 }); assert.ok(Math.abs(state.zoom - 2.4) < 1e-12); element.dispatch('pointermove', { pointerId: 2, clientX: 1 }); assert.equal(state.zoom, 1); element.dispatch('pointermove', { pointerId: 2, clientX: 2 }); assert.equal(state.zoom, 2); }); test('two-to-one transition resumes drag from the remaining finger with no jump', () => { const { element, state } = setup(); startPinch(element); element.dispatch('pointermove', { pointerId: 1, clientX: -50, clientY: 20 }); element.dispatch('pointerup', { pointerId: 2, clientX: 100 }); const zoom = state.zoom; element.dispatch('pointermove', { pointerId: 1, clientX: -50, clientY: 20 }); assert.deepEqual(state.views, []); element.dispatch('pointermove', { pointerId: 1, clientX: -30, clientY: 30 }); assert.deepEqual(state.views, [[0.1, -0.1]]); assert.equal(state.zoom, zoom); }); test('pointercancel rebases the surviving pointer and ignores canceled moves', () => { const { element, state } = setup(); startPinch(element); element.dispatch('pointermove', { pointerId: 2, clientX: 150, clientY: 20 }); element.dispatch('pointercancel', { pointerId: 1 }); element.dispatch('pointermove', { pointerId: 1, clientX: 500 }); element.dispatch('pointermove', { pointerId: 2, clientX: 150, clientY: 20 }); assert.deepEqual(state.views, []); element.dispatch('pointermove', { pointerId: 2, clientX: 170, clientY: 30 }); assert.deepEqual(state.views, [[0.1, -0.1]]); element.dispatch('pointercancel', { pointerId: 2 }); assert.equal(element.captured.size, 0); assert.equal(element.classes.has('is-dragging'), false); }); test('zero-distance pinch rebases before zooming and stays finite', () => { const { element, state } = setup(1.5); startPinch(element, 0); element.dispatch('pointermove', { pointerId: 2, clientX: 10 }); assert.equal(state.zoom, 1.5); element.dispatch('pointermove', { pointerId: 2, clientX: 20 }); assert.equal(state.zoom, 3); assert.deepEqual(state.views, []); }); test('a third touch does not replace the active pinch pair', () => { const { element, state } = setup(); startPinch(element); element.dispatch('pointerdown', { pointerId: 3, clientX: 500 }); element.dispatch('pointermove', { pointerId: 3, clientX: 1000 }); assert.equal(state.zoom, 1); assert.deepEqual(state.views, []); element.dispatch('pointermove', { pointerId: 2, clientX: 200 }); assert.equal(state.zoom, 2); assert.deepEqual([...element.captured], [1, 2]); }); test('disabled scene input neither captures pointers nor blocks overlay scrolling', () => { const { element, state } = setup(2); state.enabled = false; element.dispatch('pointerdown'); element.dispatch('pointermove', { clientX: 100 }); assert.equal(element.dispatch('wheel', { deltaY: -100 }).defaultPrevented, false); assert.equal(state.zoom, 2); assert.deepEqual(state.views, []); assert.equal(element.captured.size, 0); }); test('disabling during a gesture clears stale pointers before re-enabling', () => { const { element, state } = setup(); startPinch(element); state.enabled = false; element.dispatch('pointermove', { pointerId: 2, clientX: 200 }); assert.equal(element.captured.size, 0); state.enabled = true; element.dispatch('pointermove', { pointerId: 2, clientX: 400 }); assert.equal(state.zoom, 1); assert.deepEqual(state.views, []); }); test('cancelGesture lets overlay opening end input immediately', () => { const { element, state, controller } = setup(); startPinch(element); element.dispatch('pointermove', { pointerId: 2, clientX: 150 }); assert.equal(typeof controller.cancelGesture, 'function'); controller.cancelGesture(); assert.equal(element.captured.size, 0); assert.equal(element.classes.has('is-dragging'), false); element.dispatch('pointermove', { pointerId: 2, clientX: 200 }); assert.equal(state.zoom, 1.5); assert.deepEqual(state.views, []); }); test('unexpected lost pointer capture cannot keep an old drag alive', () => { const { element, state } = setup(); element.dispatch('pointerdown'); element.captured.delete(1); element.dispatch('lostpointercapture'); element.dispatch('pointermove', { clientX: 100 }); assert.deepEqual(state.views, []); }); test('capture races are harmless and disposal removes listeners and both captures', () => { const { element, state, controller } = setup(); element.captureFails = true; assert.doesNotThrow(() => element.dispatch('pointerdown')); element.dispatch('pointerup'); element.captureFails = false; startPinch(element); element.dispatch('pointermove', { pointerId: 2, clientX: 150 }); element.releaseFails = true; assert.doesNotThrow(() => controller.dispose()); assert.equal(element.captured.size, 0); assert.equal(element.classes.has('is-dragging'), false); assert.equal([...element.listeners.values()].flat().length, 0); element.dispatch('wheel', { deltaY: -100 }); element.dispatch('pointermove', { pointerId: 2, clientX: 200 }); assert.equal(state.zoom, 1.5); }); test('wheel supplies cursor NDC relative to the actual canvas rectangle', () => { const { element, state } = setup(); element.dispatch('wheel', { deltaY: -100, clientX: 180, clientY: 45 }); assert.deepEqual(state.anchors.at(-1), { from: { x: .5, y: .5 }, to: { x: .5, y: .5 } }); }); test('pinch supplies both midpoints and tracks translation even at maximum zoom', () => { const { element, state } = setup(3); element.dispatch('pointerdown', { pointerId: 1, clientX: 80, clientY: 70 }); element.dispatch('pointerdown', { pointerId: 2, clientX: 180, clientY: 70 }); element.dispatch('pointermove', { pointerId: 2, clientX: 220, clientY: 70 }); assert.equal(state.zoom, 3); assert.deepEqual(state.anchors.at(-1), { from: { x: 0, y: 0 }, to: { x: .19999999999999996, y: 0 } }); assert.deepEqual(state.views, []); }); test('zero-size canvas cannot consume wheel or corrupt zoom', () => { const { element, state } = setup(); element.rect.width = 0; assert.equal(element.dispatch('wheel', { deltaY: -100 }).defaultPrevented, false); assert.equal(state.zoom, 1); assert.deepEqual(state.anchors, []); }); test('constant-distance pinch still passes midpoint translation', () => { const { element, state } = setup(2); element.dispatch('pointerdown', { pointerId: 1, clientX: 80, clientY: 70 }); element.dispatch('pointerdown', { pointerId: 2, clientX: 180, clientY: 70 }); element.dispatch('pointermove', { pointerId: 2, clientX: 80, clientY: 170 }); assert.equal(state.zoom, 2); assert.deepEqual(state.anchors.at(-1), { from: { x: 0, y: 0 }, to: { x: -.5, y: -1 } }); assert.equal(state.interactions, 1); }); test('either survivor resumes without a jump and a replacement second finger rebases', () => { for (const survivor of [1, 2]) for (const end of ['pointerup', 'pointercancel', 'lostpointercapture']) { const { element, state } = setup(2); element.dispatch('pointerdown', { pointerId: 1, clientX: 80, clientY: 70 }); element.dispatch('pointerdown', { pointerId: 2, clientX: 180, clientY: 70 }); element.dispatch(end, { pointerId: survivor === 1 ? 2 : 1 }); const x = survivor === 1 ? 80 : 180; element.dispatch('pointermove', { pointerId: survivor, clientX: x, clientY: 70 }); assert.deepEqual(state.views, []); assert.deepEqual(state.zooms, []); element.dispatch('pointermove', { pointerId: survivor, clientX: x + 20, clientY: 80 }); assert.deepEqual(state.views, [[.1, -.1]]); element.dispatch('pointerdown', { pointerId: 3, clientX: x + 120, clientY: 80 }); element.dispatch('pointermove', { pointerId: 3, clientX: x + 170, clientY: 80 }); assert.equal(state.zoom, 3); assert.equal(state.zooms.length, 1); assert.deepEqual(state.views, [[.1, -.1]]); } }); test('gesture state distinguishes pinch, surviving touch, cancel and capture failure', () => { const element = new SceneElement(), states = []; const controller = new PointerController(element, () => {}, undefined, { getZoom: () => 1, setZoom: () => {}, onGestureChange: (active, pinching) => states.push([active, pinching]), }); assert.equal(controller.isInteracting, false); startPinch(element); assert.equal(controller.isInteracting, true); assert.deepEqual(states, [[true, false], [true, true]]); element.dispatch('pointerup', { pointerId: 2 }); assert.deepEqual(states.at(-1), [true, false]); controller.cancelGesture(); assert.equal(controller.isInteracting, false); assert.deepEqual(states.at(-1), [false, false]); element.captureFails = true; element.dispatch('pointerdown', { pointerId: 5 }); assert.equal(controller.isInteracting, false); assert.deepEqual(states.at(-1), [false, false]); controller.dispose(); }); test('comparison panes use local wheel anchors and reject cross-pane pinch starts', () => { const element=new SceneElement(),anchors=[],views=[]; let zoom=1; const bounds={left:300,top:50,width:200,height:100}; const controller=new PointerController(element,(x,y)=>views.push([x,y]),undefined,{ getZoom:()=>zoom,setZoom:(next,from,to)=>{zoom=next;anchors.push({from,to});}, getBounds:()=>bounds, }); element.dispatch('wheel',{clientX:350,clientY:75,deltaY:-10}); assert.deepEqual(anchors[0].from,{x:-.5,y:.5}); element.dispatch('pointerdown',{pointerId:1,clientX:350,clientY:75}); element.dispatch('pointerdown',{pointerId:2,clientX:100,clientY:75}); assert.equal(element.captured.size,1,'touches in different panes must not pinch together'); element.dispatch('pointermove',{pointerId:1,clientX:370,clientY:85}); assert.deepEqual(views,[[.1,-.1]]); controller.dispose(); }); test('handle drag owns one pointer, retains partial release and yields to a touch pinch', () => { const element=new SceneElement(),moves=[],ends=[],views=[],zooms=[]; const controller=new PointerController(element,(...v)=>views.push(v),undefined,{ getZoom:()=>1,setZoom:(z)=>zooms.push(z), subjectDrag:{begin:p=>p.x===40,move:p=>moves.push(p.x),end:cancelled=>ends.push(cancelled)}, }); element.dispatch('pointerdown',{clientX:40,clientY:40}); element.dispatch('pointermove',{clientX:60,clientY:40}); assert.deepEqual(moves,[60]);assert.deepEqual(views,[]); element.dispatch('pointerup');assert.deepEqual(ends,[false]); element.dispatch('pointerdown',{pointerId:2,clientX:40,clientY:40}); element.dispatch('pointermove',{pointerId:2,clientX:50,clientY:40}); element.dispatch('pointerdown',{pointerId:3,clientX:120,clientY:40}); assert.deepEqual(ends,[false,true]); element.dispatch('pointermove',{pointerId:3,clientX:140,clientY:40});assert.equal(zooms.length,1); element.dispatch('pointerup',{pointerId:3}); element.dispatch('pointermove',{pointerId:2,clientX:70,clientY:40}); assert.deepEqual(moves,[60,50]);assert.deepEqual(views,[],'pinch takeover never jumps into a view or door drag'); element.dispatch('pointercancel',{pointerId:2});controller.dispose(); }); test('overlay and capture cancellation stop the door without toggling it',()=>{ const element=new SceneElement(),ends=[]; const controller=new PointerController(element,()=>assert.fail('door must own the pointer'),undefined,{getZoom:()=>1,setZoom(){},subjectDrag:{begin:()=>true,move(){},end:c=>ends.push(c)}}); element.dispatch('pointerdown');controller.cancelGesture();assert.deepEqual(ends,[true]); element.dispatch('pointerdown');element.dispatch('lostpointercapture');assert.deepEqual(ends,[true,true]);controller.dispose(); }); test('right and middle mouse buttons cannot start or toggle a handle drag',()=>{ const element=new SceneElement();let begins=0; const controller=new PointerController(element,()=>{},undefined,{getZoom:()=>1,setZoom(){},subjectDrag:{begin:()=>{begins++;return true;},move(){},end(){}}}); for(const button of [1,2]){element.dispatch('pointerdown',{pointerType:'mouse',button});element.dispatch('pointerup',{pointerType:'mouse',button});} assert.equal(begins,0);assert.equal(element.captured.size,0);controller.dispose(); });