Files
pbakaus_impeccable/crates/html/tests/cascade_units.rs
T
Abdul WahabandClaude Fable 5.1 2422ec058f Sampled contrast: keep the sheet url rewrite inside one url token
The linked-sheet url rewrite ran one regex over the raw stylesheet text,
and its unquoted `url(` form could match across lines to a `)` in a
later rule, so an unclosed `url(` inside a comment would have replaced
the rules between them with one rewritten path, and that text feeds the
whole cascade, not only sampled contrast. Comments are now copied
through untouched, a quoted url ends at its line, and an unquoted one
stops at whitespace, quotes, parens, braces, or a semicolon, as CSS
does. Tests pin a commented `url(`, an unclosed quote, and an
unterminated comment.

AI assistance: Claude Code (Claude Fable 5.1), on maintainer instruction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 10:52:08 +05:00

393 lines
14 KiB
Rust

//! Unit tests for the cascade helpers without recorded vectors. Expected
//! values were produced by running the JS `css-cascade.mjs` in Node.
use impeccable_html::cascade::checks_shim::{resolve_length_px, resolve_var_refs, CustomProps};
use impeccable_html::cascade::rules::{
apply_static_declaration, parse_static_style_attribute, DeclMeta, SpecifiedStore,
};
use impeccable_html::cascade::values::{normalize_color_for_check, unwrap_css_at_layer};
#[test]
fn normalize_color_for_check_matches_node() {
let cases: &[(&str, &str)] = &[
("#ffffff", "rgb(255, 255, 255)"),
("#FfF", "rgb(255, 255, 255)"),
("#abc", "rgb(170, 187, 204)"),
(" #ABCDEF ", "rgb(171, 205, 239)"),
("white", "rgb(255, 255, 255)"),
("Black", "rgb(0, 0, 0)"),
("GRAY", "rgb(128, 128, 128)"),
("grey", "rgb(128, 128, 128)"),
("silver", "rgb(192, 192, 192)"),
("red", "rgb(255, 0, 0)"),
("green", "rgb(0, 128, 0)"),
("blue", "rgb(0, 0, 255)"),
("yellow", "rgb(255, 255, 0)"),
("purple", "purple"),
("rgb(1, 2, 3)", "rgb(1, 2, 3)"),
("oklch(50% 0.1 20)", "oklch(50% 0.1 20)"),
("#abcd", "#abcd"),
("#12345", "#12345"),
("", ""),
(" ", ""),
("var(--x)", "var(--x)"),
("transparent", "transparent"),
];
for (input, expected) in cases {
assert_eq!(
normalize_color_for_check(input),
*expected,
"input {:?}",
input
);
}
}
fn meta(important: bool, specificity: [u32; 3], order: i64, inline: bool) -> DeclMeta {
DeclMeta {
important,
specificity,
order,
inline,
}
}
#[test]
fn apply_static_declaration_matches_node() {
let mut specified: SpecifiedStore<&str> = SpecifiedStore::new();
let node = "n1";
let mut apply = |prop: &str, value: &str, m: DeclMeta| {
apply_static_declaration(&mut specified, node, prop, value, &m);
};
apply("margin", "0", meta(false, [0, 0, 0], 0, false));
apply("margin-top", "5px", meta(false, [0, 1, 0], 1, false));
apply("margin", "10px 20px", meta(false, [0, 0, 1], 2, false));
apply("color", "red", meta(true, [0, 0, 0], 3, false));
apply("color", "blue", meta(false, [1, 0, 0], 4, true));
apply("background", "var(--x)", meta(false, [0, 1, 0], 5, false));
apply("background", "none", meta(false, [0, 1, 0], 6, false));
apply("--brand", "#fff", meta(false, [0, 1, 0], 7, false));
apply(
"font",
"italic 700 12px/1.4 Inter, sans-serif",
meta(false, [0, 1, 0], 8, false),
);
apply("box-sizing", "border-box", meta(false, [0, 1, 0], 9, false));
apply("margin-top", "1px", meta(false, [0, 0, 0], 10, false));
apply("margin-top", "2px", meta(true, [0, 0, 0], 11, false));
apply("margin-top", "3px", meta(false, [9, 9, 9], 12, true));
apply("outline", "0", meta(false, [0, 0, 0], 13, false));
apply(
"border-left",
"3px solid teal",
meta(false, [0, 0, 0], 14, false),
);
let map = specified.get(&node).expect("node entry");
let got: Vec<(String, bool, [u32; 3], i64, bool, String)> = map
.iter()
.map(|(k, d)| {
(
k.clone(),
d.meta.important,
d.meta.specificity,
d.meta.order,
d.meta.inline,
d.value.clone(),
)
})
.collect();
let s = |x: &str| x.to_string();
let expected = vec![
(s("marginTop"), true, [0, 0, 0], 11, false, s("2px")),
(s("marginRight"), false, [0, 0, 1], 2, false, s("20px")),
(s("marginBottom"), false, [0, 0, 1], 2, false, s("10px")),
(s("marginLeft"), false, [0, 0, 1], 2, false, s("20px")),
(s("color"), true, [0, 0, 0], 3, false, s("red")),
(
s("backgroundColor"),
false,
[0, 1, 0],
6,
false,
s("rgba(0, 0, 0, 0)"),
),
(s("backgroundImage"), false, [0, 1, 0], 6, false, s("none")),
(s("--brand"), false, [0, 1, 0], 7, false, s("#fff")),
(s("fontStyle"), false, [0, 1, 0], 8, false, s("italic")),
(s("fontWeight"), false, [0, 1, 0], 8, false, s("700")),
(s("fontSize"), false, [0, 1, 0], 8, false, s("12px")),
(s("lineHeight"), false, [0, 1, 0], 8, false, s("1.4")),
(
s("fontFamily"),
false,
[0, 1, 0],
8,
false,
s("Inter, sans-serif"),
),
(s("outlineWidth"), false, [0, 0, 0], 13, false, s("0px")),
(s("borderLeftWidth"), false, [0, 0, 0], 14, false, s("3px")),
(s("borderLeftColor"), false, [0, 0, 0], 14, false, s("teal")),
];
assert_eq!(got, expected);
// prop is the expanded property name
assert_eq!(map.get("marginTop").unwrap().prop, "marginTop");
}
#[test]
fn parse_static_style_attribute_edge_cases_match_node() {
let decls = parse_static_style_attribute(
": x; a:b; c: d !important ; e:f!IMPORTANT; g; h:; :i; j:k:l",
5,
);
let got: Vec<(String, String, bool, i64)> = decls
.into_iter()
.map(|d| (d.prop, d.value, d.important, d.order))
.collect();
let s = |x: &str| x.to_string();
assert_eq!(
got,
vec![
(s("a"), s("b"), false, 5),
(s("c"), s("d"), true, 6),
(s("e"), s("f"), true, 7),
(s("h"), s(""), false, 8),
(s(""), s("i"), false, 9),
(s("j"), s("k:l"), false, 10),
]
);
}
#[test]
fn unwrap_css_at_layer_shapes() {
assert_eq!(unwrap_css_at_layer(""), "");
assert_eq!(unwrap_css_at_layer(".a{color:red}"), ".a{color:red}");
assert_eq!(
unwrap_css_at_layer("@layer base { .a{color:red} } .b{c:d}"),
" .a{color:red} .b{c:d}"
);
assert_eq!(
unwrap_css_at_layer("@layer{ .a{ .n{x:y} } }@layer a.b { .c{d:e} }"),
" .a{ .n{x:y} } .c{d:e} "
);
// statement form is untouched
assert_eq!(
unwrap_css_at_layer("@layer a, b; .x{y:z}"),
"@layer a, b; .x{y:z}"
);
// unbalanced: source unchanged
assert_eq!(
unwrap_css_at_layer("@layer x { .a{color:red}"),
"@layer x { .a{color:red}"
);
// `@layered` does not match the word boundary
assert_eq!(
unwrap_css_at_layer("@layered x { .a{c:d} }"),
"@layered x { .a{c:d} }"
);
}
#[test]
fn checks_shim_helpers() {
let mut props = CustomProps::new();
props.insert("--a".into(), "var(--b)".into());
props.insert("--b".into(), "#fff".into());
props.insert("--loop".into(), "var(--loop)".into());
assert_eq!(resolve_var_refs("var(--a)", &props), "#fff");
assert_eq!(resolve_var_refs("var( --b , red )", &props), "#fff");
assert_eq!(resolve_var_refs("var(--missing, red )", &props), "red");
assert_eq!(resolve_var_refs("var(--missing)", &props), "var(--missing)");
assert_eq!(resolve_var_refs("var(--loop)", &props), "var(--loop)");
assert_eq!(resolve_var_refs("plain", &props), "plain");
assert_eq!(resolve_length_px("normal", 16.0), None);
assert_eq!(resolve_length_px("", 16.0), None);
assert_eq!(resolve_length_px("abc", 16.0), None);
assert_eq!(resolve_length_px("12px", 16.0), Some(12.0));
assert_eq!(resolve_length_px("1.5rem", 10.0), Some(24.0));
assert_eq!(resolve_length_px("2em", 10.0), Some(20.0));
assert_eq!(resolve_length_px("50%", 10.0), Some(5.0));
assert_eq!(resolve_length_px("1.5", 10.0), Some(15.0));
}
#[test]
fn background_longhands_ride_beside_the_expansion() {
use impeccable_html::cascade::rules::apply_static_longhand;
use impeccable_html::cascade::shorthand::background_longhands;
// The expansion itself is pinned by the recorded vectors and stays
// image-and-color only; repeat and size come from the side channel.
assert_eq!(
background_longhands(
"background",
"url(a.png) center / cover no-repeat, url(b.png)"
),
vec![
(
"backgroundRepeat".to_string(),
"no-repeat, repeat".to_string()
),
("backgroundSize".to_string(), "cover, auto".to_string()),
]
);
// The css-tree generator glues the first keyword to the call.
assert_eq!(
background_longhands("background", "url(a.png)center/cover no-repeat"),
vec![
("backgroundRepeat".to_string(), "no-repeat".to_string()),
("backgroundSize".to_string(), "cover".to_string()),
]
);
assert_eq!(
background_longhands("background", "#fff url(a.png) repeat-x"),
vec![
("backgroundRepeat".to_string(), "repeat-x".to_string()),
("backgroundSize".to_string(), "auto".to_string()),
]
);
assert_eq!(
background_longhands("Background-Size", "100% 32px"),
vec![("backgroundSize".to_string(), "100% 32px".to_string())]
);
// A color-only shorthand still resets both longhands, as in CSS.
assert_eq!(
background_longhands("background", "#fff"),
vec![
("backgroundRepeat".to_string(), "repeat".to_string()),
("backgroundSize".to_string(), "auto".to_string()),
]
);
assert_eq!(
background_longhands("background", "Inherit"),
vec![
("backgroundRepeat".to_string(), "inherit".to_string()),
("backgroundSize".to_string(), "inherit".to_string()),
]
);
assert!(background_longhands("background", "var(--surface)").is_empty());
assert!(background_longhands("color", "red").is_empty());
// A later shorthand with an image resets an earlier longhand, and a
// later longhand overrides a shorthand, under the cascade's priority.
let mut specified: SpecifiedStore<&str> = SpecifiedStore::new();
let node = "n1";
let apply = |specified: &mut SpecifiedStore<&str>, prop: &str, value: &str, m: DeclMeta| {
apply_static_declaration(specified, node, prop, value, &m);
for (p, v) in background_longhands(prop, value) {
apply_static_longhand(specified, node, &p, &v, &m);
}
};
apply(
&mut specified,
"background-repeat",
"no-repeat",
meta(false, [0, 1, 0], 0, false),
);
apply(
&mut specified,
"background",
"url(hero.jpg) center / cover",
meta(false, [0, 1, 0], 1, false),
);
apply(
&mut specified,
"background-size",
"contain",
meta(false, [0, 1, 0], 2, false),
);
let map = specified.get(&node).expect("node entry");
assert_eq!(
map.get("backgroundRepeat").map(|d| d.value.as_str()),
Some("repeat")
);
assert_eq!(
map.get("backgroundSize").map(|d| d.value.as_str()),
Some("contain")
);
assert_eq!(
map.get("backgroundImage").map(|d| d.value.as_str()),
Some("url(hero.jpg) center / cover")
);
// `background: #fff` after a longhand resets it, so a later
// `background-image` is classified with the defaults, not a stale value.
apply(
&mut specified,
"background-repeat",
"no-repeat",
meta(false, [0, 1, 0], 3, false),
);
apply(
&mut specified,
"background",
"#fff",
meta(false, [0, 1, 0], 4, false),
);
let map = specified.get(&node).expect("node entry");
assert_eq!(
map.get("backgroundRepeat").map(|d| d.value.as_str()),
Some("repeat")
);
assert_eq!(
map.get("backgroundSize").map(|d| d.value.as_str()),
Some("auto")
);
}
#[test]
fn linked_sheet_urls_are_rewritten_page_relative() {
use impeccable_html::cascade::build::rewrite_sheet_urls;
let css = concat!(
".a { background: url(light.png) }\n",
".b { background: url(\"../img/hero.jpg?v=3\") no-repeat }\n",
".c { background-image: url('./x.webp'), url(/root.png), url(data:image/png;base64,AAAA) }\n",
".d { background: url(https://cdn.example.com/a.png) }\n",
".e { mask: url(#clip) }\n",
".f { background: URL( a b.png ) }\n",
);
let out = rewrite_sheet_urls(css, "/site/css", "/site");
assert!(
out.contains(".a { background: url(css/light.png) }"),
"{out}"
);
assert!(
out.contains(".b { background: url(\"img/hero.jpg?v=3\") no-repeat }"),
"{out}"
);
assert!(
out.contains("url('css/x.webp'), url(/root.png), url(data:image/png;base64,AAAA)"),
"{out}"
);
assert!(out.contains("url(https://cdn.example.com/a.png)"), "{out}");
assert!(out.contains("url(#clip)"), "{out}");
// An unquoted url with a space is not a url token in CSS; it stays put.
assert!(out.contains("URL( a b.png )"), "{out}");
// A sheet beside the page keeps every path where it was (the engine
// does not even call the rewrite for that case).
let same = rewrite_sheet_urls(css, "/site", "/site");
assert!(same.contains(".a { background: url(light.png) }"), "{same}");
assert!(same.contains("url(\"../img/hero.jpg?v=3\")"), "{same}");
// A sheet above the page walks back up.
let up = rewrite_sheet_urls(".a { background: url(light.png) }", "/site", "/site/pages");
assert_eq!(up, ".a { background: url(../light.png) }");
// A stray `url(` in a comment, or an unclosed quote, never swallows the
// rules after it: comments pass through untouched and a url form ends
// where CSS says it ends.
let hazards = concat!(
"/* see url( for details */ .g { color: red }\n",
".h { background: url(a.png) }\n",
".i { background: url(\"oops }\n",
".j { background: url(b.png) }\n",
"/* url(unterminated.png",
);
let out = rewrite_sheet_urls(hazards, "/site/css", "/site");
assert!(
out.contains("/* see url( for details */ .g { color: red }"),
"{out}"
);
assert!(out.contains(".h { background: url(css/a.png) }"), "{out}");
assert!(out.contains(".i { background: url(\"oops }"), "{out}");
assert!(out.contains(".j { background: url(css/b.png) }"), "{out}");
assert!(out.ends_with("/* url(unterminated.png"), "{out}");
}