1import { expect, it } from "@effect/vitest";
2
3import { scriptOf } from "./fake-fx.ts";
4import { caseResult, checkFails, feedMotion, frameStats, type SampleRow } from "./grade.ts";
5
6const steady = (count: number, gap: number) =>
7 Array.from({ length: count }, (_, index) => index * gap);
8
9it("counts the frames a long gap swallowed, against the device's own refresh rate", () => {
10 const at120 = steady(120, 1000 / 120);
11 expect(frameStats(at120, "ui")).toMatchObject({ uiRefreshHz: 120, uiDroppedPct: 0 });
12
13 // One 50 ms stall at 60 Hz hides two frames.
14 const stalled = [...steady(60, 1000 / 60), 59 * (1000 / 60) + 50];
15 expect(frameStats(stalled, "js")).toMatchObject({ jsRefreshHz: 60, jsDroppedPct: 3.2 });
16
17 // JS rAF fires on a timer, so a late frame makes the next gap short; the UI rate sets the budget.
18 const jittered = steady(300, 1000 / 60).map((time, index) =>
19 index % 15 === 7 ? time + 6 : time,
20 );
21 expect(frameStats(jittered, "js")).toMatchObject({ jsRefreshHz: 120 });
22 expect(frameStats(jittered, "js", 1000 / 60)).toMatchObject({ jsRefreshHz: 60, jsDroppedPct: 0 });
23
24 // A steady 20 FPS is two dropped frames in every three, not a slow display.
25 expect(frameStats(steady(60, 50), "ui")).toMatchObject({ uiRefreshHz: 60, uiDroppedPct: 66.7 });
26});
27
28it("reads row motion on screen, so an offset change that keeps content still is not a jump", () => {
29 const rows = (entries: Record<string, number>) => ({ threadId: "t", ready: true, rows: entries });
30 // Scrolling up: rows move down the screen; the list then grows above and shifts its offset.
31 const smooth = [rows({ a: 100, b: 400 }), rows({ a: 180, b: 480 }), rows({ a: 180, b: 480 })];
32 expect(feedMotion(smooth, 1)).toMatchObject({ backwardPx: 0, teleports: 0, largestStepPx: 80 });
33
34 const jerk = [rows({ a: 100 }), rows({ a: 180 }), rows({ a: 60 })];
35 expect(feedMotion(jerk, 1)).toMatchObject({ backwardPx: 120 });
36
37 const teleport = [rows({ a: 100 }), rows({ a: 1300 })];
38 expect(feedMotion(teleport, 1)).toMatchObject({ teleports: 1, largestStepPx: 1200 });
39
40 // A jump past every visible row leaves no shared row; the offset change is the move.
41 const offset = (scroll: number, entries: Record<string, number>) => ({
42 ...rows(entries),
43 scroll,
44 content: 9000,
45 });
46 expect(feedMotion([offset(6000, { a: 100 }), offset(1000, { z: 100 })], 1)).toMatchObject({
47 teleports: 1,
48 largestStepPx: 5000,
49 });
50});
51
52it("fails a case when half or more of its graded samples fail", () => {
53 const row = (pass: boolean | null, value: number): SampleRow => ({
54 id: "c",
55 device: "phone",
56 sample: 1,
57 pass,
58 failures: [],
59 metrics: { backwardPx: value },
60 });
61 expect(
62 caseResult("c", "phone", "backwardPx", [row(true, 1), row(true, 3), row(false, 90)]),
63 ).toMatchObject({
64 pass: true,
65 key: 3,
66 });
67 expect(caseResult("c", "phone", "backwardPx", [row(true, 1), row(false, 90)]).pass).toBe(false);
68 expect(caseResult("c", "phone", "backwardPx", [row(null, 0)]).pass).toBe(null);
69
70 // Errors that outnumber graded samples leave the case incomplete, not passing.
71 const errored: SampleRow = { ...row(null, 0), error: "Inspector timed out" };
72 expect(caseResult("c", "phone", "backwardPx", [row(true, 1), errored, errored])).toMatchObject({
73 pass: null,
74 errors: 2,
75 });
76});
77
78it("checks exact values and bounds", () => {
79 expect(checkFails({ metric: "rowStatus", equals: "input" }, { rowStatus: "working" })).toBe(
80 "rowStatus=working, want input",
81 );
82 expect(checkFails({ metric: "backwardPx", max: 24 }, { backwardPx: 24 })).toBe(null);
83 expect(checkFails({ metric: "contentGrewPx", min: 500 }, {})).toBe("contentGrewPx missing");
84});
85
86it("reads the fake provider's script from the prompt marker", () => {
87 expect(scriptOf("Write it. [[eval:stream:4000:25]]")).toMatchObject({
88 kind: "stream",
89 words: 4000,
90 chunkMs: 25,
91 });
92 expect(scriptOf("Run. [[eval:hold:bg-1]]")).toMatchObject({ kind: "hold", key: "bg-1" });
93 expect(scriptOf("plain text")).toMatchObject({ kind: "reply" });
94});