paperlint 6.1.0 → 6.2.1

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 (96) hide show
  1. package/dist/adapters/mp4box/index.d.ts +4 -0
  2. package/dist/adapters/mp4box/index.d.ts.map +1 -0
  3. package/dist/adapters/mp4box/index.js +46 -0
  4. package/dist/adapters/mp4box/index.js.map +1 -0
  5. package/dist/adapters/node/index.d.ts +1 -0
  6. package/dist/adapters/node/index.d.ts.map +1 -1
  7. package/dist/adapters/node/index.js +1 -0
  8. package/dist/adapters/node/index.js.map +1 -1
  9. package/dist/adapters/node/list-dir.io.d.ts +3 -0
  10. package/dist/adapters/node/list-dir.io.d.ts.map +1 -0
  11. package/dist/adapters/node/list-dir.io.js +15 -0
  12. package/dist/adapters/node/list-dir.io.js.map +1 -0
  13. package/dist/adapters/png/index.d.ts +11 -0
  14. package/dist/adapters/png/index.d.ts.map +1 -0
  15. package/dist/adapters/png/index.js +21 -0
  16. package/dist/adapters/png/index.js.map +1 -0
  17. package/dist/adapters/srt/index.d.ts +4 -0
  18. package/dist/adapters/srt/index.d.ts.map +1 -0
  19. package/dist/adapters/srt/index.js +12 -0
  20. package/dist/adapters/srt/index.js.map +1 -0
  21. package/dist/cli.d.ts.map +1 -1
  22. package/dist/cli.js +25 -4
  23. package/dist/cli.js.map +1 -1
  24. package/dist/domain/talk.d.ts +121 -0
  25. package/dist/domain/talk.d.ts.map +1 -0
  26. package/dist/domain/talk.js +121 -0
  27. package/dist/domain/talk.js.map +1 -0
  28. package/dist/lib/paper-config.d.ts +2 -0
  29. package/dist/lib/paper-config.d.ts.map +1 -1
  30. package/dist/lib/paper-config.js +3 -0
  31. package/dist/lib/paper-config.js.map +1 -1
  32. package/dist/paper-settings.d.ts +3 -0
  33. package/dist/paper-settings.d.ts.map +1 -1
  34. package/dist/paper-settings.js +15 -9
  35. package/dist/paper-settings.js.map +1 -1
  36. package/dist/ports/talk-media.d.ts +18 -0
  37. package/dist/ports/talk-media.d.ts.map +1 -0
  38. package/dist/ports/talk-media.js +2 -0
  39. package/dist/ports/talk-media.js.map +1 -0
  40. package/dist/presets.d.ts +3 -0
  41. package/dist/presets.d.ts.map +1 -1
  42. package/dist/presets.js +6 -10
  43. package/dist/presets.js.map +1 -1
  44. package/dist/talk-rules.d.ts +65 -0
  45. package/dist/talk-rules.d.ts.map +1 -0
  46. package/dist/talk-rules.js +451 -0
  47. package/dist/talk-rules.js.map +1 -0
  48. package/dist/tex-requirements.d.ts +3 -0
  49. package/dist/tex-requirements.d.ts.map +1 -1
  50. package/dist/tex-requirements.js +2 -0
  51. package/dist/tex-requirements.js.map +1 -1
  52. package/docs/configuration.md +2 -1
  53. package/docs/rules/talk/captions-cover.md +47 -0
  54. package/docs/rules/talk/duration-floor.md +38 -0
  55. package/docs/rules/talk/duration.md +43 -0
  56. package/docs/rules/talk/one-slide-size.md +38 -0
  57. package/docs/rules/talk/profile.md +64 -0
  58. package/docs/rules/talk/required-files.md +39 -0
  59. package/docs/rules/talk/undeclared.md +35 -0
  60. package/docs/rules/talk/video-format.md +43 -0
  61. package/docs/rules.md +11 -0
  62. package/docs/talk.md +94 -0
  63. package/fixtures/talk/audio-only.mp4 +0 -0
  64. package/fixtures/talk/one-slide.png +0 -0
  65. package/fixtures/talk/short-audio.mp4 +0 -0
  66. package/fixtures/talk/talk.mov +0 -0
  67. package/fixtures/talk/talk.mp4 +0 -0
  68. package/fixtures/talk/talk.srt +7 -0
  69. package/lib/paper-config.harness.mjs +5 -2
  70. package/lib/paper-config.ts +4 -0
  71. package/package.json +3 -1
  72. package/presets/agenticdev.jsonc +30 -0
  73. package/presets/venue-profile.schema.json +132 -0
  74. package/skills/submit-paper/references/venues/agenticdev.md +36 -9
  75. package/src/adapters/mp4box/index.test.ts +63 -0
  76. package/src/adapters/mp4box/index.ts +50 -0
  77. package/src/adapters/node/index.ts +1 -0
  78. package/src/adapters/node/list-dir.io.ts +15 -0
  79. package/src/adapters/node/list-dir.test.ts +30 -0
  80. package/src/adapters/png/index.test.ts +43 -0
  81. package/src/adapters/png/index.ts +29 -0
  82. package/src/adapters/srt/index.test.ts +24 -0
  83. package/src/adapters/srt/index.ts +13 -0
  84. package/src/cli.test.ts +12 -0
  85. package/src/cli.ts +31 -3
  86. package/src/domain/talk.test.ts +125 -0
  87. package/src/domain/talk.ts +274 -0
  88. package/src/new-paper.test.ts +1 -0
  89. package/src/paper-settings.test.ts +18 -1
  90. package/src/paper-settings.ts +21 -11
  91. package/src/ports/talk-media.ts +19 -0
  92. package/src/presets.ts +9 -10
  93. package/src/rule-cases.test.ts +110 -3
  94. package/src/talk-rules.test.ts +459 -0
  95. package/src/talk-rules.ts +672 -0
  96. package/src/tex-requirements.ts +9 -0
