//! Drawing the model. //! //! `render(model, theme, now, frame)` is a pure function of the model, the //! theme and the clock. `now` is threaded through rather than read from the //! system clock precisely so that this stays true: with an ambient clock, every //! snapshot test becomes time-dependent and the whole surface stops being //! verifiable without a live daemon. That is unpleasant to retrofit and trivial //! to keep. The theme is threaded the same way and for the same reason. //! //! Every colour here is a makeover intent (`crate::theme`), never a literal. //! The intent names what a thing IS — a danger, a muted label, the surface a //! modal floats on — and which colour that is belongs to the theme file. What //! the terminal costs is geometry rather than colour, so nothing below leans on //! a hue to carry a distinction that the layout should be carrying. use chrono::{DateTime, Utc}; use makeover_tui::Theme; use makeover_tui::makeover_layout::{Column, Priority, Width}; use makeover_tui::table::{self, Cell as TableCell, Sizing, TableStyle}; use ops_status::{Method, Node}; use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Clear, List, ListItem, Paragraph, TableState, Tabs}; use crate::model::{Model, Prompt, SourceState, Tab}; use crate::value; pub(crate) fn render(model: &Model, theme: &Theme, now: DateTime, frame: &mut Frame) { let [header, body, footer] = Layout::vertical([ Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ]) .areas(frame.area()); render_header(model, theme, now, frame, header); match model.tab { Tab::Rollup => render_rollup(model, theme, now, frame, body), Tab::Source(i) => match model.sources.get(i) { Some(source) => render_source(source, theme, now, frame, body), None => frame.render_widget(Paragraph::new("no such source").style(muted(theme)), body), }, } // A prompt floats over whatever tab is showing: the state behind it keeps // updating on every poll, which is the point of not blocking on the modal. if model.prompt.is_some() { render_prompt(model, theme, frame, body); } render_footer(model, theme, frame, footer); } /// Secondary text: labels, hints, the things you read past. fn muted(theme: &Theme) -> Style { Style::default().fg(theme.content_muted) } /// The selected row, on every list and table here. /// /// A raised surface rather than a reversed one. Reversing swaps a span's own /// foreground into the background, so a selected `FAIL` row used to paint its /// danger colour behind the text and lose the one signal the row carries; /// giving selection a surface of its own leaves every status colour intact on /// top of it. fn selected(theme: &Theme) -> Style { Style::default() .bg(theme.surface_raised) .fg(theme.content_primary) } /// A bordered container: the frame in the theme's border colour, the title in /// secondary content so it reads as a label on the box rather than as content. fn container(theme: &Theme, title: &'static str) -> Block<'static> { Block::bordered() .border_style(Style::default().fg(theme.line_border)) .title(Span::styled( title, Style::default().fg(theme.content_secondary), )) } fn render_header(model: &Model, theme: &Theme, now: DateTime, frame: &mut Frame, area: Rect) { let worst = model.worst(now); let [mark, tabs] = Layout::horizontal([Constraint::Length(6), Constraint::Min(1)]).areas(area); // The rollup mark is a filled chip: the worst status is the one thing on // screen that has to be readable from across the room, so it takes the // status colour as a background rather than as text. let status = value::status_style(theme, worst); frame.render_widget( Paragraph::new(Span::styled( format!(" {} ", value::status_mark(worst)), Style::default() .bg(status.fg.unwrap_or(theme.content_primary)) .fg(theme.surface_page) .add_modifier(Modifier::BOLD), )), mark, ); frame.render_widget( Tabs::new(model.tab_titles()) .select(model.tab_index()) .style(muted(theme)) .highlight_style(selected(theme).add_modifier(Modifier::BOLD)) .divider(" "), tabs, ); } fn render_footer(model: &Model, theme: &Theme, frame: &mut Frame, area: Rect) { let text = match &model.message { // A fired action's outcome, which is transient and worth noticing. Some(message) => Span::styled( format!(" {message}"), Style::default().fg(theme.status_warning), ), None => Span::styled( " tab/shift-tab switch up/down move enter open 1-9 jump q quit", muted(theme), ), }; frame.render_widget( Paragraph::new(Line::from(text)).style(Style::default().bg(theme.surface_sunken)), area, ); } // --------------------------------------------------------------------------- // Rollup // --------------------------------------------------------------------------- /// The rollup's columns, left to right. /// /// The mark's name is empty because the name is what the header row draws, and /// this column's header always was blank: the glyph says what it is. /// /// Nothing here is `Optional`. A rollup with the status or the source name /// dropped is not a narrower rollup, it is a different screen, and the age is /// what turns "FAIL" into "FAIL, and it has been that way for two days". Detail /// absorbs what is left, which is what the old `Min(10)` was saying. const ROLLUP_COLUMNS: [Column<'static>; 4] = [ Column { name: "", width: Width::Fixed, priority: Priority::Essential, sortable: false, sorted: None, }, Column { name: "source", width: Width::Content, priority: Priority::Essential, sortable: false, sorted: None, }, Column { name: "age", width: Width::Content, priority: Priority::Secondary, sortable: false, sorted: None, }, Column { name: "detail", width: Width::Fill, priority: Priority::Essential, sortable: false, sorted: None, }, ]; /// The tracks the hand-written `Constraint`s carried, lifted rather than /// re-chosen. The two `Width::Content` columns measure themselves from the /// cells and use these only as a floor, so a run of short source names stops /// spending fourteen columns to say `pom`. const ROLLUP_SIZING: Sizing<'static> = Sizing { lengths: &[("", 4), ("source", 14), ("age", 8), ("detail", 10)], fallback: 8, }; /// Every source at once, worst first. /// /// Without this magicmirror is N tabs you still have to visit one at a time, /// which is the situation it replaces, with extra steps. fn render_rollup(model: &Model, theme: &Theme, now: DateTime, frame: &mut Frame, area: Rect) { let order = model.rollup_order(now); let rows: Vec> = order .iter() .map(|&index| { let source = &model.sources[index]; let status = source.status(now); let age = match source.age(now) { Some(age) => value::duration(age.num_seconds()), None => "-".into(), }; vec![ // The mark styles its own span rather than the cell: a status // colour is this app's, not a part the table module knows, and // a span's style sits on top of the cell's. TableCell::new( "", Span::styled( value::status_mark(status), value::status_style(theme, status), ), ), TableCell::new("source", source.name.clone()), TableCell::new("age", Span::styled(age, muted(theme))), TableCell::new("detail", source.summary(now)), ] }) .collect(); // The block first, because narrowing is measured against the width the // table actually gets rather than the width of the area around it. Two // columns of border is the difference between "detail fits" and "detail // is cut", which is exactly the decision the cutoff is making. let block = container(theme, " all sources "); let inner = block.inner(area); let table = table::table( &ROLLUP_COLUMNS, &rows, &ROLLUP_SIZING, &TableStyle::from_theme(theme), inner.width, ) .block(block); // Selection through the widget's own highlight rather than a per-row style. // `TableStyle::from_theme` carries it on the background alone, which is what // leaves a FAIL row's danger colour on top of it -- the same reason the // local `selected` helper gives a surface instead of reversing. let mut state = TableState::default().with_selected(Some(model.rollup_selected)); frame.render_stateful_widget(table, area, &mut state); } // --------------------------------------------------------------------------- // One source // --------------------------------------------------------------------------- fn render_source( source: &SourceState, theme: &Theme, now: DateTime, frame: &mut Frame, area: Rect, ) { let [list_area, detail_area] = Layout::vertical([Constraint::Percentage(55), Constraint::Min(5)]).areas(area); let rows = source.rows(); let items: Vec = rows .iter() .enumerate() .map(|(i, row)| { let indent = " ".repeat(row.depth); let spans = vec![ Span::styled( format!("{:<5}", value::status_mark(row.node.status)), value::status_style(theme, row.node.status), ), Span::styled( format!("{indent}{}", row.node.label), Style::default().fg(theme.content_primary), ), Span::styled(format!(" ({})", row.node.kind), muted(theme)), ]; // Selection is the row's surface, applied under the spans rather // than patched into each one, so a status colour survives being // selected. let item = ListItem::new(Line::from(spans)); if i == source.selected { item.style(selected(theme)) } else { item } }) .collect(); let title = format!(" {} ", source.name); let list = if items.is_empty() { List::new(vec![ListItem::new(Line::from(Span::styled( source.summary(now), muted(theme), )))]) } else { List::new(items) }; frame.render_widget( list.block( Block::bordered() .border_style(Style::default().fg(theme.line_border)) .title(Span::styled( title, Style::default().fg(theme.content_secondary), )), ), list_area, ); render_detail(source, theme, now, frame, detail_area); } /// The selected node's fields and conditions. /// /// Conditions are the half that usually gets dropped, and the half that pays: /// "blocked" is useless, "blocked because burn_in is 31h of 48h" is what saves /// an SSH. fn render_detail( source: &SourceState, theme: &Theme, now: DateTime, frame: &mut Frame, area: Rect, ) { let width = area.width.saturating_sub(4) as usize; let mut lines: Vec = Vec::new(); match source.selected_node() { Some(node) => { lines.push(Line::from(vec![ Span::styled( value::status_mark(node.status), value::status_style(theme, node.status), ), Span::raw(" "), Span::styled( node.label.clone(), Style::default() .fg(theme.content_primary) .add_modifier(Modifier::BOLD), ), ])); lines.extend(field_lines(theme, node, now, width)); lines.extend(condition_lines(theme, node, width)); if !node.actions.is_empty() { // The hint tells the operator whether Enter does anything here, // so a read-only source does not look broken when a keypress is // ignored. let hint = if source.allow_actions { " (enter to run)" } else { " (read-only)" }; lines.push(Line::from(vec![ Span::styled( format!("actions: {}", node.actions.join(", ")), muted(theme), ), Span::styled(hint, muted(theme)), ])); } } None => lines.push(Line::from(Span::styled(source.summary(now), muted(theme)))), } frame.render_widget( Paragraph::new(lines).block(container(theme, " detail ")), area, ); } fn field_lines(theme: &Theme, node: &Node, now: DateTime, width: usize) -> Vec> { let label_width = node .fields .iter() .map(|f| f.label.chars().count()) .max() .unwrap_or(0); node.fields .iter() .map(|field| { let budget = width.saturating_sub(label_width + 2); let rendered = value::render(&field.value, now, budget); let style = match &field.value { ops_status::Value::Progress { value, max, .. } => { value::progress_style(theme, *value, *max) } other => value::style(theme, other), }; Line::from(vec![ Span::styled(format!("{: Vec> { node.conditions .iter() .map(|condition| { let mut spans = vec![ Span::styled( format!("{:<5}", value::status_mark(condition.status)), value::status_style(theme, condition.status), ), Span::styled( condition.condition_type.clone(), Style::default().fg(theme.content_primary), ), ]; if let Some(detail) = &condition.detail { let budget = width.saturating_sub(condition.condition_type.chars().count() + 8); spans.push(Span::styled( format!(" {}", truncate(detail, budget.max(8))), muted(theme), )); } Line::from(spans) }) .collect() } // --------------------------------------------------------------------------- // Action prompts // --------------------------------------------------------------------------- /// The modal path to firing an action: pick, then clear its guard. /// /// A `danger` action shows a red header and asks the operator to type its key; /// muscle memory cannot type `rollback-b`, which is the whole safeguard. The /// resolved host is left off — the tab already names the source — so the line /// stays short and shows the method and path the request will use. fn render_prompt(model: &Model, theme: &Theme, frame: &mut Frame, area: Rect) { let Some(prompt) = &model.prompt else { return }; let source = model.sources.get(prompt_source(prompt)); let plain = Style::default().fg(theme.content_primary); let danger_style = Style::default().fg(theme.status_danger); let (title, title_style, lines) = match prompt { Prompt::Pick { keys, selected, .. } => { let mut lines = Vec::new(); for (i, key) in keys.iter().enumerate() { let action = source.and_then(|s| s.action(key)); let label = action.map_or(key.as_str(), |a| a.label.as_str()); let danger = action.is_some_and(|a| a.danger); let marker = if i == *selected { "> " } else { " " }; // Danger is the foreground and selection the surface, so a // selected danger action is still red rather than swapped into // its own background. let mut style = if danger { danger_style } else { plain }; if i == *selected { style = style.bg(theme.surface_raised); } lines.push(Line::from(Span::styled( format!("{marker}{}. {label} [{key}]", i + 1), style, ))); } lines.push(Line::from("")); lines.push(Line::from(Span::styled( "enter run esc cancel", muted(theme), ))); ( " run action ", Style::default().fg(theme.line_border), lines, ) } Prompt::Confirm { key, .. } => { let action = source.and_then(|s| s.action(key)); let lines = vec![ action_summary_line(theme, action, key), Line::from(""), Line::from(Span::styled( "press y to confirm esc cancel", muted(theme), )), ]; (" confirm ", Style::default().fg(theme.line_border), lines) } Prompt::Type { key, typed, .. } => { let lines = vec![ Line::from(Span::styled( "DANGER", danger_style.add_modifier(Modifier::BOLD), )), action_summary_line(theme, source.and_then(|s| s.action(key)), key), Line::from(""), Line::from(Span::styled( format!("type '{key}' to confirm:"), muted(theme), )), Line::from(Span::styled(format!("> {typed}\u{258f}"), danger_style)), Line::from(""), Line::from(Span::styled("esc cancel", muted(theme))), ]; (" DANGER ", danger_style.add_modifier(Modifier::BOLD), lines) } }; let width = lines .iter() .map(Line::width) .chain(std::iter::once(title.len())) .max() .unwrap_or(0) as u16 + 4; let height = lines.len() as u16 + 2; let popup = centered(area, width, height); // Clear what is under the popup so the tab behind does not show through, // then paint the overlay surface: makeover's `surface.overlay` is the // intent for exactly this, a surface that floats above the page. frame.render_widget(Clear, popup); frame.render_widget( Paragraph::new(lines) .style(Style::default().bg(theme.surface_overlay)) .block( Block::bordered() .title(Span::styled(title, title_style)) .border_style(title_style), ), popup, ); } /// One line naming what an action will do: `Roll back POST /rollback/b`. fn action_summary_line( theme: &Theme, action: Option<&ops_status::Action>, key: &str, ) -> Line<'static> { match action { Some(action) => Line::from(vec![ Span::styled( action.label.clone(), Style::default() .fg(theme.content_primary) .add_modifier(Modifier::BOLD), ), Span::styled( format!(" {} {}", method_str(action.method), action.url), Style::default().fg(theme.content_secondary), ), ]), // The action was retracted by a poll since the prompt opened; say so // rather than render a blank confirmation. None => Line::from(Span::styled( format!("{key}: no longer offered"), Style::default().fg(theme.status_warning), )), } } fn method_str(method: Method) -> &'static str { match method { Method::Get => "GET", Method::Post => "POST", Method::Put => "PUT", Method::Delete => "DELETE", } } fn prompt_source(prompt: &Prompt) -> usize { match prompt { Prompt::Pick { source, .. } | Prompt::Confirm { source, .. } | Prompt::Type { source, .. } => *source, } } /// A rectangle of the given size centered in `area`, clamped so it always fits. fn centered(area: Rect, width: u16, height: u16) -> Rect { let width = width.min(area.width); let height = height.min(area.height); Rect { x: area.x + (area.width - width) / 2, y: area.y + (area.height - height) / 2, width, height, } } /// Clip to a character budget, marking that something was cut. fn truncate(text: &str, max: usize) -> String { // A detail string is producer-supplied and may carry newlines; the pane is // line-oriented, so flatten first. let flat: String = text.replace('\n', " "); if flat.chars().count() <= max { return flat; } let kept: String = flat.chars().take(max.saturating_sub(1)).collect(); format!("{kept}…") } #[cfg(test)] mod tests { use super::*; use crate::model::SourceState; use chrono::TimeDelta; use ops_status::{Condition, Field, Payload, Status, Value}; use ratatui::Terminal; use ratatui::backend::TestBackend; fn now() -> DateTime { "2026-07-21T18:00:00Z".parse().unwrap() } /// Render a model into a fixed-size buffer and return it as text lines. /// /// This is the whole payoff of keeping render pure: the entire surface is /// verifiable with no daemon running and no terminal attached. fn draw(model: &Model, now: DateTime, width: u16, height: u16) -> Vec { let theme = crate::theme::tests::fixed(); let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); terminal .draw(|frame| render(model, &theme, now, frame)) .unwrap(); let buffer = terminal.backend().buffer().clone(); (0..buffer.area.height) .map(|y| { (0..buffer.area.width) .map(|x| buffer[(x, y)].symbol().to_string()) .collect::() .trim_end() .to_string() }) .collect() } fn node(id: &str, label: &str, status: Status) -> Node { Node { id: id.into(), kind: "tier".into(), label: label.into(), status, fields: Vec::new(), conditions: Vec::new(), children: Vec::new(), actions: Vec::new(), } } fn source(name: &str, at: DateTime, nodes: Vec) -> SourceState { let mut s = SourceState::new(name, TimeDelta::seconds(60)); let mut p = Payload::new(name, at); p.nodes = nodes; s.observe(p, at); s } fn joined(lines: &[String]) -> String { lines.join("\n") } #[test] fn the_rollup_leads_with_the_worst_source() { let model = Model::new(vec![ source("sando", now(), vec![node("a", "tier a", Status::Ok)]), source("bento", now(), vec![node("b", "goingson", Status::Failed)]), ]); let lines = draw(&model, now(), 80, 12); let text = joined(&lines); assert!(text.contains("rollup"), "{text}"); // Skip the tab bar, which names every source regardless of order. let body = &lines[1..]; let bento = body.iter().position(|l| l.contains("bento")).unwrap(); let sando = body.iter().position(|l| l.contains("sando")).unwrap(); assert!(bento < sando, "the failing source must be on top:\n{text}"); assert!(text.contains("FAIL"), "{text}"); } #[test] fn a_source_that_has_never_answered_says_so_rather_than_showing_nothing() { let model = Model::new(vec![SourceState::new("bento", TimeDelta::seconds(60))]); let text = joined(&draw(&model, now(), 80, 12)); assert!( text.contains("????"), "an unreachable source must be loud:\n{text}" ); assert!(text.contains("waiting for first poll"), "{text}"); } #[test] fn a_stale_source_shows_its_age_in_the_rollup() { let model = Model::new(vec![source( "pom", now() - TimeDelta::hours(4), vec![node("backup", "backup", Status::Ok)], )]); let text = joined(&draw(&model, now(), 80, 12)); assert!(text.contains("4h"), "the age must be visible:\n{text}"); assert!(text.contains("degr"), "stale-but-green is not ok:\n{text}"); } #[test] fn a_narrow_rollup_drops_the_age_before_it_drops_the_detail() { // What the hand-written `Constraint`s could not do: at 80 columns every // column is drawn, and at a width where they no longer all fit the // priority decides which one goes rather than the order they were // written in. Age is the only Secondary column, so it is the only one // that can go. let model = Model::new(vec![source( "pom", now() - TimeDelta::hours(4), vec![node("backup", "backup", Status::Ok)], )]); let wide = joined(&draw(&model, now(), 80, 12)); assert!(wide.contains("age"), "the age column at 80 wide:\n{wide}"); let narrow = joined(&draw(&model, now(), 24, 12)); assert!(!narrow.contains("age"), "age must drop first:\n{narrow}"); assert!(narrow.contains("pom"), "the source stays:\n{narrow}"); assert!(narrow.contains("detail"), "the detail stays:\n{narrow}"); } #[test] fn a_source_tab_lists_its_nodes_with_children_indented() { let mut parent = node("tier:b", "b (prod-1)", Status::Ok); parent.children = vec!["node:prod-1".into()]; let child = node("node:prod-1", "prod-1", Status::Ok); let mut model = Model::new(vec![source("sando", now(), vec![parent, child])]); model.select_tab(1); let lines = draw(&model, now(), 80, 20); let text = joined(&lines); let parent_row = lines.iter().position(|l| l.contains("b (prod-1)")).unwrap(); let child_row = lines.iter().position(|l| l.contains("prod-1 (")).unwrap(); assert!(parent_row < child_row, "{text}"); // The child is indented relative to its parent. let parent_col = lines[parent_row].find("b (prod-1)").unwrap(); let child_col = lines[child_row].find("prod-1").unwrap(); assert!(child_col > parent_col, "child must be indented:\n{text}"); } #[test] fn the_detail_pane_shows_conditions_with_their_why() { let mut n = node("tier:b", "b", Status::Ok); n.conditions = vec![Condition { condition_type: "burn_in".into(), status: Status::Pending, since: None, detail: Some("17 hours remaining of 48".into()), }]; let mut model = Model::new(vec![source("sando", now(), vec![n])]); model.select_tab(1); let text = joined(&draw(&model, now(), 80, 20)); assert!(text.contains("burn_in"), "{text}"); assert!( text.contains("17 hours remaining"), "a condition without its why is useless:\n{text}" ); } #[test] fn a_progress_field_renders_as_a_bar() { let mut n = node("tier:b", "b", Status::Ok); n.fields = vec![Field::new( "burn-in", Value::Progress { value: 31.0, max: 48.0, unit: Some("hour".into()), }, )]; let mut model = Model::new(vec![source("sando", now(), vec![n])]); model.select_tab(1); let text = joined(&draw(&model, now(), 80, 20)); assert!(text.contains("31/48 hour"), "{text}"); assert!( text.contains('#'), "a progress value must draw a bar:\n{text}" ); } #[test] fn an_instant_renders_relative_to_the_passed_in_clock() { let mut n = node("tier:b", "b", Status::Ok); n.fields = vec![Field::new( "built", Value::Instant { value: now() - TimeDelta::minutes(3), }, )]; let mut model = Model::new(vec![source("sando", now(), vec![n])]); model.select_tab(1); let text = joined(&draw(&model, now(), 80, 20)); assert!(text.contains("3m 0s ago"), "{text}"); } #[test] fn render_is_deterministic_for_a_fixed_clock() { // The property every snapshot test rests on. let mut n = node("tier:b", "b", Status::Ok); n.fields = vec![Field::new( "built", Value::Instant { value: now() - TimeDelta::minutes(3), }, )]; let mut model = Model::new(vec![source("sando", now(), vec![n])]); model.select_tab(1); assert_eq!(draw(&model, now(), 80, 20), draw(&model, now(), 80, 20)); } #[test] fn an_unknown_value_kind_still_renders_as_text() { // Version skew: a producer one release ahead must not blank the pane. let field: Field = serde_json::from_str(r#"{"label":"temp","kind":"celsius","value":"41"}"#).unwrap(); let mut n = node("tier:b", "b", Status::Ok); n.fields = vec![field]; let mut model = Model::new(vec![source("sando", now(), vec![n])]); model.select_tab(1); let text = joined(&draw(&model, now(), 80, 20)); assert!(text.contains("temp"), "{text}"); assert!(text.contains("41"), "{text}"); } #[test] fn a_narrow_terminal_does_not_panic() { // Every widget here has to survive a width no layout was designed for. let mut n = node("tier:b", "a rather long tier label", Status::Failed); n.fields = vec![Field::new( "path", Value::Path { value: "/srv/sando/releases/a3f9c21b7e4d8056/bin/makenotwork".into(), }, )]; n.conditions = vec![Condition { condition_type: "node_health".into(), status: Status::Failed, since: None, detail: Some("prod-1 unhealthy: connection refused after 30s".into()), }]; let mut model = Model::new(vec![source("sando", now(), vec![n])]); model.select_tab(1); for width in [8_u16, 12, 20, 40] { for height in [4_u16, 8, 20] { let _ = draw(&model, now(), width, height); } } } #[test] fn a_multiline_detail_is_flattened_not_sprawled() { assert_eq!(truncate("a\nb", 40), "a b"); assert!(truncate(&"x".repeat(100), 10).ends_with('…')); assert_eq!(truncate(&"x".repeat(100), 10).chars().count(), 10); } fn action(label: &str, danger: bool) -> ops_status::Action { ops_status::Action { label: label.into(), method: ops_status::Method::Post, url: "/rollback/b".into(), confirm: true, danger, body: None, } } /// A source with one node declaring `keys`, actions allowed, on its tab. fn actionable(keys: &[(&str, bool)]) -> Model { let mut n = node("tier:b", "b (prod-1)", Status::Ok); n.actions = keys.iter().map(|(k, _)| k.to_string()).collect(); let mut p = Payload::new("sando", now()); p.nodes = vec![n]; p.actions = keys .iter() .map(|(k, d)| (k.to_string(), action(k, *d))) .collect(); let mut s = SourceState::new("sando", TimeDelta::seconds(60)).with_actions(true); s.observe(p, now()); let mut m = Model::new(vec![s]); m.select_tab(1); m } #[test] fn the_detail_hint_says_whether_actions_can_run() { let mut m = actionable(&[("rollback-b", true)]); let text = joined(&draw(&m, now(), 80, 20)); assert!(text.contains("rollback-b"), "{text}"); assert!(text.contains("enter to run"), "{text}"); m.sources[0].allow_actions = false; let text = joined(&draw(&m, now(), 80, 20)); assert!( text.contains("read-only"), "a disabled source must say so:\n{text}" ); } #[test] fn the_picker_lists_a_nodes_actions() { let mut m = actionable(&[("promote-b", false), ("rollback-b", true)]); m.open_actions(); let text = joined(&draw(&m, now(), 80, 20)); assert!(text.contains("run action"), "{text}"); assert!(text.contains("[promote-b]"), "{text}"); assert!(text.contains("[rollback-b]"), "{text}"); assert!(text.contains("enter run"), "{text}"); } #[test] fn a_danger_prompt_shows_the_key_to_type() { let mut m = actionable(&[("rollback-b", true)]); m.open_actions(); m.prompt_enter(); // Pick -> Type (danger) let text = joined(&draw(&m, now(), 80, 20)); assert!( text.contains("DANGER"), "a danger action must be loud:\n{text}" ); assert!( text.contains("type 'rollback-b'"), "the exact key to type must be shown:\n{text}" ); } #[test] fn a_retracted_action_is_named_in_the_confirmation_not_left_blank() { let mut m = actionable(&[("promote-b", false)]); m.open_actions(); m.prompt_enter(); // Pick -> Confirm (confirm, not danger) // A poll drops the action while the confirm box is up. let mut p = Payload::new("sando", now()); p.nodes = vec![node("tier:b", "b", Status::Ok)]; m.sources[0].observe(p, now()); let text = joined(&draw(&m, now(), 80, 20)); assert!(text.contains("no longer offered"), "{text}"); } #[test] fn the_footer_shows_a_message_when_there_is_one() { let mut model = Model::new(vec![source("sando", now(), vec![])]); let text = joined(&draw(&model, now(), 80, 12)); assert!(text.contains("q quit"), "{text}"); model.message = Some("refreshing".into()); let text = joined(&draw(&model, now(), 80, 12)); assert!(text.contains("refreshing"), "{text}"); } }