200 lines
8.9 KiB
JavaScript
200 lines
8.9 KiB
JavaScript
// 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 重读为 false,savingCount 归零
|
||
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();
|
||
});
|
||
}); |