paperlint 7.0.0 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/.github/workflows/ci.yml +3 -0
  2. package/.github/workflows/deadlines.yml +100 -0
  3. package/README.md +24 -11
  4. package/dist/adapters/hotcrp/deadlines.d.ts +8 -0
  5. package/dist/adapters/hotcrp/deadlines.d.ts.map +1 -0
  6. package/dist/adapters/hotcrp/deadlines.io.d.ts +6 -0
  7. package/dist/adapters/hotcrp/deadlines.io.d.ts.map +1 -0
  8. package/dist/adapters/hotcrp/deadlines.io.js +26 -0
  9. package/dist/adapters/hotcrp/deadlines.io.js.map +1 -0
  10. package/dist/adapters/hotcrp/deadlines.js +134 -0
  11. package/dist/adapters/hotcrp/deadlines.js.map +1 -0
  12. package/dist/adapters/hotcrp/index.d.ts +2 -0
  13. package/dist/adapters/hotcrp/index.d.ts.map +1 -1
  14. package/dist/adapters/hotcrp/index.js +2 -0
  15. package/dist/adapters/hotcrp/index.js.map +1 -1
  16. package/dist/cli.d.ts.map +1 -1
  17. package/dist/cli.js +42 -7
  18. package/dist/cli.js.map +1 -1
  19. package/dist/cycle-rules.d.ts +24 -0
  20. package/dist/cycle-rules.d.ts.map +1 -0
  21. package/dist/cycle-rules.js +212 -0
  22. package/dist/cycle-rules.js.map +1 -0
  23. package/dist/domain/body-pages.d.ts +29 -0
  24. package/dist/domain/body-pages.d.ts.map +1 -1
  25. package/dist/domain/body-pages.js +25 -0
  26. package/dist/domain/body-pages.js.map +1 -1
  27. package/dist/domain/cycle.d.ts +148 -0
  28. package/dist/domain/cycle.d.ts.map +1 -0
  29. package/dist/domain/cycle.js +281 -0
  30. package/dist/domain/cycle.js.map +1 -0
  31. package/dist/domain/deadline.d.ts +128 -0
  32. package/dist/domain/deadline.d.ts.map +1 -0
  33. package/dist/domain/deadline.js +281 -0
  34. package/dist/domain/deadline.js.map +1 -0
  35. package/dist/eslint-rules/cycle-stage.d.ts +39 -0
  36. package/dist/eslint-rules/cycle-stage.d.ts.map +1 -0
  37. package/dist/eslint-rules/cycle-stage.js +119 -0
  38. package/dist/eslint-rules/cycle-stage.js.map +1 -0
  39. package/dist/lib/paper-config.d.ts.map +1 -1
  40. package/dist/lib/paper-config.js +2 -0
  41. package/dist/lib/paper-config.js.map +1 -1
  42. package/dist/paper-includes.d.ts +3 -0
  43. package/dist/paper-includes.d.ts.map +1 -1
  44. package/dist/paper-includes.js +17 -2
  45. package/dist/paper-includes.js.map +1 -1
  46. package/dist/paper-settings.d.ts +7 -0
  47. package/dist/paper-settings.d.ts.map +1 -1
  48. package/dist/paper-settings.js +61 -8
  49. package/dist/paper-settings.js.map +1 -1
  50. package/dist/ports/venue-deadlines.d.ts +14 -0
  51. package/dist/ports/venue-deadlines.d.ts.map +1 -0
  52. package/dist/ports/venue-deadlines.js +2 -0
  53. package/dist/ports/venue-deadlines.js.map +1 -0
  54. package/dist/presets.d.ts +22 -3
  55. package/dist/presets.d.ts.map +1 -1
  56. package/dist/presets.js +26 -17
  57. package/dist/presets.js.map +1 -1
  58. package/dist/submission.d.ts +16 -0
  59. package/dist/submission.d.ts.map +1 -1
  60. package/dist/submission.js +59 -16
  61. package/dist/submission.js.map +1 -1
  62. package/dist/tex-requirements.d.ts +12 -0
  63. package/dist/tex-requirements.d.ts.map +1 -1
  64. package/dist/tex-requirements.js +26 -3
  65. package/dist/tex-requirements.js.map +1 -1
  66. package/dist/tex-venue-rules.d.ts +24 -2
  67. package/dist/tex-venue-rules.d.ts.map +1 -1
  68. package/dist/tex-venue-rules.js +98 -10
  69. package/dist/tex-venue-rules.js.map +1 -1
  70. package/dist/venue-rules.d.ts +12 -1
  71. package/dist/venue-rules.d.ts.map +1 -1
  72. package/dist/venue-rules.js +81 -17
  73. package/dist/venue-rules.js.map +1 -1
  74. package/docs/configuration.md +140 -12
  75. package/docs/e2e.md +14 -12
  76. package/docs/rules/cycle/evidence.md +87 -0
  77. package/docs/rules/cycle/port-done.md +75 -0
  78. package/docs/rules/cycle/record.md +152 -0
  79. package/docs/rules/cycle/stage.md +85 -0
  80. package/docs/rules/format/page-limit.md +18 -1
  81. package/docs/rules/tex/template.md +25 -5
  82. package/docs/rules/tex/venue-leftover.md +13 -0
  83. package/docs/rules.md +68 -55
  84. package/docs/submission.md +10 -1
  85. package/eslint-rules/cycle-stage.test.mjs +131 -0
  86. package/eslint-rules/cycle-stage.ts +141 -0
  87. package/eslint.config.mjs +41 -1
  88. package/fixtures/build-e2e/msr/PIPELINE-STATUS.md +3 -0
  89. package/fixtures/build-e2e/msr/expect.json +18 -0
  90. package/fixtures/build-e2e/msr/paper.tex +49 -0
  91. package/fixtures/build-e2e/msr/paperlint.json +1 -0
  92. package/fixtures/build-e2e/msr-appendix-after/PIPELINE-STATUS.md +3 -0
  93. package/fixtures/build-e2e/msr-appendix-after/expect.json +20 -0
  94. package/fixtures/build-e2e/msr-appendix-after/paper.tex +48 -0
  95. package/fixtures/build-e2e/msr-appendix-after/paperlint.json +1 -0
  96. package/lib/paper-config.ts +3 -0
  97. package/package.json +1 -1
  98. package/presets/aidc.jsonc +24 -3
  99. package/presets/aidc.md +6 -3
  100. package/presets/ieee-conference.md +1 -1
  101. package/presets/msr.jsonc +95 -0
  102. package/presets/msr.md +98 -0
  103. package/presets/venue-profile.schema.json +64 -0
  104. package/scripts/check.ts +4 -0
  105. package/scripts/refresh-deadlines.test.ts +431 -0
  106. package/scripts/refresh-deadlines.ts +387 -0
  107. package/skills/camera-ready/SKILL.md +8 -1
  108. package/skills/camera-ready/SKILL.md.spec.ts +7 -0
  109. package/skills/submission-portal/SKILL.md +2 -2
  110. package/skills/submission-portal/SKILL.md.spec.ts +1 -1
  111. package/skills/submit-paper/SKILL.md +9 -1
  112. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  113. package/src/adapters/hotcrp/cassette/README.md +22 -0
  114. package/src/adapters/hotcrp/cassette/deadlines-aidc26.html +17 -0
  115. package/src/adapters/hotcrp/cassette/deadlines-fse2027.html +24 -0
  116. package/src/adapters/hotcrp/cassette/deadlines-icse2027.html +25 -0
  117. package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +25 -0
  118. package/src/adapters/hotcrp/cassette/deadlines-nosuch.html +18 -0
  119. package/src/adapters/hotcrp/cassette/deadlines-sosp26.html +26 -0
  120. package/src/adapters/hotcrp/deadlines.io.test.ts +86 -0
  121. package/src/adapters/hotcrp/deadlines.io.ts +27 -0
  122. package/src/adapters/hotcrp/deadlines.test.ts +170 -0
  123. package/src/adapters/hotcrp/deadlines.ts +161 -0
  124. package/src/adapters/hotcrp/index.ts +2 -0
  125. package/src/cli.test.ts +9 -0
  126. package/src/cli.ts +50 -6
  127. package/src/cycle-rules.test.ts +278 -0
  128. package/src/cycle-rules.ts +273 -0
  129. package/src/domain/body-pages.test.ts +144 -0
  130. package/src/domain/body-pages.ts +61 -1
  131. package/src/domain/cycle.test.ts +456 -0
  132. package/src/domain/cycle.ts +432 -0
  133. package/src/domain/deadline.test.ts +386 -0
  134. package/src/domain/deadline.ts +472 -0
  135. package/src/new-paper.test.ts +28 -0
  136. package/src/paper-includes.test.ts +26 -0
  137. package/src/paper-includes.ts +20 -2
  138. package/src/paper-settings-commands.test.ts +1 -1
  139. package/src/paper-settings.test.ts +167 -0
  140. package/src/paper-settings.ts +85 -11
  141. package/src/ports/venue-deadlines.ts +14 -0
  142. package/src/preset-card-rule.test.ts +1 -0
  143. package/src/presets.test.ts +171 -1
  144. package/src/presets.ts +65 -15
  145. package/src/rule-cases.test.ts +144 -0
  146. package/src/submission.test.ts +147 -4
  147. package/src/submission.ts +100 -18
  148. package/src/tex-requirements.ts +52 -4
  149. package/src/tex-venue-rules.test.ts +237 -0
  150. package/src/tex-venue-rules.ts +130 -15
  151. package/src/venue-rules.test.ts +255 -2
  152. package/src/venue-rules.ts +115 -22
