From 8ada857f57605962c8cd3850af7105c4cca7ac6c Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 15 Jun 2026 15:17:48 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E5=A2=9E=E5=8A=A0QQBot=E5=BC=BA?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...hitecture-convergence-bangdream-rewrite.md | 12 +- .../qqbot-module-boundary.spec.ts | 143 ++++++++++++++++++ .../qqbot-plugin-package-boundary.spec.ts | 134 ++++++++++++++++ .../core/qqbot-core-plugin-ports.spec.ts | 58 +++++++ .../napcat-persistent-login-state.spec.ts | 69 +++++++++ .../plugin-lifecycle-runtime.spec.ts | 70 +++++++++ .../bangdream-operation-parity.spec.ts | 110 ++++++++++++++ 7 files changed, 590 insertions(+), 6 deletions(-) create mode 100644 test/modules/qqbot/architecture/qqbot-module-boundary.spec.ts create mode 100644 test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts create mode 100644 test/modules/qqbot/core/qqbot-core-plugin-ports.spec.ts create mode 100644 test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts create mode 100644 test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts create mode 100644 test/modules/qqbot/plugins/bangdream-rewrite/bangdream-operation-parity.spec.ts diff --git a/docs/superpowers/plans/2026-06-15-qqbot-architecture-convergence-bangdream-rewrite.md b/docs/superpowers/plans/2026-06-15-qqbot-architecture-convergence-bangdream-rewrite.md index 66bcf6a..948a7e3 100644 --- a/docs/superpowers/plans/2026-06-15-qqbot-architecture-convergence-bangdream-rewrite.md +++ b/docs/superpowers/plans/2026-06-15-qqbot-architecture-convergence-bangdream-rewrite.md @@ -227,7 +227,7 @@ Expected: fails on current `bangDream`, `ff14Market`, Nest services, and old reg - Create: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugin-platform\plugin-lifecycle-runtime.spec.ts` - Create: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\core\qqbot-core-plugin-ports.spec.ts` - Create: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\napcat\napcat-persistent-login-state.spec.ts` -- Create: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugins\bangdream\bangdream-operation-parity.spec.ts` +- Create: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugins\bangdream-rewrite\bangdream-operation-parity.spec.ts` - [ ] Lifecycle test covers install, enable, active registry refresh, operation execution, event dispatch, disable, uninstall, timeout, crash isolation, and runtime event persistence. - [ ] Core ports test covers command prefix and alias matching in core, raw args handed to Plugin Platform, plugin output normalized back to command log/send queue, and plugin event dispatch after core does not consume a message. @@ -236,7 +236,7 @@ Expected: fails on current `bangDream`, `ff14Market`, Nest services, and old reg - [ ] Verify RED: ```powershell -pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api exec jest --runInBand --runTestsByPath test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts test/modules/qqbot/core/qqbot-core-plugin-ports.spec.ts test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts test/modules/qqbot/plugins/bangdream/bangdream-operation-parity.spec.ts +pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api exec jest --runInBand --runTestsByPath test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts test/modules/qqbot/core/qqbot-core-plugin-ports.spec.ts test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts test/modules/qqbot/plugins/bangdream-rewrite/bangdream-operation-parity.spec.ts ``` Expected: fails because current runtime and plugin package boundaries are not the approved model. @@ -448,7 +448,7 @@ git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "feat: 重建QQBot插 - [ ] Run: ```powershell -pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api exec jest --runInBand --runTestsByPath test/modules/qqbot/plugins/bangdream/bangdream-operation-parity.spec.ts test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts test/modules/qqbot/plugins/plugin-platform-migration.spec.ts test/modules/qqbot/plugins/plugin-registry-compat.spec.ts +pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api exec jest --runInBand --runTestsByPath test/modules/qqbot/plugins/bangdream-rewrite/bangdream-operation-parity.spec.ts test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts test/modules/qqbot/plugins/plugin-platform-migration.spec.ts test/modules/qqbot/plugins/plugin-registry-compat.spec.ts pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck ``` @@ -471,7 +471,7 @@ powershell -ExecutionPolicy Bypass -File D:\MyFiles\KT\Node\kt-template-online-a - [ ] Commit: ```powershell -git -C D:\MyFiles\KT\Node\kt-template-online-api add src/modules/qqbot/plugins/bangdream test/modules/qqbot/plugins/bangdream test/modules/qqbot/architecture sql/refactor-v3 docs/refactor-v3 API.md +git -C D:\MyFiles\KT\Node\kt-template-online-api add src/modules/qqbot/plugins/bangdream test/modules/qqbot/plugins/bangdream-rewrite test/modules/qqbot/architecture sql/refactor-v3 docs/refactor-v3 API.md git -C D:\MyFiles\KT\Node\kt-template-online-api add -u src/modules/qqbot/plugins/bangDream git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "refactor: 重写BangDream插件" ``` @@ -739,7 +739,7 @@ Expected: no old runtime implementation references; only explicit legacy alias f - [ ] Run: ```powershell -pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api exec jest --runInBand --runTestsByPath test/modules/qqbot/architecture/qqbot-module-boundary.spec.ts test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts test/modules/qqbot/plugins/bangdream/bangdream-operation-parity.spec.ts test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts +pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api exec jest --runInBand --runTestsByPath test/modules/qqbot/architecture/qqbot-module-boundary.spec.ts test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts test/modules/qqbot/plugins/bangdream-rewrite/bangdream-operation-parity.spec.ts test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin -F @vben/web-antdv-next run typecheck pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run global-review -- --project api --content-scan-mode changed @@ -771,7 +771,7 @@ git -C D:\MyFiles\KT commit -m "docs: 更新QQBot插件架构规则" ```powershell git -C D:\MyFiles\KT\Node\kt-template-online-api status --short --branch pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck -pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api exec jest --runInBand --runTestsByPath test/modules/qqbot/architecture/qqbot-module-boundary.spec.ts test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts test/modules/qqbot/core/qqbot-core-plugin-ports.spec.ts test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts test/modules/qqbot/plugins/bangdream/bangdream-operation-parity.spec.ts test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts +pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api exec jest --runInBand --runTestsByPath test/modules/qqbot/architecture/qqbot-module-boundary.spec.ts test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts test/modules/qqbot/core/qqbot-core-plugin-ports.spec.ts test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts test/modules/qqbot/plugins/bangdream-rewrite/bangdream-operation-parity.spec.ts test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api exec jest --runInBand git -C D:\MyFiles\KT\Node\kt-template-online-api diff --check ``` diff --git a/test/modules/qqbot/architecture/qqbot-module-boundary.spec.ts b/test/modules/qqbot/architecture/qqbot-module-boundary.spec.ts new file mode 100644 index 0000000..cdd5f0d --- /dev/null +++ b/test/modules/qqbot/architecture/qqbot-module-boundary.spec.ts @@ -0,0 +1,143 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'fs'; +import { basename, join, relative } from 'path'; + +const repoRoot = join(__dirname, '../../../..'); +const qqbotRoot = join(repoRoot, 'src/modules/qqbot'); + +const collectTsFiles = (root: string): string[] => { + if (!existsSync(root)) return []; + + return readdirSync(root).flatMap((name) => { + const filePath = join(root, name); + const stat = statSync(filePath); + if (stat.isDirectory()) return collectTsFiles(filePath); + return filePath.endsWith('.ts') ? [filePath] : []; + }); +}; + +const readSource = (filePath: string) => readFileSync(filePath, 'utf8'); + +const toRepoPath = (filePath: string) => + relative(repoRoot, filePath).replace(/\\/g, '/'); + +const listTopLevelEntries = (moduleName: string) => { + const root = join(qqbotRoot, moduleName); + return readdirSync(root) + .map((name) => { + const filePath = join(root, name); + const stat = statSync(filePath); + return { + isDirectory: stat.isDirectory(), + name, + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +}; + +describe('QQBot third-phase module boundaries', () => { + it('keeps core, plugin-platform, and napcat top-level structure strict', () => { + const allowedDirectories = new Set([ + 'application', + 'contract', + 'domain', + 'infrastructure', + 'schema', + ]); + const allowedRootFiles = new Set(['index.ts']); + + const violations = ['core', 'plugin-platform', 'napcat'].flatMap( + (moduleName) => + listTopLevelEntries(moduleName) + .filter((entry) => + entry.isDirectory + ? !allowedDirectories.has(entry.name) + : !allowedRootFiles.has(entry.name) && + !entry.name.endsWith('.module.ts'), + ) + .map((entry) => `${moduleName}/${entry.name}`), + ); + + expect(violations).toEqual([]); + }); + + it('prevents QQBot core from importing concrete plugin implementation', () => { + const violations = collectTsFiles(join(qqbotRoot, 'core')) + .map((filePath) => ({ + file: toRepoPath(filePath), + source: readSource(filePath), + })) + .filter(({ source }) => + /@\/modules\/qqbot\/plugins\/|src\/modules\/qqbot\/plugins\//.test( + source, + ), + ) + .map(({ file }) => file); + + expect(violations).toEqual([]); + }); + + it('keeps QqbotCoreModule free of plugin platform controllers, registries, SDKs, and concrete plugin services', () => { + const source = readSource(join(qqbotRoot, 'core/qqbot-core.module.ts')); + const bannedSymbols = [ + 'QqbotPluginController', + 'QqbotPluginHttpClientService', + 'QqbotPluginRegistryService', + 'QqbotEventPluginRegistryService', + 'QqbotBangDream', + 'QqbotFf14', + 'QqbotFflogs', + 'QqbotRepeater', + 'TsuguApplicationService', + ]; + + const violations = bannedSymbols.filter((symbol) => + source.includes(symbol), + ); + + expect(violations).toEqual([]); + }); + + it('keeps core domain independent from Nest, TypeORM, Admin, Plugin Platform, Docker, and HTTP implementation', () => { + const violations = collectTsFiles(join(qqbotRoot, 'core/domain')) + .map((filePath) => ({ + file: toRepoPath(filePath), + source: readSource(filePath), + })) + .filter(({ source }) => + /@nestjs\/|typeorm|@\/modules\/admin|@\/modules\/qqbot\/plugin-platform|child_process|docker|ssh|axios|fetch\(/i.test( + source, + ), + ) + .map(({ file }) => file); + + expect(violations).toEqual([]); + }); + + it('keeps Docker and SSH shell construction inside napcat infrastructure integration only', () => { + const violations = collectTsFiles(join(qqbotRoot, 'napcat')) + .map((filePath) => ({ + file: toRepoPath(filePath), + source: readSource(filePath), + })) + .filter(({ file, source }) => { + if (file.includes('/napcat/infrastructure/integration/')) { + return false; + } + return /child_process|spawn\(|\bssh\b|\bdocker\b|docker run|docker logs/i.test( + source, + ); + }) + .map(({ file }) => file); + + expect(violations).toEqual([]); + }); + + it('has schema ownership notes for each QQBot runtime module', () => { + const missing = ['core', 'plugin-platform', 'napcat'] + .map((moduleName) => join(qqbotRoot, moduleName, 'schema/README.md')) + .filter((filePath) => !existsSync(filePath)) + .map((filePath) => `${basename(join(filePath, '../..'))}/schema/README.md`); + + expect(missing).toEqual([]); + }); +}); diff --git a/test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts b/test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts new file mode 100644 index 0000000..76a3e39 --- /dev/null +++ b/test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts @@ -0,0 +1,134 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'fs'; +import { join, relative } from 'path'; + +const repoRoot = join(__dirname, '../../../..'); +const pluginRoot = join(repoRoot, 'src/modules/qqbot/plugins'); + +const collectTsFiles = (root: string): string[] => { + if (!existsSync(root)) return []; + + return readdirSync(root).flatMap((name) => { + const filePath = join(root, name); + const stat = statSync(filePath); + if (stat.isDirectory()) return collectTsFiles(filePath); + return filePath.endsWith('.ts') ? [filePath] : []; + }); +}; + +const toRepoPath = (filePath: string) => + relative(repoRoot, filePath).replace(/\\/g, '/'); + +const requiredPluginPaths = [ + 'plugin.json', + 'src/index.ts', + 'src/operations', + 'src/events', + 'src/domain', + 'src/application', + 'src/infrastructure/integration', + 'src/infrastructure/storage', + 'src/config', + 'src/assets', + 'src/migrations', + 'src/tests', +]; + +describe('QQBot plugin package boundary', () => { + it('uses only approved built-in plugin package keys as directory names', () => { + const pluginDirs = readdirSync(pluginRoot) + .filter((name) => statSync(join(pluginRoot, name)).isDirectory()) + .sort(); + + expect(pluginDirs).toEqual([ + 'bangdream', + 'ff14-market', + 'fflogs', + 'repeater', + ]); + }); + + it('uses the same package shape for every built-in plugin', () => { + const missing = ['bangdream', 'ff14-market', 'fflogs', 'repeater'].flatMap( + (pluginKey) => + requiredPluginPaths + .map((pathName) => `${pluginKey}/${pathName}`) + .filter((pathName) => !existsSync(join(pluginRoot, pathName))), + ); + + expect(missing).toEqual([]); + }); + + it('keeps plugin runtime source independent from host internals and direct IO', () => { + const banned = [ + { + name: '@nestjs', + pattern: /@nestjs\//, + }, + { + name: 'Admin module import', + pattern: /@\/modules\/admin\//, + }, + { + name: 'QQBot core import', + pattern: /@\/modules\/qqbot\/core\//, + }, + { + name: 'ConfigService', + pattern: /\bConfigService\b/, + }, + { + name: 'DictService', + pattern: /\bDictService\b/, + }, + { + name: 'process.env', + pattern: /process\.env/, + }, + { + name: 'direct axios', + pattern: /\baxios\b/, + }, + { + name: 'direct fetch', + pattern: /\bfetch\(/, + }, + { + name: 'direct fs', + pattern: /from ['"](?:node:)?fs['"]|require\(['"](?:node:)?fs['"]\)/, + }, + { + name: 'import-time timer', + pattern: /\bset(?:Interval|Timeout)\(/, + }, + ]; + + const violations = collectTsFiles(pluginRoot).flatMap((filePath) => { + const source = readFileSync(filePath, 'utf8'); + return banned + .filter(({ pattern }) => pattern.test(source)) + .map(({ name }) => `${toRepoPath(filePath)} :: ${name}`); + }); + + expect(violations).toEqual([]); + }); + + it('uses plugin.json as the only operation metadata source', () => { + const duplicateMetadataFiles = collectTsFiles(pluginRoot) + .filter((filePath) => { + const file = toRepoPath(filePath); + if (file.endsWith('/src/index.ts')) return false; + const source = readFileSync(filePath, 'utf8'); + return ( + /operation-registry|COMMAND_DEFINITIONS|OperationDefinition/.test( + file, + ) || + /operation-registry|COMMAND_DEFINITIONS|OperationDefinition/.test( + source, + ) + ); + }) + .map(toRepoPath); + + expect(duplicateMetadataFiles).toEqual([]); + }); +}); diff --git a/test/modules/qqbot/core/qqbot-core-plugin-ports.spec.ts b/test/modules/qqbot/core/qqbot-core-plugin-ports.spec.ts new file mode 100644 index 0000000..3bad5cf --- /dev/null +++ b/test/modules/qqbot/core/qqbot-core-plugin-ports.spec.ts @@ -0,0 +1,58 @@ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; + +const repoRoot = join(__dirname, '../../../..'); + +const readSource = (relativePath: string) => + readFileSync(join(repoRoot, relativePath), 'utf8'); + +describe('QQBot core plugin execution ports', () => { + it('defines core-side plugin execution and event dispatch ports', () => { + const portPath = join( + repoRoot, + 'src/modules/qqbot/core/domain/plugin-execution.port.ts', + ); + + expect(existsSync(portPath)).toBe(true); + if (!existsSync(portPath)) return; + + const source = readFileSync(portPath, 'utf8'); + expect(source).toEqual(expect.stringContaining('executeOperation')); + expect(source).toEqual(expect.stringContaining('dispatchEvent')); + expect(source).toEqual(expect.stringContaining('listActiveOperations')); + expect(source).toEqual(expect.stringContaining('getOperationByCommand')); + }); + + it('keeps command parser generic and leaves plugin-specific parsing to plugins', () => { + const source = readSource( + 'src/modules/qqbot/core/command/qqbot-command-parser.service.ts', + ); + + const bannedParserSignals = [ + 'DictService', + 'ff14Price', + 'fflogsCharacter', + '@/modules/qqbot/plugins/', + ].filter((signal) => source.includes(signal)); + + expect(bannedParserSignals).toEqual([]); + expect(source).toContain('rawArgs'); + }); + + it('dispatches unconsumed events through Plugin Platform instead of directly invoking Repeater', () => { + const source = readSource( + 'src/modules/qqbot/core/rule/qqbot-rule-engine.service.ts', + ); + + const bannedRepeaterSignals = [ + 'QqbotRepeaterPluginService', + '@/modules/qqbot/plugins/repeater', + ].filter((signal) => source.includes(signal)); + const missingDispatcherSignals = ['dispatchEvent'].filter( + (signal) => !source.includes(signal), + ); + + expect(bannedRepeaterSignals).toEqual([]); + expect(missingDispatcherSignals).toEqual([]); + }); +}); diff --git a/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts b/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts new file mode 100644 index 0000000..a93cef0 --- /dev/null +++ b/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts @@ -0,0 +1,69 @@ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; + +const repoRoot = join(__dirname, '../../../..'); + +const readSource = (relativePath: string) => + readFileSync(join(repoRoot, relativePath), 'utf8'); + +describe('NapCat persistent login state contract', () => { + it('owns the approved third-phase persistence tables', () => { + const persistenceRoot = join( + repoRoot, + 'src/modules/qqbot/napcat/infrastructure/persistence', + ); + const sqlSchema = readSource('sql/refactor-v3/00-full-schema.sql'); + const persistenceFiles = existsSync(persistenceRoot) + ? readSource( + 'src/modules/qqbot/napcat/infrastructure/persistence/qqbot-account-napcat.entity.ts', + ) + + '\n' + + readSource( + 'src/modules/qqbot/napcat/infrastructure/persistence/qqbot-napcat-container.entity.ts', + ) + : ''; + const source = `${sqlSchema}\n${persistenceFiles}`; + + const missing = [ + 'napcat_container', + 'napcat_device_identity', + 'napcat_account_binding', + 'napcat_login_session', + 'napcat_login_challenge', + 'napcat_runtime_cleanup', + ].filter((tableName) => !source.includes(tableName)); + + expect(missing).toEqual([]); + }); + + it('keeps login truth in persistence rather than in-memory scan sessions', () => { + const loginSource = readSource( + 'src/modules/qqbot/napcat/login/qqbot-napcat-login.service.ts', + ); + + const bannedMemorySignals = ['new Map', 'scanSessions'].filter((signal) => + loginSource.includes(signal), + ); + const missingPersistenceSignals = [ + 'loginSession', + 'loginChallenge', + 'runtimeCleanup', + ].filter((signal) => !loginSource.includes(signal)); + + expect(bannedMemorySignals).toEqual([]); + expect(missingPersistenceSignals).toEqual([]); + }); + + it('keeps captcha and new-device challenges recoverable and separate', () => { + const stateMachine = readSource( + 'src/modules/qqbot/napcat/login/napcat-login-state-machine.ts', + ); + + expect(stateMachine).toEqual(expect.stringContaining("type: 'captcha'")); + expect(stateMachine).toEqual(expect.stringContaining("type: 'new-device'")); + expect(stateMachine).toEqual(expect.stringContaining('qr-pending')); + expect(stateMachine).toEqual(expect.stringContaining('scanned')); + expect(stateMachine).toEqual(expect.stringContaining('confirming')); + expect(stateMachine).toEqual(expect.stringContaining('cleanup-failed')); + }); +}); diff --git a/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts b/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts new file mode 100644 index 0000000..57c981a --- /dev/null +++ b/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts @@ -0,0 +1,70 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const repoRoot = join(__dirname, '../../../..'); + +const readSource = (relativePath: string) => + readFileSync(join(repoRoot, relativePath), 'utf8'); + +describe('QQBot plugin platform lifecycle runtime contract', () => { + it('uses dedicated lifecycle use cases instead of direct status flips', () => { + const controller = readSource( + 'src/modules/qqbot/plugin-platform/plugin-platform.controller.ts', + ); + const service = readSource( + 'src/modules/qqbot/plugin-platform/plugin-platform.service.ts', + ); + + const bannedDirectStatusFlips = [ + controller.includes('setInstallationStatus') + ? 'controller.setInstallationStatus' + : '', + service.includes('setInstallationStatus') + ? 'service.setInstallationStatus' + : '', + ].filter(Boolean); + const missingLifecycleMethods = [ + 'enableInstallation', + 'disableInstallation', + 'upgradeInstallation', + 'uninstallInstallation', + ].filter((methodName) => !service.includes(methodName)); + + expect(bannedDirectStatusFlips).toEqual([]); + expect(missingLifecycleMethods).toEqual([]); + }); + + it('activates workers and refreshes active registries during lifecycle transitions', () => { + const source = [ + readSource('src/modules/qqbot/plugin-platform/plugin-platform.service.ts'), + readSource('src/modules/qqbot/plugin-platform/runtime/worker-runtime.ts'), + ].join('\n'); + + const missingRuntimeSignals = [ + 'QqbotPluginWorkerRuntime', + 'activate', + 'deactivate', + 'dispose', + 'refreshActive', + 'activeOperation', + 'activeEvent', + ].filter((signal) => !source.includes(signal)); + + expect(missingRuntimeSignals).toEqual([]); + }); + + it('exposes operation executor and event dispatcher through the platform', () => { + const source = readSource( + 'src/modules/qqbot/plugin-platform/plugin-platform.service.ts', + ); + + const missingExecutorSignals = [ + 'executeOperation', + 'dispatchEvent', + 'runtimeEventRepository', + 'command log', + ].filter((signal) => !source.includes(signal)); + + expect(missingExecutorSignals).toEqual([]); + }); +}); diff --git a/test/modules/qqbot/plugins/bangdream-rewrite/bangdream-operation-parity.spec.ts b/test/modules/qqbot/plugins/bangdream-rewrite/bangdream-operation-parity.spec.ts new file mode 100644 index 0000000..c53a7a1 --- /dev/null +++ b/test/modules/qqbot/plugins/bangdream-rewrite/bangdream-operation-parity.spec.ts @@ -0,0 +1,110 @@ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; + +const repoRoot = join(__dirname, '../../../../..'); +const newBangDreamRoot = join( + repoRoot, + 'src/modules/qqbot/plugins/bangdream', +); +const baselineTest = join( + repoRoot, + 'test/modules/qqbot/architecture/qqbot-current-operation-matrix.spec.ts', +); + +const expectedOperationKeys = [ + 'bangdream.song.search', + 'bangdream.song.chart', + 'bangdream.song.random', + 'bangdream.song.meta', + 'bangdream.card.search', + 'bangdream.card.illustration', + 'bangdream.character.search', + 'bangdream.event.search', + 'bangdream.event.stage', + 'bangdream.player.search', + 'bangdream.gacha.search', + 'bangdream.gacha.simulate', + 'bangdream.cutoff.detail', + 'bangdream.cutoff.all', + 'bangdream.cutoff.recent', +]; + +describe('BangDream rewritten plugin parity', () => { + it('uses the new platform package structure', () => { + const requiredPaths = [ + 'plugin.json', + 'src/index.ts', + 'src/operations/song-search', + 'src/operations/song-chart', + 'src/operations/song-random', + 'src/operations/song-meta', + 'src/operations/card-search', + 'src/operations/card-illustration', + 'src/operations/character-search', + 'src/operations/event-search', + 'src/operations/event-stage', + 'src/operations/player-search', + 'src/operations/gacha-search', + 'src/operations/gacha-simulate', + 'src/operations/cutoff-detail', + 'src/operations/cutoff-all', + 'src/operations/cutoff-recent', + 'src/domain/song', + 'src/domain/card', + 'src/domain/character', + 'src/domain/event', + 'src/domain/gacha', + 'src/domain/player', + 'src/domain/cutoff', + 'src/domain/catalog', + 'src/application', + 'src/infrastructure/integration', + 'src/infrastructure/storage', + 'src/config', + 'src/assets', + 'src/migrations', + 'src/tests', + ]; + + const missing = requiredPaths.filter( + (relativePath) => !existsSync(join(newBangDreamRoot, relativePath)), + ); + + expect(missing).toEqual([]); + }); + + it('preserves the 15 approved operation keys in the new manifest', () => { + const manifestPath = join(newBangDreamRoot, 'plugin.json'); + expect(existsSync(manifestPath)).toBe(true); + if (!existsSync(manifestPath)) return; + + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + operations?: Array<{ key?: string }>; + pluginKey?: string; + }; + + expect(manifest.pluginKey).toBe('bangdream'); + expect(manifest.operations?.map((operation) => operation.key)).toEqual( + expectedOperationKeys, + ); + }); + + it('keeps event stage split output as a hard parity gate', () => { + const baseline = readFileSync(baselineTest, 'utf8'); + const operationSourcePath = join( + newBangDreamRoot, + 'src/operations/event-stage', + ); + + expect(baseline).toContain('bangdream.event.stage'); + expect(existsSync(operationSourcePath)).toBe(true); + if (!existsSync(operationSourcePath)) return; + + const eventStageSource = readFileSync( + join(operationSourcePath, 'index.ts'), + 'utf8', + ); + expect(eventStageSource).toContain('imageCount'); + expect(eventStageSource).toContain('5'); + }); +});