From 8dae67769c32dbf65413d47ac56ca46e65eaeecf Mon Sep 17 00:00:00 2001 From: roymondchen Date: Thu, 28 May 2026 16:40:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=95=B0=E6=8D=AE=E6=BA=90?= =?UTF-8?q?=E4=B8=8E=E4=BB=A3=E7=A0=81=E5=9D=97=20service=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20undo/redo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dataSourceService / codeBlockService 新增 undo / redo / canUndo / canRedo 方法 - undo/redo 内部复用 add / update / remove / setCodeDslByIdSync / deleteCodeDslByIds 写回, 并强制 doNotPushHistory,借此自动驱动 initService 中的依赖收集链路 (DepTargetType.DATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD / CODE_BLOCK) - 更新场景下若 step 带 changeRecords,按 propPath 局部 patch,不冲掉同节点其它无关变更; 缺省退化为整 schema / 整内容替换 - 补充对应单测与 API 文档 --- docs/api/editor/codeBlockServiceMethods.md | 66 +++++++++ docs/api/editor/dataSourceServiceMethods.md | 66 +++++++++ packages/editor/src/services/codeBlock.ts | 107 +++++++++++++- packages/editor/src/services/dataSource.ts | 107 +++++++++++++- .../tests/unit/services/codeBlock.spec.ts | 126 ++++++++++++++++ .../tests/unit/services/dataSource.spec.ts | 137 ++++++++++++++++++ 6 files changed, 606 insertions(+), 3 deletions(-) diff --git a/docs/api/editor/codeBlockServiceMethods.md b/docs/api/editor/codeBlockServiceMethods.md index 9316f6a3..9db0b6ba 100644 --- a/docs/api/editor/codeBlockServiceMethods.md +++ b/docs/api/editor/codeBlockServiceMethods.md @@ -226,6 +226,72 @@ `newContent=null` 的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。 ::: +## undo + +- **参数:** + - `{Id}` id 代码块id + +- **返回:** + - `{Promise}` 撤销的 step;栈不存在或已无可撤销时返回 `null` + +- **详情:** + + 撤销指定代码块的最近一次变更。内部根据 [historyService](./historyServiceMethods.md) 取出 step 后, + 复用 [setCodeDslByIdSync](#setcodedslbyidsync) / [deleteCodeDslByIds](#deletecodedslbyids) 写回, + 并自动带上 `doNotPushHistory: true`,确保不会再次入栈。 + + 写回会触发对应的 `addOrUpdate` / `remove` 事件,编辑器内部据此重新维护 + `DepTargetType.CODE_BLOCK` 的 dep target,无需调用方额外处理。 + + 对于带有 `changeRecords` 的更新 step,会按 `propPath` 局部 patch 当前代码块内容;缺省才退化为整内容替换, + 避免冲掉同代码块上的其它无关变更。 + +- **示例:** + +```js +import { codeBlockService } from "@tmagic/editor"; + +if (codeBlockService.canUndo("code_1234")) { + await codeBlockService.undo("code_1234"); +} +``` + +## redo + +- **参数:** + - `{Id}` id 代码块id + +- **返回:** + - `{Promise}` 重做的 step;栈不存在或已无可重做时返回 `null` + +- **详情:** + + 重做指定代码块的下一次变更。其它行为同 [undo](#undo)。 + +## canUndo + +- **参数:** + - `{Id}` id 代码块id + +- **返回:** + - `{boolean}` + +- **详情:** + + 当前指定代码块是否可撤销,等价于 `historyService.canUndoCodeBlock(id)`。 + +## canRedo + +- **参数:** + - `{Id}` id 代码块id + +- **返回:** + - `{boolean}` + +- **详情:** + + 当前指定代码块是否可重做,等价于 `historyService.canRedoCodeBlock(id)`。 + ## setParamsColConfig - **参数:** diff --git a/docs/api/editor/dataSourceServiceMethods.md b/docs/api/editor/dataSourceServiceMethods.md index 2b8e84e7..146a9a36 100644 --- a/docs/api/editor/dataSourceServiceMethods.md +++ b/docs/api/editor/dataSourceServiceMethods.md @@ -443,6 +443,72 @@ const ds = dataSourceService.getDataSourceById("ds_123"); console.log(ds); ``` +## undo + +- **参数:** + - `{Id}` id 数据源id + +- **返回:** + - {`DataSourceStepValue` | null} 撤销的 step;栈不存在或已无可撤销时返回 `null` + +- **详情:** + + 撤销指定数据源的最近一次变更。内部根据 [historyService](./historyServiceMethods.md) 取出 step 后, + 复用 [add](#add) / [update](#update) / [remove](#remove) 写回,并自动带上 `doNotPushHistory: true`, + 确保不会再次入栈。 + + 写回会触发对应的 `add` / `update` / `remove` 事件,编辑器内部据此重新维护数据源相关的依赖收集 + (`DepTargetType.DATA_SOURCE` / `DATA_SOURCE_COND` / `DATA_SOURCE_METHOD`),无需调用方额外处理。 + + 对于带有 `changeRecords` 的更新 step,会按 `propPath` 局部 patch 当前数据源;缺省才退化为整 schema 替换, + 避免冲掉同节点上的其它无关变更。 + +- **示例:** + +```js +import { dataSourceService } from "@tmagic/editor"; + +if (dataSourceService.canUndo("ds_123")) { + dataSourceService.undo("ds_123"); +} +``` + +## redo + +- **参数:** + - `{Id}` id 数据源id + +- **返回:** + - {`DataSourceStepValue` | null} 重做的 step;栈不存在或已无可重做时返回 `null` + +- **详情:** + + 重做指定数据源的下一次变更。其它行为同 [undo](#undo)。 + +## canUndo + +- **参数:** + - `{Id}` id 数据源id + +- **返回:** + - `{boolean}` + +- **详情:** + + 当前指定数据源是否可撤销,等价于 `historyService.canUndoDataSource(id)`。 + +## canRedo + +- **参数:** + - `{Id}` id 数据源id + +- **返回:** + - `{boolean}` + +- **详情:** + + 当前指定数据源是否可重做,等价于 `historyService.canRedoDataSource(id)`。 + ## copyWithRelated - **参数:** diff --git a/packages/editor/src/services/codeBlock.ts b/packages/editor/src/services/codeBlock.ts index d401097d..07abe4af 100644 --- a/packages/editor/src/services/codeBlock.ts +++ b/packages/editor/src/services/codeBlock.ts @@ -23,11 +23,12 @@ import type { Writable } from 'type-fest'; import type { CodeBlockContent, CodeBlockDSL, Id, MNode, TargetOptions } from '@tmagic/core'; import { Target, Watcher } from '@tmagic/core'; import type { ChangeRecord, TableColumnConfig } from '@tmagic/form'; +import { getValueByKeyPath, setValueByKeyPath } from '@tmagic/utils'; 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 type { AsyncHookPlugin, CodeBlockStepValue, CodeState } from '@editor/type'; import { CODE_DRAFT_STORAGE_KEY } from '@editor/type'; import { getEditorConfig } from '@editor/utils/config'; import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor'; @@ -276,6 +277,46 @@ class CodeBlock extends BaseService { return this.state.paramsColConfig; } + /** + * 撤销指定代码块的最近一次变更。 + * + * 内部走 setCodeDslByIdSync / deleteCodeDslByIds,因此会自动触发 codeBlockService 的 + * `addOrUpdate` / `remove` 事件,由 initService 中的 handler 重新维护 dep target + * (DepTargetType.CODE_BLOCK 的 add / remove)。所有写回都带 `doNotPushHistory: true`, + * 确保不会在历史栈里产生新的记录。 + * + * @param id 代码块 id + * @returns 撤销的 step;栈不存在或已无可撤销时返回 null + */ + public async undo(id: Id): Promise { + const step = historyService.undoCodeBlock(id); + if (!step) return null; + await this.applyHistoryStep(step, true); + return step; + } + + /** + * 重做指定代码块的下一次变更。 + * @param id 代码块 id + * @returns 重做的 step;栈不存在或已无可重做时返回 null + */ + public async redo(id: Id): Promise { + const step = historyService.redoCodeBlock(id); + if (!step) return null; + await this.applyHistoryStep(step, false); + return step; + } + + /** 是否可对指定代码块撤销。 */ + public canUndo(id: Id): boolean { + return historyService.canUndoCodeBlock(id); + } + + /** 是否可对指定代码块重做。 */ + public canRedo(id: Id): boolean { + return historyService.canRedoCodeBlock(id); + } + /** * 生成代码块唯一id * @returns {Id} 代码块唯一id @@ -357,6 +398,70 @@ class CodeBlock extends BaseService { public usePlugin(options: AsyncHookPlugin): void { super.usePlugin(options); } + + /** + * 把一条历史 step 应用到当前代码块服务上。 + * + * 复用现有的 setCodeDslByIdSync / deleteCodeDslByIds,目的是借助它们发出的事件 + * 触发 initService 中的 dep target 维护(CODE_BLOCK 的 add / remove)。 + * 所有写回都带 `doNotPushHistory: true`,确保不会在历史栈里产生新的记录。 + * + * - oldContent=null, newContent≠null:原始为新增 → undo 删除;redo 再次 setCodeDslByIdSync + * - oldContent≠null, newContent=null:原始为删除 → undo 还原写入;redo 再次删除 + * - 两侧都有:原始为更新 → 按 changeRecords 局部 patch;缺省退化为整内容替换 + * + * @param step 历史 step + * @param reverse true=撤销,false=重做 + */ + private async applyHistoryStep(step: CodeBlockStepValue, reverse: boolean): Promise { + const { id, oldContent, newContent, changeRecords } = step; + + // 新增 / 删除:直接 set 或 delete,不走 patch 逻辑 + if (oldContent === null && newContent) { + if (reverse) { + await this.deleteCodeDslByIds([id], { doNotPushHistory: true }); + } else { + this.setCodeDslByIdSync(id, cloneDeep(newContent), true, { doNotPushHistory: true }); + } + return; + } + + if (oldContent && newContent === null) { + if (reverse) { + this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { doNotPushHistory: true }); + } else { + await this.deleteCodeDslByIds([id], { doNotPushHistory: true }); + } + return; + } + + if (!oldContent || !newContent) return; + + // 更新场景:优先按 changeRecords 局部 patch;缺省退化为整内容替换 + const sourceForValues = reverse ? oldContent : newContent; + + if (changeRecords?.length) { + const current = this.getCodeContentById(id); + if (!current) return; + const patched = cloneDeep(current) as CodeBlockContent; + let fallbackToFullReplace = false; + for (const record of changeRecords) { + if (!record.propPath) { + fallbackToFullReplace = true; + break; + } + const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues)); + setValueByKeyPath(record.propPath, value, patched); + } + this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(sourceForValues) : patched, true, { + changeRecords, + doNotPushHistory: true, + }); + return; + } + + this.setCodeDslByIdSync(id, cloneDeep(sourceForValues), true, { doNotPushHistory: true }); + } } export type CodeBlockService = CodeBlock; diff --git a/packages/editor/src/services/dataSource.ts b/packages/editor/src/services/dataSource.ts index b19f1030..1b56871a 100644 --- a/packages/editor/src/services/dataSource.ts +++ b/packages/editor/src/services/dataSource.ts @@ -5,12 +5,12 @@ import type { Writable } from 'type-fest'; import type { DataSourceSchema, EventOption, Id, MNode, TargetOptions } from '@tmagic/core'; import { Target, Watcher } from '@tmagic/core'; import type { ChangeRecord, FormConfig } from '@tmagic/form'; -import { guid, toLine } from '@tmagic/utils'; +import { getValueByKeyPath, guid, setValueByKeyPath, 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 type { DataSourceStepValue, DatasourceTypeOption, SyncHookPlugin } from '@editor/type'; import { getFormConfig, getFormValue } from '@editor/utils/data-source'; import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor'; @@ -184,6 +184,45 @@ class DataSource extends BaseService { this.emit('remove', id); } + /** + * 撤销指定数据源的最近一次变更。 + * + * 内部走 add / update / remove,因此会自动触发 dataSourceService 的事件, + * 由 initService 中的对应 handler 重新收集 dep(DATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD)。 + * 所有写回都带 `doNotPushHistory: true`,避免在历史栈里产生新的记录。 + * + * @param id 数据源 id + * @returns 撤销的 step;栈不存在或已无可撤销时返回 null + */ + public undo(id: Id) { + const step = historyService.undoDataSource(id); + if (!step) return null; + this.applyHistoryStep(step, true); + return step; + } + + /** + * 重做指定数据源的下一次变更。 + * @param id 数据源 id + * @returns 重做的 step;栈不存在或已无可重做时返回 null + */ + public redo(id: Id) { + const step = historyService.redoDataSource(id); + if (!step) return null; + this.applyHistoryStep(step, false); + return step; + } + + /** 是否可对指定数据源撤销。 */ + public canUndo(id: Id): boolean { + return historyService.canUndoDataSource(id); + } + + /** 是否可对指定数据源重做。 */ + public canRedo(id: Id): boolean { + return historyService.canRedoDataSource(id); + } + public createId(): string { return `ds_${guid()}`; } @@ -258,6 +297,70 @@ class DataSource extends BaseService { } }); } + + /** + * 把一条历史 step 应用到当前数据源服务上。 + * + * 复用现有的 add / update / remove,目的是借助它们发出的事件触发 initService 中 + * 的依赖收集逻辑(DATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD)。 + * 所有写回都带 `doNotPushHistory: true`,确保不会在历史栈里产生新的记录。 + * + * - oldSchema=null, newSchema≠null:原始为新增 → undo 删除;redo 再次 add + * - oldSchema≠null, newSchema=null:原始为删除 → undo 还原 add;redo 再次删除 + * - 两侧都有:原始为更新 → 按 changeRecords 局部 patch;缺省退化为整 schema 替换 + * + * @param step 历史 step + * @param reverse true=撤销,false=重做 + */ + private applyHistoryStep(step: DataSourceStepValue, reverse: boolean): void { + const { id, oldSchema, newSchema, changeRecords } = step; + + // 新增 / 删除:直接 add 或 remove,不走 patch 逻辑 + if (oldSchema === null && newSchema) { + if (reverse) { + this.remove(`${id}`, { doNotPushHistory: true }); + } else { + this.add(cloneDeep(newSchema), { doNotPushHistory: true }); + } + return; + } + + if (oldSchema && newSchema === null) { + if (reverse) { + this.add(cloneDeep(oldSchema), { doNotPushHistory: true }); + } else { + this.remove(`${id}`, { doNotPushHistory: true }); + } + return; + } + + if (!oldSchema || !newSchema) return; + + // 更新场景:优先按 changeRecords 局部 patch;缺省退化为整 schema 替换 + const sourceForValues = reverse ? oldSchema : newSchema; + + if (changeRecords?.length) { + const current = this.getDataSourceById(`${id}`); + if (!current) return; + const patched = cloneDeep(current) as DataSourceSchema; + let fallbackToFullReplace = false; + for (const record of changeRecords) { + if (!record.propPath) { + fallbackToFullReplace = true; + break; + } + const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues)); + setValueByKeyPath(record.propPath, value, patched); + } + this.update(fallbackToFullReplace ? cloneDeep(sourceForValues) : patched, { + changeRecords, + doNotPushHistory: true, + }); + return; + } + + this.update(cloneDeep(sourceForValues), { doNotPushHistory: true }); + } } export type DataSourceService = DataSource; diff --git a/packages/editor/tests/unit/services/codeBlock.spec.ts b/packages/editor/tests/unit/services/codeBlock.spec.ts index b596d7d1..f27b6808 100644 --- a/packages/editor/tests/unit/services/codeBlock.spec.ts +++ b/packages/editor/tests/unit/services/codeBlock.spec.ts @@ -230,3 +230,129 @@ describe('CodeBlockService - 历史记录接入', () => { expect(step?.changeRecords).toBeUndefined(); }); }); + +describe('CodeBlockService - undo / redo', () => { + test('undo / redo - 新增场景:撤销=删除,重做=再写回', async () => { + await codeBlockService.setCodeDsl({} as any); + codeBlockService.setCodeDslByIdSync('a', { name: 'A' } as any); + + await codeBlockService.undo('a'); + expect(codeBlockService.getCodeContentById('a')).toBeNull(); + + await codeBlockService.redo('a'); + expect(codeBlockService.getCodeContentById('a')?.name).toBe('A'); + }); + + test('undo / redo - 删除场景:撤销=还原内容,重做=再删除', async () => { + await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any); + historyService.reset(); + + await codeBlockService.deleteCodeDslByIds(['a']); + expect(codeBlockService.getCodeContentById('a')).toBeNull(); + + await codeBlockService.undo('a'); + expect(codeBlockService.getCodeContentById('a')?.name).toBe('A'); + + await codeBlockService.redo('a'); + expect(codeBlockService.getCodeContentById('a')).toBeNull(); + }); + + test('undo / redo - 更新场景(无 changeRecords):整内容替换', async () => { + await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any); + historyService.reset(); + + codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any); + expect(codeBlockService.getCodeContentById('a')?.name).toBe('A2'); + + await codeBlockService.undo('a'); + expect(codeBlockService.getCodeContentById('a')?.name).toBe('A'); + + await codeBlockService.redo('a'); + expect(codeBlockService.getCodeContentById('a')?.name).toBe('A2'); + }); + + test('undo / redo - 更新场景(带 changeRecords):按 propPath 局部 patch,不冲掉同节点其它字段', async () => { + await codeBlockService.setCodeDsl({ + a: { name: 'A', desc: 'origin' }, + } as any); + historyService.reset(); + + // form 端仅改 name + codeBlockService.setCodeDslByIdSync('a', { name: 'A2', desc: 'origin' } as any, true, { + changeRecords: [{ propPath: 'name', value: 'A2' }], + }); + + // 中间外部同步改了 desc(不入历史) + codeBlockService.setCodeDslByIdSync('a', { desc: 'changed-by-other' } as any, true, { + doNotPushHistory: true, + }); + + // undo 只回滚 name + await codeBlockService.undo('a'); + const after = codeBlockService.getCodeContentById('a') as any; + expect(after?.name).toBe('A'); + expect(after?.desc).toBe('changed-by-other'); + + // redo 只重做 name + await codeBlockService.redo('a'); + const redo = codeBlockService.getCodeContentById('a') as any; + expect(redo?.name).toBe('A2'); + expect(redo?.desc).toBe('changed-by-other'); + }); + + test('undo / redo - 通过 addOrUpdate / remove 事件触发,依赖收集链路保留', async () => { + const addOrUpdateFn = vi.fn(); + const removeFn = vi.fn(); + codeBlockService.on('addOrUpdate', addOrUpdateFn); + codeBlockService.on('remove', removeFn); + + await codeBlockService.setCodeDsl({} as any); + codeBlockService.setCodeDslByIdSync('a', { name: 'A' } as any); + addOrUpdateFn.mockClear(); + + // 撤销新增 → 触发 remove 事件,initService 中的 codeBlockRemoveHandler 会移除 dep target + await codeBlockService.undo('a'); + expect(removeFn).toHaveBeenCalledWith('a'); + + // 重做新增 → 触发 addOrUpdate 事件,initService 会重新 addTarget + await codeBlockService.redo('a'); + expect(addOrUpdateFn).toHaveBeenCalled(); + + codeBlockService.off('addOrUpdate', addOrUpdateFn); + codeBlockService.off('remove', removeFn); + }); + + test('undo / redo - 写回时不会再次入历史栈', async () => { + await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any); + historyService.reset(); + + codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any); + expect(historyService.canUndoCodeBlock('a')).toBe(true); + + await codeBlockService.undo('a'); + expect(historyService.canRedoCodeBlock('a')).toBe(true); + + await codeBlockService.redo('a'); + expect(historyService.canRedoCodeBlock('a')).toBe(false); + expect(historyService.canUndoCodeBlock('a')).toBe(true); + }); + + test('canUndo / canRedo 委托给 historyService', async () => { + expect(codeBlockService.canUndo('ghost')).toBe(false); + expect(codeBlockService.canRedo('ghost')).toBe(false); + + await codeBlockService.setCodeDsl({} as any); + codeBlockService.setCodeDslByIdSync('a', { name: 'A' } as any); + expect(codeBlockService.canUndo('a')).toBe(true); + expect(codeBlockService.canRedo('a')).toBe(false); + + await codeBlockService.undo('a'); + expect(codeBlockService.canUndo('a')).toBe(false); + expect(codeBlockService.canRedo('a')).toBe(true); + }); + + test('undo / redo - 栈不存在或已无可操作时返回 null', async () => { + await expect(codeBlockService.undo('ghost')).resolves.toBeNull(); + await expect(codeBlockService.redo('ghost')).resolves.toBeNull(); + }); +}); diff --git a/packages/editor/tests/unit/services/dataSource.spec.ts b/packages/editor/tests/unit/services/dataSource.spec.ts index 5323031c..f9a1b601 100644 --- a/packages/editor/tests/unit/services/dataSource.spec.ts +++ b/packages/editor/tests/unit/services/dataSource.spec.ts @@ -186,3 +186,140 @@ describe('DataSource service - 历史记录接入', () => { expect(step?.changeRecords).toBeUndefined(); }); }); + +describe('DataSource service - undo / redo', () => { + test('undo / redo - 新增场景:撤销=移除,重做=再添加', () => { + const created = dataSource.add({ title: 'a', type: 'base' } as any); + + // undo 后数据源应被移除 + const undoStep = dataSource.undo(created.id!); + expect(undoStep).not.toBeNull(); + expect(dataSource.getDataSourceById(created.id!)).toBeUndefined(); + + // redo 后数据源应被重新添加 + const redoStep = dataSource.redo(created.id!); + expect(redoStep).not.toBeNull(); + expect(dataSource.getDataSourceById(created.id!)?.title).toBe('a'); + }); + + test('undo / redo - 删除场景:撤销=还原,重做=再删除', () => { + const created = dataSource.add({ title: 'a', type: 'base' } as any); + historyService.reset(); + + dataSource.remove(created.id!); + expect(dataSource.getDataSourceById(created.id!)).toBeUndefined(); + + dataSource.undo(created.id!); + expect(dataSource.getDataSourceById(created.id!)?.title).toBe('a'); + + dataSource.redo(created.id!); + expect(dataSource.getDataSourceById(created.id!)).toBeUndefined(); + }); + + test('undo / redo - 更新场景(无 changeRecords):整 schema 替换', () => { + const created = dataSource.add({ title: 'a', type: 'base' } as any); + historyService.reset(); + + dataSource.update({ ...created, title: 'b' } as any); + expect(dataSource.getDataSourceById(created.id!)?.title).toBe('b'); + + dataSource.undo(created.id!); + expect(dataSource.getDataSourceById(created.id!)?.title).toBe('a'); + + dataSource.redo(created.id!); + expect(dataSource.getDataSourceById(created.id!)?.title).toBe('b'); + }); + + test('undo / redo - 更新场景(带 changeRecords):按 propPath 局部 patch,不冲掉同节点其它字段', () => { + const created = dataSource.add({ title: 'a', type: 'base', description: 'origin' } as any); + historyService.reset(); + + // 模拟 form 端只更新 title + dataSource.update({ ...created, title: 'b', description: 'origin' } as any, { + changeRecords: [{ propPath: 'title', value: 'b' }], + }); + + // 在两次 update 之间用户又改了同节点的另一个字段(不入历史,模拟外部同步) + dataSource.update({ ...dataSource.getDataSourceById(created.id!), description: 'changed-by-other' } as any, { + doNotPushHistory: true, + }); + + // undo 应只回滚 title,不影响 description + dataSource.undo(created.id!); + const after = dataSource.getDataSourceById(created.id!); + expect(after?.title).toBe('a'); + expect(after?.description).toBe('changed-by-other'); + + // redo 应只重做 title + dataSource.redo(created.id!); + const redo = dataSource.getDataSourceById(created.id!); + expect(redo?.title).toBe('b'); + expect(redo?.description).toBe('changed-by-other'); + }); + + test('undo / redo - 通过 add / update / remove 触发事件,依赖收集链路保留', () => { + const addFn = vi.fn(); + const updateFn = vi.fn(); + const removeFn = vi.fn(); + dataSource.on('add', addFn); + dataSource.on('update', updateFn); + dataSource.on('remove', removeFn); + + const created = dataSource.add({ title: 'a', type: 'base' } as any); + addFn.mockClear(); + + // 撤销新增 → 触发 remove 事件,initService 中的 dataSourceRemoveHandler 会清依赖 + dataSource.undo(created.id!); + expect(removeFn).toHaveBeenCalledWith(created.id); + + // 重做新增 → 触发 add 事件,initService 会重新 collectIdle + dataSource.redo(created.id!); + expect(addFn).toHaveBeenCalled(); + + // 推入一次 update 历史,再 undo 触发 update 事件 + historyService.reset(); + dataSource.update({ ...created, title: 'b' } as any); + updateFn.mockClear(); + dataSource.undo(created.id!); + expect(updateFn).toHaveBeenCalled(); + + dataSource.off('add', addFn); + dataSource.off('update', updateFn); + dataSource.off('remove', removeFn); + }); + + test('undo / redo - 写回时不会再次入历史栈', () => { + const created = dataSource.add({ title: 'a', type: 'base' } as any); + historyService.reset(); + + dataSource.update({ ...created, title: 'b' } as any); + // 此时栈里只有一条 update + expect(historyService.canUndoDataSource(created.id!)).toBe(true); + + dataSource.undo(created.id!); + // undo 后栈应可 redo,并且 undo 不应再生新栈记录 + expect(historyService.canRedoDataSource(created.id!)).toBe(true); + + dataSource.redo(created.id!); + expect(historyService.canRedoDataSource(created.id!)).toBe(false); + expect(historyService.canUndoDataSource(created.id!)).toBe(true); + }); + + test('canUndo / canRedo 委托给 historyService', () => { + expect(dataSource.canUndo('ghost')).toBe(false); + expect(dataSource.canRedo('ghost')).toBe(false); + + const created = dataSource.add({ title: 'a', type: 'base' } as any); + expect(dataSource.canUndo(created.id!)).toBe(true); + expect(dataSource.canRedo(created.id!)).toBe(false); + + dataSource.undo(created.id!); + expect(dataSource.canUndo(created.id!)).toBe(false); + expect(dataSource.canRedo(created.id!)).toBe(true); + }); + + test('undo - 栈不存在或已无可撤销时返回 null', () => { + expect(dataSource.undo('ghost')).toBeNull(); + expect(dataSource.redo('ghost')).toBeNull(); + }); +});