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();} });