feat(mapping): centralize domain <-> contracts mappings (B5)
New @umt/mapping (depends on domain + contracts only): download status + pause reason, evidence level / media kind, identity resolution status, slot status / spec columns / evaluation, ParsedRelease <-> source_release.parsed, SiteRelease <-> source_release, PathBinding, OrganizePlan -> organization_record / file results / organize.plan output. Tables are Record<union, ...> so adding a value on either side fails typecheck or tests. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
da8e53bbca
commit
42ecc93fa5
16 files changed
+1282
-1
No files matched your search
@@ -0,0 +1,266 @@
|
||||
import * as C from "@umt/contracts";
|
||||
import {
|
||||
DEFAULT_TEMPLATES,
|
||||
evaluateCandidate,
|
||||
type IdentityCandidate,
|
||||
parseRelease,
|
||||
planOrganize,
|
||||
resolveIdentity,
|
||||
resolveTargetDirectory,
|
||||
type SlotSpec,
|
||||
} from "@umt/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as M from "../src/index.ts";
|
||||
|
||||
const U = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
|
||||
const AT = "2026-09-27T00:00:00.000Z";
|
||||
const GiB = 1024 ** 3;
|
||||
|
||||
describe("ParsedRelease ↔ source_release.parsed", () => {
|
||||
it("往返无损;版本不符或形状不对返回 null(调用方重解析)", () => {
|
||||
const p = parseRelease("Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1-FGT");
|
||||
const cols = M.toParsedColumns(p);
|
||||
expect(C.SourceReleaseCreate.shape.parserVersion.safeParse(cols.parserVersion).success).toBe(true);
|
||||
expect(M.fromParsedColumns(cols)).toEqual(JSON.parse(JSON.stringify(p)));
|
||||
expect(M.fromParsedColumns({ ...cols, parserVersion: "0" })).toBeNull();
|
||||
expect(
|
||||
M.fromParsedColumns({ parsed: { ...cols.parsed, audio: "x" }, parserVersion: cols.parserVersion }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("身份", () => {
|
||||
const p = parseRelease("Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.H.265-FLUX");
|
||||
const tmdb: IdentityCandidate = {
|
||||
provider: "tmdb",
|
||||
entityType: "movie",
|
||||
externalId: "693134",
|
||||
titles: ["Dune: Part Two", "Dune Part Two"],
|
||||
year: 2024,
|
||||
aliases: [],
|
||||
};
|
||||
|
||||
it("排名候选 → ExternalIdentityCreate(schema 合法);本地候选不生成外部身份", () => {
|
||||
const res = resolveIdentity(p, [tmdb]);
|
||||
const create = M.rankedToIdentityCreate(res.ranked[0]!, { mediaEntityId: U(1) }, "accepted");
|
||||
expect(create).not.toBeNull();
|
||||
expect(C.ExternalIdentityCreate.safeParse(create).success).toBe(true);
|
||||
expect(create).toMatchObject({ provider: "tmdb", externalId: "693134", status: "accepted" });
|
||||
|
||||
const local = M.localIdentityCandidate(
|
||||
{ id: U(2), kind: "movie", title: "沙丘2", originalTitle: "Dune: Part Two", year: 2024 },
|
||||
["Dune Part Two"],
|
||||
[{ provider: "tmdb", externalId: "693134", status: "accepted" }],
|
||||
);
|
||||
expect(local).toMatchObject({
|
||||
provider: "local",
|
||||
localEntityId: U(2),
|
||||
linkedExternalIds: [{ provider: "tmdb", id: "693134" }],
|
||||
});
|
||||
const both = resolveIdentity(p, [tmdb, local!]);
|
||||
// 本地作品与 TMDB 是同一身份(linkedExternalIds),不构成歧义
|
||||
expect(both.status).toBe("auto");
|
||||
const localRanked = both.ranked.find((r) => r.candidate.provider === "local")!;
|
||||
expect(M.rankedToIdentityCreate(localRanked, { mediaEntityId: U(2) })).toBeNull();
|
||||
expect(
|
||||
M.localIdentityCandidate({ id: U(3), kind: "collection", title: "x", originalTitle: null, year: null }, []),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PathBinding", () => {
|
||||
const row = (over: Partial<C.PathBinding>): C.PathBinding => ({
|
||||
id: U(10),
|
||||
createdAt: AT,
|
||||
updatedAt: AT,
|
||||
mediaEntityId: U(1),
|
||||
contentUnitId: null,
|
||||
seasonNumber: null,
|
||||
bindingKind: "entity",
|
||||
libraryRootId: U(20),
|
||||
relativePath: "最后生还者 (2023)",
|
||||
templateVersion: "1",
|
||||
status: "active",
|
||||
boundAt: AT,
|
||||
boundBy: null,
|
||||
supersededById: null,
|
||||
note: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("行 → domain 后参与目标目录决策:active 优先,非 active 忽略", () => {
|
||||
const bindings = [
|
||||
row({}),
|
||||
row({ id: U(11), bindingKind: "season", seasonNumber: 1, relativePath: "最后生还者 (2023)/S1" }),
|
||||
row({ id: U(12), relativePath: "旧目录", status: "superseded", templateVersion: "legacy" }),
|
||||
].map(M.toDomainPathBinding);
|
||||
expect(bindings[2]).toMatchObject({ active: false, templateVersion: 0 });
|
||||
const r = resolveTargetDirectory({
|
||||
entity: { id: U(1), kind: "tv" },
|
||||
naming: { title: "最后的生还者", year: 2023 },
|
||||
season: 1,
|
||||
bindings,
|
||||
libraryRoot: { id: U(20), hostPath: "/media/tv" },
|
||||
templates: DEFAULT_TEMPLATES,
|
||||
});
|
||||
expect(r).toMatchObject({ kind: "existing", relativePath: "最后生还者 (2023)/S1", bindingIds: [U(11)] });
|
||||
});
|
||||
|
||||
it("新绑定 → PathBindingCreate(schema 合法,season 绑定带 seasonNumber)", () => {
|
||||
const r = resolveTargetDirectory({
|
||||
entity: { id: U(1), kind: "tv" },
|
||||
naming: { title: "最后生还者", year: 2023 },
|
||||
season: 2,
|
||||
bindings: [],
|
||||
libraryRoot: { id: U(20), hostPath: "/media/tv" },
|
||||
templates: DEFAULT_TEMPLATES,
|
||||
});
|
||||
expect(r.kind).toBe("new");
|
||||
if (r.kind !== "new") return;
|
||||
const creates = r.newBindings.map((nb) => M.toPathBindingCreate(nb, { mediaEntityId: U(1) }));
|
||||
for (const c of creates) expect(C.PathBindingCreate.safeParse(c).success, JSON.stringify(c)).toBe(true);
|
||||
expect(creates.map((c) => [c.bindingKind, c.seasonNumber ?? null, c.templateVersion])).toEqual([
|
||||
["entity", null, "1"],
|
||||
["season", 2, "1"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("整理计划 → 整理记录", () => {
|
||||
const ROOT = "/downloads/tv/The.Last.of.Us.S01.2160p.WEB-DL-FLUX";
|
||||
const plan = planOrganize({
|
||||
operation: "copy",
|
||||
mediaType: "tv",
|
||||
sourceRoot: ROOT,
|
||||
files: [
|
||||
{ path: `${ROOT}/The.Last.of.Us.S01E01.2160p.mkv`, size: 5 * GiB, device: 1 },
|
||||
{ path: `${ROOT}/The.Last.of.Us.S01E01.2160p.chs.srt`, size: 50_000, device: 1 },
|
||||
{ path: `${ROOT}/Sample/the.last.of.us.s01e01.sample.mkv`, size: 80 * 1024 ** 2, device: 1 },
|
||||
],
|
||||
entity: { title: "最后生还者", year: 2023 },
|
||||
target: { seasonDirs: { 1: "/media/tv/最后生还者 (2023)/Season 1" }, device: 2 },
|
||||
existingTargets: [],
|
||||
});
|
||||
|
||||
it("记录与逐文件草稿都通过 contracts schema;排除文件记为 skipped", () => {
|
||||
const rec = M.organizePlanToRecord(plan, {
|
||||
trigger: "auto",
|
||||
actorType: "system",
|
||||
downloadTaskId: U(5),
|
||||
sourcePath: ROOT,
|
||||
planFingerprint: "h1",
|
||||
});
|
||||
expect(C.OrganizationRecordCreate.safeParse(rec).success).toBe(true);
|
||||
expect(rec).toMatchObject({ action: "copy", sourcePathView: "local", downloadTaskId: U(5) });
|
||||
expect(rec.plan).toMatchObject({ operation: "copy" });
|
||||
|
||||
const files = M.organizePlanToFileResults(plan, { targetLibraryRootId: U(20) });
|
||||
for (const f of files) {
|
||||
expect(C.OrganizationFileResultCreate.safeParse({ ...f, organizationRecordId: U(9) }).success).toBe(true);
|
||||
expect(C.FileResultStatus.options).toContain(f.status);
|
||||
}
|
||||
expect(files.map((f) => [f.ordinal, f.role, f.status, f.skipReason])).toEqual([
|
||||
[0, "main", "queued", null],
|
||||
[1, "subtitle", "queued", null],
|
||||
[2, "main", "skipped", "excluded:sample"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("能力输出合法;整理记录状态按文件结果汇总", () => {
|
||||
const out = M.organizePlanToCapabilityOutput(plan, { planId: U(7), payloadHash: "h1" });
|
||||
expect(C.OrganizePlanOutput.safeParse(out).success).toBe(true);
|
||||
expect(out.files).toHaveLength(3);
|
||||
expect(M.organizationStatusFromFiles(["succeeded", "skipped"])).toBe("succeeded");
|
||||
expect(M.organizationStatusFromFiles(["succeeded", "failed"])).toBe("partial");
|
||||
expect(M.organizationStatusFromFiles(["failed", "skipped"])).toBe("failed");
|
||||
expect(M.organizationStatusFromFiles(["succeeded", "running"])).toBe("running");
|
||||
expect(M.organizationStatusFromFiles(["cancelled"])).toBe("cancelled");
|
||||
expect(M.organizationStatusFromFiles([])).toBe("succeeded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Slot 规则与评估", () => {
|
||||
const spec: SlotSpec = {
|
||||
id: U(30),
|
||||
name: "archival",
|
||||
hard: { resolutions: ["2160p"], maxSizeBytes: 40 * GiB },
|
||||
unknownPolicy: { size: "accept" },
|
||||
soft: { hdr: ["DV"] },
|
||||
upgrade: { enabled: true },
|
||||
};
|
||||
|
||||
it("SlotSpec → 规则列 → SlotSpec 往返", () => {
|
||||
const cols = M.slotSpecToColumns(spec);
|
||||
expect(C.AcquisitionSlotCreate.safeParse({ ...cols, mediaEntityId: U(1) }).success).toBe(true);
|
||||
const back = M.slotSpecFromRow({ id: U(30), ...cols, completion: cols.completion ?? {} } as Parameters<
|
||||
typeof M.slotSpecFromRow
|
||||
>[0]);
|
||||
expect(back).toEqual(spec);
|
||||
});
|
||||
|
||||
it("评估结果 → 扁平表示(rejected 与 DownloadPlanOutput.rejected 同形)", () => {
|
||||
const ok = M.toContractSlotEvaluation(evaluateCandidate(spec, parseRelease("Dune.2021.2160p.BluRay.x265.DV-CHD")));
|
||||
expect(ok).toMatchObject({ decision: "accept", acceptable: true, rejected: [] });
|
||||
const bad = M.toContractSlotEvaluation(evaluateCandidate(spec, parseRelease("Dune.2021.1080p.HDTV.x264-GRP")));
|
||||
expect(bad.acceptable).toBe(false);
|
||||
expect(bad.rejected[0]?.code).toMatch(/^resolution\./);
|
||||
const shape = C.DownloadPlanOutput.shape.rejected;
|
||||
expect(shape.safeParse(bad.rejected).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SiteRelease ↔ source_release", () => {
|
||||
const site: M.SiteReleaseLike = {
|
||||
siteId: "audiences",
|
||||
siteReleaseId: "123",
|
||||
title: "Dune.Part.Two.2024.2160p.WEB-DL-FLUX",
|
||||
subtitle: "沙丘2",
|
||||
category: { id: "401", name: "电影" },
|
||||
sizeBytes: 20 * GiB,
|
||||
seeders: 10,
|
||||
leechers: 1,
|
||||
snatched: 30,
|
||||
promotion: { type: "free", downloadFactor: 0, uploadFactor: 1, expiresAt: "2026-09-28T00:00:00.000Z" },
|
||||
hr: "unknown",
|
||||
uploadedAt: "2026-09-26T00:00:00.000Z",
|
||||
detailUrl: "https://pt.example/details.php?id=123",
|
||||
downloadUrl: "https://pt.example/download.php?id=123",
|
||||
infoHash: "A".repeat(40),
|
||||
imdbId: "tt15239678",
|
||||
tags: ["中字"],
|
||||
raw: { small_descr: "沙丘2" },
|
||||
observedAt: AT,
|
||||
};
|
||||
|
||||
it("转换结果通过 SourceReleaseCreate;downloadUrl 不入库;siteId 用 site 行 UUID", () => {
|
||||
const create = M.siteReleaseToSourceRelease(site, { siteId: U(40), parsed: parseRelease(site.title) });
|
||||
expect(C.SourceReleaseCreate.safeParse(create).success).toBe(true);
|
||||
expect(create).toMatchObject({ siteId: U(40), infohashV1: "a".repeat(40), infohashV2: null, categoryRaw: "401" });
|
||||
expect(JSON.stringify(create)).not.toContain("download.php");
|
||||
expect(create.parserVersion).toBe("1");
|
||||
});
|
||||
|
||||
it("source_release 行还原为 SiteReleaseLike(下载入口除外)", () => {
|
||||
const create = M.siteReleaseToSourceRelease(site, { siteId: U(40) });
|
||||
const row: C.SourceRelease = {
|
||||
...(create as Required<typeof create>),
|
||||
id: U(41),
|
||||
createdAt: AT,
|
||||
updatedAt: AT,
|
||||
firstSeenAt: AT,
|
||||
lastSeenAt: AT,
|
||||
fileList: null,
|
||||
detailFetchedAt: null,
|
||||
parsed: {},
|
||||
parserVersion: null,
|
||||
mediaEntityId: null,
|
||||
contentUnitId: null,
|
||||
resolutionStatus: "unresolved",
|
||||
resolutionEvidence: {},
|
||||
availability: "active",
|
||||
};
|
||||
expect(C.SourceRelease.safeParse(row).success).toBe(true);
|
||||
const { downloadUrl: _d, ...expected } = site;
|
||||
expect(M.sourceReleaseToSiteRelease(row)).toEqual({ ...expected, infoHash: "a".repeat(40) });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import * as C from "@umt/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as M from "../src/index.ts";
|
||||
|
||||
// 枚举全覆盖:
|
||||
// - domain 侧是 TS 联合类型,映射表写成 Record<DomainX, …>,domain 增删值时 typecheck 直接失败;
|
||||
// 这里再在运行时比对 DOMAIN_* 清单与期望清单,防止有人用类型断言绕过。
|
||||
// - contracts 侧是 z.enum,逐个 .options 断言都有映射,新增值时测试失败。
|
||||
|
||||
const sorted = (xs: readonly string[]) => [...xs].sort();
|
||||
|
||||
describe("下载状态", () => {
|
||||
it("domain 取值清单固定", () => {
|
||||
expect(sorted(M.DOMAIN_DOWNLOAD_STATUSES)).toEqual(
|
||||
sorted([
|
||||
"queued",
|
||||
"downloading",
|
||||
"paused_by_threshold",
|
||||
"paused_by_user",
|
||||
"completed",
|
||||
"error",
|
||||
"missing_files",
|
||||
"removed",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("domain → contracts → domain 往返无损,pauseReason 只在 paused 时出现", () => {
|
||||
for (const s of M.DOMAIN_DOWNLOAD_STATUSES) {
|
||||
const c = M.toContractDownloadState(s);
|
||||
expect(C.DownloadStatus.options).toContain(c.status);
|
||||
expect(c.pauseReason !== null).toBe(c.status === "paused");
|
||||
expect(M.toDomainDownloadStatus(c.status, c.pauseReason), s).toBe(s);
|
||||
}
|
||||
});
|
||||
|
||||
it("contracts 每个状态、每个暂停原因都能折回 domain", () => {
|
||||
for (const s of C.DownloadStatus.options) {
|
||||
for (const r of [null, ...C.PauseReason.options]) {
|
||||
expect(M.DOMAIN_DOWNLOAD_STATUSES, `${s}/${r}`).toContain(M.toDomainDownloadStatus(s, r));
|
||||
}
|
||||
}
|
||||
expect(M.toDomainDownloadStatus("paused", "threshold")).toBe("paused_by_threshold");
|
||||
expect(M.toDomainDownloadStatus("paused", "agent")).toBe("paused_by_user");
|
||||
expect(M.toDomainDownloadStatus("imported", null)).toBe("completed");
|
||||
});
|
||||
|
||||
it("合并观察:业务叠加态在底层状态不变时保留,否则以观察为准", () => {
|
||||
const st = (status: C.DownloadStatus, pauseReason: C.PauseReason | null = null) => ({ status, pauseReason });
|
||||
expect(M.mergeObservedDownloadState(st("importing"), "completed")).toEqual(st("importing"));
|
||||
expect(M.mergeObservedDownloadState(st("imported"), "completed")).toEqual(st("imported"));
|
||||
expect(M.mergeObservedDownloadState(st("stalled"), "downloading")).toEqual(st("stalled"));
|
||||
expect(M.mergeObservedDownloadState(st("stalled"), "completed")).toEqual(st("completed"));
|
||||
expect(M.mergeObservedDownloadState(st("submitting"), "queued")).toEqual(st("submitting"));
|
||||
expect(M.mergeObservedDownloadState(st("paused", "agent"), "paused_by_user")).toEqual(st("paused", "agent"));
|
||||
expect(M.mergeObservedDownloadState(st("paused", "user"), "paused_by_threshold")).toEqual(
|
||||
st("paused", "threshold"),
|
||||
);
|
||||
expect(M.mergeObservedDownloadState(st("imported"), "removed")).toEqual(st("removed"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("证据等级 / 媒体类型", () => {
|
||||
it("domain 匹配等级清单固定;rejected 不形成证据", () => {
|
||||
expect(sorted(M.DOMAIN_MATCH_LEVELS)).toEqual(
|
||||
sorted(["rule_confirmed", "multi_evidence", "needs_confirm", "rejected"]),
|
||||
);
|
||||
expect(M.toContractEvidenceLevel("needs_confirm")).toBe("needs_review");
|
||||
expect(M.toContractEvidenceLevel("rejected")).toBeNull();
|
||||
for (const l of M.DOMAIN_MATCH_LEVELS) {
|
||||
const c = M.toContractEvidenceLevel(l);
|
||||
if (c) expect(M.toDomainConfidence(c)).toBe(l);
|
||||
}
|
||||
});
|
||||
|
||||
it("contracts 每个证据等级都能折回 domain;manual 视为最强", () => {
|
||||
for (const l of C.EvidenceLevel.options) expect(M.DOMAIN_MATCH_LEVELS).toContain(M.toDomainConfidence(l));
|
||||
expect(M.toDomainConfidence("manual")).toBe("rule_confirmed");
|
||||
});
|
||||
|
||||
it("媒体类型与外部实体类型", () => {
|
||||
for (const k of C.MediaKind.options) {
|
||||
const d = M.toDomainMediaType(k);
|
||||
if (d) expect(M.toDomainMediaType(M.toContractMediaKind(d, { variety: k === "variety" }))).toBe(d);
|
||||
}
|
||||
expect(M.toContractMediaKind("tv")).toBe("series");
|
||||
expect(M.toContractMediaKind("tv", { variety: true })).toBe("variety");
|
||||
for (const t of C.ExternalEntityType.options) {
|
||||
const d = M.toDomainEntityType(t);
|
||||
if (d) expect(M.toContractEntityType(d)).toBe(t);
|
||||
}
|
||||
expect(M.toDomainEntityType("season")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("身份解析状态", () => {
|
||||
it("domain 状态清单固定,映射到能力输出与 source_release 状态", () => {
|
||||
expect(sorted(M.DOMAIN_RESOLUTION_STATUSES)).toEqual(sorted(["auto", "ambiguous", "no_match"]));
|
||||
const out = C.IdentityResolveOutput.shape.status.options;
|
||||
for (const s of M.DOMAIN_RESOLUTION_STATUSES) {
|
||||
for (const autoAccept of [true, false]) expect(out).toContain(M.toContractResolveStatus(s, { autoAccept }));
|
||||
}
|
||||
expect(M.toContractResolveStatus("auto", { autoAccept: true })).toBe("resolved");
|
||||
expect(M.toContractResolveStatus("auto", { autoAccept: false })).toBe("proposed");
|
||||
expect(M.toContractResolveStatus("no_match", { autoAccept: true })).toBe("unidentifiable");
|
||||
});
|
||||
|
||||
it("能力输出每个状态都能映射到 ResolutionStatus", () => {
|
||||
for (const s of C.IdentityResolveOutput.shape.status.options) {
|
||||
expect(C.ResolutionStatus.options).toContain(M.toReleaseResolutionStatus(s));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Slot / 内容类型 / 分段 / 整理动作", () => {
|
||||
it("Slot 状态两侧一一对应", () => {
|
||||
expect(sorted(M.DOMAIN_SLOT_STATUSES)).toEqual(sorted(C.SlotStatus.options));
|
||||
for (const s of C.SlotStatus.options) expect(M.toContractSlotStatus(M.toDomainSlotStatus(s))).toBe(s);
|
||||
});
|
||||
|
||||
it("综艺内容类型两侧一一对应;分段 unknown ↔ 缺失", () => {
|
||||
expect(sorted(M.DOMAIN_CONTENT_TYPES)).toEqual(sorted(C.ContentType.options));
|
||||
for (const t of C.ContentType.options) expect(M.toContractContentType(M.toDomainContentType(t))).toBe(t);
|
||||
expect(sorted([...M.DOMAIN_SEGMENTS, "unknown"])).toEqual(sorted(C.Segment.options));
|
||||
for (const s of C.Segment.options) expect(M.toContractSegment(M.toDomainSegment(s))).toBe(s);
|
||||
});
|
||||
|
||||
it("整理操作、文件类型、计划动作都有映射", () => {
|
||||
expect(sorted(M.DOMAIN_ORGANIZE_OPERATIONS)).toEqual(sorted(["copy", "move", "rename"]));
|
||||
for (const op of M.DOMAIN_ORGANIZE_OPERATIONS) expect(M.toOrganizeOperation(M.toOrganizationAction(op))).toBe(op);
|
||||
for (const a of C.OrganizationAction.options) {
|
||||
const op = M.toOrganizeOperation(a);
|
||||
if (op) expect(M.toOrganizationAction(op)).toBe(a);
|
||||
}
|
||||
expect(sorted(M.DOMAIN_FILE_KINDS)).toEqual(sorted(["video", "subtitle", "audio", "nfo", "other"]));
|
||||
for (const k of M.DOMAIN_FILE_KINDS) {
|
||||
expect(C.FileRole.options).toContain(M.toFileRole({ role: "sidecar", kind: k }));
|
||||
}
|
||||
expect(sorted(M.DOMAIN_PLANNED_ACTIONS)).toEqual(sorted(["transfer", "replace", "skip", "hold"]));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user