#![cfg(all(not(feature = "mini"), not(feature = "embedded"), not(target_arch = "wasm32")))]
use rust_widgets::theme::theme_test_guard;
use rust_widgets::widget::census::{
census_all_controls, install_preset_appearances, ControlCensus, CENSUS_RECT, CENSUS_TEXT,
};
const KNOWN_INVISIBLE: &[&str] = &[];
const KNOWN_THEME_BLIND: &[&str] = &[];
fn surface_coincidence_exemptions() -> Vec<String> {
let Ok(text) = std::fs::read_to_string("tools/control_surface_coincidence_exemptions.txt")
else {
return Vec::new();
};
text.lines()
.filter(|line| !line.trim_start().starts_with('#'))
.filter_map(|line| line.split_whitespace().next())
.map(str::to_string)
.collect()
}
fn data_color_exemptions() -> Vec<String> {
let Ok(text) = std::fs::read_to_string("tools/control_color_exemptions.txt") else {
return Vec::new();
};
text.lines()
.filter(|line| !line.trim_start().starts_with('#'))
.filter_map(|line| line.split_whitespace().next())
.map(str::to_string)
.collect()
}
fn overflow_exemptions() -> Vec<String> {
let Ok(text) = std::fs::read_to_string("tools/control_overflow_exemptions.txt") else {
return Vec::new();
};
text.lines()
.filter(|line| !line.trim_start().starts_with('#'))
.filter_map(|line| line.split_whitespace().next())
.map(str::to_string)
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct ElementBounds {
left: f32,
top: f32,
right: f32,
bottom: f32,
}
fn element_bounds(svg: &str) -> Vec<(String, Option<ElementBounds>)> {
let mut out = Vec::new();
for raw in svg.lines() {
let line = raw.trim();
let tag = line.split([' ', '>', '/']).next().unwrap_or("");
match tag {
"<rect" => {
let (x, y) = (attr(line, "x="), attr(line, "y="));
let (w, h) = (attr(line, "width="), attr(line, "height="));
let (Some(x), Some(y), Some(w), Some(h)) = (x, y, w, h) else {
out.push((line.to_string(), None));
continue;
};
out.push((line.to_string(), Some(bounds(x as f32, y as f32, w as f32, h as f32))));
}
"<circle" => {
let (Some(cx), Some(cy), Some(r)) =
(attr(line, "cx="), attr(line, "cy="), attr(line, "r="))
else {
out.push((line.to_string(), None));
continue;
};
let (cx, cy, r) = (cx as f32, cy as f32, r as f32);
let stroke = attr(line, "stroke-width=").map(|w| w as f32 / 2.0).unwrap_or(0.0);
out.push((
line.to_string(),
Some(bounds(
cx - r - stroke,
cy - r - stroke,
r * 2.0 + stroke * 2.0,
r * 2.0 + stroke * 2.0,
)),
));
}
"<line" => {
let (x1, y1) = (attr(line, "x1="), attr(line, "y1="));
let (x2, y2) = (attr(line, "x2="), attr(line, "y2="));
let (Some(x1), Some(y1), Some(x2), Some(y2)) = (x1, y1, x2, y2) else {
out.push((line.to_string(), None));
continue;
};
let (x1, y1, x2, y2) = (x1 as f32, y1 as f32, x2 as f32, y2 as f32);
let half_stroke =
attr(line, "stroke-width=").map(|w| w as f32 / 2.0).unwrap_or(1.0);
out.push((
line.to_string(),
Some(bounds(
x1.min(x2) - half_stroke,
y1.min(y2) - half_stroke,
(x2 - x1).abs() + half_stroke * 2.0,
(y2 - y1).abs() + half_stroke * 2.0,
)),
));
}
"<text" => {
let (Some(x), Some(y)) = (attr(line, "x="), attr(line, "y=")) else {
out.push((line.to_string(), None));
continue;
};
let size = attr(line, "font-size=").map(|s| s as f32).unwrap_or(14.0);
let label = element_text(line);
let advance: f32 = label.chars().map(|ch| glyph_advance(ch, size)).sum();
out.push((
line.to_string(),
Some(bounds(x as f32, y as f32, advance.max(glyph_advance('M', size)), size)),
));
}
"<path" => {
let Some(d) = attr_text(line, "d=") else {
out.push((line.to_string(), None));
continue;
};
match path_bounds(&d) {
Some(b) => out.push((line.to_string(), Some(b))),
None => out.push((line.to_string(), None)),
}
}
_ => {}
}
}
out
}
fn bounds(x: f32, y: f32, w: f32, h: f32) -> ElementBounds {
ElementBounds { left: x, top: y, right: x + w, bottom: y + h }
}
fn glyph_advance(ch: char, size: f32) -> f32 {
if ch.is_whitespace() {
(size * 0.33).max(1.0)
} else if is_wide_scalar(ch) {
size.max(1.0)
} else {
(size * 0.6).max(1.0)
}
}
fn is_wide_scalar(ch: char) -> bool {
let code = ch as u32;
matches!(code,
0x1100..=0x115F | 0x2E80..=0x303E | 0x3041..=0x33FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xA000..=0xA4CF | 0xAC00..=0xD7A3 | 0xF900..=0xFAFF | 0xFE30..=0xFE6F | 0xFF00..=0xFF60 | 0xFFE0..=0xFFE6
| 0x1F300..=0x1FAFF )
}
fn attr(line: &str, prefix: &str) -> Option<f64> {
attr_text(line, prefix)?.parse::<f64>().ok()
}
fn attr_text(line: &str, prefix: &str) -> Option<String> {
let start = line.find(prefix)? + prefix.len();
let rest = line[start..].trim_start_matches('"');
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
fn element_text(line: &str) -> String {
let Some(open) = line.find('>') else {
return String::new();
};
let rest = &line[open + 1..];
match rest.find("</text>") {
Some(end) => rest[..end].to_string(),
None => rest.to_string(),
}
}
fn path_bounds(d: &str) -> Option<ElementBounds> {
let tokens: Vec<&str> = d.split_whitespace().collect();
let mut xs: Vec<f32> = Vec::new();
let mut ys: Vec<f32> = Vec::new();
let mut index = 0usize;
while index < tokens.len() {
match tokens[index] {
"M" | "L" => {
let (x, y) = (tokens.get(index + 1)?, tokens.get(index + 2)?);
xs.push(x.parse::<f32>().ok()?);
ys.push(y.parse::<f32>().ok()?);
index += 3;
}
"A" => {
let (rx, ry) = (tokens.get(index + 1)?, tokens.get(index + 2)?);
let (x, y) = (tokens.get(index + 6)?, tokens.get(index + 7)?);
let (rx, ry) = (rx.parse::<f32>().ok()?, ry.parse::<f32>().ok()?);
let (x, y) = (x.parse::<f32>().ok()?, y.parse::<f32>().ok()?);
xs.push(x - rx.abs());
xs.push(x + rx.abs());
ys.push(y - ry.abs());
ys.push(y + ry.abs());
index += 8;
}
_ => index += 1,
}
}
if xs.is_empty() || ys.is_empty() {
return None;
}
let left = xs.iter().copied().fold(f32::INFINITY, f32::min);
let right = xs.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let top = ys.iter().copied().fold(f32::INFINITY, f32::min);
let bottom = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max);
Some(ElementBounds { left, top, right, bottom })
}
#[test]
fn p5_every_control_paints_inside_its_own_box() {
let _guard = theme_test_guard();
install_preset_appearances();
let factory = rust_widgets::widget::WidgetFactory::new_with_defaults();
let exemptions = overflow_exemptions();
let mut escaped: Vec<String> = Vec::new();
let mut unmeasurable: Vec<String> = Vec::new();
let mut no_longer_overflowing: Vec<String> = Vec::new();
for name in factory.widget_names() {
rust_widgets::theme::global_theme_manager()
.set_appearance(rust_widgets::theme::AppearanceMode::Dark);
let Some(mut widget) = factory.create(name, CENSUS_RECT, CENSUS_TEXT) else {
continue;
};
rust_widgets::theme::apply_theme_to_widget(widget.as_mut());
let Some(drawable) = rust_widgets::widget::draw_bridge::draw_of(widget.as_mut()) else {
continue;
};
let svg = rust_widgets::widget::svg::render_widget_to_svg(drawable, CENSUS_RECT);
let slack = 0.5f32;
let left = CENSUS_RECT.x as f32 - slack;
let top = CENSUS_RECT.y as f32 - slack;
let right = (CENSUS_RECT.x + CENSUS_RECT.width as i32) as f32 + slack;
let bottom = (CENSUS_RECT.y + CENSUS_RECT.height as i32) as f32 + slack;
let exempt = exemptions.iter().any(|exempted| exempted == name);
let mut overflowed = false;
for (element, element_bounds) in element_bounds(&svg) {
let Some(b) = element_bounds else {
unmeasurable.push(format!("{name}: {element}"));
continue;
};
if b.left < left || b.top < top || b.right > right || b.bottom > bottom {
overflowed = true;
if !exempt {
escaped.push(format!(
"{name}: [{:.0},{:.0}..{:.0},{:.0}] escapes the box \n {}",
b.left, b.top, b.right, b.bottom, element
));
}
}
}
if exempt && !overflowed {
no_longer_overflowing.push(name.to_string());
}
}
assert!(
unmeasurable.is_empty(),
"these elements' bounds could not be determined, so this assertion cannot claim \
they are inside the control — teach `element_bounds` the element rather than \
letting it pass unmeasured (a skipped input is not a passing one):\n{}",
unmeasurable.join("\n")
);
assert!(
escaped.is_empty(),
"these controls paint outside their own rectangle. The raster backends clip it \
away, so P1-P4 cannot see it; the SVG snapshot shows it as drawing that leaves \
the picture. A geometry built from the wrong coordinate space (a child-space \
rect used as an absolute one, or a label centred on an edge instead of inside) \
is the usual cause:\n{}",
escaped.join("\n")
);
assert!(
no_longer_overflowing.is_empty(),
"these controls no longer paint outside their box, so their P5 exemption is now \
a blanket permission for a future regression — remove them from \
tools/control_overflow_exemptions.txt: {no_longer_overflowing:?}"
);
}
fn census() -> Vec<ControlCensus> {
let _guard = theme_test_guard();
install_preset_appearances();
census_all_controls()
}
#[test]
fn every_published_control_is_measured() {
let rows = census();
let expected = rust_widgets::widget::WidgetFactory::new_with_defaults().widget_names().len();
assert_eq!(
rows.len(),
expected,
"the census must cover every registered control, not a sample"
);
let mut names: Vec<&str> = rows.iter().map(|row| row.name).collect();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), rows.len(), "each control must appear exactly once");
}
#[test]
fn p1_every_control_paints_something_unless_known_invisible() {
let rows = census();
let mut unexpected = Vec::new();
for row in &rows {
let invisible = !row.light.paints_anything();
let known = KNOWN_INVISIBLE.contains(&row.name);
if invisible && !known {
unexpected.push(row.name);
}
}
assert!(
unexpected.is_empty(),
"these controls painted nothing and are not recorded as known-invisible: {unexpected:?}"
);
let mut fixed = Vec::new();
for name in KNOWN_INVISIBLE {
if let Some(row) = rows.iter().find(|row| row.name == *name) {
if row.light.paints_anything() {
fixed.push(*name);
}
}
}
assert!(
fixed.is_empty(),
"these controls now paint but are still listed as invisible — remove them: {fixed:?}"
);
}
#[test]
fn p2_every_visible_control_differs_from_its_background() {
let rows = census();
let surface_exemptions = surface_coincidence_exemptions();
let mut failures = Vec::new();
let mut unexpectedly_exempt = Vec::new();
for row in &rows {
if !row.light.paints_anything() {
continue;
}
let exempt = surface_exemptions.iter().any(|name| name == row.name);
let visible = row.visible_against_its_surface();
if !visible && !exempt {
failures.push(row.name);
}
if visible && exempt {
unexpectedly_exempt.push(row.name);
}
}
assert!(
failures.is_empty(),
"these controls paint, but only in the colour of the surface they sit on, \
so the user cannot see them: {failures:?}"
);
assert!(
unexpectedly_exempt.is_empty(),
"these controls are visible against their surface and no longer need a \
P2 exemption — remove them from \
tools/control_surface_coincidence_exemptions.txt: {unexpectedly_exempt:?}"
);
}
#[test]
fn p3_chrome_follows_the_appearance_unless_exempted_as_data() {
let rows = census();
let exemptions = data_color_exemptions();
assert!(
!exemptions.is_empty(),
"the exemption table must be readable; an empty table would silently \
exempt nothing and every data-coloured chart would fail"
);
for forbidden in ["banner", "calendar", "progress_dialog", "message_box"] {
assert!(
!exemptions.iter().any(|name| name == forbidden),
"{forbidden} carries a semantic colour and must not be data-exempt"
);
}
let mut unexpected = Vec::new();
for row in &rows {
if exemptions.iter().any(|name| name == row.name) {
continue;
}
if !row.differs_between_appearances() && !KNOWN_THEME_BLIND.contains(&row.name) {
unexpected.push(row.name);
}
}
assert!(
unexpected.is_empty(),
"these controls render identically in light and dark and are not recorded \
as theme-blind nor exempted as data colours: {unexpected:?}"
);
let mut stale = Vec::new();
for name in &exemptions {
let Some(row) = rows.iter().find(|row| row.name == name) else {
continue;
};
if row.differs_between_appearances() {
stale.push(name.clone());
}
}
assert!(
stale.is_empty(),
"these data-colour exemptions are no longer needed — the control now follows \
the appearance, so remove them from tools/control_color_exemptions.txt: {stale:?}"
);
}
#[test]
fn p4_every_semantic_token_is_consumed_and_moves_with_the_appearance() {
let rows = census();
let banner = rows
.iter()
.find(|row| row.name == "banner")
.expect("the banner control must be registered");
assert!(
banner.light.paints_anything(),
"the banner must paint, or the semantic tokens have no visible consumer"
);
assert!(
banner.differs_between_appearances(),
"the banner must change with the appearance, which proves it reads a token \
rather than a literal"
);
let _guard = theme_test_guard();
install_preset_appearances();
let mut manager = rust_widgets::theme::global_theme_manager();
manager.set_appearance(rust_widgets::theme::AppearanceMode::Light);
let light: Vec<_> = rust_widgets::theme::SemanticColor::ALL
.iter()
.map(|token| token.of(manager.current_theme().expect("a theme is active")))
.collect();
manager.set_appearance(rust_widgets::theme::AppearanceMode::Dark);
let dark: Vec<_> = rust_widgets::theme::SemanticColor::ALL
.iter()
.map(|token| token.of(manager.current_theme().expect("a theme is active")))
.collect();
for (index, token) in rust_widgets::theme::SemanticColor::ALL.iter().enumerate() {
assert_ne!(
light[index],
dark[index],
"semantic token `{}` resolves to the same colour in light and dark, so a \
control reading it could not respond to a theme switch",
token.token()
);
}
}
#[test]
fn known_theme_blind_matches_the_census() {
let rows = census();
let exemptions = data_color_exemptions();
let mut fixed = Vec::new();
for name in KNOWN_THEME_BLIND {
let Some(row) = rows.iter().find(|row| row.name == *name) else {
continue;
};
if row.differs_between_appearances() {
fixed.push(*name);
}
}
assert!(
fixed.is_empty(),
"these controls now follow the appearance but are still listed as \
theme-blind — remove them: {fixed:?}"
);
let mut regressed = Vec::new();
for row in &rows {
if exemptions.iter().any(|name| name == row.name) || KNOWN_THEME_BLIND.contains(&row.name) {
continue;
}
if row.light.paints_anything() && !row.differs_between_appearances() {
regressed.push(row.name);
}
}
assert!(regressed.is_empty(), "these controls stopped following the appearance: {regressed:?}");
}