@@ -145,10 +145,65 @@ const switched = (second: string): string =>
145
145
  const aidc = (body: string, cls = IEEE) =>
146
146
  paper(tex(`\\section{Introduction}\n${body}\n${STATEMENT}`, cls), AIDC);
147
147
 
148
+ // ── a paper that declares a talk: a preset of its own over agenticdev, and synthetic media ───────
149
+
150
+ const media = (name: string): Uint8Array =>
151
+ readFileSync(join(ROOT, "fixtures", "talk", name));
152
+ /** The talk block of the tree's own preset: every limit the 3 s, 1280×720 fixture meets. */
153
+ const TALK_VENUE = {
154
+ modes: ["remote-video"],
155
+ artifacts: {
156
+ "remote-video": { required: ["video", "one-slide", "captions"] },
157
+ },
158
+ kinds: { short: { slot_s: 10, talk_s_min: 2, talk_s_max: 4 } },
159
+ video: { container: "mp4", min_height_px: 720, max_bytes: 100_000 },
160
+ one_slide: { width_px: 16, height_px: 9 },
161
+ };
162
+ /** A paper declaring a talk, its preset's `talk` patched, its files replaced or (null) removed. */
163
+ const talkPaper = (
164
+ venue: Record<string, unknown> = {},
165
+ talk: Record<string, unknown> | null = { mode: "remote-video" },
166
+ files: Record<string, string | Uint8Array | null> = {},
167
+ ): Record<string, string | Uint8Array> => {
168
+ const all: Record<string, string | Uint8Array | null> = {
169
+ [`${P}/talk/talk.mp4`]: media("talk.mp4"),
170
+ [`${P}/talk/one-slide.png`]: media("one-slide.png"),
171
+ [`${P}/talk/talk.srt`]: media("talk.srt"),
172
+ ...files,
173
+ };
174
+ return {
175
+ ...paper(ACM_TEX, {
176
+ [`${P}/talk-venue.jsonc`]: JSON.stringify({
177
+ extends: "paperlint:agenticdev",
178
+ talk: { ...TALK_VENUE, ...venue },
179
+ }),
180
+ [`${P}/paperlint.json`]: JSON.stringify({
181
+ extends: "./talk-venue.jsonc",
182
+ kind: "short",
183
+ ...(talk === null ? {} : { talk }),
184
+ }),
185
+ }),
186
+ ...Object.fromEntries(
187
+ Object.entries(all).filter(
188
+ (e): e is [string, string | Uint8Array] => e[1] !== null,
189
+ ),
190
+ ),
191
+ };
192
+ };
193
+ const onTalk = (
194
+ tree: Record<string, string | Uint8Array>,
195
+ severity: 1 | 2 = 2,
196
+ ) => ({
197
+ tree,
198
+ file: TEX_FILE,
199
+ severity,
200
+ line: 1,
201
+ });
202
+
148
203
  // ── the case table ─────────────────────────────────────────────────────────────────────────────
