Skip to main content

max / quasi

9.6 KB · 237 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`, `figures`,
58 /// `form`, `rest` and the rest name containers makeover's vocabulary has no
59 /// word for because nothing else needs one: makeover styles the things inside
60 /// them.
61 ///
62 /// Anything added here should be one of those two. A name that describes how a
63 /// thing *looks* belongs in makeover, and putting it here is how the drift this
64 /// test exists to catch would come back wearing a licence.
65 const RENDERER_OWN: &[&str] = &[
66 "act-submit",
67 "chip-remove",
68 "field-writes",
69 "figure-act",
70 "figures",
71 "form",
72 "heading",
73 "notices",
74 "region",
75 "rest",
76 "rest-more",
77 "rich",
78 "row-activate",
79 "row-menu",
80 "row-select",
81 "selector",
82 "table-sort",
83 "text",
84 ];
85
86 /// The source files that can name a class.
87 const SOURCES: &[(&str, &str)] = &[
88 ("src/node.rs", include_str!("../src/node.rs")),
89 ("src/lib.rs", include_str!("../src/lib.rs")),
90 ("src/shell.rs", include_str!("../src/shell.rs")),
91 ];
92
93 #[test]
94 fn every_class_this_renderer_emits_is_makeovers_or_declared_as_its_own() {
95 let opts = makeover_webview::Emit::default();
96 let makeover = makeover_webview::vocabulary::names(&opts);
97 let mut stray: Vec<String> = Vec::new();
98
99 for (name, src) in SOURCES {
100 for (line, literal) in class_literals(src) {
101 if makeover.contains(&literal) || RENDERER_OWN.contains(&literal.as_str()) {
102 continue;
103 }
104 stray.push(format!(" {name}:{line} \"{literal}\""));
105 }
106 }
107
108 assert!(
109 stray.is_empty(),
110 "{} class name(s) are neither makeover's nor declared in RENDERER_OWN:\n{}\n\n\
111 If makeover already answers for this thing, call its naming function \
112 (`class`, `option_class`, `part_class`, `cell_part_class`) instead of \
113 spelling the name here -- that is the 0.27.0 defect, where `tabs` and \
114 `segmented` rendered flat because makeover's rules say `tab` and \
115 `segment`. If it is genuinely this renderer's, a behavioural hook or a \
116 container makeover has no word for, add it to RENDERER_OWN and say which.",
117 stray.len(),
118 stray.join("\n")
119 );
120 }
121
122 #[test]
123 fn nothing_this_renderer_claims_as_its_own_is_something_makeover_already_names() {
124 // The other direction. A name in both lists means two crates believe they
125 // own the same class, and the app gets whichever rule wins the cascade.
126 let opts = makeover_webview::Emit::default();
127 let makeover = makeover_webview::vocabulary::names(&opts);
128 let overlap: Vec<&&str> = RENDERER_OWN
129 .iter()
130 .filter(|name| makeover.contains(**name))
131 .collect();
132 assert!(
133 overlap.is_empty(),
134 "makeover defines {overlap:?}, so this renderer must not claim to own it. \
135 Delete the entry from RENDERER_OWN; the emitted name is already correct."
136 );
137 }
138
139 #[test]
140 fn renderer_own_carries_nothing_that_stopped_being_emitted() {
141 // A declared exception that no longer corresponds to anything is a licence
142 // nobody is using, and the next stray name lands next to it and reads as
143 // company.
144 let emitted: BTreeSet<String> = SOURCES
145 .iter()
146 .flat_map(|(_, src)| class_literals(src).into_iter().map(|(_, l)| l))
147 .collect();
148 let dead: Vec<&&str> = RENDERER_OWN
149 .iter()
150 .filter(|name| !emitted.contains(**name))
151 .collect();
152 assert!(
153 dead.is_empty(),
154 "RENDERER_OWN declares {dead:?}, which nothing emits any more. Delete them."
155 );
156 }
157
158 #[test]
159 fn the_reader_finds_every_call_shape_and_ignores_prose() {
160 let src = r#"
161 // class_attr(&["not-a-real-one"]) in a comment
162 class_attr(&["alpha"], opts, out);
163 class_attr(&["beta", "gamma"], opts, out);
164 out.push_str(&escape(&class("delta", opts)));
165 class_into("epsilon", opts, out);
166 class_attr(&[part_class(layout::RowPart::Primary)], opts, out);
167 class_into(option_class(kind), opts, out);
168 "#;
169 let found: BTreeSet<String> = class_literals(src).into_iter().map(|(_, l)| l).collect();
170 let expected: BTreeSet<String> = ["alpha", "beta", "gamma", "delta", "epsilon"]
171 .into_iter()
172 .map(String::from)
173 .collect();
174 // `part_class(...)` and `option_class(...)` are makeover answering, not
175 // literals, so neither is here.
176 assert_eq!(found, expected);
177 }
178
179 /// `(line, class name)` for every literal handed to `class_attr` or `class`.
180 ///
181 /// A call whose argument is a naming function rather than a literal contributes
182 /// nothing, which is the point: that call is makeover answering, and this test
183 /// is only interested in the names this crate spells itself.
184 fn class_literals(src: &str) -> Vec<(usize, String)> {
185 let mut out = Vec::new();
186 for (i, line) in src.lines().enumerate() {
187 let code = line.trim_start();
188 // A comment can hold an example, and an example is not an emission.
189 if code.starts_with("//") {
190 continue;
191 }
192 // `class_into(` before `class(`, and the two cannot both match: there
193 // is no `class(` inside `class_into(`.
194 for (call, open) in [
195 ("class_attr(&[", ']'),
196 ("class_into(", ')'),
197 ("class(", ')'),
198 ] {
199 let mut at = 0;
200 while let Some(found) = line[at..].find(call) {
201 let start = at + found + call.len();
202 // `option_class(`, `part_class(` and `cell_part_class(` all end
203 // in `class(` and are makeover answering rather than a literal.
204 let is_suffix = call == "class("
205 && line[..at + found]
206 .chars()
207 .next_back()
208 .is_some_and(|c| c.is_alphanumeric() || c == '_');
209 at = start;
210 if is_suffix {
211 continue;
212 }
213 let Some(end) = line[start..].find(open) else {
214 continue;
215 };
216 for literal in string_literals(&line[start..start + end]) {
217 out.push((i + 1, literal));
218 }
219 }
220 }
221 }
222 out
223 }
224
225 /// The contents of every double-quoted literal in a fragment of Rust.
226 fn string_literals(fragment: &str) -> Vec<String> {
227 let mut out = Vec::new();
228 let mut rest = fragment;
229 while let Some(open) = rest.find('"') {
230 rest = &rest[open + 1..];
231 let Some(close) = rest.find('"') else { break };
232 out.push(rest[..close].to_string());
233 rest = &rest[close + 1..];
234 }
235 out
236 }
237