Skip to content

Commit 2588916

Browse files
committed
Pin idle landing snow through the mount timer
Paint-function tests stayed green while snow was unreachable. These assert the real mount timer, its teardown, and the reduced-motion hook.
1 parent 60ead03 commit 2588916

2 files changed

Lines changed: 106 additions & 1 deletion

File tree

src/tui/landing.test.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* telemetry disclosure and selectable starters — and nothing left over once
44
* the transcript has content.
55
*/
6-
import { describe, expect, test } from "bun:test";
6+
import { afterEach, describe, expect, test } from "bun:test";
77
import type { CapturedSpan } from "@opentui/core";
88
import { rgbToHex } from "@opentui/core";
99
import { withTestRenderer, type Harness } from "./harness";
@@ -47,6 +47,54 @@ import { UI } from "./theme";
4747
const SIZE = { width: 80, height: 24 } as const;
4848
const NOTICE = "Anonymous usage telemetry is enabled. Disable in /settings.";
4949

50+
const nativeSetInterval = globalThis.setInterval;
51+
const nativeClearInterval = globalThis.clearInterval;
52+
53+
afterEach(() => {
54+
globalThis.setInterval = nativeSetInterval;
55+
globalThis.clearInterval = nativeClearInterval;
56+
});
57+
58+
/**
59+
* 125 is `LANDING_IDLE_REPAINT_INTERVAL_MS` in shell.ts. Hardcoded so a
60+
* cadence change fails these tests on purpose rather than tracking a product
61+
* export.
62+
*/
63+
const LANDING_IDLE_REPAINT_INTERVAL_MS = 125;
64+
65+
type IntervalHandle = ReturnType<typeof nativeSetInterval>;
66+
67+
/** Wrap globals; the original handle is returned so `unref` still exists. */
68+
function wrapLandingIdleTimer(): {
69+
armed: IntervalHandle[];
70+
cleared: unknown[];
71+
} {
72+
const armed: IntervalHandle[] = [];
73+
const cleared: unknown[] = [];
74+
globalThis.setInterval = ((
75+
handler: Parameters<typeof nativeSetInterval>[0],
76+
delay?: number,
77+
...args: unknown[]
78+
) => {
79+
const handle = nativeSetInterval.call(globalThis, handler, delay, ...args);
80+
if (delay === LANDING_IDLE_REPAINT_INTERVAL_MS) armed.push(handle);
81+
return handle;
82+
}) as typeof nativeSetInterval;
83+
globalThis.clearInterval = ((handle: Parameters<typeof nativeClearInterval>[0]) => {
84+
cleared.push(handle);
85+
return nativeClearInterval.call(globalThis, handle);
86+
}) as typeof nativeClearInterval;
87+
return { armed, cleared };
88+
}
89+
90+
function soleLandingIdleHandle(armed: readonly IntervalHandle[]): IntervalHandle {
91+
const handle = armed[0];
92+
if (armed.length !== 1 || handle === undefined) {
93+
throw new Error(`expected exactly one 125ms interval, got ${String(armed.length)}`);
94+
}
95+
return handle;
96+
}
97+
5098
/** Newly added scroll-box children need a layout pass before they paint. */
5199
async function settle(h: Harness): Promise<void> {
52100
await h.renderOnce();
@@ -307,6 +355,40 @@ describe("landing screen", () => {
307355
}, SIZE);
308356
}, 15_000);
309357

358+
test("appending a transcript row clears the landing idle timer", async () => {
359+
const { armed, cleared } = wrapLandingIdleTimer();
360+
await withTestRenderer(async (h) => {
361+
const shell = createAppShell(h.renderer, {
362+
run: "idle",
363+
wireKeys: false,
364+
terminal: { columns: 80, rows: 24 },
365+
});
366+
try {
367+
const handle = soleLandingIdleHandle(armed);
368+
appendStreamRow(shell, { role: "user", text: "first prompt" });
369+
expect(isLanding(shell)).toBe(false);
370+
expect(cleared).toContain(handle);
371+
await settle(h);
372+
} finally {
373+
shell.dispose();
374+
}
375+
}, SIZE);
376+
});
377+
378+
test("disposing the shell with no transcript clears the landing idle timer", async () => {
379+
const { armed, cleared } = wrapLandingIdleTimer();
380+
await withTestRenderer(async (h) => {
381+
const shell = createAppShell(h.renderer, {
382+
run: "idle",
383+
wireKeys: false,
384+
terminal: { columns: 80, rows: 24 },
385+
});
386+
const handle = soleLandingIdleHandle(armed);
387+
shell.dispose();
388+
expect(cleared).toContain(handle);
389+
}, SIZE);
390+
});
391+
310392
test("a starter key fills the prompt; a typed prompt keeps its digits", async () => {
311393
await withTestRenderer(async (h) => {
312394
const shell = createAppShell(h.renderer, {

src/tui/mark-anim.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,29 @@ describe("renderMark", () => {
135135
expect(new Set(withSnow).size).toBeGreaterThan(1);
136136
});
137137

138+
test("reducedMotion drops snow at a clock that otherwise snows, without reshaping the mountain", () => {
139+
const times = [0, 1500, 3000, 4500, 6000, 7500];
140+
const nowMs = times.find((t) =>
141+
renderMark({ nowMs: t, still: true, reducedMotion: false, grid: MARK_LARGE })
142+
.flat()
143+
.some((cell) => isSnow(cell.char)),
144+
);
145+
if (nowMs === undefined) {
146+
throw new Error("expected a still-mode clock that draws snow");
147+
}
148+
149+
const snowing = renderMark({ nowMs, still: true, reducedMotion: false, grid: MARK_LARGE });
150+
const quiet = renderMark({ nowMs, still: true, reducedMotion: true, grid: MARK_LARGE });
151+
expect(snowing.flat().some((cell) => isSnow(cell.char))).toBe(true);
152+
expect(quiet.flat().some((cell) => isSnow(cell.char))).toBe(false);
153+
154+
const mountainOnly = (grid: typeof snowing) =>
155+
grid
156+
.map((row) => row.map((cell) => (isMountain(cell.char) ? cell.char : " ")).join(""))
157+
.join("\n");
158+
expect(mountainOnly(quiet)).toBe(mountainOnly(snowing));
159+
});
160+
138161
test("the animated frame advances with the injected clock", () => {
139162
const frames = [0, 400, 900, 1500, 2400, 3200].map((nowMs) =>
140163
markText(renderMark({ nowMs, still: false })),

0 commit comments

Comments
 (0)