Skip to main content

scope_cli/cli/tui/
wizard.rs

1//! The monitor wizard (#47): builds or edits a watchlist in four steps.
2//!
3//! 1. **Targets:** `a` adds one (`pair USDC binance`, `token PEPE solana`,
4//!    `address 0x… ethereum`), `d` deletes the selected one.
5//! 2. **Metrics:** for the selected target, the market health preset and
6//!    the catalog metrics of its kind. Space turns one on or off, `o`
7//!    changes the operator, Enter edits the value.
8//! 3. **Timing:** the interval and the duration, with the number of
9//!    fetches they cost.
10//! 4. **Outputs:** the name, the CSV and the report paths; Save and Start.
11//!
12//! Tab and Shift-Tab move between the steps; Ctrl-S saves and Ctrl-R
13//! starts from any step. The state is pure; [`draw`] draws it.
14
15use crate::cli::theme::{ACCENT, ERR, KEY, OK, SECONDARY, SELECTED, WARN};
16use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
17use ratatui::Frame;
18use ratatui::layout::Rect;
19use ratatui::style::Modifier;
20use ratatui::text::{Line, Span};
21use ratatui::widgets::{Block, Borders, Paragraph};
22use scope::market::HealthThresholds;
23use scope::metrics::{MetricGroup, MetricId, Threshold, describe};
24use scope::watch::time::{format_duration, is_until_cancelled, parse_duration};
25use scope::watch::{Op, Preset, Rule, Target, TargetKind, Watchlist};
26use std::path::{Path, PathBuf};
27use std::time::Duration;
28
29/// The wizard's steps, in order.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Step {
32    Targets,
33    Metrics,
34    Timing,
35    Outputs,
36}
37
38impl Step {
39    const ALL: [Step; 4] = [Step::Targets, Step::Metrics, Step::Timing, Step::Outputs];
40
41    fn name(self) -> &'static str {
42        match self {
43            Step::Targets => "Targets",
44            Step::Metrics => "Metrics",
45            Step::Timing => "Timing",
46            Step::Outputs => "Outputs",
47        }
48    }
49
50    fn index(self) -> usize {
51        Step::ALL.iter().position(|s| *s == self).unwrap_or(0)
52    }
53}
54
55/// The line being typed, and what it is for.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum EditKind {
58    AddTarget,
59    /// The value of the rule on this metric of the selected target.
60    Value(MetricId),
61    Interval,
62    Duration,
63    Name,
64    Csv,
65    Report,
66}
67
68/// What a wizard key asks the TUI to do.
69#[derive(Debug, Clone, PartialEq)]
70pub enum WizardAction {
71    None,
72    /// Close the wizard.
73    Leave,
74    /// Start this watchlist in the grid.
75    Start(Box<Watchlist>),
76    /// The watchlist was saved to this file.
77    Saved(PathBuf),
78}
79
80/// The rows of the Outputs step.
81const OUTPUT_ROWS: [&str; 5] = ["Name", "CSV", "Report", "Save", "Start"];
82
83/// The key legend of each step.
84fn legend(step: Step) -> &'static str {
85    match step {
86        Step::Targets => "a add · d delete · ↑↓ select · Tab next step · Ctrl-R start · Esc leave",
87        Step::Metrics => {
88            "Space on/off · o operator · Enter value · ←→ target · Tab next · Ctrl-R start"
89        }
90        Step::Timing | Step::Outputs => {
91            "↑↓ select · Enter edit · Ctrl-S save · Ctrl-R start · Tab next · Esc leave"
92        }
93    }
94}
95
96/// The wizard's state.
97#[derive(Debug)]
98pub struct WizardState {
99    pub list: Watchlist,
100    /// Notes from the arguments (flags that do nothing, dropped alerts).
101    pub notes: Vec<String>,
102    pub step: Step,
103    /// The selected target (Targets and Metrics).
104    pub target: usize,
105    /// The selected row of the Metrics, Timing or Outputs step.
106    pub row: usize,
107    /// The line being typed.
108    pub edit: Option<(EditKind, String)>,
109    /// The venue IDs a target may name.
110    pub venues: Vec<String>,
111    /// Where Save writes.
112    pub dir: PathBuf,
113    /// Something changed since the last save.
114    pub dirty: bool,
115    /// Esc asked "Leave without saving?".
116    pub confirm_leave: bool,
117    /// The last problem, until the next key.
118    pub error: Option<String>,
119}
120
121impl WizardState {
122    /// A wizard over `list`. `dir` holds the saved watchlists.
123    pub fn new(list: Watchlist, notes: Vec<String>, venues: Vec<String>, dir: PathBuf) -> Self {
124        // A list from arguments already has its targets: show their metrics.
125        let step = if list.targets.is_empty() {
126            Step::Targets
127        } else {
128            Step::Metrics
129        };
130        Self {
131            list,
132            notes,
133            step,
134            target: 0,
135            row: 0,
136            edit: None,
137            venues,
138            dir,
139            dirty: false,
140            confirm_leave: false,
141            error: None,
142        }
143    }
144
145    /// Applies one key.
146    pub fn key(&mut self, key: KeyEvent) -> WizardAction {
147        self.error = None;
148        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
149        if self.confirm_leave {
150            self.confirm_leave = false;
151            return match key.code {
152                KeyCode::Char('y' | 'Y') => WizardAction::Leave,
153                _ => WizardAction::None,
154            };
155        }
156        if let Some((kind, mut text)) = self.edit.take() {
157            match key.code {
158                KeyCode::Esc => {}
159                KeyCode::Enter => self.finish_edit(kind, text),
160                KeyCode::Backspace => {
161                    text.pop();
162                    self.edit = Some((kind, text));
163                }
164                KeyCode::Char(c) if !ctrl => {
165                    text.push(c);
166                    self.edit = Some((kind, text));
167                }
168                _ => self.edit = Some((kind, text)),
169            }
170            return WizardAction::None;
171        }
172        match key.code {
173            KeyCode::Char('s') if ctrl => return self.save(),
174            KeyCode::Char('r') if ctrl => return self.start(),
175            KeyCode::Esc => {
176                if self.dirty {
177                    self.confirm_leave = true;
178                    return WizardAction::None;
179                }
180                return WizardAction::Leave;
181            }
182            KeyCode::Tab => self.go(self.step.index() + 1),
183            KeyCode::BackTab => self.go(self.step.index() + Step::ALL.len() - 1),
184            _ => {
185                return match self.step {
186                    Step::Targets => self.targets_key(key),
187                    Step::Metrics => self.metrics_key(key),
188                    Step::Timing => self.timing_key(key),
189                    Step::Outputs => self.outputs_key(key),
190                };
191            }
192        }
193        WizardAction::None
194    }
195
196    fn go(&mut self, index: usize) {
197        self.step = Step::ALL[index % Step::ALL.len()];
198        self.row = 0;
199    }
200
201    fn targets_key(&mut self, key: KeyEvent) -> WizardAction {
202        let n = self.list.targets.len();
203        match key.code {
204            KeyCode::Char('a') => self.edit = Some((EditKind::AddTarget, String::new())),
205            KeyCode::Char('d') if n > 0 => {
206                self.list.targets.remove(self.target.min(n - 1));
207                self.target = self.target.min(n.saturating_sub(2));
208                self.dirty = true;
209            }
210            KeyCode::Down if n > 0 => self.target = (self.target + 1) % n,
211            KeyCode::Up if n > 0 => self.target = (self.target + n - 1) % n,
212            KeyCode::Enter if n > 0 => self.go(Step::Metrics.index()),
213            _ => {}
214        }
215        WizardAction::None
216    }
217
218    /// The Metrics rows of the selected target: the preset (when the kind
219    /// takes market metrics), then the kind's metrics.
220    pub fn metric_rows(&self) -> Vec<Option<MetricId>> {
221        let Some(t) = self.list.targets.get(self.target) else {
222            return Vec::new();
223        };
224        let mut rows = Vec::new();
225        if t.kind.accepts(MetricGroup::Market) {
226            rows.push(None);
227        }
228        rows.extend(
229            t.kind
230                .metrics()
231                .into_iter()
232                .filter(|m| *m != MetricId::Healthy)
233                .map(Some),
234        );
235        rows
236    }
237
238    fn metrics_key(&mut self, key: KeyEvent) -> WizardAction {
239        let rows = self.metric_rows();
240        let n = rows.len();
241        let targets = self.list.targets.len();
242        match key.code {
243            KeyCode::Down if n > 0 => self.row = (self.row + 1) % n,
244            KeyCode::Up if n > 0 => self.row = (self.row + n - 1) % n,
245            KeyCode::Right if targets > 0 => {
246                self.target = (self.target + 1) % targets;
247                self.row = 0;
248            }
249            KeyCode::Left if targets > 0 => {
250                self.target = (self.target + targets - 1) % targets;
251                self.row = 0;
252            }
253            KeyCode::Char(' ') if n > 0 => self.toggle(rows[self.row.min(n - 1)]),
254            KeyCode::Char('o') if n > 0 => {
255                if let Some(m) = rows[self.row.min(n - 1)]
256                    && let Some(rule) = self.rule_mut(m)
257                {
258                    rule.op = next_op(rule.op);
259                    self.dirty = true;
260                }
261            }
262            KeyCode::Enter if n > 0 => {
263                if let Some(m) = rows[self.row.min(n - 1)]
264                    && let Some(rule) = self.rule_mut(m)
265                {
266                    self.edit = Some((EditKind::Value(m), rule.value.to_string()));
267                }
268            }
269            _ => {}
270        }
271        WizardAction::None
272    }
273
274    fn rule_mut(&mut self, metric: MetricId) -> Option<&mut Rule> {
275        self.list
276            .targets
277            .get_mut(self.target)?
278            .rules
279            .iter_mut()
280            .find(|r| r.metric == metric)
281    }
282
283    /// Turns the preset or a metric's rule on or off.
284    fn toggle(&mut self, row: Option<MetricId>) {
285        let Some(t) = self.list.targets.get_mut(self.target) else {
286            return;
287        };
288        let needs_venue = row.is_none_or(|m| m.group() == MetricGroup::Market);
289        match row {
290            None if t.preset.is_some() => t.preset = None,
291            Some(m) if t.rules.iter().any(|r| r.metric == m) => t.rules.retain(|r| r.metric != m),
292            _ if needs_venue && t.venue.is_none() => {
293                self.error = Some(format!(
294                    "{} has no venue: market metrics need one (add the target as `pair {} binance`).",
295                    t.id, t.id
296                ));
297                return;
298            }
299            None => t.preset = Some(Preset::MarketHealth),
300            Some(m) => t.rules.push(default_rule(m)),
301        }
302        self.dirty = true;
303    }
304
305    fn timing_key(&mut self, key: KeyEvent) -> WizardAction {
306        match key.code {
307            KeyCode::Down | KeyCode::Up => self.row = 1 - self.row.min(1),
308            KeyCode::Enter => {
309                let (kind, text) = if self.row == 0 {
310                    (
311                        EditKind::Interval,
312                        format_duration(self.list.interval.as_secs()),
313                    )
314                } else {
315                    (
316                        EditKind::Duration,
317                        self.list.duration.map_or_else(
318                            || "forever".to_string(),
319                            |d| format_duration(d.as_secs()),
320                        ),
321                    )
322                };
323                self.edit = Some((kind, text));
324            }
325            _ => {}
326        }
327        WizardAction::None
328    }
329
330    fn outputs_key(&mut self, key: KeyEvent) -> WizardAction {
331        let n = OUTPUT_ROWS.len();
332        match key.code {
333            KeyCode::Down => self.row = (self.row + 1) % n,
334            KeyCode::Up => self.row = (self.row + n - 1) % n,
335            KeyCode::Enter => match self.row {
336                0 => self.edit = Some((EditKind::Name, self.list.name.clone())),
337                1 => self.edit = Some((EditKind::Csv, path_text(&self.list.outputs.csv))),
338                2 => self.edit = Some((EditKind::Report, path_text(&self.list.outputs.report))),
339                3 => return self.save(),
340                _ => return self.start(),
341            },
342            _ => {}
343        }
344        WizardAction::None
345    }
346
347    /// Applies a typed line.
348    fn finish_edit(&mut self, kind: EditKind, text: String) {
349        let text = text.trim().to_string();
350        let result = match kind {
351            EditKind::AddTarget => self.add_target(&text),
352            EditKind::Value(m) => match text.replace('_', "").parse::<f64>() {
353                Ok(v) if v.is_finite() => {
354                    if let Some(rule) = self.rule_mut(m) {
355                        rule.value = v;
356                    }
357                    Ok(())
358                }
359                _ => Err(format!("`{text}` is not a number")),
360            },
361            EditKind::Interval => parse_duration(&text).map(|s| {
362                self.list.interval = Duration::from_secs(s);
363            }),
364            EditKind::Duration => {
365                if is_until_cancelled(&text) {
366                    self.list.duration = None;
367                    Ok(())
368                } else {
369                    parse_duration(&text).map(|s| self.list.duration = Some(Duration::from_secs(s)))
370                }
371            }
372            EditKind::Name => {
373                if text.is_empty() || text.contains(['/', '\\', ':']) {
374                    Err("a name has no / \\ or : and is not empty".to_string())
375                } else {
376                    self.list.name = text;
377                    Ok(())
378                }
379            }
380            EditKind::Csv => {
381                self.list.outputs.csv = (!text.is_empty()).then(|| PathBuf::from(&text));
382                Ok(())
383            }
384            EditKind::Report => {
385                self.list.outputs.report = (!text.is_empty()).then(|| PathBuf::from(&text));
386                Ok(())
387            }
388        };
389        match result {
390            Ok(()) => self.dirty = true,
391            Err(e) => self.error = Some(e),
392        }
393    }
394
395    /// Adds `kind id [venue|chain]`.
396    fn add_target(&mut self, text: &str) -> Result<(), String> {
397        const USAGE: &str =
398            "type `pair USDC binance`, `token PEPE [chain|venue]` or `address 0x… [chain]`";
399        let words: Vec<&str> = text.split_whitespace().collect();
400        let (kind, id, extra) = match words.as_slice() {
401            [kind, id] => (*kind, *id, None),
402            [kind, id, extra] => (*kind, *id, Some(*extra)),
403            _ => return Err(USAGE.to_string()),
404        };
405        let kind = match kind.to_ascii_lowercase().as_str() {
406            "pair" => TargetKind::Pair,
407            "token" => TargetKind::Token,
408            "address" => TargetKind::Address,
409            _ => return Err(USAGE.to_string()),
410        };
411        let mut t = Target::new(kind, id);
412        if let Some(extra) = extra {
413            let is_venue = self.venues.iter().any(|v| v.eq_ignore_ascii_case(extra));
414            match (kind, is_venue) {
415                (TargetKind::Address, _) | (TargetKind::Token, false) => {
416                    t.chain = Some(extra.to_string())
417                }
418                (_, true) => t.venue = Some(extra.to_string()),
419                (TargetKind::Pair, false) => {
420                    return Err(format!(
421                        "unknown venue `{extra}`; the venues are {}",
422                        self.venues.join(", ")
423                    ));
424                }
425            }
426        }
427        if kind == TargetKind::Pair && t.venue.is_none() {
428            return Err("a pair needs a venue: `pair USDC binance`".to_string());
429        }
430        if t.venue.is_some() {
431            t.preset = Some(Preset::MarketHealth);
432        }
433        self.list.targets.push(t);
434        self.target = self.list.targets.len() - 1;
435        Ok(())
436    }
437
438    /// The problem that stops a save or a start, if any.
439    pub fn problem(&self) -> Option<String> {
440        if self.list.targets.is_empty() {
441            return Some("Add a target first (Targets step, `a`).".to_string());
442        }
443        self.list
444            .targets
445            .iter()
446            .find(|t| t.preset.is_none() && t.rules.is_empty())
447            .map(|t| {
448                format!(
449                    "{} has nothing to check: turn on a metric or the preset (Metrics step).",
450                    t.label()
451                )
452            })
453    }
454
455    /// The file Save writes.
456    pub fn path(&self) -> PathBuf {
457        scope::watch::file::path_in(&self.dir, &self.list.name)
458    }
459
460    fn save(&mut self) -> WizardAction {
461        if let Some(p) = self.problem() {
462            self.error = Some(p);
463            return WizardAction::None;
464        }
465        let path = self.path();
466        match scope::watch::file::save(&path, &self.list) {
467            Ok(()) => {
468                self.dirty = false;
469                WizardAction::Saved(path)
470            }
471            Err(e) => {
472                self.error = Some(format!("Could not save {}: {e}", path.display()));
473                WizardAction::None
474            }
475        }
476    }
477
478    fn start(&mut self) -> WizardAction {
479        if let Some(p) = self.problem() {
480            self.error = Some(p);
481            return WizardAction::None;
482        }
483        WizardAction::Start(Box::new(self.list.clone()))
484    }
485}
486
487/// The rule a metric starts with: its threshold from the defaults, or
488/// `> 0` for a metric with none.
489fn default_rule(metric: MetricId) -> Rule {
490    let (op, value) = match describe(metric, &HealthThresholds::default()).threshold {
491        Some(Threshold::Max(v)) => (Op::Le, v),
492        Some(Threshold::Min(v)) => (Op::Ge, v),
493        Some(Threshold::Target(_)) | None => (Op::Gt, 0.0),
494    };
495    Rule { metric, op, value }
496}
497
498fn next_op(op: Op) -> Op {
499    match op {
500        Op::Lt => Op::Le,
501        Op::Le => Op::Gt,
502        Op::Gt => Op::Ge,
503        Op::Ge => Op::Eq,
504        Op::Eq => Op::Ne,
505        Op::Ne => Op::Lt,
506    }
507}
508
509fn path_text(p: &Option<PathBuf>) -> String {
510    p.as_deref()
511        .map(Path::display)
512        .map(|d| d.to_string())
513        .unwrap_or_default()
514}
515
516/// A row: the marker, then the text, selected or not.
517fn row(selected: bool, text: String) -> Line<'static> {
518    if selected {
519        Line::from(vec![Span::styled("> ", KEY), Span::styled(text, SELECTED)])
520    } else {
521        Line::from(vec![Span::raw("  "), Span::raw(text)])
522    }
523}
524
525fn target_text(t: &Target) -> String {
526    let mut s = format!("{:<8} {}", t.kind.name(), t.id);
527    if let Some(v) = &t.venue {
528        s.push_str(&format!(" @{v}"));
529    }
530    if let Some(c) = &t.chain {
531        s.push_str(&format!(" on {c}"));
532    }
533    if t.preset.is_some() {
534        s.push_str("  [market health]");
535    }
536    if !t.rules.is_empty() {
537        s.push_str(&format!("  {} rule(s)", t.rules.len()));
538    }
539    s
540}
541
542/// Draws the wizard in `area`.
543pub fn draw(f: &mut Frame, area: Rect, w: &WizardState) {
544    let block = Block::new()
545        .borders(Borders::TOP | Borders::BOTTOM)
546        .border_style(ACCENT)
547        .title(Line::styled(
548            format!(" monitor wizard · {} ", w.list.name),
549            ACCENT.add_modifier(Modifier::BOLD),
550        ));
551    let inner = block.inner(area);
552    f.render_widget(block, area);
553    let mut lines: Vec<Line> = Vec::new();
554    let mut tabs = vec![Span::raw(" ")];
555    for s in Step::ALL {
556        let style = if s == w.step {
557            SELECTED.add_modifier(Modifier::BOLD)
558        } else {
559            SECONDARY
560        };
561        tabs.push(Span::styled(
562            format!(" {} {} ", s.index() + 1, s.name()),
563            style,
564        ));
565    }
566    lines.push(Line::from(tabs));
567    for n in &w.notes {
568        lines.push(Line::styled(format!(" {n}"), WARN));
569    }
570    lines.push(Line::raw(""));
571    match w.step {
572        Step::Targets => {
573            if w.list.targets.is_empty() {
574                lines.push(Line::styled(
575                    " No targets yet: press a to add one.",
576                    SECONDARY,
577                ));
578            }
579            for (i, t) in w.list.targets.iter().enumerate() {
580                lines.push(row(i == w.target, target_text(t)));
581            }
582        }
583        Step::Metrics => match w.list.targets.get(w.target) {
584            None => lines.push(Line::styled(" Add a target first.", SECONDARY)),
585            Some(t) => {
586                lines.push(Line::styled(
587                    format!(
588                        " {} ({}/{})  ←→ other target",
589                        t.label(),
590                        w.target + 1,
591                        w.list.targets.len()
592                    ),
593                    ACCENT,
594                ));
595                for (i, r) in w.metric_rows().iter().enumerate() {
596                    let text = match r {
597                        None => format!(
598                            "[{}] market health (the checks of market summary)",
599                            if t.preset.is_some() { "x" } else { " " }
600                        ),
601                        Some(m) => match t.rules.iter().find(|x| x.metric == *m) {
602                            Some(rule) => format!("[x] {rule}"),
603                            None => format!("[ ] {}", m.name()),
604                        },
605                    };
606                    lines.push(row(i == w.row, text));
607                }
608            }
609        },
610        Step::Timing => {
611            let duration = w.list.duration.map_or_else(
612                || "until stopped".to_string(),
613                |d| format_duration(d.as_secs()),
614            );
615            lines.push(row(
616                w.row == 0,
617                format!("Interval  {}", format_duration(w.list.interval.as_secs())),
618            ));
619            lines.push(row(w.row == 1, format!("Duration  {duration}")));
620            let per = w.list.fetches_per_tick();
621            let total = w.list.ticks().map_or_else(
622                || "until stopped".to_string(),
623                |t| format!("{} in all", t * per as u64),
624            );
625            lines.push(Line::raw(""));
626            lines.push(Line::styled(
627                format!(" {per} fetch(es) per run · {total}"),
628                SECONDARY,
629            ));
630        }
631        Step::Outputs => {
632            let values = [
633                w.list.name.clone(),
634                path_text(&w.list.outputs.csv),
635                path_text(&w.list.outputs.report),
636                format!("to {}", w.path().display()),
637                "the grid".to_string(),
638            ];
639            for (i, (label, value)) in OUTPUT_ROWS.iter().zip(values).enumerate() {
640                lines.push(row(i == w.row, format!("{label:<7} {value}")));
641            }
642        }
643    }
644    lines.push(Line::raw(""));
645    if let Some((kind, text)) = &w.edit {
646        let label = match kind {
647            EditKind::AddTarget => "Add".to_string(),
648            EditKind::Value(m) => format!("{} value", m.name()),
649            EditKind::Interval => "Interval".to_string(),
650            EditKind::Duration => "Duration".to_string(),
651            EditKind::Name => "Name".to_string(),
652            EditKind::Csv => "CSV".to_string(),
653            EditKind::Report => "Report".to_string(),
654        };
655        lines.push(Line::from(vec![
656            Span::styled(format!(" {label}: "), KEY),
657            Span::raw(text.clone()),
658            Span::styled("█", ACCENT),
659        ]));
660        if *kind == EditKind::AddTarget {
661            lines.push(Line::styled(
662                "   pair USDC binance · token PEPE solana · address 0x… ethereum · Enter adds · Esc cancels",
663                SECONDARY,
664            ));
665        }
666    }
667    if w.confirm_leave {
668        lines.push(Line::styled(
669            " Leave without saving? [y/N]",
670            WARN.add_modifier(Modifier::BOLD),
671        ));
672    }
673    if let Some(e) = &w.error {
674        lines.push(Line::styled(format!(" {e}"), ERR));
675    } else if w.problem().is_none() && !w.dirty {
676        lines.push(Line::styled(" Ready: Ctrl-R starts the run.", OK));
677    }
678    // The legend on the last row.
679    let body = Rect {
680        height: inner.height.saturating_sub(1),
681        ..inner
682    };
683    let first = lines.len().saturating_sub(body.height as usize);
684    f.render_widget(
685        Paragraph::new(lines[first.min(lines.len())..].to_vec()),
686        body,
687    );
688    if inner.height > 0 {
689        let last = Rect::new(inner.x, inner.bottom() - 1, inner.width, 1);
690        f.render_widget(
691            Paragraph::new(Line::styled(format!(" {}", legend(w.step)), KEY)),
692            last,
693        );
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use ratatui::Terminal;
701    use ratatui::backend::TestBackend;
702
703    fn key(code: KeyCode) -> KeyEvent {
704        KeyEvent::new(code, KeyModifiers::NONE)
705    }
706
707    fn ctrl(c: char) -> KeyEvent {
708        KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
709    }
710
711    fn typed(w: &mut WizardState, text: &str) {
712        for c in text.chars() {
713            w.key(key(KeyCode::Char(c)));
714        }
715        w.key(key(KeyCode::Enter));
716    }
717
718    fn wizard(dir: &Path) -> WizardState {
719        WizardState::new(
720            Watchlist::new("w"),
721            Vec::new(),
722            vec!["binance".into(), "weex".into()],
723            dir.to_path_buf(),
724        )
725    }
726
727    fn screen(w: &WizardState, cols: u16, rows: u16) -> String {
728        let mut t = Terminal::new(TestBackend::new(cols, rows)).unwrap();
729        t.draw(|f| draw(f, f.area(), w)).unwrap();
730        let buf = t.backend().buffer().clone();
731        let mut s = String::new();
732        for y in 0..rows {
733            for x in 0..cols {
734                s.push_str(buf[(x, y)].symbol());
735            }
736            s.push('\n');
737        }
738        s
739    }
740
741    #[test]
742    fn add_targets_with_venue_or_chain_and_delete() {
743        let dir = tempfile::tempdir().unwrap();
744        let mut w = wizard(dir.path());
745        w.key(key(KeyCode::Char('a')));
746        typed(&mut w, "pair USDC binance");
747        let t = &w.list.targets[0];
748        assert_eq!(
749            (t.kind, t.venue.as_deref(), t.preset),
750            (
751                TargetKind::Pair,
752                Some("binance"),
753                Some(Preset::MarketHealth)
754            )
755        );
756        w.key(key(KeyCode::Char('a')));
757        typed(&mut w, "token PEPE solana");
758        assert_eq!(w.list.targets[1].chain.as_deref(), Some("solana"));
759        assert_eq!(w.list.targets[1].preset, None);
760        w.key(key(KeyCode::Char('a')));
761        typed(&mut w, "pair DAI nowhere");
762        assert!(
763            w.error
764                .as_deref()
765                .unwrap()
766                .contains("unknown venue `nowhere`")
767        );
768        w.key(key(KeyCode::Char('a')));
769        typed(&mut w, "pair DAI");
770        assert!(w.error.as_deref().unwrap().contains("needs a venue"));
771        assert_eq!(w.list.targets.len(), 2);
772        w.key(key(KeyCode::Char('d')));
773        assert_eq!(w.list.targets.len(), 1);
774    }
775
776    #[test]
777    fn metrics_toggle_change_op_and_value() {
778        let dir = tempfile::tempdir().unwrap();
779        let mut w = wizard(dir.path());
780        w.key(key(KeyCode::Char('a')));
781        typed(&mut w, "token PEPE");
782        w.key(key(KeyCode::Tab));
783        assert_eq!(w.step, Step::Metrics);
784        // Row 0 is the preset: a token with no venue cannot have it.
785        w.key(key(KeyCode::Char(' ')));
786        assert!(w.error.as_deref().unwrap().contains("no venue"));
787        let rows = w.metric_rows();
788        let holders = rows
789            .iter()
790            .position(|r| *r == Some(MetricId::Holders))
791            .unwrap();
792        w.row = holders;
793        w.key(key(KeyCode::Char(' ')));
794        assert_eq!(w.list.targets[0].rules[0].to_string(), "holders > 0");
795        w.key(key(KeyCode::Char('o')));
796        assert_eq!(w.list.targets[0].rules[0].op, Op::Ge);
797        w.key(key(KeyCode::Enter));
798        w.key(key(KeyCode::Backspace));
799        typed(&mut w, "1000");
800        assert_eq!(w.list.targets[0].rules[0].to_string(), "holders >= 1000");
801        w.key(key(KeyCode::Char(' ')));
802        assert!(w.list.targets[0].rules.is_empty());
803    }
804
805    #[test]
806    fn market_metrics_start_from_the_health_thresholds() {
807        assert_eq!(default_rule(MetricId::SpreadPct).op, Op::Le);
808        assert_eq!(
809            default_rule(MetricId::SpreadPct).value,
810            HealthThresholds::default().max_spread_pct
811        );
812        assert_eq!(default_rule(MetricId::BidDepth).op, Op::Ge);
813    }
814
815    #[test]
816    fn timing_edits_parse_and_bad_values_say_why() {
817        let dir = tempfile::tempdir().unwrap();
818        let mut w = wizard(dir.path());
819        w.step = Step::Timing;
820        w.key(key(KeyCode::Enter));
821        for _ in 0..5 {
822            w.key(key(KeyCode::Backspace));
823        }
824        typed(&mut w, "0s");
825        assert!(w.error.is_some());
826        assert_eq!(w.list.interval, Duration::from_secs(60), "unchanged");
827        w.key(key(KeyCode::Enter));
828        for _ in 0..5 {
829            w.key(key(KeyCode::Backspace));
830        }
831        typed(&mut w, "30s");
832        assert_eq!(w.list.interval, Duration::from_secs(30));
833        w.key(key(KeyCode::Down));
834        w.key(key(KeyCode::Enter));
835        for _ in 0..10 {
836            w.key(key(KeyCode::Backspace));
837        }
838        typed(&mut w, "forever");
839        assert_eq!(w.list.duration, None);
840    }
841
842    #[test]
843    fn start_and_save_need_targets_with_checks_and_save_reads_back() {
844        let dir = tempfile::tempdir().unwrap();
845        let mut w = wizard(dir.path());
846        assert_eq!(w.key(ctrl('r')), WizardAction::None);
847        assert!(w.error.as_deref().unwrap().contains("Add a target"));
848        w.key(key(KeyCode::Char('a')));
849        typed(&mut w, "token PEPE");
850        assert_eq!(w.key(ctrl('s')), WizardAction::None);
851        assert!(w.error.as_deref().unwrap().contains("nothing to check"));
852        w.key(key(KeyCode::Char('a')));
853        w.key(key(KeyCode::Esc));
854        w.key(key(KeyCode::Char('d')));
855        w.key(key(KeyCode::Char('a')));
856        typed(&mut w, "pair USDC binance");
857        let WizardAction::Saved(path) = w.key(ctrl('s')) else {
858            panic!("{:?}", w.error)
859        };
860        assert_eq!(
861            scope::watch::file::load(&path, None).unwrap(),
862            w.list,
863            "the file reads back equal"
864        );
865        let WizardAction::Start(list) = w.key(ctrl('r')) else {
866            panic!("{:?}", w.error)
867        };
868        assert_eq!(*list, w.list);
869    }
870
871    #[test]
872    fn esc_with_changes_asks_first() {
873        let dir = tempfile::tempdir().unwrap();
874        let mut w = wizard(dir.path());
875        assert_eq!(
876            w.key(key(KeyCode::Esc)),
877            WizardAction::Leave,
878            "nothing changed"
879        );
880        let mut w = wizard(dir.path());
881        w.key(key(KeyCode::Char('a')));
882        typed(&mut w, "pair USDC binance");
883        assert_eq!(w.key(key(KeyCode::Esc)), WizardAction::None);
884        assert!(w.confirm_leave);
885        assert_eq!(w.key(key(KeyCode::Char('n'))), WizardAction::None);
886        assert!(!w.confirm_leave);
887        w.key(key(KeyCode::Esc));
888        assert_eq!(w.key(key(KeyCode::Char('y'))), WizardAction::Leave);
889    }
890
891    #[test]
892    fn draws_each_step_at_several_sizes() {
893        let dir = tempfile::tempdir().unwrap();
894        let mut w = wizard(dir.path());
895        w.notes.push("--layout has no effect".into());
896        w.key(key(KeyCode::Char('a')));
897        typed(&mut w, "pair USDN weex");
898        for step in Step::ALL {
899            w.step = step;
900            for (cols, rows) in [(120, 36), (80, 24), (40, 10)] {
901                let s = screen(&w, cols, rows);
902                assert!(s.contains("monitor wizard"), "{step:?} {cols}x{rows}\n{s}");
903            }
904        }
905        w.step = Step::Metrics;
906        let s = screen(&w, 120, 36);
907        assert!(s.contains("[x] market health"), "{s}");
908        assert!(s.contains("--layout has no effect"), "{s}");
909        w.step = Step::Timing;
910        let s = screen(&w, 120, 36);
911        assert!(s.contains("1 fetch(es) per run · 61 in all"), "{s}");
912    }
913
914    fn clear(w: &mut WizardState) {
915        for _ in 0..40 {
916            w.key(key(KeyCode::Backspace));
917        }
918    }
919
920    #[test]
921    fn targets_step_moves_the_selection_and_enter_opens_its_metrics() {
922        let dir = tempfile::tempdir().unwrap();
923        let mut w = wizard(dir.path());
924        w.key(key(KeyCode::Down));
925        w.key(key(KeyCode::Enter));
926        assert_eq!((w.step, w.target), (Step::Targets, 0), "no target yet");
927        for t in ["pair USDC binance", "address 0xabc ethereum", "token PEPE"] {
928            w.key(key(KeyCode::Char('a')));
929            typed(&mut w, t);
930        }
931        assert_eq!(w.target, 2, "the new target is selected");
932        w.key(key(KeyCode::Down));
933        assert_eq!(w.target, 0, "down from the last is the first");
934        w.key(key(KeyCode::Up));
935        w.key(key(KeyCode::Up));
936        assert_eq!(w.target, 1);
937        w.row = 3;
938        w.key(key(KeyCode::Enter));
939        assert_eq!((w.step, w.target, w.row), (Step::Metrics, 1, 0));
940    }
941
942    #[test]
943    fn metrics_step_moves_between_rows_and_targets() {
944        let dir = tempfile::tempdir().unwrap();
945        let mut w = wizard(dir.path());
946        w.step = Step::Metrics;
947        assert!(w.metric_rows().is_empty(), "no target");
948        for code in [KeyCode::Down, KeyCode::Right, KeyCode::Char(' ')] {
949            w.key(key(code));
950        }
951        assert_eq!((w.target, w.row, w.dirty), (0, 0, false));
952        assert!(screen(&w, 80, 24).contains("Add a target first."));
953        w.step = Step::Targets;
954        for t in ["pair USDC binance", "token PEPE"] {
955            w.key(key(KeyCode::Char('a')));
956            typed(&mut w, t);
957        }
958        w.key(key(KeyCode::Tab));
959        let n = w.metric_rows().len();
960        w.key(key(KeyCode::Up));
961        assert_eq!(w.row, n - 1, "up from the first row is the last");
962        w.key(key(KeyCode::Down));
963        assert_eq!(w.row, 0);
964        w.key(key(KeyCode::Down));
965        w.key(key(KeyCode::Right));
966        assert_eq!(
967            (w.target, w.row),
968            (0, 0),
969            "right wraps and starts at the top"
970        );
971        assert_eq!(w.metric_rows()[0], None, "a pair starts with the preset");
972        w.key(key(KeyCode::Down));
973        w.key(key(KeyCode::Left));
974        assert_eq!((w.target, w.row), (1, 0), "left wraps");
975        let s = screen(&w, 120, 36);
976        assert!(s.contains("(2/2)"), "{s}");
977    }
978
979    #[test]
980    fn the_preset_turns_off_and_on_again() {
981        let dir = tempfile::tempdir().unwrap();
982        let mut w = wizard(dir.path());
983        w.key(key(KeyCode::Char('a')));
984        typed(&mut w, "pair USDC binance");
985        w.key(key(KeyCode::Tab));
986        w.key(key(KeyCode::Char(' ')));
987        assert_eq!(w.list.targets[0].preset, None);
988        assert!(screen(&w, 120, 36).contains("[ ] market health"));
989        w.key(key(KeyCode::Char(' ')));
990        assert_eq!(w.list.targets[0].preset, Some(Preset::MarketHealth));
991        // `o` and Enter have nothing to change on the preset row.
992        w.key(key(KeyCode::Char('o')));
993        w.key(key(KeyCode::Enter));
994        assert_eq!(w.edit, None);
995    }
996
997    #[test]
998    fn o_goes_through_every_operator_and_back() {
999        let dir = tempfile::tempdir().unwrap();
1000        let mut w = wizard(dir.path());
1001        w.key(key(KeyCode::Char('a')));
1002        typed(&mut w, "token PEPE");
1003        w.key(key(KeyCode::Tab));
1004        w.row = w
1005            .metric_rows()
1006            .iter()
1007            .position(|r| *r == Some(MetricId::Holders))
1008            .unwrap();
1009        w.key(key(KeyCode::Char('o')));
1010        assert!(w.list.targets[0].rules.is_empty(), "no rule to change yet");
1011        w.key(key(KeyCode::Char(' ')));
1012        let ops: Vec<Op> = (0..6)
1013            .map(|_| {
1014                w.key(key(KeyCode::Char('o')));
1015                w.list.targets[0].rules[0].op
1016            })
1017            .collect();
1018        assert_eq!(ops, [Op::Ge, Op::Eq, Op::Ne, Op::Lt, Op::Le, Op::Gt]);
1019    }
1020
1021    #[test]
1022    fn a_value_must_be_a_finite_number() {
1023        let dir = tempfile::tempdir().unwrap();
1024        let mut w = wizard(dir.path());
1025        w.key(key(KeyCode::Char('a')));
1026        typed(&mut w, "token PEPE");
1027        w.key(key(KeyCode::Tab));
1028        w.row = w
1029            .metric_rows()
1030            .iter()
1031            .position(|r| *r == Some(MetricId::Holders))
1032            .unwrap();
1033        w.key(key(KeyCode::Char(' ')));
1034        for bad in ["lots", "inf", ""] {
1035            w.key(key(KeyCode::Enter));
1036            clear(&mut w);
1037            typed(&mut w, bad);
1038            assert_eq!(
1039                w.error.as_deref(),
1040                Some(format!("`{bad}` is not a number").as_str())
1041            );
1042            assert_eq!(w.list.targets[0].rules[0].value, 0.0, "unchanged");
1043        }
1044        w.key(key(KeyCode::Enter));
1045        clear(&mut w);
1046        typed(&mut w, "1_000");
1047        assert_eq!(
1048            w.list.targets[0].rules[0].value, 1000.0,
1049            "`_` groups digits"
1050        );
1051    }
1052
1053    #[test]
1054    fn while_typing_ctrl_and_other_keys_do_not_type() {
1055        let dir = tempfile::tempdir().unwrap();
1056        let mut w = wizard(dir.path());
1057        w.key(key(KeyCode::Char('a')));
1058        w.key(key(KeyCode::Char('p')));
1059        for k in [ctrl('s'), ctrl('r'), key(KeyCode::Left), key(KeyCode::Tab)] {
1060            assert_eq!(w.key(k), WizardAction::None);
1061        }
1062        assert_eq!(w.edit, Some((EditKind::AddTarget, "p".to_string())));
1063        assert_eq!(w.step, Step::Targets);
1064        assert_eq!(w.error, None, "Ctrl-S did not try to save");
1065        w.key(key(KeyCode::Esc));
1066        assert_eq!(w.edit, None, "Esc cancels");
1067        assert!(w.list.targets.is_empty());
1068    }
1069
1070    #[test]
1071    fn shift_tab_goes_back_and_wraps() {
1072        let dir = tempfile::tempdir().unwrap();
1073        let mut w = wizard(dir.path());
1074        w.row = 2;
1075        w.key(key(KeyCode::BackTab));
1076        assert_eq!((w.step, w.row), (Step::Outputs, 0));
1077        w.key(key(KeyCode::BackTab));
1078        assert_eq!(w.step, Step::Timing);
1079        w.key(key(KeyCode::Tab));
1080        w.key(key(KeyCode::Tab));
1081        assert_eq!(w.step, Step::Targets, "Tab wraps too");
1082    }
1083
1084    #[test]
1085    fn the_duration_edit_starts_from_the_current_one_and_counts_the_fetches() {
1086        let dir = tempfile::tempdir().unwrap();
1087        let mut w = wizard(dir.path());
1088        w.key(key(KeyCode::Char('a')));
1089        typed(&mut w, "pair USDC binance");
1090        w.list.duration = Some(Duration::from_secs(7200));
1091        w.step = Step::Timing;
1092        w.key(key(KeyCode::Up));
1093        assert_eq!(w.row, 1, "up from the interval is the duration");
1094        w.key(key(KeyCode::Enter));
1095        assert_eq!(w.edit, Some((EditKind::Duration, format_duration(7200))));
1096        clear(&mut w);
1097        typed(&mut w, "soon");
1098        assert!(w.error.is_some(), "not a duration");
1099        assert_eq!(w.list.duration, Some(Duration::from_secs(7200)));
1100        w.key(key(KeyCode::Enter));
1101        clear(&mut w);
1102        typed(&mut w, "30m");
1103        assert_eq!(w.list.duration, Some(Duration::from_secs(1800)));
1104        let s = screen(&w, 120, 36);
1105        assert!(
1106            s.contains(&format!("Duration  {}", format_duration(1800))),
1107            "{s}"
1108        );
1109        // Every 60 s for 30 min: 30 runs and the first.
1110        assert!(s.contains("1 fetch(es) per run · 31 in all"), "{s}");
1111    }
1112
1113    #[test]
1114    fn a_run_with_no_duration_runs_until_stopped() {
1115        let dir = tempfile::tempdir().unwrap();
1116        let mut w = wizard(dir.path());
1117        w.key(key(KeyCode::Char('a')));
1118        typed(&mut w, "pair USDC binance");
1119        w.list.duration = None;
1120        w.step = Step::Timing;
1121        let s = screen(&w, 120, 36);
1122        assert!(s.contains("Duration  until stopped"), "{s}");
1123        assert!(s.contains("1 fetch(es) per run · until stopped"), "{s}");
1124        w.key(key(KeyCode::Down));
1125        w.key(key(KeyCode::Enter));
1126        assert_eq!(
1127            w.edit,
1128            Some((EditKind::Duration, "forever".to_string())),
1129            "the edit starts from a word the parser takes back"
1130        );
1131        w.key(key(KeyCode::Enter));
1132        assert_eq!((w.list.duration, w.error.as_deref()), (None, None));
1133    }
1134
1135    #[test]
1136    fn outputs_edit_the_name_and_paths_then_save_and_start() {
1137        let dir = tempfile::tempdir().unwrap();
1138        let mut w = wizard(dir.path());
1139        w.key(key(KeyCode::Char('a')));
1140        typed(&mut w, "pair USDC binance");
1141        w.step = Step::Outputs;
1142        w.key(key(KeyCode::Up));
1143        assert_eq!(w.row, 4, "up from Name is Start");
1144        w.key(key(KeyCode::Down));
1145        assert_eq!(w.row, 0);
1146        for bad in ["", "a/b", "c:d"] {
1147            w.key(key(KeyCode::Enter));
1148            clear(&mut w);
1149            typed(&mut w, bad);
1150            assert!(
1151                w.error.as_deref().unwrap().contains("a name has no"),
1152                "{bad:?}"
1153            );
1154            assert_eq!(w.list.name, "w", "unchanged");
1155        }
1156        w.key(key(KeyCode::Enter));
1157        assert_eq!(w.edit, Some((EditKind::Name, "w".to_string())));
1158        clear(&mut w);
1159        typed(&mut w, " stables ");
1160        assert_eq!(w.list.name, "stables", "trimmed");
1161        w.key(key(KeyCode::Down));
1162        w.key(key(KeyCode::Enter));
1163        typed(&mut w, "out.csv");
1164        assert_eq!(w.list.outputs.csv, Some(PathBuf::from("out.csv")));
1165        w.key(key(KeyCode::Enter));
1166        assert_eq!(w.edit, Some((EditKind::Csv, "out.csv".to_string())));
1167        clear(&mut w);
1168        w.key(key(KeyCode::Enter));
1169        assert_eq!(w.list.outputs.csv, None, "an empty path is no file");
1170        w.key(key(KeyCode::Down));
1171        w.key(key(KeyCode::Enter));
1172        typed(&mut w, "report.md");
1173        assert_eq!(w.list.outputs.report, Some(PathBuf::from("report.md")));
1174        w.key(key(KeyCode::Enter));
1175        clear(&mut w);
1176        w.key(key(KeyCode::Enter));
1177        assert_eq!(w.list.outputs.report, None);
1178        let s = screen(&w, 120, 36);
1179        assert!(s.contains(&format!("to {}", w.path().display())), "{s}");
1180        w.key(key(KeyCode::Down));
1181        let WizardAction::Saved(path) = w.key(key(KeyCode::Enter)) else {
1182            panic!("{:?}", w.error)
1183        };
1184        assert_eq!(path, w.path());
1185        assert!(path.exists());
1186        assert!(!w.dirty);
1187        w.key(key(KeyCode::Down));
1188        let WizardAction::Start(list) = w.key(key(KeyCode::Enter)) else {
1189            panic!("{:?}", w.error)
1190        };
1191        assert_eq!(list.name, "stables");
1192    }
1193
1194    #[test]
1195    fn add_target_says_how_when_the_line_is_wrong() {
1196        let dir = tempfile::tempdir().unwrap();
1197        let mut w = wizard(dir.path());
1198        for bad in ["pair", "pair USDC binance extra", "wallet 0xabc"] {
1199            w.key(key(KeyCode::Char('a')));
1200            typed(&mut w, bad);
1201            assert!(
1202                w.error.as_deref().unwrap().starts_with("type `pair USDC"),
1203                "{bad}: {:?}",
1204                w.error
1205            );
1206        }
1207        assert!(w.list.targets.is_empty());
1208        // An address's third word is always its chain, even a venue's name;
1209        // a token's is its venue when it names one.
1210        for line in ["ADDRESS 0xabc binance", "token PEPE WEEX"] {
1211            w.key(key(KeyCode::Char('a')));
1212            typed(&mut w, line);
1213        }
1214        let [a, t] = &w.list.targets[..] else {
1215            panic!("{:?}", w.list.targets)
1216        };
1217        assert_eq!(
1218            (a.kind, a.chain.as_deref(), a.venue.as_deref()),
1219            (TargetKind::Address, Some("binance"), None)
1220        );
1221        assert_eq!(
1222            (t.venue.as_deref(), t.preset),
1223            (Some("WEEX"), Some(Preset::MarketHealth))
1224        );
1225    }
1226
1227    #[test]
1228    fn a_save_that_fails_says_why_and_stays_dirty() {
1229        let dir = tempfile::tempdir().unwrap();
1230        // The folder is a file: it cannot hold the watchlist.
1231        let file = dir.path().join("not-a-folder");
1232        std::fs::write(&file, "").unwrap();
1233        let mut w = wizard(&file);
1234        w.key(key(KeyCode::Char('a')));
1235        typed(&mut w, "pair USDC binance");
1236        assert_eq!(w.key(ctrl('s')), WizardAction::None);
1237        assert!(
1238            w.error.as_deref().unwrap().starts_with("Could not save"),
1239            "{:?}",
1240            w.error
1241        );
1242        assert!(w.dirty);
1243        assert!(screen(&w, 120, 36).contains("Could not save"));
1244    }
1245
1246    #[test]
1247    fn draws_the_targets_the_line_being_typed_and_the_question() {
1248        let dir = tempfile::tempdir().unwrap();
1249        let mut w = wizard(dir.path());
1250        let s = screen(&w, 120, 36);
1251        assert!(s.contains("No targets yet: press a to add one."), "{s}");
1252        assert!(!s.contains("Ready"), "nothing to start: {s}");
1253        for t in ["address 0xabc ethereum", "token PEPE"] {
1254            w.key(key(KeyCode::Char('a')));
1255            typed(&mut w, t);
1256        }
1257        w.list.targets[1]
1258            .rules
1259            .push(default_rule(MetricId::Holders));
1260        let s = screen(&w, 120, 36);
1261        assert!(s.contains("0xabc on ethereum"), "{s}");
1262        assert!(s.contains("PEPE  1 rule(s)"), "{s}");
1263        for (kind, label) in [
1264            (EditKind::AddTarget, "Add"),
1265            (EditKind::Value(MetricId::Holders), "holders value"),
1266            (EditKind::Interval, "Interval"),
1267            (EditKind::Duration, "Duration"),
1268            (EditKind::Name, "Name"),
1269            (EditKind::Csv, "CSV"),
1270            (EditKind::Report, "Report"),
1271        ] {
1272            let hint = kind == EditKind::AddTarget;
1273            w.edit = Some((kind, "abc".to_string()));
1274            let s = screen(&w, 120, 36);
1275            assert!(s.contains(&format!(" {label}: abc█")), "{label}\n{s}");
1276            assert_eq!(s.contains("Enter adds · Esc cancels"), hint, "{s}");
1277        }
1278        w.edit = None;
1279        w.key(key(KeyCode::Esc));
1280        assert!(screen(&w, 120, 36).contains("Leave without saving? [y/N]"));
1281        w.key(key(KeyCode::Char('n')));
1282        w.dirty = false;
1283        let s = screen(&w, 120, 36);
1284        assert!(!s.contains("Leave without saving"), "{s}");
1285        assert!(!s.contains("Ready"), "the address checks nothing: {s}");
1286        w.list.targets[0]
1287            .rules
1288            .push(default_rule(MetricId::RiskScore));
1289        let s = screen(&w, 120, 36);
1290        assert!(s.contains("Ready: Ctrl-R starts the run."), "{s}");
1291    }
1292}