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:
Yunfei He
2026-06-03 03:42:33 +08:00
committed by GitHub
parent 5f55b59b70
commit c8c2c4a32d
9 changed files with 728 additions and 2 deletions
@@ -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");
});