import {test} from 'node:test'; import assert from 'node:assert/strict'; import {readFile} from 'node:fs/promises'; import vm from 'node:vm'; import ts from 'typescript'; import {seo} from './helpers/seo.mjs'; const sourceUrl=new URL('../src/lib/client/application.ts',import.meta.url); const {outputText}=ts.transpileModule(await readFile(sourceUrl,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}); const flush=()=>new Promise(resolve=>setImmediate(resolve)); function fixture({reducedMotion=false,load=async()=>{}}={}){ const animations=[],overlays=[],nodes=new Map(); const target={isConnected:true,current:0,replaceChildren(frame){this.current=frame.selected;},append(node){overlays.push(node);},cloneNode(){ return {current:this.current,inert:false,classList:{add(){}},removeAttribute(){},setAttribute(){},querySelectorAll:()=>[], remove(){const index=overlays.indexOf(this);if(index>=0)overlays.splice(index,1);}, animate(frames,options){ let finish,reject; const animation={frames,options,finished:new Promise((done,fail)=>{finish=done;reject=fail;}),finish:()=>finish(),cancel(){reject(new Error('cancelled'));}}; animations.push(animation);return animation; }}; }}; const document={querySelector(selector){ if(selector==='#hero')return target; if(!nodes.has(selector))nodes.set(selector,{});return nodes.get(selector); },querySelectorAll:()=>[],createElement(){ return {set innerHTML(value){this.selected=Number(value);this.childNodes=[{selected:this.selected}];},querySelector(){ const source='image-'+this.selected; return {getAttribute:()=>source,decode:async()=>{}}; }}; }}; const exports={}; const require=specifier=>{ if(specifier==='./api')return {ApiClient:class {}}; if(specifier==='./render')return {activeBanners:home=>home.homeBannerData,hero:(_,selected)=>String(selected),empty:()=>'',escape:String}; if(specifier==='./images')return {processImage:load}; if(specifier==='../routing')return {parseSitePath:()=>null,topNavigationSection:()=>''}; if(specifier==='../seo')return seo; if(specifier==='./seo')return {applyPageHead:head=>{document.title=head.title;}}; return {}; }; vm.runInNewContext(outputText,{exports,require,AbortController,document,window:{matchMedia:()=>({matches:reducedMotion}),scrollTo(){}},location:{pathname:'/missing/',search:''},clearInterval(){}},{filename:sourceUrl.pathname}); const app=new exports.Application({homeBannerData:[{id:1},{id:2},{id:3}]}); return {app,target,animations,overlays}; } test('keeps the old frame until its replacement image is ready, then fades it out and removes it',async()=>{ let ready; const f=fixture({load:()=>new Promise(resolve=>ready=resolve)}); const changing=f.app.selectHero(1); assert.equal(f.target.current,0);assert.equal(f.overlays.length,0); ready();await changing; assert.equal(f.target.current,1);assert.equal(f.overlays[0].current,0);assert.equal(f.overlays[0].inert,true); assert.equal(f.animations[0].options.duration,500); assert.deepEqual(Array.from(f.animations[0].frames,frame=>frame.opacity),[1,0]); f.animations[0].finish();await flush();assert.equal(f.overlays.length,0); }); test('rapid switches keep the latest request and replace any unfinished fade without stacking snapshots',async()=>{ const pending=new Map(); const f=fixture({load:source=>new Promise(resolve=>pending.set(source,resolve))}); const first=f.app.selectHero(1),second=f.app.selectHero(2); pending.get('image-2')();await second;assert.equal(f.target.current,2); pending.get('image-1')();await first;assert.equal(f.target.current,2);assert.equal(f.overlays.length,1); const third=f.app.selectHero(0);pending.get('image-0')();await third;await flush(); assert.equal(f.target.current,0);assert.equal(f.overlays.length,1);assert.equal(f.overlays[0].current,2); f.animations.at(-1).finish();await flush();assert.equal(f.overlays.length,0); }); test('document disposal cancels pending image commits and clean up a running fade',async()=>{ let ready; const f=fixture({load:()=>new Promise(resolve=>ready=resolve)}); const changing=f.app.selectHero(1);f.app.disposePage();ready();await changing; assert.equal(f.target.current,0);assert.equal(f.animations.length,0);assert.equal(f.app.feature,0); const running=fixture();await running.app.selectHero(1);assert.equal(running.overlays.length,1); running.app.disposePage();await flush();assert.equal(running.overlays.length,0); }); test('reduced motion switches directly, and selecting the current slide leaves the frame intact',async()=>{ const f=fixture({reducedMotion:true}); await f.app.selectHero(0);assert.equal(f.target.current,0);assert.equal(f.animations.length,0); await f.app.selectHero(1);assert.equal(f.target.current,1);assert.equal(f.animations.length,0);assert.equal(f.overlays.length,0); });