//! The templates themselves, rendered against the themes the image ships. //! //! The unit tests cover the renderer; this covers the tree it renders. Without //! it a mistyped token in a config file is caught by `podman build`, which is //! the slowest feedback loop in the project and the one least likely to be run //! before a commit. use std::path::{Path, PathBuf}; use makeover::Rgb; use skelgen::{Palette, render, theme_directive}; fn repo() -> PathBuf { // crates/skelgen -> the repo root. Path::new(env!("CARGO_MANIFEST_DIR")) .ancestors() .nth(2) .expect("skelgen lives two levels under the repo root") .to_path_buf() } fn palette(id: &str) -> Palette { let dir = makeover::bundled_themes_dir().expect("makeover bundles its themes"); let theme = makeover::load_theme(&[(dir, false)], id).unwrap_or_else(|e| panic!("{id}: {e}")); Palette::new(id, &theme).unwrap_or_else(|e| panic!("{id}: {e}")) } fn templates() -> Vec { fn walk(dir: &Path, out: &mut Vec) { for entry in std::fs::read_dir(dir).expect("template tree is readable") { let path = entry.expect("readable entry").path(); if path.is_dir() { walk(&path, out); } else if path.extension().is_some_and(|e| e == "in") { out.push(path); } } } let mut out = Vec::new(); walk(&repo().join("templates"), &mut out); out.sort(); assert!(!out.is_empty(), "found no templates under templates/"); out } /// Every template renders, against every theme its directive names. /// /// A `variants` template is rendered once per name here, the same fan-out the /// build does, because a token that resolves on one polarity and not the other /// is a file that only breaks after dark. #[test] fn the_whole_skeleton_renders() { let default = palette("akari-dawn"); let night = palette("akari-night"); for path in templates() { let text = std::fs::read_to_string(&path).expect("template is readable"); let (directive, body) = theme_directive(&text).unwrap_or_else(|e| panic!("{}: {e:#}", path.display())); let target = path.to_string_lossy().trim_end_matches(".in").to_string(); let renders = directive .renders(&target) .unwrap_or_else(|e| panic!("{}: {e:#}", path.display())); for (theme, out_path) in renders { let palette = match theme.as_str() { "default" => &default, "night" => &night, other => panic!("{}: unknown theme `{other}`", path.display()), }; let out = render(&body, palette).unwrap_or_else(|e| panic!("{out_path}: {e:#}")); assert!( !out.contains("@{"), "{out_path}: an expression survived rendering" ); } } } /// No template smuggles a hex literal past the generator. /// /// The whole point of the tree is that colors come from the theme. A literal is /// not always wrong — a comment can quote one, and the Helix header does — so /// this checks the lines that assign rather than every line. /// /// Both spellings, because one of them was a blind spot the size of the /// lockscreen: swaylock and imv take their colors *bare*, with no `#`, so this /// test would have passed their literals even once the files became templates. /// `#`-prefixed /// is checked against the whole line and bare against the code before any /// comment, which is not an inconsistency: `#` is both the hex prefix and the /// comment character, so a `#rrggbb` value *is* everything after a `#` and /// cutting there would throw the value away. #[test] fn no_template_assigns_a_hex_literal() { for path in templates() { let text = std::fs::read_to_string(&path).expect("template is readable"); for (n, line) in text.lines().enumerate() { let code = line.split('#').next().unwrap_or(""); let is_comment = line.trim_start().starts_with('#') || line.trim_start().starts_with("/*"); if is_comment || !code.contains('=') { continue; } let word = |w: &str| { w.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '#') .to_string() }; let prefixed = line .split_whitespace() .find(|w| word(w).len() == 7 && w.contains('#')); // Six or eight digits, `rrggbb` and swaylock's `rrggbbaa`. At least // one a-f, so a plain decimal that happens to be six digits long is // not a color: `indicator-radius=90` is fine, and so would be // `font-size=100000`. let bare = code.split_whitespace().map(word).find(|w| { matches!(w.len(), 6 | 8) && w.bytes().all(|b| b.is_ascii_hexdigit()) && w.bytes().any(|b| b.is_ascii_alphabetic()) }); assert!( prefixed.is_none() && bare.is_none(), "{}:{}: hex literal in an assignment: {line}", path.display(), n + 1 ); } } } /// The Helix palettes stay legible on both polarities. /// /// This is the check the hand-authored pair had no way to run, and the one that /// caught the two real regressions in deriving them: `comment` resolving to a /// near-foreground tone on the dark theme, and `bright-white` landing lighter /// than the page on the light one. #[test] fn every_helix_foreground_reads_against_its_background() { for (theme, file) in [ ("akari-dawn", "akari-dawn.toml.in"), ("akari-night", "akari-night.toml.in"), ] { let path = repo() .join("templates/etc/skel/.config/helix/themes") .join(file); let text = std::fs::read_to_string(&path).expect("helix template is readable"); let (_, body) = theme_directive(&text).expect("helix template has a valid directive"); let out = render(&body, &palette(theme)).expect("helix template renders"); let entries = palette_entries(&out); let background = entries .iter() .find(|(k, _)| k == "background") .map(|(_, v)| *v) .expect("the palette declares a background"); // Only the tones the highlight rules actually put in a foreground. A // palette entry nothing references cannot be illegible, and several are // unreferenced — `bright-white` is declared for completeness and used // nowhere. Reading the rules rather than listing the fills by hand also // means this keeps working when a rule changes which tone it uses. let used = foreground_names(&out); assert!( used.len() > 10, "{theme}: only found {} foreground names; the parser is wrong", used.len() ); // Three exemptions, and the reason differs. // // `border` is a divider rule, and docs/TOKENS.md names `border-subtle` // a decorative divider on purpose — a separator that met AA-UI would be // a line, not a hairline. // // `yellow` and `amber` are one value, the theme's authored // `status.warning`, spent on the diff gutter. TOKENS.md's // "accent-on-glyph, not on body text" rule covers exactly this. It is // 2.80:1 on Akari Dawn's page, which is short even of AA-UI: a property // of the authored theme rather than of the derivation, and the same // value the hand-written file shipped. Worth fixing in the theme, not // here. // // Everything else clears 3.0 on both polarities. let decorative = ["border", "yellow", "amber"]; for (key, value) in &entries { if !used.contains(key) || decorative.contains(&key.as_str()) { continue; } let contrast = makeover::wcag_contrast(*value, background); assert!( contrast >= 3.0, "{theme}: `{key}` = {} is only {contrast:.2}:1 on the background", value.to_hex() ); } } } /// Palette names the highlight rules use as a foreground. /// /// Helix writes a foreground three ways: `fg = "name"`, a bare `"name"` as the /// whole value, and inside an inline table alongside a `bg`. All three land in /// the same place, so this reads the rule half of the file and collects every /// quoted name that is not sitting behind a `bg =`. fn foreground_names(rendered: &str) -> Vec { let rules = rendered .split_once("[palette]") .map_or(rendered, |(rules, _)| rules); let mut names = Vec::new(); for line in rules.lines() { let line = line.trim(); if line.starts_with('#') || !line.contains('=') { continue; } let Some((_, value)) = line.split_once('=') else { continue; }; let value = value.trim(); if let Some(inner) = value.strip_prefix('{').and_then(|v| v.strip_suffix('}')) { // A rule that sets its own background is contrasted against *that*, // not against the page — `ui.statusline.insert` puts the page color // on an accent chip, which is correct and would fail a check // against the page. Only rules that let the page show through are // this test's business. if inner.contains("bg") { continue; } for field in inner.split(',') { let Some((key, name)) = field.split_once('=') else { continue; }; if key.trim() == "fg" && let Some(name) = name.split('"').nth(1) { names.push(name.to_string()); } } } else if let Some(name) = value.split('"').nth(1) { // A bare value is a foreground. names.push(name.to_string()); } } names.sort(); names.dedup(); names } fn palette_entries(rendered: &str) -> Vec<(String, Rgb)> { let (_, palette) = rendered .split_once("[palette]") .expect("a helix theme ends with its palette"); palette .lines() .filter_map(|line| { let line = line.trim(); if line.starts_with('#') { return None; } let (key, value) = line.split_once('=')?; let value = value.trim().trim_matches('"'); Some((key.trim().to_string(), Rgb::from_hex(value)?)) }) .collect() }