Files
UHD_Media_Tool/apps/server/test/acquisition-organize/upgrade-completion.test.ts
T
rootandClaude Opus 5.5 fcd0d8f223 fix(acquisition): 订阅总带自己的收录版本、洗版到最高档才完成、接续后续季加季下界、重评择优并放宽范围、手动下载计入在途
- H1:新建 / 编辑订阅时没有自己的收录版本就按策略模板建,没有模板建隐式「默认版本」(质量预设随后写入);
  旧订阅留下的同名版本改名加序号;已有的 0 收录版本订阅在首次评估 / 订阅重评时补建
- H2:收录版本有升级目标(规则组 / 阶梯按 pri 洗版)时,订阅完成要求覆盖齐且每个已覆盖单元到目标 pri;
  slotComplete 多返回 atTarget,接收即计入与入库后两处完成判定统一用 completesOnCoverage + slotDone
- H4:「接续后续季」评估只看不早于订阅季的资源,与覆盖矩阵同口径(seasonInScope)
- M4:按收录版本 / 订阅重评时先用 rankForSlot 按主收录版本择优排序再逐条预留;订阅重评只评该订阅的收录版本
- M5:重评取最近 500 条(原 50 条),先按订阅季范围、已覆盖单元在内存里过滤再逐条评估
- M6:同作品手动下载中的计划(slot_id 为空)并入在途单元,冲突延后时排退避重评
- 资源解析 / 站点上下文挪到 release-facts.ts,评估与完成判定共用,避免循环引用

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

156 lines
7.8 KiB
TypeScript

