Files
UHD_Media_Tool/packages/contracts/test/schemas.test.ts
T
rootandClaude Opus 5.5 f4666a4671 feat(identity): web metadata enrichment for titles missing on TMDB (META-1)
- metadata.enrich (propose, low, replay safe): normalized-title cache (kv_cache
  region enrich, 7 days) -> daily budget -> web.search (title + 短剧 / 电视剧 平台,
  second query limited to whitelist via site:) -> web.fetch of up to 3 relevant
  whitelisted pages (registered site domains never read, redirects out of the
  whitelist dropped) -> upstream model extraction with rule fallback -> server-side
  evidence check (rule_confirmed / multi_evidence / needs_review). Suggestion only.
- POST /identity-pool/{id}/enrich (suggest) and /enrich/apply (console session):
  provisional entity or same-title merge, season/episode units, field-level
  metadata with source web/agent + evidence, aliases, release assignment.
  Poster proxy GET /img/enrich/{id} (og:image from whitelisted pages only).
- Auto-enrich once per pool entry when subscription/rule lookups find nothing on
  TMDB (identity.autoEnrich, off by default); identity.enrichDailyBudget,
  identity.enrichDomains settings; web.fetch now returns og:image.
- Pool sheet "元数据补全" section; entity detail shows evidence links.
- Migration 0049_enrichment (metadata_enrichment, metadata_field.evidence, source
  web). Requires image rebuild / api + worker restart to take effect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 15:57:48 +08:00

