kt-template-online-api/test/qqbot/plugins/bangdream/application/bangdream-operation-branch-matrix.spec.ts

770 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type {
BangDreamCommandInput,
BangDreamOperationHandlerName,
BangDreamOperationKey,
} from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream.types';
/**
* 执行 BangDream 插件流程。
* @param name - 名称文本;影响 mockImageBuffer 的返回值。
*/
const mockImageBuffer = (name: string) => Buffer.from(`image:${name}`);
const mockFuzzySearch = jest.fn(() => ({ song: [136] }));
const mockSongCtor = jest.fn().mockImplementation((songId: number) => ({
songId,
}));
const mockDrawSongDetail = jest.fn(async () => [
mockImageBuffer('song-detail'),
]);
const mockDrawSongList = jest.fn(async () => [mockImageBuffer('song-list')]);
const mockDrawSongChart = jest.fn(async () => [mockImageBuffer('song-chart')]);
const mockDrawSongRandom = jest.fn(async () => [
mockImageBuffer('song-random'),
]);
const mockDrawSongMetaList = jest.fn(async () => [
mockImageBuffer('song-meta'),
]);
const mockDrawCardDetail = jest.fn(async () => [
mockImageBuffer('card-detail'),
]);
const mockDrawCardList = jest.fn(async () => [mockImageBuffer('card-list')]);
const mockCardGetTrainingStatusList = jest.fn(() => [false, true]);
const mockCardGetCardIllustrationImageBuffer = jest.fn(
async (trainingStatus: boolean) =>
mockImageBuffer(`card-illustration-${trainingStatus}`),
);
const mockCardCtor = jest.fn().mockImplementation((cardId: number) => ({
cardId,
getCardIllustrationImageBuffer: mockCardGetCardIllustrationImageBuffer,
getTrainingStatusList: mockCardGetTrainingStatusList,
isExist: true,
}));
const mockDrawCharacterDetail = jest.fn(async () => [
mockImageBuffer('character-detail'),
]);
const mockDrawCharacterList = jest.fn(async () => [
mockImageBuffer('character-list'),
]);
const mockGetPresentEvent = jest.fn(() => ({ eventId: 50 }));
const mockDrawEventDetail = jest.fn(async () => [
mockImageBuffer('event-detail'),
]);
const mockDrawEventList = jest.fn(async () => [mockImageBuffer('event-list')]);
const mockDrawEventStage = jest.fn(async () =>
Array.from({ length: 5 }, (_, index) =>
mockImageBuffer(`event-stage-${index}`),
),
);
const mockDrawPlayerDetail = jest.fn(async () => [
mockImageBuffer('player-detail'),
]);
const mockDrawGachaDetail = jest.fn(async () => [
mockImageBuffer('gacha-detail'),
]);
const mockDrawRandomGacha = jest.fn(async () => [
mockImageBuffer('gacha-simulate'),
]);
const mockGachaCtor = jest.fn().mockImplementation((gachaId: number) => ({
gachaId,
type: 'normal',
}));
const mockGetPresentGachaList = jest.fn(async () => [
{
gachaId: 300,
type: 'normal',
},
]);
const mockDrawCutoffDetail = jest.fn(async () => [
mockImageBuffer('cutoff-detail'),
]);
const mockDrawCutoffEventTop = jest.fn(async () => [
mockImageBuffer('cutoff-event-top'),
]);
const mockDrawCutoffAll = jest.fn(async () => [mockImageBuffer('cutoff-all')]);
const mockDrawCutoffListOfRecentEvent = jest.fn(async () => [
mockImageBuffer('cutoff-recent'),
]);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search',
() => ({
fuzzySearch: mockFuzzySearch,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/song/song.model',
() => ({
Song: mockSongCtor,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/song/song-detail.renderer',
() => ({
drawSongDetail: mockDrawSongDetail,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/song/song-search.renderer',
() => ({
drawSongList: mockDrawSongList,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/song/song-chart.renderer',
() => ({
drawSongChart: mockDrawSongChart,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/song/song-random.renderer',
() => ({
drawSongRandom: mockDrawSongRandom,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/song/song-meta.renderer',
() => ({
drawSongMetaList: mockDrawSongMetaList,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/card/card-detail.renderer',
() => ({
drawCardDetail: mockDrawCardDetail,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/card/card-search.renderer',
() => ({
drawCardList: mockDrawCardList,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/card/card.model',
() => ({
Card: mockCardCtor,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/character/character-detail.renderer',
() => ({
drawCharacterDetail: mockDrawCharacterDetail,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/character/character-search.renderer',
() => ({
drawCharacterList: mockDrawCharacterList,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/event/event.model',
() => ({
getPresentEvent: mockGetPresentEvent,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/event/event-detail.renderer',
() => ({
drawEventDetail: mockDrawEventDetail,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/event/event-search.renderer',
() => ({
drawEventList: mockDrawEventList,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/event/event-stage.renderer',
() => ({
drawEventStage: mockDrawEventStage,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/player/player-detail.renderer',
() => ({
drawPlayerDetail: mockDrawPlayerDetail,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha-detail.renderer',
() => ({
drawGachaDetail: mockDrawGachaDetail,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha-simulate.renderer',
() => ({
drawRandomGacha: mockDrawRandomGacha,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha.model',
() => ({
Gacha: mockGachaCtor,
getPresentGachaList: mockGetPresentGachaList,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/policy/gacha.policy',
() => ({
BANGDREAM_GACHA_DEFAULT_SPIN_COUNT: 10,
isBirthdayGachaType: jest.fn(() => false),
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-detail.renderer',
() => ({
drawCutoffDetail: mockDrawCutoffDetail,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-event-top.renderer',
() => ({
drawCutoffEventTop: mockDrawCutoffEventTop,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-all.renderer',
() => ({
drawCutoffAll: mockDrawCutoffAll,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-recent.renderer',
() => ({
drawCutoffListOfRecentEvent: mockDrawCutoffListOfRecentEvent,
}),
);
type ManifestOperation = {
handlerName: BangDreamOperationHandlerName;
key: BangDreamOperationKey;
};
type BranchCase = {
assertBranch: () => void;
expectedImageCount: number;
expectedQuery: string;
input: BangDreamCommandInput;
key: BangDreamOperationKey;
name: string;
};
const branchCases: BranchCase[] = [
{
/**
* 执行 BangDream回调。
*/
assertBranch: () => expect(mockDrawSongDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '136',
input: { text: '136' },
key: 'bangdream.song.search',
name: 'song.search numeric detail',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () => expect(mockDrawSongList).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '夏祭',
input: { text: '夏祭' },
key: 'bangdream.song.search',
name: 'song.search fuzzy list',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () => expect(mockDrawSongChart).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '136 3',
input: { text: '136 expert' },
key: 'bangdream.song.chart',
name: 'song.chart explicit difficulty',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () => expect(mockDrawSongRandom).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '随机曲',
input: { text: '' },
key: 'bangdream.song.random',
name: 'song.random empty query',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () => expect(mockDrawSongMetaList).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: 'cn',
input: { text: 'cn' },
key: 'bangdream.song.meta',
name: 'song.meta server',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () => expect(mockDrawCardDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '1001',
input: { text: '1001' },
key: 'bangdream.card.search',
name: 'card.search numeric detail',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () => expect(mockDrawCardList).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '香澄',
input: { text: '香澄' },
key: 'bangdream.card.search',
name: 'card.search fuzzy list',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () => {
expect(mockCardCtor).toHaveBeenCalledWith(1001);
expect(mockCardGetCardIllustrationImageBuffer).toHaveBeenCalledTimes(2);
},
expectedImageCount: 2,
expectedQuery: '1001',
input: { text: '1001' },
key: 'bangdream.card.illustration',
name: 'card.illustration trained variants',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () =>
expect(mockDrawCharacterDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '1',
input: { text: '1' },
key: 'bangdream.character.search',
name: 'character.search numeric detail',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () => expect(mockDrawCharacterList).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '香澄',
input: { text: '香澄' },
key: 'bangdream.character.search',
name: 'character.search fuzzy list',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () => expect(mockDrawEventDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '50',
input: { text: '50' },
key: 'bangdream.event.search',
name: 'event.search numeric detail',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () => expect(mockDrawEventList).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: 'summer',
input: { text: 'summer' },
key: 'bangdream.event.search',
name: 'event.search fuzzy list',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () =>
expect(mockDrawEventStage).toHaveBeenCalledWith(50, 3, true, true),
expectedImageCount: 5,
expectedQuery: '50',
input: { text: '50 -m cn' },
key: 'bangdream.event.stage',
name: 'event.stage meta split output',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () => expect(mockDrawPlayerDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '123456',
input: { text: '123456 cn' },
key: 'bangdream.player.search',
name: 'player.search server detail',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () => expect(mockDrawGachaDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '300',
input: { text: '300' },
key: 'bangdream.gacha.search',
name: 'gacha.search detail',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () => {
expect(mockGachaCtor).toHaveBeenCalledWith(300);
expect(mockDrawRandomGacha).toHaveBeenCalledTimes(1);
},
expectedImageCount: 1,
expectedQuery: '10 300',
input: { text: '10 300' },
key: 'bangdream.gacha.simulate',
name: 'gacha.simulate explicit gacha',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () => {
expect(mockGetPresentGachaList).toHaveBeenCalledWith(3);
expect(mockDrawRandomGacha).toHaveBeenCalledTimes(1);
},
expectedImageCount: 1,
expectedQuery: '10',
input: { text: '10 cn' },
key: 'bangdream.gacha.simulate',
name: 'gacha.simulate present gacha fallback',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () => expect(mockDrawCutoffDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '1000 50',
input: { text: 'ycx 1000 cn', eventId: 50 },
key: 'bangdream.cutoff.detail',
name: 'cutoff.detail tier detail',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () => expect(mockDrawCutoffEventTop).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '10 50',
input: { eventId: 50, mainServer: 'cn', tier: 10 },
key: 'bangdream.cutoff.detail',
name: 'cutoff.detail top10 branch',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () => expect(mockDrawCutoffAll).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '50',
input: { eventId: 50, text: 'cn' },
key: 'bangdream.cutoff.all',
name: 'cutoff.all event',
},
{
/**
* 执行 BangDream回调。
*/
assertBranch: () =>
expect(mockDrawCutoffListOfRecentEvent).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '1000 50',
input: { text: '1000 50 cn' },
key: 'bangdream.cutoff.recent',
name: 'cutoff.recent tier',
},
];
const manifestOperations: ManifestOperation[] = JSON.parse(
readFileSync(
join(process.cwd(), 'src/modules/qqbot/plugins/bangdream/plugin.json'),
'utf8',
),
).operations;
let operationsByKey: Map<BangDreamOperationKey, any>;
describe('BangDream operation branch matrix', () => {
beforeAll(async () => {
const { getBangDreamOperationsByHandlerName } =
await import('@/modules/qqbot/plugins/bangdream/src/operations');
const operationsByHandlerName = getBangDreamOperationsByHandlerName();
operationsByKey = new Map(
manifestOperations.map((operation) => [
operation.key,
operationsByHandlerName.get(operation.handlerName),
]),
);
});
beforeEach(() => {
jest.clearAllMocks();
});
it('covers every manifest command operation in the branch matrix', () => {
expect([...new Set(branchCases.map((item) => item.key))].sort()).toEqual(
manifestOperations.map((operation) => operation.key).sort(),
);
});
it.each(branchCases)(
'executes $name and compares output contract',
async (branchCase) => {
const operation = operationsByKey.get(branchCase.key);
expect(operation).toBeDefined();
const output = await operation!.execute(
branchCase.input,
createCommandContext(),
);
expect(output).toMatchObject({
imageCount: branchCase.expectedImageCount,
operationKey: branchCase.key,
query: branchCase.expectedQuery,
source: 'BangDream 内置插件',
});
expect(
output.replyText.match(/\[CQ:image,file=base64:\/\//g),
).toHaveLength(branchCase.expectedImageCount);
branchCase.assertBranch();
},
);
});
/**
* 创建 BangDream 插件对象或配置。
*/
function createCommandContext() {
/**
* 执行 BangDream 插件局部步骤。
* @param input - input 输入;使用 `query`、`text`、`raw` 字段生成结果。
*/
const pickText = (input: BangDreamCommandInput) =>
`${input.query || input.text || input.raw || ''}`.trim();
/**
* 执行 BangDream 插件局部步骤。
* @param value - 待转换值;驱动 `Number()` 的 BangDream步骤。
*/
const optionalNumber = (value: unknown) => {
if (value === undefined || value === null || value === '') return undefined;
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : undefined;
};
/**
* 收集 BangDream 插件数据。
* @param input - input 输入;使用 `args` 字段生成结果。
*/
const getTokens = (input: BangDreamCommandInput) => {
if (Array.isArray(input.args)) {
return input.args.map((item) => `${item}`.trim()).filter(Boolean);
}
return pickText(input).split(/\s+/).filter(Boolean);
};
/**
* 执行 BangDream 插件局部步骤。
* @param value - 待转换值;决定 BangDream条件分支。
*/
const normalizeServer = (value: unknown) => {
if (value === undefined || value === null || value === '') return undefined;
const raw = `${value}`.trim().toLowerCase();
const mapped: Record<string, number> = {
cn: 3,
en: 1,
jp: 0,
kr: 4,
tw: 2,
国服: 3,
日服: 0,
};
const numeric = optionalNumber(raw);
if (numeric !== undefined && numeric >= 0 && numeric <= 4) {
return numeric;
}
return mapped[raw];
};
/**
* 执行 BangDream 插件局部步骤。
* @param input - input 输入;使用 `mainServer`、`serverName`、`server` 字段生成结果。
* @param tokens - 协议 token执行 `tokens.find()` 对应的 BangDream步骤。
*/
const pickMainServer = (input: BangDreamCommandInput, tokens: string[]) => {
const explicit =
input.mainServer ??
input.serverName ??
input.server ??
tokens.find((item) => normalizeServer(item) !== undefined);
return normalizeServer(explicit) ?? 3;
};
return {
/**
* 执行 BangDream回调。
* @param _query - _query 输入;影响 drawFuzzyResult 的返回值。
* @param render - render 输入;影响 drawFuzzyResult 的返回值。
*/
drawFuzzyResult: async (
_query: string,
render: (matches: Record<string, unknown>) => Promise<Array<Buffer>>,
) => render({ result: [1] }),
/**
* 执行 BangDream回调。
* @param tokens - 协议 token影响 firstNumber 的返回值。
*/
firstNumber: (tokens: string[]) =>
tokens
.map((item) => optionalNumber(item))
.find((item) => item !== undefined),
/**
* 执行 BangDream回调。
* @param input - input 输入;驱动 `getTokens()` 的 BangDream步骤。
*/
firstToken: (input: BangDreamCommandInput) => getTokens(input)[0],
/**
* 读取 BangDream回调数据。
* @param input - input 输入;使用 `compress`、`useEasyBG` 字段生成结果。
* @param defaults - BangDream列表使用 `useEasyBG` 字段生成结果。
*/
getRenderOptions: (
input: BangDreamCommandInput,
defaults: { useEasyBG?: boolean } = {},
) => ({
compress: input.compress === undefined ? true : input.compress !== false,
displayedServerList: [3, 0],
mainServer: pickMainServer(input, getTokens(input)),
useEasyBG:
input.useEasyBG === undefined
? (defaults.useEasyBG ?? false)
: input.useEasyBG !== false,
}),
getTokens,
/**
* 判断 BangDream回调条件。
* @param value - 待转换值;驱动 `test()` 的 BangDream步骤。
*/
isInteger: (value: string) => /^(0|[1-9]\d*)$/.test(value),
/**
* 执行 BangDream回调。
* @param value - 待转换值;决定 BangDream条件分支。
* @param fallback - 兜底值;影响 normalizeBoolean 的返回值。
*/
normalizeBoolean: (value: unknown, fallback: boolean) => {
if (value === undefined || value === null || value === '')
return fallback;
if (typeof value === 'boolean') return value;
return ['1', 'true', 'yes', 'y', '-m'].includes(
`${value}`.trim().toLowerCase(),
);
},
optionalNumber,
/**
* 执行 BangDream回调。
* @param value - 待转换值;驱动 `optionalNumber()` 的 BangDream步骤。
*/
pickDifficulty: (value: unknown) => {
const numeric = optionalNumber(value);
if (numeric !== undefined) return numeric;
const mapped: Record<string, number> = {
easy: 0,
expert: 3,
hard: 2,
normal: 1,
special: 4,
};
return mapped[`${value || ''}`.trim().toLowerCase()];
},
pickMainServer,
pickText,
/**
* 执行 BangDream回调。
* @param explicit - explicit 输入;驱动 `optionalNumber()` 的 BangDream步骤。
* @param fallback - 兜底值;驱动 `optionalNumber()` 的 BangDream步骤。
* @param message - message 输入;影响 requireNumber 的返回值。
*/
requireNumber: (explicit: unknown, fallback: unknown, message: string) => {
const value = optionalNumber(explicit) ?? optionalNumber(fallback);
if (value === undefined) throw new Error(message);
return value;
},
/**
* 执行 BangDream回调。
* @param input - input 输入;驱动 `pickText()` 的 BangDream步骤。
* @param message - message 输入;影响 requireText 的返回值。
*/
requireText: (input: BangDreamCommandInput, message: string) => {
const value = pickText(input);
if (!value) throw new Error(message);
return value;
},
/**
* 执行 BangDream回调。
* @param tokens - 协议 token影响 secondNumber 的返回值。
*/
secondNumber: (tokens: string[]) =>
tokens
.map((item) => optionalNumber(item))
.filter((item) => item !== undefined)[1],
/**
* 执行 BangDream回调。
* @param operationKey - operationKey 输入;影响 toImageReply 的返回值。
* @param query - 查询参数 DTO限定 BangDream分页、搜索或详情查询条件。
* @param list - BangDream列表筛选 BangDream列表项。
*/
toImageReply: (
operationKey: BangDreamOperationKey,
query: string,
list: Array<Buffer | string>,
) => {
const images = list.filter((item): item is Buffer =>
Buffer.isBuffer(item),
);
if (images.length === 0) {
throw new Error(
list.find((item): item is string => typeof item === 'string') ||
'BangDream 未返回图片',
);
}
return {
imageCount: images.length,
operationKey,
query,
replyText: images
.map((item) => `[CQ:image,file=base64://${item.toString('base64')}]`)
.join('\n'),
source: 'BangDream 内置插件',
};
},
} as any;
}