fix(editor): 修复历史记录面板分组与展示逻辑

统一历史记录列表在不同维度下的分组与展示行为,避免对比信息与交互状态不一致,并补齐对应单测覆盖。
This commit is contained in:
roymondchen 2026-06-17 17:14:27 +08:00
parent 24b9b34f65
commit bbf79fd6df
10 changed files with 133 additions and 467 deletions

View File

@ -135,6 +135,7 @@ import type { FormState } from '@tmagic/form';
import MIcon from '@editor/components/Icon.vue';
import { useServices } from '@editor/hooks/use-services';
import type {
BaseStepValue,
CodeBlockStepValue,
DataSourceStepValue,
DiffDialogPayload,
@ -143,15 +144,7 @@ import type {
} from '@editor/type';
import BucketTab from './BucketTab.vue';
import {
describeCodeBlockGroup,
describeCodeBlockStep,
describeDataSourceGroup,
describeDataSourceStep,
isCodeBlockStepRevertable,
isDataSourceStepRevertable,
useHistoryList,
} from './composables';
import { describeStep, isSingleDiffStepRevertable, useHistoryList } from './composables';
import HistoryDiffDialog from './HistoryDiffDialog.vue';
import PageTab from './PageTab.vue';
@ -223,34 +216,28 @@ const {
*/
const pageMarker = computed(() => historyService.getPageMarker());
/** 数据源 step 仅 update前后 schema 都存在)时可查看差异。 */
const isDataSourceStepDiffable = (step: DataSourceStepValue) =>
Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema);
/** 代码块 step 仅 update前后 content 都存在)时可查看差异。 */
const isCodeBlockStepDiffable = (step: CodeBlockStepValue) =>
Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema);
const isStepDiffable = (step: BaseStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema);
/**
* 数据源 / 代码块两类 bucket 历史的整体渲染配置 title / prefix 与各自的描述
* 可差异可回滚判定收敛为单一对象整体注入 BucketTab组件内部按需读取
*/
// / describeGroup toRowGroup 退 describeStep
const dataSourceConfig: HistoryBucketConfig<DataSourceStepValue> = {
title: '数据源',
prefix: 'ds',
describeGroup: describeDataSourceGroup,
describeStep: describeDataSourceStep,
isStepDiffable: isDataSourceStepDiffable,
isStepRevertable: isDataSourceStepRevertable,
describeStep: (step: DataSourceStepValue): string => describeStep(step, (schema) => schema?.title, '数据源'),
isStepDiffable,
isStepRevertable: isSingleDiffStepRevertable,
};
const codeBlockConfig: HistoryBucketConfig<CodeBlockStepValue> = {
title: '代码块',
prefix: 'cb',
describeGroup: describeCodeBlockGroup,
describeStep: describeCodeBlockStep,
isStepDiffable: isCodeBlockStepDiffable,
isStepRevertable: isCodeBlockStepRevertable,
describeStep: (step: CodeBlockStepValue): string => describeStep(step, (content) => content?.name, '代码块'),
isStepDiffable,
isStepRevertable: isSingleDiffStepRevertable,
};
/** 把"目标 step 索引"翻译成"目标 cursor"(已应用步骤数量)。 */

View File

