import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; import vm from 'node:vm'; import ts from 'typescript'; import * as THREE from 'three'; const main=await readFile(new URL('../src/main.ts',import.meta.url),'utf8'); const compile=source=>ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ES2022}}).outputText; const helper=await readFile(new URL('../src/rendering/RenderDemand.ts',import.meta.url),'utf8'); const {RenderDemand}=await import(`data:text/javascript;base64,${Buffer.from(compile(helper)).toString('base64')}`); const {loadTypescript}=await import('./support/load-typescript.mjs'); const {CameraController}=await loadTypescript(new URL('../src/input/CameraController.ts',import.meta.url)); const slideSource=await readFile(new URL('../src/input/WindowSlideController.ts',import.meta.url),'utf8'); const {WindowSlideController}=await import(`data:text/javascript;base64,${Buffer.from(compile(slideSource)).toString('base64')}`); const {SceneHints}=await import(`data:text/javascript;base64,${Buffer.from(compile(await readFile(new URL('../src/ui/SceneHints.ts',import.meta.url),'utf8'))).toString('base64')}`); function harness(){ const camera=new THREE.PerspectiveCamera(40,1,.1,100),petDepth=new THREE.Mesh(new THREE.PlaneGeometry(),new THREE.MeshBasicMaterial()); camera.position.z=3; const cameraController=new CameraController(camera),renderDemand=new RenderDemand(),doorMotion=new WindowSlideController(); const uniforms={uViewOffset:{value:new THREE.Vector2()},uPetWorldToLocal:{value:new THREE.Matrix4()},uContentViewProjection:{value:new THREE.Matrix4()},uContentInverseViewProjection:{value:new THREE.Matrix4()},uContentCameraPosition:{value:new THREE.Vector3()},uTime:{value:0},uSourceZ:{value:0},uRegionAvailable:{value:0},uRegionStrength:{value:0},uStrength:{value:1}}; let submissions=0,now=0,signatureRevision=0; const context={firstGuideUI:{recenter(){}},gyroRecenter:{hidden:true},exportUI:{setAvailable(){}},canCaptureScene:()=>false,canAdjustScenePhoto:()=>false,sceneMotion:{hidden:false},gyroNotice:{hidden:true},mobileTilt:true,hintElement:{dataset:{},textContent:''},sceneHints:new SceneHints(),comparisonRequested:false,THREE,last:0,raf:0,gyro:{setPaused(){}},experience:{isBlocked:()=>false,isGuideOpen:()=>false,isPhotoAdjusting:()=>false,stopPhotoAdjust(){}},pointer:{isInteracting:false},doorMotion,slideEnabled:()=>true,doorReducedMotion:null,syncDoorPresentation(){},renderLost:false,aiDepthBusy:false,document:{hidden:false,querySelector:()=>({hidden:true})},comparisonMetrics:{reset(){}},meshFailure:false,uniforms,cameraController,sensitivity:.55,camera,petDepth,housePhotoDepth:{sync(){}},houseSurround:{isReady:true},blenderFrame:{isWood:true},mode:'final',active:{},emptyTex:{},depthDebug:{update:()=>''},depthUniforms:{uDepthScale:{value:.2},uDepthBias:{value:0}},wrap:{clientWidth:1000},maxDepthValue:1,debugOutput:{},comparison:null,comparisonPanes:null,renderSelected(){submissions++;},requestAnimationFrame:()=>1,renderDemand,activeIsVideo:false,selectedRenderer:'b',effectInput:{value:'mosaic'},mosaicStyleInput:{value:'2'},comparisonTiming:{textContent:''},currentRenderSignature:()=>[...camera.matrixWorld.elements,...camera.projectionMatrix.elements,...petDepth.matrixWorld.elements,doorMotion.value,signatureRevision].join('|')}; context.renderer={domElement:{width:1000,height:1000}}; context.blenderFrame.isReady=true; context.cancelDoorInteraction=()=>doorMotion.cancel(); const hintStart=main.indexOf('function syncSceneHints('),hintEnd=main.indexOf('const imageInput =',hintStart); vm.runInNewContext(compile(main.slice(hintStart,hintEnd)),context); const begin=main.indexOf('function currentRenderSignature()'); const end=main.indexOf('\nraf = requestAnimationFrame(frame);',begin); assert.ok(begin>=0&&end>begin,'extract the actual production frame function'); vm.runInNewContext(compile(main.slice(begin,end))+'\nglobalThis.runFrame=frame;',context); const visibility=main.match(/document\.addEventListener\('visibilitychange',(\(\)=>\{.*?\}),\{signal:/); assert.ok(visibility,'exercise the real visibility listener without a hidden RAF'); vm.runInNewContext('globalThis.visibility='+visibility[1],context); return {context,cameraController,doorMotion,renderDemand,get submissions(){return submissions;},advance(frames=1){for(let i=0;i{ const h=harness();h.advance();const warmup=h.submissions;h.advance(600); assert.equal(h.submissions-warmup,0,'idle must bypass renderSelected, not merely throttle JS'); }); test('actual main resumes drag, settles exactly, then resumes zoom, asset, depth and recovery',()=>{ const h=harness();h.advance();h.cameraController.add(.5,-.25);h.advance(15); assert.ok(h.submissions>1,'camera smoothing renders intermediate frames'); h.advance(240);const settled=h.submissions;h.advance(600);assert.equal(h.submissions,settled); let expected=settled; for(const reason of ['pinch','asset ready','depth complete','theme','resize','renderer retry']){ h.change();h.advance();assert.equal(h.submissions,++expected,reason);h.advance();assert.equal(h.submissions,expected); } h.context.renderLost=true;h.advance(10);assert.equal(h.submissions,expected); h.context.renderLost=false;h.advance();assert.equal(h.submissions,++expected,'restored canvas receives a new frame'); }); test('actual frame preserves mechanical movement and its exact final position',()=>{ const h=harness();h.advance();h.doorMotion.moveTo(1,1000/60);h.advance(10); assert.ok(h.doorMotion.value>0&&h.doorMotion.value<1);assert.ok(h.submissions>1); h.advance(20);assert.equal(h.doorMotion.value,1);const settled=h.submissions;h.advance(120);assert.equal(h.submissions,settled); }); test('hidden and AI-busy paths submit nothing and redraw once when resumed',()=>{ const h=harness();h.advance();let expected=h.submissions; for(const flag of ['hidden','aiDepthBusy']){ const owner=flag==='hidden'?h.context.document:h.context; owner[flag]=true;h.advance(60);assert.equal(h.submissions,expected); owner[flag]=false;h.advance();assert.equal(h.submissions,++expected);h.advance();assert.equal(h.submissions,expected); } }); test('actual frame and signature follow decoded video frames, then stop on paused video',()=>{ const h=harness(),texture=new THREE.Texture();texture.image={currentTime:0,requestVideoFrameCallback(){}}; h.context.active=texture;h.context.activeIsVideo=true;h.context.depthUniforms.uTexture={value:texture}; h.advance();const initial=h.submissions;h.advance(60);assert.equal(h.submissions,initial); texture.needsUpdate=true;h.advance();assert.equal(h.submissions,initial+1); h.advance(60);assert.equal(h.submissions,initial+1,'no new decoded frame means no repeated GPU draw'); delete texture.image.requestVideoFrameCallback; h.advance();const fallback=h.submissions;texture.image.currentTime=.04;h.advance();assert.equal(h.submissions,fallback+1); texture.needsUpdate=true;h.advance();assert.equal(h.submissions,fallback+1,'fallback texture upload cannot invalidate itself'); }); test('static comparison is also idle and its cadence label clearly says paused',()=>{ const h=harness();h.context.comparison={};h.advance();const first=h.submissions;h.advance(60); assert.equal(h.submissions,first);assert.match(h.context.comparisonTiming.textContent,/정지 화면/); }); test('tab restoration redraws even when the browser delivered zero hidden RAF callbacks',()=>{ const h=harness();h.advance(2);const first=h.submissions; h.context.document.hidden=true;h.context.visibility(); h.context.document.hidden=false;h.context.visibility(); h.advance();assert.equal(h.submissions,first+1);h.advance();assert.equal(h.submissions,first+1); }); test('passive hint fade and repeat change DOM without waking idle WebGL',()=>{ const h=harness();h.advance();const initial=h.submissions; h.advance(310);assert.equal(h.context.hintElement.dataset.phase,'in'); h.advance(50);assert.equal(h.context.hintElement.dataset.phase,'hold'); h.advance(170);assert.equal(h.context.hintElement.dataset.phase,'out'); h.advance(40);assert.equal(h.context.hintElement.dataset.phase,'hidden'); h.advance(1090);assert.notEqual(h.context.hintElement.dataset.phase,'hidden'); assert.equal(h.submissions,initial,'teaching cycles never invalidate the GL image'); }); test('mobile unavailable notice hides while settings block the scene',()=>{ const h=harness();h.context.gyro.state='unavailable';h.advance(); assert.equal(h.context.gyroNotice.hidden,false); h.context.experience.isBlocked=()=>true;h.advance();assert.equal(h.context.gyroNotice.hidden,true); h.context.experience.isBlocked=()=>false;h.advance();assert.equal(h.context.gyroNotice.hidden,false,'fallback stays discoverable after closing controls'); }); test('desktop never exposes the tilt row, including after a scene unblocks',()=>{ const h=harness();h.context.mobileTilt=false;h.context.gyro.state='unavailable';h.advance(); assert.equal(h.context.sceneMotion.hidden,true);assert.equal(h.context.gyroNotice.hidden,true); h.context.experience.isBlocked=()=>true;h.advance();h.context.experience.isBlocked=()=>false;h.advance(); assert.equal(h.context.sceneMotion.hidden,true); h.context.mobileTilt=true;h.advance();assert.equal(h.context.sceneMotion.hidden,false); }); test('mobile recenter is shown only with live tilt and no blocking dialog',()=>{ const h=harness();h.context.gyro.state='active';h.advance();assert.equal(h.context.gyroRecenter.hidden,false); h.context.mobileTilt=false;h.advance();assert.equal(h.context.gyroRecenter.hidden,true); h.context.mobileTilt=true;h.context.gyro.state='off';h.advance();assert.equal(h.context.gyroRecenter.hidden,true); h.context.gyro.state='active';h.context.experience.isBlocked=()=>true;h.advance();assert.equal(h.context.gyroRecenter.hidden,true); }); test('spotlight leaves mobile targets visible while pausing normal scene input',()=>{ const h=harness();h.context.mobileTilt=true;h.context.gyro.state='paused';h.context.experience.isBlocked=()=>true;h.context.experience.isGuideOpen=()=>true;h.advance(); assert.equal(h.context.sceneMotion.hidden,false);assert.equal(h.context.gyroRecenter.hidden,false); h.context.renderLost=true;h.advance();assert.equal(h.context.sceneMotion.hidden,true); });