//! Stylesheet cascade: parse `
"#,
);
let target = tree.get_element_by_id("target").unwrap();
let sibling = tree.get_element_by_id("sibling").unwrap();
let laid = crate::dom::layout_dom(&tree, (800.0, 600.0));
assert_eq!(laid.rects[&target].width, 300.0);
assert_eq!(laid.rects[&target].height, 20.0);
assert_eq!(laid.rects[&sibling].x, 300.0);
}
#[test]
fn animation_sparse_tracks_ignore_unrelated_intermediate_keyframes() {
let css = r#"
@keyframes sparse {
from { width:100px; opacity:0; background-color:red }
50% { opacity:1 }
to { width:300px; opacity:0; background-color:blue }
}
#target { animation:sparse 1s linear both }
"#;
let style = sampled_animation_style(css, 500.0, "target");
assert_eq!(style.width, crate::Dimension::Px(200.0));
assert_opacity(style.opacity.unwrap(), 1.0);
assert_eq!(style.background_color, Some([128, 0, 128, 255]));
let implicit = r#"
@keyframes middle { 50% { width:150px } }
#target { width:50px; animation:middle 1s linear both }
"#;
assert_eq!(
sampled_animation_style(implicit, 250.0, "target").width,
crate::Dimension::Px(100.0)
);
assert_eq!(
sampled_animation_style(implicit, 750.0, "target").width,
crate::Dimension::Px(100.0)
);
}
#[test]
fn keyframe_duplicate_offsets_merge_per_property_and_ignore_important() {
let css = r#"
@keyframes merged {
0% { width:100px; color:red; opacity:.1 }
from { width:999px !important; opacity:.8 }
}
#target { width:40px; animation:merged 1s both }
"#;
let style = sampled_animation_style(css, 0.0, "target");
assert_eq!(style.width, crate::Dimension::Px(100.0));
assert_eq!(style.color, Some([255, 0, 0, 255]));
assert_opacity(style.opacity.unwrap(), 0.8);
}
#[test]
fn author_important_overrides_every_sampled_animation_property() {
let css = r#"
@keyframes forced { from, to { width:300px; background-color:red } }
#target {
animation:forced 1s both;
width:80px !important;
background-color:blue !important;
}
"#;
let style = sampled_animation_style(css, 0.0, "target");
assert_eq!(style.width, crate::Dimension::Px(80.0));
assert_eq!(style.background_color, Some([0, 0, 255, 255]));
}
#[test]
fn conditional_keyframes_and_empty_later_definitions_follow_browser_selection() {
let css = r#"
@keyframes chosen { from, to { width:222px } }
@media (max-width:100px) {
@keyframes chosen { from, to { width:111px } }
}
@supports (unknown-parity-property:value) {
@keyframes chosen { from, to { width:123px } }
}
#target { width:50px; animation:chosen 1s both }
"#;
assert_eq!(
sampled_animation_style(css, 0.0, "target").width,
crate::Dimension::Px(222.0)
);
let empty = r#"
@keyframes cleared { from, to { width:300px } }
@keyframes cleared {}
#target { width:50px; animation:cleared 1s both }
"#;
assert_eq!(
sampled_animation_style(empty, 0.0, "target").width,
crate::Dimension::Px(50.0)
);
}
#[test]
fn keyframe_names_follow_cascade_layer_order_and_standard_prefix_priority() {
let unlayered = r#"
@keyframes chosen { from, to { width:222px } }
@layer outer { @keyframes chosen { from, to { width:111px } } }
#target { animation:chosen 1s both }
"#;
assert_eq!(
sampled_animation_style(unlayered, 0.0, "target").width,
crate::Dimension::Px(222.0)
);
let ordered = r#"
@layer weak, strong;
@layer strong { @keyframes chosen { from, to { width:333px } } }
@layer weak { @keyframes chosen { from, to { width:444px } } }
#target { animation:chosen 1s both }
"#;
assert_eq!(
sampled_animation_style(ordered, 0.0, "target").width,
crate::Dimension::Px(333.0)
);
let reversed = ordered.replace("@layer weak, strong", "@layer strong, weak");
assert_eq!(
sampled_animation_style(&reversed, 0.0, "target").width,
crate::Dimension::Px(444.0)
);
let prefix = r#"
@keyframes chosen { from, to { width:555px } }
@-webkit-keyframes chosen { from, to { width:666px } }
#target { animation:chosen 1s both }
"#;
assert_eq!(
sampled_animation_style(prefix, 0.0, "target").width,
crate::Dimension::Px(555.0)
);
}
#[test]
fn animation_timeline_handles_delay_fill_iterations_and_direction_at_t0() {
assert_opacity(sampled_fade("", 0.0), 0.0);
assert_opacity(sampled_fade("animation-delay:250ms", 0.0), 0.4);
assert_opacity(
sampled_fade("animation-delay:250ms;animation-fill-mode:backwards", 0.0),
0.0,
);
assert_opacity(
sampled_fade(
"animation-delay:250ms;animation-fill-mode:backwards;animation-direction:reverse",
0.0,
),
1.0,
);
assert_opacity(sampled_fade("animation-delay:-250ms", 0.0), 0.25);
assert_opacity(sampled_fade("animation-delay:-1s", 0.0), 0.0);
assert_opacity(
sampled_fade("animation-delay:-1s;animation-direction:alternate", 0.0),
1.0,
);
assert_opacity(
sampled_fade(
"animation-delay:-1s;animation-iteration-count:1;animation-fill-mode:none",
0.0,
),
0.4,
);
assert_opacity(
sampled_fade(
"animation-delay:-1s;animation-iteration-count:1;animation-fill-mode:forwards",
0.0,
),
1.0,
);
assert_opacity(
sampled_fade(
"animation-delay:-1s;animation-iteration-count:1;animation-fill-mode:forwards;animation-direction:reverse",
0.0,
),
0.0,
);
assert_opacity(
sampled_fade(
"animation-delay:-2s;animation-iteration-count:2;animation-fill-mode:forwards;animation-direction:alternate",
0.0,
),
0.0,
);
assert_opacity(
sampled_fade(
"animation-iteration-count:0;animation-fill-mode:forwards",
0.0,
),
0.0,
);
assert_opacity(
sampled_fade(
"animation-duration:0s;animation-iteration-count:1;animation-fill-mode:none",
0.0,
),
0.4,
);
assert_opacity(
sampled_fade(
"animation-duration:0s;animation-iteration-count:1;animation-fill-mode:forwards",
0.0,
),
1.0,
);
assert_opacity(
sampled_fade(
"animation-delay:-2.5s;animation-iteration-count:2.5;animation-fill-mode:forwards",
0.0,
),
0.5,
);
}
#[test]
fn animation_calc_delay_pause_and_important_origin_are_deterministic() {
assert_opacity(
sampled_fade("--step:.1s;animation-delay:calc(var(--step) * -2.5)", 0.0),
0.25,
);
assert_opacity(sampled_fade("animation-play-state:paused", 500.0), 0.0);
assert_opacity(sampled_fade("", 500.0), 0.5);
assert_opacity(
sampled_fade(
"animation-delay:-250ms !important;opacity:.8 !important",
0.0,
),
0.8,
);
}
#[test]
fn opacity_segments_use_underlying_endpoints_and_exact_later_boundaries() {
let missing_endpoints = r#"
@keyframes middle { 50% { opacity:1 } }
#target { opacity:.4; animation:middle 1s linear 1 both }
"#;
assert_opacity(
sampled_animation_style(missing_endpoints, 250.0, "target")
.opacity
.unwrap(),
0.7,
);
assert_opacity(
sampled_animation_style(missing_endpoints, 750.0, "target")
.opacity
.unwrap(),
0.7,
);
let exact_boundary = r#"
@keyframes peak {
0% { opacity:0 }
50% { opacity:1 }
100% { opacity:0 }
}
#target { opacity:.4; animation:peak 1s linear 1 both }
"#;
assert_opacity(
sampled_animation_style(exact_boundary, 500.0, "target")
.opacity
.unwrap(),
1.0,
);
}
#[test]
fn missing_keyframe_offsets_follow_web_animation_distribution() {
let stop = |offset| KeyframeStop {
offset,
declarations: "opacity:1".to_string(),
source_order: 0,
};
let single = [stop(None)];
assert_eq!(normalized_keyframe_offsets(&single)[0].0, 1.0);
let pair = [stop(None), stop(None)];
assert_eq!(
normalized_keyframe_offsets(&pair)
.iter()
.map(|(offset, _)| *offset)
.collect::>(),
[0.0, 1.0]
);
let distributed = [
stop(Some(0.0)),
stop(None),
stop(None),
stop(Some(0.75)),
stop(None),
];
assert_eq!(
normalized_keyframe_offsets(&distributed)
.iter()
.map(|(offset, _)| *offset)
.collect::>(),
[0.0, 0.25, 0.5, 0.75, 1.0]
);
}
#[test]
fn mozilla_stagger_samples_only_the_delay_zero_frame() {
let mut html = String::new();
let mut selectors = String::new();
for frame in 0..12 {
html.push_str(&format!(r#""#));
selectors.push_str(&format!(
"#frame{frame}{{animation-delay:calc(var(--base-delay) * {frame})}}"
));
}
let tree = obscura_dom::parse_html(&html);
let css = format!(
r#"
@keyframes wave {{
0%, 8.333% {{ opacity:1 }}
8.4%, to {{ opacity:0 }}
}}
.frame {{
--base-delay:.1s;
opacity:0;
animation:wave 1.2s linear infinite;
}}
{selectors}
"#
);
let sheet = Stylesheet::parse_for_viewport_at_animation_time(
&tree,
&[css],
(1280.0, 720.0),
crate::AnimationSampleTime::default(),
);
for frame in 0..12 {
let id = format!("frame{frame}");
let target = tree.get_element_by_id(&id).unwrap();
let node = tree.get_node(target).unwrap();
let element = node.as_element().unwrap();
let classes = vec!["frame".to_string()];
let mut matcher = tree.matcher();
let mut style = LayoutStyle::default();
sheet.apply(
&tree,
&mut matcher,
target,
node.get_attribute("id"),
&classes,
element.local.as_ref(),
&mut style,
&HashMap::new(),
None,
);
assert_opacity(style.opacity.unwrap(), if frame == 0 { 1.0 } else { 0.0 });
}
}
#[test]
fn media_rules_use_the_live_width_and_height() {
let css = r#"
.base { color: black; }
@media (max-width: 950px) { .narrow { color: green; } }
@media (min-height: 900px) { .tall { color: blue; } }
@media (orientation: portrait) { .portrait { color: red; } }
"#;
let selectors = |viewport| {
parse_stylesheet_for_viewport(css, viewport)
.into_iter()
.map(|(selector, _)| selector)
.collect::>()
};
let narrow_tall = selectors((900.0, 1000.0));
assert!(narrow_tall.iter().any(|s| s == ".base"));
assert!(narrow_tall.iter().any(|s| s == ".narrow"));
assert!(narrow_tall.iter().any(|s| s == ".tall"));
assert!(narrow_tall.iter().any(|s| s == ".portrait"));
let wide_short = selectors((1280.0, 720.0));
assert!(wide_short.iter().any(|s| s == ".base"));
assert!(!wide_short.iter().any(|s| s == ".narrow"));
assert!(!wide_short.iter().any(|s| s == ".tall"));
assert!(!wide_short.iter().any(|s| s == ".portrait"));
}
#[test]
fn nested_media_rules_use_the_live_viewport() {
let css = ".card { display:block; @media (max-width: 950px) { width:100%; } }";
let narrow = parse_stylesheet_for_viewport(css, (900.0, 1000.0));
let wide = parse_stylesheet_for_viewport(css, (1280.0, 720.0));
assert!(narrow
.iter()
.any(|(selector, declarations)| selector == ".card"
&& declarations.contains("width:100%")));
assert!(!wide
.iter()
.any(|(_, declarations)| declarations.contains("width:100%")));
}
#[test]
fn tailwind_container_query_is_retained_but_simple_parser_omits_it() {
let css = r#"
.base { width: 10px }
@container (min-width: 28rem) {
.\@md\:flex-row { flex-direction: row }
}
@container main not (max-inline-size: 60em) {
.named { width: 20px }
}
"#;
let mut conditions = condition_arena_root();
let parsed = parse_stylesheet_for_viewport_preserving_containers(
css, (1280.0, 720.0), &mut conditions, ContainerConditionId::NONE,
);
assert_eq!(parsed.len(), 3);
assert_eq!(parsed[1].container_condition_id, ContainerConditionId(1));
assert_eq!(parsed[2].container_condition_id, ContainerConditionId(2));
assert_eq!(conditions.len(), 3);
assert_eq!(
conditions[1].alternatives[0].condition,
Some(ContainerQueryExpr::Feature(ContainerSizeFeature {
axis: ContainerQueryAxis::Width,
comparison: ContainerQueryComparison::Min,
length: ContainerQueryLength::Rem(28.0),
}))
);
assert_eq!(conditions[2].alternatives[0].name.as_deref(), Some("main"));
assert!(matches!(
conditions[2].alternatives[0].condition,
Some(ContainerQueryExpr::Not(_))
));
assert_eq!(parse_stylesheet(css).len(), 1);
}
#[test]
fn container_at_rule_keyword_is_ascii_insensitive_and_exact() {
assert_eq!(
at_rule_prelude("CoNtAiNeR (min-width:1px)", "container"),
Some("(min-width:1px)")
);
assert_eq!(
at_rule_prelude("container/**/(min-width:1px)", "container"),
Some("(min-width:1px)")
);
assert!(at_rule_prelude("containerfoo (min-width:1px)", "container").is_none());
assert!(at_rule_prelude("container-type (min-width:1px)", "container").is_none());
let css = r#"
@CONTAINER (min-width:1px) {
.top-level { width:1px }
}
.host {
@CoNtAiNeR (min-width:2px) { width:2px }
@containerfoo (min-width:3px) { height:3px }
}
.comment-host {
@CONTAINER/**/(min-width:5px) { height:5px }
}
@containerfoo (min-width:4px) {
.unknown-prefix { width:4px }
}
"#;
let mut conditions = condition_arena_root();
let parsed = parse_stylesheet_for_viewport_preserving_containers(
css,
(1280.0, 720.0),
&mut conditions,
ContainerConditionId::NONE,
);
assert_eq!(conditions.len(), 4);
assert_eq!(parsed.len(), 3, "unknown prefix at-rules must be dropped");
assert!(parsed.iter().any(|rule| rule.selector == ".top-level"));
assert!(parsed
.iter()
.any(|rule| { rule.selector == ".host" && rule.declarations.contains("width:2px") }));
assert!(parsed.iter().any(|rule| {
rule.selector == ".comment-host" && rule.declarations.contains("height:5px")
}));
assert!(!parsed.iter().any(|rule| {
rule.selector == ".unknown-prefix" || rule.declarations.contains("height:3px")
}));
}
#[test]
fn global_at_rules_inside_container_are_not_conditioned() {
let css = r#"
@container (min-width:10000px) {
@property --cq-token {
syntax: "";
inherits: false;
initial-value: 17px;
}
.conditional { width:999px }
}
"#;
let mut conditions = condition_arena_root();
let parsed = parse_stylesheet_for_viewport_preserving_containers(
css,
(1280.0, 720.0),
&mut conditions,
ContainerConditionId::NONE,
);
assert_eq!(conditions.len(), 2);
let registration = parsed
.iter()
.find(|rule| {
rule.selector
== format!("{PROPERTY_REGISTRATION_SELECTOR_PREFIX}--cq-token")
})
.expect("@property initial value should be registered");
assert_eq!(
registration.container_condition_id,
ContainerConditionId::NONE
);
assert!(registration.declarations.contains("initial-value: 17px"));
let conditional = parsed
.iter()
.find(|rule| rule.selector == ".conditional")
.expect("ordinary conditional rule should remain indexed");
assert_eq!(conditional.container_condition_id, ContainerConditionId(1));
assert!(parse_stylesheet(css).is_empty());
}
fn apply_registered_property_test_style(
sheet: &Stylesheet,
tree: &DomTree,
target: NodeId,
parent_props: &HashMap,
) -> (LayoutStyle, HashMap) {
let node = tree.get_node(target).unwrap();
let element = node.as_element().unwrap();
let classes = node
.get_attribute("class")
.map(|value| value.split_whitespace().map(str::to_string).collect::>())
.unwrap_or_default();
let mut matcher = tree.matcher();
let mut style = LayoutStyle::default();
let effective = sheet.apply(
tree,
&mut matcher,
target,
node.get_attribute("id"),
&classes,
element.local.as_ref(),
&mut style,
parent_props,
None,
);
(style, effective.unwrap_or_else(|| parent_props.clone()))
}
#[test]
fn registered_custom_properties_obey_inherits_descriptors() {
let tree = obscura_dom::parse_html(
r#""#,
);
let css = r#"
@property --private {
syntax:"";
inherits:false;
initial-value:75%;
}
@property --shared {
syntax:"";
inherits:true;
initial-value:25%;
}
#parent { --private:20%; --shared:30% }
#child, #initial {
width:var(--private);
height:var(--shared);
}
"#;
let sheet = Stylesheet::parse(&tree, &[css.to_string()]);
let parent = tree.get_element_by_id("parent").unwrap();
let child = tree.get_element_by_id("child").unwrap();
let initial = tree.get_element_by_id("initial").unwrap();
let (_, parent_props) =
apply_registered_property_test_style(&sheet, &tree, parent, &HashMap::new());
let (child_style, _) =
apply_registered_property_test_style(&sheet, &tree, child, &parent_props);
assert_eq!(child_style.width, crate::Dimension::Percent(0.75));
assert_eq!(child_style.height, crate::Dimension::Percent(0.30));
let (initial_style, _) =
apply_registered_property_test_style(&sheet, &tree, initial, &HashMap::new());
assert_eq!(initial_style.width, crate::Dimension::Percent(0.75));
assert_eq!(initial_style.height, crate::Dimension::Percent(0.25));
}
#[test]
fn registered_custom_property_overrides_and_var_fallbacks_stay_distinct() {
let tree = obscura_dom::parse_html(
r#""#,
);
let css = r#"
@property --registered {
syntax:"";
inherits:false;
initial-value:75%;
}
#override {
--registered:60%;
width:var(--registered, 10%);
height:var(--missing, 12%);
}
#reset {
--registered:initial;
width:var(--registered, 10%);
height:var(--missing, 12%);
}
#invalid {
--registered:red;
width:var(--registered, 10%);
}
"#;
let sheet = Stylesheet::parse(&tree, &[css.to_string()]);
let override_id = tree.get_element_by_id("override").unwrap();
let reset_id = tree.get_element_by_id("reset").unwrap();
let invalid_id = tree.get_element_by_id("invalid").unwrap();
let (overridden, _) = apply_registered_property_test_style(
&sheet,
&tree,
override_id,
&HashMap::new(),
);
assert_eq!(overridden.width, crate::Dimension::Percent(0.60));
assert_eq!(overridden.height, crate::Dimension::Percent(0.12));
let (reset, _) =
apply_registered_property_test_style(&sheet, &tree, reset_id, &HashMap::new());
assert_eq!(reset.width, crate::Dimension::Percent(0.75));
assert_eq!(reset.height, crate::Dimension::Percent(0.12));
let (invalid, invalid_props) =
apply_registered_property_test_style(&sheet, &tree, invalid_id, &HashMap::new());
assert_eq!(invalid.width, crate::Dimension::Percent(0.75));
assert_eq!(
invalid_props.get("--registered").map(String::as_str),
Some("75%"),
"an invalid typed value computes to the registered initial value"
);
}
#[test]
fn var_substitution_preserves_neighboring_token_boundaries() {
let props = HashMap::from([
("--stroke".to_string(), "2px".to_string()),
("--amount".to_string(), "10".to_string()),
]);
assert_eq!(
substitute_var_value("var(--stroke)solid", &props, 0).as_deref(),
Some("2px solid")
);
assert_eq!(
substitute_var_value("calc(var(--stroke)*3)", &props, 0).as_deref(),
Some("calc(2px*3)"),
"punctuation adjacency must not gain calc-significant whitespace"
);
assert_eq!(
substitute_var_value("calc(var(--amount)- 2px)", &props, 0).as_deref(),
Some("calc(10- 2px)"),
"substitution must not make an invalid unspaced calc minus valid"
);
assert_eq!(
substitute_var_value("+var(--amount)", &props, 0).as_deref(),
Some("+ 10"),
"a delim plus followed by a number must not become a signed number token"
);
assert_eq!(
substitute_var_value("var(--amount)%", &props, 0).as_deref(),
Some("10 %"),
"a number token followed by a percent delimiter is not a percentage token"
);
let tree = obscura_dom::parse_html(r#""#);
let css = r#"
#target {
--stroke:2px;
--ink:#123456;
border:var(--stroke)solid var(--ink);
height:calc(var(--stroke)*3);
}
"#;
let sheet = Stylesheet::parse(&tree, &[css.to_string()]);
let target = tree.get_element_by_id("target").unwrap();
let (style, _) =
apply_registered_property_test_style(&sheet, &tree, target, &HashMap::new());
assert_eq!(
style.border,
crate::Edges {
top: 2.0,
right: 2.0,
bottom: 2.0,
left: 2.0,
}
);
assert_eq!(style.border_color, Some([0x12, 0x34, 0x56, 0xff]));
assert_eq!(style.height, crate::Dimension::Px(6.0));
}
#[test]
fn wildcard_duplicate_and_invalid_property_registrations_are_bounded() {
let tree = obscura_dom::parse_html(
r#""#,
);
let css = r#"
@property --wild { syntax:"*"; inherits:false }
@property --typed {
syntax:"";
inherits:false;
initial-value:2;
}
@property --typed {
syntax:"";
initial-value:9;
}
@property --unsupported {
syntax:"";
inherits:false;
initial-value:30deg;
}
@property --last {
syntax:"";
inherits:false;
initial-value:1;
}
@property --last {
syntax:"";
inherits:false;
initial-value:3;
}
#wild { --wild:20px; width:var(--wild, 7px) }
#wild-fallback { width:var(--wild, 7px) }
#typed {
opacity:var(--typed, .1);
height:var(--unsupported, 11px);
}
"#;
let sheet = Stylesheet::parse(&tree, &[css.to_string()]);
let wild = tree.get_element_by_id("wild").unwrap();
let wild_fallback = tree.get_element_by_id("wild-fallback").unwrap();
let typed = tree.get_element_by_id("typed").unwrap();
let (wild_style, _) =
apply_registered_property_test_style(&sheet, &tree, wild, &HashMap::new());
assert_eq!(wild_style.width, crate::Dimension::Px(20.0));
let (fallback_style, _) =
apply_registered_property_test_style(&sheet, &tree, wild_fallback, &HashMap::new());
assert_eq!(fallback_style.width, crate::Dimension::Px(7.0));
let (typed_style, typed_props) =
apply_registered_property_test_style(&sheet, &tree, typed, &HashMap::new());
assert_eq!(typed_style.opacity, Some(2.0));
assert_eq!(typed_style.height, crate::Dimension::Px(11.0));
assert_eq!(
typed_props.get("--typed").map(String::as_str),
Some("2"),
"a later invalid duplicate registration must not replace the valid one"
);
assert_eq!(
typed_props.get("--last").map(String::as_str),
Some("3"),
"the last valid registration wins"
);
assert!(!typed_props.contains_key("--unsupported"));
}
#[test]
fn registered_percentage_initial_value_keeps_radial_gradient_valid() {
let tree = obscura_dom::parse_html(r#""#);
let css = r#"
@property --pulse-outer {
syntax:"";
inherits:false;
initial-value:75%;
}
#pulse {
background-image:radial-gradient(
circle at 50% 50%,
transparent var(--pulse-outer),
rgb(255 255 255) 100%
);
}
"#;
let sheet = Stylesheet::parse(&tree, &[css.to_string()]);
let pulse = tree.get_element_by_id("pulse").unwrap();
let (style, props) =
apply_registered_property_test_style(&sheet, &tree, pulse, &HashMap::new());
assert_eq!(props.get("--pulse-outer").map(String::as_str), Some("75%"));
let (_, stops) = style
.background_radial_gradient
.expect("registered initial value should preserve the gradient");
assert_eq!(stops[0].1, Some(0.75));
assert_eq!(stops[1].1, Some(1.0));
}
#[test]
fn container_boolean_grammar_rejects_mixed_operators() {
for invalid in [
"(min-width:1px) and (max-width:2px) or (min-inline-size:3px)",
"not (min-width:1px) and (max-width:2px)",
"(min-width:1px) or not (max-width:2px)",
] {
assert!(
parse_container_query_expr(invalid).is_none(),
"invalid boolean grammar was accepted: {invalid}"
);
}
assert!(matches!(
parse_container_query_expr(
"(min-width:1px) and ((max-width:2px) or (min-inline-size:3px))"
),
Some(ContainerQueryExpr::And(_))
));
assert!(matches!(
parse_container_query_expr("not ((min-width:1px) and (max-inline-size:2px))"),
Some(ContainerQueryExpr::Not(_))
));
}
#[test]
fn container_custom_ident_rejects_css_wide_and_default() {
for reserved in [
"none",
"not",
"and",
"or",
"default",
"initial",
"inherit",
"unset",
"revert",
"revert-layer",
] {
assert!(
parse_container_query_name(reserved).is_none(),
"reserved custom-ident was accepted: {reserved}"
);
if reserved != "not" {
assert!(
parse_container_query(&format!("{reserved} (min-width:1px)")).is_none(),
"reserved query name was accepted: {reserved}"
);
}
}
assert!(
matches!(
parse_container_query("not (min-width:1px)"),
Some(ContainerQuery {
name: None,
condition: Some(ContainerQueryExpr::Not(_)),
})
),
"`not` is reserved as a name but valid as the unary query operator"
);
for valid in ["auto", "normal", "container", "--card", "main"] {
assert_eq!(
parse_container_query_name(valid).as_deref(),
Some(valid),
"valid query name was rejected: {valid}"
);
}
}
#[test]
fn unknown_comma_arm_does_not_drop_supported_arm() {
let queries = parse_container_query_list("(future(foo)), main (min-width:1px)")
.expect("general-enclosed arm is valid unknown syntax");
assert_eq!(queries.len(), 2);
assert_eq!(queries[0].condition, Some(ContainerQueryExpr::Unknown));
assert_eq!(queries[1].name.as_deref(), Some("main"));
assert!(matches!(
queries[1].condition,
Some(ContainerQueryExpr::Feature(_))
));
let mut conditions = condition_arena_root();
let parsed = parse_stylesheet_for_viewport_preserving_containers(
"@container (future(foo)), main (min-width:1px) {.card{display:grid}}",
(1280.0, 720.0),
&mut conditions,
ContainerConditionId::NONE,
);
assert_eq!(parsed.len(), 1);
assert_eq!(conditions[1].alternatives.len(), 2);
}
#[test]
fn container_query_recursion_depth_is_bounded() {
let nested = format!(
"{}min-width:1px{}",
"(".repeat(MAX_CONTAINER_QUERY_DEPTH + 16),
")".repeat(MAX_CONTAINER_QUERY_DEPTH + 16)
);
assert!(parse_container_query_expr(&nested).is_none());
}
#[test]
fn supports_accepts_container_css_wide_values() {
for property in ["container", "container-name", "container-type"] {
for keyword in ["initial", "inherit", "unset", "revert", "revert-layer"] {
assert!(
supports_condition_applies(&format!("({property}:{keyword})")),
"{property}:{keyword} is a valid whole-value CSS-wide declaration"
);
}
}
}
fn evaluate_container_styles(
tree: &DomTree,
sheet: &Stylesheet,
target: NodeId,
snapshot: &ContainerSnapshot,
) -> (LayoutStyle, ContainerDecisionSignature, ContainerQueryStats) {
let node = tree.get_node(target).unwrap();
let element = node.as_element().unwrap();
let id = node.get_attribute("id");
let classes: Vec = node
.get_attribute("class")
.map(|value| value.split_whitespace().map(str::to_string).collect())
.unwrap_or_default();
let mut matcher = tree.matcher();
let mut evaluator = ContainerQueryEvaluator::new(tree, snapshot);
let mut style = LayoutStyle::default();
sheet.apply_with_container_queries(
tree,
&mut matcher,
target,
id,
&classes,
element.local.as_ref(),
&mut style,
&HashMap::new(),
None,
&mut evaluator,
);
let (signature, stats) = evaluator.finish();
(style, signature, stats)
}
fn container_box(
container_type: crate::ContainerType,
names: &[&str],
content_width: f32,
font_size: f32,
) -> ContainerBox {
ContainerBox {
container_type,
available_type: container_type,
names: names.iter().map(|name| (*name).to_string()).collect(),
content_width,
content_height: 100.0,
font_size,
}
}
#[test]
fn container_evaluator_honors_tailwind_threshold_and_cache() {
let tree = obscura_dom::parse_html(
r#""#,
);
let container = tree.get_element_by_id("container").unwrap();
let target = tree.get_element_by_id("target").unwrap();
let sheet = Stylesheet::parse(
&tree,
&[r#"
#target { width:1px; height:1px }
@container (min-width:28rem) {
#target { width:2px }
#target { height:2px }
}
"#
.to_string()],
);
let snapshot = |width| {
let mut snapshot = ContainerSnapshot {
root_font_size: 16.0,
..Default::default()
};
snapshot.boxes.insert(
container,
container_box(crate::ContainerType::InlineSize, &[], width, 16.0),
);
snapshot
};
let (below, _, _) =
evaluate_container_styles(&tree, &sheet, target, &snapshot(447.0));
assert_eq!(below.width, crate::Dimension::Px(1.0));
assert_eq!(below.height, crate::Dimension::Px(1.0));
let (at, _, stats) =
evaluate_container_styles(&tree, &sheet, target, &snapshot(448.0));
assert_eq!(at.width, crate::Dimension::Px(2.0));
assert_eq!(at.height, crate::Dimension::Px(2.0));
assert_eq!(stats.evaluations, 1);
assert!(stats.cache_hits >= 1);
}
#[test]
fn container_evaluator_matches_selector_before_ancestor_lookup() {
let tree = obscura_dom::parse_html(
r#""#,
);
let container = tree.get_element_by_id("container").unwrap();
let target = tree.get_element_by_id("target").unwrap();
let sheet = Stylesheet::parse(
&tree,
&[r#"
@container (min-width:1px) {
div[data-never-present] { width:999px }
}
"#
.to_string()],
);
let mut snapshot = ContainerSnapshot {
root_font_size: 16.0,
..Default::default()
};
snapshot.boxes.insert(
container,
container_box(crate::ContainerType::InlineSize, &[], 500.0, 16.0),
);
let (_, signature, stats) =
evaluate_container_styles(&tree, &sheet, target, &snapshot);
assert_eq!(stats.evaluations, 0);
assert_eq!(stats.ancestor_steps, 0);
assert!(signature.decisions.is_empty());
}
#[test]
fn container_evaluator_selects_nearest_eligible_named_ancestor() {
let tree = obscura_dom::parse_html(
r#""#,
);
let outer = tree.get_element_by_id("outer").unwrap();
let inner = tree.get_element_by_id("inner").unwrap();
let target = tree.get_element_by_id("target").unwrap();
let sheet = Stylesheet::parse(
&tree,
&[r#"
#target { width:1px; height:1px }
@container shell (min-width:500px) { #target { width:11px } }
@container (min-width:500px) { #target { height:22px } }
"#
.to_string()],
);
let mut snapshot = ContainerSnapshot {
root_font_size: 16.0,
..Default::default()
};
snapshot.boxes.insert(
outer,
container_box(
crate::ContainerType::InlineSize,
&["shell"],
600.0,
16.0,
),
);
snapshot.boxes.insert(
inner,
container_box(
crate::ContainerType::InlineSize,
&["other"],
300.0,
16.0,
),
);
let (style, _, stats) =
evaluate_container_styles(&tree, &sheet, target, &snapshot);
assert_eq!(style.width, crate::Dimension::Px(11.0));
assert_eq!(style.height, crate::Dimension::Px(1.0));
assert!(stats.ancestor_steps >= 3);
}
#[test]
fn container_query_em_uses_container_font_and_rem_uses_root_font() {
let tree = obscura_dom::parse_html(
r#""#,
);
let container = tree.get_element_by_id("container").unwrap();
let target = tree.get_element_by_id("target").unwrap();
let sheet = Stylesheet::parse(
&tree,
&[r#"
#target { width:1px; height:1px }
@container (min-width:30em) { #target { width:3px } }
@container (min-width:30rem) { #target { height:3px } }
"#
.to_string()],
);
let mut snapshot = ContainerSnapshot {
root_font_size: 20.0,
..Default::default()
};
snapshot.boxes.insert(
container,
container_box(crate::ContainerType::InlineSize, &[], 400.0, 10.0),
);
let (style, _, _) =
evaluate_container_styles(&tree, &sheet, target, &snapshot);
assert_eq!(style.width, crate::Dimension::Px(3.0));
assert_eq!(style.height, crate::Dimension::Px(1.0));
}
#[test]
fn container_snapshot_compares_only_axes_exposed_by_container_type() {
let mut inline_a =
container_box(crate::ContainerType::InlineSize, &[], 400.0, 16.0);
let mut inline_b = inline_a.clone();
inline_a.content_height = 100.0;
inline_b.content_height = 900.0;
assert_eq!(inline_a, inline_b);
let mut size_a = container_box(crate::ContainerType::Size, &[], 400.0, 16.0);
let mut size_b = size_a.clone();
size_a.content_height = 100.0;
size_b.content_height = 900.0;
assert_ne!(size_a, size_b);
}
#[test]
fn nested_container_conditions_select_independent_containers() {
let tree = obscura_dom::parse_html(
r#""#,
);
let outer = tree.get_element_by_id("outer").unwrap();
let inner = tree.get_element_by_id("inner").unwrap();
let target = tree.get_element_by_id("target").unwrap();
let sheet = Stylesheet::parse(
&tree,
&[r#"
#target { width:1px }
@container outer (min-width:500px) {
@container inner (min-width:200px) {
#target { width:9px }
}
}
"#
.to_string()],
);
let snapshot = |inner_width| {
let mut snapshot = ContainerSnapshot {
root_font_size: 16.0,
..Default::default()
};
snapshot.boxes.insert(
outer,
container_box(
crate::ContainerType::InlineSize,
&["outer"],
600.0,
16.0,
),
);
snapshot.boxes.insert(
inner,
container_box(
crate::ContainerType::InlineSize,
&["inner"],
inner_width,
16.0,
),
);
snapshot
};
let (matching, _, _) =
evaluate_container_styles(&tree, &sheet, target, &snapshot(200.0));
assert_eq!(matching.width, crate::Dimension::Px(9.0));
let (failing, _, _) =
evaluate_container_styles(&tree, &sheet, target, &snapshot(199.0));
assert_eq!(failing.width, crate::Dimension::Px(1.0));
}
#[test]
fn unknown_container_alternative_does_not_mask_true_alternative() {
let tree = obscura_dom::parse_html(
r#""#,
);
let container = tree.get_element_by_id("container").unwrap();
let target = tree.get_element_by_id("target").unwrap();
let sheet = Stylesheet::parse(
&tree,
&[r#"
#target { width:1px }
@container (future(foo)), (min-width:100px) {
#target { width:7px }
}
"#
.to_string()],
);
let mut snapshot = ContainerSnapshot {
root_font_size: 16.0,
..Default::default()
};
snapshot.boxes.insert(
container,
container_box(crate::ContainerType::InlineSize, &[], 100.0, 16.0),
);
let (style, _, _) =
evaluate_container_styles(&tree, &sheet, target, &snapshot);
assert_eq!(style.width, crate::Dimension::Px(7.0));
}
#[test]
fn container_query_boolean_evaluation_uses_kleene_truth_tables() {
let container =
container_box(crate::ContainerType::InlineSize, &[], 200.0, 16.0);
let min_width = |threshold| {
ContainerQueryExpr::Feature(ContainerSizeFeature {
axis: ContainerQueryAxis::Width,
comparison: ContainerQueryComparison::Min,
length: ContainerQueryLength::Px(threshold),
})
};
assert_eq!(
evaluate_container_query_expr(
&ContainerQueryExpr::Or(vec![
min_width(100.0),
ContainerQueryExpr::Unknown,
]),
&container,
16.0,
),
ContainerQueryTruth::True
);
assert_eq!(
evaluate_container_query_expr(
&ContainerQueryExpr::And(vec![
min_width(300.0),
ContainerQueryExpr::Unknown,
]),
&container,
16.0,
),
ContainerQueryTruth::False
);
assert_eq!(
evaluate_container_query_expr(
&ContainerQueryExpr::Or(vec![
min_width(300.0),
ContainerQueryExpr::Unknown,
]),
&container,
16.0,
),
ContainerQueryTruth::Unknown
);
assert_eq!(
evaluate_container_query_expr(
&ContainerQueryExpr::Not(Box::new(ContainerQueryExpr::Unknown)),
&container,
16.0,
),
ContainerQueryTruth::Unknown
);
}
#[test]
fn container_range_syntax_supports_strict_inclusive_and_chained_queries() {
let container =
container_box(crate::ContainerType::Size, &[], 200.0, 16.0);
for query in [
"(width)",
"(width > 199px)",
"(width>=200px)",
"(199px < width)",
"(199px < width <= 200px)",
"(height = 100px)",
"(block-size >= 100px)",
] {
let expression =
parse_container_query_expr(query).expect("valid range query");
assert_eq!(
evaluate_container_query_expr(&expression, &container, 16.0),
ContainerQueryTruth::True,
"{query}"
);
}
for query in [
"(width > 200px)",
"(width < 200px)",
"(200px < width < 300px)",
"(height > 100px)",
] {
let expression =
parse_container_query_expr(query).expect("valid range query");
assert_eq!(
evaluate_container_query_expr(&expression, &container, 16.0),
ContainerQueryTruth::False,
"{query}"
);
}
for invalid in [
"(100px < width > 200px)",
"(200px > width < 100px)",
"(100px = width = 100px)",
"(100px < width = 200px)",
] {
assert!(
parse_container_query_expr(invalid).is_none(),
"invalid mixed/equality chain parsed: {invalid}"
);
}
}
#[test]
fn block_axis_query_requires_size_container() {
let tree = obscura_dom::parse_html(
r#""#,
);
let container = tree.get_element_by_id("container").unwrap();
let target = tree.get_element_by_id("target").unwrap();
let sheet = Stylesheet::parse(
&tree,
&[r#"
#target { width:1px }
@container (height >= 100px) { #target { width:8px } }
"#
.to_string()],
);
let snapshot = |container_type| {
let mut snapshot = ContainerSnapshot {
root_font_size: 16.0,
..Default::default()
};
snapshot.boxes.insert(
container,
container_box(container_type, &[], 300.0, 16.0),
);
snapshot
};
let (inline_only, _, _) = evaluate_container_styles(
&tree,
&sheet,
target,
&snapshot(crate::ContainerType::InlineSize),
);
assert_eq!(inline_only.width, crate::Dimension::Px(1.0));
let (size, _, _) = evaluate_container_styles(
&tree,
&sheet,
target,
&snapshot(crate::ContainerType::Size),
);
assert_eq!(size.width, crate::Dimension::Px(8.0));
}
#[test]
fn nested_container_rules_form_a_parent_condition_chain() {
let css = "@container shell (min-width:40rem){\
@container (max-inline-size:50rem){.card{display:grid}}}";
let mut conditions = condition_arena_root();
let parsed = parse_stylesheet_for_viewport_preserving_containers(
css, (1280.0, 720.0), &mut conditions, ContainerConditionId::NONE,
);
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].container_condition_id, ContainerConditionId(2));
assert_eq!(conditions[2].parent, ContainerConditionId(1));
assert_eq!(conditions[1].parent, ContainerConditionId::NONE);
}
#[test]
fn unresolved_container_rules_do_not_enter_cascade_or_pseudos() {
let tree = obscura_dom::parse_html(r#""#);
let target = tree.query_selector("#target").unwrap().unwrap();
let sheet = Stylesheet::parse(&tree, &[r#"
#target{width:10px}
@container (min-width:28rem){
#target{width:999px}
#target::before{content:"inactive"}
}
#target{height:20px}
"#.to_string()]);
assert_eq!(sheet.rules.len(), 3, "conditional rule remains indexed");
assert_eq!(sheet.container_conditions.len(), 2);
let mut matcher = tree.matcher();
let mut style = LayoutStyle::default();
sheet.apply(
&tree, &mut matcher, target, Some("target"), &[], "div", &mut style,
&HashMap::new(), None,
);
assert_eq!(style.width, crate::Dimension::Px(10.0));
assert_eq!(style.height, crate::Dimension::Px(20.0));
let (before, after) =
sheet.pseudo_styles(&tree, &mut matcher, target, &HashMap::new(), &style);
assert!(before.is_none() && after.is_none());
}
#[test]
fn no_container_parser_output_and_order_are_unchanged() {
let css = ".card{width:10px}\
@supports (display:grid){.card{display:grid}}\
@media (min-width:64rem){.card{width:20px}}\
.card{height:30px}";
assert_eq!(
parse_stylesheet_for_viewport(css, (1280.0, 720.0)),
vec![
(".card".into(), "width:10px;".into()),
(".card".into(), "display:grid;".into()),
(".card".into(), "width:20px;".into()),
(".card".into(), "height:30px;".into()),
]
);
let mut conditions = condition_arena_root();
let rich = parse_stylesheet_for_viewport_preserving_containers(
css, (1280.0, 720.0), &mut conditions, ContainerConditionId::NONE,
);
assert_eq!(conditions.len(), 1);
assert!(rich.iter().all(|rule| rule.container_condition_id == ContainerConditionId::NONE));
}
#[test]
fn supports_conditions_gate_legacy_framework_fallbacks() {
let legacy_probe = "(((-webkit-hyphens:none)) and \
(not (margin-trim:inline))) or \
((-moz-orient:inline) and \
(not (color:rgb(from red r g b))))";
assert!(!supports_condition_applies(legacy_probe));
assert!(supports_condition_applies(
"(display:grid) and (selector(.card > *))"
));
assert!(supports_condition_applies(
"not (unknown-engine-prop:value)"
));
let css = format!(
"@supports {legacy_probe} {{ .legacy {{ line-height:1.5 }} }}\
@supports (display:grid) {{ .modern {{ display:grid }} }}\
.host {{ @supports {legacy_probe} {{ width:999px }} }}"
);
let rules = parse_stylesheet_for_viewport(&css, (1280.0, 720.0));
assert!(
!rules.iter().any(|(selector, declarations)| {
selector == ".legacy" || declarations.contains("999px")
}),
"false supports branches must be skipped: {rules:?}"
);
assert!(
rules.iter().any(|(selector, declarations)| {
selector == ".modern" && declarations.contains("display:grid")
}),
"true supports branch should remain: {rules:?}"
);
}
#[test]
fn supports_conditions_reject_invalid_boolean_grammar() {
assert!(supports_condition_applies("(display:grid) and (word-break:break-all)"));
assert!(supports_condition_applies("(display:grid) or (unknown:value)"));
assert!(!supports_condition_applies(
"(display:grid) and (word-break:break-all) or (display:flex)"
));
assert!(!supports_condition_applies(
"not ((display:grid) and (word-break:break-all) or (display:flex))"
));
assert!(!supports_condition_applies("not ()"));
assert!(!supports_condition_applies("(display:grid;)"));
assert!(!supports_condition_applies("not (display:grid"));
}
#[test]
fn supports_polygon_clip_path_activates_only_painted_shape_subset() {
let css = "@supports (clip-path:polygon(0 0,100% 0,50% 100%)){\
.transition{height:90px}\
}\
@supports (clip-path:polygon(0 0,100% 0,50% 100%) content-box){\
.unsupported{height:999px}\
}";
let rules = parse_stylesheet_for_viewport(css, (1280.0, 720.0));
assert!(
rules.iter().any(|(selector, declarations)| {
selector == ".transition" && declarations.contains("height:90px")
}),
"the supported polygon branch must enter the cascade: {rules:?}"
);
assert!(
!rules.iter().any(|(selector, _)| selector == ".unsupported"),
"an unpainted geometry-box variant must not pass @supports: {rules:?}"
);
}
#[test]
fn media_breakpoints_support_font_relative_lengths_and_ranges() {
assert!(!media_query_applies_for_viewport(
"@media (min-width: 64rem)",
(900.0, 1000.0)
));
assert!(media_query_applies_for_viewport(
"@media (min-width: 64rem)",
(1024.0, 768.0)
));
assert!(media_query_applies_for_viewport(
"@media (56.25rem <= width)",
(900.0, 1000.0)
));
assert!(!media_query_applies_for_viewport(
"@media (width > calc(60em - 1px))",
(900.0, 1000.0)
));
}
#[test]
fn negated_min_width_queries_form_max_breakpoints() {
assert!(media_query_applies_for_viewport(
"@media not all and (min-width: 40rem)",
(639.0, 900.0)
));
assert!(!media_query_applies_for_viewport(
"@media not all and (min-width: 40rem)",
(1280.0, 900.0)
));
let css = r#"
.hidden { display: none }
@media not all and (min-width: 40rem) {
.max-sm\:inline { display: inline }
}
@media (min-width: 80rem) {
.xl\:inline { display: inline }
}
"#;
let desktop = parse_stylesheet_for_viewport(css, (1280.0, 900.0));
assert!(!desktop
.iter()
.any(|(selector, _)| selector == r".max-sm\:inline"));
assert!(desktop
.iter()
.any(|(selector, _)| selector == r".xl\:inline"));
}
#[test]
fn media_query_lists_are_or_conditions() {
assert!(media_query_applies_for_viewport(
"@media print, (min-width: 64rem)",
(1280.0, 720.0)
));
assert!(!media_query_applies_for_viewport(
"@media print, (min-width: 64rem)",
(900.0, 1000.0)
));
}
#[test]
fn rem_breakpoint_does_not_reveal_desktop_menu_on_narrow_viewport() {
let css = r#"
header .menu-toolkit { display: none }
@media (min-width: 64rem) {
header .menu-toolkit { display: flex }
}
"#;
let narrow = parse_stylesheet_for_viewport(css, (900.0, 1000.0));
assert!(narrow.iter().any(|(selector, declarations)| {
selector == "header .menu-toolkit" && declarations.contains("display: none")
}));
assert!(!narrow.iter().any(|(_, declarations)| declarations.contains("display: flex")));
let wide = parse_stylesheet_for_viewport(css, (1024.0, 768.0));
assert!(wide.iter().any(|(_, declarations)| declarations.contains("display: flex")));
}
}