This commit is contained in:
2026-09-12 14:15:26 +08:00
commit 9c06d3f4be
99 changed files with 41853 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
// src/context-menu.js 的单测
//
// 主要覆盖 audit #C3critical memory leak修复
// - 快速 show→hide→show 循环rAF 回调里的 addEventListener 不应泄漏
// - 同步 hide() 应当取消未 fire 的 rAF避免它在我们 detach 之后再去挂监听
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ContextMenu } from '../../src/context-menu.js';
describe('ContextMenu show/hide lifecycle', () => {
let menu;
let container;
beforeEach(() => {
// 用独立 container避免污染全局 body / 与其它测试串扰
container = document.createElement('div');
container.id = 'context-menu-test-container';
document.body.appendChild(container);
menu = new ContextMenu({ container });
});
afterEach(() => {
menu.hide();
if (container.parentElement) container.parentElement.removeChild(container);
});
it('show 后 isVisible() → true', () => {
menu.show({
x: 100, y: 100,
items: [{ label: '复制', value: 'copy' }],
onSelect: () => {},
});
expect(menu.isVisible()).toBe(true);
expect(container.querySelector('.context-menu')).toBeTruthy();
});
it('hide 后 isVisible() → false', () => {
menu.show({
x: 100, y: 100,
items: [{ label: '复制', value: 'copy' }],
onSelect: () => {},
});
menu.hide();
expect(menu.isVisible()).toBe(false);
expect(container.querySelector('.context-menu')).toBeNull();
});
it('hide() 可重复调用(不抛错)', () => {
menu.show({
x: 100, y: 100,
items: [{ label: '复制', value: 'copy' }],
onSelect: () => {},
});
expect(() => {
menu.hide();
menu.hide();
menu.hide();
}).not.toThrow();
});
it('audit #C3show 后立即 hide应取消挂起的 rAF', async () => {
// 模拟场景用户打开菜单立刻关闭onSelect 路径)。原实现的 rAF 仍会
// fire 并挂 5 个 listener —— 现在必须被取消。
menu.show({
x: 100, y: 100,
items: [{ label: '复制', value: 'copy' }],
onSelect: () => {},
});
// rAF 还没 firejsdom 默认 fire但 show 同步执行后立即 hide 应抢先)
menu.hide();
// 等待足够时间让原 rAF如果没被取消会 fire 的窗口过去
await new Promise((r) => setTimeout(r, 50));
// 此时再 show 一次:必须能成功挂上新菜单
expect(() => {
menu.show({
x: 200, y: 200,
items: [{ label: '粘贴', value: 'paste' }],
onSelect: () => {},
});
}).not.toThrow();
expect(menu.isVisible()).toBe(true);
});
it('audit #C3show→hide→show 快速循环不抛错、不泄漏菜单节点', async () => {
// 模拟右键 → 选项 → 立即右键 的快速循环
for (let i = 0; i < 5; i += 1) {
menu.show({
x: 50 + i * 10, y: 50 + i * 10,
items: [{ label: `Item ${i}`, value: `v${i}` }],
onSelect: () => {},
});
menu.hide();
}
// 让所有挂起的 rAF 跑完
await new Promise((r) => setTimeout(r, 50));
// 容器里只应该有一个当前菜单,或者零个(取决于时序)
const menus = container.querySelectorAll('.context-menu');
expect(menus.length).toBeLessThanOrEqual(1);
expect(menu.isVisible()).toBe(false);
});
it('空 items 数组 → 不创建菜单', () => {
menu.show({ x: 100, y: 100, items: [], onSelect: () => {} });
expect(menu.isVisible()).toBe(false);
expect(container.querySelector('.context-menu')).toBeNull();
});
it('非数组 items → 不创建菜单', () => {
menu.show({ x: 100, y: 100, items: null, onSelect: () => {} });
expect(menu.isVisible()).toBe(false);
});
});