Files
behind_glass/scripts/test-scene-gestures.mjs
T
horoli e358e24436 Replace project with current server release and Windows setup
Import the 2026-10-09 source release, Fastify/SQLite backend, restored source
assets, and analytics-enabled server build. Replace files from the previous
release while retaining the existing repository history.

Make builds portable across Windows and Linux, configure local startup,
normalize Windows static asset paths, and fix test fixture cleanup and file
URL handling. Exclude local environment settings, databases, dependencies,
validation output, and TypeScript build cache from version control.

Validation: npm ci, static and server builds, 106 arrival tests, and actual
local HTTP checks passed. The full suite passed 467 of 470 tests; three tests
still require QA fixtures omitted from the supplied release, documented in
README-서버실행.md.
2026-10-09 16:43:35 +09:00

383 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});