import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; import ts from 'typescript'; const source = await readFile(new URL('../src/input/GyroController.ts', import.meta.url), 'utf8'); const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ES2020 } }).outputText; const { GyroController } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`); class Events { listeners = new Map(); addEventListener(type, fn) { if (!this.listeners.has(type)) this.listeners.set(type, new Set()); this.listeners.get(type).add(fn); } removeEventListener(type, fn) { this.listeners.get(type)?.delete(fn); } emit(type, data = {}) { for (const fn of [...(this.listeners.get(type) ?? [])]) fn(data); } count(type) { return this.listeners.get(type)?.size ?? 0; } } function setup({ secure = true, permission, supported = true, angle = 0 } = {}) { const win = new Events(), doc = new Events(), orientation = new Events(); const timers = new Map(); let next = 0; Object.assign(win, { isSecureContext: secure, screen: { orientation }, setTimeout: fn => { timers.set(++next, fn); return next; }, clearTimeout: id => timers.delete(id) }); orientation.angle = angle; doc.hidden = false; if (supported) win.DeviceOrientationEvent = permission ? { requestPermission: permission } : {}; globalThis.window = win; globalThis.document = doc; const view = { x: .2, y: -.1 }, values = [], states = []; const gyro = new GyroController((x, y) => { Object.assign(view, { x, y }); values.push([x, y]); }, (state, message) => states.push({ state, message }), () => ({ ...view })); return { gyro, win, doc, orientation, view, values, states, timers, reading: (beta = 0, gamma = 0, alpha = 0) => win.emit('deviceorientation', { beta, gamma, alpha }), expire: () => { const callbacks = [...timers.values()]; timers.clear(); callbacks.forEach(fn => fn()); } }; } const near = (actual, expected, tolerance = 1e-10) => assert.ok(Math.abs(actual - expected) < tolerance, `${actual} != ${expected}`); test('no automatic permission request; toggle calls permission synchronously in gesture', async () => { let calls = 0; const s = setup({ permission: () => { calls++; return Promise.resolve('granted'); } }); assert.equal(calls, 0); assert.equal(s.win.count('deviceorientation'), 0); const starting = s.gyro.toggle(); assert.equal(calls, 1); await starting; assert.equal(s.gyro.state, 'waiting'); s.reading(45, 8, 180); assert.equal(s.gyro.state, 'active'); assert.deepEqual(s.values.at(-1), [0, 0]); s.gyro.dispose(); assert.equal(s.timers.size, 0); }); test('insecure and unsupported contexts, denial, and permission errors leave drag fallback', async () => { for (const options of [{ secure: false }, { supported: false }, { permission: async () => 'denied' }, { permission: () => { throw Error('Blocked'); } }]) { const s = setup(options); await s.gyro.toggle(); assert.equal(s.gyro.state, 'unavailable'); assert.match(s.states.at(-1).message, /드래그/); assert.equal(s.win.count('deviceorientation'), 0); assert.deepEqual(s.values, []); s.gyro.dispose(); } }); test('only finite full orientation samples activate; no-data timeout cleans listeners', async () => { const s = setup(); await s.gyro.toggle(); for (const [beta, gamma, alpha] of [[null, 0, 0], [0, NaN, 0], [0, 0, Infinity], [0, 0, null], [Infinity, 0, 0]]) s.reading(beta, gamma, alpha); assert.equal(s.gyro.state, 'waiting'); assert.deepEqual(s.values, []); s.expire(); assert.equal(s.gyro.state, 'unavailable'); assert.match(s.states.at(-1).message, /드래그/); assert.equal(s.win.count('deviceorientation'), 0); }); test('relative tilt is limited, orientation compensated, and starts at neutral', async () => { for (const angle of [0, 90, 180, 270, -90]) { const s = setup({ angle }); await s.gyro.toggle(); s.reading(); s.reading(0, 17.5); near(s.view.x, .5 * Math.cos(angle * Math.PI / 180)); near(s.view.y, -.5 * Math.sin(angle * Math.PI / 180)); s.reading(0, 85); assert.ok(Math.abs(s.view.x) <= 1 && Math.abs(s.view.y) <= 1); s.gyro.dispose(); } }); test('upright equivalent Euler representations do not cause a jump', async () => { const s = setup(); await s.gyro.toggle(); s.reading(89, 10, 20); s.reading(91, -170, 200); // same rotation expressed across the Euler representation boundary assert.ok(Math.abs(s.view.x) < .1 && Math.abs(s.view.y) < .1); s.gyro.dispose(); }); test('pause ignores motion and resumes relative to current manual view with no jump', async () => { const s = setup(); await s.gyro.toggle(); s.reading(); s.reading(0, 10); s.gyro.setPaused(true); const frozen = { ...s.view }; s.reading(30, 25); assert.deepEqual(s.view, frozen); Object.assign(s.view, { x: .4, y: -.3 }); s.gyro.setPaused(false); s.reading(35, 30); near(s.view.x, .4); near(s.view.y, -.3); s.reading(35, 32); assert.ok(s.view.x > .4); s.gyro.dispose(); }); test('screen rotation rebases while preserving current view, including legacy angle', async () => { const s = setup(); await s.gyro.toggle(); s.reading(); s.reading(0, 10); const frozen = { ...s.view }; s.orientation.angle = 90; s.orientation.emit('change'); s.reading(30, 20); assert.deepEqual(s.view, frozen); delete s.win.screen.orientation; s.win.orientation = -90; s.win.emit('orientationchange'); s.reading(20, 15); assert.deepEqual(s.view, frozen); s.gyro.dispose(); assert.equal(s.orientation.count('change'), 0); }); test('recenter resets neutral and disable preserves manual fallback', async () => { const s = setup(); await s.gyro.toggle(); s.reading(); s.reading(0, 10); s.gyro.reset(); s.reading(20, 20); assert.deepEqual(s.values.at(-1), [0, 0]); await s.gyro.toggle(); assert.equal(s.gyro.state, 'off'); const count = s.values.length; s.reading(); assert.equal(s.values.length, count); }); test('background removes sensor listener, resumes without jump, pagehide stops', async () => { const s = setup(); await s.gyro.toggle(); s.reading(); s.reading(0, 10); const frozen = { ...s.view }; s.doc.hidden = true; s.doc.emit('visibilitychange'); assert.equal(s.win.count('deviceorientation'), 0); assert.equal(s.timers.size, 0); s.doc.hidden = false; s.doc.emit('visibilitychange'); assert.equal(s.win.count('deviceorientation'), 1); s.reading(35, 20); assert.deepEqual(s.view, frozen); s.win.emit('pagehide'); assert.equal(s.gyro.state, 'off'); assert.equal(s.win.count('deviceorientation'), 0); s.gyro.dispose(); }); test('cancelled permission resolutions cannot activate or overwrite a newer start', async () => { let resolve; const s = setup({ permission: () => new Promise(r => { resolve = r; }) }); const first = s.gyro.toggle(); await s.gyro.toggle(); resolve('granted'); await first; assert.equal(s.gyro.state, 'off'); assert.equal(s.win.count('deviceorientation'), 0); const second = s.gyro.toggle(); const oldResolve = resolve; s.gyro.dispose(); oldResolve('granted'); await second; assert.equal(s.win.count('deviceorientation'), 0); assert.equal(s.doc.count('visibilitychange'), 0); }); test('repeat start/stop does not accumulate listeners or timers; stationary active has no watchdog', async () => { const s = setup(); for (let i = 0; i < 5; i++) { await s.gyro.toggle(); assert.equal(s.win.count('deviceorientation'), 1); s.reading(); assert.equal(s.timers.size, 0); await s.gyro.toggle(); assert.equal(s.win.count('deviceorientation'), 0); assert.equal(s.timers.size, 0); } s.gyro.dispose(); assert.equal(s.doc.count('visibilitychange'), 0); assert.equal(s.win.count('orientationchange'), 0); assert.equal(s.win.count('pagehide'), 0); }); test('enabling inside settings validates data but waits to center until the scene resumes', async () => { const s = setup(); s.gyro.setPaused(true); await s.gyro.toggle(); s.reading(50, 15); assert.equal(s.gyro.state, 'paused'); assert.deepEqual(s.values, []); assert.equal(s.timers.size, 0); s.gyro.setPaused(false); s.reading(55, 18); assert.deepEqual(s.values.at(-1), [0, 0]); s.gyro.dispose(); }); test('invalid readings after activation cannot poison the last finite camera target', async () => { const s = setup(); await s.gyro.toggle(); s.reading(); s.reading(0, 10); const before = { ...s.view }; s.reading(NaN, 10); s.reading(30, Infinity); assert.deepEqual(s.view, before); s.gyro.dispose(); }); test('late rejected old permission cannot cancel a newer sensor session', async () => { let reject; let calls = 0; const s = setup({ permission: () => ++calls === 1 ? new Promise((_, r) => { reject = r; }) : Promise.resolve('granted') }); const old = s.gyro.toggle(); await s.gyro.toggle(); await s.gyro.toggle(); s.reading(); reject(Error('late')); await old; assert.equal(s.gyro.state, 'active'); assert.equal(s.win.count('deviceorientation'), 1); s.gyro.dispose(); }); test('background during permission cancels activation; proven stationary sensor stays active on resume', async () => { let resolve; const pending = setup({ permission: () => new Promise(r => { resolve = r; }) }); const first = pending.gyro.toggle(); pending.doc.hidden = true; pending.doc.emit('visibilitychange'); resolve('granted'); await first; assert.equal(pending.gyro.state, 'off'); assert.equal(pending.win.count('deviceorientation'), 0); pending.gyro.dispose(); const s = setup(); await s.gyro.toggle(); s.reading(); s.doc.hidden = true; s.doc.emit('visibilitychange'); s.doc.hidden = false; s.doc.emit('visibilitychange'); s.expire(); assert.equal(s.gyro.state, 'active'); assert.equal(s.win.count('deviceorientation'), 1); s.gyro.dispose(); }); test('sensor controller neither transmits nor stores readings', () => { assert.doesNotMatch(source, /fetch\s*\(|XMLHttpRequest|WebSocket|sendBeacon|localStorage|sessionStorage|indexedDB|console\./); }); test('proven stationary sensors do not fail after a tap, settings, recenter or rotation', async () => { const s = setup(); await s.gyro.toggle(); s.reading(); s.gyro.setPaused(true); s.gyro.setPaused(false); s.expire(); assert.equal(s.gyro.state, 'active'); s.gyro.reset(); s.expire(); assert.equal(s.gyro.state, 'active'); s.orientation.angle = 90; s.orientation.emit('change'); s.expire(); assert.equal(s.win.count('deviceorientation'), 1); s.gyro.dispose(); }); test('hint intent ignores sensor jitter and rebases after pause; only deliberate relative tilt counts', async()=>{ const s=setup();let gestures=0; s.gyro.dispose(); const gyro=new GyroController(()=>{},()=>{},()=>({x:0,y:0}),()=>gestures++); await gyro.toggle();s.reading(); for(let i=0;i<80;i++)s.reading(.08*Math.sin(i),.12*Math.cos(i)); assert.equal(gestures,0); s.reading(0,6);assert.equal(gestures,1); for(let i=0;i<80;i++)s.reading(0,6+.12*Math.cos(i)); assert.equal(gestures,1,'holding a tilted pose is idle, not repeated action'); gyro.setPaused(true);s.reading(40,30);gyro.setPaused(false);s.reading(40,30); assert.equal(gestures,1);s.reading(40,42);assert.equal(gestures,2);gyro.dispose(); }); test('recenter makes the current held phone pose the new zero without re-requesting permission',async()=>{ let permissionCalls=0;const s=setup({permission:async()=>{permissionCalls++;return 'granted';}}); await s.gyro.toggle();s.reading(20,10,15);s.reading(20,25,15);assert.notEqual(s.view.x,0); s.gyro.reset();s.reading(20,25,15);near(s.view.x,0);near(s.view.y,0); s.reading(20,30,15);assert.notEqual(s.view.x,0);assert.equal(permissionCalls,1);s.gyro.dispose(); }); test('explicit disconnect is idempotent and invalidates a pending permission result',async()=>{ let resolve,calls=0;const s=setup({permission:()=>{calls++;return new Promise(r=>resolve=r);}}); assert.equal(typeof s.gyro.disconnect,'function');s.gyro.disconnect();assert.equal(calls,0);assert.equal(s.gyro.state,'off'); const pending=s.gyro.toggle();assert.equal(calls,1);s.gyro.disconnect();resolve('granted');assert.equal(await pending,false); assert.equal(s.gyro.state,'off');assert.equal(s.win.count('deviceorientation'),0);assert.equal(s.timers.size,0); s.gyro.disconnect();assert.equal(calls,1);s.gyro.dispose(); }); test('disconnect removes active listeners and does not reenable on visibility changes',async()=>{ const s=setup();await s.gyro.toggle();s.reading();s.reading(0,12);const old=s.values.length; s.gyro.disconnect();s.reading(0,30);s.doc.hidden=true;s.doc.emit('visibilitychange');s.doc.hidden=false;s.doc.emit('visibilitychange'); assert.equal(s.values.length,old);assert.equal(s.gyro.state,'off');assert.equal(s.win.count('deviceorientation'),0);assert.equal(s.timers.size,0); await s.gyro.toggle();s.reading(0,30);assert.deepEqual(s.values.at(-1),[0,0]);s.gyro.dispose(); }); test('old permission completion cannot overwrite a newer explicitly reconnected session',async()=>{ for(const rejected of [false,true]){ const requests=[];const s=setup({permission:()=>new Promise((resolve,reject)=>requests.push({resolve,reject}))}); const old=s.gyro.toggle();s.gyro.disconnect();const fresh=s.gyro.toggle();requests[1].resolve('granted');await fresh;s.reading(0,8);assert.equal(s.gyro.state,'active'); if(rejected)requests[0].reject(new Error('old permission failed'));else requests[0].resolve('granted');await old; assert.equal(s.gyro.state,'active');assert.equal(s.win.count('deviceorientation'),1);s.gyro.dispose(); } });