tmagic-editor/packages/editor/tests/unit/sdk/services-extra.spec.ts
roymondchen 94fae10ae0 feat(editor): 新增 SDK 层并将核心服务迁移至 sdk 目录
- 新增 packages/editor/src/sdk,通过 sdk.ts 对外导出
- 将 services 下的核心服务迁移至 sdk/services
- 工具函数拆分至 sdk/utils,校验逻辑迁移至 sdk/validate,新增 sdk/wiring
- services/sdk-instance.ts 负责在编辑器中实例化 SDK
- 补充 SDK 相关单元测试,新增 docs/api/editor/sdk.md 文档
2026-10-08 19:58:03 +08:00

464 lines
17 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.

// @vitest-environment node
import { describe, expect, test } from 'vitest';
import type { MApp } from '@tmagic/core';
import { HookCodeType, HookType } from '@tmagic/core';
import { LayerOffset } from '@editor/type';
import { createDsl, createSdk } from './helpers';
/** 每个节点都带 style,便于测试位置相关的操作 */
const createStyledDsl = (): MApp =>
({
id: 'app_1',
type: 'app',
items: [
{
id: 'page_1',
type: 'page',
name: 'page',
style: { width: 375, height: 800 },
items: [
{
id: 'text_1',
type: 'text',
text: 'a',
style: { position: 'absolute', left: 10, top: 10, width: 100, height: 20 },
},
{
id: 'text_2',
type: 'text',
text: 'b',
style: { position: 'absolute', left: 20, top: 40, width: 100, height: 20 },
},
{
id: 'text_3',
type: 'text',
text: 'c',
style: { position: 'absolute', left: 30, top: 70, width: 100, height: 20 },
},
{
id: 'container_1',
type: 'container',
style: { position: 'absolute', left: 0, top: 100, width: 300, height: 300 },
items: [
{
id: 'button_1',
type: 'button',
style: { position: 'absolute', left: 5, top: 5, width: 50, height: 20 },
},
],
},
],
},
{ id: 'page_2', type: 'page', name: 'page2', items: [{ id: 'text_9', type: 'text', text: 'z' }] },
],
dataSources: [{ id: 'ds_1', type: 'base', title: 'ds', fields: [{ name: 'name' }], methods: [], events: [] }],
codeBlocks: { code_1: { name: 'code', content: '() => {}', params: [] } },
}) as any;
const createStyled = () => createSdk({ dsl: createStyledDsl() });
describe('editor:选择与高亮', () => {
test('selectNextNode / selectNextPage', async () => {
const sdk = createStyled();
await sdk.editor.select('text_1');
expect((await sdk.editor.selectNextNode())?.id).toBe('text_2');
await sdk.editor.select('text_3');
expect((await sdk.editor.selectNextNode())?.id).toBeTruthy();
await sdk.editor.select('page_1');
expect((await sdk.editor.selectNextNode())?.id).toBe('page_1');
expect((await sdk.editor.selectNextPage()).id).toBe('page_2');
expect((await sdk.editor.selectNextPage()).id).toBe('page_1');
});
test('selectNextPage 没有页面 / root 时报错', async () => {
const sdk = createSdk({ dsl: null });
await expect(sdk.editor.selectNextPage()).rejects.toThrow('page不能为空');
});
test('highlight / multiSelect', async () => {
const sdk = createStyled();
sdk.editor.highlight('text_1');
expect(sdk.editor.get('highlightNode')?.id).toBe('text_1');
sdk.editor.highlight('text_1');
expect(sdk.editor.get('highlightNode')?.id).toBe('text_1');
sdk.editor.multiSelect(['text_1', 'text_2', 'text_2', 'not-exist']);
expect(sdk.editor.get('nodes').map((node) => node.id)).toEqual(['text_1', 'text_2']);
});
test('selectRoot', () => {
const sdk = createStyled();
sdk.editor.selectRoot();
expect(sdk.editor.get('node')?.id).toBe('app_1');
});
});
describe('editor:位置与层级', () => {
test('alignCenter(单个 / 多个)', async () => {
const sdk = createStyled();
const one = (await sdk.editor.alignCenter(sdk.editor.getNodeById('text_1')!)) as any;
expect(one).toBeTruthy();
const many = await sdk.editor.alignCenter([sdk.editor.getNodeById('text_2')!, sdk.editor.getNodeById('text_3')!], {
doNotPushHistory: true,
});
expect(Array.isArray(many)).toBe(true);
});
test('move 改变选中节点的位置', async () => {
const sdk = createStyled();
await sdk.editor.select('text_1');
await sdk.editor.move(5, 6);
const { style } = sdk.editor.getNodeById('text_1') as any;
expect(style.left).toBe(15);
expect(style.top).toBe(16);
// 页面不可移动
await sdk.editor.select('page_1');
await sdk.editor.move(1, 1);
});
test('moveLayer', async () => {
const sdk = createStyled();
await sdk.editor.select('text_1');
await sdk.editor.moveLayer(1);
await sdk.editor.moveLayer(LayerOffset.BOTTOM);
await sdk.editor.moveLayer(LayerOffset.TOP);
const ids = (sdk.editor.getNodeById('page_1') as any).items.map((item: any) => item.id);
expect(ids).toContain('text_1');
const withId = await sdk.editor.moveLayerAndGetHistoryId(-1);
expect(withId.historyIds.length).toBeGreaterThan(0);
});
test('moveToContainer / dragTo,含 *AndGetHistoryId', async () => {
const sdk = createStyled();
await sdk.editor.moveToContainer(
[sdk.editor.getNodeById('text_1')!, sdk.editor.getNodeById('text_2')!],
'container_1',
{
doNotSelect: true,
},
);
const container = sdk.editor.getNodeById('container_1') as any;
expect(container.items.map((item: any) => item.id)).toEqual(expect.arrayContaining(['text_1', 'text_2']));
const moved = await sdk.editor.moveToContainerAndGetHistoryId(sdk.editor.getNodeById('text_3')!, 'container_1');
expect(moved.historyIds.length).toBeGreaterThan(0);
// 同容器内排序
await sdk.editor.dragTo(sdk.editor.getNodeById('text_3')!, container, 0);
expect(container.items[0].id).toBe('text_3');
// 跨容器
const page = sdk.editor.getNodeById('page_1') as any;
const dragged = await sdk.editor.dragToAndGetHistoryId(sdk.editor.getNodeById('button_1')!, page, 0);
expect(dragged.historyIds.length).toBeGreaterThan(0);
expect(page.items[0].id).toBe('button_1');
// 目标不是容器时什么也不做
expect(
await sdk.editor.dragTo(sdk.editor.getNodeById('text_1')!, sdk.editor.getNodeById('text_2') as any, 0),
).toBeUndefined();
// 不能移动页面
await expect(sdk.editor.moveToContainer(sdk.editor.getNodeById('page_2')!, 'container_1')).rejects.toThrow();
await expect(sdk.editor.moveToContainer(sdk.editor.getNodeById('text_1')!, 'missing')).rejects.toThrow(
'目标容器不存在',
);
});
});
describe('editor:历史', () => {
test('gotoPageStep / revertPageStep / revertPageStepById', async () => {
const sdk = createStyled();
await sdk.editor.update({ id: 'text_1', text: 'a1' } as any, {
changeRecords: [{ propPath: 'text', value: 'a1' }],
});
await sdk.editor.update({ id: 'text_2', text: 'b1' } as any, {
changeRecords: [{ propPath: 'text', value: 'b1' }],
});
const added = (await sdk.editor.add({ type: 'text', text: 'new' } as any)) as any;
await sdk.editor.remove(sdk.editor.getNodeById('text_3')!);
// 回滚第一次更新,不影响后面的更新
const reverted = await sdk.editor.revertPageStep(1);
expect(reverted).toBeTruthy();
expect((sdk.editor.getNodeById('text_1') as any).text).toBe('a');
expect((sdk.editor.getNodeById('text_2') as any).text).toBe('b1');
// 回滚新增 / 删除
const listLength = sdk.history.getStepList('page', 'page_1').length;
expect(await sdk.editor.revertPageStep(3)).toBeTruthy();
expect(sdk.editor.getNodeById(added.id)).toBeFalsy();
expect(sdk.history.getStepList('page', 'page_1').length).toBeGreaterThan(listLength - 1);
// 初始基线不可回滚 / 越界
expect(await sdk.editor.revertPageStep(0)).toBeNull();
expect(await sdk.editor.revertPageStep(99)).toBeNull();
const { step } = sdk.history.getStepList('page', 'page_1')[2];
const byId = await sdk.editor.revertPageStepById([step.uuid!, 'not-found']);
expect(byId[1]).toBeNull();
const cursor = await sdk.editor.gotoPageStep(0);
expect(cursor).toBeLessThanOrEqual(1);
expect((sdk.editor.getNodeById('text_2') as any).text).toBe('b');
const end = await sdk.editor.gotoPageStep(99);
expect(end).toBeGreaterThan(0);
});
test('没有选中页面时 undo / redo 返回 null', async () => {
const sdk = createSdk({ dsl: null });
expect(await sdk.editor.undo()).toBeNull();
expect(await sdk.editor.redo()).toBeNull();
});
test('撤销新增 / 删除 / 更新各类历史', async () => {
const sdk = createStyled();
await sdk.editor.add({ type: 'text', text: 'n1' } as any);
await sdk.editor.undo();
await sdk.editor.redo();
await sdk.editor.undo();
await sdk.editor.remove(sdk.editor.getNodeById('text_1')!);
await sdk.editor.undo();
await sdk.editor.redo();
await sdk.editor.undo();
await sdk.editor.update({ id: 'text_2', text: 'u' } as any);
await sdk.editor.undo();
await sdk.editor.redo();
expect((sdk.editor.getNodeById('text_2') as any).text).toBe('u');
});
});
describe('editor:copyWithRelated / paste', () => {
test('copyWithRelated 把关联节点一起复制', async () => {
const dsl = createStyledDsl();
(dsl.items[0].items![0] as any).relate = 'text_2';
const sdk = createSdk({ dsl });
sdk.editor.copyWithRelated(sdk.editor.getNodeById('text_1')!, {
type: 'relate' as any,
isTarget: (key: string | number, value: any) => key === 'relate' && !!value,
} as any);
const copied = sdk.storage.getItem('tmagic-editor-copy' as any) ?? null;
expect(copied === null || Array.isArray(copied) || typeof copied === 'object').toBe(true);
});
test('粘贴选中节点的副本时放进它的父节点', async () => {
const sdk = createStyled();
await sdk.editor.select('text_1');
await sdk.editor.copy(sdk.editor.getNodeById('text_1')!);
const pasted = (await sdk.editor.paste({}, undefined, { doNotSelect: true })) as any;
expect(sdk.editor.getParentById(Array.isArray(pasted) ? pasted[0].id : pasted.id)?.id).toBe('page_1');
});
});
describe('props:关联 id', () => {
test('setNewItemId 记录映射,replaceRelateId 替换关联 id', () => {
const sdk = createSdk();
const origin = [
{ id: 'a', type: 'text', relate: 'b' },
{ id: 'b', type: 'text', items: [{ id: 'c', type: 'text', relate: 'a' }] },
] as any[];
const copied = JSON.parse(JSON.stringify(origin));
sdk.props.clearRelateId();
copied.forEach((config: any) => {
sdk.props.setNewItemId(config);
config.items?.forEach((item: any) => sdk.props.setNewItemId(item));
});
sdk.props.replaceRelateId(origin, copied, {
type: 'relate' as any,
isTarget: (key: string | number, value: any) => key === 'relate' && !!value,
} as any);
expect(copied[0].relate).toBe(copied[1].id);
// 没有映射时什么也不做
sdk.props.clearRelateId();
sdk.props.replaceRelateId(origin, copied, { type: 'relate' as any, isTarget: () => false } as any);
});
test('area 类型使用 button 的配置', async () => {
const sdk = createSdk({ propsValues: { button: { text: 'btn' } } });
await sdk.ready;
const value = (await sdk.props.getPropsValue('area')) as any;
expect(value.className).toBe('action-area');
expect(await sdk.props.getPropsConfig('area')).toBeTruthy();
});
test('props 的 usePlugin', async () => {
const sdk = createSdk();
sdk.props.usePlugin({ afterGetPropsConfig: async () => [{ name: 'plugin' }] } as any);
sdk.codeBlock.usePlugin({} as any);
sdk.dataSource.usePlugin({} as any);
sdk.editor.usePlugin({} as any);
});
});
describe('codeBlock', () => {
test('goto / revert / revertById / 历史', async () => {
const sdk = createStyled();
const { codeBlock } = sdk;
await codeBlock.setCodeDslById('code_2', { name: 'c2', content: '() => 1', params: [] });
await codeBlock.setCodeDslById(
'code_2',
{ name: 'c2-new', content: '() => 2', params: [] },
{
changeRecords: [{ propPath: 'name', value: 'c2-new' }],
},
);
await codeBlock.setCodeDslById(
'code_2',
{ name: 'c2-3', content: '() => 3', params: [] },
{
changeRecords: [{ propPath: '', value: {} }],
},
);
await codeBlock.deleteCodeDslByIds(['code_2']);
// 回滚删除 -> 写回
const list = sdk.history.getStepList('codeBlock', 'code_2');
expect(await codeBlock.revert('code_2', list.length - 1)).toBeTruthy();
expect(codeBlock.getCodeContentById('code_2')).toBeTruthy();
// 局部回滚更新
expect(await codeBlock.revert('code_2', 1)).toBeTruthy();
// 回滚带空 propPath 的更新 -> 整体替换
expect(await codeBlock.revert('code_2', 2)).toBeTruthy();
// 回滚新增 -> 删除
expect(await codeBlock.revert('code_2', 0)).toBeTruthy();
expect(codeBlock.getCodeContentById('code_2')).toBeFalsy();
// 更新类但没有 changeRecords 的不可回滚;越界为 null
expect(await codeBlock.revert('code_2', 99)).toBeNull();
const { step } = sdk.history.getStepList('codeBlock', 'code_2')[0];
expect((await codeBlock.revertById([step.uuid!, 'missing']))[1]).toBeNull();
// goto
const total = sdk.history.getStepList('codeBlock', 'code_2').length;
expect(await codeBlock.goto('code_2', 1)).toBe(1);
expect(await codeBlock.goto('code_2', total)).toBe(total);
expect(await codeBlock.goto('code_2', -1)).toBe(0);
});
test('undo / redo 单个代码块', async () => {
const sdk = createStyled();
await sdk.codeBlock.setCodeDslById(
'code_1',
{ name: 'changed' },
{ changeRecords: [{ propPath: 'name', value: 'changed' }] },
);
expect(await sdk.codeBlock.undo('code_1')).toBeTruthy();
expect(sdk.codeBlock.getCodeContentById('code_1')?.name).toBe('code');
expect(await sdk.codeBlock.redo('code_1')).toBeTruthy();
expect(sdk.codeBlock.getCodeContentById('code_1')?.name).toBe('changed');
});
test('copyWithRelated 复制节点关联的代码块', async () => {
const dsl = createStyledDsl();
(dsl.items[0].items![0] as any)[HookType.HOOK_TYPE] = HookType.CODE;
(dsl.items[0].items![0] as any).hooks = [
{ hookType: HookType.CODE, hookData: [{ codeType: HookCodeType.CODE, codeId: 'code_1' }] },
];
const sdk = createSdk({ dsl });
sdk.codeBlock.copyWithRelated(sdk.editor.getNodeById('text_1')!, {
type: 'code-block' as any,
isTarget: (key: string | number, value: any) => key === 'codeId' && !!value,
} as any);
sdk.codeBlock.copyWithRelated(sdk.editor.getNodeById('text_1')!);
// 不存在的节点 / 没有收集器时不报错
sdk.codeBlock.copyWithRelated([], { type: 'code-block' as any, isTarget: () => true } as any);
});
});
describe('dataSource', () => {
test('goto / revert / revertById / undo / redo', () => {
const sdk = createStyled();
const { dataSource } = sdk;
dataSource.add({ id: 'ds_2', type: 'base', title: 'two', fields: [], methods: [], events: [] } as any);
dataSource.update({ id: 'ds_2', type: 'base', title: 'three', fields: [], methods: [], events: [] } as any, {
changeRecords: [{ propPath: 'title', value: 'three' }],
});
dataSource.update({ id: 'ds_2', type: 'base', title: 'four', fields: [], methods: [], events: [] } as any, {
changeRecords: [{ propPath: '', value: {} }],
});
dataSource.update({ id: 'ds_2', type: 'base', title: 'five', fields: [], methods: [], events: [] } as any);
dataSource.remove('ds_2');
const total = sdk.history.getStepList('dataSource', 'ds_2').length;
// 回滚删除 -> 写回
expect(dataSource.revert('ds_2', total - 1)).toBeTruthy();
expect(dataSource.getDataSourceById('ds_2')).toBeTruthy();
// 局部回滚 / 整体回滚
expect(dataSource.revert('ds_2', 1)).toBeTruthy();
expect(dataSource.revert('ds_2', 2)).toBeTruthy();
// 没有 changeRecords 的更新不可回滚
expect(dataSource.revert('ds_2', 3)).toBeNull();
expect(dataSource.revert('ds_2', 99)).toBeNull();
// 回滚新增 -> 删除
expect(dataSource.revert('ds_2', 0)).toBeTruthy();
expect(dataSource.getDataSourceById('ds_2')).toBeFalsy();
const { step } = sdk.history.getStepList('dataSource', 'ds_2')[0];
expect(dataSource.revertById([step.uuid!, 'missing'])[1]).toBeNull();
const { length } = sdk.history.getStepList('dataSource', 'ds_2');
expect(dataSource.goto('ds_2', 1)).toBe(1);
expect(dataSource.goto('ds_2', length)).toBe(length);
expect(dataSource.goto('ds_2', -1)).toBe(0);
expect(dataSource.undo('ds_2')).toBeNull();
expect(dataSource.redo('ds_2')).toBeTruthy();
});
test('copyWithRelated 复制节点关联的数据源', () => {
const sdk = createStyled();
(sdk.editor.getNodeById('text_1') as any).dsField = ['ds_1', 'name'];
sdk.dataSource.copyWithRelated(sdk.editor.getNodeById('text_1')!, {
type: 'data-source' as any,
isTarget: (key: string | number, value: any) => key === 'dsField' && Array.isArray(value),
} as any);
sdk.dataSource.copyWithRelated(sdk.editor.getNodeById('text_1')!);
sdk.dataSource.copyWithRelated([], { type: 'data-source' as any, isTarget: () => true } as any);
});
});
describe('DSL 兜底', () => {
test('createDsl 夹具本身是合法的', async () => {
const sdk = createSdk({ dsl: createDsl() });
expect((await sdk.validate.dsl()).valid).toBe(true);
});
});