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
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 站点索引解析差分:MP `SiteSpider.parse`(PyQuery 回退)vs 本项目 `SiteSpider.parse`。
|
||||
* 同一 indexer 配置(本项目 definitions/*.json 或内联配置)+ 同一 HTML;期望由 scripts/mp-sync/gen-differential.py rss|indexer 生成(indexer.json)。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BUILTIN_SITE_DEFINITIONS, type IndexerDefinition, SiteSpider } from "../../src/sites/index.ts";
|
||||
import {
|
||||
diffAgainstRegistry,
|
||||
dumpMismatches,
|
||||
FieldCollector,
|
||||
formatMismatches,
|
||||
loadDoc,
|
||||
readInput,
|
||||
} from "./diff-util.ts";
|
||||
|
||||
interface IndexerSample {
|
||||
id: string;
|
||||
definition:
|
||||
| { source: "definition"; id: string }
|
||||
| {
|
||||
source: "inline";
|
||||
config: unknown;
|
||||
};
|
||||
input: { file?: string; inline?: string };
|
||||
query: { keyword: string | null; search_type: "torrents" | "subtitles" };
|
||||
mp: {
|
||||
is_error?: boolean;
|
||||
torrents?: Array<Record<string, unknown>>;
|
||||
status?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const doc = loadDoc<IndexerSample>("indexer.json");
|
||||
|
||||
function definitionOf(s: IndexerSample): IndexerDefinition {
|
||||
if (s.definition.source === "definition") {
|
||||
return BUILTIN_SITE_DEFINITIONS[s.definition.id] as IndexerDefinition;
|
||||
}
|
||||
return s.definition.config as IndexerDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行对齐:条数相同按下标;条数不同(MP 整行丢弃等)且双方都有 page_url 时按 page_url 配对,
|
||||
* 避免一处丢行导致后续所有行错位。行号 i 取 MP 侧下标。
|
||||
*/
|
||||
function alignRows(
|
||||
mp: Array<Record<string, unknown>>,
|
||||
ours: Array<Record<string, unknown>>,
|
||||
): Array<[number, Record<string, unknown>, Record<string, unknown>]> {
|
||||
const byUrl = mp.length !== ours.length && [...mp, ...ours].every((r) => typeof r.page_url === "string");
|
||||
if (!byUrl) {
|
||||
return mp.slice(0, Math.min(mp.length, ours.length)).map((m, i) => [i, m, ours[i]!]);
|
||||
}
|
||||
const index = new Map(ours.map((o) => [o.page_url as string, o]));
|
||||
const out: Array<[number, Record<string, unknown>, Record<string, unknown>]> = [];
|
||||
mp.forEach((m, i) => {
|
||||
const o = index.get(m.page_url as string);
|
||||
if (o) out.push([i, m, o]);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("站点索引差分(MP SiteSpider Python 回退)", () => {
|
||||
const c = new FieldCollector();
|
||||
for (const s of doc.samples) {
|
||||
if (s.mp.status === "error") {
|
||||
throw new Error(`${s.id}: MP 运行失败 ${s.mp.error}`);
|
||||
}
|
||||
if (s.query.search_type === "subtitles") {
|
||||
// 本项目 SiteSpider 未移植字幕分支(parse_indexer_subtitles)
|
||||
c.mismatches.push({
|
||||
key: `${s.id}.status`,
|
||||
mp: `ok:${s.mp.torrents?.length ?? 0}`,
|
||||
ours: "unsupported",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const html = s.input.file ? readInput(s.input.file) : (s.input.inline ?? "");
|
||||
const spider = new SiteSpider(definitionOf(s), s.query.keyword ? { keyword: s.query.keyword } : {});
|
||||
const ours = spider.parse(html);
|
||||
const mpRows = s.mp.torrents ?? [];
|
||||
if (Boolean(s.mp.is_error) !== ours.isError) {
|
||||
c.mismatches.push({
|
||||
key: `${s.id}.status`,
|
||||
mp: s.mp.is_error,
|
||||
ours: ours.isError,
|
||||
});
|
||||
}
|
||||
if (mpRows.length !== ours.torrents.length) {
|
||||
c.mismatches.push({
|
||||
key: `${s.id}.count`,
|
||||
mp: mpRows.length,
|
||||
ours: ours.torrents.length,
|
||||
});
|
||||
}
|
||||
for (const [i, m, o] of alignRows(mpRows, ours.torrents as Array<Record<string, unknown>>)) {
|
||||
for (const f of doc.fields) c.check(`${s.id}#${i}.${f}`, m[f], o[f]);
|
||||
}
|
||||
}
|
||||
|
||||
it("有样本且记录了生成来源", () => {
|
||||
expect(doc.samples.length).toBeGreaterThan(10);
|
||||
expect(doc.generatedFrom.commit).toMatch(/^[0-9a-f]{40}$/);
|
||||
});
|
||||
|
||||
it("实际不一致集合 == 登记的 deviations", () => {
|
||||
dumpMismatches("indexer", c.mismatches, {
|
||||
compared: c.compared,
|
||||
equal: c.equal,
|
||||
});
|
||||
const { unregistered, stale } = diffAgainstRegistry(c.mismatches, doc.deviations);
|
||||
expect(formatMismatches(unregistered), "未登记的不一致").toBe("");
|
||||
expect(stale.join("\n"), "登记了但已不再出现的偏差(请从 indexer.json deviations 删除)").toBe("");
|
||||
});
|
||||
|
||||
it("登记项分类合法", () => {
|
||||
for (const [key, d] of Object.entries(doc.deviations)) {
|
||||
expect(["intentional", "missing", "our_bug", "mp_bug"], key).toContain(d.kind);
|
||||
expect(d.note.length, key).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user