

























@@ -0,0 +1,197 @@
1+import type { APIApplicationCommand } from "discord-api-types/v10";
2+import { describe, expect, test } from "vitest";
3+import { __testing } from "./command-deploy.js";
4+5+const { commandsEqual } = __testing;
6+7+/**
8+ * Regression tests for Discord slash-command reconcile/deploy equality.
9+ *
10+ * These protect against a class of bugs where Discord's server-side storage
11+ * normalization causes our desired descriptor to re-compare unequal to the
12+ * command Discord returns, which leads to a spurious `PATCH` on every
13+ * gateway startup and, under the per-application rate limit, a cascade of
14+ * `429` responses that silently drop some commands until the next restart.
15+ */
16+describe("commandsEqual", () => {
17+// Shape of what Discord returns on `GET /applications/{appId}/commands`.
18+// Fields like `version`, `dm_permission`, `nsfw`, `application_id` are
19+// always present on the server side but absent from our locally-serialized
20+// desired descriptors — they must therefore be ignored by the comparator.
21+function currentFromDiscord(
22+overrides: Partial<APIApplicationCommand> = {},
23+): APIApplicationCommand {
24+return {
25+id: "cmd-1",
26+application_id: "app",
27+type: 1,
28+name: "ping",
29+description: "ping the bot",
30+version: "v1",
31+default_member_permissions: null,
32+dm_permission: true,
33+nsfw: false,
34+ ...overrides,
35+} as APIApplicationCommand;
36+}
37+38+// Shape of what a `BaseCommand.serialize()` produces locally.
39+function desiredFromLocal(overrides: Record<string, unknown> = {}): Record<string, unknown> {
40+return {
41+name: "ping",
42+description: "ping the bot",
43+type: 1,
44+default_member_permissions: null,
45+ ...overrides,
46+};
47+}
48+49+test("ignores Discord server-side default fields (dm_permission, nsfw, version, id, application_id)", () => {
50+expect(commandsEqual(currentFromDiscord(), desiredFromLocal())).toBe(true);
51+});
52+53+test("ignores Discord null localization maps when local command omits them", () => {
54+const current = currentFromDiscord({
55+name_localizations: null,
56+description_localizations: null,
57+options: [
58+{
59+type: 3,
60+name: "name",
61+name_localizations: null,
62+description: "Skill name",
63+description_localizations: null,
64+} as any,
65+],
66+});
67+const desired = desiredFromLocal({
68+options: [{ name: "name", description: "Skill name", type: 3 }],
69+});
70+expect(commandsEqual(current, desired)).toBe(true);
71+});
72+73+test("treats `required: false` on an option as equivalent to field absent", () => {
74+const current = currentFromDiscord({
75+name: "skill",
76+description: "Run a skill.",
77+options: [{ type: 3, name: "name", description: "Skill name" } as any],
78+});
79+const desired = desiredFromLocal({
80+name: "skill",
81+description: "Run a skill.",
82+options: [{ name: "name", description: "Skill name", type: 3, required: false }],
83+});
84+expect(commandsEqual(current, desired)).toBe(true);
85+});
86+87+test("keeps `required: true` meaningful", () => {
88+const current = currentFromDiscord({
89+name: "skill",
90+description: "Run a skill.",
91+options: [{ type: 3, name: "name", description: "Skill name" } as any],
92+});
93+const desired = desiredFromLocal({
94+name: "skill",
95+description: "Run a skill.",
96+options: [{ name: "name", description: "Skill name", type: 3, required: true }],
97+});
98+expect(commandsEqual(current, desired)).toBe(false);
99+});
100+101+test("treats CJK descriptions with `\\n` separators as equal to Discord's collapsed form", () => {
102+// Discord server collapses whitespace between CJK characters when storing
103+// command descriptions, so our local desired `\n`-separated description
104+// round-trips back without the newline.
105+const current = currentFromDiscord({
106+description:
107+"将任意文本转化为杂志质感 HTML 信息卡片,并自动截图保存为图片。支持直接输入 URL。",
108+});
109+const desired = desiredFromLocal({
110+description:
111+"将任意文本转化为杂志质感 HTML 信息卡片,并自动截图保存为图片。\n支持直接输入 URL。",
112+});
113+expect(commandsEqual(current, desired)).toBe(true);
114+});
115+116+test("treats mixed CJK/ASCII descriptions with consecutive whitespace as equal to collapsed form", () => {
117+const current = currentFromDiscord({
118+description: "联网操作策略框架。访问需登录站点时触发。",
119+});
120+const desired = desiredFromLocal({
121+description: "联网操作策略框架。\n\n访问需登录站点时触发。",
122+});
123+expect(commandsEqual(current, desired)).toBe(true);
124+});
125+126+test("treats localized descriptions with CJK whitespace as equal to Discord's collapsed form", () => {
127+const current = currentFromDiscord({
128+description_localizations: {
129+"zh-CN": "第一行说明。第二行说明。",
130+},
131+});
132+const desired = desiredFromLocal({
133+description_localizations: {
134+"zh-CN": "第一行说明。\n第二行说明。",
135+},
136+});
137+expect(commandsEqual(current, desired)).toBe(true);
138+});
139+140+test("treats option localized descriptions with CJK whitespace as equal to Discord's collapsed form", () => {
141+const current = currentFromDiscord({
142+name: "skill",
143+description: "Run a skill.",
144+options: [
145+{
146+type: 3,
147+name: "name",
148+description: "Skill name",
149+description_localizations: { "zh-CN": "技能名称。直接输入。" },
150+} as any,
151+],
152+});
153+const desired = desiredFromLocal({
154+name: "skill",
155+description: "Run a skill.",
156+options: [
157+{
158+name: "name",
159+description: "Skill name",
160+description_localizations: { "zh-CN": "技能名称。\n直接输入。" },
161+type: 3,
162+},
163+],
164+});
165+expect(commandsEqual(current, desired)).toBe(true);
166+});
167+168+test("keeps localized substantive description differences meaningful", () => {
169+const current = currentFromDiscord({
170+description_localizations: {
171+"zh-CN": "旧说明",
172+},
173+});
174+const desired = desiredFromLocal({
175+description_localizations: {
176+"zh-CN": "新说明",
177+},
178+});
179+expect(commandsEqual(current, desired)).toBe(false);
180+});
181+182+test("keeps substantive description differences meaningful", () => {
183+const current = currentFromDiscord({ description: "old text" });
184+const desired = desiredFromLocal({ description: "new text" });
185+expect(commandsEqual(current, desired)).toBe(false);
186+});
187+188+test("treats ASCII `\\n` as whitespace and collapses it to space for comparison", () => {
189+// For pure ASCII descriptions, `\n` collapses to a single space so
190+// "ping the bot" == "ping\nthe bot". The contract is: whitespace
191+// differences (ASCII or CJK-boundary) are never substantive after
192+// Discord's server normalization.
193+const current = currentFromDiscord({ description: "ping the bot" });
194+const desired = desiredFromLocal({ description: "ping\nthe bot" });
195+expect(commandsEqual(current, desired)).toBe(true);
196+});
197+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。