Files
pbakaus_impeccable/crates/html/tests/cascade_units.rs
T
Abdul WahabandClaude Fable 5.1 4c5f3974b3 Sampled contrast: comment scan honors CSS strings and url tokens
The sheet url rewrite skipped comments by looking for `/*` in the raw
text, which is not how the CSS tokenizer sees it: a `/*` inside a quoted
string or an unquoted url() is content, so `content: "a/*b"` turned the
rest of the sheet into a comment and its relative urls stayed
sheet-relative, where the sampler could not find them. The scan is a
small state machine now (code, string, unquoted url) that mirrors the
tokenizer: a string ends at its quote or at a raw newline, a url at its
closing paren, and only a `/*` in code opens a comment. Tests pin a
string with a marker, a url with a marker, and a real comment after a
string.

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

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

410 lines
15 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}");
// A `/*` inside a string or an unquoted url is content, not a comment,
// so the rules after it are still rewritten.
let strings = concat!(
".k { content: \"a/*b\"; background: url(k.png) }\n",
".l { background: url(l/*.png) }\n",
".m { content: 'c/*d'; } /* real url( */ .n { background: url(n.png) }\n",
);
let out = rewrite_sheet_urls(strings, "/site/css", "/site");
assert!(
out.contains(".k { content: \"a/*b\"; background: url(css/k.png) }"),
"{out}"
);
assert!(out.contains(".l { background: url(css/l/*.png) }"), "{out}");
assert!(
out.contains("/* real url( */ .n { background: url(css/n.png) }"),
"{out}"
);
}