From 4eec2d6d050d45476a8b9c463b60f2b6491828f2 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 6 Jun 2026 21:39:37 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8E=A5=E5=85=A5=20BangDream=20Ts?= =?UTF-8?q?ugu=20=E6=89=A7=E8=A1=8C=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ot-bangdream-tsugu-global-refactor-plan.md | 8 + scripts/bangdream-render-smoke.ps1 | 5 +- .../qqbot-bangdream-client.service.ts | 13 +- .../qqbot-bangdream-renderer.service.ts | 32 ++-- .../renderer/tsugu-application.service.ts | 74 +++++++++ .../bangDream/tsugu/runtime/hook-registry.ts | 148 ++++++++++++++++++ src/qqbot/qqbot.module.ts | 2 + .../qqbot-bangdream-client.service.spec.ts | 24 +-- .../tsugu-application.service.spec.ts | 73 +++++++++ .../bangDream/tsugu/hook-registry.spec.ts | 66 ++++++++ 10 files changed, 407 insertions(+), 38 deletions(-) create mode 100644 src/qqbot/plugins/bangDream/renderer/tsugu-application.service.ts create mode 100644 src/qqbot/plugins/bangDream/tsugu/runtime/hook-registry.ts create mode 100644 test/qqbot/plugins/bangDream/tsugu-application.service.spec.ts create mode 100644 test/qqbot/plugins/bangDream/tsugu/hook-registry.spec.ts diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index 45273ec..e80dd8f 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -389,6 +389,14 @@ export interface TsuguHook { - 每次命令执行能记录 operation、耗时、图片数、错误。 - 线上日志能定位 BangDream 命令的具体失败阶段。 +当前进度: + +- 已新增 `tsugu/runtime/hook-registry.ts`,定义 `TsuguHookContext`、`TsuguHook`、`TsuguHookRegistry` 和默认 `TsuguLogHook`,命令执行日志包含 `operation`、`stage`、`handler`、`query`、`imageCount`、`durationMs` 和错误字符串。 +- 已新增 `renderer/tsugu-application.service.ts` 作为 BangDream Tsugu 应用入口,统一执行主数据 ready 等待、operation registry 查找、handler 调度、hook 触发和错误字符串化。 +- `QqbotBangDreamClientService` 已改为注入 `TsuguApplicationService`,不再直接调用 renderer;`QqbotBangDreamRendererService` 移除 operation key 调度,只保留字典刷新、健康检查、渲染选项解析和 15 个具体 handler。 +- `scripts/bangdream-render-smoke.ps1` 已切到 `TsuguApplicationService`,本地 smoke 继续走真实应用入口,避免 facade 改造后验证脚本回退到旧链路。 +- 已新增 `hook-registry.spec.ts` 和 `tsugu-application.service.spec.ts`,覆盖 hook 顺序、错误 hook、应用入口执行、字典刷新和错误字符串化;本地 Phase 7 smoke 已生成 `phase7-hook-event-50.jpg`、`phase7-hook-cutoff-detail-100-50-cn.jpg`、`phase7-hook-cutoff-recent-100-50-cn.jpg`。 + ## 文件迁移优先级 | 优先级 | 文件/目录 | 原因 | diff --git a/scripts/bangdream-render-smoke.ps1 b/scripts/bangdream-render-smoke.ps1 index ac35d6c..d49eddc 100644 --- a/scripts/bangdream-render-smoke.ps1 +++ b/scripts/bangdream-render-smoke.ps1 @@ -43,10 +43,13 @@ const fs = require("fs"); const path = require("path"); const { ConfigService } = require("@nestjs/config"); const payload = JSON.parse(Buffer.from("$PayloadBase64", "base64").toString("utf8")); +const { ToolsService } = require("./src/common/services/tool.service"); const { QqbotBangDreamRendererService } = require("./src/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service"); +const { TsuguApplicationService } = require("./src/qqbot/plugins/bangDream/renderer/tsugu-application.service"); (async () => { - const service = new QqbotBangDreamRendererService(new ConfigService({}), undefined); + const renderer = new QqbotBangDreamRendererService(new ConfigService({}), undefined); + const service = new TsuguApplicationService(renderer, new ToolsService()); await service.onApplicationBootstrap(); const result = await service.execute(payload.operationKey, payload.input); const match = result.replyText.match(/base64:\/\/([A-Za-z0-9+/=]+)/); diff --git a/src/qqbot/plugins/bangDream/qqbot-bangdream-client.service.ts b/src/qqbot/plugins/bangDream/qqbot-bangdream-client.service.ts index 93cd5a8..cfba091 100644 --- a/src/qqbot/plugins/bangDream/qqbot-bangdream-client.service.ts +++ b/src/qqbot/plugins/bangDream/qqbot-bangdream-client.service.ts @@ -5,28 +5,31 @@ import type { QqbotBangDreamSongSearchInput, QqbotBangDreamSongSummary, } from './qqbot-bangdream.types'; -import { QqbotBangDreamRendererService } from './renderer/qqbot-bangdream-renderer.service'; +import { TsuguApplicationService } from './renderer/tsugu-application.service'; @Injectable() export class QqbotBangDreamClientService { constructor( - private readonly rendererService: QqbotBangDreamRendererService, + private readonly tsuguApplicationService: TsuguApplicationService, ) {} async checkHealth() { - return this.rendererService.checkHealth(); + return this.tsuguApplicationService.checkHealth(); } async execute( operationKey: QqbotBangDreamOperationKey, input: QqbotBangDreamCommandInput, ) { - return await this.rendererService.execute(operationKey, input); + return await this.tsuguApplicationService.execute(operationKey, input); } async searchSong( params: QqbotBangDreamSongSearchInput, ): Promise { - return await this.rendererService.execute('bangdream.song.search', params); + return await this.tsuguApplicationService.execute( + 'bangdream.song.search', + params, + ); } } diff --git a/src/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service.ts b/src/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service.ts index abb597c..83fe43a 100644 --- a/src/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service.ts +++ b/src/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service.ts @@ -1,4 +1,4 @@ -import { Injectable, OnApplicationBootstrap, Optional } from '@nestjs/common'; +import { Injectable, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DictService } from '@/admin/dict/dict.service'; import { Card } from '../tsugu/models/card'; @@ -8,7 +8,10 @@ import { Gacha, getPresentGachaList } from '../tsugu/models/gacha'; import { Server } from '../tsugu/models/server'; import { Song } from '../tsugu/models/song'; import mainAPI, { waitForMainDataReady } from '../tsugu/models/main-data-store'; -import { fuzzySearch, type FuzzySearchResult } from '../tsugu/search/fuzzy-search'; +import { + fuzzySearch, + type FuzzySearchResult, +} from '../tsugu/search/fuzzy-search'; import { drawCardDetail } from '../tsugu/command-renderers/card-detail'; import { drawCardList } from '../tsugu/command-renderers/card-list'; import { drawCharacterDetail } from '../tsugu/command-renderers/character-detail'; @@ -32,7 +35,7 @@ import { BangDreamDictionaryLoader, type BangDreamDictionaryItem, } from '../tsugu/runtime/dictionary-loader'; -import { getBangDreamOperationDefinition } from '../tsugu/runtime/operation-registry'; +import type { QqbotBangDreamOperationHandlerName } from '../tsugu/runtime/operation-registry'; import { BANGDREAM_TSUGU_ENV_KEYS, normalizeBangDreamBoolean, @@ -47,7 +50,7 @@ import type { const SOURCE_NAME = 'Tsugu BangDream Bot 内置源码'; @Injectable() -export class QqbotBangDreamRendererService implements OnApplicationBootstrap { +export class QqbotBangDreamRendererService { private readonly dictionaryLoader = new BangDreamDictionaryLoader(); constructor( @@ -56,10 +59,6 @@ export class QqbotBangDreamRendererService implements OnApplicationBootstrap { private readonly dictService?: DictService, ) {} - async onApplicationBootstrap() { - await this.refreshDictionaryCache(); - } - async refreshDictionaryCache() { await this.dictionaryLoader.refresh((dictCode) => this.fetchDictionaryItems(dictCode), @@ -76,18 +75,13 @@ export class QqbotBangDreamRendererService implements OnApplicationBootstrap { return true; } - async execute( - operationKey: QqbotBangDreamOperationKey, + async executeOperationHandler( + handlerName: QqbotBangDreamOperationHandlerName, input: QqbotBangDreamCommandInput, ) { - await waitForMainDataReady(); - const operation = getBangDreamOperationDefinition(operationKey); - if (!operation) { - throw new Error(`BangDream 插件能力不存在:${operationKey}`); - } - const handler = this[operation.handlerName]; + const handler = this[handlerName]; if (typeof handler !== 'function') { - throw new Error(`BangDream 插件能力未绑定执行器:${operationKey}`); + throw new Error(`BangDream 插件能力未绑定执行器:${handlerName}`); } return await ( handler as ( @@ -434,9 +428,7 @@ export class QqbotBangDreamRendererService implements OnApplicationBootstrap { private pickDisplayedServerList(input: QqbotBangDreamCommandInput) { const source = input.displayedServerList || - this.configService.get( - BANGDREAM_TSUGU_ENV_KEYS.displayedServers, - ); + this.configService.get(BANGDREAM_TSUGU_ENV_KEYS.displayedServers); const defaultServers = this.dictionaryLoader.getDefaultDisplayedServers(); if (!source) return defaultServers; const values = splitBangDreamOptionList(source); diff --git a/src/qqbot/plugins/bangDream/renderer/tsugu-application.service.ts b/src/qqbot/plugins/bangDream/renderer/tsugu-application.service.ts new file mode 100644 index 0000000..6ae8adf --- /dev/null +++ b/src/qqbot/plugins/bangDream/renderer/tsugu-application.service.ts @@ -0,0 +1,74 @@ +import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; +import { ToolsService } from '@/common'; +import type { + QqbotBangDreamCommandInput, + QqbotBangDreamCommandOutput, + QqbotBangDreamOperationKey, +} from '../qqbot-bangdream.types'; +import { waitForMainDataReady } from '../tsugu/models/main-data-store'; +import { + createTsuguHookContext, + createTsuguLogHook, + TsuguHookRegistry, +} from '../tsugu/runtime/hook-registry'; +import { getBangDreamOperationDefinition } from '../tsugu/runtime/operation-registry'; +import { QqbotBangDreamRendererService } from './qqbot-bangdream-renderer.service'; + +@Injectable() +export class TsuguApplicationService implements OnApplicationBootstrap { + private readonly hookRegistry = new TsuguHookRegistry([createTsuguLogHook()]); + + constructor( + private readonly rendererService: QqbotBangDreamRendererService, + private readonly toolsService: ToolsService, + ) {} + + async onApplicationBootstrap() { + await this.rendererService.refreshDictionaryCache(); + } + + async checkHealth() { + return await this.rendererService.checkHealth(); + } + + async execute( + operationKey: QqbotBangDreamOperationKey, + input: QqbotBangDreamCommandInput, + ): Promise { + const context = createTsuguHookContext(operationKey, input); + await this.hookRegistry.beforeParse(context); + + try { + context.stage = 'mainData'; + await waitForMainDataReady(); + + context.stage = 'operation'; + const operation = getBangDreamOperationDefinition(operationKey); + if (!operation) { + throw new Error(`BangDream 插件能力不存在:${operationKey}`); + } + context.handlerName = operation.handlerName; + await this.hookRegistry.afterResolve(context); + + context.stage = 'handler'; + await this.hookRegistry.beforeRender(context); + const output = await this.rendererService.executeOperationHandler( + operation.handlerName, + input, + ); + + context.stage = 'output'; + context.imageCount = output.imageCount; + context.query = output.query || context.query; + await this.hookRegistry.afterOutput(context); + return output; + } catch (err) { + const message = this.toolsService.getErrorMessage( + err, + 'BangDream 命令执行失败', + ); + await this.hookRegistry.onError(context, message); + throw new Error(message); + } + } +} diff --git a/src/qqbot/plugins/bangDream/tsugu/runtime/hook-registry.ts b/src/qqbot/plugins/bangDream/tsugu/runtime/hook-registry.ts new file mode 100644 index 0000000..8e076c8 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/runtime/hook-registry.ts @@ -0,0 +1,148 @@ +import type { + QqbotBangDreamCommandInput, + QqbotBangDreamOperationKey, +} from '../../qqbot-bangdream.types'; +import { logger } from './logger'; + +export type TsuguExecutionStage = + | 'handler' + | 'mainData' + | 'operation' + | 'output' + | 'start'; + +export type TsuguHookContext = { + handlerName?: string; + imageCount?: number; + input: QqbotBangDreamCommandInput; + operationKey: QqbotBangDreamOperationKey; + query?: string; + stage: TsuguExecutionStage; + startedAt: number; +}; + +export type TsuguHook = { + afterOutput?: (context: TsuguHookContext) => Promise | void; + afterResolve?: (context: TsuguHookContext) => Promise | void; + beforeParse?: (context: TsuguHookContext) => Promise | void; + beforeRender?: (context: TsuguHookContext) => Promise | void; + name: string; + onError?: (context: TsuguHookContext, error: unknown) => Promise | void; + order?: number; +}; + +type TsuguSimpleHookMethod = + | 'afterOutput' + | 'afterResolve' + | 'beforeParse' + | 'beforeRender'; + +/** + * 维护 Tsugu 命令执行生命周期 hook。 + */ +export class TsuguHookRegistry { + private readonly hooks: TsuguHook[]; + + constructor(hooks: readonly TsuguHook[] = []) { + this.hooks = [...hooks].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + } + + async beforeParse(context: TsuguHookContext) { + await this.emit('beforeParse', context); + } + + async afterResolve(context: TsuguHookContext) { + await this.emit('afterResolve', context); + } + + async beforeRender(context: TsuguHookContext) { + await this.emit('beforeRender', context); + } + + async afterOutput(context: TsuguHookContext) { + await this.emit('afterOutput', context); + } + + async onError(context: TsuguHookContext, error: unknown) { + for (const hook of this.hooks) { + await hook.onError?.(context, error); + } + } + + private async emit(method: TsuguSimpleHookMethod, context: TsuguHookContext) { + for (const hook of this.hooks) { + const handler = hook[method]; + if (typeof handler === 'function') { + await handler(context); + } + } + } +} + +/** + * 创建命令执行上下文,供 hook 在全链路中共享。 + */ +export function createTsuguHookContext( + operationKey: QqbotBangDreamOperationKey, + input: QqbotBangDreamCommandInput, +): TsuguHookContext { + return { + input, + operationKey, + query: extractTsuguInputText(input), + stage: 'start', + startedAt: Date.now(), + }; +} + +/** + * 创建默认日志 hook。 + */ +export function createTsuguLogHook(): TsuguHook { + return { + afterOutput: (context) => { + logger('operation', formatTsuguHookMessage('success', context)); + }, + beforeParse: (context) => { + logger('operation', formatTsuguHookMessage('start', context)); + }, + name: 'TsuguLogHook', + onError: (context, error) => { + logger( + 'operation', + `${formatTsuguHookMessage('error', context)} error=${getHookErrorMessage(error)}`, + ); + }, + }; +} + +function formatTsuguHookMessage( + status: 'error' | 'start' | 'success', + context: TsuguHookContext, +) { + const durationMs = Date.now() - context.startedAt; + return [ + `status=${status}`, + `operation=${context.operationKey}`, + `stage=${context.stage}`, + context.handlerName ? `handler=${context.handlerName}` : '', + context.query ? `query=${context.query}` : '', + context.imageCount === undefined ? '' : `imageCount=${context.imageCount}`, + `durationMs=${durationMs}`, + ] + .filter(Boolean) + .join(' '); +} + +function extractTsuguInputText(input: QqbotBangDreamCommandInput) { + const direct = `${input.query || input.text || input.raw || ''}`.trim(); + if (direct) return direct; + return Array.isArray(input.args) ? input.args.join(' ').trim() : ''; +} + +function getHookErrorMessage(error: unknown) { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + if (error === undefined || error === null) return ''; + return `${error}`; +} diff --git a/src/qqbot/qqbot.module.ts b/src/qqbot/qqbot.module.ts index af1a4bb..8f77319 100644 --- a/src/qqbot/qqbot.module.ts +++ b/src/qqbot/qqbot.module.ts @@ -41,6 +41,7 @@ import { QqbotPluginRegistryService } from './plugin/qqbot-plugin-registry.servi import { QqbotBangDreamClientService } from './plugins/bangDream/qqbot-bangdream-client.service'; import { QqbotBangDreamPluginService } from './plugins/bangDream/qqbot-bangdream.plugin'; import { QqbotBangDreamRendererService } from './plugins/bangDream/renderer/qqbot-bangdream-renderer.service'; +import { TsuguApplicationService } from './plugins/bangDream/renderer/tsugu-application.service'; import { QqbotFf14ClientService } from './plugins/ff14Market/qqbot-ff14-client.service'; import { QqbotFf14MarketPluginService } from './plugins/ff14Market/qqbot-ff14-market.plugin'; import { QqbotFflogsClientService } from './plugins/fflogs/qqbot-fflogs-client.service'; @@ -100,6 +101,7 @@ import { QqbotSendService } from './send/qqbot-send.service'; QqbotBangDreamClientService, QqbotBangDreamPluginService, QqbotBangDreamRendererService, + TsuguApplicationService, QqbotFf14ClientService, QqbotFf14MarketPluginService, QqbotFflogsClientService, diff --git a/test/qqbot/plugins/bangDream/qqbot-bangdream-client.service.spec.ts b/test/qqbot/plugins/bangDream/qqbot-bangdream-client.service.spec.ts index 1432083..2dcdf87 100644 --- a/test/qqbot/plugins/bangDream/qqbot-bangdream-client.service.spec.ts +++ b/test/qqbot/plugins/bangDream/qqbot-bangdream-client.service.spec.ts @@ -1,19 +1,19 @@ jest.mock( - '@/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service', + '@/qqbot/plugins/bangDream/renderer/tsugu-application.service', () => ({ - QqbotBangDreamRendererService: class QqbotBangDreamRendererService {}, + TsuguApplicationService: class TsuguApplicationService {}, }), ); import { QqbotBangDreamClientService } from '@/qqbot/plugins/bangDream/qqbot-bangdream-client.service'; -import type { QqbotBangDreamRendererService } from '@/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service'; +import type { TsuguApplicationService } from '@/qqbot/plugins/bangDream/renderer/tsugu-application.service'; describe('QqbotBangDreamClientService', () => { let service: QqbotBangDreamClientService; - let rendererService: jest.Mocked; + let tsuguApplicationService: jest.Mocked; beforeEach(() => { - rendererService = { + tsuguApplicationService = { checkHealth: jest.fn().mockReturnValue(true), execute: jest.fn().mockResolvedValue({ imageCount: 1, @@ -23,19 +23,19 @@ describe('QqbotBangDreamClientService', () => { source: 'Tsugu BangDream Bot 内置源码', }), } as any; - service = new QqbotBangDreamClientService(rendererService); + service = new QqbotBangDreamClientService(tsuguApplicationService); }); - it('checks embedded Tsugu renderer health', async () => { + it('checks embedded Tsugu application health', async () => { await expect(service.checkHealth()).resolves.toBe(true); - expect(rendererService.checkHealth).toHaveBeenCalledTimes(1); + expect(tsuguApplicationService.checkHealth).toHaveBeenCalledTimes(1); }); - it('searches song through embedded Tsugu renderer', async () => { + it('searches song through embedded Tsugu application facade', async () => { const result = await service.searchSong({ text: '夏祭り' }); - expect(rendererService.execute).toHaveBeenCalledWith( + expect(tsuguApplicationService.execute).toHaveBeenCalledWith( 'bangdream.song.search', { text: '夏祭り', @@ -52,7 +52,7 @@ describe('QqbotBangDreamClientService', () => { it('executes other Tsugu open commands through the same plugin facade', async () => { await service.execute('bangdream.card.search', { text: '1399' }); - expect(rendererService.execute).toHaveBeenCalledWith( + expect(tsuguApplicationService.execute).toHaveBeenCalledWith( 'bangdream.card.search', { text: '1399', @@ -61,7 +61,7 @@ describe('QqbotBangDreamClientService', () => { }); it('bubbles embedded Tsugu renderer errors as command errors', async () => { - rendererService.execute.mockRejectedValueOnce( + tsuguApplicationService.execute.mockRejectedValueOnce( new Error('错误: 没有有效的关键词'), ); diff --git a/test/qqbot/plugins/bangDream/tsugu-application.service.spec.ts b/test/qqbot/plugins/bangDream/tsugu-application.service.spec.ts new file mode 100644 index 0000000..e871b7a --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu-application.service.spec.ts @@ -0,0 +1,73 @@ +jest.mock( + '@/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service', + () => ({ + QqbotBangDreamRendererService: class QqbotBangDreamRendererService {}, + }), +); + +jest.mock('@/qqbot/plugins/bangDream/tsugu/models/main-data-store', () => ({ + __esModule: true, + default: {}, + waitForMainDataReady: jest.fn().mockResolvedValue(undefined), +})); + +import { ToolsService } from '@/common'; +import { TsuguApplicationService } from '@/qqbot/plugins/bangDream/renderer/tsugu-application.service'; +import { waitForMainDataReady } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-store'; +import type { QqbotBangDreamRendererService } from '@/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service'; + +describe('TsuguApplicationService', () => { + let rendererService: jest.Mocked; + let service: TsuguApplicationService; + + beforeEach(() => { + jest.clearAllMocks(); + rendererService = { + checkHealth: jest.fn().mockResolvedValue(true), + executeOperationHandler: jest.fn().mockResolvedValue({ + imageCount: 1, + operationKey: 'bangdream.song.search', + query: '夏祭り', + replyText: '[CQ:image,file=base64://base64-song-card]', + source: 'Tsugu BangDream Bot 内置源码', + }), + refreshDictionaryCache: jest.fn().mockResolvedValue(undefined), + } as any; + service = new TsuguApplicationService(rendererService, new ToolsService()); + }); + + it('refreshes dictionary cache during application bootstrap', async () => { + await service.onApplicationBootstrap(); + + expect(rendererService.refreshDictionaryCache).toHaveBeenCalledTimes(1); + }); + + it('executes a registry operation through the renderer handler', async () => { + const result = await service.execute('bangdream.song.search', { + text: '夏祭り', + }); + + expect(waitForMainDataReady).toHaveBeenCalledTimes(1); + expect(rendererService.executeOperationHandler).toHaveBeenCalledWith( + 'searchSong', + { + text: '夏祭り', + }, + ); + expect(result).toMatchObject({ + imageCount: 1, + operationKey: 'bangdream.song.search', + query: '夏祭り', + }); + }); + + it('normalizes command errors into string messages', async () => { + rendererService.executeOperationHandler.mockRejectedValueOnce( + '图片渲染失败', + ); + + await expect( + service.execute('bangdream.event.search', { text: '50' }), + ).rejects.toThrow('图片渲染失败'); + }); +}); diff --git a/test/qqbot/plugins/bangDream/tsugu/hook-registry.spec.ts b/test/qqbot/plugins/bangDream/tsugu/hook-registry.spec.ts new file mode 100644 index 0000000..e342542 --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/hook-registry.spec.ts @@ -0,0 +1,66 @@ +import { + createTsuguHookContext, + TsuguHookRegistry, +} from '@/qqbot/plugins/bangDream/tsugu/runtime/hook-registry'; + +describe('BangDream Tsugu hook registry', () => { + it('emits simple lifecycle hooks by configured order', async () => { + const events: string[] = []; + const registry = new TsuguHookRegistry([ + { + afterOutput: () => { + events.push('afterOutput:2'); + }, + beforeParse: () => { + events.push('beforeParse:2'); + }, + name: 'second', + order: 2, + }, + { + afterOutput: () => { + events.push('afterOutput:1'); + }, + beforeParse: () => { + events.push('beforeParse:1'); + }, + name: 'first', + order: 1, + }, + ]); + const context = createTsuguHookContext('bangdream.song.search', { + text: '夏祭り', + }); + + await registry.beforeParse(context); + await registry.afterOutput(context); + + expect(events).toEqual([ + 'beforeParse:1', + 'beforeParse:2', + 'afterOutput:1', + 'afterOutput:2', + ]); + }); + + it('passes shared context and error to error hooks', async () => { + const errors: string[] = []; + const registry = new TsuguHookRegistry([ + { + name: 'error-recorder', + onError: (context, error) => { + errors.push(`${context.operationKey}:${context.stage}:${error}`); + }, + }, + ]); + const context = createTsuguHookContext('bangdream.event.search', { + args: ['50'], + }); + context.stage = 'handler'; + + await registry.onError(context, '活动渲染失败'); + + expect(context.query).toBe('50'); + expect(errors).toEqual(['bangdream.event.search:handler:活动渲染失败']); + }); +});