Skip to main content

max / quasi

14.2 KB · 319 lines History Blame Raw
1 //! Every class this renderer emits is either one makeover defines or one this
2 //! renderer declares as its own, and nothing in between.
3 //!
4 //! # Why this test exists
5 //!
6 //! makeover-webview 0.27.0 exists because this crate spelled `tabs`, `segmented`
7 //! and `option` itself while makeover's rules key off `tab`, `segment` and
8 //! `toggle`. Every described selector rendered flat: no depth, no focus ring, no
9 //! chosen state. The CSS stayed valid, the markup stayed valid, and the two
10 //! simply did not meet. Nothing failed, so it shipped, and it was found by
11 //! reading rather than by building.
12 //!
13 //! The same shape produced `tone-info` / `tone-success` / `tone-warning` /
14 //! `tone-danger` as classes when makeover keys tone off `data-tone`, so every
15 //! toned thing arrived with a class no stylesheet in the tree had heard of.
16 //!
17 //! Both were a name invented in this crate that makeover already answered for.
18 //! Neither could be caught by a type, because both sides are strings.
19 //!
20 //! # How it reads
21 //!
22 //! Class names reach the output through exactly three places -- `class_attr`,
23 //! which writes the attribute, `class_into`, which writes one prefixed name
24 //! into a buffer, and `makeover_webview::class`, which returns one as a value
25 //! -- so the literals handed to those three are the whole surface. This test
26 //! reads them out of the source rather than out of rendered HTML: rendering
27 //! covers what the test author remembered to describe, and the failure being
28 //! guarded against is a name nobody thought about.
29 //!
30 //! `class_into` is read and `makeover_webview::push_class` is not, which is why
31 //! this crate calls the former. A name spelled at a `push_class` call would be
32 //! skipped silently: the reader drops `class(` preceded by an identifier
33 //! character, because `option_class(`, `part_class(` and `cell_part_class(` all
34 //! end that way and are makeover answering rather than a literal.
35 //!
36 //! What it does not read: a name returned by a helper rather than handed to one
37 //! of the three. `Webview::arrangement_class` and `Webview::measure_class` each
38 //! match a description value to a `&'static str`, and those names -- the
39 //! `list-detail` and `measure-wide` sets -- reach the output through a
40 //! `class_into` call whose argument is the helper. Both sets are this
41 //! renderer's, neither has ever been checked here, and closing that is its own
42 //! change rather than a line in RENDERER_OWN.
43 //!
44 //! A literal that is neither makeover's nor declared below fails. Adding one to
45 //! [`RENDERER_OWN`] is the deliberate act the 0.27.0 defect skipped.
46
47 use std::collections::BTreeSet;
48
49 /// Classes this renderer owns, with makeover answering for none of them.
50 ///
51 /// Two kinds, and both are legitimately not makeover's:
52 ///
53 /// - **Behavioural hooks.** `row-select`, `row-activate`, `table-sort`,
54 /// `chip-remove` and `field-writes` are what the transport binds to. They name
55 /// what an element *does* on this host, which is a webview concern rather than
56 /// a description one, and makeover deliberately names no behaviour.
57 /// - **Structure this renderer assembles.** `region`, `selector`, `form`,
58 /// `rest` and the rest name containers makeover's vocabulary has no word for
59 /// because nothing else needs one: makeover styles the things inside them.
60 ///
61 /// Anything added here should be one of those two. A name that describes how a
62 /// thing *looks* belongs in makeover, and putting it here is how the drift this
63 /// test exists to catch would come back wearing a licence.
64 const RENDERER_OWN: &[&str] = &[
65 "act-submit",
66 // The popover container an `Outcome::Anchored` lands in, beside every
67 // region, beside a named control and beside a screen's selection.
68 // `ae8e8836`. Structure this renderer assembles: the menu inside it is a
69 // screen and carries makeover's own names, and where the container sits
70 // relative to what it is anchored to is the app's stylesheet.
71 "anchored",
72 // The disclosure a control that asks for a value first is drawn in, its
73 // summary and its body. `Act::asks` is a description fact and the `details`
74 // is this renderer's answer to it, so makeover has no word for the wrapper;
75 // the control and the boxes inside it carry makeover's own names.
76 "ask",
77 "ask-body",
78 "ask-open",
79 "chip-remove",
80 // The element a `Node::Code` is drawn in, `<pre>` or `<code>`. `19d7602d`.
81 // Structure this renderer assembles: makeover has no word for a code block
82 // because the vocabulary carries the runs and not the container, and what a
83 // monospace block looks like is the host stylesheet's answer. The runs
84 // inside it carry `lex-<class>`, which is `layout::Syntax::name` rather than
85 // a name invented here, and is why those are not listed one by one.
86 "code",
87 // A readout the browser keeps writing. The class is a hook rather than a
88 // look: `clock.js` finds these by `data-clock`, and what a time reads like
89 // is the prose around it, which makeover already answers for.
90 "clock",
91 "field-consults",
92 // A question answered N times, its slots, one slot, and the two controls
93 // the reader adds and removes them with. `60d1753c`. A group of controls
94 // answering one question is a `fieldset` and makeover has no word for one:
95 // the fields inside carry makeover's own names, and these five are the
96 // grouping and the hooks `repeat.js` finds a group by.
97 "field-repeat",
98 "field-repeat-add",
99 "field-repeat-blank",
100 "field-repeat-legend",
101 "field-repeat-remove",
102 "field-repeat-slot",
103 "field-repeat-slots",
104 // The box one conditional question is drawn in, carrying the three
105 // `data-reveal` marks. `8fdb814c`. A hook rather than a look: `reveal.js`
106 // finds it by the attributes and makeover has no word for a wrapper whose
107 // whole job is to be hidden.
108 "field-reveal",
109 // The wrapper that asks a field's suggestion route. A hook like the two
110 // beside it: the list itself and its entries are `form-suggestions` and
111 // `form-suggestion`, which are makeover's and are ruled there.
112 "field-suggests",
113 "field-writes",
114 "figure-act",
115 // `figures` was here until makeover-webview 0.59.0, which names it: the
116 // strip is `figures_html`'s and this renderer emits it to interleave the
117 // acts, which is emitting rather than owning.
118 "form",
119 "heading",
120 "notices",
121 "region",
122 // The wrapper a region's own question hangs on. `cb62a9dc`, and a
123 // behavioural hook of the first kind: htmx takes one verb per element, so
124 // the recompute needs an element of its own, and what it names is what the
125 // transport binds to rather than anything a reader sees.
126 "region-consults",
127 "rest",
128 "rest-next",
129 // The numbered strip a description that offered jumps gets in place of the
130 // position readout. `0ce21f4b`. makeover names no pager at all, so the
131 // whole family is this renderer's.
132 "rest-page",
133 "rest-page-here",
134 "rest-pages",
135 "rest-position",
136 "rest-previous",
137 "rich",
138 "row-activate",
139 // `row-disclose` was here until 2026-09-01, on the reasoning that makeover
140 // has no word for a branch's chevron. It has had one since makeover-webview
141 // 0.70.1 (`7942bd7`, "give a nested row a rule"), which put it in
142 // `NESTING_CLASSES` beside `row-nested` and `row-branch`. This crate's lock
143 // held makeover-webview at 0.70.0, so the overlap sat here undetected until
144 // the 0.42.0 cascade forced a resolve. The emitted name was already correct
145 // throughout; only the claim of ownership was wrong.
146 "row-menu",
147 "row-select",
148 "selector",
149 // The gutter a table row's tick sits in, and the cell above it in the
150 // head. A tick takes no column -- it does not narrow, sort or carry data --
151 // so makeover's `col-` naming has nothing to say about it, and both cells
152 // also carry makeover's own `cell` and `table-heading` for their treatment.
153 // The gutter a table row's chevron sits in, and the cell above it in the
154 // head. `table-select`'s reasoning, one gutter along.
155 "table-disclose",
156 "table-disclose-head",
157 "table-select",
158 "table-select-head",
159 // A table row's held-back acts. `row-menu`'s counterpart, named separately
160 // for the reason `table-row-current` is: this one is positioned against a
161 // grid rather than against a line. The `data-menu="row"` attribute beside it
162 // is deliberately the list row's own, so a host binds one gesture for both.
163 "table-row-menu",
164 "table-sort",
165 "text",
166 ];
167
168 /// The source files that can name a class.
169 const SOURCES: &[(&str, &str)] = &[
170 ("src/node.rs", include_str!("../src/node.rs")),
171 ("src/lib.rs", include_str!("../src/lib.rs")),
172 ("src/shell.rs", include_str!("../src/shell.rs")),
173 ];
174
175 #[test]
176 fn every_class_this_renderer_emits_is_makeovers_or_declared_as_its_own() {
177 let opts = makeover_webview::Emit::default();
178 let makeover = makeover_webview::vocabulary::names(&opts);
179 let mut stray: Vec<String> = Vec::new();
180
181 for (name, src) in SOURCES {
182 for (line, literal) in class_literals(src) {
183 if makeover.contains(&literal) || RENDERER_OWN.contains(&literal.as_str()) {
184 continue;
185 }
186 stray.push(format!(" {name}:{line} \"{literal}\""));
187 }
188 }
189
190 assert!(
191 stray.is_empty(),
192 "{} class name(s) are neither makeover's nor declared in RENDERER_OWN:\n{}\n\n\
193 If makeover already answers for this thing, call its naming function \
194 (`class`, `option_class`, `part_class`, `cell_part_class`) instead of \
195 spelling the name here -- that is the 0.27.0 defect, where `tabs` and \
196 `segmented` rendered flat because makeover's rules say `tab` and \
197 `segment`. If it is genuinely this renderer's, a behavioural hook or a \
198 container makeover has no word for, add it to RENDERER_OWN and say which.",
199 stray.len(),
200 stray.join("\n")
201 );
202 }
203
204 #[test]
205 fn nothing_this_renderer_claims_as_its_own_is_something_makeover_already_names() {
206 // The other direction. A name in both lists means two crates believe they
207 // own the same class, and the app gets whichever rule wins the cascade.
208 let opts = makeover_webview::Emit::default();
209 let makeover = makeover_webview::vocabulary::names(&opts);
210 let overlap: Vec<&&str> = RENDERER_OWN
211 .iter()
212 .filter(|name| makeover.contains(**name))
213 .collect();
214 assert!(
215 overlap.is_empty(),
216 "makeover defines {overlap:?}, so this renderer must not claim to own it. \
217 Delete the entry from RENDERER_OWN; the emitted name is already correct."
218 );
219 }
220
221 #[test]
222 fn renderer_own_carries_nothing_that_stopped_being_emitted() {
223 // A declared exception that no longer corresponds to anything is a licence
224 // nobody is using, and the next stray name lands next to it and reads as
225 // company.
226 let emitted: BTreeSet<String> = SOURCES
227 .iter()
228 .flat_map(|(_, src)| class_literals(src).into_iter().map(|(_, l)| l))
229 .collect();
230 let dead: Vec<&&str> = RENDERER_OWN
231 .iter()
232 .filter(|name| !emitted.contains(**name))
233 .collect();
234 assert!(
235 dead.is_empty(),
236 "RENDERER_OWN declares {dead:?}, which nothing emits any more. Delete them."
237 );
238 }
239
240 #[test]
241 fn the_reader_finds_every_call_shape_and_ignores_prose() {
242 let src = r#"
243 // class_attr(&["not-a-real-one"]) in a comment
244 class_attr(&["alpha"], opts, out);
245 class_attr(&["beta", "gamma"], opts, out);
246 out.push_str(&escape(&class("delta", opts)));
247 class_into("epsilon", opts, out);
248 class_attr(&[part_class(layout::RowPart::Primary)], opts, out);
249 class_into(option_class(kind), opts, out);
250 "#;
251 let found: BTreeSet<String> = class_literals(src).into_iter().map(|(_, l)| l).collect();
252 let expected: BTreeSet<String> = ["alpha", "beta", "gamma", "delta", "epsilon"]
253 .into_iter()
254 .map(String::from)
255 .collect();
256 // `part_class(...)` and `option_class(...)` are makeover answering, not
257 // literals, so neither is here.
258 assert_eq!(found, expected);
259 }
260
261 /// `(line, class name)` for every literal handed to `class_attr` or `class`.
262 ///
263 /// A call whose argument is a naming function rather than a literal contributes
264 /// nothing, which is the point: that call is makeover answering, and this test
265 /// is only interested in the names this crate spells itself.
266 fn class_literals(src: &str) -> Vec<(usize, String)> {
267 let mut out = Vec::new();
268 for (i, line) in src.lines().enumerate() {
269 let code = line.trim_start();
270 // A comment can hold an example, and an example is not an emission.
271 if code.starts_with("//") {
272 continue;
273 }
274 // `class_into(` before `class(`, and the two cannot both match: there
275 // is no `class(` inside `class_into(`.
276 for (call, open) in [
277 ("class_attr(&[", ']'),
278 ("class_into(", ')'),
279 ("class(", ')'),
280 ] {
281 let mut at = 0;
282 while let Some(found) = line[at..].find(call) {
283 let start = at + found + call.len();
284 // `option_class(`, `part_class(` and `cell_part_class(` all end
285 // in `class(` and are makeover answering rather than a literal.
286 let is_suffix = call == "class("
287 && line[..at + found]
288 .chars()
289 .next_back()
290 .is_some_and(|c| c.is_alphanumeric() || c == '_');
291 at = start;
292 if is_suffix {
293 continue;
294 }
295 let Some(end) = line[start..].find(open) else {
296 continue;
297 };
298 for literal in string_literals(&line[start..start + end]) {
299 out.push((i + 1, literal));
300 }
301 }
302 }
303 }
304 out
305 }
306
307 /// The contents of every double-quoted literal in a fragment of Rust.
308 fn string_literals(fragment: &str) -> Vec<String> {
309 let mut out = Vec::new();
310 let mut rest = fragment;
311 while let Some(open) = rest.find('"') {
312 rest = &rest[open + 1..];
313 let Some(close) = rest.find('"') else { break };
314 out.push(rest[..close].to_string());
315 rest = &rest[close + 1..];
316 }
317 out
318 }
319