yggdrasil/libs/lightbox/src/lightbox.test.ts
xfy b1b1c88a74 chore(biome): 引入 Biome v2.5 monorepo 配置并格式化全量源码
新增根 biome.json(v2.5,v2 起原生支持 monorepo,子包自动向上查找):

- formatter: 2 空格缩进、单引号、行宽 100、LF、尾逗号、箭头函数括号
  (匹配现有代码风格)
- linter: recommended preset + 项目化裁剪
  - useTemplate/useTemplate off(geometry.ts 故意保留字符串拼接,
    且测试用精确浮点断言锁定行为)
  - noNonNullAssertion off(测试与 DOM 访问里的 ! 是惯用写法)
  - noConsole off(4 个 lib 用 console.error/warn 做错误上报)
  - CSS 关闭 noDescendingSpecificity/noDuplicateProperties
    (手写 CSS 的 fallback 模式)
  - useImportType/useNodejsImportProtocol/noUnusedVariables warn
- vcs.useIgnoreFile 自动读 .gitignore;排除 public/target/node_modules/
  dist/.dioxus/static + Tailwind 入口 input.css(含 @theme 等 Biome 无法
  解析的 at-rules)

首次格式化覆盖 29 个源文件:import 排序、node: 协议、引号/缩进统一,
并修复 index.ts 两处 forEach 回调隐式返回值(useIterableCallbackReturn)。
90 个 vitest 用例全绿,确认无语义改动。
2026-07-02 17:54:46 +08:00

