Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 121 additions & 1 deletion src/tui/welcome.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { describe, expect, test } from "bun:test";

import { PRODUCT_NAME } from "../branding.js";
import { MARK_PERIOD_SECONDS, markFrame, markText, renderMark } from "./mark-anim.js";
import { MARK_LARGE, MARK_MID, MARK_SMALL } from "./mark-shape.js";
import { createHarness } from "./harness.js";
import { resolveWelcomeMarkGrid, runWelcome, WELCOME_LINE } from "./welcome.js";
import { stringWidth } from "./view/height.js";
import {
resolveWelcomeLine,
resolveWelcomeMarkGrid,
runWelcome,
WELCOME_AUTO_ADVANCE_MS,
WELCOME_LINE,
welcomeMarkStill,
} from "./welcome.js";

describe("WELCOME_LINE", () => {
test("names the product as the local software factory", () => {
Expand Down Expand Up @@ -74,3 +83,114 @@ describe("runWelcome", () => {
}
});
});

describe("welcomeMarkStill", () => {
test("animates through fill, then freezes the full frame", () => {
const fillMs = 0.76 * MARK_PERIOD_SECONDS * 1000;
expect(welcomeMarkStill(fillMs - 1)).toBe(false);
expect(welcomeMarkStill(fillMs)).toBe(true);
expect(welcomeMarkStill(0.95 * MARK_PERIOD_SECONDS * 1000)).toBe(true);
expect(welcomeMarkStill(MARK_PERIOD_SECONDS * 1000 + 900)).toBe(true);
});
});

describe("WELCOME_AUTO_ADVANCE_MS", () => {
test("lands on the held filled frame, not fade-out or a second draw-in", () => {
const seconds = WELCOME_AUTO_ADVANCE_MS / 1000;
expect(seconds).toBeGreaterThanOrEqual(0.76 * MARK_PERIOD_SECONDS);
expect(seconds).toBeLessThan(MARK_PERIOD_SECONDS);

const still = welcomeMarkStill(WELCOME_AUTO_ADVANCE_MS);
const frame = markFrame(seconds, still);
expect(still).toBe(true);
expect(frame).toEqual({ drawProg: 1, fillProg: 1, alpha: 1 });

// Looping math at this delay must also still be the full hold — never the
// fade (90–100%) or the wrapped second draw-in.
const looping = markFrame(seconds, false);
expect(looping.alpha).toBe(1);
expect(looping.fillProg).toBe(1);
expect(looping.drawProg).toBe(1);
expect(seconds).toBeLessThan(0.9 * MARK_PERIOD_SECONDS + 1e-9);
});
});

describe("resolveWelcomeLine", () => {
test("keeps the full factory sentence or hides it, never a mid-word slice", () => {
expect(resolveWelcomeLine(80)).toBe(WELCOME_LINE);
expect(resolveWelcomeLine(stringWidth(WELCOME_LINE))).toBe(WELCOME_LINE);

const truncated = WELCOME_LINE.slice(0, 39);
expect(truncated).toContain("facto");
expect(truncated).not.toBe(WELCOME_LINE);

const narrow = resolveWelcomeLine(40);
expect(narrow === "" || narrow === WELCOME_LINE).toBe(true);
expect(narrow).not.toBe(truncated);
expect(narrow.includes("facto") && !narrow.includes("factory")).toBe(false);
});
});

describe("runWelcome hold and cancel", () => {
test("paints a still full mark after fill instead of fading", async () => {
const fadeMs = 0.95 * MARK_PERIOD_SECONDS * 1000;
// First `now()` is mount (`startedAt`); later samples are elapsed fadeMs.
let samples = 0;
const harness = await createHarness({ width: 80, height: 30 });
const done = runWelcome({
createRenderer: async () => harness.renderer,
autoAdvanceMs: 60_000,
now: () => (samples++ === 0 ? 0 : fadeMs),
});
try {
await harness.renderOnce();
await harness.renderOnce();
const frame = harness.captureCharFrame();
const held = markText(renderMark({ nowMs: fadeMs, still: true, grid: MARK_LARGE }));
const fading = markText(renderMark({ nowMs: fadeMs, still: false, grid: MARK_LARGE }));
const mountain = (text: string) => (text.match(/[▁▂▃▄▅▆▇█]/g) ?? []).length;
expect(mountain(frame)).toBe(mountain(held));
expect(mountain(held)).toBeGreaterThan(mountain(fading));
} finally {
harness.pressKey("Ctrl+C");
await Promise.race([done, new Promise((r) => setTimeout(r, 50))]);
harness.destroy();
}
});

test("cancels on Ctrl+D without continuing", async () => {
const harness = await createHarness({ width: 80, height: 30 });
const done = runWelcome({
createRenderer: async () => harness.renderer,
autoAdvanceMs: 60_000,
now: () => 2_000,
});
try {
await harness.renderOnce();
harness.pressKey("d", { ctrl: true });
await expect(done).resolves.toBe(false);
} finally {
harness.destroy();
}
});

test("narrow terminals do not paint a sliced factory sentence", async () => {
const harness = await createHarness({ width: 42, height: 30 });
const done = runWelcome({
createRenderer: async () => harness.renderer,
autoAdvanceMs: 60_000,
now: () => 2_000,
});
try {
await harness.renderOnce();
await harness.renderOnce();
const frame = harness.captureCharFrame();
expect(frame).not.toContain("software facto");
expect(frame.includes("facto") && !frame.includes("factory")).toBe(false);
} finally {
harness.pressKey("Ctrl+C");
await Promise.race([done, new Promise((r) => setTimeout(r, 50))]);
harness.destroy();
}
});
});
38 changes: 27 additions & 11 deletions src/tui/welcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,27 @@ const MARK_TIERS: readonly MarkGrid[] = [MARK_LARGE, MARK_MID, MARK_SMALL];
/** Rows reserved under the mark for the product line, hint, and breathing room. */
const BELOW_MARK_ROWS = 5;

/** Hold after one full mark period before auto-advancing. */
const HOLD_AFTER_PERIOD_MS = 900;
/** Fill completes at this fraction of one mark period (full-frame hold starts). */
const MARK_FILL_END = 0.76;

/** Last instant of the full-frame hold; fade starts after this. */
const MARK_HOLD_END = 0.9;

/**
* Auto-advance at the end of the full-frame hold so setup never opens on a
* fade or a second draw-in.
*/
export const WELCOME_AUTO_ADVANCE_MS = Math.round(MARK_HOLD_END * MARK_PERIOD_SECONDS * 1000);

/** Freeze the mountain on its filled frame once fill completes. */
export function welcomeMarkStill(elapsedMs: number): boolean {
return elapsedMs / 1000 >= MARK_FILL_END * MARK_PERIOD_SECONDS;
}

/** Full product line when it fits; otherwise hide it rather than slicing words. */
export function resolveWelcomeLine(columns: number): string {
return stringWidth(WELCOME_LINE) > columns ? "" : WELCOME_LINE;
}

/** Paint cadence while the mountain draws. */
const PAINT_TICK_MS = 80;
Expand All @@ -47,8 +66,8 @@ export interface WelcomeConfig {
/** Renderer factory override for headless mounting in tests. */
readonly createRenderer?: () => Promise<CliRenderer>;
/**
* Auto-advance delay after mount. Defaults to one mark period plus a short
* hold so the silhouette finishes drawing before setup opens.
* Auto-advance delay after mount. Defaults to the end of the full-frame
* hold (`WELCOME_AUTO_ADVANCE_MS`) so setup opens on a filled silhouette.
*/
readonly autoAdvanceMs?: number;
/** Injected clock for mark animation (and tests). */
Expand Down Expand Up @@ -87,8 +106,7 @@ export async function runWelcome(config: WelcomeConfig = {}): Promise<boolean> {

const now = config.now ?? Date.now;
const startedAt = now();
const autoAdvanceMs =
config.autoAdvanceMs ?? Math.round(MARK_PERIOD_SECONDS * 1000) + HOLD_AFTER_PERIOD_MS;
const autoAdvanceMs = config.autoAdvanceMs ?? WELCOME_AUTO_ADVANCE_MS;

const margin = resolveSideMargin(renderer.width || 80);

Expand Down Expand Up @@ -192,16 +210,14 @@ export async function runWelcome(config: WelcomeConfig = {}): Promise<boolean> {
markRows.forEach((row, index) => {
row.visible = grid !== null && index >= offset;
});
line.content =
stringWidth(WELCOME_LINE) > columns
? WELCOME_LINE.slice(0, Math.max(0, columns - 1))
: WELCOME_LINE;
line.content = resolveWelcomeLine(columns);
};

const paint = (): void => {
if (settled || grid === null) return;
try {
const chunks = markChunks(grid, now() - startedAt, false);
const elapsed = now() - startedAt;
const chunks = markChunks(grid, elapsed, welcomeMarkStill(elapsed));
const offset = MARK_LARGE.rows - grid.rows;
markRows.forEach((row, index) => {
if (!row.visible) return;
Expand Down
Loading