use crate::cli::charts::{HealthStrip, Sparkline};
use crate::cli::output::ProgressHook;
use crate::cli::theme::{ACCENT, ERR, KEY, OK, SECONDARY, WARN};
use crate::cli::watch::{Ready, Tick};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use scope::watch::{
Event, RuleStatus, Snapshot, TargetKind, TargetSnapshot, TargetStatus, Watchlist,
};
use std::collections::{HashMap, VecDeque};
const HISTORY: usize = 120;
const CHART_ROWS: u16 = 1;
const TARGET_COLS: usize = 24;
pub const GRID_LEGEND: &str = "↑↓ select · Enter detail · Ctrl-G shell · Esc stop the run";
pub const DETAIL_LEGEND: &str = "b book · t trades · h holders · r risk · x address · Esc back";
pub const ENDED_LEGEND: &str = "Esc or Ctrl-G back to the shell · Enter detail";
pub const STALE_NOTE: &str = "The settings changed: this run still uses the old ones. Stop it (Esc) and start it again to use them.";
#[derive(Debug, Clone)]
pub enum GridMsg {
Ready(Ready),
Tick {
run: u64,
tick: Box<Tick>,
events: Vec<Event>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GridAction {
None,
Shell,
Stop,
Close,
Run(String),
}
#[derive(Debug)]
pub struct GridState {
pub list: Watchlist,
pub ready: Option<Ready>,
pub last: Option<Tick>,
pub run: u64,
pub health: Vec<bool>,
history: HashMap<(String, String), VecDeque<Option<f64>>>,
cleared: Vec<String>,
pub banner: Option<String>,
pub bell: bool,
pub selected: usize,
pub detail: bool,
pub ended: Option<(String, Style)>,
pub progress: Option<ProgressHook>,
pub stale: bool,
}
impl GridState {
pub fn new(list: Watchlist, progress: Option<ProgressHook>) -> Self {
Self {
list,
ready: None,
last: None,
run: 0,
health: Vec::new(),
history: HashMap::new(),
cleared: Vec::new(),
banner: None,
bell: false,
selected: 0,
detail: false,
ended: None,
progress,
stale: false,
}
}
pub fn apply(&mut self, msg: GridMsg) {
match msg {
GridMsg::Ready(ready) => self.ready = Some(ready),
GridMsg::Tick { run, tick, events } => {
self.run = run;
self.health.push(tick.snapshot.breaches() == 0);
for t in &tick.snapshot.targets {
for c in &t.cells {
let h = self
.history
.entry((t.target.clone(), c.label.clone()))
.or_default();
h.push_back(c.value);
if h.len() > HISTORY {
h.pop_front();
}
}
}
self.cleared.clear();
for e in &events {
match e {
Event::BreachStarted { .. } => {
self.banner = Some(e.banner());
self.bell = true;
}
Event::BreachCleared { target, .. } => self.cleared.push(target.clone()),
_ => {}
}
}
self.last = Some(*tick);
}
}
}
pub fn end(&mut self, footer: String, style: Style) {
self.ended = Some((footer, style));
}
pub fn is_running(&self) -> bool {
self.ended.is_none()
}
pub fn breaches(&self) -> usize {
self.last.as_ref().map_or(0, |t| t.snapshot.breaches())
}
pub fn rows(&self) -> Vec<&TargetSnapshot> {
let mut rows: Vec<&TargetSnapshot> = self
.last
.as_ref()
.map(|t| t.snapshot.targets.iter().collect())
.unwrap_or_default();
rows.sort_by_key(|r| r.status);
rows
}
fn selected_target(&self) -> Option<&scope::watch::Target> {
let label = self.rows().get(self.selected)?.target.clone();
let list = self.ready.as_ref().map_or(&self.list, |r| &r.list);
list.targets.iter().find(|t| t.label() == label)
}
pub fn evidence(&self, key: char) -> Option<String> {
let t = self.selected_target()?;
let venue = t
.venue
.as_deref()
.map(|v| format!(" --venue {v}"))
.unwrap_or_default();
let chain = t
.chain
.as_deref()
.map(|c| format!(" --chain {c}"))
.unwrap_or_default();
let id = quote(&t.id);
match (key, t.kind) {
('b', TargetKind::Token | TargetKind::Pair) if t.venue.is_some() => {
Some(format!("market summary {id}{venue}"))
}
('t', TargetKind::Token | TargetKind::Pair) if t.venue.is_some() => {
Some(format!("market trades {id}{venue}"))
}
('h', TargetKind::Token) => Some(format!("crawl {id}{chain}")),
('r', TargetKind::Address) => Some(format!("compliance risk {id}{chain}")),
('x', TargetKind::Address) => Some(format!("address {id}{chain} --include-txs")),
_ => None,
}
}
pub fn key(&mut self, key: KeyEvent) -> GridAction {
self.banner = None;
let n = self.rows().len();
match key.code {
KeyCode::Down if n > 0 => self.selected = (self.selected + 1) % n,
KeyCode::Up if n > 0 => self.selected = (self.selected + n - 1) % n,
KeyCode::Enter if n > 0 => self.detail = !self.detail,
KeyCode::Esc if self.detail => self.detail = false,
KeyCode::Esc if self.is_running() => return GridAction::Stop,
KeyCode::Esc => return GridAction::Close,
KeyCode::Char(c) if self.detail => {
if let Some(line) = self.evidence(c) {
return GridAction::Run(line);
}
}
_ => {}
}
GridAction::None
}
pub fn status_part(&self) -> String {
let state = if self.is_running() {
"watch"
} else {
"watch ended"
};
let old = if self.stale && self.is_running() {
" · old settings"
} else {
""
};
format!(
"{state} {}: {} breached{old} · Ctrl-G grid",
self.list.name,
self.breaches()
)
}
fn footer(&self) -> (String, Style) {
if let Some(ended) = &self.ended {
return ended.clone();
}
let Some(hook) = &self.progress else {
return (format!("run {}", self.run), SECONDARY);
};
let p = hook.lock().unwrap_or_else(|e| e.into_inner()).clone();
let of = p.total.map(|t| format!(" of {t}")).unwrap_or_default();
let next = p
.next_at
.map(|at| {
let secs = at
.saturating_duration_since(std::time::Instant::now())
.as_secs();
format!(" · next in {secs}s")
})
.unwrap_or_else(|| " · fetching".to_string());
if p.run == 0 {
return ("resolving the targets…".to_string(), SECONDARY);
}
(format!("run {}{of}{next}", p.run), SECONDARY)
}
}
fn quote(s: &str) -> String {
if s.contains([' ', '"', '\'']) {
format!("\"{}\"", s.replace('"', "\\\""))
} else {
s.to_string()
}
}
fn status_cell(s: &RuleStatus) -> (&'static str, Style) {
match s {
RuleStatus::Pass => ("✓", OK),
RuleStatus::Warn(_) => ("!", WARN),
RuleStatus::Fail(_) => ("✗", ERR),
RuleStatus::NotApplicable(_) => ("-", SECONDARY),
RuleStatus::Error(_) => ("?", WARN),
}
}
fn target_style(s: TargetStatus) -> Style {
match s {
TargetStatus::Breached => ERR.add_modifier(Modifier::BOLD),
TargetStatus::Warned => WARN,
TargetStatus::Unknown => WARN,
TargetStatus::Passed => OK,
}
}
fn value_text(v: Option<f64>) -> String {
match v {
None => "-".to_string(),
Some(v) if v.abs() >= 10_000.0 => scope::display::format::format_large_number(v),
Some(v) => {
let s = scope::display::format::format_price(v);
if s.contains('.') {
s.trim_end_matches('0').trim_end_matches('.').to_string()
} else {
s
}
}
}
}
pub fn draw(f: &mut Frame, area: Rect, g: &GridState) {
let (footer, footer_style) = g.footer();
let block = Block::new()
.borders(Borders::TOP | Borders::BOTTOM)
.border_style(ACCENT)
.title(Line::styled(
format!(" watchlist {} ", g.list.name),
ACCENT.add_modifier(Modifier::BOLD),
))
.title(
Line::styled(
format!(" {} breached ", g.breaches()),
target_style(if g.breaches() > 0 {
TargetStatus::Breached
} else {
TargetStatus::Passed
}),
)
.right_aligned(),
);
let inner = block.inner(area);
f.render_widget(block, area);
let stale = g.stale && g.is_running();
let banner_rows = u16::from(g.banner.is_some()) + u16::from(stale);
let [banner, body, strip, foot, legend] = Layout::vertical([
Constraint::Length(banner_rows),
Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(inner);
let mut banners = Vec::new();
if let Some(text) = &g.banner {
banners.push(Line::styled(
format!(" ⚠ {text}"),
ERR.add_modifier(Modifier::BOLD),
));
}
if stale {
banners.push(Line::styled(format!(" {STALE_NOTE}"), WARN));
}
if !banners.is_empty() {
f.render_widget(Paragraph::new(banners), banner);
}
if g.detail {
draw_detail(f, body, g);
} else {
draw_rows(f, body, g);
}
f.render_widget(HealthStrip::new(&g.health), strip);
f.render_widget(
Paragraph::new(Line::styled(format!(" {footer}"), footer_style)),
foot,
);
let keys = match (g.detail, g.is_running()) {
(true, _) => DETAIL_LEGEND,
(false, true) => GRID_LEGEND,
(false, false) => ENDED_LEGEND,
};
f.render_widget(
Paragraph::new(Line::styled(format!(" {keys}"), KEY)),
legend,
);
}
fn draw_rows(f: &mut Frame, area: Rect, g: &GridState) {
let rows = g.rows();
if rows.is_empty() {
let text = if g.ready.is_none() && g.is_running() {
" Resolving the targets…"
} else {
" Waiting for the first tick…"
};
f.render_widget(Paragraph::new(Line::styled(text, SECONDARY)), area);
return;
}
let height = area.height as usize;
let first = g.selected.saturating_sub(height.saturating_sub(1));
let lines: Vec<Line> = rows
.iter()
.enumerate()
.skip(first)
.take(height)
.map(|(i, r)| {
let marker = if i == g.selected { "> " } else { " " };
let mut style = target_style(r.status);
if g.cleared.contains(&r.target) {
style = style.add_modifier(Modifier::DIM);
}
let mut spans = vec![
Span::styled(marker, KEY),
Span::styled(
format!("{:<w$.w$} ", r.target, w = TARGET_COLS),
if i == g.selected {
style.add_modifier(Modifier::REVERSED)
} else {
style
},
),
Span::styled(format!("{:<9}", r.status.name()), style),
];
for c in &r.cells {
let (glyph, st) = status_cell(&c.status);
spans.push(Span::styled(glyph, st));
}
if let Some((why, st)) = r
.cells
.iter()
.find(|c| matches!(c.status, RuleStatus::Fail(_)))
.or_else(|| {
r.cells
.iter()
.find(|c| matches!(c.status, RuleStatus::Error(_) | RuleStatus::Warn(_)))
})
.and_then(|c| Some((c.status.message()?, status_cell(&c.status).1)))
{
spans.push(Span::styled(format!(" {why}"), st));
}
Line::from(spans)
})
.collect();
f.render_widget(Paragraph::new(lines), area);
}
fn draw_detail(f: &mut Frame, area: Rect, g: &GridState) {
let rows = g.rows();
let Some(r) = rows.get(g.selected) else {
return;
};
let mut y = area.y;
let bottom = area.bottom();
let put = |f: &mut Frame, y: &mut u16, line: Line| {
if *y < bottom {
f.render_widget(Paragraph::new(line), Rect::new(area.x, *y, area.width, 1));
*y += 1;
}
};
put(
f,
&mut y,
Line::styled(
format!(" {} — {}", r.target, r.status.name()),
target_style(r.status),
),
);
for c in &r.cells {
let (glyph, st) = status_cell(&c.status);
let mut spans = vec![
Span::styled(format!(" {glyph} "), st),
Span::styled(format!("{:<34.34} ", c.label), Style::new()),
Span::styled(format!("{:>12} ", value_text(c.value)), ACCENT),
];
if let Some(m) = c.status.message().or(c.detail.as_deref()) {
let text = match c.status {
RuleStatus::Warn(_) | RuleStatus::Fail(_) | RuleStatus::Error(_) => st,
_ => SECONDARY,
};
spans.push(Span::styled(m.to_string(), text));
}
put(f, &mut y, Line::from(spans));
let values: Vec<Option<f64>> = g
.history
.get(&(r.target.clone(), c.label.clone()))
.map(|h| h.iter().copied().collect())
.unwrap_or_default();
if values.iter().any(Option::is_some) && y + CHART_ROWS <= bottom {
let threshold = c.rule.map(|rule| match rule.op {
scope::watch::Op::Lt | scope::watch::Op::Le => {
scope::metrics::Threshold::Max(rule.value)
}
scope::watch::Op::Gt | scope::watch::Op::Ge => {
scope::metrics::Threshold::Min(rule.value)
}
scope::watch::Op::Eq | scope::watch::Op::Ne => {
scope::metrics::Threshold::Target(rule.value)
}
});
let chart = Rect::new(area.x + 3, y, area.width.saturating_sub(4), CHART_ROWS);
f.render_widget(Sparkline::new(&values, ACCENT).threshold(threshold), chart);
y += CHART_ROWS;
}
}
}
pub fn summary(g: &GridState) -> String {
let runs = g.health.len();
let failed = g.health.iter().filter(|h| !**h).count();
format!(
"watchlist {}: {runs} run(s) · {failed} with a breach · {} target(s) breached at the end",
g.list.name,
g.breaches()
)
}
pub fn snapshot_text(s: &Snapshot) -> String {
let mut text = String::new();
for t in &s.targets {
text.push_str(&format!(
"{:<w$} {}\n",
t.target,
t.status.name(),
w = TARGET_COLS
));
for c in &t.cells {
let detail = c.status.message().unwrap_or("");
text.push_str(&format!(
" {:<5} {:<34} {:>12} {}\n",
c.status.name(),
c.label,
value_text(c.value),
detail
));
}
}
text
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyModifiers;
#[test]
fn test_a_tiny_value_keeps_its_digits() {
assert_eq!(value_text(Some(0.0000106)), "0.0000106");
assert_eq!(value_text(Some(1.5)), "1.5");
assert_eq!(value_text(Some(42.0)), "42");
assert_eq!(value_text(Some(25_000.0)), "25.00K");
assert_eq!(value_text(None), "-");
}
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use scope::watch::{Cell, Rule, Target};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn cell(label: &str, status: RuleStatus, value: Option<f64>) -> Cell {
Cell {
label: label.into(),
rule: Rule::parse(label).ok(),
value,
status,
detail: None,
}
}
fn list() -> Watchlist {
let mut w = Watchlist::new("stables");
let mut a = Target::new(TargetKind::Pair, "USDC");
a.venue = Some("binance".into());
let mut b = Target::new(TargetKind::Address, "0xabc");
b.chain = Some("ethereum".into());
let c = Target::new(TargetKind::Token, "PEPE");
w.targets = vec![a, b, c];
w
}
fn snapshot(spread: f64) -> Snapshot {
let fail = spread > 1.0;
Snapshot {
targets: vec![
TargetSnapshot::new(
"USDC@binance",
vec![cell(
"spread_pct < 1",
if fail {
RuleStatus::Fail(format!("spread_pct {spread}, needs < 1"))
} else {
RuleStatus::Pass
},
Some(spread),
)],
),
TargetSnapshot::new(
"0xabc",
vec![cell("risk_score <= 5", RuleStatus::Pass, Some(2.0))],
),
TargetSnapshot::new(
"PEPE",
vec![cell(
"holders > 1",
RuleStatus::Error("timeout".into()),
None,
)],
),
],
}
}
fn tick(g: &mut GridState, run: u64, spread: f64) {
let prev = g
.last
.as_ref()
.map(|t| t.snapshot.clone())
.unwrap_or_default();
let s = snapshot(spread);
let events = scope::watch::diff(&prev, &s);
g.apply(GridMsg::Tick {
run,
tick: Box::new(Tick {
snapshot: s,
summaries: vec![None, None, None],
}),
events,
});
}
fn screen(g: &GridState, w: u16, h: u16) -> String {
let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
t.draw(|f| draw(f, f.area(), g)).unwrap();
let buf = t.backend().buffer().clone();
let mut s = String::new();
for y in 0..h {
for x in 0..w {
s.push_str(buf[(x, y)].symbol());
}
s.push('\n');
}
s
}
#[test]
fn rows_sort_breached_then_unknown_then_passed() {
let mut g = GridState::new(list(), None);
tick(&mut g, 1, 3.4);
let order: Vec<&str> = g.rows().iter().map(|r| r.target.as_str()).collect();
assert_eq!(order, ["USDC@binance", "PEPE", "0xabc"]);
assert_eq!(g.breaches(), 1);
}
#[test]
fn a_new_breach_shows_a_banner_and_rings_once_and_a_key_clears_it() {
let mut g = GridState::new(list(), None);
tick(&mut g, 1, 0.5);
assert!(g.banner.is_none() && !g.bell);
tick(&mut g, 2, 3.4);
assert_eq!(
g.banner.as_deref(),
Some("USDC@binance: spread_pct 3.4, needs < 1")
);
assert!(std::mem::take(&mut g.bell));
tick(&mut g, 3, 3.5);
assert!(!g.bell, "the same breach does not ring again");
g.key(key(KeyCode::Down));
assert!(g.banner.is_none());
}
#[test]
fn a_cleared_row_is_dim_for_one_tick() {
let mut g = GridState::new(list(), None);
tick(&mut g, 1, 3.4);
tick(&mut g, 2, 0.5);
assert_eq!(g.cleared, vec!["USDC@binance".to_string()]);
tick(&mut g, 3, 0.5);
assert!(g.cleared.is_empty());
}
#[test]
fn esc_stops_a_run_then_closes_and_backs_out_of_the_detail_first() {
let mut g = GridState::new(list(), None);
tick(&mut g, 1, 0.5);
assert_eq!(g.key(key(KeyCode::Enter)), GridAction::None);
assert!(g.detail);
assert_eq!(g.key(key(KeyCode::Esc)), GridAction::None);
assert!(!g.detail);
assert_eq!(g.key(key(KeyCode::Esc)), GridAction::Stop);
g.end("Stopped.".into(), SECONDARY);
assert_eq!(g.key(key(KeyCode::Esc)), GridAction::Close);
}
#[test]
fn evidence_keys_run_the_matching_command() {
let mut g = GridState::new(list(), None);
tick(&mut g, 1, 3.4);
g.key(key(KeyCode::Enter));
assert_eq!(
g.key(key(KeyCode::Char('b'))),
GridAction::Run("market summary USDC --venue binance".into())
);
assert_eq!(
g.key(key(KeyCode::Char('t'))),
GridAction::Run("market trades USDC --venue binance".into())
);
assert_eq!(
g.key(key(KeyCode::Char('r'))),
GridAction::None,
"not for a pair"
);
g.key(key(KeyCode::Esc));
g.key(key(KeyCode::Down));
g.key(key(KeyCode::Enter));
assert_eq!(
g.key(key(KeyCode::Char('h'))),
GridAction::Run("crawl PEPE".into())
);
g.key(key(KeyCode::Esc));
g.key(key(KeyCode::Down));
g.key(key(KeyCode::Enter));
assert_eq!(
g.key(key(KeyCode::Char('r'))),
GridAction::Run("compliance risk 0xabc --chain ethereum".into())
);
assert_eq!(
g.key(key(KeyCode::Char('x'))),
GridAction::Run("address 0xabc --chain ethereum --include-txs".into())
);
}
#[test]
fn draws_rows_banner_detail_and_footer_at_several_sizes() {
let mut g = GridState::new(list(), None);
tick(&mut g, 1, 0.5);
tick(&mut g, 2, 3.4);
for (w, h) in [(120, 36), (80, 24), (40, 10)] {
let s = screen(&g, w, h);
assert!(s.contains("watchlist stables"), "{w}x{h}\n{s}");
assert!(s.contains("USDC@binance"), "{w}x{h}\n{s}");
}
let s = screen(&g, 120, 36);
assert!(
s.contains("⚠ USDC@binance: spread_pct 3.4, needs < 1"),
"{s}"
);
assert!(s.contains("breached"), "{s}");
assert!(s.contains("Esc stop the run"), "{s}");
g.key(key(KeyCode::Enter));
let s = screen(&g, 120, 36);
assert!(s.contains("spread_pct < 1"), "{s}");
assert!(s.contains("b book"), "{s}");
assert!(s.contains('█') || s.contains('▁'), "{s}");
}
#[test]
fn summary_and_snapshot_text() {
let mut g = GridState::new(list(), None);
tick(&mut g, 1, 3.4);
tick(&mut g, 2, 0.5);
assert_eq!(
summary(&g),
"watchlist stables: 2 run(s) · 1 with a breach · 0 target(s) breached at the end"
);
let text = snapshot_text(&g.last.as_ref().unwrap().snapshot);
assert!(text.contains("USDC@binance"), "{text}");
assert!(
text.contains("error timeout") || text.contains("timeout"),
"{text}"
);
}
#[test]
fn a_stale_run_says_to_restart_it_until_it_ends() {
let mut g = GridState::new(list(), None);
tick(&mut g, 1, 0.5);
g.stale = true;
assert!(screen(&g, 120, 36).contains("The settings changed"));
assert!(g.status_part().contains("old settings"));
g.end("Stopped.".into(), SECONDARY);
assert!(!screen(&g, 120, 36).contains("The settings changed"));
}
#[test]
fn status_part_names_the_list_and_the_breaches() {
let mut g = GridState::new(list(), None);
tick(&mut g, 1, 3.4);
assert_eq!(g.status_part(), "watch stables: 1 breached · Ctrl-G grid");
}
fn tick_rows(g: &mut GridState, run: u64, targets: Vec<TargetSnapshot>) {
let summaries = targets.iter().map(|_| None).collect();
g.apply(GridMsg::Tick {
run,
tick: Box::new(Tick {
snapshot: Snapshot { targets },
summaries,
}),
events: Vec::new(),
});
}
fn line_with<'a>(s: &'a str, text: &str) -> &'a str {
s.lines().find(|l| l.contains(text)).unwrap_or("")
}
#[test]
fn a_cell_keeps_its_last_values_only() {
let mut g = GridState::new(list(), None);
for run in 1..=HISTORY as u64 + 10 {
tick(&mut g, run, run as f64 / 100.0);
}
let h = &g.history[&("USDC@binance".to_string(), "spread_pct < 1".to_string())];
assert_eq!(h.len(), HISTORY);
assert_eq!(h.front(), Some(&Some(0.11)), "the oldest ten are gone");
assert_eq!(h.back(), Some(&Some(1.3)));
}
#[test]
fn up_and_down_wrap_and_do_nothing_with_no_rows() {
let mut g = GridState::new(list(), None);
g.key(key(KeyCode::Up));
assert_eq!(g.selected, 0, "no rows yet");
assert_eq!(g.key(key(KeyCode::Enter)), GridAction::None);
assert!(!g.detail, "no row to open");
tick(&mut g, 1, 3.4);
g.key(key(KeyCode::Up));
assert_eq!(g.selected, 2, "up from the first row is the last");
g.key(key(KeyCode::Up));
assert_eq!(g.selected, 1);
g.key(key(KeyCode::Down));
g.key(key(KeyCode::Down));
assert_eq!(g.selected, 0, "down from the last row is the first");
}
#[test]
fn an_ended_run_says_so_in_the_status_part_and_drops_the_old_settings_note() {
let mut g = GridState::new(list(), None);
tick(&mut g, 1, 3.4);
g.stale = true;
g.end("Done.".into(), OK);
assert!(!g.is_running());
assert_eq!(
g.status_part(),
"watch ended stables: 1 breached · Ctrl-G grid"
);
let s = screen(&g, 120, 36);
assert!(s.contains("Done."), "{s}");
assert!(s.contains(ENDED_LEGEND), "{s}");
}
#[test]
fn an_id_with_a_space_or_a_quote_is_quoted_in_the_command() {
assert_eq!(quote("PEPE"), "PEPE");
assert_eq!(quote("MY TOKEN"), "\"MY TOKEN\"");
assert_eq!(quote("a\"b"), "\"a\\\"b\"");
let mut w = Watchlist::new("odd");
let mut t = Target::new(TargetKind::Token, "MY TOKEN");
t.chain = Some("solana".into());
w.targets = vec![t];
let label = w.targets[0].label();
let mut g = GridState::new(w, None);
tick_rows(
&mut g,
1,
vec![TargetSnapshot::new(
label,
vec![cell("holders > 1", RuleStatus::Pass, Some(5.0))],
)],
);
g.key(key(KeyCode::Enter));
assert_eq!(
g.key(key(KeyCode::Char('h'))),
GridAction::Run("crawl \"MY TOKEN\" --chain solana".into())
);
}
#[test]
fn a_warned_row_comes_before_a_passed_one_and_shows_its_warning() {
let mut g = GridState::new(list(), None);
tick_rows(
&mut g,
1,
vec![
TargetSnapshot::new(
"0xabc",
vec![cell("risk_score <= 5", RuleStatus::Pass, Some(2.0))],
),
TargetSnapshot::new(
"USDC@binance",
vec![
cell("spread_pct < 1", RuleStatus::Warn("thin book".into()), None),
cell(
"holders > 1",
RuleStatus::NotApplicable("not a token".into()),
None,
),
],
),
],
);
let order: Vec<&str> = g.rows().iter().map(|r| r.target.as_str()).collect();
assert_eq!(order, ["USDC@binance", "0xabc"]);
assert_eq!(g.breaches(), 0, "a warning is not a breach");
let s = screen(&g, 120, 36);
let row = line_with(&s, "USDC@binance");
assert!(row.contains("warned"), "{row}");
assert!(
row.contains("!- thin book"),
"one glyph per cell, then why: {row}"
);
let row = line_with(&s, "0xabc");
assert!(row.contains("passed") && row.contains('✓'), "{row}");
g.key(key(KeyCode::Enter));
let mut t = Terminal::new(TestBackend::new(120, 36)).unwrap();
t.draw(|f| draw(f, f.area(), &g)).unwrap();
let buf = t.backend().buffer().clone();
let style_of = |needle: &str| {
(0..36u16)
.find_map(|y| {
let row: String = (0..120u16).map(|x| buf[(x, y)].symbol()).collect();
let x = row.find(needle)?;
let col = row[..x].chars().count() as u16;
(row.matches(needle).count() == 1 && !row.contains("warned"))
.then(|| buf[(col, y)].style())
})
.unwrap_or_else(|| panic!("no {needle} in the detail"))
};
assert_eq!(style_of("thin book").fg, WARN.fg);
assert!(style_of("not a token").add_modifier.contains(Modifier::DIM));
}
#[test]
fn with_no_rows_it_says_what_it_waits_for() {
let mut g = GridState::new(list(), None);
assert!(screen(&g, 80, 24).contains("Resolving the targets…"));
g.apply(GridMsg::Ready(Ready {
list: list(),
contracts: vec![None, None, None],
}));
assert!(screen(&g, 80, 24).contains("Waiting for the first tick…"));
let mut g = GridState::new(list(), None);
g.end("Stopped.".into(), SECONDARY);
assert!(screen(&g, 80, 24).contains("Waiting for the first tick…"));
}
#[test]
fn a_cleared_row_is_drawn_dim_for_one_tick() {
let mut g = GridState::new(list(), None);
tick(&mut g, 1, 3.4);
tick(&mut g, 2, 0.5);
let dim = |g: &GridState| {
let mut t = Terminal::new(TestBackend::new(80, 24)).unwrap();
t.draw(|f| draw(f, f.area(), g)).unwrap();
let buf = t.backend().buffer().clone();
(0..24).any(|y| {
let row: String = (0..80).map(|x| buf[(x, y)].symbol()).collect();
row.contains("USDC@binance")
&& buf[(row.find("USDC").unwrap() as u16, y)]
.modifier
.contains(Modifier::DIM)
})
};
assert!(dim(&g), "cleared on this tick");
tick(&mut g, 3, 0.5);
assert!(!dim(&g), "only once");
}
#[test]
fn the_detail_draws_the_threshold_of_each_rule_on_its_chart() {
let mut g = GridState::new(list(), None);
for run in 1..=3 {
let v = run as f64;
tick_rows(
&mut g,
run,
vec![TargetSnapshot::new(
"PEPE",
vec![
cell("holders > 10", RuleStatus::Pass, Some(20.0 + v)),
cell("holders == 22", RuleStatus::Pass, Some(20.0 + v)),
cell("holders != 99", RuleStatus::Pass, Some(20.0 + v)),
cell("risk_score <= 5", RuleStatus::Pass, Some(v)),
],
)],
);
}
g.key(key(KeyCode::Enter));
let s = screen(&g, 80, 24);
let lines: Vec<&str> = s.lines().collect();
for label in [
"holders > 10",
"holders == 22",
"holders != 99",
"risk_score <= 5",
] {
let i = lines.iter().position(|l| l.contains(label)).unwrap();
let chart = lines[i + 1];
assert!(chart.contains('─'), "{label}: the threshold line\n{s}");
assert!(
chart.chars().any(|c| ('▁'..='█').contains(&c)),
"{label}: the bars\n{s}"
);
}
}
#[test]
fn a_value_that_is_not_a_number_reads_as_such() {
assert_eq!(value_text(Some(f64::NAN)), "NaN");
}
}