@ -5,10 +5,6 @@ import { datetimeFormatter } from '@tmagic/form';
import { useServices } from '@editor/hooks/use-services';
import type {
BaseStepValue,
CodeBlockHistoryGroup,
CodeBlockStepValue,
DataSourceHistoryGroup,
DataSourceStepValue,
HistoryOpSource,
HistoryOpType,
HistoryRowDescriptor,
@ -227,12 +223,14 @@ export const toRowGroup = <T extends BaseStepValue = BaseStepValue>(
): HistoryRowGroup => {
const { describeGroup, describeStep, isStepDiffable, isStepRevertable } = descriptor;
const timestamp = groupTimestamp(group);
// 无 describeGroup 时回退到组内最后一步的 describeStep数据源/代码块不做相邻合并,每组恒为单步,二者等价。
const lastStep = group.steps[group.steps.length - 1]?.step;
return {
key,
applied: group.applied,
isCurrent: Boolean(group.isCurrent),
opType: group.opType,
desc: describeGroup(group),
desc: describeGroup ? describeGroup(group) : describeStep(lastStep),
source: groupSource(group),
time: formatHistoryTime(timestamp),
timeTitle: formatHistoryFullTime(timestamp),
@ -273,30 +271,43 @@ const pickLastDescription = (descs: (string | undefined)[]): string | undefined
return undefined;
};
export const describePageStep = (step: StepValue) => {
/**
* / /
*
* - / label退N X
* - label · propPath diff X退N X
* / / op
* id schema.id historyDescription
*/
export const describeStep = <T>(
step: BaseStepValue<T>,
getLabel: (_schema?: T) => string | number | undefined,
unit: string,
): string => {
if (step.historyDescription) return step.historyDescription;
const { opType } = step;
const items = step.diff ?? [];
if (opType === 'add') {
const count = items.length;
const label = (schema?: T) => labelWithId(getLabel(schema), (schema as { id?: string | number } | undefined)?.id);
if (step.opType === 'add') {
const node = items[0]?.newSchema;
return `新增 ${count} 个节点${count === 1 && node ? `${labelWithId(nameOf(node), node.id)}` : ''}`;
return items.length === 1 && node ? label(node) : `${items.length}${unit}`;
}
if (opType === 'remove') {
const count = items.length;
if (step.opType === 'remove') {
const node = items[0]?.oldSchema;
return `删除 ${count} 个节点${count === 1 && node ? `${labelWithId(nameOf(node), node.id)}` : ''}`;
return items.length === 1 && node ? label(node) : `${items.length}${unit}`;
}
if (!items.length) return '修改节点';
if (!items.length) return unit;
if (items.length === 1) {
const { newSchema, changeRecords } = items[0];
const propPath = changeRecords?.[0]?.propPath;
const target = labelWithId(nameOf(newSchema), newSchema?.id);
return `修改 ${target}${propPath ? ` · ${propPath}` : ''}`;
const { newSchema, oldSchema, changeRecords } = items[0];
const propPath = changeRecords?.map((changeRecord) => changeRecord.propPath).join(',');
const target = label(newSchema ?? oldSchema);
return propPath ? `${target} · ${propPath}` : target;
}
return `修改 ${items.length}节点`;
return `${items.length}${unit}`;
};
export const describePageStep = (step: StepValue): string => describeStep(step, (node) => nameOf(node), '节点');
/**
*
* - historyDescription historyDescription
@ -307,68 +318,8 @@ export const describePageGroup = (group: PageHistoryGroup) => {
const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription));
if (lastDesc) return lastDesc;
if (group.steps.length === 1) return describePageStep(group.steps[0].step);
const paths = new Set<string>();
group.steps.forEach((s) => {
s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
});
const pathList = Array.from(paths).slice(0, 3).join(', ');
const target = labelWithId(
group.targetName ?? (group.targetId !== undefined ? `${group.targetId}` : '节点'),
group.targetId,
);
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
};
export const describeDataSourceStep = (step: DataSourceStepValue) => {
if (step.historyDescription) return step.historyDescription;
const { oldSchema: oldSchema, newSchema: newSchema, changeRecords } = step.diff?.[0] ?? {};
if (!oldSchema && newSchema) return `创建 ${labelWithId(newSchema.title, newSchema.id ?? step.id)}`;
if (!newSchema && oldSchema) return `删除 ${labelWithId(oldSchema.title, oldSchema.id ?? step.id)}`;
const propPath = changeRecords?.[0]?.propPath;
const title = labelWithId(newSchema?.title || oldSchema?.title, step.id);
return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
};
export const describeDataSourceGroup = (group: DataSourceHistoryGroup) => {
const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription));
if (lastDesc) return lastDesc;
if (group.steps.length === 1) return describeDataSourceStep(group.steps[0].step);
const paths = new Set<string>();
group.steps.forEach((s) => {
s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
});
const pathList = Array.from(paths).slice(0, 3).join(', ');
const rawTitle =
group.steps[group.steps.length - 1].step.diff?.[0]?.newSchema?.title ||
group.steps[0].step.diff?.[0]?.oldSchema?.title;
const target = labelWithId(rawTitle, group.id);
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
};
export const describeCodeBlockStep = (step: CodeBlockStepValue) => {
if (step.historyDescription) return step.historyDescription;
const { oldSchema: oldContent, newSchema: newContent, changeRecords } = step.diff?.[0] ?? {};
if (!oldContent && newContent) return `创建 ${labelWithId(newContent.name, newContent.id ?? step.id)}`;
if (!newContent && oldContent) return `删除 ${labelWithId(oldContent.name, oldContent.id ?? step.id)}`;
const propPath = changeRecords?.[0]?.propPath;
const title = labelWithId(newContent?.name || oldContent?.name, step.id);
return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
};
export const describeCodeBlockGroup = (group: CodeBlockHistoryGroup) => {
const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription));
if (lastDesc) return lastDesc;
if (group.steps.length === 1) return describeCodeBlockStep(group.steps[0].step);
const paths = new Set<string>();
group.steps.forEach((s) => {
s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
});
const pathList = Array.from(paths).slice(0, 3).join(', ');
const rawName =
group.steps[group.steps.length - 1].step.diff?.[0]?.newSchema?.name ||
group.steps[0].step.diff?.[0]?.oldSchema?.name;
const target = labelWithId(rawName, group.id);
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
return labelWithId(group.targetName ?? (group.targetId !== undefined ? `${group.targetId}` : '节点'), group.targetId);
};
/**
@ -385,22 +336,11 @@ export const isPageStepRevertable = (step: StepValue): boolean => {
};
/**
* step
* diff /
* - oldSchema/ newSchema changeRecords
* - schema changeRecords patch
* - changeRecords patch
*/
export const isDataSourceStepRevertable = (step: DataSourceStepValue): boolean => {
const item = step.diff?.[0];
if (!item?.oldSchema || !item?.newSchema) return true;
return Boolean(item.changeRecords?.length);
};
/**
* step
* - oldSchema/ newSchema changeRecords
* - content changeRecords patch
*/
export const isCodeBlockStepRevertable = (step: CodeBlockStepValue): boolean => {
export const isSingleDiffStepRevertable = (step: BaseStepValue): boolean => {
const item = step.diff?.[0];
if (!item?.oldSchema || !item?.newSchema) return true;
return Boolean(item.changeRecords?.length);

View File

@ -23,9 +23,7 @@ import type { ChangeRecord } from '@tmagic/form';
import { guid } from '@tmagic/utils';
import type {
CodeBlockHistoryGroup,
CodeBlockStepValue,
DataSourceHistoryGroup,
DataSourceStepValue,
HistoryOpSource,
HistoryPersistOptions,
@ -33,6 +31,7 @@ import type {
PageHistoryGroup,
PageHistoryStepEntry,
PersistedHistoryState,
StackHistoryGroup,
StepValue,
} from '@editor/type';
import { getEditorConfig } from '@editor/utils/config';
@ -527,8 +526,8 @@ class History extends BaseService {
* codeBlockId
* update
*/
public getCodeBlockHistoryGroups(): CodeBlockHistoryGroup[] {
const groups: CodeBlockHistoryGroup[] = [];
public getCodeBlockHistoryGroups(): StackHistoryGroup<CodeBlockStepValue, 'code-block'>[] {
const groups: StackHistoryGroup<CodeBlockStepValue, 'code-block'>[] = [];
Object.entries(this.state.codeBlockState).forEach(([id, undoRedo]) => {
if (!undoRedo) return;
const list = undoRedo.getElementList();
@ -619,8 +618,8 @@ class History extends BaseService {
/**
* dataSourceId
*/
public getDataSourceHistoryGroups(): DataSourceHistoryGroup[] {
const groups: DataSourceHistoryGroup[] = [];
public getDataSourceHistoryGroups(): StackHistoryGroup<DataSourceStepValue, 'data-source'>[] {
const groups: StackHistoryGroup<DataSourceStepValue, 'data-source'>[] = [];
Object.entries(this.state.dataSourceState).forEach(([id, undoRedo]) => {
if (!undoRedo) return;
const list = undoRedo.getElementList();

View File

@ -962,32 +962,26 @@ export interface PageHistoryGroup {
}
/**
*
* - codeBlockId 'update' group
* - 'add' / 'remove'
* / id
* `kind` step
* - `StackHistoryGroup<DataSourceStepValue, 'data-source'>`
* - `StackHistoryGroup<CodeBlockStepValue, 'code-block'>`
*
* {@link PageHistoryGroup} `steps`
*/
export interface CodeBlockHistoryGroup {
kind: 'code-block';
/** 关联的 codeBlock id */
export interface StackHistoryGroup<
T extends BaseStepValue = BaseStepValue,
K extends 'code-block' | 'data-source' = 'code-block' | 'data-source',
> {
/** 区分代码块 / 数据源。 */
kind: K;
/** 关联的代码块 / 数据源 id。 */
id: Id;
/** 该分组的操作类型 */
/** 该分组的操作类型 */
opType: HistoryOpType;
/** 组内所有步骤,按时间正序 */
steps: { step: CodeBlockStepValue; index: number; applied: boolean; isCurrent?: boolean }[];
/** 组内最后一步是否已应用,用于整组的状态展示 */
applied: boolean;
/** 是否为当前所在的分组(包含该栈最近一次已应用步骤的那一组)。 */
isCurrent?: boolean;
}
/**
* CodeBlockHistoryGroup
*/
export interface DataSourceHistoryGroup {
kind: 'data-source';
id: Id;
opType: HistoryOpType;
steps: { step: DataSourceStepValue; index: number; applied: boolean; isCurrent?: boolean }[];
/** 组内所有步骤,按时间正序(不做相邻合并,恒为单元素)。 */
steps: { step: T; index: number; applied: boolean; isCurrent?: boolean }[];
/** 组内最后一步是否已应用,用于整组的状态展示。 */
applied: boolean;
/** 是否为当前所在的分组(包含该栈最近一次已应用步骤的那一组)。 */
isCurrent?: boolean;
@ -1296,8 +1290,11 @@ export interface DiffDialogPayload {
* describe* / isStep* props
*/
export interface HistoryRowDescriptor<T extends BaseStepValue = BaseStepValue> {
/** 组级描述文案生成器,接收一个 group返回展示文本。 */
describeGroup: (_group: any) => string;
/**
* group
* 退 {@link describeStep}/
*/
describeGroup?: (_group: any) => string;
/** 单步描述文案生成器,接收一个 step返回展示文本合并组展开后的子步列表用。 */
describeStep: (_step: T) => string;
/** 判断某个 step 是否可查看差异(前后值都存在)。不传则一律不展示差异入口。 */

View File

@ -26,9 +26,9 @@ import { guid } from '@tmagic/utils';
import type {
BaseStepValue,
HistoryOpSource,
HistoryOpType,
PageHistoryGroup,
PageHistoryStepEntry,
StackHistoryGroup,
StepDiffItem,
StepValue,
} from '@editor/type';
@ -131,14 +131,7 @@ export const mergeStackSteps = <S extends BaseStepValue, K extends 'code-block'
id: Id,
list: S[],
cursor: number,
): {
kind: K;
id: Id;
opType: HistoryOpType;
steps: { step: S; index: number; applied: boolean; isCurrent?: boolean }[];
applied: boolean;
isCurrent?: boolean;
}[] => {
): StackHistoryGroup<S, K>[] => {
const currentIndex = cursor - 1;
return list.map((step, index) => {
const applied = index < cursor;

View File

@ -8,8 +8,8 @@ import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import BucketTab from '@editor/layouts/history-list/BucketTab.vue';
import { describeCodeBlockGroup, describeCodeBlockStep } from '@editor/layouts/history-list/composables';
import type { CodeBlockHistoryGroup, CodeBlockStepValue } from '@editor/type';
import { describeStep } from '@editor/layouts/history-list/composables';
import type { CodeBlockStepValue, HistoryBucketConfig, StackHistoryGroup } from '@editor/type';
vi.mock('@tmagic/design', () => ({
TMagicScrollbar: defineComponent({
@ -40,7 +40,7 @@ const buildGroup = (
steps: any[],
applied = true,
startIndex = 0,
): CodeBlockHistoryGroup => ({
): StackHistoryGroup<CodeBlockStepValue, 'code-block'> => ({
kind: 'code-block',
id,
opType,
@ -49,16 +49,17 @@ const buildGroup = (
});
/** 代码块 tab 复用通用 BucketTab固定注入代码块的 configtitle/prefix/describe/isStepDiffable。 */
const codeBlockConfig: HistoryBucketConfig<any> = {
title: '代码块',
prefix: 'cb',
describeStep: (step: CodeBlockStepValue): string => describeStep(step, (content) => content?.name, '代码块'),
isStepDiffable: (step: CodeBlockStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema),
};
const mountCodeBlockTab = (props: { buckets: any[]; expanded: Record<string, boolean> }) =>
mount(BucketTab, {
props: {
config: {
title: '代码块',
prefix: 'cb',
describeGroup: describeCodeBlockGroup,
describeStep: describeCodeBlockStep,
isStepDiffable: (step: CodeBlockStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema),
},
config: codeBlockConfig,
...props,
},
});
@ -83,7 +84,7 @@ describe('CodeBlockTab.vue', () => {
expect(wrapper.find('.m-editor-history-list-bucket-title code').text()).toBe('code_1');
const desc = wrapper.find('.m-editor-history-list-item-desc').text();
expect(desc).toBe('创建 fn (id: code_1)');
expect(desc).toBe('fn (id: code_1)');
});
test('toggle 透传key 形如 cb-${id}-${idx}', async () => {
@ -178,7 +179,7 @@ describe('CodeBlockTab.vue', () => {
const items = wrapper.findAll('.m-editor-history-list-substeps li');
expect(items).toHaveLength(2);
// 子步倒序渲染最新在上params 在前content 在后
expect(items[0].text()).toContain('修改 fn (id: code_1) · params');
expect(items[1].text()).toContain('修改 fn (id: code_1) · content');
expect(items[0].text()).toContain('fn (id: code_1) · params');
expect(items[1].text()).toContain('fn (id: code_1) · content');
});
});

View File

@ -8,8 +8,8 @@ import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import BucketTab from '@editor/layouts/history-list/BucketTab.vue';
import { describeDataSourceGroup, describeDataSourceStep } from '@editor/layouts/history-list/composables';
import type { DataSourceHistoryGroup, DataSourceStepValue } from '@editor/type';
import { describeStep } from '@editor/layouts/history-list/composables';
import type { DataSourceStepValue, HistoryBucketConfig, StackHistoryGroup } from '@editor/type';
vi.mock('@tmagic/design', () => ({
TMagicScrollbar: defineComponent({
@ -40,7 +40,7 @@ const buildGroup = (
steps: any[],
applied = true,
startIndex = 0,
): DataSourceHistoryGroup => ({
): StackHistoryGroup<DataSourceStepValue, 'data-source'> => ({
kind: 'data-source',
id,
opType,
@ -49,16 +49,17 @@ const buildGroup = (
});
/** 数据源 tab 复用通用 BucketTab固定注入数据源的 configtitle/prefix/describe/isStepDiffable。 */
const dataSourceConfig: HistoryBucketConfig<any> = {
title: '数据源',
prefix: 'ds',
describeStep: (step: DataSourceStepValue): string => describeStep(step, (schema) => schema?.title, '数据源'),
isStepDiffable: (step: DataSourceStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema),
};
const mountDataSourceTab = (props: { buckets: any[]; expanded: Record<string, boolean> }) =>
mount(BucketTab, {
props: {
config: {
title: '数据源',
prefix: 'ds',
describeGroup: describeDataSourceGroup,
describeStep: describeDataSourceStep,
isStepDiffable: (step: DataSourceStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema),
},
config: dataSourceConfig,
...props,
},
});
@ -92,9 +93,9 @@ describe('DataSourceTab.vue', () => {
const rows = wrapper.findAll('.m-editor-history-list-group');
expect(rows).toHaveLength(2);
expect(rows[0].find('.m-editor-history-list-item-op').text()).toBe('新增');
expect(rows[0].find('.m-editor-history-list-item-desc').text()).toBe('创建 A (id: ds_1)');
expect(rows[0].find('.m-editor-history-list-item-desc').text()).toBe('A (id: ds_1)');
expect(rows[1].find('.m-editor-history-list-item-op').text()).toBe('删除');
expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('删除 B (id: ds_2)');
expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('B (id: ds_2)');
});
test('toggle 透传key 形如 ds-${id}-${idx}', async () => {

View File

@ -158,9 +158,9 @@ describe('HistoryListPanel.vue', () => {
expect(rows.length).toBe(3);
const descs = rows.map((r) => r.find('.m-editor-history-list-item-desc').text());
expect(descs.some((t) => t.includes('新增 1 个节点'))).toBe(true);
expect(descs.some((t) => t === '创建 DS (id: ds_1)')).toBe(true);
expect(descs.some((t) => t === '创建 CB (id: code_1)')).toBe(true);
expect(descs.some((t) => t === 'A (id: n1)')).toBe(true);
expect(descs.some((t) => t === 'DS (id: ds_1)')).toBe(true);
expect(descs.some((t) => t === 'CB (id: code_1)')).toBe(true);
});
test('点击合并组头部能切换 expanded 状态(不触发 goto', async () => {
@ -288,7 +288,7 @@ describe('HistoryListPanel.vue', () => {
const heads = wrapper.findAll('.m-editor-history-list-group-head');
// 找到数据源 tab 那一组
const dsHead = heads.find((h) => h.text().includes('创建 DS'));
const dsHead = heads.find((h) => h.text().includes('DS (id: ds_1)'));
expect(dsHead).toBeTruthy();
await dsHead!.find('.m-editor-history-list-item-goto').trigger('click');
expect(dataSourceService.goto).toHaveBeenCalledWith('ds_1', 1);
@ -307,7 +307,7 @@ describe('HistoryListPanel.vue', () => {
await nextTick();
const heads = wrapper.findAll('.m-editor-history-list-group-head');
const cbHead = heads.find((h) => h.text().includes('创建 CB'));
const cbHead = heads.find((h) => h.text().includes('CB (id: code_1)'));
expect(cbHead).toBeTruthy();
await cbHead!.find('.m-editor-history-list-item-goto').trigger('click');
expect(codeBlockService.goto).toHaveBeenCalledWith('code_1', 1);

View File

@ -71,10 +71,10 @@ describe('PageTab.vue', () => {
expect(rows).toHaveLength(2);
// 第一组 add
expect(rows[0].find('.m-editor-history-list-item-op').text()).toBe('新增');
expect(rows[0].find('.m-editor-history-list-item-desc').text()).toContain('新增 1 个节点');
expect(rows[0].find('.m-editor-history-list-item-desc').text()).toBe('A (id: n1)');
// 第二组 update
expect(rows[1].find('.m-editor-history-list-item-op').text()).toBe('修改');
expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('修改 按钮 (id: btn) · style.color');
expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('按钮 (id: btn) · style.color');
});
test('step 含 timestamp 时渲染时间元素', () => {

View File

@ -8,31 +8,18 @@ import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import {
describeCodeBlockGroup,
describeCodeBlockStep,
describeDataSourceGroup,
describeDataSourceStep,
describePageGroup,
describePageStep,
formatHistoryFullTime,
formatHistoryTime,
groupTimestamp,
isCodeBlockStepRevertable,
isDataSourceStepRevertable,
isPageStepRevertable,
isSingleDiffStepRevertable,
opLabel,
useHistoryList,
} from '@editor/layouts/history-list/composables';
import historyService from '@editor/services/history';
import type {
CodeBlockHistoryGroup,
CodeBlockStepValue,
DataSourceHistoryGroup,
DataSourceStepValue,
PageHistoryGroup,
PageHistoryStepEntry,
StepValue,
} from '@editor/type';
import type { PageHistoryGroup, PageHistoryStepEntry, StepValue } from '@editor/type';
afterEach(() => {
historyService.reset();
@ -111,7 +98,7 @@ describe('describePageStep', () => {
opType: 'add',
diff: [{ newSchema: { id: 'btn_1', type: 'button', name: '主按钮' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('新增 1 个节点(主按钮 (id: btn_1)');
expect(describePageStep(step)).toBe('主按钮 (id: btn_1)');
});
test('add 节点无 name 但有 type使用 type 作为名称', () => {
@ -119,7 +106,7 @@ describe('describePageStep', () => {
opType: 'add',
diff: [{ newSchema: { id: 'n1', type: 'text' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('新增 1 个节点(text (id: n1)');
expect(describePageStep(step)).toBe('text (id: n1)');
});
test('add 节点 name 与 id 相同:仅显示 id', () => {
@ -127,7 +114,7 @@ describe('describePageStep', () => {
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'n1' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('新增 1 个节点(n1');
expect(describePageStep(step)).toBe('n1');
});
test('add 多个节点:仅给出数量', () => {
@ -135,12 +122,12 @@ describe('describePageStep', () => {
opType: 'add',
diff: [{ newSchema: { id: 'a' } }, { newSchema: { id: 'b' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('新增 2 个节点');
expect(describePageStep(step)).toBe('2 个节点');
});
test('add 无 nodescount 为 0 且不附名称', () => {
const step = { opType: 'add' } as unknown as StepValue;
expect(describePageStep(step)).toBe('新增 0 个节点');
expect(describePageStep(step)).toBe('0 个节点');
});
test('remove 单个节点:含名称与 id', () => {
@ -148,7 +135,7 @@ describe('describePageStep', () => {
opType: 'remove',
diff: [{ oldSchema: { id: 'btn_1', name: '主按钮' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('删除 1 个节点(主按钮 (id: btn_1)');
expect(describePageStep(step)).toBe('主按钮 (id: btn_1)');
});
test('remove 多个节点', () => {
@ -156,7 +143,7 @@ describe('describePageStep', () => {
opType: 'remove',
diff: [{ oldSchema: { id: 'a' } }, { oldSchema: { id: 'b' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('删除 2 个节点');
expect(describePageStep(step)).toBe('2 个节点');
});
test('update 单节点:附 propPath 与 id', () => {
@ -170,7 +157,7 @@ describe('describePageStep', () => {
},
],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('修改 按钮 (id: btn_1) · style.color');
expect(describePageStep(step)).toBe('按钮 (id: btn_1) · style.color');
});
test('update 单节点无 propPath仅展示节点', () => {
@ -178,7 +165,7 @@ describe('describePageStep', () => {
opType: 'update',
diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('修改 按钮 (id: btn_1)');
expect(describePageStep(step)).toBe('按钮 (id: btn_1)');
});
test('update 多节点:返回数量', () => {
@ -189,12 +176,12 @@ describe('describePageStep', () => {
{ newSchema: { id: 'b' }, oldSchema: { id: 'b' } },
],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('修改 2 个节点');
expect(describePageStep(step)).toBe('2 个节点');
});
test('update diff 缺省:兜底为「修改节点」', () => {
test('update diff 缺省:兜底为「节点」', () => {
const step = { opType: 'update' } as unknown as StepValue;
expect(describePageStep(step)).toBe('修改节点');
expect(describePageStep(step)).toBe('节点');
});
});
@ -230,10 +217,10 @@ describe('describePageGroup', () => {
applied: true,
steps: [buildPageEntry(step)],
};
expect(describePageGroup(group)).toBe('修改 A (id: a)');
expect(describePageGroup(group)).toBe('A (id: a)');
});
test('多步合并组:聚合 propPath 列表', () => {
test('多步合并组:展示目标名称与 id', () => {
const mkStep = (path: string) =>
({
opType: 'update',
@ -255,10 +242,10 @@ describe('describePageGroup', () => {
applied: true,
steps: [buildPageEntry(mkStep('style.color'), 0), buildPageEntry(mkStep('style.fontSize'), 1)],
};
expect(describePageGroup(group)).toBe('修改 按钮 (id: btn_1) · style.color, style.fontSize');
expect(describePageGroup(group)).toBe('按钮 (id: btn_1)');
});
test('多步合并组:超过 3 个 propPath 时截断并加省略号', () => {
test('多步合并组:多步时仍仅展示目标', () => {
const mkStep = (path: string) =>
({
opType: 'update',
@ -285,9 +272,7 @@ describe('describePageGroup', () => {
buildPageEntry(mkStep('d'), 3),
],
};
const desc = describePageGroup(group);
expect(desc).toContain('修改 按钮 (id: btn_1) · a, b, c');
expect(desc.endsWith('…')).toBe(true);
expect(describePageGroup(group)).toBe('按钮 (id: btn_1)');
});
test('多步合并组无 propPath 时仅展示目标', () => {
@ -306,7 +291,7 @@ describe('describePageGroup', () => {
applied: true,
steps: [buildPageEntry(mkStep(), 0), buildPageEntry(mkStep(), 1)],
};
expect(describePageGroup(group)).toBe('修改 按钮 (id: btn_1)');
expect(describePageGroup(group)).toBe('按钮 (id: btn_1)');
});
test('多步组 targetName 缺省时使用 targetId 兜底', () => {
@ -322,226 +307,7 @@ describe('describePageGroup', () => {
],
};
// targetName 为 undefinedlabelWithId 看 label === id 时只展示 id
expect(describePageGroup(group)).toBe('修改 btn_1');
});
});
describe('describeDataSourceStep', () => {
test('historyDescription 优先', () => {
const step = {
id: 'ds_1',
opType: 'update',
diff: [{}],
historyDescription: '自定义',
} as unknown as DataSourceStepValue;
expect(describeDataSourceStep(step)).toBe('自定义');
});
test('新增oldSchema=null展示 title 与 id', () => {
const step = {
id: 'ds_1',
opType: 'add',
diff: [{ newSchema: { id: 'ds_1', title: '用户列表' } }],
} as unknown as DataSourceStepValue;
expect(describeDataSourceStep(step)).toBe('创建 用户列表 (id: ds_1)');
});
test('删除newSchema=null展示 title 与 id', () => {
const step = {
id: 'ds_1',
opType: 'remove',
diff: [{ oldSchema: { id: 'ds_1', title: '用户列表' } }],
} as unknown as DataSourceStepValue;
expect(describeDataSourceStep(step)).toBe('删除 用户列表 (id: ds_1)');
});
test('修改:展示 propPath', () => {
const step = {
id: 'ds_1',
opType: 'update',
diff: [
{
oldSchema: { id: 'ds_1', title: '用户列表' },
newSchema: { id: 'ds_1', title: '用户列表' },
changeRecords: [{ propPath: 'fields.0.name' }],
},
],
} as unknown as DataSourceStepValue;
expect(describeDataSourceStep(step)).toBe('修改 用户列表 (id: ds_1) · fields.0.name');
});
test('修改无 title 时仅展示 id', () => {
const step = {
id: 'ds_1',
opType: 'update',
diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' } }],
} as unknown as DataSourceStepValue;
expect(describeDataSourceStep(step)).toBe('修改 ds_1');
});
});
describe('describeDataSourceGroup', () => {
test('多步组:聚合 propPath 与目标 id', () => {
const mkStep = (path: string) =>
({
id: 'ds_1',
opType: 'update',
diff: [
{
oldSchema: { id: 'ds_1', title: 'T' },
newSchema: { id: 'ds_1', title: 'T' },
changeRecords: [{ propPath: path }],
},
],
}) as unknown as DataSourceStepValue;
const group: DataSourceHistoryGroup = {
kind: 'data-source',
id: 'ds_1',
opType: 'update',
applied: true,
steps: [
{ step: mkStep('a'), index: 0, applied: true },
{ step: mkStep('b'), index: 1, applied: true },
],
};
expect(describeDataSourceGroup(group)).toBe('修改 T (id: ds_1) · a, b');
});
test('单步组:复用 describeDataSourceStep', () => {
const group: DataSourceHistoryGroup = {
kind: 'data-source',
id: 'ds_1',
opType: 'add',
applied: true,
steps: [
{
step: {
id: 'ds_1',
opType: 'add',
diff: [{ newSchema: { id: 'ds_1', title: 'T' } }],
} as unknown as DataSourceStepValue,
index: 0,
applied: true,
},
],
};
expect(describeDataSourceGroup(group)).toBe('创建 T (id: ds_1)');
});
test('historyDescription 优先', () => {
const group: DataSourceHistoryGroup = {
kind: 'data-source',
id: 'ds_1',
opType: 'update',
applied: true,
steps: [
{
step: {
id: 'ds_1',
opType: 'update',
diff: [{}],
historyDescription: '我的描述',
} as unknown as DataSourceStepValue,
index: 0,
applied: true,
},
],
};
expect(describeDataSourceGroup(group)).toBe('我的描述');
});
});
describe('describeCodeBlockStep', () => {
test('新增', () => {
const step = {
id: 'code_1',
opType: 'add',
diff: [{ newSchema: { id: 'code_1', name: 'onClick' } }],
} as unknown as CodeBlockStepValue;
expect(describeCodeBlockStep(step)).toBe('创建 onClick (id: code_1)');
});
test('删除', () => {
const step = {
id: 'code_1',
opType: 'remove',
diff: [{ oldSchema: { id: 'code_1', name: 'onClick' } }],
} as unknown as CodeBlockStepValue;
expect(describeCodeBlockStep(step)).toBe('删除 onClick (id: code_1)');
});
test('修改 + propPath', () => {
const step = {
id: 'code_1',
opType: 'update',
diff: [
{
oldSchema: { id: 'code_1', name: 'onClick' },
newSchema: { id: 'code_1', name: 'onClick' },
changeRecords: [{ propPath: 'content' }],
},
],
} as unknown as CodeBlockStepValue;
expect(describeCodeBlockStep(step)).toBe('修改 onClick (id: code_1) · content');
});
test('historyDescription 优先', () => {
const step = {
id: 'code_1',
opType: 'update',
diff: [{}],
historyDescription: '自定义说明',
} as unknown as CodeBlockStepValue;
expect(describeCodeBlockStep(step)).toBe('自定义说明');
});
});
describe('describeCodeBlockGroup', () => {
test('多步组:聚合 propPath', () => {
const mkStep = (path: string) =>
({
id: 'code_1',
opType: 'update',
diff: [
{
oldSchema: { id: 'code_1', name: 'fn' },
newSchema: { id: 'code_1', name: 'fn' },
changeRecords: [{ propPath: path }],
},
],
}) as unknown as CodeBlockStepValue;
const group: CodeBlockHistoryGroup = {
kind: 'code-block',
id: 'code_1',
opType: 'update',
applied: true,
steps: [
{ step: mkStep('content'), index: 0, applied: true },
{ step: mkStep('params'), index: 1, applied: true },
],
};
expect(describeCodeBlockGroup(group)).toBe('修改 fn (id: code_1) · content, params');
});
test('单步组:复用 step 描述', () => {
const group: CodeBlockHistoryGroup = {
kind: 'code-block',
id: 'code_1',
opType: 'remove',
applied: false,
steps: [
{
step: {
id: 'code_1',
opType: 'remove',
diff: [{ oldSchema: { id: 'code_1', name: 'fn' } }],
} as unknown as CodeBlockStepValue,
index: 0,
applied: false,
},
],
};
expect(describeCodeBlockGroup(group)).toBe('删除 fn (id: code_1)');
expect(describePageGroup(group)).toBe('btn_1');
});
});
@ -684,38 +450,20 @@ describe('isPageStepRevertable', () => {
});
});
describe('isDataSourceStepRevertable', () => {
describe('isSingleDiffStepRevertable', () => {
test('新增 / 删除 始终可回滚', () => {
expect(isDataSourceStepRevertable({ diff: [{ newSchema: { id: 'ds_1' } }] } as any)).toBe(true);
expect(isDataSourceStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' } }] } as any)).toBe(true);
expect(isSingleDiffStepRevertable({ diff: [{ newSchema: { id: 'ds_1' } }] } as any)).toBe(true);
expect(isSingleDiffStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' } }] } as any)).toBe(true);
});
test('更新有 changeRecords 才可回滚', () => {
expect(
isDataSourceStepRevertable({
isSingleDiffStepRevertable({
diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' }, changeRecords: [{ propPath: 'title' }] }],
} as any),
).toBe(true);
expect(
isDataSourceStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' } }] } as any),
).toBe(false);
});
});
describe('isCodeBlockStepRevertable', () => {
test('新增 / 删除 始终可回滚', () => {
expect(isCodeBlockStepRevertable({ diff: [{ newSchema: { id: 'code_1' } }] } as any)).toBe(true);
expect(isCodeBlockStepRevertable({ diff: [{ oldSchema: { id: 'code_1' } }] } as any)).toBe(true);
});
test('更新有 changeRecords 才可回滚', () => {
expect(
isCodeBlockStepRevertable({
diff: [{ oldSchema: { id: 'code_1' }, newSchema: { id: 'code_1' }, changeRecords: [{ propPath: 'content' }] }],
} as any),
).toBe(true);
expect(
isCodeBlockStepRevertable({ diff: [{ oldSchema: { id: 'code_1' }, newSchema: { id: 'code_1' } }] } as any),
isSingleDiffStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' } }] } as any),
).toBe(false);
});
});