251 lines
9.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { z } from "zod";
import * as C from "../src/index.ts";
// 对 contracts 导出的每个 zod schema 做正反例 parse:
// 正例由 sample() 按 schema 结构生成(字符串/数字按候选值逐个试到通过),个别 refine 由 OVERRIDES 修正;
// 反例把一个必填字段替换成非法值(对象)或直接传 Symbol(非对象)。
interface Def {
type: string;
innerType?: z.ZodType;
shape?: Record<string, z.ZodType>;
options?: z.ZodType[];
entries?: Record<string, string>;
values?: unknown[];
element?: z.ZodType;
in?: z.ZodType;
defaultValue?: unknown;
}
const def = (s: z.ZodType) => (s as unknown as { _zod: { def: Def } })._zod.def;
const UUID = "7d3f7f5e-8a8c-4b8e-9f0a-1b2c3d4e5f60";
const STRING_CANDIDATES = [
UUID,
"2026-09-27T08:00:00.000Z",
"2026-09-27",
"https://example.test/x",
"a".repeat(40),
"/media/movies",
"tmdb",
"123",
"CatalogLookupInput",
"catalog.lookup",
"测试 Test",
"123456",
`lib:${UUID}`,
"30 3 * * *",
"ALL",
"zh-hans",
"movie:157336",
];
const NUMBER_CANDIDATES = [1, 50, 2024, 0.5, 0, 4096, 128000];
function pick<T>(schema: z.ZodType, candidates: readonly T[]): T {
const hit = candidates.find((c) => schema.safeParse(c).success);
if (hit === undefined) throw new Error(`没有候选值能通过 ${def(schema).type}`);
return hit;
}
function sample(schema: z.ZodType): unknown {
const d = def(schema);
switch (d.type) {
case "string":
return pick(schema, STRING_CANDIDATES);
case "number":
return pick(schema, NUMBER_CANDIDATES);
case "boolean":
return true;
case "enum":
return Object.values(d.entries!)[0];
case "literal":
return d.values![0];
case "nullable":
case "optional":
case "default":
case "prefault":
case "readonly":
return sample(d.innerType!);
case "array":
return [sample(d.element!)];
case "record":
return { k: "v" };
case "unknown":
return { any: 1 };
case "union":
return sample(d.options![0]!);
case "pipe":
return sample(d.in!);
case "object": {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(d.shape!)) out[k] = sample(v);
return out;
}
default:
throw new Error(`sample 未支持 ${d.type}`);
}
}
/** 需要满足跨字段 refine 的正例修正 */
const OVERRIDES: Record<string, (v: Record<string, unknown>) => Record<string, unknown>> = {
PathMappingCreate: (v) => ({ ...v, viewKind: "downloader", mediaServerInstanceId: null }),
PathMappingUpdate: (v) => ({ ...v, viewKind: "downloader", mediaServerInstanceId: null }),
ExternalIdentityCreate: (v) => ({ ...v, contentUnitId: null }),
// 枚举键的 record 在 zod v4 里要求键齐全
SubscriptionOverviewPage: (v) => ({
...v,
counts: Object.fromEntries(C.SubscriptionGroup.options.map((g) => [g, 0])),
}),
PathBindingCreate: (v) => ({ ...v, bindingKind: "season", seasonNumber: 1 }),
OrganizeRequest: (v) => ({ ...v, sourcePaths: undefined }),
FilesListQuery: (v) => ({ ...v, filter: "video,unidentified" }),
EngineRunQuery: (v) => ({ ...v, status: "running,pending", triggeredBy: "user,api_key" }),
CatalogLookupInput: (v) => v,
IdentityResolveInput: (v) => v,
AssetLocationCreate: (v) => v,
ReleaseIdentityAssign: (v) => ({ ...v, external: undefined, candidateIndex: undefined }),
MetadataEnrichInput: (v) => ({ ...v, title: undefined }),
CacheSettings: (v) => ({ ...v, hardThresholdPercent: 90, resumePercent: 85, cleanupTargetPercent: 80 }),
GlobalSettings: () => C.DEFAULT_SETTINGS,
// 方案至少 2 个(id 不同)
AgentProposeOptionsInput: (v) => {
const o = (v.options as Record<string, unknown>[])[0]!;
return { ...v, options: [o, { ...o, id: "b" }] };
},
EngineStats: (v) => ({ ...v, byStatus: { pending: 1 } }),
TypedEventEnvelope: () => ({
seq: 1,
at: "2026-09-27T08:00:00Z",
type: "system.heartbeat",
payload: { role: "all" },
}),
};
const isSchema = (v: unknown): v is z.ZodType => v instanceof z.ZodType;
const schemas: [string, z.ZodType][] = [];
for (const [k, v] of Object.entries(C) as [string, unknown][]) if (isSchema(v)) schemas.push([k, v]);
describe("所有导出 schema 的正反例", () => {
it("导出了足够多的 schema", () => {
expect(schemas.length).toBeGreaterThan(150);
});
for (const [name, schema] of schemas) {
it(name, () => {
let good = sample(schema);
const fix = OVERRIDES[name];
if (fix && good && typeof good === "object") good = fix(good as Record<string, unknown>);
const ok = schema.safeParse(good);
if (!ok.success) throw new Error(`${name} 正例失败: ${ok.error.message}\n${JSON.stringify(good)}`);
// 反例
const d = def(schema);
if (d.type === "object") {
const shape = d.shape!;
const key = Object.keys(shape).find(
(k) => !shape[k]!.safeParse(undefined).success && def(shape[k]!).type !== "unknown",
);
const bad = key ? { ...(good as object), [key]: Symbol("bad") } : Symbol("bad");
expect(schema.safeParse(bad).success, `${name} 反例应失败(${key ?? "整体"})`).toBe(false);
} else {
expect(schema.safeParse(Symbol("bad")).success, `${name} 反例应失败`).toBe(false);
}
});
}
});
describe("关键合同行为", () => {
it("Slot 状态集合与 REQ §6.1 一致", () => {
expect(C.SlotStatus.options).toEqual([
"empty",
"reserved",
"downloading",
"verifying",
"satisfied",
"upgrade_pending",
"attention",
]);
});
it("CacheSettings 拒绝恢复水位 ≥ 硬阈值", () => {
expect(C.CacheSettings.safeParse({ ...C.DEFAULT_SETTINGS.cache, resumePercent: 95 }).success).toBe(false);
});
it("GlobalSettingsUpdate 部分更新不会注入默认值,mergeSettings 保留现值", () => {
const patch = C.GlobalSettingsUpdate.parse({ automation: { enabled: false }, pi: { contextBudget: 64_000 } });
expect(patch).toEqual({ automation: { enabled: false }, pi: { contextBudget: 64_000 } });
const merged = C.mergeSettings(C.DEFAULT_SETTINGS, patch);
expect(merged.automation).toEqual({ enabled: false, readOnlyReconcile: false });
expect(merged.pi).toEqual({ ...C.DEFAULT_SETTINGS.pi, contextBudget: 64_000 });
expect(C.GlobalSettings.safeParse(merged).success).toBe(true);
});
it("OrganizationChangesQuery 接受查询串并给默认值", () => {
expect(C.OrganizationChangesQuery.parse({ after: "42", limit: "10", includeFiles: "false" })).toEqual({
after: "42",
limit: 10,
includeFiles: false,
});
expect(C.OrganizationChangesQuery.safeParse({ after: "-1" }).success).toBe(false);
});
it("ExternalIdentityCreate 要求且只能有一个本地目标", () => {
const base = { provider: "tmdb", entityType: "movie", externalId: "1" };
expect(C.ExternalIdentityCreate.safeParse({ ...base, mediaEntityId: UUID }).success).toBe(true);
expect(C.ExternalIdentityCreate.safeParse({ ...base, mediaEntityId: UUID, contentUnitId: UUID }).success).toBe(
false,
);
});
it("能力目录覆盖首批 14 个能力且 schema 引用都存在", () => {
expect(Object.keys(C.CAPABILITY_CATALOG).sort()).toEqual([...C.CAPABILITY_IDS].sort());
const exported = C as Record<string, unknown>;
for (const cap of Object.values(C.CAPABILITY_CATALOG)) {
expect(C.CapabilityDescriptor.safeParse(cap).success).toBe(true);
expect(isSchema(exported[cap.inputSchema]), cap.inputSchema).toBe(true);
expect(isSchema(exported[cap.outputSchema]), cap.outputSchema).toBe(true);
expect(exported[cap.inputSchema]).toBe(C.CAPABILITY_IO[cap.id as C.KnownCapabilityId].input);
expect(exported[cap.outputSchema]).toBe(C.CAPABILITY_IO[cap.id as C.KnownCapabilityId].output);
}
});
it("capabilitySpec 组装目录元数据与真实 schema;有副作用的类别不可重放", () => {
for (const id of C.CAPABILITY_IDS) {
const spec = C.capabilitySpec(id);
const { inputSchema: _i, outputSchema: _o, ...meta } = C.CAPABILITY_CATALOG[id];
expect(spec).toMatchObject(meta);
expect(spec.inputSchema).toBe(C.CAPABILITY_IO[id].input);
expect(spec.outputSchema).toBe(C.CAPABILITY_IO[id].output);
// 例外:site.health_check 只读首页并写回健康列,幂等可重跑;metadata.enrich 只存补全建议(7 天标题缓存),重跑无副作用
if (spec.kind !== "read" && spec.kind !== "preview" && id !== "site.health_check" && id !== "metadata.enrich")
expect(spec.replay, id).toBe("never");
}
});
it("事件 payload 表覆盖全部事件类型", () => {
expect(Object.keys(C.EVENT_PAYLOADS).sort()).toEqual(Object.values(C.EventType).sort());
expect(
C.parseEventPayload("task.status_changed", {
taskId: UUID,
kind: "ingestFeed",
from: null,
to: "running",
error: null,
}),
).not.toBeNull();
expect(C.parseEventPayload("task.status_changed", { taskId: "x" })).toBeNull();
expect(C.isEventType("nope.x")).toBe(false);
});
it("API 端点路径唯一且 buildPath 填参", () => {
const eps = C.listEndpoints();
const keys = eps.map((e) => `${e.method} ${e.path}`);
expect(new Set(keys).size).toBe(keys.length);
expect(C.buildPath(C.Api.tasks.cancel.path, { id: UUID })).toBe(`/api/v1/tasks/${UUID}/cancel`);
expect(() => C.buildPath(C.Api.tasks.get.path)).toThrow();
// 公开整理记录读写分权
expect(C.Api.organization.changes.scope).toBe("organize.read");
expect(C.Api.organization.create.scope).toBe("organize.execute");
});
});