tmagic-editor/packages/editor/tests/unit/services/history-persist.spec.ts
roymondchen 0f42989ca3 refactor(editor): 统一历史栈结构,支持扩展历史类型
将 pageSteps/codeBlockState/dataSourceState 三套独立历史栈收敛为统一的 steps 结构
(按 stepType 分桶),并新增 registerStepType/setStepName/getStepName 支持自定义
扩展历史类型。同步重构 history 相关服务、组件、工具方法、测试与文档。
2026-06-23 20:14:41 +08:00

250 lines
9.8 KiB
TypeScript
Raw Permalink 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. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import history from '@editor/services/history';
import { setEditorConfig } from '@editor/utils/config';
import { createStackStep } from '@editor/utils/history';
import * as indexedDb from '@editor/utils/indexed-db';
// pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id);用等价小工具沿用既有用例。
const pushCodeBlock = (id: any, payload: any) => {
const step = createStackStep(id, {
oldValue: payload.oldContent,
newValue: payload.newContent,
changeRecords: payload.changeRecords,
});
return step ? history.push('codeBlock', step as any, id) : null;
};
const pushDataSource = (id: any, payload: any) => {
const step = createStackStep(id, {
oldValue: payload.oldSchema,
newValue: payload.newSchema,
changeRecords: payload.changeRecords,
});
return step ? history.push('dataSource', step as any, id) : null;
};
// 用内存实现 mock 掉 IndexedDB 读写工具,避免依赖真实 IndexedDBhappy-dom 不提供)。
vi.mock('@editor/utils/indexed-db', () => {
const store = new Map<string, any>();
const k = (db: string, s: string, key: any) => `${db}__${s}__${String(key)}`;
return {
isIndexedDBSupported: () => true,
openIndexedDB: vi.fn(),
idbSet: vi.fn(async (db: string, s: string, key: any, value: any) => {
store.set(k(db, s, key), value);
}),
idbGet: vi.fn(async (db: string, s: string, key: any) => store.get(k(db, s, key))),
idbDelete: vi.fn(async (db: string, s: string, key: any) => {
store.delete(k(db, s, key));
}),
__store: store,
};
});
beforeAll(() => {
// restoreFromIndexedDB 通过 parseDSL 还原序列化字符串(默认实现即 eval
// eslint-disable-next-line no-eval
setEditorConfig({ parseDSL: (dsl: string) => eval(dsl) } as any);
});
beforeEach(() => {
(indexedDb as any).__store.clear();
});
afterEach(() => {
history.reset();
});
const pageStep = (id = 'p1') => ({ data: { id, name: '' }, modifiedNodeIds: new Map() }) as any;
describe('history service - markSaved', () => {
test('markSaved 派发 mark-saved 事件并带 kind=all', () => {
const fn = vi.fn();
history.on('mark-saved', fn);
history.markSaved('page');
expect(fn).toHaveBeenCalledWith({ kind: 'all' });
history.off('mark-saved', fn);
});
test('markSaved(stepType, id) 派发对应 kind 事件', () => {
history.push('page', pageStep(), 'p1');
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
const pageFn = vi.fn();
const codeFn = vi.fn();
history.on('mark-saved', (payload) => {
if (payload.kind === 'page') pageFn(payload);
if (payload.kind === 'codeBlock') codeFn(payload);
});
history.markSaved('page', 'p1');
history.markSaved('codeBlock', 'code_1');
expect(pageFn).toHaveBeenCalledWith({ kind: 'page', id: 'p1' });
expect(codeFn).toHaveBeenCalledWith({ kind: 'codeBlock', id: 'code_1' });
});
});
describe('history service - clear', () => {
test('clear 清空指定页面历史并复位 canUndo/canRedo', () => {
history.push('page', pageStep(), 'p1');
history.push('page', pageStep(), 'p1');
expect(history.canUndo('page', 'p1')).toBe(true);
history.clear('page', 'p1');
expect((history.state.steps.page as any).p1.getLength()).toBe(0);
expect(history.canUndo('page', 'p1')).toBe(false);
expect(history.canRedo('page', 'p1')).toBe(false);
});
test('clear 保留被清空栈的 initial 基线', () => {
history.setMarker('page', 'p1', { name: 'P1', description: '初始' });
history.push('page', pageStep(), 'p1');
expect((history.state.steps.page as any).p1.getLength()).toBe(2);
history.clear('page', 'p1');
// 真实操作记录被清空,仅保留 index 0 的 initial 基线
expect((history.state.steps.page as any).p1.getLength()).toBe(1);
expect(history.getMarker('page', 'p1')?.opType).toBe('initial');
expect(history.canUndo('page', 'p1')).toBe(false);
});
test('clear 传 id 清单个缺省清全部codeBlock', () => {
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
pushCodeBlock('code_2', { oldContent: null, newContent: { name: 'B' } as any });
history.clear('codeBlock', 'code_1');
expect((history.state.steps.codeBlock as any).code_1.getLength()).toBe(0);
expect((history.state.steps.codeBlock as any).code_2.getLength()).toBe(1);
history.clear('codeBlock');
expect((history.state.steps.codeBlock as any).code_2.getLength()).toBe(0);
});
test('clear 传 id 清单个缺省清全部dataSource', () => {
pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
pushDataSource('ds_2', { oldSchema: null, newSchema: { id: 'ds_2' } as any });
history.clear('dataSource', 'ds_1');
expect((history.state.steps.dataSource as any).ds_1.getLength()).toBe(0);
expect((history.state.steps.dataSource as any).ds_2.getLength()).toBe(1);
history.clear('dataSource');
expect((history.state.steps.dataSource as any).ds_2.getLength()).toBe(0);
});
});
describe('history service - IndexedDB 持久化', () => {
test('saveToIndexedDB 以对象写入(仅 step.diff 序列化成字符串)并返回快照对象', async () => {
history.push('page', { ...pageStep(), diff: [{ newSchema: { id: 'n1', name: '节点' } }] } as any, 'p1');
const snapshot = await history.saveToIndexedDB();
expect(snapshot.version).toBe(3);
// 实际写入 IndexedDB 的是对象(交给结构化克隆),仅每条 step 的 diff 被序列化成字符串
expect(indexedDb.idbSet).toHaveBeenCalled();
const written = (indexedDb.idbSet as any).mock.calls[0][3];
expect(typeof written).toBe('object');
expect(typeof written.steps.page.p1.elementList[0].diff).toBe('string');
// diff 之外的字段(如 modifiedNodeIds Map原样交给结构化克隆不被字符串化
expect(written.steps.page.p1.elementList[0].modifiedNodeIds instanceof Map).toBe(true);
// 返回的快照即写入 IndexedDB 的持久化形态diff 已是序列化字符串
expect(written).toBe(snapshot);
expect(typeof snapshot.steps.page.p1.elementList[0].diff).toBe('string');
});
test('restoreFromIndexedDB 还原页面 / 代码块 / 数据源全部栈与游标', async () => {
history.push('page', pageStep(), 'p1');
history.push('page', pageStep(), 'p1');
history.undo('page', 'p1'); // page cursor = 1
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
await history.saveToIndexedDB();
history.reset();
expect(Object.keys(history.state.steps.page)).toHaveLength(0);
const restored = await history.restoreFromIndexedDB();
expect(restored).not.toBeNull();
expect(history.getCursor('page', 'p1')).toBe(1);
expect((history.state.steps.codeBlock as any).code_1).toBeDefined();
expect((history.state.steps.dataSource as any).ds_1).toBeDefined();
});
test('restoreFromIndexedDB 把游标恢复到最近一个已保存记录', async () => {
history.push('page', pageStep(), 'p1');
history.push('page', pageStep(), 'p1');
history.markSaved('page', 'p1'); // 标记 index 1cursor=2
history.push('page', pageStep(), 'p1'); // cursor=3saved 仍在 index 1
await history.saveToIndexedDB();
history.reset();
await history.restoreFromIndexedDB();
// 恢复后游标定位到已保存记录之后index 1 -> cursor 2
expect(history.getCursor('page', 'p1')).toBe(2);
});
test('restoreFromIndexedDB 能还原内容中的函数serialize + parseDSL 往返)', async () => {
pushCodeBlock('code_1', {
oldContent: null,
newContent: {
name: 'A',
code() {
return 42;
},
} as any,
});
await history.saveToIndexedDB();
history.reset();
await history.restoreFromIndexedDB();
const current = (history.state.steps.codeBlock as any).code_1.getCurrentElement();
expect(typeof current.diff[0].newSchema.code).toBe('function');
expect(current.diff[0].newSchema.code()).toBe(42);
});
test('restoreFromIndexedDB 找不到记录时返回 null 且不改动当前状态', async () => {
history.push('page', pageStep(), 'p1');
const restored = await history.restoreFromIndexedDB();
expect(restored).toBeNull();
// 当前状态保持不变
expect((history.state.steps.page as any).p1.getLength()).toBe(1);
});
test('saveToIndexedDB 派发 save-to-indexed-db、restoreFromIndexedDB 派发 restore-from-indexed-db', async () => {
const saveFn = vi.fn();
const restoreFn = vi.fn();
history.on('save-to-indexed-db', saveFn);
history.on('restore-from-indexed-db', restoreFn);
history.push('page', pageStep(), 'p1');
await history.saveToIndexedDB();
await history.restoreFromIndexedDB();
expect(saveFn).toHaveBeenCalledTimes(1);
expect(restoreFn).toHaveBeenCalledTimes(1);
history.off('save-to-indexed-db', saveFn);
history.off('restore-from-indexed-db', restoreFn);
});
});