1use 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
30const HISTORY: usize = 120;
32
33const CHART_ROWS: u16 = 1;
35
36const TARGET_COLS: usize = 24;
38
39pub 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
46pub 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#[derive(Debug, Clone)]
51pub enum GridMsg {
52 Ready(Ready),
54 Tick {
56 run: u64,
57 tick: Box<Tick>,
58 events: Vec<Event>,
59 },
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum GridAction {
65 None,
66 Shell,
68 Stop,
70 Close,
72 Run(String),
74}
75
76#[derive(Debug)]
78pub struct GridState {
79 pub list: Watchlist,
81 pub ready: Option<Ready>,
83 pub last: Option<Tick>,
85 pub run: u64,
87 pub health: Vec<bool>,
89 history: HashMap<(String, String), VecDeque<Option<f64>>>,
91 cleared: Vec<String>,
93 pub banner: Option<String>,
95 pub bell: bool,
97 pub selected: usize,
99 pub detail: bool,
101 pub ended: Option<(String, Style)>,
103 pub progress: Option<ProgressHook>,
105 pub stale: bool,
108 pub(crate) venues: Selection,
110 page_size: std::cell::Cell<usize>,
112}
113
114impl GridState {
115 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 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 pub fn end(&mut self, footer: String, style: Style) {
174 self.ended = Some((footer, style));
175 }
176
177 pub fn is_running(&self) -> bool {
179 self.ended.is_none()
180 }
181
182 pub fn breaches(&self) -> usize {
184 self.last.as_ref().map_or(0, |t| t.snapshot.breaches())
185 }
186
187 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 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 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 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 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 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 pub fn key(&mut self, key: KeyEvent) -> GridAction {
339 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 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 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
425fn quote(s: &str) -> String {
427 if s.contains([' ', '"', '\'']) {
428 format!("\"{}\"", s.replace('"', "\\\""))
429 } else {
430 s.to_string()
431 }
432}
433
434pub(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
445fn 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
455pub(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 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
473pub 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 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
568fn 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 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
629fn 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 if let Some(m) = c.status.message().or(c.detail.as_deref()) {
661 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
683fn 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
694pub 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
705pub 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 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 #[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 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 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 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 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 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 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 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 (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 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 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 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 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 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}