frontend/test/shared-list-controls.test.ts
Rama master · réplica de lectura del repositorio original.
import { afterEach, expect, it, vi } from 'vitest';
import { wireHorizontalScroller, rubberBand } from '../src/ui-kit';
afterEach(async () => {document.body.replaceChildren();await new Promise(resolve=>setTimeout(resolve,0));vi.unstubAllGlobals()});
function button(){const node=document.createElement('button');node.style.overflowX='auto';document.body.append(node);return node}
it('在横排上开头的滚轮手势整段归这一排,鼠标一格逐帧走完、半路接着加,拦不住的留给页面,重复绑定与移除都释放资源', async () => {
const node=button();let left=0,now=1000;const disconnect=vi.fn();
vi.stubGlobal('ResizeObserver',class {observe(){}disconnect=disconnect});
const frames:FrameRequestCallback[]=[];
vi.stubGlobal('requestAnimationFrame',(callback:FrameRequestCallback)=>frames.push(callback));
vi.stubGlobal('cancelAnimationFrame',()=>{frames.length=0});
const step=()=>{now+=16;frames.shift()!(now)};
const flush=()=>{for(let i=0;i<200&&frames.length;i+=1)step()};
const clock=vi.spyOn(performance,'now').mockImplementation(()=>now);
const clamp=(value:number)=>Math.max(0,Math.min(100,value));
Object.defineProperties(node,{clientWidth:{value:100},scrollWidth:{value:200},scrollLeft:{get:()=>left,set:value=>{left=clamp(value)}}});
node.animate=(()=>({cancel(){},onfinish:null})) as never;
const control=wireHorizontalScroller(node,{drag:true});
expect(wireHorizontalScroller(node)).toBe(control);
const wheel=(deltaY=100,cancelable=true)=>new WheelEvent('wheel',{deltaY,cancelable});
// 内容可直接显示的布局不拦页面滚动,也不画横向提示。
node.style.overflowX='visible';control.update();
const pageWheel=wheel(60);node.dispatchEvent(pageWheel);
expect(pageWheel.defaultPrevented).toBe(false);expect(node.dataset.overflowRight).toBe('false');
node.style.overflowX='auto';control.update();
// 鼠标一格逐帧走,不是一下跳过去。
const moving=wheel(60);node.dispatchEvent(moving);
expect(moving.defaultPrevented).toBe(true);expect(left).toBe(0);
step();expect(left).toBeGreaterThan(0);expect(left).toBeLessThan(60);
// 半路再来一格,从上一格的终点接着算,一路走到头停下。
now+=40;node.dispatchEvent(wheel(60));flush();
expect(left).toBe(100);expect(frames).toHaveLength(0);
// 一帧里的几次 scroll 合成下一帧写一次渐隐标记。
node.dispatchEvent(new Event('scroll'));node.dispatchEvent(new Event('scroll'));expect(frames).toHaveLength(1);
step();expect(node.dataset.overflowRight).toBe('false');
// 到头以后还在转、隔一会再转,都不交给页面:要滚整页得把指针挪出这一排。
now+=40;const still=wheel(60);node.dispatchEvent(still);expect(still.defaultPrevented).toBe(true);
now+=1000;const next=wheel(60);node.dispatchEvent(next);expect(next.defaultPrevented).toBe(true);
expect(left).toBe(100);
// 触控板的小步直接跟手,不套动画。
left=50;now+=1000;node.dispatchEvent(wheel(-10));
expect(left).toBe(40);expect(frames).toHaveLength(0);
// 页面那边开了头的手势拦不住,这一排也不跟着动。
left=0;now+=1000;node.dispatchEvent(wheel(100,false));expect(left).toBe(0);
clock.mockRestore();
node.remove();await vi.waitFor(()=>expect(disconnect).toHaveBeenCalledOnce());
left=0;node.dispatchEvent(wheel());expect(left).toBe(0);
});
it('横排推过头按橡皮筋收敛,顶在边上再推也弹,一次滚轮手势只弹一下,拖过头松手弹回', () => {
expect(rubberBand(0,400)).toBe(0);
expect(rubberBand(100,400)).toBeGreaterThan(0);expect(rubberBand(100,400)).toBeLessThan(100);
expect(rubberBand(-100,400)).toBe(-rubberBand(100,400));
expect(rubberBand(1e6,400)).toBeLessThan(400);
const node=button();let left=100,now=1000;
vi.stubGlobal('ResizeObserver',class {observe(){}disconnect(){}});
vi.stubGlobal('matchMedia',()=>({matches:false}));
const clock=vi.spyOn(performance,'now').mockImplementation(()=>now);
Object.defineProperties(node,{clientWidth:{value:100},scrollWidth:{value:200},
scrollLeft:{get:()=>left,set:value=>{left=Math.max(0,Math.min(100,value))}}});
const animate=vi.fn(()=>({cancel(){},onfinish:null}));node.animate=animate as never;
wireHorizontalScroller(node,{drag:true});
const wheel=(deltaY:number)=>new WheelEvent('wheel',{deltaY,cancelable:true});
// 顶在最右还往前推:这一格也归这一排,弹一下,往左。
const push=wheel(30);node.dispatchEvent(push);
expect(push.defaultPrevented).toBe(true);expect(animate).toHaveBeenCalledOnce();
const [frames]=animate.mock.calls[0] as unknown as [Record<string,string>[]];
expect(parseFloat(frames[1]!['--edge-pull']!)).toBeLessThan(0);
// 惯性尾巴接着撞边,同一次手势不再弹。
now+=40;node.dispatchEvent(wheel(20));now+=40;node.dispatchEvent(wheel(10));
expect(animate).toHaveBeenCalledOnce();
// 下一次手势从半路滚过头,再弹一次。
left=90;now+=1000;node.dispatchEvent(wheel(30));
expect(left).toBe(100);expect(animate).toHaveBeenCalledTimes(2);
// 拖过左端:内容跟手右移、比手少走;松手从那里弹回原位。
// happy-dom 的 `pageX` 恒为 0,照浏览器的样子补上。
const mouse=(type:string,pageX=0)=>Object.defineProperty(new MouseEvent(type,{button:0}),'pageX',{value:pageX});
left=0;node.dispatchEvent(mouse('mousedown'));
window.dispatchEvent(mouse('mousemove',80));
const pulled=parseFloat(node.style.getPropertyValue('--edge-pull'));
expect(pulled).toBeGreaterThan(0);expect(pulled).toBeLessThan(80);
expect(node.classList.contains('edgepull')).toBe(true);
window.dispatchEvent(new MouseEvent('mouseup'));
expect(node.style.getPropertyValue('--edge-pull')).toBe('');
expect(animate).toHaveBeenCalledTimes(3);
clock.mockRestore();
});
it('按住拖动横排时整页锁成抓取光标,松手或窗口失焦就撤掉,点在控件上不起拖', () => {
vi.stubGlobal('ResizeObserver',class {observe(){}disconnect(){}});
const node=document.createElement('div');document.body.append(node);
const control=document.createElement('button');node.append(control);
Object.defineProperties(node,{clientWidth:{value:100},scrollWidth:{value:200}});
wireHorizontalScroller(node,{drag:true,dragIgnore:'button'});
const dragging=()=>document.documentElement.hasAttribute('data-drag-scrolling');
node.dispatchEvent(new MouseEvent('mousedown',{bubbles:true,button:0}));
expect(dragging()).toBe(true);
window.dispatchEvent(new MouseEvent('mouseup'));
expect(dragging()).toBe(false);
node.dispatchEvent(new MouseEvent('mousedown',{bubbles:true,button:0}));
window.dispatchEvent(new Event('blur'));
expect(dragging()).toBe(false);
control.dispatchEvent(new MouseEvent('mousedown',{bubbles:true,button:0}));
expect(dragging()).toBe(false);
});