返回 CodeWhale
media-manifest.test.ts
根目录 / web / lib / media-manifest.test.ts
1 /**
2 * Media-manifest contracts for the real-session media surface.
3 *
4 * Two enforceable states:
5 * pending — intent only: no asset fields, and no files may exist under
6 * web/public/media/. The component must render the visible
7 * "recording pending release candidate" state instead of any
8 * imagery. This is what keeps the site honest until the v0.9.2
9 * dogfood recording (#4906) exists.
10 * published — complete or nothing: poster, video, per-locale captions,
11 * transcript, and GIF fallback all present. The suite checks
12 * file presence/bytes and PNG dimensions; video dimensions and
13 * duration are declared metadata whose physical verification
14 * remains in the recording checklist.
15 *
16 * The reduced-motion contract is structural: no autoplay anywhere, poster is
17 * the static default, and the GIF is offered only as a link. The test asserts
18 * the component source carries that contract.
19 */
20 import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
21 import { createHash } from "node:crypto";
22 import { describe, expect, it } from "vitest";
23 import {
24 getMediaAsset,
25 MEDIA_ASSETS,
26 MEDIA_BUDGETS,
27 MEDIA_PUBLIC_DIR,
28 REDUCED_MOTION_POLICY,
29 TERMINAL_SCREENSHOT,
30 type MediaAsset,
31 } from "./media-manifest";
32 import { ALL_LOCALES } from "./i18n/config";
33 import { getDocsShell } from "./i18n/dictionaries";
34
35 // Captions are required for locales that ship a complete pack, not for every
36 // routed locale: `locales` also includes `partial` locales, which route with an
37 // English-fallback pack. Promising a caption track per routed locale would be a
38 // claim the recording cannot meet, and the manifest exists to keep those claims
39 // honest.
40 const shippedLocales = ALL_LOCALES.filter((l) => l.status === "shipped").map((l) => l.code);
41
42 const webRoot = new URL("../", import.meta.url);
43 const repoRoot = new URL("../../", import.meta.url);
44
45 function publicFileBytes(src: string): number {
46 return statSync(new URL(`public/${src}`, webRoot)).size;
47 }
48
49 function pngDimensions(src: string): [number, number] {
50 const image = readFileSync(new URL(`public/${src}`, webRoot));
51 expect(image.subarray(1, 4).toString("ascii")).toBe("PNG");
52 return [image.readUInt32BE(16), image.readUInt32BE(20)];
53 }
54
55 describe("media manifest integrity", () => {
56 it("keeps the terminal capture tied to its measured file and captured build", () => {
57 const src = TERMINAL_SCREENSHOT.src.slice(1);
58 const bytes = readFileSync(new URL(`public/${src}`, webRoot));
59 expect(createHash("sha256").update(bytes).digest("hex")).toBe(TERMINAL_SCREENSHOT.sha256);
60 expect(pngDimensions(src)).toEqual([TERMINAL_SCREENSHOT.width, TERMINAL_SCREENSHOT.height]);
61 expect(TERMINAL_SCREENSHOT.sourceCommit).toMatch(/^[0-9a-f]{40}$/);
62 });
63
64 it("has unique asset ids and complete localized copy", () => {
65 const ids = MEDIA_ASSETS.map((a) => a.id);
66 expect(new Set(ids).size).toBe(ids.length);
67 for (const asset of MEDIA_ASSETS) {
68 for (const pair of [asset.title, asset.description, asset.pendingLabel]) {
69 expect(pair.en.trim().length, `${asset.id} en`).toBeGreaterThan(0);
70 expect(pair.zh.trim().length, `${asset.id} zh`).toBeGreaterThan(0);
71 }
72 expect(asset.id).toMatch(/^[a-z0-9-]+$/);
73 }
74 });
75
76 it("keeps pending entries asset-free on disk and in the manifest", () => {
77 const mediaDir = new URL(`public/${MEDIA_PUBLIC_DIR}/`, webRoot);
78 const onDisk = existsSync(mediaDir) ? readdirSync(mediaDir) : [];
79
80 for (const asset of MEDIA_ASSETS.filter((a) => a.status === "pending")) {
81 expect(asset.poster, asset.id).toBeUndefined();
82 expect(asset.video, asset.id).toBeUndefined();
83 expect(asset.captions, asset.id).toBeUndefined();
84 expect(asset.gifFallback, asset.id).toBeUndefined();
85 expect(asset.transcript, asset.id).toBeUndefined();
86 // No staged or fabricated file may exist for a pending asset.
87 for (const file of onDisk) {
88 expect(file.startsWith(asset.id), `${asset.id}: unexpected file ${file}`).toBe(false);
89 }
90 // The pending state must say why, in both locales.
91 expect(asset.pendingLabel.en).toContain("pending release candidate");
92 expect(asset.pendingLabel.zh.length).toBeGreaterThan(0);
93 }
94 });
95
96 it("requires published entries to be complete and satisfy enforceable budgets", () => {
97 for (const asset of MEDIA_ASSETS.filter((a) => a.status === "published")) {
98 assertPublishedAsset(asset);
99 }
100 });
101
102 it("keeps budgets aligned with the canonical screenshot contract", () => {
103 // The site screenshot contract pins 1280×720 under 500 KB; session-media
104 // posters follow the same budget so the two surfaces stay consistent.
105 expect(MEDIA_BUDGETS.poster).toEqual({ width: 1280, height: 720, maxBytes: 500_000 });
106 expect(MEDIA_BUDGETS.video.width).toBe(MEDIA_BUDGETS.poster.width);
107 expect(MEDIA_BUDGETS.video.height).toBe(MEDIA_BUDGETS.poster.height);
108 expect(MEDIA_BUDGETS.video.maxDurationSeconds).toBeLessThanOrEqual(120);
109 expect(MEDIA_BUDGETS.gifFallback.maxBytes).toBeGreaterThan(0);
110 for (const locale of shippedLocales) {
111 expect(MEDIA_BUDGETS.captionLocales).toContain(locale);
112 }
113 });
114 });
115
116 function assertPublishedAsset(asset: MediaAsset): void {
117 const { poster, video, captions, gifFallback, transcript } = asset;
118 expect(poster, `${asset.id}.poster`).toBeTruthy();
119 expect(video, `${asset.id}.video`).toBeTruthy();
120 expect(captions, `${asset.id}.captions`).toBeTruthy();
121 expect(gifFallback, `${asset.id}.gifFallback`).toBeTruthy();
122 expect(transcript, `${asset.id}.transcript`).toBeTruthy();
123
124 expect(pngDimensions(poster!.src)).toEqual([poster!.width, poster!.height]);
125 expect([poster!.width, poster!.height]).toEqual([
126 MEDIA_BUDGETS.poster.width,
127 MEDIA_BUDGETS.poster.height,
128 ]);
129 expect(publicFileBytes(poster!.src)).toBeLessThanOrEqual(MEDIA_BUDGETS.poster.maxBytes);
130
131 expect([video!.width, video!.height]).toEqual([
132 MEDIA_BUDGETS.video.width,
133 MEDIA_BUDGETS.video.height,
134 ]);
135 expect(video!.durationSeconds).toBeLessThanOrEqual(MEDIA_BUDGETS.video.maxDurationSeconds);
136 expect(publicFileBytes(video!.src)).toBeLessThanOrEqual(MEDIA_BUDGETS.video.maxBytes);
137
138 expect(publicFileBytes(gifFallback!.src)).toBeLessThanOrEqual(
139 MEDIA_BUDGETS.gifFallback.maxBytes,
140 );
141
142 const captionLangs = captions!.map((t) => t.srclang);
143 for (const locale of MEDIA_BUDGETS.captionLocales) {
144 expect(captionLangs, `${asset.id} missing ${locale} captions`).toContain(locale);
145 }
146 for (const track of captions!) {
147 expect(track.src.endsWith(".vtt"), track.src).toBe(true);
148 expect(publicFileBytes(track.src), track.src).toBeGreaterThan(0);
149 expect(track.label.trim().length).toBeGreaterThan(0);
150 }
151
152 expect(existsSync(new URL(transcript!, repoRoot)), `${asset.id}.transcript`).toBe(true);
153 }
154
155 describe("session media component contract", () => {
156 const component = readFileSync(
157 new URL("../components/session-media.tsx", import.meta.url),
158 "utf8",
159 );
160
161 it("renders the visible pending state instead of any imagery", () => {
162 expect(component).toContain('asset.status === "pending"');
163 expect(component).toContain("session-media-pending");
164 expect(component).toContain("asset.pendingLabel");
165 // Pending copy states plainly that nothing is recorded yet, both locales.
166 expect(component).toContain("t.mediaPendingNote");
167 expect(getDocsShell("en").mediaPendingNote).toContain("There is no recording yet");
168 expect(getDocsShell("zh").mediaPendingNote).toContain("还没有录像");
169 });
170
171 it("carries the structural reduced-motion contract: no autoplay, ever", () => {
172 expect(component).toContain("REDUCED_MOTION_POLICY");
173 expect(REDUCED_MOTION_POLICY).toBe("static-poster-no-autoplay");
174 // No autoplay as a JSX/DOM attribute (the policy string and prose may
175 // mention the word; the attribute must never appear).
176 expect(component).not.toMatch(/autoPlay\s*[={]/);
177 expect(component).not.toMatch(/\sautoplay\s*[="]/);
178 expect(component).toContain('preload="none"');
179 expect(component).toContain("controls");
180 expect(component).toContain("poster={`/${poster.src}`}");
181 });
182
183 it("wires captions, transcript, and the GIF fallback for published assets", () => {
184 expect(component).toContain('kind="captions"');
185 expect(component).toContain("srcLang={track.srclang}");
186 expect(component).toContain("asset.gifFallback");
187 expect(component).toContain("asset.transcript");
188 });
189
190 it("exposes machine-checkable state hooks", () => {
191 expect(component).toContain("data-media-id={asset.id}");
192 expect(component).toContain("data-media-status={asset.status}");
193 expect(component).toContain("data-reduced-motion-policy={REDUCED_MOTION_POLICY}");
194 });
195
196 it("documents the recording plan location from the pending state", () => {
197 expect(component).toContain("docs/releases/v0.9.2-media-plan.md");
198 expect(existsSync(new URL("docs/releases/v0.9.2-media-plan.md", repoRoot))).toBe(true);
199 });
200
201 it("keeps the first-fleet-session entry addressable for the guide page", () => {
202 const asset = getMediaAsset("first-fleet-session");
203 expect(asset).toBeTruthy();
204 expect(["pending", "published"]).toContain(asset!.status);
205 const guide = readFileSync(
206 new URL("../app/[locale]/docs/guide/page.tsx", import.meta.url),
207 "utf8",
208 );
209 expect(guide).toContain('getMediaAsset("first-fleet-session")');
210 expect(guide).toContain("<SessionMedia");
211 });
212 });
213
213 lines TYPESCRIPT