roymondchen a9e9e65f9c feat(editor): 历史记录列表展示时间并优化回滚差异弹窗
为历史步骤自动写入 timestamp 并按当天/跨天格式化展示;回滚确认弹窗区分标题与说明,关闭时清理确认回调。
2026-06-03 18:09:21 +08:00

264 lines
9.9 KiB
TypeScript
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.

/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { afterEach, describe, expect, test, vi } from 'vitest';
import history from '@editor/services/history';
afterEach(() => {
history.reset();
});
describe('history service', () => {
test('changePage 切换页面会创建对应的 UndoRedo', () => {
history.changePage({ id: 'p1' } as any);
expect((history.state.pageSteps as any).p1).toBeDefined();
});
test('push / undo / redo 链路', () => {
history.changePage({ id: 'p1' } as any);
const v1 = { data: { items: [] }, modifiedNodeIds: new Map(), nodeId: 'a' } as any;
const v2 = { data: { items: [] }, modifiedNodeIds: new Map(), nodeId: 'b' } as any;
history.push(v1);
history.push(v2);
const undone = history.undo();
expect(undone).toBeDefined();
const redone = history.redo();
expect(redone).toBeDefined();
});
test('未指定 pageId 时 push/undo/redo 返回 null', () => {
history.resetPage();
expect(history.push({} as any)).toBeNull();
expect(history.undo()).toBeNull();
expect(history.redo()).toBeNull();
});
test('reset / resetPage / resetState', () => {
history.changePage({ id: 'p1' } as any);
history.push({ data: {} } as any);
history.reset();
expect(history.state.pageId).toBeUndefined();
expect(Object.keys(history.state.pageSteps)).toHaveLength(0);
});
test('canUndo / canRedo 在 push 后更新', () => {
history.changePage({ id: 'p1' } as any);
history.push({ data: {} } as any);
history.push({ data: {} } as any);
expect(history.state.canUndo).toBe(true);
});
test('changePage 接到 undefined/null 时不变更', () => {
history.changePage(null as any);
expect(history.state.pageId).toBeUndefined();
});
test('push 指定 pageId 落到目标页栈,不影响当前页', () => {
// 当前激活在 p1
history.changePage({ id: 'p1' } as any);
const step = { data: { id: 'p2', name: '' }, modifiedNodeIds: new Map() } as any;
// 跨页 push把记录推到 p2目标页p1 栈应保持为空
history.push(step, 'p2');
expect((history.state.pageSteps as any).p2).toBeDefined();
expect((history.state.pageSteps as any).p2.canUndo()).toBe(true);
// p1 栈虽然激活但没有 push 进来,仍不可撤销
expect((history.state.pageSteps as any).p1.canUndo()).toBe(false);
// 跨页 push 不应触发当前页p1的 canUndo 改变
expect(history.state.canUndo).toBe(false);
// 切到 p2 后能正常 undo 该跨页步骤
history.changePage({ id: 'p2' } as any);
expect(history.state.canUndo).toBe(true);
expect(history.undo()).toBeDefined();
});
test('push 不传 pageId 时落到当前活动页栈(向后兼容)', () => {
history.changePage({ id: 'p1' } as any);
history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
expect((history.state.pageSteps as any).p1.canUndo()).toBe(true);
expect(history.state.canUndo).toBe(true);
});
test('push 未带 timestamp 时自动写入入栈时间', () => {
history.changePage({ id: 'p1' } as any);
const before = Date.now();
const step = history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
const after = Date.now();
expect(step?.timestamp).toBeGreaterThanOrEqual(before);
expect(step?.timestamp).toBeLessThanOrEqual(after);
});
test('push 已带 timestamp 时保留调用方指定的值', () => {
history.changePage({ id: 'p1' } as any);
const step = history.push({
data: { id: 'p1', name: '' },
modifiedNodeIds: new Map(),
timestamp: 123456,
} as any);
expect(step?.timestamp).toBe(123456);
});
});
describe('history service - codeBlock', () => {
test('pushCodeBlock 入栈并触发 code-block-history-change 事件', () => {
const fn = vi.fn();
history.on('code-block-history-change', fn);
const step = history.pushCodeBlock('code_1', {
oldContent: null,
newContent: { name: 'A', content: 'x' } as any,
});
expect(step).not.toBeNull();
expect(step?.id).toBe('code_1');
expect(step?.oldContent).toBeNull();
expect(step?.newContent).toEqual({ name: 'A', content: 'x' });
expect((history.state.codeBlockState as any).code_1).toBeDefined();
expect(history.canUndoCodeBlock('code_1')).toBe(true);
expect(fn).toHaveBeenCalledWith('code_1', expect.objectContaining({ id: 'code_1' }));
history.off('code-block-history-change', fn);
});
test('pushCodeBlock 不传 id 返回 null', () => {
expect(history.pushCodeBlock('', { oldContent: null, newContent: null })).toBeNull();
});
test('pushCodeBlock 自动写入入栈时间戳', () => {
const before = Date.now();
const step = history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
const after = Date.now();
expect(step?.timestamp).toBeGreaterThanOrEqual(before);
expect(step?.timestamp).toBeLessThanOrEqual(after);
});
test('undoCodeBlock / redoCodeBlock 走对应 id 的 UndoRedo 栈', () => {
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.pushCodeBlock('code_1', {
oldContent: { name: 'A' } as any,
newContent: { name: 'B' } as any,
});
expect(history.canUndoCodeBlock('code_1')).toBe(true);
const undone = history.undoCodeBlock('code_1');
expect(undone?.newContent).toEqual({ name: 'B' });
expect(history.canRedoCodeBlock('code_1')).toBe(true);
const redone = history.redoCodeBlock('code_1');
expect(redone?.newContent).toEqual({ name: 'B' });
});
test('undoCodeBlock 对不存在 id 返回 null', () => {
expect(history.undoCodeBlock('not-exist')).toBeNull();
expect(history.redoCodeBlock('not-exist')).toBeNull();
expect(history.canUndoCodeBlock('not-exist')).toBe(false);
expect(history.canRedoCodeBlock('not-exist')).toBe(false);
});
test('不同代码块 id 的栈相互隔离', () => {
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.pushCodeBlock('code_2', { oldContent: null, newContent: { name: 'B' } as any });
expect(history.canUndoCodeBlock('code_1')).toBe(true);
expect(history.canUndoCodeBlock('code_2')).toBe(true);
history.undoCodeBlock('code_1');
expect(history.canUndoCodeBlock('code_1')).toBe(false);
// code_2 的栈不受影响
expect(history.canUndoCodeBlock('code_2')).toBe(true);
});
test('reset / resetState 清空 codeBlockState', () => {
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.reset();
expect(Object.keys(history.state.codeBlockState)).toHaveLength(0);
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.resetState();
expect(Object.keys(history.state.codeBlockState)).toHaveLength(0);
});
});
describe('history service - dataSource', () => {
test('pushDataSource 入栈并触发 data-source-history-change 事件', () => {
const fn = vi.fn();
history.on('data-source-history-change', fn);
const step = history.pushDataSource('ds_1', {
oldSchema: null,
newSchema: { id: 'ds_1', type: 'base', title: 'A' } as any,
});
expect(step).not.toBeNull();
expect(step?.id).toBe('ds_1');
expect(step?.oldSchema).toBeNull();
expect(step?.newSchema?.title).toBe('A');
expect((history.state.dataSourceState as any).ds_1).toBeDefined();
expect(history.canUndoDataSource('ds_1')).toBe(true);
expect(fn).toHaveBeenCalledWith('ds_1', expect.objectContaining({ id: 'ds_1' }));
history.off('data-source-history-change', fn);
});
test('pushDataSource 不传 id 返回 null', () => {
expect(history.pushDataSource('', { oldSchema: null, newSchema: null })).toBeNull();
});
test('pushDataSource 自动写入入栈时间戳', () => {
const before = Date.now();
const step = history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
const after = Date.now();
expect(step?.timestamp).toBeGreaterThanOrEqual(before);
expect(step?.timestamp).toBeLessThanOrEqual(after);
});
test('undoDataSource / redoDataSource 走对应 id 的 UndoRedo 栈', () => {
history.pushDataSource('ds_1', {
oldSchema: null,
newSchema: { id: 'ds_1', type: 'base', title: 'A' } as any,
});
history.pushDataSource('ds_1', {
oldSchema: { id: 'ds_1', type: 'base', title: 'A' } as any,
newSchema: { id: 'ds_1', type: 'base', title: 'B' } as any,
});
const undone = history.undoDataSource('ds_1');
expect(undone?.newSchema?.title).toBe('B');
const redone = history.redoDataSource('ds_1');
expect(redone?.newSchema?.title).toBe('B');
});
test('undoDataSource 对不存在 id 返回 null', () => {
expect(history.undoDataSource('not-exist')).toBeNull();
expect(history.redoDataSource('not-exist')).toBeNull();
expect(history.canUndoDataSource('not-exist')).toBe(false);
expect(history.canRedoDataSource('not-exist')).toBe(false);
});
test('不同数据源 id 的栈相互隔离', () => {
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
history.pushDataSource('ds_2', { oldSchema: null, newSchema: { id: 'ds_2' } as any });
history.undoDataSource('ds_1');
expect(history.canUndoDataSource('ds_1')).toBe(false);
expect(history.canUndoDataSource('ds_2')).toBe(true);
});
test('reset / resetState 清空 dataSourceState', () => {
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
history.reset();
expect(Object.keys(history.state.dataSourceState)).toHaveLength(0);
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
history.resetState();
expect(Object.keys(history.state.dataSourceState)).toHaveLength(0);
});
});