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

View File

@@ -0,0 +1,200 @@
// src/save-button-ui.js 测试
//
// 覆盖保存按钮 UI 同步函数的三态:
// - 干净isDirty=false, savingCount=0
// - 有待保存改动isDirty=true, savingCount=0
// - 保存中isDirty=*, savingCount>0
// - 同时保存中 + 脏
//
// 设计要点(见 src/save-button-ui.js 注释):
// - 始终显示按钮([[feedback-save-button-always-visible]]),不切 hidden
// - aria-busy 与 is-saving class 同步
// - btnSave=null 时静默 return不抛
// - setDirty 回调只在 isDirty 翻转时才有意义,但函数本身每次都调一次
// (去重由调用方在调用频率上控制;这里只验证契约)
//
// 限制save() / scheduleAutoSave() 内部使用 savingCount但二者都在 src/app.js
// 闭包里耦合state / els / window.api无法直接 import 单测。本文件只覆盖
// 已抽出的 syncSaveButtonUI 纯函数save() 的 in-flight sentinel / 防抖语义等
// 留待未来把 save() 拆出 helper 后再补。
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { syncSaveButton } from '../../src/save-button-ui.js';
function makeBtn() {
// 模拟工具栏保存按钮的最小子集classList + setAttribute + title getter/setter
const btn = document.createElement('button');
btn.id = 'btn-save';
return btn;
}
function makeStatusUnsaved() {
const el = document.createElement('span');
el.id = 'status-unsaved';
el.hidden = true;
el.textContent = '';
return el;
}
describe('syncSaveButton 三态同步', () => {
let btn;
let setDirty;
beforeEach(() => {
btn = makeBtn();
setDirty = vi.fn();
});
it('干净态isDirty=false, savingCount=0移除 is-dirty + is-saving', () => {
const state = { isDirty: false, savingCount: 0 };
syncSaveButton({ btnSave: btn, state, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(false);
expect(btn.classList.contains('is-saving')).toBe(false);
expect(btn.getAttribute('aria-busy')).toBe('false');
expect(btn.getAttribute('aria-label')).toBe('保存');
expect(btn.title).toBe('保存 (Ctrl+S)');
expect(setDirty).toHaveBeenCalledWith(false);
});
it('脏态isDirty=true, savingCount=0加 is-dirty + 改文案', () => {
const state = { isDirty: true, savingCount: 0 };
syncSaveButton({ btnSave: btn, state, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(true);
expect(btn.classList.contains('is-saving')).toBe(false);
expect(btn.getAttribute('aria-busy')).toBe('false');
expect(btn.getAttribute('aria-label')).toBe('保存(有待保存的改动)');
expect(btn.title).toBe('保存(有待保存的改动) (Ctrl+S)');
expect(setDirty).toHaveBeenCalledWith(true);
});
it('保存中isDirty=false, savingCount=1加 is-saving + aria-busy=true', () => {
const state = { isDirty: false, savingCount: 1 };
syncSaveButton({ btnSave: btn, state, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(false);
expect(btn.classList.contains('is-saving')).toBe(true);
expect(btn.getAttribute('aria-busy')).toBe('true');
expect(btn.getAttribute('aria-label')).toBe('保存中…');
expect(btn.title).toBe('保存中…');
// savingCount>0 不影响 isDirty 通知setDirty 仍按 isDirty 当前值传
expect(setDirty).toHaveBeenCalledWith(false);
});
it('同时保存中 + 脏savingCount>0 优先于 isDirty 的文案)', () => {
// 在飞行期间用户又敲了字isDirty 重新为 true —— 文案应当显示「保存中…」
// 而不是「有待保存改动」。is-dirty class 保留是为了视觉提示warning 色脉动)
const state = { isDirty: true, savingCount: 1 };
syncSaveButton({ btnSave: btn, state, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(true);
expect(btn.classList.contains('is-saving')).toBe(true);
expect(btn.getAttribute('aria-busy')).toBe('true');
expect(btn.getAttribute('aria-label')).toBe('保存中…');
expect(setDirty).toHaveBeenCalledWith(true);
});
it('savingCount 累加(并发 save归零前不切回非 saving 态', () => {
// 模拟 inFlightSave 共享:第一次 save 入口 ++savingCount → 1第二次 save
// 共享路径又 ++savingCount → 2。共享 then 完成时两次 finally 各 -1最终归零。
// 函数本身只读 savingCount>0 判定,所以中间任意调用都保持 is-saving。
const state = { isDirty: false, savingCount: 2 };
syncSaveButton({ btnSave: btn, state, setDirty });
expect(btn.classList.contains('is-saving')).toBe(true);
expect(btn.getAttribute('aria-busy')).toBe('true');
});
it('btnSave=null → 静默 return不抛', () => {
const state = { isDirty: true, savingCount: 0 };
// eslint-disable-next-line no-unused-vars
const localSetDirty = vi.fn();
expect(() => syncSaveButton({ btnSave: null, state, setDirty: localSetDirty }))
.not.toThrow();
// btnSave=null 时整个函数提前 return不会调 setDirty
expect(localSetDirty).not.toHaveBeenCalled();
});
it('setDirty 缺省 → 默认空操作(不抛)', () => {
const state = { isDirty: true, savingCount: 0 };
expect(() => syncSaveButton({ btnSave: btn, state })).not.toThrow();
// 即便 btnSave 存在且 state.isDirty=true没传 setDirty 也不抛
expect(btn.classList.contains('is-dirty')).toBe(true);
});
it('savingCount 归零保护helper 不修改 savingCount', () => {
// 回归保护syncSaveButtonUI 是纯 UI 同步,不应修改 state.savingCount。
// savingCount 的累加/递减必须由 save() 入口/finally 唯一管理,否则会
// 与 save() 内部的累加叠加导致计数永远不平衡 → 保存按钮永远卡在
// 「保存中…」态。手动审计 src/app.js:save() 用外层 try/finally 覆盖
// 所有 6 个 return 路径,但 helper 这里也加一道防线。
const state = { isDirty: true, savingCount: 5 };
const before = state.savingCount;
syncSaveButton({ btnSave: btn, state, setDirty });
expect(state.savingCount).toBe(before);
expect(state.savingCount).toBe(5);
});
it('连续调用:脏→保存中→干净,三阶段过渡', () => {
// 模拟完整 save() 生命周期onChange (脏) → save 入口 (savingCount++) → 成功
// (markSaved + savingCount--)
syncSaveButton({ btnSave: btn, state: { isDirty: true, savingCount: 0 }, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(true);
expect(btn.classList.contains('is-saving')).toBe(false);
// save 入口
syncSaveButton({ btnSave: btn, state: { isDirty: true, savingCount: 1 }, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(true);
expect(btn.classList.contains('is-saving')).toBe(true);
expect(btn.getAttribute('aria-busy')).toBe('true');
// save 完成isDirty 重读为 falsesavingCount 归零
syncSaveButton({ btnSave: btn, state: { isDirty: false, savingCount: 0 }, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(false);
expect(btn.classList.contains('is-saving')).toBe(false);
expect(btn.getAttribute('aria-busy')).toBe('false');
expect(btn.getAttribute('aria-label')).toBe('保存');
});
});
describe('syncSaveButton 状态栏未保存 chip', () => {
let btn;
let chip;
let setDirty;
beforeEach(() => {
btn = makeBtn();
chip = makeStatusUnsaved();
setDirty = vi.fn();
});
it('脏态chip 显示「未保存」', () => {
syncSaveButton({ btnSave: btn, statusUnsaved: chip, state: { isDirty: true, savingCount: 0 }, setDirty });
expect(chip.hidden).toBe(false);
expect(chip.textContent).toBe('未保存');
expect(chip.getAttribute('aria-label')).toBe('当前文件有未保存的改动');
});
it('干净态chip hidden', () => {
syncSaveButton({ btnSave: btn, statusUnsaved: chip, state: { isDirty: false, savingCount: 0 }, setDirty });
expect(chip.hidden).toBe(true);
});
it('保存中但已脏:仍显示 chip不让过渡态闪烁', () => {
// 设计决策savingCount>0 但 isDirty=true 时仍显示 chip —— 飞行结束后
// 会立刻切回脏/干净,状态栏不要一明一灭的视觉抖动。仅 isDirty 驱动 chip。
syncSaveButton({ btnSave: btn, statusUnsaved: chip, state: { isDirty: true, savingCount: 1 }, setDirty });
expect(chip.hidden).toBe(false);
expect(chip.textContent).toBe('未保存');
});
it('保存中且不脏飞行期用户撤销编辑chip hidden', () => {
syncSaveButton({ btnSave: btn, statusUnsaved: chip, state: { isDirty: false, savingCount: 1 }, setDirty });
expect(chip.hidden).toBe(true);
});
it('chip 不传DOM 不被影响', () => {
// 兼容旧调用方(不传 statusUnsaved不应抛错
expect(() =>
syncSaveButton({ btnSave: btn, state: { isDirty: true, savingCount: 0 }, setDirty })
).not.toThrow();
});
});