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.
This commit is contained in:
2026-10-09 16:43:35 +09:00
parent 71c8d3b7ff
commit e358e24436
642 changed files with 35202 additions and 17642 deletions
+1 -1
View File
@@ -98,7 +98,7 @@ try {
await page.locator('#view-mode').selectOption('noglass');
await page.locator('#ai-depth').click();
await page.waitForFunction(() => document.querySelector('#ai-depth')?.textContent?.includes('생성 중'), null, { timeout: 5000 });
await page.locator('#image').setInputFiles('public/assets/qa/dog1.jpeg');
await page.locator('#image').setInputFiles('public/assets/samples/dog1.jpg');
await page.waitForFunction(() => {
const button = document.querySelector('#ai-depth');
const provenance = document.querySelector('#depth-provenance');
+14
View File
@@ -0,0 +1,14 @@
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const flag = process.argv[2];
if (!['VITE_ARRIVAL_ANALYTICS=false', 'VITE_ARRIVAL_ANALYTICS=true'].includes(flag)) {
throw new Error('Choose VITE_ARRIVAL_ANALYTICS=false or VITE_ARRIVAL_ANALYTICS=true');
}
const viteCli = fileURLToPath(new URL('../node_modules/vite/bin/vite.js', import.meta.url));
const result = spawnSync(process.execPath, [viteCli, 'build', ...process.argv.slice(3)], {
stdio: 'inherit',
env: { ...process.env, VITE_ARRIVAL_ANALYTICS: flag.split('=')[1] },
});
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
const maxAssetBytes = 25 * 1024 * 1024;
function files(root, relative = '') {
return readdirSync(join(root, relative), { withFileTypes: true }).flatMap(entry => {
const path = join(relative, entry.name);
return entry.isDirectory() ? files(root, path) : [path];
});
}
const built = files('dist');
for (const file of built) {
const bytes = statSync(join('dist', file)).size;
assert(bytes <= maxAssetBytes, `${file}: ${bytes} bytes exceeds Sites' ${maxAssetBytes}-byte asset limit`);
}
for (const file of files('public')) {
assert(readFileSync(join('public', file)).equals(readFileSync(join('dist', file))), `${file}: public asset changed or missing in build`);
}
const html = readFileSync('dist/index.html', 'utf8');
assert(html.includes('id="sample"'), 'Sample button is missing');
assert(html.includes('value="dog1"'), 'Recommended sample option is missing');
for (const match of html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g)) {
assert(built.includes(match[1].slice(1)), `Missing HTML asset ${match[1]}`);
}
const js = built.filter(file => file.endsWith('.js')).map(file => readFileSync(join('dist', file), 'utf8')).join('\n');
for (const id of ['dog1', 'dog2', 'dog3', 'dog4']) {
const path = `assets/samples/${id}.jpg`;
assert(built.includes(path), `Missing sample ${path}`);
assert(statSync(join('dist', path)).size < 1_000_000, `${path} must be under 1 MB`);
assert(html.includes('/' + path), `Missing sample thumbnail ${path}`);
}
assert(js.includes('/assets/samples/'), 'Sample loader path missing');
assert(!/(?:sample-pet|assets\/qa\/dog)/.test(html + js), 'Retired sample reference in shipped bundle');
assert(!built.some(file => /(?:sample-pet|assets\/qa\/dog)/.test(file)), 'Retired sample asset shipped');
assert(js.includes('https://cdn.jsdelivr.net/npm/onnxruntime-web@'), 'Transformers runtime CDN configuration is missing');
assert(!built.some(file => /(?:assets\/countryside\/|CountrysideExperience)/i.test(file)), 'Retired photo-composite scene must not ship');
assert(built.includes('models/u2netp/model.onnx'),'Explicit opt-in small model is missing');
assert(built.filter(file=>file.endsWith('.onnx')).length===1,'Unexpected additional model');
assert(!/(?:cutout-review|countryside-start|scene-mode)/.test(html), 'Retired scene entry is still present');
assert(!/(?:CountrysideExperience|\/assets\/countryside\/)/.test(js), 'Retired scene runtime can still be requested');
assert(js.includes('이 누끼로 배치'),'Experiment must retain explicit review acceptance');
console.log(`Sites build checks passed: ${built.length} assets within 25 MiB, public assets byte-matched, sample UI/paths and runtime CDN configuration preserved, opt-in small cutout model present, retired photo-composite scene absent. Browser rendering and AI inference are separate checks.`);
+46
View File
@@ -0,0 +1,46 @@
// Numerical v13 stock-transmission formula applied to the unchanged real B geometry/thickness.
// CPU-only diagnostic: this does not execute the corrected shader or measure GPU pixels.
import {readFile} from 'node:fs/promises';
import {pathToFileURL,fileURLToPath} from 'node:url';
import ts from 'typescript';
import * as THREE from 'three';
const root=fileURLToPath(new URL('../',import.meta.url));
async function compile(file){
let s=await readFile(file,'utf8');
s=s.replace(/from ['"]three['"]/g,`from '${pathToFileURL(root+'node_modules/three/build/three.module.js')}'`);
s=s.replace(/from ['"]three\/addons\/([^'"]+)['"]/g,(_,p)=>`from '${pathToFileURL(root+'node_modules/three/examples/jsm/'+p)}'`);
for(const m of [...s.matchAll(/from ['"](\.[^'"]+)['"]/g)]) s=s.replace(m[0],`from '${await compile(new URL(m[1]+'.ts',file))}'`);
return 'data:text/javascript;base64,'+Buffer.from(ts.transpileModule(s,{compilerOptions:{module:ts.ModuleKind.ES2020,target:ts.ScriptTarget.ES2020}}).outputText).toString('base64');
}
const {PhysicalGlassComparison}=await import(await compile(pathToFileURL(root+'src/rendering/PhysicalGlassComparison.ts')));
const {sampleGobangSurface}=await import(await compile(pathToFileURL(root+'src/rendering/PatternedGlassGeometry.ts')));
const {comparisonLayout}=await import(await compile(pathToFileURL(root+'src/rendering/ComparisonLayout.ts')));
const photo=new THREE.Mesh(new THREE.PlaneGeometry(1,1,128,128),new THREE.ShaderMaterial({uniforms:{uTexture:{value:new THREE.Texture()},uDepthMap:{value:new THREE.Texture()},uDepthScale:{value:.25},uDepthBias:{value:0}}}));
const adapter=new PhysicalGlassComparison({extensions:{has:()=>false}},photo);
const w=Number(process.argv[2]||390),h=Number(process.argv[3]||844), aspect=w/h, photoAspect=.8;
const baseH=Math.min(1.78*aspect/photoAspect,1.45),shape={width:baseH*photoAspect*.8,height:baseH*.8,grid:20,curvature:1};
const panes=comparisonLayout(w,h,aspect).panes, pane=panes[0];
const camera=new THREE.PerspectiveCamera(40,aspect,.1,100);camera.position.z=3;camera.updateMatrixWorld();
adapter.sync(shape,.6,new THREE.Vector3(.6,.8,2.6),new THREE.Color());
const geometry=adapter.glass.geometry,p=geometry.attributes.position,n=geometry.attributes.normal,uv=geometry.attributes.uv;
const tex=adapter.material.thicknessMap,texW=tex.image.width,texH=tex.image.height,texData=tex.image.data;
const thicknessFactor=adapter.material.thickness;
const xValues=[];for(let i=0;;i++){if(i>0&&uv.getY(i)!==0)break;xValues.push(uv.getX(i));}
const nx=xValues.length;const yValues=[];for(let i=0;i<p.count;i+=nx){if(n.getZ(i)<.5)break;yValues.push(uv.getY(i));if(uv.getY(i)===1)break;}
function clamp(v,a,b){return Math.max(a,Math.min(b,v));}
function tAt(u,v){const x=u*texW-.5,y=v*texH-.5,x0=Math.floor(x),y0=Math.floor(y),fx=x-x0,fy=y-y0;const at=(x,y)=>texData[(clamp(y,0,texH-1)*texW+clamp(x,0,texW-1))*4+1]/255;return thicknessFactor*((1-fy)*((1-fx)*at(x0,y0)+fx*at(x0+1,y0))+fy*((1-fx)*at(x0,y0+1)+fx*at(x0+1,y0+1)));}
function idx(a,v){let lo=0,hi=a.length-1;while(hi-lo>1){const m=(lo+hi)>>1;if(a[m]<=v)lo=m;else hi=m;}return lo;}
function interpAt(u,v){const ix=idx(xValues,u),iy=idx(yValues,v),fx=(u-xValues[ix])/(xValues[ix+1]-xValues[ix]),fy=(v-yValues[iy])/(yValues[iy+1]-yValues[iy]),a=iy*nx+ix,b=a+1,c=a+nx,d=c+1;const ids=fx>=fy?[a,b,d]:[a,d,c],ws=fx>=fy?[1-fx,fx-fy,fy]:[1-fy,fx,fy-fx];const pos=new THREE.Vector3(),norm=new THREE.Vector3();ids.forEach((id,i)=>{pos.addScaledVector(new THREE.Vector3().fromBufferAttribute(p,id),ws[i]);norm.addScaledVector(new THREE.Vector3().fromBufferAttribute(n,id),ws[i]);});pos.z+=.6;return{pos,norm:norm.normalize()};}
function refract(i,n,eta){const d=n.dot(i),k=1-eta*eta*(1-d*d);return k<0?new THREE.Vector3():i.clone().multiplyScalar(eta).addScaledVector(n,-(eta*d+Math.sqrt(k)));}
function screen(pt){const s=pt.clone().project(camera);return new THREE.Vector2(s.x*pane.width*.5,s.y*pane.height*.5);}
function resultAt(u,v){const {pos,norm}=interpAt(u,v),i=pos.clone().sub(camera.position).normalize(),r=refract(i,norm,1/1.5),t=tAt(u,v),e=pos.clone().addScaledVector(r,t),b=screen(e).distanceTo(screen(pos)),s=sampleGobangSurface(u,v,shape),normal=new THREE.Vector3(-s.dx,-s.dy,1).normalize(),front=new THREE.Vector3((u-.5)*shape.width,(v-.5)*shape.height,.6+s.height),inc=front.clone().sub(camera.position).normalize(),inside=refract(inc,normal,1/1.5),backZ=.6-.008-.085*shape.width/20,back=front.clone().addScaledVector(inside,(backZ-front.z)/inside.z),out=refract(inside,new THREE.Vector3(0,0,1),1.5);const aAt=z=>screen(back.clone().addScaledVector(out,(z-backZ)/out.z)).distanceTo(screen(front));
const aBasePoint=back.clone().addScaledVector(out,(.6-.1296-.25-backZ)/out.z),aNearPoint=back.clone().addScaledVector(out,(.6-.1296-backZ)/out.z);
const nearReprojected=camera.position.clone().addScaledVector(aNearPoint.clone().sub(camera.position),(.6-.1296-.25-camera.position.z)/(aNearPoint.z-camera.position.z));
const delta=nearReprojected.sub(aBasePoint);delta.z=0;delta.multiplyScalar(Math.min(1,(shape.width/20)*.35/Math.max(delta.length(),.00001))*.65);
const aNearRefined=screen(aBasePoint.clone().add(delta)).distanceTo(screen(front));
return{u,v,normal:norm.toArray(),thickness:t,axial:pos.z-backZ,mapError:t-(pos.z-backZ),normalBakeCorrection:1/norm.z,pathCorrection:1/-r.z,b,aNear:aAt(.6-.1296),aBase:aAt(.6-.1296-.25),aNearRefined};}
let results=[];for(let y=0;y<250;y++)for(let x=0;x<200;x++)results.push(resultAt((x+.5)/200,(y+.5)/250));
function stats(key){const vals=results.map(x=>x[key]).sort((a,b)=>a-b);return{min:vals[0],median:vals[(vals.length*.5)|0],p95:vals[(vals.length*.95)|0],max:vals.at(-1),mean:vals.reduce((a,b)=>a+b,0)/vals.length};}
const samples=[[.511,.509],[.5125,.5125],[.525,.525],[.505,.505],[.499,.5]].map(([u,v])=>resultAt(u,v));
const pixelProjectionWidth=screen(new THREE.Vector3(shape.width/2,0,.6)).x*2;
console.log(JSON.stringify({shape,pane,glassDisplayedCssWidth:pixelProjectionWidth,material:{thickness:thicknessFactor,thicknessMap:{width:texW,height:texH,flipY:tex.flipY,colorSpace:tex.colorSpace,wrapS:tex.wrapS,wrapT:tex.wrapT,greenMin:Math.min(...texData.filter((v,i)=>i%4===1)),greenMax:Math.max(...texData.filter((v,i)=>i%4===1))}},frontSegments:[nx-1,yValues.length-1],thickness:stats('thickness'),mapError:stats('mapError'),normalBakeCorrection:stats('normalBakeCorrection'),actualNearAInteriorCss:stats('aNearRefined'),pathCorrection:stats('pathCorrection'),stockOffsetCss:stats('b'),twoSurfaceNearCss:stats('aNear'),twoSurfaceBaseCss:stats('aBase'),samples},null,2));
+32
View File
@@ -0,0 +1,32 @@
/** Node + Pillow CPU fixture check. Does not launch or emulate browser/GPU QA. */
import assert from 'node:assert/strict';
import {readFile,readdir,mkdtemp} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {spawnSync} from 'node:child_process';
import {createHash} from 'node:crypto';
import {loadTypescript} from './support/load-typescript.mjs';
const root=await mkdtemp(join(tmpdir(),'behind-glass-photo-fixtures-'));
const python=(...args)=>{const r=spawnSync('python',['scripts/support/photo-fixtures.py',...args],{encoding:'utf8'});if(r.status)throw new Error(r.stderr);return JSON.parse(r.stdout);};
python('generate',root);
const {createPhotoSnapshot}=await loadTypescript(new URL('../src/input/PhotoSnapshot.ts',import.meta.url));
const {readPhotoMetadata}=await loadTypescript(new URL('../src/input/PhotoMetadata.ts',import.meta.url));
const rows=[];let closes=0;
globalThis.createImageBitmap=async(file,options)=>{assert.ok(options.resizeWidth<=1024&&options.resizeHeight<=1024,'all real fixture headers must enable first-decode bounds');const result=python('inspect',file.fixturePath,String(options.resizeWidth),String(options.resizeHeight));return{...result,close(){closes++;}};};
for(const name of await readdir(root)){
const path=join(root,name),bytes=await readFile(path),hash=createHash('sha256').update(bytes).digest('hex');
const blob=new Blob([bytes]);blob.fixturePath=path;
const metadata=await readPhotoMetadata(blob),snapshot=await createPhotoSnapshot(blob,rows.length,new AbortController().signal),decoded=snapshot.bitmap;
assert.equal(snapshot.width,metadata.width<=metadata.height?Math.round(1024*metadata.width/metadata.height):1024);
assert.equal(snapshot.height,metadata.height<=metadata.width?Math.round(1024*metadata.height/metadata.width):1024);
assert.equal(decoded.sha256,hash,'original bytes untouched');
assert.equal(createHash('sha256').update(await readFile(path)).digest('hex'),hash);
if(name.includes('alpha-rotated'))assert.deepEqual(decoded.corners.map(x=>x[3]),[64,255,0,192]);
if(name.includes('alpha-mirrored'))assert.deepEqual(decoded.corners.map(x=>x[3]),[64,0,255,192]);
if(name.includes('48mp'))assert.deepEqual(decoded.alphaExtrema,[128,128]);
snapshot.bitmap.close();
const {close,...values}=decoded;
rows.push({file:name,...values,pixelReductionPercent:100*(1-values.workingRGBABytes/values.sourceRGBABytes)});
}
assert.equal(closes,rows.length);
console.log(JSON.stringify({verification:'CPU Pillow decoder behind a test double for createImageBitmap; actual TypeScript header/resize pipeline and real JPEG/PNG/WebP fixture bytes. Not browser-native decode, WebGL upload, device memory or FPS.',fixtureDirectory:root,rows},null,2));
+30
View File
@@ -0,0 +1,30 @@
// Restore byte-identical editable source assets from the included server build.
import {copyFileSync,mkdirSync} from 'node:fs';
import {dirname,resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
const root=fileURLToPath(new URL('..',import.meta.url));
const files={
"public/THIRD-PARTY-NOTICES.txt": "dist/THIRD-PARTY-NOTICES.txt",
"public/fonts/BMKkubulim.woff": "dist/fonts/BMKkubulim.woff",
"public/licenses/Threejs-MIT.txt": "dist/licenses/Threejs-MIT.txt",
"public/licenses/HuggingFace-Jinja-MIT.txt": "dist/licenses/HuggingFace-Jinja-MIT.txt",
"public/licenses/Driverjs-MIT.txt": "dist/licenses/Driverjs-MIT.txt",
"public/licenses/ONNX-Runtime-ThirdPartyNotices.txt": "dist/licenses/ONNX-Runtime-ThirdPartyNotices.txt",
"public/licenses/Apache-2.0.txt": "dist/licenses/Apache-2.0.txt",
"public/licenses/ONNX-Runtime-MIT.txt": "dist/licenses/ONNX-Runtime-MIT.txt",
"public/licenses/BMKkubulim-OFL.txt": "dist/licenses/BMKkubulim-OFL.txt",
"public/assets/samples/dog3.jpg": "dist/assets/samples/dog3.jpg",
"public/assets/samples/dog5.jpg": "dist/assets/samples/dog5.jpg",
"public/assets/samples/dog2.jpg": "dist/assets/samples/dog2.jpg",
"public/assets/samples/dog4.jpg": "dist/assets/samples/dog4.jpg",
"public/assets/samples/dog1.jpg": "dist/assets/samples/dog1.jpg",
"public/assets/window/grandma-house-surround.glb": "dist/assets/window/grandma-house-surround.glb",
"public/assets/window/grandma-wood-sash.glb": "dist/assets/window/grandma-wood-sash.glb",
"public/models/u2netp/LICENSES.txt": "dist/models/u2netp/LICENSES.txt",
"public/models/u2netp/manifest.json": "dist/models/u2netp/manifest.json",
"public/models/u2netp/model.onnx": "dist/models/u2netp/model.onnx",
"docs/qa-five-photos/reference2.png": "dist/assets/reference2-6y7JVIce.png",
"docs/qa-five-photos/reference1.png": "dist/assets/reference1-D4rK2azX.png"
};
for(const [target,source] of Object.entries(files)){const to=resolve(root,target);mkdirSync(dirname(to),{recursive:true});copyFileSync(resolve(root,source),to);}
console.log('Source assets restored:',Object.keys(files).length);
@@ -0,0 +1,18 @@
/** Capture Three-expanded courtyard/photo sources for optional offline compilers.
* This script creates no WebGL context and does not claim browser or GPU QA. */
import fs from 'node:fs';
import * as THREE from 'three';
import {WebGLProgram} from 'three/src/renderers/webgl/WebGLProgram.js';
import {loadTypescript} from './load-typescript.mjs';
const {ExperimentalCourtyard}=await loadTypescript(new URL('../../src/objects/ExperimentalCourtyard.ts',import.meta.url));
const {PetDepthMesh}=await loadTypescript(new URL('../../src/objects/PetDepthMesh.ts',import.meta.url));
const {createHousePhotoOccluder}=await loadTypescript(new URL('../../src/objects/BlenderHouseSurround.ts',import.meta.url));
const courtyard=new ExperimentalCourtyard(),photo=new PetDepthMesh(new THREE.Texture(),new THREE.Texture()),occluder=createHousePhotoOccluder(photo);
for(const [name,material] of [['courtyard',courtyard.group.children[0].material],['peeking-photo',photo.material],['peeking-occluder',occluder.mesh.material]]){
const params={vertexShader:material.vertexShader,fragmentShader:material.fragmentShader,uniforms:material.uniforms,defines:material.defines,precision:'highp',shaderType:'ShaderMaterial',shaderName:name,isRawShaderMaterial:false,toneMapping:THREE.NoToneMapping,outputColorSpace:THREE.LinearSRGBColorSpace,numDirLights:0,numPointLights:0,numSpotLights:0,numSpotLightMaps:0,numRectAreaLights:0,numHemiLights:0,numDirLightShadows:0,numSpotLightShadows:0,numSpotLightShadowsWithMaps:0,numPointLightShadows:0,numClippingPlanes:0,numClipIntersection:0};
const sentinel=new Error('offline-source-capture-complete');
const gl={VERTEX_SHADER:0,FRAGMENT_SHADER:1,createProgram:()=>({}),createShader:type=>({type}),shaderSource:(shader,source)=>fs.writeFileSync(`/tmp/b-review-${name}.${shader.type===0?'vert':'frag'}`,source),compileShader:shader=>{if(shader.type===1)throw sentinel;}};
try{new WebGLProgram({getContext:()=>gl},name,params,{});throw new Error(`Missing capture for ${name}`);}catch(error){if(error!==sentinel)throw error;console.log(`Captured r169 ${name} shader sources (no GL execution)`);}
}
occluder.dispose();courtyard.dispose();photo.geometry.dispose();photo.material.dispose();
photo.material.uniforms.uTexture.value.dispose();photo.material.uniforms.uDepthMap.value.dispose();
@@ -0,0 +1,12 @@
/** Captures Three-expanded sources only. No WebGL context, browser, or GPU execution. */
import fs from 'node:fs';
import * as THREE from 'three';
import {WebGLProgram} from 'three/src/renderers/webgl/WebGLProgram.js';
import {loadTypescript} from './load-typescript.mjs';
const {attachMeshRefraction}=await loadTypescript(new URL('../../src/rendering/MeshRefraction.ts',import.meta.url));
const material=new THREE.MeshPhysicalMaterial({transmission:1});attachMeshRefraction(material,new THREE.Mesh());const shader={vertexShader:THREE.ShaderLib.physical.vertexShader,fragmentShader:THREE.ShaderLib.physical.fragmentShader,uniforms:{}};material.onBeforeCompile(shader,{});
for(const area of [0,1]){
const params={...shader,defines:material.defines,precision:'highp',shaderType:'MeshPhysicalMaterial',shaderName:'OfflineReview',transmission:true,thicknessMap:true,thicknessMapUv:'uv',envMap:true,envMapMode:THREE.CubeUVReflectionMapping,combine:THREE.MultiplyOperation,envMapCubeUVHeight:128,toneMapping:THREE.NoToneMapping,outputColorSpace:THREE.LinearSRGBColorSpace,numDirLights:0,numPointLights:0,numSpotLights:0,numSpotLightMaps:0,numRectAreaLights:area,numHemiLights:0,numDirLightShadows:0,numSpotLightShadows:0,numSpotLightShadowsWithMaps:0,numPointLightShadows:0,numClippingPlanes:1,numClipIntersection:0};
const gl={VERTEX_SHADER:0,FRAGMENT_SHADER:1,createProgram:()=>({}),createShader:type=>({type}),shaderSource:(shader,source)=>fs.writeFileSync(`/tmp/b-review-${area}.${shader.type===0?'vert':'frag'}`,source),compileShader:shader=>{if(shader.type===1)throw new Error('Captured both sources; deliberately no GL compiler or driver used');}};
try{new WebGLProgram({getContext:()=>gl},'offline-review',params,{});}catch(e){if(!e.message.startsWith('Captured'))throw e;console.log('Captured r169 shader sources for '+area+' area lights')}
}
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import * as R from './mesh-refraction-reference.mjs';
const {v3,makeDepthTexture,depthAt,photoSurface,triangleOracle,tracePhotoDDA}=R;
const F=Math.fround,A=(a,b)=>F(a+b),S=(a,b)=>F(a-b),M=(a,b)=>F(a*b),D=(a,b)=>F(a/b),V=a=>a.map(F),add=(a,b)=>a.map((x,i)=>A(x,b[i])),sub=(a,b)=>a.map((x,i)=>S(x,b[i])),mul=(a,b)=>a.map(x=>M(x,b)),dot=(a,b)=>A(A(M(a[0],b[0]),M(a[1],b[1])),M(a[2],b[2])),cross=(a,b)=>[S(M(a[1],b[2]),M(a[2],b[1])),S(M(a[2],b[0]),M(a[0],b[2])),S(M(a[0],b[1]),M(a[1],b[0]))],INF=F(1e20);
function triangle(o,r,a,b,c){let e1=sub(b,a),e2=sub(c,a),p=cross(r,e2),det=dot(e1,p);if(det<=F(1e-9))return INF;let s=sub(o,a),u=D(dot(s,p),det);if(u<F(-.00001)||u>F(1.00001))return INF;let q=cross(s,e1),v=D(dot(r,q),det);if(v<F(-.00001)||A(u,v)>F(1.00001))return INF;let t=D(dot(e2,q),det);return t>=0?t:INF;}
function shaderDDA(surface,origin,ray){const o=V(origin.toArray()),r=V(ray.toArray());let near=0,far=INF;const bounds=[[-.5,.5],[-.5,.5],[0,.25]];for(let i=0;i<3;i++){let [lo,hi]=bounds[i];if(Math.abs(r[i])<F(1e-9)){if(!(o[i]>=lo&&o[i]<=hi))return null;}else{let a=D(S(lo,o[i]),r[i]),b=D(S(hi,o[i]),r[i]);near=Math.max(near,Math.min(a,b));far=Math.min(far,Math.max(a,b));if(far<near)return null;}}
let p=add(o,mul(r,near)),direction=r.slice(0,2).map(Math.sign),cell=p.slice(0,2).map((v,i)=>Math.min(127,Math.max(0,Math.floor(A(M(A(v,.5),128),M(direction[i],.00001)))))),next=[INF,INF],delta=[INF,INF];for(let i=0;i<2;i++)if(Math.abs(r[i])>=F(1e-9)){next[i]=D(S(S(D(A(cell[i],r[i]>=0?1:0),128),.5),o[i]),r[i]);delta[i]=D(1,M(128,Math.abs(r[i])));}
const vert=(x,y)=>[S(D(x,128),.5),S(D(y,128),.5),M(F(depthAt(surface.texture,D(x,128),D(y,128))),.25)];
for(let i=0;i<257;i++){let ll=vert(cell[0],cell[1]),lr=vert(A(cell[0],1),cell[1]),ul=vert(cell[0],A(cell[1],1)),ur=vert(A(cell[0],1),A(cell[1],1));let t=Math.min(triangle(o,r,ul,ll,ur),triangle(o,r,ll,lr,ur)),end=Math.min(far,...next);if(t>=S(near,.00001)&&t<=A(end,.00001)&&t<F(1e19))return{t,point:add(o,mul(r,t)),cells:i+1};if(end>=S(far,.000001))return null;let cx=next[0]<=next[1],cy=next[1]<=next[0];near=end;if(cx){cell[0]=A(cell[0],direction[0]);next[0]=A(next[0],delta[0]);}if(cy){cell[1]=A(cell[1],direction[1]);next[1]=A(next[1],delta[1]);}if(cell.some(v=>v<0||v>=128))return null;}throw Error('257 cells exhausted');}
const surface=photoSurface(makeDepthTexture(67,53,(x,y)=>.5+.21*Math.sin(x*1.2)+.25*Math.cos(y*.9+x*.4)));
let seed=0x5eed,random=()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/2**32;},cases=[];
for(let i=0;i<1500;i++){const o=v3((random()-.5)*1.8,(random()-.5)*1.8,.4+random()),target=v3((random()-.5)*1.2,(random()-.5)*1.2,random()*.2);cases.push([o,target.sub(o).normalize()]);}
for(const x of [-.5,-.25,0,.25,.5])for(const y of [-.5,-.25,0,.25,.5])for(const dx of [-.3,0,.3])for(const dy of [-.3,0,.3])cases.push([v3(x,y,1),v3(dx,dy,-1).normalize()]);
for(let i=0;i<20;i++)cases.push([v3(-.6,-.45+i*.02,.249),v3(1,.85,-.0001).normalize()]);
let misses=0,worst=0,maxcells=0;
for(let i=0;i<cases.length;i++){const [o,r]=cases[i],actual=shaderDDA(surface,o,r),expected=tracePhotoDDA(surface,o,r).hit;assert.equal(Boolean(actual),Boolean(expected),`hit/miss ray ${i}`);if(actual){const error=v3(...actual.point).distanceTo(expected.point);worst=Math.max(worst,error);maxcells=Math.max(maxcells,actual.cells);assert.ok(error<.0001,`ray ${i} error ${error}`);}else misses++;if(i<40){const oracle=triangleOracle(surface,o,r);assert.equal(Boolean(actual),Boolean(oracle));}}
console.log(JSON.stringify({kind:'CPU Float32 arithmetic mirror of production DDA and Moller triangles; not GPU',rays:cases.length,misses,worstHitPointError:worst,maxcells}));
const longSurface=photoSurface(makeDepthTexture(2,2,(x,y)=>x*y));let longWorst=0,longMax=0,longCases=0;
for(let i=0;i<80;i++){const o=v3(-.6,-.45+i*.005,.249),r=v3(1,.85,-.0001).normalize(),a=shaderDDA(longSurface,o,r),e=tracePhotoDDA(longSurface,o,r).hit;assert.equal(Boolean(a),Boolean(e),`long ray ${i}`);if(a){longMax=Math.max(longMax,a.cells);longWorst=Math.max(longWorst,v3(...a.point).distanceTo(e.point));assert.ok(v3(...a.point).distanceTo(e.point)<.0001);}longCases++;}
console.log(JSON.stringify({kind:'CPU Float32 long XY traversal',rays:longCases,worstHitPointError:longWorst,maxcells:longMax}));
@@ -0,0 +1,18 @@
import ctypes as C,re,sys
from pathlib import Path
s=C.CDLL('/usr/lib/x86_64-linux-gnu/libshaderc.so.1')
def fn(name,restype,args):
f=getattr(s,name);f.restype=restype;f.argtypes=args;return f
init=fn('shaderc_compiler_initialize',C.c_void_p,[]); optinit=fn('shaderc_compile_options_initialize',C.c_void_p,[])
setenv=fn('shaderc_compile_options_set_target_env',None,[C.c_void_p,C.c_int,C.c_uint]); auto=fn('shaderc_compile_options_set_auto_bind_uniforms',None,[C.c_void_p,C.c_bool]); loc=fn('shaderc_compile_options_set_auto_map_locations',None,[C.c_void_p,C.c_bool]); compile=fn('shaderc_compile_into_spv',C.c_void_p,[C.c_void_p,C.c_char_p,C.c_size_t,C.c_int,C.c_char_p,C.c_char_p,C.c_void_p]);status=fn('shaderc_result_get_compilation_status',C.c_int,[C.c_void_p]); msg=fn('shaderc_result_get_error_message',C.c_char_p,[C.c_void_p]);release=fn('shaderc_result_release',None,[C.c_void_p])
c=init(); o=optinit();setenv(o,1,450);auto(o,True);loc(o,True)
failed=0
for p in sorted(p for p in Path('/tmp').glob('b-review-*.*') if p.suffix in ('.vert','.frag')):
src=p.read_text().replace('#version 300 es','#version 310 es',1)
src=re.sub(r'\baverage\b','threeAverage',src) # 'average' becomes reserved after production GLSL ES 300
src=src.encode();r=compile(c,src,len(src),0 if p.suffix=='.vert' else 1,str(p).encode(),b'main',o)
code=status(r);failed+=code!=0
print(p.name,'status:',code,msg(r).decode());release(r)
print('CPU/offline shaderc only; version raised 300 es -> 310 es solely for SPIR-V compiler acceptance and stock average() renamed to avoid later-version reserved keyword; auto bindings/locations. No changes to B code. No WebGL or GPU execution.')
sys.exit(1 if failed else 0)
+17
View File
@@ -0,0 +1,17 @@
// Offline composition proof only: no browser, WebGL optics, or user photos.
import {writeFileSync} from 'node:fs';import * as T from 'three';
import {loadTypescript} from './load-typescript.mjs';import {loadFrameGeometry} from './load-frame-geometry.mjs';
const {prepareHouseAsset}=await loadTypescript(new URL('../../src/objects/BlenderHouseSurround.ts',import.meta.url));
const {prepareFrameAsset,fitWindowScale}=await loadTypescript(new URL('../../src/objects/BlenderWindowFrame.ts',import.meta.url));
const {CameraController}=await loadTypescript(new URL('../../src/input/CameraController.ts',import.meta.url));
const views=[];for(const [label,aspect]of[['portrait',375/812],['landscape',844/390]]){
const f=fitWindowScale(aspect,.8)*.82,w=f*.8*.8,h=f*.8,s=Math.min(w,h),room=loadFrameGeometry(new URL('../../public/assets/window/grandma-house-surround.glb',import.meta.url)),frame=loadFrameGeometry();
prepareHouseAsset(room).resize(w,h,.6);prepareFrameAsset(frame).resize(w,h,.6);const root=new T.Group();root.add(room,frame);root.updateMatrixWorld(true);
const meshes=[];root.traverse(m=>{if(!m.isMesh)return;const g=m.geometry.clone().applyMatrix4(m.matrixWorld);meshes.push({name:m.name,positions:[...g.attributes.position.array],indices:g.index?[...g.index.array]:Array.from({length:g.attributes.position.count},(_,i)=>i),colors:g.attributes.color?[...g.attributes.color.array]:null});});
for(const variant of['left','right','neutral']){
const c=new T.PerspectiveCamera(40,aspect,.1,100),control=new CameraController(c);control.setRoomBounds(w,h,.6);control.setView(variant==='left'?-1:variant==='right'?1:0,0);control.update(10,1);
c.updateMatrixWorld(true);
views.push({name:label+'-'+variant,aspect,fov:c.fov,matrix:c.matrixWorld.toArray(),meshes});
}
}
writeFileSync('/tmp/corner-qa/scenes.json',JSON.stringify(views));
@@ -0,0 +1,17 @@
// Offline wall-hug geometry evidence only; no WebGL/photo rendering.
import {writeFileSync} from 'node:fs';import * as T from 'three';
import {loadTypescript} from './load-typescript.mjs';import {loadFrameGeometry} from './load-frame-geometry.mjs';
const {prepareHouseAsset}=await loadTypescript(new URL('../../src/objects/BlenderHouseSurround.ts',import.meta.url));
const {prepareFrameAsset,fitWindowScale}=await loadTypescript(new URL('../../src/objects/BlenderWindowFrame.ts',import.meta.url));
const {CameraController}=await loadTypescript(new URL('../../src/input/CameraController.ts',import.meta.url));
const views=[];for(const [label,aspect]of[['portrait',375/812],['landscape',844/390]]){
const f=fitWindowScale(aspect,.8)*.82,w=f*.8*.8,h=f*.8,s=Math.min(w,h),room=loadFrameGeometry(new URL('../../public/assets/window/grandma-house-surround.glb',import.meta.url)),frame=loadFrameGeometry();
prepareHouseAsset(room).resize(w,h,.6,undefined,true);prepareFrameAsset(frame).resize(w,h,.6);const root=new T.Group();root.add(room,frame);root.updateMatrixWorld(true);
const meshes=[];root.traverse(m=>{if(!m.isMesh)return;const g=m.geometry.clone().applyMatrix4(m.matrixWorld);meshes.push({name:m.name,positions:[...g.attributes.position.array],indices:g.index?[...g.index.array]:Array.from({length:g.attributes.position.count},(_,i)=>i),colors:g.attributes.color?[...g.attributes.color.array]:null});});
for(const variant of['left','right','middle','neutral']){
const c=new T.PerspectiveCamera(40,aspect,.1,100),control=new CameraController(c);control.setRoomBounds(w,h,.6);control.setCompositionMode('wall');control.setView(variant==='left'?-1:variant==='right'?1:variant==='middle'?.65:0,0);control.update(10,1);
c.updateMatrixWorld(true);
views.push({name:label+'-'+variant,aspect,fov:c.fov,matrix:c.matrixWorld.toArray(),meshes});
}
}
writeFileSync('/tmp/wall-final-qa/scenes.json',JSON.stringify(views));
+13
View File
@@ -0,0 +1,13 @@
import * as THREE from 'three';
import {readFileSync} from 'node:fs';
/** Geometry-only GLB reader for deterministic Node tests; browser uses GLTFLoader. */
export function loadFrameGeometry(file=new URL('../../public/assets/window/grandma-wood-sash.glb',import.meta.url)){
const bytes=readFileSync(file);
const length=bytes.readUInt32LE(12),gltf=JSON.parse(bytes.subarray(20,20+length).toString()),bin=bytes.subarray(28+length);
const types={5121:Uint8Array,5126:Float32Array,5123:Uint16Array,5125:Uint32Array},counts={SCALAR:1,VEC2:2,VEC3:3,VEC4:4};
const accessor=i=>{const a=gltf.accessors[i],v=gltf.bufferViews[a.bufferView],T=types[a.componentType],n=counts[a.type],array=new T(a.count*n);for(let row=0;row<a.count;row++)for(let k=0;k<n;k++){const offset=(v.byteOffset??0)+(a.byteOffset??0)+row*(v.byteStride??n*T.BYTES_PER_ELEMENT)+k*T.BYTES_PER_ELEMENT;array[row*n+k]=a.componentType===5126?bin.readFloatLE(offset):a.componentType===5123?bin.readUInt16LE(offset):a.componentType===5121?bin.readUInt8(offset):bin.readUInt32LE(offset);}return new THREE.BufferAttribute(array,n,a.normalized??false);};
const materials=gltf.materials.map(m=>new THREE.MeshStandardMaterial({name:m.name,side:m.doubleSided?THREE.DoubleSide:THREE.FrontSide}));
const nodes=gltf.nodes.map(n=>{let obj=new THREE.Group();if(n.mesh!==undefined){const primitive=gltf.meshes[n.mesh].primitives[0],geometry=new THREE.BufferGeometry();for(const [key,value] of Object.entries(primitive.attributes))geometry.setAttribute({POSITION:'position',NORMAL:'normal',TEXCOORD_0:'uv',TANGENT:'tangent',COLOR_0:'color'}[key],accessor(value));geometry.setIndex(accessor(primitive.indices));obj=new THREE.Mesh(geometry,materials[primitive.material]);}obj.name=n.name;if(n.translation)obj.position.fromArray(n.translation);return obj;});
gltf.nodes.forEach((node,i)=>(node.children??[]).forEach(c=>nodes[i].add(nodes[c])));
const scene=new THREE.Group();gltf.scenes[gltf.scene??0].nodes.forEach(i=>scene.add(nodes[i]));return scene;
}
+10
View File
@@ -0,0 +1,10 @@
import { readFile } from 'node:fs/promises';
import ts from 'typescript';
const cache=new Map();
export async function loadTypescript(file){const url=file instanceof URL?file:new URL(file,import.meta.url);return import(await moduleURL(url));}
async function moduleURL(url){
if(cache.has(url.href))return cache.get(url.href);
let code=ts.transpileModule(await readFile(url,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2020,module:ts.ModuleKind.ESNext}}).outputText;
for(const match of [...code.matchAll(/(?:from\s*|import\s*)['"]([^'"]+)['"]/g)]){const name=match[1];let ref;if(name.endsWith('?raw'))ref='data:text/javascript;base64,'+Buffer.from('export default '+JSON.stringify(await readFile(new URL(name.slice(0,-4),url),'utf8'))).toString('base64');else if(name.startsWith('.'))ref=await moduleURL(new URL(name.endsWith('.ts')?name:name+'.ts',url));else if(!name.startsWith('data:')&&!name.startsWith('file:'))ref=import.meta.resolve(name);if(ref)code=code.replaceAll("'"+name+"'",JSON.stringify(ref)).replaceAll(JSON.stringify(name),JSON.stringify(ref));}
code=code.replaceAll('import.meta.url',JSON.stringify(url.href));const result='data:text/javascript;base64,'+Buffer.from(code).toString('base64');cache.set(url.href,result);return result;
}
@@ -0,0 +1,149 @@
/** Test-only, CPU numerical reference. Never imported by the renderer.
* Photo intersections use analytic per-triangle height planes and a bounded XY DDA;
* the independent oracle uses Three's indexed PlaneGeometry and Ray.intersectTriangle.
*/
import * as THREE from 'three';
const EPS = 1e-10;
export const PHOTO_SEGMENTS = 128;
export const MAX_DDA_CELLS = 257;
export const v3 = (x=0,y=0,z=0) => new THREE.Vector3(x,y,z);
export function makeDepthTexture(width, height, sample) {
const data = new Uint8Array(width*height*4);
for(let y=0;y<height;y++) for(let x=0;x<width;x++) {
const n=Math.round(255*THREE.MathUtils.clamp(sample(x,y),0,1)), i=4*(y*width+x);
data[i]=data[i+1]=data[i+2]=n; data[i+3]=255;
}
const texture=new THREE.DataTexture(data,width,height);
texture.minFilter=texture.magFilter=THREE.LinearFilter;
texture.wrapS=texture.wrapT=THREE.ClampToEdgeWrapping;
texture.generateMipmaps=false;
return texture;
}
/** GL normalized LinearFilter sampling at explicit LOD zero; Uint8 -> [0,1]. */
export function depthAt(texture,u,v) {
const {data,width,height}=texture.image;
if(texture.flipY)v=1-v;
const x=u*width-.5,y=v*height-.5,x0=Math.floor(x),y0=Math.floor(y),fx=x-x0,fy=y-y0;
const fetch=(i,j)=>data[4*(THREE.MathUtils.clamp(j,0,height-1)*width+THREE.MathUtils.clamp(i,0,width-1))]/255;
return THREE.MathUtils.lerp(THREE.MathUtils.lerp(fetch(x0,y0),fetch(x0+1,y0),fx),THREE.MathUtils.lerp(fetch(x0,y0+1),fetch(x0+1,y0+1),fx),fy);
}
export function photoSurface(texture,{segments=128,depthScale=.25,depthBias=0,matrix=new THREE.Matrix4()}={}) {
if(!Number.isInteger(segments)||segments<1||segments>128) throw new Error('Photo grid must be 1..128');
const heights=new Float64Array((segments+1)**2);
for(let y=0;y<=segments;y++) for(let x=0;x<=segments;x++) heights[y*(segments+1)+x]=(depthAt(texture,x/segments,y/segments)-depthBias)*depthScale;
const geometry=new THREE.PlaneGeometry(1,1,segments,segments),p=geometry.attributes.position,uv=geometry.attributes.uv;
for(let i=0;i<p.count;i++) p.setZ(i,(depthAt(texture,uv.getX(i),uv.getY(i))-depthBias)*depthScale);
return {texture,segments,heights,geometry,matrix:matrix.clone(),inverse:matrix.clone().invert(),minZ:Math.min(...heights),maxZ:Math.max(...heights)};
}
export function localRay(surface,origin,direction) {
return {origin:origin.clone().applyMatrix4(surface.inverse),direction:direction.clone().applyMatrix3(new THREE.Matrix3().setFromMatrix4(surface.inverse))};
}
export function triangleOracle(surface,origin,direction) {
const local=localRay(surface,origin,direction),ray=new THREE.Ray(local.origin,local.direction),p=surface.geometry.attributes.position,indices=surface.geometry.index;
const a=v3(),b=v3(),c=v3(),hit=v3();let result=null,minT=Infinity;
for(let i=0;i<indices.count;i+=3) {
a.fromBufferAttribute(p,indices.getX(i));b.fromBufferAttribute(p,indices.getX(i+1));c.fromBufferAttribute(p,indices.getX(i+2));
if(!ray.intersectTriangle(a,b,c,true,hit))continue;
const t=hit.clone().sub(local.origin).dot(local.direction)/local.direction.lengthSq();
if(t>=-EPS&&t<minT) {minT=t;result={t,local:hit.clone(),point:hit.clone().applyMatrix4(surface.matrix),uv:new THREE.Vector2(hit.x+.5,hit.y+.5),triangle:i/3};}
}
return result;
}
/** Returns nearest actual triangle hit, including multiple height crossings. */
export function tracePhotoDDA(surface,origin,direction) {
const {origin:o,direction:d}=localRay(surface,origin,direction),n=surface.segments,h=surface.heights;
let enter=0,leave=Infinity,cells=0;
for(const [axis,lo,hi] of [['x',-.5,.5],['y',-.5,.5],['z',surface.minZ,surface.maxZ]]) {
if(Math.abs(d[axis])<1e-14) {if(o[axis]<lo-EPS||o[axis]>hi+EPS)return {hit:null,cells};continue;}
const a=(lo-o[axis])/d[axis],b=(hi-o[axis])/d[axis];enter=Math.max(enter,Math.min(a,b));leave=Math.min(leave,Math.max(a,b));
}
if(leave<enter-EPS)return {hit:null,cells};
const at=o.clone().addScaledVector(d,enter),grid=(x)=>THREE.MathUtils.clamp(Math.floor((x+.5)*n),0,n-1);
let ix=grid(at.x),iy=grid(at.y),t=enter;
const sx=Math.sign(d.x),sy=Math.sign(d.y);
const next=(axis,cell,step)=>step===0?Infinity:((cell+(step>0?1:0))/n-.5-o[axis])/d[axis];
let tx=next('x',ix,sx),ty=next('y',iy,sy);
for(let step=0;step<MAX_DDA_CELLS;step++) {
cells++;
const end=Math.min(tx,ty,leave),k=iy*(n+1)+ix,z00=h[k],z10=h[k+1],z01=h[k+n+1],z11=h[k+n+2];
let best=Infinity;
// The two exact PlaneGeometry triangles share the lower-left/upper-right diagonal.
for(const lower of [true,false]) {
const ax=lower?z10-z00:z11-z01,ay=lower?z11-z10:z01-z00;
const fx0=(o.x+.5)*n-ix,fy0=(o.y+.5)*n-iy;
const denom=d.z-n*(ax*d.x+ay*d.y);
if(denom>=-1e-14)continue; // FrontSide: approach the displaced photo front.
const candidate=(z00+ax*fx0+ay*fy0-o.z)/denom;
const fx=fx0+n*d.x*candidate,fy=fy0+n*d.y*candidate;
if(candidate<t-EPS||candidate>end+EPS||candidate<0||fx<-EPS||fy<-EPS||fx>1+EPS||fy>1+EPS)continue;
if(lower?fy>fx+EPS:fx>fy+EPS)continue;
best=Math.min(best,candidate);
}
if(Number.isFinite(best)) {const local=o.clone().addScaledVector(d,best);return {hit:{t:best,local,point:local.clone().applyMatrix4(surface.matrix),uv:new THREE.Vector2(local.x+.5,local.y+.5)},cells};}
if(end>=leave-EPS)return {hit:null,cells};
const crossX=tx<=ty+EPS,crossY=ty<=tx+EPS;t=end;
if(crossX) {ix+=sx;tx=next('x',ix,sx);}if(crossY) {iy+=sy;ty=next('y',iy,sy);}
if(ix<0||ix>=n||iy<0||iy>=n)return {hit:null,cells};
}
throw new Error(`DDA exceeded ${MAX_DDA_CELLS} cells`);
}
/** GLSL refract convention: normal opposes incident direction. */
export function snell(incident,normal,eta) {
const i=incident.clone().normalize(),n=normal.clone().normalize(),dot=i.dot(n),k=1-eta*eta*(1-dot*dot);
if(k<0)return null;
return i.multiplyScalar(eta).addScaledVector(n,-eta*dot-Math.sqrt(k)).normalize();
}
/** Planar rear and finite side exits in local glass coordinates. */
export function exitGlass(entry,inside,{width,height,backZ}) {
const candidates=[];
for(const [axis,bounds] of [['x',[-width/2,width/2]],['y',[-height/2,height/2]],['z',[backZ]]]) {
if(Math.abs(inside[axis])<1e-14)continue;
for(const value of bounds) {const t=(value-entry[axis])/inside[axis];if(t>EPS)candidates.push({t,axis,value});}
}
candidates.sort((a,b)=>a.t-b.t);
for(const c of candidates) {
const point=entry.clone().addScaledVector(inside,c.t);
if(Math.abs(point.x)>width/2+EPS||Math.abs(point.y)>height/2+EPS||point.z<backZ-EPS)continue;
const normal=v3();normal[c.axis]=c.axis==='z'?-1:Math.sign(c.value);
return {point,normal,t:c.t,face:c.axis==='z'?'rear':`${c.axis}${Math.sign(c.value)>0?'+':'-'}`};
}
return null;
}
export function traceGlass({entry,normal,incident,shape,ior=1.5,surface}) {
const inside=snell(incident,normal,1/ior);if(!inside)return {status:'entry-tir'};
const exit=exitGlass(entry,inside,shape);if(!exit)return {status:'no-exit'};
const outgoing=snell(inside,exit.normal.clone().negate(),ior);if(!outgoing)return {status:'tir',inside,exit};
const result=tracePhotoDDA(surface,exit.point,outgoing);
return {status:result.hit?'hit':'miss',inside,exit,outgoing,...result};
}
/** Stock r169 only advances its refracted ray by material thickness, projects that
* endpoint, and looks up the already camera-rendered scene at that screen point. */
export function stockR169PhotoHit({entry,normal,incident,camera,thickness,ior=1.5,surface}) {
const inside=snell(incident,normal,1/ior);
if(!inside)return null;
const endpoint=entry.clone().addScaledVector(inside,thickness);
return tracePhotoDDA(surface,camera,endpoint.sub(camera).normalize()).hit;
}
/** Actual mesh intersection and perspective-correct interpolated vertex normal. */
export function meshEntry(geometry,camera,target) {
const material=new THREE.MeshBasicMaterial({side:THREE.FrontSide}),mesh=new THREE.Mesh(geometry,material);
mesh.updateMatrixWorld(true);
const incident=target.clone().sub(camera).normalize(),hit=new THREE.Raycaster(camera,incident).intersectObject(mesh,false)[0];
material.dispose();
if(!hit)return null;
const p=geometry.attributes.position,n=geometry.attributes.normal,a=v3().fromBufferAttribute(p,hit.face.a),b=v3().fromBufferAttribute(p,hit.face.b),c=v3().fromBufferAttribute(p,hit.face.c);
const bary=THREE.Triangle.getBarycoord(hit.point,a,b,c,v3());
const normal=v3().fromBufferAttribute(n,hit.face.a).multiplyScalar(bary.x).addScaledVector(v3().fromBufferAttribute(n,hit.face.b),bary.y).addScaledVector(v3().fromBufferAttribute(n,hit.face.c),bary.z).normalize();
return {entry:hit.point,normal,incident,bary,face:hit.face};
}
+42
View File
@@ -0,0 +1,42 @@
"""Reproducible synthetic fixtures and CPU-only codec oracle, never browser QA."""
import sys, json, hashlib
from pathlib import Path
from PIL import Image, ImageOps
import numpy as np
if sys.argv[1] == 'generate':
root = Path(sys.argv[2]); root.mkdir(parents=True, exist_ok=True)
rng = np.random.default_rng(20261007)
width, height = 4032, 3024
pixels = rng.integers(0, 256, (height, width, 3), dtype=np.uint8)
rgb = Image.fromarray(pixels)
alpha = Image.new('L', (width, height), 0)
alpha.paste(64, (width//2, 0, width, height//2))
alpha.paste(192, (0, height//2, width//2, height))
alpha.paste(255, (width//2, height//2, width, height))
rgba = rgb.copy(); rgba.putalpha(alpha)
for name, image, orientation, options in [
('phone-12mp-rotated.jpg', rgb, 6, {'quality':95}),
('phone-12mp-alpha-rotated.png', rgba, 8, {}),
('phone-12mp-alpha-mirrored.webp', rgba, 2, {'lossless':True}),
]:
exif = Image.Exif(); exif[274] = orientation
image.save(root / name, exif=exif, **options)
# Large pixel count with tiny encoded size exercises the pixel resource guard.
Image.new('RGBA', (8000,6000), (19,101,224,128)).save(root/'phone-48mp-alpha.png')
print(json.dumps({'directory':str(root),'files':[p.name for p in sorted(root.iterdir())]}))
else:
path = Path(sys.argv[2])
with Image.open(path) as source:
source_width, source_height = source.size
orientation = source.getexif().get(274, 1)
oriented = ImageOps.exif_transpose(source).convert('RGBA')
if len(sys.argv) > 3:
oriented = oriented.resize((int(sys.argv[3]), int(sys.argv[4])), Image.Resampling.LANCZOS)
corners = [oriented.getpixel(point) for point in [(0,0),(oriented.width-1,0),(0,oriented.height-1),(oriented.width-1,oriented.height-1)]]
# Verify transparent/partial-alpha values survive a local lossless PNG adapter.
import io
encoded = io.BytesIO(); oriented.save(encoded, format='PNG')
decoded = Image.open(io.BytesIO(encoded.getvalue())).convert('RGBA')
assert decoded.tobytes() == oriented.tobytes()
print(json.dumps({'width':oriented.width,'height':oriented.height,'sourceWidth':source_width,'sourceHeight':source_height,'orientation':orientation,'corners':corners,'alphaExtrema':oriented.getchannel('A').getextrema(),'sourceBytes':path.stat().st_size,'workingPngBytes':len(encoded.getvalue()),'sourceRGBABytes':source_width*source_height*4,'workingRGBABytes':oriented.width*oriented.height*4,'sha256':hashlib.sha256(path.read_bytes()).hexdigest(),'pngRoundTripExact':True}))
+23
View File
@@ -0,0 +1,23 @@
import bpy,json,math
from mathutils import Matrix
views=json.load(open('/tmp/corner-qa/scenes.json'))
for v in views:
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
for item in v['meshes']:
p=item['positions'];ind=item['indices'];mesh=bpy.data.meshes.new(item['name']);mesh.from_pydata(list(zip(p[::3],p[1::3],p[2::3])),[],list(zip(ind[::3],ind[1::3],ind[2::3])));mesh.update();ob=bpy.data.objects.new(item['name'],mesh);bpy.context.collection.objects.link(ob)
name=item['name'];color=(.67,.61,.49,1) if 'Plaster' in name or 'Shell' in name else (.3,.16,.08,1) if 'Wood' in name or 'Picture' in name else (.55,.51,.43,1)
if 'Curtain' in name:color=(.88,.85,.72,1)
mat=bpy.data.materials.new(name);mat.diffuse_color=color;ob.data.materials.append(mat)
if 'Picture' in name and item['colors']:
palette={};co=item['colors']
for poly in mesh.polygons:
i=poly.vertices[0];rgb=tuple(round(x,5) for x in co[i*3:i*3+3])
if rgb not in palette:
m=bpy.data.materials.new('Print');m.diffuse_color=(*rgb,1);ob.data.materials.append(m);palette[rgb]=len(ob.data.materials)-1
poly.material_index=palette[rgb]
if item['colors']:
attr=mesh.color_attributes.new(name='Color',type='FLOAT_COLOR',domain='POINT');co=item['colors']
for i,d in enumerate(attr.data):d.color=(*co[i*3:i*3+3],1)
camera=bpy.data.cameras.new('Camera');ob=bpy.data.objects.new('Camera',camera);bpy.context.collection.objects.link(ob);a=v['matrix'];ob.matrix_world=Matrix([[a[r+c*4] for c in range(4)]for r in range(4)]);camera.type='PERSP';camera.sensor_fit='VERTICAL';camera.sensor_height=24;camera.lens=24/(2*math.tan(math.radians(v['fov'])/2));bpy.context.scene.camera=ob
scene=bpy.context.scene;scene.render.engine='BLENDER_WORKBENCH';scene.display.shading.light='STUDIO';scene.display.shading.color_type='MATERIAL';scene.display.shading.show_shadows=True;scene.display.shading.show_cavity=True;scene.display.shading.cavity_type='BOTH';scene.display.shading.background_type='WORLD';scene.world.color=(.15,.15,.15)
scene.render.resolution_y=750;scene.render.resolution_x=int(750*v['aspect']);scene.render.resolution_percentage=100;scene.render.image_settings.file_format='PNG';scene.render.filepath='/tmp/corner-qa/'+v['name']+'.png';bpy.ops.render.render(write_still=True)
@@ -0,0 +1,23 @@
import bpy,json,math
from mathutils import Matrix
views=json.load(open('/tmp/wall-final-qa/scenes.json'))
for v in views:
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
for item in v['meshes']:
p=item['positions'];ind=item['indices'];mesh=bpy.data.meshes.new(item['name']);mesh.from_pydata(list(zip(p[::3],p[1::3],p[2::3])),[],list(zip(ind[::3],ind[1::3],ind[2::3])));mesh.update();ob=bpy.data.objects.new(item['name'],mesh);bpy.context.collection.objects.link(ob)
name=item['name'];color=(.67,.61,.49,1) if 'Plaster' in name or 'Shell' in name else (.3,.16,.08,1) if 'Wood' in name or 'Picture' in name else (.55,.51,.43,1)
if 'Curtain' in name:color=(.88,.85,.72,1)
mat=bpy.data.materials.new(name);mat.diffuse_color=color;ob.data.materials.append(mat)
if 'Picture' in name and item['colors']:
palette={};co=item['colors']
for poly in mesh.polygons:
i=poly.vertices[0];rgb=tuple(round(x,5) for x in co[i*3:i*3+3])
if rgb not in palette:
m=bpy.data.materials.new('Print');m.diffuse_color=(*rgb,1);ob.data.materials.append(m);palette[rgb]=len(ob.data.materials)-1
poly.material_index=palette[rgb]
if item['colors']:
attr=mesh.color_attributes.new(name='Color',type='FLOAT_COLOR',domain='POINT');co=item['colors']
for i,d in enumerate(attr.data):d.color=(*co[i*3:i*3+3],1)
camera=bpy.data.cameras.new('Camera');ob=bpy.data.objects.new('Camera',camera);bpy.context.collection.objects.link(ob);a=v['matrix'];ob.matrix_world=Matrix([[a[r+c*4] for c in range(4)]for r in range(4)]);camera.type='PERSP';camera.sensor_fit='VERTICAL';camera.sensor_height=24;camera.lens=24/(2*math.tan(math.radians(v['fov'])/2));bpy.context.scene.camera=ob
scene=bpy.context.scene;scene.render.engine='BLENDER_WORKBENCH';scene.display.shading.light='STUDIO';scene.display.shading.color_type='MATERIAL';scene.display.shading.show_shadows=True;scene.display.shading.show_cavity=True;scene.display.shading.cavity_type='BOTH';scene.display.shading.background_type='WORLD';scene.world.color=(.15,.15,.15)
scene.render.resolution_y=750;scene.render.resolution_x=int(750*v['aspect']);scene.render.resolution_percentage=100;scene.render.image_settings.file_format='PNG';scene.render.filepath='/tmp/wall-final-qa/'+v['name']+'.png';bpy.ops.render.render(write_still=True)
+397
View File
@@ -0,0 +1,397 @@
import assert from 'node:assert/strict';
import { readFile, mkdtemp, writeFile, rm } from 'node:fs/promises';
import { test } from 'node:test';
import vm from 'node:vm';
import { pathToFileURL } from 'node:url';
import ts from 'typescript';
const gpu = { device: 'webgpu', dtype: 'fp32' };
const wasm = { device: 'wasm', dtype: 'q8' };
async function providerHarness(capture) {
const sourceUrl = new URL('../src/depth/AiDepthProvider.ts', import.meta.url);
const directory = await mkdtemp(new URL('../.ai-depth-provider-test-', import.meta.url));
const source = (await readFile(sourceUrl, 'utf8')).replace("'./DepthProcessor'", JSON.stringify(new URL('../src/depth/DepthProcessor.ts', import.meta.url).href));
const { outputText } = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ESNext } });
await writeFile(`${directory}/provider.mjs`, outputText);
const originalWorker = globalThis.Worker;
const originalCapture = globalThis.createImageBitmap;
const workers = [];
const bitmaps = [];
globalThis.Worker = class {
constructor() { this.messages = []; this.terminated = false; workers.push(this); }
postMessage(message) { this.messages.push(message); }
terminate() { this.terminated = true; }
};
globalThis.createImageBitmap = capture ?? (async () => { const bitmap = { width: 10, height: 10, closed: false, close() { this.closed = true; } }; bitmaps.push(bitmap); return bitmap; });
const { AiDepthProvider } = await import(pathToFileURL(`${directory}/provider.mjs`).href);
const statuses = [];
const provider = new AiDepthProvider((status) => statuses.push(status));
return { provider, workers, statuses, bitmaps, async cleanup() { provider.cancel(); globalThis.Worker = originalWorker; globalThis.createImageBitmap = originalCapture; await rm(directory, { recursive: true, force: true }); } };
}
const photo = { naturalWidth: 100, naturalHeight: 50 };
const tick = async () => { await Promise.resolve(); await Promise.resolve(); };
function replyWithResult(worker, run) {
const id = worker.messages.at(-1).id;
worker.onmessage({ data: { type: 'result', id, width: 1, height: 1, values: Uint8Array.of(255).buffer, normalizedValues: Float32Array.of(1).buffer, run } });
}
test('provider sends a fixed-model request and preserves measured execution metadata', async () => {
const h = await providerHarness();
try {
const pending = h.provider.getDepthMap(photo);
await tick();
const worker = h.workers[0];
const requestKeys = Object.keys(worker.messages[0]).sort();
replyWithResult(worker, { model: 'small', ...gpu, preparationMs: 5, inferenceMs: 7, totalMs: 12 });
const result = await pending;
assert.deepEqual(requestKeys, ['id', 'image', 'type']);
assert.equal(result.userData.aiDepthRun.model, 'small');
assert.equal(result.userData.aiDepthRun.device, 'webgpu');
assert.equal(result.userData.aiDepthRun.dtype, 'fp32');
assert.equal(result.userData.aiDepthRun.preparationMs, 5);
assert.equal(result.userData.aiDepthRun.inferenceMs, 7);
assert.ok(result.userData.aiDepthRun.totalMs >= 0);
assert.equal(result.userData.normalizedDepth[0], 1);
assert.equal(worker.terminated, true, 'finished inference must release its runtime before scene rendering resumes');
result.dispose();
} finally { await h.cleanup(); }
});
test('provider cancellation is an AbortError and late captures are closed without spawning workers', async () => {
let resolveCapture;
const bitmap = { width: 10, height: 5, closed: false, close() { this.closed = true; } };
const h = await providerHarness(() => new Promise((resolve) => { resolveCapture = resolve; }));
try {
const pending = h.provider.getDepthMap(photo);
const rejected = assert.rejects(pending, { name: 'AbortError' });
h.provider.cancel();
resolveCapture(bitmap);
await rejected;
await tick();
assert.equal(bitmap.closed, true);
assert.equal(h.workers.length, 0);
} finally { await h.cleanup(); }
});
test('orphaned worker errors and messages cannot reject or settle a replacement request', async () => {
const h = await providerHarness();
try {
const first = h.provider.getDepthMap(photo);
const firstRejected = assert.rejects(first);
await tick();
const oldWorker = h.workers[0];
h.provider.cancel(); await firstRejected;
const second = h.provider.getDepthMap(photo);
// Attach immediately so a failing implementation does not create an unhandled rejection.
const settled = second.then((value) => ({ value }), (error) => ({ error }));
await tick();
const worker = h.workers[1];
oldWorker.onerror({ message: 'late old worker failure' });
oldWorker.onmessageerror({});
oldWorker.onmessage({ data: { type: 'status', id: worker.messages[0].id, message: 'orphaned status' } });
replyWithResult(worker, { model: 'small', ...wasm, preparationMs: 1, inferenceMs: 2, totalMs: 3 });
const result = await settled;
assert.equal(result.error, undefined, 'orphaned callbacks must be ignored');
assert.equal(h.statuses.includes('orphaned status'), false);
assert.equal(worker.terminated, true, 'the valid completed worker is released');
result.value.dispose();
} finally { await h.cleanup(); }
});
test('completed inference releases only its own worker and late replies cannot terminate a newer request', async () => {
const h = await providerHarness();
try {
const first = h.provider.getDepthMap(photo); await tick();
const old = h.workers[0];
replyWithResult(old, { model: 'small', ...gpu, preparationMs: 1, inferenceMs: 2, totalMs: 3 });
const firstTexture = await first;
assert.equal(old.terminated, true);
const next = h.provider.getDepthMap(photo); await tick();
const current = h.workers[1];
replyWithResult(old, { model: 'small', ...gpu, preparationMs: 1, inferenceMs: 2, totalMs: 3 });
old.onerror({ message: 'late old failure' });
assert.equal(current.terminated, false);
replyWithResult(current, { model: 'small', ...wasm, preparationMs: 1, inferenceMs: 2, totalMs: 3 });
const texture = await next;
assert.equal(current.terminated, true); assert.equal(texture.userData.aiDepthRun.device, 'wasm');
firstTexture.dispose(); texture.dispose();
} finally { await h.cleanup(); }
});
async function workerHarness({ hasGpu = true, adapter = {}, adapterError = false, missingAdapterMethod = false, failLoad = () => false, failInference = () => false, beforeLoad = async () => {} } = {}) {
const source = await readFile(new URL('../src/depth/aiDepth.worker.ts', import.meta.url), 'utf8');
const { outputText } = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS } });
const constants = await import(new URL('../src/constants/depth.ts', import.meta.url));
let now = 0;
let listener;
let liveSessions = 0;
let adapterProbes = 0;
let maximumSessions = 0;
const loads = [];
const sessions = [];
const messages = [];
const terminals = new Map();
const pipeline = async (_task, model, options) => {
loads.push({ model, device: options.device, dtype: options.dtype });
now += 5;
options.progress_callback({ status: 'initiate' });
options.progress_callback({ status: 'progress', progress: 50 });
await beforeLoad(options);
if (failLoad(model, options)) throw new Error('load failure');
liveSessions++;
maximumSessions = Math.max(maximumSessions, liveSessions);
const session = async () => {
now += 7;
if (failInference(model, options)) throw new Error('inference failure');
const output = { dims: [1, 1, 2], data: Float32Array.of(1, 3), dispose() { session.tensorDisposals++; } };
return { predicted_depth: output };
};
session.processor = { image_processor: { size: { width: 518, height: 518 } } };
session.disposals = 0;
session.tensorDisposals = 0;
session.dispose = async () => { now += 2; session.disposals++; liveSessions--; };
sessions.push(session);
return session;
};
vm.runInNewContext(outputText, {
exports: {}, Error,
require(name) {
if (name === '@huggingface/transformers') return { pipeline };
if (name === '../constants/depth') return constants;
throw new Error(`unexpected worker dependency ${name}`);
},
performance: { now: () => now },
navigator: hasGpu ? { gpu: missingAdapterMethod ? {} : { async requestAdapter() { adapterProbes++; if (adapterError) throw new Error('adapter unavailable'); return adapter; } } } : {},
OffscreenCanvas: class { getContext() { return { drawImage() {} }; } },
self: {
addEventListener(_type, callback) { listener = callback; },
postMessage(message) { messages.push(message); if (message.type !== 'status') terminals.get(message.id)?.(message); },
},
});
let requestId = 0;
return {
loads, sessions, messages,
get maximumSessions() { return maximumSessions; },
get adapterProbes() { return adapterProbes; },
request(legacyFields = {}) {
const id = ++requestId;
const bitmap = { width: 10, height: 5, closed: 0, close() { this.closed++; } };
return new Promise((resolve) => {
terminals.set(id, (message) => { terminals.delete(id); resolve({ message, bitmap }); });
listener({ data: { type: 'infer', id, image: bitmap, ...legacyFields } });
});
},
};
}
test('worker uses Small 770 with WASM/q8 when WebGPU is absent', async () => {
const h = await workerHarness({ hasGpu: false });
const { message, bitmap } = await h.request();
assert.equal(message.type, 'result');
assert.deepEqual(h.loads, [{ model: 'onnx-community/depth-anything-v2-small-ONNX', ...wasm }]);
assert.deepEqual({ ...message.run }, { model: 'small', ...wasm, preparationMs: 5, inferenceMs: 7, totalMs: 12 });
assert.deepEqual({ ...h.sessions[0].processor.image_processor.size }, { width: 770, height: 770 });
assert.deepEqual([...new Uint8Array(message.values)], [0, 255]);
assert.deepEqual([...new Float32Array(message.normalizedValues)], [0, 1]);
assert.equal(h.sessions[0].tensorDisposals, 1);
assert.equal(bitmap.closed, 1);
});
test('a stale model-selection field cannot load another model or override execution', async () => {
const h = await workerHarness();
const { message } = await h.request({ model: 'base', preset: wasm });
assert.equal(message.type, 'result');
assert.deepEqual(h.loads, [{ model: 'onnx-community/depth-anything-v2-small-ONNX', ...gpu }]);
assert.equal(message.run.model, 'small');
});
test('worker reuses the fixed Small session on repeated requests', async () => {
const h = await workerHarness();
await h.request();
const reused = await h.request();
assert.equal(h.loads.length, 1);
assert.equal(h.adapterProbes, 1, 'cached session does not repeatedly probe GPU');
assert.equal(h.maximumSessions, 1);
assert.equal(h.sessions[0].disposals, 0);
assert.equal(reused.message.run.preparationMs, 0);
assert.equal(reused.message.run.inferenceMs, 7);
});
test('GPU inference failure disposes and requests an isolated WASM retry', async () => {
const h = await workerHarness({ failInference: (_model, preset) => preset.device === 'webgpu' });
const { message, bitmap } = await h.request();
assert.equal(message.type, 'fallback');
assert.deepEqual(h.loads, [{ model: 'onnx-community/depth-anything-v2-small-ONNX', ...gpu }]);
assert.equal(h.sessions[0].disposals, 1);
assert.equal(message.preparationMs, 7); assert.equal(message.inferenceMs, 7);
assert.equal(bitmap.closed, 1);
});
test('GPU loading failure requests fresh worker and retains failed preparation time', async () => {
const h = await workerHarness({ failLoad: (_model, preset) => preset.device === 'webgpu' });
const { message } = await h.request();
assert.equal(message.type, 'fallback');
assert.equal(message.preparationMs, 5); assert.equal(message.inferenceMs, 0);
assert.equal(h.loads.length, 1, 'must not retry inside a poisoned worker');
});
test('a present GPU API with null or rejected adapter selects WASM directly', async () => {
for (const options of [{ adapter: null }, { adapterError: true }, { missingAdapterMethod: true }]) {
const h = await workerHarness(options); const { message } = await h.request();
assert.equal(message.type, 'result'); assert.equal(message.run.device, 'wasm');
assert.deepEqual(h.loads, [{ model: 'onnx-community/depth-anything-v2-small-ONNX', ...wasm }]);
}
});
test('forced fallback worker bypasses GPU and retains Small 770', async () => {
const h = await workerHarness(); const { message } = await h.request({ forceWasm: true });
assert.equal(message.type, 'result'); assert.equal(message.run.device, 'wasm');
assert.deepEqual(h.loads, [{ model: 'onnx-community/depth-anything-v2-small-ONNX', ...wasm }]);
assert.equal(h.sessions[0].processor.image_processor.size.width, 770);
assert.equal(h.adapterProbes, 0, 'forced CPU retry cannot touch GPU again');
});
test('terminal WASM failure closes the input and returns an error without more retries', async () => {
const h = await workerHarness({ hasGpu: false, failInference: () => true });
const { message, bitmap } = await h.request();
assert.equal(message.type, 'error');
assert.equal(message.message, 'inference failure');
assert.equal(h.loads.length, 1);
assert.equal(bitmap.closed, 1);
});
test('worker labels file progress without implying a cold download', async () => {
const h = await workerHarness();
await h.request();
const statuses = h.messages.filter(({ type }) => type === 'status').map(({ message }) => message);
assert.ok(statuses.includes('출발 준비 중 · 50%'));
assert.ok(statuses.includes('내비게이션으로 길을 찾는 중…'));
assert.ok(statuses.every((message) => !/AI|깊이|모델|WebGPU|CPU|계산/i.test(message)));
assert.ok(statuses.every((message) => !/다운로드|download/i.test(message)));
});
test('provider retries once in a fresh worker and ignores orphan messages', async () => {
const h = await providerHarness();
try {
const pending = h.provider.getDepthMap(photo); const settled = pending.then(value => ({ value }), error => ({ error }));
await tick(); const old = h.workers[0], id = old.messages[0].id;
old.onmessage({ data: { type: 'fallback', id, preparationMs: 5, inferenceMs: 7 } });
await tick();
assert.equal(h.statuses.at(-1), '다른 길을 찾고 있어요. 조금만 더 기다려 주세요…');
assert.equal(h.workers.length, 2, 'fallback requires a new JS realm');
const fallback = h.workers[1]; assert.equal(old.terminated, true); assert.equal(fallback.messages[0].forceWasm, true);
old.onmessage({ data: { type: 'error', id, message: 'late GPU failure' } });
old.onerror({ message: 'late crash' });
replyWithResult(fallback, { model: 'small', ...wasm, preparationMs: 10, inferenceMs: 20, totalMs: 30 });
const result = await settled; assert.equal(result.error, undefined);
assert.equal(result.value.userData.aiDepthRun.preparationMs, 15); assert.equal(result.value.userData.aiDepthRun.inferenceMs, 27);
assert.equal(h.workers.length, 2); result.value.dispose();
} finally { await h.cleanup(); }
});
test('fallback cannot loop and terminal failures discard the poisoned runtime', async () => {
const h = await providerHarness();
try {
const pending = h.provider.getDepthMap(photo); const settled = pending.then(value => ({ value }), error => ({ error }));
await tick(); const old = h.workers[0], id = old.messages[0].id;
old.onmessage({ data: { type: 'fallback', id, preparationMs: 5, inferenceMs: 0 } }); await tick();
assert.equal(h.workers.length, 2);
const fallback = h.workers[1]; fallback.onmessage({ data: { type: 'fallback', id, preparationMs: 5, inferenceMs: 0 } });
const result = await settled; assert.ok(result.error); assert.equal(h.workers.length, 2); assert.equal(fallback.terminated, true);
} finally { await h.cleanup(); }
});
test('photo replacement during fallback capture closes stale bitmap without replacing new worker', async () => {
let captures = 0, release;
const h = await providerHarness(async () => {
captures++;
if (captures === 2) return new Promise(resolve => { release = resolve; });
return { width: 10, height: 5, close() {} };
});
try {
const pending = h.provider.getDepthMap(photo); const rejected = assert.rejects(pending, { name: 'AbortError' });
await tick(); const old = h.workers[0], id = old.messages[0].id;
old.onmessage({ data: { type: 'fallback', id, preparationMs: 5, inferenceMs: 0 } }); await tick();
assert.equal(typeof release, 'function', 'fallback recaptures the requested photo');
h.provider.cancel(); await rejected;
const replacement = h.provider.getDepthMap({ naturalWidth: 50, naturalHeight: 100 }); await tick();
assert.equal(h.workers.length, 2); const current = h.workers[1];
const stale = { width: 10, height: 5, closed: 0, close() { this.closed++; } }; release(stale); await tick();
assert.equal(stale.closed, 1); assert.equal(h.workers.length, 2); assert.equal(old.terminated, true);
replyWithResult(current, { model: 'small', ...wasm, preparationMs: 1, inferenceMs: 2, totalMs: 3 }); (await replacement).dispose();
} finally { await h.cleanup(); }
});
test('serialized terminal error also discards runtime so manual retry starts fresh', async () => {
const h = await providerHarness();
try {
const pending = h.provider.getDepthMap(photo); const rejected = assert.rejects(pending, /WASM failed/);
await tick(); const old = h.workers[0], id = old.messages[0].id;
old.onmessage({ data: { type: 'error', id, message: 'WASM failed' } }); await rejected;
assert.equal(old.terminated, true);
const retry = h.provider.getDepthMap(photo); await tick(); assert.equal(h.workers.length, 2);
replyWithResult(h.workers[1], { model: 'small', ...wasm, preparationMs: 1, inferenceMs: 2, totalMs: 3 });
(await retry).dispose();
} finally { await h.cleanup(); }
});
// Execute the installed dependency's actual initialization chain, replacing only ORT's
// slow hardware boundary. A rejected GPU load prevents same-realm WASM from running.
async function dependencyRuntime() {
const source = await readFile(new URL('../node_modules/@huggingface/transformers/src/backends/onnx.js', import.meta.url), 'utf8');
const start = source.indexOf('export async function createInferenceSession(');
assert.ok(start >= 0);
const body = source.slice(start, source.indexOf('\n/**', start)).replace('export ', '');
const calls = [];
const context = {
ensureWasmLoaded: async () => {}, getOnnxLogSeverityLevel: () => 2, env: {}, LogLevel: { WARNING: 2 }, apis: { IS_WEB_ENV: true },
InferenceSession: { async create(_buffer, options) {
const device = options.executionProviders[0]; calls.push(device);
if (device === 'webgpu') throw new Error('Failed to get GPU adapter');
return {};
} },
};
vm.createContext(context);
vm.runInContext('let webInitChain = Promise.resolve();' + body + ';this.create = createInferenceSession;', context);
return { calls, create: preset => context.create(new Uint8Array(), { executionProviders: [preset.device] }, {}) };
}
test('real dependency rejected chain is isolated before WASM fallback', async () => {
const poisoned = await dependencyRuntime();
const gpuWorker = await workerHarness({ beforeLoad: poisoned.create });
const failed = await gpuWorker.request(); assert.equal(failed.message.type, 'fallback');
assert.deepEqual(poisoned.calls, ['webgpu']);
await assert.rejects(poisoned.create(wasm), /Failed to get GPU adapter/);
assert.deepEqual(poisoned.calls, ['webgpu'], 'same-realm fallback is skipped by the dependency, matching the reported mechanism');
const clean = await dependencyRuntime();
const cpuWorker = await workerHarness({ beforeLoad: clean.create });
const recovered = await cpuWorker.request({ forceWasm: true });
assert.equal(recovered.message.type, 'result'); assert.equal(recovered.message.run.device, 'wasm');
assert.deepEqual(clean.calls, ['wasm']);
await cpuWorker.request(); assert.deepEqual(clean.calls, ['wasm'], 'successful WASM session is reused');
});
test('duplicate and stale fallback replies cannot replace the active CPU worker', async () => {
const h = await providerHarness();
try {
const pending = h.provider.getDepthMap(photo); await tick(); const old = h.workers[0], id = old.messages[0].id;
const fallbackReply = { data: { type: 'fallback', id, preparationMs: 5, inferenceMs: 0 } };
old.onmessage(fallbackReply); old.onmessage(fallbackReply); await tick();
assert.equal(h.workers.length, 2); const current = h.workers[1];
current.onmessage({ data: { type: 'fallback', id: id - 1, preparationMs: 0, inferenceMs: 0 } });
assert.equal(current.terminated, false); assert.equal(h.workers.length, 2);
replyWithResult(current, { model: 'small', ...wasm, preparationMs: 1, inferenceMs: 2, totalMs: 3 }); (await pending).dispose();
} finally { await h.cleanup(); }
});
test('Worker constructor failure closes the captured bitmap', async () => {
const h = await providerHarness();
try {
globalThis.Worker = class { constructor() { throw new Error('worker unavailable'); } };
await assert.rejects(h.provider.getDepthMap(photo), /worker unavailable/);
assert.equal(h.bitmaps[0].closed, true);
} finally { await h.cleanup(); }
});
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import { access } from 'node:fs/promises';
import { createHash, webcrypto } from 'node:crypto';
import { test } from 'node:test';
import { loadTypescript } from './support/load-typescript.mjs';
const moduleURL = new URL('../src/analytics/Identity.ts', import.meta.url);
assert.equal(await access(moduleURL).then(() => true, () => false), true, 'private browser identity module must exist');
const { getVisitorId, makeDogId, VISITOR_ID_STORAGE_KEY } = await loadTypescript(moduleURL);
const first = '01234567-89ab-4cde-8f01-23456789abcd';
const second = '12345678-9abc-4def-9012-3456789abcde';
const digest = webcrypto.subtle.digest.bind(webcrypto.subtle);
const hash = (visitor, name) => makeDogId(visitor, name, digest);
test('visitor UUID persists across collector reloads without creating another identifier', () => {
const values = new Map();
const storage = { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value) };
assert.equal(getVisitorId({ getStorage: () => storage, randomUUID: () => first }), first);
assert.equal(values.get(VISITOR_ID_STORAGE_KEY), first);
assert.equal(VISITOR_ID_STORAGE_KEY, 'behind-glass.visitor-id.v1');
assert.equal(getVisitorId({ getStorage: () => storage, randomUUID: () => { throw Error('must reuse stored ID'); } }), first);
});
test('malformed stored UUID is replaced by a fresh valid random UUID', () => {
for (const bad of ['', 'not-a-uuid', first + 'x', '00000000-0000-0000-0000-000000000000', first.replace('-4cde-', '-1cde-'), first.replace('-4cde-', '-7cde-')]) {
let value = bad;
assert.equal(getVisitorId({ getStorage: () => ({ getItem: () => value, setItem: (_key, next) => { value = next; } }), randomUUID: () => first }), first);
assert.equal(value, first);
}
});
test('blocked storage getter, read, write, and absent storage use one UUID per collector', () => {
for (const getStorage of [
() => undefined,
() => { throw Error('blocked getter'); },
() => ({ getItem() { throw Error('blocked read'); }, setItem() { throw Error('blocked write'); } }),
() => ({ getItem: () => null, setItem() { throw Error('full'); } }),
]) {
let created = 0;
const options = { getStorage, randomUUID: () => (++created === 1 ? first : second) };
assert.equal(getVisitorId(options), first);
assert.equal(getVisitorId(options), first);
assert.equal(created, 1);
assert.equal(getVisitorId({ getStorage, randomUUID: () => second }), second);
}
});
test('unavailable, throwing, or invalid random UUID creation leaves telemetry inert', () => {
for (const randomUUID of [undefined, () => { throw Error('unavailable'); }, () => 'invented-fingerprint', () => first.replace('-4cde-', '-1cde-'), () => first.replace('-4cde-', '-7cde-')]) {
const options = { getStorage: () => undefined, randomUUID };
assert.equal(getVisitorId(options), undefined);
assert.equal(getVisitorId(options), undefined);
}
});
test('stored uppercase UUID is replaced and noncanonical generated UUID stays inert', () => {
let stored = first.toUpperCase();
const storage = { getItem: () => stored, setItem: (_key, value) => { stored = value; } };
assert.equal(getVisitorId({ getStorage: () => storage, randomUUID: () => second }), second);
assert.equal(stored, second);
assert.equal(getVisitorId({ getStorage: () => undefined, randomUUID: () => first.toUpperCase() }), undefined);
});
test('empty and whitespace names use the explicit default name hash', async () => {
assert.equal(await hash(first, ''), await hash(first, '멍멍이'));
assert.equal(await hash(first, ' \n\t '), await hash(first, '멍멍이'));
});
test('hash uses NFC, trim and 20 code points without friendly display suffixes', async () => {
assert.equal(await hash(first, ' 호롱 '), await hash(first, '호롱'));
assert.equal(await hash(first, '🐕'.repeat(21)), await hash(first, '🐕'.repeat(20)));
assert.notEqual(await hash(first, '호롱'), await hash(first, '호롱이'));
});
test('WebCrypto hashes the exact versioned UTF-8 JSON tuple to lowercase SHA-256', async () => {
const expected = createHash('sha256').update(JSON.stringify([1, first, '호롱']), 'utf8').digest('hex');
assert.equal(await hash(first, '호롱'), expected);
assert.match(expected, /^[0-9a-f]{64}$/);
assert.notEqual(await hash('a|b', 'c'), await hash('a', 'b|c'), 'tuple framing cannot collide through a delimiter');
});
test('missing, throwing, rejected or malformed digest disables only the affected hash', async () => {
for (const unavailable of [undefined, () => { throw Error('unavailable'); }, async () => { throw Error('failed'); }, async () => new ArrayBuffer(0)]) {
assert.equal(await makeDogId(first, '호롱', unavailable), '');
}
assert.match(await hash(first, '호롱'), /^[0-9a-f]{64}$/);
});
+203
View File
@@ -0,0 +1,203 @@
import assert from 'node:assert/strict';
import { access } from 'node:fs/promises';
import { test } from 'node:test';
import { loadTypescript } from './support/load-typescript.mjs';
const moduleURL = new URL('../src/analytics/ArrivalTracker.ts', import.meta.url);
assert.equal(await access(moduleURL).then(() => true, () => false), true, 'journey collector must exist');
const { createArrivalTracker } = await loadTypescript(moduleURL);
const { createTransport } = await loadTypescript(new URL('../src/analytics/Transport.ts', import.meta.url));
const visitorId = '01234567-89ab-4cde-8f01-23456789abcd';
const dogId = 'a'.repeat(64), otherDogId = 'b'.repeat(64);
const flush = () => new Promise(resolve => setImmediate(resolve));
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
function collector(overrides = {}) {
const calls = [], counts = [], hashes = [];
const tracker = createArrivalTracker({
visitorId,
makeDogId: async (visitor, name) => { hashes.push({ visitor, name }); return dogId; },
transport: { request: async (path, body, signal) => { calls.push({ path, body, signal }); return body === undefined ? { ok: true, count: 7 } : { ok: true }; } },
onCount: count => counts.push(count),
...overrides,
});
return { tracker, calls, counts, hashes };
}
test('start is nonblocking and sends exactly one visit and initial count', async () => {
const { tracker, calls, counts } = collector();
assert.equal(tracker.start(), undefined); tracker.start();
await flush();
assert.deepEqual(calls.map(({ path, body }) => ({ path, body })), [
{ path: '/api/visits', body: { visitor_id: visitorId } },
{ path: '/api/arrivals/count', body: undefined },
]);
assert.deepEqual(counts, [7]);
tracker.dispose();
});
test('sample arrivals are excluded without suppressing their visit', async () => {
const { tracker, calls, hashes } = collector();
tracker.start(); tracker.arrived(tracker.begin('sample', '호롱'));
await flush();
assert.equal(calls.filter(call => call.path === '/api/visits').length, 1);
assert.equal(calls.some(call => call.path === '/api/arrivals'), false);
assert.deepEqual(hashes, []);
tracker.dispose();
});
test('begin alone never registers an arrival and duplicate arrived calls send once', async () => {
const { tracker, calls } = collector();
const token = tracker.begin('personal', '호롱');
await flush(); assert.deepEqual(calls, []);
assert.equal(tracker.arrived(token), undefined); tracker.arrived(token);
await flush(); tracker.arrived(token); await flush();
assert.deepEqual(calls.map(call => call.path), ['/api/arrivals', '/api/arrivals/count']);
assert.deepEqual(calls[0].body, { dog_id: dogId });
tracker.dispose();
});
test('hash completion after photo replacement cannot register the old journey', async () => {
const old = deferred(), next = deferred();
let hashCalls = 0;
const { tracker, calls } = collector({ makeDogId: () => ++hashCalls === 1 ? old.promise : next.promise });
const stale = tracker.begin('personal', '호롱'); tracker.arrived(stale);
const current = tracker.begin('personal', '보리');
assert.ok(current > stale); tracker.arrived(stale); tracker.arrived(current);
old.resolve(dogId); await flush(); assert.deepEqual(calls, []);
next.resolve(otherDogId); await flush();
assert.deepEqual(calls.filter(call => call.path === '/api/arrivals').map(call => call.body), [{ dog_id: otherDogId }]);
tracker.dispose();
});
test('explicit invalidation makes delayed hashes and old tokens inert', async () => {
const pending = deferred();
const { tracker, calls } = collector({ makeDogId: () => pending.promise });
const token = tracker.begin('personal', '호롱'); tracker.arrived(token); tracker.invalidate();
pending.resolve(dogId); tracker.arrived(token); await flush();
assert.deepEqual(calls, []);
tracker.dispose();
});
test('dispose cancels visits, count reads and delayed hash continuations permanently', async () => {
const pending = deferred(), calls = [];
const { tracker, counts } = collector({
makeDogId: () => pending.promise,
transport: { request: (path, body, signal) => { calls.push({ path, signal }); return pending.promise.then(() => body === undefined ? { ok: true, count: 99 } : { ok: true }); } },
});
tracker.start(); const token = tracker.begin('personal', '호롱'); tracker.arrived(token);
tracker.dispose(); tracker.dispose(); tracker.start(); tracker.arrived(tracker.begin('personal', '보리'));
assert.ok(calls.every(call => call.signal.aborted));
pending.resolve(dogId); await flush();
assert.deepEqual(counts, []);
assert.deepEqual(calls.map(call => call.path), ['/api/visits', '/api/arrivals/count']);
});
test('begin synchronously captures the original visitor and raw name', async () => {
const pending = deferred(), captured = [];
const options = { visitorId, makeDogId: (visitor, name) => { captured.push({ visitor, name }); return pending.promise; } };
const { tracker, calls } = collector(options);
let rawName = ' 호롱 ';
const token = tracker.begin('personal', rawName);
rawName = '보리'; options.visitorId = 'replacement';
assert.deepEqual(captured, [{ visitor: visitorId, name: ' 호롱 ' }]);
tracker.arrived(token); pending.resolve(dogId); await flush();
assert.deepEqual(calls[0].body, { dog_id: dogId });
tracker.dispose();
});
test('transport retries retain the journey ID after a name edit', async () => {
const sent = [], delays = [];
let name = '호롱';
const transport = createTransport({
fetch: async (path, init) => {
if (path.endsWith('/count')) return { status: 200, json: async () => ({ count: 8 }) };
sent.push(init.body); name = '보리'; return { status: sent.length === 1 ? 503 : 204 };
},
sleep: async ms => { delays.push(ms); },
});
const { tracker, counts } = collector({ transport, makeDogId: async (_visitor, captured) => captured === '호롱' ? dogId : otherDogId });
tracker.arrived(tracker.begin('personal', name)); await flush();
assert.equal(name, '보리');
assert.deepEqual(sent, [JSON.stringify({ dog_id: dogId }), JSON.stringify({ dog_id: dogId })]);
assert.deepEqual(delays, [500]); assert.deepEqual(counts, [8]);
tracker.dispose();
});
test('invalidation during delayed retry cancels the next attempt', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const transport = createTransport({ fetch: async () => { calls++; return { status: 503 }; } });
const { tracker } = collector({ transport });
tracker.arrived(tracker.begin('personal', '호롱')); await flush();
assert.equal(calls, 1); tracker.invalidate();
t.mock.timers.tick(10000); await flush(); assert.equal(calls, 1);
tracker.dispose();
});
test('successful arrival refreshes the count while failed registration does not', async () => {
for (const ok of [true, false]) {
const calls = [];
const { tracker, counts } = collector({ transport: { request: async path => { calls.push(path); return path.endsWith('/count') ? { ok: true, count: 12 } : { ok }; } } });
tracker.arrived(tracker.begin('personal', '호롱')); await flush();
assert.deepEqual(calls, ok ? ['/api/arrivals', '/api/arrivals/count'] : ['/api/arrivals']);
assert.deepEqual(counts, ok ? [12] : []); tracker.dispose();
}
});
test('late initial count cannot overwrite a newer successful arrival refresh', async () => {
const initial = deferred(), fresh = deferred();
let reads = 0;
const { tracker, counts } = collector({ transport: { request: async path => path.endsWith('/count') ? (++reads === 1 ? initial.promise : fresh.promise) : { ok: true } } });
tracker.start(); tracker.arrived(tracker.begin('personal', '호롱')); await flush();
fresh.resolve({ ok: true, count: 11 }); await flush();
initial.resolve({ ok: true, count: 10 }); await flush();
assert.deepEqual(counts, [11]); tracker.dispose();
});
test('a failed newer count read preserves the last good count and permits an earlier valid read', async () => {
const initial = deferred(), fresh = deferred();
let reads = 0;
const { tracker, counts } = collector({ transport: { request: async path => path.endsWith('/count') ? (++reads === 1 ? initial.promise : fresh.promise) : { ok: true } } });
tracker.start(); tracker.arrived(tracker.begin('personal', '호롱')); await flush();
fresh.resolve({ ok: false }); await flush(); initial.resolve({ ok: true, count: 5 }); await flush();
assert.deepEqual(counts, [5]); tracker.dispose();
});
test('missing visitor disables identification events while aggregate reads remain available', async () => {
const { tracker, calls, hashes, counts } = collector({ visitorId: undefined });
tracker.start(); tracker.arrived(tracker.begin('personal', '호롱')); await flush();
assert.deepEqual(calls.map(call => call.path), ['/api/arrivals/count']);
assert.deepEqual(hashes, []); assert.deepEqual(counts, [7]); tracker.dispose();
});
test('throwing and rejected hash or transport failures never escape fire-and-forget methods', async () => {
for (const makeDogId of [() => { throw Error('crypto'); }, async () => { throw Error('crypto'); }, async () => '', async () => 'invalid']) {
const { tracker, calls } = collector({ makeDogId });
assert.doesNotThrow(() => tracker.arrived(tracker.begin('personal', '호롱')));
await flush(); assert.deepEqual(calls, []); tracker.dispose();
}
for (const request of [() => { throw Error('network'); }, async () => { throw Error('network'); }]) {
const { tracker, counts } = collector({ transport: { request } });
assert.doesNotThrow(() => { tracker.start(); tracker.arrived(tracker.begin('personal', '호롱')); });
await flush(); assert.deepEqual(counts, []); tracker.dispose();
}
const { tracker } = collector({ onCount: () => { throw Error('removed DOM'); } });
tracker.start(); await flush(); tracker.dispose();
});
test('invalid count responses cannot reach presentation even through an injected transport', async () => {
for (const count of [-1, 0.5, '3', Number.MAX_SAFE_INTEGER + 1, undefined]) {
const { tracker, counts } = collector({ transport: { request: async () => ({ ok: true, count }) } });
tracker.start(); await flush(); assert.deepEqual(counts, []); tracker.dispose();
}
});
test('invalidation cancels pending work but cannot undo an already accepted server write', async () => {
const accepted = [], pending = deferred();
const { tracker, counts } = collector({ transport: { request: async (path, body) => { accepted.push({ path, body }); return pending.promise; } } });
tracker.arrived(tracker.begin('personal', '호롱')); await flush();
tracker.invalidate(); pending.resolve({ ok: true }); await flush();
assert.deepEqual(accepted, [{ path: '/api/arrivals', body: { dog_id: dogId } }]);
assert.deepEqual(counts, []); tracker.dispose();
});
+210
View File
@@ -0,0 +1,210 @@
import assert from 'node:assert/strict';
import { access } from 'node:fs/promises';
import { test } from 'node:test';
import { loadTypescript } from './support/load-typescript.mjs';
const moduleURL = new URL('../src/analytics/Transport.ts', import.meta.url);
assert.equal(await access(moduleURL).then(() => true, () => false), true, 'bounded telemetry transport module must exist');
const { createTransport } = await loadTypescript(moduleURL);
const flush = () => new Promise(resolve => setImmediate(resolve));
const response = (status, value) => ({ status, json: async () => value });
test('POST sends only serialized JSON and GET returns a valid count', async () => {
const sent = [];
const transport = createTransport({ fetch: async (path, init) => { sent.push({ path, ...init }); return response(init.method === 'POST' ? 204 : 200, { count: 1234 }); } });
assert.deepEqual(await transport.request('/api/visits', { visitor_id: 'visitor' }), { ok: true });
assert.equal(sent[0].body, '{"visitor_id":"visitor"}');
assert.equal(sent[0].headers['Content-Type'], 'application/json');
assert.equal(sent[0].method, 'POST');
assert.equal(sent[0].credentials, 'same-origin');
assert.deepEqual(await transport.request('/api/arrivals/count'), { ok: true, count: 1234 });
assert.equal(sent[1].method, 'GET');
assert.equal(sent[1].body, undefined);
assert.equal(sent[1].cache, 'no-store');
});
for (const failure of ['network', 429, 500, 503]) {
test(`${failure} retries after 500 and 1500 ms with an identical payload, never a fourth attempt`, async () => {
const sent = [], delays = [];
const body = { dog_id: 'original' };
const transport = createTransport({
fetch: async (_path, init) => { sent.push(init.body); body.dog_id = 'edited'; if (failure === 'network') throw Error('offline'); return response(failure); },
sleep: async ms => { delays.push(ms); },
});
assert.deepEqual(await transport.request('/api/arrivals', body), { ok: false });
assert.deepEqual(delays, [500, 1500]);
assert.deepEqual(sent, ['{"dog_id":"original"}', '{"dog_id":"original"}', '{"dog_id":"original"}']);
});
}
test('a later successful retry finishes without further attempts', async () => {
let calls = 0;
const delays = [];
const transport = createTransport({ fetch: async () => response(++calls === 1 ? 503 : 204), sleep: async ms => { delays.push(ms); } });
assert.deepEqual(await transport.request('/api/arrivals', { dog_id: 'id' }), { ok: true });
assert.equal(calls, 2);
assert.deepEqual(delays, [500]);
});
for (const status of [400, 403, 404, 415]) {
test(`${status} is not retried`, async () => {
let calls = 0;
const transport = createTransport({ fetch: async () => { calls++; return response(status); }, sleep: async () => assert.fail('must not retry') });
assert.deepEqual(await transport.request('/api/visits', { visitor_id: 'id' }), { ok: false });
assert.equal(calls, 1);
});
}
test('the 3000 ms deadline aborts the actual fetch and is cleared after completion', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let signal;
const transport = createTransport({ fetch: (_path, init) => { signal = init.signal; return new Promise(() => {}); }, retryDelays: [] });
const pending = transport.request('/api/arrivals/count');
await flush();
t.mock.timers.tick(2999);
assert.equal(signal.aborted, false);
t.mock.timers.tick(1);
await flush();
assert.equal(signal.aborted, true);
assert.deepEqual(await pending, { ok: false });
let completedSignal;
const successful = createTransport({ fetch: async (_path, init) => { completedSignal = init.signal; return response(204); } });
await successful.request('/api/visits', { visitor_id: 'id' });
t.mock.timers.tick(10000);
assert.equal(completedSignal.aborted, false, 'completed attempt timeout was removed');
});
test('deadline also covers a stalled JSON body read', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let signal;
const transport = createTransport({ fetch: async (_path, init) => { signal = init.signal; return { status: 200, json: () => new Promise(() => {}) }; }, retryDelays: [] });
const pending = transport.request('/api/arrivals/count');
await flush();
t.mock.timers.tick(3000);
await flush();
assert.deepEqual(await pending, { ok: false });
assert.equal(signal.aborted, true);
});
test('caller abort cancels an in-flight request, timeout and further retries', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const caller = new AbortController();
let calls = 0, signal;
const transport = createTransport({ fetch: (_path, init) => { calls++; signal = init.signal; return new Promise(() => {}); } });
const pending = transport.request('/api/visits', { visitor_id: 'id' }, caller.signal);
await flush(); caller.abort(); await flush();
assert.deepEqual(await pending, { ok: false });
assert.equal(signal.aborted, true);
t.mock.timers.tick(100000); await flush();
assert.equal(calls, 1);
});
test('caller abort cancels default delayed retry and never makes another request', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const caller = new AbortController();
let calls = 0;
const transport = createTransport({ fetch: async () => { calls++; throw Error('offline'); } });
const pending = transport.request('/api/arrivals', { dog_id: 'id' }, caller.signal);
await flush(); t.mock.timers.tick(499); await flush();
assert.equal(calls, 1);
caller.abort(); await flush();
assert.deepEqual(await pending, { ok: false });
t.mock.timers.tick(100000); await flush();
assert.equal(calls, 1);
});
test('caller abort settles even an injected sleep that ignores cancellation', async () => {
const caller = new AbortController();
let calls = 0, sleepSignal;
const transport = createTransport({ fetch: async () => { calls++; throw Error('offline'); }, sleep: (_ms, signal) => { sleepSignal = signal; return new Promise(() => {}); } });
const pending = transport.request('/api/arrivals', { dog_id: 'id' }, caller.signal);
await flush(); caller.abort(); await flush();
assert.deepEqual(await pending, { ok: false });
assert.equal(sleepSignal.aborted, true);
assert.equal(calls, 1);
});
test('already-aborted requests are inert', async () => {
const caller = new AbortController(); caller.abort();
const transport = createTransport({ fetch: () => assert.fail('must not fetch') });
assert.deepEqual(await transport.request('/api/arrivals/count', undefined, caller.signal), { ok: false });
});
test('malformed count values and malformed JSON never become displayed zero', async () => {
for (const value of [null, {}, [], { count: -1 }, { count: 0.5 }, { count: '2' }, { count: Number.MAX_SAFE_INTEGER + 1 }, { count: NaN }, { count: Infinity }]) {
const transport = createTransport({ fetch: async () => response(200, value) });
assert.deepEqual(await transport.request('/api/arrivals/count'), { ok: false });
}
for (const count of [0, 1, Number.MAX_SAFE_INTEGER]) {
assert.deepEqual(await createTransport({ fetch: async () => response(200, { count }) }).request('/api/arrivals/count'), { ok: true, count });
}
assert.deepEqual(await createTransport({ fetch: async () => ({ status: 200, json: async () => { throw Error('bad JSON'); } }), retryDelays: [] }).request('/api/arrivals/count'), { ok: false });
});
test('serialization and injected dependency failures are contained without rejection', async () => {
const circular = {}; circular.self = circular;
const transport = createTransport({ fetch: () => { throw Error('missing'); }, sleep: () => { throw Error('clock unavailable'); } });
assert.deepEqual(await transport.request('/api/visits', circular), { ok: false });
assert.deepEqual(await transport.request('/api/visits', { visitor_id: 'id' }), { ok: false });
});
test('non-contract success statuses cannot register an arrival or invent a count', async () => {
assert.deepEqual(await createTransport({ fetch: async () => response(200, {}) }).request('/api/arrivals', { dog_id: 'id' }), { ok: false });
assert.deepEqual(await createTransport({ fetch: async () => response(204) }).request('/api/arrivals/count'), { ok: false });
});
test('network failure reading a count response body retries and can recover', async () => {
const delays = [];
let calls = 0;
const transport = createTransport({
fetch: async () => ++calls === 1
? new Response(new ReadableStream({ start(controller) { controller.error(new TypeError('network reset')); } }), { status: 200 })
: Response.json({ count: 14 }),
sleep: async ms => { delays.push(ms); },
});
assert.deepEqual(await transport.request('/api/arrivals/count'), { ok: true, count: 14 });
assert.equal(calls, 2); assert.deepEqual(delays, [500]);
});
test('permanent network failure reading count bodies has exactly two delayed retries', async () => {
const delays = [];
let calls = 0;
const transport = createTransport({
fetch: async () => { calls++; return new Response(new ReadableStream({ start(controller) { controller.error(new TypeError('network reset')); } }), { status: 200 }); },
sleep: async ms => { delays.push(ms); },
});
assert.deepEqual(await transport.request('/api/arrivals/count'), { ok: false });
assert.equal(calls, 3); assert.deepEqual(delays, [500, 1500]);
});
test('syntax-invalid JSON is not a retryable network failure', async () => {
let calls = 0;
const transport = createTransport({ fetch: async () => { calls++; return new Response('{invalid', { status: 200 }); }, sleep: async () => assert.fail('invalid JSON must not retry') });
assert.deepEqual(await transport.request('/api/arrivals/count'), { ok: false });
assert.equal(calls, 1);
});
test('retryable and terminal unread response bodies are cancelled before the attempt completes', async () => {
for (const [status, body, expectedAttempts] of [[429, { dog_id: 'id' }, 3], [503, { dog_id: 'id' }, 3], [200, { dog_id: 'id' }, 1], [403, undefined, 1]]) {
let calls = 0, cancelled = 0;
const cancelledBeforeRetry = [];
const caller = new AbortController();
const transport = createTransport({
fetch: async () => { calls++; return new Response(new ReadableStream({ cancel() { cancelled++; } }), { status }); },
sleep: async () => { cancelledBeforeRetry.push(cancelled); },
});
assert.deepEqual(await transport.request('/api/arrivals', body, caller.signal), { ok: false });
assert.equal(calls, expectedAttempts); assert.equal(cancelled, calls);
assert.deepEqual(cancelledBeforeRetry, expectedAttempts === 3 ? [1, 2] : []);
caller.abort(); await flush(); assert.equal(cancelled, calls, 'later caller abort leaves no open body behind');
}
});
test('response-body cleanup failures cannot reject or stall the bounded request', async () => {
for (const cancel of [() => { throw Error('cancel failed'); }, () => Promise.reject(Error('cancel failed')), () => new Promise(() => {})]) {
let cancelled = 0, signal;
const transport = createTransport({ fetch: async (_path, init) => { signal = init.signal; return { status: 403, body: { cancel: () => { cancelled++; return cancel(); } } }; } });
assert.deepEqual(await transport.request('/api/arrivals/count'), { ok: false });
assert.equal(cancelled, 1); assert.equal(signal.aborted, true);
}
});
+208
View File
@@ -0,0 +1,208 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildApp } from '../server/app.mjs';
import { openStore } from '../server/db.mjs';
const VISITOR = '12345678-1234-4123-8123-123456789abc';
const DOG = 'a'.repeat(64);
const ORIGIN = 'https://arrival.example';
async function fixture(t, options = {}) {
const root = mkdtempSync(join(tmpdir(), 'arrival-api-'));
const distRoot = join(root, 'dist');
mkdirSync(join(distRoot, 'assets'), { recursive: true });
mkdirSync(join(distRoot, 'fonts'));
mkdirSync(join(distRoot, 'models'));
writeFileSync(join(distRoot, 'index.html'), '<!doctype html><title>arrival fixture</title>');
writeFileSync(join(distRoot, 'assets', 'index-AbCd1234.js'), 'export const loaded = true');
writeFileSync(join(distRoot, 'assets', 'worker-ZyXw9876.js'), 'self.onmessage = () => {}');
writeFileSync(join(distRoot, 'assets', 'ort-wasm.wasm'), Buffer.from([0, 97, 115, 109]));
writeFileSync(join(distRoot, 'fonts', 'font.woff'), 'font');
writeFileSync(join(distRoot, 'models', 'model.onnx'), 'model');
writeFileSync(join(distRoot, 'config.json'), 'private-fixture-secret');
writeFileSync(join(distRoot, 'counts.sqlite'), 'private-fixture-secret');
writeFileSync(join(distRoot, 'counts.sqlite-wal'), 'private-fixture-secret');
writeFileSync(join(distRoot, 'counts.sqlite-shm'), 'private-fixture-secret');
writeFileSync(join(root, 'outside.txt'), 'private-fixture-secret');
symlinkSync(join(root, 'outside.txt'), join(distRoot, 'assets', 'escaped.txt'));
const store = options.store ?? openStore({ dbPath: join(root, 'private', 'counts.sqlite') });
const app = await buildApp({ store, distRoot, publicOrigin: ORIGIN, rateLimit: { max: 10000, timeWindow: 60000 }, ...options });
t.after(async () => { await app.close(); rmSync(root, { recursive: true, force: true }); });
return { app, store, root };
}
function post(app, url, payload, headers = {}) { return app.inject({ method: 'POST', url, payload, headers }); }
function assertError(response, status) {
assert.equal(response.statusCode, status, response.body);
assert.match(response.headers['content-type'], /application\/json/);
assert.equal(response.headers['cache-control'], 'no-store');
assert.doesNotMatch(response.body, /12345678|aaaaaaaaaa|sqlite|private-fixture-secret|visitor_id|dog_id|body\//i);
}
test('visit and arrival contracts use real SQLite and concurrent duplicates stay at one', async t => {
const { app, store } = await fixture(t);
assert.deepEqual((await app.inject('/api/arrivals/count')).json(), { count: 0 });
assert.equal((await post(app, '/api/visits', { visitor_id: VISITOR })).statusCode, 204);
const replies = await Promise.all(Array.from({ length: 20 }, () => post(app, '/api/arrivals', { dog_id: DOG })));
for (const reply of replies) { assert.equal(reply.statusCode, 204); assert.equal(reply.body, ''); }
assert.equal(store.arrivalCount(), 1);
const count = await app.inject('/api/arrivals/count');
assert.deepEqual(count.json(), { count: 1 });
assert.equal(count.headers['cache-control'], 'no-store');
});
test('exact schemas reject extra keys, coercion, invalid IDs, array/null and malformed JSON', async t => {
const { app } = await fixture(t);
for (const payload of [{}, { visitor_id: 1 }, { visitor_id: null }, { visitor_id: [VISITOR] }, { visitor_id: VISITOR, photo: 'private-fixture-secret' }, { visitor_id: VISITOR, name: 'private-fixture-secret' }, { visitor_id: VISITOR, visit_date: '2026-10-08' }, [], null]) {
assertError(await post(app, '/api/visits', JSON.stringify(payload), { 'content-type': 'application/json' }), 400);
}
for (const id of ['x', VISITOR.replace('-4', '-1'), VISITOR.replace('-8123', '-7123'), `${VISITOR}\n`, VISITOR.toUpperCase()]) assertError(await post(app, '/api/visits', { visitor_id: id }), 400);
for (const payload of [{}, { dog_id: 1 }, { dog_id: DOG.toUpperCase() }, { dog_id: DOG.slice(1) }, { dog_id: `${DOG}\n` }, { dog_id: DOG, photo: 'private-fixture-secret' }, { dog_id: DOG, name: 'private-fixture-secret' }, { dog_id: DOG, visitor_id: VISITOR }]) assertError(await post(app, '/api/arrivals', payload), 400);
assertError(await post(app, '/api/arrivals', '{"dog_id":', { 'content-type': 'application/json' }), 400);
assertError(await post(app, '/api/arrivals', JSON.stringify({ dog_id: DOG }), { 'content-type': 'text/plain' }), 415);
assertError(await post(app, '/api/arrivals', JSON.stringify({ dog_id: DOG }), { 'content-type': 'application/x-www-form-urlencoded' }), 415);
assertError(await post(app, '/api/arrivals', JSON.stringify({ dog_id: DOG, extra: 'x'.repeat(1024) }), { 'content-type': 'application/json' }), 413);
});
test('Origin validation uses configured public origin and allows absent Origin', async t => {
const { app } = await fixture(t);
for (const origin of [undefined, ORIGIN]) {
const headers = origin ? { origin } : {};
assert.equal((await post(app, '/api/visits', { visitor_id: VISITOR }, headers)).statusCode, 204);
}
for (const origin of ['https://foreign.example', 'null', 'not-an-origin', `${ORIGIN}/path`, `${ORIGIN}, https://foreign.example`]) {
assertError(await post(app, '/api/visits', { visitor_id: VISITOR }, { origin, host: 'foreign.example', 'x-forwarded-host': 'foreign.example' }), 403);
}
assert.equal((await post(app, '/api/visits', { visitor_id: VISITOR }, { origin: ORIGIN, host: 'forged.example' })).statusCode, 204);
});
test('API rate limit is 60 per connection IP; forged forwarded headers do not evade it', async t => {
const { app } = await fixture(t, { rateLimit: { max: 60, timeWindow: 60000 } });
for (let i = 0; i < 60; i++) assert.equal((await app.inject({ url: '/api/arrivals/count', headers: { 'x-forwarded-for': `203.0.113.${i}` } })).statusCode, 200);
assertError(await app.inject({ url: '/api/arrivals/count', headers: { 'x-forwarded-for': '198.51.100.99' } }), 429);
assert.equal((await app.inject({ url: '/api/arrivals/count', remoteAddress: '192.0.2.12' })).statusCode, 200);
assert.equal((await app.inject('/')).statusCode, 200);
});
test('closed/unavailable store returns generic 503 without identifiers or paths', async t => {
const { app, store } = await fixture(t);
store.close();
assertError(await app.inject('/api/arrivals/count'), 503);
assertError(await post(app, '/api/visits', { visitor_id: VISITOR }), 503);
assertError(await post(app, '/api/arrivals', { dog_id: DOG }), 503);
});
test('app close owns its store exactly once and import never listens', async t => {
let closed = 0;
const { app } = await fixture(t, { store: { close() { closed++; }, arrivalCount() { return 0; } } });
assert.equal(app.server.listening, false);
await app.close();
await app.close();
assert.equal(closed, 1);
});
test('unknown API and POST routes return JSON 404, never SPA HTML', async t => {
const { app } = await fixture(t);
for (const method of ['GET', 'POST']) for (const url of ['/api', '/api/', '/api/nope', '/api/monthly-visitors', '/api/visits/nope']) assertError(await app.inject({ method, url, headers: { accept: 'text/html' } }), 404);
assertError(await app.inject({ method: 'POST', url: '/somewhere', headers: { accept: 'text/html' } }), 404);
});
test('static MIME/cache and SPA navigation preserve worker/WASM/font/model asset routes', async t => {
const { app } = await fixture(t);
for (const url of ['/', '/index.html', '/journey/window']) {
const response = await app.inject({ url, headers: { accept: 'text/html' } });
assert.equal(response.statusCode, 200);
assert.match(response.body, /arrival fixture/);
assert.equal(response.headers['cache-control'], 'no-store');
}
assert.equal((await app.inject({ method: 'HEAD', url: '/journey/window', headers: { accept: 'text/html' } })).statusCode, 200);
for (const url of ['/assets/index-AbCd1234.js', '/assets/worker-ZyXw9876.js']) {
const response = await app.inject(url);
assert.equal(response.statusCode, 200);
assert.match(response.headers['content-type'], /javascript/);
assert.equal(response.headers['cache-control'], 'public,max-age=31536000,immutable');
}
const wasm = await app.inject('/assets/ort-wasm.wasm');
assert.equal(wasm.statusCode, 200);
assert.equal(wasm.headers['content-type'], 'application/wasm');
assert.equal(wasm.headers['cache-control'], 'no-cache');
for (const url of ['/fonts/font.woff', '/models/model.onnx']) assert.equal((await app.inject(url)).statusCode, 200);
for (const url of ['/assets/nope.js', '/models/missing.onnx', '/route.json', '/missing']) assertError(await app.inject({ url, headers: { accept: url === '/missing' ? '*/*' : 'text/html' } }), 404);
});
test('traversal, symlink escapes and guessed data/config files are never static responses', async t => {
const { app } = await fixture(t);
for (const url of ['/../outside.txt', '/%2e%2e/outside.txt', '/%2e%2e%2foutside.txt', '/assets/%2e%2e/%2e%2e/outside.txt', '/assets/escaped.txt', '/counts.sqlite', '/counts.sqlite-wal', '/counts.sqlite-shm', '/config.json', '/.env', '/.git/config', '/server/config.mjs', '/api/%2e%2e%2findex.html']) {
const response = await app.inject({ url, headers: { accept: 'text/html' } });
assert.ok([400, 404].includes(response.statusCode), `${url}: ${response.statusCode}`);
assert.doesNotMatch(response.body, /private-fixture-secret|arrival fixture/);
}
});
test('unhashed existing GLB assets never receive an immutable build cache policy', async t => {
const { app, root } = await fixture(t);
const directory = join(root, 'dist', 'assets', 'window');
mkdirSync(directory);
writeFileSync(join(directory, 'grandma-house-surround.glb'), 'geometry fixture');
const response = await app.inject('/assets/window/grandma-house-surround.glb?v=2');
assert.equal(response.statusCode, 200);
assert.equal(response.headers['cache-control'], 'no-cache');
});
test('percent-encoded API route aliases share the same IP limit, including writes', async t => {
const { app, store } = await fixture(t, { rateLimit: { max: 1, timeWindow: 60000 } });
assert.equal((await post(app, '/api/arrivals', { dog_id: DOG })).statusCode, 204);
for (const url of ['/%61pi/arrivals', '/a%70i/arrivals', '/ap%69/arrivals']) assertError(await post(app, url, { dog_id: 'f'.repeat(64) }), 429);
assert.equal(store.arrivalCount(), 1);
assertError(await app.inject('/%61pi/arrivals/count'), 429);
});
test('framework malformed-URL errors never echo request identifiers and are no-store', async t => {
const { app } = await fixture(t);
const response = await app.inject('/api/%ZZ/private-fixture-secret');
assertError(response, 400);
assert.doesNotMatch(response.body, /%ZZ|private-fixture-secret|FST_ERR_BAD_URL/);
});
test('same-root symlink aliases cannot expose blocked database or configuration files', async t => {
const { app, root } = await fixture(t);
for (const [name, target] of [['alias.txt', 'counts.sqlite'], ['config-alias.txt', 'config.json'], ['wal-alias.txt', 'counts.sqlite-wal']]) {
symlinkSync(join(root, 'dist', target), join(root, 'dist', name));
const response = await app.inject(`/${name}`);
assertError(response, 404);
}
});
test('unknown API paths return JSON 404 before unsupported or malformed body parsing', async t => {
const { app } = await fixture(t);
for (const url of ['/api', '/api/nope', '/api/visits/nope']) {
assertError(await post(app, url, 'private-fixture-secret', { 'content-type': 'text/plain' }), 404);
assertError(await post(app, url, '{"private-fixture-secret":', { 'content-type': 'application/json' }), 404);
}
});
test('absolute-form HTTP targets cannot bypass same-origin or API rate guards', async t => {
const { app, store } = await fixture(t, { rateLimit: { max: 1, timeWindow: 60000 } });
const { default: inject } = await import('light-my-request');
for (const origin of ['https://foreign.example', ORIGIN]) {
const response = await inject((request, reply) => {
request.url = 'http://arrival.example/api/arrivals';
app.routing(request, reply);
}, { url: '/api/arrivals', method: 'POST', payload: { dog_id: DOG }, headers: { origin } });
assertError(response, 400);
}
assert.equal(store.arrivalCount(), 0);
});
test('raw unnormalized traversal is rejected before the routing/static boundary', async t => {
const { app } = await fixture(t);
const { default: inject } = await import('light-my-request');
for (const target of ['/api/%2e%2e/index.html', '/../index.html', '/assets/../index.html']) {
const response = await inject((request, reply) => {
request.url = target;
app.routing(request, reply);
}, { url: '/', headers: { accept: 'text/html' } });
assertError(response, 404);
}
});
+48
View File
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import { access, readFile } from 'node:fs/promises';
import { test } from 'node:test';
import { loadTypescript } from './support/load-typescript.mjs';
const moduleURL = new URL('../src/ui/ArrivalCount.ts', import.meta.url);
assert.equal(await access(moduleURL).then(() => true, () => false), true, 'arrival count presentation module must exist');
const { renderArrivalCount } = await loadTypescript(moduleURL);
const element = () => ({ hidden: false, textContent: '', set innerHTML(_value) { assert.fail('numeric copy must use textContent'); } });
test('unknown initial count hides all count nodes without inventing zero', () => {
const elements = [element(), element()];
renderArrivalCount(elements, undefined);
assert.ok(elements.every(node => node.hidden));
assert.ok(elements.every(node => node.textContent === ''));
});
test('server zero is a real visible zero with the exact Korean copy', () => {
const elements = [element(), element()];
renderArrivalCount(elements, 0);
for (const node of elements) { assert.equal(node.hidden, false); assert.equal(node.textContent, '지금까지 0번 도착했어요'); }
});
test('arrival count groups using Korean locale and updates both placements', () => {
const elements = [element(), element()];
renderArrivalCount(elements, 1234);
for (const node of elements) { assert.equal(node.hidden, false); assert.equal(node.textContent, '지금까지 1,234번 도착했어요'); }
});
test('failed or invalid reads preserve the last valid count text and visibility', () => {
const elements = [element(), element()];
renderArrivalCount(elements, 1234);
for (const count of [undefined, -1, 0.5, '0', Number.MAX_SAFE_INTEGER + 1, NaN]) {
renderArrivalCount(elements, count);
for (const node of elements) { assert.equal(node.hidden, false); assert.equal(node.textContent, '지금까지 1,234번 도착했어요'); }
}
});
test('malformed initial count is hidden rather than displayed', () => {
for (const count of [-1, 0.5, '0', Number.MAX_SAFE_INTEGER + 1, NaN]) {
const node = element(); renderArrivalCount([node], count); assert.equal(node.hidden, true); assert.equal(node.textContent, '');
}
});
test('count presentation has no animation, polling or render-demand dependency', async () => {
const source = await readFile(moduleURL, 'utf8');
assert.doesNotMatch(source, /innerHTML|requestAnimationFrame|setInterval|setTimeout|RenderDemand|invalidate/);
});
+144
View File
@@ -0,0 +1,144 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, mkdirSync, statSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { openStore } from '../server/db.mjs';
import { readConfig } from '../server/config.mjs';
import Database from 'better-sqlite3';
const VISITOR_A = '12345678-1234-4123-8123-123456789abc';
const VISITOR_B = '87654321-4321-4321-a321-cba987654321';
const DOG = 'a'.repeat(64);
function fixture(t, close = () => {}) {
const root = mkdtempSync(join(tmpdir(), 'arrival-db-'));
t.after(() => { close(); rmSync(root, { recursive: true, force: true }); });
return { root, dbPath: join(root, 'private', 'counts.sqlite') };
}
function inspect(path, fn) {
const db = new Database(path, { readonly: true, fileMustExist: true });
try { return fn(db); } finally { db.close(); }
}
test('real native SQLite uses exact schema, WAL, bounded busy timeout and private files', t => {
let store;
const { dbPath } = fixture(t, () => store?.close());
store = openStore({ dbPath });
assert.equal(store.arrivalCount(), 0);
inspect(dbPath, db => {
assert.match(db.prepare('SELECT sqlite_version() AS version').get().version, /^\d+\.\d+\.\d+$/);
assert.equal(db.pragma('journal_mode', { simple: true }), 'wal');
assert.equal(db.pragma('user_version', { simple: true }), 1);
assert.deepEqual(db.pragma('table_info(visitors_daily)').map(c => c.name), ['visitor_id', 'visit_date', 'first_seen_at', 'last_seen_at']);
assert.deepEqual(db.pragma('table_info(arrivals)').map(c => c.name), ['dog_id', 'first_arrived_at']);
assert.ok(db.pragma('index_list(visitors_daily)').some(i => db.pragma(`index_info(${i.name})`).map(c => c.name).join() === 'visit_date'));
});
if (process.platform !== 'win32') {
assert.equal(statSync(join(dbPath, '..')).mode & 0o777, 0o700);
for (const path of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) assert.equal(statSync(path).mode & 0o077, 0);
}
assert.throws(() => openStore({ dbPath, busyTimeoutMs: -1 }));
assert.throws(() => openStore({ dbPath, busyTimeoutMs: 10001 }));
});
test('daily upserts preserve first time, update last time and MAU is DISTINCT across days', t => {
let store;
const { dbPath } = fixture(t, () => store?.close());
let at = '2026-10-01T00:00:00.000Z';
store = openStore({ dbPath, now: () => new Date(at) });
store.recordVisit(VISITOR_A);
at = '2026-10-01T01:00:00.000Z';
store.recordVisit(VISITOR_A);
let rows = inspect(dbPath, db => db.prepare('SELECT * FROM visitors_daily').all());
assert.deepEqual(rows, [{ visitor_id: VISITOR_A, visit_date: '2026-10-01', first_seen_at: '2026-10-01T00:00:00.000Z', last_seen_at: at }]);
at = '2026-10-02T00:00:00.000Z';
store.recordVisit(VISITOR_A);
store.recordVisit(VISITOR_B);
assert.equal(store.monthlyVisitors('2026-10'), 2);
assert.equal(inspect(dbPath, db => db.prepare('SELECT COUNT(*) AS n FROM visitors_daily').get().n), 3);
assert.equal(store.monthlyVisitors('2026-09'), 0);
});
for (const [before, after, previousMonth, nextMonth] of [
['2026-10-31T14:59:59.000Z', '2026-10-31T15:00:00.000Z', '2026-10', '2026-11'],
['2026-12-31T14:59:59.000Z', '2026-12-31T15:00:00.000Z', '2026-12', '2027-01'],
]) test(`Seoul month boundary ${previousMonth} is independent of process timezone`, t => {
let store;
const { dbPath } = fixture(t, () => store?.close());
let at = before;
store = openStore({ dbPath, now: () => new Date(at) });
store.recordVisit(VISITOR_A);
at = after;
store.recordVisit(VISITOR_B);
assert.equal(store.monthlyVisitors(previousMonth), 1);
assert.equal(store.monthlyVisitors(nextMonth), 1);
const dates = inspect(dbPath, db => db.prepare('SELECT visit_date FROM visitors_daily ORDER BY visit_date').all());
assert.equal(dates[1].visit_date, `${nextMonth}-01`);
});
test('arrivals are idempotent, first timestamp persists and migrations reopen without data loss', t => {
let store;
const { dbPath } = fixture(t, () => store?.close());
let at = '2026-10-08T00:00:00.000Z';
store = openStore({ dbPath, now: () => new Date(at) });
store.recordArrival(DOG);
at = '2026-10-09T00:00:00.000Z';
store.recordArrival(DOG);
assert.equal(store.arrivalCount(), 1);
store.close();
store.close();
store = openStore({ dbPath });
assert.equal(store.arrivalCount(), 1);
assert.deepEqual(inspect(dbPath, db => db.prepare('SELECT * FROM arrivals').all()), [{ dog_id: DOG, first_arrived_at: '2026-10-08T00:00:00.000Z' }]);
});
test('strict month validation handles year bounds and December rollover', t => {
let store;
const { dbPath } = fixture(t, () => store?.close());
store = openStore({ dbPath });
for (const month of [undefined, null, 202610, '', '2026-1', '2026-00', '2026-13', '0000-01', '10000-01', '2026-10-01', ' 2026-10', '2026-10\n']) assert.throws(() => store.monthlyVisitors(month));
for (const month of ['0001-01', '0099-12', '2026-12', '9999-12']) assert.equal(store.monthlyVisitors(month), 0);
});
test('configuration requires explicit absolute private DB path and strict origin/port', t => {
const { dbPath } = fixture(t);
const config = readConfig({ DB_PATH: dbPath });
assert.deepEqual(config, { dbPath, distRoot: resolve('dist'), host: '127.0.0.1', port: 3000, publicOrigin: 'http://127.0.0.1:3000' });
assert.equal(readConfig({ DB_PATH: dbPath, HOST: '0.0.0.0', PORT: '4010' }).host, '0.0.0.0');
assert.throws(() => readConfig({}));
assert.throws(() => readConfig({ DB_PATH: 'relative.sqlite' }));
assert.throws(() => readConfig({ DB_PATH: ':memory:' }));
for (const path of ['local.sqlite', 'src/db.sqlite', 'public/db.sqlite', 'dist/db.sqlite']) assert.throws(() => readConfig({ DB_PATH: resolve(path) }));
for (const value of ['0', '-1', '65536', '3e3', '3000x', '1.2', '']) assert.throws(() => readConfig({ DB_PATH: dbPath, PORT: value }));
assert.throws(() => readConfig({ DB_PATH: dbPath, NODE_ENV: 'production' }));
for (const origin of ['null', 'ftp://example.com', 'https://user:pass@example.com', 'https://example.com/path', 'https://example.com?x', 'https://example.com#x']) assert.throws(() => readConfig({ DB_PATH: dbPath, NODE_ENV: 'production', PUBLIC_ORIGIN: origin }));
assert.equal(readConfig({ DB_PATH: dbPath, NODE_ENV: 'production', PUBLIC_ORIGIN: 'https://example.com' }).publicOrigin, 'https://example.com');
});
test('canonical DB path validation catches existing and future paths through symlinks', t => {
const { root } = fixture(t);
symlinkSync(resolve('.'), join(root, 'source-link'), 'dir');
for (const path of [join(root, 'source-link', 'db.sqlite'), join(root, 'source-link', 'new', 'db.sqlite')]) {
assert.throws(() => readConfig({ DB_PATH: path }));
assert.throws(() => openStore({ dbPath: path }));
}
mkdirSync(join(root, 'private'), { mode: 0o700 });
symlinkSync(join(root, 'private'), join(root, 'safe-link'), 'dir');
const config = readConfig({ DB_PATH: join(root, 'safe-link', 'db.sqlite') });
assert.equal(config.dbPath, join(root, 'private', 'db.sqlite'));
});
test('DB paths reject raw and symlink-resolved whitespace before native filename trimming', async t => {
const { root, dbPath } = fixture(t);
const { writeFileSync } = await import('node:fs');
const { openReadOnlyStore } = await import('../server/db.mjs');
assert.throws(() => readConfig({ DB_PATH: `${dbPath} ` }));
assert.throws(() => openStore({ dbPath: `${dbPath}\t` }));
assert.throws(() => openReadOnlyStore(`${dbPath}\n`));
const spacedTarget = join(root, 'target.sqlite ');
writeFileSync(spacedTarget, '', { mode: 0o600 });
const alias = join(root, 'alias.sqlite');
symlinkSync(spacedTarget, alias);
assert.throws(() => readConfig({ DB_PATH: alias }));
assert.throws(() => openStore({ dbPath: alias }));
});
+307
View File
@@ -0,0 +1,307 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import vm from 'node:vm';
import ts from 'typescript';
import { loadTypescript } from './support/load-typescript.mjs';
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
const css = await readFile(new URL('../src/style.css', import.meta.url), 'utf8');
const ast = ts.createSourceFile('main.ts', main, ts.ScriptTarget.Latest, true);
const statement = predicate => {
const found = ast.statements.find(node => predicate(node, node.getText(ast)));
assert.ok(found, 'required arrival lifecycle wiring is present');
return found.getText(ast);
};
const fn = name => statement(node => ts.isFunctionDeclaration(node) && node.name?.text === name);
const variable = name => statement(node => ts.isVariableStatement(node) && node.declarationList.declarations.some(declaration => declaration.name.getText(ast) === name));
const expression = prefix => statement((node, text) => ts.isExpressionStatement(node) && text.startsWith(prefix));
const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; };
const tick = async () => { for (let index = 0; index < 25; index++) await Promise.resolve(); };
// These source contracts supplement executed production handlers below. Removing
// the gate, moving arrival to generic ready, or awaiting telemetry breaks them.
test('collector construction is explicitly opt-in and is started once outside rendering', () => {
assert.match(variable('arrivalTracker'), /import\.meta\.env\.VITE_ARRIVAL_ANALYTICS === 'true'/);
assert.equal((main.match(/arrivalTracker\?\.start\(\)/g) ?? []).length, 1);
assert.doesNotMatch(fn('frame'), /arrivalTracker|\/api\/|makeDogId/);
assert.doesNotMatch(main, /await\s+arrivalTracker/);
});
test('AI success owns the only arrival call and captures a request-local token', () => {
const ai = fn('generateAIDepth');
assert.equal((main.match(/arrivalTracker\?\.arrived\(/g) ?? []).length, 1);
assert.match(ai, /const arrivalToken = arrivalJourneyFor\(sourceTexture\)/);
assert.match(ai, /!renderLost && !meshFailure && arrivalToken !== null && arrivalToken === photoArrivalToken/);
assert.ok(ai.indexOf('setDepth(texture)') < ai.indexOf('arrivalTracker?.arrived(arrivalToken)'));
assert.ok(ai.indexOf('arrivalTracker?.arrived(arrivalToken)') < ai.indexOf("experience.setState('ready')"));
assert.match(fn('teardown'), /arrivalTracker\?\.dispose\(\)/);
});
test('photo snapshot comes from the name store and survives retry without rereading the name', () => {
assert.match(fn('beginMediaLoad'), /petName\.name/);
assert.doesNotMatch(fn('arrivalJourneyFor'), /petName\.|querySelector|\.value/);
assert.match(fn('invalidateDepthRequests'), /cancelArrivalJourney\(\)/);
assert.match(fn('showMeshFailure'), /cancelArrivalJourney\(\)/);
assert.doesNotMatch(fn('generateAIDepth'), /beginMediaLoad|petNameForms/);
});
test('two initially hidden counts sit below entry controls and the settings heading', () => {
const nodes = [...html.matchAll(/<p\b[^>]*data-arrival-count[^>]*>/g)];
assert.equal(nodes.length, 2);
for (const [node] of nodes) { assert.match(node, /\bhidden\b/); assert.doesNotMatch(node, /aria-live|role=/); }
assert.match(html, /id="notice-status"[^>]*><\/span>\s*<\/div>\s*<p[^>]*data-arrival-count/);
assert.match(html, /id="settings-title"[\s\S]*?<\/div>\s*<p[^>]*data-arrival-count/);
assert.match(css, /\.arrival-count\s*\{[^}]*(?:font-size|line-height)/);
});
class Element {
dataset = {}; attributes = {}; listeners = new Map(); hidden = false; open = false; inert = false; value = ''; isConnected = true; style = {};
addEventListener(type, callback, { signal } = {}) { const set = this.listeners.get(type) ?? new Set(); set.add(callback); this.listeners.set(type, set); signal?.addEventListener('abort', () => set.delete(callback)); }
async dispatch(type, extra = {}) { for (const callback of [...(this.listeners.get(type) ?? [])]) await callback({ target: this, ...extra }); }
setAttribute(key, value) { this.attributes[key] = value; }
removeAttribute(key) { delete this.attributes[key]; }
focus() { if (!this.inert) this.root.activeElement = this; }
showModal() { this.open = true; }
close() { this.open = false; void this.dispatch('close'); }
getBoundingClientRect() { return { left: 0, top: 0, right: 400, bottom: 800 }; }
remove() {}
}
async function harness({ enabled = true, reduced = false, pendingHash = false, retryArrival = false, cryptoFailure = '', invalidInitialId = false, blockedStorage = false } = {}) {
// Assert the actual feature before importing its separate production modules.
const initialization = ['arrivalTracker', 'photoArrivalSnapshot', 'photoArrivalTexture', 'photoArrivalToken'].map(variable).join('\n');
const identity = await loadTypescript(new URL('../src/analytics/Identity.ts', import.meta.url));
const { createArrivalTracker } = await loadTypescript(new URL('../src/analytics/ArrivalTracker.ts', import.meta.url));
const { createTransport } = await loadTypescript(new URL('../src/analytics/Transport.ts', import.meta.url));
const { renderArrivalCount } = await loadTypescript(new URL('../src/ui/ArrivalCount.ts', import.meta.url));
const { createPetNameStore } = await loadTypescript(new URL('../src/state/PetName.ts', import.meta.url));
const { initExperienceUI } = await loadTypescript(new URL('../src/ui/ExperienceUI.ts', import.meta.url));
const root = new Element(), motion = new Element(), timers = new Map(); let timerId = 0;
motion.matches = reduced;
const elements = Object.fromEntries([...html.matchAll(/\bid="([^"]+)"/g)].map(([, id]) => [id, new Element()]));
const countNodes = [new Element(), new Element()]; countNodes.forEach(node => { node.hidden = true; });
for (const node of Object.values(elements)) node.root = root;
root.querySelector = selector => elements[selector.slice(1)] ?? null;
root.querySelectorAll = selector => selector === '[data-arrival-count]' ? countNodes : [];
root.documentElement = new Element();
root.defaultView = { localStorage: { getItem() { return null; }, setItem() {} }, matchMedia: () => motion,
setTimeout(callback, ms) { timers.set(++timerId, { callback, ms }); return timerId; }, clearTimeout(id) { timers.delete(id); } };
const listenerAbort = new AbortController();
const petName = createPetNameStore(() => root.defaultView.localStorage); petName.setName('호롱');
const experience = initExperienceUI(root, listenerAbort.signal, () => {}, () => {}, () => {}, petName);
const requests = [], hashes = [], sleeps = [], inferences = [], sampleFetches = [], mediaLoads = [];
let identityReads = 0, transportCreations = 0, collectorCreations = 0, uuidCalls = 0, storageReads = 0, storageWrites = 0, digestCalls = 0;
const noop = () => {};
const disposable = () => ({ dispose: noop });
class Photo { width = 400; height = 500; src = 'blob:photo'; async decode() {} }
const texture = () => ({ ...disposable(), image: new Photo() });
const emptyTex = texture();
const context = {
document: root, listenerAbort, petName, experience, HTMLImageElement: Photo, Image: Photo,
AbortController, Object, Array, Error, Promise, Number, Boolean, Math, performance,
THREE: { SRGBColorSpace: 'srgb', Texture: class { constructor(image) { this.image = image; } } },
AI_DEPTH: { modelName: 'Small', inputSize: 770 },
active: emptyTex, emptyTex, activeIsVideo: false, aspect: 0, mediaRequest: 0, depthRequest: 0,
mediaBusy: false, manualDepthBusy: false, aiDepthBusy: false, renderLost: false, disposed: false, meshFailure: false,
pendingCutout: null, cutout: null, cutoutTexture: null, cutoutEnabled: false,
pendingSampleFraming: false, pendingPairDepth: null, pendingSampleDebugImage: null, pendingTestPhotoHasNoDepth: false, activeTestPhotoHasNoDepth: false,
aiDepthButton: elements['ai-depth'], imageInput: elements.image, videoInput: elements.video,
depthFileInput: elements['depth-image'], depthProvenance: elements['depth-provenance'], sampleButton: elements.sample,
testPhotoInput: { value: '' }, retry: new Element(), debugOutput: new Element(), hintElement: { dataset: {} },
depthUniforms: { uTexture: {}, uDepthMap: { value: disposable() } }, depthMap: disposable(),
petMat: disposable(), petDepth: { geometry: disposable(), setVideo: noop }, empty: { style: {} },
depthDebug: { clear: noop, setSample: noop }, DepthProcessor: { flatTexture: texture, toTexture: texture },
setDepth(value) { context.appliedDepth = value; }, setMode: noop, setStatus: noop, showHint: noop,
resetViewCalls:0,resetNewPhotoView(){context.resetViewCalls++;},syncPhotoControls: noop, resize: noop, clearCutout: noop, renderPhotoTexture: () => context.active,
cancelDoorInteraction: noop, resetHintForMedia: noop, hideHintAfterDrag: noop, clearPendingTestState: noop, clearPendingPair: noop,
peekingUI: { cancel: noop, setState: noop, dispose: noop }, gyro: { setPaused: noop, dispose: noop },
aiDepthProvider: { cancel: noop, getDepthMap(image) { const pending = deferred(); inferences.push({ ...pending, image }); return pending.promise; } },
MediaLoader: class { constructor(ready, status) { this.ready = ready; this.status = status; context.loaderInstance = this; } load(file) { mediaLoads.push(file); } cancelPending() {} retry() {} dispose() {} },
RenderRecovery: class { constructor(_canvas, _dialog, _button, lost, restored) { context.loseGraphics = lost; context.restoreGraphics = restored; } dispose() {} },
fetch(path) { const pending = deferred(); sampleFetches.push({ ...pending, path }); return pending.promise; },
File: class { constructor(_, name, options) { this.name = name; this.type = options.type; } },
URL: { createObjectURL: () => 'blob:depth', revokeObjectURL: noop }, sampleDepthProvider: { getDepthMap: async () => texture() },
rendererSelectionGeneration: 0, comparisonGeneration: 0, requestedRenderer: 'b', selectedRenderer: 'b', comparisonRequested: false, comparison: null,
meshErrorDialog: elements['mesh-render-error'], endComparison: noop, originalRenderer: null,
meshRenderer: { restore: noop, dispose: noop }, renderer: { domElement: new Element(), dispose: noop },
rendererSelect: {}, rendererNotice: {}, courtyard: disposable(), peekingGrid: disposable(), doorMotion: { cancel: noop },
raf: 0, cancelAnimationFrame: noop, observer: { disconnect: noop }, effectInput: {}, sensitivityInput: {}, resetButton: {}, gyroButton: {}, pointer: disposable(),
blenderFrame: disposable(), houseSurround: disposable(), housePhotoDepth: disposable(), windowFinish: disposable(), windowReveal: disposable(), windowRevealMaterial: disposable(),
frameMaterials: [], petDepthMaterial: disposable(), geometry: disposable(), frameGeometry: disposable(),
getVisitorId(options) {
identityReads++;
return identity.getVisitorId({ ...options, getStorage() {
storageReads++;
if (blockedStorage) throw new Error('storage unavailable');
const storage = options.getStorage();
return { getItem(key) { return invalidInitialId ? 'invalid stored id' : storage?.getItem(key); }, setItem(key, value) { storageWrites++; storage?.setItem(key, value); } };
} });
},
makeDogId(visitor, name, digest) { const id = createHash('sha256').update(JSON.stringify([1, visitor, name.normalize('NFC').trim() || '멍멍이'])).digest('hex'); const pending = deferred(); hashes.push({ visitor, name, id, ...pending }); return pendingHash ? pending.promise : identity.makeDogId(visitor, name, digest); },
createTransport() { transportCreations++; return createTransport({
fetch: async (path, init = {}) => { requests.push({ path, body: init.body && JSON.parse(init.body), signal: init.signal }); return { ok: !(retryArrival && path === '/api/arrivals'), status: retryArrival && path === '/api/arrivals' ? 503 : init.body ? 204 : 200, json: async () => ({ count: 7 }) }; },
sleep: (ms, signal) => { const pending = deferred(); sleeps.push({ ...pending, ms, signal }); return pending.promise; }, retryDelays: [1, 2]
}); },
createArrivalTracker(options) { collectorCreations++; return createArrivalTracker(options); }, renderArrivalCount,
};
// Test doubles are limited to external media/GPU/browser facilities. Lifecycle
// logic, retry transport, token cancellation, count renderer and UI are real.
const crypto = {
get randomUUID() { if (cryptoFailure === 'randomUUID') throw new Error('crypto UUID unavailable'); return () => { uuidCalls++; return '86f755c0-921b-4d34-a09c-5fcc8e4a21c6'; }; },
get subtle() {
if (cryptoFailure === 'subtle') throw new Error('subtle unavailable');
return { get digest() { if (cryptoFailure === 'digest') throw new Error('digest unavailable'); return (algorithm, data) => { digestCalls++; assert.equal(algorithm, 'SHA-256'); return Promise.resolve(Uint8Array.from(createHash('sha256').update(Buffer.from(data)).digest()).buffer); }; } };
},
};
Object.defineProperty(context, 'crypto', { get() { if (cryptoFailure === 'crypto') throw new Error('crypto unavailable'); return crypto; } });
context.meshErrorDialog ??= new Element();
vm.createContext(context);
const functions = ['loadPersonalImage', 'cancelArrivalJourney', 'arrivalJourneyFor', 'beginMediaLoad', 'applyMedia', 'invalidateDepthRequests', 'syncAIDepthButton', 'generateAIDepth', 'loadSample', 'showMeshFailure', 'teardown'].map(fn);
const handlers = [expression('imageInput.onchange'), expression('videoInput.onchange'), expression("depthFileInput?.addEventListener('change'"), expression("document.querySelector('#flow-flat')"), expression("document.querySelector('#flow-retry')")];
const code = [initialization, ...functions, variable('loader'), variable('renderRecovery'), ...handlers, expression('arrivalTracker?.start')].join('\n').replaceAll('import.meta.env.VITE_ARRIVAL_ANALYTICS', JSON.stringify(enabled ? 'true' : 'false'));
vm.runInContext(ts.transpileModule(code, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText, context);
await tick();
const accept = (kind = 'personal') => {
const input = kind === 'video' ? context.videoInput : context.imageInput;
input.onchange({ target: { files: [{ type: kind === 'video' ? 'video/mp4' : 'image/png' }], value: 'selected' } });
const photo = texture(); context.loaderInstance.ready(photo, 0.8, kind === 'video' ? {} : null); return photo;
};
const finishAI = async (index = inferences.length - 1) => { inferences[index].resolve(texture()); await tick(); };
const respondSample = async () => { sampleFetches.at(-1).resolve({ ok: true, blob: async () => ({}), headers: { get: () => 'image/jpeg' } }); await tick(); };
return { context, elements, countNodes, timers, requests, hashes, sleeps, inferences, sampleFetches, mediaLoads, accept, finishAI, respondSample,
arrivals: () => requests.filter(request => request.path === '/api/arrivals'), counters: () => ({ identityReads, transportCreations, collectorCreations, uuidCalls, storageReads, storageWrites, digestCalls }) };
}
test('disabled static build creates no collector, identity, hash, storage or API work', async () => {
const h = await harness({ enabled: false }); h.accept(); await h.finishAI();
assert.deepEqual(h.counters(), { identityReads: 0, transportCreations: 0, collectorCreations: 0, uuidCalls: 0, storageReads: 0, storageWrites: 0, digestCalls: 0 });
assert.equal(h.requests.length, 0); assert.equal(h.hashes.length, 0); assert.ok(h.countNodes.every(node => node.hidden));
assert.equal(h.elements.experience.dataset.flow, 'ready'); h.context.teardown();
});
test('valid AI success counts immediately for normal and reduced motion, without waiting for presentation', async () => {
for (const reduced of [false, true]) {
const h = await harness({ reduced }); h.accept(); await h.finishAI();
assert.equal(h.arrivals().length, 1); assert.deepEqual(Object.keys(h.arrivals()[0].body), ['dog_id']);
assert.equal(h.elements.experience.dataset.flow, 'ready'); assert.equal(h.timers.size, reduced ? 0 : 1);
assert.equal(h.countNodes[0].textContent, h.countNodes[1].textContent); assert.ok(h.countNodes.every(node => !node.hidden));
assert.equal(h.requests.filter(request => request.path === '/api/visits').length, 1); h.context.teardown();
}
});
test('picker cancellation creates no journey, while retry retains the accepted photo name', async () => {
const h = await harness(); h.context.imageInput.onchange({ target: { files: [], value: '' } });
assert.equal(h.inferences.length, 0); h.accept(); h.context.petName.setName('초코');
h.inferences[0].reject(new Error('AI unavailable')); await tick(); assert.equal(h.arrivals().length, 0);
const retry = h.context.generateAIDepth(); await h.finishAI(); await retry;
assert.equal(h.hashes.at(-1).name, '호롱'); assert.equal(h.arrivals().length, 1);
h.accept(); await h.finishAI(); assert.equal(h.hashes.at(-1).name, '초코'); assert.equal(h.arrivals().length, 2); h.context.teardown();
});
test('all replacement samples stay excluded after successful AI', async () => {
for (const selected of ['dog1', 'dog2', 'dog3', 'dog4', 'dog5']) {
const h = await harness(), loading = h.context.loadSample(selected); await h.respondSample();
await loading; h.context.loaderInstance.ready({ image: new h.context.HTMLImageElement(), dispose() {} }, 0.8, null);
await h.finishAI();
assert.equal(h.elements.experience.dataset.flow, 'ready'); assert.equal(h.arrivals().length, 0); assert.equal(h.hashes.length, 0);
assert.equal(h.requests.filter(request => request.path === '/api/visits').length, 1); h.context.teardown();
}
});
test('video, manual-depth, flat-view and errors do not create arrivals', async () => {
const video = await harness(); video.accept('video'); assert.equal(video.inferences.length, 0); assert.equal(video.arrivals().length, 0); video.context.teardown();
for (const action of ['manual', 'flat', 'media-error', 'AI-error']) {
const h = await harness(); h.accept();
if (action === 'manual') { h.context.depthFileInput.files = [{}]; await h.context.depthFileInput.dispatch('change'); }
if (action === 'flat') await h.elements['flow-flat'].dispatch('click');
if (action === 'media-error') h.context.loaderInstance.status('cannot load');
if (action === 'AI-error') { h.inferences[0].reject(new Error('failed')); await tick(); }
else await h.finishAI();
assert.equal(h.arrivals().length, 0, action); h.context.teardown();
}
});
test('photo replacement and stale AI replies cannot switch to a newer identity', async () => {
const h = await harness(); h.accept(); h.context.petName.setName('초코'); h.accept();
await h.finishAI(0); assert.equal(h.arrivals().length, 0); await h.finishAI(1);
assert.equal(h.arrivals().length, 1); assert.equal(h.hashes.at(-1).name, '초코'); h.context.teardown();
});
test('failed replacement cannot rearm a new name for the old active texture', async () => {
const h = await harness(); h.accept(); h.inferences[0].reject(new Error('failed')); await tick();
h.context.petName.setName('초코'); h.context.imageInput.onchange({ target: { files: [{ type: 'image/png' }], value: 'new' } });
h.context.loaderInstance.status('new photo failed'); const retry = h.context.generateAIDepth(); await h.finishAI(); await retry;
assert.equal(h.arrivals().length, 0); h.context.teardown();
});
test('graphics loss, render failure and teardown invalidate in-flight AI eligibility', async () => {
for (const action of ['context-loss', 'mesh-failure', 'dispose']) {
const h = await harness(); h.accept();
if (action === 'context-loss') h.context.loseGraphics();
if (action === 'mesh-failure') h.context.showMeshFailure();
if (action === 'dispose') h.context.teardown();
await h.finishAI(); assert.equal(h.arrivals().length, 0, action);
if (action !== 'dispose') h.context.teardown();
}
});
test('photo replacement, flat view, manual depth, errors and losses cancel a pending hash', async () => {
for (const action of ['replacement', 'flat', 'manual', 'media-error', 'context-loss', 'mesh-failure', 'dispose']) {
const h = await harness({ pendingHash: true }); h.accept(); await h.finishAI(); assert.equal(h.hashes.length, 1);
if (action === 'replacement') h.accept();
if (action === 'flat') await h.elements['flow-flat'].dispatch('click');
if (action === 'manual') { h.context.depthFileInput.files = [{}]; await h.context.depthFileInput.dispatch('change'); }
if (action === 'media-error') h.context.loaderInstance.status('cannot load');
if (action === 'context-loss') h.context.loseGraphics();
if (action === 'mesh-failure') h.context.showMeshFailure();
if (action === 'dispose') h.context.teardown();
h.hashes[0].resolve(h.hashes[0].id); await tick(); assert.equal(h.arrivals().length, 0, action);
if (action !== 'dispose') h.context.teardown();
}
});
test('invalidation aborts a delayed network retry without delaying the ready UI', async () => {
for (const action of ['replacement', 'flat', 'manual', 'media-error', 'context-loss', 'mesh-failure', 'dispose']) {
const h = await harness({ retryArrival: true }); h.accept(); await h.finishAI();
assert.equal(h.elements.experience.dataset.flow, 'ready'); assert.equal(h.arrivals().length, 1); assert.equal(h.sleeps.length, 1);
if (action === 'replacement') h.accept();
if (action === 'flat') await h.elements['flow-flat'].dispatch('click');
if (action === 'manual') { h.context.depthFileInput.files = [{}]; await h.context.depthFileInput.dispatch('change'); }
if (action === 'media-error') h.context.loaderInstance.status('cannot load');
if (action === 'mesh-failure') h.context.showMeshFailure();
if (action === 'context-loss') h.context.loseGraphics();
if (action === 'dispose') h.context.teardown();
assert.equal(h.sleeps[0].signal.aborted, true, action); h.sleeps[0].resolve(); await tick(); assert.equal(h.arrivals().length, 1, action);
if (action !== 'dispose') h.context.teardown();
}
});
test('invalid stored identity and unavailable storage recover without affecting AI startup', async () => {
for (const options of [{ invalidInitialId: true }, { blockedStorage: true }]) {
const h = await harness(options); h.accept(); await h.finishAI();
assert.equal(h.elements.experience.dataset.flow, 'ready'); assert.equal(h.arrivals().length, 1);
assert.equal(h.counters().uuidCalls, 1); h.context.teardown();
}
});
test('throwing crypto, randomUUID, subtle and digest getters leave the experience usable', async () => {
for (const cryptoFailure of ['crypto', 'randomUUID', 'subtle', 'digest']) {
const h = await harness({ cryptoFailure }); h.accept(); await h.finishAI();
assert.equal(h.elements.experience.dataset.flow, 'ready', cryptoFailure); assert.equal(h.arrivals().length, 0, cryptoFailure);
h.context.teardown();
}
});
test('explicit AI retry after flat, manual depth or graphics recovery re-arms the original photo snapshot', async () => {
for (const reason of ['flat', 'manual', 'context-loss']) {
const h = await harness(); h.accept();
h.inferences[0].reject(new Error('initial inference failed')); await tick();
if (reason === 'flat') await h.elements['flow-flat'].dispatch('click');
if (reason === 'manual') { h.context.depthFileInput.files = [{}]; await h.context.depthFileInput.dispatch('change'); }
if (reason === 'context-loss') { h.context.loseGraphics(); h.context.restoreGraphics(); }
h.context.petName.setName('보리');
const retry = h.context.generateAIDepth(); await h.finishAI(); await retry;
assert.equal(h.arrivals().length, 1, reason); assert.equal(h.hashes.at(-1).name, '호롱', reason);
h.context.teardown();
}
});
test('new-photo reset is called once on acceptance, never by picker cancellation, failure or AI retry',async()=>{
const h=await harness({reduced:true});h.accept();await h.finishAI();assert.equal(h.context.resetViewCalls,1);
h.context.imageInput.onchange({target:{files:[],value:''}});assert.equal(h.context.resetViewCalls,1);
const retry=h.context.generateAIDepth();await h.finishAI();await retry;assert.equal(h.context.resetViewCalls,1);
h.context.imageInput.onchange({target:{files:[{type:'image/png'}],value:'new'}});h.context.loaderInstance.status('failed decode');assert.equal(h.context.resetViewCalls,1);
const sample=h.context.loadSample('dog5');await h.respondSample();await sample;assert.equal(h.context.resetViewCalls,1);
h.context.loaderInstance.ready({...h.context.active,image:new h.context.HTMLImageElement()},.8,null);await h.finishAI();assert.equal(h.context.resetViewCalls,2);
h.context.teardown();
});
+146
View File
@@ -0,0 +1,146 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, existsSync, copyFileSync, rmSync, statSync, writeFileSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
const VISITOR = '12345678-1234-4123-8123-123456789abc';
const DOG = 'b'.repeat(64);
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'arrival-ops-'));
return { root, dbPath: join(root, 'private', 'counts.sqlite') };
}
function cli(script, args, dbPath, env = {}) {
return spawnSync(process.execPath, [resolve(`server/${script}.mjs`), ...args], { env: { ...process.env, DB_PATH: dbPath, ...env }, encoding: 'utf8' });
}
async function setup(t) {
const { openStore, migrateDatabase } = await import('../server/db.mjs');
const { default: Database } = await import('better-sqlite3');
const fixtureData = fixture();
let at = '2026-10-08T00:00:00.000Z';
const store = openStore({ dbPath: fixtureData.dbPath, now: () => new Date(at) });
t.after(() => { store.close(); rmSync(fixtureData.root, { recursive: true, force: true }); });
store.recordVisit(VISITOR); store.recordArrival(DOG);
at = '2026-10-09T00:00:00.000Z';
store.recordVisit(VISITOR); store.recordArrival(DOG);
return { ...fixtureData, store, openStore, migrateDatabase, Database };
}
function snapshot(Database, path) {
const db = new Database(path, { readonly: true, fileMustExist: true });
try {
assert.equal(db.pragma('integrity_check', { simple: true }), 'ok');
return { visits: db.prepare('SELECT * FROM visitors_daily ORDER BY visitor_id,visit_date').all(), arrivals: db.prepare('SELECT * FROM arrivals ORDER BY dog_id').all(), version: db.pragma('user_version', { simple: true }) };
} finally { db.close(); }
}
test('operational CLI entrypoints exist without starting an HTTP listener', () => {
for (const script of ['metrics', 'backup']) assert.ok(existsSync(resolve(`server/${script}.mjs`)), `missing ${script} CLI`);
});
test('metrics prints aggregate-only JSON from an existing read-only database', async t => {
const { store, dbPath } = await setup(t);
const output = cli('metrics', ['--month', '2026-10'], dbPath);
assert.equal(output.status, 0, output.stderr);
assert.deepEqual(JSON.parse(output.stdout), { month: '2026-10', monthlyVisitors: 1, arrivals: 1 });
assert.equal(output.stderr, '');
assert.doesNotMatch(output.stdout, /12345678|bbbbbbbbbb|visitor_id|dog_id/);
assert.equal(store.arrivalCount(), 1);
});
test('metrics invalid arguments, missing path and bad schema fail without creating/migrating a DB', async t => {
const { dbPath, root, Database } = await setup(t);
for (const args of [[], ['--month', '2026-13'], ['--month', '0000-01'], ['--month', '2026-1'], ['--month', '2026-10', '--extra'], ['--month', '2026-10', '--month', '2026-11']]) {
const output = cli('metrics', args, dbPath);
assert.notEqual(output.status, 0);
assert.equal(output.stdout, '');
assert.doesNotMatch(output.stderr, /12345678|bbbbbbbbbb|arrival-ops-|SELECT|SQLITE/);
}
const missing = join(root, 'typo', 'missing.sqlite');
assert.notEqual(cli('metrics', ['--month', '2026-10'], missing).status, 0);
assert.equal(existsSync(missing), false);
assert.equal(existsSync(join(root, 'typo')), false);
const malformed = join(root, 'bad.sqlite');
const db = new Database(malformed); db.exec('CREATE TABLE wrong(value TEXT)'); db.close();
assert.notEqual(cli('metrics', ['--month', '2026-10'], malformed).status, 0);
const verify = new Database(malformed);
assert.equal(verify.pragma('user_version', { simple: true }), 0); verify.close();
});
test('live SQLite backup restores both tables, first timestamps and metrics; restored writes work', async t => {
const { dbPath, root, store, openStore, Database } = await setup(t);
const backupPath = join(root, 'backups', 'snapshot.sqlite');
const before = snapshot(Database, dbPath);
assert.ok(existsSync(`${dbPath}-wal`));
const output = cli('backup', ['--output', backupPath], dbPath);
assert.equal(output.status, 0, output.stderr);
assert.doesNotMatch(output.stdout + output.stderr, /12345678|bbbbbbbbbb|visitor_id|dog_id/);
assert.deepEqual(snapshot(Database, backupPath), before);
if (process.platform !== 'win32') assert.equal(statSync(backupPath).mode & 0o077, 0);
store.recordArrival('c'.repeat(64));
assert.equal(store.arrivalCount(), 2);
assert.equal(snapshot(Database, backupPath).arrivals.length, 1);
store.close();
const restorePath = join(root, 'restore', 'counts.sqlite');
mkdirSync(join(root, 'restore'), { mode: 0o700 });
copyFileSync(backupPath, restorePath);
assert.deepEqual(snapshot(Database, restorePath), before);
assert.deepEqual(JSON.parse(cli('metrics', ['--month', '2026-10'], restorePath).stdout), { month: '2026-10', monthlyVisitors: 1, arrivals: 1 });
const restored = openStore({ dbPath: restorePath });
restored.recordArrival('d'.repeat(64));
assert.equal(restored.arrivalCount(), 2);
restored.close();
assert.equal(snapshot(Database, restorePath).arrivals.length, 2);
});
test('backup rejects overwrite, same source, invalid args, missing DB and destination failure', async t => {
const { dbPath, root, store } = await setup(t);
const existing = join(root, 'already.sqlite');
writeFileSync(existing, 'do not overwrite');
for (const args of [[], ['--output', dbPath], ['--output', existing], ['--output', 'relative.sqlite'], ['--output', join(root, 'new.sqlite'), '--extra']]) assert.notEqual(cli('backup', args, dbPath).status, 0);
assert.equal(readFileSync(existing, 'utf8'), 'do not overwrite');
const missing = join(root, 'missing.sqlite');
assert.notEqual(cli('backup', ['--output', join(root, 'out.sqlite')], missing).status, 0);
assert.equal(existsSync(missing), false);
await assert.rejects(store.backup(dbPath));
await assert.rejects(store.backup(existing));
const blockingFile = join(root, 'file'); writeFileSync(blockingFile, 'x');
await assert.rejects(store.backup(join(blockingFile, 'out.sqlite')));
});
test('failed versioned migration rolls back DDL and user_version without losing prior data', async t => {
const { root, migrateDatabase, Database } = await setup(t);
const path = join(root, 'rollback.sqlite');
const db = new Database(path);
db.exec('CREATE TABLE retained (value TEXT); INSERT INTO retained VALUES (\'kept\'); PRAGMA user_version = 1');
assert.throws(() => migrateDatabase(db, [{ version: 1, sql: '' }, { version: 2, sql: 'CREATE TABLE temporary_change(x TEXT); INVALID SQL STATEMENT;' }]));
assert.equal(db.pragma('user_version', { simple: true }), 1);
assert.equal(db.prepare('SELECT value FROM retained').get().value, 'kept');
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE name = 'temporary_change'").get().n, 0);
db.close();
});
test('default and backend builds explicitly select analytics disabled/enabled', () => {
const pkg = JSON.parse(readFileSync(resolve('package.json'), 'utf8'));
assert.match(pkg.scripts.build, /VITE_ARRIVAL_ANALYTICS=false/);
assert.match(pkg.scripts['build:server'], /VITE_ARRIVAL_ANALYTICS=true/);
});
test('backup never overwrites through raw or symlink-resolved whitespace filenames', async t => {
const { dbPath, root, store, Database } = await setup(t);
const { symlinkSync } = await import('node:fs');
const target = join(root, 'existing.sqlite');
await store.backup(target);
store.recordArrival('e'.repeat(64));
const before = snapshot(Database, target);
await assert.rejects(store.backup(`${target} `));
assert.deepEqual(snapshot(Database, target), before);
assert.equal(existsSync(`${target} `), false);
const alias = join(root, 'aliased.sqlite');
const spaced = join(root, 'canonical.sqlite ');
writeFileSync(spaced, 'untouched');
symlinkSync(spaced, alias);
await assert.rejects(store.backup(alias));
assert.equal(readFileSync(spaced, 'utf8'), 'untouched');
assert.notEqual(cli('backup', ['--output', `${target}\t`], dbPath).status, 0);
});
+61
View File
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {existsSync,readFileSync} from 'node:fs';
import vm from 'node:vm';import ts from 'typescript';import * as THREE from 'three';
import {loadFrameGeometry} from './support/load-frame-geometry.mjs';
function module(){const path=new URL('../src/objects/BlenderWindowFrame.ts',import.meta.url);assert.ok(existsSync(path),'original Blender frame adapter must exist');const exports={};vm.runInNewContext(ts.transpileModule(readFileSync(path,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText,{exports,require:name=>name==='three'?THREE:{GLTFLoader:class{}},AbortController,fetch:()=>{throw Error('tests must inject loader')}});return exports;}
function trianglesOutside(root,w,h){let count=0;root.traverse(o=>{if(!o.isMesh)return;const p=o.geometry.attributes.position,i=o.geometry.index;for(let k=0;k<i.count;k+=3){const ids=[i.getX(k),i.getX(k+1),i.getX(k+2)],xs=ids.map(j=>p.getX(j)),ys=ids.map(j=>p.getY(j)),eps=1e-6*Math.min(w,h);assert.ok(Math.max(...xs)<=-w/2+eps||Math.min(...xs)>=w/2-eps||Math.max(...ys)<=-h/2+eps||Math.min(...ys)>=h/2-eps,'triangle must not cover clear opening');count++;}});assert.equal(count,6988);}
test('actual Blender triangles resize without covering the aperture or distorting latch',()=>{
const {prepareFrameAsset}=module(),root=loadFrameGeometry();
const sourceLatch=root.getObjectByName('MovableSash_Brass');sourceLatch.geometry.computeBoundingBox();const originalLatchSize=sourceLatch.geometry.boundingBox.getSize(new THREE.Vector3()).toArray();
const asset=prepareFrameAsset(root);
for(const [w,h] of [[1,1.32],[1,1],[3,1],[1,3],[.2,4],[4,.2]]){
asset.resize(w,h,.6);trianglesOutside(root,w,h);assert.equal(root.position.z,.6);
const latch=root.getObjectByName('MovableSash_Brass'),size=new THREE.Vector3();latch.geometry.computeBoundingBox();latch.geometry.boundingBox.getSize(size);const s=Math.min(w,h);
originalLatchSize.forEach((v,i)=>assert.ok(Math.abs(size.getComponent(i)-v*s)<1e-6));
const bounds=new THREE.Box3().setFromObject(root);assert.ok(Math.abs(bounds.min.x-(-w/2-.122*s))<1e-6);assert.ok(Math.abs(bounds.min.y-(-h/2-.1815*s))<1e-6);
}
const capture=()=>{const values=[];root.traverse(o=>{if(o.isMesh)values.push([...o.geometry.attributes.position.array],[...o.geometry.attributes.uv.array]);});return values;};
asset.resize(1,1.32,.6);const before=capture();asset.resize(3,1,.6);asset.resize(1,1.32,.6);assert.deepEqual(capture(),before,'resizing never accumulates deformation');
asset.dispose();
});
test('asset uses length-compensated grain UVs and mirror-corrected normal map',()=>{
const {prepareFrameAsset,configureWoodMaterial}=module(),root=loadFrameGeometry(),asset=prepareFrameAsset(root);
const wood=root.getObjectByName('FixedSurround_Wood'),uv=wood.geometry.attributes.uv;
asset.resize(1,1.32,0);const canonical=[...uv.array];asset.resize(3,1,0);let changed=0;
for(let i=0;i<uv.count;i++){assert.equal(uv.getX(i),canonical[i*2]);if(Math.abs(uv.getY(i)-canonical[i*2+1])>1e-5)changed++;}assert.ok(changed>0);
const material=new THREE.MeshStandardMaterial({map:new THREE.Texture(),normalMap:new THREE.Texture(),roughnessMap:new THREE.Texture()});configureWoodMaterial(material);
for(const texture of [material.map,material.normalMap,material.roughnessMap]){assert.equal(texture.wrapS,THREE.ClampToEdgeWrapping);assert.equal(texture.wrapT,THREE.MirroredRepeatWrapping);}
const shader={fragmentShader:'#include <normal_fragment_maps>'};material.onBeforeCompile(shader,{});assert.match(shader.fragmentShader,/mapN\.y.*floor\(\s*vNormalMapUv\.y/);asset.dispose();
});
test('frame loading failure retains fallback, retry uses latest aspect/theme, and teardown rejects stale loads',async()=>{
const {BlenderWindowFrame}=module(),scene=new THREE.Scene(),changes=[],pending=[];
const frame=new BlenderWindowFrame(scene,(active,state)=>changes.push({active,state}),()=>new Promise((resolve,reject)=>pending.push({resolve,reject})));
frame.resize(2,1,.6);frame.apply('wood');const first=frame.load();pending.shift().reject(Error('offline'));await first;assert.equal(changes.at(-1).active,false);assert.equal(changes.at(-1).state,'error');
const retry=frame.load();frame.resize(1,3,.6);frame.apply('booth');pending.shift().resolve(loadFrameGeometry());await retry;assert.equal(changes.at(-1).active,false);frame.apply('wood');assert.equal(changes.at(-1).active,true);const asset=scene.children.find(x=>x.name==='blender-window-frame');assert.ok(asset.visible);trianglesOutside(asset,1,3);frame.dispose();assert.equal(scene.children.length,0);
const next=new BlenderWindowFrame(scene,()=>{},()=>new Promise(resolve=>pending.push({resolve})));const load=next.load();next.dispose();const stale=loadFrameGeometry();let disposed=0;stale.traverse(o=>{if(o.isMesh)o.geometry.addEventListener('dispose',()=>disposed++);});pending.shift().resolve(stale);await load;assert.equal(disposed,4);assert.equal(scene.children.length,0);
});
test('neutral camera contains the actual imported surround on phone, tablet and desktop',()=>{
const {prepareFrameAsset,fitWindowScale}=module(),root=loadFrameGeometry(),asset=prepareFrameAsset(root);
for(const [w,h] of [[375,812],[768,1024],[1440,900]])for(const aspect of [.25,1,4]){
const camera=new THREE.PerspectiveCamera(40,w/h,.1,100);camera.position.z=3;camera.updateMatrixWorld();
const scale=fitWindowScale(w/h,aspect);asset.resize(scale*aspect*.8,scale*.8,.6);
root.traverse(object=>{if(!object.isMesh)return;const p=object.geometry.attributes.position;for(let i=0;i<p.count;i++){const point=new THREE.Vector3().fromBufferAttribute(p,i).applyMatrix4(object.matrixWorld).project(camera);assert.ok(Math.abs(point.x)<=1&&Math.abs(point.y)<=1,`actual frame clips at ${w}x${h}, aspect ${aspect}`);}});
}
asset.dispose();
});
test('only movable sash slides into the fixed jamb pocket, including resize and delayed load',async()=>{
const {prepareFrameAsset,BlenderWindowFrame}=module(),root=loadFrameGeometry(),asset=prepareFrameAsset(root);
asset.resize(2,1,.6);asset.setOpen(.5);root.updateMatrixWorld(true);
assert.equal(root.getObjectByName('MovableSash').position.x,-1);
assert.equal(root.getObjectByName('FixedSurround').position.x,0);
const fixed=root.getObjectByName('FixedSurround_Wood'),sash=root.getObjectByName('MovableSash_Wood');
assert.equal(fixed.material.clippingPlanes,null);assert.equal(sash.material.clippingPlanes[0].constant,1.05);
const handle=asset.handlePosition(new THREE.Vector3());assert.ok(Math.abs(handle.x-.026)<1e-8);
asset.resize(1,2,.6);assert.equal(root.getObjectByName('MovableSash').position.x,-.5);
asset.setOpen(4);assert.equal(root.getObjectByName('MovableSash').position.x,-1);asset.setOpen(-1);assert.equal(root.getObjectByName('MovableSash').position.x,0);asset.dispose();
const scene=new THREE.Scene();let resolve;const frame=new BlenderWindowFrame(scene,()=>{},()=>new Promise(r=>resolve=r));
frame.resize(2,1,.6);frame.setOpen(.4);const loaded=frame.load();resolve(loadFrameGeometry());await loaded;
assert.equal(scene.children[0].getObjectByName('MovableSash').position.x,-.8);frame.dispose();
});
+47
View File
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import * as THREE from 'three';
import {loadTypescript} from './support/load-typescript.mjs';
const {CameraController}=await loadTypescript(new URL('../src/input/CameraController.ts',import.meta.url));
const {readCameraComposition,saveCameraComposition}=await loadTypescript(new URL('../src/input/CameraComposition.ts',import.meta.url));
test('wall experiment defaults locally, persists rollback and tolerates unavailable storage',()=>{
const items=new Map(),storage={getItem:k=>items.get(k),setItem:(k,v)=>items.set(k,v)};
assert.equal(readCameraComposition(storage),'wall');saveCameraComposition(storage,'legacy');assert.equal(readCameraComposition(storage),'legacy');saveCameraComposition(storage,'wall');assert.equal(readCameraComposition(storage),'wall');
assert.equal(readCameraComposition({getItem(){throw Error('blocked');}}),'wall');assert.equal(saveCameraComposition({setItem(){throw Error('blocked');}},'legacy'),false);
});
test('composition switch preserves input and projection state, exact rollback and neutral',()=>{
const camera=new THREE.PerspectiveCamera(40,.46,.1,100),control=new CameraController(camera);
control.setRoomBounds(.76,.95,.6);control.setView(1,.25);control.update(10,1);camera.updateMatrixWorld(true);const legacy={position:camera.position.toArray(),quaternion:camera.quaternion.toArray(),fov:camera.fov};
control.setCompositionMode('wall');control.update(10,1);assert.ok(camera.fov>40&&camera.fov<=65);assert.deepEqual(control.target.toArray(),[1,.25]);assert.deepEqual(control.current.toArray(),[1,.25]);assert.ok(camera.position.z<legacy.position[2]);
control.setCompositionMode('legacy');control.update(10,1);assert.deepEqual(camera.position.toArray(),legacy.position);assert.deepEqual(camera.quaternion.toArray(),legacy.quaternion);assert.equal(camera.fov,legacy.fov);
control.setCompositionMode('wall');control.reset();control.update(10,1);assert.deepEqual(camera.position.toArray(),[0,0,3]);assert.equal(camera.fov,40);
});
test('landscape extremes retain the original mirrored near-wall pose and fixed lens',()=>{
const poses=[];
for(const aspect of [1,1.5,2.164])for(const side of [-1,1]){
const c=new THREE.PerspectiveCamera(40,aspect,.1,100),ctrl=new CameraController(c);ctrl.setRoomBounds(.76096,.9512,.6);ctrl.setCompositionMode('wall');ctrl.setView(side,0);ctrl.update(10,1);
const half=.76096/2+.95*.76096,unit=half/1.45;
assert.ok(Math.abs(c.position.x-side*.65*half)<1e-10);assert.ok(Math.abs(c.position.z-(.6+.6*unit))<1e-10);assert.equal(c.fov,40);
const dir=c.getWorldDirection(new THREE.Vector3());assert.ok(Math.abs(Math.atan2(-dir.z,Math.abs(dir.x))*180/Math.PI-25)<1e-9);poses.push({side,p:c.position.toArray(),q:c.quaternion.toArray()});
}
for(let i=2;i<poses.length;i++){assert.deepEqual(poses[i].p,poses[i%2].p);assert.deepEqual(poses[i].q,poses[i%2].q);}
});
test('physical shallow-return experiment restores exact previous room geometry',async()=>{
const {loadFrameGeometry}=await import('./support/load-frame-geometry.mjs');
const {prepareHouseAsset}=await loadTypescript(new URL('../src/objects/BlenderHouseSurround.ts',import.meta.url));
const root=loadFrameGeometry(new URL('../public/assets/window/grandma-house-surround.glb',import.meta.url)),house=prepareHouseAsset(root);
const snapshot=()=>{const rows=[];root.traverse(o=>{if(o.isMesh)rows.push([o.name,...o.geometry.attributes.position.array]);});return rows;};
house.resize(.76,.95,.6);const before=snapshot();house.resize(.76,.95,.6,.76,true);assert.notDeepEqual(snapshot(),before);house.resize(.76,.95,.6,.76,false);assert.deepEqual(snapshot(),before);house.dispose();
});
test('real startup wiring restores saved legacy mode and switches both camera and physical room',async()=>{
const {readFile}=await import('node:fs/promises'),{default:ts}=await import('typescript'),{runInNewContext}=await import('node:vm');
const main=await readFile(new URL('../src/main.ts',import.meta.url),'utf8');
const code=main.slice(main.indexOf('let cameraCompositionStorage'),main.indexOf('const sceneZoom ='));
const elements=new Map(),callbacks=new Map();for(const id of ['#camera-composition','#camera-composition-note'])elements.set(id,{value:'',textContent:'',addEventListener:(_type,fn)=>callbacks.set(id,fn)});
const items=new Map([['behind-glass-camera-composition-v32','legacy']]),camera=new THREE.PerspectiveCamera(40,.46,.1,100),cameraController=new CameraController(camera);cameraController.setRoomBounds(.76,.95,.6);cameraController.setView(1,0);cameraController.update(10,1);const legacy=camera.position.toArray();
let roomMode,invalidations=0;
runInNewContext(ts.transpileModule(code,{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText,{window:{localStorage:{getItem:key=>items.get(key),setItem:(key,value)=>items.set(key,value)}},readCameraComposition,saveCameraComposition,camera,cameraController,houseSurround:{setCompositionMode:mode=>roomMode=mode},document:{querySelector:id=>elements.get(id)},listenerAbort:new AbortController(),sensitivity:1,renderDemand:{invalidate(){invalidations++;}}});
assert.equal(elements.get('#camera-composition').value,'legacy');assert.equal(roomMode,'legacy');cameraController.update(10,1);assert.deepEqual(camera.position.toArray(),legacy);
elements.get('#camera-composition').value='wall';callbacks.get('#camera-composition')();assert.equal(roomMode,'wall');assert.equal(items.get('behind-glass-camera-composition-v32'),'wall');assert.ok(camera.fov>40&&camera.fov<=65);
elements.get('#camera-composition').value='legacy';callbacks.get('#camera-composition')();assert.equal(roomMode,'legacy');assert.deepEqual(camera.position.toArray(),legacy);assert.equal(invalidations,2);
});
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import * as THREE from 'three';
import { loadTypescript } from './support/load-typescript.mjs';
import { loadFrameGeometry } from './support/load-frame-geometry.mjs';
const { CameraController } = await loadTypescript(new URL('../src/input/CameraController.ts', import.meta.url));
const { SceneZoomController } = await loadTypescript(new URL('../src/input/SceneZoomController.ts', import.meta.url));
const { prepareHouseAsset, houseViewScale } = await loadTypescript(new URL('../src/objects/BlenderHouseSurround.ts', import.meta.url));
const { prepareFrameAsset, fitWindowScale } = await loadTypescript(new URL('../src/objects/BlenderWindowFrame.ts', import.meta.url));
test('gentler .75 default and selectable .90 maximum widen both axes without changing forward distance', () => {
const camera = new THREE.PerspectiveCamera(40, 1, .1, 100), controller = new CameraController(camera);
for (const x of [-1, 0, 1]) for (const y of [-1, 0, 1]) {
controller.setView(x, y); controller.update(10, 1);
assert.deepEqual(camera.position.toArray(), [x * .75, y * .75, 3]);
const direction = camera.getWorldDirection(new THREE.Vector3());
assert.ok(direction.distanceTo(camera.position.clone().negate().normalize()) < 1e-12);
}
controller.setMaxOffset(5); controller.setView(-9, 9); controller.update(10, 2);
assert.deepEqual(camera.position.toArray(), [-.9, .9, 3], 'sensitivity and excess range cannot exceed the measured envelope');
});
test('old range choices, recenter, pinch hold and anchored projection zoom remain independent', () => {
const camera = new THREE.PerspectiveCamera(40, 1, .1, 100), controller = new CameraController(camera), zoom = new SceneZoomController(camera);
zoom.setZoom(2.1, { x: .25, y: -.4 });
const projection = camera.projectionMatrix.toArray(), offset = zoom.offset.toArray();
for (const value of [.35, .5, .65, .8, .9]) {
controller.setMaxOffset(value); controller.setView(1, -1); controller.update(10, 2);
assert.deepEqual(camera.position.toArray(), [value, -value, 3]);
controller.reset(); controller.update(.016, 1); assert.deepEqual(camera.position.toArray(), [0, 0, 3]);
controller.setView(1, -1); controller.update(10, 2);
assert.equal(camera.position.x, value, 'recenter retains the chosen movement range');
}
controller.setView(-1, 1); controller.update(1 / 60, 1);
controller.hold(); const held = camera.position.toArray(); controller.update(.1, 1);
assert.deepEqual(camera.position.toArray(), held);
assert.deepEqual(camera.projectionMatrix.toArray(), projection); assert.deepEqual(zoom.offset.toArray(), offset);
assert.equal(zoom.value, 2.1); assert.equal(camera.near, .1); assert.equal(camera.fov, 40);
});
test('room-relative default and maximum camera paths keep the eye and visible near rays outside the real geometry', () => {
const house=loadFrameGeometry(new URL('../public/assets/window/grandma-house-surround.glb',import.meta.url)),frame=loadFrameGeometry();
const roomAsset=prepareHouseAsset(house),frameAsset=prepareFrameAsset(frame),group=new THREE.Group();group.add(house,frame);
let rays=0;
for(const[vw,vh]of[[375,812],[844,390],[1440,900]])for(const aspect of[.05,.2,.56,.8,1,1.77,5,20]){
const fit=fitWindowScale(vw/vh,aspect)*houseViewScale('wood'),w=fit*aspect*.8,h=fit*.8,s=Math.min(w,h),floor=-h/2-.24*s;
roomAsset.resize(w,h,.6);frameAsset.resize(w,h,.6);
const camera=new THREE.PerspectiveCamera(40,vw/vh,.1,100),control=new CameraController(camera);control.setRoomBounds(w,h,.6);
for(const cap of[.75,.9])for(const open of[0,.5,1]){control.setMaxOffset(cap);frameAsset.setOpen(open);group.updateMatrixWorld(true);
for(const x of[-1,-.5,0,.5,1])for(const y of[-1,-.5,0,.5,1]){
control.setView(x,y);control.update(10,1);camera.updateMatrixWorld(true);assert.ok(camera.position.y>floor);
for(const px of[-1,0,1])for(const py of[-1,0,1]){
const corner=new THREE.Vector3(px,py,-1).unproject(camera);assert.ok(corner.y>floor-1e-8,'near corners stay above the floor');
const ray=new THREE.Raycaster();ray.setFromCamera(new THREE.Vector2(px,py),camera);const hit=ray.intersectObject(group,true).find(h=>!(h.object.material.clippingPlanes??[]).some(p=>p.distanceToPoint(h.point)<0));
if(hit){const depth=-hit.point.clone().applyMatrix4(camera.matrixWorldInverse).z;assert.ok(depth>=camera.near-1e-7,JSON.stringify({vw,vh,aspect,cap,open,x,y,px,py,depth,near:camera.near,position:camera.position.toArray(),hit:hit.object.name}));}rays++;
}
}
}
}
console.log('Room envelope visible-ray and floor checks:',rays);roomAsset.dispose();frameAsset.dispose();
});
+15 -13
View File
@@ -3,16 +3,16 @@ import { readFile } from 'node:fs/promises';
const controller = await readFile(new URL('../src/input/CameraController.ts', import.meta.url), 'utf8');
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
const main = await readFile(new URL('../src/rendering/OriginalGlassRenderer.ts', import.meta.url), 'utf8');
const historyShader = await readFile(new URL('../src/shaders/region-history.frag', import.meta.url), 'utf8');
const mosaicShader = await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8');
assert.match(controller, /maxOffset = 0\.5/);
assert.match(controller, /clamp\(value, 0, 0\.9\)/);
assert.match(controller, /maxOffset = 0\.75/);
assert.match(controller, /clamp\(value, 0\.35, 0\.90\)/);
assert.match(controller, /multiplyScalar\(sensitivity\)/);
assert.match(html, /id="camera-offset" type="range" min="0" max="0\.9" step="0\.01" value="0\.5"/);
assert.match(html, /id="camera-offset" type="range" min="0\.35" max="0\.90" step="0\.01" value="0\.75"/);
assert.match(html, /id="sensitivity" type="range" min="0\.2" max="2"/);
assert.match(html, /Camera Offset <output id="cameraOut">0\.50/);
assert.match(html, /권장 0\.35–0\.65 · 시점 이동 범위/);
assert.match(html, /움직임 범위 <output id="cameraOut">0\.75/);
assert.match(html, /움직임 범위[\s\S]*살짝[\s\S]*넓게/);
assert.match(mosaicShader, /edgeTaper=style<=\.5\?1\.25:\.75/);
assert.match(mosaicShader, /historyTexel=clamp\(floor\(fract\(gridUv\)\*8\.\),vec2\(0\.\),vec2\(7\.\)\)/);
assert.equal(Math.floor(0.45 * 8), 3, 'split sample just left of the half-cell boundary stays in texel 3');
@@ -20,24 +20,26 @@ assert.equal(Math.floor(0.50 * 8), 4, 'split sample at the half-cell boundary en
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const cameraPosition = (target, sensitivity, maxOffset) => clamp(target * sensitivity, -1, 1) * maxOffset;
assert.equal(cameraPosition(1, 1, 0.5), 0.5, 'default offset reaches the configured midpoint');
assert.equal(cameraPosition(1, 1, 0.9), 0.9, 'maximum offset reaches 0.9');
assert.equal(cameraPosition(1, 2, 0.9), 0.9, 'sensitivity does not exceed the view clamp');
assert.equal(clamp(1, 0, 0.9), 0.9, 'UI maximum matches controller clamp');
assert.equal(cameraPosition(1, 1, 0.75), 0.75, 'default offset reaches the gentler 0.75');
assert.equal(cameraPosition(1, 1, 0.90), 0.90, 'maximum offset reaches 0.90');
assert.equal(cameraPosition(1, 2, 0.90), 0.90, 'sensitivity does not exceed the view clamp');
assert.equal(clamp(1, 0.35, 0.90), 0.90, 'UI maximum matches controller clamp');
assert.equal(clamp(0, 0.35, 0.90), 0.35, 'UI minimum matches controller clamp');
const signatureStart = main.indexOf('function currentSplitHistorySignature()');
const signatureEnd = main.indexOf('\nfunction clearSplitHistory()', signatureStart);
assert.ok(signatureStart >= 0 && signatureEnd > signatureStart, 'history reset signature exists');
const signature = main.slice(signatureStart, signatureEnd);
for (const key of ['mode', 'effectInput.value', 'mosaicStyleInput.value', 'active.uuid', 'depth.uuid', 'uRegionAvailable', 'uGridSize', 'uStrength', 'uRegionStrength', 'uCurvature', 'uPaneSize', 'matrixWorld', 'uDepthScale', 'uDepthBias', 'renderTarget.width', 'regionAtlasTarget.width', 'regionPaletteTarget.width']) {
for (const key of ['mode', 'effect', 'style', 'active.uuid', 'depth.uuid', 'uRegionAvailable', 'uGridSize', 'uStrength', 'uRegionStrength', 'uCurvature', 'uPaneSize', 'matrixWorld', 'uDepthScale', 'uDepthBias', 'renderTarget.width', 'regionAtlasTarget.width', 'regionPaletteTarget.width']) {
assert.ok(signature.includes(key), `model-reset signature includes ${key}`);
}
assert.doesNotMatch(signature, /uContentViewProjection|cameraController|uContentCameraPosition/, 'camera movement must not reset split history');
assert.match(main, /function render\(dt: number, output: THREE\.WebGLRenderTarget \| null = null\)/, 'explicit A single view still defaults to the screen');
assert.match(main, /const writeIndex = 1 - splitHistoryRead/);
assert.match(main, /uPreviousHistory\.value = splitHistoryTargets\[splitHistoryRead\]\.texture/);
assert.match(main, /uHistoryBlend\.value = splitHistoryInitialized \? 1 - Math\.exp\(-dt \/ 0\.07\) : 1/);
assert.match(main, /updateSplitHistory\(dt\); renderer\.setRenderTarget\(null\); renderer\.render\(scene, camera\)/, 'inactive view updates the signature and clears stale history');
assert.match(main, /splitHistoryTargets\.forEach\(\(target\) => target\.dispose\(\)\)/);
assert.match(main, /updateSplitHistory\(dt\); renderer\.setRenderTarget\(output\); renderer\.render\(scene, camera\)/, 'inactive view updates the signature and clears stale history');
assert.match(main, /splitHistoryTargets\.forEach\(target=>target\.dispose\(\)\)/);
assert.match(main, /target\.setSize\(atlasWidth, atlasHeight\)/);
assert.match(historyShader, /if\(uHistoryBlend>=\.9999\)\{gl_FragColor=current;return;\}/, 'first frame avoids sampling uninitialized history');
assert.match(historyShader, /gl_FragColor=mix\(previous,current,clamp\(uHistoryBlend,0\.,1\.\)\)/);
+298
View File
@@ -0,0 +1,298 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import { loadTypescript } from './support/load-typescript.mjs';
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
const source = await readFile(new URL('../src/ui/ExperienceUI.ts', import.meta.url), 'utf8');
const exports = await loadTypescript(new URL('../src/ui/ExperienceUI.ts', import.meta.url));
class Element {
dataset = {}; attributes = {}; listeners = new Map(); hidden = false; open = false; inert = false; value = ''; isConnected = true;
addEventListener(type, fn, { signal } = {}) { const list = this.listeners.get(type) ?? new Set(); list.add(fn); this.listeners.set(type, list); signal?.addEventListener('abort', () => list.delete(fn)); }
dispatch(type, extra = {}) { for (const fn of [...(this.listeners.get(type) ?? [])]) fn({ target: this, ...extra }); }
setAttribute(key, value) { this.attributes[key] = value; }
focus() { if (!this.inert) this.root.activeElement = this; }
showModal() { this.open = true; }
close() { this.open = false; this.dispatch('close'); }
getBoundingClientRect() { return { left: 0, top: 0, right: 400, bottom: 800 }; }
}
function harness(reduced = false) {
const root = new Element(), media = new Element(), timers = new Map(); let timerId = 0;
media.matches = reduced;
const els = Object.fromEntries([...html.matchAll(/\bid="([^"]+)"/g)].map(([, id]) => [id, new Element()]));
for (const element of Object.values(els)) element.root = root;
root.querySelectorAll = () => []; root.documentElement = new Element(); root.querySelector = selector => els[selector.slice(1)] ?? null;
root.defaultView = { localStorage: { getItem() { return null; }, setItem() {} }, matchMedia: () => media,
setTimeout(fn, ms) { timers.set(++timerId, { fn, ms }); return timerId; }, clearTimeout(id) { timers.delete(id); } };
let reveals = 0;
const abort = new AbortController(), ui = exports.initExperienceUI(root, abort.signal, () => {}, () => { reveals += 1; });
const tick = () => { const [id, timer] = timers.entries().next().value; timers.delete(id); timer.fn(); return timer.ms; };
return { root, els, ui, media, timers, abort, tick, get reveals() { return reveals; } };
}
test('only an explicit real AI start drives the selected photo, without a completion timer', () => {
const h = harness();
assert.equal(typeof h.ui.startJourney, 'function', 'AI work needs its own truthful journey start');
h.ui.setPhoto('blob:user-photo'); h.ui.setState('loading');
assert.notEqual(h.els['flow-overlay'].dataset.journey, 'driving', 'plain file loading is not AI travel');
h.ui.startJourney(); h.ui.setDetail('CPU/WASM에서 입체감을 계산하고 있어요');
assert.equal(h.els['flow-photo'].src, 'blob:user-photo');
assert.equal(h.els['flow-overlay'].dataset.journey, 'driving');
assert.equal(h.els['flow-overlay'].attributes['aria-busy'], 'true');
assert.equal(h.ui.isBlocked(), true); assert.equal(h.timers.size, 0, 'slow inference must not arrive on a timer');
assert.match(h.els['flow-detail'].textContent, /CPU\/WASM/);
});
test('real success holds arrival for 1350ms, unpacks for 1500ms, then reveals the scene', () => {
const h = harness(); h.ui.setPhoto('blob:photo'); h.ui.startJourney(); h.ui.setState('ready');
assert.equal(h.els.experience.dataset.flow, 'ready', 'inference has truthfully completed');
assert.equal(h.ui.isBlocked(), true, 'scene input waits for the short presentation');
assert.equal(h.els['canvas-wrap'].inert, true);
assert.equal(h.els['flow-overlay'].hidden, false);
assert.equal(h.els['flow-overlay'].dataset.journey, 'arrived');
assert.equal(h.els['flow-overlay'].attributes['aria-busy'], 'false');
assert.match(h.els['flow-title'].textContent, /할머니 집에 도착/);
assert.equal(h.els['flow-detail'].hidden, true, 'technical success text stays out of the arrival moment');
assert.equal(h.els['flow-actions'].hidden, true);
h.els['flow-overlay'].dispatch('animationend', { animationName: 'journey-arrival-fade' });
assert.equal(h.els['flow-overlay'].hidden, false, 'old animation events cannot skip the holds');
assert.equal(h.tick(), 1350);
assert.equal(h.els['flow-overlay'].dataset.journey, 'unpacking');
assert.equal(h.els['flow-title'].textContent, '짐을 푸는 중…');
assert.equal(h.ui.isBlocked(), true); assert.equal(h.reveals, 0);
assert.equal(h.tick(), 1500);
assert.equal(h.els['flow-overlay'].hidden, true); assert.equal(h.timers.size, 0);
assert.equal(h.ui.isBlocked(), false); assert.equal(h.els['canvas-wrap'].inert, false);
assert.equal(h.root.activeElement, h.els['canvas-wrap']); assert.equal(h.reveals, 1);
});
test('failure stops the trip and flat fallback cannot masquerade as arrival', () => {
const h = harness(); h.ui.setPhoto('blob:photo'); h.ui.startJourney(); h.ui.setState('error', true);
assert.equal(h.els['flow-overlay'].dataset.journey, 'idle'); assert.equal(h.els['flow-retry'].hidden, false);
assert.equal(h.els['flow-flat'].hidden, false); assert.doesNotMatch(h.els['flow-title'].textContent, /도착/);
assert.equal(h.timers.size, 0); h.ui.setState('ready'); assert.equal(h.els['flow-overlay'].hidden, true);
});
test('motion pause and reduced motion never pause AI or fabricate progress', () => {
const h = harness(); h.ui.setPhoto('blob:photo'); h.ui.startJourney();
assert.equal(h.els['flow-overlay'].dataset.motion, 'running');
assert.equal(h.els.experience.dataset.flow, 'loading'); assert.equal(h.els['flow-overlay'].attributes['aria-busy'], 'true');
h.media.matches = true; h.media.dispatch('change'); assert.equal(h.els['flow-overlay'].dataset.motion, 'paused');
h.ui.setState('ready'); assert.equal(h.els['flow-overlay'].hidden, true);
assert.equal(h.timers.size, 0); assert.equal(h.ui.isBlocked(), false);
assert.equal(h.root.activeElement, h.els['canvas-wrap'], 'reduced-motion reveal focuses the now-interactive scene');
const reduced = harness(true); reduced.ui.setPhoto('blob:photo'); reduced.ui.startJourney();
assert.equal(reduced.els['flow-overlay'].dataset.motion, 'paused');
});
test('replacement and retry cancel old arrival cleanup without hiding the newer operation', () => {
const h = harness(); h.ui.setPhoto('blob:first'); h.ui.startJourney(); h.ui.setState('ready');
const oldCleanup = [...h.timers.values()][0].fn;
h.ui.setPhoto(''); h.ui.setState('loading'); h.ui.setPhoto('blob:second'); h.ui.startJourney(); oldCleanup();
assert.equal(h.els['flow-photo'].src, 'blob:second'); assert.equal(h.els['flow-overlay'].hidden, false);
assert.equal(h.els['flow-overlay'].dataset.journey, 'driving'); assert.equal(h.els.experience.dataset.flow, 'loading');
h.ui.setState('error', true); h.ui.startJourney(); assert.equal(h.els['flow-overlay'].dataset.journey, 'driving');
});
test('render loss blocks input and stops travel/arrival; teardown removes all pending animation work', () => {
const h = harness(); h.ui.setPhoto('blob:photo'); h.ui.startJourney(); h.ui.setRenderLost(true);
assert.equal(h.ui.isBlocked(), true); assert.notEqual(h.els['flow-overlay'].dataset.journey, 'driving');
h.ui.setState('ready'); assert.equal(h.els['flow-overlay'].hidden, true); assert.equal(h.ui.isBlocked(), true);
h.ui.setRenderLost(false); assert.equal(h.ui.isBlocked(), false);
h.ui.startJourney(); h.ui.setState('ready'); assert.equal(h.timers.size, 1); h.abort.abort();
assert.equal(h.timers.size, 0); h.media.matches=true;h.media.dispatch('change');
assert.notEqual(h.els['flow-overlay'].dataset.journey, 'driving');
});
test('both arrival stages cancel safely on replacement, recovery, reduced motion and disposal', () => {
for (const stage of ['arrived', 'unpacking']) {
for (const reason of ['photo', 'loading', 'error', 'render-loss', 'reduced-motion', 'dispose']) {
const h = harness(); h.ui.setPhoto('blob:photo'); h.ui.startJourney(); h.ui.setState('ready');
if (stage === 'unpacking') h.tick();
const stale = [...h.timers.values()][0].fn;
if (reason === 'photo') h.ui.setPhoto('blob:replacement');
if (reason === 'loading') h.ui.startJourney();
if (reason === 'error') h.ui.setState('error', true);
if (reason === 'render-loss') h.ui.setRenderLost(true);
if (reason === 'reduced-motion') { h.media.matches = true; h.media.dispatch('change'); }
if (reason === 'dispose') h.abort.abort();
const title = h.els['flow-title'].textContent, hidden = h.els['flow-overlay'].hidden;
stale();
assert.equal(h.timers.size, 0, `${stage}/${reason} leaves no pending timer`);
assert.equal(h.els['flow-title'].textContent, title, `${stage}/${reason} ignores stale callback`);
assert.equal(h.els['flow-overlay'].hidden, hidden);
if (reason === 'photo' || reason === 'reduced-motion') {
assert.equal(hidden, true); assert.equal(h.ui.isBlocked(), false);
}
}
}
});
test('opening settings dismisses the arrival without stealing dialog focus later', () => {
const h = harness(); h.ui.setPhoto('blob:photo'); h.ui.startJourney(); h.ui.setState('ready'); h.tick();
const stale = [...h.timers.values()][0].fn;
h.els['settings-open'].dispatch('click');
assert.equal(h.els['flow-overlay'].hidden, true); assert.equal(h.timers.size, 0);
assert.equal(h.root.activeElement, h.els['settings-close']); stale();
assert.equal(h.root.activeElement, h.els['settings-close']); assert.equal(h.ui.isBlocked(), true);
});
test('the illustration is original inline SVG with contained photo and no scrolling or progress meter', async () => {
const css = await readFile(new URL('../src/style.css', import.meta.url), 'utf8');
assert.match(html, /id="journey"/); assert.match(html, /class="journey-car"/); assert.match(html, /class="journey-house"/);
assert.doesNotMatch(html, /id="journey-motion"/);
assert.doesNotMatch(html, /arrival-scroll|scroll-story|<progress|role="progressbar"/);
assert.match(css, /\.journey-passenger[\s\S]*?object-fit:contain/);
assert.match(css, /data-journey=driving/); assert.match(css, /data-motion=paused/);
assert.match(css, /prefers-reduced-motion:reduce/);
});
test('short wide layouts keep the full photo passenger above the road without clipping', async () => {
const css = await readFile(new URL('../src/style.css', import.meta.url), 'utf8');
assert.match(css, /width:min\(var\(--journey-car-width\),\s*calc\(\(var\(--journey-height\)\s*-\s*38px\)\s*\*\s*5\s*\/\s*3\)\)/,
'car width must also be bounded by available illustration height, not only viewport width');
for (const [viewportWidth, viewportHeight] of [[1280,450],[500,400],[1024,600],[900,431],[375,667],[320,568],[1440,900]]) {
const compact = viewportHeight <= 650;
const illustrationHeight = compact ? Math.max(100, Math.min(.27 * viewportHeight, 160)) : Math.max(144, Math.min(.3 * viewportHeight, 236));
const illustrationWidth = Math.min(compact ? 380 : 460, viewportWidth * (compact ? .8 : .88));
const carWidth = Math.min(illustrationWidth * (compact ? .54 : .58), (illustrationHeight - 38) * 5 / 3, 267);
// Same box geometry as CSS; reserve 8px for the photo rotation and gentle bob.
assert.ok(illustrationHeight - 24 - carWidth * 3 / 5 - 8 >= 0, `${viewportWidth}×${viewportHeight} leaves the photo unclipped`);
}
});
test('unpacking hops the contained original photo before walking and fades only in the final 200ms', async () => {
const css = await readFile(new URL('../src/style.css', import.meta.url), 'utf8');
assert.match(html, /class="journey-traveler"/);
assert.match(css, /data-journey=unpacking\] \.journey-traveler[^}]*animation:journey-walk 1\.3s/);
assert.match(css, /data-journey=unpacking\] \.journey-passenger[^}]*animation:journey-waddle 1\.3s/);
assert.match(css, /data-journey=unpacking\] \{ animation:journey-arrival-fade \.2s ease-in 1\.3s both/);
assert.match(css, /@keyframes journey-walk\s*\{\s*0%,28%\s*\{ left:16%; transform:translateX\(-10%\); \}/);
assert.match(css, /@keyframes journey-waddle[^@]*28%\s*\{ top:35%;/);
assert.match(css, /@keyframes journey-waddle[^@]*100%\s*\{ top:35%;/);
assert.match(css, /@keyframes journey-waddle[^@]*rotate\(5deg\)/);
assert.ok(css.indexOf('#flow-overlay[data-motion=paused] .journey *') > css.indexOf('animation:journey-waddle'), 'manual pause wins over animation shorthand reset');
const h = harness(); h.ui.setPhoto('blob:original-photo'); h.ui.startJourney();
h.ui.setState('ready'); h.tick();
assert.equal(h.els['flow-photo'].src, 'blob:original-photo');
assert.equal(h.els['flow-overlay'].dataset.motion, 'running');
assert.equal(h.tick(), 1500, 'walking completes on the existing reveal timer');
assert.equal(h.ui.isBlocked(), false);
});
test('walking photo stays inside responsive journey bounds through its full route', () => {
for (const [w, h] of [[460,236],[380,160],[256,153.36],[300,100],[170,170],[180,100]]) {
const c=Math.min(w*(h<=160?.54:.58),(h-38)*5/3,267);
for(let i=0;i<=100;i++) {
const t=i/100; const walk=Math.max(0,(t-.28)/.72); const drop=Math.min(t/.24,1);
const left=(.16+.715*walk)*w+(-.1-.29*walk)*c+.22*c;
// Conservative rotated card envelope, including the 3px upward footstep.
const rotationAllowance=Math.sin(5*Math.PI/180)*.65*.6*c;
assert.ok(left-rotationAllowance>=0);
assert.ok(left+.34*c+rotationAllowance<=w);
const top=h-24-.6*c+.35*.6*c*drop;
assert.ok(top-3-rotationAllowance>=0);
assert.ok(top+.65*.6*c+rotationAllowance<=h);
}
}
});
// Parse production keyframes so changing the CSS trajectory breaks these checks.
async function hopFrames() {
const css = await readFile(new URL('../src/style.css', import.meta.url), 'utf8');
const block = css.match(/@keyframes journey-waddle \{([\s\S]*?)\n\}/)[1];
return [...block.matchAll(/(\d+)%\s*\{([^}]+)\}/g)].map(([, percent, body]) => ({
time: +percent, top: +body.match(/top:([\d.]+)/)[1],
y: +body.match(/translateY\((-?[\d.]+)(?:px)?\)/)[1], body,
}));
}
test('photo rises before descending, softens its landing, and settles before X travel', async () => {
const frames = await hopFrames();
const lift = frames.find(f => f.y < 0 && f.top === 0);
assert.ok(lift, 'photo needs an upward takeoff before the descent');
const landing = frames.find(f => f.top === 35);
const settled = frames.find(f => f.time > landing.time && f.y === 0 && /scaleY\(1\)/.test(f.body));
assert.ok(lift.time < landing.time && landing.time < settled.time);
assert.match(landing.body, /scaleY\(\.97\)/, 'small foot-pivot landing compression');
assert.match(lift.body, /animation-timing-function:cubic-bezier\(\.4,0,\.65,1\)/, 'descent eases to a soft zero-speed landing');
assert.equal(settled.time, 28, 'X hold ends only after the landing settles');
assert.ok(frames.filter(f => f.time >= settled.time).every(f => f.top === 35), 'walking stays at ground baseline');
});
test('production hop keyframes fit short and narrow illustrations including takeoff headroom', async () => {
const frames = await hopFrames();
for (const [w,h,ratio] of [[460,236,.58],[380,160,.54],[256,153.36,.54],[300,100,.54],[170,170,.54],[180,100,.54]]) {
const c = Math.min(w*ratio,(h-38)*5/3,267);
for (const f of frames) {
const walk = Math.max(0,(f.time-28)/72);
const x = (.16+.715*walk)*w+(-.1-.29*walk)*c+.22*c;
const rotation = Math.sin(5*Math.PI/180)*.65*.6*c;
const top = h-24-.6*c+f.top/100*.6*c+f.y;
assert.ok(top-rotation>=0, `${w}x${h} at ${f.time}% retains takeoff headroom`);
assert.ok(top+.65*.6*c+rotation<=h);
assert.ok(x-rotation>=0 && x+.34*c+rotation<=w);
}
}
});
test('editing the global name changes copy without restarting loading, arrival or unpacking timers', () => {
const h = harness();
const edit = name => { h.els['entry-pet-name'].value = name; h.els['entry-pet-name'].dispatch('input'); };
edit('호롱'); h.ui.setPhoto('blob:photo'); h.ui.startJourney();
assert.equal(h.els['flow-title'].textContent, '호롱이를 할머니 집으로 데려가는 중…');
assert.equal(h.timers.size, 0);
h.ui.setState('ready'); const arrivalTimer = [...h.timers.entries()][0];
edit('초코'); assert.equal(h.els['flow-title'].textContent, '초코가 할머니 집에 도착했어요');
assert.deepEqual([...h.timers.entries()][0], arrivalTimer, 'name edits preserve the exact arrival timer');
assert.equal(h.tick(), 1350); const unpackTimer = [...h.timers.entries()][0];
edit('보리'); assert.equal(h.els['flow-title'].textContent, '짐을 푸는 중…');
assert.deepEqual([...h.timers.entries()][0], unpackTimer, 'name edits preserve the exact unpacking timer');
assert.equal(h.tick(), 1500); assert.equal(h.reveals, 1); assert.equal(h.ui.isBlocked(), false);
});
test('sample copy uses its nickname across both themes and never changes the stored name', () => {
for (const theme of ['wood', 'booth']) {
const h = harness(); h.els['entry-pet-name'].value = '호롱'; h.els['entry-pet-name'].dispatch('input');
h.els['theme-select'].value = theme; h.els['theme-select'].dispatch('change');
h.ui.setMediaOrigin('sample'); h.ui.setPhoto('blob:sample'); h.ui.startJourney();
assert.match(h.els['flow-title'].textContent, /누렁이/); assert.doesNotMatch(h.els['flow-title'].textContent, /호롱/);
h.ui.setState('ready'); assert.match(h.els['flow-title'].textContent, /누렁이/);
assert.equal(h.els['settings-pet-name'].value, '호롱');
h.ui.setMediaOrigin('personal'); h.ui.setPhoto('blob:own'); h.ui.startJourney();
assert.match(h.els['flow-title'].textContent, /호롱이/);
}
});
test('sample return cancels arrival timers and keeps landing focus even after stale callbacks', () => {
const h = harness(); h.ui.setMediaOrigin('sample'); h.ui.setPhoto('blob:sample');
h.ui.startJourney(); h.ui.setState('ready');
const stale = [...h.timers.values()][0].fn;
h.els['sample-home'].dispatch('click');h.els['home-confirm'].dispatch('click');
assert.equal(h.timers.size, 0);
stale();
assert.equal(h.timers.size, 0);
assert.equal(h.els.experience.dataset.flow, 'entry');
assert.equal(h.els['flow-overlay'].hidden, true);
assert.equal(h.els['canvas-wrap'].inert, true);
assert.equal(h.root.activeElement, h.els['entry-photo-button']);
assert.equal(h.reveals, 0);
});
test('sample return during unpacking cancels reveal and preserves the selected name', () => {
const h = harness(); h.els['entry-pet-name'].value='장준'; h.els['entry-pet-name'].dispatch('input');
h.ui.setMediaOrigin('sample'); h.ui.setPhoto('blob:sample'); h.ui.startJourney(); h.ui.setState('ready'); h.tick();
const stale = [...h.timers.values()][0].fn;
h.els['sample-home'].dispatch('click');h.els['home-confirm'].dispatch('click'); stale();
assert.equal(h.els.experience.dataset.flow, 'entry');
assert.equal(h.els['flow-overlay'].hidden, true);
assert.equal(h.els['entry-pet-name'].value, '장준');
assert.equal(h.els['entry-photo-button'].textContent, '장준이 사진 고르기');
assert.equal(h.timers.size, 0); assert.equal(h.reveals, 0);
});
test('personal and sample journeys share identical loading and arrival templates', () => {
for (const theme of ['wood','booth']) {
const h=harness();h.els['entry-pet-name'].value='누렁이';h.els['entry-pet-name'].dispatch('input');h.els['theme-select'].value=theme;h.els['theme-select'].dispatch('change');
h.ui.setMediaOrigin('personal');h.ui.setPhoto('blob:own');h.ui.startJourney();const loading=h.els['flow-title'].textContent;h.ui.setState('ready');const arrived=h.els['flow-title'].textContent;
h.ui.setMediaOrigin('sample');h.ui.setSampleName('누렁이');h.ui.setPhoto('blob:sample');h.ui.startJourney();assert.equal(h.els['flow-title'].textContent,loading);h.ui.setState('ready');assert.equal(h.els['flow-title'].textContent,arrived);
}
});
test('menu interrupts arrival without consuming first tutorial or leaving timers alive',()=>{
const h=harness();h.ui.setPhoto('blob:dog');h.ui.startJourney();h.ui.setState('ready');
assert.ok(h.timers.size>0);h.els['scene-menu-open'].dispatch('click');
assert.equal(h.els['scene-menu'].open,true);assert.equal(h.timers.size,0);assert.equal(h.reveals,0);
h.els['scene-menu-close'].dispatch('click');assert.equal(h.ui.isBlocked(),false);
});
+8
View File
@@ -0,0 +1,8 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {loadTypescript} from './support/load-typescript.mjs';
const load=()=>loadTypescript(new URL('../src/cutout/CutoutMask.ts',import.meta.url));
test('cutout bounds retain all components, padded edges, and unchanged soft alpha',async()=>{const {analyzeCutout}=await load();const mask=new Uint8Array(100*80);mask[0]=255;mask[7999]=255;mask[1]=12;const original=mask.slice();const result=analyzeCutout(mask,100,80);assert.deepEqual(result.bounds,{x0:0,y0:0,x1:100,y1:80});assert.deepEqual(mask,original);assert.equal(result.largeComponentCount,2);assert.ok(result.warnings.includes('multiple-foreground'));});
test('invalid masks fail, binary silhouettes pass, tiny silhouettes require review',async()=>{const {analyzeCutout}=await load();for(const a of [new Uint8Array(4),new Uint8Array(4).fill(255),new Uint8Array(3)])assert.throws(()=>analyzeCutout(a,2,2));const a=new Uint8Array(100);a[55]=255;const r=analyzeCutout(a,10,10);assert.ok(r.warnings.includes('tiny-foreground'));assert.ok(r.bounds.x1>r.bounds.x0);});
test('model alpha rejects NaN/constant and returns canonical soft values without thresholding',async()=>{const {normalizeMask}=await load();assert.throws(()=>normalizeMask(Float32Array.of(1,NaN,2,3),2,2,2,2));assert.throws(()=>normalizeMask(new Float32Array(4).fill(.4),2,2,2,2));const result=normalizeMask(Float32Array.of(0,.25,.5,1),2,2,2,2);assert.deepEqual([...result],[0,64,128,255]);});
test('U2Net preprocessing uses one RGB maximum, channel mean/std and CHW',async()=>{const {preprocessU2Net}=await loadTypescript(new URL('../src/cutout/U2NetPreprocess.ts',import.meta.url));const x=preprocessU2Net(Uint8ClampedArray.of(255,128,0,255),1,1);assert.equal(x.length,3);assert.ok(Math.abs(x[0]-(1-.485)/.229)<1e-5);assert.ok(Math.abs(x[1]-(128/255-.456)/.224)<1e-5);assert.ok(Math.abs(x[2]+.406/.225)<1e-5);});
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { test } from 'node:test';
const read = path => readFile(new URL('../' + path, import.meta.url));
test('opt-in model is the unchanged licensed 4.57 MB foreground model', async () => {
const bytes = await read('public/models/u2netp/model.onnx');
const manifest = JSON.parse(await read('public/models/u2netp/manifest.json'));
assert.equal(bytes.length, 4574861);
assert.equal(bytes.length, manifest.bytes);
assert.equal(createHash('sha256').update(bytes).digest('hex'), manifest.sha256);
assert.equal(manifest.sha256, '309c8469258dda742793dce0ebea8e6dd393174f89934733ecc8b14c76f4ddd8');
assert.match((await read('public/models/u2netp/LICENSES.txt')).toString(), /Apache|MIT/);
});
test('worker uses local pixels, same-origin model and installed external WASM runtime', async () => {
const source = (await read('src/cutout/cutout.worker.ts')).toString();
const runtime = JSON.parse(await read('node_modules/onnxruntime-web/package.json'));
assert.match(source, /new OffscreenCanvas\(320,320\)/);
assert.match(source, /context.drawImage\(image/);
assert.match(source, /const MODEL='\/models\/u2netp\/model.onnx'/);
assert.ok(source.includes(`const RUNTIME='${runtime.version}'`));
assert.match(source, /crypto.subtle.digest\('SHA-256',bytes\)/);
assert.match(source, /ort.env.wasm.numThreads=1/);
assert.equal([...source.matchAll(/\bfetch\(/g)].length, 1);
assert.doesNotMatch(source, /FormData|XMLHttpRequest|sendBeacon|method:\s*['"]POST/);
});
+149
View File
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { readFile } from 'node:fs/promises';
import vm from 'node:vm';
import ts from 'typescript';
import { loadTypescript } from './support/load-typescript.mjs';
const maskModule = await loadTypescript(new URL('../src/cutout/CutoutMask.ts', import.meta.url));
const tick = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); };
async function harness({ delayedSnapshot = false, delayedPreview = false, previewError = false, encodeError = false } = {}) {
const source = await readFile(new URL('../src/cutout/CutoutPreparation.ts', import.meta.url), 'utf8');
const exports = {}, captures = [], workers = [], images = [], blobs = [], revoked = [], canvases = [];
const pixels = Uint8ClampedArray.of(200, 120, 20, 255, 10, 30, 90, 128, 44, 55, 66, 255, 11, 22, 33, 255);
class Provider {
constructor(onStatus) { this.onStatus = onStatus; this.cancelled = 0; workers.push(this); }
run(snapshot, signal) { this.snapshot = snapshot; this.signal = signal; return new Promise((resolve, reject) => { this.resolve = resolve; this.reject = reject; signal.addEventListener('abort', () => reject(new DOMException('cancel', 'AbortError')), { once: true }); }); }
cancel() { this.cancelled++; }
dispose() { this.disposed = true; }
}
function capture(_file, id, signal) {
const snapshot = { requestId: id, width: 2, height: 2, bitmap: { width: 2, height: 2, closes: 0, close() { this.closes++; } } };
const request = { snapshot, signal };
captures.push(request);
return delayedSnapshot ? new Promise(resolve => { request.resolve = () => resolve(snapshot); }) : Promise.resolve(snapshot);
}
const document = { createElement(tag) {
assert.equal(tag, 'canvas');
const canvas = { width: 0, height: 0, pixels: pixels.slice(), draws: [], getContext() { return {
drawImage: image => canvas.draws.push(image),
getImageData: () => ({ data: canvas.pixels.slice() }),
putImageData: data => { canvas.pixels = data.data.slice(); },
}; }, toBlob(callback, type) {
assert.equal(type, 'image/png', 'every adapter must be PNG, never SVG');
const blob = new Blob([canvas.pixels], { type });
blob.rgba = canvas.pixels.slice();
blobs.push(blob);
callback(encodeError ? null : blob);
} };
canvases.push(canvas);
return canvas;
} };
class Image {
constructor() { images.push(this); }
set src(value) { this._src = value; if (!delayedPreview) queueMicrotask(() => previewError ? this.onerror?.() : this.onload?.()); }
get src() { return this._src; }
removeAttribute(name) { assert.equal(name, 'src'); this._src = ''; }
}
const urls = [];
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, {
exports, AbortController, Blob, DOMException, Error, Image, Set, Uint8Array, Uint8ClampedArray, document,
URL: { createObjectURL(blob) { urls.push(blob); return `blob:png-${urls.length}`; }, revokeObjectURL(url) { revoked.push(url); } },
require(name) { if (name.includes('PhotoSnapshot')) return { createPhotoSnapshot: capture }; if (name.includes('CutoutMask')) return maskModule; if (name.includes('CutoutProvider')) return { CutoutProvider: Provider }; throw new Error(name); },
});
const preparation = new exports.CutoutPreparation();
function succeed(alpha = Uint8Array.of(0, 128, 255, 255)) {
const p = workers[0];
p.resolve({ requestId: p.snapshot.requestId, width: 2, height: 2, alpha, bounds: { x0: 0, y0: 0, x1: 2, y1: 2 }, warnings: [], largeComponentCount: 1, run: { model: 'test' } });
}
return { preparation, captures, workers, images, blobs, urls, revoked, canvases, pixels, succeed };
}
test('preparation is lazy and produces review-only PNGs with full original RGB preserved', async () => {
const h = await harness();
assert.equal(h.captures.length, 0);
const progress = [], task = h.preparation.prepare(new Blob(['photo']), message => progress.push(message));
await tick();
assert.equal(h.workers[0].snapshot, h.captures[0].snapshot);
h.workers[0].onStatus(h.captures[0].snapshot.requestId, 'mask ready');
h.succeed();
const result = await task;
assert.equal(result.originalBlob.type, 'image/png');
assert.equal(result.cutoutBlob.type, 'image/png');
assert.deepEqual([...result.originalBlob.rgba], [...h.pixels]);
assert.deepEqual([...result.cutoutBlob.rgba], [200,120,20,0,10,30,90,64,44,55,66,255,11,22,33,255]);
assert.deepEqual([...result.alpha], [0,64,255,255], 'result alpha is the actual combined alpha');
assert.equal(result.originalImage.src, result.originalUrl);
assert.equal(result.cutoutImage.src, result.cutoutUrl);
assert.equal(result.width, 2);
assert.equal(result.height, 2);
assert.equal(result.requiresReview, true);
assert.ok(progress.includes('mask ready'));
assert.equal(h.captures[0].snapshot.bitmap.closes, 1);
assert.ok(h.canvases.every(canvas => canvas.width === 1 && canvas.height === 1));
result.dispose(); result.dispose();
assert.equal(h.revoked.length, 2);
assert.ok(h.images.every(image => image.src === ''));
h.preparation.dispose();
assert.equal(h.revoked.length, 2);
});
test('external cancellation terminates inference and cannot publish preview URLs', async () => {
const h = await harness(), abort = new AbortController();
const task = h.preparation.prepare(new Blob(), undefined, abort.signal), rejected = assert.rejects(task, { name: 'AbortError' });
await tick(); abort.abort(); await rejected;
assert.equal(h.workers[0].signal.aborted, true);
assert.equal(h.captures[0].snapshot.bitmap.closes, 1);
assert.equal(h.urls.length, 0);
});
test('cancel while decoding closes a late snapshot and never starts segmentation', async () => {
const h = await harness({ delayedSnapshot: true });
const task = h.preparation.prepare(new Blob()), rejected = assert.rejects(task, { name: 'AbortError' });
h.preparation.cancel(); h.captures[0].resolve(); await rejected;
assert.equal(h.captures[0].snapshot.bitmap.closes, 1);
assert.equal(h.workers[0].snapshot, undefined);
assert.equal(h.urls.length, 0);
});
test('replacement invalidates old work while a delivered result survives until disposal', async () => {
const h = await harness();
const first = h.preparation.prepare(new Blob()), rejected = assert.rejects(first, { name: 'AbortError' });
await tick();
const second = h.preparation.prepare(new Blob());
await rejected; await tick(); h.succeed(); const result = await second;
const third = h.preparation.prepare(new Blob()), thirdRejected = assert.rejects(third, { name: 'AbortError' });
assert.equal(h.revoked.length, 0, 'accepted old consumer owns its URLs through a replacement');
await tick(); h.preparation.dispose(); await thirdRejected;
assert.equal(h.revoked.length, 2);
assert.equal(result.originalImage.src, '');
assert.equal(h.workers[0].disposed, true);
await assert.rejects(h.preparation.prepare(new Blob()), /종료|disposed/);
});
test('preview PNG decoding failure revokes both URLs and releases the bitmap', async () => {
const h = await harness({ previewError: true });
const task = h.preparation.prepare(new Blob()), rejected = assert.rejects(task, /미리보기|사진|PNG/);
await tick(); h.succeed(); await rejected;
assert.equal(h.captures[0].snapshot.bitmap.closes, 1);
assert.equal(h.revoked.length, h.urls.length);
assert.ok(h.images.every(image => image.src === ''));
});
test('cancellation during pending PNG preview decode clears handlers and URLs', async () => {
const h = await harness({ delayedPreview: true }), abort = new AbortController();
const task = h.preparation.prepare(new Blob(), undefined, abort.signal), rejected = assert.rejects(task, { name: 'AbortError' });
await tick(); h.succeed(); await tick();
assert.ok(h.urls.length > 0);
abort.abort(); await rejected;
assert.equal(h.revoked.length, h.urls.length);
assert.ok(h.images.every(image => image.src === '' && image.onload === null && image.onerror === null));
});
test('PNG encoding failure does not return incomplete preview or leak snapshot', async () => {
const h = await harness({ encodeError: true });
const task = h.preparation.prepare(new Blob()), rejected = assert.rejects(task, /PNG|미리보기|사진/);
await tick(); h.succeed(); await rejected;
assert.equal(h.captures[0].snapshot.bitmap.closes, 1);
assert.equal(h.urls.length, 0);
});
+15
View File
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';import {test} from 'node:test';import {loadTypescript} from './support/load-typescript.mjs';
const tick=async()=>{for(let i=0;i<8;i++)await Promise.resolve();};
async function harness(){const workers=[],bitmaps=[];const oldW=globalThis.Worker,oldB=globalThis.createImageBitmap;globalThis.Worker=class{constructor(){this.messages=[];this.terminated=false;workers.push(this);}postMessage(m){this.messages.push(m);}terminate(){this.terminated=true;}};globalThis.createImageBitmap=async()=>{const b={width:10,height:10,closed:false,close(){this.closed=true;}};bitmaps.push(b);return b;};const {CutoutProvider}=await loadTypescript(new URL('../src/cutout/CutoutProvider.ts',import.meta.url));const statuses=[];const p=new CutoutProvider((id,m)=>statuses.push([id,m]));return{p,workers,bitmaps,statuses,cleanup(){p.cancel();globalThis.Worker=oldW;globalThis.createImageBitmap=oldB;}};}
const snapshot={requestId:12,width:10,height:10,bitmap:{}};
function success(w){const alpha=new Uint8Array(100);alpha.fill(255,30,60);w.onmessage({data:{type:'result',id:12,width:10,height:10,alpha:alpha.buffer,run:{model:'u2netp',revision:'test',backend:'wasm',downloadBytes:0,preparationMs:1,inferenceMs:1,totalMs:2}}});}
test('provider validates result and terminates inference worker before resolving',async()=>{const h=await harness();try{const task=h.p.run(snapshot,new AbortController().signal);await tick();success(h.workers[0]);const result=await task;assert.equal(result.requestId,12);assert.equal(h.workers[0].terminated,true);assert.ok(result.bounds.x1>result.bounds.x0);}finally{h.cleanup();}});
test('GPU failure retries at most once in a fresh WASM worker; stale messages ignored',async()=>{const h=await harness();try{const task=h.p.run(snapshot,new AbortController().signal);await tick();const old=h.workers[0];old.onmessage({data:{type:'fallback',id:12}});await tick();assert.equal(old.terminated,true);assert.equal(h.workers[1].messages[0].forceWasm,true);old.onmessage({data:{type:'error',id:12,message:'late'}});success(h.workers[1]);await task;assert.equal(h.workers.length,2);}finally{h.cleanup();}});
test('abort terminates and rejects, never accepting a late result or status',async()=>{const h=await harness();try{const abort=new AbortController();const task=h.p.run(snapshot,abort.signal);const rejected=assert.rejects(task,{name:'AbortError'});await tick();const w=h.workers[0];abort.abort();await rejected;success(w);w.onmessage({data:{type:'status',id:12,message:'late'}});assert.equal(w.terminated,true);assert.equal(h.statuses.length,0);}finally{h.cleanup();}});
test('photo snapshot applies EXIF once then constrains canonical longest edge',async()=>{const original=globalThis.createImageBitmap;const calls=[];let closed=0;globalThis.createImageBitmap=async(source,options)=>{calls.push(options);return calls.length===1?{width:1600,height:800,close(){closed++;}}:{width:1024,height:512,close(){closed++;}};};try{const {createPhotoSnapshot}=await loadTypescript(new URL('../src/input/PhotoSnapshot.ts',import.meta.url));const result=await createPhotoSnapshot({},2,new AbortController().signal);assert.equal(result.width,1024);assert.equal(result.height,512);assert.equal(calls[0].imageOrientation,'from-image');assert.equal(calls[1].resizeWidth,1024);assert.equal(closed,1);}finally{globalThis.createImageBitmap=original;}});
test('late rejected capture never rejects a newer request',async()=>{const h=await harness();try{let rejectCapture;globalThis.createImageBitmap=()=>new Promise((_,reject)=>{rejectCapture=reject;});const first=h.p.run(snapshot,new AbortController().signal);const firstRejected=assert.rejects(first,{name:'AbortError'});h.p.cancel();await firstRejected;globalThis.createImageBitmap=async()=>({width:10,height:10,close(){}});const next=h.p.run(snapshot,new AbortController().signal);const settled=next.then(value=>({value}),error=>({error}));await tick();rejectCapture(new Error('old decode failure'));await tick();success(h.workers[0]);assert.equal((await settled).error,undefined);}finally{h.cleanup();}});
test('worker construction failure closes the untransferred clone',async()=>{const h=await harness();try{globalThis.Worker=class{constructor(){throw new Error('worker unavailable');}};await assert.rejects(h.p.run(snapshot,new AbortController().signal),/worker unavailable/);assert.equal(h.bitmaps[0].closed,true);}finally{h.cleanup();}});
test('abort while clone is pending closes only clone and never creates a worker',async()=>{const h=await harness();try{let resolveCapture;let canonicalClosed=0,cloneClosed=0;const snap={...snapshot,bitmap:{close(){canonicalClosed++;}}};globalThis.createImageBitmap=()=>new Promise(resolve=>{resolveCapture=resolve;});const abort=new AbortController(),task=h.p.run(snap,abort.signal);const rejected=assert.rejects(task,{name:'AbortError'});abort.abort();await rejected;resolveCapture({width:10,height:10,close(){cloneClosed++;}});await tick();assert.equal(cloneClosed,1);assert.equal(canonicalClosed,0);assert.equal(h.workers.length,0);}finally{h.cleanup();}});
test('second fallback fails with no third worker',async()=>{const h=await harness();try{const task=h.p.run(snapshot,new AbortController().signal);const rejected=assert.rejects(task,/계산하지 못/);await tick();h.workers[0].onmessage({data:{type:'fallback',id:12}});await tick();h.workers[1].onmessage({data:{type:'fallback',id:12}});await rejected;assert.equal(h.workers.length,2);assert.ok(h.workers.every(w=>w.terminated));}finally{h.cleanup();}});
test('snapshot abort during resize closes decoded and resized bitmaps exactly once',async()=>{const old=globalThis.createImageBitmap;let resolveResize,decodedClosed=0,resizedClosed=0;let count=0;globalThis.createImageBitmap=async()=>{if(++count===1)return{width:1600,height:800,close(){decodedClosed++;}};return new Promise(resolve=>{resolveResize=resolve;});};try{const {createPhotoSnapshot}=await loadTypescript(new URL('../src/input/PhotoSnapshot.ts',import.meta.url));const abort=new AbortController(),task=createPhotoSnapshot({},2,abort.signal),rejected=assert.rejects(task,{name:'AbortError'});await tick();abort.abort();resolveResize({width:1024,height:512,close(){resizedClosed++;}});await rejected;assert.equal(decodedClosed,1);assert.equal(resizedClosed,1);}finally{globalThis.createImageBitmap=old;}});
test('disposing a provider cancels active work and permanently prevents a new worker',async()=>{const h=await harness();try{const task=h.p.run(snapshot,new AbortController().signal),rejected=assert.rejects(task,{name:'AbortError'});await tick();h.p.dispose();h.p.dispose();await rejected;assert.equal(h.workers[0].terminated,true);await assert.rejects(h.p.run(snapshot,new AbortController().signal),/종료/);assert.equal(h.workers.length,1);}finally{h.cleanup();}});
+53
View File
@@ -0,0 +1,53 @@
/** Production worker + real ORT-Web WASM under Node. Canvas supplies retained
* local RGB fixture pixels; this is not browser, GPU, CDN or mobile validation. */
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { readFile } from 'node:fs/promises';
import vm from 'node:vm';
import ts from 'typescript';
import * as ort from 'onnxruntime-web/webgpu';
import { webcrypto } from 'node:crypto';
import { loadTypescript } from './support/load-typescript.mjs';
test('production worker executes the retained local model and releases its input', { timeout: 30000 }, async () => {
const mask = await loadTypescript(new URL('../src/cutout/CutoutMask.ts', import.meta.url));
const preprocess = await loadTypescript(new URL('../src/cutout/U2NetPreprocess.ts', import.meta.url));
const model = await readFile(new URL('../public/models/u2netp/model.onnx', import.meta.url));
const rgba = await readFile(new URL('../docs/qa-countryside-cutout-2026-10-07/runtime/sample-320.rgba', import.meta.url));
// Only the host boundary is changed. The model and all preprocess, inference,
// normalization and worker cleanup logic are the actual production code.
ort.env.wasm.wasmPaths = new URL('../node_modules/onnxruntime-web/dist/', import.meta.url).href;
ort.env.wasm.numThreads = 1;
const wasm = new Proxy(ort.env.wasm, { set(target, key, value) { if (key !== 'wasmPaths') target[key] = value; return true; } });
const runtime = { ...ort, env: { ...ort.env, wasm } };
let listener, complete;
const terminal = new Promise(resolve => { complete = resolve; });
const messages = [], requested = [];
const image = { width: 415, height: 739, closes: 0, close() { this.closes++; } };
const source = await readFile(new URL('../src/cutout/cutout.worker.ts', import.meta.url), 'utf8');
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText, {
exports: {}, require: name => name === 'onnxruntime-web/webgpu' ? runtime : name === './U2NetPreprocess' ? preprocess : mask,
performance, crypto: webcrypto, navigator: {},
fetch: async (url, options) => { requested.push({ url, options }); return { ok: true, arrayBuffer: async () => model.buffer.slice(model.byteOffset, model.byteOffset + model.byteLength) }; },
OffscreenCanvas: class { constructor(width, height) { assert.equal(width, 320); assert.equal(height, 320); } getContext() { return { drawImage(input) { assert.equal(input, image); }, getImageData() { return { data: new Uint8ClampedArray(rgba) }; } }; } },
self: { addEventListener(_type, fn) { listener = fn; }, postMessage(message) { messages.push(message); if (['result', 'error', 'fallback'].includes(message.type)) complete(message); } },
});
listener({ data: { type: 'infer', id: 17, image, forceWasm: true } });
const result = await terminal;
await new Promise(resolve => setImmediate(resolve));
assert.equal(result.type, 'result', result.message);
assert.equal(result.id, 17);
assert.equal(result.width, 415);
assert.equal(result.height, 739);
assert.equal(result.run.backend, 'wasm');
assert.equal(result.run.downloadBytes, 4574861);
const alpha = new Uint8Array(result.alpha), analysis = mask.analyzeCutout(alpha, result.width, result.height);
assert.equal(alpha.length, 415 * 739);
assert.ok(alpha.some(value => value > 0 && value < 255), 'soft fringe is retained');
assert.ok(analysis.bounds.y0 > 0 && analysis.bounds.y1 < result.height);
assert.equal(image.closes, 1);
assert.equal(requested.length, 1);
assert.equal(requested[0].url, '/models/u2netp/model.onnx');
assert.equal(requested[0].options.body, undefined);
assert.equal(messages.filter(message => message.type === 'status').length, 2);
});
+139
View File
@@ -0,0 +1,139 @@
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { test } from 'node:test';
import vm from 'node:vm';
import ts from 'typescript';
import * as THREE from 'three';
import { RectAreaLightUniformsLib } from 'three/addons/lights/RectAreaLightUniformsLib.js';
function load(relative) {
const path=new URL(relative,import.meta.url); const exports={};
const source=readFileSync(path,'utf8');
vm.runInNewContext(ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText,{exports,require(spec){if(spec==='three')return THREE;if(spec.includes('RectArea'))return {RectAreaLightUniformsLib};return load(new URL(spec+'.ts',path).href);}});
return exports;
}
function fixture(){
const {MeshGlassRenderer}=load('../src/rendering/MeshGlassRenderer.ts');
const calls=[];let target=null; const renderer={autoClear:true,extensions:{has:()=>false},debug:{},getContext:()=>({isContextLost:()=>false}),getRenderTarget:()=>target,setRenderTarget(t){target=t;calls.push(['target',t]);},setScissorTest(){},clearDepth(){},render(scene,camera){camera.updateMatrixWorld();scene.updateMatrixWorld();calls.push(['render',scene,target]);scene.traverse(o=>{if(o.isMesh)o.onBeforeRender(renderer,scene,camera,o.geometry,o.material,null);});}};
const source=new THREE.Mesh(new THREE.PlaneGeometry(1,1,128,128),new THREE.ShaderMaterial({uniforms:{uTexture:{value:new THREE.Texture()},uDepthMap:{value:new THREE.Texture()},uDepthScale:{value:.25},uDepthBias:{value:0},uIsVideo:{value:false}}}));
const b=new MeshGlassRenderer(renderer,source);return {b,renderer,calls,source};
}
test('B is a standalone default renderer without A output targets or shaders',()=>{
assert.ok(existsSync(new URL('../src/rendering/MeshGlassRenderer.ts',import.meta.url)),'standalone B module must exist');
const {b,calls}=fixture(); b.sync({width:1,height:1.32,grid:20,curvature:1},.6,new THREE.Vector3(.6,.8,2.6),new THREE.Color());
const camera=new THREE.PerspectiveCamera(),frame=new THREE.Scene();b.render(camera,frame,true);
assert.equal('targetA' in b,false);assert.equal('targetB' in b,false);
assert.equal(calls.filter(c=>c[0]==='render').length,2);assert.equal(calls.filter(c=>c[0]==='render')[0][2],null);
b.dispose();
});
test('B shares replacements and photo transforms without first rendering A',()=>{
const {b,source}=fixture(),shape={width:1,height:1.32,grid:20,curvature:1};
b.sync(shape,.6,new THREE.Vector3(),new THREE.Color());const geometry=b.glass.geometry;
source.position.set(.1,-.2,.22);source.scale.set(.8,1.1,1);const texture=new THREE.Texture();source.material.uniforms.uTexture.value=texture;
b.sync(shape,.6,new THREE.Vector3(),new THREE.Color());assert.equal(b.glass.geometry,geometry);
const shader={uniforms:{},vertexShader:THREE.ShaderLib.physical.vertexShader,fragmentShader:THREE.ShaderLib.physical.fragmentShader};b.material.onBeforeCompile(shader,{});
assert.equal(shader.uniforms.uBPhoto.value,texture);assert.deepEqual(b.scene.children.find(x=>x.geometry===source.geometry).position.toArray(),source.position.toArray());
b.dispose();
});
test('B excludes caller output from owned transmission targets and disposes once',()=>{
const {b,renderer,source}=fixture();b.sync({width:1,height:1,grid:20,curvature:1},.6,new THREE.Vector3(),new THREE.Color());
const output=new THREE.WebGLRenderTarget(20,20);let outputDisposed=0,sourceDisposed=0,glassDisposed=0;
output.addEventListener('dispose',()=>outputDisposed++);source.geometry.addEventListener('dispose',()=>sourceDisposed++);b.glass.geometry.addEventListener('dispose',()=>glassDisposed++);
b.render(new THREE.PerspectiveCamera(),new THREE.Scene(),true,output);b.dispose();b.dispose();
assert.equal(outputDisposed,0);assert.equal(sourceDisposed,0);assert.equal(glassDisposed,1);output.dispose();
});
test('normal flow selects B, A is an explicit lazy option, and failure never silently selects A',()=>{
const main=readFileSync(new URL('../src/main.ts',import.meta.url),'utf8'),html=readFileSync(new URL('../index.html',import.meta.url),'utf8');
assert.match(html,/value="b" selected/);assert.match(main,/import\('\.\/rendering\/OriginalGlassRenderer'\)/);
assert.match(main,/meshRenderer\.render/);assert.match(main,/showMeshFailure/);
assert.match(main,/meshRenderer\.restore/);assert.match(main,/originalRenderer\?\.invalidate/);
});
test('a compiled B failure stays explicit until retry or context restoration',()=>{
const {b,renderer}=fixture();let first=true;const render=renderer.render.bind(renderer);
renderer.render=(...args)=>{if(first){first=false;renderer.debug.onShaderError?.({},null,null,null);}render(...args);};
const camera=new THREE.PerspectiveCamera(),frame=new THREE.Scene();
assert.throws(()=>b.render(camera,frame,true),/shader|failed/i);
assert.throws(()=>b.render(camera,frame,true),/shader|failed/i,'Three caches failed programs; absence of a second callback is not success');
b.restore();assert.doesNotThrow(()=>b.render(camera,frame,true));b.dispose();
});
test('fresh Glass Only still releases the hidden transmission target without drawing the photo',()=>{
const {b,renderer,source}=fixture(),internal=new THREE.WebGLRenderTarget(1,1);let frees=0;
internal.addEventListener('dispose',()=>frees++);
const original=renderer.render.bind(renderer),setTarget=renderer.setRenderTarget.bind(renderer);
renderer.render=(scene,camera)=>{
if(scene===b.scene){const output=renderer.getRenderTarget();setTarget(internal);for(const object of scene.children)if(object.visible&&object.geometry&&object!==b.glass){assert.equal(object.material.colorWrite,false);assert.equal(object.material.depthWrite,false);object.onBeforeRender(renderer,scene,camera,object.geometry,object.material,null);}setTarget(output);}
original(scene,camera);
};
b.render(new THREE.PerspectiveCamera(),new THREE.Scene(),true,null,false);b.dispose();
assert.equal(frees,1);assert.equal(source.material.colorWrite,true);assert.equal(source.material.depthWrite,true,'shared photo material state restores');
});
test('sliding B moves only the actual pane and keeps world-space exits tied to the translated geometry',()=>{
const {b,source}=fixture(),shape={width:1,height:1.32,grid:20,curvature:1};
b.setSlide(-.5);b.sync(shape,.6,new THREE.Vector3(),new THREE.Color());
assert.equal(b.glass.position.x,-.5);assert.equal(source.position.x,0);
const geometry=b.glass.geometry;
const shader={uniforms:{},fragmentShader:THREE.ShaderLib.physical.fragmentShader};b.material.onBeforeCompile(shader,{});
assert.equal(shader.uniforms.uBPaneCenterX.value,-.5);
assert.match(shader.fragmentShader,/side\*uBPaneHalfSize\.x\+uBPaneCenterX-entry\.x/);
assert.ok(b.material.clippingPlanes.length===1);assert.equal(b.material.clippingPlanes[0].constant,.55);
b.setSlide(-1);b.sync(shape,.6,new THREE.Vector3(),new THREE.Color());
assert.equal(b.glass.geometry,geometry,'opening does not rebuild patterned glass');
assert.equal(shader.uniforms.uBPaneCenterX.value,-1);
assert.equal(b.scene.children.find(x=>x.geometry===source.geometry).position.x,0);
b.restore();assert.equal(b.glass.position.x,-1);b.dispose();
});
// A CPU depth-buffer oracle around the real production render calls. Geometry
// intersections are real Three triangles; this does not execute WebGL shaders.
test('B preserves pane depth into the frame pass while foreground and open-door reveal still draw',()=>{
const {b,renderer,source}=fixture();
const {WindowReveal}=load('../src/objects/WindowReveal.ts');
source.position.z=.2;source.scale.set(1.2,1.584,1);
const frame=new THREE.Scene(),wood=new THREE.MeshBasicMaterial({side:THREE.DoubleSide});
const reveal=new WindowReveal(wood);reveal.update(1,1.32,1.2,1.584,.6,.2);
frame.add(...reveal.meshes);reveal.meshes.forEach(mesh=>mesh.name='reveal');
const front=new THREE.Mesh(new THREE.BoxGeometry(.12,1.6,.16),wood);front.position.set(-.55,0,.66);front.name='foreground';frame.add(front);
const originalRender=renderer.render.bind(renderer);let samples=[],depth=[],winner=[];
renderer.clearDepth=()=>{depth.fill(Infinity);};
renderer.render=(scene,camera)=>{
originalRender(scene,camera);
if(renderer.autoClear){depth=samples.map(()=>Infinity);winner=samples.map(()=>null);}
for(let i=0;i<samples.length;i++){
const ray=new THREE.Raycaster(camera.position,samples[i].clone().sub(camera.position).normalize());
const hits=ray.intersectObjects(scene.children,true).filter(hit=>hit.object.visible&&hit.object.material.depthTest!==false);
for(const hit of hits){if(hit.object===b.glass&&hit.point.x < -b.material.clippingPlanes[0].constant)continue;
if(hit.distance<=depth[i]){if(hit.object.material.colorWrite)winner[i]=hit.object;if(hit.object.material.depthWrite)depth[i]=hit.distance;}
}
}
};
let protectedRear=0,visibleFront=0,visibleOpenReveal=0;
for(const opening of [0,.5,1])for(const eye of [[0,0,3],[.6,-.2,1.1],[-.6,-.2,1.1],[0,.5,1.05]]){
b.setSlide(-opening);b.sync({width:1,height:1.32,grid:20,curvature:1},.6,new THREE.Vector3(),new THREE.Color());
const camera=new THREE.PerspectiveCamera();camera.position.set(...eye);camera.lookAt(0,0,.6);camera.updateMatrixWorld();
samples=[];for(const y of [-.6,-.3,0,.3,.6])for(const x of [-.56,-.49,-.4,-.2,0,.2,.4,.49])samples.push(new THREE.Vector3(x,y,.6));
b.render(camera,frame,true);
for(let i=0;i<samples.length;i++){
const ray=new THREE.Raycaster(camera.position,samples[i].clone().sub(camera.position).normalize());
const all=ray.intersectObjects([...b.scene.children,...frame.children],true).filter(hit=>hit.object.visible&&!(hit.object===b.glass&&hit.point.x < -b.material.clippingPlanes[0].constant));
const nearest=all[0]?.object??null;
assert.equal(winner[i]?.uuid,nearest?.uuid,`opening ${opening}, eye ${eye}, target ${samples[i].toArray()}: rear frame must not overwrite a nearer pane`);
if(nearest===b.glass&&all.some(hit=>reveal.meshes.includes(hit.object)))protectedRear++;
if(nearest===front)visibleFront++;
if(opening>0&&reveal.meshes.includes(nearest))visibleOpenReveal++;
}
}
assert.ok(protectedRear>0,'regression rays actually see rear reveal behind glass');
assert.ok(visibleFront>0,'near-side wood still occludes the pane');
assert.ok(visibleOpenReveal>0,'slid-open areas retain direct reveal coverage');
console.log('B/frame depth oracle:',JSON.stringify({protectedRear,visibleFront,visibleOpenReveal}));
b.dispose();reveal.dispose();front.geometry.dispose();wood.dispose();source.geometry.dispose();source.material.dispose();
});
test('B keeps caller render state after a failing frame pass without clearing the pane depth',()=>{
const {b,renderer}=fixture(),frame=new THREE.Scene();let clears=0;
renderer.clearDepth=()=>{clears++;};const render=renderer.render.bind(renderer);
renderer.render=(scene,camera)=>{if(scene===frame)throw new Error('frame failure');render(scene,camera);};
assert.throws(()=>b.render(new THREE.PerspectiveCamera(),frame,true),/frame failure/);
assert.equal(renderer.autoClear,true);assert.equal(clears,0,'frame failure must not discard the current pane depth');
b.dispose();
});
+3 -1
View File
@@ -6,7 +6,9 @@ import { DEPTH_REFRACTION } from '../src/constants/depth.ts';
const shader = await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8');
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
const media = main.slice(main.indexOf('function applyMedia('), main.indexOf('function setDepth('));
assert.doesNotMatch(media, /photo(?:Zoom|PanX|PanY)\s*=/, 'media replacement preserves manual framing');
const ordinaryMedia=media.replace(/if\(incoming&&!video\)\{[^}]*\}else incoming\?\.dispose\(\);/,'');
assert.doesNotMatch(ordinaryMedia, /photo(?:Zoom|PanX|PanY)\s*=/, 'ordinary media replacement preserves manual framing');
assert.match(media,/if\(incoming&&!video\)\{[^}]*photoZoom=1;photoPanX=photoPanY=0/,'only explicit experiment acceptance starts its peeking placement');
assert.match(shader, /sceneHit=refineSceneHit\(gh,normalize\(mix\(i,curvedRay,strength\)\),sceneHit,cellWorld\)/);
assert.match(shader, /if\(!reconstructScenePoint\(projectUv\(hit\),surface\)\)return artisticHit/);
assert.match(shader, /confidence=1\.-smoothstep/);
+57
View File
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {readFile} from 'node:fs/promises';
import {loadTypescript} from './support/load-typescript.mjs';
// Removing the carousel markup must fail before implementing it.
test('entry replaces three permanent explanations with one manually controlled notice region',async()=>{
const html=await readFile(new URL('../index.html',import.meta.url),'utf8');
assert.match(html,/id="entry-notices"/);
assert.match(html,/id="notice-prev"[^>]*aria-label="이전 안내"/);
assert.match(html,/id="notice-next"[^>]*aria-label="다음 안내"/);
assert.doesNotMatch(html,/<p class="entry-privacy">/);
});
class El extends EventTarget {
constructor(id){super();this.id=id;this.hidden=false;this.dataset={};this.attrs={};}
setAttribute(k,v){this.attrs[k]=v;}
fire(type,props={}){const e=new Event(type,{cancelable:true});Object.assign(e,props);this.dispatchEvent(e);return e;}
closest(){return null;}
}
async function harness(desktop=false){
const {initEntryNotices}=await loadTypescript(new URL('../src/ui/EntryNotices.ts',import.meta.url));
const ids=['entry-notices','notice-prev','notice-next','notice-position','notice-status','photo-tip','privacy-tip','paste-tip'];
const els=Object.fromEntries(ids.map(id=>[id,new El(id)]));
els['photo-tip'].textContent='정면 사진이면 더 귀여워요';els['privacy-tip'].textContent='사진은 이 브라우저 안에서만 처리해요';els['paste-tip'].textContent='복사한 사진도 붙여넣을 수 있어요 Ctrl+V · ⌘V';
const media=new El('media');media.matches=desktop;
const root={querySelector:s=>els[s.slice(1)],defaultView:{matchMedia:()=>media}};
const controller=new AbortController();initEntryNotices(root,controller.signal);
return {els,media,controller,active:()=>ids.filter(id=>els[id].dataset.active==='true')};
}
test('mobile has two notices, arrows loop, and initial render is silent',async()=>{
const h=await harness();assert.deepEqual(h.active(),['photo-tip']);assert.equal(h.els['paste-tip'].hidden,true);assert.equal(h.els['notice-position'].textContent,'1 / 2');assert.equal(h.els['notice-status'].textContent,undefined);
h.els['notice-next'].fire('click');assert.deepEqual(h.active(),['privacy-tip']);assert.match(h.els['notice-status'].textContent,/2 \/ 2/);
h.els['notice-next'].fire('click');assert.deepEqual(h.active(),['photo-tip']);
h.els['notice-prev'].fire('click');assert.deepEqual(h.active(),['privacy-tip']);
});
test('desktop adds paste notice and changing to touch-only removes it immediately',async()=>{
const h=await harness(true);h.els['notice-prev'].fire('click');assert.deepEqual(h.active(),['paste-tip']);
h.media.matches=false;h.media.fire('change');assert.deepEqual(h.active(),['photo-tip']);assert.equal(h.els['paste-tip'].hidden,true);assert.equal(h.els['notice-status'].textContent,'');
});
test('horizontal single-touch swipe advances; vertical, cancelled and multiple pointers do not',async()=>{
const h=await harness();const el=h.els['entry-notices'];
const down=(id=1)=>el.fire('pointerdown',{pointerId:id,pointerType:'touch',isPrimary:true,clientX:160,clientY:20,targetElement:null});
const up=(x,y=20,id=1)=>el.fire('pointerup',{pointerId:id,clientX:x,clientY:y});
down();up(70);assert.deepEqual(h.active(),['privacy-tip']);
down();up(150,100);assert.deepEqual(h.active(),['privacy-tip']);
down();el.fire('pointercancel',{pointerId:1});up(70);assert.deepEqual(h.active(),['privacy-tip']);
down();el.fire('pointerdown',{pointerId:2,pointerType:'touch',isPrimary:false,clientX:120,clientY:20});up(70);assert.deepEqual(h.active(),['privacy-tip']);
});
test('abort removes interaction and media listeners',async()=>{
const h=await harness(true);h.controller.abort();h.els['notice-next'].fire('click');h.media.matches=false;h.media.fire('change');assert.deepEqual(h.active(),['photo-tip']);assert.equal(h.els['paste-tip'].hidden,false);
});
test('notices follow the sample action and use app-font counters with a bottom mobile layout',async()=>{
const html=await readFile(new URL('../index.html',import.meta.url),'utf8');
const css=await readFile(new URL('../src/style.css',import.meta.url),'utf8');
assert.ok(html.indexOf('id="entry-samples"') < html.indexOf('id="entry-notices"'));
assert.match(css,/#notice-position\s*\{[^}]*font-family:inherit/);
assert.match(css,/@media\s*\(max-width:640px\)[\s\S]*#entry-notices\s*\{[^}]*margin-top:auto/);
});
+277
View File
@@ -0,0 +1,277 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { loadTypescript } from './support/load-typescript.mjs';
const html = await readFile(new URL('../index.html', import.meta.url),'utf8');
assert.match(html, /id="settings-dialog"/, 'all options must be in a dismissible dialog, never a persistent sidebar');
const themes=await loadTypescript(new URL('../src/ui/themes.ts',import.meta.url));
const {initExperienceUI}=await loadTypescript(new URL('../src/ui/ExperienceUI.ts',import.meta.url));
class Element {
dataset={};attributes={};listeners={};value='';hidden=false;open=false;inert=false;isConnected=true;
addEventListener(type,fn,{signal}={}){(this.listeners[type]??=[]).push(fn);signal?.addEventListener('abort',()=>this.listeners[type]=this.listeners[type].filter(f=>f!==fn));}
dispatch(type,extra={}){for(const fn of this.listeners[type]??[])fn({target:this,...extra});}
setAttribute(key,value){this.attributes[key]=value;}
focus(){this.owner.activeElement=this;}
showModal(){this.open=true;}
close(){this.open=false;this.dispatch('close');}
getBoundingClientRect(){return {left:20,right:300,top:20,bottom:500};}
}
function harness(value=null,{readError=false,writeError=false}={}){
const values=new Map(value===null?[]:[[themes.THEME_STORAGE_KEY,value]]);let writes=0;
const root=new Element();root.documentElement=new Element();root.activeElement=null;
const ids=['home-dialog','home-confirm','home-cancel','photo-adjust-open','photo-adjust-panel','photo-adjust-done','scene-menu','scene-menu-open','scene-menu-close','scene-guide-replay','scene-zoom-reset','comparison-exit','first-guide','export-open','export-dialog','credits-open','credits-close','credits-dialog','entry-notices','entry-pet-name','settings-pet-name','entry-name-notice','settings-name-notice','welcome-name','entry-photo-button','image','pet-stage','experience','welcome','flow-overlay','flow-title','flow-detail','flow-photo','journey','journey-motion','flow-actions','flow-retry','flow-flat','settings-dialog','settings-open','settings-close','entry-samples','sample-browser','sample-dialog','sample-close','sample-confirm','flow-samples','theme-select','canvas-wrap','scene-toolbar','scene-photo-change','sample-home','entry-name-step','entry-photo-step','entry-name-next','entry-name-skip','entry-name-edit'];
const els=Object.fromEntries(ids.map(id=>[id,new Element()]));Object.values(els).forEach(e=>e.owner=root);root.querySelector=s=>els[s.slice(1)]??null;
const choices=['dog5','dog1','dog2','dog3','dog4'].map(id=>{const e=new Element();e.owner=root;e.dataset.sampleChoice=id;return e;});root.querySelectorAll=()=>choices;
root.defaultView={get localStorage(){if(readError)throw new Error('blocked');return{getItem:key=>values.get(key)??null,setItem(key,value){if(writeError)throw new Error('full');values.set(key,value);writes++;}};}};
let homeCalls=0,arrivalEnds=0;const samples=[];const themesSeen=[];const abort=new AbortController();const ui=initExperienceUI(root,abort.signal,theme=>themesSeen.push(theme),()=>{arrivalEnds++;},sample=>samples.push(sample), undefined, ()=>homeCalls++);
return {root,els,choices,samples,ui,themesSeen,values,abort,get writes(){return writes;},get homeCalls(){return homeCalls;},get arrivalEnds(){return arrivalEnds;}};
}
for(const [value,expected] of [[null,'wood'],['wood','wood'],['booth','booth'],['invalid','wood'],['','wood']]){const h=harness(value);assert.equal(h.root.documentElement.dataset.theme,expected);assert.equal(h.els['theme-select'].value,expected);assert.equal(h.themesSeen.at(-1),expected);assert.equal(h.els['settings-dialog'].open,false);assert.equal(h.els.experience.dataset.flow,'entry');assert.equal(h.ui.isBlocked(),true);}
for(const errors of [{readError:true},{writeError:true},{}]){const h=harness(null,errors);h.els['theme-select'].value='booth';h.els['theme-select'].dispatch('change');assert.equal(h.root.documentElement.dataset.theme,'booth');assert.equal(h.themesSeen.at(-1),'booth');h.els['theme-select'].dispatch('change');assert.equal(h.themesSeen.length,2,'reselect causes no additional appearance work');}
const h=harness();h.els['theme-select'].value='booth';h.els['theme-select'].dispatch('change');assert.equal(h.values.get(themes.THEME_STORAGE_KEY),'booth');assert.equal(harness(h.values.get(themes.THEME_STORAGE_KEY)).els['theme-select'].value,'booth');
h.ui.setPhoto('blob:photo');h.ui.setState('loading');assert.equal(h.els['flow-photo'].src,'blob:photo');assert.equal(h.els.experience.dataset.flow,'loading');assert.equal(h.els['flow-retry'].hidden,true);assert.equal(h.ui.isBlocked(),true);
h.ui.setDetail('model download');assert.equal(h.els['flow-detail'].textContent,'model download');
h.ui.setState('error',true);assert.equal(h.els.experience.dataset.flow,'error');assert.equal(h.els['flow-retry'].hidden,false);assert.equal(h.els['flow-flat'].hidden,false);
h.ui.setState('ready');assert.equal(h.els['flow-overlay'].hidden,true);assert.equal(h.els.welcome.hidden,true);assert.equal(h.ui.isBlocked(),false);
h.ui.setRenderLost(true);assert.equal(h.ui.isBlocked(),true);assert.equal(h.els['canvas-wrap'].inert,true);
h.ui.setState('ready');assert.equal(h.ui.isBlocked(),true,'a late ready state cannot bypass render recovery');
h.ui.setRenderLost(false);assert.equal(h.ui.isBlocked(),false);assert.equal(h.els['canvas-wrap'].inert,false);
h.els['settings-open'].focus();h.els['settings-open'].dispatch('click');assert.equal(h.els['settings-dialog'].open,true);assert.equal(h.ui.isBlocked(),true);assert.equal(h.els['canvas-wrap'].inert,true);
h.els['settings-dialog'].dispatch('cancel',{preventDefault(){}});assert.equal(h.els['settings-dialog'].open,false);assert.equal(h.root.activeElement,h.els['scene-menu-open']);assert.equal(h.els['canvas-wrap'].inert,false);
h.root.activeElement=null;h.els['settings-open'].dispatch('click');h.els['settings-dialog'].dispatch('click',{clientX:10,clientY:10});assert.equal(h.els['settings-dialog'].open,false);assert.equal(h.root.activeElement,h.els['scene-menu-open'],'pointer browsers that do not focus buttons still restore to the opener');
h.els['entry-samples'].focus();h.els['entry-samples'].dispatch('click');assert.equal(h.els['sample-dialog'].open,true);assert.equal(h.els['settings-dialog'].open,false);h.els['sample-close'].dispatch('click');assert.equal(h.root.activeElement,h.els['entry-samples']);
h.els['settings-open'].dispatch('click');h.ui.setState('loading');assert.equal(h.els['settings-dialog'].open,false,'new media closes the options');assert.equal(h.ui.isBlocked(),true);
h.els['entry-samples'].dispatch('click');
assert.equal(h.choices[0].attributes['aria-pressed'],'true');
h.choices[2].dispatch('click');assert.equal(h.choices[2].attributes['aria-pressed'],'true');assert.equal(h.choices[0].attributes['aria-pressed'],'false');assert.equal(h.samples.length,0,'selection must never start the experience');
h.els['sample-confirm'].dispatch('click');assert.deepEqual(h.samples,['dog2']);assert.equal(h.els['sample-dialog'].open,false);
h.els['entry-samples'].dispatch('click');h.els['sample-confirm'].dispatch('click');assert.deepEqual(h.samples,['dog2','dog2'],'same photo can be reselected');
h.ui.setState('error');assert.equal(h.els['flow-samples'].hidden,false);h.els['flow-samples'].dispatch('click');assert.equal(h.els['sample-dialog'].open,true);h.els['sample-dialog'].dispatch('cancel',{preventDefault(){}});assert.equal(h.root.activeElement,h.els['flow-samples']);
h.els['flow-samples'].dispatch('click');h.ui.setRenderLost(true);assert.equal(h.els['sample-dialog'].open,false);h.els['flow-samples'].dispatch('click');assert.equal(h.els['sample-dialog'].open,false);h.ui.setRenderLost(false);
h.els['flow-samples'].dispatch('click');h.abort.abort();assert.equal(h.els['sample-dialog'].open,false);const count=h.themesSeen.length;h.els['theme-select'].value='wood';h.els['theme-select'].dispatch('change');assert.equal(h.themesSeen.length,count,'teardown removes theme handlers');
console.log('Experience UI: default/persisted/invalid/unavailable storage, no-op reselect, real-state flow, source preview, error recovery, modal dismiss/focus/inert, teardown passed (DOM boundary double, no visual QA)');
const named=harness();
const entryName=named.els['entry-pet-name'], settingsName=named.els['settings-pet-name'];
entryName.value='호롱';entryName.dispatch('input');
assert.equal(named.els['entry-photo-button'].textContent,'호롱이 사진 고르기','typing a name updates landing copy');
assert.equal(settingsName.value,'호롱','settings reflects the same app state');
assert.equal(named.els['welcome-name'].textContent,'호롱이가 왔어요');
named.ui.startJourney();assert.equal(named.els['flow-title'].textContent,'호롱이를 할머니 집으로 데려가는 중…');
settingsName.value='초코';settingsName.dispatch('input');
assert.equal(named.els['flow-title'].textContent,'초코를 할머니 집으로 데려가는 중…');
assert.equal(entryName.value,'초코');
settingsName.dispatch('compositionstart');settingsName.value='보';settingsName.dispatch('input',{isComposing:true});
assert.equal(named.els['entry-photo-button'].textContent,'초코 사진 고르기','IME intermediate input does not update state');
settingsName.value='보리';settingsName.dispatch('compositionend');
assert.equal(named.els['entry-photo-button'].textContent,'보리 사진 고르기');
settingsName.value=' <img onerror=x> ';settingsName.dispatch('change');
assert.equal(settingsName.value,'<img onerror=x>');assert.equal(named.els['entry-photo-button'].textContent,'<img onerror=x> 사진 고르기');
named.ui.setMediaOrigin('sample');named.ui.startJourney();
assert.equal(named.els['flow-title'].textContent,'누렁이를 할머니 집으로 데려가는 중…');
assert.equal(settingsName.value,'<img onerror=x>','sample never overwrites the saved name');
named.ui.setMediaOrigin('personal');named.ui.startJourney();
assert.equal(named.els['flow-title'].textContent,'<img onerror=x>를 할머니 집으로 데려가는 중…');
settingsName.value=' ';settingsName.dispatch('change');assert.equal(named.els['entry-photo-button'].textContent,'멍멍이 사진 고르기');
const blockedName=harness(null,{writeError:true});blockedName.els['entry-pet-name'].value='호롱';blockedName.els['entry-pet-name'].dispatch('input');
assert.equal(blockedName.els['entry-name-notice'].hidden,false);assert.equal(blockedName.els['settings-name-notice'].hidden,false);
assert.equal(blockedName.els['entry-photo-button'].textContent,'호롱이 사진 고르기');
console.log('Pet name UI: live shared copy, Korean IME, literal HTML-like names, sample origin, clearing, storage warning passed.');
const longName = harness(); longName.root.activeElement = longName.els['entry-pet-name'];
longName.els['entry-pet-name'].value = '가'.repeat(30); longName.els['entry-pet-name'].dispatch('input');
assert.equal(longName.els['entry-pet-name'].value, '가'.repeat(20), 'focused field enforces the advertised name length after completed input');
for (const [root, unavailable] of [[named, false], [blockedName, true]]) {
for (const prefix of ['entry', 'settings']) {
const description = root.els[`${prefix}-pet-name`].attributes['aria-describedby'];
assert.equal(description, `${prefix}-name-help${unavailable ? ` ${prefix}-name-notice` : ''}`, 'screen-reader descriptions include storage failure only when true');
}
}
const sampleHome = harness();
sampleHome.ui.setMediaOrigin('sample'); sampleHome.ui.setPhoto('blob:sample'); sampleHome.ui.setState('ready');
assert.equal(sampleHome.els['sample-home'].hidden, false);
assert.equal(sampleHome.els['scene-photo-change'].hidden, true);
assert.equal(sampleHome.els['settings-open'].hidden, true);
sampleHome.els['settings-open'].dispatch('click');
assert.equal(sampleHome.els['settings-dialog'].open, false, 'sample settings cannot be opened by stale clicks');
sampleHome.els['sample-home'].dispatch('click');sampleHome.els['home-confirm'].dispatch('click');
assert.equal(sampleHome.homeCalls, 1, 'home invokes media cancellation callback once');
assert.equal(sampleHome.els.experience.dataset.flow, 'entry');
assert.equal(sampleHome.els.welcome.hidden, false);
assert.equal(sampleHome.els['sample-home'].hidden, true);
assert.equal(sampleHome.els['settings-open'].hidden, true);assert.equal(sampleHome.els['credits-open'].hidden,false);
assert.equal(sampleHome.els['flow-photo'].src, '');
assert.equal(sampleHome.root.activeElement, sampleHome.els['entry-photo-button']);
sampleHome.ui.setMediaOrigin('personal'); sampleHome.ui.setState('ready');
assert.equal(sampleHome.els['scene-photo-change'].hidden, false);
assert.equal(sampleHome.els['sample-home'].hidden, false);
assert.match(html, /placeholder="예시 : 장준"/);
assert.doesNotMatch(html, /placeholder="예: 호롱"|창문 열기/);
console.log('Sample return: own-photo controls restored, sample settings gated, landing focus and placeholder verified.');
for (const state of ['loading', 'error']) {
const h = harness(); h.ui.setMediaOrigin('sample'); h.ui.setState(state);
assert.equal(h.els['sample-home'].hidden, false);
assert.equal(h.els['settings-open'].hidden, true);
assert.equal(h.els['scene-photo-change'].hidden, true);
h.els['sample-home'].dispatch('click');h.els['home-confirm'].dispatch('click');
assert.equal(h.homeCalls, 1);
assert.equal(h.els.experience.dataset.flow, 'entry');
}
const pasteGate=harness();
assert.equal(pasteGate.ui.canAcceptPhotoPaste(),false,'name step keeps focus on naming');pasteGate.els['entry-name-skip'].dispatch('click');assert.equal(pasteGate.ui.canAcceptPhotoPaste(),true,'photo step accepts paste');
pasteGate.ui.setMediaOrigin('sample');pasteGate.ui.setState('ready');
assert.equal(pasteGate.ui.canAcceptPhotoPaste(),false,'sample presentation cannot bypass hidden replace action');
pasteGate.els['sample-home'].dispatch('click');pasteGate.els['home-confirm'].dispatch('click');assert.equal(pasteGate.ui.canAcceptPhotoPaste(),true);
pasteGate.els['credits-open'].dispatch('click');assert.equal(pasteGate.ui.canAcceptPhotoPaste(),false);
pasteGate.els['credits-close'].dispatch('click');assert.equal(pasteGate.ui.canAcceptPhotoPaste(),true);
pasteGate.ui.setRenderLost(true);assert.equal(pasteGate.ui.canAcceptPhotoPaste(),false);
pasteGate.ui.setRenderLost(false);pasteGate.abort.abort();assert.equal(pasteGate.ui.canAcceptPhotoPaste(),false);
const step=harness();
assert.equal(step.els['entry-name-step'].hidden,false);
assert.equal(step.els['entry-photo-step'].hidden,true);assert.equal(step.els['entry-notices'].hidden,true);
step.els['entry-name-skip'].dispatch('click');
assert.equal(step.els['entry-name-step'].hidden,true);
assert.equal(step.els['entry-photo-step'].hidden,false);assert.equal(step.els['entry-notices'].hidden,false);
step.ui.setMediaOrigin('sample');step.ui.setState('ready');step.els['sample-home'].dispatch('click');step.els['home-confirm'].dispatch('click');
assert.equal(step.els['entry-photo-step'].hidden,false,'sample return skips already-completed name step');
step.els['entry-name-edit'].dispatch('click');assert.equal(step.els['entry-name-step'].hidden,false);
step.els['entry-pet-name'].value='장준';step.els['entry-pet-name'].dispatch('input');step.els['entry-name-next'].dispatch('click');
assert.equal(step.els['entry-photo-button'].textContent,'장준이 사진 고르기');
assert.equal(step.els['entry-photo-step'].hidden,false);assert.equal(step.els['entry-notices'].hidden,false);
const imeStep=harness();imeStep.els['entry-pet-name'].dispatch('compositionstart');imeStep.els['entry-pet-name'].value='누';
imeStep.els['entry-pet-name'].dispatch('keydown',{key:'Enter',isComposing:false,keyCode:229,preventDefault(){}});
assert.equal(imeStep.els['entry-name-step'].hidden,false,'IME commit Enter never advances or steals focus');
imeStep.els['entry-pet-name'].value='누렁';imeStep.els['entry-pet-name'].dispatch('compositionend');
imeStep.els['entry-pet-name'].dispatch('keydown',{key:'Enter',isComposing:false,keyCode:13,preventDefault(){}});
assert.equal(imeStep.els['entry-photo-step'].hidden,false);
const personalHome=harness();personalHome.els['entry-name-skip'].dispatch('click');personalHome.ui.setMediaOrigin('personal');personalHome.ui.setPhoto('blob:own');personalHome.ui.setState('ready');
assert.equal(personalHome.els['sample-home'].hidden,false,'personal scene also offers home');
personalHome.els['sample-home'].dispatch('click');personalHome.els['home-confirm'].dispatch('click');assert.equal(personalHome.homeCalls,1);assert.equal(personalHome.els.experience.dataset.flow,'entry');assert.equal(personalHome.els['entry-photo-step'].hidden,false);
const skipVisibility=harness();const nameField=skipVisibility.els['entry-pet-name'];
assert.equal(skipVisibility.els['entry-name-skip'].hidden,false);
nameField.value='장준';nameField.dispatch('input');assert.equal(skipVisibility.els['entry-name-skip'].hidden,true);
nameField.value=' ';nameField.dispatch('input');assert.equal(skipVisibility.els['entry-name-skip'].hidden,false);
nameField.dispatch('compositionstart');nameField.value='장';nameField.dispatch('input',{isComposing:true});assert.equal(skipVisibility.els['entry-name-skip'].hidden,true,'visible composing text hides skip without mutating stored name');
nameField.value='';nameField.dispatch('compositionend');assert.equal(skipVisibility.els['entry-name-skip'].hidden,false);
// Main entry has maker details instead of settings, without starting a photo flow.
const credits=harness();
assert.equal(credits.els['credits-open'].hidden,false);
assert.equal(credits.els['settings-open'].hidden,true);assert.equal(credits.els['scene-photo-change'].hidden,true);
credits.els['entry-name-skip'].dispatch('click');
credits.els['credits-open'].dispatch('click');
assert.equal(credits.els['credits-dialog'].open,true);
assert.equal(credits.ui.canAcceptPhotoPaste(),false);
credits.els['credits-dialog'].dispatch('cancel',{preventDefault(){}});
assert.equal(credits.els['credits-dialog'].open,false);
assert.equal(credits.root.activeElement,credits.els['credits-open']);
credits.els['credits-open'].dispatch('click');credits.ui.setState('loading');
assert.equal(credits.els['credits-dialog'].open,false);
assert.equal(credits.els['credits-open'].hidden,true);
credits.ui.setState('ready');assert.equal(credits.els['settings-open'].hidden,false);
credits.els['credits-open'].dispatch('click');assert.equal(credits.els['credits-dialog'].open,false);
credits.els['sample-home'].dispatch('click');credits.els['home-confirm'].dispatch('click');assert.equal(credits.els['credits-open'].hidden,false);
credits.els['credits-open'].dispatch('click');credits.abort.abort();assert.equal(credits.els['credits-dialog'].open,false);
assert.match(html, /id="scene-photo-change"[^>]*\bhidden\b/, 'entry markup must not reserve an invisible photo action before JS starts');
const creditsDismiss=harness();
creditsDismiss.els['credits-open'].dispatch('click');assert.equal(creditsDismiss.els['credits-dialog'].open,true,'maker works from name entry');
creditsDismiss.els['credits-open'].dispatch('click');assert.equal(creditsDismiss.els['credits-dialog'].open,true,'repeated click is safe');
creditsDismiss.els['credits-dialog'].dispatch('click',{clientX:100,clientY:100});assert.equal(creditsDismiss.els['credits-dialog'].open,true,'inside click remains open');
creditsDismiss.els['credits-dialog'].dispatch('click',{clientX:0,clientY:0});assert.equal(creditsDismiss.els['credits-dialog'].open,false,'backdrop closes');
creditsDismiss.els['credits-open'].dispatch('click');creditsDismiss.ui.setRenderLost(true);assert.equal(creditsDismiss.els['credits-dialog'].open,false);
creditsDismiss.els['credits-open'].dispatch('click');assert.equal(creditsDismiss.els['credits-dialog'].open,false,'render-loss guards reopen');
assert.match(html,/href="mailto:sunha321@gmail.com"/);assert.match(html,/<dd>horoli<\/dd>/);
const firstArrival=harness();firstArrival.ui.setPhoto('blob:dog');firstArrival.ui.startJourney();firstArrival.ui.setState('ready');
assert.equal(firstArrival.arrivalEnds,1,'immediate/reduced arrival notifies onboarding once');assert.equal(firstArrival.ui.canShowFirstGuide(),true);
firstArrival.els['first-guide'].showModal();firstArrival.ui.refreshBlocking();assert.equal(firstArrival.ui.isBlocked(),true);assert.equal(firstArrival.ui.canAcceptPhotoPaste(),false);assert.equal(firstArrival.ui.canExportPhoto(),false);
firstArrival.ui.setState('loading');assert.equal(firstArrival.els['first-guide'].open,false);
firstArrival.ui.setMediaOrigin('sample');firstArrival.ui.setState('ready');assert.equal(firstArrival.ui.canShowFirstGuide(),false);
firstArrival.ui.setMediaOrigin('personal');firstArrival.els['theme-select'].value='booth';firstArrival.els['theme-select'].dispatch('change');assert.equal(firstArrival.ui.canShowFirstGuide(),false);
const timedGuide=harness();const guideTimers=new Map();let guideTimerId=0;
timedGuide.root.defaultView.setTimeout=(fn,ms)=>{guideTimers.set(++guideTimerId,{fn,ms});return guideTimerId;};timedGuide.root.defaultView.clearTimeout=id=>guideTimers.delete(id);
timedGuide.ui.setPhoto('blob:dog');timedGuide.ui.startJourney();timedGuide.ui.setState('ready');
assert.equal(timedGuide.arrivalEnds,0);assert.equal(timedGuide.ui.canShowFirstGuide(),false);
let guideTimer=[...guideTimers.entries()][0];assert.equal(guideTimer[1].ms,1350);guideTimers.delete(guideTimer[0]);guideTimer[1].fn();assert.equal(timedGuide.arrivalEnds,0);
guideTimer=[...guideTimers.entries()][0];assert.equal(guideTimer[1].ms,1500);guideTimers.delete(guideTimer[0]);guideTimer[1].fn();assert.equal(timedGuide.arrivalEnds,1);assert.equal(timedGuide.ui.canShowFirstGuide(),true);
timedGuide.ui.setPhoto('blob:next');timedGuide.ui.startJourney();timedGuide.ui.setState('ready');timedGuide.els['settings-open'].dispatch('click');
assert.equal(timedGuide.arrivalEnds,1,'interrupting arrival never opens a guide beneath another modal');assert.equal(guideTimers.size,0);
const menu = harness();
assert.equal(menu.els['scene-menu-open'].hidden,true,'entry uses credits, not scene menu');
menu.ui.setState('ready');menu.els['scene-menu-open'].dispatch('click');
assert.equal(menu.els['scene-menu'].open,true,'scene menu opens');
assert.equal(menu.ui.isBlocked(),true,'scene input is blocked behind menu');
assert.equal(menu.ui.canAcceptPhotoPaste(),false,'menu blocks paste');
assert.equal(menu.ui.canExportPhoto(),true,'menu must not disable export eligibility');
menu.els['scene-menu'].dispatch('cancel',{preventDefault(){}});
assert.equal(menu.els['scene-menu'].open,false);assert.equal(menu.root.activeElement,menu.els['scene-menu-open']);
menu.els['scene-menu-open'].dispatch('click');menu.els['settings-open'].dispatch('click');
assert.equal(menu.els['scene-menu'].open,false);assert.equal(menu.els['settings-dialog'].open,true);
menu.els['settings-close'].dispatch('click');assert.equal(menu.root.activeElement,menu.els['scene-menu-open']);
for (const state of ['loading','error']) {
menu.ui.setState(state);menu.els['scene-menu-open'].dispatch('click');assert.equal(menu.els['scene-menu'].open,true);
menu.ui.setState('ready');assert.equal(menu.els['scene-menu'].open,false);
}
menu.els['scene-menu-open'].dispatch('click');menu.ui.setRenderLost(true);assert.equal(menu.els['scene-menu'].open,false);
menu.els['scene-menu-open'].dispatch('click');assert.equal(menu.els['scene-menu'].open,false);
menu.ui.setRenderLost(false);menu.els['scene-menu-open'].dispatch('click');menu.abort.abort();assert.equal(menu.els['scene-menu'].open,false);
const toolbar=html.match(/<header id="scene-toolbar"[\s\S]*?<\/header>/)[0];
for(const id of ['scene-photo-change','export-open','settings-open','scene-zoom-reset'])assert.ok(!toolbar.includes('id="'+id+'"'),'secondary action removed from header: '+id);
assert.ok(toolbar.includes('id="scene-menu-open"'));
console.log('Scene menu: lifecycle, blocking, action handoff, focus and reduced header passed.');
const repeatMenu=harness();repeatMenu.ui.setState('ready');
repeatMenu.els['scene-menu-open'].dispatch('click');repeatMenu.els['scene-menu-open'].dispatch('click');
repeatMenu.els['scene-menu-close'].dispatch('click');repeatMenu.els['scene-menu-open'].dispatch('click');
repeatMenu.els['scene-menu'].dispatch('close');assert.equal(repeatMenu.els['scene-menu'].open,true);assert.equal(repeatMenu.els['scene-menu-open'].attributes['aria-expanded'],'true');
repeatMenu.els['scene-menu'].dispatch('click',{clientX:0,clientY:0});assert.equal(repeatMenu.els['scene-menu'].open,false);
for(const action of ['scene-photo-change','export-open','scene-zoom-reset','scene-guide-replay']){
repeatMenu.els['scene-menu-open'].dispatch('click');repeatMenu.els[action].dispatch('click');assert.equal(repeatMenu.els['scene-menu'].open,false,action+' closes the sheet synchronously');
}
repeatMenu.ui.setMediaOrigin('sample');repeatMenu.els['scene-menu-open'].dispatch('click');
for(const action of ['scene-photo-change','export-open','settings-open','scene-guide-replay'])assert.equal(repeatMenu.els[action].hidden,true);
repeatMenu.els['sample-home'].dispatch('click');repeatMenu.els['home-confirm'].dispatch('click');assert.equal(repeatMenu.els['scene-menu'].open,false);assert.equal(repeatMenu.els['scene-menu-open'].hidden,true);assert.equal(repeatMenu.root.activeElement,repeatMenu.els['entry-photo-button']);
const loadingActions=harness();
for(const origin of ['personal','sample']){
loadingActions.ui.setMediaOrigin(origin);
for(const state of ['entry','loading','ready']){
loadingActions.ui.setState(state);assert.equal(loadingActions.els['flow-actions'].hidden,true,'no replacement action in '+state);
}
loadingActions.ui.setState('error',true);assert.equal(loadingActions.els['flow-actions'].hidden,false,'error keeps recovery actions');
assert.equal(loadingActions.els['flow-retry'].hidden,false);
}
const publicSettings=html.match(/<dialog id="settings-dialog"[\s\S]*?<\/dialog>/)[0];
assert.ok(!publicSettings.includes('id="camera-composition"'),'window composition is not a consumer setting');
assert.ok(html.indexOf('id="camera-composition"')>html.indexOf('id="internal-controls"'),'retain hidden rollback adapter without changing saved camera');
assert.ok(!html.includes('익숙한 유리문 너머에서 만나보세요'),'redundant landing introduction removed');
assert.ok(!html.includes('id="welcome-pet-copy"'),'removed introduction has no live name binding');
const edit=harness();edit.ui.setState('ready');
assert.equal(typeof edit.ui.startPhotoAdjust,'function');edit.ui.startPhotoAdjust();
assert.equal(edit.ui.isPhotoAdjusting(),true);assert.equal(edit.ui.isBlocked(),false);assert.equal(edit.els['canvas-wrap'].inert,false);
assert.equal(edit.ui.canShowFirstGuide(),false);assert.equal(edit.ui.canAcceptPhotoPaste(),false);assert.equal(edit.ui.canExportPhoto(),false);
edit.els['photo-adjust-done'].dispatch('click');assert.equal(edit.ui.isPhotoAdjusting(),false);
edit.ui.setMediaOrigin('sample');edit.ui.startPhotoAdjust();assert.equal(edit.ui.isPhotoAdjusting(),true,'sample edit supported');
edit.root.dispatch('keydown',{key:'Escape',preventDefault(){}});assert.equal(edit.ui.isPhotoAdjusting(),false);
edit.ui.startPhotoAdjust();edit.els['scene-menu-open'].dispatch('click');assert.equal(edit.ui.isPhotoAdjusting(),false);edit.els['scene-menu-close'].dispatch('click');
edit.ui.startPhotoAdjust();edit.els['sample-home'].dispatch('click');assert.equal(edit.ui.isPhotoAdjusting(),false);assert.equal(edit.homeCalls,0);assert.equal(edit.els['home-dialog'].open,true);
edit.els['home-cancel'].dispatch('click');assert.equal(edit.els['home-dialog'].open,false);assert.equal(edit.root.activeElement,edit.els['sample-home']);assert.equal(edit.els.experience.dataset.flow,'ready');
edit.els['sample-home'].dispatch('click');edit.els['home-confirm'].dispatch('click');assert.equal(edit.homeCalls,1);assert.equal(edit.els.experience.dataset.flow,'entry');
for(const transition of ['state','photo','origin','loss','abort']){
const e=harness();e.ui.setState('ready');e.ui.startPhotoAdjust();
if(transition==='state')e.ui.setState('loading');if(transition==='photo')e.ui.setPhoto('blob:new');if(transition==='origin')e.ui.setMediaOrigin('sample');if(transition==='loss')e.ui.setRenderLost(true);if(transition==='abort')e.abort.abort();
assert.equal(e.ui.isPhotoAdjusting(),false,transition+' exits photo edit');
}
const askHome=harness();askHome.ui.setMediaOrigin('sample');askHome.ui.setPhoto('blob:unchanged');askHome.ui.setState('loading');
askHome.els['sample-home'].dispatch('click');askHome.els['sample-home'].dispatch('click');assert.equal(askHome.homeCalls,0);
askHome.els['home-dialog'].dispatch('cancel',{preventDefault(){}});assert.equal(askHome.els['flow-photo'].src,'blob:unchanged');assert.equal(askHome.els.experience.dataset.flow,'loading');assert.equal(askHome.homeCalls,0);
askHome.els['sample-home'].dispatch('click');askHome.els['home-dialog'].dispatch('click',{clientX:0,clientY:0});assert.equal(askHome.els['home-dialog'].open,false);
askHome.els['sample-home'].dispatch('click');askHome.ui.setState('ready');askHome.els['home-confirm'].dispatch('click');assert.equal(askHome.homeCalls,0,'stale confirmation after new state cannot navigate');
askHome.els['sample-home'].dispatch('click');askHome.els['home-dialog'].dispatch('close');assert.equal(askHome.els['home-dialog'].open,true,'old close event cannot close a new confirmation');
askHome.ui.setRenderLost(true);assert.equal(askHome.els['home-dialog'].open,false);
assert.match(toolbar,/id="sample-home"[^>]*aria-label="첫 화면으로 가기"/);
assert.ok(!html.match(/<dialog id="scene-menu"[\s\S]*?<\/dialog>/)[0].includes('id="sample-home"'));
assert.ok(!html.match(/<dialog id="scene-menu"[\s\S]*?<\/dialog>/)[0].includes('id="photo-adjust-open"'));
+144
View File
@@ -0,0 +1,144 @@
import {test} from 'node:test';
/** CPU geometry/ray contract checks; these do not execute GLSL or verify WebGL pixels. */
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import * as THREE from 'three';
const source = new URL('../src/objects/ExperimentalCourtyard.ts', import.meta.url);
assert.ok(existsSync(source), 'the opt-in courtyard module must exist');
const { ExperimentalCourtyard, createCourtyardUniforms, createCourtyardLayout, courtyardShader, courtyardSurfaceShader } = await import(source);
assert.equal(typeof createCourtyardUniforms, 'function', 'B needs independent default-disabled uniforms before a courtyard exists');
const defaults = createCourtyardUniforms();
assert.equal(defaults.uYardEnabled.value, 0);
assert.notEqual(defaults.uYardBoxMin.value, createCourtyardUniforms().uYardBoxMin.value);
// Independent slab traversal matching the shader, compared with actual raster triangles.
function intersectBoxes(layout, origin, ray) {
let closest = null;
for (const box of layout) {
let near = -1e20, far = 1e20, nearAxis = -1, farAxis = -1;
let missed = false;
for (let axis = 0; axis < 3; axis++) {
const p = origin.getComponent(axis), d = ray.getComponent(axis);
if (Math.abs(d) < 1e-8) {
if (p < box.min[axis] || p > box.max[axis]) missed = true;
continue;
}
const a = (box.min[axis] - p) / d, b = (box.max[axis] - p) / d;
if (Math.min(a, b) > near) { near = Math.min(a, b); nearAxis = axis; }
if (Math.max(a, b) < far) { far = Math.max(a, b); farAxis = axis; }
}
if (missed || near > far || far < 1e-5) continue;
const outside = near >= 1e-5, distance = outside ? near : far;
const axis = outside ? nearAxis : farAxis;
const normal = new THREE.Vector3().setComponent(axis, Math.sign(ray.getComponent(axis)) * (outside ? -1 : 1));
if (!closest || distance < closest.distance) closest = { distance, normal, box };
}
return closest;
}
const yard = new ExperimentalCourtyard();
assert.equal(yard.group.visible, false, 'ordinary photo mode must not show the courtyard');
assert.equal(yard.uniforms.uYardEnabled.value, 0);
assert.equal(yard.group.children.length, 1, 'all courtyard surfaces must share one draw');
const mesh = yard.group.children[0];
assert.ok(mesh.isMesh);
assert.equal(mesh.material.uniforms, yard.uniforms, 'raster and B must share live uniform objects');
assert.equal(mesh.material.side, THREE.DoubleSide, 'inside-box hits must match the double-sided raster geometry');
assert.equal(mesh.material.lights, false);
assert.ok(mesh.material.fragmentShader.includes(courtyardSurfaceShader));
assert.ok(courtyardShader.includes(courtyardSurfaceShader), 'direct and refracted colors need identical source');
assert.match(courtyardShader, /bool bCourtyardHit\(vec3 origin,\s*vec3 ray,\s*out float distance,\s*out vec3 color\)/);
assert.ok(!yard.group.children.some(object => object.isLight));
const uniformIdentities = Object.fromEntries(Object.entries(yard.uniforms).map(([key, uniform]) => [key, uniform]));
let seed = 291;
const random = () => ((seed = Math.imul(seed, 1664525) + 1013904223 | 0) >>> 0) / 4294967296;
let rays = 0, hits = 0, maxError = 0;
for (const [width, height] of [[.9, 1.4], [1.8, 1.1], [2.8, .35], [.3, 2.2]]) {
const glassZ = .6;
yard.resize(width, height, glassZ);
yard.setEnabled(true);
yard.group.updateMatrixWorld(true);
const layout = createCourtyardLayout(width, height, glassZ);
assert.ok(layout.length <= 16);
assert.equal(mesh.geometry.getAttribute('position').count, layout.length * 36);
assert.equal(mesh.geometry.groups.length, 0, 'merged geometry must not preserve per-box draw groups');
const floor = layout.find(box => box.name === 'ground');
assert.ok(Math.abs(floor.max[1] - (-height / 2 - .1 * Math.min(width, height))) < 1e-10);
assert.ok(Math.abs(layout.find(box => box.name === 'boundary-wall').max[2] - (glassZ - 3 * height)) < 1e-10);
assert.ok(layout.find(box => box.name === 'wood-platform').max[0] < 0, 'the dog foreground on the right stays clear');
for (let boxIndex = 0; boxIndex < layout.length; boxIndex++) {
const box = layout[boxIndex];
assert.deepEqual(yard.uniforms.uYardBoxMin.value[boxIndex].toArray(), box.min);
assert.deepEqual(yard.uniforms.uYardBoxMax.value[boxIndex].toArray(), box.max);
assert.equal(yard.uniforms.uYardBoxKind.value[boxIndex], box.kind);
assert.ok(box.max[2] < glassZ, `${box.name} must be outside the glass`);
assert.ok(box.min.every((min, axis) => min < box.max[axis]));
}
const cases = [];
// Exit rays from glass, side/under views, both signs and exactly parallel slabs.
for (let i = 0; i < 1600; i++) {
cases.push([
new THREE.Vector3((random() - .5) * width * 4, (random() - .5) * height * 3, glassZ + (random() - .7) * height * 5),
new THREE.Vector3(random() - .5, random() - .5, random() - .5).normalize()
]);
}
for (const box of layout) {
const center = new THREE.Vector3().fromArray(box.min).add(new THREE.Vector3().fromArray(box.max)).multiplyScalar(.5);
for (let axis = 0; axis < 3; axis++) for (const sign of [-1, 1]) {
cases.push([center, new THREE.Vector3().setComponent(axis, sign)]);
const surface = center.clone().setComponent(axis, sign < 0 ? box.min[axis] : box.max[axis]);
cases.push([surface.clone().addScaledVector(new THREE.Vector3().setComponent(axis, sign), height), new THREE.Vector3().setComponent(axis, -sign)]);
}
}
for (const [origin, direction] of cases) {
const intersections = new THREE.Raycaster(origin, direction, 1e-5).intersectObject(mesh, false);
const expected = intersections[0];
const actual = intersectBoxes(layout, origin, direction);
assert.equal(Boolean(actual), Boolean(expected), `hit mismatch at ray ${rays}`);
if (expected) {
const error = Math.abs(expected.distance - actual.distance);
maxError = Math.max(error, maxError);
assert.ok(error < 5e-5, `nearest distance mismatch ${error} at ray ${rays}`);
// Box junctions have coincident inward/outward faces. Float32 mesh bounds can reverse their tie order.
assert.ok(intersections.some(hit => Math.abs(hit.distance - actual.distance) < 5e-5 && actual.normal.dot(hit.face.normal) > .999), `outward normal mismatch at ray ${rays}`);
hits++;
}
rays++;
}
for (const [key, uniform] of Object.entries(uniformIdentities)) assert.equal(yard.uniforms[key], uniform, `${key} identity must survive resize`);
}
const previousGeometry = mesh.geometry;
let replacedDisposed = 0;
previousGeometry.addEventListener('dispose', () => replacedDisposed++);
yard.resize(1, 1, .6);
assert.equal(replacedDisposed, 1, 'resize must release replaced geometry');
yard.setEnabled(false);
assert.equal(yard.group.visible, false);
assert.equal(yard.uniforms.uYardEnabled.value, 0);
yard.setEnabled(true);
assert.equal(yard.group.visible, true);
assert.equal(yard.uniforms.uYardEnabled.value, 1);
assert.throws(() => yard.resize(0, 1, .6), /positive/);
assert.throws(() => yard.resize(1, NaN, .6), /finite/);
assert.throws(() => yard.resize(1, 1, Infinity), /finite/);
const scene = new THREE.Scene();
scene.add(yard.group);
let geometryDisposed = 0, materialDisposed = 0;
mesh.geometry.addEventListener('dispose', () => geometryDisposed++);
mesh.material.addEventListener('dispose', () => materialDisposed++);
yard.dispose();
yard.dispose();
assert.equal(geometryDisposed, 1);
assert.equal(materialDisposed, 1);
assert.equal(yard.group.parent, null);
assert.equal(yard.uniforms.uYardEnabled.value, 0);
console.log(JSON.stringify({ test: 'ExperimentalCourtyard CPU geometry/analytic ray contract', rays, hits, maxError, boxes: yard.uniforms.uYardBoxMin.value.length, draws: 1, webglPixels: 'not tested' }));
test('optional dog contact shadow uses the shared yard shader without another mesh or pass',()=>{
const yard=new ExperimentalCourtyard();yard.setDogContact(.2,.35,.12,.6);
assert.deepEqual(yard.uniforms.uYardDogContact.value.toArray(),[.2,.35,.12,.6]);
assert.equal(yard.group.children.length,1);assert.match(yard.group.children[0].material.fragmentShader,/uYardDogContact/);assert.match(courtyardShader,/uYardDogContact/);yard.dispose();
});
+47
View File
@@ -0,0 +1,47 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadTypescript} from './support/load-typescript.mjs';
class El extends EventTarget {
hidden=false;disabled=false;open=false;attrs={};textContent='';isConnected=true;dataset={};
setAttribute(k,v){this.attrs[k]=v;}removeAttribute(k){delete this.attrs[k];}
focus(){this.focused=true;}showModal(){this.open=true;}close(){this.open=false;this.dispatchEvent(new Event('close'));}
fire(type,values={}){const e=new Event(type,{cancelable:true});Object.assign(e,values);this.dispatchEvent(e);}
getBoundingClientRect(){return {left:10,right:100,top:10,bottom:100};}
}
async function harness(){
const {initExportUI}=await loadTypescript(new URL('../src/ui/ExportUI.ts',import.meta.url));
const ids=['scene-menu-open','export-open','export-dialog','export-close','export-photo','export-scene','export-preview','export-status','export-download','export-share'];
const els=Object.fromEntries(ids.map(id=>[id,new El()]));const revoked=[],pending=[];let allowed=true;
const root={querySelector:s=>els[s.slice(1)],defaultView:{URL:{createObjectURL:()=>`blob:${pending.length}`,revokeObjectURL:url=>revoked.push(url)},navigator:{}}};
const abort=new AbortController();const ui=initExportUI(root,abort.signal,()=>allowed,(kind,signal)=>new Promise((resolve,reject)=>pending.push({kind,signal,resolve,reject})),()=>{});
return {els,pending,revoked,abort,ui,nav:root.defaultView.navigator,setAllowed:v=>allowed=v};
}
const tick=()=>new Promise(r=>setTimeout(r,0));
test('export is opt-in, repeated capture is ignored, preview is ready before saving',async()=>{
const h=await harness();assert.equal(h.pending.length,0);h.els['export-open'].fire('click');assert.equal(h.els['export-dialog'].open,true);
h.els['export-photo'].fire('click');h.els['export-photo'].fire('click');assert.equal(h.pending.length,1);assert.equal(h.pending[0].kind,'photo');
h.pending[0].resolve(new Blob(['png'],{type:'image/png'}));await tick();assert.equal(h.els['export-download'].hidden,false);assert.equal(h.els['export-preview'].src,'blob:1');
h.els['export-close'].fire('click');assert.deepEqual(h.revoked,['blob:1']);assert.equal(h.els['export-dialog'].open,false);
});
test('cancel and abort suppress stale PNG and revoke results',async()=>{
const h=await harness();h.els['export-open'].fire('click');h.els['export-scene'].fire('click');h.els['export-dialog'].fire('cancel');
assert.equal(h.pending[0].signal.aborted,true);h.pending[0].resolve(new Blob(['png']));await tick();assert.equal(h.els['export-download'].hidden,true);
h.els['export-open'].fire('click');h.els['export-photo'].fire('click');h.abort.abort();assert.equal(h.pending[1].signal.aborted,true);assert.equal(h.els['export-dialog'].open,false);
});
test('unavailable scene cannot open or start export; recoverable errors remain retryable',async()=>{
const h=await harness();h.setAllowed(false);h.els['export-open'].fire('click');assert.equal(h.els['export-dialog'].open,false);
h.setAllowed(true);h.els['export-open'].fire('click');h.els['export-photo'].fire('click');h.pending[0].reject(new Error('try again'));await tick();assert.equal(h.els['export-photo'].disabled,false);assert.match(h.els['export-status'].textContent,/try again/);
});
test('interrupting a native share does not disable sharing the next preview',async()=>{
const h=await harness();let finish;h.nav.canShare=()=>true;h.nav.share=()=>new Promise(resolve=>finish=resolve);
h.els['export-open'].fire('click');h.els['export-photo'].fire('click');h.pending[0].resolve(new Blob(['png']));await tick();
h.els['export-share'].fire('click');assert.equal(h.els['export-share'].disabled,true);
h.els['export-close'].fire('click');h.els['export-open'].fire('click');h.els['export-photo'].fire('click');h.pending[1].resolve(new Blob(['png']));await tick();
finish();await tick();assert.equal(h.els['export-share'].disabled,false);assert.equal(h.els['export-share'].hidden,false);
});
test('export dismissal restores focus to visible scene menu, not the hidden sheet action',async()=>{
const h=await harness();h.els['export-open'].fire('click');h.els['export-close'].fire('click');
assert.equal(h.els['scene-menu-open'].focused,true);assert.notEqual(h.els['export-open'].focused,true);
});
+47
View File
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {loadTypescript} from './support/load-typescript.mjs';
class El extends EventTarget {open=false;inert=false;hidden=false;textContent='';focus(){}show(){this.open=true;}close(){this.open=false;this.dispatchEvent(new Event('close'));}setAttribute(){}removeAttribute(){}getBoundingClientRect(){return {width:100,height:44};}closest(){return this.hidden?this:null;}fire(t){this.dispatchEvent(new Event(t,{cancelable:true}));}}
async function harness(mobile=false,saved={},throws=false){
const {initFirstGuide}=await loadTypescript(new URL('../src/ui/FirstGuide.ts',import.meta.url));
const ids=['first-guide','experience','guide-scene-target','door-toggle','gyro','gyro-recenter'];const els=Object.fromEntries(ids.map(id=>[id,new El()]));let eligible=true;const values=new Map(Object.entries(saved)),writes=[],tours=[];
const view=new El();Object.defineProperty(view,'localStorage',{get(){if(throws)throw Error('blocked');return {getItem:k=>values.get(k)??null,setItem:(k,v)=>{writes.push(k);values.set(k,v);}};}});view.matchMedia=()=>({matches:true});view.visualViewport=new El();
const root={querySelector:s=>els[s.slice(1)],defaultView:view};
const factory=config=>{let index=0,active=false;const tour={config,drive(){active=true;},isActive:()=>active,isLastStep:()=>index===config.steps.length-1,moveNext(){index++;},getActiveIndex:()=>index,refresh(){tour.refreshed=true;},destroy(){if(!active)return;active=false;config.onDestroyed?.();}};tours.push(tour);return tour;};
const abort=new AbortController();const guide=initFirstGuide(root,abort.signal,mobile,()=>eligible,()=>{},factory);
return {els,guide,abort,writes,tours,view,values,setEligible:v=>eligible=v};
}
test('mobile driver tour highlights actual controls, blocks actions, and finishes once',async()=>{
const h=await harness(true);h.guide.start();const t=h.tours[0];assert.equal(h.els['first-guide'].open,true);assert.equal(h.els.experience.inert,true);
assert.deepEqual(t.config.steps.map(s=>s.element()),[h.els['guide-scene-target'],h.els['door-toggle'],h.els.gyro]);
assert.equal(t.config.disableActiveInteraction,true);assert.equal(t.config.overlayClickBehavior,'none');assert.equal(t.config.animate,false);
t.config.onNextClick();t.config.onNextClick();assert.equal(h.writes.length,0);t.config.onNextClick();assert.equal(h.writes.length,1);assert.equal(h.els.experience.inert,false);h.guide.start();assert.equal(h.tours.length,1);
});
test('desktop has two steps; previous completion suppresses auto tour but replay works',async()=>{
const h=await harness(false,{'behind-glass.first-guide.v1':'1'});h.guide.start();assert.equal(h.tours.length,0);h.guide.start(true);assert.equal(h.tours[0].config.steps.length,2);h.tours[0].config.onCloseClick();assert.equal(h.els['first-guide'].open,false);
});
test('external close and abort do not save completion; explicit Escape does',async()=>{
const h=await harness();h.setEligible(false);h.guide.start();assert.equal(h.tours.length,0);h.setEligible(true);h.guide.start();h.els['first-guide'].close();assert.equal(h.writes.length,0);assert.equal(h.els.experience.inert,false);
h.guide.start();h.tours[1].config.onDestroyStarted();assert.equal(h.writes.length,1);
const a=await harness();a.guide.start();a.abort.abort();assert.equal(a.els['first-guide'].open,false);assert.equal(a.writes.length,0);a.guide.start();assert.equal(a.tours.length,1);
});
test('recenter hint is contextual, mobile-only and independently once-per-browser',async()=>{
const h=await harness(true,{'behind-glass.first-guide.v1':'1'});h.els['gyro-recenter'].hidden=true;h.guide.recenter();assert.equal(h.tours.length,0);h.els['gyro-recenter'].hidden=false;h.guide.recenter();assert.equal(h.tours[0].config.steps.length,1);h.tours[0].config.onNextClick();assert.ok(h.values.has('behind-glass.recenter-guide.v1'));h.guide.recenter();assert.equal(h.tours.length,1);
const pc=await harness(false,{'behind-glass.first-guide.v1':'1'});pc.guide.recenter();assert.equal(pc.tours.length,0);
});
test('storage denial is session-only, missing targets resolve undefined, viewport changes refresh active tour',async()=>{
const h=await harness(true,{},true);h.guide.start();h.els['door-toggle'].hidden=true;assert.equal(h.tours[0].config.steps[1].element(),undefined);h.view.visualViewport.fire('resize');assert.equal(h.tours[0].refreshed,true);h.tours[0].config.onCloseClick();h.guide.start();assert.equal(h.tours.length,1);
});
test('cleanup still runs when Driver destroys before its first RAF without onDestroyed',async()=>{
const h=await harness();h.guide.start();h.tours[0].destroy=()=>{};h.abort.abort();assert.equal(h.els.experience.inert,false);assert.equal(h.els['first-guide'].open,false);assert.equal(h.writes.length,0);
});
test('queued close from prior run cannot cancel a newer replay',async()=>{
const h=await harness();h.guide.start();h.els['first-guide'].close();h.guide.start(true);h.els['first-guide'].fire('close');assert.equal(h.els['first-guide'].open,true);assert.equal(h.els.experience.inert,true);
});
test('spotlight popover keeps arrows outside scroll clipping and colors the correct edge',async()=>{
const {readFile}=await import('node:fs/promises');const css=await readFile(new URL('../src/style.css',import.meta.url),'utf8');
assert.match(css,/\.behind-glass-tour\s*\{[^}]*overflow:visible/);
assert.match(css,/arrow-side-top\s*\{\s*border-top-color:var\(--cream\)/);
assert.match(css,/arrow-side-bottom\s*\{\s*border-bottom-color:var\(--cream\)/);
});
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
const range = id => {
const input = html.match(new RegExp(`<input\\b[^>]*\\bid="${id}"[^>]*>`))?.[0];
assert.ok(input, `${id} is a real HTML input`);
return Object.fromEntries([...input.matchAll(/([\w-]+)="([^"]*)"/g)].map(([, key, value]) => [key, value]));
};
test('photo distance defaults to 16 within the original 0–21 percent range', () => {
const input = range('photo-glass-distance');
assert.deepEqual([input.type, input.min, input.max, input.step, input.value], ['range', '0', '21', '1', '16']);
assert.match(html, /<output id="photoGlassDistanceOut">16%<\/output>/);
assert.doesNotMatch(html, /100% 멀어짐/);
});
test('camera offset exposes 0.35–0.90 with the gentler 0.75 default', () => {
const input = range('camera-offset');
assert.deepEqual([input.type, input.min, input.max, input.step, input.value], ['range', '0.35', '0.90', '0.01', '0.75']);
});
test('curvature offers exactly convex and concave with convex selected', () => {
const select = html.match(/<select\b[^>]*\bid="curvature"[^>]*>([\s\S]*?)<\/select>/)?.[1];
assert.ok(select, 'curvature is a select rather than a continuous slider');
const options = [...select.matchAll(/<option\b([^>]*)>([^<]*)<\/option>/g)];
assert.equal(options.length, 2);
assert.deepEqual(options.map(([, attrs, label]) => [attrs.match(/value="([^"]+)"/)?.[1], label]), [['1', '볼록'], ['-1', '오목']]);
assert.match(options[0][1], /\bselected\b/);
assert.doesNotMatch(options[1][1], /\bselected\b/);
assert.doesNotMatch(html, /id="curvatureOut"/);
});
test('the gloss slider and its output are removed from the DOM', () => {
assert.doesNotMatch(html, /id="gloss-strength"|id="glossOut"/);
});
+173
View File
@@ -0,0 +1,173 @@
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();
}
});
+33
View File
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import * as THREE from 'three';
import {loadTypescript} from './support/load-typescript.mjs';
const {WindowFinish}=await loadTypescript(new URL('../src/objects/WindowFinish.ts',import.meta.url));
const {WindowReveal}=await loadTypescript(new URL('../src/objects/WindowReveal.ts',import.meta.url));
const {attachMeshRefraction}=await loadTypescript(new URL('../src/rendering/MeshRefraction.ts',import.meta.url));
function fixture(){
const geometry=new THREE.BoxGeometry(),materials=[new THREE.MeshBasicMaterial()],frames=Array.from({length:4},()=>new THREE.Mesh(geometry,materials)),scene=new THREE.Scene(),reveal=new WindowReveal(materials[0]);
const finish=new WindowFinish(scene,frames,reveal,materials,materials[0]);finish.apply('wood');
return {scene,finish,reveal,dispose(){finish.dispose();reveal.dispose();geometry.dispose();materials.forEach(m=>m.dispose());}};
}
test('room finish has neutral fill and upper-right daylight without new render passes',()=>{
const f=fixture(),lights=f.scene.children.filter(o=>o.isLight),ambient=lights.find(o=>o.isAmbientLight),key=lights.find(o=>o.isDirectionalLight);
assert.equal(lights.length,2,'one ambient and one key keep existing B reveal and mobile light budget');
assert.ok(ambient.color.b/ambient.color.r>.95,'room fill must not turn ivory/plaster olive');
assert.ok(key.position.x>0&&key.position.y>0&&key.position.z>0,'daylight comes from upper right in the shipped frame scene');
assert.ok(ambient.intensity>=1.35&&ambient.intensity<=1.7);
assert.ok(key.intensity>=2&&key.intensity<=2.5);
assert.equal(key.castShadow,false,'portable authored contact shading avoids new shadow-map passes');
f.finish.apply('booth');assert.ok(lights.every(l=>!l.visible));f.finish.apply('wood');assert.ok(lights.every(l=>l.visible));
f.dispose();assert.ok(lights.every(l=>l.parent===null));
});
test('custom B reveal reads the same refined room lights, not stale preview lighting',()=>{
const f=fixture(),source=new THREE.Mesh(new THREE.PlaneGeometry(1,1),new THREE.ShaderMaterial({uniforms:{uDepthMap:{value:new THREE.Texture()},uDepthScale:{value:.25},uDepthBias:{value:0}}}));
const glass=new THREE.MeshPhysicalMaterial(),optics=attachMeshRefraction(glass,source);
optics.syncReveal(f.reveal.meshes,f.scene);
const ambient=f.scene.children.find(o=>o.isAmbientLight),key=f.scene.children.find(o=>o.isDirectionalLight);
assert.deepEqual(optics.uniforms.uBRevealAmbient.value.toArray(),ambient.color.clone().multiplyScalar(ambient.intensity).toArray());
assert.deepEqual(optics.uniforms.uBRevealLightColor.value.toArray(),key.color.clone().multiplyScalar(key.intensity).toArray());
assert.ok(optics.uniforms.uBRevealLightDirection.value.distanceTo(key.position.clone().normalize())<1e-12);
f.dispose();source.geometry.dispose();source.material.uniforms.uDepthMap.value.dispose();source.material.dispose();glass.dispose();
});
+125
View File
@@ -0,0 +1,125 @@
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { test } from 'node:test';
import * as THREE from 'three';
import { loadTypescript } from './support/load-typescript.mjs';
import { loadFrameGeometry } from './support/load-frame-geometry.mjs';
const modulePath = new URL('../src/objects/BlenderHouseSurround.ts', import.meta.url);
const assetPath = new URL('../public/assets/window/grandma-house-surround.glb', import.meta.url);
const load = async () => { assert.ok(existsSync(modulePath), 'A bounded adapter must attach the authored room around the intact photo'); return loadTypescript(modulePath); };
const geometry = () => loadFrameGeometry(assetPath);
function vertices(root) { const result=[]; root.traverse(o=>{if(o.isMesh)result.push([...o.geometry.attributes.position.array]);}); return result; }
test('house surround attaches to the existing frame scene and has a bounded room fit', async () => {
const { houseViewScale } = await load();
assert.equal(houseViewScale('wood'), .82);
assert.equal(houseViewScale('booth'), 1);
});
test('authored house geometry preserves an open photo aperture, stable resize and slipper proportions', async () => {
const { prepareHouseAsset } = await load(), root=geometry(), slippers=root.getObjectByName('HouseSlippers');
slippers.geometry.computeBoundingBox(); const original=slippers.geometry.boundingBox.getSize(new THREE.Vector3());
const asset=prepareHouseAsset(root);
for(const [w,h] of [[1,1.32],[1,1],[3,1],[1,3],[.2,4],[4,.2]]) {
asset.resize(w,h,.6); root.updateMatrixWorld(true);
root.traverse(o=>{if(!o.isMesh)return;const p=o.geometry.attributes.position,n=o.geometry.attributes.normal;
for(let i=0;i<p.count;i++){assert.ok([p.getX(i),p.getY(i),p.getZ(i),n.getX(i),n.getY(i),n.getZ(i)].every(Number.isFinite));}
});
for(const x of [-.4,0,.4])for(const y of [-.4,0,.4]) {
const ray=new THREE.Raycaster(new THREE.Vector3(x*w,y*h,4),new THREE.Vector3(0,0,-1));
assert.equal(ray.intersectObject(root,true).length,0,'room geometry must not replace the intact-photo opening');
}
slippers.geometry.computeBoundingBox();const size=slippers.geometry.boundingBox.getSize(new THREE.Vector3()),s=Math.min(w,h);
for(let i=0;i<3;i++)assert.ok(Math.abs(size.getComponent(i)-original.getComponent(i)*s)<1e-5,'slippers keep physical proportions');
assert.equal(root.position.z,.6);
}
asset.resize(1,1.32,.6);const before=vertices(root);asset.resize(3,1,.6);asset.resize(1,1.32,.6);assert.deepEqual(vertices(root),before);asset.dispose();
});
test('house loader keeps single ownership through repeat loads, hidden theme, failed retry and late teardown', async () => {
const { BlenderHouseSurround }=await load(),scene=new THREE.Scene(),pending=[],states=[];
const room=new BlenderHouseSurround(scene,state=>states.push(state),signal=>new Promise((resolve,reject)=>pending.push({resolve,reject,signal})));
room.resize(1,2,.6);const first=room.load();assert.equal(first,room.load());pending[0].reject(new Error('offline'));await first;assert.equal(states.at(-1),'error');assert.equal(scene.children.length,0);
const retry=room.load();room.apply('booth');room.resize(2,1,.6);pending[1].resolve(geometry());await retry;assert.equal(states.at(-1),'ready');assert.equal(scene.children[0].visible,false);room.apply('wood');assert.equal(scene.children[0].visible,true);
const root=scene.children[0];let disposed=0;root.traverse(o=>{if(o.isMesh)o.geometry.addEventListener('dispose',()=>disposed++);});room.dispose();room.dispose();assert.equal(disposed,10);assert.equal(scene.children.length,0);
const late=new BlenderHouseSurround(scene,()=>{},signal=>new Promise(resolve=>pending.push({resolve,signal})));const loading=late.load();late.dispose();assert.equal(pending[2].signal.aborted,true);const stale=geometry();let staleDisposed=0;stale.traverse(o=>{if(o.isMesh)o.geometry.addEventListener('dispose',()=>staleDisposed++);});pending[2].resolve(stale);await loading;assert.equal(staleDisposed,7);assert.equal(scene.children.length,0);
});
test('room fit keeps a useful neutral photo and central view clear during gentle room-relative lean', async () => {
const { prepareHouseAsset,houseViewScale }=await load();const{fitWindowScale}=await loadTypescript(new URL('../src/objects/BlenderWindowFrame.ts',import.meta.url));
const root=geometry(),asset=prepareHouseAsset(root);
const {CameraController}=await loadTypescript(new URL('../src/input/CameraController.ts',import.meta.url));
for(const [width,height] of [[375,812],[844,390],[1440,900]])for(const aspect of [.56,1,1.77]) {
const fit=fitWindowScale(width/height,aspect)*houseViewScale('wood'),w=fit*aspect*.8,h=fit*.8;asset.resize(w,h,.6);root.updateMatrixWorld(true);
// Fit is an18% global composition margin, never a crop or an extra photo transform.
const camera=new THREE.PerspectiveCamera(40,width/height,.1,100);camera.position.z=3;camera.updateMatrixWorld(true);
const a=new THREE.Vector3(-w/2,-h/2,.6).project(camera),b=new THREE.Vector3(w/2,h/2,.6).project(camera);
assert.ok(Math.max((b.x-a.x)*width/2,(b.y-a.y)*height/2)>=Math.min(width,height)*.5,'photo remains a substantial part of the view');
const controller=new CameraController(camera);controller.setRoomBounds(w,h,.6);
for(const x of [-.2,0,.2])for(const y of [-.2,0,.2])for(const px of [-.2,0,.2])for(const py of [-.2,0,.2]) {
controller.setView(x,y);controller.update(10,1);const origin=camera.position.clone(),target=new THREE.Vector3(px*w,py*h,.6),direction=target.clone().sub(origin),distance=direction.length();
const ray=new THREE.Raycaster(origin,direction.normalize(),0,distance-.0001);
assert.equal(ray.intersectObject(root,true).length,0,`central photo blocked at ${width}×${height}, aspect${aspect}, view${x},${y}, target${px},${py}`);
}
}
asset.dispose();
});
test('main lifecycle shares room framing, reuses current zoom and excludes room changes from photo/depth/glass optics', async () => {
const main=readFileSync(new URL('../src/main.ts',import.meta.url),'utf8');
assert.match(main,/new BlenderHouseSurround\(frameScene/);
assert.match(main,/houseSurround\.resize\(frameW,frameH,uniforms\.uGlassZ\.value,undefined,\{grid:uniforms\.uGridSize\.value,curvature:uniforms\.uCurvature\.value\}\)/);
assert.match(main,/houseSurround\.apply\(theme\)/);
assert.match(main,/houseSurround\.dispose\(\)/);
assert.match(main,/houseViewScale\(blenderFrame\.isWood\?'wood':'booth'\)/);
assert.match(main,/if\(sceneLayoutReady\)resize\(\)/);
assert.doesNotMatch(main,/houseSurround[\s\S]{0,80}(?:photoZoom\s*=|sceneZoom\.setZoom|cameraController\.reset)/);
});
test('canonical room resize preserves authored smooth normals and tangents exactly inside unchanged detail bounds', async () => {
const { prepareHouseAsset }=await load(),root=geometry(),authored=new Map();
root.traverse(o=>{if(o.isMesh)authored.set(o.name,{positions:[...o.geometry.attributes.position.array],normal:[...o.geometry.attributes.normal.array],tangent:o.geometry.attributes.tangent?[...o.geometry.attributes.tangent.array]:null});});
const asset=prepareHouseAsset(root);asset.resize(1,1.32,0);
root.traverse(o=>{if(!o.isMesh||!authored.has(o.name))return;const a=authored.get(o.name),n=o.geometry.attributes.normal,t=o.geometry.attributes.tangent;
for(let i=0;i<n.count;i++){const [x,y,z]=a.positions.slice(i*3,i*3+3);if(Math.abs(x)>1.3||y>1.15||z>1.3)continue;
for(let k=0;k<3;k++)assert.equal(n.array[i*3+k],a.normal[i*3+k],o.name+' detailed area preserves Blender normals');
if(t)for(let k=0;k<4;k++)assert.equal(t.array[i*4+k],a.tangent[i*4+k],o.name+' detailed area preserves Blender tangents');
}});
asset.dispose();
});
test('room pass seeds the real shared photo depth before opaque surroundings without owning photo resources',async()=>{
const {createHousePhotoOccluder}=await load();assert.equal(typeof createHousePhotoOccluder,'function','rear threshold must be tested against photo depth after clearDepth');
const uniforms={uDepthMap:{value:new THREE.Texture()},uDepthScale:{value:.25},uDepthBias:{value:0}};
const material=new THREE.ShaderMaterial({uniforms,vertexShader:'void main(){gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}',fragmentShader:'void main(){gl_FragColor=vec4(1.);}'});
const source=new THREE.Mesh(new THREE.PlaneGeometry(1,1,128,128),material);source.position.set(.1,-.2,.2204);source.scale.set(.8,1.4,1);source.updateMatrixWorld(true);
const depth=createHousePhotoOccluder(source);depth.sync(true);
assert.equal(depth.mesh.geometry,source.geometry);assert.equal(depth.mesh.material.uniforms,uniforms);assert.equal(depth.mesh.material.vertexShader,material.vertexShader);
assert.equal(depth.mesh.material.colorWrite,false);assert.equal(depth.mesh.material.depthWrite,true);assert.equal(depth.mesh.material.depthTest,true);assert.ok(depth.mesh.renderOrder<0);
assert.deepEqual(depth.mesh.matrix.toArray(),source.matrixWorld.toArray());assert.equal(depth.mesh.visible,true);
source.position.x=.4;source.updateMatrixWorld(true);depth.sync(false);assert.equal(depth.mesh.visible,false);assert.equal(depth.mesh.matrix.elements[12],.4);
let sourceDisposed=0,materialDisposed=0;source.geometry.addEventListener('dispose',()=>sourceDisposed++);material.addEventListener('dispose',()=>sourceDisposed++);depth.mesh.material.addEventListener('dispose',()=>materialDisposed++);
depth.dispose();depth.dispose();assert.equal(sourceDisposed,0);assert.equal(materialDisposed,1);source.geometry.dispose();material.dispose();uniforms.uDepthMap.value.dispose();
});
test('rear threshold cannot win the photo depth test at a steep downward view',async()=>{
const {prepareHouseAsset,createHousePhotoOccluder}=await load(),root=geometry(),asset=prepareHouseAsset(root);
asset.resize(.49601915524754814,.8857484915134788,.6);
const source=new THREE.Mesh(new THREE.PlaneGeometry(1,1,128,128),new THREE.ShaderMaterial({uniforms:{uDepthScale:{value:0}},vertexShader:'void main(){gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}'}));
source.position.z=.4704;source.scale.set(.522804190, .933578910, 1);source.updateMatrixWorld(true);
const target=new THREE.Vector3(0,-.448*.933578910,.4704),origin=new THREE.Vector3(0,.65,3),ray=new THREE.Raycaster(origin,target.clone().sub(origin).normalize());
const photoHit=ray.intersectObject(source)[0],rearHit=ray.intersectObject(root,true).find(hit=>hit.object.name==='HouseThreshold');
assert.ok(photoHit&&rearHit&&rearHit.distance>photoHit.distance+.3,'reproduces the old clearDepth overpaint, not an empty or front-occluded fixture');
const depth=createHousePhotoOccluder(source),pass=new THREE.Scene();pass.add(depth.mesh,root);depth.sync(true);pass.updateMatrixWorld(true);
const hits=ray.intersectObject(pass,true);assert.equal(hits[0].object,depth.mesh);
assert.ok(hits.filter(hit=>hit.object!==depth.mesh).every(hit=>hit.distance>hits[0].distance),'seeded LESS_EQUAL photo depth rejects every rear fragment on this ray');
depth.dispose();asset.dispose();source.geometry.dispose();source.material.dispose();
});
test('refined room keeps portable contact shading in all authored meshes through resize',async()=>{
const {prepareHouseAsset,HOUSE_ASSET_URL}=await load(),root=geometry(),colors=new Map();
assert.ok(HOUSE_ASSET_URL.endsWith('?v=2'),'refined asset must replace the cached v1 transfer');
root.traverse(mesh=>{if(!mesh.isMesh)return;const attribute=mesh.geometry.attributes.color;assert.ok(attribute,'every room role carries portable authored contact shading');colors.set(mesh.name,[...attribute.array]);});
const asset=prepareHouseAsset(root);asset.resize(.72,1.4,.6);asset.resize(1.2,.6,.6);
root.traverse(mesh=>{if(mesh.isMesh&&colors.has(mesh.name))assert.deepEqual([...mesh.geometry.attributes.color.array],colors.get(mesh.name),'layout must not discard or regenerate authored occlusion');});asset.dispose();
});
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { loadTypescript } from './support/load-typescript.mjs';
const { initImagePaste } = await loadTypescript(new URL('../src/input/ImagePaste.ts', import.meta.url));
function harness() {
const handlers = new Map(), accepted=[]; let enabled=true;
const root={activeElement:null,addEventListener(type,cb,{signal}){handlers.set(type,cb);signal.addEventListener('abort',()=>handlers.delete(type));}};
const abort=new AbortController(); initImagePaste(root,abort.signal,()=>enabled,file=>{accepted.push(file);return true;});
const paste=(items=[],extra={})=>{let prevented=false; const event={defaultPrevented:false,target:null,clipboardData:{items,files:[]},preventDefault(){prevented=true;},...extra}; handlers.get('paste')?.(event);return prevented;};
return {root,abort,accepted,paste,disable(){enabled=false;}};
}
const photo={name:'paste.png',type:'image/png',size:1024};
const item=(file=photo)=>({kind:'file',type:file.type,getAsFile:()=>file});
test('explicit image paste accepts first image once and consumes only that paste',()=>{const h=harness();assert.equal(h.paste([item(),item()]),true);assert.deepEqual(h.accepted,[photo]);});
test('text, missing clipboard and invalid image items remain untouched',()=>{const h=harness();assert.equal(h.paste([{kind:'string',type:'text/plain'}]),false);assert.equal(h.paste([], {clipboardData:null}),false);assert.equal(h.paste([{kind:'file',type:'image/png',getAsFile:()=>null}]),false);assert.equal(h.accepted.length,0);});
test('editable fields and contenteditable keep native paste',()=>{for(const target of [{isContentEditable:true},{closest:()=>({})}]){const h=harness();assert.equal(h.paste([item()],{target}),false);assert.equal(h.accepted.length,0);} const h=harness();h.root.activeElement={closest:()=>({})};assert.equal(h.paste([item()]),false);});
test('blocked mode, previously handled event and teardown do not read images',()=>{const h=harness();h.disable();assert.equal(h.paste([item()]),false);const other=harness();assert.equal(other.paste([item()],{defaultPrevented:true}),false);other.abort.abort();assert.equal(other.paste([item()]),false);assert.equal(other.accepted.length,0);});
test('files-only clipboard fallback accepts image but not nonimages',()=>{const h=harness();assert.equal(h.paste([],{clipboardData:{files:[{type:'text/plain'},photo]}}),true);assert.deepEqual(h.accepted,[photo]);});
test('open dialogs and editable composed paths leave paste untouched',()=>{const h=harness();h.root.querySelector=()=>({open:true});assert.equal(h.paste([item()]),false);h.root.querySelector=()=>null;assert.equal(h.paste([item()],{composedPath:()=>[{isContentEditable:true}]}),false);assert.equal(h.accepted.length,0);});
test('JPEG after text and unusable image is accepted without reading HTML',()=>{const h=harness(),jpg={name:'dog.jpg',type:'image/jpeg',size:20};assert.equal(h.paste([{kind:'string',type:'text/html',getAsString(){throw Error('not allowed');}},{kind:'file',type:'image/png',getAsFile:()=>null},item(jpg)]),true);assert.deepEqual(h.accepted,[jpg]);});
test('rejected image does not consume native paste',()=>{let callback;const abort=new AbortController();const root={activeElement:null,addEventListener(_,fn){callback=fn;}};initImagePaste(root,abort.signal,()=>true,()=>false);let prevented=false;callback({target:null,clipboardData:{items:[item()]},preventDefault(){prevented=true;}});assert.equal(prevented,false);});
test('keyboard shortcut hint is part of the desktop-only manual notices', async()=>{
const {readFile}=await import('node:fs/promises');const source=await readFile(new URL('../src/ui/EntryNotices.ts',import.meta.url),'utf8');
assert.match(source,/\(hover: hover\) and \(pointer: fine\)/);
assert.match(source,/desktop\?\.matches \? 3 : 2/);
});
+24
View File
@@ -0,0 +1,24 @@
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';
const source = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
test('public failure guidance keeps technical details only in hidden diagnostics', () => {
const status = {}, detail = {};
const code = source.match(/function setStatus\([^]*?\n\n/)[0];
const context = { status, experience: { setDetail(message) { detail.textContent = message; } } };
vm.createContext(context);
vm.runInContext(ts.transpileModule(code, { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText, context);
vm.runInContext("setStatus('WebGPU model failure', '사진을 준비하지 못했어요. 다시 시도해 주세요.')", context);
assert.equal(status.textContent, 'WebGPU model failure');
assert.equal(detail.textContent, '사진을 준비하지 못했어요. 다시 시도해 주세요.');
vm.runInContext("setStatus('내비게이션으로 길을 찾는 중…')", context);
assert.equal(detail.textContent, status.textContent);
});
test('automatic journey starts in everyday language and failure has actionable public guidance', () => {
const generate = source.slice(source.indexOf('async function generateAIDepth'), source.indexOf("aiDepthButton.addEventListener"));
assert.ok(generate.includes("setStatus('출발 준비 중이에요. 처음에는 조금 더 걸릴 수 있어요.');"));
assert.match(generate, /setStatus\(`사진 분석 실패: \$\{detail\}[^]*?'사진을 준비하지 못했어요\. 다시 시도하거나 다른 사진을 골라 주세요\. 평면 사진으로 볼 수도 있어요\.'\)/);
assert.ok(generate.includes("experience.setState('error', true)"));
});
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import ts from 'typescript';
import vm from 'node:vm';
import * as THREE from 'three';
const source=await readFile(new URL('../src/input/MediaLoader.ts',import.meta.url),'utf8');
function harness(text){
const statuses=[];let resolvePlay;let rejectPlay;
const video={videoWidth:400,videoHeight:500,paused:false,playCalls:0,pause(){this.paused=true;},load(){},removeAttribute(){},play(){this.playCalls++;return new Promise((resolve,reject)=>{resolvePlay=resolve;rejectPlay=reject;});}};
const exports={};vm.runInNewContext(ts.transpileModule(text,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText,{exports,require:()=>THREE,document:{createElement:()=>video},URL:{createObjectURL:()=> 'blob:video',revokeObjectURL(){}}});
const loader=new exports.MediaLoader(()=>{},message=>statuses.push(message));
return {loader,video,statuses,resolve:()=>resolvePlay(),reject:()=>rejectPlay(new Error('blocked'))};
}
const tick=async()=>{for(let i=0;i<6;i++)await Promise.resolve();};
async function testSuccess(text){const h=harness(text);h.loader.load({type:'video/mp4'});h.video.onloadeddata();h.reject();await tick();assert.match(h.statuses.at(-1),/자동 재생/);h.loader.retry();h.resolve();await tick();const result=h.statuses.at(-1);h.loader.dispose();return result;}
const prior=source.replace(".then(() => { if (token === this.token && video === this.activeVideo) this.onStatus(''); })",'');
assert.notEqual(await testSuccess(prior),'','mutation without retry success status reproduces stuck error');
assert.equal(await testSuccess(source),'','actual successful retry clears playback error');
const stale=harness(source);stale.loader.load({type:'video/mp4'});stale.video.onloadeddata();stale.reject();await tick();stale.loader.retry();stale.loader.cancelPending();stale.resolve();await tick();assert.match(stale.statuses.at(-1),/自|자동/,'stale retry may not announce ready for replaced media');stale.loader.dispose();
console.log('Media retry: successful playback emits recovery, blocked play and stale retry preserve error (actual MediaLoader, DOM/play promises doubled)');
const recovered=harness(source);recovered.loader.load({type:'video/mp4'});recovered.video.onloadeddata();recovered.reject();await tick();
recovered.loader.cancelPending(true);recovered.loader.retry();
assert.equal(recovered.video.playCalls,2,'context recovery preserves replay of the active blocked video');
recovered.resolve();await tick();assert.equal(recovered.statuses.at(-1),'');recovered.loader.dispose();
+14
View File
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {readFile} from 'node:fs/promises';
import ts from 'typescript';
import vm from 'node:vm';
import * as THREE from 'three';
const source=await readFile(new URL('../src/input/MediaLoader.ts',import.meta.url),'utf8');
const tick=async()=>{for(let i=0;i<12;i++)await Promise.resolve();};
function harness(onReady=()=>{}){const pending=[],photos=[],ready=[],statuses=[],rawURLs=[];const exports={};class RawImage{width=8000;height=6000;set src(_v){queueMicrotask(()=>this.onload?.());}}vm.runInNewContext(ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText,{exports,AbortController,Image:RawImage,queueMicrotask,Error,URL:{createObjectURL(file){rawURLs.push(file);return'blob:original';},revokeObjectURL(){}},require:name=>name==='three'?THREE:{createWorkingPhotoImage(_file,id,signal){return new Promise((resolve,reject)=>pending.push({id,signal,resolve,reject}));}}});const loader=new exports.MediaLoader((texture,aspect)=>{ready.push({texture,aspect});onReady(texture,aspect);},m=>statuses.push(m));return{loader,pending,photos,ready,statuses,rawURLs,resolve(index,width=1024,height=768){const photo={image:{width,height},url:'blob:working'+index,disposed:0,dispose(){this.disposed++;}};photos.push(photo);pending[index].resolve(photo);return photo;}};}
test('legacy uploads use only bounded working pixels for the actual Three texture',async()=>{const h=harness();h.loader.load({type:'image/jpeg'});await tick();assert.equal(h.pending.length,1);const p=h.resolve(0);await tick();assert.equal(h.ready[0].texture.image,p.image);assert.equal(h.ready[0].aspect,4/3);assert.equal(h.rawURLs.length,0);h.loader.dispose();assert.equal(p.disposed,1);});
test('replacement/cancel closes stale images without overwriting newer photo or status',async()=>{const h=harness();h.loader.load({type:'image/png'});assert.equal(h.pending.length,1);h.loader.load({type:'image/webp'});assert.equal(h.pending[0].signal.aborted,true);const second=h.resolve(1);await tick();const first=h.resolve(0);await tick();assert.equal(h.ready.length,1);assert.equal(h.ready[0].texture.image,second.image);assert.equal(first.disposed,1);h.loader.load({type:'image/jpeg'});h.loader.cancelPending();const cancelled=h.resolve(2);await tick();assert.equal(cancelled.disposed,1);assert.equal(h.ready.length,1);h.loader.dispose();assert.equal(second.disposed,1);});
test('sequential successful photos release every old decoded image and preview URL',async()=>{const h=harness();for(let i=0;i<8;i++){h.loader.load({type:'image/png'});assert.equal(h.pending.length,i+1);h.resolve(i);await tick();assert.equal(h.photos.filter(p=>!p.disposed).length,1);}h.loader.dispose();assert.ok(h.photos.every(p=>p.disposed===1));});
test('old DOM photo stays readable throughout replacement callback, then releases',async()=>{let previous;const h=harness(()=>{if(previous)assert.equal(previous.disposed,0,'cached depth diagnostic may still draw old image until its setDepth runs');});h.loader.load({type:'image/png'});previous=h.resolve(0);await tick();h.loader.load({type:'image/jpeg'});h.resolve(1);await tick();assert.equal(h.statuses.at(-1),'');assert.equal(previous.disposed,1);h.loader.dispose();});
+71
View File
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import * as THREE from 'three';
import {loadTypescript} from './support/load-typescript.mjs';
const {attachMeshRefraction}=await loadTypescript(new URL('../src/rendering/MeshRefraction.ts',import.meta.url));
const {WindowReveal}=await loadTypescript(new URL('../src/objects/WindowReveal.ts',import.meta.url));
function fixture(){
const rgb=new THREE.Texture(),depth=new THREE.DataTexture(new Uint8Array([128,128,128,255]),1,1);
const photo=new THREE.Mesh(new THREE.PlaneGeometry(1,1,128,128),new THREE.ShaderMaterial({uniforms:{uTexture:{value:rgb},uDepthMap:{value:depth},uDepthScale:{value:.25},uDepthBias:{value:0},uIsVideo:{value:false}}}));
photo.position.set(.1,-.2,.2204);photo.scale.set(.6,.8,1);
const material=new THREE.MeshPhysicalMaterial({transmission:1,ior:1.5});
return{photo,material,controller:attachMeshRefraction(material,photo),rgb,depth};
}
test('B extends only its material and traces actual mesh entry to photo triangles',()=>{
const f=fixture();f.controller.sync({width:.66,height:.82,grid:20,curvature:1},.5892,new THREE.Color(.2,.3,.4));
const shader={vertexShader:THREE.ShaderLib.physical.vertexShader,fragmentShader:THREE.ShaderLib.physical.fragmentShader,uniforms:{}};
const before=THREE.ShaderChunk.transmission_fragment;
f.material.onBeforeCompile(shader,{});
assert.equal(shader.vertexShader,THREE.ShaderLib.physical.vertexShader,'real physical mesh vertex path is preserved');
assert.match(shader.fragmentShader,/bTracePhotoRay\(\s*vWorldPosition/);
assert.match(shader.fragmentShader,/bExitGlass/);assert.match(shader.fragmentShader,/bPhotoHit/);
assert.match(shader.fragmentShader,/textureLod\(\s*uBDepthMap/);
assert.match(shader.fragmentShader,/textureLod\(\s*uBPhoto/);
assert.doesNotMatch(shader.fragmentShader,/#include <transmission_fragment>/);
assert.equal(THREE.ShaderChunk.transmission_fragment,before,'never patches global Three or A');
assert.equal(shader.uniforms.uBPhoto.value,f.rgb);assert.equal(shader.uniforms.uBDepthMap.value,f.depth);
assert.equal(shader.uniforms.uBBackZ.value,.5892);
assert.equal(shader.uniforms.uBDepthScale.value,.25);
assert.equal(shader.uniforms.uBPhotoSegments.value,128);
const inverse=shader.uniforms.uBPhotoWorldToLocal.value.clone().multiply(f.photo.matrixWorld);
inverse.elements.forEach((v,i)=>assert.ok(Math.abs(v-(i%5===0?1:0))<1e-10));
assert.match(f.material.customProgramCacheKey(),/mesh-exit-photo/);
});
test('photo, depth and matrices refresh even when glass shape is unchanged',()=>{
const f=fixture(),shape={width:1,height:1,grid:20,curvature:1};
f.controller.sync(shape,.58,new THREE.Color());
const next=new THREE.Texture();f.photo.material.uniforms.uDepthMap.value=next;
f.photo.material.uniforms.uDepthScale.value=.4;f.photo.material.uniforms.uDepthBias.value=.2;f.photo.material.uniforms.uIsVideo.value=true;
f.photo.position.set(-.2,.4,.17);f.photo.scale.set(1.8,2.4,1);
f.controller.sync(shape,.58,new THREE.Color(.5,.4,.3));
const u=f.controller.uniforms;
assert.equal(u.uBDepthMap.value,next);assert.equal(u.uBDepthScale.value,.4);assert.equal(u.uBDepthBias.value,.2);assert.equal(u.uBIsVideo.value,true);
u.uBPhotoWorldToLocal.value.clone().invert().elements.forEach((v,i)=>assert.ok(Math.abs(v-f.photo.matrixWorld.elements[i])<1e-10));
assert.equal(u.uBBackground.value.r,.5);
});
test('refracted rays intersect the exact fixed reveal before choosing photo or room background',async()=>{
const f=fixture(),map=new THREE.Texture(),material=new THREE.MeshStandardMaterial({map,color:0xbca18b,side:THREE.DoubleSide});
const reveal=new WindowReveal(material);reveal.update(.928,1.16,1.45*.8*(3-.2204)/3,1.45*(3-.2204)/3,.6,.2204);
const scene=new THREE.Scene();scene.add(...reveal.meshes,new THREE.AmbientLight(0xfff0d9,1.35));
f.controller.syncReveal(reveal.meshes,scene);
const shader={uniforms:{},fragmentShader:THREE.ShaderLib.physical.fragmentShader};f.material.onBeforeCompile(shader,{});
assert.equal(shader.uniforms.uBRevealEnabled.value,true);
assert.equal(shader.uniforms.uBRevealMapVertical.value,map);
assert.equal(shader.uniforms.uBRevealVertices.value.length,16);
for(let wall=0;wall<4;wall++)for(let i=0;i<4;i++){
const expected=new THREE.Vector3().fromBufferAttribute(reveal.meshes[wall].geometry.attributes.position,i).applyMatrix4(reveal.meshes[wall].matrixWorld);
assert.ok(shader.uniforms.uBRevealVertices.value[wall*4+i].distanceTo(expected)<1e-10);
}
assert.match(shader.fragmentShader,/bRevealHit\(exitPoint,outgoing/);
assert.match(shader.fragmentShader,/backgroundDistance=revealHit\?revealDistance:1e20/,'reveal initializes the background hit distance');
assert.match(shader.fragmentShader,/yardDistance<backgroundDistance/,'only a nearer courtyard hit replaces the reveal');
assert.match(shader.fragmentShader,/backgroundDistance<photoDistance/,'the nearest background and photo retain depth ordering');
assert.match(shader.fragmentShader,/abs\(det\)/,'reveals are DoubleSide');
// Exact screenshot-regression ray: misses finite photo, but intersects the top reveal.
const origin=new THREE.Vector3(.008207626,.491639562,.588056),ray=new THREE.Vector3(.032347439,.452479898,-.891187738).normalize();
const hits=new THREE.Raycaster(origin,ray).intersectObjects(reveal.meshes,false);
assert.ok(hits.length);assert.equal(hits[0].object,reveal.meshes[2]);
assert.ok(Math.abs(hits[0].distance-.378402377)<1e-5);
reveal.dispose();material.dispose();map.dispose();
});
+222
View File
@@ -0,0 +1,222 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { readFile } from 'node:fs/promises';
import * as THREE from 'three';
import ts from 'typescript';
import { PHOTO_SEGMENTS,MAX_DDA_CELLS,v3,makeDepthTexture,depthAt,photoSurface,tracePhotoDDA,triangleOracle,snell,exitGlass,traceGlass,stockR169PhotoHit,meshEntry } from './support/mesh-refraction-reference.mjs';
async function loadTS(relative) {
async function compile(file) {
let source=await readFile(file,'utf8');
source=source.replace(/from ['"]three['"]/g,`from '${new URL('../node_modules/three/build/three.module.js',import.meta.url)}'`);
for(const m of [...source.matchAll(/from ['"](\.[^'"]+)['"]/g)])source=source.replace(m[0],`from '${await compile(new URL(m[1].endsWith('.ts')?m[1]:m[1]+'.ts',file))}'`);
return `data:text/javascript;base64,${Buffer.from(ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.ES2020,target:ts.ScriptTarget.ES2020}}).outputText).toString('base64')}`;
}
return import(await compile(new URL(relative,import.meta.url)));
}
const {createPatternedGlassGeometry}=await loadTS('../src/rendering/PatternedGlassGeometry.ts');
const {SceneZoomController}=await loadTS('../src/input/SceneZoomController.ts');
const {DepthProcessor}=await loadTS('../src/depth/DepthProcessor.ts');
const near=(a,b,epsilon=2e-7,label='numbers agree')=>assert.ok(Math.abs(a-b)<=epsilon,`${label}: ${a} vs ${b}`);
const vectorNear=(a,b,epsilon=2e-7,label='points agree')=>assert.ok(a.distanceTo(b)<=epsilon,`${label}: distance ${a.distanceTo(b)}`);
const flatTexture=makeDepthTexture(2,2,()=>1);
const texture=makeDepthTexture(19,13,(x,y)=>.5+.22*Math.sin(x*1.8)+.25*Math.cos(y*1.23+x*.4));
const flat=(z,options={})=>photoSurface(flatTexture,{depthScale:0,matrix:new THREE.Matrix4().makeTranslation(0,0,z),...options});
const shape={width:1,height:1.32,grid:20,curvature:1};
const slab={width:1,height:1.32,backZ:-.01225};
function compare(surface,o,d,label='DDA vs independent triangle oracle') {
const {hit,cells}=tracePhotoDDA(surface,o,d),oracle=triangleOracle(surface,o,d);
assert.ok(cells<=MAX_DDA_CELLS,'bounded crossed cells');
assert.equal(Boolean(hit),Boolean(oracle),label);
if(hit) {vectorNear(hit.point,oracle.point,3e-7,label);near(hit.t,oracle.t,4e-7,label);}
return {hit,cells};
}
// Regressions are deliberately independent of the production fragment math. The
// separate test-mesh-refraction-material.mjs checks that the shader is attached.
test('depth samples 8-bit normalized linear texels at LOD zero before vertex displacement',()=>{
const t=makeDepthTexture(2,2,(x,y)=>(x+2*y)/3);
near(depthAt(t,0,0),0);near(depthAt(t,1,1),1);near(depthAt(t,.5,.5),.5);
near(depthAt(t,.25,.25),0);near(depthAt(t,.75,.25),1/3);
const s=photoSurface(t,{depthScale:.25,depthBias:.2});
assert.equal(s.heights.length,129*129);near(s.heights[0],-.05);near(s.heights.at(-1),.2);
assert.equal(s.texture.type,THREE.UnsignedByteType);
const actual=DepthProcessor.fromNormalizedDepth(new Uint8Array([0,85,170,255]),2,2);
assert.equal(actual.flipY,true);near(depthAt(actual,.25,.75),0);near(depthAt(actual,.75,.25),1);
compare(photoSurface(actual),v3(.1,.2,1),v3(0,0,-1),'production normalized texture');
});
test('photo follows actual lower-left to upper-right mesh triangles, not continuous bilinear depth',()=>{
const t=makeDepthTexture(2,2,(x,y)=>x===1&&y===1?1:0),s=photoSurface(t,{segments:1,depthScale:1});
const {hit}=compare(s,v3(-.25,0,2),v3(0,0,-1));
near(hit.local.z,.25);assert.ok(Math.abs(hit.local.z-depthAt(t,.25,.5))>.1);
const indices=s.geometry.index.array;assert.deepEqual(Array.from(indices),[0,2,1,2,3,1]);
});
test('128×128 DDA agrees with independent triangle oracle for deterministic rays and high-frequency depth',()=>{
const s=photoSurface(texture);let seed=0x5eed,cases=0;
const random=()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/2**32;};
for(let i=0;i<72;i++) {
const o=v3((random()-.5)*1.8,(random()-.5)*1.8,.4+random()),target=v3((random()-.5)*1.2,(random()-.5)*1.2,random()*.2);
compare(s,o,target.sub(o).normalize(),`random ray ${i}`);cases++;
}
assert.equal(cases,72);assert.equal(s.segments,PHOTO_SEGMENTS);
});
test('DDA handles zero ray XY, exact diagonal, cell corners, reverse directions and outside misses',()=>{
const s=photoSurface(texture);
for(const [o,d] of [
[v3(0,0,1),v3(0,0,-1)],[v3(.5,.5,1),v3(0,0,-1)],
[v3(-1,-1,1),v3(1,1,-1)],[v3(1,1,1),v3(-1,-1,-1)],
[v3(0,.25,1),v3(0,-.3,-1)],[v3(.5,0,1),v3(-.3,0,-1)],
[v3(.6,0,1),v3(0,0,-1)],[v3(0,0,1),v3(0,0,1)],
[v3(-1,0,.1),v3(1,0,0)],
])compare(s,o,d.clone().normalize());
});
test('photo FrontSide rejects back-facing crossings while preserving the next front-facing triangle',()=>{
const s=photoSurface(texture);
const fromBelow=compare(flat(0),v3(0,0,-1),v3(0,0,1));
assert.equal(fromBelow.hit,null);
// These rays can cross a displaced heightfield more than once; the oracle
// independently chooses the nearest intersection of an actual front face.
for(const direction of [v3(1,0,-.03),v3(-1,0,-.03),v3(0,1,-.03),v3(0,-1,-.03)]) {
const origin=direction.clone().multiplyScalar(-.6);origin.z=.13;
compare(s,origin,direction.normalize());
}
});
test('DDA crosses the whole maximal grid within its fixed 257-cell loop bound',()=>{
const s=photoSurface(makeDepthTexture(2,2,(x,y)=>x*y));
// Almost horizontal rays exercise many XY cells before finding the first hit.
let most=0;
for(let i=0;i<12;i++) {
const {cells}=compare(s,v3(-.6,-.45+i*.02,.249),v3(1,.85,-.0001).normalize());most=Math.max(most,cells);
}
assert.ok(most>128,`long traversal covered ${most} cells`);
assert.ok(most<=257);
assert.throws(()=>photoSurface(texture,{segments:129}),/1\.\.128/);
});
test('photo object pan, nonuniform scale, rotation and depth bias transform correctly',()=>{
const matrix=new THREE.Matrix4().compose(v3(.18,-.23,-.3),new THREE.Quaternion().setFromEuler(new THREE.Euler(.16,-.22,.11)),v3(1.8,.76,1.35));
const s=photoSurface(texture,{matrix,depthScale:.25,depthBias:.2});
for(let i=0;i<16;i++) {
const localTarget=v3(-.4+i*.05,Math.sin(i)*.3,.1),worldTarget=localTarget.applyMatrix4(matrix),o=v3(.2,-.1,1.2);
compare(s,o,worldTarget.sub(o).normalize(),`transformed photo ${i}`);
}
});
test('Snell handles air→glass and glass→air independently, including total internal reflection',()=>{
const i=v3(Math.sin(.6),0,-Math.cos(.6)),inside=snell(i,v3(0,0,1),1/1.5);
near(inside.x,Math.sin(.6)/1.5,1e-12);vectorNear(snell(inside,v3(0,0,1),1.5),i,1e-12);
assert.equal(snell(v3(.9,0,-Math.sqrt(1-.81)),v3(0,0,1),1.5),null);
vectorNear(snell(i,v3(.3,0,1),1),i,1e-12);
});
test('flat normal straight-on is undistorted, and oblique slab yields analytic lateral shift',()=>{
const s=flat(-.3),entry=v3(.08,-.1,0),normal=v3(0,0,1);
const straight=traceGlass({entry,normal,incident:v3(0,0,-1),shape:slab,surface:s});
vectorNear(straight.hit.point,v3(.08,-.1,-.3),1e-12);
const theta=.5,incident=v3(Math.sin(theta),0,-Math.cos(theta)),result=traceGlass({entry,normal,incident,shape:slab,surface:s});
assert.equal(result.exit.face,'rear');vectorNear(result.outgoing,incident,1e-12);
const insideAngle=Math.asin(Math.sin(theta)/1.5),expectedX=entry.x+(-slab.backZ)*Math.tan(insideAngle)+(.3+slab.backZ)*Math.tan(theta);
near(result.hit.point.x,expectedX,1e-12);
const noGlass=entry.x+.3*Math.tan(theta);
near(result.hit.point.x-noGlass,(-slab.backZ)*(Math.tan(insideAngle)-Math.tan(theta)),1e-12);
});
test('finite side exits use their own face normal, including side total internal reflection',()=>{
const exit=exitGlass(v3(.49,0,0),v3(.9,0,-.435889894).normalize(),slab);
assert.equal(exit.face,'x+');near(exit.point.x,.5,1e-12);assert.ok(exit.point.z>slab.backZ);
const outgoing=snell(v3(.9,0,-.435889894).normalize(),exit.normal.clone().negate(),1.5);
assert.ok(outgoing);assert.ok(outgoing.x>0);
const tir=traceGlass({entry:v3(.4999,0,0),normal:v3(0,0,1),incident:v3(.4,0,-1).normalize(),shape:slab,surface:flat(-.3)});
assert.equal(tir.exit.face,'x+');assert.equal(tir.status,'tir');
const rear=exitGlass(v3(.1,0,0),v3(0,0,-1),slab);assert.equal(rear.face,'rear');near(rear.point.z,slab.backZ);
for(const [entry,ray,face,normal] of [
[v3(-.49,0,0),v3(-.9,0,-.4359),'x-',v3(-1,0,0)],
[v3(0,.65,0),v3(0,.9,-.4359),'y+',v3(0,1,0)],
[v3(0,-.65,0),v3(0,-.9,-.4359),'y-',v3(0,-1,0)],
]) {
const side=exitGlass(entry,ray.normalize(),slab);assert.equal(side.face,face);vectorNear(side.normal,normal,1e-12);
const leaving=snell(ray,normal.clone().negate(),1.5);assert.ok(leaving);assert.ok(leaving.dot(normal)>0);
}
});
test('IOR 1 stays on the unbent incident ray through actual patterned mesh at every gap',()=>{
const built=createPatternedGlassGeometry(shape),camera=v3(.2,-.1,3);
const entry=meshEntry(built.geometry,camera,v3(.123,.181,0));assert.ok(entry);
for(const gap of [.04,.1296,.1576]) {
const s=photoSurface(texture,{matrix:new THREE.Matrix4().makeTranslation(0,0,-gap-.25)});
const glass=traceGlass({...entry,shape:{...shape,backZ:built.backZ},surface:s,ior:1}),direct=tracePhotoDDA(s,camera,entry.incident);
assert.equal(glass.status,'hit');vectorNear(glass.hit.point,direct.hit.point,1e-10);
}
built.geometry.dispose();
});
test('real interpolated mesh normals produce opposite signed convex/concave offsets and visible default-gap refraction beyond stock r169',()=>{
const camera=v3(0,0,3),results=[];
for(const curvature of [1,-1]) {
const built=createPatternedGlassGeometry({...shape,curvature}),entry=meshEntry(built.geometry,camera,v3(.011,.014,0));
assert.ok(entry);near(entry.bary.x+entry.bary.y+entry.bary.z,1,1e-12);
assert.ok(Math.min(entry.bary.x,entry.bary.y,entry.bary.z)>0,'point lies inside actual triangle, not an analytic normal sample');
const offsets=[];
for(const gap of [.04,.1296,.1576]) {
const surface=flat(-gap),args={...entry,shape:{...shape,backZ:built.backZ},surface};
const result=traceGlass(args),direct=tracePhotoDDA(surface,camera,entry.incident).hit;
const stock=stockR169PhotoHit({...args,camera,thickness:entry.entry.z-built.backZ});
assert.equal(result.status,'hit');assert.ok(stock);
const shift=result.hit.point.clone().sub(direct.point),stockShift=stock.point.clone().sub(direct.point);
assert.ok(shift.length()>stockShift.length()*2,`gap ${gap}: actual ${shift.length()} vs stock ${stockShift.length()}`);
assert.ok(shift.length()>.001,`visible scene-unit displacement at gap ${gap}`);
offsets.push(shift);
compare(surface,result.exit.point,result.outgoing,'complete ray vs photo triangle oracle');
if(gap===.1296)results.push({curvature,shift:shift.toArray(),stockShift:stockShift.toArray(),ratio:shift.length()/stockShift.length()});
}
assert.ok(offsets[2].length()>offsets[1].length()&&offsets[1].length()>offsets[0].length(),'gap scales the outgoing path');
built.geometry.dispose();
}
assert.ok(v3(...results[0].shift).dot(v3(...results[1].shift))<0,'curvature flips offset direction');
console.log('Default-gap actual mesh offsets vs stock r169:',JSON.stringify(results));
});
test('depth .25 is intersected as geometry at all supported gap extrema and default',()=>{
const built=createPatternedGlassGeometry(shape),camera=v3(.15,-.08,3);
for(const gap of [.04,.1296,.1576]) {
const s=photoSurface(texture,{depthScale:.25,matrix:new THREE.Matrix4().makeTranslation(0,0,-gap-.25)});
for(const target of [v3(.011,.014,0),v3(-.173,.217,0),v3(.23,-.13,0)]) {
const e=meshEntry(built.geometry,camera,target),r=traceGlass({...e,shape:{...shape,backZ:built.backZ},surface:s});
assert.equal(r.status,'hit');compare(s,r.exit.point,r.outgoing);
assert.ok(r.hit.point.z<=-gap+1e-10);
}
}
built.geometry.dispose();
});
test('camera anchored zoom preserves the world ray and mesh/photo hit at the anchor',()=>{
const camera=new THREE.PerspectiveCamera(35,1.4,.01,100);camera.position.set(.1,-.05,3);camera.lookAt(0,0,0);camera.updateMatrixWorld();
const anchor=new THREE.Vector2(.33,-.24),raycaster=new THREE.Raycaster();raycaster.setFromCamera(anchor,camera);
const original=raycaster.ray.clone(),zoom=new SceneZoomController(camera);zoom.setZoom(2.3,anchor);raycaster.setFromCamera(anchor,camera);
vectorNear(raycaster.ray.direction,original.direction,1e-12);
const built=createPatternedGlassGeometry(shape),s=photoSurface(texture,{matrix:new THREE.Matrix4().makeTranslation(0,0,-.42)});
const e1=meshEntry(built.geometry,camera.position,original.at(2,v3())),e2=meshEntry(built.geometry,camera.position,raycaster.ray.at(2,v3()));
assert.ok(e1&&e2);vectorNear(e1.entry,e2.entry,1e-12);
const r1=traceGlass({...e1,shape:{...shape,backZ:built.backZ},surface:s}),r2=traceGlass({...e2,shape:{...shape,backZ:built.backZ},surface:s});
vectorNear(r1.hit.point,r2.hit.point,1e-12);built.geometry.dispose();
});
test('shader source contract (not execution): mesh triangle traversal and both Snell transitions stay connected',async()=>{
const {meshRefractionShader:shader}=await loadTS('../src/rendering/MeshRefraction.ts');
assert.match(shader,/refract\(\s*-viewDirection\s*,\s*normalize\(normal\)\s*,\s*1\.\s*\/\s*ior\s*\)/,'air to glass uses eta=1/ior');
assert.match(shader,/refract\(\s*internalRay\s*,\s*inwardNormal\s*,\s*ior\s*\)/,'glass to air uses eta=ior');
assert.match(shader,/if\(det\s*<=\s*1e-9\)return/,'photo triangle winding culls its back face');
assert.match(shader,/iteration\s*<\s*257/,'bounded grid traversal');
assert.match(shader,/bTriangle\(origin,ray,ul,ll,ur\)/,'upper-left triangle shares lower-left to upper-right diagonal');
assert.match(shader,/bTriangle\(origin,ray,ll,lr,ur\)/,'lower-right triangle shares the same diagonal');
assert.match(shader,/textureLod\(\s*uBDepthMap\s*,\s*uv\s*,\s*0\.\s*\)/,'same base LOD as vertex depth displacement');
assert.match(shader,/uBPhotoWorldToLocal\s*\*\s*vec4\(worldRay,0\.\)/,'direction transforms without translation');
assert.match(shader,/uBPhotoWorldToLocal\s*\*\s*vec4\(worldOrigin,1\.\)/,'origin transforms with translation');
assert.match(shader,/dot\(outgoing,outgoing\)\s*<\s*1e-8/,'TIR never becomes a fabricated photo intersection');
});
+38
View File
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {readFile} from 'node:fs/promises';
import {loadTypescript} from './support/load-typescript.mjs';
test('view controls are in settings and scene contains only a text tilt button',async()=>{
const html=await readFile(new URL('../index.html',import.meta.url),'utf8');
const settings=html.slice(html.indexOf('<dialog id="settings-dialog"'),html.indexOf('<!-- Internal control'));
assert.match(settings,/id="tab-view"[^>]*data-tool="view"/);
assert.match(settings,/id="panel-view"[\s\S]*id="camera-offset"/);
assert.doesNotMatch(html,/id="motion-tools"|◫/);
const header=html.slice(html.indexOf('<header id="scene-toolbar"'),html.indexOf('</header>'));
assert.match(header,/id="scene-motion"/);
assert.match(header,/id="gyro-label">기울기 연결/);
});
test('mobile eligibility does not mistake a narrow or touch-enabled desktop for a phone',async()=>{
const {isMobileDevice}=await loadTypescript(new URL('../src/ui/MobileDevice.ts',import.meta.url));
for(const nav of [{userAgent:'Mozilla Android'},{userAgent:'Mozilla iPhone'},{userAgent:'Mozilla iPad'},{userAgent:'Macintosh',maxTouchPoints:5},{userAgent:'Unknown',userAgentData:{mobile:true}}])assert.equal(isMobileDevice(nav),true);
for(const nav of [undefined,{}, {userAgent:'Windows',maxTouchPoints:10},{userAgent:'Macintosh',maxTouchPoints:0},{userAgent:'Linux x86_64',maxTouchPoints:1}])assert.equal(isMobileDevice(nav),false);
});
test('tilt matches transparent underlined door styling and header rows use content height',async()=>{
const css=await readFile(new URL('../src/style.css',import.meta.url),'utf8');
assert.match(css,/#gyro,#gyro\[aria-pressed=true\],#gyro\[aria-busy=true\]\s*\{[^}]*background:transparent;[^}]*border:0;[^}]*text-decoration:underline/);
assert.doesNotMatch(css,/#gyro[^}]*background:#315b43/);
assert.match(css,/\.scene-toolbar\s*\{[^}]*display:grid/);
assert.match(css,/\.scene-motion\s*\{[^}]*position:static/);
});
test('in-flow unavailable notice clears old floating transform',async()=>{
const css=await readFile(new URL('../src/style.css',import.meta.url),'utf8');
assert.match(css,/#gyro-notice\s*\{[^}]*position:static;[^}]*transform:none/);
});
test('actions remain right aligned when comparison hides the brand',async()=>{
const css=await readFile(new URL('../src/style.css',import.meta.url),'utf8');
assert.match(css,/\.scene-actions\s*\{[^}]*grid-column:2/);
});
test('tilt keyboard focus retains the contrasting door-style halo in every state',async()=>{
const css=await readFile(new URL('../src/style.css',import.meta.url),'utf8');
assert.match(css,/#gyro:focus-visible\s*\{[^}]*box-shadow:0 0 0 5px #302b25/);
});
+1 -1
View File
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
import * as THREE from 'three';
const shader = await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8');
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8') + await readFile(new URL('../src/rendering/OriginalGlassRenderer.ts',import.meta.url),'utf8');
assert.match(shader, /uniform sampler2D uSceneTexture,uSceneDepth/);
assert.match(shader, /color=sampleScene\(finalSceneUv\)/);
assert.match(shader, /regionSurfaceValid\(finalSceneUv,gh\)/);
+52
View File
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import { readFile, readdir, access } from 'node:fs/promises';
import { test } from 'node:test';
const read = path => readFile(new URL('../' + path, import.meta.url), 'utf8');
async function files(path) {
const entries = await readdir(new URL('../' + path, import.meta.url), { withFileTypes: true });
return (await Promise.all(entries.map(entry => entry.isDirectory() ? files(path + '/' + entry.name) : path + '/' + entry.name))).flat();
}
test('ordinary photo entry preserves intact-photo flow beside an explicitly selected experiment', async () => {
const html = await read('index.html'), main = await read('src/main.ts');
assert.doesNotMatch(html, /cutout|countryside|scene-mode|누끼/);
assert.match(html, /data-pick-photo/);
assert.doesNotMatch(main, /CountrysideExperience|loadLegacyFile/);
assert.match(main,/let cutoutTexture[^;]*cutoutEnabled=false/);
assert.match(main,/onAccept\(result\)/);
assert.match(main, /imageInput\.onchange[\s\S]*?loader\.load\(file\)/);
assert.match(main, /selectedRenderer: 'a' \| 'b' = 'b'/);
});
test('experimental segmentation is lazy and the original bounded preparation remains', async () => {
const sourceFiles = await files('src');
const source = (await Promise.all(sourceFiles.map(read))).join('\n');
assert.doesNotMatch(source, /CountrysideExperience|LayeredSceneData|LayeredMeshRefraction/);
const ui=await read('src/ui/PeekingExperimentUI.ts');
assert.match(ui,/import\(['"]\.\.\/cutout\/CutoutPreparation['"]\)/);
assert.match(ui,/이 누끼로 배치/);
assert.match(source, /PHOTO_WORKING_LONG_EDGE = 1024/);
assert.match(source, /createWorkingPhotoImage/);
});
test('only the explicitly restored small model ships, while private house composites stay out of public assets', async () => {
const publicFiles = await files('public');
assert.equal(publicFiles.some(path => /assets\/countryside\//.test(path)), false);
assert.ok(publicFiles.includes('public/models/u2netp/model.onnx'));
assert.equal(publicFiles.filter(path=>path.endsWith('.onnx')).length,1);
await access(new URL('../docs/reference-assets/countryside/open-house.png', import.meta.url));
const pkg = JSON.parse(await read('package.json'));
assert.equal(Object.hasOwn(pkg.dependencies, 'onnxruntime-web'), false, 'direct runtime was only the cutout worker; Depth Anything retains its transitive ORT');
assert.equal(pkg.dependencies['@huggingface/transformers'], '^4.3.0');
assert.match(await read('vite.config.ts'), /onnxruntime-web-use-extern-wasm/);
});
test('car travel is triggered by actual depth analysis rather than photo decoding or a mask stage', async () => {
const main = await read('src/main.ts');
assert.equal([...main.matchAll(/experience\.startJourney\(\)/g)].length, 1);
const fn = main.slice(main.indexOf('async function generateAIDepth'), main.indexOf("aiDepthButton.addEventListener"));
assert.match(fn, /aiDepthBusy = true;[\s\S]*experience\.startJourney\(\);[\s\S]*await aiDepthProvider\.getDepthMap\(sourceImage\)/);
assert.match(fn, /request !== depthRequest \|\| active !== sourceTexture/);
assert.match(fn, /catch[\s\S]*experience\.setState\('error', true\)/);
});
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import * as THREE from 'three';
import {loadTypescript} from './support/load-typescript.mjs';
const {peekingLayout,placePeekingDog}=await loadTypescript(new URL('../src/objects/PeekingComposition.ts',import.meta.url));
const {fitWindowScale}=await loadTypescript(new URL('../src/objects/BlenderWindowFrame.ts',import.meta.url));
const {CameraController}=await loadTypescript(new URL('../src/input/CameraController.ts',import.meta.url));
test('experimental door keeps fixed proportions and legacy clearance reference dimensions',()=>{
for(const view of[375/812,812/375,1.4])for(const photo of[.2,.5625,.8,1,1.5,5,12]){
const layout=peekingLayout(view,photo),scale=fitWindowScale(view,photo)*.82;
assert.ok(Math.abs(layout.width/layout.height-.74)<1e-12);
assert.deepEqual(layout.cameraBounds,[scale*photo*.8,scale*.8,.6]);
for(const max of[.35,.75,.9])for(const x of[-1,-.5,0,.5,1])for(const y of[-1,-.5,0,.5,1]){
const oldCamera=new THREE.PerspectiveCamera(40,view,.1,100),newCamera=oldCamera.clone();
const old=new CameraController(oldCamera,max),fresh=new CameraController(newCamera,max);
old.setRoomBounds(scale*photo*.8,scale*.8,.6);fresh.setRoomBounds(...layout.cameraBounds);
old.setView(x,y);fresh.setView(x,y);
for(const dt of[0,.016,.1,10]){old.update(dt,1);fresh.update(dt,1);assert.deepEqual(newCamera.position.toArray(),oldCamera.position.toArray());assert.deepEqual(newCamera.quaternion.toArray(),oldCamera.quaternion.toArray());assert.equal(newCamera.near,oldCamera.near);}
assert.ok(newCamera.position.y-newCamera.near> -layout.height/2-.24*Math.min(layout.width,layout.height),'eye/near above resized indoor floor');
}
}
});
test('dog mask occupies lower right with cropped bottom hidden below sill, world-fixed independently of slide',()=>{
const layout=peekingLayout(.6,1.5),bounds={x:.15,y:.1,width:.55,height:.8};
const a=placePeekingDog(layout,1.5,bounds,0.4704,1,0,0);
const maskTop=a.y+(.5-bounds.y)*a.height,maskBottom=a.y+(.5-bounds.y-bounds.height)*a.height;
const maskCenterX=a.x+(bounds.x+bounds.width/2-.5)*a.width;
assert.ok(maskCenterX>0);assert.ok(maskTop<0);assert.ok(maskTop>-.35*layout.height);assert.ok(maskBottom<-.5*layout.height);
assert.equal(a.z,.4704);assert.ok(a.width/a.height===1.5);
const moved=placePeekingDog(layout,1.5,bounds,.4704,1,1,1);assert.ok(moved.x>a.x&&moved.y>a.y,'position controls work at zoom 1');
});
test('experimental house preserves conservative front-detail clearance and closed rear room',async()=>{
const {loadFrameGeometry}=await import('./support/load-frame-geometry.mjs');
const {prepareHouseAsset}=await loadTypescript(new URL('../src/objects/BlenderHouseSurround.ts',import.meta.url));
const root=loadFrameGeometry(new URL('../public/assets/window/grandma-house-surround.glb',import.meta.url)),asset=prepareHouseAsset(root);
const layout=peekingLayout(375/812,12);asset.resize(layout.width,layout.height,.6,layout.clearanceScale);root.updateMatrixWorld(true);
const curtain=root.getObjectByName('HouseCurtain');curtain.geometry.computeBoundingBox();
assert.ok(curtain.geometry.boundingBox.max.z<=.448*layout.clearanceScale,'foreground thickness retains its conservative legacy clearance');
for(const d of[[0,0,1],[0,-1,0],[1,0,0]])assert.ok(new THREE.Raycaster(new THREE.Vector3(0,0,3),new THREE.Vector3(...d),.001,20).intersectObject(root,true).length,'room closure survives shallow front');
asset.dispose();
});
+8
View File
@@ -0,0 +1,8 @@
import assert from 'node:assert/strict';import {test} from 'node:test';import * as THREE from 'three';import {loadTypescript} from './support/load-typescript.mjs';
const {PeekingDoorGrid}=await loadTypescript(new URL('../src/objects/PeekingDoorGrid.ts',import.meta.url));
test('structural cross rails travel with one sash while their fixed aperture pocket clips',()=>{
const grid=new PeekingDoorGrid();grid.resize(.74,1,.6);grid.setOpen(.5);grid.group.updateMatrixWorld(true);
assert.equal(grid.group.position.x,-.37);assert.equal(grid.group.children.length,2);
for(const rail of grid.group.children){assert.equal(rail.material.clippingPlanes.length,1);assert.ok(rail.geometry.index.count>0);}
grid.setOpen(1);assert.equal(grid.group.position.x,-.74);grid.setOpen(0);assert.equal(grid.group.position.x,0);grid.dispose();
});
+16
View File
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';import {test} from 'node:test';import {readFile} from 'node:fs/promises';
const main=await readFile(new URL('../src/main.ts',import.meta.url),'utf8');
test('scene sizing aligns corner camera with the actual fixed courtyard door',()=>{
assert.match(main,/peekingLayout\(camera\.aspect,aspect\)/);
assert.match(main,/cameraController\.setRoomBounds\(frameW,frameH,uniforms\.uGlassZ\.value\)/);
assert.match(main,/houseSurround\.resize\([^;]*peeking\.clearanceScale/);
});
test('experimental renderer shares one B courtyard and never switches active depth RGB to cutout',()=>{
assert.match(main,/meshRenderer\.syncCourtyard\(isPeekingScene\(\)\?courtyard:null\)/);
assert.match(main,/const sourceImage = active\.image;/);
assert.match(main,/initPeekingExperimentUI/);
assert.match(main,/pendingCutout/);
assert.match(main,/peekingGrid\.setOpen\(effective\)/);
});
test('experimental No Glass retains alpha-aware post-pass depth seed',()=>{assert.match(main,/housePhotoDepth\.sync\([^;]*mode==='noglass'&&depthUniforms\.uUsePhotoAlpha\?\.value/);});
+43
View File
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {readFile} from 'node:fs/promises';
import * as THREE from 'three';
import {loadTypescript} from './support/load-typescript.mjs';
const {attachMeshRefraction,meshRefractionShader}=await loadTypescript(new URL('../src/rendering/MeshRefraction.ts',import.meta.url));
const {PetDepthMesh}=await loadTypescript(new URL('../src/objects/PetDepthMesh.ts',import.meta.url));
test('opt-in photo alpha preserves original opaque default and exposes partial-edge continuation',()=>{
const source=new PetDepthMesh(new THREE.Texture(),new THREE.Texture());
assert.equal(source.material.uniforms.uUsePhotoAlpha?.value,false);
const b=attachMeshRefraction(new THREE.MeshPhysicalMaterial(),source);
source.material.uniforms.uUsePhotoAlpha.value=true;
b.sync({width:1,height:1,grid:20,curvature:1},.58,new THREE.Color());
assert.equal(b.uniforms.uBUsePhotoAlpha.value,true);
assert.match(meshRefractionShader,/photoColor\.rgb\+backgroundColor\*\(1\.-photoColor\.a\)/);
assert.match(meshRefractionShader,/bCourtyardHit\(exitPoint,outgoing/);
assert.match(source.material.fragmentShader,/if\(uUsePhotoAlpha/);
source.geometry.dispose();source.material.dispose();
});
test('house post-pass depth honors the same dog alpha instead of occluding its rectangular background',async()=>{
const {createHousePhotoOccluder}=await loadTypescript(new URL('../src/objects/BlenderHouseSurround.ts',import.meta.url));
const source=new PetDepthMesh(new THREE.Texture(),new THREE.Texture()),depth=createHousePhotoOccluder(source);
assert.match(depth.mesh.material.fragmentShader,/uUsePhotoAlpha/);assert.match(depth.mesh.material.fragmentShader,/discard/);
assert.equal(depth.mesh.material.uniforms,source.material.uniforms);depth.dispose();source.geometry.dispose();source.material.dispose();
});
test('two separated photo hits accumulate a soft fringe in front of an opaque dog instead of leaking yard',()=>{
const depth=new THREE.PlaneGeometry(1,1,128,128),position=depth.getAttribute('position');
for(let i=0;i<position.count;i++)position.setZ(i,.15+.12*Math.cos(position.getX(i)*Math.PI*8));
depth.computeVertexNormals();
const dog=new THREE.Mesh(depth,new THREE.MeshBasicMaterial());dog.updateMatrixWorld(true);
const ray=new THREE.Raycaster(new THREE.Vector3(-.7,0,.2),new THREE.Vector3(1,0,-.02).normalize());
const hits=ray.intersectObject(dog,false).filter((hit,i,all)=>!i||Math.abs(hit.distance-all[i-1].distance)>.00001);
assert.ok(hits.length>=2,'actual displaced geometry provides distinct front-facing layers');
const match=meshRefractionShader.match(/float bPhotoCoverageWeight\(float coverage,float alpha\)\s*\{return ([^;]+);\}/);
assert.ok(match,'production ray uses front-to-back coverage weight');
const weight=new Function('coverage','alpha','return '+match[1]);
let coverage=0,rgb=[0,0,0];
for(const [color,alpha] of [[[1,0,0],.25],[[0,1,0],1]]){const w=weight(coverage,alpha);rgb=rgb.map((v,i)=>v+w*color[i]);coverage+=w;}
assert.deepEqual(rgb,[.25,.75,0]);assert.equal(coverage,1);
assert.match(meshRefractionShader,/sampleIndex<2/);assert.match(meshRefractionShader,/t<backgroundDistance/);
assert.match(meshRefractionShader,/hitColor\.rgb\+=sampleColor\.rgb\*weight/);
depth.dispose();dog.material.dispose();
});
+239
View File
@@ -0,0 +1,239 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { readFile } from 'node:fs/promises';
import vm from 'node:vm';
import ts from 'typescript';
// DOM and segmentation are browser boundaries. Exercise the real UI controller;
// do not load a network model or claim this is rendered-browser verification.
class Element {
constructor(tag, owner) {
this.tagName = tag.toUpperCase(); this.ownerDocument = owner;
this.children = []; this.attributes = {}; this.listeners = {};
this.hidden = false; this.disabled = false; this.open = false;
this.value = ''; this.textContent = ''; this.files = []; this.dataset = {};
this.connected = false; this.clicks = 0;
}
get isConnected() { return this.connected || Boolean(this.parentElement?.isConnected); }
set isConnected(value) { this.connected = value; }
append(...children) { for (const child of children) { child.parentElement = this; child.isConnected = this.isConnected; this.children.push(child); } }
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return this.attributes[name] ?? null; }
removeAttribute(name) { delete this.attributes[name]; if (name === 'src') this.src = ''; }
addEventListener(type, fn, { signal } = {}) {
if (signal?.aborted) return;
(this.listeners[type] ??= []).push(fn);
signal?.addEventListener('abort', () => this.removeEventListener(type, fn), { once: true });
}
removeEventListener(type, fn) { this.listeners[type] = (this.listeners[type] ?? []).filter(listener => listener !== fn); }
dispatch(type, extra = {}) {
const event = { type, target: this, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; }, stopPropagation() { this.stopped = true; }, ...extra };
for (const fn of [...this.listeners[type] ?? []]) fn(event);
return event;
}
click() { if (!this.disabled) { this.clicks++; this.dispatch('click'); } }
focus() { this.ownerDocument.activeElement = this; }
showModal() { assert.equal(this.open, false); this.open = true; }
close() { this.open = false; this.dispatch('close'); }
remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter(child => child !== this); this.parentElement = null; this.connected = false; }
querySelector(selector) { return this.querySelectorAll(selector)[0] ?? null; }
querySelectorAll(selector) {
const matches = el => selector.startsWith('#') ? el.id === selector.slice(1) : el.tagName.toLowerCase() === selector;
return this.children.flatMap(child => [...(matches(child) ? [child] : []), ...child.querySelectorAll(selector)]);
}
}
const tick = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); };
function candidate(name = 'first', warnings = []) {
let disposed = false;
return { originalUrl: `blob:${name}-original`, cutoutUrl: `blob:${name}-cutout`, warnings, requiresReview: true,
get disposed() { return disposed; }, dispose() { disposed = true; } };
}
async function harness({ importFailure = false } = {}) {
const root = { activeElement: null };
root.createElement = tag => new Element(tag, root);
root.body = root.createElement('body'); root.body.isConnected = true;
root.querySelector = selector => root.body.querySelector(selector);
const settings = root.createElement('dialog'); settings.id = 'settings-dialog'; settings.open = true;
const panel = root.createElement('section'); panel.id = 'panel-photo';
const internal = root.createElement('div'); internal.id = 'internal-controls'; internal.hidden = true; internal.inert = true; root.body.append(internal);
const originalInput = root.createElement('input'); originalInput.id = 'image';
root.body.append(settings); settings.append(panel); panel.append(originalInput);
const preparations = [], requests = [], accepted = [], toggled = [];
let imports = 0;
class Preparation {
constructor() { this.results = new Set(); preparations.push(this); }
prepare(file, onProgress, signal) {
const request = { file, onProgress, signal, preparation: this };
requests.push(request);
return new Promise((resolve, reject) => {
request.resolve = result => { this.results.add(result); resolve(result); };
request.reject = reject;
});
}
cancel() { this.cancelled = true; }
dispose() { this.disposed = true; this.cancel(); for (const result of this.results) result.dispose(); }
}
const exports = {};
const source = await readFile(new URL('../src/ui/PeekingExperimentUI.ts', import.meta.url), 'utf8');
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, {
exports, AbortController, Error, DOMException,
require(name) { assert.match(name, /CutoutPreparation$/); imports++; if (importFailure) throw Error('module unavailable'); return { CutoutPreparation: Preparation }; },
});
const abort = new AbortController();
const ui = exports.initPeekingExperimentUI(root, abort.signal, {
onAccept(result) { assert.equal(root.querySelector('#peeking-preview-dialog').open, false, 'close preview before main changes settings/loading'); accepted.push(result); },
onToggle(enabled) { toggled.push(enabled); },
});
const get = id => { const element = root.querySelector(`#${id}`); assert.ok(element, id); return element; };
const select = async name => { const picker = get('peeking-photo'); picker.files = [{ name, type: 'image/png' }]; picker.value = name; picker.dispatch('change'); await tick(); };
return { root, settings, panel, internal, originalInput, ui, abort, get, select, preparations, requests, accepted, toggled, get imports() { return imports; } };
}
test('retired experiment stays outside user settings in hidden inert internal controls', async () => {
const h = await harness();
assert.equal(h.imports, 0); assert.equal(h.preparations.length, 0);
assert.equal(h.get('peeking-experiment').parentElement, h.internal);
assert.equal(h.internal.hidden,true);assert.equal(h.internal.inert,true);
assert.equal(h.panel.querySelector('#peeking-experiment'),null);
assert.equal(h.originalInput.listeners.change, undefined);
assert.equal(h.get('peeking-choose').textContent, '마당에서 빼꼼 · 실험');
assert.equal(h.get('peeking-toggle').hidden, true);
assert.equal(h.get('peeking-photo').type, 'file'); assert.equal(h.get('peeking-photo').hidden, true);
h.get('peeking-choose').click(); assert.equal(h.get('peeking-photo').clicks, 1); assert.equal(h.imports, 0);
assert.equal(h.settings.open, true);
assert.match(h.get('peeking-limitations').textContent, /털/);
assert.match(h.get('peeking-limitations').textContent, /배경/);
assert.match(h.get('peeking-limitations').textContent, /자세/);
assert.match(h.get('peeking-limitations').textContent, /옆/);
});
test('selection stays in settings, shows progress and review, and requires explicit acceptance', async () => {
const h = await harness(); await h.select('dog.png');
assert.equal(h.imports, 1); assert.equal(h.requests.length, 1);
assert.equal(h.settings.open, true); assert.equal(h.get('peeking-preview-dialog').open, true);
assert.equal(h.get('peeking-accept').disabled, true);
assert.equal(h.get('peeking-photo').value, '', 'same file can be selected again');
assert.equal(h.get('peeking-progress').getAttribute('role'), 'status');
h.requests[0].onProgress('누끼 모델을 준비하는 중…');
assert.equal(h.get('peeking-progress').textContent, '누끼 모델을 준비하는 중…');
h.requests[0].resolve(candidate()); await tick();
assert.equal(h.accepted.length, 0); assert.equal(h.get('peeking-accept').disabled, false);
assert.equal(h.get('peeking-original').src, 'blob:first-original');
assert.equal(h.get('peeking-cutout').src, 'blob:first-cutout');
assert.equal(h.get('peeking-preview-dialog').getAttribute('aria-busy'), 'false');
h.get('peeking-accept').click(); assert.equal(h.accepted.length, 1);
h.get('peeking-accept').click(); assert.equal(h.accepted.length, 1, 'duplicate accept is inert');
});
test('accepted ownership survives UI cancel, replacement and teardown until caller releases it', async () => {
const h = await harness(); await h.select('dog.png');
const result = candidate(); h.requests[0].resolve(result); await tick(); h.get('peeking-accept').click();
h.ui.cancel(); assert.equal(result.disposed, false);
await h.select('other.png'); h.ui.dispose();
assert.equal(result.disposed, false); assert.equal(h.preparations[0].disposed, undefined);
h.accepted[0].dispose(); assert.equal(result.disposed, true); assert.equal(h.preparations[0].disposed, true);
h.accepted[0].dispose();
});
test('Escape aborts pending work, keeps settings open, restores focus, and disposes late results', async () => {
const h = await harness(); h.get('peeking-choose').focus(); await h.select('dog.png');
const event = h.get('peeking-preview-dialog').dispatch('cancel');
assert.equal(event.defaultPrevented, true); assert.equal(h.requests[0].signal.aborted, true);
assert.equal(h.settings.open, true); assert.equal(h.get('peeking-preview-dialog').open, false);
assert.equal(h.root.activeElement?.id, 'peeking-choose');
const late = candidate('late'); h.requests[0].resolve(late); await tick();
assert.equal(late.disposed, true); assert.equal(h.accepted.length, 0);
});
test('replacement aborts old work and ignores stale progress and errors', async () => {
const h = await harness(); await h.select('first.png'); await h.select('second.png');
assert.equal(h.requests[0].signal.aborted, true);
const status = h.get('peeking-progress').textContent;
h.requests[0].onProgress('stale'); h.requests[0].reject(Error('old error')); await tick();
assert.equal(h.get('peeking-progress').textContent, status);
const result = candidate('second'); h.requests[1].resolve(result); await tick();
assert.equal(h.get('peeking-cutout').src, result.cutoutUrl);
h.ui.cancel(); assert.equal(result.disposed, true);
assert.equal(h.get('peeking-original').src, ''); assert.equal(h.get('peeking-cutout').src, '');
});
test('errors expose same-photo retry and another-photo selection without changing the scene', async () => {
const h = await harness(); await h.select('dog.png');
h.requests[0].reject(Error('모델을 불러오지 못했어요.')); await tick();
assert.match(h.get('peeking-error').textContent, /모델/); assert.equal(h.get('peeking-error').hidden, false);
assert.equal(h.get('peeking-error').getAttribute('role'), 'alert');
assert.equal(h.get('peeking-retry').hidden, false); assert.equal(h.get('peeking-accept').disabled, true);
h.get('peeking-retry').click(); await tick(); assert.equal(h.requests.length, 2); assert.equal(h.requests[1].file.name, 'dog.png');
h.requests[1].resolve(candidate('retry', ['multiple-foreground', 'tiny-foreground'])); await tick();
assert.match(h.get('peeking-warning').textContent, /여러/); assert.match(h.get('peeking-warning').textContent, /작/);
h.get('peeking-another').click(); assert.equal(h.get('peeking-photo').clicks, 1);
assert.equal(h.preparations[1].disposed, true); assert.equal(h.accepted.length, 0);
});
test('toggle only invokes explicit route changes while an accepted image is available and idle', async () => {
const h = await harness(); h.get('peeking-toggle').click(); assert.deepEqual(h.toggled, []);
h.ui.setState({ available: true, enabled: true, busy: false });
assert.equal(h.get('peeking-toggle').hidden, false); assert.equal(h.get('peeking-toggle').textContent, '원본 사진으로 돌아가기');
h.get('peeking-toggle').click(); assert.deepEqual(h.toggled, [false]);
h.ui.setState({ available: true, enabled: false, busy: false });
assert.equal(h.get('peeking-toggle').textContent, '마당 실험 다시 보기'); h.get('peeking-toggle').click();
assert.deepEqual(h.toggled, [false, true]);
h.ui.setState({ available: true, enabled: true, busy: true });
assert.equal(h.get('peeking-toggle').disabled, true); assert.equal(h.get('peeking-choose').disabled, true);
h.ui.setState({ available: false, enabled: false, busy: false }); assert.equal(h.get('peeking-toggle').hidden, true);
});
test('parent settings close, lifetime abort, and main cancellation release previews', async () => {
for (const action of ['settings', 'signal', 'main', 'native-close']) {
const h = await harness(); await h.select('dog.png'); const result = candidate(); h.requests[0].resolve(result); await tick();
if (action === 'settings') h.settings.close();
if (action === 'signal') h.abort.abort();
if (action === 'main') h.ui.cancel();
if (action === 'native-close') h.get('peeking-preview-dialog').close();
assert.equal(result.disposed, true, action); assert.equal(h.accepted.length, 0);
if (action === 'signal') { assert.equal(h.root.querySelector('#peeking-experiment'), null); assert.equal(h.root.querySelector('#peeking-preview-dialog'), null); }
else assert.equal(h.get('peeking-preview-dialog').open, false);
}
});
test('module load failure is recoverable and selecting after settings closes does not start work', async () => {
const h = await harness({ importFailure: true }); await h.select('dog.png');
assert.equal(h.get('peeking-error').hidden, false); assert.equal(h.get('peeking-retry').hidden, false);
h.ui.cancel(); h.settings.close(); await h.select('late-file.png');
assert.equal(h.imports, 1); assert.equal(h.get('peeking-preview-dialog').open, false);
});
test('cancelling before the lazy module settles creates no preparation or result', async () => {
const h = await harness();
const picker = h.get('peeking-photo');
picker.files = [{ name: 'dog.png', type: 'image/png' }];
picker.dispatch('change'); h.ui.cancel(); await tick();
assert.equal(h.preparations.length, 0); assert.equal(h.accepted.length, 0);
assert.equal(h.get('peeking-preview-dialog').open, false);
});
test('queued close events from an earlier review cannot cancel a newly opened preview', async () => {
const h = await harness(); await h.select('first.png'); await h.select('second.png');
h.get('peeking-preview-dialog').dispatch('close');
assert.equal(h.requests[1].signal.aborted, false);
h.requests[1].resolve(candidate('second')); await tick();
assert.equal(h.get('peeking-preview-dialog').open, true);
assert.equal(h.get('peeking-cutout').src, 'blob:second-cutout');
h.ui.cancel();
});
test('empty picker changes and retained controls after disposal cannot start work', async () => {
const h = await harness(); const picker = h.get('peeking-photo'); const choose = h.get('peeking-choose');
picker.files = []; picker.dispatch('change'); await tick();
assert.equal(h.imports, 0);
h.ui.dispose(); picker.files = [{ name: 'late.png' }]; picker.dispatch('change'); choose.click(); await tick();
assert.equal(h.imports, 0); assert.equal(h.root.querySelector('#peeking-experiment'), null);
});
test('dedicated CSS supplies checkerboard, responsive containment, keyboard focus and touch targets', async () => {
const css = await readFile(new URL('../src/ui/peeking-experiment.css', import.meta.url), 'utf8');
assert.match(css, /repeating-conic-gradient|linear-gradient/);
assert.match(css, /object-fit:\s*contain/);
assert.match(css, /:focus-visible/); assert.match(css, /min-height:\s*44px/);
assert.match(css, /@media\s*\(max-width:\s*480px\)/);
});
+46
View File
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import { readFile, access } from 'node:fs/promises';
import { test } from 'node:test';
import { loadTypescript } from './support/load-typescript.mjs';
const moduleURL = new URL('../src/state/PetName.ts', import.meta.url);
assert.equal(await access(moduleURL).then(() => true, () => false), true, 'application-wide pet name state must exist');
const { normalizePetName, petNameForms, createPetNameStore, PET_NAME_STORAGE_KEY } = await loadTypescript(moduleURL);
test('friendly Korean name forms avoid duplicated 이 and match natural particles', () => {
for (const [raw, display, object, subject] of [
['', '멍멍이', '멍멍이를', '멍멍이가'], ['호롱', '호롱이', '호롱이를', '호롱이가'],
['초코', '초코', '초코를', '초코가'], ['보리', '보리', '보리를', '보리가'],
['호롱이', '호롱이', '호롱이를', '호롱이가'],
]) assert.deepEqual(petNameForms(raw), { display, object, subject, possessive: `${display}의` });
});
test('name normalization trims, composes Korean, bounds Unicode safely, preserves literal text', () => {
assert.equal(normalizePetName(' 호롱 '), '호롱');
assert.equal(normalizePetName(' \n\t '), '');
assert.equal(normalizePetName('호롱'), '호롱');
assert.equal(normalizePetName('🐕'.repeat(25)), '🐕'.repeat(20));
assert.equal(normalizePetName('<img onerror=x>'), '<img onerror=x>');
});
test('one store publishes normalized state, persists reloads and unsubscribe stops updates', () => {
const values = new Map(), storage = { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value) };
const store = createPetNameStore(() => storage), updates = [], signal = new AbortController();
store.subscribe(() => updates.push(store.name), signal.signal);
store.setName(' 호롱 '); store.setName('호롱');
assert.deepEqual(updates, ['호롱']); assert.equal(values.get(PET_NAME_STORAGE_KEY), '호롱');
assert.equal(createPetNameStore(() => storage).name, '호롱');
store.setName(''); assert.equal(createPetNameStore(() => storage).name, '');
signal.abort(); store.setName('초코'); assert.deepEqual(updates, ['호롱', '']);
});
test('storage getter, read and write failures never prevent session edits', () => {
for (const getStorage of [() => undefined, () => { throw Error('blocked'); }, () => ({ getItem() { throw Error('blocked'); }, setItem() { throw Error('full'); } }), () => ({ getItem: () => '호롱', setItem() { throw Error('full'); } })]) {
const store = createPetNameStore(getStorage); store.setName('보리');
assert.equal(store.name, '보리'); assert.equal(store.storageUnavailable, true);
}
});
test('removed settings surfaces are outside settings and inert, landing sample/retry stay available', async () => {
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
const settings = html.slice(html.indexOf('<dialog id="settings-dialog"'), html.indexOf('</dialog>', html.indexOf('<dialog id="settings-dialog"')));
for (const old of ['sample-browser', 'developer-tools', 'processing-card', 'local-note', 'id="ai-depth"', 'id="status"']) assert.equal(settings.includes(old), false, `${old} is no longer exposed in settings`);
assert.match(html, /id="internal-controls"[^>]*hidden[^>]*inert/);
for (const id of ['entry-samples', 'sample-dialog', 'flow-retry', 'flow-flat']) assert.match(html, new RegExp(`id="${id}"`));
for (const id of ['entry-pet-name', 'settings-pet-name']) assert.match(html, new RegExp(`id="${id}"[^>]*autocomplete="off"`));
});
+69
View File
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import vm from 'node:vm';
import ts from 'typescript';
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
// These requirements fail on the prior developer-oriented shell.
assert.match(html, /id="tool-tabs"[^>]*role="tablist"/, 'consumer tools use an accessible tab list');
assert.match(html, /<details\b[^>]*id="developer-tools"(?![^>]*\bopen)[^>]*>/, 'diagnostics are collapsed by default');
assert.ok(html.indexOf('id="reference-strip"') > html.indexOf('id="developer-tools"'), 'references live in developer tools');
for (const value of ['2','0','1']) assert.match(html, new RegExp(`data-pattern="${value}"`));
for (const id of ['image','test-photo','sample','ai-depth','depth-provenance','status','grid-size','curvature','photo-glass-distance','camera-offset','photo-zoom','photo-pan-x','photo-pan-y','reset','gyro','depth-image','video','effect','view-mode','depth-scale','light-x','light-y','sensitivity','depth-inspection']) {
assert.equal([...html.matchAll(new RegExp(`\\bid="${id}"`,'g'))].length, 1, `${id} stays available exactly once`);
}
for (const [,attrs,body] of html.matchAll(/<label\b([^>]*)>([\s\S]*?)<\/label>/g)) {
const controlId=body.match(/<(?:input|select)\b[^>]*\bid="([^"]+)"/)?.[1];
if (controlId && body.includes('<output')) assert.ok(attrs.includes(`for="${controlId}"`), `${controlId} has an explicit label instead of labeling its output`);
}
class Element {
constructor(data={}) {this.dataset=data;this.attributes={};this.listeners={};this.hidden=false;this.value='';this.tabIndex=0;}
setAttribute(key,value){this.attributes[key]=value;}
getAttribute(key){return this.attributes[key];}
addEventListener(key,fn){(this.listeners[key]??=[]).push(fn);}
dispatchEvent(event){for(const fn of this.listeners[event.type]??[])fn(event);}
focus(){this.focused=true;}
}
const tabs=['glass','photo','view'].map(name=>new Element({tool:name}));
const panels=tabs.map(tab=>new Element({panel:tab.dataset.tool}));
const cards=['2','0','1'].map(pattern=>new Element({pattern}));
const select=new Element();select.value='2';
const effect=new Element();effect.value='mosaic';
const cardGroup=new Element();
const document={querySelectorAll:query=>query==='[data-tool]'?tabs:query==='[data-panel]'?panels:cards,querySelector:query=>({'#mosaic-style':select,'#effect':effect,'#pattern-cards':cardGroup}[query])};
const source=await readFile(new URL('../src/ui/PhotoBoothUI.ts',import.meta.url),'utf8');
const module={exports:{}};
vm.runInNewContext(ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText,{exports:module.exports,Event});
module.exports.initPhotoBoothUI(document,new AbortController().signal);
const click=element=>element.dispatchEvent({type:'click'});
assert.equal(tabs[0].attributes['aria-selected'],'true');assert.deepEqual(panels.map(p=>p.hidden),[false,true,true]);
click(tabs[2]);assert.deepEqual(panels.map(p=>p.hidden),[true,true,false]);
assert.deepEqual(tabs.map(t=>t.tabIndex),[-1,-1,0]);
let prevented=false;tabs[2].dispatchEvent({type:'keydown',key:'ArrowRight',preventDefault(){prevented=true;}});
assert.equal(tabs[0].focused,true);assert.equal(prevented,true);
tabs[0].dispatchEvent({type:'keydown',key:'End',preventDefault(){}});assert.equal(tabs[2].attributes['aria-selected'],'true');
tabs[2].dispatchEvent({type:'keydown',key:'Home',preventDefault(){}});assert.equal(tabs[0].attributes['aria-selected'],'true');
let changes=0;select.addEventListener('change',()=>changes++);
click(cards[0]);assert.equal(changes,0,'reselecting the current pattern must not reset its custom grid');
for (const card of [cards[1],cards[2],cards[0],cards[1]]) {click(card);assert.equal(select.value,card.dataset.pattern);assert.equal(card.attributes['aria-pressed'],'true');assert.equal(cards.filter(c=>c.attributes['aria-pressed']==='true').length,1);}
assert.equal(changes,4);
select.value='1';select.dispatchEvent(new Event('change'));assert.equal(cards[2].attributes['aria-pressed'],'true');
effect.value='frosted';effect.dispatchEvent(new Event('change'));assert.equal(cardGroup.hidden,true);
effect.value='mosaic';effect.dispatchEvent(new Event('change'));assert.equal(cardGroup.hidden,false);
const css=await readFile(new URL('../src/style.css',import.meta.url),'utf8');
assert.match(css,/prefers-reduced-motion:\s*reduce/);
assert.match(css,/:focus-visible/);
assert.match(css,/min-height:\s*44px/);
// Source-level palette checks, not a claim about computed styles or rendered UI.
const ruleColor=(selector,property)=>{
const start=css.indexOf(`${selector} {`);assert.ok(start>=0,selector);
const rule=css.slice(start,css.indexOf('}',start));
const value=rule.match(new RegExp(`${property}:#([0-9a-f]{6})(?:;|\\b)`))?.[1];assert.ok(value,`${selector} ${property}`);return value;
};
const luminance=hex=>[0,2,4].map(i=>parseInt(hex.slice(i,i+2),16)/255).map(c=>c<=.04045?c/12.92:((c+.055)/1.055)**2.4).reduce((sum,c,i)=>sum+c*[.2126,.7152,.0722][i],0);
const contrast=(a,b)=>(Math.max(luminance(a),luminance(b))+.05)/(Math.min(luminance(a),luminance(b))+.05);
assert.ok(contrast(ruleColor('.pattern-detail','color'),ruleColor('.pattern-card[aria-pressed=true]','background'))>=4.5,'selected card detail contrast');
assert.ok(contrast(ruleColor('.app-footer','color'),ruleColor(':root','background'))>=4.5,'footer contrast');
assert.match(css,/#canvas-wrap:focus-visible\s*\{\s*outline-offset:-4px/,'preview focus ring stays inside the clipping edge');
assert.match(css,/@media\(max-width:850px\)/,'tablet/phone stack breakpoint');
assert.match(css,/@media\(max-width:480px\)/,'compact phone styling');
console.log('Photo booth UI passed: preserved controls, collapsed diagnostics, card synchronization, repeat selection, effect visibility, tab keyboard navigation, reduced-motion/focus/touch rules, palette contrast and responsive source rules (no layout engine)');
+12
View File
@@ -0,0 +1,12 @@
import assert from'node:assert/strict';import{test}from'node:test';import{loadTypescript}from'./support/load-typescript.mjs';
const load=()=>loadTypescript(new URL('../src/input/PhotoMetadata.ts',import.meta.url));
function jpeg(orientation,width=4032,height=3024){const bytes=new Uint8Array(51),v=new DataView(bytes.buffer);bytes.set([255,216,255,225,0,34,69,120,105,102,0,0,73,73,42,0,8,0,0,0,1,0,18,1,3,0,1,0,0,0,orientation,0,0,0,0,0,0,0,255,192,0,11,8],0);v.setUint16(43,height);v.setUint16(45,width);bytes.set([1,1,17,0],47);return bytes;}
test('JPEG EXIF1–8 dimensions swap only on transpose orientations',async()=>{const{readPhotoMetadata}=await load();for(let o=1;o<=8;o++){const m=await readPhotoMetadata(new Blob([jpeg(o)]));assert.equal(m.orientation,o);assert.deepEqual([m.width,m.height],o>=5?[3024,4032]:[4032,3024]);}});
test('PNG and WebP header dimensions do not decode full image',async()=>{const{readPhotoMetadata}=await load();const p=new Uint8Array(24);p.set([137,80,78,71,13,10,26,10]);new DataView(p.buffer).setUint32(16,8000);new DataView(p.buffer).setUint32(20,6000);assert.equal((await readPhotoMetadata(new Blob([p]))).width,8000);const w=new Uint8Array(30);w.set(new TextEncoder().encode('RIFFxxxxWEBPVP8X'));w[24]=255;w[25]=15;w[27]=255;w[28]=7;assert.deepEqual(await readPhotoMetadata(new Blob([w])),{width:4096,height:2048,orientation:1});});
test('truncated/malformed metadata safely falls back and read budget is bounded',async()=>{const{readPhotoMetadata,PHOTO_HEADER_BYTES}=await load();assert.equal(await readPhotoMetadata(new Blob([new Uint8Array(3)])),null);let length;await readPhotoMetadata({slice(start,end){length=end-start;return new Blob([new Uint8Array(3)]);}});assert.ok(length<=256*1024);assert.equal(length,PHOTO_HEADER_BYTES);});
test('known large file requests its canonical dimensions on the first decoder invocation',async()=>{const old=globalThis.createImageBitmap,calls=[];globalThis.createImageBitmap=async(_source,opts)=>{calls.push(opts);return{width:opts.resizeWidth,height:opts.resizeHeight,close(){}};};try{const{createPhotoSnapshot}=await loadTypescript(new URL('../src/input/PhotoSnapshot.ts',import.meta.url));const result=await createPhotoSnapshot(new Blob([jpeg(6)]),1,new AbortController().signal);assert.equal(calls.length,1);assert.deepEqual([calls[0].resizeWidth,calls[0].resizeHeight],[768,1024]);assert.deepEqual([result.width,result.height],[768,1024]);}finally{globalThis.createImageBitmap=old;}});
test('unknown PNG/WebP orientation never forces raw-aspect decoder resize',async()=>{const old=globalThis.createImageBitmap,calls=[];const p=new Uint8Array(24);p.set([137,80,78,71,13,10,26,10]);new DataView(p.buffer).setUint32(16,4032);new DataView(p.buffer).setUint32(20,3024);globalThis.createImageBitmap=async(_source,opts)=>{calls.push(opts);return calls.length===1?{width:3024,height:4032,close(){}}:{width:768,height:1024,close(){}};};try{const{createPhotoSnapshot}=await loadTypescript(new URL('../src/input/PhotoSnapshot.ts',import.meta.url));const result=await createPhotoSnapshot(new Blob([p]),1,new AbortController().signal);assert.equal(calls[0].resizeWidth,undefined);assert.equal(calls[1].resizeWidth,768);assert.equal(result.width,768);}finally{globalThis.createImageBitmap=old;}});
function tiff(o){const b=new Uint8Array(26),v=new DataView(b.buffer);b.set([73,73,42,0,8,0,0,0,1,0,18,1,3,0,1,0,0,0,o]);return b;}
function chunk(type,data,png){const b=new Uint8Array(8+data.length+(png?4:data.length%2)),v=new DataView(b.buffer);b.set(new TextEncoder().encode(type),png?4:0);v.setUint32(png?0:4,data.length,!png);b.set(data,8);return b;}
test('PNG/WebP EXIF after large encoded payload is read with bounded slices for rotations and mirrors',async()=>{const{readPhotoMetadata,PHOTO_HEADER_BYTES}=await load();for(const png of[true,false])for(let o=1;o<=8;o++){let bytes;if(png){const ihdr=new Uint8Array(13),v=new DataView(ihdr.buffer);v.setUint32(0,8000);v.setUint32(4,6000);bytes=new Blob([new Uint8Array([137,80,78,71,13,10,26,10]),chunk('IHDR',ihdr,true),chunk('IDAT',new Uint8Array(1024*1024),true),chunk('eXIf',tiff(o),true),chunk('IEND',new Uint8Array(),true)]);}else{const vp8x=new Uint8Array(10);vp8x[0]=8;vp8x[4]=63;vp8x[5]=31;vp8x[7]=111;vp8x[8]=23;const body=new Blob([chunk('VP8X',vp8x,false),chunk('VP8 ',new Uint8Array(1024*1024),false),chunk('EXIF',tiff(o),false)]),head=new Uint8Array(12);head.set(new TextEncoder().encode('RIFF'));new DataView(head.buffer).setUint32(4,body.size+4,true);head.set(new TextEncoder().encode('WEBP'),8);bytes=new Blob([head,body]);}let read=0;const file={size:bytes.size,slice(start,end){read+=end-start;return bytes.slice(start,end);}};const metadata=await readPhotoMetadata(file);assert.deepEqual(metadata,{width:o>=5?6000:8000,height:o>=5?8000:6000,orientation:o});assert.ok(read<=2*PHOTO_HEADER_BYTES);}});
test('JPEG without EXIF positively records orientation1 when scan reaches image data',async()=>{const{readPhotoMetadata}=await load(),b=new Uint8Array([255,216,255,192,0,11,8,11,208,15,160,1,1,17,0,255,218,0,2]);assert.deepEqual(await readPhotoMetadata(new Blob([b])),{width:4000,height:3024,orientation:1});});
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {loadTypescript} from './support/load-typescript.mjs';
const load=()=>loadTypescript(new URL('../src/input/PhotoSnapshot.ts',import.meta.url));
function png(width,height){const b=new Uint8Array(45),v=new DataView(b.buffer);b.set([137,80,78,71,13,10,26,10]);v.setUint32(8,13);b.set(new TextEncoder().encode('IHDR'),12);v.setUint32(16,width);v.setUint32(20,height);b.set(new TextEncoder().encode('IEND'),37);return new Blob([b],{type:'image/png'});}
const withBitmap=async(fn,body)=>{const old=globalThis.createImageBitmap;globalThis.createImageBitmap=fn;try{await body();}finally{globalThis.createImageBitmap=old;}};
test('ordinary PNG requests bounded first decode, keeps aspect and no upscale',async()=>{await withBitmap(async(_f,o)=>({width:o.resizeWidth??8000,height:o.resizeHeight??6000,close(){}}),async()=>{const{createPhotoSnapshot}=await load();const s=await createPhotoSnapshot(png(8000,6000),1,new AbortController().signal);assert.deepEqual([s.width,s.height],[1024,768]);const small=await createPhotoSnapshot(png(320,240),2,new AbortController().signal);assert.deepEqual([small.width,small.height],[320,240]);});});
test('oversize dimensions are rejected before native decode',async()=>{let calls=0;await withBitmap(async()=>{calls++;return{width:1024,height:1024,close(){}};},async()=>{const{createPhotoSnapshot}=await load();await assert.rejects(createPhotoSnapshot(png(100000,100000),1,new AbortController().signal),/크|픽셀|64/);assert.equal(calls,0);});});
test('cancelled decode closes its result and cannot become a working photo',async()=>{let release,closed=0;await withBitmap(()=>new Promise(r=>{release=r;}),async()=>{const{createPhotoSnapshot}=await load(),abort=new AbortController(),pending=createPhotoSnapshot(png(4000,3000),1,abort.signal);while(!release)await new Promise(r=>setImmediate(r));abort.abort();release({width:1024,height:768,close(){closed++;}});await assert.rejects(pending,{name:'AbortError'});assert.equal(closed,1);});});
test('late resize cancellation releases both native bitmaps',async()=>{let release,calls=0,closed=0;await withBitmap(async()=>{if(++calls===1)return{width:4000,height:3000,close(){closed++;}};return new Promise(r=>{release=r;});},async()=>{const{createPhotoSnapshot}=await load(),abort=new AbortController(),pending=createPhotoSnapshot(png(4000,3000),1,abort.signal);while(!release)await new Promise(r=>setImmediate(r));abort.abort();release({width:1024,height:768,close(){closed++;}});await assert.rejects(pending,{name:'AbortError'});assert.equal(closed,2);});});
function imageHarness({abortOnBlob=false,decodeError=false}={}){const originals={Image:globalThis.Image,document:globalThis.document,createImageBitmap:globalThis.createImageBitmap,create:URL.createObjectURL,revoke:URL.revokeObjectURL};const abort=new AbortController(),urls=[],revoked=[],canvases=[],images=[];let closes=0;globalThis.createImageBitmap=async(source,options)=>({width:options?.resizeWidth??source.width,height:options?.resizeHeight??source.height,close(){closes++;}});globalThis.Image=class{constructor(){images.push(this);}set src(value){this._src=value;if(value)queueMicrotask(()=>decodeError?this.onerror?.():this.onload?.());}get src(){return this._src;}removeAttribute(){this._src='';}};globalThis.document={createElement(){const c={width:0,height:0,draws:[],getContext(){return{drawImage:(...args)=>c.draws.push(args)};},toBlob(callback,type){c.mime=type;if(abortOnBlob)abort.abort();callback(new Blob(['working-png'],{type}));}};canvases.push(c);return c;}};URL.createObjectURL=blob=>{urls.push(blob);return'blob:bounded'+urls.length;};URL.revokeObjectURL=url=>revoked.push(url);return{abort,urls,revoked,canvases,images,get closes(){return closes;},restore(){globalThis.Image=originals.Image;globalThis.document=originals.document;globalThis.createImageBitmap=originals.createImageBitmap;URL.createObjectURL=originals.create;URL.revokeObjectURL=originals.revoke;}};}
test('lossless DOM adapter encodes only normalized RGB, retains alpha format and releases on dispose',async()=>{const h=imageHarness();try{const {createWorkingPhotoImage}=await loadTypescript(new URL('../src/input/WorkingPhotoImage.ts',import.meta.url));const p=await createWorkingPhotoImage(png(8000,6000),1,h.abort.signal);assert.equal(h.canvases[0].mime,'image/png');assert.deepEqual([h.canvases[0].draws[0][0].width,h.canvases[0].draws[0][0].height],[1024,768]);assert.equal(h.urls[0].type,'image/png');assert.equal(h.closes,1);assert.equal(h.canvases[0].width,1);assert.equal(h.revoked.length,0);p.dispose();p.dispose();assert.equal(h.revoked.length,1);assert.equal(p.image.src,'');}finally{h.restore();}});
test('cancel during PNG encode releases working bitmap/canvas without creating a URL',async()=>{const h=imageHarness({abortOnBlob:true});try{const {createWorkingPhotoImage}=await loadTypescript(new URL('../src/input/WorkingPhotoImage.ts',import.meta.url));await assert.rejects(createWorkingPhotoImage(png(4000,3000),1,h.abort.signal),{name:'AbortError'});assert.equal(h.closes,1);assert.equal(h.canvases[0].width,1);assert.equal(h.urls.length,0);}finally{h.restore();}});
test('failed preview decode revokes its URL and bitmap, never returns half-ready RGB',async()=>{const h=imageHarness({decodeError:true});try{const {createWorkingPhotoImage}=await loadTypescript(new URL('../src/input/WorkingPhotoImage.ts',import.meta.url));await assert.rejects(createWorkingPhotoImage(png(4000,3000),1,h.abort.signal),/작업용 사진/);assert.equal(h.closes,1);assert.equal(h.revoked.length,1);}finally{h.restore();}});
test('browser Blob-resize rejection falls back to a bounded alpha canvas and revokes source URL',async()=>{const h=imageHarness();let calls=0;globalThis.createImageBitmap=async(source,options)=>{calls++;if(source instanceof Blob)throw new Error('resize unsupported');assert.deepEqual([source.width,source.height],[1024,768]);return{width:source.width,height:source.height,close(){}};};globalThis.Image=class{naturalWidth=8000;naturalHeight=6000;set src(_value){queueMicrotask(()=>this.onload?.());}removeAttribute(){}};try{const {createPhotoSnapshot}=await load();const result=await createPhotoSnapshot(png(8000,6000),1,h.abort.signal);assert.deepEqual([result.width,result.height],[1024,768]);assert.equal(calls,2);assert.equal(h.revoked.length,1);assert.equal(h.canvases[0].width,1);}finally{h.restore();}});
test('unknown-format decoded dimensions still get the source guard and release',async()=>{let closed=0;await withBitmap(async()=>({width:100000,height:100000,close(){closed++;}}),async()=>{const{createPhotoSnapshot}=await load();await assert.rejects(createPhotoSnapshot(new Blob(['unknown-format']),1,new AbortController().signal),/사진이 너무 커요/);assert.equal(closed,1);});});
test('unsupported browser codec produces useful error and releases fallback object URL',async()=>{const h=imageHarness({decodeError:true});globalThis.createImageBitmap=async()=>{throw new Error('unsupported');};try{const{createPhotoSnapshot}=await load();await assert.rejects(createPhotoSnapshot(new Blob(['fake-heic'],{type:'image/heic'}),1,h.abort.signal),/HEIC.*JPEG.*PNG/);assert.equal(h.revoked.length,1);}finally{h.restore();}});
test('abort during fallback image decode revokes source URL without waiting for onload',async()=>{const h=imageHarness();let image;globalThis.createImageBitmap=async()=>{throw new Error('resize unsupported');};globalThis.Image=class{constructor(){image=this;}set src(v){this._src=v;}removeAttribute(){this._src='';}};try{const{createPhotoSnapshot}=await load(),pending=createPhotoSnapshot(png(4000,3000),1,h.abort.signal);while(!image)await new Promise(r=>setImmediate(r));h.abort.abort();await assert.rejects(pending,{name:'AbortError'});assert.equal(h.revoked.length,1);assert.equal(image._src,'');assert.equal(image.onload,null);}finally{h.restore();}});
test('browser silently ignoring bitmap resize options uses bounded canvas fallback',async()=>{const h=imageHarness();let closes=0,calls=0;globalThis.createImageBitmap=async(source)=>{calls++;const canvas=!(source instanceof Blob)&&source.getContext;return{width:canvas?source.width:4000,height:canvas?source.height:3000,close(){closes++;}};};try{const{createPhotoSnapshot}=await load();const result=await createPhotoSnapshot(png(4000,3000),1,h.abort.signal);assert.deepEqual([result.width,result.height],[1024,768]);assert.equal(calls,3);assert.equal(closes,2);result.bitmap.close();assert.equal(closes,3);}finally{h.restore();}});
// Retained original-photo coverage previously colocated with the retired cutout provider.
const tick=async()=>{for(let i=0;i<8;i++)await Promise.resolve();};
test('photo snapshot applies EXIF once then constrains canonical longest edge',async()=>{const original=globalThis.createImageBitmap;const calls=[];let closed=0;globalThis.createImageBitmap=async(source,options)=>{calls.push(options);return calls.length===1?{width:1600,height:800,close(){closed++;}}:{width:1024,height:512,close(){closed++;}};};try{const {createPhotoSnapshot}=await loadTypescript(new URL('../src/input/PhotoSnapshot.ts',import.meta.url));const result=await createPhotoSnapshot({},2,new AbortController().signal);assert.equal(result.width,1024);assert.equal(result.height,512);assert.equal(calls[0].imageOrientation,'from-image');assert.equal(calls[1].resizeWidth,1024);assert.equal(closed,1);}finally{globalThis.createImageBitmap=original;}});
test('snapshot abort during resize closes decoded and resized bitmaps exactly once',async()=>{const old=globalThis.createImageBitmap;let resolveResize,decodedClosed=0,resizedClosed=0;let count=0;globalThis.createImageBitmap=async()=>{if(++count===1)return{width:1600,height:800,close(){decodedClosed++;}};return new Promise(resolve=>{resolveResize=resolve;});};try{const {createPhotoSnapshot}=await loadTypescript(new URL('../src/input/PhotoSnapshot.ts',import.meta.url));const abort=new AbortController(),task=createPhotoSnapshot({},2,abort.signal),rejected=assert.rejects(task,{name:'AbortError'});await tick();abort.abort();resolveResize({width:1024,height:512,close(){resizedClosed++;}});await rejected;assert.equal(decodedClosed,1);assert.equal(resizedClosed,1);}finally{globalThis.createImageBitmap=old;}});
+59
View File
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import * as THREE from 'three';
import {loadTypescript} from './support/load-typescript.mjs';
import {loadFrameGeometry} from './support/load-frame-geometry.mjs';
const {prepareHouseAsset}=await loadTypescript(new URL('../src/objects/BlenderHouseSurround.ts',import.meta.url));
const {createPatternedGlassGeometry}=await loadTypescript(new URL('../src/rendering/PatternedGlassGeometry.ts',import.meta.url));
const room=()=>loadFrameGeometry(new URL('../public/assets/window/grandma-house-surround.glb',import.meta.url));
// Regression: the lining's BACK face must clear even the highest translated
// pane vertex, not merely put its front face in front of the pane's base plane.
test('fixed pocket lining clears the actual bumpy pane envelope across shapes and compositions',()=>{
const root=room(),asset=prepareHouseAsset(root);
for(const [width,height] of [[.76,.95],[.15,1.5],[2,.1]])for(const grid of Array.from({length:15},(_,i)=>10+i))for(const curvature of [-1,0,1])for(const wall of [false,true]){
const shape={width,height,grid,curvature},built=createPatternedGlassGeometry(shape);
built.geometry.computeBoundingBox();const front=built.geometry.boundingBox.max.z;
asset.resize(width,height,.6,Math.min(width,height),wall,shape);
const p=root.getObjectByName('HouseRoomShell').geometry.attributes.position;
// Four chamber boxes precede the two pocket-side lining boxes (24 vertices each).
// The top/bottom strips retain their depth: their Y range never overlaps the pane.
let back=Infinity;for(let i=96;i<144;i++)back=Math.min(back,p.getZ(i));
assert.ok(back>front+1e-6,`lining back ${back} must clear pane front ${front}: ${JSON.stringify(shape)}, wall=${wall}`);
const s=Math.min(width,height);assert.ok(Math.abs(p.getX(96)+width/2+.002*s)<1e-6,'inner aperture X unchanged');
for(let i=144;i<p.count;i++){assert.ok(Math.abs(p.getY(i))>height/2,'top/bottom lining stays outside pane Y extent');assert.ok(p.getZ(i)>=-1e-8&&p.getZ(i)<=.002*s+1e-8,'top/bottom reveal seal retains its depth');}
built.geometry.dispose();
}
asset.dispose();
});
test('lining wins pocket rays while leaving the aperture clear through door travel in both room/photo modes',async()=>{
const {fitWindowScale,prepareFrameAsset}=await loadTypescript(new URL('../src/objects/BlenderWindowFrame.ts',import.meta.url));
const {peekingLayout}=await loadTypescript(new URL('../src/objects/PeekingComposition.ts',import.meta.url));
let checked=0;
for(const aspect of [.46,2.16])for(const photoMode of ['original','courtyard'])for(const wall of [false,true]){
const fit=fitWindowScale(aspect,.8)*.82,layout=photoMode==='courtyard'?peekingLayout(aspect,.8):{width:fit*.8*.8,height:fit*.8};
const {width:w,height:h}=layout,s=Math.min(w,h),root=room(),asset=prepareHouseAsset(root),frame=loadFrameGeometry(),sash=prepareFrameAsset(frame);
const shape={width:w,height:h,grid:10,curvature:1},built=createPatternedGlassGeometry(shape),pane=new THREE.Mesh(built.geometry,new THREE.MeshBasicMaterial());pane.name='pane';pane.position.z=.6;
asset.resize(w,h,.6,layout.clearanceScale,wall,shape);sash.resize(w,h,.6);
const shell=root.getObjectByName('HouseRoomShell');
for(const open of [0,.5,1]){
sash.setOpen(open);pane.position.x=-w*open;pane.updateMatrixWorld(true);frame.updateMatrixWorld(true);
for(const oblique of [-.5,0,.5])for(let i=0;i<48;i++){
const target=new THREE.Vector3(-w/2-.02*s,(-.46+i/47*.92)*h,.6+.005*s);
const origin=target.clone().add(new THREE.Vector3(oblique*s,0,s));
const ray=new THREE.Raycaster(origin,target.clone().sub(origin).normalize(),0,2*s);
const lining=ray.intersectObject(shell)[0];assert.ok(lining,'pocket lining remains solid');
const glass=ray.intersectObject(pane).find(hit=>hit.point.x>=-w/2-.05*s);
assert.ok(!glass||lining.distance<glass.distance,'no patterned pane can punch through fixed lining');checked++;
}
// A perpendicular center ray must reach the unchanged aperture, not plaster.
const aperture=new THREE.Raycaster(new THREE.Vector3(0,0,.6+s),new THREE.Vector3(0,0,-1),0,s+.02*s);
assert.equal(aperture.intersectObject(shell).length,0,'lining does not close the aperture');
const frameRay=new THREE.Raycaster(new THREE.Vector3(w/2+.07*s,0,.6+s),new THREE.Vector3(0,0,-1),0,2*s);
const hit=frameRay.intersectObject(frame,true).find(h=>!(h.object.material.clippingPlanes??[]).some(p=>p.distanceToPoint(h.point)<0));
assert.ok(hit,'fixed front wood retains ordinary occlusion');
}
pane.geometry.dispose();pane.material.dispose();asset.dispose();sash.dispose();
}
console.log('Pocket lining CPU rays:',checked);
});
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import * as THREE from 'three';
import {loadTypescript} from './support/load-typescript.mjs';
const {CameraController}=await loadTypescript(new URL('../src/input/CameraController.ts',import.meta.url));
function pose(aspect,x,y=0){const camera=new THREE.PerspectiveCamera(40,aspect,.1,100),control=new CameraController(camera);control.setCompositionMode('wall');control.setRoomBounds(.76096,.9512,.6);control.setView(x,y);control.update(10,1);camera.updateMatrixWorld(true);return {camera,control};}
test('portrait keeps neutral and vertical-only views but widens late horizontal composition',()=>{
for(const y of [-1,0,1]){const p=pose(.46,0,y).camera,l=pose(2.2,0,y).camera;assert.deepEqual(p.position.toArray(),l.position.toArray());assert.deepEqual(p.quaternion.toArray(),l.quaternion.toArray());assert.equal(p.fov,40);}
const p=pose(.46,1).camera,l=pose(2.2,1).camera;assert.ok(p.position.z>l.position.z+.1,'portrait retreats');assert.ok(p.fov>40&&p.fov<=65,'bounded wider lens');
});
test('portrait compensation is mirrored, continuous, and preserves anchored scene zoom',()=>{
const a=pose(.46,-1).camera,b=pose(.46,1).camera;assert.ok(Math.abs(a.position.x+b.position.x)<1e-12);assert.equal(a.position.z,b.position.z);assert.equal(a.fov,b.fov);
const {camera,control}=pose(.46,0);camera.zoom=2;camera.updateProjectionMatrix();camera.projectionMatrix.elements[8]=.2;camera.projectionMatrix.elements[9]=-.3;camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
let previous=camera.position.clone(),fov=40;
for(let i=1;i<=100;i++){control.setView(i/100,0);control.update(10,1);assert.ok(camera.position.distanceTo(previous)<.15);assert.ok(camera.fov>=fov-1e-12);assert.equal(camera.zoom,2);assert.equal(camera.projectionMatrix.elements[8],.2);assert.equal(camera.projectionMatrix.elements[9],-.3);const product=new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix,camera.projectionMatrixInverse);if(i>35)assert.ok(product.elements.every((v,j)=>Math.abs(v-(j%5===0?1:0))<1e-10));previous.copy(camera.position);fov=camera.fov;}
assert.ok(fov>40);control.reset();control.update(0,1);assert.equal(camera.fov,40);assert.deepEqual(camera.position.toArray(),[0,0,3]);
});
test('portrait endpoints show aperture center and unoccluded opposite artwork across photo ratios',async()=>{
const {loadFrameGeometry}=await import('./support/load-frame-geometry.mjs');
const {prepareHouseAsset}=await loadTypescript(new URL('../src/objects/BlenderHouseSurround.ts',import.meta.url));
const {fitWindowScale}=await loadTypescript(new URL('../src/objects/BlenderWindowFrame.ts',import.meta.url));
for(const aspect of [.46,.5,.56,.7])for(const photo of [.2,.56,.8,1,2,5])for(const side of [-1,1]){
const fit=fitWindowScale(aspect,photo)*.82,w=fit*photo*.8,h=fit*.8;
const root=loadFrameGeometry(new URL('../public/assets/window/grandma-house-surround.glb',import.meta.url)),house=prepareHouseAsset(root);house.resize(w,h,.6,Math.min(w,h),true);
const c=new THREE.PerspectiveCamera(40,aspect,.1,100),ctrl=new CameraController(c);ctrl.setCompositionMode('wall');ctrl.setRoomBounds(w,h,.6);ctrl.setView(side,0);ctrl.update(10,1);c.updateMatrixWorld(true);
const door=new THREE.Vector3(0,0,.6).project(c),picture=root.getObjectByName(side>0?'RoomPictureLeft':'RoomPictureRight');
assert.ok(Math.abs(door.x)<.9&&Math.abs(door.y)<.85,'aperture center remains visible');
for(const x of [-.19,0,.19])for(const y of [-.23,0,.23]){
const world=picture.localToWorld(new THREE.Vector3(x,y,.024)),p=world.clone().project(c);assert.ok(Math.abs(p.x)<1&&Math.abs(p.y)<1,JSON.stringify({aspect,photo,side,p:p.toArray()}));
}
const center=picture.localToWorld(new THREE.Vector3(0,0,.028)),ray=new THREE.Raycaster(c.position,center.clone().sub(c.position).normalize());assert.equal(ray.intersectObject(root,true)[0]?.object,picture,'artwork is not hidden behind the wall return');
house.dispose();
}
});
test('portrait rotation compensation is reversible and smooth at both aspect boundaries',()=>{
const {camera,control}=pose(.46,1);const original={p:camera.position.toArray(),q:camera.quaternion.toArray(),fov:camera.fov};
for(const boundary of [.7,1]){camera.aspect=boundary-1e-5;control.update(0,1);const p=camera.position.clone(),q=camera.quaternion.clone(),f=camera.fov;camera.aspect=boundary+1e-5;control.update(0,1);assert.ok(camera.position.distanceTo(p)<.001);assert.ok(camera.quaternion.angleTo(q)<.001);assert.ok(Math.abs(camera.fov-f)<.001);}
camera.aspect=2.2;control.update(0,1);assert.equal(camera.fov,40);camera.aspect=.46;control.update(0,1);assert.deepEqual(camera.position.toArray(),original.p);assert.deepEqual(camera.quaternion.toArray(),original.q);assert.equal(camera.fov,original.fov);
});
+8 -7
View File
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises';
const shader = await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8');
const paletteShader = await readFile(new URL('../src/shaders/region-palette.frag', import.meta.url), 'utf8');
const historyShader = await readFile(new URL('../src/shaders/region-history.frag', import.meta.url), 'utf8');
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8') + await readFile(new URL('../src/rendering/OriginalGlassRenderer.ts',import.meta.url),'utf8');
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
assert.match(main, /uGridSize: \{ value: GOBANG_GLASS.grid \}/);
assert.match(main, /uStrength: \{ value: 1 \}/);
@@ -14,8 +14,9 @@ assert.match(main, /uRegionPaletteSize: \{ value: new THREE\.Vector2\(65, 13\) \
assert.match(main, /new THREE\.WebGLRenderTarget\(65, 13/);
assert.match(main, /paletteWidth = gridX \* 5/);
assert.match(html, /id="grid-size" type="range" min="10" max="24" step="1" value="20"/);
assert.match(html, /id="curvature" type="range" min="-1" max="1" step="0\.01" value="1"/);
assert.match(html, /id="gloss-strength" type="range" min="0" max="0\.4" step="0\.01" value="0\.30"/);
assert.match(html, /<select id="curvature">/);
assert.doesNotMatch(html, /id="gloss-strength"|id="glossOut"/);
assert.match(main, /uGlossStrength: \{ value: 0\.3 \}/);
assert.match(html, /id="region-fixed-note"/);
assert.doesNotMatch(html, /id="region-strength"|id="strength"|id="strengthOut"/);
assert.doesNotMatch(main, /strengthInput|strengthOutput|regionStrengthInput|regionStrengthOutput|region-strength|strengthOut/);
@@ -23,10 +24,10 @@ for (const [, id] of main.matchAll(/document\.querySelector\('#([^']+)'\)/g)) {
assert.ok(html.includes(`id="${id}"`), `main DOM reference #${id} must exist in index.html`);
}
assert.match(main, /uniforms\.uGridSize\.value = style === 0 \? 13 : style === 2 \? GOBANG_GLASS.grid : 24; uniforms\.uRegionStrength\.value = style === 0 \? 1 : 0; uniforms\.uStrength\.value = 1/);
assert.match(main, /setMode\('final'\);\s*status\.textContent = automatic \?/);
assert.match(html, /<details>\s*<summary>사진 구도<\/summary>/);
assert.match(html, /<details>\s*<summary>개발 비교<\/summary>/);
assert.match(html, /<summary>개발 비교<\/summary>[\s\S]*id="video"[\s\S]*id="depth-image"[\s\S]*id="view-mode"[\s\S]*id="effect"[\s\S]*id="depth-scale"[\s\S]*id="light-x"[\s\S]*id="sensitivity"/);
assert.match(main, /setMode\('final'\);\s*if \(!renderLost && !meshFailure && arrivalToken !== null && arrivalToken === photoArrivalToken\) arrivalTracker\?\.arrived\(arrivalToken\);\s*experience\.setState\('ready'\);\s*setStatus\(automatic \?/);
assert.match(html, /id="panel-photo"[^>]*role="tabpanel"[\s\S]*사진 구도/);
assert.match(html, /<details id="developer-tools"><summary>개발 도구/);
assert.match(html, /<details id="developer-tools">[\s\S]*id="video"[\s\S]*id="depth-image"[\s\S]*id="view-mode"[\s\S]*id="effect"[\s\S]*id="depth-scale"[\s\S]*id="light-x"[\s\S]*id="sensitivity"/);
assert.match(shader, /cell\.x\*5\.\+index/);
assert.match(shader, /for\(int y=-1;y<=1;y\+\+\)for\(int x=-1;x<=1;x\+\+\)/);
assert.match(shader, /candidateColor=mix\(radialColor,splitColor,splitHistory\.a\)/);
+129
View File
@@ -0,0 +1,129 @@
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<frames;i++){now+=1000/60;context.runFrame(now);}},change(){context.depthUniforms.uDepthScale.value+=.001;signatureRevision++;}};
}
test('actual main frame submits no GPU render calls during ten seconds of static B idle',()=>{
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);
});
+62
View File
@@ -0,0 +1,62 @@
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/rendering/RenderDemand.ts',import.meta.url),'utf8');
const output=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ES2022}}).outputText;
const {RenderDemand}=await import(`data:text/javascript;base64,${Buffer.from(output).toString('base64')}`);
const state=(signature='still',continuous=false,temporal=false)=>({signature,continuous,temporal});
test('static B submits once, then zero renders across ten seconds of idle RAFs',()=>{
const demand=new RenderDemand();
assert.equal(demand.shouldRender(1/60,state()),true);
let submitted=0;
for(let i=0;i<600;i++)if(demand.shouldRender(1/60,state()))submitted++;
assert.equal(submitted,0);
});
test('input signatures and asset invalidation resume immediately and coalesce duplicate events',()=>{
const demand=new RenderDemand();demand.shouldRender(1/60,state());
assert.equal(demand.shouldRender(1/60,state('new camera')),true);
assert.equal(demand.shouldRender(1/60,state('new camera')),false);
for(const reason of ['asset','depth','resize','theme','recovery','renderer retry']){
demand.invalidate();demand.invalidate();
assert.equal(demand.shouldRender(1/60,state('new camera')),true,reason);
assert.equal(demand.shouldRender(1/60,state('new camera')),false,reason);
}
});
test('mechanical animation keeps every requested frame and stops at its exact final pose',()=>{
const demand=new RenderDemand();
for(let i=0;i<240;i++)assert.equal(demand.shouldRender(1/60,state('still',true)),true);
assert.equal(demand.shouldRender(1/60,state()),false);
assert.equal(demand.shouldRender(1/60,state('final animation position')),true);
assert.equal(demand.shouldRender(1/60,state('final animation position')),false);
});
test('A history settles for ten existing 70ms time constants and then stops',()=>{
const demand=new RenderDemand();
let submitted=0;
for(let i=0;i<600;i++)if(demand.shouldRender(1/60,state('a',false,true)))submitted++;
assert.ok(submitted>=42&&submitted<=44,`expected bounded settling, got ${submitted}`);
assert.ok(Math.exp(-submitted/60/.07)<.0001,'remaining history contribution below 1e-4');
assert.equal(demand.shouldRender(1/60,state('a',false,true)),false);
assert.equal(demand.shouldRender(1/60,state('new a view',false,true)),true);
});
test('continuous A video resets history settling and B never inherits its tail',()=>{
const demand=new RenderDemand();
for(let i=0;i<90;i++)demand.shouldRender(1/60,state('a',true,true));
let tail=0;for(let i=0;i<120;i++)if(demand.shouldRender(1/60,state('a',false,true)))tail++;
assert.ok(tail>=41&&tail<=43);
assert.equal(demand.shouldRender(1/60,state('b')),true);
assert.equal(demand.shouldRender(1/60,state('b')),false);
});
test('invalid and long frame deltas cannot discard temporal work',()=>{
for(const dt of [NaN,-1,Infinity,100]){
const demand=new RenderDemand();assert.equal(demand.shouldRender(dt,state('a',false,true)),true);
assert.equal(demand.shouldRender(1/60,state('a',false,true)),true);
}
});
+65
View File
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import ts from 'typescript';
const awaitSource = await readFile(new URL('../src/rendering/OriginalGlassRenderer.ts',import.meta.url),'utf8');
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
test('the scene has explicit context-loss and restoration handling', () => {
assert.match(main, /new RenderRecovery\(/, 'a lost WebGL context must not leave the UI ready over a white canvas');
});
async function harness({ restoreThrows = false } = {}) {
const source = await readFile(new URL('../src/ui/RenderRecovery.ts', import.meta.url), 'utf8');
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ES2020, target: ts.ScriptTarget.ES2020 } }).outputText;
const { RenderRecovery } = await import(`data:text/javascript;base64,${Buffer.from(output).toString('base64')}`);
class Element extends EventTarget {
style = { visibility: '' }; open = false; focusCount = 0;
showModal() { this.open = true; }
close() { this.open = false; }
focus() { this.focusCount++; }
}
const canvas = new Element(), dialog = new Element(), button = new Element();
let losses = 0, restores = 0, reloads = 0;
const recovery = new RenderRecovery(canvas, dialog, button, () => { losses++; }, () => {
restores++; if (restoreThrows) throw new Error('restore failed');
}, () => { reloads++; });
const emit = type => { const event = new Event(type, { cancelable: true }); canvas.dispatchEvent(event); return event; };
return { recovery, canvas, dialog, button, emit, get losses() { return losses; }, get restores() { return restores; }, get reloads() { return reloads; } };
}
test('loss is visible and blocks dismissal; duplicate events do not restart work or reload', async () => {
const h = await harness();
assert.equal(h.recovery.lost, false);
assert.equal(h.emit('webglcontextlost').defaultPrevented, true);
assert.equal(h.recovery.lost, true);
assert.equal(h.canvas.style.visibility, 'hidden');
assert.equal(h.dialog.open, true);
h.emit('webglcontextlost'); assert.equal(h.losses, 1); assert.equal(h.reloads, 0);
const cancel = new Event('cancel', { cancelable: true }); h.dialog.dispatchEvent(cancel);
assert.equal(cancel.defaultPrevented, true);
h.button.dispatchEvent(new Event('click')); assert.equal(h.reloads, 1);
h.recovery.dispose();
});
test('restoration resets app resources before revealing the same canvas and tolerates another loss', async () => {
const h = await harness();
h.emit('webglcontextrestored'); assert.equal(h.restores, 0);
h.emit('webglcontextlost'); h.emit('webglcontextrestored');
assert.equal(h.restores, 1); assert.equal(h.recovery.lost, false);
assert.equal(h.canvas.style.visibility, ''); assert.equal(h.dialog.open, false); assert.equal(h.reloads, 0);
h.emit('webglcontextlost'); assert.equal(h.losses, 2);
h.recovery.dispose(); h.emit('webglcontextrestored'); assert.equal(h.restores, 1);
});
test('failed app restoration keeps recovery visible without retry loops', async () => {
const h = await harness({ restoreThrows: true });
h.emit('webglcontextlost'); h.emit('webglcontextrestored');
assert.equal(h.recovery.lost, true); assert.equal(h.dialog.open, true);
assert.equal(h.canvas.style.visibility, 'hidden'); assert.equal(h.reloads, 0);
h.recovery.dispose();
});
test('GPU work pauses during inference, and restored render targets invalidate cached history', () => {
assert.match(main, /if \(renderLost \|\| aiDepthBusy \|\| document\.hidden\)/);
assert.match(main, /originalRenderer\?\.invalidate\(\); meshRenderer\.restore\(\)/);
const original=awaitSource;
assert.match(original, /regionSignature = ''; splitHistorySignature = ''; splitHistoryInitialized = false/);
assert.match(main, /renderRecovery\.dispose\(\)/);
});
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
/**
* Independent CPU counterpart of MeshRefraction.ts's bRevealTriangle traversal.
* Compares its hit/miss and world-distance results against Three's Raycaster on
* the actual WindowReveal geometry. This does not execute GLSL or validate GPU
* rendering, texture color, or the photo/reveal nearest-hit selection.
*
* Run from the repository root:
* node scripts/test-reveal-intersections.mjs
* Or supply the repository root:
* node scripts/test-reveal-intersections.mjs /path/to/behind_glass
*/
import assert from 'node:assert/strict';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const root = path.resolve(process.argv[2] ?? process.cwd());
const THREE = await import(pathToFileURL(path.join(root, 'node_modules/three/build/three.module.js')).href);
const { WindowReveal } = await import(pathToFileURL(path.join(root, 'src/objects/WindowReveal.ts')).href);
const material = new THREE.MeshStandardMaterial({ side: THREE.DoubleSide });
const reveal = new WindowReveal(material);
reveal.update(.928, 1.16, 1.45 * .8 * (3 - .2204) / 3, 1.45 * (3 - .2204) / 3, .6, .2204);
const vertices = reveal.meshes.flatMap(mesh => Array.from({ length: 4 }, (_, i) =>
new THREE.Vector3().fromBufferAttribute(mesh.geometry.attributes.position, i)));
// Independent arithmetic equivalent to the bounded DoubleSide GLSL triangle test.
function triangleDistance(origin, ray, a, b, c) {
const e1 = b.clone().sub(a);
const e2 = c.clone().sub(a);
const p = ray.clone().cross(e2);
const determinant = e1.dot(p);
if (Math.abs(determinant) < 1e-9) return 1e20;
const delta = origin.clone().sub(a);
const u = delta.dot(p) / determinant;
const q = delta.clone().cross(e1);
const v = ray.dot(q) / determinant;
if (u < -.00001 || v < -.00001 || u + v > 1.00001) return 1e20;
const t = e2.dot(q) / determinant;
return t < .000001 ? 1e20 : t;
}
let seed = 17;
const random = () => ((seed = Math.imul(seed, 1664525) + 1013904223 | 0) >>> 0) / 4294967296;
let matched = 0;
let hits = 0;
let maxError = 0;
try {
for (let i = 0; i < 10000; i++) {
const origin = new THREE.Vector3((random() - .5) * .928, (random() - .5) * 1.16, .588);
const ray = new THREE.Vector3(random() - .5, random() - .5, -random() - .1).normalize();
let distance = 1e20;
for (let wall = 0; wall < 4; wall++) {
const a = wall * 4;
distance = Math.min(distance,
triangleDistance(origin, ray, vertices[a], vertices[a + 1], vertices[a + 2]),
triangleDistance(origin, ray, vertices[a], vertices[a + 2], vertices[a + 3]));
}
const expected = new THREE.Raycaster(origin, ray).intersectObjects(reveal.meshes, false)[0];
assert.equal(distance < 1e19, Boolean(expected), `Hit/miss mismatch at deterministic ray ${i}`);
if (expected) {
hits++;
const error = Math.abs(distance - expected.distance);
maxError = Math.max(maxError, error);
assert.ok(error <= 1e-9, `Distance mismatch ${error} at deterministic ray ${i}`);
}
matched++;
}
console.log(JSON.stringify({ matched, hits, misses: matched - hits, maxDistanceError: maxError }));
} finally {
reveal.dispose();
material.dispose();
}
+47
View File
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import * as THREE from 'three';
import {loadTypescript} from './support/load-typescript.mjs';
import {loadFrameGeometry} from './support/load-frame-geometry.mjs';
const {CameraController}=await loadTypescript(new URL('../src/input/CameraController.ts',import.meta.url));
const {prepareHouseAsset,houseRoomBounds}=await loadTypescript(new URL('../src/objects/BlenderHouseSurround.ts',import.meta.url));
const {fitWindowScale}=await loadTypescript(new URL('../src/objects/BlenderWindowFrame.ts',import.meta.url));
test('horizontal endpoints compose perpendicular room wall, picture, floor and side-positioned door',()=>{
for(const aspect of[375/812,844/390,1.6])for(const photoAspect of[.2,.5,.8,1,2,5])for(const range of[.75,.9])for(const side of[-1,1]){
const fit=fitWindowScale(aspect,photoAspect)*.82,w=fit*photoAspect*.8,h=fit*.8,s=Math.min(w,h),bounds=houseRoomBounds(w,h,.6);
const root=loadFrameGeometry(new URL('../public/assets/window/grandma-house-surround.glb',import.meta.url)),room=prepareHouseAsset(root);room.resize(w,h,.6);
const camera=new THREE.PerspectiveCamera(40,aspect,.1,100),control=new CameraController(camera);control.setRoomBounds(w,h,.6);control.setMaxOffset(range);control.setView(side,0);control.update(10,1);camera.updateMatrixWorld(true);
const door=new THREE.Vector3(0,0,.6).project(camera);
assert.ok(Math.abs(door.x)>.25&&Math.abs(door.x)<.95,JSON.stringify({aspect,photoAspect,range,side,door:door.toArray()}));
const picture=root.getObjectByName(side>0?'RoomPictureLeft':'RoomPictureRight');assert.ok(picture,'static frame belongs on perpendicular wall');
const screen=picture.getWorldPosition(new THREE.Vector3()).project(camera);assert.ok(Math.abs(screen.x)<.95&&Math.abs(screen.y)<.8,'wall picture is in viewport');assert.ok(screen.x*door.x<0,'wall artwork and door occupy opposite sides');
for(const [px,py] of[[0,0],[-.15,-.19],[.15,.19],[-.15,.19],[.15,-.19]]){const point=picture.localToWorld(new THREE.Vector3(px,py,.024)),ndc=point.clone().project(camera);assert.ok(Math.abs(ndc.x)<1&&Math.abs(ndc.y)<1,'frame corners in viewport');const ray=new THREE.Raycaster();ray.setFromCamera(new THREE.Vector2(ndc.x,ndc.y),camera);assert.equal(ray.intersectObject(root,true)[0]?.object,picture,'artwork is first visible surface');}
let wall=0,floor=0;for(let x=-.9;x<=.91;x+=.15)for(let y=-.9;y<=.91;y+=.15){const ray=new THREE.Raycaster();ray.setFromCamera(new THREE.Vector2(x,y),camera);const hit=ray.intersectObject(root,true)[0];if(!hit)continue;if(Math.abs(Math.abs(hit.point.x)-bounds.halfWidth)<.002)wall++;if(Math.abs(hit.point.y-bounds.floor)<.002)floor++;}
assert.ok(wall>18,'broad visible perpendicular wall, not a strip');assert.ok(floor>=10,JSON.stringify({aspect,photoAspect,range,side,wall,floor}));
control.reset();control.update(10,1);assert.equal(camera.fov,40);assert.deepEqual(camera.position.toArray(),[0,0,3]);room.dispose();
}
});
test('lens and camera remain continuous across viewport rotation while anchored zoom is preserved',async()=>{
const {SceneZoomController}=await loadTypescript(new URL('../src/input/SceneZoomController.ts',import.meta.url));
const camera=new THREE.PerspectiveCamera(40,.9999,.1,100),control=new CameraController(camera),zoom=new SceneZoomController(camera);control.setRoomBounds(.76,.95,.6);control.setView(1,0);zoom.setZoom(2.5,{x:.5,y:-.4});control.update(10,1);const before=camera.position.clone(),fov=camera.fov,offset=zoom.offset.toArray();camera.aspect=1.0001;zoom.updateProjection();control.update(10,1);assert.ok(camera.position.distanceTo(before)<.001);assert.ok(Math.abs(camera.fov-fov)<.01);assert.deepEqual(zoom.offset.toArray(),offset);assert.equal(camera.zoom,2.5);
const matrix=new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix,camera.projectionMatrixInverse);assert.ok(matrix.elements.every((v,i)=>Math.abs(v-new THREE.Matrix4().elements[i])<1e-10));control.reset();control.update(10,1);assert.equal(camera.fov,40);assert.equal(camera.zoom,2.5);assert.deepEqual(zoom.offset.toArray(),offset);
});
test('original and courtyard rooms contain zoomed anchored near frusta through open/partial/closed doors',async()=>{
const {SceneZoomController}=await loadTypescript(new URL('../src/input/SceneZoomController.ts',import.meta.url));
const {prepareFrameAsset}=await loadTypescript(new URL('../src/objects/BlenderWindowFrame.ts',import.meta.url));
const {peekingLayout}=await loadTypescript(new URL('../src/objects/PeekingComposition.ts',import.meta.url));
let rays=0;
for(const aspect of[375/812,844/390])for(const mode of['original','courtyard'])for(const photoAspect of[.2,.8,5]){
const fit=fitWindowScale(aspect,photoAspect)*.82,layout=mode==='courtyard'?peekingLayout(aspect,photoAspect):{width:fit*photoAspect*.8,height:fit*.8},w=layout.width,h=layout.height,bounds=houseRoomBounds(w,h,.6);
const root=loadFrameGeometry(new URL('../public/assets/window/grandma-house-surround.glb',import.meta.url)),room=prepareHouseAsset(root),frame=loadFrameGeometry(),sash=prepareFrameAsset(frame),group=new THREE.Group();group.add(root,frame);room.resize(w,h,.6,layout.clearanceScale);sash.resize(w,h,.6);
const camera=new THREE.PerspectiveCamera(40,aspect,.1,100),control=new CameraController(camera),zoom=new SceneZoomController(camera);control.setRoomBounds(w,h,.6);
for(const range of[.75,.9])for(const open of[0,.5,1])for(const [x,y]of[[-1,0],[1,0],[-1,-1],[1,1],[0,-1]])for(const z of[1,2,3]){
control.setMaxOffset(range);control.setView(x,y);control.update(10,1);sash.setOpen(open);zoom.setZoom(1);zoom.setZoom(z,{x:x,y:y||1});camera.updateMatrixWorld(true);group.updateMatrixWorld(true);
for(const px of[-1,0,1])for(const py of[-1,0,1]){const near=new THREE.Vector3(px,py,-1).unproject(camera);assert.ok(near.y>bounds.floor-1e-8&&Math.abs(near.x)<bounds.halfWidth&&near.z<bounds.rear,'zoomed near frustum stays in chamber');const ray=new THREE.Raycaster();ray.setFromCamera(new THREE.Vector2(px,py),camera);const hit=ray.intersectObject(group,true).find(h=>!(h.object.material.clippingPlanes??[]).some(p=>p.distanceToPoint(h.point)<0));if(hit)assert.ok(-hit.point.clone().applyMatrix4(camera.matrixWorldInverse).z>=camera.near-1e-7,'nearest room/frame surface stays beyond near plane');rays++;}
}
room.dispose();sash.dispose();
}
console.log('Anchored original/courtyard near-frustum rays:',rays);
});
+56
View File
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import * as THREE from 'three';
import {loadTypescript} from './support/load-typescript.mjs';
import {loadFrameGeometry} from './support/load-frame-geometry.mjs';
const {CameraController}=await loadTypescript(new URL('../src/input/CameraController.ts',import.meta.url));
const {prepareHouseAsset}=await loadTypescript(new URL('../src/objects/BlenderHouseSurround.ts',import.meta.url));
const room=()=>loadFrameGeometry(new URL('../public/assets/window/grandma-house-surround.glb',import.meta.url));
test('default room gesture reaches an actual wall-side viewpoint and stays above the floor',()=>{
const c=new THREE.PerspectiveCamera(40,1.4,.1,100),control=new CameraController(c);
assert.equal(typeof control.setRoomBounds,'function');control.setRoomBounds(.76096,.9512,.6);
for(const x of [-1,1]){control.setView(x,0);control.update(10,1);c.updateMatrixWorld(true);assert.ok(c.position.z>1.25,'corner view leaves enough room to see the wall');assert.ok(Math.abs(new THREE.Vector3(0,0,.6).project(c).x)>.25,'door is shifted to the side');}
control.setView(0,-1);control.update(10,1);assert.ok(c.position.y>-.6582304+.1,'eye cannot go under actual floor');assert.ok(Math.atan2(-c.position.y,c.position.z-.6)>Math.PI/8,'lower viewpoint is more oblique than old fixed-z lean');
control.reset();control.update(.016,1);assert.deepEqual(c.position.toArray(),[0,0,3]);
});
test('floor continues underneath the eye and the surrounding chamber closes side, ceiling and rear views',()=>{
const root=room(),asset=prepareHouseAsset(root);asset.resize(.76096,.9512,.6);root.updateMatrixWorld(true);
const origin=new THREE.Vector3(0,0,3);
for(const direction of [[0,-1,0],[1,0,0],[-1,0,0],[0,1,0],[0,0,1]]){
const hits=new THREE.Raycaster(origin,new THREE.Vector3(...direction),.1,20).intersectObject(root,true);assert.ok(hits.length,'room is closed in '+direction);
}
const floor=new THREE.Raycaster(origin,new THREE.Vector3(0,-1,0),.1,20).intersectObject(root,true)[0];assert.ok(Math.abs(floor.point.y+.6582304)<1e-6);asset.dispose();
});
test('room path preserves pinch anchor, range selection, diagonal continuity and reset',async()=>{
const {SceneZoomController}=await loadTypescript(new URL('../src/input/SceneZoomController.ts',import.meta.url));
const camera=new THREE.PerspectiveCamera(40,.46,.1,100),control=new CameraController(camera),zoom=new SceneZoomController(camera);
control.setRoomBounds(.5,.1,.6);zoom.setZoom(2.2,{x:.4,y:-.3});const centers=[camera.projectionMatrix.elements[8],camera.projectionMatrix.elements[9]],offset=zoom.offset.toArray();
control.setRoomBounds(.5,.05,.6);assert.deepEqual([camera.projectionMatrix.elements[8],camera.projectionMatrix.elements[9]],centers);assert.ok(camera.near<.1);assert.ok(new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix,camera.projectionMatrixInverse).equals(new THREE.Matrix4())||new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix,camera.projectionMatrixInverse).elements.every((v,i)=>Math.abs(v-new THREE.Matrix4().elements[i])<1e-12));
const projection=camera.projectionMatrix.toArray();
for(const range of[.35,.65,.75,.9]){control.setMaxOffset(range);control.setView(1,-1);control.update(10,2);const pose=camera.position.clone();control.hold();control.update(.016,2);assert.ok(camera.position.distanceTo(pose)<1e-12);control.reset();control.update(.016,1);assert.deepEqual(camera.position.toArray(),[0,0,3]);}
assert.deepEqual(camera.projectionMatrix.toArray(),projection);assert.deepEqual(zoom.offset.toArray(),offset);assert.equal(zoom.value,2.2);
control.setRoomBounds(.5,.1,.6,false);assert.equal(camera.near,.1);control.setView(1,-1);control.update(10,1);assert.deepEqual(camera.position.toArray(),[.9,-.9,3]);
});
test('room geometry is stable across repeated extreme resizes and preserves all original textures/colors',()=>{
const root=room(),colors=new Map();root.traverse(mesh=>{if(mesh.isMesh)colors.set(mesh.name,[...mesh.geometry.attributes.color.array]);});const asset=prepareHouseAsset(root);
const snapshots=()=>{const result=[];root.traverse(mesh=>{if(mesh.isMesh)result.push([mesh.name,[...mesh.geometry.attributes.position.array]]);});return result;};
asset.resize(.76,.95,.6);const original=snapshots();
for(const[w,h]of[[.05,1],[2,.05],[.1,3],[3,.1],[.76,.95]]){asset.resize(w,h,.6);root.traverse(mesh=>{if(!mesh.isMesh)return;for(const name of['position','normal','tangent']){const a=mesh.geometry.attributes[name];if(a)assert.ok(a.array.every(Number.isFinite));}if(colors.has(mesh.name))assert.deepEqual([...mesh.geometry.attributes.color.array],colors.get(mesh.name));});}
assert.deepEqual(snapshots(),original);asset.dispose();
});
test('fixed aperture lining seals top and bottom openings when the sash is fully in its pocket',async()=>{
const {prepareFrameAsset,fitWindowScale}=await loadTypescript(new URL('../src/objects/BlenderWindowFrame.ts',import.meta.url));
const {WindowReveal}=await loadTypescript(new URL('../src/objects/WindowReveal.ts',import.meta.url));
const w=fitWindowScale(375/812,5)*.82*.8*5,h=w/5,root=room(),asset=prepareHouseAsset(root),frame=loadFrameGeometry(),fixed=prepareFrameAsset(frame);
asset.resize(w,h,.6);fixed.resize(w,h,.6);fixed.setOpen(1);
const camera=new THREE.PerspectiveCamera(40,375/812,.1,100),control=new CameraController(camera);control.setRoomBounds(w,h,.6);control.setMaxOffset(.9);control.setView(0,-1);control.update(10,1);camera.updateMatrixWorld(true);
const reveal=new WindowReveal(new THREE.MeshBasicMaterial({side:THREE.DoubleSide}));reveal.update(w,h,w*(3-.4704)/2.4,h*(3-.4704)/2.4,.6,.4704);
const photo=new THREE.Mesh(new THREE.PlaneGeometry(w*(3-.4704)/2.4,h*(3-.4704)/2.4),new THREE.MeshBasicMaterial());photo.position.z=.4704;
const scene=new THREE.Scene();scene.add(root,frame,photo,...reveal.meshes);scene.updateMatrixWorld(true);
for(const y of[-.583333333333,.5]){const ray=new THREE.Raycaster();ray.setFromCamera(new THREE.Vector2(0,y),camera);assert.ok(ray.intersectObject(scene,true).length,'uncovered fixed-aperture band at y='+y);}
asset.dispose();fixed.dispose();reveal.dispose();photo.geometry.dispose();photo.material.dispose();
});
test('runtime chamber and fixed lining use only the existing plaster atlas tile',()=>{
const root=room(),asset=prepareHouseAsset(root);asset.resize(.76,.95,.6);const uv=root.getObjectByName('HouseRoomShell').geometry.getAttribute('uv');
for(let i=0;i<uv.count;i++){assert.ok(uv.getX(i)>.005&&uv.getX(i)<.495,'U stays inside plaster left column');assert.ok(uv.getY(i)>.505&&uv.getY(i)<.995,'V stays inside plaster upper row');}asset.dispose();
});
+86
View File
@@ -0,0 +1,86 @@
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';
const source = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
const body = source.slice(source.indexOf('async function loadSample('), source.indexOf("sampleButton?.addEventListener('click'", source.indexOf('async function loadSample(')));
function harness() {
const calls = [], pending = [];
const context = {
renderLost: false, disposed: false, mediaRequest: 0, mediaBusy: false, pendingCutout: null,
pendingPairDepth: null, pendingSampleDebugImage: null, pendingSampleFraming: false,
pendingTestPhotoHasNoDepth: false, testPhotoInput: { value: '' }, sampleButton: { textContent: '' },
resetHintForMedia() { calls.push('hint-reset'); }, peekingUI: { cancel() {} },
invalidateDepthRequests() { calls.push('depth-invalidate'); }, cancelArrivalJourney() {},
beginMediaLoad(origin) { calls.push('loading'); context.mediaOrigin = origin; }, syncAIDepthButton() {}, clearPendingTestState() {},
setStatus(text) { calls.push(['status', text]); }, experience: { setState(state) { calls.push(['state', state]); } },
loader: { cancelPending() { calls.push('cancel-old'); }, load(file) { calls.push(['load', file.name]); } },
fetch(path) { calls.push(['fetch', path]); return new Promise(resolve => pending.push(resolve)); },
File: class { constructor(_, name) { this.name = name; } },
Image: class { async decode() {} },
URL: { createObjectURL() { return 'blob:depth'; }, revokeObjectURL(url) { calls.push(['revoke', url]); } },
sampleDepthProvider: { async getDepthMap() { return { dispose() { calls.push('depth-dispose'); } }; } },
};
vm.createContext(context);
vm.runInContext(ts.transpileModule(body, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText, context);
const respond = (ok = true) => pending.shift()({ ok, async blob() { return {}; }, headers: { get() { return 'image/jpeg'; } } });
return { context, calls, pending, respond };
}
test('confirmed sample uses the shared loader and preserves loading/invalidation and final pipeline', async () => {
const h = harness(), request = h.context.loadSample('dog1');
assert.equal(h.context.testPhotoInput.value, 'dog1');
assert.equal(h.context.mediaOrigin, 'sample', 'shared loader marks sample presentation before asynchronous fetching');
assert.deepEqual(h.calls.slice(0, 4), ['hint-reset', 'depth-invalidate', 'loading', 'cancel-old']);
assert.deepEqual(h.calls.find(c => c[0] === 'fetch'), ['fetch', '/assets/samples/dog1.jpg']);
h.respond(); await request;
assert.deepEqual(h.calls.find(c => c[0] === 'load'), ['load', 'dog1.jpg']);
assert.equal(h.context.pendingTestPhotoHasNoDepth, true);
});
test('superseded sample fetch cannot replace the newer media', async () => {
const h = harness(), first = h.context.loadSample('dog1'), second = h.context.loadSample('dog3');
h.respond(); await first;
assert.equal(h.calls.some(c => c[0] === 'load'), false);
h.respond(); await second;
assert.deepEqual(h.calls.filter(c => c[0] === 'load'), [['load', 'dog3.jpg']]);
});
test('sample failure exposes existing error flow, and render loss or teardown forbids starting', async () => {
const h = harness(), request = h.context.loadSample('dog2'); h.respond(false); await request;
assert.deepEqual(h.calls.find(c => c[0] === 'state'), ['state', 'error']);
assert.equal(h.context.mediaBusy, false);
for (const key of ['renderLost', 'disposed']) {
const blocked = harness(); blocked.context[key] = true; await blocked.context.loadSample('dog1');
assert.deepEqual(blocked.calls, []);
}
});
test('all five samples start without mismatched manual depth', async () => {
for (const id of ['dog1', 'dog2', 'dog3', 'dog4', 'dog5']) {
const h = harness(), request = h.context.loadSample(id);
h.respond(); await request;
assert.deepEqual(h.calls.filter(c => c[0] === 'fetch'), [['fetch', `/assets/samples/${id}.jpg`]]);
assert.equal(h.context.pendingPairDepth, null);
assert.equal(h.context.pendingSampleFraming, false);
assert.equal(h.context.pendingTestPhotoHasNoDepth, true);
}
});
test('unknown sample IDs cannot fetch arbitrary or retired photo paths', async () => {
for (const id of ['sample', '../private', 'dog6']) {
const h = harness(); await h.context.loadSample(id);
assert.equal(h.calls.some(c => c[0] === 'fetch'), false);
}
});
test('returning home invalidates the pending sample response without another load', async () => {
const h = harness(), request = h.context.loadSample('dog1');
const returning = source.slice(source.indexOf('function returnFromSample('), source.indexOf('function beginMediaLoad('));
Object.assign(h.context, { photoArrivalSnapshot: {origin:'sample'}, photoArrivalTexture: {},
cancelDoorInteraction(){}, gyro:{setPaused(){}}, hideHintAfterDrag(){} });
vm.runInContext(ts.transpileModule(returning, { compilerOptions:{target:ts.ScriptTarget.ES2022} }).outputText, h.context);
h.context.returnFromSample(); h.respond(); await request;
assert.equal(h.calls.some(c => c[0] === 'load'), false);
assert.equal(h.context.mediaBusy, false);
assert.equal(h.context.photoArrivalSnapshot, null);
assert.equal(h.context.photoArrivalTexture, null);
});
+74
View File
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { test } from 'node:test';
import vm from 'node:vm';
import ts from 'typescript';
import * as THREE from 'three';
const path = new URL('../src/input/SceneZoomController.ts', import.meta.url);
assert.ok(existsSync(path), 'anchored scene projection controller is required');
const exports = {};
vm.runInNewContext(ts.transpileModule(readFileSync(path, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports, require: () => THREE });
const { SceneZoomController } = exports;
const close = (actual, expected, label = '') => assert.ok(Math.abs(actual - expected) < 1e-10, `${label}: ${actual} != ${expected}`);
const setup = () => {
const camera = new THREE.PerspectiveCamera(40, 1.6, .1, 100);
camera.position.set(.2, -.1, 3); camera.lookAt(0, 0, 0); camera.updateMatrixWorld(true);
return { camera, zoom: new SceneZoomController(camera) };
};
const pointAt = (camera, anchor, depth = .3) => new THREE.Vector3(anchor.x, anchor.y, depth).unproject(camera);
const expectAt = (camera, point, anchor) => { const p = point.clone().project(camera); close(p.x, anchor.x, 'x anchor'); close(p.y, anchor.y, 'y anchor'); };
test('all depths stay below an off-center cursor under the common projection', () => {
for (const anchor of [{ x: .6, y: .2 }, { x: -1, y: 1 }, { x: 1, y: -1 }, { x: 0, y: 0 }]) {
const { camera, zoom } = setup();
const position = camera.position.clone(), orientation = camera.quaternion.clone();
const points = [-.7, .2, .99].map(depth => pointAt(camera, anchor, depth));
zoom.setZoom(2.4, anchor);
points.forEach(point => expectAt(camera, point, anchor));
assert.deepEqual(camera.position, position); assert.deepEqual(camera.quaternion.toArray(), orientation.toArray());
assert.equal(camera.zoom, 2.4);
}
});
test('moving pinch midpoint carries its content to the new midpoint', () => {
const { camera, zoom } = setup(); zoom.setZoom(2);
const from = { x: -.2, y: .1 }, to = { x: .2, y: -.15 }, point = pointAt(camera, from);
zoom.setZoom(2.6, from, to); expectAt(camera, point, to);
});
test('pinch translation works at fixed zoom and at the maximum clamp', () => {
for (const requested of [3, 300]) {
const { camera, zoom } = setup(); zoom.setZoom(3);
const from = { x: 0, y: 0 }, to = { x: .2, y: .3 }, point = pointAt(camera, from);
zoom.setZoom(requested, from, to); expectAt(camera, point, to); assert.equal(zoom.value, 3);
}
});
test('clamping zoom uses the actual ratio rather than an overshooting request', () => {
const { camera, zoom } = setup(); zoom.setZoom(2);
const anchor = { x: .7, y: -.6 }, point = pointAt(camera, anchor);
zoom.setZoom(80, anchor); expectAt(camera, point, anchor);
});
test('zoom and offset are bounded, and zoom1 restores the full centered view', () => {
const { camera, zoom } = setup(); zoom.setZoom(3, { x: 0, y: 0 }, { x: 99, y: -99 });
assert.deepEqual(zoom.offset.toArray(), [2, -2]);
zoom.setZoom(.1, { x: -.2, y: .3 });
assert.equal(zoom.value, 1); assert.deepEqual(zoom.offset.toArray(), [0, 0]);
const neutral = new THREE.PerspectiveCamera(40, 1.6, .1, 100);
assert.deepEqual(camera.projectionMatrix.elements, neutral.projectionMatrix.elements);
});
test('repeated anchored in/out stays stable with no accumulated drift', () => {
const { camera, zoom } = setup(), anchor = { x: .8, y: -.7 }, point = pointAt(camera, anchor);
for (let i = 0; i < 100; i++) { zoom.setZoom(2.7, anchor); expectAt(camera, point, anchor); zoom.setZoom(1, anchor); expectAt(camera, point, anchor); }
assert.deepEqual(zoom.offset.toArray(), [0, 0]);
});
test('resize reapplies normalized offset and refreshes the inverse matrix', () => {
const { camera, zoom } = setup(); zoom.setZoom(2, { x: .5, y: -.25 });
const offset = zoom.offset.clone(); camera.aspect = .5; zoom.updateProjection();
assert.deepEqual(zoom.offset, offset); close(camera.projectionMatrix.elements[8], -offset.x); close(camera.projectionMatrix.elements[9], -offset.y);
const product = camera.projectionMatrix.clone().multiply(camera.projectionMatrixInverse);
product.elements.forEach((value, i) => close(value, i % 5 === 0 ? 1 : 0));
});
test('nonfinite requests and anchors cannot corrupt projection state', () => {
const { camera, zoom } = setup(); zoom.setZoom(2, { x: .3, y: .2 }); const matrix = camera.projectionMatrix.clone();
for (const value of [NaN, Infinity, -Infinity]) zoom.setZoom(value);
zoom.setZoom(2.5, { x: NaN, y: 0 }); zoom.setZoom(2.5, { x: 0, y: 0 }, { x: Infinity, y: 0 });
assert.deepEqual(camera.projectionMatrix, matrix);
});
+67
View File
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {readFile} from 'node:fs/promises';
import {loadTypescript} from './support/load-typescript.mjs';
test('export shader keeps normal path and can produce transparent photo misses',async()=>{
const source=await readFile(new URL('../src/rendering/MeshRefraction.ts',import.meta.url),'utf8');
assert.match(source,/uBPhotoExport/);
assert.match(source,/gl_FragColor\.a\s*=\s*clamp\(bLight\.a/);
});
test('pixel extraction flips WebGL rows, crops alpha bounds, and preserves partial alpha',async()=>{
const {prepareExportPixels}=await loadTypescript(new URL('../src/rendering/ExportPixels.ts',import.meta.url));
const bytes=new Uint8Array(4*3*4);bytes.set([30,40,50,128],(1*4+2)*4);bytes.set([10,20,30,255],(2*4+1)*4);
const result=prepareExportPixels(bytes,4,3,true);
assert.equal(result.width,2);assert.equal(result.height,2);
assert.deepEqual([...result.data.slice(0,4)],[10,20,30,255]);
assert.deepEqual([...result.data.slice(12,16)],[30,40,50,128]);
const full=prepareExportPixels(bytes,4,3,false);assert.equal(full.width,4);assert.equal(full.height,3);
assert.throws(()=>prepareExportPixels(new Uint8Array(16),2,2,true),/사진/);
});
import * as THREE from 'three';
function captureHarness({readError=false,drawError=false,delay=false}={}){
let target=null,disposed=false,resolveRead;const original={name:'original'};
const renderer={autoClear:false,current:original,viewport:new THREE.Vector4(1,2,3,4),scissor:new THREE.Vector4(2,3,4,5),scissorTest:true,color:new THREE.Color(.2,.3,.4),alpha:.7,
getContext:()=>({isContextLost:()=>false}),getDrawingBufferSize:v=>v.set(4,3),getRenderTarget(){return this.current;},getViewport(v){return v.copy(this.viewport);},getScissor(v){return v.copy(this.scissor);},getScissorTest(){return this.scissorTest;},getClearColor(v){return v.copy(this.color);},getClearAlpha(){return this.alpha;},
setRenderTarget(v){this.current=v;},setViewport(v){this.viewport.copy(v);},setScissor(v){this.scissor.copy(v);},setScissorTest(v){this.scissorTest=v;},setClearColor(v,a){this.color.set(v);this.alpha=a;},
async readRenderTargetPixelsAsync(t,x,y,w,h,bytes){assert.equal(this.current,original,'live target restored before readback wait');if(readError)throw Error('read failed');if(delay)await new Promise(r=>resolveRead=r);for(let i=0;i<bytes.length;i+=4)bytes.set([128,128,128,255],i);}};
let output;
const root={createElement:()=>({getContext:()=>({createImageData:(w,h)=>({data:new Uint8ClampedArray(w*h*4)}),putImageData:data=>output=data.data}),toBlob:cb=>cb(new Blob(['png'],{type:'image/png'}))})};
const draw=t=>{target=t;t.addEventListener('dispose',()=>disposed=true);assert.equal(t.texture.colorSpace,THREE.SRGBColorSpace);assert.equal(t.samples,0);renderer.setRenderTarget(t);renderer.setViewport(new THREE.Vector4(0,0,4,3));if(drawError)throw Error('draw failed');};
const restored=()=>{assert.equal(renderer.current,original);assert.deepEqual(renderer.viewport.toArray(),[1,2,3,4]);assert.deepEqual(renderer.scissor.toArray(),[2,3,4,5]);assert.equal(renderer.scissorTest,true);assert.equal(renderer.autoClear,false);assert.equal(renderer.alpha,.7);assert.ok(Math.abs(renderer.color.r-.2)<1e-10);};
return {renderer,root,draw,restored,get output(){return output;},get disposed(){return disposed;},resolve:()=>resolveRead()};
}
test('capture restores renderer and disposes target on success and failures without double color encoding',async()=>{
const {captureScenePNG}=await loadTypescript(new URL('../src/rendering/SceneExport.ts',import.meta.url));
for(const settings of [{},{readError:true},{drawError:true}]){
const h=captureHarness(settings),signal=new AbortController().signal;
if(settings.readError||settings.drawError)await assert.rejects(captureScenePNG(h.renderer,h.root,'scene',signal,h.draw),/failed/);
else {assert.equal((await captureScenePNG(h.renderer,h.root,'photo',signal,h.draw)).type,'image/png');assert.deepEqual([...h.output.slice(0,4)],[128,128,128,255]);}
h.restored();assert.equal(h.disposed,true);
}
});
test('aborting pending readback prevents PNG creation and releases target after readback settles',async()=>{
const {captureScenePNG}=await loadTypescript(new URL('../src/rendering/SceneExport.ts',import.meta.url));
const h=captureHarness({delay:true}),controller=new AbortController();
const promise=captureScenePNG(h.renderer,h.root,'photo',controller.signal,h.draw);h.restored();controller.abort();h.resolve();
await assert.rejects(promise,{name:'AbortError'});assert.equal(h.disposed,true);assert.equal(h.output,undefined);
});
test('isolated B draw omits frame and restores background, alpha flags, camera and slide even on failure',async()=>{
const {MeshGlassRenderer}=await loadTypescript(new URL('../src/rendering/MeshGlassRenderer.ts',import.meta.url));
for(const fail of [false,true]){
let current=null;const calls=[];
const renderer={extensions:{has:()=>false},debug:{onShaderError:null},autoClear:true,getContext:()=>({isContextLost:()=>false}),getRenderTarget:()=>current,setRenderTarget:t=>current=t,setScissorTest(){},render(scene,camera){calls.push({scene,camera});scene.children[0]?.onBeforeRender?.();if(fail)throw Error('forced');}};
const material=new THREE.ShaderMaterial({uniforms:{uTexture:{value:new THREE.Texture()},uDepthMap:{value:new THREE.Texture()},uDepthScale:{value:0},uDepthBias:{value:0}},alphaToCoverage:true});
const source=new THREE.Mesh(new THREE.PlaneGeometry(1,1),material),mesh=new MeshGlassRenderer(renderer,source);
const bg=new THREE.Color(.2,.3,.4);mesh.scene.background=bg;mesh.setSlide(-.3);
const camera=new THREE.PerspectiveCamera();camera.projectionMatrix.elements[8]=.17;const projection=[...camera.projectionMatrix.elements];
const target=new THREE.WebGLRenderTarget(2,2),frame=new THREE.Scene();
const blockerMaterial=new THREE.MeshBasicMaterial();frame.add(new THREE.Mesh(new THREE.PlaneGeometry(1,1),blockerMaterial));
const originalRender=renderer.render;renderer.render=(scene,camera)=>{if(scene===frame)assert.equal(blockerMaterial.colorWrite,false);return originalRender(scene,camera);};
if(fail)assert.throws(()=>mesh.renderForExport(camera,frame,target,true),/forced/);else mesh.renderForExport(camera,frame,target,true);
assert.equal(calls.length,fail?1:2);assert.equal(calls[0].camera,camera);assert.equal(calls[0].scene,frame,'frame depth blocks hidden photo without painting object colors');
if(!fail)assert.equal(calls[1].scene,mesh.scene);
assert.equal(mesh.scene.background,bg);assert.equal(blockerMaterial.colorWrite,true);assert.equal(renderer.autoClear,true);assert.equal(material.alphaToCoverage,true);assert.equal(mesh.glass.position.x,-.3);assert.deepEqual(camera.projectionMatrix.elements,projection);
assert.equal(mesh.internalTargets.has(target),false,'caller-owned export target never becomes an internal target');
assert.equal(mesh.refraction.uniforms.uBPhotoExport.value,false);mesh.dispose();target.dispose();source.geometry.dispose();material.dispose();
}
});
+382
View File
@@ -0,0 +1,382 @@
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();
});
+87
View File
@@ -0,0 +1,87 @@
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('<dialog id="settings-dialog"'));
for(const id of ['gyro','gyro-recenter','reset','camera-offset','scene-zoom-reset']) {
assert.match(html,new RegExp(`id="${id}"`));
assert.equal([...html.matchAll(new RegExp(`id="${id}"`,'g'))].length,1);
}
assert.doesNotMatch(scene,/id="motion-tools"/);assert.match(scene,/id="gyro-label"/);
assert.match(scene,/id="hint"[^>]*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'],/끄기/);
}
});
+23
View File
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const reeded=await readFile(new URL('../src/shaders/reeded.frag',import.meta.url),'utf8');
const frosted=await readFile(new URL('../src/shaders/frosted.frag',import.meta.url),'utf8');
// The shaders expose a small pure GLSL helper; execute that exact arithmetic in JS.
function stableUv(shader) {
const body=shader.match(/vec2 patternScreenUv\(vec2 uv\)\{return ([^;]+);\}/)?.[1];
assert.ok(body,'legacy screen-pattern coordinates must compensate projection zoom');
const expression=body.replaceAll('uv','component').replaceAll('uSceneOffset','offset').replaceAll('uSceneZoom','zoom').replaceAll('max(','Math.max(');
return new Function('component','zoom','offset',`return ${expression};`);
}
for(const shader of [reeded,frosted]) {
const stable=stableUv(shader);
for(const original of [.1,.25,.5,.8]) for(const zoom of [1,1.5,2,3]) for(const offset of [0,.2,-.6]) {
const projected=.5+(original-.5)*zoom+offset*.5;
assert.ok(Math.abs(stable(projected,zoom,offset)-original)<1e-12,'the same material point keeps its original pattern coordinate under projection zoom');
}
assert.match(shader,/patternScreenUv\(vUv\)/,'actual pattern coordinate consumes the compensated helper');
}
assert.match(reeded,/coord=patternScreenUv\(vUv\)\.x\*count/);
assert.match(frosted,/st=patternScreenUv\(vUv\)\*/);
assert.match(frosted,/rad=vec2\(\.008\)\*uSceneZoom\*/,'frosted blur footprint magnifies with the view');
console.log('Scene zoom materials: shader helper arithmetic preserves zoom1 pattern coordinates and tracks zoomed material points; Frosted footprint source contract verified (no GPU execution)');
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {test} from 'node:test';
const main=readFileSync(new URL('../src/main.ts',import.meta.url),'utf8'),html=readFileSync(new URL('../index.html',import.meta.url),'utf8'),css=readFileSync(new URL('../src/style.css',import.meta.url),'utf8');
test('sliding has an explicit handle target, keyboard alternative and enabled local clipping',()=>{
assert.match(main,/renderer.localClippingEnabled\s*=\s*true/);
assert.match(main,/subjectDrag:/);assert.match(main,/intersectPlane/);assert.match(main,/cameraController.hold\(\)/);
assert.match(main,/blenderFrame.setOpen\(effective/);assert.match(main,/meshRenderer.setSlide\(-uniforms.uPaneSize.value.x\*effective/);
assert.match(html,/id="door-toggle"/);assert.match(html,/id="door-handle"/);assert.match(css,/#door-handle[^}]*width:\s*44px/);
assert.match(main,/event.key==='Home'/);assert.match(main,/event.key==='End'/);
});
test('A, comparison and booth explicitly use a closed diagnostic view without discarding B openness',()=>{
assert.match(main,/selectedRenderer==='b'&&!comparisonRequested/);
assert.match(main,/blenderFrame.isWood&&blenderFrame.isReady/);
assert.match(main,/A·B 비교.*닫힌/);assert.match(main,/A 진단.*닫힌/);
assert.doesNotMatch(main,/petDepth.position.x\s*=.*door|pet.position.x\s*=.*door/);
});
+758
View File
@@ -0,0 +1,758 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import vm from 'node:vm';
import ts from 'typescript';
import * as THREE from 'three';
import {loadFrameGeometry} from './support/load-frame-geometry.mjs';
import {loadTypescript} from './support/load-typescript.mjs';
let fetchFrameAllowed=false;
import {mergeGeometries} from 'three/addons/utils/BufferGeometryUtils.js';
import { RectAreaLightUniformsLib } from 'three/addons/lights/RectAreaLightUniformsLib.js';
import { DepthProcessor } from '../src/depth/DepthProcessor.ts';
import * as depthConstants from '../src/constants/depth.ts';
import * as glassConstants from '../src/constants/glass.ts';
const {CameraController}=await loadTypescript(new URL('../src/input/CameraController.ts',import.meta.url));
// Browser boundaries are doubles; production main state machine, camera, THREE objects,
// and the single-view rendering state machine execute unchanged. This is not GPU QA.
class Element {
value = ''; open = false; isConnected = true; inert = false; hidden = false; disabled = false; textContent = ''; checked = false;
style = {}; dataset = {}; attributes = {}; listeners = new Map(); width = 960; height = 600; clientWidth = 960; clientHeight = 600;
classList = { add(){}, remove(){} };
addEventListener(name, fn) { const list = this.listeners.get(name) ?? []; list.push(fn); this.listeners.set(name,list); }
dispatch(name, extra={}) { for (const fn of this.listeners.get(name) ?? []) fn({type:name,target:this,...extra}); }
setAttribute(key,value){this.attributes[key]=value;} removeAttribute(key){delete this.attributes[key];}
focus(){documentRoot.activeElement=this;}
showModal(){this.open=true;}
close(){this.open=false;this.dispatch('close');}
dispatchEvent(event){this.dispatch(event.type);}
removeEventListener() {} append() {} remove() {} closest() { return new Element(); }
getBoundingClientRect() { return {x:0,y:0,left:0,top:0,width:this.width,height:this.height}; }
getContext() { return { createLinearGradient:()=>({addColorStop(){}}), fillRect(){}, drawImage(){}, getImageData:()=>({data:new Uint8ClampedArray(this.width*this.height*4).fill(128)}) }; }
}
// Mirror actual HTML membership so a removed/mistyped control cannot be invented by the harness.
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
const elements = new Map([...html.matchAll(/\bid="([^"]+)"/g)].map(([,id]) => [`#${id}`,new Element()]));
if (/<aside(?:\s|>)/.test(html)) elements.set('aside',new Element());
const groups = new Map();
for (const attr of ['tool','panel','pattern']) {
const matches=[...html.matchAll(new RegExp(`<[^>]+data-${attr}="([^"]+)"[^>]*>`,'g'))];
groups.set(`[data-${attr}]`, matches.map(([tag,value])=>{const id=tag.match(/id="([^"]+)"/)?.[1];const element=id?elements.get(`#${id}`):new Element();element.dataset[attr]=value;return element;}));
}
const querySelector = selector => elements.get(selector) ?? null;
const el = id => { const element = querySelector(id); assert.ok(element, `Missing HTML control: ${id}`); return element; };
for (const [id,value] of Object.entries({'#mosaic-style':'2','#effect':'mosaic'})) el(id).value=value;
let frame, teardown, provider, loader, pointer, renderer, inspection, resizeCallback;
let animationNow=null;
let peekingCallbacks,peekingState;
const peekingBoundary={cancelCalls:0,disposeCalls:0,setState(state){peekingState={...state};},cancel(){this.cancelCalls++;},dispose(){this.disposeCalls++;}};
class FakeImage { src='blob:photo'; width=400; height=500; naturalWidth=400; naturalHeight=500; async decode(){} }
class Renderer {
domElement=new Element(); capabilities={maxTextureSize:4096}; autoClear=true; target=null; scissor=false; viewport=[]; draws=[]; color=new THREE.Color(); alpha=1;
constructor(){renderer=this;}
extensions={has:name=>name==='OES_texture_float_linear'};
debug={onShaderError:null}; pixelRatio=1; lost=false; internals=[];
info={autoReset:true,render:{calls:0,triangles:0},memory:{geometries:0,textures:0},reset:()=>{this.info.render.calls=0;this.info.render.triangles=0;}};
getContext(){return {isContextLost:()=>this.lost};}
getPixelRatio(){return this.pixelRatio;} setPixelRatio(v){this.pixelRatio=v;} setSize(w,h){this.domElement.clientWidth=w;this.domElement.clientHeight=h;} setRenderTarget(v){this.target=v;} getRenderTarget(){return this.target;}
setViewport(...v){this.viewport=v;} setScissor(...v){this.scissorRect=v;} setScissorTest(v){this.scissor=v;}
getClearColor(result){return result.copy(this.color);} getClearAlpha(){return this.alpha;} setClearColor(v,a){this.color.set(v);this.alpha=a;}
clear(){} clearDepth(){} dispose(){}
render(scene,camera){
const physical=scene.children.find(child=>child.visible&&child.material?.isMeshPhysicalMaterial);
if(physical&&this.failNextMesh){this.failNextMesh=false;this.debug.onShaderError?.({},null,null,null);}
if(physical){
if(!scene.userData.internal){scene.userData.internal=new THREE.WebGLRenderTarget(1,1);this.internals.push(scene.userData.internal);scene.userData.internal.userData={disposed:0};scene.userData.internal.addEventListener('dispose',()=>scene.userData.internal.userData.disposed++);}
const output=this.target;this.target=scene.userData.internal;
for(const child of scene.children)if(child.visible&&child.material&&!child.material.isMeshPhysicalMaterial)child.onBeforeRender(this,scene,camera,child.geometry,child.material,null);
this.target=output;
}
for(const child of scene.children)if(child.visible&&child.geometry){this.info.render.calls++;this.info.render.triangles+=(child.geometry.index?.count??0)/3;}
const object=scene.children.find(child=>child.visible&&child.material?.colorWrite!==false&&(child.material?.uniforms?.uDepthMap || child.material?.uniforms?.uProtectionDepthMap));
const optics=object?.material.uniforms;
this.draws.push({objects:scene.children,target:this.target,scissor:this.scissor,viewport:[...this.viewport],camera:camera.clone(),depth:optics?.uDepthMap?.value??optics?.uProtectionDepthMap?.value,history:optics?.uRegionHistory?.value,grid:optics?.uGridSize?.value,curvature:optics?.uCurvature?.value,gloss:optics?.uGlossStrength?.value,sceneZoom:optics?.uSceneZoom?.value,sceneOffset:optics?.uSceneOffset?.value.clone(),glassZ:optics?.uGlassZ?.value,sourceZ:optics?.uSourceZ?.value,depthScale:optics?.uDepthScale?.value,depthBias:optics?.uDepthBias?.value,position:object?.position.clone(),scale:object?.scale.clone(),photo:optics?.uTexture?.value,background:scene.background?.clone(),shaderBackground:optics?.uBackgroundColor?.value?.clone(),projection:optics?.uContentViewProjection?.value.clone(),inverseProjection:optics?.uContentInverseViewProjection?.value.clone()});
}
}
class Provider {
calls=[]; pending=[];
constructor(){provider=this;}
getDepthMap(image){this.calls.push({image,argumentCount:arguments.length});return new Promise((resolve,reject)=>this.pending.push({resolve,reject}));}
cancel(){for(const pending of this.pending.splice(0)) pending.reject(new Error('cancelled'));}
finish(){const pending=this.pending.shift();assert.ok(pending);const texture=DepthProcessor.fromNormalizedDepth(new Uint8Array([0,100,255,128]),2,2);texture.userData.aiDepthRun={model:'small',device:'wasm',dtype:'q8',preparationMs:10,inferenceMs:20,totalMs:30};texture.userData.disposals=0;texture.addEventListener('dispose',()=>texture.userData.disposals++);pending.resolve(texture);return texture;}
}
class Loader {loads=0;constructor(apply,status){this.apply=apply;this.status=status;loader=this;} dispose(){} cancelPending(){} retry(){} load(file){this.loads++;this.lastFile=file;} }
class Pointer {constructor(_element,move,_onDrag,zoom){this.move=move;this.zoom=zoom;pointer=this;}cancelGesture(){this.cancelled=true;} dispose(){} }
let gyroDisconnects=0;class Gyro {disconnect(){gyroDisconnects++;}reset(){} setPaused(){} syncManualView(){} dispose(){} }
class Inspection {constructor(){inspection=this;} setDepth(_image,texture){this.texture=texture;} updateDistance(){} }
class Debug {clear(){} update(){return '';} }
const modules = new Map();
// The default static build must not initialize or call analytics dependencies.
const analyticsDisabled = () => { throw new Error('static build invoked analytics'); };
modules.set('Identity', { getVisitorId: analyticsDisabled, makeDogId: analyticsDisabled });
modules.set('Transport', { createTransport: analyticsDisabled });
modules.set('ArrivalTracker', { createArrivalTracker: analyticsDisabled });
modules.set('ArrivalCount', { renderArrivalCount: analyticsDisabled });
modules.set('PetName',await loadTypescript(new URL('../src/state/PetName.ts',import.meta.url)));
modules.set('FirstGuide',await loadTypescript(new URL('../src/ui/FirstGuide.ts',import.meta.url)));
modules.set('ExportUI',await loadTypescript(new URL('../src/ui/ExportUI.ts',import.meta.url)));
modules.set('SceneExport',await loadTypescript(new URL('../src/rendering/SceneExport.ts',import.meta.url)));
modules.set('MobileDevice',await loadTypescript(new URL('../src/ui/MobileDevice.ts',import.meta.url)));
modules.set('EntryNotices',await loadTypescript(new URL('../src/ui/EntryNotices.ts',import.meta.url)));
modules.set('ImagePaste',await loadTypescript(new URL('../src/input/ImagePaste.ts',import.meta.url)));
modules.set('CameraComposition',await loadTypescript(new URL('../src/input/CameraComposition.ts',import.meta.url)));
// Geometry, shading, and composition are production modules. Only fetch/decode,
// browser UI, and GL calls are doubles; the opt-in UI is tested separately.
for(const key of ['RoomComposition','ExperimentalCourtyard','PeekingDoorGrid','PeekingComposition','WindowReveal','PetDepthMesh']){
modules.set(key,await loadTypescript(new URL(`../src/objects/${key}.ts`,import.meta.url)));
}
modules.set('PeekingExperimentUI',{initPeekingExperimentUI(_document,_signal,callbacks){peekingCallbacks=callbacks;return peekingBoundary;}});
for (const key of ['SceneHints','themes','ExperienceUI','RenderRecovery','PhotoBoothUI','WindowFinish','BlenderWindowFrame','BlenderHouseSurround','WindowSlideController','SceneZoomController','ComparisonLayout','ComparisonMetrics','RenderDemand','PatternedGlassGeometry','MeshRefraction','MeshGlassRenderer','OriginalGlassRenderer','PhysicalGlassComparison']) {
const output={}; const path=['ComparisonLayout','ComparisonMetrics','RenderDemand','PatternedGlassGeometry','MeshRefraction','MeshGlassRenderer','OriginalGlassRenderer','PhysicalGlassComparison'].includes(key)?'rendering':['WindowFinish','BlenderWindowFrame','BlenderHouseSurround'].includes(key)?'objects':['WindowSlideController','SceneZoomController'].includes(key)?'input':'ui';
const text=await readFile(new URL(`../src/${path}/${key}.ts`,import.meta.url),'utf8');
vm.runInNewContext(ts.transpileModule(text,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText,{exports:output,Event,AbortController,fetch:async()=>{if(fetchFrameAllowed)return {ok:true,arrayBuffer:async()=>new ArrayBuffer(0)};throw Error('offline asset fixture');},require:name=>name.endsWith('?raw')?{default:''}:name==='three'?THREE:name.includes('GLTFLoader')?{GLTFLoader:class{async parseAsync(){return {scene:loadFrameGeometry(key==='BlenderHouseSurround'?new URL('../public/assets/window/grandma-house-surround.glb',import.meta.url):undefined)};}}}:name.includes('BufferGeometryUtils')?{mergeGeometries}:name.includes('RectAreaLightUniformsLib')?{RectAreaLightUniformsLib}:name.endsWith('/glass')?glassConstants:modules.get(name.split('/').at(-1))});
modules.set(key,output);
}
const require = name => {
if(modules.has(name.split('/').at(-1))) return modules.get(name.split('/').at(-1));
if(name==='three') return {...THREE,WebGLRenderer:Renderer};
if(name.endsWith('?raw')) return {default:''};
if(name.endsWith('?url')) return {default:'test.png'};
if(name.endsWith('.css')) return {};
const classes={MediaLoader:Loader,PointerController:Pointer,CameraController,GyroController:Gyro,StaticDepthProvider:class{},AiDepthProvider:Provider,DepthInspection:Inspection,DepthDebug:Debug};
const key=name.split('/').at(-1);if(classes[key])return {[key]:classes[key]};
if(key==='DepthProcessor')return {DepthProcessor};if(key==='depth')return depthConstants;if(key==='glass')return glassConstants;
throw new Error(`unexpected import ${name}`);
};
const source=await readFile(new URL('../src/main.ts',import.meta.url),'utf8');
const documentEvents=new Element();
const motionPreference=new Element();motionPreference.matches=false;
const documentRoot={addEventListener:(...args)=>documentEvents.addEventListener(...args),hidden:false,querySelector,querySelectorAll:selector=>groups.get(selector)??[],createElement:()=>new Element(),documentElement:new Element(),defaultView:{URL:{createObjectURL:()=>'blob:export',revokeObjectURL(){}},navigator:{},matchMedia:()=>motionPreference,localStorage:{getItem(key){return key==='behind-glass.first-guide.v1'?'1':null;},setItem(){}}},activeElement:null};
const timers=new Map();let timerId=0;
const compiled=ts.transpileModule(source.replaceAll('import.meta','testImportMeta'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
vm.runInNewContext(compiled,{require,exports:{},testImportMeta:{env:{VITE_ARRIVAL_ANALYTICS:'false'},hot:{dispose(fn){teardown=fn;}}},document:documentRoot,setTimeout:fn=>{timers.set(++timerId,fn);return timerId;},clearTimeout:id=>timers.delete(id),devicePixelRatio:1,HTMLImageElement:FakeImage,Image:FakeImage,AbortController,ResizeObserver:class{constructor(fn){resizeCallback=fn;}observe(){}disconnect(){}},performance:{now:()=>animationNow??performance.now()},queueMicrotask,requestAnimationFrame:fn=>{frame=fn;return 1;},cancelAnimationFrame(){},console,DOMException,URL,Promise,File,Blob});
const flush=async()=>{for(let i=0;i<10;i++)await Promise.resolve();};
const beforeNameInference = provider.calls.length, beforeNameLoads = loader.loads;
el('#entry-pet-name').value='호롱'; el('#entry-pet-name').dispatch('input');
assert.equal(el('#settings-pet-name').value,'호롱');
assert.equal(el('#entry-photo-button').textContent,'호롱이 사진 고르기');
assert.equal(provider.calls.length,beforeNameInference,'editing name never starts AI');
assert.equal(loader.loads,beforeNameLoads,'editing name never reloads media');
assert.equal(el('#depth-image').disabled,true,'manual depth requires a decoded still photo');
renderer.draws=[];frame(performance.now()+16);
assert.ok(renderer.draws.some(draw=>draw.objects.some(object=>object.material?.isMeshPhysicalMaterial)),'B is the default full-size renderer');
assert.equal(renderer.draws.some(draw=>draw.gloss!==undefined),false,'default rendering never submits A');
assert.equal(renderer.draws.some(draw=>draw.target?.depthTexture),false,'default does not allocate or draw A scene target');
assert.equal(el('#glass-renderer').value,'b');
// Run the existing A optics regression through its explicit preserved diagnostic entry.
el('#glass-renderer').value='a';el('#glass-renderer').dispatch('change');await flush();
assert.equal(el('#glass-renderer').value,'a');
// A demand-rendered canvas keeps its last presented image when no new draw is needed.
const sampleFrame=now=>{const previous=renderer.draws;renderer.draws=[];frame(now);if(!renderer.draws.length&&!el('#render-recovery').open&&!el('#mesh-render-error').open&&el('#experience').dataset.flow!=='loading')renderer.draws=previous;};
const renderOptics=()=>{sampleFrame(performance.now()+16);return renderer.draws.find(draw=>draw.target===null&&draw.gloss!==undefined);};
assert.equal(renderOptics().curvature,1,'convex is the initial shader setting');
assert.equal(renderOptics().gloss,.3,'gloss is fixed at 0.30');
for(const value of ['-1','1','-1','1']){
el('#curvature').value=value;el('#curvature').dispatch('change');
assert.equal(renderOptics().curvature,Number(value),'both choices reach the shader on repeated selection');
assert.equal(renderOptics().gloss,.3,'curvature changes do not alter gloss');
assert.equal(renderer.draws.find(draw=>draw.target?.depthTexture).camera.position.z,3,'curvature does not move the camera behind the glass');
}
for(const effect of ['reeded','frosted','mosaic']){
el('#effect').value=effect;el('#effect').dispatch('change');
assert.equal(el('#curvature').disabled,effect!=='mosaic');
assert.equal(el('#curvature-controls').hidden,effect!=='mosaic');
assert.equal(renderOptics().gloss,.3,'switching effects preserves fixed gloss');
}
for(const style of ['0','1','2']){
el('#mosaic-style').value=style;el('#mosaic-style').dispatch('change');
assert.equal(renderOptics().gloss,.3,'switching glass patterns preserves fixed gloss');
}
el('#grid-size').value='11';el('#grid-size').dispatch('input');
groups.get('[data-pattern]').find(card=>card.dataset.pattern==='2').dispatch('click');
assert.equal(renderOptics().grid,11,'reselecting the current card preserves the custom grid in the renderer');
const assertGap=(expected)=>{
const optics=renderOptics();
const content=renderer.draws.find(draw=>draw.target?.depthTexture);
const maxDepth=content.depth.image.data.reduce((max,value,index)=>index%4===0?Math.max(max,value):max,0)/255;
const closestZ=content.position.z+(maxDepth-content.depthBias)*content.depthScale;
assert.ok(Math.abs(optics.glassZ-closestZ-expected)<1e-12,`nearest surface gap should be ${expected}`);
assert.equal(optics.sourceZ,content.position.z,'refraction source plane follows the depth mesh base');
};
assertGap(.1296);
loader.apply(new THREE.Texture(new FakeImage()),.8,null);await flush();
renderer.draws=[];frame(performance.now()+16);assert.equal(renderer.draws.length,0,'hidden 3D rendering pauses during inference');
assert.equal(provider.calls.length,1,'one automatic depth request per photo');
assert.equal(el('#experience').dataset.flow,'loading','only a real pending operation shows delivery');
assert.equal(el('#flow-overlay').dataset.journey,'driving','real AI work drives the photo passenger');
const pendingCalls=provider.calls.length;el('#theme-select').value='booth';el('#theme-select').dispatch('change');
assert.equal(provider.calls.length,pendingCalls,'theme switch during loading must not restart inference');
assert.equal(provider.pending.length,1);
assert.equal(el('#experience').dataset.flow,'loading');
assert.equal(provider.calls[0].argumentCount,1,'model and preset are no longer selected by the UI');
// Give the actual presentation controller controllable browser timers for this AI completion.
const journeyTimers=new Map();let journeyTimerId=0;
documentRoot.defaultView.setTimeout=(fn,ms)=>{journeyTimers.set(++journeyTimerId,{fn,ms});return journeyTimerId;};
documentRoot.defaultView.clearTimeout=id=>journeyTimers.delete(id);
const advanceJourney=expected=>{const [id,timer]=journeyTimers.entries().next().value;assert.equal(timer.ms,expected);journeyTimers.delete(id);timer.fn();};
const first=provider.finish();await flush();
assert.equal(el('#ai-depth').disabled,false);
assert.equal(inspection.texture,first);
assert.equal(el('#experience').dataset.flow,'ready');
assert.equal(el('#flow-overlay').dataset.journey,'arrived','only applied current AI success arrives');
assert.match(el('#depth-provenance').textContent,/Small.*770/);
renderer.draws=[];frame(performance.now()+16);
const screen=renderer.draws.filter(draw=>draw.target===null&&draw.depth);
assert.equal(screen.length,1,'a single full-width view is rendered');
assert.equal(screen[0].depth,first);
assert.equal(renderer.draws.find(draw=>draw.target?.depthTexture).camera.aspect,960/600);
assert.equal(el('#canvas-wrap').inert,true,'arrival keeps scene interaction blocked');
assert.equal(el('#hint').dataset.phase,'hidden','hint waits until the scene can be used');
const arrivalInferenceCalls=provider.calls.length;
const arrivalPosition=screen[0].camera.position.x, arrivalDraws=renderer.draws;
pointer.move(.5,0);renderer.draws=[];frame(performance.now()+20);
assert.equal(renderer.draws.length,0,'static arrival submits no extra scene render');
assert.equal(screen[0].camera.position.x,arrivalPosition,'arrival blocks hidden camera input');
advanceJourney(1350);
assert.equal(el('#flow-overlay').dataset.journey,'unpacking');
assert.equal(el('#flow-title').textContent,'짐을 푸는 중…');
renderer.draws=[];frame(performance.now()+24);
assert.equal(renderer.draws.length,0,'unpacking adds no GPU scene work');
advanceJourney(1500);
assert.equal(el('#flow-overlay').hidden,true);assert.equal(el('#canvas-wrap').inert,false);
assert.equal(el('#hint').dataset.phase,'hidden','hint starts only after five idle seconds beyond the reveal');
assert.equal(provider.calls.length,arrivalInferenceCalls,'the arrival story never starts inference');
delete documentRoot.defaultView.setTimeout;delete documentRoot.defaultView.clearTimeout;
renderer.draws=arrivalDraws; // Keep the last framebuffer snapshot for the remaining demand-rendering checks.
assertGap(.1296);
el('#photo-reset').dispatch('click');assertGap(.1296);
el('#reset').onclick();assertGap(.1296);
el('#scene-zoom-reset').dispatch('click');assertGap(.1296);
for(const [value,gap] of [['0',.04],['10',.096],['16',.1296],['21',.1576]]){
el('#photo-glass-distance').value=value;el('#photo-glass-distance').dispatch('input');
assert.equal(el('#photoGlassDistanceOut').value,`${value}%`);
assertGap(gap);
}
el('#photo-reset').dispatch('click');assertGap(.1576);
el('#reset').onclick();assertGap(.1576);
el('#scene-zoom-reset').dispatch('click');assertGap(.1576);
assert.equal(el('#photoGlassDistanceOut').value,'21%','framing and viewpoint resets preserve the chosen distance');
for(const value of ['0.35','0.50','0.65','0.90']){
el('#camera-offset').value=value;el('#camera-offset').dispatch('input');
assert.equal(el('#cameraOut').value,Number(value).toFixed(2));
}
const isolatedCamera=new THREE.PerspectiveCamera();
const isolatedController=new CameraController(isolatedCamera);
isolatedController.add(1,-1);isolatedController.update(10,1);
assert.equal(isolatedCamera.position.x,.75,'camera default is the gentler 0.75');
for(const [requested,expected] of [[0,.35],[.35,.35],[.5,.5],[.65,.65],[.9,.9],[1.2,.9]]){
isolatedController.setMaxOffset(requested);isolatedController.update(10,2);
assert.equal(isolatedCamera.position.x,expected,'camera uses the bounded offset');
assert.equal(isolatedCamera.position.y,-expected);
}
pointer.move(.1,0);renderer.draws=[];frame(performance.now()+32);
assert.ok(renderer.draws.find(draw=>draw.target?.depthTexture).camera.position.x>0);
// Keyboard is an equivalent, bounded preview input rather than a new camera algorithm.
el('#reset').onclick();frame(performance.now()+40);
let prevented = false;
el('#canvas-wrap').dispatch('keydown',{key:'ArrowRight',preventDefault(){prevented=true;}});
assert.equal(prevented,true,'preview consumes its arrow-key input');
renderer.draws=[];frame(performance.now()+80);
assert.ok(renderer.draws.find(draw=>draw.target?.depthTexture).camera.position.x>0,'keyboard moves the shared camera');
// Guidance is temporary and remains hidden once the user has acted.
assert.equal(el('#hint').dataset.phase,'hidden','keyboard interaction hides the hint');
const hintCallbacks=[...timers.values()];hintCallbacks.forEach(fn=>fn());assert.equal(el('#hint').dataset.phase,'hidden');
// Scene gestures magnify the common projection, leaving physical photo/depth framing alone.
assert.ok(pointer.zoom,'scene gesture zoom is connected');
assert.equal(pointer.zoom.getZoom(),1);
const sceneBefore=renderOptics();const physicalBefore=renderer.draws.find(draw=>draw.target?.depthTexture);const inferenceBefore=provider.calls.length;
pointer.zoom.setZoom(2, { x: .5, y: -.25 });const sceneAfter=renderOptics();const physicalAfter=renderer.draws.find(draw=>draw.target?.depthTexture);
assert.equal(physicalAfter.camera.projectionMatrix.elements[8], .5, 'off-center zoom translates the common projection');assert.equal(physicalAfter.camera.projectionMatrix.elements[9], -.25);
assert.equal(physicalAfter.camera.zoom,2);assert.equal(sceneAfter.sceneZoom,2,'screen-relative legacy glass receives the same zoom');assert.deepEqual(sceneAfter.sceneOffset.toArray(),[-.5,.25],'legacy patterns receive the same projection shift');
assert.equal(physicalAfter.camera.projectionMatrix.elements[0],physicalBefore.camera.projectionMatrix.elements[0]*2,'zoom updates the actual projection matrix');
const shaderProjection=new THREE.Matrix4().multiplyMatrices(physicalAfter.camera.projectionMatrix,physicalAfter.camera.matrixWorldInverse);
assert.deepEqual(sceneAfter.projection.elements,shaderProjection.elements,'glass projection follows zoomed content camera');
const identity=sceneAfter.projection.clone().multiply(sceneAfter.inverseProjection);
identity.elements.forEach((value,index)=>assert.ok(Math.abs(value-(index%5===0?1:0))<1e-10,'glass inverse projection stays synchronized'));
assert.equal(physicalAfter.camera.position.z,3);assert.deepEqual(physicalAfter.scale.toArray(),physicalBefore.scale.toArray());assert.equal(physicalAfter.position.z,physicalBefore.position.z);assert.equal(sceneAfter.sourceZ,sceneBefore.sourceZ);assert.equal(sceneAfter.depth,sceneBefore.depth);assert.equal(el('#photo-zoom').value,'1.00');assert.equal(provider.calls.length,inferenceBefore);
assert.equal(el('#scene-zoom-reset').hidden,false);resizeCallback();assert.equal(renderOptics().camera.zoom,1,'post camera is independent');assert.equal(renderer.draws.find(draw=>draw.target?.depthTexture).camera.zoom,2,'resize preserves projection zoom');assert.equal(renderer.draws.find(draw=>draw.target?.depthTexture).camera.projectionMatrix.elements[8],.5,'resize reapplies anchored offset');
const canvas=el('#canvas-wrap');const oldWidth=canvas.width;canvas.width=375;resizeCallback();renderOptics();assert.equal(renderer.draws.find(draw=>draw.target?.depthTexture).camera.projectionMatrix.elements[8],.5,'aspect changes retain normalized anchored crop');canvas.width=oldWidth;resizeCallback();
el('#scene-zoom-reset').dispatch('click');assert.equal(pointer.zoom.getZoom(),1);assert.equal(el('#scene-zoom-reset').hidden,true);assert.deepEqual(renderOptics().sceneOffset.toArray(),[0,0],'whole view resets the offset too');
// Theme and overlay changes preserve camera, depth, photo, optics and manual framing.
pointer.zoom.setZoom(1.5, { x: -.4, y: .6 });const beforeTheme=renderOptics(); const callsBeforeTheme=provider.calls.length;
const beforeCamera=renderer.draws.find(draw=>draw.target?.depthTexture).camera.position.clone();
el('#theme-select').value='wood';el('#theme-select').dispatch('change');
const afterTheme=renderOptics();assert.equal(afterTheme.depth,beforeTheme.depth);assert.equal(afterTheme.sourceZ,beforeTheme.sourceZ);assert.equal(afterTheme.grid,beforeTheme.grid);assert.equal(provider.calls.length,callsBeforeTheme);
assert.equal(afterTheme.shaderBackground.getHexString(),'e6dfcf');assert.deepEqual(afterTheme.sceneOffset.toArray(),beforeTheme.sceneOffset.toArray(),'theme preserves anchored crop');
const photoCamera=renderer.draws.find(draw=>draw.target?.depthTexture).camera;assert.deepEqual(renderer.draws.at(-1).camera.projectionMatrix.elements,photoCamera.projectionMatrix.elements,'wood frame shares the same anchored projection');
assert.equal(el('#theme-select').value,'wood');assert.equal(renderer.draws.find(draw=>draw.target?.depthTexture).camera.zoom,1.5,'theme keeps whole-scene zoom');
el('#settings-open').dispatch('click');const blocked=renderer.draws.find(draw=>draw.target?.depthTexture).camera.position.clone();
pointer.move(.5,.5);el('#canvas-wrap').dispatch('keydown',{key:'ArrowLeft',preventDefault(){}});renderOptics();assert.deepEqual(renderer.draws.find(draw=>draw.target?.depthTexture).camera.position.toArray(),blocked.toArray(),'camera stays still behind modal');
el('#settings-close').dispatch('click');
// Zoom changes XY photo size, not the camera, source Z, depth identity or closest gap.
const zoomStart=renderOptics();const contentStart=renderer.draws.find(draw=>draw.target?.depthTexture);
el('#photo-zoom').value='2';el('#photo-zoom').dispatch('input');const zoomEnd=renderOptics();const contentEnd=renderer.draws.find(draw=>draw.target?.depthTexture);
assert.equal(contentEnd.scale.x,contentStart.scale.x*2);assert.equal(contentEnd.scale.y,contentStart.scale.y*2);assert.equal(contentEnd.scale.z,1);assert.equal(contentEnd.position.z,contentStart.position.z);assert.equal(zoomEnd.sourceZ,zoomStart.sourceZ);assert.equal(contentEnd.depth,contentStart.depth);assert.equal(contentEnd.photo,contentStart.photo);assert.equal(contentEnd.depthScale,contentStart.depthScale);assert.equal(provider.calls.length,callsBeforeTheme);assertGap(.1576);el('#scene-zoom-reset').dispatch('click');assert.equal(el('#photo-zoom').value,'2.00','full-view reset leaves hidden photo framing untouched');
// The real optional adapter executes with only GL/browser calls doubled. No model duplication.
const compareCalls=provider.calls.length;
const beforeComparison=renderOptics();
for(let toggle=0;toggle<3;toggle++){
el('#comparison-toggle').dispatch('click');await flush();
assert.equal(el('#comparison-overlay').hidden,false,'opt-in comparison opens');
assert.equal(el('#comparison-exit').hidden,false);
renderer.draws=[];frame(performance.now()+1000);
const custom=renderer.draws.find(draw=>draw.gloss!==undefined);
const photoDraws=renderer.draws.filter(draw=>draw.photo);
assert.ok(custom.target,'A renders its unchanged shader to a comparison target');
assert.ok(photoDraws.length>=2,'both use an actual photo depth mesh');
assert.equal(photoDraws[0].photo,photoDraws[1].photo);assert.equal(photoDraws[0].depth,photoDraws[1].depth);
assert.deepEqual(photoDraws[0].camera.projectionMatrix.elements,photoDraws[1].camera.projectionMatrix.elements);
assert.deepEqual(photoDraws[0].position.toArray(),photoDraws[1].position.toArray());
assert.deepEqual(photoDraws[0].scale.toArray(),photoDraws[1].scale.toArray());
assert.equal(provider.calls.length,compareCalls,'opening and frames share one inference');
assert.equal(renderer.draws.filter(draw=>draw.target===null).length,2,'both panes composited simultaneously');
el('#comparison-exit').dispatch('click');
assert.equal(el('#comparison-overlay').hidden,true);
assert.equal(renderer.internals.at(-1).userData.disposed,0,'shared standalone B remains reusable after comparison closes');
const restored=renderOptics();assert.equal(restored.depth,beforeComparison.depth);assert.equal(restored.sourceZ,beforeComparison.sourceZ);
assert.deepEqual(restored.sceneOffset.toArray(),beforeComparison.sceneOffset.toArray());
}
assert.equal(renderer.info.autoReset,true);
// Cancellation before the lazy import settles must not resurrect the comparison.
el('#comparison-toggle').dispatch('click');el('#comparison-toggle').dispatch('click');await flush();
assert.equal(el('#comparison-overlay').hidden,true);
// Renderer fallback is explicit and avoids r169’s invalid half-float LTC branch.
el('#comparison-toggle').dispatch('click');await flush();
assert.match(el('#comparison-label-b').textContent,/맞춤 굴절/);
el('#comparison-exit').dispatch('click');
// Unsupported optics return to A without changing the chosen controls.
el('#comparison-toggle').dispatch('click');await flush();
el('#effect').value='frosted';el('#effect').dispatch('change');await flush();
assert.equal(el('#comparison-overlay').hidden,true);assert.equal(el('#effect').value,'frosted');
el('#effect').value='mosaic';el('#effect').dispatch('change');await flush();
// Existing depth survives a failed rerun, and controls become usable for retry.
el('#ai-depth').dispatch('click');await flush();
el('#ai-depth').dispatch('click');await flush();
assert.equal(provider.pending.length,1,'repeat clicks while busy cannot duplicate inference');
provider.pending.shift().reject(new Error('test failure'));await flush();
assert.equal(inspection.texture,first);
assert.equal(el('#ai-depth').disabled,false);
assert.equal(el('#flow-overlay').dataset.journey,'idle','failed AI stops travel');
assert.match(el('#status').textContent,/실패/);assert.equal(el('#experience').dataset.flow,'error');assert.equal(el('#flow-retry').hidden,false);assert.equal(el('#flow-flat').hidden,false);
el('#ai-depth').dispatch('click');await flush();
const second=provider.finish();await flush();
assert.equal(inspection.texture,second);
assert.equal(first.userData.disposals,1);
// Framing and distance remain manual through another AI result.
for (const [id,value] of [['#photo-zoom','2'],['#photo-pan-x','0.4'],['#photo-pan-y','-0.2'],['#photo-glass-distance','21']]) {el(id).value=value;el(id).dispatch('input');}
el('#ai-depth').dispatch('click');await flush();provider.finish();await flush();
assert.equal(el('#photo-zoom').value,'2.00');
assert.equal(el('#photo-pan-x').value,'0.40');
assert.equal(el('#photo-pan-y').value,'-0.20');
assert.equal(el('#photoGlassDistanceOut').value,'21%');
assertGap(.1576);
// A late result cannot replace depth for a newly selected photo.
el('#ai-depth').dispatch('click');await flush();const stale=provider.finish();
el('#image').files=[{type:'image/png'}];el('#image').onchange({target:el('#image')});await flush();
assert.equal(stale.userData.disposals,1);
assert.notEqual(el('#flow-overlay').dataset.journey,'arrived','stale success must not arrive for replacement photo');
loader.apply(new THREE.Texture(new FakeImage()),.8,null);await flush();const latest=provider.finish();await flush();
assert.equal(inspection.texture,latest);
assert.equal(el('#photoGlassDistanceOut').value,'21%');
assertGap(.1576);
// Browser image decoding and canvas pixels are doubles; the production manual-depth
// handler and texture/provenance state execute unchanged.
globalThis.document={createElement:()=>new Element()};
el('#depth-image').files=[new Blob(['depth'],{type:'image/png'})];
el('#depth-image').dispatch('change');await flush();
assert.equal(el('#depth-provenance').hidden,false,'manual provenance stays visible outside developer tools');
assert.match(el('#depth-provenance').textContent,/직접 올린.*AI 추정 아님/);
assert.equal(el('#ai-depth').disabled,false);
assertGap(.1576);
delete globalThis.document;
// A manual depth upload during pending inference cancels inference and exits loading.
el('#ai-depth').dispatch('click');await flush();assert.equal(el('#experience').dataset.flow,'loading');
globalThis.document={createElement:()=>new Element()};el('#depth-image').files=[new Blob(['depth'],{type:'image/png'})];el('#depth-image').dispatch('change');await flush();delete globalThis.document;
assert.equal(el('#experience').dataset.flow,'ready','successful manual depth must recover the actual experience state');
assert.equal(el('#flow-overlay').dataset.journey,'idle','manual depth does not claim an AI arrival');
// Failed replacement must never offer a retry/flat action that silently uses the prior photo.
el('#image').files=[{type:'image/png'}];el('#image').onchange({target:el('#image')});loader.status('이미지를 읽지 못했습니다.');await flush();assert.equal(el('#experience').dataset.flow,'error');assert.equal(el('#flow-retry').hidden,true);assert.equal(el('#flow-flat').hidden,true);
// A video remains flat, with AI disabled.
const callsBeforeVideo=provider.calls.length;
loader.apply(new THREE.Texture({}),.8,{});await flush();
assert.equal(el('#ai-depth').disabled,true);
assert.equal(provider.calls.length,callsBeforeVideo);
loader.status('자동 재생이 차단되었습니다. 화면을 눌러 재생하세요.');assert.equal(el('#experience').dataset.flow,'error');assert.equal(el('#flow-retry').hidden,false,'video playback gets a retry');assert.equal(el('#flow-flat').hidden,true);
loader.status('');assert.equal(el('#experience').dataset.flow,'ready','successful retry status restores the video scene');
// Project the actual Three.js frame assemblies at neutral view: no screenshot/render claim.
for (const [width,height] of [[375,812],[768,1024],[1440,900]]) for (const photoAspect of [.25,1,4]) {
el('#canvas-wrap').width=width;el('#canvas-wrap').height=height;resizeCallback();
loader.apply(new THREE.Texture(new FakeImage()),photoAspect,null);await flush();provider.finish();await flush();
el('#reset').onclick();renderOptics();
const frameDraw=renderer.draws.find(draw=>draw.target===null&&draw.objects.some(object=>object.isGroup));
const points=[];
for(const object of frameDraw.objects) {object.updateMatrixWorld(true);object.traverseVisible(child=>{const positions=child.geometry?.getAttribute('position');if(!positions)return;for(let i=0;i<positions.count;i++) points.push(new THREE.Vector3().fromBufferAttribute(positions,i).applyMatrix4(child.matrixWorld).project(frameDraw.camera));});}
const minY=Math.min(...points.map(p=>p.y)),maxY=Math.max(...points.map(p=>p.y)),maxX=Math.max(...points.map(p=>Math.abs(p.x)));
assert.ok(minY>=-1&&maxY<=1&&maxX<=1,`neutral full assembly fits ${width}x${height}, photo aspect ${photoAspect}: ${minY},${maxY},${maxX}`);
}
// Execute production loss/recovery integration with DOM/WebGL boundaries doubled.
el('#reset').onclick();const beforeLoss=renderOptics();const callsBeforeLoss=provider.calls.length;
el('#comparison-toggle').dispatch('click');await flush();frame(performance.now()+1000);
assert.equal(el('#comparison-overlay').hidden,false);
renderer.lost=true;
renderer.domElement.dispatch('webglcontextlost',{preventDefault(){}});
assert.equal(el('#render-recovery').open,true);assert.equal(el('#canvas-wrap').inert,true);
assert.equal(el('#comparison-overlay').hidden,true,'context loss ends the extra renderer');
assert.equal(renderer.internals.at(-1).userData.disposed,0,'B stays selected/available through loss until restore invalidates its targets');
renderer.draws=[];frame(performance.now()+16);assert.equal(renderer.draws.length,0,'lost context receives no frame draws');
assert.equal(pointer.zoom.isEnabled(),false,'loss blocks pointer and gyro through experience state');
// A picker can have been opened before context loss, and return its result afterward.
const loadsBeforeLoss=loader.loads;
for(const id of ['#image','#video']) {el(id).files=[{type:'image/png'}];el(id).onchange({target:el(id)});}
assert.equal(loader.loads,loadsBeforeLoss,'late picker changes cannot start media during render loss');
assert.equal(el('#experience').dataset.flow,'ready','late picker changes cannot trap the restored scene in loading');
renderer.lost=false;renderer.domElement.dispatch('webglcontextrestored');await flush();
assert.equal(el('#comparison-overlay').hidden,true,'restoration never silently restarts double rendering');
assert.equal(el('#render-recovery').open,false);assert.equal(el('#canvas-wrap').inert,false);
assert.equal(provider.calls.length,callsBeforeLoss,'restoration never automatically restarts AI');
const restored=renderOptics();assert.equal(restored.depth,beforeLoss.depth);assert.equal(restored.sourceZ,beforeLoss.sourceZ);
assert.deepEqual(restored.sceneOffset.toArray(),beforeLoss.sceneOffset.toArray());
el('#ai-depth').dispatch('click');await flush();assert.equal(provider.pending.length,1);
renderer.domElement.dispatch('webglcontextlost',{preventDefault(){}});await flush();
assert.equal(provider.pending.length,0);assert.equal(el('#experience').dataset.flow,'error');
assert.equal(el('#ai-depth').disabled,true);renderer.domElement.dispatch('webglcontextrestored');await flush();
assert.equal(el('#ai-depth').disabled,false);assert.equal(provider.pending.length,0,'interrupted AI waits for explicit retry');
el('#flow-retry').dispatch('click');await flush();const pendingAtLoss=provider.finish();
renderer.domElement.dispatch('webglcontextlost',{preventDefault(){}});await flush();
assert.equal(pendingAtLoss.userData.disposals,1,'a transferred but not yet applied stale result is disposed');
renderer.domElement.dispatch('webglcontextrestored');await flush();
el('#depth-image').value='C:/fakepath/depth.png';el('#depth-image').files=[new Blob(['depth'],{type:'image/png'})];
el('#depth-image').dispatch('change');renderer.domElement.dispatch('webglcontextlost',{preventDefault(){}});await flush();
assert.equal(el('#depth-image').value,'','interrupted manual depth can be reselected after recovery');
renderer.domElement.dispatch('webglcontextrestored');await flush();
// Normal B flow remains independent after leaving the explicit A regression.
el('#effect').value='frosted';el('#effect').dispatch('change');assert.equal(el('#pattern-cards').hidden,true);
el('#glass-renderer').value='b';el('#glass-renderer').dispatch('change');await flush();
assert.equal(el('#pattern-cards').hidden,false,'returning from A Frosted to B restores the Gobang card');
const drawB=(advance=16)=>{if(animationNow!==null)animationNow+=advance;sampleFrame(animationNow??performance.now()+16);return renderer.draws.find(draw=>draw.objects.some(object=>object.material?.isMeshPhysicalMaterial));};
// Trigger a fresh source upload before injecting a shader failure: idle frames intentionally never compile/draw.
const injectMeshFailure=()=>{const photo=renderer.draws.find(draw=>draw.photo)?.photo;assert.ok(photo);photo.needsUpdate=true;renderer.failNextMesh=true;};
let bDraw=drawB();assert.ok(bDraw);assert.equal(renderer.draws.some(draw=>draw.gloss!==undefined),false);
for(const value of ['a','b','a']){el('#glass-renderer').value=value;el('#glass-renderer').dispatch('change');}
await flush();assert.equal(el('#glass-renderer').value,'a');assert.ok(renderOptics(),'latest A intent survives stale A-B-A lazy completions');
el('#glass-renderer').value='b';el('#glass-renderer').dispatch('change');await flush();assert.ok(drawB());
el('#comparison-toggle').dispatch('click');await flush();assert.equal(el('#comparison-overlay').hidden,false);
el('#glass-renderer').value='a';el('#glass-renderer').dispatch('change');await flush();
assert.equal(el('#comparison-overlay').hidden,true);assert.ok(renderOptics(),'B comparison to A retains the incoming renderer');
el('#glass-renderer').value='b';el('#glass-renderer').dispatch('change');await flush();assert.ok(drawB());
const beforeBDepth=inspection.texture;
el('#photo-glass-distance').value='16';el('#photo-glass-distance').dispatch('input');
pointer.zoom.setZoom(2,{x:.4,y:-.2});bDraw=drawB();
assert.equal(bDraw.camera.zoom,2);assert.equal(bDraw.depth,beforeBDepth);
const bGlass=bDraw.objects.find(object=>object.material?.isMeshPhysicalMaterial),bPhoto=bDraw.objects.find(object=>object.material?.uniforms?.uDepthMap);
const maxB=bPhoto.material.uniforms.uDepthMap.value.image.data.reduce((n,v,i)=>i%4===0?Math.max(n,v/255):n,0);
assert.ok(Math.abs(bGlass.position.z-bPhoto.position.z-maxB*.25-.1296)<1e-9,'default B keeps the selected closest-surface gap');
el('#reset').onclick();assert.equal(drawB().camera.zoom,1);assert.equal(el('#photoGlassDistanceOut').value,'16%');
loader.apply(new THREE.Texture(new FakeImage()),1.5,null);await flush();renderer.draws=[];frame(performance.now()+16);assert.equal(renderer.draws.length,0);
const newB=provider.finish();await flush();assert.equal(drawB().depth,newB,'B receives replacement photo/depth after AI');
renderer.lost=true;renderer.domElement.dispatch('webglcontextlost',{preventDefault(){}});renderer.draws=[];frame(performance.now()+16);assert.equal(renderer.draws.length,0);
renderer.lost=false;renderer.domElement.dispatch('webglcontextrestored');await flush();assert.equal(el('#glass-renderer').value,'b');assert.equal(drawB().depth,newB,'context restoration retains B and its depth');
injectMeshFailure();drawB();assert.equal(el('#mesh-render-error').open,true);assert.equal(el('#glass-renderer').value,'b','shader failure does not silently choose A');
renderer.draws=[];frame(performance.now()+16);assert.equal(renderer.draws.length,0,'failure waits for an explicit choice');
el('#mesh-retry').dispatch('click');assert.equal(el('#mesh-render-error').open,false);assert.ok(drawB(),'explicit retry rebuilds B');
injectMeshFailure();drawB();el('#mesh-use-a').dispatch('click');await flush();assert.equal(el('#glass-renderer').value,'a');assert.equal(el('#mesh-render-error').open,false);assert.ok(renderOptics(),'A requires the user recovery choice');
// Delayed real-geometry asset load enables door mechanics without another AI run.
el('#glass-renderer').value='b';el('#glass-renderer').dispatch('change');await flush();
drawB();if(el('#mesh-render-error').open)el('#mesh-retry').dispatch('click');
fetchFrameAllowed=true;el('#frame-retry').dispatch('click');await flush();
const roomCallsBefore=provider.calls.length,roomDepthBefore=inspection.texture;
assert.equal(el('#house-retry').hidden,false,'failed optional room has an explicit retry without blocking the photo');
el('#house-retry').dispatch('click');await flush();drawB();
const houseRoot=renderer.draws.flatMap(draw=>draw.objects).find(object=>object.name==='blender-house-surround');
const houseDepth=renderer.draws.flatMap(draw=>draw.objects).find(object=>object.name==='house-photo-depth-occluder');
assert.ok(houseRoot?.visible);assert.equal(houseDepth.visible,true);assert.equal(houseDepth.material.uniforms.uDepthMap.value,roomDepthBefore);
assert.equal(provider.calls.length,roomCallsBefore,'optional house retry does not start inference');assert.equal(el('#house-retry').hidden,true);
el('#settings-dialog').close();drawB();
// At the corner pose, distance/settings/resize must not re-scale the photo.
animationNow=performance.now()+2000;el('#camera-offset').value='0.75';el('#camera-offset').dispatch('input');
const neutralPhotoScale=drawB().objects.find(o=>o.material?.uniforms?.uDepthMap).scale.clone();
pointer.move(2,0);for(let i=0;i<180;i++)drawB(16);let closeView=drawB();
assert.equal(closeView.camera.fov,40,'new default keeps a fixed lens rather than fitting the doorway');
const closeDirection=closeView.camera.getWorldDirection(new THREE.Vector3());assert.ok(Math.abs(Math.atan2(-closeDirection.z,Math.abs(closeDirection.x))*180/Math.PI-25)<1e-6,'default maximum drag uses the approved wall-hug gaze');
const closePhoto=closeView.objects.find(o=>o.material?.uniforms?.uDepthMap);assert.deepEqual(closePhoto.scale.toArray(),neutralPhotoScale.toArray());
const framingBeforeResize=closePhoto.scale.toArray();resizeCallback();assert.deepEqual(drawB().objects.find(o=>o.material?.uniforms?.uDepthMap).scale.toArray(),framingBeforeResize,'same-size resize at close viewpoint must not crop photo');
el('#photo-glass-distance').value='16';el('#photo-glass-distance').dispatch('input');assert.deepEqual(drawB().objects.find(o=>o.material?.uniforms?.uDepthMap).scale.toArray(),framingBeforeResize,'reapplying distance at close viewpoint uses neutral geometry');
el('#reset').onclick();drawB();
assert.equal(el('#door-controls').hidden,false);assert.equal(renderer.localClippingEnabled,true);
animationNow=performance.now()+2000;
const beforeDoorPhoto=drawB().position.clone();
const glassPosition=()=>renderer.draws.find(draw=>draw.objects.some(o=>o.material?.isMeshPhysicalMaterial)).objects.find(o=>o.material?.isMeshPhysicalMaterial).position.x;
el('#door-toggle').dispatch('click');drawB(0);
assert.equal(glassPosition(),0,'a discrete open request starts at the rendered position, not its target');
drawB(175);const halfOpen=glassPosition();assert.ok(halfOpen<0);
drawB(175);assert.ok(glassPosition()<halfOpen,'open passes through an intermediate position before the endpoint');
el('#door-toggle').dispatch('click');drawB(350);assert.equal(glassPosition(),0);
el('#door-toggle').dispatch('keydown',{key:'ArrowLeft',preventDefault(){}});
bDraw=drawB();let slidingGlass=bDraw.objects.find(o=>o.material?.isMeshPhysicalMaterial);
assert.ok(slidingGlass.position.x<0);assert.deepEqual(bDraw.position.toArray(),beforeDoorPhoto.toArray(),'opening leaves the photo fixed');
const partialSlide=slidingGlass.position.x;
el('#glass-renderer').value='a';el('#glass-renderer').dispatch('change');await flush();renderOptics();assert.equal(el('#door-controls').hidden,true);assert.match(el('#door-notice').textContent,/A 진단.*닫힌/);
el('#glass-renderer').value='b';el('#glass-renderer').dispatch('change');await flush();assert.equal(drawB().objects.find(o=>o.material?.isMeshPhysicalMaterial).position.x,partialSlide);
el('#comparison-toggle').dispatch('click');await flush();drawB();assert.equal(el('#door-controls').hidden,true);assert.match(el('#door-notice').textContent,/A·B 비교.*닫힌/);
el('#comparison-exit').dispatch('click');assert.equal(drawB().objects.find(o=>o.material?.isMeshPhysicalMaterial).position.x,partialSlide);
renderer.domElement.dispatch('webglcontextlost',{preventDefault(){}});frame(performance.now()+16);assert.equal(el('#door-controls').hidden,true);
renderer.domElement.dispatch('webglcontextrestored');assert.equal(drawB().objects.find(o=>o.material?.isMeshPhysicalMaterial).position.x,partialSlide);
el('#door-toggle').dispatch('keydown',{key:'End',preventDefault(){}});bDraw=drawB();assert.ok(bDraw.objects.find(o=>o.material?.isMeshPhysicalMaterial).position.x<partialSlide);
el('#door-toggle').dispatch('keydown',{key:'Home',preventDefault(){}});assert.equal(drawB(350).objects.find(o=>o.material?.isMeshPhysicalMaterial).position.x,0);
// Ordinary sub-threshold tap jitter still toggles from the start position.
let handleX=parseFloat(el('#door-handle').style.left),handleY=parseFloat(el('#door-handle').style.top);
assert.equal(pointer.zoom.subjectDrag.begin({x:handleX,y:handleY}),true);
pointer.zoom.subjectDrag.move({x:handleX-.5,y:handleY});pointer.zoom.subjectDrag.end(false);
assert.ok(drawB(350).objects.find(o=>o.material?.isMeshPhysicalMaterial).position.x<partialSlide,'a jittery closed-handle tap opens fully');
el('#door-toggle').dispatch('click');drawB(350);
handleX=parseFloat(el('#door-handle').style.left);handleY=parseFloat(el('#door-handle').style.top);
assert.equal(pointer.zoom.subjectDrag.begin({x:handleX,y:handleY}),true);
pointer.zoom.subjectDrag.move({x:handleX-20,y:handleY});pointer.zoom.subjectDrag.end(true);
assert.ok(drawB().objects.find(o=>o.material?.isMeshPhysicalMaterial).position.x<0,'cancel retains reached partial position');
pointer.cancelled=false;documentRoot.hidden=true;documentEvents.dispatch('visibilitychange');assert.equal(pointer.cancelled,true,'background cancels door/view interaction');documentRoot.hidden=false;
pointer.cancelled=false;resizeCallback();assert.equal(pointer.cancelled,true,'resize cancels stale world-space drag anchors');
// Deterministic production-main animation/interrupt regressions. GL and DOM remain doubles.
const keyDoor=key=>el('#door-toggle').dispatch('keydown',{key,preventDefault(){}});
const closeDoor=()=>{keyDoor('Home');drawB(350);assert.equal(glassPosition(),0);};
const near=(a,b,message)=>assert.ok(Math.abs(a-b)<1e-10,message??`${a} != ${b}`);
const verifySynchronized=()=>{
const draw=renderer.draws.find(d=>d.objects.some(o=>o.material?.isMeshPhysicalMaterial));
const glass=draw.objects.find(o=>o.material?.isMeshPhysicalMaterial);
const sash=renderer.draws.flatMap(d=>d.objects).map(o=>o.getObjectByName('MovableSash')).find(Boolean);
assert.ok(sash);near(sash.position.x,glass.position.x,'sash and actual pane share every interpolated position');
const shader={uniforms:{},fragmentShader:THREE.ShaderLib.physical.fragmentShader};glass.material.onBeforeCompile(shader,{});
near(shader.uniforms.uBPaneCenterX.value,glass.position.x,'translated shader exits track the visible pane');
const pull=sash.getObjectByName('BrassLatch').getWorldPosition(new THREE.Vector3()).project(draw.camera);
near(parseFloat(el('#door-handle').style.left),(pull.x+1)*renderer.domElement.width/2,'44px hit target tracks moving brass pull');
near(parseFloat(el('#door-handle').style.top),(1-pull.y)*renderer.domElement.height/2);
assert.deepEqual(draw.position.toArray(),beforeDoorPhoto.toArray(),'animation does not move the photo');
return glass.position.x;
};
closeDoor();el('#door-toggle').dispatch('click');drawB(0);verifySynchronized();
let previous=0;for(let i=0;i<10;i++){drawB(35);const x=verifySynchronized();assert.ok(x<previous);previous=x;}
const fullSlide=previous;
// Reverse open-close-open between frames: no endpoint jump, and latest intent wins.
closeDoor();el('#door-toggle').dispatch('click');drawB(140);const reversingFrom=glassPosition();
el('#door-toggle').dispatch('click');drawB(0);assert.equal(glassPosition(),reversingFrom);
drawB(35);assert.ok(glassPosition()>reversingFrom);const openingAgain=glassPosition();
el('#door-toggle').dispatch('click');drawB(0);assert.equal(glassPosition(),openingAgain);
drawB(350);near(glassPosition(),fullSlide);
closeDoor();keyDoor('ArrowLeft');keyDoor('ArrowLeft');keyDoor('ArrowLeft');drawB(350);near(glassPosition(),fullSlide*.3,'key repeats accumulate target increments');
// A manual grab interrupts the trajectory at the visible handle, without jumping.
closeDoor();keyDoor('End');drawB(140);const grabbed=glassPosition();
handleX=parseFloat(el('#door-handle').style.left);handleY=parseFloat(el('#door-handle').style.top);
assert.equal(pointer.zoom.subjectDrag.begin({x:handleX,y:handleY}),true);drawB(700);assert.equal(glassPosition(),grabbed);
pointer.zoom.subjectDrag.move({x:handleX-24,y:handleY});const manualPosition=glassPosition();
assert.ok(manualPosition<grabbed,'manual drag updates directly before any RAF');pointer.zoom.subjectDrag.end(false);drawB(700);assert.equal(glassPosition(),manualPosition,'partial release does not snap');
// Exercise the real pointer controller with production-main callbacks for pinch priority.
const pointerSource=await readFile(new URL('../src/input/PointerController.ts',import.meta.url),'utf8');const pointerModule={};
vm.runInNewContext(ts.transpileModule(pointerSource,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText,{exports:pointerModule});
const captures=new Set();Object.assign(renderer.domElement,{setPointerCapture:id=>captures.add(id),hasPointerCapture:id=>captures.has(id),releasePointerCapture:id=>captures.delete(id)});
const realPointer=new pointerModule.PointerController(renderer.domElement,pointer.move,undefined,pointer.zoom);
const touch=(type,id,x,y)=>renderer.domElement.dispatch(type,{pointerId:id,pointerType:'touch',button:0,clientX:x,clientY:y});
closeDoor();keyDoor('End');drawB(105);const pinchFrozen=glassPosition();
touch('pointerdown',1,100,100);touch('pointerdown',2,200,100);drawB(700);assert.equal(glassPosition(),pinchFrozen,'pinch outside handle cancels button motion');
touch('pointerup',2,200,100);touch('pointerup',1,100,100);
handleX=parseFloat(el('#door-handle').style.left);handleY=parseFloat(el('#door-handle').style.top);
touch('pointerdown',3,handleX,handleY);touch('pointermove',3,handleX-24,handleY);const beforePinch=glassPosition();
touch('pointerdown',4,handleX-90,handleY);touch('pointerup',4,handleX-90,handleY);touch('pointermove',3,handleX-40,handleY);drawB(700);assert.equal(glassPosition(),beforePinch,'pinch handoff does not resume dragging from stale anchors');touch('pointerup',3,handleX-40,handleY);
realPointer.dispose();
// Every interruption holds the last frame even after a long gap and restoration.
for(const reason of ['background','resize','settings','renderer','comparison','theme','diagnostic','context','mesh failure']){
closeDoor();keyDoor('End');drawB(105);const held=glassPosition();
if(reason==='background'){documentRoot.hidden=true;documentEvents.dispatch('visibilitychange');animationNow+=5000;documentRoot.hidden=false;documentEvents.dispatch('visibilitychange');}
if(reason==='resize'){resizeCallback();animationNow+=5000;}
if(reason==='settings'){el('#settings-open').dispatch('click');animationNow+=5000;el('#settings-close').dispatch('click');}
if(reason==='renderer'){el('#glass-renderer').value='a';el('#glass-renderer').dispatch('change');await flush();animationNow+=5000;el('#glass-renderer').value='b';el('#glass-renderer').dispatch('change');await flush();}
if(reason==='comparison'){el('#comparison-toggle').dispatch('click');await flush();drawB(5000);el('#comparison-exit').dispatch('click');}
if(reason==='theme'){el('#theme-select').value='booth';el('#theme-select').dispatch('change');drawB(5000);el('#theme-select').value='wood';el('#theme-select').dispatch('change');}
if(reason==='diagnostic'){el('#view-mode').value='noglass';el('#view-mode').dispatch('change');drawB(5000);el('#view-mode').value='final';el('#view-mode').dispatch('change');}
if(reason==='context'){renderer.domElement.dispatch('webglcontextlost',{preventDefault(){}});drawB(5000);assert.equal(renderer.draws.length,0);renderer.domElement.dispatch('webglcontextrestored');}
if(reason==='mesh failure'){injectMeshFailure();drawB(0);assert.equal(el('#mesh-render-error').open,true);drawB(5000);assert.equal(renderer.draws.length,0);el('#mesh-retry').dispatch('click');}
drawB(0);assert.equal(glassPosition(),held,`${reason} must not finish or restart its stale motion`);drawB(700);assert.equal(glassPosition(),held);
}
motionPreference.matches=true;keyDoor('Home');drawB(0);assert.equal(glassPosition(),0);keyDoor('End');drawB(0);near(glassPosition(),fullSlide,'reduced-motion request is immediate');
motionPreference.matches=false;keyDoor('Home');drawB(105);assert.ok(glassPosition()<0);motionPreference.matches=true;drawB(0);assert.equal(glassPosition(),0,'live reduced-motion change settles on next scene frame');
motionPreference.matches=false;
// Room geometry uses the existing camera/zoom and never resets photo analysis or framing.
pointer.zoom.setZoom(2,{x:.4,y:-.2});const roomBefore=drawB(),roomProjection=roomBefore.camera.projectionMatrix.elements.slice(),roomPhoto=roomBefore.photo;
const roomMatrix=houseRoot.matrixWorld.toArray(),roomInference=provider.calls.length;
el('#theme-select').value='booth';el('#theme-select').dispatch('change');const boothRoom=drawB();
assert.equal(houseRoot.visible,false);assert.equal(houseDepth.visible,false);assert.equal(boothRoom.camera.zoom,2);assert.equal(boothRoom.photo,roomPhoto);assert.equal(boothRoom.depth,roomBefore.depth);
assert.equal(boothRoom.camera.projectionMatrix.elements[8],roomProjection[8]);assert.equal(boothRoom.camera.projectionMatrix.elements[9],roomProjection[9]);
el('#theme-select').value='wood';el('#theme-select').dispatch('change');const restoredRoom=drawB();assert.equal(houseRoot.visible,true);assert.equal(houseDepth.visible,true);assert.deepEqual(restoredRoom.scale.toArray(),roomBefore.scale.toArray());assert.deepEqual(houseRoot.matrixWorld.toArray(),roomMatrix);
assert.equal(provider.calls.length,roomInference);el('#scene-zoom-reset').dispatch('click');assert.equal(drawB().camera.zoom,1);
el('#view-mode').value='glass';el('#view-mode').dispatch('change');await flush();frame(animationNow);assert.equal(houseDepth.visible,false,'Glass Only never seeds a hidden photo into the room depth buffer');
el('#view-mode').value='final';el('#view-mode').dispatch('change');el('#glass-renderer').value='b';el('#glass-renderer').dispatch('change');await flush();drawB();assert.equal(houseDepth.visible,true);
renderer.domElement.dispatch('webglcontextlost',{preventDefault(){}});renderer.domElement.dispatch('webglcontextrestored');drawB();assert.equal(houseRoot.visible,true);assert.equal(provider.calls.length,roomInference,'context restoration never reloads AI for the room');
// Explicit photo adjustment changes shared framing, never the camera or scene zoom.
el('#flow-flat').dispatch('click');el('#reset').dispatch('click');drawB(0);
const editBefore=drawB(),editCamera=editBefore.camera.matrixWorld.toArray(),editProjection=editBefore.camera.projectionMatrix.toArray(),editCalls=provider.calls.length;
el('#photo-adjust-open').dispatch('click');assert.equal(el('#experience').dataset.photoAdjust,'true');
pointer.zoom.setZoom(2,{x:0,y:0},{x:0,y:0});pointer.move(.05,.04);
const editAfter=drawB();assert.equal(el('#photo-zoom').value,'2.00');assert.notEqual(el('#photo-pan-x').value,'0.00');
assert.deepEqual(editAfter.camera.matrixWorld.toArray(),editCamera);assert.deepEqual(editAfter.camera.projectionMatrix.toArray(),editProjection);assert.equal(provider.calls.length,editCalls);
assert.equal(pointer.zoom.subjectDrag.begin({x:480,y:300}),true);pointer.zoom.subjectDrag.move({x:490,y:310});pointer.zoom.subjectDrag.end(false);
el('#photo-adjust-done').dispatch('click');assert.equal(el('#experience').dataset.photoAdjust,undefined);
const preserved=el('#photo-pan-x').value;pointer.zoom.setZoom(1.5,{x:0,y:0});assert.equal(el('#photo-pan-x').value,preserved);assert.equal(drawB().camera.zoom,1.5);
el('#scene-zoom-reset').dispatch('click');el('#photo-reset').dispatch('click');drawB();
// Real main opt-in integration. The experiment's preview/segmentation boundary
// supplies an accepted result; original RGB, depth, composition and render state
// below execute unchanged. This is CPU state/geometry QA, not browser pixels.
assert.ok(peekingCallbacks?.onAccept&&peekingCallbacks?.onToggle);
assert.equal(peekingState.available,false,'ordinary uploads never silently enable the experiment');
assert.equal(peekingState.enabled,false);
const shaderFor=draw=>{
const material=draw.objects.find(object=>object.material?.isMeshPhysicalMaterial).material;
const shader={uniforms:{},fragmentShader:THREE.ShaderLib.physical.fragmentShader};material.onBeforeCompile(shader,{});return shader;
};
const photoObject=draw=>draw.objects.find(object=>object.material?.uniforms?.uDepthMap);
const assertFullPhoto=(draw,texture,depth)=>{
assert.equal(draw.photo,texture,'fallback displays the whole original RGB texture');
assert.equal(draw.depth,depth,'fallback reuses the accepted original-RGB depth');
assert.equal(photoObject(draw).material.uniforms.uUsePhotoAlpha.value,false,'fallback is the original opaque photo path');
assert.equal(shaderFor(draw).uniforms.uYardEnabled.value,0,'fallback disables the courtyard ray path');
const yard=draw.objects.find(object=>object.name==='experimental-courtyard');
if(yard)assert.equal(yard.visible,false,'fallback hides courtyard raster geometry');
};
const assertIdle=label=>{
// Complete camera settling and any finite history work before measuring.
for(let i=0;i<240;i++){animationNow+=1000/60;frame(animationNow);}
renderer.draws=[];
for(let i=0;i<600;i++){animationNow+=1000/60;frame(animationNow);}
assert.equal(renderer.draws.length,0,`${label}: ten seconds of idle submit no scene draws`);
};
const trajectory=()=>{
// Establish an exact integer timestamp before repeated 16 ms steps. Idle
// harness frames use 1000/60 and otherwise accumulate floating-point dt drift.
animationNow=Math.round(animationNow??performance.now());
const poses=[];
for(const [x,y] of [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[1,-1],[-1,1],[1,1]]){
el('#reset').onclick();drawB(0);pointer.move(x,y);
for(let i=0;i<90;i++){
const camera=drawB(16).camera;
poses.push([...camera.position.toArray(),...camera.quaternion.toArray(),camera.near,...camera.projectionMatrix.elements]);
}
}
el('#reset').onclick();drawB(0);return poses;
};
const legacyTrajectory=trajectory();
assertIdle('ordinary B before opt-in');
const prepared={
originalBlob:new Blob(['complete unmasked RGB fixture'],{type:'image/png'}),
originalImage:new FakeImage(),cutoutImage:new FakeImage(),width:4,height:4,
alpha:new Uint8Array([255,255,255,255,255,255,255,255,64,0,0,0,0,0,0,0]),
bounds:{x0:0,y0:0,x1:4,y1:2},disposals:0,dispose(){this.disposals++;}
};
prepared.originalImage.src='blob:accepted-original';prepared.cutoutImage.src='blob:accepted-alpha';
const inferenceBeforeAccept=provider.calls.length;
peekingCallbacks.onAccept(prepared);
assert.equal(provider.calls.length,inferenceBeforeAccept,'acceptance first decodes the original file');
assert.equal(await loader.lastFile.text(),await prepared.originalBlob.text(),'the loader receives original PNG bytes, never the cutout preview');
assert.equal(peekingState.busy,true);
const acceptedRGB=new THREE.Texture(prepared.originalImage);
loader.apply(acceptedRGB,1.5,null);await flush();
assert.equal(provider.calls.length,inferenceBeforeAccept+1);
assert.equal(provider.calls.at(-1).image,prepared.originalImage,'Depth Anything receives the unmasked original image');
assert.notEqual(provider.calls.at(-1).image,prepared.cutoutImage);
peekingCallbacks.onToggle(false);
assert.equal(peekingState.enabled,true,'busy inference blocks toggle changes');
const acceptedDepth=provider.finish();await flush();
let acceptedDraw=drawB();const acceptedPhoto=photoObject(acceptedDraw),acceptedCutout=acceptedDraw.photo;
assert.equal(acceptedCutout.image,prepared.cutoutImage,'accepted alpha becomes the B photo texture');
assert.equal(acceptedPhoto.material.uniforms.uUsePhotoAlpha.value,true);
assert.equal(shaderFor(acceptedDraw).uniforms.uBUsePhotoAlpha.value,true);
assert.equal(shaderFor(acceptedDraw).uniforms.uYardEnabled.value,1);
assert.equal(acceptedDraw.objects.find(object=>object.name==='experimental-courtyard')?.visible,true,'B raster and refraction share the enabled courtyard');
assert.equal(houseDepth.material.uniforms,acceptedPhoto.material.uniforms,'room depth occluder shares the actual alpha/depth uniforms');
assert.equal(acceptedDraw.depth,acceptedDepth);
assert.ok(Math.abs(acceptedDraw.position.z-(.6-.1296-.25))<1e-10,'conservative full-depth maximum keeps every soft-alpha interpolated triangle behind glass');
assert.ok(acceptedDraw.position.z+.25<=.6-.1296+1e-10,'alpha=64 edge at maximum depth preserves the original gap');
assert.equal(peekingState.available,true);assert.equal(peekingState.enabled,true);assert.equal(peekingState.busy,false);
assert.equal(prepared.disposals,0,'an applied result remains owned by the scene');
const courtyardTrajectory=trajectory();assert.equal(courtyardTrajectory.length,720);assert.ok(courtyardTrajectory.every(pose=>pose.every(Number.isFinite)),'all actual courtyard camera/projection poses remain finite');assert.notDeepEqual(courtyardTrajectory,legacyTrajectory,'courtyard corner path uses physical door dimensions');assert.deepEqual(drawB().camera.position.toArray(),[0,0,3],'courtyard reset remains neutral');
assertIdle('experimental B');
const inferenceAfterAccept=provider.calls.length;
// Camera rollback is immediate and never reloads/reframes the accepted photo.
pointer.zoom.setZoom(2.3,{x:.5,y:-.4});pointer.move(.8,.3);for(let i=0;i<180;i++)drawB(16);
const beforeComposition=drawB(),compositionInference=provider.calls.length;
for(const composition of ['legacy','wall','legacy','wall']){
el('#camera-composition').value=composition;el('#camera-composition').dispatch('change');
const after=drawB();assert.equal(after.photo,beforeComposition.photo);assert.equal(after.depth,beforeComposition.depth);
assert.deepEqual(after.position.toArray(),beforeComposition.position.toArray());assert.deepEqual(after.scale.toArray(),beforeComposition.scale.toArray());
assert.equal(after.camera.zoom,beforeComposition.camera.zoom);for(const index of [8,9])assert.equal(after.camera.projectionMatrix.elements[index],beforeComposition.camera.projectionMatrix.elements[index]);
assert.equal(provider.calls.length,compositionInference,'camera switch never repeats inference');
}
assert.match(el('#camera-composition-note').textContent,/이번 선택이 유지되지 않을 수/,'storage failure clearly describes reload behavior');
el('#reset').onclick();drawB();
// Toggling resets only the user-facing photo placement controls and reuses RGB/depth.
for(const [id,value] of [['#photo-zoom','1.7'],['#photo-pan-x','.6'],['#photo-pan-y','-.4']]){el(id).value=value;el(id).dispatch('input');}
peekingCallbacks.onToggle(false);let originalDraw=drawB();assertFullPhoto(originalDraw,acceptedRGB,acceptedDepth);
assert.equal(peekingState.available,true);assert.equal(peekingState.enabled,false);
for(const [id,value] of [['#photo-zoom','1.00'],['#photo-pan-x','0.00'],['#photo-pan-y','0.00']])assert.equal(el(id).value,value,'toggle resets photo placement');
const restoredTrajectory=trajectory();assert.equal(restoredTrajectory.length,legacyTrajectory.length);
const trajectoryError=Math.max(...restoredTrajectory.flatMap((pose,i)=>pose.map((value,j)=>Math.abs(value-legacyTrajectory[i][j]))));
assert.deepEqual(restoredTrajectory,legacyTrajectory,'leaving courtyard restores the original-photo physical room path');
console.log('Restored original-photo trajectory maximum error:',trajectoryError);assertIdle('full-photo B fallback');
peekingCallbacks.onToggle(true);acceptedDraw=drawB();assert.equal(acceptedDraw.photo,acceptedCutout,'re-enabling reuses the accepted cutout');
assert.equal(provider.calls.length,inferenceAfterAccept,'toggles perform no new depth inference');
// Theme and renderer fallbacks retain the original, with no experiment geometry.
el('#theme-select').value='booth';el('#theme-select').dispatch('change');assertFullPhoto(drawB(),acceptedRGB,acceptedDepth);
el('#theme-select').value='wood';el('#theme-select').dispatch('change');assert.equal(drawB().photo,acceptedCutout);
el('#glass-renderer').value='a';el('#glass-renderer').dispatch('change');await flush();renderOptics();
const fallbackA=renderer.draws.find(draw=>draw.target?.depthTexture);
assert.equal(fallbackA.photo,acceptedRGB,'preserved A uses the complete original photo');assert.equal(fallbackA.depth,acceptedDepth);
assert.equal(photoObject(fallbackA).material.uniforms.uUsePhotoAlpha.value,false);
el('#glass-renderer').value='b';el('#glass-renderer').dispatch('change');await flush();assert.equal(drawB().photo,acceptedCutout);
el('#comparison-toggle').dispatch('click');await flush();drawB();
const comparisonPhotos=renderer.draws.filter(draw=>draw.photo);
assert.ok(comparisonPhotos.length>=2);assert.ok(comparisonPhotos.every(draw=>draw.photo===acceptedRGB),'both comparison panes fall back to complete original RGB');
assert.ok(comparisonPhotos.every(draw=>draw.depth===acceptedDepth));
el('#comparison-exit').dispatch('click');assert.equal(drawB().photo,acceptedCutout);
renderer.domElement.dispatch('webglcontextlost',{preventDefault(){}});
assert.equal(peekingState.busy,true);
renderer.domElement.dispatch('webglcontextrestored');await flush();assert.equal(drawB().photo,acceptedCutout);
assert.equal(provider.calls.length,inferenceAfterAccept,'theme/renderer/comparison/context changes reuse inference');
// Replacing with an ordinary photo releases the experimental result and alpha texture.
let cutoutDisposals=0;acceptedCutout.addEventListener('dispose',()=>cutoutDisposals++);
// A successfully accepted new photo clears per-photo interaction state; opening a picker does not.
for(const [id,value] of [['#photo-zoom','2.2'],['#photo-pan-x','.6'],['#photo-pan-y','-.4']]){el(id).value=value;el(id).dispatch('input');}
pointer.zoom.setZoom(2.4,{x:.4,y:-.2});pointer.move(.4,.2);drawB(100);keyDoor('End');drawB(700);
const dirtyReplacementView=drawB();assert.equal(dirtyReplacementView.camera.zoom,2.4);assert.notEqual(dirtyReplacementView.camera.projectionMatrix.elements[8],0);assert.notDeepEqual(dirtyReplacementView.camera.position.toArray(),[0,0,3]);assert.notEqual(glassPosition(),0);
const disconnectsBeforeReplacement=gyroDisconnects;
// Cancelled picker and rejected type must not erase the existing adjustments.
el('#image').files=[];el('#image').onchange({target:el('#image')});assert.equal(el('#photo-zoom').value,'2.20');assert.equal(gyroDisconnects,disconnectsBeforeReplacement);
el('#image').files=[new File(['bad'],'note.txt',{type:'text/plain'})];el('#image').onchange({target:el('#image')});assert.equal(el('#photo-zoom').value,'2.20');assert.equal(gyroDisconnects,disconnectsBeforeReplacement);
el('#image').files=[new File(['replacement'],'replacement.png',{type:'image/png'})];el('#image').onchange({target:el('#image')});
assert.equal(gyroDisconnects,disconnectsBeforeReplacement);
const replacementRGB=new THREE.Texture(new FakeImage());loader.apply(replacementRGB,1.5,null);assert.equal(gyroDisconnects,disconnectsBeforeReplacement+1);assert.equal(el('#photo-zoom').value,'1.00');assert.equal(el('#photo-pan-x').value,'0.00');assert.equal(el('#photo-pan-y').value,'0.00');assert.equal(el('#scene-zoom-reset').hidden,true);await flush();
const replacementDepth=provider.finish();await flush();assertFullPhoto(drawB(),replacementRGB,replacementDepth);const resetView=drawB();assert.equal(resetView.camera.zoom,1);assert.equal(Math.abs(resetView.camera.projectionMatrix.elements[8]),0);assert.equal(Math.abs(resetView.camera.projectionMatrix.elements[9]),0);assert.deepEqual(resetView.camera.position.toArray(),[0,0,3]);assert.equal(glassPosition(),0);
assert.equal(prepared.disposals,1);assert.equal(cutoutDisposals,1);
assert.equal(peekingState.available,false);assert.equal(peekingState.enabled,false);
peekingCallbacks.onToggle(true);assertFullPhoto(drawB(),replacementRGB,replacementDepth);
assert.equal(provider.calls.length,inferenceAfterAccept+1,'one ordinary replacement produces exactly one new inference');
assertIdle('ordinary B after replacement');
// View settings prevent the canvas keyboard route moving behind its controls.
el('#settings-open').dispatch('click');el('#tab-view').dispatch('click');
assert.equal(pointer.zoom.isEnabled(),false,'movement panel suspends canvas gestures');
let consumedWhilePanelOpen=false;
el('#canvas-wrap').dispatch('keydown',{key:'ArrowRight',preventDefault(){consumedWhilePanelOpen=true;}});
assert.equal(consumedWhilePanelOpen,false,'canvas keyboard view pauses with the movement panel open');
el('#settings-close').dispatch('click');
assert.equal(pointer.zoom.isEnabled(),true);
let sharedPhotoDisposed=0;houseDepth.geometry.addEventListener('dispose',()=>sharedPhotoDisposed++);
teardown();assert.equal(peekingBoundary.disposeCalls,1,'teardown releases the experiment UI boundary');assert.equal(sharedPhotoDisposed,1,'the depth-only room pass never takes ownership of shared photo geometry');
console.log('Main integration passed (default B recovery + preserved A + optional comparison lifecycle): fixed gloss, binary curvature, control limits, original distance mapping/persistence, automatic inference, full-width rendering/drag, inspector, repeat/failure/retry, manual controls/provenance, card reselect, keyboard, stale result and video handling; accepted original-RGB depth, cutout toggles and fallbacks, 720 per-mode physical-room camera poses, four 600-frame idle windows, replacement cleanup (no GPU execution)');
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import ts from 'typescript';
import * as THREE from 'three';
import {loadTypescript} from './support/load-typescript.mjs';
const {CameraController}=await loadTypescript(new URL('../src/input/CameraController.ts',import.meta.url));
test('camera absolute input rejects invalid readings, clamps, and smooths at default .75', () => {
const camera = new THREE.PerspectiveCamera(), controller = new CameraController(camera);
controller.setView(3, -3); assert.deepEqual(controller.target.toArray(), [1, -1]);
controller.setView(NaN, 0); assert.deepEqual(controller.target.toArray(), [1, -1]);
const first = controller.update(1 / 60, 1); assert.ok(first.x > 0 && first.x < 1);
for (let i = 0; i < 120; i++) controller.update(1 / 60, 2);
assert.equal(camera.position.x, .75); assert.equal(camera.position.y, -.75); assert.equal(camera.position.z, 3);
const before = controller.current.toArray(); controller.hold(); controller.update(.1, 2); assert.deepEqual(controller.current.toArray(), before);
controller.setMaxOffset(NaN); controller.update(.1, 2); assert.equal(camera.position.x, .75);
});
test('the existing camera smoothing is frame-rate independent', () => {
const results = [30, 60, 120].map(fps => { const c = new CameraController(new THREE.PerspectiveCamera()); c.setView(.75, -.25); for (let i = 0; i < fps; i++) c.update(1 / fps, 1); return c.current.x; });
assert.ok(Math.max(...results) - Math.min(...results) < 1e-12);
});
test('integration keeps scene zoom separate and freezes camera at pinch start', async () => {
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
assert.match(main, /if \(pinching\)\s*\{?\s*cameraController\.hold\(\)/);
assert.match(main, /gyro\.setPaused\(experience\.isBlocked\(\) \|\| experience\.isPhotoAdjusting\(\) \|\| pointer\.isInteracting\)/);
assert.match(main, /gyroButton\.onclick = \(\) => \{ if \(mobileTilt && !experience\.isBlocked\(\) && !experience\.isPhotoAdjusting\(\)\) void gyro\.toggle\(\); \}/);
assert.match(main, /gyroRecenter\.addEventListener\('click', \(\) => \{ if\(experience\.isBlocked\(\)\|\|experience\.isPhotoAdjusting\(\)\)return;cameraController\.reset\(\); gyro\.reset\(\); \}/);
assert.match(main, /gyro\.syncManualView\(\)/);
assert.match(html, /id="gyro"[^>]*aria-pressed="false"[^>]*>[\s\S]*?id="gyro-label">기울기 연결/);
assert.match(html, /id="gyro-status"[^>]*role="status"/);
assert.match(html, /센서값은 이 기기 안에서만/);
assert.match(main, /gyro\.dispose\(\)/);
});
test('real pointer, gyro and camera controllers freeze pending smoothing and resume manual view', async () => {
async function controllerModule(name) {
const text = await readFile(new URL(`../src/input/${name}.ts`, import.meta.url), 'utf8');
const output = ts.transpileModule(text, { compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ES2020 } }).outputText;
return import(`data:text/javascript;base64,${Buffer.from(output).toString('base64')}`);
}
const { PointerController } = await controllerModule('PointerController');
const { GyroController } = await controllerModule('GyroController');
class Events {
handlers = new Map(); captures = new Set(); classList = { add() {}, remove() {} };
addEventListener(type, fn) { if (!this.handlers.has(type)) this.handlers.set(type, new Set()); this.handlers.get(type).add(fn); }
removeEventListener(type, fn) { this.handlers.get(type)?.delete(fn); }
emit(type, init = {}) { for (const fn of [...(this.handlers.get(type) ?? [])]) fn({ pointerType: 'touch', pointerId: 1, clientX: 0, clientY: 0, ...init }); }
setPointerCapture(id) { this.captures.add(id); }
hasPointerCapture(id) { return this.captures.has(id); }
releasePointerCapture(id) { this.captures.delete(id); }
getBoundingClientRect() { return { left: 0, top: 0, width: 200, height: 100 }; }
}
const win = new Events(), doc = new Events(), element = new Events(), orientation = new Events();
const timers = new Map(); let timer = 0;
Object.assign(win, { screen: { orientation }, DeviceOrientationEvent: {}, isSecureContext: true, setTimeout: fn => { timers.set(++timer, fn); return timer; }, clearTimeout: id => timers.delete(id) });
orientation.angle = 0; doc.hidden = false; globalThis.window = win; globalThis.document = doc;
const camera = new THREE.PerspectiveCamera(), controller = new CameraController(camera);
const gyro = new GyroController((x, y) => controller.setView(x, y), () => {}, () => ({ x: controller.target.x, y: controller.target.y }));
let zoom = 1;
const pointer = new PointerController(element, (x, y) => { controller.add(x, y); gyro.syncManualView(); }, undefined, {
getZoom: () => zoom, setZoom: value => { zoom = value; },
onGestureChange: (active, pinching) => { if (pinching) controller.hold(); gyro.setPaused(active); },
});
const reading = (beta, gamma) => win.emit('deviceorientation', { alpha: 0, beta, gamma });
await gyro.toggle(); reading(0, 0); reading(0, 30); controller.update(1 / 60, 1);
assert.ok(controller.target.x > controller.current.x, 'smoothing was still in flight');
element.emit('pointerdown'); element.emit('pointerdown', { pointerId: 2, clientX: 100 });
const held = camera.position.clone(); reading(45, -30);
element.emit('pointermove', { pointerId: 2, clientX: 150 }); assert.equal(zoom, 1.5);
for (let i = 0; i < 20; i++) controller.update(1 / 60, 1);
assert.deepEqual(camera.position.toArray(), held.toArray(), 'pinch freezes real camera, including remaining smoothing');
element.emit('pointerup', { pointerId: 2 }); element.emit('pointermove', { clientX: 20 });
const manual = controller.target.clone(); assert.ok(manual.x > held.x / .75);
element.emit('pointerup'); reading(45, -30);
assert.deepEqual(controller.target.toArray(), manual.toArray(), 'first sensor sample preserves surviving-finger drag');
controller.add(-.06, 0); gyro.syncManualView(); const keyboard = controller.target.clone(); reading(50, -25);
assert.deepEqual(controller.target.toArray(), keyboard.toArray(), 'first sensor sample preserves keyboard view');
pointer.dispose(); gyro.dispose(); assert.equal(timers.size, 0);
});
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
const root = new URL('../', import.meta.url);
const css = await readFile(new URL('src/style.css', root), 'utf8');
const html = await readFile(new URL('index.html', root), 'utf8');
const face = css.match(/@font-face\s*\{([^}]+)\}/)?.[1] ?? '';
assert.match(face, /font-family:\s*"BM Kkubulim"/, 'the requested font has an explicit local webfont face');
assert.match(face, /src:\s*url\("\/fonts\/BMKkubulim\.woff"\)\s*format\("woff"\)/);
assert.match(face, /font-display:\s*swap/, 'text remains available while the font loads');
assert.match(face, /font-weight:\s*400/);
assert.match(face, /font-style:\s*normal/);
assert.match(css, /--font-ui:\s*"BM Kkubulim"[^;]+system-ui,sans-serif/);
assert.match(css, /:root\s*\{[^}]*font-family:\s*var\(--font-ui\)/);
assert.match(css, /font-synthesis:\s*none/, 'do not invent a bold or italic font face');
assert.doesNotMatch(css, /font-weight:\s*(?:[5-9]\d\d|bold|bolder)/, 'only the official regular weight is requested');
assert.doesNotMatch(css, /"Batang"|"Noto Serif KR"|"Arial Rounded MT Bold"/, 'old theme-specific font families cannot win the cascade');
assert.match(css, /:root\[data-theme=wood\]\s*\{(?![^}]*font-family)/, 'wood shares the requested font');
assert.match(css, /\.welcome h1\s*\{[^}]*font-family:\s*inherit/);
assert.match(css, /output,input\[type=number\],pre,code\s*\{[^}]*font-family:\s*var\(--font-values\)/, 'technical values keep a readable fixed-width face');
assert.match(css, /strong,b\s*\{[^}]*font-weight:\s*400/);
assert.match(html, /<link\s+rel="preload"\s+href="\/fonts\/BMKkubulim\.woff"\s+as="font"\s+type="font\/woff"\s+crossorigin>/);
const font = await readFile(new URL('public/fonts/BMKkubulim.woff', root));
assert.equal(font.toString('ascii', 0, 4), 'wOFF', 'bundled asset is a real WOFF font');
assert.equal(font.readUInt32BE(8), font.length, 'font header length matches the asset');
assert.equal(createHash('sha256').update(font).digest('hex'), 'dc1e920bfdc5fd3b90189687e3f1ec3335e7a009015bca11d5b2348c4f4257ec', 'bundle the verified lossless official font');
assert.ok(font.length > 100_000 && font.length < 3_000_000, 'the complete font remains reasonably sized');
const license = await readFile(new URL('public/licenses/BMKkubulim-OFL.txt', root), 'utf8');
for (const text of ['Woowa Brothers', 'BMkkubulim', 'SIL OPEN FONT LICENSE', 'PERMISSION & CONDITIONS', 'TERMINATION', 'DISCLAIMER']) assert.ok(license.includes(text), `license includes ${text}`);
const notices = await readFile(new URL('public/THIRD-PARTY-NOTICES.txt', root), 'utf8');
assert.match(notices, /licenses\/BMKkubulim-OFL\.txt/);
console.log('UI font passed: local Kkubulim face/preload, shared themes, real font asset, regular weight, readable numeric fallback and complete license wiring (source checks, no browser layout engine).');
+25
View File
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import * as THREE from 'three';
import {loadTypescript} from './support/load-typescript.mjs';
import {loadFrameGeometry} from './support/load-frame-geometry.mjs';
const {CameraController}=await loadTypescript(new URL('../src/input/CameraController.ts',import.meta.url));
const {prepareHouseAsset,houseRoomBounds}=await loadTypescript(new URL('../src/objects/BlenderHouseSurround.ts',import.meta.url));
const {fitWindowScale}=await loadTypescript(new URL('../src/objects/BlenderWindowFrame.ts',import.meta.url));
test('experimental wall-hug original and courtyard rooms contain zoomed anchored near frusta through open/partial/closed doors',async()=>{
const {SceneZoomController}=await loadTypescript(new URL('../src/input/SceneZoomController.ts',import.meta.url));
const {prepareFrameAsset}=await loadTypescript(new URL('../src/objects/BlenderWindowFrame.ts',import.meta.url));
const {peekingLayout}=await loadTypescript(new URL('../src/objects/PeekingComposition.ts',import.meta.url));
let rays=0;
for(const aspect of[375/812,844/390])for(const mode of['original','courtyard'])for(const photoAspect of[.2,.8,5]){
const fit=fitWindowScale(aspect,photoAspect)*.82,layout=mode==='courtyard'?peekingLayout(aspect,photoAspect):{width:fit*photoAspect*.8,height:fit*.8},w=layout.width,h=layout.height,bounds=houseRoomBounds(w,h,.6);
const root=loadFrameGeometry(new URL('../public/assets/window/grandma-house-surround.glb',import.meta.url)),room=prepareHouseAsset(root),frame=loadFrameGeometry(),sash=prepareFrameAsset(frame),group=new THREE.Group();group.add(root,frame);room.resize(w,h,.6,layout.clearanceScale,true);sash.resize(w,h,.6);
const camera=new THREE.PerspectiveCamera(40,aspect,.1,100),control=new CameraController(camera),zoom=new SceneZoomController(camera);control.setRoomBounds(w,h,.6);control.setCompositionMode('wall');
for(const range of[.75,.9])for(const open of[0,.5,1])for(const [x,y]of[[-1,0],[1,0],[-.5,0],[.5,0],[-1,-1],[1,1],[0,-1]])for(const z of[1,2,3]){
control.setMaxOffset(range);control.setView(x,y);control.update(10,1);sash.setOpen(open);zoom.setZoom(1);zoom.setZoom(z,{x:x,y:y||1});camera.updateMatrixWorld(true);group.updateMatrixWorld(true);
for(const px of[-1,0,1])for(const py of[-1,0,1]){const near=new THREE.Vector3(px,py,-1).unproject(camera);assert.ok(near.y>bounds.floor-1e-8&&Math.abs(near.x)<bounds.halfWidth&&near.z<bounds.rear,'zoomed near frustum stays in chamber');const ray=new THREE.Raycaster();ray.setFromCamera(new THREE.Vector2(px,py),camera);const hit=ray.intersectObject(group,true).find(h=>!(h.object.material.clippingPlanes??[]).some(p=>p.distanceToPoint(h.point)<0));if(hit)assert.ok(-hit.point.clone().applyMatrix4(camera.matrixWorldInverse).z>=camera.near-1e-7,'nearest room/frame surface stays beyond near plane');rays++;}
}
room.dispose();sash.dispose();
}
console.log('Anchored original/courtyard near-frustum rays:',rays);
});
+121
View File
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import { pathToFileURL, fileURLToPath } from 'node:url';
import ts from 'typescript';
async function loadTS(relative) {
const file = new URL(relative, import.meta.url);
let source = await readFile(file, 'utf8');
source = source.replace(/from ['"]three['"]/g, `from '${pathToFileURL(fileURLToPath(new URL('../node_modules/three/build/three.module.js', import.meta.url)))}'`);
const imports = [...source.matchAll(/from ['"](\.[^'"]+)['"]/g)];
for (const match of imports) {
const child = new URL(match[1] + '.ts', file);
const url = await compile(child);
source = source.replace(match[0], `from '${url}'`);
}
return import(encode(source));
}
function encode(source) { return `data:text/javascript;base64,${Buffer.from(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ES2020, target: ts.ScriptTarget.ES2020 } }).outputText).toString('base64')}`; }
async function compile(file) {
let source = await readFile(file, 'utf8');
source = source.replace(/from ['"]three['"]/g, `from '${new URL('../node_modules/three/build/three.module.js', import.meta.url)}'`);
for (const match of [...source.matchAll(/from ['"](\.[^'"]+)['"]/g)]) source = source.replace(match[0], `from '${await compile(new URL(match[1] + '.ts', file))}'`);
return encode(source);
}
const { comparisonLayout, paneAt } = await loadTS('../src/rendering/ComparisonLayout.ts');
const { createPatternedGlassGeometry, sampleGobangSurface } = await loadTS('../src/rendering/PatternedGlassGeometry.ts');
const { ComparisonMetrics } = await loadTS('../src/rendering/ComparisonMetrics.ts');
test('desktop and mobile comparison preserve a common unchanged projection aspect', () => {
for (const [width,height,aspect] of [[1440,900,1.6],[390,844,390/844],[844,390,390/844],[320,320,1]]) {
const {panes} = comparisonLayout(width,height,aspect);
const [a,b] = panes;
assert.equal(a.width,b.width); assert.equal(a.height,b.height);
assert.ok(Math.abs(a.width/a.height-aspect)<1e-10);
for (const p of panes) { assert.ok(p.x>=0 && p.y>=0); assert.ok(p.x+p.width<=width+1e-8); assert.ok(p.y+p.height<=height+1e-8); }
assert.ok(a.x+a.width<=b.x || a.y+a.height<=b.y);
assert.equal(paneAt(panes,b.x+b.width/2,b.y+b.height/2),b);
}
});
test('actual glass has finite front normals, closed edges and the same A back plane', () => {
for(const curvature of [-1,1]) {
const result=createPatternedGlassGeometry({width:1,height:1.32,grid:20,curvature});
const geometry=result.geometry, pos=geometry.attributes.position, normal=geometry.attributes.normal;
assert.ok(geometry.index.count/3<100000,'bounded tessellation, not Blender 275k mesh');
assert.ok(geometry.index.count/3>1000,'real patterned geometry');
assert.ok(pos.array.every(Number.isFinite)); assert.ok(normal.array.every(Number.isFinite));
assert.ok(Math.abs(result.backZ-(-.008-.085/20))<1e-10);
assert.ok(result.minThickness>0);
assert.ok(result.maxThickness>result.minThickness);
const samples=[];
for(let i=0;i<pos.count;i++) if(normal.getZ(i)>.5) samples.push(pos.getZ(i));
assert.ok(Math.max(...samples)-Math.min(...samples)>.002);
// Weld attribute seams by position, then require two incident triangles on every edge.
const key=i=>[pos.getX(i),pos.getY(i),pos.getZ(i)].map(v=>v.toFixed(6)).join(',');
const edges=new Map();
for(let i=0;i<geometry.index.count;i+=3) for(const [a,b] of [[0,1],[1,2],[2,0]]) { const edge=[key(geometry.index.array[i+a]),key(geometry.index.array[i+b])].sort().join('|'); edges.set(edge,(edges.get(edge)||0)+1); }
assert.ok([...edges.values()].every(n=>n===2),'watertight triangulated volume');
geometry.dispose();
}
});
test('geometry uses the existing gobang profile and periodic analytic normals', () => {
const c={width:1,height:1.32,grid:20,curvature:1};
const p=sampleGobangSurface(.125,.2,c), q=sampleGobangSurface(.175,.2,c);
assert.ok(Math.abs(p.height-q.height)<1e-10);
const epsilon=1e-6;
const derivative=(sampleGobangSurface(.126+epsilon,.203,c).height-sampleGobangSurface(.126-epsilon,.203,c).height)/(2*epsilon*c.width);
const center=sampleGobangSurface(.126,.203,c);
assert.ok(Math.abs(derivative-center.dx)<1e-5);
const inv=sampleGobangSurface(.126,.203,{...c,curvature:-1});
assert.equal(inv.height,-center.height); assert.equal(inv.dx,-center.dx);
});
test('metrics keep CPU submission time separate from cadence and never invent GPU memory', () => {
const metrics=new ComparisonMetrics();
metrics.record('a',2.5,{calls:4,triangles:100}); metrics.record('b',5,{calls:6,triangles:200});
metrics.frame(100); metrics.frame(120);
const snap=metrics.snapshot();
assert.equal(snap.a.cpuMs,2.5); assert.equal(snap.b.cpuMs,5);
assert.equal(snap.a.calls,4); assert.equal(snap.b.triangles,200);
assert.equal(snap.frameMs,20);
assert.equal('gpuMs' in snap,false); assert.equal('gpuMemory' in snap,false);
metrics.reset(); assert.equal(metrics.snapshot().frameMs,null);
});
test('B uses explicitly extended Three transmission with shared photo/depth, never A refraction shader', async () => {
const b=await readFile(new URL('../src/rendering/MeshGlassRenderer.ts',import.meta.url),'utf8');
assert.match(b,/new THREE\.MeshPhysicalMaterial/); assert.match(b,/transmission:\s*1/);
assert.doesNotMatch(b,/mosaic\.frag|new THREE\.ShaderMaterial|AiDepthProvider|Worker\(/);
assert.match(b,/attachMeshRefraction\(this\.material,depthSource\)/);
assert.match(b,/depthSource\.geometry/); assert.match(b,/depthSource\.material/);
assert.match(b,/dispose\(\)/);
const main=await readFile(new URL('../src/main.ts',import.meta.url),'utf8');
assert.match(main,/import\('\.\/rendering\/PhysicalGlassComparison'\)/,'lazy opt-in compositor');
assert.equal((main.match(/new AiDepthProvider/g)||[]).length,1);
assert.match(main,/comparison\?\.dispose/);
assert.match(main,/renderLost \|\| aiDepthBusy \|\| document\.hidden/);
});
test('tessellation stays bounded on extreme aspect ratios and approximates the height profile', () => {
for(const height of [.02,.5,1.32,4,30]) {
const shape={width:1,height,grid:24,curvature:1};
const result=createPatternedGlassGeometry(shape),g=result.geometry;
assert.ok(g.index.count/3<86000,`hard triangle budget at ratio ${height}`);
assert.ok(g.attributes.position.count<50000,`hard vertex budget at ratio ${height}`);
if(height===1.32) {
let maxError=0;
const p=g.attributes.position,n=g.attributes.normal;
for(let i=0;i<g.index.count;i+=3) {
const [a,b,c]=[g.index.array[i],g.index.array[i+1],g.index.array[i+2]];
if(n.getZ(a)<.5||n.getZ(b)<.5||n.getZ(c)<.5)continue;
const x=(p.getX(a)+p.getX(b)+p.getX(c))/3,y=(p.getY(a)+p.getY(b)+p.getY(c))/3,z=(p.getZ(a)+p.getZ(b)+p.getZ(c))/3;
const actual=sampleGobangSurface(x+.5,y/height+.5,shape).height;
maxError=Math.max(maxError,Math.abs(z-actual));
}
assert.ok(maxError/(.085/24)<.04,`sampled triangle-centroid height error ${maxError/(.085/24)} of amplitude`);
}
g.dispose();
}
});
+32
View File
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import * as THREE from 'three';
assert.ok(existsSync(new URL('../src/objects/WindowFinish.ts', import.meta.url)), 'real frame finish is missing');
const { WindowFinish } = await import('../src/objects/WindowFinish.ts');
const { WindowReveal } = await import('../src/objects/WindowReveal.ts');
const frameGeometry = new THREE.BoxGeometry(1,1,1);
const colors=[0xb4a0c5,0xc3afd3,0x9d86b0,0xd9c7e6,0xaa91bd,0xc0a9d2];
const materials=colors.map(color=>new THREE.MeshBasicMaterial({color}));
const frames=Array.from({length:4},()=>new THREE.Mesh(frameGeometry,materials));
const revealMaterial=new THREE.MeshBasicMaterial({color:colors[1],side:THREE.DoubleSide});
const reveal=new WindowReveal(revealMaterial);const scene=new THREE.Scene();scene.add(...frames,...reveal.meshes);
const finish=new WindowFinish(scene,frames,reveal,materials,revealMaterial);
finish.update(1,.8,.6,.035,.0525);finish.apply('wood');
assert.ok(frames.every(frame=>frame.material.every(m=>m.isMeshStandardMaterial&&m.map&&m.bumpMap)),'wood must be textured and lit in WebGL');
assert.notEqual(frames[0].material[4].map,frames[2].material[4].map,'vertical stiles and horizontal rails need separately oriented grain');
assert.ok(reveal.meshes.every(mesh=>mesh.material.map),'inner reveal must receive the wooden finish');
assert.ok(reveal.meshes.every(mesh=>mesh.geometry.getAttribute('uv')),'textured reveals need UVs');
for (const mesh of reveal.meshes) {
const uv=mesh.geometry.getAttribute('uv');mesh.material.map.updateMatrix();
const a=new THREE.Vector2(uv.getX(0),uv.getY(0)).applyMatrix3(mesh.material.map.matrix);
const b=new THREE.Vector2(uv.getX(1),uv.getY(1)).applyMatrix3(mesh.material.map.matrix);
assert.ok(Math.abs(b.x-a.x)<1e-8 && Math.abs(b.y-a.y)>.9,'longitudinal reveal edge must follow the wood fibers on every side');
}
const decorative=scene.children.filter(x=>x.isGroup);
assert.ok(decorative.some(group=>group.visible&&group.children.some(x=>x.name==='brass-latch')),'actual brass hardware must be present');
const identities=frames.map(f=>[f.geometry,f.position,f.scale]);
finish.apply('booth');assert.ok(frames.every(f=>f.material===materials));assert.ok(reveal.meshes.every(f=>f.material===revealMaterial));assert.ok(decorative.every(group=>!group.visible));
finish.apply('wood');assert.deepEqual(frames.map(f=>[f.geometry,f.position,f.scale]),identities,'theme switches must not replace or move the primary frame');
let disposed=0;const maps=new Set(frames.flatMap(f=>f.material.flatMap(m=>[m.map,m.bumpMap])));maps.forEach(map=>map.addEventListener('dispose',()=>disposed++));finish.dispose();assert.equal(disposed,maps.size,'owned texture resources dispose exactly once');
reveal.dispose();frameGeometry.dispose();materials.forEach(m=>m.dispose());revealMaterial.dispose();
console.log('Wood finish: lit textured six-face frame, directional grain, UV reveal, brass hardware, booth restoration, geometry preservation and disposal passed (no WebGL pixels)');
+40
View File
@@ -0,0 +1,40 @@
/** CPU ray/geometry regression, not GLSL or browser pixel execution. */
import assert from 'node:assert/strict';
import * as THREE from 'three';
import {loadTypescript} from './support/load-typescript.mjs';
import {photoSurface,makeDepthTexture,tracePhotoDDA,triangleOracle,snell,exitGlass} from './support/mesh-refraction-reference.mjs';
const {WindowReveal}=await loadTypescript(new URL('../src/objects/WindowReveal.ts',import.meta.url));
const {createPatternedGlassGeometry}=await loadTypescript(new URL('../src/rendering/PatternedGlassGeometry.ts',import.meta.url));
const {attachMeshRefraction}=await loadTypescript(new URL('../src/rendering/MeshRefraction.ts',import.meta.url));
const {fitWindowScale}=await loadTypescript(new URL('../src/objects/BlenderWindowFrame.ts',import.meta.url));
const aspects=[.8,1,1.6],gaps=[.04,.1296,.1576],depths=[0,.25,.4],opens=[0,.5,1],views=[[0,-.9],[0,.9],[-.9,0],[.9,0],[-.9,-.9],[.9,-.9],[-.9,.9],[.9,.9],[0,0]],edges=[[-.995,.01313],[.995,.01313],[.01313,-.995],[.01313,.995],[-.99273,-.995],[-.99273,.995],[.99273,-.995],[.99273,.995]];
const results=[],counts={cases:0,glass:0,open:0,photo:0,reveal:0,photoOnly:0,revealOnly:0,both:0,tir:0,photoFirst:0,revealFirst:0,noGlassAtTarget:0,originalPhotoMissRepaired:0};
function revealShader(uniforms,o,d){let best=null;const v=uniforms.uBRevealVertices.value;for(let wall=0;wall<4;wall++)for(const tri of [[0,1,2],[0,2,3]]){const [a,b,c]=tri.map(k=>v[wall*4+k]);const e1=b.clone().sub(a),e2=c.clone().sub(a),p=d.clone().cross(e2),det=e1.dot(p);if(Math.abs(det)<1e-9)continue;const delta=o.clone().sub(a),u=delta.dot(p)/det,q=delta.clone().cross(e1),w=d.dot(q)/det;if(u<-.00001||w<-.00001||u+w>1.00001)continue;const t=e2.dot(q)/det;if(t<.000001)continue;if(!best||t<best.t)best={t,wall};}return best;}
function check(o,d,surface,reveal,uniforms,label){const photo=tracePhotoDDA(surface,o,d).hit,oracle=triangleOracle(surface,o,d);assert.equal(!!photo,!!oracle,`${label} photo presence`);if(photo)assert.ok(Math.abs(photo.t-oracle.t)<1e-6,`${label} photo distance`);const wall=revealShader(uniforms,o,d),oracleWall=new THREE.Raycaster(o,d).intersectObjects(reveal.meshes)[0];assert.equal(!!wall,!!oracleWall,`${label} reveal presence`);if(wall)assert.ok(Math.abs(wall.t-oracleWall.distance)<1e-6,`${label} reveal distance`);const chosen=wall&&(!photo||wall.t<photo.t)?'reveal':photo?'photo':'empty';const expected=oracleWall&&(!oracle||oracleWall.distance<oracle.t)?'reveal':oracle?'photo':'empty';assert.equal(chosen,expected,`${label} ordering`);return {chosen,photoT:photo?.t??null,revealT:wall?.t??null,wall:wall?.wall??null};}
for(let i=0;i<72;i++){
const aspect=aspects[i%3],gap=gaps[Math.floor(i/3)%3],depth=depths[Math.floor(i/9)%3],open=opens[Math.floor(i/24)],curvature=i%2?1:-1,[cx,cy]=views[i%9],edge=edges[Math.floor(i/9)];
const H=812,W=375,baseH=fitWindowScale(W/H,aspect),baseW=baseH*aspect,fw=baseW*.8,fh=baseH*.8,z=.6,backZ=z-gap-depth,camera=new THREE.Vector3(cx,cy,3),centerX=-fw*open;
const pw=baseW*(3-backZ)/3,ph=baseH*(3-backZ)/3,texture=makeDepthTexture(9,9,(x,y)=>x===4&&y===4?1:0),matrix=new THREE.Matrix4().compose(new THREE.Vector3(0,0,backZ),new THREE.Quaternion(),new THREE.Vector3(pw,ph,1)),surface=photoSurface(texture,{depthScale:depth,matrix});
const source=new THREE.Mesh(surface.geometry,new THREE.ShaderMaterial({uniforms:{uTexture:{value:texture},uDepthMap:{value:texture},uDepthScale:{value:depth},uDepthBias:{value:0}}}));source.matrixAutoUpdate=false;source.matrix.copy(matrix);source.updateMatrixWorld(true);
const reveal=new WindowReveal(new THREE.MeshBasicMaterial({side:THREE.DoubleSide}));reveal.update(fw,fh,pw,ph,z,backZ);const scene=new THREE.Scene();scene.add(...reveal.meshes);scene.updateMatrixWorld(true);
const refraction=attachMeshRefraction(new THREE.MeshPhysicalMaterial(),source);refraction.syncReveal(reveal.meshes,scene);
const built=createPatternedGlassGeometry({width:fw,height:fh,grid:20,curvature}),glass=new THREE.Mesh(built.geometry,new THREE.MeshBasicMaterial());glass.position.set(centerX,0,z);glass.updateMatrixWorld(true);refraction.sync({width:fw,height:fh,grid:20,curvature},z+built.backZ,new THREE.Color(),centerX);
const target=new THREE.Vector3(pw/2*edge[0],ph/2*edge[1],backZ),incident=target.clone().sub(camera).normalize(),entry=new THREE.Raycaster(camera,incident).intersectObject(glass)[0];
let checked,status='direct',direct=check(camera,incident,surface,reveal,refraction.uniforms,`case${i} direct`);assert.notEqual(direct.chosen,'empty',`case${i} direct hole`);
if(entry){counts.glass++;const normal=entry.normal.clone().normalize(),internal=snell(incident,normal,1/1.5),local=entry.point.clone().sub(glass.position),exit=exitGlass(local,internal,{width:fw,height:fh,backZ:built.backZ});assert.ok(exit,`case${i} exit`);const outgoing=snell(internal,exit.normal.clone().negate(),1.5);if(!outgoing){counts.tir++;status='tir';}else{checked=check(exit.point.clone().add(glass.position),outgoing,surface,reveal,refraction.uniforms,`case${i} optical`);status=checked.chosen;if(checked.chosen==='empty'){const masked=new THREE.Raycaster(camera,incident).intersectObjects(reveal.meshes)[0];assert.ok(masked,`case${i} unmasked optical hole`);status='masked optical miss';}else counts[checked.chosen]++;if(checked.photoT!==null&&checked.revealT!==null){counts.both++;counts[checked.chosen==='photo'?'photoFirst':'revealFirst']++;}else if(checked.photoT!==null)counts.photoOnly++;else if(checked.revealT!==null){counts.revealOnly++;counts.originalPhotoMissRepaired++;}}}
else{counts.open++;checked=direct;counts[checked.chosen]++;}
counts.cases++;results.push({i,aspect,gap,depth,open,curvature,camera:[cx,cy],edge,status,...checked});
built.geometry.dispose();glass.material.dispose();reveal.dispose();surface.geometry.dispose();texture.dispose();
}
// Targeted ordering counterpart: same boundary ray, but flat foreground photo
// sits in front of the wall. Both must hit and the photo must win.
{
const z=.2204,pw=1.45*.8*(3-z)/3,ph=1.45*(3-z)/3;
const t=makeDepthTexture(2,2,()=>1),surface=photoSurface(t,{depthScale:.25,matrix:new THREE.Matrix4().compose(new THREE.Vector3(0,0,z),new THREE.Quaternion(),new THREE.Vector3(pw,ph,1))});
const reveal=new WindowReveal(new THREE.MeshBasicMaterial({side:THREE.DoubleSide}));reveal.update(.928,1.16,pw,ph,.6,z);const scene=new THREE.Scene();scene.add(...reveal.meshes);scene.updateMatrixWorld(true);
const source=new THREE.Mesh(surface.geometry,new THREE.ShaderMaterial({uniforms:{uTexture:{value:t}}})),r=attachMeshRefraction(new THREE.MeshPhysicalMaterial(),source);r.syncReveal(reveal.meshes,scene);
const hit=check(new THREE.Vector3(.008207625903806859,.49163956178806933,.588056),new THREE.Vector3(.03234743889587834,.452479898306991,-.8911877382599991),surface,reveal,r.uniforms,'targeted photo-first');
assert.equal(hit.chosen,'photo');assert.ok(hit.photoT!==null&&hit.revealT!==null);counts.cases++;counts.photo++;counts.both++;counts.photoFirst++;results.push({i:72,targeted:'photo in front of reveal',...hit});
}
assert.ok(counts.originalPhotoMissRepaired>0,'matrix must exercise original bug');assert.ok(counts.open>0&&counts.glass>0);assert.ok(counts.both>0,'matrix must exercise depth ordering');
const report={counts,coverage:{aspects,gaps,depths,opens,views,edges,curvature:[-1,1],stratified:true,cartesian:false},results,limits:'CPU actual geometry and independent triangle oracle only; no WebGL/browser pixel verification. No shader execution. Test sample count is73 (72 stratified + 1 targeted ordering), not exhaustive all combinations.'};console.log(JSON.stringify({counts,coverage:report.coverage},null,2));
+65
View File
@@ -0,0 +1,65 @@
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/WindowSlideController.ts',import.meta.url),'utf8');
const compiled=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ES2022}}).outputText;
const {WindowSlideController}=await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
const near=(actual,expected)=>assert.ok(Math.abs(actual-expected)<1e-12,`${actual} != ${expected}`);
test('discrete motion traverses bounded eased positions and reaches exact endpoints',()=>{
const slide=new WindowSlideController();slide.moveTo(1,100);
assert.equal(slide.value,0);assert.equal(slide.target,1);assert.equal(slide.isAnimating,true);
let prior=0;for(let now=100;now<=450;now+=7){const current=slide.update(now);assert.ok(current>=prior&&current<=1);prior=current;}
assert.equal(slide.value,1);assert.equal(slide.isAnimating,false);
slide.moveTo(0,500);assert.equal(slide.value,1);near(slide.update(675),.5);assert.equal(slide.update(850),0);
assert.equal(slide.target,0);assert.equal(slide.isAnimating,false);
slide.moveTo(1,1000);const early=slide.update(1035);assert.ok(early>0&&early<.1,'ease in, rather than linear or a step');
near(slide.update(1315),1-early);
});
test('motion depends on timestamps rather than frame count or refresh rate',()=>{
for(const intermediate of [[110,120,130,140,150,160,170],[140,170],[]]){
const slide=new WindowSlideController();slide.moveTo(1,100);intermediate.forEach(t=>slide.update(t));near(slide.update(275),.5);assert.equal(slide.update(450),1);
}
});
test('reversal restarts exactly at the last rendered position including rapid repeats',()=>{
const slide=new WindowSlideController();slide.moveTo(1,0);near(slide.update(175),.5);
slide.moveTo(0,220);assert.equal(slide.value,.5,'request must not advance to an unrendered 220ms position');
assert.equal(slide.update(220),.5);near(slide.update(395),.25);
slide.moveTo(1,395);assert.equal(slide.value,.25);near(slide.update(570),.625);assert.equal(slide.update(745),1);
slide.moveTo(0,800);slide.moveTo(1,800);assert.equal(slide.value,1);assert.equal(slide.isAnimating,false);
});
test('same-target repeat preserves timing and partial keyboard targets can accumulate',()=>{
const slide=new WindowSlideController();slide.moveTo(.1,0);slide.update(100);slide.moveTo(slide.target+.1,100);
assert.equal(slide.target,.2);const reached=slide.value;slide.moveTo(.2,120);assert.equal(slide.value,reached);
assert.equal(slide.update(450),.2,'a repeated identical target does not delay completion');
slide.moveTo(1,500);slide.update(675);slide.moveTo(1,675);assert.equal(slide.update(850),1);
});
test('manual ownership is direct, preserves partial release and cancels every old target',()=>{
const slide=new WindowSlideController();slide.moveTo(1,0);const displayed=slide.update(105);slide.cancel();
assert.equal(slide.value,displayed);slide.set(displayed+.03);assert.equal(slide.value,displayed+.03);slide.update(10000);
assert.equal(slide.value,displayed+.03);assert.equal(slide.target,displayed+.03);assert.equal(slide.isAnimating,false);
});
test('disable freezes last rendered value before elapsed time and never resumes',()=>{
const slide=new WindowSlideController();slide.moveTo(1,0);const displayed=slide.update(70);
assert.equal(slide.update(10000,false),displayed);assert.equal(slide.update(10001,true),displayed);
slide.moveTo(1,10001);assert.equal(slide.update(10001),displayed);assert.equal(slide.update(10351),1);
});
test('reduced motion is immediate at request and settles an in-flight target on preference change',()=>{
const slide=new WindowSlideController();slide.moveTo(1,0,true);assert.equal(slide.value,1);assert.equal(slide.isAnimating,false);
slide.moveTo(0,100);assert.ok(slide.update(200)>0);assert.equal(slide.update(201,true,true),0);assert.equal(slide.isAnimating,false);
slide.moveTo(1,300);const displayed=slide.update(350);assert.equal(slide.update(1000,false,true),displayed,'blocked state cancellation wins over preference settlement');
});
test('finite inputs and clamps keep invalid timestamps or endstop requests safe',()=>{
const slide=new WindowSlideController();slide.set(.4);slide.moveTo(NaN,0);slide.moveTo(1,NaN);slide.set(Infinity);assert.equal(slide.value,.4);
slide.moveTo(5,100);assert.equal(slide.target,1);assert.equal(slide.update(50),.4);assert.equal(slide.update(NaN),.4);assert.equal(slide.update(450),1);
slide.moveTo(-5,500);assert.equal(slide.update(850),0);slide.set(5);assert.equal(slide.value,1);slide.set(-1);assert.equal(slide.value,0);
});