SimReady-assets-explorer / tests /interaction.test.mjs
ZIP Space Deployer
Deploy ZIP — 2026-10-27 22:06 UTC
7539b2e
Raw History Blame Contribute Delete
8.17 kB
// React interaction tests with a deliberately simulated 3D engine, not WebGL QA.
import test,{after} from 'node:test';
import assert from 'node:assert/strict';
import {readFile,mkdir,rm} from 'node:fs/promises';
import {build} from 'esbuild';
import {JSDOM} from 'jsdom';
const dom=new JSDOM('<!doctype html><html><head></head><body><div id="root"></div></body></html>',{url:'https://example.test/',pretendToBeVisual:true});
for(const name of ['window','document','navigator','HTMLElement','SVGElement','localStorage','Event','MouseEvent','KeyboardEvent'])Object.defineProperty(globalThis,name,{value:dom.window[name],configurable:true});
Object.assign(globalThis,{innerWidth:1200,innerHeight:800,IS_REACT_ACT_ENVIRONMENT:true,matchMedia:()=>({matches:false}),requestAnimationFrame:fn=>setTimeout(()=>fn(performance.now()),8),cancelAnimationFrame:clearTimeout,ResizeObserver:class{constructor(fn){this.fn=fn;}observe(){this.fn();}disconnect(){}}});
Object.defineProperties(dom.window.HTMLElement.prototype,{clientWidth:{get(){return 1200}},clientHeight:{get(){return 800}}});
dom.window.HTMLElement.prototype.getBoundingClientRect=function(){return {x:0,y:0,left:0,top:0,right:1200,bottom:800,width:1200,height:800}};
dom.window.HTMLMediaElement.prototype.pause=function(){};
const {createElement,act}=await import('react'),{createRoot}=await import('react-dom/client');
const records=[];globalThis.__qaEngines=records;
const cache=new URL('../.cache/interactions/',import.meta.url);await mkdir(cache,{recursive:true});
for(const component of ['ObjectViewer','App'])await build({entryPoints:[new URL(`../src/${component}.tsx`,import.meta.url).pathname],outfile:new URL(component+'.mjs',cache).pathname,bundle:true,platform:'node',format:'esm',jsx:'automatic',packages:'external',resolveExtensions:['.ts','.tsx','.js','.jsx','.json'],define:{'import.meta.env.BASE_URL':'"/"'},loader:{'.css':'empty'},logLevel:'silent',plugins:[{name:'simulated-engine',setup(b){b.onResolve({filter:/^\.\/(basket|Basket)$/},args=>({path:new URL('../src/'+(args.path==='./basket'?'basket.ts':'Basket.tsx'),import.meta.url).pathname}));b.onResolve({filter:/viewer-engine$/},()=>({path:'engine',namespace:'qa'}));b.onLoad({filter:/.*/,namespace:'qa'},()=>({contents:`export function createEngine(host,asset,status,ready,failure,motion){const r={asset,status,ready,failure,motion,calls:[],disposed:false};globalThis.__qaEngines.push(r);status('Loading 3D…');return {dispose(){r.disposed=true},setVisible(v){r.calls.push(['visible',v])},play(...args){r.calls.push(['play',...args])},setJoint(...args){r.calls.push(['joint',...args])},selectJoint(...args){r.calls.push(['select',...args])},setHighlight(...args){r.calls.push(['highlight',...args])}}}` }));}}]});
const {default:Viewer}=await import(new URL('ObjectViewer.mjs',cache)),{default:App}=await import(new URL('App.mjs',cache));
const catalogue=JSON.parse(await readFile(new URL('../public/catalogue.json',import.meta.url)));
const baseAsset={...catalogue.assets[0],bytes:1200,video:null,moving:1};
const defaults={asset:baseAsset,x:600,y:400,width:1200,height:800,objectSize:328,scale:1,inBasket:false,onClose:()=>{},onToggleBasket:()=>{}};
let root=createRoot(document.getElementById('root'));
const render=async(props={})=>act(async()=>root.render(createElement(Viewer,{...defaults,...props})));
const click=async label=>act(async()=>{const button=[...document.querySelectorAll('button')].find(b=>b.getAttribute('aria-label')===label||b.textContent.trim()===label);assert(button,`Missing ${label}`);button.dispatchEvent(new MouseEvent('click',{bubbles:true}));});
const until=async check=>{for(let i=0;i<80;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,10))});}assert(check(),'Timed out');};
const reset=async()=>{await act(async()=>root.unmount());root=createRoot(document.getElementById('root'));records.length=0;};
after(async()=>{await act(async()=>root.unmount());dom.window.close();await rm(cache,{recursive:true,force:true});});
test('selection shows feedback immediately and cancels abandoned loads before creating a renderer',async()=>{
await render({moving:true});assert(document.querySelector('.selection-indicator[data-state=loading]'));assert.equal(records.length,0);
await render({moving:true,asset:{...baseAsset,id:'second'}});await new Promise(r=>setTimeout(r,25));assert.equal(records.length,0);
await render({moving:false,asset:{...baseAsset,id:'second'}});await until(()=>records.length===1);
await reset();assert.equal(records.length,0);
});
test('failure, retry and movement controls follow the current engine; old callbacks cannot overwrite selection',async()=>{
await render();await until(()=>records.length===1);const first=records[0];
await act(async()=>first.failure(new Error('Network test failure')));assert.match(document.body.textContent,/3D unavailable/);assert(document.querySelector('.asset-image'));
await click('Retry 3D preview');await until(()=>records.length===2);assert(first.disposed);
const current=records[1];await act(async()=>{first.ready([]);current.ready([{id:'door',label:'door',type:'revolute',home:0,far:90},{id:'drawer',type:'prismatic',home:0,far:.5}]);});
assert.match(document.body.textContent,/3D ready/);assert.equal(document.querySelector('input[type=range]').getAttribute('aria-label'),'Door position');
await click('Play this movement');assert.deepEqual(current.calls.at(-1),['play',true,'door']);
await click('Next movement');assert.deepEqual(current.calls.at(-1),['select','drawer']);
await click('Reset this movement');assert.deepEqual(current.calls.at(-1),['joint','drawer',0]);
await render({x:-400});assert(!document.querySelector('[aria-label="Object controls"]'));assert.equal(records.length,2);assert(current.calls.some(c=>c[0]==='visible'&&!c[1]));
await render();assert(document.querySelector('[aria-label="Object controls"]'));assert.equal(records.length,2);
await reset();assert(current.disposed);
});
test('null model and explicit large-preview gate never allocate a renderer until requested',async()=>{
await render({asset:{...baseAsset,model:null}});await new Promise(r=>setTimeout(r,35));assert.equal(records.length,0);assert(!document.querySelector('.tiny-spinner'));
await reset();await render({asset:{...baseAsset,bytes:80*1024*1024}});assert.equal(records.length,0);
await act(async()=>document.querySelector('.selection-status button').click());await until(()=>records.length===1);await reset();
});
test('information, downloads and clipboard fallback work while the preview is unavailable',async()=>{
await render({asset:{...baseAsset,model:null}});await click('Object information');assert(document.getElementById('selection-details'));assert.match(document.body.textContent,/Source formatOpenUSD/);
Object.defineProperty(navigator,'clipboard',{value:{writeText:async()=>{throw Error('Unavailable')}},configurable:true});
await click('Download object');await click('Copy download command');assert(document.querySelector('[role=alert]'));assert(document.querySelector('details[open] code'));await reset();
});
test('canvas selection keeps the current zoom, recenters, and cancels cleanly on close',async()=>{
globalThis.fetch=async url=>({ok:true,json:async()=>String(url).includes('preview-index')?{schema:1,assets:{}}:{meta:{...catalogue.meta,assetCount:3},assets:catalogue.assets.slice(0,3).map(a=>({...a,bytes:1000}))}});
await act(async()=>root.render(createElement(App)));await until(()=>document.querySelectorAll('.object').length===3);
await click('Zoom in');await until(()=>document.querySelector('.zoom-value').textContent==='120%');
const contentSize=(parseFloat(document.querySelectorAll('.object')[1].style.width)-28)*1.2;
await act(async()=>document.querySelectorAll('.object')[1].click());assert(document.querySelector('.selection-indicator'));
await until(()=>records.length===1);assert.equal(document.querySelector('.zoom-value').textContent,'120%');
assert(Math.abs(parseFloat(document.querySelector('.object-stage').style.width)-contentSize)<1e-6);
await click('Close object');assert(records[0].disposed);assert(!document.querySelector('.object-stage'));await reset();
});