import { defineComponent, ref, shallowRef, nextTick, watchEffect } from "vue"; import { expect, test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; import { Box, Text, useBoxMetrics } from "@vue-tui/runtime"; test("set width", async () => { const { lastFrame } = await render( defineComponent(() => () => ( A B )), { columns: 100 }, ); expect(lastFrame({ trimLines: true })).toBe("A B"); }); test("set width in percent", async () => { const { lastFrame } = await render( defineComponent(() => () => ( A B )), { columns: 100 }, ); expect(lastFrame({ trimLines: true })).toBe("A B"); }); // Ink coerces a STRING width to a PERCENT (styles.ts applyDimensionStyles: // `typeof style.width === 'string'` → setWidthPercent(parseInt(width, 10))). // So a bare-numeric string is a PERCENT, NOT absolute cells: width="50" on a // width=10 parent → 5 cells. Without the fix vue forwards "50" raw to // setWidth → 50 absolute cells → "A" + 50 spaces + "B". test("set width with bare numeric string is a percent (Ink parity)", async () => { const { lastFrame } = await render( defineComponent(() => () => ( A B )), { columns: 100 }, ); expect(lastFrame({ trimLines: true })).toBe("A B"); }); // Ink uses parseInt(width, 10) which TRUNCATES the fraction: "55.9%" → 55%, NOT // parseFloat which would give 55.9% → 56 cells (yoga rounds 55.9% of 100 to 56). // Assert the COMPUTED width directly via useBoxMetrics so the test discriminates // parseInt(55) from parseFloat(55.9→56): a paint-frame assertion can't, because // trimLines collapses both a 55- and 56-cell box to the same column once the // child text is left-aligned. RED on the pre-fix parseFloat path (width 56), // GREEN after (width 55). test("set width with fractional percent string truncates to 55 like Ink parseInt", async () => { const computedWidth = shallowRef(-1); const App = defineComponent(() => { const boxRef = ref(null); const metrics = useBoxMetrics(boxRef); watchEffect(() => { computedWidth.value = metrics.width.value; }); return () => ( A B ); }); await render(App, { columns: 200 }); // useBoxMetrics defers measurement to nextTick after the commit. await nextTick(); // parseInt("55.9", 10) → 55 → 55% of 100 = 55 cells (NOT parseFloat → 55.9 → 56). expect(computedWidth.value).toBe(55); }); // Ink: parseInt("", 10) → NaN, which yoga accepts via setWidthPercent without // throwing. vue forwarded "" raw to setWidth("") which THROWS, crashing render. test("set width to empty string does not throw and renders child (Ink parity)", async () => { const { lastFrame } = await render( defineComponent(() => () => ( X )), { columns: 100 }, ); expect(lastFrame()).toContain("X"); }); // Ink's applyDimensionStyles else-branch routes a non-number/non-string width to // setWidthAuto() (styles.ts:669-671), so a junk value renders fine. vue used to // forward it raw to setWidth(false) which THROWS ("Invalid value false for // setWidth"), crashing the render. Lock the parity: junk width must not throw and // must still render the child (auto sizing). Vue's [Number, String] prop // validation only WARNS on `false` and still forwards it, so this path is real. test("set width to a junk (non-number/non-string) value does not throw and renders child (Ink parity)", async () => { const { lastFrame } = await render( defineComponent(() => () => ( X )), { columns: 100 }, ); expect(lastFrame()).toContain("X"); }); test("set min width", async () => { const { lastFrame: smallerFrame } = await render( defineComponent(() => () => ( A B )), { columns: 100 }, ); expect(smallerFrame({ raw: true })).toBe("A B"); const { lastFrame: largerFrame } = await render( defineComponent(() => () => ( AAAAA B )), { columns: 100 }, ); expect(largerFrame()).toBe("AAAAAB"); }); test.skip("set min width in percent — known Yoga issue", async () => { const { lastFrame } = await render( defineComponent(() => () => ( A B )), { columns: 100 }, ); expect(lastFrame({ trimLines: true })).toBe("A B"); }); test("set height", async () => { const { lastFrame } = await render( defineComponent(() => () => ( A B )), { columns: 100 }, ); expect(lastFrame({ trimLines: true })).toBe("AB\n\n\n"); }); test("set height in percent", async () => { const { lastFrame } = await render( defineComponent(() => () => ( A B )), { columns: 100 }, ); expect(lastFrame({ trimLines: true })).toBe("A\n\n\nB\n\n"); }); test("cut text over the set height", async () => { const { lastFrame } = await render( defineComponent(() => () => ( AAAABBBBCCCC )), { columns: 4 }, ); expect(lastFrame({ trimLines: true })).toBe("AAAA\nBBBB"); }); test("set min height", async () => { const { lastFrame: smallerFrame } = await render( defineComponent(() => () => ( A )), { columns: 100 }, ); expect(smallerFrame({ raw: true })).toBe("A\n\n\n"); const { lastFrame: largerFrame } = await render( defineComponent(() => () => ( A )), { columns: 100 }, ); expect(largerFrame({ raw: true })).toBe("A\n\n\n"); }); test("set min height in percent", async () => { const { lastFrame } = await render( defineComponent(() => () => ( A B )), { columns: 100 }, ); expect(lastFrame({ trimLines: true })).toBe("A\n\n\nB\n\n"); }); test("set max width", async () => { const { lastFrame: constrainedFrame } = await render( defineComponent(() => () => ( AAAAA B )), { columns: 10 }, ); expect(constrainedFrame()).toBe("AAAB\nAA"); const { lastFrame: unconstrainedFrame } = await render( defineComponent(() => () => ( AAA B )), { columns: 100 }, ); expect(unconstrainedFrame()).toBe("AAAB"); }); test("clears maxWidth on rerender", async () => { const maxWidth = shallowRef(3); const { lastFrame } = await render( defineComponent(() => () => ( AAAAA B )), { columns: 10 }, ); expect(lastFrame({ trimLines: true })).toBe("AAAB\nAA"); maxWidth.value = undefined; await nextTick(); expect(lastFrame({ trimLines: true })).toBe("AAAAAB"); }); test("set max height", async () => { const { lastFrame: constrainedFrame } = await render( defineComponent(() => () => ( A )), { columns: 100 }, ); expect(constrainedFrame({ raw: true })).toBe("A\n"); const { lastFrame: unconstrainedFrame } = await render( defineComponent(() => () => ( A )), { columns: 100 }, ); expect(unconstrainedFrame()).toBe("A"); }); test("clears maxHeight on rerender", async () => { const maxHeight = shallowRef(2); const { lastFrame } = await render( defineComponent(() => () => ( A )), { columns: 100 }, ); expect(lastFrame({ trimLines: true })).toBe("A\n"); maxHeight.value = undefined; await nextTick(); expect(lastFrame({ trimLines: true })).toBe("A\n\n\n"); }); test("set aspect ratio with width", async () => { const { lastFrame } = await render( defineComponent(() => () => ( X Y )), { columns: 100 }, ); expect(lastFrame({ trimLines: true })).toBe("┌──────┐\n│X │\n│ │\n└──────┘\nY"); }); test("set aspect ratio with height", async () => { const { lastFrame } = await render( defineComponent(() => () => ( X Y )), { columns: 100 }, ); expect(lastFrame({ trimLines: true })).toBe("┌────┐\n│X │\n└────┘\nY"); }); test("set aspect ratio with width and height", async () => { const { lastFrame } = await render( defineComponent(() => () => ( X Y )), { columns: 100 }, ); expect(lastFrame({ trimLines: true })).toBe("┌────┐\n│X │\n└────┘\nY"); }); test("set aspect ratio with maxHeight constraint", async () => { const { lastFrame } = await render( defineComponent(() => () => ( X Y )), { columns: 100 }, ); expect(lastFrame({ trimLines: true })).toBe("┌────┐\n│X │\n└────┘\nY"); }); test("clears aspectRatio on rerender", async () => { const aspectRatio = shallowRef(2); const { lastFrame } = await render( defineComponent(() => () => ( X Y )), { columns: 100 }, ); expect(lastFrame({ trimLines: true })).toBe("┌──────┐\n│X │\n│ │\n└──────┘\nY"); aspectRatio.value = undefined; await nextTick(); expect(lastFrame({ trimLines: true })).toBe("┌──────┐\n│X │\n└──────┘\nY"); }); // Ink parity G12: Ink's getWindowSize() uses a truthy guard (if (columns && rows)) // so that a 0 value from stdout in non-TTY environments falls back to terminal-size // and then 80/24 defaults. vue-tui's renderer was using `stdout.columns ?? 80` // which only falls back for null/undefined — not 0 — collapsing layout to width 0. // References: Ink /tmp/ink-40b3a75/src/utils.ts lines 8-23. test("falls back to default width when stdout reports 0 columns (Ink parity G12)", async () => { const { lastFrame } = await render( defineComponent(() => () => ( hello )), // columns: 0 — simulates non-TTY where stdout.columns is 0 (not null/undefined). // With the bug: width resolves to 0, yoga collapses to 0-width, "hello" disappears. // With the fix: resolveSize() truthy-guards 0, falls back to terminal-size / 80. { columns: 0, rows: 0 }, ); expect(lastFrame()).toContain("hello"); }); test.skip("set max width in percent — known Yoga issue", async () => { const { lastFrame } = await render( defineComponent(() => () => ( AAAAAAAAAA B )), { columns: 100 }, ); expect(lastFrame({ trimLines: true })).toBe("AAAAAB"); }); test("set max height in percent", async () => { const { lastFrame } = await render( defineComponent(() => () => ( A B )), { columns: 100 }, ); expect(lastFrame({ trimLines: true })).toBe("A\n\n\nB\n\n"); }); // Skipped: set width - concurrent // Skipped: set height - concurrent