- 新表 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
278 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|