tmagic-editor/packages/data-source/tests/createDataSourceManager.spec.ts
2026-05-14 15:26:22 +08:00

844 lines
23 KiB
TypeScript
Raw 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.

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import TMagicApp, { type MApp, NodeType } from '@tmagic/core';
import { createDataSourceManager, DataSource, DataSourceManager } from '@data-source/index';
const createDsl = (): MApp => ({
type: NodeType.ROOT,
id: 'app_1',
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
type: 'text',
id: 61705611,
text: '{{ds_bebcb2d5.text}}',
},
],
},
],
dataSourceDeps: {
ds_bebcb2d5: {
61705611: {
name: '文本',
keys: ['text'],
},
},
},
dataSources: [
{
id: 'ds_bebcb2d5',
type: 'http',
fields: [
{
name: 'text',
},
],
methods: [],
events: [],
},
],
});
afterEach(() => {
DataSourceManager.clearDataSourceClass();
});
describe('createDataSourceManager - 基础', () => {
test('instance', () => {
const manager = createDataSourceManager(new TMagicApp({ config: createDsl() }));
expect(manager).toBeInstanceOf(DataSourceManager);
});
test('dsl 中没有 dataSources 时返回 undefined', () => {
const app = new TMagicApp({
config: {
type: NodeType.ROOT,
id: 'app_no_ds',
items: [],
},
});
const manager = createDataSourceManager(app);
expect(manager).toBeUndefined();
});
test('app 没有 dsl 时返回 undefined', () => {
const app = new TMagicApp({});
const manager = createDataSourceManager(app);
expect(manager).toBeUndefined();
});
test('useMock 透传到 DataSourceManager', () => {
const manager = createDataSourceManager(new TMagicApp({ config: createDsl() }), true);
expect(manager?.useMock).toBe(true);
});
test('initialData 透传到 DataSourceManager', () => {
const manager = createDataSourceManager(new TMagicApp({ config: createDsl() }), false, {
ds_bebcb2d5: { text: 'preset' },
});
expect(manager?.initialData.ds_bebcb2d5).toEqual({ text: 'preset' });
expect(manager?.data.ds_bebcb2d5.text).toBe('preset');
});
});
describe('createDataSourceManager - 初始化阶段编译', () => {
test('platform!=editor && 存在 dataSourceCondDeps 时按节点写入 condResult', () => {
const dsl: MApp = {
type: NodeType.ROOT,
id: 'app_cond',
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
type: 'text',
id: 'cond_node',
text: 'hello',
displayConds: [{ cond: [{ field: ['ds_1', 'a'], op: '=', value: 1 }] }],
} as any,
],
},
],
dataSourceCondDeps: {
ds_1: {
cond_node: { name: '文本', keys: ['displayConds'] },
},
},
dataSourceDeps: {},
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [{ name: 'a', defaultValue: 1 }],
methods: [],
events: [],
},
],
};
const app = new TMagicApp({ config: dsl, platform: 'mobile' });
createDataSourceManager(app);
const node: any = (app.dsl?.items[0] as any).items[0];
expect(node.condResult).toBe(true);
});
test('platform=editor 时初始化不写入 condResult', () => {
const dsl: MApp = {
type: NodeType.ROOT,
id: 'app_cond_editor',
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
type: 'text',
id: 'cond_node',
text: 'hello',
displayConds: [{ cond: [{ field: ['ds_1', 'a'], op: '=', value: 1 }] }],
} as any,
],
},
],
dataSourceCondDeps: {
ds_1: {
cond_node: { name: '文本', keys: ['displayConds'] },
},
},
dataSourceDeps: {},
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [{ name: 'a', defaultValue: 1 }],
methods: [],
events: [],
},
],
};
const app = new TMagicApp({ config: dsl, platform: 'editor' });
createDataSourceManager(app);
const node: any = (app.dsl?.items[0] as any).items[0];
expect(node.condResult).toBeUndefined();
});
test('存在 dataSourceDeps 时初始化即编译节点字段(模板)', () => {
const dsl: MApp = {
type: NodeType.ROOT,
id: 'app_dep',
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
type: 'text',
id: 'dep_node',
text: 'hello ${ds_1.name}',
} as any,
],
},
],
dataSourceDeps: {
ds_1: {
dep_node: { name: '文本', keys: ['text'] },
},
},
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [{ name: 'name', defaultValue: 'world' }],
methods: [],
events: [],
},
],
};
const app = new TMagicApp({ config: dsl, platform: 'mobile' });
createDataSourceManager(app);
const node: any = (app.dsl?.items[0] as any).items[0];
expect(node.text).toBe('hello world');
});
});
describe('createDataSourceManager - jsEngine=nodejs', () => {
test('nodejs 环境下不监听 change触发 setData 不会走 update-data', () => {
const app = new TMagicApp({ config: createDsl(), jsEngine: 'nodejs' });
const manager = createDataSourceManager(app);
expect(manager).toBeInstanceOf(DataSourceManager);
expect(manager?.listenerCount('change')).toBe(0);
const updateSpy = vi.fn();
manager?.on('update-data', updateSpy);
const ds = manager?.get('ds_bebcb2d5');
ds?.setData({ text: 'changed' });
expect(updateSpy).not.toHaveBeenCalled();
});
});
describe('createDataSourceManager - change 事件', () => {
let app: TMagicApp;
let manager: DataSourceManager | undefined;
beforeEach(() => {
const dsl: MApp = {
type: NodeType.ROOT,
id: 'app_change',
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
type: 'text',
id: 'text_1',
text: 'origin ${ds_1.name}',
} as any,
],
},
],
dataSourceDeps: {
ds_1: {
text_1: { name: '文本', keys: ['text'] },
},
},
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [{ name: 'name', defaultValue: 'world' }],
methods: [],
events: [],
},
],
};
app = new TMagicApp({ config: dsl, platform: 'mobile' });
manager = createDataSourceManager(app);
});
test('change 事件触发后会发出 update-data并携带新节点 / sourceId / pageId', () => {
const update = vi.fn();
manager?.on('update-data', update);
const ds = manager?.get('ds_1');
ds?.setData({ name: 'new' });
expect(update).toHaveBeenCalledTimes(1);
const [newNodes, sourceId, , pageId] = update.mock.calls[0];
expect(sourceId).toBe('ds_1');
expect(pageId).toBe('page_1');
expect(newNodes[0].id).toBe('text_1');
expect(newNodes[0].text).toBe('origin new');
});
test('change 事件会调用 page.setData 并触发节点 setData', () => {
const node = app.getNode('text_1');
const setDataSpy = vi.spyOn(node!, 'setData');
const ds = manager?.get('ds_1');
ds?.setData({ name: 'second' });
expect(setDataSpy).toHaveBeenCalled();
const calledArg = setDataSpy.mock.calls[0][0] as any;
expect(calledArg.text).toBe('origin second');
});
test('依赖中的节点不存在时不会发出 update-data', () => {
const update = vi.fn();
manager?.on('update-data', update);
if (app.dsl?.dataSourceDeps) {
app.dsl.dataSourceDeps = {};
}
const ds = manager?.get('ds_1');
ds?.setData({ name: 'noop' });
expect(update).not.toHaveBeenCalled();
});
test('page 自身被命中时调用 app.page.setData', () => {
// 把 page 自己加入到依赖中
if (app.dsl?.dataSourceDeps) {
app.dsl.dataSourceDeps.ds_1 = {
page_1: { name: 'page', keys: ['style'] },
} as any;
}
const pageSetData = vi.spyOn(app.page!, 'setData');
const ds = manager?.get('ds_1');
ds?.setData({ name: 'X' });
expect(pageSetData).toHaveBeenCalled();
const arg: any = pageSetData.mock.calls[0][0];
expect(arg.id).toBe('page_1');
});
test('page 没有 instance 时通过 replaceChildNode 写回 page.data', () => {
const ds = manager?.get('ds_1');
expect(app.page?.instance).toBeFalsy();
ds?.setData({ name: 'replaced' });
const replacedText = (app.page?.data as any).items[0].text;
expect(replacedText).toBe('origin replaced');
});
});
describe('createDataSourceManager - editor 平台', () => {
test('editor 平台会遍历所有页面,而非仅当前页', () => {
const dsl: MApp = {
type: NodeType.ROOT,
id: 'app_editor',
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [{ type: 'text', id: 'text_a', text: 'a ${ds_1.name}' } as any],
},
{
type: NodeType.PAGE,
id: 'page_2',
items: [{ type: 'text', id: 'text_b', text: 'b ${ds_1.name}' } as any],
},
],
dataSourceDeps: {
ds_1: {
text_a: { name: '文本', keys: ['text'] },
text_b: { name: '文本', keys: ['text'] },
},
},
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [{ name: 'name', defaultValue: 'init' }],
methods: [],
events: [],
},
],
};
const app = new TMagicApp({ config: dsl, platform: 'editor' });
const manager = createDataSourceManager(app);
const update = vi.fn();
manager?.on('update-data', update);
const ds = manager?.get('ds_1');
ds?.setData({ name: 'V' });
expect(update).toHaveBeenCalledTimes(2);
const pageIds = update.mock.calls.map((c) => c[3]);
expect(pageIds).toContain('page_1');
expect(pageIds).toContain('page_2');
});
test('非 editor 平台只处理当前页', () => {
const dsl: MApp = {
type: NodeType.ROOT,
id: 'app_runtime',
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [{ type: 'text', id: 'text_a', text: 'a ${ds_1.name}' } as any],
},
{
type: NodeType.PAGE,
id: 'page_2',
items: [{ type: 'text', id: 'text_b', text: 'b ${ds_1.name}' } as any],
},
],
dataSourceDeps: {
ds_1: {
text_a: { name: '文本', keys: ['text'] },
text_b: { name: '文本', keys: ['text'] },
},
},
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [{ name: 'name', defaultValue: 'init' }],
methods: [],
events: [],
},
],
};
const app = new TMagicApp({ config: dsl, platform: 'mobile', curPage: 'page_1' });
const manager = createDataSourceManager(app);
const update = vi.fn();
manager?.on('update-data', update);
const ds = manager?.get('ds_1');
ds?.setData({ name: 'V' });
expect(update).toHaveBeenCalledTimes(1);
expect(update.mock.calls[0][3]).toBe('page_1');
});
test('非 editor 平台命中 isPageFragment 分支也会被处理', () => {
const dsl: MApp = {
type: NodeType.ROOT,
id: 'app_pf',
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [{ type: 'text', id: 'text_a', text: 'a' } as any],
},
{
type: NodeType.PAGE_FRAGMENT,
id: 'pf_1',
items: [{ type: 'text', id: 'text_b', text: 'b ${ds_1.name}' } as any],
} as any,
],
dataSourceDeps: {
ds_1: {
text_b: { name: '文本', keys: ['text'] },
},
},
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [{ name: 'name', defaultValue: 'init' }],
methods: [],
events: [],
},
],
};
const app = new TMagicApp({ config: dsl, platform: 'mobile', curPage: 'page_1' });
const manager = createDataSourceManager(app);
const update = vi.fn();
manager?.on('update-data', update);
const ds = manager?.get('ds_1');
ds?.setData({ name: 'V' });
expect(update).toHaveBeenCalledTimes(1);
expect(update.mock.calls[0][3]).toBe('pf_1');
});
});
describe('createDataSourceManager - pageFragments 同步', () => {
test('当 newNode 为 pageFragment 自身时,调用 pageFragment.setData', () => {
const dsl: MApp = {
type: NodeType.ROOT,
id: 'app_pf_self',
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
type: 'page-fragment-container',
id: 'pfc_1',
pageFragmentId: 'pf_1',
items: [],
} as any,
],
},
{
type: NodeType.PAGE_FRAGMENT,
id: 'pf_1',
items: [{ type: 'text', id: 'pf_text', text: 'pf ${ds_1.name}' } as any],
extra: '${ds_1.name}',
} as any,
],
dataSourceDeps: {
ds_1: {
pf_1: { name: 'pf', keys: ['extra'] },
},
},
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [{ name: 'name', defaultValue: 'init' }],
methods: [],
events: [],
},
],
};
const app = new TMagicApp({ config: dsl, platform: 'editor', curPage: 'page_1' });
const manager = createDataSourceManager(app);
expect(app.pageFragments.size).toBeGreaterThan(0);
const pageFragment = app.pageFragments.get('pfc_1')!;
const pfSetData = vi.spyOn(pageFragment, 'setData');
const ds = manager?.get('ds_1');
ds?.setData({ name: 'X' });
expect(pfSetData).toHaveBeenCalled();
const arg: any = pfSetData.mock.calls[0][0];
expect(arg.id).toBe('pf_1');
});
test('当 newNode 是 pageFragment 内子节点时pageFragment 内同步并 replaceChildNode', () => {
const dsl: MApp = {
type: NodeType.ROOT,
id: 'app_pf_child',
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
type: 'page-fragment-container',
id: 'pfc_1',
pageFragmentId: 'pf_1',
items: [],
} as any,
],
},
{
type: NodeType.PAGE_FRAGMENT,
id: 'pf_1',
items: [{ type: 'text', id: 'pf_text', text: 'pf ${ds_1.name}' } as any],
} as any,
],
dataSourceDeps: {
ds_1: {
pf_text: { name: 'pf_text', keys: ['text'] },
},
},
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [{ name: 'name', defaultValue: 'init' }],
methods: [],
events: [],
},
],
};
const app = new TMagicApp({ config: dsl, platform: 'editor', curPage: 'page_1' });
const manager = createDataSourceManager(app);
const pageFragment = app.pageFragments.get('pfc_1')!;
const innerNode = pageFragment.getNode('pf_text', { strict: true })!;
const innerSetData = vi.spyOn(innerNode, 'setData');
const ds = manager?.get('ds_1');
ds?.setData({ name: 'Y' });
expect(innerSetData).toHaveBeenCalled();
const arg: any = innerSetData.mock.calls[0][0];
expect(arg.text).toBe('pf Y');
expect((pageFragment.data as any).items[0].text).toBe('pf Y');
});
});
describe('createDataSourceManager - app.page 不存在', () => {
test('app.page 缺失时跳过 page.setData / 节点 setData但仍发出 update-data', () => {
const dsl: MApp = {
type: NodeType.ROOT,
id: 'app_no_page',
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [{ type: 'text', id: 'text_a', text: 'a ${ds_1.name}' } as any],
},
],
dataSourceDeps: {
ds_1: {
text_a: { name: '文本', keys: ['text'] },
},
},
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [{ name: 'name', defaultValue: 'init' }],
methods: [],
events: [],
},
],
};
// curPage 指向不存在的页setPage 会调用 deletePage 让 app.page = undefined
const app = new TMagicApp({ config: dsl, platform: 'editor', curPage: 'not_exist' });
expect(app.page).toBeUndefined();
const manager = createDataSourceManager(app);
const update = vi.fn();
manager?.on('update-data', update);
const ds = manager?.get('ds_1');
expect(() => ds?.setData({ name: 'V' })).not.toThrow();
expect(update).toHaveBeenCalledTimes(1);
expect(update.mock.calls[0][3]).toBe('page_1');
});
});
describe('createDataSourceManager - pageFragment 与被遍历 page 同 id', () => {
test('editor 平台遍历到 pageFragment 自身页时进入 pageFragment.data.id === page.id 分支', () => {
const dsl: MApp = {
type: NodeType.ROOT,
id: 'app_pf_iter',
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
type: 'page-fragment-container',
id: 'pfc_1',
pageFragmentId: 'pf_1',
items: [],
} as any,
],
},
{
type: NodeType.PAGE_FRAGMENT,
id: 'pf_1',
items: [{ type: 'text', id: 'pf_text', text: 'pf ${ds_1.name}' } as any],
} as any,
],
dataSourceDeps: {
ds_1: {
pf_text: { name: 'pf_text', keys: ['text'] },
},
},
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [{ name: 'name', defaultValue: 'init' }],
methods: [],
events: [],
},
],
};
const app = new TMagicApp({ config: dsl, platform: 'editor', curPage: 'page_1' });
const manager = createDataSourceManager(app);
const pageFragment = app.pageFragments.get('pfc_1')!;
const innerNode = pageFragment.getNode('pf_text', { strict: true })!;
const innerSetData = vi.spyOn(innerNode, 'setData');
const ds = manager?.get('ds_1');
ds?.setData({ name: 'Z' });
expect(innerSetData).toHaveBeenCalled();
const arg: any = innerSetData.mock.calls[0][0];
expect(arg.text).toBe('pf Z');
expect((pageFragment.data as any).items[0].text).toBe('pf Z');
});
});
describe('createDataSourceManager - pageFragment 边界分支', () => {
const buildDsl = (): MApp => ({
type: NodeType.ROOT,
id: 'app_pf_edge',
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
type: 'page-fragment-container',
id: 'pfc_1',
pageFragmentId: 'pf_1',
items: [],
} as any,
],
},
{
type: NodeType.PAGE_FRAGMENT,
id: 'pf_1',
items: [{ type: 'text', id: 'pf_text', text: 'pf ${ds_1.name}' } as any],
} as any,
],
dataSourceDeps: {
ds_1: {
pf_text: { name: 'pf_text', keys: ['text'] },
},
},
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [{ name: 'name', defaultValue: 'init' }],
methods: [],
events: [],
},
],
});
test('pageFragment.getNode 返回 undefined 时安全跳过 setData', () => {
const app = new TMagicApp({ config: buildDsl(), platform: 'editor', curPage: 'page_1' });
const manager = createDataSourceManager(app);
const pageFragment = app.pageFragments.get('pfc_1')!;
// 模拟 pageFragment 内对应节点已被移除的边界
pageFragment.nodes.delete('pf_text');
const ds = manager?.get('ds_1');
expect(() => ds?.setData({ name: 'A' })).not.toThrow();
});
test('pageFragment 与当前遍历的 page、newNode 都无关时不会进入 pageFragment 同步分支', () => {
const dsl: MApp = {
type: NodeType.ROOT,
id: 'app_pf_unrelated',
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{ type: 'text', id: 'plain_text', text: 'a ${ds_1.name}' } as any,
{
type: 'page-fragment-container',
id: 'pfc_1',
pageFragmentId: 'pf_1',
items: [],
} as any,
],
},
{
type: NodeType.PAGE_FRAGMENT,
id: 'pf_1',
items: [{ type: 'text', id: 'pf_text', text: 'pf' } as any],
} as any,
],
dataSourceDeps: {
ds_1: {
plain_text: { name: 'plain_text', keys: ['text'] },
},
},
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [{ name: 'name', defaultValue: 'init' }],
methods: [],
events: [],
},
],
};
const app = new TMagicApp({ config: dsl, platform: 'mobile', curPage: 'page_1' });
const manager = createDataSourceManager(app);
const pageFragment = app.pageFragments.get('pfc_1')!;
const pfSetData = vi.spyOn(pageFragment, 'setData');
const ds = manager?.get('ds_1');
ds?.setData({ name: 'C' });
// pageFragment 与本次更新无关,不会被同步
expect(pfSetData).not.toHaveBeenCalled();
});
test('pageFragment.instance 为真时跳过 replaceChildNode', () => {
const app = new TMagicApp({ config: buildDsl(), platform: 'editor', curPage: 'page_1' });
const manager = createDataSourceManager(app);
const pageFragment = app.pageFragments.get('pfc_1')!;
pageFragment.setInstance({ __isVue: true });
const before = (pageFragment.data as any).items[0].text;
const ds = manager?.get('ds_1');
ds?.setData({ name: 'B' });
// 因为 instance 存在pageFragment.data 不会被 replaceChildNode 改写
expect((pageFragment.data as any).items[0].text).toBe(before);
});
});
describe('createDataSourceManager - 自定义数据源类型尚未注册', () => {
test('未知类型在初始化时不抛错,仅写入默认数据', () => {
const dsl: MApp = {
type: NodeType.ROOT,
id: 'app_pending',
items: [],
dataSources: [
{
id: 'ds_unknown',
type: 'custom-not-registered',
fields: [{ name: 'name', defaultValue: 'd' }],
methods: [],
events: [],
} as any,
],
};
const app = new TMagicApp({ config: dsl });
const manager = createDataSourceManager(app);
expect(manager).toBeInstanceOf(DataSourceManager);
expect(manager?.data.ds_unknown).toEqual({ name: 'd' });
expect(manager?.get('ds_unknown')).toBeUndefined();
});
test('在未注册期间通过 register 触发延迟初始化', () => {
const dsl: MApp = {
type: NodeType.ROOT,
id: 'app_lazy',
items: [],
dataSources: [
{
id: 'ds_lazy',
type: 'lazy-type',
fields: [{ name: 'name' }],
methods: [],
events: [],
} as any,
],
};
const app = new TMagicApp({ config: dsl });
const manager = createDataSourceManager(app);
expect(manager?.get('ds_lazy')).toBeUndefined();
class LazyDataSource extends DataSource {}
DataSourceManager.register('lazy-type', LazyDataSource as any);
expect(manager?.get('ds_lazy')).toBeInstanceOf(LazyDataSource);
});
});