This commit is contained in:
2026-09-12 13:55:57 +08:00
commit 30f1cedd39
95 changed files with 42057 additions and 0 deletions

View File

@@ -0,0 +1,244 @@
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);