diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index 2b47013..01b855d 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -291,7 +291,9 @@ export interface TsuguHook { - `command-renderers/song-list.ts`、`event-list.ts`、`card-list.ts`、`gacha-detail.ts`、`player-detail.ts`、`cutoff-detail.ts` 已开始改走 repository 创建模型或读取主数据。 - `models/song.ts`、`card.ts`、`event.ts`、`gacha.ts`、`player.ts` 的 Bestdori API/素材请求已开始改走 Bestdori provider;`models/cutoff.ts` 的 Bestdori/HHWX Tracker fallback 已改走 provider,并在 fallback 时输出数据源切换日志。 - 已新增 `models/event-data-repository.ts` 收口活动详情、横幅、背景、轮播、Logo、奖励表情和装饰素材请求;`models/event.ts` 不再直接依赖 Bestdori provider 或 `loadImage`。 -- `drawEventDetail` 的活动真实底图改为轻量图片背景,避免长图走模糊三角纹理导致超时;活动搜索默认遵循 `BANGDREAM_TSUGU_USE_EASY_BG=false`,不再强制简易背景。 +- `drawEventDetail` 的活动真实底图改为轻量图片背景,避免长图走模糊三角纹理导致超时;活动底图由 `bg_eventtop.png` 和 `trim_eventtop.png` 合成,避免只渲染模糊背景底图;活动搜索默认遵循 `BANGDREAM_TSUGU_USE_EASY_BG=false`,不再强制简易背景。 +- 已接入 `BANGDREAM_TSUGU_REQUEST_TIMEOUT_MS` 和 `BANGDREAM_TSUGU_MAIN_DATA_READY_TIMEOUT_MS`,HTTP 请求和主数据首次 ready 等待都有硬超时,BangDream 命令执行前会等待关键主数据集合可用。 +- 已新增 `scripts/bangdream-render-smoke.ps1`,BangDream 图片 smoke 通过父进程限时、子进程 PID 清理、生成后显式退出,避免本地调试命令卡住进程。 - 已新增 `test/qqbot/plugins/bangDream/tsugu/data-provider.spec.ts`,覆盖 provider URL 解析、Bestdori/HHWX mock 数据源、retry/cache wrapper。 - 本地图片烟测已生成查歌 `136`、查活动 `50` 简易背景和真实活动背景图片,证明 provider/repository 第一段迁移后仍能输出非空图片。 @@ -312,6 +314,13 @@ export interface TsuguHook { - 新增昵称或难度别名无需改 matcher 代码。 - 旧 search/fuzzy-search 测试全部通过。 +当前进度: + +- 已新增 `search/fuzzy-search-types.ts`、`search/search-dictionary-repository.ts`、`search/relation-matcher.ts`、`search/fuzzy-search-rule-registry.ts`,把搜索类型、搜索字典读取、关系表达式和规则注册表从 `fuzzy-search.ts` 中拆出。 +- `fuzzy-search.ts` 保留关键词拆分、结果校验和目标匹配的兼容入口,关键词解析改由 `FuzzySearchRuleRegistry` 按 number、level、relation、config、fallback 顺序处理,文件长度从 516 行降到 281 行。 +- `entity-list-matcher.ts` 改为只依赖搜索结果类型和关系匹配器,并支持延迟读取 `source`,修复 mainAPI 异步加载前捕获空数据源导致查歌/查卡/查活动搜索不到的问题。 +- `song-list.ts`、`card-list.ts`、`event-list.ts` 的实体列表匹配器已改为运行时读取 repository source,查歌 `夏祭り` 和查活动 `summer` 本地图片 smoke 均生成非空图片。 + ### Phase 5:渲染 theme、layout spec 和 section builder 目标:Canvas 绘制仍保留函数式性能,但颜色、尺寸、字体和区块顺序收口。 diff --git a/scripts/bangdream-render-smoke.ps1 b/scripts/bangdream-render-smoke.ps1 new file mode 100644 index 0000000..ac35d6c --- /dev/null +++ b/scripts/bangdream-render-smoke.ps1 @@ -0,0 +1,90 @@ +param( + [string]$OperationKey = "bangdream.event.search", + [string]$Text = "50", + [string]$DisplayedServerList = "cn jp", + [string]$OutFile = ".kt-workspace/bangdream-smoke/bangdream-smoke.jpg", + [int]$TimeoutSeconds = 45, + [switch]$UseEasyBg +) + +$ErrorActionPreference = "Stop" + +$ProjectRoot = Split-Path -Parent $PSScriptRoot +$ResolvedOutFile = if ([System.IO.Path]::IsPathRooted($OutFile)) { + $OutFile +} else { + Join-Path $ProjectRoot $OutFile +} +$OutDir = Split-Path -Parent $ResolvedOutFile +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + +$LogDir = Join-Path $ProjectRoot ".kt-workspace/bangdream-smoke" +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null +$LogName = [System.IO.Path]::GetFileNameWithoutExtension($ResolvedOutFile) +$StdoutLog = Join-Path $LogDir "$LogName.out.log" +$StderrLog = Join-Path $LogDir "$LogName.err.log" +Remove-Item -LiteralPath $StdoutLog,$StderrLog -Force -ErrorAction SilentlyContinue + +$Payload = @{ + input = @{ + compress = $true + displayedServerList = $DisplayedServerList + text = $Text + useEasyBG = [bool]$UseEasyBg + } + operationKey = $OperationKey + outFile = $ResolvedOutFile +} +$PayloadJson = $Payload | ConvertTo-Json -Compress -Depth 5 +$PayloadBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($PayloadJson)) + +$NodeCode = @" +const fs = require("fs"); +const path = require("path"); +const { ConfigService } = require("@nestjs/config"); +const payload = JSON.parse(Buffer.from("$PayloadBase64", "base64").toString("utf8")); +const { QqbotBangDreamRendererService } = require("./src/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service"); + +(async () => { + const service = new QqbotBangDreamRendererService(new ConfigService({}), undefined); + await service.onApplicationBootstrap(); + const result = await service.execute(payload.operationKey, payload.input); + const match = result.replyText.match(/base64:\/\/([A-Za-z0-9+/=]+)/); + if (!match) throw new Error("No image CQ payload"); + fs.mkdirSync(path.dirname(payload.outFile), { recursive: true }); + fs.writeFileSync(payload.outFile, Buffer.from(match[1], "base64")); + process.stdout.write(JSON.stringify({ + bytes: fs.statSync(payload.outFile).size, + imageCount: result.imageCount, + out: payload.outFile, + }, null, 2) + "\n"); + process.exit(0); +})().catch((error) => { + process.stderr.write(String(error?.stack || error) + "\n"); + process.exit(1); +}); +"@ +$NodeCodeBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($NodeCode)) +$EvalCode = "eval(Buffer.from('$NodeCodeBase64','base64').toString('utf8'))" + +$env:TS_NODE_TRANSPILE_ONLY = "true" +$Process = Start-Process ` + -FilePath "node" ` + -ArgumentList @("-r", "ts-node/register", "-r", "tsconfig-paths/register", "-e", $EvalCode) ` + -WorkingDirectory $ProjectRoot ` + -RedirectStandardOutput $StdoutLog ` + -RedirectStandardError $StderrLog ` + -PassThru ` + -WindowStyle Hidden + +if (-not $Process.WaitForExit($TimeoutSeconds * 1000)) { + Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue + Write-Output "BangDream smoke timed out and process $($Process.Id) was killed." + if (Test-Path $StdoutLog) { Get-Content $StdoutLog } + if (Test-Path $StderrLog) { Get-Content $StderrLog } + exit 124 +} + +if (Test-Path $StdoutLog) { Get-Content $StdoutLog } +if (Test-Path $StderrLog) { Get-Content $StderrLog } +exit $Process.ExitCode 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 f075460..abb597c 100644 --- a/src/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service.ts +++ b/src/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service.ts @@ -7,7 +7,7 @@ import { getPresentEvent } from '../tsugu/models/event'; import { Gacha, getPresentGachaList } from '../tsugu/models/gacha'; import { Server } from '../tsugu/models/server'; import { Song } from '../tsugu/models/song'; -import mainAPI from '../tsugu/models/main-data-store'; +import mainAPI, { waitForMainDataReady } from '../tsugu/models/main-data-store'; 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'; @@ -66,7 +66,8 @@ export class QqbotBangDreamRendererService implements OnApplicationBootstrap { ); } - checkHealth() { + async checkHealth() { + await waitForMainDataReady(); const data = mainAPI as { cards?: unknown; songs?: unknown }; if (!data.songs || !data.cards) { throw new Error('Tsugu 数据配置未加载'); @@ -79,6 +80,7 @@ export class QqbotBangDreamRendererService implements OnApplicationBootstrap { operationKey: QqbotBangDreamOperationKey, input: QqbotBangDreamCommandInput, ) { + await waitForMainDataReady(); const operation = getBangDreamOperationDefinition(operationKey); if (!operation) { throw new Error(`BangDream 插件能力不存在:${operationKey}`); diff --git a/src/qqbot/plugins/bangDream/tsugu/command-renderers/card-list.ts b/src/qqbot/plugins/bangDream/tsugu/command-renderers/card-list.ts index 3231122..670cee1 100644 --- a/src/qqbot/plugins/bangDream/tsugu/command-renderers/card-list.ts +++ b/src/qqbot/plugins/bangDream/tsugu/command-renderers/card-list.ts @@ -63,7 +63,7 @@ export async function drawCardList( //计算模糊搜索结果 export const matchCardList = createTsuguEntityMatcher({ - source: cardRepository.getSource(), + source: () => cardRepository.getSource(), /** * 在QQBot 图片视图层中创建Entity。 * diff --git a/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-list.ts b/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-list.ts index 433ddd8..a2c4eaf 100644 --- a/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-list.ts +++ b/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-list.ts @@ -144,7 +144,7 @@ export async function drawEventList( } const matchEventList = createTsuguEntityMatcher({ - source: eventRepository.getSource(), + source: () => eventRepository.getSource(), /** * 在QQBot 图片视图层中创建Entity。 * diff --git a/src/qqbot/plugins/bangDream/tsugu/command-renderers/song-list.ts b/src/qqbot/plugins/bangDream/tsugu/command-renderers/song-list.ts index 1276717..fc8a372 100644 --- a/src/qqbot/plugins/bangDream/tsugu/command-renderers/song-list.ts +++ b/src/qqbot/plugins/bangDream/tsugu/command-renderers/song-list.ts @@ -118,7 +118,7 @@ export async function drawSongList( // 计算歌曲模糊搜索结果 export const matchSongList = createTsuguEntityMatcher({ - source: songRepository.getSource(), + source: () => songRepository.getSource(), /** * 在QQBot 图片视图层中创建Entity。 * diff --git a/src/qqbot/plugins/bangDream/tsugu/data-clients/file-cache-client.ts b/src/qqbot/plugins/bangDream/tsugu/data-clients/file-cache-client.ts index 9b5c4aa..7978f21 100644 --- a/src/qqbot/plugins/bangDream/tsugu/data-clients/file-cache-client.ts +++ b/src/qqbot/plugins/bangDream/tsugu/data-clients/file-cache-client.ts @@ -1,8 +1,20 @@ import axios from 'axios'; import * as path from 'path'; import * as fs from 'fs'; +import { + BANGDREAM_TSUGU_ENV_KEYS, + normalizeBangDreamPositiveInteger, +} from '@/qqbot/plugins/bangDream/tsugu/runtime/runtime-options'; const errUrl: string[] = []; +const DEFAULT_REQUEST_TIMEOUT_MS = 8000; + +function getRequestTimeoutMs(): number { + return normalizeBangDreamPositiveInteger( + process.env[BANGDREAM_TSUGU_ENV_KEYS.requestTimeoutMs], + DEFAULT_REQUEST_TIMEOUT_MS, + ); +} /** * 在数据下载与缓存层中下载当前数据。 @@ -45,7 +57,11 @@ export async function download( const headers = eTag ? { 'If-None-Match': eTag } : {}; let response; try { - response = await axios.get(url, { headers, responseType: 'arraybuffer' }); + response = await axios.get(url, { + headers, + responseType: 'arraybuffer', + timeout: getRequestTimeoutMs(), + }); } catch (error) { if (error.response && error.response.status === 304) { const cachedData = fs.readFileSync(cacheFilePath); @@ -130,7 +146,11 @@ export async function getJsonAndSave( const headers = eTag ? { 'If-None-Match': eTag } : {}; let response; try { - response = await axios.get(url, { headers, responseType: 'arraybuffer' }); + response = await axios.get(url, { + headers, + responseType: 'arraybuffer', + timeout: getRequestTimeoutMs(), + }); } catch (error) { if (error.response && error.response.status === 304) { const cachedData = fs.readFileSync(cacheFilePath, 'utf-8'); diff --git a/src/qqbot/plugins/bangDream/tsugu/models/event-data-repository.ts b/src/qqbot/plugins/bangDream/tsugu/models/event-data-repository.ts index 4a9f42f..2d1bf32 100644 --- a/src/qqbot/plugins/bangDream/tsugu/models/event-data-repository.ts +++ b/src/qqbot/plugins/bangDream/tsugu/models/event-data-repository.ts @@ -1,4 +1,4 @@ -import { Image, loadImage } from 'skia-canvas'; +import { Canvas, Image, loadImage } from 'skia-canvas'; import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider'; import { bangDreamMainDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-repository'; import { @@ -80,12 +80,18 @@ class EventDataRepository { * * @param event - 活动资源上下文。 */ - async getBackgroundImage(event: EventAssetContext): Promise { + async getBackgroundImage(event: EventAssetContext): Promise { const server = getServerByPriority(event.startAt); const bgImageBuffer = await bangDreamBestdoriProvider.getAsset( `/assets/${Server[server]}/event/${event.assetBundleName}/topscreen_rip/bg_eventtop.png`, ); - return await loadImage(bgImageBuffer); + const backgroundImage = await loadImage(bgImageBuffer); + try { + const trimImage = await this.getTopscreenTrimImage(event); + return this.mergeTopscreenImages(backgroundImage, trimImage); + } catch { + return backgroundImage; + } } /** @@ -130,6 +136,31 @@ class EventDataRepository { return await loadImage(topscreenTrimImageBuffer); } + private mergeTopscreenImages( + backgroundImage: Image, + trimImage: Image, + ): Canvas { + const canvas = new Canvas(backgroundImage.width, backgroundImage.height); + const ctx = canvas.getContext('2d'); + ctx.drawImage(backgroundImage, 0, 0); + + const scale = Math.min( + backgroundImage.width / trimImage.width, + backgroundImage.height / trimImage.height, + 1, + ); + const width = trimImage.width * scale; + const height = trimImage.height * scale; + ctx.drawImage( + trimImage, + (backgroundImage.width - width) / 2, + backgroundImage.height - height, + width, + height, + ); + return canvas; + } + /** * 获取活动 Logo 图。 * diff --git a/src/qqbot/plugins/bangDream/tsugu/models/event.ts b/src/qqbot/plugins/bangDream/tsugu/models/event.ts index 82a3311..75f98b3 100644 --- a/src/qqbot/plugins/bangDream/tsugu/models/event.ts +++ b/src/qqbot/plugins/bangDream/tsugu/models/event.ts @@ -1,4 +1,4 @@ -import { Image } from 'skia-canvas'; +import { Canvas, Image } from 'skia-canvas'; import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server'; import { bangDreamMainDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-repository'; import { Attribute } from '@/qqbot/plugins/bangDream/tsugu/models/attribute'; @@ -213,7 +213,7 @@ export class Event { * * @returns 异步处理结果。 */ - async getEventBGImage(): Promise { + async getEventBGImage(): Promise { return await eventDataRepository.getBackgroundImage(this); } //活动规则轮播图 diff --git a/src/qqbot/plugins/bangDream/tsugu/models/main-data-store.ts b/src/qqbot/plugins/bangDream/tsugu/models/main-data-store.ts index e51ac43..3145b78 100644 --- a/src/qqbot/plugins/bangDream/tsugu/models/main-data-store.ts +++ b/src/qqbot/plugins/bangDream/tsugu/models/main-data-store.ts @@ -2,8 +2,33 @@ import { bestdoriApiPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/config' import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider'; import { bangDreamStaticPatchProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/static-patch-provider'; import { logger } from '@/qqbot/plugins/bangDream/tsugu/runtime/logger'; +import { + BANGDREAM_TSUGU_ENV_KEYS, + normalizeBangDreamPositiveInteger, +} from '@/qqbot/plugins/bangDream/tsugu/runtime/runtime-options'; const mainAPI: Record = {}; //main对象,用于存放所有api数据,数据来源于Bestdori网站 +const REQUIRED_MAIN_DATA_KEYS = ['cards', 'characters', 'events', 'gacha', 'songs']; +const DEFAULT_MAIN_DATA_READY_TIMEOUT_MS = 15000; + +function getMainDataReadyTimeoutMs(): number { + return normalizeBangDreamPositiveInteger( + process.env[BANGDREAM_TSUGU_ENV_KEYS.mainDataReadyTimeoutMs], + DEFAULT_MAIN_DATA_READY_TIMEOUT_MS, + ); +} + +function isMainDataReady(): boolean { + return REQUIRED_MAIN_DATA_KEYS.every((key) => { + const collection = mainAPI[key]; + return collection && Object.keys(collection).length > 0; + }); +} + +async function rejectAfter(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); + throw new Error(`BangDream 主数据首次加载超时:${ms}ms`); +} //加载mainAPI /** @@ -71,11 +96,24 @@ async function loadMainAPI(useCache: boolean = false) { } logger('mainAPI', 'initializing...'); -loadMainAPI(true).then(() => { +const initialLoadPromise = loadMainAPI(true).then(() => { logger('mainAPI', 'initializing done'); loadMainAPI(); }); setInterval(loadMainAPI, 1000 * 60 * 5); //5分钟更新一次 +/** + * 等待 BangDream 主数据完成首次加载。 + */ +export async function waitForMainDataReady(): Promise { + if (isMainDataReady()) { + return; + } + await Promise.race([initialLoadPromise, rejectAfter(getMainDataReadyTimeoutMs())]); + if (!isMainDataReady()) { + throw new Error('BangDream 主数据未完成关键集合加载'); + } +} + export default mainAPI; diff --git a/src/qqbot/plugins/bangDream/tsugu/runtime/runtime-options.ts b/src/qqbot/plugins/bangDream/tsugu/runtime/runtime-options.ts index 2667b42..6f464e5 100644 --- a/src/qqbot/plugins/bangDream/tsugu/runtime/runtime-options.ts +++ b/src/qqbot/plugins/bangDream/tsugu/runtime/runtime-options.ts @@ -11,6 +11,7 @@ export const BANGDREAM_TSUGU_ENV_KEYS = { compress: 'BANGDREAM_TSUGU_COMPRESS', displayedServers: 'BANGDREAM_TSUGU_DISPLAYED_SERVERS', hhwxBaseUrl: 'BANGDREAM_TSUGU_HHWX_BASE_URL', + mainDataReadyTimeoutMs: 'BANGDREAM_TSUGU_MAIN_DATA_READY_TIMEOUT_MS', mainServer: 'BANGDREAM_TSUGU_MAIN_SERVER', requestTimeoutMs: 'BANGDREAM_TSUGU_REQUEST_TIMEOUT_MS', retryCount: 'BANGDREAM_TSUGU_RETRY_COUNT', @@ -97,6 +98,14 @@ export const BANGDREAM_CN_ESTIMATE_START_EVENT_ID = 298; export const BANGDREAM_CN_BLOCKED_EVENT_IDS: readonly number[] = []; export const BANGDREAM_DEFAULT_NO_BANG_DAYS = 1; +export function normalizeBangDreamPositiveInteger( + value: unknown, + fallback: number, +) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +} + export function normalizeBangDreamBoolean( value: unknown, fallback: boolean, diff --git a/src/qqbot/plugins/bangDream/tsugu/search/entity-list-matcher.ts b/src/qqbot/plugins/bangDream/tsugu/search/entity-list-matcher.ts index ee3b7bb..e90c26e 100644 --- a/src/qqbot/plugins/bangDream/tsugu/search/entity-list-matcher.ts +++ b/src/qqbot/plugins/bangDream/tsugu/search/entity-list-matcher.ts @@ -1,11 +1,9 @@ import type { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server'; -import { - checkRelationList, - FuzzySearchResult, -} from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search'; +import { checkRelationList } from '@/qqbot/plugins/bangDream/tsugu/search/relation-matcher'; +import type { FuzzySearchResult } from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-types'; interface TsuguEntityMatcherOptions { - source: Record; + source: Record | (() => Record); createEntity: (id: number) => T; isCandidate?: (entity: T) => boolean; isReleased: (entity: T, displayedServerList: Server[]) => boolean; @@ -49,6 +47,15 @@ const getRelationList = (matches: FuzzySearchResult): string[] | undefined => ? (matches._relationStr as string[]) : undefined; +/** + * 在QQBot 图片视图层中获取当前实体源。 + * + * @param source - 静态实体源或延迟读取函数。 + */ +const getCurrentSource = ( + source: TsuguEntityMatcherOptions['source'], +) => (typeof source === 'function' ? source() : source); + /** * 在QQBot 图片视图层中判断是否需要Check关系表达式。 * @@ -76,13 +83,14 @@ export const createTsuguEntityMatcher = }: TsuguEntityMatcherOptions) => (matches: FuzzySearchResult, displayedServerList: Server[]): T[] => { const result: T[] = []; + const currentSource = getCurrentSource(source); const relationList = getRelationList(matches); const relationOnly = relationList !== undefined && getMatchKeyCount(matches) === 1; const useRelation = shouldCheckRelation(relationList, relationOnly); - for (const id in source) { - if (!hasOwn(source, id)) { + for (const id in currentSource) { + if (!hasOwn(currentSource, id)) { continue; } diff --git a/src/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-rule-registry.ts b/src/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-rule-registry.ts new file mode 100644 index 0000000..0d70b14 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-rule-registry.ts @@ -0,0 +1,225 @@ +import { + isValidRelationStr, + normalizeRelationKeyword, +} from '@/qqbot/plugins/bangDream/tsugu/search/relation-matcher'; +import type { + FuzzySearchConfig, + FuzzySearchConfigValue, + FuzzySearchMatchValue, + FuzzySearchResultWriter, +} from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-types'; + +const INTEGER_PATTERN = /^(0|[1-9]\d*)$/; + +export interface FuzzySearchKeyword { + lowerKeyword: string; + normalizedKeyword: string; + rawKeyword: string; +} + +export interface FuzzySearchRule { + name: string; + canHandle: (keyword: FuzzySearchKeyword) => boolean; + match: ( + keyword: FuzzySearchKeyword, + push: FuzzySearchResultWriter, + ) => void; +} + +export class FuzzySearchRuleRegistry { + constructor(private readonly rules: readonly FuzzySearchRule[]) {} + + /** + * 按注册顺序匹配并执行第一条可处理规则。 + * + * @param keyword - 结构化关键词。 + * @param push - 搜索结果写入器。 + */ + match( + keyword: FuzzySearchKeyword, + push: FuzzySearchResultWriter, + ): boolean { + const rule = this.rules.find((item) => item.canHandle(keyword)); + if (!rule) return false; + rule.match(keyword, push); + return true; + } +} + +/** + * 创建结构化关键词,供规则判断和写入使用。 + * + * @param rawKeyword - 用户输入关键词。 + */ +export function createFuzzySearchKeyword(rawKeyword: string): FuzzySearchKeyword { + const lowerKeyword = rawKeyword.toLowerCase(); + return { + lowerKeyword, + normalizedKeyword: normalizeRelationKeyword(lowerKeyword), + rawKeyword, + }; +} + +/** + * 创建默认模糊搜索规则注册表。 + * + * @param config - 搜索别名配置。 + */ +export function createDefaultFuzzySearchRuleRegistry( + config: FuzzySearchConfig, +) { + return new FuzzySearchRuleRegistry([ + createNumberRule(), + createLevelRule(), + createRelationRule(), + createConfigRule(config), + createFallbackRule(), + ]); +} + +/** + * 创建数字 ID 规则。 + */ +function createNumberRule(): FuzzySearchRule { + return { + canHandle: ({ lowerKeyword }) => isInteger(lowerKeyword), + match: ({ lowerKeyword }, push) => + push('_number')(parseInt(lowerKeyword, 10)), + name: 'number', + }; +} + +/** + * 创建等级关键词规则。 + */ +function createLevelRule(): FuzzySearchRule { + return { + canHandle: ({ normalizedKeyword }) => extractLvNumber(normalizedKeyword) !== null, + match: ({ normalizedKeyword }, push) => + push('songLevels')(extractLvNumber(normalizedKeyword) ?? 0), + name: 'level', + }; +} + +/** + * 创建关系表达式规则。 + */ +function createRelationRule(): FuzzySearchRule { + return { + canHandle: ({ normalizedKeyword }) => isValidRelationStr(normalizedKeyword), + match: ({ normalizedKeyword }, push) => + push('_relationStr')(normalizedKeyword), + name: 'relation', + }; +} + +/** + * 创建配置别名规则。 + * + * @param config - 搜索别名配置。 + */ +function createConfigRule(config: FuzzySearchConfig): FuzzySearchRule { + return { + canHandle: ({ normalizedKeyword }) => + collectConfigMatches(config, normalizedKeyword).length > 0, + match: ({ normalizedKeyword }, push) => { + for (const item of collectConfigMatches(config, normalizedKeyword)) { + push(item.type)(item.value); + } + }, + name: 'config', + }; +} + +/** + * 创建兜底全文规则。 + */ +function createFallbackRule(): FuzzySearchRule { + return { + canHandle: () => true, + match: ({ rawKeyword }, push) => push('_all')(rawKeyword), + name: 'fallback', + }; +} + +/** + * 从等级关键词中提取数字等级。 + * + * @param str - 标准化后的关键词。 + */ +function extractLvNumber(str: string): number | null { + const match = str.match(/^lv(\d+)$/i); + return match?.[1] ? parseInt(match[1], 10) : null; +} + +/** + * 判断字符串是否为非负整数。 + * + * @param value - 当前处理的值。 + */ +function isInteger(value: string): boolean { + return INTEGER_PATTERN.test(value); +} + +/** + * 判断对象是否包含指定自有属性。 + * + * @param source - 输入来源对象或数据集合。 + * @param key - 当前字段键名。 + */ +const hasOwn = (source: object, key: string) => + Object.prototype.hasOwnProperty.call(source, key); + +/** + * 把配置键转换成数字或字符串匹配值。 + * + * @param key - 当前字段键名。 + */ +function parseConfigKey(key: string): FuzzySearchMatchValue { + return isInteger(key) ? parseInt(key, 10) : key; +} + +/** + * 判断配置值是否命中关键词。 + * + * @param value - 当前处理的值。 + * @param keyword - 用户输入的搜索关键词。 + */ +function configValueMatches( + value: FuzzySearchConfigValue, + keyword: string, +): boolean { + if (typeof value === 'string') { + return value === keyword; + } + if (Array.isArray(value)) { + return value.includes(keyword); + } + if (value && typeof value === 'object') { + return hasOwn(value, keyword); + } + return false; +} + +/** + * 收集配置别名命中结果。 + * + * @param config - 搜索别名配置。 + * @param keyword - 标准化后的关键词。 + */ +function collectConfigMatches(config: FuzzySearchConfig, keyword: string) { + const result: Array<{ type: string; value: FuzzySearchMatchValue }> = []; + + for (const type in config) { + const typeConfig = config[type]; + for (const key in typeConfig) { + for (const value of typeConfig[key]) { + if (configValueMatches(value, keyword)) { + result.push({ type, value: parseConfigKey(key) }); + } + } + } + } + + return result; +} diff --git a/src/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-types.ts b/src/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-types.ts new file mode 100644 index 0000000..c4d3a15 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-types.ts @@ -0,0 +1,17 @@ +export type FuzzySearchMatchValue = string | number; + +export type FuzzySearchConfigValue = + | FuzzySearchMatchValue + | FuzzySearchMatchValue[] + | Record; + +export interface FuzzySearchConfig { + [type: string]: { [key: string]: FuzzySearchConfigValue[] }; +} + +export interface FuzzySearchResult { + [key: string]: FuzzySearchMatchValue[]; +} + +export type FuzzySearchResultWriter = + (key: string) => (value: FuzzySearchMatchValue) => void; diff --git a/src/qqbot/plugins/bangDream/tsugu/search/fuzzy-search.ts b/src/qqbot/plugins/bangDream/tsugu/search/fuzzy-search.ts index c573516..0d95fc7 100644 --- a/src/qqbot/plugins/bangDream/tsugu/search/fuzzy-search.ts +++ b/src/qqbot/plugins/bangDream/tsugu/search/fuzzy-search.ts @@ -1,28 +1,33 @@ -import * as fs from 'fs'; -import { fuzzySearchPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; -import { logger } from '@/qqbot/plugins/bangDream/tsugu/runtime/logger'; +import { + createDefaultFuzzySearchRuleRegistry, + createFuzzySearchKeyword, +} from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-rule-registry'; +import { searchDictionaryRepository } from '@/qqbot/plugins/bangDream/tsugu/search/search-dictionary-repository'; +import type { + FuzzySearchConfig, + FuzzySearchMatchValue, + FuzzySearchResult, + FuzzySearchResultWriter, +} from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-types'; -type FuzzySearchMatchValue = string | number; -type FuzzySearchConfigValue = - | FuzzySearchMatchValue - | FuzzySearchMatchValue[] - | Record; - -interface FuzzySearchConfig { - [type: string]: { [key: string]: FuzzySearchConfigValue[] }; -} - -export interface FuzzySearchResult { - [key: string]: FuzzySearchMatchValue[]; -} +export type { + FuzzySearchConfig, + FuzzySearchConfigValue, + FuzzySearchMatchValue, + FuzzySearchResult, +} from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-types'; +export { checkRelationList } from '@/qqbot/plugins/bangDream/tsugu/search/relation-matcher'; const KEYWORD_PATTERN = /["“”『』「」]([^"“”『』「」]+)["“”『』「」]|\S+/g; const QUOTE_EDGE_PATTERN = /^["“”『』「」]|["“”『』「」]$/g; const RESERVED_MATCH_KEYS = new Set(['_number', '_relationStr', '_all']); -const RELATION_PATTERNS = [/^<\d+$/, /^>\d+$/, /^\d+-\d+$/]; + +export const config: FuzzySearchConfig = + searchDictionaryRepository.loadConfig(); +const ruleRegistry = createDefaultFuzzySearchRuleRegistry(config); /** - * 在模糊搜索入口中判断对象是否包含指定自有属性。 + * 判断对象是否包含指定自有属性。 * * @param source - 输入来源对象或数据集合。 * @param key - 当前字段键名。 @@ -31,42 +36,9 @@ const hasOwn = (source: object, key: string) => Object.prototype.hasOwnProperty.call(source, key); /** - * 在模糊搜索入口中加载模糊搜索配置文件。 - * - * @returns 处理结果。 - */ -function loadConfig(): FuzzySearchConfig { - const fileContent = fs.readFileSync(fuzzySearchPath, 'utf-8'); - logger('fuzzySearch', 'loaded fuzzy search config'); - return JSON.parse(fileContent); -} - -/** - * 在模糊搜索入口中从等级关键词中提取数字等级。 - * - * @param str - str参数。 - * @returns 计算后的数值。 - */ -function extractLvNumber(str: string): number | null { - const match = str.match(/^lv(\d+)$/i); - return match?.[1] ? parseInt(match[1], 10) : null; -} - -/** - * 在模糊搜索入口中判断字符串是否为非负整数。 - * - * @param value - 当前处理的值。 - * @returns 判断结果。 - */ -function isInteger(value: string): boolean { - return /^(0|[1-9]\d*)$/.test(value); -} - -/** - * 在模糊搜索入口中按空白和引号拆分搜索关键词。 + * 按空白和引号拆分搜索关键词。 * * @param keyword - 用户输入的搜索关键词。 - * @returns 格式化后的文本。 */ function extractKeywords(keyword: string): string[] { return (keyword.match(KEYWORD_PATTERN) || []).map((item) => @@ -75,105 +47,20 @@ function extractKeywords(keyword: string): string[] { } /** - * 在模糊搜索入口中统一关系表达式中的符号写法。 - * - * @param keyword - 用户输入的搜索关键词。 - * @returns 格式化后的文本。 - */ -function normalizeRelationKeyword(keyword: string): string { - return keyword - .replace(/>/g, '>') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/</g, '<'); -} - -/** - * 在模糊搜索入口中创建向匹配结果追加值的写入器。 + * 创建向匹配结果追加值的写入器。 * * @param matches - 模糊搜索命中结果。 */ -const appendTo = - (matches: FuzzySearchResult) => +const appendTo = (matches: FuzzySearchResult): FuzzySearchResultWriter => (key: string) => (value: FuzzySearchMatchValue): void => { (matches[key] ??= []).push(value); }; /** - * 在模糊搜索入口中把配置键转换成数字或字符串匹配值。 - * - * @param key - 当前字段键名。 - * @returns 处理结果。 - */ -function parseConfigKey(key: string): FuzzySearchMatchValue { - return isInteger(key) ? parseInt(key, 10) : key; -} - -/** - * 在模糊搜索入口中判断配置值是否命中关键词。 + * 校验模糊搜索结果结构。 * * @param value - 当前处理的值。 - * @param keyword - 用户输入的搜索关键词。 - * @returns 判断结果。 - */ -function configValueMatches( - value: FuzzySearchConfigValue, - keyword: string, -): boolean { - if (typeof value === 'string') { - return value === keyword; - } - if (Array.isArray(value)) { - return value.includes(keyword); - } - if (value && typeof value === 'object') { - return hasOwn(value, keyword); - } - return false; -} - -/** - * 在模糊搜索入口中把配置命中结果写入模糊搜索结果。 - * - * @param keyword - 用户输入的搜索关键词。 - * @param push - push参数。 - * @returns 判断结果。 - */ -function appendConfigMatches( - keyword: string, - push: ReturnType, -): boolean { - let matched = false; - - for (const type in config) { - const typeConfig = config[type]; - const pushType = push(type); - - for (const key in typeConfig) { - const matchValue = parseConfigKey(key); - for (const value of typeConfig[key]) { - if (!configValueMatches(value, keyword)) { - continue; - } - - pushType(matchValue); - matched = true; - } - } - } - - return matched; -} - -export const config: FuzzySearchConfig = loadConfig(); - -// 自定义验证函数 -/** - * 在模糊搜索入口中校验模糊搜索结果结构。 - * - * @param value - 当前处理的值。 - * @returns 判断结果。 */ export function isFuzzySearchResult( value: unknown, @@ -189,71 +76,35 @@ export function isFuzzySearchResult( } /** - * 在模糊搜索入口中把用户关键词解析成结构化匹配条件。 + * 把用户关键词解析成结构化匹配条件。 * * @param keyword - 用户输入的搜索关键词。 - * @returns 处理结果。 */ export function fuzzySearch(keyword: string): FuzzySearchResult { const matches: FuzzySearchResult = {}; const push = appendTo(matches); for (const rawKeyword of extractKeywords(keyword)) { - const keywordLowerCase = rawKeyword.toLowerCase(); - - if (isInteger(keywordLowerCase)) { - push('_number')(parseInt(keywordLowerCase, 10)); - continue; - } - - const normalizedKeyword = normalizeRelationKeyword(keywordLowerCase); - const lvNumber = extractLvNumber(normalizedKeyword); - if (lvNumber !== null) { - push('songLevels')(lvNumber); - continue; - } - - if (isValidRelationStr(normalizedKeyword)) { - push('_relationStr')(normalizedKeyword); - continue; - } - - if (appendConfigMatches(normalizedKeyword, push)) { - continue; - } - - push('_all')(rawKeyword); + ruleRegistry.match(createFuzzySearchKeyword(rawKeyword), push); } return matches; } /** - * 在模糊搜索入口中判断关系表达式是否可用于范围匹配。 - * - * @param _relationStr - 关系表达式Str参数。 - * @returns 判断结果。 - */ -function isValidRelationStr(_relationStr: string): boolean { - return RELATION_PATTERNS.some((pattern) => pattern.test(_relationStr)); -} - -/** - * 在模糊搜索入口中判断字段是否为模糊搜索保留键。 + * 判断字段是否为模糊搜索保留键。 * * @param key - 当前字段键名。 - * @returns 判断结果。 */ function isReservedMatchKey(key: string): boolean { return RESERVED_MATCH_KEYS.has(key); } /** - * 在模糊搜索入口中判断候选值是否命中目标字段值。 + * 判断候选值是否命中目标字段值。 * * @param candidates - 候选匹配值列表。 * @param targetValue - 待比较的目标值。 - * @returns 判断结果。 */ function candidateMatches( candidates: FuzzySearchMatchValue[], @@ -278,13 +129,12 @@ function candidateMatches( } /** - * 在模糊搜索入口中判断数字别名是否命中目标字段。 + * 判断数字别名是否命中目标字段。 * * @param matches - 模糊搜索命中结果。 * @param target - 待匹配或待写入的目标对象。 * @param key - 当前字段键名。 * @param numberTypeKey - 允许使用数字别名匹配的字段列表。 - * @returns 判断结果。 */ function numberAliasMatches( matches: FuzzySearchResult, @@ -300,13 +150,12 @@ function numberAliasMatches( } /** - * 在模糊搜索入口中判断目标对象指定字段是否命中搜索条件。 + * 判断目标对象指定字段是否命中搜索条件。 * * @param matches - 模糊搜索命中结果。 * @param target - 待匹配或待写入的目标对象。 * @param key - 当前字段键名。 * @param numberTypeKey - 允许使用数字别名匹配的字段列表。 - * @returns 判断结果。 */ function targetMatchesKey( matches: FuzzySearchResult, @@ -321,11 +170,10 @@ function targetMatchesKey( } /** - * 在模糊搜索入口中判断兜底关键词是否命中任意目标值。 + * 判断兜底关键词是否命中任意目标值。 * * @param targetValue - 待比较的目标值。 * @param searchValue - 标准化后的搜索值。 - * @returns 判断结果。 */ function allKeywordMatches(targetValue: unknown, searchValue: string): boolean { if (typeof targetValue === 'string') { @@ -341,11 +189,10 @@ function allKeywordMatches(targetValue: unknown, searchValue: string): boolean { } /** - * 在模糊搜索入口中处理targetIncludes全部关键词。 + * 判断目标对象是否包含兜底关键词。 * * @param target - 待匹配或待写入的目标对象。 - * @param rawSearchValue - raw搜索值参数。 - * @returns 判断结果。 + * @param rawSearchValue - 原始搜索值。 */ function targetIncludesAllKeyword( target: any, @@ -365,10 +212,9 @@ function targetIncludesAllKeyword( } /** - * 在模糊搜索入口中获取匹配KeyCount。 + * 获取匹配条件 key 数量。 * * @param matches - 模糊搜索命中结果。 - * @returns 计算后的数值。 */ function getMatchKeyCount(matches: FuzzySearchResult): number { let count = 0; @@ -381,23 +227,21 @@ function getMatchKeyCount(matches: FuzzySearchResult): number { } /** - * 在模糊搜索入口中处理匹配结果Only全部。 + * 判断匹配结果是否只包含兜底关键词。 * * @param matches - 模糊搜索命中结果。 - * @param keyCount - keyCount参数。 - * @returns 判断结果。 + * @param keyCount - 匹配条件 key 数量。 */ function matchesOnlyAll(matches: FuzzySearchResult, keyCount: number): boolean { return matches._all !== undefined && keyCount === 1; } /** - * 在模糊搜索入口中执行结构化模糊搜索条件匹配。 + * 执行结构化模糊搜索条件匹配。 * * @param matches - 模糊搜索命中结果。 * @param target - 待匹配或待写入的目标对象。 * @param numberTypeKey - 允许使用数字别名匹配的字段列表。 - * @returns 判断结果。 */ export function match( matches: FuzzySearchResult, @@ -435,82 +279,3 @@ export function match( return matched; } - -interface RelationChecker { - pattern: RegExp; - test: (num: number, match: RegExpMatchArray) => boolean; -} - -const RELATION_CHECKERS: RelationChecker[] = [ - { - pattern: /^<(\d+)$/, - /** - * 在模糊搜索入口中处理test。 - * - * @param num - num参数。 - * @param match - 匹配参数。 - */ - test: (num, match) => num < parseFloat(match[1]), - }, - { - pattern: /^>(\d+)$/, - /** - * 在模糊搜索入口中处理test。 - * - * @param num - num参数。 - * @param match - 匹配参数。 - */ - test: (num, match) => num > parseFloat(match[1]), - }, - { - pattern: /^(\d+)-(\d+)$/, - /** - * 在模糊搜索入口中处理test。 - * - * @param num - num参数。 - * @param match - 匹配参数。 - */ - test: (num, match) => - num >= parseFloat(match[1]) && num <= parseFloat(match[2]), - }, -]; - -/** - * 在模糊搜索入口中创建数值关系表达式匹配器。 - * - * @param _relationStr - 关系表达式Str参数。 - * @returns 判断结果。 - */ -function createRelationMatcher(_relationStr: string): (num: number) => boolean { - for (const checker of RELATION_CHECKERS) { - const relationMatch = _relationStr.match(checker.pattern); - if (relationMatch) { - return (num) => checker.test(num, relationMatch); - } - } - throw new Error('Invalid relation string format'); -} - -// 以下为数字与范围函数 -/** - * 在模糊搜索入口中检查数值列表是否满足关系表达式。 - * - * @param num - num参数。 - * @param _relationStrList - 关系表达式Str列表参数。 - * @returns 判断结果。 - */ -export function checkRelationList( - num: number, - _relationStrList: string[], -): boolean { - for (const relationStr of _relationStrList) { - try { - if (createRelationMatcher(relationStr)(num)) { - return true; - } - } catch { - logger('fuzzySearch', 'Invalid relation string format'); - } - } - return false; -} diff --git a/src/qqbot/plugins/bangDream/tsugu/search/relation-matcher.ts b/src/qqbot/plugins/bangDream/tsugu/search/relation-matcher.ts new file mode 100644 index 0000000..301e3ed --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/search/relation-matcher.ts @@ -0,0 +1,101 @@ +import { logger } from '@/qqbot/plugins/bangDream/tsugu/runtime/logger'; + +const RELATION_PATTERNS = [/^<\d+$/, /^>\d+$/, /^\d+-\d+$/]; + +interface RelationChecker { + pattern: RegExp; + test: (num: number, match: RegExpMatchArray) => boolean; +} + +const RELATION_CHECKERS: RelationChecker[] = [ + { + pattern: /^<(\d+)$/, + /** + * 判断数字是否小于关系表达式右值。 + * + * @param num - 待匹配数字。 + * @param match - 关系表达式捕获结果。 + */ + test: (num, match) => num < parseFloat(match[1]), + }, + { + pattern: /^>(\d+)$/, + /** + * 判断数字是否大于关系表达式右值。 + * + * @param num - 待匹配数字。 + * @param match - 关系表达式捕获结果。 + */ + test: (num, match) => num > parseFloat(match[1]), + }, + { + pattern: /^(\d+)-(\d+)$/, + /** + * 判断数字是否落入关系表达式闭区间。 + * + * @param num - 待匹配数字。 + * @param match - 关系表达式捕获结果。 + */ + test: (num, match) => + num >= parseFloat(match[1]) && num <= parseFloat(match[2]), + }, +]; + +/** + * 统一关系表达式中的符号写法。 + * + * @param keyword - 用户输入的搜索关键词。 + */ +export function normalizeRelationKeyword(keyword: string): string { + return keyword + .replace(/>/g, '>') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/</g, '<'); +} + +/** + * 判断关系表达式是否可用于范围匹配。 + * + * @param relationStr - 关系表达式。 + */ +export function isValidRelationStr(relationStr: string): boolean { + return RELATION_PATTERNS.some((pattern) => pattern.test(relationStr)); +} + +/** + * 创建数值关系表达式匹配器。 + * + * @param relationStr - 关系表达式。 + */ +function createRelationMatcher(relationStr: string): (num: number) => boolean { + for (const checker of RELATION_CHECKERS) { + const relationMatch = relationStr.match(checker.pattern); + if (relationMatch) { + return (num) => checker.test(num, relationMatch); + } + } + throw new Error('Invalid relation string format'); +} + +/** + * 检查数值列表是否满足关系表达式。 + * + * @param num - 待匹配数字。 + * @param relationStrList - 关系表达式列表。 + */ +export function checkRelationList( + num: number, + relationStrList: string[], +): boolean { + for (const relationStr of relationStrList) { + try { + if (createRelationMatcher(relationStr)(num)) { + return true; + } + } catch { + logger('fuzzySearch', 'Invalid relation string format'); + } + } + return false; +} diff --git a/src/qqbot/plugins/bangDream/tsugu/search/search-dictionary-repository.ts b/src/qqbot/plugins/bangDream/tsugu/search/search-dictionary-repository.ts new file mode 100644 index 0000000..31f02cb --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/search/search-dictionary-repository.ts @@ -0,0 +1,19 @@ +import * as fs from 'fs'; +import { fuzzySearchPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; +import { logger } from '@/qqbot/plugins/bangDream/tsugu/runtime/logger'; +import type { FuzzySearchConfig } from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-types'; + +export class SearchDictionaryRepository { + constructor(private readonly filePath = fuzzySearchPath) {} + + /** + * 读取搜索别名配置。 + */ + loadConfig(): FuzzySearchConfig { + const fileContent = fs.readFileSync(this.filePath, 'utf-8'); + logger('fuzzySearch', 'loaded fuzzy search config'); + return JSON.parse(fileContent); + } +} + +export const searchDictionaryRepository = new SearchDictionaryRepository(); diff --git a/test/qqbot/plugins/bangDream/tsugu/fuzzy-search.spec.ts b/test/qqbot/plugins/bangDream/tsugu/fuzzy-search.spec.ts index 204e704..9fc5719 100644 --- a/test/qqbot/plugins/bangDream/tsugu/fuzzy-search.spec.ts +++ b/test/qqbot/plugins/bangDream/tsugu/fuzzy-search.spec.ts @@ -3,6 +3,11 @@ import { fuzzySearch, match, } from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search'; +import { + createDefaultFuzzySearchRuleRegistry, + createFuzzySearchKeyword, +} from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-rule-registry'; +import type { FuzzySearchResult } from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-types'; import { createTsuguEntityMatcher } from '@/qqbot/plugins/bangDream/tsugu/search/entity-list-matcher'; describe('Tsugu fuzzy search helpers', () => { @@ -36,6 +41,23 @@ describe('Tsugu fuzzy search helpers', () => { expect(checkRelationList(136, ['<100', '130-140'])).toBe(true); expect(checkRelationList(136, ['<100', '>200'])).toBe(false); }); + + it('routes configured aliases through the rule registry', () => { + const registry = createDefaultFuzzySearchRuleRegistry({ + songs: { + '136': ['夏祭り'], + }, + }); + const result: FuzzySearchResult = {}; + const push = (key: string) => (value: string | number) => { + (result[key] ??= []).push(value); + }; + + expect(registry.match(createFuzzySearchKeyword('夏祭り'), push)).toBe( + true, + ); + expect(result).toEqual({ songs: [136] }); + }); }); describe('Tsugu entity list matcher', () => { @@ -79,4 +101,33 @@ describe('Tsugu entity list matcher', () => { expect(result.map((entity) => entity.id)).toEqual([3]); }); + + it('reads entity source lazily when matching', () => { + let dynamicSource: Record = { + '1': {}, + }; + const matchDynamicEntity = createTsuguEntityMatcher({ + source: () => dynamicSource, + createEntity, + isReleased: (entity, displayedServerList) => + displayedServerList.some((server) => entity.releasedAt[server] != null), + isMatched: (matches, entity) => match(matches, entity, []), + relationValue: (entity) => entity.id, + }); + + expect(matchDynamicEntity({ _relationStr: ['1-3'] }, [CN_SERVER])).toHaveLength( + 1, + ); + + dynamicSource = { + '1': {}, + '3': {}, + }; + + expect( + matchDynamicEntity({ _relationStr: ['1-3'] }, [CN_SERVER]).map( + (entity) => entity.id, + ), + ).toEqual([1, 3]); + }); });