test(runtime): lock reconciler, measure, flex, overflow, build-output (Ink parity) (#116)
Final round-2 test-only batch (behaviors already at parity with Ink reconciler.tsx,
measure-text.tsx, flex-*.tsx, overflow.tsx, build-output.ts):
- build-output: every package.json export target resolves on disk (runtime/cli/testing)
+ the .d.mts declaration sibling for the typed libraries (runtime/testing, not cli).
- reconciler: keyed insert-between [a,c]→[a,b,c]; replace a colored <Text> child with a
plain string; setElementText A→B + the text-context guard; marginLeft removal reset.
- measure: empty <Text> contributes height 0 in a column; non-zero left (marginLeft=5 →
5,1); measureTextNatural trailing/only-newline heights.
- flex: alignSelf='auto' == default + alignSelf removal resets to AUTO; the two
space-around known-yoga-bug cases converted from test.skip to test.fails (they assert
the DESIRED output and flip to a real failure if yoga ever fixes the bug); the documented
flexDirection/flexWrap removal-reset divergence (was comment-only) now has a visual lock.
- overflow: out-of-bounds writes produce Ink's exact clipped frame (sparse past-width cell,
filtered hole) — tightened from toBeDefined().
- components: inline + top-level non-empty fragment in <Text>; the previously-skipped
ST-terminated OSC-8 hyperlink hard-wrap now passes ('abcde\nfghij') — un-skipped as a lock.
Codex-reviewed GENUINE.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -186,6 +186,66 @@ test("remeasure text when text nodes are changed", async () => {
|
||||
expect(lastFrame()).toBe("abcx");
|
||||
});
|
||||
|
||||
// Ink reconciler.tsx:328-344 / components.tsx:715-731 ("replace child node with
|
||||
// text"): an outer <Text> whose only child is a colored <Text> is replaced across
|
||||
// a rerender by a plain string. The frame must flip from the colored "test" to a
|
||||
// bare "x" — the nested styled child node is fully torn down and the text-leaf
|
||||
// takes its place.
|
||||
test("replace a colored <Text> child with a plain string across a rerender", async () => {
|
||||
const replace = shallowRef(false);
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => (
|
||||
<Text>{replace.value ? "x" : <Text color="green">test</Text>}</Text>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
// Before: the nested green child is the only content → chalk.green("test").
|
||||
expect(lastFrame()).toBe(chalk.green("test"));
|
||||
|
||||
replace.value = true;
|
||||
await nextTick();
|
||||
// After: the styled child is gone, replaced by a plain text-leaf → "x".
|
||||
expect(lastFrame()).toBe("x");
|
||||
});
|
||||
|
||||
// Locks the node-ops setElementText host op + remeasure: flipping <Text>A</Text>
|
||||
// to <Text>B</Text> goes through Vue's setElementText fast path (a single static
|
||||
// text child), which clears the leaf, inserts the new one, and dirties the text
|
||||
// measure owner so yoga remeasures. The frame must update A -> B.
|
||||
test("setElementText path updates A to B and remeasures", async () => {
|
||||
const flip = shallowRef(false);
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => <Text>{flip.value ? "B" : "A"}</Text>),
|
||||
{ columns: 100 },
|
||||
);
|
||||
expect(lastFrame()).toBe("A");
|
||||
|
||||
flip.value = true;
|
||||
await nextTick();
|
||||
expect(lastFrame()).toBe("B");
|
||||
});
|
||||
|
||||
// The text-context guard fires only for NON-EMPTY raw text directly inside a
|
||||
// <Box>. Vue materializes the empty branch of `cond ? 'oops' : ''` as an empty
|
||||
// text-leaf (a fragment anchor), which node-ops insert() deliberately skips — so
|
||||
// the empty case renders "" without throwing, while the non-empty "oops" throws.
|
||||
test("<Box>{cond ? 'oops' : ''}</Box> throws for the non-empty branch only", async () => {
|
||||
// `cond` drives the ternary at runtime (a literal true/false here is flagged as
|
||||
// a constant condition by the linter; shallowRef keeps the exact two-branch shape).
|
||||
const oopsCond = shallowRef(true);
|
||||
const oops = defineComponent(() => () => <Box>{oopsCond.value ? "oops" : ""}</Box>);
|
||||
await expect(render(oops)).rejects.toThrow(
|
||||
/^Text string "oops" must be rendered inside <Text> component$/,
|
||||
);
|
||||
|
||||
// The empty branch is a skipped fragment anchor — no text reaches the Box, so
|
||||
// it renders an empty frame without tripping the guard.
|
||||
const emptyCond = shallowRef(false);
|
||||
const empty = defineComponent(() => () => <Box>{emptyCond.value ? "oops" : ""}</Box>);
|
||||
const { lastFrame } = await render(empty, { columns: 100 });
|
||||
expect(lastFrame()).toBe("");
|
||||
});
|
||||
|
||||
test("text with content 'constructor' wraps correctly", async () => {
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => <Text>constructor</Text>),
|
||||
@@ -617,6 +677,35 @@ test("number", async () => {
|
||||
expect(lastFrame()).toBe("1");
|
||||
});
|
||||
|
||||
// Ink components.tsx:80-88,363-372: a fragment nested inline inside <Text> is
|
||||
// flattened into the surrounding text run, so "Hello " + <>World</> squashes to
|
||||
// "Hello World" (the fragment contributes no layout of its own).
|
||||
test("inline fragment inside <Text> flattens into the text run", async () => {
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => (
|
||||
<Text>
|
||||
Hello <>World</>
|
||||
</Text>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
expect(lastFrame()).toBe("Hello World");
|
||||
});
|
||||
|
||||
// A top-level fragment wrapping a single <Text> renders as that text — the
|
||||
// fragment is transparent at the root, matching Ink's root-fragment handling.
|
||||
test("top-level fragment wrapping a <Text> renders the text", async () => {
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => (
|
||||
<>
|
||||
<Text>Hello World</Text>
|
||||
</>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
expect(lastFrame()).toBe("Hello World");
|
||||
});
|
||||
|
||||
test("do not wrap text with BEL-terminated OSC hyperlinks", async () => {
|
||||
const hyperlink = "\x1b]8;;https://example.com\x07Click here\x1b]8;;\x07";
|
||||
const output = renderToString(
|
||||
@@ -739,8 +828,11 @@ test("hard-wrap text containing an inline erase-line (\\x1b[2K) sequence across
|
||||
expect(output).toBe(`${line1}\n${line2}`);
|
||||
});
|
||||
|
||||
// Feature gap: ST-terminated OSC sequences not handled correctly in wrap-ansi path
|
||||
test.skip("hard-wrap single-word ST-terminated OSC hyperlink", async () => {
|
||||
// ST-terminated (ESC\) OSC-8 hyperlink, single long word, hard-wrapped at width 5.
|
||||
// The wrap protection covers both OSC terminators (BEL and ST), so the word breaks
|
||||
// at the cell boundary exactly like its BEL-terminated sibling above: "abcde\nfghij".
|
||||
// Verified against Ink v7.0.4 (un-skipped — vue produces Ink's identical output).
|
||||
test("hard-wrap single-word ST-terminated OSC hyperlink", async () => {
|
||||
const hyperlink = "\x1b]8;;https://example.com\x1b\\abcdefghij\x1b]8;;\x1b\\";
|
||||
const output = renderToString(
|
||||
defineComponent(() => () => (
|
||||
|
||||
Reference in New Issue
Block a user