149
204
 
150
205
  interface Reports {
151
- readonly tree: Record<string, string>;
206
+ readonly tree: Record<string, string | Uint8Array>;
152
207
  /** The file the finding is on, relative to the project. */
153
208
  readonly file: string;
154
209
  readonly severity: 1 | 2;
@@ -157,7 +212,7 @@ interface Reports {
157
212
  interface RuleCases {
158
213
  readonly reports: Reports;
159
214
  /** A tree on which the rule says nothing, on any file. */
160
- readonly silent: Record<string, string>;
215
+ readonly silent: Record<string, string | Uint8Array>;
161
216
  }
162
217
 
163
218
  const TEX_FILE = `${P}/paper.tex`;
@@ -463,6 +518,56 @@ const CASES: Readonly<Record<string, RuleCases>> = {
463
518
  { ...AGENTICDEV, rules: { "pdf/last-page-balance": "error" } },
464
519
  ),
465
520
  },
521
+ "talk/profile": {
522
+ reports: onTalk(talkPaper({}, { mode: "in-person" })),
523
+ silent: talkPaper(),
524
+ },
525
+ "talk/required-files": {
526
+ reports: onTalk(
527
+ talkPaper({}, undefined, { [`${P}/talk/one-slide.png`]: null }),
528
+ ),
529
+ silent: talkPaper(),
530
+ },
531
+ "talk/undeclared": {
532
+ reports: onTalk(talkPaper({}, null), 1),
533
+ silent: talkPaper(),
534
+ },
535
+ "talk/duration": {
536
+ reports: onTalk(
537
+ talkPaper({ kinds: { short: { slot_s: 10, talk_s_max: 2 } } }),
538
+ ),
539
+ silent: talkPaper(),
540
+ },
541
+ "talk/duration-floor": {
542
+ reports: onTalk(
543
+ talkPaper({
544
+ kinds: { short: { slot_s: 10, talk_s_min: 5, talk_s_max: 9 } },
545
+ }),
546
+ 1,
547
+ ),
548
+ silent: talkPaper(),
549
+ },
550
+ "talk/video-format": {
551
+ reports: onTalk(
552
+ talkPaper({ video: { container: "mp4", min_height_px: 1080 } }),
553
+ ),
554
+ silent: talkPaper(),
555
+ },
556
+ "talk/one-slide-size": {
557
+ reports: onTalk(
558
+ talkPaper({ one_slide: { width_px: 1920, height_px: 1080 } }),
559
+ ),
560
+ silent: talkPaper(),
561
+ },
562
+ "talk/captions-cover": {
563
+ reports: onTalk(
564
+ talkPaper({}, undefined, {
565
+ [`${P}/talk/talk.srt`]: "1\n00:00:02,000 --> 00:00:02,900\nLate.\n",
566
+ }),
567
+ 1,
568
+ ),
569
+ silent: talkPaper(),
570
+ },
466
571
  };
467
572
 
