From aadfed25959348f0f04c38cc627eb601446ab01d Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 24 Jun 2026 11:40:36 +0800 Subject: [PATCH] test(lightbox): add happy-dom behavior regression tests for refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为现代化重构(var→const、拆 IIFE、for→for..of)补行为回归防线。 黑盒驱动 window.__initLightbox,覆盖高风险路径: - 循环闭包捕获 idx(点第 1/2/3 张 counter 显示正确序号) - 点击打开(overlay 创建、origSrc 去 query、caption、单张 counter 隐藏) - 关闭(Esc / 点背景;点图片本身不关) - 图集 gotoIndex 循环边界(首末衔接 + originNode 更新后焦点归还) - 单张图不参与切换 happy-dom 20.10.6 作 devDep,vitest 默认环境改 happy-dom; geometry.test.ts 用 // @vitest-environment node 指令保持纯函数在 node 跑。 23 tests (12 geometry + 11 behavior) 全绿。 --- libs/lightbox/package-lock.json | 112 ++++++++++++ libs/lightbox/package.json | 1 + libs/lightbox/src/geometry.test.ts | 1 + libs/lightbox/src/lightbox.test.ts | 266 +++++++++++++++++++++++++++++ libs/lightbox/vitest.config.ts | 4 +- 5 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 libs/lightbox/src/lightbox.test.ts 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 = ` + ${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 + 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'], }, });