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) = 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| c.status.message())
457            {
458                spans.push(Span::styled(format!("  {why}"), SECONDARY));
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            spans.push(Span::styled(m.to_string(), SECONDARY));
499        }
500        put(f, &mut y, Line::from(spans));
501        let values: Vec<Option<f64>> = g
502            .history
503            .get(&(r.target.clone(), c.label.clone()))
504            .map(|h| h.iter().copied().collect())
505            .unwrap_or_default();
506        if values.iter().any(Option::is_some) && y + CHART_ROWS <= bottom {
507            let threshold = c.rule.map(|rule| match rule.op {
508                scope::watch::Op::Lt | scope::watch::Op::Le => {
509                    scope::metrics::Threshold::Max(rule.value)
510                }
511                scope::watch::Op::Gt | scope::watch::Op::Ge => {
512                    scope::metrics::Threshold::Min(rule.value)
513                }
514                scope::watch::Op::Eq | scope::watch::Op::Ne => {
515                    scope::metrics::Threshold::Target(rule.value)
516                }
517            });
518            let chart = Rect::new(area.x + 3, y, area.width.saturating_sub(4), CHART_ROWS);
519            f.render_widget(Sparkline::new(&values, ACCENT).threshold(threshold), chart);
520            y += CHART_ROWS;
521        }
522    }
523}
524
525/// The scrollback summary when the grid closes.
526pub fn summary(g: &GridState) -> String {
527    let runs = g.health.len();
528    let failed = g.health.iter().filter(|h| !**h).count();
529    format!(
530        "watchlist {}: {runs} run(s) · {failed} with a breach · {} target(s) breached at the end",
531        g.list.name,
532        g.breaches()
533    )
534}
535
536/// The snapshot as plain text, for the scrollback copy when the grid closes.
537pub fn snapshot_text(s: &Snapshot) -> String {
538    let mut text = String::new();
539    for t in &s.targets {
540        text.push_str(&format!(
541            "{:<w$} {}\n",
542            t.target,
543            t.status.name(),
544            w = TARGET_COLS
545        ));
546        for c in &t.cells {
547            let detail = c.status.message().unwrap_or("");
548            text.push_str(&format!(
549                "    {:<5} {:<34} {:>12} {}\n",
550                c.status.name(),
551                c.label,
552                value_text(c.value),
553                detail
554            ));
555        }
556    }
557    text
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    use crossterm::event::KeyModifiers;
564
565    #[test]
566    fn test_a_tiny_value_keeps_its_digits() {
567        // The 0.8.0 smoke test: PEPE's price read as 0.
568        assert_eq!(value_text(Some(0.0000106)), "0.0000106");
569        assert_eq!(value_text(Some(1.5)), "1.5");
570        assert_eq!(value_text(Some(42.0)), "42");
571        assert_eq!(value_text(Some(25_000.0)), "25.00K");
572        assert_eq!(value_text(None), "-");
573    }
574    use ratatui::Terminal;
575    use ratatui::backend::TestBackend;
576    use scope::watch::{Cell, Rule, Target};
577
578    fn key(code: KeyCode) -> KeyEvent {
579        KeyEvent::new(code, KeyModifiers::NONE)
580    }
581
582    fn cell(label: &str, status: RuleStatus, value: Option<f64>) -> Cell {
583        Cell {
584            label: label.into(),
585            rule: Rule::parse(label).ok(),
586            value,
587            status,
588            detail: None,
589        }
590    }
591
592    fn list() -> Watchlist {
593        let mut w = Watchlist::new("stables");
594        let mut a = Target::new(TargetKind::Pair, "USDC");
595        a.venue = Some("binance".into());
596        let mut b = Target::new(TargetKind::Address, "0xabc");
597        b.chain = Some("ethereum".into());
598        let c = Target::new(TargetKind::Token, "PEPE");
599        w.targets = vec![a, b, c];
600        w
601    }
602
603    fn snapshot(spread: f64) -> Snapshot {
604        let fail = spread > 1.0;
605        Snapshot {
606            targets: vec![
607                TargetSnapshot::new(
608                    "USDC@binance",
609                    vec![cell(
610                        "spread_pct < 1",
611                        if fail {
612                            RuleStatus::Fail(format!("spread_pct {spread}, needs < 1"))
613                        } else {
614                            RuleStatus::Pass
615                        },
616                        Some(spread),
617                    )],
618                ),
619                TargetSnapshot::new(
620                    "0xabc",
621                    vec![cell("risk_score <= 5", RuleStatus::Pass, Some(2.0))],
622                ),
623                TargetSnapshot::new(
624                    "PEPE",
625                    vec![cell(
626                        "holders > 1",
627                        RuleStatus::Error("timeout".into()),
628                        None,
629                    )],
630                ),
631            ],
632        }
633    }
634
635    fn tick(g: &mut GridState, run: u64, spread: f64) {
636        let prev = g
637            .last
638            .as_ref()
639            .map(|t| t.snapshot.clone())
640            .unwrap_or_default();
641        let s = snapshot(spread);
642        let events = scope::watch::diff(&prev, &s);
643        g.apply(GridMsg::Tick {
644            run,
645            tick: Box::new(Tick {
646                snapshot: s,
647                summaries: vec![None, None, None],
648            }),
649            events,
650        });
651    }
652
653    fn screen(g: &GridState, w: u16, h: u16) -> String {
654        let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
655        t.draw(|f| draw(f, f.area(), g)).unwrap();
656        let buf = t.backend().buffer().clone();
657        let mut s = String::new();
658        for y in 0..h {
659            for x in 0..w {
660                s.push_str(buf[(x, y)].symbol());
661            }
662            s.push('\n');
663        }
664        s
665    }
666
667    #[test]
668    fn rows_sort_breached_then_unknown_then_passed() {
669        let mut g = GridState::new(list(), None);
670        tick(&mut g, 1, 3.4);
671        let order: Vec<&str> = g.rows().iter().map(|r| r.target.as_str()).collect();
672        assert_eq!(order, ["USDC@binance", "PEPE", "0xabc"]);
673        assert_eq!(g.breaches(), 1);
674    }
675
676    #[test]
677    fn a_new_breach_shows_a_banner_and_rings_once_and_a_key_clears_it() {
678        let mut g = GridState::new(list(), None);
679        tick(&mut g, 1, 0.5);
680        assert!(g.banner.is_none() && !g.bell);
681        tick(&mut g, 2, 3.4);
682        assert_eq!(
683            g.banner.as_deref(),
684            Some("USDC@binance: spread_pct 3.4, needs < 1")
685        );
686        assert!(std::mem::take(&mut g.bell));
687        tick(&mut g, 3, 3.5);
688        assert!(!g.bell, "the same breach does not ring again");
689        g.key(key(KeyCode::Down));
690        assert!(g.banner.is_none());
691    }
692
693    #[test]
694    fn a_cleared_row_is_dim_for_one_tick() {
695        let mut g = GridState::new(list(), None);
696        tick(&mut g, 1, 3.4);
697        tick(&mut g, 2, 0.5);
698        assert_eq!(g.cleared, vec!["USDC@binance".to_string()]);
699        tick(&mut g, 3, 0.5);
700        assert!(g.cleared.is_empty());
701    }
702
703    #[test]
704    fn esc_stops_a_run_then_closes_and_backs_out_of_the_detail_first() {
705        let mut g = GridState::new(list(), None);
706        tick(&mut g, 1, 0.5);
707        assert_eq!(g.key(key(KeyCode::Enter)), GridAction::None);
708        assert!(g.detail);
709        assert_eq!(g.key(key(KeyCode::Esc)), GridAction::None);
710        assert!(!g.detail);
711        assert_eq!(g.key(key(KeyCode::Esc)), GridAction::Stop);
712        g.end("Stopped.".into(), SECONDARY);
713        assert_eq!(g.key(key(KeyCode::Esc)), GridAction::Close);
714    }
715
716    #[test]
717    fn evidence_keys_run_the_matching_command() {
718        let mut g = GridState::new(list(), None);
719        tick(&mut g, 1, 3.4);
720        g.key(key(KeyCode::Enter));
721        assert_eq!(
722            g.key(key(KeyCode::Char('b'))),
723            GridAction::Run("market summary USDC --venue binance".into())
724        );
725        assert_eq!(
726            g.key(key(KeyCode::Char('t'))),
727            GridAction::Run("market trades USDC --venue binance".into())
728        );
729        assert_eq!(
730            g.key(key(KeyCode::Char('r'))),
731            GridAction::None,
732            "not for a pair"
733        );
734        // Sorted order: USDC, PEPE, 0xabc.
735        g.key(key(KeyCode::Esc));
736        g.key(key(KeyCode::Down));
737        g.key(key(KeyCode::Enter));
738        assert_eq!(
739            g.key(key(KeyCode::Char('h'))),
740            GridAction::Run("crawl PEPE".into())
741        );
742        g.key(key(KeyCode::Esc));
743        g.key(key(KeyCode::Down));
744        g.key(key(KeyCode::Enter));
745        assert_eq!(
746            g.key(key(KeyCode::Char('r'))),
747            GridAction::Run("compliance risk 0xabc --chain ethereum".into())
748        );
749        assert_eq!(
750            g.key(key(KeyCode::Char('x'))),
751            GridAction::Run("address 0xabc --chain ethereum --include-txs".into())
752        );
753    }
754
755    #[test]
756    fn draws_rows_banner_detail_and_footer_at_several_sizes() {
757        let mut g = GridState::new(list(), None);
758        tick(&mut g, 1, 0.5);
759        tick(&mut g, 2, 3.4);
760        for (w, h) in [(120, 36), (80, 24), (40, 10)] {
761            let s = screen(&g, w, h);
762            assert!(s.contains("watchlist stables"), "{w}x{h}\n{s}");
763            assert!(s.contains("USDC@binance"), "{w}x{h}\n{s}");
764        }
765        let s = screen(&g, 120, 36);
766        assert!(
767            s.contains("⚠ USDC@binance: spread_pct 3.4, needs < 1"),
768            "{s}"
769        );
770        assert!(s.contains("breached"), "{s}");
771        assert!(s.contains("Esc stop the run"), "{s}");
772        g.key(key(KeyCode::Enter));
773        let s = screen(&g, 120, 36);
774        assert!(s.contains("spread_pct < 1"), "{s}");
775        assert!(s.contains("b book"), "{s}");
776        // The chart of the two values.
777        assert!(s.contains('█') || s.contains('▁'), "{s}");
778    }
779
780    #[test]
781    fn summary_and_snapshot_text() {
782        let mut g = GridState::new(list(), None);
783        tick(&mut g, 1, 3.4);
784        tick(&mut g, 2, 0.5);
785        assert_eq!(
786            summary(&g),
787            "watchlist stables: 2 run(s) · 1 with a breach · 0 target(s) breached at the end"
788        );
789        let text = snapshot_text(&g.last.as_ref().unwrap().snapshot);
790        assert!(text.contains("USDC@binance"), "{text}");
791        assert!(
792            text.contains("error timeout") || text.contains("timeout"),
793            "{text}"
794        );
795    }
796
797    #[test]
798    fn a_stale_run_says_to_restart_it_until_it_ends() {
799        let mut g = GridState::new(list(), None);
800        tick(&mut g, 1, 0.5);
801        g.stale = true;
802        assert!(screen(&g, 120, 36).contains("The settings changed"));
803        assert!(g.status_part().contains("old settings"));
804        g.end("Stopped.".into(), SECONDARY);
805        assert!(!screen(&g, 120, 36).contains("The settings changed"));
806    }
807
808    #[test]
809    fn status_part_names_the_list_and_the_breaches() {
810        let mut g = GridState::new(list(), None);
811        tick(&mut g, 1, 3.4);
812        assert_eq!(g.status_part(), "watch stables: 1 breached · Ctrl-G grid");
813    }
814
815    /// One tick of hand-made rows, with no events.
816    fn tick_rows(g: &mut GridState, run: u64, targets: Vec<TargetSnapshot>) {
817        let summaries = targets.iter().map(|_| None).collect();
818        g.apply(GridMsg::Tick {
819            run,
820            tick: Box::new(Tick {
821                snapshot: Snapshot { targets },
822                summaries,
823            }),
824            events: Vec::new(),
825        });
826    }
827
828    /// The row of `s` that holds `text`.
829    fn line_with<'a>(s: &'a str, text: &str) -> &'a str {
830        s.lines().find(|l| l.contains(text)).unwrap_or("")
831    }
832
833    #[test]
834    fn a_cell_keeps_its_last_values_only() {
835        let mut g = GridState::new(list(), None);
836        for run in 1..=HISTORY as u64 + 10 {
837            tick(&mut g, run, run as f64 / 100.0);
838        }
839        let h = &g.history[&("USDC@binance".to_string(), "spread_pct < 1".to_string())];
840        assert_eq!(h.len(), HISTORY);
841        assert_eq!(h.front(), Some(&Some(0.11)), "the oldest ten are gone");
842        assert_eq!(h.back(), Some(&Some(1.3)));
843    }
844
845    #[test]
846    fn up_and_down_wrap_and_do_nothing_with_no_rows() {
847        let mut g = GridState::new(list(), None);
848        g.key(key(KeyCode::Up));
849        assert_eq!(g.selected, 0, "no rows yet");
850        assert_eq!(g.key(key(KeyCode::Enter)), GridAction::None);
851        assert!(!g.detail, "no row to open");
852        tick(&mut g, 1, 3.4);
853        g.key(key(KeyCode::Up));
854        assert_eq!(g.selected, 2, "up from the first row is the last");
855        g.key(key(KeyCode::Up));
856        assert_eq!(g.selected, 1);
857        g.key(key(KeyCode::Down));
858        g.key(key(KeyCode::Down));
859        assert_eq!(g.selected, 0, "down from the last row is the first");
860    }
861
862    #[test]
863    fn an_ended_run_says_so_in_the_status_part_and_drops_the_old_settings_note() {
864        let mut g = GridState::new(list(), None);
865        tick(&mut g, 1, 3.4);
866        g.stale = true;
867        g.end("Done.".into(), OK);
868        assert!(!g.is_running());
869        assert_eq!(
870            g.status_part(),
871            "watch ended stables: 1 breached · Ctrl-G grid"
872        );
873        let s = screen(&g, 120, 36);
874        assert!(s.contains("Done."), "{s}");
875        assert!(s.contains(ENDED_LEGEND), "{s}");
876    }
877
878    #[test]
879    fn an_id_with_a_space_or_a_quote_is_quoted_in_the_command() {
880        assert_eq!(quote("PEPE"), "PEPE");
881        assert_eq!(quote("MY TOKEN"), "\"MY TOKEN\"");
882        assert_eq!(quote("a\"b"), "\"a\\\"b\"");
883        let mut w = Watchlist::new("odd");
884        let mut t = Target::new(TargetKind::Token, "MY TOKEN");
885        t.chain = Some("solana".into());
886        w.targets = vec![t];
887        let label = w.targets[0].label();
888        let mut g = GridState::new(w, None);
889        tick_rows(
890            &mut g,
891            1,
892            vec![TargetSnapshot::new(
893                label,
894                vec![cell("holders > 1", RuleStatus::Pass, Some(5.0))],
895            )],
896        );
897        g.key(key(KeyCode::Enter));
898        assert_eq!(
899            g.key(key(KeyCode::Char('h'))),
900            GridAction::Run("crawl \"MY TOKEN\" --chain solana".into())
901        );
902    }
903
904    #[test]
905    fn a_warned_row_comes_before_a_passed_one_and_shows_its_warning() {
906        let mut g = GridState::new(list(), None);
907        tick_rows(
908            &mut g,
909            1,
910            vec![
911                TargetSnapshot::new(
912                    "0xabc",
913                    vec![cell("risk_score <= 5", RuleStatus::Pass, Some(2.0))],
914                ),
915                TargetSnapshot::new(
916                    "USDC@binance",
917                    vec![
918                        cell("spread_pct < 1", RuleStatus::Warn("thin book".into()), None),
919                        cell(
920                            "holders > 1",
921                            RuleStatus::NotApplicable("not a token".into()),
922                            None,
923                        ),
924                    ],
925                ),
926            ],
927        );
928        let order: Vec<&str> = g.rows().iter().map(|r| r.target.as_str()).collect();
929        assert_eq!(order, ["USDC@binance", "0xabc"]);
930        assert_eq!(g.breaches(), 0, "a warning is not a breach");
931        let s = screen(&g, 120, 36);
932        let row = line_with(&s, "USDC@binance");
933        assert!(row.contains("warned"), "{row}");
934        assert!(
935            row.contains("!-  thin book"),
936            "one glyph per cell, then why: {row}"
937        );
938        let row = line_with(&s, "0xabc");
939        assert!(row.contains("passed") && row.contains('✓'), "{row}");
940    }
941
942    #[test]
943    fn with_no_rows_it_says_what_it_waits_for() {
944        let mut g = GridState::new(list(), None);
945        assert!(screen(&g, 80, 24).contains("Resolving the targets…"));
946        g.apply(GridMsg::Ready(Ready {
947            list: list(),
948            contracts: vec![None, None, None],
949        }));
950        assert!(screen(&g, 80, 24).contains("Waiting for the first tick…"));
951        // A run that ended before it resolved waits for nothing.
952        let mut g = GridState::new(list(), None);
953        g.end("Stopped.".into(), SECONDARY);
954        assert!(screen(&g, 80, 24).contains("Waiting for the first tick…"));
955    }
956
957    #[test]
958    fn a_cleared_row_is_drawn_dim_for_one_tick() {
959        let mut g = GridState::new(list(), None);
960        tick(&mut g, 1, 3.4);
961        tick(&mut g, 2, 0.5);
962        let dim = |g: &GridState| {
963            let mut t = Terminal::new(TestBackend::new(80, 24)).unwrap();
964            t.draw(|f| draw(f, f.area(), g)).unwrap();
965            let buf = t.backend().buffer().clone();
966            // The rows start under the title; the target name after `> `.
967            (0..24).any(|y| {
968                let row: String = (0..80).map(|x| buf[(x, y)].symbol()).collect();
969                row.contains("USDC@binance")
970                    && buf[(row.find("USDC").unwrap() as u16, y)]
971                        .modifier
972                        .contains(Modifier::DIM)
973            })
974        };
975        assert!(dim(&g), "cleared on this tick");
976        tick(&mut g, 3, 0.5);
977        assert!(!dim(&g), "only once");
978    }
979
980    #[test]
981    fn the_detail_draws_the_threshold_of_each_rule_on_its_chart() {
982        let mut g = GridState::new(list(), None);
983        for run in 1..=3 {
984            let v = run as f64;
985            tick_rows(
986                &mut g,
987                run,
988                vec![TargetSnapshot::new(
989                    "PEPE",
990                    vec![
991                        cell("holders > 10", RuleStatus::Pass, Some(20.0 + v)),
992                        cell("holders == 22", RuleStatus::Pass, Some(20.0 + v)),
993                        cell("holders != 99", RuleStatus::Pass, Some(20.0 + v)),
994                        cell("risk_score <= 5", RuleStatus::Pass, Some(v)),
995                    ],
996                )],
997            );
998        }
999        g.key(key(KeyCode::Enter));
1000        let s = screen(&g, 80, 24);
1001        let lines: Vec<&str> = s.lines().collect();
1002        for label in [
1003            "holders > 10",
1004            "holders == 22",
1005            "holders != 99",
1006            "risk_score <= 5",
1007        ] {
1008            let i = lines.iter().position(|l| l.contains(label)).unwrap();
1009            let chart = lines[i + 1];
1010            assert!(chart.contains('─'), "{label}: the threshold line\n{s}");
1011            assert!(
1012                chart.chars().any(|c| ('▁'..='█').contains(&c)),
1013                "{label}: the bars\n{s}"
1014            );
1015        }
1016    }
1017
1018    #[test]
1019    fn a_value_that_is_not_a_number_reads_as_such() {
1020        assert_eq!(value_text(Some(f64::NAN)), "NaN");
1021    }
1022}