//! Tests for [`super`]. 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]); // Row 0 is the source line, row 1 its only node. Actions hang off the // node, so that is where the cursor has to be. m.selected = 1; m } fn event(at: DateTime, label: &str) -> Event { Event { at, label: label.into(), status: None, detail: None, node_id: None, } } 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 the_tabs_are_fixed_and_wrap_in_both_directions() { // Two sources, three tabs: the count no longer follows the config, // which is the whole of what this restructure changed. let mut m = Model::new(vec![source("a", now(), vec![]), source("b", now(), vec![])]); assert_eq!(Tab::titles(), vec!["live", "logs", "store"]); assert_eq!(m.tab, Tab::Live); m.next_tab(); assert_eq!(m.tab, Tab::Logs); m.next_tab(); assert_eq!(m.tab, Tab::Store); m.next_tab(); assert_eq!(m.tab, Tab::Live); m.prev_tab(); assert_eq!(m.tab, Tab::Store); } #[test] fn a_tab_out_of_range_is_ignored_rather_than_clamped() { let mut m = Model::new(vec![source("a", now(), vec![])]); m.select_tab(1); assert_eq!(m.tab, Tab::Logs); m.select_tab(9); assert_eq!(m.tab, Tab::Logs, "a mistyped digit must not move the tab"); } #[test] fn the_live_rows_put_each_sources_nodes_under_it_worst_first() { let mut parent = node("tier:b", Status::Failed, vec!["node:prod-1"]); parent.children = vec!["node:prod-1".into()]; let m = Model::new(vec![ source("healthy", now(), vec![node("n", Status::Ok, vec![])]), source( "broken", now(), vec![parent, node("node:prod-1", Status::Ok, vec![])], ), ]); let rows = m.live_rows(now()); // The failing source leads, then its node, then its child, then the // healthy source and its node. assert_eq!(rows.len(), 5); assert!(matches!(rows[0], LiveRow::Source { index: 1 })); assert!(matches!(rows[1], LiveRow::Node { depth: 1, .. })); assert!(matches!(rows[2], LiveRow::Node { depth: 2, .. })); assert!(matches!(rows[3], LiveRow::Source { index: 0 })); assert_eq!(rows[1].source_index(), 1, "a node knows its own source"); assert!(rows[0].node().is_none(), "a source line is not a node"); } #[test] fn selection_cannot_run_off_either_end() { let mut m = Model::new(vec![source( "sando", now(), vec![node("a", Status::Ok, vec![]), node("b", Status::Ok, vec![])], )]); m.move_selection(-5, now()); assert_eq!(m.selected, 0); m.move_selection(99, now()); // One source line plus two nodes. assert_eq!(m.selected, 2); } #[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 m = Model::new(vec![source( "sando", now(), vec![ node("a", Status::Ok, vec![]), node("b", Status::Ok, vec![]), node("c", Status::Ok, vec![]), ], )]); m.move_selection(3, now()); assert_eq!(m.selected, 3); m.sources[0].observe(payload(now(), vec![node("a", Status::Ok, vec![])]), now()); m.clamp_selection(now()); assert_eq!(m.selected, 1); assert!(m.live_rows(now()).get(m.selected).is_some()); } #[test] fn selection_survives_an_empty_payload() { let mut m = Model::new(vec![SourceState::new("sando", TimeDelta::seconds(60))]); m.sources[0].observe(payload(now(), vec![]), now()); m.move_selection(1, now()); // The source line is still a row; it just has nothing under it. assert_eq!(m.selected, 0); assert!(m.live_rows(now())[0].node().is_none()); } #[test] fn the_logs_group_by_source_by_name_and_run_newest_first() { let mut a = SourceState::new("zebra", TimeDelta::seconds(60)); let mut pz = payload(now(), vec![]); pz.events = vec![event(now(), "z-old"), event(now(), "z-new")]; pz.events[0].at = now() - TimeDelta::minutes(5); a.observe(pz, now()); let mut b = SourceState::new("alpha", TimeDelta::seconds(60)); let mut pa = payload(now(), vec![]); pa.events = vec![event(now(), "a-only")]; b.observe(pa, now()); let m = Model::new(vec![a, b]); let rows = m.log_rows(); let seen: Vec<(&str, &str)> = rows .iter() .map(|r| (r.source, r.event.label.as_str())) .collect(); assert_eq!( seen, vec![("alpha", "a-only"), ("zebra", "z-new"), ("zebra", "z-old"),], "sources in name order, events newest first within each" ); } fn spec(series: &str, label: &str) -> Series { Series { name: series.into(), label: label.into(), unit: Some("edges".into()), } } fn reading(series: &str, labels: &str, value: f64) -> Reading { Reading { series: series.into(), labels: labels.into(), value, at: now(), } } #[test] fn the_store_rows_follow_the_config_and_split_by_label_set() { let mut store = StoreState::new( "witchbroom", vec![ spec("soak.coverage_edges", "Coverage reached"), spec("cache.size_bytes", "Cache size"), ], ); store.observe( vec![ reading("soak.coverage_edges", r#"{"repo":"a"}"#, 100.0), reading("soak.coverage_edges", r#"{"repo":"b"}"#, 200.0), // In the store, never named in config: must not appear. reading("cache.hit_rate_pct", "{}", 90.0), ], now(), ); let m = Model::new(vec![]).with_stores(vec![store]); let rows = m.store_rows(); assert_eq!(rows.len(), 3, "two label sets plus the unrecorded series"); assert!(matches!( rows[0], StoreRow::Value { spec, .. } if spec.label == "Coverage reached" )); assert!(matches!(rows[1], StoreRow::Value { .. })); // A configured series the store has nothing for is shown, not skipped. assert!(matches!( rows[2], StoreRow::Missing { spec, .. } if spec.label == "Cache size" )); } #[test] fn a_series_the_config_never_named_is_not_a_row() { // The ruling's accepted cost. A fallback that rendered this "just in // case" is the table browser arriving by the back door. let mut store = StoreState::new("witchbroom", vec![spec("named", "Named")]); store.observe(vec![reading("unnamed", "{}", 1.0)], now()); let m = Model::new(vec![]).with_stores(vec![store]); let rows = m.store_rows(); assert_eq!(rows.len(), 1); assert!(matches!(rows[0], StoreRow::Missing { .. })); } #[test] fn an_unreadable_store_says_so_above_whatever_it_last_said() { let mut store = StoreState::new("witchbroom", vec![spec("s", "S")]); store.observe(vec![reading("s", "{}", 41.0)], now()); store.observe_error("unable to open database file"); let m = Model::new(vec![]).with_stores(vec![store]); let rows = m.store_rows(); assert!( matches!(rows[0], StoreRow::Unavailable { reason, .. } if reason.contains("open")), "the failure leads, so old numbers are not read as current" ); assert!( matches!(rows[1], StoreRow::Value { reading, .. } if (reading.value - 41.0).abs() < f64::EPSILON), "the last known values are still there" ); } #[test] fn no_configured_store_is_no_rows_rather_than_an_empty_one() { assert!(Model::new(vec![]).store_rows().is_empty()); } #[test] fn the_store_cursor_cannot_run_off_either_end() { let mut store = StoreState::new("witchbroom", vec![spec("a", "A"), spec("b", "B")]); store.observe(vec![], now()); let mut m = Model::new(vec![]).with_stores(vec![store]); m.tab = Tab::Store; m.move_selection(99, now()); assert_eq!(m.store_scroll, 1); m.move_selection(-99, now()); assert_eq!(m.store_scroll, 0); } #[test] fn a_shrinking_store_pulls_its_cursor_back_in_bounds() { let mut store = StoreState::new("witchbroom", vec![spec("s", "S")]); store.observe( vec![ reading("s", r#"{"repo":"a"}"#, 1.0), reading("s", r#"{"repo":"b"}"#, 2.0), reading("s", r#"{"repo":"c"}"#, 3.0), ], now(), ); let mut m = Model::new(vec![]).with_stores(vec![store]); m.tab = Tab::Store; m.move_selection(2, now()); assert_eq!(m.store_scroll, 2); m.stores[0].observe(vec![reading("s", r#"{"repo":"a"}"#, 1.0)], now()); m.clamp_selection(now()); assert_eq!(m.store_scroll, 0); } #[test] fn a_source_that_never_answered_contributes_no_log_rows() { let m = Model::new(vec![SourceState::new("bento", TimeDelta::seconds(60))]); assert!(m.log_rows().is_empty()); } #[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.selected = 1; m.open_actions(now()); 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(now()); assert!(m.prompt.is_none()); assert!(m.message.is_none()); } #[test] fn a_source_line_never_opens_an_action_prompt() { let mut m = actionable( &["rollback-b"], vec![("rollback-b", act("Roll back", true, true))], ); m.selected = 0; // the source line, not its node m.open_actions(now()); assert!( m.prompt.is_none(), "a source declares no actions; its nodes do" ); } #[test] fn the_other_tabs_never_open_an_action_prompt() { for tab in [Tab::Logs, Tab::Store] { let mut m = actionable( &["rollback-b"], vec![("rollback-b", act("Roll back", true, true))], ); m.tab = tab; m.open_actions(now()); assert!(m.prompt.is_none(), "{tab:?} must not fire actions"); } } #[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(now()); 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(now()); 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(now()); // 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(now()); 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(now()); 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(now()); // 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"); }