yggdrasil/libs/lightbox/src/lightbox.test.ts
xfy 99d33b54d2
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
fix(lightbox): 图集切换不重算几何导致图片压扁
gotoIndex 换图时只替换 src,target/baseW/baseH/transform 全部沿用
第一张图的几何,宽高比不同的图被套用错误的 scale(显示宽高比 =
自身宽高比 × 首图宽高比)。同时 .lightbox-img 的 object-fit: contain
与 transformFor 依赖的「位图填满布局盒」前提冲突,素材网格的方形
缩略图容器下首图即被压扁。

修复:
- swap() 在新图加载后按 naturalWidth/Height 重算 fitCentered 目标,
  布局盒设为 target 尺寸、transform 归位 scale(1,1),baseW/H 同步
  供关闭飞回动画复用
- 先换 src 再判 complete,避免用旧图的 complete 误判缓存命中
- 移除 .lightbox-img 的 object-fit: contain,恢复位图填满布局盒

新增回归测试:切换后几何必须按新图宽高比重算(红绿验证通过)
2026-07-24 18:15:11 +08:00

311 lines
11 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');
}
/** stub 灯箱图的 natural 尺寸与 completehappy-dom 不加载真实图片)。 */
function stubNatural(img: HTMLImageElement, w: number, h: number): void {
Object.defineProperty(img, 'naturalWidth', { configurable: true, value: w });
Object.defineProperty(img, 'naturalHeight', { configurable: true, value: h });
Object.defineProperty(img, 'complete', { configurable: true, value: true });
}
/** 模拟元素 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);
});
it('切换后按新图宽高比重算几何(不沿用第一张的 target/scale', () => {
const imgs = [makeGalleryImage('/a.webp', 'A'), makeGalleryImage('/b.webp', 'B')];
mountRoot(imgs);
window.__initLightbox('.post-content');
clickEl(imgs[0]);
const lbImg = getLightboxImg()!;
// happy-dom 不真实加载图片:手动 stub 第 1 张 natural 尺寸并派发 load
// 让 openLightbox 的 start() 走完state.target/baseW 按第 1 张建立)。
stubNatural(lbImg, 1000, 500);
lbImg.dispatchEvent(new Event('load'));
vi.advanceTimersByTime(50); // double-rAF 在 fake timers 下同步推进
// 切到第 2 张前,先把 natural stub 成新图尺寸(竖图),模拟缓存命中
pressKey('ArrowRight');
stubNatural(lbImg, 500, 1000);
vi.advanceTimersByTime(200); // 150ms 淡出后 swap 同步执行
// 几何必须按 500x1000 重算:布局盒 = fitCentered 目标尺寸(宽高比 0.5
// transform 归位 scale(1,1)。修复前这里仍是第 1 张的 width/height/scale。
const w = parseFloat(lbImg.style.width);
const h = parseFloat(lbImg.style.height);
expect(w).toBeGreaterThan(0);
expect(w / h).toBeCloseTo(0.5, 2);
expect(lbImg.style.transform).toContain('scale(1,1)');
});
});
describe('重复初始化幂等SPA 数据刷新场景)', () => {
it('同一批节点重复 __initLightbox点击只创建一次 overlay', () => {
const img = makeGalleryImage('/a.webp', 'A');
mountRoot([img]);
window.__initLightbox('.post-content');
// 模拟 /admin/assets 刷新后 Dioxus 复用 DOM 节点导致的重复初始化
window.__initLightbox('.post-content');
const appendSpy = vi.spyOn(document.body, 'appendChild');
clickEl(img);
// 无守卫时两个 click 监听先后触发 openLightbox → overlay 被 append 两次
const overlayAppends = appendSpy.mock.calls.filter(
(c) => c[0] instanceof HTMLElement && c[0].classList.contains('lightbox-overlay'),
);
expect(overlayAppends).toHaveLength(1);
appendSpy.mockRestore();
});
});
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(); // 仍打开
});
});
});