From 48558c5af6bfbdc427e5e1a54c017aea0bddec01 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 1 Jun 2026 03:28:35 +0800 Subject: [PATCH] test(runtime): lock reconciler, measure, flex, overflow, build-output (Ink parity) (#116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 child with a plain string; setElementText A→B + the text-context guard; marginLeft removal reset. - measure: empty 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 ; 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) --- .../integration/build-output.test.ts | 92 ++++++++++++++++++ .../components/conditional-list.test.tsx | 30 ++++++ .../integration/components/text.test.tsx | 96 ++++++++++++++++++- .../composables/use-box-metrics.test.tsx | 27 ++++++ .../layout/flex-align-self.test.tsx | 56 ++++++++++- .../layout/flex-justify-content.test.tsx | 17 ++-- .../integration/layout/overflow.test.tsx | 45 ++++++++- .../integration/layout/prop-reset.test.tsx | 74 ++++++++++++++ .../layout/text-measure-parity.test.tsx | 18 ++++ .../runtime/src/host/text-measure.test.ts | 14 +++ 10 files changed, 457 insertions(+), 12 deletions(-) create mode 100644 packages/runtime-tests/integration/build-output.test.ts diff --git a/packages/runtime-tests/integration/build-output.test.ts b/packages/runtime-tests/integration/build-output.test.ts new file mode 100644 index 0000000..67a8205 --- /dev/null +++ b/packages/runtime-tests/integration/build-output.test.ts @@ -0,0 +1,92 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vite-plus/test"; + +// Mirror of Ink's test/build-output.ts: walk each published package's +// package.json `exports` map and assert every string target actually exists in +// the built `dist/`. This is an INTEGRATION test run after `vp run build` +// (`vp run ci` builds first); a missing target is a real packaging gap that +// would ship a broken `import`/`require`. + +const here = path.dirname(fileURLToPath(import.meta.url)); +// integration/ -> runtime-tests/ -> packages/ +const packagesDir = path.resolve(here, "..", ".."); + +type Exports = string | { [condition: string]: Exports }; + +/** + * Collect every string leaf reachable from an `exports` value, descending + * through nested condition objects (`import`/`require`/`types`/...) and named + * subpaths (`.`, `./internal`, `./package.json`). Each leaf is the literal path + * the resolver would hand back, so each must exist on disk. + */ +function collectTargets(value: Exports, out: string[] = []): string[] { + if (typeof value === "string") { + out.push(value); + return out; + } + for (const nested of Object.values(value)) { + collectTargets(nested, out); + } + return out; +} + +/** + * For a TYPED library entry, the declaration sibling sits next to the runtime + * `.mjs` with a `.d.mts` extension (tsdown/`vp pack` emits `index.mjs` + + * `index.d.mts`). We assert it explicitly for runtime/testing — cli has no + * public type surface (no `index.d.mts`), so it is excluded. + */ +function declarationSibling(mjsTarget: string): string { + return mjsTarget.replace(/\.mjs$/, ".d.mts"); +} + +type PackageCase = { + /** Directory name under packages/ */ + dir: string; + /** Whether this package ships a public type surface (.d.mts siblings). */ + typed: boolean; +}; + +const cases: PackageCase[] = [ + { dir: "runtime", typed: true }, + { dir: "cli", typed: false }, + { dir: "testing", typed: true }, +]; + +describe("build output: package.json exports resolve to built files", () => { + for (const { dir, typed } of cases) { + const pkgDir = path.join(packagesDir, dir); + const pkgJsonPath = path.join(pkgDir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")) as { + name: string; + exports: Exports; + }; + + test(`${pkg.name}: every exports target exists`, () => { + const targets = collectTargets(pkg.exports); + // Sanity: the map must have at least the root entry. + expect(targets.length).toBeGreaterThan(0); + for (const target of targets) { + const abs = path.join(pkgDir, target); + expect(fs.existsSync(abs), `${pkg.name} exports target missing: ${target}`).toBe(true); + } + }); + + if (typed) { + test(`${pkg.name}: each .mjs library export has a .d.mts declaration sibling`, () => { + const mjsTargets = collectTargets(pkg.exports).filter( + (t) => t.endsWith(".mjs") && t.startsWith("./dist/"), + ); + // A typed library must expose at least one runtime entry. + expect(mjsTargets.length).toBeGreaterThan(0); + for (const mjs of mjsTargets) { + const dts = declarationSibling(mjs); + const abs = path.join(pkgDir, dts); + expect(fs.existsSync(abs), `${pkg.name} missing declaration sibling: ${dts}`).toBe(true); + } + }); + } + } +}); diff --git a/packages/runtime-tests/integration/components/conditional-list.test.tsx b/packages/runtime-tests/integration/components/conditional-list.test.tsx index d6f01f5..86e03e1 100644 --- a/packages/runtime-tests/integration/components/conditional-list.test.tsx +++ b/packages/runtime-tests/integration/components/conditional-list.test.tsx @@ -62,6 +62,36 @@ test("keyed v-for reorder renders in new order", async () => { expect(lines[2]).toContain("item-2"); }); +// Ink reconciler.tsx:154-212 ("insert child between other children"): with +// STABLE keys, inserting a new child between two existing ones must place it in +// the right slot — not append, not reorder. Mirrors Ink's keyed [a,c] -> [a,b,c]. +test("keyed insert between existing children places B between A and C", async () => { + const insert = shallowRef(false); + const App = defineComponent( + () => () => + insert.value ? ( + + A + B + C + + ) : ( + + A + C + + ), + ); + + const { lastFrame } = await render(App, { columns: 10 }); + expect(lastFrame()).toBe("A\nC"); + + insert.value = true; + await nextTick(); + // B lands in the middle (stable keys), so the column order is A / B / C. + expect(lastFrame()).toBe("A\nB\nC"); +}); + test("repeated list shuffles don't crash", async () => { const items = shallowRef([1, 2, 3, 4, 5]); const App = defineComponent(() => { diff --git a/packages/runtime-tests/integration/components/text.test.tsx b/packages/runtime-tests/integration/components/text.test.tsx index 27f322d..b4654e0 100644 --- a/packages/runtime-tests/integration/components/text.test.tsx +++ b/packages/runtime-tests/integration/components/text.test.tsx @@ -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 whose only child is a colored 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 child with a plain string across a rerender", async () => { + const replace = shallowRef(false); + const { lastFrame } = await render( + defineComponent(() => () => ( + {replace.value ? "x" : test} + )), + { 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 A +// to B 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(() => () => {flip.value ? "B" : "A"}), + { 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 +// . 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("{cond ? 'oops' : ''} 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(() => () => {oopsCond.value ? "oops" : ""}); + await expect(render(oops)).rejects.toThrow( + /^Text string "oops" must be rendered inside 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(() => () => {emptyCond.value ? "oops" : ""}); + const { lastFrame } = await render(empty, { columns: 100 }); + expect(lastFrame()).toBe(""); +}); + test("text with content 'constructor' wraps correctly", async () => { const { lastFrame } = await render( defineComponent(() => () => constructor), @@ -617,6 +677,35 @@ test("number", async () => { expect(lastFrame()).toBe("1"); }); +// Ink components.tsx:80-88,363-372: a fragment nested inline inside 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 flattens into the text run", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + Hello <>World + + )), + { columns: 100 }, + ); + expect(lastFrame()).toBe("Hello World"); +}); + +// A top-level fragment wrapping a single renders as that text — the +// fragment is transparent at the root, matching Ink's root-fragment handling. +test("top-level fragment wrapping a renders the text", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + <> + Hello World + + )), + { 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(() => () => ( diff --git a/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx b/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx index b77416d..6494833 100644 --- a/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx +++ b/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx @@ -47,6 +47,33 @@ describe("useBoxMetrics", () => { expect(pos.value.t).toBe(0); }); + // Ink use-box-metrics.tsx:37-61 ("returns correct position"): a tracked box on + // the SECOND row of a column with marginLeft=5 must report left=5 (the margin) + // and top=1 (the row below the first line). + test("returns non-zero left/top for an offset box on the second row", async () => { + const pos = shallowRef({ l: -1, t: -1 }); + const App = defineComponent(() => { + const boxRef = ref(null); + const metrics = useBoxMetrics(boxRef); + watchEffect(() => { + pos.value = { l: metrics.left.value, t: metrics.top.value }; + }); + return () => ( + + first line + + tracked + + + ); + }); + await render(App, { columns: 100 }); + await nextTick(); + // marginLeft=5 → left=5; second row → top=1. + expect(pos.value.l).toBe(5); + expect(pos.value.t).toBe(1); + }); + test("hasMeasured starts false", async () => { let measured = false; const App = defineComponent(() => { diff --git a/packages/runtime-tests/integration/layout/flex-align-self.test.tsx b/packages/runtime-tests/integration/layout/flex-align-self.test.tsx index 1e0d802..e1b909c 100644 --- a/packages/runtime-tests/integration/layout/flex-align-self.test.tsx +++ b/packages/runtime-tests/integration/layout/flex-align-self.test.tsx @@ -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, Newline } from "@vue-tui/runtime"; @@ -117,6 +117,60 @@ test("row - align self stretch", async () => { expect(lastFrame({ trimLines: true })).toBe("┌─┐\n│X│\n│ │\n│ │\n└─┘"); }); +// Ink styles.ts:580 maps alignSelf="auto" to yoga's ALIGN_AUTO, which is the +// default — so an explicit alignSelf="auto" must render identically to no +// alignSelf at all (the child stays at the cross-axis start, left-aligned here). +test("column - alignSelf='auto' equals the no-alignSelf default", async () => { + const { lastFrame: withAuto } = await render( + defineComponent(() => () => ( + + + Test + + + )), + { columns: 100 }, + ); + const { lastFrame: withoutAlign } = await render( + defineComponent(() => () => ( + + + Test + + + )), + { columns: 100 }, + ); + // Both stay at the start (left) — "auto" is the default, not center/end. + expect(withAuto({ trimLines: true })).toBe("Test"); + expect(withAuto({ trimLines: true })).toBe(withoutAlign({ trimLines: true })); +}); + +// G19: removing alignSelf must reset to yoga's ALIGN_AUTO default (per the +// declarative contract render = f(current props)). The child starts flex-end +// (right-aligned in a width-10 column) and reverts to the unaligned default +// (left) once alignSelf is removed. +test("column - alignSelf removal resets to AUTO default (G19)", async () => { + const aligned = shallowRef(true); + const { lastFrame } = await render( + defineComponent(() => () => ( + + + Test + + + )), + { columns: 100 }, + ); + // flex-end pushes "Test" to the right edge of the width-10 column. + expect(lastFrame({ trimLines: true })).toBe(" Test"); + + aligned.value = false; + await nextTick(); + // After removal, alignSelf resets to AUTO → back to the left (unaligned default). + expect(lastFrame({ trimLines: true })).toBe("Test"); +}); + test("row - align self baseline", async () => { const { lastFrame } = await render( defineComponent(() => () => ( diff --git a/packages/runtime-tests/integration/layout/flex-justify-content.test.tsx b/packages/runtime-tests/integration/layout/flex-justify-content.test.tsx index 2a9da34..5d2bd77 100644 --- a/packages/runtime-tests/integration/layout/flex-justify-content.test.tsx +++ b/packages/runtime-tests/integration/layout/flex-justify-content.test.tsx @@ -80,9 +80,13 @@ test("row - space evenly two text nodes", async () => { expect(lastFrame({ trimLines: true })).toBe(" A B"); }); -// Yoga has a bug, where first child in a container with space-around doesn't have -// the correct X coordinate and measure function is used on that child node -test.skip("row - align two text nodes with equal space around them — known Yoga issue", async () => { +// Yoga has a bug where the first child in a space-around container gets the wrong +// X coordinate (its measure func runs on a mis-placed node). Mirrors Ink's +// test.failing for the same input: we assert the DESIRED output " A B" and mark +// the test `fails`, so it RUNS and passes only WHILE the shared yoga bug persists. +// If yoga ever fixes this, the test flips to passing-unexpectedly and fails the +// run — flagging that this comment (and Ink's matching divergence note) is stale. +test.fails("row - align two text nodes with equal space around them — known Yoga issue", async () => { const { lastFrame } = await render( defineComponent(() => () => ( @@ -144,9 +148,10 @@ test("column - align two text nodes on the edges", async () => { expect(lastFrame({ trimLines: true })).toBe("A\n\n\nB"); }); -// Yoga has a bug, where first child in a container with space-around doesn't have -// the correct X coordinate and measure function is used on that child node -test.skip("column - align two text nodes with equal space around them — known Yoga issue", async () => { +// Same yoga space-around first-child bug on the column axis. test.fails asserts +// the DESIRED "\nA\n\nB\n" and passes only while the bug persists (see the row +// case above for the rationale). Mirrors Ink's test.failing. +test.fails("column - align two text nodes with equal space around them — known Yoga issue", async () => { const { lastFrame } = await render( defineComponent(() => () => ( diff --git a/packages/runtime-tests/integration/layout/overflow.test.tsx b/packages/runtime-tests/integration/layout/overflow.test.tsx index 73058a9..48f2072 100644 --- a/packages/runtime-tests/integration/layout/overflow.test.tsx +++ b/packages/runtime-tests/integration/layout/overflow.test.tsx @@ -575,13 +575,52 @@ test("nested overflow", async () => { expect(lastFrame({ trimLines: true })).toBe("AA\nBB\nXXXX\nYYYY\n"); }); -test("out of bounds writes do not crash", async () => { - // Just verify it renders without throwing; exact output varies by terminal width +/** Build a round-border box of the given inner width / total height, like + * boxen('', { width, height, borderStyle: 'round' }). All lines are exactly + * `width` columns wide (the border glyphs included). */ +function roundBox(width: number, height: number): string { + const inner = width - 2; + const top = `╭${"─".repeat(inner)}╮`; + const bottom = `╰${"─".repeat(inner)}╯`; + const middle = `│${" ".repeat(inner)}│`; + const lines = [top]; + for (let i = 0; i < height - 2; i++) lines.push(middle); + lines.push(bottom); + return lines.join("\n"); +} + +// Ink overflow.tsx:509-528 ("out of bounds writes do not crash"): a width-12, +// height-10 round-border box rendered into a 10-column terminal. The box is 2 +// columns WIDER than the terminal, so the renderer clips. Ink keeps the past-width +// right-border glyph as a sparse cell while the over-width interior column (col 10) +// is dropped — so each middle row collapses from "│"+10 spaces+"│" to "│"+9 +// spaces+"│" (the col-10 hole filtered out, the right "│" surviving at col 11). The +// top/bottom border rows keep their full 12-column run. Mirrors Ink's exact frame +// (it slices middle lines to `line.slice(0,10) + line[11]`). +test("out of bounds writes do not crash (exact clipped frame)", async () => { const { lastFrame } = await render( defineComponent(() => () => ), { columns: 10 }, ); - expect(lastFrame({ trimLines: true })).toBeDefined(); + + const expected = roundBox(12, 10) + .split("\n") + .map((line, index) => + index === 0 || index === 9 + ? line // top/bottom borders survive at full 12-column width + : `${line.slice(0, 10)}${line[11] ?? ""}`, + ) + .join("\n"); + + // Sanity-check the constructed expectation matches the documented shape: + // full borders top/bottom, "│" + 9 spaces + "│" (11 cols) middle rows. + const expectedLines = expected.split("\n"); + expect(expectedLines[0]).toBe(`╭${"─".repeat(10)}╮`); + expect(expectedLines[9]).toBe(`╰${"─".repeat(10)}╯`); + expect(expectedLines[1]).toBe(`│${" ".repeat(9)}│`); + + // lastFrame() (no trim) is byte-exact — assert the full clipped frame. + expect(lastFrame()).toBe(expected); }); // --- absolute overlay wide glyph clipping (issue #10) --- diff --git a/packages/runtime-tests/integration/layout/prop-reset.test.tsx b/packages/runtime-tests/integration/layout/prop-reset.test.tsx index 541ba52..0721347 100644 --- a/packages/runtime-tests/integration/layout/prop-reset.test.tsx +++ b/packages/runtime-tests/integration/layout/prop-reset.test.tsx @@ -27,6 +27,26 @@ test("reset prop when it's removed from the element", async () => { expect(lastFrame()).toBe("x"); }); +// Ink reconciler.tsx:76-96 ("remove style prop from intrinsic node"): removing +// marginLeft must reset the yoga margin edge to 0, not keep the stale 1. The +// frame flips from " X" (1-col left margin) to "X". +test("reset marginLeft to 0 on removal (Ink reconciler parity)", async () => { + const withStyle = shallowRef(true); + + const Dynamic = defineComponent(() => () => ( + + X + + )); + + const { lastFrame } = await render(Dynamic, { columns: 100 }); + expect(lastFrame()).toBe(" X"); + + withStyle.value = false; + await nextTick(); + expect(lastFrame()).toBe("X"); +}); + // G19: dynamic removal of yoga style props must reset to yoga/Ink default, not keep stale value. test("reset marginTop to 0 on removal (G19)", async () => { @@ -350,3 +370,57 @@ test("reset position to relative on removal (G19)", async () => { // After reset to relative (position prop removed), A re-enters flow above B expect(lastFrame({ trimLines: true })).toBe("A\nB\n"); }); + +// G19 (the documented flexDirection/flexWrap divergence — previously only a code +// comment, no test): removing flexDirection / flexWrap must reset to the yoga +// default (row / nowrap), per the declarative contract render = f(current props). +// Ink's applyStyles persists the last value on a removed prop; vue-tui resets to +// the default. These lock the VISUAL effect of the reset. + +test("reset flexDirection to row (default) on removal (G19 divergence)", async () => { + // While flexDirection="column" the two texts stack (A over B); after removal the + // box reverts to the row default and they sit side-by-side (AB). + const col = shallowRef(true); + + const Dynamic = defineComponent(() => () => ( + + A + B + + )); + + const { lastFrame } = await render(Dynamic, { columns: 100 }); + expect(lastFrame({ trimLines: true })).toBe("A\nB"); + + col.value = false; + await nextTick(); + expect(lastFrame({ trimLines: true })).toBe("AB"); +}); + +test("reset flexWrap to nowrap (default) on removal (G19 divergence)", async () => { + // Width-4 container with three width-2 boxes. While flexWrap="wrap" they wrap to + // two rows (AA BB on row 0, CC on row 1); after removal the box reverts to the + // nowrap default, overflowing all three onto a single row (AABBCC). + const wrap = shallowRef(true); + + const Dynamic = defineComponent(() => () => ( + + + AA + + + BB + + + CC + + + )); + + const { lastFrame } = await render(Dynamic, { columns: 100 }); + expect(lastFrame({ trimLines: true })).toBe("AABB\nCC"); + + wrap.value = false; + await nextTick(); + expect(lastFrame({ trimLines: true })).toBe("AABBCC"); +}); diff --git a/packages/runtime-tests/integration/layout/text-measure-parity.test.tsx b/packages/runtime-tests/integration/layout/text-measure-parity.test.tsx index abc65af..e694b4c 100644 --- a/packages/runtime-tests/integration/layout/text-measure-parity.test.tsx +++ b/packages/runtime-tests/integration/layout/text-measure-parity.test.tsx @@ -37,3 +37,21 @@ test("narrow truncate matches Ink's re-measure-then-truncate behavior", async () expect(lines[0]).toBe("x"); expect(lines[1]).toBe("…"); }); + +// Ink measure-text.tsx returns height 0 for empty text (text.length === 0), and +// the yoga measure func short-circuits raw === "" to {width:0,height:0}. So an +// empty in a column contributes NO row — the only visible line is the +// non-empty sibling, with NO leading blank line above it. +test("empty in a column contributes height 0 (no leading blank line)", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + {""} + hello + + )), + { columns: 100 }, + ); + // Single line "hello" — the empty text adds no row above it. + expect(lastFrame()).toBe("hello"); +}); diff --git a/packages/runtime/src/host/text-measure.test.ts b/packages/runtime/src/host/text-measure.test.ts index 179655e..39c7c7b 100644 --- a/packages/runtime/src/host/text-measure.test.ts +++ b/packages/runtime/src/host/text-measure.test.ts @@ -412,6 +412,20 @@ test("measureTextNatural uses widest line and raw line count", () => { expect(measureTextNatural("")).toEqual({ width: 0, height: 1 }); }); +// Ink measure-text.tsx:24-34 (widest-line width, `text.split('\n').length` +// height): a TRAILING newline produces an extra (empty) trailing line, and a +// string of only newlines is all empty lines. height = number of \n-separated +// segments; width = widest line. +test("measureTextNatural counts the trailing-newline empty line", () => { + // "hello\n" → ["hello", ""] → widest line 5, two lines. + expect(measureTextNatural("hello\n")).toEqual({ width: 5, height: 2 }); +}); + +test("measureTextNatural counts an only-newline string as all empty lines", () => { + // "\n\n" → ["", "", ""] → no visible width, three lines. + expect(measureTextNatural("\n\n")).toEqual({ width: 0, height: 3 }); +}); + test("clipped empty write does not corrupt existing wide characters", () => { // When a write is clipped to an empty string, the boundary cleanup // must not run, otherwise it would destroy a wide character that