Files
Notes/tests/unit/context-menu.test.js
2026-09-12 14:15:26 +08:00

115 lines
3.7 KiB
JavaScript
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.
// 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);
});
});