Files
Todo-List/scripts/check-status-meta.mjs
2026-09-12 13:55:57 +08:00

244 lines
9.1 KiB
JavaScript
Raw Permalink 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.
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
import { installApiStub } from './_lib/api-stub.mjs';
// 回归测试:状态栏 #status-meta 在保存完成后必须显示「已同步 HH:MM」
//
// 触发 bug 的姿势app.js 第 178-192 行旧实现):
// _doAutoSave / saveNow 成功 → emit('saved') → 处理器把 #status-meta 写成
// 「已同步 HH:MM」
// → 紧接着 emit('dirty', false) → 处理器把 textContent=''
// 用户看到的现象:保存之后状态栏空空的,好像没保存过。
//
// 修复dirty(false) 处理器只在 isDirty=true 时覆写文本saved 处理器独立
// 维护时间戳;新增 load 处理器在重新加载后清空状态栏。
//
// 跑法node scripts/check-status-meta.mjs
// 桩 DOM状态栏文本 + classList。最简化的 stub不必仿真全部 DOM API。
let savedText = '';
let savedClasses = new Set();
const fakeMeta = {
get textContent() { return savedText; },
set textContent(v) { savedText = v; },
classList: {
toggle(c, on) { if (on) savedClasses.add(c); else savedClasses.delete(c); },
add(c) { savedClasses.add(c); },
remove(c) { savedClasses.delete(c); },
contains(c) { return savedClasses.has(c); }
}
};
globalThis.document = {
getElementById: (id) => (id === 'status-meta' ? fakeMeta : null)
};
// 桩 window.api.notifyDirtyChanged渲染端 → 主进程的 IPC
const dirtyNotifications = [];
installApiStub({
notifyDirtyChanged: (isDirty) => { dirtyNotifications.push(isDirty); }
});
const { TaskStore } = await import('../src/task-store.js');
const { CategoryKind } = await import('../src/markdown-parser.js');
// 把 app.js 里的 dirty/saved/load 处理器搬过来 —— 必须保持一致
function attachStatusMetaHandlers(store) {
store.on('dirty', (isDirty) => {
if (isDirty) fakeMeta.textContent = '未保存';
fakeMeta.classList.toggle('dirty', isDirty);
fakeMeta.classList.toggle('saved', !isDirty);
globalThis.window.api.notifyDirtyChanged(isDirty);
});
store.on('saved', () => {
const t = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
fakeMeta.textContent = `已同步 ${t}`;
fakeMeta.classList.remove('dirty');
fakeMeta.classList.add('saved');
});
store.on('load', () => {
fakeMeta.textContent = '';
fakeMeta.classList.remove('dirty');
fakeMeta.classList.remove('saved');
});
}
function resetMeta() {
savedText = '';
savedClasses = new Set();
dirtyNotifications.length = 0;
}
// ============================================
console.log('[1] 自动保存完成后状态栏必须保留「已同步 HH:MM」');
{
resetMeta();
const store = new TaskStore();
attachStatusMetaHandlers(store);
store.filePath = '/tmp/test.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
store.addTask('未分类', '买牛奶');
ok('addTask 后 textContent = 「未保存」', fakeMeta.textContent === '未保存',
`实际 ${JSON.stringify(fakeMeta.textContent)}`);
ok('addTask 后含 dirty 类、不含 saved 类',
fakeMeta.classList.contains('dirty') && !fakeMeta.classList.contains('saved'),
`classes=${[...savedClasses].join(',')}`);
// 触发自动保存800ms debounce flush
store._autoSave.flush();
await new Promise(r => setTimeout(r, 30));
// 关键断言saved 写入的时间戳必须留下来
if (fakeMeta.textContent.startsWith('已同步 ')) {
ok('自动保存完成后 textContent 仍为「已同步 HH:MM」',
true);
} else {
bad('自动保存完成后 textContent 仍为「已同步 HH:MM」',
`实际 ${JSON.stringify(fakeMeta.textContent)}`);
}
ok('自动保存完成后 dirty 类已移除', !fakeMeta.classList.contains('dirty'),
`classes=${[...savedClasses].join(',')}`);
ok('自动保存完成后 saved 类已添加', fakeMeta.classList.contains('saved'),
`classes=${[...savedClasses].join(',')}`);
ok('notifyDirtyChanged(true → false) 被按序通知',
JSON.stringify(dirtyNotifications) === '[true,false]',
`实际 ${JSON.stringify(dirtyNotifications)}`);
}
// ============================================
console.log('\n[2] Ctrl+S (saveNow) 后状态栏同样保留时间戳');
{
resetMeta();
const store = new TaskStore();
attachStatusMetaHandlers(store);
store.filePath = '/tmp/test.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
store.addTask('未分类', '买牛奶');
await store.saveNow();
if (fakeMeta.textContent.startsWith('已同步 ')) {
ok('saveNow 后 textContent 仍为「已同步 HH:MM」', true);
} else {
bad('saveNow 后 textContent 仍为「已同步 HH:MM」',
`实际 ${JSON.stringify(fakeMeta.textContent)}`);
}
}
// ============================================
console.log('\n[3] 编辑 → 保存 → 再编辑:状态栏正确切换「已同步 → 未保存」');
{
resetMeta();
const store = new TaskStore();
attachStatusMetaHandlers(store);
store.filePath = '/tmp/test.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
store.addTask('未分类', 'A');
await store.saveNow();
// 此时应为「已同步 HH:MM」
const afterFirstSave = fakeMeta.textContent;
if (!afterFirstSave.startsWith('已同步 ')) {
bad('第一次 saveNow 后 textContent 应为「已同步 HH:MM」',
`实际 ${JSON.stringify(afterFirstSave)}`);
} else {
ok('第一次 saveNow 后 textContent 为「已同步 HH:MM」', true);
}
store.addTask('未分类', 'B');
// 现在应该是「未保存」
if (fakeMeta.textContent === '未保存') {
ok('再次编辑后 textContent 切回「未保存」', true);
} else {
bad('再次编辑后 textContent 应切回「未保存」',
`实际 ${JSON.stringify(fakeMeta.textContent)}`);
}
ok('再次编辑后 dirty 类被添加', fakeMeta.classList.contains('dirty'),
`classes=${[...savedClasses].join(',')}`);
}
// ============================================
console.log('\n[4] 重新加载文件后清掉旧的保存状态');
{
resetMeta();
const store = new TaskStore();
attachStatusMetaHandlers(store);
store.filePath = '/tmp/test.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
// 先走一次「保存」留下时间戳
store.addTask('未分类', 'A');
await store.saveNow();
if (!fakeMeta.textContent.startsWith('已同步 ')) {
bad('前序 saveNow 后 textContent 应为「已同步 HH:MM」',
`实际 ${JSON.stringify(fakeMeta.textContent)}`);
} else {
ok('前序 saveNow 后 textContent 为「已同步 HH:MM」', true);
}
// 模拟「文件被外部修改 → 重新加载」
store.loadFromContent(
`# 全部任务\n\n## 未分类\n\n- [ ] C\n`,
'/tmp/test.md'
);
if (fakeMeta.textContent === '') {
ok('loadFromContent 后 textContent 清空', true);
} else {
bad('loadFromContent 后 textContent 应清空',
`实际 ${JSON.stringify(fakeMeta.textContent)}`);
}
ok('loadFromContent 后 dirty/saved 类都清掉',
!fakeMeta.classList.contains('dirty') && !fakeMeta.classList.contains('saved'),
`classes=${[...savedClasses].join(',')}`);
}
// ============================================
console.log('\n[5] loadFromContent 后 dirty=false 被显式通知给监听者');
{
resetMeta();
const store = new TaskStore();
attachStatusMetaHandlers(store);
store.filePath = '/tmp/test.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
store.addTask('未分类', 'A');
// 此时 dirtyNotifications 应有 true
await store.saveNow();
// 此时 dirtyNotifications 应有 true, false
const before = [...dirtyNotifications];
store.loadFromContent(
`# 全部任务\n\n## 未分类\n\n- [ ] C\n`,
'/tmp/test.md'
);
// loadFromContent 内部 _suppressDirty=true 期间已设 dirty=false
// finally 之后必须 emit('dirty', false),否则 notifyDirtyChanged
// 收不到这次复位,主进程在退出时仍可能误判状态
if (dirtyNotifications.length > before.length &&
dirtyNotifications[dirtyNotifications.length - 1] === false) {
ok('loadFromContent 后有 dirty(false) 通知',
true);
} else {
bad('loadFromContent 后应有 dirty(false) 通知',
`before=${JSON.stringify(before)} after=${JSON.stringify(dirtyNotifications)}`);
}
}
// ============================================
console.log(`\n${'─'.repeat(48)}`);
console.log(`通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);