import { compileEntityWords } from "@umt/contracts"; import { createMigrator, type DB, migrateToLatest, sql } from "@umt/db"; import { Kysely, PostgresDialect } from "kysely"; import pg from "pg"; import { afterAll, describe, expect, it } from "vitest"; import { up } from "../../../../packages/db/migrations/0094_identify_words.ts"; import { PG_URL, pgAvailable, withSchemaLock } from "../auth-settings/harness.ts"; // 0094:episode_mapping_rule 结构化规则逐作品转成作品识别词文本行后删表。部署库只有一部作品 9 条规则 // (药屋少女的呢喃:S2 任意组 EP+24 → S1;S3 任意组 EP+48 → S1;S3 七个发布组 EP → S1),按它造数据。 const hasPg = await pgAvailable(); describe.skipIf(!hasPg)("0094 映射规则迁成作品识别词", () => { const schema = `iw_mig_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 6)}`; const admin = async (q: string) => { const c = new pg.Client({ connectionString: PG_URL }); await c.connect(); await c.query(q).finally(() => c.end()); }; let db: Kysely; afterAll(async () => { await db?.destroy(); await withSchemaLock(() => admin(`drop schema if exists ${schema} cascade`)); }); // biome-ignore lint/suspicious/noExplicitAny: 迁移前的旧表不在类型里 const raw = () => db as unknown as Kysely; const rule = (entity: string, v: Record) => raw() .insertInto("episode_mapping_rule") .values({ media_entity_id: entity, kind: "expression", created_by_type: "user", ...v }) .execute(); const groups = ["ADWeb", "MWeb", "CHDWEB", "黒ネズミたち", "Judas", "绿茶字幕组", "綠茶字幕組"]; it("药屋 9 条规则合成 3 行;停用的变注释;单集对照一行一条;智能体的带注释;重跑只补缺的行", async () => { await admin(`create schema ${schema}`); db = new Kysely({ dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 2, options: `-c search_path=${schema},public` }), }), }); await withSchemaLock(async () => { const r = await createMigrator(db, { migrationTableSchema: schema }).migrateTo("0093_pi_azure_provider"); if (r.error) throw r.error; }); const entity = async (title: string) => ( await sql<{ id: string; }>`insert into media_entity (kind, title) values ('series', ${title}) returning id`.execute(db) ).rows[0]!.id; const kusuriya = await entity("药屋少女的呢喃"); const other = await entity("别的剧"); await rule(kusuriya, { season: 2, expression: "EP+24", target_season: 1 }); await rule(kusuriya, { season: 3, expression: "EP+48", target_season: 1 }); for (const g of groups) await rule(kusuriya, { season: 3, release_group: g, expression: "EP", target_season: 1 }); // 别的剧:停用(自动停用留下的)、单集对照、特别篇、智能体写的 await rule(other, { season: 1, release_group: "ANi", expression: "EP-12", target_season: 2, status: "disabled", disabled_reason: JSON.stringify({ source: { season: 1 } }), }); await rule(other, { season: 1, release_group: "Baha", kind: "table", expression: null, table_entries: JSON.stringify([ { from: { start: 13, end: 13 }, to: { season: 0, episode: 1 } }, { from: { start: 27, end: 28 }, to: { season: 2, episode: 2 } }, ]), }); await rule(other, { season: null, release_group: "SweetSub", kind: "special", expression: null, special: "extra" }); await rule(other, { season: 2, release_group: "Nekomoe", expression: "EP-30", created_by_type: "agent", evidence: JSON.stringify({ reasons: ["发布时间对上播出日期"] }), }); await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema })); const text = async (id: string) => (await db.selectFrom("identify_words").select("text").where("media_entity_id", "=", id).executeTakeFirstOrThrow()) .text; expect(await text(kusuriya)).toBe( ["S02 >> S01 EP+24", "S03 >> S01 EP+48", `S03 ${groups.map((g) => `@${g}`).join(" ")} >> S01 EP`, ""].join("\n"), ); expect(await text(other)).toBe( [ "S01E13 @Baha >> S00E01", "S01E27-E28 @Baha >> S02E02", "# S01 @ANi >> S02 EP-12", "# 智能体:发布时间对上播出日期", "S02 @Nekomoe >> EP-30", "* @SweetSub >> 花絮", "", ].join("\n"), ); // 迁出来的文本能原样编译,规则与原来等价(药屋 2 + 7 条) const c = compileEntityWords(await text(kusuriya)); expect(c.errors).toEqual([]); expect(c.rules.map((r) => [r.season, r.releaseGroup, r.expression, r.targetSeason])).toEqual([ [2, null, "EP+24", 1], [3, null, "EP+48", 1], ...groups.map((g) => [3, g, "EP", 1]), ]); expect(compileEntityWords(await text(other)).errors).toEqual([]); const tables = await sql<{ t: string | null; }>`select to_regclass(${`${schema}.episode_mapping_rule`})::text as t`.execute(db); expect(tables.rows[0]!.t).toBeNull(); const version = await db .selectFrom("identify_words") .select(["version", "updated_by_type"]) .where("media_entity_id", "=", kusuriya) .executeTakeFirstOrThrow(); expect(version).toEqual({ version: 1, updated_by_type: "system" }); // 重跑(表还在的情况):已有的行不重复,只追加新的 await sql .raw(`create table episode_mapping_rule ( id uuid primary key default gen_random_uuid(), media_entity_id uuid not null, season integer, release_group text, kind text not null, expression text, target_season integer, table_entries jsonb, special text, status text not null default 'active', evidence jsonb not null default '{}'::jsonb, created_by_type text not null, created_at timestamptz not null default now())`) .execute(db); await rule(kusuriya, { season: 2, expression: "EP+24", target_season: 1 }); await rule(kusuriya, { season: 4, expression: "EP+72", target_season: 1 }); await up(raw()); expect(await text(kusuriya)).toBe( [ "S02 >> S01 EP+24", "S03 >> S01 EP+48", `S03 ${groups.map((g) => `@${g}`).join(" ")} >> S01 EP`, "S04 >> S01 EP+72", "", ].join("\n"), ); }); });