Skip to main content

max / makeover-webview

11.7 KB · 331 lines History Blame Raw
1 //! A render of everything this crate can emit, scraped for class names.
2 //!
3 //! [`vocabulary::names`](crate::vocabulary::names) claims to hold every class
4 //! this crate can put in markup, and this is what checks the claim. A name
5 //! missing from that set is worse than a name missing from the stylesheet: an
6 //! app checking its own stylesheet against the set concludes that its rules for
7 //! the name are dead and deletes live styling.
8 //!
9 //! This is a render and not a scan of the emitters: a careful read of `quasi-webview`'s emitters produced 35 names and
10 //! its corpus found 14 more. A class assembled at runtime -- a width class, a
11 //! drop class, a state appended to an attribute already open -- is a literal
12 //! nowhere in this source, and that is the shape of every name that was
13 //! missing here.
14 //!
15 //! # What it is not
16 //!
17 //! Not a rendering test. Nothing here asserts what an emitter produced, only
18 //! which classes came out, so it stays quiet when markup changes and speaks
19 //! when the vocabulary does.
20
21 use crate::form::{Filling, Markup, Value, field_html};
22 use crate::list::{Cell, cells_html};
23 use crate::{Emit, facet::facet_html, figure::figures_html};
24 use crate::{meter::meter_html, placeholder::placeholder_html};
25 use makeover_layout::{
26 Accepted, CellPart, Choice, Column, Contrast, Facet, FacetValue, Field, FieldKind, Figure,
27 Meter, Nesting, Priority, Readiness, Selecting, Sort, Standing, ThemeChoice, ThemeVariant,
28 Tone, Width,
29 };
30 use std::collections::BTreeSet;
31
32 /// Every class the corpus puts in a document, unprefixed.
33 ///
34 /// Scraped from `class="..."` rather than predicted, which is the point.
35 pub(crate) fn emitted() -> BTreeSet<String> {
36 emitted_with(&Emit::default())
37 }
38
39 /// [`emitted`], with the emit options a host would set.
40 fn emitted_with(opts: &Emit) -> BTreeSet<String> {
41 let mut found = BTreeSet::new();
42 for html in documents(opts) {
43 let mut rest = html.as_str();
44 while let Some(at) = rest.find("class=\"") {
45 rest = &rest[at + "class=\"".len()..];
46 let end = rest.find('"').expect("an attribute closes");
47 for name in rest[..end].split_whitespace() {
48 found.insert(name.to_owned());
49 }
50 rest = &rest[end..];
51 }
52 }
53 found
54 }
55
56 /// One document per emitter, over every input that changes what it writes.
57 ///
58 /// Every markup emitter this crate has is called here. A new one that is not
59 /// added is the one hole this guard has, which is why the list is short enough
60 /// to read: `placeholder`, `form`, `figure`, `facet`, `meter` and `list` are
61 /// the whole of what emits markup, and `lib.rs` writes rules rather than
62 /// documents.
63 fn documents(opts: &Emit) -> Vec<String> {
64 let mut out = vec![placeholders(opts), fields(opts), rows(opts)];
65 out.push(figures_html(
66 &[
67 Figure::new("42", "Tasks"),
68 Figure::new("12.5%", "Growth")
69 .change("+3")
70 .tone(Tone::Success),
71 ],
72 opts,
73 ));
74 for mode in [
75 Selecting::OneOf,
76 Selecting::AnyOf,
77 Selecting::Range,
78 Selecting::Text,
79 Selecting::Subtree,
80 ] {
81 let values = [
82 FacetValue::new("music", "Music")
83 .standing(Standing::Taken)
84 .counted(128)
85 .at(Nesting::at(0), true),
86 FacetValue::new("music/synths", "Synths")
87 .standing(Standing::Inherited)
88 .at(Nesting::at(1), false),
89 FacetValue::new("music/drums", "Drums")
90 .standing(Standing::Pruned)
91 .at(Nesting::at(1), false),
92 FacetValue::new("talk", "Talk").at(Nesting::at(0), false),
93 ];
94 out.push(facet_html(&Facet::new("Tag", mode, &values), opts));
95 }
96 // Toned, untoned, and over its total, which is the one state that adds an
97 // attribute of its own.
98 for meter in [
99 Meter::new(3, 7),
100 Meter::new(3, 7).tone(Tone::Success).label("subtasks"),
101 Meter::new(9, 7).tone(Tone::Danger),
102 ] {
103 out.push(meter_html(&meter, opts));
104 }
105 out
106 }
107
108 /// Every readiness, with and without the action a stand-in can carry.
109 fn placeholders(opts: &Emit) -> String {
110 let mut html = String::new();
111 for state in [
112 Readiness::Ready,
113 Readiness::Pending,
114 Readiness::Empty,
115 Readiness::Failed,
116 ] {
117 html.push_str(&placeholder_html(state, "Nothing here", None, opts));
118 html.push_str(&placeholder_html(
119 state,
120 "Nothing here",
121 Some(Markup("<button>Add one</button>")),
122 opts,
123 ));
124 }
125 html
126 }
127
128 /// Every field kind, twice: plain, and carrying everything a group can hold.
129 ///
130 /// The second pass is where the vocabulary lives. A hint, a unit and an error
131 /// each add a class, and an error adds two -- one on the message and one on the
132 /// group, which is `Field::invalid`'s own reasoning about a renderer that
133 /// cannot find the group from the message.
134 fn fields(opts: &Emit) -> String {
135 const OPTIONS: &[Choice<'_>] = &[
136 Choice::new("a", "The first"),
137 Choice::new("b", "The second").unless("Not while the first is running"),
138 ];
139 const ACCEPT: &[Accepted<'_>] = &[Accepted::Type("image/png"), Accepted::Suffix(".zip")];
140 // Two variants and two tiers, so the corpus carries a group boundary and a
141 // badge that is not the same badge twice. One theme per variant would emit
142 // one `<optgroup>` and prove nothing about where the next one opens.
143 const THEMES: &[ThemeChoice<'_>] = &[
144 ThemeChoice::new("goingson", "GoingsOn", ThemeVariant::Light, Contrast::High),
145 ThemeChoice::new("ayu-light", "Ayu Light", ThemeVariant::Light, Contrast::Low),
146 ThemeChoice::new("carbonfox", "Carbonfox", ThemeVariant::Dark, Contrast::High),
147 ThemeChoice::new("nord", "Nord", ThemeVariant::Dark, Contrast::Standard),
148 ];
149
150 let mut html = String::new();
151 for kind in [
152 FieldKind::Text,
153 FieldKind::Secret,
154 FieldKind::Number,
155 FieldKind::Range,
156 FieldKind::Interval,
157 FieldKind::Email,
158 FieldKind::Url,
159 FieldKind::Tel,
160 FieldKind::Date,
161 FieldKind::DateTime,
162 FieldKind::Textarea,
163 FieldKind::Rich,
164 FieldKind::Select,
165 FieldKind::Radio,
166 FieldKind::Checkbox,
167 FieldKind::File,
168 FieldKind::Theme,
169 FieldKind::Hidden,
170 ] {
171 let plain = Field {
172 options: OPTIONS,
173 themes: THEMES,
174 follows: Some(Choice::new("system", "Follow System")),
175 accept: ACCEPT,
176 upper_name: Some("upper"),
177 min: Some("0"),
178 max: Some("10"),
179 ..Field::new(kind, "name", "Label")
180 };
181 let dressed = Field {
182 hint: Some("What it is for"),
183 error: Some("That will not do"),
184 unit: Some("minutes"),
185 required: true,
186 ..plain
187 };
188 for field in [plain, dressed] {
189 for value in [
190 Value::Absent,
191 Value::Text("a"),
192 Value::On(true),
193 Value::Between {
194 lower: "1",
195 upper: "9",
196 },
197 ] {
198 html.push_str(&field_html(&field, &Filling::of(value), opts));
199 }
200 }
201 }
202 html
203 }
204
205 /// A cell of every width and every priority, and every part a cell can be.
206 ///
207 /// The width and the drop are the classes no source literal carries: they are
208 /// chosen from the column and pushed, which is how `cell-fill` came to be
209 /// emitted by every table in the tree and named by nothing.
210 fn rows(opts: &Emit) -> String {
211 let mut columns = Vec::new();
212 for (index, width) in [Width::Content, Width::Fixed, Width::Fill]
213 .into_iter()
214 .enumerate()
215 {
216 for (rank, priority) in [Priority::Optional, Priority::Secondary, Priority::Essential]
217 .into_iter()
218 .enumerate()
219 {
220 columns.push(Column {
221 width,
222 priority,
223 sortable: true,
224 sorted: Some(if rank % 2 == 0 {
225 Sort::Ascending
226 } else {
227 Sort::Descending
228 }),
229 ..Column::new(NAMES[index * 3 + rank])
230 });
231 }
232 }
233
234 let parts = [
235 None,
236 Some(CellPart::Value),
237 Some(CellPart::Tokens),
238 Some(CellPart::Actions),
239 Some(CellPart::Link),
240 ];
241 let mut html = String::new();
242 for part in parts {
243 let cells: Vec<Cell<'_>> = columns
244 .iter()
245 .map(|column| Cell {
246 column: column.name,
247 part,
248 content: Markup("<span>x</span>"),
249 })
250 .collect();
251 html.push_str(&cells_html(&columns, &cells, opts));
252 }
253 html
254 }
255
256 /// A name per column, so the nine are nine columns rather than one repeated.
257 const NAMES: [&str; 9] = ["a", "b", "c", "d", "e", "f", "g", "h", "i"];
258
259 #[cfg(test)]
260 mod tests {
261 use super::*;
262
263 #[test]
264 fn every_class_this_crate_emits_is_one_its_vocabulary_names() {
265 // The guard `MAKEOVER_UNLISTED` in quasi-webview was standing in for.
266 // A name emitted and not written down shrinks the set an app checks
267 // against, so the app concludes its live rules are dead -- silently,
268 // and in the direction that deletes styling rather than keeping too
269 // much of it.
270 let opts = Emit::default();
271 let names = crate::vocabulary::names(&opts);
272 let emitted = emitted();
273 assert!(
274 emitted.len() > 30,
275 "the corpus rendered {} classes, which reads as the corpus having \
276 stopped calling the emitters rather than the crate having shrunk",
277 emitted.len()
278 );
279
280 let stranger: Vec<&String> = emitted
281 .iter()
282 // The one open family, and it is this crate's: a cell carries a
283 // class built from its column's name, which is app data and which
284 // no set can hold. See `list::push_column_class`.
285 .filter(|class| !class.starts_with("col-"))
286 .filter(|class| !names.contains(*class))
287 .collect();
288 assert!(
289 stranger.is_empty(),
290 "{} class(es) emitted that `vocabulary::names` does not hold. Give \
291 them a rule, or add them to the unruled list `names` reads:\n{}",
292 stranger.len(),
293 stranger
294 .iter()
295 .map(|c| format!(" .{c}"))
296 .collect::<Vec<_>>()
297 .join("\n")
298 );
299 }
300
301 #[test]
302 fn a_class_prefix_reaches_every_class_but_the_states() {
303 // The same guard with a prefix set, which is a different question: a
304 // name that reached markup without going through `class` is prefixed
305 // nowhere and would pass the check above, then fail in the one app that
306 // sets a prefix. `names` answers for both, because it prefixes the
307 // written-down half and leaves the states alone.
308 let opts = Emit {
309 class_prefix: "mk-",
310 ..Emit::default()
311 };
312 let names = crate::vocabulary::names(&opts);
313 let stranger: Vec<String> = emitted_with(&opts)
314 .into_iter()
315 .filter(|class| !class.starts_with("mk-col-"))
316 .filter(|class| !names.contains(class))
317 .collect();
318 assert!(
319 stranger.is_empty(),
320 "{} class(es) a prefixed render emits that `vocabulary::names` does \
321 not hold:\n{}",
322 stranger.len(),
323 stranger
324 .iter()
325 .map(|c| format!(" .{c}"))
326 .collect::<Vec<_>>()
327 .join("\n")
328 );
329 }
330 }
331