258 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Lightbox 行为回归测试happy-dom 真实 DOM黑盒驱动
*
* 通过唯一公开入口 window.__initLightbox 驱动,构造 .blur-img DOM
* 模拟点击/键盘/滚动,断言 overlay 出现/消失、counter 文本、originNode 捕获。
*
* 目的:钉住高风险路径(循环闭包捕获 idx、图集 gotoIndex 循环边界、
* 关闭清理供后续现代化重构var→const、拆 IIFE、for→for..of做回归防线。
*
* 不覆盖:飞行动画的几何计算(依赖 img naturalWidth + load 事件happy-dom
* 不真实加载图片),那部分由 geometry.test.ts 的纯函数测试覆盖。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import './index';
// ============ 测试夹具 ============
/**
* 构造一个 .blur-img 容器(图集成员)。
* full img 带 data-src + alt模拟文章正文图结构。
*/
function makeGalleryImage(dataSrc: string, alt: string): HTMLElement {
const container = document.createElement('div');
container.className = 'blur-img';
container.innerHTML = `
<img class="blur-img-placeholder" src="${dataSrc}?w=20" alt="${alt}">
<img class="blur-img-full" data-src="${dataSrc}" alt="${alt}">
`;
return container;
}
/** 构造单张图(封面,带 lightbox-single。 */
function makeSingleImage(dataSrc: string, alt: string): HTMLElement {
const container = makeGalleryImage(dataSrc, alt);
container.classList.add('lightbox-single');
return container;
}
/**
* 把若干图片挂到一个 root 容器下,再挂到 document.body。
* 返回 root 以便选择器命中。
*/
function mountRoot(images: HTMLElement[]): HTMLElement {
const root = document.createElement('div');
root.className = 'post-content';
for (const img of images) root.appendChild(img);
document.body.appendChild(root);
return root;
}
/** 取当前 overlay灯箱打开时存在。 */
function getOverlay(): HTMLElement | null {
return document.querySelector('.lightbox-overlay');
}
/** 取灯箱图(.lightbox-img。 */
function getLightboxImg(): HTMLImageElement | null {
return document.querySelector('.lightbox-img');
}
/** 取计数器。 */
function getCounter(): HTMLElement | null {
return document.querySelector('.lightbox-counter');
}
/** 模拟元素 click真实事件派发触发 addEventListener('click'))。 */
function clickEl(el: Element): void {
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
}
/** 模拟 keydown。 */
function pressKey(key: string): void {
document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
// ============ 测试 ============
describe('lightbox 黑盒行为', () => {
beforeEach(() => {
// 每个测试干净的 DOM + matchMediaprefersReducedMotion 读它)
document.body.innerHTML = '';
// happy-dom 的 matchMedia 返回值默认 matches=falsereduced-motion 关闭,
// 这样打开走 double-rAF 动画路径(更接近真实)。但我们用 fake timers 跳过动画。
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = '';
});
describe('循环闭包捕获 idxgallery 绑定)', () => {
it('点击第 1/2/3 张图counter 分别显示 1/3、2/3、3/3', () => {
const imgs = [
makeGalleryImage('/a.webp', '图A'),
makeGalleryImage('/b.webp', '图B'),
makeGalleryImage('/c.webp', '图C'),
];
mountRoot(imgs);
window.__initLightbox('.post-content');
// 逐张点击,验证捕获的 idx 正确(这是 var g + IIFE 的核心风险点)
clickEl(imgs[0]);
expect(getCounter()?.textContent).toBe('1 / 3');
pressKey('Escape'); // 关闭
vi.advanceTimersByTime(300); // 等 close 的 280ms 兜底
clickEl(imgs[1]);
expect(getCounter()?.textContent).toBe('2 / 3');
pressKey('Escape');
vi.advanceTimersByTime(300);
clickEl(imgs[2]);
expect(getCounter()?.textContent).toBe('3 / 3');
});
it('点图片时 preventDefault阻止默认导航/拖拽)', () => {
const img = makeGalleryImage('/a.webp', '图A');
mountRoot([img]);
window.__initLightbox('.post-content');
const ev = new MouseEvent('click', { bubbles: true, cancelable: true });
img.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(true);
});
});
describe('点击打开灯箱overlay 创建 + originNode 捕获)', () => {
it('点击 gallery 图后出现 overlayimg src 为原图(去 query', () => {
const img = makeGalleryImage('/uploads/x.webp?w=800', '描述');
mountRoot([img]);
window.__initLightbox('.post-content');
expect(getOverlay()).toBeNull(); // 打开前无 overlay
clickEl(img);
expect(getOverlay()).not.toBeNull(); // 打开后有 overlay
// origSrc = data-src 去 query
expect(getLightboxImg()?.getAttribute('src')).toBe('/uploads/x.webp');
});
it('caption 显示 alt 文本', () => {
const img = makeGalleryImage('/a.webp', '我的描述文字');
mountRoot([img]);
window.__initLightbox('.post-content');
clickEl(img);
const caption = document.querySelector('.lightbox-caption');
expect(caption?.textContent).toBe('我的描述文字');
});
it('单张图lightbox-single打开时 counter 隐藏', () => {
const img = makeSingleImage('/cover.webp', '封面');
mountRoot([img]);
window.__initLightbox('.post-content');
clickEl(img);
expect(getCounter()?.style.display).toBe('none');
});
});
describe('关闭灯箱', () => {
it('Esc 关闭后 overlay 从 DOM 移除', () => {
const img = makeGalleryImage('/a.webp', 'A');
mountRoot([img]);
window.__initLightbox('.post-content');
clickEl(img);
expect(getOverlay()).not.toBeNull();
pressKey('Escape');
// closeLightbox 走 transitionendhappy-dom 不触发)+ 280ms 兜底
vi.advanceTimersByTime(300);
expect(getOverlay()).toBeNull();
});
it('点 overlay 背景关闭(点图片本身不关)', () => {
const img = makeGalleryImage('/a.webp', 'A');
mountRoot([img]);
window.__initLightbox('.post-content');
clickEl(img);
const overlay = getOverlay()!;
const lbImg = getLightboxImg()!;
// 点图片本身 → 不关闭(箭头在图上,避免误关)
clickEl(lbImg);
expect(getOverlay()).not.toBeNull();
// 点背景 → 关闭
clickEl(overlay);
vi.advanceTimersByTime(300);
expect(getOverlay()).toBeNull();
});
});
describe('图集 gotoIndex 循环边界', () => {
it('第 1 张按 ← 跳到最后一张(循环)', () => {
const imgs = [makeGalleryImage('/a.webp', 'A'), makeGalleryImage('/b.webp', 'B')];
mountRoot(imgs);
window.__initLightbox('.post-content');
clickEl(imgs[0]);
expect(getCounter()?.textContent).toBe('1 / 2');
pressKey('ArrowLeft'); // 从第 1 张往左 → 循环到最后
vi.advanceTimersByTime(200); // gotoIndex 的 150ms 淡出 + 淡入
expect(getCounter()?.textContent).toBe('2 / 2');
});
it('最后一张按 → 跳到第 1 张(循环)', () => {
const imgs = [makeGalleryImage('/a.webp', 'A'), makeGalleryImage('/b.webp', 'B')];
mountRoot(imgs);
window.__initLightbox('.post-content');
clickEl(imgs[1]); // 最后一张
expect(getCounter()?.textContent).toBe('2 / 2');
pressKey('ArrowRight'); // 循环到第 1 张
vi.advanceTimersByTime(200);
expect(getCounter()?.textContent).toBe('1 / 2');
});
it('切换后 originNode 更新:关闭后焦点归还到新图', () => {
const imgs = [makeGalleryImage('/a.webp', 'A'), makeGalleryImage('/b.webp', 'B')];
mountRoot(imgs);
window.__initLightbox('.post-content');
clickEl(imgs[0]);
pressKey('ArrowRight'); // 切到 imgs[1]
vi.advanceTimersByTime(200);
// originNode 应已更新为 imgs[1],关闭后焦点归还给 imgs[1] 的 full img
pressKey('Escape');
vi.advanceTimersByTime(300);
const fullB = imgs[1].querySelector('.blur-img-full');
expect(document.activeElement).toBe(fullB);
});
});
describe('单张图不参与图集切换', () => {
it('单张图打开后按 ←/→ 不切换(无 counter、无箭头', () => {
const img = makeSingleImage('/cover.webp', '封面');
mountRoot([img]);
window.__initLightbox('.post-content');
clickEl(img);
// 单张模式无导航箭头
expect(document.querySelector('.lightbox-prev')).toBeNull();
expect(document.querySelector('.lightbox-next')).toBeNull();
// 按 ← 不报错也不改变状态gotoIndex 早返)
pressKey('ArrowLeft');
vi.advanceTimersByTime(200);
expect(getOverlay()).not.toBeNull(); // 仍打开
});
});
});