import test from 'node:test'; import assert from 'node:assert/strict'; import { acceptedRoomFitGesture, beginDoubleFitPointer, completeDoubleFitPointer, DoubleFitGestureRecognizer, PlanTapGestureController, DOUBLE_FIT_WINDOW_MS, planGestureOwnerFromPath, roomFitCameraTarget, roomFitClampFrame, roomFitGeometryBounds, roomFitOwnerFromPath, roomFitPointBounds, validRoomFitBounds, } from '../test-build/room-fit.js'; import { projectPlanPoint } from '../test-build/iso-projection.js'; const close = (actual, expected, epsilon = 1e-9) => { assert.ok(Math.abs(actual - expected) <= epsilon, `expected ${actual} to be within ${epsilon} of ${expected}`); }; test('#152 point bounds include exact wall vertices and reject malformed geometry', () => { assert.deepEqual(roomFitPointBounds([[10, 20], [90, 20], [90, 80], [10, 80]], 2), { x: 8, y: 18, w: 84, h: 64 }); assert.deepEqual(roomFitGeometryBounds({ floor: [[20, 20], [80, 20], [80, 70], [20, 70]], wall: [[10, 10], [90, 10], [90, 80], [10, 80]], projection: 'flat', }), { x: 10, y: 10, w: 80, h: 70 }); assert.equal(roomFitPointBounds([]), null); assert.equal(roomFitPointBounds([[0, 0], [NaN, 1]]), null); assert.equal(roomFitPointBounds([[0, 0], [0, 10]]), null); assert.equal(validRoomFitBounds({ x: 0, y: 0, w: 0, h: 10 }), false); }); test('#152 isometric bounds project every floor, floor-depth and wall-height vertex', () => { const floor = [[100, 200], [300, 200], [250, 400]]; const wall = [[90, 190], [310, 190], [260, 410]]; const wallHeight = 64; const floorDepth = 10; const expectedPoints = [ ...floor.flatMap((point) => [projectPlanPoint(point, 0), projectPlanPoint(point, -floorDepth)]), ...wall.flatMap((point) => [projectPlanPoint(point, 0), projectPlanPoint(point, wallHeight)]), ]; const expected = roomFitPointBounds(expectedPoints); assert.deepEqual(roomFitGeometryBounds({ floor, wall, projection: 'iso', wallHeight, floorDepth, }), expected); const withoutHeight = roomFitGeometryBounds({ floor, wall, projection: 'iso' }); assert.ok(expected.y < withoutHeight.y, 'wall tops must extend the projected room upward'); }); test('#152 camera target gives exact 10% fields for the 2000x1000 square example', () => { const target = roomFitCameraTarget({ bounds: { x: 100, y: 100, w: 400, h: 400 }, baseFit: { x: 0, y: 0, w: 2000, h: 1000 }, stageWidth: 2000, stageHeight: 1000, minZoom: 1 / 3, maxZoom: 8, }); assert.ok(target); close(target.viewBox.w, 1000); close(target.viewBox.h, 500); close(target.zoom, 2); const screenWidth = 400 / target.viewBox.w * 2000; const screenHeight = 400 / target.viewBox.h * 1000; close(screenWidth, 800); close(screenHeight, 800); close(target.viewBox.x + target.viewBox.w / 2, 300); close(target.viewBox.y + target.viewBox.h / 2, 300); }); test('#152 camera target handles width limit, height limit and zoom clamps', () => { const base = { x: 0, y: 0, w: 1000, h: 500 }; const wide = roomFitCameraTarget({ bounds: { x: 0, y: 0, w: 800, h: 100 }, baseFit: base, stageWidth: 1000, stageHeight: 500, minZoom: 1 / 3, maxZoom: 8, }); assert.ok(wide); close(wide.viewBox.w, 1000); const tall = roomFitCameraTarget({ bounds: { x: 0, y: 0, w: 100, h: 400 }, baseFit: base, stageWidth: 1000, stageHeight: 500, minZoom: 1 / 3, maxZoom: 8, }); assert.ok(tall); close(tall.viewBox.h, 500); const tiny = roomFitCameraTarget({ bounds: { x: 0, y: 0, w: 1, h: 1 }, baseFit: base, stageWidth: 1000, stageHeight: 500, minZoom: 1 / 3, maxZoom: 8, }); assert.equal(tiny.zoom, 8); const huge = roomFitCameraTarget({ bounds: { x: 0, y: 0, w: 4000, h: 2000 }, baseFit: base, stageWidth: 1000, stageHeight: 500, minZoom: 1 / 3, maxZoom: 8, }); assert.equal(huge.zoom, 1 / 3); }); test('#152 invalid stage and bounds fail without a target', () => { const input = { bounds: { x: 0, y: 0, w: 100, h: 100 }, baseFit: { x: 0, y: 0, w: 1000, h: 500 }, stageWidth: 1000, stageHeight: 500, minZoom: 1 / 3, maxZoom: 8, }; assert.equal(roomFitCameraTarget({ ...input, stageWidth: 0 }), null); assert.equal(roomFitCameraTarget({ ...input, bounds: { ...input.bounds, w: NaN } }), null); assert.equal(roomFitCameraTarget({ ...input, safeFraction: 0 }), null); }); test('#152 clamp frame covers the current and detached target cameras', () => { const frame = roomFitClampFrame( { x: 0, y: 0, w: 1000, h: 500 }, { x: -500, y: 100, w: 250, h: 125 }, { x: 1800, y: 700, w: 400, h: 200 }, { x: 1900, y: 760, w: 120, h: 80 }, ); assert.deepEqual(frame, { x: -500, y: 0, w: 2700, h: 900 }); }); const pathNode = (selectors, id = null) => ({ matches: (selector) => selector.split(',').some((part) => selectors.includes(part.trim())), getAttribute: (name) => name === 'data-id' ? id : null, }); test('#152 browser event path is the room authority and interactive children suppress it', () => { const room = pathNode(['[data-hp="room"][data-id]'], 'room-a'); const label = pathNode(['.roomlabel[data-id]', '[role="button"]'], 'room-a'); const stage = pathNode(['.stage']); assert.equal(roomFitOwnerFromPath([room]), 'room-a'); assert.equal(roomFitOwnerFromPath([pathNode(['.rlname']), label]), 'room-a'); assert.equal(roomFitOwnerFromPath([pathNode(['.rlgo', '[role="link"]']), label]), null); assert.equal(roomFitOwnerFromPath([pathNode(['.dev']), room]), null); assert.equal(roomFitOwnerFromPath([pathNode(['button']), room]), null); assert.deepEqual(planGestureOwnerFromPath([stage]), { kind: 'background' }); assert.deepEqual(planGestureOwnerFromPath([room, stage]), { kind: 'room', roomId: 'room-a' }); assert.deepEqual(planGestureOwnerFromPath([pathNode(['.dev']), room, stage]), { kind: 'interactive' }); assert.deepEqual(planGestureOwnerFromPath([pathNode(['button']), stage]), { kind: 'interactive' }); assert.deepEqual(planGestureOwnerFromPath([pathNode(['.opening']), stage]), { kind: 'interactive' }); assert.deepEqual(planGestureOwnerFromPath([pathNode(['[data-room-fit-block]']), stage]), { kind: 'interactive' }); assert.deepEqual(planGestureOwnerFromPath([]), { kind: 'outside' }); }); test('#152 release accepts only the same pointer, space and painted room', () => { const candidate = { pointerId: 7, spaceId: 'floor-a', roomId: 'room-a' }; assert.equal(acceptedRoomFitGesture(candidate, 7, 'floor-a', 'room-a', false), 'room-a'); assert.equal(acceptedRoomFitGesture(candidate, 8, 'floor-a', 'room-a', false), null); assert.equal(acceptedRoomFitGesture(candidate, 7, 'floor-b', 'room-a', false), null); assert.equal(acceptedRoomFitGesture(candidate, 7, 'floor-a', 'room-b', false), null); assert.equal(acceptedRoomFitGesture(candidate, 7, 'floor-a', 'room-a', true), null); }); const background = { kind: 'background' }; const roomOwner = { kind: 'room', roomId: 'room-a' }; const down = (overrides = {}) => beginDoubleFitPointer({ pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, spaceId: 'floor-a', mode: 'view', owner: background, blocked: false, x: 100, y: 100, ...overrides, }); const up = (sequence, candidate, overrides = {}) => completeDoubleFitPointer( sequence, candidate, { pointerId: 1, spaceId: 'floor-a', mode: 'view', owner: background, blocked: false, x: 101, y: 100, now: 1_000, ...overrides, }, ); test('#691 only a primary clean View press on a plan surface becomes a candidate', () => { assert.deepEqual(down(), { pointerId: 1, spaceId: 'floor-a', modality: 'mouse', x: 100, y: 100, }); assert.deepEqual(down({ owner: roomOwner }), { pointerId: 1, spaceId: 'floor-a', modality: 'mouse', x: 100, y: 100, roomId: 'room-a', }); assert.equal(down({ pointerType: 'touch' }).modality, 'touch'); assert.equal(down({ pointerType: 'pen' }).modality, 'pen'); for (const rejected of [ { pointerType: '' }, { isPrimary: false }, { button: 2 }, { mode: 'plan' }, { owner: { kind: 'interactive' } }, { owner: { kind: 'outside' } }, { blocked: true }, ]) assert.equal(down(rejected), null); }); test('#449 two clean taps within 350 ms trigger once and clear before the command', () => { const first = up(null, down()); assert.equal(first.trigger, false); assert.deepEqual(first.sequence, { at: 1_000, spaceId: 'floor-a', modality: 'mouse' }); const secondCandidate = down({ pointerId: 2 }); const second = up(first.sequence, secondCandidate, { pointerId: 2, now: 1_350 }); assert.deepEqual(second, { sequence: null, trigger: true }); assert.deepEqual(up(second.sequence, down({ pointerId: 3 }), { pointerId: 3, now: 1_351 }), { sequence: { at: 1_351, spaceId: 'floor-a', modality: 'mouse' }, trigger: false, }); }); test('#691 a first room tap arms delayed room-fit and any second plan tap fits all', () => { const roomCandidate = down({ owner: roomOwner }); const first = up(null, roomCandidate, { owner: roomOwner }); assert.deepEqual(first, { sequence: { at: 1_000, spaceId: 'floor-a', modality: 'mouse' }, trigger: false, roomId: 'room-a', }); const roomToBackground = up(first.sequence, down({ pointerId: 2 }), { pointerId: 2, now: 1_200, }); assert.deepEqual(roomToBackground, { sequence: null, trigger: true }); const backgroundFirst = up(null, down(), { now: 2_000 }); const backgroundToRoom = up( backgroundFirst.sequence, down({ pointerId: 3, owner: roomOwner }), { pointerId: 3, owner: roomOwner, now: 2_100 }, ); assert.deepEqual(backgroundToRoom, { sequence: null, trigger: true }); assert.deepEqual(up(null, roomCandidate, { owner: { kind: 'room', roomId: 'room-b' }, }), { sequence: null, trigger: false }); }); test('#449 an expired tap becomes the new first tap and modalities never mix', () => { const old = { at: 1_000, spaceId: 'floor-a', modality: 'mouse' }; const expired = up(old, down(), { now: 1_000 + DOUBLE_FIT_WINDOW_MS + 1 }); assert.equal(expired.trigger, false); assert.equal(expired.sequence.at, 1_000 + DOUBLE_FIT_WINDOW_MS + 1); const touch = up(old, down({ pointerType: 'touch' }), { now: 1_100 }); assert.deepEqual(touch, { sequence: { at: 1_100, spaceId: 'floor-a', modality: 'touch' }, trigger: false, }); const otherSpace = up(old, down({ spaceId: 'floor-b' }), { spaceId: 'floor-b', now: 1_100, }); assert.deepEqual(otherSpace, { sequence: { at: 1_100, spaceId: 'floor-b', modality: 'mouse' }, trigger: false, }); }); test('#449 moved, cancelled, multitouch, foreign-owner and editor releases disarm the pair', () => { const armed = { at: 900, spaceId: 'floor-a', modality: 'mouse' }; const candidate = down(); const invalid = [ { pointerId: 2 }, { spaceId: 'floor-b' }, { mode: 'decor' }, { owner: { kind: 'room', roomId: 'room-a' } }, { owner: { kind: 'interactive' } }, { blocked: true }, { x: 108, y: 100 }, ]; for (const input of invalid) { assert.deepEqual(up(armed, candidate, input), { sequence: null, trigger: false }); } assert.deepEqual(up(armed, null), { sequence: null, trigger: false }); }); test('#449 recognizer instances keep independent transient sequences', () => { const stage = pathNode(['.stage']); const event = (pointerId) => ({ pointerId, pointerType: 'mouse', isPrimary: true, button: 0, clientX: 10, clientY: 10, composedPath: () => [stage], }); const first = new DoubleFitGestureRecognizer(); const second = new DoubleFitGestureRecognizer(); first.pointerDown(event(1), 'floor-a', true); assert.equal(first.pointerUp(event(1), 'floor-a', true, false, 1_000).trigger, false); second.pointerDown(event(2), 'floor-a', true); assert.equal(second.pointerUp(event(2), 'floor-a', true, false, 1_100).trigger, false); first.pointerDown(event(3), 'floor-a', true); assert.equal(first.pointerUp(event(3), 'floor-a', true, false, 1_200).trigger, true); second.clear(); second.pointerDown(event(4), 'floor-a', true); assert.equal(second.pointerUp(event(4), 'floor-a', true, false, 1_250).trigger, false); }); test('#691 an interactive or outside pointerdown disarms the plan sequence', () => { const stage = pathNode(['.stage']); const interactive = pathNode(['.dev']); const event = (pointerId, path = [stage]) => ({ pointerId, pointerType: 'touch', isPrimary: true, button: 0, clientX: 10, clientY: 10, composedPath: () => path, }); const recognizer = new DoubleFitGestureRecognizer(); recognizer.pointerDown(event(1), 'floor-a', true); recognizer.pointerUp(event(1), 'floor-a', true, false, 1_000); assert.equal(recognizer.clearNonPlan(event(2, [interactive, stage])), true); recognizer.pointerDown(event(3), 'floor-a', true); assert.equal(recognizer.pointerUp(event(3), 'floor-a', true, false, 1_100).trigger, false); assert.equal(recognizer.clearNonPlan(event(4, [stage])), false); }); test('#691 controller owns one fake-clock room timer and cancels it on second intent', () => { const stage = pathNode(['.stage']); const room = pathNode(['.roomlabel[data-id]'], 'room-a'); const interactive = pathNode(['.dev']); const pointer = (pointerId, path, pointerType = 'touch') => ({ pointerId, pointerType, isPrimary: true, button: 0, clientX: 20, clientY: 30, composedPath: () => path, }); let nextTimer = 1; const timers = new Map(); const delays = []; const timerPort = { set(callback, delayMs) { const id = nextTimer++; timers.set(id, callback); delays.push(delayMs); return id; }, clear(handle) { timers.delete(handle); }, }; const calls = []; let current = { spaceId: 'floor-a', enabled: true }; const controller = new PlanTapGestureController({ current: () => current, fitAll: () => calls.push('all'), fitRoom: (roomId) => calls.push(`room:${roomId}`), }, timerPort); controller.pointerDown(pointer(1, [room, stage]), 'floor-a', true); assert.equal(controller.pointerUp( pointer(1, [room, stage]), { pointerId: 1, spaceId: 'floor-a', roomId: 'room-a' }, 'floor-a', true, false, 1_000, ), 'room-a'); assert.deepEqual(calls, []); assert.deepEqual(delays, [DOUBLE_FIT_WINDOW_MS]); assert.equal(timers.size, 1); controller.pointerDown(pointer(2, [stage]), 'floor-a', true); assert.equal(timers.size, 0, 'a second clean pointer cancels pending room motion'); controller.pointerUp(pointer(2, [stage]), null, 'floor-a', true, false, 1_100); assert.deepEqual(calls, ['all']); assert.equal(timers.size, 0); calls.length = 0; controller.pointerDown(pointer(3, [room, stage]), 'floor-a', true); controller.pointerUp( pointer(3, [room, stage]), { pointerId: 3, spaceId: 'floor-a', roomId: 'room-a' }, 'floor-a', true, false, 2_000, ); assert.equal(controller.clearNonPlan(pointer(4, [interactive, stage])), true); assert.equal(timers.size, 0, 'interactive intent cancels the pending room command'); controller.pointerDown(pointer(5, [room, stage]), 'floor-a', true); controller.pointerUp( pointer(5, [room, stage]), { pointerId: 5, spaceId: 'floor-a', roomId: 'room-a' }, 'floor-a', true, false, 3_000, ); current = { spaceId: 'floor-b', enabled: true }; for (const callback of [...timers.values()]) callback(); assert.deepEqual(calls, [], 'a stale-space timer is inert'); });