Skip to main content

max / makenotwork

32.1 KB · 966 lines History Blame Raw
1 //! What the viewer knows, and the rollup derived from it.
2 //!
3 //! The model holds one [`SourceState`] per configured source and nothing about
4 //! what any of them mean. Everything the rollup shows is computed from the
5 //! payloads alone, which is what keeps the shell from acquiring domain
6 //! knowledge one convenience at a time.
7
8 use chrono::{DateTime, TimeDelta, Utc};
9 use ops_status::{Action, Node, Payload, Status};
10
11 /// One source, as last heard from.
12 pub(crate) struct SourceState {
13 pub name: String,
14 /// Whether this source's declared actions may be fired. Off by default; set
15 /// from the config's per-source `allow_actions`. Kept on the state rather
16 /// than threaded through key handling so the model stays self-contained and
17 /// testable.
18 pub allow_actions: bool,
19 /// The last payload that parsed. Kept across a failed poll so the screen
20 /// shows the last known state alongside the fact that it is now stale,
21 /// rather than going blank.
22 pub payload: Option<Payload>,
23 /// Why the last poll failed, if it did.
24 pub error: Option<String>,
25 /// When a poll last succeeded.
26 pub last_ok: Option<DateTime<Utc>>,
27 /// Age past which this source's answer stops counting as current.
28 pub stale_after: TimeDelta,
29 /// Row selection within this source's tab.
30 pub selected: usize,
31 }
32
33 impl SourceState {
34 pub(crate) fn new(name: impl Into<String>, stale_after: TimeDelta) -> Self {
35 SourceState {
36 name: name.into(),
37 allow_actions: false,
38 payload: None,
39 error: None,
40 last_ok: None,
41 stale_after,
42 selected: 0,
43 }
44 }
45
46 /// Let this source's declared actions be fired. Builder-style so tests and
47 /// `main` set it without a wider constructor.
48 pub(crate) fn with_actions(mut self, allow: bool) -> Self {
49 self.allow_actions = allow;
50 self
51 }
52
53 /// A declared action by key, from the current payload. `None` if the source
54 /// has not answered or no longer offers it — the latter matters because a
55 /// poll between opening a prompt and confirming can retract an action.
56 pub(crate) fn action(&self, key: &str) -> Option<&Action> {
57 self.payload.as_ref().and_then(|p| p.actions.get(key))
58 }
59
60 /// How old the current payload is, if there is one.
61 pub(crate) fn age(&self, now: DateTime<Utc>) -> Option<TimeDelta> {
62 self.payload.as_ref().map(|p| p.age(now))
63 }
64
65 pub(crate) fn is_stale(&self, now: DateTime<Utc>) -> bool {
66 self.age(now).is_some_and(|age| age > self.stale_after)
67 }
68
69 /// This source's line in the rollup.
70 ///
71 /// Three things can be wrong and all three are visible here:
72 ///
73 /// - it cannot be reached at all (`unknown`)
74 /// - it answers, but with something old (`degraded` at minimum, however
75 /// green its contents)
76 /// - it answers freshly and reports trouble (whatever it reports)
77 ///
78 /// The middle case is the one that is normally missed. A backup check that
79 /// answers "ok" about a snapshot taken forty days ago is not ok, and every
80 /// check that existed said it was.
81 pub(crate) fn status(&self, now: DateTime<Utc>) -> Status {
82 let Some(payload) = &self.payload else {
83 return Status::Unknown;
84 };
85 if self.error.is_some() || self.is_stale(now) {
86 return payload.worst_status().max(Status::Degraded);
87 }
88 payload.worst_status()
89 }
90
91 /// A short phrase for why this source reads the way it does.
92 pub(crate) fn summary(&self, now: DateTime<Utc>) -> String {
93 if let Some(error) = &self.error {
94 let last = match self.last_ok {
95 Some(at) => crate::value::relative(at, now),
96 None => "never".into(),
97 };
98 return format!("unreachable ({error}); last ok {last}");
99 }
100 let Some(payload) = &self.payload else {
101 return "waiting for first poll".into();
102 };
103 if self.is_stale(now) {
104 return format!(
105 "stale: last answered {}",
106 crate::value::duration(payload.age(now).num_seconds())
107 );
108 }
109
110 let failing = payload
111 .nodes
112 .iter()
113 .filter(|n| n.status >= Status::Degraded)
114 .count();
115 match (failing, payload.nodes.len()) {
116 (0, 1) => "1 node ok".into(),
117 (0, total) => format!("{total} nodes ok"),
118 (1, _) => "1 node needs attention".into(),
119 (n, _) => format!("{n} nodes need attention"),
120 }
121 }
122
123 /// Nodes in display order: each root followed by its children.
124 ///
125 /// Children are referenced by id rather than nested, so this is where the
126 /// flat list becomes a tree. Depth stops at one: the contract allows deeper
127 /// nesting but nothing emits it, and an unbounded recursion over
128 /// producer-supplied ids is a denial-of-service waiting to happen.
129 pub(crate) fn rows(&self) -> Vec<Row<'_>> {
130 let Some(payload) = &self.payload else {
131 return Vec::new();
132 };
133 let mut rows = Vec::new();
134 for root in payload.roots() {
135 rows.push(Row {
136 node: root,
137 depth: 0,
138 });
139 for child_id in &root.children {
140 if let Some(child) = payload.node(child_id) {
141 rows.push(Row {
142 node: child,
143 depth: 1,
144 });
145 }
146 }
147 }
148 rows
149 }
150
151 /// The node the cursor is on.
152 pub(crate) fn selected_node(&self) -> Option<&Node> {
153 let rows = self.rows();
154 rows.get(self.selected.min(rows.len().saturating_sub(1)))
155 .map(|r| r.node)
156 }
157
158 pub(crate) fn move_selection(&mut self, delta: isize) {
159 let len = self.rows().len();
160 if len == 0 {
161 self.selected = 0;
162 return;
163 }
164 let next = self.selected as isize + delta;
165 self.selected = next.clamp(0, len as isize - 1) as usize;
166 }
167
168 /// Record a successful poll.
169 pub(crate) fn observe(&mut self, payload: Payload, at: DateTime<Utc>) {
170 self.payload = Some(payload);
171 self.error = None;
172 self.last_ok = Some(at);
173 // A payload with fewer nodes than before must not leave the cursor
174 // pointing past the end.
175 let len = self.rows().len();
176 if self.selected >= len {
177 self.selected = len.saturating_sub(1);
178 }
179 }
180
181 /// Record a failed poll, keeping the last known payload.
182 pub(crate) fn observe_error(&mut self, error: impl Into<String>) {
183 self.error = Some(error.into());
184 }
185 }
186
187 /// One line in a source tab.
188 pub(crate) struct Row<'a> {
189 pub node: &'a Node,
190 pub depth: usize,
191 }
192
193 /// Which tab is showing.
194 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
195 pub(crate) enum Tab {
196 /// Every source at once, worst first. The tab that earns the product.
197 Rollup,
198 Source(usize),
199 }
200
201 /// A modal step between "I want to run this" and the request going out.
202 ///
203 /// Firing a declared action can move production, so the path to it is explicit
204 /// and never a single keystroke: pick which action, then clear its guard. The
205 /// guard's weight is set by the action itself — a `danger` action is confirmed
206 /// by typing its key, a `confirm` one by a `y`, a plain one not at all.
207 #[derive(Debug, Clone, PartialEq, Eq)]
208 pub(crate) enum Prompt {
209 /// Choosing which of the selected node's actions to run.
210 Pick {
211 source: usize,
212 keys: Vec<String>,
213 selected: usize,
214 },
215 /// A `y`/`n` guard for a `confirm` action that is not `danger`.
216 Confirm { source: usize, key: String },
217 /// The heaviest guard: type the action key to fire a `danger` action. Muscle
218 /// memory cannot type `rollback-b`, which is the point.
219 Type {
220 source: usize,
221 key: String,
222 typed: String,
223 },
224 }
225
226 /// A confirmed request the run loop is to issue. The model records it and stops
227 /// there; resolving the source's URL and token and making the HTTP call is the
228 /// loop's job, which keeps every network effect out of the model.
229 #[derive(Debug, Clone, PartialEq, Eq)]
230 pub(crate) struct FireRequest {
231 pub source: usize,
232 pub key: String,
233 }
234
235 /// What a keypress asked the model to do once the prompt resolved.
236 #[derive(Debug, PartialEq, Eq)]
237 pub(crate) enum PromptStep {
238 /// Still in a prompt (or none was open); nothing to dispatch.
239 Idle,
240 /// The prompt closed with a confirmed request to fire.
241 Fire(FireRequest),
242 /// The prompt closed without firing.
243 Cancelled,
244 }
245
246 pub(crate) struct Model {
247 pub sources: Vec<SourceState>,
248 pub tab: Tab,
249 /// Cursor within the rollup tab.
250 pub rollup_selected: usize,
251 /// Transient message shown in the footer.
252 pub message: Option<String>,
253 /// The open modal, if any.
254 pub prompt: Option<Prompt>,
255 }
256
257 impl Model {
258 pub(crate) fn new(sources: Vec<SourceState>) -> Self {
259 Model {
260 sources,
261 tab: Tab::Rollup,
262 rollup_selected: 0,
263 message: None,
264 prompt: None,
265 }
266 }
267
268 // -- Actions -----------------------------------------------------------
269
270 /// Enter on a source node: open the action picker, or explain why not.
271 ///
272 /// A node with no actions does nothing. A source with actions disabled says
273 /// so rather than silently ignoring the key, because a viewer that looks
274 /// like it should be able to act and does not is worse than one that says it
275 /// cannot.
276 pub(crate) fn open_actions(&mut self) {
277 let Tab::Source(i) = self.tab else { return };
278 let Some(source) = self.sources.get(i) else {
279 return;
280 };
281 let Some(node) = source.selected_node() else {
282 return;
283 };
284 if node.actions.is_empty() {
285 return;
286 }
287 if !source.allow_actions {
288 self.message = Some(format!(
289 "{}: actions are read-only here (set allow_actions to enable)",
290 source.name
291 ));
292 return;
293 }
294 self.prompt = Some(Prompt::Pick {
295 source: i,
296 keys: node.actions.clone(),
297 selected: 0,
298 });
299 }
300
301 /// Move the cursor inside an open picker. No-op for the other prompts.
302 pub(crate) fn prompt_move(&mut self, delta: isize) {
303 if let Some(Prompt::Pick { keys, selected, .. }) = &mut self.prompt {
304 if keys.is_empty() {
305 return;
306 }
307 let next = *selected as isize + delta;
308 *selected = next.clamp(0, keys.len() as isize - 1) as usize;
309 }
310 }
311
312 /// A digit inside a picker jumps straight to that action (1-based).
313 pub(crate) fn prompt_digit(&mut self, n: usize) {
314 if let Some(Prompt::Pick { keys, selected, .. }) = &mut self.prompt
315 && (1..=keys.len()).contains(&n)
316 {
317 *selected = n - 1;
318 }
319 }
320
321 /// A printable character while typing a `danger` action's key.
322 pub(crate) fn prompt_push(&mut self, c: char) {
323 if let Some(Prompt::Type { typed, .. }) = &mut self.prompt {
324 typed.push(c);
325 }
326 }
327
328 /// Backspace while typing.
329 pub(crate) fn prompt_backspace(&mut self) {
330 if let Some(Prompt::Type { typed, .. }) = &mut self.prompt {
331 typed.pop();
332 }
333 }
334
335 /// Enter: advance the picker into a guard, or clear a `Type` guard.
336 ///
337 /// - On a picker, resolves the chosen action and either opens its guard
338 /// (`Type` for danger, `Confirm` for confirm) or fires it outright.
339 /// - On a `Type` guard, fires only when the typed text matches the key.
340 /// - `Confirm` does not respond to Enter; it wants an explicit `y`
341 /// ([`confirm_yes`]), so a stray Enter cannot promote through it.
342 pub(crate) fn prompt_enter(&mut self) -> PromptStep {
343 match self.prompt.take() {
344 Some(Prompt::Pick {
345 source,
346 keys,
347 selected,
348 }) => {
349 let Some(key) = keys.get(selected).cloned() else {
350 return PromptStep::Cancelled;
351 };
352 let Some(action) = self.sources.get(source).and_then(|s| s.action(&key)) else {
353 self.message = Some(format!("{key}: no longer offered"));
354 return PromptStep::Cancelled;
355 };
356 if action.danger {
357 self.prompt = Some(Prompt::Type {
358 source,
359 key,
360 typed: String::new(),
361 });
362 PromptStep::Idle
363 } else if action.confirm {
364 self.prompt = Some(Prompt::Confirm { source, key });
365 PromptStep::Idle
366 } else {
367 self.fire(source, key)
368 }
369 }
370 Some(Prompt::Type { source, key, typed }) => {
371 if typed == key {
372 self.fire(source, key)
373 } else {
374 self.message = Some(format!("type '{key}' exactly to confirm"));
375 self.prompt = Some(Prompt::Type {
376 source,
377 key,
378 typed: String::new(),
379 });
380 PromptStep::Idle
381 }
382 }
383 other => {
384 self.prompt = other;
385 PromptStep::Idle
386 }
387 }
388 }
389
390 /// `y` on a `Confirm` guard fires; anywhere else it is nothing.
391 pub(crate) fn confirm_yes(&mut self) -> PromptStep {
392 if let Some(Prompt::Confirm { source, key }) = self.prompt.take() {
393 self.fire(source, key)
394 } else {
395 PromptStep::Idle
396 }
397 }
398
399 /// Close any open prompt without firing.
400 pub(crate) fn cancel_prompt(&mut self) -> PromptStep {
401 if self.prompt.take().is_some() {
402 PromptStep::Cancelled
403 } else {
404 PromptStep::Idle
405 }
406 }
407
408 /// Record a confirmed request and clear the prompt. Guards checked the
409 /// action still existed, so this only assembles the request; the loop makes
410 /// the call.
411 fn fire(&mut self, source: usize, key: String) -> PromptStep {
412 self.prompt = None;
413 PromptStep::Fire(FireRequest { source, key })
414 }
415
416 /// Source indices ordered worst-first, then by name.
417 ///
418 /// Worst-first is the whole argument for the rollup existing. Sorted any
419 /// other way it is a list you still have to read all of, which is the
420 /// situation it replaces.
421 pub(crate) fn rollup_order(&self, now: DateTime<Utc>) -> Vec<usize> {
422 let mut order: Vec<usize> = (0..self.sources.len()).collect();
423 order.sort_by(|&a, &b| {
424 let (sa, sb) = (self.sources[a].status(now), self.sources[b].status(now));
425 sb.cmp(&sa)
426 .then_with(|| self.sources[a].name.cmp(&self.sources[b].name))
427 });
428 order
429 }
430
431 /// The worst status across every source: the one thing to look at first.
432 pub(crate) fn worst(&self, now: DateTime<Utc>) -> Status {
433 self.sources
434 .iter()
435 .map(|s| s.status(now))
436 .max()
437 .unwrap_or(Status::Unknown)
438 }
439
440 pub(crate) fn tab_titles(&self) -> Vec<String> {
441 let mut titles = vec!["rollup".to_string()];
442 titles.extend(self.sources.iter().map(|s| s.name.clone()));
443 titles
444 }
445
446 pub(crate) fn tab_index(&self) -> usize {
447 match self.tab {
448 Tab::Rollup => 0,
449 Tab::Source(i) => i + 1,
450 }
451 }
452
453 pub(crate) fn select_tab(&mut self, index: usize) {
454 self.tab = match index {
455 0 => Tab::Rollup,
456 n if n <= self.sources.len() => Tab::Source(n - 1),
457 _ => self.tab,
458 };
459 }
460
461 pub(crate) fn next_tab(&mut self) {
462 let next = (self.tab_index() + 1) % (self.sources.len() + 1);
463 self.select_tab(next);
464 }
465
466 pub(crate) fn prev_tab(&mut self) {
467 let count = self.sources.len() + 1;
468 let next = (self.tab_index() + count - 1) % count;
469 self.select_tab(next);
470 }
471
472 /// Move the cursor in whichever tab is showing.
473 pub(crate) fn move_selection(&mut self, delta: isize, now: DateTime<Utc>) {
474 match self.tab {
475 Tab::Rollup => {
476 let len = self.rollup_order(now).len();
477 if len == 0 {
478 return;
479 }
480 let next = self.rollup_selected as isize + delta;
481 self.rollup_selected = next.clamp(0, len as isize - 1) as usize;
482 }
483 Tab::Source(i) => {
484 if let Some(source) = self.sources.get_mut(i) {
485 source.move_selection(delta);
486 }
487 }
488 }
489 }
490
491 /// Enter on a rollup row opens that source's tab.
492 pub(crate) fn open_selected(&mut self, now: DateTime<Utc>) {
493 if self.tab == Tab::Rollup {
494 let order = self.rollup_order(now);
495 if let Some(&index) = order.get(self.rollup_selected) {
496 self.tab = Tab::Source(index);
497 }
498 }
499 }
500 }
501
502 #[cfg(test)]
503 mod tests {
504 use super::*;
505 use ops_status::{Action, Condition, Method, Node};
506 use std::collections::BTreeMap;
507
508 fn now() -> DateTime<Utc> {
509 "2026-07-21T18:00:00Z".parse().unwrap()
510 }
511
512 fn node(id: &str, status: Status, children: Vec<&str>) -> Node {
513 Node {
514 id: id.into(),
515 kind: "tier".into(),
516 label: id.into(),
517 status,
518 fields: Vec::new(),
519 conditions: Vec::new(),
520 children: children.into_iter().map(Into::into).collect(),
521 actions: Vec::new(),
522 }
523 }
524
525 fn act(label: &str, confirm: bool, danger: bool) -> Action {
526 Action {
527 label: label.into(),
528 method: Method::Post,
529 url: "/x".into(),
530 confirm,
531 danger,
532 body: None,
533 }
534 }
535
536 /// A source on a single node that declares the given actions, with actions
537 /// allowed, sitting on its own tab ready to prompt.
538 fn actionable(node_actions: &[&str], declared: Vec<(&str, Action)>) -> Model {
539 let mut n = node("tier:b", Status::Ok, vec![]);
540 n.actions = node_actions.iter().map(ToString::to_string).collect();
541 let mut p = payload(now(), vec![n]);
542 p.actions = declared
543 .into_iter()
544 .map(|(k, a)| (k.to_string(), a))
545 .collect::<BTreeMap<_, _>>();
546 let mut s = SourceState::new("sando", TimeDelta::seconds(60)).with_actions(true);
547 s.observe(p, now());
548 let mut m = Model::new(vec![s]);
549 m.select_tab(1);
550 m
551 }
552
553 fn payload(at: DateTime<Utc>, nodes: Vec<Node>) -> Payload {
554 let mut p = Payload::new("sando", at);
555 p.nodes = nodes;
556 p
557 }
558
559 fn source(name: &str, at: DateTime<Utc>, nodes: Vec<Node>) -> SourceState {
560 let mut s = SourceState::new(name, TimeDelta::seconds(60));
561 s.observe(payload(at, nodes), at);
562 s
563 }
564
565 #[test]
566 fn a_source_never_polled_is_unknown_not_ok() {
567 let s = SourceState::new("sando", TimeDelta::seconds(60));
568 assert_eq!(s.status(now()), Status::Unknown);
569 assert_eq!(s.summary(now()), "waiting for first poll");
570 }
571
572 #[test]
573 fn a_fresh_healthy_source_is_ok() {
574 let s = source("sando", now(), vec![node("tier:b", Status::Ok, vec![])]);
575 assert_eq!(s.status(now()), Status::Ok);
576 assert_eq!(s.summary(now()), "1 node ok");
577 }
578
579 #[test]
580 fn a_stale_but_green_source_is_degraded() {
581 // The forty-day-old backup that every check called healthy.
582 let s = source(
583 "pom",
584 now() - TimeDelta::hours(4),
585 vec![node("backup", Status::Ok, vec![])],
586 );
587 assert_eq!(s.status(now()), Status::Degraded);
588 assert!(
589 s.summary(now()).starts_with("stale:"),
590 "{}",
591 s.summary(now())
592 );
593 }
594
595 #[test]
596 fn staleness_never_downgrades_a_worse_status() {
597 let mut s = source(
598 "sando",
599 now() - TimeDelta::hours(4),
600 vec![node("tier:b", Status::Failed, vec![])],
601 );
602 assert_eq!(s.status(now()), Status::Failed);
603 s.observe_error("connection refused");
604 assert_eq!(s.status(now()), Status::Failed);
605 }
606
607 #[test]
608 fn an_unreachable_source_keeps_its_last_payload_and_says_when() {
609 let mut s = source("bento", now(), vec![node("app:x", Status::Ok, vec![])]);
610 s.observe_error("connection refused");
611 // Degraded, not Unknown: we still have a recent answer, we just could
612 // not refresh it.
613 assert_eq!(s.status(now()), Status::Degraded);
614 assert!(
615 s.payload.is_some(),
616 "the last known state must not go blank"
617 );
618 let summary = s.summary(now());
619 assert!(summary.contains("connection refused"), "{summary}");
620 assert!(summary.contains("last ok"), "{summary}");
621 }
622
623 #[test]
624 fn a_source_that_never_answered_and_then_failed_is_unknown() {
625 let mut s = SourceState::new("bento", TimeDelta::seconds(60));
626 s.observe_error("connection refused");
627 assert_eq!(s.status(now()), Status::Unknown);
628 assert!(s.summary(now()).contains("last ok never"));
629 }
630
631 #[test]
632 fn rows_put_children_under_their_parent() {
633 let s = source(
634 "sando",
635 now(),
636 vec![
637 node("tier:b", Status::Ok, vec!["node:prod-1"]),
638 node("node:prod-1", Status::Ok, vec![]),
639 ],
640 );
641 let rows = s.rows();
642 assert_eq!(rows.len(), 2);
643 assert_eq!(rows[0].node.id, "tier:b");
644 assert_eq!(rows[0].depth, 0);
645 assert_eq!(rows[1].node.id, "node:prod-1");
646 assert_eq!(rows[1].depth, 1);
647 }
648
649 #[test]
650 fn a_dangling_child_reference_is_skipped_not_fatal() {
651 let s = source(
652 "sando",
653 now(),
654 vec![node("tier:b", Status::Ok, vec!["node:ghost"])],
655 );
656 assert_eq!(s.rows().len(), 1);
657 }
658
659 #[test]
660 fn the_rollup_puts_the_worst_source_first() {
661 let m = Model::new(vec![
662 source("aaa", now(), vec![node("n", Status::Ok, vec![])]),
663 source("bbb", now(), vec![node("n", Status::Failed, vec![])]),
664 source("ccc", now(), vec![node("n", Status::Degraded, vec![])]),
665 ]);
666 let order = m.rollup_order(now());
667 let names: Vec<&str> = order.iter().map(|&i| m.sources[i].name.as_str()).collect();
668 assert_eq!(names, vec!["bbb", "ccc", "aaa"]);
669 assert_eq!(m.worst(now()), Status::Failed);
670 }
671
672 #[test]
673 fn an_unreachable_source_outranks_a_merely_degraded_one() {
674 let m = Model::new(vec![
675 source("degraded", now(), vec![node("n", Status::Degraded, vec![])]),
676 SourceState::new("silent", TimeDelta::seconds(60)),
677 ]);
678 let order = m.rollup_order(now());
679 assert_eq!(m.sources[order[0]].name, "silent");
680 }
681
682 #[test]
683 fn equal_statuses_sort_by_name_so_the_order_does_not_jitter() {
684 let m = Model::new(vec![
685 source("zebra", now(), vec![node("n", Status::Ok, vec![])]),
686 source("alpha", now(), vec![node("n", Status::Ok, vec![])]),
687 ]);
688 let order = m.rollup_order(now());
689 let names: Vec<&str> = order.iter().map(|&i| m.sources[i].name.as_str()).collect();
690 assert_eq!(names, vec!["alpha", "zebra"]);
691 }
692
693 #[test]
694 fn tabs_wrap_in_both_directions() {
695 let mut m = Model::new(vec![source("a", now(), vec![]), source("b", now(), vec![])]);
696 assert_eq!(m.tab, Tab::Rollup);
697 m.next_tab();
698 assert_eq!(m.tab, Tab::Source(0));
699 m.next_tab();
700 assert_eq!(m.tab, Tab::Source(1));
701 m.next_tab();
702 assert_eq!(m.tab, Tab::Rollup);
703 m.prev_tab();
704 assert_eq!(m.tab, Tab::Source(1));
705 }
706
707 #[test]
708 fn enter_on_the_rollup_opens_the_worst_source() {
709 let mut m = Model::new(vec![
710 source("healthy", now(), vec![node("n", Status::Ok, vec![])]),
711 source("broken", now(), vec![node("n", Status::Failed, vec![])]),
712 ]);
713 m.open_selected(now());
714 assert_eq!(
715 m.tab,
716 Tab::Source(1),
717 "the first rollup row is the worst source"
718 );
719 }
720
721 #[test]
722 fn selection_cannot_run_off_either_end() {
723 let mut s = source(
724 "sando",
725 now(),
726 vec![node("a", Status::Ok, vec![]), node("b", Status::Ok, vec![])],
727 );
728 s.move_selection(-5);
729 assert_eq!(s.selected, 0);
730 s.move_selection(99);
731 assert_eq!(s.selected, 1);
732 }
733
734 #[test]
735 fn a_shrinking_payload_pulls_the_cursor_back_in_bounds() {
736 // A poll that returns fewer nodes must not leave the cursor dangling.
737 let mut s = source(
738 "sando",
739 now(),
740 vec![
741 node("a", Status::Ok, vec![]),
742 node("b", Status::Ok, vec![]),
743 node("c", Status::Ok, vec![]),
744 ],
745 );
746 s.move_selection(2);
747 assert_eq!(s.selected, 2);
748 s.observe(payload(now(), vec![node("a", Status::Ok, vec![])]), now());
749 assert_eq!(s.selected, 0);
750 assert!(s.selected_node().is_some());
751 }
752
753 #[test]
754 fn selection_survives_an_empty_payload() {
755 let mut s = SourceState::new("sando", TimeDelta::seconds(60));
756 s.observe(payload(now(), vec![]), now());
757 s.move_selection(1);
758 assert_eq!(s.selected, 0);
759 assert!(s.selected_node().is_none());
760 }
761
762 #[test]
763 fn a_disabled_source_refuses_to_open_the_picker_and_says_why() {
764 let mut n = node("tier:b", Status::Ok, vec![]);
765 n.actions = vec!["rollback-b".into()];
766 let mut p = payload(now(), vec![n]);
767 p.actions
768 .insert("rollback-b".into(), act("Roll back", true, true));
769 // allow_actions defaults off.
770 let mut s = SourceState::new("sando", TimeDelta::seconds(60));
771 s.observe(p, now());
772 let mut m = Model::new(vec![s]);
773 m.select_tab(1);
774
775 m.open_actions();
776 assert!(
777 m.prompt.is_none(),
778 "a read-only source must not open a prompt"
779 );
780 assert!(m.message.as_deref().unwrap().contains("read-only"));
781 }
782
783 #[test]
784 fn a_node_with_no_actions_does_nothing_on_enter() {
785 let mut m = actionable(&[], vec![]);
786 m.open_actions();
787 assert!(m.prompt.is_none());
788 assert!(m.message.is_none());
789 }
790
791 #[test]
792 fn the_rollup_tab_never_opens_an_action_prompt() {
793 let mut m = actionable(
794 &["rollback-b"],
795 vec![("rollback-b", act("Roll back", true, true))],
796 );
797 m.tab = Tab::Rollup;
798 m.open_actions();
799 assert!(
800 m.prompt.is_none(),
801 "actions belong to a source tab, not the rollup"
802 );
803 }
804
805 #[test]
806 fn a_plain_action_fires_straight_from_the_picker() {
807 // No confirm, no danger: the picker is the whole ceremony.
808 let mut m = actionable(
809 &["recheck"],
810 vec![("recheck", act("Recheck", false, false))],
811 );
812 m.open_actions();
813 assert!(matches!(m.prompt, Some(Prompt::Pick { .. })));
814 let step = m.prompt_enter();
815 assert_eq!(
816 step,
817 PromptStep::Fire(FireRequest {
818 source: 0,
819 key: "recheck".into()
820 })
821 );
822 assert!(m.prompt.is_none());
823 }
824
825 #[test]
826 fn a_confirm_action_needs_an_explicit_y_and_enter_will_not_do() {
827 let mut m = actionable(
828 &["promote-b"],
829 vec![("promote-b", act("Promote", true, false))],
830 );
831 m.open_actions();
832 assert_eq!(m.prompt_enter(), PromptStep::Idle);
833 assert!(
834 matches!(m.prompt, Some(Prompt::Confirm { .. })),
835 "picker enter opens the y/n guard"
836 );
837 // A stray Enter must not promote through a confirm guard.
838 assert_eq!(m.prompt_enter(), PromptStep::Idle);
839 assert!(matches!(m.prompt, Some(Prompt::Confirm { .. })));
840 // Only 'y' fires.
841 let step = m.confirm_yes();
842 assert_eq!(
843 step,
844 PromptStep::Fire(FireRequest {
845 source: 0,
846 key: "promote-b".into()
847 })
848 );
849 }
850
851 #[test]
852 fn a_danger_action_must_be_typed_out_to_fire() {
853 let mut m = actionable(
854 &["rollback-b"],
855 vec![("rollback-b", act("Roll back", true, true))],
856 );
857 m.open_actions();
858 // Picker -> Type, not a y/n: danger outranks confirm.
859 assert_eq!(m.prompt_enter(), PromptStep::Idle);
860 assert!(matches!(m.prompt, Some(Prompt::Type { .. })));
861
862 // A wrong key does not fire; it resets the buffer with a nudge.
863 for c in "rollback-a".chars() {
864 m.prompt_push(c);
865 }
866 assert_eq!(m.prompt_enter(), PromptStep::Idle);
867 assert!(m.message.as_deref().unwrap().contains("exactly"));
868 if let Some(Prompt::Type { typed, .. }) = &m.prompt {
869 assert!(typed.is_empty(), "a mismatch clears what was typed");
870 } else {
871 panic!("still in Type after a mismatch");
872 }
873
874 // The exact key fires.
875 for c in "rollback-b".chars() {
876 m.prompt_push(c);
877 }
878 m.prompt_backspace();
879 m.prompt_push('b');
880 let step = m.prompt_enter();
881 assert_eq!(
882 step,
883 PromptStep::Fire(FireRequest {
884 source: 0,
885 key: "rollback-b".into()
886 })
887 );
888 assert!(m.prompt.is_none());
889 }
890
891 #[test]
892 fn esc_cancels_without_firing() {
893 let mut m = actionable(
894 &["rollback-b"],
895 vec![("rollback-b", act("Roll back", true, true))],
896 );
897 m.open_actions();
898 assert_eq!(m.cancel_prompt(), PromptStep::Cancelled);
899 assert!(m.prompt.is_none());
900 assert_eq!(
901 m.cancel_prompt(),
902 PromptStep::Idle,
903 "nothing to cancel twice"
904 );
905 }
906
907 #[test]
908 fn the_picker_moves_and_digits_jump_within_the_nodes_actions() {
909 let mut m = actionable(
910 &["promote-b", "rollback-b"],
911 vec![
912 ("promote-b", act("Promote", true, false)),
913 ("rollback-b", act("Roll back", true, true)),
914 ],
915 );
916 m.open_actions();
917 m.prompt_move(1);
918 if let Some(Prompt::Pick { selected, .. }) = &m.prompt {
919 assert_eq!(*selected, 1);
920 }
921 m.prompt_move(5); // clamps
922 if let Some(Prompt::Pick { selected, .. }) = &m.prompt {
923 assert_eq!(*selected, 1);
924 }
925 m.prompt_digit(1);
926 if let Some(Prompt::Pick { selected, .. }) = &m.prompt {
927 assert_eq!(*selected, 0);
928 }
929 m.prompt_digit(9); // out of range, ignored
930 if let Some(Prompt::Pick { selected, .. }) = &m.prompt {
931 assert_eq!(*selected, 0);
932 }
933 }
934
935 #[test]
936 fn an_action_retracted_between_pick_and_confirm_does_not_fire() {
937 let mut m = actionable(
938 &["rollback-b"],
939 vec![("rollback-b", act("Roll back", true, true))],
940 );
941 m.open_actions();
942 // A poll drops the action out from under the open picker.
943 m.sources[0].observe(
944 payload(now(), vec![node("tier:b", Status::Ok, vec![])]),
945 now(),
946 );
947 let step = m.prompt_enter();
948 assert_eq!(step, PromptStep::Cancelled);
949 assert!(m.message.as_deref().unwrap().contains("no longer offered"));
950 assert!(m.prompt.is_none());
951 }
952
953 #[test]
954 fn a_node_needing_attention_is_counted_once() {
955 let mut n = node("tier:b", Status::Failed, vec![]);
956 n.conditions.push(Condition {
957 condition_type: "burn_in".into(),
958 status: Status::Pending,
959 since: None,
960 detail: None,
961 });
962 let s = source("sando", now(), vec![n, node("ok", Status::Ok, vec![])]);
963 assert_eq!(s.summary(now()), "1 node needs attention");
964 }
965 }
966