Skip to main content

max / makeover-webview

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