@@ -0,0 +1,386 @@
1
+ /**
2
+ * A VENUE'S DEADLINE as a paper records it — what each page said (`observed`), and a human's
3
+ * explicit `override` — and the instant that binds: the override, else the EARLIEST reading. Pure.
4
+ */
5
+ import { describe, expect, it } from "vitest";
6
+ import {
7
+ deadlineOrderProblems,
8
+ deadlinesInForce,
9
+ instantOf,
10
+ instantOfUnix,
11
+ parseDeadlines,
12
+ type Deadline,
13
+ type Reading,
14
+ } from "./deadline.ts";
15
+
16
+ const PORTAL = {
17
+ at: "2026-10-20T04:00:00Z",
18
+ source: "portal",
19
+ url: "https://beta2027.example.org/deadlines",
20
+ read: "2026-10-05",
21
+ };
22
+ const CALL = {
23
+ at: "2026-10-23 AoE",
24
+ source: "call",
25
+ url: "https://beta2027.example.org/call",
26
+ read: "2026-10-05",
27
+ };
28
+ const OVERRIDE = {
29
+ at: "2026-10-27T04:00:00Z",
30
+ reason: "the chairs extended the deadline by mail to every author",
31
+ evidence: "mail/extension.eml",
32
+ };
33
+
34
+ const parsed = (input: unknown): readonly Deadline[] => {
35
+ const r = parseDeadlines(input, "cycles[0]");
36
+ if (!r.ok) throw new Error(r.error);
37
+ return r.value;
38
+ };
39
+
40
+ const refusal = (input: unknown): string => {
41
+ const r = parseDeadlines(input, "cycles[0]");
42
+ if (r.ok) throw new Error(`accepted: ${JSON.stringify(r.value)}`);
43
+ return r.error;
44
+ };
45
+
46
+ describe("parseDeadlines — the record", () => {
47
+ it("absent is no deadlines", () => {
48
+ expect(parsed(undefined)).toEqual([]);
49
+ });
50
+
51
+ it("holds the portal's AND the call's reading of one deadline, the call's AoE day converted", () => {
52
+ expect(parsed([{ what: "submission", observed: [PORTAL, CALL] }])).toEqual([
53
+ {
54
+ what: "submission",
55
+ observed: [
56
+ PORTAL,
57
+ { ...CALL, at: "2026-10-24T11:59:59Z" }, // the end of 2026-10-23 at UTC−12
58
+ ],
59
+ override: null,
60
+ },
61
+ ]);
62
+ });
63
+
64
+ it("holds an override with its reason and evidence, its instant normalised to UTC", () => {
65
+ expect(
66
+ parsed([
67
+ {
68
+ what: "submission",
69
+ observed: [PORTAL],
70
+ override: { ...OVERRIDE, at: "2026-10-27T09:00:00+05:00" },
71
+ },
72
+ ]),
73
+ ).toEqual([
74
+ {
75
+ what: "submission",
76
+ observed: [PORTAL],
77
+ override: OVERRIDE,
78
+ },
79
+ ]);
80
+ });
81
+
82
+ it("an override alone is a record: a preset venue's readings come from the preset", () => {
83
+ expect(parsed([{ what: "submission", override: OVERRIDE }])).toEqual([
84
+ { what: "submission", observed: [], override: OVERRIDE },
85
+ ]);
86
+ });
87
+ });
88
+
89
+ describe("parseDeadlines refuses an unknown key ANYWHERE in a deadline, naming it", () => {
90
+ it.each([
91
+ [
92
+ "beside what and observed (an override typed as a plain value under another name)",
93
+ [{ what: "submission", observed: [PORTAL], notes: "x" }],
94
+ /^cycles\[0\]\.deadlines\[0\]: unknown key "notes" — a deadline's keys: what, observed, override$/,
95
+ ],
96
+ [
97
+ "in a reading",
98
+ [{ what: "submission", observed: [{ ...PORTAL, checked: true }] }],
99
+ /^cycles\[0\]\.deadlines\[0\]\.observed\[0\]: unknown key "checked" — a reading's keys: at, source, url, read$/,
100
+ ],
101
+ [
102
+ "in an override",
103
+ [{ what: "submission", override: { ...OVERRIDE, by: "me" } }],
104
+ /^cycles\[0\]\.deadlines\[0\]\.override: unknown key "by" — an override's keys: at, reason, evidence$/,
105
+ ],
106
+ ])("%s", (_what, input, re) => {
107
+ expect(refusal(input)).toMatch(re);
108
+ });
109
+ });
110
+
111
+ describe("parseDeadlines refuses what cannot be in force, naming the place and the cure", () => {
112
+ it.each([
113
+ [
114
+ "an override written as a bare date: it would be silently ignored",
115
+ [{ what: "submission", observed: [PORTAL], override: "2026-10-01 AoE" }],
116
+ /deadlines\[0\]: "override" must be \{ "at", "reason", "evidence" \}/,
117
+ ],
118
+ [
119
+ "an override without a reason",
120
+ [{ what: "submission", override: { ...OVERRIDE, reason: "" } }],
121
+ /deadlines\[0\]\.override: "reason" must say why this instant binds/,
122
+ ],
123
+ [
124
+ "an override without evidence",
125
+ [{ what: "submission", override: { ...OVERRIDE, evidence: undefined } }],
126
+ /deadlines\[0\]\.override: "evidence" must be a file in the paper folder/,
127
+ ],
128
+ [
129
+ "an override whose instant has no zone",
130
+ [{ what: "submission", override: { ...OVERRIDE, at: "2026-10-27" } }],
131
+ /deadlines\[0\]\.override: "at" must be an instant with its zone \(2026-10-20T04:00:00Z\) or a call's "YYYY-MM-DD AoE", got "2026-10-27"/,
132
+ ],
133
+ [
134
+ "a deadline that records nothing",
135
+ [{ what: "submission", observed: [] }],
136
+ /deadlines\[0\]: the "submission" deadline records nothing — add what a page said \("observed"\) or an "override"/,
137
+ ],
138
+ [
139
+ "one deadline in two entries",
140
+ [
141
+ { what: "submission", observed: [PORTAL] },
142
+ { what: "submission", observed: [CALL] },
143
+ ],
144
+ /^cycles\[0\]: the "submission" deadline has two entries \(deadlines\[0\] and deadlines\[1\]\) — one entry per deadline: put every reading of it in that entry's "observed" list$/,
145
+ ],
146
+ ])("%s", (_what, input, re) => {
147
+ expect(refusal(input)).toMatch(re);
148
+ });
149
+ });
150
+
151
+ describe("parseDeadlines refuses a reading that cannot be in force, naming the place and the cure", () => {
152
+ it.each([
153
+ [
154
+ "two readings from one source",
155
+ [
156
+ {
157
+ what: "submission",
158
+ observed: [PORTAL, { ...PORTAL, read: "2026-10-06" }],
159
+ },
160
+ ],
161
+ /deadlines\[0\]: two "portal" readings — keep one per source, the latest \(its "read" says when\)/,
162
+ ],
163
+ [
164
+ "a reading without the day it was read",
165
+ [{ what: "submission", observed: [{ ...PORTAL, read: "today" }] }],
166
+ /observed\[0\]: "read" must be the day the page was read, YYYY-MM-DD/,
167
+ ],
168
+ [
169
+ "a reading from a mail: that is an override, with the mail as its evidence",
170
+ [{ what: "submission", observed: [{ ...PORTAL, source: "email" }] }],
171
+ /observed\[0\]: "source" must be "portal" or "call", got "email" — a deadline moved by mail is an "override", with the mail as its "evidence"/,
172
+ ],
173
+ [
174
+ "a reading with a bare date — the message names the AoE form",
175
+ [{ what: "submission", observed: [{ ...CALL, at: "2026-10-24" }] }],
176
+ /observed\[0\]: "at" must be an instant with its zone \(2026-10-20T04:00:00Z\) or a call's "YYYY-MM-DD AoE", got "2026-10-24"/,
177
+ ],
178
+ [
179
+ "a reading whose instant is a number (Unix seconds)",
180
+ [{ what: "submission", observed: [{ ...PORTAL, at: 1792468800 }] }],
181
+ /observed\[0\]: "at" must be an instant with its zone .*, got 1792468800/,
182
+ ],
183
+ [
184
+ "a reading without the page it was read on",
185
+ [{ what: "submission", observed: [{ ...CALL, url: "" }] }],
186
+ /observed\[0\]: "url" must be where the deadline was read/,
187
+ ],
188
+ [
189
+ "the call's word «abstract» — the message says what each kind is",
190
+ [{ what: "abstract", observed: [CALL] }],
191
+ /"what" must be one of registration \(abstract registration: a call's "abstract" date\), submission \(the full paper: a call's "paper" date\), resubmission \(updates to a completed submission\), notification, camera-ready; got "abstract"/,
192
+ ],
193
+ ])("%s", (_what, input, re) => {
194
+ expect(refusal(input)).toMatch(re);
195
+ });
196
+ });
197
+
198
+ describe("parseDeadlines refuses a value of the wrong shape, naming the place", () => {
199
+ it.each([
200
+ [
201
+ "readings that are not a list",
202
+ [{ what: "submission", observed: PORTAL }],
203
+ /deadlines\[0\]: "observed" must be a list of readings \{ "at", "source", "url", "read" \}/,
204
+ ],
205
+ [
206
+ "a reading that is not an object",
207
+ [{ what: "submission", observed: ["2026-10-20T04:00:00Z"] }],
208
+ /observed\[0\]: a reading must be \{ "at", "source", "url", "read" \}/,
209
+ ],
210
+ [
211
+ "a deadline that is not an object",
212
+ ["2026-10-20T04:00:00Z"],
213
+ /deadlines\[0\]: a deadline must be \{ "what", "observed": \[ … \], "override"\? \}/,
214
+ ],
215
+ [
216
+ "deadlines that are not a list",
217
+ { what: "submission" },
218
+ /^cycles\[0\]: "deadlines" must be a list/,
219
+ ],
220
+ [
221
+ "two bad deadlines: the first is named",
222
+ ["x", "y"],
223
+ /deadlines\[0\]: a deadline must be/,
224
+ ],
225
+ [
226
+ "two bad readings: the first is named",
227
+ [{ what: "submission", observed: ["x", "y"] }],
228
+ /observed\[0\]: a reading must be/,
229
+ ],
230
+ ])("%s", (_what, input, re) => {
231
+ expect(refusal(input)).toMatch(re);
232
+ });
233
+ });
234
+
235
+ const reading = (
236
+ what: Reading["what"],
237
+ at: string,
238
+ source: Reading["source"] = "portal",
239
+ ): Reading => ({
240
+ what,
241
+ at,
242
+ source,
243
+ url: `https://example.org/${source}`,
244
+ read: "2026-10-05",
245
+ });
246
+
247
+ /** `reading("submission", "2026-10-20T04:00:00Z")` as a deadline's reading: no `what`. */
248
+ const PORTAL_READ = {
249
+ at: "2026-10-20T04:00:00Z",
250
+ source: "portal",
251
+ url: "https://example.org/portal",
252
+ read: "2026-10-05",
253
+ };
254
+
255
+ describe("deadlinesInForce — the override, else the EARLIEST reading", () => {
256
+ it("the portal and the call disagree: the earlier binds, and both readings are kept", () => {
257
+ const own = parsed([{ what: "submission", observed: [CALL] }]);
258
+ const preset = [reading("submission", "2026-10-20T04:00:00Z")];
259
+ expect(deadlinesInForce(own, preset)).toEqual([
260
+ {
261
+ what: "submission",
262
+ at: "2026-10-20T04:00:00Z",
263
+ by: { kind: "reading", reading: PORTAL_READ },
264
+ readings: [PORTAL_READ, { ...CALL, at: "2026-10-24T11:59:59Z" }],
265
+ },
266
+ ]);
267
+ });
268
+
269
+ it("an override wins over every reading, even a later one", () => {
270
+ const own = parsed([{ what: "submission", override: OVERRIDE }]);
271
+ const [d] = deadlinesInForce(own, [
272
+ reading("submission", "2026-10-20T04:00:00Z"),
273
+ ]);
274
+ expect(d?.at).toBe("2026-10-27T04:00:00Z");
275
+ expect(d?.by).toEqual({ kind: "override", override: OVERRIDE });
276
+ });
277
+
278
+ it("derived from the preset alone, in the order of the kinds; a tie keeps the first reading", () => {
279
+ const preset = [
280
+ reading("resubmission", "2026-10-23T04:00:00Z"),
281
+ reading("submission", "2026-10-20T04:00:00Z"),
282
+ reading("submission", "2026-10-20T04:00:00Z", "call"),
283
+ ];
284
+ const got = deadlinesInForce([], preset);
285
+ expect(got.map((d) => [d.what, d.at, d.by.kind])).toEqual([
286
+ ["submission", "2026-10-20T04:00:00Z", "reading"],
287
+ ["resubmission", "2026-10-23T04:00:00Z", "reading"],
288
+ ]);
289
+ expect(got[0]?.by).toEqual({ kind: "reading", reading: PORTAL_READ });
290
+ });
291
+
292
+ it("nothing recorded, nothing in force", () => {
293
+ expect(deadlinesInForce([], [])).toEqual([]);
294
+ });
295
+ });
296
+
297
+ describe("deadlineOrderProblems — within ONE source; across sources is the disagreement the record holds", () => {
298
+ it("the portal's submission before the call's abstract day is not a problem: the earlier binds", () => {
299
+ // MSR 2027, as recorded 2026-10-05: the portal closes completed submissions on Oct 20, 04:00 UTC;
300
+ // the call names Oct 20 AoE for the abstract and Oct 23 AoE for the paper.
301
+ const own = parsed([
302
+ {
303
+ what: "registration",
304
+ observed: [{ ...CALL, at: "2026-10-20 AoE" }],
305
+ },
306
+ { what: "submission", observed: [CALL] },
307
+ ]);
308
+ const preset = [
309
+ reading("submission", "2026-10-20T04:00:00Z"),
310
+ reading("resubmission", "2026-10-23T04:00:00Z"),
311
+ ];
312
+ expect(deadlineOrderProblems(own, preset)).toEqual([]);
313
+ });
314
+
315
+ it("registration ≤ submission ≤ resubmission < notification < camera-ready, per source", () => {
316
+ const own = parsed([
317
+ {
318
+ what: "registration",
319
+ observed: [{ ...CALL, at: "2026-10-25 AoE" }],
320
+ },
321
+ { what: "submission", observed: [CALL] },
322
+ ]);
323
+ const preset = [
324
+ reading("camera-ready", "2027-01-01T00:00:00Z"),
325
+ reading("notification", "2027-02-01T00:00:00Z"),
326
+ ];
327
+ expect(deadlineOrderProblems(own, preset)).toEqual([
328
+ 'the portal\'s "notification" (2027-02-01T00:00:00Z) is after its "camera-ready" (2027-01-01T00:00:00Z)',
329
+ 'the call\'s "registration" (2026-10-26T11:59:59Z) is after its "submission" (2026-10-24T11:59:59Z)',
330
+ ]);
331
+ });
332
+
333
+ it("overrides are typed by a human and are checked against each other", () => {
334
+ const own = parsed([
335
+ {
336
+ what: "registration",
337
+ override: { ...OVERRIDE, at: "2026-10-28T04:00:00Z" },
338
+ },
339
+ { what: "submission", override: OVERRIDE },
340
+ ]);
341
+ expect(deadlineOrderProblems(own, [])).toEqual([
342
+ 'the override of "registration" (2026-10-28T04:00:00Z) is after the override of "submission" (2026-10-27T04:00:00Z)',
343
+ ]);
344
+ });
345
+ });
346
+
347
+ describe("instantOf", () => {
348
+ it("Unix seconds, as a portal gives them, are an instant in UTC", () => {
349
+ expect(instantOfUnix(1792468800)).toBe("2026-10-20T04:00:00Z");
350
+ });
351
+
352
+ it("refuses a day that is no day, as AoE and as a zoned instant", () => {
353
+ expect(instantOf("2026-13-45 AoE").ok).toBe(false);
354
+ expect(instantOf("2026-13-45T00:00:00Z").ok).toBe(false);
355
+ });
356
+
357
+ it("accepts an ISO instant with Z or an offset, normalised to Z", () => {
358
+ expect(instantOf("2026-10-20T04:00:00Z")).toEqual({
359
+ ok: true,
360
+ value: "2026-10-20T04:00:00Z",
361
+ });
362
+ expect(instantOf("2026-10-20T09:00:00+05:00")).toEqual({
363
+ ok: true,
364
+ value: "2026-10-20T04:00:00Z",
365
+ });
366
+ });
367
+
368
+ it("turns a call's `YYYY-MM-DD AoE` into the instant it means: the end of that day at UTC-12", () => {
369
+ // «October 2, 2026 (AoE)» ends at 2026-10-03T11:59:59Z — the day after, in UTC.
370
+ expect(instantOf("2026-10-02 AoE")).toEqual({
371
+ ok: true,
372
+ value: "2026-10-03T11:59:59Z",
373
+ });
374
+ expect(instantOf("2026-12-31 AoE")).toEqual({
375
+ ok: true,
376
+ value: "2027-01-01T11:59:59Z",
377
+ });
378
+ });
379
+
380
+ it("refuses a bare date, a bare local time and nonsense: an instant without a zone is the deadline people get wrong", () => {
381
+ for (const bad of ["2026-10-20", "2026-10-20T04:00:00", "soon", ""]) {
382
+ const r = instantOf(bad);
383
+ expect(r.ok, bad).toBe(false);
384
+ }
385
+ });
386
+ });