Skip to content

Commit 4778b2c

Browse files
committed
[red] require instructional visuals in every tutorial
Signed-off-by: Viwat Vchirawongkwin <viwat.v@chula.ac.th>
1 parent 32de782 commit 4778b2c

2 files changed

Lines changed: 163 additions & 1 deletion

File tree

‎tools/web/src/test/learn-content-contract.test.tsx‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ const lessonPages: Array<{
144144
},
145145
];
146146

147+
const physicalTabletLessonPages = lessonPages.slice(0, 6);
148+
147149
function mainText(Page: ComponentType): string {
148150
render(<Page />);
149151
return screen.getByRole("main").textContent ?? "";
@@ -305,6 +307,55 @@ describe("shared tutorial structure", () => {
305307
);
306308
});
307309

310+
describe("instructional visual contract", () => {
311+
it.each(lessonPages)(
312+
"$href includes a purposeful, accessible visual",
313+
({ Page }) => {
314+
render(<Page />);
315+
const main = screen.getByRole("main");
316+
const figures = [...main.querySelectorAll("figure")];
317+
318+
expect(figures.length).toBeGreaterThan(0);
319+
for (const figure of figures) {
320+
expect(figure.querySelector("figcaption")).not.toBeNull();
321+
const images = [...figure.querySelectorAll("img")];
322+
for (const image of images) {
323+
expect(image).toHaveAttribute("alt");
324+
expect(image.getAttribute("alt")?.trim().length).toBeGreaterThan(20);
325+
expect(Number(image.getAttribute("width"))).toBeGreaterThan(0);
326+
expect(Number(image.getAttribute("height"))).toBeGreaterThan(0);
327+
expect(image).toHaveAttribute("loading", "lazy");
328+
}
329+
}
330+
},
331+
);
332+
333+
it.each(physicalTabletLessonPages)(
334+
"$href includes truthful physical Lenovo app evidence",
335+
({ Page }) => {
336+
render(<Page />);
337+
const main = screen.getByRole("main");
338+
const physicalFigures = [...main.querySelectorAll("figure")].filter(
339+
(figure) =>
340+
figure
341+
.querySelector("figcaption")
342+
?.textContent?.includes("Actual Android tablet · Lenovo TB-J616X"),
343+
);
344+
345+
expect(physicalFigures.length).toBeGreaterThan(0);
346+
for (const figure of physicalFigures) {
347+
const image = within(figure).getByRole("img");
348+
expect(image.getAttribute("src")).toMatch(
349+
/^\/learn\/app\/[a-z0-9.-]+\.png$/,
350+
);
351+
expect(image.getAttribute("alt")).toMatch(/PyBLE 0\.2\.0 beta/i);
352+
expect(figure).not.toHaveTextContent(/integration test|debug|golden/i);
353+
}
354+
expect(main).not.toHaveTextContent(/PyBLE Integration Test/i);
355+
},
356+
);
357+
});
358+
308359
describe("tutorial truth and safety content", () => {
309360
it("teaches exact provisioning boundaries before BLE use", () => {
310361
const text = mainText(SetupTutorial);
@@ -325,6 +376,8 @@ describe("tutorial truth and safety content", () => {
325376

326377
expect(text).toContain('print("Hello from PyBLE!")');
327378
expect(text).toMatch(/hardware-free/i);
379+
expect(text).toMatch(/In Files.*New file.*hello\.py.*open.*Editor/is);
380+
expect(text).not.toMatch(/New file in the Editor.*name.*hello\.py/is);
328381
expect(text).toMatch(/hello\.py.*Save.*Run.*Console/is);
329382
expect(text).toMatch(/Stop.*soft reboot.*reconnect/is);
330383
});
@@ -352,7 +405,8 @@ describe("tutorial truth and safety content", () => {
352405
expect(text).toMatch(/editable.*repository URL/is);
353406
expect(text).toMatch(/branch.*only branches/is);
354407
expect(text).toMatch(/main.*branch discovery/is);
355-
expect(text).toMatch(/advanced.*tag or commit.*full.*commit/is);
408+
expect(text).toMatch(/Use a tag or commit.*40-character commit/is);
409+
expect(text).not.toMatch(/Open Advanced/i);
356410
expect(text).toMatch(/public.*no.*account.*token/is);
357411
expect(text).toMatch(/rate limit/is);
358412
expect(text).toMatch(/lowercase \.py.*direct.*one folder/is);
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// SPDX-License-Identifier: MIT
2+
// Part of PyBLE (https://pyble.dev) — see /LICENSE.
3+
4+
import { createHash } from "node:crypto";
5+
import { readFile } from "node:fs/promises";
6+
import { basename, join } from "node:path";
7+
8+
import { describe, expect, it } from "vitest";
9+
10+
import { tutorialAppCaptures } from "@/lib/tutorial-app-captures";
11+
12+
const expectedCaptureIds = [
13+
"setupScanResults",
14+
"setupConnectedIdentity",
15+
"firstProgramEditorConsole",
16+
"filesMultiDeleteReview",
17+
"githubBranchChooser",
18+
"githubPrewriteReview",
19+
"examplesImportComplete",
20+
"blocksHelloWorkspace",
21+
] as const;
22+
23+
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
24+
const forbiddenMetadataChunks = new Set([
25+
"eXIf",
26+
"iTXt",
27+
"tEXt",
28+
"tIME",
29+
"zTXt",
30+
]);
31+
32+
function pngChunkTypes(bytes: Buffer): string[] {
33+
const chunkTypes: string[] = [];
34+
let offset = pngSignature.length;
35+
36+
while (offset + 12 <= bytes.length) {
37+
const dataLength = bytes.readUInt32BE(offset);
38+
const chunkEnd = offset + 12 + dataLength;
39+
expect(chunkEnd).toBeLessThanOrEqual(bytes.length);
40+
41+
const chunkType = bytes.toString("ascii", offset + 4, offset + 8);
42+
chunkTypes.push(chunkType);
43+
offset = chunkEnd;
44+
45+
if (chunkType === "IEND") {
46+
break;
47+
}
48+
}
49+
50+
return chunkTypes;
51+
}
52+
53+
describe("reviewed physical tutorial capture assets", () => {
54+
it("keeps the reviewed capture registry complete and unique", () => {
55+
expect(Object.keys(tutorialAppCaptures)).toEqual(expectedCaptureIds);
56+
expect(
57+
new Set(Object.values(tutorialAppCaptures).map(({ src }) => src)).size,
58+
).toBe(expectedCaptureIds.length);
59+
});
60+
61+
it.each(expectedCaptureIds)(
62+
"%s binds content-versioned, stripped PNG bytes to reviewed provenance",
63+
async (captureId) => {
64+
const capture = tutorialAppCaptures[captureId];
65+
const { review } = capture.provenance;
66+
67+
if (review.status !== "reviewed") {
68+
throw new Error(`${captureId} has not completed derivative review`);
69+
}
70+
71+
expect(review.sha256).toMatch(/^[0-9a-f]{64}$/);
72+
expect(basename(capture.src)).toContain(review.sha256.slice(0, 12));
73+
expect(capture.src).not.toContain("pending");
74+
expect(capture.provenance.rawCapture).toMatch(
75+
/production-release\.raw\.png$/,
76+
);
77+
expect(capture.provenance.rawCapture).not.toMatch(
78+
/debug|golden|integration/i,
79+
);
80+
expect(capture.provenance.processing).toMatch(
81+
/crop.*status.*navigation.*strip metadata.*not retouch/is,
82+
);
83+
84+
const asset = await readFile(
85+
join(process.cwd(), "public", capture.src.slice(1)),
86+
);
87+
expect(asset.subarray(0, pngSignature.length)).toEqual(pngSignature);
88+
expect(asset.readUInt32BE(16)).toBe(capture.width);
89+
expect(asset.readUInt32BE(20)).toBe(capture.height);
90+
expect({ width: capture.width, height: capture.height }).toEqual({
91+
width: 2000,
92+
height: 1092,
93+
});
94+
expect(createHash("sha256").update(asset).digest("hex")).toBe(
95+
review.sha256,
96+
);
97+
98+
const chunkTypes = pngChunkTypes(asset);
99+
expect(chunkTypes.at(0)).toBe("IHDR");
100+
expect(chunkTypes.at(-1)).toBe("IEND");
101+
expect(
102+
chunkTypes.filter((chunkType) =>
103+
forbiddenMetadataChunks.has(chunkType),
104+
),
105+
).toEqual([]);
106+
},
107+
);
108+
});

0 commit comments

Comments
 (0)