From 714f51a1a65e6cea749e55fffac5e99b91441c6c Mon Sep 17 00:00:00 2001 From: noelorin Date: Sat, 26 Sep 2026 20:39:23 +0800 Subject: [PATCH] fix(trpc): bind ranked top candidate and cover low-confidence fetch --- .../trpc/src/services/danmaku.service.test.ts | 24 ++++++ .../trpc/src/services/scrape.service.test.ts | 83 ++++++++++++++++++- packages/trpc/src/services/scrape.service.ts | 2 +- 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/packages/trpc/src/services/danmaku.service.test.ts b/packages/trpc/src/services/danmaku.service.test.ts index 9ee507c..6292169 100644 --- a/packages/trpc/src/services/danmaku.service.test.ts +++ b/packages/trpc/src/services/danmaku.service.test.ts @@ -212,6 +212,30 @@ describe("danmakuService.fetch", () => { }), ); }); + + it("多候选低置信:不自动绑,提示手选", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); + vi.mocked(danmakuDocsDao.get).mockResolvedValue(null); + vi.mocked(hasOpenCredentials).mockReturnValue(true); + vi.mocked(danmakuCacheDao.getValid).mockResolvedValue(null); + // 两条同分(score 1.0),lead 0 < 0.15 → highConfidence false + vi.mocked(openMatch).mockResolvedValue({ + ok: true, + errorMessage: null, + isMatched: true, + candidates: [ + { episodeId: 11, animeId: 1, animeTitle: "T", episodeTitle: "E", imageUrl: null }, + { episodeId: 22, animeId: 2, animeTitle: "T2", episodeTitle: "E2", imageUrl: null }, + ], + }); + const r = await danmakuService.fetch("u1", "m1"); + expect(r.source).toBe("none"); + expect(r.xml).toBe(""); + expect(r.message).toContain("手选"); + expect(mediaItemDao.setDanmakuMatch).not.toHaveBeenCalled(); + expect(openCommentXml).not.toHaveBeenCalled(); + expect(danmakuCacheDao.upsert).not.toHaveBeenCalled(); + }); }); describe("danmakuService.selectMatch", () => { diff --git a/packages/trpc/src/services/scrape.service.test.ts b/packages/trpc/src/services/scrape.service.test.ts index f379cca..8958dd2 100644 --- a/packages/trpc/src/services/scrape.service.test.ts +++ b/packages/trpc/src/services/scrape.service.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { openMatch, hasOpenCredentials } from "@app/danmaku"; +import { mediaItemDao } from "@app/dao"; +import type { MediaItem } from "@app/models"; import { AUTO_BIND_THRESHOLD, buildSearchQuery, @@ -6,6 +9,30 @@ import { pickDandanEpisode, scoreHit, } from "./scrape.service.js"; +import { scrapeService } from "./scrape.service.js"; + +vi.mock("@app/dao", () => ({ + mediaItemDao: { + getByIdForUser: vi.fn(), + updateScrape: vi.fn(), + listByPathPrefix: vi.fn(), + }, +})); + +vi.mock("@app/danmaku", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + openMatch: vi.fn(), + openCommentXml: vi.fn(), + hasOpenCredentials: vi.fn(), + openFetchJson: vi.fn(), + }; +}); + +vi.mock("./danmaku.service.js", () => ({ + remoteFileHash: vi.fn().mockResolvedValue("hash"), +})); describe("parseEpisodeFromFilename", () => { it("S01E02", () => { @@ -89,3 +116,57 @@ describe("scoreHit", () => { expect(s).toBeLessThan(AUTO_BIND_THRESHOLD); }); }); + +function mediaItem(over: Partial = {}): MediaItem { + return { + id: "m1", + userId: "u1", + mountId: null, + path: "/anime/Show - 01.mkv", + rawName: "Show - 01.mkv", + title: "Show", + size: 1024, + mime: null, + bangumiId: null, + epNumber: 1, + scrapeStatus: "pending", + scrapedAt: null, + posterUrl: null, + dandanplayEpisodeId: null, + matchedHash: null, + danmakuMatchSource: "none", + scannedAt: new Date(), + updatedAt: new Date(), + ...over, + }; +} + +describe("scrapeService.matchDandanplay 绑定一致性", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(hasOpenCredentials).mockReturnValue(true); + vi.mocked(openMatch).mockResolvedValue({ + ok: true, + errorMessage: null, + isMatched: true, + candidates: [ + // score 0:无 animeTitle/episodeTitle → rankCandidates 会排到末尾 + { episodeId: 11, animeId: 1, animeTitle: null, episodeTitle: null, imageUrl: null }, + // score 1.0 → ranked 第一名 + { episodeId: 22, animeId: 2, animeTitle: "T", episodeTitle: "E", imageUrl: null }, + ], + }); + }); + + it("绑定 ranked.candidates[0],且与返回 candidates[0] 一致", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); + const r = await scrapeService.matchDandanplay("u1", "m1"); + expect(r.matched).toBe(true); + expect(r.candidates[0]?.episodeId).toBe(22); + expect(mediaItemDao.updateScrape).toHaveBeenCalledWith( + "m1", + "u1", + expect.objectContaining({ dandanplayEpisodeId: 22 }), + ); + }); +}); diff --git a/packages/trpc/src/services/scrape.service.ts b/packages/trpc/src/services/scrape.service.ts index df45efa..a4af10e 100644 --- a/packages/trpc/src/services/scrape.service.ts +++ b/packages/trpc/src/services/scrape.service.ts @@ -234,7 +234,7 @@ export const scrapeService = { }; } const ranked = rankCandidates(outcome.candidates); - const best = outcome.candidates[0]; + const best = ranked.candidates[0]; if (best) { await mediaItemDao.updateScrape(mediaItemId, userId, { dandanplayEpisodeId: best.episodeId,