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. For a target on two or more venues, the
7//! detail is the venue charts (`venue_charts`): one chart per
8//! market cell with a line per venue. Evidence keys put the matching CLI
9//! command in the shell and run it there.
10//!
11//! The state is pure: the event loop feeds it [`GridMsg`]s from the run and
12//! keys from the user; [`draw`] draws it.
13
14use super::venue_charts::{self, Selection, Series, VenueChart, VenueCharts};
15use crate::cli::charts::{HealthStrip, Sparkline, enlarge_hint};
16use crate::cli::output::ProgressHook;
17use crate::cli::theme::{ACCENT, ERR, KEY, OK, SECONDARY, WARN};
18use crate::cli::watch::{Ready, Tick};
19use crossterm::event::{KeyCode, KeyEvent};
20use ratatui::Frame;
21use ratatui::layout::{Constraint, Layout, Rect};
22use ratatui::style::{Modifier, Style};
23use ratatui::text::{Line, Span};
24use ratatui::widgets::{Block, Borders, Paragraph};
25use scope::watch::{
26    Event, RuleStatus, Snapshot, TargetKind, TargetSnapshot, TargetStatus, Watchlist,
27};
28use std::collections::{HashMap, VecDeque};
29
30/// The most values a cell keeps for its chart.
31const HISTORY: usize = 120;
32
33/// Rows a cell's chart takes in the detail pane.
34const CHART_ROWS: u16 = 1;
35
36/// The width of the target column.
37const TARGET_COLS: usize = 24;
38
39/// The key legend of the grid and of the detail pane.
40pub const GRID_LEGEND: &str = "↑↓ select · Enter detail · Ctrl-G shell · Esc stop the run";
41pub const DETAIL_LEGEND: &str = "b book · t trades · h holders · r risk · x address · Esc back";
42pub const VENUE_DETAIL_LEGEND: &str =
43    "←→ venue · Space isolate · Tab chart · PgUp PgDn page · b book · t trades · Esc back";
44pub const ENDED_LEGEND: &str = "Esc or Ctrl-G back to the shell · Enter detail";
45
46/// Shown while a run uses the settings from before `setup` saved new ones.
47pub const STALE_NOTE: &str = "The settings changed: this run still uses the old ones. Stop it (Esc) and start it again to use them.";
48
49/// What the run sends the grid.
50#[derive(Debug, Clone)]
51pub enum GridMsg {
52    /// The targets are resolved; the first tick starts.
53    Ready(Ready),
54    /// One tick.
55    Tick {
56        run: u64,
57        tick: Box<Tick>,
58        events: Vec<Event>,
59    },
60}
61
62/// What a key in the grid asks the event loop to do.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum GridAction {
65    None,
66    /// Show the shell screen; the run goes on.
67    Shell,
68    /// Stop the run (it stays on screen with its footer).
69    Stop,
70    /// Leave the finished grid for the shell.
71    Close,
72    /// Run this CLI command in the shell.
73    Run(String),
74}
75
76/// The grid's state.
77#[derive(Debug)]
78pub struct GridState {
79    /// The watchlist as the user gave it.
80    pub list: Watchlist,
81    /// The watchlist as it runs, once resolved.
82    pub ready: Option<Ready>,
83    /// The last tick, once there is one.
84    pub last: Option<Tick>,
85    /// The run number of the last tick.
86    pub run: u64,
87    /// Each run's health, oldest first (true = no breach).
88    pub health: Vec<bool>,
89    /// The last values of each cell, by target and cell label.
90    history: HashMap<(String, String), VecDeque<Option<f64>>>,
91    /// Targets whose breach cleared on the last tick: drawn dim once.
92    cleared: Vec<String>,
93    /// The banner of the last breach that started, until the next key.
94    pub banner: Option<String>,
95    /// True once to ring the bell for a new breach; the loop takes it.
96    pub bell: bool,
97    /// The selected row, in sorted order.
98    pub selected: usize,
99    /// The detail pane of the selected row is open.
100    pub detail: bool,
101    /// The footer once the run ended (`Done`, `Stopped`, or the error).
102    pub ended: Option<(String, Style)>,
103    /// The run's progress (run N of M, next in Ns).
104    pub progress: Option<ProgressHook>,
105    /// `setup` saved new settings while the run went on: the run still
106    /// uses the ones it started with.
107    pub stale: bool,
108    /// What the keys select in the venue charts.
109    pub(crate) venues: Selection,
110    /// Charts per page at the last draw, for PgUp and PgDn.
111    page_size: std::cell::Cell<usize>,
112}
113
114impl GridState {
115    /// A grid for `list`, before its first tick.
116    pub fn new(list: Watchlist, progress: Option<ProgressHook>) -> Self {
117        Self {
118            list,
119            ready: None,
120            last: None,
121            run: 0,
122            health: Vec::new(),
123            history: HashMap::new(),
124            cleared: Vec::new(),
125            banner: None,
126            bell: false,
127            selected: 0,
128            detail: false,
129            ended: None,
130            progress,
131            stale: false,
132            venues: Selection::default(),
133            page_size: std::cell::Cell::new(1),
134        }
135    }
136
137    /// Takes one message from the run.
138    pub fn apply(&mut self, msg: GridMsg) {
139        match msg {
140            GridMsg::Ready(ready) => self.ready = Some(ready),
141            GridMsg::Tick { run, tick, events } => {
142                self.run = run;
143                self.health.push(tick.snapshot.breaches() == 0);
144                for t in &tick.snapshot.targets {
145                    for c in &t.cells {
146                        let h = self
147                            .history
148                            .entry((t.target.clone(), c.label.clone()))
149                            .or_default();
150                        h.push_back(c.value);
151                        if h.len() > HISTORY {
152                            h.pop_front();
153                        }
154                    }
155                }
156                self.cleared.clear();
157                for e in &events {
158                    match e {
159                        Event::BreachStarted { .. } => {
160                            self.banner = Some(e.banner());
161                            self.bell = true;
162                        }
163                        Event::BreachCleared { target, .. } => self.cleared.push(target.clone()),
164                        _ => {}
165                    }
166                }
167                self.last = Some(*tick);
168            }
169        }
170    }
171
172    /// The run ended: `footer` says how.
173    pub fn end(&mut self, footer: String, style: Style) {
174        self.ended = Some((footer, style));
175    }
176
177    /// Whether the run still goes on.
178    pub fn is_running(&self) -> bool {
179        self.ended.is_none()
180    }
181
182    /// How many targets are breached now.
183    pub fn breaches(&self) -> usize {
184        self.last.as_ref().map_or(0, |t| t.snapshot.breaches())
185    }
186
187    /// The rows in grid order: breached, warned, unknown, passed; the
188    /// watchlist order within each.
189    pub fn rows(&self) -> Vec<&TargetSnapshot> {
190        let mut rows: Vec<&TargetSnapshot> = self
191            .last
192            .as_ref()
193            .map(|t| t.snapshot.targets.iter().collect())
194            .unwrap_or_default();
195        rows.sort_by_key(|r| r.status);
196        rows
197    }
198
199    /// The target of the selected row, from the watchlist, and the row's
200    /// venue.
201    fn selected_target(&self) -> Option<(&scope::watch::Target, Option<String>)> {
202        let row = self.rows().get(self.selected).copied()?;
203        let list = self.ready.as_ref().map_or(&self.list, |r| &r.list);
204        let t = list
205            .targets
206            .iter()
207            .find(|t| t.rows().into_iter().any(|v| t.row_label(v) == row.target))?;
208        Some((t, row.venue.clone()))
209    }
210
211    /// The venue charts of the selected row: when the detail is open on a
212    /// market row of a target on two or more venues. One chart per cell of
213    /// the row, each with every venue's history of that cell.
214    pub(crate) fn venue_charts(&self) -> Option<VenueCharts> {
215        if !self.detail {
216            return None;
217        }
218        let rows = self.rows();
219        let row = rows.get(self.selected)?;
220        row.venue.as_ref()?;
221        let (t, _) = self.selected_target()?;
222        if t.venues.len() < 2 {
223            return None;
224        }
225        let last = self.last.as_ref()?;
226        let charts = row
227            .cells
228            .iter()
229            .map(|c| VenueChart {
230                label: c.label.clone(),
231                threshold: c.rule.map(rule_threshold),
232                unit: c.rule.map(|r| {
233                    scope::metrics::describe(r.metric, &scope::market::HealthThresholds::default())
234                        .unit
235                }),
236                series: t
237                    .venues
238                    .iter()
239                    .map(|v| {
240                        let label = t.row_label(Some(v));
241                        let values = self
242                            .history
243                            .get(&(label.clone(), c.label.clone()))
244                            .map(|h| h.iter().copied().collect())
245                            .unwrap_or_default();
246                        let status = last
247                            .snapshot
248                            .targets
249                            .iter()
250                            .find(|r| r.target == label)
251                            .and_then(|r| r.cells.iter().find(|x| x.label == c.label))
252                            .map_or_else(
253                                || RuleStatus::NotApplicable("no reading".to_string()),
254                                |x| x.status.clone(),
255                            );
256                        Series {
257                            venue: v.clone(),
258                            values,
259                            status,
260                        }
261                    })
262                    .collect(),
263            })
264            .filter(|c| {
265                c.series
266                    .iter()
267                    .any(|s| s.values.iter().any(Option::is_some))
268            })
269            .collect::<Vec<_>>();
270        (!charts.is_empty()).then_some(VenueCharts {
271            charts,
272            interval: self.list.interval,
273        })
274    }
275
276    /// Starts the venue charts on the venue of the selected row.
277    fn reset_venues(&mut self) {
278        let venue = self.selected_target().and_then(|(t, v)| {
279            let v = v?;
280            t.venues.iter().position(|x| *x == v)
281        });
282        self.venues = Selection {
283            venue: venue.unwrap_or(0),
284            ..Selection::default()
285        };
286    }
287
288    /// A key of the venue charts.
289    fn venue_key(&mut self, code: KeyCode, charts: &VenueCharts) {
290        let venues = charts.charts[0].series.len();
291        let n = charts.charts.len();
292        let page = self.page_size.get().max(1);
293        let s = &mut self.venues;
294        match code {
295            KeyCode::Right => s.venue = (s.venue + 1) % venues,
296            KeyCode::Left => s.venue = (s.venue + venues - 1) % venues,
297            KeyCode::Char(' ') => s.isolated = !s.isolated,
298            KeyCode::Tab => s.chart = (s.chart + 1) % n,
299            KeyCode::PageDown => s.chart = ((s.chart / page + 1) * page).min(n - 1),
300            KeyCode::PageUp => s.chart = (s.chart / page).saturating_sub(1) * page,
301            _ => {}
302        }
303    }
304
305    /// The CLI command an evidence key runs for the selected row; in the
306    /// venue charts, for the venue the legend selects.
307    pub fn evidence(&self, key: char) -> Option<String> {
308        let (t, row_venue) = self.selected_target()?;
309        let row_venue = match self.venue_charts() {
310            Some(_) => t.venues.get(self.venues.venue).cloned(),
311            None => row_venue,
312        };
313        let venue = row_venue
314            .as_deref()
315            .map(|v| format!(" --venue {v}"))
316            .unwrap_or_default();
317        let chain = t
318            .chain
319            .as_deref()
320            .map(|c| format!(" --chain {c}"))
321            .unwrap_or_default();
322        let id = quote(&t.id);
323        match (key, t.kind) {
324            ('b', TargetKind::Token | TargetKind::Pair) if row_venue.is_some() => {
325                Some(format!("market summary {id}{venue}"))
326            }
327            ('t', TargetKind::Token | TargetKind::Pair) if row_venue.is_some() => {
328                Some(format!("market trades {id}{venue}"))
329            }
330            ('h', TargetKind::Token) => Some(format!("crawl {id}{chain}")),
331            ('r', TargetKind::Address) => Some(format!("compliance risk {id}{chain}")),
332            ('x', TargetKind::Address) => Some(format!("address {id}{chain} --include-txs")),
333            _ => None,
334        }
335    }
336
337    /// Applies one key.
338    pub fn key(&mut self, key: KeyEvent) -> GridAction {
339        // Any key dismisses the banner, and still does its job.
340        self.banner = None;
341        let n = self.rows().len();
342        match key.code {
343            KeyCode::Down if n > 0 => {
344                self.selected = (self.selected + 1) % n;
345                self.reset_venues();
346            }
347            KeyCode::Up if n > 0 => {
348                self.selected = (self.selected + n - 1) % n;
349                self.reset_venues();
350            }
351            KeyCode::Enter if n > 0 => {
352                self.detail = !self.detail;
353                self.reset_venues();
354            }
355            KeyCode::Left
356            | KeyCode::Right
357            | KeyCode::Char(' ')
358            | KeyCode::Tab
359            | KeyCode::PageUp
360            | KeyCode::PageDown
361                if self.detail =>
362            {
363                if let Some(charts) = self.venue_charts() {
364                    self.venue_key(key.code, &charts);
365                }
366            }
367            KeyCode::Esc if self.detail => self.detail = false,
368            KeyCode::Esc if self.is_running() => return GridAction::Stop,
369            KeyCode::Esc => return GridAction::Close,
370            KeyCode::Char(c) if self.detail => {
371                if let Some(line) = self.evidence(c) {
372                    return GridAction::Run(line);
373                }
374            }
375            _ => {}
376        }
377        GridAction::None
378    }
379
380    /// The status-line part for the shell screen while a run goes on.
381    pub fn status_part(&self) -> String {
382        let state = if self.is_running() {
383            "watch"
384        } else {
385            "watch ended"
386        };
387        let old = if self.stale && self.is_running() {
388            " · old settings"
389        } else {
390            ""
391        };
392        format!(
393            "{state} {}: {} breached{old} · Ctrl-G grid",
394            self.list.name,
395            self.breaches()
396        )
397    }
398
399    /// The footer: progress while it runs, or how it ended.
400    fn footer(&self) -> (String, Style) {
401        if let Some(ended) = &self.ended {
402            return ended.clone();
403        }
404        let Some(hook) = &self.progress else {
405            return (format!("run {}", self.run), SECONDARY);
406        };
407        let p = hook.lock().unwrap_or_else(|e| e.into_inner()).clone();
408        let of = p.total.map(|t| format!(" of {t}")).unwrap_or_default();
409        let next = p
410            .next_at
411            .map(|at| {
412                let secs = at
413                    .saturating_duration_since(std::time::Instant::now())
414                    .as_secs();
415                format!(" · next in {secs}s")
416            })
417            .unwrap_or_else(|| " · fetching".to_string());
418        if p.run == 0 {
419            return ("resolving the targets…".to_string(), SECONDARY);
420        }
421        (format!("run {}{of}{next}", p.run), SECONDARY)
422    }
423}
424
425/// A word with a space or a quote, quoted for the shell line.
426fn quote(s: &str) -> String {
427    if s.contains([' ', '"', '\'']) {
428        format!("\"{}\"", s.replace('"', "\\\""))
429    } else {
430        s.to_string()
431    }
432}
433
434/// A status's glyph and style.
435pub(super) fn status_cell(s: &RuleStatus) -> (&'static str, Style) {
436    match s {
437        RuleStatus::Pass => ("✓", OK),
438        RuleStatus::Warn(_) => ("!", WARN),
439        RuleStatus::Fail(_) => ("✗", ERR),
440        RuleStatus::NotApplicable(_) => ("-", SECONDARY),
441        RuleStatus::Error(_) => ("?", WARN),
442    }
443}
444
445/// A target status's style.
446fn target_style(s: TargetStatus) -> Style {
447    match s {
448        TargetStatus::Breached => ERR.add_modifier(Modifier::BOLD),
449        TargetStatus::Warned => WARN,
450        TargetStatus::Unknown => WARN,
451        TargetStatus::Passed => OK,
452    }
453}
454
455/// A value as the grid shows it.
456pub(super) fn value_text(v: Option<f64>) -> String {
457    match v {
458        None => "-".to_string(),
459        Some(v) if v.abs() >= 10_000.0 => scope::display::format::format_large_number(v),
460        // The decimals of the value's size (`price_decimals`), so a tiny
461        // price keeps its digits: PEPE at 0.0000106 read as `0` before.
462        Some(v) => {
463            let s = scope::display::format::format_price(v);
464            if s.contains('.') {
465                s.trim_end_matches('0').trim_end_matches('.').to_string()
466            } else {
467                s
468            }
469        }
470    }
471}
472
473/// Draws the grid in `area`.
474pub fn draw(f: &mut Frame, area: Rect, g: &GridState) {
475    let (footer, footer_style) = g.footer();
476    let block = Block::new()
477        .borders(Borders::TOP | Borders::BOTTOM)
478        .border_style(ACCENT)
479        .title(Line::styled(
480            format!(" watchlist {} ", g.list.name),
481            ACCENT.add_modifier(Modifier::BOLD),
482        ))
483        .title(
484            Line::styled(
485                format!(" {} breached ", g.breaches()),
486                target_style(if g.breaches() > 0 {
487                    TargetStatus::Breached
488                } else {
489                    TargetStatus::Passed
490                }),
491            )
492            .right_aligned(),
493        );
494    let inner = block.inner(area);
495    f.render_widget(block, area);
496    let stale = g.stale && g.is_running();
497    let banner_rows = u16::from(g.banner.is_some()) + u16::from(stale);
498    let [banner, body, strip, foot, legend] = Layout::vertical([
499        Constraint::Length(banner_rows),
500        Constraint::Min(1),
501        Constraint::Length(1),
502        Constraint::Length(1),
503        Constraint::Length(1),
504    ])
505    .areas(inner);
506    let mut banners = Vec::new();
507    if let Some(text) = &g.banner {
508        banners.push(Line::styled(
509            format!(" ⚠ {text}"),
510            ERR.add_modifier(Modifier::BOLD),
511        ));
512    }
513    if stale {
514        banners.push(Line::styled(format!(" {STALE_NOTE}"), WARN));
515    }
516    if !banners.is_empty() {
517        f.render_widget(Paragraph::new(banners), banner);
518    }
519    if let Some(charts) = g.venue_charts() {
520        match venue_charts::shortfall(body, &charts) {
521            None => {
522                let mut sel = g.venues;
523                sel.venue = sel.venue.min(charts.charts[0].series.len() - 1);
524                sel.chart = sel.chart.min(charts.charts.len() - 1);
525                g.page_size.set(venue_charts::draw(f, body, &charts, sel));
526            }
527            // Too small for the charts: the one-venue detail, and how much
528            // to enlarge the window.
529            Some((cols, rows)) => {
530                let text = format!(" {}", enlarge_hint(cols, rows, "the venue charts"));
531                let hint_rows = text
532                    .chars()
533                    .count()
534                    .div_ceil(usize::from(body.width.max(1)));
535                let [detail, hint] =
536                    Layout::vertical([Constraint::Min(0), Constraint::Length(hint_rows as u16)])
537                        .areas(body);
538                draw_detail(f, detail, g);
539                f.render_widget(
540                    Paragraph::new(Line::styled(text, WARN))
541                        .wrap(ratatui::widgets::Wrap { trim: false }),
542                    hint,
543                );
544            }
545        }
546    } else if g.detail {
547        draw_detail(f, body, g);
548    } else {
549        draw_rows(f, body, g);
550    }
551    f.render_widget(HealthStrip::new(&g.health), strip);
552    f.render_widget(
553        Paragraph::new(Line::styled(format!(" {footer}"), footer_style)),
554        foot,
555    );
556    let keys = match (g.detail, g.is_running()) {
557        (true, _) if g.venue_charts().is_some() => VENUE_DETAIL_LEGEND,
558        (true, _) => DETAIL_LEGEND,
559        (false, true) => GRID_LEGEND,
560        (false, false) => ENDED_LEGEND,
561    };
562    f.render_widget(
563        Paragraph::new(Line::styled(format!(" {keys}"), KEY)),
564        legend,
565    );
566}
567
568/// The rows: target, status, then one glyph per cell.
569fn draw_rows(f: &mut Frame, area: Rect, g: &GridState) {
570    let rows = g.rows();
571    if rows.is_empty() {
572        let text = if g.ready.is_none() && g.is_running() {
573            " Resolving the targets…"
574        } else {
575            " Waiting for the first tick…"
576        };
577        f.render_widget(Paragraph::new(Line::styled(text, SECONDARY)), area);
578        return;
579    }
580    let height = area.height as usize;
581    let first = g.selected.saturating_sub(height.saturating_sub(1));
582    let lines: Vec<Line> = rows
583        .iter()
584        .enumerate()
585        .skip(first)
586        .take(height)
587        .map(|(i, r)| {
588            let marker = if i == g.selected { "> " } else { "  " };
589            let mut style = target_style(r.status);
590            if g.cleared.contains(&r.target) {
591                style = style.add_modifier(Modifier::DIM);
592            }
593            let mut spans = vec![
594                Span::styled(marker, KEY),
595                Span::styled(
596                    format!("{:<w$.w$} ", r.target, w = TARGET_COLS),
597                    if i == g.selected {
598                        style.add_modifier(Modifier::REVERSED)
599                    } else {
600                        style
601                    },
602                ),
603                Span::styled(format!("{:<9}", r.status.name()), style),
604            ];
605            for c in &r.cells {
606                let (glyph, st) = status_cell(&c.status);
607                spans.push(Span::styled(glyph, st));
608            }
609            // The first problem, so the row says why without the detail.
610            if let Some((why, st)) = r
611                .cells
612                .iter()
613                .find(|c| matches!(c.status, RuleStatus::Fail(_)))
614                .or_else(|| {
615                    r.cells
616                        .iter()
617                        .find(|c| matches!(c.status, RuleStatus::Error(_) | RuleStatus::Warn(_)))
618                })
619                .and_then(|c| Some((c.status.message()?, status_cell(&c.status).1)))
620            {
621                spans.push(Span::styled(format!("  {why}"), st));
622            }
623            Line::from(spans)
624        })
625        .collect();
626    f.render_widget(Paragraph::new(lines), area);
627}
628
629/// The detail of the selected row: one line per cell, and a chart of the
630/// cell's last values when it has some.
631fn draw_detail(f: &mut Frame, area: Rect, g: &GridState) {
632    let rows = g.rows();
633    let Some(r) = rows.get(g.selected) else {
634        return;
635    };
636    let mut y = area.y;
637    let bottom = area.bottom();
638    let put = |f: &mut Frame, y: &mut u16, line: Line| {
639        if *y < bottom {
640            f.render_widget(Paragraph::new(line), Rect::new(area.x, *y, area.width, 1));
641            *y += 1;
642        }
643    };
644    put(
645        f,
646        &mut y,
647        Line::styled(
648            format!(" {} — {}", r.target, r.status.name()),
649            target_style(r.status),
650        ),
651    );
652    for c in &r.cells {
653        let (glyph, st) = status_cell(&c.status);
654        let mut spans = vec![
655            Span::styled(format!(" {glyph} "), st),
656            Span::styled(format!("{:<34.34} ", c.label), Style::new()),
657            Span::styled(format!("{:>12} ", value_text(c.value)), ACCENT),
658        ];
659        // A passing check has no status message; its text keeps the numbers.
660        if let Some(m) = c.status.message().or(c.detail.as_deref()) {
661            // A warning or failure reads in its colour; only n/a is dimmed.
662            let text = match c.status {
663                RuleStatus::Warn(_) | RuleStatus::Fail(_) | RuleStatus::Error(_) => st,
664                _ => SECONDARY,
665            };
666            spans.push(Span::styled(m.to_string(), text));
667        }
668        put(f, &mut y, Line::from(spans));
669        let values: Vec<Option<f64>> = g
670            .history
671            .get(&(r.target.clone(), c.label.clone()))
672            .map(|h| h.iter().copied().collect())
673            .unwrap_or_default();
674        if values.iter().any(Option::is_some) && y + CHART_ROWS <= bottom {
675            let threshold = c.rule.map(rule_threshold);
676            let chart = Rect::new(area.x + 3, y, area.width.saturating_sub(4), CHART_ROWS);
677            f.render_widget(Sparkline::new(&values, ACCENT).threshold(threshold), chart);
678            y += CHART_ROWS;
679        }
680    }
681}
682
683/// The line a rule draws on its chart: its limit.
684fn rule_threshold(rule: scope::watch::Rule) -> scope::metrics::Threshold {
685    use scope::metrics::Threshold;
686    use scope::watch::Op;
687    match rule.op {
688        Op::Lt | Op::Le => Threshold::Max(rule.value),
689        Op::Gt | Op::Ge => Threshold::Min(rule.value),
690        Op::Eq | Op::Ne => Threshold::Target(rule.value),
691    }
692}
693
694/// The scrollback summary when the grid closes.
695pub fn summary(g: &GridState) -> String {
696    let runs = g.health.len();
697    let failed = g.health.iter().filter(|h| !**h).count();
698    format!(
699        "watchlist {}: {runs} run(s) · {failed} with a breach · {} target(s) breached at the end",
700        g.list.name,
701        g.breaches()
702    )
703}
704
705/// The snapshot as plain text, for the scrollback copy when the grid closes.
706pub fn snapshot_text(s: &Snapshot) -> String {
707    let mut text = String::new();
708    for t in &s.targets {
709        text.push_str(&format!(
710            "{:<w$} {}\n",
711            t.target,
712            t.status.name(),
713            w = TARGET_COLS
714        ));
715        for c in &t.cells {
716            let detail = c.status.message().unwrap_or("");
717            text.push_str(&format!(
718                "    {:<5} {:<34} {:>12} {}\n",
719                c.status.name(),
720                c.label,
721                value_text(c.value),
722                detail
723            ));
724        }
725    }
726    text
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732    use crossterm::event::KeyModifiers;
733
734    #[test]
735    fn test_a_tiny_value_keeps_its_digits() {
736        // The 0.8.0 smoke test: PEPE's price read as 0.
737        assert_eq!(value_text(Some(0.0000106)), "0.0000106");
738        assert_eq!(value_text(Some(1.5)), "1.5");
739        assert_eq!(value_text(Some(42.0)), "42");
740        assert_eq!(value_text(Some(25_000.0)), "25.00K");
741        assert_eq!(value_text(None), "-");
742    }
743    use crate::cli::theme::series_style;
744    use ratatui::Terminal;
745    use ratatui::backend::TestBackend;
746    use scope::watch::{Cell, Rule, Target};
747
748    fn key(code: KeyCode) -> KeyEvent {
749        KeyEvent::new(code, KeyModifiers::NONE)
750    }
751
752    fn cell(label: &str, status: RuleStatus, value: Option<f64>) -> Cell {
753        Cell {
754            label: label.into(),
755            rule: Rule::parse(label).ok(),
756            value,
757            status,
758            detail: None,
759        }
760    }
761
762    fn list() -> Watchlist {
763        let mut w = Watchlist::new("stables");
764        let mut a = Target::new(TargetKind::Pair, "USDC");
765        a.venues = vec!["binance".into()];
766        let mut b = Target::new(TargetKind::Address, "0xabc");
767        b.chain = Some("ethereum".into());
768        let c = Target::new(TargetKind::Token, "PEPE");
769        w.targets = vec![a, b, c];
770        w
771    }
772
773    fn snapshot(spread: f64) -> Snapshot {
774        let fail = spread > 1.0;
775        Snapshot {
776            targets: vec![
777                TargetSnapshot::new(
778                    "USDC@binance",
779                    vec![cell(
780                        "spread_pct < 1",
781                        if fail {
782                            RuleStatus::Fail(format!("spread_pct {spread}, needs < 1"))
783                        } else {
784                            RuleStatus::Pass
785                        },
786                        Some(spread),
787                    )],
788                )
789                .on_venue(Some("binance")),
790                TargetSnapshot::new(
791                    "0xabc",
792                    vec![cell("risk_score <= 5", RuleStatus::Pass, Some(2.0))],
793                ),
794                TargetSnapshot::new(
795                    "PEPE",
796                    vec![cell(
797                        "holders > 1",
798                        RuleStatus::Error("timeout".into()),
799                        None,
800                    )],
801                ),
802            ],
803        }
804    }
805
806    fn tick(g: &mut GridState, run: u64, spread: f64) {
807        let prev = g
808            .last
809            .as_ref()
810            .map(|t| t.snapshot.clone())
811            .unwrap_or_default();
812        let s = snapshot(spread);
813        let events = scope::watch::diff(&prev, &s);
814        g.apply(GridMsg::Tick {
815            run,
816            tick: Box::new(Tick {
817                snapshot: s,
818                summaries: vec![None, None, None],
819            }),
820            events,
821        });
822    }
823
824    fn screen(g: &GridState, w: u16, h: u16) -> String {
825        let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
826        t.draw(|f| draw(f, f.area(), g)).unwrap();
827        let buf = t.backend().buffer().clone();
828        let mut s = String::new();
829        for y in 0..h {
830            for x in 0..w {
831                s.push_str(buf[(x, y)].symbol());
832            }
833            s.push('\n');
834        }
835        s
836    }
837
838    #[test]
839    fn rows_sort_breached_then_unknown_then_passed() {
840        let mut g = GridState::new(list(), None);
841        tick(&mut g, 1, 3.4);
842        let order: Vec<&str> = g.rows().iter().map(|r| r.target.as_str()).collect();
843        assert_eq!(order, ["USDC@binance", "PEPE", "0xabc"]);
844        assert_eq!(g.breaches(), 1);
845    }
846
847    #[test]
848    fn a_new_breach_shows_a_banner_and_rings_once_and_a_key_clears_it() {
849        let mut g = GridState::new(list(), None);
850        tick(&mut g, 1, 0.5);
851        assert!(g.banner.is_none() && !g.bell);
852        tick(&mut g, 2, 3.4);
853        assert_eq!(
854            g.banner.as_deref(),
855            Some("USDC@binance: spread_pct 3.4, needs < 1")
856        );
857        assert!(std::mem::take(&mut g.bell));
858        tick(&mut g, 3, 3.5);
859        assert!(!g.bell, "the same breach does not ring again");
860        g.key(key(KeyCode::Down));
861        assert!(g.banner.is_none());
862    }
863
864    #[test]
865    fn a_cleared_row_is_dim_for_one_tick() {
866        let mut g = GridState::new(list(), None);
867        tick(&mut g, 1, 3.4);
868        tick(&mut g, 2, 0.5);
869        assert_eq!(g.cleared, vec!["USDC@binance".to_string()]);
870        tick(&mut g, 3, 0.5);
871        assert!(g.cleared.is_empty());
872    }
873
874    #[test]
875    fn esc_stops_a_run_then_closes_and_backs_out_of_the_detail_first() {
876        let mut g = GridState::new(list(), None);
877        tick(&mut g, 1, 0.5);
878        assert_eq!(g.key(key(KeyCode::Enter)), GridAction::None);
879        assert!(g.detail);
880        assert_eq!(g.key(key(KeyCode::Esc)), GridAction::None);
881        assert!(!g.detail);
882        assert_eq!(g.key(key(KeyCode::Esc)), GridAction::Stop);
883        g.end("Stopped.".into(), SECONDARY);
884        assert_eq!(g.key(key(KeyCode::Esc)), GridAction::Close);
885    }
886
887    // Design 2026-11-04, section 2.4: each venue row of one target runs
888    // its own venue's evidence.
889    #[test]
890    fn evidence_keys_use_the_venue_of_the_selected_row() {
891        let mut w = Watchlist::new("pegs");
892        let mut t = Target::new(TargetKind::Pair, "USDN");
893        t.venues = vec!["weex".into(), "binance".into()];
894        t.preset = Some(scope::watch::Preset::MarketHealth);
895        w.targets = vec![t];
896        let mut g = GridState::new(w, None);
897        let row = |venue: &str| {
898            TargetSnapshot::new(
899                format!("USDN@{venue}"),
900                vec![cell("spread_pct < 1", RuleStatus::Pass, Some(0.1))],
901            )
902            .on_venue(Some(venue))
903        };
904        tick_rows(&mut g, 1, vec![row("weex"), row("binance")]);
905        g.key(key(KeyCode::Down));
906        g.key(key(KeyCode::Enter));
907        assert_eq!(
908            g.key(key(KeyCode::Char('b'))),
909            GridAction::Run("market summary USDN --venue binance".into())
910        );
911    }
912
913    #[test]
914    fn evidence_keys_run_the_matching_command() {
915        let mut g = GridState::new(list(), None);
916        tick(&mut g, 1, 3.4);
917        g.key(key(KeyCode::Enter));
918        assert_eq!(
919            g.key(key(KeyCode::Char('b'))),
920            GridAction::Run("market summary USDC --venue binance".into())
921        );
922        assert_eq!(
923            g.key(key(KeyCode::Char('t'))),
924            GridAction::Run("market trades USDC --venue binance".into())
925        );
926        assert_eq!(
927            g.key(key(KeyCode::Char('r'))),
928            GridAction::None,
929            "not for a pair"
930        );
931        // Sorted order: USDC, PEPE, 0xabc.
932        g.key(key(KeyCode::Esc));
933        g.key(key(KeyCode::Down));
934        g.key(key(KeyCode::Enter));
935        assert_eq!(
936            g.key(key(KeyCode::Char('h'))),
937            GridAction::Run("crawl PEPE".into())
938        );
939        g.key(key(KeyCode::Esc));
940        g.key(key(KeyCode::Down));
941        g.key(key(KeyCode::Enter));
942        assert_eq!(
943            g.key(key(KeyCode::Char('r'))),
944            GridAction::Run("compliance risk 0xabc --chain ethereum".into())
945        );
946        assert_eq!(
947            g.key(key(KeyCode::Char('x'))),
948            GridAction::Run("address 0xabc --chain ethereum --include-txs".into())
949        );
950    }
951
952    #[test]
953    fn draws_rows_banner_detail_and_footer_at_several_sizes() {
954        let mut g = GridState::new(list(), None);
955        tick(&mut g, 1, 0.5);
956        tick(&mut g, 2, 3.4);
957        for (w, h) in [(120, 36), (80, 24), (40, 10)] {
958            let s = screen(&g, w, h);
959            assert!(s.contains("watchlist stables"), "{w}x{h}\n{s}");
960            assert!(s.contains("USDC@binance"), "{w}x{h}\n{s}");
961        }
962        let s = screen(&g, 120, 36);
963        assert!(
964            s.contains("⚠ USDC@binance: spread_pct 3.4, needs < 1"),
965            "{s}"
966        );
967        assert!(s.contains("breached"), "{s}");
968        assert!(s.contains("Esc stop the run"), "{s}");
969        g.key(key(KeyCode::Enter));
970        let s = screen(&g, 120, 36);
971        assert!(s.contains("spread_pct < 1"), "{s}");
972        assert!(s.contains("b book"), "{s}");
973        // The chart of the two values.
974        assert!(s.contains('█') || s.contains('▁'), "{s}");
975    }
976
977    #[test]
978    fn summary_and_snapshot_text() {
979        let mut g = GridState::new(list(), None);
980        tick(&mut g, 1, 3.4);
981        tick(&mut g, 2, 0.5);
982        assert_eq!(
983            summary(&g),
984            "watchlist stables: 2 run(s) · 1 with a breach · 0 target(s) breached at the end"
985        );
986        let text = snapshot_text(&g.last.as_ref().unwrap().snapshot);
987        assert!(text.contains("USDC@binance"), "{text}");
988        assert!(
989            text.contains("error timeout") || text.contains("timeout"),
990            "{text}"
991        );
992    }
993
994    #[test]
995    fn a_stale_run_says_to_restart_it_until_it_ends() {
996        let mut g = GridState::new(list(), None);
997        tick(&mut g, 1, 0.5);
998        g.stale = true;
999        assert!(screen(&g, 120, 36).contains("The settings changed"));
1000        assert!(g.status_part().contains("old settings"));
1001        g.end("Stopped.".into(), SECONDARY);
1002        assert!(!screen(&g, 120, 36).contains("The settings changed"));
1003    }
1004
1005    #[test]
1006    fn status_part_names_the_list_and_the_breaches() {
1007        let mut g = GridState::new(list(), None);
1008        tick(&mut g, 1, 3.4);
1009        assert_eq!(g.status_part(), "watch stables: 1 breached · Ctrl-G grid");
1010    }
1011
1012    /// One tick of hand-made rows, with no events.
1013    fn tick_rows(g: &mut GridState, run: u64, targets: Vec<TargetSnapshot>) {
1014        let summaries = targets.iter().map(|_| None).collect();
1015        g.apply(GridMsg::Tick {
1016            run,
1017            tick: Box::new(Tick {
1018                snapshot: Snapshot { targets },
1019                summaries,
1020            }),
1021            events: Vec::new(),
1022        });
1023    }
1024
1025    /// The row of `s` that holds `text`.
1026    fn line_with<'a>(s: &'a str, text: &str) -> &'a str {
1027        s.lines().find(|l| l.contains(text)).unwrap_or("")
1028    }
1029
1030    #[test]
1031    fn a_cell_keeps_its_last_values_only() {
1032        let mut g = GridState::new(list(), None);
1033        for run in 1..=HISTORY as u64 + 10 {
1034            tick(&mut g, run, run as f64 / 100.0);
1035        }
1036        let h = &g.history[&("USDC@binance".to_string(), "spread_pct < 1".to_string())];
1037        assert_eq!(h.len(), HISTORY);
1038        assert_eq!(h.front(), Some(&Some(0.11)), "the oldest ten are gone");
1039        assert_eq!(h.back(), Some(&Some(1.3)));
1040    }
1041
1042    #[test]
1043    fn up_and_down_wrap_and_do_nothing_with_no_rows() {
1044        let mut g = GridState::new(list(), None);
1045        g.key(key(KeyCode::Up));
1046        assert_eq!(g.selected, 0, "no rows yet");
1047        assert_eq!(g.key(key(KeyCode::Enter)), GridAction::None);
1048        assert!(!g.detail, "no row to open");
1049        tick(&mut g, 1, 3.4);
1050        g.key(key(KeyCode::Up));
1051        assert_eq!(g.selected, 2, "up from the first row is the last");
1052        g.key(key(KeyCode::Up));
1053        assert_eq!(g.selected, 1);
1054        g.key(key(KeyCode::Down));
1055        g.key(key(KeyCode::Down));
1056        assert_eq!(g.selected, 0, "down from the last row is the first");
1057    }
1058
1059    #[test]
1060    fn an_ended_run_says_so_in_the_status_part_and_drops_the_old_settings_note() {
1061        let mut g = GridState::new(list(), None);
1062        tick(&mut g, 1, 3.4);
1063        g.stale = true;
1064        g.end("Done.".into(), OK);
1065        assert!(!g.is_running());
1066        assert_eq!(
1067            g.status_part(),
1068            "watch ended stables: 1 breached · Ctrl-G grid"
1069        );
1070        let s = screen(&g, 120, 36);
1071        assert!(s.contains("Done."), "{s}");
1072        assert!(s.contains(ENDED_LEGEND), "{s}");
1073    }
1074
1075    #[test]
1076    fn an_id_with_a_space_or_a_quote_is_quoted_in_the_command() {
1077        assert_eq!(quote("PEPE"), "PEPE");
1078        assert_eq!(quote("MY TOKEN"), "\"MY TOKEN\"");
1079        assert_eq!(quote("a\"b"), "\"a\\\"b\"");
1080        let mut w = Watchlist::new("odd");
1081        let mut t = Target::new(TargetKind::Token, "MY TOKEN");
1082        t.chain = Some("solana".into());
1083        w.targets = vec![t];
1084        let label = w.targets[0].label();
1085        let mut g = GridState::new(w, None);
1086        tick_rows(
1087            &mut g,
1088            1,
1089            vec![TargetSnapshot::new(
1090                label,
1091                vec![cell("holders > 1", RuleStatus::Pass, Some(5.0))],
1092            )],
1093        );
1094        g.key(key(KeyCode::Enter));
1095        assert_eq!(
1096            g.key(key(KeyCode::Char('h'))),
1097            GridAction::Run("crawl \"MY TOKEN\" --chain solana".into())
1098        );
1099    }
1100
1101    #[test]
1102    fn a_warned_row_comes_before_a_passed_one_and_shows_its_warning() {
1103        let mut g = GridState::new(list(), None);
1104        tick_rows(
1105            &mut g,
1106            1,
1107            vec![
1108                TargetSnapshot::new(
1109                    "0xabc",
1110                    vec![cell("risk_score <= 5", RuleStatus::Pass, Some(2.0))],
1111                ),
1112                TargetSnapshot::new(
1113                    "USDC@binance",
1114                    vec![
1115                        cell("spread_pct < 1", RuleStatus::Warn("thin book".into()), None),
1116                        cell(
1117                            "holders > 1",
1118                            RuleStatus::NotApplicable("not a token".into()),
1119                            None,
1120                        ),
1121                    ],
1122                ),
1123            ],
1124        );
1125        let order: Vec<&str> = g.rows().iter().map(|r| r.target.as_str()).collect();
1126        assert_eq!(order, ["USDC@binance", "0xabc"]);
1127        assert_eq!(g.breaches(), 0, "a warning is not a breach");
1128        let s = screen(&g, 120, 36);
1129        let row = line_with(&s, "USDC@binance");
1130        assert!(row.contains("warned"), "{row}");
1131        assert!(
1132            row.contains("!-  thin book"),
1133            "one glyph per cell, then why: {row}"
1134        );
1135        let row = line_with(&s, "0xabc");
1136        assert!(row.contains("passed") && row.contains('✓'), "{row}");
1137        // The detail: the warning reads in its colour, n/a dimmed.
1138        g.key(key(KeyCode::Enter));
1139        let mut t = Terminal::new(TestBackend::new(120, 36)).unwrap();
1140        t.draw(|f| draw(f, f.area(), &g)).unwrap();
1141        let buf = t.backend().buffer().clone();
1142        let style_of = |needle: &str| {
1143            (0..36u16)
1144                .find_map(|y| {
1145                    let row: String = (0..120u16).map(|x| buf[(x, y)].symbol()).collect();
1146                    let x = row.find(needle)?;
1147                    // Columns, not bytes: the glyphs before are multi-byte.
1148                    let col = row[..x].chars().count() as u16;
1149                    (row.matches(needle).count() == 1 && !row.contains("warned"))
1150                        .then(|| buf[(col, y)].style())
1151                })
1152                .unwrap_or_else(|| panic!("no {needle} in the detail"))
1153        };
1154        assert_eq!(style_of("thin book").fg, WARN.fg);
1155        assert!(style_of("not a token").add_modifier.contains(Modifier::DIM));
1156    }
1157
1158    #[test]
1159    fn with_no_rows_it_says_what_it_waits_for() {
1160        let mut g = GridState::new(list(), None);
1161        assert!(screen(&g, 80, 24).contains("Resolving the targets…"));
1162        g.apply(GridMsg::Ready(Ready {
1163            list: list(),
1164            contracts: vec![None, None, None],
1165        }));
1166        assert!(screen(&g, 80, 24).contains("Waiting for the first tick…"));
1167        // A run that ended before it resolved waits for nothing.
1168        let mut g = GridState::new(list(), None);
1169        g.end("Stopped.".into(), SECONDARY);
1170        assert!(screen(&g, 80, 24).contains("Waiting for the first tick…"));
1171    }
1172
1173    #[test]
1174    fn a_cleared_row_is_drawn_dim_for_one_tick() {
1175        let mut g = GridState::new(list(), None);
1176        tick(&mut g, 1, 3.4);
1177        tick(&mut g, 2, 0.5);
1178        let dim = |g: &GridState| {
1179            let mut t = Terminal::new(TestBackend::new(80, 24)).unwrap();
1180            t.draw(|f| draw(f, f.area(), g)).unwrap();
1181            let buf = t.backend().buffer().clone();
1182            // The rows start under the title; the target name after `> `.
1183            (0..24).any(|y| {
1184                let row: String = (0..80).map(|x| buf[(x, y)].symbol()).collect();
1185                row.contains("USDC@binance")
1186                    && buf[(row.find("USDC").unwrap() as u16, y)]
1187                        .modifier
1188                        .contains(Modifier::DIM)
1189            })
1190        };
1191        assert!(dim(&g), "cleared on this tick");
1192        tick(&mut g, 3, 0.5);
1193        assert!(!dim(&g), "only once");
1194    }
1195
1196    #[test]
1197    fn the_detail_draws_the_threshold_of_each_rule_on_its_chart() {
1198        let mut g = GridState::new(list(), None);
1199        for run in 1..=3 {
1200            let v = run as f64;
1201            tick_rows(
1202                &mut g,
1203                run,
1204                vec![TargetSnapshot::new(
1205                    "PEPE",
1206                    vec![
1207                        cell("holders > 10", RuleStatus::Pass, Some(20.0 + v)),
1208                        cell("holders == 22", RuleStatus::Pass, Some(20.0 + v)),
1209                        cell("holders != 99", RuleStatus::Pass, Some(20.0 + v)),
1210                        cell("risk_score <= 5", RuleStatus::Pass, Some(v)),
1211                    ],
1212                )],
1213            );
1214        }
1215        g.key(key(KeyCode::Enter));
1216        let s = screen(&g, 80, 24);
1217        let lines: Vec<&str> = s.lines().collect();
1218        for label in [
1219            "holders > 10",
1220            "holders == 22",
1221            "holders != 99",
1222            "risk_score <= 5",
1223        ] {
1224            let i = lines.iter().position(|l| l.contains(label)).unwrap();
1225            let chart = lines[i + 1];
1226            assert!(chart.contains('─'), "{label}: the threshold line\n{s}");
1227            assert!(
1228                chart.chars().any(|c| ('▁'..='█').contains(&c)),
1229                "{label}: the bars\n{s}"
1230            );
1231        }
1232    }
1233
1234    #[test]
1235    fn a_value_that_is_not_a_number_reads_as_such() {
1236        assert_eq!(value_text(Some(f64::NAN)), "NaN");
1237    }
1238
1239    // ---- Venue charts (design 2026-11-04, section 2.9) ----
1240
1241    /// USDN on weex and binance, with `cells` market rules, after `ticks`
1242    /// ticks; `value(venue, cell, tick)` gives each value. The detail is
1243    /// open on the first row (weex: binance fails, so weex sorts after it
1244    /// unless both pass; every row passes here).
1245    fn on_two_venues(
1246        cells: &[&str],
1247        ticks: u64,
1248        value: impl Fn(usize, usize, u64) -> f64,
1249    ) -> GridState {
1250        let mut w = Watchlist::new("pegs");
1251        let mut t = Target::new(TargetKind::Pair, "USDN");
1252        t.venues = vec!["weex".into(), "binance".into()];
1253        t.rules = cells.iter().map(|c| Rule::parse(c).unwrap()).collect();
1254        w.targets = vec![t];
1255        let mut g = GridState::new(w, None);
1256        for run in 1..=ticks {
1257            let rows = ["weex", "binance"]
1258                .iter()
1259                .enumerate()
1260                .map(|(v, venue)| {
1261                    let cells = cells
1262                        .iter()
1263                        .enumerate()
1264                        .map(|(c, label)| cell(label, RuleStatus::Pass, Some(value(v, c, run))))
1265                        .collect();
1266                    TargetSnapshot::new(format!("USDN@{venue}"), cells).on_venue(Some(venue))
1267                })
1268                .collect();
1269            tick_rows(&mut g, run, rows);
1270        }
1271        g.key(key(KeyCode::Enter));
1272        g
1273    }
1274
1275    fn peg(v: usize, _c: usize, run: u64) -> f64 {
1276        1.0 + (run % 5) as f64 * 0.0001 + v as f64 * 0.0003
1277    }
1278
1279    fn is_braille(s: &str) -> bool {
1280        s.chars().any(|c| ('\u{2801}'..='\u{28FF}').contains(&c))
1281    }
1282
1283    #[test]
1284    fn venue_charts_show_a_chart_per_cell_with_a_line_per_venue_and_one_legend() {
1285        let g = on_two_venues(&["mid_price > 0", "spread_pct < 1"], 30, peg);
1286        for (w, h) in [(120, 36), (80, 24)] {
1287            let s = screen(&g, w, h);
1288            assert!(s.contains(" mid_price > 0 "), "{w}x{h}\n{s}");
1289            assert!(s.contains(" spread_pct < 1 "), "{w}x{h}\n{s}");
1290            assert!(s.contains(" venues: mid_price > 0 "), "{w}x{h}\n{s}");
1291            assert!(is_braille(&s), "braille lines: {w}x{h}\n{s}");
1292            // The range of the last values across venues, then each venue.
1293            let all = line_with(&s, "  all  ");
1294            assert!(all.contains("1–1.0003"), "{all}");
1295            assert!(line_with(&s, "> ■ weex").contains('✓'), "{s}");
1296            assert!(line_with(&s, "  ■ binance").contains("1.0003"), "{s}");
1297            assert!(s.contains("now"), "the time axis: {s}");
1298            assert!(s.contains("←→ venue · Space isolate"), "{s}");
1299        }
1300    }
1301
1302    #[test]
1303    fn the_selected_venue_is_drawn_on_top_where_lines_touch() {
1304        // The same values on both venues: every braille cell is shared.
1305        let mut g = on_two_venues(&["mid_price > 0"], 30, |_, _, run| 1.0 + run as f64);
1306        let colours = |g: &GridState| {
1307            let mut t = Terminal::new(TestBackend::new(120, 36)).unwrap();
1308            t.draw(|f| draw(f, f.area(), g)).unwrap();
1309            let buf = t.backend().buffer().clone();
1310            buf.content
1311                .iter()
1312                .filter(|c| is_braille(c.symbol()))
1313                .map(|c| c.fg)
1314                .collect::<std::collections::HashSet<_>>()
1315        };
1316        let fg = |i| {
1317            series_style(i)
1318                .fg
1319                .unwrap_or(crate::cli::theme::DEFAULT_COLOR)
1320        };
1321        assert_eq!(colours(&g), [fg(0)].into());
1322        g.key(key(KeyCode::Right));
1323        assert_eq!(colours(&g), [fg(1)].into());
1324    }
1325
1326    #[test]
1327    fn keys_select_a_venue_isolate_it_and_change_the_legend_chart() {
1328        let mut g = on_two_venues(&["mid_price > 0", "spread_pct < 1"], 30, peg);
1329        g.key(key(KeyCode::Right));
1330        assert!(line_with(&screen(&g, 120, 36), "> ■ binance").contains("1.0003"));
1331        // The evidence keys follow the legend's venue.
1332        assert_eq!(
1333            g.key(key(KeyCode::Char('b'))),
1334            GridAction::Run("market summary USDN --venue binance".into())
1335        );
1336        g.key(key(KeyCode::Char(' ')));
1337        let s = screen(&g, 120, 36);
1338        assert!(!is_braille(&s), "one venue is bars: {s}");
1339        assert!(s.contains('█') || s.contains('▄'), "{s}");
1340        let mid = line_with(&s, " mid_price > 0 ");
1341        assert!(mid.contains("binance") && !mid.contains("weex"), "{mid}");
1342        g.key(key(KeyCode::Char(' ')));
1343        assert!(is_braille(&screen(&g, 120, 36)), "all venues again");
1344        g.key(key(KeyCode::Tab));
1345        assert!(screen(&g, 120, 36).contains(" venues: spread_pct < 1 "));
1346        g.key(key(KeyCode::Left));
1347        g.key(key(KeyCode::Left));
1348        assert!(
1349            line_with(&screen(&g, 120, 36), "> ■ ").contains("binance"),
1350            "wraps"
1351        );
1352    }
1353
1354    #[test]
1355    fn charts_that_do_not_fit_go_on_pages() {
1356        let cells = [
1357            "mid_price > 0",
1358            "spread_pct < 1",
1359            "bid_depth > 0",
1360            "ask_depth > 0",
1361            "peg_deviation_pct < 1",
1362            "largest_trade < 50000",
1363        ];
1364        let mut g = on_two_venues(&cells, 10, peg);
1365        let s = screen(&g, 100, 20);
1366        let page = line_with(&s, " charts 1–");
1367        assert!(page.contains(" of 6 "), "{s}");
1368        g.key(key(KeyCode::PageDown));
1369        let s = screen(&g, 100, 20);
1370        assert!(!s.contains(" charts 1–"), "the next page: {s}");
1371        assert!(!s.contains(" mid_price > 0 "), "{s}");
1372        g.key(key(KeyCode::PageUp));
1373        assert!(screen(&g, 100, 20).contains(" charts 1–"));
1374    }
1375
1376    #[test]
1377    fn a_window_too_small_for_the_charts_shows_the_detail_and_says_how_much_to_enlarge_it() {
1378        let g = on_two_venues(&["mid_price > 0"], 5, peg);
1379        let s = screen(&g, 50, 12);
1380        assert!(!is_braille(&s), "{s}");
1381        assert!(
1382            s.contains("USDN@weex — passed"),
1383            "the one-venue detail: {s}"
1384        );
1385        // A narrow window wraps the hint.
1386        let text = s.lines().map(str::trim_end).collect::<Vec<_>>().join(" ");
1387        assert!(text.contains("! Window too small: enlarge it"), "{s}");
1388        assert!(text.contains("wider to see the venue charts"), "{s}");
1389    }
1390
1391    #[test]
1392    fn with_colour_off_every_venue_still_has_its_name_and_value() {
1393        let g = on_two_venues(&["mid_price > 0"], 30, peg);
1394        let mut t = Terminal::new(TestBackend::new(120, 36)).unwrap();
1395        t.draw(|f| {
1396            draw(f, f.area(), &g);
1397            crate::cli::plain::strip_color(f.buffer_mut());
1398        })
1399        .unwrap();
1400        let buf = t.backend().buffer().clone();
1401        assert!(
1402            buf.content
1403                .iter()
1404                .all(|c| c.fg == crate::cli::theme::DEFAULT_COLOR)
1405        );
1406        let s = screen(&g, 120, 36);
1407        let mid = line_with(&s, " mid_price > 0 ");
1408        assert!(
1409            mid.contains("weex 1.0003") || mid.contains("weex 1"),
1410            "{mid}"
1411        );
1412        assert!(mid.contains("binance"), "{mid}");
1413    }
1414
1415    #[test]
1416    fn one_venue_keeps_the_detail_of_today() {
1417        let mut g = GridState::new(list(), None);
1418        tick(&mut g, 1, 0.5);
1419        g.key(key(KeyCode::Enter));
1420        assert!(g.venue_charts().is_none());
1421        assert!(screen(&g, 120, 36).contains(DETAIL_LEGEND));
1422    }
1423}