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

188
tests/unit/feedback.test.js Normal file
View File

@@ -0,0 +1,188 @@
// Stage 7+: feedback.js (showToast)
//
// 覆盖 feedback.js 的「被广泛调用但没测试」的分支:
// - #toast-container 缺失 → 静默 return不抛、不 console 噪声)
// - MAX_VISIBLE_TOASTS 超出 → 同步移除最早的(不等淡出动画)
// - dismiss() 重入守卫dataset.dismissed=1 后第二次调用直接 return
// (防 transition 重入 + 防 double-remove
// - duration=0 → 不挂 auto-close timer但 click 仍能关)
// - duration>0 → 计时器到期自动 dismiss
// - 默认 type='info' → className 含 is-info
// - message / type 写入 DOMrole=status + textContent
//
// 75+ 个调用点app.js / file-ops.js / settings-dialog.js 等。补上 guard 守护,
// 防止未来重构时无意改坏 eviction / dismiss 语义(数据丢失风险虽低,但 toast
// 满天飞也是用户能感知的 bug
//
// 需要 DOMjsdom 环境。
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { showToast } from '../../src/feedback.js';
beforeEach(() => {
// 标准 toast container —— src/index.html 里的 id
document.body.innerHTML = '<div id="toast-container"></div>';
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = '';
});
/**
* 等待一次 raf 让 DOM 操作 / 微任务走完 —— jsdom 里 setTimeout / 微任务
* 时序差异大,必须显式推进。
*/
function flushAll() {
// 推进 fake timersduration 到期)+ queueMicrotask
return Promise.resolve().then(() => Promise.resolve());
}
describe('showToast 基础行为', () => {
it('#toast-container 缺失 → 静默 return不抛、不写 console', () => {
document.body.innerHTML = '';
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
expect(() => showToast('hi')).not.toThrow();
expect(warn).not.toHaveBeenCalled();
expect(log).not.toHaveBeenCalled();
warn.mockRestore();
log.mockRestore();
});
it('默认 type=info → toast 含 className "toast is-info"', () => {
showToast('hello');
const t = document.querySelector('#toast-container > .toast');
expect(t).not.toBeNull();
expect(t.className).toContain('is-info');
});
it('type=success / warning / error → 各自 className', () => {
showToast('a', 'success');
showToast('b', 'warning');
showToast('c', 'error');
const list = Array.from(document.querySelectorAll('#toast-container > .toast'));
expect(list.map((el) => el.className)).toEqual([
expect.stringContaining('is-success'),
expect.stringContaining('is-warning'),
expect.stringContaining('is-error'),
]);
});
it('message 写入 textContentrole=status', () => {
showToast('自定义消息', 'warning');
const t = document.querySelector('#toast-container > .toast');
expect(t.textContent).toBe('自定义消息');
expect(t.getAttribute('role')).toBe('status');
});
});
describe('showToast 自动关闭 + duration', () => {
it('duration>0 → 计时器到期后自动 dismiss', () => {
showToast('auto', 'info', 1000);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(1);
vi.advanceTimersByTime(1000);
// dismiss 内部还有 150ms 的 fade-out setTimeout
vi.advanceTimersByTime(150);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(0);
});
it('duration=0 → 不挂 auto-close timer但 click 仍能关', () => {
showToast('sticky', 'info', 0);
const t = document.querySelector('#toast-container > .toast');
expect(t).not.toBeNull();
vi.advanceTimersByTime(60_000);
// 还在
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(1);
// click 关闭
t.click();
vi.advanceTimersByTime(150);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(0);
});
});
describe('showToast click 立即关闭', () => {
it('点击 toast → 立即触发 dismiss', () => {
showToast('clickable', 'info', 60_000);
const t = document.querySelector('#toast-container > .toast');
t.click();
vi.advanceTimersByTime(150);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(0);
});
});
describe('showToast MAX_VISIBLE_TOASTS 上限', () => {
// MAX_VISIBLE_TOASTS = 5 — 反馈模块内部常量。如果改了这里也要改测试。
// 抽成常量让失败信息更可读。
const MAX = 5;
it('到上限再 push → 最旧的被同步移除', () => {
for (let i = 0; i < MAX; i += 1) showToast(`t${i}`);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(MAX);
showToast(`t${MAX}`);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(MAX);
// 最旧的 t0 应被踢出
const texts = Array.from(document.querySelectorAll('#toast-container > .toast'))
.map((el) => el.textContent);
expect(texts).not.toContain('t0');
expect(texts).toContain(`t${MAX}`);
});
it('连续 push 6 条 → 仍只剩最新 5 条', () => {
for (let i = 0; i < MAX + 1; i += 1) showToast(`t${i}`);
const list = Array.from(document.querySelectorAll('#toast-container > .toast'));
expect(list).toHaveLength(MAX);
const texts = list.map((el) => el.textContent);
expect(texts).toEqual(['t1', 't2', 't3', 't4', 't5']);
});
it('被踢出的 toast 带 data-dismissed="1"(防 timer / click 回调闭包污染)', () => {
for (let i = 0; i < MAX + 1; i += 1) showToast(`t${i}`);
// 已经从 DOM 移除 —— 但闭包里的 dismiss 标记应当仍为 1。
// 这里只能从新增的第 6 条倒推:第 6 条入 DOM 时把 t0 踢出,
// t0.dataset.dismissed 应是 1。
// 由于 t0 已经脱离 DOMdataset 仍可读dataset 是元素本身的属性)。
// 我们拿到的是被踢的节点引用 —— 没法直接从 container 查。
// 改为验证:再 push 1 条时,没有 console errordismiss 回调跑过、被守卫挡住)。
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
showToast('extra');
// 让所有到期 timer 跑一遍(包括 t0 的 auto-close timer —— 若 dismiss 没被
// 守卫挡,会触发 container.removeChild(t0),但 t0 已经脱离 DOM
// removeChild 抛 NotFoundError 进 console
vi.advanceTimersByTime(60_000);
expect(err).not.toHaveBeenCalled();
err.mockRestore();
});
});
describe('showToast dismiss() 重入守卫', () => {
it('同一 toast 的 dismiss 被多次触发auto + click 抢跑)→ 只移除一次', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
showToast('race', 'info', 1000);
const t = document.querySelector('#toast-container > .toast');
// click 先触发 dismissdataset.dismissed=1, fade-out timer 排队)
t.click();
// 同时计时器到期再触发 dismiss —— 应被 dataset 守卫挡住
vi.advanceTimersByTime(1000);
// 让两段 fade-out 完成
vi.advanceTimersByTime(500);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(0);
// removeChild 不抛 NotFoundError —— 第二次 removeChild 被 parentElement 检查挡住
expect(err).not.toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
err.mockRestore();
});
});