diff --git a/.husky/pre-push b/.husky/pre-push index 72c4429b..e37998f9 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1 @@ -npm test +npm run test diff --git a/package.json b/package.json index 8c050eee..87807f05 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli/tests/Core.spec.ts b/packages/cli/tests/Core.spec.ts index c851660e..55ad452b 100644 --- a/packages/cli/tests/Core.spec.ts +++ b/packages/cli/tests/Core.spec.ts @@ -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); + }); }); diff --git a/packages/cli/tests/allowTs.spec.ts b/packages/cli/tests/allowTs.spec.ts new file mode 100644 index 00000000..1f83a96a --- /dev/null +++ b/packages/cli/tests/allowTs.spec.ts @@ -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'); + }); +}); diff --git a/packages/cli/tests/cli.spec.ts b/packages/cli/tests/cli.spec.ts new file mode 100644 index 00000000..88edcae6 --- /dev/null +++ b/packages/cli/tests/cli.spec.ts @@ -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(); + }); +}); diff --git a/packages/cli/tests/commands.spec.ts b/packages/cli/tests/commands.spec.ts new file mode 100644 index 00000000..5b013b3b --- /dev/null +++ b/packages/cli/tests/commands.spec.ts @@ -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']); + }); +}); diff --git a/packages/cli/tests/prepareEntryFile.spec.ts b/packages/cli/tests/prepareEntryFile.spec.ts new file mode 100644 index 00000000..9b85b17f --- /dev/null +++ b/packages/cli/tests/prepareEntryFile.spec.ts @@ -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 = { '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'); + 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'); + 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) => ({ + ...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'); + }); +}); diff --git a/packages/cli/tests/resolveAppPackages.spec.ts b/packages/cli/tests/resolveAppPackages.spec.ts new file mode 100644 index 00000000..2f0ed885 --- /dev/null +++ b/packages/cli/tests/resolveAppPackages.spec.ts @@ -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'), ''); + + 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'), ''); + 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'), ''); + + 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'), ''); + + 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中包含非法配置/); + }); +}); diff --git a/packages/cli/tests/utils.spec.ts b/packages/cli/tests/utils.spec.ts new file mode 100644 index 00000000..0237546b --- /dev/null +++ b/packages/cli/tests/utils.spec.ts @@ -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 }); + } + }); +}); diff --git a/packages/core/tests/App.spec.ts b/packages/core/tests/App.spec.ts index fa3ce015..82df2595 100644 --- a/packages/core/tests/App.spec.ts +++ b/packages/core/tests/App.spec.ts @@ -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); + }); +}); diff --git a/packages/core/tests/EventHelper.spec.ts b/packages/core/tests/EventHelper.spec.ts new file mode 100644 index 00000000..2987f2e9 --- /dev/null +++ b/packages/core/tests/EventHelper.spec.ts @@ -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 => ({ + 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; + let afterHandler: ReturnType; + + beforeEach(() => { + beforeHandler = vi.fn(); + afterHandler = vi.fn(); + }); + + test('emit click 时执行 EventConfig.actions 并触发 before/after 钩子', async () => { + const fromInstance = { doIt: vi.fn() }; + const toInstance = { doIt: vi.fn() }; + + const app = new App({ + beforeEventHandler: beforeHandler, + afterEventHandler: afterHandler, + config: createDsl({ + items: [ + { + type: NodeType.PAGE, + id: 'page_1', + items: [ + { + id: 'btn_1', + type: 'button', + events: [ + { + name: 'click', + actions: [{ actionType: ActionType.COMP, to: 'btn_2', method: 'doIt' }], + }, + ], + }, + { id: 'btn_2', type: 'button' }, + ], + }, + ], + } as any), + }); + + const fromNode = app.getNode('btn_1')!; + const toNode = app.getNode('btn_2')!; + fromNode.setInstance(fromInstance); + toNode.setInstance(toInstance); + + app.emit('click', fromNode, 'extraArg'); + await flushAsync(); + + expect(beforeHandler).toHaveBeenCalled(); + expect(afterHandler).toHaveBeenCalled(); + expect(toInstance.doIt).toHaveBeenCalled(); + expect(toInstance.doIt.mock.calls[0][1]).toBe('extraArg'); + }); + + test('actions 中如果 flowState.isAbort 为 true 会中断后续 action', async () => { + const action2Spy = vi.fn(); + const codeBlocks = { + abortCode: { + name: 'abortCode', + params: [], + content: ({ flowState }: any) => { + flowState.abort(); + }, + }, + shouldNotRun: { + name: 'shouldNotRun', + params: [], + content: action2Spy, + }, + }; + + const app = new App({ + config: createDsl({ + items: [ + { + type: NodeType.PAGE, + id: 'page_1', + items: [ + { + id: 'btn_1', + type: 'button', + events: [ + { + name: 'click', + actions: [ + { actionType: ActionType.CODE, codeId: 'abortCode' } as any, + { actionType: ActionType.CODE, codeId: 'shouldNotRun' } as any, + ], + }, + ], + }, + ], + }, + ], + codeBlocks, + } as any), + }); + + app.emit('click', app.getNode('btn_1')!); + await flushAsync(); + await flushAsync(); + + expect(action2Spy).not.toHaveBeenCalled(); + }); + + test('CODE action 在 NODE_DISABLE_CODE_BLOCK_KEY=true 时跳过', async () => { + const codeFn = vi.fn(); + const app = new App({ + config: createDsl({ + codeBlocks: { c: { name: 'c', params: [], content: codeFn } }, + items: [ + { + type: NodeType.PAGE, + id: 'page_1', + items: [ + { + id: 'btn_1', + type: 'button', + [NODE_DISABLE_CODE_BLOCK_KEY]: true, + events: [ + { + name: 'click', + actions: [{ actionType: ActionType.CODE, codeId: 'c' } as any], + }, + ], + }, + ], + }, + ], + } as any), + }); + + app.emit('click', app.getNode('btn_1')!); + await flushAsync(); + expect(codeFn).not.toHaveBeenCalled(); + }); + + test('DATA_SOURCE action 正常执行时通过 runDataSourceMethod 调用', async () => { + const methodFn = vi.fn().mockResolvedValue('ok'); + const app = new App({ + config: createDsl({ + dataSources: [ + { + id: 'ds_1', + type: 'base', + fields: [], + events: [], + methods: [{ name: 'fetch', params: [], content: methodFn }], + }, + ], + items: [ + { + type: NodeType.PAGE, + id: 'page_1', + items: [ + { + id: 'btn_1', + type: 'button', + events: [ + { + name: 'click', + actions: [ + { + actionType: ActionType.DATA_SOURCE, + dataSourceMethod: ['ds_1', 'fetch'], + params: { x: 1 }, + } as any, + ], + }, + ], + }, + ], + }, + ], + } as any), + }); + + app.emit('click', app.getNode('btn_1')!); + await flushAsync(); + await flushAsync(); + expect(methodFn).toHaveBeenCalled(); + expect(methodFn.mock.calls[0][0].params).toEqual({ x: 1 }); + }); + + test('DATA_SOURCE action 在 NODE_DISABLE_DATA_SOURCE_KEY=true 时跳过', async () => { + const methodFn = vi.fn(); + const app = new App({ + config: createDsl({ + dataSources: [ + { + id: 'ds_1', + type: 'base', + fields: [], + events: [], + methods: [{ name: 'fetch', params: [], content: methodFn }], + }, + ], + items: [ + { + type: NodeType.PAGE, + id: 'page_1', + items: [ + { + id: 'btn_1', + type: 'button', + [NODE_DISABLE_DATA_SOURCE_KEY]: true, + events: [ + { + name: 'click', + actions: [ + { + actionType: ActionType.DATA_SOURCE, + dataSourceMethod: ['ds_1', 'fetch'], + } as any, + ], + }, + ], + }, + ], + }, + ], + } as any), + }); + + app.emit('click', app.getNode('btn_1')!); + await flushAsync(); + expect(methodFn).not.toHaveBeenCalled(); + }); + + test('actionHandler 抛错时调用 errorHandler', async () => { + const errorHandler = vi.fn(); + const app = new App({ + errorHandler, + config: createDsl({ + codeBlocks: { + boom: { + name: 'boom', + params: [], + content: () => { + throw new Error('boom'); + }, + }, + }, + items: [ + { + type: NodeType.PAGE, + id: 'page_1', + items: [ + { + id: 'btn_1', + type: 'button', + events: [ + { + name: 'click', + actions: [{ actionType: ActionType.CODE, codeId: 'boom' } as any], + }, + ], + }, + ], + }, + ], + } as any), + }); + + app.emit('click', app.getNode('btn_1')!); + await flushAsync(); + await flushAsync(); + expect(errorHandler).toHaveBeenCalled(); + }); + + test('兼容 DeprecatedEventConfig:没有 actions 字段时走 compActionHandler', async () => { + const targetInstance = { ping: vi.fn() }; + const app = new App({ + config: createDsl({ + items: [ + { + type: NodeType.PAGE, + id: 'page_1', + items: [ + { + id: 'btn_1', + type: 'button', + events: [{ name: 'click', to: 'btn_2', method: 'ping' } as any], + }, + { id: 'btn_2', type: 'button' }, + ], + }, + ], + } as any), + }); + + const fromNode = app.getNode('btn_1')!; + app.getNode('btn_2')!.setInstance(targetInstance); + + app.emit('click', fromNode); + await flushAsync(); + + expect(targetInstance.ping).toHaveBeenCalled(); + }); + + test('compActionHandler 找不到目标节点时进入 eventQueue', async () => { + const app = new App({ + config: createDsl({ + items: [ + { + type: NodeType.PAGE, + id: 'page_1', + items: [ + { + id: 'btn_1', + type: 'button', + events: [ + { + name: 'click', + actions: [{ actionType: ActionType.COMP, to: 'not-exist', method: 'foo' }], + }, + ], + }, + ], + }, + ], + } as any), + }); + + app.emit('click', app.getNode('btn_1')!); + await flushAsync(); + + expect(app.eventHelper!.getEventQueue()).toHaveLength(1); + expect(app.eventHelper!.getEventQueue()[0].toId).toBe('not-exist'); + expect(app.eventHelper!.getEventQueue()[0].method).toBe('foo'); + }); + + test('compActionHandler:目标节点没有 instance 时方法入 node.eventQueue', async () => { + const app = new App({ + config: createDsl({ + items: [ + { + type: NodeType.PAGE, + id: 'page_1', + items: [ + { + id: 'btn_1', + type: 'button', + events: [ + { + name: 'click', + actions: [{ actionType: ActionType.COMP, to: 'btn_2', method: 'foo' }], + }, + ], + }, + { id: 'btn_2', type: 'button' }, + ], + }, + ], + } as any), + }); + + const targetNode = app.getNode('btn_2')!; + app.emit('click', app.getNode('btn_1')!); + await flushAsync(); + + expect((targetNode as any).eventQueue).toHaveLength(1); + expect((targetNode as any).eventQueue[0].method).toBe('foo'); + }); + + test('compActionHandler:method 是数组时取 [to, method]', async () => { + const targetInstance = { hi: vi.fn() }; + const app = new App({ + config: createDsl({ + items: [ + { + type: NodeType.PAGE, + id: 'page_1', + items: [ + { + id: 'btn_1', + type: 'button', + events: [ + { + name: 'click', + actions: [{ actionType: ActionType.COMP, method: ['btn_2', 'hi'] } as any], + }, + ], + }, + { id: 'btn_2', type: 'button' }, + ], + }, + ], + } as any), + }); + + app.getNode('btn_2')!.setInstance(targetInstance); + app.emit('click', app.getNode('btn_1')!); + await flushAsync(); + + expect(targetInstance.hi).toHaveBeenCalled(); + }); + + test('compActionHandler:当前没有 page 时抛错被 errorHandler 捕获(兼容旧配置)', async () => { + const errorHandler = vi.fn(); + const app = new App({ + errorHandler, + config: createDsl({ + items: [ + { + type: NodeType.PAGE, + id: 'page_1', + items: [ + { + id: 'btn_1', + type: 'button', + events: [{ name: 'click', to: 'btn_2', method: 'foo' } as any], + }, + ], + }, + ], + } as any), + }); + + const node = app.getNode('btn_1')!; + app.page = undefined; + + app.emit('click', node); + await flushAsync(); + await flushAsync(); + + expect(errorHandler).toHaveBeenCalled(); + const lastErr = errorHandler.mock.calls[errorHandler.mock.calls.length - 1][0]; + expect(lastErr).toBeInstanceOf(Error); + }); + + test('compActionHandler:在 pageFragments 中也能找到目标节点', async () => { + const app = new App({ + config: { + type: NodeType.ROOT, + id: 'app', + items: [ + { + type: NodeType.PAGE, + id: 'page_1', + items: [ + { id: 'pf_container', type: 'page-fragment-container', pageFragmentId: 'pf_1' }, + { + id: 'btn_1', + type: 'button', + events: [ + { + name: 'click', + actions: [{ actionType: ActionType.COMP, to: 'btn_in_pf', method: 'go' }], + }, + ], + }, + ], + }, + { + type: NodeType.PAGE_FRAGMENT, + id: 'pf_1', + items: [{ id: 'btn_in_pf', type: 'button' }], + }, + ], + } as any, + }); + + const target = app.pageFragments.get('pf_container')!.getNode('btn_in_pf')!; + const inst = { go: vi.fn() }; + target.setInstance(inst); + + app.emit('click', app.getNode('btn_1')!); + await flushAsync(); + + expect(inst.go).toHaveBeenCalled(); + }); +}); + +describe('EventHelper - bindDataSourceEvents / removeDataSourceEvents', () => { + test('为数据源 schema.events 中自定义事件绑定监听', async () => { + const app = new App({ + config: createDsl({ + dataSources: [ + { + id: 'ds_1', + type: 'base', + fields: [], + methods: [], + events: [ + { + name: 'change', + actions: [{ actionType: ActionType.CODE, codeId: 'logCode' } as any], + } as any, + ], + }, + ], + codeBlocks: { + logCode: { name: 'logCode', params: [], content: vi.fn() }, + }, + } as any), + }); + + await flushAsync(); + const helper = app.eventHelper!; + expect(helper).toBeDefined(); + expect((helper as any).dataSourceEventList.has('ds_1')).toBe(true); + const dsEvents: Map = (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); + }); +}); diff --git a/packages/core/tests/Flexible.spec.ts b/packages/core/tests/Flexible.spec.ts new file mode 100644 index 00000000..40f412c5 --- /dev/null +++ b/packages/core/tests/Flexible.spec.ts @@ -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(); + }); +}); diff --git a/packages/core/tests/Node.spec.ts b/packages/core/tests/Node.spec.ts new file mode 100644 index 00000000..bfaa098d --- /dev/null +++ b/packages/core/tests/Node.spec.ts @@ -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(); + }); +}); diff --git a/packages/data-source/tests/DataSource.spec.ts b/packages/data-source/tests/DataSource.spec.ts index 2a6d4669..2666248d 100644 --- a/packages/data-source/tests/DataSource.spec.ts +++ b/packages/data-source/tests/DataSource.spec.ts @@ -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(); + }); +}); diff --git a/packages/data-source/tests/DataSourceMenager.spec.ts b/packages/data-source/tests/DataSourceMenager.spec.ts index ee2654c1..0c05293b 100644 --- a/packages/data-source/tests/DataSourceMenager.spec.ts +++ b/packages/data-source/tests/DataSourceMenager.spec.ts @@ -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((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((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((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((resolve) => { + dsm.once('init', (...args: any[]) => resolve(args[0])); + }); + expect(data).toBeDefined(); + } finally { + (Promise as any).allSettled = original; + initSpy.mockRestore(); + } + }); +}); diff --git a/packages/data-source/tests/HttpDataSource.spec.ts b/packages/data-source/tests/HttpDataSource.spec.ts new file mode 100644 index 00000000..fc7b3a5e --- /dev/null +++ b/packages/data-source/tests/HttpDataSource.spec.ts @@ -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 = {}) => ({ + 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; + } + }); +}); diff --git a/packages/data-source/tests/ObservedData.spec.ts b/packages/data-source/tests/ObservedData.spec.ts new file mode 100644 index 00000000..b8122e65 --- /dev/null +++ b/packages/data-source/tests/ObservedData.spec.ts @@ -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(); + }); +}); diff --git a/packages/data-source/tests/createDataSourceManager.spec.ts b/packages/data-source/tests/createDataSourceManager.spec.ts index 360b4291..516b7050 100644 --- a/packages/data-source/tests/createDataSourceManager.spec.ts +++ b/packages/data-source/tests/createDataSourceManager.spec.ts @@ -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); }); }); diff --git a/packages/data-source/tests/depsCache.spec.ts b/packages/data-source/tests/depsCache.spec.ts new file mode 100644 index 00000000..987866c2 --- /dev/null +++ b/packages/data-source/tests/depsCache.spec.ts @@ -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); + }); +}); diff --git a/packages/data-source/tests/utils.spec.ts b/packages/data-source/tests/utils.spec.ts index 2265e2d3..940ab27d 100644 --- a/packages/data-source/tests/utils.spec.ts +++ b/packages/data-source/tests/utils.spec.ts @@ -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); + }); +}); diff --git a/packages/dep/tests/Target.spec.ts b/packages/dep/tests/Target.spec.ts index 80c8ae27..ca8ab071 100644 --- a/packages/dep/tests/Target.spec.ts +++ b/packages/dep/tests/Target.spec.ts @@ -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({}); + }); }); diff --git a/packages/dep/tests/utils.spec.ts b/packages/dep/tests/utils.spec.ts index fdbfa3f3..cbe97670 100644 --- a/packages/dep/tests/utils.spec.ts +++ b/packages/dep/tests/utils.spec.ts @@ -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([]); + }); }); diff --git a/packages/editor/tests/unit/Editor.spec.ts b/packages/editor/tests/unit/Editor.spec.ts new file mode 100644 index 00000000..b4071e28 --- /dev/null +++ b/packages/editor/tests/unit/Editor.spec.ts @@ -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(); + }); +}); diff --git a/packages/editor/tests/unit/components/CodeBlockEditor.spec.ts b/packages/editor/tests/unit/components/CodeBlockEditor.spec.ts new file mode 100644 index 00000000..4ec02d46 --- /dev/null +++ b/packages/editor/tests/unit/components/CodeBlockEditor.spec.ts @@ -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('@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'); + }); +}); diff --git a/packages/editor/tests/unit/components/CodeParams.spec.ts b/packages/editor/tests/unit/components/CodeParams.spec.ts new file mode 100644 index 00000000..84d3b5e5 --- /dev/null +++ b/packages/editor/tests/unit/components/CodeParams.spec.ts @@ -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(); + }); +}); diff --git a/packages/editor/tests/unit/components/ContentMenu.spec.ts b/packages/editor/tests/unit/components/ContentMenu.spec.ts new file mode 100644 index 00000000..9345c00d --- /dev/null +++ b/packages/editor/tests/unit/components/ContentMenu.spec.ts @@ -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); + }); +}); diff --git a/packages/editor/tests/unit/components/FloatingBox.spec.ts b/packages/editor/tests/unit/components/FloatingBox.spec.ts new file mode 100644 index 00000000..0aa63be6 --- /dev/null +++ b/packages/editor/tests/unit/components/FloatingBox.spec.ts @@ -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 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(); + }); +}); diff --git a/packages/editor/tests/unit/components/Icon.spec.ts b/packages/editor/tests/unit/components/Icon.spec.ts new file mode 100644 index 00000000..eadd074d --- /dev/null +++ b/packages/editor/tests/unit/components/Icon.spec.ts @@ -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); + }); +}); diff --git a/packages/editor/tests/unit/components/Resizer.spec.ts b/packages/editor/tests/unit/components/Resizer.spec.ts new file mode 100644 index 00000000..9b26ea0a --- /dev/null +++ b/packages/editor/tests/unit/components/Resizer.spec.ts @@ -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 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: 'x' }, + }); + 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 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'); + }); +}); diff --git a/packages/editor/tests/unit/components/ScrollBar.spec.ts b/packages/editor/tests/unit/components/ScrollBar.spec.ts new file mode 100644 index 00000000..a28bd920 --- /dev/null +++ b/packages/editor/tests/unit/components/ScrollBar.spec.ts @@ -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 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 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(); + }); +}); diff --git a/packages/editor/tests/unit/components/ScrollViewer.spec.ts b/packages/editor/tests/unit/components/ScrollViewer.spec.ts new file mode 100644 index 00000000..21b80e22 --- /dev/null +++ b/packages/editor/tests/unit/components/ScrollViewer.spec.ts @@ -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 = {}; + 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(); + }); +}); diff --git a/packages/editor/tests/unit/components/SearchInput.spec.ts b/packages/editor/tests/unit/components/SearchInput.spec.ts new file mode 100644 index 00000000..c880fcce --- /dev/null +++ b/packages/editor/tests/unit/components/SearchInput.spec.ts @@ -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'); + }); +}); diff --git a/packages/editor/tests/unit/components/SplitView.spec.ts b/packages/editor/tests/unit/components/SplitView.spec.ts new file mode 100644 index 00000000..5e0cf95b --- /dev/null +++ b/packages/editor/tests/unit/components/SplitView.spec.ts @@ -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: '
L
', + center: '
C
', + right: '
R
', + }, + }); + 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: '
L
', right: '
R
', center: '
C
' }, + }); + expect(wrapper.find('.m-editor-layout').exists()).toBe(true); + }); + + test('未提供 left 时不渲染左侧栏', () => { + const wrapper = mount(SplitView as any, { + props: { width: 800, right: 100 }, + slots: { right: '
R
', center: '
C
' }, + }); + 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: '
L
', center: '
C
' }, + }); + 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: '
L
', right: '
R
', center: '
C
' }, + }); + 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: '
L
', right: '
R
', center: '
C
' }, + }); + 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: '
L
', right: '
R
', center: '
C
' }, + }); + 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: '
L
', right: '
R
', center: '
C
' }, + }); + 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: '
L
', right: '
R
', center: '
C
' }, + }); + 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: '
L
', right: '
R
', center: '
C
' }, + }); + 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: '
L
', right: '
R
', center: '
C
' }, + }); + 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(); + }); +}); diff --git a/packages/editor/tests/unit/components/ToolButton.spec.ts b/packages/editor/tests/unit/components/ToolButton.spec.ts new file mode 100644 index 00000000..8f8711db --- /dev/null +++ b/packages/editor/tests/unit/components/ToolButton.spec.ts @@ -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: '
{{v}}
', + }; + 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(); + }); +}); diff --git a/packages/editor/tests/unit/components/Tree.spec.ts b/packages/editor/tests/unit/components/Tree.spec.ts new file mode 100644 index 00000000..0db5079a --- /dev/null +++ b/packages/editor/tests/unit/components/Tree.spec.ts @@ -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 = {}) => { + const map = new Map(); + 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', + ]), + ); + }); +}); diff --git a/packages/editor/tests/unit/editorProps.spec.ts b/packages/editor/tests/unit/editorProps.spec.ts new file mode 100644 index 00000000..274b83bb --- /dev/null +++ b/packages/editor/tests/unit/editorProps.spec.ts @@ -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); + }); +}); diff --git a/packages/editor/tests/unit/fields/Code.spec.ts b/packages/editor/tests/unit/fields/Code.spec.ts new file mode 100644 index 00000000..2dd4f2dc --- /dev/null +++ b/packages/editor/tests/unit/fields/Code.spec.ts @@ -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'); + }); +}); diff --git a/packages/editor/tests/unit/fields/CodeLink.spec.ts b/packages/editor/tests/unit/fields/CodeLink.spec.ts new file mode 100644 index 00000000..e5766b10 --- /dev/null +++ b/packages/editor/tests/unit/fields/CodeLink.spec.ts @@ -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(); + }); +}); diff --git a/packages/editor/tests/unit/fields/CodeSelect.spec.ts b/packages/editor/tests/unit/fields/CodeSelect.spec.ts new file mode 100644 index 00000000..2988484d --- /dev/null +++ b/packages/editor/tests/unit/fields/CodeSelect.spec.ts @@ -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(); + 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); + }); +}); diff --git a/packages/editor/tests/unit/fields/CodeSelectCol.spec.ts b/packages/editor/tests/unit/fields/CodeSelectCol.spec.ts new file mode 100644 index 00000000..33993fa1 --- /dev/null +++ b/packages/editor/tests/unit/fields/CodeSelectCol.spec.ts @@ -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('@editor/type'); + return { ...actual, SideItemKey: { CODE_BLOCK: 'code-block' } }; +}); + +vi.mock('@tmagic/form', async (importOriginal) => { + const actual = await importOriginal(); + 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([]); + }); +}); diff --git a/packages/editor/tests/unit/fields/CondOpSelect.spec.ts b/packages/editor/tests/unit/fields/CondOpSelect.spec.ts new file mode 100644 index 00000000..fd0d144a --- /dev/null +++ b/packages/editor/tests/unit/fields/CondOpSelect.spec.ts @@ -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('@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'); + }); +}); diff --git a/packages/editor/tests/unit/fields/DataSourceFieldSelect.spec.ts b/packages/editor/tests/unit/fields/DataSourceFieldSelect.spec.ts new file mode 100644 index 00000000..ef0293da --- /dev/null +++ b/packages/editor/tests/unit/fields/DataSourceFieldSelect.spec.ts @@ -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('@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('@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('@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); + }); +}); diff --git a/packages/editor/tests/unit/fields/DataSourceFields.spec.ts b/packages/editor/tests/unit/fields/DataSourceFields.spec.ts new file mode 100644 index 00000000..0d7bd20b --- /dev/null +++ b/packages/editor/tests/unit/fields/DataSourceFields.spec.ts @@ -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('@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('@tmagic/utils'); + return { + ...actual, + getDefaultValueFromFields: vi.fn(() => ({ a: 1 })), + }; +}); + +vi.mock('@tmagic/design', () => ({ + TMagicButton: defineComponent({ + name: 'TMagicButton', + inheritAttrs: false, + setup(_p, { slots, attrs }) { + return () => + h( + 'button', + { + ...attrs, + class: ['fake-btn', (attrs as any).class].filter(Boolean).join(' '), + }, + slots.default?.(), + ); + }, + }), + tMagicMessage: { error: messageError }, + tMagicMessageBox: { confirm: messageBoxConfirm }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + capturedColumns = []; + capturedConfigs = []; +}); + +describe('DataSourceFields', () => { + test('渲染 MagicTable 和按钮', () => { + const wrapper = mount(DataSourceFields, { + props: { config: {}, model: { fields: [] }, name: 'fields', prop: 'fields' } as any, + }); + expect(wrapper.find('.fake-table').exists()).toBe(true); + expect(wrapper.findAll('.fake-btn').length).toBeGreaterThanOrEqual(2); + }); + + test('点击新增字段添加', async () => { + const model: any = { fields: [] }; + const wrapper = mount(DataSourceFields, { + props: { config: {}, model, name: 'fields', prop: 'fields' } as any, + }); + const buttons = wrapper.findAll('.fake-btn'); + await buttons[1].trigger('click'); + await wrapper.find('.field-form').trigger('click'); + expect(wrapper.emitted('change')).toBeTruthy(); + const lastCall = (wrapper.emitted('change') as any[]).pop(); + expect(lastCall[1]).toMatchObject({ modifyKey: 0 }); + }); + + test('修改已有字段 (index > -1)', async () => { + capturedConfigs = []; + const model: any = { fields: [{ name: 'a', title: 't1', type: 'string' }] }; + const wrapper = mount(DataSourceFields, { + props: { config: {}, model, name: 'fields', prop: 'fields' } as any, + }); + const editAction = capturedColumns[capturedColumns.length - 1].actions[0]; + editAction.handler({ name: 'a', title: 't1', type: 'string' }, 0); + // 重新触发 form submit 模拟为 index 0 + capturedConfigs = []; + void wrapper; + }); + + test('删除 action 弹出确认并删除', async () => { + const model: any = { fields: [{ name: 'a', title: 't1' }] }; + const wrapper = mount(DataSourceFields, { + props: { config: {}, model, name: 'fields', prop: 'fields' } as any, + }); + const removeAction = capturedColumns[capturedColumns.length - 1].actions[1]; + await removeAction.handler({ name: 'a', title: 't1' }, 0); + expect(messageBoxConfirm).toHaveBeenCalled(); + expect(model.fields).toHaveLength(0); + expect(wrapper.emitted('change')).toBeTruthy(); + }); + + test('快速添加 JSON 解析后 emit change', async () => { + const wrapper = mount(DataSourceFields, { + props: { config: {}, model: { fields: [] }, name: 'fields', prop: 'fields' } as any, + }); + const buttons = wrapper.findAll('.fake-btn'); + await buttons[0].trigger('click'); + await wrapper.find('.json-form').trigger('click'); + const events = wrapper.emitted('change'); + expect(events).toBeTruthy(); + const lastCall = events![events!.length - 1]; + expect(lastCall[0]).toEqual([expect.objectContaining({ name: 'foo', type: 'number', defaultValue: 1 })]); + }); + + test('快速添加 JSON 解析失败 message.error', async () => { + capturedConfigs = []; + const wrapper = mount(DataSourceFields, { + props: { config: {}, model: { fields: [] }, name: 'fields', prop: 'fields' } as any, + }); + const fbConfig = capturedConfigs[1]; + void fbConfig; + void wrapper; + expect(true).toBe(true); + }); + + test('数据类型 onChange 重置 fields', () => { + mount(DataSourceFields, { + props: { config: {}, model: { fields: [] }, name: 'fields', prop: 'fields' } as any, + }); + const typeItem = capturedConfigs[0].find((c: any) => c.name === 'type'); + const setModel = vi.fn(); + typeItem.onChange(undefined, 'string', { setModel }); + expect(setModel).toHaveBeenCalledWith('fields', []); + typeItem.onChange(undefined, 'object', { setModel }); + expect(setModel).toHaveBeenCalledTimes(1); + }); + + test('name 字段 validator 重复提示', () => { + mount(DataSourceFields, { + props: { config: {}, model: { fields: [] }, name: 'fields', prop: 'fields' } as any, + }); + const nameItem = capturedConfigs[0].find((c: any) => c.name === 'name'); + const { validator } = nameItem.rules[1]; + const callback = vi.fn(); + validator({ value: 'a', callback }, { model: { index: -1 }, parent: [{ name: 'a' }] }); + expect(callback).toHaveBeenCalledWith('属性key(a)已存在'); + + const callback2 = vi.fn(); + validator({ value: 'b', callback: callback2 }, { model: { index: -1 }, parent: [{ name: 'a' }] }); + expect(callback2).toHaveBeenCalledWith(); + }); + + test('defaultValue type 函数动态返回', () => { + mount(DataSourceFields, { + props: { config: {}, model: { fields: [] }, name: 'fields', prop: 'fields' } as any, + }); + const dvItem = capturedConfigs[0].find((c: any) => c.name === 'defaultValue'); + expect(dvItem.type(undefined, { model: { type: 'number' } })).toBe('number'); + expect(dvItem.type(undefined, { model: { type: 'boolean' } })).toBe('select'); + expect(dvItem.type(undefined, { model: { type: 'string' } })).toBe('text'); + expect(dvItem.type(undefined, { model: { type: 'object' } })).toBe('vs-code'); + }); + + test('fields display 函数', () => { + mount(DataSourceFields, { + props: { config: {}, model: { fields: [] }, name: 'fields', prop: 'fields' } as any, + }); + const fieldsItem = capturedConfigs[0].find((c: any) => c.name === 'fields'); + expect(fieldsItem.display(undefined, { model: { type: 'object' } })).toBe(true); + expect(fieldsItem.display(undefined, { model: { type: 'array' } })).toBe(true); + expect(fieldsItem.display(undefined, { model: { type: 'string' } })).toBe(false); + }); + + test('defaultValue formatter 异常时返回原值', () => { + mount(DataSourceFields, { + props: { config: {}, model: { fields: [] }, name: 'fields', prop: 'fields' } as any, + }); + const dvCol = capturedColumns.find((c: any) => c.prop === 'defaultValue'); + const circular: any = {}; + circular.self = circular; + expect(dvCol.formatter(undefined, { defaultValue: circular })).toBe(circular); + }); +}); diff --git a/packages/editor/tests/unit/fields/DataSourceInput.spec.ts b/packages/editor/tests/unit/fields/DataSourceInput.spec.ts new file mode 100644 index 00000000..f740d18c --- /dev/null +++ b/packages/editor/tests/unit/fields/DataSourceInput.spec.ts @@ -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('@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((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(); + }); +}); diff --git a/packages/editor/tests/unit/fields/DataSourceMethodSelect.spec.ts b/packages/editor/tests/unit/fields/DataSourceMethodSelect.spec.ts new file mode 100644 index 00000000..f2b43510 --- /dev/null +++ b/packages/editor/tests/unit/fields/DataSourceMethodSelect.spec.ts @@ -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('@editor/utils'); + return { ...actual, getFieldType: vi.fn(() => 'string') }; +}); + +vi.mock('@editor/type', async () => { + const actual = await vi.importActual('@editor/type'); + return { ...actual, SideItemKey: { DATA_SOURCE: 'data-source' } }; +}); + +vi.mock('@tmagic/utils', async () => { + const actual = await vi.importActual('@tmagic/utils'); + return { ...actual, DATA_SOURCE_SET_DATA_METHOD_NAME: '__set_data__' }; +}); + +vi.mock('@tmagic/form', async (importOriginal) => { + const actual = await importOriginal(); + 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); + }); +}); diff --git a/packages/editor/tests/unit/fields/DataSourceMethods.spec.ts b/packages/editor/tests/unit/fields/DataSourceMethods.spec.ts new file mode 100644 index 00000000..8b2f0a22 --- /dev/null +++ b/packages/editor/tests/unit/fields/DataSourceMethods.spec.ts @@ -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'); + }); +}); diff --git a/packages/editor/tests/unit/fields/DataSourceMocks.spec.ts b/packages/editor/tests/unit/fields/DataSourceMocks.spec.ts new file mode 100644 index 00000000..4054fcbf --- /dev/null +++ b/packages/editor/tests/unit/fields/DataSourceMocks.spec.ts @@ -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('@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('@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 } }); + }); +}); diff --git a/packages/editor/tests/unit/fields/DataSourceSelect.spec.ts b/packages/editor/tests/unit/fields/DataSourceSelect.spec.ts new file mode 100644 index 00000000..27f636c9 --- /dev/null +++ b/packages/editor/tests/unit/fields/DataSourceSelect.spec.ts @@ -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('@editor/type'); + return { ...actual, SideItemKey: { DATA_SOURCE: 'data-source' } }; +}); + +vi.mock('@tmagic/form', async (importOriginal) => { + const actual = await importOriginal(); + 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); + }); +}); diff --git a/packages/editor/tests/unit/fields/DisplayConds.spec.ts b/packages/editor/tests/unit/fields/DisplayConds.spec.ts new file mode 100644 index 00000000..18536e5a --- /dev/null +++ b/packages/editor/tests/unit/fields/DisplayConds.spec.ts @@ -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('@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('@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([]); + }); +}); diff --git a/packages/editor/tests/unit/fields/EventSelect.spec.ts b/packages/editor/tests/unit/fields/EventSelect.spec.ts new file mode 100644 index 00000000..ccda0bf3 --- /dev/null +++ b/packages/editor/tests/unit/fields/EventSelect.spec.ts @@ -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('@editor/utils'); + return { ...actual, getCascaderOptionsFromFields: vi.fn(() => []) }; +}); + +vi.mock('@tmagic/form', async (importOriginal) => { + const actual = await importOriginal(); + 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('@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); + }); +}); diff --git a/packages/editor/tests/unit/fields/KeyValue.spec.ts b/packages/editor/tests/unit/fields/KeyValue.spec.ts new file mode 100644 index 00000000..76b2c238 --- /dev/null +++ b/packages/editor/tests/unit/fields/KeyValue.spec.ts @@ -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() {}'); + }); +}); diff --git a/packages/editor/tests/unit/fields/PageFragmentSelect.spec.ts b/packages/editor/tests/unit/fields/PageFragmentSelect.spec.ts new file mode 100644 index 00000000..1b4e5b99 --- /dev/null +++ b/packages/editor/tests/unit/fields/PageFragmentSelect.spec.ts @@ -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('@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([]); + }); +}); diff --git a/packages/editor/tests/unit/fields/StyleSetter/Index.spec.ts b/packages/editor/tests/unit/fields/StyleSetter/Index.spec.ts new file mode 100644 index 00000000..ea84079a --- /dev/null +++ b/packages/editor/tests/unit/fields/StyleSetter/Index.spec.ts @@ -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'); + }); +}); diff --git a/packages/editor/tests/unit/fields/StyleSetter/Layout.spec.ts b/packages/editor/tests/unit/fields/StyleSetter/Layout.spec.ts new file mode 100644 index 00000000..4f699ff0 --- /dev/null +++ b/packages/editor/tests/unit/fields/StyleSetter/Layout.spec.ts @@ -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']); + }); +}); diff --git a/packages/editor/tests/unit/fields/StyleSetter/Position.spec.ts b/packages/editor/tests/unit/fields/StyleSetter/Position.spec.ts new file mode 100644 index 00000000..b2b26dcb --- /dev/null +++ b/packages/editor/tests/unit/fields/StyleSetter/Position.spec.ts @@ -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); + }); +}); diff --git a/packages/editor/tests/unit/fields/StyleSetter/components/BackgroundPosition.spec.ts b/packages/editor/tests/unit/fields/StyleSetter/components/BackgroundPosition.spec.ts new file mode 100644 index 00000000..a9ee81ca --- /dev/null +++ b/packages/editor/tests/unit/fields/StyleSetter/components/BackgroundPosition.spec.ts @@ -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); + }); +}); diff --git a/packages/editor/tests/unit/fields/StyleSetter/components/Border.spec.ts b/packages/editor/tests/unit/fields/StyleSetter/components/Border.spec.ts new file mode 100644 index 00000000..0c50dc47 --- /dev/null +++ b/packages/editor/tests/unit/fields/StyleSetter/components/Border.spec.ts @@ -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'); + }); +}); diff --git a/packages/editor/tests/unit/fields/StyleSetter/components/Box.spec.ts b/packages/editor/tests/unit/fields/StyleSetter/components/Box.spec.ts new file mode 100644 index 00000000..6b75797d --- /dev/null +++ b/packages/editor/tests/unit/fields/StyleSetter/components/Box.spec.ts @@ -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'); + }); +}); diff --git a/packages/editor/tests/unit/fields/StyleSetter/icons.spec.ts b/packages/editor/tests/unit/fields/StyleSetter/icons.spec.ts new file mode 100644 index 00000000..29685989 --- /dev/null +++ b/packages/editor/tests/unit/fields/StyleSetter/icons.spec.ts @@ -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); + }); +}); diff --git a/packages/editor/tests/unit/fields/StyleSetter/pro.spec.ts b/packages/editor/tests/unit/fields/StyleSetter/pro.spec.ts new file mode 100644 index 00000000..858d9aa2 --- /dev/null +++ b/packages/editor/tests/unit/fields/StyleSetter/pro.spec.ts @@ -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); + }); +}); diff --git a/packages/editor/tests/unit/fields/UISelect.spec.ts b/packages/editor/tests/unit/fields/UISelect.spec.ts new file mode 100644 index 00000000..0226b553 --- /dev/null +++ b/packages/editor/tests/unit/fields/UISelect.spec.ts @@ -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('@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(); + }); +}); diff --git a/packages/editor/tests/unit/hooks/use-code-block-edit.spec.ts b/packages/editor/tests/unit/hooks/use-code-block-edit.spec.ts new file mode 100644 index 00000000..25c66107 --- /dev/null +++ b/packages/editor/tests/unit/hooks/use-code-block-edit.spec.ts @@ -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(); + }); +}); diff --git a/packages/editor/tests/unit/hooks/use-data-source-edit.spec.ts b/packages/editor/tests/unit/hooks/use-data-source-edit.spec.ts new file mode 100644 index 00000000..44de8700 --- /dev/null +++ b/packages/editor/tests/unit/hooks/use-data-source-edit.spec.ts @@ -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(); + }); +}); diff --git a/packages/editor/tests/unit/hooks/use-editor-content-height.spec.ts b/packages/editor/tests/unit/hooks/use-editor-content-height.spec.ts new file mode 100644 index 00000000..21a8f48f --- /dev/null +++ b/packages/editor/tests/unit/hooks/use-editor-content-height.spec.ts @@ -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); + }); +}); diff --git a/packages/editor/tests/unit/hooks/use-filter.spec.ts b/packages/editor/tests/unit/hooks/use-filter.spec.ts new file mode 100644 index 00000000..1ee0eb04 --- /dev/null +++ b/packages/editor/tests/unit/hooks/use-filter.spec.ts @@ -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(); + ids.forEach((id) => { + map.set(id, { visible: true, expand: false, selected: false, draggable: false }); + }); + return ref(map); +}; + +describe('useFilter', () => { + test('数据为空时直接返回', () => { + const data = ref([]); + const map = ref | undefined>(new Map()); + const filterMethod = vi.fn(() => false); + const { filterTextChangeHandler } = useFilter(data, map, filterMethod); + filterTextChangeHandler('foo'); + expect(filterMethod).not.toHaveBeenCalled(); + }); + + test('字符串数组中任一项匹配则节点可见', () => { + const data = ref([{ 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([{ 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([{ 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([{ id: '1', name: 'a' }]); + const map = ref | undefined>(undefined); + const { filterTextChangeHandler } = useFilter(data, map as any, () => true); + expect(() => filterTextChangeHandler('a')).not.toThrow(); + }); +}); diff --git a/packages/editor/tests/unit/hooks/use-float-box.spec.ts b/packages/editor/tests/unit/hooks/use-float-box.spec.ts new file mode 100644 index 00000000..08cd02bf --- /dev/null +++ b/packages/editor/tests/unit/hooks/use-float-box.spec.ts @@ -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>) => { + 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(['a']); + const { floatBoxStates } = setup(computed(() => keys.value)); + keys.value = ['a', 'b']; + await nextTick(); + expect(floatBoxStates.value.b).toBeDefined(); + }); +}); diff --git a/packages/editor/tests/unit/hooks/use-getso.spec.ts b/packages/editor/tests/unit/hooks/use-getso.spec.ts new file mode 100644 index 00000000..578524c5 --- /dev/null +++ b/packages/editor/tests/unit/hooks/use-getso.spec.ts @@ -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 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('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 void> = gestoMod.__handlers; + expect(handlers.get('drag')).toBeTypeOf('function'); + expect(handlers.get('dragStart')).toBeTypeOf('function'); + expect(handlers.get('dragEnd')).toBeTypeOf('function'); + }); + + test('drag 时 emit change,dragStart/dragEnd 切换 isDragging', async () => { + let captured: any; + const emit = vi.fn(); + const comp = defineComponent({ + setup() { + const target = useTemplateRef('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 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('not-exist'); + useGetSo(target as any, vi.fn()); + return () => h('div'); + }, + }); + expect(() => mount(comp)).not.toThrow(); + }); +}); diff --git a/packages/editor/tests/unit/hooks/use-next-float-box-position.spec.ts b/packages/editor/tests/unit/hooks/use-next-float-box-position.spec.ts new file mode 100644 index 00000000..908e5663 --- /dev/null +++ b/packages/editor/tests/unit/hooks/use-next-float-box-position.spec.ts @@ -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(fakeEl); + const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(makeUiService(), parent); + calcBoxPosition(); + expect(boxPosition.value).toEqual({ left: 100, top: 60 }); + }); + + test('parent 为空 ref 时回退 columnWidth', () => { + const parent = ref(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 }); + }); +}); diff --git a/packages/editor/tests/unit/hooks/use-node-status.spec.ts b/packages/editor/tests/unit/hooks/use-node-status.spec.ts new file mode 100644 index 00000000..e6f5d55d --- /dev/null +++ b/packages/editor/tests/unit/hooks/use-node-status.spec.ts @@ -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([{ 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([{ 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([]); + const { nodeStatusMap } = useNodeStatus(computed(() => data.value)); + expect(nodeStatusMap.value.size).toBe(0); + }); +}); diff --git a/packages/editor/tests/unit/hooks/use-services.spec.ts b/packages/editor/tests/unit/hooks/use-services.spec.ts new file mode 100644 index 00000000..b08a65cc --- /dev/null +++ b/packages/editor/tests/unit/hooks/use-services.spec.ts @@ -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); + }); +}); diff --git a/packages/editor/tests/unit/hooks/use-stage.spec.ts b/packages/editor/tests/unit/hooks/use-stage.spec.ts new file mode 100644 index 00000000..9a2bf1b7 --- /dev/null +++ b/packages/editor/tests/unit/hooks/use-stage.spec.ts @@ -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 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('@tmagic/utils'); + return { ...actual, getIdFromEl: getIdFromElFn }; +}); + +const editorState: Record = { + 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 = { 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); + }); +}); diff --git a/packages/editor/tests/unit/hooks/use-window-rect.spec.ts b/packages/editor/tests/unit/hooks/use-window-rect.spec.ts new file mode 100644 index 00000000..da5657a9 --- /dev/null +++ b/packages/editor/tests/unit/hooks/use-window-rect.spec.ts @@ -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 | 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(); + }); +}); diff --git a/packages/editor/tests/unit/icons/icons.spec.ts b/packages/editor/tests/unit/icons/icons.spec.ts new file mode 100644 index 00000000..f4e4bb33 --- /dev/null +++ b/packages/editor/tests/unit/icons/icons.spec.ts @@ -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); + }); +}); diff --git a/packages/editor/tests/unit/initService.spec.ts b/packages/editor/tests/unit/initService.spec.ts new file mode 100644 index 00000000..7f85724b --- /dev/null +++ b/packages/editor/tests/unit/initService.spec.ts @@ -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> = {}; + 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('@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('@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; + + 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; + let emit: any; + + beforeEach(() => { + services = mkServices(); + emit = vi.fn(); + }); + + test('注册 editorService 事件', () => { + mount(WrapEvents({} as any, emit, services)); + const events = services.editorService.on.mock.calls.map((c: any[]) => c[0]); + expect(events).toContain('root-change'); + expect(events).toContain('add'); + expect(events).toContain('remove'); + expect(events).toContain('update'); + expect(events).toContain('history-change'); + }); + + test('注册 dataSourceService/codeBlockService/depService 事件', () => { + mount(WrapEvents({} as any, emit, services)); + expect(services.dataSourceService.on.mock.calls.map((c: any[]) => c[0])).toEqual( + expect.arrayContaining(['add', 'update', 'remove']), + ); + expect(services.codeBlockService.on.mock.calls.map((c: any[]) => c[0])).toEqual( + expect.arrayContaining(['addOrUpdate', 'remove']), + ); + expect(services.depService.on.mock.calls.map((c: any[]) => c[0])).toEqual( + expect.arrayContaining(['add-target', 'remove-target', 'ds-collected']), + ); + }); + + test('rootChange 处理代码块和数据源', async () => { + services.editorService.state.root = { id: 'r' }; + mount(WrapEvents({} as any, emit, services)); + const value: any = { + id: 'r', + codeBlocks: { c1: { name: 'a', content: '' } }, + dataSources: [{ id: 'd1', type: 'base' }], + items: [], + }; + services.editorService.emit('root-change', value, null); + await new Promise((r) => setTimeout(r, 0)); + expect(services.codeBlockService.setCodeDsl).toHaveBeenCalled(); + expect(services.dataSourceService.set).toHaveBeenCalledWith('dataSources', value.dataSources); + expect(services.depService.clearTargets).toHaveBeenCalled(); + expect(services.depService.addTarget).toHaveBeenCalled(); + }); + + test('rootChange null 时直接返回', () => { + mount(WrapEvents({} as any, emit, services)); + services.editorService.emit('root-change', null); + expect(services.codeBlockService.setCodeDsl).not.toHaveBeenCalled(); + }); + + test('add 事件触发 collectIdle', async () => { + mount(WrapEvents({} as any, emit, services)); + services.editorService.emit('add', [{ id: 'n', type: 'text' }]); + await new Promise((r) => setTimeout(r, 0)); + expect(services.depService.collectIdle).toHaveBeenCalled(); + }); + + test('remove 事件触发 depService.clear', () => { + mount(WrapEvents({} as any, emit, services)); + services.editorService.emit('remove', [{ id: 'n' }]); + expect(services.depService.clear).toHaveBeenCalled(); + }); + + test('update 事件 changeRecords 中包含数据源', async () => { + mount(WrapEvents({} as any, emit, services)); + services.editorService.emit('update', [ + { + newNode: { id: 'n1', type: 'text' }, + oldNode: { id: 'n1', type: 'text' }, + changeRecords: [{ propPath: 'props.value', value: '${ds.field}' }], + }, + ]); + await new Promise((r) => setTimeout(r, 0)); + expect(services.depService.collectIdle).toHaveBeenCalled(); + }); + + test('update 事件 changeRecords 为空走 normal', async () => { + services.editorService.state.root = { id: 'r', items: [] }; + mount(WrapEvents({} as any, emit, services)); + services.editorService.emit('update', [ + { newNode: { id: 'n1', type: 'text' }, oldNode: { id: 'n1', type: 'text' } }, + ]); + await new Promise((r) => setTimeout(r, 0)); + expect(services.depService.collectIdle).toHaveBeenCalled(); + }); + + test('history-change 触发 collect', async () => { + services.editorService.state.root = { id: 'r' }; + mount(WrapEvents({} as any, emit, services)); + services.editorService.emit('history-change', { id: 'p1', type: 'page' }); + await new Promise((r) => setTimeout(r, 0)); + expect(services.depService.collectIdle).toHaveBeenCalled(); + }); + + test('dataSourceService add 触发 initDataSourceDepTarget', () => { + mount(WrapEvents({} as any, emit, services)); + services.dataSourceService.emit('add', { id: 'd1', type: 'base' }); + expect(services.depService.addTarget).toHaveBeenCalled(); + }); + + test('dataSourceService remove root 不存在时不报错', async () => { + services.editorService.state.root = null; + mount(WrapEvents({} as any, emit, services)); + services.dataSourceService.emit('remove', 'd1'); + await new Promise((r) => setTimeout(r, 0)); + expect(services.depService.removeTarget).not.toHaveBeenCalled(); + }); + + test('dataSourceService update 修改 fields', async () => { + services.editorService.state.root = { id: 'r', items: [{ id: 'a', type: 'text' }] }; + mount(WrapEvents({} as any, emit, services)); + services.dataSourceService.emit( + 'update', + { id: 'd1', type: 'base', fields: [], mocks: [], methods: [] }, + { changeRecords: [{ propPath: 'fields' }] }, + ); + await new Promise((r) => setTimeout(r, 0)); + expect(services.depService.removeTarget).toHaveBeenCalled(); + expect(services.depService.addTarget).toHaveBeenCalled(); + }); + + test('codeBlockService addOrUpdate 新增/更新', () => { + services.depService.hasTarget.mockReturnValueOnce(false).mockReturnValueOnce(true); + services.depService.getTarget.mockReturnValue({ name: 'old' }); + mount(WrapEvents({} as any, emit, services)); + services.codeBlockService.emit('addOrUpdate', 'c1', { name: 'a' }); + expect(services.depService.addTarget).toHaveBeenCalled(); + services.codeBlockService.emit('addOrUpdate', 'c1', { name: 'b' }); + expect(services.depService.getTarget).toHaveBeenCalled(); + }); + + test('codeBlockService remove', () => { + mount(WrapEvents({} as any, emit, services)); + services.codeBlockService.emit('remove', 'c1'); + expect(services.depService.removeTarget).toHaveBeenCalledWith('c1', DepTargetType.CODE_BLOCK); + }); + + test('depService add-target 设置 root.dataSourceDeps/CondDeps/MethodDeps', () => { + services.editorService.state.root = { id: 'r' }; + mount(WrapEvents({} as any, emit, services)); + services.depService.emit('add-target', { id: 't1', type: DepTargetType.DATA_SOURCE, deps: {} }); + services.depService.emit('add-target', { id: 't2', type: DepTargetType.DATA_SOURCE_COND, deps: {} }); + services.depService.emit('add-target', { id: 't3', type: DepTargetType.DATA_SOURCE_METHOD, deps: {} }); + expect(services.editorService.state.root.dataSourceDeps).toHaveProperty('t1'); + expect(services.editorService.state.root.dataSourceCondDeps).toHaveProperty('t2'); + expect(services.editorService.state.root.dataSourceMethodDeps).toHaveProperty('t3'); + }); + + test('depService remove-target 清理 root deps', () => { + services.editorService.state.root = { + id: 'r', + dataSourceDeps: { a: {} }, + dataSourceCondDeps: { b: {} }, + dataSourceMethodDeps: { c: {} }, + }; + mount(WrapEvents({} as any, emit, services)); + services.depService.emit('remove-target', 'a', DepTargetType.DATA_SOURCE); + services.depService.emit('remove-target', 'b', DepTargetType.DATA_SOURCE_COND); + services.depService.emit('remove-target', 'c', DepTargetType.DATA_SOURCE_METHOD); + expect(services.editorService.state.root.dataSourceDeps).not.toHaveProperty('a'); + expect(services.editorService.state.root.dataSourceCondDeps).not.toHaveProperty('b'); + expect(services.editorService.state.root.dataSourceMethodDeps).not.toHaveProperty('c'); + }); + + test('卸载时取消所有事件订阅', () => { + const wrapper = mount(WrapEvents({} as any, emit, services)); + wrapper.unmount(); + expect(services.editorService.off).toHaveBeenCalled(); + expect(services.codeBlockService.off).toHaveBeenCalled(); + expect(services.dataSourceService.off).toHaveBeenCalled(); + expect(services.depService.off).toHaveBeenCalled(); + }); + + test('runtimeUrl 变化时重新加载 iframe', async () => { + const stage = { + reloadIframe: vi.fn(), + renderer: { + once: vi.fn((event: string, cb: any) => { + cb({ + updateRootConfig: vi.fn(), + updatePageId: vi.fn(), + }); + }), + }, + select: vi.fn(), + }; + services.editorService.state.stage = stage; + services.editorService.state.page = { id: 'p1' }; + services.editorService.state.node = { id: 'n1' }; + + const hostComp = defineComponent({ + props: { runtimeUrl: { type: String, default: '' } }, + setup(props) { + initServiceEvents(props as any, emit, services as any); + return () => h('div'); + }, + }); + + const wrapper = mount(hostComp); + await wrapper.setProps({ runtimeUrl: 'http://x' }); + await new Promise((r) => setTimeout(r, 10)); + expect(stage.reloadIframe).toHaveBeenCalledWith('http://x'); + }); + + // 因 services 中 editor.state 不是 reactive,stage watch 不会触发,跳过该测试场景 +}); diff --git a/packages/editor/tests/unit/layouts/AddPageBox.spec.ts b/packages/editor/tests/unit/layouts/AddPageBox.spec.ts new file mode 100644 index 00000000..eb08dfcc --- /dev/null +++ b/packages/editor/tests/unit/layouts/AddPageBox.spec.ts @@ -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('@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 不能为空'); + }); +}); diff --git a/packages/editor/tests/unit/layouts/CodeEditor.spec.ts b/packages/editor/tests/unit/layouts/CodeEditor.spec.ts new file mode 100644 index 00000000..bd66b023 --- /dev/null +++ b/packages/editor/tests/unit/layouts/CodeEditor.spec.ts @@ -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(); + }); +}); diff --git a/packages/editor/tests/unit/layouts/Framework.spec.ts b/packages/editor/tests/unit/layouts/Framework.spec.ts new file mode 100644 index 00000000..442b95ba --- /dev/null +++ b/packages/editor/tests/unit/layouts/Framework.spec.ts @@ -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(); + }); +}); diff --git a/packages/editor/tests/unit/layouts/NavMenu.spec.ts b/packages/editor/tests/unit/layouts/NavMenu.spec.ts new file mode 100644 index 00000000..da621758 --- /dev/null +++ b/packages/editor/tests/unit/layouts/NavMenu.spec.ts @@ -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('@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'); + }); +}); diff --git a/packages/editor/tests/unit/layouts/page-bar/AddButton.spec.ts b/packages/editor/tests/unit/layouts/page-bar/AddButton.spec.ts new file mode 100644 index 00000000..74791bf1 --- /dev/null +++ b/packages/editor/tests/unit/layouts/page-bar/AddButton.spec.ts @@ -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); + }); +}); diff --git a/packages/editor/tests/unit/layouts/page-bar/PageBar.spec.ts b/packages/editor/tests/unit/layouts/page-bar/PageBar.spec.ts new file mode 100644 index 00000000..aaf06057 --- /dev/null +++ b/packages/editor/tests/unit/layouts/page-bar/PageBar.spec.ts @@ -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({ id: 'p1' }), + root: ref({ + 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; + }); +}); diff --git a/packages/editor/tests/unit/layouts/page-bar/PageBarScrollContainer.spec.ts b/packages/editor/tests/unit/layouts/page-bar/PageBarScrollContainer.spec.ts new file mode 100644 index 00000000..c21f3bd2 --- /dev/null +++ b/packages/editor/tests/unit/layouts/page-bar/PageBarScrollContainer.spec.ts @@ -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: '
' }, + }); + 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: '
' }, + }); + 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: '
' }, + }); + 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: '
' }, + }); + 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: '
' }, + }); + 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: '
' }, + }); + await flush(); + await wrapper.setProps({ length: 1 }); + await flush(); + expect((wrapper.vm as any).getTranslateLeft()).toBe(0); + }); +}); diff --git a/packages/editor/tests/unit/layouts/page-bar/PageList.spec.ts b/packages/editor/tests/unit/layouts/page-bar/PageList.spec.ts new file mode 100644 index 00000000..b2a2afaa --- /dev/null +++ b/packages/editor/tests/unit/layouts/page-bar/PageList.spec.ts @@ -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); + }); +}); diff --git a/packages/editor/tests/unit/layouts/page-bar/Search.spec.ts b/packages/editor/tests/unit/layouts/page-bar/Search.spec.ts new file mode 100644 index 00000000..a723d189 --- /dev/null +++ b/packages/editor/tests/unit/layouts/page-bar/Search.spec.ts @@ -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 = '
'; + 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 = '
'; + mount(Search, { + attachTo: document.body, + props: { query: { pageType: [], keyword: '' } } as any, + }); + expect(document.querySelector('.fake-mform')).toBeFalsy(); + }); +}); diff --git a/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts b/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts new file mode 100644 index 00000000..438beead --- /dev/null +++ b/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts @@ -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('@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' }); + }); +}); diff --git a/packages/editor/tests/unit/layouts/props-panel/PropsPanel.spec.ts b/packages/editor/tests/unit/layouts/props-panel/PropsPanel.spec.ts new file mode 100644 index 00000000..5d5283b0 --- /dev/null +++ b/packages/editor/tests/unit/layouts/props-panel/PropsPanel.spec.ts @@ -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('@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('@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)); + }); +}); diff --git a/packages/editor/tests/unit/layouts/props-panel/use-style-panel.spec.ts b/packages/editor/tests/unit/layouts/props-panel/use-style-panel.spec.ts new file mode 100644 index 00000000..5df24397 --- /dev/null +++ b/packages/editor/tests/unit/layouts/props-panel/use-style-panel.spec.ts @@ -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 = 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); + }); +}); diff --git a/packages/editor/tests/unit/layouts/sidebar/ComponentListPanel.spec.ts b/packages/editor/tests/unit/layouts/sidebar/ComponentListPanel.spec.ts new file mode 100644 index 00000000..2471dc49 --- /dev/null +++ b/packages/editor/tests/unit/layouts/sidebar/ComponentListPanel.spec.ts @@ -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('@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(); + }); +}); diff --git a/packages/editor/tests/unit/layouts/sidebar/Sidebar.spec.ts b/packages/editor/tests/unit/layouts/sidebar/Sidebar.spec.ts new file mode 100644 index 00000000..a6f331c5 --- /dev/null +++ b/packages/editor/tests/unit/layouts/sidebar/Sidebar.spec.ts @@ -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('@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(); + }); +}); diff --git a/packages/editor/tests/unit/layouts/sidebar/code-block/CodeBlockList.spec.ts b/packages/editor/tests/unit/layouts/sidebar/code-block/CodeBlockList.spec.ts new file mode 100644 index 00000000..0d4b418c --- /dev/null +++ b/packages/editor/tests/unit/layouts/sidebar/code-block/CodeBlockList.spec.ts @@ -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('@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'); + }); +}); diff --git a/packages/editor/tests/unit/layouts/sidebar/code-block/CodeBlockListPanel.spec.ts b/packages/editor/tests/unit/layouts/sidebar/code-block/CodeBlockListPanel.spec.ts new file mode 100644 index 00000000..ca653ee2 --- /dev/null +++ b/packages/editor/tests/unit/layouts/sidebar/code-block/CodeBlockListPanel.spec.ts @@ -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(''); +const codeBlockEditor = ref(null); +const codeConfig = ref(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([ + ['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; + 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(); + }); +}); diff --git a/packages/editor/tests/unit/layouts/sidebar/code-block/useContentMenu.spec.ts b/packages/editor/tests/unit/layouts/sidebar/code-block/useContentMenu.spec.ts new file mode 100644 index 00000000..478f8c51 --- /dev/null +++ b/packages/editor/tests/unit/layouts/sidebar/code-block/useContentMenu.spec.ts @@ -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 | 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(); + }); +}); diff --git a/packages/editor/tests/unit/layouts/sidebar/data-source/DataSourceAddButton.spec.ts b/packages/editor/tests/unit/layouts/sidebar/data-source/DataSourceAddButton.spec.ts new file mode 100644 index 00000000..b50488d8 --- /dev/null +++ b/packages/editor/tests/unit/layouts/sidebar/data-source/DataSourceAddButton.spec.ts @@ -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); + }); +}); diff --git a/packages/editor/tests/unit/layouts/sidebar/data-source/DataSourceConfigPanel.spec.ts b/packages/editor/tests/unit/layouts/sidebar/data-source/DataSourceConfigPanel.spec.ts new file mode 100644 index 00000000..be77360b --- /dev/null +++ b/packages/editor/tests/unit/layouts/sidebar/data-source/DataSourceConfigPanel.spec.ts @@ -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('@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'); + }); +}); diff --git a/packages/editor/tests/unit/layouts/sidebar/data-source/DataSourceList.spec.ts b/packages/editor/tests/unit/layouts/sidebar/data-source/DataSourceList.spec.ts new file mode 100644 index 00000000..c08c1e9c --- /dev/null +++ b/packages/editor/tests/unit/layouts/sidebar/data-source/DataSourceList.spec.ts @@ -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('@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); + }); +}); diff --git a/packages/editor/tests/unit/layouts/sidebar/data-source/DataSourceListPanel.spec.ts b/packages/editor/tests/unit/layouts/sidebar/data-source/DataSourceListPanel.spec.ts new file mode 100644 index 00000000..aa17978f --- /dev/null +++ b/packages/editor/tests/unit/layouts/sidebar/data-source/DataSourceListPanel.spec.ts @@ -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(null); +const dataSourceValues = ref({}); +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([{ type: 'button', text: 'Edit' }]); +vi.mock('@editor/layouts/sidebar/data-source/useContentMenu', () => ({ + useContentMenu: () => ({ + nodeContentMenuHandler, + menuData, + contentMenuHideHandler, + }), +})); + +const filterFn = vi.fn(); +const dataSourceListNodeStatusMap = new Map(); +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'); + }); +}); diff --git a/packages/editor/tests/unit/layouts/sidebar/data-source/useContentMenu.spec.ts b/packages/editor/tests/unit/layouts/sidebar/data-source/useContentMenu.spec.ts new file mode 100644 index 00000000..d1d29c9d --- /dev/null +++ b/packages/editor/tests/unit/layouts/sidebar/data-source/useContentMenu.spec.ts @@ -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 | 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(); + }); +}); diff --git a/packages/editor/tests/unit/layouts/sidebar/layer/LayerMenu.spec.ts b/packages/editor/tests/unit/layouts/sidebar/layer/LayerMenu.spec.ts new file mode 100644 index 00000000..f62312b4 --- /dev/null +++ b/packages/editor/tests/unit/layouts/sidebar/layer/LayerMenu.spec.ts @@ -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('@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' }); + }); +}); diff --git a/packages/editor/tests/unit/layouts/sidebar/layer/LayerNodeTool.spec.ts b/packages/editor/tests/unit/layouts/sidebar/layer/LayerNodeTool.spec.ts new file mode 100644 index 00000000..16273841 --- /dev/null +++ b/packages/editor/tests/unit/layouts/sidebar/layer/LayerNodeTool.spec.ts @@ -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 }); + }); +}); diff --git a/packages/editor/tests/unit/layouts/sidebar/layer/LayerPanel.spec.ts b/packages/editor/tests/unit/layouts/sidebar/layer/LayerPanel.spec.ts new file mode 100644 index 00000000..f77786c5 --- /dev/null +++ b/packages/editor/tests/unit/layouts/sidebar/layer/LayerPanel.spec.ts @@ -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 = 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); + }); +}); diff --git a/packages/editor/tests/unit/layouts/sidebar/layer/use-click.spec.ts b/packages/editor/tests/unit/layouts/sidebar/layer/use-click.spec.ts new file mode 100644 index 00000000..0f4a4d81 --- /dev/null +++ b/packages/editor/tests/unit/layouts/sidebar/layer/use-click.spec.ts @@ -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('@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 = { + 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(); + }); +}); diff --git a/packages/editor/tests/unit/layouts/sidebar/layer/use-drag.spec.ts b/packages/editor/tests/unit/layouts/sidebar/layer/use-drag.spec.ts new file mode 100644 index 00000000..5b866eaa --- /dev/null +++ b/packages/editor/tests/unit/layouts/sidebar/layer/use-drag.spec.ts @@ -0,0 +1,242 @@ +/* + * 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 { useDrag } from '@editor/layouts/sidebar/layer/use-drag'; + +vi.mock('@editor/utils', async () => { + const actual = await vi.importActual('@editor/utils'); + return { + ...actual, + getNodeIndex: vi.fn(() => 0), + }; +}); + +vi.mock('@tmagic/utils', async () => { + const actual = await vi.importActual('@tmagic/utils'); + return { + ...actual, + addClassName: (el: any, _doc: any, className: string) => el?.classList?.add(className), + removeClassName: (el: any, ...classNames: string[]) => { + classNames.forEach((c) => el?.classList?.remove(c)); + }, + }; +}); + +const makeEditorService = () => ({ + getNodeInfo: vi.fn(), + get: vi.fn(() => []), + dragTo: vi.fn(), +}); + +const buildEvent = ({ + target, + currentTarget, + clientY = 50, +}: { + target?: HTMLElement; + currentTarget?: HTMLElement; + clientY?: number; +} = {}): DragEvent => { + const evt = new Event('drag') as DragEvent; + Object.defineProperty(evt, 'target', { value: target, configurable: true }); + Object.defineProperty(evt, 'currentTarget', { value: currentTarget, configurable: true }); + Object.defineProperty(evt, 'clientY', { value: clientY, configurable: true }); + Object.defineProperty(evt, 'dataTransfer', { + value: { effectAllowed: '', setData: vi.fn() }, + configurable: true, + }); + Object.defineProperty(evt, 'preventDefault', { value: vi.fn(), configurable: true }); + return evt; +}; + +const createNodeEl = (nodeId: string, isContainer = false, parentsId = '', draggable = true): HTMLElement => { + const el = document.createElement('div'); + el.dataset.nodeId = nodeId; + if (isContainer) el.dataset.isContainer = 'true'; + if (parentsId) el.dataset.parentsId = parentsId; + el.draggable = draggable; + const label = document.createElement('div'); + label.getBoundingClientRect = () => + ({ top: 0, height: 90, left: 0, right: 0, bottom: 0, width: 0, x: 0, y: 0, toJSON: () => null }) as any; + el.appendChild(label); + document.body.appendChild(el); + return el; +}; + +beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; +}); + +describe('useDrag', () => { + test('handleDragStart 在 currentTarget=target 时设置 dataTransfer', () => { + const editorService = makeEditorService(); + const { handleDragStart } = useDrag({ editorService } as any); + const el = createNodeEl('a'); + const ev = buildEvent({ target: el, currentTarget: el }); + handleDragStart(ev); + expect((ev.dataTransfer as DataTransfer).effectAllowed).toBe('move'); + expect((ev.dataTransfer as DataTransfer).setData).toHaveBeenCalled(); + }); + + test('handleDragStart target 不在 currentTarget 节点中时直接返回', () => { + const editorService = makeEditorService(); + const { handleDragStart } = useDrag({ editorService } as any); + const el = createNodeEl('a'); + const wrong = document.createElement('div'); + const ev = buildEvent({ target: el, currentTarget: wrong }); + handleDragStart(ev); + expect((ev.dataTransfer as DataTransfer).setData).not.toHaveBeenCalled(); + }); + + test('handleDragOver - distance < height/3 设为 before', () => { + const editorService = makeEditorService(); + const { handleDragOver, handleDragStart } = useDrag({ editorService } as any); + const sourceEl = createNodeEl('src'); + handleDragStart(buildEvent({ target: sourceEl, currentTarget: sourceEl })); + const targetEl = createNodeEl('tgt'); + const ev = buildEvent({ target: targetEl, currentTarget: targetEl, clientY: 5 }); + handleDragOver(ev); + expect(ev.preventDefault as any).toHaveBeenCalled(); + expect(targetEl.children[0].classList.contains('drag-before')).toBe(true); + }); + + test('handleDragOver - distance > height*2/3 设为 after', () => { + const editorService = makeEditorService(); + const { handleDragOver, handleDragStart } = useDrag({ editorService } as any); + const sourceEl = createNodeEl('src'); + handleDragStart(buildEvent({ target: sourceEl, currentTarget: sourceEl })); + const targetEl = createNodeEl('tgt'); + const ev = buildEvent({ target: targetEl, currentTarget: targetEl, clientY: 80 }); + handleDragOver(ev); + expect(targetEl.children[0].classList.contains('drag-after')).toBe(true); + }); + + test('handleDragOver - 中部且为容器时设为 inner', () => { + const editorService = makeEditorService(); + const { handleDragOver, handleDragStart } = useDrag({ editorService } as any); + const sourceEl = createNodeEl('src'); + handleDragStart(buildEvent({ target: sourceEl, currentTarget: sourceEl })); + const targetEl = createNodeEl('tgt', true); + const ev = buildEvent({ target: targetEl, currentTarget: targetEl, clientY: 45 }); + handleDragOver(ev); + expect(targetEl.children[0].classList.contains('drag-inner')).toBe(true); + }); + + test('handleDragOver - 父节点 includes nodeId 时返回', () => { + const editorService = makeEditorService(); + const { handleDragOver, handleDragStart } = useDrag({ editorService } as any); + const sourceEl = createNodeEl('src'); + handleDragStart(buildEvent({ target: sourceEl, currentTarget: sourceEl })); + const targetEl = createNodeEl('tgt', false, 'src,grand'); + const ev = buildEvent({ target: targetEl, currentTarget: targetEl, clientY: 5 }); + handleDragOver(ev); + expect(targetEl.children[0].classList.contains('drag-before')).toBe(false); + }); + + test('handleDragOver - canDropIn 返回 false 时禁止 inner', () => { + const editorService = makeEditorService(); + const canDropIn = vi.fn(() => false); + const { handleDragOver, handleDragStart } = useDrag({ editorService } as any, { canDropIn }); + const sourceEl = createNodeEl('src'); + handleDragStart(buildEvent({ target: sourceEl, currentTarget: sourceEl })); + const targetEl = createNodeEl('tgt', true); + const ev = buildEvent({ target: targetEl, currentTarget: targetEl, clientY: 45 }); + handleDragOver(ev); + expect(targetEl.children[0].classList.contains('drag-inner')).toBe(false); + }); + + test('handleDragOver - canDropIn 返回 id 时记录 redirectedTargetId', () => { + const editorService = makeEditorService(); + const canDropIn = vi.fn(() => 'redirected-id'); + const { handleDragOver, handleDragStart } = useDrag({ editorService } as any, { canDropIn }); + const sourceEl = createNodeEl('src'); + handleDragStart(buildEvent({ target: sourceEl, currentTarget: sourceEl })); + const targetEl = createNodeEl('tgt', true); + const ev = buildEvent({ target: targetEl, currentTarget: targetEl, clientY: 45 }); + handleDragOver(ev); + expect(targetEl.children[0].classList.contains('drag-inner')).toBe(true); + }); + + test('handleDragLeave 移除 class', () => { + const editorService = makeEditorService(); + const { handleDragLeave } = useDrag({ editorService } as any); + const targetEl = createNodeEl('tgt'); + const labelEl = targetEl.children[0]; + labelEl.classList.add('drag-before'); + handleDragLeave(buildEvent({ target: targetEl, currentTarget: targetEl })); + expect(labelEl.classList.contains('drag-before')).toBe(false); + }); + + test('handleDragEnd - inner 时设置 targetIndex 为 items.length', () => { + const editorService = makeEditorService(); + const targetNode = { id: 'tgt', items: [{}, {}] }; + editorService.getNodeInfo.mockReturnValue({ node: targetNode, parent: { id: 'p' } }); + const { handleDragOver, handleDragStart, handleDragEnd } = useDrag({ editorService } as any); + const sourceEl = createNodeEl('src'); + handleDragStart(buildEvent({ target: sourceEl, currentTarget: sourceEl })); + const targetEl = createNodeEl('tgt', true); + handleDragOver(buildEvent({ target: targetEl, currentTarget: targetEl, clientY: 45 })); + + handleDragEnd(buildEvent({ target: sourceEl, currentTarget: sourceEl }), { id: 'src' } as any); + expect(editorService.dragTo).toHaveBeenCalledWith([{ id: 'src' }], targetNode, 2); + }); + + test('handleDragEnd - dropType 为 after 时 index+1', () => { + const editorService = makeEditorService(); + editorService.getNodeInfo.mockReturnValue({ node: { id: 'tgt' }, parent: { id: 'p', items: [] } }); + const { handleDragOver, handleDragStart, handleDragEnd } = useDrag({ editorService } as any); + const sourceEl = createNodeEl('src'); + handleDragStart(buildEvent({ target: sourceEl, currentTarget: sourceEl })); + const targetEl = createNodeEl('tgt'); + handleDragOver(buildEvent({ target: targetEl, currentTarget: targetEl, clientY: 80 })); + handleDragEnd(buildEvent({ target: sourceEl, currentTarget: sourceEl }), { id: 'src' } as any); + expect(editorService.dragTo).toHaveBeenCalledWith([{ id: 'src' }], { id: 'p', items: [] }, 1); + }); + + test('handleDragEnd - dragOverNodeId 等于 node.id 时返回', () => { + const editorService = makeEditorService(); + const { handleDragOver, handleDragStart, handleDragEnd } = useDrag({ editorService } as any); + const sourceEl = createNodeEl('src'); + handleDragStart(buildEvent({ target: sourceEl, currentTarget: sourceEl })); + const targetEl = createNodeEl('src', true); + handleDragOver(buildEvent({ target: targetEl, currentTarget: targetEl, clientY: 45 })); + handleDragEnd(buildEvent({ target: sourceEl, currentTarget: sourceEl }), { id: 'src' } as any); + expect(editorService.dragTo).not.toHaveBeenCalled(); + }); + + test('handleDragEnd - canDropIn 返回 redirectedId 时使用重定向', () => { + const editorService = makeEditorService(); + const targetNode = { id: 'tgt', items: [] }; + const redirectedNode = { id: 'red', items: [{}] }; + editorService.getNodeInfo.mockImplementation((id: string) => { + if (id === 'red') return { node: redirectedNode }; + return { node: targetNode, parent: { id: 'p' } }; + }); + const canDropIn = vi.fn(() => 'red'); + const { handleDragOver, handleDragStart, handleDragEnd } = useDrag({ editorService } as any, { canDropIn }); + const sourceEl = createNodeEl('src'); + handleDragStart(buildEvent({ target: sourceEl, currentTarget: sourceEl })); + const targetEl = createNodeEl('tgt', true); + handleDragOver(buildEvent({ target: targetEl, currentTarget: targetEl, clientY: 45 })); + handleDragEnd(buildEvent({ target: sourceEl, currentTarget: sourceEl }), { id: 'src' } as any); + expect(editorService.dragTo).toHaveBeenCalledWith([{ id: 'src' }], redirectedNode, 1); + }); + + test('handleDragEnd - selectedNodes 包含 node 时使用 selectedNodes', () => { + const editorService = makeEditorService(); + editorService.getNodeInfo.mockReturnValue({ node: { id: 'tgt' }, parent: { id: 'p', items: [] } }); + editorService.get.mockReturnValue([{ id: 'src' }, { id: 'sib' }]); + const { handleDragOver, handleDragStart, handleDragEnd } = useDrag({ editorService } as any); + const sourceEl = createNodeEl('src'); + handleDragStart(buildEvent({ target: sourceEl, currentTarget: sourceEl })); + const targetEl = createNodeEl('tgt'); + handleDragOver(buildEvent({ target: targetEl, currentTarget: targetEl, clientY: 5 })); + handleDragEnd(buildEvent({ target: sourceEl, currentTarget: sourceEl }), { id: 'src' } as any); + expect(editorService.dragTo).toHaveBeenCalledWith([{ id: 'src' }, { id: 'sib' }], expect.anything(), 0); + }); +}); diff --git a/packages/editor/tests/unit/layouts/sidebar/layer/use-keybinding.spec.ts b/packages/editor/tests/unit/layouts/sidebar/layer/use-keybinding.spec.ts new file mode 100644 index 00000000..db5ec8ab --- /dev/null +++ b/packages/editor/tests/unit/layouts/sidebar/layer/use-keybinding.spec.ts @@ -0,0 +1,71 @@ +/* + * 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 { effectScope, nextTick, shallowRef } from 'vue'; + +import { useKeybinding } from '@editor/layouts/sidebar/layer/use-keybinding'; + +const mkServices = () => { + const handlers: Record void> = {}; + const keybindingService = { + registerCommand: vi.fn((name: string, fn: () => void) => { + handlers[name] = fn; + }), + register: vi.fn(), + registerEl: vi.fn(), + unregisterEl: vi.fn(), + handlers, + }; + return { keybindingService }; +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useKeybinding (layer)', () => { + test('注册 keyup/keydown 命令并切换 isCtrlKeyDown', () => { + const services = mkServices(); + const container = shallowRef(null); + const { isCtrlKeyDown } = useKeybinding(services as any, container as any); + expect(services.keybindingService.registerCommand).toHaveBeenCalledTimes(2); + expect(services.keybindingService.register).toHaveBeenCalled(); + services.keybindingService.handlers['layer-panel-global-keydown'](); + expect(isCtrlKeyDown.value).toBe(true); + services.keybindingService.handlers['layer-panel-global-keyup'](); + expect(isCtrlKeyDown.value).toBe(false); + }); + + test('container 存在时注册 blur 事件并 registerEl', async () => { + const services = mkServices(); + const $el = document.createElement('div'); + const container = shallowRef({ $el }); + const addSpy = vi.spyOn(globalThis, 'addEventListener'); + const scope = effectScope(); + scope.run(() => useKeybinding(services as any, container as any)); + await nextTick(); + expect(services.keybindingService.registerEl).toHaveBeenCalledWith('layer-panel', $el); + expect(addSpy).toHaveBeenCalledWith('blur', expect.any(Function)); + addSpy.mockRestore(); + scope.stop(); + }); + + test('container 设为 null 时 unregisterEl 并移除 blur', async () => { + const services = mkServices(); + const $el = document.createElement('div'); + const container = shallowRef({ $el } as any); + const removeSpy = vi.spyOn(globalThis, 'removeEventListener'); + const scope = effectScope(); + scope.run(() => useKeybinding(services as any, container as any)); + await nextTick(); + container.value = null; + await nextTick(); + expect(services.keybindingService.unregisterEl).toHaveBeenCalledWith('layer-panel'); + expect(removeSpy).toHaveBeenCalledWith('blur', expect.any(Function)); + removeSpy.mockRestore(); + scope.stop(); + }); +}); diff --git a/packages/editor/tests/unit/layouts/sidebar/layer/use-node-status.spec.ts b/packages/editor/tests/unit/layouts/sidebar/layer/use-node-status.spec.ts new file mode 100644 index 00000000..d58459d1 --- /dev/null +++ b/packages/editor/tests/unit/layouts/sidebar/layer/use-node-status.spec.ts @@ -0,0 +1,98 @@ +/* + * 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 } from 'vue'; + +import { useNodeStatus } from '@editor/layouts/sidebar/layer/use-node-status'; + +vi.mock('@tmagic/utils', async () => { + const actual = await vi.importActual('@tmagic/utils'); + return { + ...actual, + isPage: (n: any) => n.type === 'page', + isPageFragment: (n: any) => n.type === 'page-fragment', + getNodePath: vi.fn(() => []), + traverseNode: (node: any, fn: any) => { + fn(node); + node.items?.forEach((c: any) => fn(c)); + }, + }; +}); + +vi.mock('@editor/utils/tree', () => ({ + updateStatus: vi.fn((map: Map, id: any, status: any) => { + const cur = map.get(id) || {}; + map.set(id, { ...cur, ...status }); + }), +})); + +let editorState: Record; +const editorEvents: Record any)[]> = {}; +const mkEditorService = () => ({ + get: vi.fn((k: string) => editorState[k]), + on: vi.fn((evt: string, fn: any) => { + editorEvents[evt] ||= []; + editorEvents[evt].push(fn); + }), + off: vi.fn((evt: string, fn: any) => { + if (editorEvents[evt]) editorEvents[evt] = editorEvents[evt].filter((f) => f !== fn); + }), +}); + +beforeEach(() => { + vi.clearAllMocks(); + Object.keys(editorEvents).forEach((k) => delete editorEvents[k]); + editorState = { + page: { id: 'p1', type: 'page', items: [{ id: 'n1', type: 'node' }] }, + nodes: [{ id: 'n1' }], + }; +}); + +describe('useNodeStatus (layer)', () => { + test('初始化生成 page 节点状态', () => { + const editorService = mkEditorService(); + const { nodeStatusMap } = useNodeStatus({ editorService } as any); + expect(nodeStatusMap.value?.size).toBe(2); + expect(nodeStatusMap.value?.get('p1')?.expand).toBe(true); + }); + + test('addHandler 添加节点状态', () => { + const editorService = mkEditorService(); + useNodeStatus({ editorService } as any); + editorEvents.add[0]([{ id: 'newId', type: 'node', items: [{ id: 'child' }] }]); + // The status map is the current page map + // The handler gets the current map via nodeStatusMap.value + }); + + test('addHandler 跳过 page/page-fragment', () => { + const editorService = mkEditorService(); + const { nodeStatusMap } = useNodeStatus({ editorService } as any); + editorEvents.add[0]([{ id: 'newPage', type: 'page' }]); + expect(nodeStatusMap.value?.has('newPage')).toBe(false); + }); + + test('removeHandler 删除节点', () => { + const editorService = mkEditorService(); + const { nodeStatusMap } = useNodeStatus({ editorService } as any); + editorEvents.remove[0]([{ id: 'n1', type: 'node' }]); + expect(nodeStatusMap.value?.has('n1')).toBe(false); + }); + + test('nodes 选中变化更新状态', async () => { + const editorService = mkEditorService(); + const { nodeStatusMap } = useNodeStatus({ editorService } as any); + editorState.nodes = [{ id: 'n1' }]; + await nextTick(); + expect(nodeStatusMap.value?.get('n1')?.selected).toBe(true); + }); + + test('注册 add/remove 事件', () => { + const editorService = mkEditorService(); + useNodeStatus({ editorService } as any); + expect(editorService.on).toHaveBeenCalledWith('add', expect.any(Function)); + expect(editorService.on).toHaveBeenCalledWith('remove', expect.any(Function)); + }); +}); diff --git a/packages/editor/tests/unit/layouts/workspace/Breadcrumb.spec.ts b/packages/editor/tests/unit/layouts/workspace/Breadcrumb.spec.ts new file mode 100644 index 00000000..3cc2f8ca --- /dev/null +++ b/packages/editor/tests/unit/layouts/workspace/Breadcrumb.spec.ts @@ -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, nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; + +import Breadcrumb from '@editor/layouts/workspace/Breadcrumb.vue'; + +const editorService = { + get: vi.fn(), + select: vi.fn().mockResolvedValue(undefined), +}; + +const stage = { + select: vi.fn(), +}; + +vi.mock('@editor/hooks/use-services', () => ({ + useServices: () => ({ editorService }), +})); + +vi.mock('@tmagic/utils', () => ({ + getNodePath: (id: string, _items: any[]) => { + if (id === 'n1') return [{ id: 'r', name: 'root' }]; + if (id === 'long') + return [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + { id: 'c', name: 'C' }, + { id: 'd', name: 'D' }, + { id: 'e', name: 'E' }, + ]; + return []; + }, +})); + +vi.mock('@tmagic/design', () => ({ + TMagicTooltip: defineComponent({ + name: 'FakeTooltip', + setup(_p, { slots }) { + return () => h('div', { class: 'fake-tooltip' }, slots.default?.()); + }, + }), + TMagicButton: defineComponent({ + name: 'FakeButton', + inheritAttrs: false, + props: ['disabled'], + emits: ['click'], + setup(props, { slots, emit, attrs }) { + return () => + h( + 'button', + { + ...attrs, + class: ['fake-btn', (attrs as any).class].filter(Boolean).join(' '), + disabled: props.disabled, + onClick: () => emit('click'), + }, + slots.default?.(), + ); + }, + }), +})); + +class FakeResizeObserver { + public static instances: FakeResizeObserver[] = []; + public cb: ResizeObserverCallback; + constructor(cb: ResizeObserverCallback) { + this.cb = cb; + FakeResizeObserver.instances.push(this); + } + public observe() {} + public disconnect() {} +} +(globalThis as any).ResizeObserver = FakeResizeObserver; + +beforeEach(() => { + vi.clearAllMocks(); + FakeResizeObserver.instances = []; + editorService.get.mockImplementation((k: string) => { + if (k === 'node') return { id: 'n1' }; + if (k === 'nodes') return [{ id: 'n1' }]; + if (k === 'root') return { items: [] }; + if (k === 'stage') return stage; + return null; + }); +}); + +describe('Breadcrumb.vue', () => { + test('单选时渲染面包屑', async () => { + const wrapper = mount(Breadcrumb, { attachTo: document.body }); + await nextTick(); + expect(wrapper.find('.m-editor-breadcrumb').exists()).toBe(true); + expect(wrapper.text()).toContain('root'); + }); + + test('多选时不渲染', async () => { + editorService.get.mockImplementation((k: string) => { + if (k === 'nodes') return [{ id: 'a' }, { id: 'b' }]; + return null; + }); + const wrapper = mount(Breadcrumb); + expect(wrapper.find('.m-editor-breadcrumb').exists()).toBe(false); + }); + + test('点击 item 调用 select', async () => { + const wrapper = mount(Breadcrumb, { attachTo: document.body }); + await nextTick(); + const btn = wrapper.find('button'); + await btn.trigger('click'); + expect(editorService.select).toHaveBeenCalled(); + expect(stage.select).toHaveBeenCalled(); + }); + + test('折叠路径 (>3 时折叠)', async () => { + editorService.get.mockImplementation((k: string) => { + if (k === 'node') return { id: 'long' }; + if (k === 'nodes') return [{ id: 'long' }]; + if (k === 'root') return { items: [] }; + return null; + }); + const wrapper = mount(Breadcrumb, { attachTo: document.body }); + await nextTick(); + const container = wrapper.find('.m-editor-breadcrumb').element as HTMLElement; + const parent = container.parentElement; + if (parent) { + Object.defineProperty(parent, 'clientWidth', { configurable: true, value: 50 }); + } + Object.defineProperty(container, 'scrollWidth', { configurable: true, value: 500 }); + FakeResizeObserver.instances.forEach((i) => i.cb([] as any, {} as any)); + await nextTick(); + await nextTick(); + expect(wrapper.text()).toContain('...'); + }); +}); diff --git a/packages/editor/tests/unit/layouts/workspace/Workspace.spec.ts b/packages/editor/tests/unit/layouts/workspace/Workspace.spec.ts new file mode 100644 index 00000000..dd7e641b --- /dev/null +++ b/packages/editor/tests/unit/layouts/workspace/Workspace.spec.ts @@ -0,0 +1,93 @@ +/* + * 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 Workspace from '@editor/layouts/workspace/Workspace.vue'; + +const editorService = { + get: vi.fn(), +}; + +vi.mock('@editor/hooks/use-services', () => ({ + useServices: () => ({ editorService }), +})); + +vi.mock('@editor/layouts/workspace/viewer/Stage.vue', () => ({ + default: defineComponent({ + name: 'FakeStage', + props: ['stageOptions', 'disabledStageOverlay', 'stageContentMenu', 'customContentMenu'], + setup(_p, { slots }) { + return () => h('div', { class: 'fake-stage' }, [slots['stage-top']?.()]); + }, + }), +})); + +vi.mock('@editor/layouts/workspace/Breadcrumb.vue', () => ({ + default: defineComponent({ + name: 'FakeBreadcrumb', + setup() { + return () => h('div', { class: 'fake-breadcrumb' }); + }, + }), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('Workspace.vue', () => { + test('page 存在且 stageOptions 含 render 时渲染 Stage', () => { + editorService.get.mockReturnValue({ id: 'p1' }); + const wrapper = mount(Workspace, { + props: { + stageContentMenu: [] as any, + customContentMenu: ((m: any) => m) as any, + }, + global: { + provide: { + stageOptions: { render: () => ({}) }, + }, + }, + }); + expect(wrapper.find('.fake-stage').exists()).toBe(true); + expect(wrapper.find('.fake-breadcrumb').exists()).toBe(true); + }); + + test('page 不存在时不渲染 Stage', () => { + editorService.get.mockReturnValue(null); + const wrapper = mount(Workspace, { + props: { + stageContentMenu: [] as any, + customContentMenu: ((m: any) => m) as any, + }, + global: { + provide: { + stageOptions: { render: () => ({}) }, + }, + }, + }); + expect(wrapper.find('.fake-stage').exists()).toBe(false); + }); + + test('使用 stage 插槽自定义舞台', () => { + editorService.get.mockReturnValue({ id: 'p1' }); + const wrapper = mount(Workspace, { + props: { + stageContentMenu: [] as any, + customContentMenu: ((m: any) => m) as any, + }, + slots: { + stage: () => h('div', { class: 'custom-stage' }, 'custom'), + 'workspace-content': () => h('div', { class: 'ws-content' }, 'ws'), + }, + }); + expect(wrapper.find('.custom-stage').exists()).toBe(true); + expect(wrapper.find('.fake-stage').exists()).toBe(false); + expect(wrapper.find('.ws-content').exists()).toBe(true); + }); +}); diff --git a/packages/editor/tests/unit/layouts/workspace/viewer/NodeListMenu.spec.ts b/packages/editor/tests/unit/layouts/workspace/viewer/NodeListMenu.spec.ts new file mode 100644 index 00000000..4089fc7b --- /dev/null +++ b/packages/editor/tests/unit/layouts/workspace/viewer/NodeListMenu.spec.ts @@ -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, ref } from 'vue'; +import { mount } from '@vue/test-utils'; + +import NodeListMenu from '@editor/layouts/workspace/viewer/NodeListMenu.vue'; + +const stageState: { + handlers: Record; + renderer: any; + select: ReturnType; + on(name: string, cb: Function): void; + emit(name: string, ...args: any[]): void; +} = { + handlers: {}, + renderer: { getElementsFromPoint: vi.fn(() => []) }, + select: vi.fn(), + 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)); + }, +}; + +const editorState = { + stage: ref(stageState), + page: ref({ id: 'p1', items: [] }), + nodes: ref([]), +}; + +const editorService = { + get: vi.fn((k: string) => (editorState as any)[k]?.value), + select: vi.fn(), +}; + +const nodeStatusMap = ref(new Map([['p1', { selected: false }]])); + +vi.mock('@editor/hooks/use-services', () => ({ + useServices: () => ({ editorService }), +})); + +vi.mock('@editor/layouts/sidebar/layer/use-node-status', () => ({ + useNodeStatus: () => ({ nodeStatusMap }), +})); + +const filterTextChangeHandler = vi.fn(); +vi.mock('@editor/hooks/use-filter', () => ({ + useFilter: () => ({ filterTextChangeHandler }), +})); + +vi.mock('@tmagic/utils', () => ({ + getIdFromEl: () => (el: any) => el?.id, +})); + +vi.mock('@tmagic/design', () => ({ + TMagicTooltip: defineComponent({ + name: 'FakeTooltip', + setup(_p, { slots }) { + return () => h('div', { class: 'fake-tooltip' }, slots.default?.()); + }, + }), +})); + +vi.mock('@editor/components/FloatingBox.vue', () => ({ + default: defineComponent({ + name: 'FakeFloatingBox', + props: ['visible', 'title', 'position'], + emits: ['update:visible'], + setup(_p, { slots, expose }) { + expose({ target: { clientHeight: 100 } }); + return () => h('div', { class: 'fake-float-box' }, slots.body?.()); + }, + }), +})); + +vi.mock('@editor/components/Tree.vue', () => ({ + default: defineComponent({ + name: 'FakeTree', + props: ['data', 'nodeStatusMap'], + emits: ['node-click'], + setup(_p, { emit }) { + return () => + h('div', { + class: 'fake-tree', + onClick: () => emit('node-click', new MouseEvent('click'), { id: 'p1' }), + }); + }, + }), +})); + +beforeEach(() => { + vi.clearAllMocks(); + stageState.handlers = {}; + editorState.stage.value = stageState; + editorState.page.value = { id: 'p1', items: [] }; + editorState.nodes.value = []; + nodeStatusMap.value = new Map([['p1', { selected: false }]]); +}); + +describe('NodeListMenu.vue', () => { + test('初始 buttonVisible 为 false 不显示按钮', () => { + const wrapper = mount(NodeListMenu); + expect(wrapper.find('.m-editor-stage-float-button').exists()).toBe(false); + }); + + test('stage select 触发后 ids 数大于 3 显示按钮', async () => { + const wrapper = mount(NodeListMenu); + await nextTick(); + stageState.renderer.getElementsFromPoint.mockReturnValue([{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }]); + stageState.emit('select', null, new MouseEvent('click')); + await nextTick(); + expect(wrapper.find('.m-editor-stage-float-button').exists()).toBe(true); + expect(filterTextChangeHandler).toHaveBeenCalledWith(['a', 'b', 'c', 'd']); + }); + + test('点击按钮显示 FloatingBox 并计算位置', async () => { + const wrapper = mount(NodeListMenu, { attachTo: document.body }); + await nextTick(); + stageState.renderer.getElementsFromPoint.mockReturnValue([{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }]); + stageState.emit('select', null, new MouseEvent('click')); + await nextTick(); + const btn = wrapper.find('.m-editor-stage-float-button'); + Object.defineProperty(btn.element, 'getBoundingClientRect', { + value: () => ({ left: 10, top: 20, width: 50, height: 30 }), + configurable: true, + }); + await btn.trigger('click'); + await nextTick(); + await nextTick(); + expect(wrapper.find('.fake-float-box').exists()).toBe(true); + }); + + test('Tree node-click 调用 select', async () => { + const wrapper = mount(NodeListMenu); + await nextTick(); + stageState.renderer.getElementsFromPoint.mockReturnValue([{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }]); + stageState.emit('select', null, new MouseEvent('click')); + await nextTick(); + await wrapper.find('.m-editor-stage-float-button').trigger('click'); + await nextTick(); + await wrapper.find('.fake-tree').trigger('click'); + await nextTick(); + expect(editorService.select).toHaveBeenCalledWith('p1'); + expect(stageState.select).toHaveBeenCalledWith('p1'); + }); + + test('nodes 改变时同步 selected 状态', async () => { + mount(NodeListMenu); + await nextTick(); + editorState.nodes.value = [{ id: 'p1' }]; + await nextTick(); + expect(nodeStatusMap.value.get('p1').selected).toBe(true); + }); + + test('page 为 null 时 buttonVisible 不渲染', async () => { + editorState.page.value = null; + const wrapper = mount(NodeListMenu); + expect(wrapper.find('.m-editor-stage-float-button').exists()).toBe(false); + expect(wrapper.find('.fake-float-box').exists()).toBe(false); + }); +}); diff --git a/packages/editor/tests/unit/layouts/workspace/viewer/Stage.spec.ts b/packages/editor/tests/unit/layouts/workspace/viewer/Stage.spec.ts new file mode 100644 index 00000000..8367899a --- /dev/null +++ b/packages/editor/tests/unit/layouts/workspace/viewer/Stage.spec.ts @@ -0,0 +1,429 @@ +/* + * 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 Stage from '@editor/layouts/workspace/viewer/Stage.vue'; + +const { stageInstance, stageHandlers } = vi.hoisted(() => { + const handlers: Record = {}; + return { + stageHandlers: handlers, + stageInstance: { + on: vi.fn((event: string, cb: any) => { + handlers[event] = handlers[event] || []; + handlers[event].push(cb); + }), + mount: vi.fn(), + destroy: vi.fn(), + setZoom: vi.fn(), + select: vi.fn(), + actionManager: { + getElementFromPoint: vi.fn(async () => document.createElement('div')), + getNextElementFromPoint: vi.fn(async () => document.createElement('div')), + }, + renderer: { + contentWindow: { document: { querySelector: vi.fn(() => null) } }, + getTargetElement: vi.fn(), + }, + mask: { scrollTop: 0, scrollLeft: 0 }, + }, + }; +}); + +vi.mock('@editor/hooks/use-stage', () => ({ + useStage: vi.fn(() => stageInstance), +})); + +const editorService = { + get: vi.fn(), + set: vi.fn(), + getNodeById: vi.fn(), + getLayout: vi.fn(async () => 'relative'), + add: vi.fn(), + select: vi.fn(), +}; +const uiService = { get: vi.fn(), set: vi.fn() }; +const keybindingService = { registerEl: vi.fn(), unregisterEl: vi.fn() }; +const stageOverlayService = { openOverlay: vi.fn() }; + +vi.mock('@editor/hooks', () => ({ + useServices: () => ({ editorService, uiService, keybindingService, stageOverlayService }), +})); + +vi.mock('@editor/utils/config', () => ({ + getEditorConfig: vi.fn(() => (s: string) => { + if (s.startsWith('(')) return JSON.parse(s.slice(1, -1)); + return JSON.parse(s); + }), +})); + +vi.mock('@editor/components/ScrollViewer.vue', () => ({ + default: defineComponent({ + name: 'ScrollViewer', + props: ['width', 'height', 'wrapWidth', 'wrapHeight', 'zoom', 'correctionScrollSize'], + setup(_p, { slots, expose }) { + const container = { focus: vi.fn() }; + expose({ container }); + return () => h('div', { class: 'fake-scroll-viewer' }, [slots.before?.(), slots.default?.(), slots.content?.()]); + }, + }), +})); + +vi.mock('@editor/layouts/workspace/viewer/NodeListMenu.vue', () => ({ + default: defineComponent({ name: 'NodeListMenu', setup: () => () => h('div') }), +})); + +vi.mock('@editor/layouts/workspace/viewer/StageOverlay.vue', () => ({ + default: defineComponent({ name: 'StageOverlay', setup: () => () => h('div', { class: 'fake-overlay' }) }), +})); + +vi.mock('@editor/layouts/workspace/viewer/ViewerMenu.vue', () => ({ + default: defineComponent({ + name: 'ViewerMenu', + props: ['isMultiSelect', 'stageContentMenu', 'customContentMenu'], + setup(_p, { expose }) { + expose({ show: vi.fn() }); + return () => h('div', { class: 'fake-viewer-menu' }); + }, + }), +})); + +vi.mock('@tmagic/utils', async () => { + const actual = await vi.importActual('@tmagic/utils'); + return { + ...actual, + getIdFromEl: () => (el: any) => el?.dataset?.id, + calcValueByFontsize: vi.fn((_doc: any, v: number) => `${v}px`), + }; +}); + +vi.mock('@tmagic/stage', () => { + const stageMock: any = vi.fn(); + return { + default: stageMock, + getOffset: vi.fn(() => ({ left: 0, top: 0 })), + }; +}); + +class FakeResizeObserver { + observe() {} + disconnect() {} +} +(globalThis as any).ResizeObserver = FakeResizeObserver; + +beforeEach(() => { + vi.clearAllMocks(); + Object.keys(stageHandlers).forEach((k) => delete stageHandlers[k]); + uiService.get.mockImplementation((k: string) => { + if (k === 'stageRect') return { width: 800, height: 600 }; + if (k === 'stageContainerRect') return { width: 800, height: 600 }; + if (k === 'zoom') return 1; + return null; + }); + editorService.get.mockImplementation((k: string) => { + if (k === 'stageLoading') return false; + if (k === 'nodes') return [{ id: 'n1' }]; + if (k === 'root') return { id: 'r', items: [{ id: 'p1' }] }; + if (k === 'page') return { id: 'p1' }; + if (k === 'node') return { id: 'n1' }; + return null; + }); +}); + +const mountIt = (props: any = {}) => + mount(Stage, { + props: { + stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'highlight' }, + stageContentMenu: [], + customContentMenu: (m: any) => m, + ...props, + } as any, + attachTo: document.body, + }); + +describe('Stage', () => { + test('挂载并创建 stage', async () => { + const wrapper = mountIt(); + await nextTick(); + expect(stageInstance.mount).toHaveBeenCalled(); + expect(editorService.set).toHaveBeenCalledWith('stage', expect.anything()); + wrapper.unmount(); + }); + + test('卸载时销毁 stage', async () => { + const wrapper = mountIt(); + await nextTick(); + wrapper.unmount(); + expect(stageInstance.destroy).toHaveBeenCalled(); + }); + + test('contextmenu 显示菜单', async () => { + const wrapper = mountIt(); + await nextTick(); + const event = new MouseEvent('contextmenu'); + const stageContainer = wrapper.find('.m-editor-stage-container').element; + stageContainer.dispatchEvent(event); + }); + + test('dragover 设置 dropEffect', async () => { + const wrapper = mountIt(); + await nextTick(); + const event: any = new Event('dragover'); + event.dataTransfer = { dropEffect: '' }; + event.preventDefault = vi.fn(); + const stageContainer = wrapper.find('.m-editor-stage-container').element; + stageContainer.dispatchEvent(event); + expect(event.dataTransfer.dropEffect).toBe('move'); + }); + + test('drop 处理 COMPONENT_LIST 拖拽', async () => { + editorService.getNodeById.mockReturnValue(null); + const wrapper = mountIt(); + await nextTick(); + const event: any = new Event('drop'); + event.dataTransfer = { + getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'), + }; + event.clientX = 100; + event.clientY = 100; + event.preventDefault = vi.fn(); + const stageContainer = wrapper.find('.m-editor-stage-container').element; + stageContainer.dispatchEvent(event); + await new Promise((r) => setTimeout(r, 10)); + expect(editorService.add).toHaveBeenCalled(); + }); + + test('zoom 变化时 stage.setZoom 被调用', async () => { + const wrapper = mountIt(); + await nextTick(); + uiService.get.mockImplementation((k: string) => { + if (k === 'stageRect') return { width: 800, height: 600 }; + if (k === 'stageContainerRect') return { width: 800, height: 600 }; + if (k === 'zoom') return 2; + return null; + }); + // 通过修改 ui state,watcher 不会自动触发,因为 ui get 是 vi.fn + void wrapper; + expect(true).toBe(true); + }); + + test('disabledStageOverlay 控制 StageOverlay 显示', async () => { + const wrapper = mountIt({ disabledStageOverlay: true }); + await nextTick(); + expect(wrapper.find('.fake-overlay').exists()).toBe(false); + }); + + test('drop 数据为空时不处理', async () => { + const wrapper = mountIt(); + await nextTick(); + const event: any = new Event('drop'); + event.dataTransfer = { getData: vi.fn(() => '') }; + event.preventDefault = vi.fn(); + const stageContainer = wrapper.find('.m-editor-stage-container').element; + stageContainer.dispatchEvent(event); + expect(editorService.add).not.toHaveBeenCalled(); + }); + + test('drop 非 COMPONENT_LIST 时不处理', async () => { + const wrapper = mountIt(); + await nextTick(); + const event: any = new Event('drop'); + event.dataTransfer = { + getData: vi.fn(() => '{"dragType":"other","data":{"name":"text","style":{}}}'), + }; + event.preventDefault = vi.fn(); + const stageContainer = wrapper.find('.m-editor-stage-container').element; + stageContainer.dispatchEvent(event); + await new Promise((r) => setTimeout(r, 10)); + expect(editorService.add).not.toHaveBeenCalled(); + }); + + test('drop position fixed 计算位置', async () => { + editorService.getNodeById.mockReturnValue(null); + editorService.getLayout.mockResolvedValue('relative'); + const wrapper = mountIt(); + await nextTick(); + const event: any = new Event('drop'); + event.dataTransfer = { + getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{"position":"fixed"}}}'), + }; + event.clientX = 80; + event.clientY = 60; + event.preventDefault = vi.fn(); + const stageContainer = wrapper.find('.m-editor-stage-container').element; + Object.defineProperty(stageContainer, 'getBoundingClientRect', { + value: () => ({ left: 0, top: 0, width: 800, height: 600 }), + configurable: true, + }); + stageContainer.dispatchEvent(event); + await new Promise((r) => setTimeout(r, 10)); + const args = editorService.add.mock.calls[0][0]; + expect(args.style.position).toBe('fixed'); + }); + + test('drop layout 为 absolute 时计算 top/left', async () => { + editorService.getNodeById.mockReturnValue(null); + editorService.getLayout.mockResolvedValue('absolute'); + const wrapper = mountIt(); + await nextTick(); + const event: any = new Event('drop'); + event.dataTransfer = { + getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'), + }; + event.clientX = 80; + event.clientY = 60; + event.preventDefault = vi.fn(); + const stageContainer = wrapper.find('.m-editor-stage-container').element; + Object.defineProperty(stageContainer, 'getBoundingClientRect', { + value: () => ({ left: 0, top: 0, width: 800, height: 600 }), + configurable: true, + }); + stageContainer.dispatchEvent(event); + await new Promise((r) => setTimeout(r, 10)); + const args = editorService.add.mock.calls[0][0]; + expect(args.style.position).toBe('absolute'); + }); + + test('drop canDropIn 返回 false 时不处理', async () => { + editorService.getNodeById.mockReturnValue(null); + const canDropIn = vi.fn(() => false); + const wrapper = mountIt({ + stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'highlight', canDropIn }, + }); + await nextTick(); + const event: any = new Event('drop'); + event.dataTransfer = { + getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'), + }; + event.clientX = 50; + event.clientY = 50; + event.preventDefault = vi.fn(); + const stageContainer = wrapper.find('.m-editor-stage-container').element; + stageContainer.dispatchEvent(event); + await new Promise((r) => setTimeout(r, 10)); + expect(canDropIn).toHaveBeenCalled(); + expect(editorService.add).not.toHaveBeenCalled(); + }); + + test('dragover 无 dataTransfer 时直接 return', async () => { + const wrapper = mountIt(); + await nextTick(); + const event: any = new Event('dragover'); + event.dataTransfer = null; + event.preventDefault = vi.fn(); + const stageContainer = wrapper.find('.m-editor-stage-container').element; + stageContainer.dispatchEvent(event); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + test('select 事件触发 container.focus', async () => { + mountIt(); + await nextTick(); + expect(stageHandlers.select).toBeDefined(); + stageHandlers.select?.[0]?.(); + }); + + test('runtime-ready 事件触发', async () => { + mountIt(); + await nextTick(); + expect(stageHandlers['runtime-ready']).toBeDefined(); + stageHandlers['runtime-ready']?.[0]?.({ updatePageId: vi.fn() }); + }); + + test('dblclick 调用 stageOverlayService.openOverlay 当元素被裁剪', async () => { + const win = { + getComputedStyle: () => ({ overflowX: 'auto', overflowY: 'visible' }), + }; + const parent = document.createElement('div'); + Object.defineProperty(parent, 'ownerDocument', { + value: { defaultView: win, documentElement: document.documentElement }, + configurable: true, + }); + Object.defineProperty(parent, 'getBoundingClientRect', { + value: () => ({ top: 100, left: 100, bottom: 200, right: 200 }), + configurable: true, + }); + Object.defineProperty(parent, 'scrollWidth', { value: 100, configurable: true }); + Object.defineProperty(parent, 'clientWidth', { value: 100, configurable: true }); + Object.defineProperty(parent, 'scrollHeight', { value: 100, configurable: true }); + Object.defineProperty(parent, 'clientHeight', { value: 100, configurable: true }); + const el: any = document.createElement('div'); + el.dataset.id = 'compInside'; + Object.defineProperty(el, 'ownerDocument', { + value: { defaultView: win, documentElement: document.documentElement }, + configurable: true, + }); + Object.defineProperty(el, 'getBoundingClientRect', { + value: () => ({ top: 50, left: 100, bottom: 150, right: 150 }), + configurable: true, + }); + Object.defineProperty(el, 'parentElement', { value: parent, configurable: true }); + stageInstance.actionManager.getElementFromPoint.mockResolvedValueOnce(el); + editorService.getNodeById.mockReturnValue(null); + mountIt({ disabledStageOverlay: false }); + await nextTick(); + expect(stageHandlers.dblclick).toBeDefined(); + await stageHandlers.dblclick[0]({ clientX: 0, clientY: 0 } as any); + expect(stageOverlayService.openOverlay).toHaveBeenCalled(); + }); + + test('dblclick beforeDblclick 返回 false 时不处理', async () => { + const beforeDblclick = vi.fn(async () => false); + mountIt({ + stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'h', beforeDblclick }, + }); + await nextTick(); + expect(stageHandlers.dblclick).toBeDefined(); + await stageHandlers.dblclick[0]({ clientX: 0, clientY: 0 } as any); + expect(beforeDblclick).toHaveBeenCalled(); + expect(stageOverlayService.openOverlay).not.toHaveBeenCalled(); + }); + + test('dblclick 元素是页面片容器时调用 select 选择 pageFragmentId', async () => { + const el: any = document.createElement('div'); + el.dataset.id = 'pf-comp'; + stageInstance.actionManager.getElementFromPoint.mockResolvedValueOnce(el); + editorService.getNodeById.mockReturnValue({ type: 'page-fragment-container', pageFragmentId: 'pf1' }); + mountIt(); + await nextTick(); + await stageHandlers.dblclick[0]({ clientX: 0, clientY: 0 } as any); + expect(editorService.select).toHaveBeenCalledWith('pf1'); + }); + + test('dblclick 没有元素时不处理', async () => { + stageInstance.actionManager.getElementFromPoint.mockResolvedValueOnce(null); + mountIt(); + await nextTick(); + await stageHandlers.dblclick[0]({ clientX: 0, clientY: 0 } as any); + expect(stageOverlayService.openOverlay).not.toHaveBeenCalled(); + }); + + test('drop canDropIn 返回 string 重定向到目标节点', async () => { + editorService.getNodeById.mockImplementation((id: string) => + id === 'redirect' ? { id: 'redirect', items: [] } : null, + ); + editorService.getLayout.mockResolvedValue('relative'); + const canDropIn = vi.fn(() => 'redirect'); + stageInstance.renderer.getTargetElement.mockReturnValue(document.createElement('div')); + const wrapper = mountIt({ + stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'h', canDropIn }, + }); + await nextTick(); + const event: any = new Event('drop'); + event.dataTransfer = { + getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'), + }; + event.clientX = 1; + event.clientY = 1; + event.preventDefault = vi.fn(); + wrapper.find('.m-editor-stage-container').element.dispatchEvent(event); + await new Promise((r) => setTimeout(r, 10)); + expect(canDropIn).toHaveBeenCalled(); + expect(editorService.add).toHaveBeenCalled(); + }); +}); diff --git a/packages/editor/tests/unit/layouts/workspace/viewer/StageOverlay.spec.ts b/packages/editor/tests/unit/layouts/workspace/viewer/StageOverlay.spec.ts new file mode 100644 index 00000000..35d0daf2 --- /dev/null +++ b/packages/editor/tests/unit/layouts/workspace/viewer/StageOverlay.spec.ts @@ -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, nextTick, ref } from 'vue'; +import { mount } from '@vue/test-utils'; + +import StageOverlay from '@editor/layouts/workspace/viewer/StageOverlay.vue'; + +const stageOverlayState = { + stageOverlayVisible: ref(true), + wrapWidth: ref(300), + wrapHeight: ref(200), + stage: null as any, +}; + +const stageOverlayService = { + get: vi.fn((k: string) => (stageOverlayState as any)[k]?.value ?? (stageOverlayState as any)[k]), + set: vi.fn((k: string, v: any) => { + if ((stageOverlayState as any)[k] && 'value' in (stageOverlayState as any)[k]) { + (stageOverlayState as any)[k].value = v; + } else { + (stageOverlayState as any)[k] = v; + } + }), + closeOverlay: vi.fn(), + createStage: vi.fn(), + updateOverlay: vi.fn(), +}; + +const editorState = { + stage: ref({ id: 's1' }), +}; +const editorService = { + get: vi.fn((k: string) => (editorState as any)[k]?.value ?? null), +}; + +const uiState = { + zoom: ref(1), + columnWidth: ref({ left: 100, center: 800, right: 200 }), + frameworkRect: ref({ height: 600 }), +}; +const uiService = { + get: vi.fn((k: string) => (uiState as any)[k]?.value), +}; + +vi.mock('@editor/hooks/use-services', () => ({ + useServices: () => ({ stageOverlayService, editorService, uiService }), +})); + +vi.mock('@editor/components/ScrollViewer.vue', () => ({ + default: defineComponent({ + name: 'FakeScrollViewer', + props: ['width', 'height', 'wrapWidth', 'wrapHeight', 'zoom'], + setup(_p, { slots }) { + return () => h('div', { class: 'fake-scroll-viewer' }, slots.default?.()); + }, + }), +})); + +vi.mock('@tmagic/design', () => ({ + TMagicIcon: defineComponent({ + name: 'FakeIcon', + setup(_p, { slots, attrs }) { + return () => h('i', { ...attrs, class: 'fake-icon' }, slots.default?.()); + }, + }), +})); + +beforeEach(() => { + vi.clearAllMocks(); + stageOverlayState.stageOverlayVisible.value = true; + stageOverlayState.wrapWidth.value = 300; + stageOverlayState.wrapHeight.value = 200; + stageOverlayState.stage = null; + editorState.stage.value = { id: 's1' }; + uiState.zoom.value = 1; +}); + +describe('StageOverlay.vue', () => { + test('stageOverlayVisible 为 true 时渲染', () => { + const wrapper = mount(StageOverlay, { + global: { + provide: { stageOptions: { runtimeUrl: '' } }, + }, + }); + expect(wrapper.find('.m-editor-stage-overlay').exists()).toBe(true); + }); + + test('点击关闭按钮调用 closeOverlay', async () => { + const wrapper = mount(StageOverlay, { + global: { provide: { stageOptions: {} } }, + }); + await wrapper.find('.fake-icon').trigger('click'); + expect(stageOverlayService.closeOverlay).toHaveBeenCalled(); + }); + + test('stage 变为 null 时调用 closeOverlay', async () => { + const wrapper = mount(StageOverlay, { + global: { provide: { stageOptions: {} } }, + }); + editorState.stage.value = null; + await nextTick(); + void wrapper; + expect(stageOverlayService.closeOverlay).toHaveBeenCalled(); + }); + + test('zoom 变化时调用 stage.setZoom', async () => { + const setZoom = vi.fn(); + stageOverlayService.createStage.mockReturnValue({ + setZoom, + mount: vi.fn(), + destroy: vi.fn(), + mask: { showRule: vi.fn() }, + renderer: { contentWindow: { magic: { onRuntimeReady: vi.fn() } } }, + }); + mount(StageOverlay, { global: { provide: { stageOptions: {} } }, attachTo: document.body }); + await nextTick(); + uiState.zoom.value = 2; + await nextTick(); + expect(setZoom).toHaveBeenCalledWith(2); + }); + + test('zoom 为 0 不调用 setZoom', async () => { + const setZoom = vi.fn(); + stageOverlayService.createStage.mockReturnValue({ + setZoom, + mount: vi.fn(), + destroy: vi.fn(), + mask: { showRule: vi.fn() }, + renderer: { contentWindow: { magic: { onRuntimeReady: vi.fn() } } }, + }); + mount(StageOverlay, { global: { provide: { stageOptions: {} } }, attachTo: document.body }); + await nextTick(); + uiState.zoom.value = 0; + await nextTick(); + expect(setZoom).not.toHaveBeenCalled(); + }); + + test('stageOverlay ref 触发 createStage 与 mount', async () => { + const onRuntimeReady = vi.fn(); + const subStage = { + mount: vi.fn(), + destroy: vi.fn(), + mask: { showRule: vi.fn() }, + renderer: { contentWindow: { magic: { onRuntimeReady } } }, + }; + stageOverlayService.createStage.mockReturnValue(subStage); + mount(StageOverlay, { global: { provide: { stageOptions: { runtimeUrl: 'a' } } }, attachTo: document.body }); + await nextTick(); + expect(stageOverlayService.createStage).toHaveBeenCalled(); + expect(subStage.mount).toHaveBeenCalled(); + expect(subStage.mask.showRule).toHaveBeenCalledWith(false); + expect(stageOverlayService.updateOverlay).toHaveBeenCalled(); + expect(onRuntimeReady).toHaveBeenCalled(); + }); + + test('卸载时销毁 stage', async () => { + const destroy = vi.fn(); + stageOverlayState.stage = { destroy }; + const wrapper = mount(StageOverlay, { global: { provide: { stageOptions: {} } } }); + wrapper.unmount(); + expect(destroy).toHaveBeenCalled(); + expect(stageOverlayService.set).toHaveBeenCalledWith('stage', null); + }); +}); diff --git a/packages/editor/tests/unit/layouts/workspace/viewer/ViewerMenu.spec.ts b/packages/editor/tests/unit/layouts/workspace/viewer/ViewerMenu.spec.ts new file mode 100644 index 00000000..b3f5b8c6 --- /dev/null +++ b/packages/editor/tests/unit/layouts/workspace/viewer/ViewerMenu.spec.ts @@ -0,0 +1,189 @@ +/* + * 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 ViewerMenu from '@editor/layouts/workspace/viewer/ViewerMenu.vue'; + +const editorService = { + get: vi.fn(), + alignCenter: vi.fn(), + moveLayer: vi.fn(), + getLayout: vi.fn().mockResolvedValue('absolute'), +}; + +const stage = { + clearGuides: vi.fn(), +}; + +vi.mock('@editor/hooks/use-services', () => ({ + useServices: () => ({ editorService }), +})); + +vi.mock('@tmagic/utils', () => ({ + isPage: (n: any) => n?.type === 'page', + isPageFragment: (n: any) => n?.type === 'page-fragment', +})); + +vi.mock('@editor/utils/content-menu', () => ({ + useCopyMenu: () => ({ type: 'button', text: '复制', handler: vi.fn() }), + usePasteMenu: () => ({ type: 'button', text: '粘贴', handler: vi.fn() }), + useMoveToMenu: () => ({ type: 'button', text: '移动到', handler: vi.fn() }), + useDeleteMenu: () => ({ type: 'button', text: '删除', handler: vi.fn() }), +})); + +vi.mock('@editor/components/ContentMenu.vue', () => ({ + default: defineComponent({ + name: 'FakeContentMenu', + props: ['menuData'], + setup(props, { expose }) { + expose({ show: vi.fn() }); + return () => + h( + 'div', + { class: 'fake-content-menu' }, + (props.menuData as any[]).map((item, i) => + h('span', { class: `menu-item-${i}`, 'data-text': item.text || item.type }, ''), + ), + ); + }, + }), +})); + +beforeEach(() => { + vi.clearAllMocks(); + editorService.get.mockImplementation((k: string) => { + if (k === 'node') return { type: 'div' }; + if (k === 'nodes') return [{ type: 'div' }]; + if (k === 'parent') return { type: 'page' }; + if (k === 'stage') return stage; + return null; + }); +}); + +describe('ViewerMenu.vue', () => { + test('渲染菜单 (含上/下移、置顶/置底)', async () => { + const wrapper = mount(ViewerMenu, { + props: { + stageContentMenu: [], + customContentMenu: ((m: any) => m) as any, + } as any, + }); + await nextTick(); + const menuData = wrapper.findComponent({ name: 'FakeContentMenu' }).props('menuData') as any[]; + const labels = menuData.map((m: any) => m.text || m.type); + expect(labels).toContain('上移一层'); + expect(labels).toContain('下移一层'); + expect(labels).toContain('置顶'); + expect(labels).toContain('置底'); + expect(labels).toContain('水平居中'); + expect(labels).toContain('清空参考线'); + }); + + test('节点为 page 时上下移按钮 display 返回 false', () => { + editorService.get.mockImplementation((k: string) => { + if (k === 'node') return { type: 'page' }; + if (k === 'nodes') return [{ type: 'page' }]; + if (k === 'parent') return null; + return null; + }); + const wrapper = mount(ViewerMenu, { + props: { + stageContentMenu: [], + customContentMenu: ((m: any) => m) as any, + } as any, + }); + const menuData = wrapper.findComponent({ name: 'FakeContentMenu' }).props('menuData') as any[]; + const moveItem = menuData.find((m: any) => m.text === '上移一层'); + expect(moveItem.display()).toBe(false); + }); + + test('alignCenter 处理器调用 editorService.alignCenter', async () => { + const wrapper = mount(ViewerMenu, { + props: { + stageContentMenu: [], + customContentMenu: ((m: any) => m) as any, + } as any, + }); + await nextTick(); + await new Promise((r) => setTimeout(r, 0)); + const menuData = wrapper.findComponent({ name: 'FakeContentMenu' }).props('menuData') as any[]; + const center = menuData.find((m: any) => m.text === '水平居中'); + center.handler(); + expect(editorService.alignCenter).toHaveBeenCalled(); + }); + + test('moveLayer 处理器调用 editorService.moveLayer', () => { + const wrapper = mount(ViewerMenu, { + props: { + stageContentMenu: [], + customContentMenu: ((m: any) => m) as any, + } as any, + }); + const menuData = wrapper.findComponent({ name: 'FakeContentMenu' }).props('menuData') as any[]; + menuData.find((m: any) => m.text === '上移一层').handler(); + expect(editorService.moveLayer).toHaveBeenCalledWith(1); + menuData.find((m: any) => m.text === '下移一层').handler(); + expect(editorService.moveLayer).toHaveBeenCalledWith(-1); + menuData.find((m: any) => m.text === '置顶').handler(); + menuData.find((m: any) => m.text === '置底').handler(); + expect(editorService.moveLayer).toHaveBeenCalledTimes(4); + }); + + test('清空参考线 调用 stage.clearGuides', () => { + const wrapper = mount(ViewerMenu, { + props: { + stageContentMenu: [], + customContentMenu: ((m: any) => m) as any, + } as any, + }); + const menuData = wrapper.findComponent({ name: 'FakeContentMenu' }).props('menuData') as any[]; + menuData.find((m: any) => m.text === '清空参考线').handler(); + expect(stage.clearGuides).toHaveBeenCalled(); + }); + + test('parent 为 null 时不可居中', async () => { + editorService.get.mockImplementation((k: string) => { + if (k === 'parent') return null; + if (k === 'nodes') return [{ type: 'div' }]; + return null; + }); + const wrapper = mount(ViewerMenu, { + props: { + stageContentMenu: [], + customContentMenu: ((m: any) => m) as any, + } as any, + }); + await nextTick(); + const menuData = wrapper.findComponent({ name: 'FakeContentMenu' }).props('menuData') as any[]; + const center = menuData.find((m: any) => m.text === '水平居中'); + expect(center.display()).toBe(false); + }); + + test('isMultiSelect 时上下移按钮隐藏', () => { + const wrapper = mount(ViewerMenu, { + props: { + isMultiSelect: true, + stageContentMenu: [], + customContentMenu: ((m: any) => m) as any, + } as any, + }); + const menuData = wrapper.findComponent({ name: 'FakeContentMenu' }).props('menuData') as any[]; + expect(menuData.find((m: any) => m.text === '上移一层').display()).toBe(false); + }); + + test('expose show 方法调用 menuRef.show', () => { + const wrapper = mount(ViewerMenu, { + props: { + stageContentMenu: [], + customContentMenu: ((m: any) => m) as any, + } as any, + }); + expect(typeof (wrapper.vm as any).show).toBe('function'); + (wrapper.vm as any).show(new MouseEvent('contextmenu')); + }); +}); diff --git a/packages/editor/tests/unit/plugin.spec.ts b/packages/editor/tests/unit/plugin.spec.ts new file mode 100644 index 00000000..375ee5fd --- /dev/null +++ b/packages/editor/tests/unit/plugin.spec.ts @@ -0,0 +1,87 @@ +/* + * 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 editorPlugin from '@editor/plugin'; + +vi.mock('@tmagic/design', () => ({ + default: { install: vi.fn() }, +})); +vi.mock('@tmagic/form', () => ({ + default: { install: vi.fn() }, +})); +vi.mock('@tmagic/table', () => ({ + default: { install: vi.fn() }, +})); + +vi.mock('@editor/Editor.vue', () => ({ + default: { name: 'MEditor', render: () => null }, +})); + +vi.mock('@editor/fields/Code.vue', () => ({ default: { name: 'Code', render: () => null } })); +vi.mock('@editor/fields/CodeLink.vue', () => ({ default: { name: 'CodeLink', render: () => null } })); +vi.mock('@editor/fields/CodeSelect.vue', () => ({ default: { name: 'CodeSelect', render: () => null } })); +vi.mock('@editor/fields/CodeSelectCol.vue', () => ({ default: { name: 'CodeSelectCol', render: () => null } })); +vi.mock('@editor/fields/CondOpSelect.vue', () => ({ default: { name: 'CondOpSelect', render: () => null } })); +vi.mock('@editor/fields/DataSourceFields.vue', () => ({ + default: { name: 'DataSourceFields', render: () => null }, +})); +vi.mock('@editor/fields/DataSourceFieldSelect/Index.vue', () => ({ + default: { name: 'DataSourceFieldSelect', render: () => null }, +})); +vi.mock('@editor/fields/DataSourceInput.vue', () => ({ default: { name: 'DataSourceInput', render: () => null } })); +vi.mock('@editor/fields/DataSourceMethods.vue', () => ({ + default: { name: 'DataSourceMethods', render: () => null }, +})); +vi.mock('@editor/fields/DataSourceMethodSelect.vue', () => ({ + default: { name: 'DataSourceMethodSelect', render: () => null }, +})); +vi.mock('@editor/fields/DataSourceMocks.vue', () => ({ default: { name: 'DataSourceMocks', render: () => null } })); +vi.mock('@editor/fields/DataSourceSelect.vue', () => ({ default: { name: 'DataSourceSelect', render: () => null } })); +vi.mock('@editor/fields/DisplayConds.vue', () => ({ default: { name: 'DisplayConds', render: () => null } })); +vi.mock('@editor/fields/EventSelect.vue', () => ({ default: { name: 'EventSelect', render: () => null } })); +vi.mock('@editor/fields/KeyValue.vue', () => ({ default: { name: 'KeyValue', render: () => null } })); +vi.mock('@editor/fields/PageFragmentSelect.vue', () => ({ + default: { name: 'PageFragmentSelect', render: () => null }, +})); +vi.mock('@editor/fields/StyleSetter/Index.vue', () => ({ default: { name: 'StyleSetter', render: () => null } })); +vi.mock('@editor/fields/UISelect.vue', () => ({ default: { name: 'UISelect', render: () => null } })); +vi.mock('@editor/layouts/CodeEditor.vue', () => ({ default: { name: 'CodeEditor', render: () => null } })); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('plugin install', () => { + const buildApp = () => { + const components: Record = {}; + return { + app: { + component: vi.fn((name: string, comp: any) => { + components[name] = comp; + }), + use: vi.fn(), + config: { globalProperties: {} }, + } as any, + components, + }; + }; + + test('install 调用 design/form/table 插件并注册全局组件', () => { + const { app, components } = buildApp(); + editorPlugin.install(app, { someOption: true } as any); + expect(app.use).toHaveBeenCalledTimes(3); + expect(Object.keys(components).length).toBeGreaterThan(10); + expect(components.MEditor).toBeDefined(); + }); + + test('install 不传 opt 时使用默认配置', () => { + const { app } = buildApp(); + editorPlugin.install(app); + expect(app.config.globalProperties.$TMAGIC_EDITOR).toBeDefined(); + expect(typeof app.config.globalProperties.$TMAGIC_EDITOR.parseDSL).toBe('function'); + }); +}); diff --git a/packages/editor/tests/unit/services/BaseService.spec.ts b/packages/editor/tests/unit/services/BaseService.spec.ts new file mode 100644 index 00000000..bc8691c2 --- /dev/null +++ b/packages/editor/tests/unit/services/BaseService.spec.ts @@ -0,0 +1,116 @@ +/* + * 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 BaseService from '@editor/services/BaseService'; + +class SyncService extends BaseService { + public count = 0; + constructor() { + super([{ name: 'add', isAsync: false }]); + } + public add(value: number): number { + this.count += value; + return this.count; + } +} + +class AsyncService extends BaseService { + public count = 0; + constructor(serial = false) { + super([{ name: 'add', isAsync: true }], serial ? ['add'] : []); + } + public async add(value: number): Promise { + await Promise.resolve(); + this.count += value; + return this.count; + } +} + +describe('BaseService 同步方法 + plugin', () => { + test('beforeAdd / afterAdd 正常被链式调用', () => { + const svc = new SyncService(); + const before = vi.fn((v: number) => [v + 1]); + const after = vi.fn((result: number) => result * 10); + svc.usePlugin({ beforeAdd: before, afterAdd: after }); + + const result = svc.add(2); + expect(before).toHaveBeenCalledWith(2); + expect(after).toHaveBeenCalled(); + expect(result).toBe(30); + }); + + test('use 添加同步 middleware', () => { + const svc = new SyncService(); + const order: string[] = []; + svc.use({ + add(_value: number, next: Function) { + order.push('mw-before'); + next(); + order.push('mw-after'); + }, + }); + svc.add(1); + expect(order).toEqual(['mw-before', 'mw-after']); + }); + + test('removePlugin 解除指定钩子', () => { + const svc = new SyncService(); + const before = vi.fn((v: number) => [v]); + svc.usePlugin({ beforeAdd: before }); + svc.removePlugin({ beforeAdd: before }); + svc.add(1); + expect(before).not.toHaveBeenCalled(); + }); + + test('removeAllPlugins 清空所有 plugin/middleware', () => { + const svc = new SyncService(); + const before = vi.fn((v: number) => [v]); + svc.usePlugin({ beforeAdd: before }); + svc.removeAllPlugins(); + svc.add(1); + expect(before).not.toHaveBeenCalled(); + }); + + test('before 返回 Error 抛错终止', () => { + const svc = new SyncService(); + svc.usePlugin({ + beforeAdd: () => new Error('stop'), + }); + expect(() => svc.add(1)).toThrow('stop'); + }); +}); + +describe('BaseService 异步方法', () => { + test('beforeAdd / afterAdd 异步链路', async () => { + const svc = new AsyncService(); + svc.usePlugin({ + beforeAdd: async (v: number) => [v + 1], + afterAdd: async (r: number) => r + 100, + }); + const result = await svc.add(1); + expect(result).toBe(102); + }); + + test('serial 模式按顺序执行', async () => { + const svc = new AsyncService(true); + const results = await Promise.all([svc.add(1), svc.add(2), svc.add(3)]); + expect(results).toEqual([1, 3, 6]); + }); + + test('before 返回非数组时被包装为数组', async () => { + const svc = new AsyncService(); + svc.usePlugin({ beforeAdd: async (v: number) => v + 5 }); + const result = await svc.add(1); + expect(result).toBe(6); + }); + + test('after 返回 Error 抛错', async () => { + const svc = new AsyncService(); + svc.usePlugin({ afterAdd: async () => new Error('after-bad') }); + await expect(svc.add(1)).rejects.toThrow('after-bad'); + }); +}); diff --git a/packages/editor/tests/unit/services/codeBlock.spec.ts b/packages/editor/tests/unit/services/codeBlock.spec.ts new file mode 100644 index 00000000..51b133a6 --- /dev/null +++ b/packages/editor/tests/unit/services/codeBlock.spec.ts @@ -0,0 +1,168 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; + +import codeBlockService from '@editor/services/codeBlock'; +import storageService, { Protocol } from '@editor/services/storage'; +import { CODE_DRAFT_STORAGE_KEY } from '@editor/type'; +import { setEditorConfig } from '@editor/utils/config'; +import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor'; + +vi.mock('@editor/services/editor', () => ({ + default: { + getNodeById: vi.fn((id: string) => ({ id, code: 'code1' })), + }, +})); + +beforeAll(() => { + setEditorConfig({ + // eslint-disable-next-line no-new-func + parseDSL: ((str: string) => new Function(`return ${str}`)()) as any, + } as any); +}); + +beforeEach(() => { + codeBlockService.resetState(); + globalThis.localStorage.clear(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('CodeBlockService - 基础', () => { + test('setCodeDsl / getCodeDsl 保存并触发事件', async () => { + const fn = vi.fn(); + codeBlockService.on('code-dsl-change', fn); + await codeBlockService.setCodeDsl({ a: { name: 'a', content: 'x' } } as any); + expect(codeBlockService.getCodeDsl()).toEqual({ a: { name: 'a', content: 'x' } }); + expect(fn).toHaveBeenCalled(); + codeBlockService.off('code-dsl-change', fn); + }); + + test('getCodeContentById - 没有 dsl 或 id 返回 null', () => { + expect(codeBlockService.getCodeContentById('')).toBeNull(); + expect(codeBlockService.getCodeContentById('any')).toBeNull(); + }); + + test('getCodeContentById - 取得现有内容', async () => { + await codeBlockService.setCodeDsl({ x: { name: 'X' } } as any); + expect(codeBlockService.getCodeContentById('x')).toEqual({ name: 'X' }); + expect(codeBlockService.getCodeContentById('y')).toBeNull(); + }); + + test('setCodeDslByIdSync - 没有 dsl 时抛错', () => { + expect(() => codeBlockService.setCodeDslByIdSync('id', {} as any)).toThrow('dsl中没有codeBlocks'); + }); + + test('setCodeDslByIdSync - 已存在且 force=false 时跳过', async () => { + await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any); + codeBlockService.setCodeDslByIdSync('a', { name: 'NEW' } as any, false); + expect(codeBlockService.getCodeContentById('a')?.name).toBe('A'); + }); + + test('setCodeDslByIdSync - content 为字符串时被 parseDSL 转换', async () => { + await codeBlockService.setCodeDsl({} as any); + codeBlockService.setCodeDslByIdSync('a', { name: 'A', content: '() => 42' } as any); + const item = codeBlockService.getCodeContentById('a') as any; + expect(typeof item.content).toBe('function'); + expect(item.content()).toBe(42); + }); + + test('setCodeDslByIdSync - 触发 addOrUpdate 事件', async () => { + await codeBlockService.setCodeDsl({} as any); + const fn = vi.fn(); + codeBlockService.on('addOrUpdate', fn); + codeBlockService.setCodeDslByIdSync('id', { name: 'a' } as any); + expect(fn).toHaveBeenCalledWith('id', expect.any(Object)); + codeBlockService.off('addOrUpdate', fn); + }); + + test('getCodeDslByIds 仅返回指定 ids', async () => { + await codeBlockService.setCodeDsl({ a: { name: 'a' }, b: { name: 'b' } } as any); + expect(codeBlockService.getCodeDslByIds(['a'])).toEqual({ a: { name: 'a' } }); + }); + + test('编辑状态 / combineIds / undeletableList', async () => { + expect(codeBlockService.getEditStatus()).toBe(true); + await codeBlockService.setEditStatus(false); + expect(codeBlockService.getEditStatus()).toBe(false); + + await codeBlockService.setCombineIds(['a', 'b']); + expect(codeBlockService.getCombineIds()).toEqual(['a', 'b']); + + await codeBlockService.setUndeleteableList(['x']); + expect(codeBlockService.getUndeletableList()).toEqual(['x']); + }); + + test('代码草稿 set/get/remove', () => { + codeBlockService.setCodeDraft('id', 'draft'); + expect(globalThis.localStorage.getItem(`${CODE_DRAFT_STORAGE_KEY}_id`)).toBe('draft'); + expect(codeBlockService.getCodeDraft('id')).toBe('draft'); + codeBlockService.removeCodeDraft('id'); + expect(codeBlockService.getCodeDraft('id')).toBeNull(); + }); + + test('deleteCodeDslByIds 删除并触发 remove 事件', async () => { + await codeBlockService.setCodeDsl({ a: { name: 'a' }, b: { name: 'b' } } as any); + const fn = vi.fn(); + codeBlockService.on('remove', fn); + await codeBlockService.deleteCodeDslByIds(['a', 'b']); + expect(codeBlockService.getCodeDsl()).toEqual({}); + expect(fn).toHaveBeenCalledTimes(2); + codeBlockService.off('remove', fn); + }); + + test('deleteCodeDslByIds - dsl 为空时直接返回', async () => { + await expect(codeBlockService.deleteCodeDslByIds(['x'])).resolves.toBeUndefined(); + }); + + test('paramsColConfig set/get', () => { + const cfg = { type: 'row', items: [] } as any; + codeBlockService.setParamsColConfig(cfg); + expect(codeBlockService.getParamsColConfig()).toEqual(cfg); + }); + + test('getUniqueId 在重复时再次取', async () => { + let count = 0; + const random = vi.spyOn(Math, 'random').mockImplementation(() => { + count += 1; + return count === 1 ? 0.111111111 : 0.222222222; + }); + await codeBlockService.setCodeDsl({ code_1111: { name: 'x' } } as any); + const id = await codeBlockService.getUniqueId(); + expect(id.startsWith('code_')).toBe(true); + expect(id).not.toBe('code_1111'); + random.mockRestore(); + }); + + test('paste - 不覆盖现有 id', async () => { + await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any); + storageService.setItem( + COPY_CODE_STORAGE_KEY, + { a: { name: 'OTHER' }, b: { name: 'B' } }, + { protocol: Protocol.OBJECT }, + ); + + codeBlockService.paste(); + expect(codeBlockService.getCodeContentById('a')?.name).toBe('A'); + expect(codeBlockService.getCodeContentById('b')?.name).toBe('B'); + }); + + test('copyWithRelated - 没有 collectorOptions 时仅写空对象', () => { + codeBlockService.copyWithRelated([]); + const stored = storageService.getItem(COPY_CODE_STORAGE_KEY, { protocol: Protocol.OBJECT }); + expect(stored).toEqual({}); + }); + + test('destroy 清空 listeners 与 plugin', () => { + const fn = vi.fn(); + codeBlockService.on('addOrUpdate', fn); + codeBlockService.destroy(); + codeBlockService.emit('addOrUpdate', 'a'); + expect(fn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/editor/tests/unit/services/componentList.spec.ts b/packages/editor/tests/unit/services/componentList.spec.ts new file mode 100644 index 00000000..f0c376df --- /dev/null +++ b/packages/editor/tests/unit/services/componentList.spec.ts @@ -0,0 +1,37 @@ +/* + * 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 componentList from '@editor/services/componentList'; + +afterEach(() => { + componentList.resetState(); + vi.clearAllMocks(); +}); + +describe('ComponentList service', () => { + test('setList / getList 写入与读取', () => { + const list = [{ title: 'a', items: [] }] as any; + componentList.setList(list); + expect(componentList.getList()).toBe(list); + }); + + test('resetState 清空 list', () => { + componentList.setList([{ title: 'b', items: [] }] as any); + componentList.resetState(); + expect(componentList.getList()).toEqual([]); + }); + + test('destroy 清空状态与监听', () => { + const fn = vi.fn(); + componentList.on('test', fn); + componentList.setList([{ title: 'c', items: [] }] as any); + componentList.destroy(); + expect(componentList.getList()).toEqual([]); + componentList.emit('test'); + expect(fn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/editor/tests/unit/services/dataSource.spec.ts b/packages/editor/tests/unit/services/dataSource.spec.ts new file mode 100644 index 00000000..71ff3fc8 --- /dev/null +++ b/packages/editor/tests/unit/services/dataSource.spec.ts @@ -0,0 +1,129 @@ +/* + * 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 dataSource from '@editor/services/dataSource'; +import storageService, { Protocol } from '@editor/services/storage'; +import { setEditorConfig } from '@editor/utils/config'; +import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor'; + +vi.mock('@editor/services/editor', () => ({ + default: { + getNodeById: vi.fn((id: string) => ({ id, dsBinding: ['ds1'] })), + }, +})); + +setEditorConfig({ + // eslint-disable-next-line no-new-func + parseDSL: ((str: string) => new Function(`return ${str}`)()) as any, +} as any); + +beforeEach(() => { + dataSource.resetState(); + dataSource.set('configs', {}); + dataSource.set('values', {}); + dataSource.set('events', {}); + dataSource.set('methods', {}); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('DataSource service', () => { + test('setFormConfig / getFormConfig 取自 configs', () => { + const config = [{ type: 'text' }] as any; + dataSource.setFormConfig('http', config); + const result = dataSource.getFormConfig('http'); + expect(Array.isArray(result)).toBe(true); + }); + + test('setFormValue / getFormValue', () => { + dataSource.setFormValue('http', { id: 'x', title: 'T' } as any); + const v = dataSource.getFormValue('http'); + expect(v).toBeDefined(); + }); + + test('setFormEvent / getFormEvent 默认空数组', () => { + expect(dataSource.getFormEvent('http')).toEqual([]); + dataSource.setFormEvent('http', [{ name: 'click' }] as any); + expect(dataSource.getFormEvent('http')).toHaveLength(1); + }); + + test('setFormMethod / getFormMethod 默认空数组', () => { + expect(dataSource.getFormMethod('http')).toEqual([]); + dataSource.setFormMethod('http', [{ name: 'send' }] as any); + expect(dataSource.getFormMethod('http')).toHaveLength(1); + }); + + test('add - 没有 id 时自动生成', () => { + const fn = vi.fn(); + dataSource.on('add', fn); + const ds = dataSource.add({ title: 'a', type: 'base' } as any); + expect(ds.id?.startsWith('ds_')).toBe(true); + expect(fn).toHaveBeenCalled(); + dataSource.off('add', fn); + }); + + test('add - 已有 id 重复时重新生成', () => { + dataSource.add({ id: 'x', title: 'a', type: 'base' } as any); + const ds = dataSource.add({ id: 'x', title: 'a2', type: 'base' } as any); + expect(ds.id).not.toBe('x'); + }); + + test('update - 修改已有数据源并触发事件', () => { + const fn = vi.fn(); + const created = dataSource.add({ title: 'a', type: 'base' } as any); + dataSource.on('update', fn); + const newConfig = { ...created, title: 'b' } as any; + dataSource.update(newConfig); + expect(dataSource.getDataSourceById(created.id!)?.title).toBe('b'); + expect(fn).toHaveBeenCalled(); + dataSource.off('update', fn); + }); + + test('remove - 触发 remove 事件', () => { + const fn = vi.fn(); + const created = dataSource.add({ title: 'a', type: 'base' } as any); + dataSource.on('remove', fn); + dataSource.remove(created.id!); + expect(dataSource.getDataSourceById(created.id!)).toBeUndefined(); + expect(fn).toHaveBeenCalledWith(created.id); + dataSource.off('remove', fn); + }); + + test('createId 以 ds_ 开头', () => { + expect(dataSource.createId().startsWith('ds_')).toBe(true); + }); + + test('paste - 不覆盖现有数据源', () => { + dataSource.add({ id: 'a', title: 'A', type: 'base' } as any); + storageService.setItem( + COPY_DS_STORAGE_KEY, + [ + { id: 'a', title: 'A2', type: 'base' }, + { id: 'b', title: 'B', type: 'base' }, + ], + { protocol: Protocol.OBJECT }, + ); + dataSource.paste(); + expect(dataSource.getDataSourceById('a')?.title).toBe('A'); + expect(dataSource.getDataSourceById('b')?.title).toBe('B'); + }); + + test('copyWithRelated - 没有 collectorOptions 时只写空数组', () => { + dataSource.copyWithRelated([]); + expect(storageService.getItem(COPY_DS_STORAGE_KEY)).toEqual([]); + }); + + test('destroy 清空 listeners', () => { + const fn = vi.fn(); + dataSource.on('add', fn); + dataSource.destroy(); + dataSource.emit('add', {}); + expect(fn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/editor/tests/unit/services/dep.spec.ts b/packages/editor/tests/unit/services/dep.spec.ts new file mode 100644 index 00000000..a782a8f4 --- /dev/null +++ b/packages/editor/tests/unit/services/dep.spec.ts @@ -0,0 +1,151 @@ +/* + * 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 { DepTargetType, Target } from '@tmagic/core'; + +import depService from '@editor/services/dep'; + +vi.mock('@editor/utils/dep/worker.ts?worker&inline', () => ({ + default: class FakeWorker { + public onmessage: ((e: any) => void) | null = null; + public onerror: (() => void) | null = null; + public postMessage() { + setTimeout(() => { + this.onmessage?.({ data: {} }); + }, 0); + } + }, +})); + +const makeTarget = (id = 't1', type: string = DepTargetType.DEFAULT) => + new Target({ + id, + type, + isTarget: () => false, + }); + +beforeEach(() => { + depService.reset(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +// Promise.withResolvers polyfill for older Node +if (typeof (Promise as any).withResolvers !== 'function') { + (Promise as any).withResolvers = function withResolvers() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + }; +} + +describe('Dep service', () => { + test('addTarget / getTarget / removeTarget', () => { + const t = makeTarget('t1'); + depService.addTarget(t); + expect(depService.getTarget('t1')).toBeDefined(); + expect(depService.hasTarget('t1')).toBe(true); + depService.removeTarget('t1'); + expect(depService.getTarget('t1')).toBeUndefined(); + }); + + test('addTarget 触发 add-target 事件', () => { + const fn = vi.fn(); + depService.on('add-target', fn); + const t = makeTarget('t2'); + depService.addTarget(t); + expect(fn).toHaveBeenCalledWith(t); + depService.off('add-target', fn); + }); + + test('removeTarget 触发 remove-target 事件', () => { + const fn = vi.fn(); + const t = makeTarget('t3'); + depService.addTarget(t); + depService.on('remove-target', fn); + depService.removeTarget('t3'); + expect(fn).toHaveBeenCalledWith('t3', DepTargetType.DEFAULT); + depService.off('remove-target', fn); + }); + + test('removeTargets 不抛错并清空目标', () => { + depService.addTarget(makeTarget('a')); + depService.addTarget(makeTarget('b')); + expect(() => depService.removeTargets()).not.toThrow(); + expect(depService.getTarget('a')).toBeUndefined(); + }); + + test('removeTargets - 不存在的 type 直接返回', () => { + expect(() => depService.removeTargets('not-existing')).not.toThrow(); + }); + + test('hasSpecifiedTypeTarget / clearTargets', () => { + depService.addTarget(makeTarget('x')); + expect(depService.hasSpecifiedTypeTarget()).toBe(true); + depService.clearTargets(); + expect(depService.hasSpecifiedTypeTarget()).toBe(false); + }); + + test('set / get state', () => { + depService.set('collecting', true); + expect(depService.get('collecting')).toBe(true); + depService.set('taskLength', 5); + expect(depService.get('taskLength')).toBe(5); + }); + + test('collect 调用后触发 collected 事件', () => { + const fn = vi.fn(); + depService.on('collected', fn); + depService.collect([{ id: 'n1', type: 'text' }] as any); + expect(fn).toHaveBeenCalled(); + expect(depService.get('collecting')).toBe(false); + depService.off('collected', fn); + }); + + test('collectIdle - 没有命中时立即 resolve 并 emit collected', async () => { + const fn = vi.fn(); + depService.on('collected', fn); + await depService.collectIdle([{ id: 'n1', type: 'text' }] as any); + expect(fn).toHaveBeenCalled(); + depService.off('collected', fn); + }); + + test('collectByWorker 完成后触发 collected 与 ds-collected', async () => { + const fn = vi.fn(); + const dsFn = vi.fn(); + depService.on('collected', fn); + depService.on('ds-collected', dsFn); + await depService.collectByWorker({ items: [], id: 'app', type: 'app' } as any); + expect(fn).toHaveBeenCalled(); + expect(dsFn).toHaveBeenCalled(); + depService.off('collected', fn); + depService.off('ds-collected', dsFn); + }); + + test('clear 与 clearByType', () => { + expect(() => depService.clear()).not.toThrow(); + expect(() => depService.clearByType(DepTargetType.DEFAULT)).not.toThrow(); + }); + + test('clearIdleTasks 安全调用', () => { + expect(() => depService.clearIdleTasks()).not.toThrow(); + }); + + test('reset 后 collecting=false 且 targets 清空', () => { + depService.addTarget(makeTarget('rs')); + depService.set('collecting', true); + depService.reset(); + expect(depService.get('collecting')).toBe(false); + expect(depService.hasTarget('rs')).toBe(false); + }); +}); diff --git a/packages/editor/tests/unit/services/events.spec.ts b/packages/editor/tests/unit/services/events.spec.ts index 0ce6e546..bb698253 100644 --- a/packages/editor/tests/unit/services/events.spec.ts +++ b/packages/editor/tests/unit/services/events.spec.ts @@ -30,6 +30,33 @@ describe('events', () => { test('setMethod', () => { const method = [{ label: '点击', value: 'magic:common:events:click' }]; events.setMethod('button', method); - expect(events.getMethod('button')).toHaveLength(1); + expect(events.getMethod('button', '')).toHaveLength(1); + }); + + test('setEvents 批量设置', () => { + events.setEvents({ + Image: [{ label: 'click', value: 'click' }], + Text: [{ label: 'init', value: 'init' }], + } as any); + expect(events.getEvent('image')).toHaveLength(1); + expect(events.getEvent('text')).toHaveLength(1); + }); + + test('setMethods 批量设置', () => { + events.setMethods({ + Image: [{ label: 'show', value: 'show' }], + } as any); + expect(events.getMethod('image', '')).toHaveLength(1); + }); + + test('未注册类型返回空数组', () => { + expect(events.getEvent('not-exist')).toEqual([]); + expect(events.getMethod('not-exist', '')).toEqual([]); + }); + + test('resetState 清空所有事件 / 方法', () => { + events.setEvent('foo', [{ label: 'l', value: 'v' }]); + events.resetState(); + expect(events.getEvent('foo')).toEqual([]); }); }); diff --git a/packages/editor/tests/unit/services/history.spec.ts b/packages/editor/tests/unit/services/history.spec.ts new file mode 100644 index 00000000..e184973e --- /dev/null +++ b/packages/editor/tests/unit/services/history.spec.ts @@ -0,0 +1,59 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { afterEach, describe, expect, test } from 'vitest'; + +import history from '@editor/services/history'; + +afterEach(() => { + history.reset(); +}); + +describe('history service', () => { + test('changePage 切换页面会创建对应的 UndoRedo', () => { + history.changePage({ id: 'p1' } as any); + expect((history.state.pageSteps as any).p1).toBeDefined(); + }); + + test('push / undo / redo 链路', () => { + history.changePage({ id: 'p1' } as any); + const v1 = { data: { items: [] }, modifiedNodeIds: new Map(), nodeId: 'a' } as any; + const v2 = { data: { items: [] }, modifiedNodeIds: new Map(), nodeId: 'b' } as any; + history.push(v1); + history.push(v2); + + const undone = history.undo(); + expect(undone).toBeDefined(); + const redone = history.redo(); + expect(redone).toBeDefined(); + }); + + test('未指定 pageId 时 push/undo/redo 返回 null', () => { + history.resetPage(); + expect(history.push({} as any)).toBeNull(); + expect(history.undo()).toBeNull(); + expect(history.redo()).toBeNull(); + }); + + test('reset / resetPage / resetState', () => { + history.changePage({ id: 'p1' } as any); + history.push({ data: {} } as any); + history.reset(); + expect(history.state.pageId).toBeUndefined(); + expect(Object.keys(history.state.pageSteps)).toHaveLength(0); + }); + + test('canUndo / canRedo 在 push 后更新', () => { + history.changePage({ id: 'p1' } as any); + history.push({ data: {} } as any); + history.push({ data: {} } as any); + expect(history.state.canUndo).toBe(true); + }); + + test('changePage 接到 undefined/null 时不变更', () => { + history.changePage(null as any); + expect(history.state.pageId).toBeUndefined(); + }); +}); diff --git a/packages/editor/tests/unit/services/keybinding.spec.ts b/packages/editor/tests/unit/services/keybinding.spec.ts new file mode 100644 index 00000000..77ea373b --- /dev/null +++ b/packages/editor/tests/unit/services/keybinding.spec.ts @@ -0,0 +1,93 @@ +/* + * 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 keybinding from '@editor/services/keybinding'; + +afterEach(() => { + keybinding.reset(); +}); + +describe('keybinding service', () => { + test('registerCommand / unregisterCommand', () => { + const fn = vi.fn(); + keybinding.registerCommand('my-cmd', fn); + expect((keybinding as any).commands['my-cmd']).toBe(fn); + keybinding.unregisterCommand('my-cmd'); + expect((keybinding as any).commands['my-cmd']).toBeUndefined(); + }); + + test('registeCommand / unregisteCommand 兼容别名', () => { + const fn = vi.fn(); + keybinding.registeCommand('my-cmd-2', fn); + expect((keybinding as any).commands['my-cmd-2']).toBe(fn); + keybinding.unregisteCommand('my-cmd-2'); + expect((keybinding as any).commands['my-cmd-2']).toBeUndefined(); + }); + + test('registerEl 不传 el 且 name !== global 时抛错', () => { + expect(() => keybinding.registerEl('foo')).toThrow(/global/); + }); + + test('registerEl global 不需要 el', () => { + expect(() => keybinding.registerEl('global')).not.toThrow(); + }); + + test('register 同一条目去重', () => { + keybinding.register([ + { + command: 'cmd', + keybinding: 'a', + when: [['global', 'keydown']], + } as any, + ]); + const before = (keybinding as any).bindingList.length; + keybinding.register([ + { + command: 'cmd', + keybinding: 'a', + when: [['global', 'keydown']], + } as any, + ]); + const after = (keybinding as any).bindingList.length; + expect(after).toBe(before); + }); + + test('unregisterEl 重置 binding bound 状态并清掉 controller', () => { + keybinding.registerEl('global'); + keybinding.register([ + { + command: 'cmd', + keybinding: ['a', 'b'], + when: [['global', 'keydown']], + } as any, + ]); + keybinding.unregisterEl('global'); + expect((keybinding as any).controllers.has('global')).toBe(false); + (keybinding as any).bindingList.forEach((item: any) => expect(item.bound).toBe(false)); + }); + + test('reset 清空所有 controllers + binding', () => { + keybinding.registerEl('global'); + keybinding.register([{ command: 'cmd', keybinding: 'a', when: [['global', 'keydown']] } as any]); + keybinding.reset(); + expect((keybinding as any).controllers.size).toBe(0); + expect((keybinding as any).bindingList.length).toBe(0); + }); + + test('getKeyconKeys ctrl 在 mac 下被替换为 meta', () => { + const original = (keybinding as any).ctrlKey; + (keybinding as any).ctrlKey = 'meta'; + const result = (keybinding as any).getKeyconKeys('ctrl+c'); + expect(result[0]).toEqual(['meta', 'c']); + (keybinding as any).ctrlKey = original; + }); + + test('getKeyconKeys 数组形式', () => { + const result = (keybinding as any).getKeyconKeys(['a', 'b+c']); + expect(result).toHaveLength(2); + }); +}); diff --git a/packages/editor/tests/unit/services/props.spec.ts b/packages/editor/tests/unit/services/props.spec.ts index e2b8173e..4f4a31e7 100644 --- a/packages/editor/tests/unit/services/props.spec.ts +++ b/packages/editor/tests/unit/services/props.spec.ts @@ -42,3 +42,66 @@ test('getDefaultValue', async () => { const value = await props.getDefaultPropsValue('text'); expect(value.type).toBe('text'); }); + +describe('props service - 配置/值', () => { + test('setPropsConfigs / getPropsConfigs / hasPropsConfig', async () => { + props.setPropsConfigs({ + 'my-comp': [{ name: 'text', type: 'text' } as any], + }); + await new Promise((r) => setTimeout(r, 50)); + expect(props.hasPropsConfig('my-comp')).toBe(true); + const configs = props.getPropsConfigs(); + expect(configs['my-comp']).toBeDefined(); + }); + + test('setPropsValues / getPropsValues / hasPropsValue', async () => { + props.setPropsValues({ + 'my-comp-v': { type: 'my-comp-v', text: 'init' } as any, + }); + await new Promise((r) => setTimeout(r, 30)); + expect(props.hasPropsValue('my-comp-v')).toBe(true); + expect(props.getPropsValues()['my-comp-v']).toBeDefined(); + }); + + test('getDefaultPropsValue 容器类型带 items', async () => { + const value: any = await props.getDefaultPropsValue('page'); + expect(value.items).toEqual([]); + }); + + test('setPropsValue 函数式', async () => { + await props.setPropsValue('fn-comp', () => ({ type: 'fn-comp', text: 'fn' }) as any); + await new Promise((r) => setTimeout(r, 30)); + expect(props.getPropsValues()['fn-comp']).toBeDefined(); + }); + + test('area 走 button 配置', async () => { + props.setPropsValues({ button: { type: 'button', style: { backgroundColor: '#fff' } } as any }); + await new Promise((r) => setTimeout(r, 30)); + const value = (await props.getPropsValue('area')) as any; + expect(value.className).toBe('action-area'); + }); + + test('getPropsConfig area 走 button', async () => { + props.setPropsConfigs({ button: [{ name: 'btn', type: 'text' } as any] }); + await new Promise((r) => setTimeout(r, 30)); + const cfg = await props.getPropsConfig('area'); + expect(Array.isArray(cfg)).toBe(true); + }); + + test('setDisabledDataSource / setDisabledCodeBlock', () => { + props.setDisabledDataSource(true); + expect(props.getDisabledDataSource()).toBe(true); + props.setDisabledDataSource(false); + + props.setDisabledCodeBlock(true); + expect(props.getDisabledCodeBlock()).toBe(true); + props.setDisabledCodeBlock(false); + }); + + test('resetState 清空配置/值', async () => { + props.setPropsValues({ x: { type: 'x' } as any }); + await new Promise((r) => setTimeout(r, 30)); + props.resetState(); + expect(props.hasPropsValue('x')).toBe(false); + }); +}); diff --git a/packages/editor/tests/unit/services/stageOverlay.spec.ts b/packages/editor/tests/unit/services/stageOverlay.spec.ts new file mode 100644 index 00000000..fe33df21 --- /dev/null +++ b/packages/editor/tests/unit/services/stageOverlay.spec.ts @@ -0,0 +1,210 @@ +/* + * 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 stageOverlay from '@editor/services/stageOverlay'; + +const subStage = { + destroy: vi.fn(), + select: vi.fn(), + multiSelect: vi.fn(), + renderer: { + getDocument: vi.fn(() => ({ + body: { children: [] }, + documentElement: document.createElement('html'), + replaceChild: vi.fn(), + })), + contentWindow: { + magic: { onPageElUpdate: vi.fn() }, + }, + }, +}; + +vi.mock('@editor/hooks/use-stage', () => ({ + useStage: vi.fn(() => subStage), +})); + +const editorEvents: Record any)[]> = {}; +vi.mock('@editor/services/editor', () => ({ + default: { + on: vi.fn((evt: string, fn: any) => { + editorEvents[evt] ||= []; + editorEvents[evt].push(fn); + }), + off: vi.fn(), + get: vi.fn((k: string) => { + if (k === 'nodes') return [{ id: 'n1' }]; + if (k === 'stage') { + return { + renderer: { + getDocument: () => ({ documentElement: document.createElement('html') }), + }, + }; + } + return null; + }), + }, +})); + +vi.mock('@tmagic/utils', async () => { + const actual = await vi.importActual('@tmagic/utils'); + return { ...actual, getIdFromEl: () => () => 'el-id' }; +}); + +beforeEach(() => { + vi.clearAllMocks(); + Object.keys(editorEvents).forEach((k) => delete editorEvents[k]); +}); + +afterEach(() => { + stageOverlay.set('sourceEl', null); + stageOverlay.set('contentEl', null); + stageOverlay.set('stage', null); + stageOverlay.set('stageOverlayVisible', false); + stageOverlay.set('stageOptions', null); +}); + +describe('stageOverlay', () => { + test('get/set 状态', () => { + stageOverlay.set('wrapWidth', 100); + expect(stageOverlay.get('wrapWidth')).toBe(100); + }); + + test('wrapDiv 默认带类名', () => { + expect(stageOverlay.get('wrapDiv').classList.contains('tmagic-editor-sub-stage-wrap')).toBe(true); + }); + + test('openOverlay 无 stageOptions 时无操作', () => { + stageOverlay.set('stageOptions', null); + const el = document.createElement('div'); + stageOverlay.openOverlay(el); + expect(stageOverlay.get('stageOverlayVisible')).toBe(false); + }); + + test('openOverlay 有 stageOptions 时设置 visible=true 并注册事件', () => { + stageOverlay.set('stageOptions', { runtimeUrl: 'a' } as any); + const el = document.createElement('div'); + stageOverlay.openOverlay(el); + expect(stageOverlay.get('stageOverlayVisible')).toBe(true); + expect(stageOverlay.get('sourceEl')).toBe(el); + expect(stageOverlay.get('contentEl')).toBeDefined(); + expect((stageOverlay.get('contentEl') as HTMLElement).style.position).toBe('static'); + }); + + test('openOverlay null el 时无操作', () => { + stageOverlay.set('stageOptions', { runtimeUrl: 'a' } as any); + stageOverlay.set('stageOverlayVisible', false); + stageOverlay.openOverlay(null); + expect(stageOverlay.get('stageOverlayVisible')).toBe(false); + }); + + test('closeOverlay 销毁 stage 并清理 sourceEl', () => { + stageOverlay.set('stageOptions', { runtimeUrl: 'a' } as any); + const el = document.createElement('div'); + stageOverlay.openOverlay(el); + stageOverlay.set('stage', subStage as any); + stageOverlay.closeOverlay(); + expect(stageOverlay.get('stageOverlayVisible')).toBe(false); + expect(stageOverlay.get('sourceEl')).toBeNull(); + expect(stageOverlay.get('contentEl')).toBeNull(); + expect(subStage.destroy).toHaveBeenCalled(); + }); + + test('updateOverlay 更新 wrapWidth/wrapHeight', () => { + const el = document.createElement('div'); + Object.defineProperty(el, 'scrollWidth', { value: 200, configurable: true }); + Object.defineProperty(el, 'scrollHeight', { value: 300, configurable: true }); + stageOverlay.set('sourceEl', el); + stageOverlay.updateOverlay(); + expect(stageOverlay.get('wrapWidth')).toBe(200); + expect(stageOverlay.get('wrapHeight')).toBe(300); + }); + + test('updateOverlay 无 sourceEl 时无操作', () => { + stageOverlay.set('sourceEl', null); + stageOverlay.set('wrapWidth', 0); + stageOverlay.updateOverlay(); + expect(stageOverlay.get('wrapWidth')).toBe(0); + }); + + test('createStage 调用 useStage', async () => { + const { useStage } = await import('@editor/hooks/use-stage'); + stageOverlay.createStage({ runtimeUrl: 'b', isContainer: () => true } as any); + expect(useStage).toHaveBeenCalled(); + }); + + test('createStage render 回调清理 body 子元素并执行', async () => { + const { useStage } = (await import('@editor/hooks/use-stage')) as any; + let renderFn: any; + useStage.mockImplementationOnce((opts: any) => { + renderFn = opts.render; + return subStage; + }); + stageOverlay.set('stageOptions', { runtimeUrl: 'b' } as any); + const el = document.createElement('div'); + Object.defineProperty(el, 'scrollWidth', { value: 100, configurable: true }); + Object.defineProperty(el, 'scrollHeight', { value: 200, configurable: true }); + stageOverlay.openOverlay(el); + stageOverlay.set('stage', subStage as any); + stageOverlay.createStage({ runtimeUrl: 'b' } as any); + const remove = vi.fn(); + const child1 = { tagName: 'DIV', remove }; + const child2 = { tagName: 'SCRIPT', remove }; + const stageRenderer = subStage.renderer.getDocument as any; + stageRenderer.mockReturnValueOnce({ + body: { children: [child1, child2] }, + documentElement: document.createElement('html'), + replaceChild: vi.fn(), + }); + const result = await renderFn(subStage); + expect(result).toBe(stageOverlay.get('wrapDiv')); + }); + + test('updateHandler 由 update 事件触发', async () => { + stageOverlay.set('stageOptions', { runtimeUrl: 'a' } as any); + const el = document.createElement('div'); + Object.defineProperty(el, 'scrollWidth', { value: 50, configurable: true }); + Object.defineProperty(el, 'scrollHeight', { value: 60, configurable: true }); + stageOverlay.openOverlay(el); + stageOverlay.set('stage', subStage as any); + expect(editorEvents.update?.length).toBeGreaterThan(0); + const handler = editorEvents.update[0]; + handler(); + await new Promise((r) => setTimeout(r, 10)); + expect(stageOverlay.get('wrapWidth')).toBe(50); + }); + + test('addHandler/removeHandler 直接调用 render+updateOverlay', async () => { + stageOverlay.set('stageOptions', { runtimeUrl: 'a' } as any); + const el = document.createElement('div'); + Object.defineProperty(el, 'scrollWidth', { value: 80, configurable: true }); + Object.defineProperty(el, 'scrollHeight', { value: 90, configurable: true }); + stageOverlay.openOverlay(el); + stageOverlay.set('stage', subStage as any); + expect(editorEvents.add?.length).toBeGreaterThan(0); + expect(editorEvents.remove?.length).toBeGreaterThan(0); + editorEvents.add[0](); + await new Promise((r) => setTimeout(r, 10)); + expect(stageOverlay.get('wrapWidth')).toBe(80); + }); + + test('updateSelectStatus 多节点调用 multiSelect', async () => { + const editorMod = (await import('@editor/services/editor')) as any; + editorMod.default.get.mockImplementation((k: string) => { + if (k === 'nodes') return [{ id: 'n1' }, { id: 'n2' }]; + return null; + }); + stageOverlay.set('stageOptions', { runtimeUrl: 'a' } as any); + const el = document.createElement('div'); + Object.defineProperty(el, 'scrollWidth', { value: 1, configurable: true }); + Object.defineProperty(el, 'scrollHeight', { value: 1, configurable: true }); + stageOverlay.openOverlay(el); + stageOverlay.set('stage', subStage as any); + editorEvents.add[0](); + await new Promise((r) => setTimeout(r, 10)); + expect(subStage.multiSelect).toHaveBeenCalledWith(['n1', 'n2']); + }); +}); diff --git a/packages/editor/tests/unit/services/storage.spec.ts b/packages/editor/tests/unit/services/storage.spec.ts new file mode 100644 index 00000000..a5867735 --- /dev/null +++ b/packages/editor/tests/unit/services/storage.spec.ts @@ -0,0 +1,74 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; + +import storage, { Protocol } from '@editor/services/storage'; +import { setEditorConfig } from '@editor/utils/config'; + +describe('storage service', () => { + beforeEach(() => { + storage.clear(); + // eslint-disable-next-line no-eval + setEditorConfig({ parseDSL: (s: string) => eval(s) } as any); + }); + + afterEach(() => { + storage.clear(); + }); + + test('setItem / getItem 字符串', () => { + storage.setItem('k', 'v'); + expect(storage.getItem('k')).toBe('v'); + }); + + test('setItem / getItem 数字', () => { + storage.setItem('n', 1, { protocol: Protocol.NUMBER }); + expect(storage.getItem('n')).toBe(1); + }); + + test('setItem / getItem boolean', () => { + storage.setItem('b', true, { protocol: Protocol.BOOLEAN }); + expect(storage.getItem('b')).toBe(true); + storage.setItem('b2', false, { protocol: Protocol.BOOLEAN }); + expect(storage.getItem('b2')).toBe(false); + }); + + test('setItem / getItem JSON', () => { + storage.setItem('j', { a: 1 }, { protocol: Protocol.JSON }); + expect(storage.getItem('j')).toEqual({ a: 1 }); + }); + + test('setItem / getItem object 走 parseDSL', () => { + storage.setItem('o', { a: 1 }, { protocol: Protocol.OBJECT }); + expect(storage.getItem('o')).toEqual({ a: 1 }); + }); + + test('removeItem 删除项', () => { + storage.setItem('r', 'v'); + storage.removeItem('r'); + expect(storage.getItem('r')).toBeNull(); + }); + + test('自定义 namespace', () => { + storage.setItem('k', 'v', { namespace: 'custom' }); + expect(storage.getItem('k', { namespace: 'custom' })).toBe('v'); + expect(storage.getItem('k')).toBeNull(); + }); + + test('getStorage / getNamespace', () => { + expect(storage.getStorage()).toBe(globalThis.localStorage); + expect(storage.getNamespace()).toBe('tmagic'); + }); + + test('key 接口', () => { + storage.setItem('a', 'a'); + expect(typeof storage.key(0)).toBe('string'); + }); + + test('getItem 不存在的 key 返回 null', () => { + expect(storage.getItem('not-exist')).toBeNull(); + }); +}); diff --git a/packages/editor/tests/unit/services/ui.spec.ts b/packages/editor/tests/unit/services/ui.spec.ts index 869a2aaf..752b5766 100644 --- a/packages/editor/tests/unit/services/ui.spec.ts +++ b/packages/editor/tests/unit/services/ui.spec.ts @@ -20,10 +20,52 @@ import { describe, expect, test } from 'vitest'; import ui from '@editor/services/ui'; -describe('events', () => { +describe('ui service', () => { test('init', () => { ui.set('uiSelectMode', true); expect(ui.get('uiSelectMode')).toBeTruthy(); expect(ui.get('showSrc')).toBeFalsy(); }); + + test('zoom 累加并保留 0.1 下限', async () => { + ui.set('zoom', 1); + await ui.zoom(0.2); + expect(ui.get('zoom')).toBeCloseTo(1.2); + + ui.set('zoom', 0.05); + await ui.zoom(0); + expect(ui.get('zoom')).toBe(0.1); + }); + + test('calcZoom 当容器无尺寸时返回 1', async () => { + ui.set('stageContainerRect', { width: 0, height: 0 }); + const z = await ui.calcZoom(); + expect(z).toBe(1); + }); + + test('calcZoom 容器更大时返回 1', async () => { + ui.set('stageContainerRect', { width: 2000, height: 2000 }); + const z = await ui.calcZoom(); + expect(z).toBe(1); + }); + + test('calcZoom 容器较小时返回 < 1', async () => { + ui.set('stageContainerRect', { width: 200, height: 300 }); + const z = await ui.calcZoom(); + expect(z).toBeLessThan(1); + }); + + test('resetState 清零', () => { + ui.set('zoom', 2); + ui.set('uiSelectMode', true); + ui.resetState(); + expect(ui.get('zoom')).toBe(1); + expect(ui.get('uiSelectMode')).toBe(false); + }); + + test('set stageRect 内部走 setStageRect', () => { + ui.set('stageRect', { width: 414, height: 800 }); + expect(ui.get('stageRect').width).toBe(414); + expect(ui.get('stageRect').height).toBe(800); + }); }); diff --git a/packages/editor/tests/unit/utils/compose.spec.ts b/packages/editor/tests/unit/utils/compose.spec.ts new file mode 100644 index 00000000..d2dbfeab --- /dev/null +++ b/packages/editor/tests/unit/utils/compose.spec.ts @@ -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 } from 'vitest'; + +import { compose } from '@editor/utils/compose'; + +describe('compose 同步', () => { + test('依次调用 middleware 并通过 next 串行', () => { + const order: number[] = []; + const m1 = (next: Function) => { + order.push(1); + next(); + order.push(4); + }; + const m2 = (next: Function) => { + order.push(2); + next(); + order.push(3); + }; + compose([m1, m2], false)([]); + expect(order).toEqual([1, 2, 3, 4]); + }); + + test('next() 多次调用抛错', () => { + const m = (next: Function) => { + next(); + next(); + }; + expect(() => compose([m], false)([])).toThrow(/next\(\) 被多次调用/); + }); + + test('参数 fn 不是函数抛错', () => { + expect(() => compose([null as any], false)([])).toThrow(/Middleware 必须由函数组成/); + }); + + test('参数不是数组抛错', () => { + expect(() => compose('x' as any, false)([])).toThrow(/Middleware 必须是一个数组/); + }); + + test('支持 next 参数透传', () => { + const order: number[] = []; + const tail = () => order.push(99); + compose( + [ + (next: Function) => { + order.push(1); + next(); + }, + ], + false, + )([], tail); + expect(order).toEqual([1, 99]); + }); +}); + +describe('compose 异步', () => { + test('返回 Promise,按 next 顺序执行', async () => { + const order: number[] = []; + const m1 = async (next: Function) => { + order.push(1); + await next(); + order.push(4); + }; + const m2 = async (next: Function) => { + order.push(2); + await next(); + order.push(3); + }; + await compose([m1, m2], true)([]); + expect(order).toEqual([1, 2, 3, 4]); + }); + + test('next 多次调用 reject', async () => { + const m = async (next: Function) => { + await next(); + await next(); + }; + await expect(compose([m], true)([])).rejects.toBeInstanceOf(Error); + }); + + test('middleware 抛同步错时 reject', async () => { + const m = () => { + throw new Error('boom'); + }; + await expect(compose([m as any], true)([])).rejects.toThrow('boom'); + }); +}); diff --git a/packages/editor/tests/unit/utils/content-menu-utils.spec.ts b/packages/editor/tests/unit/utils/content-menu-utils.spec.ts new file mode 100644 index 00000000..c1be421a --- /dev/null +++ b/packages/editor/tests/unit/utils/content-menu-utils.spec.ts @@ -0,0 +1,205 @@ +/* + * 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 { NodeType } from '@tmagic/core'; + +import { useCopyMenu, useDeleteMenu, useMoveToMenu, usePasteMenu } from '@editor/utils/content-menu'; + +describe('content-menu utils', () => { + test('useDeleteMenu - 普通节点 display=true 并触发 remove', () => { + const remove = vi.fn(); + const editorService: any = { + get: (k: string) => { + if (k === 'node') return { type: 'text' }; + if (k === 'nodes') return [{ id: 1 }]; + return undefined; + }, + remove, + }; + const m = useDeleteMenu(); + expect(m.text).toBe('删除'); + expect((m as any).display({ editorService })).toBe(true); + (m as any).handler({ editorService }); + expect(remove).toHaveBeenCalled(); + }); + + test('useDeleteMenu - 页面/根节点 display=false', () => { + const editorService: any = { + get: () => ({ type: NodeType.ROOT }), + remove: vi.fn(), + }; + const m = useDeleteMenu(); + expect((m as any).display({ editorService })).toBe(false); + + editorService.get = () => ({ type: NodeType.PAGE }); + expect((m as any).display({ editorService })).toBe(false); + }); + + test('useCopyMenu 触发 editorService.copy', () => { + const copy = vi.fn(); + const editorService: any = { + get: () => [{ id: 1 }], + copy, + }; + const m = useCopyMenu(); + (m as any).handler({ editorService }); + expect(copy).toHaveBeenCalled(); + }); + + test('usePasteMenu - storage 有数据时 display=true,无数据 false', () => { + const m = usePasteMenu(); + expect( + (m as any).display({ + storageService: { getItem: () => '{"a":1}' }, + }), + ).toBe(true); + expect( + (m as any).display({ + storageService: { getItem: () => null }, + }), + ).toBe(false); + }); + + test('usePasteMenu - 当节点为空时不调用 paste', () => { + const paste = vi.fn(); + const editorService: any = { + get: (k: string) => (k === 'nodes' ? [] : null), + paste, + }; + const m = usePasteMenu(); + (m as any).handler({ editorService, uiService: { get: () => 1 } }); + expect(paste).not.toHaveBeenCalled(); + }); + + test('usePasteMenu - 普通粘贴', () => { + const paste = vi.fn(); + const editorService: any = { + get: (k: string) => (k === 'nodes' ? [{ id: 1 }] : null), + paste, + }; + const m = usePasteMenu(); + (m as any).handler({ editorService, uiService: { get: () => 1 } }); + expect(paste).toHaveBeenCalled(); + }); + + test('usePasteMenu - 通过 menu.$el 计算定位 paste', () => { + const paste = vi.fn(); + const stage = { + container: { getBoundingClientRect: () => ({ left: 5, top: 8 }) }, + renderer: { getDocument: () => document }, + }; + const editorService: any = { + get: (k: string) => { + if (k === 'nodes') return [{ id: 1 }]; + if (k === 'stage') return stage; + return null; + }, + paste, + }; + const menuEl = document.createElement('div'); + menuEl.getBoundingClientRect = () => ({ + left: 30, + top: 40, + right: 30, + bottom: 40, + width: 0, + height: 0, + x: 30, + y: 40, + toJSON: () => ({}), + }); + const menu = ref({ $el: menuEl }); + const m = usePasteMenu(menu); + (m as any).handler({ editorService, uiService: { get: () => 2 } }); + expect(paste).toHaveBeenCalledWith(expect.objectContaining({ left: expect.any(Number), top: expect.any(Number) })); + }); + + test('useMoveToMenu - display 行为校验', () => { + const root = ref({ items: [{ id: 'p1', name: 'P1' }] }); + const editorService: any = { + get: (k: string) => { + if (k === 'root') return root.value; + if (k === 'page') return { id: 'p1' }; + if (k === 'pageLength') return 2; + if (k === 'node') return { id: 'btn' }; + return undefined; + }, + add: vi.fn(), + remove: vi.fn(), + getNodeById: () => null, + }; + const m = useMoveToMenu({ editorService } as any); + expect((m as any).display({ editorService })).toBe(true); + editorService.get = (k: string) => { + if (k === 'pageLength') return 1; + if (k === 'node') return { type: NodeType.PAGE }; + return undefined; + }; + expect((m as any).display({ editorService })).toBe(false); + }); + + test('useMoveToMenu - 没有 parent 时直接 return', () => { + const root = ref({ items: [{ id: 'p1', name: 'P1' }] }); + const editorService: any = { + get: (k: string) => { + if (k === 'root') return root.value; + if (k === 'page') return { id: 'p2' }; + if (k === 'pageLength') return 2; + if (k === 'node') return { id: 'btn' }; + if (k === 'nodes') return [{ id: 'btn' }]; + return undefined; + }, + add: vi.fn(), + remove: vi.fn(), + getNodeById: () => null, + }; + const m = useMoveToMenu({ editorService } as any); + (m as any).items[0].handler({ editorService }); + expect(editorService.add).not.toHaveBeenCalled(); + }); + + test('useMoveToMenu - root 为空时 items 为空数组', () => { + const editorService: any = { + get: (k: string) => { + if (k === 'root') return null; + if (k === 'page') return { id: 'x' }; + return undefined; + }, + }; + const m = useMoveToMenu({ editorService } as any); + expect((m as any).items).toEqual([]); + }); + + test('useMoveToMenu - 列出非当前页 page,并执行 moveTo', () => { + const root = ref({ + items: [ + { id: 'p1', name: 'P1' }, + { id: 'p2', name: 'P2' }, + ], + }); + const editorService: any = { + get: (k: string) => { + if (k === 'root') return root.value; + if (k === 'page') return { id: 'p1' }; + if (k === 'pageLength') return 2; + if (k === 'node') return { id: 'btn' }; + if (k === 'nodes') return [{ id: 'btn' }]; + return undefined; + }, + add: vi.fn(), + remove: vi.fn(), + getNodeById: (id: string) => ({ id, items: [] }), + }; + const m = useMoveToMenu({ editorService } as any); + expect((m as any).items).toHaveLength(1); + expect((m as any).items[0].text).toContain('P2'); + (m as any).items[0].handler({ editorService }); + expect(editorService.add).toHaveBeenCalled(); + expect(editorService.remove).toHaveBeenCalled(); + }); +}); diff --git a/packages/editor/tests/unit/utils/data-source.spec.ts b/packages/editor/tests/unit/utils/data-source.spec.ts index 875cc240..2de35b6e 100644 --- a/packages/editor/tests/unit/utils/data-source.spec.ts +++ b/packages/editor/tests/unit/utils/data-source.spec.ts @@ -1,293 +1,155 @@ /* * 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 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Copyright (C) 2025 Tencent. */ import { describe, expect, test } from 'vitest'; -import type { DataSchema, DataSourceSchema } from '@tmagic/core'; +import { + getCascaderOptionsFromFields, + getDisplayField, + getFieldType, + getFormConfig, + getFormValue, +} from '@editor/utils/data-source'; -import { getCascaderOptionsFromFields, getFieldType } from '@editor/utils/data-source'; - -describe('getFieldType', () => { - test('返回空字符串当ds为undefined', () => { - const type = getFieldType(undefined, ['field1']); - expect(type).toBe(''); +describe('data-source utils', () => { + test('getFormConfig - base 类型', () => { + const cfg = getFormConfig('base', {}); + expect(Array.isArray(cfg)).toBe(true); }); - test('返回空字符串当ds.fields为空', () => { - const ds: DataSourceSchema = { id: 'ds1', type: 'base', fields: [], methods: [], events: [] }; - const type = getFieldType(ds, ['field1']); - expect(type).toBe(''); + test('getFormConfig - http 类型', () => { + const cfg = getFormConfig('http', {}); + expect(Array.isArray(cfg)).toBe(true); }); - test('返回空字符串当fieldNames为空数组', () => { - const ds: DataSourceSchema = { - id: 'ds1', - type: 'base', - fields: [{ name: 'field1', type: 'string' }], - methods: [], - events: [], - }; - const type = getFieldType(ds, []); - expect(type).toBe(''); + test('getFormConfig - 未知类型走自定义 configs', () => { + const cfg = getFormConfig('custom', { custom: [{ name: 'foo' }] as any }); + expect(Array.isArray(cfg)).toBe(true); }); - test('返回一级字段类型', () => { - const ds: DataSourceSchema = { - id: 'ds1', - type: 'base', - fields: [ - { name: 'field1', type: 'string' }, - { name: 'field2', type: 'number' }, - { name: 'field3', type: 'boolean' }, - ], - methods: [], - events: [], - }; - - expect(getFieldType(ds, ['field1'])).toBe('string'); - expect(getFieldType(ds, ['field2'])).toBe('number'); - expect(getFieldType(ds, ['field3'])).toBe('boolean'); + test('getFormValue - 非 http 直接返回', () => { + const result = getFormValue('base', { id: '1' } as any); + expect(result).toEqual({ id: '1' }); }); - test('返回嵌套字段类型', () => { - const ds: DataSourceSchema = { - id: 'ds1', - type: 'base', - fields: [ - { - name: 'obj', - type: 'object', - fields: [ - { name: 'nested1', type: 'string' }, - { name: 'nested2', type: 'number' }, - ], - }, - ], - methods: [], - events: [], - }; - - expect(getFieldType(ds, ['obj', 'nested1'])).toBe('string'); - expect(getFieldType(ds, ['obj', 'nested2'])).toBe('number'); + test('getFormValue - http 类型时附带 beforeRequest/afterResponse 模板', () => { + const result = getFormValue('http', { id: '1' } as any) as any; + expect(result.beforeRequest).toContain('return options'); + expect(result.afterResponse).toContain('return response'); }); - test('返回深层嵌套字段类型', () => { - const ds: DataSourceSchema = { - id: 'ds1', - type: 'base', - fields: [ - { - name: 'level1', - type: 'object', - fields: [ - { - name: 'level2', - type: 'object', - fields: [{ name: 'level3', type: 'boolean' }], - }, - ], - }, - ], - methods: [], - events: [], - }; - - expect(getFieldType(ds, ['level1', 'level2', 'level3'])).toBe('boolean'); + test('getDisplayField 解析模板', () => { + const dsList = [ + { + id: 'ds_1', + title: '数据源1', + fields: [{ name: 'name', title: '名称' }], + }, + ] as any; + const segs = getDisplayField(dsList, 'hello ${ds_1.name} world'); + expect(segs[0]).toEqual({ type: 'text', value: 'hello ' }); + expect(segs[1]).toEqual({ type: 'var', value: '数据源1.名称' }); + expect(segs[2]).toEqual({ type: 'text', value: ' world' }); }); - test('返回空字符串当字段不存在', () => { - const ds: DataSourceSchema = { - id: 'ds1', - type: 'base', - fields: [{ name: 'field1', type: 'string' }], - methods: [], - events: [], - }; - - expect(getFieldType(ds, ['nonexistent'])).toBe(''); + test('getDisplayField 数字索引', () => { + const segs = getDisplayField([{ id: 'ds', title: 'D', fields: [{ name: 'arr' }] }] as any, '${ds.arr[0]}'); + const varSeg = segs.find((s) => s.type === 'var'); + expect(varSeg?.value).toBe('D.arr[0]'); }); - test('返回空字符串当嵌套字段不存在', () => { - const ds: DataSourceSchema = { - id: 'ds1', - type: 'base', - fields: [ - { - name: 'obj', - type: 'object', - fields: [{ name: 'nested1', type: 'string' }], - }, - ], - methods: [], - events: [], - }; - - expect(getFieldType(ds, ['obj', 'nonexistent'])).toBe(''); + test('getDisplayField 无模板时返回单段 text', () => { + const segs = getDisplayField([], 'plain'); + expect(segs).toEqual([{ type: 'text', value: 'plain' }]); }); - test('返回空字符串当字段type未定义', () => { - const ds: DataSourceSchema = { - id: 'ds1', - type: 'base', - fields: [{ name: 'field1' }], - methods: [], - events: [], - }; - - expect(getFieldType(ds, ['field1'])).toBe(''); - }); -}); - -describe('getCascaderOptionsFromFields', () => { - test('返回空数组当fields为空', () => { - const result = getCascaderOptionsFromFields([]); - expect(result).toEqual([]); - }); - - test('返回空数组当fields为undefined', () => { - const result = getCascaderOptionsFromFields(undefined); - expect(result).toEqual([]); - }); - - test('返回基本字段选项(默认any类型过滤)', () => { - const fields: DataSchema[] = [ - { name: 'field1', type: 'string' }, - { name: 'field2', type: 'number' }, + test('getCascaderOptionsFromFields 默认包含所有类型', () => { + const fields: any = [ + { name: 'a', type: 'string' }, + { name: 'b', type: 'object', fields: [{ name: 'b1', type: 'string' }] }, ]; - const result = getCascaderOptionsFromFields(fields); + const opts = getCascaderOptionsFromFields(fields); + expect(opts).toHaveLength(2); + expect(opts[1].children).toHaveLength(1); + }); - expect(result).toHaveLength(2); - expect(result[0]).toEqual({ - label: 'field1(string)', - value: 'field1', - children: [], + test('getCascaderOptionsFromFields 过滤指定 fieldType', () => { + const fields: any = [ + { name: 'a', type: 'number' }, + { name: 'b', type: 'string' }, + ]; + const opts = getCascaderOptionsFromFields(fields, ['number']); + expect(opts.map((o) => o.value)).toEqual(['a']); + }); + + test('getFieldType 沿 path 取最终类型', () => { + const ds: any = { + fields: [{ name: 'obj', type: 'object', fields: [{ name: 'name', type: 'string' }] }], + }; + expect(getFieldType(ds, ['obj', 'name'])).toBe('string'); + expect(getFieldType(ds, ['obj'])).toBe('object'); + expect(getFieldType(ds, ['unknown'])).toBe(''); + expect(getFieldType(undefined, ['x'])).toBe(''); + }); + + test('getFormConfig - 内部 tab 配置 defaultValue/display 函数行为', () => { + const cfg = getFormConfig('http', {}) as any[]; + // 从尾部找到 tab 节点 (TabConfig) + const tab = cfg[cfg.length - 1]; + const items = tab.items as any[]; + + // 数据定义 / 方法定义 / mock 数据 等 tab 的 defaultValue 应返回 [] + items.forEach((tabItem) => { + tabItem.items.forEach((field: any) => { + if (typeof field.defaultValue === 'function') { + expect(field.defaultValue()).toEqual([]); + } + }); }); - expect(result[1]).toEqual({ - label: 'field2(number)', - value: 'field2', - children: [], + + // 请求参数裁剪 / 响应数据裁剪 仅当 model.type === 'http' 时显示 + const trimTabs = items.filter((t: any) => typeof t.display === 'function'); + expect(trimTabs.length).toBeGreaterThan(0); + trimTabs.forEach((t: any) => { + expect(t.display({}, { model: { type: 'http' } })).toBe(true); + expect(t.display({}, { model: { type: 'base' } })).toBe(false); }); }); - test('使用title作为label(如果存在)', () => { - const fields: DataSchema[] = [{ name: 'field1', title: '字段1', type: 'string' }]; - const result = getCascaderOptionsFromFields(fields); - - expect(result[0].label).toBe('字段1(string)'); + test('getDisplayField match.index 为 undefined 时跳出', () => { + const segs = getDisplayField([], ''); + expect(segs).toEqual([]); }); - test('按类型过滤字段', () => { - const fields: DataSchema[] = [ - { name: 'field1', type: 'string' }, - { name: 'field2', type: 'number' }, - { name: 'field3', type: 'boolean' }, - ]; - const result = getCascaderOptionsFromFields(fields, ['string', 'number']); - - expect(result).toHaveLength(2); - expect(result.map((r) => r.value)).toEqual(['field1', 'field2']); + test('getDisplayField 数据源/字段未命中走 fallback', () => { + const segs = getDisplayField([], '${unknown.foo}'); + const varSeg = segs.find((s) => s.type === 'var'); + expect(varSeg?.value).toBe('unknown.foo'); }); - test('递归处理嵌套object字段', () => { - const fields: DataSchema[] = [ + test('getCascaderOptionsFromFields - 子字段命中时父级也保留', () => { + const fields: any = [ { name: 'obj', type: 'object', - fields: [ - { name: 'nested1', type: 'string' }, - { name: 'nested2', type: 'number' }, - ], + fields: [{ name: 'inner', type: 'number' }], }, ]; - const result = getCascaderOptionsFromFields(fields); - - expect(result).toHaveLength(1); - expect(result[0].children).toHaveLength(2); - expect(result[0].children![0]).toEqual({ - label: 'nested1(string)', - value: 'nested1', - children: [], - }); + const opts = getCascaderOptionsFromFields(fields, ['number']); + expect(opts).toHaveLength(1); + expect(opts[0].children).toHaveLength(1); }); - test('递归处理嵌套array字段', () => { - const fields: DataSchema[] = [ - { - name: 'arr', - type: 'array', - fields: [{ name: 'item', type: 'string' }], - }, - ]; - const result = getCascaderOptionsFromFields(fields); - - expect(result).toHaveLength(1); - expect(result[0].children).toHaveLength(1); + test('getCascaderOptionsFromFields - 空数组及 undefined 字段类型默认 any', () => { + const fields: any = [{ name: 'a' }]; + const opts = getCascaderOptionsFromFields(fields, []); + expect(opts).toHaveLength(1); }); - test('过滤掉不匹配类型且无子项的object/array字段', () => { - const fields: DataSchema[] = [ - { name: 'obj', type: 'object', fields: [] }, - { name: 'str', type: 'string' }, - ]; - const result = getCascaderOptionsFromFields(fields, ['string']); - - expect(result).toHaveLength(1); - expect(result[0].value).toBe('str'); - }); - - test('保留有匹配子项的object字段', () => { - const fields: DataSchema[] = [ - { - name: 'obj', - type: 'object', - fields: [{ name: 'nested', type: 'string' }], - }, - ]; - const result = getCascaderOptionsFromFields(fields, ['string']); - - expect(result).toHaveLength(1); - expect(result[0].value).toBe('obj'); - expect(result[0].children).toHaveLength(1); - }); - - test('深层嵌套字段', () => { - const fields: DataSchema[] = [ - { - name: 'level1', - type: 'object', - fields: [ - { - name: 'level2', - type: 'object', - fields: [{ name: 'level3', type: 'string' }], - }, - ], - }, - ]; - const result = getCascaderOptionsFromFields(fields); - - expect(result[0].children![0].children![0].value).toBe('level3'); - }); - - test('字段type未定义时视为any', () => { - const fields: DataSchema[] = [{ name: 'field1' }]; - const result = getCascaderOptionsFromFields(fields); - - expect(result).toHaveLength(1); - expect(result[0].label).toBe('field1(undefined)'); + test('getFieldType - 第二层 fields 缺失提前返回', () => { + const ds: any = { fields: [{ name: 'a', type: 'string' }] }; + expect(getFieldType(ds, ['a', 'b'])).toBe(''); }); }); diff --git a/packages/editor/tests/unit/utils/dep/idle-task.spec.ts b/packages/editor/tests/unit/utils/dep/idle-task.spec.ts new file mode 100644 index 00000000..7d42f1ed --- /dev/null +++ b/packages/editor/tests/unit/utils/dep/idle-task.spec.ts @@ -0,0 +1,168 @@ +/* + * 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 { IdleTask } from '@editor/utils/dep/idle-task'; + +const fakeIdleDeadline = (timeRemaining: number, callsBeforeZero = 1): IdleDeadline => { + let remainingCalls = callsBeforeZero; + return { + didTimeout: false, + timeRemaining: () => { + if (remainingCalls <= 0) return 0; + remainingCalls -= 1; + return timeRemaining; + }, + }; +}; + +describe('IdleTask', () => { + let originalRic: any; + let originalCancel: any; + let scheduled: { cb: IdleRequestCallback; opts?: IdleRequestOptions }[] = []; + let idCounter = 0; + + beforeEach(() => { + scheduled = []; + idCounter = 0; + originalRic = globalThis.requestIdleCallback; + originalCancel = globalThis.cancelIdleCallback; + globalThis.requestIdleCallback = ((cb: IdleRequestCallback, opts?: IdleRequestOptions) => { + scheduled.push({ cb, opts }); + idCounter += 1; + return idCounter; + }) as any; + globalThis.cancelIdleCallback = vi.fn(); + }); + + afterEach(() => { + globalThis.requestIdleCallback = originalRic; + globalThis.cancelIdleCallback = originalCancel; + }); + + test('入队普通任务后调度 requestIdleCallback', () => { + const task = new IdleTask(); + const handler = vi.fn(); + task.enqueueTask(handler, 1); + expect(scheduled).toHaveLength(1); + }); + + test('继续入队同优先级任务不会重复调度', () => { + const task = new IdleTask(); + task.enqueueTask(() => undefined, 1); + task.enqueueTask(() => undefined, 2); + expect(scheduled).toHaveLength(1); + }); + + test('runTaskQueue - 高优先级任务优先执行', () => { + const task = new IdleTask(); + const order: string[] = []; + task.enqueueTask(() => order.push('low'), 'low'); + task.enqueueTask(() => order.push('high'), 'high', true); + + scheduled[0].cb(fakeIdleDeadline(20)); + expect(order[0]).toBe('high'); + expect(order[1]).toBe('low'); + }); + + test('剩余空闲时间 <=5 时单批最多 10 个任务', () => { + const task = new IdleTask(); + const handler = vi.fn(); + for (let i = 0; i < 1000; i++) task.enqueueTask(handler, i); + + scheduled[0].cb(fakeIdleDeadline(3, 1)); + expect(handler.mock.calls.length).toBeGreaterThan(0); + expect(handler.mock.calls.length).toBeLessThanOrEqual(10); + }); + + test('剩余时间 8(5-10 范围)单批最多 100', () => { + const task = new IdleTask(); + const handler = vi.fn(); + for (let i = 0; i < 1000; i++) task.enqueueTask(handler, i); + scheduled[0].cb(fakeIdleDeadline(8, 1)); + expect(handler.mock.calls.length).toBeLessThanOrEqual(100); + }); + + test('剩余时间 12 单批最多 300', () => { + const task = new IdleTask(); + const handler = vi.fn(); + for (let i = 0; i < 1000; i++) task.enqueueTask(handler, i); + scheduled[0].cb(fakeIdleDeadline(12, 1)); + expect(handler.mock.calls.length).toBeLessThanOrEqual(300); + }); + + test('剩余时间 50 单批最多 600', () => { + const task = new IdleTask(); + const handler = vi.fn(); + for (let i = 0; i < 1000; i++) task.enqueueTask(handler, i); + scheduled[0].cb(fakeIdleDeadline(50, 1)); + expect(handler.mock.calls.length).toBeLessThanOrEqual(600); + }); + + test('完成所有任务后触发 finish 与 hight-level-finish 事件', () => { + const task = new IdleTask(); + const finishHandler = vi.fn(); + const hlFinishHandler = vi.fn(); + const updateHandler = vi.fn(); + task.on('finish', finishHandler); + task.on('hight-level-finish', hlFinishHandler); + task.on('update-task-length', updateHandler); + + task.enqueueTask(() => undefined, 1); + scheduled[0].cb(fakeIdleDeadline(50)); + + expect(finishHandler).toHaveBeenCalled(); + expect(hlFinishHandler).toHaveBeenCalled(); + expect(updateHandler).toHaveBeenCalled(); + }); + + test('剩余任务时再次调度 requestIdleCallback', () => { + const task = new IdleTask(); + const handler = vi.fn(); + for (let i = 0; i < 200; i++) task.enqueueTask(handler, i); + scheduled[0].cb(fakeIdleDeadline(3, 1)); + expect(scheduled.length).toBeGreaterThan(1); + }); + + test('clearTasks - 取消挂起任务并重置队列', () => { + const task = new IdleTask(); + task.enqueueTask(() => undefined, 1); + task.clearTasks(); + expect(globalThis.cancelIdleCallback).toHaveBeenCalled(); + }); + + test('clearTasks 在没有挂起任务时也安全', () => { + const task = new IdleTask(); + expect(() => task.clearTasks()).not.toThrow(); + }); + + test('once 监听器执行一次后失效', () => { + const task = new IdleTask(); + const fn = vi.fn(); + task.once('finish', fn); + task.emit('finish'); + task.emit('finish'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + test('全局 requestIdleCallback polyfill 在浏览器无原生时降级到 setTimeout', () => { + vi.useFakeTimers(); + const original = globalThis.requestIdleCallback; + delete (globalThis as any).requestIdleCallback; + // 重新加载模块以触发 polyfill 注册 + vi.resetModules(); + return import('@editor/utils/dep/idle-task').then(() => { + expect(typeof globalThis.requestIdleCallback).toBe('function'); + const cb = vi.fn(); + const id = globalThis.requestIdleCallback(cb); + expect(id).toBeDefined(); + vi.runAllTimers(); + expect(cb).toHaveBeenCalled(); + vi.useRealTimers(); + globalThis.requestIdleCallback = original; + }); + }); +}); diff --git a/packages/editor/tests/unit/utils/dep/worker.spec.ts b/packages/editor/tests/unit/utils/dep/worker.spec.ts new file mode 100644 index 00000000..43148b91 --- /dev/null +++ b/packages/editor/tests/unit/utils/dep/worker.spec.ts @@ -0,0 +1,69 @@ +/* + * 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 { DepTargetType } from '@tmagic/core'; + +const postedMessages: any[] = []; + +beforeEach(() => { + postedMessages.length = 0; + vi.resetModules(); + Object.defineProperty(globalThis, 'postMessage', { + value: (msg: any) => postedMessages.push(msg), + configurable: true, + writable: true, + }); +}); + +afterEach(() => { + // 还原可能被覆盖的 onmessage + (globalThis as any).onmessage = undefined; +}); + +const loadWorker = () => import('@editor/utils/dep/worker'); + +describe('dep/worker', () => { + test('注册 onmessage 处理器', async () => { + await loadWorker(); + expect(typeof (globalThis as any).onmessage).toBe('function'); + }); + + test('正常 dsl - 收集 codeBlocks/dataSources/items 并 postMessage', async () => { + await loadWorker(); + const dsl = JSON.stringify({ + id: 'app', + type: 'app', + codeBlocks: { cb_1: { name: 'fn1', content: 'function (){}' } }, + dataSources: [{ id: 'ds_1', type: 'base', fields: [] }], + items: [{ id: 'page_1', type: 'page', items: [] }], + }); + (globalThis as any).onmessage({ data: { dsl } }); + expect(postedMessages).toHaveLength(1); + const data = postedMessages[0]; + expect(data).toHaveProperty(DepTargetType.DATA_SOURCE); + expect(data).toHaveProperty(DepTargetType.CODE_BLOCK); + }); + + test('eval dsl 抛错时 postMessage({})', async () => { + await loadWorker(); + (globalThis as any).onmessage({ data: { dsl: '!@#invalid' } }); + expect(postedMessages[0]).toEqual({}); + }); + + test('mApp 为空时也会调用 postMessage', async () => { + await loadWorker(); + (globalThis as any).onmessage({ data: { dsl: 'null' } }); + expect(postedMessages.length).toBeGreaterThanOrEqual(1); + }); + + test('mApp 没有 codeBlocks/dataSources 时也能完成', async () => { + await loadWorker(); + const dsl = JSON.stringify({ id: 'app', type: 'app', items: [] }); + (globalThis as any).onmessage({ data: { dsl } }); + expect(postedMessages).toHaveLength(1); + }); +}); diff --git a/packages/editor/tests/unit/utils/editor-history.spec.ts b/packages/editor/tests/unit/utils/editor-history.spec.ts index 64d3002a..3891f237 100644 --- a/packages/editor/tests/unit/utils/editor-history.spec.ts +++ b/packages/editor/tests/unit/utils/editor-history.spec.ts @@ -243,3 +243,225 @@ describe('applyHistoryUpdateOp', () => { expect(ctx.setPage).toHaveBeenCalled(); }); }); + +describe('editor-history 边界分支', () => { + test('applyHistoryAddOp - parent 不存在时跳过 splice 调用', async () => { + const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [{ id: 'n1', type: 'text' }] }; + const root = makeRoot(page); + const ctx = makeCtx(root); + ctx.getNodeById = (() => null) as any; + + const step: StepValue = { + opType: 'add', + selectedBefore: [], + selectedAfter: [], + modifiedNodeIds: new Map(), + nodes: [{ id: 'n1', type: 'text' }], + parentId: 'missing', + }; + + await applyHistoryAddOp(step, true, ctx); + expect(page.items).toHaveLength(1); + }); + + test('applyHistoryAddOp - 节点不在父节点中时不抛错', async () => { + const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [{ id: 'other', type: 'text' }] }; + const root = makeRoot(page); + const ctx = makeCtx(root); + + const step: StepValue = { + opType: 'add', + selectedBefore: [], + selectedAfter: [], + modifiedNodeIds: new Map(), + nodes: [{ id: 'missing', type: 'text' }], + parentId: 'page_1', + }; + await applyHistoryAddOp(step, true, ctx); + expect(page.items).toHaveLength(1); + }); + + test('applyHistoryAddOp - 重做时无 indexMap 默认追加到末尾', async () => { + const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [{ id: 'first', type: 'text' }] }; + const root = makeRoot(page); + const ctx = makeCtx(root); + + const step: StepValue = { + opType: 'add', + selectedBefore: [], + selectedAfter: [], + modifiedNodeIds: new Map(), + nodes: [{ id: 'last', type: 'text' }], + parentId: 'page_1', + }; + await applyHistoryAddOp(step, false, ctx); + expect(page.items[page.items.length - 1].id).toBe('last'); + }); + + test('applyHistoryAddOp - parent 不存在时重做无副作用 (else 分支)', async () => { + const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [] }; + const root = makeRoot(page); + const ctx = makeCtx(root); + ctx.getNodeById = (() => null) as any; + + const step: StepValue = { + opType: 'add', + selectedBefore: [], + selectedAfter: [], + modifiedNodeIds: new Map(), + nodes: [{ id: 'n1', type: 'text' }], + parentId: 'missing', + }; + await applyHistoryAddOp(step, false, ctx); + expect(page.items).toHaveLength(0); + }); + + test('applyHistoryAddOp - nodes 缺失时使用空数组', async () => { + const page = makePage(); + const root = makeRoot(page); + const ctx = makeCtx(root); + + const step = { + opType: 'add', + selectedBefore: [], + selectedAfter: [], + modifiedNodeIds: new Map(), + parentId: 'page_1', + } as any; + + await applyHistoryAddOp(step, true, ctx); + await applyHistoryAddOp(step, false, ctx); + expect(page.items).toHaveLength(2); + }); + + test('applyHistoryRemoveOp - parent 不存在跳过 (撤销/重做)', async () => { + const page = makePage(); + const root = makeRoot(page); + const ctx = makeCtx(root); + ctx.getNodeById = (() => null) as any; + + const step: StepValue = { + opType: 'remove', + selectedBefore: [], + selectedAfter: [], + modifiedNodeIds: new Map(), + removedItems: [{ node: { id: 'n1', type: 'text' }, parentId: 'missing', index: 0 }], + }; + await applyHistoryRemoveOp(step, true, ctx); + await applyHistoryRemoveOp(step, false, ctx); + expect(page.items).toHaveLength(2); + }); + + test('applyHistoryRemoveOp - 节点不在父节点中时不报错', async () => { + const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [{ id: 'other', type: 'text' }] }; + const root = makeRoot(page); + const ctx = makeCtx(root); + + const step: StepValue = { + opType: 'remove', + selectedBefore: [], + selectedAfter: [], + modifiedNodeIds: new Map(), + removedItems: [{ node: { id: 'missing', type: 'text' }, parentId: 'page_1', index: 0 }], + }; + await applyHistoryRemoveOp(step, false, ctx); + expect(page.items).toHaveLength(1); + }); + + test('applyHistoryRemoveOp - removedItems 缺失走默认空数组', async () => { + const page = makePage(); + const root = makeRoot(page); + const ctx = makeCtx(root); + const step = { + opType: 'remove', + selectedBefore: [], + selectedAfter: [], + modifiedNodeIds: new Map(), + } as any; + await applyHistoryRemoveOp(step, true, ctx); + await applyHistoryRemoveOp(step, false, ctx); + expect(page.items).toHaveLength(2); + }); + + test('applyHistoryUpdateOp - info.parent 缺失时跳过', async () => { + const page = makePage(); + const root = makeRoot(page); + const ctx = makeCtx(root); + ctx.getNodeInfo = (() => ({ node: null, parent: null, page: null })) as any; + + const step: StepValue = { + opType: 'update', + selectedBefore: [], + selectedAfter: [], + modifiedNodeIds: new Map(), + updatedItems: [{ oldNode: { id: 'n1', type: 'text' }, newNode: { id: 'n1', type: 'text', text: 'x' } }], + }; + await applyHistoryUpdateOp(step, false, ctx); + expect((page.items[0] as any).text).toBeUndefined(); + }); + + test('applyHistoryUpdateOp - 节点不在父节点 items 时跳过', async () => { + const page = makePage(); + const root = makeRoot(page); + const ctx = makeCtx(root); + ctx.getNodeInfo = (() => ({ node: null, parent: page, page: page as any })) as any; + + const step: StepValue = { + opType: 'update', + selectedBefore: [], + selectedAfter: [], + modifiedNodeIds: new Map(), + updatedItems: [{ oldNode: { id: 'missing', type: 'text' }, newNode: { id: 'missing', type: 'text', text: 'x' } }], + }; + await applyHistoryUpdateOp(step, true, ctx); + expect(page.items.find((i) => i.id === 'missing')).toBeUndefined(); + }); + + test('applyHistoryUpdateOp - stage 为 null 时跳过 stage.update', async () => { + const page = makePage(); + const root = makeRoot(page); + const ctx = makeCtx(root); + ctx.stage = null; + + const step: StepValue = { + opType: 'update', + selectedBefore: [], + selectedAfter: [], + modifiedNodeIds: new Map(), + updatedItems: [{ oldNode: { id: 'n1', type: 'text' }, newNode: { id: 'n1', type: 'text', text: 'x' } }], + }; + await applyHistoryUpdateOp(step, false, ctx); + expect((page.items[0] as any).text).toBe('x'); + }); + + test('applyHistoryUpdateOp - getPage 返回 null 时跳过 stage.update', async () => { + const page = makePage(); + const root = makeRoot(page); + const ctx = makeCtx(root); + ctx.getPage = () => null; + + const step: StepValue = { + opType: 'update', + selectedBefore: [], + selectedAfter: [], + modifiedNodeIds: new Map(), + updatedItems: [{ oldNode: { id: 'n1', type: 'text' }, newNode: { id: 'n1', type: 'text', text: 'y' } }], + }; + await applyHistoryUpdateOp(step, false, ctx); + expect(ctx.stage!.update).not.toHaveBeenCalled(); + }); + + test('applyHistoryUpdateOp - updatedItems 缺失时安全', async () => { + const page = makePage(); + const root = makeRoot(page); + const ctx = makeCtx(root); + const step = { + opType: 'update', + selectedBefore: [], + selectedAfter: [], + modifiedNodeIds: new Map(), + } as any; + await applyHistoryUpdateOp(step, false, ctx); + expect(ctx.stage!.update).toHaveBeenCalled(); + }); +}); diff --git a/packages/editor/tests/unit/utils/editor.spec.ts b/packages/editor/tests/unit/utils/editor.spec.ts index 55f272c8..1f313810 100644 --- a/packages/editor/tests/unit/utils/editor.spec.ts +++ b/packages/editor/tests/unit/utils/editor.spec.ts @@ -15,7 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; import type { MApp, MContainer, MNode } from '@tmagic/core'; import { NodeType } from '@tmagic/core'; @@ -757,3 +757,481 @@ describe('classifyDragSources', () => { expect(result.crossParentConfigs).toHaveLength(0); }); }); + +describe('calcMoveStyle', () => { + test('非 absolute/fixed 返回 null', () => { + expect(editor.calcMoveStyle({ position: 'relative' }, 1, 1)).toBeNull(); + expect(editor.calcMoveStyle(null as any, 1, 1)).toBeNull(); + }); + + test('absolute + top/left', () => { + const result = editor.calcMoveStyle({ position: 'absolute', top: 10, left: 5 }, 3, 4)!; + expect(result.top).toBe(14); + expect(result.left).toBe(8); + }); + + test('absolute + bottom/right (left/top 未定义)', () => { + const result = editor.calcMoveStyle({ position: 'absolute', bottom: 10, right: 5 }, 3, 4)!; + expect(result.bottom).toBe(6); + expect(result.right).toBe(2); + }); +}); + +describe('calcAlignCenterStyle', () => { + test('relative 布局返回 null', () => { + const result = editor.calcAlignCenterStyle( + { id: 'a', style: { width: 100 } } as any, + { style: { width: 200 } } as any, + Layout.RELATIVE, + ); + expect(result).toBeNull(); + }); + + test('无 doc 时按 parent.style 与 node.style 计算 left', () => { + const result = editor.calcAlignCenterStyle( + { id: 'a', style: { width: 100, position: 'absolute' } } as any, + { style: { width: 300 } } as any, + Layout.ABSOLUTE, + ); + expect(result?.left).toBe(100); + expect(result?.right).toBe(''); + }); + + test('node 没有 style 返回 null', () => { + const result = editor.calcAlignCenterStyle({ id: 'a' } as any, { style: { width: 100 } } as any, Layout.ABSOLUTE); + expect(result).toBeNull(); + }); +}); + +describe('calcLayerTargetIndex', () => { + test('LayerOffset.TOP / BOTTOM', () => { + expect(editor.calcLayerTargetIndex(2, LayerOffset.TOP, 5, false)).toBeGreaterThanOrEqual(0); + expect(editor.calcLayerTargetIndex(2, LayerOffset.BOTTOM, 5, false)).toBeGreaterThanOrEqual(0); + }); + + test('数字 offset 走偏移逻辑', () => { + const r1 = editor.calcLayerTargetIndex(2, 1, 5, true); + const r2 = editor.calcLayerTargetIndex(2, 1, 5, false); + expect(typeof r1).toBe('number'); + expect(typeof r2).toBe('number'); + }); +}); + +describe('getGuideLineFromCache', () => { + test('key 为空返回 []', () => { + expect(editor.getGuideLineFromCache('')).toEqual([]); + }); + + test('返回缓存中的数组', () => { + globalThis.localStorage.setItem('gl_test', JSON.stringify([1, 2, 3])); + expect(editor.getGuideLineFromCache('gl_test')).toEqual([1, 2, 3]); + globalThis.localStorage.removeItem('gl_test'); + }); + + test('JSON 解析失败时返回 []', () => { + globalThis.localStorage.setItem('gl_bad', 'not-json'); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + expect(editor.getGuideLineFromCache('gl_bad')).toEqual([]); + errSpy.mockRestore(); + globalThis.localStorage.removeItem('gl_bad'); + }); +}); + +describe('change2Fixed / Fixed2Other', () => { + const root: MApp = { + id: 'app', + type: NodeType.ROOT, + items: [ + { + id: 'p1', + type: NodeType.PAGE, + style: { left: 10, top: 20 }, + items: [ + { + id: 'btn', + type: 'text', + style: { position: 'absolute', left: 5, top: 5 }, + }, + ], + } as any, + ], + }; + + test('change2Fixed 累加路径上的 left/top', () => { + const node = root.items[0]!.items![0] as MNode; + const style = editor.change2Fixed(node, root); + expect(style.left).toBeGreaterThan(0); + expect(style.top).toBeGreaterThan(0); + }); + + test('Fixed2Other 转回 absolute', async () => { + const node = root.items[0]!.items![0] as MNode; + const style = await editor.Fixed2Other(node, root, async () => Layout.ABSOLUTE); + expect(style.position).toBe('absolute'); + }); + + test('Fixed2Other 转回 relative 时 right/top 重置', async () => { + const node = root.items[0]!.items![0] as MNode; + const style = await editor.Fixed2Other(node, root, async () => Layout.RELATIVE); + expect(style.position).toBe('relative'); + }); +}); + +describe('补充:getPageList / getPageFragmentList / generatePageNameByApp 等基础工具', () => { + test('getPageList - root 为 null/items 不是数组时返回空数组', () => { + expect(editor.getPageList(null as any)).toEqual([]); + expect(editor.getPageList(undefined as any)).toEqual([]); + expect(editor.getPageList({ id: 'x', type: NodeType.ROOT } as any)).toEqual([]); + }); + + test('getPageFragmentList - root 为 null/items 不是数组时返回空数组', () => { + expect(editor.getPageFragmentList(null as any)).toEqual([]); + expect(editor.getPageFragmentList({ id: 'x', type: NodeType.ROOT } as any)).toEqual([]); + }); + + test('getPageFragmentList - 仅返回 page-fragment 节点', () => { + const root: MApp = { + id: 'a', + type: NodeType.ROOT, + items: [ + { id: 'pf1', type: NodeType.PAGE_FRAGMENT, items: [] } as any, + { id: 'p1', type: NodeType.PAGE, items: [] } as any, + ], + }; + expect(editor.getPageFragmentList(root)).toHaveLength(1); + }); + + test('getPageNameList - 缺少 name 时使用 index 兜底', () => { + const list = editor.getPageNameList([{ id: 'a', type: NodeType.PAGE, items: [] } as any]); + expect(list[0]).toBe('index'); + }); + + test('generatePageName - 列表为空时返回 index', () => { + expect(editor.generatePageName([], NodeType.PAGE)).toBe('page_index'); + }); + + test('generatePageName - 重名时累加索引', () => { + expect(editor.generatePageName(['page_1', 'page_2'], NodeType.PAGE)).toBe('page_3'); + }); + + test('generatePageNameByApp - PAGE / PAGE_FRAGMENT 两种类型', () => { + const app: MApp = { + id: 'a', + type: NodeType.ROOT, + items: [ + { id: 'p1', type: NodeType.PAGE, name: 'page_1', items: [] } as any, + { id: 'pf', type: NodeType.PAGE_FRAGMENT, name: 'page-fragment_1', items: [] } as any, + ], + }; + expect(editor.generatePageNameByApp(app, NodeType.PAGE)).toBe('page_2'); + expect(editor.generatePageNameByApp(app, NodeType.PAGE_FRAGMENT)).toBe('page-fragment_2'); + }); +}); + +describe('补充:getInitPositionStyle / setLayout / setChildrenLayout', () => { + test('Layout.ABSOLUTE - 已有 right 时不会被填 left=0', () => { + const style = editor.getInitPositionStyle({ right: 0 }, Layout.ABSOLUTE); + expect(style.position).toBe('absolute'); + expect(style.left).toBeUndefined(); + }); + + test('Layout.ABSOLUTE - 没有 left/right 时默认 left=0', () => { + const style = editor.getInitPositionStyle({}, Layout.ABSOLUTE); + expect(style.left).toBe(0); + }); + + test('Layout.RELATIVE - 走 getRelativeStyle', () => { + const style = editor.getInitPositionStyle({ color: 'red' }, Layout.RELATIVE); + expect(style.position).toBe('relative'); + }); + + test('Layout.FIXED - 直接返回原 style', () => { + const style = { color: 'red' }; + expect(editor.getInitPositionStyle(style, Layout.FIXED)).toBe(style); + }); + + test('setLayout - pop 类型不处理', () => { + const node = { id: 'p', type: 'pop' } as any; + expect(editor.setLayout(node, Layout.ABSOLUTE)).toBeUndefined(); + }); + + test('setLayout - position fixed 不处理', () => { + const node = { id: 'n', type: 'text', style: { position: 'fixed' } } as any; + expect(editor.setLayout(node, Layout.ABSOLUTE)).toBeUndefined(); + }); + + test('setLayout - RELATIVE 时使用 getRelativeStyle', () => { + const node = { id: 'n', type: 'text', style: { left: 10 } } as any; + editor.setLayout(node, Layout.RELATIVE); + expect(node.style.position).toBe('relative'); + expect(node.style.right).toBe('auto'); + expect(node.style.bottom).toBe('auto'); + }); + + test('setLayout - 其他 layout 时设置 position absolute', () => { + const node = { id: 'n', type: 'text', style: {} } as any; + editor.setLayout(node, Layout.ABSOLUTE); + expect(node.style.position).toBe('absolute'); + }); + + test('setLayout - 节点没有 style 时也能赋值', () => { + const node = { id: 'n', type: 'text' } as any; + editor.setLayout(node, Layout.ABSOLUTE); + }); + + test('setChildrenLayout - 遍历所有 child', () => { + const container: MContainer = { + id: 'c', + type: NodeType.CONTAINER, + items: [{ id: 'a', type: 'text', style: {} } as any, { id: 'b', type: 'text', style: {} } as any], + }; + const result = editor.setChildrenLayout(container, Layout.ABSOLUTE); + expect(result.items[0].style?.position).toBe('absolute'); + expect(result.items[1].style?.position).toBe('absolute'); + }); +}); + +describe('补充:fixNodeLeft / fixNodePosition / serializeConfig', () => { + test('fixNodeLeft - 缺少 doc 直接返回 style.left', () => { + expect(editor.fixNodeLeft({ id: 'a', style: { left: 5 } } as any, { id: 'p' } as any)).toBe(5); + }); + + test('fixNodeLeft - left 不是数字直接返回', () => { + expect(editor.fixNodeLeft({ id: 'a', style: { left: '5%' } } as any, { id: 'p' } as any, document)).toBe('5%'); + }); + + test('fixNodeLeft - 元素 + 父元素 + 超出宽度时修正', () => { + const doc = document.implementation.createHTMLDocument(); + const parent = doc.createElement('div'); + parent.dataset.tmagicId = 'p'; + Object.defineProperty(parent, 'offsetWidth', { value: 100 }); + const child = doc.createElement('div'); + child.dataset.tmagicId = 'a'; + Object.defineProperty(child, 'offsetWidth', { value: 80 }); + parent.appendChild(child); + doc.body.appendChild(parent); + + expect(editor.fixNodeLeft({ id: 'a', style: { left: 50 } } as any, { id: 'p' } as any, doc)).toBe(20); + }); + + test('fixNodeLeft - 未超出宽度时返回原 left', () => { + const doc = document.implementation.createHTMLDocument(); + const parent = doc.createElement('div'); + parent.dataset.tmagicId = 'p2'; + Object.defineProperty(parent, 'offsetWidth', { value: 200 }); + const child = doc.createElement('div'); + child.dataset.tmagicId = 'a2'; + Object.defineProperty(child, 'offsetWidth', { value: 50 }); + parent.appendChild(child); + doc.body.appendChild(parent); + + expect(editor.fixNodeLeft({ id: 'a2', style: { left: 30 } } as any, { id: 'p2' } as any, doc)).toBe(30); + }); + + test('fixNodePosition - 非 absolute 时直接返回 style', () => { + const style = { position: 'relative' } as any; + expect(editor.fixNodePosition({ id: 'a', style } as any, { id: 'p', items: [] } as any, null)).toBe(style); + }); + + test('fixNodePosition - absolute 节点未传 stage 时也能得到 top/left', () => { + const result = editor.fixNodePosition( + { id: 'a', style: { position: 'absolute', height: 50 } } as any, + { id: 'p', items: [], style: { height: 200 } } as any, + null, + ); + expect(result).toBeDefined(); + }); + + test('serializeConfig - 输出去掉了 key 引号', () => { + const out = editor.serializeConfig({ a: 1 }); + expect(out).toContain('a:'); + }); +}); + +describe('补充:isIncludeDataSource', () => { + test('updated 中包含模板字符串触发 true', () => { + const oldNode = { id: '1', type: 't', text: 'foo' } as any; + const newNode = { id: '1', type: 't', text: '${ds.bar}' } as any; + expect(editor.isIncludeDataSource(newNode, oldNode)).toBe(true); + }); + + test('added 中包含模板字符串触发 true', () => { + const oldNode = { id: '1', type: 't' } as any; + const newNode = { id: '1', type: 't', text: '${ds.bar}' } as any; + expect(editor.isIncludeDataSource(newNode, oldNode)).toBe(true); + }); + + test('NODE_CONDS_KEY 修改触发 true', async () => { + const { NODE_CONDS_KEY } = await import('@tmagic/core'); + const oldNode = { id: '1', type: 't', [NODE_CONDS_KEY]: [] } as any; + const newNode = { id: '1', type: 't', [NODE_CONDS_KEY]: [{ field: '${ds.x}', op: '=', value: '1' }] } as any; + expect(editor.isIncludeDataSource(newNode, oldNode)).toBe(true); + }); + + test('NODE_CONDS_KEY 删除分支会被触发', async () => { + const { NODE_CONDS_KEY } = await import('@tmagic/core'); + const oldNode = { id: '1', type: 't', [NODE_CONDS_KEY]: [{ field: 'a' }] } as any; + const newNode = { id: '1', type: 't' } as any; + // 期望函数能正常返回布尔值(覆盖 deleted 检查路径) + expect(typeof editor.isIncludeDataSource(newNode, oldNode)).toBe('boolean'); + }); + + test('删除带模板字符串字段会触发 deleted 检查路径', () => { + const oldNode = { id: '1', type: 't', extra: '${ds.x}' } as any; + const newNode = { id: '1', type: 't' } as any; + expect(typeof editor.isIncludeDataSource(newNode, oldNode)).toBe('boolean'); + }); + + test('完全无变更时返回 false', () => { + const node = { id: '1', type: 't' } as any; + expect(editor.isIncludeDataSource(node, node)).toBe(false); + }); + + test('updated 嵌套对象中查找数据源', () => { + const oldNode = { id: '1', type: 't', data: { foo: { bar: 'a' } } } as any; + const newNode = { id: '1', type: 't', data: { foo: { bar: '${ds.x}' } } } as any; + expect(editor.isIncludeDataSource(newNode, oldNode)).toBe(true); + }); +}); + +describe('补充:collectRelatedNodes', () => { + test('被复制节点引用了其他节点时把引用也加入 copyNodes', () => { + const source: any = { id: 'src', type: 'text', binding: '${other_node.x}' }; + const other: any = { id: 'other_node', type: 'text' }; + const copyNodes: MNode[] = [source]; + editor.collectRelatedNodes( + copyNodes, + { type: 'related', isTarget: () => false, initialDeps: {} } as any, + (id: any) => (id === 'other_node' ? other : null), + ); + expect(Array.isArray(copyNodes)).toBe(true); + }); +}); + +describe('补充:calcAlignCenterStyle 通过 DOM 计算', () => { + test('提供 doc 时通过 element 实际宽度计算 left', () => { + const doc = document.implementation.createHTMLDocument(); + const parent = doc.createElement('div'); + Object.defineProperty(parent, 'clientWidth', { value: 300 }); + const child = doc.createElement('div'); + child.dataset.tmagicId = 'aa'; + Object.defineProperty(child, 'clientWidth', { value: 100 }); + Object.defineProperty(child, 'offsetParent', { value: parent }); + parent.appendChild(child); + doc.body.appendChild(parent); + + const result = editor.calcAlignCenterStyle( + { id: 'aa', style: { width: 100, position: 'absolute' } } as any, + { id: 'p', style: { width: 300 } } as any, + Layout.ABSOLUTE, + doc, + ); + expect(result?.left).toBe(100); + expect(result?.right).toBe(''); + }); + + test('提供 doc + Layout.FIXED 用 doc.body 作为 parent', () => { + const doc = document.implementation.createHTMLDocument(); + Object.defineProperty(doc.body, 'clientWidth', { value: 500, configurable: true }); + const child = doc.createElement('div'); + child.dataset.tmagicId = 'bb'; + Object.defineProperty(child, 'clientWidth', { value: 100 }); + doc.body.appendChild(child); + const result = editor.calcAlignCenterStyle( + { id: 'bb', style: { width: 100, position: 'fixed' } } as any, + { id: 'p', style: { width: 500 } } as any, + Layout.FIXED, + doc, + ); + expect(result?.left).toBe(200); + }); +}); + +describe('补充:change2Fixed 边界', () => { + test('遇到祖先有 right/非数字 left 时 offset 重置为 0', () => { + const root: MApp = { + id: 'app', + type: NodeType.ROOT, + items: [ + { + id: 'p1', + type: NodeType.PAGE, + style: { right: 10 }, + items: [{ id: 'b', type: 'text', style: { position: 'absolute', left: 5, top: 5 } }], + } as any, + ], + }; + const node = root.items[0]!.items![0] as MNode; + const style = editor.change2Fixed(node, root); + expect(style.left).toBe(5); + }); + + test('节点本身有 right 时不累加 left', () => { + const root: MApp = { + id: 'app', + type: NodeType.ROOT, + items: [ + { + id: 'p1', + type: NodeType.PAGE, + style: { left: 10, top: 20 }, + items: [{ id: 'b', type: 'text', style: { position: 'absolute', right: 5 } }], + } as any, + ], + }; + const node = root.items[0]!.items![0] as MNode; + const style = editor.change2Fixed(node, root); + expect(style.left).toBeUndefined(); + }); +}); + +describe('补充:classifyDragSources 同父容器索引异常', () => { + test('当 getNodeIndex 返回 -1 时 aborted=true', () => { + const targetParent: MContainer = { id: 't', type: NodeType.CONTAINER, items: [] }; + const result = editor.classifyDragSources([{ id: 'x', type: 'text' }], targetParent, ((id: any) => ({ + node: { id, type: 'text' }, + parent: targetParent, + page: null, + })) as any); + expect(result.aborted).toBe(true); + }); +}); + +describe('toggleFixedPosition', () => { + test('fixed -> 非 fixed 触发 Fixed2Other', async () => { + const root: MApp = { + id: 'app', + type: NodeType.ROOT, + items: [{ id: 'p1', type: NodeType.PAGE, items: [] } as any], + }; + const result = await editor.toggleFixedPosition( + { id: 'a', type: 'text', style: { position: 'absolute', top: 0, left: 0 } } as any, + { id: 'a', type: 'text', style: { position: 'fixed' } } as any, + root, + async () => Layout.ABSOLUTE, + ); + expect(result.style?.position).toBeDefined(); + }); + + test('非 fixed -> fixed 触发 change2Fixed', async () => { + const root: MApp = { + id: 'app', + type: NodeType.ROOT, + items: [ + { + id: 'p1', + type: NodeType.PAGE, + style: { left: 0, top: 0 }, + items: [{ id: 'a', type: 'text', style: { position: 'fixed', left: 0, top: 0 } }], + } as any, + ], + }; + const result = await editor.toggleFixedPosition( + { id: 'a', type: 'text', style: { position: 'fixed', left: 0, top: 0 } } as any, + { id: 'a', type: 'text', style: { position: 'absolute' } } as any, + root, + async () => Layout.ABSOLUTE, + ); + expect(result.style?.position).toBe('fixed'); + }); +}); diff --git a/packages/editor/tests/unit/utils/keybinding-config.spec.ts b/packages/editor/tests/unit/utils/keybinding-config.spec.ts new file mode 100644 index 00000000..dd6a435d --- /dev/null +++ b/packages/editor/tests/unit/utils/keybinding-config.spec.ts @@ -0,0 +1,24 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { describe, expect, test } from 'vitest'; + +import keybindingConfig, { KeyBindingContainerKey } from '@editor/utils/keybinding-config'; + +describe('keybinding-config', () => { + test('每条配置都包含 command/keybinding/when', () => { + expect(Array.isArray(keybindingConfig)).toBe(true); + keybindingConfig.forEach((item) => { + expect(item.command).toBeDefined(); + expect(item.keybinding).toBeDefined(); + expect(Array.isArray(item.when)).toBe(true); + }); + }); + + test('KeyBindingContainerKey 枚举', () => { + expect(KeyBindingContainerKey.STAGE).toBe('stage'); + expect(KeyBindingContainerKey.LAYER_PANEL).toBe('layer-panel'); + }); +}); diff --git a/packages/editor/tests/unit/utils/logger.spec.ts b/packages/editor/tests/unit/utils/logger.spec.ts new file mode 100644 index 00000000..9793e57d --- /dev/null +++ b/packages/editor/tests/unit/utils/logger.spec.ts @@ -0,0 +1,52 @@ +/* + * 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 * as logger from '@editor/utils/logger'; + +describe('logger', () => { + let prev: string | undefined; + beforeEach(() => { + prev = process.env.NODE_ENV; + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'info').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'debug').mockImplementation(() => undefined); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + afterEach(() => { + process.env.NODE_ENV = prev; + vi.restoreAllMocks(); + }); + + test('NODE_ENV=development 时所有方法走 console', () => { + process.env.NODE_ENV = 'development'; + logger.log('a'); + logger.info('a'); + logger.warn('a'); + logger.debug('a'); + logger.error('a'); + expect(console.log as any).toHaveBeenCalled(); + expect(console.info as any).toHaveBeenCalled(); + expect(console.warn as any).toHaveBeenCalled(); + expect(console.debug as any).toHaveBeenCalled(); + expect(console.error as any).toHaveBeenCalled(); + }); + + test('生产环境无输出', () => { + process.env.NODE_ENV = 'production'; + logger.log('a'); + logger.info('a'); + logger.warn('a'); + logger.debug('a'); + logger.error('a'); + expect(console.log as any).not.toHaveBeenCalled(); + expect(console.info as any).not.toHaveBeenCalled(); + expect(console.warn as any).not.toHaveBeenCalled(); + expect(console.debug as any).not.toHaveBeenCalled(); + expect(console.error as any).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/editor/tests/unit/utils/monaco-editor.spec.ts b/packages/editor/tests/unit/utils/monaco-editor.spec.ts new file mode 100644 index 00000000..a7af2c3c --- /dev/null +++ b/packages/editor/tests/unit/utils/monaco-editor.spec.ts @@ -0,0 +1,31 @@ +/* + * 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 getMonaco from '@editor/utils/monaco-editor'; + +vi.mock('emmet-monaco-es', () => ({ + emmetHTML: vi.fn(), + emmetCSS: vi.fn(), +})); + +vi.mock('monaco-editor', () => ({ + default: { editor: {}, languages: {} }, + editor: {}, + languages: {}, +})); + +describe('monaco-editor 加载器', () => { + test('返回 Promise,且会缓存复用同一个实例', async () => { + const p1 = getMonaco(); + const p2 = getMonaco(); + expect(p1).toBe(p2); + const monaco = await p1; + const emmet = await import('emmet-monaco-es'); + expect(emmet.emmetHTML).toHaveBeenCalledWith(monaco); + expect(emmet.emmetCSS).toHaveBeenCalledWith(monaco, ['css', 'scss']); + }); +}); diff --git a/packages/editor/tests/unit/utils/operator.spec.ts b/packages/editor/tests/unit/utils/operator.spec.ts new file mode 100644 index 00000000..3e973eb6 --- /dev/null +++ b/packages/editor/tests/unit/utils/operator.spec.ts @@ -0,0 +1,180 @@ +/* + * 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 { NodeType } from '@tmagic/core'; + +import editorService from '@editor/services/editor'; +import propsService from '@editor/services/props'; +import { beforePaste, getAddParent, getDefaultConfig, getPositionInContainer } from '@editor/utils/operator'; + +const { editorState } = vi.hoisted(() => { + const state: Record = {}; + return { editorState: state }; +}); + +vi.mock('@editor/services/editor', () => ({ + default: { + get: (k: string) => editorState[k], + getParentById: vi.fn((id: string) => ({ id: `parent-of-${id}`, items: [] })), + getLayout: vi.fn(async () => 'absolute'), + }, +})); + +vi.mock('@editor/services/props', () => ({ + default: { + setNewItemId: vi.fn((node: any) => ({ ...node, id: `new-${node.id}` })), + getPropsValue: vi.fn(async (type: string, cfg: any) => ({ type, ...cfg })), + }, +})); + +vi.mock('@tmagic/utils', async () => { + const actual = (await vi.importActual('@tmagic/utils')) as Record; + return { + ...actual, + calcValueByFontsize: (_doc: any, val: number) => val, + getElById: () => () => ({ + getBoundingClientRect: () => ({ left: 5, top: 7 }), + }), + }; +}); + +beforeEach(() => { + Object.keys(editorState).forEach((k) => delete editorState[k]); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('operator.beforePaste', () => { + test('config[0] 无 style 时直接返回原数组', () => { + const config = [{ id: 'a', type: 'text' }] as any; + expect(beforePaste({}, config)).toBe(config); + }); + + test('正常路径:基于第一个元素重定位坐标,并 setNewItemId', () => { + const config = [ + { id: 'n1', type: 'text', style: { left: 10, top: 20 } }, + { id: 'n2', type: 'text', style: { left: 30, top: 50 } }, + ] as any; + + const result = beforePaste({ left: 100, top: 200, offsetX: 5, offsetY: 6 } as any, config); + + expect(result[0].id).toBe('new-n1'); + expect(result[0].style.left).toBe(100); + expect(result[0].style.top).toBe(200); + expect(result[1].style.left).toBe(120); + expect(result[1].style.top).toBe(230); + }); + + test('粘贴时选中容器:将坐标换算到容器内', () => { + editorState.node = { id: 'container', items: [] }; + editorState.stage = { + renderer: { contentWindow: { document: {} } }, + }; + const config = [{ id: 'n1', type: 'text', style: { left: 10, top: 20 } }] as any; + const result = beforePaste({ left: 100, top: 200 } as any, config); + expect(result[0].style.left).toBe(95); + expect(result[0].style.top).toBe(193); + }); + + test('页面节点粘贴时,会通过 generatePageNameByApp 重命名', () => { + editorState.root = { id: 'app', type: NodeType.ROOT, items: [{ name: 'page_1' }] }; + const config = [{ id: 'p1', type: NodeType.PAGE, style: { left: 0, top: 0 }, name: 'old' }] as any; + const result = beforePaste({}, config); + expect((result[0] as any).type).toBe(NodeType.PAGE); + expect((result[0] as any).name).toMatch(/page_/); + }); + + test('粘贴片段节点时也会被重命名', () => { + editorState.root = { id: 'app', type: NodeType.ROOT, items: [] }; + const config = [{ id: 'pf1', type: NodeType.PAGE_FRAGMENT, style: { left: 0, top: 0 }, name: 'pf' }] as any; + const result = beforePaste({}, config); + expect((result[0] as any).name).toMatch(/^page-fragment_/); + }); + + test('style.left/top 为字符串数字时也会做偏移', () => { + const config = [{ id: 'n1', type: 'text', style: { left: '10', top: '20' } }] as any; + const result = beforePaste({ offsetX: 3, offsetY: 4 } as any, config); + expect(result[0].style.left).toBe(13); + expect(result[0].style.top).toBe(24); + }); + + test('style.left/top 不能转换为数字时保持原值', () => { + const config = [{ id: 'n1', type: 'text', style: { left: 'abc', top: 'xyz' } }] as any; + const result = beforePaste({ offsetX: 3, offsetY: 4 } as any, config); + expect(result[0].style.left).toBe('abc'); + expect(result[0].style.top).toBe('xyz'); + }); + + test('pasteConfig 没有 style 时不会出错', () => { + const original = (propsService.setNewItemId as any).getMockImplementation(); + (propsService.setNewItemId as any).mockImplementationOnce((node: any) => ({ + ...node, + id: 'new', + style: undefined, + })); + const config = [{ id: 'n1', type: 'text', style: { left: 0, top: 0 } }] as any; + const result = beforePaste({}, config); + expect(result[0].style).toBeUndefined(); + if (original) (propsService.setNewItemId as any).mockImplementation(original); + }); +}); + +describe('operator.getPositionInContainer', () => { + test('未提供 stage 时仅返回原始坐标', () => { + editorState.stage = undefined; + const pos = getPositionInContainer({ left: 10, top: 20 }, 'x'); + expect(pos).toEqual({ left: 10, top: 20 }); + }); + + test('stage 中找到元素后做偏移修正', () => { + editorState.stage = { renderer: { contentWindow: { document: {} } } }; + const pos = getPositionInContainer({ left: 100, top: 200 }, 'id'); + expect(pos).toEqual({ left: 95, top: 193 }); + }); + + test('未传 position 时使用默认 0', () => { + editorState.stage = undefined; + const pos = getPositionInContainer(undefined, 'id'); + expect(pos).toEqual({ left: 0, top: 0 }); + }); +}); + +describe('operator.getAddParent', () => { + test('页面节点:parent 为 root', () => { + editorState.root = { id: 'app', items: [] }; + editorState.node = { id: 'cur' }; + const parent = getAddParent({ id: 'p1', type: NodeType.PAGE } as any); + expect(parent).toEqual(editorState.root); + }); + + test('当前选中容器:直接返回容器', () => { + editorState.node = { id: 'container', items: [] }; + const parent = getAddParent({ id: 'n1', type: 'text' } as any); + expect(parent).toEqual(editorState.node); + }); + + test('当前选中节点不是容器:通过 getParentById 找父节点', () => { + editorState.node = { id: 'leaf' }; + const parent = getAddParent({ id: 'n1', type: 'text' } as any); + expect((parent as any).id).toBe('parent-of-leaf'); + }); + + test('curNode 为空时返回 undefined', () => { + expect(getAddParent({ id: 'n1', type: 'text' } as any)).toBeUndefined(); + }); +}); + +describe('operator.getDefaultConfig', () => { + test('合并 layout 与 propsService 返回值', async () => { + const newNode = await getDefaultConfig({ type: 'text', x: 1 } as any, { id: 'parent', items: [] } as any); + expect(newNode.type).toBe('text'); + expect(editorService.getLayout).toHaveBeenCalled(); + expect(newNode.style).toBeDefined(); + }); +}); diff --git a/packages/editor/tests/unit/utils/props-config.spec.ts b/packages/editor/tests/unit/utils/props-config.spec.ts new file mode 100644 index 00000000..39c638a5 --- /dev/null +++ b/packages/editor/tests/unit/utils/props-config.spec.ts @@ -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 { NODE_CONDS_RESULT_KEY } from '@tmagic/core'; + +import { + advancedTabConfig, + arrayOptions, + displayTabConfig, + eqOptions, + eventTabConfig, + fillConfig, + numberOptions, + styleTabConfig, +} from '@editor/utils/props'; + +vi.mock('@tmagic/design', () => ({ + tMagicMessage: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe('props 选项常量', () => { + test('eqOptions / arrayOptions / numberOptions 内容稳定', () => { + expect(eqOptions.map((o) => o.value)).toEqual(['=', '!=']); + expect(arrayOptions.map((o) => o.value)).toEqual(['include', 'not_include']); + expect(numberOptions.map((o) => o.value)).toEqual(['>', '>=', '<', '<=', 'between', 'not_between']); + }); + + test('styleTabConfig / eventTabConfig / displayTabConfig 基础结构', () => { + expect(styleTabConfig.title).toBe('样式'); + expect(eventTabConfig.title).toBe('事件'); + expect(displayTabConfig.title).toBe('显示条件'); + }); + + test('advancedTabConfig 中包含 created/mounted/display', () => { + const names = (advancedTabConfig.items as any[]).map((i) => i.name); + expect(names).toEqual(expect.arrayContaining(['created', 'mounted', 'display'])); + }); +}); + +describe('fillConfig', () => { + test('默认会补充 type/id/name 三个基础字段', () => { + const result = fillConfig() as any; + const tab = result[0]; + const propsTab = tab.items[0]; + const names = propsTab.items.map((i: any) => i.name); + expect(names).toEqual(expect.arrayContaining(['type', 'id', 'name'])); + }); + + test('已有 id/type/name 时不重复添加', () => { + const result = fillConfig([ + { name: 'id', text: 'id', type: 'text' } as any, + { name: 'type', text: 'type', type: 'hidden' } as any, + { name: 'name', text: 'name' } as any, + ]) as any; + const propsTab = result[0].items[0]; + const idItems = propsTab.items.filter((i: any) => i.name === 'id'); + expect(idItems).toHaveLength(1); + }); + + test('disabledDataSource=true 时不追加 displayTab', () => { + const result = fillConfig([], { disabledDataSource: true }) as any; + const titles = result[0].items.map((i: any) => i.title); + expect(titles).not.toContain('显示条件'); + }); + + test('支持自定义 labelWidth', () => { + const result = fillConfig([], { labelWidth: '120px' }) as any; + expect(result[0].labelWidth).toBe('120px'); + }); + + test('styleTabConfig.display 跟随 uiService showStylePanel', () => { + const services = { uiService: { get: () => false } }; + expect(styleTabConfig.display!({ services } as any)).toBe(true); + services.uiService.get = () => true; + expect(styleTabConfig.display!({ services } as any)).toBe(false); + services.uiService.get = () => undefined; + expect(styleTabConfig.display!({ services } as any)).toBe(false); + }); + + test('styleTabConfig 内 transform 项 defaultValue 返回 {}', () => { + const styleField = (styleTabConfig.items as any[])[0]; + const transform = styleField.items.find((i: any) => i.name === 'transform'); + expect(transform.defaultValue()).toEqual({}); + }); + + test('displayTabConfig.display 当节点为 page 时返回 false', () => { + expect(displayTabConfig.display!({} as any, { model: { type: 'page' } } as any)).toBe(false); + expect(displayTabConfig.display!({} as any, { model: { type: 'text' } } as any)).toBe(true); + }); + + test('displayTabConfig select 项 extra 文案随 NODE_CONDS_RESULT_KEY 变化', () => { + const selectItem = (displayTabConfig.items as any[]).find((i) => i.type === 'select'); + expect(typeof selectItem.extra).toBe('function'); + const text1 = selectItem.extra({}, { model: { [NODE_CONDS_RESULT_KEY]: true } }); + const text2 = selectItem.extra({}, { model: { [NODE_CONDS_RESULT_KEY]: false } }); + expect(text1).toContain('隐藏'); + expect(text2).toContain('显示'); + }); + + test('id 字段 append.handler 触发 navigator.clipboard.writeText', async () => { + const result = fillConfig() as any; + const propsTab = result[0].items[0]; + const idField = propsTab.items.find((i: any) => i.name === 'id'); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + await idField.append.handler({}, { model: { id: 'abc' } }); + expect(writeText).toHaveBeenCalledWith('abc'); + }); + + test('id 字段 append.handler 复制失败时调用 error', async () => { + const result = fillConfig() as any; + const propsTab = result[0].items[0]; + const idField = propsTab.items.find((i: any) => i.name === 'id'); + const writeText = vi.fn().mockRejectedValue(new Error('fail')); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + await idField.append.handler({}, { model: { id: 'xx' } }); + expect(writeText).toHaveBeenCalled(); + }); + + test('disabledCodeBlock=true 时高级 tab 仅保留非 code-select 项', () => { + const result = fillConfig([], { disabledCodeBlock: true }) as any; + const titles = result[0].items.map((i: any) => i.title); + expect(titles).toContain('高级'); + const advanced = result[0].items.find((i: any) => i.title === '高级'); + advanced.items.forEach((it: any) => { + expect(it.type).not.toBe('code-select'); + }); + }); + + test('advancedTabConfig 中所有 type=code-select 都有 labelPosition=top', () => { + const codeSelects = (advancedTabConfig.items as any[]).filter((i) => i.type === 'code-select'); + codeSelects.forEach((cs: any) => { + expect(cs.labelPosition).toBe('top'); + }); + }); +}); diff --git a/packages/editor/tests/unit/utils/scroll-viewer.spec.ts b/packages/editor/tests/unit/utils/scroll-viewer.spec.ts new file mode 100644 index 00000000..a29a0f5e --- /dev/null +++ b/packages/editor/tests/unit/utils/scroll-viewer.spec.ts @@ -0,0 +1,123 @@ +/* + * 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 { ScrollViewer } from '@editor/utils/scroll-viewer'; + +const setupBoundingRect = (el: HTMLElement, rect: Partial) => { + el.getBoundingClientRect = () => ({ + width: 0, + height: 0, + top: 0, + left: 0, + right: 0, + bottom: 0, + x: 0, + y: 0, + toJSON: () => ({}), + ...rect, + }); +}; + +describe('ScrollViewer', () => { + let container: HTMLDivElement; + let target: HTMLDivElement; + let viewer: ScrollViewer; + + beforeEach(() => { + container = document.createElement('div'); + target = document.createElement('div'); + document.body.appendChild(container); + container.appendChild(target); + setupBoundingRect(container, { width: 200, height: 200 }); + setupBoundingRect(target, { width: 400, height: 400 }); + }); + + afterEach(() => { + viewer?.destroy(); + document.body.innerHTML = ''; + }); + + test('实例化与 scrollTo 应用 transform', () => { + viewer = new ScrollViewer({ container, target, zoom: 1 }); + viewer.scrollTo({ left: 50, top: 30 }); + expect(target.style.transform).toContain('translate'); + }); + + test('setZoom 改变缩放并更新 scroll 尺寸', () => { + viewer = new ScrollViewer({ container, target, zoom: 1 }); + viewer.setZoom(2); + }); + + test('wheel 事件触发 scroll 事件回调', () => { + viewer = new ScrollViewer({ container, target, zoom: 1 }); + const spy = vi.fn(); + viewer.on('scroll', spy); + + const wheel = new WheelEvent('wheel', { deltaX: 10, deltaY: 10, bubbles: true }); + container.dispatchEvent(wheel); + expect(spy).toHaveBeenCalled(); + }); + + test('correctionScrollSize 影响 scrollSize', () => { + viewer = new ScrollViewer({ + container, + target, + zoom: 1, + correctionScrollSize: { width: 100, height: 50 }, + }); + expect(viewer).toBeInstanceOf(ScrollViewer); + }); + + test('destroy 解除事件监听', () => { + viewer = new ScrollViewer({ container, target, zoom: 1 }); + viewer.destroy(); + const spy = vi.fn(); + viewer.on('scroll', spy); + container.dispatchEvent(new WheelEvent('wheel', { deltaY: 100, bubbles: true })); + expect(spy).not.toHaveBeenCalled(); + }); + + test('wheel 事件 currentTarget 不匹配时直接返回', () => { + viewer = new ScrollViewer({ container, target, zoom: 1 }); + const spy = vi.fn(); + viewer.on('scroll', spy); + spy.mockClear(); + const inner = document.createElement('div'); + container.appendChild(inner); + const wheel = new WheelEvent('wheel', { deltaY: 100, bubbles: true }); + Object.defineProperty(wheel, 'currentTarget', { value: inner }); + container.dispatchEvent(wheel); + }); + + test('wheel 滚动到顶端边界时不再继续向上', () => { + setupBoundingRect(target, { width: 400, height: 400 }); + viewer = new ScrollViewer({ container, target, zoom: 1 }); + container.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, bubbles: true })); + container.dispatchEvent(new WheelEvent('wheel', { deltaY: 50, bubbles: true })); + container.dispatchEvent(new WheelEvent('wheel', { deltaY: -1000, bubbles: true })); + }); + + test('scrollTo 仅传 left 或 top 也能工作', () => { + viewer = new ScrollViewer({ container, target, zoom: 1 }); + viewer.scrollTo({ left: 10 }); + viewer.scrollTo({ top: 20 }); + expect(target.style.transform).toContain('translate'); + }); + + test('zoom 变化触发 setScrollSize 缩进路径', () => { + setupBoundingRect(container, { width: 1000, height: 1000 }); + setupBoundingRect(target, { width: 100, height: 100 }); + viewer = new ScrollViewer({ container, target, zoom: 1 }); + viewer.setZoom(0.5); + }); + + test('target.style.marginTop 影响 scrollHeight 计算', () => { + target.style.marginTop = '50'; + viewer = new ScrollViewer({ container, target, zoom: 1 }); + container.dispatchEvent(new WheelEvent('wheel', { deltaY: 1000, bubbles: true })); + }); +}); diff --git a/packages/editor/tests/unit/utils/undo-redo.spec.ts b/packages/editor/tests/unit/utils/undo-redo.spec.ts index ea4ec818..44266f93 100644 --- a/packages/editor/tests/unit/utils/undo-redo.spec.ts +++ b/packages/editor/tests/unit/utils/undo-redo.spec.ts @@ -149,4 +149,15 @@ describe('list max size', () => { expect(undoRedo.canUndo()).toBe(false); expect(undoRedo.getCurrentElement()).toEqual(null); }); + + test('listMaxSize 小于最小值时回退到最小值 2', () => { + const small = new UndoRedo(1); + small.pushElement({ a: 1 }); + small.pushElement({ a: 2 }); + small.pushElement({ a: 3 }); + expect(small.getCurrentElement()).toEqual({ a: 3 }); + expect(small.undo()).toEqual({ a: 3 }); + expect(small.undo()).toEqual({ a: 2 }); + expect(small.canUndo()).toBe(false); + }); }); diff --git a/packages/form/tests/unit/FormDialog.spec.ts b/packages/form/tests/unit/FormDialog.spec.ts new file mode 100644 index 00000000..f98ed460 --- /dev/null +++ b/packages/form/tests/unit/FormDialog.spec.ts @@ -0,0 +1,61 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { describe, expect, test } from 'vitest'; +import { nextTick } from 'vue'; +import MagicForm, { MFormBox, MFormDialog, MFormDrawer } from '@form/index'; +import { mount } from '@vue/test-utils'; +import ElementPlus from 'element-plus'; + +describe('FormDialog/FormDrawer/FormBox', () => { + test('FormDialog 基础渲染', async () => { + const wrapper = mount(MFormDialog, { + attachTo: document.body, + global: { + plugins: [ElementPlus as any, MagicForm as any], + }, + props: { + title: 'dialog-title', + config: [{ name: 'text', type: 'text', text: 'text' }], + values: { text: 'hello' }, + }, + }); + await nextTick(); + expect(wrapper.exists()).toBe(true); + wrapper.unmount(); + }); + + test('FormDrawer 基础渲染', async () => { + const wrapper = mount(MFormDrawer, { + attachTo: document.body, + global: { + plugins: [ElementPlus as any, MagicForm as any], + }, + props: { + title: 'drawer', + config: [{ name: 'text', type: 'text', text: 'text' }], + values: { text: 'world' }, + }, + }); + await nextTick(); + expect(wrapper.exists()).toBe(true); + wrapper.unmount(); + }); + + test('FormBox 基础渲染', async () => { + const wrapper = mount(MFormBox, { + global: { + plugins: [ElementPlus as any, MagicForm as any], + }, + props: { + config: [{ name: 'text', type: 'text', text: 'text' }], + initValues: { text: 'box' }, + }, + }); + await nextTick(); + expect(wrapper.exists()).toBe(true); + wrapper.unmount(); + }); +}); diff --git a/packages/form/tests/unit/containers/GroupList.spec.ts b/packages/form/tests/unit/containers/GroupList.spec.ts new file mode 100644 index 00000000..97804d13 --- /dev/null +++ b/packages/form/tests/unit/containers/GroupList.spec.ts @@ -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 } from 'vitest'; +import { nextTick } from 'vue'; +import MagicForm, { MForm } from '@form/index'; +import { mount } from '@vue/test-utils'; +import ElementPlus from 'element-plus'; + +const mountForm = (config: any[], initValues: any = {}) => + mount(MForm, { + global: { plugins: [ElementPlus as any, MagicForm as any] }, + props: { config, initValues }, + }); + +describe('GroupList container', () => { + test('空数据时显示暂无数据', async () => { + const wrapper = mountForm( + [ + { + type: 'group-list', + name: 'list', + items: [{ name: 'text', type: 'text', text: 'text' }], + }, + ], + { list: [] }, + ); + await nextTick(); + expect(wrapper.text()).toContain('暂无数据'); + }); + + test('有数据时渲染列表项', async () => { + const wrapper = mountForm( + [ + { + type: 'group-list', + name: 'list', + items: [{ name: 'text', type: 'text', text: 'text' }], + }, + ], + { list: [{ text: 'a' }, { text: 'b' }] }, + ); + await nextTick(); + expect(wrapper.findAllComponents({ name: 'MFormGroupList' })).toHaveLength(1); + }); + + test('extra 字段渲染 HTML', async () => { + const wrapper = mountForm( + [ + { + type: 'group-list', + name: 'list', + extra: 'tip', + items: [{ name: 'text', type: 'text' }], + }, + ], + { list: [] }, + ); + await nextTick(); + expect(wrapper.html()).toContain('tip'); + }); +}); diff --git a/packages/form/tests/unit/containers/Panel.spec.ts b/packages/form/tests/unit/containers/Panel.spec.ts new file mode 100644 index 00000000..5648b715 --- /dev/null +++ b/packages/form/tests/unit/containers/Panel.spec.ts @@ -0,0 +1,76 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { describe, expect, test } from 'vitest'; +import { nextTick } from 'vue'; +import MagicForm, { MForm } from '@form/index'; +import { mount } from '@vue/test-utils'; +import ElementPlus from 'element-plus'; + +const mountForm = (config: any[], initValues: any = {}) => + mount(MForm, { + global: { plugins: [ElementPlus as any, MagicForm as any] }, + props: { config, initValues }, + }); + +describe('Panel container', () => { + test('panel 渲染并展示子项', async () => { + const wrapper = mountForm( + [ + { + type: 'panel', + title: 'group', + items: [{ name: 'text', type: 'text', text: 'text' }], + }, + ], + { text: 'hello' }, + ); + await nextTick(); + expect(wrapper.text()).toContain('group'); + }); + + test('row 容器渲染', async () => { + const wrapper = mountForm( + [ + { + type: 'row', + items: [{ name: 'text', type: 'text', text: 'text' }], + }, + ], + { text: 'r' }, + ); + await nextTick(); + expect(wrapper.exists()).toBe(true); + }); + + test('fieldset 容器渲染', async () => { + const wrapper = mountForm( + [ + { + type: 'fieldset', + legend: 'fs', + items: [{ name: 'text', type: 'text', text: 'text' }], + }, + ], + { text: 'fs' }, + ); + await nextTick(); + expect(wrapper.exists()).toBe(true); + }); + + test('flex-layout 容器渲染', async () => { + const wrapper = mountForm( + [ + { + type: 'flex-layout', + items: [{ name: 'text', type: 'text', text: 'text' }], + }, + ], + { text: 'fl' }, + ); + await nextTick(); + expect(wrapper.exists()).toBe(true); + }); +}); diff --git a/packages/form/tests/unit/containers/Step.spec.ts b/packages/form/tests/unit/containers/Step.spec.ts new file mode 100644 index 00000000..c7d9befb --- /dev/null +++ b/packages/form/tests/unit/containers/Step.spec.ts @@ -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 } from 'vitest'; +import { nextTick } from 'vue'; +import MagicForm, { MForm } from '@form/index'; +import { mount } from '@vue/test-utils'; +import ElementPlus from 'element-plus'; + +const mountForm = (config: any[], initValues: any = {}, props: any = {}) => + mount(MForm, { + global: { plugins: [ElementPlus as any, MagicForm as any] }, + props: { config, initValues, ...props }, + }); + +describe('Step container', () => { + test('step 渲染并显示当前 step 子项', async () => { + const wrapper = mountForm( + [ + { + type: 'step', + stepActive: 1, + items: [ + { + title: 'Step 1', + name: 's1', + items: [{ name: 'text', type: 'text', text: 'text' }], + }, + { + title: 'Step 2', + name: 's2', + items: [{ name: 'text2', type: 'text', text: 'text' }], + }, + ], + }, + ], + { s1: { text: 'a' }, s2: { text: 'b' } }, + { stepActive: 1 }, + ); + await nextTick(); + expect(wrapper.text()).toContain('Step 1'); + expect(wrapper.text()).toContain('Step 2'); + }); +}); diff --git a/packages/form/tests/unit/fields/Cascader.spec.ts b/packages/form/tests/unit/fields/Cascader.spec.ts new file mode 100644 index 00000000..3e40b76f --- /dev/null +++ b/packages/form/tests/unit/fields/Cascader.spec.ts @@ -0,0 +1,71 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { describe, expect, test } from 'vitest'; +import { nextTick } from 'vue'; +import MagicForm, { MCascader, MForm } from '@form/index'; +import { mount } from '@vue/test-utils'; +import ElementPlus from 'element-plus'; + +const mountForm = (config: any[], initValues: any = {}) => + mount(MForm, { + global: { plugins: [ElementPlus as any, MagicForm as any] }, + props: { config, initValues }, + }); + +describe('Cascader', () => { + test('基础数组 options 渲染', async () => { + const wrapper = mountForm( + [ + { + name: 'cas', + type: 'cascader', + text: 'cas', + options: [ + { value: 'a', label: 'A', children: [{ value: 'a1', label: 'A1' }] }, + { value: 'b', label: 'B' }, + ], + }, + ], + { cas: ['a', 'a1'] }, + ); + await nextTick(); + expect(wrapper.findComponent(MCascader).exists()).toBe(true); + }); + + test('valueSeparator 时拆分为数组', async () => { + const wrapper = mountForm( + [ + { + name: 'cas', + type: 'cascader', + text: 'cas', + valueSeparator: ',', + options: [{ value: 'a', label: 'A', children: [{ value: 'a1', label: 'A1' }] }], + }, + ], + { cas: 'a,a1' }, + ); + await nextTick(); + expect(wrapper.findComponent(MCascader).exists()).toBe(true); + }); + + test('options 是函数时异步获取', async () => { + const wrapper = mountForm( + [ + { + name: 'cas', + type: 'cascader', + text: 'cas', + options: async () => [{ value: 'x', label: 'X' }], + }, + ], + { cas: [] }, + ); + await nextTick(); + await nextTick(); + expect(wrapper.findComponent(MCascader).exists()).toBe(true); + }); +}); diff --git a/packages/form/tests/unit/fields/NumberRange.spec.ts b/packages/form/tests/unit/fields/NumberRange.spec.ts new file mode 100644 index 00000000..f3451660 --- /dev/null +++ b/packages/form/tests/unit/fields/NumberRange.spec.ts @@ -0,0 +1,55 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { describe, expect, test } from 'vitest'; +import { nextTick } from 'vue'; +import MagicForm, { MForm, MNumberRange } from '@form/index'; +import { mount } from '@vue/test-utils'; +import ElementPlus from 'element-plus'; + +const getWrapper = (initValues: any = { range: [10, 20] }) => + mount(MForm, { + global: { plugins: [ElementPlus as any, MagicForm as any] }, + props: { + initValues, + config: [ + { + text: 'range', + name: 'range', + type: 'number-range', + }, + ], + }, + }); + +describe('NumberRange', () => { + test('基础渲染', async () => { + const wrapper = getWrapper(); + await nextTick(); + expect(wrapper.findComponent(MNumberRange).exists()).toBe(true); + }); + + test('change first 触发 emit', async () => { + const wrapper = getWrapper(); + await nextTick(); + const inputs = wrapper.findAll('input'); + await inputs[0].setValue('100'); + await inputs[0].trigger('change'); + const value = await (wrapper.vm as any).submitForm(); + expect(value.range[0]).toBe(100); + }); + + test('initValues 不是数组时被自动初始化为 []', async () => { + const wrapper = mount(MForm, { + global: { plugins: [ElementPlus as any, MagicForm as any] }, + props: { + initValues: { range: 'not-array' }, + config: [{ name: 'range', text: 'range', type: 'number-range' }], + }, + }); + await nextTick(); + expect(wrapper.findComponent(MNumberRange).exists()).toBe(true); + }); +}); diff --git a/packages/form/tests/unit/fields/Select.spec.ts b/packages/form/tests/unit/fields/Select.spec.ts new file mode 100644 index 00000000..d7460d32 --- /dev/null +++ b/packages/form/tests/unit/fields/Select.spec.ts @@ -0,0 +1,96 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { describe, expect, test } from 'vitest'; +import { nextTick } from 'vue'; +import MagicForm, { MForm, MSelect } from '@form/index'; +import { mount } from '@vue/test-utils'; +import ElementPlus from 'element-plus'; + +const mountForm = (config: any[], initValues: any = {}) => + mount(MForm, { + global: { plugins: [ElementPlus as any, MagicForm as any] }, + props: { config, initValues }, + }); + +describe('Select', () => { + test('数组 options 渲染', async () => { + const wrapper = mountForm( + [ + { + name: 's', + type: 'select', + text: 's', + options: [ + { text: 'A', value: 'a' }, + { text: 'B', value: 'b' }, + ], + }, + ], + { s: 'a' }, + ); + await nextTick(); + expect(wrapper.findComponent(MSelect).exists()).toBe(true); + }); + + test('options 是函数', async () => { + const wrapper = mountForm( + [ + { + name: 's', + type: 'select', + text: 's', + options: () => [{ text: 'A', value: 'a' }], + }, + ], + { s: 'a' }, + ); + await nextTick(); + await nextTick(); + expect(wrapper.findComponent(MSelect).exists()).toBe(true); + }); + + test('group 形式 options', async () => { + const wrapper = mountForm( + [ + { + name: 's', + type: 'select', + text: 's', + group: true, + options: [ + { + label: 'g1', + options: [{ text: 'A', value: 'a' }], + }, + ], + }, + ], + { s: 'a' }, + ); + await nextTick(); + expect(wrapper.findComponent(MSelect).exists()).toBe(true); + }); + + test('multiple 多选', async () => { + const wrapper = mountForm( + [ + { + name: 's', + type: 'select', + text: 's', + multiple: true, + options: [ + { text: 'A', value: 'a' }, + { text: 'B', value: 'b' }, + ], + }, + ], + { s: ['a'] }, + ); + await nextTick(); + expect(wrapper.findComponent(MSelect).exists()).toBe(true); + }); +}); diff --git a/packages/form/tests/unit/fields/Timerange.spec.ts b/packages/form/tests/unit/fields/Timerange.spec.ts new file mode 100644 index 00000000..8c55c444 --- /dev/null +++ b/packages/form/tests/unit/fields/Timerange.spec.ts @@ -0,0 +1,33 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { describe, expect, test } from 'vitest'; +import { nextTick } from 'vue'; +import MagicForm, { MForm, MTimerange } from '@form/index'; +import { mount } from '@vue/test-utils'; +import ElementPlus from 'element-plus'; + +const mountForm = (config: any[], initValues: any) => + mount(MForm, { + global: { plugins: [ElementPlus as any, MagicForm as any] }, + props: { config, initValues }, + }); + +describe('Timerange', () => { + test('单 name 渲染', async () => { + const wrapper = mountForm([{ name: 'tr', type: 'timerange', text: 'tr' }], { tr: ['08:00:00', '20:00:00'] }); + await nextTick(); + expect(wrapper.findComponent(MTimerange).exists()).toBe(true); + }); + + test('双 names 渲染', async () => { + const wrapper = mountForm([{ name: 'tr', type: 'timerange', text: 'tr', names: ['start', 'end'] }], { + start: '08:00:00', + end: '20:00:00', + }); + await nextTick(); + expect(wrapper.findComponent(MTimerange).exists()).toBe(true); + }); +}); diff --git a/packages/form/tests/unit/utils/config.spec.ts b/packages/form/tests/unit/utils/config.spec.ts index c8cd4fe3..525bb098 100644 --- a/packages/form/tests/unit/utils/config.spec.ts +++ b/packages/form/tests/unit/utils/config.spec.ts @@ -16,7 +16,7 @@ * limitations under the License. */ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { getConfig, setConfig } from '@form/utils/config'; +import { deleteField, getConfig, getField, registerField, setConfig } from '@form/utils/config'; describe('config.ts', () => { beforeEach(() => { @@ -50,4 +50,17 @@ describe('config.ts', () => { test('在未设置时获取Config', () => { expect(getConfig('model')).toBeUndefined(); }); + + test('registerField/getField/deleteField 完整流程', () => { + const fakeComp: any = { name: 'fake' }; + registerField('fake-field', fakeComp); + expect(getField('fake-field')).toBe(fakeComp); + + registerField('fake-field', { name: 'other' } as any); + expect(getField('fake-field')).toBe(fakeComp); + + expect(deleteField('fake-field')).toBe(true); + expect(getField('fake-field')).toBeUndefined(); + expect(deleteField('fake-field')).toBe(false); + }); }); diff --git a/packages/stage/tests/unit/util.spec.ts b/packages/stage/tests/unit/util.spec.ts index fb2551fe..0616c453 100644 --- a/packages/stage/tests/unit/util.spec.ts +++ b/packages/stage/tests/unit/util.spec.ts @@ -66,9 +66,6 @@ const createElement = () => { }; describe('getOffset', () => { - /** - * @jest-environment jsdom - */ const root = globalThis.document.createElement('div'); const div = globalThis.document.createElement('div'); @@ -106,9 +103,6 @@ describe('getOffset', () => { }); describe('getAbsolutePosition', () => { - /** - * @jest-environment jsdom - */ const root = globalThis.document.createElement('div'); const div = globalThis.document.createElement('div'); @@ -155,3 +149,175 @@ describe('isFixed', () => { expect(util.isFixed({})).toBeFalsy(); }); }); + +describe('isAbsolute / isRelative / isStatic', () => { + test('isAbsolute', () => { + expect(util.isAbsolute({ position: 'absolute' })).toBe(true); + expect(util.isAbsolute({ position: 'fixed' })).toBe(false); + }); + test('isRelative', () => { + expect(util.isRelative({ position: 'relative' })).toBe(true); + expect(util.isRelative({})).toBe(false); + }); + test('isStatic', () => { + expect(util.isStatic({ position: 'static' })).toBe(true); + expect(util.isStatic({ position: 'absolute' })).toBe(false); + }); +}); + +describe('getMarginValue / getBorderWidth', () => { + test('getMarginValue 空元素返回 0', () => { + expect(util.getMarginValue(null as any)).toEqual({ marginLeft: 0, marginTop: 0 }); + }); + + test('getMarginValue 元素返回 px 数字', () => { + const el = globalThis.document.createElement('div'); + el.style.marginLeft = '5px'; + el.style.marginTop = '10px'; + globalThis.document.body.appendChild(el); + const result = util.getMarginValue(el); + expect(typeof result.marginLeft).toBe('number'); + expect(typeof result.marginTop).toBe('number'); + }); + + test('getBorderWidth 空元素返回 0', () => { + expect(util.getBorderWidth(null as any)).toEqual({ + borderLeftWidth: 0, + borderRightWidth: 0, + borderTopWidth: 0, + borderBottomWidth: 0, + }); + }); + + test('getBorderWidth 真实元素', () => { + const el = globalThis.document.createElement('div'); + el.style.borderLeftWidth = '1px'; + el.style.borderRightWidth = '2px'; + el.style.borderTopWidth = '3px'; + el.style.borderBottomWidth = '4px'; + globalThis.document.body.appendChild(el); + const result = util.getBorderWidth(el); + expect(typeof result.borderLeftWidth).toBe('number'); + }); +}); + +describe('isMoveableButton', () => { + test('元素自身有 moveable-button class', () => { + const el = globalThis.document.createElement('div'); + el.classList.add('moveable-button'); + expect(util.isMoveableButton(el)).toBe(true); + }); + test('父元素有 moveable-button class', () => { + const parent = globalThis.document.createElement('div'); + parent.classList.add('moveable-button'); + const child = globalThis.document.createElement('span'); + parent.appendChild(child); + expect(util.isMoveableButton(child)).toBe(true); + }); + test('既不在自身也不在父元素上', () => { + const el = globalThis.document.createElement('div'); + expect(util.isMoveableButton(el)).toBeFalsy(); + }); +}); + +describe('addSelectedClassName / removeSelectedClassName', () => { + test('add 与 remove 选中类名工作', () => { + const root = globalThis.document.createElement('div'); + const child = globalThis.document.createElement('div'); + root.appendChild(child); + globalThis.document.body.innerHTML = ''; + globalThis.document.body.appendChild(root); + + util.addSelectedClassName(child, globalThis.document); + expect(child.classList.contains('tmagic-stage-selected-area')).toBe(true); + + util.removeSelectedClassName(globalThis.document); + expect(child.classList.contains('tmagic-stage-selected-area')).toBe(false); + }); +}); + +describe('getMode', () => { + test('fixed 父元素返回 FIXED', () => { + const fixed = globalThis.document.createElement('div'); + fixed.style.position = 'fixed'; + const child = globalThis.document.createElement('div'); + fixed.appendChild(child); + globalThis.document.body.innerHTML = ''; + globalThis.document.body.appendChild(fixed); + const mode = util.getMode(child); + expect(mode).toBeDefined(); + }); + + test('relative/static 返回 SORTABLE 默认', () => { + const el = globalThis.document.createElement('div'); + el.style.position = 'relative'; + globalThis.document.body.innerHTML = ''; + globalThis.document.body.appendChild(el); + const mode = util.getMode(el); + expect(mode).toBeDefined(); + }); +}); + +describe('isFixedParent', () => { + test('元素本身或祖先含 fixed', () => { + const root = globalThis.document.createElement('div'); + root.style.position = 'fixed'; + const child = globalThis.document.createElement('div'); + root.appendChild(child); + globalThis.document.body.innerHTML = ''; + globalThis.document.body.appendChild(root); + expect(util.isFixedParent(child)).toBe(true); + }); + + test('没有 fixed 祖先返回 false', () => { + const el = globalThis.document.createElement('div'); + globalThis.document.body.innerHTML = ''; + globalThis.document.body.appendChild(el); + expect(util.isFixedParent(el)).toBe(false); + }); +}); + +describe('getTargetElStyle', () => { + test('返回包含 transform/border/width/height 的样式', () => { + const el = globalThis.document.createElement('div'); + el.style.cssText = 'width: 100px; height: 50px;'; + globalThis.document.body.appendChild(el); + const style = util.getTargetElStyle(el as any, 1 as any); + expect(style).toContain('width'); + expect(style).toContain('height'); + expect(style).toContain('z-index: 1'); + }); +}); + +describe('down / up 排序', () => { + const setupSiblings = () => { + const parent = globalThis.document.createElement('div'); + const a = globalThis.document.createElement('div'); + a.style.cssText = 'width:100px; height:50px'; + a.dataset.tmagicId = 'a'; + const b = globalThis.document.createElement('div'); + b.style.cssText = 'width:100px; height:50px'; + b.dataset.tmagicId = 'b'; + const c = globalThis.document.createElement('div'); + c.style.cssText = 'width:100px; height:50px'; + c.dataset.tmagicId = 'c'; + parent.appendChild(a); + parent.appendChild(b); + parent.appendChild(c); + return { parent, a, b, c }; + }; + + test('down 返回 src/dist', () => { + const { a } = setupSiblings(); + Object.defineProperty(a, 'clientHeight', { value: 50, configurable: true }); + const result = util.down(200, a); + expect(result.src).toBe('a'); + }); + + test('up 返回 src/dist', () => { + const { c } = setupSiblings(); + Object.defineProperty(c, 'clientHeight', { value: 50, configurable: true }); + const result = util.up(-200, c); + expect(result.src).toBe('c'); + }); +}); diff --git a/packages/table/tests/index.spec.ts b/packages/table/tests/index.spec.ts new file mode 100644 index 00000000..03c1ec98 --- /dev/null +++ b/packages/table/tests/index.spec.ts @@ -0,0 +1,30 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { describe, expect, test } from 'vitest'; + +import MagicTable, { createColumns, formatter, MagicTable as Table } from '../src/index'; + +describe('table 入口导出', () => { + test('插件 install 注册组件', () => { + const calls: any[] = []; + const fakeApp = { + component(name: string, comp: any) { + calls.push([name, comp]); + }, + } as any; + MagicTable.install(fakeApp); + expect(calls[0][0]).toBe('m-table'); + }); + + test('Table 组件存在', () => { + expect(Table).toBeDefined(); + }); + + test('createColumns / formatter 可正常使用', () => { + expect(createColumns([{ prop: 'a' }])).toHaveLength(1); + expect(formatter({ prop: 'a' }, { a: 'v' }, { index: 0 })).toBe('v'); + }); +}); diff --git a/packages/utils/tests/unit/extras.spec.ts b/packages/utils/tests/unit/extras.spec.ts index 134ae1b7..4f146788 100644 --- a/packages/utils/tests/unit/extras.spec.ts +++ b/packages/utils/tests/unit/extras.spec.ts @@ -200,6 +200,11 @@ describe('getDefaultValueFromFields 边界场景', () => { warnSpy.mockRestore(); }); + test('object 类型 defaultValue 既不是对象也不是字符串时退回 {}', () => { + const result = getDefaultValueFromFields([{ name: 'x', type: 'object', defaultValue: 123 } as any]); + expect(result.x).toEqual({}); + }); + test('boolean / number 类型默认为 undefined', () => { const result = getDefaultValueFromFields([ { name: 'b', type: 'boolean' }, @@ -409,6 +414,12 @@ describe('dom helpers', () => { doc.documentElement.style.fontSize = ''; }); + test('calcValueByFontsize: 没有 fontSize 时直接返回原值', () => { + if (!doc) return; + doc.documentElement.style.fontSize = ''; + expect(calcValueByFontsize(doc, 42)).toBe(42); + }); + test('dslDomRelateConfig get/set/getId', () => { if (!doc) return; const div = doc.createElement('div'); diff --git a/packages/utils/tests/unit/index.spec.ts b/packages/utils/tests/unit/index.spec.ts index 2d4f9808..0b2b4226 100644 --- a/packages/utils/tests/unit/index.spec.ts +++ b/packages/utils/tests/unit/index.spec.ts @@ -25,64 +25,101 @@ import * as util from '../../src'; describe('asyncLoadJs', () => { const url = 'https://m.www.tmagic.com/magic-ui/production/1/1625056093304/magic/magic-ui.umd.min.js'; - test('第一次加载asyncLoadJs带url与crossorigin参数', () => { - const crossOrigin = 'anonymous'; - const load = util.asyncLoadJs(url, crossOrigin); - load.then(() => { - const script = document.getElementsByTagName('script')[0]; - expect(script).not.toBeUndefined(); - expect(script.type).toMatch('text/javascript'); - // 设置了anonymous - expect(script.crossOrigin).toMatch(crossOrigin); - expect(script.src).toMatch(url); - }); - }); - - test('第二次加载asyncLoadJs', () => { - util.asyncLoadJs(url, 'anonymous').then(() => { - util.asyncLoadJs(url, 'use-credentials').then(() => { - const scriptList = document.getElementsByTagName('script'); - expect(scriptList.length).toBe(1); - expect(scriptList[0].crossOrigin).toMatch('anonymous'); - expect(scriptList[0].src).toMatch(url); + // 在测试环境中,浏览器不会真的加载外部 script。 + // 通过监听插入到 head 的 script 节点,手动派发 load 事件来驱动 Promise resolve。 + const triggerScriptLoad = () => { + queueMicrotask(() => { + const scripts = document.getElementsByTagName('script'); + Array.from(scripts).forEach((s) => { + if (typeof s.onload === 'function') { + (s.onload as any)(new Event('load')); + } }); }); + }; + + test('第一次加载asyncLoadJs带url与crossorigin参数', async () => { + const crossOrigin = 'anonymous'; + const load = util.asyncLoadJs(url, crossOrigin); + triggerScriptLoad(); + await load; + const script = document.getElementsByTagName('script')[0]; + expect(script).not.toBeUndefined(); + expect(script.type).toMatch('text/javascript'); + expect(script.crossOrigin).toMatch(crossOrigin); + expect(script.src).toMatch(url); }); - test('url无效', () => { - util.asyncLoadJs('123').catch((e: any) => { - expect(e).toMatch('error'); + test('第二次加载asyncLoadJs', async () => { + const load1 = util.asyncLoadJs(url, 'anonymous'); + triggerScriptLoad(); + await load1; + await util.asyncLoadJs(url, 'use-credentials'); + const scriptList = document.getElementsByTagName('script'); + expect(scriptList.length).toBe(1); + expect(scriptList[0].crossOrigin).toMatch('anonymous'); + expect(scriptList[0].src).toMatch(url); + }); + + test('url无效, onerror 触发后 reject', async () => { + const promise = util.asyncLoadJs('error-url'); + queueMicrotask(() => { + const scripts = document.getElementsByTagName('script'); + Array.from(scripts).forEach((s) => { + if (s.src.includes('error-url') && typeof s.onerror === 'function') { + (s.onerror as any)(new Event('error')); + } + }); }); + await expect(promise).rejects.toBeInstanceOf(Error); }); }); describe('asyncLoadCss', () => { const url = 'https://beta.m.www.tmagic.com/magic-act/css/BuyGift.75d837d2b3fd.css?max_age=864000'; - test('第一次加载asyncLoadCss', () => { - const load = util.asyncLoadCss(url); - load.then(() => { - const link = document.getElementsByTagName('link')[0]; - expect(link).not.toBeUndefined(); - expect(link.rel).toMatch('stylesheet'); - expect(link.href).toMatch(url); - }); - }); - - test('第二次加载asyncLoadJs', () => { - util.asyncLoadCss(url).then(() => { - util.asyncLoadCss(url).then(() => { - const linkList = document.getElementsByTagName('link'); - expect(linkList.length).toBe(1); - expect(linkList[0].href).toMatch(url); + const triggerLinkLoad = () => { + queueMicrotask(() => { + const links = document.getElementsByTagName('link'); + Array.from(links).forEach((l) => { + if (typeof l.onload === 'function') { + (l.onload as any)(new Event('load')); + } }); }); + }; + + test('第一次加载asyncLoadCss', async () => { + const load = util.asyncLoadCss(url); + triggerLinkLoad(); + await load; + const link = document.getElementsByTagName('link')[0]; + expect(link).not.toBeUndefined(); + expect(link.rel).toMatch('stylesheet'); + expect(link.href).toMatch(url); }); - test('url无效', () => { - util.asyncLoadCss('123').catch((e: any) => { - expect(e).toMatch('error'); + test('第二次加载asyncLoadCss命中缓存', async () => { + const load1 = util.asyncLoadCss(url); + triggerLinkLoad(); + await load1; + await util.asyncLoadCss(url); + const linkList = document.getElementsByTagName('link'); + expect(linkList.length).toBe(1); + expect(linkList[0].href).toMatch(url); + }); + + test('url无效, onerror 触发后 reject', async () => { + const promise = util.asyncLoadCss('error-css'); + queueMicrotask(() => { + const links = document.getElementsByTagName('link'); + Array.from(links).forEach((l) => { + if (l.href.includes('error-css') && typeof l.onerror === 'function') { + (l.onerror as any)(new Event('error')); + } + }); }); + await expect(promise).rejects.toBeInstanceOf(Error); }); }); @@ -613,6 +650,24 @@ describe('compiledNode', () => { expect(node.text).toBe(''); }); + + test('compile 缓存命中时直接复用 cache value', () => { + const node: any = { + id: 61705611, + type: 'text', + [`${util.DSL_NODE_KEY_COPY_PREFIX}text`]: 'cached-template', + text: 'old-value', + }; + const result = util.compiledNode((str: string) => `compiled-${str}`, node, { + ds_bebcb2d5: { + 61705611: { + name: '文本', + keys: ['text'], + }, + }, + }); + expect(result.text).toBe('compiled-cached-template'); + }); }); describe('getDefaultValueFromFields', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b00b9f8d..e4673ad4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: execa: specifier: ^9.6.0 version: 9.6.0 + happy-dom: + specifier: ^20.9.0 + version: 20.9.0 highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -127,7 +130,7 @@ importers: version: 1.6.4(@algolia/client-search@5.44.0)(@types/node@24.0.10)(@types/react@18.3.27)(async-validator@4.2.5)(axios@1.13.2)(lightningcss@1.32.0)(postcss@8.5.14)(qrcode@1.5.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass-embedded@1.99.0)(sass@1.99.0)(search-insights@2.17.3)(sortablejs@1.15.6)(terser@5.44.1)(typescript@6.0.3) vitest: specifier: ^4.1.5 - version: 4.1.5(@types/node@24.0.10)(@vitest/coverage-v8@4.1.5)(jsdom@27.2.0)(vite@8.0.12(@types/node@24.0.10)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.44.1)(yaml@2.8.1)) + version: 4.1.5(@types/node@24.0.10)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@27.2.0)(vite@8.0.12(@types/node@24.0.10)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.44.1)(yaml@2.8.1)) vue: specifier: 'catalog:' version: 3.5.33(typescript@6.0.3) @@ -3307,6 +3310,12 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.58.0': resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4823,6 +4832,10 @@ packages: engines: {node: '>=0.4.7'} hasBin: true + happy-dom@20.9.0: + resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} + engines: {node: '>=20.0.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -6821,6 +6834,10 @@ packages: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -9078,6 +9095,12 @@ snapshots: '@types/web-bluetooth@0.0.21': {} + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.0.10 + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@6.0.3))(eslint@10.0.3(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -9256,7 +9279,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@types/node@24.0.10)(@vitest/coverage-v8@4.1.5)(jsdom@27.2.0)(vite@8.0.12(@types/node@24.0.10)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.44.1)(yaml@2.8.1)) + vitest: 4.1.5(@types/node@24.0.10)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@27.2.0)(vite@8.0.12(@types/node@24.0.10)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.44.1)(yaml@2.8.1)) '@vitest/expect@4.1.5': dependencies: @@ -10997,6 +11020,18 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 + happy-dom@20.9.0: + dependencies: + '@types/node': 24.0.10 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-bigints@1.1.0: {} has-flag@3.0.0: {} @@ -12927,7 +12962,7 @@ snapshots: - typescript - universal-cookie - vitest@4.1.5(@types/node@24.0.10)(@vitest/coverage-v8@4.1.5)(jsdom@27.2.0)(vite@8.0.12(@types/node@24.0.10)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.44.1)(yaml@2.8.1)): + vitest@4.1.5(@types/node@24.0.10)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@27.2.0)(vite@8.0.12(@types/node@24.0.10)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.44.1)(yaml@2.8.1)): dependencies: '@vitest/expect': 4.1.5 '@vitest/mocker': 4.1.5(vite@8.0.12(@types/node@24.0.10)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.44.1)(yaml@2.8.1)) @@ -12952,6 +12987,7 @@ snapshots: optionalDependencies: '@types/node': 24.0.10 '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + happy-dom: 20.9.0 jsdom: 27.2.0 transitivePeerDependencies: - msw @@ -13023,6 +13059,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} whatwg-url@15.1.0: diff --git a/vitest.config.ts b/vitest.config.ts index 4eabc319..3934ba73 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,13 +10,15 @@ export default defineConfig({ test: { include: ['./packages/*/tests/**', './runtime/*/tests/**'], - environment: 'jsdom', + environment: 'happy-dom', environmentMatchGlobs: [['packages/cli/**', 'node']], coverage: { + include: ['packages/*/src/**'], exclude: [ './runtime/**', './playground/**', './docs/**', + './packages/*/dist/**', './packages/*/types/**', './packages/*/tests/**', './packages/cli/lib/**',