test(porting): differential tests against MoviePilot Rust-accelerated parsers
Snapshot differential tests (known deviations registered, current state passes): - title/path recognition: 216 samples vs MP MetaInfo/MetaInfoPath - filter rules: 42 rules x 28 torrents vs MP FilterModule - RSS/indexer parsing: 15 feeds, 72 indexer samples - zhconv: 69 samples Generator: scripts/mp-sync/gen-differential.py; report: docs/porting/mp-rust-logic-parity.md Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b86f75c011
commit
591cc78c7d
44 files changed
+32206
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* MoviePilot 过滤规则 vs Slot 差分测试。
|
||||
*
|
||||
* filter-cases.json 由 MP 的 Python 回退实现生成(见 generatedFrom)。这里把可表达的规则
|
||||
* 手工翻译成 SlotSpec(每个 `>` 优先级层级一个 Slot),用 parseRelease + evaluateCandidate
|
||||
* 求"首个 accept 的层级",与 MP 的 level 比对。
|
||||
*
|
||||
* 断言:不一致集合 == deviations 登记集合。补迁消除差异后测试会失败,提醒删登记。
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
evaluateCandidate,
|
||||
type ParsedRelease,
|
||||
parseRelease,
|
||||
type ReleaseSource,
|
||||
rankForSlot,
|
||||
type SlotSpec,
|
||||
} from "../../src/index.ts";
|
||||
|
||||
interface Torrent {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
size: number;
|
||||
seeders: number;
|
||||
}
|
||||
interface Rule {
|
||||
id: string;
|
||||
rule: string;
|
||||
verdict: "full" | "partial" | "none";
|
||||
}
|
||||
interface Case {
|
||||
caseId: string;
|
||||
ruleId: string;
|
||||
torrentId: string;
|
||||
mp: { matched: boolean; priOrder: number | null; level: number | null };
|
||||
}
|
||||
type DeviationKind = "intentional" | "missing" | "our_bug" | "mp_bug" | "not_expressible";
|
||||
interface Deviation {
|
||||
kind: DeviationKind;
|
||||
note: string;
|
||||
pairs?: string[];
|
||||
}
|
||||
interface Fixture {
|
||||
generatedFrom: { commit: string; rustCrossCheck: { mismatches: unknown[] } };
|
||||
torrents: Torrent[];
|
||||
rules: Rule[];
|
||||
cases: Case[];
|
||||
deviations: Record<string, Deviation>;
|
||||
}
|
||||
|
||||
const fx = JSON.parse(readFileSync(new URL("./filter-cases.json", import.meta.url), "utf8")) as Fixture;
|
||||
|
||||
// ---------------------------------------------------------------- MP 关键字 → Slot 片段
|
||||
const MB = 1024 * 1024; // MP size_range 单位 MB = 1024*1024 字节
|
||||
const ALL: ReleaseSource[] = ["REMUX", "BluRay", "BluRay-Disc", "WEB-DL", "WEBRip", "HDTV", "HDRip", "DVD", "DVDRip"];
|
||||
const without = (...xs: ReleaseSource[]) => ALL.filter((s) => !xs.includes(s));
|
||||
|
||||
type Frag = Pick<SlotSpec, "hard"> & { unknown?: Partial<Record<string, "reject" | "accept">> };
|
||||
const RES_4K: Frag = { hard: { resolutions: ["2160p"] }, unknown: { resolution: "reject" } };
|
||||
const RES_1080: Frag = { hard: { resolutions: ["1080p", "1080i"] }, unknown: { resolution: "reject" } };
|
||||
const RES_720: Frag = { hard: { resolutions: ["720p"] }, unknown: { resolution: "reject" } };
|
||||
/** 正向来源条件:来源未知 = MP 正则不命中 = 拒绝 */
|
||||
const srcIs = (...s: ReleaseSource[]): Frag => ({ hard: { sources: s }, unknown: { source: "reject" } });
|
||||
/** 取反来源条件:来源未知 = MP 正则不命中 = !X 为真 = 放行 */
|
||||
const srcNot = (...s: ReleaseSource[]): Frag => ({ hard: { sources: without(...s) }, unknown: { source: "accept" } });
|
||||
const CNSUB: Frag = { hard: { requiredSubtitles: ["zh"] }, unknown: { subtitles: "reject" } };
|
||||
const size = (min?: number, max?: number): Frag => ({
|
||||
hard: {
|
||||
minSizeBytes: min !== undefined ? min * MB : undefined,
|
||||
maxSizeBytes: max !== undefined ? max * MB : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
/** AND 合并片段(同字段取交集由调用方保证不冲突) */
|
||||
function and(...fs: Frag[]): SlotSpec {
|
||||
const hard: SlotSpec["hard"] = {};
|
||||
const unknown: Record<string, "reject" | "accept"> = {};
|
||||
for (const f of fs) {
|
||||
for (const [k, v] of Object.entries(f.hard)) if (v !== undefined) (hard as Record<string, unknown>)[k] = v;
|
||||
Object.assign(unknown, f.unknown);
|
||||
}
|
||||
return { name: "mp", hard, unknownPolicy: { ...unknown } };
|
||||
}
|
||||
|
||||
/** 每条规则 → 各优先级层级的 SlotSpec(verdict=none 的规则不翻译) */
|
||||
const TRANSLATE: Record<string, SlotSpec[]> = {
|
||||
R01: [and(RES_4K)],
|
||||
R02: [and(RES_1080)],
|
||||
R03: [and(RES_720)],
|
||||
R04: [and({ hard: { resolutions: ["2160p", "1080p", "1080i"] }, unknown: { resolution: "reject" } })],
|
||||
R05: [and(RES_4K), and(RES_1080), and(RES_720)],
|
||||
R06: [and(srcIs("BluRay-Disc"))],
|
||||
R07: [and(srcNot("BluRay-Disc"))],
|
||||
R08: [and(srcIs("REMUX"))],
|
||||
R09: [and(srcNot("REMUX"))],
|
||||
R10: [and(srcIs("WEB-DL", "WEBRip"))],
|
||||
R11: [and(srcNot("BluRay-Disc", "REMUX"))],
|
||||
R12: [and(RES_4K, srcNot("REMUX")), and(RES_1080, srcIs("WEB-DL", "WEBRip"))],
|
||||
R13: [and(CNSUB)],
|
||||
R14: [and(CNSUB, RES_4K, srcNot("BluRay-Disc"))],
|
||||
R15: [and(CNSUB, RES_1080), and(RES_1080)],
|
||||
R16: [and(size(2000, 30000))],
|
||||
R17: [and(size(undefined, 8000))],
|
||||
R18: [and(RES_4K, size(2000, 30000))],
|
||||
R19: [and(srcNot("BluRay-Disc", "REMUX", "WEB-DL", "WEBRip"))],
|
||||
R20: [and({ hard: { sources: ["BluRay"], requireEncode: true }, unknown: { source: "reject" } })],
|
||||
R21: [and(RES_4K, srcNot("BluRay-Disc", "REMUX")), and(RES_1080, srcNot("BluRay-Disc", "REMUX"))],
|
||||
R22: [
|
||||
and(
|
||||
{ hard: { resolutions: ["2160p", "1080p", "1080i"] }, unknown: { resolution: "reject" } },
|
||||
srcIs("WEB-DL", "WEBRip"),
|
||||
),
|
||||
],
|
||||
// partial:近似翻译
|
||||
R23: [and(CNSUB)], // 无 TMDB 原语种放行
|
||||
R24: [and(RES_4K)], // 无 !DOLBY
|
||||
R25: [and(CNSUB, srcNot("BluRay-Disc"))], // 无 SPECSUB
|
||||
R26: [and(size(20000))],
|
||||
};
|
||||
|
||||
const parsed = new Map<string, ParsedRelease>(
|
||||
fx.torrents.map((t) => [
|
||||
t.id,
|
||||
{ ...parseRelease(t.title, t.description ? { subtitle: t.description } : {}), sizeBytes: t.size },
|
||||
]),
|
||||
);
|
||||
|
||||
function ourLevel(ruleId: string, torrentId: string): number | null {
|
||||
const levels = TRANSLATE[ruleId]!;
|
||||
const r = parsed.get(torrentId)!;
|
||||
for (let i = 0; i < levels.length; i++) if (evaluateCandidate(levels[i]!, r).decision === "accept") return i + 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 排序语义:单 Slot + rankForSlot vs MP 优先级
|
||||
/** MP `4K > 1080P > 720P` 用单个 Slot(允许三种分辨率)+ rankForSlot 表达时的逆序对 */
|
||||
const RANK_CHECKS: Record<string, { rule: string; spec: SlotSpec }> = {
|
||||
"R05~rank": {
|
||||
rule: "R05",
|
||||
spec: and({ hard: { resolutions: ["2160p", "1080p", "1080i", "720p"] }, unknown: { resolution: "reject" } }),
|
||||
},
|
||||
};
|
||||
|
||||
function rankInversions(key: string): string[] {
|
||||
const { rule, spec } = RANK_CHECKS[key]!;
|
||||
const level = new Map(fx.cases.filter((c) => c.ruleId === rule).map((c) => [c.torrentId, c.mp.level]));
|
||||
const ranked = rankForSlot(
|
||||
spec,
|
||||
fx.torrents.map((t) => ({ ...parsed.get(t.id)!, raw: t.id })),
|
||||
).filter((e) => e.evaluation.decision === "accept");
|
||||
const order = ranked.map((e) => e.candidate.raw);
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < order.length; i++)
|
||||
for (let j = i + 1; j < order.length; j++) {
|
||||
const a = level.get(order[i]!);
|
||||
const b = level.get(order[j]!);
|
||||
// 我们把 a 排在 b 前,MP 却认为 b 的层级更优
|
||||
if (a != null && b != null && b < a) out.push(`${order[j]}>${order[i]}`);
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 断言
|
||||
describe("MP 过滤规则差分(filter-cases.json)", () => {
|
||||
it("fixture 完整:≥20 种子,Python/Rust 一致,每条可表达规则都有翻译", () => {
|
||||
expect(fx.torrents.length).toBeGreaterThanOrEqual(20);
|
||||
expect(fx.generatedFrom.rustCrossCheck.mismatches).toEqual([]);
|
||||
const translatable = fx.rules.filter((r) => r.verdict !== "none").map((r) => r.id);
|
||||
expect(Object.keys(TRANSLATE).sort()).toEqual(translatable.sort());
|
||||
});
|
||||
|
||||
it("不一致集合 == deviations 登记集合", () => {
|
||||
const actual: Record<string, { mp: number | null; ours: number | null }> = {};
|
||||
for (const c of fx.cases) {
|
||||
if (!TRANSLATE[c.ruleId]) continue;
|
||||
const ours = ourLevel(c.ruleId, c.torrentId);
|
||||
if (ours !== c.mp.level) actual[c.caseId] = { mp: c.mp.level, ours };
|
||||
}
|
||||
const registered = Object.keys(fx.deviations).filter((k) => !k.includes("~"));
|
||||
expect(Object.keys(actual).sort()).toEqual(registered.sort());
|
||||
for (const k of registered)
|
||||
expect(["intentional", "missing", "our_bug", "mp_bug", "not_expressible"]).toContain(fx.deviations[k]!.kind);
|
||||
});
|
||||
|
||||
it("单 Slot + rankForSlot 的排序逆序对 == 登记", () => {
|
||||
for (const key of Object.keys(RANK_CHECKS)) {
|
||||
const inv = rankInversions(key);
|
||||
const dev = fx.deviations[key];
|
||||
if (inv.length === 0) expect(dev).toBeUndefined();
|
||||
else expect(dev?.pairs?.slice().sort()).toEqual(inv);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** 生成/更新登记时用:UMT_FILTER_DUMP=1 deno task test 打印当前不一致 */
|
||||
if (process.env.UMT_FILTER_DUMP) {
|
||||
const rows: Record<string, unknown> = {};
|
||||
for (const c of fx.cases) {
|
||||
if (!TRANSLATE[c.ruleId]) continue;
|
||||
const ours = ourLevel(c.ruleId, c.torrentId);
|
||||
if (ours !== c.mp.level) rows[c.caseId] = { mp: c.mp.level, ours };
|
||||
}
|
||||
for (const key of Object.keys(RANK_CHECKS)) rows[key] = rankInversions(key);
|
||||
console.log(`UMT_FILTER_DUMP ${JSON.stringify(rows)}`);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 标题识别差分:把 MP MetaInfo 输出与我们的 ParsedRelease 映射到同一"规范形",逐字段比较。
|
||||
* 规范化只抹平表示差异(大小写、分隔符、同义写法),不抹平语义差异;语义差异登记在 meta-deviations.json。
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { type ParsedRelease, parseRelease, parseReleasePath } from "../../src/release-parser/index.ts";
|
||||
|
||||
export interface MetaSample {
|
||||
id: string;
|
||||
kind: "title" | "path";
|
||||
input: string;
|
||||
subtitle?: string;
|
||||
source: string;
|
||||
ourNote?: string;
|
||||
}
|
||||
|
||||
export type MpMeta = Record<string, string | number | null> & { error?: string };
|
||||
|
||||
export type DeviationKind = "intentional" | "missing" | "our_bug" | "mp_bug" | "representation";
|
||||
export interface Deviation {
|
||||
kind: DeviationKind;
|
||||
mp: string | null;
|
||||
ours: string | null;
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** 参与比较的字段(media_id = find_metainfo 显式 ID 标签;edition = resource_type + resource_effect,由这两项覆盖;customization 双方均未配置) */
|
||||
export const FIELDS = [
|
||||
"name",
|
||||
"cn_name",
|
||||
"en_name",
|
||||
"year",
|
||||
"type",
|
||||
"begin_season",
|
||||
"end_season",
|
||||
"begin_episode",
|
||||
"end_episode",
|
||||
"resource_pix",
|
||||
"resource_type",
|
||||
"resource_effect",
|
||||
"video_encode",
|
||||
"audio_encode",
|
||||
"resource_team",
|
||||
"part",
|
||||
"web_source",
|
||||
"media_id",
|
||||
] as const;
|
||||
export type Field = (typeof FIELDS)[number];
|
||||
export type Canon = Record<Field, string | null>;
|
||||
|
||||
const readJson = <T>(name: string): T => JSON.parse(readFileSync(new URL(`./${name}`, import.meta.url), "utf8")) as T;
|
||||
|
||||
export function loadMeta() {
|
||||
const samples = readJson<{ samples: MetaSample[] }>("meta-samples.json").samples;
|
||||
const mp = readJson<{ python: Record<string, MpMeta>; rustMismatch: Record<string, unknown> }>("meta-mp.json");
|
||||
const deviations = readJson<{ deviations: Record<string, Partial<Record<Field, Deviation>>> }>(
|
||||
"meta-deviations.json",
|
||||
).deviations;
|
||||
return { samples, mp, deviations };
|
||||
}
|
||||
|
||||
const str = (v: unknown): string | null => (v === null || v === undefined || v === "" ? null : String(v));
|
||||
/** 名称:忽略大小写与标点/空白(MP 英文名做 Title Case,我们保留原大小写) */
|
||||
const canonName = (v: unknown) => {
|
||||
const s = str(v);
|
||||
return s === null
|
||||
? null
|
||||
: s
|
||||
.normalize("NFKC")
|
||||
.toLowerCase()
|
||||
.replace(/[\s._\-::·'’!!??,,&]+/g, "") || null;
|
||||
};
|
||||
|
||||
function canonSource(v: string | null): string | null {
|
||||
if (!v) return null;
|
||||
const u = v.toUpperCase();
|
||||
const cls = /REMUX/.test(u)
|
||||
? "REMUX"
|
||||
: /BLURAY|BDRIP|\bBD\b|BLU/.test(u)
|
||||
? "BluRay"
|
||||
: /WEB-?DL|^WEB$|\bWEB\b(?!RIP)/.test(u)
|
||||
? "WEB-DL"
|
||||
: /WEBRIP/.test(u)
|
||||
? "WEBRip"
|
||||
: /HDTV|UHDTV/.test(u)
|
||||
? "HDTV"
|
||||
: /HDRIP/.test(u)
|
||||
? "HDRip"
|
||||
: /DVD/.test(u)
|
||||
? "DVD"
|
||||
: /HDDVD/.test(u)
|
||||
? "HDDVD"
|
||||
: null;
|
||||
const uhd = /\bUHD\b/.test(u.replace(/UHDTV/g, "")) ? "UHD " : "";
|
||||
return `${uhd}${cls ?? `?${u}`}`.trim();
|
||||
}
|
||||
|
||||
const HDR_CANON: Record<string, string> = {
|
||||
HDR: "HDR10",
|
||||
HDR10: "HDR10",
|
||||
"HDR10+": "HDR10+",
|
||||
HDR10PLUS: "HDR10+",
|
||||
HDR10P: "HDR10+",
|
||||
DV: "DV",
|
||||
DOVI: "DV",
|
||||
HLG: "HLG",
|
||||
HDRVIVID: "HDR Vivid",
|
||||
VIVID: "HDR Vivid",
|
||||
"HDR VIVID": "HDR Vivid",
|
||||
};
|
||||
function canonHdrList(tokens: string[]): string | null {
|
||||
const set = new Set<string>();
|
||||
for (const t of tokens) {
|
||||
const k = t.toUpperCase().replace(/\s+/g, "");
|
||||
set.add(HDR_CANON[k] ?? HDR_CANON[t.toUpperCase()] ?? `?${t}`);
|
||||
}
|
||||
return set.size ? [...set].sort().join(",") : null;
|
||||
}
|
||||
|
||||
function canonCodec(v: string | null): string | null {
|
||||
if (!v) return null;
|
||||
const u = v.toUpperCase().replace(/[\s.]/g, "");
|
||||
if (/265|HEVC/.test(u)) return "H.265";
|
||||
if (/264|AVC/.test(u)) return "H.264";
|
||||
if (/AV1/.test(u)) return "AV1";
|
||||
if (/VC-?1/.test(u)) return "VC-1";
|
||||
if (/MPEG-?2/.test(u)) return "MPEG-2";
|
||||
if (/XVID/.test(u)) return "XVID";
|
||||
if (/DIVX/.test(u)) return "DIVX";
|
||||
return `?${u}`;
|
||||
}
|
||||
|
||||
const canonAudio = (v: string | null) =>
|
||||
v
|
||||
? v
|
||||
.toUpperCase()
|
||||
.replace(/DD\+/g, "DDP")
|
||||
.replace(/[\s\-_.]/g, "") || null
|
||||
: null;
|
||||
|
||||
const canonGroups = (v: string | null) =>
|
||||
v
|
||||
? v
|
||||
.split(/[@&]/)
|
||||
.map((g) => g.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join("@")
|
||||
: null;
|
||||
|
||||
function canonPix(v: string | null): string | null {
|
||||
if (!v) return null;
|
||||
const u = v.toLowerCase();
|
||||
return ({ "4k": "2160p", "8k": "4320p", "2k": "1440p" } as Record<string, string>)[u] ?? u;
|
||||
}
|
||||
|
||||
const canonPart = (v: string | null) => (v ? v.toUpperCase().replace(/[\s._-]/g, "") : null);
|
||||
const canonType = (v: string | null) =>
|
||||
v === "电视剧" ? "tv" : v === "电影" ? "movie" : v === "未知" || v === null ? "unknown" : v;
|
||||
|
||||
export function canonMp(m: MpMeta): Canon {
|
||||
const effect = str(m.resource_effect);
|
||||
const hdrTokens = effect ? effect.split(/\s+/).filter((t) => /^(HDR|DV|DOVI|HLG|VIVID|HDRVIVID)/i.test(t)) : [];
|
||||
return {
|
||||
name: canonName(m.name),
|
||||
cn_name: canonName(m.cn_name),
|
||||
en_name: canonName(m.en_name),
|
||||
year: str(m.year),
|
||||
type: canonType(str(m.type)),
|
||||
begin_season: str(m.begin_season),
|
||||
end_season: str(m.end_season),
|
||||
begin_episode: str(m.begin_episode),
|
||||
end_episode: str(m.end_episode),
|
||||
resource_pix: canonPix(str(m.resource_pix)),
|
||||
resource_type: canonSource(str(m.resource_type)),
|
||||
resource_effect: canonHdrList(hdrTokens),
|
||||
video_encode: canonCodec(str(m.video_encode)),
|
||||
audio_encode: canonAudio(str(m.audio_encode)),
|
||||
resource_team: canonGroups(str(m.resource_team)),
|
||||
part: canonPart(str(m.part)),
|
||||
web_source: canonName(m.web_source),
|
||||
media_id: m.media_id ? `${m.media_source}:${m.media_id}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
const PROVIDER: Record<string, string> = { tmdb: "themoviedb", douban: "douban", imdb: "imdb", tvdb: "tvdb" };
|
||||
|
||||
export function oursParse(s: MetaSample): ParsedRelease {
|
||||
return s.kind === "path"
|
||||
? parseReleasePath(s.input)
|
||||
: parseRelease(s.input, s.subtitle ? { subtitle: s.subtitle } : {});
|
||||
}
|
||||
|
||||
export function canonOurs(r: ParsedRelease): Canon {
|
||||
const src = r.source
|
||||
? `${r.uhd ? "UHD " : ""}${r.source === "BluRay-Disc" ? "BluRay" : r.source === "DVDRip" ? "DVD" : r.source}`
|
||||
: r.uhd
|
||||
? "UHD ?"
|
||||
: null;
|
||||
const eps = r.episodes;
|
||||
return {
|
||||
name: canonName(r.title),
|
||||
cn_name: canonName(r.titleCn),
|
||||
en_name: canonName(r.titleEn),
|
||||
year: str(r.year),
|
||||
type: r.mediaTypeHint,
|
||||
begin_season: str(r.season),
|
||||
end_season: str(r.seasonEnd),
|
||||
begin_episode: str(eps?.start),
|
||||
end_episode: eps && eps.end !== eps.start ? String(eps.end) : null,
|
||||
resource_pix: canonPix(str(r.resolution)),
|
||||
resource_type: src,
|
||||
resource_effect: r.hdr.length ? canonHdrList(r.hdr) : null,
|
||||
video_encode: canonCodec(str(r.videoCodec)),
|
||||
audio_encode: r.audio.length ? canonAudio(r.audio.join(" ")) : null,
|
||||
resource_team: canonGroups(str(r.releaseGroup?.name)),
|
||||
part: canonPart(str(r.part)),
|
||||
web_source: canonName(r.webSource),
|
||||
media_id: r.explicitIds[0] ? `${PROVIDER[r.explicitIds[0].provider]}:${r.explicitIds[0].id}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 逐字段差异(规范形不等) */
|
||||
export function diffSample(
|
||||
s: MetaSample,
|
||||
mp: MpMeta,
|
||||
): Partial<Record<Field, { mp: string | null; ours: string | null }>> {
|
||||
const a = canonMp(mp);
|
||||
const b = canonOurs(oursParse(s));
|
||||
const out: Partial<Record<Field, { mp: string | null; ours: string | null }>> = {};
|
||||
for (const f of FIELDS) if (a[f] !== b[f]) out[f] = { mp: a[f], ours: b[f] };
|
||||
return out;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 标题识别差分测试:MP MetaInfo/MetaInfoPath(Python 参考实现)vs parseRelease/parseReleasePath。
|
||||
* 期望输出由 `scripts/mp-sync/gen-differential.py meta` 生成(meta-mp.json),已知差异登记在 meta-deviations.json。
|
||||
* 断言:实际差异集合 == 登记集合,且规范形取值与快照一致。补迁某条后测试会失败,提示删除对应登记。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { diffSample, loadMeta } from "./meta-map.ts";
|
||||
|
||||
const { samples, mp, deviations } = loadMeta();
|
||||
|
||||
describe("标题识别差分(MP MetaInfo ↔ parseRelease)", () => {
|
||||
it("样本 ≥ 150 且每条都有 MP 输出", () => {
|
||||
expect(samples.length).toBeGreaterThanOrEqual(150);
|
||||
for (const s of samples) {
|
||||
expect(mp.python[s.id], s.id).toBeDefined();
|
||||
expect(mp.python[s.id]?.error, s.id).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("登记项都对应存在的样本", () => {
|
||||
const ids = new Set(samples.map((s) => s.id));
|
||||
for (const id of Object.keys(deviations)) expect(ids.has(id), id).toBe(true);
|
||||
});
|
||||
|
||||
for (const s of samples) {
|
||||
it(`${s.id} ${s.input}`, () => {
|
||||
const actual = diffSample(s, mp.python[s.id]!);
|
||||
const registered = deviations[s.id] ?? {};
|
||||
const snapshot = Object.fromEntries(
|
||||
Object.entries(registered).map(([f, d]) => [f, { mp: d!.mp, ours: d!.ours }]),
|
||||
);
|
||||
expect(actual, `${s.id} 差异与 meta-deviations.json 登记不一致`).toEqual(snapshot);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"note": "繁→简差分已知差异:key=输入。kind 同 meta-deviations.json。",
|
||||
"deviations": {
|
||||
"咒術迴戰": { "kind": "missing", "mp": "咒术回战", "ours": "咒术迴战", "note": "字表缺 迴→回" },
|
||||
"孤獨搖滾": { "kind": "missing", "mp": "孤独摇滚", "ours": "孤独摇滾", "note": "字表缺 滾→滚" },
|
||||
"魷魚遊戲": { "kind": "missing", "mp": "鱿鱼游戏", "ours": "魷鱼游戏", "note": "字表缺 魷→鱿" },
|
||||
"瑯琊榜": { "kind": "missing", "mp": "琅琊榜", "ours": "瑯琊榜", "note": "字表缺 瑯→琅(异体)" },
|
||||
"慶餘年": {
|
||||
"kind": "mp_bug",
|
||||
"mp": "庆馀年",
|
||||
"ours": "庆余年",
|
||||
"note": "MediaWiki 词表把 餘 转成 馀,官方简体片名为 庆余年"
|
||||
},
|
||||
"臥虎藏龍": { "kind": "missing", "mp": "卧虎藏龙", "ours": "臥虎藏龙", "note": "字表缺 臥→卧" },
|
||||
"駭客任務": { "kind": "missing", "mp": "骇客任务", "ours": "駭客任务", "note": "字表缺 駭→骇" },
|
||||
"乾杯": { "kind": "missing", "mp": "干杯", "ours": "乾杯", "note": "一对多字 乾 刻意不收;MP 有词组表" },
|
||||
"瞭解": { "kind": "missing", "mp": "了解", "ours": "瞭解", "note": "一对多字 瞭 刻意不收;MP 有词组表" },
|
||||
"頭髮": { "kind": "missing", "mp": "头发", "ours": "头髮", "note": "髮→发 未收(发/發 合并字)" },
|
||||
"理髮": { "kind": "missing", "mp": "理发", "ours": "理髮", "note": "髮→发 未收" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"generatedFrom": {
|
||||
"repo": "MoviePilot",
|
||||
"commit": "e6820a8",
|
||||
"entry": "moviepilot_rust.zhconv_fast(text, 'zh-hans')"
|
||||
},
|
||||
"hans": {
|
||||
"葬送的芙莉蓮": "葬送的芙莉莲",
|
||||
"處刑少女的生存之道": "处刑少女的生存之道",
|
||||
"夏日重現": "夏日重现",
|
||||
"間諜家家酒": "间谍家家酒",
|
||||
"進擊的巨人": "进击的巨人",
|
||||
"鬼滅之刃": "鬼灭之刃",
|
||||
"咒術迴戰": "咒术回战",
|
||||
"我的英雄學院": "我的英雄学院",
|
||||
"藥師少女的獨語": "药师少女的独语",
|
||||
"無職轉生": "无职转生",
|
||||
"關於我轉生變成史萊姆這檔事": "关于我转生变成史莱姆这档事",
|
||||
"輝夜姬想讓人告白": "辉夜姬想让人告白",
|
||||
"排球少年!!": "排球少年!!",
|
||||
"鏈鋸人": "链锯人",
|
||||
"孤獨搖滾": "孤独摇滚",
|
||||
"黑暗榮耀": "黑暗荣耀",
|
||||
"與神同行": "与神同行",
|
||||
"魷魚遊戲": "鱿鱼游戏",
|
||||
"機智醫生生活": "机智医生生活",
|
||||
"請回答1988": "请回答1988",
|
||||
"陽光先生": "阳光先生",
|
||||
"鬼怪": "鬼怪",
|
||||
"來自星星的你": "来自星星的你",
|
||||
"後宮甄嬛傳": "后宫甄嬛传",
|
||||
"瑯琊榜": "琅琊榜",
|
||||
"慶餘年": "庆馀年",
|
||||
"琅琊榜之風起長林": "琅琊榜之风起长林",
|
||||
"甄嬛傳": "甄嬛传",
|
||||
"雍正王朝": "雍正王朝",
|
||||
"乾隆王朝": "乾隆王朝",
|
||||
"康熙王朝": "康熙王朝",
|
||||
"大明王朝1566": "大明王朝1566",
|
||||
"頭文字D": "头文字D",
|
||||
"無間道": "无间道",
|
||||
"少林足球": "少林足球",
|
||||
"功夫": "功夫",
|
||||
"臥虎藏龍": "卧虎藏龙",
|
||||
"英雄本色": "英雄本色",
|
||||
"倩女幽魂": "倩女幽魂",
|
||||
"喋血雙雄": "喋血双雄",
|
||||
"阿飛正傳": "阿飞正传",
|
||||
"花樣年華": "花样年华",
|
||||
"重慶森林": "重庆森林",
|
||||
"復仇者聯盟:終局之戰": "复仇者联盟:终局之战",
|
||||
"蜘蛛人:無家日": "蜘蛛人:无家日",
|
||||
"星際效應": "星际效应",
|
||||
"全面啟動": "全面启动",
|
||||
"駭客任務": "骇客任务",
|
||||
"魔戒首部曲:魔戒現身": "魔戒首部曲:魔戒现身",
|
||||
"神隱少女": "神隐少女",
|
||||
"霍爾的移動城堡": "霍尔的移动城堡",
|
||||
"乾杯": "干杯",
|
||||
"著作": "著作",
|
||||
"瞭解": "了解",
|
||||
"頭髮": "头发",
|
||||
"理髮": "理发",
|
||||
"發現": "发现",
|
||||
"後來": "后来",
|
||||
"皇后": "皇后",
|
||||
"範圍": "范围",
|
||||
"姓范": "姓范",
|
||||
"裡面": "里面",
|
||||
"計畫": "计画",
|
||||
"週末": "周末",
|
||||
"臺灣": "台湾",
|
||||
"台灣": "台湾",
|
||||
"麵包": "面包",
|
||||
"鬱金香": "郁金香",
|
||||
"舞台劇": "舞台剧"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"note": "繁→简差分样本(影视标题/人名/常见一对多字)。期望由 MP moviepilot_rust.zhconv_fast(text, 'zh-hans') 生成",
|
||||
"samples": [
|
||||
"葬送的芙莉蓮",
|
||||
"處刑少女的生存之道",
|
||||
"夏日重現",
|
||||
"間諜家家酒",
|
||||
"進擊的巨人",
|
||||
"鬼滅之刃",
|
||||
"咒術迴戰",
|
||||
"我的英雄學院",
|
||||
"藥師少女的獨語",
|
||||
"無職轉生",
|
||||
"關於我轉生變成史萊姆這檔事",
|
||||
"輝夜姬想讓人告白",
|
||||
"排球少年!!",
|
||||
"鏈鋸人",
|
||||
"孤獨搖滾",
|
||||
"黑暗榮耀",
|
||||
"與神同行",
|
||||
"魷魚遊戲",
|
||||
"機智醫生生活",
|
||||
"請回答1988",
|
||||
"陽光先生",
|
||||
"鬼怪",
|
||||
"來自星星的你",
|
||||
"後宮甄嬛傳",
|
||||
"瑯琊榜",
|
||||
"慶餘年",
|
||||
"琅琊榜之風起長林",
|
||||
"甄嬛傳",
|
||||
"雍正王朝",
|
||||
"乾隆王朝",
|
||||
"康熙王朝",
|
||||
"大明王朝1566",
|
||||
"頭文字D",
|
||||
"無間道",
|
||||
"少林足球",
|
||||
"功夫",
|
||||
"臥虎藏龍",
|
||||
"英雄本色",
|
||||
"倩女幽魂",
|
||||
"喋血雙雄",
|
||||
"阿飛正傳",
|
||||
"花樣年華",
|
||||
"重慶森林",
|
||||
"復仇者聯盟:終局之戰",
|
||||
"蜘蛛人:無家日",
|
||||
"星際效應",
|
||||
"全面啟動",
|
||||
"駭客任務",
|
||||
"魔戒首部曲:魔戒現身",
|
||||
"神隱少女",
|
||||
"霍爾的移動城堡",
|
||||
"乾杯",
|
||||
"著作",
|
||||
"瞭解",
|
||||
"頭髮",
|
||||
"理髮",
|
||||
"發現",
|
||||
"後來",
|
||||
"皇后",
|
||||
"範圍",
|
||||
"姓范",
|
||||
"裡面",
|
||||
"計畫",
|
||||
"週末",
|
||||
"臺灣",
|
||||
"台灣",
|
||||
"麵包",
|
||||
"鬱金香",
|
||||
"舞台劇"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 繁→简差分:MP moviepilot_rust.zhconv_fast(zh-hans) vs identity/toSimplified。
|
||||
* 期望由 `scripts/mp-sync/gen-differential.py zhconv` 生成;已知差异登记在 zhconv-deviations.json。
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, it } from "vitest";
|
||||
import { toSimplified } from "../../src/identity/index.ts";
|
||||
|
||||
const read = (n: string) => JSON.parse(readFileSync(new URL(`./${n}`, import.meta.url), "utf8"));
|
||||
const mp: Record<string, string> = read("zhconv-mp.json").hans;
|
||||
const dev: Record<string, { mp: string; ours: string }> = read("zhconv-deviations.json").deviations;
|
||||
|
||||
it("toSimplified 与 MP zhconv 的差异集合 == 登记集合", () => {
|
||||
const actual: Record<string, { mp: string; ours: string }> = {};
|
||||
for (const [src, want] of Object.entries(mp)) {
|
||||
const ours = toSimplified(src);
|
||||
if (ours !== want) actual[src] = { mp: want, ours };
|
||||
}
|
||||
const snapshot = Object.fromEntries(Object.entries(dev).map(([k, d]) => [k, { mp: d.mp, ours: d.ours }]));
|
||||
expect(actual).toEqual(snapshot);
|
||||
expect(Object.keys(mp).length).toBeGreaterThanOrEqual(60);
|
||||
});
|
||||
Reference in new issue
Block a user