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()=>{},pathname='/missing/',hidden=false,bannerCount=3}={}){ const animations=[],overlays=[],nodes=new Map(),timers=new Map();let timerId=0; 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={hidden,querySelector(selector){ if(selector==='#hero-feature-carousel')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 {}; }; const location={pathname,search:''}; vm.runInNewContext(outputText,{exports,require,AbortController,document,window:{matchMedia:()=>({matches:reducedMotion}),scrollTo(){}},location, setInterval(callback,delay){const id=++timerId;timers.set(id,{callback,delay});return id;},clearInterval(id){timers.delete(id);} },{filename:sourceUrl.pathname}); const app=new exports.Application({homeBannerData:Array.from({length:bannerCount},(_,index)=>({id:index+1}))}); return {app,target,animations,overlays,timers,document,location,tick:()=>timers.values().next().value.callback()}; } 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); }); test('autoplay switches every three seconds, wraps around, and restarts the wait after manual selection',async()=>{ const f=fixture({pathname:'/'});f.app.restartHeroAutoplay(); assert.equal(f.timers.size,1);assert.equal(f.timers.values().next().value.delay,3000); for(const expected of [1,2,0]){await f.tick();assert.equal(f.target.current,expected);assert.equal(f.timers.size,1);} const previousTimer=[...f.timers.keys()][0]; await f.app.selectHero(2);assert.equal(f.target.current,2); assert.notEqual([...f.timers.keys()][0],previousTimer); await f.tick();assert.equal(f.target.current,0); f.app.disposePage();assert.equal(f.timers.size,0);await flush(); }); test('slow artwork keeps the current slide visible without skipping requests, then grants the new slide three seconds',async()=>{ const pending=new Map(); const f=fixture({pathname:'/',load:source=>new Promise(resolve=>pending.set(source,resolve))}); f.app.restartHeroAutoplay();const previousTimer=[...f.timers.keys()][0]; const changing=f.tick();await f.tick();await f.tick(); assert.deepEqual([...pending.keys()],['image-1']);assert.equal(f.target.current,0); pending.get('image-1')();await changing; assert.equal(f.target.current,1);assert.notEqual([...f.timers.keys()][0],previousTimer); assert.equal(f.timers.values().next().value.delay,3000); const manual=f.app.selectHero(0);await f.tick(); assert.deepEqual([...pending.keys()],['image-1','image-0']); pending.get('image-0')();await manual;assert.equal(f.target.current,0); f.app.disposePage();await flush(); }); test('autoplay stops in the background, on other pages, with fewer than two slides, and after disposal',()=>{ for(const options of [{pathname:'/shorts/'},{pathname:'/',hidden:true},{pathname:'/',bannerCount:1},{pathname:'/',bannerCount:0}]){ const f=fixture(options);f.app.restartHeroAutoplay();assert.equal(f.timers.size,0); } const f=fixture({pathname:'/'});f.app.restartHeroAutoplay();assert.equal(f.timers.size,1); f.document.hidden=true;f.app.restartHeroAutoplay();assert.equal(f.timers.size,0); f.document.hidden=false;f.app.restartHeroAutoplay();assert.equal(f.timers.size,1); f.app.disposePage();f.app.restartHeroAutoplay();assert.equal(f.timers.size,0); });