95 lines
6.3 KiB
JavaScript
95 lines
6.3 KiB
JavaScript
import {test} from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {readFile} from 'node:fs/promises';
|
|
import ts from 'typescript';
|
|
import {setMaxListeners} from 'node:events';
|
|
|
|
const code=ts.transpileModule(await readFile(new URL('../src/lib/client/promo-marquee.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
|
|
const {bindPromotionMarquee}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
|
|
|
|
class Element extends EventTarget {
|
|
attributes=new Map();children=[];tabIndex=0;
|
|
setAttribute(key,value){this.attributes.set(key,value);}
|
|
hasAttribute(key){return this.attributes.has(key);}
|
|
removeAttribute(key){this.attributes.delete(key);}
|
|
remove(){if(this.parent)this.parent.children=this.parent.children.filter(child=>child!==this);}
|
|
append(child){this.children.push(child);child.parent=this;}
|
|
querySelectorAll(selector){return selector==='[id]'?this.children.filter(child=>child.hasAttribute('id')):this.children;}
|
|
cloneNode(){const copy=new Element();for(const child of this.children){const link=new Element();link.attributes=new Map(child.attributes);copy.append(link);}return copy;}
|
|
}
|
|
|
|
function event(target,type,values={}){const e=new Event(type);for(const [key,value] of Object.entries(values))Object.defineProperty(e,key,{value});target.dispatchEvent(e);}
|
|
function fixture({width=800,viewport=500,reduced=false}={}){
|
|
const previous=globalThis.ResizeObserver,frames=new Map(),timers=new Map();let sequence=0,observer;
|
|
const root=new Element(),group=new Element(),track=new Element(),link=new Element(),doc=new EventTarget(),view=new EventTarget(),motion=new EventTarget();
|
|
link.setAttribute('id','original-ad');group.append(link);track.append(group);
|
|
root.clientWidth=viewport;root.scrollLeft=0;root.isConnected=true;
|
|
root.querySelector=selector=>selector==='.promo-group'?group:track;
|
|
root.contains=node=>node===link||track.children.some(child=>child.children.includes(node));
|
|
group.getBoundingClientRect=()=>({width:width+(root.hasAttribute('data-looping')?14:0)});
|
|
doc.hidden=false;doc.activeElement=null;doc.defaultView=view;root.ownerDocument=doc;
|
|
motion.matches=reduced;view.matchMedia=()=>motion;
|
|
view.getComputedStyle=()=>({paddingRight:root.hasAttribute('data-looping')?'14px':'0px'});
|
|
view.requestAnimationFrame=callback=>{const id=++sequence;frames.set(id,callback);return id;};
|
|
view.cancelAnimationFrame=id=>frames.delete(id);
|
|
view.setTimeout=callback=>{const id=++sequence;timers.set(id,callback);return id;};view.clearTimeout=id=>timers.delete(id);
|
|
globalThis.ResizeObserver=class {constructor(callback){this.callback=callback;this.disconnects=0;observer=this;}observe(){}disconnect(){this.disconnects++;}};
|
|
const controller=new AbortController();setMaxListeners(30,controller.signal);
|
|
const dispose=bindPromotionMarquee(root,controller.signal);
|
|
return {root,group,track,link,doc,view,motion,frames,timers,controller,
|
|
tick(time){const callbacks=[...frames.values()];frames.clear();for(const callback of callbacks)callback(time);},
|
|
settle(){const callbacks=[...timers.values()];timers.clear();for(const callback of callbacks)callback();},
|
|
resize(value){root.clientWidth=value;observer.callback();},
|
|
get observer(){return observer;},
|
|
close(){dispose();if(previous===undefined)delete globalThis.ResizeObserver;else globalThis.ResizeObserver=previous;},
|
|
};
|
|
}
|
|
|
|
test('a fitting row stays still; overflow makes one seamless clickable copy and resizing removes it',()=>{
|
|
const f=fixture({viewport:900});
|
|
try{
|
|
assert.equal(f.track.children.length,1);assert.equal(f.frames.size,0);
|
|
f.resize(500);assert.equal(f.track.children.length,2);assert.equal(f.frames.size,1);
|
|
const copy=f.track.children[1];assert.equal(copy.attributes.get('aria-hidden'),'true');assert.equal(copy.children[0].tabIndex,-1);assert.equal(copy.children[0].hasAttribute('id'),false);
|
|
f.resize(499);assert.equal(f.track.children.length,2);assert.equal(f.frames.size,1);
|
|
f.tick(0);f.tick(100);assert.ok(Math.abs(f.root.scrollLeft-3.2)<0.001);
|
|
event(f.root,'wheel');f.root.scrollLeft=813;f.settle();f.tick(200);f.tick(300);
|
|
assert.ok(Math.abs(f.root.scrollLeft-2.2)<0.001,'scrolling must wrap by exactly the original group including its trailing gap');
|
|
f.resize(1000);assert.equal(f.track.children.length,1);assert.equal(f.frames.size,0);assert.equal(f.root.hasAttribute('data-looping'),false);
|
|
}finally{f.close();}
|
|
});
|
|
|
|
test('mouse hover and keyboard focus pause the row and resume from the existing position',()=>{
|
|
const f=fixture();
|
|
try{
|
|
f.tick(0);f.tick(100);const position=f.root.scrollLeft;
|
|
event(f.root,'pointerenter',{pointerType:'mouse'});assert.equal(f.frames.size,0);
|
|
event(f.root,'pointerleave',{pointerType:'mouse'});assert.equal(f.frames.size,1);f.tick(10000);assert.equal(f.root.scrollLeft,position);
|
|
f.doc.activeElement=f.link;event(f.root,'focusin');assert.equal(f.frames.size,0);
|
|
event(f.root,'focusout',{relatedTarget:null});f.doc.activeElement=null;f.settle();assert.equal(f.frames.size,1);
|
|
}finally{f.close();}
|
|
});
|
|
|
|
test('touch momentum finishes before auto-scrolling resumes, without changing the manually selected ad',()=>{
|
|
const f=fixture();
|
|
try{
|
|
event(f.root,'pointerdown',{pointerType:'touch'});assert.equal(f.frames.size,0);
|
|
f.root.scrollLeft=937.5;event(f.view,'pointerup');assert.equal(f.timers.size,1);
|
|
event(f.root,'scroll');assert.equal(f.timers.size,1);
|
|
f.settle();f.tick(0);f.tick(100);
|
|
assert.ok(Math.abs(f.root.scrollLeft-126.7)<0.001);assert.equal(f.frames.size,1);
|
|
}finally{f.close();}
|
|
});
|
|
|
|
test('reduced motion, hidden pages and route cancellation stop animation and clean up listeners and duplicate links',()=>{
|
|
const f=fixture({reduced:true});
|
|
try{
|
|
assert.equal(f.track.children.length,1);assert.equal(f.frames.size,0);
|
|
f.motion.matches=false;event(f.motion,'change');assert.equal(f.track.children.length,2);
|
|
f.doc.hidden=true;event(f.doc,'visibilitychange');assert.equal(f.frames.size,0);
|
|
f.doc.hidden=false;event(f.doc,'visibilitychange');assert.equal(f.frames.size,1);
|
|
f.controller.abort();assert.equal(f.frames.size,0);assert.equal(f.track.children.length,1);assert.equal(f.observer.disconnects,1);
|
|
event(f.root,'pointerdown');event(f.view,'pointerup');assert.equal(f.timers.size,0,'cancelled routes must remove interaction handlers');
|
|
}finally{f.close();}
|
|
});
|