mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-10-28 13:30:46 +00:00
- 新增 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 文档
464 lines
17 KiB
TypeScript
464 lines
17 KiB
TypeScript
// @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);
|
||
});
|
||
});
|