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\\d+)集"), "第\\g话", "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).error).toContain(">>"); }); it("停用 / 启用、改备注、调顺序、删除,其余行原样", () => { const rows = wordsToRows(ENTITY, "entity"); const r2 = rows[3] as Extract; const r4 = rows[4] as Extract; 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).note).toEqual(["动漫"]); expect((rows[2] as Extract).note).toEqual(["屏蔽词也行"]); expect(rowsToText(rows)).toBe(text); }); });