diff --git a/libs/lightbox/package-lock.json b/libs/lightbox/package-lock.json
index a4532c9..f740a54 100644
--- a/libs/lightbox/package-lock.json
+++ b/libs/lightbox/package-lock.json
@@ -8,6 +8,7 @@
"name": "@yggdrasil/lightbox",
"version": "1.0.0",
"devDependencies": {
+ "happy-dom": "^20.10.6",
"typescript": "^6.0.3",
"vite": "^8.1.0",
"vitest": "^4.1.9"
@@ -388,6 +389,33 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "26.0.0",
+ "resolved": "https://registry.npmmirror.com/@types/node/-/node-26.0.0.tgz",
+ "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@types/whatwg-mimetype": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmmirror.com/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
+ "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@vitest/expect": {
"version": "4.1.9",
"resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-4.1.9.tgz",
@@ -511,6 +539,19 @@
"node": ">=12"
}
},
+ "node_modules/buffer-image-size": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmmirror.com/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
+ "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz",
@@ -538,6 +579,19 @@
"node": ">=8"
}
},
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-module-lexer": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
@@ -598,6 +652,25 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/happy-dom": {
+ "version": "20.10.6",
+ "resolved": "https://registry.npmmirror.com/happy-dom/-/happy-dom-20.10.6.tgz",
+ "integrity": "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": ">=20.0.0",
+ "@types/whatwg-mimetype": "^3.0.2",
+ "@types/ws": "^8.18.1",
+ "buffer-image-size": "^0.6.4",
+ "entities": "^7.0.1",
+ "whatwg-mimetype": "^3.0.0",
+ "ws": "^8.21.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1101,6 +1174,13 @@
"node": ">=14.17"
}
},
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/vite": {
"version": "8.1.0",
"resolved": "https://registry.npmmirror.com/vite/-/vite-8.1.0.tgz",
@@ -1269,6 +1349,16 @@
}
}
},
+ "node_modules/whatwg-mimetype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+ "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -1285,6 +1375,28 @@
"engines": {
"node": ">=8"
}
+ },
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/libs/lightbox/package.json b/libs/lightbox/package.json
index b9cae50..b4808bf 100644
--- a/libs/lightbox/package.json
+++ b/libs/lightbox/package.json
@@ -11,6 +11,7 @@
"test:watch": "vitest"
},
"devDependencies": {
+ "happy-dom": "^20.10.6",
"typescript": "^6.0.3",
"vite": "^8.1.0",
"vitest": "^4.1.9"
diff --git a/libs/lightbox/src/geometry.test.ts b/libs/lightbox/src/geometry.test.ts
index 82e19d2..dbf4975 100644
--- a/libs/lightbox/src/geometry.test.ts
+++ b/libs/lightbox/src/geometry.test.ts
@@ -1,3 +1,4 @@
+// @vitest-environment node
import { describe, it, expect } from 'vitest';
import { fitCentered, transformFor, originalUrl, type Rect } from './geometry';
diff --git a/libs/lightbox/src/lightbox.test.ts b/libs/lightbox/src/lightbox.test.ts
new file mode 100644
index 0000000..22ffc88
--- /dev/null
+++ b/libs/lightbox/src/lightbox.test.ts
@@ -0,0 +1,266 @@
+/**
+ * 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 { describe, it, expect, beforeEach, afterEach, 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 = `
+
+
+ `;
+ 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 + matchMedia(prefersReducedMotion 读它)
+ document.body.innerHTML = '';
+ // happy-dom 的 matchMedia 返回值默认 matches=false,reduced-motion 关闭,
+ // 这样打开走 double-rAF 动画路径(更接近真实)。但我们用 fake timers 跳过动画。
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ document.body.innerHTML = '';
+ });
+
+ describe('循环闭包捕获 idx(gallery 绑定)', () => {
+ 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 图后出现 overlay,img 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 走 transitionend(happy-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(); // 仍打开
+ });
+ });
+});
diff --git a/libs/lightbox/vitest.config.ts b/libs/lightbox/vitest.config.ts
index 7eeb3f8..beedb8f 100644
--- a/libs/lightbox/vitest.config.ts
+++ b/libs/lightbox/vitest.config.ts
@@ -2,7 +2,9 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
- environment: 'node',
+ // 默认 happy-dom:index.ts 的行为测试需要真实 DOM(点击/keydown/scroll/IO)。
+ // geometry.test.ts 是纯函数,用文件内 // @vitest-environment node 指令保持 node 环境。
+ environment: 'happy-dom',
include: ['src/**/*.test.ts'],
},
});