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