import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
import { submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { applyEpisodeTotal } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
// 洗版完成口径(H2):收录版本有升级目标(规则组阶梯按 pri 洗版)时,覆盖齐还不够,要到最高档才完成订阅。
// 电影洗版、按集洗版、全集洗版各一例;下载器接收即计入(afterDownloadAccepted)与入库后(afterSlotVerified)同口径。
const hasPg = await pgAvailable();
const SIZE = 300_000;
describe.skipIf(!hasPg)("洗版到最高档才完成(真实 PG + mock qB + 临时目录)", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
const disk = fakeDisk(10_000_000_000);
const torrents = new Map<string, Uint8Array>();
let base: Awaited<ReturnType<typeof seedBase>>;
let groupId: string;
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" });
cache = await createTempTree({}, { prefix: "umt-cache-" });
disk.state.watch = [cache.root];
disk.setPct(10);
h = await createHarness("upgradedone", {
diskUsage: disk.fn,
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
});
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
const g = await h.api("POST", "/rules", { name: "洗版到 REMUX", ruleString: "REMUX > WEBDL" });
expect(g.status, JSON.stringify(g.body)).toBe(201);
groupId = g.body.data.id;
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
await lib?.cleanup();
await cache?.cleanup();
});
const db = () => h.db;
async function washSub(kind: "movie" | "series", title: string, mode: "movie" | "per_unit" | "full_pack") {
const entityId = await seedEntity(db(), { kind, title, year: 2024 });
const sub = await db()
.insertInto("subscription")
.values({ media_entity_id: entityId, media_kind: kind, mode: "once", season_scope: "current_season" })
.returning("id")
.executeTakeFirstOrThrow();
await db()
.insertInto("acquisition_slot")
.values({
media_entity_id: entityId,
subscription_id: sub.id,
name: "洗版",
upgrade_threshold: JSON.stringify({ enabled: true, mode }),
rule_group_ids: JSON.stringify([groupId]),
})
.execute();
return { entityId, subId: sub.id };
}
async function release(entityId: string, title: string, files?: { path: string; length: number }[]) {
const t = files
? makeTorrent({ name: title, files, pieceSeed: Math.floor(Math.random() * 1e6) })
: makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) });
const id = await seedRelease(db(), { siteId: base.siteId, entityId, title, size: SIZE, hash: t.hash });
torrents.set(id, t.bytes);
return { id, hash: t.hash };
}
const subRow = (id: string) =>
db().selectFrom("subscription").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
const planRow = (id: string) =>
db().selectFrom("acquisition_plan").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
async function planFor(relId: string) {
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: relId });
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
return ev.plans[0]!;
}
async function submit(planId: string) {
const s = await submitDownload(h.d, planId);
expect(s.status).toBe("submitted");
return s.downloadTaskId!;
}
async function completeAndImport(hash: string, dtId: string) {
await qb.complete(hash, { writeFiles: true });
const sync = await syncDownloads(h.d);
expect(sync.completed).toContain(dtId);
const p = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" });
expect(p.status, JSON.stringify(p)).toBe("running");
const ex = await executeRecord(h.d, p.recordId!);
expect(ex.status).toBe("succeeded");
}
it("电影洗版:WEB-DL(pri 99)接收、入库都不完成;REMUX(pri 100)接收即完成", async () => {
const { entityId, subId } = await washSub("movie", "Wash Film", "movie");
const web = await release(entityId, "Wash.Film.2024.1080p.WEB-DL.x264-GRP");
const webTask = await submit(await planFor(web.id));
expect(await subRow(subId)).toMatchObject({ status: "active" });
await completeAndImport(web.hash, webTask);
expect(await subRow(subId)).toMatchObject({ status: "active" });
const remux = await release(entityId, "Wash.Film.2024.1080p.BluRay.REMUX.AVC-GRP");
const up = await planFor(remux.id);
expect(await planRow(up)).toMatchObject({ purpose: "upgrade" });
await submit(up);
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
}, 60_000);
it("按集洗版:全集已覆盖但不是最高档时不完成;逐集洗到 REMUX 后完成", async () => {
const { entityId, subId } = await washSub("series", "Wash Episodes", "per_unit");
await db()
.transaction()
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 1, now: new Date() }));
const web = await release(entityId, "Wash.Episodes.S01E01.1080p.WEB-DL.x264-GRP");
const webTask = await submit(await planFor(web.id));
expect(await subRow(subId)).toMatchObject({ status: "active" });
await completeAndImport(web.hash, webTask);
expect(await subRow(subId)).toMatchObject({ status: "active" });
const remux = await release(entityId, "Wash.Episodes.S01E01.1080p.BluRay.REMUX.AVC-GRP");
const up = await planFor(remux.id);
expect(await planRow(up)).toMatchObject({ purpose: "upgrade", unit_keys: ["s1e1"] });
await submit(up);
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
}, 60_000);
it("全集洗版:WEB-DL 季包覆盖全季不完成;单集 REMUX 不接;REMUX 季包后完成", async () => {
const { entityId, subId } = await washSub("series", "Wash Pack", "full_pack");
await db()
.transaction()
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 2, now: new Date() }));
const files = (tag: string) => [1, 2].map((e) => ({ path: `Wash.Pack.S01E0${e}.1080p.${tag}.mkv`, length: SIZE }));
const web = await release(entityId, "Wash.Pack.S01.1080p.WEB-DL.x264-GRP", files("WEB-DL.x264-GRP"));
const webPlan = await planFor(web.id);
expect((await planRow(webPlan)).unit_keys).toEqual(["s1e1", "s1e2"]);
const webTask = await submit(webPlan);
expect(await subRow(subId)).toMatchObject({ status: "active" });
await completeAndImport(web.hash, webTask);
expect(await subRow(subId)).toMatchObject({ status: "active" });
const single = await release(entityId, "Wash.Pack.S01E01.1080p.BluRay.REMUX.AVC-GRP");
expect((await evaluateAcquisition(h.d, { sourceReleaseId: single.id })).plans).toHaveLength(0);
const remux = await release(entityId, "Wash.Pack.S01.1080p.BluRay.REMUX.AVC-GRP", files("BluRay.REMUX.AVC-GRP"));
const up = await planFor(remux.id);
expect(await planRow(up)).toMatchObject({ purpose: "upgrade" });
await submit(up);
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
}, 60_000);
});