use crossterm::style::Color;
use htui::html::{css_parse, html_parse};
use htui::layout::layout::TermMap;
use htui::tdom::node::{NodeArena, NodeId, NodeType};
use htui::tui::graph_api::TermLayout;
fn find_box_by_type<'a>(
boxes: &'a [htui::layout::LayoutBox],
target: &NodeType,
arena: &NodeArena,
) -> Option<&'a htui::layout::LayoutBox> {
for b in boxes {
if &arena[b.node_id].node_type == target {
return Some(b);
}
if let Some(found) = find_box_by_type(&b.children, target, arena) {
return Some(found);
}
}
None
}
fn init() {
let _ = env_logger::builder().is_test(true).try_init();
}
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)
}
const COLS: u16 = 80;
const ROWS: u16 = 24;
fn mock_term_map() -> TermMap {
TermMap {
columns: COLS,
rows: ROWS,
}
}
#[test]
fn test_aside_basic() {
init();
let (root_id, arena) = setup("<aside>Side note</aside>", "");
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let canvas: String = tl.layout.iter().map(|b| b.word.as_str()).collect();
let joined: String = canvas.chars().filter(|c| !c.is_whitespace()).collect();
assert!(
joined.contains("Sidenote"),
"aside should contain 'Side note', got {:?}",
joined
);
}
#[test]
fn test_aside_with_color() {
init();
let html = "<aside>Colored aside</aside>";
let css = "aside { color: blue; background-color: yellow; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let has_fg = tl
.layout
.iter()
.any(|b| b.fcolor == Some(Color::Rgb { r: 0, g: 0, b: 255 }));
let has_bg = tl.layout.iter().any(|b| {
b.bcolor
== Some(Color::Rgb {
r: 255,
g: 255,
b: 0,
})
});
assert!(has_fg, "should have blue foreground");
assert!(has_bg, "should have yellow background");
}
#[test]
fn test_aside_with_nested_div() {
init();
let html = "<aside><div>Inner div</div></aside>";
let (root_id, arena) = setup(html, "");
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let canvas: String = tl.layout.iter().map(|b| b.word.as_str()).collect();
let joined: String = canvas.chars().filter(|c| !c.is_whitespace()).collect();
assert!(
joined.contains("Innerdiv"),
"aside should render nested div, got {:?}",
joined
);
}
#[test]
fn test_aside_width_50pct() {
init();
let html = "<aside>Half</aside>";
let css = "aside { width: 50%; background-color: blue; color: white; }";
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 block_left = 0;
let block_width = cols / 2;
let row = tl
.layout
.iter()
.position(|b| b.bcolor == Some(Color::Rgb { r: 0, g: 0, b: 255 }))
.map(|index| index / cols)
.unwrap();
let row_start = row * cols;
let block = &tl.layout[row_start..row_start + cols];
assert!(
block[block_left..block_left + block_width]
.iter()
.all(|b| b.bcolor == Some(Color::Rgb { r: 0, g: 0, b: 255 })),
"cols 0..cols/2 should all carry blue bg (block is flush left)"
);
assert!(
block[block_left + block_width..]
.iter()
.all(|b| b.bcolor.is_none()),
"cols cols/2..cols should be bg-free (outside the block)"
);
for cell in &block[block_left..block_left + block_width] {
if cell.word != " " {
assert_eq!(
cell.fcolor,
Some(Color::Rgb {
r: 255,
g: 255,
b: 255
})
);
}
}
}
#[test]
fn test_aside_width_fixed_px() {
init();
let html = "<aside>Fix</aside>";
let css = "aside { width: 20ch; background-color: red; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let row = tl
.layout
.iter()
.position(|b| b.bcolor == Some(Color::Rgb { r: 255, g: 0, b: 0 }))
.map(|index| index / term_map.columns as usize)
.unwrap();
let row_start = row * term_map.columns as usize;
let block = &tl.layout[row_start..row_start + 20];
assert!(
block
.iter()
.all(|b| { b.bcolor == Some(Color::Rgb { r: 255, g: 0, b: 0 }) }),
"cols 0..20 should all carry red bg (block is flush left, 20 wide)"
);
let after = &tl.layout[row_start + 20..row_start + 30];
assert!(
after.iter().all(|b| b.bcolor.is_none()),
"cols 20..30 should be bg-free (outside the 20-col block)"
);
}
#[test]
fn test_aside_width_default_full() {
init();
let html = "<aside>Full width</aside>";
let css = "aside { background-color: green; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let row = tl
.layout
.iter()
.position(|b| b.bcolor == Some(Color::Rgb { r: 0, g: 128, b: 0 }))
.map(|index| index / term_map.columns as usize)
.unwrap();
let row_start = row * term_map.columns as usize;
assert!(
tl.layout[row_start..row_start + COLS as usize]
.iter()
.all(|b| b.bcolor == Some(Color::Rgb { r: 0, g: 128, b: 0 }))
);
assert!(
tl.layout[row_start..row_start + COLS as usize]
.iter()
.any(|b| b.word == " ")
);
}
#[test]
fn test_aside_text_align_center() {
init();
let html = "<aside>Centered</aside>";
let css = "aside { text-align: center; width: 40ch; background-color: darkblue; }";
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 first text char");
let col = text_idx % cols;
assert_eq!(
col, 16,
"text must be centered within the (flush-left) 40-col block"
);
let bg_cells: Vec<_> = tl.layout.iter().filter(|b| b.bcolor.is_some()).collect();
assert_eq!(
bg_cells.len(),
40,
"background should cover the whole block"
);
let bg_cols: Vec<_> = bg_cells
.iter()
.map(|b| tl.layout.iter().position(|c| std::ptr::eq(c, *b)).unwrap() % cols)
.collect();
assert!(
bg_cols.iter().all(|&c| c < 40),
"block bg must be within cols 0..40 (flush left)"
);
}
#[test]
fn test_aside_text_align_right() {
init();
let html = "<aside>Right</aside>";
let css = "aside { text-align: right; width: 40ch; background-color: darkblue; }";
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 == *"R")
.expect("should find first text char");
let col = text_idx % cols;
assert_eq!(
col, 35,
"text must be right-aligned within the (flush-left) 40-col block"
);
let bg_cells: Vec<_> = tl.layout.iter().filter(|b| b.bcolor.is_some()).collect();
assert_eq!(
bg_cells.len(),
40,
"background should cover the whole block"
);
let bg_cols: Vec<_> = bg_cells
.iter()
.map(|b| tl.layout.iter().position(|c| std::ptr::eq(c, *b)).unwrap() % cols)
.collect();
assert!(
bg_cols.iter().all(|&c| c < 40),
"block bg must be within cols 0..40 (flush left)"
);
}
#[test]
fn test_aside_bg_covers_nested_p_row() {
init();
let html = "<aside><p>Nested content</p></aside>";
let css = "aside { background-color: darkblue; }";
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 row0 = &tl.layout[0..cols];
let row0_bg = row0
.iter()
.filter(|b| b.bcolor == Some(Color::Rgb { r: 0, g: 0, b: 139 }))
.count();
assert_eq!(row0_bg, cols, "row 0 = <p> margin-top, fully aside bg");
let row1 = &tl.layout[cols..2 * cols];
let row1_bg = row1
.iter()
.filter(|b| b.bcolor == Some(Color::Rgb { r: 0, g: 0, b: 139 }))
.count();
assert!(
row1_bg >= cols - "Nested content".len(),
"row 1 bg should fill all non-text cells (got {row1_bg} bg cells, expected >= {})",
cols - "Nested content".len()
);
let row2 = &tl.layout[2 * cols..3 * cols];
assert!(
row2.iter()
.all(|b| b.bcolor == Some(Color::Rgb { r: 0, g: 0, b: 139 })),
"row 2 = <p> margin-bottom, fully aside bg"
);
let row3 = &tl.layout[3 * cols..4 * cols];
assert!(
row3.iter().all(|b| b.bcolor.is_none()),
"row 3 must not carry the aside's bg (aside is exactly 3 rows tall)"
);
}
#[test]
fn test_aside_rect_height_includes_nested_children() {
init();
let html = "<aside><p>only child</p></aside>";
let css = "aside { background-color: darkblue; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
use htui::layout::{BoxConstraint, Cursor, inner_formatting_context::flow::layout_tree};
let containing = BoxConstraint::root(&term_map);
let mut cursor = Cursor::new();
let boxes = layout_tree(&arena, root_id, containing, &mut cursor, 80, 24);
let aside_box = find_box_by_type(&boxes, &NodeType::Aside, &arena)
.expect("aside box should be present in the layout tree");
assert_eq!(
aside_box.rect.height, 3,
"aside wraps the <p> plus its 1-row UA margins (margin 1 + text 1 + margin 1)"
);
assert_eq!(aside_box.children.len(), 1, "aside should have 1 child box");
assert_eq!(
aside_box.children[0].rect.y,
aside_box.rect.y.saturating_add(1),
"the <p>'s UA margin-top puts it one row below the aside's top"
);
}
#[test]
fn test_aside_bg_covers_two_nested_p_rows() {
init();
let html = "<aside><p>first</p><p>second</p></aside>";
let css = "aside { background-color: darkblue; }";
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);
for row in 0..5 {
let start = row * cols;
let row_slice = &tl.layout[start..start + cols];
let bg_count = row_slice
.iter()
.filter(|b| b.bcolor == Some(Color::Rgb { r: 0, g: 0, b: 139 }))
.count();
assert!(
bg_count > 0,
"row {row} must carry the aside's bg (got {bg_count}/{cols})"
);
}
let row5_start = 5 * cols;
if row5_start + cols <= tl.layout.len() {
let row5 = &tl.layout[row5_start..row5_start + cols];
assert!(
row5.iter().all(|b| b.bcolor.is_none()),
"row 5 must not carry the aside's bg (rect should be exactly 5 rows)"
);
}
}
#[test]
fn test_header_has_no_trailing_spacing() {
init();
let html = "<header>Pinned title</header><p>after</p>";
let css = "header { background-color: green; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
use htui::layout::{BoxConstraint, Cursor, inner_formatting_context::flow::layout_tree};
let containing = BoxConstraint::root(&term_map);
let mut cursor = Cursor::new();
let boxes = layout_tree(&arena, root_id, containing, &mut cursor, 80, 24);
let header_box =
find_box_by_type(&boxes, &NodeType::Header, &arena).expect("header box should be present");
assert_eq!(
header_box.rect.height, 1,
"header must not reserve trailing spacing rows"
);
}
#[test]
fn test_footer_has_no_trailing_spacing() {
init();
let html = "<p>above</p><footer>tail</footer>";
let css = "footer { background-color: red; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
use htui::layout::{BoxConstraint, Cursor, inner_formatting_context::flow::layout_tree};
let containing = BoxConstraint::root(&term_map);
let mut cursor = Cursor::new();
let boxes = layout_tree(&arena, root_id, containing, &mut cursor, 80, 24);
let footer_box =
find_box_by_type(&boxes, &NodeType::Footer, &arena).expect("footer box should be present");
assert_eq!(
footer_box.rect.height, 1,
"footer must not reserve trailing spacing rows"
);
}
#[test]
fn test_semantic_blocks_have_no_implicit_background() {
init();
for tag in ["aside", "header", "footer"] {
let html = format!("<{tag}>text</{tag}>");
let (root_id, arena) = setup(&html, "");
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
assert!(
tl.layout.iter().all(|b| b.bcolor.is_none()),
"<{tag}> must not paint an implicit background"
);
}
}
#[test]
fn test_aside_with_nested_div_extends_bg_to_div_row() {
init();
let html = "<aside><div>block child</div></aside>";
let css = "aside { background-color: darkblue; }";
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 row0 = &tl.layout[0..cols];
let row0_bg = row0
.iter()
.filter(|b| b.bcolor == Some(Color::Rgb { r: 0, g: 0, b: 139 }))
.count();
assert!(
row0_bg > 0,
"row 0 should carry the aside's bg around the nested <div> (got {row0_bg} bg cells)"
);
assert!(
row0_bg >= cols - "block child".len(),
"row 0 should be mostly bg except for text cells"
);
let row1 = &tl.layout[cols..2 * cols];
assert!(
row1.iter().all(|b| b.bcolor.is_none()),
"row 1 must not carry the aside's bg (aside is exactly 1 row tall)"
);
}
#[test]
fn test_aside_without_children_still_has_height_one() {
init();
let html = "<aside>just text</aside>";
let css = "aside { background-color: darkblue; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
use htui::layout::{BoxConstraint, Cursor, inner_formatting_context::flow::layout_tree};
let containing = BoxConstraint::root(&term_map);
let mut cursor = Cursor::new();
let boxes = layout_tree(&arena, root_id, containing, &mut cursor, 80, 24);
let aside_box = find_box_by_type(&boxes, &NodeType::Aside, &arena)
.expect("aside box should be present in the layout tree");
assert_eq!(
aside_box.rect.height, 1,
"aside with no nested blocks must remain height=1"
);
assert!(
aside_box.children.is_empty(),
"aside should have no children"
);
}
fn cell_containing(tl: &TermLayout, ch: char) -> &htui::tui::graph_api::Block {
let needle = ch.to_string();
let idx = tl
.layout
.iter()
.position(|b| b.word == needle)
.unwrap_or_else(|| panic!("should find character {ch:?} in canvas"));
&tl.layout[idx]
}
#[test]
fn test_nested_p_text_inherits_aside_bg() {
init();
let html = "<aside><p>30col</p></aside>";
let css = "aside { background-color: cyan; color: black; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
for ch in ['3', '0', 'c', 'o', 'l'] {
let cell = cell_containing(&tl, ch);
assert_eq!(
cell.bcolor,
Some(Color::Rgb {
r: 0,
g: 255,
b: 255
}),
"text cell for {ch:?} must inherit the aside's cyan bg"
);
assert_eq!(
cell.fcolor,
Some(Color::Rgb { r: 0, g: 0, b: 0 }),
"text cell for {ch:?} must keep the black fg"
);
}
}
#[test]
fn test_nested_p_text_bg_wins_over_parent() {
init();
let html = "<aside><p>kid</p></aside>";
let css = "aside { background-color: cyan; } p { background-color: yellow; color: black; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
for ch in ['k', 'i', 'd'] {
let cell = cell_containing(&tl, ch);
assert_eq!(
cell.bcolor,
Some(Color::Rgb {
r: 255,
g: 255,
b: 0
}),
"text cell for {ch:?} should use <p>'s own yellow bg, not aside's cyan"
);
}
}
#[test]
fn test_nested_p_inherits_through_intermediate_div() {
init();
let html = "<aside><div><p>X</p></div></aside>";
let css =
"aside { background-color: blue; } div { background-color: red; } p { color: white; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let cell = cell_containing(&tl, 'X');
assert_eq!(
cell.bcolor,
Some(Color::Rgb { r: 255, g: 0, b: 0 }),
"<p> text should inherit <div>'s red bg, not aside's blue"
);
assert_eq!(
cell.fcolor,
Some(Color::Rgb {
r: 255,
g: 255,
b: 255
})
);
}
#[test]
fn test_nested_p_inherits_when_parent_has_no_bg() {
init();
let html = "<aside><p>foo</p></aside>";
let css = "";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
for ch in ['f', 'o'] {
let cell = cell_containing(&tl, ch);
assert!(
cell.bcolor.is_none(),
"with no parent bg, text cell {ch:?} should carry no bg"
);
}
}
#[test]
fn test_nested_p_in_aside_with_fixed_width_centered() {
init();
let html = "<aside><p>30col</p></aside>";
let css = "aside { width: 30ch; text-align: center; background-color: cyan; color: black; }";
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 aside_left = 0;
let idx = tl
.layout
.iter()
.position(|b| b.word == *"3")
.expect("should find '3'");
let col = idx % cols;
assert_eq!(
col,
aside_left + (30 - 5) / 2,
"30col must be centered inside the (flush-left) 30-col aside block"
);
for ch in ['3', '0', 'c', 'o', 'l'] {
let cell = cell_containing(&tl, ch);
assert!(
cell.bcolor
== Some(Color::Rgb {
r: 0,
g: 255,
b: 255
}),
"text cell {ch:?} must inherit cyan bg from the aside"
);
}
}