244 lines
9.1 KiB
JavaScript
244 lines
9.1 KiB
JavaScript
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);
|