test: backfill Ink-parity coverage gaps (suite as a superset of Ink) (#130)
Regression tests for behaviors the audit found correct-but-unpinned, so the suite is a strict superset of Ink: - A07: two <Static> regions both render (the additive divergence) - B04: Static render-prop index = absolute index across appends; container vertical padding adds blank rows to the static frame - B11: lazy raw-mode acquire/release under rawMode:'auto' (the path the 'always' default masks) - B19: child useCursor unmount emits the cursor-hide escape (stream-level) - B20: animation interval 0/negative clamps to 1ms (normalizeInterval unit) and advances without busy-hang - B21/B28: INK_SCREEN_READER env auto-detection + useIsScreenReaderEnabled true-path (env tests isolated in a *.sequential file per the global-state rule) - B29: renderToString serves useCursor/usePaste/useTerminalSize/useAnimation/ useBoxMetrics as inert no-ops (don't throw) - B30: dedicated columnGap/rowGap props + their removal-reset Test-only; no production changes. Codex-reviewed for non-vacuousness, Ink correctness, and process-global isolation. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -774,3 +774,129 @@ test("Static items do not add blank lines to the dynamic frame", async () => {
|
||||
const frame = lastFrame()!;
|
||||
expect(frame).toBe("[live]");
|
||||
});
|
||||
|
||||
// A07 — multiple <Static> regions both render.
|
||||
//
|
||||
// Ink honors only the FIRST <Static> in the tree (ink.tsx tracks a single
|
||||
// `staticNode`); a second one is silently dropped. vue-tui is an ADDITIVE
|
||||
// DIVERGENCE: every <Static> node found by findStatics() is painted, so two
|
||||
// independent static regions in one tree BOTH emit their items. This pins that
|
||||
// superset behavior (there was previously no test mounting two regions).
|
||||
test("two separate <Static> regions both render their items (additive divergence)", async () => {
|
||||
const headerItems = shallowRef<string[]>([]);
|
||||
const logItems = shallowRef<string[]>([]);
|
||||
|
||||
const App = defineComponent(() => () => (
|
||||
<Box flexDirection="column">
|
||||
<Static items={headerItems.value}>
|
||||
{{ default: ({ item }: { item: string }) => <Text key={item}>{item}</Text> }}
|
||||
</Static>
|
||||
<Static items={logItems.value}>
|
||||
{{ default: ({ item }: { item: string }) => <Text key={item}>{item}</Text> }}
|
||||
</Static>
|
||||
<Text>[live]</Text>
|
||||
</Box>
|
||||
));
|
||||
|
||||
const { frames } = await render(App);
|
||||
|
||||
// Populate both regions; each region's items must appear in the emitted output.
|
||||
headerItems.value = ["HEADER-1", "HEADER-2"];
|
||||
logItems.value = ["LOG-1", "LOG-2"];
|
||||
await nextTick();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await nextTick();
|
||||
|
||||
const allOutput = frames.join("");
|
||||
// BOTH regions render (Ink would drop the second).
|
||||
expect(allOutput).toContain("HEADER-1");
|
||||
expect(allOutput).toContain("HEADER-2");
|
||||
expect(allOutput).toContain("LOG-1");
|
||||
expect(allOutput).toContain("LOG-2");
|
||||
});
|
||||
|
||||
// B04(a) — the render-prop's SECOND arg (`index`) is the ABSOLUTE array index,
|
||||
// stable across INCREMENTAL appends.
|
||||
//
|
||||
// Static.ts renders `items.slice(cursor)` and passes `index = cursor + i`, where
|
||||
// the cursor advances to items.length after each batch is written. So an item's
|
||||
// index is its position in the FULL list, never its position within the append
|
||||
// batch. This mirrors Ink's Static, which renders `items.slice(index)` and passes
|
||||
// the absolute index to the render prop. (Confirmed: batch [a0,a1] → indices 0,1;
|
||||
// batch [b2,b3] appended → indices 2,3, NOT 0,1.)
|
||||
test("Static render-prop index is the absolute array index across incremental appends", async () => {
|
||||
const seen: Array<{ item: string; index: number }> = [];
|
||||
const items = shallowRef<string[]>([]);
|
||||
|
||||
const App = defineComponent(() => () => (
|
||||
<Box>
|
||||
<Static items={items.value}>
|
||||
{{
|
||||
default: ({ item, index }: { item: string; index: number }) => {
|
||||
seen.push({ item, index });
|
||||
return <Text key={item}>{item}</Text>;
|
||||
},
|
||||
}}
|
||||
</Static>
|
||||
<Text>[live]</Text>
|
||||
</Box>
|
||||
));
|
||||
|
||||
await render(App);
|
||||
|
||||
// First batch: two items at absolute indices 0 and 1.
|
||||
items.value = ["a0", "a1"];
|
||||
await nextTick();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await nextTick();
|
||||
|
||||
// Second batch appended: the new items must get absolute indices 2 and 3
|
||||
// (their position in the FULL [a0,a1,b2,b3] list), not the batch-local 0,1.
|
||||
items.value = ["a0", "a1", "b2", "b3"];
|
||||
await nextTick();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await nextTick();
|
||||
|
||||
// Each item was rendered with its absolute index. (Items render once; the
|
||||
// already-written a0/a1 are sliced out before the second batch renders.)
|
||||
expect(seen).toEqual([
|
||||
{ item: "a0", index: 0 },
|
||||
{ item: "a1", index: 1 },
|
||||
{ item: "b2", index: 2 },
|
||||
{ item: "b3", index: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
// B04(b) — vertical padding on the <Static> container paints into the static frame.
|
||||
//
|
||||
// Static.ts merges the caller `style` onto the internal static box, and
|
||||
// static-channel.ts paints that node via its OWN yoga node (paintIsolated). So
|
||||
// paddingTop/paddingBottom resolve as real layout: they add blank rows above /
|
||||
// below the item inside the painted static frame. Confirmed against the pinned
|
||||
// Ink reference (v7.0.4): paddingTop:2 paddingBottom:1 → static frame "\n\nX\n\n"
|
||||
// (2 blank rows above X, then X, then 1 blank row below).
|
||||
test("Static container vertical padding adds blank rows to the painted static frame", async () => {
|
||||
const items = shallowRef<string[]>([]);
|
||||
|
||||
const App = defineComponent(() => () => (
|
||||
<Box flexDirection="column">
|
||||
<Static items={items.value} style={{ paddingTop: 2, paddingBottom: 1 }}>
|
||||
{{ default: ({ item }: { item: string }) => <Text key={item}>{item}</Text> }}
|
||||
</Static>
|
||||
<Text>[live]</Text>
|
||||
</Box>
|
||||
));
|
||||
|
||||
const { frames } = await render(App);
|
||||
|
||||
items.value = ["X"];
|
||||
await nextTick();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await nextTick();
|
||||
|
||||
// The captured static chunk is the "\n"-terminated fullStaticOutput. With
|
||||
// paddingTop:2 / paddingBottom:1 the painted region is "\n\nX\n\n" (Ink parity).
|
||||
const staticFrame = frames.find((f) => f.includes("X") && !f.includes("[live]"));
|
||||
expect(staticFrame).toBeDefined();
|
||||
expect(staticFrame).toBe("\n\nX\n\n");
|
||||
});
|
||||
|
||||
@@ -289,6 +289,89 @@ test("rawMode 'auto': a no-input app never enables raw mode (Ink lazy behavior)"
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
// B11 — under rawMode 'auto' the Ink-parity LAZY path is exercised end-to-end:
|
||||
// useInput ACQUIRES raw mode on mount and RELEASES it when its component
|
||||
// unmounts. The new 'always' default masks this (its app-lifetime floor ref keeps
|
||||
// raw mode pinned for the whole session); 'auto' is the escape hatch that restores
|
||||
// Ink's lazy acquire/release, so this pins the behavior the default hides.
|
||||
//
|
||||
// useInput.ts: attach() → stdin.acquireRawMode() on mount; detach() (onScopeDispose
|
||||
// / isActive→false) → stdin.releaseRawMode(). With NO floor ref under 'auto', the
|
||||
// last consumer's release drops the refcount to 0 and disables raw mode (back to
|
||||
// cooked). The disable is deferred to a microtask (render.ts), which settle() drains.
|
||||
test("rawMode 'auto': useInput acquires raw on mount and releases it on unmount (Ink lazy)", async () => {
|
||||
const showInput = shallowRef(true);
|
||||
const Child = defineComponent(() => {
|
||||
useInput(() => {});
|
||||
return () => <Text>input</Text>;
|
||||
});
|
||||
const App = defineComponent(() => () => (showInput.value ? <Child /> : <Text>idle</Text>));
|
||||
|
||||
const stdout = makeFakeWritable();
|
||||
const { stream: stdin, setRawModeCalls, refCount } = makeSpyStdin();
|
||||
|
||||
const app = createApp(App);
|
||||
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false, rawMode: "auto" });
|
||||
await settle();
|
||||
|
||||
// Mounting the useInput consumer enables raw mode exactly once (0→1).
|
||||
expect(setRawModeCalls).toEqual([true]);
|
||||
expect(refCount()).toBe(1);
|
||||
|
||||
// Unmount the ONLY input consumer. Under 'auto' there is no floor ref, so the
|
||||
// release drops the refcount to 0 and disables raw mode — back to cooked.
|
||||
showInput.value = false;
|
||||
await settle();
|
||||
expect(setRawModeCalls).toEqual([true, false]);
|
||||
expect(refCount()).toBe(0);
|
||||
|
||||
// Teardown must not re-toggle (raw mode is already cooked).
|
||||
app.unmount();
|
||||
await settle();
|
||||
expect(setRawModeCalls).toEqual([true, false]);
|
||||
expect(refCount()).toBe(0);
|
||||
});
|
||||
|
||||
// B11 — the same lazy release is driven by useInput's isActive gate (not just by
|
||||
// unmount). Setting isActive false detaches (releaseRawMode → cooked); flipping it
|
||||
// back true re-attaches (acquireRawMode → raw). This is the per-consumer gating Ink
|
||||
// exposes via the `isActive` option (use-input.ts), still live under 'auto'.
|
||||
test("rawMode 'auto': useInput isActive=false releases raw mode, true re-acquires (Ink lazy)", async () => {
|
||||
const active = shallowRef(true);
|
||||
const App = defineComponent(() => {
|
||||
useInput(() => {}, { isActive: () => active.value });
|
||||
return () => <Text>listening</Text>;
|
||||
});
|
||||
|
||||
const stdout = makeFakeWritable();
|
||||
const { stream: stdin, setRawModeCalls, refCount } = makeSpyStdin();
|
||||
|
||||
const app = createApp(App);
|
||||
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false, rawMode: "auto" });
|
||||
await settle();
|
||||
|
||||
// Active on mount → raw acquired once.
|
||||
expect(setRawModeCalls).toEqual([true]);
|
||||
expect(refCount()).toBe(1);
|
||||
|
||||
// Deactivate → detach releases raw mode (cooked).
|
||||
active.value = false;
|
||||
await settle();
|
||||
expect(setRawModeCalls).toEqual([true, false]);
|
||||
expect(refCount()).toBe(0);
|
||||
|
||||
// Reactivate → re-attach re-acquires raw mode.
|
||||
active.value = true;
|
||||
await settle();
|
||||
expect(setRawModeCalls).toEqual([true, false, true]);
|
||||
expect(refCount()).toBe(1);
|
||||
|
||||
app.unmount();
|
||||
await settle();
|
||||
expect(setRawModeCalls).toEqual([true, false, true, false]);
|
||||
expect(refCount()).toBe(0);
|
||||
});
|
||||
|
||||
// rawMode 'always': raw mode must NOT drop when an input component unmounts
|
||||
// mid-session — the app's lifetime ref holds the floor at 1, so there is no
|
||||
// cooked-mode oscillation as the user navigates between input and no-input
|
||||
|
||||
@@ -597,6 +597,59 @@ describe("useAnimation", () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
// B20: a zero interval must not busy-hang the scheduler — the animation keeps
|
||||
// ADVANCING (and real wall-clock time progresses) over a short window rather
|
||||
// than spinning forever or stalling. This is the observable end-to-end
|
||||
// guarantee ONLY: it deliberately makes NO claim about the exact cadence. The
|
||||
// exact `Math.max(1, 0) === 1` clamp is pinned directly in the scheduler unit
|
||||
// test (packages/runtime-tests/unit/animation-scheduler.sequential.test.ts —
|
||||
// normalizeInterval), which is the discriminating guard against the clamp
|
||||
// regressing (e.g. to 30ms); a "frame > 1 after 60ms" assertion here cannot
|
||||
// tell 1ms from 30ms apart, so we do not assert it.
|
||||
test("interval 0 advances frames without busy-hanging (no cadence claim)", async () => {
|
||||
let frameVal = 0;
|
||||
let timeVal = 0;
|
||||
const App = defineComponent(() => {
|
||||
const { frame, time } = useAnimation({ interval: 0 });
|
||||
watchEffect(() => {
|
||||
frameVal = frame.value;
|
||||
timeVal = time.value;
|
||||
});
|
||||
return () => <Text>{String(frame.value)}</Text>;
|
||||
});
|
||||
const { unmount } = await render(App);
|
||||
// If 0 were NOT clamped to a positive delay, the scheduler would either
|
||||
// busy-loop (never yielding) or never settle and this test would hang/time
|
||||
// out. Reaching the assertions at all proves it did neither.
|
||||
await delay(60);
|
||||
// The animation advanced (made real progress) ...
|
||||
expect(frameVal).toBeGreaterThanOrEqual(1);
|
||||
// ... and real wall-clock time progressed.
|
||||
expect(timeVal).toBeGreaterThan(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
// B20: a negative interval must ALSO not busy-hang — same observable
|
||||
// advances/no-hang guarantee as interval 0. The exact `Math.max(1, -5) === 1`
|
||||
// clamp is pinned in the scheduler unit test (normalizeInterval), not here.
|
||||
test("negative interval (-5) advances frames without busy-hanging (no cadence claim)", async () => {
|
||||
let frameVal = 0;
|
||||
let timeVal = 0;
|
||||
const App = defineComponent(() => {
|
||||
const { frame, time } = useAnimation({ interval: -5 });
|
||||
watchEffect(() => {
|
||||
frameVal = frame.value;
|
||||
timeVal = time.value;
|
||||
});
|
||||
return () => <Text>{String(frame.value)}</Text>;
|
||||
});
|
||||
const { unmount } = await render(App);
|
||||
await delay(60);
|
||||
expect(frameVal).toBeGreaterThanOrEqual(1);
|
||||
expect(timeVal).toBeGreaterThan(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
// -- Behavior over real time (migrated from fake-timer tests) --
|
||||
// Timer-precision and exact-frame assertions now live in the scheduler
|
||||
// unit tests (packages/runtime-tests/unit/animation-scheduler.test.ts).
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
// Sequential: these tests mutate the process-GLOBAL env var
|
||||
// `INK_SCREEN_READER`. The mount path auto-detects it
|
||||
// (render.ts:526-527: isScreenReaderEnabled =
|
||||
// options.isScreenReaderEnabled ?? process.env["INK_SCREEN_READER"] === "true").
|
||||
// Under the repo's file-level parallelism a concurrent sibling in another file
|
||||
// could observe the mutated value mid-flight (test.sequential only serializes
|
||||
// WITHIN a file), so per the process-global convention they live here. Each test
|
||||
// captures the prior value in beforeEach and restores-or-deletes it in afterEach
|
||||
// so an ambient INK_SCREEN_READER is never blown away.
|
||||
|
||||
import { defineComponent, nextTick } from "vue";
|
||||
import { afterEach, beforeEach, expect, test } from "vite-plus/test";
|
||||
import { Box, Text, createApp, useIsScreenReaderEnabled } from "@vue-tui/runtime";
|
||||
import {
|
||||
makeFakeStdin,
|
||||
makeFakeWritable,
|
||||
captureWrites,
|
||||
getContentWrites,
|
||||
} from "../lifecycle/test-streams.ts";
|
||||
|
||||
// Whether INK_SCREEN_READER was set in the ambient environment, and to what.
|
||||
let hadEnv = false;
|
||||
let savedEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
hadEnv = Object.prototype.hasOwnProperty.call(process.env, "INK_SCREEN_READER");
|
||||
savedEnv = process.env["INK_SCREEN_READER"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore the ORIGINAL state: only re-set the value if it was actually
|
||||
// present originally; otherwise delete it. Never resurrect/clobber an
|
||||
// unset-vs-set distinction so an ambient value survives untouched.
|
||||
if (hadEnv) {
|
||||
process.env["INK_SCREEN_READER"] = savedEnv;
|
||||
} else {
|
||||
delete process.env["INK_SCREEN_READER"];
|
||||
}
|
||||
});
|
||||
|
||||
test.sequential("INK_SCREEN_READER=true auto-detects SR mode at mount (no explicit option) — linearized output, no border glyphs", async () => {
|
||||
process.env["INK_SCREEN_READER"] = "true";
|
||||
|
||||
const App = defineComponent(() => {
|
||||
return () => (
|
||||
<Box borderStyle="round">
|
||||
<Text>Hello world</Text>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
const stdout = makeFakeWritable({ columns: 80 });
|
||||
const stderr = makeFakeWritable({ columns: 80 });
|
||||
const { stream: stdin } = makeFakeStdin();
|
||||
const writes = captureWrites(stdout);
|
||||
|
||||
// NO isScreenReaderEnabled option — the env var alone must enable SR mode.
|
||||
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const content = getContentWrites(writes).join("");
|
||||
// SR-linearized text is present...
|
||||
expect(content).toContain("Hello world");
|
||||
// ...and the 2D box-drawing glyphs are NOT (SR mode linearizes the tree;
|
||||
// the non-SR path would emit a bordered grid with these glyphs).
|
||||
for (const glyph of ["╭", "╮", "╰", "╯", "─", "│"]) {
|
||||
expect(content).not.toContain(glyph);
|
||||
}
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
test.sequential("useIsScreenReaderEnabled() returns true under INK_SCREEN_READER=true auto-detection", async () => {
|
||||
process.env["INK_SCREEN_READER"] = "true";
|
||||
|
||||
let observed: boolean | undefined;
|
||||
const App = defineComponent(() => {
|
||||
observed = useIsScreenReaderEnabled();
|
||||
return () => <Text>flag</Text>;
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
const stdout = makeFakeWritable({ columns: 80 });
|
||||
const stderr = makeFakeWritable({ columns: 80 });
|
||||
const { stream: stdin } = makeFakeStdin();
|
||||
|
||||
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// The composable observed the env-var-derived SR flag as enabled.
|
||||
expect(observed).toBe(true);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
test.sequential("explicit isScreenReaderEnabled:false overrides INK_SCREEN_READER=true (?? only falls back when option is undefined)", async () => {
|
||||
// Guards the `??` semantics: the env var is the FALLBACK, not an override.
|
||||
// A live app explicitly opting OUT must render the visual (bordered) frame
|
||||
// even when INK_SCREEN_READER=true.
|
||||
process.env["INK_SCREEN_READER"] = "true";
|
||||
|
||||
let observed: boolean | undefined;
|
||||
const App = defineComponent(() => {
|
||||
observed = useIsScreenReaderEnabled();
|
||||
return () => (
|
||||
<Box borderStyle="round">
|
||||
<Text>Visible</Text>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
const stdout = makeFakeWritable({ columns: 80 });
|
||||
const stderr = makeFakeWritable({ columns: 80 });
|
||||
const { stream: stdin } = makeFakeStdin();
|
||||
const writes = captureWrites(stdout);
|
||||
|
||||
// Explicit false must win over the env var.
|
||||
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, isScreenReaderEnabled: false });
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(observed).toBe(false);
|
||||
const content = getContentWrites(writes).join("");
|
||||
expect(content).toContain("Visible");
|
||||
// The visual (non-SR) path DOES emit border glyphs.
|
||||
expect(content).toContain("─");
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { defineComponent } from "vue";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { render } from "@vue-tui/testing";
|
||||
import { Text, useIsScreenReaderEnabled } from "@vue-tui/runtime";
|
||||
import { Text, renderToString, useIsScreenReaderEnabled } from "@vue-tui/runtime";
|
||||
|
||||
// NOTE: tests that auto-detect SR via the process-GLOBAL env var
|
||||
// `INK_SCREEN_READER` live in use-screen-reader-env.sequential.test.tsx (the
|
||||
// repo's process-global convention). The tests below never mutate that global.
|
||||
|
||||
test("useIsScreenReaderEnabled returns false by default", async () => {
|
||||
let result = false;
|
||||
@@ -12,3 +16,19 @@ test("useIsScreenReaderEnabled returns false by default", async () => {
|
||||
await render(App);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
// B28: the existing test only covers the `false` default. When SR IS enabled,
|
||||
// useIsScreenReaderEnabled() must return `true`. It reads ctx.isScreenReaderEnabled
|
||||
// (useIsScreenReaderEnabled.ts:8), which renderToString seeds from the
|
||||
// isScreenReaderEnabled option (render-to-string.ts:61-64,179).
|
||||
test("useIsScreenReaderEnabled returns true when SR is enabled (renderToString option)", () => {
|
||||
let result: boolean | undefined;
|
||||
const App = defineComponent(() => {
|
||||
result = useIsScreenReaderEnabled();
|
||||
return () => <Text>sr enabled</Text>;
|
||||
});
|
||||
const output = renderToString(App, { isScreenReaderEnabled: true });
|
||||
// Composable observed the enabled flag, and the SR text still rendered.
|
||||
expect(result).toBe(true);
|
||||
expect(output).toBe("sr enabled");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineComponent } from "vue";
|
||||
import { defineComponent, nextTick, shallowRef } from "vue";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { render } from "@vue-tui/testing";
|
||||
import { Box, Text } from "@vue-tui/runtime";
|
||||
@@ -43,6 +43,148 @@ test("row gap", async () => {
|
||||
expect(lastFrame({ trimLines: true })).toBe("A\n\nB");
|
||||
});
|
||||
|
||||
// --- dedicated columnGap / rowGap props (B30, distinct from the `gap` shorthand) ---
|
||||
//
|
||||
// Ink semantics (styles.ts applyGapStyles): `columnGap` → yoga GUTTER_COLUMN
|
||||
// (the gap BETWEEN columns, i.e. horizontal spacing, which is the MAIN-axis gap
|
||||
// in flexDirection:"row"); `rowGap` → yoga GUTTER_ROW (the gap BETWEEN rows, i.e.
|
||||
// vertical spacing, the main-axis gap in flexDirection:"column"). vue-tui maps the
|
||||
// same way (host/yoga.ts: columnGap→GUTTER_COLUMN, rowGap→GUTTER_ROW).
|
||||
//
|
||||
// Confirmed against the pinned Ink reference (v7.0.4, /tmp/ink-40b3a75/build,
|
||||
// renderToString cols=100):
|
||||
// row + columnGap:2 → "A B"
|
||||
// column + rowGap:2 → "A\n\n\nB"
|
||||
// row wrap + columnGap:1 width:3 → "A B\nC"
|
||||
// row + rowGap:2 → "AB" (cross-axis only; no main-axis effect)
|
||||
// column + columnGap:2 → "A\nB" (cross-axis only; no main-axis effect)
|
||||
|
||||
test("columnGap is the main-axis (horizontal) gap in flexDirection:row", async () => {
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => (
|
||||
<Box flexDirection="row" columnGap={2}>
|
||||
<Text>A</Text>
|
||||
<Text>B</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
// Two spaces between the columns — matches Ink "A B".
|
||||
expect(lastFrame({ trimLines: true })).toBe("A B");
|
||||
});
|
||||
|
||||
test("rowGap is the main-axis (vertical) gap in flexDirection:column", async () => {
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => (
|
||||
<Box flexDirection="column" rowGap={2}>
|
||||
<Text>A</Text>
|
||||
<Text>B</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
// Two blank rows between A and B — matches Ink "A\n\n\nB".
|
||||
expect(lastFrame({ trimLines: true })).toBe("A\n\n\nB");
|
||||
});
|
||||
|
||||
test("columnGap spaces wrapped columns horizontally (row + flexWrap)", async () => {
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => (
|
||||
<Box flexDirection="row" columnGap={1} width={3} flexWrap="wrap">
|
||||
<Text>A</Text>
|
||||
<Text>B</Text>
|
||||
<Text>C</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
// "A" and "B" share the first row with a 1-col gap; "C" wraps. Matches Ink "A B\nC".
|
||||
expect(lastFrame({ trimLines: true })).toBe("A B\nC");
|
||||
});
|
||||
|
||||
test("rowGap has no main-axis effect in flexDirection:row (cross-axis only)", async () => {
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => (
|
||||
<Box flexDirection="row" rowGap={2}>
|
||||
<Text>A</Text>
|
||||
<Text>B</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
// rowGap is the BETWEEN-ROWS gap; a single-row layout has no rows to separate,
|
||||
// so it adds nothing. Matches Ink "AB".
|
||||
expect(lastFrame({ trimLines: true })).toBe("AB");
|
||||
});
|
||||
|
||||
test("columnGap has no main-axis effect in flexDirection:column (cross-axis only)", async () => {
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => (
|
||||
<Box flexDirection="column" columnGap={2}>
|
||||
<Text>A</Text>
|
||||
<Text>B</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
// columnGap is the BETWEEN-COLUMNS gap; a single-column layout has no columns to
|
||||
// separate, so it adds nothing. Matches Ink "A\nB".
|
||||
expect(lastFrame({ trimLines: true })).toBe("A\nB");
|
||||
});
|
||||
|
||||
// Prop-reset: removing columnGap (undefined) or setting it to 0 must collapse the
|
||||
// horizontal spacing back to the no-gap baseline. yoga.ts resets the gutter to 0
|
||||
// when the prop is null/undefined (G19), so the layout must re-flow tight.
|
||||
test("columnGap resets when removed (undefined) or set to 0", async () => {
|
||||
const gap = shallowRef<number | undefined>(2);
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => (
|
||||
<Box flexDirection="row" columnGap={gap.value}>
|
||||
<Text>A</Text>
|
||||
<Text>B</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
expect(lastFrame({ trimLines: true })).toBe("A B");
|
||||
|
||||
// Remove the prop entirely → gutter resets to 0, columns abut.
|
||||
gap.value = undefined;
|
||||
await nextTick();
|
||||
expect(lastFrame({ trimLines: true })).toBe("AB");
|
||||
|
||||
// Re-apply, then explicitly set 0 → also tight.
|
||||
gap.value = 3;
|
||||
await nextTick();
|
||||
expect(lastFrame({ trimLines: true })).toBe("A B");
|
||||
gap.value = 0;
|
||||
await nextTick();
|
||||
expect(lastFrame({ trimLines: true })).toBe("AB");
|
||||
});
|
||||
|
||||
// Prop-reset for rowGap in the vertical direction.
|
||||
test("rowGap resets when removed (undefined) or set to 0", async () => {
|
||||
const gap = shallowRef<number | undefined>(2);
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => (
|
||||
<Box flexDirection="column" rowGap={gap.value}>
|
||||
<Text>A</Text>
|
||||
<Text>B</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
expect(lastFrame({ trimLines: true })).toBe("A\n\n\nB");
|
||||
|
||||
gap.value = undefined;
|
||||
await nextTick();
|
||||
expect(lastFrame({ trimLines: true })).toBe("A\nB");
|
||||
|
||||
gap.value = 0;
|
||||
await nextTick();
|
||||
expect(lastFrame({ trimLines: true })).toBe("A\nB");
|
||||
});
|
||||
|
||||
// Skipped: gap - concurrent
|
||||
// Skipped: column gap - concurrent
|
||||
// Skipped: row gap - concurrent
|
||||
|
||||
@@ -309,6 +309,68 @@ describe("cursor commit-path wiring (interactive stream level)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a child useCursor unmount emits the cursor-HIDE escape (show -> hide ordering)", async () => {
|
||||
// B19 (Ink parity, use-cursor.ts:29-31): when a `useCursor` child unmounts,
|
||||
// its onScopeDispose runs ctx.setCursorPosition(undefined) — exactly Ink's
|
||||
// useInsertionEffect cleanup `context.setCursorPosition(undefined)`. That
|
||||
// marks log-update cursorDirty with an undefined position. On the NEXT
|
||||
// commit, the frame changed (child swapped to a no-cursor branch) so render()
|
||||
// takes the `else` branch and writes buildReturnToBottomPrefix(cursorWasShown:
|
||||
// true, ...) — which begins with hideCursorEscape (`\x1b[?25l`). So the cursor
|
||||
// that was SHOWN at the child's position must be HIDDEN when the owner unmounts.
|
||||
//
|
||||
// This is observable only at the interactive stream level: the debug render()
|
||||
// helper has FrameWriter.log === null, so log-update (and its cursor escapes)
|
||||
// never runs. We capture raw stdout write chunks (Ink's getWriteCalls pattern).
|
||||
const showChild = shallowRef(true);
|
||||
const CursorChild = defineComponent(() => {
|
||||
const { setCursorPosition } = useCursor();
|
||||
return () => {
|
||||
// Distinct cursor column so the SHOW is unambiguous: x=5 -> cursorTo(5).
|
||||
setCursorPosition({ x: 5, y: 0 });
|
||||
return <Text>child</Text>;
|
||||
};
|
||||
});
|
||||
const HostApp = defineComponent(() => {
|
||||
return () => <Box>{showChild.value ? <CursorChild /> : <Text>no cursor here</Text>}</Box>;
|
||||
});
|
||||
|
||||
const { stream: stdout, writes } = makeTtyStdout();
|
||||
const stdin = makeTtyStdin();
|
||||
|
||||
const app = createApp(HostApp);
|
||||
app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0 });
|
||||
await app.waitUntilRenderFlush();
|
||||
|
||||
// While the child is mounted the cursor is SHOWN at its position (x=5).
|
||||
const beforeUnmount = writes.join("");
|
||||
expect(beforeUnmount).toContain(showCursorEscape);
|
||||
expect(beforeUnmount).toContain(cursorTo(5));
|
||||
// The last visibility change so far is a SHOW (the active cursor).
|
||||
expect(beforeUnmount.lastIndexOf(showCursorEscape)).toBeGreaterThan(
|
||||
beforeUnmount.lastIndexOf(hideCursorEscape),
|
||||
);
|
||||
|
||||
// Unmount the cursor owner. onScopeDispose -> setCursorPosition(undefined);
|
||||
// the next commit must HIDE the previously-shown cursor.
|
||||
const writesBeforeUnmount = writes.length;
|
||||
showChild.value = false;
|
||||
await nextTick();
|
||||
await app.waitUntilRenderFlush();
|
||||
|
||||
// A HIDE escape must have been emitted on the unmount commit, and it must be
|
||||
// the LAST visibility change (the cursor is now gone — not re-shown).
|
||||
const unmountWrites = writes.slice(writesBeforeUnmount).join("");
|
||||
expect(unmountWrites).toContain(hideCursorEscape);
|
||||
|
||||
const fullOutput = writes.join("");
|
||||
expect(fullOutput.lastIndexOf(hideCursorEscape)).toBeGreaterThan(
|
||||
fullOutput.lastIndexOf(showCursorEscape),
|
||||
);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
test("a synchronous mount throw rethrows the ORIGINAL error even if cursor-restore also throws", async () => {
|
||||
// DEFECT 2b (Codex review): the mount path tears down on a synchronous
|
||||
// throw to re-show the cursor, but teardown's restore (mountedWriter.done()
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
useStdin,
|
||||
useStdout,
|
||||
useStderr,
|
||||
useCursor,
|
||||
usePaste,
|
||||
useTerminalSize,
|
||||
useAnimation,
|
||||
useBoxMetrics,
|
||||
} from "@vue-tui/runtime";
|
||||
|
||||
describe("renderToString", () => {
|
||||
@@ -637,4 +642,102 @@ describe("renderToString", () => {
|
||||
// "[hi]" glyphs are RAW (no bg SGR); only the trailing Box-fill padding is green.
|
||||
expect(renderToString(App, { columns: 100 })).toBe("[hi]" + chalk.bgGreen(" "));
|
||||
});
|
||||
|
||||
// ── B29: renderToString serves the TERMINAL composables with inert no-op
|
||||
// contexts ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// renderToString runs with NO terminal session: it provides no-op AppContext +
|
||||
// StdinContext + a no-op AnimationScheduler (render-to-string.ts:93-96). The
|
||||
// existing suite covers useInput/useApp/useFocus/useFocusManager/useStdin/
|
||||
// useStdout/useStderr. These pin the remaining terminal composables —
|
||||
// useCursor, usePaste, useTerminalSize, useAnimation, useBoxMetrics — so that
|
||||
// rendering a component which CALLS them degrades to inert values instead of
|
||||
// throwing (they must still return a string).
|
||||
describe("terminal composables degrade to no-ops (do not throw)", () => {
|
||||
test("useCursor does not throw in renderToString", () => {
|
||||
const App = defineComponent(() => {
|
||||
// setCursorPosition forwards to the no-op AppContext.setCursorPosition.
|
||||
const { setCursorPosition } = useCursor();
|
||||
setCursorPosition({ x: 2, y: 0 });
|
||||
return () => <Text>with cursor</Text>;
|
||||
});
|
||||
const output = renderToString(App);
|
||||
expect(output).toBe("with cursor");
|
||||
});
|
||||
|
||||
test("usePaste does not throw in renderToString", () => {
|
||||
let pasted = "";
|
||||
const App = defineComponent(() => {
|
||||
// usePaste injects StdinContext (no-op here) and attaches to its
|
||||
// internal_eventEmitter — no terminal session, so the handler never fires.
|
||||
usePaste((text) => {
|
||||
pasted = text;
|
||||
});
|
||||
return () => <Text>with paste</Text>;
|
||||
});
|
||||
const output = renderToString(App);
|
||||
expect(output).toBe("with paste");
|
||||
// The no-op stdin never emits a paste, so the handler stayed inert.
|
||||
expect(pasted).toBe("");
|
||||
});
|
||||
|
||||
test("useTerminalSize does not throw in renderToString", () => {
|
||||
const App = defineComponent(() => {
|
||||
// Resolves dimensions from ctx.stdout (process.stdout in the no-op
|
||||
// context) with the terminal-size fallback; never throws.
|
||||
const { columns, rows } = useTerminalSize();
|
||||
return () => <Text>size {columns.value > 0 && rows.value > 0 ? "ok" : "fallback"}</Text>;
|
||||
});
|
||||
const output = renderToString(App);
|
||||
expect(output).toContain("size");
|
||||
});
|
||||
|
||||
test("useAnimation does not throw in renderToString (frame frozen at 0)", () => {
|
||||
const App = defineComponent(() => {
|
||||
// The no-op AnimationScheduler never ticks, so frame stays 0 and no timer
|
||||
// leaks (subscribe returns an inert unsubscribe).
|
||||
const { frame } = useAnimation({ interval: 50 });
|
||||
return () => <Text>{`frame:${frame.value}`}</Text>;
|
||||
});
|
||||
const output = renderToString(App);
|
||||
expect(output).toBe("frame:0");
|
||||
});
|
||||
|
||||
test("useBoxMetrics does not throw in renderToString", () => {
|
||||
const App = defineComponent(() => {
|
||||
// useBoxMetrics tracks a Box ref via the root layout listener. In the
|
||||
// synchronous renderToString teardown the post-flush measurement may not
|
||||
// have run, so hasMeasured can still be false — the point is it must NOT
|
||||
// throw and the frame must still render.
|
||||
const boxRef = shallowRef(null);
|
||||
const { hasMeasured } = useBoxMetrics(boxRef);
|
||||
return () => (
|
||||
<Box ref={boxRef}>
|
||||
<Text>{hasMeasured.value ? "measured" : "metrics"}</Text>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
const output = renderToString(App, { columns: 40 });
|
||||
expect(output).toContain("metrics");
|
||||
});
|
||||
|
||||
test("all five terminal composables together render to a string without throwing", () => {
|
||||
const App = defineComponent(() => {
|
||||
const { setCursorPosition } = useCursor();
|
||||
setCursorPosition({ x: 1, y: 0 });
|
||||
usePaste(() => {});
|
||||
useTerminalSize();
|
||||
const { frame } = useAnimation({ interval: 30 });
|
||||
const boxRef = shallowRef(null);
|
||||
useBoxMetrics(boxRef);
|
||||
return () => (
|
||||
<Box ref={boxRef}>
|
||||
<Text>{`all:${frame.value}`}</Text>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
const output = renderToString(App, { columns: 40 });
|
||||
expect(output).toContain("all:0");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ describe.sequential("normalizeInterval", () => {
|
||||
test("clamps and defaults", () => {
|
||||
expect(normalizeInterval(50)).toBe(50);
|
||||
expect(normalizeInterval(0)).toBe(1);
|
||||
expect(normalizeInterval(-5)).toBe(1);
|
||||
expect(normalizeInterval(-10)).toBe(1);
|
||||
expect(normalizeInterval(undefined)).toBe(100);
|
||||
expect(normalizeInterval(Number.NaN)).toBe(100);
|
||||
|
||||
Reference in New Issue
Block a user