123 lines
7.4 KiB
JavaScript
123 lines
7.4 KiB
JavaScript
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);
|
|
});
|