Files
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

489 lines
23 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture, type TmdbFixtureEpisode } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
import {
applyRecoveryOutcome,
autoInvestigate,
checkRecovery,
recoveryActions,
} from "../../src/modules/agent/investigate.ts";
import { INVESTIGATE_CAPABILITIES, RECOVER_CAPABILITIES } from "../../src/modules/agent/runner.ts";
import { registerSourcesCapabilities } from "../../src/modules/sources-identity/capabilities.ts";
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
import { createHarness, type Harness, json, pgAvailable, seedSite } from "./harness.ts";
// 集数映射的智能体兜底(docs/plan/episode-resolution.md 批次 5):episode_mapping.inspect 的上下文包与上限、
// simulate(试一行作品识别词)、apply_rule 强制模拟(不通过 / 会让已对上的资源变待确认都拒绝且不写库;通过则把这一行
// 连同理由注释追加进作品识别词 + 重算 + 事项关闭)、
// 后台调查接入(没有持续授权降级只读并留智能体建议 → 待处理一键确认;有授权时恢复动作按业务事实核实)。
// 不调真实模型:按假模型会走的调用顺序直接经注册中心调用能力。
const pg = await pgAvailable();
// biome-ignore lint/suspicious/noExplicitAny: 能力输出按 schema 校验过,测试里直接取字段
type Any = any;
const weekly = (n: number, start: string): TmdbFixtureEpisode[] =>
Array.from({ length: n }, (_, k) => ({
episode_number: k + 1,
air_date: new Date(Date.parse(start) + k * 7 * 86_400_000).toISOString().slice(0, 10),
names: { "zh-CN": `第 ${k + 1} 集`, "en-US": `Episode ${k + 1}` },
}));
// 已完结:S1 = 12(2023-10-22 起)、S2 = 13(2025-01-10 起,每周一集;E10 = 03-14、E11 = 03-21、E12 = 03-28)
const KUSURIYA: TmdbFixture = {
type: "tv",
id: 990102,
original_title: "Kusuriya no Hitorigoto",
original_language: "ja",
origin_country: ["JP"],
date: "2023-10-22",
titles: { "zh-CN": "药屋少女的呢喃", "en-US": "Kusuriya no Hitorigoto" },
popularity: 30,
alternative_titles: [],
external_ids: {},
rich: { status: "Ended" },
seasons: [
{ season_number: 1, air_date: "2023-10-22", names: { "zh-CN": "第 1 季" }, episodes: weekly(12, "2023-10-22") },
{ season_number: 2, air_date: "2025-01-10", names: { "zh-CN": "第 2 季" }, episodes: weekly(13, "2025-01-10") },
],
} as TmdbFixture;
const AIR = { 40: "2025-03-14T15:00:00Z", 41: "2025-03-21T15:00:00Z", 42: "2025-03-28T15:00:00Z" } as const;
/** 作品识别词:第 1 季连续编号减 30 换到第 2 季 */
const ep30 = (group: string, expr = "EP-30") => `S01 @${group} >> S02 ${expr}`;
describe.skipIf(!pg)("集数映射的智能体兜底", () => {
let h: Harness;
let tmdb: MockTmdb;
let siteId: string;
let n = 0;
let kId: string;
beforeAll(async () => {
tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, KUSURIYA] });
h = await createHarness("episode_mapping_agent", { tmdbUrl: tmdb.url });
const registry = createCapabilityRegistry({ logger: h.ctx.logger, db: h.db, settings: h.ctx.settings });
registerSourcesCapabilities(registry, () => h.rt);
(h.ctx as { capabilities: unknown }).capabilities = registry;
siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id;
await h.db
.insertInto("settings")
.values({
key: "agent",
value: JSON.stringify({ autoInvestigate: true, autoInvestigateMode: "recover", investigateMaxAttempts: 3 }),
})
.execute();
}, 120_000);
afterAll(async () => {
await h?.close();
await tmdb?.close();
});
const resolve = async (title: string, publishedAt: string | null = null) => {
const r = await h.db
.insertInto("source_release")
.values({
site_id: siteId,
site_release_id: String(820000 + ++n),
title,
subtitle: "药屋少女的呢喃",
source_published_at: publishedAt ? new Date(publishedAt) : null,
})
.returningAll()
.executeTakeFirstOrThrow();
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" });
expect(res.outcome, JSON.stringify(res)).toBe("auto");
return { id: r.id, entityId: res.mediaEntityId! };
};
/** 某发布组的连续编号 40 / 41 / 42(发布时间贴着 S02E10-12 的播出日期) */
const continuous = async (group: string, eps: (40 | 41 | 42)[] = [40, 41, 42]) => {
const ids: string[] = [];
for (const e of eps)
ids.push((await resolve(`[${group}] Kusuriya no Hitorigoto - ${e} [WEB-DL][1080p]`, AIR[e])).id);
return ids;
};
const decisionOf = async (group: string) =>
h.db
.selectFrom("decision")
.selectAll()
.where("kind", "=", "episode_mapping")
.where("dedupe_key", "=", `episode_mapping:${kId}:1:${group.toLowerCase()}`)
.orderBy("created_at", "desc")
.executeTakeFirstOrThrow();
/** 作品识别词里提到这个发布组的行(含注释) */
const wordsText = async () =>
(await h.db.selectFrom("identify_words").select("text").where("media_entity_id", "=", kId).executeTakeFirst())
?.text ?? "";
const rulesOf = async (group: string) => (await wordsText()).split("\n").filter((l) => l.includes(`@${group}`));
const methods = async (ids: string[]) =>
(await h.db.selectFrom("source_release").select(["id", "episode_resolution"]).where("id", "in", ids).execute()).map(
(r) => (r.episode_resolution as { method: string }).method,
);
const invoke = (id: string, input: Record<string, unknown>, runId?: string) =>
h.ctx.capabilities.invoke(id, input, {
actorId: runId ? "system:auto-investigate" : "user:admin",
taskId: randomUUID(),
scopeIds: [],
signal: new AbortController().signal,
source: runId ? "agent" : "web",
...(runId ? { agentRunId: runId, invocationId: randomUUID() } : {}),
}) as Promise<Any>;
let lilith: string[];
it("准备:季表就绪,Lilith-Raws 连续编号 40-42 待确认、开一条待处理;同组 S02E09 原号已对上", async () => {
kId = (await resolve("[ANi] Kusuriya no Hitorigoto - 01 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]")).entityId;
expect((await refreshMetadata(h.rt, kId, { full: true })).status).toBe("refreshed");
lilith = await continuous("Lilith-Raws");
await resolve("[Lilith-Raws] Kusuriya no Hitorigoto S02E09 [WEB-DL][1080p]", "2025-03-07T15:00:00Z");
expect(await methods(lilith)).toEqual(["unresolved", "unresolved", "unresolved"]);
const d = await decisionOf("Lilith-Raws");
expect(d.status).toBe("open");
expect((d.evidence as Any).sourceReleaseIds.sort()).toEqual([...lilith].sort());
});
it("inspect:事项内资源、同组相邻资源、季表(播出日期与标题)、规则、确定性建议", async () => {
const d = await decisionOf("Lilith-Raws");
const out = await invoke("episode_mapping.inspect", { decisionId: d.id });
expect(out.entity).toMatchObject({ id: kId, kind: "series" });
expect(out.scope).toEqual({ season: 1, releaseGroup: "Lilith-Raws" });
expect(out.decision).toMatchObject({ id: d.id, status: "open" });
expect(out.decision).not.toHaveProperty("failedRules");
expect(out.releases).toHaveLength(3);
expect(out.releases[0]).toMatchObject({
releaseGroup: "Lilith-Raws",
source: { season: 1, episodes: { start: 42, end: 42 } },
current: { method: "unresolved", canonical: null },
});
expect(out.releases[0].publishedAt).toBe("2025-03-28T15:00:00.000Z");
// 相邻资源:同组已对上的 S02E09;别的发布组(ANi)不在其中
expect(out.neighbors).toHaveLength(1);
expect(out.neighbors[0]).toMatchObject({
source: { season: 2, episodes: { start: 9, end: 9 } },
current: { method: "raw", canonical: { season: 2, episodes: { start: 9, end: 9 } } },
});
const s2 = out.seasons.find((s: Any) => s.season === 2);
expect(s2.total).toBe(13);
expect(s2.episodes[9]).toMatchObject({ episode: 10, airDate: "2025-03-14", title: "第 10 集" });
expect(out).toMatchObject({
latestSeason: 2,
ended: true,
words: { text: "", lines: [{ line: 1, kind: "blank" }] },
});
expect(out.suggestion).toMatchObject({
season: 1,
releaseGroup: "Lilith-Raws",
expression: "EP-30",
targetSeason: 2,
});
expect(out.truncated).toEqual({ releases: false, neighbors: false });
});
it("inspect 有上限:相邻资源最多 50 条并标出截断;不给事项时按作品 + 发布组取", async () => {
const rows = Array.from({ length: 55 }, (_, k) => ({
site_id: siteId,
site_release_id: `cap-${k}`,
title: `[Cap-Group] Kusuriya no Hitorigoto - ${String((k % 12) + 1).padStart(2, "0")} [${k}]`,
media_entity_id: kId,
resolution_status: "resolved" as const,
episode_resolution: JSON.stringify({
method: "raw",
source: { season: 1, episodes: { start: (k % 12) + 1, end: (k % 12) + 1 } },
canonical: { season: 1, episodes: { start: (k % 12) + 1, end: (k % 12) + 1 } },
evidence: ["validate:pass"],
}),
}));
await h.db.insertInto("source_release").values(rows).execute();
const out = await invoke("episode_mapping.inspect", { mediaEntityId: kId, season: 1, releaseGroup: "cap-group" });
expect(out.decision).toBeNull();
expect(out.releases).toEqual([]);
expect(out.neighbors).toHaveLength(50);
expect(out.truncated).toEqual({ releases: false, neighbors: true });
// 每条只带判断用的字段
expect(Object.keys(out.neighbors[0]).sort()).toEqual(
["current", "evidence", "publishedAt", "releaseGroup", "source", "sourceReleaseId", "title"].sort(),
);
});
it("simulate:草稿越界 → 没通过并写明原因;EP-30 → 样本全部解决;纯读,控制台调用不留建议", async () => {
const d = await decisionOf("Lilith-Raws");
const bad = await invoke("episode_mapping.simulate", { decisionId: d.id, line: ep30("Lilith-Raws", "EP-20") });
expect(bad.summary).toMatchObject({ samples: 3, resolved: 0, ok: false });
expect(bad.failures.join("")).toContain("仍对不上季表");
const ok = await invoke("episode_mapping.simulate", {
decisionId: d.id,
line: "s01 @Lilith-Raws >> s02 ep - 30",
reasons: ["发布时间对上播出日期"],
});
// 回给规范写法
expect(ok.line).toBe("S01 @Lilith-Raws >> S02 EP-30");
expect(ok.summary).toMatchObject({ samples: 3, resolved: 3, passed: 3, regressions: 0, missing: 0, ok: true });
expect(ok.failures).toEqual([]);
const i40 = ok.items.find((i: Any) => i.sourceReleaseId === lilith[0]);
expect(i40).toMatchObject({
sample: true,
before: { method: "unresolved" },
after: { method: "rule", canonical: { season: 2, episodes: { start: 10, end: 10 } } },
validation: "pass",
});
expect(ok.suggestionRecorded).toBe(false);
expect(await rulesOf("Lilith-Raws")).toHaveLength(0);
expect((await decisionOf("Lilith-Raws")).evidence).not.toHaveProperty("agentSuggestion");
});
it("apply_rule:模拟不通过被拒,不写规则、资源与事项不变", async () => {
const d = await decisionOf("Lilith-Raws");
await expect(
invoke("episode_mapping.apply_rule", {
decisionId: d.id,
line: ep30("Lilith-Raws", "EP-20"),
reasons: ["试试"],
}),
).rejects.toThrow(/模拟没通过/);
expect(await rulesOf("Lilith-Raws")).toHaveLength(0);
expect(await methods(lilith)).toEqual(["unresolved", "unresolved", "unresolved"]);
expect((await decisionOf("Lilith-Raws")).status).toBe("open");
});
it("apply_rule:会让已对上的资源变成待确认的规则被拒(同组 S01E05 原号已对上)", async () => {
await resolve("[Sakurato] Kusuriya no Hitorigoto - 05 [WEB-DL][1080p]", "2023-11-19T15:00:00Z");
const ids = await continuous("Sakurato", [40, 41]);
const d = await decisionOf("Sakurato");
const sim = await invoke("episode_mapping.simulate", { decisionId: d.id, line: ep30("Sakurato") });
expect(sim.summary).toMatchObject({ samples: 2, resolved: 2, regressions: 1, ok: false });
expect(sim.failures.join("")).toContain("已对上的资源变成待确认");
await expect(
invoke("episode_mapping.apply_rule", { decisionId: d.id, line: ep30("Sakurato"), reasons: ["连续编号"] }),
).rejects.toThrow(/已对上的资源变成待确认/);
expect(await rulesOf("Sakurato")).toHaveLength(0);
expect(await methods(ids)).toEqual(["unresolved", "unresolved"]);
});
it("后台调查没有持续授权:降级只读,模拟通过的规则留作智能体建议;写规则被拒;待处理一键确认", async () => {
const d = await decisionOf("Lilith-Raws");
const r = await autoInvestigate(h.ctx, d.id);
expect(r).toMatchObject({ created: true, mode: "investigate" });
const run = await h.db.selectFrom("agent_run").selectAll().where("id", "=", r.runId!).executeTakeFirstOrThrow();
expect(run).toMatchObject({ trigger: "event", mode: "plan_only" });
expect(run.scope).toMatchObject({ capabilities: [...INVESTIGATE_CAPABILITIES] });
expect((run.scope as Any).capabilities).toEqual(
expect.arrayContaining(["episode_mapping.inspect", "episode_mapping.simulate"]),
);
expect((run.scope as Any).capabilities).not.toContain("episode_mapping.apply_rule");
expect(run.goal).toContain("episode_mapping.inspect");
const inv = await decisionOf("Lilith-Raws");
expect(inv.status).toBe("investigating");
expect((inv.evidence as Any).autoRecover).toMatchObject({ mode: "investigate", requestedMode: "recover" });
expect((inv.evidence as Any).autoRecover.degraded.reason).toContain("episode_mapping.apply_rule");
// 假模型:inspect → simulate(带理由)→ 试图写规则
await invoke("episode_mapping.inspect", { decisionId: d.id }, run.id);
const sim = await invoke(
"episode_mapping.simulate",
{
decisionId: d.id,
line: ep30("Lilith-Raws"),
reasons: ["40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期"],
},
run.id,
);
expect(sim).toMatchObject({ suggestionRecorded: true, summary: { ok: true } });
await expect(
invoke(
"episode_mapping.apply_rule",
{ decisionId: d.id, line: ep30("Lilith-Raws"), reasons: ["对上播出日期"] },
run.id,
),
).rejects.toThrow(/授权/);
expect(await rulesOf("Lilith-Raws")).toHaveLength(0);
const withSug = await decisionOf("Lilith-Raws");
expect((withSug.evidence as Any).agentSuggestion).toMatchObject({
line: "S01 @Lilith-Raws >> S02 EP-30",
reasons: ["40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期"],
simulation: { samples: 3, resolved: 3, ok: true },
runId: run.id,
});
expect((withSug.evidence as Any).agentSuggestion.samples).toHaveLength(3);
expect((withSug.options as Any[]).map((o) => o.key)).toEqual([
"confirm_agent_suggestion",
"confirm_suggestion",
"ignore_rule",
]);
// 新资源进来刷新事项:建议与自动调查记录保留
const more = await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 43 [WEB-DL][1080p]", "2025-04-04T15:00:00Z");
const refreshed = await decisionOf("Lilith-Raws");
expect(refreshed.id).toBe(d.id);
expect((refreshed.evidence as Any).sourceReleaseIds).toContain(more.id);
expect((refreshed.evidence as Any).agentSuggestion).toMatchObject({ runId: run.id });
expect((refreshed.evidence as Any).autoRecover).toMatchObject({ mode: "investigate" });
expect((refreshed.options as Any[])[0].key).toBe("confirm_agent_suggestion");
// 待处理「确认智能体建议」:按当前资源再模拟(43 → S02E13 也对得上)→ 建规则 + 重算 + 事项关闭
const body = await json(
await h.req(`/decisions/${d.id}/resolve`, { body: { optionKey: "confirm_agent_suggestion" } }),
);
expect(body.status, JSON.stringify(body.body)).toBe(200);
expect(body.body.data).toMatchObject({
status: "resolved",
resolution: { optionKey: "confirm_agent_suggestion", execution: { reresolve: { resolved: 4 } } },
});
expect(body.body.data.resolution.execution).toMatchObject({
line: "S01 @Lilith-Raws >> S02 EP-30",
simulation: { samples: 4, ok: true },
});
// 写进作品识别词:上面一行注释写明来历与理由
expect((await wordsText()).trimEnd().split("\n")).toEqual([
expect.stringMatching(/^# \d{4}-\d{2}-\d{2} 智能体建议:40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期$/),
"S01 @Lilith-Raws >> S02 EP-30",
]);
expect(await methods([...lilith, more.id])).toEqual(["rule", "rule", "rule", "rule"]);
});
it("确认智能体建议前再模拟:现在不通过就拒绝,不写规则", async () => {
const ids = await continuous("Kirara", [40, 41]);
const d = await decisionOf("Kirara");
// 直接写一条现在模拟不通过的建议(如季表变了之后)
await h.db
.updateTable("decision")
.set({
evidence: JSON.stringify({
...(d.evidence as object),
agentSuggestion: {
line: "S01 @Kirara >> S02 EP-20",
reasons: ["旧建议"],
simulation: {
samples: 2,
resolved: 2,
passed: 2,
missing: 0,
regressions: 0,
changed: 2,
scanned: 2,
ok: true,
},
samples: [],
runId: null,
at: new Date().toISOString(),
},
}),
options: JSON.stringify([{ key: "confirm_agent_suggestion", label: "确认智能体建议" }]),
})
.where("id", "=", d.id)
.execute();
const r = await json(
await h.req(`/decisions/${d.id}/resolve`, { body: { optionKey: "confirm_agent_suggestion" } }),
);
expect(r.status).toBe(409);
expect(r.body.error.message).toContain("模拟不通过");
expect(await rulesOf("Kirara")).toHaveLength(0);
expect(await methods(ids)).toEqual(["unresolved", "unresolved"]);
expect((await decisionOf("Kirara")).status).toBe("open");
});
it("后台调查有持续授权:inspect → simulate → apply_rule 写规则(智能体 / 多证据)+ 重算 + 事项关闭,系统核实已恢复", async () => {
await h.db
.insertInto("automation_grant")
.values({ action: "episode_mapping.apply_rule", granted_by: "admin:test", reason: "集数映射兜底" })
.execute();
const ids = await continuous("Nekomoe");
const d = await decisionOf("Nekomoe");
const r = await autoInvestigate(h.ctx, d.id);
expect(r).toMatchObject({ created: true, mode: "recover" });
const run = await h.db.selectFrom("agent_run").selectAll().where("id", "=", r.runId!).executeTakeFirstOrThrow();
expect(run).toMatchObject({ mode: "execute", created_by: "system:auto-recover" });
expect(run.scope).toMatchObject({ capabilities: [...RECOVER_CAPABILITIES] });
expect((run.scope as Any).capabilities).toContain("episode_mapping.apply_rule");
expect(run.goal).toContain("episode_mapping.apply_rule");
await invoke("episode_mapping.inspect", { decisionId: d.id }, run.id);
expect(
(await invoke("episode_mapping.simulate", { decisionId: d.id, line: ep30("Nekomoe") }, run.id)).summary.ok,
).toBe(true);
const out = await invoke(
"episode_mapping.apply_rule",
{
decisionId: d.id,
line: ep30("Nekomoe"),
reasons: ["发布时间对上第 2 季第 10-12 集播出日期", "同组 S02E09 已按原号对上"],
},
run.id,
);
expect(out).toMatchObject({
summary: { samples: 3, resolved: 3, ok: true },
reresolve: { resolved: 3 },
decisionStatus: "resolved",
});
// 追加在已有内容后面,注释写明是智能体、哪个会话、理由
const lines = (await wordsText()).trimEnd().split("\n");
expect(lines.slice(-2)).toEqual([
`# ${new Date().toISOString().slice(0, 10)} 智能体(会话 ${run.id.slice(0, 8)}):发布时间对上第 2 季第 10-12 集播出日期;同组 S02E09 已按原号对上`,
"S01 @Nekomoe >> S02 EP-30",
]);
expect(out.appended).toBe(lines.slice(-2).join("\n"));
expect(out.lineNumber).toBe(lines.length);
const audit = await h.db
.selectFrom("audit_event")
.select(["actor_type", "actor_id"])
.where("action", "=", "identify_words.updated")
.orderBy("at", "desc")
.executeTakeFirstOrThrow();
expect(audit).toMatchObject({ actor_type: "agent", actor_id: run.id });
expect(await methods(ids)).toEqual(["rule", "rule", "rule"]);
// 系统核实:事项内资源都已不是待确认
const acts = await recoveryActions(h.ctx, run.id);
expect(acts.actions).toEqual([
expect.objectContaining({ capabilityId: "episode_mapping.apply_rule", status: "succeeded" }),
]);
const check = await checkRecovery(h.ctx, acts.actions);
expect(check).toMatchObject({ state: "recovered", facts: { [`decision:${d.id}`]: { unresolved: 0 } } });
const rec = await applyRecoveryOutcome(
h.ctx,
run.id,
{ status: "completed", reason: null, budget: null, message: null, finalText: "已写入规则。" },
acts,
check,
);
expect(rec).toMatchObject({ decisionId: d.id, outcome: "recovered" });
expect(rec!.summary).toMatch(/^已恢复(系统核实)/);
const closed = await decisionOf("Nekomoe");
expect(closed.status).toBe("resolved");
expect((closed.evidence as Any).autoRecover).toMatchObject({ outcome: "recovered", recoverAttempted: true });
});
it("用户把智能体写的那一行注释掉后:资源回到待确认,同范围不再自动写", async () => {
const cur = await json(await h.req(`/media-entities/${kId}/identify-words`));
const text = (cur.body.data.block.text as string).replace(
"S01 @Nekomoe >> S02 EP-30",
"# S01 @Nekomoe >> S02 EP-30",
);
const off = await json(
await h.req(`/media-entities/${kId}/identify-words`, {
method: "PUT",
body: { text, expectedVersion: cur.body.data.block.version },
}),
);
expect(off.status, JSON.stringify(off.body)).toBe(200);
const d = await decisionOf("Nekomoe");
expect(d.status).toBe("open");
await expect(
invoke("episode_mapping.apply_rule", { decisionId: d.id, line: ep30("Nekomoe"), reasons: ["再写一次"] }),
).rejects.toThrow(/注释掉/);
expect(await rulesOf("Nekomoe")).toEqual(["# S01 @Nekomoe >> S02 EP-30"]);
});
it("恢复核实:写规则没解决事项内资源时不算恢复", async () => {
const check = await checkRecovery(h.ctx, [
{
capabilityId: "episode_mapping.apply_rule",
input: { decisionId: (await decisionOf("Sakurato")).id },
status: "succeeded",
},
]);
expect(check.state).toBe("pending");
});
});