Skip to main content

max / quasi

8.3 KB · 213 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 two places -- `class_attr`,
23 //! which writes the attribute, and `makeover_webview::class`, which prefixes one
24 //! name -- so the literals handed to those two are the whole surface. This test
25 //! reads them out of the source rather than out of rendered HTML: rendering
26 //! covers what the test author remembered to describe, and the failure being
27 //! guarded against is a name nobody thought about.
28 //!
29 //! A literal that is neither makeover's nor declared below fails. Adding one to
30 //! [`RENDERER_OWN`] is the deliberate act the 0.27.0 defect skipped.
31
32 use std::collections::BTreeSet;
33
34 /// Classes this renderer owns, with makeover answering for none of them.
35 ///
36 /// Two kinds, and both are legitimately not makeover's:
37 ///
38 /// - **Behavioural hooks.** `row-select`, `row-activate`, `table-sort`,
39 /// `chip-remove` and `field-writes` are what the transport binds to. They name
40 /// what an element *does* on this host, which is a webview concern rather than
41 /// a description one, and makeover deliberately names no behaviour.
42 /// - **Structure this renderer assembles.** `region`, `selector`, `figures`,
43 /// `form`, `rest` and the rest name containers makeover's vocabulary has no
44 /// word for because nothing else needs one: makeover styles the things inside
45 /// them.
46 ///
47 /// Anything added here should be one of those two. A name that describes how a
48 /// thing *looks* belongs in makeover, and putting it here is how the drift this
49 /// test exists to catch would come back wearing a licence.
50 const RENDERER_OWN: &[&str] = &[
51 "act-submit",
52 "chip-remove",
53 "field-writes",
54 "figure-act",
55 "figures",
56 "form",
57 "heading",
58 "notices",
59 "region",
60 "rest",
61 "rest-more",
62 "rich",
63 "row-activate",
64 "row-menu",
65 "row-select",
66 "selector",
67 "table-sort",
68 "text",
69 ];
70
71 /// The source files that can name a class.
72 const SOURCES: &[(&str, &str)] = &[
73 ("src/node.rs", include_str!("../src/node.rs")),
74 ("src/lib.rs", include_str!("../src/lib.rs")),
75 ("src/shell.rs", include_str!("../src/shell.rs")),
76 ];
77
78 #[test]
79 fn every_class_this_renderer_emits_is_makeovers_or_declared_as_its_own() {
80 let opts = makeover_webview::Emit::default();
81 let makeover = makeover_webview::vocabulary::names(&opts);
82 let mut stray: Vec<String> = Vec::new();
83
84 for (name, src) in SOURCES {
85 for (line, literal) in class_literals(src) {
86 if makeover.contains(&literal) || RENDERER_OWN.contains(&literal.as_str()) {
87 continue;
88 }
89 stray.push(format!(" {name}:{line} \"{literal}\""));
90 }
91 }
92
93 assert!(
94 stray.is_empty(),
95 "{} class name(s) are neither makeover's nor declared in RENDERER_OWN:\n{}\n\n\
96 If makeover already answers for this thing, call its naming function \
97 (`class`, `option_class`, `part_class`, `cell_part_class`) instead of \
98 spelling the name here -- that is the 0.27.0 defect, where `tabs` and \
99 `segmented` rendered flat because makeover's rules say `tab` and \
100 `segment`. If it is genuinely this renderer's, a behavioural hook or a \
101 container makeover has no word for, add it to RENDERER_OWN and say which.",
102 stray.len(),
103 stray.join("\n")
104 );
105 }
106
107 #[test]
108 fn nothing_this_renderer_claims_as_its_own_is_something_makeover_already_names() {
109 // The other direction. A name in both lists means two crates believe they
110 // own the same class, and the app gets whichever rule wins the cascade.
111 let opts = makeover_webview::Emit::default();
112 let makeover = makeover_webview::vocabulary::names(&opts);
113 let overlap: Vec<&&str> = RENDERER_OWN
114 .iter()
115 .filter(|name| makeover.contains(**name))
116 .collect();
117 assert!(
118 overlap.is_empty(),
119 "makeover defines {overlap:?}, so this renderer must not claim to own it. \
120 Delete the entry from RENDERER_OWN; the emitted name is already correct."
121 );
122 }
123
124 #[test]
125 fn renderer_own_carries_nothing_that_stopped_being_emitted() {
126 // A declared exception that no longer corresponds to anything is a licence
127 // nobody is using, and the next stray name lands next to it and reads as
128 // company.
129 let emitted: BTreeSet<String> = SOURCES
130 .iter()
131 .flat_map(|(_, src)| class_literals(src).into_iter().map(|(_, l)| l))
132 .collect();
133 let dead: Vec<&&str> = RENDERER_OWN
134 .iter()
135 .filter(|name| !emitted.contains(**name))
136 .collect();
137 assert!(
138 dead.is_empty(),
139 "RENDERER_OWN declares {dead:?}, which nothing emits any more. Delete them."
140 );
141 }
142
143 #[test]
144 fn the_reader_finds_both_call_shapes_and_ignores_prose() {
145 let src = r#"
146 // class_attr(&["not-a-real-one"]) in a comment
147 class_attr(&["alpha"], opts, out);
148 class_attr(&["beta", "gamma"], opts, out);
149 out.push_str(&escape(&class("delta", opts)));
150 class_attr(&[part_class(layout::RowPart::Primary)], opts, out);
151 "#;
152 let found: BTreeSet<String> = class_literals(src).into_iter().map(|(_, l)| l).collect();
153 let expected: BTreeSet<String> = ["alpha", "beta", "gamma", "delta"]
154 .into_iter()
155 .map(String::from)
156 .collect();
157 // `part_class(...)` is makeover answering, not a literal, so it is not here.
158 assert_eq!(found, expected);
159 }
160
161 /// `(line, class name)` for every literal handed to `class_attr` or `class`.
162 ///
163 /// A call whose argument is a naming function rather than a literal contributes
164 /// nothing, which is the point: that call is makeover answering, and this test
165 /// is only interested in the names this crate spells itself.
166 fn class_literals(src: &str) -> Vec<(usize, String)> {
167 let mut out = Vec::new();
168 for (i, line) in src.lines().enumerate() {
169 let code = line.trim_start();
170 // A comment can hold an example, and an example is not an emission.
171 if code.starts_with("//") {
172 continue;
173 }
174 for (call, open) in [("class_attr(&[", ']'), ("class(", ')')] {
175 let mut at = 0;
176 while let Some(found) = line[at..].find(call) {
177 let start = at + found + call.len();
178 // `option_class(`, `part_class(` and `cell_part_class(` all end
179 // in `class(` and are makeover answering rather than a literal.
180 let is_suffix = call == "class("
181 && line[..at + found]
182 .chars()
183 .next_back()
184 .is_some_and(|c| c.is_alphanumeric() || c == '_');
185 at = start;
186 if is_suffix {
187 continue;
188 }
189 let Some(end) = line[start..].find(open) else {
190 continue;
191 };
192 for literal in string_literals(&line[start..start + end]) {
193 out.push((i + 1, literal));
194 }
195 }
196 }
197 }
198 out
199 }
200
201 /// The contents of every double-quoted literal in a fragment of Rust.
202 fn string_literals(fragment: &str) -> Vec<String> {
203 let mut out = Vec::new();
204 let mut rest = fragment;
205 while let Some(open) = rest.find('"') {
206 rest = &rest[open + 1..];
207 let Some(close) = rest.find('"') else { break };
208 out.push(rest[..close].to_string());
209 rest = &rest[close + 1..];
210 }
211 out
212 }
213