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

321 lines
12 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.
// Stage 7 tests: modal.js
//
// 覆盖:
// - mountModal基本 DOM 结构 / aria 属性
// - 关闭路径:× 按钮 / Esc 键 / overlay-click 背景 / close(value) 显式
// - data-action 按钮 → onAction 回调(**只对 footer/× 生效body 内不拦截**
// - 单槽冲突:已开 modal 时再 mount 返回 null
// - setBody / setFooter 替换内容
// - setOnClose 接收 close 的 value
// - #modal-root 缺失时返回 null
//
// 需要 DOMjsdom 环境。
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mountModal } from '../../src/modal.js';
import { isOpen as modalIsOpen } from '../../src/modal-stack.js';
beforeEach(() => {
// 每个测试前清空 #modal-root保证 modal-stack 状态干净
document.body.innerHTML = '<div id="modal-root"></div>';
// 防御:万一上一次测试失败没 unregister
// modal-stack 的 isOpen 用 currentOverlay.isConnected 校验DOM 清了后 isOpen() 就 false 了。
// 不需要手动 unregister。
});
afterEach(() => {
// 兜底:派发 Esc 给 document让任何漏关的 modal 收掉
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
document.body.innerHTML = '';
});
function makeModal(opts = {}) {
return mountModal({
title: opts.title ?? '测试标题',
body: opts.body ?? '<p>内容</p>',
footer: opts.footer ?? '<button class="btn" data-action="ok">确定</button>',
...opts,
});
}
describe('mountModal 基本结构', () => {
it('#modal-root 缺失 → 返回 null', () => {
document.body.innerHTML = '';
const result = mountModal({ title: 'x' });
expect(result).toBeNull();
});
it('挂载后返回 controller 且 overlay 在 DOM 里', () => {
const modal = makeModal();
expect(modal).not.toBeNull();
expect(modal.overlay).toBeInstanceOf(HTMLElement);
expect(modal.overlay.classList.contains('modal-overlay')).toBe(true);
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true);
});
it('设了 ARIA 属性', () => {
const modal = makeModal({ title: 'aria 测试' });
expect(modal.overlay.getAttribute('role')).toBe('dialog');
expect(modal.overlay.getAttribute('aria-modal')).toBe('true');
const titleId = modal.overlay.getAttribute('aria-labelledby');
expect(titleId).toBeTruthy();
// title 元素真有这个 id
const titleEl = document.getElementById(titleId);
expect(titleEl).not.toBeNull();
expect(titleEl.textContent).toBe('aria 测试');
});
it('body / footer 内容写入对应容器', () => {
const modal = makeModal({
body: '<div class="custom-body">hello</div>',
footer: '<button class="btn" data-action="cancel">取消</button>',
});
expect(modal.body.querySelector('.custom-body').textContent).toBe('hello');
expect(modal.footer.querySelector('[data-action="cancel"]')).not.toBeNull();
});
});
describe('mountModal 关闭路径', () => {
it('close(value) → 从 DOM 移除 + onClose(value) 触发', () => {
const modal = makeModal();
const onClose = vi.fn();
modal.setOnClose(onClose);
modal.close('ok');
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false);
expect(modalIsOpen()).toBe(false);
expect(onClose).toHaveBeenCalledWith('ok');
});
it('重复 close 是幂等的', () => {
const modal = makeModal();
const onClose = vi.fn();
modal.setOnClose(onClose);
modal.close('first');
modal.close('second');
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledWith('first');
});
it('点 × 按钮 → onBackdropClose 触发关闭', () => {
const modal = makeModal({ onBackdropClose: 'cancel' });
const onClose = vi.fn();
modal.setOnClose(onClose);
const closeBtn = modal.overlay.querySelector('.modal-close');
closeBtn.click();
expect(onClose).toHaveBeenCalledWith('cancel');
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false);
});
it('按 Esc → onBackdropClose 触发关闭', () => {
const modal = makeModal({ onBackdropClose: 'esc-cancel' });
const onClose = vi.fn();
modal.setOnClose(onClose);
modal.overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(onClose).toHaveBeenCalledWith('esc-cancel');
});
it('点 overlay 背景(不是 modal 内部) → onBackdropClose', () => {
const modal = makeModal({ onBackdropClose: 'dismissed' });
const onClose = vi.fn();
modal.setOnClose(onClose);
// 直接派发 click 到 overlay 本身
modal.overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onClose).toHaveBeenCalledWith('dismissed');
});
it('点 modal 内部(不是 overlay 背景)不关闭', () => {
const modal = makeModal({ onBackdropClose: 'should-not-fire' });
const onClose = vi.fn();
modal.setOnClose(onClose);
// 点击 modal 内部元素
const inner = modal.overlay.querySelector('.modal');
inner.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onClose).not.toHaveBeenCalled();
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true);
});
});
describe('mountModal onAction 回调', () => {
it('点 footer [data-action] 按钮 → onAction(action, controller)', () => {
const onAction = vi.fn();
const modal = makeModal({
onAction,
footer: `
<button class="btn" data-action="save">保存</button>
<button class="btn" data-action="cancel">取消</button>
`,
});
const cancelBtn = modal.footer.querySelector('[data-action="cancel"]');
cancelBtn.click();
expect(onAction).toHaveBeenCalledWith('cancel', modal);
});
it('onAction 返回 "close" → 自动 close(action)', () => {
const onAction = vi.fn(() => 'close');
const modal = makeModal({ onAction });
const onClose = vi.fn();
modal.setOnClose(onClose);
const btn = modal.footer.querySelector('[data-action="ok"]');
btn.click();
expect(onAction).toHaveBeenCalled();
expect(onClose).toHaveBeenCalledWith('ok');
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false);
});
it('onAction 返回 undefined → 不关', () => {
const onAction = vi.fn();
const modal = makeModal({ onAction });
const onClose = vi.fn();
modal.setOnClose(onClose);
modal.footer.querySelector('[data-action="ok"]').click();
expect(onAction).toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true);
});
// audit fix (Round 8 M-3)onAction 抛同步错时 modal 不能挂死。
// 旧实现onAction 抛错 → event listener 抛 → modal 不关 → 用户没法关窗。
// 修复try/catch 兜底,错误打 consolemodal 保持打开(用户看到具体错误)。
it('onAction 抛同步错 → 不挂死modal 仍可关Round 8 M-3', () => {
const onAction = vi.fn(() => { throw new Error('dialog logic exploded'); });
const modal = makeModal({ onAction });
const onClose = vi.fn();
modal.setOnClose(onClose);
// 静默 console.error避免测试输出噪音
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => modal.footer.querySelector('[data-action="ok"]').click()).not.toThrow();
expect(onAction).toHaveBeenCalled();
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
// 关键断言 —— modal 仍可关闭(× 按钮正常工作)
modal.footer.querySelector('[data-action="__modal_close__"], .modal-close')?.click();
// 没有 × 时 overlay 背景点击也走 onBackdropClose这里显式 close(value) 模拟用户关窗
modal.close('manual');
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false);
});
});
describe('mountModal data-action 作用域(仅 modal-level', () => {
// 契约data-action 是 modal-level 属性,只被 modal 在 footer + × 关闭按钮上拦截。
// body 内的 [data-action] 是 dialog 自己的 in-body action例如 settings-dialog
// 的「显示/隐藏 API Key」「清空 AI 配置」),由 dialog 各自 bind 处理modal
// 不应拦截、不应触发 onAction、也不应自动关闭。
//
// 之前 modal 无差别拦截所有 [data-action]body 内按钮的 onAction 默认返回
// 'close' → 点「显示」就立刻关 modalUX 坏掉。
it('body 内 [data-action] 按钮不触发 onAction', () => {
const onAction = vi.fn(() => 'close');
const modal = makeModal({
onAction,
body: '<button type="button" data-action="toggle-ai-key">显示 API Key</button>',
footer: '<button class="btn" data-action="ok">确定</button>',
});
modal.body.querySelector('[data-action="toggle-ai-key"]').click();
expect(onAction).not.toHaveBeenCalled();
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true);
});
it('body 内 [data-action] 按钮即使 onAction 返回 "close" 也不关 modal', () => {
const onAction = vi.fn(() => 'close');
const onClose = vi.fn();
const modal = makeModal({
onAction,
onClose,
body: '<button type="button" data-action="clear-ai">[清空 AI 配置]</button>',
});
modal.setOnClose(onClose);
modal.body.querySelector('[data-action="clear-ai"]').click();
expect(onAction).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true);
});
it('footer 内 [data-action] 仍然走 onAction回归保护', () => {
const onAction = vi.fn(() => 'close');
const modal = makeModal({
onAction,
body: '<button type="button" data-action="body-btn">body btn</button>',
footer: '<button class="btn" data-action="footer-btn">footer btn</button>',
});
modal.footer.querySelector('[data-action="footer-btn"]').click();
expect(onAction).toHaveBeenCalledWith('footer-btn', modal);
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false);
});
});
describe('mountModal 单槽冲突', () => {
it('已开一个再开第二个 → 第二个返回 null', () => {
const first = makeModal({ title: 'first' });
const second = makeModal({ title: 'second' });
expect(first).not.toBeNull();
expect(second).toBeNull();
});
it('关掉第一个后才能再开第二个', () => {
const first = makeModal({ title: 'first' });
expect(makeModal({ title: 'second' })).toBeNull();
first.close('ok');
const second = makeModal({ title: 'second' });
expect(second).not.toBeNull();
});
});
describe('mountModal setBody / setFooter', () => {
it('setBody 替换正文', () => {
const modal = makeModal();
modal.setBody('<p class="replaced">new</p>');
expect(modal.body.querySelector('.replaced').textContent).toBe('new');
});
it('setFooter 替换 footer 且按钮事件继续走委托', () => {
const onAction = vi.fn();
const modal = makeModal({ onAction });
modal.setFooter('<button class="btn" data-action="replaced">替换的按钮</button>');
modal.footer.querySelector('[data-action="replaced"]').click();
expect(onAction).toHaveBeenCalledWith('replaced', modal);
});
});
describe('mountModal abortSignal', () => {
it('暴露 AbortSignal 用于外部挂监听', () => {
const modal = makeModal();
expect(modal.abortSignal).toBeInstanceOf(AbortSignal);
expect(modal.abortSignal.aborted).toBe(false);
modal.close('x');
expect(modal.abortSignal.aborted).toBe(true);
});
});