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 // == "[32mTest[39m"
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 // == "[38;2;255;136;0mTest[39m"
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 // == "[42mTest[49m"
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 // == "[2mTest[22m"
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 // == "[1m[31mTest[39m[22m"
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 // == "[32MTEST[39M"
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 // == "[31ma[34mb[39m[39m"
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}