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,229 @@
// src/editor-toolbar.js
//
// 三态preview / edit / split视图模式控件图标按钮 + aria-pressed +
// body.dataset.editorModeCSS 据此切布局)。构造函数绑 click → selectMode。
//
// 测试覆盖:
// - selectMode 合法值:触发 onChange + DOM 更新 + aria-pressed 切换
// - selectMode 非法值:被静默忽略(不抛、不触发 onChange、不动 DOM
// - selectMode 同模式no-op不重复触发 onChange
// - setMode同步 DOM 但不触发 onChange用于外部「持久化设置改了」
// 反向同步 UI避免循环触发
// - getMode从 body.dataset.editorMode 读,默认 'preview'
// - cyclepreview → edit → split → preview不依赖 setMode 路径,
// 走 selectMode 路径所以每次都触发 onChange
// - a11y每个按钮都拿到 aria-pressed不仅 active 那个);不依赖按钮
// 缺省时整个 component 仍能工作(按钮可选)
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { EditorToolbar, EDITOR_MODES } from '../../src/editor-toolbar.js';
function makeBtn() {
const btn = document.createElement('button');
btn.type = 'button';
return btn;
}
function setupDOM(initialMode = null) {
document.body.innerHTML = '';
if (initialMode === null) {
delete document.body.dataset.editorMode;
} else {
document.body.dataset.editorMode = initialMode;
}
const btnPreview = makeBtn();
const btnEdit = makeBtn();
const btnSplit = makeBtn();
document.body.append(btnPreview, btnEdit, btnSplit);
return { btnPreview, btnEdit, btnSplit };
}
describe('EditorToolbar 构造 + 事件绑定', () => {
it('按钮 click → selectMode(对应 mode) + onChange', () => {
// 起始 'split' —— 不与任何一个按钮 mode 重合,确保 click 都触发 onChange
const els = setupDOM('split');
const onChange = vi.fn();
new EditorToolbar({ elements: els, onChange });
els.btnPreview.click();
expect(onChange).toHaveBeenCalledWith('preview');
onChange.mockClear();
els.btnEdit.click();
expect(onChange).toHaveBeenCalledWith('edit');
onChange.mockClear();
els.btnSplit.click();
expect(onChange).toHaveBeenCalledWith('split');
});
it('缺按钮:对应 click 不绑、构造不抛', () => {
const els = setupDOM();
const onChange = vi.fn();
// 只给 btnEditbtnPreview / btnSplit 缺
const partial = { btnEdit: els.btnEdit };
expect(() => new EditorToolbar({ elements: partial, onChange })).not.toThrow();
els.btnEdit.click();
expect(onChange).toHaveBeenCalledWith('edit');
// 缺按钮的 click 不应触发 selectMode没绑事件
els.btnPreview.click();
// onChange 仍只被调用 1 次(来自 btnEdit 这次)
expect(onChange).toHaveBeenCalledTimes(1);
});
});
describe('EditorToolbar.selectMode', () => {
it('合法值:更新 body.dataset.editorMode + aria-pressed + is-active class', () => {
const els = setupDOM();
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
tb.selectMode('edit');
expect(document.body.dataset.editorMode).toBe('edit');
expect(els.btnEdit.classList.contains('is-active')).toBe(true);
expect(els.btnPreview.classList.contains('is-active')).toBe(false);
expect(els.btnSplit.classList.contains('is-active')).toBe(false);
expect(els.btnEdit.getAttribute('aria-pressed')).toBe('true');
expect(els.btnPreview.getAttribute('aria-pressed')).toBe('false');
expect(els.btnSplit.getAttribute('aria-pressed')).toBe('false');
});
it('非法值:被忽略(不抛、不触发 onChange、不动 DOM', () => {
const els = setupDOM();
const onChange = vi.fn();
const tb = new EditorToolbar({ elements: els, onChange });
document.body.dataset.editorMode = 'split';
expect(() => tb.selectMode('wide')).not.toThrow();
expect(() => tb.selectMode('')).not.toThrow();
expect(() => tb.selectMode(null)).not.toThrow();
expect(() => tb.selectMode(undefined)).not.toThrow();
expect(onChange).not.toHaveBeenCalled();
expect(document.body.dataset.editorMode).toBe('split');
});
it('同模式 → no-op不重复触发 onChange', () => {
const els = setupDOM();
const onChange = vi.fn();
const tb = new EditorToolbar({ elements: els, onChange });
tb.selectMode('edit');
expect(onChange).toHaveBeenCalledTimes(1);
tb.selectMode('edit');
expect(onChange).toHaveBeenCalledTimes(1);
tb.selectMode('split');
expect(onChange).toHaveBeenCalledTimes(2);
});
it('无 onChange 回调:不抛', () => {
const els = setupDOM();
const tb = new EditorToolbar({ elements: els });
expect(() => tb.selectMode('split')).not.toThrow();
expect(document.body.dataset.editorMode).toBe('split');
});
});
describe('EditorToolbar.setMode外部同步 UI不触发 onChange', () => {
it('同步 DOM 但不调用 onChange避免循环', () => {
const els = setupDOM();
const onChange = vi.fn();
const tb = new EditorToolbar({ elements: els, onChange });
tb.setMode('split');
expect(document.body.dataset.editorMode).toBe('split');
expect(els.btnSplit.classList.contains('is-active')).toBe(true);
expect(els.btnSplit.getAttribute('aria-pressed')).toBe('true');
expect(onChange).not.toHaveBeenCalled();
});
it('同模式也执行 setDOM与 selectMode 行为不同)', () => {
// 用途:外部从持久化设置读了模式,反向同步 UI。即便模式已经是
// 当前值,仍要走一遍 setDOM 确保 aria-pressed / is-active 状态
// 是最新的(防止外部代码直接改过 dataset 但 UI 状态没跟上)。
const els = setupDOM();
document.body.dataset.editorMode = 'edit';
els.btnEdit.classList.add('is-active');
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
tb.setMode('edit');
// 即使「同模式」_setDOM 仍走一遍aria-pressed / is-active 被重写)
expect(els.btnEdit.getAttribute('aria-pressed')).toBe('true');
expect(els.btnPreview.getAttribute('aria-pressed')).toBe('false');
});
it('非法值:静默忽略', () => {
const els = setupDOM();
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
document.body.dataset.editorMode = 'preview';
tb.setMode('not-a-mode');
expect(document.body.dataset.editorMode).toBe('preview');
});
});
describe('EditorToolbar.getMode', () => {
it('从 body.dataset.editorMode 读', () => {
const els = setupDOM();
document.body.dataset.editorMode = 'split';
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
expect(tb.getMode()).toBe('split');
});
it('dataset 未设置:返回默认 "preview"', () => {
const els = setupDOM();
delete document.body.dataset.editorMode;
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
expect(tb.getMode()).toBe('preview');
});
});
describe('EditorToolbar.cycle', () => {
it('preview → edit → split → preview每次都触发 onChange', () => {
const els = setupDOM();
const onChange = vi.fn();
delete document.body.dataset.editorMode;
const tb = new EditorToolbar({ elements: els, onChange });
tb.cycle();
expect(tb.getMode()).toBe('edit');
expect(onChange).toHaveBeenLastCalledWith('edit');
tb.cycle();
expect(tb.getMode()).toBe('split');
expect(onChange).toHaveBeenLastCalledWith('split');
tb.cycle();
expect(tb.getMode()).toBe('preview');
expect(onChange).toHaveBeenLastCalledWith('preview');
expect(onChange).toHaveBeenCalledTimes(3);
});
});
describe('EditorToolbar a11y', () => {
it('三个按钮都拿到 aria-pressed不仅 active 那个)', () => {
// 屏幕阅读器依靠 aria-pressed 判状态;非 active 按钮也要明确
// 'false' 而不是缺省(缺省时 SR 不知道这是 toggle
// 起始 'edit'selectMode('preview') 不会因同模式被早退。
const els = setupDOM('edit');
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
tb.selectMode('preview');
expect(els.btnPreview.getAttribute('aria-pressed')).toBe('true');
expect(els.btnEdit.getAttribute('aria-pressed')).toBe('false');
expect(els.btnSplit.getAttribute('aria-pressed')).toBe('false');
});
it('EDIT_MODES 导出值与内部一致', () => {
expect(EDITOR_MODES).toEqual(['preview', 'edit', 'split']);
});
});