//! Every class this renderer emits is either one makeover defines or one this //! renderer declares as its own, and nothing in between. //! //! # Why this test exists //! //! makeover-webview 0.27.0 exists because this crate spelled `tabs`, `segmented` //! and `option` itself while makeover's rules key off `tab`, `segment` and //! `toggle`. Every described selector rendered flat: no depth, no focus ring, no //! chosen state. The CSS stayed valid, the markup stayed valid, and the two //! simply did not meet. Nothing failed, so it shipped, and it was found by //! reading rather than by building. //! //! The same shape produced `tone-info` / `tone-success` / `tone-warning` / //! `tone-danger` as classes when makeover keys tone off `data-tone`, so every //! toned thing arrived with a class no stylesheet in the tree had heard of. //! //! Both were a name invented in this crate that makeover already answered for. //! Neither could be caught by a type, because both sides are strings. //! //! # How it reads //! //! Class names reach the output through exactly three places -- `class_attr`, //! which writes the attribute, `class_into`, which writes one prefixed name //! into a buffer, and `makeover_webview::class`, which returns one as a value //! -- so the literals handed to those three are the whole surface. This test //! reads them out of the source rather than out of rendered HTML: rendering //! covers what the test author remembered to describe, and the failure being //! guarded against is a name nobody thought about. //! //! `class_into` is read and `makeover_webview::push_class` is not, which is why //! this crate calls the former. A name spelled at a `push_class` call would be //! skipped silently: the reader drops `class(` preceded by an identifier //! character, because `option_class(`, `part_class(` and `cell_part_class(` all //! end that way and are makeover answering rather than a literal. //! //! What it does not read: a name returned by a helper rather than handed to one //! of the three. `Webview::arrangement_class` and `Webview::measure_class` each //! match a description value to a `&'static str`, and those names -- the //! `list-detail` and `measure-wide` sets -- reach the output through a //! `class_into` call whose argument is the helper. Both sets are this //! renderer's, neither has ever been checked here, and closing that is its own //! change rather than a line in RENDERER_OWN. //! //! A literal that is neither makeover's nor declared below fails. Adding one to //! [`RENDERER_OWN`] is the deliberate act the 0.27.0 defect skipped. use std::collections::BTreeSet; /// Classes this renderer owns, with makeover answering for none of them. /// /// Two kinds, and both are legitimately not makeover's: /// /// - **Behavioural hooks.** `row-select`, `row-activate`, `table-sort`, /// `chip-remove` and `field-writes` are what the transport binds to. They name /// what an element *does* on this host, which is a webview concern rather than /// a description one, and makeover deliberately names no behaviour. /// - **Structure this renderer assembles.** `region`, `selector`, `figures`, /// `form`, `rest` and the rest name containers makeover's vocabulary has no /// word for because nothing else needs one: makeover styles the things inside /// them. /// /// Anything added here should be one of those two. A name that describes how a /// thing *looks* belongs in makeover, and putting it here is how the drift this /// test exists to catch would come back wearing a licence. const RENDERER_OWN: &[&str] = &[ "act-submit", "chip-remove", "field-writes", "figure-act", "figures", "form", "heading", "notices", "region", "rest", "rest-more", "rich", "row-activate", "row-menu", "row-select", "selector", "table-sort", "text", ]; /// The source files that can name a class. const SOURCES: &[(&str, &str)] = &[ ("src/node.rs", include_str!("../src/node.rs")), ("src/lib.rs", include_str!("../src/lib.rs")), ("src/shell.rs", include_str!("../src/shell.rs")), ]; #[test] fn every_class_this_renderer_emits_is_makeovers_or_declared_as_its_own() { let opts = makeover_webview::Emit::default(); let makeover = makeover_webview::vocabulary::names(&opts); let mut stray: Vec = Vec::new(); for (name, src) in SOURCES { for (line, literal) in class_literals(src) { if makeover.contains(&literal) || RENDERER_OWN.contains(&literal.as_str()) { continue; } stray.push(format!(" {name}:{line} \"{literal}\"")); } } assert!( stray.is_empty(), "{} class name(s) are neither makeover's nor declared in RENDERER_OWN:\n{}\n\n\ If makeover already answers for this thing, call its naming function \ (`class`, `option_class`, `part_class`, `cell_part_class`) instead of \ spelling the name here -- that is the 0.27.0 defect, where `tabs` and \ `segmented` rendered flat because makeover's rules say `tab` and \ `segment`. If it is genuinely this renderer's, a behavioural hook or a \ container makeover has no word for, add it to RENDERER_OWN and say which.", stray.len(), stray.join("\n") ); } #[test] fn nothing_this_renderer_claims_as_its_own_is_something_makeover_already_names() { // The other direction. A name in both lists means two crates believe they // own the same class, and the app gets whichever rule wins the cascade. let opts = makeover_webview::Emit::default(); let makeover = makeover_webview::vocabulary::names(&opts); let overlap: Vec<&&str> = RENDERER_OWN .iter() .filter(|name| makeover.contains(**name)) .collect(); assert!( overlap.is_empty(), "makeover defines {overlap:?}, so this renderer must not claim to own it. \ Delete the entry from RENDERER_OWN; the emitted name is already correct." ); } #[test] fn renderer_own_carries_nothing_that_stopped_being_emitted() { // A declared exception that no longer corresponds to anything is a licence // nobody is using, and the next stray name lands next to it and reads as // company. let emitted: BTreeSet = SOURCES .iter() .flat_map(|(_, src)| class_literals(src).into_iter().map(|(_, l)| l)) .collect(); let dead: Vec<&&str> = RENDERER_OWN .iter() .filter(|name| !emitted.contains(**name)) .collect(); assert!( dead.is_empty(), "RENDERER_OWN declares {dead:?}, which nothing emits any more. Delete them." ); } #[test] fn the_reader_finds_every_call_shape_and_ignores_prose() { let src = r#" // class_attr(&["not-a-real-one"]) in a comment class_attr(&["alpha"], opts, out); class_attr(&["beta", "gamma"], opts, out); out.push_str(&escape(&class("delta", opts))); class_into("epsilon", opts, out); class_attr(&[part_class(layout::RowPart::Primary)], opts, out); class_into(option_class(kind), opts, out); "#; let found: BTreeSet = class_literals(src).into_iter().map(|(_, l)| l).collect(); let expected: BTreeSet = ["alpha", "beta", "gamma", "delta", "epsilon"] .into_iter() .map(String::from) .collect(); // `part_class(...)` and `option_class(...)` are makeover answering, not // literals, so neither is here. assert_eq!(found, expected); } /// `(line, class name)` for every literal handed to `class_attr` or `class`. /// /// A call whose argument is a naming function rather than a literal contributes /// nothing, which is the point: that call is makeover answering, and this test /// is only interested in the names this crate spells itself. fn class_literals(src: &str) -> Vec<(usize, String)> { let mut out = Vec::new(); for (i, line) in src.lines().enumerate() { let code = line.trim_start(); // A comment can hold an example, and an example is not an emission. if code.starts_with("//") { continue; } // `class_into(` before `class(`, and the two cannot both match: there // is no `class(` inside `class_into(`. for (call, open) in [ ("class_attr(&[", ']'), ("class_into(", ')'), ("class(", ')'), ] { let mut at = 0; while let Some(found) = line[at..].find(call) { let start = at + found + call.len(); // `option_class(`, `part_class(` and `cell_part_class(` all end // in `class(` and are makeover answering rather than a literal. let is_suffix = call == "class(" && line[..at + found] .chars() .next_back() .is_some_and(|c| c.is_alphanumeric() || c == '_'); at = start; if is_suffix { continue; } let Some(end) = line[start..].find(open) else { continue; }; for literal in string_literals(&line[start..start + end]) { out.push((i + 1, literal)); } } } } out } /// The contents of every double-quoted literal in a fragment of Rust. fn string_literals(fragment: &str) -> Vec { let mut out = Vec::new(); let mut rest = fragment; while let Some(open) = rest.find('"') { rest = &rest[open + 1..]; let Some(close) = rest.find('"') else { break }; out.push(rest[..close].to_string()); rest = &rest[close + 1..]; } out }