Skip to main content

max / makenotwork

20.2 KB · 664 lines History Blame Raw
1 //! Tests for [`super`].
2
3 use super::*;
4 use ops_status::{Action, Condition, Method, Node};
5 use std::collections::BTreeMap;
6
7 fn now() -> DateTime<Utc> {
8 "2026-07-21T18:00:00Z".parse().unwrap()
9 }
10
11 fn node(id: &str, status: Status, children: Vec<&str>) -> Node {
12 Node {
13 id: id.into(),
14 kind: "tier".into(),
15 label: id.into(),
16 status,
17 fields: Vec::new(),
18 conditions: Vec::new(),
19 children: children.into_iter().map(Into::into).collect(),
20 actions: Vec::new(),
21 }
22 }
23
24 fn act(label: &str, confirm: bool, danger: bool) -> Action {
25 Action {
26 label: label.into(),
27 method: Method::Post,
28 url: "/x".into(),
29 confirm,
30 danger,
31 body: None,
32 }
33 }
34
35 /// A source on a single node that declares the given actions, with actions
36 /// allowed, sitting on its own tab ready to prompt.
37 fn actionable(node_actions: &[&str], declared: Vec<(&str, Action)>) -> Model {
38 let mut n = node("tier:b", Status::Ok, vec![]);
39 n.actions = node_actions.iter().map(ToString::to_string).collect();
40 let mut p = payload(now(), vec![n]);
41 p.actions = declared
42 .into_iter()
43 .map(|(k, a)| (k.to_string(), a))
44 .collect::<BTreeMap<_, _>>();
45 let mut s = SourceState::new("sando", TimeDelta::seconds(60)).with_actions(true);
46 s.observe(p, now());
47 let mut m = Model::new(vec![s]);
48 // Row 0 is the source line, row 1 its only node. Actions hang off the
49 // node, so that is where the cursor has to be.
50 m.selected = 1;
51 m
52 }
53
54 fn event(at: DateTime<Utc>, label: &str) -> Event {
55 Event {
56 at,
57 label: label.into(),
58 status: None,
59 detail: None,
60 node_id: None,
61 }
62 }
63
64 fn payload(at: DateTime<Utc>, nodes: Vec<Node>) -> Payload {
65 let mut p = Payload::new("sando", at);
66 p.nodes = nodes;
67 p
68 }
69
70 fn source(name: &str, at: DateTime<Utc>, nodes: Vec<Node>) -> SourceState {
71 let mut s = SourceState::new(name, TimeDelta::seconds(60));
72 s.observe(payload(at, nodes), at);
73 s
74 }
75
76 #[test]
77 fn a_source_never_polled_is_unknown_not_ok() {
78 let s = SourceState::new("sando", TimeDelta::seconds(60));
79 assert_eq!(s.status(now()), Status::Unknown);
80 assert_eq!(s.summary(now()), "waiting for first poll");
81 }
82
83 #[test]
84 fn a_fresh_healthy_source_is_ok() {
85 let s = source("sando", now(), vec![node("tier:b", Status::Ok, vec![])]);
86 assert_eq!(s.status(now()), Status::Ok);
87 assert_eq!(s.summary(now()), "1 node ok");
88 }
89
90 #[test]
91 fn a_stale_but_green_source_is_degraded() {
92 // The forty-day-old backup that every check called healthy.
93 let s = source(
94 "pom",
95 now() - TimeDelta::hours(4),
96 vec![node("backup", Status::Ok, vec![])],
97 );
98 assert_eq!(s.status(now()), Status::Degraded);
99 assert!(
100 s.summary(now()).starts_with("stale:"),
101 "{}",
102 s.summary(now())
103 );
104 }
105
106 #[test]
107 fn staleness_never_downgrades_a_worse_status() {
108 let mut s = source(
109 "sando",
110 now() - TimeDelta::hours(4),
111 vec![node("tier:b", Status::Failed, vec![])],
112 );
113 assert_eq!(s.status(now()), Status::Failed);
114 s.observe_error("connection refused");
115 assert_eq!(s.status(now()), Status::Failed);
116 }
117
118 #[test]
119 fn an_unreachable_source_keeps_its_last_payload_and_says_when() {
120 let mut s = source("bento", now(), vec![node("app:x", Status::Ok, vec![])]);
121 s.observe_error("connection refused");
122 // Degraded, not Unknown: we still have a recent answer, we just could
123 // not refresh it.
124 assert_eq!(s.status(now()), Status::Degraded);
125 assert!(
126 s.payload.is_some(),
127 "the last known state must not go blank"
128 );
129 let summary = s.summary(now());
130 assert!(summary.contains("connection refused"), "{summary}");
131 assert!(summary.contains("last ok"), "{summary}");
132 }
133
134 #[test]
135 fn a_source_that_never_answered_and_then_failed_is_unknown() {
136 let mut s = SourceState::new("bento", TimeDelta::seconds(60));
137 s.observe_error("connection refused");
138 assert_eq!(s.status(now()), Status::Unknown);
139 assert!(s.summary(now()).contains("last ok never"));
140 }
141
142 #[test]
143 fn rows_put_children_under_their_parent() {
144 let s = source(
145 "sando",
146 now(),
147 vec![
148 node("tier:b", Status::Ok, vec!["node:prod-1"]),
149 node("node:prod-1", Status::Ok, vec![]),
150 ],
151 );
152 let rows = s.rows();
153 assert_eq!(rows.len(), 2);
154 assert_eq!(rows[0].node.id, "tier:b");
155 assert_eq!(rows[0].depth, 0);
156 assert_eq!(rows[1].node.id, "node:prod-1");
157 assert_eq!(rows[1].depth, 1);
158 }
159
160 #[test]
161 fn a_dangling_child_reference_is_skipped_not_fatal() {
162 let s = source(
163 "sando",
164 now(),
165 vec![node("tier:b", Status::Ok, vec!["node:ghost"])],
166 );
167 assert_eq!(s.rows().len(), 1);
168 }
169
170 #[test]
171 fn the_rollup_puts_the_worst_source_first() {
172 let m = Model::new(vec![
173 source("aaa", now(), vec![node("n", Status::Ok, vec![])]),
174 source("bbb", now(), vec![node("n", Status::Failed, vec![])]),
175 source("ccc", now(), vec![node("n", Status::Degraded, vec![])]),
176 ]);
177 let order = m.rollup_order(now());
178 let names: Vec<&str> = order.iter().map(|&i| m.sources[i].name.as_str()).collect();
179 assert_eq!(names, vec!["bbb", "ccc", "aaa"]);
180 assert_eq!(m.worst(now()), Status::Failed);
181 }
182
183 #[test]
184 fn an_unreachable_source_outranks_a_merely_degraded_one() {
185 let m = Model::new(vec![
186 source("degraded", now(), vec![node("n", Status::Degraded, vec![])]),
187 SourceState::new("silent", TimeDelta::seconds(60)),
188 ]);
189 let order = m.rollup_order(now());
190 assert_eq!(m.sources[order[0]].name, "silent");
191 }
192
193 #[test]
194 fn equal_statuses_sort_by_name_so_the_order_does_not_jitter() {
195 let m = Model::new(vec![
196 source("zebra", now(), vec![node("n", Status::Ok, vec![])]),
197 source("alpha", now(), vec![node("n", Status::Ok, vec![])]),
198 ]);
199 let order = m.rollup_order(now());
200 let names: Vec<&str> = order.iter().map(|&i| m.sources[i].name.as_str()).collect();
201 assert_eq!(names, vec!["alpha", "zebra"]);
202 }
203
204 #[test]
205 fn the_tabs_are_fixed_and_wrap_in_both_directions() {
206 // Two sources, three tabs: the count no longer follows the config,
207 // which is the whole of what this restructure changed.
208 let mut m = Model::new(vec![source("a", now(), vec![]), source("b", now(), vec![])]);
209 assert_eq!(Tab::titles(), vec!["live", "logs", "store"]);
210 assert_eq!(m.tab, Tab::Live);
211 m.next_tab();
212 assert_eq!(m.tab, Tab::Logs);
213 m.next_tab();
214 assert_eq!(m.tab, Tab::Store);
215 m.next_tab();
216 assert_eq!(m.tab, Tab::Live);
217 m.prev_tab();
218 assert_eq!(m.tab, Tab::Store);
219 }
220
221 #[test]
222 fn a_tab_out_of_range_is_ignored_rather_than_clamped() {
223 let mut m = Model::new(vec![source("a", now(), vec![])]);
224 m.select_tab(1);
225 assert_eq!(m.tab, Tab::Logs);
226 m.select_tab(9);
227 assert_eq!(m.tab, Tab::Logs, "a mistyped digit must not move the tab");
228 }
229
230 #[test]
231 fn the_live_rows_put_each_sources_nodes_under_it_worst_first() {
232 let mut parent = node("tier:b", Status::Failed, vec!["node:prod-1"]);
233 parent.children = vec!["node:prod-1".into()];
234 let m = Model::new(vec![
235 source("healthy", now(), vec![node("n", Status::Ok, vec![])]),
236 source(
237 "broken",
238 now(),
239 vec![parent, node("node:prod-1", Status::Ok, vec![])],
240 ),
241 ]);
242 let rows = m.live_rows(now());
243 // The failing source leads, then its node, then its child, then the
244 // healthy source and its node.
245 assert_eq!(rows.len(), 5);
246 assert!(matches!(rows[0], LiveRow::Source { index: 1 }));
247 assert!(matches!(rows[1], LiveRow::Node { depth: 1, .. }));
248 assert!(matches!(rows[2], LiveRow::Node { depth: 2, .. }));
249 assert!(matches!(rows[3], LiveRow::Source { index: 0 }));
250 assert_eq!(rows[1].source_index(), 1, "a node knows its own source");
251 assert!(rows[0].node().is_none(), "a source line is not a node");
252 }
253
254 #[test]
255 fn selection_cannot_run_off_either_end() {
256 let mut m = Model::new(vec![source(
257 "sando",
258 now(),
259 vec![node("a", Status::Ok, vec![]), node("b", Status::Ok, vec![])],
260 )]);
261 m.move_selection(-5, now());
262 assert_eq!(m.selected, 0);
263 m.move_selection(99, now());
264 // One source line plus two nodes.
265 assert_eq!(m.selected, 2);
266 }
267
268 #[test]
269 fn a_shrinking_payload_pulls_the_cursor_back_in_bounds() {
270 // A poll that returns fewer nodes must not leave the cursor dangling.
271 let mut m = Model::new(vec![source(
272 "sando",
273 now(),
274 vec![
275 node("a", Status::Ok, vec![]),
276 node("b", Status::Ok, vec![]),
277 node("c", Status::Ok, vec![]),
278 ],
279 )]);
280 m.move_selection(3, now());
281 assert_eq!(m.selected, 3);
282 m.sources[0].observe(payload(now(), vec![node("a", Status::Ok, vec![])]), now());
283 m.clamp_selection(now());
284 assert_eq!(m.selected, 1);
285 assert!(m.live_rows(now()).get(m.selected).is_some());
286 }
287
288 #[test]
289 fn selection_survives_an_empty_payload() {
290 let mut m = Model::new(vec![SourceState::new("sando", TimeDelta::seconds(60))]);
291 m.sources[0].observe(payload(now(), vec![]), now());
292 m.move_selection(1, now());
293 // The source line is still a row; it just has nothing under it.
294 assert_eq!(m.selected, 0);
295 assert!(m.live_rows(now())[0].node().is_none());
296 }
297
298 #[test]
299 fn the_logs_group_by_source_by_name_and_run_newest_first() {
300 let mut a = SourceState::new("zebra", TimeDelta::seconds(60));
301 let mut pz = payload(now(), vec![]);
302 pz.events = vec![event(now(), "z-old"), event(now(), "z-new")];
303 pz.events[0].at = now() - TimeDelta::minutes(5);
304 a.observe(pz, now());
305
306 let mut b = SourceState::new("alpha", TimeDelta::seconds(60));
307 let mut pa = payload(now(), vec![]);
308 pa.events = vec![event(now(), "a-only")];
309 b.observe(pa, now());
310
311 let m = Model::new(vec![a, b]);
312 let rows = m.log_rows();
313 let seen: Vec<(&str, &str)> = rows
314 .iter()
315 .map(|r| (r.source, r.event.label.as_str()))
316 .collect();
317 assert_eq!(
318 seen,
319 vec![("alpha", "a-only"), ("zebra", "z-new"), ("zebra", "z-old"),],
320 "sources in name order, events newest first within each"
321 );
322 }
323
324 fn spec(series: &str, label: &str) -> Series {
325 Series {
326 name: series.into(),
327 label: label.into(),
328 unit: Some("edges".into()),
329 }
330 }
331
332 fn reading(series: &str, labels: &str, value: f64) -> Reading {
333 Reading {
334 series: series.into(),
335 labels: labels.into(),
336 value,
337 at: now(),
338 }
339 }
340
341 #[test]
342 fn the_store_rows_follow_the_config_and_split_by_label_set() {
343 let mut store = StoreState::new(
344 "witchbroom",
345 vec![
346 spec("soak.coverage_edges", "Coverage reached"),
347 spec("cache.size_bytes", "Cache size"),
348 ],
349 );
350 store.observe(
351 vec![
352 reading("soak.coverage_edges", r#"{"repo":"a"}"#, 100.0),
353 reading("soak.coverage_edges", r#"{"repo":"b"}"#, 200.0),
354 // In the store, never named in config: must not appear.
355 reading("cache.hit_rate_pct", "{}", 90.0),
356 ],
357 now(),
358 );
359 let m = Model::new(vec![]).with_stores(vec![store]);
360 let rows = m.store_rows();
361
362 assert_eq!(rows.len(), 3, "two label sets plus the unrecorded series");
363 assert!(matches!(
364 rows[0],
365 StoreRow::Value { spec, .. } if spec.label == "Coverage reached"
366 ));
367 assert!(matches!(rows[1], StoreRow::Value { .. }));
368 // A configured series the store has nothing for is shown, not skipped.
369 assert!(matches!(
370 rows[2],
371 StoreRow::Missing { spec, .. } if spec.label == "Cache size"
372 ));
373 }
374
375 #[test]
376 fn a_series_the_config_never_named_is_not_a_row() {
377 // The ruling's accepted cost. A fallback that rendered this "just in
378 // case" is the table browser arriving by the back door.
379 let mut store = StoreState::new("witchbroom", vec![spec("named", "Named")]);
380 store.observe(vec![reading("unnamed", "{}", 1.0)], now());
381 let m = Model::new(vec![]).with_stores(vec![store]);
382 let rows = m.store_rows();
383 assert_eq!(rows.len(), 1);
384 assert!(matches!(rows[0], StoreRow::Missing { .. }));
385 }
386
387 #[test]
388 fn an_unreadable_store_says_so_above_whatever_it_last_said() {
389 let mut store = StoreState::new("witchbroom", vec![spec("s", "S")]);
390 store.observe(vec![reading("s", "{}", 41.0)], now());
391 store.observe_error("unable to open database file");
392 let m = Model::new(vec![]).with_stores(vec![store]);
393 let rows = m.store_rows();
394
395 assert!(
396 matches!(rows[0], StoreRow::Unavailable { reason, .. } if reason.contains("open")),
397 "the failure leads, so old numbers are not read as current"
398 );
399 assert!(
400 matches!(rows[1], StoreRow::Value { reading, .. } if (reading.value - 41.0).abs() < f64::EPSILON),
401 "the last known values are still there"
402 );
403 }
404
405 #[test]
406 fn no_configured_store_is_no_rows_rather_than_an_empty_one() {
407 assert!(Model::new(vec![]).store_rows().is_empty());
408 }
409
410 #[test]
411 fn the_store_cursor_cannot_run_off_either_end() {
412 let mut store = StoreState::new("witchbroom", vec![spec("a", "A"), spec("b", "B")]);
413 store.observe(vec![], now());
414 let mut m = Model::new(vec![]).with_stores(vec![store]);
415 m.tab = Tab::Store;
416 m.move_selection(99, now());
417 assert_eq!(m.store_scroll, 1);
418 m.move_selection(-99, now());
419 assert_eq!(m.store_scroll, 0);
420 }
421
422 #[test]
423 fn a_shrinking_store_pulls_its_cursor_back_in_bounds() {
424 let mut store = StoreState::new("witchbroom", vec![spec("s", "S")]);
425 store.observe(
426 vec![
427 reading("s", r#"{"repo":"a"}"#, 1.0),
428 reading("s", r#"{"repo":"b"}"#, 2.0),
429 reading("s", r#"{"repo":"c"}"#, 3.0),
430 ],
431 now(),
432 );
433 let mut m = Model::new(vec![]).with_stores(vec![store]);
434 m.tab = Tab::Store;
435 m.move_selection(2, now());
436 assert_eq!(m.store_scroll, 2);
437 m.stores[0].observe(vec![reading("s", r#"{"repo":"a"}"#, 1.0)], now());
438 m.clamp_selection(now());
439 assert_eq!(m.store_scroll, 0);
440 }
441
442 #[test]
443 fn a_source_that_never_answered_contributes_no_log_rows() {
444 let m = Model::new(vec![SourceState::new("bento", TimeDelta::seconds(60))]);
445 assert!(m.log_rows().is_empty());
446 }
447
448 #[test]
449 fn a_disabled_source_refuses_to_open_the_picker_and_says_why() {
450 let mut n = node("tier:b", Status::Ok, vec![]);
451 n.actions = vec!["rollback-b".into()];
452 let mut p = payload(now(), vec![n]);
453 p.actions
454 .insert("rollback-b".into(), act("Roll back", true, true));
455 // allow_actions defaults off.
456 let mut s = SourceState::new("sando", TimeDelta::seconds(60));
457 s.observe(p, now());
458 let mut m = Model::new(vec![s]);
459 m.selected = 1;
460
461 m.open_actions(now());
462 assert!(
463 m.prompt.is_none(),
464 "a read-only source must not open a prompt"
465 );
466 assert!(m.message.as_deref().unwrap().contains("read-only"));
467 }
468
469 #[test]
470 fn a_node_with_no_actions_does_nothing_on_enter() {
471 let mut m = actionable(&[], vec![]);
472 m.open_actions(now());
473 assert!(m.prompt.is_none());
474 assert!(m.message.is_none());
475 }
476
477 #[test]
478 fn a_source_line_never_opens_an_action_prompt() {
479 let mut m = actionable(
480 &["rollback-b"],
481 vec![("rollback-b", act("Roll back", true, true))],
482 );
483 m.selected = 0; // the source line, not its node
484 m.open_actions(now());
485 assert!(
486 m.prompt.is_none(),
487 "a source declares no actions; its nodes do"
488 );
489 }
490
491 #[test]
492 fn the_other_tabs_never_open_an_action_prompt() {
493 for tab in [Tab::Logs, Tab::Store] {
494 let mut m = actionable(
495 &["rollback-b"],
496 vec![("rollback-b", act("Roll back", true, true))],
497 );
498 m.tab = tab;
499 m.open_actions(now());
500 assert!(m.prompt.is_none(), "{tab:?} must not fire actions");
501 }
502 }
503
504 #[test]
505 fn a_plain_action_fires_straight_from_the_picker() {
506 // No confirm, no danger: the picker is the whole ceremony.
507 let mut m = actionable(
508 &["recheck"],
509 vec![("recheck", act("Recheck", false, false))],
510 );
511 m.open_actions(now());
512 assert!(matches!(m.prompt, Some(Prompt::Pick { .. })));
513 let step = m.prompt_enter();
514 assert_eq!(
515 step,
516 PromptStep::Fire(FireRequest {
517 source: 0,
518 key: "recheck".into()
519 })
520 );
521 assert!(m.prompt.is_none());
522 }
523
524 #[test]
525 fn a_confirm_action_needs_an_explicit_y_and_enter_will_not_do() {
526 let mut m = actionable(
527 &["promote-b"],
528 vec![("promote-b", act("Promote", true, false))],
529 );
530 m.open_actions(now());
531 assert_eq!(m.prompt_enter(), PromptStep::Idle);
532 assert!(
533 matches!(m.prompt, Some(Prompt::Confirm { .. })),
534 "picker enter opens the y/n guard"
535 );
536 // A stray Enter must not promote through a confirm guard.
537 assert_eq!(m.prompt_enter(), PromptStep::Idle);
538 assert!(matches!(m.prompt, Some(Prompt::Confirm { .. })));
539 // Only 'y' fires.
540 let step = m.confirm_yes();
541 assert_eq!(
542 step,
543 PromptStep::Fire(FireRequest {
544 source: 0,
545 key: "promote-b".into()
546 })
547 );
548 }
549
550 #[test]
551 fn a_danger_action_must_be_typed_out_to_fire() {
552 let mut m = actionable(
553 &["rollback-b"],
554 vec![("rollback-b", act("Roll back", true, true))],
555 );
556 m.open_actions(now());
557 // Picker -> Type, not a y/n: danger outranks confirm.
558 assert_eq!(m.prompt_enter(), PromptStep::Idle);
559 assert!(matches!(m.prompt, Some(Prompt::Type { .. })));
560
561 // A wrong key does not fire; it resets the buffer with a nudge.
562 for c in "rollback-a".chars() {
563 m.prompt_push(c);
564 }
565 assert_eq!(m.prompt_enter(), PromptStep::Idle);
566 assert!(m.message.as_deref().unwrap().contains("exactly"));
567 if let Some(Prompt::Type { typed, .. }) = &m.prompt {
568 assert!(typed.is_empty(), "a mismatch clears what was typed");
569 } else {
570 panic!("still in Type after a mismatch");
571 }
572
573 // The exact key fires.
574 for c in "rollback-b".chars() {
575 m.prompt_push(c);
576 }
577 m.prompt_backspace();
578 m.prompt_push('b');
579 let step = m.prompt_enter();
580 assert_eq!(
581 step,
582 PromptStep::Fire(FireRequest {
583 source: 0,
584 key: "rollback-b".into()
585 })
586 );
587 assert!(m.prompt.is_none());
588 }
589
590 #[test]
591 fn esc_cancels_without_firing() {
592 let mut m = actionable(
593 &["rollback-b"],
594 vec![("rollback-b", act("Roll back", true, true))],
595 );
596 m.open_actions(now());
597 assert_eq!(m.cancel_prompt(), PromptStep::Cancelled);
598 assert!(m.prompt.is_none());
599 assert_eq!(
600 m.cancel_prompt(),
601 PromptStep::Idle,
602 "nothing to cancel twice"
603 );
604 }
605
606 #[test]
607 fn the_picker_moves_and_digits_jump_within_the_nodes_actions() {
608 let mut m = actionable(
609 &["promote-b", "rollback-b"],
610 vec![
611 ("promote-b", act("Promote", true, false)),
612 ("rollback-b", act("Roll back", true, true)),
613 ],
614 );
615 m.open_actions(now());
616 m.prompt_move(1);
617 if let Some(Prompt::Pick { selected, .. }) = &m.prompt {
618 assert_eq!(*selected, 1);
619 }
620 m.prompt_move(5); // clamps
621 if let Some(Prompt::Pick { selected, .. }) = &m.prompt {
622 assert_eq!(*selected, 1);
623 }
624 m.prompt_digit(1);
625 if let Some(Prompt::Pick { selected, .. }) = &m.prompt {
626 assert_eq!(*selected, 0);
627 }
628 m.prompt_digit(9); // out of range, ignored
629 if let Some(Prompt::Pick { selected, .. }) = &m.prompt {
630 assert_eq!(*selected, 0);
631 }
632 }
633
634 #[test]
635 fn an_action_retracted_between_pick_and_confirm_does_not_fire() {
636 let mut m = actionable(
637 &["rollback-b"],
638 vec![("rollback-b", act("Roll back", true, true))],
639 );
640 m.open_actions(now());
641 // A poll drops the action out from under the open picker.
642 m.sources[0].observe(
643 payload(now(), vec![node("tier:b", Status::Ok, vec![])]),
644 now(),
645 );
646 let step = m.prompt_enter();
647 assert_eq!(step, PromptStep::Cancelled);
648 assert!(m.message.as_deref().unwrap().contains("no longer offered"));
649 assert!(m.prompt.is_none());
650 }
651
652 #[test]
653 fn a_node_needing_attention_is_counted_once() {
654 let mut n = node("tier:b", Status::Failed, vec![]);
655 n.conditions.push(Condition {
656 condition_type: "burn_in".into(),
657 status: Status::Pending,
658 since: None,
659 detail: None,
660 });
661 let s = source("sando", now(), vec![n, node("ok", Status::Ok, vec![])]);
662 assert_eq!(s.summary(now()), "1 node needs attention");
663 }
664