Skip to main content

scope_cli/cli/tui/
grid.rs

1//! The watchlist grid (#47): the live view of a running watchlist.
2//!
3//! One row per target, breached rows first. A breach that starts rings the
4//! bell once and shows a banner until the next key. `Enter` opens the
5//! detail of the selected row: each cell with its value, its status and a
6//! chart of its last values. Evidence keys put the matching CLI command in
7//! the shell and run it there.
8//!
9//! The state is pure: the event loop feeds it [`GridMsg`]s from the run and
10//! keys from the user; [`draw`] draws it.
11
12use crate::cli::charts::{HealthStrip, Sparkline};
13use crate::cli::output::ProgressHook;
14use crate::cli::theme::{ACCENT, ERR, KEY, OK, SECONDARY, WARN};
15use crate::cli::watch::{Ready, Tick};
16use crossterm::event::{KeyCode, KeyEvent};
17use ratatui::Frame;
18use ratatui::layout::{Constraint, Layout, Rect};
19use ratatui::style::{Modifier, Style};
20use ratatui::text::{Line, Span};
21use ratatui::widgets::{Block, Borders, Paragraph};
22use scope::watch::{
23    Event, RuleStatus, Snapshot, TargetKind, TargetSnapshot, TargetStatus, Watchlist,
24};
25use std::collections::{HashMap, VecDeque};
26
27/// The most values a cell keeps for its chart.
28const HISTORY: usize = 120;
29
30/// Rows a cell's chart takes in the detail pane.
31const CHART_ROWS: u16 = 1;
32
33/// The width of the target column.
34const TARGET_COLS: usize = 24;
35
36/// The key legend of the grid and of the detail pane.
37pub const GRID_LEGEND: &str = "↑↓ select · Enter detail · Ctrl-G shell · Esc stop the run";
38pub const DETAIL_LEGEND: &str = "b book · t trades · h holders · r risk · x address · Esc back";
39pub const ENDED_LEGEND: &str = "Esc or Ctrl-G back to the shell · Enter detail";
40
41/// Shown while a run uses the settings from before `setup` saved new ones.
42pub const STALE_NOTE: &str = "The settings changed: this run still uses the old ones. Stop it (Esc) and start it again to use them.";
43
44/// What the run sends the grid.
45#[derive(Debug, Clone)]
46pub enum GridMsg {
47    /// The targets are resolved; the first tick starts.
48    Ready(Ready),
49    /// One tick.
50    Tick {
51        run: u64,
52        tick: Box<Tick>,
53        events: Vec<Event>,
54    },
55}
56
57/// What a key in the grid asks the event loop to do.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum GridAction {
60    None,
61    /// Show the shell screen; the run goes on.
62    Shell,
63    /// Stop the run (it stays on screen with its footer).
64    Stop,
65    /// Leave the finished grid for the shell.
66    Close,
67    /// Run this CLI command in the shell.
68    Run(String),
69}
70
71/// The grid's state.
72#[derive(Debug)]
73pub struct GridState {
74    /// The watchlist as the user gave it.
75    pub list: Watchlist,
76    /// The watchlist as it runs, once resolved.
77    pub ready: Option<Ready>,
78    /// The last tick, once there is one.
79    pub last: Option<Tick>,
80    /// The run number of the last tick.
81    pub run: u64,
82    /// Each run's health, oldest first (true = no breach).
83    pub health: Vec<bool>,
84    /// The last values of each cell, by target and cell label.
85    history: HashMap<(String, String), VecDeque<Option<f64>>>,
86    /// Targets whose breach cleared on the last tick: drawn dim once.
87    cleared: Vec<String>,
88    /// The banner of the last breach that started, until the next key.
89    pub banner: Option<String>,
90    /// True once to ring the bell for a new breach; the loop takes it.
91    pub bell: bool,
92    /// The selected row, in sorted order.
93    pub selected: usize,
94    /// The detail pane of the selected row is open.
95    pub detail: bool,
96    /// The footer once the run ended (`Done`, `Stopped`, or the error).
97    pub ended: Option<(String, Style)>,
98    /// The run's progress (run N of M, next in Ns).
99    pub progress: Option<ProgressHook>,
100    /// `setup` saved new settings while the run went on: the run still
101    /// uses the ones it started with.
102    pub stale: bool,
103}
104
105impl GridState {
106    /// A grid for `list`, before its first tick.
107    pub fn new(list: Watchlist, progress: Option<ProgressHook>) -> Self {
108        Self {
109            list,
110            ready: None,
111            last: None,
112            run: 0,
113            health: Vec::new(),
114            history: HashMap::new(),
115            cleared: Vec::new(),
116            banner: None,
117            bell: false,
118            selected: 0,
119            detail: false,
120            ended: None,
121            progress,
122            stale: false,
123        }
124    }
125
126    /// Takes one message from the run.
127    pub fn apply(&mut self, msg: GridMsg) {
128        match msg {
129            GridMsg::Ready(ready) => self.ready = Some(ready),
130            GridMsg::Tick { run, tick, events } => {
131                self.run = run;
132                self.health.push(tick.snapshot.breaches() == 0);
133                for t in &tick.snapshot.targets {
134                    for c in &t.cells {
135                        let h = self
136                            .history
137                            .entry((t.target.clone(), c.label.clone()))
138                            .or_default();
139                        h.push_back(c.value);
140                        if h.len() > HISTORY {
141                            h.pop_front();
142                        }
143                    }
144                }
145                self.cleared.clear();
146                for e in &events {
147                    match e {
148                        Event::BreachStarted { .. } => {
149                            self.banner = Some(e.banner());
150                            self.bell = true;
151                        }
152                        Event::BreachCleared { target, .. } => self.cleared.push(target.clone()),
153                        _ => {}
154                    }
155                }
156                self.last = Some(*tick);
157            }
158        }
159    }
160
161    /// The run ended: `footer` says how.
162    pub fn end(&mut self, footer: String, style: Style) {
163        self.ended = Some((footer, style));
164    }
165
166    /// Whether the run still goes on.
167    pub fn is_running(&self) -> bool {
168        self.ended.is_none()
169    }
170
171    /// How many targets are breached now.
172    pub fn breaches(&self) -> usize {
173        self.last.as_ref().map_or(0, |t| t.snapshot.breaches())
174    }
175
176    /// The rows in grid order: breached, warned, unknown, passed; the
177    /// watchlist order within each.
178    pub fn rows(&self) -> Vec<&TargetSnapshot> {
179        let mut rows: Vec<&TargetSnapshot> = self
180            .last
181            .as_ref()
182            .map(|t| t.snapshot.targets.iter().collect())
183            .unwrap_or_default();
184        rows.sort_by_key(|r| r.status);
185        rows
186    }
187
188    /// The target of the selected row, from the watchlist.
189    fn selected_target(&self) -> Option<&scope::watch::Target> {
190        let label = self.rows().get(self.selected)?.target.clone();
191        let list = self.ready.as_ref().map_or(&self.list, |r| &r.list);
192        list.targets.iter().find(|t| t.label() == label)
193    }
194
195    /// The CLI command an evidence key runs for the selected target.
196    pub fn evidence(&self, key: char) -> Option<String> {
197        let t = self.selected_target()?;
198        let venue = t
199            .venue
200            .as_deref()
201            .map(|v| format!(" --venue {v}"))
202            .unwrap_or_default();
203        let chain = t
204            .chain
205            .as_deref()
206            .map(|c| format!(" --chain {c}"))
207            .unwrap_or_default();
208        let id = quote(&t.id);
209        match (key, t.kind) {
210            ('b', TargetKind::Token | TargetKind::Pair) if t.venue.is_some() => {
211                Some(format!("market summary {id}{venue}"))
212            }
213            ('t', TargetKind::Token | TargetKind::Pair) if t.venue.is_some() => {
214                Some(format!("market trades {id}{venue}"))
215            }
216            ('h', TargetKind::Token) => Some(format!("crawl {id}{chain}")),
217            ('r', TargetKind::Address) => Some(format!("compliance risk {id}{chain}")),
218            ('x', TargetKind::Address) => Some(format!("address {id}{chain} --include-txs")),
219            _ => None,
220        }
221    }
222
223    /// Applies one key.
224    pub fn key(&mut self, key: KeyEvent) -> GridAction {
225        // Any key dismisses the banner, and still does its job.
226        self.banner = None;
227        let n = self.rows().len();
228        match key.code {
229            KeyCode::Down if n > 0 => self.selected = (self.selected + 1) % n,
230            KeyCode::Up if n > 0 => self.selected = (self.selected + n - 1) % n,
231            KeyCode::Enter if n > 0 => self.detail = !self.detail,
232            KeyCode::Esc if self.detail => self.detail = false,
233            KeyCode::Esc if self.is_running() => return GridAction::Stop,
234            KeyCode::Esc => return GridAction::Close,
235            KeyCode::Char(c) if self.detail => {
236                if let Some(line) = self.evidence(c) {
237                    return GridAction::Run(line);
238                }
239            }
240            _ => {}
241        }
242        GridAction::None
243    }
244
245    /// The status-line part for the shell screen while a run goes on.
246    pub fn status_part(&self) -> String {
247        let state = if self.is_running() {
248            "watch"
249        } else {
250            "watch ended"
251        };
252        let old = if self.stale && self.is_running() {
253            " · old settings"
254        } else {
255            ""
256        };
257        format!(
258            "{state} {}: {} breached{old} · Ctrl-G grid",
259            self.list.name,
260            self.breaches()
261        )
262    }
263
264    /// The footer: progress while it runs, or how it ended.
265    fn footer(&self) -> (String, Style) {
266        if let Some(ended) = &self.ended {
267            return ended.clone();
268        }
269        let Some(hook) = &self.progress else {
270            return (format!("run {}", self.run), SECONDARY);
271        };
272        let p = hook.lock().unwrap_or_else(|e| e.into_inner()).clone();
273        let of = p.total.map(|t| format!(" of {t}")).unwrap_or_default();
274        let next = p
275            .next_at
276            .map(|at| {
277                let secs = at
278                    .saturating_duration_since(std::time::Instant::now())
279                    .as_secs();
280                format!(" · next in {secs}s")
281            })
282            .unwrap_or_else(|| " · fetching".to_string());
283        if p.run == 0 {
284            return ("resolving the targets…".to_string(), SECONDARY);
285        }
286        (format!("run {}{of}{next}", p.run), SECONDARY)
287    }
288}
289
290/// A word with a space or a quote, quoted for the shell line.
291fn quote(s: &str) -> String {
292    if s.contains([' ', '"', '\'']) {
293        format!("\"{}\"", s.replace('"', "\\\""))
294    } else {
295        s.to_string()
296    }
297}
298
299/// A status's glyph and style.
300fn status_cell(s: &RuleStatus) -> (&'static str, Style) {
301    match s {
302        RuleStatus::Pass => ("✓", OK),
303        RuleStatus::Warn(_) => ("!", WARN),
304        RuleStatus::Fail(_) => ("✗", ERR),
305        RuleStatus::NotApplicable(_) => ("-", SECONDARY),
306        RuleStatus::Error(_) => ("?", WARN),
307    }
308}
309
310/// A target status's style.
311fn target_style(s: TargetStatus) -> Style {
312    match s {
313        TargetStatus::Breached => ERR.add_modifier(Modifier::BOLD),
314        TargetStatus::Warned => WARN,
315        TargetStatus::Unknown => WARN,
316        TargetStatus::Passed => OK,
317    }
318}
319
320/// A value as the grid shows it.
321fn value_text(v: Option<f64>) -> String {
322    match v {
323        None => "-".to_string(),
324        Some(v) if v.abs() >= 10_000.0 => scope::display::format::format_large_number(v),
325        // The decimals of the value's size (`price_decimals`), so a tiny
326        // price keeps its digits: PEPE at 0.0000106 read as `0` before.
327        Some(v) => {
328            let s = scope::display::format::format_price(v);
329            if s.contains('.') {
330                s.trim_end_matches('0').trim_end_matches('.').to_string()
331            } else {
332                s
333            }
334        }
335    }
336}
337
338/// Draws the grid in `area`.
339pub fn draw(f: &mut Frame, area: Rect, g: &GridState) {
340    let (footer, footer_style) = g.footer();
341    let block = Block::new()
342        .borders(Borders::TOP | Borders::BOTTOM)
343        .border_style(ACCENT)
344        .title(Line::styled(
345            format!(" watchlist {} ", g.list.name),
346            ACCENT.add_modifier(Modifier::BOLD),
347        ))
348        .title(
349            Line::styled(
350                format!(" {} breached ", g.breaches()),
351                target_style(if g.breaches() > 0 {
352                    TargetStatus::Breached
353                } else {
354                    TargetStatus::Passed
355                }),
356            )
357            .right_aligned(),
358        );
359    let inner = block.inner(area);
360    f.render_widget(block, area);
361    let stale = g.stale && g.is_running();
362    let banner_rows = u16::from(g.banner.is_some()) + u16::from(stale);
363    let [banner, body, strip, foot, legend] = Layout::vertical([
364        Constraint::Length(banner_rows),
365        Constraint::Min(1),
366        Constraint::Length(1),
367        Constraint::Length(1),
368        Constraint::Length(1),
369    ])
370    .areas(inner);
371    let mut banners = Vec::new();
372    if let Some(text) = &g.banner {
373        banners.push(Line::styled(
374            format!(" ⚠ {text}"),
375            ERR.add_modifier(Modifier::BOLD),
376        ));
377    }
378    if stale {
379        banners.push(Line::styled(format!(" {STALE_NOTE}"), WARN));
380    }
381    if !banners.is_empty() {
382        f.render_widget(Paragraph::new(banners), banner);
383    }
384    if g.detail {
385        draw_detail(f, body, g);
386    } else {
387        draw_rows(f, body, g);
388    }
389    f.render_widget(HealthStrip::new(&g.health), strip);
390    f.render_widget(
391        Paragraph::new(Line::styled(format!(" {footer}"), footer_style)),
392        foot,
393    );
394    let keys = match (g.detail, g.is_running()) {
395        (true, _) => DETAIL_LEGEND,
396        (false, true) => GRID_LEGEND,
397        (false, false) => ENDED_LEGEND,
398    };
399    f.render_widget(
400        Paragraph::new(Line::styled(format!(" {keys}"), KEY)),
401        legend,
402    );
403}
404
405/// The rows: target, status, then one glyph per cell.
406fn draw_rows(f: &mut Frame, area: Rect, g: &GridState) {
407    let rows = g.rows();
408    if rows.is_empty() {
409        let text = if g.ready.is_none() && g.is_running() {
410            " Resolving the targets…"
411        } else {
412            " Waiting for the first tick…"
413        };
414        f.render_widget(Paragraph::new(Line::styled(text, SECONDARY)), area);
415        return;
416    }
417    let height = area.height as usize;
418    let first = g.selected.saturating_sub(height.saturating_sub(1));
419    let lines: Vec<Line> = rows
420        .iter()
421        .enumerate()
422        .skip(first)
423        .take(height)
424        .map(|(i, r)| {
425            let marker = if i == g.selected { "> " } else { "  " };
426            let mut style = target_style(r.status);
427            if g.cleared.contains(&r.target) {
428                style = style.add_modifier(Modifier::DIM);
429            }
430            let mut spans = vec![
431                Span::styled(marker, KEY),
432                Span::styled(
433                    format!("{:<w$.w$} ", r.target, w = TARGET_COLS),
434                    if i == g.selected {
435                        style.add_modifier(Modifier::REVERSED)
436                    } else {
437                        style
438                    },
439                ),
440                Span::styled(format!("{:<9}", r.status.name()), style),
441            ];
442            for c in &r.cells {
443                let (glyph, st) = status_cell(&c.status);
444                spans.push(Span::styled(glyph, st));
445            }
446            // The first problem, so the row says why without the detail.
447            if let Some((why, st)) = r
448                .cells
449                .iter()
450                .find(|c| matches!(c.status, RuleStatus::Fail(_)))
451                .or_else(|| {
452                    r.cells
453                        .iter()
454                        .find(|c| matches!(c.status, RuleStatus::Error(_) | RuleStatus::Warn(_)))
455                })
456                .and_then(|c| Some((c.status.message()?, status_cell(&c.status).1)))
457            {
458                spans.push(Span::styled(format!("  {why}"), st));
459            }
460            Line::from(spans)
461        })
462        .collect();
463    f.render_widget(Paragraph::new(lines), area);
464}
465
466/// The detail of the selected row: one line per cell, and a chart of the
467/// cell's last values when it has some.
468fn draw_detail(f: &mut Frame, area: Rect, g: &GridState) {
469    let rows = g.rows();
470    let Some(r) = rows.get(g.selected) else {
471        return;
472    };
473    let mut y = area.y;
474    let bottom = area.bottom();
475    let put = |f: &mut Frame, y: &mut u16, line: Line| {
476        if *y < bottom {
477            f.render_widget(Paragraph::new(line), Rect::new(area.x, *y, area.width, 1));
478            *y += 1;
479        }
480    };
481    put(
482        f,
483        &mut y,
484        Line::styled(
485            format!(" {} — {}", r.target, r.status.name()),
486            target_style(r.status),
487        ),
488    );
489    for c in &r.cells {
490        let (glyph, st) = status_cell(&c.status);
491        let mut spans = vec![
492            Span::styled(format!(" {glyph} "), st),
493            Span::styled(format!("{:<34.34} ", c.label), Style::new()),
494            Span::styled(format!("{:>12} ", value_text(c.value)), ACCENT),
495        ];
496        // A passing check has no status message; its text keeps the numbers.
497        if let Some(m) = c.status.message().or(c.detail.as_deref()) {
498            // A warning or failure reads in its colour; only n/a is dimmed.
499            let text = match c.status {
500                RuleStatus::Warn(_) | RuleStatus::Fail(_) | RuleStatus::Error(_) => st,
501                _ => SECONDARY,
502            };
503            spans.push(Span::styled(m.to_string(), text));
504        }
505        put(f, &mut y, Line::from(spans));
506        let values: Vec<Option<f64>> = g
507            .history
508            .get(&(r.target.clone(), c.label.clone()))
509            .map(|h| h.iter().copied().collect())
510            .unwrap_or_default();
511        if values.iter().any(Option::is_some) && y + CHART_ROWS <= bottom {
512            let threshold = c.rule.map(|rule| match rule.op {
513                scope::watch::Op::Lt | scope::watch::Op::Le => {
514                    scope::metrics::Threshold::Max(rule.value)
515                }
516                scope::watch::Op::Gt | scope::watch::Op::Ge => {
517                    scope::metrics::Threshold::Min(rule.value)
518                }
519                scope::watch::Op::Eq | scope::watch::Op::Ne => {
520                    scope::metrics::Threshold::Target(rule.value)
521                }
522            });
523            let chart = Rect::new(area.x + 3, y, area.width.saturating_sub(4), CHART_ROWS);
524            f.render_widget(Sparkline::new(&values, ACCENT).threshold(threshold), chart);
525            y += CHART_ROWS;
526        }
527    }
528}
529
530/// The scrollback summary when the grid closes.
531pub fn summary(g: &GridState) -> String {
532    let runs = g.health.len();
533    let failed = g.health.iter().filter(|h| !**h).count();
534    format!(
535        "watchlist {}: {runs} run(s) · {failed} with a breach · {} target(s) breached at the end",
536        g.list.name,
537        g.breaches()
538    )
539}
540
541/// The snapshot as plain text, for the scrollback copy when the grid closes.
542pub fn snapshot_text(s: &Snapshot) -> String {
543    let mut text = String::new();
544    for t in &s.targets {
545        text.push_str(&format!(
546            "{:<w$} {}\n",
547            t.target,
548            t.status.name(),
549            w = TARGET_COLS
550        ));
551        for c in &t.cells {
552            let detail = c.status.message().unwrap_or("");
553            text.push_str(&format!(
554                "    {:<5} {:<34} {:>12} {}\n",
555                c.status.name(),
556                c.label,
557                value_text(c.value),
558                detail
559            ));
560        }
561    }
562    text
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use crossterm::event::KeyModifiers;
569
570    #[test]
571    fn test_a_tiny_value_keeps_its_digits() {
572        // The 0.8.0 smoke test: PEPE's price read as 0.
573        assert_eq!(value_text(Some(0.0000106)), "0.0000106");
574        assert_eq!(value_text(Some(1.5)), "1.5");
575        assert_eq!(value_text(Some(42.0)), "42");
576        assert_eq!(value_text(Some(25_000.0)), "25.00K");
577        assert_eq!(value_text(None), "-");
578    }
579    use ratatui::Terminal;
580    use ratatui::backend::TestBackend;
581    use scope::watch::{Cell, Rule, Target};
582
583    fn key(code: KeyCode) -> KeyEvent {
584        KeyEvent::new(code, KeyModifiers::NONE)
585    }
586
587    fn cell(label: &str, status: RuleStatus, value: Option<f64>) -> Cell {
588        Cell {
589            label: label.into(),
590            rule: Rule::parse(label).ok(),
591            value,
592            status,
593            detail: None,
594        }
595    }
596
597    fn list() -> Watchlist {
598        let mut w = Watchlist::new("stables");
599        let mut a = Target::new(TargetKind::Pair, "USDC");
600        a.venue = Some("binance".into());
601        let mut b = Target::new(TargetKind::Address, "0xabc");
602        b.chain = Some("ethereum".into());
603        let c = Target::new(TargetKind::Token, "PEPE");
604        w.targets = vec![a, b, c];
605        w
606    }
607
608    fn snapshot(spread: f64) -> Snapshot {
609        let fail = spread > 1.0;
610        Snapshot {
611            targets: vec![
612                TargetSnapshot::new(
613                    "USDC@binance",
614                    vec![cell(
615                        "spread_pct < 1",
616                        if fail {
617                            RuleStatus::Fail(format!("spread_pct {spread}, needs < 1"))
618                        } else {
619                            RuleStatus::Pass
620                        },
621                        Some(spread),
622                    )],
623                ),
624                TargetSnapshot::new(
625                    "0xabc",
626                    vec![cell("risk_score <= 5", RuleStatus::Pass, Some(2.0))],
627                ),
628                TargetSnapshot::new(
629                    "PEPE",
630                    vec![cell(
631                        "holders > 1",
632                        RuleStatus::Error("timeout".into()),
633                        None,
634                    )],
635                ),
636            ],
637        }
638    }
639
640    fn tick(g: &mut GridState, run: u64, spread: f64) {
641        let prev = g
642            .last
643            .as_ref()
644            .map(|t| t.snapshot.clone())
645            .unwrap_or_default();
646        let s = snapshot(spread);
647        let events = scope::watch::diff(&prev, &s);
648        g.apply(GridMsg::Tick {
649            run,
650            tick: Box::new(Tick {
651                snapshot: s,
652                summaries: vec![None, None, None],
653            }),
654            events,
655        });
656    }
657
658    fn screen(g: &GridState, w: u16, h: u16) -> String {
659        let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
660        t.draw(|f| draw(f, f.area(), g)).unwrap();
661        let buf = t.backend().buffer().clone();
662        let mut s = String::new();
663        for y in 0..h {
664            for x in 0..w {
665                s.push_str(buf[(x, y)].symbol());
666            }
667            s.push('\n');
668        }
669        s
670    }
671
672    #[test]
673    fn rows_sort_breached_then_unknown_then_passed() {
674        let mut g = GridState::new(list(), None);
675        tick(&mut g, 1, 3.4);
676        let order: Vec<&str> = g.rows().iter().map(|r| r.target.as_str()).collect();
677        assert_eq!(order, ["USDC@binance", "PEPE", "0xabc"]);
678        assert_eq!(g.breaches(), 1);
679    }
680
681    #[test]
682    fn a_new_breach_shows_a_banner_and_rings_once_and_a_key_clears_it() {
683        let mut g = GridState::new(list(), None);
684        tick(&mut g, 1, 0.5);
685        assert!(g.banner.is_none() && !g.bell);
686        tick(&mut g, 2, 3.4);
687        assert_eq!(
688            g.banner.as_deref(),
689            Some("USDC@binance: spread_pct 3.4, needs < 1")
690        );
691        assert!(std::mem::take(&mut g.bell));
692        tick(&mut g, 3, 3.5);
693        assert!(!g.bell, "the same breach does not ring again");
694        g.key(key(KeyCode::Down));
695        assert!(g.banner.is_none());
696    }
697
698    #[test]
699    fn a_cleared_row_is_dim_for_one_tick() {
700        let mut g = GridState::new(list(), None);
701        tick(&mut g, 1, 3.4);
702        tick(&mut g, 2, 0.5);
703        assert_eq!(g.cleared, vec!["USDC@binance".to_string()]);
704        tick(&mut g, 3, 0.5);
705        assert!(g.cleared.is_empty());
706    }
707
708    #[test]
709    fn esc_stops_a_run_then_closes_and_backs_out_of_the_detail_first() {
710        let mut g = GridState::new(list(), None);
711        tick(&mut g, 1, 0.5);
712        assert_eq!(g.key(key(KeyCode::Enter)), GridAction::None);
713        assert!(g.detail);
714        assert_eq!(g.key(key(KeyCode::Esc)), GridAction::None);
715        assert!(!g.detail);
716        assert_eq!(g.key(key(KeyCode::Esc)), GridAction::Stop);
717        g.end("Stopped.".into(), SECONDARY);
718        assert_eq!(g.key(key(KeyCode::Esc)), GridAction::Close);
719    }
720
721    #[test]
722    fn evidence_keys_run_the_matching_command() {
723        let mut g = GridState::new(list(), None);
724        tick(&mut g, 1, 3.4);
725        g.key(key(KeyCode::Enter));
726        assert_eq!(
727            g.key(key(KeyCode::Char('b'))),
728            GridAction::Run("market summary USDC --venue binance".into())
729        );
730        assert_eq!(
731            g.key(key(KeyCode::Char('t'))),
732            GridAction::Run("market trades USDC --venue binance".into())
733        );
734        assert_eq!(
735            g.key(key(KeyCode::Char('r'))),
736            GridAction::None,
737            "not for a pair"
738        );
739        // Sorted order: USDC, PEPE, 0xabc.
740        g.key(key(KeyCode::Esc));
741        g.key(key(KeyCode::Down));
742        g.key(key(KeyCode::Enter));
743        assert_eq!(
744            g.key(key(KeyCode::Char('h'))),
745            GridAction::Run("crawl PEPE".into())
746        );
747        g.key(key(KeyCode::Esc));
748        g.key(key(KeyCode::Down));
749        g.key(key(KeyCode::Enter));
750        assert_eq!(
751            g.key(key(KeyCode::Char('r'))),
752            GridAction::Run("compliance risk 0xabc --chain ethereum".into())
753        );
754        assert_eq!(
755            g.key(key(KeyCode::Char('x'))),
756            GridAction::Run("address 0xabc --chain ethereum --include-txs".into())
757        );
758    }
759
760    #[test]
761    fn draws_rows_banner_detail_and_footer_at_several_sizes() {
762        let mut g = GridState::new(list(), None);
763        tick(&mut g, 1, 0.5);
764        tick(&mut g, 2, 3.4);
765        for (w, h) in [(120, 36), (80, 24), (40, 10)] {
766            let s = screen(&g, w, h);
767            assert!(s.contains("watchlist stables"), "{w}x{h}\n{s}");
768            assert!(s.contains("USDC@binance"), "{w}x{h}\n{s}");
769        }
770        let s = screen(&g, 120, 36);
771        assert!(
772            s.contains("⚠ USDC@binance: spread_pct 3.4, needs < 1"),
773            "{s}"
774        );
775        assert!(s.contains("breached"), "{s}");
776        assert!(s.contains("Esc stop the run"), "{s}");
777        g.key(key(KeyCode::Enter));
778        let s = screen(&g, 120, 36);
779        assert!(s.contains("spread_pct < 1"), "{s}");
780        assert!(s.contains("b book"), "{s}");
781        // The chart of the two values.
782        assert!(s.contains('█') || s.contains('▁'), "{s}");
783    }
784
785    #[test]
786    fn summary_and_snapshot_text() {
787        let mut g = GridState::new(list(), None);
788        tick(&mut g, 1, 3.4);
789        tick(&mut g, 2, 0.5);
790        assert_eq!(
791            summary(&g),
792            "watchlist stables: 2 run(s) · 1 with a breach · 0 target(s) breached at the end"
793        );
794        let text = snapshot_text(&g.last.as_ref().unwrap().snapshot);
795        assert!(text.contains("USDC@binance"), "{text}");
796        assert!(
797            text.contains("error timeout") || text.contains("timeout"),
798            "{text}"
799        );
800    }
801
802    #[test]
803    fn a_stale_run_says_to_restart_it_until_it_ends() {
804        let mut g = GridState::new(list(), None);
805        tick(&mut g, 1, 0.5);
806        g.stale = true;
807        assert!(screen(&g, 120, 36).contains("The settings changed"));
808        assert!(g.status_part().contains("old settings"));
809        g.end("Stopped.".into(), SECONDARY);
810        assert!(!screen(&g, 120, 36).contains("The settings changed"));
811    }
812
813    #[test]
814    fn status_part_names_the_list_and_the_breaches() {
815        let mut g = GridState::new(list(), None);
816        tick(&mut g, 1, 3.4);
817        assert_eq!(g.status_part(), "watch stables: 1 breached · Ctrl-G grid");
818    }
819
820    /// One tick of hand-made rows, with no events.
821    fn tick_rows(g: &mut GridState, run: u64, targets: Vec<TargetSnapshot>) {
822        let summaries = targets.iter().map(|_| None).collect();
823        g.apply(GridMsg::Tick {
824            run,
825            tick: Box::new(Tick {
826                snapshot: Snapshot { targets },
827                summaries,
828            }),
829            events: Vec::new(),
830        });
831    }
832
833    /// The row of `s` that holds `text`.
834    fn line_with<'a>(s: &'a str, text: &str) -> &'a str {
835        s.lines().find(|l| l.contains(text)).unwrap_or("")
836    }
837
838    #[test]
839    fn a_cell_keeps_its_last_values_only() {
840        let mut g = GridState::new(list(), None);
841        for run in 1..=HISTORY as u64 + 10 {
842            tick(&mut g, run, run as f64 / 100.0);
843        }
844        let h = &g.history[&("USDC@binance".to_string(), "spread_pct < 1".to_string())];
845        assert_eq!(h.len(), HISTORY);
846        assert_eq!(h.front(), Some(&Some(0.11)), "the oldest ten are gone");
847        assert_eq!(h.back(), Some(&Some(1.3)));
848    }
849
850    #[test]
851    fn up_and_down_wrap_and_do_nothing_with_no_rows() {
852        let mut g = GridState::new(list(), None);
853        g.key(key(KeyCode::Up));
854        assert_eq!(g.selected, 0, "no rows yet");
855        assert_eq!(g.key(key(KeyCode::Enter)), GridAction::None);
856        assert!(!g.detail, "no row to open");
857        tick(&mut g, 1, 3.4);
858        g.key(key(KeyCode::Up));
859        assert_eq!(g.selected, 2, "up from the first row is the last");
860        g.key(key(KeyCode::Up));
861        assert_eq!(g.selected, 1);
862        g.key(key(KeyCode::Down));
863        g.key(key(KeyCode::Down));
864        assert_eq!(g.selected, 0, "down from the last row is the first");
865    }
866
867    #[test]
868    fn an_ended_run_says_so_in_the_status_part_and_drops_the_old_settings_note() {
869        let mut g = GridState::new(list(), None);
870        tick(&mut g, 1, 3.4);
871        g.stale = true;
872        g.end("Done.".into(), OK);
873        assert!(!g.is_running());
874        assert_eq!(
875            g.status_part(),
876            "watch ended stables: 1 breached · Ctrl-G grid"
877        );
878        let s = screen(&g, 120, 36);
879        assert!(s.contains("Done."), "{s}");
880        assert!(s.contains(ENDED_LEGEND), "{s}");
881    }
882
883    #[test]
884    fn an_id_with_a_space_or_a_quote_is_quoted_in_the_command() {
885        assert_eq!(quote("PEPE"), "PEPE");
886        assert_eq!(quote("MY TOKEN"), "\"MY TOKEN\"");
887        assert_eq!(quote("a\"b"), "\"a\\\"b\"");
888        let mut w = Watchlist::new("odd");
889        let mut t = Target::new(TargetKind::Token, "MY TOKEN");
890        t.chain = Some("solana".into());
891        w.targets = vec![t];
892        let label = w.targets[0].label();
893        let mut g = GridState::new(w, None);
894        tick_rows(
895            &mut g,
896            1,
897            vec![TargetSnapshot::new(
898                label,
899                vec![cell("holders > 1", RuleStatus::Pass, Some(5.0))],
900            )],
901        );
902        g.key(key(KeyCode::Enter));
903        assert_eq!(
904            g.key(key(KeyCode::Char('h'))),
905            GridAction::Run("crawl \"MY TOKEN\" --chain solana".into())
906        );
907    }
908
909    #[test]
910    fn a_warned_row_comes_before_a_passed_one_and_shows_its_warning() {
911        let mut g = GridState::new(list(), None);
912        tick_rows(
913            &mut g,
914            1,
915            vec![
916                TargetSnapshot::new(
917                    "0xabc",
918                    vec![cell("risk_score <= 5", RuleStatus::Pass, Some(2.0))],
919                ),
920                TargetSnapshot::new(
921                    "USDC@binance",
922                    vec![
923                        cell("spread_pct < 1", RuleStatus::Warn("thin book".into()), None),
924                        cell(
925                            "holders > 1",
926                            RuleStatus::NotApplicable("not a token".into()),
927                            None,
928                        ),
929                    ],
930                ),
931            ],
932        );
933        let order: Vec<&str> = g.rows().iter().map(|r| r.target.as_str()).collect();
934        assert_eq!(order, ["USDC@binance", "0xabc"]);
935        assert_eq!(g.breaches(), 0, "a warning is not a breach");
936        let s = screen(&g, 120, 36);
937        let row = line_with(&s, "USDC@binance");
938        assert!(row.contains("warned"), "{row}");
939        assert!(
940            row.contains("!-  thin book"),
941            "one glyph per cell, then why: {row}"
942        );
943        let row = line_with(&s, "0xabc");
944        assert!(row.contains("passed") && row.contains('✓'), "{row}");
945        // The detail: the warning reads in its colour, n/a dimmed.
946        g.key(key(KeyCode::Enter));
947        let mut t = Terminal::new(TestBackend::new(120, 36)).unwrap();
948        t.draw(|f| draw(f, f.area(), &g)).unwrap();
949        let buf = t.backend().buffer().clone();
950        let style_of = |needle: &str| {
951            (0..36u16)
952                .find_map(|y| {
953                    let row: String = (0..120u16).map(|x| buf[(x, y)].symbol()).collect();
954                    let x = row.find(needle)?;
955                    // Columns, not bytes: the glyphs before are multi-byte.
956                    let col = row[..x].chars().count() as u16;
957                    (row.matches(needle).count() == 1 && !row.contains("warned"))
958                        .then(|| buf[(col, y)].style())
959                })
960                .unwrap_or_else(|| panic!("no {needle} in the detail"))
961        };
962        assert_eq!(style_of("thin book").fg, WARN.fg);
963        assert!(style_of("not a token").add_modifier.contains(Modifier::DIM));
964    }
965
966    #[test]
967    fn with_no_rows_it_says_what_it_waits_for() {
968        let mut g = GridState::new(list(), None);
969        assert!(screen(&g, 80, 24).contains("Resolving the targets…"));
970        g.apply(GridMsg::Ready(Ready {
971            list: list(),
972            contracts: vec![None, None, None],
973        }));
974        assert!(screen(&g, 80, 24).contains("Waiting for the first tick…"));
975        // A run that ended before it resolved waits for nothing.
976        let mut g = GridState::new(list(), None);
977        g.end("Stopped.".into(), SECONDARY);
978        assert!(screen(&g, 80, 24).contains("Waiting for the first tick…"));
979    }
980
981    #[test]
982    fn a_cleared_row_is_drawn_dim_for_one_tick() {
983        let mut g = GridState::new(list(), None);
984        tick(&mut g, 1, 3.4);
985        tick(&mut g, 2, 0.5);
986        let dim = |g: &GridState| {
987            let mut t = Terminal::new(TestBackend::new(80, 24)).unwrap();
988            t.draw(|f| draw(f, f.area(), g)).unwrap();
989            let buf = t.backend().buffer().clone();
990            // The rows start under the title; the target name after `> `.
991            (0..24).any(|y| {
992                let row: String = (0..80).map(|x| buf[(x, y)].symbol()).collect();
993                row.contains("USDC@binance")
994                    && buf[(row.find("USDC").unwrap() as u16, y)]
995                        .modifier
996                        .contains(Modifier::DIM)
997            })
998        };
999        assert!(dim(&g), "cleared on this tick");
1000        tick(&mut g, 3, 0.5);
1001        assert!(!dim(&g), "only once");
1002    }
1003
1004    #[test]
1005    fn the_detail_draws_the_threshold_of_each_rule_on_its_chart() {
1006        let mut g = GridState::new(list(), None);
1007        for run in 1..=3 {
1008            let v = run as f64;
1009            tick_rows(
1010                &mut g,
1011                run,
1012                vec![TargetSnapshot::new(
1013                    "PEPE",
1014                    vec![
1015                        cell("holders > 10", RuleStatus::Pass, Some(20.0 + v)),
1016                        cell("holders == 22", RuleStatus::Pass, Some(20.0 + v)),
1017                        cell("holders != 99", RuleStatus::Pass, Some(20.0 + v)),
1018                        cell("risk_score <= 5", RuleStatus::Pass, Some(v)),
1019                    ],
1020                )],
1021            );
1022        }
1023        g.key(key(KeyCode::Enter));
1024        let s = screen(&g, 80, 24);
1025        let lines: Vec<&str> = s.lines().collect();
1026        for label in [
1027            "holders > 10",
1028            "holders == 22",
1029            "holders != 99",
1030            "risk_score <= 5",
1031        ] {
1032            let i = lines.iter().position(|l| l.contains(label)).unwrap();
1033            let chart = lines[i + 1];
1034            assert!(chart.contains('─'), "{label}: the threshold line\n{s}");
1035            assert!(
1036                chart.chars().any(|c| ('▁'..='█').contains(&c)),
1037                "{label}: the bars\n{s}"
1038            );
1039        }
1040    }
1041
1042    #[test]
1043    fn a_value_that_is_not_a_number_reads_as_such() {
1044        assert_eq!(value_text(Some(f64::NAN)), "NaN");
1045    }
1046}