From 33cc9c3dcd5f7f5575281feb02c0e4c72e9eea22 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 15 Jun 2026 11:39:40 +0800 Subject: [PATCH] test(runtime): full wrap-mode transition matrix; vouch the wrap re-measure divergence (#200) Encode the declarative invariant for the runtime `wrap` re-measure fix (PR #193) as a full matrix: for all 6 wrap modes and all 30 ordered transitions, toggling `wrap` at runtime produces the exact same frame as a fresh mount with that wrap (measure == paint). Ground-truth fresh-mount frames are derived at runtime, not hardcoded. Reverting the one-line fix in node-ops.ts turns 16 of the 30 transitions red, so the matrix genuinely guards the fix. Vouch the divergence: add [VOUCHED @hyf0] to the ink-divergences.md entry and reword it to lead with correctness (Ink v7.0.4 has the latent stale measure bug; vue-tui keeps the correct invariant). Drop "pending a human vouch" from the node-ops comment. Co-authored-by: Claude Opus 4.8 (1M context) --- .agents/docs/ink-divergences.md | 21 +-- .../text-wrap-remeasure-matrix.test.tsx | 122 ++++++++++++++++++ packages/runtime/src/host/node-ops.ts | 2 +- 3 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 packages/runtime-tests/integration/layout/text-wrap-remeasure-matrix.test.tsx diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index c012aea..9b41921 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -473,14 +473,19 @@ different runtime behavior, ownership rule, or out-of-contract handling. `"aaaa\nbbbb\ncccc\nZZZZ"`. `wrap` is the only STYLE_PROP that affects measured height (the measure func reads `text.props.wrap`); the rest (color/bold/border colors/…) are paint-only, so this is the sole case. -- **Why:** aligning to Ink reduces bugs only where Ink is correct. Here Ink is itself - buggy — a stale cached measure that contradicts paint — so vue-tui deliberately diverges to - the obviously-correct behavior: render = f(current props), layout and paint agree. The fix - is minimal (one `markDirty`) and matches the layout Ink ALREADY produces whenever its measure - func happens to be invalidated. KEEP — proposed divergence, PENDING @hyf0 vouch (Ink is buggy - here; awaiting human bless before this is settled). Tests: - `text-wrap-remeasure.test.tsx` (both directions; RED without the fix, reproducing Ink's - stale frame). +- **Why:** the correct behavior is the declarative invariant — a runtime `wrap` change must + produce the EXACT SAME frame as a fresh mount with that `wrap` (measure == paint, render = + f(current props)). This is VERIFIED across the full 6-mode transition matrix (`wrap`, `hard`, + `truncate`, `truncate-end`, `truncate-middle`, `truncate-start` → all 30 ordered transitions): + each toggled frame equals the fresh-mount frame for the target mode. Ink v7.0.4 diverges from + this correct behavior — a run-verified latent bug where `applyStyles` ignores `textWrap` and + never `markDirty`s, leaving a stale cached measure that contradicts paint. Aligning to Ink + reduces bugs only where Ink is correct; here Ink is buggy, so vue-tui keeps the correct + invariant. The fix is minimal (one `markDirty`) and matches the layout Ink ALREADY produces + whenever its measure func happens to be invalidated. KEEP. [VOUCHED @hyf0] Tests: + `text-wrap-remeasure.test.tsx` (both directions; RED without the fix, reproducing Ink's stale + frame) and `text-wrap-remeasure-matrix.test.tsx` (full 6-mode / 30-transition matrix proving + the invariant; 16 transitions go RED without the fix). ### Second `mount()` on a live stdout is an inert no-op diff --git a/packages/runtime-tests/integration/layout/text-wrap-remeasure-matrix.test.tsx b/packages/runtime-tests/integration/layout/text-wrap-remeasure-matrix.test.tsx new file mode 100644 index 0000000..465a04e --- /dev/null +++ b/packages/runtime-tests/integration/layout/text-wrap-remeasure-matrix.test.tsx @@ -0,0 +1,122 @@ +import { defineComponent, shallowRef } from "vue"; +import { beforeAll, describe, expect, test } from "vite-plus/test"; +import { render } from "@vue-tui/testing"; +import { Box, Text } from "@vue-tui/runtime"; + +// Full transition matrix for the "re-measure text when `wrap` changes at +// runtime" divergence (see .agents/docs/ink-divergences.md). The sibling +// `text-wrap-remeasure.test.tsx` covers the two directions of the original +// run-verified case; this file proves the GENERAL declarative invariant across +// every wrap mode: +// +// changing `wrap` at runtime produces the EXACT SAME frame as mounting with +// that `wrap` from the start (i.e. measure always equals paint). +// +// `wrap` is the one STYLE_PROP that changes a text node's MEASURED height yet is +// not a yoga prop, so a wrap-only change must re-mark the text dirty or yoga +// keeps the stale height while paint uses the new mode → layout/paint disagree. +// The fresh-mount frames are the GROUND TRUTH: we derive them at runtime (mount +// once per mode) rather than hardcoding, so the matrix is robust to harmless +// rendering tweaks and a failure means a real measure/paint disagreement. + +// Box width 6, column layout. "aaaa bbbb cccc" is 14 cols, so the modes produce +// different measured heights (wrap/hard → multiple rows; the truncate variants → +// 1 row), which is exactly what a runtime change must re-measure. +const CONTENT = "aaaa bbbb cccc"; + +type WrapMode = + | "wrap" + | "hard" + | "truncate" + | "truncate-end" + | "truncate-middle" + | "truncate-start"; + +const MODES: readonly WrapMode[] = [ + "wrap", + "hard", + "truncate", + "truncate-end", + "truncate-middle", + "truncate-start", +]; + +function makeDynamic(wrap: { value: WrapMode }) { + // A reactive over fixed content, with a sentinel below so + // a stale measured height strands/overwrites the sentinel (the bug's symptom). + return defineComponent(() => () => ( + + {CONTENT} + ZZZZ + + )); +} + +// Mount fresh with `mode` and return the settled frame. This is the ground truth +// each toggled frame is compared against. +async function freshMountFrame(mode: WrapMode): Promise { + const wrap = shallowRef(mode); + const { lastFrame } = await render(makeDynamic(wrap), { columns: 40 }); + return lastFrame() ?? ""; +} + +// 30 ordered (from, to) pairs with from ≠ to. +const transitions: Array<[WrapMode, WrapMode]> = []; +for (const from of MODES) { + for (const to of MODES) { + if (from !== to) transitions.push([from, to]); + } +} + +describe("wrap-mode transition matrix: a runtime `wrap` change === a fresh mount with that wrap", () => { + // Ground-truth fresh-mount frame per mode. Derived in beforeAll (runs before + // every test in this suite regardless of ordering) so the matrix never depends + // on a sibling test having run first. + const freshFrames = new Map(); + + beforeAll(async () => { + for (const mode of MODES) { + freshFrames.set(mode, await freshMountFrame(mode)); + } + }); + + test("derived ground-truth fresh-mount frames match explicit expectations", () => { + // Pin every mode's ground truth, so the matrix below compares toggled frames + // against the layouts we actually expect (not a silently-wrong baseline). + // `wrap`/`hard` wrap across rows; the four truncate modes collapse to one + // row (`truncate` and `truncate-end` are identical — both ellipsis-at-end). + const expected: Record = { + wrap: "aaaa\nbbbb\ncccc\nZZZZ", + hard: "aaaa b\nbbb cc\ncc\nZZZZ", + truncate: "aaaa …\nZZZZ", + "truncate-end": "aaaa …\nZZZZ", + "truncate-middle": "aaa…cc\nZZZZ", + "truncate-start": "… cccc\nZZZZ", + }; + for (const mode of MODES) { + expect(freshFrames.get(mode), `fresh-mount frame for ${mode}`).toBe(expected[mode]); + } + }); + + // Each transition: mount with `from`, toggle the reactive ref to `to`, flush, + // and assert the toggled frame equals the fresh-mount frame for `to`. + test.each(transitions)( + "toggle %s -> %s equals a fresh mount with the target wrap", + async (from, to) => { + const expected = freshFrames.get(to); + expect(expected, `ground-truth frame for ${to} not derived`).toBeDefined(); + + const wrap = shallowRef(from); + const { lastFrame, waitUntilRenderFlush } = await render(makeDynamic(wrap), { + columns: 40, + }); + + wrap.value = to; + // Deterministic flush (forces the scheduler's pending/throttled commit) + // rather than racing the ~32ms commit throttle with bare nextTick. + await waitUntilRenderFlush(); + + expect(lastFrame(), `${from} -> ${to} must match fresh-mount(${to})`).toBe(expected); + }, + ); +}); diff --git a/packages/runtime/src/host/node-ops.ts b/packages/runtime/src/host/node-ops.ts index 735a6a0..2d6867b 100644 --- a/packages/runtime/src/host/node-ops.ts +++ b/packages/runtime/src/host/node-ops.ts @@ -491,7 +491,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions