Files
UHD_Media_Tool/packages/contracts/test/identify-words.test.ts
T
rootandClaude Opus 5.5 63e94b308e feat(identify-words): 识别词——全局识别词(MP 语法)+ 作品识别词(季集换算),规则页「识别词」页签
- 新表 identify_words(0094):全局一块 + 每部作品一块原文,带版本与审计;episode_mapping_rule 转成文本行后删表
- 全局识别词移植 MP WordsMatcher(屏蔽 / 替换 / 集数偏移 / 替换+偏移 / {[tmdbid=…]}),所有按标题 / 文件名识别的地方解析前套用,命中的行记在解析结果上;保存后后台按改动行重新识别
- 作品识别词:S02 >> S01 EP+24 / S01E13 >> S00E01 / S01 @组 >> 忽略,解析器 + 打印器 + 编译;发布组繁简视为同一个
- 去掉规则自动停用:算出来对不上季表只待确认,要停用就注释掉那一行
- 集数待确认的采用 / 忽略与智能体 apply_rule 改为往作品识别词追加一行(带来历注释)
- 编辑器可视化 / 文本两种模式改同一份原文,保存前试算;作品页「集数映射」用同一个编辑器

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 17:03:00 +08:00

278 lines
11 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
compileEntityWords,
evalEpisodeExpr,
parseEntityWordLine,
parseGlobalWordLine,
parseGlobalWords,
parseIdentityTag,
printEntityWord,
printGlobalWord,
printIdentityTag,
pyRegex,
pySubn,
rowsToText,
ruleRowLines,
updateRuleRow,
type WordsRow,
wordsToRows,
} from "../src/index.ts";
// 识别词语法(docs/design/identify-words.md):全局行按 MP __parse_word 拆;作品识别词的解析 / 打印往返、
// 编译(范围、优先级、冲突)、可视化行模型与文本往返(注释、停用、顺序、写错的行都不丢)。
describe("全局识别词(MP 语法)", () => {
it("四种写法与注释、空行", () => {
expect(parseGlobalWordLine("国语中字")).toMatchObject({ kind: "block", pattern: "国语中字" });
expect(parseGlobalWordLine("A => B")).toMatchObject({ kind: "replace", pattern: "A", replacement: "B" });
expect(parseGlobalWordLine("测试替换 => ")).toMatchObject({
kind: "replace",
pattern: "测试替换",
replacement: "",
});
expect(parseGlobalWordLine("第 <> 集 >> EP+1")).toMatchObject({
kind: "offset",
front: "第",
back: "集",
offset: "EP+1",
});
expect(parseGlobalWordLine("旧名 => 新名 && 第 <> 集 >> EP+1")).toMatchObject({
kind: "replace_offset",
pattern: "旧名",
replacement: "新名",
front: "第",
back: "集",
offset: "EP+1",
});
expect(parseGlobalWordLine("# 动漫").kind).toBe("comment");
expect(parseGlobalWordLine(" ").kind).toBe("blank");
});
it("打印与解析往返", () => {
for (const t of ["国语中字", "A => B", "第 <> 集 >> EP+1", "旧名 => 新名 && 第 <> 集 >> 2*EP-1"]) {
const p = parseGlobalWordLine(t);
expect(printGlobalWord(p)).toBe(t);
}
});
it("提前报错:正则写错、替换引用了不存在的分组、偏移表达式写错、两个定位词都空", () => {
expect(parseGlobalWordLine("([ => X").error).toContain("正则");
expect(parseGlobalWordLine("(a) => \\2").error).toContain("分组");
expect(parseGlobalWordLine("第 <> 集 >> 2EP").error).toContain("运算符");
expect(parseGlobalWordLine(" <> >> EP+1").error).toBeDefined();
expect(parseGlobalWords("A => B\n\n# x\nC").map((w) => [w.line, w.kind])).toEqual([
[1, "replace"],
[2, "blank"],
[3, "comment"],
[4, "block"],
]);
});
it("Python 正则与替换模板", () => {
expect(pySubn(pyRegex("(?P<n>\\d+)集"), "第\\g<n>话", "12集")).toEqual(["第12话", 1]);
expect(pySubn(pyRegex("(a)(b)"), "\\2\\1", "abab")).toEqual(["baba", 2]);
expect(pySubn(pyRegex("(?i)foo"), "bar", "FOO foo")).toEqual(["bar bar", 2]);
expect(pySubn(pyRegex("x"), "$1\\\\", "x")).toEqual(["$1\\", 1]);
expect(pySubn(pyRegex("\\p{Han}+"), "中", "ab汉字cd")).toEqual(["ab中cd", 1]);
});
it("指定作品标签", () => {
const t = parseIdentityTag("一念永恒{[tmdbid=107371;type=tv;s=1;g=abc]}S01");
expect(t).toMatchObject({
before: "一念永恒",
after: "S01",
tag: { tmdbId: "107371", type: "tv", season: "1", extra: ["g=abc"] },
});
expect(printIdentityTag(t!.tag)).toBe("{[tmdbid=107371;type=tv;s=1;g=abc]}");
expect(parseIdentityTag("没有标签")).toBeNull();
});
});
describe("作品识别词", () => {
it("季集换算:目标季可省、EP 单独写 = 集号不变、发布组可多个、任意季写 *", () => {
expect(parseEntityWordLine("S02 >> S01 EP+24")).toMatchObject({
kind: "map",
season: 2,
groups: [],
targetSeason: 1,
expr: "EP+24",
});
expect(parseEntityWordLine("S03 @ADWeb @MWeb >> S01 EP")).toMatchObject({
kind: "map",
season: 3,
groups: ["ADWeb", "MWeb"],
targetSeason: 1,
expr: "EP",
});
expect(parseEntityWordLine("S01 @Lilith-Raws >> ep - 12")).toMatchObject({ targetSeason: null, expr: "EP-12" });
expect(parseEntityWordLine("S02 >> S01")).toMatchObject({ targetSeason: 1, expr: "EP" });
expect(parseEntityWordLine("* @Judas >> EP*2-1")).toMatchObject({ season: null, groups: ["Judas"] });
expect(parseEntityWordLine('S01 @"Some Group" >> EP')).toMatchObject({ groups: ["Some Group"] });
});
it("单集对照与特别篇", () => {
expect(parseEntityWordLine("S01E13 >> S00E01")).toMatchObject({
kind: "table",
season: 1,
from: { start: 13, end: 13 },
to: { season: 0, episode: 1 },
});
expect(parseEntityWordLine("S01E27-E28 @Baha >> S02E02")).toMatchObject({
kind: "table",
groups: ["Baha"],
from: { start: 27, end: 28 },
});
expect(parseEntityWordLine("E05 >> S00E02")).toMatchObject({ kind: "table", season: null });
expect(parseEntityWordLine("S00 @SweetSub >> 特别篇")).toMatchObject({ kind: "special", special: "special" });
expect(parseEntityWordLine("S01 >> 花絮")).toMatchObject({ kind: "special", special: "extra" });
expect(parseEntityWordLine("S01 >> 忽略")).toMatchObject({ kind: "special", special: "ignore" });
});
it("写错的行给出原因", () => {
expect(parseEntityWordLine("S02 S01 EP+1").error).toContain(">>");
expect(parseEntityWordLine("Season2 >> EP").error).toContain("不是季号");
expect(parseEntityWordLine("S02 ADWeb >> EP").error).toContain("@");
expect(parseEntityWordLine("S02 >> S01 2EP").error).toContain("运算符");
expect(parseEntityWordLine("S02 >> S01 12").error).toContain("EP");
expect(parseEntityWordLine("S01E13 >> 第一集").error).toContain("目标季集");
expect(parseEntityWordLine("S01E13-E10 >> S00E01").error).toContain("写反");
});
it("打印与解析往返(规范写法)", () => {
for (const t of [
"S02 >> S01 EP+24",
"S03 @ADWeb @MWeb @黒ネズミたち >> S01 EP",
"* @Judas >> EP*2-1",
"S01E13 >> S00E01",
"S01E27-E28 @Baha >> S02E02",
"E05 >> S00E02",
"S01 @SweetSub >> 花絮",
'S01 @"Some Group" >> EP-1',
]) {
const p = parseEntityWordLine(t);
expect(p.error).toBeUndefined();
expect(printEntityWord({ ...p, kind: p.kind, season: p.season ?? null, groups: p.groups ?? [] })).toBe(t);
}
});
it("编译:一行多个发布组拆成多条;单集对照多行合成一张表;同范围冲突报错带行号", () => {
const c = compileEntityWords(
[
"# 药屋",
"S02 >> S01 EP+24",
"S03 >> S01 EP+48",
"S03 @ADWeb @MWeb >> S01 EP",
"# S03 @Judas >> S01 EP",
"S01E13 @Baha >> S00E01",
"S01E14 @Baha >> S00E02",
"S01E14 @Baha >> S00E03",
"S01 @Baha >> 忽略",
"s02 >> EP",
].join("\n"),
);
expect(c.rules.map((r) => [r.id, r.season, r.releaseGroup, r.kind, r.line])).toEqual([
["L0002", 2, null, "expression", 2],
["L0003", 3, null, "expression", 3],
["L0004", 3, "ADWeb", "expression", 4],
["L0004.1", 3, "MWeb", "expression", 4],
["L0006", 1, "Baha", "table", 6],
]);
expect(c.rules[4]!.table).toEqual([
{ from: { start: 13, end: 13 }, to: { season: 0, episode: 1 } },
{ from: { start: 14, end: 14 }, to: { season: 0, episode: 2 } },
]);
expect(c.errors).toEqual([
{ line: 8, message: expect.stringContaining("重叠") },
{ line: 9, message: expect.stringContaining("第 6 行已有规则") },
{ line: 10, message: expect.stringContaining("第 2 行已有规则") },
]);
});
it("集号算术与 MP 一致,EP 必须出现", () => {
expect(evalEpisodeExpr("EP+24", 1)).toBe(25);
expect(evalEpisodeExpr("ep*2-1", 3)).toBe(5);
expect(() => evalEpisodeExpr("5", 1)).toThrow();
});
});
describe("可视化行模型 ⇄ 文本", () => {
const ENTITY = [
"# 药屋少女的呢喃",
"",
"# 第二季接着第一季编号",
"S02 >> S01 EP+24",
"S03 >> S01 EP+48",
"# S03 @Judas >> S01 EP",
"S03 @ADWeb @MWeb >> S01 EP",
"这一行写错了",
"",
"",
"# 结尾注释",
].join("\n");
it("原文逐字往返(含结尾换行、连续空行、注释、写错的行)", () => {
for (const text of [ENTITY, `${ENTITY}\n`, "", "S02 >> S01 EP+24", "#x\n#y\n"]) {
const rows = wordsToRows(text, "entity");
expect(rowsToText(rows, text.endsWith("\n"))).toBe(text);
}
});
it("备注贴在规则上方;注释掉的规则是停用行;写错的行是原文行", () => {
const rows = wordsToRows(ENTITY, "entity");
expect(rows.map((r) => [r.kind, r.kind === "rule" ? [r.enabled, r.note, r.rule] : r.lines])).toEqual([
["comment", ["# 药屋少女的呢喃"]],
["blank", [""]],
["rule", [true, ["第二季接着第一季编号"], "S02 >> S01 EP+24"]],
["rule", [true, [], "S03 >> S01 EP+48"]],
["rule", [false, [], "S03 @Judas >> S01 EP"]],
["rule", [true, [], "S03 @ADWeb @MWeb >> S01 EP"]],
["invalid", ["这一行写错了"]],
["blank", ["", ""]],
["comment", ["# 结尾注释"]],
]);
expect((rows[6] as Extract<WordsRow, { kind: "invalid" }>).error).toContain(">>");
});
it("停用 / 启用、改备注、调顺序、删除,其余行原样", () => {
const rows = wordsToRows(ENTITY, "entity");
const r2 = rows[3] as Extract<WordsRow, { kind: "rule" }>;
const r4 = rows[4] as Extract<WordsRow, { kind: "rule" }>;
const next = [...rows];
next[3] = updateRuleRow(r2, { enabled: false });
next[4] = updateRuleRow(r4, { enabled: true, note: ["Judas 接着第一季"] });
// 交换两行
[next[3], next[4]] = [next[4]!, next[3]!];
next.splice(6, 1);
expect(rowsToText(next)).toBe(
[
"# 药屋少女的呢喃",
"",
"# 第二季接着第一季编号",
"S02 >> S01 EP+24",
"# Judas 接着第一季",
"S03 @Judas >> S01 EP",
"# S03 >> S01 EP+48",
"S03 @ADWeb @MWeb >> S01 EP",
"",
"",
"# 结尾注释",
].join("\n"),
);
expect(ruleRowLines(["备注"], false, "S01 >> EP")).toEqual(["# 备注", "# S01 >> EP"]);
});
it("全局:带运算符的注释是停用的识别词,其余注释是备注;MP 列表原样往返", () => {
const text = ["#动漫", "Fake => 真", "# 旧名 => 新名", "#屏蔽词也行", "国语中字", "([ => 写错"].join("\n");
const rows = wordsToRows(text, "global");
expect(rows.map((r) => [r.kind, r.kind === "rule" ? r.enabled : null])).toEqual([
["rule", true],
["rule", false],
["rule", true],
["invalid", null],
]);
expect((rows[0] as Extract<WordsRow, { kind: "rule" }>).note).toEqual(["动漫"]);
expect((rows[2] as Extract<WordsRow, { kind: "rule" }>).note).toEqual(["屏蔽词也行"]);
expect(rowsToText(rows)).toBe(text);
});
});