kt-template-online-api/test/qqbot/plugins/bangdream/application/bangdream-package-entry.spec.ts

466 lines
16 KiB
TypeScript

import * as path from 'path';
import * as XLSX from 'xlsx';
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache',
() => ({
__esModule: true,
default: {},
waitForBangDreamCatalogReady: jest.fn().mockResolvedValue(undefined),
}),
);
const mockContext = {
checkHealth: jest.fn(),
refreshDictionaryCache: jest.fn(),
};
const mockConfigureBangDreamRuntimeIo = jest.fn();
const mockReadBangDreamRuntimeConfig = jest.fn(() => undefined);
const mockPreloadBangDreamRenderAssets = jest.fn();
const mockOperationModules = new Map<string, jest.Mock>();
const mockManifestOperations = [
['bangdream.song.search', 'searchSong'],
['bangdream.song.chart', 'getSongChart'],
['bangdream.song.random', 'randomSong'],
['bangdream.song.meta', 'getSongMeta'],
['bangdream.card.search', 'searchCard'],
['bangdream.card.illustration', 'getCardIllustration'],
['bangdream.character.search', 'searchCharacter'],
['bangdream.event.search', 'searchEvent'],
['bangdream.event.stage', 'getEventStage'],
['bangdream.player.search', 'searchPlayer'],
['bangdream.gacha.search', 'searchGacha'],
['bangdream.gacha.simulate', 'simulateGacha'],
['bangdream.cutoff.detail', 'getCutoffDetail'],
['bangdream.cutoff.all', 'getCutoffAll'],
['bangdream.cutoff.recent', 'getCutoffRecent'],
] as const;
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/application/bangdream-command-context',
() => ({
BangDreamCommandContext: jest.fn(() => mockContext),
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io',
() => ({
configureBangDreamRuntimeIo: mockConfigureBangDreamRuntimeIo,
readBangDreamRuntimeConfig: mockReadBangDreamRuntimeConfig,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/application/render-assets',
() => ({
preloadBangDreamRenderAssets: mockPreloadBangDreamRenderAssets,
}),
);
jest.mock('@/modules/qqbot/plugins/bangdream/src/operations', () => ({
/**
* 读取 BangDream回调数据。
*/
getBangDreamOperationsByHandlerName: () =>
new Map(
mockManifestOperations.map(([key, handlerName]) => {
const execute = jest.fn();
mockOperationModules.set(key, execute);
return [
handlerName,
{
catalogKeys:
key === 'bangdream.song.search'
? ['songs', 'meta', 'singer', 'bands', 'characters', 'events']
: undefined,
execute,
handlerName,
},
];
}),
),
}));
import { createPlugin } from '@/modules/qqbot/plugins/bangdream/src';
import { BangDreamCommandContext } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-command-context';
import { waitForBangDreamCatalogReady } from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache';
import {
fuzzySearchPath,
projectRoot,
} from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config';
import type { BangDreamRuntimeIo } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io';
const manifestOperations = mockManifestOperations.map(([key, handlerName]) => ({
handlerName,
key,
}));
const bangDreamPackageRoot = path.resolve(projectRoot, '..');
/**
* Creates a minimal generic BangDream manifest for package-entry adapter tests.
* @returns Manifest descriptor fields required by the generic worker `createPlugin` contract.
*/
function createGenericBangDreamManifest() {
return {
key: 'bangdream',
pluginKey: 'bangdream',
name: 'BangDream',
version: '1.0.0',
entry: 'src/index.ts',
runtime: {
configKeys: ['BANGDREAM_TSUGU_BESTDORI_BASE_URL'],
maxConcurrency: 1,
memoryMb: 256,
timeoutMs: 30000,
workerType: 'thread',
},
operations: manifestOperations,
tasks: [],
events: [],
};
}
/**
* Returns the BangDream runtime IO adapter that the package entry registered during generic creation.
* @returns Runtime IO object passed to `configureBangDreamRuntimeIo`.
*/
function getConfiguredRuntimeIo(): BangDreamRuntimeIo {
const io = mockConfigureBangDreamRuntimeIo.mock.calls.at(-1)?.[0];
if (!io) throw new Error('BangDream runtime IO was not configured');
return io as BangDreamRuntimeIo;
}
/**
* Creates a deterministic XLSX workbook buffer for generic readExcelRows adapter tests.
* @returns XLSX buffer containing one data row on the first worksheet.
*/
function createWorkbookBuffer() {
const workbook = XLSX.utils.book_new();
const worksheet = XLSX.utils.json_to_sheet([
{ name: 'Test Card', score: 42 },
]);
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
return XLSX.write(workbook, { bookType: 'xlsx', type: 'buffer' }) as Buffer;
}
/**
* Normalizes arbitrary BangDream package errors the same way the generic worker passes errors to entries.
* @param error - Error or thrown value produced while creating or executing the plugin.
* @returns Stable message string consumed by the package-local error adapter.
*/
function normalizeGenericBangDreamError(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
/**
* Provides a deterministic generic worker clock for package entry creation.
* @returns Fixed Date used to prove the entry accepts the generic `now` callback.
*/
function getGenericBangDreamNow() {
return new Date('2026-06-18T00:00:00.000Z');
}
/**
* Creates the generic BangDream plugin with a supplied host and default config snapshot.
* @param host - Generic host facade whose methods are inspected by adapter tests.
* @returns Plugin instance created through the generic worker contract.
*/
async function createGenericBangDreamPlugin(host: Record<string, unknown>) {
return await createPlugin({
host,
manifest: createGenericBangDreamManifest(),
normalizeError: normalizeGenericBangDreamError,
now: getGenericBangDreamNow,
runtime: {
configSnapshot: {
BANGDREAM_TSUGU_BESTDORI_BASE_URL: 'https://example.invalid/bestdori',
},
installationId: 'install-bangdream',
},
});
}
describe('BangDream package entry', () => {
beforeEach(() => {
jest.clearAllMocks();
mockOperationModules.clear();
mockContext.refreshDictionaryCache.mockResolvedValue(undefined);
mockContext.checkHealth.mockResolvedValue(true);
mockPreloadBangDreamRenderAssets.mockResolvedValue(undefined);
});
it('binds every manifest operation directly to the package operation modules', async () => {
const io = {
requestJson: jest.fn(),
} as any;
const plugin = createPlugin({
io,
operations: manifestOperations,
});
const songSearch = mockOperationModules.get('bangdream.song.search');
songSearch?.mockResolvedValueOnce({
imageCount: 1,
operationKey: 'bangdream.song.search',
query: '夏祭り',
replyText: '[CQ:image,file=base64://base64-song-card]',
source: 'BangDream 内置插件',
});
expect(mockConfigureBangDreamRuntimeIo).toHaveBeenCalledWith(io);
expect(BangDreamCommandContext).toHaveBeenCalledWith({
io,
operations: manifestOperations,
});
await plugin.activate();
await expect(plugin.health()).resolves.toMatchObject({
message: 'BangDream 插件可用',
status: 'healthy',
});
await expect(
plugin.executeOperation('bangdream.song.search', { text: '夏祭り' }),
).resolves.toMatchObject({
imageCount: 1,
query: '夏祭り',
});
expect(mockContext.refreshDictionaryCache).toHaveBeenCalledTimes(1);
expect(mockPreloadBangDreamRenderAssets).toHaveBeenCalledTimes(1);
expect(waitForBangDreamCatalogReady).toHaveBeenCalledWith([
'songs',
'meta',
'singer',
'bands',
'characters',
'events',
]);
expect(songSearch).toHaveBeenCalledWith({ text: '夏祭り' }, mockContext);
expect(manifestOperations).toHaveLength(15);
});
it('reports degraded health when catalog health fails instead of throwing', async () => {
mockContext.checkHealth.mockRejectedValueOnce(
new Error('Bestdori unavailable'),
);
const plugin = createPlugin({
operations: manifestOperations,
});
await expect(plugin.health()).resolves.toMatchObject({
message: 'Bestdori unavailable',
status: 'degraded',
});
});
it('normalizes operation errors without an application-service wrapper', async () => {
const plugin = createPlugin({
/**
* 执行 BangDream回调。
* @param error - 异常或失败对象;提取状态码、错误体、堆栈或失败原因。
*/
normalizeError: (error) => `normalized:${error}`,
operations: manifestOperations,
});
mockOperationModules
.get('bangdream.event.search')
?.mockRejectedValueOnce('图片渲染失败');
await expect(
plugin.executeOperation('bangdream.event.search', { text: '50' }),
).rejects.toThrow('normalized:图片渲染失败');
});
it('accepts the generic plugin platform createPlugin contract', /**
* Verifies the BangDream package entry can be created from the generic worker descriptor contract.
* @returns Assertion promise proving the entry exposes command execution and health hooks.
*/
async () => {
const plugin = await createGenericBangDreamPlugin({
/**
* Simulates the generic host config RPC; the BangDream adapter should prefer runtime.configSnapshot for sync config reads.
* @returns No value so the test fails if sync package code depends on this async RPC path.
*/
getConfig: async () => undefined,
/**
* Simulates the generic host batch config RPC that is present on real worker hosts.
* @returns Empty config map because this contract test uses the startup snapshot as its config source.
*/
getConfigMany: async () => ({}),
/**
* Simulates dictionary lookup for BangDream alias/server mappings during plugin activation.
* @returns Empty dictionary list because this test only checks entry construction.
*/
getDictItemsByKey: async () => [],
/**
* Simulates package asset reads through the generic worker host boundary.
* @returns Empty buffer because no render asset is consumed while creating the plugin instance.
*/
readAssetFile: async () => Buffer.from([]),
/**
* Simulates binary HTTP requests for BangDream resource downloads.
* @returns Empty buffer because this contract test never executes a networked operation.
*/
requestBuffer: async () => Buffer.from([]),
/**
* Simulates JSON HTTP requests for BangDream catalog/data clients.
* @returns Empty object because this contract test only asserts the created plugin shape.
*/
requestJson: async () => ({}),
/**
* Simulates host-mediated sleep used by retry logic.
* @returns Resolved promise without delaying the package-entry contract test.
*/
sleep: async () => undefined,
/**
* Simulates host warning logging for non-fatal worker adapter notices.
* @returns Nothing because warning output is irrelevant to plugin construction.
*/
warn: () => undefined,
});
expect(plugin).toEqual(
expect.objectContaining({
executeOperation: expect.any(Function),
health: expect.any(Function),
}),
);
});
it('maps absolute package asset paths to package-relative generic host reads', /**
* Verifies the generic BangDream IO adapter never sends absolute package paths to host file APIs.
* @returns Assertion promise covering package-root relative host path normalization.
*/
async () => {
const readAssetFile = jest.fn(
/**
* Simulates generic host package asset reads and records the normalized path.
* @param filePath - Package-relative path received by the host bridge.
* @returns Distinct asset bytes used to prove the adapter returns host content.
*/
async (filePath: string) => Buffer.from(`asset:${filePath}`),
);
await createGenericBangDreamPlugin({
/**
* Simulates the generic host config RPC; sync BangDream config must come from the runtime snapshot.
* @returns No value because this test only exercises file IO path normalization.
*/
getConfig: async () => undefined,
/**
* Simulates dictionary lookup for activation-time cache refresh.
* @returns Empty list because file IO path normalization is independent of dictionaries.
*/
getDictItemsByKey: async () => [],
readAssetFile,
/**
* Simulates JSON HTTP host calls that are not used in this path-normalization test.
* @returns Empty object for unused request paths.
*/
requestJson: async () => ({}),
/**
* Simulates host sleep used by retry paths outside this test.
* @returns Resolved promise without delay.
*/
sleep: async () => undefined,
/**
* Simulates host warning logging for non-fatal adapter messages.
* @returns Nothing because warning output is irrelevant here.
*/
warn: () => undefined,
});
const io = getConfiguredRuntimeIo();
const absoluteAssetPath = path.join(
bangDreamPackageRoot,
'src',
'assets',
'BG',
'live.png',
);
await expect(io.readAssetFile?.(absoluteAssetPath)).resolves.toEqual(
Buffer.from('asset:src/assets/BG/live.png'),
);
expect(readAssetFile).toHaveBeenCalledWith('src/assets/BG/live.png');
});
it('preloads sync JSON and parses Excel rows through generic host file reads', /**
* Verifies generic BangDream IO supplies sync JSON reads and XLSX parsing without absolute host paths.
* @returns Assertion promise covering synchronous JSON cache and XLSX buffer parsing.
*/
async () => {
const fuzzySearchConfig = {
aliases: { : ['natsumatsuri'] },
servers: ['cn'],
};
const workbookBuffer = createWorkbookBuffer();
const readJsonFile = jest.fn(
/**
* Simulates generic host JSON reads used to preload synchronous BangDream config files.
* @param filePath - Package-relative JSON path received by the host bridge.
* @returns Fuzzy-search config snapshot for sync package consumers.
*/
async (filePath: string) =>
filePath === 'src/config/static/fuzzy-search-settings.json'
? fuzzySearchConfig
: { filePath },
);
const readAssetFile = jest.fn(
/**
* Simulates generic host binary package reads for Excel static patch files.
* @param filePath - Package-relative asset or config path received by the host bridge.
* @returns XLSX workbook bytes for static patch parsing.
*/
async (filePath: string) =>
filePath.endsWith('.xlsx') ? workbookBuffer : Buffer.from([]),
);
await createGenericBangDreamPlugin({
/**
* Simulates the generic host config RPC; sync BangDream config must come from the runtime snapshot.
* @returns No value because this test only exercises package file IO.
*/
getConfig: async () => undefined,
/**
* Simulates dictionary lookup for activation-time cache refresh.
* @returns Empty list because file IO behavior is independent of dictionaries.
*/
getDictItemsByKey: async () => [],
readAssetFile,
readJsonFile,
/**
* Simulates JSON HTTP host calls that are not used in this file IO test.
* @returns Empty object for unused request paths.
*/
requestJson: async () => ({}),
/**
* Simulates host sleep used by retry paths outside this test.
* @returns Resolved promise without delay.
*/
sleep: async () => undefined,
/**
* Simulates host warning logging for non-fatal adapter messages.
* @returns Nothing because warning output is irrelevant here.
*/
warn: () => undefined,
});
const io = getConfiguredRuntimeIo();
const absoluteExcelPath = path.join(
bangDreamPackageRoot,
'src',
'config',
'static',
'cards.xlsx',
);
expect(io.readJsonFileSync?.(fuzzySearchPath)).toEqual(fuzzySearchConfig);
await expect(io.readExcelRows?.(absoluteExcelPath)).resolves.toEqual([
{ name: 'Test Card', score: 42 },
]);
expect(readJsonFile).toHaveBeenCalledWith(
'src/config/static/fuzzy-search-settings.json',
);
expect(readAssetFile).toHaveBeenCalledWith('src/config/static/cards.xlsx');
});
});