use crossterm::style::Color;
use htui::html::{css_parse, html_parse};
use htui::layout::layout::TermMap;
use htui::tdom::node::{NodeArena, NodeId};
use htui::tui::graph_api::TermLayout;
const COLS: u16 = 80;
const ROWS: u16 = 24;
fn setup(html: &str, css: &str) -> (NodeId, NodeArena) {
let mut html_parser = html_parse::Parser::new(html);
let (root_id, mut arena, embedded) = html_parser.parse();
let combined = if css.is_empty() {
embedded
} else if embedded.is_empty() {
css.to_string()
} else {
format!("{embedded}\n{css}")
};
if !combined.trim().is_empty() {
let mut css_parser = css_parse::Parser::new(&combined);
let rules = css_parser.parse();
css_parse::css_into_node(&mut arena, root_id, &rules);
}
(root_id, arena)
}
fn mock_term_map() -> TermMap {
TermMap {
columns: COLS,
rows: ROWS,
}
}
#[test]
fn two_paragraphs_land_on_different_rows() {
let (root_id, arena) = setup("<p>first</p><p>second</p>", "");
let term_map = mock_term_map();
let cols = term_map.columns as usize;
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let first_idx = tl
.layout
.iter()
.position(|b| b.word == *"f")
.expect("first paragraph should render");
let second_idx = tl
.layout
.iter()
.rposition(|b| b.word == *"s")
.expect("second paragraph should render");
let first_row = first_idx / cols;
let second_row = second_idx / cols;
assert!(
second_row > first_row,
"second <p> must start on a row strictly below the first, got rows {first_row}/{second_row}"
);
}
#[test]
fn nested_paragraph_shares_aside_row_with_bg() {
let (root_id, arena) = setup(
"<aside><p>The rough-skinned newt defends itself.</p></aside>",
"aside { background-color: darkblue; } p { color: yellow; }",
);
let term_map = mock_term_map();
let cols = term_map.columns as usize;
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let aside_row = tl
.layout
.iter()
.position(|b| b.bcolor == Some(Color::Rgb { r: 0, g: 0, b: 139 }))
.map(|i| i / cols)
.expect("aside should paint a darkblue row");
let paragraph_row = tl
.layout
.iter()
.position(|b| {
b.fcolor
== Some(Color::Rgb {
r: 255,
g: 255,
b: 0,
})
})
.map(|i| i / cols)
.expect("nested paragraph should still render yellow text");
assert_eq!(
paragraph_row,
aside_row + 1,
"nested <p> sits one row below the aside's top (its 1-row UA margin-top), got rows {aside_row}/{paragraph_row}"
);
let row_after_start = (aside_row + 3) * cols;
if row_after_start + cols <= tl.layout.len() {
let row_after = &tl.layout[row_after_start..row_after_start + cols];
assert!(
row_after.iter().all(|b| b.bcolor.is_none()),
"row after the aside must be bg-free (aside is exactly 3 rows tall)"
);
}
}
#[test]
fn nested_paragraph_uses_parent_containing_block_for_text_align() {
let html = "<aside><p>Centered</p></aside>";
let css = "aside { width: 40ch; background-color: darkblue; text-align: center; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let cols = term_map.columns as usize;
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let text_idx = tl
.layout
.iter()
.position(|b| b.word == *"C")
.expect("should find paragraph text");
let col = text_idx % cols;
let aside_left = 0; let text_w = "Centered".len();
let expected = aside_left + (40 - text_w) / 2;
assert_eq!(
col, expected,
"nested <p> text must be centered within the (flush-left) aside, col={col} expected={expected}"
);
}
#[test]
fn nested_div_in_div_stacks_below_outer_div() {
let html = "<div><p>outer text</p><div><p>inner text</p></div></div>";
let (root_id, arena) = setup(html, "");
let term_map = mock_term_map();
let cols = term_map.columns as usize;
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let outer_idx = tl
.layout
.iter()
.position(|b| b.word == "o")
.expect("outer <p> should render");
let inner_idx = tl
.layout
.iter()
.position(|b| b.word == "i")
.expect("inner <p> should render");
assert!(
inner_idx / cols > outer_idx / cols,
"inner <p> must sit on a later row than outer <p>"
);
}
#[test]
fn layout_box_rect_is_deterministic() {
use htui::layout::{BoxConstraint, Cursor, inner_formatting_context::flow::layout_tree};
let (root_id, arena) = setup(
"<div>A</div><div>B</div><div>C</div>",
"div { width: 20ch; background-color: red; }",
);
let containing = BoxConstraint::root(&mock_term_map());
let mut c1 = Cursor::new();
let boxes1 = layout_tree(&arena, root_id, containing, &mut c1, 80, 24);
let mut c2 = Cursor::new();
let boxes2 = layout_tree(&arena, root_id, containing, &mut c2, 80, 24);
assert_eq!(boxes1.len(), boxes2.len());
for (a, b) in boxes1.iter().zip(boxes2.iter()) {
assert_eq!(a.rect, b.rect, "boxes must agree across runs");
assert_eq!(
a.paint_style.runs, b.paint_style.runs,
"runs must agree across runs"
);
}
}
#[test]
fn rendered_rows_walks_full_layout_tree() {
use htui::layout::inner_formatting_context::flow::layout_tree;
let (root_id, arena) = setup("<div><p>A</p><div><p>B</p></div></div>", "");
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
use htui::layout::{BoxConstraint, Cursor};
let mut cursor = Cursor::new();
let boxes = layout_tree(
&arena,
root_id,
BoxConstraint::root(&term_map),
&mut cursor,
80,
24,
);
let max_leaf = boxes
.iter()
.flat_map(|b| std::iter::once(b).chain(b.children.iter()))
.flat_map(|b| std::iter::once(b).chain(b.children.iter()))
.map(|b| b.rect.y + b.rect.height.max(1))
.max()
.unwrap();
assert_eq!(max_leaf, 6, "deepest leaf should land on row 6");
assert_eq!(
tl.rendered_rows as usize, max_leaf,
"rendered_rows must match the deepest leaf in the layout tree, \
otherwise max_scroll() collapses to 0 and scrolling is disabled"
);
}
#[test]
fn scroll_limits_match_layout_depth() {
let html = "<p>0</p><p>1</p><p>2</p><p>3</p><p>4</p>\
<p>5</p><p>6</p><p>7</p><p>8</p><p>9</p>\
<p>10</p><p>11</p><p>12</p><p>13</p><p>14</p>\
<p>15</p><p>16</p><p>17</p><p>18</p><p>19</p>\
<p>20</p><p>21</p><p>22</p><p>23</p><p>24</p>\
<p>25</p><p>26</p><p>27</p>";
let (root_id, arena) = setup(html, "");
let term_map = TermMap {
columns: 80,
rows: 10,
};
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
assert!(
tl.rendered_rows > term_map.rows as u32,
"DOM is taller than the viewport"
);
assert!(
tl.max_scroll() > 0,
"scroll must be enabled when content overflows"
);
}
#[test]
fn draw_is_reusable_across_redraws() {
fn snapshot(html: &str) -> Vec<u8> {
let (root_id, arena) = setup(html, "");
let term_map = TermMap {
columns: 80,
rows: 24,
};
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
tl.layout
.iter()
.flat_map(|b| b.word.as_bytes().iter().copied())
.collect()
}
let first_html = "<p>hello world</p>";
let second_html = "<p>goodbye</p><p>extra row below</p>";
let fresh = snapshot(second_html);
let term_map = TermMap {
columns: 80,
rows: 24,
};
let mut tl = TermLayout::new(&term_map, 1.0);
let (root1, arena1) = setup(first_html, "");
tl.draw(&arena1, root1, &term_map);
let (root2, arena2) = setup(second_html, "");
tl.draw(&arena2, root2, &term_map);
let reused: Vec<u8> = tl
.layout
.iter()
.flat_map(|b| b.word.as_bytes().iter().copied())
.collect();
assert_eq!(
fresh, reused,
"second draw on reused canvas must wipe stale cells from the first draw"
);
}