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,121 @@
|
||||
/**
|
||||
* RSS 解析差分:MP `RssHelper._parse_impl`(lxml 回退)vs 本项目 `parseFeed`。
|
||||
* 期望由 scripts/mp-sync/gen-differential.py rss|indexer 生成(rss.json);不一致必须登记在 rss.json `deviations`。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FeedError, type FeedItem, parseFeed } from "../../src/rss/index.ts";
|
||||
import {
|
||||
diffAgainstRegistry,
|
||||
dumpMismatches,
|
||||
FieldCollector,
|
||||
formatMismatches,
|
||||
loadDoc,
|
||||
readInput,
|
||||
} from "./diff-util.ts";
|
||||
|
||||
interface MpPubdate {
|
||||
kind: "absent" | "unparsed" | "aware" | "naive";
|
||||
text?: string;
|
||||
wall?: string;
|
||||
utc?: string;
|
||||
}
|
||||
interface MpRssItem {
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
enclosure: string;
|
||||
size: number;
|
||||
nickname?: string;
|
||||
pubdate: MpPubdate;
|
||||
}
|
||||
interface RssSample {
|
||||
id: string;
|
||||
input: { file: string; sha256: string };
|
||||
mp: { status: "ok" | "false_error" | "none_expired"; items: MpRssItem[] };
|
||||
}
|
||||
|
||||
const doc = loadDoc<RssSample>("rss.json");
|
||||
|
||||
/** MP 字段 → 可与本项目比较的值:空串即缺省;pubdate 统一为 UTC ISO */
|
||||
function mpPubdate(p: MpPubdate): string | undefined {
|
||||
if (p.kind === "aware") return p.utc;
|
||||
// naive:MP astimezone(None) 以宿主本地时区解释墙上时间;JS 对无偏移的日期时间同样按本地解释
|
||||
if (p.kind === "naive") return new Date(p.wall!).toISOString();
|
||||
return undefined;
|
||||
}
|
||||
const blank = (s: string | null | undefined) => (s === "" || s == null ? undefined : s);
|
||||
|
||||
function runOurs(xml: string): { status: string; items: FeedItem[] } {
|
||||
try {
|
||||
return {
|
||||
status: "ok",
|
||||
items: parseFeed(xml, { fetchedAt: new Date("2026-09-27T00:00:00Z") }).items,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof FeedError) return { status: `error:${e.kind}`, items: [] };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function statusEquivalent(mp: RssSample["mp"]["status"], ours: string): boolean {
|
||||
if (mp === "ok") return ours === "ok";
|
||||
if (mp === "none_expired") return ours === "error:expired";
|
||||
return ours.startsWith("error:") && ours !== "error:expired";
|
||||
}
|
||||
|
||||
describe("RSS 差分(MP RssHelper Python 回退)", () => {
|
||||
const c = new FieldCollector();
|
||||
for (const s of doc.samples) {
|
||||
const ours = runOurs(readInput(s.input.file));
|
||||
if (!statusEquivalent(s.mp.status, ours.status)) {
|
||||
c.mismatches.push({
|
||||
key: `${s.id}.status`,
|
||||
mp: s.mp.status,
|
||||
ours: ours.status,
|
||||
});
|
||||
}
|
||||
if (s.mp.status !== "ok" || ours.status !== "ok") continue;
|
||||
if (s.mp.items.length !== ours.items.length) {
|
||||
c.mismatches.push({
|
||||
key: `${s.id}.count`,
|
||||
mp: s.mp.items.length,
|
||||
ours: ours.items.length,
|
||||
});
|
||||
}
|
||||
const n = Math.min(s.mp.items.length, ours.items.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const m = s.mp.items[i]!;
|
||||
const o = ours.items[i]!;
|
||||
const k = (f: string) => `${s.id}#${i}.${f}`;
|
||||
c.check(k("title"), m.title, o.title);
|
||||
c.check(k("description"), blank(m.description), blank(o.description));
|
||||
c.check(k("link"), blank(m.link), blank(o.link));
|
||||
c.check(k("enclosure"), blank(m.enclosure), blank(o.enclosureUrl));
|
||||
c.check(k("size"), m.size, o.size);
|
||||
c.check(k("pubdate"), mpPubdate(m.pubdate), o.publishedAt);
|
||||
c.check(k("nickname"), blank(m.nickname), blank(o.nickname));
|
||||
}
|
||||
}
|
||||
|
||||
it("有样本且记录了生成来源", () => {
|
||||
expect(doc.samples.length).toBeGreaterThan(10);
|
||||
expect(doc.generatedFrom.commit).toMatch(/^[0-9a-f]{40}$/);
|
||||
});
|
||||
|
||||
it("实际不一致集合 == 登记的 deviations", () => {
|
||||
dumpMismatches("rss", c.mismatches, {
|
||||
compared: c.compared,
|
||||
equal: c.equal,
|
||||
});
|
||||
const { unregistered, stale } = diffAgainstRegistry(c.mismatches, doc.deviations);
|
||||
expect(formatMismatches(unregistered), "未登记的不一致").toBe("");
|
||||
expect(stale.join("\n"), "登记了但已不再出现的偏差(请从 rss.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