feat(editor): 代码块与数据源支持按 id 独立的历史记录

- history service 新增 pushCodeBlock/undoCodeBlock/redoCodeBlock
  /canUndoCodeBlock/canRedoCodeBlock 及数据源对称 API
- 按 id 维度各自维护独立 UndoRedo 栈,与页面/节点历史完全解耦
- type 新增 CodeBlockStepValue / DataSourceStepValue 独立类型
- HistoryState 扩展 codeBlockState / dataSourceState 字段
- codeBlockService.setCodeDslByIdSync / deleteCodeDslByIds 自动入历史
- dataSourceService.add / update / remove 自动入历史
- 入栈成功时 emit code-block-history-change / data-source-history-change
- 补充单测共 21 例,更新 history/codeBlock/dataSource 相关文档
This commit is contained in:
roymondchen 2026-05-27 19:50:17 +08:00
parent a341c7d73e
commit e2c065f90d
11 changed files with 662 additions and 6 deletions

View File

@ -70,6 +70,11 @@
同步版本的 [setCodeDslById](#setcodedslbyid),并会触发 `addOrUpdate` 事件
::: tip
写入成功时(`force=false` 且同 id 已存在的跳过场景除外)会自动调用 `historyService.pushCodeBlock`
把本次变更入历史栈,参见 [historyService.pushCodeBlock](./historyServiceMethods.md#pushcodeblock)。
:::
## getCodeDslByIds
- **参数:**
@ -202,6 +207,11 @@
在dsl数据源中删除指定id的代码块每删除一个会触发一次 `remove` 事件
::: tip
对每个实际存在并被删除的代码块,会自动调用 `historyService.pushCodeBlock` 入栈一条
`newContent=null` 的删除记录;不存在的 id 不会入历史。
:::
## setParamsColConfig
- **参数:**

View File

@ -306,6 +306,11 @@ dataSourceService.setFormMethod("http", [
添加一个数据源如果配置中没有id或id已存在会自动生成新的id
::: tip
添加成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema=null` 的新增记录,
参见 [historyService.pushDataSource](./historyServiceMethods.md#pushdatasource)。
:::
- **示例:**
```js
@ -341,6 +346,11 @@ console.log(newDs.id); // 自动生成的id
更新数据源
::: tip
更新成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema` / `newSchema`
均为对应 schema 的更新记录。
:::
- **示例:**
```js
@ -370,6 +380,11 @@ console.log(updatedDs);
删除指定id的数据源
::: tip
对实际存在的数据源会自动调用 `historyService.pushDataSource` 入栈一条 `newSchema=null`
的删除记录;不存在的 id 不会入历史。
:::
- **示例:**
```js

View File

@ -29,3 +29,41 @@
:::tip
当游标处于历史栈边界(已经无法继续撤销或重做)时,`UndoRedo.undo()` / `redo()` 返回 `null`,对应 `change` 回调收到的 `state``null`
:::
## code-block-history-change
- **详情:** 代码块历史记录发生变化(`pushCodeBlock` / `undoCodeBlock` / `redoCodeBlock` 成功时触发)
- **事件回调函数:** `(codeBlockId: Id, step: CodeBlockStepValue) => void`
::: details 查看 CodeBlockStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts}
<<< @/../packages/schema/src/index.ts#CodeBlockContent{ts}
<<< @/../packages/schema/src/index.ts#Id{ts}
:::
:::tip
- 新增触发的 step 中 `oldContent``null`
- 删除触发的 step 中 `newContent``null`
- `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件
:::
## data-source-history-change
- **详情:** 数据源历史记录发生变化(`pushDataSource` / `undoDataSource` / `redoDataSource` 成功时触发)
- **事件回调函数:** `(dataSourceId: Id, step: DataSourceStepValue) => void`
::: details 查看 DataSourceStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts}
<<< @/../packages/schema/src/index.ts#Id{ts}
:::
:::tip
- 新增触发的 step 中 `oldSchema``null`
- 删除触发的 step 中 `newSchema``null`
- `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件
:::

View File

@ -4,7 +4,7 @@
- **详情:**
重置记录
重置全部历史记录(包括页面节点栈、代码块栈、数据源栈),并重置当前页面 id / canRedo / canUndo
## resetPage
@ -16,7 +16,7 @@
- **详情:**
重置历史记录全部内部状态(清空 pageId、pageSteps、canRedo、canUndo
重置历史记录全部内部状态(清空 pageId、pageSteps、canRedo、canUndo、codeBlockState、dataSourceState
## changePage
@ -73,6 +73,159 @@
恢复到下一步
## pushCodeBlock
- **参数:**
- `{Id} codeBlockId` 代码块 id
- `{Object} payload`
- `{CodeBlockContent | null} oldContent` 变更前的代码块内容;新增时为 `null`
- `{CodeBlockContent | null} newContent` 变更后的代码块内容;删除时为 `null`
::: details 查看 CodeBlockStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts}
<<< @/../packages/schema/src/index.ts#CodeBlockContent{ts}
:::
- **返回:**
- `{CodeBlockStepValue | null}` 入栈失败(未传 id时返回 `null`
- **详情:**
推入一条代码块变更记录。与页面 / 节点完全无关,按 `codeBlockId` 维度独立一份 `UndoRedo` 栈,
栈实例存放在 `historyService.state.codeBlockState[codeBlockId]`
入栈成功后会触发 `code-block-history-change` 事件。
::: tip
`codeBlockService.setCodeDslByIdSync``codeBlockService.deleteCodeDslByIds` 内部已经
自动调用本方法,业务代码通常无需手动调用。
:::
## undoCodeBlock
- **参数:**
- `{Id} codeBlockId`
- **返回:**
- `{CodeBlockStepValue | null}` 栈不存在或已无可撤销记录时返回 `null`
- **详情:**
撤销指定代码块的最近一次变更。成功时会触发 `code-block-history-change` 事件。
拿到 step 后由调用方根据 `step.oldContent` 写回 `codeBlockService`(本方法不会自动回放)。
## redoCodeBlock
- **参数:**
- `{Id} codeBlockId`
- **返回:**
- `{CodeBlockStepValue | null}` 栈不存在或已无可重做记录时返回 `null`
- **详情:**
重做指定代码块的下一次变更。成功时会触发 `code-block-history-change` 事件。
## canUndoCodeBlock
- **参数:**
- `{Id} codeBlockId`
- **返回:**
- `{boolean}`
- **详情:**
指定代码块当前是否可撤销。栈不存在时返回 `false`
## canRedoCodeBlock
- **参数:**
- `{Id} codeBlockId`
- **返回:**
- `{boolean}`
- **详情:**
指定代码块当前是否可重做。栈不存在时返回 `false`
## pushDataSource
- **参数:**
- `{Id} dataSourceId` 数据源 id
- `{Object} payload`
- `{DataSourceSchema | null} oldSchema` 变更前的数据源 schema新增时为 `null`
- `{DataSourceSchema | null} newSchema` 变更后的数据源 schema删除时为 `null`
::: details 查看 DataSourceStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts}
:::
- **返回:**
- `{DataSourceStepValue | null}` 入栈失败(未传 id时返回 `null`
- **详情:**
推入一条数据源变更记录。与页面 / 节点完全无关,按 `dataSourceId` 维度独立一份 `UndoRedo` 栈,
栈实例存放在 `historyService.state.dataSourceState[dataSourceId]`
入栈成功后会触发 `data-source-history-change` 事件。
::: tip
`dataSourceService.add` / `update` / `remove` 内部已经自动调用本方法,业务代码通常无需手动调用。
:::
## undoDataSource
- **参数:**
- `{Id} dataSourceId`
- **返回:**
- `{DataSourceStepValue | null}`
- **详情:**
撤销指定数据源的最近一次变更。成功时会触发 `data-source-history-change` 事件。
拿到 step 后由调用方根据 `step.oldSchema` 写回 `dataSourceService`(本方法不会自动回放)。
## redoDataSource
- **参数:**
- `{Id} dataSourceId`
- **返回:**
- `{DataSourceStepValue | null}`
- **详情:**
重做指定数据源的下一次变更。成功时会触发 `data-source-history-change` 事件。
## canUndoDataSource
- **参数:**
- `{Id} dataSourceId`
- **返回:**
- `{boolean}`
- **详情:**
指定数据源当前是否可撤销。栈不存在时返回 `false`
## canRedoDataSource
- **参数:**
- `{Id} dataSourceId`
- **返回:**
- `{boolean}`
- **详情:**
指定数据源当前是否可重做。栈不存在时返回 `false`
## destroy
- **详情:**

View File

@ -25,6 +25,7 @@ import { Target, Watcher } from '@tmagic/core';
import type { TableColumnConfig } from '@tmagic/form';
import editorService from '@editor/services/editor';
import historyService from '@editor/services/history';
import storageService, { Protocol } from '@editor/services/storage';
import type { AsyncHookPlugin, CodeState } from '@editor/type';
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
@ -123,6 +124,9 @@ class CodeBlock extends BaseService {
}
}
// 历史记录:在写入前快照旧内容,区分新增/更新
const oldContent: CodeBlockContent | null = codeDsl[id] ? cloneDeep(codeDsl[id]) : null;
const existContent = codeDsl[id] || {};
codeDsl[id] = {
@ -130,6 +134,10 @@ class CodeBlock extends BaseService {
...codeConfigProcessed,
};
const newContent = cloneDeep(codeDsl[id]);
historyService.pushCodeBlock(id, { oldContent, newContent });
this.emit('addOrUpdate', id, codeDsl[id]);
}
@ -225,8 +233,15 @@ class CodeBlock extends BaseService {
if (!currentDsl) return;
codeIds.forEach((id) => {
// 历史记录:删除前快照内容;不存在的 id 直接跳过历史推入
const oldContent: CodeBlockContent | null = currentDsl[id] ? cloneDeep(currentDsl[id]) : null;
delete currentDsl[id];
if (oldContent) {
historyService.pushCodeBlock(id, { oldContent, newContent: null });
}
this.emit('remove', id);
});
}

View File

@ -8,6 +8,7 @@ import type { ChangeRecord, FormConfig } from '@tmagic/form';
import { guid, toLine } from '@tmagic/utils';
import editorService from '@editor/services/editor';
import historyService from '@editor/services/history';
import storageService, { Protocol } from '@editor/services/storage';
import type { DatasourceTypeOption, SyncHookPlugin } from '@editor/type';
import { getFormConfig, getFormValue } from '@editor/utils/data-source';
@ -110,6 +111,8 @@ class DataSource extends BaseService {
this.get('dataSources').push(newConfig);
historyService.pushDataSource(newConfig.id, { oldSchema: null, newSchema: newConfig });
this.emit('add', newConfig);
return newConfig;
@ -125,6 +128,11 @@ class DataSource extends BaseService {
dataSources[index] = newConfig;
historyService.pushDataSource(newConfig.id, {
oldSchema: oldConfig ? cloneDeep(oldConfig) : null,
newSchema: newConfig,
});
this.emit('update', newConfig, {
oldConfig,
changeRecords,
@ -136,8 +144,13 @@ class DataSource extends BaseService {
public remove(id: string) {
const dataSources = this.get('dataSources');
const index = dataSources.findIndex((ds) => ds.id === id);
const oldConfig = index !== -1 ? dataSources[index] : null;
dataSources.splice(index, 1);
if (oldConfig) {
historyService.pushDataSource(id, { oldSchema: cloneDeep(oldConfig), newSchema: null });
}
this.emit('remove', id);
}

View File

@ -17,10 +17,11 @@
*/
import { reactive } from 'vue';
import { cloneDeep } from 'lodash-es';
import type { Id, MPage, MPageFragment } from '@tmagic/core';
import type { CodeBlockContent, DataSourceSchema, Id, MPage, MPageFragment } from '@tmagic/core';
import type { HistoryState, StepValue } from '@editor/type';
import type { CodeBlockStepValue, DataSourceStepValue, HistoryState, StepValue } from '@editor/type';
import { UndoRedo } from '@editor/utils/undo-redo';
import BaseService from './BaseService';
@ -31,6 +32,8 @@ class History extends BaseService {
pageId: undefined,
canRedo: false,
canUndo: false,
codeBlockState: {},
dataSourceState: {},
});
constructor() {
@ -41,6 +44,8 @@ class History extends BaseService {
public reset() {
this.state.pageSteps = {};
this.state.codeBlockState = {};
this.state.dataSourceState = {};
this.resetPage();
}
@ -69,6 +74,8 @@ class History extends BaseService {
this.state.pageSteps = {};
this.state.canRedo = false;
this.state.canUndo = false;
this.state.codeBlockState = {};
this.state.dataSourceState = {};
}
/**
@ -88,6 +95,114 @@ class History extends BaseService {
return state;
}
/**
* / `codeBlockId` UndoRedo
*
* - oldContent = nullnewContent =
* - oldContent / newContent
* - newContent = nulloldContent =
* - codeBlockService
*/
public pushCodeBlock(
codeBlockId: Id,
payload: {
oldContent: CodeBlockContent | null;
newContent: CodeBlockContent | null;
},
): CodeBlockStepValue | null {
if (!codeBlockId) return null;
const step: CodeBlockStepValue = {
id: codeBlockId,
oldContent: payload.oldContent ? cloneDeep(payload.oldContent) : null,
newContent: payload.newContent ? cloneDeep(payload.newContent) : null,
};
this.getCodeBlockUndoRedo(codeBlockId).pushElement(step);
this.emit('code-block-history-change', codeBlockId, step);
return step;
}
/**
* / `dataSourceId` UndoRedo
* pushCodeBlock oldSchema=null newSchema=null
*/
public pushDataSource(
dataSourceId: Id,
payload: {
oldSchema: DataSourceSchema | null;
newSchema: DataSourceSchema | null;
},
): DataSourceStepValue | null {
if (!dataSourceId) return null;
const step: DataSourceStepValue = {
id: dataSourceId,
oldSchema: payload.oldSchema ? cloneDeep(payload.oldSchema) : null,
newSchema: payload.newSchema ? cloneDeep(payload.newSchema) : null,
};
this.getDataSourceUndoRedo(dataSourceId).pushElement(step);
this.emit('data-source-history-change', dataSourceId, step);
return step;
}
/** 撤销指定代码块的最近一次变更。 */
public undoCodeBlock(codeBlockId: Id): CodeBlockStepValue | null {
const undoRedo = this.state.codeBlockState[codeBlockId];
if (!undoRedo) return null;
const step = undoRedo.undo();
if (step) this.emit('code-block-history-change', codeBlockId, step);
return step;
}
/** 重做指定代码块的下一次变更。 */
public redoCodeBlock(codeBlockId: Id): CodeBlockStepValue | null {
const undoRedo = this.state.codeBlockState[codeBlockId];
if (!undoRedo) return null;
const step = undoRedo.redo();
if (step) this.emit('code-block-history-change', codeBlockId, step);
return step;
}
/** 是否可对指定代码块撤销。 */
public canUndoCodeBlock(codeBlockId: Id): boolean {
return this.state.codeBlockState[codeBlockId]?.canUndo() ?? false;
}
/** 是否可对指定代码块重做。 */
public canRedoCodeBlock(codeBlockId: Id): boolean {
return this.state.codeBlockState[codeBlockId]?.canRedo() ?? false;
}
/** 撤销指定数据源的最近一次变更。 */
public undoDataSource(dataSourceId: Id): DataSourceStepValue | null {
const undoRedo = this.state.dataSourceState[dataSourceId];
if (!undoRedo) return null;
const step = undoRedo.undo();
if (step) this.emit('data-source-history-change', dataSourceId, step);
return step;
}
/** 重做指定数据源的下一次变更。 */
public redoDataSource(dataSourceId: Id): DataSourceStepValue | null {
const undoRedo = this.state.dataSourceState[dataSourceId];
if (!undoRedo) return null;
const step = undoRedo.redo();
if (step) this.emit('data-source-history-change', dataSourceId, step);
return step;
}
/** 是否可对指定数据源撤销。 */
public canUndoDataSource(dataSourceId: Id): boolean {
return this.state.dataSourceState[dataSourceId]?.canUndo() ?? false;
}
/** 是否可对指定数据源重做。 */
public canRedoDataSource(dataSourceId: Id): boolean {
return this.state.dataSourceState[dataSourceId]?.canRedo() ?? false;
}
public undo(): StepValue | null {
const undoRedo = this.getUndoRedo();
if (!undoRedo) return null;
@ -130,6 +245,26 @@ class History extends BaseService {
this.state.canRedo = undoRedo?.canRedo() || false;
this.state.canUndo = undoRedo?.canUndo() || false;
}
/**
* id UndoRedo
*/
private getCodeBlockUndoRedo(codeBlockId: Id): UndoRedo<CodeBlockStepValue> {
if (!this.state.codeBlockState[codeBlockId]) {
this.state.codeBlockState[codeBlockId] = new UndoRedo<CodeBlockStepValue>();
}
return this.state.codeBlockState[codeBlockId];
}
/**
* id UndoRedo
*/
private getDataSourceUndoRedo(dataSourceId: Id): UndoRedo<DataSourceStepValue> {
if (!this.state.dataSourceState[dataSourceId]) {
this.state.dataSourceState[dataSourceId] = new UndoRedo<DataSourceStepValue>();
}
return this.state.dataSourceState[dataSourceId];
}
}
export type HistoryService = History;

View File

@ -22,7 +22,17 @@ import type * as Monaco from 'monaco-editor';
import type { default as Sortable, Options, SortableEvent } from 'sortablejs';
import type { PascalCasedProperties, Writable } from 'type-fest';
import type { CodeBlockContent, CodeBlockDSL, Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core';
import type {
CodeBlockContent,
CodeBlockDSL,
DataSourceSchema,
Id,
MApp,
MContainer,
MNode,
MPage,
MPageFragment,
} from '@tmagic/core';
import type { ChangeRecord, FormConfig, TableColumnConfig, TypeFunction } from '@tmagic/form';
import type StageCore from '@tmagic/stage';
import type {
@ -632,11 +642,55 @@ export interface StepValue {
}
// #endregion StepValue
// #region CodeBlockStepValue
/**
* codeBlock.id historyState.codeBlockState
* - oldContent = nullnewContent =
* - oldContent / newContent
* - newContent = nulloldContent =
*/
export interface CodeBlockStepValue {
/** 关联的代码块 id */
id: Id;
/** 变更前的代码块内容,新增时为 null */
oldContent: CodeBlockContent | null;
/** 变更后的代码块内容,删除时为 null */
newContent: CodeBlockContent | null;
}
// #endregion CodeBlockStepValue
// #region DataSourceStepValue
/**
* dataSource.id historyState.dataSourceState
* - oldSchema = nullnewSchema = schema
* - oldSchema / newSchema schema
* - newSchema = nulloldSchema = schema
*/
export interface DataSourceStepValue {
/** 关联的数据源 id */
id: Id;
/** 变更前的数据源 schema新增时为 null */
oldSchema: DataSourceSchema | null;
/** 变更后的数据源 schema删除时为 null */
newSchema: DataSourceSchema | null;
}
// #endregion DataSourceStepValue
export interface HistoryState {
pageId?: Id;
pageSteps: Record<Id, UndoRedo<StepValue>>;
canRedo: boolean;
canUndo: boolean;
/**
* codeBlock.id UndoRedo
* / undo/redo
*/
codeBlockState: Record<Id, UndoRedo<CodeBlockStepValue>>;
/**
* dataSource.id UndoRedo
* / undo/redo
*/
dataSourceState: Record<Id, UndoRedo<DataSourceStepValue>>;
}
export enum KeyBindingCommand {

View File

@ -6,6 +6,7 @@
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import codeBlockService from '@editor/services/codeBlock';
import historyService from '@editor/services/history';
import storageService, { Protocol } from '@editor/services/storage';
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
import { setEditorConfig } from '@editor/utils/config';
@ -26,6 +27,7 @@ beforeAll(() => {
beforeEach(() => {
codeBlockService.resetState();
historyService.reset();
globalThis.localStorage.clear();
});
@ -166,3 +168,45 @@ describe('CodeBlockService - 基础', () => {
expect(fn).not.toHaveBeenCalled();
});
});
describe('CodeBlockService - 历史记录接入', () => {
test('setCodeDslByIdSync - 新增时入历史oldContent=null', async () => {
await codeBlockService.setCodeDsl({} as any);
codeBlockService.setCodeDslByIdSync('new_code', { name: 'A' } as any);
expect(historyService.canUndoCodeBlock('new_code')).toBe(true);
const step = historyService.undoCodeBlock('new_code');
expect(step?.oldContent).toBeNull();
expect(step?.newContent).toEqual(expect.objectContaining({ name: 'A' }));
});
test('setCodeDslByIdSync - 更新时入历史oldContent / newContent 都非空)', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
const step = historyService.undoCodeBlock('a');
expect(step?.oldContent).toEqual({ name: 'A' });
expect(step?.newContent).toEqual(expect.objectContaining({ name: 'A2' }));
});
test('setCodeDslByIdSync - force=false 已存在时不入历史', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any, false);
expect(historyService.canUndoCodeBlock('a')).toBe(false);
});
test('deleteCodeDslByIds - 删除已存在的代码块入历史newContent=null', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' }, b: { name: 'B' } } as any);
await codeBlockService.deleteCodeDslByIds(['a']);
const step = historyService.undoCodeBlock('a');
expect(step?.oldContent).toEqual({ name: 'A' });
expect(step?.newContent).toBeNull();
});
test('deleteCodeDslByIds - 删除不存在的 id 不入历史', async () => {
await codeBlockService.setCodeDsl({} as any);
await codeBlockService.deleteCodeDslByIds(['ghost']);
expect(historyService.canUndoCodeBlock('ghost')).toBe(false);
});
});

View File

@ -6,6 +6,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import dataSource from '@editor/services/dataSource';
import historyService from '@editor/services/history';
import storageService, { Protocol } from '@editor/services/storage';
import { setEditorConfig } from '@editor/utils/config';
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
@ -27,6 +28,7 @@ beforeEach(() => {
dataSource.set('values', {});
dataSource.set('events', {});
dataSource.set('methods', {});
historyService.reset();
});
afterEach(() => {
@ -127,3 +129,39 @@ describe('DataSource service', () => {
expect(fn).not.toHaveBeenCalled();
});
});
describe('DataSource service - 历史记录接入', () => {
test('add - 入历史oldSchema=null', () => {
const ds = dataSource.add({ title: 'a', type: 'base' } as any);
expect(historyService.canUndoDataSource(ds.id!)).toBe(true);
const step = historyService.undoDataSource(ds.id!);
expect(step?.oldSchema).toBeNull();
expect(step?.newSchema?.title).toBe('a');
});
test('update - 入历史oldSchema 是旧值newSchema 是新值', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
// 清掉 add 推入的那条
historyService.reset();
dataSource.update({ ...created, title: 'b' } as any);
const step = historyService.undoDataSource(created.id!);
expect(step?.oldSchema?.title).toBe('a');
expect(step?.newSchema?.title).toBe('b');
});
test('remove - 入历史newSchema=null', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
historyService.reset();
dataSource.remove(created.id!);
const step = historyService.undoDataSource(created.id!);
expect(step?.oldSchema?.title).toBe('a');
expect(step?.newSchema).toBeNull();
});
test('remove - 不存在的 id 不入历史', () => {
dataSource.remove('ghost');
expect(historyService.canUndoDataSource('ghost')).toBe(false);
});
});

View File

@ -3,7 +3,7 @@
*
* Copyright (C) 2025 Tencent.
*/
import { afterEach, describe, expect, test } from 'vitest';
import { afterEach, describe, expect, test, vi } from 'vitest';
import history from '@editor/services/history';
@ -85,3 +85,144 @@ describe('history service', () => {
expect(history.state.canUndo).toBe(true);
});
});
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('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('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);
});
});