Files
vue-tui/packages/runtime-tests/integration/components/transform.test.tsx
T
Yunfei He c8cbacfbb8 fix(runtime): render direct text/Newline children of a standalone <Transform> (Ink parity, G58) (#61)
In Ink, <Transform> IS an ink-text host node (Transform.tsx renders
<ink-text internal_transform={fn}>), and the reconciler sets isInsideText
for ink-text. So bare-string and <Newline> children of a standalone
<Transform> render inline within that ink-text, with the transform applied
per line by the Output, and a nested <Text> child is squashed inline too.
The canonical README pattern `<Transform transform={fn}>Hello World</Transform>`
(no inner <Text>) works standalone.

vue-tui's transform host was a non-text yoga carrier: its direct text-leaf
children hit the paint no-op leaf branch and were silently dropped, a
<Newline> inside it rendered as a standalone "text" node (name-based
isInsideText saw no <Text> ancestor), and the SR squash ignored bare
text-leaf children. So `<Transform>ab</Transform>` rendered nothing and
`<Transform>a<Newline/>b</Transform>` applied the transform to empty lines.

Fix (treat a standalone <Transform> as a text context, matching Ink):
- paint.ts: when a transform has no yoga-carrying child, squash its inline
  children to a string and write it like a text node, pushing the transform
  as a per-line Output transformer (applied at paint, never at squash —
  matching Ink). Transforms wrapping a yoga child keep the recursion path.
- yoga.ts: bind a text-style measure func on a standalone transform (its
  squashed children, WITHOUT its own fn — matching Ink measureTextNode), and
  toggle it off/on as yoga children are inserted/removed.
- Newline.ts / Text.ts: treat a <Transform> ancestor as a text context, so a
  <Newline>/<Text> directly inside a transform emits inline virtual-text.
- node-ops.ts: allow bare text-leaf children of a transform (text-context
  check) and mark a standalone transform dirty on inline child change; the
  <Box>-in-<Text> guard still excludes transform (Ink renders Box-in-Transform
  empty, no throw).
- screen-reader.ts: include bare text-leaf/virtual-text children of a
  transform in SR output (Ink squashTextNodes includes #text).

All transform-in-text (G21/G32/G52), <Transform><Text>…</Text></Transform>,
nested transforms, plain <Text>/<Newline>, and Box-in-Transform cases verified
against Ink 7.0.4. Expected outputs (columns=40) confirmed by running the Ink
reference: `<Transform transform={s=>`<${s}>`}>ab</Transform>` + after →
"<ab>\nafter"; `<Transform>a<Newline/>b</Transform>` → "<a>\n<b>" (per-line);
SR → "ab" / "a\nb".

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:07:56 +08:00

573 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { defineComponent, nextTick, shallowRef } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, Newline, Text, Transform } from "@vue-tui/runtime";
test("Transform uppercases descendant text", async () => {
const { lastFrame } = await render(() => (
<Transform transform={(line: string) => line.toUpperCase()}>
<Text>abc</Text>
</Transform>
));
expect(lastFrame()).toContain("ABC");
});
// --- Ink transform tests ---
test("transform children — <Transform> inside <Text>", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Transform transform={(s: string, idx: number) => `[${idx}: ${s}]`}>
<Text>
<Transform transform={(s: string, idx: number) => `{${idx}: ${s}}`}>
<Text>test</Text>
</Transform>
</Text>
</Transform>
)),
{ columns: 100 },
);
expect(lastFrame()).toBe("[0: {0: test}]");
});
test("squash multiple text nodes — <Transform> inside <Text>", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Transform transform={(s: string, idx: number) => `[${idx}: ${s}]`}>
<Text>
<Transform transform={(s: string, idx: number) => `{${idx}: ${s}}`}>
<Text>hello world</Text>
</Transform>
</Text>
</Transform>
)),
{ columns: 100 },
);
expect(lastFrame()).toBe("[0: {0: hello world}]");
});
// G21: a nested <Transform> receives its POSITIONAL sibling index among the
// parent <Text>'s children, matching Ink squash-text-nodes.ts:13,38 (the index
// is the plain loop counter over ALL childNodes, including text-leaf siblings).
test("nested <Transform> as 2nd child of <Text> gets index 1", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Text>
a<Transform transform={(s: string, i: number) => `${s}[${i}]`}>b</Transform>
</Text>
)),
{ columns: 100 },
);
expect(lastFrame()).toBe("ab[1]");
});
test("nested <Transform> as 3rd child of <Text> gets index 2", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Text>
a<Text>b</Text>
<Transform transform={(s: string, i: number) => `${s}[${i}]`}>c</Transform>
</Text>
)),
{ columns: 100 },
);
expect(lastFrame()).toBe("abc[2]");
});
test("sole/first-child nested <Transform> still gets index 0", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Text>
<Transform transform={(s: string, i: number) => `${s}[${i}]`}>a</Transform>b
</Text>
)),
{ columns: 100 },
);
expect(lastFrame()).toBe("a[0]b");
});
// Resolved: Transform nodes are now yoga carriers, so multi-line text
// under a Transform node is properly laid out. See transform-yoga.test.tsx.
test("squash multiple nested text nodes — <Transform> inside <Text>", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Transform transform={(s: string, idx: number) => `[${idx}: ${s}]`}>
<Text>
<Transform transform={(s: string, idx: number) => `{${idx}: ${s}}`}>
hello
<Text> world</Text>
</Transform>
</Text>
</Transform>
)),
{ columns: 100 },
);
expect(lastFrame()).toBe("[0: {0: hello world}]");
});
test("squash empty <Text> nodes — <Transform> inside <Text>", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Transform transform={(s: string) => `[${s}]`}>
<Text>
<Transform transform={(s: string) => `{${s}}`}>
<Text>{[]}</Text>
</Transform>
</Text>
</Transform>
)),
{ columns: 100 },
);
expect(lastFrame()).toBe("");
});
test("<Transform> with undefined children", async () => {
const { lastFrame } = await render(
defineComponent(() => () => <Transform transform={(s: string) => s} />),
{ columns: 100 },
);
expect(lastFrame()).toBe("");
});
test("<Transform> with null children", async () => {
const { lastFrame } = await render(
defineComponent(() => () => <Transform transform={(s: string) => s} />),
{ columns: 100 },
);
expect(lastFrame()).toBe("");
});
test("nested transforms apply inner-first: outer wraps inner result", async () => {
const outer = (s: string) => `(${s})`;
const inner = (s: string) => `{${s}}`;
const App = defineComponent(() => () => (
<Transform transform={outer}>
<Transform transform={inner}>
<Text>x</Text>
</Transform>
</Transform>
));
const { lastFrame } = await render(App, { columns: 100 });
// With prepend [node.transform, ...transformers]:
// Inner: transformers = [inner, outer]
// Apply left-to-right: inner("x") = "{x}", then outer("{x}") = "({x})"
expect(lastFrame()).toBe("({x})");
});
// G32: a <Transform> nested DIRECTLY inside another <Transform> (both inside a
// <Text>) must be recursed into during squash so BOTH transforms run. Ink's
// squash-text-nodes.ts:22-39 recurses generically into any ink-text/ink-virtual-text
// child (a <Transform> renders an ink-text with internal_transform) — inner applied
// first, then outer. Previously vue-tui dropped the inner transform's content
// entirely (silent total content loss + 0-width measurement).
test("nested <Transform> directly inside <Transform> in <Text> — both apply", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Text>
<Transform transform={(s: string) => `[O${s}O]`}>
<Transform transform={(s: string) => `<I${s}I>`}>x</Transform>
</Transform>
</Text>
)),
{ columns: 100 },
);
expect(lastFrame()).toBe("[O<IxI>O]");
});
test("triple-nested <Transform> in <Text> — all three apply to any depth", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Text>
<Transform transform={(s: string) => `A${s}A`}>
<Transform transform={(s: string) => `B${s}B`}>
<Transform transform={(s: string) => `C${s}C`}>x</Transform>
</Transform>
</Transform>
</Text>
)),
{ columns: 100 },
);
expect(lastFrame()).toBe("ABCxCBA");
});
test("nested <Transform>-in-<Transform> reserves correct width (measurement)", async () => {
// The whole box must be wide enough to hold "[O<IxI>O]" (9 cols) — if the inner
// transform were dropped at measure time the box would reserve width 0/3 and the
// sibling marker would overlap. Place a sibling after the text and assert it lands
// at the expected column, proving measurement counted the full transformed width.
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row">
<Text>
<Transform transform={(s: string) => `[O${s}O]`}>
<Transform transform={(s: string) => `<I${s}I>`}>x</Transform>
</Transform>
</Text>
<Text>|</Text>
</Box>
)),
{ columns: 100 },
);
expect(lastFrame()).toBe("[O<IxI>O]|");
});
// G52: Vue materializes a `null`/`false`/`v-if` render as a COMMENT host node
// that occupies a positional slot in `node.children`. React never produces a
// childNode for such children, so Ink's squash loop (squash-text-nodes.ts:13)
// never advances `index` past them. The transform index must therefore advance
// only for children React would have rendered — comment nodes must be skipped.
// Reproduces "A{null}<Transform>B</Transform>" → "A1:B" (NOT "A2:B").
test("G52: null sibling does not shift nested <Transform> index (paint)", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Text>
A{null}
<Transform transform={(s: string, i: number) => `${i}:${s}`}>B</Transform>
</Text>
)),
{ columns: 100 },
);
// "A" = index 0, the null produces a comment that must NOT take a slot, so the
// Transform stays at index 1.
expect(lastFrame()).toBe("A1:B");
});
test("G52 control: no null sibling — <Transform> still gets index 1 (paint)", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Text>
A<Transform transform={(s: string, i: number) => `${i}:${s}`}>B</Transform>
</Text>
)),
{ columns: 100 },
);
// Without the null sibling the Transform is the 2nd child → index 1. Pairing
// this with the case above proves the null is what (wrongly) shifts the index.
expect(lastFrame()).toBe("A1:B");
});
test("G52: multiple null siblings don't shift index (paint)", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Text>
{null}A{null}
{false}
<Transform transform={(s: string, i: number) => `${i}:${s}`}>B</Transform>
</Text>
)),
{ columns: 100 },
);
// Three comment nodes (one before A, two after) must all be skipped: A=0,
// Transform=1.
expect(lastFrame()).toBe("A1:B");
});
test("G52: null sibling does not shift measured width (measurement)", async () => {
// If measurement counted the comment slot the Transform index would differ
// between paint and measure, desyncing reserved width. A trailing sibling
// marker pins the measured width: "A1:B" is 4 cols, so "|" must land at col 4.
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row">
<Text>
A{null}
<Transform transform={(s: string, i: number) => `${i}:${s}`}>B</Transform>
</Text>
<Text>|</Text>
</Box>
)),
{ columns: 100 },
);
expect(lastFrame()).toBe("A1:B|");
});
// G52 (recursive twin): the comment-skip must also apply to the RECURSIVE
// grandchild loop that recurses transform-in-transform (G32's domain). A
// `{null}`/comment inside an OUTER <Transform> must NOT shift an INNER
// <Transform>'s index. Ink iterates the outer transform's real childNodes
// (squash-text-nodes.ts:13); React produces no node for `{null}`, so the inner
// transform stays at index 0. Reproduces
// "A<Transform outer>{null}<Transform inner>B</Transform></Transform>" so the
// inner transform sees index 0 (output "0:B", NOT "1:B").
test("G52 recursive: null inside outer <Transform> does not shift inner index (paint)", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Text>
A
<Transform transform={(s: string) => `O${s}`}>
{null}
<Transform transform={(s: string, i: number) => `${i}:${s}`}>B</Transform>
</Transform>
</Text>
)),
{ columns: 100 },
);
// Inside the outer transform: the {null} comment must not take a slot, so the
// inner transform stays at index 0 → "0:B", wrapped by outer → "O0:B".
expect(lastFrame()).toBe("AO0:B");
});
test("G52 recursive control: no null inside outer <Transform> — inner still index 0 (paint)", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Text>
A
<Transform transform={(s: string) => `O${s}`}>
<Transform transform={(s: string, i: number) => `${i}:${s}`}>B</Transform>
</Transform>
</Text>
)),
{ columns: 100 },
);
// Sole child of the outer transform → index 0. Pairs with the case above to
// prove the null is what (wrongly) shifts the recursive index.
expect(lastFrame()).toBe("AO0:B");
});
test("G52 recursive: null inside outer <Transform> does not shift measured width (measurement)", async () => {
// If measurement counted the comment slot, the inner transform index would
// differ between paint and measure, desyncing reserved width. A trailing
// sibling marker pins the measured width: "AO0:B" is 5 cols, so "|" lands at
// col 5.
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row">
<Text>
A
<Transform transform={(s: string) => `O${s}`}>
{null}
<Transform transform={(s: string, i: number) => `${i}:${s}`}>B</Transform>
</Transform>
</Text>
<Text>|</Text>
</Box>
)),
{ columns: 100 },
);
expect(lastFrame()).toBe("AO0:B|");
});
// G58: a STANDALONE <Transform> (NOT wrapped in <Text>) with DIRECT bare-string
// children must render that text with the transform applied — matching Ink, where
// <Transform> IS an ink-text host node (Transform.tsx renders <ink-text
// internal_transform={fn}>), so bare-string children render inline within that
// ink-text and the transform applies per line via Output. Previously vue-tui's
// transform host was a non-text yoga carrier whose direct text-leaf children hit
// the paint no-op branch, so the content was silently dropped. This is the
// canonical Ink README pattern `<Transform transform={fn}>Hello World</Transform>`.
test("G58: standalone <Transform> with bare-string children applies the transform", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<>
<Transform transform={(s: string) => `<${s}>`}>ab</Transform>
<Text>after</Text>
</>
)),
{ columns: 40 },
);
// Ink reference (v7.0.4, columns=40): "<ab>\nafter".
expect(lastFrame()).toBe("<ab>\nafter");
});
// G58 (Newline sub-symptom): a <Newline> directly inside a standalone
// <Transform> (no <Text> wrapper) must emit an inline line break inside the
// transform's text, and the transform applies PER LINE (via Output). Ink
// reference: `<Transform transform={s=>`<${s}>`}>a<Newline/>b</Transform>` →
// "<a>\n<b>" (transform applied to each line "a" and "b" separately). Previously
// the <Newline> rendered as a standalone "text" yoga node (name-based
// isInsideText saw no <Text> ancestor) and the bare strings were dropped, so the
// transform was applied to EMPTY lines giving "<>\n<>".
test("G58: standalone <Transform> with <Newline> applies the transform per line", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Transform transform={(s: string) => `<${s}>`}>
a<Newline />b
</Transform>
)),
{ columns: 40 },
);
// Ink reference (v7.0.4, columns=40): "<a>\n<b>".
expect(lastFrame()).toBe("<a>\n<b>");
});
test("G58: standalone <Transform> with <Newline> and identity transform keeps both lines", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Transform transform={(s: string) => s}>
a<Newline />b
</Transform>
)),
{ columns: 40 },
);
// Ink reference (v7.0.4): identity transform → "a\nb".
expect(lastFrame()).toBe("a\nb");
});
test("transform with multiple lines", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Transform transform={(s: string, idx: number) => `[${idx}: ${s}]`}>
<Text>{"hello world\ngoodbye world"}</Text>
</Transform>
)),
{ columns: 100 },
);
expect(lastFrame()).toBe("[0: hello world]\n[1: goodbye world]");
});
// G58 follow-up — MUST-FIX 1: an INLINE <Transform> (inside a <Text>) does NOT
// own the <Text>'s yoga measure func, so a reactive change to its child text
// must dirty the ENCLOSING <Text> (the measure owner), not stop at the inline
// transform. Ink climbs to the nearest node WITH a yoga node (dom.ts
// findClosestYogaNode) — an inline transform renders as ink-virtual-text which
// has NO yoga node, so the climb passes through it to the ink-text. Previously
// the dirty-bubble stopped at the inline transform, leaving the <Text> stale, so
// "b"→"long" only repainted within the old (narrow) measured width.
test("G58 MF1: reactive update inside inline <Transform> dirties enclosing <Text> (width grows)", async () => {
const s = shallowRef("b");
const { lastFrame, waitUntilRenderFlush } = await render(
defineComponent(() => () => (
<Box flexDirection="row">
<Text>
a<Transform transform={(x: string) => `<${x}>`}>{s.value}</Transform>
</Text>
<Text>|</Text>
</Box>
)),
{ columns: 100 },
);
// Initial: measured width of "a<b>" = 4, sibling "|" lands at col 4.
expect(lastFrame()).toBe("a<b>|");
s.value = "long";
await nextTick();
await waitUntilRenderFlush();
// Ink reference: the enclosing <Text> remeasures "a<long>" (7 cols) so "|"
// lands at col 7 and the full transformed text is visible. RED on cb68dd2
// produced "a<lo|" (stale layout — width never grew).
expect(lastFrame()).toBe("a<long>|");
});
// G58 follow-up — MUST-FIX 2: a <Transform> nested DIRECTLY inside another
// STANDALONE <Transform> (no <Text> wrapper) is INLINE — it must NOT be inserted
// as a yoga child of the outer transform. Instead the outer (standalone) measures
// the whole squashed text, applying the inner CHILD transform's fn (Ink's
// squashTextNodes applies child internal_transform), but NOT the outer's own fn
// (that runs at Output paint). So the reserved width = width of "<IxI>" (5), and
// the painted "[O<IxI>O]" overflows past that — a sibling overwrites the overflow.
test("G58 MF2: nested <Transform>-in-standalone-<Transform> reserves inner-applied width", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row">
<Transform transform={(s: string) => `[O${s}O]`}>
<Transform transform={(s: string) => `<I${s}I>`}>x</Transform>
</Transform>
<Text>|</Text>
</Box>
)),
{ columns: 100 },
);
// Ink reference (v40b3a75): "[O<Ix|>O]" — measured width = 5 ("<IxI>", inner
// child fn applied, outer fn not), so "|" lands at col 5 and overwrites the
// outer-applied overflow. RED on cb68dd2 produced "[|<IxI>O]" (inner transform
// became a yoga child → width 0 reserved, "|" at col 1).
expect(lastFrame()).toBe("[O<Ix|>O]");
});
// G58 follow-up — STRUCTURAL-DIRTY: inserting/removing an inline child under a
// plain <Text> (or virtual-text) parent must dirty the enclosing <Text>'s yoga
// measure owner so the layout re-measures. Ink marks the parent dirty in
// appendChildNode / insertBeforeNode / removeChildNode for ink-text AND
// ink-virtual-text parents (dom.ts:132,165,185), then climbs to the closest yoga
// node (dom.ts:248). The previous vue-tui code only dirtied when the parent was a
// <Transform>, so a STRUCTURAL change (v-if inserting/removing an inline
// <Transform>, or inserting a bare-text child) under a <Text> left STALE LAYOUT.
test("structural dirty: v-if inserting an inline <Transform> into <Text> grows width", async () => {
const show = shallowRef(false);
const { lastFrame, waitUntilRenderFlush } = await render(
defineComponent(() => () => (
<Box flexDirection="row">
<Text>
a{show.value ? <Transform transform={(s: string) => `<${s}>`}>long</Transform> : null}
</Text>
<Text>|</Text>
</Box>
)),
{ columns: 100 },
);
// Initial: only "a" → width 1, sibling "|" at col 1.
expect(lastFrame()).toBe("a|");
show.value = true;
await nextTick();
await waitUntilRenderFlush();
// Ink reference (v40b3a75): the <Text> remeasures "a<long>" (7 cols) so "|"
// lands at col 7. RED before the fix: stayed "a|" (stale layout — the
// structural insert never dirtied the enclosing <Text>).
expect(lastFrame()).toBe("a<long>|");
});
test("structural dirty: v-if removing an inline <Transform> from <Text> shrinks width", async () => {
const show = shallowRef(true);
const { lastFrame, waitUntilRenderFlush } = await render(
defineComponent(() => () => (
<Box flexDirection="row">
<Text>
a{show.value ? <Transform transform={(s: string) => `<${s}>`}>long</Transform> : null}
</Text>
<Text>|</Text>
</Box>
)),
{ columns: 100 },
);
// Initial: "a<long>" → width 7, sibling "|" at col 7.
expect(lastFrame()).toBe("a<long>|");
show.value = false;
await nextTick();
await waitUntilRenderFlush();
// Ink reference (v40b3a75): the <Text> remeasures "a" (1 col) so "|" lands at
// col 1. RED before the fix: stayed "a<long>|" with stale width (painted
// "a |" — trailing blanks from the un-shrunk measured width).
expect(lastFrame()).toBe("a|");
});
test("structural dirty: inserting a bare-text child into <Text> grows width", async () => {
const show = shallowRef(false);
const { lastFrame, waitUntilRenderFlush } = await render(
defineComponent(() => () => (
<Box flexDirection="row">
<Text>a{show.value ? "long" : null}</Text>
<Text>|</Text>
</Box>
)),
{ columns: 100 },
);
// Initial: only "a" → width 1, sibling "|" at col 1.
expect(lastFrame()).toBe("a|");
show.value = true;
await nextTick();
await waitUntilRenderFlush();
// Ink reference (v40b3a75): the <Text> remeasures "along" (5 cols) so "|"
// lands at col 5. RED before the fix: stayed "a|" (stale layout).
expect(lastFrame()).toBe("along|");
});
// G58 follow-up — SHOULD-FIX: a standalone <Transform> is a text context (Ink
// models it as ink-text → isInsideText), so a <Box> directly inside it must
// throw the SAME dev error as a <Box> inside a <Text>. Ink reconciler.ts:205
// throws `<Box> can't be nested inside <Text> component` from createInstance.
test("G58 should-fix: <Box> directly inside standalone <Transform> throws like <Box> in <Text>", async () => {
await expect(
render(
defineComponent(() => () => (
<Transform transform={(s: string) => s}>
<Box>
<Text>hi</Text>
</Box>
</Transform>
)),
{ columns: 40 },
),
).rejects.toThrow("<Box> can’t be nested inside <Text> component");
});