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

631 lines
18 KiB
TypeScript

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';
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[] = [
{
assertBranch: () => expect(mockDrawSongDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '136',
input: { text: '136' },
key: 'bangdream.song.search',
name: 'song.search numeric detail',
},
{
assertBranch: () => expect(mockDrawSongList).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '夏祭',
input: { text: '夏祭' },
key: 'bangdream.song.search',
name: 'song.search fuzzy list',
},
{
assertBranch: () => expect(mockDrawSongChart).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '136 3',
input: { text: '136 expert' },
key: 'bangdream.song.chart',
name: 'song.chart explicit difficulty',
},
{
assertBranch: () => expect(mockDrawSongRandom).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '随机曲',
input: { text: '' },
key: 'bangdream.song.random',
name: 'song.random empty query',
},
{
assertBranch: () => expect(mockDrawSongMetaList).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: 'cn',
input: { text: 'cn' },
key: 'bangdream.song.meta',
name: 'song.meta server',
},
{
assertBranch: () => expect(mockDrawCardDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '1001',
input: { text: '1001' },
key: 'bangdream.card.search',
name: 'card.search numeric detail',
},
{
assertBranch: () => expect(mockDrawCardList).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '香澄',
input: { text: '香澄' },
key: 'bangdream.card.search',
name: 'card.search fuzzy list',
},
{
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',
},
{
assertBranch: () =>
expect(mockDrawCharacterDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '1',
input: { text: '1' },
key: 'bangdream.character.search',
name: 'character.search numeric detail',
},
{
assertBranch: () => expect(mockDrawCharacterList).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '香澄',
input: { text: '香澄' },
key: 'bangdream.character.search',
name: 'character.search fuzzy list',
},
{
assertBranch: () => expect(mockDrawEventDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '50',
input: { text: '50' },
key: 'bangdream.event.search',
name: 'event.search numeric detail',
},
{
assertBranch: () => expect(mockDrawEventList).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: 'summer',
input: { text: 'summer' },
key: 'bangdream.event.search',
name: 'event.search fuzzy list',
},
{
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',
},
{
assertBranch: () => expect(mockDrawPlayerDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '123456',
input: { text: '123456 cn' },
key: 'bangdream.player.search',
name: 'player.search server detail',
},
{
assertBranch: () => expect(mockDrawGachaDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '300',
input: { text: '300' },
key: 'bangdream.gacha.search',
name: 'gacha.search detail',
},
{
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',
},
{
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',
},
{
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',
},
{
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',
},
{
assertBranch: () => expect(mockDrawCutoffAll).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '50',
input: { eventId: 50, text: 'cn' },
key: 'bangdream.cutoff.all',
name: 'cutoff.all event',
},
{
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();
},
);
});
function createCommandContext() {
const pickText = (input: BangDreamCommandInput) =>
`${input.query || input.text || input.raw || ''}`.trim();
const optionalNumber = (value: unknown) => {
if (value === undefined || value === null || value === '') return undefined;
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : undefined;
};
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);
};
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];
};
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 {
drawFuzzyResult: async (
_query: string,
render: (matches: Record<string, unknown>) => Promise<Array<Buffer>>,
) => render({ result: [1] }),
firstNumber: (tokens: string[]) =>
tokens.map((item) => optionalNumber(item)).find((item) => item !== undefined),
firstToken: (input: BangDreamCommandInput) => getTokens(input)[0],
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,
isInteger: (value: string) => /^(0|[1-9]\d*)$/.test(value),
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,
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,
requireNumber: (explicit: unknown, fallback: unknown, message: string) => {
const value = optionalNumber(explicit) ?? optionalNumber(fallback);
if (value === undefined) throw new Error(message);
return value;
},
requireText: (input: BangDreamCommandInput, message: string) => {
const value = pickText(input);
if (!value) throw new Error(message);
return value;
},
secondNumber: (tokens: string[]) =>
tokens
.map((item) => optionalNumber(item))
.filter((item) => item !== undefined)[1],
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;
}