use std::sync::Arc;
use crossterm::style::Color;
use htui::html::css_parse::{
Background, BorderStyle, BoxSizing, Combinator, CornerRadii, CssAttr, CssRule, CssRuleEntry,
CssSelector, DynamicState, EdgeAuto, EdgeSizes, FontContext, FontStyleKind, FontWeight,
GradientKind, INHERITED_PROPS, Length, LengthContext, LineHeight, MarginSizes, NthExpr,
PX_PER_CELL, Parser, SUPPORTED_PROPERTIES, Selector, TextAlign, TextDecoration, TextTransform,
UserSelect, Viewport, WhiteSpace, css_into_node, css_into_node_with_state,
parse_background_str, parse_color_str, parse_declaration_text, reset_css_attr,
resolve_line_height_cells, selector_chain_matches, selector_chain_matches_with_state,
};
use htui::html::html_parse;
use htui::tdom::node::{Node, NodeArena, NodeId, NodeType, effective_font_size_cells};
use indexmap::IndexMap;
const DEFAULT_FONT_SIZE: usize = 1;
const DEFAULT_ROOT_FONT_SIZE: usize = 1;
const DEFAULT_VIEWPORT_COLS: usize = 80;
const DEFAULT_VIEWPORT_ROWS: usize = 24;
fn font_ctx(font_size_cells: usize, root_font_size_cells: usize) -> FontContext {
FontContext {
font_size_cells,
root_font_size_cells,
}
}
fn viewport() -> Viewport {
Viewport {
cols: DEFAULT_VIEWPORT_COLS,
rows: DEFAULT_VIEWPORT_ROWS,
}
}
fn length_ctx(containing: usize, font: FontContext) -> LengthContext {
LengthContext {
containing,
font,
viewport: viewport(),
}
}
fn default_font_ctx() -> FontContext {
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE)
}
fn init() {
let _ = env_logger::builder().is_test(true).try_init();
}
fn build_test(arena: &mut NodeArena) -> (NodeId, NodeId) {
let root = arena.insert(Node::from(NodeType::Document));
let div_id = arena.insert(Node::from(NodeType::Div));
arena[div_id].attributes = IndexMap::from([
("class".to_string(), "active".to_string()),
("id".to_string(), "main".to_string()),
]);
let p_id = arena.insert(Node::from(NodeType::P));
let text_id = arena.insert(Node::text("Hello"));
Node::append_child(arena, p_id, text_id);
Node::append_child(arena, div_id, p_id);
Node::append_child(arena, root, div_id);
(root, div_id)
}
fn build_p_with_classes(arena: &mut NodeArena) -> (NodeId, NodeId) {
let root = arena.insert(Node::from(NodeType::Document));
let div_id = arena.insert(Node::from(NodeType::Div));
let p_id = arena.insert(Node::from(NodeType::P));
arena[p_id]
.attributes
.insert("class".to_string(), "a b".to_string());
let text_id = arena.insert(Node::text("x"));
Node::append_child(arena, p_id, text_id);
Node::append_child(arena, div_id, p_id);
Node::append_child(arena, root, div_id);
(root, p_id)
}
#[test]
fn test_parse_simple() {
init();
let mut p = Parser::new("p { color: red; }");
assert_eq!(p.parse().0.len(), 1);
}
#[test]
fn test_parse_desc() {
init();
let s: CssSelector = "div p".parse().unwrap();
assert_eq!(s.combinators[0], Combinator::Descendant);
}
#[test]
fn test_parse_child() {
init();
let s: CssSelector = "div > p".parse().unwrap();
assert_eq!(s.combinators[0], Combinator::Child);
}
#[test]
fn test_matches_descendant() {
init();
let mut arena = NodeArena::default();
let (root, _) = build_test(&mut arena);
let sel: CssSelector = "div p".parse().unwrap();
let div_id = arena[root].children[0];
let p_id = arena[div_id].children[0];
assert!(selector_chain_matches(&arena, p_id, &sel));
}
#[test]
fn test_nth_child_even_matches_second_child() {
init();
let mut arena = NodeArena::default();
let parent = arena.insert(Node::from(NodeType::Div));
let mut children = Vec::new();
for _ in 0..4 {
let id = arena.insert(Node::from(NodeType::P));
Node::append_child(&mut arena, parent, id);
children.push(id);
}
let sel: CssSelector = ":nth-child(even)".parse().unwrap();
assert!(!selector_chain_matches(&arena, children[0], &sel));
assert!(selector_chain_matches(&arena, children[1], &sel));
assert!(!selector_chain_matches(&arena, children[2], &sel));
assert!(selector_chain_matches(&arena, children[3], &sel));
}
#[test]
fn test_nth_child_odd_matches_first_child() {
init();
let mut arena = NodeArena::default();
let parent = arena.insert(Node::from(NodeType::Div));
let mut children = Vec::new();
for _ in 0..3 {
let id = arena.insert(Node::from(NodeType::P));
Node::append_child(&mut arena, parent, id);
children.push(id);
}
let sel: CssSelector = ":nth-child(odd)".parse().unwrap();
assert!(selector_chain_matches(&arena, children[0], &sel));
assert!(!selector_chain_matches(&arena, children[1], &sel));
assert!(selector_chain_matches(&arena, children[2], &sel));
}
#[test]
fn test_nth_child_bare_integer_matches_exact_position() {
init();
let mut arena = NodeArena::default();
let parent = arena.insert(Node::from(NodeType::Div));
let mut children = Vec::new();
for _ in 0..4 {
let id = arena.insert(Node::from(NodeType::P));
Node::append_child(&mut arena, parent, id);
children.push(id);
}
let sel: CssSelector = ":nth-child(2)".parse().unwrap();
assert!(!selector_chain_matches(&arena, children[0], &sel));
assert!(selector_chain_matches(&arena, children[1], &sel));
assert!(!selector_chain_matches(&arena, children[2], &sel));
}
#[test]
fn test_nth_child_an_plus_b_form() {
init();
let mut arena = NodeArena::default();
let parent = arena.insert(Node::from(NodeType::Div));
let mut children = Vec::new();
for _ in 0..9 {
let id = arena.insert(Node::from(NodeType::P));
Node::append_child(&mut arena, parent, id);
children.push(id);
}
let sel: CssSelector = ":nth-child(3n+2)".parse().unwrap();
for (i, id) in children.iter().enumerate() {
let expected = (i + 1 == 2) || (i + 1 == 5) || (i + 1 == 8);
assert_eq!(
selector_chain_matches(&arena, *id, &sel),
expected,
"3n+2 should match index {} → {}",
i + 1,
expected
);
}
}
#[test]
fn test_nth_child_compound_with_tag() {
init();
let mut arena = NodeArena::default();
let table = arena.insert(Node::from(NodeType::Table));
let tr_ids: Vec<_> = (0..3)
.map(|_| {
let id = arena.insert(Node::from(NodeType::Tr));
Node::append_child(&mut arena, table, id);
id
})
.collect();
let sel: CssSelector = "tr:nth-child(even)".parse().unwrap();
assert!(!selector_chain_matches(&arena, tr_ids[0], &sel));
assert!(selector_chain_matches(&arena, tr_ids[1], &sel));
assert!(!selector_chain_matches(&arena, tr_ids[2], &sel));
let span = arena.insert(Node::from(NodeType::Span));
Node::append_child(&mut arena, table, span);
assert!(!selector_chain_matches(&arena, span, &sel));
}
#[test]
fn test_nth_child_descendant_combinator() {
init();
let mut arena = NodeArena::default();
let table = arena.insert(Node::from(NodeType::Table));
let tr_ids: Vec<_> = (0..4)
.map(|_| {
let id = arena.insert(Node::from(NodeType::Tr));
Node::append_child(&mut arena, table, id);
id
})
.collect();
let sel: CssSelector = "table tr:nth-child(even)".parse().unwrap();
assert!(!selector_chain_matches(&arena, tr_ids[0], &sel));
assert!(selector_chain_matches(&arena, tr_ids[1], &sel));
assert!(!selector_chain_matches(&arena, tr_ids[2], &sel));
assert!(selector_chain_matches(&arena, tr_ids[3], &sel));
}
#[test]
fn test_nth_child_nth_expr_matches_predicate() {
init();
assert!(!NthExpr::EVEN.matches(1));
assert!(NthExpr::EVEN.matches(2));
assert!(!NthExpr::EVEN.matches(3));
assert!(NthExpr::EVEN.matches(4));
assert!(NthExpr::ODD.matches(1));
assert!(!NthExpr::ODD.matches(2));
assert!(NthExpr::ODD.matches(3));
let expr = NthExpr { a: 3, b: 2 };
assert!(expr.matches(2));
assert!(expr.matches(5));
assert!(expr.matches(8));
assert!(!expr.matches(1));
assert!(!expr.matches(4));
let single = NthExpr { a: 0, b: 3 };
assert!(single.matches(3));
assert!(!single.matches(2));
assert!(!single.matches(4));
}
#[test]
fn test_nth_child_unsupported_forms_fail_parse() {
init();
assert!(":nth-child(n)".parse::<CssSelector>().is_err());
assert!(":nth-child(-n+3)".parse::<CssSelector>().is_err());
assert!(":nth-child(2n-1)".parse::<CssSelector>().is_err());
assert!(":nth-child(0)".parse::<CssSelector>().is_err());
assert!(":nth-child".parse::<CssSelector>().is_err());
}
#[test]
fn test_css_into_node() {
init();
let mut arena = NodeArena::default();
let (root, _) = build_test(&mut arena);
let mut css = CssRule::new();
let sel: CssSelector = "div p".parse().unwrap();
let mut m = IndexMap::new();
m.insert("color".into(), "red".into());
css.add(sel, CssAttr::from(m));
css_into_node(&mut arena, root, &css);
let div_id = arena[root].children[0];
let p_id = arena[div_id].children[0];
assert!(arena[p_id].css_attr.get_color().is_some());
}
#[test]
fn test_css_selector_matches_title_and_meta() {
init();
let mut arena = NodeArena::default();
let root = arena.insert(Node::from(NodeType::Html));
let head_id = arena.insert(Node::from(NodeType::Head));
Node::append_child(&mut arena, root, head_id);
let title_id = arena.insert(Node::from(NodeType::Title));
let meta_id = arena.insert(Node::from(NodeType::Meta));
Node::append_child(&mut arena, head_id, title_id);
Node::append_child(&mut arena, head_id, meta_id);
let title_sel: CssSelector = "title".parse().unwrap();
let meta_sel: CssSelector = "meta".parse().unwrap();
assert!(selector_chain_matches(&arena, title_id, &title_sel));
assert!(selector_chain_matches(&arena, meta_id, &meta_sel));
assert!(!selector_chain_matches(&arena, meta_id, &title_sel));
assert!(!selector_chain_matches(&arena, title_id, &meta_sel));
}
#[test]
fn test_universal_selector_parses_and_matches_elements_only() {
init();
let mut arena = NodeArena::default();
let (root, div_id) = build_test(&mut arena);
let sel: CssSelector = "*".parse().unwrap();
assert_eq!(sel.compounds.len(), 1);
assert_eq!(sel.compounds[0], vec![Selector::Universal]);
let p_id = arena[div_id].children[0];
let text_id = arena[p_id].children[0];
assert!(selector_chain_matches(&arena, div_id, &sel));
assert!(selector_chain_matches(&arena, p_id, &sel));
assert!(!selector_chain_matches(&arena, root, &sel));
assert!(!selector_chain_matches(&arena, text_id, &sel));
}
#[test]
fn test_universal_selector_in_compound_and_descendant() {
init();
let mut arena = NodeArena::default();
let (_, div_id) = build_test(&mut arena);
let compound_sel: CssSelector = "*.active".parse().unwrap();
assert!(selector_chain_matches(&arena, div_id, &compound_sel));
let descendant_sel: CssSelector = "div *".parse().unwrap();
let p_id = arena[div_id].children[0];
assert!(selector_chain_matches(&arena, p_id, &descendant_sel));
assert!(!selector_chain_matches(&arena, div_id, &descendant_sel));
}
#[test]
fn test_universal_selector_specificity_is_zero() {
init();
let univ: CssSelector = "*".parse().unwrap();
let tag: CssSelector = "div".parse().unwrap();
let chain: CssSelector = "* div".parse().unwrap();
assert_eq!(univ.specificity(), (0, 0, 0));
assert_eq!(chain.specificity(), tag.specificity());
}
#[test]
fn test_universal_selector_cascades_to_elements_not_text() {
init();
let mut arena = NodeArena::default();
let (root, _) = build_test(&mut arena);
let mut css = CssRule::new();
let sel: CssSelector = "*".parse().unwrap();
let mut m = IndexMap::new();
m.insert("background-color".into(), "red".into());
css.add(sel, CssAttr::from(m));
css_into_node(&mut arena, root, &css);
let div_id = arena[root].children[0];
let p_id = arena[div_id].children[0];
let text_id = arena[p_id].children[0];
assert!(arena[div_id].css_attr.get_bg_color().is_some());
assert!(arena[p_id].css_attr.get_bg_color().is_some());
assert!(arena[text_id].css_attr.get_bg_color().is_none());
}
#[test]
fn test_e_type_selector_covers_every_element_tag() {
init();
let mut arena = NodeArena::default();
for nt in NodeType::ALL {
match nt.tag_name() {
Some(tag) => {
assert_eq!(NodeType::from_tag_name(tag), Some(*nt));
assert_eq!(html_parse::tag_name_to_node_type(tag), Some(*nt));
let sel: CssSelector = tag
.parse()
.unwrap_or_else(|e| panic!("tag {tag} failed as a selector: {e}"));
let node_id = arena.insert(Node::from(*nt));
assert!(
selector_chain_matches(&arena, node_id, &sel),
"selector `{tag}` must match its node type"
);
}
None => {
assert!(
matches!(
nt,
NodeType::Document
| NodeType::DocumentType
| NodeType::DocumentFragment
| NodeType::Text
| NodeType::Comment
),
"{nt:?} is an element but has no tag name"
);
}
}
}
}
#[test]
fn test_e_type_selector_rejects_unknown_tag() {
init();
assert!("madeup".parse::<CssSelector>().is_err());
assert!("div blink".parse::<CssSelector>().is_err());
}
#[test]
fn test_is_set_distinguishes_unset_from_explicit_none() {
init();
let mut attr = CssAttr::new();
assert!(!attr.is_set("text-decoration"));
assert!(!attr.is_set("color"));
let declared = parse_declaration_text("text-decoration: none;");
attr.add(&CssAttr(declared));
assert!(
attr.is_set("text-decoration"),
"explicit `none` declaration must still count as set"
);
assert_eq!(attr.get_text_decoration(), TextDecoration::None);
}
#[test]
fn test_color_name_parsing() {
assert_eq!(
parse_color_str("darkblue"),
Some(Color::Rgb {
r: 0x00,
g: 0x00,
b: 0x8B
})
);
assert_eq!(
parse_color_str("DarkBlue"),
Some(Color::Rgb {
r: 0x00,
g: 0x00,
b: 0x8B
})
);
assert_eq!(
parse_color_str("red"),
Some(Color::Rgb {
r: 0xFF,
g: 0x00,
b: 0x00
})
);
assert_eq!(
parse_color_str("darkred"),
Some(Color::Rgb {
r: 0x8B,
g: 0x00,
b: 0x00
})
);
assert_eq!(
parse_color_str("blue"),
Some(Color::Rgb {
r: 0x00,
g: 0x00,
b: 0xFF
})
);
assert_eq!(
parse_color_str("grey"),
Some(Color::Rgb {
r: 0x80,
g: 0x80,
b: 0x80
})
);
assert_eq!(
parse_color_str("gray"),
Some(Color::Rgb {
r: 0x80,
g: 0x80,
b: 0x80
})
);
}
#[test]
fn test_color_hex_parsing() {
assert_eq!(
parse_color_str("#1e293b"),
Some(Color::Rgb {
r: 0x1e,
g: 0x29,
b: 0x3b
})
);
assert_eq!(
parse_color_str("#fff"),
Some(Color::Rgb {
r: 255,
g: 255,
b: 255
})
);
}
#[test]
fn test_color_rgb_parsing() {
assert_eq!(
parse_color_str("rgb(30,41,59)"),
Some(Color::Rgb {
r: 30,
g: 41,
b: 59
})
);
}
#[test]
fn test_color_rgba_and_modern_syntax() {
assert_eq!(
parse_color_str("rgba(255, 0, 0, 0.5)"),
Some(Color::Rgb { r: 255, g: 0, b: 0 }),
"rgba alpha is dropped (opaque)"
);
assert_eq!(
parse_color_str("rgb(255 0 0 / 50%)"),
Some(Color::Rgb { r: 255, g: 0, b: 0 })
);
assert_eq!(
parse_color_str("rgb(100% 0% 0%)"),
Some(Color::Rgb { r: 255, g: 0, b: 0 })
);
assert_eq!(
parse_color_str("rgb(300, 0, 0)"),
Some(Color::Rgb { r: 255, g: 0, b: 0 }),
"out-of-range components clamp to 0…255"
);
assert_eq!(
parse_color_str("rgb(20% 40% 60%)"),
Some(Color::Rgb {
r: 51,
g: 102,
b: 153
})
);
}
#[test]
fn test_color_hsl_parsing() {
assert_eq!(
parse_color_str("hsl(0, 100%, 50%)"),
Some(Color::Rgb { r: 255, g: 0, b: 0 })
);
assert_eq!(
parse_color_str("hsl(120, 100%, 50%)"),
Some(Color::Rgb { r: 0, g: 255, b: 0 })
);
assert_eq!(
parse_color_str("hsl(120 100% 50% / 0.25)"),
Some(Color::Rgb { r: 0, g: 255, b: 0 })
);
assert_eq!(
parse_color_str("hsl(480, 100%, 50%)"),
Some(Color::Rgb { r: 0, g: 255, b: 0 })
);
assert_eq!(
parse_color_str("hsl(0, 0%, 50%)"),
Some(Color::Rgb {
r: 128,
g: 128,
b: 128
})
);
assert_eq!(
parse_color_str("hsla(0, 100%, 50%, 0.5)"),
Some(Color::Rgb { r: 255, g: 0, b: 0 })
);
}
#[test]
fn test_color_hex_alpha_parsing() {
assert_eq!(
parse_color_str("#ff000080"),
Some(Color::Rgb { r: 255, g: 0, b: 0 }),
"#RRGGBBAA drops the alpha channel"
);
assert_eq!(
parse_color_str("#f008"),
Some(Color::Rgb { r: 255, g: 0, b: 0 }),
"#RGBA drops the alpha channel"
);
}
#[test]
fn test_color_transparent_and_malformed() {
assert_eq!(parse_color_str("transparent"), None);
assert_eq!(parse_color_str("rgb(0, 0)"), None);
assert_eq!(parse_color_str("rgb(0 0)"), None);
assert_eq!(parse_color_str("hsl(0, 100%)"), None);
assert_eq!(parse_color_str("rgba(0, 0, 0, 1.5)"), None);
assert_eq!(parse_color_str("hsl(0, 0%, 0%, 2)"), None);
}
#[test]
fn test_linear_gradient_parsing_and_sampling() {
let Some(Background::Gradient(gradient)) =
parse_background_str("linear-gradient(to right, #ff0000 0%, rgb(0, 0, 255) 100%)")
else {
panic!("linear gradient should parse");
};
assert_eq!(gradient.kind, GradientKind::Linear { angle_deg: 90.0 });
assert_eq!(gradient.stops.len(), 2);
assert_eq!(gradient.stops[0].color, Color::Rgb { r: 255, g: 0, b: 0 });
assert_eq!(gradient.stops[1].color, Color::Rgb { r: 0, g: 0, b: 255 });
let left = gradient.color_at(0, 0, 10, 1).unwrap();
let right = gradient.color_at(9, 0, 10, 1).unwrap();
assert_ne!(left, right);
}
#[test]
fn test_radial_gradient_parsing_with_center() {
let Some(Background::Gradient(gradient)) =
parse_background_str("radial-gradient(circle at 25% 75%, red, blue)")
else {
panic!("radial gradient should parse");
};
assert_eq!(
gradient.kind,
GradientKind::Radial {
center_x: 0.25,
center_y: 0.75
}
);
assert_eq!(gradient.stops.len(), 2);
assert!(parse_background_str("linear-gradient(red)").is_none());
}
#[test]
fn test_background_clip_text_getter() {
let mut attr = CssAttr::new();
attr.add(&CssAttr(parse_declaration_text(
"background-clip: text; color: transparent;",
)));
assert!(attr.background_clips_to_text());
assert_eq!(attr.get_color(), None);
}
#[test]
fn test_css_attr_full_color() {
let mut attr = CssAttr::new();
let css = "color: darkblue; background-color: #1e293b;";
let m = parse_declaration_text(css);
attr.add(&CssAttr(m));
assert_eq!(
attr.get_color(),
Some(Color::Rgb {
r: 0x00,
g: 0x00,
b: 0x8B
})
);
assert_eq!(
attr.get_bg_color(),
Some(Color::Rgb {
r: 0x1e,
g: 0x29,
b: 0x3b
})
);
}
#[test]
fn user_select_is_recognised_and_resolved_against_ancestors() {
assert!(SUPPORTED_PROPERTIES.contains(&"user-select"));
let mut attr = CssAttr::new();
attr.0.insert("user-select".into(), "none".into());
assert_eq!(attr.get_user_select(), UserSelect::None);
let mut arena = NodeArena::default();
let root = arena.insert(Node::from(NodeType::Div));
let child = arena.insert(Node::from(NodeType::Div));
arena[root].children.push(child);
arena[child].parent = Some(root);
assert!(
!htui::layout::user_select_is_none(&arena, child),
"auto without an explicit ancestor defaults to selectable"
);
arena[root]
.css_attr
.0
.insert("user-select".into(), "none".into());
assert!(
htui::layout::user_select_is_none(&arena, child),
"auto resolves through the nearest explicit ancestor"
);
arena[child]
.css_attr
.0
.insert("user-select".into(), "text".into());
assert!(
!htui::layout::user_select_is_none(&arena, child),
"an explicit text descendant opts back into selection"
);
}
#[test]
fn test_supported_properties_lists_known_names() {
assert!(SUPPORTED_PROPERTIES.contains(&"color"));
assert!(SUPPORTED_PROPERTIES.contains(&"background-color"));
assert!(SUPPORTED_PROPERTIES.contains(&"background"));
assert!(SUPPORTED_PROPERTIES.contains(&"text-align"));
assert!(SUPPORTED_PROPERTIES.contains(&"font-weight"));
assert!(SUPPORTED_PROPERTIES.contains(&"font-style"));
assert!(SUPPORTED_PROPERTIES.contains(&"text-decoration"));
assert!(SUPPORTED_PROPERTIES.contains(&"width"));
assert!(SUPPORTED_PROPERTIES.contains(&"padding"));
assert!(SUPPORTED_PROPERTIES.contains(&"padding-top"));
assert!(SUPPORTED_PROPERTIES.contains(&"margin"));
assert!(SUPPORTED_PROPERTIES.contains(&"margin-left"));
assert!(SUPPORTED_PROPERTIES.contains(&"border"));
assert!(SUPPORTED_PROPERTIES.contains(&"border-top"));
assert!(SUPPORTED_PROPERTIES.contains(&"border-width"));
assert!(SUPPORTED_PROPERTIES.contains(&"border-style"));
assert!(SUPPORTED_PROPERTIES.contains(&"border-color"));
assert!(SUPPORTED_PROPERTIES.contains(&"visibility"));
assert!(SUPPORTED_PROPERTIES.contains(&"overflow"));
}
#[test]
fn visibility_is_supported_and_inheritable() {
init();
assert!(SUPPORTED_PROPERTIES.contains(&"visibility"));
assert!(INHERITED_PROPS.contains(&"visibility"));
}
#[test]
fn overflow_hidden_is_parsed_and_kept_by_the_cascade() {
init();
let mut arena = NodeArena::default();
let (root, div) = build_test(&mut arena);
let css = Parser::new("div { overflow: hidden }").parse();
css_into_node(&mut arena, root, &css);
assert_eq!(
arena[div].css_attr.0.get("overflow").map(|s| s.as_ref()),
Some("hidden"),
"overflow: hidden must survive the cascade"
);
}
#[test]
fn test_inherited_props_is_a_subset_of_supported() {
for p in INHERITED_PROPS {
assert!(
SUPPORTED_PROPERTIES.contains(p),
"{p} is in INHERITED_PROPS but missing from SUPPORTED_PROPERTIES"
);
}
}
#[test]
fn test_inherited_props_contains_visual_inheritable_set() {
assert!(INHERITED_PROPS.contains(&"color"));
assert!(INHERITED_PROPS.contains(&"text-align"));
assert!(INHERITED_PROPS.contains(&"font-weight"));
assert!(INHERITED_PROPS.contains(&"font-style"));
assert!(INHERITED_PROPS.contains(&"text-decoration"));
assert!(!INHERITED_PROPS.contains(&"background-color"));
assert!(!INHERITED_PROPS.contains(&"background"));
assert!(!INHERITED_PROPS.contains(&"width"));
}
#[test]
fn test_important_marker_is_stripped_from_value() {
let m = parse_declaration_text("color: red !important;");
assert_eq!(m.get("color").map(|s| s.as_ref()), Some("red"));
}
#[test]
fn test_important_marker_with_space_is_not_recognised() {
let m = parse_declaration_text("font-weight: bold ! important;");
let val = m.get("font-weight").map(|s| s.as_ref());
assert_eq!(val, Some("bold"));
assert!(
!val.unwrap().contains("!important"),
"must not smuggle the bogus `! important` into the value"
);
}
#[test]
fn test_important_marker_with_leading_space_is_kept_out_of_value() {
let m = parse_declaration_text("font-style: italic !important ;");
assert_eq!(m.get("font-style").map(|s| s.as_ref()), Some("italic"));
}
#[test]
fn test_block_comment_is_stripped() {
let m = parse_declaration_text("/* color: ignored */ color: red;");
assert_eq!(m.get("color").map(|s| s.as_ref()), Some("red"));
assert_eq!(m.len(), 1, "commented-out decl must not appear as a key");
}
#[test]
fn test_block_comment_inside_string_is_preserved() {
let m = parse_declaration_text("content: \"a /* b */ c\";");
assert_eq!(
m.get("content").map(|s| s.as_ref()),
Some("\"a /* b */ c\"")
);
}
#[test]
fn test_unterminated_comment_truncates_input() {
let m = parse_declaration_text("color: red; /* oops");
assert_eq!(m.get("color").map(|s| s.as_ref()), Some("red"));
}
#[test]
fn test_missing_trailing_semicolon_is_ok() {
let m = parse_declaration_text("color: blue");
assert_eq!(m.get("color").map(|s| s.as_ref()), Some("blue"));
}
#[test]
fn test_parse_splits_important_declarations() {
init();
let css = Parser::new("p { color: red !important; width: 10px }").parse();
assert_eq!(css.0.len(), 1);
let entry = &css.0[0];
assert_eq!(
entry.normal.0.get("width").map(|s| s.as_ref()),
Some("10px")
);
assert!(entry.normal.0.get("color").is_none());
assert_eq!(
entry.important.0.get("color").map(|s| s.as_ref()),
Some("red")
);
assert!(entry.important.0.get("width").is_none());
}
#[test]
fn test_cascade_specificity_units() {
init();
let spec = |s: &str| s.parse::<CssSelector>().unwrap().specificity();
assert_eq!(spec("p"), (0, 0, 1));
assert_eq!(spec(".a"), (0, 1, 0));
assert_eq!(spec("#i"), (1, 0, 0));
assert_eq!(spec("div p span"), (0, 0, 3));
assert_eq!(spec(".a .b"), (0, 2, 0));
assert_eq!(spec("div.active"), (0, 1, 1));
assert_eq!(spec("p:hover"), (0, 1, 1));
assert_eq!(spec("li:nth-child(2)"), (0, 1, 1));
assert_eq!(spec("div > p + span"), (0, 0, 3));
}
#[test]
fn test_cascade_specificity_wins_regardless_of_order() {
init();
for css_text in [
"div p { color: red } p { color: blue }",
"p { color: blue } div p { color: red }",
] {
let mut arena = NodeArena::default();
let (root, div_id) = build_test(&mut arena);
let p_id = arena[div_id].children[0];
let css = Parser::new(css_text).parse();
css_into_node(&mut arena, root, &css);
assert_eq!(
arena[p_id].css_attr.get_color(),
Some(Color::Rgb {
r: 0xFF,
g: 0x00,
b: 0x00
}),
"css: {css_text}"
);
}
}
#[test]
fn test_cascade_important_beats_normal() {
init();
for css_text in [
"p { color: blue } p { color: red !important }",
"p { color: red !important } p { color: blue }",
] {
let mut arena = NodeArena::default();
let (root, div_id) = build_test(&mut arena);
let p_id = arena[div_id].children[0];
let css = Parser::new(css_text).parse();
css_into_node(&mut arena, root, &css);
assert_eq!(
arena[p_id].css_attr.get_color(),
Some(Color::Rgb {
r: 0xFF,
g: 0x00,
b: 0x00
}),
"css: {css_text}"
);
}
}
#[test]
fn test_cascade_important_beats_higher_specificity_normal() {
init();
let mut arena = NodeArena::default();
let (root, p_id) = build_p_with_classes(&mut arena);
let css = Parser::new("p { color: red !important } .b { color: blue }").parse();
css_into_node(&mut arena, root, &css);
assert_eq!(
arena[p_id].css_attr.get_color(),
Some(Color::Rgb {
r: 0xFF,
g: 0x00,
b: 0x00
})
);
}
#[test]
fn test_cascade_inline_style_beats_stylesheet_normal() {
init();
let mut arena = NodeArena::default();
let (root, div_id) = build_test(&mut arena);
let p_id = arena[div_id].children[0];
arena[p_id]
.attributes
.insert("style".to_string(), "color: blue".to_string());
let css = Parser::new("p { color: red }").parse();
css_into_node(&mut arena, root, &css);
assert_eq!(
arena[p_id].css_attr.get_color(),
Some(Color::Rgb {
r: 0x00,
g: 0x00,
b: 0xFF
})
);
}
#[test]
fn test_cascade_important_precedence_with_inline_style() {
init();
let mut arena = NodeArena::default();
let (root, div_id) = build_test(&mut arena);
let p_id = arena[div_id].children[0];
arena[p_id]
.attributes
.insert("style".to_string(), "color: blue".to_string());
let css = Parser::new("p { color: red !important }").parse();
css_into_node(&mut arena, root, &css);
assert_eq!(
arena[p_id].css_attr.get_color(),
Some(Color::Rgb {
r: 0xFF,
g: 0x00,
b: 0x00
})
);
let mut arena2 = NodeArena::default();
let (root2, div2) = build_test(&mut arena2);
let p2 = arena2[div2].children[0];
arena2[p2]
.attributes
.insert("style".to_string(), "color: blue !important".to_string());
let css2 = Parser::new("p { color: red !important }").parse();
css_into_node(&mut arena2, root2, &css2);
assert_eq!(
arena2[p2].css_attr.get_color(),
Some(Color::Rgb {
r: 0x00,
g: 0x00,
b: 0xFF
})
);
}
#[test]
fn test_cascade_same_specificity_later_source_wins() {
init();
let mut arena = NodeArena::default();
let (root, p_id) = build_p_with_classes(&mut arena);
let css = Parser::new(".a { color: red } .b { color: blue } .a { color: green }").parse();
css_into_node(&mut arena, root, &css);
assert_eq!(
arena[p_id].css_attr.get_color(),
Some(Color::Rgb {
r: 0x00,
g: 0x80,
b: 0x00
})
);
}
#[test]
fn test_background_color_does_not_inherit() {
init();
let mut arena = NodeArena::default();
let (root, _) = build_test(&mut arena);
let mut css = CssRule::new();
let sel: CssSelector = "div".parse().unwrap();
let mut m = IndexMap::new();
m.insert("background-color".into(), "yellow".into());
css.add(sel, CssAttr::from(m));
css_into_node(&mut arena, root, &css);
let div_id = arena[root].children[0];
let p_id = arena[div_id].children[0];
assert_eq!(
arena[div_id].css_attr.get_bg_color(),
Some(Color::Rgb {
r: 0xFF,
g: 0xFF,
b: 0x00
}),
"div should have its own bg"
);
assert_eq!(
arena[p_id].css_attr.get_bg_color(),
None,
"non-inheritable property must not leak to descendant"
);
}
#[test]
fn test_color_does_inherit() {
init();
let mut arena = NodeArena::default();
let (root, _) = build_test(&mut arena);
let mut css = CssRule::new();
let sel: CssSelector = "div".parse().unwrap();
let mut m = IndexMap::new();
m.insert("color".into(), "red".into());
css.add(sel, CssAttr::from(m));
css_into_node(&mut arena, root, &css);
let div_id = arena[root].children[0];
let p_id = arena[div_id].children[0];
assert_eq!(
arena[div_id].css_attr.get_color(),
Some(Color::Rgb {
r: 0xFF,
g: 0x00,
b: 0x00
})
);
assert_eq!(
arena[p_id].css_attr.get_color(),
Some(Color::Rgb {
r: 0xFF,
g: 0x00,
b: 0x00
}),
"inheritable property must propagate to descendant"
);
}
#[test]
fn test_full_parse_pipeline_handles_important_and_comments() {
init();
let mut parser = Parser::new(
"/* heading style */
\
h1 { color: red !important; font-weight: bold; }
\
h2 { /* nothing */ font-style: italic; }",
);
let _rules = parser.parse();
let mut arena = NodeArena::default();
let root = arena.insert(Node::from(NodeType::Document));
let h1 = arena.insert(Node::from(NodeType::H1));
Node::append_child(&mut arena, root, h1);
let mut css = CssRule::new();
let sel_h1: CssSelector = "h1".parse().unwrap();
let mut decls = IndexMap::new();
decls.insert("color".into(), "red".into());
decls.insert("font-weight".into(), "bold".into());
css.add(sel_h1, CssAttr::from(decls));
css_into_node(&mut arena, root, &css);
assert_eq!(
arena[h1].css_attr.get_color(),
Some(Color::Rgb {
r: 0xFF,
g: 0x00,
b: 0x00
})
);
assert_eq!(arena[h1].css_attr.get_font_weight(), FontWeight::Bold);
}
#[test]
fn length_parse_auto() {
init();
assert_eq!(Length::parse("auto"), Some(Length::Auto));
assert_eq!(Length::parse("AUTO"), Some(Length::Auto));
assert_eq!(Length::parse("Auto"), Some(Length::Auto));
assert_eq!(Length::parse(" auto "), Some(Length::Auto));
}
#[test]
fn length_parse_cells_px() {
init();
assert_eq!(Length::parse("40px"), Some(Length::Px(40.0)));
assert_eq!(Length::parse(" 40px "), Some(Length::Px(40.0)));
assert_eq!(Length::parse("20Px"), Some(Length::Px(20.0)));
assert_eq!(Length::parse("30PX"), Some(Length::Px(30.0)));
assert_eq!(
Length::parse(
"20px
"
),
Some(Length::Px(20.0))
);
}
#[test]
fn px_per_cell_constant_anchors_px_to_browser_scale() {
assert_eq!(PX_PER_CELL, 8.0);
}
#[test]
fn length_parse_cells_px_fractional_rounds_half_away_from_zero() {
init();
assert_eq!(Length::parse("0.4px"), Some(Length::Px(0.4)));
assert_eq!(Length::parse("0.5px"), Some(Length::Px(0.5)));
assert_eq!(Length::parse("0.6px"), Some(Length::Px(0.6)));
assert_eq!(Length::parse("1.5px"), Some(Length::Px(1.5)));
assert_eq!(Length::parse("2.5px"), Some(Length::Px(2.5)));
assert_eq!(Length::parse("100.4px"), Some(Length::Px(100.4)));
}
#[test]
fn length_parse_cells_px_zero_and_integer_exact() {
init();
assert_eq!(Length::parse("0px"), Some(Length::Px(0.0)));
assert_eq!(Length::parse("1px"), Some(Length::Px(1.0)));
assert_eq!(Length::parse("1000px"), Some(Length::Px(1000.0)));
}
#[test]
fn length_parse_cells_px_divide_matches_browser_scale() {
init();
assert_eq!(
(40.0_f32 / PX_PER_CELL).round() as usize,
Length::parse("40px")
.unwrap()
.resolve(length_ctx(80, default_font_ctx()))
);
}
#[test]
fn length_parse_cells_bare_number() {
init();
assert_eq!(Length::parse("40"), Some(Length::Px(40.0)));
assert_eq!(Length::parse("0"), Some(Length::Px(0.0)));
assert_eq!(Length::parse("1.5"), Some(Length::Px(1.5)));
assert_eq!(Length::parse("0.5"), Some(Length::Px(0.5)));
}
#[test]
fn length_parse_pct() {
init();
assert_eq!(Length::parse("50%"), Some(Length::Pct(50.0)));
assert_eq!(Length::parse("33%"), Some(Length::Pct(33.0)));
assert_eq!(Length::parse("0%"), Some(Length::Pct(0.0)));
assert_eq!(Length::parse("100%"), Some(Length::Pct(100.0)));
}
#[test]
fn length_parse_em_rem() {
init();
assert_eq!(Length::parse("2em"), Some(Length::Em(2.0)));
assert_eq!(Length::parse("1.5em"), Some(Length::Em(1.5)));
assert_eq!(Length::parse("2REM"), Some(Length::Rem(2.0)));
assert_eq!(Length::parse("0.5rem"), Some(Length::Rem(0.5)));
}
#[test]
fn length_parse_vw_vh() {
init();
assert_eq!(Length::parse("50vw"), Some(Length::Vw(50.0)));
assert_eq!(Length::parse("50vh"), Some(Length::Vh(50.0)));
assert_eq!(Length::parse("100VW"), Some(Length::Vw(100.0)));
assert_eq!(Length::parse("12.5vh"), Some(Length::Vh(12.5)));
}
#[test]
fn length_parse_ch() {
init();
assert_eq!(Length::parse("12ch"), Some(Length::Ch(12.0)));
assert_eq!(Length::parse("1.5ch"), Some(Length::Ch(1.5)));
assert_eq!(Length::parse("8CH"), Some(Length::Ch(8.0)));
assert_eq!(Length::parse(" 4ch "), Some(Length::Ch(4.0)));
}
#[test]
fn length_parse_rejects_unsupported_units() {
init();
assert_eq!(Length::parse("50vmin"), None);
assert_eq!(Length::parse("50vmax"), None);
assert_eq!(Length::parse("12pt"), None);
assert_eq!(Length::parse("5cm"), None);
assert_eq!(Length::parse("5mm"), None);
assert_eq!(Length::parse("1in"), None);
assert_eq!(Length::parse("1pc"), None);
}
#[test]
fn length_parse_rejects_signed_and_junk() {
init();
assert_eq!(Length::parse("-10px"), None);
assert_eq!(Length::parse("+10px"), None);
assert_eq!(Length::parse(""), None);
assert_eq!(Length::parse(" "), None);
assert_eq!(Length::parse("40 px"), None);
assert_eq!(Length::parse("40 PX"), None);
assert_eq!(Length::parse("40px garbage"), None);
assert_eq!(Length::parse("hello"), None);
assert_eq!(Length::parse("!important"), None);
}
#[test]
fn length_resolve_auto_and_cells() {
init();
assert_eq!(Length::Auto.resolve(length_ctx(80, default_font_ctx())), 80);
assert_eq!(
Length::Px(40.0).resolve(length_ctx(80, default_font_ctx())),
5
);
assert_eq!(
Length::Px(0.0).resolve(length_ctx(80, default_font_ctx())),
0
); assert_eq!(
Length::Px(1000.0).resolve(length_ctx(80, default_font_ctx())),
125
); }
#[test]
fn length_resolve_pct_rounds_and_min_1() {
init();
assert_eq!(
Length::Pct(50.0).resolve(length_ctx(80, default_font_ctx())),
40
);
assert_eq!(
Length::Pct(100.0).resolve(length_ctx(80, default_font_ctx())),
80
);
assert_eq!(
Length::Pct(33.0).resolve(length_ctx(80, default_font_ctx())),
26
);
assert_eq!(
Length::Pct(50.5).resolve(length_ctx(80, default_font_ctx())),
40
);
assert_eq!(
Length::Pct(50.6).resolve(length_ctx(80, default_font_ctx())),
40
);
assert_eq!(
Length::Pct(50.0).resolve(length_ctx(0, default_font_ctx())),
1
);
assert_eq!(
Length::Pct(0.0).resolve(length_ctx(80, default_font_ctx())),
1
); }
#[test]
fn length_resolve_em_rem_rounds_away_from_zero() {
init();
assert_eq!(
Length::Em(2.0).resolve(length_ctx(80, default_font_ctx())),
2
);
assert_eq!(
Length::Em(1.5).resolve(length_ctx(80, default_font_ctx())),
2
); assert_eq!(
Length::Em(0.5).resolve(length_ctx(80, default_font_ctx())),
1
); assert_eq!(
Length::Em(2.5).resolve(length_ctx(80, default_font_ctx())),
3
); assert_eq!(
Length::Rem(1.5).resolve(length_ctx(80, default_font_ctx())),
2
);
assert_eq!(
Length::Rem(0.0).resolve(length_ctx(80, default_font_ctx())),
0
);
}
#[test]
fn length_resolve_em_scales_with_font_size() {
init();
assert_eq!(Length::Em(2.0).resolve(length_ctx(80, font_ctx(2, 1))), 4);
assert_eq!(Length::Em(1.5).resolve(length_ctx(80, font_ctx(3, 1))), 5);
assert_eq!(Length::Em(0.5).resolve(length_ctx(80, font_ctx(2, 1))), 1);
}
#[test]
fn length_resolve_rem_scales_with_root_font_size() {
init();
assert_eq!(Length::Rem(2.0).resolve(length_ctx(80, font_ctx(1, 3))), 6);
assert_eq!(Length::Rem(1.5).resolve(length_ctx(80, font_ctx(2, 4))), 6);
assert_eq!(Length::Rem(0.0).resolve(length_ctx(80, font_ctx(5, 5))), 0);
}
#[test]
fn length_resolve_ch_is_font_size_independent() {
init();
assert_eq!(
Length::Ch(12.0).resolve(length_ctx(80, default_font_ctx())),
12
);
assert_eq!(Length::Ch(1.5).resolve(length_ctx(80, font_ctx(2, 1))), 2); assert_eq!(Length::Ch(0.4).resolve(length_ctx(80, font_ctx(3, 2))), 0);
}
#[test]
fn length_resolve_vw_vh_anchor_to_viewport() {
init();
assert_eq!(Length::Vw(50.0).resolve(length_ctx(80, font_ctx(1, 1))), 40); assert_eq!(
Length::Vw(100.0).resolve(length_ctx(80, font_ctx(1, 1))),
80
);
assert_eq!(
Length::Vw(50.0).resolve(length_ctx(999, font_ctx(1, 1))),
40
); assert_eq!(Length::Vh(50.0).resolve(length_ctx(80, font_ctx(1, 1))), 12); assert_eq!(
Length::Vh(100.0).resolve(length_ctx(80, font_ctx(1, 1))),
24
);
assert_eq!(Length::Vw(33.3).resolve(length_ctx(80, font_ctx(1, 1))), 27);
assert_eq!(Length::Vw(0.5).resolve(length_ctx(80, font_ctx(1, 1))), 1);
}
#[test]
fn css_attr_get_width_end_to_end() {
init();
let mut attr = CssAttr::new();
let mut decls = IndexMap::new();
decls.insert("width".into(), "auto".into());
attr.add(&CssAttr::from(decls));
assert_eq!(
attr.get_width(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport()
),
None
);
let mut attr = CssAttr::new();
let mut decls = IndexMap::new();
decls.insert("width".into(), "40ch".into());
attr.add(&CssAttr::from(decls));
assert_eq!(
attr.get_width(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport()
),
Some(40)
);
let mut attr = CssAttr::new();
let mut decls = IndexMap::new();
decls.insert("width".into(), "50%".into());
attr.add(&CssAttr::from(decls));
assert_eq!(
attr.get_width(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport()
),
Some(40)
);
let mut attr = CssAttr::new();
let mut decls = IndexMap::new();
decls.insert("width".into(), "2em".into());
attr.add(&CssAttr::from(decls));
assert_eq!(
attr.get_width(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport()
),
Some(2)
);
let mut attr = CssAttr::new();
let mut decls = IndexMap::new();
decls.insert("width".into(), "1.5em".into());
attr.add(&CssAttr::from(decls));
assert_eq!(
attr.get_width(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport()
),
Some(2)
);
let mut attr = CssAttr::new();
let mut decls = IndexMap::new();
decls.insert("width".into(), "2rem".into());
attr.add(&CssAttr::from(decls));
assert_eq!(
attr.get_width(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport()
),
Some(2)
);
let mut attr = CssAttr::new();
let mut decls = IndexMap::new();
decls.insert("width".into(), "50vw".into());
attr.add(&CssAttr::from(decls));
assert_eq!(
attr.get_width(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport()
),
Some(40)
);
let mut attr = CssAttr::new();
let mut decls = IndexMap::new();
decls.insert("width".into(), "50vh".into());
attr.add(&CssAttr::from(decls));
assert_eq!(
attr.get_width(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport()
),
Some(12)
);
assert_eq!(
CssAttr::new().get_width(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport()
),
None
);
}
fn attr_from_decl(name: &str, value: &str) -> CssAttr {
let mut decls = IndexMap::new();
decls.insert(name.into(), value.into());
let mut attr = CssAttr::new();
attr.add(&CssAttr::from(decls));
attr
}
#[test]
fn padding_shorthand_one_value_fills_all_sides() {
let attr = attr_from_decl("padding", "5ch");
let edges = attr.get_padding(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport(),
);
assert_eq!(edges, EdgeSizes::new(5, 5, 5, 5));
}
#[test]
fn padding_shorthand_two_values_vertical_horizontal() {
let attr = attr_from_decl("padding", "10ch 20ch");
assert_eq!(
attr.get_padding(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport()
),
EdgeSizes::new(10, 20, 10, 20)
);
}
#[test]
fn padding_shorthand_three_values_top_horizontal_bottom() {
let attr = attr_from_decl("padding", "1ch 2ch 3ch");
assert_eq!(
attr.get_padding(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport()
),
EdgeSizes::new(1, 2, 3, 2)
);
}
#[test]
fn padding_shorthand_four_values_clockwise() {
let attr = attr_from_decl("padding", "1ch 2ch 3ch 4ch");
assert_eq!(
attr.get_padding(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport()
),
EdgeSizes::new(1, 2, 3, 4)
);
}
#[test]
fn padding_longhands_override_shorthand() {
let mut decls = IndexMap::new();
decls.insert("padding".into(), "0".into());
decls.insert("padding-top".into(), "7ch".into());
let mut attr = CssAttr::new();
attr.add(&CssAttr::from(decls));
assert_eq!(
attr.get_padding(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport()
),
EdgeSizes::new(7, 0, 0, 0)
);
}
#[test]
fn padding_supports_percent_and_em() {
let attr = attr_from_decl("padding", "50%");
assert_eq!(
attr.get_padding(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport()
)
.left,
40
);
let attr = attr_from_decl("padding-top", "3em");
assert_eq!(
attr.get_padding(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport()
)
.top,
3
);
}
#[test]
fn margin_shorthand_mirrors_padding() {
let attr = attr_from_decl("margin", "2ch 4ch");
assert_eq!(
attr.get_margin(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport()
),
MarginSizes::new(2, 4, 2, 4)
);
let attr = attr_from_decl("margin-left", "6ch");
assert_eq!(
attr.get_margin(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport()
),
MarginSizes::new(0, 0, 0, 6)
);
}
#[test]
fn margin_auto_collapses_to_zero() {
let attr = attr_from_decl("margin", "auto");
assert_eq!(
attr.get_margin(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport()
),
MarginSizes::default()
);
}
#[test]
fn border_shorthand_solid_parses_width_and_style() {
let attr = attr_from_decl("border", "1ch solid red");
let sides = attr.get_border(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport(),
);
assert_eq!(sides.top.width, 1);
assert_eq!(sides.top.style, BorderStyle::Solid);
assert_eq!(
sides.top.color,
Some(Color::Rgb {
r: 0xFF,
g: 0x00,
b: 0x00
})
);
assert_eq!(sides.right.width, 1);
assert_eq!(sides.bottom.width, 1);
assert_eq!(sides.left.width, 1);
}
#[test]
fn border_shorthand_parses_functional_colors() {
let attr = attr_from_decl("border", "1ch solid rgb(255, 0, 0)");
let sides = attr.get_border(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport(),
);
assert_eq!(
sides.top.color,
Some(Color::Rgb { r: 255, g: 0, b: 0 }),
"comma-syntax rgb() must survive the border shorthand"
);
assert_eq!(sides.top.style, BorderStyle::Solid);
let attr = attr_from_decl("border", "2ch solid hsl(120 100% 50%)");
let sides = attr.get_border(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport(),
);
assert_eq!(
sides.top.color,
Some(Color::Rgb { r: 0, g: 255, b: 0 }),
"space-syntax hsl() must survive the border shorthand"
);
assert_eq!(sides.top.width, 2);
}
#[test]
fn border_shorthand_style_alone_applies_to_all_sides() {
let attr = attr_from_decl("border", "solid");
let sides = attr.get_border(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport(),
);
assert_eq!(sides.top.style, BorderStyle::Solid);
assert_eq!(sides.bottom.style, BorderStyle::Solid);
assert_eq!(sides.left.style, BorderStyle::Solid);
assert_eq!(sides.right.style, BorderStyle::Solid);
}
#[test]
fn border_longhand_overrides_universal_shorthand() {
let mut decls = IndexMap::new();
decls.insert("border".into(), "1ch solid red".into());
decls.insert("border-top".into(), "3ch solid blue".into());
let mut attr = CssAttr::new();
attr.add(&CssAttr::from(decls));
let sides = attr.get_border(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport(),
);
assert_eq!(sides.top.width, 3);
assert_eq!(
sides.top.color,
Some(Color::Rgb {
r: 0x00,
g: 0x00,
b: 0xFF
})
);
assert_eq!(sides.right.width, 1);
assert_eq!(
sides.right.color,
Some(Color::Rgb {
r: 0xFF,
g: 0x00,
b: 0x00
})
);
}
#[test]
fn border_universal_width_style_color_layers_correctly() {
let mut decls = IndexMap::new();
decls.insert("border-width".into(), "2ch".into());
decls.insert("border-style".into(), "solid".into());
decls.insert("border-color".into(), "green".into());
let mut attr = CssAttr::new();
attr.add(&CssAttr::from(decls));
let sides = attr.get_border(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport(),
);
assert_eq!(sides.top.width, 2);
assert_eq!(sides.top.style, BorderStyle::Solid);
assert_eq!(
sides.top.color,
Some(Color::Rgb {
r: 0x00,
g: 0x80,
b: 0x00
})
);
}
#[test]
fn border_style_none_hides_the_border() {
let attr = attr_from_decl("border-style", "none");
let sides = attr.get_border(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport(),
);
assert!(!sides.any_visible());
}
#[test]
fn border_unknown_style_falls_back_to_solid() {
for keyword in ["dashed", "dotted", "double", "groove"] {
let attr = attr_from_decl("border", &format!("1ch {keyword} red"));
let sides = attr.get_border(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport(),
);
assert_eq!(
sides.top.style,
BorderStyle::Solid,
"keyword {keyword} should fall back to solid"
);
}
}
#[test]
fn get_box_edges_returns_combined_padding_border_margin() {
let mut decls = IndexMap::new();
decls.insert("padding".into(), "2ch".into());
decls.insert("margin".into(), "4ch".into());
decls.insert("border".into(), "1ch solid red".into());
let mut attr = CssAttr::new();
attr.add(&CssAttr::from(decls));
let edges = attr.get_box_edges(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport(),
);
assert_eq!(edges.padding.top, 2);
assert_eq!(edges.margin.left, 4);
assert_eq!(edges.border.top.width, 1);
assert_eq!(edges.border.top.style, BorderStyle::Solid);
assert_eq!(edges.inner_horizontal(), 6); }
#[test]
fn font_size_parses_to_length() {
init();
let attr = attr_from_decl("font-size", "2em");
assert_eq!(attr.get_font_size(), Some(Length::Em(2.0)));
let attr = attr_from_decl("font-size", "1.5rem");
assert_eq!(attr.get_font_size(), Some(Length::Rem(1.5)));
let attr = attr_from_decl("font-size", "16px");
assert_eq!(attr.get_font_size(), Some(Length::Px(16.0)));
let attr = attr_from_decl("font-size", "120%");
assert_eq!(attr.get_font_size(), Some(Length::Pct(120.0)));
assert_eq!(CssAttr::new().get_font_size(), None);
}
#[test]
fn font_size_property_is_supported_and_inheritable() {
init();
assert!(
SUPPORTED_PROPERTIES.contains(&"font-size"),
"font-size should be a supported CSS property"
);
assert!(
INHERITED_PROPS.contains(&"font-size"),
"font-size should inherit from the parent so nested spans keep the same em base"
);
}
#[test]
fn padding_em_scales_with_element_font_size() {
init();
let attr = attr_from_decl("padding", "1em");
assert_eq!(attr.get_padding(80, font_ctx(2, 1), viewport()).top, 2);
assert_eq!(attr.get_padding(80, font_ctx(3, 1), viewport()).top, 3);
assert_eq!(attr.get_padding(80, font_ctx(1, 1), viewport()).top, 1);
}
#[test]
fn margin_rem_uses_root_font_size_not_element() {
init();
let attr = attr_from_decl("margin", "2rem");
assert_eq!(attr.get_margin(80, font_ctx(1, 3), viewport()).top, 6);
assert_eq!(attr.get_margin(80, font_ctx(5, 3), viewport()).top, 6);
assert_eq!(attr.get_margin(80, font_ctx(1, 1), viewport()).top, 2);
}
#[test]
fn text_transform_parses_and_defaults_to_none() {
init();
let attr = attr_from_decl("text-transform", "uppercase");
assert_eq!(attr.get_text_transform(), TextTransform::Uppercase);
let attr = attr_from_decl("text-transform", "LOWERCASE");
assert_eq!(attr.get_text_transform(), TextTransform::Lowercase);
let attr = attr_from_decl("text-transform", "capitalize");
assert_eq!(attr.get_text_transform(), TextTransform::Capitalize);
assert_eq!(CssAttr::new().get_text_transform(), TextTransform::None);
let attr = attr_from_decl("text-transform", "full-width");
assert_eq!(attr.get_text_transform(), TextTransform::None);
}
#[test]
fn text_transform_is_supported_and_inheritable() {
init();
assert!(SUPPORTED_PROPERTIES.contains(&"text-transform"));
assert!(INHERITED_PROPS.contains(&"text-transform"));
}
#[test]
fn line_height_parses_normal_number_and_length() {
init();
let attr = attr_from_decl("line-height", "normal");
assert_eq!(attr.get_line_height(), LineHeight::Normal);
let attr = attr_from_decl("line-height", "1.5");
assert_eq!(attr.get_line_height(), LineHeight::Number(1.5));
let attr = attr_from_decl("line-height", "2em");
assert_eq!(attr.get_line_height(), LineHeight::Length(Length::Em(2.0)));
let attr = attr_from_decl("line-height", "150%");
assert_eq!(
attr.get_line_height(),
LineHeight::Length(Length::Pct(150.0))
);
assert_eq!(CssAttr::new().get_line_height(), LineHeight::Normal);
}
#[test]
fn line_height_resolves_to_whole_rows() {
init();
assert_eq!(
resolve_line_height_cells(LineHeight::Normal, 1, default_font_ctx(), viewport()),
1
);
assert_eq!(
resolve_line_height_cells(LineHeight::Number(1.5), 1, default_font_ctx(), viewport()),
2
);
assert_eq!(
resolve_line_height_cells(LineHeight::Number(2.0), 2, default_font_ctx(), viewport()),
4
);
assert_eq!(
resolve_line_height_cells(
LineHeight::Length(Length::Em(1.0)),
3,
font_ctx(3, 1),
viewport()
),
3
);
assert_eq!(
resolve_line_height_cells(
LineHeight::Length(Length::Pct(150.0)),
2,
font_ctx(2, 1),
viewport()
),
3
);
}
#[test]
fn line_height_is_supported_and_inheritable() {
init();
assert!(SUPPORTED_PROPERTIES.contains(&"line-height"));
assert!(INHERITED_PROPS.contains(&"line-height"));
}
#[test]
fn box_sizing_defaults_to_border_box_and_parses() {
init();
assert_eq!(CssAttr::new().get_box_sizing(), BoxSizing::BorderBox);
let attr = attr_from_decl("box-sizing", "content-box");
assert_eq!(attr.get_box_sizing(), BoxSizing::ContentBox);
let attr = attr_from_decl("box-sizing", "BORDER-BOX");
assert_eq!(attr.get_box_sizing(), BoxSizing::BorderBox);
let attr = attr_from_decl("box-sizing", "padding-box");
assert_eq!(attr.get_box_sizing(), BoxSizing::BorderBox);
}
#[test]
fn box_sizing_is_supported() {
init();
assert!(SUPPORTED_PROPERTIES.contains(&"box-sizing"));
}
#[test]
fn border_radius_parses_one_to_four_values() {
init();
let attr = attr_from_decl("border-radius", "2ch");
let r = attr.get_border_radius(80, default_font_ctx(), viewport());
assert_eq!(
r,
CornerRadii {
top_left: 2,
top_right: 2,
bottom_right: 2,
bottom_left: 2
}
);
let attr = attr_from_decl("border-radius", "4ch 1ch");
let r = attr.get_border_radius(80, default_font_ctx(), viewport());
assert_eq!(
r,
CornerRadii {
top_left: 4,
top_right: 1,
bottom_right: 4,
bottom_left: 1
}
);
let attr = attr_from_decl("border-radius", "1ch 2ch 3ch 4ch");
let r = attr.get_border_radius(80, default_font_ctx(), viewport());
assert_eq!(
r,
CornerRadii {
top_left: 1,
top_right: 2,
bottom_right: 3,
bottom_left: 4
}
);
assert_eq!(
CssAttr::new().get_border_radius(80, default_font_ctx(), viewport()),
CornerRadii::default()
);
}
#[test]
fn border_radius_is_supported() {
init();
assert!(SUPPORTED_PROPERTIES.contains(&"border-radius"));
}
#[test]
fn border_radius_corner_longhands_override_shorthand() {
init();
let mut decls = IndexMap::new();
decls.insert("border-radius".into(), "2ch".into());
decls.insert("border-top-left-radius".into(), "5ch".into());
let mut attr = CssAttr::new();
attr.add(&CssAttr::from(decls));
assert_eq!(
attr.get_border_radius(80, default_font_ctx(), viewport()),
CornerRadii {
top_left: 5,
top_right: 2,
bottom_right: 2,
bottom_left: 2,
}
);
}
#[test]
fn border_radius_corner_longhand_alone() {
init();
let attr = attr_from_decl("border-top-right-radius", "3ch");
assert_eq!(
attr.get_border_radius(80, default_font_ctx(), viewport()),
CornerRadii {
top_left: 0,
top_right: 3,
bottom_right: 0,
bottom_left: 0,
}
);
}
#[test]
fn border_radius_corner_longhands_are_supported() {
init();
for p in [
"border-top-left-radius",
"border-top-right-radius",
"border-bottom-right-radius",
"border-bottom-left-radius",
] {
assert!(
SUPPORTED_PROPERTIES.contains(&p),
"{p} must be in SUPPORTED_PROPERTIES"
);
}
}
#[test]
fn border_side_component_longhand_overrides_side_shorthand() {
init();
let mut decls = IndexMap::new();
decls.insert("border".into(), "1ch solid red".into());
decls.insert("border-top".into(), "3ch solid blue".into());
decls.insert("border-top-width".into(), "5ch".into());
let mut attr = CssAttr::new();
attr.add(&CssAttr::from(decls));
let sides = attr.get_border(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport(),
);
assert_eq!(sides.top.width, 5, "component longhand wins the width");
assert_eq!(sides.top.style, BorderStyle::Solid);
assert_eq!(
sides.top.color,
Some(Color::Rgb { r: 0, g: 0, b: 255 }),
"style/color come from the side shorthand"
);
assert_eq!(sides.right.width, 1);
}
#[test]
fn border_side_component_longhand_alone() {
init();
let attr = attr_from_decl("border-left-color", "green");
let sides = attr.get_border(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport(),
);
assert_eq!(sides.left.width, 0);
assert_eq!(sides.left.style, BorderStyle::None);
assert_eq!(sides.left.color, Some(Color::Rgb { r: 0, g: 128, b: 0 }));
assert_eq!(sides.top.color, None, "other sides unaffected");
}
#[test]
fn border_side_component_longhands_are_supported() {
init();
for side in ["top", "right", "bottom", "left"] {
for prop in ["width", "style", "color"] {
let name = format!("border-{side}-{prop}");
assert!(
SUPPORTED_PROPERTIES.contains(&name.as_str()),
"{name} must be in SUPPORTED_PROPERTIES"
);
}
}
}
#[test]
fn margin_auto_flags_parse_from_shorthand_and_longhand() {
init();
let attr = attr_from_decl("margin", "0 auto");
let auto = attr.get_margin_auto();
assert!(!auto.top && !auto.bottom, "vertical auto stays false");
assert!(auto.left && auto.right, "horizontal edges are auto");
let attr = attr_from_decl("margin", "1px auto 2px 3px");
let auto = attr.get_margin_auto();
assert!(!auto.top && auto.right && !auto.bottom && !auto.left);
let attr = attr_from_decl("margin", "auto");
let auto = attr.get_margin_auto();
assert!(auto.top && auto.right && auto.bottom && auto.left);
let mut decls = IndexMap::new();
decls.insert("margin".into(), "auto".into());
decls.insert("margin-right".into(), "5px".into());
let mut attr = CssAttr::new();
attr.add(&CssAttr::from(decls));
let auto = attr.get_margin_auto();
assert!(auto.left, "shorthand left stays auto");
assert!(!auto.right, "longhand clears the right auto");
assert_eq!(CssAttr::new().get_margin_auto(), EdgeAuto::default());
}
#[test]
fn percentage_height_resolves_against_definite_containing_height_only() {
init();
let attr = attr_from_decl("height", "50%");
assert_eq!(
attr.get_height(Some(100), font_ctx(1, 1), viewport()),
Some(50),
"definite containing height: 50% of 100"
);
assert_eq!(
attr.get_height(None, font_ctx(1, 1), viewport()),
None,
"indefinite containing height: percentage height is auto"
);
let mh = attr_from_decl("min-height", "25%");
assert_eq!(
mh.get_min_height(Some(200), font_ctx(1, 1), viewport()),
Some(50)
);
assert_eq!(mh.get_min_height(None, font_ctx(1, 1), viewport()), None);
}
#[test]
fn min_max_size_getters_resolve_and_default_to_none() {
init();
let mut decls = IndexMap::new();
decls.insert("min-width".into(), "10ch".into());
decls.insert("max-width".into(), "40ch".into());
decls.insert("min-height".into(), "3ch".into());
decls.insert("max-height".into(), "none".into());
let mut attr = CssAttr::new();
attr.add(&CssAttr::from(decls));
let f = default_font_ctx();
assert_eq!(attr.get_min_width(80, f, viewport()), Some(10));
assert_eq!(attr.get_max_width(80, f, viewport()), Some(40));
assert_eq!(attr.get_min_height(Some(80), f, viewport()), Some(3));
assert_eq!(attr.get_max_height(Some(80), f, viewport()), None);
let attr = attr_from_decl("min-width", "auto");
assert_eq!(attr.get_min_width(80, f, viewport()), None);
assert_eq!(CssAttr::new().get_min_width(80, f, viewport()), None);
assert_eq!(CssAttr::new().get_max_width(80, f, viewport()), None);
assert_eq!(CssAttr::new().get_min_height(Some(80), f, viewport()), None);
assert_eq!(CssAttr::new().get_max_height(Some(80), f, viewport()), None);
}
#[test]
fn min_max_size_properties_are_supported_not_inherited() {
init();
assert!(SUPPORTED_PROPERTIES.contains(&"min-width"));
assert!(SUPPORTED_PROPERTIES.contains(&"max-width"));
assert!(SUPPORTED_PROPERTIES.contains(&"min-height"));
assert!(SUPPORTED_PROPERTIES.contains(&"max-height"));
assert!(!INHERITED_PROPS.contains(&"min-width"));
assert!(!INHERITED_PROPS.contains(&"max-height"));
}
fn ref_apply_rule(
arena: &mut NodeArena,
node_id: NodeId,
selector: &CssSelector,
decls: &CssAttr,
state: &DynamicState,
) {
if selector_chain_matches_with_state(arena, node_id, selector, state) {
arena[node_id].css_attr.add(decls);
}
let children: Vec<NodeId> = arena[node_id].children.clone();
for child in children {
ref_apply_rule(arena, child, selector, decls, state);
}
}
fn ref_inherit(arena: &mut NodeArena, node_id: NodeId, parent: &IndexMap<Arc<str>, Arc<str>>) {
{
let node = &mut arena[node_id];
for key in INHERITED_PROPS {
if let Some(value) = parent.get(*key) {
node.css_attr
.0
.entry(Arc::from(*key))
.or_insert_with(|| value.clone());
}
}
}
let node = &arena[node_id];
let mut merged = parent.clone();
for key in INHERITED_PROPS {
if let Some(value) = node.css_attr.0.get(*key) {
merged.insert(Arc::from(*key), value.clone());
}
}
let children: Vec<NodeId> = node.children.clone();
for child in children {
ref_inherit(arena, child, &merged);
}
}
fn reference_cascade(arena: &mut NodeArena, root: NodeId, css: &CssRule, state: &DynamicState) {
reset_css_attr(arena, root);
let mut rules: Vec<&CssRuleEntry> = css.0.iter().collect();
rules.sort_by_key(|entry| entry.selector.specificity());
for entry in &rules {
if !entry.normal.0.is_empty() {
ref_apply_rule(arena, root, &entry.selector, &entry.normal, state);
}
}
for entry in &rules {
if !entry.important.0.is_empty() {
ref_apply_rule(arena, root, &entry.selector, &entry.important, state);
}
}
ref_inherit(arena, root, &IndexMap::new());
}
fn assert_matches_reference(arena: &NodeArena, root: NodeId, css: &str, state: &DynamicState) {
let mut reference = arena.clone();
let mut parser = Parser::new(css);
let rules = parser.parse();
reference_cascade(&mut reference, root, &rules, state);
let mut stack = vec![root];
while let Some(id) = stack.pop() {
assert_eq!(
arena[id].css_attr.0,
reference[id].css_attr.0,
"css_attr mismatch at node {:?} (class {:?})",
arena[id].node_type,
arena[id].attributes.get("class")
);
stack.extend(arena[id].children.iter().copied());
}
}
fn build_cascade_tree(arena: &mut NodeArena) -> (NodeId, NodeId, NodeId, NodeId, NodeId) {
let root = arena.insert(Node::from(NodeType::Document));
let div = arena.insert(Node::from(NodeType::Div));
arena[div].attributes = IndexMap::from([
("class".to_string(), "menu".to_string()),
("id".to_string(), "main".to_string()),
]);
let p = arena.insert(Node::from(NodeType::P));
arena[p]
.attributes
.insert("id".to_string(), "lead".to_string());
let ul = arena.insert(Node::from(NodeType::Ul));
let li1 = arena.insert(Node::from(NodeType::Li));
let li2 = arena.insert(Node::from(NodeType::Li));
let li3 = arena.insert(Node::from(NodeType::Li));
let a = arena.insert(Node::from(NodeType::A));
arena[a]
.attributes
.insert("class".to_string(), "nav".to_string());
let em = arena.insert(Node::from(NodeType::Em));
for (parent, children) in [
(root, vec![div]),
(div, vec![p, ul, a, em]),
(ul, vec![li1, li2, li3]),
] {
for child in children {
Node::append_child(&mut *arena, parent, child);
}
}
(root, div, ul, li2, em)
}
const CASCADE_SHEET: &str = "
div { padding-top: 1px; }
div { color: purple; }
.menu { color: red; }
#lead { font-weight: bold; }
* { line-height: 2px; }
ul li { text-align: center; }
div > a { text-decoration: underline; }
li + li { color: green; }
p ~ em { font-style: italic; }
p:nth-child(odd) { font-style: italic; }
li:hover { background-color: blue; }
li:nth-child(even) { text-transform: uppercase; }
a { color: yellow; }
a { color: orange; }
em { color: black !important; }
";
#[test]
fn bucketed_cascade_matches_reference_cascade() {
init();
let mut arena = NodeArena::default();
let (root, div, _ul, li2, em) = build_cascade_tree(&mut arena);
css_into_node_with_state(
&mut arena,
root,
&parse_sheet(CASCADE_SHEET),
&DynamicState::default(),
);
assert_matches_reference(&arena, root, CASCADE_SHEET, &DynamicState::default());
assert_eq!(
arena[div].css_attr.0.get("color").map(|s| s.as_ref()),
Some("red")
);
assert_eq!(
arena[li2].css_attr.0.get("color").map(|s| s.as_ref()),
Some("green")
);
assert_eq!(
arena[em].css_attr.0.get("color").map(|s| s.as_ref()),
Some("black")
);
assert_eq!(
arena[em].css_attr.0.get("font-style").map(|s| s.as_ref()),
Some("italic")
);
assert_eq!(
arena[div].css_attr.0.get("padding-top").map(|s| s.as_ref()),
Some("1px")
);
let hovered = DynamicState {
hovered: Some(li2),
..Default::default()
};
let mut arena = NodeArena::default();
let (root, _div, _ul, _li2, _em) = build_cascade_tree(&mut arena);
css_into_node_with_state(&mut arena, root, &parse_sheet(CASCADE_SHEET), &hovered);
assert_matches_reference(&arena, root, CASCADE_SHEET, &hovered);
}
fn parse_sheet(css: &str) -> CssRule {
let mut parser = Parser::new(css);
parser.parse()
}
#[test]
fn cascade_index_is_rebuilt_after_push() {
init();
let mut arena = NodeArena::default();
let (root, div) = build_test(&mut arena);
let rules = parse_sheet("div { color: red; }");
let mut cascaded = arena.clone();
css_into_node_with_state(&mut cascaded, root, &rules, &DynamicState::default());
assert_eq!(
cascaded[div].css_attr.0.get("color").map(|s| s.as_ref()),
Some("red")
);
let mut rules = rules;
let selector: CssSelector = "div".parse().unwrap();
let mut decls = IndexMap::new();
decls.insert("color".to_string(), "blue".to_string());
rules.push(selector, CssAttr::from(decls), CssAttr::new());
let mut recascaded = arena.clone();
css_into_node_with_state(&mut recascaded, root, &rules, &DynamicState::default());
assert_eq!(
recascaded[div].css_attr.0.get("color").map(|s| s.as_ref()),
Some("blue")
);
}
#[test]
fn cascade_inline_style_precedence_unchanged() {
init();
let mut arena = NodeArena::default();
let root = arena.insert(Node::from(NodeType::Document));
let div = arena.insert(Node::from(NodeType::Div));
arena[div].attributes = IndexMap::from([(
"style".to_string(),
"color: green; background-color: white !important".to_string(),
)]);
Node::append_child(&mut arena, root, div);
let rules = parse_sheet("div { color: red !important; }");
css_into_node_with_state(&mut arena, root, &rules, &DynamicState::default());
assert_eq!(
arena[div].css_attr.0.get("color").map(|s| s.as_ref()),
Some("red")
);
assert_eq!(
arena[div]
.css_attr
.0
.get("background-color")
.map(|s| s.as_ref()),
Some("white")
);
let mut arena = NodeArena::default();
let root = arena.insert(Node::from(NodeType::Document));
let div = arena.insert(Node::from(NodeType::Div));
arena[div]
.attributes
.insert("style".to_string(), "color: green".to_string());
Node::append_child(&mut arena, root, div);
let rules = parse_sheet("div { color: red; }");
css_into_node_with_state(&mut arena, root, &rules, &DynamicState::default());
assert_eq!(
arena[div].css_attr.0.get("color").map(|s| s.as_ref()),
Some("green")
);
}
#[test]
fn cascade_inheritance_values_survive_copy_on_write() {
init();
let mut arena = NodeArena::default();
let root = arena.insert(Node::from(NodeType::Document));
let div = arena.insert(Node::from(NodeType::Div));
let p = arena.insert(Node::from(NodeType::P));
let span = arena.insert(Node::from(NodeType::Span));
let em = arena.insert(Node::from(NodeType::Em));
for (parent, child) in [(root, div), (div, p), (p, span), (span, em)] {
Node::append_child(&mut arena, parent, child);
}
let rules = parse_sheet("div { color: red; } p { color: red; } span { color: blue; }");
css_into_node_with_state(&mut arena, root, &rules, &DynamicState::default());
assert_eq!(
arena[div].css_attr.0.get("color").map(|s| s.as_ref()),
Some("red")
);
assert_eq!(
arena[p].css_attr.0.get("color").map(|s| s.as_ref()),
Some("red")
);
assert_eq!(
arena[span].css_attr.0.get("color").map(|s| s.as_ref()),
Some("blue")
);
assert_eq!(
arena[em].css_attr.0.get("color").map(|s| s.as_ref()),
Some("blue")
);
}
#[test]
fn declaration_property_names_are_case_insensitive() {
init();
let m =
parse_declaration_text("WIDTH: 10px; BACKGROUND-COLOR: red; Text-Transform: uppercase;");
assert_eq!(m.get("width").map(|s| s.as_ref()), Some("10px"));
assert_eq!(m.get("background-color").map(|s| s.as_ref()), Some("red"));
assert_eq!(
m.get("text-transform").map(|s| s.as_ref()),
Some("uppercase")
);
}
#[test]
fn text_align_keywords_are_case_insensitive_and_support_start_end() {
init();
assert_eq!(
attr_from_decl("text-align", "CENTER").get_text_align(),
TextAlign::Center
);
assert_eq!(
attr_from_decl("text-align", "Right").get_text_align(),
TextAlign::Right
);
assert_eq!(
attr_from_decl("text-align", "end").get_text_align(),
TextAlign::Right
);
assert_eq!(
attr_from_decl("text-align", "start").get_text_align(),
TextAlign::Left
);
assert_eq!(
attr_from_decl("text-align", "justify").get_text_align(),
TextAlign::Left
);
}
#[test]
fn font_weight_is_case_insensitive_and_600_is_bold() {
init();
assert_eq!(
attr_from_decl("font-weight", "BOLD").get_font_weight(),
FontWeight::Bold
);
assert_eq!(
attr_from_decl("font-weight", "bolder").get_font_weight(),
FontWeight::Bold
);
assert_eq!(
attr_from_decl("font-weight", "600").get_font_weight(),
FontWeight::Bold
);
assert_eq!(
attr_from_decl("font-weight", "500").get_font_weight(),
FontWeight::Normal
);
}
#[test]
fn font_style_is_case_insensitive() {
init();
assert_eq!(
attr_from_decl("font-style", "Italic").get_font_style_kind(),
FontStyleKind::Italic
);
assert_eq!(
attr_from_decl("font-style", "OBLIQUE").get_font_style_kind(),
FontStyleKind::Oblique
);
assert_eq!(
attr_from_decl("font-style", "normal").get_font_style_kind(),
FontStyleKind::Normal
);
}
#[test]
fn negative_margin_resolves_signed_horizontally() {
init();
let attr = attr_from_decl("margin", "-4ch");
let m = attr.get_margin(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport(),
);
assert_eq!(m, MarginSizes::new(0, -4, 0, -4));
let attr = attr_from_decl("margin-left", "-6ch");
let m = attr.get_margin(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport(),
);
assert_eq!(m, MarginSizes::new(0, 0, 0, -6));
}
#[test]
fn negative_inset_resolves_signed() {
init();
let mut attr = attr_from_decl("top", "-2ch");
attr.add(&attr_from_decl("left", "-10ch"));
let pos = attr.get_positioning(
80,
font_ctx(DEFAULT_FONT_SIZE, DEFAULT_ROOT_FONT_SIZE),
viewport(),
);
assert_eq!(pos.inset.top, Some(-2));
assert_eq!(pos.inset.left, Some(-10));
}
#[test]
fn font_size_percentage_resolves_against_parent() {
init();
let mut arena = NodeArena::default();
let root = arena.insert(Node::from(NodeType::Document));
let div_id = arena.insert(Node::from(NodeType::Div));
Node::append_child(&mut arena, root, div_id);
let rules = parse_sheet("div { font-size: 200%; }");
css_into_node(&mut arena, root, &rules);
let ctx = FontContext {
font_size_cells: 2,
root_font_size_cells: 1,
};
assert_eq!(
effective_font_size_cells(&arena, div_id, ctx, viewport()),
4
);
}
#[test]
fn white_space_inherits_to_inline_children() {
init();
let mut arena = NodeArena::default();
let root = arena.insert(Node::from(NodeType::Document));
let div_id = arena.insert(Node::from(NodeType::Div));
let span_id = arena.insert(Node::from(NodeType::Span));
Node::append_child(&mut arena, root, div_id);
Node::append_child(&mut arena, div_id, span_id);
let rules = parse_sheet("div { white-space: pre-wrap; }");
css_into_node(&mut arena, root, &rules);
assert_eq!(
arena[span_id]
.css_attr
.get_white_space(&arena[span_id].node_type),
WhiteSpace::PreWrap
);
}
#[test]
fn stale_node_id_fails_to_match_instead_of_panicking() {
let mut p = html_parse::Parser::new(r#"<div class="btn"><span class="btn">x</span></div>"#);
let (root_id, mut arena, _) = p.parse();
let mut cp = Parser::new(".btn:hover { color: red }");
css_into_node(&mut arena, root_id, &cp.parse());
let mut cp2 = Parser::new(".btn:hover { color: red }");
let rules = cp2.parse();
let sel = &rules.0[0].selector;
let span = arena
.iter()
.find(|(_, n)| n.node_type == NodeType::Span)
.map(|(id, _)| id)
.expect("span present");
let stale = span;
arena.remove(stale).expect("span removed");
assert!(
arena.get(stale).is_none(),
"the removed key must be stale for this arena"
);
assert!(!selector_chain_matches_with_state(
&arena,
stale,
sel,
&DynamicState::default(),
));
let real = arena
.iter()
.find(|(_, n)| n.node_type == NodeType::Div)
.map(|(id, _)| id)
.expect("div present");
assert!(!selector_chain_matches_with_state(
&arena,
real,
sel,
&DynamicState {
hovered: Some(stale),
..Default::default()
},
));
}
fn children_of(n: usize) -> (NodeArena, NodeId, Vec<NodeId>) {
let mut arena = NodeArena::default();
let parent = arena.insert(Node::from(NodeType::Div));
let children: Vec<_> = (0..n)
.map(|_| {
let id = arena.insert(Node::from(NodeType::P));
Node::append_child(&mut arena, parent, id);
id
})
.collect();
(arena, parent, children)
}
#[test]
fn test_first_last_only_child_pseudo_classes() {
init();
let (arena, _parent, kids) = children_of(3);
let first: CssSelector = "p:first-child".parse().unwrap();
assert!(selector_chain_matches(&arena, kids[0], &first));
assert!(!selector_chain_matches(&arena, kids[1], &first));
assert!(!selector_chain_matches(&arena, kids[2], &first));
let last: CssSelector = "p:last-child".parse().unwrap();
assert!(!selector_chain_matches(&arena, kids[0], &last));
assert!(!selector_chain_matches(&arena, kids[1], &last));
assert!(selector_chain_matches(&arena, kids[2], &last));
let only: CssSelector = "p:only-child".parse().unwrap();
assert!(!selector_chain_matches(&arena, kids[0], &only));
let (arena1, _p1, kids1) = children_of(1);
assert!(selector_chain_matches(&arena1, kids1[0], &only));
let upper: CssSelector = "p:FIRST-CHILD".parse().unwrap();
assert!(selector_chain_matches(&arena, kids[0], &upper));
}
#[test]
fn test_nth_last_child_matches_from_end() {
init();
let (arena, _parent, kids) = children_of(4);
let sel: CssSelector = "p:nth-last-child(2)".parse().unwrap();
assert!(!selector_chain_matches(&arena, kids[0], &sel));
assert!(!selector_chain_matches(&arena, kids[1], &sel));
assert!(selector_chain_matches(&arena, kids[2], &sel));
assert!(!selector_chain_matches(&arena, kids[3], &sel));
let odd: CssSelector = "p:nth-last-child(odd)".parse().unwrap();
assert!(!selector_chain_matches(&arena, kids[0], &odd));
assert!(selector_chain_matches(&arena, kids[1], &odd));
assert!(!selector_chain_matches(&arena, kids[2], &odd));
assert!(selector_chain_matches(&arena, kids[3], &odd));
}
#[test]
fn test_root_and_empty_pseudo_classes() {
init();
let mut arena = NodeArena::default();
let doc = arena.insert(Node::from(NodeType::Document));
let html = arena.insert(Node::from(NodeType::Html));
Node::append_child(&mut arena, doc, html);
let body = arena.insert(Node::from(NodeType::Body));
Node::append_child(&mut arena, html, body);
let empty_div = arena.insert(Node::from(NodeType::Div));
Node::append_child(&mut arena, body, empty_div);
let root: CssSelector = ":root".parse().unwrap();
assert!(selector_chain_matches(&arena, html, &root));
assert!(!selector_chain_matches(&arena, doc, &root));
assert!(!selector_chain_matches(&arena, body, &root));
let empty: CssSelector = "div:empty".parse().unwrap();
assert!(selector_chain_matches(&arena, empty_div, &empty));
let child = arena.insert(Node::from(NodeType::Span));
Node::append_child(&mut arena, empty_div, child);
assert!(!selector_chain_matches(&arena, empty_div, &empty));
let mut arena2 = NodeArena::default();
let div = arena2.insert(Node::from(NodeType::Div));
let mut comment = Node::from(NodeType::Comment);
comment.data = Some("note".to_string());
let comment_id = arena2.insert(comment);
Node::append_child(&mut arena2, div, comment_id);
let empty_sel: CssSelector = ":empty".parse().unwrap();
assert!(selector_chain_matches(&arena2, div, &empty_sel));
let empty_sel: CssSelector = ":empty".parse().unwrap();
for data in ["hi", " "] {
let mut arena3 = NodeArena::default();
let div = arena3.insert(Node::from(NodeType::Div));
let text = arena3.insert(Node::text(data));
Node::append_child(&mut arena3, div, text);
assert!(
!selector_chain_matches(&arena3, div, &empty_sel),
"text child {data:?} must make :empty fail"
);
}
}
#[test]
fn test_not_pseudo_class_matches() {
init();
let mut arena = NodeArena::default();
let parent = arena.insert(Node::from(NodeType::Div));
let attach = |nt: NodeType, class: &str, arena: &mut NodeArena| {
let mut node = Node::from(nt);
if !class.is_empty() {
node.attributes
.insert("class".to_string(), class.to_string());
}
let id = arena.insert(node);
Node::append_child(arena, parent, id);
id
};
let p = attach(NodeType::P, "lead", &mut arena);
let span = attach(NodeType::Span, "", &mut arena);
let div = attach(NodeType::Div, "lead", &mut arena);
let sel: CssSelector = ":not(.lead)".parse().unwrap();
assert!(!selector_chain_matches(&arena, p, &sel));
assert!(selector_chain_matches(&arena, span, &sel));
assert!(!selector_chain_matches(&arena, div, &sel));
let list: CssSelector = ":not(p, div)".parse().unwrap();
assert!(!selector_chain_matches(&arena, p, &list));
assert!(!selector_chain_matches(&arena, div, &list));
assert!(selector_chain_matches(&arena, span, &list));
let chained: CssSelector = "div > :not(span)".parse().unwrap();
assert!(selector_chain_matches(&arena, p, &chained));
assert!(!selector_chain_matches(&arena, span, &chained));
let spaced: CssSelector = ":not( p , div )".parse().unwrap();
assert!(selector_chain_matches(&arena, span, &spaced));
let double: CssSelector = ":not(p):not(div)".parse().unwrap();
assert!(selector_chain_matches(&arena, span, &double));
assert!(!selector_chain_matches(&arena, p, &double));
}
#[test]
fn test_not_pseudo_class_parse_errors() {
init();
assert!(":not".parse::<CssSelector>().is_err());
assert!(":not(".parse::<CssSelector>().is_err());
assert!(":not()".parse::<CssSelector>().is_err());
assert!(":not(p div)".parse::<CssSelector>().is_err());
let deep = format!("{}{}", ":not(".repeat(32), ")".repeat(32));
assert!(deep.parse::<CssSelector>().is_err());
}
#[test]
fn test_form_state_pseudo_classes() {
init();
let mut arena = NodeArena::default();
let mut checkbox = Node::from(NodeType::Input);
checkbox
.attributes
.insert("type".to_string(), "checkbox".to_string());
checkbox
.attributes
.insert("checked".to_string(), String::new());
let checked = arena.insert(checkbox);
let mut unchecked = Node::from(NodeType::Input);
unchecked
.attributes
.insert("type".to_string(), "checkbox".to_string());
let unchecked = arena.insert(unchecked);
let mut text_input = Node::from(NodeType::Input);
text_input
.attributes
.insert("checked".to_string(), String::new());
let text_input = arena.insert(text_input);
let mut selected = Node::from(NodeType::HtmlOption);
selected
.attributes
.insert("selected".to_string(), String::new());
let selected = arena.insert(selected);
let checked_sel: CssSelector = ":checked".parse().unwrap();
assert!(selector_chain_matches(&arena, checked, &checked_sel));
assert!(!selector_chain_matches(&arena, unchecked, &checked_sel));
assert!(!selector_chain_matches(&arena, text_input, &checked_sel));
assert!(selector_chain_matches(&arena, selected, &checked_sel));
let mut disabled_btn = Node::from(NodeType::Button);
disabled_btn
.attributes
.insert("disabled".to_string(), String::new());
let disabled_btn = arena.insert(disabled_btn);
let enabled_btn = arena.insert(Node::from(NodeType::Button));
let disabled_sel: CssSelector = ":disabled".parse().unwrap();
let enabled_sel: CssSelector = ":enabled".parse().unwrap();
assert!(selector_chain_matches(&arena, disabled_btn, &disabled_sel));
assert!(!selector_chain_matches(&arena, disabled_btn, &enabled_sel));
assert!(selector_chain_matches(&arena, enabled_btn, &enabled_sel));
let mut fieldset = Node::from(NodeType::Fieldset);
fieldset
.attributes
.insert("disabled".to_string(), String::new());
let fieldset = arena.insert(fieldset);
let inner = arena.insert(Node::from(NodeType::Input));
Node::append_child(&mut arena, fieldset, inner);
assert!(selector_chain_matches(&arena, inner, &disabled_sel));
assert!(!selector_chain_matches(&arena, inner, &enabled_sel));
let plain = arena.insert(Node::from(NodeType::Div));
assert!(!selector_chain_matches(&arena, plain, &enabled_sel));
assert!(!selector_chain_matches(&arena, plain, &disabled_sel));
}
#[test]
fn test_selector_paren_args_allow_spaces() {
init();
let sel: CssSelector = ":nth-child(2n + 1)".parse().unwrap();
let (arena, _parent, kids) = children_of(3);
assert!(selector_chain_matches(&arena, kids[0], &sel));
assert!(!selector_chain_matches(&arena, kids[1], &sel));
assert!(selector_chain_matches(&arena, kids[2], &sel));
}
#[test]
fn test_not_specificity_takes_most_specific_argument() {
init();
let class_sel: CssSelector = "p.leading".parse().unwrap();
let not_sel: CssSelector = "p:not(#a)".parse().unwrap();
assert_eq!(class_sel.specificity(), (0, 1, 1));
assert_eq!(not_sel.specificity(), (1, 0, 1));
}
fn mixed_children() -> (NodeArena, NodeId, Vec<NodeId>, Vec<NodeId>) {
let mut arena = NodeArena::default();
let parent = arena.insert(Node::from(NodeType::Div));
let text = arena.insert(Node::text("t"));
Node::append_child(&mut arena, parent, text);
let mut ps = Vec::new();
let mut spans = Vec::new();
for (i, nt) in [
NodeType::P,
NodeType::Span,
NodeType::P,
NodeType::P,
NodeType::Span,
]
.into_iter()
.enumerate()
{
if i == 3 {
let mut comment = Node::from(NodeType::Comment);
comment.data = Some("c".to_string());
let comment_id = arena.insert(comment);
Node::append_child(&mut arena, parent, comment_id);
}
let id = arena.insert(Node::from(nt));
Node::append_child(&mut arena, parent, id);
if nt == NodeType::P {
ps.push(id);
} else {
spans.push(id);
}
}
(arena, parent, ps, spans)
}
#[test]
fn test_nth_of_type_counts_same_type_only() {
init();
let (arena, _parent, ps, spans) = mixed_children();
let sel: CssSelector = "p:nth-of-type(2)".parse().unwrap();
assert!(!selector_chain_matches(&arena, ps[0], &sel));
assert!(selector_chain_matches(&arena, ps[1], &sel));
assert!(!selector_chain_matches(&arena, ps[2], &sel));
let even: CssSelector = "span:nth-of-type(even)".parse().unwrap();
assert!(!selector_chain_matches(&arena, spans[0], &even));
assert!(selector_chain_matches(&arena, spans[1], &even));
let last: CssSelector = "p:nth-last-of-type(1)".parse().unwrap();
assert!(!selector_chain_matches(&arena, ps[0], &last));
assert!(!selector_chain_matches(&arena, ps[1], &last));
assert!(selector_chain_matches(&arena, ps[2], &last));
let second_last: CssSelector = "p:nth-last-of-type(2)".parse().unwrap();
assert!(selector_chain_matches(&arena, ps[1], &second_last));
}
#[test]
fn test_first_last_only_of_type() {
init();
let (arena, _parent, ps, spans) = mixed_children();
let first_p: CssSelector = "p:first-of-type".parse().unwrap();
assert!(selector_chain_matches(&arena, ps[0], &first_p));
assert!(!selector_chain_matches(&arena, ps[1], &first_p));
let last_span: CssSelector = "span:last-of-type".parse().unwrap();
assert!(!selector_chain_matches(&arena, spans[0], &last_span));
assert!(selector_chain_matches(&arena, spans[1], &last_span));
let only: CssSelector = ":only-of-type".parse().unwrap();
for id in ps.iter().chain(spans.iter()) {
assert!(!selector_chain_matches(&arena, *id, &only));
}
let mut arena2 = NodeArena::default();
let parent = arena2.insert(Node::from(NodeType::Div));
let b = arena2.insert(Node::from(NodeType::B));
Node::append_child(&mut arena2, parent, b);
let text = arena2.insert(Node::text("x"));
Node::append_child(&mut arena2, parent, text);
assert!(selector_chain_matches(&arena2, b, &only));
let upper: CssSelector = "p:FIRST-OF-TYPE".parse().unwrap();
assert!(selector_chain_matches(&arena, ps[0], &upper));
}
#[test]
fn test_nth_of_type_parse_errors() {
init();
assert!("p:nth-of-type".parse::<CssSelector>().is_err());
assert!("p:nth-of-type(".parse::<CssSelector>().is_err());
assert!("p:nth-of-type(0)".parse::<CssSelector>().is_err());
assert!("p:nth-last-of-type(n)".parse::<CssSelector>().is_err());
assert!("p:nth-of-type(2n + 1)".parse::<CssSelector>().is_ok());
}
fn cascaded_node(html: &str, tag: &str) -> CssAttr {
let mut parser = html_parse::Parser::new(html);
let (root_id, mut arena, _) = parser.parse();
let rules = Parser::new("p {}").parse();
css_into_node(&mut arena, root_id, &rules);
let id = arena
.iter()
.find(|(_, n)| n.node_type.tag_name().is_some_and(|t| t == tag))
.map(|(id, _)| id)
.expect("tag present");
arena[id].css_attr.clone()
}
#[test]
fn test_list_style_type_and_shorthand() {
init();
use htui::html::css_parse::ListStyleType as L;
let attr = cascaded_node(
"<ul style=\"list-style-type: square\"><li>x</li></ul>",
"li",
);
assert_eq!(attr.get_list_style_type(L::Disc), L::Square);
let attr = cascaded_node(
"<ul style=\"list-style: lower-alpha\"><li>x</li></ul>",
"li",
);
assert_eq!(attr.get_list_style_type(L::Disc), L::LowerAlpha);
let attr = cascaded_node("<ol><li>x</li></ol>", "li");
assert_eq!(attr.get_list_style_type(L::Disc), L::Disc);
assert_eq!(attr.get_list_style_type(L::Decimal), L::Decimal);
assert_eq!(L::Circle.marker(1), "○ ");
assert_eq!(L::LowerRoman.marker(4), "iv ");
assert_eq!(L::UpperRoman.marker(9), "IX ");
assert_eq!(L::UpperAlpha.marker(27), "AA. ");
assert_eq!(L::None.marker(3), "");
}
#[test]
fn test_font_shorthand_expands() {
init();
let attr = cascaded_node(
"<p style=\"font: italic bold 2em/1.5 monospace\">x</p>",
"p",
);
assert_eq!(attr.get_font_weight(), FontWeight::Bold);
assert_eq!(attr.get_font_style_kind(), FontStyleKind::Italic);
assert!(
attr.0
.get("line-height")
.is_some_and(|v| v.as_ref() == "1.5"),
"line-height component expanded, got {:?}",
attr.0.get("line-height")
);
let attr = cascaded_node("<p style=\"font: 12px serif\">x</p>", "p");
assert!(
attr.0
.get("font-size")
.is_some_and(|v| v.as_ref() == "12px")
);
let attr = cascaded_node("<p style=\"font: 12px\">x</p>", "p");
assert!(attr.0.get("font-size").is_none());
}
#[test]
fn test_table_property_getters() {
init();
let attr = cascaded_node(
"<table style=\"table-layout: fixed; caption-side: bottom; empty-cells: hide\"><tr><td>a</td></tr></table>",
"table",
);
assert_eq!(
attr.get_table_layout(),
htui::html::css_parse::TableLayout::Fixed
);
assert_eq!(
attr.get_caption_side(),
htui::html::css_parse::CaptionSide::Bottom
);
assert_eq!(
attr.get_empty_cells(),
htui::html::css_parse::EmptyCells::Hide
);
let attr = cascaded_node("<table><tr><td>a</td></tr></table>", "table");
assert_eq!(
attr.get_table_layout(),
htui::html::css_parse::TableLayout::Auto
);
assert_eq!(
attr.get_caption_side(),
htui::html::css_parse::CaptionSide::Top
);
assert_eq!(
attr.get_empty_cells(),
htui::html::css_parse::EmptyCells::Show
);
}
#[test]
fn test_lang_pseudo_class() {
init();
let mut arena = NodeArena::default();
let doc = arena.insert(Node::from(NodeType::Document));
let html = {
let mut n = Node::from(NodeType::Html);
n.attributes.insert("lang".to_string(), "zh-CN".to_string());
arena.insert(n)
};
Node::append_child(&mut arena, doc, html);
let p = arena.insert(Node::from(NodeType::P));
Node::append_child(&mut arena, html, p);
let en_child = {
let mut n = Node::from(NodeType::Span);
n.attributes.insert("lang".to_string(), "EN".to_string());
arena.insert(n)
};
Node::append_child(&mut arena, html, en_child);
let zh: CssSelector = ":lang(zh)".parse().unwrap();
assert!(selector_chain_matches(&arena, html, &zh));
assert!(
selector_chain_matches(&arena, p, &zh),
"descendant inherits the ancestor lang"
);
let en: CssSelector = ":lang(en)".parse().unwrap();
assert!(
selector_chain_matches(&arena, en_child, &en),
"nearest lang wins (case-insensitive)"
);
assert!(!selector_chain_matches(&arena, p, &en));
let zh_cn: CssSelector = ":lang(zh-cn)".parse().unwrap();
assert!(!selector_chain_matches(&arena, p, &zh_cn));
assert!(":lang".parse::<CssSelector>().is_err());
assert!(":lang()".parse::<CssSelector>().is_err());
assert!(":lang(zh en)".parse::<CssSelector>().is_err());
}
#[test]
fn test_inherit_and_initial_keywords() {
init();
let attr = cascaded_node(
r#"<div style="color: red"><p style="color: inherit">x</p></div>"#,
"p",
);
assert_eq!(attr.0.get("color").map(|s| s.as_ref()), Some("red"));
let attr = cascaded_node(
r#"<div style="width: 20ch"><p style="width: inherit">x</p></div>"#,
"p",
);
assert_eq!(attr.0.get("width").map(|s| s.as_ref()), Some("20ch"));
let attr = cascaded_node(
r#"<div style="color: red"><p style="color: initial">x</p></div>"#,
"p",
);
assert_eq!(
attr.get_color(),
None,
"initial beats the inherited value (getter falls back to default)"
);
let attr = cascaded_node(r#"<p style="color: inherit">x</p>"#, "p");
assert_eq!(attr.get_color(), None);
let attr = cascaded_node(
r#"<div style="color: red"><p style="color: INHERIT">x</p></div>"#,
"p",
);
assert_eq!(attr.0.get("color").map(|s| s.as_ref()), Some("red"));
}
#[test]
fn test_math_functions_parse_and_resolve() {
init();
let ctx = length_ctx(80, font_ctx(1, 1));
let len = Length::parse("calc(100% - 4ch)").expect("calc parses");
assert_eq!(len.resolve(ctx), 76);
let len = Length::parse("calc(2 * 10px)").expect("calc with * parses");
assert_eq!(len.resolve(ctx), 3);
let len = Length::parse("calc(10ch / 2)").expect("calc with / parses");
assert_eq!(len.resolve(ctx), 5);
let len = Length::parse("calc(6ch)").expect("lone term parses");
assert_eq!(len.resolve(ctx), 6);
let len = Length::parse("min(50%, 30ch)").expect("min parses");
assert_eq!(len.resolve(ctx), 30, "30ch < 50% of 80");
let len = Length::parse("max(50%, 30ch)").expect("max parses");
assert_eq!(len.resolve(ctx), 40, "50% of 80 > 30ch");
let len = Length::parse("clamp(10ch, 50%, 30ch)").expect("clamp parses");
assert_eq!(len.resolve(ctx), 30, "50% of 80 clamps down to 30ch");
let len = Length::parse("clamp(10ch, 2ch, 30ch)").expect("clamp parses");
assert_eq!(len.resolve(ctx), 10, "2ch clamps up to 10ch");
let len = Length::parse_signed("calc(2ch - 10ch)").expect("signed calc parses");
assert_eq!(len.resolve_signed(ctx), -8);
assert!(Length::parse("calc(4ch").is_none(), "missing )");
assert!(Length::parse("calc()").is_none(), "empty body");
assert!(Length::parse("min(1ch)").is_none(), "min needs 2 terms");
assert!(Length::parse("clamp(1ch, 2ch)").is_none(), "clamp needs 3");
assert!(
Length::parse("calc(10pt + 2ch)").is_none(),
"unsupported unit"
);
}
#[test]
fn test_math_functions_flow_through_properties() {
init();
let attr = cascaded_node(
r#"<div style="width: 80ch"><p style="width: calc(100% - 4ch)">x</p></div>"#,
"p",
);
let len = attr
.0
.get("width")
.and_then(|raw| Length::parse(raw))
.expect("calc width survives the cascade");
assert_eq!(len.resolve(length_ctx(80, font_ctx(1, 1))), 76);
}
#[test]
fn test_pseudo_element_selector_parses() {
init();
let sel: CssSelector = "p.note::before".parse().expect("::before parses");
assert_eq!(
sel.pseudo_element,
Some(htui::html::css_parse::PseudoElement::Before)
);
assert_eq!(sel.compounds.len(), 1);
let sel: CssSelector = "div > a:after".parse().expect("legacy :after parses");
assert_eq!(
sel.pseudo_element,
Some(htui::html::css_parse::PseudoElement::After)
);
assert_eq!(sel.compounds.len(), 2, "combinator chain survives");
assert!("p::first-line".parse::<CssSelector>().is_err());
assert!(
"::before".parse::<CssSelector>().is_err(),
"bare pseudo-element has no origin"
);
}
#[test]
fn test_generated_content_renders_before_and_after() {
init();
let (root_id, mut arena, _) = {
let mut parser = html_parse::Parser::new("<p>mid</p>");
parser.parse()
};
let rules = Parser::new(
"p::before { content: \"[\"; color: red; } p::after { content: \"]\"; font-weight: bold; }",
)
.parse();
css_into_node(&mut arena, root_id, &rules);
let term_map = htui::layout::layout::TermMap {
columns: 80,
rows: 24,
};
let mut tl = htui::tui::graph_api::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();
assert!(
canvas.contains("[mid]"),
"generated ::before/::after runs surround the text, got {canvas:?}"
);
assert!(
tl.layout.iter().any(|b| {
b.word == "["
&& b.fcolor
== Some(Color::Rgb {
r: 0xFF,
g: 0,
b: 0,
})
}),
"::before colour applies to the generated run"
);
let (root2, mut arena2, _) = {
let mut parser = html_parse::Parser::new("<p>mid</p>");
parser.parse()
};
let rules = Parser::new("p::before { content: none; }").parse();
css_into_node(&mut arena2, root2, &rules);
let mut tl2 = htui::tui::graph_api::TermLayout::new(&term_map, 1.0);
tl2.draw(&arena2, root2, &term_map);
let canvas2: String = tl2.layout.iter().map(|b| b.word.as_str()).collect();
assert!(
canvas2.contains("mid") && !canvas2.contains('['),
"content: none generates nothing"
);
}
use htui::html::css_parse::{MediaQuery, MediaQueryList};
fn media_queries(css: &str) -> Vec<Option<MediaQueryList>> {
Parser::new(css)
.parse()
.0
.iter()
.map(|e| e.media.clone())
.collect()
}
#[test]
fn test_media_query_matches_features_and_or() {
init();
let list = MediaQueryList(vec![MediaQuery {
max_width: Some(70.0),
..Default::default()
}]);
assert!(
list.matches(&Viewport { cols: 70, rows: 24 }),
"boundary is inclusive"
);
assert!(list.matches(&Viewport { cols: 40, rows: 24 }));
assert!(!list.matches(&Viewport { cols: 71, rows: 24 }));
let both = MediaQueryList(vec![MediaQuery {
max_width: Some(70.0),
min_width: Some(40.0),
..Default::default()
}]);
assert!(both.matches(&Viewport { cols: 60, rows: 24 }));
assert!(!both.matches(&Viewport { cols: 39, rows: 24 }));
assert!(!both.matches(&Viewport { cols: 80, rows: 24 }));
let or = MediaQueryList(vec![
MediaQuery {
max_width: Some(40.0),
..Default::default()
},
MediaQuery {
max_height: Some(10.0),
..Default::default()
},
]);
assert!(or.matches(&Viewport { cols: 39, rows: 24 }), "first query");
assert!(or.matches(&Viewport { cols: 80, rows: 9 }), "second query");
assert!(
!or.matches(&Viewport { cols: 80, rows: 24 }),
"neither query"
);
assert!(!MediaQueryList(vec![]).matches(&Viewport { cols: 80, rows: 24 }));
assert!(!list.matches(&Viewport { cols: 0, rows: 0 }));
}
#[test]
fn test_media_at_rule_parses_conditional_entries() {
init();
let media = media_queries("@media (max-width: 70ch) { .side { display: none; } }");
assert_eq!(media.len(), 1);
let list = media[0].as_ref().expect("condition attached");
assert!(list.matches(&Viewport { cols: 60, rows: 24 }));
assert!(!list.matches(&Viewport { cols: 80, rows: 24 }));
let mixed = media_queries("p { color: red; } @media (max-height: 14) { .x { color: blue; } }");
assert_eq!(mixed.len(), 2);
assert!(mixed[0].is_none());
let list = mixed[1].as_ref().expect("media condition");
assert!(list.matches(&Viewport { cols: 80, rows: 10 }));
assert!(!list.matches(&Viewport { cols: 80, rows: 24 }));
let list = media_queries(
"@media (max-width: 40) and (min-height: 20), (min-width: 100) { .x { color: red; } }",
);
let list = list[0].as_ref().expect("condition attached");
assert!(
list.matches(&Viewport { cols: 30, rows: 24 }),
"first query"
);
assert!(
!list.matches(&Viewport { cols: 30, rows: 10 }),
"height too small"
);
assert!(
list.matches(&Viewport {
cols: 120,
rows: 24
}),
"second query"
);
}
#[test]
fn test_media_cascade_by_viewport() {
init();
let css = "@media (max-width: 40) { .side { display: none; } } .side { color: red; }";
let rules = Parser::new(css).parse();
let mut arena = NodeArena::default();
let (root, div_id) = build_test(&mut arena);
arena[div_id]
.attributes
.insert("class".to_string(), "side".to_string());
css_into_node_with_state(
&mut arena,
root,
&rules,
&DynamicState {
viewport: Viewport { cols: 80, rows: 24 },
..Default::default()
},
);
assert_eq!(
arena[div_id].css_attr.0.get("display").map(|s| s.as_ref()),
None
);
let mut arena = NodeArena::default();
let (root, div_id) = build_test(&mut arena);
arena[div_id]
.attributes
.insert("class".to_string(), "side".to_string());
css_into_node_with_state(
&mut arena,
root,
&rules,
&DynamicState {
viewport: Viewport { cols: 40, rows: 24 },
..Default::default()
},
);
assert_eq!(
arena[div_id].css_attr.0.get("display").map(|s| s.as_ref()),
Some("none")
);
reset_css_attr(&mut arena, root);
css_into_node_with_state(
&mut arena,
root,
&rules,
&DynamicState {
viewport: Viewport { cols: 80, rows: 24 },
..Default::default()
},
);
assert_eq!(
arena[div_id].css_attr.0.get("display").map(|s| s.as_ref()),
None
);
assert_eq!(
arena[div_id].css_attr.0.get("color").map(|s| s.as_ref()),
Some("red")
);
let mut arena = NodeArena::default();
let (root, div_id) = build_test(&mut arena);
arena[div_id]
.attributes
.insert("class".to_string(), "side".to_string());
css_into_node_with_state(&mut arena, root, &rules, &DynamicState::default());
assert_eq!(
arena[div_id].css_attr.0.get("display").map(|s| s.as_ref()),
None
);
assert_eq!(
arena[div_id].css_attr.0.get("color").map(|s| s.as_ref()),
Some("red")
);
}
#[test]
fn test_media_unsupported_query_drops_the_block() {
init();
let media = media_queries("@media (max-width: 5em) { .side { display: none; } }");
assert_eq!(media.len(), 1);
let list = media[0].as_ref().expect("condition attached");
assert!(!list.matches(&Viewport { cols: 1, rows: 1 }));
let media =
media_queries("@media (hover: hover), (max-width: 40) { .side { display: none; } }");
assert_eq!(media.len(), 1);
let list = media[0].as_ref().expect("condition attached");
assert!(list.matches(&Viewport { cols: 30, rows: 24 }));
assert!(!list.matches(&Viewport { cols: 80, rows: 24 }));
let media = media_queries("@media (orientation: landscape) { .side { display: none; } }");
let list = media[0].as_ref().expect("condition attached");
assert!(list.matches(&Viewport { cols: 80, rows: 24 }));
assert!(!list.matches(&Viewport { cols: 30, rows: 40 }), "portrait");
let media = media_queries("@media (orientation: portrait) { .side { display: none; } }");
let list = media[0].as_ref().expect("condition attached");
assert!(list.matches(&Viewport { cols: 30, rows: 40 }));
let media = media_queries("@media screen and (max-width: 40) { .side { display: none; } }");
let list = media[0].as_ref().expect("condition attached");
assert!(list.matches(&Viewport { cols: 30, rows: 24 }));
assert!(!list.matches(&Viewport { cols: 80, rows: 24 }));
let media = media_queries("@media print { .side { display: none; } }");
let list = media[0].as_ref().expect("condition attached");
assert!(!list.matches(&Viewport { cols: 30, rows: 24 }));
let media = media_queries("@media not (max-width: 40) { .side { display: none; } }");
let list = media[0].as_ref().expect("condition attached");
assert!(!list.matches(&Viewport { cols: 30, rows: 24 }));
assert!(list.matches(&Viewport { cols: 80, rows: 24 }));
let media =
media_queries("@media only screen and (max-width: 40) { .side { display: none; } }");
let list = media[0].as_ref().expect("condition attached");
assert!(list.matches(&Viewport { cols: 30, rows: 24 }));
assert!(media_queries("@media { .side { display: none; } }").is_empty());
let media = media_queries("@media (max-width:) { .side { display: none; } }");
assert_eq!(media.len(), 1);
assert!(
!media[0]
.as_ref()
.expect("condition attached")
.matches(&Viewport { cols: 1, rows: 1 })
);
}
#[test]
fn test_media_at_rule_does_not_disturb_following_rules() {
init();
let css = "@media (max-width: 40) { .side { display: none; } } .after { color: red; }";
let rules = Parser::new(css).parse();
assert_eq!(rules.0.len(), 2, "media child + following rule");
assert!(rules.0[0].media.is_some());
assert!(rules.0[1].media.is_none());
assert_eq!(
rules.0[1].normal.0.get("color").map(|s| s.as_ref()),
Some("red")
);
}
#[test]
fn attribute_selector_exists() {
let sel: CssSelector = "[disabled]".parse().expect("bare attr parses");
assert_eq!(sel.compounds.len(), 1);
assert_eq!(sel.compounds[0].len(), 1);
let htui::html::css_parse::AttributeSelector {
name, op, value, ..
} = match &sel.compounds[0][0] {
htui::html::css_parse::Selector::Attribute(a) => a.clone(),
_ => panic!("expected Attribute"),
};
assert_eq!(name, "disabled");
assert_eq!(op, htui::html::css_parse::AttributeOp::Exists);
assert_eq!(value, "");
}
#[test]
fn attribute_selector_equals_quoted_and_unquoted() {
for css in ["[type=\"range\"]", "[type=range]"] {
let sel: CssSelector = css.parse().expect("equals attr parses");
match &sel.compounds[0][0] {
htui::html::css_parse::Selector::Attribute(a) => {
assert_eq!(a.name, "type");
assert_eq!(a.op, htui::html::css_parse::AttributeOp::Equals);
assert_eq!(a.value, "range");
}
_ => panic!("expected Attribute"),
}
}
}
#[test]
fn attribute_selector_compound_with_tag() {
let sel: CssSelector = "input[type=\"range\"]"
.parse()
.expect("compound attr parses");
assert_eq!(sel.compounds[0].len(), 2, "tag + attribute");
match &sel.compounds[0][0] {
htui::html::css_parse::Selector::Tag(nt) => {
assert_eq!(*nt, htui::tdom::node::NodeType::Input);
}
_ => panic!("expected Tag"),
}
}
#[test]
fn attribute_selector_prefix_suffix_substring() {
let sel: CssSelector = "[href^=\"https\"]".parse().unwrap();
match &sel.compounds[0][0] {
htui::html::css_parse::Selector::Attribute(a) => {
assert_eq!(a.op, htui::html::css_parse::AttributeOp::PrefixMatch);
assert_eq!(a.value, "https");
}
_ => panic!(),
}
let sel: CssSelector = "[href$=\".pdf\"]".parse().unwrap();
match &sel.compounds[0][0] {
htui::html::css_parse::Selector::Attribute(a) => {
assert_eq!(a.op, htui::html::css_parse::AttributeOp::SuffixMatch);
}
_ => panic!(),
}
let sel: CssSelector = "[href*=\"example\"]".parse().unwrap();
match &sel.compounds[0][0] {
htui::html::css_parse::Selector::Attribute(a) => {
assert_eq!(a.op, htui::html::css_parse::AttributeOp::SubstringMatch);
}
_ => panic!(),
}
}
#[test]
fn attribute_selector_dash_and_includes() {
let sel: CssSelector = "[lang|=\"en\"]".parse().unwrap();
match &sel.compounds[0][0] {
htui::html::css_parse::Selector::Attribute(a) => {
assert_eq!(a.op, htui::html::css_parse::AttributeOp::DashMatch);
}
_ => panic!(),
}
let sel: CssSelector = "[class~=\"btn\"]".parse().unwrap();
match &sel.compounds[0][0] {
htui::html::css_parse::Selector::Attribute(a) => {
assert_eq!(a.op, htui::html::css_parse::AttributeOp::Includes);
}
_ => panic!(),
}
}
#[test]
fn attribute_selector_case_insensitive_flag() {
let sel: CssSelector = "[type=\"RANGE\" i]".parse().unwrap();
match &sel.compounds[0][0] {
htui::html::css_parse::Selector::Attribute(a) => {
assert!(a.case_insensitive);
assert_eq!(a.value, "RANGE");
}
_ => panic!(),
}
}
#[test]
fn attribute_selector_specificity_is_class_level() {
let bare: CssSelector = "input".parse().unwrap();
let attr: CssSelector = "input[type]".parse().unwrap();
let (_, b_bare, _) = bare.specificity();
let (_, b_attr, _) = attr.specificity();
assert_eq!(b_attr, b_bare + 1, "attribute adds one b-level specificity");
}
#[test]
fn attribute_selector_unclosed_bracket_is_error() {
assert!("[type".parse::<CssSelector>().is_err());
assert!("[]".parse::<CssSelector>().is_err());
}
fn parse_and_cascade(html: &str) -> (NodeId, NodeArena) {
let mut html_parser = html_parse::Parser::new(html);
let (root_id, mut arena, embedded) = html_parser.parse();
let mut css_parser = Parser::new(&embedded);
let rules = css_parser.parse();
css_into_node(&mut arena, root_id, &rules);
(root_id, arena)
}
fn find_first_by_tag(arena: &NodeArena, root: NodeId, tag: NodeType) -> NodeId {
fn walk(arena: &NodeArena, id: NodeId, tag: NodeType) -> Option<NodeId> {
if arena[id].node_type == tag {
return Some(id);
}
for &c in &arena[id].children {
if let Some(found) = walk(arena, c, tag) {
return Some(found);
}
}
None
}
walk(arena, root, tag).expect("node with tag not found")
}
#[test]
fn css_var_basic_substitution() {
let (root, arena) = parse_and_cascade(
r#"<style>
:root { --primary: #ff0000; }
p { color: var(--primary); }
</style><p>hi</p>"#,
);
let p = find_first_by_tag(&arena, root, NodeType::P);
let color = arena[p].css_attr.get_color();
assert_eq!(
color,
Some(Color::Rgb { r: 255, g: 0, b: 0 }),
"var(--primary) resolves to red"
);
}
#[test]
fn css_var_fallback_value() {
let (root, arena) = parse_and_cascade(
r#"<style>
p { color: var(--undefined, blue); }
</style><p>hi</p>"#,
);
let p = find_first_by_tag(&arena, root, NodeType::P);
assert_eq!(
arena[p].css_attr.get_color(),
Some(Color::Rgb { r: 0, g: 0, b: 255 }),
"fallback is used when the variable is undefined"
);
}
#[test]
fn css_var_inheritance_children() {
let (root, arena) = parse_and_cascade(
r#"<style>
div { --c: green; }
span { color: var(--c); }
</style><div><span>s</span></div>"#,
);
let div = find_first_by_tag(&arena, root, NodeType::Div);
let span = arena[div].children[0];
assert_eq!(
arena[span].css_attr.get_color(),
Some(Color::Rgb { r: 0, g: 128, b: 0 }),
"--c inherits from div to span"
);
}
#[test]
fn css_var_override_in_child() {
let (root, arena) = parse_and_cascade(
r#"<style>
div { --c: red; }
span { --c: blue; color: var(--c); }
</style><div><span>s</span></div>"#,
);
let div = find_first_by_tag(&arena, root, NodeType::Div);
let span = arena[div].children[0];
assert_eq!(
arena[span].css_attr.get_color(),
Some(Color::Rgb { r: 0, g: 0, b: 255 }),
"child's --c override wins over parent"
);
}
#[test]
fn css_var_in_calc() {
let (root, arena) = parse_and_cascade(
r#"<style>
:root { --w: 20ch; }
div { width: calc(var(--w) + 5ch); }
</style><div>d</div>"#,
);
let div = find_first_by_tag(&arena, root, NodeType::Div);
let w = arena[div]
.css_attr
.get_width(80, font_ctx(1, 1), viewport());
assert_eq!(w, Some(25), "calc(var(--w) + 5ch) = 20 + 5 = 25");
}
#[test]
fn css_var_chain_reference() {
let (root, arena) = parse_and_cascade(
r#"<style>
:root { --a: red; --b: var(--a); }
p { color: var(--b); }
</style><p>x</p>"#,
);
let p = find_first_by_tag(&arena, root, NodeType::P);
assert_eq!(
arena[p].css_attr.get_color(),
Some(Color::Rgb { r: 255, g: 0, b: 0 }),
"--b references --a which is red"
);
}
#[test]
fn css_var_undefined_no_fallback_substitutes_empty() {
let (root, arena) = parse_and_cascade(
r#"<style>
p { color: var(--nothing); }
</style><p>x</p>"#,
);
let p = find_first_by_tag(&arena, root, NodeType::P);
assert_eq!(arena[p].css_attr.get_color(), None);
}
#[test]
fn css_var_multiple_in_one_value() {
let (root, arena) = parse_and_cascade(
r#"<style>
:root { --bw: 2ch; --bc: red; }
div { border: var(--bw) solid var(--bc); }
</style><div>d</div>"#,
);
let div = find_first_by_tag(&arena, root, NodeType::Div);
let has_unresolved = arena[div].css_attr.0.values().any(|v| v.contains("var("));
assert!(!has_unresolved, "all var() references are substituted");
let border = arena[div].css_attr.0.get("border");
assert_eq!(
border.map(|s| s.as_ref()),
Some("2ch solid red"),
"border shorthand has both vars substituted"
);
}
#[test]
fn grid_line_parses_numbers_spans_and_area_names() {
use htui::html::css_parse::{GridLine, parse_grid_axis_shorthand};
assert_eq!(GridLine::parse("3"), Some(GridLine::Line(3)));
assert_eq!(GridLine::parse("-2"), Some(GridLine::Line(-2)));
assert_eq!(GridLine::parse("0"), None);
assert_eq!(GridLine::parse("span 2"), Some(GridLine::Span(2)));
assert_eq!(GridLine::parse("span"), None);
assert_eq!(GridLine::parse("auto"), Some(GridLine::Auto));
assert_eq!(
GridLine::parse("head"),
Some(GridLine::Name(Arc::from("head")))
);
assert_eq!(GridLine::parse("2ch"), None, "lengths are not grid lines");
assert_eq!(
parse_grid_axis_shorthand("sidebar"),
Some((
GridLine::Name(Arc::from("sidebar")),
GridLine::Name(Arc::from("sidebar"))
))
);
assert_eq!(
parse_grid_axis_shorthand("2"),
Some((GridLine::Line(2), GridLine::Auto))
);
}
#[test]
fn grid_area_shorthand_copies_ident_starts_into_end_lines() {
use htui::html::css_parse::{GridLine, parse_grid_area};
let name = |s: &str| Some(GridLine::Name(Arc::from(s)));
let whole = parse_grid_area("head").expect("single name addresses the whole area");
assert_eq!(whole.row_start, name("head"));
assert_eq!(whole.column_start, name("head"));
assert_eq!(whole.row_end, name("head"));
assert_eq!(whole.column_end, name("head"));
let numeric = parse_grid_area("2 / 3").expect("numeric lines");
assert_eq!(numeric.row_start, Some(GridLine::Line(2)));
assert_eq!(numeric.column_start, Some(GridLine::Line(3)));
assert_eq!(numeric.row_end, Some(GridLine::Auto));
assert_eq!(numeric.column_end, Some(GridLine::Auto));
}
#[test]
fn grid_template_areas_parses_a_rectangular_matrix() {
use htui::html::css_parse::{GridAreas, parse_grid_template_areas};
let areas = parse_grid_template_areas(r#""a . b" "a . b""#).expect("valid matrix");
assert_eq!(areas.row_count(), 2);
assert_eq!(areas.column_count(), 3);
assert_eq!(areas.cells[1][0].as_deref(), Some("a"));
assert_eq!(areas.cells[1][1], None, "a dot cell is null");
assert_eq!(areas.cells[1][2].as_deref(), Some("b"));
assert_eq!(
parse_grid_template_areas("none"),
Some(GridAreas { cells: Vec::new() })
);
}
#[test]
fn grid_template_areas_rejects_ragged_or_non_rectangular_values() {
use htui::html::css_parse::parse_grid_template_areas;
assert!(
parse_grid_template_areas(r#""a a" "a""#).is_none(),
"rows must share one width"
);
assert!(
parse_grid_template_areas(r#""a b" "b a""#).is_none(),
"an L-shaped area is invalid"
);
assert!(parse_grid_template_areas("a b").is_none(), "unquoted cells");
assert!(parse_grid_template_areas("").is_none());
}
#[test]
fn grid_track_parses_minmax_and_fit_content() {
use htui::html::css_parse::{GridTrack, Length, parse_grid_track_list};
let length_track = |s: &str| GridTrack::Length(Length::parse(s).expect("test lengths parse"));
assert_eq!(
GridTrack::parse("minmax(1ch, 3ch)"),
Some(GridTrack::MinMax {
min: Box::new(length_track("1ch")),
max: Box::new(length_track("3ch")),
})
);
assert_eq!(
GridTrack::parse("minmax(1fr, 2ch)"),
None,
"the min side of minmax is never flexible"
);
assert_eq!(
GridTrack::parse("minmax(minmax(1ch, 2ch), 3ch)"),
None,
"a breadth is never a nested function"
);
assert_eq!(
GridTrack::parse("FIT-CONTENT(2ch)"),
Some(GridTrack::MinMax {
min: Box::new(GridTrack::Auto),
max: Box::new(length_track("2ch")),
}),
"fit-content desugars to minmax(auto, length); function names are case-insensitive"
);
let fr_max = GridTrack::parse("minmax(auto, 1fr)").expect("a flexible max is valid");
assert!(fr_max.is_fr(), "a flexible max joins fr distribution");
let repeated = parse_grid_track_list("repeat(2, minmax(1ch, 3ch))").expect("repeat expands");
assert_eq!(repeated.len(), 2);
assert!(
repeated
.iter()
.all(|t| matches!(t, GridTrack::MinMax { .. }))
);
}
#[test]
fn grid_track_parses_auto_fill_and_auto_fit_repeats() {
use htui::html::css_parse::{GridTrack, parse_grid_track_list};
let list = parse_grid_track_list("repeat(auto-fill, minmax(10ch, 1fr))").expect("auto-fill");
assert_eq!(list.len(), 1, "an auto-repeat stays one unresolved track");
assert!(
matches!(&list[0], GridTrack::AutoRepeat { fit: false, tracks } if tracks.len() == 1),
"auto-fill is recorded with its inner track list"
);
let fit =
parse_grid_track_list("repeat(AUTO-FIT, 3ch 1ch)").expect("auto-fit, case-insensitive");
assert!(matches!(&fit[0], GridTrack::AutoRepeat { fit: true, tracks } if tracks.len() == 2));
assert!(
parse_grid_track_list("repeat(auto-fill, repeat(auto-fill, 3ch))").is_none(),
"auto-repeats cannot nest"
);
assert!(
parse_grid_track_list("repeat(2, repeat(auto-fill, 3ch))").is_none(),
"auto-repeats cannot nest inside an integer repeat"
);
assert!(
parse_grid_track_list("repeat(auto-fill,)").is_none(),
"an empty track list is invalid"
);
}
#[test]
fn grid_line_parses_named_spans() {
use htui::html::css_parse::GridLine;
assert_eq!(
GridLine::parse("span 2 head"),
Some(GridLine::SpanName {
span: 2,
name: Arc::from("head")
})
);
assert_eq!(
GridLine::parse("span head"),
Some(GridLine::SpanName {
span: 1,
name: Arc::from("head")
})
);
assert_eq!(GridLine::parse("span 2"), Some(GridLine::Span(2)));
assert_eq!(GridLine::parse("span 0"), None);
assert_eq!(GridLine::parse("span 2 3"), None, "two integers");
assert_eq!(GridLine::parse("span head tail"), None, "two idents");
}
#[test]
fn grid_template_shorthand_expands_area_strings() {
let (root, arena) = parse_and_cascade(
r#"<style>
.g { grid-template: "head head" 1fr "side main" 2ch / 2ch 1fr; }
</style><div class="g"></div>"#,
);
let div = find_first_by_tag(&arena, root, NodeType::Div);
assert_eq!(
arena[div]
.css_attr
.0
.get("grid-template-areas")
.map(|s| s.as_ref()),
Some(r#""head head" "side main""#)
);
assert_eq!(
arena[div]
.css_attr
.0
.get("grid-template-rows")
.map(|s| s.as_ref()),
Some("1fr 2ch"),
"each area row gets its size, defaulting to auto"
);
assert_eq!(
arena[div]
.css_attr
.0
.get("grid-template-columns")
.map(|s| s.as_ref()),
Some("2ch 1fr")
);
let (root, arena) = parse_and_cascade(
r#"<style>
.p { grid-template: 2ch 1fr; }
</style><div class="p"></div>"#,
);
let div = find_first_by_tag(&arena, root, NodeType::Div);
assert!(
arena[div].css_attr.0.contains_key("grid-template"),
"the plain track-list form keeps the raw shorthand"
);
assert!(!arena[div].css_attr.0.contains_key("grid-template-areas"));
}
#[test]
fn stylesheet_prelude_blocks_do_not_truncate_selectors() {
let (root, arena) = parse_and_cascade(
r#"<style>
:root { --accent: #43e5a0; }
input[type="range"] { accent-color: var(--accent); }
p:not(.x) { color: #ff7a90; }
</style>
<input type="range"><p>one</p>"#,
);
let input = find_first_by_tag(&arena, root, NodeType::Input);
assert_eq!(
arena[input].css_attr.get_accent_color(),
parse_color_str("#43e5a0"),
"an attribute-selector rule applies, with var() substituted"
);
let p = find_first_by_tag(&arena, root, NodeType::P);
assert_eq!(
arena[p].css_attr.get_color(),
parse_color_str("#ff7a90"),
"an argumented pseudo-class rule applies"
);
}