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

200 lines
8.9 KiB
JavaScript
Raw 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-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();
});
});