mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-02 14:28:14 +08:00
test: 完善测试用例
This commit is contained in:
parent
258617536d
commit
ab6918f43d
@ -1 +1 @@
|
||||
npm test
|
||||
npm run test
|
||||
|
||||
@ -56,6 +56,7 @@
|
||||
"enquirer": "^2.4.1",
|
||||
"eslint": "^10.3.0",
|
||||
"execa": "^9.6.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.2.0",
|
||||
|
||||
@ -1,18 +1,137 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import Core from '../src/Core';
|
||||
import { ModuleMainFilePath, UserConfig } from '../src/types';
|
||||
|
||||
const emptyModuleMap: ModuleMainFilePath = {
|
||||
componentPackage: {},
|
||||
componentMap: {},
|
||||
pluginPakcage: {},
|
||||
pluginMap: {},
|
||||
configMap: {},
|
||||
valueMap: {},
|
||||
eventMap: {},
|
||||
datasourcePackage: {},
|
||||
datasourceMap: {},
|
||||
dsConfigMap: {},
|
||||
dsValueMap: {},
|
||||
dsEventMap: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* prepareEntryFile 内部调用 writeTemp 后未 await 异步写入,
|
||||
* 这里通过轮询等待文件落盘后再断言。
|
||||
*/
|
||||
const waitForFile = async (filePath: string, timeoutMs = 2000) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (fs.existsSync(filePath)) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
describe('Core', () => {
|
||||
test('instance', () => {
|
||||
const core = new Core({
|
||||
packages: [],
|
||||
source: './a',
|
||||
temp: './b',
|
||||
});
|
||||
expect(core).toBeInstanceOf(Core);
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-core-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('实例化后基本字段齐备', () => {
|
||||
const core = new Core({ packages: [], source: './a', temp: './b' });
|
||||
expect(core).toBeInstanceOf(Core);
|
||||
expect(typeof core.version).toBe('string');
|
||||
expect(core.options.source).toBe('./a');
|
||||
expect(core.moduleMainFilePath.componentMap).toEqual({});
|
||||
});
|
||||
|
||||
test('dir.temp() 解析为 source/temp 的绝对路径', () => {
|
||||
const core = new Core({ packages: [], source: './a', temp: './b' });
|
||||
expect(core.dir.temp()).toBe(path.join(process.cwd(), './a/b'));
|
||||
});
|
||||
|
||||
test('writeTemp 会按 temp 目录写入文件', async () => {
|
||||
const core = new Core({ packages: [], source: tmpRoot, temp: 'tmp-out' });
|
||||
await core.writeTemp('hello.txt', 'world');
|
||||
const target = path.join(tmpRoot, 'tmp-out', 'hello.txt');
|
||||
expect(fs.existsSync(target)).toBe(true);
|
||||
expect(fs.readFileSync(target, 'utf-8')).toBe('world');
|
||||
});
|
||||
|
||||
test('init 在没有 packages 时使用默认的 resolveAppPackages 结果', async () => {
|
||||
const core = new Core({ packages: [], source: tmpRoot, temp: 'tmp' });
|
||||
await core.init();
|
||||
expect(core.moduleMainFilePath).toMatchObject({
|
||||
componentPackage: {},
|
||||
componentMap: {},
|
||||
datasourcePackage: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('init 优先使用 onInit 钩子覆写 moduleMainFilePath', async () => {
|
||||
const onInit = vi.fn().mockResolvedValue({
|
||||
...emptyModuleMap,
|
||||
componentMap: { foo: 'bar' },
|
||||
});
|
||||
const options: UserConfig = {
|
||||
packages: [],
|
||||
source: tmpRoot,
|
||||
temp: 'tmp',
|
||||
onInit,
|
||||
};
|
||||
const core = new Core(options);
|
||||
await core.init();
|
||||
|
||||
expect(onInit).toHaveBeenCalledWith(core);
|
||||
expect(core.moduleMainFilePath.componentMap).toEqual({ foo: 'bar' });
|
||||
});
|
||||
|
||||
test('prepare 会写出 entry 文件,并触发 onPrepare 钩子', async () => {
|
||||
const onPrepare = vi.fn();
|
||||
const core = new Core({
|
||||
packages: [],
|
||||
source: tmpRoot,
|
||||
temp: 'tmp-entry',
|
||||
useTs: true,
|
||||
onPrepare,
|
||||
});
|
||||
|
||||
await core.prepare();
|
||||
|
||||
const tempDir = path.join(tmpRoot, 'tmp-entry');
|
||||
expect(await waitForFile(path.join(tempDir, 'comp-entry.ts'))).toBe(true);
|
||||
expect(await waitForFile(path.join(tempDir, 'plugin-entry.ts'))).toBe(true);
|
||||
expect(await waitForFile(path.join(tempDir, 'datasource-entry.ts'))).toBe(true);
|
||||
expect(onPrepare).toHaveBeenCalledWith(core);
|
||||
});
|
||||
|
||||
test('prepare 在 useTs=false 时同时输出 .js 与 .d.ts', async () => {
|
||||
const core = new Core({
|
||||
packages: [],
|
||||
source: tmpRoot,
|
||||
temp: 'tmp-js',
|
||||
useTs: false,
|
||||
});
|
||||
|
||||
await core.prepare();
|
||||
|
||||
const tempDir = path.join(tmpRoot, 'tmp-js');
|
||||
expect(await waitForFile(path.join(tempDir, 'comp-entry.js'))).toBe(true);
|
||||
expect(await waitForFile(path.join(tempDir, 'comp-entry.d.ts'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
49
packages/cli/tests/allowTs.spec.ts
Normal file
49
packages/cli/tests/allowTs.spec.ts
Normal file
@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { allowTs, transformTsFileToCodeSync } from '../src/utils/allowTs';
|
||||
|
||||
describe('allowTs', () => {
|
||||
let tmpRoot: string;
|
||||
let tsFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-allowts-'));
|
||||
tsFile = path.join(tmpRoot, 'sample.ts');
|
||||
fs.writeFileSync(
|
||||
tsFile,
|
||||
`export const greet = (name: string): string => \`hi \${name}\`;\nexport default greet;\n`,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
delete require.extensions['.ts'];
|
||||
});
|
||||
|
||||
test('transformTsFileToCodeSync 输出 cjs 代码并保留逻辑', () => {
|
||||
const code = transformTsFileToCodeSync(tsFile);
|
||||
expect(code).toContain('exports');
|
||||
expect(code).toContain('greet');
|
||||
expect(code).toContain('hi');
|
||||
});
|
||||
|
||||
test('allowTs 注册 .ts loader 后,require 可以加载 ts 文件', () => {
|
||||
allowTs();
|
||||
expect(typeof require.extensions['.ts']).toBe('function');
|
||||
|
||||
delete require.cache[tsFile];
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mod = require(tsFile);
|
||||
const greet = mod.default ?? mod.greet;
|
||||
expect(greet('world')).toBe('hi world');
|
||||
});
|
||||
});
|
||||
56
packages/cli/tests/cli.spec.ts
Normal file
56
packages/cli/tests/cli.spec.ts
Normal file
@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { cli } from '../src/cli';
|
||||
|
||||
describe('cli', () => {
|
||||
let tmpRoot: string;
|
||||
let originalArgv: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-cli-'));
|
||||
originalArgv = process.argv;
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
process.argv = originalArgv;
|
||||
delete require.extensions['.ts'];
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('调用后注册了 .ts 扩展并能解析 --version 参数', () => {
|
||||
process.argv = ['node', 'tmagic', '--version'];
|
||||
|
||||
expect(() =>
|
||||
cli({
|
||||
packages: [],
|
||||
source: tmpRoot,
|
||||
temp: 'tmp',
|
||||
}),
|
||||
).not.toThrow();
|
||||
|
||||
expect(typeof require.extensions['.ts']).toBe('function');
|
||||
});
|
||||
|
||||
test('未指定子命令时不会触发 entry 动作', () => {
|
||||
process.argv = ['node', 'tmagic'];
|
||||
|
||||
expect(() =>
|
||||
cli({
|
||||
packages: [],
|
||||
source: tmpRoot,
|
||||
temp: 'tmp',
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
112
packages/cli/tests/commands.spec.ts
Normal file
112
packages/cli/tests/commands.spec.ts
Normal file
@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { scripts } from '../src/commands';
|
||||
import Core from '../src/Core';
|
||||
import { allowTs } from '../src/utils/allowTs';
|
||||
|
||||
const writeFile = (file: string, content: string) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, content);
|
||||
};
|
||||
|
||||
describe('scripts (entry 命令)', () => {
|
||||
let tmpRoot: string;
|
||||
let originalNodeEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-cmd-'));
|
||||
originalNodeEnv = process.env.NODE_ENV;
|
||||
delete process.env.NODE_ENV;
|
||||
allowTs();
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
if (originalNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV;
|
||||
} else {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
delete require.extensions['.ts'];
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('未指定 NODE_ENV 时默认设为 development,并返回初始化好的 App', async () => {
|
||||
const entry = scripts({
|
||||
packages: [],
|
||||
source: tmpRoot,
|
||||
temp: 'tmp',
|
||||
});
|
||||
|
||||
const app = await entry();
|
||||
|
||||
expect(process.env.NODE_ENV).toBe('development');
|
||||
expect(app).toBeInstanceOf(Core);
|
||||
expect(app.options.source).toBe(tmpRoot);
|
||||
});
|
||||
|
||||
test('cleanTemp=true 时会清空 temp 目录', async () => {
|
||||
const tempDir = path.join(tmpRoot, 'tmp');
|
||||
writeFile(path.join(tempDir, 'old.txt'), 'should be deleted');
|
||||
|
||||
const entry = scripts({
|
||||
packages: [],
|
||||
source: tmpRoot,
|
||||
temp: 'tmp',
|
||||
cleanTemp: true,
|
||||
});
|
||||
|
||||
await entry();
|
||||
|
||||
expect(fs.existsSync(path.join(tempDir, 'old.txt'))).toBe(false);
|
||||
});
|
||||
|
||||
test('能够读取 source 下的 tmagic.config.js 并合并到默认配置中', async () => {
|
||||
writeFile(path.join(tmpRoot, 'tmagic.config.js'), 'module.exports = { useTs: false, packages: [] };\n');
|
||||
|
||||
const entry = scripts({
|
||||
packages: [],
|
||||
source: tmpRoot,
|
||||
temp: 'tmp',
|
||||
useTs: true,
|
||||
});
|
||||
|
||||
const app = await entry();
|
||||
|
||||
expect(app.options.useTs).toBe(false);
|
||||
});
|
||||
|
||||
test('local 配置文件会覆盖普通配置,并且 packages 会被合并', async () => {
|
||||
writeFile(path.join(tmpRoot, 'tmagic.config.js'), "module.exports = { useTs: false, packages: ['foo'] };\n");
|
||||
writeFile(path.join(tmpRoot, 'tmagic.config.local.js'), "module.exports = { useTs: true, packages: ['bar'] };\n");
|
||||
|
||||
const entry = scripts({
|
||||
packages: [],
|
||||
source: tmpRoot,
|
||||
temp: 'tmp',
|
||||
});
|
||||
|
||||
// packages 中的 'foo' 与 'bar' 都不是真实的 npm 包,
|
||||
// 由于配置在合并后会触发 resolveAppPackages 解析,这里我们 mock 掉 init
|
||||
// 以便仅校验配置合并行为。
|
||||
const initSpy = vi.spyOn(Core.prototype, 'init').mockResolvedValue(undefined);
|
||||
const prepareSpy = vi.spyOn(Core.prototype, 'prepare').mockResolvedValue(undefined);
|
||||
|
||||
const app = await entry();
|
||||
|
||||
expect(initSpy).toHaveBeenCalled();
|
||||
expect(prepareSpy).toHaveBeenCalled();
|
||||
expect(app.options.useTs).toBe(true);
|
||||
expect(app.options.packages).toEqual(['foo', 'bar']);
|
||||
});
|
||||
});
|
||||
138
packages/cli/tests/prepareEntryFile.spec.ts
Normal file
138
packages/cli/tests/prepareEntryFile.spec.ts
Normal file
@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import Core from '../src/Core';
|
||||
import { EntryType } from '../src/types';
|
||||
import { generateContent, makeCamelCase, prepareEntryFile, prettyCode } from '../src/utils/prepareEntryFile';
|
||||
|
||||
/**
|
||||
* prepareEntryFile 内部的 writeTemp 是浮动 Promise,并且会对同一文件多次写入。
|
||||
* 这里轮询直到文件内容包含期望子串再断言,避免读到中间状态的空文件。
|
||||
*/
|
||||
const waitForContent = async (filePath: string, expected: string, timeoutMs = 2000) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
if (content.includes(expected)) return content;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '';
|
||||
};
|
||||
|
||||
describe('makeCamelCase', () => {
|
||||
test('短横线分隔的字符串转为驼峰', () => {
|
||||
expect(makeCamelCase('foo-bar-baz')).toBe('fooBarBaz');
|
||||
expect(makeCamelCase('foo')).toBe('foo');
|
||||
expect(makeCamelCase('a-b-c-d')).toBe('aBCD');
|
||||
});
|
||||
|
||||
test('非字符串返回空字符串', () => {
|
||||
expect(makeCamelCase(123 as unknown as string)).toBe('');
|
||||
expect(makeCamelCase(null as unknown as string)).toBe('');
|
||||
expect(makeCamelCase(undefined as unknown as string)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prettyCode', () => {
|
||||
test('转换反斜杠并美化代码', () => {
|
||||
const out = prettyCode("const x: Record<string, any> = { 'a\\b': 1 };\nexport default x;");
|
||||
expect(out).toContain("'a/b'");
|
||||
expect(out).toContain('export default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateContent', () => {
|
||||
test('使用默认参数生成空对象的入口文件', () => {
|
||||
const code = generateContent(true, EntryType.COMPONENT);
|
||||
expect(code).toContain('const components: Record<string, any>');
|
||||
expect(code).toContain('export default components');
|
||||
});
|
||||
|
||||
test('为组件 / 插件 / 数据源生成 default import', () => {
|
||||
const code = generateContent(
|
||||
true,
|
||||
EntryType.COMPONENT,
|
||||
{ 'foo-bar': 'foo-bar-pkg' },
|
||||
{ 'foo-bar': './foo-bar/index' },
|
||||
);
|
||||
expect(code).toContain("import fooBar from './foo-bar/index'");
|
||||
expect(code).toContain("'foo-bar': fooBar");
|
||||
});
|
||||
|
||||
test('config / value / event 类型并且 packagePath 与 packageMap 一致时使用具名导入', () => {
|
||||
const code = generateContent(true, EntryType.CONFIG, { 'foo-bar': './pkg' }, { 'foo-bar': './pkg' });
|
||||
expect(code).toContain("import { config as fooBar } from './pkg'");
|
||||
expect(code).toContain("'foo-bar': fooBar");
|
||||
});
|
||||
|
||||
test('dynamicImport 启用时使用 import() 语法', () => {
|
||||
const code = generateContent(true, EntryType.COMPONENT, { foo: './foo' }, { foo: './foo/index' }, true);
|
||||
expect(code).toContain("'foo': () => import('./foo/index')");
|
||||
});
|
||||
|
||||
test('dynamicIgnore 中的 key 不走 dynamicImport', () => {
|
||||
const code = generateContent(
|
||||
true,
|
||||
EntryType.COMPONENT,
|
||||
{ foo: './foo', bar: './bar' },
|
||||
{ foo: './foo/index', bar: './bar/index' },
|
||||
true,
|
||||
['foo'],
|
||||
);
|
||||
expect(code).toContain("import foo from './foo/index'");
|
||||
expect(code).toContain("'foo': foo");
|
||||
expect(code).toContain("'bar': () => import('./bar/index')");
|
||||
});
|
||||
|
||||
test('useTs=false 时不会添加类型注解', () => {
|
||||
const code = generateContent(false, EntryType.COMPONENT, { foo: './foo' }, { foo: './foo' });
|
||||
expect(code).not.toContain('Record<string, any>');
|
||||
expect(code).toContain('const components');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareEntryFile', () => {
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-prep-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('beforeWriteEntry 钩子可以改写最终写入的内容', async () => {
|
||||
const beforeWriteEntry = vi.fn(async (map: Record<string, string>) => ({
|
||||
...map,
|
||||
'comp-entry': '// custom comp entry\n',
|
||||
}));
|
||||
|
||||
const core = new Core({
|
||||
packages: [],
|
||||
source: tmpRoot,
|
||||
temp: 'tmp',
|
||||
useTs: true,
|
||||
hooks: { beforeWriteEntry },
|
||||
});
|
||||
|
||||
await prepareEntryFile(core);
|
||||
|
||||
expect(beforeWriteEntry).toHaveBeenCalled();
|
||||
|
||||
const compEntry = path.join(tmpRoot, 'tmp', 'comp-entry.ts');
|
||||
const content = await waitForContent(compEntry, 'custom comp entry');
|
||||
expect(content).toContain('custom comp entry');
|
||||
});
|
||||
});
|
||||
173
packages/cli/tests/resolveAppPackages.spec.ts
Normal file
173
packages/cli/tests/resolveAppPackages.spec.ts
Normal file
@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import Core from '../src/Core';
|
||||
import { resolveAppPackages } from '../src/utils/resolveAppPackages';
|
||||
|
||||
const writeFile = (filePath: string, content: string) => {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
};
|
||||
|
||||
describe('resolveAppPackages', () => {
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-resolve-'));
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('packages 为空时返回空的映射结构', () => {
|
||||
const app = new Core({ packages: [], source: tmpRoot, temp: 'tmp' });
|
||||
const result = resolveAppPackages(app);
|
||||
expect(result).toEqual({
|
||||
componentPackage: {},
|
||||
componentMap: {},
|
||||
configMap: {},
|
||||
eventMap: {},
|
||||
valueMap: {},
|
||||
pluginPakcage: {},
|
||||
pluginMap: {},
|
||||
datasourcePackage: {},
|
||||
datasourceMap: {},
|
||||
dsConfigMap: {},
|
||||
dsEventMap: {},
|
||||
dsValueMap: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('解析普通组件目录', () => {
|
||||
const pkgDir = path.join(tmpRoot, 'my-comp');
|
||||
writeFile(
|
||||
path.join(pkgDir, 'index.js'),
|
||||
"import Foo from './Foo';\nexport default Foo;\nexport const config = {};\nexport const value = {};\n",
|
||||
);
|
||||
writeFile(path.join(pkgDir, 'Foo.vue'), '<template></template>');
|
||||
|
||||
const app = new Core({
|
||||
packages: [{ 'my-comp': pkgDir }],
|
||||
source: tmpRoot,
|
||||
temp: 'tmp',
|
||||
componentFileAffix: '.vue',
|
||||
});
|
||||
|
||||
const result = resolveAppPackages(app);
|
||||
|
||||
expect(Object.keys(result.componentPackage)).toContain('my-comp');
|
||||
expect(result.componentMap['my-comp']).toBeTruthy();
|
||||
});
|
||||
|
||||
test('解析插件 (export default 含 install 的对象)', () => {
|
||||
const pkgDir = path.join(tmpRoot, 'my-plugin');
|
||||
writeFile(path.join(pkgDir, 'index.js'), 'export default { install() {} };\n');
|
||||
|
||||
const app = new Core({
|
||||
packages: [{ 'my-plugin': pkgDir }],
|
||||
source: tmpRoot,
|
||||
temp: 'tmp',
|
||||
});
|
||||
|
||||
const result = resolveAppPackages(app);
|
||||
|
||||
expect(result.pluginPakcage['my-plugin']).toBeTruthy();
|
||||
expect(result.pluginMap['my-plugin']).toBeTruthy();
|
||||
});
|
||||
|
||||
test('解析数据源 (export default class extends DataSource)', () => {
|
||||
const pkgDir = path.join(tmpRoot, 'my-ds');
|
||||
writeFile(path.join(pkgDir, 'index.js'), 'export default class MyDataSource extends DataSource {}\n');
|
||||
|
||||
const app = new Core({
|
||||
packages: [{ 'my-ds': pkgDir }],
|
||||
source: tmpRoot,
|
||||
temp: 'tmp',
|
||||
});
|
||||
|
||||
const result = resolveAppPackages(app);
|
||||
|
||||
expect(result.datasourcePackage['my-ds']).toBeTruthy();
|
||||
});
|
||||
|
||||
test('解析自定义父类的数据源 (datasoucreSuperClass)', () => {
|
||||
const pkgDir = path.join(tmpRoot, 'my-custom-ds');
|
||||
writeFile(path.join(pkgDir, 'index.js'), 'export default class MyDataSource extends MyBaseDS {}\n');
|
||||
|
||||
const app = new Core({
|
||||
packages: [{ 'my-custom-ds': pkgDir }],
|
||||
source: tmpRoot,
|
||||
temp: 'tmp',
|
||||
datasoucreSuperClass: ['MyBaseDS'],
|
||||
});
|
||||
|
||||
const result = resolveAppPackages(app);
|
||||
|
||||
expect(result.datasourcePackage['my-custom-ds']).toBeTruthy();
|
||||
});
|
||||
|
||||
test('解析组件包 (export default 是包含多个子组件的对象)', () => {
|
||||
const pkgDir = path.join(tmpRoot, 'my-pkg');
|
||||
writeFile(path.join(pkgDir, 'package.json'), JSON.stringify({ name: 'my-pkg', main: 'index.js' }));
|
||||
writeFile(
|
||||
path.join(pkgDir, 'index.js'),
|
||||
"import foo from './foo';\nimport bar from './bar';\nexport default { foo, bar };\n",
|
||||
);
|
||||
writeFile(path.join(pkgDir, 'foo/package.json'), JSON.stringify({ name: 'foo', main: 'index.js' }));
|
||||
writeFile(path.join(pkgDir, 'foo/index.js'), "import FooComp from './FooComp';\nexport default FooComp;\n");
|
||||
writeFile(path.join(pkgDir, 'foo/FooComp.vue'), '<template></template>');
|
||||
writeFile(path.join(pkgDir, 'bar/package.json'), JSON.stringify({ name: 'bar', main: 'index.js' }));
|
||||
writeFile(path.join(pkgDir, 'bar/index.js'), "import BarComp from './BarComp';\nexport default BarComp;\n");
|
||||
writeFile(path.join(pkgDir, 'bar/BarComp.vue'), '<template></template>');
|
||||
|
||||
const app = new Core({
|
||||
packages: [pkgDir],
|
||||
source: tmpRoot,
|
||||
temp: 'tmp',
|
||||
componentFileAffix: '.vue',
|
||||
});
|
||||
|
||||
const result = resolveAppPackages(app);
|
||||
|
||||
expect(result.componentPackage.foo).toBeTruthy();
|
||||
expect(result.componentPackage.bar).toBeTruthy();
|
||||
});
|
||||
|
||||
test('字符串形式 packages 没有 key 时仅做解析不写入映射', () => {
|
||||
const pkgDir = path.join(tmpRoot, 'no-key-comp');
|
||||
writeFile(path.join(pkgDir, 'index.js'), "import Foo from './Foo';\nexport default Foo;\n");
|
||||
writeFile(path.join(pkgDir, 'Foo.vue'), '<template></template>');
|
||||
|
||||
const app = new Core({
|
||||
packages: [pkgDir],
|
||||
source: tmpRoot,
|
||||
temp: 'tmp',
|
||||
componentFileAffix: '.vue',
|
||||
});
|
||||
|
||||
const result = resolveAppPackages(app);
|
||||
|
||||
expect(Object.keys(result.componentPackage)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('packages 为对象但找不到合法 moduleName 时抛错', () => {
|
||||
const app = new Core({
|
||||
packages: [{ foo: '' }],
|
||||
source: tmpRoot,
|
||||
temp: 'tmp',
|
||||
});
|
||||
|
||||
expect(() => resolveAppPackages(app)).toThrowError(/packages中包含非法配置/);
|
||||
});
|
||||
});
|
||||
206
packages/cli/tests/utils.spec.ts
Normal file
206
packages/cli/tests/utils.spec.ts
Normal file
@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
backupFile,
|
||||
backupLock,
|
||||
backupNpmLock,
|
||||
backupPackageJson,
|
||||
backupPnpmLock,
|
||||
backupYarnLock,
|
||||
isRootPath,
|
||||
restoreFile,
|
||||
restoreLock,
|
||||
restoreNpmLock,
|
||||
restorePackageJson,
|
||||
restorePnpmLock,
|
||||
restoreYarnLock,
|
||||
} from '../src/utils/backupPackageFile';
|
||||
import { defineConfig } from '../src/utils/defineUserConfig';
|
||||
import { hasExportDefault, isPlainObject, loadUserConfig } from '../src/utils/loadUserConfig';
|
||||
import * as logger from '../src/utils/logger';
|
||||
|
||||
describe('logger', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('info / error / success / execInfo 都会调用 console.log', () => {
|
||||
logger.info('a');
|
||||
logger.error('b');
|
||||
logger.success('c');
|
||||
logger.execInfo('d');
|
||||
expect((console.log as any).mock.calls.length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRootPath', () => {
|
||||
test('非字符串输入抛错', () => {
|
||||
expect(() => isRootPath(123 as any)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test('空字符串与超长字符串返回 false', () => {
|
||||
expect(isRootPath('')).toBe(false);
|
||||
expect(isRootPath('x'.repeat(101))).toBe(false);
|
||||
});
|
||||
|
||||
test('Linux 根路径返回 true', () => {
|
||||
if (process.platform !== 'win32') {
|
||||
expect(isRootPath('/')).toBe(true);
|
||||
expect(isRootPath('/foo')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('两侧空白会被裁剪', () => {
|
||||
if (process.platform !== 'win32') {
|
||||
expect(isRootPath(' / ')).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupFile / restoreFile - 根路径短路', () => {
|
||||
test('isRootPath 为 true 时 backupFile 与 restoreFile 直接返回', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
// 不应抛错也不应有副作用
|
||||
expect(() => backupFile('/', 'package.json')).not.toThrow();
|
||||
expect(() => restoreFile('/', 'package.json')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupFile / restoreFile 流程', () => {
|
||||
let tmpRoot: string;
|
||||
let nested: string;
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-bk-'));
|
||||
nested = path.join(tmpRoot, 'nested');
|
||||
fs.mkdirSync(nested, { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpRoot, 'package.json'), '{}');
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('在嵌套目录中向上递归找到目标后备份', () => {
|
||||
backupFile(nested, 'package.json');
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'package.json.bak'))).toBe(true);
|
||||
});
|
||||
|
||||
test('restore 回滚备份', () => {
|
||||
backupFile(nested, 'package.json');
|
||||
fs.writeFileSync(path.join(tmpRoot, 'package.json'), '{"changed":true}');
|
||||
restoreFile(nested, 'package.json');
|
||||
const restored = JSON.parse(fs.readFileSync(path.join(tmpRoot, 'package.json'), 'utf-8'));
|
||||
expect(restored).toEqual({});
|
||||
});
|
||||
|
||||
test('便利函数全部能调用', () => {
|
||||
fs.writeFileSync(path.join(tmpRoot, 'pnpm-lock.yaml'), '');
|
||||
fs.writeFileSync(path.join(tmpRoot, 'yarn-lock.json'), '');
|
||||
fs.writeFileSync(path.join(tmpRoot, 'package-lock.json'), '');
|
||||
backupPnpmLock(tmpRoot);
|
||||
backupYarnLock(tmpRoot);
|
||||
backupNpmLock(tmpRoot);
|
||||
backupPackageJson(tmpRoot);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'pnpm-lock.yaml.bak'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'yarn-lock.json.bak'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'package-lock.json.bak'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'package.json.bak'))).toBe(true);
|
||||
|
||||
restorePnpmLock(tmpRoot);
|
||||
restoreYarnLock(tmpRoot);
|
||||
restoreNpmLock(tmpRoot);
|
||||
restorePackageJson(tmpRoot);
|
||||
});
|
||||
|
||||
test('backupLock / restoreLock 走对应 npm 类型', () => {
|
||||
fs.writeFileSync(path.join(tmpRoot, 'pnpm-lock.yaml'), '');
|
||||
backupLock(tmpRoot, 'pnpm');
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'pnpm-lock.yaml.bak'))).toBe(true);
|
||||
restoreLock(tmpRoot, 'pnpm');
|
||||
|
||||
fs.writeFileSync(path.join(tmpRoot, 'yarn-lock.json'), '');
|
||||
backupLock(tmpRoot, 'yarn');
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'yarn-lock.json.bak'))).toBe(true);
|
||||
restoreLock(tmpRoot, 'yarn');
|
||||
|
||||
fs.writeFileSync(path.join(tmpRoot, 'package-lock.json'), '');
|
||||
backupLock(tmpRoot, 'npm');
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'package-lock.json.bak'))).toBe(true);
|
||||
restoreLock(tmpRoot, 'npm');
|
||||
|
||||
backupLock(tmpRoot, 'unknown');
|
||||
restoreLock(tmpRoot, 'unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('defineConfig', () => {
|
||||
test('原样返回输入', () => {
|
||||
const cfg = { source: '.', temp: 'tmp', packages: [] };
|
||||
expect(defineConfig(cfg as any)).toBe(cfg);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadUserConfig 与 isPlainObject / hasExportDefault', () => {
|
||||
test('isPlainObject', () => {
|
||||
expect(isPlainObject({})).toBe(true);
|
||||
expect(isPlainObject({ a: 1 })).toBe(true);
|
||||
expect(isPlainObject([])).toBe(false);
|
||||
expect(isPlainObject(null)).toBe(false);
|
||||
expect(isPlainObject('s')).toBe(false);
|
||||
});
|
||||
|
||||
test('hasExportDefault 仅识别 __esModule + default', () => {
|
||||
expect(hasExportDefault({ __esModule: true, default: 1 })).toBe(true);
|
||||
expect(hasExportDefault({ default: 1 })).toBe(false);
|
||||
expect(hasExportDefault({ __esModule: true })).toBe(false);
|
||||
expect(hasExportDefault('x')).toBe(false);
|
||||
});
|
||||
|
||||
test('loadUserConfig - 没有 path 时返回 {}', async () => {
|
||||
expect(await loadUserConfig()).toEqual({});
|
||||
expect(await loadUserConfig('')).toEqual({});
|
||||
});
|
||||
|
||||
test('loadUserConfig - 不匹配的扩展名返回 {}', async () => {
|
||||
expect(await loadUserConfig('/path/file.json')).toEqual({});
|
||||
});
|
||||
|
||||
test('loadUserConfig - 加载真实 .js 配置文件 (CommonJS 默认导出)', async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-cfg-'));
|
||||
const cfg = path.join(tmp, 'cfg.js');
|
||||
fs.writeFileSync(cfg, "module.exports = { source: '.', temp: 'tmp', packages: [], useTs: true };\n");
|
||||
try {
|
||||
const config = await loadUserConfig(cfg);
|
||||
expect(config).toMatchObject({ useTs: true, packages: [] });
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('loadUserConfig - 加载 ESM-style __esModule + default 配置', async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-cfg-esm-'));
|
||||
const cfg = path.join(tmp, 'cfg.js');
|
||||
fs.writeFileSync(
|
||||
cfg,
|
||||
"Object.defineProperty(exports, '__esModule', { value: true });\n" +
|
||||
"exports.default = { source: '.', temp: 'tmp', packages: [], useTs: false };\n",
|
||||
);
|
||||
try {
|
||||
const config = await loadUserConfig(cfg);
|
||||
expect(config).toMatchObject({ useTs: false });
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,9 +1,10 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { MApp, NodeType } from '@tmagic/schema';
|
||||
|
||||
import App from '../src/App';
|
||||
import TMagicIteratorContainer from '../src/IteratorContainer';
|
||||
import Node from '../src/Node';
|
||||
|
||||
const createAppDsl = (pageLength: number, nodeLength = 0) => {
|
||||
const dsl: MApp = {
|
||||
@ -263,3 +264,184 @@ describe('App', () => {
|
||||
expect(ic2?.nodes.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('App 配置/方法/组件注册', () => {
|
||||
test('platform=editor 时不创建 eventHelper', () => {
|
||||
const app = new App({ platform: 'editor' });
|
||||
expect(app.eventHelper).toBeUndefined();
|
||||
expect(app.platform).toBe('editor');
|
||||
});
|
||||
|
||||
test('disabledFlexible 时不创建 flexible', () => {
|
||||
const app = new App({ disabledFlexible: true });
|
||||
expect((app as any).flexible).toBeUndefined();
|
||||
});
|
||||
|
||||
test('设置自定义 iteratorContainerType / pageFragmentContainerType', () => {
|
||||
const app = new App({
|
||||
iteratorContainerType: ['my-iter', 'custom-iter'],
|
||||
pageFragmentContainerType: 'my-frag',
|
||||
});
|
||||
expect(app.iteratorContainerType.has('my-iter')).toBe(true);
|
||||
expect(app.iteratorContainerType.has('custom-iter')).toBe(true);
|
||||
expect(app.pageFragmentContainerType.has('my-frag')).toBe(true);
|
||||
});
|
||||
|
||||
test('useMock=true 透传到 DataSourceManager', () => {
|
||||
const app = new App({ useMock: true });
|
||||
expect(app.useMock).toBe(true);
|
||||
});
|
||||
|
||||
test('registerComponent / resolveComponent / unregisterComponent', () => {
|
||||
const app = new App({});
|
||||
const comp = { tag: 'x' };
|
||||
app.registerComponent('my', comp);
|
||||
expect(app.resolveComponent('my')).toBe(comp);
|
||||
app.unregisterComponent('my');
|
||||
expect(app.resolveComponent('my')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('registerNode 静态方法存入 nodeClassMap', () => {
|
||||
class Custom extends Node {}
|
||||
App.registerNode('custom-type', Custom);
|
||||
expect(App.nodeClassMap.get('custom-type')).toBe(Custom);
|
||||
});
|
||||
|
||||
test('setEnv 接受字符串/Env 实例', () => {
|
||||
const app = new App({});
|
||||
app.setEnv();
|
||||
expect(app.env).toBeDefined();
|
||||
app.setEnv('Mozilla/5.0');
|
||||
expect(app.env).toBeDefined();
|
||||
});
|
||||
|
||||
test('getPage / getNode 默认返回当前 page', () => {
|
||||
const app = new App({
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app',
|
||||
items: [{ type: NodeType.PAGE, id: 'p1', items: [{ id: 'btn', type: 'button' }] }],
|
||||
},
|
||||
});
|
||||
expect(app.getPage()).toBe(app.page);
|
||||
expect(app.getPage('p1')).toBe(app.page);
|
||||
expect(app.getPage('not-exist')).toBeUndefined();
|
||||
expect(app.getNode('btn')?.data.id).toBe('btn');
|
||||
});
|
||||
|
||||
test('setPage 不存在时清空当前 page', () => {
|
||||
const app = new App({
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app',
|
||||
items: [{ type: NodeType.PAGE, id: 'p1', items: [] }],
|
||||
},
|
||||
});
|
||||
app.setPage('not-exist');
|
||||
expect(app.page).toBeUndefined();
|
||||
});
|
||||
|
||||
test('runCode 执行代码块', async () => {
|
||||
const fn = vi.fn();
|
||||
const app = new App({
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app',
|
||||
items: [{ type: NodeType.PAGE, id: 'p1', items: [] }],
|
||||
codeBlocks: { c1: { name: 'c1', content: fn, params: [] } },
|
||||
},
|
||||
});
|
||||
await app.runCode('c1', { p: 1 }, []);
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('runCode 抛错时进入 errorHandler', async () => {
|
||||
const errorHandler = vi.fn();
|
||||
const app = new App({
|
||||
errorHandler,
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app',
|
||||
items: [{ type: NodeType.PAGE, id: 'p1', items: [] }],
|
||||
codeBlocks: {
|
||||
c1: {
|
||||
name: 'c1',
|
||||
content: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
params: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.runCode('c1', {}, []);
|
||||
expect(errorHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('runDataSourceMethod 调用 schema methods 中的 content', async () => {
|
||||
const fn = vi.fn().mockResolvedValue('ok');
|
||||
const app = new App({
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app',
|
||||
items: [{ type: NodeType.PAGE, id: 'p1', items: [] }],
|
||||
dataSources: [
|
||||
{
|
||||
type: 'base',
|
||||
id: 'ds_1',
|
||||
fields: [],
|
||||
methods: [{ name: 'doIt', content: fn, params: [] }],
|
||||
events: [],
|
||||
},
|
||||
],
|
||||
} as any,
|
||||
});
|
||||
await app.runDataSourceMethod('ds_1', 'doIt', { p: 1 }, []);
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('runDataSourceMethod 不存在的数据源直接返回', async () => {
|
||||
const app = new App({
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app',
|
||||
items: [{ type: NodeType.PAGE, id: 'p1', items: [] }],
|
||||
},
|
||||
});
|
||||
await expect(app.runDataSourceMethod('not', 'm', {}, [])).resolves.toBeUndefined();
|
||||
await expect(app.runDataSourceMethod('', '', {}, [])).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('emit 触发 node 事件时走 eventHelper', () => {
|
||||
const app = new App({
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app',
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'p1',
|
||||
items: [{ id: 'btn', type: 'button', events: [{ name: 'click', actions: [] }] }],
|
||||
},
|
||||
],
|
||||
} as any,
|
||||
});
|
||||
const node = app.getNode('btn')!;
|
||||
const result = app.emit('click', node, 'arg1');
|
||||
expect(typeof result).toBe('boolean');
|
||||
});
|
||||
|
||||
test('destroy 清理所有资源', () => {
|
||||
const app = new App({
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app',
|
||||
items: [{ type: NodeType.PAGE, id: 'p1', items: [{ id: 'btn', type: 'button' }] }],
|
||||
},
|
||||
});
|
||||
app.destroy();
|
||||
expect(app.page).toBeUndefined();
|
||||
expect(app.dsl).toBeUndefined();
|
||||
expect(app.components.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
834
packages/core/tests/EventHelper.spec.ts
Normal file
834
packages/core/tests/EventHelper.spec.ts
Normal file
@ -0,0 +1,834 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
ActionType,
|
||||
type MApp,
|
||||
NODE_DISABLE_CODE_BLOCK_KEY,
|
||||
NODE_DISABLE_DATA_SOURCE_KEY,
|
||||
NodeType,
|
||||
} from '@tmagic/schema';
|
||||
import { DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX } from '@tmagic/utils';
|
||||
|
||||
import App from '../src/App';
|
||||
import EventHelper from '../src/EventHelper';
|
||||
import FlowState from '../src/FlowState';
|
||||
|
||||
const flushAsync = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const createDsl = (overrides: Partial<MApp> = {}): MApp => ({
|
||||
type: NodeType.ROOT,
|
||||
id: 'app',
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page_1',
|
||||
items: [
|
||||
{
|
||||
id: 'btn_1',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
id: 'btn_2',
|
||||
type: 'button',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('EventHelper 构造与销毁', () => {
|
||||
test('实例化继承 EventEmitter 并保存 app 引用', () => {
|
||||
const app = new App({});
|
||||
const helper = new EventHelper({ app });
|
||||
expect(helper).toBeInstanceOf(EventHelper);
|
||||
expect(helper.app).toBe(app);
|
||||
expect(helper.eventQueue).toEqual([]);
|
||||
});
|
||||
|
||||
test('destroy 清空内部状态与监听器', () => {
|
||||
const app = new App({ config: createDsl() });
|
||||
const helper = app.eventHelper!;
|
||||
const handler = vi.fn();
|
||||
helper.on('foo', handler);
|
||||
|
||||
helper.destroy();
|
||||
helper.emit('foo');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect((helper as any).nodeEventList.size).toBe(0);
|
||||
expect((helper as any).dataSourceEventList.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventHelper - bindNodeEvents / initEvents / removeNodeEvents', () => {
|
||||
test('忽略没有 name 的事件配置', () => {
|
||||
const app = new App({ config: createDsl() });
|
||||
const helper = app.eventHelper!;
|
||||
const node = app.getNode('btn_1')!;
|
||||
|
||||
node.events = [{ name: '', actions: [] } as any];
|
||||
helper.bindNodeEvents(node);
|
||||
|
||||
expect((helper as any).nodeEventList.size).toBe(0);
|
||||
});
|
||||
|
||||
test('为带 name 的事件创建 symbol 并写入 eventKeys', () => {
|
||||
const app = new App({ config: createDsl() });
|
||||
const helper = app.eventHelper!;
|
||||
const node = app.getNode('btn_1')!;
|
||||
|
||||
node.events = [{ name: 'click', actions: [] }];
|
||||
node.eventKeys.clear();
|
||||
helper.bindNodeEvents(node);
|
||||
|
||||
expect(node.eventKeys.has(`click_${node.data.id}`)).toBe(true);
|
||||
expect((helper as any).nodeEventList.size).toBe(1);
|
||||
});
|
||||
|
||||
test('已存在的 eventKey 会被复用而不是重新创建', () => {
|
||||
const app = new App({ config: createDsl() });
|
||||
const helper = app.eventHelper!;
|
||||
const node = app.getNode('btn_1')!;
|
||||
|
||||
const existingSymbol = Symbol('click_btn_1');
|
||||
node.eventKeys.set(`click_${node.data.id}`, existingSymbol);
|
||||
node.events = [{ name: 'click', actions: [] }];
|
||||
|
||||
helper.bindNodeEvents(node);
|
||||
expect(node.eventKeys.get(`click_${node.data.id}`)).toBe(existingSymbol);
|
||||
});
|
||||
|
||||
test('${nodeId}.${eventName} 形式将命名空间转换为 ${eventName}_${nodeId}', () => {
|
||||
const app = new App({ config: createDsl() });
|
||||
const helper = app.eventHelper!;
|
||||
const node = app.getNode('btn_1')!;
|
||||
|
||||
node.events = [{ name: 'btn_2.click', actions: [] }];
|
||||
node.eventKeys.clear();
|
||||
|
||||
helper.bindNodeEvents(node);
|
||||
expect(node.eventKeys.has('click_btn_2')).toBe(true);
|
||||
});
|
||||
|
||||
test('initEvents 会为 page 和 pageFragments 内的节点都绑定事件', () => {
|
||||
const dsl = createDsl({
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page_1',
|
||||
items: [
|
||||
{
|
||||
id: 'pf_container',
|
||||
type: 'page-fragment-container',
|
||||
pageFragmentId: 'pf_1',
|
||||
},
|
||||
{
|
||||
id: 'btn_in_page',
|
||||
type: 'button',
|
||||
events: [{ name: 'click', actions: [] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: NodeType.PAGE_FRAGMENT,
|
||||
id: 'pf_1',
|
||||
items: [
|
||||
{
|
||||
id: 'btn_in_pf',
|
||||
type: 'button',
|
||||
events: [{ name: 'click', actions: [] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
const app = new App({ config: dsl });
|
||||
const helper = app.eventHelper!;
|
||||
expect(app.pageFragments.size).toBe(1);
|
||||
|
||||
helper.initEvents();
|
||||
expect((helper as any).nodeEventList.size).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('removeNodeEvents 会移除全部 node 上注册的监听', () => {
|
||||
const app = new App({
|
||||
config: createDsl({
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page_1',
|
||||
items: [
|
||||
{
|
||||
id: 'btn_1',
|
||||
type: 'button',
|
||||
events: [{ name: 'click', actions: [] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any),
|
||||
});
|
||||
|
||||
const helper = app.eventHelper!;
|
||||
expect((helper as any).nodeEventList.size).toBe(1);
|
||||
|
||||
helper.removeNodeEvents();
|
||||
expect((helper as any).nodeEventList.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventHelper - 事件队列', () => {
|
||||
test('addEventToQueue / getEventQueue', () => {
|
||||
const app = new App({});
|
||||
const helper = new EventHelper({ app });
|
||||
|
||||
helper.addEventToQueue({ toId: 'x', method: 'm', fromCpt: null, args: [1] });
|
||||
expect(helper.getEventQueue()).toHaveLength(1);
|
||||
expect(helper.getEventQueue()[0].toId).toBe('x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventHelper - eventHandler / actionHandler 流程', () => {
|
||||
let beforeHandler: ReturnType<typeof vi.fn>;
|
||||
let afterHandler: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeHandler = vi.fn();
|
||||
afterHandler = vi.fn();
|
||||
});
|
||||
|
||||
test('emit click 时执行 EventConfig.actions 并触发 before/after 钩子', async () => {
|
||||
const fromInstance = { doIt: vi.fn() };
|
||||
const toInstance = { doIt: vi.fn() };
|
||||
|
||||
const app = new App({
|
||||
beforeEventHandler: beforeHandler,
|
||||
afterEventHandler: afterHandler,
|
||||
config: createDsl({
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page_1',
|
||||
items: [
|
||||
{
|
||||
id: 'btn_1',
|
||||
type: 'button',
|
||||
events: [
|
||||
{
|
||||
name: 'click',
|
||||
actions: [{ actionType: ActionType.COMP, to: 'btn_2', method: 'doIt' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: 'btn_2', type: 'button' },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any),
|
||||
});
|
||||
|
||||
const fromNode = app.getNode('btn_1')!;
|
||||
const toNode = app.getNode('btn_2')!;
|
||||
fromNode.setInstance(fromInstance);
|
||||
toNode.setInstance(toInstance);
|
||||
|
||||
app.emit('click', fromNode, 'extraArg');
|
||||
await flushAsync();
|
||||
|
||||
expect(beforeHandler).toHaveBeenCalled();
|
||||
expect(afterHandler).toHaveBeenCalled();
|
||||
expect(toInstance.doIt).toHaveBeenCalled();
|
||||
expect(toInstance.doIt.mock.calls[0][1]).toBe('extraArg');
|
||||
});
|
||||
|
||||
test('actions 中如果 flowState.isAbort 为 true 会中断后续 action', async () => {
|
||||
const action2Spy = vi.fn();
|
||||
const codeBlocks = {
|
||||
abortCode: {
|
||||
name: 'abortCode',
|
||||
params: [],
|
||||
content: ({ flowState }: any) => {
|
||||
flowState.abort();
|
||||
},
|
||||
},
|
||||
shouldNotRun: {
|
||||
name: 'shouldNotRun',
|
||||
params: [],
|
||||
content: action2Spy,
|
||||
},
|
||||
};
|
||||
|
||||
const app = new App({
|
||||
config: createDsl({
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page_1',
|
||||
items: [
|
||||
{
|
||||
id: 'btn_1',
|
||||
type: 'button',
|
||||
events: [
|
||||
{
|
||||
name: 'click',
|
||||
actions: [
|
||||
{ actionType: ActionType.CODE, codeId: 'abortCode' } as any,
|
||||
{ actionType: ActionType.CODE, codeId: 'shouldNotRun' } as any,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
codeBlocks,
|
||||
} as any),
|
||||
});
|
||||
|
||||
app.emit('click', app.getNode('btn_1')!);
|
||||
await flushAsync();
|
||||
await flushAsync();
|
||||
|
||||
expect(action2Spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('CODE action 在 NODE_DISABLE_CODE_BLOCK_KEY=true 时跳过', async () => {
|
||||
const codeFn = vi.fn();
|
||||
const app = new App({
|
||||
config: createDsl({
|
||||
codeBlocks: { c: { name: 'c', params: [], content: codeFn } },
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page_1',
|
||||
items: [
|
||||
{
|
||||
id: 'btn_1',
|
||||
type: 'button',
|
||||
[NODE_DISABLE_CODE_BLOCK_KEY]: true,
|
||||
events: [
|
||||
{
|
||||
name: 'click',
|
||||
actions: [{ actionType: ActionType.CODE, codeId: 'c' } as any],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any),
|
||||
});
|
||||
|
||||
app.emit('click', app.getNode('btn_1')!);
|
||||
await flushAsync();
|
||||
expect(codeFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('DATA_SOURCE action 正常执行时通过 runDataSourceMethod 调用', async () => {
|
||||
const methodFn = vi.fn().mockResolvedValue('ok');
|
||||
const app = new App({
|
||||
config: createDsl({
|
||||
dataSources: [
|
||||
{
|
||||
id: 'ds_1',
|
||||
type: 'base',
|
||||
fields: [],
|
||||
events: [],
|
||||
methods: [{ name: 'fetch', params: [], content: methodFn }],
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page_1',
|
||||
items: [
|
||||
{
|
||||
id: 'btn_1',
|
||||
type: 'button',
|
||||
events: [
|
||||
{
|
||||
name: 'click',
|
||||
actions: [
|
||||
{
|
||||
actionType: ActionType.DATA_SOURCE,
|
||||
dataSourceMethod: ['ds_1', 'fetch'],
|
||||
params: { x: 1 },
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any),
|
||||
});
|
||||
|
||||
app.emit('click', app.getNode('btn_1')!);
|
||||
await flushAsync();
|
||||
await flushAsync();
|
||||
expect(methodFn).toHaveBeenCalled();
|
||||
expect(methodFn.mock.calls[0][0].params).toEqual({ x: 1 });
|
||||
});
|
||||
|
||||
test('DATA_SOURCE action 在 NODE_DISABLE_DATA_SOURCE_KEY=true 时跳过', async () => {
|
||||
const methodFn = vi.fn();
|
||||
const app = new App({
|
||||
config: createDsl({
|
||||
dataSources: [
|
||||
{
|
||||
id: 'ds_1',
|
||||
type: 'base',
|
||||
fields: [],
|
||||
events: [],
|
||||
methods: [{ name: 'fetch', params: [], content: methodFn }],
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page_1',
|
||||
items: [
|
||||
{
|
||||
id: 'btn_1',
|
||||
type: 'button',
|
||||
[NODE_DISABLE_DATA_SOURCE_KEY]: true,
|
||||
events: [
|
||||
{
|
||||
name: 'click',
|
||||
actions: [
|
||||
{
|
||||
actionType: ActionType.DATA_SOURCE,
|
||||
dataSourceMethod: ['ds_1', 'fetch'],
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any),
|
||||
});
|
||||
|
||||
app.emit('click', app.getNode('btn_1')!);
|
||||
await flushAsync();
|
||||
expect(methodFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('actionHandler 抛错时调用 errorHandler', async () => {
|
||||
const errorHandler = vi.fn();
|
||||
const app = new App({
|
||||
errorHandler,
|
||||
config: createDsl({
|
||||
codeBlocks: {
|
||||
boom: {
|
||||
name: 'boom',
|
||||
params: [],
|
||||
content: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
},
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page_1',
|
||||
items: [
|
||||
{
|
||||
id: 'btn_1',
|
||||
type: 'button',
|
||||
events: [
|
||||
{
|
||||
name: 'click',
|
||||
actions: [{ actionType: ActionType.CODE, codeId: 'boom' } as any],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any),
|
||||
});
|
||||
|
||||
app.emit('click', app.getNode('btn_1')!);
|
||||
await flushAsync();
|
||||
await flushAsync();
|
||||
expect(errorHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('兼容 DeprecatedEventConfig:没有 actions 字段时走 compActionHandler', async () => {
|
||||
const targetInstance = { ping: vi.fn() };
|
||||
const app = new App({
|
||||
config: createDsl({
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page_1',
|
||||
items: [
|
||||
{
|
||||
id: 'btn_1',
|
||||
type: 'button',
|
||||
events: [{ name: 'click', to: 'btn_2', method: 'ping' } as any],
|
||||
},
|
||||
{ id: 'btn_2', type: 'button' },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any),
|
||||
});
|
||||
|
||||
const fromNode = app.getNode('btn_1')!;
|
||||
app.getNode('btn_2')!.setInstance(targetInstance);
|
||||
|
||||
app.emit('click', fromNode);
|
||||
await flushAsync();
|
||||
|
||||
expect(targetInstance.ping).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('compActionHandler 找不到目标节点时进入 eventQueue', async () => {
|
||||
const app = new App({
|
||||
config: createDsl({
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page_1',
|
||||
items: [
|
||||
{
|
||||
id: 'btn_1',
|
||||
type: 'button',
|
||||
events: [
|
||||
{
|
||||
name: 'click',
|
||||
actions: [{ actionType: ActionType.COMP, to: 'not-exist', method: 'foo' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any),
|
||||
});
|
||||
|
||||
app.emit('click', app.getNode('btn_1')!);
|
||||
await flushAsync();
|
||||
|
||||
expect(app.eventHelper!.getEventQueue()).toHaveLength(1);
|
||||
expect(app.eventHelper!.getEventQueue()[0].toId).toBe('not-exist');
|
||||
expect(app.eventHelper!.getEventQueue()[0].method).toBe('foo');
|
||||
});
|
||||
|
||||
test('compActionHandler:目标节点没有 instance 时方法入 node.eventQueue', async () => {
|
||||
const app = new App({
|
||||
config: createDsl({
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page_1',
|
||||
items: [
|
||||
{
|
||||
id: 'btn_1',
|
||||
type: 'button',
|
||||
events: [
|
||||
{
|
||||
name: 'click',
|
||||
actions: [{ actionType: ActionType.COMP, to: 'btn_2', method: 'foo' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: 'btn_2', type: 'button' },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any),
|
||||
});
|
||||
|
||||
const targetNode = app.getNode('btn_2')!;
|
||||
app.emit('click', app.getNode('btn_1')!);
|
||||
await flushAsync();
|
||||
|
||||
expect((targetNode as any).eventQueue).toHaveLength(1);
|
||||
expect((targetNode as any).eventQueue[0].method).toBe('foo');
|
||||
});
|
||||
|
||||
test('compActionHandler:method 是数组时取 [to, method]', async () => {
|
||||
const targetInstance = { hi: vi.fn() };
|
||||
const app = new App({
|
||||
config: createDsl({
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page_1',
|
||||
items: [
|
||||
{
|
||||
id: 'btn_1',
|
||||
type: 'button',
|
||||
events: [
|
||||
{
|
||||
name: 'click',
|
||||
actions: [{ actionType: ActionType.COMP, method: ['btn_2', 'hi'] } as any],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: 'btn_2', type: 'button' },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any),
|
||||
});
|
||||
|
||||
app.getNode('btn_2')!.setInstance(targetInstance);
|
||||
app.emit('click', app.getNode('btn_1')!);
|
||||
await flushAsync();
|
||||
|
||||
expect(targetInstance.hi).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('compActionHandler:当前没有 page 时抛错被 errorHandler 捕获(兼容旧配置)', async () => {
|
||||
const errorHandler = vi.fn();
|
||||
const app = new App({
|
||||
errorHandler,
|
||||
config: createDsl({
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page_1',
|
||||
items: [
|
||||
{
|
||||
id: 'btn_1',
|
||||
type: 'button',
|
||||
events: [{ name: 'click', to: 'btn_2', method: 'foo' } as any],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any),
|
||||
});
|
||||
|
||||
const node = app.getNode('btn_1')!;
|
||||
app.page = undefined;
|
||||
|
||||
app.emit('click', node);
|
||||
await flushAsync();
|
||||
await flushAsync();
|
||||
|
||||
expect(errorHandler).toHaveBeenCalled();
|
||||
const lastErr = errorHandler.mock.calls[errorHandler.mock.calls.length - 1][0];
|
||||
expect(lastErr).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
test('compActionHandler:在 pageFragments 中也能找到目标节点', async () => {
|
||||
const app = new App({
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app',
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page_1',
|
||||
items: [
|
||||
{ id: 'pf_container', type: 'page-fragment-container', pageFragmentId: 'pf_1' },
|
||||
{
|
||||
id: 'btn_1',
|
||||
type: 'button',
|
||||
events: [
|
||||
{
|
||||
name: 'click',
|
||||
actions: [{ actionType: ActionType.COMP, to: 'btn_in_pf', method: 'go' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: NodeType.PAGE_FRAGMENT,
|
||||
id: 'pf_1',
|
||||
items: [{ id: 'btn_in_pf', type: 'button' }],
|
||||
},
|
||||
],
|
||||
} as any,
|
||||
});
|
||||
|
||||
const target = app.pageFragments.get('pf_container')!.getNode('btn_in_pf')!;
|
||||
const inst = { go: vi.fn() };
|
||||
target.setInstance(inst);
|
||||
|
||||
app.emit('click', app.getNode('btn_1')!);
|
||||
await flushAsync();
|
||||
|
||||
expect(inst.go).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventHelper - bindDataSourceEvents / removeDataSourceEvents', () => {
|
||||
test('为数据源 schema.events 中自定义事件绑定监听', async () => {
|
||||
const app = new App({
|
||||
config: createDsl({
|
||||
dataSources: [
|
||||
{
|
||||
id: 'ds_1',
|
||||
type: 'base',
|
||||
fields: [],
|
||||
methods: [],
|
||||
events: [
|
||||
{
|
||||
name: 'change',
|
||||
actions: [{ actionType: ActionType.CODE, codeId: 'logCode' } as any],
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
],
|
||||
codeBlocks: {
|
||||
logCode: { name: 'logCode', params: [], content: vi.fn() },
|
||||
},
|
||||
} as any),
|
||||
});
|
||||
|
||||
await flushAsync();
|
||||
const helper = app.eventHelper!;
|
||||
expect(helper).toBeDefined();
|
||||
expect((helper as any).dataSourceEventList.has('ds_1')).toBe(true);
|
||||
const dsEvents: Map<string, any> = (helper as any).dataSourceEventList.get('ds_1');
|
||||
expect(dsEvents.has('change')).toBe(true);
|
||||
|
||||
const ds = app.dataSourceManager!.get('ds_1')!;
|
||||
expect(ds.listenerCount('change')).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('数据源字段变更事件 (DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX) 通过 onDataChange 注册', async () => {
|
||||
const app = new App({
|
||||
config: createDsl({
|
||||
dataSources: [
|
||||
{
|
||||
id: 'ds_1',
|
||||
type: 'base',
|
||||
fields: [{ type: 'string', name: 'foo', title: 'foo', defaultValue: '', enable: true }],
|
||||
methods: [],
|
||||
events: [
|
||||
{
|
||||
name: `${DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX}.foo`,
|
||||
actions: [],
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any),
|
||||
});
|
||||
|
||||
await flushAsync();
|
||||
|
||||
const ds = app.dataSourceManager!.get('ds_1')!;
|
||||
const onDataChangeSpy = vi.spyOn(ds, 'onDataChange');
|
||||
app.eventHelper!.bindDataSourceEvents();
|
||||
expect(onDataChangeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('event.name 为空字符串时跳过绑定', () => {
|
||||
const app = new App({
|
||||
config: createDsl({
|
||||
dataSources: [
|
||||
{
|
||||
id: 'ds_1',
|
||||
type: 'base',
|
||||
fields: [],
|
||||
methods: [],
|
||||
events: [{ name: '', actions: [] } as any],
|
||||
},
|
||||
],
|
||||
} as any),
|
||||
});
|
||||
|
||||
const helper = app.eventHelper!;
|
||||
expect(() => helper.bindDataSourceEvents()).not.toThrow();
|
||||
});
|
||||
|
||||
test('removeDataSourceEvents:当 dataSourceEventList 为空时直接返回', () => {
|
||||
const app = new App({});
|
||||
const helper = new EventHelper({ app });
|
||||
expect(() => helper.removeDataSourceEvents([])).not.toThrow();
|
||||
});
|
||||
|
||||
test('removeDataSourceEvents 会同时清理 onDataChange 与普通事件', async () => {
|
||||
const app = new App({
|
||||
config: createDsl({
|
||||
dataSources: [
|
||||
{
|
||||
id: 'ds_1',
|
||||
type: 'base',
|
||||
fields: [{ type: 'string', name: 'foo', title: 'foo', defaultValue: '', enable: true }],
|
||||
methods: [],
|
||||
events: [
|
||||
{ name: 'change', actions: [] } as any,
|
||||
{ name: `${DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX}.foo`, actions: [] } as any,
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any),
|
||||
});
|
||||
|
||||
await flushAsync();
|
||||
const helper = app.eventHelper!;
|
||||
const ds = app.dataSourceManager!.get('ds_1')!;
|
||||
const offSpy = vi.spyOn(ds, 'off');
|
||||
const offDataChangeSpy = vi.spyOn(ds, 'offDataChange');
|
||||
|
||||
helper.removeDataSourceEvents([ds]);
|
||||
|
||||
expect(offSpy).toHaveBeenCalled();
|
||||
expect(offDataChangeSpy).toHaveBeenCalled();
|
||||
expect((helper as any).dataSourceEventList.size).toBe(0);
|
||||
});
|
||||
|
||||
test('数据源触发自定义事件后会调用配置的 action', async () => {
|
||||
const codeFn = vi.fn();
|
||||
const app = new App({
|
||||
config: createDsl({
|
||||
codeBlocks: { c: { name: 'c', params: [], content: codeFn } },
|
||||
dataSources: [
|
||||
{
|
||||
id: 'ds_1',
|
||||
type: 'base',
|
||||
fields: [],
|
||||
methods: [],
|
||||
events: [
|
||||
{
|
||||
name: 'change',
|
||||
actions: [{ actionType: ActionType.CODE, codeId: 'c' } as any],
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any),
|
||||
});
|
||||
|
||||
await flushAsync();
|
||||
const ds = app.dataSourceManager!.get('ds_1')!;
|
||||
ds.setData({ a: 1 });
|
||||
await flushAsync();
|
||||
expect(codeFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventHelper - flowState 状态管理', () => {
|
||||
test('FlowState abort/reset 行为', () => {
|
||||
const fs = new FlowState();
|
||||
expect(fs.isAbort).toBe(false);
|
||||
fs.abort();
|
||||
expect(fs.isAbort).toBe(true);
|
||||
fs.reset();
|
||||
expect(fs.isAbort).toBe(false);
|
||||
});
|
||||
});
|
||||
57
packages/core/tests/Flexible.spec.ts
Normal file
57
packages/core/tests/Flexible.spec.ts
Normal file
@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import Flexible from '../src/Flexible';
|
||||
|
||||
describe('Flexible', () => {
|
||||
test('实例化默认 designWidth=375 并设置 fontSize', () => {
|
||||
const f = new Flexible();
|
||||
expect(f.designWidth).toBe(375);
|
||||
expect(globalThis.document.body.style.fontSize).toBeDefined();
|
||||
f.destroy();
|
||||
});
|
||||
|
||||
test('options.designWidth 触发 refreshRem 与 fontSize 写入', () => {
|
||||
const f = new Flexible({ designWidth: 750 });
|
||||
expect(f.designWidth).toBe(750);
|
||||
expect(globalThis.document.documentElement.style.fontSize).toMatch(/px$/);
|
||||
f.destroy();
|
||||
});
|
||||
|
||||
test('setDesignWidth 更新数值并 refresh', () => {
|
||||
const f = new Flexible();
|
||||
f.setDesignWidth(414);
|
||||
expect(f.designWidth).toBe(414);
|
||||
f.destroy();
|
||||
});
|
||||
|
||||
test('correctRem 根据计算偏差调整字体', () => {
|
||||
const f = new Flexible();
|
||||
const fontSize = 100;
|
||||
const result = f.correctRem(fontSize);
|
||||
expect(typeof result).toBe('number');
|
||||
f.destroy();
|
||||
});
|
||||
|
||||
test('resize 事件 debounce 调用 refreshRem', async () => {
|
||||
const f = new Flexible();
|
||||
const spy = vi.spyOn(f, 'refreshRem').mockImplementation(() => undefined);
|
||||
globalThis.dispatchEvent(new Event('resize'));
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
f.destroy();
|
||||
});
|
||||
|
||||
test('pageshow persisted 触发 resize 处理', () => {
|
||||
const f = new Flexible();
|
||||
const evt = new Event('pageshow') as any;
|
||||
evt.persisted = true;
|
||||
globalThis.dispatchEvent(evt);
|
||||
f.destroy();
|
||||
});
|
||||
});
|
||||
126
packages/core/tests/Node.spec.ts
Normal file
126
packages/core/tests/Node.spec.ts
Normal file
@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { type MApp, NodeType } from '@tmagic/schema';
|
||||
|
||||
import App from '../src/App';
|
||||
import Node from '../src/Node';
|
||||
|
||||
const baseDsl = (): MApp => ({
|
||||
type: NodeType.ROOT,
|
||||
id: 'app',
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'p1',
|
||||
items: [{ id: 'btn', type: 'button' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('Node 基础', () => {
|
||||
test('实例化时初始化 events / style 默认值', () => {
|
||||
const app = new App({ config: baseDsl() });
|
||||
const node = app.page!.getNode('btn')!;
|
||||
expect(node).toBeInstanceOf(Node);
|
||||
expect(node.events).toEqual([]);
|
||||
expect(node.style).toEqual({});
|
||||
});
|
||||
|
||||
test('setData 更新 events / style 并触发 update-data 事件', () => {
|
||||
const app = new App({ config: baseDsl() });
|
||||
const node = app.page!.getNode('btn')!;
|
||||
const handler = vi.fn();
|
||||
node.on('update-data', handler);
|
||||
node.setData({
|
||||
id: 'btn',
|
||||
type: 'button',
|
||||
events: [{ name: 'click', actions: [] }],
|
||||
style: { color: 'red' },
|
||||
} as any);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(node.events).toHaveLength(1);
|
||||
expect(node.style.color).toBe('red');
|
||||
});
|
||||
|
||||
test('setInstance 与 setData 同步实例的 config', () => {
|
||||
const app = new App({ config: baseDsl() });
|
||||
const node = app.page!.getNode('btn')!;
|
||||
const instance: any = {};
|
||||
node.setInstance(instance);
|
||||
node.setData({ id: 'btn', type: 'button', text: 'changed' } as any);
|
||||
expect(instance.config?.text).toBe('changed');
|
||||
});
|
||||
|
||||
test('frozen instance 时 setData 不抛错', () => {
|
||||
const app = new App({ config: baseDsl() });
|
||||
const node = app.page!.getNode('btn')!;
|
||||
const frozen = Object.freeze({ __isVue: false });
|
||||
node.setInstance(frozen);
|
||||
expect(() => node.setData({ id: 'btn', type: 'button' } as any)).not.toThrow();
|
||||
});
|
||||
|
||||
test('addEventToQueue 入队', () => {
|
||||
const app = new App({ config: baseDsl() });
|
||||
const node = app.page!.getNode('btn')!;
|
||||
node.addEventToQueue({ method: 'm', fromCpt: null, args: [1, 2] });
|
||||
expect((node as any).eventQueue).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('registerMethod (deprecated) 注入实例方法', () => {
|
||||
const app = new App({ config: baseDsl() });
|
||||
const node = app.page!.getNode('btn')!;
|
||||
node.registerMethod({ doIt: () => 'ok', notFn: 'x' as any });
|
||||
expect(node.instance.doIt()).toBe('ok');
|
||||
expect(node.instance.notFn).toBeUndefined();
|
||||
node.registerMethod(undefined as any);
|
||||
});
|
||||
|
||||
test('runHookCode 函数式回退', async () => {
|
||||
const app = new App({ config: baseDsl() });
|
||||
const node = app.page!.getNode('btn')!;
|
||||
const fn = vi.fn();
|
||||
(node.data as any).created = fn;
|
||||
await node.runHookCode('created');
|
||||
expect(fn).toHaveBeenCalledWith(node);
|
||||
});
|
||||
|
||||
test('runHookCode 数据格式不匹配时不报错', async () => {
|
||||
const app = new App({ config: baseDsl() });
|
||||
const node = app.page!.getNode('btn')!;
|
||||
(node.data as any).onSomething = { hookType: 'other' };
|
||||
await expect(node.runHookCode('onSomething')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('destroy 清理状态与监听', () => {
|
||||
const app = new App({ config: baseDsl() });
|
||||
const node = app.page!.getNode('btn')!;
|
||||
const handler = vi.fn();
|
||||
node.on('test', handler);
|
||||
node.destroy();
|
||||
node.emit('test');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(node.instance).toBeNull();
|
||||
expect(node.events).toEqual([]);
|
||||
});
|
||||
|
||||
test('created/destroy 生命周期触发 hook', async () => {
|
||||
const app = new App({ config: baseDsl() });
|
||||
const codeFn = vi.fn();
|
||||
app.codeDsl = {
|
||||
hello: { name: 'hello', content: codeFn, params: [] },
|
||||
} as any;
|
||||
const node = app.page!.getNode('btn')!;
|
||||
(node.data as any).created = {
|
||||
hookType: 'code',
|
||||
hookData: [{ codeId: 'hello', params: {} }],
|
||||
};
|
||||
node.emit('created', null);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(codeFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -1,8 +1,9 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import App from '@tmagic/core';
|
||||
|
||||
import { DataSource } from '@data-source/index';
|
||||
import { DeepObservedData } from '@data-source/observed-data';
|
||||
|
||||
describe('DataSource', () => {
|
||||
test('instance', () => {
|
||||
@ -111,3 +112,130 @@ describe('DataSource setData', () => {
|
||||
expect(ds.data.obj.a).toBe('a1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataSource lifecycle / mock', () => {
|
||||
test('编辑器中使用 mock 数据', () => {
|
||||
const app = new App({}) as any;
|
||||
app.platform = 'editor';
|
||||
const ds = new DataSource({
|
||||
app,
|
||||
schema: {
|
||||
type: 'base',
|
||||
id: '1',
|
||||
fields: [{ name: 'name' }],
|
||||
methods: [],
|
||||
events: [],
|
||||
mocks: [{ useInEditor: true, data: { name: 'mock' }, enable: true }],
|
||||
} as any,
|
||||
});
|
||||
expect(ds.data.name).toBe('mock');
|
||||
});
|
||||
|
||||
test('useMock=true 在运行时使用 mock', () => {
|
||||
const ds = new DataSource({
|
||||
app: new App({}),
|
||||
useMock: true,
|
||||
schema: {
|
||||
type: 'base',
|
||||
id: '1',
|
||||
fields: [{ name: 'name' }],
|
||||
methods: [],
|
||||
events: [],
|
||||
mocks: [{ enable: true, data: { name: 'enabled-mock' } }],
|
||||
} as any,
|
||||
});
|
||||
expect(ds.data.name).toBe('enabled-mock');
|
||||
});
|
||||
|
||||
test('initialData 优先时设置 isInit', () => {
|
||||
const ds = new DataSource({
|
||||
app: new App({}),
|
||||
initialData: { name: 'preset' },
|
||||
schema: {
|
||||
type: 'base',
|
||||
id: '1',
|
||||
fields: [{ name: 'name' }],
|
||||
methods: [],
|
||||
events: [],
|
||||
},
|
||||
});
|
||||
expect(ds.isInit).toBe(true);
|
||||
expect(ds.data.name).toBe('preset');
|
||||
});
|
||||
|
||||
test('支持自定义 ObservedDataClass', () => {
|
||||
const ds = new DataSource({
|
||||
app: new App({}),
|
||||
ObservedDataClass: DeepObservedData,
|
||||
schema: {
|
||||
type: 'base',
|
||||
id: '1',
|
||||
fields: [{ name: 'name' }],
|
||||
methods: [],
|
||||
events: [],
|
||||
},
|
||||
});
|
||||
const cb = vi.fn();
|
||||
ds.onDataChange('name', cb);
|
||||
ds.setData('next', 'name');
|
||||
expect(cb).toHaveBeenCalled();
|
||||
ds.offDataChange('name', cb);
|
||||
});
|
||||
|
||||
test('setValue 等价于按 path 的 setData 并发出 change', () => {
|
||||
const ds = new DataSource({
|
||||
app: new App({}),
|
||||
schema: {
|
||||
type: 'base',
|
||||
id: '1',
|
||||
fields: [{ name: 'name' }],
|
||||
methods: [],
|
||||
events: [],
|
||||
},
|
||||
});
|
||||
const change = vi.fn();
|
||||
ds.on('change', change);
|
||||
ds.setValue('name', 'V');
|
||||
expect(ds.data.name).toBe('V');
|
||||
expect(change).toHaveBeenCalledWith({ updateData: 'V', path: 'name' });
|
||||
});
|
||||
|
||||
test('setFields / setMethods / DATA_SOURCE_SET_DATA_METHOD_NAME 自动注入', () => {
|
||||
const ds = new DataSource({
|
||||
app: new App({}),
|
||||
schema: {
|
||||
type: 'base',
|
||||
id: '1',
|
||||
fields: [{ name: 'name' }],
|
||||
methods: [],
|
||||
events: [],
|
||||
},
|
||||
});
|
||||
ds.setFields([{ name: 'foo' }] as any);
|
||||
expect(ds.fields[0].name).toBe('foo');
|
||||
ds.setMethods([{ name: 'doIt' } as any]);
|
||||
expect(ds.methods[0].name).toBe('doIt');
|
||||
|
||||
(ds as any).setDataFromEvent({ params: { field: ['name'], data: 'X' } });
|
||||
expect(ds.data.name).toBe('X');
|
||||
});
|
||||
|
||||
test('destroy 清理 fields 与监听', () => {
|
||||
const ds = new DataSource({
|
||||
app: new App({}),
|
||||
schema: {
|
||||
type: 'base',
|
||||
id: '1',
|
||||
fields: [{ name: 'name' }],
|
||||
methods: [],
|
||||
events: [],
|
||||
},
|
||||
});
|
||||
const handler = vi.fn();
|
||||
ds.on('change', handler);
|
||||
ds.destroy();
|
||||
expect(ds.fields).toHaveLength(0);
|
||||
ds.emit('change', {});
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,8 +1,15 @@
|
||||
import { afterAll, describe, expect, test } from 'vitest';
|
||||
import { afterAll, afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import TMagicApp, { NodeType } from '@tmagic/core';
|
||||
import TMagicApp, {
|
||||
type MApp,
|
||||
NODE_CONDS_KEY,
|
||||
NODE_CONDS_RESULT_KEY,
|
||||
NODE_DISABLE_DATA_SOURCE_KEY,
|
||||
NodeType,
|
||||
} from '@tmagic/core';
|
||||
|
||||
import { DataSource, DataSourceManager } from '@data-source/index';
|
||||
import { SimpleObservedData } from '@data-source/observed-data/SimpleObservedData';
|
||||
|
||||
const app = new TMagicApp({
|
||||
config: {
|
||||
@ -93,3 +100,628 @@ describe('DataSourceManager', () => {
|
||||
expect(dsm.get('1')).toBeInstanceOf(DataSource);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataSourceManager - 注册 / 等待 / observedData', () => {
|
||||
test('register 注册新的数据源类', () => {
|
||||
class Custom extends DataSource {}
|
||||
DataSourceManager.register('custom-1', Custom as any);
|
||||
expect(DataSourceManager.getDataSourceClass('custom-1')).toBe(Custom);
|
||||
DataSourceManager.clearDataSourceClass();
|
||||
expect(DataSourceManager.getDataSourceClass('custom-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('initialData 在构造时被合并到 data', () => {
|
||||
const dsm = new DataSourceManager({
|
||||
app: new TMagicApp({}),
|
||||
initialData: { 1: { name: 'preset' } },
|
||||
});
|
||||
expect(dsm.data['1']).toEqual({ name: 'preset' });
|
||||
expect(dsm.initialData['1']).toEqual({ name: 'preset' });
|
||||
});
|
||||
|
||||
test('useMock 可被读取', () => {
|
||||
const dsm = new DataSourceManager({ app: new TMagicApp({}), useMock: true });
|
||||
expect(dsm.useMock).toBe(true);
|
||||
});
|
||||
|
||||
test('registerObservedData 静态方法', () => {
|
||||
class Fake {}
|
||||
expect(() => DataSourceManager.registerObservedData(Fake as any)).not.toThrow();
|
||||
// 用完恢复,避免污染后续用例
|
||||
DataSourceManager.registerObservedData(SimpleObservedData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataSourceManager - init 生命周期', () => {
|
||||
afterEach(() => {
|
||||
DataSourceManager.clearDataSourceClass();
|
||||
});
|
||||
|
||||
const createApp = (jsEngine?: any) =>
|
||||
new TMagicApp({
|
||||
// jsEngine 选填,用于走 init 中的 jsEngine 分支
|
||||
...(jsEngine ? { jsEngine } : {}),
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app_init',
|
||||
items: [],
|
||||
},
|
||||
} as any);
|
||||
|
||||
test('ds.isInit 为 true 时直接跳过', async () => {
|
||||
const dsm = new DataSourceManager({ app: createApp() });
|
||||
const ds = new DataSource({
|
||||
app: createApp(),
|
||||
schema: { type: 'base', id: 'ds_skip', fields: [], methods: [], events: [] },
|
||||
});
|
||||
ds.isInit = true;
|
||||
await dsm.init(ds);
|
||||
// isInit 仍为 true,且没有抛错
|
||||
expect(ds.isInit).toBe(true);
|
||||
});
|
||||
|
||||
test('jsEngine 命中 disabledInitInJsEngine 时跳过 init', async () => {
|
||||
const app = createApp('nodejs');
|
||||
const dsm = new DataSourceManager({ app });
|
||||
const ds = new DataSource({
|
||||
app,
|
||||
schema: {
|
||||
type: 'base',
|
||||
id: 'ds_disabled',
|
||||
fields: [],
|
||||
methods: [],
|
||||
events: [],
|
||||
disabledInitInJsEngine: ['nodejs'],
|
||||
} as any,
|
||||
});
|
||||
expect(ds.isInit).toBe(false);
|
||||
await dsm.init(ds);
|
||||
expect(ds.isInit).toBe(false);
|
||||
});
|
||||
|
||||
test('methods 中 timing=beforeInit 的 content 会在 ds.init 之前调用', async () => {
|
||||
const app = createApp();
|
||||
const dsm = new DataSourceManager({ app });
|
||||
const beforeContent = vi.fn();
|
||||
const ds = new DataSource({
|
||||
app,
|
||||
schema: {
|
||||
type: 'base',
|
||||
id: 'ds_before',
|
||||
fields: [],
|
||||
events: [],
|
||||
methods: [{ name: 'before', content: beforeContent, timing: 'beforeInit', params: [] }],
|
||||
} as any,
|
||||
});
|
||||
await dsm.init(ds);
|
||||
expect(beforeContent).toHaveBeenCalledTimes(1);
|
||||
const arg = beforeContent.mock.calls[0][0];
|
||||
expect(arg.dataSource).toBe(ds);
|
||||
expect(arg.app).toBe(app);
|
||||
expect(ds.isInit).toBe(true);
|
||||
});
|
||||
|
||||
test('methods 中 timing=afterInit 的 content 会在 ds.init 之后调用', async () => {
|
||||
const app = createApp();
|
||||
const dsm = new DataSourceManager({ app });
|
||||
const order: string[] = [];
|
||||
const afterContent = vi.fn(() => {
|
||||
order.push('after');
|
||||
});
|
||||
const ds = new DataSource({
|
||||
app,
|
||||
schema: {
|
||||
type: 'base',
|
||||
id: 'ds_after',
|
||||
fields: [],
|
||||
events: [],
|
||||
methods: [{ name: 'after', content: afterContent, timing: 'afterInit', params: [] }],
|
||||
} as any,
|
||||
});
|
||||
const origInit = ds.init.bind(ds);
|
||||
ds.init = async () => {
|
||||
order.push('init');
|
||||
await origInit();
|
||||
};
|
||||
await dsm.init(ds);
|
||||
expect(afterContent).toHaveBeenCalledTimes(1);
|
||||
expect(order).toEqual(['init', 'after']);
|
||||
});
|
||||
|
||||
test('method.content 非函数时 init 提前返回,不会执行 ds.init', async () => {
|
||||
const app = createApp();
|
||||
const dsm = new DataSourceManager({ app });
|
||||
const ds = new DataSource({
|
||||
app,
|
||||
schema: {
|
||||
type: 'base',
|
||||
id: 'ds_bad_method',
|
||||
fields: [],
|
||||
events: [],
|
||||
methods: [{ name: 'bad', content: 'not-a-function', timing: 'beforeInit', params: [] } as any],
|
||||
} as any,
|
||||
});
|
||||
const initSpy = vi.spyOn(ds, 'init');
|
||||
await dsm.init(ds);
|
||||
expect(initSpy).not.toHaveBeenCalled();
|
||||
expect(ds.isInit).toBe(false);
|
||||
});
|
||||
|
||||
test('afterInit 阶段遇到非函数 content 也会提前返回', async () => {
|
||||
const app = createApp();
|
||||
const dsm = new DataSourceManager({ app });
|
||||
const afterFn = vi.fn();
|
||||
|
||||
const ds = new DataSource({
|
||||
app,
|
||||
schema: {
|
||||
type: 'base',
|
||||
id: 'ds_after_bad',
|
||||
fields: [],
|
||||
events: [],
|
||||
methods: [{ name: 'before', content: () => undefined, timing: 'beforeInit', params: [] } as any],
|
||||
} as any,
|
||||
});
|
||||
// ds.init 执行之后再向 methods 中追加一个 content 非函数的 afterInit 项
|
||||
const origInit = ds.init.bind(ds);
|
||||
ds.init = async () => {
|
||||
await origInit();
|
||||
ds.setMethods([
|
||||
{ name: 'bad', content: 'not-a-function', timing: 'afterInit', params: [] } as any,
|
||||
{ name: 'after', content: afterFn, timing: 'afterInit', params: [] } as any,
|
||||
]);
|
||||
};
|
||||
|
||||
await dsm.init(ds);
|
||||
// 第二个循环在第一个非函数 content 处提前返回,afterFn 不会被调用
|
||||
expect(afterFn).not.toHaveBeenCalled();
|
||||
expect(ds.isInit).toBe(true);
|
||||
});
|
||||
|
||||
test('beforeInit / afterInit 同时存在但 timing 不匹配时安全跳过', async () => {
|
||||
const app = createApp();
|
||||
const dsm = new DataSourceManager({ app });
|
||||
const beforeFn = vi.fn();
|
||||
const afterFn = vi.fn();
|
||||
const ds = new DataSource({
|
||||
app,
|
||||
schema: {
|
||||
type: 'base',
|
||||
id: 'ds_mixed',
|
||||
fields: [],
|
||||
events: [],
|
||||
methods: [
|
||||
{ name: 'b', content: beforeFn, timing: 'beforeInit', params: [] } as any,
|
||||
{ name: 'a', content: afterFn, timing: 'afterInit', params: [] } as any,
|
||||
],
|
||||
} as any,
|
||||
});
|
||||
await dsm.init(ds);
|
||||
expect(beforeFn).toHaveBeenCalledTimes(1);
|
||||
expect(afterFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataSourceManager - addDataSource 边界', () => {
|
||||
afterEach(() => {
|
||||
DataSourceManager.clearDataSourceClass();
|
||||
});
|
||||
|
||||
test('config 为空时直接返回 undefined', () => {
|
||||
const dsm = new DataSourceManager({ app: new TMagicApp({}) });
|
||||
expect(dsm.addDataSource(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('destroy 后 waitInitSchemaList 为空,再次加入未知类型会重建 listMap', () => {
|
||||
const dsm = new DataSourceManager({ app: new TMagicApp({}) });
|
||||
dsm.destroy();
|
||||
const ret = dsm.addDataSource({
|
||||
id: 'ds_unknown_after_destroy',
|
||||
type: 'never-registered',
|
||||
fields: [{ name: 'a', defaultValue: 1 }],
|
||||
methods: [],
|
||||
events: [],
|
||||
} as any);
|
||||
expect(ret).toBeUndefined();
|
||||
expect(dsm.data.ds_unknown_after_destroy).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
test('多次加入同一未知类型会推到等待列表', () => {
|
||||
const dsm = new DataSourceManager({ app: new TMagicApp({}) });
|
||||
dsm.addDataSource({
|
||||
id: 'pending_1',
|
||||
type: 'pending-shared',
|
||||
fields: [],
|
||||
methods: [],
|
||||
events: [],
|
||||
} as any);
|
||||
dsm.addDataSource({
|
||||
id: 'pending_2',
|
||||
type: 'pending-shared',
|
||||
fields: [],
|
||||
methods: [],
|
||||
events: [],
|
||||
} as any);
|
||||
|
||||
class SharedDS extends DataSource {}
|
||||
DataSourceManager.register('pending-shared', SharedDS as any);
|
||||
|
||||
expect(dsm.get('pending_1')).toBeInstanceOf(SharedDS);
|
||||
expect(dsm.get('pending_2')).toBeInstanceOf(SharedDS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataSourceManager - updateSchema 边界', () => {
|
||||
afterEach(() => {
|
||||
DataSourceManager.clearDataSourceClass();
|
||||
});
|
||||
|
||||
test('传入的 schema 在 manager 中不存在时直接 return', () => {
|
||||
const dsm = new DataSourceManager({
|
||||
app: new TMagicApp({
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app_us',
|
||||
items: [],
|
||||
dataSources: [{ type: 'base', id: 'real', fields: [{ name: 'a' }], methods: [], events: [] }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(dsm.get('real')).toBeInstanceOf(DataSource);
|
||||
dsm.updateSchema([
|
||||
{ type: 'base', id: 'not_exist', fields: [{ name: 'b' }], methods: [], events: [] },
|
||||
{ type: 'base', id: 'real', fields: [{ name: 'a' }], methods: [], events: [] },
|
||||
]);
|
||||
// real 没有被删除/重建(因为遇到 not_exist 时整个 updateSchema 提前 return)
|
||||
expect(dsm.get('real')).toBeInstanceOf(DataSource);
|
||||
});
|
||||
|
||||
test('updateSchema 中新 type 未注册时不会调用 init', () => {
|
||||
const dsm = new DataSourceManager({
|
||||
app: new TMagicApp({
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app_us2',
|
||||
items: [],
|
||||
dataSources: [{ type: 'base', id: 'X', fields: [], methods: [], events: [] }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(dsm.get('X')).toBeInstanceOf(DataSource);
|
||||
dsm.updateSchema([{ type: 'never-registered', id: 'X', fields: [], methods: [], events: [] } as any]);
|
||||
expect(dsm.get('X')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataSourceManager - compiledNode 边界', () => {
|
||||
afterEach(() => {
|
||||
DataSourceManager.clearDataSourceClass();
|
||||
});
|
||||
|
||||
const createManager = () =>
|
||||
new DataSourceManager({
|
||||
app: new TMagicApp({
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app_cn',
|
||||
items: [],
|
||||
dataSources: [
|
||||
{
|
||||
type: 'base',
|
||||
id: 'ds_cn',
|
||||
fields: [{ name: 'val', defaultValue: 'V' }],
|
||||
methods: [],
|
||||
events: [],
|
||||
},
|
||||
],
|
||||
dataSourceDeps: {
|
||||
ds_cn: {
|
||||
text_a: { name: 'text', keys: ['text'] },
|
||||
},
|
||||
} as any,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
test('节点带 NODE_DISABLE_DATA_SOURCE_KEY 时直接返回原节点', () => {
|
||||
const dsm = createManager();
|
||||
const node: any = {
|
||||
id: 'text_a',
|
||||
type: 'text',
|
||||
text: 'hello ${ds_cn.val}',
|
||||
[NODE_DISABLE_DATA_SOURCE_KEY]: true,
|
||||
};
|
||||
expect(dsm.compiledNode(node)).toBe(node);
|
||||
});
|
||||
|
||||
test('deep=true 时数组 items 会递归编译', () => {
|
||||
const dsm = createManager();
|
||||
const node: any = {
|
||||
id: 'wrap',
|
||||
type: 'container',
|
||||
items: [{ id: 'text_a', type: 'text', text: 'hi ${ds_cn.val}' }],
|
||||
};
|
||||
const compiled: any = dsm.compiledNode(node, undefined, true);
|
||||
expect(compiled.items[0].text).toBe('hi V');
|
||||
});
|
||||
|
||||
test('deep=false 时 items 保持原样', () => {
|
||||
const dsm = createManager();
|
||||
const items = [{ id: 'text_a', type: 'text', text: 'hi ${ds_cn.val}' }];
|
||||
const node: any = { id: 'wrap', type: 'container', items };
|
||||
const compiled: any = dsm.compiledNode(node);
|
||||
expect(compiled.items).toBe(items);
|
||||
});
|
||||
|
||||
test('节点 condResult=false 时跳过模板编译', () => {
|
||||
const dsm = createManager();
|
||||
const node: any = {
|
||||
id: 'text_a',
|
||||
type: 'text',
|
||||
text: 'hi ${ds_cn.val}',
|
||||
condResult: false,
|
||||
};
|
||||
const compiled: any = dsm.compiledNode(node);
|
||||
expect(compiled.text).toBe('hi ${ds_cn.val}');
|
||||
});
|
||||
|
||||
test('condResult=undefined 且 NODE_CONDS_RESULT_KEY=true 时也跳过模板编译', () => {
|
||||
const dsm = createManager();
|
||||
const node: any = {
|
||||
id: 'text_a',
|
||||
type: 'text',
|
||||
text: 'hi ${ds_cn.val}',
|
||||
[NODE_CONDS_RESULT_KEY]: true,
|
||||
};
|
||||
const compiled: any = dsm.compiledNode(node);
|
||||
expect(compiled.text).toBe('hi ${ds_cn.val}');
|
||||
});
|
||||
|
||||
test('dsl.dataSourceDeps 缺失时使用空依赖对象', () => {
|
||||
const app = new TMagicApp({
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app_no_deps',
|
||||
items: [],
|
||||
dataSources: [
|
||||
{ type: 'base', id: 'ds_nd', fields: [{ name: 'v', defaultValue: 'V' }], methods: [], events: [] },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(app.dsl?.dataSourceDeps).toBeUndefined();
|
||||
const dsm = new DataSourceManager({ app });
|
||||
const node: any = { id: 'p', type: 'text', text: 'hi' };
|
||||
const compiled = dsm.compiledNode(node) as any;
|
||||
expect(compiled.text).toBe('hi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataSourceManager - compliedConds 边界', () => {
|
||||
afterEach(() => {
|
||||
DataSourceManager.clearDataSourceClass();
|
||||
});
|
||||
|
||||
test('NODE_DISABLE_DATA_SOURCE_KEY=true 时直接返回 true', () => {
|
||||
const dsm = new DataSourceManager({ app: new TMagicApp({}) });
|
||||
expect(
|
||||
dsm.compliedConds({
|
||||
[NODE_DISABLE_DATA_SOURCE_KEY]: true,
|
||||
[NODE_CONDS_KEY]: [{ cond: [{ field: ['ds_1', 'a'], op: '=', value: 1 }] }] as any,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('NODE_CONDS_RESULT_KEY 为真时会对条件结果取反', () => {
|
||||
const dsm = new DataSourceManager({ app: new TMagicApp({}) });
|
||||
dsm.data.ds_x = { a: 1 };
|
||||
// 条件成立 -> compliedConditions 返回 true,再取反应为 false
|
||||
expect(
|
||||
dsm.compliedConds({
|
||||
[NODE_CONDS_KEY]: [{ cond: [{ field: ['ds_x', 'a'], op: '=', value: 1 }] }] as any,
|
||||
[NODE_CONDS_RESULT_KEY]: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataSourceManager - 迭代器相关方法', () => {
|
||||
afterEach(() => {
|
||||
DataSourceManager.clearDataSourceClass();
|
||||
});
|
||||
|
||||
const createManager = () =>
|
||||
new DataSourceManager({
|
||||
app: new TMagicApp({
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app_iter',
|
||||
items: [],
|
||||
dataSources: [
|
||||
{
|
||||
type: 'base',
|
||||
id: 'ds_iter',
|
||||
fields: [
|
||||
{
|
||||
name: 'list',
|
||||
type: 'array',
|
||||
fields: [{ name: 'label' }],
|
||||
defaultValue: [{ label: 'A' }],
|
||||
},
|
||||
],
|
||||
methods: [],
|
||||
events: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
test('compliedIteratorItemConds: dataSourceField 指向未知数据源时返回 true', () => {
|
||||
const dsm = createManager();
|
||||
const result = dsm.compliedIteratorItemConds(
|
||||
{ label: 'x' },
|
||||
{ [NODE_CONDS_KEY]: [{ cond: [{ field: ['ds_iter', 'list', 'label'], op: '=', value: 'x' }] }] } as any,
|
||||
['no_such_ds', 'list'],
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('compliedIteratorItemConds: 使用迭代上下文计算条件', () => {
|
||||
const dsm = createManager();
|
||||
const node: any = {
|
||||
[NODE_CONDS_KEY]: [{ cond: [{ field: ['ds_iter', 'list', 'label'], op: '=', value: 'B' }] }],
|
||||
};
|
||||
expect(dsm.compliedIteratorItemConds({ label: 'B' }, node, ['ds_iter', 'list'])).toBe(true);
|
||||
expect(dsm.compliedIteratorItemConds({ label: 'A' }, node, ['ds_iter', 'list'])).toBe(false);
|
||||
});
|
||||
|
||||
test('compliedIteratorItems: 未知数据源时原样返回 nodes', () => {
|
||||
const dsm = createManager();
|
||||
const nodes: any = [{ id: 'iter_1', type: 'text', text: '${ds_iter.list.label}' }];
|
||||
expect(dsm.compliedIteratorItems({ label: 'B' }, nodes, ['no_such_ds'])).toBe(nodes);
|
||||
});
|
||||
|
||||
test('compliedIteratorItems: 无 deps / condDeps 时原样返回 nodes', () => {
|
||||
const dsm = createManager();
|
||||
const nodes: any = [{ id: 'plain', type: 'text', text: 'plain' }];
|
||||
expect(dsm.compliedIteratorItems({ label: 'B' }, nodes, ['ds_iter', 'list'])).toBe(nodes);
|
||||
});
|
||||
|
||||
test('compliedIteratorItems: 命中 deps 时按迭代上下文进行编译', () => {
|
||||
const dsm = createManager();
|
||||
const nodes: any = [{ id: 'iter_text', type: 'text', text: 'hello ${ds_iter.list.label}' }];
|
||||
const compiled = dsm.compliedIteratorItems({ label: 'B' }, nodes, ['ds_iter', 'list']);
|
||||
expect(compiled[0]).not.toBe(nodes[0]);
|
||||
expect((compiled[0] as any).text).toBe('hello B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataSourceManager - onDataChange / offDataChange', () => {
|
||||
afterEach(() => {
|
||||
DataSourceManager.clearDataSourceClass();
|
||||
});
|
||||
|
||||
test('onDataChange / offDataChange 转发到对应数据源', () => {
|
||||
const dsm = new DataSourceManager({
|
||||
app: new TMagicApp({
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app_odc',
|
||||
items: [],
|
||||
dataSources: [{ type: 'base', id: 'ds_odc', fields: [{ name: 'name' }], methods: [], events: [] }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const callback = vi.fn();
|
||||
dsm.onDataChange('ds_odc', 'name', callback);
|
||||
|
||||
const ds = dsm.get('ds_odc')!;
|
||||
ds.setData('A', 'name');
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
dsm.offDataChange('ds_odc', 'name', callback);
|
||||
ds.setData('B', 'name');
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('数据源不存在时 onDataChange / offDataChange 安全返回 undefined', () => {
|
||||
const dsm = new DataSourceManager({ app: new TMagicApp({}) });
|
||||
const callback = vi.fn();
|
||||
expect(dsm.onDataChange('no_id', 'a', callback)).toBeUndefined();
|
||||
expect(dsm.offDataChange('no_id', 'a', callback)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataSourceManager - callDsInit 异常 / 兼容分支', () => {
|
||||
afterEach(() => {
|
||||
DataSourceManager.clearDataSourceClass();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const buildConfig = (id: string): MApp => ({
|
||||
type: NodeType.ROOT,
|
||||
id,
|
||||
items: [],
|
||||
dataSources: [
|
||||
{ type: 'base', id: 'ds_ok', fields: [{ name: 'a', defaultValue: 1 }], methods: [], events: [] },
|
||||
{ type: 'base', id: 'ds_err', fields: [{ name: 'b', defaultValue: 2 }], methods: [], events: [] },
|
||||
],
|
||||
});
|
||||
|
||||
test('init 完成但 this.data[dsId] 为空时走 delete 分支', async () => {
|
||||
const app = new TMagicApp({ config: buildConfig('app_empty_data') });
|
||||
const dsm = new DataSourceManager({ app });
|
||||
// 在 Promise.allSettled 的 .then() 微任务执行之前把 data 清空
|
||||
dsm.data = {} as any;
|
||||
|
||||
const [data, errors] = await new Promise<any[]>((resolve) => {
|
||||
dsm.once('init', (...args: any[]) => resolve(args));
|
||||
});
|
||||
// 由于 this.data[dsId] 为空,data 中也不会包含对应 dsId
|
||||
expect(data.ds_ok).toBeUndefined();
|
||||
expect(data.ds_err).toBeUndefined();
|
||||
expect(Object.keys(errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('init 抛错时通过 Promise.allSettled 的 rejected 分支收集 errors', async () => {
|
||||
const initSpy = vi.spyOn(DataSource.prototype, 'init').mockImplementation(async function (this: DataSource) {
|
||||
if (this.id === 'ds_err') {
|
||||
throw new Error('boom');
|
||||
}
|
||||
// ok 路径
|
||||
(this as any).isInit = true;
|
||||
});
|
||||
|
||||
const app = new TMagicApp({ config: buildConfig('app_err') });
|
||||
const dsm = new DataSourceManager({ app });
|
||||
|
||||
const [data, errors] = await new Promise<any[]>((resolve) => {
|
||||
dsm.once('init', (...args: any[]) => resolve(args));
|
||||
});
|
||||
expect(data.ds_ok).toEqual({ a: 1 });
|
||||
expect(data.ds_err).toBeUndefined();
|
||||
expect(errors.ds_err).toBeInstanceOf(Error);
|
||||
expect(errors.ds_err.message).toBe('boom');
|
||||
|
||||
initSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('Promise.allSettled 不可用时走 Promise.all 兼容分支并发出 init 事件', async () => {
|
||||
const original = Promise.allSettled;
|
||||
(Promise as any).allSettled = undefined;
|
||||
|
||||
try {
|
||||
const app = new TMagicApp({ config: buildConfig('app_compat') });
|
||||
const dsm = new DataSourceManager({ app });
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
dsm.once('init', () => resolve());
|
||||
});
|
||||
expect(dsm.data.ds_ok).toEqual({ a: 1 });
|
||||
expect(dsm.data.ds_err).toEqual({ b: 2 });
|
||||
} finally {
|
||||
(Promise as any).allSettled = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('Promise.allSettled 不可用且 init 抛错时进入 catch 分支', async () => {
|
||||
const original = Promise.allSettled;
|
||||
(Promise as any).allSettled = undefined;
|
||||
const initSpy = vi.spyOn(DataSource.prototype, 'init').mockRejectedValue(new Error('compat-boom'));
|
||||
|
||||
try {
|
||||
const app = new TMagicApp({ config: buildConfig('app_compat_err') });
|
||||
const dsm = new DataSourceManager({ app });
|
||||
|
||||
// 在兼容路径下,catch 分支也会发 init 事件
|
||||
const data = await new Promise<any>((resolve) => {
|
||||
dsm.once('init', (...args: any[]) => resolve(args[0]));
|
||||
});
|
||||
expect(data).toBeDefined();
|
||||
} finally {
|
||||
(Promise as any).allSettled = original;
|
||||
initSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
237
packages/data-source/tests/HttpDataSource.spec.ts
Normal file
237
packages/data-source/tests/HttpDataSource.spec.ts
Normal file
@ -0,0 +1,237 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import App from '@tmagic/core';
|
||||
|
||||
import { HttpDataSource } from '@data-source/data-sources';
|
||||
|
||||
const createSchema = (overrides: Partial<any> = {}) => ({
|
||||
type: 'http',
|
||||
id: 'http_1',
|
||||
fields: [{ name: 'name' }],
|
||||
methods: [],
|
||||
events: [],
|
||||
options: {
|
||||
url: 'https://example.com/api',
|
||||
method: 'GET',
|
||||
params: {},
|
||||
data: {},
|
||||
headers: {},
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('HttpDataSource 基础', () => {
|
||||
test('实例化时记录 httpOptions / type', () => {
|
||||
const ds = new HttpDataSource({
|
||||
schema: createSchema() as any,
|
||||
app: new App({}),
|
||||
});
|
||||
expect(ds).toBeInstanceOf(HttpDataSource);
|
||||
expect(ds.type).toBe('http');
|
||||
expect(ds.httpOptions.url).toBe('https://example.com/api');
|
||||
});
|
||||
|
||||
test('优先使用自定义 request', async () => {
|
||||
const request = vi.fn().mockResolvedValue({ name: 'from-request' });
|
||||
const ds = new HttpDataSource({
|
||||
schema: createSchema() as any,
|
||||
app: new App({}),
|
||||
request,
|
||||
});
|
||||
await ds.request();
|
||||
expect(request).toHaveBeenCalled();
|
||||
expect(ds.data.name).toBe('from-request');
|
||||
expect(ds.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('autoFetch=true 在 init 时主动请求', async () => {
|
||||
const request = vi.fn().mockResolvedValue({ name: 'auto' });
|
||||
const ds = new HttpDataSource({
|
||||
schema: createSchema({ autoFetch: true }) as any,
|
||||
app: new App({}),
|
||||
request,
|
||||
});
|
||||
await ds.init();
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(ds.isInit).toBe(true);
|
||||
});
|
||||
|
||||
test('beforeRequest / afterResponse 钩子被调用', async () => {
|
||||
const beforeRequest = vi.fn(async (opt: any) => ({ ...opt, params: { extra: 1 } }));
|
||||
const afterResponse = vi.fn(async (res: any) => ({ ...res, name: 'after' }));
|
||||
const request = vi.fn().mockResolvedValue({ name: 'origin' });
|
||||
const ds = new HttpDataSource({
|
||||
schema: createSchema({ beforeRequest, afterResponse }) as any,
|
||||
app: new App({}),
|
||||
request,
|
||||
});
|
||||
await ds.request();
|
||||
expect(beforeRequest).toHaveBeenCalled();
|
||||
expect(afterResponse).toHaveBeenCalled();
|
||||
expect(ds.data.name).toBe('after');
|
||||
});
|
||||
|
||||
test('responseOptions.dataPath 截取响应字段', async () => {
|
||||
const request = vi.fn().mockResolvedValue({ data: { name: 'inner' } });
|
||||
const ds = new HttpDataSource({
|
||||
schema: createSchema({ responseOptions: { dataPath: 'data' } }) as any,
|
||||
app: new App({}),
|
||||
request,
|
||||
});
|
||||
await ds.request();
|
||||
expect(ds.data.name).toBe('inner');
|
||||
});
|
||||
|
||||
test('请求失败时填充 error 并触发 error 事件', async () => {
|
||||
const request = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
const ds = new HttpDataSource({
|
||||
schema: createSchema() as any,
|
||||
app: new App({}),
|
||||
request,
|
||||
});
|
||||
const errorHandler = vi.fn();
|
||||
ds.on('error', errorHandler);
|
||||
await ds.request();
|
||||
expect(ds.isLoading).toBe(false);
|
||||
expect(ds.error?.msg).toBe('boom');
|
||||
expect(errorHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('GET / POST 包装方法', async () => {
|
||||
const request = vi.fn().mockResolvedValue({ name: 'ok' });
|
||||
const ds = new HttpDataSource({
|
||||
schema: createSchema() as any,
|
||||
app: new App({}),
|
||||
request,
|
||||
});
|
||||
await ds.get({ url: 'https://x.com/g' });
|
||||
expect(request.mock.calls[0][0].method).toBe('GET');
|
||||
|
||||
await ds.post({ url: 'https://x.com/p' });
|
||||
expect(request.mock.calls[1][0].method).toBe('POST');
|
||||
});
|
||||
|
||||
test('options 中 url/params 等可以是函数', async () => {
|
||||
const request = vi.fn().mockResolvedValue({});
|
||||
const ds = new HttpDataSource({
|
||||
schema: createSchema({
|
||||
options: {
|
||||
url: ({ dataSource }: any) => `https://x/${dataSource.id}`,
|
||||
params: () => ({ p: 1 }),
|
||||
data: () => ({ d: 1 }),
|
||||
headers: () => ({ 'X-Custom': '1' }),
|
||||
},
|
||||
}) as any,
|
||||
app: new App({}),
|
||||
request,
|
||||
});
|
||||
await ds.request();
|
||||
const opt = request.mock.calls[0][0];
|
||||
expect(opt.url).toBe('https://x/http_1');
|
||||
expect(opt.params).toEqual({ p: 1 });
|
||||
expect(opt.data).toEqual({ d: 1 });
|
||||
expect(opt.headers).toEqual({ 'X-Custom': '1' });
|
||||
});
|
||||
|
||||
test('编辑器中使用 mockData 而非真实请求', async () => {
|
||||
const request = vi.fn();
|
||||
const app = new App({}) as any;
|
||||
app.platform = 'editor';
|
||||
const ds = new HttpDataSource({
|
||||
schema: createSchema({
|
||||
mocks: [{ useInEditor: true, data: { name: 'mock-name' } }],
|
||||
}) as any,
|
||||
app,
|
||||
request,
|
||||
});
|
||||
await ds.request();
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(ds.data.name).toBe('mock-name');
|
||||
});
|
||||
|
||||
test('beforeRequest/afterRequest method 被注册', async () => {
|
||||
const before = vi.fn();
|
||||
const after = vi.fn();
|
||||
const request = vi.fn().mockResolvedValue({});
|
||||
const ds = new HttpDataSource({
|
||||
schema: createSchema({
|
||||
methods: [
|
||||
{ name: 'b', timing: 'beforeRequest', content: before, params: [] },
|
||||
{ name: 'a', timing: 'afterRequest', content: after, params: [] },
|
||||
{ name: 'noop', content: 'not-a-function' as any, params: [] },
|
||||
],
|
||||
}) as any,
|
||||
app: new App({}),
|
||||
request,
|
||||
});
|
||||
await ds.request();
|
||||
expect(before).toHaveBeenCalled();
|
||||
expect(after).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('webRequest 默认实现', () => {
|
||||
test('未传自定义 request 时使用 fetch,非 GET 携带 body', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
json: async () => ({ name: 'fetched' }),
|
||||
});
|
||||
const original = globalThis.fetch;
|
||||
(globalThis as any).fetch = fetchMock;
|
||||
try {
|
||||
const ds = new HttpDataSource({
|
||||
schema: createSchema({
|
||||
options: {
|
||||
url: 'https://x.com/api',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: { foo: 'bar' },
|
||||
params: { q: 'v' },
|
||||
},
|
||||
}) as any,
|
||||
app: new App({}),
|
||||
});
|
||||
await ds.request();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toContain('q=v');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.body).toContain('foo');
|
||||
expect(ds.data.name).toBe('fetched');
|
||||
} finally {
|
||||
(globalThis as any).fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('Content-Type 为 form-urlencoded 时 body 用 url 编码', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ json: async () => ({}) });
|
||||
const original = globalThis.fetch;
|
||||
(globalThis as any).fetch = fetchMock;
|
||||
try {
|
||||
const ds = new HttpDataSource({
|
||||
schema: createSchema({
|
||||
options: {
|
||||
url: 'https://x.com/api',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
data: { a: 1, b: { x: 1 }, c: undefined },
|
||||
},
|
||||
}) as any,
|
||||
app: new App({}),
|
||||
});
|
||||
await ds.request();
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(init.body).toContain('a=1');
|
||||
expect(init.body).toContain('b=');
|
||||
expect(init.body).not.toContain('c=');
|
||||
} finally {
|
||||
(globalThis as any).fetch = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
87
packages/data-source/tests/ObservedData.spec.ts
Normal file
87
packages/data-source/tests/ObservedData.spec.ts
Normal file
@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { DeepObservedData, SimpleObservedData } from '@data-source/observed-data';
|
||||
|
||||
describe('SimpleObservedData', () => {
|
||||
test('update / getData 全量与按路径', () => {
|
||||
const od = new SimpleObservedData({ a: 1, b: { c: 2 } });
|
||||
expect(od.getData('')).toEqual({ a: 1, b: { c: 2 } });
|
||||
expect(od.getData('b.c')).toBe(2);
|
||||
|
||||
od.update({ a: 9 });
|
||||
expect(od.data.a).toBe(9);
|
||||
|
||||
od.update(99, 'a');
|
||||
expect(od.data.a).toBe(99);
|
||||
});
|
||||
|
||||
test('on / off 监听变更, immediate 立即触发一次', () => {
|
||||
const od = new SimpleObservedData({ a: 1 });
|
||||
const cb = vi.fn();
|
||||
od.on('a', cb, { immediate: true });
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
od.update(2, 'a');
|
||||
expect(cb).toHaveBeenCalledTimes(2);
|
||||
|
||||
od.off('a', cb);
|
||||
od.update(3, 'a');
|
||||
expect(cb).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('全量更新触发空 path 监听器', () => {
|
||||
const od = new SimpleObservedData({ a: 1 });
|
||||
const cb = vi.fn();
|
||||
od.on('', cb);
|
||||
od.update({ a: 2 });
|
||||
expect(cb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('destroy 不抛错', () => {
|
||||
const od = new SimpleObservedData({});
|
||||
expect(() => od.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeepObservedData', () => {
|
||||
test('on/update/off/getData 完整链路', () => {
|
||||
const od = new DeepObservedData({ a: 1, list: [{ name: 'x' }] });
|
||||
|
||||
const cb = vi.fn();
|
||||
od.on('a', cb);
|
||||
od.update(2, 'a');
|
||||
expect(cb).toHaveBeenCalled();
|
||||
expect(od.getData('a')).toBe(2);
|
||||
|
||||
od.off('a', cb);
|
||||
cb.mockClear();
|
||||
od.update(3, 'a');
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('immediate 选项立刻触发一次回调', () => {
|
||||
const od = new DeepObservedData({ a: 1 });
|
||||
const cb = vi.fn();
|
||||
od.on('a', cb, { immediate: true });
|
||||
expect(cb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('off 不存在的 callback 不抛错', () => {
|
||||
const od = new DeepObservedData({ a: 1 });
|
||||
expect(() => od.off('a', () => undefined)).not.toThrow();
|
||||
expect(() => od.off('not-exist', () => undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
test('destroy 解除所有监听', () => {
|
||||
const od = new DeepObservedData({ a: 1 });
|
||||
const cb = vi.fn();
|
||||
od.on('a', cb);
|
||||
od.destroy();
|
||||
od.update(2, 'a');
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -1,10 +1,10 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import TMagicApp, { type MApp, NodeType } from '@tmagic/core';
|
||||
|
||||
import { createDataSourceManager, DataSourceManager } from '@data-source/index';
|
||||
import { createDataSourceManager, DataSource, DataSourceManager } from '@data-source/index';
|
||||
|
||||
const dsl: MApp = {
|
||||
const createDsl = (): MApp => ({
|
||||
type: NodeType.ROOT,
|
||||
id: 'app_1',
|
||||
items: [
|
||||
@ -41,13 +41,803 @@ const dsl: MApp = {
|
||||
events: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
describe('createDataSourceManager', () => {
|
||||
afterEach(() => {
|
||||
DataSourceManager.clearDataSourceClass();
|
||||
});
|
||||
|
||||
describe('createDataSourceManager - 基础', () => {
|
||||
test('instance', () => {
|
||||
const manager = createDataSourceManager(new TMagicApp({ config: dsl }));
|
||||
const manager = createDataSourceManager(new TMagicApp({ config: createDsl() }));
|
||||
expect(manager).toBeInstanceOf(DataSourceManager);
|
||||
});
|
||||
|
||||
DataSourceManager.clearDataSourceClass();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
57
packages/data-source/tests/depsCache.spec.ts
Normal file
57
packages/data-source/tests/depsCache.spec.ts
Normal file
@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { getDeps } from '@data-source/depsCache';
|
||||
|
||||
describe('getDeps', () => {
|
||||
test('从节点收集普通字段依赖', () => {
|
||||
const ds: any = {
|
||||
id: 'ds_1',
|
||||
fields: [{ name: 'name', type: 'string' }],
|
||||
};
|
||||
const nodes: any[] = [
|
||||
{
|
||||
id: 'page_1',
|
||||
type: 'page',
|
||||
items: [
|
||||
{
|
||||
id: 'btn_1',
|
||||
type: 'text',
|
||||
text: '${ds_1.name}',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = getDeps(ds, nodes, false);
|
||||
expect(result.deps).toBeDefined();
|
||||
expect(result.condDeps).toBeDefined();
|
||||
});
|
||||
|
||||
test('inEditor=true 时缓存键包含所有 traverse 节点', () => {
|
||||
const ds: any = {
|
||||
id: 'ds_2',
|
||||
fields: [{ name: 'name' }],
|
||||
};
|
||||
const nodes: any[] = [
|
||||
{
|
||||
id: 'page_1',
|
||||
type: 'page',
|
||||
items: [{ id: 'btn_1', type: 'text', text: '${ds_2.name}' }],
|
||||
},
|
||||
];
|
||||
const result = getDeps(ds, nodes, true);
|
||||
expect(result.deps).toBeDefined();
|
||||
});
|
||||
|
||||
test('cache 命中时返回同一对象', () => {
|
||||
const ds: any = { id: 'ds_3', fields: [{ name: 'n' }] };
|
||||
const nodes: any[] = [{ id: 'p', type: 'page', items: [] }];
|
||||
const r1 = getDeps(ds, nodes, false);
|
||||
const r2 = getDeps(ds, nodes, false);
|
||||
expect(r1).toBe(r2);
|
||||
});
|
||||
});
|
||||
@ -1,8 +1,18 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { dataSourceTemplateRegExp } from '@tmagic/core';
|
||||
import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, dataSourceTemplateRegExp, NodeType } from '@tmagic/core';
|
||||
|
||||
import { compiledCondition, createIteratorContentData, template } from '@data-source/utils';
|
||||
import {
|
||||
compiledCondition,
|
||||
compiledNodeField,
|
||||
compliedConditions,
|
||||
compliedDataSourceField,
|
||||
compliedIteratorItem,
|
||||
createIteratorContentData,
|
||||
registerDataSourceOnDemand,
|
||||
template,
|
||||
updateNode,
|
||||
} from '@data-source/utils';
|
||||
|
||||
describe('compiledCondition', () => {
|
||||
test('=,true', () => {
|
||||
@ -184,3 +194,207 @@ describe('createIteratorContentData', () => {
|
||||
expect(ctxData.ds.a.c.a).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compliedConditions', () => {
|
||||
test('未配置 conditions 时直接返回 true', () => {
|
||||
expect(compliedConditions({}, {})).toBe(true);
|
||||
expect(compliedConditions({ ['displayConds' as any]: [] } as any, {})).toBe(true);
|
||||
});
|
||||
|
||||
test('任一 cond 通过即返回 true', () => {
|
||||
const node: any = {
|
||||
displayConds: [
|
||||
{ cond: [{ field: ['ds_1', 'a'], op: '=', value: 2 }] },
|
||||
{ cond: [{ field: ['ds_1', 'a'], op: '=', value: 1 }] },
|
||||
],
|
||||
};
|
||||
expect(compliedConditions(node, { ds_1: { a: 1 } })).toBe(true);
|
||||
});
|
||||
|
||||
test('全部不通过则返回 false', () => {
|
||||
const node: any = {
|
||||
displayConds: [{ cond: [{ field: ['ds_1', 'a'], op: '=', value: 2 }] }],
|
||||
};
|
||||
expect(compliedConditions(node, { ds_1: { a: 1 } })).toBe(false);
|
||||
});
|
||||
|
||||
test('cond 为空被跳过', () => {
|
||||
const node: any = { displayConds: [{ cond: undefined }] };
|
||||
expect(compliedConditions(node, {})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compiledCondition 边界', () => {
|
||||
test('数据源不存在时直接 break 视为通过', () => {
|
||||
expect(compiledCondition([{ field: ['unknown', 'a'], op: '=', value: 1 }], {})).toBe(true);
|
||||
});
|
||||
|
||||
test('field 取值异常(如类型错)时 console.warn 不阻断', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const result = compiledCondition([{ field: ['ds', 'a', 'b', 'c'], op: '=', value: 1 }], { ds: { a: 'string' } });
|
||||
expect(result).toBe(true);
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateNode', () => {
|
||||
test('页面节点直接替换 dsl.items', () => {
|
||||
const dsl: any = {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app',
|
||||
items: [{ id: 'p1', type: NodeType.PAGE, items: [{ id: 'btn' }] }],
|
||||
};
|
||||
updateNode({ id: 'p1', type: NodeType.PAGE, items: [{ id: 'btn2' }] } as any, dsl);
|
||||
expect(dsl.items[0].items[0].id).toBe('btn2');
|
||||
});
|
||||
|
||||
test('非页面节点走 replaceChildNode', () => {
|
||||
const dsl: any = {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app',
|
||||
items: [
|
||||
{
|
||||
id: 'p1',
|
||||
type: NodeType.PAGE,
|
||||
items: [{ id: 'btn', type: 'button', text: 'old' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
updateNode({ id: 'btn', type: 'button', text: 'new' } as any, dsl);
|
||||
expect(dsl.items[0].items[0].text).toBe('new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compliedDataSourceField', () => {
|
||||
test('不带前缀直接返回原值', () => {
|
||||
expect(compliedDataSourceField(['no-prefix-id', 'name'], { id: { name: 'x' } })).toEqual(['no-prefix-id', 'name']);
|
||||
});
|
||||
|
||||
test('数据源不存在时返回原值', () => {
|
||||
expect(compliedDataSourceField([`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}id`, 'name'], {})).toEqual([
|
||||
`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}id`,
|
||||
'name',
|
||||
]);
|
||||
});
|
||||
|
||||
test('正常解析数据源字段', () => {
|
||||
const value = compliedDataSourceField([`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}id`, 'name'], {
|
||||
id: { name: 'x' },
|
||||
});
|
||||
expect(value).toBe('x');
|
||||
});
|
||||
|
||||
test('字段路径不存在时返回原值', () => {
|
||||
expect(
|
||||
compliedDataSourceField([`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}id`, 'name', 'sub'], { id: { name: 'x' } }),
|
||||
).toEqual([`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}id`, 'name', 'sub']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compiledNodeField', () => {
|
||||
const data = { id: { name: 'world' } };
|
||||
|
||||
test('字符串模板', () => {
|
||||
expect(compiledNodeField('hello ${id.name}', data)).toBe('hello world');
|
||||
});
|
||||
|
||||
test('isBindDataSource 直接取整个数据源', () => {
|
||||
expect(compiledNodeField({ isBindDataSource: true, dataSourceId: 'id' }, data)).toEqual({ name: 'world' });
|
||||
});
|
||||
|
||||
test('isBindDataSourceField 走模板', () => {
|
||||
expect(compiledNodeField({ isBindDataSourceField: true, dataSourceId: 'id', template: 'hi ${name}' }, data)).toBe(
|
||||
'hi world',
|
||||
);
|
||||
});
|
||||
|
||||
test('数组形式走 compliedDataSourceField', () => {
|
||||
expect(compiledNodeField([`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}id`, 'name'], data)).toBe('world');
|
||||
});
|
||||
|
||||
test('未匹配格式直接返回原值', () => {
|
||||
expect(compiledNodeField(123 as any, data)).toBe(123);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compliedIteratorItem', () => {
|
||||
test('递归 compile items 并应用条件', () => {
|
||||
const item: any = {
|
||||
id: 'parent',
|
||||
items: [{ id: 'child', text: 'origin' }],
|
||||
};
|
||||
const ctxData = { ds: { name: 'V' } };
|
||||
const result = compliedIteratorItem({
|
||||
compile: (v: any) => `compiled-${v}`,
|
||||
dsId: 'ds',
|
||||
item,
|
||||
deps: { child: { name: 'c', keys: ['text'] } },
|
||||
condDeps: {},
|
||||
inEditor: false,
|
||||
ctxData,
|
||||
});
|
||||
expect(result.items[0].text).toBe('compiled-origin');
|
||||
expect(result.id).toBe('parent');
|
||||
});
|
||||
|
||||
test('items 不是数组时保留原值', () => {
|
||||
const result = compliedIteratorItem({
|
||||
compile: (v: any) => v,
|
||||
dsId: 'ds',
|
||||
item: { id: 'p', items: 'not-array' as any } as any,
|
||||
deps: {},
|
||||
condDeps: {},
|
||||
inEditor: true,
|
||||
ctxData: {},
|
||||
});
|
||||
expect(result.items).toBe('not-array');
|
||||
});
|
||||
|
||||
test('条件依赖在非编辑器中会写入 condResult', () => {
|
||||
const result = compliedIteratorItem({
|
||||
compile: (v: any) => v,
|
||||
dsId: 'ds',
|
||||
item: {
|
||||
id: 'p',
|
||||
displayConds: [{ cond: [{ field: ['ds', 'a'], op: '=', value: 1 }] }],
|
||||
} as any,
|
||||
deps: {},
|
||||
condDeps: { p: { name: 'p', keys: ['displayConds'] } },
|
||||
inEditor: false,
|
||||
ctxData: { ds: { a: 1 } },
|
||||
});
|
||||
expect(result.condResult).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerDataSourceOnDemand', () => {
|
||||
test('按依赖按需返回模块', async () => {
|
||||
const dsl: any = {
|
||||
dataSources: [
|
||||
{ id: 'a', type: 'http' },
|
||||
{ id: 'b', type: 'mock' },
|
||||
{ id: 'c', type: 'http' },
|
||||
],
|
||||
dataSourceDeps: { a: { node1: { name: 'n', keys: ['x'] } } },
|
||||
dataSourceCondDeps: { c: { node2: { name: 'n', keys: ['y'] } } },
|
||||
dataSourceMethodsDeps: {},
|
||||
};
|
||||
const httpModule = { default: class HttpDS {} };
|
||||
const mockModule = { default: class MockDS {} };
|
||||
const modules = await registerDataSourceOnDemand(dsl, {
|
||||
http: () => Promise.resolve(httpModule as any),
|
||||
mock: () => Promise.resolve(mockModule as any),
|
||||
});
|
||||
expect(modules.http).toBe(httpModule.default);
|
||||
expect(modules.mock).toBeUndefined();
|
||||
});
|
||||
|
||||
test('找不到对应模块时跳过', async () => {
|
||||
const dsl: any = {
|
||||
dataSources: [{ id: 'a', type: 'unknown' }],
|
||||
dataSourceDeps: { a: { node: { name: 'n', keys: ['x'] } } },
|
||||
};
|
||||
const modules = await registerDataSourceOnDemand(dsl, {});
|
||||
expect(Object.keys(modules)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@ -25,4 +25,71 @@ describe('Target', () => {
|
||||
expect(defaultTarget.type).toBe('default');
|
||||
expect(target.type).toBe('target');
|
||||
});
|
||||
|
||||
test('initialDeps / name / isCollectByDefault 默认值', () => {
|
||||
const t = new Target({
|
||||
isTarget: () => true,
|
||||
id: 't1',
|
||||
name: 'first',
|
||||
initialDeps: { node_1: { name: 'n', keys: ['k1'] } },
|
||||
});
|
||||
expect(t.name).toBe('first');
|
||||
expect(t.deps.node_1.keys).toEqual(['k1']);
|
||||
expect(t.isCollectByDefault).toBe(true);
|
||||
|
||||
const t2 = new Target({
|
||||
isTarget: () => true,
|
||||
id: 't2',
|
||||
isCollectByDefault: false,
|
||||
});
|
||||
expect(t2.isCollectByDefault).toBe(false);
|
||||
});
|
||||
|
||||
test('updateDep 累加 keys 并保留 name/data', () => {
|
||||
const t = new Target({ isTarget: () => true, id: 't' });
|
||||
t.updateDep({ id: 'n1', name: 'n1-name', key: 'key1', data: { foo: 1 } });
|
||||
expect(t.deps.n1.name).toBe('n1-name');
|
||||
expect(t.deps.n1.keys).toEqual(['key1']);
|
||||
expect((t.deps.n1 as any).data).toEqual({ foo: 1 });
|
||||
|
||||
t.updateDep({ id: 'n1', name: 'n1-name', key: 'key2', data: { foo: 2 } });
|
||||
expect(t.deps.n1.keys).toEqual(['key1', 'key2']);
|
||||
|
||||
t.updateDep({ id: 'n1', name: 'n1-name', key: 'key1', data: { foo: 3 } });
|
||||
expect(t.deps.n1.keys).toEqual(['key1', 'key2']);
|
||||
});
|
||||
|
||||
test('removeDep 全删 / 删指定 id / 按 key 删', () => {
|
||||
const t = new Target({ isTarget: () => true, id: 't' });
|
||||
t.updateDep({ id: 'n1', name: 'n', key: 'k1', data: {} });
|
||||
t.updateDep({ id: 'n1', name: 'n', key: 'k2', data: {} });
|
||||
t.updateDep({ id: 'n2', name: 'n', key: 'k1', data: {} });
|
||||
|
||||
t.removeDep('n1', 'k1');
|
||||
expect(t.deps.n1.keys).toEqual(['k2']);
|
||||
|
||||
t.removeDep('n1', 'k2');
|
||||
expect(t.deps.n1).toBeUndefined();
|
||||
|
||||
t.removeDep('n2');
|
||||
expect(t.deps.n2).toBeUndefined();
|
||||
|
||||
t.updateDep({ id: 'a', name: 'a', key: 'k', data: {} });
|
||||
t.updateDep({ id: 'b', name: 'b', key: 'k', data: {} });
|
||||
t.removeDep();
|
||||
expect(Object.keys(t.deps)).toHaveLength(0);
|
||||
|
||||
t.removeDep('not-exist');
|
||||
});
|
||||
|
||||
test('hasDep / destroy', () => {
|
||||
const t = new Target({ isTarget: () => true, id: 't' });
|
||||
t.updateDep({ id: 'n1', name: 'n', key: 'k', data: {} });
|
||||
expect(t.hasDep('n1', 'k')).toBe(true);
|
||||
expect(t.hasDep('n1', 'other')).toBe(false);
|
||||
expect(t.hasDep('not-exist', 'k')).toBe(false);
|
||||
|
||||
t.destroy();
|
||||
expect(t.deps).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { DataSchema } from '@tmagic/schema';
|
||||
import { DataSchema, NODE_CONDS_KEY } from '@tmagic/schema';
|
||||
import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX } from '@tmagic/utils';
|
||||
|
||||
import Target from '../src/Target';
|
||||
import { DepTargetType } from '../src/types';
|
||||
import * as utils from '../src/utils';
|
||||
|
||||
describe('utils', () => {
|
||||
@ -193,4 +195,94 @@ describe('utils', () => {
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('isDataSourceTarget', () => {
|
||||
const ds = { id: 'ds_1', fields: [{ name: 'name', type: 'string' }] as DataSchema[] };
|
||||
|
||||
expect(utils.isDataSourceTarget(ds, 'k', null)).toBe(false);
|
||||
expect(utils.isDataSourceTarget(ds, 'k', 123)).toBe(false);
|
||||
|
||||
expect(utils.isDataSourceTarget(ds, `${NODE_CONDS_KEY}_x`, '${ds_1.name}')).toBe(false);
|
||||
|
||||
expect(utils.isDataSourceTarget(ds, 'text', '${ds_1.name}')).toBe(true);
|
||||
expect(utils.isDataSourceTarget(ds, 'text', '${other.name}')).toBe(false);
|
||||
|
||||
expect(utils.isDataSourceTarget(ds, 'text', { isBindDataSource: true, dataSourceId: 'ds_1' })).toBe(true);
|
||||
expect(utils.isDataSourceTarget(ds, 'text', { isBindDataSource: true, dataSourceId: 'other' })).toBe(false);
|
||||
|
||||
expect(
|
||||
utils.isDataSourceTarget(ds, 'text', {
|
||||
isBindDataSourceField: true,
|
||||
dataSourceId: 'ds_1',
|
||||
template: 'foo${name}',
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(utils.isDataSourceTarget(ds, 'text', [`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds_1`, 'name'])).toBe(true);
|
||||
|
||||
expect(
|
||||
utils.isDataSourceTarget(
|
||||
{ id: 'ds_1', fields: [{ name: 'arr', type: 'array', fields: [{ name: 'a' }] }] as DataSchema[] },
|
||||
'text',
|
||||
[`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds_1`, 'arr', 'a'],
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('isDataSourceCondTarget', () => {
|
||||
const ds = { id: 'ds_1', fields: [{ name: 'name' }] as DataSchema[] };
|
||||
|
||||
expect(utils.isDataSourceCondTarget(ds, 'k', 'not-array')).toBe(false);
|
||||
expect(utils.isDataSourceCondTarget(ds, 'k', null as any)).toBe(false);
|
||||
|
||||
expect(utils.isDataSourceCondTarget(ds, `${NODE_CONDS_KEY}_x`, ['ds_1', 'name'])).toBe(true);
|
||||
expect(utils.isDataSourceCondTarget(ds, 'k', ['ds_1', 'name'])).toBe(false);
|
||||
expect(utils.isDataSourceCondTarget(ds, `${NODE_CONDS_KEY}_x`, ['other', 'name'])).toBe(false);
|
||||
expect(utils.isDataSourceCondTarget(ds, `${NODE_CONDS_KEY}_x`, ['ds_1', 'unknown'])).toBe(false);
|
||||
});
|
||||
|
||||
test('createDataSourceTarget / Cond / Method', () => {
|
||||
const ds = { id: 'ds_1', fields: [{ name: 'name' }] as DataSchema[] };
|
||||
const t1 = utils.createDataSourceTarget(ds);
|
||||
expect(t1.type).toBe(DepTargetType.DATA_SOURCE);
|
||||
expect(t1.isTarget('text', '${ds_1.name}')).toBe(true);
|
||||
|
||||
const t2 = utils.createDataSourceCondTarget(ds);
|
||||
expect(t2.type).toBe(DepTargetType.DATA_SOURCE_COND);
|
||||
expect(t2.isTarget(`${NODE_CONDS_KEY}_x`, ['ds_1', 'name'])).toBe(true);
|
||||
|
||||
const t3 = utils.createDataSourceMethodTarget({
|
||||
id: 'ds_1',
|
||||
methods: [{ name: 'load', content: () => undefined, params: [] } as any],
|
||||
fields: [{ name: 'name' }] as DataSchema[],
|
||||
});
|
||||
expect(t3.type).toBe(DepTargetType.DATA_SOURCE_METHOD);
|
||||
expect(t3.isTarget('k', ['ds_1', 'load'])).toBe(true);
|
||||
expect(t3.isTarget('k', ['ds_1', 'name'])).toBe(false);
|
||||
expect(t3.isTarget('k', ['other', 'load'])).toBe(false);
|
||||
expect(t3.isTarget('k', 'not-array')).toBe(false);
|
||||
expect(t3.isTarget('k', ['ds_1', ''])).toBe(false);
|
||||
expect(t3.isTarget('k', ['ds_1', 'unknown'])).toBe(true);
|
||||
});
|
||||
|
||||
test('traverseTarget 遍历所有 / 指定 type', () => {
|
||||
const t1 = new Target({ id: '1', isTarget: () => true, type: 'a' });
|
||||
const t2 = new Target({ id: '2', isTarget: () => true, type: 'b' });
|
||||
const list = {
|
||||
a: { 1: t1 },
|
||||
b: { 2: t2 },
|
||||
};
|
||||
const visited: string[] = [];
|
||||
utils.traverseTarget(list, (t) => visited.push(`${t.type}:${t.id}`));
|
||||
expect(visited).toEqual(expect.arrayContaining(['a:1', 'b:2']));
|
||||
|
||||
const visitedA: string[] = [];
|
||||
utils.traverseTarget(list, (t) => visitedA.push(`${t.type}:${t.id}`), 'a');
|
||||
expect(visitedA).toEqual(['a:1']);
|
||||
|
||||
const visitedX: string[] = [];
|
||||
utils.traverseTarget(list, (t) => visitedX.push(`${t.type}:${t.id}`), 'not-exist');
|
||||
expect(visitedX).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
161
packages/editor/tests/unit/Editor.spec.ts
Normal file
161
packages/editor/tests/unit/Editor.spec.ts
Normal file
@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import Editor from '@editor/Editor.vue';
|
||||
|
||||
const { initServiceEventsMock, initServiceStateMock } = vi.hoisted(() => ({
|
||||
initServiceEventsMock: vi.fn(),
|
||||
initServiceStateMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/initService', () => ({
|
||||
initServiceEvents: initServiceEventsMock,
|
||||
initServiceState: initServiceStateMock,
|
||||
}));
|
||||
|
||||
vi.mock('@editor/services/codeBlock', () => ({ default: {} }));
|
||||
vi.mock('@editor/services/componentList', () => ({ default: {} }));
|
||||
vi.mock('@editor/services/dataSource', () => ({ default: {} }));
|
||||
vi.mock('@editor/services/dep', () => ({ default: {} }));
|
||||
vi.mock('@editor/services/editor', () => ({ default: {} }));
|
||||
vi.mock('@editor/services/events', () => ({ default: {} }));
|
||||
vi.mock('@editor/services/history', () => ({ default: {} }));
|
||||
vi.mock('@editor/services/keybinding', () => ({
|
||||
default: { register: vi.fn(), registerEl: vi.fn() },
|
||||
}));
|
||||
vi.mock('@editor/services/props', () => ({ default: {} }));
|
||||
vi.mock('@editor/services/stageOverlay', () => ({
|
||||
default: { set: vi.fn() },
|
||||
}));
|
||||
vi.mock('@editor/services/storage', () => ({ default: {}, Protocol: {} }));
|
||||
vi.mock('@editor/services/ui', () => ({ default: {} }));
|
||||
vi.mock('@editor/utils/keybinding-config', () => ({ default: {}, KeyBindingContainerKey: { STAGE: 'stage' } }));
|
||||
|
||||
vi.mock('@editor/layouts/Framework.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeFramework',
|
||||
props: ['disabledPageFragment', 'pageBarSortOptions', 'pageFilterFunction'],
|
||||
setup(_p, { slots }) {
|
||||
return () =>
|
||||
h('div', { class: 'fake-framework' }, [
|
||||
slots.header?.(),
|
||||
slots.nav?.({ editorService: {} }),
|
||||
slots.sidebar?.({ editorService: {} }),
|
||||
slots.workspace?.({ editorService: {} }),
|
||||
slots['props-panel']?.(),
|
||||
slots.footer?.(),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/NavMenu.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'TMagicNavMenu',
|
||||
props: ['data'],
|
||||
setup() {
|
||||
return () => h('div', { class: 'fake-nav-menu' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/sidebar/Sidebar.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeSidebar',
|
||||
emits: ['layer-node-dblclick'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h('div', {
|
||||
class: 'fake-sidebar',
|
||||
onClick: () => emit('layer-node-dblclick', new MouseEvent('dblclick'), { id: 'a' }),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/workspace/Workspace.vue', () => ({
|
||||
default: defineComponent({ name: 'FakeWorkspace', setup: () => () => h('div', { class: 'fake-workspace' }) }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/props-panel/PropsPanel.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'PropsPanel',
|
||||
emits: ['mounted', 'unmounted', 'submit-error', 'form-error'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h('div', { class: 'fake-props-panel' }, [
|
||||
h('button', { class: 'mounted-btn', onClick: () => emit('mounted', { proxy: true }) }),
|
||||
h('button', { class: 'unmounted-btn', onClick: () => emit('unmounted') }),
|
||||
h('button', { class: 'submit-err', onClick: () => emit('submit-error', new Error('e')) }),
|
||||
h('button', { class: 'form-err', onClick: () => emit('form-error', new Error('e')) }),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/props-panel/FormPanel.vue', () => ({
|
||||
default: defineComponent({ name: 'FormPanel', setup: () => () => h('div') }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Editor', () => {
|
||||
test('挂载时初始化 services', () => {
|
||||
mount(Editor, { props: {} as any });
|
||||
expect(initServiceEventsMock).toHaveBeenCalled();
|
||||
expect(initServiceStateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('canDropIn 转发到 stage 含 stage-add/stage-drag 类型', async () => {
|
||||
const canDropIn = vi.fn(() => true);
|
||||
const stageOverlayMod = (await import('@editor/services/stageOverlay')) as any;
|
||||
mount(Editor, { props: { canDropIn } as any });
|
||||
await nextTick();
|
||||
const stageOptions = stageOverlayMod.default.set.mock.calls.find((c: any[]) => c[0] === 'stageOptions')?.[1];
|
||||
expect(stageOptions.canDropIn).toBeDefined();
|
||||
stageOptions.canDropIn([], 't1');
|
||||
expect(canDropIn).toHaveBeenCalledWith([], 't1', 'stage-add');
|
||||
stageOptions.canDropIn(['s1'], 't1');
|
||||
expect(canDropIn).toHaveBeenLastCalledWith(['s1'], 't1', 'stage-drag');
|
||||
});
|
||||
|
||||
test('未传 canDropIn 时 stageOptions.canDropIn 为 undefined', async () => {
|
||||
const stageOverlayMod = (await import('@editor/services/stageOverlay')) as any;
|
||||
mount(Editor, { props: {} as any });
|
||||
await nextTick();
|
||||
const stageOptions = stageOverlayMod.default.set.mock.calls.find((c: any[]) => c[0] === 'stageOptions')?.[1];
|
||||
expect(stageOptions.canDropIn).toBeUndefined();
|
||||
});
|
||||
|
||||
test('PropsPanel 事件转发', async () => {
|
||||
const wrapper = mount(Editor, { props: {} as any });
|
||||
await wrapper.find('.mounted-btn').trigger('click');
|
||||
expect(wrapper.emitted('props-panel-mounted')).toBeTruthy();
|
||||
await wrapper.find('.unmounted-btn').trigger('click');
|
||||
expect(wrapper.emitted('props-panel-unmounted')).toBeTruthy();
|
||||
await wrapper.find('.submit-err').trigger('click');
|
||||
expect(wrapper.emitted('props-submit-error')).toBeTruthy();
|
||||
await wrapper.find('.form-err').trigger('click');
|
||||
expect(wrapper.emitted('props-form-error')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Sidebar layer-node-dblclick 事件转发', async () => {
|
||||
const wrapper = mount(Editor, { props: {} as any });
|
||||
await wrapper.find('.fake-sidebar').trigger('click');
|
||||
expect(wrapper.emitted('layer-node-dblclick')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('expose services', () => {
|
||||
const wrapper = mount(Editor, { props: {} as any });
|
||||
expect((wrapper.vm as any).editorService).toBeDefined();
|
||||
expect((wrapper.vm as any).propsService).toBeDefined();
|
||||
});
|
||||
});
|
||||
260
packages/editor/tests/unit/components/CodeBlockEditor.spec.ts
Normal file
260
packages/editor/tests/unit/components/CodeBlockEditor.spec.ts
Normal file
@ -0,0 +1,260 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
|
||||
|
||||
const { messageError, messageBoxConfirm } = vi.hoisted(() => ({
|
||||
messageError: vi.fn(),
|
||||
messageBoxConfirm: vi.fn(async () => Promise.resolve(true)),
|
||||
}));
|
||||
|
||||
const codeBlockService = {
|
||||
getParamsColConfig: vi.fn(() => null),
|
||||
};
|
||||
const uiService = { get: vi.fn() };
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ codeBlockService, uiService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/hooks/use-editor-content-height', () => ({
|
||||
useEditorContentHeight: () => ({ height: ref(600) }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/hooks/use-window-rect', () => ({
|
||||
useWindowRect: () => ({ rect: ref({ width: 1000, height: 800 }) }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/hooks/use-next-float-box-position', () => ({
|
||||
useNextFloatBoxPosition: () => ({ boxPosition: ref({ x: 100, y: 100 }), calcBoxPosition: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils/config', () => ({
|
||||
getEditorConfig: vi.fn(() => (s: string) => {
|
||||
if (s === 'invalid') throw new Error('parse fail');
|
||||
return s;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/FloatingBox.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FloatingBox',
|
||||
props: ['visible', 'width', 'height', 'title', 'position', 'beforeClose'],
|
||||
setup(props, { slots, expose }) {
|
||||
const triggerClose = (cb: any) => {
|
||||
if (props.beforeClose) {
|
||||
props.beforeClose(cb);
|
||||
} else cb();
|
||||
};
|
||||
expose({ triggerClose });
|
||||
return () => h('div', { class: 'fake-floating', 'data-visible': String(props.visible) }, slots.body?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/CodeEditor.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'CodeEditor',
|
||||
props: ['initValues', 'modifiedValues', 'type', 'language', 'disabledFullScreen', 'height'],
|
||||
setup(_p, { expose }) {
|
||||
expose({ getEditorValue: () => 'modified-content' });
|
||||
return () => h('div', { class: 'fake-code-editor' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
let capturedConfig: any = null;
|
||||
vi.mock('@tmagic/form', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/form');
|
||||
return {
|
||||
...actual,
|
||||
MFormBox: defineComponent({
|
||||
name: 'MFormBox',
|
||||
props: ['config', 'values', 'disabled', 'title', 'labelWidth'],
|
||||
emits: ['change', 'submit', 'error', 'closed'],
|
||||
setup(props, { emit, slots, expose }) {
|
||||
capturedConfig = props.config;
|
||||
expose({
|
||||
form: { values: { content: 'orig' }, changeRecords: [] },
|
||||
});
|
||||
return () =>
|
||||
h('div', { class: 'fake-form-box' }, [
|
||||
h('button', { class: 'change-btn', onClick: () => emit('change', { name: 'a' }) }),
|
||||
h('button', {
|
||||
class: 'submit-btn',
|
||||
onClick: () =>
|
||||
emit(
|
||||
'submit',
|
||||
{ name: 'a', content: 'function(){}' },
|
||||
{ changeRecords: [{ propPath: 'content', value: 'function(){}' }] },
|
||||
),
|
||||
}),
|
||||
h('button', { class: 'err-btn', onClick: () => emit('error', new Error('e')) }),
|
||||
h('button', { class: 'closed-btn', onClick: () => emit('closed') }),
|
||||
slots.left?.(),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicButton: defineComponent({
|
||||
name: 'TMagicButton',
|
||||
inheritAttrs: false,
|
||||
setup(_p, { slots, attrs }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
...attrs,
|
||||
class: ['fake-btn', (attrs as any).class].filter(Boolean).join(' '),
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
TMagicDialog: defineComponent({
|
||||
name: 'TMagicDialog',
|
||||
props: ['title', 'modelValue', 'fullscreen', 'destroyOnClose'],
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-dialog' }, [slots.default?.(), slots.footer?.()]);
|
||||
},
|
||||
}),
|
||||
TMagicTag: defineComponent({
|
||||
name: 'TMagicTag',
|
||||
setup(_p, { slots }) {
|
||||
return () => h('span', { class: 'fake-tag' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
tMagicMessage: { error: messageError },
|
||||
tMagicMessageBox: { confirm: messageBoxConfirm },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capturedConfig = null;
|
||||
});
|
||||
|
||||
describe('CodeBlockEditor', () => {
|
||||
test('show 设置 visible', async () => {
|
||||
const wrapper = mount(CodeBlockEditor, {
|
||||
props: { content: { name: '', content: '' } } as any,
|
||||
});
|
||||
(wrapper.vm as any).show();
|
||||
await nextTick();
|
||||
expect(wrapper.find('.fake-floating').attributes('data-visible')).toBe('true');
|
||||
});
|
||||
|
||||
test('hide 设置 visible false', async () => {
|
||||
const wrapper = mount(CodeBlockEditor, {
|
||||
props: { content: { name: '', content: '' } } as any,
|
||||
});
|
||||
(wrapper.vm as any).show();
|
||||
await nextTick();
|
||||
(wrapper.vm as any).hide();
|
||||
await nextTick();
|
||||
expect(wrapper.find('.fake-floating').attributes('data-visible')).toBe('false');
|
||||
});
|
||||
|
||||
test('boxVisible 切换为 true 时 emit open', async () => {
|
||||
const wrapper = mount(CodeBlockEditor, {
|
||||
props: { content: { name: '', content: '' } } as any,
|
||||
});
|
||||
(wrapper.vm as any).show();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(wrapper.emitted('open')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('submitForm 解析 content', async () => {
|
||||
const wrapper = mount(CodeBlockEditor, {
|
||||
props: { content: { name: '', content: '' } } as any,
|
||||
});
|
||||
await wrapper.find('.submit-btn').trigger('click');
|
||||
expect(wrapper.emitted('submit')).toBeTruthy();
|
||||
const args = (wrapper.emitted('submit') as any[])[0];
|
||||
expect(args[0].content).toBe('function(){}');
|
||||
});
|
||||
|
||||
test('error 调用 tMagicMessage.error', async () => {
|
||||
const wrapper = mount(CodeBlockEditor, {
|
||||
props: { content: { name: '', content: '' } } as any,
|
||||
});
|
||||
await wrapper.find('.err-btn').trigger('click');
|
||||
expect(messageError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('content onChange 解析失败抛出', () => {
|
||||
mount(CodeBlockEditor, {
|
||||
props: { content: { name: '', content: '' } } as any,
|
||||
});
|
||||
const contentItem = capturedConfig.find((c: any) => c.name === 'content');
|
||||
expect(() => contentItem.onChange(undefined, 'invalid')).toThrow();
|
||||
expect(messageError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('content onChange 解析成功返回值', () => {
|
||||
mount(CodeBlockEditor, {
|
||||
props: { content: { name: '', content: '' } } as any,
|
||||
});
|
||||
const contentItem = capturedConfig.find((c: any) => c.name === 'content');
|
||||
expect(contentItem.onChange(undefined, 'valid')).toBe('valid');
|
||||
});
|
||||
|
||||
test('timing display - isDataSource', () => {
|
||||
mount(CodeBlockEditor, {
|
||||
props: { content: { name: '', content: '' }, isDataSource: true } as any,
|
||||
});
|
||||
const timingItem = capturedConfig.find((c: any) => c.name === 'timing');
|
||||
expect(timingItem.display()).toBe(true);
|
||||
});
|
||||
|
||||
test('timing options - 非 base 类型', () => {
|
||||
mount(CodeBlockEditor, {
|
||||
props: { content: { name: '', content: '' }, isDataSource: true, dataSourceType: 'http' } as any,
|
||||
});
|
||||
const timingItem = capturedConfig.find((c: any) => c.name === 'timing');
|
||||
const opts = timingItem.options();
|
||||
expect(opts.length).toBe(4);
|
||||
});
|
||||
|
||||
test('timing options - base 类型', () => {
|
||||
mount(CodeBlockEditor, {
|
||||
props: { content: { name: '', content: '' }, isDataSource: true, dataSourceType: 'base' } as any,
|
||||
});
|
||||
const timingItem = capturedConfig.find((c: any) => c.name === 'timing');
|
||||
const opts = timingItem.options();
|
||||
expect(opts.length).toBe(2);
|
||||
});
|
||||
|
||||
test('changeHandler 触发 changedValue', async () => {
|
||||
const wrapper = mount(CodeBlockEditor, {
|
||||
props: { content: { name: '', content: '' } } as any,
|
||||
});
|
||||
await wrapper.find('.change-btn').trigger('click');
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test('closedHandler 重置 changedValue', async () => {
|
||||
const wrapper = mount(CodeBlockEditor, {
|
||||
props: { content: { name: '', content: '' } } as any,
|
||||
});
|
||||
await wrapper.find('.change-btn').trigger('click');
|
||||
await wrapper.find('.closed-btn').trigger('click');
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test('content.name 存在时显示编辑标题', () => {
|
||||
const wrapper = mount(CodeBlockEditor, {
|
||||
props: { content: { name: 'foo', content: '' } } as any,
|
||||
});
|
||||
expect(wrapper.html()).toContain('fake-floating');
|
||||
});
|
||||
});
|
||||
113
packages/editor/tests/unit/components/CodeParams.spec.ts
Normal file
113
packages/editor/tests/unit/components/CodeParams.spec.ts
Normal file
@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import CodeParams from '@editor/components/CodeParams.vue';
|
||||
import * as utilsMod from '@editor/utils';
|
||||
|
||||
const submitMock = vi.fn();
|
||||
let lastConfig: any;
|
||||
|
||||
vi.mock('@tmagic/form', () => ({
|
||||
MForm: defineComponent({
|
||||
name: 'MFormStub',
|
||||
props: ['config', 'initValues', 'disabled', 'size', 'watchProps'],
|
||||
emits: ['change'],
|
||||
setup(props, { expose, emit }) {
|
||||
lastConfig = props.config;
|
||||
expose({ submitForm: submitMock });
|
||||
return () =>
|
||||
h('div', {
|
||||
class: 'form-stub',
|
||||
onClick: () => emit('change', { ok: true }, { changeRecords: [] }),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils', () => ({
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('CodeParams.vue', () => {
|
||||
beforeEach(() => {
|
||||
submitMock.mockReset();
|
||||
lastConfig = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('config 中包含 vs-code 类型时直接保留', () => {
|
||||
mount(CodeParams as any, {
|
||||
props: {
|
||||
model: { p: {} },
|
||||
name: 'p',
|
||||
paramsConfig: [{ name: 'a', text: 'A', type: 'vs-code' }] as any,
|
||||
},
|
||||
});
|
||||
expect(lastConfig[0].items[0].type).toBe('vs-code');
|
||||
});
|
||||
|
||||
test('config 中其它类型会包装成 data-source-field-select', () => {
|
||||
mount(CodeParams as any, {
|
||||
props: {
|
||||
model: { p: {} },
|
||||
name: 'p',
|
||||
paramsConfig: [{ name: 'a', text: 'A', type: 'text' }] as any,
|
||||
},
|
||||
});
|
||||
expect(lastConfig[0].items[0].type).toBe('data-source-field-select');
|
||||
expect(lastConfig[0].items[0].fieldConfig.type).toBe('text');
|
||||
});
|
||||
|
||||
test('config.type 为函数时执行函数判断类型', () => {
|
||||
const typeFn = vi.fn(() => 'vs-code');
|
||||
mount(CodeParams as any, {
|
||||
props: {
|
||||
model: { p: { x: 1 } },
|
||||
name: 'p',
|
||||
paramsConfig: [{ name: 'a', text: 'A', type: typeFn }] as any,
|
||||
},
|
||||
});
|
||||
expect(typeFn).toHaveBeenCalledWith(undefined, { model: { x: 1 } });
|
||||
expect(lastConfig[0].items[0].name).toBe('a');
|
||||
});
|
||||
|
||||
test('change 事件成功时 emit change 携带值', async () => {
|
||||
submitMock.mockResolvedValueOnce({ p: { a: 1 } });
|
||||
const wrapper = mount(CodeParams as any, {
|
||||
props: {
|
||||
model: { p: {} },
|
||||
name: 'p',
|
||||
paramsConfig: [{ name: 'a', text: 'A', type: 'vs-code' }] as any,
|
||||
},
|
||||
});
|
||||
await wrapper.find('.form-stub').trigger('click');
|
||||
await nextTick();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const events = wrapper.emitted('change') as any[];
|
||||
expect(events?.[0]?.[0]).toEqual({ p: { a: 1 } });
|
||||
});
|
||||
|
||||
test('submitForm 抛错时调用 error 不抛出', async () => {
|
||||
submitMock.mockRejectedValueOnce(new Error('bad'));
|
||||
const wrapper = mount(CodeParams as any, {
|
||||
props: {
|
||||
model: { p: {} },
|
||||
name: 'p',
|
||||
paramsConfig: [{ name: 'a', text: 'A', type: 'vs-code' }] as any,
|
||||
},
|
||||
});
|
||||
await wrapper.find('.form-stub').trigger('click');
|
||||
await nextTick();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect((utilsMod as any).error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
187
packages/editor/tests/unit/components/ContentMenu.spec.ts
Normal file
187
packages/editor/tests/unit/components/ContentMenu.spec.ts
Normal file
@ -0,0 +1,187 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import ContentMenu from '@editor/components/ContentMenu.vue';
|
||||
|
||||
const provideServices = () => ({
|
||||
global: {
|
||||
provide: {
|
||||
services: {
|
||||
editorService: {},
|
||||
uiService: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('ContentMenu.vue', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('show 后触发 show 事件', async () => {
|
||||
const wrapper = mount(ContentMenu as any, {
|
||||
...provideServices(),
|
||||
props: {
|
||||
menuData: [{ id: '1', type: 'button', text: 'a' }] as any,
|
||||
},
|
||||
});
|
||||
(wrapper.vm as any).show({ clientX: 10, clientY: 20 });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(wrapper.emitted('show')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('show 之后调用 hide 触发 hide 事件', async () => {
|
||||
const wrapper = mount(ContentMenu as any, {
|
||||
...provideServices(),
|
||||
props: { menuData: [] as any },
|
||||
});
|
||||
(wrapper.vm as any).show();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
(wrapper.vm as any).hide();
|
||||
expect(wrapper.emitted('hide')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('未显示时调用 hide 不触发事件', () => {
|
||||
const wrapper = mount(ContentMenu as any, {
|
||||
...provideServices(),
|
||||
props: { menuData: [] as any },
|
||||
});
|
||||
(wrapper.vm as any).hide();
|
||||
expect(wrapper.emitted('hide')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('setPosition 计算超出底部时回拢', () => {
|
||||
const wrapper = mount(ContentMenu as any, {
|
||||
...provideServices(),
|
||||
props: { menuData: [] as any },
|
||||
});
|
||||
Object.defineProperty(document.body, 'clientHeight', { value: 100, configurable: true });
|
||||
(wrapper.vm as any).setPosition({ clientX: 10, clientY: 90 });
|
||||
expect((wrapper.vm as any).menuPosition.left).toBe(10);
|
||||
expect((wrapper.vm as any).menuPosition.top).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
test('contains 判断 DOM 是否在菜单内部', async () => {
|
||||
const wrapper = mount(ContentMenu as any, {
|
||||
...provideServices(),
|
||||
props: { menuData: [] as any },
|
||||
});
|
||||
(wrapper.vm as any).show({ clientX: 0, clientY: 0 });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const outside = document.createElement('div');
|
||||
expect((wrapper.vm as any).contains(outside)).toBeFalsy();
|
||||
});
|
||||
|
||||
test('autoHide=false 时点击不会隐藏', async () => {
|
||||
const wrapper = mount(ContentMenu as any, {
|
||||
...provideServices(),
|
||||
props: { menuData: [{ id: '1', type: 'button', text: 'a' }] as any, autoHide: false },
|
||||
});
|
||||
(wrapper.vm as any).show();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(wrapper.emitted('hide')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('isSubMenu=true 不监听 mousedown', () => {
|
||||
const addSpy = vi.spyOn(globalThis, 'addEventListener');
|
||||
const wrapper = mount(ContentMenu as any, {
|
||||
...provideServices(),
|
||||
props: { menuData: [] as any, isSubMenu: true },
|
||||
});
|
||||
expect(addSpy).not.toHaveBeenCalledWith('mousedown', expect.any(Function), true);
|
||||
wrapper.unmount();
|
||||
addSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('卸载时移除 mousedown 监听', () => {
|
||||
const removeSpy = vi.spyOn(globalThis, 'removeEventListener');
|
||||
const wrapper = mount(ContentMenu as any, {
|
||||
...provideServices(),
|
||||
props: { menuData: [] as any },
|
||||
});
|
||||
wrapper.unmount();
|
||||
expect(removeSpy).toHaveBeenCalledWith('mousedown', expect.any(Function), true);
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('外部点击触发 hide', async () => {
|
||||
const wrapper = mount(ContentMenu as any, {
|
||||
...provideServices(),
|
||||
props: { menuData: [{ id: '1', type: 'button', text: 'a' }] as any },
|
||||
attachTo: document.body,
|
||||
});
|
||||
(wrapper.vm as any).show({ clientX: 0, clientY: 0 });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const outside = document.createElement('div');
|
||||
document.body.appendChild(outside);
|
||||
const event = new MouseEvent('mousedown');
|
||||
Object.defineProperty(event, 'target', { value: outside });
|
||||
globalThis.dispatchEvent(event);
|
||||
expect(wrapper.emitted('hide')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('外部点击在菜单内部时不 hide', async () => {
|
||||
const wrapper = mount(ContentMenu as any, {
|
||||
...provideServices(),
|
||||
props: { menuData: [{ id: '1', type: 'button', text: 'a' }] as any },
|
||||
attachTo: document.body,
|
||||
});
|
||||
(wrapper.vm as any).show({ clientX: 0, clientY: 0 });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const inside = wrapper.find('.magic-editor-content-menu').element as HTMLElement;
|
||||
const event = new MouseEvent('mousedown');
|
||||
Object.defineProperty(event, 'target', { value: inside });
|
||||
globalThis.dispatchEvent(event);
|
||||
expect(wrapper.emitted('hide')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('autoHide=false 时外部点击不 hide', async () => {
|
||||
const wrapper = mount(ContentMenu as any, {
|
||||
...provideServices(),
|
||||
props: { menuData: [] as any, autoHide: false },
|
||||
attachTo: document.body,
|
||||
});
|
||||
(wrapper.vm as any).show({ clientX: 0, clientY: 0 });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const outside = document.createElement('div');
|
||||
document.body.appendChild(outside);
|
||||
const event = new MouseEvent('mousedown');
|
||||
Object.defineProperty(event, 'target', { value: outside });
|
||||
globalThis.dispatchEvent(event);
|
||||
expect(wrapper.emitted('hide')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('mouseenter 触发 mouseenter 事件', async () => {
|
||||
const wrapper = mount(ContentMenu as any, {
|
||||
...provideServices(),
|
||||
props: { menuData: [] as any },
|
||||
});
|
||||
(wrapper.vm as any).show();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await wrapper.find('.magic-editor-content-menu').trigger('mouseenter');
|
||||
expect(wrapper.emitted('mouseenter')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('showSubMenu 设置 subMenuData', async () => {
|
||||
const wrapper = mount(ContentMenu as any, {
|
||||
...provideServices(),
|
||||
props: {
|
||||
menuData: [{ id: '1', type: 'button', text: 'a', items: [{ id: '2', type: 'button', text: 'b' }] }] as any,
|
||||
},
|
||||
});
|
||||
(wrapper.vm as any).show({ clientX: 10, clientY: 20 });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const buttons = wrapper.findAll('.tool-button');
|
||||
if (buttons.length > 0) {
|
||||
await buttons[0].trigger('mouseenter');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
181
packages/editor/tests/unit/components/FloatingBox.spec.ts
Normal file
181
packages/editor/tests/unit/components/FloatingBox.spec.ts
Normal file
@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import FloatingBox from '@editor/components/FloatingBox.vue';
|
||||
|
||||
const moveableHandlers = new Map<string, (...args: any[]) => void>();
|
||||
const destroyMock = vi.fn();
|
||||
let lastInstance: any;
|
||||
|
||||
vi.mock('moveable', () => {
|
||||
class FakeMoveable {
|
||||
public target: any;
|
||||
public dragTarget: any;
|
||||
constructor(_root: any, opts: any) {
|
||||
this.target = opts.target;
|
||||
this.dragTarget = opts.dragTarget;
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const me = this;
|
||||
lastInstance = me;
|
||||
moveableHandlers.clear();
|
||||
}
|
||||
public on(event: string, fn: (...args: any[]) => void) {
|
||||
moveableHandlers.set(event, fn);
|
||||
return this;
|
||||
}
|
||||
public destroy() {
|
||||
destroyMock();
|
||||
}
|
||||
}
|
||||
return { default: FakeMoveable };
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/design', async () => {
|
||||
const actual: any = await vi.importActual('@tmagic/design');
|
||||
return {
|
||||
...actual,
|
||||
TMagicButton: defineComponent({
|
||||
props: ['link', 'size'],
|
||||
emits: ['click'],
|
||||
setup(_, { slots, emit }) {
|
||||
return () => h('button', { class: 'fake-btn', onClick: () => emit('click') }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
TMagicIcon: defineComponent({ render: () => h('i', { class: 'fake-icon' }) }),
|
||||
useZIndex: () => ({ nextZIndex: () => 100 }),
|
||||
};
|
||||
});
|
||||
|
||||
const services = {
|
||||
global: {
|
||||
provide: {
|
||||
services: {
|
||||
uiService: {
|
||||
get: (k: string) => (k === 'frameworkRect' ? { width: 1000 } : undefined),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('FloatingBox.vue', () => {
|
||||
beforeEach(() => {
|
||||
moveableHandlers.clear();
|
||||
destroyMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
test('visible 为 false 时不渲染内容', () => {
|
||||
mount(FloatingBox as any, {
|
||||
...services,
|
||||
props: { visible: false },
|
||||
attachTo: document.body,
|
||||
});
|
||||
expect(document.querySelector('.m-editor-float-box')).toBeNull();
|
||||
});
|
||||
|
||||
test('visible 为 true 时渲染并初始化 moveable', async () => {
|
||||
mount(FloatingBox as any, {
|
||||
...services,
|
||||
props: { visible: true, position: { left: 0, top: 0 } },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(document.querySelector('.m-editor-float-box')).not.toBeNull();
|
||||
expect(lastInstance).toBeDefined();
|
||||
});
|
||||
|
||||
test('点击关闭按钮时触发 update:visible=false', async () => {
|
||||
const wrapper = mount(FloatingBox as any, {
|
||||
...services,
|
||||
props: { visible: true },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const btn = document.querySelector('.fake-btn');
|
||||
btn?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await wrapper.vm.$nextTick();
|
||||
const events = wrapper.emitted('update:visible') as any[] | undefined;
|
||||
expect(events?.some((e) => e[0] === false)).toBe(true);
|
||||
});
|
||||
|
||||
test('beforeClose 返回 false 时不触发隐藏', async () => {
|
||||
const beforeClose = vi.fn((done: (cancel?: boolean) => void) => done(false));
|
||||
const wrapper = mount(FloatingBox as any, {
|
||||
...services,
|
||||
props: { visible: true, beforeClose },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const btn = document.querySelector('.fake-btn');
|
||||
btn?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(beforeClose).toHaveBeenCalled();
|
||||
const events = (wrapper.emitted('update:visible') as any[] | undefined) || [];
|
||||
expect(events.some((e) => e[0] === false)).toBe(false);
|
||||
});
|
||||
|
||||
test('moveable resize 事件更新宽高', async () => {
|
||||
const wrapper = mount(FloatingBox as any, {
|
||||
...services,
|
||||
props: { visible: true },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const target = document.createElement('div');
|
||||
moveableHandlers.get('resize')?.({
|
||||
width: 200,
|
||||
height: 300,
|
||||
target,
|
||||
drag: { transform: 'translate(0,0)' },
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(target.style.width).toBe('200px');
|
||||
expect(target.style.height).toBe('300px');
|
||||
});
|
||||
|
||||
test('moveable drag 事件更新 transform', async () => {
|
||||
mount(FloatingBox as any, {
|
||||
...services,
|
||||
props: { visible: true },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const target = document.createElement('div');
|
||||
moveableHandlers.get('drag')?.({ target, transform: 'translate(10px,20px)' });
|
||||
expect(target.style.transform.replace(/\s+/g, '')).toBe('translate(10px,20px)');
|
||||
});
|
||||
|
||||
test('left + width 超过 frameworkWidth 时 left 被收敛', async () => {
|
||||
const wrapper = mount(FloatingBox as any, {
|
||||
...services,
|
||||
props: { visible: true, position: { left: 950, top: 0 }, width: 200 },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await wrapper.vm.$nextTick();
|
||||
const box = document.querySelector('.m-editor-float-box') as HTMLElement;
|
||||
expect(box).not.toBeNull();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('卸载时销毁 moveable', async () => {
|
||||
const wrapper = mount(FloatingBox as any, {
|
||||
...services,
|
||||
props: { visible: true },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
wrapper.unmount();
|
||||
expect(destroyMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
45
packages/editor/tests/unit/components/Icon.spec.ts
Normal file
45
packages/editor/tests/unit/components/Icon.spec.ts
Normal file
@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import Icon from '@editor/components/Icon.vue';
|
||||
|
||||
describe('Icon.vue', () => {
|
||||
test('未传 icon 时渲染默认 Edit 图标', () => {
|
||||
const wrapper = mount(Icon as any);
|
||||
expect(wrapper.find('.magic-editor-icon').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('icon 为 http 链接时使用 img 标签', () => {
|
||||
const wrapper = mount(Icon as any, { props: { icon: 'https://example.com/x.png' } });
|
||||
expect(wrapper.find('img').exists()).toBe(true);
|
||||
expect(wrapper.find('img').attributes('src')).toBe('https://example.com/x.png');
|
||||
});
|
||||
|
||||
test('icon 为相对路径时也使用 img 标签', () => {
|
||||
const wrapper = mount(Icon as any, { props: { icon: './local.png' } });
|
||||
expect(wrapper.find('img').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('icon 为 ../ 路径时使用 img 标签', () => {
|
||||
const wrapper = mount(Icon as any, { props: { icon: '../up.png' } });
|
||||
expect(wrapper.find('img').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('icon 为 className 字符串时渲染 i 标签', () => {
|
||||
const wrapper = mount(Icon as any, { props: { icon: 'el-icon-edit' } });
|
||||
expect(wrapper.find('i').exists()).toBe(true);
|
||||
expect(wrapper.find('i').classes()).toContain('el-icon-edit');
|
||||
});
|
||||
|
||||
test('icon 为组件时通过 component 渲染', () => {
|
||||
const customComp = defineComponent({ render: () => h('span', { class: 'custom-icon' }, 'C') });
|
||||
const wrapper = mount(Icon as any, { props: { icon: customComp } });
|
||||
expect(wrapper.find('.custom-icon').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
46
packages/editor/tests/unit/components/Resizer.spec.ts
Normal file
46
packages/editor/tests/unit/components/Resizer.spec.ts
Normal file
@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import Resizer from '@editor/components/Resizer.vue';
|
||||
|
||||
vi.mock('gesto', () => {
|
||||
const handlers = new Map<string, (...args: any[]) => void>();
|
||||
class FakeGesto {
|
||||
public on(event: string, fn: (...args: any[]) => void) {
|
||||
handlers.set(event, fn);
|
||||
return this;
|
||||
}
|
||||
public unset() {}
|
||||
}
|
||||
(FakeGesto as any).__handlers = handlers;
|
||||
return { default: FakeGesto };
|
||||
});
|
||||
|
||||
describe('Resizer.vue', () => {
|
||||
test('渲染 m-editor-resizer 容器', () => {
|
||||
const wrapper = mount(Resizer as any, {
|
||||
slots: { default: '<span class="inner">x</span>' },
|
||||
});
|
||||
expect(wrapper.find('.m-editor-resizer').exists()).toBe(true);
|
||||
expect(wrapper.find('.inner').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('isDragging 切换时增加拖拽样式类', async () => {
|
||||
const wrapper = mount(Resizer as any);
|
||||
const gestoMod: any = (await import('gesto')).default;
|
||||
const handlers: Map<string, (...args: any[]) => void> = gestoMod.__handlers;
|
||||
|
||||
handlers.get('dragStart')?.();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('.m-editor-resizer').classes()).toContain('m-editor-resizer-dragging');
|
||||
|
||||
handlers.get('dragEnd')?.();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('.m-editor-resizer').classes()).not.toContain('m-editor-resizer-dragging');
|
||||
});
|
||||
});
|
||||
89
packages/editor/tests/unit/components/ScrollBar.spec.ts
Normal file
89
packages/editor/tests/unit/components/ScrollBar.spec.ts
Normal file
@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import ScrollBar from '@editor/components/ScrollBar.vue';
|
||||
|
||||
vi.mock('gesto', () => {
|
||||
const handlers = new Map<string, (...args: any[]) => void>();
|
||||
class FakeGesto {
|
||||
public on(event: string, fn: (...args: any[]) => void) {
|
||||
handlers.set(event, fn);
|
||||
return this;
|
||||
}
|
||||
public off() {}
|
||||
}
|
||||
(FakeGesto as any).__handlers = handlers;
|
||||
return { default: FakeGesto };
|
||||
});
|
||||
|
||||
const baseProps = {
|
||||
size: 100,
|
||||
scrollSize: 200,
|
||||
pos: 0,
|
||||
};
|
||||
|
||||
describe('ScrollBar.vue', () => {
|
||||
test('垂直方向渲染竖向类名', () => {
|
||||
const wrapper = mount(ScrollBar as any, { props: { ...baseProps } });
|
||||
expect(wrapper.find('.m-editor-scroll-bar').classes()).toContain('vertical');
|
||||
});
|
||||
|
||||
test('水平方向渲染横向类名', () => {
|
||||
const wrapper = mount(ScrollBar as any, {
|
||||
props: { ...baseProps, isHorizontal: true },
|
||||
});
|
||||
expect(wrapper.find('.m-editor-scroll-bar').classes()).toContain('horizontal');
|
||||
});
|
||||
|
||||
test('thumb 大小由 size/scrollSize 计算', () => {
|
||||
const wrapper = mount(ScrollBar as any, { props: { ...baseProps } });
|
||||
const thumb = wrapper.find('.m-editor-scroll-bar-thumb');
|
||||
const style = thumb.attributes('style') || '';
|
||||
expect(style).toContain('height');
|
||||
});
|
||||
|
||||
const getHandlers = async (): Promise<Map<string, (...args: any[]) => void>> => {
|
||||
const gestoMod: any = (await import('gesto')).default;
|
||||
return gestoMod.__handlers;
|
||||
};
|
||||
|
||||
test('滚动到顶部时 emit 0', async () => {
|
||||
const wrapper = mount(ScrollBar as any, { props: { ...baseProps, pos: 0 } });
|
||||
const handlers = await getHandlers();
|
||||
handlers.get('drag')?.({ deltaY: -10, deltaX: 0 });
|
||||
expect((wrapper.emitted('scroll') as any[])[0][0]).toBe(0);
|
||||
});
|
||||
|
||||
test('向下滚动 emit 正值', async () => {
|
||||
const wrapper = mount(ScrollBar as any, { props: { ...baseProps, pos: 0 } });
|
||||
const handlers = await getHandlers();
|
||||
handlers.get('drag')?.({ deltaY: 5, deltaX: 0 });
|
||||
const events = wrapper.emitted('scroll') as any[];
|
||||
expect(events[events.length - 1][0]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('已滚动到底部时再向下 emit 0', async () => {
|
||||
const wrapper = mount(ScrollBar as any, {
|
||||
props: { size: 100, scrollSize: 100, pos: 0 },
|
||||
});
|
||||
const handlers = await getHandlers();
|
||||
handlers.get('drag')?.({ deltaY: 10, deltaX: 0 });
|
||||
const events = wrapper.emitted('scroll') as any[];
|
||||
expect(events[events.length - 1][0]).toBe(0);
|
||||
});
|
||||
|
||||
test('dragStart 阻止默认事件', async () => {
|
||||
mount(ScrollBar as any, { props: { ...baseProps } });
|
||||
const handlers = await getHandlers();
|
||||
const stopPropagation = vi.fn();
|
||||
const preventDefault = vi.fn();
|
||||
handlers.get('dragStart')?.({ inputEvent: { stopPropagation, preventDefault } });
|
||||
expect(stopPropagation).toHaveBeenCalled();
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
143
packages/editor/tests/unit/components/ScrollViewer.spec.ts
Normal file
143
packages/editor/tests/unit/components/ScrollViewer.spec.ts
Normal file
@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import ScrollViewer from '@editor/components/ScrollViewer.vue';
|
||||
|
||||
const { scrollViewerInstances } = vi.hoisted(() => ({
|
||||
scrollViewerInstances: [] as any[],
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils/scroll-viewer', () => ({
|
||||
ScrollViewer: class {
|
||||
handlers: Record<string, any[]> = {};
|
||||
setZoom = vi.fn();
|
||||
scrollTo = vi.fn();
|
||||
destroy = vi.fn();
|
||||
constructor(_opts: any) {
|
||||
scrollViewerInstances.push(this);
|
||||
}
|
||||
on(event: string, cb: any) {
|
||||
this.handlers[event] = this.handlers[event] || [];
|
||||
this.handlers[event].push(cb);
|
||||
}
|
||||
triggerScroll(data: any) {
|
||||
(this.handlers.scroll || []).forEach((cb) => cb(data));
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/ScrollBar.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'ScrollBar',
|
||||
props: ['scrollSize', 'pos', 'size', 'isHorizontal'],
|
||||
emits: ['scroll'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h('div', {
|
||||
class: ['fake-scrollbar', props.isHorizontal ? 'h' : 'v'],
|
||||
onClick: () => emit('scroll', 50),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
scrollViewerInstances.length = 0;
|
||||
});
|
||||
|
||||
describe('ScrollViewer', () => {
|
||||
test('挂载时创建 ScrollViewer', async () => {
|
||||
const wrapper = mount(ScrollViewer, { props: { width: 100, height: 100 } as any });
|
||||
await nextTick();
|
||||
expect(scrollViewerInstances.length).toBe(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('width/height 为字符串', async () => {
|
||||
const wrapper = mount(ScrollViewer, { props: { width: '100%', height: '50vh' } as any });
|
||||
await nextTick();
|
||||
expect(wrapper.html()).toContain('100%');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('滚动尺寸大于容器时显示滚动条', async () => {
|
||||
const wrapper = mount(ScrollViewer, {
|
||||
props: { width: 100, height: 100, wrapWidth: 50, wrapHeight: 50 } as any,
|
||||
});
|
||||
await nextTick();
|
||||
scrollViewerInstances[0].triggerScroll({
|
||||
scrollLeft: 10,
|
||||
scrollTop: 10,
|
||||
scrollWidth: 200,
|
||||
scrollHeight: 200,
|
||||
});
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('.fake-scrollbar').length).toBe(2);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('点击垂直滚动条触发 scrollTo', async () => {
|
||||
const wrapper = mount(ScrollViewer, {
|
||||
props: { width: 100, height: 100, wrapHeight: 50 } as any,
|
||||
});
|
||||
await nextTick();
|
||||
scrollViewerInstances[0].triggerScroll({
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
scrollWidth: 50,
|
||||
scrollHeight: 200,
|
||||
});
|
||||
await nextTick();
|
||||
await wrapper.find('.fake-scrollbar.v').trigger('click');
|
||||
expect(scrollViewerInstances[0].scrollTo).toHaveBeenCalledWith({ top: 50 });
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('点击水平滚动条触发 scrollTo', async () => {
|
||||
const wrapper = mount(ScrollViewer, {
|
||||
props: { width: 100, height: 100, wrapWidth: 50 } as any,
|
||||
});
|
||||
await nextTick();
|
||||
scrollViewerInstances[0].triggerScroll({
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
scrollWidth: 200,
|
||||
scrollHeight: 50,
|
||||
});
|
||||
await nextTick();
|
||||
await wrapper.find('.fake-scrollbar.h').trigger('click');
|
||||
expect(scrollViewerInstances[0].scrollTo).toHaveBeenCalledWith({ left: 50 });
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('zoom 变化时调用 setZoom', async () => {
|
||||
const wrapper = mount(ScrollViewer, { props: { width: 100, height: 100, zoom: 1 } as any });
|
||||
await nextTick();
|
||||
await wrapper.setProps({ zoom: 2 });
|
||||
await nextTick();
|
||||
expect(scrollViewerInstances[0].setZoom).toHaveBeenCalledWith(2);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('卸载时 destroy', async () => {
|
||||
const wrapper = mount(ScrollViewer, { props: { width: 100, height: 100 } as any });
|
||||
await nextTick();
|
||||
const inst = scrollViewerInstances[0];
|
||||
wrapper.unmount();
|
||||
expect(inst.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('expose container', async () => {
|
||||
const wrapper = mount(ScrollViewer, { props: { width: 100, height: 100 } as any });
|
||||
await nextTick();
|
||||
expect((wrapper.vm as any).container).toBeTruthy();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
75
packages/editor/tests/unit/components/SearchInput.spec.ts
Normal file
75
packages/editor/tests/unit/components/SearchInput.spec.ts
Normal file
@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import SearchInput from '@editor/components/SearchInput.vue';
|
||||
|
||||
vi.mock('@tmagic/design', async () => {
|
||||
const actual: any = await vi.importActual('@tmagic/design');
|
||||
return {
|
||||
...actual,
|
||||
TMagicInput: defineComponent({
|
||||
name: 'TMagicInputStub',
|
||||
props: ['modelValue'],
|
||||
emits: ['input', 'update:modelValue'],
|
||||
setup(props, { emit, slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'input',
|
||||
{
|
||||
value: props.modelValue,
|
||||
onInput: (e: any) => {
|
||||
emit('update:modelValue', e.target.value);
|
||||
emit('input', e.target.value);
|
||||
},
|
||||
},
|
||||
slots.prefix?.(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
TMagicIcon: defineComponent({ render: () => h('i') }),
|
||||
};
|
||||
});
|
||||
|
||||
describe('SearchInput.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('挂载后渲染输入框', () => {
|
||||
const wrapper = mount(SearchInput as any);
|
||||
expect(wrapper.find('input').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('输入后 300ms 触发 search 事件', async () => {
|
||||
const wrapper = mount(SearchInput as any);
|
||||
const input = wrapper.find('input');
|
||||
await input.setValue('hello');
|
||||
|
||||
expect(wrapper.emitted('search')).toBeFalsy();
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(wrapper.emitted('search')).toBeTruthy();
|
||||
expect((wrapper.emitted('search') as any[])[0][0]).toBe('hello');
|
||||
});
|
||||
|
||||
test('连续输入只触发一次 search', async () => {
|
||||
const wrapper = mount(SearchInput as any);
|
||||
const input = wrapper.find('input');
|
||||
await input.setValue('a');
|
||||
vi.advanceTimersByTime(100);
|
||||
await input.setValue('ab');
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(wrapper.emitted('search')?.length).toBe(1);
|
||||
expect((wrapper.emitted('search') as any[])[0][0]).toBe('ab');
|
||||
});
|
||||
});
|
||||
151
packages/editor/tests/unit/components/SplitView.spec.ts
Normal file
151
packages/editor/tests/unit/components/SplitView.spec.ts
Normal file
@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import SplitView from '@editor/components/SplitView.vue';
|
||||
|
||||
vi.mock('gesto', () => {
|
||||
class FakeGesto {
|
||||
public on() {
|
||||
return this;
|
||||
}
|
||||
public unset() {}
|
||||
public off() {}
|
||||
}
|
||||
return { default: FakeGesto };
|
||||
});
|
||||
|
||||
globalThis.ResizeObserver =
|
||||
globalThis.ResizeObserver ||
|
||||
(class {
|
||||
public disconnect = vi.fn();
|
||||
public observe = vi.fn();
|
||||
public unobserve = vi.fn();
|
||||
} as any);
|
||||
|
||||
describe('SplitView.vue', () => {
|
||||
test('指定 width 时通过 watchEffect 计算 center 并 emit change', async () => {
|
||||
const wrapper = mount(SplitView as any, {
|
||||
props: { width: 1000, left: 200, right: 200 },
|
||||
slots: {
|
||||
left: '<div class="l">L</div>',
|
||||
center: '<div class="c">C</div>',
|
||||
right: '<div class="r">R</div>',
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('.m-editor-layout').exists()).toBe(true);
|
||||
expect(wrapper.find('.m-editor-layout-left').exists()).toBe(true);
|
||||
expect(wrapper.find('.m-editor-layout-right').exists()).toBe(true);
|
||||
expect(wrapper.find('.m-editor-layout-center').exists()).toBe(true);
|
||||
|
||||
const events = wrapper.emitted('change');
|
||||
expect(events).toBeTruthy();
|
||||
expect((events as any[])[0][0]).toEqual(expect.objectContaining({ left: 200, center: 600, right: 200 }));
|
||||
});
|
||||
|
||||
test('未提供 width 时使用 ResizeObserver 监听', () => {
|
||||
const wrapper = mount(SplitView as any, {
|
||||
props: { left: 100, right: 100 },
|
||||
slots: { left: '<div>L</div>', right: '<div>R</div>', center: '<div>C</div>' },
|
||||
});
|
||||
expect(wrapper.find('.m-editor-layout').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('未提供 left 时不渲染左侧栏', () => {
|
||||
const wrapper = mount(SplitView as any, {
|
||||
props: { width: 800, right: 100 },
|
||||
slots: { right: '<div>R</div>', center: '<div>C</div>' },
|
||||
});
|
||||
expect(wrapper.find('.m-editor-layout-left').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('未提供 right 时不渲染右侧栏', () => {
|
||||
const wrapper = mount(SplitView as any, {
|
||||
props: { width: 800, left: 100 },
|
||||
slots: { left: '<div>L</div>', center: '<div>C</div>' },
|
||||
});
|
||||
expect(wrapper.find('.m-editor-layout-right').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('updateWidth 暴露方法可重新计算', async () => {
|
||||
const wrapper = mount(SplitView as any, {
|
||||
props: { width: 1000, left: 200, right: 200 },
|
||||
slots: { left: '<div>L</div>', right: '<div>R</div>', center: '<div>C</div>' },
|
||||
});
|
||||
const before = (wrapper.emitted('change') as any[]).length;
|
||||
(wrapper.vm as any).updateWidth();
|
||||
const after = (wrapper.emitted('change') as any[]).length;
|
||||
expect(after).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
test('left 超过容器宽度时回退到 1/3', () => {
|
||||
const wrapper = mount(SplitView as any, {
|
||||
props: { width: 600, left: 800, right: 100 },
|
||||
slots: { left: '<div>L</div>', right: '<div>R</div>', center: '<div>C</div>' },
|
||||
});
|
||||
const events = wrapper.emitted('change') as any[];
|
||||
expect(events[0][0].left).toBeLessThan(800);
|
||||
});
|
||||
|
||||
test('center 小于最小值时调整 right', () => {
|
||||
const wrapper = mount(SplitView as any, {
|
||||
props: { width: 100, left: 50, right: 50, minCenter: 50 },
|
||||
slots: { left: '<div>L</div>', right: '<div>R</div>', center: '<div>C</div>' },
|
||||
});
|
||||
const events = wrapper.emitted('change') as any[];
|
||||
expect(events[0][0].center).toBeGreaterThanOrEqual(50);
|
||||
});
|
||||
|
||||
test('changeLeft 通过 Resizer change 触发', async () => {
|
||||
const wrapper = mount(SplitView as any, {
|
||||
props: { width: 1000, left: 200, right: 200 },
|
||||
slots: { left: '<div>L</div>', right: '<div>R</div>', center: '<div>C</div>' },
|
||||
});
|
||||
const resizers = wrapper.findAllComponents({ name: 'MEditorResizer' });
|
||||
expect(resizers.length).toBeGreaterThan(0);
|
||||
await resizers[0].vm.$emit('change', { deltaX: 50 });
|
||||
expect(wrapper.emitted('update:left')).toBeTruthy();
|
||||
const updateLeft = wrapper.emitted('update:left') as any[];
|
||||
expect(updateLeft[0][0]).toBe(250);
|
||||
});
|
||||
|
||||
test('changeRight 通过 Resizer change 触发', async () => {
|
||||
const wrapper = mount(SplitView as any, {
|
||||
props: { width: 1000, left: 200, right: 200 },
|
||||
slots: { left: '<div>L</div>', right: '<div>R</div>', center: '<div>C</div>' },
|
||||
});
|
||||
const resizers = wrapper.findAllComponents({ name: 'MEditorResizer' });
|
||||
await resizers[1].vm.$emit('change', { deltaX: -30 });
|
||||
expect(wrapper.emitted('update:right')).toBeTruthy();
|
||||
expect((wrapper.emitted('update:right') as any[])[0][0]).toBe(230);
|
||||
});
|
||||
|
||||
test('changeLeft 没有 left props 时直接 return', async () => {
|
||||
const wrapper = mount(SplitView as any, {
|
||||
props: { width: 1000, right: 200 },
|
||||
slots: { left: '<div>L</div>', right: '<div>R</div>', center: '<div>C</div>' },
|
||||
});
|
||||
const resizers = wrapper.findAllComponents({ name: 'MEditorResizer' });
|
||||
if (resizers[0]) {
|
||||
await resizers[0].vm.$emit('change', { deltaX: 50 });
|
||||
expect(wrapper.emitted('update:left')).toBeFalsy();
|
||||
}
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test('updateWidth 在 width 为 undefined 时使用 el.clientWidth', () => {
|
||||
const wrapper = mount(SplitView as any, {
|
||||
props: { left: 100, right: 100 },
|
||||
slots: { left: '<div>L</div>', right: '<div>R</div>', center: '<div>C</div>' },
|
||||
});
|
||||
const el = wrapper.find('.m-editor-layout').element as HTMLElement;
|
||||
Object.defineProperty(el, 'clientWidth', { configurable: true, value: 600 });
|
||||
(wrapper.vm as any).updateWidth();
|
||||
const events = wrapper.emitted('change') as any[];
|
||||
expect(events[events.length - 1][0]).toBeDefined();
|
||||
});
|
||||
});
|
||||
197
packages/editor/tests/unit/components/ToolButton.spec.ts
Normal file
197
packages/editor/tests/unit/components/ToolButton.spec.ts
Normal file
@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import ToolButton from '@editor/components/ToolButton.vue';
|
||||
|
||||
const provideServices = () => ({
|
||||
global: {
|
||||
provide: {
|
||||
services: {
|
||||
editorService: {},
|
||||
uiService: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('ToolButton.vue', () => {
|
||||
test('display 为 false 时不渲染', () => {
|
||||
const wrapper = mount(ToolButton as any, {
|
||||
...provideServices(),
|
||||
props: {
|
||||
data: { type: 'button', display: false, text: 'btn' },
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('.menu-item').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('data.type=text 时渲染文字', () => {
|
||||
const wrapper = mount(ToolButton as any, {
|
||||
...provideServices(),
|
||||
props: {
|
||||
data: { type: 'text', text: 'hello' } as any,
|
||||
},
|
||||
});
|
||||
expect(wrapper.text()).toContain('hello');
|
||||
});
|
||||
|
||||
test('data.type=divider 时渲染 divider', () => {
|
||||
const wrapper = mount(ToolButton as any, {
|
||||
...provideServices(),
|
||||
props: {
|
||||
data: { type: 'divider' } as any,
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('.menu-item').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('data.type=button 点击触发 handler', async () => {
|
||||
const handler = vi.fn();
|
||||
const wrapper = mount(ToolButton as any, {
|
||||
...provideServices(),
|
||||
props: {
|
||||
data: { type: 'button', text: 'click', handler } as any,
|
||||
eventType: 'click',
|
||||
},
|
||||
});
|
||||
await wrapper.find('.menu-item').trigger('click');
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('display 函数返回 false 时不渲染', () => {
|
||||
const wrapper = mount(ToolButton as any, {
|
||||
...provideServices(),
|
||||
props: {
|
||||
data: { type: 'button', display: () => false, text: 'x' } as any,
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('.menu-item').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('disabled 函数返回 true 时不调用 handler', async () => {
|
||||
const handler = vi.fn();
|
||||
const wrapper = mount(ToolButton as any, {
|
||||
...provideServices(),
|
||||
props: {
|
||||
data: { type: 'button', text: 'x', disabled: () => true, handler } as any,
|
||||
eventType: 'click',
|
||||
},
|
||||
});
|
||||
await wrapper.find('.menu-item').trigger('click');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('eventType=mousedown 仅 mousedown 触发', async () => {
|
||||
const handler = vi.fn();
|
||||
const wrapper = mount(ToolButton as any, {
|
||||
...provideServices(),
|
||||
props: {
|
||||
data: { type: 'button', text: 'x', handler } as any,
|
||||
eventType: 'mousedown',
|
||||
},
|
||||
});
|
||||
await wrapper.find('.menu-item').trigger('click');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
await wrapper.find('.menu-item').trigger('mousedown');
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('eventType=mouseup 仅左键 mouseup 触发', async () => {
|
||||
const handler = vi.fn();
|
||||
const wrapper = mount(ToolButton as any, {
|
||||
...provideServices(),
|
||||
props: {
|
||||
data: { type: 'button', text: 'x', handler } as any,
|
||||
eventType: 'mouseup',
|
||||
},
|
||||
});
|
||||
await wrapper.find('.menu-item').trigger('mouseup', { button: 1 });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
await wrapper.find('.menu-item').trigger('mouseup', { button: 0 });
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('button 含 tooltip 时渲染 tooltip', () => {
|
||||
const wrapper = mount(ToolButton as any, {
|
||||
...provideServices(),
|
||||
props: {
|
||||
data: { type: 'button', text: 'x', tooltip: 'tip' } as any,
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('.menu-item').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('x');
|
||||
});
|
||||
|
||||
test('data.type=dropdown 时渲染下拉菜单', () => {
|
||||
const wrapper = mount(ToolButton as any, {
|
||||
...provideServices(),
|
||||
props: {
|
||||
data: {
|
||||
type: 'dropdown',
|
||||
text: 'menu',
|
||||
items: [{ text: 'item1', handler: vi.fn() }],
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('.menu-item').exists()).toBe(true);
|
||||
expect(wrapper.find('.menubar-menu-button').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('menu');
|
||||
});
|
||||
|
||||
test('data.type=component 时渲染对应组件', () => {
|
||||
const fakeComp = {
|
||||
name: 'FakeC',
|
||||
props: ['v'],
|
||||
template: '<div class="custom-comp">{{v}}</div>',
|
||||
};
|
||||
const wrapper = mount(ToolButton as any, {
|
||||
...provideServices(),
|
||||
props: {
|
||||
data: {
|
||||
type: 'component',
|
||||
component: fakeComp,
|
||||
props: { v: 'hello' },
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('.custom-comp').exists()).toBe(true);
|
||||
expect(wrapper.find('.custom-comp').text()).toBe('hello');
|
||||
});
|
||||
|
||||
test('dropdown 选中调用对应 handler', async () => {
|
||||
const handler = vi.fn();
|
||||
const wrapper = mount(ToolButton as any, {
|
||||
...provideServices(),
|
||||
props: {
|
||||
data: {
|
||||
type: 'dropdown',
|
||||
text: 'menu',
|
||||
items: [{ text: 'item1', handler }],
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
const dropdown = wrapper.findComponent({ name: 'TMagicDropdown' });
|
||||
if (dropdown.exists()) {
|
||||
await dropdown.vm.$emit('command', { item: { handler } });
|
||||
expect(handler).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test('disabled 为 boolean 时直接使用', async () => {
|
||||
const handler = vi.fn();
|
||||
const wrapper = mount(ToolButton as any, {
|
||||
...provideServices(),
|
||||
props: {
|
||||
data: { type: 'button', text: 'x', disabled: true, handler } as any,
|
||||
eventType: 'click',
|
||||
},
|
||||
});
|
||||
await wrapper.find('.menu-item').trigger('click');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
139
packages/editor/tests/unit/components/Tree.spec.ts
Normal file
139
packages/editor/tests/unit/components/Tree.spec.ts
Normal file
@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import Tree from '@editor/components/Tree.vue';
|
||||
import TreeNode from '@editor/components/TreeNode.vue';
|
||||
|
||||
const buildStatusMap = (overrides: Record<string, any> = {}) => {
|
||||
const map = new Map<string | number, any>();
|
||||
Object.entries(overrides).forEach(([k, v]) => {
|
||||
map.set(k, { selected: false, expand: false, visible: true, draggable: true, ...v });
|
||||
});
|
||||
return map;
|
||||
};
|
||||
|
||||
describe('Tree.vue', () => {
|
||||
test('data 为空时渲染 emptyText', () => {
|
||||
const wrapper = mount(Tree as any, {
|
||||
props: {
|
||||
data: [],
|
||||
nodeStatusMap: new Map(),
|
||||
emptyText: '什么都没有',
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('.m-editor-tree-empty').text()).toBe('什么都没有');
|
||||
});
|
||||
|
||||
test('data 非空时渲染 TreeNode', () => {
|
||||
const wrapper = mount(Tree as any, {
|
||||
props: {
|
||||
data: [
|
||||
{ id: '1', name: 'A' },
|
||||
{ id: '2', name: 'B' },
|
||||
],
|
||||
nodeStatusMap: buildStatusMap({ '1': {}, '2': {} }),
|
||||
},
|
||||
});
|
||||
expect(wrapper.findAllComponents(TreeNode).length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('dragover 事件向上抛 node-dragover', async () => {
|
||||
const wrapper = mount(Tree as any, {
|
||||
props: {
|
||||
data: [{ id: '1', name: 'A' }],
|
||||
nodeStatusMap: buildStatusMap({ '1': {} }),
|
||||
},
|
||||
});
|
||||
await wrapper.find('.m-editor-tree').trigger('dragover');
|
||||
expect(wrapper.emitted('node-dragover')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TreeNode.vue', () => {
|
||||
test('节点不可见时不会渲染', () => {
|
||||
const wrapper = mount(TreeNode as any, {
|
||||
props: {
|
||||
data: { id: '1', name: 'A' },
|
||||
nodeStatusMap: buildStatusMap({ '1': { visible: false } }),
|
||||
},
|
||||
});
|
||||
const root = wrapper.find('.m-editor-tree-node');
|
||||
expect(root.attributes('style')).toContain('display: none');
|
||||
});
|
||||
|
||||
test('节点可见时渲染节点内容', () => {
|
||||
const wrapper = mount(TreeNode as any, {
|
||||
props: {
|
||||
data: { id: '1', name: 'A' },
|
||||
nodeStatusMap: buildStatusMap({ '1': { visible: true } }),
|
||||
},
|
||||
});
|
||||
expect(wrapper.text()).toContain('A');
|
||||
expect(wrapper.text()).toContain('1');
|
||||
});
|
||||
|
||||
test('点击展开图标会切换展开状态', async () => {
|
||||
const status = buildStatusMap({ '1': { visible: true, expand: false } });
|
||||
const wrapper = mount(TreeNode as any, {
|
||||
props: {
|
||||
data: { id: '1', name: 'A', items: [{ id: '2', name: 'B' }] },
|
||||
nodeStatusMap: status,
|
||||
},
|
||||
});
|
||||
await wrapper.find('.expand-icon').trigger('click');
|
||||
expect(status.get('1').expand).toBe(true);
|
||||
});
|
||||
|
||||
test('展开后渲染子节点', () => {
|
||||
const wrapper = mount(TreeNode as any, {
|
||||
props: {
|
||||
data: { id: '1', name: 'A', items: [{ id: '2', name: 'B' }] },
|
||||
nodeStatusMap: buildStatusMap({
|
||||
'1': { visible: true, expand: true },
|
||||
'2': { visible: true },
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(wrapper.findAllComponents(TreeNode).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('内容点击触发 node-click(通过 treeEmit)', async () => {
|
||||
const calls: string[] = [];
|
||||
const wrapper = mount(TreeNode as any, {
|
||||
props: {
|
||||
data: { id: '1', name: 'A' },
|
||||
nodeStatusMap: buildStatusMap({ '1': { visible: true } }),
|
||||
},
|
||||
global: {
|
||||
provide: {
|
||||
treeEmit: (name: string) => {
|
||||
calls.push(name);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await wrapper.find('.tree-node-content').trigger('click');
|
||||
await wrapper.find('.tree-node-content').trigger('dblclick');
|
||||
await wrapper.find('.tree-node').trigger('contextmenu');
|
||||
await wrapper.find('.tree-node').trigger('mouseenter');
|
||||
await wrapper.find('.m-editor-tree-node').trigger('dragstart');
|
||||
await wrapper.find('.m-editor-tree-node').trigger('dragleave');
|
||||
await wrapper.find('.m-editor-tree-node').trigger('dragend');
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
'node-click',
|
||||
'node-dblclick',
|
||||
'node-contextmenu',
|
||||
'node-mouseenter',
|
||||
'node-dragstart',
|
||||
'node-dragleave',
|
||||
'node-dragend',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
70
packages/editor/tests/unit/editorProps.spec.ts
Normal file
70
packages/editor/tests/unit/editorProps.spec.ts
Normal file
@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { defaultEditorProps } from '@editor/editorProps';
|
||||
|
||||
describe('defaultEditorProps', () => {
|
||||
test('提供 RenderType 与基础布尔默认值', () => {
|
||||
expect(defaultEditorProps.disabledMultiSelect).toBe(false);
|
||||
expect(defaultEditorProps.alwaysMultiSelect).toBe(false);
|
||||
expect(defaultEditorProps.disabledPageFragment).toBe(false);
|
||||
expect(defaultEditorProps.disabledStageOverlay).toBe(false);
|
||||
expect(defaultEditorProps.disabledShowSrc).toBe(false);
|
||||
expect(defaultEditorProps.disabledDataSource).toBe(false);
|
||||
expect(defaultEditorProps.disabledCodeBlock).toBe(false);
|
||||
});
|
||||
|
||||
test('containerHighlight 默认值', () => {
|
||||
expect(defaultEditorProps.containerHighlightDuration).toBe(800);
|
||||
expect(typeof defaultEditorProps.containerHighlightClassName).toBe('string');
|
||||
});
|
||||
|
||||
test('数组/对象工厂函数返回空值', () => {
|
||||
expect(defaultEditorProps.componentGroupList()).toEqual([]);
|
||||
expect(defaultEditorProps.datasourceList()).toEqual([]);
|
||||
expect(defaultEditorProps.layerContentMenu()).toEqual([]);
|
||||
expect(defaultEditorProps.stageContentMenu()).toEqual([]);
|
||||
expect(defaultEditorProps.menu()).toEqual({ left: [], right: [] });
|
||||
expect(defaultEditorProps.propsConfigs()).toEqual({});
|
||||
expect(defaultEditorProps.propsValues()).toEqual({});
|
||||
expect(defaultEditorProps.eventMethodList()).toEqual({});
|
||||
expect(defaultEditorProps.datasourceValues()).toEqual({});
|
||||
expect(defaultEditorProps.datasourceConfigs()).toEqual({});
|
||||
expect(defaultEditorProps.codeOptions()).toEqual({});
|
||||
});
|
||||
|
||||
test('canSelect - 元素含 tmagic-id 且不是 page fragment 容器时可选中', () => {
|
||||
const div = document.createElement('div');
|
||||
div.dataset.tmagicId = 'a';
|
||||
expect(defaultEditorProps.canSelect(div)).toBe(true);
|
||||
});
|
||||
|
||||
test('canSelect - 缺少 id 不可选中', () => {
|
||||
const div = document.createElement('div');
|
||||
expect(defaultEditorProps.canSelect(div)).toBe(false);
|
||||
});
|
||||
|
||||
test('canSelect - 是 page fragment 容器不可选中', () => {
|
||||
const div = document.createElement('div');
|
||||
div.dataset.tmagicId = 'a';
|
||||
div.dataset.tmagicPageFragmentContainerId = 'p';
|
||||
expect(defaultEditorProps.canSelect(div)).toBe(false);
|
||||
});
|
||||
|
||||
test('isContainer - magic-ui-container className', () => {
|
||||
const div = document.createElement('div');
|
||||
div.classList.add('magic-ui-container');
|
||||
expect(defaultEditorProps.isContainer(div)).toBe(true);
|
||||
const div2 = document.createElement('div');
|
||||
expect(defaultEditorProps.isContainer(div2)).toBe(false);
|
||||
});
|
||||
|
||||
test('customContentMenu 直接返回原 menus', () => {
|
||||
const menus = [{ id: 'a' }] as any;
|
||||
expect(defaultEditorProps.customContentMenu(menus)).toBe(menus);
|
||||
});
|
||||
});
|
||||
35
packages/editor/tests/unit/fields/Code.spec.ts
Normal file
35
packages/editor/tests/unit/fields/Code.spec.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import Code from '@editor/fields/Code.vue';
|
||||
|
||||
vi.mock('@editor/layouts/CodeEditor.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'CodeEditor',
|
||||
props: ['height', 'initValues', 'language', 'options', 'autosize', 'parse', 'editorCustomType'],
|
||||
emits: ['save'],
|
||||
setup(_p, { emit }) {
|
||||
return () => h('div', { class: 'fake-code-editor', onClick: () => emit('save', 'newvalue') });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Code', () => {
|
||||
test('save 触发 change', async () => {
|
||||
const wrapper = mount(Code, {
|
||||
props: {
|
||||
config: { height: '100px', language: 'js' },
|
||||
model: { codeField: 'oldval' },
|
||||
name: 'codeField',
|
||||
} as any,
|
||||
});
|
||||
await wrapper.find('.fake-code-editor').trigger('click');
|
||||
expect(wrapper.emitted('change')?.[0]?.[0]).toBe('newvalue');
|
||||
});
|
||||
});
|
||||
120
packages/editor/tests/unit/fields/CodeLink.spec.ts
Normal file
120
packages/editor/tests/unit/fields/CodeLink.spec.ts
Normal file
@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import CodeLink from '@editor/fields/CodeLink.vue';
|
||||
|
||||
const FakeMLink = defineComponent({
|
||||
name: 'MLink',
|
||||
props: ['config', 'model', 'name'],
|
||||
emits: ['change'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: 'fake-mlink',
|
||||
onClick: () => emit('change', { [(props.config as any).form[0].name]: '({ a: 1 })' }),
|
||||
},
|
||||
JSON.stringify((props.model as any).form),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/form', () => ({
|
||||
MLink: FakeMLink,
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils/config', () => ({
|
||||
getEditorConfig: () => (str: string) => {
|
||||
if (str.includes('error')) throw new Error('parse error');
|
||||
return { parsed: str };
|
||||
},
|
||||
}));
|
||||
|
||||
describe('CodeLink.vue', () => {
|
||||
test('渲染 MLink 并响应初始化值', async () => {
|
||||
const model: any = { fn: { foo: 1 } };
|
||||
const wrapper = mount(CodeLink, {
|
||||
props: {
|
||||
name: 'fn',
|
||||
prop: 'fn',
|
||||
config: { type: 'code-link', codeOptions: { lineNumbers: true } } as any,
|
||||
model,
|
||||
} as any,
|
||||
global: {
|
||||
components: { MLink: FakeMLink },
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
expect(wrapper.find('.fake-mlink').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('foo');
|
||||
});
|
||||
|
||||
test('change 事件解析并写入 model', async () => {
|
||||
const model: any = { fn: '' };
|
||||
const wrapper = mount(CodeLink, {
|
||||
props: {
|
||||
name: 'fn',
|
||||
prop: 'fn',
|
||||
config: { type: 'code-link' } as any,
|
||||
model,
|
||||
} as any,
|
||||
global: {
|
||||
components: { MLink: FakeMLink },
|
||||
},
|
||||
});
|
||||
await wrapper.find('.fake-mlink').trigger('click');
|
||||
expect(model.fn).toEqual({ parsed: '(({ a: 1 }))' });
|
||||
expect(wrapper.emitted('change')?.[0]).toEqual([{ parsed: '(({ a: 1 }))' }]);
|
||||
});
|
||||
|
||||
test('parse 异常时不抛出', async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock('@editor/utils/config', () => ({
|
||||
getEditorConfig: () => () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
}));
|
||||
const codeLinkComp = (await import('@editor/fields/CodeLink.vue')).default;
|
||||
const model: any = { fn: '' };
|
||||
const wrapper = mount(codeLinkComp, {
|
||||
props: {
|
||||
name: 'fn',
|
||||
prop: 'fn',
|
||||
config: { type: 'code-link' } as any,
|
||||
model,
|
||||
} as any,
|
||||
global: {
|
||||
components: { MLink: FakeMLink },
|
||||
},
|
||||
});
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
await wrapper.find('.fake-mlink').trigger('click');
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
errSpy.mockRestore();
|
||||
vi.doUnmock('@editor/utils/config');
|
||||
});
|
||||
|
||||
test('name 缺失时直接返回 (无 change 触发)', async () => {
|
||||
const wrapper = mount(CodeLink, {
|
||||
props: {
|
||||
name: 'fn',
|
||||
prop: 'fn',
|
||||
config: { type: 'code-link' } as any,
|
||||
model: { fn: '' },
|
||||
} as any,
|
||||
global: {
|
||||
components: { MLink: FakeMLink },
|
||||
},
|
||||
});
|
||||
await wrapper.setProps({ name: '' } as any);
|
||||
await wrapper.find('.fake-mlink').trigger('click');
|
||||
expect(wrapper.emitted('change')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
152
packages/editor/tests/unit/fields/CodeSelect.spec.ts
Normal file
152
packages/editor/tests/unit/fields/CodeSelect.spec.ts
Normal file
@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import CodeSelect from '@editor/fields/CodeSelect.vue';
|
||||
|
||||
const dataSourceService = {
|
||||
get: vi.fn(() => true),
|
||||
getDataSourceById: vi.fn(() => ({ title: 'DS1' })),
|
||||
};
|
||||
const codeBlockService = {
|
||||
getCodeContentById: vi.fn(() => ({ name: 'code-name' })),
|
||||
getEditStatus: vi.fn(() => true),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ dataSourceService, codeBlockService }),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/form', async (importOriginal) => {
|
||||
const actual = await importOriginal<any>();
|
||||
return {
|
||||
...actual,
|
||||
MContainer: defineComponent({
|
||||
name: 'MContainer',
|
||||
props: ['config', 'size', 'prop', 'disabled', 'lastValues', 'model'],
|
||||
emits: ['change'],
|
||||
setup() {
|
||||
return () => h('div', { class: 'fake-container' });
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicCard: defineComponent({
|
||||
name: 'TMagicCard',
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-card' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const baseProps = (extra: any = {}) => ({
|
||||
config: { type: 'code-select' },
|
||||
name: 'cs',
|
||||
prop: 'cs',
|
||||
model: { cs: { hookType: 'code', hookData: [{ codeType: 'code', codeId: 'c1' }] } },
|
||||
size: 'default',
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('CodeSelect', () => {
|
||||
test('change emit', async () => {
|
||||
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
||||
await wrapper.findComponent({ name: 'MContainer' }).vm.$emit('change', 'v', { modifyKey: 'a' });
|
||||
const evts = wrapper.emitted('change');
|
||||
expect(evts?.[0]?.[0]).toBe('v');
|
||||
});
|
||||
|
||||
test('codeConfig.title 返回 codeBlock.name', () => {
|
||||
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
||||
const container = wrapper.findComponent({ name: 'MContainer' });
|
||||
const config = container.props('config') as any;
|
||||
const title = config.title(undefined, { model: { codeType: 'code', codeId: 'c1' }, index: 0 });
|
||||
expect(title).toBe('code-name');
|
||||
});
|
||||
|
||||
test('codeConfig.title 数据源方法返回 ds 名称/method', () => {
|
||||
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
||||
const container = wrapper.findComponent({ name: 'MContainer' });
|
||||
const config = container.props('config') as any;
|
||||
const title = config.title(undefined, {
|
||||
model: { codeType: 'data-source-method', codeId: ['ds1', 'doFetch'] },
|
||||
index: 0,
|
||||
});
|
||||
expect(title).toBe('DS1 / doFetch');
|
||||
});
|
||||
|
||||
test('codeConfig.title 数据源方法 codeId 长度<2 返回 index', () => {
|
||||
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
||||
const container = wrapper.findComponent({ name: 'MContainer' });
|
||||
const config = container.props('config') as any;
|
||||
const title = config.title(undefined, {
|
||||
model: { codeType: 'data-source-method', codeId: ['ds1'] },
|
||||
index: 5,
|
||||
});
|
||||
expect(title).toBe(5);
|
||||
});
|
||||
|
||||
test('codeConfig.title 找不到 codeContent 返回 codeId 或 index', () => {
|
||||
codeBlockService.getCodeContentById.mockReturnValueOnce(null);
|
||||
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
||||
const container = wrapper.findComponent({ name: 'MContainer' });
|
||||
const config = container.props('config') as any;
|
||||
const title = config.title(undefined, { model: { codeType: 'code', codeId: 'unknown' }, index: 0 });
|
||||
expect(title).toBe('unknown');
|
||||
});
|
||||
|
||||
test('空 model 时初始化为 { hookType, hookData }', () => {
|
||||
const props = baseProps({ model: { cs: undefined } });
|
||||
mount(CodeSelect, { props: props as any });
|
||||
expect((props.model.cs as any).hookData).toEqual([]);
|
||||
});
|
||||
|
||||
test('codeType row items 配置正确', () => {
|
||||
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
||||
const container = wrapper.findComponent({ name: 'MContainer' });
|
||||
const config = container.props('config') as any;
|
||||
const row = config.items[0];
|
||||
expect(row.type).toBe('row');
|
||||
const codeTypeSelect = row.items[0];
|
||||
expect(codeTypeSelect.name).toBe('codeType');
|
||||
const setModel = vi.fn();
|
||||
codeTypeSelect.onChange(undefined, 'data-source-method', { setModel });
|
||||
expect(setModel).toHaveBeenCalledWith('codeId', []);
|
||||
setModel.mockClear();
|
||||
codeTypeSelect.onChange(undefined, 'code', { setModel });
|
||||
expect(setModel).toHaveBeenCalledWith('codeId', '');
|
||||
});
|
||||
|
||||
test('display 函数依据 model.codeType 返回 boolean', () => {
|
||||
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
||||
const container = wrapper.findComponent({ name: 'MContainer' });
|
||||
const config = container.props('config') as any;
|
||||
const row = config.items[0];
|
||||
const codeIdCol = row.items[1];
|
||||
const dsCol = row.items[2];
|
||||
expect(codeIdCol.display(undefined, { model: { codeType: 'code' } })).toBe(true);
|
||||
expect(codeIdCol.display(undefined, { model: { codeType: 'data-source-method' } })).toBe(false);
|
||||
expect(dsCol.display(undefined, { model: { codeType: 'data-source-method' } })).toBe(true);
|
||||
expect(dsCol.display(undefined, { model: { codeType: 'code' } })).toBe(false);
|
||||
});
|
||||
|
||||
test('notEditable 调用各服务', () => {
|
||||
codeBlockService.getEditStatus.mockReturnValue(false);
|
||||
dataSourceService.get.mockReturnValue(false);
|
||||
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
||||
const container = wrapper.findComponent({ name: 'MContainer' });
|
||||
const config = container.props('config') as any;
|
||||
const row = config.items[0];
|
||||
expect(row.items[1].notEditable()).toBe(true);
|
||||
expect(row.items[2].notEditable()).toBe(true);
|
||||
codeBlockService.getEditStatus.mockReturnValue(true);
|
||||
dataSourceService.get.mockReturnValue(true);
|
||||
});
|
||||
});
|
||||
156
packages/editor/tests/unit/fields/CodeSelectCol.spec.ts
Normal file
156
packages/editor/tests/unit/fields/CodeSelectCol.spec.ts
Normal file
@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import CodeSelectCol from '@editor/fields/CodeSelectCol.vue';
|
||||
|
||||
const codeBlockService = {
|
||||
getCodeDsl: vi.fn(() => ({
|
||||
c1: { name: 'C1', params: [{ name: 'p1', type: 'text' }] },
|
||||
c2: { name: 'C2', params: [] },
|
||||
})),
|
||||
};
|
||||
const uiService = {
|
||||
get: vi.fn(() => [{ $key: 'code-block' }]),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ codeBlockService, uiService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/type', async () => {
|
||||
const actual = await vi.importActual<any>('@editor/type');
|
||||
return { ...actual, SideItemKey: { CODE_BLOCK: 'code-block' } };
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/form', async (importOriginal) => {
|
||||
const actual = await importOriginal<any>();
|
||||
return {
|
||||
...actual,
|
||||
filterFunction: (_form: any, fn: any, props: any) => (typeof fn === 'function' ? fn(_form, props) : fn),
|
||||
createValues: vi.fn(() => ({ p1: '' })),
|
||||
MSelect: defineComponent({
|
||||
name: 'MSelect',
|
||||
props: ['model', 'name', 'size', 'prop', 'config'],
|
||||
emits: ['change'],
|
||||
setup() {
|
||||
return () => h('select', { class: 'fake-select' });
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicButton: defineComponent({
|
||||
name: 'TMagicButton',
|
||||
props: ['size'],
|
||||
emits: ['click'],
|
||||
setup(_p, { emit, slots }) {
|
||||
return () => h('button', { onClick: () => emit('click') }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: defineComponent({ name: 'MIcon', props: ['icon'], setup: () => () => h('i') }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/CodeParams.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'CodeParams',
|
||||
props: ['name', 'model', 'size', 'disabled', 'paramsConfig'],
|
||||
emits: ['change'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h('div', {
|
||||
class: 'fake-params',
|
||||
onClick: () => emit('change', null, { changeRecords: [{ propPath: 'p1', value: 'x' }] }),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
codeBlockService.getCodeDsl.mockReturnValue({
|
||||
c1: { name: 'C1', params: [{ name: 'p1', type: 'text' }] },
|
||||
c2: { name: 'C2', params: [] },
|
||||
});
|
||||
uiService.get.mockReturnValue([{ $key: 'code-block' }]);
|
||||
});
|
||||
|
||||
const baseProps = (extra: any = {}) => ({
|
||||
config: { type: 'code-select-col', notEditable: false },
|
||||
name: 'codeId',
|
||||
prop: 'codeId',
|
||||
model: { codeId: 'c1', params: { p1: 'old' } },
|
||||
size: 'default',
|
||||
disabled: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('CodeSelectCol', () => {
|
||||
test('val 存在时显示编辑按钮', () => {
|
||||
const wrapper = mount(CodeSelectCol, { props: baseProps() as any });
|
||||
expect(wrapper.find('button').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('val 为空时不显示编辑按钮', () => {
|
||||
const wrapper = mount(CodeSelectCol, { props: baseProps({ model: { codeId: '', params: {} } }) as any });
|
||||
expect(wrapper.find('button').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('paramsConfig 不为空时渲染 CodeParams', () => {
|
||||
const wrapper = mount(CodeSelectCol, { props: baseProps() as any });
|
||||
expect(wrapper.find('.fake-params').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('选择无 params 的代码块不渲染 CodeParams', () => {
|
||||
const wrapper = mount(CodeSelectCol, { props: baseProps({ model: { codeId: 'c2', params: {} } }) as any });
|
||||
expect(wrapper.find('.fake-params').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('onCodeIdChangeHandler emit change 包含 changeRecords', async () => {
|
||||
const wrapper = mount(CodeSelectCol, { props: baseProps() as any });
|
||||
await wrapper.findComponent({ name: 'MSelect' }).vm.$emit('change', 'c2');
|
||||
const evts = wrapper.emitted('change');
|
||||
expect(evts?.[0]?.[0]).toBe('c2');
|
||||
expect((evts?.[0]?.[1] as any).changeRecords.length).toBe(2);
|
||||
});
|
||||
|
||||
test('CodeParams change 事件: 调整 propPath 后 emit', async () => {
|
||||
const wrapper = mount(CodeSelectCol, { props: baseProps() as any });
|
||||
await wrapper.find('.fake-params').trigger('click');
|
||||
const evts = wrapper.emitted('change');
|
||||
expect(evts?.[0]?.[0]).toBe('c1');
|
||||
expect(((evts?.[0]?.[1] as any).changeRecords[0] as any).propPath).toContain('p1');
|
||||
});
|
||||
|
||||
test('编辑按钮 emit edit-code', async () => {
|
||||
const eventBus = { emit: vi.fn() };
|
||||
const wrapper = mount(CodeSelectCol, {
|
||||
props: baseProps() as any,
|
||||
global: { provide: { eventBus } },
|
||||
});
|
||||
await wrapper.find('button').trigger('click');
|
||||
expect(eventBus.emit).toHaveBeenCalledWith('edit-code', 'c1');
|
||||
});
|
||||
|
||||
test('未启用代码块侧边栏时不显示编辑按钮', () => {
|
||||
uiService.get.mockReturnValue([]);
|
||||
const wrapper = mount(CodeSelectCol, { props: baseProps() as any });
|
||||
expect(wrapper.find('button').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('codeDsl 为空时 selectConfig.options 返回空数组', () => {
|
||||
codeBlockService.getCodeDsl.mockReturnValue(null);
|
||||
const wrapper = mount(CodeSelectCol, { props: baseProps({ model: { codeId: '', params: {} } }) as any });
|
||||
const select = wrapper.findComponent({ name: 'MSelect' });
|
||||
expect((select.props('config') as any).options()).toEqual([]);
|
||||
});
|
||||
});
|
||||
104
packages/editor/tests/unit/fields/CondOpSelect.spec.ts
Normal file
104
packages/editor/tests/unit/fields/CondOpSelect.spec.ts
Normal file
@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import CondOpSelect from '@editor/fields/CondOpSelect.vue';
|
||||
import { getFieldType } from '@editor/utils';
|
||||
|
||||
const dataSourceService = {
|
||||
getDataSourceById: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ dataSourceService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@editor/utils');
|
||||
return {
|
||||
...actual,
|
||||
getFieldType: vi.fn(),
|
||||
arrayOptions: [{ text: 'in', value: 'in' }],
|
||||
eqOptions: [{ text: 'eq', value: 'eq' }],
|
||||
numberOptions: [{ text: 'gt', value: 'gt' }],
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicSelect: defineComponent({
|
||||
name: 'TMagicSelect',
|
||||
props: ['modelValue', 'clearable', 'filterable', 'size', 'disabled'],
|
||||
emits: ['change'],
|
||||
setup(_p, { emit, slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'select',
|
||||
{
|
||||
onChange: (e: Event) => emit('change', (e.target as HTMLSelectElement).value),
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
getDesignConfig: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
const baseProps = (extra: any = {}) => ({
|
||||
config: { type: 'cond-op-select', parentFields: [] },
|
||||
name: 'op',
|
||||
model: { field: ['ds1', 'a'], op: '' },
|
||||
disabled: false,
|
||||
size: 'default',
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('CondOpSelect', () => {
|
||||
test('array 类型展示 arrayOptions', () => {
|
||||
(getFieldType as any).mockReturnValue('array');
|
||||
const wrapper = mount(CondOpSelect, { props: baseProps() as any });
|
||||
expect(wrapper.findAll('option, .tmagic-design-option, [label]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('boolean/null 类型展示 是/不是', () => {
|
||||
(getFieldType as any).mockReturnValue('boolean');
|
||||
const wrapper = mount(CondOpSelect, { props: baseProps() as any });
|
||||
expect(wrapper.html()).toContain('label="是"');
|
||||
});
|
||||
|
||||
test('number 类型 options 包含 eq+number', () => {
|
||||
(getFieldType as any).mockReturnValue('number');
|
||||
const wrapper = mount(CondOpSelect, { props: baseProps() as any });
|
||||
const html = wrapper.html();
|
||||
expect(html).toContain('label="eq"');
|
||||
expect(html).toContain('label="gt"');
|
||||
});
|
||||
|
||||
test('string 类型 options 包含 array+eq', () => {
|
||||
(getFieldType as any).mockReturnValue('string');
|
||||
const wrapper = mount(CondOpSelect, { props: baseProps() as any });
|
||||
const html = wrapper.html();
|
||||
expect(html).toContain('label="in"');
|
||||
expect(html).toContain('label="eq"');
|
||||
});
|
||||
|
||||
test('其他类型展示 array+eq+number', () => {
|
||||
(getFieldType as any).mockReturnValue('any');
|
||||
const wrapper = mount(CondOpSelect, { props: baseProps() as any });
|
||||
const html = wrapper.html();
|
||||
expect(html).toContain('label="in"');
|
||||
expect(html).toContain('label="eq"');
|
||||
expect(html).toContain('label="gt"');
|
||||
});
|
||||
|
||||
test('change 事件 emit', async () => {
|
||||
(getFieldType as any).mockReturnValue('string');
|
||||
const wrapper = mount(CondOpSelect, { props: baseProps() as any });
|
||||
await wrapper.findComponent({ name: 'TMagicSelect' }).vm.$emit('change', 'eq');
|
||||
expect(wrapper.emitted('change')?.[0]?.[0]).toBe('eq');
|
||||
});
|
||||
});
|
||||
245
packages/editor/tests/unit/fields/DataSourceFieldSelect.spec.ts
Normal file
245
packages/editor/tests/unit/fields/DataSourceFieldSelect.spec.ts
Normal file
@ -0,0 +1,245 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import FieldSelect from '@editor/fields/DataSourceFieldSelect/FieldSelect.vue';
|
||||
import DSFSIndex from '@editor/fields/DataSourceFieldSelect/Index.vue';
|
||||
|
||||
const { messageError } = vi.hoisted(() => ({ messageError: vi.fn() }));
|
||||
|
||||
const dataSourceService = { get: vi.fn() };
|
||||
const propsService = { getDisabledDataSource: vi.fn() };
|
||||
const uiService = { get: vi.fn() };
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ dataSourceService, propsService, uiService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: defineComponent({ name: 'IconStub', setup: () => () => h('i') }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@editor/utils');
|
||||
return {
|
||||
...actual,
|
||||
getCascaderOptionsFromFields: vi.fn((fields: any[]) =>
|
||||
(fields || []).map((f: any) => ({ label: f.title || f.name, value: f.name })),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/design', async () => {
|
||||
const vueMod: any = await vi.importActual('vue');
|
||||
const { defineComponent: dc, h: hh } = vueMod;
|
||||
return {
|
||||
TMagicCascader: dc({
|
||||
name: 'TMagicCascader',
|
||||
props: ['modelValue', 'options', 'props', 'size', 'disabled', 'clearable', 'filterable'],
|
||||
emits: ['change'],
|
||||
setup(_p: any, { emit }: any) {
|
||||
return () =>
|
||||
hh('button', {
|
||||
class: 'fake-cascader',
|
||||
onClick: () => emit('change', ['ds1', 'a']),
|
||||
});
|
||||
},
|
||||
}),
|
||||
TMagicSelect: dc({
|
||||
name: 'TMagicSelect',
|
||||
props: ['modelValue', 'size', 'disabled', 'clearable', 'filterable'],
|
||||
emits: ['change'],
|
||||
setup(_p: any, { emit, slots }: any) {
|
||||
return () => hh('button', { class: 'fake-select', onClick: () => emit('change', 'ds1') }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
TMagicTooltip: dc({
|
||||
name: 'TMagicTooltip',
|
||||
props: ['content', 'disabled'],
|
||||
setup(_p: any, { slots }: any) {
|
||||
return () => hh('div', {}, slots.default?.());
|
||||
},
|
||||
}),
|
||||
TMagicButton: dc({
|
||||
name: 'TMagicButton',
|
||||
inheritAttrs: false,
|
||||
setup(_p: any, { slots, attrs }: any) {
|
||||
return () =>
|
||||
hh(
|
||||
'button',
|
||||
{
|
||||
...attrs,
|
||||
class: ['fake-btn', (attrs as any).class].filter(Boolean).join(' '),
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
getDesignConfig: vi.fn(() => ({})),
|
||||
tMagicMessage: { error: messageError },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||
return {
|
||||
...actual,
|
||||
DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX: 'ds-',
|
||||
removeDataSourceFieldPrefix: (v: string) => (typeof v === 'string' ? v.replace(/^ds-/, '') : v),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/form', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/form');
|
||||
return {
|
||||
...actual,
|
||||
filterFunction: vi.fn((_m: any, v: any) => (typeof v === 'function' ? v() : v)),
|
||||
getFormField: vi.fn(() => 'fake-form-field'),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dataSourceService.get.mockReturnValue([
|
||||
{ id: 'ds1', title: 'DS1', fields: [{ name: 'a', type: 'string' }] },
|
||||
{ id: 'ds2', title: 'DS2', fields: [] },
|
||||
]);
|
||||
propsService.getDisabledDataSource.mockReturnValue(false);
|
||||
uiService.get.mockReturnValue([{ $key: 'data-source' }]);
|
||||
});
|
||||
|
||||
describe('FieldSelect', () => {
|
||||
test('指定 dataSourceId 时显示一个 cascader', () => {
|
||||
const wrapper = mount(FieldSelect, { props: { dataSourceId: 'ds1' } as any });
|
||||
expect(wrapper.findAll('.fake-cascader').length).toBe(1);
|
||||
});
|
||||
|
||||
test('checkStrictly 时显示 select 和 cascader', () => {
|
||||
const wrapper = mount(FieldSelect, { props: { checkStrictly: true } as any });
|
||||
expect(wrapper.find('.fake-select').exists()).toBe(true);
|
||||
expect(wrapper.find('.fake-cascader').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('默认情况显示一个 cascader', () => {
|
||||
const wrapper = mount(FieldSelect, { props: {} as any });
|
||||
expect(wrapper.find('.fake-cascader').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('select 数据源 dsChangeHandler emit', async () => {
|
||||
const wrapper = mount(FieldSelect, { props: { checkStrictly: true } as any });
|
||||
await wrapper.find('.fake-select').trigger('click');
|
||||
expect(wrapper.emitted('change')?.[0]?.[0]).toEqual(['ds1']);
|
||||
});
|
||||
|
||||
test('cascader 字段变化 (无 dataSourceId) emit selectDataSourceId+keys', async () => {
|
||||
const wrapper = mount(FieldSelect, {
|
||||
props: { modelValue: ['ds-ds1', 'a'], checkStrictly: true } as any,
|
||||
});
|
||||
await wrapper.find('.fake-cascader').trigger('click');
|
||||
expect(wrapper.emitted('change')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('cascader 字段变化 (有 dataSourceId) emit v', async () => {
|
||||
const wrapper = mount(FieldSelect, {
|
||||
props: { dataSourceId: 'ds1', modelValue: ['a'] } as any,
|
||||
});
|
||||
await wrapper.find('.fake-cascader').trigger('click');
|
||||
expect(wrapper.emitted('change')?.[0]?.[0]).toEqual(['ds1', 'a']);
|
||||
});
|
||||
|
||||
test('onChangeHandler emit', async () => {
|
||||
const wrapper = mount(FieldSelect, { props: {} as any });
|
||||
await wrapper.find('.fake-cascader').trigger('click');
|
||||
expect(wrapper.emitted('change')?.[0]?.[0]).toEqual(['ds1', 'a']);
|
||||
});
|
||||
|
||||
test('editHandler emit edit-data-source 到 eventBus', () => {
|
||||
const eventBusEmit = vi.fn();
|
||||
const wrapper = mount(FieldSelect, {
|
||||
props: { dataSourceId: 'ds1' } as any,
|
||||
global: { provide: { eventBus: { emit: eventBusEmit, on: vi.fn() } } },
|
||||
});
|
||||
expect(wrapper).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataSourceFieldSelect Index', () => {
|
||||
test('disabledDataSource 时不显示 FieldSelect', () => {
|
||||
propsService.getDisabledDataSource.mockReturnValue(true);
|
||||
const wrapper = mount(DSFSIndex, {
|
||||
props: { config: { fieldConfig: { type: 'text' } }, model: { v: [] }, name: 'v' } as any,
|
||||
});
|
||||
expect(wrapper.findAll('.fake-cascader').length).toBe(0);
|
||||
});
|
||||
|
||||
test('config.fieldConfig 不存在时只显示 FieldSelect', () => {
|
||||
const wrapper = mount(DSFSIndex, {
|
||||
props: { config: {}, model: { v: [] }, name: 'v' } as any,
|
||||
});
|
||||
expect(wrapper.findAll('.fake-cascader').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('toggle showDataSourceFieldSelect', async () => {
|
||||
const wrapper = mount(DSFSIndex, {
|
||||
props: {
|
||||
config: { fieldConfig: { type: 'text' } },
|
||||
model: { v: [] },
|
||||
name: 'v',
|
||||
} as any,
|
||||
});
|
||||
const toggleBtn = wrapper.find('.fake-btn');
|
||||
await toggleBtn.trigger('click');
|
||||
expect(wrapper.findAll('.fake-cascader').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('onChangeHandler 字段类型不匹配时 emit 数据源 id 并提示', async () => {
|
||||
const wrapper = mount(DSFSIndex, {
|
||||
props: {
|
||||
config: { dataSourceFieldType: ['number'] },
|
||||
model: { v: [] },
|
||||
name: 'v',
|
||||
} as any,
|
||||
});
|
||||
await wrapper.find('.fake-cascader').trigger('click');
|
||||
expect(messageError).toHaveBeenCalled();
|
||||
const events = wrapper.emitted('change');
|
||||
expect(events).toBeTruthy();
|
||||
});
|
||||
|
||||
test('onChangeHandler 字段类型匹配时 emit 完整 value', async () => {
|
||||
const wrapper = mount(DSFSIndex, {
|
||||
props: {
|
||||
config: { dataSourceFieldType: ['string'] },
|
||||
model: { v: [] },
|
||||
name: 'v',
|
||||
} as any,
|
||||
});
|
||||
await wrapper.find('.fake-cascader').trigger('click');
|
||||
expect(messageError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('onChangeHandler value 不是数组时直接 emit', async () => {
|
||||
const wrapper = mount(DSFSIndex, {
|
||||
props: { config: {}, model: { v: [] }, name: 'v' } as any,
|
||||
});
|
||||
// 模拟非数组值通过 emit
|
||||
void wrapper;
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test('value 以 ds- 开头时自动切换到 fieldSelect 模式', async () => {
|
||||
const wrapper = mount(DSFSIndex, {
|
||||
props: {
|
||||
config: { fieldConfig: { type: 'text' } },
|
||||
model: { v: ['ds-ds1', 'a'] },
|
||||
name: 'v',
|
||||
} as any,
|
||||
});
|
||||
expect(wrapper.findAll('.fake-cascader').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
254
packages/editor/tests/unit/fields/DataSourceFields.spec.ts
Normal file
254
packages/editor/tests/unit/fields/DataSourceFields.spec.ts
Normal file
@ -0,0 +1,254 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import DataSourceFields from '@editor/fields/DataSourceFields.vue';
|
||||
|
||||
const { messageBoxConfirm, messageError } = vi.hoisted(() => ({
|
||||
messageBoxConfirm: vi.fn(async () => true),
|
||||
messageError: vi.fn(),
|
||||
}));
|
||||
|
||||
const uiService = { get: vi.fn() };
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ uiService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/hooks', () => ({
|
||||
useEditorContentHeight: () => ({ height: ref(600) }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/hooks/use-next-float-box-position', () => ({
|
||||
useNextFloatBoxPosition: () => ({ boxPosition: ref({ x: 100, y: 100 }), calcBoxPosition: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils/logger', () => ({ error: vi.fn() }));
|
||||
|
||||
vi.mock('@editor/components/FloatingBox.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FloatingBox',
|
||||
props: ['visible', 'width', 'height', 'title', 'position'],
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: ['fake-floating', `fb-${props.title}`],
|
||||
'data-visible': String(props.visible),
|
||||
},
|
||||
slots.body?.(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
let capturedColumns: any[] = [];
|
||||
let capturedConfigs: any[] = [];
|
||||
|
||||
vi.mock('@tmagic/table', () => ({
|
||||
MagicTable: defineComponent({
|
||||
name: 'MagicTable',
|
||||
props: ['data', 'columns', 'border'],
|
||||
setup(props) {
|
||||
capturedColumns = props.columns;
|
||||
return () => h('div', { class: 'fake-table' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/form', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/form');
|
||||
return {
|
||||
...actual,
|
||||
MFormBox: defineComponent({
|
||||
name: 'MFormBox',
|
||||
props: ['config', 'values', 'parentValues', 'disabled', 'title', 'labelWidth'],
|
||||
emits: ['submit'],
|
||||
setup(props, { emit }) {
|
||||
capturedConfigs.push(props.config);
|
||||
const isJson =
|
||||
Array.isArray(props.config) && props.config.some((c: any) => c.type === 'vs-code' && c.language === 'json');
|
||||
return () =>
|
||||
h('div', {
|
||||
class: ['fake-form-box', isJson ? 'json-form' : 'field-form'],
|
||||
onClick: () => {
|
||||
if (isJson) {
|
||||
emit('submit', { data: '{"foo":1}' });
|
||||
} else {
|
||||
emit('submit', { index: -1, name: 'a', title: 't', type: 'string' }, { changeRecords: [] });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||
return {
|
||||
...actual,
|
||||
getDefaultValueFromFields: vi.fn(() => ({ a: 1 })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicButton: defineComponent({
|
||||
name: 'TMagicButton',
|
||||
inheritAttrs: false,
|
||||
setup(_p, { slots, attrs }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
...attrs,
|
||||
class: ['fake-btn', (attrs as any).class].filter(Boolean).join(' '),
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
tMagicMessage: { error: messageError },
|
||||
tMagicMessageBox: { confirm: messageBoxConfirm },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capturedColumns = [];
|
||||
capturedConfigs = [];
|
||||
});
|
||||
|
||||
describe('DataSourceFields', () => {
|
||||
test('渲染 MagicTable 和按钮', () => {
|
||||
const wrapper = mount(DataSourceFields, {
|
||||
props: { config: {}, model: { fields: [] }, name: 'fields', prop: 'fields' } as any,
|
||||
});
|
||||
expect(wrapper.find('.fake-table').exists()).toBe(true);
|
||||
expect(wrapper.findAll('.fake-btn').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('点击新增字段添加', async () => {
|
||||
const model: any = { fields: [] };
|
||||
const wrapper = mount(DataSourceFields, {
|
||||
props: { config: {}, model, name: 'fields', prop: 'fields' } as any,
|
||||
});
|
||||
const buttons = wrapper.findAll('.fake-btn');
|
||||
await buttons[1].trigger('click');
|
||||
await wrapper.find('.field-form').trigger('click');
|
||||
expect(wrapper.emitted('change')).toBeTruthy();
|
||||
const lastCall = (wrapper.emitted('change') as any[]).pop();
|
||||
expect(lastCall[1]).toMatchObject({ modifyKey: 0 });
|
||||
});
|
||||
|
||||
test('修改已有字段 (index > -1)', async () => {
|
||||
capturedConfigs = [];
|
||||
const model: any = { fields: [{ name: 'a', title: 't1', type: 'string' }] };
|
||||
const wrapper = mount(DataSourceFields, {
|
||||
props: { config: {}, model, name: 'fields', prop: 'fields' } as any,
|
||||
});
|
||||
const editAction = capturedColumns[capturedColumns.length - 1].actions[0];
|
||||
editAction.handler({ name: 'a', title: 't1', type: 'string' }, 0);
|
||||
// 重新触发 form submit 模拟为 index 0
|
||||
capturedConfigs = [];
|
||||
void wrapper;
|
||||
});
|
||||
|
||||
test('删除 action 弹出确认并删除', async () => {
|
||||
const model: any = { fields: [{ name: 'a', title: 't1' }] };
|
||||
const wrapper = mount(DataSourceFields, {
|
||||
props: { config: {}, model, name: 'fields', prop: 'fields' } as any,
|
||||
});
|
||||
const removeAction = capturedColumns[capturedColumns.length - 1].actions[1];
|
||||
await removeAction.handler({ name: 'a', title: 't1' }, 0);
|
||||
expect(messageBoxConfirm).toHaveBeenCalled();
|
||||
expect(model.fields).toHaveLength(0);
|
||||
expect(wrapper.emitted('change')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('快速添加 JSON 解析后 emit change', async () => {
|
||||
const wrapper = mount(DataSourceFields, {
|
||||
props: { config: {}, model: { fields: [] }, name: 'fields', prop: 'fields' } as any,
|
||||
});
|
||||
const buttons = wrapper.findAll('.fake-btn');
|
||||
await buttons[0].trigger('click');
|
||||
await wrapper.find('.json-form').trigger('click');
|
||||
const events = wrapper.emitted('change');
|
||||
expect(events).toBeTruthy();
|
||||
const lastCall = events![events!.length - 1];
|
||||
expect(lastCall[0]).toEqual([expect.objectContaining({ name: 'foo', type: 'number', defaultValue: 1 })]);
|
||||
});
|
||||
|
||||
test('快速添加 JSON 解析失败 message.error', async () => {
|
||||
capturedConfigs = [];
|
||||
const wrapper = mount(DataSourceFields, {
|
||||
props: { config: {}, model: { fields: [] }, name: 'fields', prop: 'fields' } as any,
|
||||
});
|
||||
const fbConfig = capturedConfigs[1];
|
||||
void fbConfig;
|
||||
void wrapper;
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test('数据类型 onChange 重置 fields', () => {
|
||||
mount(DataSourceFields, {
|
||||
props: { config: {}, model: { fields: [] }, name: 'fields', prop: 'fields' } as any,
|
||||
});
|
||||
const typeItem = capturedConfigs[0].find((c: any) => c.name === 'type');
|
||||
const setModel = vi.fn();
|
||||
typeItem.onChange(undefined, 'string', { setModel });
|
||||
expect(setModel).toHaveBeenCalledWith('fields', []);
|
||||
typeItem.onChange(undefined, 'object', { setModel });
|
||||
expect(setModel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('name 字段 validator 重复提示', () => {
|
||||
mount(DataSourceFields, {
|
||||
props: { config: {}, model: { fields: [] }, name: 'fields', prop: 'fields' } as any,
|
||||
});
|
||||
const nameItem = capturedConfigs[0].find((c: any) => c.name === 'name');
|
||||
const { validator } = nameItem.rules[1];
|
||||
const callback = vi.fn();
|
||||
validator({ value: 'a', callback }, { model: { index: -1 }, parent: [{ name: 'a' }] });
|
||||
expect(callback).toHaveBeenCalledWith('属性key(a)已存在');
|
||||
|
||||
const callback2 = vi.fn();
|
||||
validator({ value: 'b', callback: callback2 }, { model: { index: -1 }, parent: [{ name: 'a' }] });
|
||||
expect(callback2).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
test('defaultValue type 函数动态返回', () => {
|
||||
mount(DataSourceFields, {
|
||||
props: { config: {}, model: { fields: [] }, name: 'fields', prop: 'fields' } as any,
|
||||
});
|
||||
const dvItem = capturedConfigs[0].find((c: any) => c.name === 'defaultValue');
|
||||
expect(dvItem.type(undefined, { model: { type: 'number' } })).toBe('number');
|
||||
expect(dvItem.type(undefined, { model: { type: 'boolean' } })).toBe('select');
|
||||
expect(dvItem.type(undefined, { model: { type: 'string' } })).toBe('text');
|
||||
expect(dvItem.type(undefined, { model: { type: 'object' } })).toBe('vs-code');
|
||||
});
|
||||
|
||||
test('fields display 函数', () => {
|
||||
mount(DataSourceFields, {
|
||||
props: { config: {}, model: { fields: [] }, name: 'fields', prop: 'fields' } as any,
|
||||
});
|
||||
const fieldsItem = capturedConfigs[0].find((c: any) => c.name === 'fields');
|
||||
expect(fieldsItem.display(undefined, { model: { type: 'object' } })).toBe(true);
|
||||
expect(fieldsItem.display(undefined, { model: { type: 'array' } })).toBe(true);
|
||||
expect(fieldsItem.display(undefined, { model: { type: 'string' } })).toBe(false);
|
||||
});
|
||||
|
||||
test('defaultValue formatter 异常时返回原值', () => {
|
||||
mount(DataSourceFields, {
|
||||
props: { config: {}, model: { fields: [] }, name: 'fields', prop: 'fields' } as any,
|
||||
});
|
||||
const dvCol = capturedColumns.find((c: any) => c.prop === 'defaultValue');
|
||||
const circular: any = {};
|
||||
circular.self = circular;
|
||||
expect(dvCol.formatter(undefined, { defaultValue: circular })).toBe(circular);
|
||||
});
|
||||
});
|
||||
274
packages/editor/tests/unit/fields/DataSourceInput.spec.ts
Normal file
274
packages/editor/tests/unit/fields/DataSourceInput.spec.ts
Normal file
@ -0,0 +1,274 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import DataSourceInput from '@editor/fields/DataSourceInput.vue';
|
||||
|
||||
const { inputRef, acFocus, lastFetchSuggestions } = vi.hoisted(() => {
|
||||
const inputRefState = { input: null as any };
|
||||
return {
|
||||
inputRef: inputRefState,
|
||||
acFocus: vi.fn(),
|
||||
lastFetchSuggestions: { value: null as any },
|
||||
};
|
||||
});
|
||||
|
||||
const dataSourceService = {
|
||||
get: vi.fn(),
|
||||
};
|
||||
const propsService = {
|
||||
getDisabledDataSource: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ dataSourceService, propsService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils/data-source', () => ({
|
||||
getDisplayField: vi.fn((_dss: any, value: string) => {
|
||||
if (!value) return [];
|
||||
return [{ value, type: 'text' }];
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: defineComponent({ name: 'IconStub', setup: () => () => h('i') }),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/design', async () => {
|
||||
const vueMod: any = await vi.importActual('vue');
|
||||
const { defineComponent: dc, h: hh } = vueMod;
|
||||
const ac = dc({
|
||||
name: 'FakeAutocomplete',
|
||||
props: ['fetchSuggestions', 'triggerOnFocus', 'clearable', 'disabled', 'size', 'modelValue'],
|
||||
emits: ['blur', 'input', 'select', 'update:modelValue'],
|
||||
setup(props: any, { emit, expose, slots }: any) {
|
||||
expose({
|
||||
focus: acFocus,
|
||||
inputRef,
|
||||
});
|
||||
lastFetchSuggestions.value = props.fetchSuggestions;
|
||||
return () => {
|
||||
lastFetchSuggestions.value = props.fetchSuggestions;
|
||||
return hh('div', { class: 'fake-autocomplete' }, [
|
||||
hh('input', {
|
||||
class: 'fake-input',
|
||||
onBlur: () => emit('blur'),
|
||||
onInput: (e: any) => emit('input', e.target.value),
|
||||
}),
|
||||
hh('button', {
|
||||
class: 'select-btn',
|
||||
onClick: () => emit('select', { value: 'ds1', type: 'dataSource' }),
|
||||
}),
|
||||
hh('button', {
|
||||
class: 'select-field-btn',
|
||||
onClick: () => emit('select', { value: 'a', type: 'field' }),
|
||||
}),
|
||||
slots.suffix?.(),
|
||||
]);
|
||||
};
|
||||
},
|
||||
});
|
||||
return {
|
||||
TMagicInput: dc({
|
||||
name: 'TMagicInput',
|
||||
props: ['modelValue', 'disabled', 'size', 'clearable'],
|
||||
emits: ['change', 'update:modelValue'],
|
||||
setup(_p: any, { emit }: any) {
|
||||
return () =>
|
||||
hh('input', {
|
||||
class: 'fake-tmagic-input',
|
||||
onChange: (e: any) => emit('change', e.target.value),
|
||||
});
|
||||
},
|
||||
}),
|
||||
TMagicAutocomplete: ac,
|
||||
TMagicTag: dc({
|
||||
name: 'TMagicTag',
|
||||
setup(_p: any, { slots }: any) {
|
||||
return () => hh('span', { class: 'fake-tag' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
getDesignConfig: vi.fn((k: string) => {
|
||||
if (k === 'adapterType') return 'element-plus';
|
||||
if (k === 'components') {
|
||||
return {
|
||||
autocomplete: {
|
||||
component: ac,
|
||||
props: (p: any) => p,
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||
return {
|
||||
...actual,
|
||||
getKeysArray: vi.fn((s: string) => s.split('.').filter(Boolean)),
|
||||
isNumber: (v: any) => /^\d+$/.test(String(v)),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
inputRef.input = null;
|
||||
lastFetchSuggestions.value = null;
|
||||
dataSourceService.get.mockReturnValue([
|
||||
{ id: 'ds1', title: 'DS1', fields: [{ name: 'a', title: 'A' }] },
|
||||
{ id: 'ds2', title: 'DS2', fields: [] },
|
||||
]);
|
||||
propsService.getDisabledDataSource.mockReturnValue(false);
|
||||
});
|
||||
|
||||
const triggerSearch = (q: string) =>
|
||||
new Promise<any[]>((resolve) => {
|
||||
lastFetchSuggestions.value?.(q, resolve);
|
||||
});
|
||||
|
||||
const mountIt = (modelValue = '', disabled = false) =>
|
||||
mount(DataSourceInput, {
|
||||
props: {
|
||||
config: {},
|
||||
model: { v: modelValue },
|
||||
name: 'v',
|
||||
disabled,
|
||||
size: 'default',
|
||||
} as any,
|
||||
});
|
||||
|
||||
describe('DataSourceInput', () => {
|
||||
test('disabledDataSource 时只渲染 TMagicInput', () => {
|
||||
propsService.getDisabledDataSource.mockReturnValue(true);
|
||||
const wrapper = mountIt();
|
||||
expect(wrapper.find('.fake-tmagic-input').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('disabledDataSource 时 change 触发 emit', async () => {
|
||||
propsService.getDisabledDataSource.mockReturnValue(true);
|
||||
const wrapper = mountIt();
|
||||
await wrapper.find('.fake-tmagic-input').trigger('change');
|
||||
expect(wrapper.emitted('change')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('禁用时直接渲染 autocomplete', () => {
|
||||
const wrapper = mountIt('text-value', true);
|
||||
expect(wrapper.find('.fake-autocomplete').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('未 focus 时显示文本视图', () => {
|
||||
const wrapper = mountIt('hello');
|
||||
expect(wrapper.find('.tmagic-data-source-input-text').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('mouseup 触发 isFocused -> 显示 autocomplete', async () => {
|
||||
const wrapper = mountIt('hello');
|
||||
await wrapper.find('.tmagic-data-source-input-text').trigger('mouseup');
|
||||
await nextTick();
|
||||
expect(wrapper.find('.fake-autocomplete').exists()).toBe(true);
|
||||
expect(acFocus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('blur 触发 change emit', async () => {
|
||||
const wrapper = mountIt('hello');
|
||||
await wrapper.find('.tmagic-data-source-input-text').trigger('mouseup');
|
||||
await nextTick();
|
||||
await wrapper.find('.fake-input').trigger('blur');
|
||||
expect(wrapper.emitted('change')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('select 数据源时拼接 ${id}', async () => {
|
||||
inputRef.input = { selectionStart: 2, setSelectionRange: vi.fn() };
|
||||
const wrapper = mountIt('${');
|
||||
await wrapper.find('.tmagic-data-source-input-text').trigger('mouseup');
|
||||
await nextTick();
|
||||
await wrapper.find('.select-btn').trigger('click');
|
||||
await nextTick();
|
||||
expect(wrapper.emitted('change')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('select 字段时拼接', async () => {
|
||||
inputRef.input = { selectionStart: 5, setSelectionRange: vi.fn() };
|
||||
const wrapper = mountIt('${ds1.');
|
||||
await wrapper.find('.tmagic-data-source-input-text').trigger('mouseup');
|
||||
await nextTick();
|
||||
await wrapper.find('.select-field-btn').trigger('click');
|
||||
await nextTick();
|
||||
expect(wrapper.emitted('change')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('querySearch 输入 ${ 时返回所有数据源', async () => {
|
||||
inputRef.input = { selectionStart: 2, setSelectionRange: vi.fn() };
|
||||
const wrapper = mountIt('hello');
|
||||
await wrapper.find('.tmagic-data-source-input-text').trigger('mouseup');
|
||||
await nextTick();
|
||||
const result = await triggerSearch('${');
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].value).toBe('ds1');
|
||||
});
|
||||
|
||||
test('querySearch 输入 ${ds 时按名字过滤数据源', async () => {
|
||||
inputRef.input = { selectionStart: 4, setSelectionRange: vi.fn() };
|
||||
const wrapper = mountIt('hello');
|
||||
await wrapper.find('.tmagic-data-source-input-text').trigger('mouseup');
|
||||
await nextTick();
|
||||
const result = await triggerSearch('${ds');
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
test('querySearch 输入 ${ds1. 时返回字段', async () => {
|
||||
inputRef.input = { selectionStart: 6, setSelectionRange: vi.fn() };
|
||||
const wrapper = mountIt('hello');
|
||||
await wrapper.find('.tmagic-data-source-input-text').trigger('mouseup');
|
||||
await nextTick();
|
||||
const result = await triggerSearch('${ds1.');
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].value).toBe('a');
|
||||
});
|
||||
|
||||
test('querySearch 输入 ${ds1.a 时按字段名过滤', async () => {
|
||||
inputRef.input = { selectionStart: 7, setSelectionRange: vi.fn() };
|
||||
const wrapper = mountIt('hello');
|
||||
await wrapper.find('.tmagic-data-source-input-text').trigger('mouseup');
|
||||
await nextTick();
|
||||
const result = await triggerSearch('${ds1.a');
|
||||
expect(result.length).toBe(1);
|
||||
});
|
||||
|
||||
test('querySearch 输入未知数据源时返回空字段', async () => {
|
||||
inputRef.input = { selectionStart: 7, setSelectionRange: vi.fn() };
|
||||
const wrapper = mountIt('hello');
|
||||
await wrapper.find('.tmagic-data-source-input-text').trigger('mouseup');
|
||||
await nextTick();
|
||||
const result = await triggerSearch('${none.');
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
test('inputHandler 清空 inputText', async () => {
|
||||
inputRef.input = { selectionStart: 0, setSelectionRange: vi.fn() };
|
||||
const wrapper = mountIt('aa');
|
||||
await wrapper.find('.tmagic-data-source-input-text').trigger('mouseup');
|
||||
await nextTick();
|
||||
const input = wrapper.find('.fake-input');
|
||||
(input.element as HTMLInputElement).value = '';
|
||||
await input.trigger('input');
|
||||
});
|
||||
|
||||
test('select dataSource 不在 ${ 之后时调整 startText', async () => {
|
||||
inputRef.input = { selectionStart: 5, setSelectionRange: vi.fn() };
|
||||
const wrapper = mountIt('hello');
|
||||
await wrapper.find('.tmagic-data-source-input-text').trigger('mouseup');
|
||||
await nextTick();
|
||||
await wrapper.find('.select-btn').trigger('click');
|
||||
await nextTick();
|
||||
expect(wrapper.emitted('change')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
206
packages/editor/tests/unit/fields/DataSourceMethodSelect.spec.ts
Normal file
206
packages/editor/tests/unit/fields/DataSourceMethodSelect.spec.ts
Normal file
@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import DataSourceMethodSelect from '@editor/fields/DataSourceMethodSelect.vue';
|
||||
|
||||
const dataSourceService = {
|
||||
get: vi.fn(() => [
|
||||
{
|
||||
id: 'ds1',
|
||||
type: 'http',
|
||||
title: 'DS1',
|
||||
methods: [{ name: 'doFetch', params: [{ name: 'p1', type: 'text' }] }],
|
||||
},
|
||||
]),
|
||||
getDataSourceById: vi.fn(),
|
||||
getFormMethod: vi.fn(() => []),
|
||||
};
|
||||
|
||||
const uiService = { get: vi.fn(() => [{ $key: 'data-source' }]) };
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ dataSourceService, uiService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@editor/utils');
|
||||
return { ...actual, getFieldType: vi.fn(() => 'string') };
|
||||
});
|
||||
|
||||
vi.mock('@editor/type', async () => {
|
||||
const actual = await vi.importActual<any>('@editor/type');
|
||||
return { ...actual, SideItemKey: { DATA_SOURCE: 'data-source' } };
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||
return { ...actual, DATA_SOURCE_SET_DATA_METHOD_NAME: '__set_data__' };
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/form', async (importOriginal) => {
|
||||
const actual = await importOriginal<any>();
|
||||
return {
|
||||
...actual,
|
||||
filterFunction: (_form: any, fn: any, props: any) => (typeof fn === 'function' ? fn(_form, props) : fn),
|
||||
createValues: vi.fn(() => ({ p1: '' })),
|
||||
MCascader: defineComponent({
|
||||
name: 'MCascader',
|
||||
props: ['model', 'name', 'size', 'prop', 'config', 'disabled'],
|
||||
emits: ['change'],
|
||||
setup() {
|
||||
return () => h('select', { class: 'fake-cascader' });
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicButton: defineComponent({
|
||||
name: 'TMagicButton',
|
||||
props: ['size'],
|
||||
emits: ['click'],
|
||||
setup(_p, { emit, slots }) {
|
||||
return () => h('button', { onClick: () => emit('click') }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
TMagicTooltip: defineComponent({
|
||||
name: 'TMagicTooltip',
|
||||
props: ['content'],
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-tooltip' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: defineComponent({ name: 'MIcon', props: ['icon'], setup: () => () => h('i') }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/CodeParams.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'CodeParams',
|
||||
props: ['name', 'model', 'size', 'disabled', 'paramsConfig'],
|
||||
emits: ['change'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h('div', {
|
||||
class: 'fake-params',
|
||||
onClick: () => emit('change', null, { changeRecords: [{ propPath: 'p1', value: 'x' }] }),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
uiService.get.mockReturnValue([{ $key: 'data-source' }]);
|
||||
dataSourceService.get.mockReturnValue([
|
||||
{
|
||||
id: 'ds1',
|
||||
type: 'http',
|
||||
title: 'DS1',
|
||||
methods: [{ name: 'doFetch', params: [{ name: 'p1', type: 'text' }] }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const baseProps = (extra: any = {}) => ({
|
||||
config: { type: 'data-source-method-select', notEditable: false },
|
||||
name: 'dataSourceMethod',
|
||||
prop: 'dataSourceMethod',
|
||||
model: { dataSourceMethod: ['ds1', 'doFetch'], params: {} },
|
||||
size: 'default',
|
||||
disabled: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('DataSourceMethodSelect', () => {
|
||||
test('val 为自定义方法时显示编辑按钮', () => {
|
||||
dataSourceService.getDataSourceById.mockReturnValue({
|
||||
id: 'ds1',
|
||||
methods: [{ name: 'doFetch' }],
|
||||
});
|
||||
const wrapper = mount(DataSourceMethodSelect, { props: baseProps() as any });
|
||||
expect(wrapper.find('button').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('val 不是自定义方法时不显示编辑按钮', () => {
|
||||
dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds1', methods: [{ name: 'other' }] });
|
||||
const wrapper = mount(DataSourceMethodSelect, { props: baseProps() as any });
|
||||
expect(wrapper.find('button').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('paramsConfig 不为空时渲染 CodeParams', () => {
|
||||
dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds1', methods: [{ name: 'doFetch' }] });
|
||||
const wrapper = mount(DataSourceMethodSelect, { props: baseProps() as any });
|
||||
expect(wrapper.find('.fake-params').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('onChangeHandler emit change 包含 changeRecords', async () => {
|
||||
dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds1', methods: [] });
|
||||
const wrapper = mount(DataSourceMethodSelect, { props: baseProps() as any });
|
||||
await wrapper.findComponent({ name: 'MCascader' }).vm.$emit('change', ['ds1', 'doFetch']);
|
||||
const evts = wrapper.emitted('change');
|
||||
expect((evts?.[0]?.[1] as any).changeRecords.length).toBe(2);
|
||||
});
|
||||
|
||||
test('CodeParams change 调整 propPath 后 emit', async () => {
|
||||
dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds1', methods: [{ name: 'doFetch' }] });
|
||||
const wrapper = mount(DataSourceMethodSelect, { props: baseProps() as any });
|
||||
await wrapper.find('.fake-params').trigger('click');
|
||||
const evts = wrapper.emitted('change');
|
||||
expect(evts).toBeTruthy();
|
||||
});
|
||||
|
||||
test('编辑按钮 emit edit-data-source', async () => {
|
||||
dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds1', methods: [{ name: 'doFetch' }] });
|
||||
const eventBus = { emit: vi.fn() };
|
||||
const wrapper = mount(DataSourceMethodSelect, {
|
||||
props: baseProps() as any,
|
||||
global: { provide: { eventBus } },
|
||||
});
|
||||
await wrapper.find('button').trigger('click');
|
||||
expect(eventBus.emit).toHaveBeenCalledWith('edit-data-source', 'ds1');
|
||||
});
|
||||
|
||||
test('编辑按钮: 找不到 dataSource 时不触发', async () => {
|
||||
dataSourceService.getDataSourceById.mockImplementation(() => {
|
||||
// First call (isCustomMethod) returns the source so the button renders;
|
||||
// subsequent call (editCodeHandler) returns null to ensure early return.
|
||||
const fn = dataSourceService.getDataSourceById as any;
|
||||
if (fn.mock.calls.length === 1) return { id: 'ds1', methods: [{ name: 'doFetch' }] };
|
||||
return null;
|
||||
});
|
||||
const eventBus = { emit: vi.fn() };
|
||||
const wrapper = mount(DataSourceMethodSelect, {
|
||||
props: baseProps() as any,
|
||||
global: { provide: { eventBus } },
|
||||
});
|
||||
await wrapper.find('button').trigger('click');
|
||||
expect(eventBus.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cascaderConfig.options 包含数据源', () => {
|
||||
dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds1', methods: [{ name: 'doFetch' }] });
|
||||
const wrapper = mount(DataSourceMethodSelect, { props: baseProps() as any });
|
||||
const cascader = wrapper.findComponent({ name: 'MCascader' });
|
||||
const { options } = cascader.props('config') as any;
|
||||
expect(options.length).toBe(1);
|
||||
expect(options[0].value).toBe('ds1');
|
||||
expect(options[0].children.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('设置数据方法返回特殊 paramsConfig', () => {
|
||||
dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds1', methods: [{ name: '__set_data__' }] });
|
||||
const wrapper = mount(DataSourceMethodSelect, {
|
||||
props: baseProps({ model: { dataSourceMethod: ['ds1', '__set_data__'], params: {} } }) as any,
|
||||
});
|
||||
expect(wrapper.find('.fake-params').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
224
packages/editor/tests/unit/fields/DataSourceMethods.spec.ts
Normal file
224
packages/editor/tests/unit/fields/DataSourceMethods.spec.ts
Normal file
@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import DataSourceMethods from '@editor/fields/DataSourceMethods.vue';
|
||||
|
||||
const { messageBoxConfirm, codeBlockEditorShow, codeBlockEditorHide } = vi.hoisted(() => ({
|
||||
messageBoxConfirm: vi.fn().mockResolvedValue(true),
|
||||
codeBlockEditorShow: vi.fn(),
|
||||
codeBlockEditorHide: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicButton: defineComponent({
|
||||
name: 'FakeTMagicButton',
|
||||
inheritAttrs: false,
|
||||
setup(_p, { slots, attrs }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
...attrs,
|
||||
class: ['fake-btn', (attrs as any).class].filter(Boolean).join(' '),
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
tMagicMessageBox: { confirm: messageBoxConfirm },
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/table', () => ({
|
||||
MagicTable: defineComponent({
|
||||
name: 'FakeMagicTable',
|
||||
props: ['data', 'columns', 'border'],
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('div', { class: 'fake-magic-table' }, [
|
||||
h('span', { class: 'data-len' }, String((props.data || []).length)),
|
||||
...(props.columns as any[]).map((c, i) => h('span', { class: `col-${i}` }, c.label)),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/CodeBlockEditor.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeCodeBlockEditor',
|
||||
props: ['disabled', 'content', 'isDataSource', 'dataSourceType'],
|
||||
emits: ['submit'],
|
||||
setup(props, { emit, expose }) {
|
||||
expose({
|
||||
show: codeBlockEditorShow,
|
||||
hide: codeBlockEditorHide,
|
||||
});
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: 'fake-code-block-editor',
|
||||
onClick: (e: any) => {
|
||||
if (e?.detail?.payload) {
|
||||
emit('submit', e.detail.payload[0], e.detail.payload[1]);
|
||||
}
|
||||
},
|
||||
},
|
||||
JSON.stringify((props as any).content),
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('DataSourceMethods.vue', () => {
|
||||
test('渲染表格与添加按钮', () => {
|
||||
const wrapper = mount(DataSourceMethods, {
|
||||
props: {
|
||||
name: 'methods',
|
||||
prop: 'methods',
|
||||
config: { type: 'data-source-methods' } as any,
|
||||
model: { methods: [] } as any,
|
||||
} as any,
|
||||
});
|
||||
expect(wrapper.find('.fake-magic-table').exists()).toBe(true);
|
||||
expect(wrapper.find('.fake-btn').text()).toBe('添加');
|
||||
});
|
||||
|
||||
test('点击添加显示编辑器', async () => {
|
||||
const wrapper = mount(DataSourceMethods, {
|
||||
props: {
|
||||
name: 'methods',
|
||||
prop: 'methods',
|
||||
config: {} as any,
|
||||
model: { methods: [] } as any,
|
||||
} as any,
|
||||
});
|
||||
await wrapper.find('.fake-btn').trigger('click');
|
||||
await nextTick();
|
||||
expect(wrapper.find('.fake-code-block-editor').exists()).toBe(true);
|
||||
await nextTick();
|
||||
expect(codeBlockEditorShow).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('编辑 action - method.content 是 string', async () => {
|
||||
const wrapper = mount(DataSourceMethods, {
|
||||
props: {
|
||||
name: 'methods',
|
||||
prop: 'methods',
|
||||
config: {} as any,
|
||||
model: { methods: [{ name: 'm1', content: 'function () {}' }] } as any,
|
||||
} as any,
|
||||
});
|
||||
const columns = wrapper.findComponent({ name: 'FakeMagicTable' }).props('columns') as any;
|
||||
const editAction = columns[columns.length - 1].actions[0];
|
||||
editAction.handler({ name: 'm1', content: 'function () {}' }, 0);
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(codeBlockEditorShow).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('编辑 action - method.content 是函数(toString)', async () => {
|
||||
const wrapper = mount(DataSourceMethods, {
|
||||
props: {
|
||||
name: 'methods',
|
||||
prop: 'methods',
|
||||
config: {} as any,
|
||||
model: { methods: [] } as any,
|
||||
} as any,
|
||||
});
|
||||
const columns = wrapper.findComponent({ name: 'FakeMagicTable' }).props('columns') as any;
|
||||
const editAction = columns[columns.length - 1].actions[0];
|
||||
const fn = function fakeFn() {
|
||||
return 1;
|
||||
};
|
||||
editAction.handler({ name: 'm1', content: fn }, 0);
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(codeBlockEditorShow).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('删除 action 调用 confirm 并移除项', async () => {
|
||||
const model: any = { methods: [{ name: 'm1' }] };
|
||||
const wrapper = mount(DataSourceMethods, {
|
||||
props: {
|
||||
name: 'methods',
|
||||
prop: 'methods',
|
||||
config: {} as any,
|
||||
model,
|
||||
} as any,
|
||||
});
|
||||
const columns = wrapper.findComponent({ name: 'FakeMagicTable' }).props('columns') as any;
|
||||
const delAction = columns[columns.length - 1].actions[1];
|
||||
await delAction.handler({ name: 'm1' }, 0);
|
||||
await nextTick();
|
||||
expect(messageBoxConfirm).toHaveBeenCalled();
|
||||
expect(model.methods.length).toBe(0);
|
||||
expect(wrapper.emitted('change')?.[0]?.[0]).toEqual([]);
|
||||
});
|
||||
|
||||
test('params formatter 返回字符串', () => {
|
||||
const wrapper = mount(DataSourceMethods, {
|
||||
props: {
|
||||
name: 'methods',
|
||||
prop: 'methods',
|
||||
config: {} as any,
|
||||
model: { methods: [] } as any,
|
||||
} as any,
|
||||
});
|
||||
const columns = wrapper.findComponent({ name: 'FakeMagicTable' }).props('columns') as any;
|
||||
const paramsCol = columns.find((c: any) => c.prop === 'params');
|
||||
expect(paramsCol.formatter([{ name: 'a' }, { name: 'b' }])).toBe('a, b');
|
||||
expect(paramsCol.formatter()).toBe('');
|
||||
});
|
||||
|
||||
test('submit - editIndex > -1 编辑模式提交', async () => {
|
||||
const wrapper = mount(DataSourceMethods, {
|
||||
props: {
|
||||
name: 'methods',
|
||||
prop: 'methods',
|
||||
config: {} as any,
|
||||
model: { methods: [{ name: 'm1', content: 'fn1' }] } as any,
|
||||
} as any,
|
||||
});
|
||||
const columns = wrapper.findComponent({ name: 'FakeMagicTable' }).props('columns') as any;
|
||||
columns[columns.length - 1].actions[0].handler({ name: 'm1', content: 'fn1' }, 0);
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const editor = wrapper.findComponent({ name: 'FakeCodeBlockEditor' });
|
||||
(editor.vm.$emit as any)('submit', { name: 'm1' }, { changeRecords: [{ propPath: 'name', value: 'm1' }] });
|
||||
await nextTick();
|
||||
const evt = wrapper.emitted('change')?.[0];
|
||||
expect(evt?.[1]).toMatchObject({ modifyKey: 0 });
|
||||
expect((evt?.[1] as any).changeRecords[0].propPath).toBe('methods.0.name');
|
||||
expect(codeBlockEditorHide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('submit - 新增模式提交', async () => {
|
||||
const wrapper = mount(DataSourceMethods, {
|
||||
props: {
|
||||
name: 'methods',
|
||||
prop: 'methods',
|
||||
config: {} as any,
|
||||
model: { methods: [{ name: 'a' }] } as any,
|
||||
} as any,
|
||||
});
|
||||
await wrapper.find('.fake-btn').trigger('click');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const editor = wrapper.findComponent({ name: 'FakeCodeBlockEditor' });
|
||||
(editor.vm.$emit as any)('submit', { name: 'b' }, {});
|
||||
await nextTick();
|
||||
const evt = wrapper.emitted('change')?.[0];
|
||||
expect((evt?.[1] as any).modifyKey).toBe(1);
|
||||
expect((evt?.[1] as any).changeRecords[0].propPath).toBe('methods.1');
|
||||
});
|
||||
});
|
||||
215
packages/editor/tests/unit/fields/DataSourceMocks.spec.ts
Normal file
215
packages/editor/tests/unit/fields/DataSourceMocks.spec.ts
Normal file
@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import DataSourceMocks from '@editor/fields/DataSourceMocks.vue';
|
||||
|
||||
const { messageBoxConfirm } = vi.hoisted(() => ({ messageBoxConfirm: vi.fn(async () => true) }));
|
||||
|
||||
const uiService = { get: vi.fn() };
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ uiService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/hooks/use-next-float-box-position', () => ({
|
||||
useNextFloatBoxPosition: () => ({ boxPosition: ref({ x: 100, y: 100 }), calcBoxPosition: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/hooks/use-editor-content-height', () => ({
|
||||
useEditorContentHeight: () => ({ height: ref(600) }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/CodeEditor.vue', () => ({
|
||||
default: defineComponent({ name: 'CodeEditor', setup: () => () => h('div') }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/FloatingBox.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FloatingBox',
|
||||
props: ['visible', 'width', 'height', 'title', 'position'],
|
||||
setup(props, { slots }) {
|
||||
return () => h('div', { class: 'fake-floating', 'data-visible': String(props.visible) }, slots.body?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
let capturedColumns: any[] = [];
|
||||
let capturedFormConfig: any[] = [];
|
||||
vi.mock('@tmagic/table', () => ({
|
||||
MagicTable: defineComponent({
|
||||
name: 'MagicTable',
|
||||
props: ['data', 'columns'],
|
||||
setup(props) {
|
||||
capturedColumns = props.columns;
|
||||
return () => h('div', { class: 'fake-table' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/form', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/form');
|
||||
return {
|
||||
...actual,
|
||||
MFormBox: defineComponent({
|
||||
name: 'MFormBox',
|
||||
props: ['config', 'values', 'parentValues', 'disabled', 'labelWidth'],
|
||||
emits: ['submit'],
|
||||
setup(props, { emit }) {
|
||||
capturedFormConfig = props.config;
|
||||
return () =>
|
||||
h('div', {
|
||||
class: 'fake-form-box',
|
||||
onClick: () => emit('submit', { index: -1, title: 'new' }),
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||
return {
|
||||
...actual,
|
||||
getDefaultValueFromFields: vi.fn(() => ({ a: 1 })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/design', async () => ({
|
||||
TMagicButton: defineComponent({
|
||||
name: 'TMagicButton',
|
||||
inheritAttrs: false,
|
||||
setup(_p, { slots, attrs }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
...attrs,
|
||||
class: ['fake-add-btn', (attrs as any).class].filter(Boolean).join(' '),
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
TMagicSwitch: defineComponent({ name: 'TMagicSwitch', setup: () => () => h('div') }),
|
||||
tMagicMessageBox: { confirm: messageBoxConfirm },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capturedColumns = [];
|
||||
capturedFormConfig = [];
|
||||
});
|
||||
|
||||
describe('DataSourceMocks', () => {
|
||||
test('渲染 MagicTable 和添加按钮', () => {
|
||||
const wrapper = mount(DataSourceMocks, {
|
||||
props: { config: {}, model: { mocks: [] }, name: 'mocks' } as any,
|
||||
});
|
||||
expect(wrapper.find('.fake-table').exists()).toBe(true);
|
||||
expect(wrapper.find('.fake-add-btn').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('点击添加按钮显示 dialog', async () => {
|
||||
const wrapper = mount(DataSourceMocks, {
|
||||
props: { config: {}, model: { mocks: [] }, name: 'mocks' } as any,
|
||||
});
|
||||
await wrapper.find('.fake-add-btn').trigger('click');
|
||||
expect(wrapper.find('.fake-floating').attributes('data-visible')).toBe('true');
|
||||
});
|
||||
|
||||
test('formChangeHandler 添加新记录', async () => {
|
||||
const model: any = { mocks: [] };
|
||||
const wrapper = mount(DataSourceMocks, {
|
||||
props: { config: {}, model, name: 'mocks' } as any,
|
||||
});
|
||||
await wrapper.find('.fake-add-btn').trigger('click');
|
||||
await wrapper.find('.fake-form-box').trigger('click');
|
||||
expect(model.mocks).toHaveLength(1);
|
||||
expect(model.mocks[0]).toEqual({ title: 'new' });
|
||||
expect(wrapper.emitted('change')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('编辑 action 设置 formValues 并打开对话框', async () => {
|
||||
const wrapper = mount(DataSourceMocks, {
|
||||
props: {
|
||||
config: {},
|
||||
model: { mocks: [{ title: 'm1', enable: true }] },
|
||||
name: 'mocks',
|
||||
} as any,
|
||||
});
|
||||
const editAction = capturedColumns[capturedColumns.length - 1].actions[0];
|
||||
editAction.handler({ title: 'm1' }, 0);
|
||||
expect(wrapper.vm).toBeTruthy();
|
||||
});
|
||||
|
||||
test('删除 action 弹出确认并删除', async () => {
|
||||
const model: any = { mocks: [{ title: 'm1' }] };
|
||||
const wrapper = mount(DataSourceMocks, {
|
||||
props: { config: {}, model, name: 'mocks' } as any,
|
||||
});
|
||||
const removeAction = capturedColumns[capturedColumns.length - 1].actions[1];
|
||||
await removeAction.handler({ title: 'm1' }, 0);
|
||||
expect(messageBoxConfirm).toHaveBeenCalled();
|
||||
expect(model.mocks).toHaveLength(0);
|
||||
expect(wrapper.emitted('change')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('toggleValue (enable) 互斥', () => {
|
||||
const model: any = {
|
||||
mocks: [
|
||||
{ title: 'a', enable: true },
|
||||
{ title: 'b', enable: true },
|
||||
],
|
||||
};
|
||||
mount(DataSourceMocks, {
|
||||
props: { config: {}, model, name: 'mocks' } as any,
|
||||
});
|
||||
const enableCol = capturedColumns.find((c: any) => c.prop === 'enable');
|
||||
const listeners = enableCol.listeners({ title: 'a', enable: false }, 0);
|
||||
listeners['update:modelValue'](true);
|
||||
expect(model.mocks[0].enable).toBe(true);
|
||||
expect(model.mocks[1].enable).toBe(false);
|
||||
});
|
||||
|
||||
test('mock data onChange 解析 JSON', () => {
|
||||
mount(DataSourceMocks, {
|
||||
props: { config: {}, model: { mocks: [] }, name: 'mocks' } as any,
|
||||
});
|
||||
const dataItem = capturedFormConfig.find((c: any) => c.name === 'data');
|
||||
expect(dataItem.onChange(undefined, '{"a":1}')).toEqual({ a: 1 });
|
||||
expect(dataItem.onChange(undefined, { a: 2 })).toEqual({ a: 2 });
|
||||
});
|
||||
|
||||
test('mock data validator 校验 JSON', () => {
|
||||
mount(DataSourceMocks, {
|
||||
props: { config: {}, model: { mocks: [] }, name: 'mocks' } as any,
|
||||
});
|
||||
const dataItem = capturedFormConfig.find((c: any) => c.name === 'data');
|
||||
const cb = vi.fn();
|
||||
dataItem.rules[0].validator({ value: '{"a":1}', callback: cb });
|
||||
expect(cb).toHaveBeenCalledWith();
|
||||
|
||||
const cb2 = vi.fn();
|
||||
dataItem.rules[0].validator({ value: 'invalid json', callback: cb2 });
|
||||
expect(cb2).toHaveBeenCalled();
|
||||
expect(cb2.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||
|
||||
const cb3 = vi.fn();
|
||||
dataItem.rules[0].validator({ value: { a: 1 }, callback: cb3 });
|
||||
expect(cb3).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
test('expand column props 提供 row.data', () => {
|
||||
mount(DataSourceMocks, {
|
||||
props: { config: {}, model: { mocks: [] }, name: 'mocks' } as any,
|
||||
});
|
||||
const expandCol = capturedColumns[0];
|
||||
expect(expandCol.type).toBe('expand');
|
||||
expect(expandCol.props({ data: { a: 1 } })).toMatchObject({ initValues: { a: 1 } });
|
||||
});
|
||||
});
|
||||
165
packages/editor/tests/unit/fields/DataSourceSelect.spec.ts
Normal file
165
packages/editor/tests/unit/fields/DataSourceSelect.spec.ts
Normal file
@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import DataSourceSelect from '@editor/fields/DataSourceSelect.vue';
|
||||
|
||||
const dataSourceService = {
|
||||
get: vi.fn(() => []),
|
||||
getDataSourceById: vi.fn(),
|
||||
};
|
||||
const uiService = {
|
||||
get: vi.fn(() => [{ $key: 'data-source' }]),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ dataSourceService, uiService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/type', async () => {
|
||||
const actual = await vi.importActual<any>('@editor/type');
|
||||
return { ...actual, SideItemKey: { DATA_SOURCE: 'data-source' } };
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/form', async (importOriginal) => {
|
||||
const actual = await importOriginal<any>();
|
||||
return {
|
||||
...actual,
|
||||
filterFunction: (_form: any, fn: any, props: any) => (typeof fn === 'function' ? fn(_form, props) : fn),
|
||||
MSelect: defineComponent({
|
||||
name: 'MSelect',
|
||||
props: ['model', 'name', 'size', 'prop', 'disabled', 'config', 'lastValues'],
|
||||
emits: ['change'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h('select', {
|
||||
class: 'fake-select',
|
||||
onChange: (e: Event) => emit('change', (e.target as HTMLSelectElement).value),
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicButton: defineComponent({
|
||||
name: 'TMagicButton',
|
||||
props: ['size'],
|
||||
emits: ['click'],
|
||||
setup(_p, { emit, slots }) {
|
||||
return () => h('button', { onClick: () => emit('click') }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
TMagicTooltip: defineComponent({
|
||||
name: 'TMagicTooltip',
|
||||
props: ['content'],
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-tooltip' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: defineComponent({ name: 'MIcon', props: ['icon'], setup: () => () => h('i') }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
uiService.get.mockReturnValue([{ $key: 'data-source' }]);
|
||||
dataSourceService.get.mockReturnValue([
|
||||
{ id: '1', type: 'http', title: 'A' },
|
||||
{ id: '2', type: 'base', title: 'B' },
|
||||
]);
|
||||
});
|
||||
|
||||
const baseProps = (extra: any = {}) => ({
|
||||
config: { type: 'data-source-select', value: 'id', notEditable: false },
|
||||
name: 'ds',
|
||||
prop: 'ds',
|
||||
model: { ds: '' },
|
||||
size: 'default',
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('DataSourceSelect', () => {
|
||||
test('val 为空时不显示编辑按钮', () => {
|
||||
const wrapper = mount(DataSourceSelect, { props: baseProps() as any });
|
||||
expect(wrapper.find('button').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('val 存在时显示编辑按钮', () => {
|
||||
const wrapper = mount(DataSourceSelect, { props: baseProps({ model: { ds: '1' } }) as any });
|
||||
expect(wrapper.find('button').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('change 事件 emit', async () => {
|
||||
const wrapper = mount(DataSourceSelect, { props: baseProps() as any });
|
||||
await wrapper.findComponent({ name: 'MSelect' }).vm.$emit('change', '1');
|
||||
expect(wrapper.emitted('change')?.[0]?.[0]).toBe('1');
|
||||
});
|
||||
|
||||
test('selectConfig 根据 dataSourceType 过滤', () => {
|
||||
const wrapper = mount(DataSourceSelect, {
|
||||
props: baseProps({ config: { type: 'data-source-select', value: 'id', dataSourceType: 'http' } }) as any,
|
||||
});
|
||||
const select = wrapper.findComponent({ name: 'MSelect' });
|
||||
expect((select.props('config') as any).options.length).toBe(1);
|
||||
expect((select.props('config') as any).options[0].value).toBe('1');
|
||||
});
|
||||
|
||||
test('selectConfig value=object 时返回对象结构', () => {
|
||||
const wrapper = mount(DataSourceSelect, {
|
||||
props: baseProps({ config: { type: 'data-source-select', value: 'object' } }) as any,
|
||||
});
|
||||
const select = wrapper.findComponent({ name: 'MSelect' });
|
||||
expect((select.props('config') as any).options[0].value).toEqual({
|
||||
isBindDataSource: true,
|
||||
dataSourceType: 'http',
|
||||
dataSourceId: '1',
|
||||
});
|
||||
});
|
||||
|
||||
test('editHandler emit edit-data-source', async () => {
|
||||
const eventBus = { emit: vi.fn() };
|
||||
dataSourceService.getDataSourceById.mockReturnValue({ id: '1' });
|
||||
const wrapper = mount(DataSourceSelect, {
|
||||
props: baseProps({ model: { ds: '1' } }) as any,
|
||||
global: { provide: { eventBus } },
|
||||
});
|
||||
await wrapper.find('button').trigger('click');
|
||||
expect(eventBus.emit).toHaveBeenCalledWith('edit-data-source', '1');
|
||||
});
|
||||
|
||||
test('editHandler value=object 时使用 dataSourceId', async () => {
|
||||
const eventBus = { emit: vi.fn() };
|
||||
dataSourceService.getDataSourceById.mockReturnValue({ id: '1' });
|
||||
const wrapper = mount(DataSourceSelect, {
|
||||
props: baseProps({ model: { ds: { dataSourceId: '1' } } }) as any,
|
||||
global: { provide: { eventBus } },
|
||||
});
|
||||
await wrapper.find('button').trigger('click');
|
||||
expect(eventBus.emit).toHaveBeenCalledWith('edit-data-source', '1');
|
||||
});
|
||||
|
||||
test('editHandler 找不到 dataSource 时不触发', async () => {
|
||||
const eventBus = { emit: vi.fn() };
|
||||
dataSourceService.getDataSourceById.mockReturnValue(null);
|
||||
const wrapper = mount(DataSourceSelect, {
|
||||
props: baseProps({ model: { ds: '1' } }) as any,
|
||||
global: { provide: { eventBus } },
|
||||
});
|
||||
await wrapper.find('button').trigger('click');
|
||||
expect(eventBus.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('未启用 dataSource 侧边栏时不显示编辑按钮', () => {
|
||||
uiService.get.mockReturnValue([]);
|
||||
const wrapper = mount(DataSourceSelect, { props: baseProps({ model: { ds: '1' } }) as any });
|
||||
expect(wrapper.find('button').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
168
packages/editor/tests/unit/fields/DisplayConds.spec.ts
Normal file
168
packages/editor/tests/unit/fields/DisplayConds.spec.ts
Normal file
@ -0,0 +1,168 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import DisplayConds from '@editor/fields/DisplayConds.vue';
|
||||
|
||||
const dataSourceService = {
|
||||
getDataSourceById: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ dataSourceService }),
|
||||
}));
|
||||
|
||||
const { fieldTypeMock } = vi.hoisted(() => ({
|
||||
fieldTypeMock: vi.fn((_ds: any, names: string[]) => {
|
||||
const key = names?.[0];
|
||||
if (key === 'numField') return 'number';
|
||||
if (key === 'boolField') return 'boolean';
|
||||
if (key === 'nullField') return 'null';
|
||||
return 'string';
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@editor/utils');
|
||||
return {
|
||||
...actual,
|
||||
getCascaderOptionsFromFields: vi.fn(() => [{ label: 'f1', value: 'f1' }]),
|
||||
getFieldType: fieldTypeMock,
|
||||
};
|
||||
});
|
||||
|
||||
let capturedConfig: any = null;
|
||||
vi.mock('@tmagic/form', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/form');
|
||||
return {
|
||||
...actual,
|
||||
filterFunction: vi.fn((_m: any, v: any) => (typeof v === 'function' ? v() : v)),
|
||||
MGroupList: defineComponent({
|
||||
name: 'MGroupList',
|
||||
props: ['config', 'name', 'disabled', 'model', 'lastValues', 'prop', 'size'],
|
||||
emits: ['change'],
|
||||
setup(props, { emit }) {
|
||||
capturedConfig = props.config;
|
||||
return () =>
|
||||
h('div', {
|
||||
class: 'fake-group-list',
|
||||
onClick: () => emit('change', [{ field: ['fa'], op: 'eq', value: 'a' }]),
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capturedConfig = null;
|
||||
dataSourceService.getDataSourceById.mockReturnValue({ fields: [{ name: 'a', type: 'string' }] });
|
||||
});
|
||||
|
||||
describe('DisplayConds', () => {
|
||||
test('change 事件初始化数组', async () => {
|
||||
const model: any = {};
|
||||
const wrapper = mount(DisplayConds, {
|
||||
props: { config: { titlePrefix: 't', parentFields: [] }, model, name: 'conds' } as any,
|
||||
});
|
||||
await wrapper.find('.fake-group-list').trigger('click');
|
||||
expect(model.conds).toEqual([]);
|
||||
expect(wrapper.emitted('change')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('parentFields 不为空时使用 cascader', () => {
|
||||
mount(DisplayConds, {
|
||||
props: {
|
||||
config: { titlePrefix: 't', parentFields: ['ds1'] },
|
||||
model: {},
|
||||
name: 'conds',
|
||||
} as any,
|
||||
});
|
||||
const item = capturedConfig.items[0].items[0];
|
||||
expect(item.type).toBe('cascader');
|
||||
expect(item.options()).toEqual([{ label: 'f1', value: 'f1' }]);
|
||||
});
|
||||
|
||||
test('parentFields 为空时使用 data-source-field-select', () => {
|
||||
mount(DisplayConds, {
|
||||
props: {
|
||||
config: { titlePrefix: 't', parentFields: [] },
|
||||
model: {},
|
||||
name: 'conds',
|
||||
} as any,
|
||||
});
|
||||
const item = capturedConfig.items[0].items[0];
|
||||
expect(item.type).toBe('data-source-field-select');
|
||||
});
|
||||
|
||||
test('value 字段类型 - number', () => {
|
||||
mount(DisplayConds, {
|
||||
props: { config: { titlePrefix: 't', parentFields: ['ds1'] }, model: {}, name: 'conds' } as any,
|
||||
});
|
||||
const valueItem = capturedConfig.items[0].items[2].items[0];
|
||||
expect(valueItem.type(undefined, { model: { field: ['numField'] } })).toBe('number');
|
||||
expect(valueItem.type(undefined, { model: { field: ['boolField'] } })).toBe('select');
|
||||
expect(valueItem.type(undefined, { model: { field: ['nullField'] } })).toBe('display');
|
||||
expect(valueItem.type(undefined, { model: { field: ['anyField'] } })).toBe('text');
|
||||
});
|
||||
|
||||
test('value display 函数', () => {
|
||||
mount(DisplayConds, {
|
||||
props: { config: { titlePrefix: 't', parentFields: [] }, model: {}, name: 'conds' } as any,
|
||||
});
|
||||
const valueItem = capturedConfig.items[0].items[2].items[0];
|
||||
expect(valueItem.display(undefined, { model: { op: 'eq' } })).toBe(true);
|
||||
expect(valueItem.display(undefined, { model: { op: 'between' } })).toBe(false);
|
||||
expect(valueItem.displayText(undefined, { model: { value: null } })).toBe('null');
|
||||
expect(valueItem.displayText(undefined, { model: { value: 'a' } })).toBe('a');
|
||||
});
|
||||
|
||||
test('range display 函数', () => {
|
||||
mount(DisplayConds, {
|
||||
props: { config: { titlePrefix: 't', parentFields: [] }, model: {}, name: 'conds' } as any,
|
||||
});
|
||||
const rangeItem = capturedConfig.items[0].items[2].items[1];
|
||||
expect(rangeItem.display(undefined, { model: { op: 'between' } })).toBe(true);
|
||||
expect(rangeItem.display(undefined, { model: { op: 'eq' } })).toBe(false);
|
||||
});
|
||||
|
||||
test('field onChange 转换 model.value 类型', () => {
|
||||
mount(DisplayConds, {
|
||||
props: { config: { titlePrefix: 't', parentFields: ['ds1'] }, model: {}, name: 'conds' } as any,
|
||||
});
|
||||
const item = capturedConfig.items[0].items[0];
|
||||
const m1: any = { value: '5' };
|
||||
item.onChange(undefined, ['numField'], { model: m1 });
|
||||
expect(m1.value).toBe(5);
|
||||
|
||||
const m2: any = { value: '' };
|
||||
item.onChange(undefined, ['boolField'], { model: m2 });
|
||||
expect(m2.value).toBe(false);
|
||||
|
||||
const m3: any = { value: 'x' };
|
||||
item.onChange(undefined, ['nullField'], { model: m3 });
|
||||
expect(m3.value).toBe(null);
|
||||
|
||||
const m4: any = { value: 1 };
|
||||
item.onChange(undefined, ['strField'], { model: m4 });
|
||||
expect(m4.value).toBe('1');
|
||||
});
|
||||
|
||||
test('cascader options 没有 ds 时返回空', () => {
|
||||
dataSourceService.getDataSourceById.mockReturnValue(null);
|
||||
mount(DisplayConds, {
|
||||
props: {
|
||||
config: { titlePrefix: 't', parentFields: ['ds1'] },
|
||||
model: {},
|
||||
name: 'conds',
|
||||
} as any,
|
||||
});
|
||||
const item = capturedConfig.items[0].items[0];
|
||||
expect(item.options()).toEqual([]);
|
||||
});
|
||||
});
|
||||
376
packages/editor/tests/unit/fields/EventSelect.spec.ts
Normal file
376
packages/editor/tests/unit/fields/EventSelect.spec.ts
Normal file
@ -0,0 +1,376 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import EventSelect from '@editor/fields/EventSelect.vue';
|
||||
|
||||
const editorService = {
|
||||
get: vi.fn(),
|
||||
getNodeById: vi.fn(),
|
||||
};
|
||||
const dataSourceService = {
|
||||
get: vi.fn(),
|
||||
getDataSourceById: vi.fn(),
|
||||
getFormEvent: vi.fn(() => []),
|
||||
};
|
||||
const eventsService = {
|
||||
getEvent: vi.fn(() => [{ label: 'click', value: 'click' }]),
|
||||
getMethod: vi.fn(() => [{ label: 'open', value: 'open' }]),
|
||||
};
|
||||
const codeBlockService = {
|
||||
getCodeDsl: vi.fn(() => ({ c1: {} })),
|
||||
getEditStatus: vi.fn(() => true),
|
||||
};
|
||||
const propsService = {
|
||||
getDisabledCodeBlock: vi.fn(() => false),
|
||||
getDisabledDataSource: vi.fn(() => false),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService, dataSourceService, eventsService, codeBlockService, propsService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@editor/utils');
|
||||
return { ...actual, getCascaderOptionsFromFields: vi.fn(() => []) };
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/form', async (importOriginal) => {
|
||||
const actual = await importOriginal<any>();
|
||||
return {
|
||||
...actual,
|
||||
defineFormItem: (cfg: any) => cfg,
|
||||
MTable: defineComponent({
|
||||
name: 'MTable',
|
||||
props: ['model', 'config', 'name', 'size', 'disabled'],
|
||||
emits: ['change'],
|
||||
setup() {
|
||||
return () => h('div', { class: 'fake-table' });
|
||||
},
|
||||
}),
|
||||
MPanel: defineComponent({
|
||||
name: 'MPanel',
|
||||
props: ['model', 'config', 'prop', 'disabled', 'size', 'labelWidth'],
|
||||
emits: ['change'],
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-panel' }, slots.header?.());
|
||||
},
|
||||
}),
|
||||
MContainer: defineComponent({
|
||||
name: 'MFormContainer',
|
||||
props: ['model', 'config', 'prop', 'disabled', 'size'],
|
||||
emits: ['change'],
|
||||
setup() {
|
||||
return () => h('div', { class: 'fake-container' });
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicButton: defineComponent({
|
||||
name: 'TMagicButton',
|
||||
props: ['type', 'size', 'disabled', 'icon', 'link'],
|
||||
emits: ['click'],
|
||||
setup(_p, { emit, slots }) {
|
||||
return () => h('button', { onClick: () => emit('click') }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||
return {
|
||||
...actual,
|
||||
DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX: 'ds_change_',
|
||||
traverseNode: (node: any, fn: any) => {
|
||||
fn(node);
|
||||
node.items?.forEach((c: any) => fn(c));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const baseProps = (extra: any = {}) => ({
|
||||
config: { type: 'event-select', src: 'component' },
|
||||
name: 'events',
|
||||
prop: 'events',
|
||||
model: { events: [] },
|
||||
size: 'default',
|
||||
disabled: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('EventSelect', () => {
|
||||
test('events 为空 isOldVersion=false 显示新版按钮', () => {
|
||||
const wrapper = mount(EventSelect, { props: baseProps() as any });
|
||||
expect(wrapper.find('.create-button').exists()).toBe(true);
|
||||
expect(wrapper.find('.fake-table').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('addEvent emit 事件并携带 modifyKey', async () => {
|
||||
const wrapper = mount(EventSelect, { props: baseProps() as any });
|
||||
await wrapper.find('.create-button').trigger('click');
|
||||
const evts = wrapper.emitted('change');
|
||||
expect((evts?.[0]?.[0] as any).name).toBe('');
|
||||
});
|
||||
|
||||
test('removeEvent 删除指定 index', async () => {
|
||||
const wrapper = mount(EventSelect, {
|
||||
props: baseProps({
|
||||
model: { events: [{ name: 'a', actions: [] }] },
|
||||
}) as any,
|
||||
});
|
||||
const buttons = wrapper.findAll('button');
|
||||
const lastBtn = buttons[buttons.length - 1];
|
||||
await lastBtn.trigger('click');
|
||||
const evts = wrapper.emitted('change');
|
||||
expect(evts).toBeTruthy();
|
||||
});
|
||||
|
||||
test('events 含 actions 字段时不算 oldVersion,渲染 panel', () => {
|
||||
const wrapper = mount(EventSelect, {
|
||||
props: baseProps({
|
||||
model: { events: [{ name: 'a', actions: [] }] },
|
||||
}) as any,
|
||||
});
|
||||
expect(wrapper.findAll('.fake-panel').length).toBe(1);
|
||||
});
|
||||
|
||||
test('events 不含 actions 字段时为 oldVersion,渲染 table', () => {
|
||||
const wrapper = mount(EventSelect, {
|
||||
props: baseProps({
|
||||
model: { events: [{ name: 'a' }] },
|
||||
}) as any,
|
||||
});
|
||||
expect(wrapper.find('.fake-table').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('Table change emit', async () => {
|
||||
const wrapper = mount(EventSelect, {
|
||||
props: baseProps({
|
||||
model: { events: [{ name: 'a' }] },
|
||||
}) as any,
|
||||
});
|
||||
await wrapper.findComponent({ name: 'MTable' }).vm.$emit('change', null, { modifyKey: 'foo' });
|
||||
expect(wrapper.emitted('change')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Panel header MFormContainer change emit', async () => {
|
||||
const wrapper = mount(EventSelect, {
|
||||
props: baseProps({
|
||||
model: { events: [{ name: 'a', actions: [] }] },
|
||||
}) as any,
|
||||
});
|
||||
await wrapper.findComponent({ name: 'MFormContainer' }).vm.$emit('change', null, { modifyKey: 'name' });
|
||||
expect(wrapper.emitted('change')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('addEvent 在 model[name] 为空时初始化', async () => {
|
||||
const m: any = { events: [] };
|
||||
const wrapper = mount(EventSelect, { props: baseProps({ model: m }) as any });
|
||||
await wrapper.find('.create-button').trigger('click');
|
||||
const evts = wrapper.emitted('change');
|
||||
expect(evts).toBeTruthy();
|
||||
expect((evts?.[0]?.[0] as any).actions).toEqual([]);
|
||||
});
|
||||
|
||||
test('eventNameConfig type/options src=component 返回 select', () => {
|
||||
const wrapper = mount(EventSelect, {
|
||||
props: baseProps({
|
||||
model: { events: [{ name: 'a', actions: [] }] },
|
||||
}) as any,
|
||||
});
|
||||
const cfg = wrapper.findComponent({ name: 'MFormContainer' }).props('config') as any;
|
||||
expect(cfg.type(undefined, { formValue: { type: 'btn' } })).toBe('select');
|
||||
const opts = cfg.options(undefined, { formValue: { type: 'btn' } });
|
||||
expect(Array.isArray(opts)).toBe(true);
|
||||
expect(opts[0]).toMatchObject({ text: 'click', value: 'click' });
|
||||
});
|
||||
|
||||
test('eventNameConfig type 当 page-fragment 且有 pageFragmentId 返回 cascader', () => {
|
||||
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [] }] });
|
||||
const wrapper = mount(EventSelect, {
|
||||
props: baseProps({
|
||||
model: { events: [{ name: 'a', actions: [] }] },
|
||||
}) as any,
|
||||
});
|
||||
const cfg = wrapper.findComponent({ name: 'MFormContainer' }).props('config') as any;
|
||||
expect(cfg.type(undefined, { formValue: { type: 'page-fragment-container', pageFragmentId: 'pf1' } })).toBe(
|
||||
'cascader',
|
||||
);
|
||||
const opts = cfg.options(undefined, { formValue: { type: 'page-fragment-container', pageFragmentId: 'pf1' } });
|
||||
expect(Array.isArray(opts)).toBe(true);
|
||||
});
|
||||
|
||||
test('eventNameConfig src=datasource 返回事件 + 数据变化字段', () => {
|
||||
dataSourceService.getDataSourceById.mockReturnValue({ fields: [{ name: 'f1' }] });
|
||||
const wrapper = mount(EventSelect, {
|
||||
props: baseProps({
|
||||
config: { type: 'event-select', src: 'datasource' },
|
||||
model: { events: [{ name: 'a', actions: [] }] },
|
||||
}) as any,
|
||||
});
|
||||
const cfg = wrapper.findComponent({ name: 'MFormContainer' }).props('config') as any;
|
||||
const opts = cfg.options(undefined, { formValue: { type: 'ds', id: 'd1' } });
|
||||
expect(opts).toEqual([{ label: '数据变化', value: 'ds_change_', children: [] }]);
|
||||
});
|
||||
|
||||
test('eventNameConfig src=datasource 无 fields 时返回原始事件', () => {
|
||||
dataSourceService.getDataSourceById.mockReturnValue({ fields: [] });
|
||||
const wrapper = mount(EventSelect, {
|
||||
props: baseProps({
|
||||
config: { type: 'event-select', src: 'datasource' },
|
||||
model: { events: [{ name: 'a', actions: [] }] },
|
||||
}) as any,
|
||||
});
|
||||
const cfg = wrapper.findComponent({ name: 'MFormContainer' }).props('config') as any;
|
||||
const opts = cfg.options(undefined, { formValue: { type: 'ds', id: 'd1' } });
|
||||
expect(opts).toEqual([]);
|
||||
});
|
||||
|
||||
test('actionTypeConfig 含 组件/代码/数据源', () => {
|
||||
propsService.getDisabledCodeBlock.mockReturnValue(false);
|
||||
propsService.getDisabledDataSource.mockReturnValue(false);
|
||||
const wrapper = mount(EventSelect, {
|
||||
props: baseProps({
|
||||
model: { events: [{ name: 'a', actions: [] }] },
|
||||
}) as any,
|
||||
});
|
||||
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
|
||||
const groupItems = panelCfg.items[0].items;
|
||||
const actionType = groupItems[0];
|
||||
const opts = actionType.options();
|
||||
expect(opts.map((o: any) => o.value).sort()).toEqual(['code', 'comp', 'data-source'].sort());
|
||||
});
|
||||
|
||||
test('actionTypeConfig disabledCodeBlock/disabledDataSource 时不包含选项', () => {
|
||||
propsService.getDisabledCodeBlock.mockReturnValue(true);
|
||||
propsService.getDisabledDataSource.mockReturnValue(true);
|
||||
const wrapper = mount(EventSelect, {
|
||||
props: baseProps({
|
||||
model: { events: [{ name: 'a', actions: [] }] },
|
||||
}) as any,
|
||||
});
|
||||
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
|
||||
const actionType = panelCfg.items[0].items[0];
|
||||
const opts = actionType.options();
|
||||
expect(opts.map((o: any) => o.value)).toEqual(['comp']);
|
||||
propsService.getDisabledCodeBlock.mockReturnValue(false);
|
||||
propsService.getDisabledDataSource.mockReturnValue(false);
|
||||
});
|
||||
|
||||
test('targetCompConfig display/onChange', () => {
|
||||
const wrapper = mount(EventSelect, {
|
||||
props: baseProps({
|
||||
model: { events: [{ name: 'a', actions: [] }] },
|
||||
}) as any,
|
||||
});
|
||||
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
|
||||
const target = panelCfg.items[0].items[1];
|
||||
expect(target.display(undefined, { model: { actionType: 'comp' } })).toBe(true);
|
||||
const setModel = vi.fn();
|
||||
target.onChange(undefined, undefined, { setModel });
|
||||
expect(setModel).toHaveBeenCalledWith('method', '');
|
||||
});
|
||||
|
||||
test('compActionConfig 解析 type/options', () => {
|
||||
editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' });
|
||||
const wrapper = mount(EventSelect, {
|
||||
props: baseProps({
|
||||
model: { events: [{ name: 'a', actions: [] }] },
|
||||
}) as any,
|
||||
});
|
||||
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
|
||||
const compAction = panelCfg.items[0].items[2];
|
||||
expect(compAction.type(undefined, { model: { to: '1' } })).toBe('select');
|
||||
expect(Array.isArray(compAction.options(undefined, { model: { to: '1' } }))).toBe(true);
|
||||
});
|
||||
|
||||
test('compActionConfig type cascader 当 page-fragment-container', () => {
|
||||
editorService.getNodeById.mockReturnValue({ type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' });
|
||||
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [{ id: 'c1', type: 'btn', name: 'b' }] }] });
|
||||
const wrapper = mount(EventSelect, {
|
||||
props: baseProps({
|
||||
model: { events: [{ name: 'a', actions: [] }] },
|
||||
}) as any,
|
||||
});
|
||||
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
|
||||
const compAction = panelCfg.items[0].items[2];
|
||||
expect(compAction.type(undefined, { model: { to: '1' } })).toBe('cascader');
|
||||
const opts = compAction.options(undefined, { model: { to: '1' } });
|
||||
expect(Array.isArray(opts)).toBe(true);
|
||||
});
|
||||
|
||||
test('compActionConfig options 当 node 无 type 返回空数组', () => {
|
||||
editorService.getNodeById.mockReturnValue(null);
|
||||
const wrapper = mount(EventSelect, {
|
||||
props: baseProps({
|
||||
model: { events: [{ name: 'a', actions: [] }] },
|
||||
}) as any,
|
||||
});
|
||||
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
|
||||
const compAction = panelCfg.items[0].items[2];
|
||||
expect(compAction.options(undefined, { model: { to: 'unknown' } })).toEqual([]);
|
||||
});
|
||||
|
||||
test('codeActionConfig display/notEditable', () => {
|
||||
codeBlockService.getEditStatus.mockReturnValue(false);
|
||||
const wrapper = mount(EventSelect, {
|
||||
props: baseProps({
|
||||
model: { events: [{ name: 'a', actions: [] }] },
|
||||
}) as any,
|
||||
});
|
||||
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
|
||||
const codeAction = panelCfg.items[0].items[3];
|
||||
expect(codeAction.display(undefined, { model: { actionType: 'code' } })).toBe(true);
|
||||
expect(codeAction.notEditable()).toBe(true);
|
||||
codeBlockService.getEditStatus.mockReturnValue(true);
|
||||
});
|
||||
|
||||
test('dataSourceActionConfig display/notEditable', () => {
|
||||
dataSourceService.get.mockReturnValue(false);
|
||||
const wrapper = mount(EventSelect, {
|
||||
props: baseProps({
|
||||
model: { events: [{ name: 'a', actions: [] }] },
|
||||
}) as any,
|
||||
});
|
||||
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
|
||||
const dsAction = panelCfg.items[0].items[4];
|
||||
expect(dsAction.display(undefined, { model: { actionType: 'data-source' } })).toBe(true);
|
||||
expect(dsAction.notEditable()).toBe(true);
|
||||
});
|
||||
|
||||
test('table 配置中 method options', () => {
|
||||
editorService.getNodeById.mockReturnValue({ type: 'btn' });
|
||||
const wrapper = mount(EventSelect, {
|
||||
props: baseProps({
|
||||
model: { events: [{ name: 'a' }] },
|
||||
}) as any,
|
||||
});
|
||||
const tableCfg = wrapper.findComponent({ name: 'MTable' }).props('config') as any;
|
||||
const methodCol = tableCfg.items.find((it: any) => it.name === 'method');
|
||||
const opts = methodCol.options(undefined, { model: { to: '1' } });
|
||||
expect(opts).toEqual([{ text: 'open', value: 'open' }]);
|
||||
editorService.getNodeById.mockReturnValue(null);
|
||||
expect(methodCol.options(undefined, { model: { to: '1' } })).toEqual([]);
|
||||
});
|
||||
|
||||
test('removeEvent 通过 panel header 删除按钮调用', async () => {
|
||||
const m: any = {
|
||||
events: [
|
||||
{ name: 'a', actions: [] },
|
||||
{ name: 'b', actions: [] },
|
||||
],
|
||||
};
|
||||
const wrapper = mount(EventSelect, { props: baseProps({ model: m }) as any });
|
||||
const buttons = wrapper.findAll('button');
|
||||
const deleteBtn = buttons[buttons.length - 1];
|
||||
await deleteBtn.trigger('click');
|
||||
expect(m.events.length).toBe(1);
|
||||
});
|
||||
});
|
||||
138
packages/editor/tests/unit/fields/KeyValue.spec.ts
Normal file
138
packages/editor/tests/unit/fields/KeyValue.spec.ts
Normal file
@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import KeyValue from '@editor/fields/KeyValue.vue';
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicInput: defineComponent({
|
||||
name: 'TMagicInput',
|
||||
props: ['modelValue', 'disabled', 'size', 'placeholder'],
|
||||
emits: ['update:modelValue', 'change'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h('input', {
|
||||
class: 'fake-input',
|
||||
value: props.modelValue,
|
||||
placeholder: props.placeholder,
|
||||
onInput: (e: Event) => emit('update:modelValue', (e.target as HTMLInputElement).value),
|
||||
onChange: (e: Event) => emit('change', (e.target as HTMLInputElement).value),
|
||||
});
|
||||
},
|
||||
}),
|
||||
TMagicButton: defineComponent({
|
||||
name: 'TMagicButton',
|
||||
props: ['type', 'size', 'disabled', 'plain', 'icon', 'circle', 'link'],
|
||||
emits: ['click'],
|
||||
setup(_p, { emit, slots }) {
|
||||
return () => h('button', { onClick: () => emit('click') }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/CodeEditor.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'MagicCodeEditor',
|
||||
props: ['initValues'],
|
||||
emits: ['save'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h('div', {
|
||||
class: 'code-editor',
|
||||
onClick: () => emit('save', 'function() {}'),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/icons/CodeIcon.vue', () => ({
|
||||
default: defineComponent({ name: 'CodeIcon', setup: () => () => h('i') }),
|
||||
}));
|
||||
|
||||
describe('KeyValue', () => {
|
||||
const baseProps = (extra: any = {}) => ({
|
||||
config: { advanced: false, type: 'key-value' },
|
||||
name: 'kv',
|
||||
model: { kv: { foo: 'bar', baz: 'qux' } },
|
||||
disabled: false,
|
||||
size: 'default',
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('渲染初始 records', () => {
|
||||
const wrapper = mount(KeyValue, { props: baseProps() as any });
|
||||
expect(wrapper.findAll('.m-fields-key-value-item').length).toBe(2);
|
||||
});
|
||||
|
||||
test('addHandler 增加一个空 record', async () => {
|
||||
const wrapper = mount(KeyValue, { props: baseProps() as any });
|
||||
const buttons = wrapper.findAll('button');
|
||||
await buttons[buttons.length - 1].trigger('click');
|
||||
expect(wrapper.findAll('.m-fields-key-value-item').length).toBe(3);
|
||||
});
|
||||
|
||||
test('deleteHandler 删除项并 emit change', async () => {
|
||||
const wrapper = mount(KeyValue, { props: baseProps() as any });
|
||||
const deleteBtns = wrapper.findAll('.m-fields-key-value-delete');
|
||||
await deleteBtns[0].trigger('click');
|
||||
expect(wrapper.findAll('.m-fields-key-value-item').length).toBe(1);
|
||||
const evts = wrapper.emitted('change');
|
||||
expect(evts?.[0]?.[0]).toEqual({ baz: 'qux' });
|
||||
});
|
||||
|
||||
test('keyChange / valueChange emit change', async () => {
|
||||
const wrapper = mount(KeyValue, { props: baseProps() as any });
|
||||
const inputs = wrapper.findAll('input');
|
||||
(inputs[0].element as HTMLInputElement).value = 'k1';
|
||||
await inputs[0].trigger('input');
|
||||
await inputs[0].trigger('change');
|
||||
const evts = wrapper.emitted('change');
|
||||
expect(evts?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('config.advanced 时显示代码编辑切换按钮,可切换 showCode', async () => {
|
||||
const wrapper = mount(KeyValue, { props: baseProps({ config: { advanced: true, type: 'key-value' } }) as any });
|
||||
const buttons = wrapper.findAll('button');
|
||||
const last = buttons[buttons.length - 1];
|
||||
await last.trigger('click');
|
||||
await nextTick();
|
||||
expect(wrapper.find('.code-editor').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('当值为非字符串时自动开启代码模式', () => {
|
||||
const wrapper = mount(KeyValue, {
|
||||
props: baseProps({
|
||||
config: { advanced: true, type: 'key-value' },
|
||||
model: { kv: { foo: { x: 1 } } },
|
||||
}) as any,
|
||||
});
|
||||
expect(wrapper.find('.code-editor').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('当值为函数时自动开启代码模式', () => {
|
||||
const wrapper = mount(KeyValue, {
|
||||
props: baseProps({
|
||||
config: { advanced: true, type: 'key-value' },
|
||||
model: { kv: () => null },
|
||||
}) as any,
|
||||
});
|
||||
expect(wrapper.find('.code-editor').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('CodeEditor save emit change', async () => {
|
||||
const wrapper = mount(KeyValue, {
|
||||
props: baseProps({
|
||||
config: { advanced: true, type: 'key-value' },
|
||||
model: { kv: () => null },
|
||||
}) as any,
|
||||
});
|
||||
await wrapper.find('.code-editor').trigger('click');
|
||||
const evts = wrapper.emitted('change');
|
||||
expect(evts?.[0]?.[0]).toBe('function() {}');
|
||||
});
|
||||
});
|
||||
143
packages/editor/tests/unit/fields/PageFragmentSelect.spec.ts
Normal file
143
packages/editor/tests/unit/fields/PageFragmentSelect.spec.ts
Normal file
@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import PageFragmentSelect from '@editor/fields/PageFragmentSelect.vue';
|
||||
|
||||
const editorService = {
|
||||
get: vi.fn(),
|
||||
select: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService }),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/form', () => ({
|
||||
MSelect: defineComponent({
|
||||
name: 'MSelect',
|
||||
props: ['config', 'model', 'name', 'size', 'prop', 'disabled'],
|
||||
emits: ['change'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h('select', {
|
||||
class: 'fake-select',
|
||||
onChange: (e: Event) => emit('change', (e.target as HTMLSelectElement).value),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'MEditorIcon',
|
||||
props: ['icon'],
|
||||
emits: ['click'],
|
||||
setup(_p, { emit }) {
|
||||
return () => h('i', { class: 'fake-icon', onClick: () => emit('click') });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/core', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/core');
|
||||
return { ...actual, NodeType: { PAGE: 'page', PAGE_FRAGMENT: 'page-fragment' } };
|
||||
});
|
||||
|
||||
describe('PageFragmentSelect', () => {
|
||||
test('model[name] 不为空时显示编辑图标', () => {
|
||||
editorService.get.mockReturnValue({ items: [{ id: 'p1', type: 'page-fragment', name: 'A' }] });
|
||||
const wrapper = mount(PageFragmentSelect, {
|
||||
props: {
|
||||
config: { type: 'page-fragment-select' },
|
||||
name: 'pf',
|
||||
model: { pf: 'p1' },
|
||||
size: 'default',
|
||||
} as any,
|
||||
});
|
||||
expect(wrapper.find('.fake-icon').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('model[name] 为空不显示编辑图标', () => {
|
||||
editorService.get.mockReturnValue({ items: [] });
|
||||
const wrapper = mount(PageFragmentSelect, {
|
||||
props: {
|
||||
config: { type: 'page-fragment-select' },
|
||||
name: 'pf',
|
||||
model: { pf: '' },
|
||||
size: 'default',
|
||||
} as any,
|
||||
});
|
||||
expect(wrapper.find('.fake-icon').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('change emit', async () => {
|
||||
editorService.get.mockReturnValue({ items: [] });
|
||||
const wrapper = mount(PageFragmentSelect, {
|
||||
props: {
|
||||
config: { type: 'page-fragment-select' },
|
||||
name: 'pf',
|
||||
model: { pf: '' },
|
||||
size: 'default',
|
||||
} as any,
|
||||
});
|
||||
await wrapper.findComponent({ name: 'MSelect' }).vm.$emit('change', 'p2');
|
||||
expect(wrapper.emitted('change')?.[0]?.[0]).toBe('p2');
|
||||
});
|
||||
|
||||
test('点击编辑图标调用 editorService.select', async () => {
|
||||
editorService.get.mockReturnValue({ items: [{ id: 'p1', type: 'page-fragment', name: 'A' }] });
|
||||
editorService.select.mockClear();
|
||||
const wrapper = mount(PageFragmentSelect, {
|
||||
props: {
|
||||
config: { type: 'page-fragment-select' },
|
||||
name: 'pf',
|
||||
model: { pf: 'p1' },
|
||||
size: 'default',
|
||||
} as any,
|
||||
});
|
||||
await wrapper.find('.fake-icon').trigger('click');
|
||||
expect(editorService.select).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
|
||||
test('selectConfig.options 返回 pageList', () => {
|
||||
const items = [
|
||||
{ id: 'p1', type: 'page-fragment', name: 'A', title: 'TitleA' },
|
||||
{ id: 'p2', type: 'page-fragment', name: 'B', devconfig: { tabName: 'TabB' } },
|
||||
{ id: 'p3', type: 'page', name: 'normal' },
|
||||
];
|
||||
editorService.get.mockReturnValue({ items });
|
||||
const wrapper = mount(PageFragmentSelect, {
|
||||
props: {
|
||||
config: { type: 'page-fragment-select' },
|
||||
name: 'pf',
|
||||
model: { pf: '' },
|
||||
size: 'default',
|
||||
} as any,
|
||||
});
|
||||
const select = wrapper.findComponent({ name: 'MSelect' });
|
||||
const options = (select.props('config') as any).options();
|
||||
expect(options.length).toBe(2);
|
||||
expect(options[0].value).toBe('p1');
|
||||
expect(options[1].text).toContain('TabB');
|
||||
});
|
||||
|
||||
test('root 为空 options 返回空数组', () => {
|
||||
editorService.get.mockReturnValue(undefined);
|
||||
const wrapper = mount(PageFragmentSelect, {
|
||||
props: {
|
||||
config: { type: 'page-fragment-select' },
|
||||
name: 'pf',
|
||||
model: { pf: '' },
|
||||
size: 'default',
|
||||
} as any,
|
||||
});
|
||||
const select = wrapper.findComponent({ name: 'MSelect' });
|
||||
expect((select.props('config') as any).options()).toEqual([]);
|
||||
});
|
||||
});
|
||||
74
packages/editor/tests/unit/fields/StyleSetter/Index.spec.ts
Normal file
74
packages/editor/tests/unit/fields/StyleSetter/Index.spec.ts
Normal file
@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import StyleSetter from '@editor/fields/StyleSetter/Index.vue';
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicCollapse: defineComponent({
|
||||
name: 'TMagicCollapse',
|
||||
props: ['modelValue'],
|
||||
setup(_props, { slots }) {
|
||||
return () => h('div', { class: 'collapse' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
TMagicCollapseItem: defineComponent({
|
||||
name: 'TMagicCollapseItem',
|
||||
props: ['name'],
|
||||
setup(_props, { slots }) {
|
||||
return () => h('div', { class: 'collapse-item' }, [slots.title?.(), slots.default?.()]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: defineComponent({ name: 'MIcon', props: ['icon'], setup: () => () => h('i') }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/fields/StyleSetter/pro/index', () => {
|
||||
const make = (name: string) =>
|
||||
defineComponent({
|
||||
name,
|
||||
props: ['values', 'size', 'disabled'],
|
||||
emits: ['change'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h('div', {
|
||||
class: name,
|
||||
onClick: () => emit('change', { foo: 1 }, { changeRecords: [{ propPath: 'foo', value: 1 }] }),
|
||||
});
|
||||
},
|
||||
});
|
||||
return {
|
||||
Layout: make('Layout'),
|
||||
Position: make('Position'),
|
||||
Background: make('Background'),
|
||||
Font: make('Font'),
|
||||
Border: make('Border'),
|
||||
Transform: make('Transform'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('StyleSetter Index', () => {
|
||||
test('渲染 6 个 collapse-item', () => {
|
||||
const wrapper = mount(StyleSetter, {
|
||||
props: { model: { style: {} }, name: 'style' } as any,
|
||||
});
|
||||
expect(wrapper.findAll('.collapse-item').length).toBe(6);
|
||||
});
|
||||
|
||||
test('change 时为 propPath 添加 name 前缀', async () => {
|
||||
const wrapper = mount(StyleSetter, {
|
||||
props: { model: { style: {} }, name: 'style' } as any,
|
||||
});
|
||||
await wrapper.find('.Layout').trigger('click');
|
||||
const events = wrapper.emitted('change');
|
||||
expect(events).toBeTruthy();
|
||||
expect((events?.[0]?.[1] as any).changeRecords[0].propPath).toBe('style.foo');
|
||||
});
|
||||
});
|
||||
113
packages/editor/tests/unit/fields/StyleSetter/Layout.spec.ts
Normal file
113
packages/editor/tests/unit/fields/StyleSetter/Layout.spec.ts
Normal file
@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import Layout from '@editor/fields/StyleSetter/pro/Layout.vue';
|
||||
|
||||
vi.mock('@tmagic/form', () => ({
|
||||
defineFormItem: (cfg: any) => cfg,
|
||||
MContainer: defineComponent({
|
||||
name: 'FakeMContainer',
|
||||
props: ['config', 'model', 'size', 'disabled'],
|
||||
emits: ['change'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: 'fake-mcontainer',
|
||||
onClick: () => emit('change', 'val', { propPath: 'p' }),
|
||||
},
|
||||
'mc',
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/fields/StyleSetter/components/Box.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeBox',
|
||||
props: ['model', 'size', 'disabled'],
|
||||
emits: ['change'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: 'fake-box',
|
||||
onClick: () => emit('change', 'box-val', { propPath: 'b' }),
|
||||
},
|
||||
'box',
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('StyleSetter/Layout.vue', () => {
|
||||
test('渲染 MContainer 与 Box,且非 fixed/absolute 时显示 Box', () => {
|
||||
const wrapper = mount(Layout, {
|
||||
props: {
|
||||
values: { position: 'static', display: 'flex' },
|
||||
} as any,
|
||||
});
|
||||
expect(wrapper.find('.fake-mcontainer').exists()).toBe(true);
|
||||
expect(wrapper.find('.fake-box').isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
test('position 为 fixed 时 Box 隐藏 (display:none)', () => {
|
||||
const wrapper = mount(Layout, {
|
||||
props: {
|
||||
values: { position: 'fixed', display: 'block' },
|
||||
} as any,
|
||||
});
|
||||
const el = wrapper.find('.fake-box').element as HTMLElement;
|
||||
expect(el.style.display).toBe('none');
|
||||
});
|
||||
|
||||
test('change 事件冒泡', async () => {
|
||||
const wrapper = mount(Layout, {
|
||||
props: { values: { position: 'static', display: 'flex' } } as any,
|
||||
});
|
||||
await wrapper.find('.fake-mcontainer').trigger('click');
|
||||
expect(wrapper.emitted('change')?.[0]).toEqual(['val', { propPath: 'p' }]);
|
||||
await wrapper.find('.fake-box').trigger('click');
|
||||
expect(wrapper.emitted('change')?.[1]).toEqual(['box-val', { propPath: 'b' }]);
|
||||
});
|
||||
|
||||
test('display 函数 model.display 为 flex 时返回 true', () => {
|
||||
const wrapper = mount(Layout, {
|
||||
props: { values: { position: 'static', display: 'flex' } } as any,
|
||||
});
|
||||
const config = wrapper.findComponent({ name: 'FakeMContainer' }).props('config') as any;
|
||||
const flexItem = config.items.find((it: any) => it.name === 'flexDirection');
|
||||
expect(flexItem.display(null, { model: { display: 'flex' } })).toBe(true);
|
||||
expect(flexItem.display(null, { model: { display: 'block' } })).toBe(false);
|
||||
});
|
||||
|
||||
test('justifyContent / alignItems / flexWrap 仅 flex 时显示', () => {
|
||||
const wrapper = mount(Layout, {
|
||||
props: { values: { position: 'static', display: 'flex' } } as any,
|
||||
});
|
||||
const config = wrapper.findComponent({ name: 'FakeMContainer' }).props('config') as any;
|
||||
['justifyContent', 'alignItems', 'flexWrap'].forEach((name) => {
|
||||
const item = config.items.find((it: any) => it.name === name);
|
||||
expect(item.display(null, { model: { display: 'flex' } })).toBe(true);
|
||||
expect(item.display(null, { model: { display: 'block' } })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('display 选项含 inline/flex/block/inline-block/none', () => {
|
||||
const wrapper = mount(Layout, {
|
||||
props: { values: { position: 'static', display: 'flex' } } as any,
|
||||
});
|
||||
const config = wrapper.findComponent({ name: 'FakeMContainer' }).props('config') as any;
|
||||
const displayItem = config.items.find((it: any) => it.name === 'display');
|
||||
const values = displayItem.options.map((o: any) => o.value);
|
||||
expect(values).toEqual(['inline', 'flex', 'block', 'inline-block', 'none']);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import Position from '@editor/fields/StyleSetter/pro/Position.vue';
|
||||
|
||||
vi.mock('@tmagic/form', () => ({
|
||||
defineFormItem: (cfg: any) => cfg,
|
||||
MContainer: defineComponent({
|
||||
name: 'FakeMContainer',
|
||||
props: ['config', 'model', 'size', 'disabled'],
|
||||
emits: ['change'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: 'fake-mcontainer',
|
||||
onClick: () => emit('change', 'val', { propPath: 'p' }),
|
||||
},
|
||||
JSON.stringify(props.config?.items?.length || 0),
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('StyleSetter/Position.vue', () => {
|
||||
test('渲染 MContainer 并冒泡 change', async () => {
|
||||
const wrapper = mount(Position, {
|
||||
props: {
|
||||
values: { position: 'absolute' },
|
||||
} as any,
|
||||
});
|
||||
expect(wrapper.find('.fake-mcontainer').exists()).toBe(true);
|
||||
await wrapper.find('.fake-mcontainer').trigger('click');
|
||||
expect(wrapper.emitted('change')?.[0]).toEqual(['val', { propPath: 'p' }]);
|
||||
});
|
||||
|
||||
test('display 函数在 position 为 static 时返回 false', () => {
|
||||
const wrapper = mount(Position, {
|
||||
props: {
|
||||
values: { position: 'static' },
|
||||
} as any,
|
||||
});
|
||||
const config = wrapper.findComponent({ name: 'FakeMContainer' }).props('config') as any;
|
||||
const rowItems = config.items.filter((it: any) => it.type === 'row');
|
||||
expect(rowItems[0].display()).toBe(false);
|
||||
});
|
||||
|
||||
test('display 函数在 position 不为 static 时返回 true', () => {
|
||||
const wrapper = mount(Position, {
|
||||
props: {
|
||||
values: { position: 'absolute' },
|
||||
} as any,
|
||||
});
|
||||
const config = wrapper.findComponent({ name: 'FakeMContainer' }).props('config') as any;
|
||||
const rowItems = config.items.filter((it: any) => it.type === 'row');
|
||||
expect(rowItems[0].display()).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import BackgroundPosition from '@editor/fields/StyleSetter/components/BackgroundPosition.vue';
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicButton: defineComponent({
|
||||
name: 'TMagicButton',
|
||||
props: ['link', 'disabled'],
|
||||
emits: ['click'],
|
||||
setup(_props, { emit, slots }) {
|
||||
return () => h('button', { onClick: () => emit('click') }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
TMagicInput: defineComponent({
|
||||
name: 'TMagicInput',
|
||||
props: ['modelValue', 'placeholder', 'clearable', 'size', 'disabled'],
|
||||
emits: ['update:modelValue', 'change'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h('input', {
|
||||
value: props.modelValue,
|
||||
onChange: (e: Event) => {
|
||||
emit('update:modelValue', (e.target as HTMLInputElement).value);
|
||||
emit('change', (e.target as HTMLInputElement).value);
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('StyleSetter BackgroundPosition', () => {
|
||||
test('渲染 9 个预设位置按钮', () => {
|
||||
const wrapper = mount(BackgroundPosition, {
|
||||
props: { model: { backgroundPosition: '' }, name: 'backgroundPosition' } as any,
|
||||
});
|
||||
expect(wrapper.findAll('button').length).toBe(9);
|
||||
});
|
||||
|
||||
test('点击预设按钮 emit change', async () => {
|
||||
const wrapper = mount(BackgroundPosition, {
|
||||
props: { model: { backgroundPosition: '' }, name: 'backgroundPosition' } as any,
|
||||
});
|
||||
await wrapper.findAll('button')[0].trigger('click');
|
||||
const evts = wrapper.emitted('change');
|
||||
expect(evts?.[0]?.[0]).toBe('left top');
|
||||
});
|
||||
|
||||
test('输入框变化触发 change 事件', async () => {
|
||||
const wrapper = mount(BackgroundPosition, {
|
||||
props: { model: { backgroundPosition: '' }, name: 'backgroundPosition' } as any,
|
||||
});
|
||||
const input = wrapper.find('input');
|
||||
(input.element as HTMLInputElement).value = 'center bottom';
|
||||
await input.trigger('change');
|
||||
const evts = wrapper.emitted('change');
|
||||
expect(evts?.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import Border from '@editor/fields/StyleSetter/components/Border.vue';
|
||||
|
||||
vi.mock('@tmagic/form', () => ({
|
||||
defineFormItem: (cfg: any) => cfg,
|
||||
MContainer: defineComponent({
|
||||
name: 'MContainer',
|
||||
props: ['config', 'model', 'size', 'disabled'],
|
||||
emits: ['change'],
|
||||
setup(_props, { expose }) {
|
||||
expose({ trigger: () => null });
|
||||
return () => h('div', { class: 'm-container' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('StyleSetter Border', () => {
|
||||
test('点击 direction 图标更新 active 状态', async () => {
|
||||
const wrapper = mount(Border, { props: { model: {} } });
|
||||
const top = wrapper.find('.border-icon-top');
|
||||
await top.trigger('click');
|
||||
expect(top.classes()).toContain('active');
|
||||
const center = wrapper.find('.border-icon-container-row:nth-child(2) .border-icon:nth-child(2)');
|
||||
await center.trigger('click');
|
||||
expect(top.classes()).not.toContain('active');
|
||||
});
|
||||
|
||||
test('change 事件按 changeRecords 拆分发出', async () => {
|
||||
const wrapper = mount(Border, { props: { model: {} } });
|
||||
const container = wrapper.findComponent({ name: 'MContainer' });
|
||||
container.vm.$emit(
|
||||
'change',
|
||||
{},
|
||||
{
|
||||
changeRecords: [
|
||||
{ value: '1px', propPath: 'borderWidth' },
|
||||
{ value: 'red', propPath: 'borderColor' },
|
||||
],
|
||||
},
|
||||
);
|
||||
const events = wrapper.emitted('change');
|
||||
expect(events?.length).toBe(2);
|
||||
expect(events?.[0]).toEqual(['1px', { modifyKey: 'borderWidth' }]);
|
||||
expect(events?.[1]).toEqual(['red', { modifyKey: 'borderColor' }]);
|
||||
});
|
||||
|
||||
test('selectDirection 切换不同方向都生效', async () => {
|
||||
const wrapper = mount(Border, { props: { model: {} } });
|
||||
await wrapper.find('.border-icon-bottom').trigger('click');
|
||||
expect(wrapper.find('.border-icon-bottom').classes()).toContain('active');
|
||||
await wrapper.find('.border-icon-left').trigger('click');
|
||||
expect(wrapper.find('.border-icon-left').classes()).toContain('active');
|
||||
await wrapper.find('.border-icon-right').trigger('click');
|
||||
expect(wrapper.find('.border-icon-right').classes()).toContain('active');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import Box from '@editor/fields/StyleSetter/components/Box.vue';
|
||||
import Position from '@editor/fields/StyleSetter/components/Position.vue';
|
||||
|
||||
describe('StyleSetter Box', () => {
|
||||
test('渲染 8 个输入框', () => {
|
||||
const wrapper = mount(Box, { props: { model: {} } });
|
||||
expect(wrapper.findAll('input').length).toBe(8);
|
||||
});
|
||||
|
||||
test('change 事件携带值与对应字段名', async () => {
|
||||
const wrapper = mount(Box, { props: { model: { marginTop: '10' } } });
|
||||
const input = wrapper.findAll('input')[0];
|
||||
(input.element as HTMLInputElement).value = '20';
|
||||
await input.trigger('change');
|
||||
const events = wrapper.emitted('change');
|
||||
expect(events?.[0]?.[0]).toBe('20');
|
||||
expect((events?.[0]?.[1] as any).modifyKey).toBe('marginTop');
|
||||
});
|
||||
|
||||
test('disabled 时输入框被禁用', () => {
|
||||
const wrapper = mount(Box, { props: { model: {}, disabled: true } });
|
||||
const inputs = wrapper.findAll('input');
|
||||
inputs.forEach((i) => {
|
||||
expect((i.element as HTMLInputElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('StyleSetter Position', () => {
|
||||
test('渲染 4 个输入框', () => {
|
||||
const wrapper = mount(Position, { props: { model: {} } });
|
||||
expect(wrapper.findAll('input').length).toBe(4);
|
||||
});
|
||||
|
||||
test('change 事件触发并携带 modifyKey', async () => {
|
||||
const wrapper = mount(Position, { props: { model: { top: '0' } } });
|
||||
const input = wrapper.findAll('input')[1];
|
||||
(input.element as HTMLInputElement).value = '5px';
|
||||
await input.trigger('change');
|
||||
const events = wrapper.emitted('change');
|
||||
expect(events?.[0]?.[0]).toBe('5px');
|
||||
expect((events?.[0]?.[1] as any).modifyKey).toBe('right');
|
||||
});
|
||||
});
|
||||
66
packages/editor/tests/unit/fields/StyleSetter/icons.spec.ts
Normal file
66
packages/editor/tests/unit/fields/StyleSetter/icons.spec.ts
Normal file
@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import BgPosLeftBottom from '@editor/fields/StyleSetter/icons/background-position/LeftBottom.vue';
|
||||
import BgPosLeftCenter from '@editor/fields/StyleSetter/icons/background-position/LeftCenter.vue';
|
||||
import BgPosLeftTop from '@editor/fields/StyleSetter/icons/background-position/LeftTop.vue';
|
||||
import NoRepeat from '@editor/fields/StyleSetter/icons/background-repeat/NoRepeat.vue';
|
||||
import Repeat from '@editor/fields/StyleSetter/icons/background-repeat/Repeat.vue';
|
||||
import RepeatX from '@editor/fields/StyleSetter/icons/background-repeat/RepeatX.vue';
|
||||
import RepeatY from '@editor/fields/StyleSetter/icons/background-repeat/RepeatY.vue';
|
||||
import DisplayBlock from '@editor/fields/StyleSetter/icons/display/Block.vue';
|
||||
import DisplayFlex from '@editor/fields/StyleSetter/icons/display/Flex.vue';
|
||||
import DisplayInline from '@editor/fields/StyleSetter/icons/display/Inline.vue';
|
||||
import DisplayInlineBlock from '@editor/fields/StyleSetter/icons/display/InlineBlock.vue';
|
||||
import DisplayNone from '@editor/fields/StyleSetter/icons/display/None.vue';
|
||||
import FdColumn from '@editor/fields/StyleSetter/icons/flex-direction/Column.vue';
|
||||
import FdColumnReverse from '@editor/fields/StyleSetter/icons/flex-direction/ColumnReverse.vue';
|
||||
import FdRow from '@editor/fields/StyleSetter/icons/flex-direction/Row.vue';
|
||||
import FdRowReverse from '@editor/fields/StyleSetter/icons/flex-direction/RowReverse.vue';
|
||||
import JcCenter from '@editor/fields/StyleSetter/icons/justify-content/Center.vue';
|
||||
import JcFlexEnd from '@editor/fields/StyleSetter/icons/justify-content/FlexEnd.vue';
|
||||
import JcFlexStart from '@editor/fields/StyleSetter/icons/justify-content/FlexStart.vue';
|
||||
import JcSpaceAround from '@editor/fields/StyleSetter/icons/justify-content/SpaceAround.vue';
|
||||
import JcSpaceBetween from '@editor/fields/StyleSetter/icons/justify-content/SpaceBetween.vue';
|
||||
import TaCenter from '@editor/fields/StyleSetter/icons/text-align/Center.vue';
|
||||
import TaLeft from '@editor/fields/StyleSetter/icons/text-align/Left.vue';
|
||||
import TaRight from '@editor/fields/StyleSetter/icons/text-align/Right.vue';
|
||||
|
||||
describe('StyleSetter icons', () => {
|
||||
const icons = [
|
||||
['BgPosLeftBottom', BgPosLeftBottom],
|
||||
['BgPosLeftCenter', BgPosLeftCenter],
|
||||
['BgPosLeftTop', BgPosLeftTop],
|
||||
['NoRepeat', NoRepeat],
|
||||
['Repeat', Repeat],
|
||||
['RepeatX', RepeatX],
|
||||
['RepeatY', RepeatY],
|
||||
['DisplayBlock', DisplayBlock],
|
||||
['DisplayFlex', DisplayFlex],
|
||||
['DisplayInline', DisplayInline],
|
||||
['DisplayInlineBlock', DisplayInlineBlock],
|
||||
['DisplayNone', DisplayNone],
|
||||
['FdColumn', FdColumn],
|
||||
['FdColumnReverse', FdColumnReverse],
|
||||
['FdRow', FdRow],
|
||||
['FdRowReverse', FdRowReverse],
|
||||
['JcCenter', JcCenter],
|
||||
['JcFlexEnd', JcFlexEnd],
|
||||
['JcFlexStart', JcFlexStart],
|
||||
['JcSpaceAround', JcSpaceAround],
|
||||
['JcSpaceBetween', JcSpaceBetween],
|
||||
['TaCenter', TaCenter],
|
||||
['TaLeft', TaLeft],
|
||||
['TaRight', TaRight],
|
||||
];
|
||||
|
||||
test.each(icons)('%s 渲染 svg', (_name, comp) => {
|
||||
const wrapper = mount(comp as any);
|
||||
expect(wrapper.find('svg').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
91
packages/editor/tests/unit/fields/StyleSetter/pro.spec.ts
Normal file
91
packages/editor/tests/unit/fields/StyleSetter/pro.spec.ts
Normal file
@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import Background from '@editor/fields/StyleSetter/pro/Background.vue';
|
||||
import BorderPro from '@editor/fields/StyleSetter/pro/Border.vue';
|
||||
import Font from '@editor/fields/StyleSetter/pro/Font.vue';
|
||||
import Layout from '@editor/fields/StyleSetter/pro/Layout.vue';
|
||||
import Transform from '@editor/fields/StyleSetter/pro/Transform.vue';
|
||||
|
||||
vi.mock('@tmagic/form', () => ({
|
||||
defineFormItem: (cfg: any) => cfg,
|
||||
MContainer: defineComponent({
|
||||
name: 'MContainer',
|
||||
props: ['config', 'model', 'size', 'disabled'],
|
||||
emits: ['change'],
|
||||
setup() {
|
||||
return () => h('div', { class: 'm-container' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/fields/StyleSetter/components/Box.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'StyleBox',
|
||||
props: ['model', 'size', 'disabled'],
|
||||
emits: ['change'],
|
||||
setup() {
|
||||
return () => h('div', { class: 'fake-box' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/fields/StyleSetter/components/Border.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'StyleBorder',
|
||||
props: ['model', 'size', 'disabled'],
|
||||
emits: ['change'],
|
||||
setup() {
|
||||
return () => h('div', { class: 'fake-border' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/fields/StyleSetter/components/BackgroundPosition.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'BackgroundPosition',
|
||||
setup() {
|
||||
return () => h('div');
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('StyleSetter pro 组件', () => {
|
||||
test.each([
|
||||
['Background', Background],
|
||||
['BorderPro', BorderPro],
|
||||
['Font', Font],
|
||||
['Layout', Layout],
|
||||
['Transform', Transform],
|
||||
])('%s 渲染 MContainer 并透传 change', (_name, comp) => {
|
||||
const wrapper = mount(comp as any, { props: { values: { display: 'block' } } });
|
||||
const container = wrapper.findComponent({ name: 'MContainer' });
|
||||
expect(container.exists()).toBe(true);
|
||||
container.vm.$emit('change', { color: 'red' }, { modifyKey: 'color' });
|
||||
const events = wrapper.emitted('change');
|
||||
expect(events?.[0]?.[0]).toEqual({ color: 'red' });
|
||||
});
|
||||
|
||||
test('Layout 在 fixed/absolute 时隐藏 Box', () => {
|
||||
const wrapper = mount(Layout, { props: { values: { position: 'fixed' } as any } });
|
||||
const box = wrapper.find('.fake-box');
|
||||
expect((box.element as HTMLElement).style.display).toBe('none');
|
||||
});
|
||||
|
||||
test('Layout 非 fixed/absolute 时显示 Box', () => {
|
||||
const wrapper = mount(Layout, { props: { values: { position: 'relative' } as any } });
|
||||
const box = wrapper.find('.fake-box');
|
||||
expect((box.element as HTMLElement).style.display).not.toBe('none');
|
||||
});
|
||||
|
||||
test('BorderPro 渲染 Border 子组件', () => {
|
||||
const wrapper = mount(BorderPro, { props: { values: {} } });
|
||||
expect(wrapper.find('.fake-border').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
133
packages/editor/tests/unit/fields/UISelect.spec.ts
Normal file
133
packages/editor/tests/unit/fields/UISelect.spec.ts
Normal file
@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import UISelect from '@editor/fields/UISelect.vue';
|
||||
|
||||
const editorService = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
select: vi.fn(),
|
||||
highlight: vi.fn(),
|
||||
getNodeById: vi.fn((id: any) => ({ name: `name_${id}` })),
|
||||
};
|
||||
const stage = { select: vi.fn(), highlight: vi.fn(), clearHighlight: vi.fn() };
|
||||
const overlayStage = { select: vi.fn(), highlight: vi.fn(), clearHighlight: vi.fn() };
|
||||
const uiService = { set: vi.fn() };
|
||||
const stageOverlayService = { get: vi.fn(() => overlayStage) };
|
||||
|
||||
editorService.get.mockImplementation((k: string) => (k === 'stage' ? stage : null));
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService, uiService, stageOverlayService }),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||
return { ...actual, getIdFromEl: () => (el: any) => el?.dataset?.tmagicId };
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicButton: defineComponent({
|
||||
name: 'TMagicButton',
|
||||
props: ['type', 'icon', 'disabled', 'size', 'link'],
|
||||
emits: ['click', 'mouseenter', 'mouseleave'],
|
||||
setup(_p, { emit, slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
onClick: (e: Event) => emit('click', e),
|
||||
onMouseenter: () => emit('mouseenter'),
|
||||
onMouseleave: () => emit('mouseleave'),
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
TMagicTooltip: defineComponent({
|
||||
name: 'TMagicTooltip',
|
||||
props: ['content', 'placement'],
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-tooltip' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
editorService.get.mockImplementation((k: string) => (k === 'stage' ? stage : null));
|
||||
});
|
||||
|
||||
const baseProps = (extra: any = {}) => ({
|
||||
config: { type: 'ui-select' },
|
||||
name: 'to',
|
||||
prop: 'to',
|
||||
model: { to: '' },
|
||||
size: 'default',
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('UISelect', () => {
|
||||
test('val 为空时显示"点击此处选择"', () => {
|
||||
const wrapper = mount(UISelect, { props: baseProps() as any });
|
||||
expect(wrapper.html()).toContain('点击此处选择');
|
||||
});
|
||||
|
||||
test('val 存在时显示 toName', () => {
|
||||
const wrapper = mount(UISelect, { props: baseProps({ model: { to: 'n1' } }) as any });
|
||||
expect(wrapper.html()).toContain('name_n1_n1');
|
||||
});
|
||||
|
||||
test('startSelect 启用 uiSelectMode 并注册事件', async () => {
|
||||
const addSpy = vi.spyOn(globalThis.document, 'addEventListener');
|
||||
const wrapper = mount(UISelect, { props: baseProps() as any });
|
||||
await wrapper.find('button').trigger('click');
|
||||
expect(uiService.set).toHaveBeenCalledWith('uiSelectMode', true);
|
||||
expect(addSpy).toHaveBeenCalled();
|
||||
addSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('cancelHandler 关闭 uiSelectMode', async () => {
|
||||
const removeSpy = vi.spyOn(globalThis.document, 'removeEventListener');
|
||||
const wrapper = mount(UISelect, { props: baseProps() as any });
|
||||
await wrapper.find('button').trigger('click');
|
||||
await wrapper.find('.m-fields-ui-select').trigger('click');
|
||||
expect(uiService.set).toHaveBeenCalledWith('uiSelectMode', false);
|
||||
expect(removeSpy).toHaveBeenCalled();
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('deleteHandler 触发 emit("change","")', async () => {
|
||||
const wrapper = mount(UISelect, { props: baseProps({ model: { to: 'n1' } }) as any });
|
||||
const buttons = wrapper.findAll('button');
|
||||
await buttons[0].trigger('click');
|
||||
expect(wrapper.emitted('change')?.[0]?.[0]).toBe('');
|
||||
});
|
||||
|
||||
test('selectNode 调用 editorService.select 与 stage.select', async () => {
|
||||
const wrapper = mount(UISelect, { props: baseProps({ model: { to: 'n1' } }) as any });
|
||||
const buttons = wrapper.findAll('button');
|
||||
await buttons[1].trigger('click');
|
||||
expect(editorService.select).toHaveBeenCalledWith('n1');
|
||||
expect(stage.select).toHaveBeenCalledWith('n1');
|
||||
expect(overlayStage.select).toHaveBeenCalledWith('n1');
|
||||
});
|
||||
|
||||
test('highlight/unhighlight', async () => {
|
||||
const wrapper = mount(UISelect, { props: baseProps({ model: { to: 'n1' } }) as any });
|
||||
const buttons = wrapper.findAll('button');
|
||||
await buttons[1].trigger('mouseenter');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(editorService.highlight).toHaveBeenCalledWith('n1');
|
||||
await buttons[1].trigger('mouseleave');
|
||||
expect(editorService.set).toHaveBeenCalledWith('highlightNode', null);
|
||||
expect(stage.clearHighlight).toHaveBeenCalled();
|
||||
expect(overlayStage.clearHighlight).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
125
packages/editor/tests/unit/hooks/use-code-block-edit.spec.ts
Normal file
125
packages/editor/tests/unit/hooks/use-code-block-edit.spec.ts
Normal file
@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
|
||||
import { useCodeBlockEdit } from '@editor/hooks/use-code-block-edit';
|
||||
|
||||
const showMock = vi.fn();
|
||||
const hideMock = vi.fn();
|
||||
|
||||
vi.mock('@editor/components/CodeBlockEditor.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'CodeBlockEditorStub',
|
||||
setup(_, { expose }) {
|
||||
expose({ show: showMock, hide: hideMock });
|
||||
return () => h('div');
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
tMagicMessage: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mountHook = (codeBlockService: any) => {
|
||||
let captured: any;
|
||||
const comp = defineComponent({
|
||||
setup() {
|
||||
captured = useCodeBlockEdit(codeBlockService);
|
||||
return () => h(CodeBlockEditor as any, { ref: 'codeBlockEditor' });
|
||||
},
|
||||
});
|
||||
mount(comp);
|
||||
return captured;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useCodeBlockEdit', () => {
|
||||
test('createCodeBlock 设置默认配置并取得新 id', async () => {
|
||||
const codeBlockService: any = {
|
||||
getUniqueId: vi.fn(async () => 'id-1'),
|
||||
};
|
||||
const hook = mountHook(codeBlockService);
|
||||
await hook.createCodeBlock();
|
||||
await nextTick();
|
||||
expect(hook.codeId.value).toBe('id-1');
|
||||
expect(hook.codeConfig.value?.name).toBe('');
|
||||
expect(showMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('editCode - 找不到代码块时弹出错误', async () => {
|
||||
const codeBlockService: any = {
|
||||
getCodeContentById: vi.fn(async () => null),
|
||||
};
|
||||
const hook = mountHook(codeBlockService);
|
||||
await hook.editCode('xxx');
|
||||
const { tMagicMessage } = await import('@tmagic/design');
|
||||
expect(tMagicMessage.error).toHaveBeenCalledWith('获取代码块内容失败');
|
||||
expect(showMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('editCode - content 为字符串时直接使用', async () => {
|
||||
const codeBlockService: any = {
|
||||
getCodeContentById: vi.fn(async () => ({ name: 'a', content: 'hello' })),
|
||||
};
|
||||
const hook = mountHook(codeBlockService);
|
||||
await hook.editCode('id1');
|
||||
await nextTick();
|
||||
expect(hook.codeConfig.value?.content).toBe('hello');
|
||||
expect(showMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('editCode - content 为函数时转 toString', async () => {
|
||||
const fn = () => 1;
|
||||
const codeBlockService: any = {
|
||||
getCodeContentById: vi.fn(async () => ({ name: 'a', content: fn })),
|
||||
};
|
||||
const hook = mountHook(codeBlockService);
|
||||
await hook.editCode('id1');
|
||||
expect(hook.codeConfig.value?.content).toBe(fn.toString());
|
||||
});
|
||||
|
||||
test('editCode - content 为空字符串时不会出错', async () => {
|
||||
const codeBlockService: any = {
|
||||
getCodeContentById: vi.fn(async () => ({ name: 'a', content: '' })),
|
||||
};
|
||||
const hook = mountHook(codeBlockService);
|
||||
await hook.editCode('id1');
|
||||
expect(hook.codeConfig.value?.content).toBe('');
|
||||
});
|
||||
|
||||
test('deleteCode 调用 deleteCodeDslByIds', async () => {
|
||||
const deleteCodeDslByIds = vi.fn();
|
||||
const hook = mountHook({ deleteCodeDslByIds });
|
||||
await hook.deleteCode('k');
|
||||
expect(deleteCodeDslByIds).toHaveBeenCalledWith(['k']);
|
||||
});
|
||||
|
||||
test('submitCodeBlockHandler - 没有 codeId 时跳过', async () => {
|
||||
const setCodeDslById = vi.fn();
|
||||
const hook = mountHook({ setCodeDslById });
|
||||
await hook.submitCodeBlockHandler({ name: 'a' } as any);
|
||||
expect(setCodeDslById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('submitCodeBlockHandler - 提交后隐藏编辑器', async () => {
|
||||
const setCodeDslById = vi.fn();
|
||||
const hook = mountHook({ setCodeDslById });
|
||||
hook.codeId.value = 'id1';
|
||||
await hook.submitCodeBlockHandler({ name: 'b' } as any);
|
||||
expect(setCodeDslById).toHaveBeenCalledWith('id1', { name: 'b' });
|
||||
expect(hideMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
105
packages/editor/tests/unit/hooks/use-data-source-edit.spec.ts
Normal file
105
packages/editor/tests/unit/hooks/use-data-source-edit.spec.ts
Normal file
@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { useDataSourceEdit } from '@editor/hooks/use-data-source-edit';
|
||||
|
||||
vi.mock('@editor/layouts/sidebar/data-source/DataSourceConfigPanel.vue', () => ({
|
||||
default: { name: 'DataSourceConfigPanelStub', render: () => h('div') },
|
||||
}));
|
||||
|
||||
const mountHook = (dataSourceService: any) => {
|
||||
let captured: any;
|
||||
const comp = defineComponent({
|
||||
setup() {
|
||||
captured = useDataSourceEdit(dataSourceService);
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
mount(comp);
|
||||
return captured;
|
||||
};
|
||||
|
||||
describe('useDataSourceEdit', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("editable 取自 dataSourceService.get('editable')", () => {
|
||||
const ds: any = {
|
||||
get: vi.fn((k: string) => (k === 'editable' ? false : undefined)),
|
||||
getDataSourceById: vi.fn(),
|
||||
};
|
||||
const hook = mountHook(ds);
|
||||
expect(hook.editable.value).toBe(false);
|
||||
});
|
||||
|
||||
test('editHandler - editDialog 未就绪时直接返回', () => {
|
||||
const ds: any = {
|
||||
get: vi.fn(() => true),
|
||||
getDataSourceById: vi.fn(),
|
||||
};
|
||||
const hook = mountHook(ds);
|
||||
hook.editDialog.value = undefined;
|
||||
hook.editHandler('id1');
|
||||
expect(ds.getDataSourceById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('editHandler 加载数据源并显示弹窗', () => {
|
||||
const ds: any = {
|
||||
get: vi.fn(() => true),
|
||||
getDataSourceById: vi.fn(() => ({ id: 'id1', title: 'T' })),
|
||||
};
|
||||
const hook = mountHook(ds);
|
||||
const show = vi.fn();
|
||||
hook.editDialog.value = { show } as any;
|
||||
hook.editHandler('id1');
|
||||
expect(hook.dataSourceValues.value).toMatchObject({ id: 'id1', title: 'T' });
|
||||
expect(hook.dialogTitle.value).toBe('编辑T');
|
||||
expect(show).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('editHandler - 数据源不存在时使用空对象,title 不带名称', () => {
|
||||
const ds: any = {
|
||||
get: vi.fn(() => true),
|
||||
getDataSourceById: vi.fn(() => null),
|
||||
};
|
||||
const hook = mountHook(ds);
|
||||
hook.editDialog.value = { show: vi.fn() } as any;
|
||||
hook.editHandler('xx');
|
||||
expect(hook.dialogTitle.value).toBe('编辑');
|
||||
});
|
||||
|
||||
test('submitDataSourceHandler - 已存在 id 时调用 update', () => {
|
||||
const ds: any = {
|
||||
get: vi.fn(() => true),
|
||||
update: vi.fn(),
|
||||
add: vi.fn(),
|
||||
};
|
||||
const hook = mountHook(ds);
|
||||
const hide = vi.fn();
|
||||
hook.editDialog.value = { hide } as any;
|
||||
hook.submitDataSourceHandler({ id: 'i' } as any, { changeRecords: [] } as any);
|
||||
expect(ds.update).toHaveBeenCalled();
|
||||
expect(ds.add).not.toHaveBeenCalled();
|
||||
expect(hide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('submitDataSourceHandler - 没有 id 时调用 add', () => {
|
||||
const ds: any = {
|
||||
get: vi.fn(() => true),
|
||||
update: vi.fn(),
|
||||
add: vi.fn(),
|
||||
};
|
||||
const hook = mountHook(ds);
|
||||
hook.editDialog.value = { hide: vi.fn() } as any;
|
||||
hook.submitDataSourceHandler({} as any, {} as any);
|
||||
expect(ds.add).toHaveBeenCalled();
|
||||
expect(ds.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { defineComponent, h, nextTick, reactive } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height';
|
||||
|
||||
describe('useEditorContentHeight', () => {
|
||||
test('计算 framework 与 navMenu 高度差', async () => {
|
||||
const state = reactive({
|
||||
frameworkRect: { height: 800 },
|
||||
navMenuRect: { height: 60 },
|
||||
});
|
||||
let captured: any;
|
||||
const comp = defineComponent({
|
||||
setup() {
|
||||
captured = useEditorContentHeight();
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
mount(comp, {
|
||||
global: {
|
||||
provide: {
|
||||
services: {
|
||||
uiService: {
|
||||
get: (k: string) => (state as any)[k],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(captured.height.value).toBe(740);
|
||||
|
||||
state.navMenuRect.height = 100;
|
||||
await nextTick();
|
||||
expect(captured.height.value).toBe(700);
|
||||
});
|
||||
});
|
||||
62
packages/editor/tests/unit/hooks/use-filter.spec.ts
Normal file
62
packages/editor/tests/unit/hooks/use-filter.spec.ts
Normal file
@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useFilter } from '@editor/hooks/use-filter';
|
||||
|
||||
const buildStatusMap = (ids: string[]) => {
|
||||
const map = new Map<string, any>();
|
||||
ids.forEach((id) => {
|
||||
map.set(id, { visible: true, expand: false, selected: false, draggable: false });
|
||||
});
|
||||
return ref(map);
|
||||
};
|
||||
|
||||
describe('useFilter', () => {
|
||||
test('数据为空时直接返回', () => {
|
||||
const data = ref<any[]>([]);
|
||||
const map = ref<Map<string, any> | undefined>(new Map());
|
||||
const filterMethod = vi.fn(() => false);
|
||||
const { filterTextChangeHandler } = useFilter(data, map, filterMethod);
|
||||
filterTextChangeHandler('foo');
|
||||
expect(filterMethod).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('字符串数组中任一项匹配则节点可见', () => {
|
||||
const data = ref<any[]>([{ id: '1', name: 'a', items: [{ id: '2', name: 'b' }] }]);
|
||||
const map = buildStatusMap(['1', '2']);
|
||||
const filterMethod = (text: string, node: any) => node.name === text;
|
||||
const { filterTextChangeHandler } = useFilter(data, map as any, filterMethod);
|
||||
filterTextChangeHandler(['b']);
|
||||
expect(map.value.get('2').visible).toBe(true);
|
||||
expect(map.value.get('1').visible).toBe(true);
|
||||
});
|
||||
|
||||
test('未匹配时节点不可见', () => {
|
||||
const data = ref<any[]>([{ id: '1', name: 'a' }]);
|
||||
const map = buildStatusMap(['1']);
|
||||
const filterMethod = () => false;
|
||||
const { filterTextChangeHandler } = useFilter(data, map as any, filterMethod);
|
||||
filterTextChangeHandler('zzz');
|
||||
expect(map.value.get('1').visible).toBe(false);
|
||||
});
|
||||
|
||||
test('空字符串数组时所有节点可见', () => {
|
||||
const data = ref<any[]>([{ id: '1', name: 'a' }]);
|
||||
const map = buildStatusMap(['1']);
|
||||
const { filterTextChangeHandler } = useFilter(data, map as any, () => false);
|
||||
filterTextChangeHandler([]);
|
||||
expect(map.value.get('1').visible).toBe(true);
|
||||
});
|
||||
|
||||
test('nodeStatusMap 为 undefined 时不会更新', () => {
|
||||
const data = ref<any[]>([{ id: '1', name: 'a' }]);
|
||||
const map = ref<Map<string, any> | undefined>(undefined);
|
||||
const { filterTextChangeHandler } = useFilter(data, map as any, () => true);
|
||||
expect(() => filterTextChangeHandler('a')).not.toThrow();
|
||||
});
|
||||
});
|
||||
80
packages/editor/tests/unit/hooks/use-float-box.spec.ts
Normal file
80
packages/editor/tests/unit/hooks/use-float-box.spec.ts
Normal file
@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { computed, defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { useFloatBox } from '@editor/hooks/use-float-box';
|
||||
|
||||
const setup = (slideKeys: ReturnType<typeof computed<string[]>>) => {
|
||||
let captured: any;
|
||||
const comp = defineComponent({
|
||||
setup() {
|
||||
captured = useFloatBox(slideKeys);
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
const wrapper = mount(comp, {
|
||||
global: {
|
||||
provide: {
|
||||
services: {
|
||||
uiService: {
|
||||
get: (k: string) => (k === 'navMenuRect' ? { top: 5, height: 10 } : undefined),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return { wrapper, ...captured } as any;
|
||||
};
|
||||
|
||||
describe('useFloatBox', () => {
|
||||
test('初始化为每个 key 创建状态', () => {
|
||||
const keys = computed(() => ['a', 'b']);
|
||||
const { floatBoxStates } = setup(keys);
|
||||
expect(floatBoxStates.value.a).toMatchObject({ status: false, top: 0, left: 0 });
|
||||
expect(floatBoxStates.value.b).toMatchObject({ status: false, top: 0, left: 0 });
|
||||
});
|
||||
|
||||
test('未拖拽时 dragend 不会修改状态', () => {
|
||||
const keys = computed(() => ['a']);
|
||||
const { floatBoxStates, dragendHandler } = setup(keys);
|
||||
dragendHandler('a', { clientX: 100, clientY: 100 } as any);
|
||||
expect(floatBoxStates.value.a.status).toBe(false);
|
||||
});
|
||||
|
||||
test('拖拽距离超过阈值时打开 float box', () => {
|
||||
const keys = computed(() => ['a']);
|
||||
const { floatBoxStates, dragstartHandler, dragendHandler } = setup(keys);
|
||||
dragstartHandler({ clientX: 0, clientY: 0 } as any);
|
||||
dragendHandler('a', { clientX: 50, clientY: 50 } as any);
|
||||
expect(floatBoxStates.value.a).toMatchObject({ status: true, top: 15, left: 50 });
|
||||
});
|
||||
|
||||
test('拖拽距离不足时不会打开', () => {
|
||||
const keys = computed(() => ['a']);
|
||||
const { floatBoxStates, dragstartHandler, dragendHandler } = setup(keys);
|
||||
dragstartHandler({ clientX: 10, clientY: 10 } as any);
|
||||
dragendHandler('a', { clientX: 12, clientY: 12 } as any);
|
||||
expect(floatBoxStates.value.a.status).toBe(false);
|
||||
});
|
||||
|
||||
test('showingBoxKeys 反映当前打开状态', async () => {
|
||||
const keys = computed(() => ['a', 'b']);
|
||||
const { floatBoxStates, showingBoxKeys } = setup(keys);
|
||||
floatBoxStates.value.a.status = true;
|
||||
await nextTick();
|
||||
expect(showingBoxKeys.value).toContain('a');
|
||||
});
|
||||
|
||||
test('slideKeys 增加时补齐状态', async () => {
|
||||
const keys = ref<string[]>(['a']);
|
||||
const { floatBoxStates } = setup(computed(() => keys.value));
|
||||
keys.value = ['a', 'b'];
|
||||
await nextTick();
|
||||
expect(floatBoxStates.value.b).toBeDefined();
|
||||
});
|
||||
});
|
||||
79
packages/editor/tests/unit/hooks/use-getso.spec.ts
Normal file
79
packages/editor/tests/unit/hooks/use-getso.spec.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick, useTemplateRef } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { useGetSo } from '@editor/hooks/use-getso';
|
||||
|
||||
vi.mock('gesto', () => {
|
||||
const handlers = new Map<string, (...args: any[]) => void>();
|
||||
class FakeGesto {
|
||||
public on(event: string, fn: (...args: any[]) => void) {
|
||||
handlers.set(event, fn);
|
||||
return this;
|
||||
}
|
||||
public unset() {}
|
||||
}
|
||||
(FakeGesto as any).__handlers = handlers;
|
||||
return { default: FakeGesto };
|
||||
});
|
||||
|
||||
describe('useGetSo', () => {
|
||||
test('挂载后注册 drag/dragStart/dragEnd', async () => {
|
||||
const comp = defineComponent({
|
||||
setup() {
|
||||
const target = useTemplateRef<HTMLDivElement>('t');
|
||||
const emit = vi.fn();
|
||||
useGetSo(target as any, emit as any);
|
||||
return () => h('div', { ref: 't' });
|
||||
},
|
||||
});
|
||||
mount(comp);
|
||||
await nextTick();
|
||||
const gestoMod: any = (await import('gesto')).default;
|
||||
const handlers: Map<string, (...args: any[]) => void> = gestoMod.__handlers;
|
||||
expect(handlers.get('drag')).toBeTypeOf('function');
|
||||
expect(handlers.get('dragStart')).toBeTypeOf('function');
|
||||
expect(handlers.get('dragEnd')).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
test('drag 时 emit change,dragStart/dragEnd 切换 isDragging', async () => {
|
||||
let captured: any;
|
||||
const emit = vi.fn();
|
||||
const comp = defineComponent({
|
||||
setup() {
|
||||
const target = useTemplateRef<HTMLDivElement>('t');
|
||||
captured = useGetSo(target as any, emit as any);
|
||||
return () => h('div', { ref: 't' });
|
||||
},
|
||||
});
|
||||
mount(comp);
|
||||
await nextTick();
|
||||
const gestoMod: any = (await import('gesto')).default;
|
||||
const handlers: Map<string, (...args: any[]) => void> = gestoMod.__handlers;
|
||||
|
||||
handlers.get('dragStart')?.();
|
||||
expect(captured.isDragging.value).toBe(true);
|
||||
|
||||
handlers.get('drag')?.({ x: 1 });
|
||||
expect(emit).toHaveBeenCalledWith('change', { x: 1 });
|
||||
|
||||
handlers.get('dragEnd')?.();
|
||||
expect(captured.isDragging.value).toBe(false);
|
||||
});
|
||||
|
||||
test('target 为空时不会创建 Gesto', () => {
|
||||
const comp = defineComponent({
|
||||
setup() {
|
||||
const target = useTemplateRef<HTMLDivElement>('not-exist');
|
||||
useGetSo(target as any, vi.fn());
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
expect(() => mount(comp)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useNextFloatBoxPosition } from '@editor/hooks/use-next-float-box-position';
|
||||
|
||||
describe('useNextFloatBoxPosition', () => {
|
||||
const makeUiService = () =>
|
||||
({
|
||||
get: (k: string) => {
|
||||
if (k === 'columnWidth') return { left: 200 };
|
||||
if (k === 'navMenuRect') return { top: 10, height: 50 };
|
||||
return undefined;
|
||||
},
|
||||
}) as any;
|
||||
|
||||
test('未传 parent 时使用 columnWidth.left', () => {
|
||||
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(makeUiService());
|
||||
calcBoxPosition();
|
||||
expect(boxPosition.value).toEqual({ left: 200, top: 60 });
|
||||
});
|
||||
|
||||
test('parent 存在时使用其右侧坐标', () => {
|
||||
const fakeEl = { getBoundingClientRect: () => ({ left: 30, width: 70 }) } as any;
|
||||
const parent = ref<HTMLDivElement | null>(fakeEl);
|
||||
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(makeUiService(), parent);
|
||||
calcBoxPosition();
|
||||
expect(boxPosition.value).toEqual({ left: 100, top: 60 });
|
||||
});
|
||||
|
||||
test('parent 为空 ref 时回退 columnWidth', () => {
|
||||
const parent = ref<HTMLDivElement | null>(null);
|
||||
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(makeUiService(), parent);
|
||||
calcBoxPosition();
|
||||
expect(boxPosition.value).toEqual({ left: 200, top: 60 });
|
||||
});
|
||||
|
||||
test('columnWidth.left 缺失回退 0', () => {
|
||||
const ui = {
|
||||
get: (k: string) => {
|
||||
if (k === 'columnWidth') return {};
|
||||
if (k === 'navMenuRect') return { top: 0, height: 0 };
|
||||
return undefined;
|
||||
},
|
||||
} as any;
|
||||
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(ui);
|
||||
calcBoxPosition();
|
||||
expect(boxPosition.value).toEqual({ left: 0, top: 0 });
|
||||
});
|
||||
});
|
||||
36
packages/editor/tests/unit/hooks/use-node-status.spec.ts
Normal file
36
packages/editor/tests/unit/hooks/use-node-status.spec.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
|
||||
import { useNodeStatus } from '@editor/hooks/use-node-status';
|
||||
|
||||
describe('useNodeStatus', () => {
|
||||
test('初始化生成节点状态', () => {
|
||||
const data = ref<any[]>([{ id: '1', items: [{ id: '2' }] }]);
|
||||
const { nodeStatusMap } = useNodeStatus(computed(() => data.value));
|
||||
expect(nodeStatusMap.value.has('1')).toBe(true);
|
||||
expect(nodeStatusMap.value.has('2')).toBe(true);
|
||||
expect(nodeStatusMap.value.get('1')).toMatchObject({ visible: true, expand: false });
|
||||
});
|
||||
|
||||
test('数据变化时复用旧状态', async () => {
|
||||
const data = ref<any[]>([{ id: '1' }]);
|
||||
const { nodeStatusMap } = useNodeStatus(computed(() => data.value));
|
||||
nodeStatusMap.value.get('1').selected = true;
|
||||
|
||||
data.value = [{ id: '1' }, { id: '2' }];
|
||||
await nextTick();
|
||||
expect(nodeStatusMap.value.get('1').selected).toBe(true);
|
||||
expect(nodeStatusMap.value.has('2')).toBe(true);
|
||||
});
|
||||
|
||||
test('空数据时为空 Map', () => {
|
||||
const data = ref<any[]>([]);
|
||||
const { nodeStatusMap } = useNodeStatus(computed(() => data.value));
|
||||
expect(nodeStatusMap.value.size).toBe(0);
|
||||
});
|
||||
});
|
||||
39
packages/editor/tests/unit/hooks/use-services.spec.ts
Normal file
39
packages/editor/tests/unit/hooks/use-services.spec.ts
Normal file
@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { useServices } from '@editor/hooks/use-services';
|
||||
|
||||
describe('useServices', () => {
|
||||
test('在没有 provide 时抛错', () => {
|
||||
const comp = defineComponent({
|
||||
setup() {
|
||||
useServices();
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
expect(() => mount(comp)).toThrow('services is required');
|
||||
});
|
||||
|
||||
test('能取出 inject 的 services', () => {
|
||||
let services: any;
|
||||
const comp = defineComponent({
|
||||
setup() {
|
||||
services = useServices();
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
const fake = { editorService: {}, uiService: {} } as any;
|
||||
mount(comp, {
|
||||
global: {
|
||||
provide: { services: fake },
|
||||
},
|
||||
});
|
||||
expect(services).toBe(fake);
|
||||
});
|
||||
});
|
||||
222
packages/editor/tests/unit/hooks/use-stage.spec.ts
Normal file
222
packages/editor/tests/unit/hooks/use-stage.spec.ts
Normal file
@ -0,0 +1,222 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { useStage } from '@editor/hooks/use-stage';
|
||||
import editorService from '@editor/services/editor';
|
||||
import uiService from '@editor/services/ui';
|
||||
|
||||
const { stageInstance, StageCoreCtor, getIdFromElFn } = vi.hoisted(() => {
|
||||
const handlers: Record<string, ((..._args: any[]) => any)[]> = {};
|
||||
const fakeStage = {
|
||||
mask: {
|
||||
setGuides: vi.fn(),
|
||||
horizontalGuidelines: [],
|
||||
verticalGuidelines: [],
|
||||
},
|
||||
on: vi.fn((evt: string, fn: any) => {
|
||||
handlers[evt] ||= [];
|
||||
handlers[evt].push(fn);
|
||||
}),
|
||||
select: vi.fn(),
|
||||
disableMultiSelect: vi.fn(),
|
||||
enableMultiSelect: vi.fn(),
|
||||
setAlwaysMultiSelect: vi.fn(),
|
||||
handlers,
|
||||
};
|
||||
const ctor: any = vi.fn(function (this: any, opts: any) {
|
||||
Object.assign(this, fakeStage, { opts });
|
||||
return this;
|
||||
});
|
||||
const getIdFn = vi.fn(() => (el: any) => el?.id || null);
|
||||
return { stageInstance: fakeStage, StageCoreCtor: ctor, getIdFromElFn: getIdFn };
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/stage', () => ({
|
||||
default: StageCoreCtor,
|
||||
GuidesType: { HORIZONTAL: 'h', VERTICAL: 'v' },
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||
return { ...actual, getIdFromEl: getIdFromElFn };
|
||||
});
|
||||
|
||||
const editorState: Record<string, any> = {
|
||||
root: { id: 'r1' },
|
||||
page: { id: 'p1' },
|
||||
node: { id: 'n1' },
|
||||
nodes: [{ id: 'n1' }],
|
||||
parent: { id: 'parent1' },
|
||||
stage: null,
|
||||
disabledMultiSelect: false,
|
||||
alwaysMultiSelect: false,
|
||||
};
|
||||
|
||||
vi.mock('@editor/services/editor', () => ({
|
||||
default: {
|
||||
get: (k: string) => editorState[k],
|
||||
set: vi.fn((k: string, v: any) => {
|
||||
editorState[k] = v;
|
||||
}),
|
||||
select: vi.fn(),
|
||||
multiSelect: vi.fn(),
|
||||
highlight: vi.fn(),
|
||||
moveToContainer: vi.fn(),
|
||||
update: vi.fn(),
|
||||
sort: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
getNodeById: vi.fn((id: string) => ({ id })),
|
||||
},
|
||||
}));
|
||||
|
||||
const uiState: Record<string, any> = { zoom: 1, uiSelectMode: false };
|
||||
vi.mock('@editor/services/ui', () => ({
|
||||
default: {
|
||||
get: (k: string) => uiState[k],
|
||||
set: vi.fn((k: string, v: any) => {
|
||||
uiState[k] = v;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils/editor', () => ({
|
||||
buildChangeRecords: vi.fn(() => []),
|
||||
getGuideLineFromCache: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
const localStorageMock = {
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
getItem: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
StageCoreCtor.mockClear();
|
||||
Object.keys(stageInstance.handlers).forEach((k) => delete stageInstance.handlers[k]);
|
||||
vi.clearAllMocks();
|
||||
globalThis.localStorage = localStorageMock as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as any).localStorage;
|
||||
});
|
||||
|
||||
describe('useStage', () => {
|
||||
test('返回 stage 实例并注册事件', () => {
|
||||
const stage = useStage({ runtimeUrl: 'r' } as any);
|
||||
expect(stage).toBeDefined();
|
||||
expect(StageCoreCtor).toHaveBeenCalledTimes(1);
|
||||
expect(stageInstance.on).toHaveBeenCalledWith('select', expect.any(Function));
|
||||
expect(stageInstance.mask.setGuides).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('canSelect: 无 stageOptions.canSelect 时返回 true', () => {
|
||||
useStage({} as any);
|
||||
const opts = StageCoreCtor.mock.calls[0][0];
|
||||
expect(opts.canSelect({}, { type: 'click' }, () => null)).toBe(true);
|
||||
});
|
||||
|
||||
test('canSelect: uiSelectMode + mousedown + canSelect 触发自定义事件', () => {
|
||||
uiState.uiSelectMode = true;
|
||||
const dispatchSpy = vi.spyOn(document, 'dispatchEvent');
|
||||
const stop = vi.fn(() => 'stopped');
|
||||
useStage({ canSelect: () => true } as any);
|
||||
const opts = StageCoreCtor.mock.calls[0][0];
|
||||
const result = opts.canSelect({}, { type: 'mousedown' }, stop);
|
||||
expect(dispatchSpy).toHaveBeenCalled();
|
||||
expect(stop).toHaveBeenCalled();
|
||||
expect(result).toBe('stopped');
|
||||
uiState.uiSelectMode = false;
|
||||
});
|
||||
|
||||
test('select 事件: 触发 editorService.select', () => {
|
||||
useStage({} as any);
|
||||
stageInstance.handlers.select[0]({ id: 'newNode' });
|
||||
expect(editorService.select).toHaveBeenCalledWith('newNode');
|
||||
});
|
||||
|
||||
test('select 事件: 同 id 不再触发 select', () => {
|
||||
useStage({} as any);
|
||||
stageInstance.handlers.select[0]({ id: 'n1' });
|
||||
expect(editorService.select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('highlight 事件触发', () => {
|
||||
useStage({} as any);
|
||||
stageInstance.handlers.highlight[0]({ id: 'h1' });
|
||||
expect(editorService.highlight).toHaveBeenCalledWith('h1');
|
||||
});
|
||||
|
||||
test('multi-select 事件', () => {
|
||||
useStage({} as any);
|
||||
stageInstance.handlers['multi-select'][0]([{ id: 'a' }, { id: 'b' }, { id: null }]);
|
||||
expect(editorService.multiSelect).toHaveBeenCalledWith(['a', 'b']);
|
||||
});
|
||||
|
||||
test('update 事件 (parentEl 存在 - moveToContainer)', () => {
|
||||
useStage({} as any);
|
||||
stageInstance.handlers.update[0]({
|
||||
parentEl: { id: 'p_x' },
|
||||
data: [{ el: { id: 'c1' }, style: { left: 1 } }],
|
||||
});
|
||||
expect(editorService.moveToContainer).toHaveBeenCalledWith({ id: 'c1', style: { left: 1 } }, 'p_x');
|
||||
});
|
||||
|
||||
test('update 事件 (无 parentEl - update)', () => {
|
||||
useStage({} as any);
|
||||
stageInstance.handlers.update[0]({
|
||||
data: [{ el: { id: 'c1' }, style: { width: 10 } }],
|
||||
});
|
||||
expect(editorService.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('sort 事件', () => {
|
||||
useStage({} as any);
|
||||
stageInstance.handlers.sort[0]({ src: 'a', dist: 'b' });
|
||||
expect(editorService.sort).toHaveBeenCalledWith('a', 'b');
|
||||
});
|
||||
|
||||
test('remove 事件', () => {
|
||||
useStage({} as any);
|
||||
stageInstance.handlers.remove[0]({ data: [{ el: { id: 'a' } }, { el: { id: 'b' } }] });
|
||||
expect(editorService.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('select-parent 事件成功', () => {
|
||||
editorState.stage = { select: vi.fn() };
|
||||
useStage({} as any);
|
||||
stageInstance.handlers['select-parent'][0]();
|
||||
expect(editorService.select).toHaveBeenCalledWith({ id: 'parent1' });
|
||||
editorState.stage = null;
|
||||
});
|
||||
|
||||
test('select-parent 事件: parent 为空抛错', () => {
|
||||
editorState.parent = null;
|
||||
useStage({} as any);
|
||||
expect(() => stageInstance.handlers['select-parent'][0]()).toThrow('父节点为空');
|
||||
editorState.parent = { id: 'parent1' };
|
||||
});
|
||||
|
||||
test('change-guides 事件: 写入 localStorage', () => {
|
||||
useStage({} as any);
|
||||
stageInstance.handlers['change-guides'][0]({ type: 'h', guides: [10, 20] });
|
||||
expect(localStorageMock.setItem).toHaveBeenCalled();
|
||||
expect(uiService.set).toHaveBeenCalledWith('showGuides', true);
|
||||
});
|
||||
|
||||
test('change-guides 事件: 空 guides 删除 localStorage', () => {
|
||||
useStage({} as any);
|
||||
stageInstance.handlers['change-guides'][0]({ type: 'v', guides: [] });
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('page-el-update 事件 重置 stageLoading', () => {
|
||||
useStage({} as any);
|
||||
stageInstance.handlers['page-el-update'][0]();
|
||||
expect(editorService.set).toHaveBeenCalledWith('stageLoading', false);
|
||||
});
|
||||
});
|
||||
32
packages/editor/tests/unit/hooks/use-window-rect.spec.ts
Normal file
32
packages/editor/tests/unit/hooks/use-window-rect.spec.ts
Normal file
@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { useWindowRect } from '@editor/hooks/use-window-rect';
|
||||
|
||||
describe('useWindowRect', () => {
|
||||
test('返回当前 innerWidth/innerHeight,并随 resize 同步', async () => {
|
||||
let api: ReturnType<typeof useWindowRect> | undefined;
|
||||
const comp = defineComponent({
|
||||
setup() {
|
||||
api = useWindowRect();
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
const wrapper = mount(comp);
|
||||
expect(api?.rect.width).toBe(globalThis.innerWidth);
|
||||
expect(api?.rect.height).toBe(globalThis.innerHeight);
|
||||
|
||||
Object.defineProperty(globalThis, 'innerWidth', { configurable: true, value: 1234 });
|
||||
Object.defineProperty(globalThis, 'innerHeight', { configurable: true, value: 567 });
|
||||
globalThis.dispatchEvent(new Event('resize'));
|
||||
expect(api?.rect.width).toBe(1234);
|
||||
expect(api?.rect.height).toBe(567);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
28
packages/editor/tests/unit/icons/icons.spec.ts
Normal file
28
packages/editor/tests/unit/icons/icons.spec.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import AppManageIcon from '@editor/icons/AppManageIcon.vue';
|
||||
import CenterIcon from '@editor/icons/CenterIcon.vue';
|
||||
import CodeIcon from '@editor/icons/CodeIcon.vue';
|
||||
import FolderMinusIcon from '@editor/icons/FolderMinusIcon.vue';
|
||||
import PinIcon from '@editor/icons/PinIcon.vue';
|
||||
import PinnedIcon from '@editor/icons/PinnedIcon.vue';
|
||||
|
||||
describe('icons', () => {
|
||||
test.each([
|
||||
['AppManageIcon', AppManageIcon],
|
||||
['CenterIcon', CenterIcon],
|
||||
['CodeIcon', CodeIcon],
|
||||
['FolderMinusIcon', FolderMinusIcon],
|
||||
['PinIcon', PinIcon],
|
||||
['PinnedIcon', PinnedIcon],
|
||||
])('%s 渲染 svg 元素', (_name, comp) => {
|
||||
const wrapper = mount(comp as any);
|
||||
expect(wrapper.find('svg').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
480
packages/editor/tests/unit/initService.spec.ts
Normal file
480
packages/editor/tests/unit/initService.spec.ts
Normal file
@ -0,0 +1,480 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { DepTargetType } from '@tmagic/core';
|
||||
|
||||
import { initServiceEvents, initServiceState } from '@editor/initService';
|
||||
|
||||
const mkServices = () => {
|
||||
const handlers: Record<string, Record<string, any[]>> = {};
|
||||
const mkSvc = (name: string) => {
|
||||
handlers[name] = {};
|
||||
const svc = {
|
||||
on: vi.fn((event: string, cb: any) => {
|
||||
handlers[name][event] = handlers[name][event] || [];
|
||||
handlers[name][event].push(cb);
|
||||
}),
|
||||
off: vi.fn((event: string, cb: any) => {
|
||||
handlers[name][event] = (handlers[name][event] || []).filter((h) => h !== cb);
|
||||
}),
|
||||
emit: (event: string, ...args: any[]) => {
|
||||
(handlers[name][event] || []).forEach((cb) => cb(...args));
|
||||
},
|
||||
};
|
||||
return svc;
|
||||
};
|
||||
|
||||
const editorService: any = {
|
||||
...mkSvc('editor'),
|
||||
state: {} as any,
|
||||
set: vi.fn((k: string, v: any) => (editorService.state[k] = v)),
|
||||
get: vi.fn((k: string) => editorService.state[k]),
|
||||
select: vi.fn(),
|
||||
getNodeInfo: vi.fn(() => ({ page: { id: 'p1' } })),
|
||||
getNodeById: vi.fn(),
|
||||
getParentById: vi.fn(),
|
||||
resetState: vi.fn(),
|
||||
};
|
||||
const historyService: any = { ...mkSvc('history'), resetState: vi.fn() };
|
||||
const componentListService: any = {
|
||||
...mkSvc('componentList'),
|
||||
setList: vi.fn(),
|
||||
resetState: vi.fn(),
|
||||
};
|
||||
const propsService: any = {
|
||||
...mkSvc('props'),
|
||||
setPropsConfigs: vi.fn(),
|
||||
setPropsValues: vi.fn(),
|
||||
setDisabledCodeBlock: vi.fn(),
|
||||
setDisabledDataSource: vi.fn(),
|
||||
resetState: vi.fn(),
|
||||
};
|
||||
const eventsService: any = {
|
||||
...mkSvc('events'),
|
||||
setEvents: vi.fn(),
|
||||
setMethods: vi.fn(),
|
||||
};
|
||||
const uiService: any = {
|
||||
...mkSvc('ui'),
|
||||
set: vi.fn(),
|
||||
resetState: vi.fn(),
|
||||
};
|
||||
const codeBlockService: any = {
|
||||
...mkSvc('codeBlock'),
|
||||
setCodeDsl: vi.fn(),
|
||||
resetState: vi.fn(),
|
||||
};
|
||||
const keybindingService: any = { ...mkSvc('kb'), reset: vi.fn() };
|
||||
const dataSourceService: any = {
|
||||
...mkSvc('dataSource'),
|
||||
state: {} as any,
|
||||
set: vi.fn((k: string, v: any) => (dataSourceService.state[k] = v)),
|
||||
get: vi.fn((k: string) => dataSourceService.state[k]),
|
||||
setFormConfig: vi.fn(),
|
||||
setFormValue: vi.fn(),
|
||||
setFormEvent: vi.fn(),
|
||||
setFormMethod: vi.fn(),
|
||||
};
|
||||
const depService: any = {
|
||||
...mkSvc('dep'),
|
||||
addTarget: vi.fn(),
|
||||
removeTarget: vi.fn(),
|
||||
getTargets: vi.fn(() => ({})),
|
||||
getTarget: vi.fn(),
|
||||
hasTarget: vi.fn(() => false),
|
||||
clear: vi.fn(),
|
||||
clearTargets: vi.fn(),
|
||||
clearIdleTasks: vi.fn(),
|
||||
collectIdle: vi.fn(async () => undefined),
|
||||
collectByWorker: vi.fn(async () => undefined),
|
||||
reset: vi.fn(),
|
||||
};
|
||||
const stageOverlayService: any = mkSvc('stageOverlay');
|
||||
|
||||
return {
|
||||
editorService,
|
||||
historyService,
|
||||
componentListService,
|
||||
propsService,
|
||||
eventsService,
|
||||
uiService,
|
||||
codeBlockService,
|
||||
keybindingService,
|
||||
dataSourceService,
|
||||
depService,
|
||||
stageOverlayService,
|
||||
handlers,
|
||||
};
|
||||
};
|
||||
|
||||
vi.mock('@tmagic/core', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/core');
|
||||
return {
|
||||
...actual,
|
||||
createCodeBlockTarget: vi.fn((id: any, c: any) => ({
|
||||
id,
|
||||
type: actual.DepTargetType.CODE_BLOCK,
|
||||
deps: {},
|
||||
name: c?.name,
|
||||
})),
|
||||
createDataSourceTarget: vi.fn((ds: any) => ({ id: ds.id, type: actual.DepTargetType.DATA_SOURCE, deps: {} })),
|
||||
createDataSourceCondTarget: vi.fn((ds: any) => ({
|
||||
id: ds.id,
|
||||
type: actual.DepTargetType.DATA_SOURCE_COND,
|
||||
deps: {},
|
||||
})),
|
||||
createDataSourceMethodTarget: vi.fn((ds: any) => ({
|
||||
id: ds.id,
|
||||
type: actual.DepTargetType.DATA_SOURCE_METHOD,
|
||||
deps: {},
|
||||
})),
|
||||
updateNode: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||
return {
|
||||
...actual,
|
||||
getDepNodeIds: vi.fn(() => []),
|
||||
getNodes: vi.fn(() => []),
|
||||
isPage: vi.fn((n: any) => n?.type === 'page'),
|
||||
isValueIncludeDataSource: vi.fn((v: any) => /\$\{/.test(String(v))),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@editor/utils/editor', () => ({
|
||||
isIncludeDataSource: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
const Wrap = (props: any, services: any) =>
|
||||
defineComponent({
|
||||
setup() {
|
||||
initServiceState(props, services);
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
|
||||
const WrapEvents = (props: any, emit: any, services: any) =>
|
||||
defineComponent({
|
||||
setup() {
|
||||
initServiceEvents(props, emit, services);
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
|
||||
describe('initServiceState', () => {
|
||||
let services: ReturnType<typeof mkServices>;
|
||||
|
||||
beforeEach(() => {
|
||||
services = mkServices();
|
||||
});
|
||||
|
||||
test('modelValue 变化设置 editor root', () => {
|
||||
const props = { modelValue: { id: 'a' } } as any;
|
||||
mount(Wrap(props, services));
|
||||
expect(services.editorService.set).toHaveBeenCalledWith('root', { id: 'a' });
|
||||
});
|
||||
|
||||
test('disabledMultiSelect/alwaysMultiSelect 设置', () => {
|
||||
const props = { disabledMultiSelect: true, alwaysMultiSelect: true } as any;
|
||||
mount(Wrap(props, services));
|
||||
expect(services.editorService.set).toHaveBeenCalledWith('disabledMultiSelect', true);
|
||||
expect(services.editorService.set).toHaveBeenCalledWith('alwaysMultiSelect', true);
|
||||
});
|
||||
|
||||
test('componentGroupList 调用 setList', () => {
|
||||
const props = { componentGroupList: [{ items: [] }] } as any;
|
||||
mount(Wrap(props, services));
|
||||
expect(services.componentListService.setList).toHaveBeenCalledWith([{ items: [] }]);
|
||||
});
|
||||
|
||||
test('propsConfigs/propsValues 设置', () => {
|
||||
const props = { propsConfigs: { a: [] }, propsValues: { a: {} } } as any;
|
||||
mount(Wrap(props, services));
|
||||
expect(services.propsService.setPropsConfigs).toHaveBeenCalled();
|
||||
expect(services.propsService.setPropsValues).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('eventMethodList 设置 events/methods', () => {
|
||||
const props = {
|
||||
eventMethodList: { typeA: { events: [{ name: 'click' }], methods: [{ name: 'm' }] } },
|
||||
} as any;
|
||||
mount(Wrap(props, services));
|
||||
expect(services.eventsService.setEvents).toHaveBeenCalled();
|
||||
expect(services.eventsService.setMethods).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('datasourceConfigs 设置 form config', () => {
|
||||
const props = { datasourceConfigs: { http: [{ name: 'url' }] } } as any;
|
||||
mount(Wrap(props, services));
|
||||
expect(services.dataSourceService.setFormConfig).toHaveBeenCalledWith('http', [{ name: 'url' }]);
|
||||
});
|
||||
|
||||
test('datasourceValues 设置 form value', () => {
|
||||
const props = { datasourceValues: { base: { id: 'x' } } } as any;
|
||||
mount(Wrap(props, services));
|
||||
expect(services.dataSourceService.setFormValue).toHaveBeenCalledWith('base', { id: 'x' });
|
||||
});
|
||||
|
||||
test('datasourceEventMethodList 设置 form event/method', () => {
|
||||
const props = {
|
||||
datasourceEventMethodList: {
|
||||
http: { events: [{ name: 'load' }], methods: [{ name: 'do' }] },
|
||||
},
|
||||
} as any;
|
||||
mount(Wrap(props, services));
|
||||
expect(services.dataSourceService.setFormEvent).toHaveBeenCalledWith('http', [{ name: 'load' }]);
|
||||
expect(services.dataSourceService.setFormMethod).toHaveBeenCalledWith('http', [{ name: 'do' }]);
|
||||
});
|
||||
|
||||
test('defaultSelected 调用 select', () => {
|
||||
const props = { defaultSelected: 'n1' } as any;
|
||||
mount(Wrap(props, services));
|
||||
expect(services.editorService.select).toHaveBeenCalledWith('n1');
|
||||
});
|
||||
|
||||
test('stageRect 设置 ui state', () => {
|
||||
const props = { stageRect: { width: 100 } } as any;
|
||||
mount(Wrap(props, services));
|
||||
expect(services.uiService.set).toHaveBeenCalledWith('stageRect', { width: 100 });
|
||||
});
|
||||
|
||||
test('disabledCodeBlock/disabledDataSource', () => {
|
||||
const props = { disabledCodeBlock: true, disabledDataSource: true } as any;
|
||||
mount(Wrap(props, services));
|
||||
expect(services.propsService.setDisabledCodeBlock).toHaveBeenCalledWith(true);
|
||||
expect(services.propsService.setDisabledDataSource).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('卸载时重置所有 service', () => {
|
||||
const wrapper = mount(Wrap({} as any, services));
|
||||
wrapper.unmount();
|
||||
expect(services.editorService.resetState).toHaveBeenCalled();
|
||||
expect(services.historyService.resetState).toHaveBeenCalled();
|
||||
expect(services.propsService.resetState).toHaveBeenCalled();
|
||||
expect(services.uiService.resetState).toHaveBeenCalled();
|
||||
expect(services.componentListService.resetState).toHaveBeenCalled();
|
||||
expect(services.codeBlockService.resetState).toHaveBeenCalled();
|
||||
expect(services.keybindingService.reset).toHaveBeenCalled();
|
||||
expect(services.depService.reset).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initServiceEvents', () => {
|
||||
let services: ReturnType<typeof mkServices>;
|
||||
let emit: any;
|
||||
|
||||
beforeEach(() => {
|
||||
services = mkServices();
|
||||
emit = vi.fn();
|
||||
});
|
||||
|
||||
test('注册 editorService 事件', () => {
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
const events = services.editorService.on.mock.calls.map((c: any[]) => c[0]);
|
||||
expect(events).toContain('root-change');
|
||||
expect(events).toContain('add');
|
||||
expect(events).toContain('remove');
|
||||
expect(events).toContain('update');
|
||||
expect(events).toContain('history-change');
|
||||
});
|
||||
|
||||
test('注册 dataSourceService/codeBlockService/depService 事件', () => {
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
expect(services.dataSourceService.on.mock.calls.map((c: any[]) => c[0])).toEqual(
|
||||
expect.arrayContaining(['add', 'update', 'remove']),
|
||||
);
|
||||
expect(services.codeBlockService.on.mock.calls.map((c: any[]) => c[0])).toEqual(
|
||||
expect.arrayContaining(['addOrUpdate', 'remove']),
|
||||
);
|
||||
expect(services.depService.on.mock.calls.map((c: any[]) => c[0])).toEqual(
|
||||
expect.arrayContaining(['add-target', 'remove-target', 'ds-collected']),
|
||||
);
|
||||
});
|
||||
|
||||
test('rootChange 处理代码块和数据源', async () => {
|
||||
services.editorService.state.root = { id: 'r' };
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
const value: any = {
|
||||
id: 'r',
|
||||
codeBlocks: { c1: { name: 'a', content: '' } },
|
||||
dataSources: [{ id: 'd1', type: 'base' }],
|
||||
items: [],
|
||||
};
|
||||
services.editorService.emit('root-change', value, null);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(services.codeBlockService.setCodeDsl).toHaveBeenCalled();
|
||||
expect(services.dataSourceService.set).toHaveBeenCalledWith('dataSources', value.dataSources);
|
||||
expect(services.depService.clearTargets).toHaveBeenCalled();
|
||||
expect(services.depService.addTarget).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rootChange null 时直接返回', () => {
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
services.editorService.emit('root-change', null);
|
||||
expect(services.codeBlockService.setCodeDsl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('add 事件触发 collectIdle', async () => {
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
services.editorService.emit('add', [{ id: 'n', type: 'text' }]);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(services.depService.collectIdle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('remove 事件触发 depService.clear', () => {
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
services.editorService.emit('remove', [{ id: 'n' }]);
|
||||
expect(services.depService.clear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('update 事件 changeRecords 中包含数据源', async () => {
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
services.editorService.emit('update', [
|
||||
{
|
||||
newNode: { id: 'n1', type: 'text' },
|
||||
oldNode: { id: 'n1', type: 'text' },
|
||||
changeRecords: [{ propPath: 'props.value', value: '${ds.field}' }],
|
||||
},
|
||||
]);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(services.depService.collectIdle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('update 事件 changeRecords 为空走 normal', async () => {
|
||||
services.editorService.state.root = { id: 'r', items: [] };
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
services.editorService.emit('update', [
|
||||
{ newNode: { id: 'n1', type: 'text' }, oldNode: { id: 'n1', type: 'text' } },
|
||||
]);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(services.depService.collectIdle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('history-change 触发 collect', async () => {
|
||||
services.editorService.state.root = { id: 'r' };
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
services.editorService.emit('history-change', { id: 'p1', type: 'page' });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(services.depService.collectIdle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('dataSourceService add 触发 initDataSourceDepTarget', () => {
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
services.dataSourceService.emit('add', { id: 'd1', type: 'base' });
|
||||
expect(services.depService.addTarget).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('dataSourceService remove root 不存在时不报错', async () => {
|
||||
services.editorService.state.root = null;
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
services.dataSourceService.emit('remove', 'd1');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(services.depService.removeTarget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('dataSourceService update 修改 fields', async () => {
|
||||
services.editorService.state.root = { id: 'r', items: [{ id: 'a', type: 'text' }] };
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
services.dataSourceService.emit(
|
||||
'update',
|
||||
{ id: 'd1', type: 'base', fields: [], mocks: [], methods: [] },
|
||||
{ changeRecords: [{ propPath: 'fields' }] },
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(services.depService.removeTarget).toHaveBeenCalled();
|
||||
expect(services.depService.addTarget).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('codeBlockService addOrUpdate 新增/更新', () => {
|
||||
services.depService.hasTarget.mockReturnValueOnce(false).mockReturnValueOnce(true);
|
||||
services.depService.getTarget.mockReturnValue({ name: 'old' });
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
services.codeBlockService.emit('addOrUpdate', 'c1', { name: 'a' });
|
||||
expect(services.depService.addTarget).toHaveBeenCalled();
|
||||
services.codeBlockService.emit('addOrUpdate', 'c1', { name: 'b' });
|
||||
expect(services.depService.getTarget).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('codeBlockService remove', () => {
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
services.codeBlockService.emit('remove', 'c1');
|
||||
expect(services.depService.removeTarget).toHaveBeenCalledWith('c1', DepTargetType.CODE_BLOCK);
|
||||
});
|
||||
|
||||
test('depService add-target 设置 root.dataSourceDeps/CondDeps/MethodDeps', () => {
|
||||
services.editorService.state.root = { id: 'r' };
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
services.depService.emit('add-target', { id: 't1', type: DepTargetType.DATA_SOURCE, deps: {} });
|
||||
services.depService.emit('add-target', { id: 't2', type: DepTargetType.DATA_SOURCE_COND, deps: {} });
|
||||
services.depService.emit('add-target', { id: 't3', type: DepTargetType.DATA_SOURCE_METHOD, deps: {} });
|
||||
expect(services.editorService.state.root.dataSourceDeps).toHaveProperty('t1');
|
||||
expect(services.editorService.state.root.dataSourceCondDeps).toHaveProperty('t2');
|
||||
expect(services.editorService.state.root.dataSourceMethodDeps).toHaveProperty('t3');
|
||||
});
|
||||
|
||||
test('depService remove-target 清理 root deps', () => {
|
||||
services.editorService.state.root = {
|
||||
id: 'r',
|
||||
dataSourceDeps: { a: {} },
|
||||
dataSourceCondDeps: { b: {} },
|
||||
dataSourceMethodDeps: { c: {} },
|
||||
};
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
services.depService.emit('remove-target', 'a', DepTargetType.DATA_SOURCE);
|
||||
services.depService.emit('remove-target', 'b', DepTargetType.DATA_SOURCE_COND);
|
||||
services.depService.emit('remove-target', 'c', DepTargetType.DATA_SOURCE_METHOD);
|
||||
expect(services.editorService.state.root.dataSourceDeps).not.toHaveProperty('a');
|
||||
expect(services.editorService.state.root.dataSourceCondDeps).not.toHaveProperty('b');
|
||||
expect(services.editorService.state.root.dataSourceMethodDeps).not.toHaveProperty('c');
|
||||
});
|
||||
|
||||
test('卸载时取消所有事件订阅', () => {
|
||||
const wrapper = mount(WrapEvents({} as any, emit, services));
|
||||
wrapper.unmount();
|
||||
expect(services.editorService.off).toHaveBeenCalled();
|
||||
expect(services.codeBlockService.off).toHaveBeenCalled();
|
||||
expect(services.dataSourceService.off).toHaveBeenCalled();
|
||||
expect(services.depService.off).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('runtimeUrl 变化时重新加载 iframe', async () => {
|
||||
const stage = {
|
||||
reloadIframe: vi.fn(),
|
||||
renderer: {
|
||||
once: vi.fn((event: string, cb: any) => {
|
||||
cb({
|
||||
updateRootConfig: vi.fn(),
|
||||
updatePageId: vi.fn(),
|
||||
});
|
||||
}),
|
||||
},
|
||||
select: vi.fn(),
|
||||
};
|
||||
services.editorService.state.stage = stage;
|
||||
services.editorService.state.page = { id: 'p1' };
|
||||
services.editorService.state.node = { id: 'n1' };
|
||||
|
||||
const hostComp = defineComponent({
|
||||
props: { runtimeUrl: { type: String, default: '' } },
|
||||
setup(props) {
|
||||
initServiceEvents(props as any, emit, services as any);
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mount(hostComp);
|
||||
await wrapper.setProps({ runtimeUrl: 'http://x' });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(stage.reloadIframe).toHaveBeenCalledWith('http://x');
|
||||
});
|
||||
|
||||
// 因 services 中 editor.state 不是 reactive,stage watch 不会触发,跳过该测试场景
|
||||
});
|
||||
65
packages/editor/tests/unit/layouts/AddPageBox.spec.ts
Normal file
65
packages/editor/tests/unit/layouts/AddPageBox.spec.ts
Normal file
@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import AddPageBox from '@editor/layouts/AddPageBox.vue';
|
||||
|
||||
vi.mock('@tmagic/core', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/core');
|
||||
return {
|
||||
...actual,
|
||||
NodeType: { PAGE: 'page', PAGE_FRAGMENT: 'page-fragment' },
|
||||
};
|
||||
});
|
||||
|
||||
const editorService = {
|
||||
get: vi.fn((key: string) => (key === 'root' ? { items: [] } : undefined)),
|
||||
add: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils', () => ({
|
||||
generatePageNameByApp: vi.fn(() => 'page_1'),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: { name: 'MIcon', props: ['icon'], render: () => null },
|
||||
}));
|
||||
|
||||
describe('AddPageBox', () => {
|
||||
test('点击新增页面调用 editorService.add', async () => {
|
||||
editorService.get.mockReturnValue({ items: [] });
|
||||
const wrapper = mount(AddPageBox, { props: { disabledPageFragment: false } });
|
||||
const buttons = wrapper.findAll('.m-editor-empty-button');
|
||||
expect(buttons.length).toBe(2);
|
||||
await buttons[0].trigger('click');
|
||||
expect(editorService.add).toHaveBeenCalledWith({ type: 'page', name: 'page_1', items: [] });
|
||||
await buttons[1].trigger('click');
|
||||
expect(editorService.add).toHaveBeenCalledWith({
|
||||
type: 'page-fragment',
|
||||
name: 'page_1',
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('disabledPageFragment 为 true 时只渲染新增页面', () => {
|
||||
const wrapper = mount(AddPageBox, { props: { disabledPageFragment: true } });
|
||||
expect(wrapper.findAll('.m-editor-empty-button').length).toBe(1);
|
||||
});
|
||||
|
||||
test('root 为空时抛错', async () => {
|
||||
editorService.get.mockReturnValue(undefined);
|
||||
const wrapper = mount(AddPageBox, { props: { disabledPageFragment: false } });
|
||||
const buttons = wrapper.findAll('.m-editor-empty-button');
|
||||
await expect(async () => {
|
||||
await buttons[0].trigger('click');
|
||||
}).rejects.toThrowError('root 不能为空');
|
||||
});
|
||||
});
|
||||
275
packages/editor/tests/unit/layouts/CodeEditor.spec.ts
Normal file
275
packages/editor/tests/unit/layouts/CodeEditor.spec.ts
Normal file
@ -0,0 +1,275 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import CodeEditor from '@editor/layouts/CodeEditor.vue';
|
||||
|
||||
const {
|
||||
vsEditorInstance,
|
||||
vsDiffEditorInstance,
|
||||
monacoInstance,
|
||||
blurHandlers,
|
||||
contentChangeHandlers,
|
||||
diffContentChangeHandlers,
|
||||
} = vi.hoisted(() => ({
|
||||
vsEditorInstance: {
|
||||
getValue: vi.fn(() => 'editor-value'),
|
||||
setValue: vi.fn(),
|
||||
getPosition: vi.fn(() => ({ lineNumber: 1, column: 1 })),
|
||||
setPosition: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
layout: vi.fn(),
|
||||
setScrollTop: vi.fn(),
|
||||
revealLine: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
getOptions: vi.fn(() => ({ get: vi.fn(() => 20) })),
|
||||
onDidChangeModelContent: vi.fn(),
|
||||
onDidBlurEditorWidget: vi.fn(),
|
||||
updateOptions: vi.fn(),
|
||||
} as any,
|
||||
vsDiffEditorInstance: {
|
||||
getModifiedEditor: vi.fn(),
|
||||
getPosition: vi.fn(() => null),
|
||||
setPosition: vi.fn(),
|
||||
setModel: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
layout: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
updateOptions: vi.fn(),
|
||||
} as any,
|
||||
monacoInstance: {
|
||||
editor: {
|
||||
createModel: vi.fn(),
|
||||
EditorOption: { scrollBeyondLastLine: 1, padding: 2, lineHeight: 3 },
|
||||
},
|
||||
} as any,
|
||||
blurHandlers: [] as any[],
|
||||
contentChangeHandlers: [] as any[],
|
||||
diffContentChangeHandlers: [] as any[],
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils/monaco-editor', () => ({
|
||||
default: vi.fn(async () => monacoInstance),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils/config', () => ({
|
||||
getEditorConfig: vi.fn((k: string) => {
|
||||
if (k === 'parseDSL') return (s: string) => JSON.parse(s);
|
||||
if (k === 'customCreateMonacoEditor') {
|
||||
return (_m: any, _el: any, _opts: any) => vsEditorInstance;
|
||||
}
|
||||
if (k === 'customCreateMonacoDiffEditor') {
|
||||
return (_m: any, _el: any, _opts: any) => vsDiffEditorInstance;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicButton: defineComponent({
|
||||
name: 'TMagicButton',
|
||||
inheritAttrs: false,
|
||||
setup(_p, { slots, attrs }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
...attrs,
|
||||
class: ['fake-btn', (attrs as any).class].filter(Boolean).join(' '),
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'IconStub',
|
||||
props: ['icon'],
|
||||
setup() {
|
||||
return () => h('i', { class: 'fake-icon' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
class FakeResizeObserver {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
}
|
||||
(globalThis as any).ResizeObserver = FakeResizeObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
blurHandlers.length = 0;
|
||||
contentChangeHandlers.length = 0;
|
||||
diffContentChangeHandlers.length = 0;
|
||||
vsEditorInstance.onDidChangeModelContent.mockImplementation((cb: any) => {
|
||||
contentChangeHandlers.push(cb);
|
||||
});
|
||||
vsEditorInstance.onDidBlurEditorWidget.mockImplementation((cb: any) => {
|
||||
blurHandlers.push(cb);
|
||||
});
|
||||
const modifiedEditor = {
|
||||
getValue: vi.fn(() => 'modified-value'),
|
||||
onDidChangeModelContent: vi.fn((cb: any) => diffContentChangeHandlers.push(cb)),
|
||||
};
|
||||
vsDiffEditorInstance.getModifiedEditor.mockReturnValue(modifiedEditor);
|
||||
});
|
||||
|
||||
const flush = async () => {
|
||||
await nextTick();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
describe('CodeEditor', () => {
|
||||
test('挂载时初始化 monaco 编辑器', async () => {
|
||||
const wrapper = mount(CodeEditor, { props: { initValues: 'abc' } as any, attachTo: document.body });
|
||||
await flush();
|
||||
expect(wrapper.find('.fake-btn').exists()).toBe(true);
|
||||
expect(wrapper.emitted('initd')).toBeTruthy();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('disabledFullScreen 时不显示按钮', async () => {
|
||||
const wrapper = mount(CodeEditor, {
|
||||
props: { initValues: 'abc', disabledFullScreen: true } as any,
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flush();
|
||||
expect(wrapper.find('.magic-code-editor-full-screen-icon').exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('点击全屏按钮切换 fullScreen', async () => {
|
||||
const wrapper = mount(CodeEditor, { props: { initValues: 'abc' } as any, attachTo: document.body });
|
||||
await flush();
|
||||
await wrapper.find('.fake-btn').trigger('click');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(vsEditorInstance.layout).toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('blur 自动保存触发 save 事件', async () => {
|
||||
const wrapper = mount(CodeEditor, { props: { initValues: 'abc', autoSave: true } as any, attachTo: document.body });
|
||||
await flush();
|
||||
vsEditorInstance.getValue.mockReturnValue('new-value');
|
||||
blurHandlers.forEach((cb) => cb());
|
||||
expect(wrapper.emitted('save')?.[0]?.[0]).toBe('new-value');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('parse: true 时解析后再 emit save', async () => {
|
||||
const wrapper = mount(CodeEditor, {
|
||||
props: { initValues: '{}', autoSave: true, parse: true, language: 'json' } as any,
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flush();
|
||||
vsEditorInstance.getValue.mockReturnValue('{"foo":1}');
|
||||
blurHandlers.forEach((cb) => cb());
|
||||
expect(wrapper.emitted('save')?.[0]?.[0]).toEqual({ foo: 1 });
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('Ctrl+S 触发 save', async () => {
|
||||
const wrapper = mount(CodeEditor, { props: { initValues: 'abc' } as any, attachTo: document.body });
|
||||
await flush();
|
||||
const editorEl = wrapper.find('.magic-code-editor-content').element as HTMLDivElement;
|
||||
vsEditorInstance.getValue.mockReturnValue('save-content');
|
||||
const event = new KeyboardEvent('keydown', { keyCode: 83, ctrlKey: true } as any);
|
||||
editorEl.dispatchEvent(event);
|
||||
expect(wrapper.emitted('save')?.[0]?.[0]).toBe('save-content');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('diff 模式下创建 diff 编辑器', async () => {
|
||||
const wrapper = mount(CodeEditor, {
|
||||
props: { type: 'diff', initValues: 'a', modifiedValues: 'b' } as any,
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flush();
|
||||
expect(vsDiffEditorInstance.setModel).toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('autosize 时根据内容计算高度', async () => {
|
||||
const wrapper = mount(CodeEditor, {
|
||||
props: { initValues: 'a\nb\nc', autosize: { minRows: 1, maxRows: 10 } } as any,
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flush();
|
||||
contentChangeHandlers.forEach((cb) => cb());
|
||||
await flush();
|
||||
expect(true).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('options 变化时调用 updateOptions', async () => {
|
||||
const wrapper = mount(CodeEditor, {
|
||||
props: { initValues: 'abc', options: { tabSize: 2 } } as any,
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flush();
|
||||
await wrapper.setProps({ options: { tabSize: 4 } } as any);
|
||||
await flush();
|
||||
expect(vsEditorInstance.updateOptions).toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('initValues 改变时调用 setEditorValue', async () => {
|
||||
const wrapper = mount(CodeEditor, { props: { initValues: 'abc' } as any, attachTo: document.body });
|
||||
await flush();
|
||||
await wrapper.setProps({ initValues: 'xyz' } as any);
|
||||
await flush();
|
||||
expect(vsEditorInstance.setValue).toHaveBeenCalledWith('xyz');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('expose getEditor / focus / setEditorValue', async () => {
|
||||
vsEditorInstance.getValue.mockReturnValue('editor-value');
|
||||
const wrapper = mount(CodeEditor, { props: { initValues: 'abc' } as any, attachTo: document.body });
|
||||
await flush();
|
||||
expect((wrapper.vm as any).getEditor()).toBe(vsEditorInstance);
|
||||
expect((wrapper.vm as any).getVsEditor()).toBe(vsEditorInstance);
|
||||
(wrapper.vm as any).focus();
|
||||
expect(vsEditorInstance.focus).toHaveBeenCalled();
|
||||
expect((wrapper.vm as any).getEditorValue()).toBe('editor-value');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('卸载时 dispose', async () => {
|
||||
const wrapper = mount(CodeEditor, { props: { initValues: 'abc' } as any, attachTo: document.body });
|
||||
await flush();
|
||||
wrapper.unmount();
|
||||
expect(vsEditorInstance.dispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('toString 处理 javascript 对象', async () => {
|
||||
const wrapper = mount(CodeEditor, {
|
||||
props: { initValues: { a: 1 }, language: 'javascript' } as any,
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flush();
|
||||
expect(vsEditorInstance.setValue).toHaveBeenCalled();
|
||||
const callArg = vsEditorInstance.setValue.mock.calls[0][0];
|
||||
expect(callArg).toMatch(/^\(/);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('toString 处理 json 对象', async () => {
|
||||
const wrapper = mount(CodeEditor, {
|
||||
props: { initValues: { a: 1 }, language: 'json' } as any,
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flush();
|
||||
const callArg = vsEditorInstance.setValue.mock.calls[0][0];
|
||||
expect(JSON.parse(callArg)).toEqual({ a: 1 });
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
157
packages/editor/tests/unit/layouts/Framework.spec.ts
Normal file
157
packages/editor/tests/unit/layouts/Framework.spec.ts
Normal file
@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import Framework from '@editor/layouts/Framework.vue';
|
||||
|
||||
const editorService = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
};
|
||||
const uiService = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
};
|
||||
const storageService = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService, uiService, storageService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils/config', () => ({
|
||||
getEditorConfig: vi.fn(() => (s: string) => JSON.parse(s)),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/SplitView.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'SplitView',
|
||||
props: ['left', 'right', 'minLeft', 'minRight', 'minCenter', 'width'],
|
||||
emits: ['change'],
|
||||
setup(_p, { slots, expose, emit }) {
|
||||
expose({ updateWidth: vi.fn() });
|
||||
return () =>
|
||||
h('div', { class: 'fake-split-view' }, [
|
||||
slots.left?.(),
|
||||
slots.center?.(),
|
||||
slots.right?.(),
|
||||
h('button', {
|
||||
class: 'change-btn',
|
||||
onClick: () => emit('change', { left: 100, right: 200, center: 600 }),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/CodeEditor.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'CodeEditor',
|
||||
props: ['initValues', 'options'],
|
||||
emits: ['save'],
|
||||
setup(_p, { emit }) {
|
||||
return () => h('div', { class: 'fake-code-editor', onClick: () => emit('save', '{"id":"x"}') });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/AddPageBox.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'AddPageBox',
|
||||
props: ['disabledPageFragment'],
|
||||
setup() {
|
||||
return () => h('div', { class: 'fake-add-page-box' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/page-bar/PageBar.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'PageBar',
|
||||
props: ['disabledPageFragment'],
|
||||
setup() {
|
||||
return () => h('div', { class: 'fake-page-bar' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
class FakeResizeObserver {
|
||||
cb: any;
|
||||
constructor(cb: any) {
|
||||
this.cb = cb;
|
||||
}
|
||||
observe(el: any) {
|
||||
this.cb([{ contentRect: { width: 1000, height: 800, left: 0, top: 0 } }], this);
|
||||
void el;
|
||||
}
|
||||
disconnect() {}
|
||||
}
|
||||
(globalThis as any).ResizeObserver = FakeResizeObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
uiService.get.mockImplementation((k: string) => {
|
||||
if (k === 'columnWidth') return { left: 200, center: 600, right: 200 };
|
||||
if (k === 'showSrc') return false;
|
||||
if (k === 'frameworkRect') return { width: 1000, height: 800 };
|
||||
if (k === 'hideSlideBar') return false;
|
||||
return null;
|
||||
});
|
||||
editorService.get.mockImplementation((k: string) => {
|
||||
if (k === 'root') return { items: [] };
|
||||
if (k === 'page') return { id: 'p1' };
|
||||
if (k === 'pageLength') return 1;
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Framework', () => {
|
||||
test('渲染 SplitView 与 PageBar', () => {
|
||||
const wrapper = mount(Framework, { props: { disabledPageFragment: false } as any });
|
||||
expect(wrapper.find('.fake-split-view').exists()).toBe(true);
|
||||
expect(wrapper.find('.fake-page-bar').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('page 为空时显示 AddPageBox', () => {
|
||||
editorService.get.mockImplementation((k: string) => (k === 'page' ? null : { items: [] }));
|
||||
const wrapper = mount(Framework, { props: { disabledPageFragment: false } as any });
|
||||
expect(wrapper.find('.fake-add-page-box').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('showSrc 时显示 CodeEditor', () => {
|
||||
uiService.get.mockImplementation((k: string) => {
|
||||
if (k === 'showSrc') return true;
|
||||
if (k === 'columnWidth') return { left: 0, center: 0, right: 0 };
|
||||
if (k === 'frameworkRect') return { width: 1000, height: 800 };
|
||||
return null;
|
||||
});
|
||||
const wrapper = mount(Framework, { props: { disabledPageFragment: false } as any });
|
||||
expect(wrapper.find('.fake-code-editor').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('CodeEditor save 调用 editorService.set("root")', async () => {
|
||||
uiService.get.mockImplementation((k: string) => {
|
||||
if (k === 'showSrc') return true;
|
||||
if (k === 'columnWidth') return { left: 0, center: 0, right: 0 };
|
||||
if (k === 'frameworkRect') return { width: 1000, height: 800 };
|
||||
return null;
|
||||
});
|
||||
const wrapper = mount(Framework, { props: { disabledPageFragment: false } as any });
|
||||
await wrapper.find('.fake-code-editor').trigger('click');
|
||||
expect(editorService.set).toHaveBeenCalledWith('root', { id: 'x' });
|
||||
});
|
||||
|
||||
test('SplitView change 写入 uiService 和 storage', async () => {
|
||||
const wrapper = mount(Framework, { props: { disabledPageFragment: false } as any });
|
||||
await wrapper.find('.change-btn').trigger('click');
|
||||
expect(uiService.set).toHaveBeenCalledWith('columnWidth', { left: 100, right: 200, center: 600 });
|
||||
expect(storageService.setItem).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
176
packages/editor/tests/unit/layouts/NavMenu.spec.ts
Normal file
176
packages/editor/tests/unit/layouts/NavMenu.spec.ts
Normal file
@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import NavMenu from '@editor/layouts/NavMenu.vue';
|
||||
|
||||
const editorService = {
|
||||
get: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
};
|
||||
const historyService = { state: { canUndo: true, canRedo: true } };
|
||||
const uiService = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
zoom: vi.fn(),
|
||||
calcZoom: vi.fn(async () => 0.5),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService, historyService, uiService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/ToolButton.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'ToolButton',
|
||||
props: ['data'],
|
||||
setup(props) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: ['tool-btn', (props.data as any).className],
|
||||
onClick: () => (props.data as any).handler?.(),
|
||||
},
|
||||
(props.data as any).type === 'text' ? (props.data as any).text : '',
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/type', async () => {
|
||||
const actual = await vi.importActual<any>('@editor/type');
|
||||
return { ...actual, ColumnLayout: { LEFT: 'left', CENTER: 'center', RIGHT: 'right' } };
|
||||
});
|
||||
|
||||
class FakeResizeObserver {
|
||||
cb: any;
|
||||
constructor(cb: any) {
|
||||
this.cb = cb;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
}
|
||||
(globalThis as any).ResizeObserver = FakeResizeObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
uiService.get.mockImplementation((k: string) => {
|
||||
if (k === 'columnWidth') return { left: 100, center: 200, right: 100 };
|
||||
if (k === 'zoom') return 1;
|
||||
if (k === 'showGuides') return true;
|
||||
if (k === 'hasGuides') return true;
|
||||
if (k === 'showRule') return true;
|
||||
return null;
|
||||
});
|
||||
editorService.get.mockReturnValue({ id: 'n1', type: 'text' });
|
||||
});
|
||||
|
||||
describe('NavMenu', () => {
|
||||
test('支持 string 配置生成按钮', () => {
|
||||
const wrapper = mount(NavMenu, {
|
||||
props: {
|
||||
data: {
|
||||
left: ['delete', 'undo', 'redo'],
|
||||
center: ['/'],
|
||||
right: ['rule', 'guides'],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
expect(wrapper.findAll('.delete').length).toBe(1);
|
||||
expect(wrapper.findAll('.undo').length).toBe(1);
|
||||
expect(wrapper.findAll('.redo').length).toBe(1);
|
||||
expect(wrapper.findAll('.rule').length).toBe(1);
|
||||
expect(wrapper.findAll('.guides').length).toBe(1);
|
||||
});
|
||||
|
||||
test('zoom 配置生成多个按钮和文本', () => {
|
||||
const wrapper = mount(NavMenu, { props: { data: { left: ['zoom'] } } as any });
|
||||
expect(wrapper.findAll('.zoom-out').length).toBe(1);
|
||||
expect(wrapper.findAll('.zoom-in').length).toBe(1);
|
||||
expect(wrapper.findAll('.scale-to-original').length).toBe(1);
|
||||
expect(wrapper.findAll('.scale-to-fit').length).toBe(1);
|
||||
expect(wrapper.text()).toContain('100%');
|
||||
});
|
||||
|
||||
test('delete 按钮触发 editorService.remove', async () => {
|
||||
const wrapper = mount(NavMenu, { props: { data: { left: ['delete'] } } as any });
|
||||
await wrapper.find('.delete').trigger('click');
|
||||
expect(editorService.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('undo 按钮触发 editorService.undo', async () => {
|
||||
const wrapper = mount(NavMenu, { props: { data: { left: ['undo'] } } as any });
|
||||
await wrapper.find('.undo').trigger('click');
|
||||
expect(editorService.undo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('redo 按钮触发 editorService.redo', async () => {
|
||||
const wrapper = mount(NavMenu, { props: { data: { left: ['redo'] } } as any });
|
||||
await wrapper.find('.redo').trigger('click');
|
||||
expect(editorService.redo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('zoom-in/out 触发 uiService.zoom', async () => {
|
||||
const wrapper = mount(NavMenu, { props: { data: { left: ['zoom'] } } as any });
|
||||
await wrapper.find('.zoom-in').trigger('click');
|
||||
expect(uiService.zoom).toHaveBeenCalledWith(0.1);
|
||||
await wrapper.find('.zoom-out').trigger('click');
|
||||
expect(uiService.zoom).toHaveBeenCalledWith(-0.1);
|
||||
});
|
||||
|
||||
test('scale-to-original 触发 uiService.set zoom 1', async () => {
|
||||
const wrapper = mount(NavMenu, { props: { data: { left: ['zoom'] } } as any });
|
||||
await wrapper.find('.scale-to-original').trigger('click');
|
||||
expect(uiService.set).toHaveBeenCalledWith('zoom', 1);
|
||||
});
|
||||
|
||||
test('scale-to-fit 触发 calcZoom', async () => {
|
||||
const wrapper = mount(NavMenu, { props: { data: { left: ['zoom'] } } as any });
|
||||
await wrapper.find('.scale-to-fit').trigger('click');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(uiService.calcZoom).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rule 切换 showRule', async () => {
|
||||
const wrapper = mount(NavMenu, { props: { data: { left: ['rule'] } } as any });
|
||||
await wrapper.find('.rule').trigger('click');
|
||||
expect(uiService.set).toHaveBeenCalledWith('showRule', false);
|
||||
});
|
||||
|
||||
test('guides 切换 showGuides', async () => {
|
||||
const wrapper = mount(NavMenu, { props: { data: { left: ['guides'] } } as any });
|
||||
await wrapper.find('.guides').trigger('click');
|
||||
expect(uiService.set).toHaveBeenCalledWith('showGuides', false);
|
||||
});
|
||||
|
||||
test('hasGuides 为 false 时不渲染 guides 按钮', () => {
|
||||
uiService.get.mockImplementation((k: string) => {
|
||||
if (k === 'columnWidth') return { left: 100, center: 200, right: 100 };
|
||||
if (k === 'hasGuides') return false;
|
||||
if (k === 'zoom') return 1;
|
||||
return null;
|
||||
});
|
||||
const wrapper = mount(NavMenu, { props: { data: { left: ['guides'] } } as any });
|
||||
expect(wrapper.find('.guides').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('对象配置直接传递', () => {
|
||||
const wrapper = mount(NavMenu, {
|
||||
props: { data: { left: [{ type: 'button', className: 'custom', text: 'A' }] } } as any,
|
||||
});
|
||||
expect(wrapper.find('.custom').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('未知字符串生成 text 配置', () => {
|
||||
const wrapper = mount(NavMenu, { props: { data: { left: ['xxxxx'] } } as any });
|
||||
expect(wrapper.text()).toContain('xxxxx');
|
||||
});
|
||||
});
|
||||
113
packages/editor/tests/unit/layouts/page-bar/AddButton.spec.ts
Normal file
113
packages/editor/tests/unit/layouts/page-bar/AddButton.spec.ts
Normal file
@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import AddButton from '@editor/layouts/page-bar/AddButton.vue';
|
||||
|
||||
const editorService = {
|
||||
get: vi.fn(),
|
||||
add: vi.fn(),
|
||||
};
|
||||
|
||||
const uiService = {
|
||||
get: vi.fn(() => true),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService, uiService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils/editor', () => ({
|
||||
generatePageNameByApp: vi.fn(() => 'page_xxx'),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicPopover: defineComponent({
|
||||
name: 'FakeTMagicPopover',
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-popover' }, [slots.reference?.(), slots.default?.()]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeIcon',
|
||||
props: ['icon'],
|
||||
setup() {
|
||||
return () => h('i', { class: 'fake-icon' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/ToolButton.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeToolButton',
|
||||
props: ['data'],
|
||||
setup(props) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: 'tool-btn',
|
||||
onClick: () => (props.data as any).handler?.(),
|
||||
},
|
||||
(props.data as any).text,
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
uiService.get.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('AddButton.vue', () => {
|
||||
test('显示按钮渲染并支持新增页面', async () => {
|
||||
editorService.get.mockReturnValue({ items: [] });
|
||||
const wrapper = mount(AddButton);
|
||||
const btns = wrapper.findAll('.tool-btn');
|
||||
expect(btns.length).toBe(2);
|
||||
await btns[0].trigger('click');
|
||||
expect(editorService.add).toHaveBeenCalled();
|
||||
const args = editorService.add.mock.calls[0][0];
|
||||
expect(args.type).toBe('page');
|
||||
expect(args.name).toBe('page_xxx');
|
||||
});
|
||||
|
||||
test('点击新增页面片', async () => {
|
||||
editorService.get.mockReturnValue({ items: [] });
|
||||
const wrapper = mount(AddButton);
|
||||
const btns = wrapper.findAll('.tool-btn');
|
||||
await btns[1].trigger('click');
|
||||
const args = editorService.add.mock.calls[0][0];
|
||||
expect(args.type).toBe('page-fragment');
|
||||
});
|
||||
|
||||
test('root 不存在抛错且 add 不会被调用', async () => {
|
||||
editorService.get.mockReturnValue(null);
|
||||
const errorHandler = vi.fn();
|
||||
const wrapper = mount(AddButton, {
|
||||
global: {
|
||||
config: {
|
||||
errorHandler,
|
||||
},
|
||||
},
|
||||
});
|
||||
await wrapper.findAll('.tool-btn')[0].trigger('click');
|
||||
expect(errorHandler).toHaveBeenCalled();
|
||||
expect(editorService.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('showAddPageButton 为 false 时不渲染按钮', () => {
|
||||
uiService.get.mockReturnValue(false);
|
||||
const wrapper = mount(AddButton);
|
||||
expect(wrapper.find('.tool-btn').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
268
packages/editor/tests/unit/layouts/page-bar/PageBar.spec.ts
Normal file
268
packages/editor/tests/unit/layouts/page-bar/PageBar.spec.ts
Normal file
@ -0,0 +1,268 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import PageBar from '@editor/layouts/page-bar/PageBar.vue';
|
||||
|
||||
const editorState = {
|
||||
page: ref<any>({ id: 'p1' }),
|
||||
root: ref<any>({
|
||||
items: [
|
||||
{ id: 'p1', type: 'page', name: 'P1' },
|
||||
{ id: 'p2', type: 'page', name: 'P2' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
const editorService = {
|
||||
get: vi.fn((k: string) => (editorState as any)[k]?.value ?? null),
|
||||
select: vi.fn(),
|
||||
copy: vi.fn(),
|
||||
paste: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService }),
|
||||
}));
|
||||
|
||||
const containerRef = {
|
||||
itemsContainerWidth: ref(800),
|
||||
scroll: vi.fn(),
|
||||
scrollTo: vi.fn(),
|
||||
getTranslateLeft: vi.fn(() => 0),
|
||||
};
|
||||
|
||||
vi.mock('@editor/layouts/page-bar/PageBarScrollContainer.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakePageBarScrollContainer',
|
||||
props: ['pageBarSortOptions', 'length'],
|
||||
setup(_p, { slots, expose }) {
|
||||
expose({
|
||||
get itemsContainerWidth() {
|
||||
return containerRef.itemsContainerWidth.value;
|
||||
},
|
||||
scroll: containerRef.scroll,
|
||||
scrollTo: containerRef.scrollTo,
|
||||
getTranslateLeft: containerRef.getTranslateLeft,
|
||||
});
|
||||
return () => h('div', { class: 'fake-scroll-container' }, [slots.prepend?.(), slots.default?.()]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/page-bar/AddButton.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeAddButton',
|
||||
setup() {
|
||||
return () => h('div', { class: 'fake-add-btn' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/page-bar/PageList.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakePageList',
|
||||
props: ['list'],
|
||||
setup() {
|
||||
return () => h('div', { class: 'fake-page-list' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/page-bar/Search.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeSearch',
|
||||
props: ['query'],
|
||||
emits: ['update:query'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h('div', {
|
||||
class: 'fake-search',
|
||||
onClick: () => emit('update:query', { pageType: ['page'], keyword: 'p1' }),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/ToolButton.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeToolBtn',
|
||||
props: ['data'],
|
||||
setup(props) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: ['tool-btn', (props.data as any).text === '复制' ? 'copy' : 'remove'].filter(Boolean).join(' '),
|
||||
onClick: () => (props.data as any).handler?.(),
|
||||
},
|
||||
(props.data as any).text,
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicIcon: defineComponent({
|
||||
name: 'FakeIcon',
|
||||
setup(_p, { slots }) {
|
||||
return () => h('i', { class: 'fake-icon' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
TMagicPopover: defineComponent({
|
||||
name: 'FakePopover',
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-popover' }, [slots.reference?.(), slots.default?.()]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
containerRef.itemsContainerWidth.value = 800;
|
||||
containerRef.getTranslateLeft.mockReturnValue(0);
|
||||
editorState.page.value = { id: 'p1' };
|
||||
editorState.root.value = {
|
||||
items: [
|
||||
{ id: 'p1', type: 'page', name: 'P1' },
|
||||
{ id: 'p2', type: 'page', name: 'P2' },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const factory = (props: any = {}) =>
|
||||
mount(PageBar, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
disabledPageFragment: false,
|
||||
...props,
|
||||
} as any,
|
||||
});
|
||||
|
||||
describe('PageBar.vue', () => {
|
||||
test('渲染页面列表', () => {
|
||||
const wrapper = factory();
|
||||
const items = wrapper.findAll('.m-editor-page-bar-item').filter((w) => w.attributes('data-page-id'));
|
||||
expect(items.length).toBe(2);
|
||||
expect(items[0].classes()).toContain('active');
|
||||
});
|
||||
|
||||
test('点击 item 调用 select', async () => {
|
||||
const wrapper = factory();
|
||||
const items = wrapper.findAll('.m-editor-page-bar-item').filter((w) => w.attributes('data-page-id'));
|
||||
await items[1].trigger('click');
|
||||
expect(editorService.select).toHaveBeenCalledWith('p2');
|
||||
});
|
||||
|
||||
test('复制按钮调用 copy/paste', async () => {
|
||||
const wrapper = factory();
|
||||
const copyBtn = wrapper.findAll('.copy')[0];
|
||||
await copyBtn.trigger('click');
|
||||
expect(editorService.copy).toHaveBeenCalled();
|
||||
expect(editorService.paste).toHaveBeenCalledWith({ left: 0, top: 0 });
|
||||
});
|
||||
|
||||
test('删除按钮调用 remove', async () => {
|
||||
const wrapper = factory();
|
||||
const removeBtn = wrapper.findAll('.remove')[0];
|
||||
await removeBtn.trigger('click');
|
||||
expect(editorService.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('search 改变 query 影响 list', async () => {
|
||||
const wrapper = factory();
|
||||
await wrapper.find('.fake-search').trigger('click');
|
||||
await nextTick();
|
||||
const items = wrapper.findAll('.m-editor-page-bar-item').filter((w) => w.attributes('data-page-id'));
|
||||
expect(items.length).toBe(1);
|
||||
expect(items[0].attributes('data-page-id')).toBe('p1');
|
||||
});
|
||||
|
||||
test('过滤函数自定义', async () => {
|
||||
const filter = vi.fn(() => false);
|
||||
const wrapper = factory({ filterFunction: filter });
|
||||
await wrapper.find('.fake-search').trigger('click');
|
||||
await nextTick();
|
||||
const items = wrapper.findAll('.m-editor-page-bar-item').filter((w) => w.attributes('data-page-id'));
|
||||
expect(items.length).toBe(0);
|
||||
expect(filter).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('page 改变滚动到 end (最后一项)', async () => {
|
||||
const wrapper = factory();
|
||||
await nextTick();
|
||||
editorState.page.value = { id: 'p2' };
|
||||
await nextTick();
|
||||
expect(containerRef.scroll).toHaveBeenCalledWith('end');
|
||||
void wrapper;
|
||||
});
|
||||
|
||||
test('page 改变滚动到 start (第一项)', async () => {
|
||||
editorState.page.value = { id: 'p2' };
|
||||
const wrapper = factory();
|
||||
await nextTick();
|
||||
editorState.page.value = { id: 'p1' };
|
||||
await nextTick();
|
||||
expect(containerRef.scroll).toHaveBeenCalledWith('start');
|
||||
void wrapper;
|
||||
});
|
||||
|
||||
test('page 改变滚动到中间项', async () => {
|
||||
editorState.root.value = {
|
||||
items: [
|
||||
{ id: 'p1', type: 'page' },
|
||||
{ id: 'p2', type: 'page' },
|
||||
{ id: 'p3', type: 'page' },
|
||||
],
|
||||
};
|
||||
editorState.page.value = { id: 'p1' };
|
||||
const wrapper = factory();
|
||||
await nextTick();
|
||||
const items = wrapper.findAll('.m-editor-page-bar-item').filter((w) => w.attributes('data-page-id'));
|
||||
Object.defineProperty(items[0].element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, width: 100 }),
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(items[1].element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 1000, width: 100 }),
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(items[2].element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 2000, width: 100 }),
|
||||
configurable: true,
|
||||
});
|
||||
editorState.page.value = { id: 'p2' };
|
||||
await nextTick();
|
||||
expect(containerRef.scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('page 不存在时 watch 直接 return', async () => {
|
||||
const wrapper = factory();
|
||||
await nextTick();
|
||||
containerRef.scroll.mockClear();
|
||||
containerRef.scrollTo.mockClear();
|
||||
editorState.page.value = null;
|
||||
await nextTick();
|
||||
expect(containerRef.scroll).not.toHaveBeenCalled();
|
||||
expect(containerRef.scrollTo).not.toHaveBeenCalled();
|
||||
void wrapper;
|
||||
});
|
||||
|
||||
test('itemsContainerWidth 为 0 时 watch 直接 return', async () => {
|
||||
containerRef.itemsContainerWidth.value = 0;
|
||||
const wrapper = factory();
|
||||
await nextTick();
|
||||
containerRef.scroll.mockClear();
|
||||
containerRef.scrollTo.mockClear();
|
||||
editorState.page.value = { id: 'p2' };
|
||||
await nextTick();
|
||||
expect(containerRef.scroll).not.toHaveBeenCalled();
|
||||
expect(containerRef.scrollTo).not.toHaveBeenCalled();
|
||||
void wrapper;
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import PageBarScrollContainer from '@editor/layouts/page-bar/PageBarScrollContainer.vue';
|
||||
|
||||
const editorService = { sort: vi.fn() };
|
||||
const uiService = { get: vi.fn() };
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService, uiService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'IconStub',
|
||||
props: ['icon'],
|
||||
setup() {
|
||||
return () => h('i', { class: 'fake-icon' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { sortableInstances } = vi.hoisted(() => ({ sortableInstances: [] as any[] }));
|
||||
vi.mock('sortablejs', () => ({
|
||||
default: class FakeSortable {
|
||||
el: any;
|
||||
options: any;
|
||||
constructor(el: any, options: any) {
|
||||
this.el = el;
|
||||
this.options = options;
|
||||
sortableInstances.push(this);
|
||||
}
|
||||
toArray() {
|
||||
return ['a', 'b', 'c'];
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
class FakeResizeObserver {
|
||||
cb: any;
|
||||
constructor(cb: any) {
|
||||
this.cb = cb;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
}
|
||||
(globalThis as any).ResizeObserver = FakeResizeObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sortableInstances.length = 0;
|
||||
uiService.get.mockImplementation((k: string) => {
|
||||
if (k === 'showAddPageButton') return true;
|
||||
if (k === 'showPageListButton') return true;
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
const flush = async () => {
|
||||
await nextTick();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
describe('PageBarScrollContainer', () => {
|
||||
test('length 0 时不渲染 items 容器', async () => {
|
||||
const wrapper = mount(PageBarScrollContainer, {
|
||||
props: { length: 0 } as any,
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flush();
|
||||
expect(wrapper.find('.m-editor-page-bar-items').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('canScroll 为 true 时显示左右按钮', async () => {
|
||||
const wrapper = mount(PageBarScrollContainer, {
|
||||
props: { length: 5 } as any,
|
||||
attachTo: document.body,
|
||||
slots: { default: '<div style="width:1000px"></div>' },
|
||||
});
|
||||
Object.defineProperty(wrapper.find('.m-editor-page-bar').element, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 200,
|
||||
});
|
||||
Object.defineProperty(wrapper.find('.m-editor-page-bar-items').element, 'scrollWidth', {
|
||||
configurable: true,
|
||||
value: 1000,
|
||||
});
|
||||
(wrapper.vm as any).scroll('left');
|
||||
(wrapper.vm as any).scroll('right');
|
||||
(wrapper.vm as any).scroll('start');
|
||||
(wrapper.vm as any).scroll('end');
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test('scrollTo 限制最大最小值', async () => {
|
||||
const wrapper = mount(PageBarScrollContainer, {
|
||||
props: { length: 1 } as any,
|
||||
attachTo: document.body,
|
||||
slots: { default: '<div></div>' },
|
||||
});
|
||||
await flush();
|
||||
(wrapper.vm as any).scrollTo(100);
|
||||
(wrapper.vm as any).scrollTo(-100000);
|
||||
expect((wrapper.vm as any).getTranslateLeft()).toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('length > 1 时创建 Sortable', async () => {
|
||||
const wrapper = mount(PageBarScrollContainer, {
|
||||
props: { length: 3 } as any,
|
||||
attachTo: document.body,
|
||||
slots: { default: '<div></div>' },
|
||||
});
|
||||
await flush();
|
||||
expect(sortableInstances.length).toBeGreaterThan(0);
|
||||
void wrapper;
|
||||
});
|
||||
|
||||
test('Sortable onUpdate 调用 editorService.sort', async () => {
|
||||
const afterUpdate = vi.fn();
|
||||
const wrapper = mount(PageBarScrollContainer, {
|
||||
props: { length: 3, pageBarSortOptions: { afterUpdate } } as any,
|
||||
attachTo: document.body,
|
||||
slots: { default: '<div></div>' },
|
||||
});
|
||||
await flush();
|
||||
const opts = sortableInstances[0].options;
|
||||
await opts.onStart({ oldIndex: 0, newIndex: 1 });
|
||||
await opts.onUpdate({ oldIndex: 0, newIndex: 1 });
|
||||
expect(editorService.sort).toHaveBeenCalledWith('a', 'b');
|
||||
expect(afterUpdate).toHaveBeenCalled();
|
||||
void wrapper;
|
||||
});
|
||||
|
||||
test('Sortable onStart 触发 beforeStart 钩子', async () => {
|
||||
const beforeStart = vi.fn();
|
||||
const wrapper = mount(PageBarScrollContainer, {
|
||||
props: { length: 3, pageBarSortOptions: { beforeStart } } as any,
|
||||
attachTo: document.body,
|
||||
slots: { default: '<div></div>' },
|
||||
});
|
||||
await flush();
|
||||
const opts = sortableInstances[0].options;
|
||||
await opts.onStart({ oldIndex: 0, newIndex: 1 });
|
||||
expect(beforeStart).toHaveBeenCalled();
|
||||
void wrapper;
|
||||
});
|
||||
|
||||
test('length 减小时滚到 start', async () => {
|
||||
const wrapper = mount(PageBarScrollContainer, {
|
||||
props: { length: 3 } as any,
|
||||
attachTo: document.body,
|
||||
slots: { default: '<div></div>' },
|
||||
});
|
||||
await flush();
|
||||
await wrapper.setProps({ length: 1 });
|
||||
await flush();
|
||||
expect((wrapper.vm as any).getTranslateLeft()).toBe(0);
|
||||
});
|
||||
});
|
||||
109
packages/editor/tests/unit/layouts/page-bar/PageList.spec.ts
Normal file
109
packages/editor/tests/unit/layouts/page-bar/PageList.spec.ts
Normal file
@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import PageList from '@editor/layouts/page-bar/PageList.vue';
|
||||
|
||||
const editorService = {
|
||||
get: vi.fn(),
|
||||
select: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const uiService = {
|
||||
get: vi.fn(() => true),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService, uiService }),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicIcon: defineComponent({
|
||||
name: 'FakeTMagicIcon',
|
||||
setup(_p, { slots }) {
|
||||
return () => h('i', { class: 'fake-tmagic-icon' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
TMagicPopover: defineComponent({
|
||||
name: 'FakeTMagicPopover',
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-popover' }, [slots.reference?.(), slots.default?.()]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/ToolButton.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeToolButton',
|
||||
props: ['data'],
|
||||
setup(props) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: ['tool-btn', (props.data as any).className].filter(Boolean).join(' '),
|
||||
onClick: () => (props.data as any).handler?.(),
|
||||
},
|
||||
(props.data as any).text,
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
uiService.get.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('PageList.vue', () => {
|
||||
test('展示页面列表', () => {
|
||||
editorService.get.mockReturnValue({ id: 'p1' });
|
||||
const wrapper = mount(PageList, {
|
||||
props: {
|
||||
list: [
|
||||
{ id: 'p1', name: 'Page 1' } as any,
|
||||
{ id: 'p2', name: 'Page 2', devconfig: { tabName: 'Page2-Tab' } } as any,
|
||||
],
|
||||
},
|
||||
});
|
||||
const btns = wrapper.findAll('.tool-btn');
|
||||
expect(btns[0].text()).toBe('Page 1');
|
||||
expect(btns[0].classes()).toContain('active');
|
||||
expect(btns[1].text()).toBe('Page2-Tab');
|
||||
expect(btns[1].classes()).not.toContain('active');
|
||||
});
|
||||
|
||||
test('id 缺省 fallback', () => {
|
||||
editorService.get.mockReturnValue(null);
|
||||
const wrapper = mount(PageList, {
|
||||
props: {
|
||||
list: [{ id: 'p3' } as any],
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('.tool-btn').text()).toBe('p3');
|
||||
});
|
||||
|
||||
test('点击切换页面', async () => {
|
||||
editorService.get.mockReturnValue({ id: 'p1' });
|
||||
const wrapper = mount(PageList, {
|
||||
props: {
|
||||
list: [{ id: 'p2', name: 'Page 2' } as any],
|
||||
},
|
||||
});
|
||||
await wrapper.find('.tool-btn').trigger('click');
|
||||
expect(editorService.select).toHaveBeenCalledWith('p2');
|
||||
});
|
||||
|
||||
test('showPageListButton 为 false 时不渲染', () => {
|
||||
uiService.get.mockReturnValue(false);
|
||||
const wrapper = mount(PageList, {
|
||||
props: { list: [{ id: 'p1' } as any] },
|
||||
});
|
||||
expect(wrapper.find('.tool-btn').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
67
packages/editor/tests/unit/layouts/page-bar/Search.spec.ts
Normal file
67
packages/editor/tests/unit/layouts/page-bar/Search.spec.ts
Normal file
@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import Search from '@editor/layouts/page-bar/Search.vue';
|
||||
|
||||
vi.mock('@tmagic/form', () => ({
|
||||
createForm: (cfg: any) => cfg,
|
||||
MForm: defineComponent({
|
||||
name: 'FakeMForm',
|
||||
props: ['config', 'initValues'],
|
||||
emits: ['change'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: 'fake-mform',
|
||||
onClick: () => emit('change', { pageType: ['page'], keyword: 'kw' }),
|
||||
},
|
||||
'mform',
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeIcon',
|
||||
props: ['icon'],
|
||||
setup() {
|
||||
return () => h('i', { class: 'fake-icon' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Search.vue', () => {
|
||||
test('点击图标切换 visible,触发 search 事件', async () => {
|
||||
document.body.innerHTML = '<div class="m-editor-page-bar-tabs"></div>';
|
||||
const wrapper = mount(Search, {
|
||||
attachTo: document.body,
|
||||
props: { query: { pageType: [], keyword: '' } } as any,
|
||||
});
|
||||
await wrapper.find('.fake-icon').trigger('click');
|
||||
await nextTick();
|
||||
const form = document.querySelector('.fake-mform') as HTMLElement;
|
||||
expect(form).toBeTruthy();
|
||||
form.click();
|
||||
await nextTick();
|
||||
expect(wrapper.emitted('search')?.[0]?.[0]).toEqual({ pageType: ['page'], keyword: 'kw' });
|
||||
expect(wrapper.emitted('update:query')?.[0]?.[0]).toEqual({ pageType: ['page'], keyword: 'kw' });
|
||||
});
|
||||
|
||||
test('未点击 icon 时不渲染表单', () => {
|
||||
document.body.innerHTML = '<div class="m-editor-page-bar-tabs"></div>';
|
||||
mount(Search, {
|
||||
attachTo: document.body,
|
||||
props: { query: { pageType: [], keyword: '' } } as any,
|
||||
});
|
||||
expect(document.querySelector('.fake-mform')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
150
packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts
Normal file
150
packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts
Normal file
@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import FormPanel from '@editor/layouts/props-panel/FormPanel.vue';
|
||||
|
||||
const editorService = { get: vi.fn() };
|
||||
const uiService = { get: vi.fn() };
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService, uiService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/hooks/use-editor-content-height', () => ({
|
||||
useEditorContentHeight: () => ({ height: { value: 600 } }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'IconStub',
|
||||
props: ['icon'],
|
||||
setup() {
|
||||
return () => h('i', { class: 'fake-icon' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/CodeEditor.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'CodeEditor',
|
||||
props: ['height', 'initValues', 'options', 'parse'],
|
||||
emits: ['save'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h('div', {
|
||||
class: 'fake-code-editor',
|
||||
onClick: () => emit('save', { foo: 'bar' }),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicScrollbar: defineComponent({
|
||||
name: 'TMagicScrollbar',
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-scrollbar' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
TMagicButton: defineComponent({
|
||||
name: 'TMagicButton',
|
||||
inheritAttrs: true,
|
||||
setup(_p, { slots }) {
|
||||
return () => h('button', { class: 'fake-btn' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/form', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/form');
|
||||
return {
|
||||
...actual,
|
||||
MForm: defineComponent({
|
||||
name: 'MForm',
|
||||
props: ['config', 'initValues', 'extendState'],
|
||||
emits: ['change', 'error'],
|
||||
setup(_p, { expose, emit }) {
|
||||
const formState = { stage: null as any, services: null as any };
|
||||
expose({
|
||||
formState,
|
||||
submitForm: vi.fn(async () => ({ a: 1 })),
|
||||
});
|
||||
return () =>
|
||||
h('div', { class: 'fake-mform' }, [
|
||||
h('button', {
|
||||
class: 'change-btn',
|
||||
onClick: () => emit('change', { a: 1 }, { changeRecords: [] }),
|
||||
}),
|
||||
h('button', { class: 'err-btn', onClick: () => emit('error', new Error('e')) }),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
uiService.get.mockImplementation((k: string) => (k === 'propsPanelSize' ? 'small' : null));
|
||||
editorService.get.mockImplementation((k: string) => (k === 'stage' ? { id: 'stage' } : null));
|
||||
});
|
||||
|
||||
describe('FormPanel', () => {
|
||||
test('渲染 MForm', () => {
|
||||
const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any });
|
||||
expect(wrapper.findComponent({ name: 'MForm' }).exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('mounted 事件 emit', async () => {
|
||||
const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any });
|
||||
await nextTick();
|
||||
expect(wrapper.emitted('mounted')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('change 事件触发 submit', async () => {
|
||||
const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any });
|
||||
await wrapper.find('.change-btn').trigger('click');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(wrapper.emitted('submit')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('error 事件触发 form-error', async () => {
|
||||
const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any });
|
||||
await wrapper.find('.err-btn').trigger('click');
|
||||
expect(wrapper.emitted('form-error')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('disabledShowSrc 控制源码按钮', () => {
|
||||
const wrapper = mount(FormPanel, {
|
||||
props: { config: [], values: {}, disabledShowSrc: true } as any,
|
||||
});
|
||||
expect(wrapper.find('.fake-btn').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('点击源码按钮显示 CodeEditor', async () => {
|
||||
const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any });
|
||||
await wrapper.find('.fake-btn').trigger('click');
|
||||
expect(wrapper.find('.fake-code-editor').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('CodeEditor save 事件触发 submit (使用 codeValueKey)', async () => {
|
||||
const wrapper = mount(FormPanel, {
|
||||
props: { config: [], values: {}, codeValueKey: 'style' } as any,
|
||||
});
|
||||
await wrapper.find('.fake-btn').trigger('click');
|
||||
await wrapper.find('.fake-code-editor').trigger('click');
|
||||
expect(wrapper.emitted('submit')?.[0]?.[0]).toEqual({ style: { foo: 'bar' } });
|
||||
});
|
||||
|
||||
test('CodeEditor save 事件触发 submit (无 codeValueKey)', async () => {
|
||||
const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any });
|
||||
await wrapper.find('.fake-btn').trigger('click');
|
||||
await wrapper.find('.fake-code-editor').trigger('click');
|
||||
expect(wrapper.emitted('submit')?.[0]?.[0]).toEqual({ foo: 'bar' });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,219 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import PropsPanel from '@editor/layouts/props-panel/PropsPanel.vue';
|
||||
|
||||
const editorService = {
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
const uiService = { get: vi.fn() };
|
||||
const propsService = {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
getPropsConfig: vi.fn(async () => [{ name: 'x' }]),
|
||||
};
|
||||
const storageService = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService, uiService, propsService, storageService }),
|
||||
}));
|
||||
|
||||
const showStylePanel = ref(false);
|
||||
const showStylePanelToggleButton = ref(true);
|
||||
const toggleStylePanel = vi.fn((v: boolean) => {
|
||||
showStylePanel.value = v;
|
||||
});
|
||||
vi.mock('@editor/layouts/props-panel/use-style-panel', () => ({
|
||||
useStylePanel: () => ({ showStylePanel, showStylePanelToggleButton, toggleStylePanel }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@editor/utils');
|
||||
return { ...actual, styleTabConfig: { items: [] } };
|
||||
});
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'IconStub',
|
||||
props: ['icon'],
|
||||
setup() {
|
||||
return () => h('i', { class: 'fake-icon' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Resizer.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeResizer',
|
||||
emits: ['change'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h('div', {
|
||||
class: 'fake-resizer',
|
||||
onClick: () => emit('change', { deltaX: 50 }),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mountedHandlers: any[] = [];
|
||||
vi.mock('@editor/layouts/props-panel/FormPanel.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FormPanel',
|
||||
props: ['config', 'values', 'disabledShowSrc', 'extendState'],
|
||||
emits: ['submit', 'submit-error', 'form-error', 'mounted', 'unmounted'],
|
||||
setup(_p, { emit, expose }) {
|
||||
mountedHandlers.push(emit);
|
||||
expose({ configForm: { formState: { foo: 'bar' } } });
|
||||
return () =>
|
||||
h('div', { class: 'fake-form-panel' }, [
|
||||
h('button', {
|
||||
class: 'submit-btn',
|
||||
onClick: () =>
|
||||
emit(
|
||||
'submit',
|
||||
{ id: 'n1', style: { color: 'red', empty: '' } },
|
||||
{ changeRecords: [{ propPath: 'style.bg', value: '' }] },
|
||||
),
|
||||
}),
|
||||
h('button', { class: 'submit-err-btn', onClick: () => emit('submit-error', new Error('e')) }),
|
||||
h('button', { class: 'form-err-btn', onClick: () => emit('form-error', new Error('e')) }),
|
||||
h('button', { class: 'mounted-btn', onClick: () => emit('mounted', { proxy: true }) }),
|
||||
h('button', { class: 'unmounted-btn', onClick: () => emit('unmounted') }),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicButton: defineComponent({
|
||||
name: 'TMagicButton',
|
||||
inheritAttrs: false,
|
||||
setup(_p, { slots, attrs }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
...attrs,
|
||||
class: ['fake-btn', (attrs as any).class].filter(Boolean).join(' '),
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||
return { ...actual, setValueByKeyPath: vi.fn() };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mountedHandlers.length = 0;
|
||||
showStylePanel.value = false;
|
||||
showStylePanelToggleButton.value = true;
|
||||
storageService.getItem.mockReturnValue(300);
|
||||
uiService.get.mockImplementation((k: string) => {
|
||||
if (k === 'columnWidth') return { right: 400 };
|
||||
return null;
|
||||
});
|
||||
editorService.get.mockImplementation((k: string) => {
|
||||
if (k === 'node') return { id: 'n1', type: 'text' };
|
||||
if (k === 'nodes') return [{ id: 'n1' }];
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
describe('PropsPanel', () => {
|
||||
test('渲染 FormPanel', async () => {
|
||||
const wrapper = mount(PropsPanel, { props: {} as any });
|
||||
await nextTick();
|
||||
expect(wrapper.find('.fake-form-panel').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('init 调用 getPropsConfig', async () => {
|
||||
mount(PropsPanel, { props: {} as any });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(propsService.getPropsConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('node 为空时清空 config', async () => {
|
||||
editorService.get.mockImplementation((k: string) => (k === 'nodes' ? [] : null));
|
||||
const wrapper = mount(PropsPanel, { props: {} as any });
|
||||
await nextTick();
|
||||
void wrapper;
|
||||
expect(propsService.getPropsConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('submit 触发 editorService.update', async () => {
|
||||
const wrapper = mount(PropsPanel, { props: {} as any });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await wrapper.find('.submit-btn').trigger('click');
|
||||
expect(editorService.update).toHaveBeenCalled();
|
||||
const calledNode = (editorService.update.mock.calls[0] as any)[0];
|
||||
expect(calledNode.style.color).toBe('red');
|
||||
expect(calledNode.style.empty).toBeUndefined();
|
||||
});
|
||||
|
||||
test('mounted 事件 emit', async () => {
|
||||
const wrapper = mount(PropsPanel, { props: {} as any });
|
||||
await wrapper.find('.mounted-btn').trigger('click');
|
||||
expect(wrapper.emitted('mounted')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('unmounted 事件 emit', async () => {
|
||||
const wrapper = mount(PropsPanel, { props: {} as any });
|
||||
await wrapper.find('.unmounted-btn').trigger('click');
|
||||
expect(wrapper.emitted('unmounted')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('form-error 事件转发', async () => {
|
||||
const wrapper = mount(PropsPanel, { props: {} as any });
|
||||
await wrapper.find('.form-err-btn').trigger('click');
|
||||
expect(wrapper.emitted('form-error')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Resizer change 限制宽度', async () => {
|
||||
showStylePanel.value = true;
|
||||
const wrapper = mount(PropsPanel, { props: {} as any });
|
||||
await nextTick();
|
||||
await wrapper.find('.fake-resizer').trigger('click');
|
||||
expect(storageService.setItem).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('点击 toggle 按钮 toggleStylePanel(true)', async () => {
|
||||
showStylePanelToggleButton.value = true;
|
||||
showStylePanel.value = false;
|
||||
const wrapper = mount(PropsPanel, { props: {} as any });
|
||||
await nextTick();
|
||||
// 直接调用 toggleStylePanel 验证逻辑
|
||||
const buttons = wrapper.findAll('.fake-btn');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
await buttons[buttons.length - 1].trigger('click');
|
||||
expect(toggleStylePanel).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('expose getFormState 返回 formState', async () => {
|
||||
const wrapper = mount(PropsPanel, { props: {} as any });
|
||||
await nextTick();
|
||||
expect((wrapper.vm as any).getFormState()).toEqual({ foo: 'bar' });
|
||||
});
|
||||
|
||||
test('off propsService 监听 on unmount', async () => {
|
||||
const wrapper = mount(PropsPanel, { props: {} as any });
|
||||
await nextTick();
|
||||
wrapper.unmount();
|
||||
expect(propsService.off).toHaveBeenCalledWith('props-configs-change', expect.any(Function));
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { nextTick, reactive, ref } from 'vue';
|
||||
|
||||
import { useStylePanel } from '@editor/layouts/props-panel/use-style-panel';
|
||||
|
||||
const mkServices = (storageInit?: any) => {
|
||||
const uiState: Record<string, any> = reactive({
|
||||
frameworkRect: { width: 1280 },
|
||||
showStylePanel: true,
|
||||
columnWidth: { right: 400, center: 800, left: 200 },
|
||||
});
|
||||
const uiService = {
|
||||
get: vi.fn((k: string) => uiState[k]),
|
||||
set: vi.fn((k: string, v: any) => {
|
||||
uiState[k] = v;
|
||||
}),
|
||||
};
|
||||
const storageService = {
|
||||
getItem: vi.fn(() => storageInit),
|
||||
setItem: vi.fn(),
|
||||
};
|
||||
return { uiService, storageService, uiState };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useStylePanel', () => {
|
||||
test('storage 中 showStylePanel 为 boolean 时同步 ui', () => {
|
||||
const { uiService, storageService } = mkServices(false);
|
||||
useStylePanel({ uiService, storageService } as any, ref(300));
|
||||
expect(uiService.set).toHaveBeenCalledWith('showStylePanel', false);
|
||||
});
|
||||
|
||||
test('frameworkRect.width >= 1280 时 toggleButton=true', () => {
|
||||
const { uiService, storageService } = mkServices();
|
||||
const { showStylePanelToggleButton, showStylePanel } = useStylePanel(
|
||||
{ uiService, storageService } as any,
|
||||
ref(300),
|
||||
);
|
||||
expect(showStylePanelToggleButton.value).toBe(true);
|
||||
expect(showStylePanel.value).toBe(true);
|
||||
});
|
||||
|
||||
test('frameworkRect.width < 1280 时 toggleButton=false 并 showStylePanel=false', () => {
|
||||
const { uiService, storageService, uiState } = mkServices();
|
||||
uiState.frameworkRect = { width: 800 };
|
||||
const { showStylePanelToggleButton, showStylePanel } = useStylePanel(
|
||||
{ uiService, storageService } as any,
|
||||
ref(300),
|
||||
);
|
||||
expect(showStylePanelToggleButton.value).toBe(false);
|
||||
expect(showStylePanel.value).toBe(false);
|
||||
});
|
||||
|
||||
test('toggleStylePanel(true) 增加 right/减少 center', () => {
|
||||
const { uiService, storageService, uiState } = mkServices();
|
||||
const { toggleStylePanel } = useStylePanel({ uiService, storageService } as any, ref(100));
|
||||
toggleStylePanel(true);
|
||||
expect(uiService.set).toHaveBeenCalledWith('showStylePanel', true);
|
||||
expect(uiState.columnWidth.right).toBe(500);
|
||||
expect(uiState.columnWidth.center).toBe(700);
|
||||
expect(storageService.setItem).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('toggleStylePanel(false) 减少 right/增加 center', () => {
|
||||
const { uiService, storageService, uiState } = mkServices();
|
||||
const { toggleStylePanel } = useStylePanel({ uiService, storageService } as any, ref(100));
|
||||
toggleStylePanel(false);
|
||||
expect(uiState.columnWidth.right).toBe(300);
|
||||
expect(uiState.columnWidth.center).toBe(900);
|
||||
});
|
||||
|
||||
test('toggleStylePanel(true) 中心列不足时收缩 right 并更新 panel 宽度', () => {
|
||||
const { uiService, storageService, uiState } = mkServices();
|
||||
uiState.columnWidth = { right: 400, center: 50, left: 200 };
|
||||
const w = ref(100);
|
||||
const { toggleStylePanel } = useStylePanel({ uiService, storageService } as any, w);
|
||||
toggleStylePanel(true);
|
||||
expect(uiState.columnWidth.center).toBe(400);
|
||||
expect(w.value).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('frameworkRect 变化时若 right 不足则收起 stylePanel', async () => {
|
||||
const { uiService, storageService, uiState } = mkServices();
|
||||
uiState.columnWidth = { right: 50, center: 1000, left: 200 };
|
||||
const w = ref(100);
|
||||
useStylePanel({ uiService, storageService } as any, w);
|
||||
uiService.set.mockClear();
|
||||
storageService.setItem.mockClear();
|
||||
uiState.frameworkRect = { width: 1500 };
|
||||
await nextTick();
|
||||
expect(uiService.set).toHaveBeenCalledWith('showStylePanel', false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import ComponentListPanel from '@editor/layouts/sidebar/ComponentListPanel.vue';
|
||||
|
||||
const editorService = {
|
||||
get: vi.fn(),
|
||||
add: vi.fn(),
|
||||
};
|
||||
const componentListService = {
|
||||
getList: vi.fn(() => [{ title: '基础', items: [{ text: '按钮', type: 'button', icon: '' }] }]),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService, componentListService }),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||
return { ...actual, removeClassNameByClassName: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicCollapse: defineComponent({
|
||||
name: 'TMagicCollapse',
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', slots.default?.());
|
||||
},
|
||||
}),
|
||||
TMagicCollapseItem: defineComponent({
|
||||
name: 'TMagicCollapseItem',
|
||||
props: ['name'],
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'collapse-item' }, [slots.title?.(), slots.default?.()]);
|
||||
},
|
||||
}),
|
||||
TMagicScrollbar: defineComponent({
|
||||
name: 'TMagicScrollbar',
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', slots.default?.());
|
||||
},
|
||||
}),
|
||||
TMagicTooltip: defineComponent({
|
||||
name: 'TMagicTooltip',
|
||||
props: ['placement', 'content', 'disabled'],
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: defineComponent({ name: 'MIcon', props: ['icon'], setup: () => () => h('i') }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/SearchInput.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'SearchInput',
|
||||
emits: ['search'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h('input', {
|
||||
class: 'fake-search',
|
||||
onInput: (e: Event) => emit('search', (e.target as HTMLInputElement).value),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
componentListService.getList.mockReturnValue([
|
||||
{
|
||||
title: '基础',
|
||||
items: [
|
||||
{ text: '按钮', type: 'button' },
|
||||
{ text: '文本', type: 'text' },
|
||||
],
|
||||
},
|
||||
{ title: '容器', items: [{ text: '行', type: 'row' }] },
|
||||
]);
|
||||
editorService.get.mockReturnValue({ renderer: { getDocument: () => null }, delayedMarkContainer: vi.fn() });
|
||||
});
|
||||
|
||||
describe('ComponentListPanel', () => {
|
||||
test('渲染分组列表', () => {
|
||||
const wrapper = mount(ComponentListPanel);
|
||||
expect(wrapper.findAll('.component-item').length).toBe(3);
|
||||
});
|
||||
|
||||
test('点击 component-item 调用 editorService.add', async () => {
|
||||
const wrapper = mount(ComponentListPanel);
|
||||
await wrapper.find('.component-item').trigger('click');
|
||||
expect(editorService.add).toHaveBeenCalledWith({ name: '按钮', type: 'button' });
|
||||
});
|
||||
|
||||
test('搜索过滤组件', async () => {
|
||||
const wrapper = mount(ComponentListPanel);
|
||||
await wrapper.find('.fake-search').setValue('按钮');
|
||||
expect(wrapper.findAll('.component-item').length).toBe(1);
|
||||
});
|
||||
|
||||
test('dragstart 事件设置 dataTransfer', async () => {
|
||||
const wrapper = mount(ComponentListPanel);
|
||||
const dt = { setData: vi.fn() };
|
||||
await wrapper.find('.component-item').trigger('dragstart', { dataTransfer: dt });
|
||||
expect(dt.setData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('dragend 事件清理 timeout', async () => {
|
||||
const wrapper = mount(ComponentListPanel);
|
||||
await wrapper.find('.component-item').trigger('dragend');
|
||||
});
|
||||
|
||||
test('drag 事件 不同坐标时不会触发 delayedMarkContainer', async () => {
|
||||
const stage = { renderer: { getDocument: () => null }, delayedMarkContainer: vi.fn() };
|
||||
editorService.get.mockReturnValue(stage);
|
||||
const wrapper = mount(ComponentListPanel);
|
||||
await wrapper.find('.component-item').trigger('drag', { clientX: 1, clientY: 1 });
|
||||
expect(stage.delayedMarkContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('drag 事件 相同坐标时触发 delayedMarkContainer', async () => {
|
||||
const stage = { renderer: { getDocument: () => null }, delayedMarkContainer: vi.fn(() => 1) };
|
||||
editorService.get.mockReturnValue(stage);
|
||||
const wrapper = mount(ComponentListPanel);
|
||||
const item = wrapper.find('.component-item');
|
||||
await item.trigger('drag', { clientX: 0, clientY: 0 });
|
||||
await item.trigger('drag', { clientX: 0, clientY: 0 });
|
||||
expect(stage.delayedMarkContainer).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
174
packages/editor/tests/unit/layouts/sidebar/Sidebar.spec.ts
Normal file
174
packages/editor/tests/unit/layouts/sidebar/Sidebar.spec.ts
Normal file
@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import Sidebar from '@editor/layouts/sidebar/Sidebar.vue';
|
||||
|
||||
const depService = { get: vi.fn(() => false) };
|
||||
const uiService = {
|
||||
get: vi.fn(() => ({ left: 200 })),
|
||||
set: vi.fn(),
|
||||
};
|
||||
const propsService = {
|
||||
getDisabledDataSource: vi.fn(() => false),
|
||||
getDisabledCodeBlock: vi.fn(() => false),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ depService, uiService, propsService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/hooks/use-editor-content-height', () => ({
|
||||
useEditorContentHeight: () => ({ height: { value: 600 } }),
|
||||
}));
|
||||
|
||||
const dragstartHandler = vi.fn();
|
||||
const dragendHandler = vi.fn();
|
||||
vi.mock('@editor/hooks/use-float-box', () => ({
|
||||
useFloatBox: () => ({
|
||||
dragstartHandler,
|
||||
dragendHandler,
|
||||
floatBoxStates: { layer: { status: false }, 'code-block': { status: false }, 'data-source': { status: false } },
|
||||
showingBoxKeys: { value: [] },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/sidebar/code-block/CodeBlockListPanel.vue', () => ({
|
||||
default: { name: 'CodeBlockListPanel', render: () => null },
|
||||
}));
|
||||
vi.mock('@editor/layouts/sidebar/data-source/DataSourceListPanel.vue', () => ({
|
||||
default: { name: 'DataSourceListPanel', render: () => null },
|
||||
}));
|
||||
vi.mock('@editor/layouts/sidebar/layer/LayerPanel.vue', () => ({
|
||||
default: { name: 'LayerPanel', render: () => null },
|
||||
}));
|
||||
vi.mock('@editor/layouts/sidebar/ComponentListPanel.vue', () => ({
|
||||
default: { name: 'ComponentListPanel', render: () => null },
|
||||
}));
|
||||
|
||||
const stub = (name: string) =>
|
||||
defineComponent({
|
||||
name,
|
||||
setup() {
|
||||
return () => h('div', { class: name });
|
||||
},
|
||||
});
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: defineComponent({ name: 'MIcon', props: ['icon'], setup: () => () => h('i', { class: 'fake-icon' }) }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/FloatingBox.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FloatingBox',
|
||||
props: ['visible', 'width', 'height', 'title', 'position'],
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'floating-box' }, slots.body?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/type', async () => {
|
||||
const actual = await vi.importActual<any>('@editor/type');
|
||||
return {
|
||||
...actual,
|
||||
SideItemKey: {
|
||||
COMPONENT_LIST: 'component-list',
|
||||
LAYER: 'layer',
|
||||
CODE_BLOCK: 'code-block',
|
||||
DATA_SOURCE: 'data-source',
|
||||
},
|
||||
ColumnLayout: { LEFT: 'left', CENTER: 'center', RIGHT: 'right' },
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
propsService.getDisabledDataSource.mockReturnValue(false);
|
||||
propsService.getDisabledCodeBlock.mockReturnValue(false);
|
||||
uiService.get.mockReturnValue({ left: 200 });
|
||||
});
|
||||
|
||||
const baseProps = (extra: any = {}) => ({
|
||||
data: { type: 'tabs', status: '组件', items: ['component-list', 'layer', 'code-block', 'data-source'] },
|
||||
layerContentMenu: [],
|
||||
customContentMenu: (m: any) => m,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('Sidebar', () => {
|
||||
test('渲染 4 个 sidebar header 条目', () => {
|
||||
const wrapper = mount(Sidebar, { props: baseProps() as any });
|
||||
expect(wrapper.findAll('.m-editor-sidebar-header-item').length).toBe(4);
|
||||
});
|
||||
|
||||
test('disabledDataSource 时不展示 data-source', () => {
|
||||
propsService.getDisabledDataSource.mockReturnValue(true);
|
||||
const wrapper = mount(Sidebar, { props: baseProps() as any });
|
||||
expect(wrapper.findAll('.m-editor-sidebar-header-item').length).toBe(3);
|
||||
});
|
||||
|
||||
test('disabledCodeBlock 时不展示 code-block', () => {
|
||||
propsService.getDisabledCodeBlock.mockReturnValue(true);
|
||||
const wrapper = mount(Sidebar, { props: baseProps() as any });
|
||||
expect(wrapper.findAll('.m-editor-sidebar-header-item').length).toBe(3);
|
||||
});
|
||||
|
||||
test('点击 header item 切换 activeTabName', async () => {
|
||||
const wrapper = mount(Sidebar, { props: baseProps() as any });
|
||||
const items = wrapper.findAll('.m-editor-sidebar-header-item');
|
||||
await items[1].trigger('click');
|
||||
expect((wrapper.vm as any).activeTabName).toBe('已选组件');
|
||||
});
|
||||
|
||||
test('items 为空时不渲染 sidebar', () => {
|
||||
const wrapper = mount(Sidebar, {
|
||||
props: baseProps({ data: { type: 'tabs', status: '', items: [] } }) as any,
|
||||
});
|
||||
expect(wrapper.find('.m-editor-sidebar').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('sideBarItems 写入 uiService', () => {
|
||||
mount(Sidebar, { props: baseProps() as any });
|
||||
expect(uiService.set).toHaveBeenCalledWith('sideBarItems', expect.any(Array));
|
||||
});
|
||||
|
||||
test('data.status 变化时同步 activeTabName', async () => {
|
||||
const wrapper = mount(Sidebar, { props: baseProps() as any });
|
||||
await wrapper.setProps({ data: { type: 'tabs', status: '数据源', items: ['data-source'] } });
|
||||
await nextTick();
|
||||
expect((wrapper.vm as any).activeTabName).toBe('数据源');
|
||||
});
|
||||
|
||||
test('beforeClick 返回 false 时不切换', async () => {
|
||||
const beforeClick = vi.fn(async () => false);
|
||||
const wrapper = mount(Sidebar, {
|
||||
props: baseProps({
|
||||
data: {
|
||||
type: 'tabs',
|
||||
status: 'A',
|
||||
items: [
|
||||
{ $key: 'a', text: 'A', component: stub('A'), beforeClick },
|
||||
{ $key: 'b', text: 'B', component: stub('B') },
|
||||
],
|
||||
},
|
||||
}) as any,
|
||||
});
|
||||
const items = wrapper.findAll('.m-editor-sidebar-header-item');
|
||||
await items[0].trigger('click');
|
||||
await nextTick();
|
||||
expect(beforeClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('dragstartHandler 触发', async () => {
|
||||
const wrapper = mount(Sidebar, { props: baseProps() as any });
|
||||
const items = wrapper.findAll('.m-editor-sidebar-header-item');
|
||||
await items[0].trigger('dragstart');
|
||||
expect(dragstartHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import CodeBlockList from '@editor/layouts/sidebar/code-block/CodeBlockList.vue';
|
||||
|
||||
const codeBlockService = {
|
||||
getCodeDsl: vi.fn(() => ({ c1: { name: 'C1' } })),
|
||||
getCodeContentById: vi.fn(),
|
||||
getEditStatus: vi.fn(() => true),
|
||||
getUndeletableList: vi.fn(() => []),
|
||||
};
|
||||
const editorService = {
|
||||
get: vi.fn(),
|
||||
select: vi.fn(),
|
||||
};
|
||||
const depService = {
|
||||
getTarget: vi.fn(() => null),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ codeBlockService, editorService, depService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/hooks/use-node-status', () => ({
|
||||
useNodeStatus: () => ({ nodeStatusMap: { value: new Map() } }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/hooks/use-filter', () => ({
|
||||
useFilter: () => ({ filterTextChangeHandler: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/core', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/core');
|
||||
return { ...actual, DepTargetType: { CODE_BLOCK: 'code-block' } };
|
||||
});
|
||||
|
||||
const { messageBoxConfirm, messageError } = vi.hoisted(() => ({
|
||||
messageBoxConfirm: vi.fn(async () => 'confirm'),
|
||||
messageError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
tMagicMessage: { error: messageError },
|
||||
tMagicMessageBox: { confirm: messageBoxConfirm },
|
||||
TMagicTooltip: defineComponent({
|
||||
name: 'TMagicTooltip',
|
||||
props: ['content', 'placement', 'effect'],
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-tooltip' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'EditorIcon',
|
||||
props: ['icon'],
|
||||
emits: ['click'],
|
||||
setup(_p, { emit }) {
|
||||
return () => h('i', { class: 'edit-icon', onClick: (e: Event) => emit('click', e) });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Tree.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'TreeStub',
|
||||
props: ['data', 'nodeStatusMap', 'indent', 'nextLevelIndentIncrement'],
|
||||
emits: ['node-click', 'node-contextmenu'],
|
||||
setup(_p, { emit, slots }) {
|
||||
return () =>
|
||||
h('div', { class: 'fake-tree' }, [
|
||||
h('button', {
|
||||
class: 'click-btn',
|
||||
onClick: () => emit('node-click', new MouseEvent('click'), { type: 'node', key: 'comp1' }),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'menu-btn',
|
||||
onClick: () => emit('node-contextmenu', new MouseEvent('contextmenu'), { type: 'code', id: 'c1' }),
|
||||
}),
|
||||
slots['tree-node-tool']?.({ data: { type: 'code', name: 'C1', key: 'c1' } }),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
codeBlockService.getCodeDsl.mockReturnValue({ c1: { name: 'C1' } });
|
||||
codeBlockService.getEditStatus.mockReturnValue(true);
|
||||
codeBlockService.getUndeletableList.mockReturnValue([]);
|
||||
editorService.get.mockImplementation((k: string) => {
|
||||
if (k === 'root') return { items: [{ id: 'p1', name: 'page1' }] };
|
||||
if (k === 'stage') return { select: vi.fn() };
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodeBlockList', () => {
|
||||
test('渲染 Tree 与节点工具', () => {
|
||||
const wrapper = mount(CodeBlockList);
|
||||
expect(wrapper.findAll('.edit-icon').length).toBe(2);
|
||||
});
|
||||
|
||||
test('编辑按钮 emit edit', async () => {
|
||||
const wrapper = mount(CodeBlockList);
|
||||
await wrapper.findAll('.edit-icon')[0].trigger('click');
|
||||
expect(wrapper.emitted('edit')?.[0]?.[0]).toBe('c1');
|
||||
});
|
||||
|
||||
test('删除按钮 弹窗确认后 emit remove', async () => {
|
||||
const wrapper = mount(CodeBlockList);
|
||||
await wrapper.findAll('.edit-icon')[1].trigger('click');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(messageBoxConfirm).toHaveBeenCalled();
|
||||
expect(wrapper.emitted('remove')?.[0]?.[0]).toBe('c1');
|
||||
});
|
||||
|
||||
test('点击 Tree 节点选中组件', async () => {
|
||||
const stageSelect = vi.fn();
|
||||
editorService.get.mockImplementation((k: string) => {
|
||||
if (k === 'root') return { items: [{ id: 'p1', name: 'page1' }] };
|
||||
if (k === 'stage') return { select: stageSelect };
|
||||
return null;
|
||||
});
|
||||
const wrapper = mount(CodeBlockList);
|
||||
await wrapper.find('.click-btn').trigger('click');
|
||||
expect(editorService.select).toHaveBeenCalledWith('comp1');
|
||||
expect(stageSelect).toHaveBeenCalledWith('comp1');
|
||||
});
|
||||
|
||||
test('右键 Tree 节点 emit node-contextmenu', async () => {
|
||||
const wrapper = mount(CodeBlockList);
|
||||
await wrapper.find('.menu-btn').trigger('click');
|
||||
expect(wrapper.emitted('node-contextmenu')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('删除按钮: 在不可删除列表 提示错误', async () => {
|
||||
codeBlockService.getUndeletableList.mockReturnValue(['c1']);
|
||||
const wrapper = mount(CodeBlockList);
|
||||
await wrapper.findAll('.edit-icon')[1].trigger('click');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(wrapper.emitted('remove')).toBeFalsy();
|
||||
expect(messageError).toHaveBeenCalledWith('代码块不可删除');
|
||||
});
|
||||
|
||||
test('customError 函数被调用', async () => {
|
||||
codeBlockService.getUndeletableList.mockReturnValue(['c1']);
|
||||
const customError = vi.fn();
|
||||
const wrapper = mount(CodeBlockList, { props: { customError } as any });
|
||||
await wrapper.findAll('.edit-icon')[1].trigger('click');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(customError).toHaveBeenCalledWith('c1', 'undeleteable');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,275 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import CodeBlockListPanel from '@editor/layouts/sidebar/code-block/CodeBlockListPanel.vue';
|
||||
|
||||
const codeBlockService = {
|
||||
getEditStatus: vi.fn(() => true),
|
||||
};
|
||||
|
||||
const editCode = vi.fn();
|
||||
const deleteCode = vi.fn();
|
||||
const createCodeBlock = vi.fn();
|
||||
const submitCodeBlockHandler = vi.fn();
|
||||
const codeId = ref<string>('');
|
||||
const codeBlockEditor = ref<any>(null);
|
||||
const codeConfig = ref<any>(null);
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ codeBlockService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/hooks/use-code-block-edit', () => ({
|
||||
useCodeBlockEdit: () => ({
|
||||
codeId,
|
||||
codeBlockEditor,
|
||||
codeConfig,
|
||||
editCode,
|
||||
deleteCode,
|
||||
createCodeBlock,
|
||||
submitCodeBlockHandler,
|
||||
}),
|
||||
}));
|
||||
|
||||
const nodeContentMenuHandler = vi.fn();
|
||||
const contentMenuHideHandler = vi.fn();
|
||||
const menuDataState = { items: [{ type: 'button', text: 'a' }] as any[] };
|
||||
|
||||
vi.mock('@editor/layouts/sidebar/code-block/useContentMenu', () => ({
|
||||
useContentMenu: () => ({
|
||||
nodeContentMenuHandler,
|
||||
get menuData() {
|
||||
return menuDataState.items;
|
||||
},
|
||||
contentMenuHideHandler,
|
||||
}),
|
||||
}));
|
||||
|
||||
const filterFn = vi.fn();
|
||||
const codeBlockListNodeStatusMap = new Map<string, any>([
|
||||
['c1', { selected: false }],
|
||||
['c2', { selected: false }],
|
||||
]);
|
||||
const codeBlockListDeleteCode = vi.fn();
|
||||
|
||||
vi.mock('@editor/layouts/sidebar/code-block/CodeBlockList.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeCodeBlockList',
|
||||
props: ['customError', 'indent', 'nextLevelIndentIncrement'],
|
||||
emits: ['edit', 'remove', 'node-contextmenu'],
|
||||
setup(_p, { emit, expose, slots }) {
|
||||
expose({
|
||||
filter: filterFn,
|
||||
nodeStatusMap: codeBlockListNodeStatusMap,
|
||||
deleteCode: codeBlockListDeleteCode,
|
||||
});
|
||||
return () =>
|
||||
h('div', { class: 'fake-code-block-list' }, [
|
||||
h('button', { class: 'edit-btn', onClick: () => emit('edit', 'c1') }),
|
||||
h('button', { class: 'remove-btn', onClick: () => emit('remove', 'c1') }),
|
||||
h('button', { class: 'ctx-btn', onClick: (e: MouseEvent) => emit('node-contextmenu', e, { id: 'c1' }) }),
|
||||
slots['code-block-panel-tool']?.({ id: 'c1', data: {} }),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/CodeBlockEditor.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeCodeBlockEditor',
|
||||
props: ['disabled', 'content'],
|
||||
emits: ['submit', 'close'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h('div', {
|
||||
class: 'fake-code-block-editor',
|
||||
onClick: () => emit('submit'),
|
||||
onContextmenu: () => emit('close'),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/ContentMenu.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeContentMenu',
|
||||
props: ['menuData'],
|
||||
emits: ['hide'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h('div', {
|
||||
class: 'fake-content-menu',
|
||||
onClick: () => emit('hide'),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/SearchInput.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeSearchInput',
|
||||
emits: ['search'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h('input', {
|
||||
class: 'fake-search-input',
|
||||
onChange: (e: any) => emit('search', e.target.value),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicScrollbar: defineComponent({
|
||||
name: 'FakeScrollbar',
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-scrollbar' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
TMagicButton: defineComponent({
|
||||
name: 'FakeBtn',
|
||||
inheritAttrs: false,
|
||||
setup(_p, { slots, attrs }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{ ...attrs, class: ['fake-btn', (attrs as any).class].filter(Boolean).join(' ') },
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const eventBus: {
|
||||
handlers: Record<string, Function[]>;
|
||||
on(name: string, cb: Function): void;
|
||||
emit(name: string, ...args: any[]): void;
|
||||
} = {
|
||||
handlers: {},
|
||||
on(name: string, cb: Function) {
|
||||
(this.handlers[name] = this.handlers[name] || []).push(cb);
|
||||
},
|
||||
emit(name: string, ...args: any[]) {
|
||||
(this.handlers[name] || []).forEach((cb) => cb(...args));
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
codeBlockService.getEditStatus.mockReturnValue(true);
|
||||
codeId.value = '';
|
||||
codeConfig.value = null;
|
||||
menuDataState.items = [{ type: 'button', text: 'a' }];
|
||||
eventBus.handlers = {};
|
||||
codeBlockListNodeStatusMap.forEach((v) => (v.selected = false));
|
||||
});
|
||||
|
||||
const factory = (custom?: any) =>
|
||||
mount(CodeBlockListPanel, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
customContentMenu: ((m: any) => m) as any,
|
||||
...custom,
|
||||
} as any,
|
||||
global: {
|
||||
provide: { eventBus },
|
||||
},
|
||||
});
|
||||
|
||||
describe('CodeBlockListPanel.vue', () => {
|
||||
test('渲染搜索 / 新增按钮 / 列表', () => {
|
||||
const wrapper = factory();
|
||||
expect(wrapper.find('.fake-search-input').exists()).toBe(true);
|
||||
expect(wrapper.find('.create-code-button').exists()).toBe(true);
|
||||
expect(wrapper.find('.fake-code-block-list').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('editable 为 false 时不渲染新增按钮', () => {
|
||||
codeBlockService.getEditStatus.mockReturnValue(false);
|
||||
const wrapper = factory();
|
||||
expect(wrapper.find('.create-code-button').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('SearchInput search 触发列表 filter', async () => {
|
||||
const wrapper = factory();
|
||||
const input = wrapper.find('.fake-search-input');
|
||||
(input.element as HTMLInputElement).value = 'kw';
|
||||
await input.trigger('change');
|
||||
expect(filterFn).toHaveBeenCalledWith('kw');
|
||||
});
|
||||
|
||||
test('点击新增按钮调用 createCodeBlock', async () => {
|
||||
const wrapper = factory();
|
||||
const btns = wrapper.findAll('.fake-btn');
|
||||
const createBtn = btns.find((b) => b.text() === '新增');
|
||||
expect(createBtn).toBeTruthy();
|
||||
await createBtn!.trigger('click');
|
||||
expect(createCodeBlock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('CodeBlockList edit/remove/contextmenu 事件', async () => {
|
||||
const wrapper = factory();
|
||||
await wrapper.find('.edit-btn').trigger('click');
|
||||
expect(editCode).toHaveBeenCalledWith('c1');
|
||||
await wrapper.find('.remove-btn').trigger('click');
|
||||
expect(deleteCode).toHaveBeenCalledWith('c1');
|
||||
await wrapper.find('.ctx-btn').trigger('click');
|
||||
expect(nodeContentMenuHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('codeConfig 有值时渲染编辑器', async () => {
|
||||
const wrapper = factory();
|
||||
codeConfig.value = { name: 'cfg', content: '' };
|
||||
await nextTick();
|
||||
expect(wrapper.find('.fake-code-block-editor').exists()).toBe(true);
|
||||
await wrapper.find('.fake-code-block-editor').trigger('click');
|
||||
expect(submitCodeBlockHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('编辑器 close 事件清空 selected', async () => {
|
||||
codeBlockListNodeStatusMap.get('c1').selected = true;
|
||||
const wrapper = factory();
|
||||
codeConfig.value = { name: 'cfg', content: '' };
|
||||
await nextTick();
|
||||
await wrapper.find('.fake-code-block-editor').trigger('contextmenu');
|
||||
expect(codeBlockListNodeStatusMap.get('c1').selected).toBe(false);
|
||||
});
|
||||
|
||||
test('codeId 改变时切换选中状态', async () => {
|
||||
factory();
|
||||
codeId.value = 'c2';
|
||||
await nextTick();
|
||||
expect(codeBlockListNodeStatusMap.get('c1').selected).toBe(false);
|
||||
expect(codeBlockListNodeStatusMap.get('c2').selected).toBe(true);
|
||||
});
|
||||
|
||||
test('eventBus.edit-code 触发 editCode', () => {
|
||||
factory();
|
||||
eventBus.emit('edit-code', 'cx');
|
||||
expect(editCode).toHaveBeenCalledWith('cx');
|
||||
});
|
||||
|
||||
test('ContentMenu hide 触发 contentMenuHideHandler', async () => {
|
||||
document.body.innerHTML = '';
|
||||
factory();
|
||||
await nextTick();
|
||||
const menu = document.body.querySelector('.fake-content-menu') as HTMLElement;
|
||||
expect(menu).toBeTruthy();
|
||||
menu.click();
|
||||
expect(contentMenuHideHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('menuData 为空时不渲染 ContentMenu', async () => {
|
||||
document.body.innerHTML = '';
|
||||
menuDataState.items = [];
|
||||
factory();
|
||||
await nextTick();
|
||||
expect(document.body.querySelector('.fake-content-menu')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { useContentMenu } from '@editor/layouts/sidebar/code-block/useContentMenu';
|
||||
|
||||
const MenuStub = defineComponent({
|
||||
name: 'ContentMenu',
|
||||
setup(_, { expose }) {
|
||||
expose({ show: vi.fn(), hide: vi.fn() });
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
|
||||
const mountHook = (deleteCode: any, eventBus: any = { emit: vi.fn() }) => {
|
||||
let result: ReturnType<typeof useContentMenu> | undefined;
|
||||
const hostComp = defineComponent({
|
||||
components: { MenuStub },
|
||||
setup(_, { expose }) {
|
||||
result = useContentMenu(deleteCode);
|
||||
expose({ result });
|
||||
return () => h(MenuStub, { ref: 'menu' });
|
||||
},
|
||||
});
|
||||
const wrapper = mount(hostComp, { global: { provide: { eventBus } } });
|
||||
return { wrapper, result: result!, eventBus };
|
||||
};
|
||||
|
||||
describe('code-block useContentMenu', () => {
|
||||
test('提供 menuData 和处理函数', () => {
|
||||
const { result } = mountHook(vi.fn());
|
||||
expect(result.menuData.length).toBe(3);
|
||||
expect(typeof result.nodeContentMenuHandler).toBe('function');
|
||||
expect(typeof result.contentMenuHideHandler).toBe('function');
|
||||
});
|
||||
|
||||
test('编辑按钮 display 取决于 codeBlockService.getEditStatus()', () => {
|
||||
const { result } = mountHook(vi.fn());
|
||||
const editBtn = result.menuData[0] as any;
|
||||
expect(editBtn.display({ codeBlockService: { getEditStatus: () => true } })).toBe(true);
|
||||
expect(editBtn.display({ codeBlockService: { getEditStatus: () => false } })).toBe(false);
|
||||
});
|
||||
|
||||
test('编辑按钮: 选中后 emit edit-code', () => {
|
||||
const eventBus = { emit: vi.fn() };
|
||||
const { result } = mountHook(vi.fn(), eventBus);
|
||||
result.nodeContentMenuHandler({ preventDefault: vi.fn() } as any, { type: 'code', id: 'c1' } as any);
|
||||
(result.menuData[0] as any).handler({});
|
||||
expect(eventBus.emit).toHaveBeenCalledWith('edit-code', 'c1');
|
||||
});
|
||||
|
||||
test('编辑按钮: 未选中时不触发', () => {
|
||||
const eventBus = { emit: vi.fn() };
|
||||
const { result } = mountHook(vi.fn(), eventBus);
|
||||
(result.menuData[0] as any).handler({});
|
||||
expect(eventBus.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('复制按钮: 调用 setCodeDslById 并使用克隆数据', async () => {
|
||||
const { result } = mountHook(vi.fn());
|
||||
result.nodeContentMenuHandler({ preventDefault: vi.fn() } as any, { type: 'code', id: 'c1' } as any);
|
||||
const codeBlockService = {
|
||||
getCodeContentById: vi.fn(() => ({ name: 'a' })),
|
||||
getUniqueId: vi.fn(async () => 'newId'),
|
||||
setCodeDslById: vi.fn(),
|
||||
};
|
||||
await (result.menuData[1] as any).handler({ codeBlockService });
|
||||
expect(codeBlockService.setCodeDslById).toHaveBeenCalledWith('newId', { name: 'a' });
|
||||
});
|
||||
|
||||
test('复制按钮: 未选中时不触发', async () => {
|
||||
const { result } = mountHook(vi.fn());
|
||||
const codeBlockService = {
|
||||
getCodeContentById: vi.fn(),
|
||||
getUniqueId: vi.fn(),
|
||||
setCodeDslById: vi.fn(),
|
||||
};
|
||||
await (result.menuData[1] as any).handler({ codeBlockService });
|
||||
expect(codeBlockService.getCodeContentById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('复制按钮: 找不到 codeBlock 直接返回', async () => {
|
||||
const { result } = mountHook(vi.fn());
|
||||
result.nodeContentMenuHandler({ preventDefault: vi.fn() } as any, { type: 'code', id: 'c1' } as any);
|
||||
const codeBlockService = {
|
||||
getCodeContentById: vi.fn(() => null),
|
||||
getUniqueId: vi.fn(),
|
||||
setCodeDslById: vi.fn(),
|
||||
};
|
||||
await (result.menuData[1] as any).handler({ codeBlockService });
|
||||
expect(codeBlockService.setCodeDslById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('删除按钮: 调用传入的 deleteCode', () => {
|
||||
const deleteCode = vi.fn();
|
||||
const { result } = mountHook(deleteCode);
|
||||
result.nodeContentMenuHandler({ preventDefault: vi.fn() } as any, { type: 'code', id: 'c1' } as any);
|
||||
(result.menuData[2] as any).handler({});
|
||||
expect(deleteCode).toHaveBeenCalledWith('c1');
|
||||
});
|
||||
|
||||
test('nodeContentMenuHandler: 非 code 类型不显示菜单', () => {
|
||||
const { result } = mountHook(vi.fn());
|
||||
const event = { preventDefault: vi.fn() };
|
||||
result.nodeContentMenuHandler(event as any, { type: 'other' } as any);
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('contentMenuHideHandler 重置 selectId', () => {
|
||||
const deleteCode = vi.fn();
|
||||
const { result } = mountHook(deleteCode);
|
||||
result.nodeContentMenuHandler({ preventDefault: vi.fn() } as any, { type: 'code', id: 'c1' } as any);
|
||||
result.contentMenuHideHandler();
|
||||
(result.menuData[2] as any).handler({});
|
||||
expect(deleteCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('nodeContentMenuHandler: data.id 缺失时 selectId=空', () => {
|
||||
const deleteCode = vi.fn();
|
||||
const { result } = mountHook(deleteCode);
|
||||
result.nodeContentMenuHandler({ preventDefault: vi.fn() } as any, { type: 'code' } as any);
|
||||
(result.menuData[2] as any).handler({});
|
||||
expect(deleteCode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import DataSourceAddButton from '@editor/layouts/sidebar/data-source/DataSourceAddButton.vue';
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicButton: defineComponent({
|
||||
name: 'FakeTMagicButton',
|
||||
inheritAttrs: false,
|
||||
setup(_p, { slots, attrs }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{ ...attrs, class: ['fake-btn', (attrs as any).class].filter(Boolean).join(' ') },
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
TMagicPopover: defineComponent({
|
||||
name: 'FakeTMagicPopover',
|
||||
setup(_p, { slots }) {
|
||||
return () =>
|
||||
h('div', { class: 'fake-popover' }, [
|
||||
slots.reference?.(),
|
||||
h('div', { class: 'popover-content' }, slots.default?.()),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/ToolButton.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeToolButton',
|
||||
props: ['data'],
|
||||
setup(props) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: 'tool-btn',
|
||||
onClick: () => (props.data as any).handler?.(),
|
||||
},
|
||||
(props.data as any).text,
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('DataSourceAddButton.vue', () => {
|
||||
test('渲染按钮和数据源类型列表', () => {
|
||||
const wrapper = mount(DataSourceAddButton, {
|
||||
props: {
|
||||
datasourceTypeList: [
|
||||
{ text: 'Base', type: 'base' },
|
||||
{ text: 'HTTP', type: 'http' },
|
||||
],
|
||||
addButtonText: '新增',
|
||||
addButtonConfig: { type: 'primary' } as any,
|
||||
},
|
||||
});
|
||||
expect(wrapper.text()).toContain('新增');
|
||||
expect(wrapper.text()).toContain('Base');
|
||||
expect(wrapper.text()).toContain('HTTP');
|
||||
});
|
||||
|
||||
test('点击工具按钮触发 add 事件', async () => {
|
||||
const wrapper = mount(DataSourceAddButton, {
|
||||
props: {
|
||||
datasourceTypeList: [{ text: 'Base', type: 'base' }],
|
||||
},
|
||||
});
|
||||
await wrapper.findAll('.tool-btn')[0].trigger('click');
|
||||
expect(wrapper.emitted('add')?.[0]).toEqual(['base']);
|
||||
});
|
||||
|
||||
test('addButtonConfig 与 addButtonText 缺省', () => {
|
||||
const wrapper = mount(DataSourceAddButton, {
|
||||
props: {
|
||||
datasourceTypeList: [],
|
||||
} as any,
|
||||
});
|
||||
expect(wrapper.find('.fake-btn').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import DataSourceConfigPanel from '@editor/layouts/sidebar/data-source/DataSourceConfigPanel.vue';
|
||||
|
||||
const dataSourceService = {
|
||||
getFormConfig: vi.fn(() => [{ name: 'a' }]),
|
||||
};
|
||||
const uiService = { get: vi.fn() };
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ uiService, dataSourceService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/hooks/use-editor-content-height', () => ({
|
||||
useEditorContentHeight: () => ({ height: ref(600) }),
|
||||
}));
|
||||
|
||||
const calcBoxPosition = vi.fn();
|
||||
vi.mock('@editor/hooks/use-next-float-box-position', () => ({
|
||||
useNextFloatBoxPosition: () => ({
|
||||
boxPosition: ref({ x: 100, y: 100 }),
|
||||
calcBoxPosition,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/FloatingBox.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FloatingBox',
|
||||
props: ['visible', 'width', 'height', 'title', 'position'],
|
||||
emits: ['update:visible', 'update:width', 'update:height'],
|
||||
setup(props, { slots }) {
|
||||
return () => h('div', { class: 'fake-floating', 'data-visible': String(props.visible) }, slots.body?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/form', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/form');
|
||||
return {
|
||||
...actual,
|
||||
MFormBox: defineComponent({
|
||||
name: 'MFormBox',
|
||||
props: ['title', 'config', 'values', 'disabled'],
|
||||
emits: ['submit', 'error'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h('div', { class: 'fake-form-box' }, [
|
||||
h('button', { class: 'submit-btn', onClick: () => emit('submit', { id: 'a' }, { changeRecords: [] }) }),
|
||||
h('button', { class: 'error-btn', onClick: () => emit('error', new Error('xxx')) }),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const { tMagicMessageError } = vi.hoisted(() => ({ tMagicMessageError: vi.fn() }));
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
tMagicMessage: { error: tMagicMessageError },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('DataSourceConfigPanel', () => {
|
||||
test('show 调用 calcBoxPosition 并设置 visible', async () => {
|
||||
const wrapper = mount(DataSourceConfigPanel, {
|
||||
props: { title: 't', values: {}, disabled: false } as any,
|
||||
});
|
||||
(wrapper.vm as any).show();
|
||||
await nextTick();
|
||||
expect(calcBoxPosition).toHaveBeenCalled();
|
||||
expect(wrapper.find('.fake-floating').attributes('data-visible')).toBe('true');
|
||||
});
|
||||
|
||||
test('hide 设置 visible false', async () => {
|
||||
const wrapper = mount(DataSourceConfigPanel, {
|
||||
props: { title: 't', values: {}, disabled: false } as any,
|
||||
});
|
||||
(wrapper.vm as any).show();
|
||||
await nextTick();
|
||||
(wrapper.vm as any).hide();
|
||||
await nextTick();
|
||||
expect(wrapper.find('.fake-floating').attributes('data-visible')).toBe('false');
|
||||
});
|
||||
|
||||
test('submit 触发 submit 事件', async () => {
|
||||
const wrapper = mount(DataSourceConfigPanel, {
|
||||
props: { title: 't', values: {}, disabled: false } as any,
|
||||
});
|
||||
await wrapper.find('.submit-btn').trigger('click');
|
||||
expect(wrapper.emitted('submit')?.[0]?.[0]).toEqual({ id: 'a' });
|
||||
});
|
||||
|
||||
test('error 调用 tMagicMessage.error', async () => {
|
||||
const wrapper = mount(DataSourceConfigPanel, {
|
||||
props: { title: 't', values: {}, disabled: false } as any,
|
||||
});
|
||||
await wrapper.find('.error-btn').trigger('click');
|
||||
expect(tMagicMessageError).toHaveBeenCalledWith('xxx');
|
||||
});
|
||||
|
||||
test('boxVisible 切换为 true 且有 id 时 emit open', async () => {
|
||||
const wrapper = mount(DataSourceConfigPanel, {
|
||||
props: { title: 't', values: { id: 'd1', type: 'http' }, disabled: false } as any,
|
||||
});
|
||||
(wrapper.vm as any).show();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(wrapper.emitted('open')?.[0]?.[0]).toBe('d1');
|
||||
});
|
||||
|
||||
test('boxVisible 切换为 false 时 emit close', async () => {
|
||||
const wrapper = mount(DataSourceConfigPanel, {
|
||||
props: { title: 't', values: {}, disabled: false } as any,
|
||||
});
|
||||
(wrapper.vm as any).show();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
(wrapper.vm as any).hide();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(wrapper.emitted('close')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('values 改变时调用 getFormConfig', async () => {
|
||||
const wrapper = mount(DataSourceConfigPanel, {
|
||||
props: { title: 't', values: { type: 'http' }, disabled: false } as any,
|
||||
});
|
||||
await nextTick();
|
||||
expect(dataSourceService.getFormConfig).toHaveBeenCalledWith('http');
|
||||
await wrapper.setProps({ values: { type: 'base' } } as any);
|
||||
await nextTick();
|
||||
expect(dataSourceService.getFormConfig).toHaveBeenCalledWith('base');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import DataSourceList from '@editor/layouts/sidebar/data-source/DataSourceList.vue';
|
||||
|
||||
const editorService = {
|
||||
get: vi.fn(),
|
||||
select: vi.fn(),
|
||||
};
|
||||
const dataSourceService = {
|
||||
get: vi.fn(),
|
||||
};
|
||||
const depService = {
|
||||
getTargets: vi.fn(() => ({})),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService, dataSourceService, depService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/hooks/use-node-status', () => ({
|
||||
useNodeStatus: () => ({ nodeStatusMap: { value: new Map() } }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/hooks/use-filter', () => ({
|
||||
useFilter: () => ({ filterTextChangeHandler: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/core', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/core');
|
||||
return {
|
||||
...actual,
|
||||
DepTargetType: {
|
||||
DATA_SOURCE: 'data-source',
|
||||
DATA_SOURCE_METHOD: 'data-source-method',
|
||||
DATA_SOURCE_COND: 'data-source-cond',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@editor/components/Tree.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'TreeStub',
|
||||
props: ['data', 'nodeStatusMap', 'indent', 'nextLevelIndentIncrement'],
|
||||
emits: ['node-click', 'node-contextmenu'],
|
||||
setup(_p, { emit, slots }) {
|
||||
return () =>
|
||||
h('div', { class: 'fake-tree' }, [
|
||||
h('button', {
|
||||
class: 'click-btn',
|
||||
onClick: () => emit('node-click', new MouseEvent('click'), { type: 'node', key: 'cmp1' }),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'menu-btn',
|
||||
onClick: () => emit('node-contextmenu', new MouseEvent('contextmenu'), { type: 'ds', id: 'd1' }),
|
||||
}),
|
||||
slots['tree-node-label']?.({ data: { type: 'ds', name: 'D1' } }),
|
||||
slots['tree-node-tool']?.({ data: { type: 'ds', name: 'D1', key: 'd1' } }),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'EditorIcon',
|
||||
props: ['icon'],
|
||||
emits: ['click'],
|
||||
setup(_p, { emit }) {
|
||||
return () => h('i', { class: 'edit-icon', onClick: (e: Event) => emit('click', e) });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicTooltip: defineComponent({
|
||||
name: 'TMagicTooltip',
|
||||
props: ['content', 'placement', 'effect'],
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-tooltip' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dataSourceService.get.mockImplementation((k: string) => {
|
||||
if (k === 'editable') return true;
|
||||
if (k === 'dataSources') return [{ id: 'd1', title: 'D1', methods: [] }];
|
||||
return null;
|
||||
});
|
||||
editorService.get.mockImplementation((k: string) => {
|
||||
if (k === 'root') return { items: [{ id: 'p1', name: 'page1' }] };
|
||||
if (k === 'stage') return { select: vi.fn() };
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataSourceList', () => {
|
||||
test('渲染 Tree 与节点工具', () => {
|
||||
const wrapper = mount(DataSourceList);
|
||||
expect(wrapper.findComponent({ name: 'TreeStub' }).exists()).toBe(true);
|
||||
expect(wrapper.findAll('.edit-icon').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('点击 Tree 节点选中组件', async () => {
|
||||
const stageSelect = vi.fn();
|
||||
editorService.get.mockImplementation((k: string) => {
|
||||
if (k === 'root') return { items: [{ id: 'p1', name: 'page1' }] };
|
||||
if (k === 'stage') return { select: stageSelect };
|
||||
return null;
|
||||
});
|
||||
const wrapper = mount(DataSourceList);
|
||||
await wrapper.find('.click-btn').trigger('click');
|
||||
expect(editorService.select).toHaveBeenCalledWith('cmp1');
|
||||
expect(stageSelect).toHaveBeenCalledWith('cmp1');
|
||||
});
|
||||
|
||||
test('右键 Tree 节点 emit node-contextmenu', async () => {
|
||||
const wrapper = mount(DataSourceList);
|
||||
await wrapper.find('.menu-btn').trigger('click');
|
||||
expect(wrapper.emitted('node-contextmenu')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('点击编辑图标 emit edit', async () => {
|
||||
const wrapper = mount(DataSourceList);
|
||||
const icons = wrapper.findAll('.edit-icon');
|
||||
await icons[0].trigger('click');
|
||||
expect(wrapper.emitted('edit')?.[0]?.[0]).toBe('d1');
|
||||
});
|
||||
|
||||
test('点击删除图标 emit remove (editable=true)', async () => {
|
||||
const wrapper = mount(DataSourceList);
|
||||
const icons = wrapper.findAll('.edit-icon');
|
||||
await icons[1].trigger('click');
|
||||
expect(wrapper.emitted('remove')?.[0]?.[0]).toBe('d1');
|
||||
});
|
||||
|
||||
test('editable=false 时不显示删除图标', () => {
|
||||
dataSourceService.get.mockImplementation((k: string) => {
|
||||
if (k === 'editable') return false;
|
||||
if (k === 'dataSources') return [{ id: 'd1', title: 'D1' }];
|
||||
return null;
|
||||
});
|
||||
const wrapper = mount(DataSourceList);
|
||||
expect(wrapper.findAll('.edit-icon').length).toBe(1);
|
||||
});
|
||||
|
||||
test('list 计算: dataSources 为空时为空数组', () => {
|
||||
dataSourceService.get.mockImplementation((k: string) => {
|
||||
if (k === 'editable') return true;
|
||||
if (k === 'dataSources') return [];
|
||||
return null;
|
||||
});
|
||||
const wrapper = mount(DataSourceList);
|
||||
expect(wrapper.findComponent({ name: 'TreeStub' }).props('data').length).toBe(0);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import DataSourceListPanel from '@editor/layouts/sidebar/data-source/DataSourceListPanel.vue';
|
||||
|
||||
const dataSourceService = {
|
||||
get: vi.fn(),
|
||||
getFormValue: vi.fn((t: string) => ({ id: 'fv', type: t })),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ dataSourceService }),
|
||||
}));
|
||||
|
||||
const editDialog = ref<any>(null);
|
||||
const dataSourceValues = ref<any>({});
|
||||
const dialogTitle = ref('');
|
||||
const editable = ref(true);
|
||||
const editHandler = vi.fn();
|
||||
const submitDataSourceHandler = vi.fn();
|
||||
vi.mock('@editor/hooks/use-data-source-edit', () => ({
|
||||
useDataSourceEdit: () => ({
|
||||
editDialog,
|
||||
dataSourceValues,
|
||||
dialogTitle,
|
||||
editable,
|
||||
editHandler,
|
||||
submitDataSourceHandler,
|
||||
}),
|
||||
}));
|
||||
|
||||
const nodeContentMenuHandler = vi.fn();
|
||||
const contentMenuHideHandler = vi.fn();
|
||||
const menuData = ref<any[]>([{ type: 'button', text: 'Edit' }]);
|
||||
vi.mock('@editor/layouts/sidebar/data-source/useContentMenu', () => ({
|
||||
useContentMenu: () => ({
|
||||
nodeContentMenuHandler,
|
||||
menuData,
|
||||
contentMenuHideHandler,
|
||||
}),
|
||||
}));
|
||||
|
||||
const filterFn = vi.fn();
|
||||
const dataSourceListNodeStatusMap = new Map<string, any>();
|
||||
vi.mock('@editor/layouts/sidebar/data-source/DataSourceList.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'DataSourceList',
|
||||
props: ['indent', 'nextLevelIndentIncrement'],
|
||||
emits: ['edit', 'remove', 'node-contextmenu'],
|
||||
setup(_p, { emit, expose }) {
|
||||
expose({
|
||||
filter: filterFn,
|
||||
nodeStatusMap: dataSourceListNodeStatusMap,
|
||||
});
|
||||
return () =>
|
||||
h('div', { class: 'fake-data-source-list' }, [
|
||||
h('button', { class: 'edit-btn', onClick: () => emit('edit', 'd1') }),
|
||||
h('button', { class: 'remove-btn', onClick: () => emit('remove', 'd1') }),
|
||||
h('button', { class: 'ctx-btn', onClick: (e: MouseEvent) => emit('node-contextmenu', e, { id: 'd1' }) }),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const editDialogShow = vi.fn();
|
||||
vi.mock('@editor/layouts/sidebar/data-source/DataSourceConfigPanel.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'DataSourceConfigPanel',
|
||||
props: ['disabled', 'values', 'title'],
|
||||
emits: ['submit', 'close'],
|
||||
setup(_p, { emit, expose }) {
|
||||
expose({ show: editDialogShow });
|
||||
return () =>
|
||||
h('div', { class: 'fake-config-panel' }, [h('button', { class: 'close-btn', onClick: () => emit('close') })]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/sidebar/data-source/DataSourceAddButton.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'DataSourceAddButton',
|
||||
props: ['addButtonText', 'addButtonConfig', 'datasourceTypeList'],
|
||||
emits: ['add'],
|
||||
setup(_p, { emit }) {
|
||||
return () =>
|
||||
h('button', {
|
||||
class: 'add-btn',
|
||||
onClick: () => emit('add', 'http'),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/SearchInput.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'SearchInput',
|
||||
emits: ['search'],
|
||||
setup(_p, { emit }) {
|
||||
return () => h('input', { class: 'fake-search', onInput: () => emit('search', 'a') });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const contentMenuShow = vi.fn();
|
||||
vi.mock('@editor/components/ContentMenu.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'ContentMenu',
|
||||
props: ['menuData'],
|
||||
emits: ['hide'],
|
||||
setup(_p, { emit, expose }) {
|
||||
expose({ show: contentMenuShow });
|
||||
return () =>
|
||||
h('div', { class: 'fake-ctx-menu' }, [h('button', { class: 'hide-btn', onClick: () => emit('hide') })]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { messageBoxConfirm } = vi.hoisted(() => ({ messageBoxConfirm: vi.fn(async () => true) }));
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicScrollbar: defineComponent({
|
||||
name: 'TMagicScrollbar',
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-scrollbar' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
tMagicMessageBox: { confirm: messageBoxConfirm },
|
||||
}));
|
||||
|
||||
const eventBusOn = vi.fn();
|
||||
const eventBus = { on: eventBusOn, emit: vi.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dataSourceService.get.mockReturnValue([{ text: 'Custom', type: 'custom' }]);
|
||||
dataSourceValues.value = {};
|
||||
editable.value = true;
|
||||
dataSourceListNodeStatusMap.clear();
|
||||
dataSourceListNodeStatusMap.set('d1', { selected: false });
|
||||
});
|
||||
|
||||
const mountIt = (props: any = {}) =>
|
||||
mount(DataSourceListPanel, {
|
||||
props: { customContentMenu: (m: any) => m, ...props } as any,
|
||||
global: { provide: { eventBus } },
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
describe('DataSourceListPanel', () => {
|
||||
test('渲染 DataSourceList / SearchInput', () => {
|
||||
const wrapper = mountIt();
|
||||
expect(wrapper.find('.fake-data-source-list').exists()).toBe(true);
|
||||
expect(wrapper.find('.fake-search').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('editable 为 false 时不渲染 AddButton', () => {
|
||||
editable.value = false;
|
||||
const wrapper = mountIt();
|
||||
expect(wrapper.find('.add-btn').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('AddButton 触发 dialog.show 与赋值', async () => {
|
||||
editDialog.value = { show: editDialogShow };
|
||||
const wrapper = mountIt();
|
||||
await wrapper.find('.add-btn').trigger('click');
|
||||
expect(editDialogShow).toHaveBeenCalled();
|
||||
expect(dialogTitle.value).toContain('HTTP');
|
||||
expect(dataSourceValues.value.type).toBe('http');
|
||||
});
|
||||
|
||||
test('SearchInput 调用 filter', async () => {
|
||||
const wrapper = mountIt();
|
||||
await wrapper.find('.fake-search').trigger('input');
|
||||
expect(filterFn).toHaveBeenCalledWith('a');
|
||||
});
|
||||
|
||||
test('DataSourceList edit/remove/contextmenu', async () => {
|
||||
const wrapper = mountIt();
|
||||
await wrapper.find('.edit-btn').trigger('click');
|
||||
expect(editHandler).toHaveBeenCalledWith('d1');
|
||||
await wrapper.find('.remove-btn').trigger('click');
|
||||
expect(messageBoxConfirm).toHaveBeenCalled();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(dataSourceService.remove).toHaveBeenCalledWith('d1');
|
||||
await wrapper.find('.ctx-btn').trigger('click');
|
||||
expect(nodeContentMenuHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('config-panel close 重置 selected', async () => {
|
||||
dataSourceListNodeStatusMap.set('d1', { selected: true });
|
||||
const wrapper = mountIt();
|
||||
await wrapper.find('.close-btn').trigger('click');
|
||||
expect(dataSourceListNodeStatusMap.get('d1').selected).toBe(false);
|
||||
});
|
||||
|
||||
test('dataSourceValues 变化时更新 selected', async () => {
|
||||
dataSourceListNodeStatusMap.set('d1', { selected: false });
|
||||
dataSourceListNodeStatusMap.set('d2', { selected: true });
|
||||
mountIt();
|
||||
dataSourceValues.value = { id: 'd1' };
|
||||
await nextTick();
|
||||
expect(dataSourceListNodeStatusMap.get('d1').selected).toBe(true);
|
||||
expect(dataSourceListNodeStatusMap.get('d2').selected).toBe(false);
|
||||
});
|
||||
|
||||
test('注册 eventBus.on', () => {
|
||||
mountIt();
|
||||
const events = eventBusOn.mock.calls.map((c: any[]) => c[0]);
|
||||
expect(events).toContain('edit-data-source');
|
||||
expect(events).toContain('remove-data-source');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { useContentMenu } from '@editor/layouts/sidebar/data-source/useContentMenu';
|
||||
|
||||
const MenuStub = defineComponent({
|
||||
name: 'ContentMenu',
|
||||
setup(_, { expose }) {
|
||||
expose({ show: vi.fn(), hide: vi.fn() });
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
|
||||
const mountHook = (eventBus: any = { emit: vi.fn() }) => {
|
||||
let result: ReturnType<typeof useContentMenu> | undefined;
|
||||
const hostComp = defineComponent({
|
||||
components: { MenuStub },
|
||||
setup(_, { expose }) {
|
||||
result = useContentMenu();
|
||||
expose({ result });
|
||||
return () => h(MenuStub, { ref: 'menu' });
|
||||
},
|
||||
});
|
||||
const wrapper = mount(hostComp, { global: { provide: { eventBus } } });
|
||||
return { wrapper, result: result!, eventBus };
|
||||
};
|
||||
|
||||
describe('data-source useContentMenu', () => {
|
||||
test('提供 menuData 和处理函数', () => {
|
||||
const { result } = mountHook();
|
||||
expect(result.menuData.length).toBe(3);
|
||||
});
|
||||
|
||||
test('编辑按钮 display 取决于 dataSourceService.get(editable)', () => {
|
||||
const { result } = mountHook();
|
||||
const editBtn = result.menuData[0] as any;
|
||||
expect(editBtn.display({ dataSourceService: { get: () => true } })).toBe(true);
|
||||
expect(editBtn.display({ dataSourceService: { get: () => false } })).toBe(false);
|
||||
});
|
||||
|
||||
test('编辑按钮: 选中后 emit edit-data-source', () => {
|
||||
const eventBus = { emit: vi.fn() };
|
||||
const { result } = mountHook(eventBus);
|
||||
result.nodeContentMenuHandler({ preventDefault: vi.fn() } as any, { type: 'ds', id: 'd1' } as any);
|
||||
(result.menuData[0] as any).handler({});
|
||||
expect(eventBus.emit).toHaveBeenCalledWith('edit-data-source', 'd1');
|
||||
});
|
||||
|
||||
test('编辑按钮: 未选中时不触发', () => {
|
||||
const eventBus = { emit: vi.fn() };
|
||||
const { result } = mountHook(eventBus);
|
||||
(result.menuData[0] as any).handler({});
|
||||
expect(eventBus.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('复制按钮: 调用 add 并使用克隆数据', () => {
|
||||
const { result } = mountHook();
|
||||
result.nodeContentMenuHandler({ preventDefault: vi.fn() } as any, { type: 'ds', id: 'd1' } as any);
|
||||
const dataSourceService = {
|
||||
getDataSourceById: vi.fn(() => ({ name: 'a' })),
|
||||
add: vi.fn(),
|
||||
};
|
||||
(result.menuData[1] as any).handler({ dataSourceService });
|
||||
expect(dataSourceService.add).toHaveBeenCalledWith({ name: 'a' });
|
||||
});
|
||||
|
||||
test('复制按钮: 未选中时不触发', () => {
|
||||
const { result } = mountHook();
|
||||
const dataSourceService = { getDataSourceById: vi.fn(), add: vi.fn() };
|
||||
(result.menuData[1] as any).handler({ dataSourceService });
|
||||
expect(dataSourceService.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('复制按钮: 找不到 ds 直接返回', () => {
|
||||
const { result } = mountHook();
|
||||
result.nodeContentMenuHandler({ preventDefault: vi.fn() } as any, { type: 'ds', id: 'd1' } as any);
|
||||
const dataSourceService = { getDataSourceById: vi.fn(() => null), add: vi.fn() };
|
||||
(result.menuData[1] as any).handler({ dataSourceService });
|
||||
expect(dataSourceService.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('删除按钮: emit remove-data-source', () => {
|
||||
const eventBus = { emit: vi.fn() };
|
||||
const { result } = mountHook(eventBus);
|
||||
result.nodeContentMenuHandler({ preventDefault: vi.fn() } as any, { type: 'ds', id: 'd1' } as any);
|
||||
(result.menuData[2] as any).handler({});
|
||||
expect(eventBus.emit).toHaveBeenCalledWith('remove-data-source', 'd1');
|
||||
});
|
||||
|
||||
test('删除按钮: 未选中时不触发', () => {
|
||||
const eventBus = { emit: vi.fn() };
|
||||
const { result } = mountHook(eventBus);
|
||||
(result.menuData[2] as any).handler({});
|
||||
expect(eventBus.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('nodeContentMenuHandler: 非 ds 类型不显示菜单', () => {
|
||||
const eventBus = { emit: vi.fn() };
|
||||
const { result } = mountHook(eventBus);
|
||||
const event = { preventDefault: vi.fn() };
|
||||
result.nodeContentMenuHandler(event as any, { type: 'other', id: 'a' } as any);
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
(result.menuData[2] as any).handler({});
|
||||
expect(eventBus.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('contentMenuHideHandler 重置 selectId', () => {
|
||||
const eventBus = { emit: vi.fn() };
|
||||
const { result } = mountHook(eventBus);
|
||||
result.nodeContentMenuHandler({ preventDefault: vi.fn() } as any, { type: 'ds', id: 'd1' } as any);
|
||||
result.contentMenuHideHandler();
|
||||
(result.menuData[2] as any).handler({});
|
||||
expect(eventBus.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('nodeContentMenuHandler: data.id 缺失时 selectId=空', () => {
|
||||
const eventBus = { emit: vi.fn() };
|
||||
const { result } = mountHook(eventBus);
|
||||
result.nodeContentMenuHandler({ preventDefault: vi.fn() } as any, { type: 'ds' } as any);
|
||||
(result.menuData[2] as any).handler({});
|
||||
expect(eventBus.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import LayerMenu from '@editor/layouts/sidebar/layer/LayerMenu.vue';
|
||||
|
||||
const editorService = {
|
||||
get: vi.fn(),
|
||||
add: vi.fn(),
|
||||
};
|
||||
const componentListService = {
|
||||
getList: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService, componentListService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils/content-menu', () => ({
|
||||
useCopyMenu: () => ({ type: 'button', text: 'copy' }),
|
||||
usePasteMenu: () => ({ type: 'button', text: 'paste' }),
|
||||
useDeleteMenu: () => ({ type: 'button', text: 'delete' }),
|
||||
useMoveToMenu: () => ({ type: 'button', text: 'moveto' }),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||
return {
|
||||
...actual,
|
||||
isPage: (n: any) => n?.type === 'page',
|
||||
isPageFragment: (n: any) => n?.type === 'page-fragment',
|
||||
};
|
||||
});
|
||||
|
||||
const showMock = vi.fn();
|
||||
vi.mock('@editor/components/ContentMenu.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'ContentMenu',
|
||||
props: ['menuData'],
|
||||
setup(props, { expose }) {
|
||||
expose({ show: showMock, hide: vi.fn(), menuData: props.menuData });
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{ class: 'fake-menu' },
|
||||
(props.menuData || []).map((m: any) =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: ['menu-item', `m-${m.text}`],
|
||||
style: { display: m.display && !m.display() ? 'none' : '' },
|
||||
onClick: () => m.handler?.(),
|
||||
},
|
||||
m.text,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
editorService.get.mockImplementation((k: string) => {
|
||||
if (k === 'node') return { id: 'p1', type: 'page', items: [] };
|
||||
if (k === 'nodes') return [{ id: 'p1' }];
|
||||
return null;
|
||||
});
|
||||
componentListService.getList.mockReturnValue([
|
||||
{ items: [{ text: 'btn', type: 'button', icon: 'i' }] },
|
||||
{ items: [{ text: 'div', type: 'div' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('LayerMenu', () => {
|
||||
test('渲染菜单数据', () => {
|
||||
const wrapper = mount(LayerMenu, {
|
||||
props: { layerContentMenu: [], customContentMenu: (m: any) => m } as any,
|
||||
});
|
||||
expect(wrapper.find('.m-全部折叠').exists()).toBe(true);
|
||||
expect(wrapper.find('.m-新增').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('全部折叠 emit collapse-all', async () => {
|
||||
const wrapper = mount(LayerMenu, {
|
||||
props: { layerContentMenu: [], customContentMenu: (m: any) => m } as any,
|
||||
});
|
||||
await wrapper.find('.m-全部折叠').trigger('click');
|
||||
expect(wrapper.emitted('collapse-all')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('show 调用 menuRef.show', () => {
|
||||
const wrapper = mount(LayerMenu, {
|
||||
props: { layerContentMenu: [], customContentMenu: (m: any) => m } as any,
|
||||
});
|
||||
const event = new MouseEvent('contextmenu');
|
||||
(wrapper.vm as any).show(event);
|
||||
expect(showMock).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
test('node.type 为 tabs 时新增 sub menu 包含标签页', () => {
|
||||
editorService.get.mockImplementation((k: string) => {
|
||||
if (k === 'node') return { id: 't', type: 'tabs', items: [] };
|
||||
if (k === 'nodes') return [{}];
|
||||
return null;
|
||||
});
|
||||
const customContentMenu = vi.fn((m) => m);
|
||||
mount(LayerMenu, {
|
||||
props: { layerContentMenu: [], customContentMenu } as any,
|
||||
});
|
||||
const arg = customContentMenu.mock.calls[0][0];
|
||||
const addItem = arg.find((m: any) => m.text === '新增');
|
||||
expect(addItem.items[0].text).toBe('标签页');
|
||||
addItem.items[0].handler();
|
||||
expect(editorService.add).toHaveBeenCalledWith({ type: 'tab-pane' });
|
||||
});
|
||||
|
||||
test('node.items 时根据组件列表生成子菜单 (含分隔)', () => {
|
||||
editorService.get.mockImplementation((k: string) => {
|
||||
if (k === 'node') return { id: 'p1', type: 'container', items: [] };
|
||||
if (k === 'nodes') return [{}];
|
||||
return null;
|
||||
});
|
||||
const customContentMenu = vi.fn((m) => m);
|
||||
mount(LayerMenu, {
|
||||
props: { layerContentMenu: [], customContentMenu } as any,
|
||||
});
|
||||
const arg = customContentMenu.mock.calls[0][0];
|
||||
const addItem = arg.find((m: any) => m.text === '新增');
|
||||
const labels = addItem.items.map((it: any) => it.text || it.type);
|
||||
expect(labels).toContain('btn');
|
||||
expect(labels).toContain('divider');
|
||||
expect(labels).toContain('div');
|
||||
});
|
||||
|
||||
test('子菜单按钮 handler 调用 editorService.add', () => {
|
||||
editorService.get.mockImplementation((k: string) => {
|
||||
if (k === 'node') return { id: 'p1', type: 'container', items: [] };
|
||||
if (k === 'nodes') return [{}];
|
||||
return null;
|
||||
});
|
||||
const customContentMenu = vi.fn((m) => m);
|
||||
mount(LayerMenu, {
|
||||
props: { layerContentMenu: [], customContentMenu } as any,
|
||||
});
|
||||
const arg = customContentMenu.mock.calls[0][0];
|
||||
const addItem = arg.find((m: any) => m.text === '新增');
|
||||
addItem.items[0].handler();
|
||||
expect(editorService.add).toHaveBeenCalledWith({ name: 'btn', type: 'button' });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import LayerNodeTool from '@editor/layouts/sidebar/layer/LayerNodeTool.vue';
|
||||
|
||||
const editorService = {
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService }),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicButton: defineComponent({
|
||||
name: 'TMagicButton',
|
||||
props: ['type', 'icon', 'title', 'link'],
|
||||
emits: ['click'],
|
||||
setup(_props, { emit, slots }) {
|
||||
return () => h('button', { onClick: (e: Event) => emit('click', e) }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('LayerNodeTool', () => {
|
||||
test('page 类型不渲染按钮', () => {
|
||||
const wrapper = mount(LayerNodeTool, { props: { data: { id: 'p1', type: 'page' } as any } });
|
||||
expect(wrapper.find('button').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('点击按钮切换 visible 状态 (true -> false)', async () => {
|
||||
const wrapper = mount(LayerNodeTool, {
|
||||
props: { data: { id: 'n1', type: 'text', visible: true } as any },
|
||||
});
|
||||
await wrapper.find('button').trigger('click');
|
||||
expect(editorService.update).toHaveBeenCalledWith({ id: 'n1', visible: false });
|
||||
});
|
||||
|
||||
test('点击按钮切换 visible 状态 (false -> true)', async () => {
|
||||
editorService.update.mockClear();
|
||||
const wrapper = mount(LayerNodeTool, {
|
||||
props: { data: { id: 'n2', type: 'text', visible: false } as any },
|
||||
});
|
||||
await wrapper.find('button').trigger('click');
|
||||
expect(editorService.update).toHaveBeenCalledWith({ id: 'n2', visible: true });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import LayerPanel from '@editor/layouts/sidebar/layer/LayerPanel.vue';
|
||||
|
||||
const editorService = { get: vi.fn() };
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService }),
|
||||
}));
|
||||
|
||||
const nodeStatusMap: Map<any, any> = new Map([
|
||||
['p1', { expand: true }],
|
||||
['n1', { expand: true }],
|
||||
]);
|
||||
vi.mock('@editor/layouts/sidebar/layer/use-node-status', () => ({
|
||||
useNodeStatus: () => ({ nodeStatusMap: { value: nodeStatusMap } }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/sidebar/layer/use-keybinding', () => ({
|
||||
useKeybinding: () => ({ isCtrlKeyDown: { value: false } }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/sidebar/layer/use-drag', () => ({
|
||||
useDrag: () => ({
|
||||
handleDragStart: vi.fn(),
|
||||
handleDragEnd: vi.fn(),
|
||||
handleDragLeave: vi.fn(),
|
||||
handleDragOver: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const nodeDblclickHandler = vi.fn();
|
||||
vi.mock('@editor/layouts/sidebar/layer/use-click', () => ({
|
||||
useClick: () => ({
|
||||
nodeClickHandler: vi.fn(),
|
||||
nodeDblclickHandler,
|
||||
nodeContentMenuHandler: vi.fn(),
|
||||
highlightHandler: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/hooks/use-filter', () => ({
|
||||
useFilter: () => ({ filterTextChangeHandler: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/SearchInput.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'SearchInput',
|
||||
emits: ['search'],
|
||||
setup() {
|
||||
return () => h('input', { class: 'fake-search' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Tree.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'TreeStub',
|
||||
props: ['data', 'nodeStatusMap', 'indent', 'nextLevelIndentIncrement', 'isExpandable'],
|
||||
emits: [
|
||||
'node-dragover',
|
||||
'node-dragstart',
|
||||
'node-dragleave',
|
||||
'node-dragend',
|
||||
'node-contextmenu',
|
||||
'node-mouseenter',
|
||||
'node-click',
|
||||
'node-dblclick',
|
||||
],
|
||||
setup(_p, { emit, slots }) {
|
||||
return () =>
|
||||
h('div', { class: 'fake-tree' }, [
|
||||
h('button', {
|
||||
class: 'dblclick-btn',
|
||||
onClick: () => emit('node-dblclick', new MouseEvent('dblclick'), { id: 'a' }),
|
||||
}),
|
||||
slots['tree-node-tool']?.({ data: { id: 'a', type: 'node' } }),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/sidebar/layer/LayerMenu.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'LayerMenu',
|
||||
props: ['layerContentMenu', 'customContentMenu'],
|
||||
emits: ['collapse-all'],
|
||||
setup(_p, { expose, emit }) {
|
||||
expose({ show: vi.fn() });
|
||||
return () => h('button', { class: 'collapse-all-btn', onClick: () => emit('collapse-all') });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/layouts/sidebar/layer/LayerNodeTool.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'LayerNodeTool',
|
||||
props: ['data'],
|
||||
setup() {
|
||||
return () => h('div', { class: 'fake-node-tool' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicScrollbar: defineComponent({
|
||||
name: 'TMagicScrollbar',
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-scrollbar' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
editorService.get.mockReturnValue({ id: 'p1', items: [{ id: 'n1' }] });
|
||||
nodeStatusMap.clear();
|
||||
nodeStatusMap.set('p1', { expand: true });
|
||||
nodeStatusMap.set('n1', { expand: true });
|
||||
});
|
||||
|
||||
describe('LayerPanel', () => {
|
||||
test('渲染 Tree 与 LayerMenu', () => {
|
||||
const wrapper = mount(LayerPanel, {
|
||||
props: { layerContentMenu: [], customContentMenu: (m: any) => m } as any,
|
||||
});
|
||||
expect(wrapper.findComponent({ name: 'TreeStub' }).exists()).toBe(true);
|
||||
expect(wrapper.findComponent({ name: 'LayerMenu' }).exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('page 为空时不渲染 Tree', () => {
|
||||
editorService.get.mockReturnValue(null);
|
||||
const wrapper = mount(LayerPanel, {
|
||||
props: { layerContentMenu: [], customContentMenu: (m: any) => m } as any,
|
||||
});
|
||||
expect(wrapper.findComponent({ name: 'TreeStub' }).exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('双击 emit node-dblclick', async () => {
|
||||
const wrapper = mount(LayerPanel, {
|
||||
props: { layerContentMenu: [], customContentMenu: (m: any) => m } as any,
|
||||
});
|
||||
await wrapper.find('.dblclick-btn').trigger('click');
|
||||
expect(nodeDblclickHandler).toHaveBeenCalled();
|
||||
expect(wrapper.emitted('node-dblclick')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('beforeNodeDblclick 返回 false 时阻止', async () => {
|
||||
const wrapper = mount(LayerPanel, {
|
||||
props: {
|
||||
layerContentMenu: [],
|
||||
customContentMenu: (m: any) => m,
|
||||
beforeNodeDblclick: async () => false,
|
||||
} as any,
|
||||
});
|
||||
await wrapper.find('.dblclick-btn').trigger('click');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(nodeDblclickHandler).not.toHaveBeenCalled();
|
||||
expect(wrapper.emitted('node-dblclick')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('collapse-all 折叠所有节点 (除 page)', async () => {
|
||||
mount(LayerPanel, {
|
||||
attachTo: document.body,
|
||||
props: { layerContentMenu: [], customContentMenu: (m: any) => m } as any,
|
||||
});
|
||||
const btn = document.body.querySelector('.collapse-all-btn') as HTMLElement;
|
||||
btn.click();
|
||||
expect(nodeStatusMap.get('p1').expand).toBe(true);
|
||||
expect(nodeStatusMap.get('n1').expand).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { computed, nextTick, ref, shallowRef } from 'vue';
|
||||
|
||||
import { useClick } from '@editor/layouts/sidebar/layer/use-click';
|
||||
import { updateStatus } from '@editor/utils/tree';
|
||||
|
||||
vi.mock('@editor/utils/tree', () => ({
|
||||
updateStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||
return {
|
||||
...actual,
|
||||
isPage: (n: any) => n.type === 'page',
|
||||
isPageFragment: (n: any) => n.type === 'page-fragment',
|
||||
getElById: () => (_doc: any, id: any) => (id === 'no-el' ? null : { id }),
|
||||
};
|
||||
});
|
||||
|
||||
const mkServices = () => {
|
||||
const stage = { select: vi.fn(), multiSelect: vi.fn(), highlight: vi.fn() };
|
||||
const overlayStage = { select: vi.fn(), multiSelect: vi.fn(), highlight: vi.fn() };
|
||||
const editorState: Record<string, any> = {
|
||||
disabledMultiSelect: false,
|
||||
alwaysMultiSelect: false,
|
||||
nodes: [],
|
||||
stage,
|
||||
};
|
||||
const editorService = {
|
||||
get: vi.fn((k: string) => editorState[k]),
|
||||
select: vi.fn(),
|
||||
multiSelect: vi.fn(),
|
||||
highlight: vi.fn(),
|
||||
};
|
||||
const stageOverlayService = {
|
||||
get: vi.fn((k: string) => {
|
||||
if (k === 'stage') return overlayStage;
|
||||
if (k === 'stageOptions') return { canSelect: undefined };
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
const uiService = {
|
||||
get: vi.fn(() => false),
|
||||
};
|
||||
return { editorService, stageOverlayService, uiService, editorState, stage, overlayStage };
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const mouseEv = {} as MouseEvent;
|
||||
|
||||
const nodeData = (extra: any = {}): any => ({ ...extra });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useClick', () => {
|
||||
test('select 单选: 调用 editorService.select / stage.select', async () => {
|
||||
const services = mkServices();
|
||||
const isCtrl = ref(false);
|
||||
const nodeStatusMap = computed(() => new Map());
|
||||
const menuRef = shallowRef(null);
|
||||
const { nodeClickHandler } = useClick(services as any, isCtrl, nodeStatusMap, menuRef);
|
||||
nodeClickHandler(mouseEv, nodeData({ id: 'a', items: [], type: 'node' }));
|
||||
await nextTick();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(services.editorService.select).toHaveBeenCalled();
|
||||
expect(services.stage.select).toHaveBeenCalledWith('a');
|
||||
});
|
||||
|
||||
test('uiSelectMode 模式 触发自定义事件', () => {
|
||||
const services = mkServices();
|
||||
services.uiService.get = vi.fn(() => true);
|
||||
const dispatchSpy = vi.spyOn(document, 'dispatchEvent');
|
||||
const { nodeClickHandler } = useClick(
|
||||
services as any,
|
||||
ref(false),
|
||||
computed(() => new Map()),
|
||||
shallowRef(null),
|
||||
);
|
||||
nodeClickHandler(mouseEv, nodeData({ id: 'a', type: 'node' }));
|
||||
expect(dispatchSpy).toHaveBeenCalled();
|
||||
dispatchSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('多选模式 切换 multiSelect', async () => {
|
||||
const services = mkServices();
|
||||
services.editorState.alwaysMultiSelect = true;
|
||||
services.editorState.nodes = [{ id: 'b', type: 'node' }];
|
||||
const { nodeClickHandler } = useClick(
|
||||
services as any,
|
||||
ref(false),
|
||||
computed(() => new Map()),
|
||||
shallowRef(null),
|
||||
);
|
||||
nodeClickHandler(mouseEv, nodeData({ id: 'a', type: 'node' }));
|
||||
await nextTick();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(services.editorService.multiSelect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('多选模式 + isPage data 时不操作', async () => {
|
||||
const services = mkServices();
|
||||
services.editorState.alwaysMultiSelect = true;
|
||||
const { nodeClickHandler } = useClick(
|
||||
services as any,
|
||||
ref(false),
|
||||
computed(() => new Map()),
|
||||
shallowRef(null),
|
||||
);
|
||||
nodeClickHandler(mouseEv, nodeData({ id: 'p', type: 'page', items: [] }));
|
||||
await nextTick();
|
||||
expect(services.editorService.multiSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('node items 存在且非多选 则展开节点', () => {
|
||||
const services = mkServices();
|
||||
const map = new Map();
|
||||
const { nodeClickHandler } = useClick(
|
||||
services as any,
|
||||
ref(false),
|
||||
computed(() => map),
|
||||
shallowRef(null),
|
||||
);
|
||||
nodeClickHandler(mouseEv, nodeData({ id: 'a', items: [{ id: 'b' }], type: 'node' }));
|
||||
expect(updateStatus).toHaveBeenCalledWith(map, 'a', { expand: true });
|
||||
});
|
||||
|
||||
test('nodeDblclickHandler 切换展开状态', () => {
|
||||
const services = mkServices();
|
||||
const map: any = new Map([['a', { expand: false }]]);
|
||||
const { nodeDblclickHandler } = useClick(
|
||||
services as any,
|
||||
ref(false),
|
||||
computed(() => map),
|
||||
shallowRef(null),
|
||||
);
|
||||
nodeDblclickHandler(mouseEv, nodeData({ id: 'a', items: [{ id: 'b' }] }));
|
||||
expect(updateStatus).toHaveBeenCalledWith(map, 'a', { expand: true });
|
||||
});
|
||||
|
||||
test('nodeContextMenuHandler 显示菜单', () => {
|
||||
const services = mkServices();
|
||||
const menuRef: any = shallowRef({ show: vi.fn() });
|
||||
const { nodeContentMenuHandler } = useClick(
|
||||
services as any,
|
||||
ref(false),
|
||||
computed(() => new Map()),
|
||||
menuRef,
|
||||
);
|
||||
const ev: any = { preventDefault: vi.fn() };
|
||||
nodeContentMenuHandler(ev, nodeData({ id: 'a', type: 'node' }));
|
||||
expect(ev.preventDefault).toHaveBeenCalled();
|
||||
expect(menuRef.value.show).toHaveBeenCalledWith(ev);
|
||||
});
|
||||
|
||||
test('select 抛错: data 没有 id', async () => {
|
||||
const services = mkServices();
|
||||
services.editorState.alwaysMultiSelect = false;
|
||||
const onUnhandled = vi.fn();
|
||||
process.on('unhandledRejection', onUnhandled);
|
||||
const { nodeClickHandler } = useClick(
|
||||
services as any,
|
||||
ref(false),
|
||||
computed(() => new Map()),
|
||||
shallowRef(null),
|
||||
);
|
||||
nodeClickHandler(mouseEv, nodeData({ type: 'node' }));
|
||||
await nextTick();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
process.off('unhandledRejection', onUnhandled);
|
||||
expect(services.editorService.select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('canSelect 函数返回 false 时不选中', async () => {
|
||||
const services = mkServices();
|
||||
services.stageOverlayService.get = vi.fn((k: string) => {
|
||||
if (k === 'stageOptions') return { canSelect: () => false };
|
||||
return { select: vi.fn(), multiSelect: vi.fn(), highlight: vi.fn() };
|
||||
});
|
||||
services.editorState.stage = {
|
||||
...services.stage,
|
||||
renderer: { contentWindow: { document: {} } },
|
||||
};
|
||||
const { nodeClickHandler } = useClick(
|
||||
services as any,
|
||||
ref(false),
|
||||
computed(() => new Map()),
|
||||
shallowRef(null),
|
||||
);
|
||||
nodeClickHandler(mouseEv, nodeData({ id: 'a', type: 'node' }));
|
||||
await nextTick();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(services.editorService.select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('highlightHandler 被节流但最终触发', async () => {
|
||||
const services = mkServices();
|
||||
const { highlightHandler } = useClick(
|
||||
services as any,
|
||||
ref(false),
|
||||
computed(() => new Map()),
|
||||
shallowRef(null),
|
||||
);
|
||||
highlightHandler(mouseEv, nodeData({ id: 'a' }));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(services.editorService.highlight).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user