test: 完善测试用例

This commit is contained in:
roymondchen 2026-05-14 15:26:22 +08:00
parent 258617536d
commit ab6918f43d
151 changed files with 21040 additions and 329 deletions

View File

@ -1 +1 @@
npm test
npm run test

View File

@ -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",

View File

@ -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);
});
});

View 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');
});
});

View 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();
});
});

View 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']);
});
});

View 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');
});
});

View 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中包含非法配置/);
});
});

View 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 });
}
});
});

View File

@ -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);
});
});

View 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('compActionHandlermethod 是数组时取 [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);
});
});

View 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();
});
});

View 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();
});
});

View File

@ -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();
});
});

View File

@ -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();
}
});
});

View 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;
}
});
});

View 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();
});
});

View File

@ -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);
});
});

View 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);
});
});

View File

@ -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);
});
});

View File

@ -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({});
});
});

View File

@ -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([]);
});
});

View 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();
});
});

View 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');
});
});

View 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();
});
});

View 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);
});
});

View 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();
});
});

View 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);
});
});

View 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');
});
});

View 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();
});
});

View 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();
});
});

View 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');
});
});

View 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();
});
});

View 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();
});
});

View 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',
]),
);
});
});

View 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);
});
});

View 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');
});
});

View 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();
});
});

View 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);
});
});

View 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([]);
});
});

View 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');
});
});

View 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);
});
});

View 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('属性keya已存在');
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);
});
});

View 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();
});
});

View 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);
});
});

View 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');
});
});

View 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 } });
});
});

View 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);
});
});

View 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([]);
});
});

View 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);
});
});

View 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() {}');
});
});

View 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([]);
});
});

View 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');
});
});

View 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']);
});
});

View 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 { 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);
});
});

View 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 { 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);
});
});

View File

@ -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');
});
});

View File

@ -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');
});
});

View 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);
});
});

View 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);
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View File

@ -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);
});
});

View 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();
});
});

View 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();
});
});

View 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 changedragStart/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();
});
});

View File

@ -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 });
});
});

View 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);
});
});

View 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);
});
});

View 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);
});
});

View 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();
});
});

View 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);
});
});

View 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 不是 reactivestage watch 不会触发,跳过该测试场景
});

View 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 不能为空');
});
});

View 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();
});
});

View 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();
});
});

View 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');
});
});

View 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);
});
});

View 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;
});
});

View File

@ -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);
});
});

View 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);
});
});

View 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();
});
});

View 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' });
});
});

View File

@ -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));
});
});

View File

@ -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);
});
});

View File

@ -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();
});
});

View 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();
});
});

View 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 } 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');
});
});

View 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, 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();
});
});

View File

@ -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();
});
});

View File

@ -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);
});
});

View 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, 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');
});
});

View File

@ -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);
});
});

View File

@ -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');
});
});

View File

@ -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();
});
});

View 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 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' });
});
});

View File

@ -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 });
});
});

View File

@ -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);
});
});

View 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 { 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