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,422 @@
// Stage 7+: prompt-dialog.js
//
// 覆盖:
// - 挂载后渲染 input + 两个按钮cancel/confirm+ label
// - 点 Confirm → resolve(input.value),包含空格原样返回
// - 点 Cancel → resolve(null)
// - Esc / × 按钮 / 点 overlay 背景 → resolve(null)onBackdropClose='cancel'
// - Enter 键(在 input 内) → resolve(input.value)
// - input 引用丢失的极端兜底 → resolve(null),不挂死
// - 默认文本 selectAllOnOpen=true 时选中(默认行为)
// - 默认文本 selectAllOnOpen=false 时不选中
// - 用户自定义 confirmLabel / cancelLabel / placeholder 写入 DOM
// - #modal-root 缺失 → resolve(null)
// - 已有 modal 占位 → resolve(null)(单槽冲突)
// - 无 title 参数 → resolve(null)(兜底,不挂死)
//
// 需要 DOMjsdom 环境。
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { showPromptDialog } from '../../src/prompt-dialog.js';
import { isOpen as modalIsOpen } from '../../src/modal-stack.js';
beforeEach(() => {
document.body.innerHTML = '<div id="modal-root"></div>';
});
afterEach(() => {
// 兜底:派发 Esc让任何漏关的 modal 收掉
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
document.body.innerHTML = '';
});
/**
* 等待 queueMicrotask + 一帧渲染focus / .select() 都依赖异步)
*/
function flushMicrotasks() {
return new Promise((resolve) => {
queueMicrotask(() => queueMicrotask(resolve));
});
}
describe('showPromptDialog 基本结构', () => {
it('挂载后渲染 input + 两个按钮', async () => {
const promise = showPromptDialog({ title: '新建笔记' });
const input = document.getElementById('prompt-dialog-input');
expect(input).not.toBeNull();
expect(input.tagName).toBe('INPUT');
expect(input.type).toBe('text');
const buttons = document.querySelectorAll('.modal-footer .btn');
expect(buttons.length).toBe(2);
const actions = Array.from(buttons).map((b) => b.dataset.action);
expect(actions).toContain('cancel');
expect(actions).toContain('confirm');
// 清理
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('label 关联到 inputfor 属性匹配 id', async () => {
const promise = showPromptDialog({ title: '重命名', inputLabel: '新文件名' });
const label = document.querySelector('.modal-body .form-label');
expect(label).not.toBeNull();
expect(label.getAttribute('for')).toBe('prompt-dialog-input');
expect(label.textContent).toBe('新文件名');
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('inputLabel 缺省时退化为 title', async () => {
const promise = showPromptDialog({ title: '我的标题' });
const label = document.querySelector('.modal-body .form-label');
expect(label.textContent).toBe('我的标题');
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('用户自定义 confirmLabel / cancelLabel / placeholder 写入 DOM', async () => {
const promise = showPromptDialog({
title: 't',
confirmLabel: '好的',
cancelLabel: '不要',
placeholder: '请输入文件名',
});
const input = document.getElementById('prompt-dialog-input');
expect(input.getAttribute('placeholder')).toBe('请输入文件名');
const buttons = document.querySelectorAll('.modal-footer .btn');
const labels = Array.from(buttons).map((b) => b.textContent.trim());
expect(labels).toContain('好的');
expect(labels).toContain('不要');
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('message 缺省不渲染 <p.conf-message>', async () => {
const promise = showPromptDialog({ title: 'no-msg' });
expect(document.querySelector('.confirm-message')).toBeNull();
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('title / message / label 的 HTML 特殊字符被转义(防止 XSS', async () => {
const promise = showPromptDialog({
title: '<img src=x>',
message: '<script>alert(1)</script>',
inputLabel: '"><b>x</b>',
});
const overlay = document.querySelector('.modal-overlay');
expect(overlay.innerHTML).not.toContain('<img src=x>');
expect(overlay.innerHTML).not.toContain('<script>alert(1)</script>');
// 转义后保留字面字符
expect(overlay.textContent).toContain('<img src=x>');
expect(overlay.textContent).toContain('<script>alert(1)</script>');
overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
});
describe('showPromptDialog 关闭路径', () => {
it('点 Confirm → resolve(input.value),含空格原样返回', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: '默认' });
const input = document.getElementById('prompt-dialog-input');
input.value = ' hello world ';
const confirmBtn = document.querySelector('.modal-footer .btn[data-action="confirm"]');
confirmBtn.click();
await expect(promise).resolves.toBe(' hello world ');
expect(modalIsOpen()).toBe(false);
});
it('点 Cancel → resolve(null)', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: '默认值' });
const cancelBtn = document.querySelector('.modal-footer .btn[data-action="cancel"]');
cancelBtn.click();
await expect(promise).resolves.toBeNull();
expect(modalIsOpen()).toBe(false);
});
it('按 Esc → resolve(null)onBackdropClose=cancel', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: 'x' });
const overlay = document.querySelector('.modal-overlay');
overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await expect(promise).resolves.toBeNull();
});
it('点 × 按钮 → resolve(null)', async () => {
const promise = showPromptDialog({ title: 't' });
document.querySelector('.modal-close').click();
await expect(promise).resolves.toBeNull();
});
it('点 overlay 背景 → resolve(null)', async () => {
const promise = showPromptDialog({ title: 't' });
const overlay = document.querySelector('.modal-overlay');
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await expect(promise).resolves.toBeNull();
});
it('Enter 键(在 input 内) → resolve(input.value)modal 关闭', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: 'old' });
const input = document.getElementById('prompt-dialog-input');
input.value = 'new name';
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await expect(promise).resolves.toBe('new name');
expect(modalIsOpen()).toBe(false);
});
it('Enter 键被 preventDefault不触发任何潜在 form submit', async () => {
const promise = showPromptDialog({ title: 't' });
const input = document.getElementById('prompt-dialog-input');
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
input.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
await promise;
});
it('关闭后 overlay 已从 DOM 移除', async () => {
const promise = showPromptDialog({ title: 't' });
expect(document.querySelector('.modal-overlay')).not.toBeNull();
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
expect(document.querySelector('.modal-overlay')).toBeNull();
});
});
describe('showPromptDialog 默认值 / 全选', () => {
it('defaultValue 写入 input.value', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: '未命名-2026' });
const input = document.getElementById('prompt-dialog-input');
expect(input.value).toBe('未命名-2026');
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('selectAllOnOpen=true默认+ defaultValue 非空 → 打开后选中', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: '选中我' });
await flushMicrotasks();
const input = document.getElementById('prompt-dialog-input');
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe('选中我'.length);
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('selectAllOnOpen=false → 不全选', async () => {
const promise = showPromptDialog({
title: 't',
defaultValue: '保持光标',
selectAllOnOpen: false,
});
await flushMicrotasks();
const input = document.getElementById('prompt-dialog-input');
// selectionStart === selectionEnd 表示无选中(光标在末尾)
expect(input.selectionStart).toBe(input.selectionEnd);
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
});
describe('showPromptDialog validate 选项', () => {
it('validate 返回字符串 → 显示行内错误、dialog 保持打开', async () => {
let calls = 0;
const promise = showPromptDialog({
title: '重命名',
defaultValue: 'old.md',
validate: (raw) => {
calls += 1;
if (raw.includes('/')) return '不能包含 /';
return null;
},
});
await flushMicrotasks();
const input = document.getElementById('prompt-dialog-input');
const errorEl = document.getElementById('prompt-dialog-error');
// 改成非法值 → 点确认
input.value = 'foo/bar.md';
const confirmBtn = document.querySelector('.modal-footer .btn[data-action="confirm"]');
confirmBtn.click();
await flushMicrotasks();
// validate 调用了错误显示modal 仍在promise 没 resolve
expect(calls).toBeGreaterThanOrEqual(1);
expect(errorEl.hidden).toBe(false);
expect(errorEl.textContent).toBe('不能包含 /');
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(input.classList.contains('is-invalid')).toBe(true);
expect(modalIsOpen()).toBe(true);
// 改成合法值 → input 事件清错误
input.value = 'foo-bar.md';
input.dispatchEvent(new Event('input', { bubbles: true }));
expect(errorEl.hidden).toBe(true);
expect(input.hasAttribute('aria-invalid')).toBe(false);
// 再次确认 → 通过 → resolve
confirmBtn.click();
await expect(promise).resolves.toBe('foo-bar.md');
});
it('validate 抛错 → 错误消息被当作 validate 返回值展示', async () => {
const promise = showPromptDialog({
title: 't',
validate: () => { throw new Error('check failed'); },
});
await flushMicrotasks();
const errorEl = document.getElementById('prompt-dialog-error');
document.querySelector('.modal-footer .btn[data-action="confirm"]').click();
await flushMicrotasks();
expect(errorEl.hidden).toBe(false);
expect(errorEl.textContent).toBe('check failed');
expect(modalIsOpen()).toBe(true);
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await expect(promise).resolves.toBeNull();
});
it('validate 不传 → 行为不变(向后兼容)', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: 'ok' });
document.querySelector('.modal-footer .btn[data-action="confirm"]').click();
await expect(promise).resolves.toBe('ok');
});
// audit fix (Phase L1-Settings P1)Enter 键必须与按钮 click 走完全同一条
// validate 路径。早期实现 Enter 直接 modal.close('confirm') 跳过校验 → 用户
// 重命名输入非法名按 Enter → dialog 静默关闭 → typed text 丢失、错误仅通过
// 通用 toast 模糊暴露。
it('Enter 键 + validate 失败 → dialog 保持打开、行内错误显示', async () => {
let calls = 0;
const promise = showPromptDialog({
title: '重命名',
defaultValue: 'old.md',
validate: (raw) => {
calls += 1;
if (raw.includes('/')) return '不能包含 /';
return null;
},
});
await flushMicrotasks();
const input = document.getElementById('prompt-dialog-input');
const errorEl = document.getElementById('prompt-dialog-error');
// 改成非法值,按 Enter —— 之前会跳过 validate 直接关 dialog
input.value = 'foo/bar.md';
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await flushMicrotasks();
expect(calls).toBeGreaterThanOrEqual(1);
expect(errorEl.hidden).toBe(false);
expect(errorEl.textContent).toBe('不能包含 /');
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(modalIsOpen()).toBe(true);
// promise 还没 resolve
let resolved = false;
promise.then(() => { resolved = true; });
await flushMicrotasks();
expect(resolved).toBe(false);
// 改成合法值按 Enter → 通过 → resolve
input.value = 'foo-bar.md';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await expect(promise).resolves.toBe('foo-bar.md');
});
it('Enter 键 + 无 validate → 行为不变(向后兼容)', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: 'ok' });
const input = document.getElementById('prompt-dialog-input');
input.value = 'changed';
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await expect(promise).resolves.toBe('changed');
});
});
describe('showPromptDialog 退化路径', () => {
it('#modal-root 缺失 → resolve(null),不挂死', async () => {
document.body.innerHTML = ''; // 干掉 #modal-root
const promise = showPromptDialog({ title: 't', defaultValue: 'd' });
await expect(promise).resolves.toBeNull();
});
it('已有 modal 占位 → resolve(null)(与 mountModal 单槽冲突一致)', async () => {
// 第一个 modal 占住 #modal-root
const first = showPromptDialog({ title: 'first' });
// 第二个开不起来
const second = showPromptDialog({ title: 'second' });
await expect(second).resolves.toBeNull();
// 收掉第一个
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await first;
});
it('无 title 参数 → 立即 resolve(null),不挂死', async () => {
const promise = showPromptDialog();
await expect(promise).resolves.toBeNull();
expect(modalIsOpen()).toBe(false);
});
});
describe('showPromptDialog Promise resolve 幂等性', () => {
it('close 后多次 resolve 不抛错Promise resolve 是幂等的)', async () => {
const promise = showPromptDialog({ title: 't' });
const overlay = document.querySelector('.modal-overlay');
overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
// 再次尝试关 —— close 内部有 closed 保护
const modalCtrl = document.getElementById('modal-root').firstChild;
// 第一次关闭后 overlay 已经从 DOM 移除;这里只确保 promise 不抛
await expect(promise).resolves.toBeNull();
});
});