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.venues.is_empty() => {
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]`: every word that names a venue goes
396    /// in the venue list.
397    fn add_target(&mut self, text: &str) -> Result<(), String> {
398        const USAGE: &str =
399            "type `pair USDC binance [okx…]`, `token PEPE [chain|venue…]` or `address 0x… [chain]`";
400        let words: Vec<&str> = text.split_whitespace().collect();
401        let (kind, id, extras) = match words.as_slice() {
402            [kind, id, extras @ ..] => (*kind, *id, extras),
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        for &extra in extras {
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) if t.chain.is_some() => {
416                    return Err(USAGE.to_string());
417                }
418                (TargetKind::Address, _) | (TargetKind::Token, false) => {
419                    t.chain = Some(extra.to_string())
420                }
421                (_, true) if t.venues.iter().any(|v| v.eq_ignore_ascii_case(extra)) => {
422                    return Err(format!("venue `{extra}` is listed twice"));
423                }
424                (_, true) => t.venues.push(extra.to_string()),
425                (TargetKind::Pair, false) => {
426                    return Err(format!(
427                        "unknown venue `{extra}`; the venues are {}",
428                        self.venues.join(", ")
429                    ));
430                }
431            }
432        }
433        if kind == TargetKind::Pair && t.venues.is_empty() {
434            return Err("a pair needs a venue: `pair USDC binance`".to_string());
435        }
436        if !t.venues.is_empty() {
437            t.preset = Some(Preset::MarketHealth);
438        }
439        self.list.targets.push(t);
440        self.target = self.list.targets.len() - 1;
441        Ok(())
442    }
443
444    /// The problem that stops a save or a start, if any.
445    pub fn problem(&self) -> Option<String> {
446        if self.list.targets.is_empty() {
447            return Some("Add a target first (Targets step, `a`).".to_string());
448        }
449        self.list
450            .targets
451            .iter()
452            .find(|t| t.preset.is_none() && t.rules.is_empty())
453            .map(|t| {
454                format!(
455                    "{} has nothing to check: turn on a metric or the preset (Metrics step).",
456                    t.label()
457                )
458            })
459    }
460
461    /// The file Save writes.
462    pub fn path(&self) -> PathBuf {
463        scope::watch::file::path_in(&self.dir, &self.list.name)
464    }
465
466    fn save(&mut self) -> WizardAction {
467        if let Some(p) = self.problem() {
468            self.error = Some(p);
469            return WizardAction::None;
470        }
471        let path = self.path();
472        match scope::watch::file::save(&path, &self.list) {
473            Ok(()) => {
474                self.dirty = false;
475                WizardAction::Saved(path)
476            }
477            Err(e) => {
478                self.error = Some(format!("Could not save {}: {e}", path.display()));
479                WizardAction::None
480            }
481        }
482    }
483
484    fn start(&mut self) -> WizardAction {
485        if let Some(p) = self.problem() {
486            self.error = Some(p);
487            return WizardAction::None;
488        }
489        WizardAction::Start(Box::new(self.list.clone()))
490    }
491}
492
493/// The rule a metric starts with: its threshold from the defaults, or
494/// `> 0` for a metric with none.
495fn default_rule(metric: MetricId) -> Rule {
496    let (op, value) = match describe(metric, &HealthThresholds::default()).threshold {
497        Some(Threshold::Max(v)) => (Op::Le, v),
498        Some(Threshold::Min(v)) => (Op::Ge, v),
499        Some(Threshold::Target(_)) | None => (Op::Gt, 0.0),
500    };
501    Rule { metric, op, value }
502}
503
504fn next_op(op: Op) -> Op {
505    match op {
506        Op::Lt => Op::Le,
507        Op::Le => Op::Gt,
508        Op::Gt => Op::Ge,
509        Op::Ge => Op::Eq,
510        Op::Eq => Op::Ne,
511        Op::Ne => Op::Lt,
512    }
513}
514
515fn path_text(p: &Option<PathBuf>) -> String {
516    p.as_deref()
517        .map(Path::display)
518        .map(|d| d.to_string())
519        .unwrap_or_default()
520}
521
522/// A row: the marker, then the text, selected or not.
523fn row(selected: bool, text: String) -> Line<'static> {
524    if selected {
525        Line::from(vec![Span::styled("> ", KEY), Span::styled(text, SELECTED)])
526    } else {
527        Line::from(vec![Span::raw("  "), Span::raw(text)])
528    }
529}
530
531fn target_text(t: &Target) -> String {
532    let mut s = format!("{:<8} {}", t.kind.name(), t.id);
533    if !t.venues.is_empty() {
534        s.push_str(&format!(" @{}", t.venues.join(",")));
535    }
536    if let Some(c) = &t.chain {
537        s.push_str(&format!(" on {c}"));
538    }
539    if t.preset.is_some() {
540        s.push_str("  [market health]");
541    }
542    if !t.rules.is_empty() {
543        s.push_str(&format!("  {} rule(s)", t.rules.len()));
544    }
545    s
546}
547
548/// Draws the wizard in `area`.
549pub fn draw(f: &mut Frame, area: Rect, w: &WizardState) {
550    let block = Block::new()
551        .borders(Borders::TOP | Borders::BOTTOM)
552        .border_style(ACCENT)
553        .title(Line::styled(
554            format!(" monitor wizard · {} ", w.list.name),
555            ACCENT.add_modifier(Modifier::BOLD),
556        ));
557    let inner = block.inner(area);
558    f.render_widget(block, area);
559    let mut lines: Vec<Line> = Vec::new();
560    let mut tabs = vec![Span::raw(" ")];
561    for s in Step::ALL {
562        let style = if s == w.step {
563            SELECTED.add_modifier(Modifier::BOLD)
564        } else {
565            SECONDARY
566        };
567        tabs.push(Span::styled(
568            format!(" {} {} ", s.index() + 1, s.name()),
569            style,
570        ));
571    }
572    lines.push(Line::from(tabs));
573    for n in &w.notes {
574        lines.push(Line::styled(format!(" {n}"), WARN));
575    }
576    lines.push(Line::raw(""));
577    match w.step {
578        Step::Targets => {
579            if w.list.targets.is_empty() {
580                lines.push(Line::styled(
581                    " No targets yet: press a to add one.",
582                    SECONDARY,
583                ));
584            }
585            for (i, t) in w.list.targets.iter().enumerate() {
586                lines.push(row(i == w.target, target_text(t)));
587            }
588        }
589        Step::Metrics => match w.list.targets.get(w.target) {
590            None => lines.push(Line::styled(" Add a target first.", SECONDARY)),
591            Some(t) => {
592                lines.push(Line::styled(
593                    format!(
594                        " {} ({}/{})  ←→ other target",
595                        t.label(),
596                        w.target + 1,
597                        w.list.targets.len()
598                    ),
599                    ACCENT,
600                ));
601                for (i, r) in w.metric_rows().iter().enumerate() {
602                    let text = match r {
603                        None => format!(
604                            "[{}] market health (the checks of market summary)",
605                            if t.preset.is_some() { "x" } else { " " }
606                        ),
607                        Some(m) => match t.rules.iter().find(|x| x.metric == *m) {
608                            Some(rule) => format!("[x] {rule}"),
609                            None => format!("[ ] {}", m.name()),
610                        },
611                    };
612                    lines.push(row(i == w.row, text));
613                }
614            }
615        },
616        Step::Timing => {
617            let duration = w.list.duration.map_or_else(
618                || "until stopped".to_string(),
619                |d| format_duration(d.as_secs()),
620            );
621            lines.push(row(
622                w.row == 0,
623                format!("Interval  {}", format_duration(w.list.interval.as_secs())),
624            ));
625            lines.push(row(w.row == 1, format!("Duration  {duration}")));
626            let per = w.list.fetches_per_tick();
627            let total = w.list.ticks().map_or_else(
628                || "until stopped".to_string(),
629                |t| format!("{} in all", t * per as u64),
630            );
631            lines.push(Line::raw(""));
632            lines.push(Line::styled(
633                format!(" {per} fetch(es) per run · {total}"),
634                SECONDARY,
635            ));
636        }
637        Step::Outputs => {
638            let values = [
639                w.list.name.clone(),
640                path_text(&w.list.outputs.csv),
641                path_text(&w.list.outputs.report),
642                format!("to {}", w.path().display()),
643                "the grid".to_string(),
644            ];
645            for (i, (label, value)) in OUTPUT_ROWS.iter().zip(values).enumerate() {
646                lines.push(row(i == w.row, format!("{label:<7} {value}")));
647            }
648        }
649    }
650    lines.push(Line::raw(""));
651    if let Some((kind, text)) = &w.edit {
652        let label = match kind {
653            EditKind::AddTarget => "Add".to_string(),
654            EditKind::Value(m) => format!("{} value", m.name()),
655            EditKind::Interval => "Interval".to_string(),
656            EditKind::Duration => "Duration".to_string(),
657            EditKind::Name => "Name".to_string(),
658            EditKind::Csv => "CSV".to_string(),
659            EditKind::Report => "Report".to_string(),
660        };
661        lines.push(Line::from(vec![
662            Span::styled(format!(" {label}: "), KEY),
663            Span::raw(text.clone()),
664            Span::styled("█", ACCENT),
665        ]));
666        if *kind == EditKind::AddTarget {
667            lines.push(Line::styled(
668                "   pair USDC binance · token PEPE solana · address 0x… ethereum · Enter adds · Esc cancels",
669                SECONDARY,
670            ));
671        }
672    }
673    if w.confirm_leave {
674        lines.push(Line::styled(
675            " Leave without saving? [y/N]",
676            WARN.add_modifier(Modifier::BOLD),
677        ));
678    }
679    if let Some(e) = &w.error {
680        lines.push(Line::styled(format!(" {e}"), ERR));
681    } else if w.problem().is_none() && !w.dirty {
682        lines.push(Line::styled(" Ready: Ctrl-R starts the run.", OK));
683    }
684    // The legend on the last row.
685    let body = Rect {
686        height: inner.height.saturating_sub(1),
687        ..inner
688    };
689    let first = lines.len().saturating_sub(body.height as usize);
690    f.render_widget(
691        Paragraph::new(lines[first.min(lines.len())..].to_vec()),
692        body,
693    );
694    if inner.height > 0 {
695        let last = Rect::new(inner.x, inner.bottom() - 1, inner.width, 1);
696        f.render_widget(
697            Paragraph::new(Line::styled(format!(" {}", legend(w.step)), KEY)),
698            last,
699        );
700    }
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706    use ratatui::Terminal;
707    use ratatui::backend::TestBackend;
708
709    fn key(code: KeyCode) -> KeyEvent {
710        KeyEvent::new(code, KeyModifiers::NONE)
711    }
712
713    fn ctrl(c: char) -> KeyEvent {
714        KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
715    }
716
717    fn typed(w: &mut WizardState, text: &str) {
718        for c in text.chars() {
719            w.key(key(KeyCode::Char(c)));
720        }
721        w.key(key(KeyCode::Enter));
722    }
723
724    fn wizard(dir: &Path) -> WizardState {
725        WizardState::new(
726            Watchlist::new("w"),
727            Vec::new(),
728            vec!["binance".into(), "weex".into()],
729            dir.to_path_buf(),
730        )
731    }
732
733    fn screen(w: &WizardState, cols: u16, rows: u16) -> String {
734        let mut t = Terminal::new(TestBackend::new(cols, rows)).unwrap();
735        t.draw(|f| draw(f, f.area(), w)).unwrap();
736        let buf = t.backend().buffer().clone();
737        let mut s = String::new();
738        for y in 0..rows {
739            for x in 0..cols {
740                s.push_str(buf[(x, y)].symbol());
741            }
742            s.push('\n');
743        }
744        s
745    }
746
747    #[test]
748    fn add_targets_with_venue_or_chain_and_delete() {
749        let dir = tempfile::tempdir().unwrap();
750        let mut w = wizard(dir.path());
751        w.key(key(KeyCode::Char('a')));
752        typed(&mut w, "pair USDC binance");
753        let t = &w.list.targets[0];
754        assert_eq!(
755            (t.kind, t.venues.as_slice(), t.preset),
756            (
757                TargetKind::Pair,
758                ["binance".to_string()].as_slice(),
759                Some(Preset::MarketHealth)
760            )
761        );
762        w.key(key(KeyCode::Char('a')));
763        typed(&mut w, "token PEPE solana");
764        assert_eq!(w.list.targets[1].chain.as_deref(), Some("solana"));
765        assert_eq!(w.list.targets[1].preset, None);
766        w.key(key(KeyCode::Char('a')));
767        typed(&mut w, "pair DAI nowhere");
768        assert!(
769            w.error
770                .as_deref()
771                .unwrap()
772                .contains("unknown venue `nowhere`")
773        );
774        w.key(key(KeyCode::Char('a')));
775        typed(&mut w, "pair DAI");
776        assert!(w.error.as_deref().unwrap().contains("needs a venue"));
777        assert_eq!(w.list.targets.len(), 2);
778        w.key(key(KeyCode::Char('d')));
779        assert_eq!(w.list.targets.len(), 1);
780    }
781
782    // Design 2026-11-04, section 2.7: one target watched on several
783    // venues, added in one line, and shown with all its venues.
784    #[test]
785    fn add_a_target_on_several_venues() {
786        let dir = tempfile::tempdir().unwrap();
787        let mut w = wizard(dir.path());
788        w.key(key(KeyCode::Char('a')));
789        typed(&mut w, "pair USDN weex binance");
790        let [t] = &w.list.targets[..] else {
791            panic!("{:?}", w.list.targets)
792        };
793        assert_eq!(t.venues, ["weex", "binance"]);
794        assert_eq!(t.preset, Some(Preset::MarketHealth));
795        assert!(
796            target_text(t).contains("@weex,binance"),
797            "{}",
798            target_text(t)
799        );
800        w.key(key(KeyCode::Char('a')));
801        typed(&mut w, "token PEPE weex solana binance");
802        let t = &w.list.targets[1];
803        assert_eq!(
804            (t.venues.as_slice(), t.chain.as_deref()),
805            (
806                ["weex".to_string(), "binance".to_string()].as_slice(),
807                Some("solana")
808            )
809        );
810        for bad in ["pair DAI weex weex", "token PEPE solana base"] {
811            w.key(key(KeyCode::Char('a')));
812            typed(&mut w, bad);
813            assert!(w.error.is_some(), "{bad}");
814        }
815        assert_eq!(w.list.targets.len(), 2);
816    }
817
818    #[test]
819    fn metrics_toggle_change_op_and_value() {
820        let dir = tempfile::tempdir().unwrap();
821        let mut w = wizard(dir.path());
822        w.key(key(KeyCode::Char('a')));
823        typed(&mut w, "token PEPE");
824        w.key(key(KeyCode::Tab));
825        assert_eq!(w.step, Step::Metrics);
826        // Row 0 is the preset: a token with no venue cannot have it.
827        w.key(key(KeyCode::Char(' ')));
828        assert!(w.error.as_deref().unwrap().contains("no venue"));
829        let rows = w.metric_rows();
830        let holders = rows
831            .iter()
832            .position(|r| *r == Some(MetricId::Holders))
833            .unwrap();
834        w.row = holders;
835        w.key(key(KeyCode::Char(' ')));
836        assert_eq!(w.list.targets[0].rules[0].to_string(), "holders > 0");
837        w.key(key(KeyCode::Char('o')));
838        assert_eq!(w.list.targets[0].rules[0].op, Op::Ge);
839        w.key(key(KeyCode::Enter));
840        w.key(key(KeyCode::Backspace));
841        typed(&mut w, "1000");
842        assert_eq!(w.list.targets[0].rules[0].to_string(), "holders >= 1000");
843        w.key(key(KeyCode::Char(' ')));
844        assert!(w.list.targets[0].rules.is_empty());
845    }
846
847    #[test]
848    fn market_metrics_start_from_the_health_thresholds() {
849        assert_eq!(default_rule(MetricId::SpreadPct).op, Op::Le);
850        assert_eq!(
851            default_rule(MetricId::SpreadPct).value,
852            HealthThresholds::default().max_spread_pct
853        );
854        assert_eq!(default_rule(MetricId::BidDepth).op, Op::Ge);
855    }
856
857    #[test]
858    fn timing_edits_parse_and_bad_values_say_why() {
859        let dir = tempfile::tempdir().unwrap();
860        let mut w = wizard(dir.path());
861        w.step = Step::Timing;
862        w.key(key(KeyCode::Enter));
863        for _ in 0..5 {
864            w.key(key(KeyCode::Backspace));
865        }
866        typed(&mut w, "0s");
867        assert!(w.error.is_some());
868        assert_eq!(w.list.interval, Duration::from_secs(60), "unchanged");
869        w.key(key(KeyCode::Enter));
870        for _ in 0..5 {
871            w.key(key(KeyCode::Backspace));
872        }
873        typed(&mut w, "30s");
874        assert_eq!(w.list.interval, Duration::from_secs(30));
875        w.key(key(KeyCode::Down));
876        w.key(key(KeyCode::Enter));
877        for _ in 0..10 {
878            w.key(key(KeyCode::Backspace));
879        }
880        typed(&mut w, "forever");
881        assert_eq!(w.list.duration, None);
882    }
883
884    #[test]
885    fn start_and_save_need_targets_with_checks_and_save_reads_back() {
886        let dir = tempfile::tempdir().unwrap();
887        let mut w = wizard(dir.path());
888        assert_eq!(w.key(ctrl('r')), WizardAction::None);
889        assert!(w.error.as_deref().unwrap().contains("Add a target"));
890        w.key(key(KeyCode::Char('a')));
891        typed(&mut w, "token PEPE");
892        assert_eq!(w.key(ctrl('s')), WizardAction::None);
893        assert!(w.error.as_deref().unwrap().contains("nothing to check"));
894        w.key(key(KeyCode::Char('a')));
895        w.key(key(KeyCode::Esc));
896        w.key(key(KeyCode::Char('d')));
897        w.key(key(KeyCode::Char('a')));
898        typed(&mut w, "pair USDC binance");
899        let WizardAction::Saved(path) = w.key(ctrl('s')) else {
900            panic!("{:?}", w.error)
901        };
902        assert_eq!(
903            scope::watch::file::load(&path, None).unwrap(),
904            w.list,
905            "the file reads back equal"
906        );
907        let WizardAction::Start(list) = w.key(ctrl('r')) else {
908            panic!("{:?}", w.error)
909        };
910        assert_eq!(*list, w.list);
911    }
912
913    #[test]
914    fn esc_with_changes_asks_first() {
915        let dir = tempfile::tempdir().unwrap();
916        let mut w = wizard(dir.path());
917        assert_eq!(
918            w.key(key(KeyCode::Esc)),
919            WizardAction::Leave,
920            "nothing changed"
921        );
922        let mut w = wizard(dir.path());
923        w.key(key(KeyCode::Char('a')));
924        typed(&mut w, "pair USDC binance");
925        assert_eq!(w.key(key(KeyCode::Esc)), WizardAction::None);
926        assert!(w.confirm_leave);
927        assert_eq!(w.key(key(KeyCode::Char('n'))), WizardAction::None);
928        assert!(!w.confirm_leave);
929        w.key(key(KeyCode::Esc));
930        assert_eq!(w.key(key(KeyCode::Char('y'))), WizardAction::Leave);
931    }
932
933    #[test]
934    fn draws_each_step_at_several_sizes() {
935        let dir = tempfile::tempdir().unwrap();
936        let mut w = wizard(dir.path());
937        w.notes.push("--layout has no effect".into());
938        w.key(key(KeyCode::Char('a')));
939        typed(&mut w, "pair USDN weex");
940        for step in Step::ALL {
941            w.step = step;
942            for (cols, rows) in [(120, 36), (80, 24), (40, 10)] {
943                let s = screen(&w, cols, rows);
944                assert!(s.contains("monitor wizard"), "{step:?} {cols}x{rows}\n{s}");
945            }
946        }
947        w.step = Step::Metrics;
948        let s = screen(&w, 120, 36);
949        assert!(s.contains("[x] market health"), "{s}");
950        assert!(s.contains("--layout has no effect"), "{s}");
951        w.step = Step::Timing;
952        let s = screen(&w, 120, 36);
953        assert!(s.contains("1 fetch(es) per run · 61 in all"), "{s}");
954    }
955
956    fn clear(w: &mut WizardState) {
957        for _ in 0..40 {
958            w.key(key(KeyCode::Backspace));
959        }
960    }
961
962    #[test]
963    fn targets_step_moves_the_selection_and_enter_opens_its_metrics() {
964        let dir = tempfile::tempdir().unwrap();
965        let mut w = wizard(dir.path());
966        w.key(key(KeyCode::Down));
967        w.key(key(KeyCode::Enter));
968        assert_eq!((w.step, w.target), (Step::Targets, 0), "no target yet");
969        for t in ["pair USDC binance", "address 0xabc ethereum", "token PEPE"] {
970            w.key(key(KeyCode::Char('a')));
971            typed(&mut w, t);
972        }
973        assert_eq!(w.target, 2, "the new target is selected");
974        w.key(key(KeyCode::Down));
975        assert_eq!(w.target, 0, "down from the last is the first");
976        w.key(key(KeyCode::Up));
977        w.key(key(KeyCode::Up));
978        assert_eq!(w.target, 1);
979        w.row = 3;
980        w.key(key(KeyCode::Enter));
981        assert_eq!((w.step, w.target, w.row), (Step::Metrics, 1, 0));
982    }
983
984    #[test]
985    fn metrics_step_moves_between_rows_and_targets() {
986        let dir = tempfile::tempdir().unwrap();
987        let mut w = wizard(dir.path());
988        w.step = Step::Metrics;
989        assert!(w.metric_rows().is_empty(), "no target");
990        for code in [KeyCode::Down, KeyCode::Right, KeyCode::Char(' ')] {
991            w.key(key(code));
992        }
993        assert_eq!((w.target, w.row, w.dirty), (0, 0, false));
994        assert!(screen(&w, 80, 24).contains("Add a target first."));
995        w.step = Step::Targets;
996        for t in ["pair USDC binance", "token PEPE"] {
997            w.key(key(KeyCode::Char('a')));
998            typed(&mut w, t);
999        }
1000        w.key(key(KeyCode::Tab));
1001        let n = w.metric_rows().len();
1002        w.key(key(KeyCode::Up));
1003        assert_eq!(w.row, n - 1, "up from the first row is the last");
1004        w.key(key(KeyCode::Down));
1005        assert_eq!(w.row, 0);
1006        w.key(key(KeyCode::Down));
1007        w.key(key(KeyCode::Right));
1008        assert_eq!(
1009            (w.target, w.row),
1010            (0, 0),
1011            "right wraps and starts at the top"
1012        );
1013        assert_eq!(w.metric_rows()[0], None, "a pair starts with the preset");
1014        w.key(key(KeyCode::Down));
1015        w.key(key(KeyCode::Left));
1016        assert_eq!((w.target, w.row), (1, 0), "left wraps");
1017        let s = screen(&w, 120, 36);
1018        assert!(s.contains("(2/2)"), "{s}");
1019    }
1020
1021    #[test]
1022    fn the_preset_turns_off_and_on_again() {
1023        let dir = tempfile::tempdir().unwrap();
1024        let mut w = wizard(dir.path());
1025        w.key(key(KeyCode::Char('a')));
1026        typed(&mut w, "pair USDC binance");
1027        w.key(key(KeyCode::Tab));
1028        w.key(key(KeyCode::Char(' ')));
1029        assert_eq!(w.list.targets[0].preset, None);
1030        assert!(screen(&w, 120, 36).contains("[ ] market health"));
1031        w.key(key(KeyCode::Char(' ')));
1032        assert_eq!(w.list.targets[0].preset, Some(Preset::MarketHealth));
1033        // `o` and Enter have nothing to change on the preset row.
1034        w.key(key(KeyCode::Char('o')));
1035        w.key(key(KeyCode::Enter));
1036        assert_eq!(w.edit, None);
1037    }
1038
1039    #[test]
1040    fn o_goes_through_every_operator_and_back() {
1041        let dir = tempfile::tempdir().unwrap();
1042        let mut w = wizard(dir.path());
1043        w.key(key(KeyCode::Char('a')));
1044        typed(&mut w, "token PEPE");
1045        w.key(key(KeyCode::Tab));
1046        w.row = w
1047            .metric_rows()
1048            .iter()
1049            .position(|r| *r == Some(MetricId::Holders))
1050            .unwrap();
1051        w.key(key(KeyCode::Char('o')));
1052        assert!(w.list.targets[0].rules.is_empty(), "no rule to change yet");
1053        w.key(key(KeyCode::Char(' ')));
1054        let ops: Vec<Op> = (0..6)
1055            .map(|_| {
1056                w.key(key(KeyCode::Char('o')));
1057                w.list.targets[0].rules[0].op
1058            })
1059            .collect();
1060        assert_eq!(ops, [Op::Ge, Op::Eq, Op::Ne, Op::Lt, Op::Le, Op::Gt]);
1061    }
1062
1063    #[test]
1064    fn a_value_must_be_a_finite_number() {
1065        let dir = tempfile::tempdir().unwrap();
1066        let mut w = wizard(dir.path());
1067        w.key(key(KeyCode::Char('a')));
1068        typed(&mut w, "token PEPE");
1069        w.key(key(KeyCode::Tab));
1070        w.row = w
1071            .metric_rows()
1072            .iter()
1073            .position(|r| *r == Some(MetricId::Holders))
1074            .unwrap();
1075        w.key(key(KeyCode::Char(' ')));
1076        for bad in ["lots", "inf", ""] {
1077            w.key(key(KeyCode::Enter));
1078            clear(&mut w);
1079            typed(&mut w, bad);
1080            assert_eq!(
1081                w.error.as_deref(),
1082                Some(format!("`{bad}` is not a number").as_str())
1083            );
1084            assert_eq!(w.list.targets[0].rules[0].value, 0.0, "unchanged");
1085        }
1086        w.key(key(KeyCode::Enter));
1087        clear(&mut w);
1088        typed(&mut w, "1_000");
1089        assert_eq!(
1090            w.list.targets[0].rules[0].value, 1000.0,
1091            "`_` groups digits"
1092        );
1093    }
1094
1095    #[test]
1096    fn while_typing_ctrl_and_other_keys_do_not_type() {
1097        let dir = tempfile::tempdir().unwrap();
1098        let mut w = wizard(dir.path());
1099        w.key(key(KeyCode::Char('a')));
1100        w.key(key(KeyCode::Char('p')));
1101        for k in [ctrl('s'), ctrl('r'), key(KeyCode::Left), key(KeyCode::Tab)] {
1102            assert_eq!(w.key(k), WizardAction::None);
1103        }
1104        assert_eq!(w.edit, Some((EditKind::AddTarget, "p".to_string())));
1105        assert_eq!(w.step, Step::Targets);
1106        assert_eq!(w.error, None, "Ctrl-S did not try to save");
1107        w.key(key(KeyCode::Esc));
1108        assert_eq!(w.edit, None, "Esc cancels");
1109        assert!(w.list.targets.is_empty());
1110    }
1111
1112    #[test]
1113    fn shift_tab_goes_back_and_wraps() {
1114        let dir = tempfile::tempdir().unwrap();
1115        let mut w = wizard(dir.path());
1116        w.row = 2;
1117        w.key(key(KeyCode::BackTab));
1118        assert_eq!((w.step, w.row), (Step::Outputs, 0));
1119        w.key(key(KeyCode::BackTab));
1120        assert_eq!(w.step, Step::Timing);
1121        w.key(key(KeyCode::Tab));
1122        w.key(key(KeyCode::Tab));
1123        assert_eq!(w.step, Step::Targets, "Tab wraps too");
1124    }
1125
1126    #[test]
1127    fn the_duration_edit_starts_from_the_current_one_and_counts_the_fetches() {
1128        let dir = tempfile::tempdir().unwrap();
1129        let mut w = wizard(dir.path());
1130        w.key(key(KeyCode::Char('a')));
1131        typed(&mut w, "pair USDC binance");
1132        w.list.duration = Some(Duration::from_secs(7200));
1133        w.step = Step::Timing;
1134        w.key(key(KeyCode::Up));
1135        assert_eq!(w.row, 1, "up from the interval is the duration");
1136        w.key(key(KeyCode::Enter));
1137        assert_eq!(w.edit, Some((EditKind::Duration, format_duration(7200))));
1138        clear(&mut w);
1139        typed(&mut w, "soon");
1140        assert!(w.error.is_some(), "not a duration");
1141        assert_eq!(w.list.duration, Some(Duration::from_secs(7200)));
1142        w.key(key(KeyCode::Enter));
1143        clear(&mut w);
1144        typed(&mut w, "30m");
1145        assert_eq!(w.list.duration, Some(Duration::from_secs(1800)));
1146        let s = screen(&w, 120, 36);
1147        assert!(
1148            s.contains(&format!("Duration  {}", format_duration(1800))),
1149            "{s}"
1150        );
1151        // Every 60 s for 30 min: 30 runs and the first.
1152        assert!(s.contains("1 fetch(es) per run · 31 in all"), "{s}");
1153    }
1154
1155    #[test]
1156    fn a_run_with_no_duration_runs_until_stopped() {
1157        let dir = tempfile::tempdir().unwrap();
1158        let mut w = wizard(dir.path());
1159        w.key(key(KeyCode::Char('a')));
1160        typed(&mut w, "pair USDC binance");
1161        w.list.duration = None;
1162        w.step = Step::Timing;
1163        let s = screen(&w, 120, 36);
1164        assert!(s.contains("Duration  until stopped"), "{s}");
1165        assert!(s.contains("1 fetch(es) per run · until stopped"), "{s}");
1166        w.key(key(KeyCode::Down));
1167        w.key(key(KeyCode::Enter));
1168        assert_eq!(
1169            w.edit,
1170            Some((EditKind::Duration, "forever".to_string())),
1171            "the edit starts from a word the parser takes back"
1172        );
1173        w.key(key(KeyCode::Enter));
1174        assert_eq!((w.list.duration, w.error.as_deref()), (None, None));
1175    }
1176
1177    #[test]
1178    fn outputs_edit_the_name_and_paths_then_save_and_start() {
1179        let dir = tempfile::tempdir().unwrap();
1180        let mut w = wizard(dir.path());
1181        w.key(key(KeyCode::Char('a')));
1182        typed(&mut w, "pair USDC binance");
1183        w.step = Step::Outputs;
1184        w.key(key(KeyCode::Up));
1185        assert_eq!(w.row, 4, "up from Name is Start");
1186        w.key(key(KeyCode::Down));
1187        assert_eq!(w.row, 0);
1188        for bad in ["", "a/b", "c:d"] {
1189            w.key(key(KeyCode::Enter));
1190            clear(&mut w);
1191            typed(&mut w, bad);
1192            assert!(
1193                w.error.as_deref().unwrap().contains("a name has no"),
1194                "{bad:?}"
1195            );
1196            assert_eq!(w.list.name, "w", "unchanged");
1197        }
1198        w.key(key(KeyCode::Enter));
1199        assert_eq!(w.edit, Some((EditKind::Name, "w".to_string())));
1200        clear(&mut w);
1201        typed(&mut w, " stables ");
1202        assert_eq!(w.list.name, "stables", "trimmed");
1203        w.key(key(KeyCode::Down));
1204        w.key(key(KeyCode::Enter));
1205        typed(&mut w, "out.csv");
1206        assert_eq!(w.list.outputs.csv, Some(PathBuf::from("out.csv")));
1207        w.key(key(KeyCode::Enter));
1208        assert_eq!(w.edit, Some((EditKind::Csv, "out.csv".to_string())));
1209        clear(&mut w);
1210        w.key(key(KeyCode::Enter));
1211        assert_eq!(w.list.outputs.csv, None, "an empty path is no file");
1212        w.key(key(KeyCode::Down));
1213        w.key(key(KeyCode::Enter));
1214        typed(&mut w, "report.md");
1215        assert_eq!(w.list.outputs.report, Some(PathBuf::from("report.md")));
1216        w.key(key(KeyCode::Enter));
1217        clear(&mut w);
1218        w.key(key(KeyCode::Enter));
1219        assert_eq!(w.list.outputs.report, None);
1220        let s = screen(&w, 120, 36);
1221        assert!(s.contains(&format!("to {}", w.path().display())), "{s}");
1222        w.key(key(KeyCode::Down));
1223        let WizardAction::Saved(path) = w.key(key(KeyCode::Enter)) else {
1224            panic!("{:?}", w.error)
1225        };
1226        assert_eq!(path, w.path());
1227        assert!(path.exists());
1228        assert!(!w.dirty);
1229        w.key(key(KeyCode::Down));
1230        let WizardAction::Start(list) = w.key(key(KeyCode::Enter)) else {
1231            panic!("{:?}", w.error)
1232        };
1233        assert_eq!(list.name, "stables");
1234    }
1235
1236    #[test]
1237    fn add_target_says_how_when_the_line_is_wrong() {
1238        let dir = tempfile::tempdir().unwrap();
1239        let mut w = wizard(dir.path());
1240        // A fourth word is another venue, so `pair USDC binance extra` is an
1241        // unknown venue (in add_a_target_on_several_venues), not a usage error.
1242        for bad in ["pair", "address 0xabc ethereum base", "wallet 0xabc"] {
1243            w.key(key(KeyCode::Char('a')));
1244            typed(&mut w, bad);
1245            assert!(
1246                w.error.as_deref().unwrap().starts_with("type `pair USDC"),
1247                "{bad}: {:?}",
1248                w.error
1249            );
1250        }
1251        assert!(w.list.targets.is_empty());
1252        // An address's third word is always its chain, even a venue's name;
1253        // a token's is its venue when it names one.
1254        for line in ["ADDRESS 0xabc binance", "token PEPE WEEX"] {
1255            w.key(key(KeyCode::Char('a')));
1256            typed(&mut w, line);
1257        }
1258        let [a, t] = &w.list.targets[..] else {
1259            panic!("{:?}", w.list.targets)
1260        };
1261        assert_eq!(
1262            (a.kind, a.chain.as_deref(), a.venues.is_empty()),
1263            (TargetKind::Address, Some("binance"), true)
1264        );
1265        assert_eq!(
1266            (t.venues.as_slice(), t.preset),
1267            (["WEEX".to_string()].as_slice(), Some(Preset::MarketHealth))
1268        );
1269    }
1270
1271    #[test]
1272    fn a_save_that_fails_says_why_and_stays_dirty() {
1273        let dir = tempfile::tempdir().unwrap();
1274        // The folder is a file: it cannot hold the watchlist.
1275        let file = dir.path().join("not-a-folder");
1276        std::fs::write(&file, "").unwrap();
1277        let mut w = wizard(&file);
1278        w.key(key(KeyCode::Char('a')));
1279        typed(&mut w, "pair USDC binance");
1280        assert_eq!(w.key(ctrl('s')), WizardAction::None);
1281        assert!(
1282            w.error.as_deref().unwrap().starts_with("Could not save"),
1283            "{:?}",
1284            w.error
1285        );
1286        assert!(w.dirty);
1287        assert!(screen(&w, 120, 36).contains("Could not save"));
1288    }
1289
1290    #[test]
1291    fn draws_the_targets_the_line_being_typed_and_the_question() {
1292        let dir = tempfile::tempdir().unwrap();
1293        let mut w = wizard(dir.path());
1294        let s = screen(&w, 120, 36);
1295        assert!(s.contains("No targets yet: press a to add one."), "{s}");
1296        assert!(!s.contains("Ready"), "nothing to start: {s}");
1297        for t in ["address 0xabc ethereum", "token PEPE"] {
1298            w.key(key(KeyCode::Char('a')));
1299            typed(&mut w, t);
1300        }
1301        w.list.targets[1]
1302            .rules
1303            .push(default_rule(MetricId::Holders));
1304        let s = screen(&w, 120, 36);
1305        assert!(s.contains("0xabc on ethereum"), "{s}");
1306        assert!(s.contains("PEPE  1 rule(s)"), "{s}");
1307        for (kind, label) in [
1308            (EditKind::AddTarget, "Add"),
1309            (EditKind::Value(MetricId::Holders), "holders value"),
1310            (EditKind::Interval, "Interval"),
1311            (EditKind::Duration, "Duration"),
1312            (EditKind::Name, "Name"),
1313            (EditKind::Csv, "CSV"),
1314            (EditKind::Report, "Report"),
1315        ] {
1316            let hint = kind == EditKind::AddTarget;
1317            w.edit = Some((kind, "abc".to_string()));
1318            let s = screen(&w, 120, 36);
1319            assert!(s.contains(&format!(" {label}: abc█")), "{label}\n{s}");
1320            assert_eq!(s.contains("Enter adds · Esc cancels"), hint, "{s}");
1321        }
1322        w.edit = None;
1323        w.key(key(KeyCode::Esc));
1324        assert!(screen(&w, 120, 36).contains("Leave without saving? [y/N]"));
1325        w.key(key(KeyCode::Char('n')));
1326        w.dirty = false;
1327        let s = screen(&w, 120, 36);
1328        assert!(!s.contains("Leave without saving"), "{s}");
1329        assert!(!s.contains("Ready"), "the address checks nothing: {s}");
1330        w.list.targets[0]
1331            .rules
1332            .push(default_rule(MetricId::RiskScore));
1333        let s = screen(&w, 120, 36);
1334        assert!(s.contains("Ready: Ctrl-R starts the run."), "{s}");
1335    }
1336}