diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index 52a541c..2732bf9 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -302,6 +302,7 @@ export interface TsuguHook { - 已新增 `models/card-resource-repository.ts`,把 `Card` 的详情请求、资源批次目录、图标/插画/trim 图片路径和重图缓存策略收口到 provider-backed repository;`card-resource-repository.spec.ts` 覆盖 `/api/cards` 缓存参数、`9999` 后资源批次、CN 优先资源路径和非 icon 图片 `memoryCache=false`,本地 `/查卡 472` 图片 smoke 输出非空。 - 已新增 `models/gacha-resource-repository.ts`,把 `Gacha` 的详情请求、首页横幅、screen 背景、`bg1` fallback 和 Logo 资源路径收口到 provider-backed repository;`gacha-resource-repository.spec.ts` 覆盖 `/api/gacha` 缓存参数、CN 优先 screen 路径、横幅缺失回退 Logo 和背景 fallback,本地 `/查卡池 259`、`/抽卡模拟 10 259` 图片 smoke 输出非空。 - 已新增 `models/event-stage-data-repository.ts`,把 `EventStage` 的 festival stages/rotationMusics 数据请求从 `bestdoriUrl + callAPIAndCacheResponse` 收口到 provider-backed repository;`event-stage-data-repository.spec.ts` 覆盖两个 festival API 路径和缓存参数,本地 `/查试炼 310` 保持拆成 5 张图片输出。 +- 已新增 `models/character-resource-repository.ts`,把 `Character` 的详情请求、角色图标、KV 立绘和名称横幅资源路径从 `bestdoriUrl + callAPIAndCacheResponse/downloadFileCache` 收口到 provider-backed repository;`character-resource-repository.spec.ts` 覆盖详情缓存策略和三类资源路径,本地 `/查角色 1` 图片 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/models/character-resource-repository.ts b/src/qqbot/plugins/bangDream/tsugu/models/character-resource-repository.ts new file mode 100644 index 0000000..6cd20a8 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/models/character-resource-repository.ts @@ -0,0 +1,84 @@ +import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider'; +import type { BangDreamDataProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/data-provider'; +import { formatNumber } from '@/qqbot/plugins/bangDream/tsugu/models/model-utils'; + +export class CharacterResourceRepository { + constructor( + private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + ) {} + + /** + * 获取角色远端详情。 + * + * @param characterId - 角色 ID。 + * @param update - 是否绕过缓存。 + */ + async getDetail( + characterId: number, + update: boolean = true, + ): Promise> { + return await this.provider.getJson>( + `/api/characters/${characterId}.json`, + { cacheTime: update ? 0 : 1 / 0 }, + ); + } + + /** + * 获取角色图标资源路径。 + * + * @param characterId - 角色 ID。 + */ + getIconPath(characterId: number): string { + return `/res/icon/chara_icon_${characterId}.png`; + } + + /** + * 获取角色 KV 立绘资源路径。 + * + * @param characterId - 角色 ID。 + */ + getIllustrationPath(characterId: number): string { + return `/assets/jp/ui/character_kv_image/${formatNumber(characterId, 3)}_rip/image.png`; + } + + /** + * 获取角色名称横幅资源路径。 + * + * @param characterId - 角色 ID。 + */ + getNameBannerPath(characterId: number): string { + return `/assets/jp/character_name_rip/name_top_chr${formatNumber( + characterId, + 2, + )}.png`; + } + + /** + * 下载角色图标资源。 + * + * @param characterId - 角色 ID。 + */ + async getIconBuffer(characterId: number): Promise { + return await this.provider.getAsset(this.getIconPath(characterId)); + } + + /** + * 下载角色 KV 立绘资源。 + * + * @param characterId - 角色 ID。 + */ + async getIllustrationBuffer(characterId: number): Promise { + return await this.provider.getAsset(this.getIllustrationPath(characterId)); + } + + /** + * 下载角色名称横幅资源。 + * + * @param characterId - 角色 ID。 + */ + async getNameBannerBuffer(characterId: number): Promise { + return await this.provider.getAsset(this.getNameBannerPath(characterId)); + } +} + +export const characterResourceRepository = new CharacterResourceRepository(); diff --git a/src/qqbot/plugins/bangDream/tsugu/models/character.ts b/src/qqbot/plugins/bangDream/tsugu/models/character.ts index cda39a1..439c069 100644 --- a/src/qqbot/plugins/bangDream/tsugu/models/character.ts +++ b/src/qqbot/plugins/bangDream/tsugu/models/character.ts @@ -1,9 +1,6 @@ import mainAPI from '@/qqbot/plugins/bangDream/tsugu/models/main-data-store'; -import { callAPIAndCacheResponse } from '@/qqbot/plugins/bangDream/tsugu/data-clients/api-cache-client'; import { Image, loadImage } from 'skia-canvas'; -import { downloadFileCache } from '@/qqbot/plugins/bangDream/tsugu/data-clients/asset-cache-client'; -import { formatNumber } from '@/qqbot/plugins/bangDream/tsugu/models/model-utils'; -import { bestdoriUrl } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; +import { characterResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/models/character-resource-repository'; export class Character { characterId: number; @@ -98,12 +95,10 @@ export class Character { * @param update - update参数,未传入时使用默认值。 */ async getData(update: boolean = true) { - const time = update ? 0 : 1 / 0; - const cardData = await callAPIAndCacheResponse( - `${bestdoriUrl}/api/characters/${this.characterId}.json`, - time, + return await characterResourceRepository.getDetail( + this.characterId, + update, ); - return cardData; } /** * 在 Character 模型中获取图标。 @@ -111,8 +106,8 @@ export class Character { * @returns 异步处理结果。 */ async getIcon(): Promise { - const iconBuffer = await downloadFileCache( - `${bestdoriUrl}/res/icon/chara_icon_${this.characterId}.png`, + const iconBuffer = await characterResourceRepository.getIconBuffer( + this.characterId, ); return await loadImage(iconBuffer); } @@ -122,9 +117,8 @@ export class Character { * @returns 异步处理结果。 */ async getIllustration(): Promise { - const illustrationBuffer = await downloadFileCache( - `${bestdoriUrl}/assets/jp/ui/character_kv_image/${formatNumber(this.characterId, 3)}_rip/image.png`, - ); + const illustrationBuffer = + await characterResourceRepository.getIllustrationBuffer(this.characterId); return await loadImage(illustrationBuffer); } /** @@ -133,9 +127,8 @@ export class Character { * @returns 异步处理结果。 */ async getNameBanner(): Promise { - const nameBannerBuffer = await downloadFileCache( - `${bestdoriUrl}/assets/jp/character_name_rip/name_top_chr${formatNumber(this.characterId, 2)}.png`, - ); + const nameBannerBuffer = + await characterResourceRepository.getNameBannerBuffer(this.characterId); return await loadImage(nameBannerBuffer); } /** diff --git a/test/qqbot/plugins/bangDream/tsugu/character-resource-repository.spec.ts b/test/qqbot/plugins/bangDream/tsugu/character-resource-repository.spec.ts new file mode 100644 index 0000000..9e3919c --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/character-resource-repository.spec.ts @@ -0,0 +1,81 @@ +import type { BangDreamDataProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/data-provider'; +import { CharacterResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/models/character-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 character resource repository', () => { + it('routes character detail requests through the provider with explicit cache policy', async () => { + const provider = createProviderMock(); + provider.getJson.mockResolvedValue({ ok: true }); + const repository = new CharacterResourceRepository(provider); + + await expect(repository.getDetail(1, true)).resolves.toEqual({ ok: true }); + await expect(repository.getDetail(1, false)).resolves.toEqual({ + ok: true, + }); + + expect(provider.getJson).toHaveBeenNthCalledWith( + 1, + '/api/characters/1.json', + { cacheTime: 0 }, + ); + expect(provider.getJson).toHaveBeenNthCalledWith( + 2, + '/api/characters/1.json', + { cacheTime: Infinity }, + ); + }); + + it('builds icon, illustration, and name banner paths', () => { + const repository = new CharacterResourceRepository(createProviderMock()); + + expect(repository.getIconPath(1)).toBe('/res/icon/chara_icon_1.png'); + expect(repository.getIllustrationPath(1)).toBe( + '/assets/jp/ui/character_kv_image/001_rip/image.png', + ); + expect(repository.getNameBannerPath(1)).toBe( + '/assets/jp/character_name_rip/name_top_chr01.png', + ); + }); + + it('downloads character assets through the provider', async () => { + const provider = createProviderMock(); + const iconBuffer = Buffer.from('icon'); + const illustrationBuffer = Buffer.from('illustration'); + const nameBannerBuffer = Buffer.from('name-banner'); + provider.getAsset + .mockResolvedValueOnce(iconBuffer) + .mockResolvedValueOnce(illustrationBuffer) + .mockResolvedValueOnce(nameBannerBuffer); + const repository = new CharacterResourceRepository(provider); + + await expect(repository.getIconBuffer(1)).resolves.toBe(iconBuffer); + await expect(repository.getIllustrationBuffer(1)).resolves.toBe( + illustrationBuffer, + ); + await expect(repository.getNameBannerBuffer(1)).resolves.toBe( + nameBannerBuffer, + ); + + expect(provider.getAsset).toHaveBeenNthCalledWith( + 1, + '/res/icon/chara_icon_1.png', + ); + expect(provider.getAsset).toHaveBeenNthCalledWith( + 2, + '/assets/jp/ui/character_kv_image/001_rip/image.png', + ); + expect(provider.getAsset).toHaveBeenNthCalledWith( + 3, + '/assets/jp/character_name_rip/name_top_chr01.png', + ); + }); +});