refactor: 接入 BangDream Tsugu 执行入口
This commit is contained in:
parent
47a48fb311
commit
4eec2d6d05
@ -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`。
|
||||
|
||||
## 文件迁移优先级
|
||||
|
||||
| 优先级 | 文件/目录 | 原因 |
|
||||
|
||||
@ -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+/=]+)/);
|
||||
|
||||
@ -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<QqbotBangDreamSongSummary> {
|
||||
return await this.rendererService.execute('bangdream.song.search', params);
|
||||
return await this.tsuguApplicationService.execute(
|
||||
'bangdream.song.search',
|
||||
params,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string>(
|
||||
BANGDREAM_TSUGU_ENV_KEYS.displayedServers,
|
||||
);
|
||||
this.configService.get<string>(BANGDREAM_TSUGU_ENV_KEYS.displayedServers);
|
||||
const defaultServers = this.dictionaryLoader.getDefaultDisplayedServers();
|
||||
if (!source) return defaultServers;
|
||||
const values = splitBangDreamOptionList(source);
|
||||
|
||||
@ -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<QqbotBangDreamCommandOutput> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
148
src/qqbot/plugins/bangDream/tsugu/runtime/hook-registry.ts
Normal file
148
src/qqbot/plugins/bangDream/tsugu/runtime/hook-registry.ts
Normal file
@ -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> | void;
|
||||
afterResolve?: (context: TsuguHookContext) => Promise<void> | void;
|
||||
beforeParse?: (context: TsuguHookContext) => Promise<void> | void;
|
||||
beforeRender?: (context: TsuguHookContext) => Promise<void> | void;
|
||||
name: string;
|
||||
onError?: (context: TsuguHookContext, error: unknown) => Promise<void> | 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}`;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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<QqbotBangDreamRendererService>;
|
||||
let tsuguApplicationService: jest.Mocked<TsuguApplicationService>;
|
||||
|
||||
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('错误: 没有有效的关键词'),
|
||||
);
|
||||
|
||||
|
||||
@ -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<QqbotBangDreamRendererService>;
|
||||
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('图片渲染失败');
|
||||
});
|
||||
});
|
||||
66
test/qqbot/plugins/bangDream/tsugu/hook-registry.spec.ts
Normal file
66
test/qqbot/plugins/bangDream/tsugu/hook-registry.spec.ts
Normal file
@ -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:活动渲染失败']);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user