//! 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`, `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", // The popover container an `Outcome::Anchored` lands in, beside every // region, beside a named control and beside a screen's selection. // `ae8e8836`. Structure this renderer assembles: the menu inside it is a // screen and carries makeover's own names, and where the container sits // relative to what it is anchored to is the app's stylesheet. "anchored", // The disclosure a control that asks for a value first is drawn in, its // summary and its body. `Act::asks` is a description fact and the `details` // is this renderer's answer to it, so makeover has no word for the wrapper; // the control and the boxes inside it carry makeover's own names. "ask", "ask-body", "ask-open", "chip-remove", // The element a `Node::Code` is drawn in, `
` or ``. `19d7602d`.
// Structure this renderer assembles: makeover has no word for a code block
// because the vocabulary carries the runs and not the container, and what a
// monospace block looks like is the host stylesheet's answer. The runs
// inside it carry `lex-`, which is `layout::Syntax::name` rather than
// a name invented here, and is why those are not listed one by one.
"code",
// A readout the browser keeps writing. The class is a hook rather than a
// look: `clock.js` finds these by `data-clock`, and what a time reads like
// is the prose around it, which makeover already answers for.
"clock",
"field-consults",
// A question answered N times, its slots, one slot, and the two controls
// the reader adds and removes them with. `60d1753c`. A group of controls
// answering one question is a `fieldset` and makeover has no word for one:
// the fields inside carry makeover's own names, and these five are the
// grouping and the hooks `repeat.js` finds a group by.
"field-repeat",
"field-repeat-add",
"field-repeat-blank",
"field-repeat-legend",
"field-repeat-remove",
"field-repeat-slot",
"field-repeat-slots",
// The box one conditional question is drawn in, carrying the three
// `data-reveal` marks. `8fdb814c`. A hook rather than a look: `reveal.js`
// finds it by the attributes and makeover has no word for a wrapper whose
// whole job is to be hidden.
"field-reveal",
// The wrapper that asks a field's suggestion route. A hook like the two
// beside it: the list itself and its entries are `form-suggestions` and
// `form-suggestion`, which are makeover's and are ruled there.
"field-suggests",
"field-writes",
"figure-act",
// `figures` was here until makeover-webview 0.59.0, which names it: the
// strip is `figures_html`'s and this renderer emits it to interleave the
// acts, which is emitting rather than owning.
"form",
"heading",
"notices",
"region",
// The wrapper a region's own question hangs on. `cb62a9dc`, and a
// behavioural hook of the first kind: htmx takes one verb per element, so
// the recompute needs an element of its own, and what it names is what the
// transport binds to rather than anything a reader sees.
"region-consults",
"rest",
"rest-next",
// The numbered strip a description that offered jumps gets in place of the
// position readout. `0ce21f4b`. makeover names no pager at all, so the
// whole family is this renderer's.
"rest-page",
"rest-page-here",
"rest-pages",
"rest-position",
"rest-previous",
"rich",
"row-activate",
// `row-disclose` was here until 2026-09-01, on the reasoning that makeover
// has no word for a branch's chevron. It has had one since makeover-webview
// 0.70.1 (`7942bd7`, "give a nested row a rule"), which put it in
// `NESTING_CLASSES` beside `row-nested` and `row-branch`. This crate's lock
// held makeover-webview at 0.70.0, so the overlap sat here undetected until
// the 0.42.0 cascade forced a resolve. The emitted name was already correct
// throughout; only the claim of ownership was wrong.
"row-menu",
"row-select",
"selector",
// The gutter a table row's tick sits in, and the cell above it in the
// head. A tick takes no column -- it does not narrow, sort or carry data --
// so makeover's `col-` naming has nothing to say about it, and both cells
// also carry makeover's own `cell` and `table-heading` for their treatment.
// The gutter a table row's chevron sits in, and the cell above it in the
// head. `table-select`'s reasoning, one gutter along.
"table-disclose",
"table-disclose-head",
"table-select",
"table-select-head",
// A table row's held-back acts. `row-menu`'s counterpart, named separately
// for the reason `table-row-current` is: this one is positioned against a
// grid rather than against a line. The `data-menu="row"` attribute beside it
// is deliberately the list row's own, so a host binds one gesture for both.
"table-row-menu",
"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
}