159 lines
6.4 KiB
TypeScript
159 lines
6.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import { parseMatchOutcome, rankCandidates } from "./match.js";
|
||
|
||
describe("parseMatchOutcome", () => {
|
||
it("maps matches and drops bad episodeId", () => {
|
||
const r = parseMatchOutcome({
|
||
isMatched: true,
|
||
matches: [{ episodeId: 12, animeTitle: "A", episodeTitle: "01" }, { episodeId: "x" }],
|
||
});
|
||
expect(r.isMatched).toBe(true);
|
||
expect(r.candidates).toHaveLength(1);
|
||
expect(r.candidates[0]?.episodeId).toBe(12);
|
||
});
|
||
});
|
||
|
||
describe("rankCandidates", () => {
|
||
it("single candidate is high confidence", () => {
|
||
const r = rankCandidates([
|
||
{ episodeId: 1, animeId: 1, animeTitle: "A", episodeTitle: "01", imageUrl: null },
|
||
]);
|
||
expect(r.highConfidence).toBe(true);
|
||
expect(r.candidates).toHaveLength(1);
|
||
});
|
||
it("close scores are not high confidence", () => {
|
||
const list = [
|
||
{ episodeId: 1, animeId: 1, animeTitle: "Show", episodeTitle: "01", imageUrl: null },
|
||
{ episodeId: 2, animeId: 1, animeTitle: "Show", episodeTitle: "01v2", imageUrl: null },
|
||
];
|
||
const r = rankCandidates(list);
|
||
expect(r.highConfidence).toBe(false);
|
||
});
|
||
it("empty candidates are not high confidence", () => {
|
||
const r = rankCandidates([]);
|
||
expect(r.highConfidence).toBe(false);
|
||
expect(r.candidates).toEqual([]);
|
||
});
|
||
it("score gap >= default lead (0.15) is high confidence", () => {
|
||
const list = [
|
||
{ episodeId: 2, animeId: 1, animeTitle: "Show", episodeTitle: null, imageUrl: "img" },
|
||
{ episodeId: 1, animeId: 1, animeTitle: "Show", episodeTitle: "01", imageUrl: null },
|
||
];
|
||
const r = rankCandidates(list);
|
||
expect(r.highConfidence).toBe(true);
|
||
expect(r.candidates[0]?.episodeId).toBe(1);
|
||
});
|
||
it("custom lead is honored", () => {
|
||
const list = [
|
||
{ episodeId: 2, animeId: 1, animeTitle: "Show", episodeTitle: null, imageUrl: "img" },
|
||
{ episodeId: 1, animeId: 1, animeTitle: "Show", episodeTitle: "01", imageUrl: null },
|
||
];
|
||
expect(rankCandidates(list, { lead: 0.5 }).highConfidence).toBe(false);
|
||
expect(rankCandidates(list, { lead: 0.3 }).highConfidence).toBe(true);
|
||
});
|
||
it("returns candidates sorted by score desc", () => {
|
||
const hi = {
|
||
episodeId: 1,
|
||
animeId: 1,
|
||
animeTitle: "A",
|
||
episodeTitle: "01",
|
||
imageUrl: "img",
|
||
};
|
||
const mid = {
|
||
episodeId: 2,
|
||
animeId: 1,
|
||
animeTitle: "B",
|
||
episodeTitle: "02",
|
||
imageUrl: null,
|
||
};
|
||
const low = {
|
||
episodeId: 3,
|
||
animeId: null,
|
||
animeTitle: null,
|
||
episodeTitle: null,
|
||
imageUrl: "x",
|
||
};
|
||
const r = rankCandidates([low, mid, hi]);
|
||
expect(r.candidates.map((c) => c.episodeId)).toEqual([1, 2, 3]);
|
||
});
|
||
|
||
it("本地标题包含命中加分:多候选时命中者高置信", () => {
|
||
const list = [
|
||
{ episodeId: 1, animeId: 1, animeTitle: "迷宫饭", episodeTitle: "01", imageUrl: null },
|
||
{ episodeId: 2, animeId: 2, animeTitle: "芙莉莲", episodeTitle: "01", imageUrl: null },
|
||
];
|
||
const r = rankCandidates(list, { hint: { title: "葬送的芙莉莲" } });
|
||
expect(r.highConfidence).toBe(true);
|
||
expect(r.candidates[0]?.episodeId).toBe(2);
|
||
});
|
||
|
||
it("本地标题完全相等加分高于部分包含", () => {
|
||
const list = [
|
||
{
|
||
episodeId: 1,
|
||
animeId: 1,
|
||
animeTitle: "命运石之门",
|
||
episodeTitle: "01",
|
||
imageUrl: null,
|
||
},
|
||
{
|
||
episodeId: 2,
|
||
animeId: 2,
|
||
animeTitle: "命运石之门 负荷领域",
|
||
episodeTitle: "01",
|
||
imageUrl: null,
|
||
},
|
||
];
|
||
// 本地标题「命运石之门」:候选 1 完全相等(+1),候选 2 为包含(+0.6)
|
||
const r = rankCandidates(list, { hint: { title: "命运石之门" } });
|
||
expect(r.highConfidence).toBe(true);
|
||
expect(r.candidates[0]?.episodeId).toBe(1);
|
||
});
|
||
|
||
it("rawName 包含候选标题也加分", () => {
|
||
const list = [
|
||
{ episodeId: 1, animeId: 1, animeTitle: "迷宫饭", episodeTitle: "01", imageUrl: null },
|
||
{ episodeId: 2, animeId: 2, animeTitle: "芙莉莲", episodeTitle: "01", imageUrl: null },
|
||
];
|
||
const r = rankCandidates(list, {
|
||
hint: { title: "本地标题", rawName: "[Sub] Sousou no Frieren - 01.mkv" },
|
||
});
|
||
// rawName 不含任一候选,无加分 → 同分不高置信
|
||
expect(r.highConfidence).toBe(false);
|
||
const r2 = rankCandidates(list, {
|
||
hint: { title: "x", rawName: "[Sub] 芙莉莲 - 01.mkv" },
|
||
});
|
||
expect(r2.highConfidence).toBe(true);
|
||
expect(r2.candidates[0]?.episodeId).toBe(2);
|
||
});
|
||
|
||
it("单字符候选名不参与包含命中,避免噪声加分", () => {
|
||
const list = [
|
||
{ episodeId: 1, animeId: 1, animeTitle: "T", episodeTitle: "E", imageUrl: null },
|
||
{ episodeId: 2, animeId: 2, animeTitle: "T2", episodeTitle: "E2", imageUrl: null },
|
||
];
|
||
const r = rankCandidates(list, {
|
||
hint: { title: "Title", rawName: "Title - 01.mkv", epNumber: 1 },
|
||
});
|
||
expect(r.highConfidence).toBe(false);
|
||
});
|
||
|
||
it("集号相等加分拉开同标题候选差距", () => {
|
||
const list = [
|
||
{ episodeId: 1, animeId: 1, animeTitle: "Show", episodeTitle: "02", imageUrl: null },
|
||
{ episodeId: 2, animeId: 1, animeTitle: "Show", episodeTitle: "01", imageUrl: null },
|
||
];
|
||
const r = rankCandidates(list, { hint: { title: "Show", epNumber: 1 } });
|
||
expect(r.highConfidence).toBe(true);
|
||
expect(r.candidates[0]?.episodeId).toBe(2);
|
||
});
|
||
|
||
it("无 hint 时保持字段存在性打分(默认 lead 0.15)", () => {
|
||
const list = [
|
||
{ episodeId: 1, animeId: 1, animeTitle: "迷宫饭", episodeTitle: "01", imageUrl: null },
|
||
{ episodeId: 2, animeId: 2, animeTitle: "芙莉莲", episodeTitle: "01", imageUrl: null },
|
||
];
|
||
expect(rankCandidates(list).highConfidence).toBe(false);
|
||
});
|
||
});
|