Skip to main content

max / makeover-webview

48.4 KB · 1284 lines History Blame Raw
1 //! Tests for [`super`].
2
3 use super::*;
4 use makeover_layout::{Accepted, Curve, Family};
5
6 fn field(kind: FieldKind) -> Field<'static> {
7 Field::new(kind, "title", "Title")
8 }
9
10 #[test]
11 fn a_value_cannot_break_out_of_the_attribute_it_sits_in() {
12 // The payload from goingson's own CHRONIC-XSS regression test.
13 let filling = Filling::of(Value::Text("x\" onfocus=alert(1) autofocus=\""));
14 let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
15 // The payload survives as text, which is the point: it is inert
16 // because the quote that would have closed the attribute is encoded,
17 // not because the words were filtered.
18 assert!(!html.contains("\" onfocus"), "{html}");
19 assert!(
20 html.contains("value=\"x&quot; onfocus=alert(1) autofocus=&quot;\""),
21 "{html}"
22 );
23 }
24
25 /// The seam quasi's suggestion source needs: a host's own attributes land
26 /// on the control, unescaped, and after everything this crate decided.
27 #[test]
28 fn a_host_can_write_its_own_attributes_onto_the_control() {
29 let mut filling = Filling::of(Value::Text("ru"));
30 filling.control_attrs = Some(Markup(
31 r#"role="combobox" aria-expanded="false" aria-controls="title-suggestions""#,
32 ));
33 let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
34 assert!(html.contains(r#"role="combobox""#), "{html}");
35 assert!(
36 html.contains(r#"aria-controls="title-suggestions""#),
37 "{html}"
38 );
39 // After the id, which is what "last" buys: a host can read what this
40 // emitter wrote and cannot be overwritten by it.
41 let id = html.find(r#"id="title""#).expect("id");
42 let role = html.find(r#"role="combobox""#).expect("role");
43 assert!(id < role, "{html}");
44 }
45
46 /// A radio group has no one control element, so there is nowhere honest to
47 /// put an attribute meant for the control. Documented on the member.
48 #[test]
49 fn a_radio_group_drops_control_attributes() {
50 let mut f = field(FieldKind::Radio);
51 let options = [Choice::new("a", "A")];
52 f.options = &options;
53 let filling = Filling {
54 control_attrs: Some(Markup(r#"data-host="1""#)),
55 ..Filling::default()
56 };
57 let html = field_html(&f, &filling, &Emit::default());
58 assert!(!html.contains("data-host"), "{html}");
59 }
60
61 #[test]
62 fn a_label_cannot_open_a_tag() {
63 let mut f = field(FieldKind::Text);
64 f.label = "<script>alert(1)</script>";
65 let html = field_html(&f, &Filling::default(), &Emit::default());
66 assert!(!html.contains("<script>"), "{html}");
67 assert!(html.contains("&lt;script&gt;"), "{html}");
68 }
69
70 #[test]
71 fn every_escaped_sink_is_covered_by_the_one_escaper() {
72 assert_eq!(escape("&<>\"'"), "&amp;&lt;&gt;&quot;&#39;");
73 // The character `textContent` serialization leaves alone, which is why
74 // the app needs two escapers and this needs one.
75 assert!(escape("\"").contains("&quot;"));
76 }
77
78 /// The streaming escaper is the one the emitters call and [`escape`] is a
79 /// buffer around it, so the two cannot be allowed to drift. It copies in
80 /// runs between the encoded characters, which is where a multi-byte
81 /// character would break it if the scan were not restricted to ASCII.
82 #[test]
83 fn the_streaming_escaper_appends_what_the_returning_one_returns() {
84 for text in [
85 "",
86 "plain",
87 "&<>\"'",
88 "&&&",
89 "a & b",
90 "trailing&",
91 "&leading",
92 "é世 & <b>naïve</b> \u{1f600}",
93 ] {
94 let mut out = String::from("kept: ");
95 escape_into(text, &mut out);
96 assert_eq!(out, format!("kept: {}", escape(text)), "{text:?}");
97 }
98 }
99
100 /// Same obligation one layer up: a form is a run of fields appended into one
101 /// buffer, and the two ways to get one have to agree byte for byte.
102 #[test]
103 fn a_streamed_field_is_the_field_the_other_form_returns() {
104 let kinds = [
105 FieldKind::Text,
106 FieldKind::Secret,
107 FieldKind::Number,
108 FieldKind::Checkbox,
109 FieldKind::Radio,
110 FieldKind::Select,
111 FieldKind::Textarea,
112 FieldKind::File,
113 FieldKind::Hidden,
114 ];
115 let choices = [Choice::plain("one"), Choice::plain("two")];
116 let opts = Emit {
117 class_prefix: "mk-",
118 ..Emit::default()
119 };
120 for kind in kinds {
121 let described = Field {
122 hint: Some("a hint"),
123 error: Some("wrong <here>"),
124 placeholder: Some("x\" y"),
125 options: &choices,
126 required: true,
127 max_length: Some(40),
128 min: Some("1"),
129 max: Some("9"),
130 extended: true,
131 ..Field::new(kind, "the & name", "The <label>")
132 };
133 let filling = Filling {
134 value: Value::Text("one"),
135 trailing: Some(Markup("<i>t</i>")),
136 control_attrs: Some(Markup(r#"data-host="1""#)),
137 id_prefix: Some("modal"),
138 };
139 let mut streamed = String::new();
140 field_html_into(&described, &filling, &opts, &mut streamed);
141 assert_eq!(
142 streamed,
143 field_html(&described, &filling, &opts),
144 "{kind:?}"
145 );
146
147 // And the bare field, where every optional half is absent.
148 let plain = Field::new(kind, "name", "Label");
149 let mut streamed = String::new();
150 field_html_into(&plain, &Filling::default(), &opts, &mut streamed);
151 assert_eq!(
152 streamed,
153 field_html(&plain, &Filling::default(), &opts),
154 "{kind:?}"
155 );
156 }
157 }
158
159 #[test]
160 fn markup_is_the_only_way_past_the_escaping() {
161 let filling = Filling {
162 trailing: Some(Markup("<div class=\"recurrence-config\"></div>")),
163 ..Filling::default()
164 };
165 let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
166 assert!(
167 html.contains("<div class=\"recurrence-config\"></div>"),
168 "{html}"
169 );
170 }
171
172 #[test]
173 fn an_invalid_field_carries_the_attribute_its_own_stylesheet_keys_on() {
174 let mut f = field(FieldKind::Text);
175 f.error = Some("Required");
176 let opts = Emit::default();
177 let html = field_html(&f, &Filling::default(), &opts);
178 assert!(html.contains("aria-invalid=\"true\""), "{html}");
179 // The selector the CSS side emits for exactly this state.
180 assert!(crate::stylesheet(&opts).contains("[aria-invalid=\"true\"]"));
181 // And the group is marked too, which a renderer without descendant
182 // selectors depends on.
183 assert!(html.contains("has-error"), "{html}");
184 }
185
186 #[test]
187 fn a_valid_field_claims_nothing_about_being_invalid() {
188 let html = field_html(
189 &field(FieldKind::Text),
190 &Filling::default(),
191 &Emit::default(),
192 );
193 assert!(!html.contains("aria-invalid"), "{html}");
194 assert!(!html.contains("has-error"), "{html}");
195 }
196
197 #[test]
198 fn a_note_sits_between_the_hint_and_the_error_and_carries_its_tone() {
199 let mut f = field(FieldKind::Text);
200 f.hint = Some("Keep it short");
201 f.note = Some((Tone::Warning, "Re-encoding drops embedded BWF"));
202 f.error = Some("Required");
203 let html = field_html(&f, &Filling::default(), &Emit::default());
204
205 // All three associated, in the order they are drawn.
206 assert!(
207 html.contains(r#"aria-describedby="title-hint title-note title-error""#),
208 "{html}"
209 );
210 assert!(
211 html.contains(r#"id="title-note" role="alert" data-tone="warning""#),
212 "{html}"
213 );
214 // And in that order in the document, so the reading order matches.
215 let hint = html.find("title-hint").unwrap();
216 let note = html.rfind("title-note").unwrap();
217 let err = html.rfind("title-error").unwrap();
218 assert!(hint < note && note < err, "{html}");
219 }
220
221 #[test]
222 fn a_quiet_note_is_polite_and_wears_no_tone_attribute() {
223 // Neutral is the bare class, matching every other toned component
224 // here, and only Warning and Danger interrupt.
225 let mut f = field(FieldKind::Text);
226 f.note = Some((Tone::Info, "This is what that setting implies"));
227 let html = field_html(&f, &Filling::default(), &Emit::default());
228 assert!(html.contains(r#"role="status" data-tone="info""#), "{html}");
229
230 f.note = Some((Tone::Neutral, "An ordinary fact"));
231 let html = field_html(&f, &Filling::default(), &Emit::default());
232 assert!(html.contains(r#"id="title-note" role="status">"#), "{html}");
233 assert!(!html.contains("data-tone"), "{html}");
234 }
235
236 #[test]
237 fn a_note_does_not_mark_the_group_invalid() {
238 // `Field::invalid` stays `error.is_some()`, and the renderer's
239 // `has-error` follows it rather than any message being present.
240 let mut f = field(FieldKind::Text);
241 f.note = Some((Tone::Danger, "This cannot be undone"));
242 let html = field_html(&f, &Filling::default(), &Emit::default());
243 assert!(!html.contains("has-error"), "{html}");
244 assert!(!html.contains(r#"aria-invalid="true""#), "{html}");
245 }
246
247 #[test]
248 fn the_hint_survives_an_error_arriving() {
249 let mut f = field(FieldKind::Text);
250 f.hint = Some("Keep it short");
251 f.error = Some("Required");
252 let html = field_html(&f, &Filling::default(), &Emit::default());
253 assert!(
254 html.contains("aria-describedby=\"title-hint title-error\""),
255 "{html}"
256 );
257 }
258
259 #[test]
260 fn a_secret_never_carries_its_value_into_the_markup() {
261 let filling = Filling::of(Value::Text("hunter2"));
262 let html = field_html(&field(FieldKind::Secret), &filling, &Emit::default());
263 assert!(!html.contains("hunter2"), "{html}");
264 assert!(html.contains("type=\"password\""), "{html}");
265 }
266
267 #[test]
268 fn a_hidden_field_is_the_input_and_nothing_else() {
269 let filling = Filling::of(Value::Text("42"));
270 let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
271 assert_eq!(html, "<input type=\"hidden\" name=\"title\" value=\"42\">");
272 }
273
274 #[test]
275 fn a_checkbox_labels_itself_and_takes_no_separate_label() {
276 let html = field_html(
277 &field(FieldKind::Checkbox),
278 &Filling::of(Value::On(true)),
279 &Emit::default(),
280 );
281 assert!(!html.contains("form-label"), "{html}");
282 assert!(html.contains("checked"), "{html}");
283 assert!(html.contains("<span>Title</span>"), "{html}");
284 }
285
286 #[test]
287 fn a_select_keeps_a_value_no_option_carries() {
288 let options = [Choice::plain("1"), Choice::plain("3"), Choice::plain("7")];
289 let f = Field::select("title", "Title", &options);
290 let html = field_html(&f, &Filling::of(Value::Text("10")), &Emit::default());
291 assert!(html.contains("data-unmatched=\"true\""), "{html}");
292 // Selected, so the next save round-trips it rather than writing the
293 // first option over the top of it.
294 assert!(html.contains("<option value=\"10\" selected"), "{html}");
295 }
296
297 #[test]
298 fn a_select_with_no_options_emits_an_empty_select() {
299 // The description says a select with no options is sayable, because an
300 // app whose option list has not loaded has exactly that. Emitting the
301 // empty select reports it on screen rather than in a log.
302 let f = Field::select("title", "Title", &[]);
303 let html = field_html(&f, &Filling::default(), &Emit::default());
304 assert!(html.contains("<select"), "{html}");
305 assert!(!html.contains("<option"), "{html}");
306 }
307
308 #[test]
309 fn an_unanswered_select_shows_its_ghost_text_and_cannot_be_chosen_back() {
310 let options = [Choice::new("sp404", "SP-404")];
311 let f = Field {
312 placeholder: Some("Select device..."),
313 ..Field::select("device", "Conform for device", &options)
314 };
315 let html = field_html(&f, &Filling::default(), &Emit::default());
316
317 assert!(
318 html.contains("<option value=\"\" disabled selected>Select device...</option>"),
319 "{html}"
320 );
321 // First, so the closed control reads it rather than the first real
322 // option.
323 assert!(
324 html.find("Select device...") < html.find("SP-404"),
325 "{html}"
326 );
327 }
328
329 #[test]
330 fn an_answered_select_drops_the_ghost_text() {
331 // It is an instruction about an empty field, so it has nothing to say
332 // once the field is answered, and leaving it in the list is one dead
333 // row every time the control is opened afterwards.
334 let options = [Choice::new("sp404", "SP-404")];
335 let f = Field {
336 placeholder: Some("Select device..."),
337 ..Field::select("device", "Conform for device", &options)
338 };
339 let html = field_html(&f, &Filling::of(Value::Text("sp404")), &Emit::default());
340 assert!(!html.contains("Select device..."), "{html}");
341 }
342
343 #[test]
344 fn a_wrong_answer_is_kept_and_is_not_the_ghost_text() {
345 // The two paths through `push_options` meet here. An unmatched value is
346 // an answer that is wrong and stays visible as itself; only the empty
347 // value is unanswered.
348 let options = [Choice::plain("1"), Choice::plain("7")];
349 let f = Field {
350 placeholder: Some("Pick one"),
351 ..Field::select("retention", "Keep backups for", &options)
352 };
353 let html = field_html(&f, &Filling::of(Value::Text("10")), &Emit::default());
354 assert!(html.contains("data-unmatched=\"true\""), "{html}");
355 assert!(!html.contains("Pick one"), "{html}");
356 }
357
358 #[test]
359 fn a_range_is_a_range_input_and_carries_its_extent() {
360 let f = Field {
361 curve: Curve::Linear { step: Some("0.01") },
362 ..Field::range("review", "Review above", "0", "1")
363 };
364 let html = field_html(&f, &Filling::of(Value::Text("0.72")), &Emit::default());
365 assert!(html.contains("type=\"range\""), "{html}");
366 assert!(html.contains("min=\"0\""), "{html}");
367 assert!(html.contains("max=\"1\""), "{html}");
368 // Without it the browser steps by 1 and a 0-to-1 question becomes a
369 // two-position control.
370 assert!(html.contains("step=\"0.01\""), "{html}");
371 }
372
373 #[test]
374 fn a_range_reads_its_granularity_off_the_curve_and_not_off_field_step() {
375 // The 0.32.0 narrowing, at the renderer. `Field::step` on a range is a
376 // site that has not been moved over, and emitting it would make the
377 // control step by a number the curve never agreed to.
378 let f = Field {
379 step: Some("99"),
380 ..Field::range("review", "Review above", "0", "1")
381 };
382 let html = field_html(&f, &Filling::of(Value::Text("0.5")), &Emit::default());
383 assert!(!html.contains("step="), "{html}");
384 }
385
386 #[test]
387 fn a_unit_is_adjacent_text_and_the_control_points_at_it() {
388 // Not decoration: the number and what it is measured in are one fact,
389 // so the association is what makes this worth emitting at all.
390 let f = Field {
391 unit: Some("dBFS"),
392 ..Field::range("threshold", "Threshold", "-96", "-20")
393 };
394 let html = field_html(&f, &Filling::of(Value::Text("-40")), &Emit::default());
395 assert!(html.contains(r#"id="threshold-unit""#), "{html}");
396 assert!(html.contains(">dBFS</span>"), "{html}");
397 assert!(
398 html.contains(r#"aria-describedby="threshold-unit""#),
399 "{html}"
400 );
401 // The label is the question's name and keeps no unit in it.
402 assert!(html.contains(">Threshold</label>"), "{html}");
403 }
404
405 #[test]
406 fn a_unit_takes_its_place_between_the_hint_and_the_error() {
407 let f = Field {
408 unit: Some("ms"),
409 hint: Some("How long the fade runs."),
410 error: Some("Too long."),
411 ..Field::new(FieldKind::Number, "fade", "Fade")
412 };
413 let html = field_html(&f, &Filling::of(Value::Text("50")), &Emit::default());
414 assert!(
415 html.contains(r#"aria-describedby="fade-hint fade-unit fade-error""#),
416 "{html}"
417 );
418 }
419
420 #[test]
421 fn a_unit_on_a_kind_that_is_not_a_quantity_is_ignored() {
422 // Sayable and ignored, the way `options` is on a kind that offers none.
423 // The renderer asks the description which kinds are measurable rather
424 // than keeping its own list.
425 let f = Field {
426 unit: Some("s"),
427 ..Field::new(FieldKind::Text, "name", "Name")
428 };
429 let html = field_html(&f, &Filling::of(Value::Text("kick")), &Emit::default());
430 assert!(!html.contains("name-unit"), "{html}");
431 assert!(!html.contains("aria-describedby"), "{html}");
432 }
433
434 #[test]
435 fn a_unit_cannot_break_out_of_the_span_it_sits_in() {
436 let f = Field {
437 unit: Some("</span><script>"),
438 ..Field::new(FieldKind::Number, "n", "N")
439 };
440 let html = field_html(&f, &Filling::of(Value::Text("1")), &Emit::default());
441 assert!(!html.contains("<script>"), "{html}");
442 assert!(html.contains("&lt;script&gt;"), "{html}");
443 }
444
445 #[test]
446 fn a_constant_ratio_curve_is_answered_with_a_linear_track() {
447 // The decided answer, not a shortfall: HTML has no logarithmic range
448 // input, so the browser draws the extent linearly. The value it submits
449 // is still a value in the field's own units, which is what every
450 // handler on this path reads. See the crate header.
451 let f = Field {
452 curve: Curve::Logarithmic {
453 step: Some("0.001"),
454 },
455 ..Field::range("attack", "Attack", "0.001", "5")
456 };
457 let html = field_html(&f, &Filling::of(Value::Text("0.005")), &Emit::default());
458 assert!(html.contains("type=\"range\""), "{html}");
459 assert!(html.contains("min=\"0.001\""), "{html}");
460 assert!(html.contains("max=\"5\""), "{html}");
461 assert!(html.contains("step=\"0.001\""), "{html}");
462 }
463
464 #[test]
465 fn a_number_with_bounds_is_still_typed_into() {
466 // The distinction the kind exists for, at the renderer where getting it
467 // wrong is most visible: goingson's `min="1"` duration must not come
468 // back as a slider.
469 let f = Field {
470 min: Some("1"),
471 ..Field::new(FieldKind::Number, "minutes", "Minutes")
472 };
473 let html = field_html(&f, &Filling::of(Value::Text("30")), &Emit::default());
474 assert!(html.contains("type=\"number\""), "{html}");
475 assert!(!html.contains("type=\"range\""), "{html}");
476 // And nothing invents a step for it.
477 assert!(!html.contains("step="), "{html}");
478 }
479
480 #[test]
481 fn an_unavailable_option_is_disabled_and_says_why() {
482 let options = [
483 Choice::new("chromatic", "Chromatic"),
484 Choice::new("multi", "Multi-sample").unless("Drop a second sample."),
485 ];
486 let f = Field::radio("mode", "Mode", &options);
487 let html = field_html(&f, &Filling::of(Value::Text("chromatic")), &Emit::default());
488
489 assert!(html.contains(" disabled"), "{html}");
490 assert!(html.contains("Drop a second sample."), "{html}");
491 // The option is still offered: dropping it is what costs the user the
492 // knowledge that the mode exists.
493 assert!(html.contains("value=\"multi\""), "{html}");
494 // And the reason is its own element, not run into the label.
495 assert!(html.contains("form-option-reason"), "{html}");
496 }
497
498 #[test]
499 fn an_unavailable_select_option_carries_its_reason_in_its_text() {
500 // A `<select>` gives an option no room for a second element, so the
501 // reason has to be in the text or be unreadable without a pointer.
502 let options = [Choice::new("multi", "Multi-sample").unless("Drop a second sample.")];
503 let f = Field::select("mode", "Mode", &options);
504 let html = field_html(&f, &Filling::default(), &Emit::default());
505 assert!(
506 html.contains(">Multi-sample: Drop a second sample.</option>"),
507 "{html}"
508 );
509 assert!(html.contains("disabled"), "{html}");
510 }
511
512 #[test]
513 fn an_option_can_say_what_picking_it_means() {
514 // makeover-layout 0.39.0. A radio group has room, so the line gets its
515 // own element under the label, and it is muted rather than unruled: an
516 // unruled second line renders identically to the label above it, which
517 // is a worse default than the markup this replaces.
518 let options = [
519 Choice::new("16", "Basic").detailing("$16/mo. Fits text, blogs, newsletters."),
520 Choice::new("24", "Small Files"),
521 ];
522 let f = Field::radio("tier", "Content tier", &options);
523 let html = field_html(&f, &Filling::default(), &Emit::default());
524
525 assert!(html.contains("form-option-detail"), "{html}");
526 assert!(
527 html.contains(">$16/mo. Fits text, blogs, newsletters.</span>"),
528 "{html}"
529 );
530 // One option carries it and the other does not, so the class appears
531 // once rather than on every label.
532 assert_eq!(html.matches("form-option-detail").count(), 1, "{html}");
533 assert!(
534 option_detail_rules(&Emit::default()).contains("var(--content-muted)"),
535 "the line orients the label rather than competing with it"
536 );
537 }
538
539 #[test]
540 fn an_option_reads_what_it_is_before_why_it_cannot_be_picked() {
541 // Two different sentences, drawn in the order they read in. A tier that
542 // is sold out is still a tier the reader is owed a description of.
543 let options = [Choice::new("24", "Small Files")
544 .detailing("$24/mo. Fits audio, plugins, binaries.")
545 .unless("Sold out while the founder window is open.")];
546 let f = Field::radio("tier", "Content tier", &options);
547 let html = field_html(&f, &Filling::default(), &Emit::default());
548
549 let detail = html.find("form-option-detail").expect("the detail");
550 let reason = html.find("form-option-reason").expect("the reason");
551 assert!(detail < reason, "{html}");
552 assert!(html.contains(" disabled"), "{html}");
553
554 // A `<select>` has room for neither element, so both run into the
555 // row's own text in the same order.
556 let f = Field::select("tier", "Content tier", &options);
557 let html = field_html(&f, &Filling::default(), &Emit::default());
558 assert!(
559 html.contains(concat!(
560 ">Small Files: $24/mo. Fits audio, plugins, binaries.",
561 ": Sold out while the founder window is open.</option>"
562 )),
563 "{html}"
564 );
565 }
566
567 #[test]
568 fn a_radio_group_is_named_by_its_label_instead_of_pointing_at_it() {
569 // The association inverts, and getting it wrong is silent: a
570 // `<label for>` aimed at a group points at no element, so the group
571 // simply has no accessible name and nothing reports that.
572 let options = [Choice::plain("copy"), Choice::plain("reference")];
573 let f = Field::radio("storage", "Storage style", &options);
574 let html = field_html(&f, &Filling::of(Value::Text("copy")), &Emit::default());
575
576 assert!(html.contains("id=\"storage-label\""), "{html}");
577 assert!(!html.contains("for=\"storage\""), "{html}");
578 assert!(html.contains("role=\"radiogroup\""), "{html}");
579 assert!(html.contains("aria-labelledby=\"storage-label\""), "{html}");
580 }
581
582 #[test]
583 fn an_interval_is_one_labelled_group_holding_both_ends() {
584 // The markup MNW's discover sidebar writes by hand, which is the
585 // measurement that decided the member: `role="group"` naming the
586 // question, two number boxes under it.
587 let f = Field::interval("min_price", "max_price", "Price");
588 let html = field_html(
589 &f,
590 &Filling::of(Value::Between {
591 lower: "5",
592 upper: "40",
593 }),
594 &Emit::default(),
595 );
596
597 assert!(html.contains("role=\"group\""), "{html}");
598 assert!(
599 html.contains("aria-labelledby=\"min_price-label\""),
600 "{html}"
601 );
602 assert!(html.contains("id=\"min_price-label\""), "{html}");
603 assert!(!html.contains("for=\"min_price\""), "{html}");
604 assert!(html.contains("name=\"min_price\""), "{html}");
605 assert!(html.contains("name=\"max_price\""), "{html}");
606 assert!(html.contains("value=\"5\""), "{html}");
607 assert!(html.contains("value=\"40\""), "{html}");
608 assert_eq!(html.matches("type=\"number\"").count(), 2, "{html}");
609 }
610
611 #[test]
612 fn both_ends_of_an_interval_take_the_whole_extent() {
613 // The extent describes the axis rather than either end of it, so a
614 // browser refuses the same values in both boxes.
615 let f = Field {
616 min: Some("0"),
617 max: Some("300"),
618 step: Some("1"),
619 ..Field::interval("bpm_min", "bpm_max", "BPM")
620 };
621 let html = field_html(&f, &Filling::default(), &Emit::default());
622
623 assert_eq!(html.matches("min=\"0\"").count(), 2, "{html}");
624 assert_eq!(html.matches("max=\"300\"").count(), 2, "{html}");
625 assert_eq!(html.matches("step=\"1\"").count(), 2, "{html}");
626 // Neither box holds anything, which is the open interval rather than an
627 // empty form: no filter on this axis at all.
628 assert_eq!(html.matches("value=\"\"").count(), 2, "{html}");
629 }
630
631 #[test]
632 fn an_interval_carries_the_fault_on_the_group_and_not_on_one_end() {
633 // A crossed interval is wrong about the answer, and the answer is the
634 // pair. This is the half two `Number` fields could not say.
635 let f = Field {
636 error: Some("The high end is below the low one."),
637 hint: Some("Leave an end empty for no bound."),
638 ..Field::interval("bpm_min", "bpm_max", "BPM")
639 };
640 let html = field_html(&f, &Filling::default(), &Emit::default());
641
642 assert_eq!(html.matches("aria-invalid=\"true\"").count(), 1, "{html}");
643 let group = html.find("role=\"group\"").expect("group");
644 let invalid = html.find("aria-invalid").expect("invalid");
645 let first_input = html.find("<input").expect("input");
646 assert!(invalid > group && invalid < first_input, "{html}");
647 assert!(
648 html.contains("aria-describedby=\"bpm_min-hint bpm_min-error\""),
649 "{html}"
650 );
651 }
652
653 #[test]
654 fn an_interval_with_one_end_named_draws_one_box() {
655 // Drawn as described rather than repaired. Inventing a name for the
656 // upper end would submit a parameter no handler reads, and
657 // `Field::interval` is what makes the omission unsayable at the source.
658 let f = Field::new(FieldKind::Interval, "bpm_min", "BPM");
659 let html = field_html(&f, &Filling::default(), &Emit::default());
660
661 assert_eq!(html.matches("<input").count(), 1, "{html}");
662 assert!(html.contains("name=\"bpm_min\""), "{html}");
663 }
664
665 #[test]
666 fn every_option_shares_the_name_and_only_the_current_one_is_checked() {
667 // One `name` is what makes them one answer rather than three; distinct
668 // ids are what keep each `<label>` wrapping its own input.
669 let options = [
670 Choice::plain("copy"),
671 Choice::plain("reference"),
672 Choice::plain("link"),
673 ];
674 let f = Field::radio("storage", "Storage style", &options);
675 let html = field_html(&f, &Filling::of(Value::Text("reference")), &Emit::default());
676
677 assert_eq!(html.matches("name=\"storage\"").count(), 3, "{html}");
678 assert_eq!(html.matches(" checked").count(), 1, "{html}");
679 assert!(
680 html.contains("value=\"reference\" checked"),
681 "the checked one is the one held: {html}"
682 );
683 for index in 0..3 {
684 assert!(html.contains(&format!("id=\"storage-{index}\"")), "{html}");
685 }
686 }
687
688 #[test]
689 fn a_radio_group_carries_the_error_rather_than_any_one_option() {
690 // What is wrong is the answer, not one of the alternatives, so marking
691 // a single input invalid would say something false. Same reading
692 // `Field::invalid` gives one level up.
693 let options = [Choice::plain("copy"), Choice::plain("reference")];
694 let f = Field {
695 error: Some("Pick one."),
696 hint: Some("Cannot be changed later."),
697 ..Field::radio("storage", "Storage style", &options)
698 };
699 let html = field_html(&f, &Filling::default(), &Emit::default());
700
701 assert_eq!(html.matches("aria-invalid=\"true\"").count(), 1, "{html}");
702 assert!(
703 html.contains("aria-describedby=\"storage-hint storage-error\""),
704 "{html}"
705 );
706 // The group is the element that carries them, so they land before the
707 // first option rather than on it.
708 let group = html.find("role=\"radiogroup\"").expect("group");
709 let first = html.find("type=\"radio\"").expect("an option");
710 assert!(group < first, "{html}");
711 }
712
713 #[test]
714 fn a_compulsory_radio_group_marks_every_option() {
715 // How HTML says a group is compulsory: the constraint reads as
716 // satisfied when any one of them is checked.
717 let options = [Choice::plain("copy"), Choice::plain("reference")];
718 let f = Field {
719 required: true,
720 ..Field::radio("storage", "Storage style", &options)
721 };
722 let html = field_html(&f, &Filling::default(), &Emit::default());
723 assert_eq!(html.matches(" required").count(), 2, "{html}");
724 }
725
726 #[test]
727 fn a_radio_option_cannot_break_out_of_its_attribute() {
728 // Values are `&str` and carry whatever the app put in them. The ids are
729 // numbered rather than derived from the value for the same reason.
730 let hostile = [Choice::new(
731 "x\" onclick=alert(1) data-x=\"",
732 "<script>alert(1)</script>",
733 )];
734 let f = Field::radio("storage", "Storage style", &hostile);
735 let html = field_html(&f, &Filling::default(), &Emit::default());
736
737 // The payload survives as text; what must not survive is the quote
738 // that would end the attribute and let the rest of it become markup.
739 assert!(html.contains("value=\"x&quot; onclick=alert(1)"), "{html}");
740 assert!(!html.contains("<script>"), "{html}");
741 assert!(html.contains("id=\"storage-0\""), "{html}");
742 }
743
744 #[test]
745 fn a_radio_group_with_no_options_emits_an_empty_group() {
746 // Same position the select takes, and the description's own.
747 let f = Field::radio("storage", "Storage style", &[]);
748 let html = field_html(&f, &Filling::default(), &Emit::default());
749 assert!(html.contains("role=\"radiogroup\""), "{html}");
750 assert!(!html.contains("type=\"radio\""), "{html}");
751 }
752
753 #[test]
754 fn a_placeholder_comes_off_the_description_and_is_escaped() {
755 // It arrived in `Filling` until makeover-layout 0.8.0 and was never
756 // covered here; it is a value in an attribute like any other.
757 let f = Field {
758 placeholder: Some("x\" onfocus=alert(1) autofocus=\""),
759 ..field(FieldKind::Text)
760 };
761 let html = field_html(&f, &Filling::default(), &Emit::default());
762 assert!(html.contains("placeholder=\""), "{html}");
763 assert!(!html.contains("\" onfocus"), "{html}");
764 }
765
766 #[test]
767 fn a_select_marks_the_option_that_matches() {
768 let options = [Choice::plain("1"), Choice::plain("3")];
769 let f = Field::select("title", "Title", &options);
770 let html = field_html(&f, &Filling::of(Value::Text("3")), &Emit::default());
771 assert!(
772 html.contains("<option value=\"3\" selected>3</option>"),
773 "{html}"
774 );
775 assert!(html.contains("<option value=\"1\">1</option>"), "{html}");
776 assert!(!html.contains("data-unmatched"), "{html}");
777 }
778
779 #[test]
780 fn a_textarea_carries_its_value_as_text_and_not_as_an_attribute() {
781 let filling = Filling::of(Value::Text("two\nlines"));
782 let html = field_html(&field(FieldKind::Textarea), &filling, &Emit::default());
783 assert!(html.contains(">two\nlines</textarea>"), "{html}");
784 }
785
786 #[test]
787 fn a_markdown_field_is_a_textarea_that_says_what_its_value_is() {
788 // The mark is the whole difference. Without it a described editor is a
789 // plain box, and an enhancement looking for editors to upgrade has
790 // nothing to find -- which is the state MNW's four hand-written section
791 // editors would have had to keep living in.
792 let filling = Filling::of(Value::Text("# Heading"));
793 let html = field_html(&field(FieldKind::Rich), &filling, &Emit::default());
794 assert!(html.contains("<textarea"), "{html}");
795 assert!(html.contains(r#"data-format="markdown""#), "{html}");
796 assert!(html.contains("># Heading</textarea>"), "{html}");
797
798 // A plain textarea claims nothing about its value, so the marker has to
799 // be absent rather than present-and-different.
800 let plain = field_html(&field(FieldKind::Textarea), &filling, &Emit::default());
801 assert!(!plain.contains("data-format"), "{plain}");
802
803 // And it is not an input: the catch-all in `input_type` would have
804 // degraded it to a single-line text box, which is the wrong shape for
805 // markdown rather than a lossless fallback.
806 assert!(!html.contains("<input"), "{html}");
807 }
808
809 #[test]
810 fn a_markdown_field_gets_the_preview_the_member_permits() {
811 // The mark on its own is what 0.50.0 shipped, and nothing read it. What
812 // a conversion needs is the pair MNW's `partial-item-text-editor.js`
813 // already draws, so describing the field is not a way to lose it.
814 let filling = Filling::of(Value::Text("# Heading"));
815 let html = field_html(&field(FieldKind::Rich), &filling, &Emit::default());
816 assert!(html.contains("data-editor-mode=\"write\""), "{html}");
817 assert!(html.contains("data-editor-mode=\"preview\""), "{html}");
818 assert!(html.contains("data-editor-preview"), "{html}");
819 // Write is the mode a fresh editor is in, and the segment says so twice
820 // because the sheet reads one and a screen reader reads the other.
821 assert!(
822 html.contains("class=\"segment chosen\" data-editor-mode=\"write\" aria-pressed=\"true\""),
823 "{html}"
824 );
825 assert!(
826 html.contains("data-editor-mode=\"preview\" aria-pressed=\"false\""),
827 "{html}"
828 );
829 // The value is still the textarea's, and still text rather than an
830 // attribute. The chrome sits around the control, not in place of it.
831 assert!(html.contains("># Heading</textarea>"), "{html}");
832 }
833
834 #[test]
835 fn a_plain_textarea_gets_no_editor_chrome() {
836 let filling = Filling::of(Value::Text("plain"));
837 let html = field_html(&field(FieldKind::Textarea), &filling, &Emit::default());
838 assert!(!html.contains("data-editor-mode"), "{html}");
839 assert!(!html.contains("data-editor-preview"), "{html}");
840 assert!(!html.contains("segment"), "{html}");
841 }
842
843 #[test]
844 fn nothing_the_editor_emits_renders_the_value_as_markup() {
845 // The whole of this crate's half of the sanitising question: the pane is
846 // empty, so no value reaches markup through it, and the host's own
847 // renderer keeps the guarantee it already has.
848 let filling = Filling::of(Value::Text("<img src=x onerror=alert(1)>"));
849 let html = field_html(&field(FieldKind::Rich), &filling, &Emit::default());
850 assert!(html.contains("data-editor-preview></div>"), "{html}");
851 assert!(!html.contains("<img"), "{html}");
852 assert!(
853 html.contains("&lt;img src=x onerror=alert(1)&gt;"),
854 "{html}"
855 );
856 }
857
858 #[test]
859 fn the_editor_rules_gate_on_the_attribute_and_on_a_binding() {
860 let css = editor_rules(&Emit::default());
861 // Behind the attribute, which is the reason the mark is an attribute:
862 // a class-keyed gate would be prefixed away from the enhancement that
863 // selects on it.
864 for line in css.lines().filter(|line| line.contains('{')) {
865 assert!(line.contains("[data-format=\"markdown\"]"), "{line}");
866 }
867 // Nothing is hidden and no control appears until something binds the
868 // editor. A reader with no script gets the textarea alone.
869 assert!(
870 css.contains("[data-format=\"markdown\"] > .form-editor-modes {\n display: none;\n}")
871 );
872 assert!(css.contains(
873 "[data-format=\"markdown\"][data-ready] > .form-editor-modes {\n display: block;\n}"
874 ));
875 assert!(css.contains(
876 "[data-ready][data-mode=\"preview\"] > .form-editor-preview {\n display: block;\n}"
877 ));
878 assert!(css.contains("[data-ready][data-mode=\"preview\"] > .field {\n display: none;\n}"));
879 // No magnitude, the line this crate holds everywhere else.
880 assert!(!css.contains("px"), "{css}");
881 assert!(!css.contains("rem"), "{css}");
882 }
883
884 /// The prefix reaches the chrome as well, and the gate deliberately does
885 /// not: an app assembling the sheet with its own prefix still has the
886 /// selector an enhancement finds the editors by.
887 #[test]
888 fn the_editor_chrome_is_prefixed_and_its_gate_is_not() {
889 let opts = Emit {
890 class_prefix: "mk-",
891 ..Emit::default()
892 };
893 let html = field_html(&field(FieldKind::Rich), &Filling::default(), &opts);
894 assert!(html.contains("class=\"mk-form-editor-modes\""), "{html}");
895 assert!(html.contains("class=\"mk-form-editor-preview\""), "{html}");
896 assert!(html.contains("class=\"mk-segment chosen\""), "{html}");
897 assert!(html.contains("data-format=\"markdown\""), "{html}");
898
899 let css = editor_rules(&opts);
900 assert!(css.contains(".mk-form-editor-modes"), "{css}");
901 assert!(css.contains("[data-format=\"markdown\"]"), "{css}");
902 }
903
904 /// Every class the editor puts in markup is one the generated sheet rules,
905 /// which is `FACET_CLASSES`' obligation without a list to keep: these two
906 /// have rules, so the vocabulary seal picks them up from the sheet itself.
907 #[test]
908 fn the_editor_classes_are_in_the_vocabulary() {
909 let opts = Emit::default();
910 let names = crate::vocabulary::names(&opts);
911 for name in ["form-editor-modes", "form-editor-preview", "segment"] {
912 assert!(names.contains(name), "{name} is not in the vocabulary");
913 }
914 }
915
916 #[test]
917 fn the_class_prefix_reaches_the_markup_as_well_as_the_stylesheet() {
918 let opts = Emit {
919 class_prefix: "mk-",
920 ..Emit::default()
921 };
922 let html = field_html(&field(FieldKind::Text), &Filling::default(), &opts);
923 assert!(html.contains("class=\"mk-form-group\""), "{html}");
924 assert!(html.contains("class=\"mk-field\""), "{html}");
925 }
926
927 #[test]
928 fn a_datetime_asking_for_an_instant_is_marked_for_the_script_that_converts_it() {
929 let mut f = field(FieldKind::DateTime);
930 f.as_instant = true;
931 let html = field_html(&f, &Filling::default(), &Emit::default());
932 assert!(html.contains("data-instant=\"true\""), "{html}");
933 // The control is unchanged: the flag says what is submitted, not what
934 // is drawn.
935 assert!(html.contains("type=\"datetime-local\""), "{html}");
936 }
937
938 #[test]
939 fn only_a_datetime_can_name_a_moment_so_only_a_datetime_is_marked() {
940 for kind in [FieldKind::Date, FieldKind::Text, FieldKind::Number] {
941 let mut f = field(kind);
942 f.as_instant = true;
943 let html = field_html(&f, &Filling::default(), &Emit::default());
944 assert!(!html.contains("data-instant"), "{kind:?}: {html}");
945 }
946 }
947
948 #[test]
949 fn a_datetime_that_did_not_ask_carries_no_mark() {
950 let html = field_html(
951 &field(FieldKind::DateTime),
952 &Filling::default(),
953 &Emit::default(),
954 );
955 assert!(!html.contains("data-instant"), "{html}");
956 }
957
958 #[test]
959 fn an_extended_field_says_so_and_leaves_the_disclosure_to_the_form() {
960 let mut f = field(FieldKind::Text);
961 f.extended = true;
962 let html = field_html(&f, &Filling::default(), &Emit::default());
963 assert!(html.contains("data-extended=\"true\""), "{html}");
964 }
965
966 /// The prefix scopes the id and leaves the name alone. Prefixing the name
967 /// too would change what the form submits, which is the failure this pair
968 /// of assertions exists to catch rather than describe.
969 #[test]
970 fn the_id_prefix_scopes_the_id_and_never_the_name() {
971 let mut f = field(FieldKind::Text);
972 f.hint = Some("Keep it short");
973 f.error = Some("Required");
974 let filling = Filling {
975 id_prefix: Some("form-modal-task-edit"),
976 ..Filling::default()
977 };
978 let html = field_html(&f, &filling, &Emit::default());
979
980 assert!(
981 html.contains(r#"id="form-modal-task-edit-title""#),
982 "{html}"
983 );
984 assert!(html.contains(r#"name="title""#), "{html}");
985 assert!(
986 !html.contains(r#"name="form-modal-task-edit-title""#),
987 "{html}"
988 );
989
990 // The label and both associations follow the id, or they point at
991 // nothing once the same form is on screen twice.
992 assert!(
993 html.contains(r#"for="form-modal-task-edit-title""#),
994 "{html}"
995 );
996 assert!(
997 html.contains(
998 r#"aria-describedby="form-modal-task-edit-title-hint form-modal-task-edit-title-error""#
999 ),
1000 "{html}"
1001 );
1002 assert!(
1003 html.contains(r#"id="form-modal-task-edit-title-hint""#),
1004 "{html}"
1005 );
1006 }
1007
1008 #[test]
1009 fn a_hidden_field_submits_its_bare_name_under_a_prefix() {
1010 let filling = Filling {
1011 value: Value::Text("42"),
1012 id_prefix: Some("scoped"),
1013 ..Filling::default()
1014 };
1015 let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
1016 assert_eq!(html, r#"<input type="hidden" name="title" value="42">"#);
1017 }
1018
1019 /// These three exist so a touch keyboard and the platform's validation
1020 /// arrive with the field. Emitting text for any of them is the regression
1021 /// the variants were added to prevent, so the type is asserted directly.
1022 #[test]
1023 fn a_constraint_becomes_the_browsers_own_attribute() {
1024 // makeover-layout 0.11.0's model: the description carries the rule and
1025 // each renderer emits its host's idiom for it. Enforcement is still
1026 // whoever validated's, and arrives back as `error`.
1027 let html = field_html(
1028 &Field {
1029 max_length: Some(100),
1030 min: Some("1"),
1031 max: Some("240"),
1032 required: true,
1033 ..Field::new(FieldKind::Number, "minutes", "Minutes")
1034 },
1035 &Filling::default(),
1036 &Emit::default(),
1037 );
1038 assert!(html.contains(r#"maxlength="100""#));
1039 assert!(html.contains(r#"min="1""#));
1040 assert!(html.contains(r#"max="240""#));
1041 assert!(html.contains(" required"));
1042 }
1043
1044 #[test]
1045 fn a_bound_is_emitted_as_written_and_escaped_like_anything_else() {
1046 // The bound is text because it is only a number for some of the kinds
1047 // that take one; goingson's own sites are a duration and a datetime.
1048 let html = field_html(
1049 &Field {
1050 min: Some("2026-08-09T14:30"),
1051 ..Field::new(FieldKind::Text, "starts", "Starts")
1052 },
1053 &Filling::default(),
1054 &Emit::default(),
1055 );
1056 assert!(html.contains(r#"min="2026-08-09T14:30""#));
1057 }
1058
1059 #[test]
1060 fn a_file_field_is_a_file_input() {
1061 // `844b5ae0`. A field that takes any file emits no `accept` at all,
1062 // which is the browser's own "any file". `accept=""` is a filter that
1063 // means nothing on one browser and everything on another.
1064 let html = field_html(
1065 &Field::new(FieldKind::File, "attachment", "Attachment"),
1066 &Filling::default(),
1067 &Emit::default(),
1068 );
1069 assert!(html.contains(r#"type="file""#));
1070 assert!(!html.contains("accept="));
1071 assert!(!html.contains("multiple"));
1072 // And it never carries a value: a file input's value is not settable
1073 // from markup, and the browser refuses one that tries.
1074 assert!(!html.contains("value="));
1075 }
1076
1077 #[test]
1078 fn an_accept_list_is_comma_joined_in_the_attributes_own_format() {
1079 // `f7261a5a`, makeover-layout 0.31.0. Each entry writes itself: a
1080 // family is its wildcard, a media type is itself, a suffix keeps its
1081 // leading dot and however many more it has.
1082 const MIXED: &[Accepted<'_>] = &[
1083 Accepted::Family(Family::Image),
1084 Accepted::Type("text/csv"),
1085 Accepted::Suffix(".tar.gz"),
1086 ];
1087 let html = field_html(
1088 &Field {
1089 multiple: true,
1090 ..Field::upload("drop", "Drop files", MIXED)
1091 },
1092 &Filling::default(),
1093 &Emit::default(),
1094 );
1095 assert!(
1096 html.contains(r#"accept="image/*,text/csv,.tar.gz""#),
1097 "{html}"
1098 );
1099 assert!(html.contains(" multiple"), "{html}");
1100 }
1101
1102 #[test]
1103 fn an_accept_entry_cannot_end_the_attribute_it_sits_in() {
1104 // The list reaches an attribute value, so it is escaped like every
1105 // other string that does. Nothing in the tree writes a quote into one;
1106 // that it cannot is the point.
1107 const HOSTILE: &[Accepted<'_>] = &[Accepted::Type(r#"image/x" onload="x"#)];
1108 let html = field_html(
1109 &Field::upload("cover", "Cover", HOSTILE),
1110 &Filling::default(),
1111 &Emit::default(),
1112 );
1113 assert!(!html.contains(r#"onload="x"#), "{html}");
1114 }
1115
1116 #[test]
1117 fn the_typed_text_kinds_keep_their_input_type() {
1118 for (kind, expected) in [
1119 (FieldKind::Email, "email"),
1120 (FieldKind::Url, "url"),
1121 (FieldKind::Tel, "tel"),
1122 (FieldKind::Date, "date"),
1123 (FieldKind::DateTime, "datetime-local"),
1124 ] {
1125 let html = field_html(&field(kind), &Filling::default(), &Emit::default());
1126 assert!(
1127 html.contains(&format!(r#"type="{expected}""#)),
1128 "{kind:?} emitted {html}"
1129 );
1130 }
1131 }
1132
1133 #[test]
1134 fn a_temporal_field_is_a_native_control_and_not_a_hinted_text_box() {
1135 // The regression this closes: described as text with a hint reading
1136 // "YYYY-MM-DD", which loses the picker, the platform's validation and
1137 // the touch keyboard, and asks prose to do all three.
1138 for kind in [FieldKind::Date, FieldKind::DateTime] {
1139 let html = field_html(&field(kind), &Filling::default(), &Emit::default());
1140 assert!(!html.contains(r#"type="text""#), "{kind:?} emitted {html}");
1141 }
1142 }
1143
1144 #[test]
1145 fn no_prefix_leaves_the_id_as_the_name() {
1146 let html = field_html(
1147 &field(FieldKind::Text),
1148 &Filling::default(),
1149 &Emit::default(),
1150 );
1151 assert!(html.contains(r#"id="title" name="title""#), "{html}");
1152 }
1153
1154 /// Two variants and two tiers, which is the smallest list that can show
1155 /// where a group opens and that two badges differ.
1156 const THEMES: &[makeover_layout::ThemeChoice<'_>] = &[
1157 makeover_layout::ThemeChoice::new(
1158 "goingson",
1159 "GoingsOn",
1160 ThemeVariant::Light,
1161 makeover_layout::Contrast::High,
1162 ),
1163 makeover_layout::ThemeChoice::new(
1164 "ayu-light",
1165 "Ayu Light",
1166 ThemeVariant::Light,
1167 makeover_layout::Contrast::Low,
1168 ),
1169 makeover_layout::ThemeChoice::new(
1170 "carbonfox",
1171 "Carbonfox",
1172 ThemeVariant::Dark,
1173 makeover_layout::Contrast::High,
1174 ),
1175 ];
1176
1177 #[test]
1178 fn a_theme_picker_opens_one_optgroup_per_variant() {
1179 let f = Field::theme("theme", "Theme", THEMES);
1180 let html = field_html(&f, &Filling::default(), &Emit::default());
1181
1182 assert_eq!(html.matches("<optgroup").count(), 2, "{html}");
1183 assert_eq!(html.matches("</optgroup>").count(), 2, "{html}");
1184 assert!(
1185 html.contains(r#"<optgroup label="Light" data-variant="light">"#),
1186 "{html}"
1187 );
1188 assert!(
1189 html.contains(r#"<optgroup label="Dark" data-variant="dark">"#),
1190 "{html}"
1191 );
1192 // The two light themes share one group: a new group opens on a change
1193 // of variant and on nothing else.
1194 assert!(
1195 html.find("Ayu Light") < html.find("<optgroup label=\"Dark\""),
1196 "{html}"
1197 );
1198 }
1199
1200 #[test]
1201 fn every_theme_carries_its_measured_tier() {
1202 // The fact the three hand-written pickers lost. It rides in the text
1203 // because a `<select>`'s options take no elements, and in an attribute
1204 // because a stylesheet cannot read text.
1205 let f = Field::theme("theme", "Theme", THEMES);
1206 let html = field_html(&f, &Filling::default(), &Emit::default());
1207
1208 assert!(html.contains(r#"data-contrast="high""#), "{html}");
1209 assert!(html.contains(r#"data-contrast="low""#), "{html}");
1210 assert!(html.contains("GoingsOn (AA)"), "{html}");
1211 assert!(html.contains("Ayu Light (low)"), "{html}");
1212 }
1213
1214 #[test]
1215 fn the_follow_row_is_first_and_sits_in_no_group() {
1216 // It names no theme and belongs to no variant, so grouping it would be
1217 // inventing a fourth variant for one row.
1218 let f =
1219 Field::theme("theme", "Theme", THEMES).following(Choice::new("system", "Follow System"));
1220 let html = field_html(&f, &Filling::default(), &Emit::default());
1221
1222 let follow = html.find("Follow System").expect("the row was offered");
1223 assert!(follow < html.find("<optgroup").expect("groups"), "{html}");
1224 }
1225
1226 #[test]
1227 fn the_stored_theme_is_the_selected_one() {
1228 let f =
1229 Field::theme("theme", "Theme", THEMES).following(Choice::new("system", "Follow System"));
1230
1231 let named = field_html(&f, &Filling::of(Value::Text("carbonfox")), &Emit::default());
1232 assert!(
1233 named.contains(r#"value="carbonfox" data-contrast="high" selected"#),
1234 "{named}"
1235 );
1236 assert!(!named.contains(r#"value="system" selected"#), "{named}");
1237
1238 let following = field_html(&f, &Filling::of(Value::Text("system")), &Emit::default());
1239 assert!(
1240 following.contains(r#"value="system" selected"#),
1241 "{following}"
1242 );
1243 }
1244
1245 #[test]
1246 fn a_theme_that_is_no_longer_installed_keeps_its_value() {
1247 // `push_options`' rule, met again: a value no row carries is a wrong
1248 // answer rather than an absent one, and dropping it would save a
1249 // different theme over the user's on the next write.
1250 let f = Field::theme("theme", "Theme", THEMES);
1251 let html = field_html(
1252 &f,
1253 &Filling::of(Value::Text("deleted-theme")),
1254 &Emit::default(),
1255 );
1256 assert!(html.contains(r#"data-unmatched="true""#), "{html}");
1257 assert!(
1258 html.contains(r#"<option value="deleted-theme" selected"#),
1259 "{html}"
1260 );
1261 }
1262
1263 #[test]
1264 fn the_follow_row_is_not_a_stray_value() {
1265 // The near-miss: `system` is carried by no `ThemeChoice`, so a check
1266 // that only walked the theme list would emit a duplicate unmatched row
1267 // beside the real one.
1268 let f =
1269 Field::theme("theme", "Theme", THEMES).following(Choice::new("system", "Follow System"));
1270 let html = field_html(&f, &Filling::of(Value::Text("system")), &Emit::default());
1271 assert!(!html.contains("data-unmatched"), "{html}");
1272 }
1273
1274 #[test]
1275 fn a_machine_with_no_themes_still_gets_a_picker() {
1276 // `Field::themes`' own position: an app whose theme directories hold
1277 // nothing has exactly this, and the empty control says so on screen.
1278 let f = Field::theme("theme", "Theme", &[]).following(Choice::new("system", "Follow System"));
1279 let html = field_html(&f, &Filling::default(), &Emit::default());
1280 assert!(html.contains("<select"), "{html}");
1281 assert!(!html.contains("<optgroup"), "{html}");
1282 assert!(html.contains("Follow System"), "{html}");
1283 }
1284