fix(trpc): bind ranked top candidate and cover low-confidence fetch

This commit is contained in:
noelorin 2026-09-26 20:39:23 +08:00
parent 188cf79270
commit 714f51a1a6
3 changed files with 107 additions and 2 deletions

View File

@ -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", () => {

View File

@ -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<typeof import("@app/danmaku")>();
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> = {}): 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 }),
);
});
});

View File

@ -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,