Skip to main content

max / makenotwork

26.8 KB · 751 lines History Blame Raw
1 //! Drawing the model.
2 //!
3 //! `render(model, now, frame)` is a pure function of the model and the clock.
4 //! `now` is threaded through rather than read from the system clock precisely
5 //! so that this stays true: with an ambient clock, every snapshot test becomes
6 //! time-dependent and the whole surface stops being verifiable without a live
7 //! daemon. That is unpleasant to retrofit and trivial to keep.
8
9 use chrono::{DateTime, Utc};
10 use ops_status::{Method, Node};
11 use ratatui::Frame;
12 use ratatui::layout::{Constraint, Layout, Rect};
13 use ratatui::style::{Color, Modifier, Style, Stylize};
14 use ratatui::text::{Line, Span};
15 use ratatui::widgets::{Block, Cell, Clear, List, ListItem, Paragraph, Row, Table, Tabs};
16
17 use crate::model::{Model, Prompt, SourceState, Tab};
18 use crate::value;
19
20 pub(crate) fn render(model: &Model, now: DateTime<Utc>, frame: &mut Frame) {
21 let [header, body, footer] = Layout::vertical([
22 Constraint::Length(1),
23 Constraint::Min(1),
24 Constraint::Length(1),
25 ])
26 .areas(frame.area());
27
28 render_header(model, now, frame, header);
29 match model.tab {
30 Tab::Rollup => render_rollup(model, now, frame, body),
31 Tab::Source(i) => match model.sources.get(i) {
32 Some(source) => render_source(source, now, frame, body),
33 None => frame.render_widget(Paragraph::new("no such source"), body),
34 },
35 }
36 // A prompt floats over whatever tab is showing: the state behind it keeps
37 // updating on every poll, which is the point of not blocking on the modal.
38 if model.prompt.is_some() {
39 render_prompt(model, frame, body);
40 }
41 render_footer(model, frame, footer);
42 }
43
44 fn render_header(model: &Model, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
45 let worst = model.worst(now);
46 let [mark, tabs] = Layout::horizontal([Constraint::Length(6), Constraint::Min(1)]).areas(area);
47
48 frame.render_widget(
49 Paragraph::new(Span::styled(
50 format!(" {} ", value::status_mark(worst)),
51 value::status_style(worst).add_modifier(Modifier::REVERSED),
52 )),
53 mark,
54 );
55 frame.render_widget(
56 Tabs::new(model.tab_titles())
57 .select(model.tab_index())
58 .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
59 .divider(" "),
60 tabs,
61 );
62 }
63
64 fn render_footer(model: &Model, frame: &mut Frame, area: Rect) {
65 let text = match &model.message {
66 Some(message) => Span::from(format!(" {message}")).yellow(),
67 None => {
68 Span::from(" tab/shift-tab switch up/down move enter open 1-9 jump q quit").dim()
69 }
70 };
71 frame.render_widget(Paragraph::new(Line::from(text)), area);
72 }
73
74 // ---------------------------------------------------------------------------
75 // Rollup
76 // ---------------------------------------------------------------------------
77
78 /// Every source at once, worst first.
79 ///
80 /// Without this the viewer is N tabs you still have to visit one at a time,
81 /// which is the situation it replaces, with extra steps.
82 fn render_rollup(model: &Model, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
83 let order = model.rollup_order(now);
84 let rows: Vec<Row> = order
85 .iter()
86 .enumerate()
87 .map(|(row, &index)| {
88 let source = &model.sources[index];
89 let status = source.status(now);
90 let age = match source.age(now) {
91 Some(age) => value::duration(age.num_seconds()),
92 None => "-".into(),
93 };
94 let style = if row == model.rollup_selected {
95 Style::default().add_modifier(Modifier::REVERSED)
96 } else {
97 Style::default()
98 };
99 Row::new(vec![
100 Cell::from(value::status_mark(status)).style(value::status_style(status)),
101 Cell::from(source.name.clone()),
102 Cell::from(age),
103 Cell::from(source.summary(now)),
104 ])
105 .style(style)
106 })
107 .collect();
108
109 let table = Table::new(
110 rows,
111 [
112 Constraint::Length(4),
113 Constraint::Length(14),
114 Constraint::Length(8),
115 Constraint::Min(10),
116 ],
117 )
118 .header(
119 Row::new(vec!["", "source", "age", "detail"])
120 .style(Style::default().add_modifier(Modifier::DIM)),
121 )
122 .block(Block::bordered().title(" all sources "));
123
124 frame.render_widget(table, area);
125 }
126
127 // ---------------------------------------------------------------------------
128 // One source
129 // ---------------------------------------------------------------------------
130
131 fn render_source(source: &SourceState, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
132 let [list_area, detail_area] =
133 Layout::vertical([Constraint::Percentage(55), Constraint::Min(5)]).areas(area);
134
135 let rows = source.rows();
136 let items: Vec<ListItem> = rows
137 .iter()
138 .enumerate()
139 .map(|(i, row)| {
140 let indent = " ".repeat(row.depth);
141 let mut spans = vec![
142 Span::styled(
143 format!("{:<5}", value::status_mark(row.node.status)),
144 value::status_style(row.node.status),
145 ),
146 Span::raw(format!("{indent}{}", row.node.label)),
147 Span::styled(format!(" ({})", row.node.kind), Style::default().dim()),
148 ];
149 if i == source.selected {
150 spans = spans
151 .into_iter()
152 .map(|s| s.patch_style(Style::default().add_modifier(Modifier::REVERSED)))
153 .collect();
154 }
155 ListItem::new(Line::from(spans))
156 })
157 .collect();
158
159 let title = format!(" {} ", source.name);
160 let list = if items.is_empty() {
161 List::new(vec![ListItem::new(Line::from(
162 Span::from(source.summary(now)).dim(),
163 ))])
164 } else {
165 List::new(items)
166 };
167 frame.render_widget(list.block(Block::bordered().title(title)), list_area);
168
169 render_detail(source, now, frame, detail_area);
170 }
171
172 /// The selected node's fields and conditions.
173 ///
174 /// Conditions are the half that usually gets dropped, and the half that pays:
175 /// "blocked" is useless, "blocked because burn_in is 31h of 48h" is what saves
176 /// an SSH.
177 fn render_detail(source: &SourceState, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
178 let width = area.width.saturating_sub(4) as usize;
179 let mut lines: Vec<Line> = Vec::new();
180
181 match source.selected_node() {
182 Some(node) => {
183 lines.push(Line::from(vec![
184 Span::styled(
185 value::status_mark(node.status),
186 value::status_style(node.status),
187 ),
188 Span::raw(" "),
189 Span::styled(node.label.clone(), Style::default().bold()),
190 ]));
191 lines.extend(field_lines(node, now, width));
192 lines.extend(condition_lines(node, width));
193 if !node.actions.is_empty() {
194 // The hint tells the operator whether Enter does anything here,
195 // so a read-only source does not look broken when a keypress is
196 // ignored.
197 let hint = if source.allow_actions {
198 " (enter to run)"
199 } else {
200 " (read-only)"
201 };
202 lines.push(Line::from(vec![
203 Span::from(format!("actions: {}", node.actions.join(", "))).dim(),
204 Span::from(hint).dim(),
205 ]));
206 }
207 }
208 None => lines.push(Line::from(Span::from(source.summary(now)).dim())),
209 }
210
211 frame.render_widget(
212 Paragraph::new(lines).block(Block::bordered().title(" detail ")),
213 area,
214 );
215 }
216
217 fn field_lines(node: &Node, now: DateTime<Utc>, width: usize) -> Vec<Line<'static>> {
218 let label_width = node
219 .fields
220 .iter()
221 .map(|f| f.label.chars().count())
222 .max()
223 .unwrap_or(0);
224
225 node.fields
226 .iter()
227 .map(|field| {
228 let budget = width.saturating_sub(label_width + 2);
229 let rendered = value::render(&field.value, now, budget);
230 let style = match &field.value {
231 ops_status::Value::Progress { value, max, .. } => {
232 value::progress_style(*value, *max)
233 }
234 other => value::style(other),
235 };
236 Line::from(vec![
237 Span::styled(
238 format!("{:<label_width$} ", field.label),
239 Style::default().dim(),
240 ),
241 Span::styled(rendered, style),
242 ])
243 })
244 .collect()
245 }
246
247 fn condition_lines(node: &Node, width: usize) -> Vec<Line<'static>> {
248 node.conditions
249 .iter()
250 .map(|condition| {
251 let mut spans = vec![
252 Span::styled(
253 format!("{:<5}", value::status_mark(condition.status)),
254 value::status_style(condition.status),
255 ),
256 Span::raw(condition.condition_type.clone()),
257 ];
258 if let Some(detail) = &condition.detail {
259 let budget = width.saturating_sub(condition.condition_type.chars().count() + 8);
260 spans.push(Span::styled(
261 format!(" {}", truncate(detail, budget.max(8))),
262 Style::default().dim(),
263 ));
264 }
265 Line::from(spans)
266 })
267 .collect()
268 }
269
270 // ---------------------------------------------------------------------------
271 // Action prompts
272 // ---------------------------------------------------------------------------
273
274 /// The modal path to firing an action: pick, then clear its guard.
275 ///
276 /// A `danger` action shows a red header and asks the operator to type its key;
277 /// muscle memory cannot type `rollback-b`, which is the whole safeguard. The
278 /// resolved host is left off — the tab already names the source — so the line
279 /// stays short and shows the method and path the request will use.
280 fn render_prompt(model: &Model, frame: &mut Frame, area: Rect) {
281 let Some(prompt) = &model.prompt else { return };
282 let source = model.sources.get(prompt_source(prompt));
283
284 let (title, title_style, lines) = match prompt {
285 Prompt::Pick { keys, selected, .. } => {
286 let mut lines = Vec::new();
287 for (i, key) in keys.iter().enumerate() {
288 let action = source.and_then(|s| s.action(key));
289 let label = action.map_or(key.as_str(), |a| a.label.as_str());
290 let danger = action.is_some_and(|a| a.danger);
291 let marker = if i == *selected { "> " } else { " " };
292 let mut style = Style::default();
293 if danger {
294 style = style.fg(Color::Red);
295 }
296 if i == *selected {
297 style = style.add_modifier(Modifier::REVERSED);
298 }
299 lines.push(Line::from(Span::styled(
300 format!("{marker}{}. {label} [{key}]", i + 1),
301 style,
302 )));
303 }
304 lines.push(Line::from(""));
305 lines.push(Line::from(Span::from("enter run esc cancel").dim()));
306 (" run action ", Style::default(), lines)
307 }
308 Prompt::Confirm { key, .. } => {
309 let action = source.and_then(|s| s.action(key));
310 let lines = vec![
311 action_summary_line(action, key),
312 Line::from(""),
313 Line::from(Span::from("press y to confirm esc cancel").dim()),
314 ];
315 (" confirm ", Style::default(), lines)
316 }
317 Prompt::Type { key, typed, .. } => {
318 let action = source.and_then(|s| s.action(key));
319 let lines = vec![
320 Line::from(Span::styled(
321 "DANGER",
322 Style::default().fg(Color::Red).bold(),
323 )),
324 action_summary_line(action, key),
325 Line::from(""),
326 Line::from(Span::from(format!("type '{key}' to confirm:")).dim()),
327 Line::from(Span::styled(
328 format!("> {typed}\u{258f}"),
329 Style::default().fg(Color::Red),
330 )),
331 Line::from(""),
332 Line::from(Span::from("esc cancel").dim()),
333 ];
334 (
335 " DANGER ",
336 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
337 lines,
338 )
339 }
340 };
341
342 let width = lines
343 .iter()
344 .map(Line::width)
345 .chain(std::iter::once(title.len()))
346 .max()
347 .unwrap_or(0) as u16
348 + 4;
349 let height = lines.len() as u16 + 2;
350 let popup = centered(area, width, height);
351
352 // Clear what is under the popup so the tab behind does not show through.
353 frame.render_widget(Clear, popup);
354 frame.render_widget(
355 Paragraph::new(lines).block(
356 Block::bordered()
357 .title(Span::styled(title, title_style))
358 .border_style(title_style),
359 ),
360 popup,
361 );
362 }
363
364 /// One line naming what an action will do: `Roll back POST /rollback/b`.
365 fn action_summary_line(action: Option<&ops_status::Action>, key: &str) -> Line<'static> {
366 match action {
367 Some(action) => Line::from(vec![
368 Span::styled(action.label.clone(), Style::default().bold()),
369 Span::from(format!(" {} {}", method_str(action.method), action.url)),
370 ]),
371 // The action was retracted by a poll since the prompt opened; say so
372 // rather than render a blank confirmation.
373 None => Line::from(Span::styled(
374 format!("{key}: no longer offered"),
375 Style::default().fg(Color::Yellow),
376 )),
377 }
378 }
379
380 fn method_str(method: Method) -> &'static str {
381 match method {
382 Method::Get => "GET",
383 Method::Post => "POST",
384 Method::Put => "PUT",
385 Method::Delete => "DELETE",
386 }
387 }
388
389 fn prompt_source(prompt: &Prompt) -> usize {
390 match prompt {
391 Prompt::Pick { source, .. }
392 | Prompt::Confirm { source, .. }
393 | Prompt::Type { source, .. } => *source,
394 }
395 }
396
397 /// A rectangle of the given size centered in `area`, clamped so it always fits.
398 fn centered(area: Rect, width: u16, height: u16) -> Rect {
399 let width = width.min(area.width);
400 let height = height.min(area.height);
401 Rect {
402 x: area.x + (area.width - width) / 2,
403 y: area.y + (area.height - height) / 2,
404 width,
405 height,
406 }
407 }
408
409 /// Clip to a character budget, marking that something was cut.
410 fn truncate(text: &str, max: usize) -> String {
411 // A detail string is producer-supplied and may carry newlines; the pane is
412 // line-oriented, so flatten first.
413 let flat: String = text.replace('\n', " ");
414 if flat.chars().count() <= max {
415 return flat;
416 }
417 let kept: String = flat.chars().take(max.saturating_sub(1)).collect();
418 format!("{kept}…")
419 }
420
421 #[cfg(test)]
422 mod tests {
423 use super::*;
424 use crate::model::SourceState;
425 use chrono::TimeDelta;
426 use ops_status::{Condition, Field, Payload, Status, Value};
427 use ratatui::Terminal;
428 use ratatui::backend::TestBackend;
429
430 fn now() -> DateTime<Utc> {
431 "2026-07-21T18:00:00Z".parse().unwrap()
432 }
433
434 /// Render a model into a fixed-size buffer and return it as text lines.
435 ///
436 /// This is the whole payoff of keeping render pure: the entire surface is
437 /// verifiable with no daemon running and no terminal attached.
438 fn draw(model: &Model, now: DateTime<Utc>, width: u16, height: u16) -> Vec<String> {
439 let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
440 terminal.draw(|frame| render(model, now, frame)).unwrap();
441 let buffer = terminal.backend().buffer().clone();
442 (0..buffer.area.height)
443 .map(|y| {
444 (0..buffer.area.width)
445 .map(|x| buffer[(x, y)].symbol().to_string())
446 .collect::<String>()
447 .trim_end()
448 .to_string()
449 })
450 .collect()
451 }
452
453 fn node(id: &str, label: &str, status: Status) -> Node {
454 Node {
455 id: id.into(),
456 kind: "tier".into(),
457 label: label.into(),
458 status,
459 fields: Vec::new(),
460 conditions: Vec::new(),
461 children: Vec::new(),
462 actions: Vec::new(),
463 }
464 }
465
466 fn source(name: &str, at: DateTime<Utc>, nodes: Vec<Node>) -> SourceState {
467 let mut s = SourceState::new(name, TimeDelta::seconds(60));
468 let mut p = Payload::new(name, at);
469 p.nodes = nodes;
470 s.observe(p, at);
471 s
472 }
473
474 fn joined(lines: &[String]) -> String {
475 lines.join("\n")
476 }
477
478 #[test]
479 fn the_rollup_leads_with_the_worst_source() {
480 let model = Model::new(vec![
481 source("sando", now(), vec![node("a", "tier a", Status::Ok)]),
482 source("bento", now(), vec![node("b", "goingson", Status::Failed)]),
483 ]);
484 let lines = draw(&model, now(), 80, 12);
485 let text = joined(&lines);
486
487 assert!(text.contains("rollup"), "{text}");
488 // Skip the tab bar, which names every source regardless of order.
489 let body = &lines[1..];
490 let bento = body.iter().position(|l| l.contains("bento")).unwrap();
491 let sando = body.iter().position(|l| l.contains("sando")).unwrap();
492 assert!(bento < sando, "the failing source must be on top:\n{text}");
493 assert!(text.contains("FAIL"), "{text}");
494 }
495
496 #[test]
497 fn a_source_that_has_never_answered_says_so_rather_than_showing_nothing() {
498 let model = Model::new(vec![SourceState::new("bento", TimeDelta::seconds(60))]);
499 let text = joined(&draw(&model, now(), 80, 12));
500 assert!(
501 text.contains("????"),
502 "an unreachable source must be loud:\n{text}"
503 );
504 assert!(text.contains("waiting for first poll"), "{text}");
505 }
506
507 #[test]
508 fn a_stale_source_shows_its_age_in_the_rollup() {
509 let model = Model::new(vec![source(
510 "pom",
511 now() - TimeDelta::hours(4),
512 vec![node("backup", "backup", Status::Ok)],
513 )]);
514 let text = joined(&draw(&model, now(), 80, 12));
515 assert!(text.contains("4h"), "the age must be visible:\n{text}");
516 assert!(text.contains("degr"), "stale-but-green is not ok:\n{text}");
517 }
518
519 #[test]
520 fn a_source_tab_lists_its_nodes_with_children_indented() {
521 let mut parent = node("tier:b", "b (prod-1)", Status::Ok);
522 parent.children = vec!["node:prod-1".into()];
523 let child = node("node:prod-1", "prod-1", Status::Ok);
524
525 let mut model = Model::new(vec![source("sando", now(), vec![parent, child])]);
526 model.select_tab(1);
527 let lines = draw(&model, now(), 80, 20);
528 let text = joined(&lines);
529
530 let parent_row = lines.iter().position(|l| l.contains("b (prod-1)")).unwrap();
531 let child_row = lines.iter().position(|l| l.contains("prod-1 (")).unwrap();
532 assert!(parent_row < child_row, "{text}");
533 // The child is indented relative to its parent.
534 let parent_col = lines[parent_row].find("b (prod-1)").unwrap();
535 let child_col = lines[child_row].find("prod-1").unwrap();
536 assert!(child_col > parent_col, "child must be indented:\n{text}");
537 }
538
539 #[test]
540 fn the_detail_pane_shows_conditions_with_their_why() {
541 let mut n = node("tier:b", "b", Status::Ok);
542 n.conditions = vec![Condition {
543 condition_type: "burn_in".into(),
544 status: Status::Pending,
545 since: None,
546 detail: Some("17 hours remaining of 48".into()),
547 }];
548 let mut model = Model::new(vec![source("sando", now(), vec![n])]);
549 model.select_tab(1);
550 let text = joined(&draw(&model, now(), 80, 20));
551
552 assert!(text.contains("burn_in"), "{text}");
553 assert!(
554 text.contains("17 hours remaining"),
555 "a condition without its why is useless:\n{text}"
556 );
557 }
558
559 #[test]
560 fn a_progress_field_renders_as_a_bar() {
561 let mut n = node("tier:b", "b", Status::Ok);
562 n.fields = vec![Field::new(
563 "burn-in",
564 Value::Progress {
565 value: 31.0,
566 max: 48.0,
567 unit: Some("hour".into()),
568 },
569 )];
570 let mut model = Model::new(vec![source("sando", now(), vec![n])]);
571 model.select_tab(1);
572 let text = joined(&draw(&model, now(), 80, 20));
573
574 assert!(text.contains("31/48 hour"), "{text}");
575 assert!(
576 text.contains('#'),
577 "a progress value must draw a bar:\n{text}"
578 );
579 }
580
581 #[test]
582 fn an_instant_renders_relative_to_the_passed_in_clock() {
583 let mut n = node("tier:b", "b", Status::Ok);
584 n.fields = vec![Field::new(
585 "built",
586 Value::Instant {
587 value: now() - TimeDelta::minutes(3),
588 },
589 )];
590 let mut model = Model::new(vec![source("sando", now(), vec![n])]);
591 model.select_tab(1);
592 let text = joined(&draw(&model, now(), 80, 20));
593 assert!(text.contains("3m 0s ago"), "{text}");
594 }
595
596 #[test]
597 fn render_is_deterministic_for_a_fixed_clock() {
598 // The property every snapshot test rests on.
599 let mut n = node("tier:b", "b", Status::Ok);
600 n.fields = vec![Field::new(
601 "built",
602 Value::Instant {
603 value: now() - TimeDelta::minutes(3),
604 },
605 )];
606 let mut model = Model::new(vec![source("sando", now(), vec![n])]);
607 model.select_tab(1);
608 assert_eq!(draw(&model, now(), 80, 20), draw(&model, now(), 80, 20));
609 }
610
611 #[test]
612 fn an_unknown_value_kind_still_renders_as_text() {
613 // Version skew: a producer one release ahead must not blank the pane.
614 let field: Field =
615 serde_json::from_str(r#"{"label":"temp","kind":"celsius","value":"41"}"#).unwrap();
616 let mut n = node("tier:b", "b", Status::Ok);
617 n.fields = vec![field];
618 let mut model = Model::new(vec![source("sando", now(), vec![n])]);
619 model.select_tab(1);
620 let text = joined(&draw(&model, now(), 80, 20));
621 assert!(text.contains("temp"), "{text}");
622 assert!(text.contains("41"), "{text}");
623 }
624
625 #[test]
626 fn a_narrow_terminal_does_not_panic() {
627 // Every widget here has to survive a width no layout was designed for.
628 let mut n = node("tier:b", "a rather long tier label", Status::Failed);
629 n.fields = vec![Field::new(
630 "path",
631 Value::Path {
632 value: "/srv/sando/releases/a3f9c21b7e4d8056/bin/makenotwork".into(),
633 },
634 )];
635 n.conditions = vec![Condition {
636 condition_type: "node_health".into(),
637 status: Status::Failed,
638 since: None,
639 detail: Some("prod-1 unhealthy: connection refused after 30s".into()),
640 }];
641 let mut model = Model::new(vec![source("sando", now(), vec![n])]);
642 model.select_tab(1);
643 for width in [8_u16, 12, 20, 40] {
644 for height in [4_u16, 8, 20] {
645 let _ = draw(&model, now(), width, height);
646 }
647 }
648 }
649
650 #[test]
651 fn a_multiline_detail_is_flattened_not_sprawled() {
652 assert_eq!(truncate("a\nb", 40), "a b");
653 assert!(truncate(&"x".repeat(100), 10).ends_with('…'));
654 assert_eq!(truncate(&"x".repeat(100), 10).chars().count(), 10);
655 }
656
657 fn action(label: &str, danger: bool) -> ops_status::Action {
658 ops_status::Action {
659 label: label.into(),
660 method: ops_status::Method::Post,
661 url: "/rollback/b".into(),
662 confirm: true,
663 danger,
664 body: None,
665 }
666 }
667
668 /// A source with one node declaring `keys`, actions allowed, on its tab.
669 fn actionable(keys: &[(&str, bool)]) -> Model {
670 let mut n = node("tier:b", "b (prod-1)", Status::Ok);
671 n.actions = keys.iter().map(|(k, _)| k.to_string()).collect();
672 let mut p = Payload::new("sando", now());
673 p.nodes = vec![n];
674 p.actions = keys
675 .iter()
676 .map(|(k, d)| (k.to_string(), action(k, *d)))
677 .collect();
678 let mut s = SourceState::new("sando", TimeDelta::seconds(60)).with_actions(true);
679 s.observe(p, now());
680 let mut m = Model::new(vec![s]);
681 m.select_tab(1);
682 m
683 }
684
685 #[test]
686 fn the_detail_hint_says_whether_actions_can_run() {
687 let mut m = actionable(&[("rollback-b", true)]);
688 let text = joined(&draw(&m, now(), 80, 20));
689 assert!(text.contains("rollback-b"), "{text}");
690 assert!(text.contains("enter to run"), "{text}");
691
692 m.sources[0].allow_actions = false;
693 let text = joined(&draw(&m, now(), 80, 20));
694 assert!(
695 text.contains("read-only"),
696 "a disabled source must say so:\n{text}"
697 );
698 }
699
700 #[test]
701 fn the_picker_lists_a_nodes_actions() {
702 let mut m = actionable(&[("promote-b", false), ("rollback-b", true)]);
703 m.open_actions();
704 let text = joined(&draw(&m, now(), 80, 20));
705 assert!(text.contains("run action"), "{text}");
706 assert!(text.contains("[promote-b]"), "{text}");
707 assert!(text.contains("[rollback-b]"), "{text}");
708 assert!(text.contains("enter run"), "{text}");
709 }
710
711 #[test]
712 fn a_danger_prompt_shows_the_key_to_type() {
713 let mut m = actionable(&[("rollback-b", true)]);
714 m.open_actions();
715 m.prompt_enter(); // Pick -> Type (danger)
716 let text = joined(&draw(&m, now(), 80, 20));
717 assert!(
718 text.contains("DANGER"),
719 "a danger action must be loud:\n{text}"
720 );
721 assert!(
722 text.contains("type 'rollback-b'"),
723 "the exact key to type must be shown:\n{text}"
724 );
725 }
726
727 #[test]
728 fn a_retracted_action_is_named_in_the_confirmation_not_left_blank() {
729 let mut m = actionable(&[("promote-b", false)]);
730 m.open_actions();
731 m.prompt_enter(); // Pick -> Confirm (confirm, not danger)
732 // A poll drops the action while the confirm box is up.
733 let mut p = Payload::new("sando", now());
734 p.nodes = vec![node("tier:b", "b", Status::Ok)];
735 m.sources[0].observe(p, now());
736 let text = joined(&draw(&m, now(), 80, 20));
737 assert!(text.contains("no longer offered"), "{text}");
738 }
739
740 #[test]
741 fn the_footer_shows_a_message_when_there_is_one() {
742 let mut model = Model::new(vec![source("sando", now(), vec![])]);
743 let text = joined(&draw(&model, now(), 80, 12));
744 assert!(text.contains("q quit"), "{text}");
745
746 model.message = Some("refreshing".into());
747 let text = joined(&draw(&model, now(), 80, 12));
748 assert!(text.contains("refreshing"), "{text}");
749 }
750 }
751