diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index bf8873d..b8d7bda 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -311,6 +311,7 @@ export interface TsuguHook { - 已新增 `models/item-resource-repository.ts`,把 `Item` 的道具缩略图路径从 `bestdoriUrl + downloadFileCache` 收口到 provider-backed repository;`item-resource-repository.spec.ts` 覆盖 material、star 和 common item 三类路径,本地 `/查卡池 259` 图片 smoke 输出非空。 - 已新增 `models/cutoff-event-top-repository.ts` 和 `render-blocks/player-ranking-resource-repository.ts`,把前十榜 eventtop 数据请求与排名徽章素材路径从 `bestdoriUrl + callAPIAndCacheResponse/downloadFileCache` 收口到 provider-backed repository;相关 spec 覆盖 eventtop query path、排名徽章路径和 provider 调用,本地 `/ycx 10 100 cn` 图片 smoke 输出非空。 - 已新增 `models/degree-resource-repository.ts`,把 `Degree` 的称号缩略图、称号框、称号图标、动态称号脚本和纹理素材路径从 `bestdoriUrl + downloadFile/downloadFileCache` 收口到 provider-backed repository;`degree-resource-repository.spec.ts` 覆盖旧/新缩略图 fallback、动态称号旧纹理白名单和 provider 调用,本地 `/ycx 10 100 cn` 图片 smoke 输出非空。 +- 已新增 `render-blocks/deck-rank-resource-repository.ts`,把玩家详情“乐队编成等级”Rank 图片从 `list-band-detail.ts` 的本地路径和 Bestdori URL 直拼收口到 repository,渲染层只负责 `Buffer -> Image`;`deck-rank-resource-repository.spec.ts` 覆盖本地素材优先、远端 `/res/icon/*.png` 兜底和 provider 调用,本地 `/查玩家 26591455 jp` 图片 smoke 输出非空。 - 固化:Windows 下不要用反斜杠测试路径直接调用 Jest pattern,容易出现 `Pattern ... - 0 matches`;指定文件测试统一使用 `pnpm exec jest --runInBand --runTestsByPath test/qqbot/plugins/bangDream/tsugu/.spec.ts ...`,路径用正斜杠。 ### Phase 4:搜索 specification 和 matcher diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/deck-rank-resource-repository.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/deck-rank-resource-repository.ts new file mode 100644 index 0000000..10fe900 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/deck-rank-resource-repository.ts @@ -0,0 +1,51 @@ +import { existsSync, readFileSync } from 'node:fs'; +import * as path from 'node:path'; + +import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider'; +import type { BangDreamDataProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/data-provider'; +import { assetsRootPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; + +export class DeckRankResourceRepository { + constructor( + private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + private readonly localRankRootPath: string = path.join( + assetsRootPath, + 'Rank', + ), + ) {} + + /** + * 获取本地乐队编成等级图片路径。 + * + * @param rankImageName - Rank 图片名称。 + */ + getLocalRankImagePath(rankImageName: string): string { + return path.join(this.localRankRootPath, `${rankImageName}.png`); + } + + /** + * 获取远端乐队编成等级图片路径。 + * + * @param rankImageName - Rank 图片名称。 + */ + getRemoteRankImagePath(rankImageName: string): string { + return `/res/icon/${rankImageName}.png`; + } + + /** + * 读取乐队编成等级图片,优先本地素材,缺失时回退到 Bestdori 资源。 + * + * @param rankImageName - Rank 图片名称。 + */ + async getRankImageBuffer(rankImageName: string): Promise { + const localImagePath = this.getLocalRankImagePath(rankImageName); + if (existsSync(localImagePath)) { + return readFileSync(localImagePath); + } + return await this.provider.getAsset( + this.getRemoteRankImagePath(rankImageName), + ); + } +} + +export const deckRankResourceRepository = new DeckRankResourceRepository(); diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-band-detail.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-band-detail.ts index bb4ea9d..843d7ea 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-band-detail.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-band-detail.ts @@ -1,19 +1,16 @@ import { Player } from '@/qqbot/plugins/bangDream/tsugu/models/player'; -import { Canvas, Image } from 'skia-canvas'; +import { Canvas, Image, loadImage } from 'skia-canvas'; import { drawList } from './list-frame'; import { resizeImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack'; import { Band } from '@/qqbot/plugins/bangDream/tsugu/models/band'; import { drawTextWithImages } from '@/qqbot/plugins/bangDream/tsugu/canvas/text'; import { starList } from './list-rarity'; import mainAPI from '@/qqbot/plugins/bangDream/tsugu/models/main-data-store'; -import { bestdoriUrl } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; -import { assetsRootPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; -import * as path from 'path'; -import { loadImageFromPath } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils'; import { BANGDREAM_DECK_TOTAL_RATING_ID, BANGDREAM_STAGE_CHALLENGE_BAND_ID, } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants'; +import { deckRankResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/deck-rank-resource-repository'; interface drawBandDetailsInListOptions { [bandId: number]: Array; @@ -114,7 +111,7 @@ export async function drawPlayerStageChallengeRankInList( } //画玩家信息内乐队卡组最高等级 -const rankImage: { [rankImageName: string]: Image } = {}; +const rankImageCache: { [rankImageName: string]: Image } = {}; /** * 在图片布局层中加载Rank图片。 * @@ -122,18 +119,12 @@ const rankImage: { [rankImageName: string]: Image } = {}; * @returns 异步处理结果。 */ async function loadRankImage(rankImageName: string): Promise { - if (rankImage[rankImageName] == undefined) { - try { - rankImage[rankImageName] = await loadImageFromPath( - path.join(assetsRootPath, `/Rank/${rankImageName}.png`), - ); - } catch { - rankImage[rankImageName] = await loadImageFromPath( - `${bestdoriUrl}/res/icon/${rankImageName}.png`, - ); - } + if (rankImageCache[rankImageName] == undefined) { + const rankImageBuffer = + await deckRankResourceRepository.getRankImageBuffer(rankImageName); + rankImageCache[rankImageName] = await loadImage(rankImageBuffer); } - return rankImage[rankImageName]; + return rankImageCache[rankImageName]; } /** diff --git a/test/qqbot/plugins/bangDream/tsugu/deck-rank-resource-repository.spec.ts b/test/qqbot/plugins/bangDream/tsugu/deck-rank-resource-repository.spec.ts new file mode 100644 index 0000000..dfd6f61 --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/deck-rank-resource-repository.spec.ts @@ -0,0 +1,69 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; + +import type { BangDreamDataProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/data-provider'; +import { DeckRankResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/deck-rank-resource-repository'; + +function createProviderMock(): jest.Mocked { + return { + getAsset: jest.fn(), + getJson: jest.fn(), + getTracker: jest.fn(), + name: 'MockBestdori', + resolveUrl: jest.fn((pathOrUrl) => `https://bestdori.example${pathOrUrl}`), + }; +} + +describe('BangDream deck rank resource repository', () => { + it('builds local and remote rank image paths', () => { + const repository = new DeckRankResourceRepository( + createProviderMock(), + 'D:/KT/assets/Rank', + ); + + expect(repository.getLocalRankImagePath('rank_4_1')).toBe( + path.join('D:/KT/assets/Rank', 'rank_4_1.png'), + ); + expect(repository.getRemoteRankImagePath('rank_4_1')).toBe( + '/res/icon/rank_4_1.png', + ); + }); + + it('uses local rank image buffers before remote fallback', async () => { + const provider = createProviderMock(); + const localRootPath = mkdtempSync(path.join(tmpdir(), 'kt-rank-')); + const localImagePath = path.join(localRootPath, 'rank_1.png'); + const localBuffer = Buffer.from('local-rank'); + writeFileSync(localImagePath, localBuffer); + const repository = new DeckRankResourceRepository( + provider, + localRootPath, + ); + + try { + await expect(repository.getRankImageBuffer('rank_1')).resolves.toEqual( + localBuffer, + ); + expect(provider.getAsset).not.toHaveBeenCalled(); + } finally { + rmSync(localRootPath, { force: true, recursive: true }); + } + }); + + it('downloads rank image buffers through the provider when local asset is missing', async () => { + const provider = createProviderMock(); + const remoteBuffer = Buffer.from('remote-rank'); + provider.getAsset.mockResolvedValue(remoteBuffer); + const repository = new DeckRankResourceRepository( + provider, + path.join(tmpdir(), 'missing-rank-root'), + ); + + await expect(repository.getRankImageBuffer('rank_7')).resolves.toBe( + remoteBuffer, + ); + + expect(provider.getAsset).toHaveBeenCalledWith('/res/icon/rank_7.png'); + }); +});