Skip to main content

max / alloy_tui

23.2 KB · 673 lines History Blame Raw
1 //! Tests for [`super`].
2
3 use super::*;
4 use ratatui::style::Color;
5
6 fn theme() -> Theme {
7 crate::theme::test_theme(crate::theme::Mode::Dark)
8 }
9
10 fn list_of(n: usize, selected: Option<usize>) -> AlloyList<'static> {
11 // Leaked so the test list can hold a 'static theme reference; the
12 // widget borrows rather than owns, and these are per-test one-offs.
13 let theme: &'static Theme = Box::leak(Box::new(theme()));
14 let items: Vec<Line<'static>> = (0..n).map(|i| Line::from(format!("row {i}"))).collect();
15 AlloyList::new(theme, items).selected(selected)
16 }
17
18 #[test]
19 fn short_list_never_scrolls() {
20 assert_eq!(list_of(3, Some(2)).offset(10), 0);
21 }
22
23 // Selection near the top must not scroll past the start of the list — a
24 // naive `selected - height/2` underflows or shows blank rows above row 0.
25 #[test]
26 fn offset_clamps_at_the_top() {
27 assert_eq!(list_of(50, Some(0)).offset(10), 0);
28 assert_eq!(list_of(50, Some(2)).offset(10), 0);
29 }
30
31 // Selection at the end must land the last row on the last visible line,
32 // not scroll into empty space past the end of the list.
33 #[test]
34 fn offset_clamps_at_the_bottom() {
35 assert_eq!(list_of(50, Some(49)).offset(10), 40);
36 }
37
38 #[test]
39 fn offset_centers_a_midlist_selection() {
40 assert_eq!(list_of(50, Some(25)).offset(10), 20);
41 }
42
43 #[test]
44 fn row_y_maps_visible_items_to_screen_rows() {
45 let area = Rect::new(0, 5, 20, 10);
46 assert_eq!(list_row_y(area, 3, Some(0), 0), Some(5));
47 assert_eq!(list_row_y(area, 3, Some(0), 2), Some(7));
48 }
49
50 // After a scroll the mapping has to follow the offset. A connector using a
51 // separate copy of the scroll rule is exactly what this prevents.
52 #[test]
53 fn row_y_accounts_for_scrolling() {
54 let area = Rect::new(0, 0, 20, 10);
55 // 50 items, selection at 25 => offset 20, so item 20 is the top row.
56 assert_eq!(list_row_y(area, 50, Some(25), 20), Some(0));
57 assert_eq!(list_row_y(area, 50, Some(25), 25), Some(5));
58 }
59
60 #[test]
61 fn row_y_is_none_for_rows_scrolled_out_of_view() {
62 let area = Rect::new(0, 0, 20, 10);
63 assert_eq!(
64 list_row_y(area, 50, Some(25), 0),
65 None,
66 "above the viewport"
67 );
68 assert_eq!(
69 list_row_y(area, 50, Some(25), 49),
70 None,
71 "below the viewport"
72 );
73 assert_eq!(
74 list_row_y(area, 3, Some(0), 9),
75 None,
76 "past the end of the list"
77 );
78 }
79
80 fn render_tabs(selected: usize, width: u16) -> String {
81 let theme = theme();
82 let mut buf = Buffer::empty(Rect::new(0, 0, width, 1));
83 AlloyTabs::new(&theme, ["installed", "boxes", "system"])
84 .selected(selected)
85 .render(Rect::new(0, 0, width, 1), &mut buf);
86 buf.content()
87 .iter()
88 .map(ratatui::buffer::Cell::symbol)
89 .collect()
90 }
91
92 #[test]
93 fn selected_tab_is_bracketed_and_others_are_not() {
94 let rendered = render_tabs(0, 60);
95 assert!(
96 rendered.contains("[ installed ]"),
97 "selected tab is bracketed"
98 );
99 assert!(!rendered.contains("[ boxes ]"), "unselected tabs are not");
100 assert!(rendered.contains("boxes"), "unselected labels still render");
101 }
102
103 // The bar must not shift horizontally as selection moves, or every tab
104 // change reads as the whole row twitching. Unselected labels pad to the
105 // bracket width for exactly this reason.
106 #[test]
107 fn labels_hold_their_columns_across_selections() {
108 let first = render_tabs(0, 60);
109 let last = render_tabs(2, 60);
110 assert_eq!(
111 first.find("system"),
112 last.find("system"),
113 "a label sits in the same columns whichever tab is selected"
114 );
115 }
116
117 // FocusRing::focus ignores out-of-range slots rather than clamping, and the
118 // bar has to agree: showing a neighbouring tab as current would misreport
119 // which screen the user is looking at.
120 #[test]
121 fn out_of_range_selection_brackets_nothing() {
122 let rendered = render_tabs(9, 60);
123 assert!(!rendered.contains('['), "no tab is marked current");
124 assert!(rendered.contains("installed"), "labels still render");
125 }
126
127 #[test]
128 fn zero_height_area_renders_nothing_rather_than_panicking() {
129 let theme = theme();
130 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1));
131 AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 40, 0), &mut buf);
132 AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 0, 1), &mut buf);
133 }
134
135 fn render_modal(area: Rect) -> Vec<String> {
136 let theme = theme();
137 let mut buf = Buffer::empty(area);
138 AlloyModal::new(&theme, "remove", "Remove tailscale?").render(area, &mut buf);
139 (0..area.height)
140 .map(|y| {
141 (0..area.width)
142 .map(|x| buf[(x, y)].symbol())
143 .collect::<String>()
144 })
145 .collect()
146 }
147
148 #[test]
149 fn modal_shows_its_message_and_both_keys() {
150 let rows = render_modal(Rect::new(0, 0, 40, 7)).join("\n");
151 assert!(rows.contains("Remove tailscale?"), "message renders");
152 assert!(rows.contains("remove"), "title renders");
153 assert!(rows.contains("Enter"), "confirm key renders");
154 assert!(rows.contains("Esc"), "cancel key renders");
155 }
156
157 // The keys are pinned to the last inner row rather than flowing after the
158 // message. A prompt whose dismiss keys move with message length, or fall
159 // off a short box, is a modal the user cannot get out of.
160 #[test]
161 fn keys_sit_on_the_last_row_whatever_the_message_length() {
162 for height in [5, 7, 12] {
163 let rows = render_modal(Rect::new(0, 0, 40, height));
164 let last_inner = &rows[height as usize - 2];
165 assert!(
166 last_inner.contains("Enter") && last_inner.contains("Esc"),
167 "height {height}: keys belong on the last inner row, got {last_inner:?}"
168 );
169 }
170 }
171
172 #[test]
173 fn modal_survives_an_area_too_small_to_draw_in() {
174 let theme = theme();
175 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 7));
176 AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 40, 0), &mut buf);
177 AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 0, 7), &mut buf);
178 AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 2, 2), &mut buf);
179 }
180
181 fn render_to(width: u16, height: u16, draw: impl FnOnce(&mut Buffer, Rect)) -> Vec<String> {
182 let area = Rect::new(0, 0, width, height);
183 let mut buf = Buffer::empty(area);
184 draw(&mut buf, area);
185 (0..height)
186 .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
187 .collect()
188 }
189
190 /// Column of `needle` in `row`, counted in characters.
191 ///
192 /// `str::find` counts bytes, and the focus marker is three of them, so a
193 /// byte offset says a focused row's value starts two columns right of an
194 /// unfocused one when both are in the same column.
195 fn column(row: &str, needle: &str) -> Option<usize> {
196 let at = row.find(needle)?;
197 Some(row[..at].chars().count())
198 }
199
200 fn field_rows(theme: &Theme) -> Vec<FormRow<'_>> {
201 vec![
202 FormRow::Section {
203 label: "cursor",
204 open: true,
205 },
206 FormRow::Field(
207 AlloyField::new(theme, "shape", FieldKind::Enum { label: "block" })
208 .help(Some("Cursor shape.")),
209 ),
210 FormRow::Field(AlloyField::new(theme, "blinking", FieldKind::Toggle(false))),
211 FormRow::Section {
212 label: "colors",
213 open: false,
214 },
215 ]
216 }
217
218 #[test]
219 fn a_toggle_reads_without_color_or_a_patched_font() {
220 let rows = render_to(40, 1, |buf, area| {
221 AlloyField::new(&theme(), "blinking", FieldKind::Toggle(true)).render(area, buf);
222 });
223 assert!(rows[0].contains("[x]"), "{rows:?}");
224
225 let rows = render_to(40, 1, |buf, area| {
226 AlloyField::new(&theme(), "blinking", FieldKind::Toggle(false)).render(area, buf);
227 });
228 assert!(rows[0].contains("[ ]"), "{rows:?}");
229 }
230
231 #[test]
232 fn a_color_field_draws_a_swatch_beside_its_hex() {
233 let rows = render_to(40, 1, |buf, area| {
234 AlloyField::new(&theme(), "background", FieldKind::Color { hex: "#e4ded6" })
235 .render(area, buf);
236 });
237 assert!(rows[0].contains("██ #e4ded6"), "{rows:?}");
238 }
239
240 // A hex the widget cannot parse still shows its text. The swatch helps read
241 // a value; it is not the value, and dropping the text would hide the one
242 // thing the user needs to see to fix it.
243 #[test]
244 fn an_unparseable_color_still_renders_its_text() {
245 let rows = render_to(40, 1, |buf, area| {
246 AlloyField::new(&theme(), "background", FieldKind::Color { hex: "e4ded6" })
247 .render(area, buf);
248 });
249 assert!(rows[0].contains("e4ded6"), "{rows:?}");
250 assert!(
251 !rows[0].contains(''),
252 "no swatch for a value it cannot parse"
253 );
254 }
255
256 #[test]
257 fn swatch_parses_both_hex_lengths_and_rejects_the_rest() {
258 assert_eq!(swatch("#e4ded6"), Some(Color::Rgb(0xe4, 0xde, 0xd6)));
259 assert_eq!(
260 swatch("#e4ded6ff"),
261 Some(Color::Rgb(0xe4, 0xde, 0xd6)),
262 "alpha is dropped, not refused",
263 );
264 assert_eq!(swatch("e4ded6"), None, "no hash");
265 assert_eq!(swatch("#e4ded"), None, "wrong length");
266 assert_eq!(swatch("#gggggg"), None, "not hex");
267 }
268
269 // An edited row shows the buffer and its caret, not the committed value.
270 #[test]
271 fn an_edited_field_draws_the_caret_buffer_in_place_of_the_value() {
272 let mut buffer = TextField::new();
273 buffer.set("Departure Mono");
274 buffer.home();
275 let rows = render_to(60, 1, |buf, area| {
276 AlloyField::new(&theme(), "family", FieldKind::Text("IosevkaTerm"))
277 .edit(Some(&buffer))
278 .render(area, buf);
279 });
280 assert!(rows[0].contains("Departure Mono"), "{rows:?}");
281 assert!(
282 !rows[0].contains("IosevkaTerm"),
283 "the committed value is not drawn"
284 );
285 }
286
287 // The caret has to be visible while appending, which is where it spends
288 // most of its life. Past the end of the line there is no character under
289 // it, so it draws on a space.
290 #[test]
291 fn a_caret_past_the_end_of_the_line_still_has_a_cell() {
292 let mut buffer = TextField::new();
293 buffer.set("alloy");
294 let (before, under, after) = buffer.split();
295 assert_eq!((before, under, after), ("alloy", None, ""));
296
297 let spans = caret_spans(&theme(), &buffer, Style::default());
298 assert_eq!(spans[1].content, " ", "the caret sits on a space");
299 }
300
301 #[test]
302 fn a_form_lines_its_value_column_up_across_rows() {
303 let theme = theme();
304 let rows = render_to(50, 6, |buf, area| {
305 AlloyForm::new(&theme, field_rows(&theme))
306 .selected(1)
307 .render(area, buf);
308 });
309 // "blinking" is the longest label, so both values start in the same
310 // column despite "shape" being three characters shorter.
311 let shape = column(&rows[1], "block").expect("enum label renders");
312 let blinking = column(&rows[2], "[ ]").expect("toggle renders");
313 assert_eq!(shape, blinking, "{rows:?}");
314 }
315
316 #[test]
317 fn a_section_header_shows_whether_it_is_folded() {
318 let theme = theme();
319 let rows = render_to(50, 6, |buf, area| {
320 AlloyForm::new(&theme, field_rows(&theme))
321 .selected(0)
322 .render(area, buf);
323 });
324 assert!(rows[0].contains("▾ cursor"), "open section: {rows:?}");
325 assert!(rows[3].contains("▸ colors"), "folded section: {rows:?}");
326 }
327
328 #[test]
329 fn the_focused_row_carries_the_same_marker_a_list_row_would() {
330 let theme = theme();
331 let rows = render_to(50, 6, |buf, area| {
332 AlloyForm::new(&theme, field_rows(&theme))
333 .selected(2)
334 .render(area, buf);
335 });
336 assert!(rows[2].starts_with(MARKER), "{rows:?}");
337 assert!(!rows[1].starts_with(MARKER), "only one row is focused");
338 }
339
340 // The footer belongs to whichever row is focused, and a diagnostic beats
341 // help: it is the reason an edit did not commit.
342 #[test]
343 fn the_footer_shows_the_focused_rows_help_and_a_diagnostic_over_it() {
344 let theme = theme();
345 let rows = render_to(50, 6, |buf, area| {
346 AlloyForm::new(&theme, field_rows(&theme))
347 .selected(1)
348 .render(area, buf);
349 });
350 assert!(rows[5].contains("Cursor shape."), "help renders: {rows:?}");
351
352 let rows = render_to(50, 6, |buf, area| {
353 let mut rows = field_rows(&theme);
354 rows[1] = FormRow::Field(
355 AlloyField::new(&theme, "shape", FieldKind::Enum { label: "bar" })
356 .help(Some("Cursor shape."))
357 .diagnostic(Some((Severity::Error, "\"bar\" is not a declared value"))),
358 );
359 AlloyForm::new(&theme, rows).selected(1).render(area, buf);
360 });
361 assert!(rows[5].contains("not a declared value"), "{rows:?}");
362 assert!(!rows[5].contains("Cursor shape."), "the diagnostic wins");
363 }
364
365 // The footer line is reserved whether or not it has anything in it. A form
366 // whose rows reflow as focus moves is one where the row under the cursor
367 // moves out from under it.
368 #[test]
369 fn rows_hold_their_lines_whether_the_footer_has_content_or_not() {
370 let theme = theme();
371 let with_help = render_to(50, 6, |buf, area| {
372 AlloyForm::new(&theme, field_rows(&theme))
373 .selected(1)
374 .render(area, buf);
375 });
376 let without = render_to(50, 6, |buf, area| {
377 AlloyForm::new(&theme, field_rows(&theme))
378 .selected(2)
379 .render(area, buf);
380 });
381 assert_eq!(
382 with_help[0], without[0],
383 "the section header sits on the same line either way",
384 );
385 assert!(without[5].trim().is_empty(), "no help, an empty footer");
386 }
387
388 // One line per row is what keeps the stateless scroll math valid, so a form
389 // longer than its area scrolls exactly the way a list does.
390 #[test]
391 fn a_form_longer_than_its_area_scrolls_like_a_list() {
392 let theme = theme();
393 let many: Vec<FormRow> = (0..50)
394 .map(|i| {
395 FormRow::Field(AlloyField::new(
396 &theme,
397 "slot",
398 FieldKind::Number(if i == 25 { "twentyfive" } else { "x" }),
399 ))
400 })
401 .collect();
402 let rows = render_to(50, 11, |buf, area| {
403 AlloyForm::new(&theme, many).selected(25).render(area, buf);
404 });
405 // 50 rows, 10 row-lines after the footer, selection 25 => offset 20.
406 assert!(rows[5].contains("twentyfive"), "{rows:?}");
407 }
408
409 #[test]
410 fn a_form_survives_an_area_too_small_to_draw_in() {
411 let theme = theme();
412 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 6));
413 AlloyForm::new(&theme, field_rows(&theme))
414 .selected(0)
415 .render(Rect::new(0, 0, 40, 0), &mut buf);
416 AlloyForm::new(&theme, field_rows(&theme))
417 .selected(0)
418 .render(Rect::new(0, 0, 0, 6), &mut buf);
419 // One line tall: the row wins over the footer.
420 let rows = render_to(40, 1, |buf, area| {
421 AlloyForm::new(&theme, field_rows(&theme))
422 .selected(0)
423 .render(area, buf);
424 });
425 assert!(rows[0].contains("cursor"), "{rows:?}");
426 }
427
428 fn picker_rows() -> Vec<PickRow<'static>> {
429 vec![
430 PickRow::new("Block").description(Some("Solid block.")),
431 PickRow::new("Beam").description(Some("Thin vertical bar.")),
432 ]
433 }
434
435 fn render_picker(filter: &TextField, rows: Vec<PickRow<'_>>, height: u16) -> Vec<String> {
436 let theme = theme();
437 render_to(50, height, |buf, area| {
438 AlloyPicker::new(&theme, "cursor.shape", filter, rows)
439 .selected(Some(0))
440 .render(area, buf);
441 })
442 }
443
444 #[test]
445 fn a_picker_shows_its_choices_with_what_they_mean() {
446 let rows = render_picker(&TextField::new(), picker_rows(), 8);
447 let joined = rows.join("\n");
448 assert!(joined.contains("cursor.shape"), "title: {joined}");
449 assert!(joined.contains("Block"), "{joined}");
450 assert!(joined.contains("Solid block."), "{joined}");
451 assert!(joined.contains("Thin vertical bar."), "{joined}");
452 }
453
454 #[test]
455 fn the_filter_row_carries_a_caret() {
456 let mut filter = TextField::new();
457 filter.set("bl");
458 let rows = render_picker(&filter, picker_rows(), 8);
459 assert!(rows[1].contains("/ bl"), "{rows:?}");
460 }
461
462 // A picker that goes blank when the filter matches nothing reads as broken
463 // rather than as narrowed.
464 #[test]
465 fn a_filter_that_matches_nothing_says_so() {
466 let rows = render_picker(&TextField::new(), Vec::new(), 8);
467 assert!(rows.join("\n").contains("no matches"), "{rows:?}");
468 }
469
470 // Same rule AlloyModal follows: a floating thing whose dismiss keys move
471 // with its content is one the user can lose.
472 #[test]
473 fn the_keys_sit_on_the_last_row_whatever_the_choice_count() {
474 for height in [6, 8, 14] {
475 let rows = render_picker(&TextField::new(), picker_rows(), height);
476 let last = &rows[height as usize - 2];
477 assert!(
478 last.contains("enter") && last.contains("esc"),
479 "height {height}: {last:?}"
480 );
481 }
482 }
483
484 #[test]
485 fn a_picker_is_wide_enough_for_its_widest_choice() {
486 let theme = theme();
487 let filter = TextField::new();
488 let width = AlloyPicker::new(&theme, "t", &filter, picker_rows()).width();
489 // "Beam" plus the gap plus "Thin vertical bar." is the longest row.
490 assert_eq!(width as usize, 4 + 2 + 18 + 4);
491 assert_eq!(
492 AlloyPicker::height(2),
493 6,
494 "two rows, a filter, keys, borders"
495 );
496 }
497
498 #[test]
499 fn a_picker_survives_an_area_too_small_to_draw_in() {
500 let theme = theme();
501 let filter = TextField::new();
502 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 8));
503 for area in [
504 Rect::new(0, 0, 40, 0),
505 Rect::new(0, 0, 0, 8),
506 Rect::new(0, 0, 4, 2),
507 Rect::new(0, 0, 40, 3),
508 ] {
509 AlloyPicker::new(&theme, "t", &filter, picker_rows()).render(area, &mut buf);
510 }
511 }
512
513 #[test]
514 fn a_table_aligns_its_columns_under_its_headers() {
515 let theme = theme();
516 let rows = render_to(60, 3, |buf, area| {
517 AlloyTable::new(
518 &theme,
519 ["key", "action"],
520 [
521 vec!["ctrl+shift+t".to_string(), "CreateTab".to_string()],
522 vec!["ctrl+w".to_string(), "CloseTab".to_string()],
523 ],
524 )
525 .render(area, buf);
526 });
527 let action = column(&rows[0], "action").expect("header renders");
528 assert_eq!(column(&rows[1], "CreateTab"), Some(action), "{rows:?}");
529 assert_eq!(column(&rows[2], "CloseTab"), Some(action), "{rows:?}");
530 }
531
532 // A file can hold a record the schema does not describe. Dropping the extra
533 // cell would hide exactly the disagreement worth seeing.
534 #[test]
535 fn a_row_wider_than_the_headers_still_renders_every_cell() {
536 let theme = theme();
537 let rows = render_to(60, 2, |buf, area| {
538 AlloyTable::new(
539 &theme,
540 ["key"],
541 [vec!["ctrl+w".to_string(), "CloseTab".to_string()]],
542 )
543 .render(area, buf);
544 });
545 assert!(rows[1].contains("CloseTab"), "{rows:?}");
546 }
547
548 // A log longer than its pane shows the newest entries. Showing the head
549 // instead would freeze the pane on startup noise and never display the
550 // command the user just triggered.
551 #[test]
552 fn log_renders_the_newest_entries() {
553 let theme = theme();
554 let entries: Vec<LogEntry> = (0..10)
555 .map(|i| LogEntry::new(format!("nmcli run {i}"), Severity::Healthy))
556 .collect();
557 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 4));
558 AlloyLog::new(&theme, &entries).render(Rect::new(0, 0, 40, 4), &mut buf);
559
560 let rendered = buf
561 .content()
562 .iter()
563 .map(ratatui::buffer::Cell::symbol)
564 .collect::<String>();
565 assert!(
566 rendered.contains("nmcli run 9"),
567 "newest entry must be visible"
568 );
569 assert!(
570 !rendered.contains("nmcli run 0"),
571 "oldest entry must have scrolled off"
572 );
573 }
574
575 // ---- button ----
576
577 fn render_button(button: AlloyButton, w: u16, h: u16) -> (Buffer, Rect) {
578 let area = Rect::new(0, 0, w, h);
579 let mut buf = Buffer::empty(area);
580 button.render(area, &mut buf);
581 (buf, area)
582 }
583
584 fn rows(buf: &Buffer, area: Rect) -> Vec<String> {
585 (area.y..area.bottom())
586 .map(|y| {
587 (area.x..area.right())
588 .map(|x| buf[(x, y)].symbol())
589 .collect::<String>()
590 })
591 .collect()
592 }
593
594 #[test]
595 fn a_button_is_a_beveled_surface_with_a_centered_label() {
596 let theme = theme();
597 let (buf, area) = render_button(AlloyButton::new(&theme, "OK"), 8, 3);
598 assert_eq!(rows(&buf, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]);
599 }
600
601 // The pressed state is the same button lit from the other corner. Asserted
602 // as a relationship rather than against literals, because that is what
603 // makes it one swap instead of a second widget.
604 #[test]
605 fn pressing_a_button_inverts_its_light_and_recesses_its_face() {
606 let theme = theme();
607 let (up, area) = render_button(AlloyButton::new(&theme, "OK"), 8, 3);
608 let (down, _) = render_button(AlloyButton::new(&theme, "OK").pressed(true), 8, 3);
609
610 assert_eq!(rows(&up, area), rows(&down, area));
611 assert_eq!(up[(0u16, 0u16)].fg, theme.makeover.bevel_light);
612 assert_eq!(down[(0u16, 0u16)].fg, theme.makeover.bevel_dark);
613 assert_eq!(up[(3u16, 1u16)].bg, theme.makeover.surface_raised);
614 // The well, not `surface_sunken`. A theme may author sunken darker than
615 // raised while a well always inverts away from the text, so the two are
616 // only interchangeable on themes where the substitution happens not to
617 // bite. Pinned as the well so it stays that way.
618 assert_eq!(down[(3u16, 1u16)].bg, theme.makeover.surface_well.unwrap());
619 assert_ne!(down[(3u16, 1u16)].bg, theme.makeover.surface_sunken);
620 }
621
622 // A theme that gave makeover nothing to derive a well from still has to
623 // produce a legible pressed state. No fill is painted and the inverted edge
624 // carries it alone, which is the whole reason the renderer declines rather
625 // than substituting some other surface.
626 #[test]
627 fn a_button_pressed_on_a_theme_with_no_well_keeps_its_inverted_edge() {
628 let mut theme = theme();
629 theme.makeover.surface_well = None;
630 let (down, area) = render_button(AlloyButton::new(&theme, "OK").pressed(true), 8, 3);
631 assert_eq!(rows(&down, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]);
632 assert_eq!(down[(0u16, 0u16)].fg, theme.makeover.bevel_dark);
633 }
634
635 // Dimmed and still there, so the layout keeps teaching itself.
636 #[test]
637 fn a_disabled_button_keeps_its_bevel_and_mutes_only_its_label() {
638 let theme = theme();
639 let (buf, area) = render_button(AlloyButton::new(&theme, "OK").disabled(true), 8, 3);
640 assert_eq!(rows(&buf, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]);
641 assert_eq!(buf[(0u16, 0u16)].fg, theme.makeover.bevel_light);
642 assert_eq!(buf[(3u16, 1u16)].fg, theme.makeover.content_muted);
643 }
644
645 #[test]
646 fn a_primary_button_inverts_polarity_without_touching_the_bevel() {
647 let theme = theme();
648 let (buf, _) = render_button(AlloyButton::new(&theme, "OK").primary(true), 8, 3);
649 assert_eq!(buf[(3u16, 1u16)].bg, theme.makeover.content_primary);
650 assert_eq!(buf[(3u16, 1u16)].fg, theme.makeover.surface_raised);
651 assert_eq!(buf[(0u16, 0u16)].fg, theme.makeover.bevel_light);
652 }
653
654 // ---- floating surfaces ----
655
656 // The shadow lands outside the modal, one cell down and right, so the page
657 // has to be bigger than the modal for it to exist at all.
658 #[test]
659 fn a_modal_casts_a_shadow_onto_the_page_behind_it() {
660 let theme = theme();
661 let page = Rect::new(0, 0, 24, 8);
662 let modal = Rect::new(2, 1, 18, 5);
663 let mut buf = Buffer::empty(page);
664 AlloyModal::new(&theme, "remove", "Remove tailscale?").render(modal, &mut buf);
665
666 // Directly under the modal's bottom edge, offset one to the right.
667 let below = &buf[(3u16, 6u16)];
668 assert_eq!(below.symbol(), "");
669 assert_eq!(below.fg, theme.border_strong);
670 // The page well away from the modal is untouched.
671 assert_eq!(buf[(23u16, 7u16)].symbol(), " ");
672 }
673