Skip to main content

inkferro_core/render/
mod.rs

1//! Plain-frame render assembly — M1-5 implementation per ADR-2.
2//!
3//! # Module structure
4//! - `cli_boxes`  — vendored cli-boxes@4.0.1 char table (border drawing chars).
5//! - `grid`       — char grid: write/clip/wide-char cleanup/get (output.ts port).
6//! - `border`     — border char drawing (render-border.ts port, plain slice).
7//! - `walk`       — arena tree walk → grid (render-node-to-output.ts port).
8//! - `mod` (this) — `render_to_string` entry point tying layout + grid together.
9//!
10//! # Engine-per-call design (ADR-3, M3-A)
11//! Both `render_to_string` and the `build_layout_engine` seam create a **fresh**
12//! `TaffyEngine` on each call. ADR-3 (`docs/adr3-engine-lifetime.md`) chose
13//! per-frame rebuild over a live incremental engine: persistence lives in the
14//! `Arena` (which `InkRoot` will own across `commit()` calls, M3-D), while the
15//! engine is a pure function of the arena at render time. A fresh engine
16//! re-runs `set_measure` for every text node every frame, so the
17//! measure-invalidation discipline (`layout/engine.rs:78-82`) is satisfied for
18//! free — the rejected incremental option would have had to replicate it by
19//! hand, at the risk of silent text-measure corruption. The node-creation walk
20//! is NEVER re-run against an already-populated engine: `insert_child` appends,
21//! so reuse would duplicate child lists. See ADR-3 for the full rationale.
22//!
23//! # Height for unconstrained render (render-to-string.ts:62-68 citation)
24//! ink's `renderToString` calls:
25//! ```ts
26//! rootNode.yogaNode!.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR);
27//! ```
28//! ink passes `undefined` for both axes in yoga, but the root node has an
29//! explicit `setWidth(columns)` call immediately before (render-to-string.ts:62),
30//! so in practice width is definite and only height is unconstrained.
31//! `render_to_string` mirrors this: width is `AvailableSpace::Definite(columns)`,
32//! height is `AvailableSpace::MaxContent` (passed as `None` to `calculate`).
33//! The grid is sized to the computed root height so trailing empty rows are
34//! never included in the output.
35
36pub mod background;
37pub mod border;
38pub mod cli_boxes;
39pub mod colorize;
40#[cfg(test)]
41mod colorize_chalk_parity_tests;
42pub mod grid;
43pub mod walk;
44
45pub use colorize::{ColorLevel, Kind as ColorKind, colorize, dim};
46
47use crate::dom::{Arena, Kind};
48use crate::layout::{LayoutEngine, Rect, TaffyEngine};
49use crate::render::grid::Grid;
50use crate::render::walk::{TransformAccessor, walk, walk_static};
51use crate::text_measure::build_measure_fn_for;
52
53/// Render the arena tree rooted at `root_id` to a plain-frame string.
54///
55/// Orchestration (mirrors `renderer.ts` + `render-to-string.ts`):
56/// 1. Build a fresh `TaffyEngine` (engine-per-call; see module docs).
57/// 2. Walk the arena: create taffy nodes, apply styles, set text measures.
58/// 3. `calculate(root, width, MAX_HEIGHT)` — unconstrained height per ink.
59/// 4. Walk arena again: render each node into a `Grid`.
60/// 5. Return `grid.get().0` (the trimmed frame string).
61///
62/// The returned string matches ink's `renderToString` output for unstyled
63/// content (no SGR transformers, no static nodes).
64pub fn render_to_string(arena: &Arena, root_id: u32, width: u16) -> String {
65    // render_to_string is the PLAIN slice of `render_styled`: a no-op transform
66    // accessor (`&|_| None`) leaves the transformer chain empty at every node,
67    // so the styled write path degenerates to the plain one and the bytes are
68    // identical to the pre-seam output (the corpus, 188/5 exact, is the proof).
69    // The returned height is discarded here; callers that need it use
70    // `render_styled` directly.
71    //
72    // Color level is fixed to `Truecolor` (=3) here: this helper backs the
73    // `layout_corpus` byte tests, whose border-color EXPECTED literals are
74    // chalk@5 level-3 bytes. The PRODUCTION `renderToString` does NOT go through
75    // this helper — it calls `render_frame` (napi) with the detected
76    // `opts.color_level`, so honoring the level lives there, not here.
77    render_styled(arena, root_id, width, &|_| None, ColorLevel::Truecolor).0
78}
79
80/// Render the arena tree rooted at `root_id` to a **styled** frame, returning
81/// both the frame string and its height in rows (`mirrors ink's `{output,
82/// height}`, output.ts:315-316). This is the entry M3-E `render_frame` calls.
83///
84/// `transform_of` is the per-node own-transform seam (see
85/// [`walk::TransformAccessor`]): given a dom id it returns the node's own output
86/// transform (ink's `internal_transform`), or `None`. `<Text color>` SGR is *not*
87/// a separate path — in ink it lives **inside** `internal_transform`
88/// (Text.tsx:94-130 → `colorize`), so a core caller wires `colorize` into the
89/// accessor and napi (M3-E) dispatches to a JS `internal_transform`; both flow
90/// through the same `[own, ...inherited]` chain (render-node-to-output.ts:136).
91///
92/// Reuses [`build_layout_engine`] (the M3-A seam) verbatim — the layout build is
93/// never duplicated. An empty/zero-sized frame returns `(String::new(), 0)`,
94/// matching the prior `render_to_string` error/empty path.
95pub fn render_styled<'a>(
96    arena: &'a Arena,
97    root_id: u32,
98    width: u16,
99    transform_of: &'a TransformAccessor<'a>,
100    color_level: ColorLevel,
101) -> (String, u16) {
102    // ── 1. Layout pass (ADR-3 seam) ───────────────────────────────────────────
103    // Build a fresh engine + compute layout via the shared seam. `InkRoot`
104    // (M3-D) calls the same seam each frame; this entry is the styled wrapper
105    // over it, so the two paths cannot drift in their layout.
106    let Some((engine, root_rect)) = build_layout_engine(arena, root_id, width) else {
107        return (String::new(), 0);
108    };
109
110    // ── 2. Render pass ────────────────────────────────────────────────────────
111    // renderer.ts:37-39: Output is sized to the computed root dimensions.
112    let grid_rows = root_rect.height as usize;
113    let grid_cols = root_rect.width as usize;
114
115    if grid_rows == 0 || grid_cols == 0 {
116        return (String::new(), 0);
117    }
118
119    let mut grid = Grid::new(grid_rows, grid_cols);
120
121    // Build a rect accessor closure from the engine.
122    // The closure captures engine by reference for the walk.
123    let rect_fn = |id: u32| engine.computed(id);
124
125    walk(
126        arena,
127        root_id,
128        &rect_fn,
129        transform_of,
130        &mut grid,
131        color_level,
132    );
133
134    // grid.get() returns (output, height); height == grid_rows == root height.
135    let (output, height) = grid.get();
136    (output, height as u16)
137}
138
139/// Render the **static** subtree (ink's `<Static>`) to its standalone output
140/// string — the text ink prints once, above the live region.
141///
142/// This is the SECOND render pass, a faithful port of `renderer.ts`'s static
143/// branch (renderer.ts:46-66):
144/// ```ts
145/// let staticOutput;
146/// if (node.staticNode?.yogaNode) {
147///   staticOutput = new Output({
148///     width:  node.staticNode.yogaNode.getComputedWidth(),
149///     height: node.staticNode.yogaNode.getComputedHeight(),
150///   });
151///   renderNodeToOutput(node.staticNode, staticOutput, {skipStaticElements: false});
152/// }
153/// // …
154/// staticOutput: staticOutput ? `${staticOutput.get().output}\n` : '',
155/// ```
156///
157/// Semantics, point by point:
158/// 1. **Find the static node.** ink tracks a single `node.staticNode` set by the
159///    reconciler when `internal_static` is applied (reconciler.ts:236-244). The
160///    arena instead marks the node with `is_static` (set by `Op::SetStatic`), so
161///    we DFS from `root_id` for the first `is_static` node. No static node →
162///    return `""` (the common case; matches `staticNode` being `undefined`).
163/// 2. **Layout.** Reuse [`build_layout_engine`] at the SAME `width` ink lays the
164///    root out at — the static node is part of that one tree, so its computed
165///    rect (`engine.computed(static_id)`) falls out of the single root layout,
166///    exactly as `node.staticNode.yogaNode` is laid out by the root's
167///    `calculateLayout`. If the static id has no computed rect, return `""`
168///    (matches `node.staticNode?.yogaNode` being absent).
169/// 3. **Own-sized grid.** Size the static grid to the static node's OWN computed
170///    width/height (renderer.ts:50-52: `new Output({width: …getComputedWidth(),
171///    height: …getComputedHeight()})`), NOT the root's.
172/// 4. **Walk at offset 0** via [`walk_static`] (`skipStaticElements: false`): the
173///    static entry's own computed left/top become the first write position
174///    (render-node-to-output.ts:129-130 with offsetX/Y defaulting to 0).
175/// 5. **Trailing newline** (renderer.ts:64-66): a PRESENT static node always
176///    appends `\n` ("static output doesn't have one, so interactive output will
177///    override last line of static output"), even for an empty body (→ `"\n"`).
178///    An ABSENT static node yields `""`. A zero-dimensioned static node skips
179///    `Grid::new` and still yields `"\n"`, matching `staticOutput.get().output`
180///    being `""` for an empty `Output` plus the appended newline.
181///
182/// `transform_of` is the same per-node own-transform seam [`render_styled`] uses;
183/// the napi caller passes the SAME accessor so a `<Transform>`/`<Text color>`
184/// inside `<Static>` is honored in the static pass too.
185pub fn render_static<'a>(
186    arena: &'a Arena,
187    root_id: u32,
188    width: u16,
189    transform_of: &'a TransformAccessor<'a>,
190    color_level: ColorLevel,
191) -> String {
192    // ── 1. Find the static node (DFS from root) — short-circuit BEFORE any
193    //       layout build so a static-free frame (the common case) skips the
194    //       layout/grid/walk work below. It still pays one O(n) pre-order DFS of
195    //       the arena (`find_static_node`) per frame before it can return `""`;
196    //       negligible in practice, but not free.
197    let Some(static_id) = find_static_node(arena, root_id) else {
198        return String::new();
199    };
200
201    // ── 2. Layout pass: the static node is part of the one root layout, so its
202    //       computed rect falls out of a `build_layout_engine` build at the SAME
203    //       width ink uses. NOTE: this builds a FRESH taffy tree rather than
204    //       reusing the M3-A persistent engine, so a static-bearing frame computes
205    //       layout twice (main pass + here). Perf-only, and only on the rare
206    //       static-bearing frame; correctness is unaffected.
207    let Some((engine, _root_rect)) = build_layout_engine(arena, root_id, width) else {
208        return String::new();
209    };
210    let Some(static_rect) = engine.computed(static_id) else {
211        return String::new();
212    };
213
214    // ── 3. Own-sized grid (renderer.ts:50-52). A zero-dim static node still gets
215    //       the trailing newline below (a present `staticNode` always does).
216    let grid_rows = static_rect.height as usize;
217    let grid_cols = static_rect.width as usize;
218    if grid_rows == 0 || grid_cols == 0 {
219        return "\n".to_owned();
220    }
221
222    let mut grid = Grid::new(grid_rows, grid_cols);
223    let rect_fn = |id: u32| engine.computed(id);
224
225    // ── 4. Render pass with skipStaticElements=false (renderer.ts:54).
226    walk_static(
227        arena,
228        static_id,
229        &rect_fn,
230        transform_of,
231        &mut grid,
232        color_level,
233    );
234
235    // ── 5. Trailing newline (renderer.ts:64-66): present static → body + "\n".
236    let (body, _height) = grid.get();
237    format!("{body}\n")
238}
239
240/// DFS the arena tree rooted at `root_id` for the first `is_static` node, in
241/// pre-order. ink supports exactly ONE `<Static>` per tree (the reconciler keeps
242/// a lone `rootNode.staticNode` reference, reconciler.ts:243), so in every
243/// supported tree there is at most one `is_static` node and "first in pre-order"
244/// is unambiguous — matching ink's single `node.staticNode`.
245///
246/// Multiple static nodes are undefined in ink itself. NOTE: for that
247/// ink-undefined case the selection *direction* differs: we take the FIRST
248/// `is_static` node in pre-order, whereas ink's reconciler reassigns
249/// `rootNode.staticNode = node` on every `internal_static` apply
250/// (reconciler.ts:243), so ink's effective static node is the LAST one committed.
251/// Both pick deterministically; they only diverge on a tree ink does not support.
252/// Returns the static entry id, or `None` when the tree carries no static node.
253fn find_static_node(arena: &Arena, id: u32) -> Option<u32> {
254    let node = arena.get(id)?;
255    if node.is_static {
256        return Some(id);
257    }
258    for &child_id in &node.children {
259        if let Some(found) = find_static_node(arena, child_id) {
260            return Some(found);
261        }
262    }
263    None
264}
265
266/// Build a fresh layout engine for the arena tree rooted at `root_id`, compute
267/// layout at the given `width`, and return the built engine plus the root rect.
268///
269/// This is the **M3-A engine-lifetime seam** (ADR-3,
270/// `docs/adr3-engine-lifetime.md`) — the **public** entry `InkRoot`
271/// (`inkferro-napi`, M3-D) calls each `render_frame`. `render_to_string` calls
272/// it as step 1 of its own body, so the two paths cannot drift. It is the single
273/// place the taffy tree is constructed, so callers cannot accidentally re-run
274/// node creation against a populated engine (which would duplicate child lists —
275/// `insert_child` appends).
276///
277/// It is `pub` (not `pub(crate)`) because the consumer lives in a *different*
278/// crate (`inkferro-napi`). Returning the concrete `TaffyEngine` — rather than
279/// `impl LayoutEngine` — lets `InkRoot` store it as a named field and read
280/// `computed(id)` later (still via the `LayoutEngine` trait). This deliberately
281/// names the concrete backend across the napi boundary; the ADR-1
282/// `LayoutEngine`-trait seam still governs *behavior*, and a backend swap would
283/// change only this return type in one place.
284///
285/// Returns the **built-and-computed** engine so a single per-frame build serves
286/// both the render walk (M3-E reads `engine.computed(id)` as the rect accessor)
287/// and `measure(id)` (M3-F reads `engine.computed(id)` from the *same* stored
288/// build) — no second rebuild.
289///
290/// # Per-frame rebuild (ADR-3 Option A)
291/// Always allocates a fresh `TaffyEngine`. Persistence is the `Arena`'s job; the
292/// engine is a pure function of the arena at render time. A fresh engine
293/// re-`set_measure`s every text node, satisfying the measure-invalidation
294/// discipline (`layout/engine.rs:78-82`) for free.
295///
296/// Returns `None` if `calculate` fails (e.g. an inconsistent tree); the caller
297/// renders an empty frame, matching the prior `render_to_string` error path.
298pub fn build_layout_engine(arena: &Arena, root_id: u32, width: u16) -> Option<(TaffyEngine, Rect)> {
299    let mut engine = TaffyEngine::new();
300
301    // First pass: create nodes and wire the tree. Run on a FRESH engine only —
302    // see ADR-3 "the trap inside A": re-running this against a populated engine
303    // re-appends children and corrupts the layout.
304    create_layout_nodes(arena, root_id, &mut engine);
305
306    // Width is definite (caller-supplied columns). Height is unconstrained —
307    // mirrors ink's render-to-string.ts:62-68 where yogaNode.calculateLayout
308    // receives `undefined` for height (MaxContent), letting content determine
309    // the frame height. None → AvailableSpace::MaxContent in TaffyEngine::calculate.
310    if engine.calculate(root_id, width as f32, None).is_err() {
311        return None;
312    }
313
314    // Read the computed root rect to size the grid (renderer.ts:37-39). Fall
315    // back to a zero-height rect at the caller width if the root id is unknown
316    // — preserves the prior `unwrap_or` behavior so empty/absent roots render
317    // an empty frame rather than panicking.
318    let root_rect = engine.computed(root_id).unwrap_or(Rect {
319        x: 0,
320        y: 0,
321        width,
322        height: 0,
323    });
324
325    Some((engine, root_rect))
326}
327
328/// Recursively register all arena nodes into the layout engine.
329///
330/// For each node: create the taffy node, apply its style, attach text measure
331/// for Text/VirtualText nodes, then insert children in order.
332fn create_layout_nodes(arena: &Arena, id: u32, engine: &mut TaffyEngine) {
333    let Some(node) = arena.get(id) else { return };
334
335    // Create the taffy node (idempotent).
336    let _ = engine.create(id);
337
338    let style = match node.kind {
339        Kind::Root => root_style_with_ink_defaults(&node.style),
340        _ => node.style.clone(),
341    };
342    let _ = engine.apply_style(id, &style);
343
344    // Wire text measure for text-bearing nodes, then STOP: a Text/VirtualText node
345    // is a taffy LEAF whose measure fn squashes its WHOLE subtree once
346    // (`build_measure_fn_for` → `squash_text`), exactly like ink's `ink-text`
347    // (dom.ts:222-246) — whose nested `ink-virtual-text` children carry NO yoga node
348    // (dom.ts:102) and are never inserted as yoga children (dom.ts:125). #71: NOT
349    // returning here gave every nested segment its own taffy node + measure fn, so
350    // taffy (which only measures LEAF nodes) measured each inner segment ALONE in
351    // its own wrap mode — truncating/wrapping per leaf instead of over the combined
352    // squash. Returning makes the text-root the sole measured unit → squash-then-
353    // truncate, byte-identical to ink and to the render walk (which already squashes
354    // the whole Text subtree once, walk.rs Text arm). The reconciler forbids a Box
355    // child of a Text node (`caseBoxInTextThrows`), so a Text node never has
356    // layout-bearing children to recurse into; multi-segment text is purely nested
357    // Text/VirtualText, all folded by `squash_text`. (Sibling Texts under a Box are
358    // separate text-ROOTS, reached from the Box/Root branch below — unaffected; and
359    // when nothing truncates, the squashed width equals the sum of segment widths,
360    // so single-segment text, fitting multi-segment text, the layout corpus, and the
361    // zero-flicker goldens keep identical dimensions.)
362    if matches!(node.kind, Kind::Text | Kind::VirtualText) {
363        engine.set_measure(id, build_measure_fn_for(arena, id));
364        return;
365    }
366
367    // Clone children list to avoid borrow conflict during recursion.
368    let children: Vec<u32> = node.children.clone();
369    for (idx, &child_id) in children.iter().enumerate() {
370        create_layout_nodes(arena, child_id, engine);
371        let _ = engine.insert_child(id, child_id, idx);
372    }
373}
374
375// ink's `ink-root` yoga node never receives Box styles — it
376// relies on Yoga's intrinsic node defaults (flex-direction: column,
377// align-items: stretch), which make every top-level child stretch to the
378// full root width (dom.ts:95-105 createNode: bare Yoga.Node.create(),
379// empty style). Taffy's default is flex-direction: row, so the Root
380// must be column + stretch explicitly to reproduce ink-root.
381// Box.tsx:85 forces flex-direction: row on every <Box>, so this is
382// Root-only. The other half of ink-root identity — the root width pin
383// (setWidth(columns)) — lives in TaffyEngine::calculate, which has the
384// viewport_width parameter.
385fn root_style_with_ink_defaults(s: &crate::dom::Style) -> crate::dom::Style {
386    let mut style = s.clone();
387    if style.flex_direction.is_none() {
388        style.flex_direction = Some(crate::dom::FlexDir::Column);
389    }
390    if style.align_items.is_none() {
391        style.align_items = Some(crate::dom::Align::Stretch);
392    }
393    style
394}
395
396// ─── Tests ───────────────────────────────────────────────────────────────────
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use crate::dom::{Arena, BorderStyle, Dim, Display, Kind, Lp, Node, Overflow, Style, TextWrap};
402
403    // ── helpers ──────────────────────────────────────────────────────────────
404
405    fn make_root(arena: &mut Arena, id: u32) {
406        arena.insert(id, Node::new(Kind::Root));
407    }
408
409    fn make_box(arena: &mut Arena, id: u32, style: Style) {
410        let mut n = Node::new(Kind::Box);
411        n.style = style;
412        arena.insert(id, n);
413    }
414
415    fn make_text(arena: &mut Arena, id: u32, text: &str) {
416        let mut n = Node::new(Kind::Text);
417        n.text = Some(text.to_owned());
418        arena.insert(id, n);
419    }
420
421    fn make_text_styled(arena: &mut Arena, id: u32, text: &str, style: Style) {
422        let mut n = Node::new(Kind::Text);
423        n.text = Some(text.to_owned());
424        n.style = style;
425        arena.insert(id, n);
426    }
427
428    fn add_child(arena: &mut Arena, parent: u32, child: u32) {
429        arena.get_mut(parent).unwrap().children.push(child);
430    }
431
432    // ── E1: plain text at root (ink oracle) ─────────────────────────────────
433    // ink: renderToString(<Text>Hello</Text>) === "Hello"
434    #[test]
435    fn e1_plain_text() {
436        let mut a = Arena::new();
437        make_root(&mut a, 0);
438        make_text(&mut a, 1, "Hello");
439        add_child(&mut a, 0, 1);
440        assert_eq!(render_to_string(&a, 0, 80), "Hello");
441    }
442
443    // ── E2: single border, empty box (ink oracle) ────────────────────────────
444    // ink: renderToString(<Box borderStyle="single" width={10} height={3}/>) ===
445    //   "┌────────┐\n│        │\n└────────┘"
446    #[test]
447    fn e2_single_border_empty_10x3() {
448        let mut a = Arena::new();
449        make_root(&mut a, 0);
450        make_box(
451            &mut a,
452            1,
453            Style {
454                border_style: Some(BorderStyle::Named("single".to_owned())),
455                width: Some(Dim::Points(10.0)),
456                height: Some(Dim::Points(3.0)),
457                ..Style::default()
458            },
459        );
460        add_child(&mut a, 0, 1);
461        assert_eq!(
462            render_to_string(&a, 0, 80),
463            "┌────────┐\n│        │\n└────────┘"
464        );
465    }
466
467    // ── E3: border with text inside (ink oracle) ─────────────────────────────
468    // ink: renderToString(<Box borderStyle="single" width={12} height={4}><Text>Hi</Text></Box>) ===
469    //   "┌──────────┐\n│Hi        │\n│          │\n└──────────┘"
470    #[test]
471    fn e3_border_with_text() {
472        let mut a = Arena::new();
473        make_root(&mut a, 0);
474        make_box(
475            &mut a,
476            1,
477            Style {
478                border_style: Some(BorderStyle::Named("single".to_owned())),
479                width: Some(Dim::Points(12.0)),
480                height: Some(Dim::Points(4.0)),
481                ..Style::default()
482            },
483        );
484        make_text(&mut a, 2, "Hi");
485        add_child(&mut a, 0, 1);
486        add_child(&mut a, 1, 2);
487        assert_eq!(
488            render_to_string(&a, 0, 80),
489            "┌──────────┐\n│Hi        │\n│          │\n└──────────┘"
490        );
491    }
492
493    // ── E4: column layout, two boxes (ink oracle) ────────────────────────────
494    // ink: renderToString(
495    //   <Box flexDirection="column" width={10}>
496    //     <Box borderStyle="single"><Text>A</Text></Box>
497    //     <Box borderStyle="single"><Text>B</Text></Box>
498    //   </Box>) ===
499    //   "┌────────┐\n│A       │\n└────────┘\n┌────────┐\n│B       │\n└────────┘"
500    #[test]
501    fn e4_column_two_bordered_boxes() {
502        let mut a = Arena::new();
503        make_root(&mut a, 0);
504        make_box(
505            &mut a,
506            1,
507            Style {
508                flex_direction: Some(crate::dom::FlexDir::Column),
509                width: Some(Dim::Points(10.0)),
510                ..Style::default()
511            },
512        );
513        make_box(
514            &mut a,
515            2,
516            Style {
517                border_style: Some(BorderStyle::Named("single".to_owned())),
518                ..Style::default()
519            },
520        );
521        make_text(&mut a, 3, "A");
522        make_box(
523            &mut a,
524            4,
525            Style {
526                border_style: Some(BorderStyle::Named("single".to_owned())),
527                ..Style::default()
528            },
529        );
530        make_text(&mut a, 5, "B");
531        add_child(&mut a, 0, 1);
532        add_child(&mut a, 1, 2);
533        add_child(&mut a, 2, 3);
534        add_child(&mut a, 1, 4);
535        add_child(&mut a, 4, 5);
536        assert_eq!(
537            render_to_string(&a, 0, 80),
538            "┌────────┐\n│A       │\n└────────┘\n┌────────┐\n│B       │\n└────────┘"
539        );
540    }
541
542    // ── E5: padding (ink oracle) ─────────────────────────────────────────────
543    // ink: renderToString(<Box padding={1}><Text>Hello</Text></Box>, {columns:20}) ===
544    //   "\n Hello\n"
545    #[test]
546    fn e5_box_with_padding() {
547        let mut a = Arena::new();
548        make_root(&mut a, 0);
549        make_box(
550            &mut a,
551            1,
552            Style {
553                padding: Some(Lp::Points(1.0)),
554                ..Style::default()
555            },
556        );
557        make_text(&mut a, 2, "Hello");
558        add_child(&mut a, 0, 1);
559        add_child(&mut a, 1, 2);
560        assert_eq!(render_to_string(&a, 0, 20), "\n Hello\n");
561    }
562
563    // ── E6: text wrapping (ink oracle) ───────────────────────────────────────
564    // ink: renderToString(<Box width={8}><Text>hello world</Text></Box>) ===
565    //   "hello\nworld"
566    #[test]
567    fn e6_text_wrap() {
568        let mut a = Arena::new();
569        make_root(&mut a, 0);
570        make_box(
571            &mut a,
572            1,
573            Style {
574                width: Some(Dim::Points(8.0)),
575                ..Style::default()
576            },
577        );
578        make_text(&mut a, 2, "hello world");
579        add_child(&mut a, 0, 1);
580        add_child(&mut a, 1, 2);
581        assert_eq!(render_to_string(&a, 0, 80), "hello\nworld");
582    }
583
584    // ── E7: overflow:hidden (ink oracle) ─────────────────────────────────────
585    // ink: renderToString(
586    //   <Box width={5} height={1} overflow="hidden"><Text>hello world</Text></Box>
587    // ) === "hello"
588    #[test]
589    fn e7_overflow_hidden() {
590        let mut a = Arena::new();
591        make_root(&mut a, 0);
592        make_box(
593            &mut a,
594            1,
595            Style {
596                width: Some(Dim::Points(5.0)),
597                height: Some(Dim::Points(1.0)),
598                overflow_x: Some(Overflow::Hidden),
599                overflow_y: Some(Overflow::Hidden),
600                ..Style::default()
601            },
602        );
603        make_text(&mut a, 2, "hello world");
604        add_child(&mut a, 0, 1);
605        add_child(&mut a, 1, 2);
606        assert_eq!(render_to_string(&a, 0, 80), "hello");
607    }
608
609    // ── E7b: border + overflow:hidden together (ink oracle) ──────────────────
610    // The clip border-inset path in walk.rs (clip = rect inset by active border
611    // edges) runs ONLY when a border and overflow:hidden coexist — every other
612    // overflow test has no border and every border test has no overflow.
613    // ink: renderToString(
614    //   <Box borderStyle="single" width={7} height={3} overflow="hidden">
615    //     <Text>hello world</Text>
616    //   </Box>) === "┌─────┐\n│hello│\n└─────┘"
617    #[test]
618    fn e7b_border_with_overflow_hidden_insets_clip() {
619        let mut a = Arena::new();
620        make_root(&mut a, 0);
621        make_box(
622            &mut a,
623            1,
624            Style {
625                width: Some(Dim::Points(7.0)),
626                height: Some(Dim::Points(3.0)),
627                border_style: Some(BorderStyle::Named("single".to_owned())),
628                overflow_x: Some(Overflow::Hidden),
629                overflow_y: Some(Overflow::Hidden),
630                ..Style::default()
631            },
632        );
633        make_text(&mut a, 2, "hello world");
634        add_child(&mut a, 0, 1);
635        add_child(&mut a, 1, 2);
636        assert_eq!(render_to_string(&a, 0, 80), "┌─────┐\n│hello│\n└─────┘");
637    }
638
639    // ── E8: display:none skipped (ink oracle) ────────────────────────────────
640    // ink: renderToString(
641    //   <Box flexDirection="column" width={20}>
642    //     <Box display="none"><Text>hidden</Text></Box>
643    //     <Text>visible</Text>
644    //   </Box>) === "visible"
645    #[test]
646    fn e8_display_none_skipped() {
647        let mut a = Arena::new();
648        make_root(&mut a, 0);
649        make_box(
650            &mut a,
651            1,
652            Style {
653                flex_direction: Some(crate::dom::FlexDir::Column),
654                width: Some(Dim::Points(20.0)),
655                ..Style::default()
656            },
657        );
658        make_box(
659            &mut a,
660            2,
661            Style {
662                display: Some(Display::None),
663                ..Style::default()
664            },
665        );
666        make_text(&mut a, 3, "hidden");
667        make_text(&mut a, 4, "visible");
668        add_child(&mut a, 0, 1);
669        add_child(&mut a, 1, 2);
670        add_child(&mut a, 2, 3);
671        add_child(&mut a, 1, 4);
672        assert_eq!(render_to_string(&a, 0, 80), "visible");
673    }
674
675    // ── E9: double border (ink oracle) ───────────────────────────────────────
676    // ink: renderToString(<Box borderStyle="double" width={10} height={3}/>) ===
677    //   "╔════════╗\n║        ║\n╚════════╝"
678    #[test]
679    fn e9_double_border() {
680        let mut a = Arena::new();
681        make_root(&mut a, 0);
682        make_box(
683            &mut a,
684            1,
685            Style {
686                border_style: Some(BorderStyle::Named("double".to_owned())),
687                width: Some(Dim::Points(10.0)),
688                height: Some(Dim::Points(3.0)),
689                ..Style::default()
690            },
691        );
692        add_child(&mut a, 0, 1);
693        assert_eq!(
694            render_to_string(&a, 0, 80),
695            "╔════════╗\n║        ║\n╚════════╝"
696        );
697    }
698
699    // ── E10: text truncate (ink oracle) ─────────────────────────────────────
700    // ink: renderToString(<Box width={8}><Text wrap="truncate">hello world</Text></Box>) ===
701    //   "hello w…"
702    #[test]
703    fn e10_text_truncate_end() {
704        let mut a = Arena::new();
705        make_root(&mut a, 0);
706        make_box(
707            &mut a,
708            1,
709            Style {
710                width: Some(Dim::Points(8.0)),
711                ..Style::default()
712            },
713        );
714        make_text_styled(
715            &mut a,
716            2,
717            "hello world",
718            Style {
719                text_wrap: Some(TextWrap::TruncateEnd),
720                ..Style::default()
721            },
722        );
723        add_child(&mut a, 0, 1);
724        add_child(&mut a, 1, 2);
725        assert_eq!(render_to_string(&a, 0, 80), "hello w\u{2026}");
726    }
727
728    // ── E11: gap between boxes (ink oracle) ──────────────────────────────────
729    // ink: renderToString(
730    //   <Box flexDirection="column" gap={1} width={10}>
731    //     <Text>line1</Text><Text>line2</Text>
732    //   </Box>) === "line1\n\nline2"
733    #[test]
734    fn e11_gap_column() {
735        let mut a = Arena::new();
736        make_root(&mut a, 0);
737        make_box(
738            &mut a,
739            1,
740            Style {
741                flex_direction: Some(crate::dom::FlexDir::Column),
742                width: Some(Dim::Points(10.0)),
743                gap: Some(1.0),
744                ..Style::default()
745            },
746        );
747        make_text(&mut a, 2, "line1");
748        make_text(&mut a, 3, "line2");
749        add_child(&mut a, 0, 1);
750        add_child(&mut a, 1, 2);
751        add_child(&mut a, 1, 3);
752        assert_eq!(render_to_string(&a, 0, 80), "line1\n\nline2");
753    }
754
755    // ── E12: no-top border (ink oracle) ─────────────────────────────────────
756    // ink: renderToString(<Box borderStyle="single" borderTop={false} width={10} height={3}/>) ===
757    //   "│        │\n│        │\n└────────┘"
758    #[test]
759    fn e12_no_top_border() {
760        let mut a = Arena::new();
761        make_root(&mut a, 0);
762        make_box(
763            &mut a,
764            1,
765            Style {
766                border_style: Some(BorderStyle::Named("single".to_owned())),
767                border_top: Some(false),
768                width: Some(Dim::Points(10.0)),
769                height: Some(Dim::Points(3.0)),
770                ..Style::default()
771            },
772        );
773        add_child(&mut a, 0, 1);
774        assert_eq!(
775            render_to_string(&a, 0, 80),
776            "│        │\n│        │\n└────────┘"
777        );
778    }
779
780    // ── E13: deeply nested border+padding (ink oracle) ───────────────────────
781    // ink: renderToString(
782    //   <Box borderStyle="single" padding={1} width={20} height={7}>
783    //     <Box borderStyle="double"><Text>inner</Text></Box>
784    //   </Box>) ===
785    // "┌──────────────────┐\n│                  │\n│ ╔═════╗          │\n│ ║inner║          │\n│ ╚═════╝          │\n│                  │\n└──────────────────┘"
786    #[test]
787    fn e13_nested_border_padding() {
788        let mut a = Arena::new();
789        make_root(&mut a, 0);
790        make_box(
791            &mut a,
792            1,
793            Style {
794                border_style: Some(BorderStyle::Named("single".to_owned())),
795                padding: Some(Lp::Points(1.0)),
796                width: Some(Dim::Points(20.0)),
797                height: Some(Dim::Points(7.0)),
798                ..Style::default()
799            },
800        );
801        make_box(
802            &mut a,
803            2,
804            Style {
805                border_style: Some(BorderStyle::Named("double".to_owned())),
806                ..Style::default()
807            },
808        );
809        make_text(&mut a, 3, "inner");
810        add_child(&mut a, 0, 1);
811        add_child(&mut a, 1, 2);
812        add_child(&mut a, 2, 3);
813        assert_eq!(
814            render_to_string(&a, 0, 80),
815            "┌──────────────────┐\n│                  │\n│ ╔═════╗          │\n│ ║inner║          │\n│ ╚═════╝          │\n│                  │\n└──────────────────┘"
816        );
817    }
818
819    // ── E14: no-left border (ink oracle) ────────────────────────────────────
820    // ink: renderToString(<Box borderStyle="single" borderLeft={false} width={10} height={3}/>) ===
821    //   "─────────┐\n         │\n─────────┘"
822    #[test]
823    fn e14_no_left_border() {
824        let mut a = Arena::new();
825        make_root(&mut a, 0);
826        make_box(
827            &mut a,
828            1,
829            Style {
830                border_style: Some(BorderStyle::Named("single".to_owned())),
831                border_left: Some(false),
832                width: Some(Dim::Points(10.0)),
833                height: Some(Dim::Points(3.0)),
834                ..Style::default()
835            },
836        );
837        add_child(&mut a, 0, 1);
838        assert_eq!(
839            render_to_string(&a, 0, 80),
840            "─────────┐\n         │\n─────────┘"
841        );
842    }
843
844    // ── E15: two text nodes side by side (ink oracle) ────────────────────────
845    // ink: renderToString(<Box width={20}><Text>left</Text><Text>right</Text></Box>) ===
846    //   "leftright"
847    #[test]
848    fn e15_two_text_nodes_row() {
849        let mut a = Arena::new();
850        make_root(&mut a, 0);
851        make_box(
852            &mut a,
853            1,
854            Style {
855                width: Some(Dim::Points(20.0)),
856                ..Style::default()
857            },
858        );
859        make_text(&mut a, 2, "left");
860        make_text(&mut a, 3, "right");
861        add_child(&mut a, 0, 1);
862        add_child(&mut a, 1, 2);
863        add_child(&mut a, 1, 3);
864        assert_eq!(render_to_string(&a, 0, 80), "leftright");
865    }
866
867    // ── M2-D regression: colorless frame byte-equals the pre-styled output ────
868    // After promoting the grid to StyledChar cells, a colorless frame must be
869    // byte-IDENTICAL to the old plain path: every cell's `styles` is empty, so
870    // `styled_chars_to_string` degenerates to plain `.value` concatenation and
871    // `trim_end_matches(' ')` collapses trailing spaces exactly as before.
872    // We assert a border-with-text frame (border draw + text write + trailing
873    // pad on every interior row) equals its plain string AND carries NO ESC byte
874    // — proving the styled machinery emits zero SGR for uncolored input.
875    #[test]
876    fn m2d_colorless_frame_byte_equals_plain() {
877        let mut a = Arena::new();
878        make_root(&mut a, 0);
879        make_box(
880            &mut a,
881            1,
882            Style {
883                border_style: Some(BorderStyle::Named("single".to_owned())),
884                width: Some(Dim::Points(12.0)),
885                height: Some(Dim::Points(4.0)),
886                ..Style::default()
887            },
888        );
889        make_text(&mut a, 2, "Hi");
890        add_child(&mut a, 0, 1);
891        add_child(&mut a, 1, 2);
892        let out = render_to_string(&a, 0, 80);
893        assert_eq!(
894            out,
895            "┌──────────┐\n│Hi        │\n│          │\n└──────────┘"
896        );
897        assert!(
898            !out.contains('\u{1b}'),
899            "colorless frame must contain no SGR escape bytes"
900        );
901    }
902
903    // ═══ M3-A engine-lifetime seam (ADR-3) ═══════════════════════════════════
904    //
905    // These tests prove the persist-arena / rebuild-engine-per-frame contract
906    // that `InkRoot` (M3-D) relies on: a SINGLE long-lived `Arena` is mutated
907    // by ops between renders, and each render drives a FRESH engine via the
908    // `build_layout_engine` seam. They are the M3-A correctness gate — the
909    // thing the rejected Option B would have risked (silent measure corruption).
910
911    use crate::dom::{Op, apply};
912
913    /// Render the persisted arena through the seam exactly as `InkRoot` will:
914    /// build a fresh engine each call, then walk into a grid. Mirrors
915    /// `render_to_string`'s body so the two paths cannot drift.
916    fn render_via_seam(arena: &Arena, root_id: u32, width: u16) -> String {
917        let Some((engine, root_rect)) = build_layout_engine(arena, root_id, width) else {
918            return String::new();
919        };
920        let grid_rows = root_rect.height as usize;
921        let grid_cols = root_rect.width as usize;
922        if grid_rows == 0 || grid_cols == 0 {
923            return String::new();
924        }
925        let mut grid = Grid::new(grid_rows, grid_cols);
926        let rect_fn = |id: u32| engine.computed(id);
927        walk(
928            arena,
929            root_id,
930            &rect_fn,
931            &|_| None,
932            &mut grid,
933            crate::render::colorize::ColorLevel::Truecolor,
934        );
935        grid.get().0
936    }
937
938    // ── A1: two sequential renders, SetText between them → measure reflects the
939    //        NEW text (this is the load-bearing measure-invalidation proof).
940    //
941    // The box is width=8. "hi" fits on one line. After `SetText` to
942    // "hello world" the fresh-engine rebuild re-`set_measure`s the text node,
943    // so the second frame measures the NEW string and wraps it to "hello\nworld"
944    // (same oracle as E6). A stale measure closure (Option B's risk) would keep
945    // measuring "hi" and produce a wrong frame. One persisted arena, two
946    // fresh-engine renders.
947    #[test]
948    fn a1_seam_settext_between_renders_remeasures() {
949        let mut a = Arena::new();
950        make_root(&mut a, 0);
951        make_box(
952            &mut a,
953            1,
954            Style {
955                width: Some(Dim::Points(8.0)),
956                ..Style::default()
957            },
958        );
959        make_text(&mut a, 2, "hi");
960        add_child(&mut a, 0, 1);
961        add_child(&mut a, 1, 2);
962
963        // Frame 1: short text fits on one line.
964        let frame1 = render_via_seam(&a, 0, 80);
965        assert_eq!(
966            frame1, "hi",
967            "frame 1 should render the original short text"
968        );
969
970        // Mutate the persisted arena: SetText to a string that wraps at width 8.
971        apply(
972            &mut a,
973            &[Op::SetText {
974                id: 2,
975                text: "hello world".to_owned(),
976            }],
977        );
978
979        // Frame 2: a FRESH engine must re-measure the new text and wrap it.
980        let frame2 = render_via_seam(&a, 0, 80);
981        assert_eq!(
982            frame2, "hello\nworld",
983            "frame 2 must reflect the new text's measurement (wrap at width 8) — \
984             a stale measure closure would still measure \"hi\""
985        );
986    }
987
988    // ── A2: SetStyle changing the box width between renders → layout reflects
989    //        the NEW width. width=20 keeps "hello world" on one line; shrinking
990    //        to 8 must wrap it. Proves style mutation on the persisted arena is
991    //        honored by the next fresh-engine render.
992    #[test]
993    fn a2_seam_setstyle_width_change_between_renders() {
994        let mut a = Arena::new();
995        make_root(&mut a, 0);
996        make_box(
997            &mut a,
998            1,
999            Style {
1000                width: Some(Dim::Points(20.0)),
1001                ..Style::default()
1002            },
1003        );
1004        make_text(&mut a, 2, "hello world");
1005        add_child(&mut a, 0, 1);
1006        add_child(&mut a, 1, 2);
1007
1008        let frame1 = render_via_seam(&a, 0, 80);
1009        assert_eq!(frame1, "hello world", "width 20 keeps the text on one line");
1010
1011        apply(
1012            &mut a,
1013            &[Op::SetStyle {
1014                id: 1,
1015                style: Box::new(Style {
1016                    width: Some(Dim::Points(8.0)),
1017                    ..Style::default()
1018                }),
1019            }],
1020        );
1021
1022        let frame2 = render_via_seam(&a, 0, 80);
1023        assert_eq!(
1024            frame2, "hello\nworld",
1025            "shrinking the box to width 8 must wrap the text on the next render"
1026        );
1027    }
1028
1029    // ── A3: node removal mid-tree between renders → removed subtree disappears.
1030    //        Column of three text lines; RemoveChild + Free the middle node;
1031    //        the next fresh-engine render shows the tree without it. Proves the
1032    //        rebuild reads the post-removal arena (no orphaned taffy node from a
1033    //        reused engine).
1034    #[test]
1035    fn a3_seam_node_removal_between_renders() {
1036        let mut a = Arena::new();
1037        make_root(&mut a, 0);
1038        make_box(
1039            &mut a,
1040            1,
1041            Style {
1042                flex_direction: Some(crate::dom::FlexDir::Column),
1043                width: Some(Dim::Points(10.0)),
1044                ..Style::default()
1045            },
1046        );
1047        make_text(&mut a, 2, "alpha");
1048        make_text(&mut a, 3, "bravo");
1049        make_text(&mut a, 4, "gamma");
1050        add_child(&mut a, 0, 1);
1051        add_child(&mut a, 1, 2);
1052        add_child(&mut a, 1, 3);
1053        add_child(&mut a, 1, 4);
1054
1055        let frame1 = render_via_seam(&a, 0, 80);
1056        assert_eq!(
1057            frame1, "alpha\nbravo\ngamma",
1058            "frame 1 renders all three column children"
1059        );
1060
1061        // Remove the middle child from its parent, then free its slot — the
1062        // op pair the reconciler emits (RemoveChild on removeChild, Free on
1063        // detachDeletedInstance, op.rs).
1064        apply(
1065            &mut a,
1066            &[
1067                Op::RemoveChild {
1068                    parent: 1,
1069                    child: 3,
1070                },
1071                Op::Free { id: 3 },
1072            ],
1073        );
1074
1075        let frame2 = render_via_seam(&a, 0, 80);
1076        assert_eq!(
1077            frame2, "alpha\ngamma",
1078            "frame 2 must drop the removed middle node — fresh engine reads the \
1079             post-removal arena with no orphan from a stale tree"
1080        );
1081    }
1082
1083    // ── A4: the seam's frozen-wrapper guarantee — `render_to_string` and a
1084    //        direct seam render produce byte-identical output for the same
1085    //        arena. If `render_to_string` ever drifts from the seam, this fails.
1086    #[test]
1087    fn a4_seam_matches_render_to_string() {
1088        let mut a = Arena::new();
1089        make_root(&mut a, 0);
1090        make_box(
1091            &mut a,
1092            1,
1093            Style {
1094                border_style: Some(BorderStyle::Named("single".to_owned())),
1095                width: Some(Dim::Points(12.0)),
1096                height: Some(Dim::Points(4.0)),
1097                ..Style::default()
1098            },
1099        );
1100        make_text(&mut a, 2, "Hi");
1101        add_child(&mut a, 0, 1);
1102        add_child(&mut a, 1, 2);
1103        assert_eq!(render_via_seam(&a, 0, 80), render_to_string(&a, 0, 80));
1104    }
1105
1106    // ═══ M3-B styled-render entry (oracle-parity gate) ════════════════════════
1107    //
1108    // Every expected literal below was MATERIALIZED by running the live ink
1109    // oracle at /home/alpha/rewrite/ink with FORCE_COLOR=3 (chalk level 3) via a
1110    // scratch `renderToString` probe (test/helpers/render-to-string.ts +
1111    // force-colors.ts), then deleted. Command (all fixtures in one run):
1112    //   FORCE_COLOR=3 npx tsx scratch_m3b_oracle.tsx
1113    // where each fixture is `renderToString(<…/>)` and the bytes are dumped as a
1114    // JSON-escaped string. The exact oracle JSON output is pinned per test.
1115    //
1116    // These tests prove the styled entry (`render_styled`) reproduces ink for
1117    // `<Text color>` SGR and `<Transform>` callbacks — `<Text color>` SGR lives
1118    // INSIDE the per-node transform (Text.tsx:94-130 → colorize), so a core test
1119    // wires `colorize` into the accessor exactly as the napi layer will dispatch
1120    // to a JS `internal_transform`.
1121
1122    use crate::render::colorize::{ColorLevel, Kind as ColorKind, colorize, dim};
1123    use crate::render::walk::TransformAccessor;
1124
1125    /// An owned output transform — the per-line closure shape the accessor mints
1126    /// (matches the `Box<…>` inside [`TransformAccessor`]).
1127    type TextTransform = Box<dyn Fn(&str, usize) -> String>;
1128
1129    /// Build a `<Text>`-style transform mirroring Text.tsx:94-130's EXACT order:
1130    /// dimColor → color(fg) → backgroundColor → bold → italic → underline →
1131    /// strikethrough → inverse. `bold`/`italic`/… resolve through `colorize`'s
1132    /// named-style branch (they are in STYLE_NAMES), reproducing chalk's bytes.
1133    /// Each flag is the chalk style name (or `None` to skip).
1134    fn text_transform(
1135        dim_color: bool,
1136        color: Option<&'static str>,
1137        bg_color: Option<&'static str>,
1138        bold: bool,
1139    ) -> TextTransform {
1140        Box::new(move |s: &str, _i: usize| {
1141            // These accessor transforms mimic ink's JS-side `<Text>` colorize,
1142            // which the conformance harness forces to chalk.level=3 — so they pin
1143            // level-3 (Truecolor) SGR bytes here too.
1144            let lvl = ColorLevel::Truecolor;
1145            // Text.tsx:95-97
1146            let mut out = if dim_color { dim(s, lvl) } else { s.to_owned() };
1147            // Text.tsx:99-101
1148            if let Some(c) = color {
1149                out = colorize(&out, Some(c), ColorKind::Fg, lvl);
1150            }
1151            // Text.tsx:103-108
1152            if let Some(bg) = bg_color {
1153                out = colorize(&out, Some(bg), ColorKind::Bg, lvl);
1154            }
1155            // Text.tsx:110-112
1156            if bold {
1157                out = colorize(&out, Some("bold"), ColorKind::Fg, lvl);
1158            }
1159            out
1160        })
1161    }
1162
1163    /// Render `<Text …>Test</Text>` at root (Root→Text), with the Text node's
1164    /// own transform supplied by `mk` (a Text.tsx-order transform). Mirrors the
1165    /// oracle's `renderToString(<Text …>Test</Text>)`.
1166    fn render_single_text(text: &str, mk: fn() -> TextTransform) -> String {
1167        let mut a = Arena::new();
1168        make_root(&mut a, 0);
1169        make_text(&mut a, 1, text);
1170        add_child(&mut a, 0, 1);
1171        // Accessor: node 1 carries the transform; all others None.
1172        let t = mk();
1173        let acc: &TransformAccessor<'_> = &|id: u32| match id {
1174            1 => Some(Box::new(|s: &str, i: usize| t(s, i)) as _),
1175            _ => None,
1176        };
1177        render_styled(&a, 0, 100, acc, ColorLevel::Truecolor).0
1178    }
1179
1180    // ── named color (fg) ──────────────────────────────────────────────────────
1181    // oracle: renderToString(<Text color="green">Test</Text>)
1182    //   == "Test"
1183    #[test]
1184    fn m3b_named_color_fg() {
1185        let out = render_single_text("Test", || text_transform(false, Some("green"), None, false));
1186        assert_eq!(out, "\u{1b}[32mTest\u{1b}[39m");
1187    }
1188
1189    // ── hex color (fg) ────────────────────────────────────────────────────────
1190    // oracle: renderToString(<Text color="#ff8800">Test</Text>)
1191    //   == "Test"
1192    #[test]
1193    fn m3b_hex_color_fg() {
1194        let out = render_single_text("Test", || {
1195            text_transform(false, Some("#ff8800"), None, false)
1196        });
1197        assert_eq!(out, "\u{1b}[38;2;255;136;0mTest\u{1b}[39m");
1198    }
1199
1200    // ── background color ──────────────────────────────────────────────────────
1201    // oracle: renderToString(<Text backgroundColor="green">Test</Text>)
1202    //   == "Test"
1203    #[test]
1204    fn m3b_bg_color() {
1205        let out = render_single_text("Test", || text_transform(false, None, Some("green"), false));
1206        assert_eq!(out, "\u{1b}[42mTest\u{1b}[49m");
1207    }
1208
1209    // ── dim ───────────────────────────────────────────────────────────────────
1210    // oracle: renderToString(<Text dimColor>Test</Text>)
1211    //   == "Test"
1212    #[test]
1213    fn m3b_dim() {
1214        let out = render_single_text("Test", || text_transform(true, None, None, false));
1215        assert_eq!(out, "\u{1b}[2mTest\u{1b}[22m");
1216    }
1217
1218    // ── bold + color combo (order: color fg inner, bold outer per Text.tsx) ────
1219    // oracle: renderToString(<Text bold color="red">Test</Text>)
1220    //   == "Test"
1221    // (Text.tsx applies color BEFORE bold, so bold's 1/22 wraps the red span.)
1222    #[test]
1223    fn m3b_bold_color_combo() {
1224        let out = render_single_text("Test", || text_transform(false, Some("red"), None, true));
1225        assert_eq!(out, "\u{1b}[1m\u{1b}[31mTest\u{1b}[39m\u{1b}[22m");
1226    }
1227
1228    // ── P6.2 CLEAR_TEXT_STYLE render-byte equivalence (op→apply→render loop) ───
1229    // The styled→plain transition is pinned at the op-emit (native-style-emit),
1230    // decode (decode_tests), and apply (op.rs) layers — but nothing renders BYTES
1231    // to confirm a CLEARED node renders PLAIN. This closes that loop end-to-end.
1232    //
1233    // We drive the NATIVE path: `render_styled` with an all-None accessor resolves
1234    // each node through `resolve_transform` (render/walk.rs), which composes SGR
1235    // from the node's OWN `text_styling` field — exactly the path the napi layer
1236    // uses for a simple styled `<Text>`. So applying SetTextStyle{red} then Clear
1237    // through `apply` (the real op-application code) and rendering twice exercises
1238    // the whole op→apply→render chain, not a hand-injected transform.
1239    //
1240    // Non-vacuity guard: the FIRST render MUST contain the red SGR (`\x1b[31m`) —
1241    // if it didn't, the node was never actually styled and the byte-identity check
1242    // below would pass vacuously. The SECOND render (after ClearTextStyle) must be
1243    // byte-identical to the SAME `Test` node rendered as a never-styled plain node.
1244    #[test]
1245    fn p6_2_clear_text_style_renders_plain_bytes() {
1246        use crate::dom::{Op, TextStyle, apply};
1247
1248        // Oracle: the SAME content rendered as a never-styled plain node. Built in
1249        // its own arena so no styling op ever touched it.
1250        let mut plain = Arena::new();
1251        make_root(&mut plain, 0);
1252        make_text(&mut plain, 1, "Test");
1253        add_child(&mut plain, 0, 1);
1254        let plain_bytes = render_styled(&plain, 0, 100, &|_| None, ColorLevel::Truecolor).0;
1255
1256        // The node under test: Root → Text("Test").
1257        let mut a = Arena::new();
1258        make_root(&mut a, 0);
1259        make_text(&mut a, 1, "Test");
1260        add_child(&mut a, 0, 1);
1261
1262        // SetTextStyle{color: red} via the real apply() path, then render NATIVE
1263        // (all-None accessor → resolve_transform reads `text_styling`).
1264        apply(
1265            &mut a,
1266            &[Op::SetTextStyle {
1267                id: 1,
1268                style: TextStyle {
1269                    color: Some("red".into()),
1270                    ..Default::default()
1271                },
1272            }],
1273        );
1274        let styled_bytes = render_styled(&a, 0, 100, &|_| None, ColorLevel::Truecolor).0;
1275
1276        // Non-vacuity: the styled render really carries the red SGR. Without this,
1277        // a no-op apply would make the byte-identity below pass for the wrong reason.
1278        assert!(
1279            styled_bytes.contains("\u{1b}[31m"),
1280            "precondition: the red-styled node renders the red SGR (\\x1b[31m); got {styled_bytes:?}"
1281        );
1282        // It also diverges from the plain oracle (belt-and-braces: styled ≠ plain).
1283        assert_ne!(
1284            styled_bytes, plain_bytes,
1285            "the red-styled render must differ from the plain render"
1286        );
1287
1288        // ClearTextStyle via apply(), then render again — must render PLAIN.
1289        apply(&mut a, &[Op::ClearTextStyle { id: 1 }]);
1290        let cleared_bytes = render_styled(&a, 0, 100, &|_| None, ColorLevel::Truecolor).0;
1291
1292        assert_eq!(
1293            cleared_bytes, plain_bytes,
1294            "after ClearTextStyle the node renders BYTE-IDENTICAL to a never-styled plain node (P6.2)"
1295        );
1296    }
1297
1298    // ── transform callback (uppercase) ────────────────────────────────────────
1299    // oracle: renderToString(
1300    //   <Transform transform={s => s.toUpperCase()}><Text>hello</Text></Transform>)
1301    //   == "HELLO"
1302    // <Transform> renders an ink-text wrapping the inner <Text> ink-text; the
1303    // uppercase transform is applied via output.write on the OUTER text node.
1304    // Arena: Root→Text(outer, uppercase)→Text(inner "hello", identity).
1305    #[test]
1306    fn m3b_transform_uppercase() {
1307        let mut a = Arena::new();
1308        make_root(&mut a, 0);
1309        make_text(&mut a, 1, ""); // outer <Transform> ink-text: no own #text
1310        make_text(&mut a, 2, "hello"); // inner <Text> leaf
1311        add_child(&mut a, 0, 1);
1312        add_child(&mut a, 1, 2);
1313        let acc: &TransformAccessor<'_> = &|id: u32| match id {
1314            1 => Some(Box::new(|s: &str, _i: usize| s.to_uppercase())),
1315            _ => None,
1316        };
1317        assert_eq!(
1318            render_styled(&a, 0, 100, acc, ColorLevel::Truecolor).0,
1319            "HELLO"
1320        );
1321    }
1322
1323    // ── nested transformers (order proof) ─────────────────────────────────────
1324    // oracle: renderToString(
1325    //   <Transform transform={s => `O(${s})`}>
1326    //     <Transform transform={s => `I(${s})`}>
1327    //       <Text>x</Text>
1328    //   </Transform></Transform>)
1329    //   == "O(I(x))"
1330    // The inner transform is applied during squash (squash-text-nodes.ts:34-39);
1331    // the outer via output.write. Arena: Root→Text(outer O)→Text(inner I)→Text("x").
1332    // Proves innermost-first order: I wraps x, then O wraps that.
1333    #[test]
1334    fn m3b_nested_transformers_order() {
1335        let mut a = Arena::new();
1336        make_root(&mut a, 0);
1337        make_text(&mut a, 1, ""); // outer <Transform>
1338        make_text(&mut a, 2, ""); // inner <Transform>
1339        make_text(&mut a, 3, "x"); // <Text>x</Text>
1340        add_child(&mut a, 0, 1);
1341        add_child(&mut a, 1, 2);
1342        add_child(&mut a, 2, 3);
1343        let acc: &TransformAccessor<'_> = &|id: u32| match id {
1344            1 => Some(Box::new(|s: &str, _i: usize| format!("O({s})"))),
1345            2 => Some(Box::new(|s: &str, _i: usize| format!("I({s})"))),
1346            _ => None,
1347        };
1348        assert_eq!(
1349            render_styled(&a, 0, 100, acc, ColorLevel::Truecolor).0,
1350            "O(I(x))"
1351        );
1352    }
1353
1354    // ── color + transform combined (squash/write interleave proof) ────────────
1355    // oracle: renderToString(
1356    //   <Transform transform={s => s.toUpperCase()}><Text color="green">test</Text></Transform>)
1357    //   == "TEST"
1358    // The inner <Text color> colorizes "test" → "\x1b[32mtest\x1b[39m" (in squash),
1359    // then the outer uppercase transform (via write) uppercases the WHOLE string
1360    // INCLUDING the SGR letters m→M. The uppercased SGR is the proof that color
1361    // (inner/squash) runs strictly before the transform (outer/write).
1362    #[test]
1363    fn m3b_color_then_transform_interleave() {
1364        let mut a = Arena::new();
1365        make_root(&mut a, 0);
1366        make_text(&mut a, 1, ""); // outer <Transform> uppercase
1367        make_text(&mut a, 2, "test"); // inner <Text color="green">
1368        add_child(&mut a, 0, 1);
1369        add_child(&mut a, 1, 2);
1370        let acc: &TransformAccessor<'_> = &|id: u32| match id {
1371            1 => Some(Box::new(|s: &str, _i: usize| s.to_uppercase())),
1372            2 => Some(Box::new(|s: &str, _i: usize| {
1373                colorize(s, Some("green"), ColorKind::Fg, ColorLevel::Truecolor)
1374            })),
1375            _ => None,
1376        };
1377        assert_eq!(
1378            render_styled(&a, 0, 100, acc, ColorLevel::Truecolor).0,
1379            "\u{1b}[32MTEST\u{1b}[39M"
1380        );
1381    }
1382
1383    // ── height return correctness (multi-line frame) ──────────────────────────
1384    // oracle: renderToString(
1385    //   <Box flexDirection="column" width={10}>
1386    //     <Text>line1</Text><Text>line2</Text><Text>line3</Text></Box>)
1387    //   == "line1\nline2\nline3" (3 rows)
1388    // render_styled must return both the string AND height == 3.
1389    #[test]
1390    fn m3b_height_return_multiline() {
1391        let mut a = Arena::new();
1392        make_root(&mut a, 0);
1393        make_box(
1394            &mut a,
1395            1,
1396            Style {
1397                flex_direction: Some(crate::dom::FlexDir::Column),
1398                width: Some(Dim::Points(10.0)),
1399                ..Style::default()
1400            },
1401        );
1402        make_text(&mut a, 2, "line1");
1403        make_text(&mut a, 3, "line2");
1404        make_text(&mut a, 4, "line3");
1405        add_child(&mut a, 0, 1);
1406        add_child(&mut a, 1, 2);
1407        add_child(&mut a, 1, 3);
1408        add_child(&mut a, 1, 4);
1409        let (out, height) = render_styled(&a, 0, 80, &|_| None, ColorLevel::Truecolor);
1410        assert_eq!(out, "line1\nline2\nline3");
1411        assert_eq!(height, 3, "3 column text lines → height 3");
1412    }
1413
1414    // ── drift guard: no styles/no transforms == render_to_string + height ──────
1415    // The styled entry with an all-None accessor must byte-equal render_to_string
1416    // AND report the correct height. Covers a border-with-text frame (border draw
1417    // + text write + interior pads): the no-op accessor path is the regression
1418    // oracle for byte-identity (the corpus proves the same for layout fixtures).
1419    #[test]
1420    fn m3b_drift_guard_noop_equals_plain() {
1421        let mut a = Arena::new();
1422        make_root(&mut a, 0);
1423        make_box(
1424            &mut a,
1425            1,
1426            Style {
1427                border_style: Some(BorderStyle::Named("single".to_owned())),
1428                width: Some(Dim::Points(12.0)),
1429                height: Some(Dim::Points(4.0)),
1430                ..Style::default()
1431            },
1432        );
1433        make_text(&mut a, 2, "Hi");
1434        add_child(&mut a, 0, 1);
1435        add_child(&mut a, 1, 2);
1436
1437        let plain = render_to_string(&a, 0, 80);
1438        let (styled, height) = render_styled(&a, 0, 80, &|_| None, ColorLevel::Truecolor);
1439        assert_eq!(
1440            styled, plain,
1441            "no-op accessor must byte-equal render_to_string"
1442        );
1443        assert_eq!(
1444            styled,
1445            "┌──────────┐\n│Hi        │\n│          │\n└──────────┘"
1446        );
1447        assert_eq!(height, 4, "4-row bordered box → height 4");
1448        assert!(
1449            !styled.contains('\u{1b}'),
1450            "no-op accessor frame must carry no SGR"
1451        );
1452    }
1453
1454    // ── nested styled text correctness (the real-world case squash threading fixes) ──
1455    // <Text color="red">a<Text color="blue">b</Text></Text>: ink colors ONLY "b"
1456    // blue (the inner child's transform applied in squash), with "a" + the blue
1457    // span both wrapped red by the outer (via write). Materialized from the oracle:
1458    //   renderToString(<Text color="red">a<Text color="blue">b</Text></Text>)
1459    //   == "ab"
1460    // (oracle: FORCE_COLOR=3 renderToString, chalk level 3.) Without squash
1461    // threading "b" would be colored red, not blue — this is the correctness gate.
1462    // ── nested same-axis styled text: inner color applies to the CHILD span ───
1463    // <Text color="red">a<Text color="blue">b</Text></Text>: the inner child's
1464    // blue transform is applied to ONLY its own folded substring ("b") during
1465    // squash (squash-text-nodes.ts:34-39); the outer red wraps the whole via
1466    // write. This is the real-world correctness the squash threading exists for —
1467    // without it, "b" would be red, not blue.
1468    //
1469    // Oracle (FORCE_COLOR=3 renderToString, chalk level 3):
1470    //   renderToString(<Text color="red">a<Text color="blue">b</Text></Text>)
1471    //   == "ESC[31ma ESC[34mb ESC[39m"   (single trailing reset)
1472    //
1473    // inkferro reproduces this BYTE-FOR-BYTE: the outer red wrap nominally yields
1474    // a doubled `ESC[39m` close, but the styled-char grid re-tokenizes the line on
1475    // write and serializes minimal SGR (`styled_chars_to_string`), collapsing the
1476    // redundant trailing reset — the same single `ESC[39m` chalk's closeRe emits.
1477    // So no divergence here: the grid's SGR de-duplication absorbs what would
1478    // otherwise be a same-close-code mismatch. The load-bearing fact is that "b"
1479    // carries BLUE (the inner child's color via squash), not red.
1480    #[test]
1481    fn m3b_nested_styled_text_inner_color_applies_to_child() {
1482        let mut a = Arena::new();
1483        make_root(&mut a, 0);
1484        make_text(&mut a, 1, "a"); // outer <Text color="red">: own #text "a"
1485        make_text(&mut a, 2, "b"); // inner <Text color="blue">b</Text>
1486        add_child(&mut a, 0, 1);
1487        add_child(&mut a, 1, 2);
1488        let acc: &TransformAccessor<'_> = &|id: u32| match id {
1489            1 => Some(Box::new(|s: &str, _i: usize| {
1490                colorize(s, Some("red"), ColorKind::Fg, ColorLevel::Truecolor)
1491            })),
1492            2 => Some(Box::new(|s: &str, _i: usize| {
1493                colorize(s, Some("blue"), ColorKind::Fg, ColorLevel::Truecolor)
1494            })),
1495            _ => None,
1496        };
1497        let out = render_styled(&a, 0, 100, acc, ColorLevel::Truecolor).0;
1498        // Byte-for-byte oracle match: red "a", blue "b" (inner color on the child
1499        // span via squash), single trailing reset (grid SGR de-dup == chalk closeRe).
1500        assert_eq!(
1501            out, "\u{1b}[31ma\u{1b}[34mb\u{1b}[39m",
1502            "inner blue must apply to the child span and match the oracle bytes"
1503        );
1504    }
1505
1506    // ═══ static render pass (renderer.ts static branch) ═══════════════════════
1507    //
1508    // `render_static` is ink's SECOND render pass: the `<Static>` subtree rendered
1509    // into its OWN-sized output with skipStaticElements=false, plus a trailing
1510    // `\n` when a static node is present (renderer.ts:46-66). The main `walk`
1511    // SKIPS the same static subtree (skipStaticElements=true), so the static
1512    // content NEVER appears in `plain_output`. Each test mutation-checks BOTH
1513    // directions: static_output carries the content, plain_output does not.
1514    // (`Op`/`apply` are already imported by the M3-A seam test section above.)
1515
1516    // ── static present: subtree → "<body>\n", and main walk omits it ──────────
1517    // Tree: Root → static Box (position:absolute, so out of flow at (0,0)) → Text.
1518    // ink's real `<Static>` is `position:absolute` (the JS component sets it), so
1519    // it occupies no flow space and the live region collapses to empty. We model
1520    // that layout effect here so the fixture matches ink's static semantics: the
1521    // live region is empty, and the static output is the box content + newline.
1522    #[test]
1523    fn static_present_renders_with_trailing_newline_and_omitted_from_main() {
1524        let mut a = Arena::new();
1525        make_root(&mut a, 0);
1526        make_box(
1527            &mut a,
1528            1,
1529            Style {
1530                position: Some(crate::dom::Position::Absolute),
1531                ..Style::default()
1532            },
1533        );
1534        make_text(&mut a, 2, "Done");
1535        add_child(&mut a, 0, 1);
1536        add_child(&mut a, 1, 2);
1537        // Mark the box static via the real op handler (exercises Op::SetStatic).
1538        apply(&mut a, &[Op::SetStatic { id: 1, value: true }]);
1539
1540        // Static pass: the static subtree renders, body + trailing "\n".
1541        let static_out = render_static(&a, 0, 80, &|_| None, ColorLevel::Truecolor);
1542        assert_eq!(
1543            static_out, "Done\n",
1544            "static output is the static subtree's content plus a trailing newline"
1545        );
1546
1547        // Main pass MUST skip the static subtree → empty live region. This is the
1548        // mutation check: if the skip regressed, plain_output would be "Done".
1549        let (plain, _h) = render_styled(&a, 0, 80, &|_| None, ColorLevel::Truecolor);
1550        assert_eq!(
1551            plain, "",
1552            "the static subtree must NOT appear in the live (main) output"
1553        );
1554    }
1555
1556    // ── no static node: static_output is "" (the common case, no behavior change) ─
1557    #[test]
1558    fn no_static_node_returns_empty_string() {
1559        let mut a = Arena::new();
1560        make_root(&mut a, 0);
1561        make_text(&mut a, 1, "live");
1562        add_child(&mut a, 0, 1);
1563
1564        assert_eq!(
1565            render_static(&a, 0, 80, &|_| None, ColorLevel::Truecolor),
1566            "",
1567            "a tree with no static node yields an empty static output"
1568        );
1569        // And the main output is unaffected: "live" renders normally.
1570        assert_eq!(
1571            render_styled(&a, 0, 80, &|_| None, ColorLevel::Truecolor).0,
1572            "live"
1573        );
1574    }
1575
1576    // ── reverse-pollution: a LIVE sibling must NOT leak into static_output ─────
1577    // Tree: Root → [ static Box(position:absolute) → "Done", live Text "live" ].
1578    // Unlike the other static fixtures, the static subtree is NOT the only content
1579    // here: a live Text sibling coexists with the static node under the same root.
1580    // This is the bidirectional pollution check the adversarial review demanded:
1581    //   • static_output == "Done\n"  catches the static walk ASCENDING to the root
1582    //     and sweeping in the live sibling;
1583    //   • render_styled == "live"    catches the converse — the static-skip eating
1584    //     the live sibling too (it would be "").
1585    //
1586    // Child order is load-bearing for the FIRST assertion to discriminate. Layout
1587    // puts BOTH the static box and the live text at (0,0) (the box is absolute, the
1588    // live text is the sole flow child), and the static grid is clipped to the
1589    // box's own 4-wide rect. So if `walk_static` ever ascended to the root, the
1590    // grid would receive both "Done" and "live" at (0,0) under last-writer-wins —
1591    // and only the LATER pre-order write survives. By appending the static box
1592    // FIRST and the live sibling SECOND, a root-ascending leak writes "live" LAST,
1593    // overwriting "Done" → "live\n" ≠ "Done\n", so the assertion catches it. (The
1594    // reversed order would let "Done" mask "live" and the test would pass even on
1595    // the leak — verified empirically.) The correct code, walking from the static
1596    // node, never sees the live sibling regardless of order.
1597    #[test]
1598    fn static_node_does_not_pull_live_sibling_into_static_output() {
1599        let mut a = Arena::new();
1600        make_root(&mut a, 0);
1601        make_box(
1602            &mut a,
1603            1,
1604            Style {
1605                position: Some(crate::dom::Position::Absolute),
1606                ..Style::default()
1607            },
1608        );
1609        make_text(&mut a, 2, "Done");
1610        make_text(&mut a, 3, "live");
1611        add_child(&mut a, 0, 1); // static box FIRST in pre-order
1612        add_child(&mut a, 1, 2);
1613        add_child(&mut a, 0, 3); // live sibling SECOND (written last on a leak)
1614        apply(&mut a, &[Op::SetStatic { id: 1, value: true }]);
1615
1616        // Static pass: ONLY the static subtree's content, not the live sibling.
1617        assert_eq!(
1618            render_static(&a, 0, 80, &|_| None, ColorLevel::Truecolor),
1619            "Done\n",
1620            "static_output carries ONLY the static subtree; the live sibling must \
1621             NOT appear (the walk starts at the static node, never ascends to root)"
1622        );
1623
1624        // Live pass: the live sibling renders; the static subtree is skipped.
1625        assert_eq!(
1626            render_styled(&a, 0, 80, &|_| None, ColorLevel::Truecolor).0,
1627            "live",
1628            "the live (main) output carries the live sibling, with the static \
1629             subtree omitted"
1630        );
1631    }
1632
1633    // ── multi-line static subtree → each line preserved, single trailing "\n" ──
1634    // A column of two static text lines: the static body is "a\nb", then the one
1635    // appended newline (renderer.ts:66) → "a\nb\n". Proves the trailing newline is
1636    // appended to the WHOLE static block once, not per line.
1637    #[test]
1638    fn static_multiline_subtree_single_trailing_newline() {
1639        let mut a = Arena::new();
1640        make_root(&mut a, 0);
1641        make_box(
1642            &mut a,
1643            1,
1644            Style {
1645                // position:absolute mirrors ink's `<Static>` (out of flow), so the
1646                // live region collapses to empty while the static box keeps its
1647                // own computed width/height for the second-pass grid.
1648                position: Some(crate::dom::Position::Absolute),
1649                flex_direction: Some(crate::dom::FlexDir::Column),
1650                width: Some(Dim::Points(10.0)),
1651                ..Style::default()
1652            },
1653        );
1654        make_text(&mut a, 2, "a");
1655        make_text(&mut a, 3, "b");
1656        add_child(&mut a, 0, 1);
1657        add_child(&mut a, 1, 2);
1658        add_child(&mut a, 1, 3);
1659        apply(&mut a, &[Op::SetStatic { id: 1, value: true }]);
1660
1661        assert_eq!(
1662            render_static(&a, 0, 80, &|_| None, ColorLevel::Truecolor),
1663            "a\nb\n",
1664            "multi-line static block keeps its lines and gets one trailing newline"
1665        );
1666        assert_eq!(
1667            render_styled(&a, 0, 80, &|_| None, ColorLevel::Truecolor).0,
1668            "",
1669            "the static block is omitted from the live output"
1670        );
1671    }
1672
1673    // ── styled/transform child INSIDE a static node → SGR carried in static_out ─
1674    // The static pass MUST honor `<Transform>`/`<Text color>` on a node inside the
1675    // `<Static>` subtree, exactly as the live pass does: `render_static` forwards
1676    // the SAME `transform_of` accessor to `walk_static`. Here the static text node
1677    // carries a `green` colorize transform; the static output must contain the SGR
1678    // escape "\x1b[32m…\x1b[39m". This is the mutation check the adversarial review
1679    // demanded: if `walk_static`'s `transform_of` were silently no-op'd (or
1680    // `render_static` stopped threading the accessor into the static walk), the
1681    // assert below would fail — the static body would be the uncolored "Done".
1682    #[test]
1683    fn static_present_honors_styled_transform_child() {
1684        let mut a = Arena::new();
1685        make_root(&mut a, 0);
1686        make_box(
1687            &mut a,
1688            1,
1689            Style {
1690                position: Some(crate::dom::Position::Absolute),
1691                ..Style::default()
1692            },
1693        );
1694        make_text(&mut a, 2, "Done");
1695        add_child(&mut a, 0, 1);
1696        add_child(&mut a, 1, 2);
1697        apply(&mut a, &[Op::SetStatic { id: 1, value: true }]);
1698
1699        // Accessor: the static leaf (id 2) carries a green fg colorize transform,
1700        // mirroring `<Text color="green">` (Text.tsx:94-130 → colorize). All other
1701        // ids None — same shape the napi caller mints per id.
1702        let acc: &crate::render::walk::TransformAccessor<'_> = &|id: u32| match id {
1703            2 => Some(Box::new(|s: &str, _i: usize| {
1704                crate::render::colorize::colorize(
1705                    s,
1706                    Some("green"),
1707                    crate::render::colorize::Kind::Fg,
1708                    ColorLevel::Truecolor,
1709                )
1710            })),
1711            _ => None,
1712        };
1713
1714        // Static pass must carry the SGR: green fg wraps "Done", then trailing "\n".
1715        let static_out = render_static(&a, 0, 80, acc, ColorLevel::Truecolor);
1716        assert_eq!(
1717            static_out, "\u{1b}[32mDone\u{1b}[39m\n",
1718            "the static pass honors a `<Text color>`/`<Transform>` child — the SGR \
1719             escape MUST appear in static_output (transform_of forwarded to walk_static)"
1720        );
1721
1722        // Mutation control: with a NO-OP accessor (the stub the review feared), the
1723        // same tree yields the uncolored body — proving the SGR above is sourced
1724        // from the forwarded accessor, not some unconditional colorization.
1725        assert_eq!(
1726            render_static(&a, 0, 80, &|_| None, ColorLevel::Truecolor),
1727            "Done\n",
1728            "a no-op transform accessor yields the uncolored static body (mutation control)"
1729        );
1730    }
1731
1732    // ── #71: multi-segment <Text wrap=truncate> squashes-then-truncates ──────
1733    //
1734    // ink squashes ALL descendant text of an `ink-text` into ONE string FIRST
1735    // (squash-text-nodes.ts), then measures THAT single fragment once — the
1736    // `ink-text` is the only node with a yoga measure func; its `ink-virtual-text`
1737    // children get NO yoga node at all (dom.ts:102/125, measureTextNode at
1738    // dom.ts:222-246). inkferro USED to attach a measure fn to AND insert a taffy
1739    // node for EVERY nested segment, so taffy measured each inner segment ALONE in
1740    // its own wrap mode → a segment WIDER than the box wrapped to ≥2 lines and
1741    // inflated the text-root's height, where ink (squash-then-truncate) yields ONE
1742    // line. The fix makes a `Text`/`VirtualText` node a taffy LEAF
1743    // (`create_layout_nodes` returns after `set_measure`), so the measured unit is
1744    // the whole squashed subtree — exactly like ink's `ink-text`.
1745    //
1746    // SCOPE of the core pin: the per-leaf bug is a MEASURE-time defect. At RENDER
1747    // time `walk_node`'s Text arm already squashes the whole subtree and truncates
1748    // once at `rect.width` (divergence map: render path already correct), and an
1749    // explicit `Box width` PINS `rect.width` regardless of the measure — so render
1750    // BYTES are not a measure discriminator and are NOT pinned here (the 6 npm byte
1751    // pins, through the real reconciler + native addon, own those). The core pin
1752    // routes through `build_layout_engine` (exercising `create_layout_nodes`) and
1753    // asserts the text-root's computed HEIGHT, which the per-leaf wrap inflates.
1754    //
1755    // FAITHFUL arena shape (mirrors the live reconciler; Text.tsx:134 sets
1756    // `textWrap` on EVERY ink-text/virtual-text, default `'wrap'`): a `Text` root
1757    // carrying the truncate `text_wrap`, own text None, whose children are
1758    // `VirtualText` SEGMENTS that EACH carry their OWN `text_wrap = Wrap` (the
1759    // component default) and the segment string. Without the own-Wrap on the
1760    // segments the pin would be vacuous: #70's `effective_text_wrap` would make a
1761    // wrap-less segment INHERIT the root's truncate (height 1) even pre-fix. The
1762    // own-Wrap is what makes the per-leaf path overflow-then-WRAP, reproducing the
1763    // inflated height the fix removes.
1764
1765    /// Build `<Box width=W><Text wrap=MODE><VirtualText wrap=wrap>seg0</>…</Text></Box>`
1766    /// at root. ids: 0=root, 1=box, 2=text-root, 3/4=segments. Each segment carries
1767    /// its OWN `text_wrap = Wrap` (the `<Text>` component default — Text.tsx:134).
1768    fn two_segment_truncate_arena(width: f32, mode: TextWrap, seg0: &str, seg1: &str) -> Arena {
1769        use crate::dom::{Op, apply};
1770        let mut a = Arena::new();
1771        make_root(&mut a, 0);
1772        make_box(
1773            &mut a,
1774            1,
1775            Style {
1776                width: Some(Dim::Points(width)),
1777                ..Style::default()
1778            },
1779        );
1780        // text-root: carries the wrap mode, NO own text (the segments hold it).
1781        let mut text_root = Node::new(Kind::Text);
1782        text_root.style.text_wrap = Some(mode);
1783        a.insert(2, text_root);
1784        // two VirtualText segments (nested <Text> → ink-virtual-text), each with
1785        // its OWN default Wrap text_wrap — exactly what Text.tsx:134 emits.
1786        let mut s0 = Node::new(Kind::VirtualText);
1787        s0.text = Some(seg0.to_owned());
1788        s0.style.text_wrap = Some(TextWrap::Wrap);
1789        a.insert(3, s0);
1790        let mut s1 = Node::new(Kind::VirtualText);
1791        s1.text = Some(seg1.to_owned());
1792        s1.style.text_wrap = Some(TextWrap::Wrap);
1793        a.insert(4, s1);
1794        // Wire via apply so `parent` is populated on every edge (op.rs:93).
1795        apply(
1796            &mut a,
1797            &[
1798                Op::AppendChild {
1799                    parent: 0,
1800                    child: 1,
1801                },
1802                Op::AppendChild {
1803                    parent: 1,
1804                    child: 2,
1805                },
1806                Op::AppendChild {
1807                    parent: 2,
1808                    child: 3,
1809                },
1810                Op::AppendChild {
1811                    parent: 2,
1812                    child: 4,
1813                },
1814            ],
1815        );
1816        a
1817    }
1818
1819    // MEASURE-LEVEL discriminator (routes through create_layout_nodes via
1820    // build_layout_engine). Box width 3 < first segment "AAAA" (4 cols). ink
1821    // squashes "AAAA"+"BBBB" → "AAAABBBB" and truncates the WHOLE unit at 3 → "AA…",
1822    // ONE line, so the text-root's computed height is 1. Pre-fix each segment is a
1823    // taffy leaf measured ALONE in its own Wrap mode: "AAAA"@3 wraps to "AAA\nA"
1824    // (2 lines), inflating the text-root to height ≥2. The HEIGHT is the
1825    // discriminator — the box pins the WIDTH to 3 both pre/post, so only height
1826    // moves (pre ≥2 → post 1).
1827    #[test]
1828    fn task71_measure_two_segment_truncate_height_is_one() {
1829        let a = two_segment_truncate_arena(3.0, TextWrap::TruncateEnd, "AAAA", "BBBB");
1830        let (engine, _root) =
1831            build_layout_engine(&a, 0, 80).expect("layout engine builds for the 2-segment tree");
1832        let text_rect = engine
1833            .computed(2)
1834            .expect("the text-root (id 2) is laid out as the measured unit");
1835        // The whole squash truncates to ONE line. Per-leaf Wrap measure of the
1836        // overflowing first segment alone inflates this to ≥2.
1837        assert_eq!(
1838            text_rect.height, 1,
1839            "the squashed multi-segment unit truncates to ONE line (per-leaf Wrap measure inflates the extent to >=2)"
1840        );
1841    }
1842
1843    // FULL-FRAME extent pin through render_to_string (plain, #71 case 5 shape but
1844    // with an OVERFLOWING first segment so the extent actually moves). Box width 3,
1845    // segments "AAAA"/"BBBB" both Wrap. Oracle-faithful: squash "AAAABBBB" truncated
1846    // at 3 → "AA…", ONE row, NO trailing blank rows. Pre-fix the per-leaf Wrap of
1847    // "AAAA"@3 folds to 2 lines → the text-root is height ≥2 → the frame carries
1848    // trailing blank rows ("AA…\n…"). The full-frame equality (single row, no
1849    // trailing newline) is the discriminator the box-pinned width cannot mask.
1850    #[test]
1851    fn task71_render_plain_overflowing_first_segment_one_row() {
1852        let a = two_segment_truncate_arena(3.0, TextWrap::TruncateEnd, "AAAA", "BBBB");
1853        assert_eq!(
1854            render_to_string(&a, 0, 80),
1855            "AA\u{2026}",
1856            "plain multi-segment truncate squashes then truncates once → exactly one row, no trailing blank rows"
1857        );
1858    }
1859}