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