Files
Notes/tests/unit/save-toast.test.js
2026-09-12 14:15:26 +08:00

270 lines
15 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.
// src/save-toast.js 单测Phase N Q-fix + Phase 2 audit 修复)
//
// 覆盖 save() outer toast 决策的所有 (silent × result) 组合:
// Phase N Q-fix 修四个真 bug
// 1. shared path silent=false manual + IIFE stale → 旧逻辑不弹 toast
// 用户按 Ctrl+S 切文件后完全无反馈
// 2. shared path silent=true auto-save + manual silent=false 写盘成功
// → 旧逻辑不弹「已保存」IIFE silent=true 不弹 + shared manual
// 因 IIFE silent 被吞也不弹)
// 3. onSaveRequest silent=false 与 main.js confirmDiscardIfDirty 错误反馈
// 双重 toast
// 4. file:write catch 直回 e.message英文 + 路径泄露)
// Phase 2 audit 修 P1 #3STALE 不再等同「写盘失败」—— writeFile 在 STALE
// 检查前已经 resolvedata 可能已落 targetPath。ok=true 让关窗路径能正常退
// 出,但 toast 文案不变(用户视角的「文件已切换」提示总是合适)。
// 本文件覆盖决策矩阵bug 3 在调用方 silent=trueonSaveRequestbug 4
// 在 main/file-ops.js#friendlyWriteError 处测,见 file-ops.test.js。
import { describe, it, expect, vi } from 'vitest';
import { decideSaveToast } from '../../src/save-toast.js';
function makeShowToast() {
return vi.fn();
}
describe('decideSaveToast —— 基础合约', () => {
it('silent=false + ok=true无 STALE→ 弹「已保存」(success)', () => {
const showToast = makeShowToast();
decideSaveToast(false, { ok: true }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('已保存', 'success', 2500);
});
it('silent=true + ok=true → 不弹auto-save 静默)', () => {
const showToast = makeShowToast();
decideSaveToast(true, { ok: true }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('silent=true + reason=STALE → 不弹auto-save 静默,不打扰用户)', () => {
const showToast = makeShowToast();
decideSaveToast(true, { ok: false, reason: 'STALE' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('silent=true + 任何 reason → 都不弹', () => {
// auto-save 飞行期任何失败 IIFE 内已按 result.error 弹过错误 toast与 silent 无关),
// 这里 silent=true 再弹会导致 auto-save 飞行期错误反馈与 silent=false 路径混淆
const showToast = makeShowToast();
decideSaveToast(true, { ok: false, reason: 'WRITE_FAILED' }, showToast);
decideSaveToast(true, { ok: false, reason: 'IPC_ERROR' }, showToast);
decideSaveToast(true, { ok: false, reason: 'STALE' }, showToast);
decideSaveToast(true, { ok: true }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('result=null / undefined → 不抛、不弹', () => {
const showToast = makeShowToast();
expect(() => decideSaveToast(false, null, showToast)).not.toThrow();
expect(() => decideSaveToast(false, undefined, showToast)).not.toThrow();
expect(showToast).not.toHaveBeenCalled();
});
});
describe('decideSaveToast —— STALE 反馈(核心 bug #1 修复 + Phase 2 P1 #3', () => {
it('silent=false + reason=STALE + ok=false → 弹「文件已切换,未保存到磁盘」(warning)', () => {
// 关键修复 #1飞行期用户切文件 → IIFE 静默 return {ok:false, reason:STALE} →
// silent=false manual caller 必须有反馈,告知「文件已切换,未保存到磁盘」。
// 旧逻辑 silent=false 但 result 不 ok → 不弹 → 用户按 Ctrl+S 什么反馈都没。
const showToast = makeShowToast();
decideSaveToast(false, { ok: false, reason: 'STALE' }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
});
it('silent=false + reason=STALE + ok=true → 同样弹「文件已切换」', () => {
// Phase 2 audit 修复 (P1 #3)writeFile 在 STALE 检查前已 resolve
// ok=true 表示 data 已落盘,但 editor 已不在 targetPath 上。用户视角
// 仍然需要「文件已切换」反馈(编辑器和预期的不一致)。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'STALE' }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
});
it('STALE 优先于 ok=truereason=STALE 时一律走 warning', () => {
// 防呆测试:即使 ok=truereason=STALE 必须弹 warning 而不是 success。
// 否则 Phase 2 修复会把「data 已落盘」的 success 文案误导成「保存好了」。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'STALE' }, showToast);
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
expect(showToast).not.toHaveBeenCalledWith('已保存', 'success', 2500);
});
it('silent=false + 其它失败 reasonCONFLICT_*/WRITE_FAILED/IPC_ERROR/...)→ 不弹', () => {
// 关键防呆IIFE 内部已按 result.error 弹过具体错误 toastFILE_CHANGED_EXTERNALLY
// → 弹冲突对话框 / FILE_NOT_FOUND → 弹「文件已被删除」/ errno → 弹「磁盘满 / 只读」等),
// 这里再弹「文件已切换...」会误导用户以为换文件了。
const showToast = makeShowToast();
decideSaveToast(false, { ok: false, reason: 'WRITE_FAILED' }, showToast);
decideSaveToast(false, { ok: false, reason: 'IPC_ERROR' }, showToast);
decideSaveToast(false, { ok: false, reason: 'CONFLICT_DISCARDED' }, showToast);
decideSaveToast(false, { ok: false, reason: 'CONFLICT_KEPT' }, showToast);
decideSaveToast(false, { ok: false, reason: 'FILE_NOT_FOUND' }, showToast);
decideSaveToast(false, { ok: false, reason: 'PATH_NOT_ALLOWED' }, showToast);
decideSaveToast(false, { ok: false, reason: 'SYMLINK_NOT_ALLOWED' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('silent=false + ok=false 无 reason → 不弹(防御性)', () => {
const showToast = makeShowToast();
decideSaveToast(false, { ok: false }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
});
describe('decideSaveToast —— shared path 场景(核心 bug #2 修复)', () => {
it('auto-save silent=true 飞行期 + manual silent=false 写盘成功 → 弹「已保存」', () => {
// 关键修复:旧逻辑 IIFE silent=true 不弹 + shared manual silent=false 也
// 被吞(直接 return IIFE 结果)→ 用户按 Ctrl+S 成功写盘无任何反馈。
// 现在 shared path 调 decideSaveToastsilent=false manual 自己决定 → 弹。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('已保存', 'success', 2500);
});
it('auto-save silent=true 飞行期 + manual silent=false STALE → 弹「文件已切换」', () => {
const showToast = makeShowToast();
decideSaveToast(false, { ok: false, reason: 'STALE' }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
});
it('auto-save silent=true 飞行期 + manual silent=false STALE+ok=true → 弹「文件已切换」', () => {
// Phase 2 P1 #3writeFile 成功但用户已切走 → 关窗路径用 ok=true 退出,
// 但 silent=false manual caller 仍应弹 STALE 反馈。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'STALE' }, showToast);
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
});
it('同 IIFE 结果auto-save silent=true 不弹 + manual silent=false 弹 → 不互相干扰', () => {
// 这是核心IIFE 写盘成功后auto-save 不弹、manual 弹「已保存」;
// 各自按 silent 决策,避免双重 toastforce_overwrite 递归 + manual 共享
// 的边缘 case 仍会有两次「已保存」,但这是 Phase N 之前已有的取舍)。
const sharedResult = { ok: true };
const autoSaveToast = makeShowToast();
const manualToast = makeShowToast();
decideSaveToast(true, sharedResult, autoSaveToast);
decideSaveToast(false, sharedResult, manualToast);
expect(autoSaveToast).not.toHaveBeenCalled();
expect(manualToast).toHaveBeenCalledTimes(1);
expect(manualToast).toHaveBeenCalledWith('已保存', 'success', 2500);
});
});
describe('decideSaveToast —— onSaveRequest silent=true核心 bug #3 修复)', () => {
it('silent=true + 任何失败 reason → 不弹', () => {
// 关键修复onSaveRequest 调 save({silent:true}),关闭路径反馈由主进程
// confirmDiscardIfDirty 原生框统一负责,避免双重 toastrenderer toast +
// main 原生「保存失败」框)。本 helper 在 silent=true 时一律不弹,
// 让 main.js 的原生对话框主导。
const showToast = makeShowToast();
decideSaveToast(true, { ok: false, reason: 'WRITE_FAILED' }, showToast);
decideSaveToast(true, { ok: false, reason: 'IPC_ERROR' }, showToast);
decideSaveToast(true, { ok: false, reason: 'STALE' }, showToast);
decideSaveToast(true, { ok: true }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('silent=true + STALE ok=true → 不弹(关窗路径由 main.js 原生框主导)', () => {
// Phase 2 P1 #3 回归onSaveRequest silent=true + STALE ok=truewriteFile
// 成功但用户已切走save() outer 返回 true 让 main.js 顺利退出,
// 但 renderer 不应弹任何 toast关窗场景用户看不见且避免与 main 原生框重叠)。
const showToast = makeShowToast();
decideSaveToast(true, { ok: true, reason: 'STALE' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
});
describe('decideSaveToast —— NO_CHANGES 反馈audit 2026-08早退路径用户感知', () => {
it('silent=false + reason=NO_CHANGES → 弹「已是最新」(info)', () => {
// 关键修复save() 的 !state.isDirty 早退路径手动调用 decideSaveToast
// 传 { ok:true, reason:'NO_CHANGES' }。旧逻辑早退 return true 不弹任何 toast
// auto-save 静默写盘后用户手动点保存 → 看不到反馈 → 「按钮坏了」类错觉。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'NO_CHANGES' }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('已是最新', 'info', 1500);
});
it('silent=true + reason=NO_CHANGES → 不弹auto-save 早退时静默)', () => {
// auto-save silent 路径不应被新分支打扰;头部 `if (silent) return` 一并拦下。
const showToast = makeShowToast();
decideSaveToast(true, { ok: true, reason: 'NO_CHANGES' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('NO_CHANGES 优先于 ok=truereason=NO_CHANGES 时一律走 info', () => {
// 防呆:传 ok=true 但带 NO_CHANGES reason 必须走 info 分支,不弹 success。
// 否则手动保存「已是最新」会被误显示成「已保存」(success),语义错位。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'NO_CHANGES' }, showToast);
expect(showToast).toHaveBeenCalledWith('已是最新', 'info', 1500);
expect(showToast).not.toHaveBeenCalledWith('已保存', 'success', 2500);
});
});
describe('decideSaveToast —— NO_FILE 反馈Round 7 S-3区分「没打开」与「已是最新」', () => {
it('silent=false + reason=NO_FILE → 弹「没有打开文件」(info)', () => {
// 关键修复save() 的 !state.currentFile 早退路径手动调用 decideSaveToast
// 传 { ok:true, reason:'NO_FILE' }。旧版共用 NO_CHANGES 文案「已是最新」,
// 但用户视角「我什么都没打开」与「有文件且未改动」语义不同 —— 改用准确
// 文案「没有打开文件」。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'NO_FILE' }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('没有打开文件', 'info', 1500);
});
it('silent=true + reason=NO_FILE → 不弹auto-save 早退时静默)', () => {
const showToast = makeShowToast();
decideSaveToast(true, { ok: true, reason: 'NO_FILE' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('NO_FILE 不被 NO_CHANGES 截胡:两个 reason 独立走各自分支', () => {
// 防呆NO_FILE 与 NO_CHANGES 是两种不同的早退语义,不能相互覆盖。
const fileToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'NO_FILE' }, fileToast);
expect(fileToast).toHaveBeenCalledWith('没有打开文件', 'info', 1500);
expect(fileToast).not.toHaveBeenCalledWith('已是最新', 'info', 1500);
const changesToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'NO_CHANGES' }, changesToast);
expect(changesToast).toHaveBeenCalledWith('已是最新', 'info', 1500);
expect(changesToast).not.toHaveBeenCalledWith('没有打开文件', 'info', 1500);
});
});
describe('decideSaveToast —— DELEGATED 反馈Round 8 S-1force_overwrite 委托 inner', () => {
// audit fix (Round 8 S-1)force_overwrite 把写盘委托给递归 inner save()
// inner 自己已经按 silent=false 弹过「已保存」或具体错误 toast。outer 只负责
// 把 ok 透传给调用方(关窗 / save_and_open 依赖它),不能再弹第二个 toast。
// 关键silent=false 也不弹,因为 inner 已经弹过silent=true 也不弹auto-save
it('silent=false + reason=DELEGATED + ok=true → 不弹inner 已弹过「已保存」)', () => {
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'DELEGATED' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('silent=false + reason=DELEGATED + ok=false → 不弹inner 已弹过错误 toast', () => {
const showToast = makeShowToast();
decideSaveToast(false, { ok: false, reason: 'DELEGATED' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('silent=true + reason=DELEGATED → 不弹auto-save 静默)', () => {
const showToast = makeShowToast();
decideSaveToast(true, { ok: true, reason: 'DELEGATED' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('DELEGATED 不被 NO_FILE / NO_CHANGES / STALE / 通用 ok=true 截胡reason=DELEGATED 永远走 DELEGATED 分支', () => {
// 反例验证ok=true 时不能被「已保存」分支兜底再弹一个 toast。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'DELEGATED' }, showToast);
expect(showToast).not.toHaveBeenCalledWith('已保存', 'success', 2500);
expect(showToast).not.toHaveBeenCalledWith('已是最新', 'info', 1500);
expect(showToast).not.toHaveBeenCalledWith('没有打开文件', 'info', 1500);
expect(showToast).not.toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
});
});