468
573
  /**
@@ -491,7 +596,9 @@ type Message = z.infer<typeof LintResults>[number]["messages"][number] & {
491
596
  };
492
597
 
493
598
  /** `paperlint lint --json` over the tree, in-process through the CLI: every message, by file. */
494
- async function lintTree(tree: Record<string, string>): Promise<Message[]> {
599
+ async function lintTree(
600
+ tree: Record<string, string | Uint8Array>,
601
+ ): Promise<Message[]> {
495
602
  const dir = writeTree(useTempDir("paperlint-rule-cases-"), {
496
603
  "package.json": '{"name":"consumer","private":true}',
497
604
  ...tree,
@@ -0,0 +1,459 @@
1
+ /**
2
+ * The talk rules: the pure judges on typed values, and each rule run on a paper tree on disk with the
3
+ * real media readers. `src/rule-cases.test.ts` runs every rule through the real CLI and ESLint; this
4
+ * file covers the branches one case per rule cannot.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { describe, expect, it } from "vitest";
10
+ import { nodeFiles, nodeListDir } from "./adapters/node/index.ts";
11
+ import { probeVideo } from "./adapters/mp4box/index.ts";
12
+ import { imageSize } from "./adapters/png/index.ts";
13
+ import { captions } from "./adapters/srt/index.ts";
14
+ import { presetsDir } from "./package-dirs.ts";
15
+ import { venueTalkOf, type VideoProbe } from "./domain/talk.ts";
16
+ import {
17
+ clock,
18
+ DEFAULT_COVER,
19
+ judgeCover,
20
+ judgeDuration,
21
+ judgeVideoFormat,
22
+ requiredArtifacts,
23
+ talkRules,
24
+ type TalkRuleName,
25
+ } from "./talk-rules.ts";
26
+ import { useTempDir, writeTree } from "../test/support.ts";
27
+
28
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
29
+ const media = (name: string): Uint8Array =>
30
+ readFileSync(join(ROOT, "fixtures", "talk", name));
31
+
32
+ const DEPS = {
33
+ files: nodeFiles,
34
+ venuesDir: presetsDir(),
35
+ media: { probeVideo, imageSize, captions },
36
+ listDir: nodeListDir,
37
+ };
38
+
39
+ const VENUE = {
40
+ modes: ["remote-video"],
41
+ artifacts: { "remote-video": { required: ["video"] } },
42
+ kinds: { short: { slot_s: 10, talk_s_min: 2, talk_s_max: 4 } },
43
+ video: { container: "mp4", min_height_px: 720, captions: "required" },
44
+ };
45
+
46
+ interface Report {
47
+ readonly messageId: string;
48
+ readonly data: Readonly<Record<string, string | number>> | undefined;
49
+ }
50
+
51
+ /** Rule `name` on a paper tree: the preset's talk block, the paper's settings, its files. */
52
+ function run(
53
+ name: TalkRuleName,
54
+ o: {
55
+ readonly venue?: Record<string, unknown> | null;
56
+ readonly settings?: Record<string, unknown>;
57
+ readonly files?: Record<string, string | Uint8Array>;
58
+ readonly options?: readonly unknown[];
59
+ readonly cwd?: string;
60
+ } = {},
61
+ ): readonly Report[] {
62
+ const dir = useTempDir("paperlint-talk-rules-");
63
+ const venue =
64
+ o.venue === null
65
+ ? { extends: "paperlint:acm-sigconf" }
66
+ : { extends: "paperlint:agenticdev", talk: { ...VENUE, ...o.venue } };
67
+ writeTree(dir, {
68
+ "package.json": "{}",
69
+ "p/paper.tex": "x",
70
+ "p/venue.jsonc": JSON.stringify(venue),
71
+ "p/paperlint.json": JSON.stringify(
72
+ o.settings ?? {
73
+ extends: "./venue.jsonc",
74
+ kind: "short",
75
+ talk: { mode: "remote-video" },
76
+ },
77
+ ),
78
+ "p/talk/talk.mp4": media("talk.mp4"),
79
+ "p/talk/talk.srt": media("talk.srt"),
80
+ ...o.files,
81
+ });
82
+ const reports: Report[] = [];
83
+ const visitor = talkRules(DEPS)[name].create({
84
+ filename: join(dir, "p", "paper.tex"),
85
+ ...(o.cwd === undefined ? {} : { cwd: o.cwd }),
86
+ sourceCode: { text: "x" },
87
+ options: o.options ?? [],
88
+ report: (d) => reports.push({ messageId: d.messageId, data: d.data }),
89
+ });
90
+ visitor.root?.();
91
+ return reports;
92
+ }
93
+ const ids = (r: readonly Report[]) => r.map((x) => x.messageId);
94
+ const declared = (talk: Record<string, unknown>, extra = {}) => ({
95
+ extends: "./venue.jsonc",
96
+ kind: "short",
97
+ talk,
98
+ ...extra,
99
+ });
100
+
101
+ describe("clock", () => {
102
+ it("prints minutes, seconds to a tenth, and the exact seconds", () => {
103
+ expect(clock(320.84)).toBe("5:20.8 (320.84 s)");
104
+ expect(clock(359.97)).toBe("6:00.0 (359.97 s)");
105
+ expect(clock(3)).toBe("0:03.0 (3 s)");
106
+ });
107
+ });
108
+
109
+ describe("talk/profile", () => {
110
+ it("is silent for the conforming tree, and on a file that is not paper.tex", () => {
111
+ expect(run("profile")).toEqual([]);
112
+ const v = talkRules(DEPS).profile.create({
113
+ filename: "/x/notes.tex",
114
+ sourceCode: { text: "" },
115
+ options: [],
116
+ report: () => undefined,
117
+ });
118
+ expect(v).toEqual({});
119
+ });
120
+
121
+ it("names a missing preset, a preset without a talk block, a missing kind and a kind with no slot", () => {
122
+ expect(
123
+ ids(run("profile", { settings: { talk: { mode: "remote-video" } } })),
124
+ ).toEqual(["noPreset"]);
125
+ expect(ids(run("profile", { venue: null }))).toEqual(["noTalkBlock"]);
126
+ expect(
127
+ ids(
128
+ run("profile", {
129
+ settings: {
130
+ extends: "./venue.jsonc",
131
+ talk: { mode: "remote-video" },
132
+ },
133
+ }),
134
+ ),
135
+ ).toEqual(["kindMissing"]);
136
+ expect(
137
+ ids(
138
+ run("profile", {
139
+ settings: declared({ mode: "remote-video" }, { kind: "full" }),
140
+ }),
141
+ ),
142
+ ).toEqual(["kindNoSlot"]);
143
+ });
144
+
145
+ it("accepts any mode when the venue lists none", () => {
146
+ expect(
147
+ run("profile", {
148
+ venue: { modes: undefined },
149
+ settings: declared({ mode: "in-person" }),
150
+ }),
151
+ ).toEqual([]);
152
+ });
153
+
154
+ it("is silent when the settings do not parse — pdf/profile says why", () => {
155
+ expect(run("profile", { settings: { talk: { mode: "zoom" } } })).toEqual(
156
+ [],
157
+ );
158
+ });
159
+ });
160
+
161
+ describe("talk/required-files and talk/undeclared", () => {
162
+ it("adds the captions when the venue requires them with the video", () => {
163
+ const vt = venueTalkOf({
164
+ artifacts: { "remote-video": { required: ["video"] } },
165
+ video: { captions: "required" },
166
+ });
167
+ expect(requiredArtifacts(vt, "remote-video")).toEqual([
168
+ "video",
169
+ "captions",
170
+ ]);
171
+ expect(requiredArtifacts(vt, "in-person")).toEqual([]);
172
+ });
173
+
174
+ it("names each missing file, under the name the paper gives it", () => {
175
+ const r = run("required-files", {
176
+ settings: declared({
177
+ mode: "remote-video",
178
+ files: { video: "mine.mp4" },
179
+ }),
180
+ });
181
+ expect(ids(r)).toEqual(["missing"]);
182
+ expect(r[0]?.data?.["artifact"]).toBe("video");
183
+ expect(String(r[0]?.data?.["file"])).toMatch(/p\/talk\/mine\.mp4$/);
184
+ expect(run("required-files", { venue: null })).toEqual([]);
185
+ });
186
+
187
+ it("warns about a video in talk/ when no talk is declared, and only then", () => {
188
+ const r = run("undeclared", {
189
+ settings: { extends: "./venue.jsonc", kind: "short" },
190
+ cwd: "/",
191
+ });
192
+ expect(ids(r)).toEqual(["undeclared"]);
193
+ // Shown relative to cwd "/": no leading slash. The temp root differs by OS
194
+ // (/tmp on Linux, /private/var/folders on macOS), so only the tail is fixed.
195
+ const shown = String(r[0]?.data?.["file"]);
196
+ expect(shown).not.toMatch(/^\//);
197
+ expect(shown).toMatch(/p\/talk\/talk\.mp4$/);
198
+ expect(run("undeclared")).toEqual([]);
199
+ expect(
200
+ run("duration", {
201
+ settings: { extends: "./venue.jsonc", kind: "short" },
202
+ }),
203
+ ).toEqual([]);
204
+ });
205
+ });
206
+
207
+ const probe = (p: Partial<VideoProbe> = {}): VideoProbe => ({
208
+ durationS: 3,
209
+ audioS: 3,
210
+ heightPx: 720,
211
+ widthPx: 1280,
212
+ brands: ["isom"],
213
+ ...p,
214
+ });
215
+ const slot = {
216
+ venue: "v",
217
+ kind: "short",
218
+ slot: { slotS: 10, talkSMin: 2, talkSMax: 4, qaS: null },
219
+ };
220
+ const at = { file: "t.mp4", tolS: 1 };
221
+
222
+ describe("talk/duration and talk/duration-floor", () => {
223
+ it("reports a video over the slot, and an audio track more than the tolerance away", () => {
224
+ expect(
225
+ ids(judgeDuration(probe({ durationS: 5, audioS: 5 }), slot, at)),
226
+ ).toEqual(["tooLong"]);
227
+ expect(ids(judgeDuration(probe({ audioS: 1.5 }), slot, at))).toEqual([
228
+ "trackMismatch",
229
+ ]);
230
+ expect(
231
+ judgeDuration(probe({ audioS: null, durationS: 9 }), null, at),
232
+ ).toEqual([]);
233
+ });
234
+
235
+ it("reads the tolerance option, and the real short-audio fixture", () => {
236
+ expect(
237
+ ids(
238
+ run("duration", {
239
+ files: { "p/talk/talk.mp4": media("short-audio.mp4") },
240
+ }),
241
+ ),
242
+ ).toEqual(["trackMismatch"]);
243
+ expect(
244
+ run("duration", {
245
+ files: { "p/talk/talk.mp4": media("short-audio.mp4") },
246
+ options: [{ trackToleranceS: 3 }],
247
+ }),
248
+ ).toEqual([]);
249
+ expect(
250
+ run("duration", { files: { "p/talk/talk.mp4": "not a video" } }),
251
+ ).toEqual([]);
252
+ });
253
+
254
+ it("warns under the floor; silent without a floor or a video", () => {
255
+ expect(
256
+ ids(
257
+ run("duration-floor", {
258
+ venue: {
259
+ kinds: { short: { slot_s: 9, talk_s_min: 5, talk_s_max: 9 } },
260
+ },
261
+ }),
262
+ ),
263
+ ).toEqual(["tooShort"]);
264
+ expect(
265
+ run("duration-floor", {
266
+ venue: { kinds: { short: { slot_s: 9, talk_s_max: 9 } } },
267
+ }),
268
+ ).toEqual([]);
269
+ expect(
270
+ run("duration-floor", {
271
+ settings: declared({
272
+ mode: "remote-video",
273
+ files: { video: "none.mp4" },
274
+ }),
275
+ }),
276
+ ).toEqual([]);
277
+ expect(
278
+ run("duration-floor", {
279
+ settings: declared({ mode: "remote-video" }, { kind: null }),
280
+ }),
281
+ ).toEqual([]);
282
+ });
283
+ });
284
+
285
+ describe("talk/video-format", () => {
286
+ const limits = {
287
+ container: "mp4" as const,
288
+ minHeightPx: 720,
289
+ maxBytes: 100,
290
+ captions: null,
291
+ };
292
+ it("reports QuickTime, a missing video track, a short picture and a large file", () => {
293
+ expect(
294
+ ids(judgeVideoFormat(probe({ brands: ["qt "] }), 1, limits)),
295
+ ).toEqual(["container"]);
296
+ expect(ids(judgeVideoFormat(probe({ heightPx: null }), 1, limits))).toEqual(
297
+ ["noVideoTrack"],
298
+ );
299
+ expect(ids(judgeVideoFormat(probe({ heightPx: 480 }), 1, limits))).toEqual([
300
+ "height",
301
+ ]);
302
+ expect(ids(judgeVideoFormat(probe(), 101, limits))).toEqual(["bytes"]);
303
+ expect(
304
+ judgeVideoFormat(probe({ heightPx: 1 }), 1e9, {
305
+ container: null,
306
+ minHeightPx: null,
307
+ maxBytes: null,
308
+ captions: null,
309
+ }),
310
+ ).toEqual([]);
311
+ });
312
+
313
+ it("reports a file that is not ISO media; silent without a video or a talk block", () => {
314
+ expect(
315
+ ids(run("video-format", { files: { "p/talk/talk.mp4": "not a video" } })),
316
+ ).toEqual(["unreadable"]);
317
+ expect(
318
+ ids(
319
+ run("video-format", {
320
+ files: { "p/talk/talk.mp4": media("talk.mov") },
321
+ }),
322
+ ),
323
+ ).toEqual(["container"]);
324
+ expect(
325
+ run("video-format", {
326
+ settings: declared({
327
+ mode: "remote-video",
328
+ files: { video: "none.mp4" },
329
+ }),
330
+ }),
331
+ ).toEqual([]);
332
+ expect(run("video-format", { venue: null })).toEqual([]);
333
+ });
334
+ });
335
+
336
+ describe("talk/one-slide-size", () => {
337
+ const png = { "p/talk/one-slide.png": media("one-slide.png") };
338
+ it("holds the image to the paper's size over the venue's", () => {
339
+ expect(
340
+ run("one-slide-size", {
341
+ files: png,
342
+ venue: { one_slide: { width_px: 16, height_px: 9 } },
343
+ }),
344
+ ).toEqual([]);
345
+ expect(
346
+ ids(
347
+ run("one-slide-size", {
348
+ files: png,
349
+ venue: { one_slide: { width_px: 1920, height_px: 1080 } },
350
+ }),
351
+ ),
352
+ ).toEqual(["size"]);
353
+ expect(
354
+ run("one-slide-size", {
355
+ files: png,
356
+ settings: declared({
357
+ mode: "remote-video",
358
+ one_slide: { width_px: 16, height_px: 9 },
359
+ }),
360
+ venue: { one_slide: { width_px: 1, height_px: 1 } },
361
+ }),
362
+ ).toEqual([]);
363
+ });
364
+
365
+ it("reports a file that is not a PNG; silent with no size declared or no file", () => {
366
+ expect(
367
+ ids(
368
+ run("one-slide-size", {
369
+ files: { "p/talk/one-slide.png": "x" },
370
+ venue: { one_slide: { width_px: 1, height_px: 1 } },
371
+ }),
372
+ ),
373
+ ).toEqual(["notPng"]);
374
+ expect(run("one-slide-size", { files: png })).toEqual([]);
375
+ expect(
376
+ run("one-slide-size", {
377
+ venue: { one_slide: { width_px: 1, height_px: 1 } },
378
+ }),
379
+ ).toEqual([]);
380
+ });
381
+ });
382
+
383
+ describe("talk/captions-cover", () => {
384
+ const cue = (startS: number, endS: number) => ({ startS, endS });
385
+ it("reports no cues, a late start, an early end and a long gap", () => {
386
+ expect(ids(judgeCover([], 10, DEFAULT_COVER))).toEqual(["noCues"]);
387
+ expect(ids(judgeCover([cue(2, 10)], 10, DEFAULT_COVER))).toEqual([
388
+ "lateStart",
389
+ ]);
390
+ expect(ids(judgeCover([cue(0, 7)], 10, DEFAULT_COVER))).toEqual([
391
+ "earlyEnd",
392
+ ]);
393
+ expect(ids(judgeCover([cue(7, 10), cue(0, 1)], 10, DEFAULT_COVER))).toEqual(
394
+ ["gap"],
395
+ );
396
+ expect(judgeCover([cue(0, 4), cue(5, 9)], 10, DEFAULT_COVER)).toEqual([]);
397
+ });
398
+
399
+ it("reads its thresholds from the options; silent without captions or a video", () => {
400
+ const late = { "p/talk/talk.srt": "1\n00:00:02,000 --> 00:00:02,900\nx\n" };
401
+ expect(ids(run("captions-cover", { files: late }))).toEqual(["lateStart"]);
402
+ expect(
403
+ run("captions-cover", { files: late, options: [{ maxStartS: 3 }] }),
404
+ ).toEqual([]);
405
+ expect(
406
+ run("captions-cover", {
407
+ settings: declared({
408
+ mode: "remote-video",
409
+ files: { captions: "none.srt" },
410
+ }),
411
+ }),
412
+ ).toEqual([]);
413
+ expect(
414
+ run("captions-cover", { files: { "p/talk/talk.mp4": "x" } }),
415
+ ).toEqual([]);
416
+ });
417
+ });
418
+
419
+ describe("without a preset or a talk block, only talk/profile speaks", () => {
420
+ const noPreset = { talk: { mode: "remote-video" }, kind: "short" };
421
+ it.each([
422
+ "required-files",
423
+ "duration",
424
+ "duration-floor",
425
+ "video-format",
426
+ ] as const)("%s is silent", (name) => {
427
+ expect(run(name, { settings: noPreset })).toEqual([]);
428
+ expect(run(name, { venue: null })).toEqual([]);
429
+ });
430
+
431
+ it("a duration mismatch is still reported without a slot", () => {
432
+ const short = { "p/talk/talk.mp4": media("short-audio.mp4") };
433
+ expect(ids(run("duration", { settings: noPreset, files: short }))).toEqual([
434
+ "trackMismatch",
435
+ ]);
436
+ });
437
+
438
+ it("a kind the venue sets no slot for: no floor, and profile lists the kinds or (none)", () => {
439
+ const full = declared({ mode: "remote-video" }, { kind: "full" });
440
+ expect(run("duration-floor", { settings: full })).toEqual([]);
441
+ const r = run("profile", { venue: { kinds: {} } });
442
+ expect(r[0]?.data?.["known"]).toBe("(none)");
443
+ });
444
+ });
445
+
446
+ describe("largest gap, in time order", () => {
447
+ const cue = (startS: number, endS: number) => ({ startS, endS });
448
+ it("keeps the longest of several gaps, and an overlap leaves none", () => {
449
+ const r = judgeCover(
450
+ [cue(0, 1), cue(7, 8), cue(8.5, 9.5)],
451
+ 10,
452
+ DEFAULT_COVER,
453
+ );
454
+ expect(r.map((f) => [f.messageId, f.data["s"]])).toEqual([["gap", "6.0"]]);
455
+ expect(
456
+ judgeCover([cue(0, 9), cue(1, 2), cue(8.5, 9.9)], 10, DEFAULT_COVER),
457
+ ).toEqual([]);
458
+ });
459
+ });