Files
UHD_Media_Tool/packages/contracts/test/schemas.test.ts
T
root b32d5c028a merge: ui2/t-discover
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei

# Conflicts:
#	packages/adapters/src/tmdb/index.ts
2026-10-06 18:34:12 +08:00

271 lines
11 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",
"zh-CN",
// 发现页筛选:原始语言、地区、年份
"zh",
"US",
"2024",
];
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" }),
LogFilter: (v) => ({ ...v, type: "task,auth", level: "error,warn" }),
LogQuery: (v) => ({ ...v, type: "task,auth", level: "error,warn" }),
LogStreamQuery: (v) => ({ ...v, type: "auto", level: "running" }),
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 } }),
// 发现页查询:筛选条件只配特定列表(跨字段校验),正例只给列表
DiscoverTmdbQuery: () => ({ list: "discover", type: "movie", genre: "28", year: "2024" }),
DiscoverDoubanQuery: () => ({ list: "explore", type: "tv", genre: "悬疑", year: "2024" }),
DiscoverBangumiQuery: () => ({ list: "rank", year: "2024" }),
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,
workerLanes: C.DEFAULT_WORKER_LANES,
});
// 分道并发部分更新:只改一道,其余保留
const lanes = C.mergeSettings(merged, C.GlobalSettingsUpdate.parse({ automation: { workerLanes: { file: 3 } } }));
expect(lanes.automation.workerLanes).toEqual({ file: 3, network: 4, maintenance: 1 });
expect(C.GlobalSettings.safeParse(lanes).success).toBe(true);
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");
});
});