115 lines
3.7 KiB
JavaScript
115 lines
3.7 KiB
JavaScript
// src/context-menu.js 的单测
|
||
//
|
||
// 主要覆盖 audit #C3(critical 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 #C3:show 后立即 hide,应取消挂起的 rAF', async () => {
|
||
// 模拟场景:用户打开菜单,立刻关闭(onSelect 路径)。原实现的 rAF 仍会
|
||
// fire 并挂 5 个 listener —— 现在必须被取消。
|
||
menu.show({
|
||
x: 100, y: 100,
|
||
items: [{ label: '复制', value: 'copy' }],
|
||
onSelect: () => {},
|
||
});
|
||
// rAF 还没 fire(jsdom 默认 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 #C3:show→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);
|
||
});
|
||
}); |