//! What the viewer knows, and the rollup derived from it. //! //! The model holds one [`SourceState`] per configured source and nothing about //! what any of them mean. Everything the rollup shows is computed from the //! payloads alone, which is what keeps the shell from acquiring domain //! knowledge one convenience at a time. use chrono::{DateTime, TimeDelta, Utc}; use ops_status::{Action, Node, Payload, Status}; /// One source, as last heard from. pub(crate) struct SourceState { pub name: String, /// Whether this source's declared actions may be fired. Off by default; set /// from the config's per-source `allow_actions`. Kept on the state rather /// than threaded through key handling so the model stays self-contained and /// testable. pub allow_actions: bool, /// The last payload that parsed. Kept across a failed poll so the screen /// shows the last known state alongside the fact that it is now stale, /// rather than going blank. pub payload: Option, /// Why the last poll failed, if it did. pub error: Option, /// When a poll last succeeded. pub last_ok: Option>, /// Age past which this source's answer stops counting as current. pub stale_after: TimeDelta, /// Row selection within this source's tab. pub selected: usize, } impl SourceState { pub(crate) fn new(name: impl Into, stale_after: TimeDelta) -> Self { SourceState { name: name.into(), allow_actions: false, payload: None, error: None, last_ok: None, stale_after, selected: 0, } } /// Let this source's declared actions be fired. Builder-style so tests and /// `main` set it without a wider constructor. pub(crate) fn with_actions(mut self, allow: bool) -> Self { self.allow_actions = allow; self } /// A declared action by key, from the current payload. `None` if the source /// has not answered or no longer offers it — the latter matters because a /// poll between opening a prompt and confirming can retract an action. pub(crate) fn action(&self, key: &str) -> Option<&Action> { self.payload.as_ref().and_then(|p| p.actions.get(key)) } /// How old the current payload is, if there is one. pub(crate) fn age(&self, now: DateTime) -> Option { self.payload.as_ref().map(|p| p.age(now)) } pub(crate) fn is_stale(&self, now: DateTime) -> bool { self.age(now).is_some_and(|age| age > self.stale_after) } /// This source's line in the rollup. /// /// Three things can be wrong and all three are visible here: /// /// - it cannot be reached at all (`unknown`) /// - it answers, but with something old (`degraded` at minimum, however /// green its contents) /// - it answers freshly and reports trouble (whatever it reports) /// /// The middle case is the one that is normally missed. A backup check that /// answers "ok" about a snapshot taken forty days ago is not ok, and every /// check that existed said it was. pub(crate) fn status(&self, now: DateTime) -> Status { let Some(payload) = &self.payload else { return Status::Unknown; }; if self.error.is_some() || self.is_stale(now) { return payload.worst_status().max(Status::Degraded); } payload.worst_status() } /// A short phrase for why this source reads the way it does. pub(crate) fn summary(&self, now: DateTime) -> String { if let Some(error) = &self.error { let last = match self.last_ok { Some(at) => crate::value::relative(at, now), None => "never".into(), }; return format!("unreachable ({error}); last ok {last}"); } let Some(payload) = &self.payload else { return "waiting for first poll".into(); }; if self.is_stale(now) { return format!( "stale: last answered {}", crate::value::duration(payload.age(now).num_seconds()) ); } let failing = payload .nodes .iter() .filter(|n| n.status >= Status::Degraded) .count(); match (failing, payload.nodes.len()) { (0, 1) => "1 node ok".into(), (0, total) => format!("{total} nodes ok"), (1, _) => "1 node needs attention".into(), (n, _) => format!("{n} nodes need attention"), } } /// Nodes in display order: each root followed by its children. /// /// Children are referenced by id rather than nested, so this is where the /// flat list becomes a tree. Depth stops at one: the contract allows deeper /// nesting but nothing emits it, and an unbounded recursion over /// producer-supplied ids is a denial-of-service waiting to happen. pub(crate) fn rows(&self) -> Vec> { let Some(payload) = &self.payload else { return Vec::new(); }; let mut rows = Vec::new(); for root in payload.roots() { rows.push(Row { node: root, depth: 0, }); for child_id in &root.children { if let Some(child) = payload.node(child_id) { rows.push(Row { node: child, depth: 1, }); } } } rows } /// The node the cursor is on. pub(crate) fn selected_node(&self) -> Option<&Node> { let rows = self.rows(); rows.get(self.selected.min(rows.len().saturating_sub(1))) .map(|r| r.node) } pub(crate) fn move_selection(&mut self, delta: isize) { let len = self.rows().len(); if len == 0 { self.selected = 0; return; } let next = self.selected as isize + delta; self.selected = next.clamp(0, len as isize - 1) as usize; } /// Record a successful poll. pub(crate) fn observe(&mut self, payload: Payload, at: DateTime) { self.payload = Some(payload); self.error = None; self.last_ok = Some(at); // A payload with fewer nodes than before must not leave the cursor // pointing past the end. let len = self.rows().len(); if self.selected >= len { self.selected = len.saturating_sub(1); } } /// Record a failed poll, keeping the last known payload. pub(crate) fn observe_error(&mut self, error: impl Into) { self.error = Some(error.into()); } } /// One line in a source tab. pub(crate) struct Row<'a> { pub node: &'a Node, pub depth: usize, } /// Which tab is showing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Tab { /// Every source at once, worst first. The tab that earns the product. Rollup, Source(usize), } /// A modal step between "I want to run this" and the request going out. /// /// Firing a declared action can move production, so the path to it is explicit /// and never a single keystroke: pick which action, then clear its guard. The /// guard's weight is set by the action itself — a `danger` action is confirmed /// by typing its key, a `confirm` one by a `y`, a plain one not at all. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum Prompt { /// Choosing which of the selected node's actions to run. Pick { source: usize, keys: Vec, selected: usize, }, /// A `y`/`n` guard for a `confirm` action that is not `danger`. Confirm { source: usize, key: String }, /// The heaviest guard: type the action key to fire a `danger` action. Muscle /// memory cannot type `rollback-b`, which is the point. Type { source: usize, key: String, typed: String, }, } /// A confirmed request the run loop is to issue. The model records it and stops /// there; resolving the source's URL and token and making the HTTP call is the /// loop's job, which keeps every network effect out of the model. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct FireRequest { pub source: usize, pub key: String, } /// What a keypress asked the model to do once the prompt resolved. #[derive(Debug, PartialEq, Eq)] pub(crate) enum PromptStep { /// Still in a prompt (or none was open); nothing to dispatch. Idle, /// The prompt closed with a confirmed request to fire. Fire(FireRequest), /// The prompt closed without firing. Cancelled, } pub(crate) struct Model { pub sources: Vec, pub tab: Tab, /// Cursor within the rollup tab. pub rollup_selected: usize, /// Transient message shown in the footer. pub message: Option, /// The open modal, if any. pub prompt: Option, } impl Model { pub(crate) fn new(sources: Vec) -> Self { Model { sources, tab: Tab::Rollup, rollup_selected: 0, message: None, prompt: None, } } // -- Actions ----------------------------------------------------------- /// Enter on a source node: open the action picker, or explain why not. /// /// A node with no actions does nothing. A source with actions disabled says /// so rather than silently ignoring the key, because a viewer that looks /// like it should be able to act and does not is worse than one that says it /// cannot. pub(crate) fn open_actions(&mut self) { let Tab::Source(i) = self.tab else { return }; let Some(source) = self.sources.get(i) else { return; }; let Some(node) = source.selected_node() else { return; }; if node.actions.is_empty() { return; } if !source.allow_actions { self.message = Some(format!( "{}: actions are read-only here (set allow_actions to enable)", source.name )); return; } self.prompt = Some(Prompt::Pick { source: i, keys: node.actions.clone(), selected: 0, }); } /// Move the cursor inside an open picker. No-op for the other prompts. pub(crate) fn prompt_move(&mut self, delta: isize) { if let Some(Prompt::Pick { keys, selected, .. }) = &mut self.prompt { if keys.is_empty() { return; } let next = *selected as isize + delta; *selected = next.clamp(0, keys.len() as isize - 1) as usize; } } /// A digit inside a picker jumps straight to that action (1-based). pub(crate) fn prompt_digit(&mut self, n: usize) { if let Some(Prompt::Pick { keys, selected, .. }) = &mut self.prompt && (1..=keys.len()).contains(&n) { *selected = n - 1; } } /// A printable character while typing a `danger` action's key. pub(crate) fn prompt_push(&mut self, c: char) { if let Some(Prompt::Type { typed, .. }) = &mut self.prompt { typed.push(c); } } /// Backspace while typing. pub(crate) fn prompt_backspace(&mut self) { if let Some(Prompt::Type { typed, .. }) = &mut self.prompt { typed.pop(); } } /// Enter: advance the picker into a guard, or clear a `Type` guard. /// /// - On a picker, resolves the chosen action and either opens its guard /// (`Type` for danger, `Confirm` for confirm) or fires it outright. /// - On a `Type` guard, fires only when the typed text matches the key. /// - `Confirm` does not respond to Enter; it wants an explicit `y` /// ([`confirm_yes`]), so a stray Enter cannot promote through it. pub(crate) fn prompt_enter(&mut self) -> PromptStep { match self.prompt.take() { Some(Prompt::Pick { source, keys, selected, }) => { let Some(key) = keys.get(selected).cloned() else { return PromptStep::Cancelled; }; let Some(action) = self.sources.get(source).and_then(|s| s.action(&key)) else { self.message = Some(format!("{key}: no longer offered")); return PromptStep::Cancelled; }; if action.danger { self.prompt = Some(Prompt::Type { source, key, typed: String::new(), }); PromptStep::Idle } else if action.confirm { self.prompt = Some(Prompt::Confirm { source, key }); PromptStep::Idle } else { self.fire(source, key) } } Some(Prompt::Type { source, key, typed }) => { if typed == key { self.fire(source, key) } else { self.message = Some(format!("type '{key}' exactly to confirm")); self.prompt = Some(Prompt::Type { source, key, typed: String::new(), }); PromptStep::Idle } } other => { self.prompt = other; PromptStep::Idle } } } /// `y` on a `Confirm` guard fires; anywhere else it is nothing. pub(crate) fn confirm_yes(&mut self) -> PromptStep { if let Some(Prompt::Confirm { source, key }) = self.prompt.take() { self.fire(source, key) } else { PromptStep::Idle } } /// Close any open prompt without firing. pub(crate) fn cancel_prompt(&mut self) -> PromptStep { if self.prompt.take().is_some() { PromptStep::Cancelled } else { PromptStep::Idle } } /// Record a confirmed request and clear the prompt. Guards checked the /// action still existed, so this only assembles the request; the loop makes /// the call. fn fire(&mut self, source: usize, key: String) -> PromptStep { self.prompt = None; PromptStep::Fire(FireRequest { source, key }) } /// Source indices ordered worst-first, then by name. /// /// Worst-first is the whole argument for the rollup existing. Sorted any /// other way it is a list you still have to read all of, which is the /// situation it replaces. pub(crate) fn rollup_order(&self, now: DateTime) -> Vec { let mut order: Vec = (0..self.sources.len()).collect(); order.sort_by(|&a, &b| { let (sa, sb) = (self.sources[a].status(now), self.sources[b].status(now)); sb.cmp(&sa) .then_with(|| self.sources[a].name.cmp(&self.sources[b].name)) }); order } /// The worst status across every source: the one thing to look at first. pub(crate) fn worst(&self, now: DateTime) -> Status { self.sources .iter() .map(|s| s.status(now)) .max() .unwrap_or(Status::Unknown) } pub(crate) fn tab_titles(&self) -> Vec { let mut titles = vec!["rollup".to_string()]; titles.extend(self.sources.iter().map(|s| s.name.clone())); titles } pub(crate) fn tab_index(&self) -> usize { match self.tab { Tab::Rollup => 0, Tab::Source(i) => i + 1, } } pub(crate) fn select_tab(&mut self, index: usize) { self.tab = match index { 0 => Tab::Rollup, n if n <= self.sources.len() => Tab::Source(n - 1), _ => self.tab, }; } pub(crate) fn next_tab(&mut self) { let next = (self.tab_index() + 1) % (self.sources.len() + 1); self.select_tab(next); } pub(crate) fn prev_tab(&mut self) { let count = self.sources.len() + 1; let next = (self.tab_index() + count - 1) % count; self.select_tab(next); } /// Move the cursor in whichever tab is showing. pub(crate) fn move_selection(&mut self, delta: isize, now: DateTime) { match self.tab { Tab::Rollup => { let len = self.rollup_order(now).len(); if len == 0 { return; } let next = self.rollup_selected as isize + delta; self.rollup_selected = next.clamp(0, len as isize - 1) as usize; } Tab::Source(i) => { if let Some(source) = self.sources.get_mut(i) { source.move_selection(delta); } } } } /// Enter on a rollup row opens that source's tab. pub(crate) fn open_selected(&mut self, now: DateTime) { if self.tab == Tab::Rollup { let order = self.rollup_order(now); if let Some(&index) = order.get(self.rollup_selected) { self.tab = Tab::Source(index); } } } } #[cfg(test)] mod tests { use super::*; use ops_status::{Action, Condition, Method, Node}; use std::collections::BTreeMap; fn now() -> DateTime { "2026-07-21T18:00:00Z".parse().unwrap() } fn node(id: &str, status: Status, children: Vec<&str>) -> Node { Node { id: id.into(), kind: "tier".into(), label: id.into(), status, fields: Vec::new(), conditions: Vec::new(), children: children.into_iter().map(Into::into).collect(), actions: Vec::new(), } } fn act(label: &str, confirm: bool, danger: bool) -> Action { Action { label: label.into(), method: Method::Post, url: "/x".into(), confirm, danger, body: None, } } /// A source on a single node that declares the given actions, with actions /// allowed, sitting on its own tab ready to prompt. fn actionable(node_actions: &[&str], declared: Vec<(&str, Action)>) -> Model { let mut n = node("tier:b", Status::Ok, vec![]); n.actions = node_actions.iter().map(ToString::to_string).collect(); let mut p = payload(now(), vec![n]); p.actions = declared .into_iter() .map(|(k, a)| (k.to_string(), a)) .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 } fn payload(at: DateTime, nodes: Vec) -> Payload { let mut p = Payload::new("sando", at); p.nodes = nodes; p } fn source(name: &str, at: DateTime, nodes: Vec) -> SourceState { let mut s = SourceState::new(name, TimeDelta::seconds(60)); s.observe(payload(at, nodes), at); s } #[test] fn a_source_never_polled_is_unknown_not_ok() { let s = SourceState::new("sando", TimeDelta::seconds(60)); assert_eq!(s.status(now()), Status::Unknown); assert_eq!(s.summary(now()), "waiting for first poll"); } #[test] fn a_fresh_healthy_source_is_ok() { let s = source("sando", now(), vec![node("tier:b", Status::Ok, vec![])]); assert_eq!(s.status(now()), Status::Ok); assert_eq!(s.summary(now()), "1 node ok"); } #[test] fn a_stale_but_green_source_is_degraded() { // The forty-day-old backup that every check called healthy. let s = source( "pom", now() - TimeDelta::hours(4), vec![node("backup", Status::Ok, vec![])], ); assert_eq!(s.status(now()), Status::Degraded); assert!( s.summary(now()).starts_with("stale:"), "{}", s.summary(now()) ); } #[test] fn staleness_never_downgrades_a_worse_status() { let mut s = source( "sando", now() - TimeDelta::hours(4), vec![node("tier:b", Status::Failed, vec![])], ); assert_eq!(s.status(now()), Status::Failed); s.observe_error("connection refused"); assert_eq!(s.status(now()), Status::Failed); } #[test] fn an_unreachable_source_keeps_its_last_payload_and_says_when() { let mut s = source("bento", now(), vec![node("app:x", Status::Ok, vec![])]); s.observe_error("connection refused"); // Degraded, not Unknown: we still have a recent answer, we just could // not refresh it. assert_eq!(s.status(now()), Status::Degraded); assert!( s.payload.is_some(), "the last known state must not go blank" ); let summary = s.summary(now()); assert!(summary.contains("connection refused"), "{summary}"); assert!(summary.contains("last ok"), "{summary}"); } #[test] fn a_source_that_never_answered_and_then_failed_is_unknown() { let mut s = SourceState::new("bento", TimeDelta::seconds(60)); s.observe_error("connection refused"); assert_eq!(s.status(now()), Status::Unknown); assert!(s.summary(now()).contains("last ok never")); } #[test] fn rows_put_children_under_their_parent() { let s = source( "sando", now(), vec![ node("tier:b", Status::Ok, vec!["node:prod-1"]), node("node:prod-1", Status::Ok, vec![]), ], ); let rows = s.rows(); assert_eq!(rows.len(), 2); assert_eq!(rows[0].node.id, "tier:b"); assert_eq!(rows[0].depth, 0); assert_eq!(rows[1].node.id, "node:prod-1"); assert_eq!(rows[1].depth, 1); } #[test] fn a_dangling_child_reference_is_skipped_not_fatal() { let s = source( "sando", now(), vec![node("tier:b", Status::Ok, vec!["node:ghost"])], ); assert_eq!(s.rows().len(), 1); } #[test] fn the_rollup_puts_the_worst_source_first() { let m = Model::new(vec![ source("aaa", now(), vec![node("n", Status::Ok, vec![])]), source("bbb", now(), vec![node("n", Status::Failed, vec![])]), source("ccc", now(), vec![node("n", Status::Degraded, vec![])]), ]); let order = m.rollup_order(now()); let names: Vec<&str> = order.iter().map(|&i| m.sources[i].name.as_str()).collect(); assert_eq!(names, vec!["bbb", "ccc", "aaa"]); assert_eq!(m.worst(now()), Status::Failed); } #[test] fn an_unreachable_source_outranks_a_merely_degraded_one() { let m = Model::new(vec![ source("degraded", now(), vec![node("n", Status::Degraded, vec![])]), SourceState::new("silent", TimeDelta::seconds(60)), ]); let order = m.rollup_order(now()); assert_eq!(m.sources[order[0]].name, "silent"); } #[test] fn equal_statuses_sort_by_name_so_the_order_does_not_jitter() { let m = Model::new(vec![ source("zebra", now(), vec![node("n", Status::Ok, vec![])]), source("alpha", now(), vec![node("n", Status::Ok, vec![])]), ]); let order = m.rollup_order(now()); let names: Vec<&str> = order.iter().map(|&i| m.sources[i].name.as_str()).collect(); assert_eq!(names, vec!["alpha", "zebra"]); } #[test] fn tabs_wrap_in_both_directions() { let mut m = Model::new(vec![source("a", now(), vec![]), source("b", now(), vec![])]); assert_eq!(m.tab, Tab::Rollup); m.next_tab(); assert_eq!(m.tab, Tab::Source(0)); m.next_tab(); assert_eq!(m.tab, Tab::Source(1)); m.next_tab(); assert_eq!(m.tab, Tab::Rollup); m.prev_tab(); assert_eq!(m.tab, Tab::Source(1)); } #[test] fn enter_on_the_rollup_opens_the_worst_source() { let mut m = Model::new(vec![ source("healthy", now(), vec![node("n", Status::Ok, vec![])]), source("broken", now(), vec![node("n", Status::Failed, vec![])]), ]); m.open_selected(now()); assert_eq!( m.tab, Tab::Source(1), "the first rollup row is the worst source" ); } #[test] fn selection_cannot_run_off_either_end() { let mut s = source( "sando", now(), vec![node("a", Status::Ok, vec![]), node("b", Status::Ok, vec![])], ); s.move_selection(-5); assert_eq!(s.selected, 0); s.move_selection(99); assert_eq!(s.selected, 1); } #[test] fn a_shrinking_payload_pulls_the_cursor_back_in_bounds() { // A poll that returns fewer nodes must not leave the cursor dangling. let mut s = source( "sando", now(), vec![ node("a", Status::Ok, vec![]), node("b", Status::Ok, vec![]), node("c", Status::Ok, vec![]), ], ); s.move_selection(2); assert_eq!(s.selected, 2); s.observe(payload(now(), vec![node("a", Status::Ok, vec![])]), now()); assert_eq!(s.selected, 0); assert!(s.selected_node().is_some()); } #[test] fn selection_survives_an_empty_payload() { let mut s = SourceState::new("sando", TimeDelta::seconds(60)); s.observe(payload(now(), vec![]), now()); s.move_selection(1); assert_eq!(s.selected, 0); assert!(s.selected_node().is_none()); } #[test] fn a_disabled_source_refuses_to_open_the_picker_and_says_why() { let mut n = node("tier:b", Status::Ok, vec![]); n.actions = vec!["rollback-b".into()]; let mut p = payload(now(), vec![n]); p.actions .insert("rollback-b".into(), act("Roll back", true, true)); // allow_actions defaults off. let mut s = SourceState::new("sando", TimeDelta::seconds(60)); s.observe(p, now()); let mut m = Model::new(vec![s]); m.select_tab(1); m.open_actions(); assert!( m.prompt.is_none(), "a read-only source must not open a prompt" ); assert!(m.message.as_deref().unwrap().contains("read-only")); } #[test] fn a_node_with_no_actions_does_nothing_on_enter() { let mut m = actionable(&[], vec![]); m.open_actions(); assert!(m.prompt.is_none()); assert!(m.message.is_none()); } #[test] fn the_rollup_tab_never_opens_an_action_prompt() { let mut m = actionable( &["rollback-b"], vec![("rollback-b", act("Roll back", true, true))], ); m.tab = Tab::Rollup; m.open_actions(); assert!( m.prompt.is_none(), "actions belong to a source tab, not the rollup" ); } #[test] fn a_plain_action_fires_straight_from_the_picker() { // No confirm, no danger: the picker is the whole ceremony. let mut m = actionable( &["recheck"], vec![("recheck", act("Recheck", false, false))], ); m.open_actions(); assert!(matches!(m.prompt, Some(Prompt::Pick { .. }))); let step = m.prompt_enter(); assert_eq!( step, PromptStep::Fire(FireRequest { source: 0, key: "recheck".into() }) ); assert!(m.prompt.is_none()); } #[test] fn a_confirm_action_needs_an_explicit_y_and_enter_will_not_do() { let mut m = actionable( &["promote-b"], vec![("promote-b", act("Promote", true, false))], ); m.open_actions(); assert_eq!(m.prompt_enter(), PromptStep::Idle); assert!( matches!(m.prompt, Some(Prompt::Confirm { .. })), "picker enter opens the y/n guard" ); // A stray Enter must not promote through a confirm guard. assert_eq!(m.prompt_enter(), PromptStep::Idle); assert!(matches!(m.prompt, Some(Prompt::Confirm { .. }))); // Only 'y' fires. let step = m.confirm_yes(); assert_eq!( step, PromptStep::Fire(FireRequest { source: 0, key: "promote-b".into() }) ); } #[test] fn a_danger_action_must_be_typed_out_to_fire() { let mut m = actionable( &["rollback-b"], vec![("rollback-b", act("Roll back", true, true))], ); m.open_actions(); // Picker -> Type, not a y/n: danger outranks confirm. assert_eq!(m.prompt_enter(), PromptStep::Idle); assert!(matches!(m.prompt, Some(Prompt::Type { .. }))); // A wrong key does not fire; it resets the buffer with a nudge. for c in "rollback-a".chars() { m.prompt_push(c); } assert_eq!(m.prompt_enter(), PromptStep::Idle); assert!(m.message.as_deref().unwrap().contains("exactly")); if let Some(Prompt::Type { typed, .. }) = &m.prompt { assert!(typed.is_empty(), "a mismatch clears what was typed"); } else { panic!("still in Type after a mismatch"); } // The exact key fires. for c in "rollback-b".chars() { m.prompt_push(c); } m.prompt_backspace(); m.prompt_push('b'); let step = m.prompt_enter(); assert_eq!( step, PromptStep::Fire(FireRequest { source: 0, key: "rollback-b".into() }) ); assert!(m.prompt.is_none()); } #[test] fn esc_cancels_without_firing() { let mut m = actionable( &["rollback-b"], vec![("rollback-b", act("Roll back", true, true))], ); m.open_actions(); assert_eq!(m.cancel_prompt(), PromptStep::Cancelled); assert!(m.prompt.is_none()); assert_eq!( m.cancel_prompt(), PromptStep::Idle, "nothing to cancel twice" ); } #[test] fn the_picker_moves_and_digits_jump_within_the_nodes_actions() { let mut m = actionable( &["promote-b", "rollback-b"], vec![ ("promote-b", act("Promote", true, false)), ("rollback-b", act("Roll back", true, true)), ], ); m.open_actions(); m.prompt_move(1); if let Some(Prompt::Pick { selected, .. }) = &m.prompt { assert_eq!(*selected, 1); } m.prompt_move(5); // clamps if let Some(Prompt::Pick { selected, .. }) = &m.prompt { assert_eq!(*selected, 1); } m.prompt_digit(1); if let Some(Prompt::Pick { selected, .. }) = &m.prompt { assert_eq!(*selected, 0); } m.prompt_digit(9); // out of range, ignored if let Some(Prompt::Pick { selected, .. }) = &m.prompt { assert_eq!(*selected, 0); } } #[test] fn an_action_retracted_between_pick_and_confirm_does_not_fire() { let mut m = actionable( &["rollback-b"], vec![("rollback-b", act("Roll back", true, true))], ); m.open_actions(); // A poll drops the action out from under the open picker. m.sources[0].observe( payload(now(), vec![node("tier:b", Status::Ok, vec![])]), now(), ); let step = m.prompt_enter(); assert_eq!(step, PromptStep::Cancelled); assert!(m.message.as_deref().unwrap().contains("no longer offered")); assert!(m.prompt.is_none()); } #[test] fn a_node_needing_attention_is_counted_once() { let mut n = node("tier:b", Status::Failed, vec![]); n.conditions.push(Condition { condition_type: "burn_in".into(), status: Status::Pending, since: None, detail: None, }); let s = source("sando", now(), vec![n, node("ok", Status::Ok, vec![])]); assert_eq!(s.summary(now()), "1 node needs attention"); } }