import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; import ts from 'typescript'; const path = new URL('../src/ui/SceneHints.ts', import.meta.url); const source = await readFile(path, 'utf8').catch(() => 'export class SceneHints { update() { return null; } interact() {} }'); const { SceneHints } = await import(`data:text/javascript;base64,${Buffer.from(ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2020,module:ts.ModuleKind.ES2020}}).outputText).toString('base64')}`); const context = { enabled: true, tiltActive: false, sliding: true, doorOpen: 0, reducedMotion: false }; test('idle starts at five seconds, fades .6s, holds 3s, fades .8s; next unseen hint waits 18s', () => { const h = new SceneHints(); assert.equal(h.update(0,context),null); assert.equal(h.update(4999,context),null); assert.deepEqual(h.update(5000,context),{kind:'drag',phase:'in',text:'드래그해서 유리 너머를 둘러보세요'}); assert.equal(h.update(5600,context).phase,'hold'); assert.equal(h.update(8599,context).phase,'hold'); assert.equal(h.update(8600,context).phase,'out'); assert.equal(h.update(9400,context),null); assert.equal(h.update(27399,context),null); assert.equal(h.update(27400,context).kind,'door-open'); }); test('actual gesture hides immediately, learned gestures survive media/context resets', () => { const h=new SceneHints();h.update(0,context);h.update(5000,context); h.interact(5100,'drag');assert.equal(h.update(5100,context),null); assert.equal(h.update(10099,context),null);assert.equal(h.update(10100,context).kind,'door-open'); h.interact(10200,'door-open');h.update(10300,{...context,enabled:false}); h.update(20000,context);assert.equal(h.update(25000,context),null); const closed={...context,doorOpen:1};assert.equal(h.update(25001,closed),null); assert.equal(h.update(30001,closed).kind,'door-close'); }); test('tilt is taught only after active samples, only one hint and current door direction', () => { const h=new SceneHints();h.update(0,context);h.interact(1,'drag'); assert.equal(h.update(5001,{...context,sliding:false}),null); assert.equal(h.update(6000,{...context,sliding:false,tiltActive:true}),null); assert.equal(h.update(11000,{...context,sliding:false,tiltActive:true}).kind,'tilt'); assert.equal(h.update(11001,{...context,sliding:false,tiltActive:false}),null); const d=new SceneHints();d.update(0,context);d.interact(1,'drag'); assert.equal(d.update(5001,context).kind,'door-open'); assert.equal(d.update(5100,{...context,doorOpen:1}),null); assert.equal(d.update(10100,{...context,doorOpen:1}).kind,'door-close'); }); test('reduced motion has a static 3-second hint, blocks and generic gestures do not teach',()=>{ const h=new SceneHints(),c={...context,reducedMotion:true};h.update(0,c); assert.equal(h.update(5000,c).phase,'hold');assert.equal(h.update(8000,c),null); h.interact(8100);h.update(9000,{...c,enabled:false});h.update(10000,c); assert.equal(h.update(14999,c),null);assert.ok(h.update(15000,c)); }); test('movement inventory stays unique with view controls in settings',async()=>{ const html=await readFile(new URL('../index.html',import.meta.url),'utf8'); const scene=html.slice(0,html.indexOf(']*aria-hidden="true"/); assert.doesNotMatch(scene,/id="door-help"/); }); test('door keyboard limit no-ops do not teach an unperformed opening or closing',async()=>{ const {default:vm}=await import('node:vm'); const main=await readFile(new URL('../src/main.ts',import.meta.url),'utf8'); const listeners={}; for(const [initial,key] of [[0,'Home'],[0,'ArrowRight'],[1,'End'],[1,'ArrowLeft']]){ const h=new SceneHints();h.update(0,context);h.interact(0,'drag'); const doorMotion={target:initial}; vm.runInNewContext(ts.transpileModule(main.slice(main.indexOf("doorToggle.addEventListener('click'"),main.indexOf('// Intersect the held camera')),{compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText,{ doorToggle:{addEventListener:(type,fn)=>listeners[type]=fn},listenerAbort:{signal:null},slideEnabled:()=>true,pointer:{cancelGesture(){}},doorMotion, hideHintAfterDrag:kind=>h.interact(0,kind),animateDoorOpen:value=>doorMotion.target=Math.max(0,Math.min(1,value)), }); listeners.keydown({key,preventDefault(){}}); const c={...context,doorOpen:initial}; assert.equal(h.update(5000,c)?.kind,initial?'door-close':'door-open',`${key} at ${initial} does not learn a direction`); } }); test('tilt accessible name contains the displayed state and retains the action',async()=>{ const {default:vm}=await import('node:vm'); const main=await readFile(new URL('../src/main.ts',import.meta.url),'utf8'); let status;const attrs={},label={textContent:''}; const a=main.indexOf('const gyro = new GyroController'),b=main.indexOf('// One normalized',a); vm.runInNewContext(ts.transpileModule(main.slice(a,b),{compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText,{ GyroController:class{constructor(_view,fn){status=fn;}},gyroLabel:label,gyroButton:{setAttribute:(key,value)=>attrs[key]=value}, gyroRecenter:{},gyroStatus:{},gyroNotice:{},mobileTilt:true,experience:{isBlocked:()=>false,isGuideOpen:()=>false,isPhotoAdjusting:()=>false,stopPhotoAdjust(){}},hideHintAfterDrag(){}, }); for(const state of ['off','permission','waiting','active','paused','unavailable']){ status(state,'test status');assert.ok(attrs['aria-label'].includes(label.textContent),state); if(['waiting','active','paused'].includes(state))assert.match(attrs['aria-label'],/끄기/); } });