1use crate::cli::theme::{ACCENT, ERR, KEY, OK, SECONDARY, SELECTED, WARN};
16use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
17use ratatui::Frame;
18use ratatui::layout::Rect;
19use ratatui::style::Modifier;
20use ratatui::text::{Line, Span};
21use ratatui::widgets::{Block, Borders, Paragraph};
22use scope::market::HealthThresholds;
23use scope::metrics::{MetricGroup, MetricId, Threshold, describe};
24use scope::watch::time::{format_duration, is_until_cancelled, parse_duration};
25use scope::watch::{Op, Preset, Rule, Target, TargetKind, Watchlist};
26use std::path::{Path, PathBuf};
27use std::time::Duration;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Step {
32 Targets,
33 Metrics,
34 Timing,
35 Outputs,
36}
37
38impl Step {
39 const ALL: [Step; 4] = [Step::Targets, Step::Metrics, Step::Timing, Step::Outputs];
40
41 fn name(self) -> &'static str {
42 match self {
43 Step::Targets => "Targets",
44 Step::Metrics => "Metrics",
45 Step::Timing => "Timing",
46 Step::Outputs => "Outputs",
47 }
48 }
49
50 fn index(self) -> usize {
51 Step::ALL.iter().position(|s| *s == self).unwrap_or(0)
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum EditKind {
58 AddTarget,
59 Value(MetricId),
61 Interval,
62 Duration,
63 Name,
64 Csv,
65 Report,
66}
67
68#[derive(Debug, Clone, PartialEq)]
70pub enum WizardAction {
71 None,
72 Leave,
74 Start(Box<Watchlist>),
76 Saved(PathBuf),
78}
79
80const OUTPUT_ROWS: [&str; 5] = ["Name", "CSV", "Report", "Save", "Start"];
82
83fn legend(step: Step) -> &'static str {
85 match step {
86 Step::Targets => "a add · d delete · ↑↓ select · Tab next step · Ctrl-R start · Esc leave",
87 Step::Metrics => {
88 "Space on/off · o operator · Enter value · ←→ target · Tab next · Ctrl-R start"
89 }
90 Step::Timing | Step::Outputs => {
91 "↑↓ select · Enter edit · Ctrl-S save · Ctrl-R start · Tab next · Esc leave"
92 }
93 }
94}
95
96#[derive(Debug)]
98pub struct WizardState {
99 pub list: Watchlist,
100 pub notes: Vec<String>,
102 pub step: Step,
103 pub target: usize,
105 pub row: usize,
107 pub edit: Option<(EditKind, String)>,
109 pub venues: Vec<String>,
111 pub dir: PathBuf,
113 pub dirty: bool,
115 pub confirm_leave: bool,
117 pub error: Option<String>,
119}
120
121impl WizardState {
122 pub fn new(list: Watchlist, notes: Vec<String>, venues: Vec<String>, dir: PathBuf) -> Self {
124 let step = if list.targets.is_empty() {
126 Step::Targets
127 } else {
128 Step::Metrics
129 };
130 Self {
131 list,
132 notes,
133 step,
134 target: 0,
135 row: 0,
136 edit: None,
137 venues,
138 dir,
139 dirty: false,
140 confirm_leave: false,
141 error: None,
142 }
143 }
144
145 pub fn key(&mut self, key: KeyEvent) -> WizardAction {
147 self.error = None;
148 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
149 if self.confirm_leave {
150 self.confirm_leave = false;
151 return match key.code {
152 KeyCode::Char('y' | 'Y') => WizardAction::Leave,
153 _ => WizardAction::None,
154 };
155 }
156 if let Some((kind, mut text)) = self.edit.take() {
157 match key.code {
158 KeyCode::Esc => {}
159 KeyCode::Enter => self.finish_edit(kind, text),
160 KeyCode::Backspace => {
161 text.pop();
162 self.edit = Some((kind, text));
163 }
164 KeyCode::Char(c) if !ctrl => {
165 text.push(c);
166 self.edit = Some((kind, text));
167 }
168 _ => self.edit = Some((kind, text)),
169 }
170 return WizardAction::None;
171 }
172 match key.code {
173 KeyCode::Char('s') if ctrl => return self.save(),
174 KeyCode::Char('r') if ctrl => return self.start(),
175 KeyCode::Esc => {
176 if self.dirty {
177 self.confirm_leave = true;
178 return WizardAction::None;
179 }
180 return WizardAction::Leave;
181 }
182 KeyCode::Tab => self.go(self.step.index() + 1),
183 KeyCode::BackTab => self.go(self.step.index() + Step::ALL.len() - 1),
184 _ => {
185 return match self.step {
186 Step::Targets => self.targets_key(key),
187 Step::Metrics => self.metrics_key(key),
188 Step::Timing => self.timing_key(key),
189 Step::Outputs => self.outputs_key(key),
190 };
191 }
192 }
193 WizardAction::None
194 }
195
196 fn go(&mut self, index: usize) {
197 self.step = Step::ALL[index % Step::ALL.len()];
198 self.row = 0;
199 }
200
201 fn targets_key(&mut self, key: KeyEvent) -> WizardAction {
202 let n = self.list.targets.len();
203 match key.code {
204 KeyCode::Char('a') => self.edit = Some((EditKind::AddTarget, String::new())),
205 KeyCode::Char('d') if n > 0 => {
206 self.list.targets.remove(self.target.min(n - 1));
207 self.target = self.target.min(n.saturating_sub(2));
208 self.dirty = true;
209 }
210 KeyCode::Down if n > 0 => self.target = (self.target + 1) % n,
211 KeyCode::Up if n > 0 => self.target = (self.target + n - 1) % n,
212 KeyCode::Enter if n > 0 => self.go(Step::Metrics.index()),
213 _ => {}
214 }
215 WizardAction::None
216 }
217
218 pub fn metric_rows(&self) -> Vec<Option<MetricId>> {
221 let Some(t) = self.list.targets.get(self.target) else {
222 return Vec::new();
223 };
224 let mut rows = Vec::new();
225 if t.kind.accepts(MetricGroup::Market) {
226 rows.push(None);
227 }
228 rows.extend(
229 t.kind
230 .metrics()
231 .into_iter()
232 .filter(|m| *m != MetricId::Healthy)
233 .map(Some),
234 );
235 rows
236 }
237
238 fn metrics_key(&mut self, key: KeyEvent) -> WizardAction {
239 let rows = self.metric_rows();
240 let n = rows.len();
241 let targets = self.list.targets.len();
242 match key.code {
243 KeyCode::Down if n > 0 => self.row = (self.row + 1) % n,
244 KeyCode::Up if n > 0 => self.row = (self.row + n - 1) % n,
245 KeyCode::Right if targets > 0 => {
246 self.target = (self.target + 1) % targets;
247 self.row = 0;
248 }
249 KeyCode::Left if targets > 0 => {
250 self.target = (self.target + targets - 1) % targets;
251 self.row = 0;
252 }
253 KeyCode::Char(' ') if n > 0 => self.toggle(rows[self.row.min(n - 1)]),
254 KeyCode::Char('o') if n > 0 => {
255 if let Some(m) = rows[self.row.min(n - 1)]
256 && let Some(rule) = self.rule_mut(m)
257 {
258 rule.op = next_op(rule.op);
259 self.dirty = true;
260 }
261 }
262 KeyCode::Enter if n > 0 => {
263 if let Some(m) = rows[self.row.min(n - 1)]
264 && let Some(rule) = self.rule_mut(m)
265 {
266 self.edit = Some((EditKind::Value(m), rule.value.to_string()));
267 }
268 }
269 _ => {}
270 }
271 WizardAction::None
272 }
273
274 fn rule_mut(&mut self, metric: MetricId) -> Option<&mut Rule> {
275 self.list
276 .targets
277 .get_mut(self.target)?
278 .rules
279 .iter_mut()
280 .find(|r| r.metric == metric)
281 }
282
283 fn toggle(&mut self, row: Option<MetricId>) {
285 let Some(t) = self.list.targets.get_mut(self.target) else {
286 return;
287 };
288 let needs_venue = row.is_none_or(|m| m.group() == MetricGroup::Market);
289 match row {
290 None if t.preset.is_some() => t.preset = None,
291 Some(m) if t.rules.iter().any(|r| r.metric == m) => t.rules.retain(|r| r.metric != m),
292 _ if needs_venue && t.venues.is_empty() => {
293 self.error = Some(format!(
294 "{} has no venue: market metrics need one (add the target as `pair {} binance`).",
295 t.id, t.id
296 ));
297 return;
298 }
299 None => t.preset = Some(Preset::MarketHealth),
300 Some(m) => t.rules.push(default_rule(m)),
301 }
302 self.dirty = true;
303 }
304
305 fn timing_key(&mut self, key: KeyEvent) -> WizardAction {
306 match key.code {
307 KeyCode::Down | KeyCode::Up => self.row = 1 - self.row.min(1),
308 KeyCode::Enter => {
309 let (kind, text) = if self.row == 0 {
310 (
311 EditKind::Interval,
312 format_duration(self.list.interval.as_secs()),
313 )
314 } else {
315 (
316 EditKind::Duration,
317 self.list.duration.map_or_else(
318 || "forever".to_string(),
319 |d| format_duration(d.as_secs()),
320 ),
321 )
322 };
323 self.edit = Some((kind, text));
324 }
325 _ => {}
326 }
327 WizardAction::None
328 }
329
330 fn outputs_key(&mut self, key: KeyEvent) -> WizardAction {
331 let n = OUTPUT_ROWS.len();
332 match key.code {
333 KeyCode::Down => self.row = (self.row + 1) % n,
334 KeyCode::Up => self.row = (self.row + n - 1) % n,
335 KeyCode::Enter => match self.row {
336 0 => self.edit = Some((EditKind::Name, self.list.name.clone())),
337 1 => self.edit = Some((EditKind::Csv, path_text(&self.list.outputs.csv))),
338 2 => self.edit = Some((EditKind::Report, path_text(&self.list.outputs.report))),
339 3 => return self.save(),
340 _ => return self.start(),
341 },
342 _ => {}
343 }
344 WizardAction::None
345 }
346
347 fn finish_edit(&mut self, kind: EditKind, text: String) {
349 let text = text.trim().to_string();
350 let result = match kind {
351 EditKind::AddTarget => self.add_target(&text),
352 EditKind::Value(m) => match text.replace('_', "").parse::<f64>() {
353 Ok(v) if v.is_finite() => {
354 if let Some(rule) = self.rule_mut(m) {
355 rule.value = v;
356 }
357 Ok(())
358 }
359 _ => Err(format!("`{text}` is not a number")),
360 },
361 EditKind::Interval => parse_duration(&text).map(|s| {
362 self.list.interval = Duration::from_secs(s);
363 }),
364 EditKind::Duration => {
365 if is_until_cancelled(&text) {
366 self.list.duration = None;
367 Ok(())
368 } else {
369 parse_duration(&text).map(|s| self.list.duration = Some(Duration::from_secs(s)))
370 }
371 }
372 EditKind::Name => {
373 if text.is_empty() || text.contains(['/', '\\', ':']) {
374 Err("a name has no / \\ or : and is not empty".to_string())
375 } else {
376 self.list.name = text;
377 Ok(())
378 }
379 }
380 EditKind::Csv => {
381 self.list.outputs.csv = (!text.is_empty()).then(|| PathBuf::from(&text));
382 Ok(())
383 }
384 EditKind::Report => {
385 self.list.outputs.report = (!text.is_empty()).then(|| PathBuf::from(&text));
386 Ok(())
387 }
388 };
389 match result {
390 Ok(()) => self.dirty = true,
391 Err(e) => self.error = Some(e),
392 }
393 }
394
395 fn add_target(&mut self, text: &str) -> Result<(), String> {
398 const USAGE: &str =
399 "type `pair USDC binance [okx…]`, `token PEPE [chain|venue…]` or `address 0x… [chain]`";
400 let words: Vec<&str> = text.split_whitespace().collect();
401 let (kind, id, extras) = match words.as_slice() {
402 [kind, id, extras @ ..] => (*kind, *id, extras),
403 _ => return Err(USAGE.to_string()),
404 };
405 let kind = match kind.to_ascii_lowercase().as_str() {
406 "pair" => TargetKind::Pair,
407 "token" => TargetKind::Token,
408 "address" => TargetKind::Address,
409 _ => return Err(USAGE.to_string()),
410 };
411 let mut t = Target::new(kind, id);
412 for &extra in extras {
413 let is_venue = self.venues.iter().any(|v| v.eq_ignore_ascii_case(extra));
414 match (kind, is_venue) {
415 (TargetKind::Address, _) | (TargetKind::Token, false) if t.chain.is_some() => {
416 return Err(USAGE.to_string());
417 }
418 (TargetKind::Address, _) | (TargetKind::Token, false) => {
419 t.chain = Some(extra.to_string())
420 }
421 (_, true) if t.venues.iter().any(|v| v.eq_ignore_ascii_case(extra)) => {
422 return Err(format!("venue `{extra}` is listed twice"));
423 }
424 (_, true) => t.venues.push(extra.to_string()),
425 (TargetKind::Pair, false) => {
426 return Err(format!(
427 "unknown venue `{extra}`; the venues are {}",
428 self.venues.join(", ")
429 ));
430 }
431 }
432 }
433 if kind == TargetKind::Pair && t.venues.is_empty() {
434 return Err("a pair needs a venue: `pair USDC binance`".to_string());
435 }
436 if !t.venues.is_empty() {
437 t.preset = Some(Preset::MarketHealth);
438 }
439 self.list.targets.push(t);
440 self.target = self.list.targets.len() - 1;
441 Ok(())
442 }
443
444 pub fn problem(&self) -> Option<String> {
446 if self.list.targets.is_empty() {
447 return Some("Add a target first (Targets step, `a`).".to_string());
448 }
449 self.list
450 .targets
451 .iter()
452 .find(|t| t.preset.is_none() && t.rules.is_empty())
453 .map(|t| {
454 format!(
455 "{} has nothing to check: turn on a metric or the preset (Metrics step).",
456 t.label()
457 )
458 })
459 }
460
461 pub fn path(&self) -> PathBuf {
463 scope::watch::file::path_in(&self.dir, &self.list.name)
464 }
465
466 fn save(&mut self) -> WizardAction {
467 if let Some(p) = self.problem() {
468 self.error = Some(p);
469 return WizardAction::None;
470 }
471 let path = self.path();
472 match scope::watch::file::save(&path, &self.list) {
473 Ok(()) => {
474 self.dirty = false;
475 WizardAction::Saved(path)
476 }
477 Err(e) => {
478 self.error = Some(format!("Could not save {}: {e}", path.display()));
479 WizardAction::None
480 }
481 }
482 }
483
484 fn start(&mut self) -> WizardAction {
485 if let Some(p) = self.problem() {
486 self.error = Some(p);
487 return WizardAction::None;
488 }
489 WizardAction::Start(Box::new(self.list.clone()))
490 }
491}
492
493fn default_rule(metric: MetricId) -> Rule {
496 let (op, value) = match describe(metric, &HealthThresholds::default()).threshold {
497 Some(Threshold::Max(v)) => (Op::Le, v),
498 Some(Threshold::Min(v)) => (Op::Ge, v),
499 Some(Threshold::Target(_)) | None => (Op::Gt, 0.0),
500 };
501 Rule { metric, op, value }
502}
503
504fn next_op(op: Op) -> Op {
505 match op {
506 Op::Lt => Op::Le,
507 Op::Le => Op::Gt,
508 Op::Gt => Op::Ge,
509 Op::Ge => Op::Eq,
510 Op::Eq => Op::Ne,
511 Op::Ne => Op::Lt,
512 }
513}
514
515fn path_text(p: &Option<PathBuf>) -> String {
516 p.as_deref()
517 .map(Path::display)
518 .map(|d| d.to_string())
519 .unwrap_or_default()
520}
521
522fn row(selected: bool, text: String) -> Line<'static> {
524 if selected {
525 Line::from(vec![Span::styled("> ", KEY), Span::styled(text, SELECTED)])
526 } else {
527 Line::from(vec![Span::raw(" "), Span::raw(text)])
528 }
529}
530
531fn target_text(t: &Target) -> String {
532 let mut s = format!("{:<8} {}", t.kind.name(), t.id);
533 if !t.venues.is_empty() {
534 s.push_str(&format!(" @{}", t.venues.join(",")));
535 }
536 if let Some(c) = &t.chain {
537 s.push_str(&format!(" on {c}"));
538 }
539 if t.preset.is_some() {
540 s.push_str(" [market health]");
541 }
542 if !t.rules.is_empty() {
543 s.push_str(&format!(" {} rule(s)", t.rules.len()));
544 }
545 s
546}
547
548pub fn draw(f: &mut Frame, area: Rect, w: &WizardState) {
550 let block = Block::new()
551 .borders(Borders::TOP | Borders::BOTTOM)
552 .border_style(ACCENT)
553 .title(Line::styled(
554 format!(" monitor wizard · {} ", w.list.name),
555 ACCENT.add_modifier(Modifier::BOLD),
556 ));
557 let inner = block.inner(area);
558 f.render_widget(block, area);
559 let mut lines: Vec<Line> = Vec::new();
560 let mut tabs = vec![Span::raw(" ")];
561 for s in Step::ALL {
562 let style = if s == w.step {
563 SELECTED.add_modifier(Modifier::BOLD)
564 } else {
565 SECONDARY
566 };
567 tabs.push(Span::styled(
568 format!(" {} {} ", s.index() + 1, s.name()),
569 style,
570 ));
571 }
572 lines.push(Line::from(tabs));
573 for n in &w.notes {
574 lines.push(Line::styled(format!(" {n}"), WARN));
575 }
576 lines.push(Line::raw(""));
577 match w.step {
578 Step::Targets => {
579 if w.list.targets.is_empty() {
580 lines.push(Line::styled(
581 " No targets yet: press a to add one.",
582 SECONDARY,
583 ));
584 }
585 for (i, t) in w.list.targets.iter().enumerate() {
586 lines.push(row(i == w.target, target_text(t)));
587 }
588 }
589 Step::Metrics => match w.list.targets.get(w.target) {
590 None => lines.push(Line::styled(" Add a target first.", SECONDARY)),
591 Some(t) => {
592 lines.push(Line::styled(
593 format!(
594 " {} ({}/{}) ←→ other target",
595 t.label(),
596 w.target + 1,
597 w.list.targets.len()
598 ),
599 ACCENT,
600 ));
601 for (i, r) in w.metric_rows().iter().enumerate() {
602 let text = match r {
603 None => format!(
604 "[{}] market health (the checks of market summary)",
605 if t.preset.is_some() { "x" } else { " " }
606 ),
607 Some(m) => match t.rules.iter().find(|x| x.metric == *m) {
608 Some(rule) => format!("[x] {rule}"),
609 None => format!("[ ] {}", m.name()),
610 },
611 };
612 lines.push(row(i == w.row, text));
613 }
614 }
615 },
616 Step::Timing => {
617 let duration = w.list.duration.map_or_else(
618 || "until stopped".to_string(),
619 |d| format_duration(d.as_secs()),
620 );
621 lines.push(row(
622 w.row == 0,
623 format!("Interval {}", format_duration(w.list.interval.as_secs())),
624 ));
625 lines.push(row(w.row == 1, format!("Duration {duration}")));
626 let per = w.list.fetches_per_tick();
627 let total = w.list.ticks().map_or_else(
628 || "until stopped".to_string(),
629 |t| format!("{} in all", t * per as u64),
630 );
631 lines.push(Line::raw(""));
632 lines.push(Line::styled(
633 format!(" {per} fetch(es) per run · {total}"),
634 SECONDARY,
635 ));
636 }
637 Step::Outputs => {
638 let values = [
639 w.list.name.clone(),
640 path_text(&w.list.outputs.csv),
641 path_text(&w.list.outputs.report),
642 format!("to {}", w.path().display()),
643 "the grid".to_string(),
644 ];
645 for (i, (label, value)) in OUTPUT_ROWS.iter().zip(values).enumerate() {
646 lines.push(row(i == w.row, format!("{label:<7} {value}")));
647 }
648 }
649 }
650 lines.push(Line::raw(""));
651 if let Some((kind, text)) = &w.edit {
652 let label = match kind {
653 EditKind::AddTarget => "Add".to_string(),
654 EditKind::Value(m) => format!("{} value", m.name()),
655 EditKind::Interval => "Interval".to_string(),
656 EditKind::Duration => "Duration".to_string(),
657 EditKind::Name => "Name".to_string(),
658 EditKind::Csv => "CSV".to_string(),
659 EditKind::Report => "Report".to_string(),
660 };
661 lines.push(Line::from(vec![
662 Span::styled(format!(" {label}: "), KEY),
663 Span::raw(text.clone()),
664 Span::styled("█", ACCENT),
665 ]));
666 if *kind == EditKind::AddTarget {
667 lines.push(Line::styled(
668 " pair USDC binance · token PEPE solana · address 0x… ethereum · Enter adds · Esc cancels",
669 SECONDARY,
670 ));
671 }
672 }
673 if w.confirm_leave {
674 lines.push(Line::styled(
675 " Leave without saving? [y/N]",
676 WARN.add_modifier(Modifier::BOLD),
677 ));
678 }
679 if let Some(e) = &w.error {
680 lines.push(Line::styled(format!(" {e}"), ERR));
681 } else if w.problem().is_none() && !w.dirty {
682 lines.push(Line::styled(" Ready: Ctrl-R starts the run.", OK));
683 }
684 let body = Rect {
686 height: inner.height.saturating_sub(1),
687 ..inner
688 };
689 let first = lines.len().saturating_sub(body.height as usize);
690 f.render_widget(
691 Paragraph::new(lines[first.min(lines.len())..].to_vec()),
692 body,
693 );
694 if inner.height > 0 {
695 let last = Rect::new(inner.x, inner.bottom() - 1, inner.width, 1);
696 f.render_widget(
697 Paragraph::new(Line::styled(format!(" {}", legend(w.step)), KEY)),
698 last,
699 );
700 }
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706 use ratatui::Terminal;
707 use ratatui::backend::TestBackend;
708
709 fn key(code: KeyCode) -> KeyEvent {
710 KeyEvent::new(code, KeyModifiers::NONE)
711 }
712
713 fn ctrl(c: char) -> KeyEvent {
714 KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
715 }
716
717 fn typed(w: &mut WizardState, text: &str) {
718 for c in text.chars() {
719 w.key(key(KeyCode::Char(c)));
720 }
721 w.key(key(KeyCode::Enter));
722 }
723
724 fn wizard(dir: &Path) -> WizardState {
725 WizardState::new(
726 Watchlist::new("w"),
727 Vec::new(),
728 vec!["binance".into(), "weex".into()],
729 dir.to_path_buf(),
730 )
731 }
732
733 fn screen(w: &WizardState, cols: u16, rows: u16) -> String {
734 let mut t = Terminal::new(TestBackend::new(cols, rows)).unwrap();
735 t.draw(|f| draw(f, f.area(), w)).unwrap();
736 let buf = t.backend().buffer().clone();
737 let mut s = String::new();
738 for y in 0..rows {
739 for x in 0..cols {
740 s.push_str(buf[(x, y)].symbol());
741 }
742 s.push('\n');
743 }
744 s
745 }
746
747 #[test]
748 fn add_targets_with_venue_or_chain_and_delete() {
749 let dir = tempfile::tempdir().unwrap();
750 let mut w = wizard(dir.path());
751 w.key(key(KeyCode::Char('a')));
752 typed(&mut w, "pair USDC binance");
753 let t = &w.list.targets[0];
754 assert_eq!(
755 (t.kind, t.venues.as_slice(), t.preset),
756 (
757 TargetKind::Pair,
758 ["binance".to_string()].as_slice(),
759 Some(Preset::MarketHealth)
760 )
761 );
762 w.key(key(KeyCode::Char('a')));
763 typed(&mut w, "token PEPE solana");
764 assert_eq!(w.list.targets[1].chain.as_deref(), Some("solana"));
765 assert_eq!(w.list.targets[1].preset, None);
766 w.key(key(KeyCode::Char('a')));
767 typed(&mut w, "pair DAI nowhere");
768 assert!(
769 w.error
770 .as_deref()
771 .unwrap()
772 .contains("unknown venue `nowhere`")
773 );
774 w.key(key(KeyCode::Char('a')));
775 typed(&mut w, "pair DAI");
776 assert!(w.error.as_deref().unwrap().contains("needs a venue"));
777 assert_eq!(w.list.targets.len(), 2);
778 w.key(key(KeyCode::Char('d')));
779 assert_eq!(w.list.targets.len(), 1);
780 }
781
782 #[test]
785 fn add_a_target_on_several_venues() {
786 let dir = tempfile::tempdir().unwrap();
787 let mut w = wizard(dir.path());
788 w.key(key(KeyCode::Char('a')));
789 typed(&mut w, "pair USDN weex binance");
790 let [t] = &w.list.targets[..] else {
791 panic!("{:?}", w.list.targets)
792 };
793 assert_eq!(t.venues, ["weex", "binance"]);
794 assert_eq!(t.preset, Some(Preset::MarketHealth));
795 assert!(
796 target_text(t).contains("@weex,binance"),
797 "{}",
798 target_text(t)
799 );
800 w.key(key(KeyCode::Char('a')));
801 typed(&mut w, "token PEPE weex solana binance");
802 let t = &w.list.targets[1];
803 assert_eq!(
804 (t.venues.as_slice(), t.chain.as_deref()),
805 (
806 ["weex".to_string(), "binance".to_string()].as_slice(),
807 Some("solana")
808 )
809 );
810 for bad in ["pair DAI weex weex", "token PEPE solana base"] {
811 w.key(key(KeyCode::Char('a')));
812 typed(&mut w, bad);
813 assert!(w.error.is_some(), "{bad}");
814 }
815 assert_eq!(w.list.targets.len(), 2);
816 }
817
818 #[test]
819 fn metrics_toggle_change_op_and_value() {
820 let dir = tempfile::tempdir().unwrap();
821 let mut w = wizard(dir.path());
822 w.key(key(KeyCode::Char('a')));
823 typed(&mut w, "token PEPE");
824 w.key(key(KeyCode::Tab));
825 assert_eq!(w.step, Step::Metrics);
826 w.key(key(KeyCode::Char(' ')));
828 assert!(w.error.as_deref().unwrap().contains("no venue"));
829 let rows = w.metric_rows();
830 let holders = rows
831 .iter()
832 .position(|r| *r == Some(MetricId::Holders))
833 .unwrap();
834 w.row = holders;
835 w.key(key(KeyCode::Char(' ')));
836 assert_eq!(w.list.targets[0].rules[0].to_string(), "holders > 0");
837 w.key(key(KeyCode::Char('o')));
838 assert_eq!(w.list.targets[0].rules[0].op, Op::Ge);
839 w.key(key(KeyCode::Enter));
840 w.key(key(KeyCode::Backspace));
841 typed(&mut w, "1000");
842 assert_eq!(w.list.targets[0].rules[0].to_string(), "holders >= 1000");
843 w.key(key(KeyCode::Char(' ')));
844 assert!(w.list.targets[0].rules.is_empty());
845 }
846
847 #[test]
848 fn market_metrics_start_from_the_health_thresholds() {
849 assert_eq!(default_rule(MetricId::SpreadPct).op, Op::Le);
850 assert_eq!(
851 default_rule(MetricId::SpreadPct).value,
852 HealthThresholds::default().max_spread_pct
853 );
854 assert_eq!(default_rule(MetricId::BidDepth).op, Op::Ge);
855 }
856
857 #[test]
858 fn timing_edits_parse_and_bad_values_say_why() {
859 let dir = tempfile::tempdir().unwrap();
860 let mut w = wizard(dir.path());
861 w.step = Step::Timing;
862 w.key(key(KeyCode::Enter));
863 for _ in 0..5 {
864 w.key(key(KeyCode::Backspace));
865 }
866 typed(&mut w, "0s");
867 assert!(w.error.is_some());
868 assert_eq!(w.list.interval, Duration::from_secs(60), "unchanged");
869 w.key(key(KeyCode::Enter));
870 for _ in 0..5 {
871 w.key(key(KeyCode::Backspace));
872 }
873 typed(&mut w, "30s");
874 assert_eq!(w.list.interval, Duration::from_secs(30));
875 w.key(key(KeyCode::Down));
876 w.key(key(KeyCode::Enter));
877 for _ in 0..10 {
878 w.key(key(KeyCode::Backspace));
879 }
880 typed(&mut w, "forever");
881 assert_eq!(w.list.duration, None);
882 }
883
884 #[test]
885 fn start_and_save_need_targets_with_checks_and_save_reads_back() {
886 let dir = tempfile::tempdir().unwrap();
887 let mut w = wizard(dir.path());
888 assert_eq!(w.key(ctrl('r')), WizardAction::None);
889 assert!(w.error.as_deref().unwrap().contains("Add a target"));
890 w.key(key(KeyCode::Char('a')));
891 typed(&mut w, "token PEPE");
892 assert_eq!(w.key(ctrl('s')), WizardAction::None);
893 assert!(w.error.as_deref().unwrap().contains("nothing to check"));
894 w.key(key(KeyCode::Char('a')));
895 w.key(key(KeyCode::Esc));
896 w.key(key(KeyCode::Char('d')));
897 w.key(key(KeyCode::Char('a')));
898 typed(&mut w, "pair USDC binance");
899 let WizardAction::Saved(path) = w.key(ctrl('s')) else {
900 panic!("{:?}", w.error)
901 };
902 assert_eq!(
903 scope::watch::file::load(&path, None).unwrap(),
904 w.list,
905 "the file reads back equal"
906 );
907 let WizardAction::Start(list) = w.key(ctrl('r')) else {
908 panic!("{:?}", w.error)
909 };
910 assert_eq!(*list, w.list);
911 }
912
913 #[test]
914 fn esc_with_changes_asks_first() {
915 let dir = tempfile::tempdir().unwrap();
916 let mut w = wizard(dir.path());
917 assert_eq!(
918 w.key(key(KeyCode::Esc)),
919 WizardAction::Leave,
920 "nothing changed"
921 );
922 let mut w = wizard(dir.path());
923 w.key(key(KeyCode::Char('a')));
924 typed(&mut w, "pair USDC binance");
925 assert_eq!(w.key(key(KeyCode::Esc)), WizardAction::None);
926 assert!(w.confirm_leave);
927 assert_eq!(w.key(key(KeyCode::Char('n'))), WizardAction::None);
928 assert!(!w.confirm_leave);
929 w.key(key(KeyCode::Esc));
930 assert_eq!(w.key(key(KeyCode::Char('y'))), WizardAction::Leave);
931 }
932
933 #[test]
934 fn draws_each_step_at_several_sizes() {
935 let dir = tempfile::tempdir().unwrap();
936 let mut w = wizard(dir.path());
937 w.notes.push("--layout has no effect".into());
938 w.key(key(KeyCode::Char('a')));
939 typed(&mut w, "pair USDN weex");
940 for step in Step::ALL {
941 w.step = step;
942 for (cols, rows) in [(120, 36), (80, 24), (40, 10)] {
943 let s = screen(&w, cols, rows);
944 assert!(s.contains("monitor wizard"), "{step:?} {cols}x{rows}\n{s}");
945 }
946 }
947 w.step = Step::Metrics;
948 let s = screen(&w, 120, 36);
949 assert!(s.contains("[x] market health"), "{s}");
950 assert!(s.contains("--layout has no effect"), "{s}");
951 w.step = Step::Timing;
952 let s = screen(&w, 120, 36);
953 assert!(s.contains("1 fetch(es) per run · 61 in all"), "{s}");
954 }
955
956 fn clear(w: &mut WizardState) {
957 for _ in 0..40 {
958 w.key(key(KeyCode::Backspace));
959 }
960 }
961
962 #[test]
963 fn targets_step_moves_the_selection_and_enter_opens_its_metrics() {
964 let dir = tempfile::tempdir().unwrap();
965 let mut w = wizard(dir.path());
966 w.key(key(KeyCode::Down));
967 w.key(key(KeyCode::Enter));
968 assert_eq!((w.step, w.target), (Step::Targets, 0), "no target yet");
969 for t in ["pair USDC binance", "address 0xabc ethereum", "token PEPE"] {
970 w.key(key(KeyCode::Char('a')));
971 typed(&mut w, t);
972 }
973 assert_eq!(w.target, 2, "the new target is selected");
974 w.key(key(KeyCode::Down));
975 assert_eq!(w.target, 0, "down from the last is the first");
976 w.key(key(KeyCode::Up));
977 w.key(key(KeyCode::Up));
978 assert_eq!(w.target, 1);
979 w.row = 3;
980 w.key(key(KeyCode::Enter));
981 assert_eq!((w.step, w.target, w.row), (Step::Metrics, 1, 0));
982 }
983
984 #[test]
985 fn metrics_step_moves_between_rows_and_targets() {
986 let dir = tempfile::tempdir().unwrap();
987 let mut w = wizard(dir.path());
988 w.step = Step::Metrics;
989 assert!(w.metric_rows().is_empty(), "no target");
990 for code in [KeyCode::Down, KeyCode::Right, KeyCode::Char(' ')] {
991 w.key(key(code));
992 }
993 assert_eq!((w.target, w.row, w.dirty), (0, 0, false));
994 assert!(screen(&w, 80, 24).contains("Add a target first."));
995 w.step = Step::Targets;
996 for t in ["pair USDC binance", "token PEPE"] {
997 w.key(key(KeyCode::Char('a')));
998 typed(&mut w, t);
999 }
1000 w.key(key(KeyCode::Tab));
1001 let n = w.metric_rows().len();
1002 w.key(key(KeyCode::Up));
1003 assert_eq!(w.row, n - 1, "up from the first row is the last");
1004 w.key(key(KeyCode::Down));
1005 assert_eq!(w.row, 0);
1006 w.key(key(KeyCode::Down));
1007 w.key(key(KeyCode::Right));
1008 assert_eq!(
1009 (w.target, w.row),
1010 (0, 0),
1011 "right wraps and starts at the top"
1012 );
1013 assert_eq!(w.metric_rows()[0], None, "a pair starts with the preset");
1014 w.key(key(KeyCode::Down));
1015 w.key(key(KeyCode::Left));
1016 assert_eq!((w.target, w.row), (1, 0), "left wraps");
1017 let s = screen(&w, 120, 36);
1018 assert!(s.contains("(2/2)"), "{s}");
1019 }
1020
1021 #[test]
1022 fn the_preset_turns_off_and_on_again() {
1023 let dir = tempfile::tempdir().unwrap();
1024 let mut w = wizard(dir.path());
1025 w.key(key(KeyCode::Char('a')));
1026 typed(&mut w, "pair USDC binance");
1027 w.key(key(KeyCode::Tab));
1028 w.key(key(KeyCode::Char(' ')));
1029 assert_eq!(w.list.targets[0].preset, None);
1030 assert!(screen(&w, 120, 36).contains("[ ] market health"));
1031 w.key(key(KeyCode::Char(' ')));
1032 assert_eq!(w.list.targets[0].preset, Some(Preset::MarketHealth));
1033 w.key(key(KeyCode::Char('o')));
1035 w.key(key(KeyCode::Enter));
1036 assert_eq!(w.edit, None);
1037 }
1038
1039 #[test]
1040 fn o_goes_through_every_operator_and_back() {
1041 let dir = tempfile::tempdir().unwrap();
1042 let mut w = wizard(dir.path());
1043 w.key(key(KeyCode::Char('a')));
1044 typed(&mut w, "token PEPE");
1045 w.key(key(KeyCode::Tab));
1046 w.row = w
1047 .metric_rows()
1048 .iter()
1049 .position(|r| *r == Some(MetricId::Holders))
1050 .unwrap();
1051 w.key(key(KeyCode::Char('o')));
1052 assert!(w.list.targets[0].rules.is_empty(), "no rule to change yet");
1053 w.key(key(KeyCode::Char(' ')));
1054 let ops: Vec<Op> = (0..6)
1055 .map(|_| {
1056 w.key(key(KeyCode::Char('o')));
1057 w.list.targets[0].rules[0].op
1058 })
1059 .collect();
1060 assert_eq!(ops, [Op::Ge, Op::Eq, Op::Ne, Op::Lt, Op::Le, Op::Gt]);
1061 }
1062
1063 #[test]
1064 fn a_value_must_be_a_finite_number() {
1065 let dir = tempfile::tempdir().unwrap();
1066 let mut w = wizard(dir.path());
1067 w.key(key(KeyCode::Char('a')));
1068 typed(&mut w, "token PEPE");
1069 w.key(key(KeyCode::Tab));
1070 w.row = w
1071 .metric_rows()
1072 .iter()
1073 .position(|r| *r == Some(MetricId::Holders))
1074 .unwrap();
1075 w.key(key(KeyCode::Char(' ')));
1076 for bad in ["lots", "inf", ""] {
1077 w.key(key(KeyCode::Enter));
1078 clear(&mut w);
1079 typed(&mut w, bad);
1080 assert_eq!(
1081 w.error.as_deref(),
1082 Some(format!("`{bad}` is not a number").as_str())
1083 );
1084 assert_eq!(w.list.targets[0].rules[0].value, 0.0, "unchanged");
1085 }
1086 w.key(key(KeyCode::Enter));
1087 clear(&mut w);
1088 typed(&mut w, "1_000");
1089 assert_eq!(
1090 w.list.targets[0].rules[0].value, 1000.0,
1091 "`_` groups digits"
1092 );
1093 }
1094
1095 #[test]
1096 fn while_typing_ctrl_and_other_keys_do_not_type() {
1097 let dir = tempfile::tempdir().unwrap();
1098 let mut w = wizard(dir.path());
1099 w.key(key(KeyCode::Char('a')));
1100 w.key(key(KeyCode::Char('p')));
1101 for k in [ctrl('s'), ctrl('r'), key(KeyCode::Left), key(KeyCode::Tab)] {
1102 assert_eq!(w.key(k), WizardAction::None);
1103 }
1104 assert_eq!(w.edit, Some((EditKind::AddTarget, "p".to_string())));
1105 assert_eq!(w.step, Step::Targets);
1106 assert_eq!(w.error, None, "Ctrl-S did not try to save");
1107 w.key(key(KeyCode::Esc));
1108 assert_eq!(w.edit, None, "Esc cancels");
1109 assert!(w.list.targets.is_empty());
1110 }
1111
1112 #[test]
1113 fn shift_tab_goes_back_and_wraps() {
1114 let dir = tempfile::tempdir().unwrap();
1115 let mut w = wizard(dir.path());
1116 w.row = 2;
1117 w.key(key(KeyCode::BackTab));
1118 assert_eq!((w.step, w.row), (Step::Outputs, 0));
1119 w.key(key(KeyCode::BackTab));
1120 assert_eq!(w.step, Step::Timing);
1121 w.key(key(KeyCode::Tab));
1122 w.key(key(KeyCode::Tab));
1123 assert_eq!(w.step, Step::Targets, "Tab wraps too");
1124 }
1125
1126 #[test]
1127 fn the_duration_edit_starts_from_the_current_one_and_counts_the_fetches() {
1128 let dir = tempfile::tempdir().unwrap();
1129 let mut w = wizard(dir.path());
1130 w.key(key(KeyCode::Char('a')));
1131 typed(&mut w, "pair USDC binance");
1132 w.list.duration = Some(Duration::from_secs(7200));
1133 w.step = Step::Timing;
1134 w.key(key(KeyCode::Up));
1135 assert_eq!(w.row, 1, "up from the interval is the duration");
1136 w.key(key(KeyCode::Enter));
1137 assert_eq!(w.edit, Some((EditKind::Duration, format_duration(7200))));
1138 clear(&mut w);
1139 typed(&mut w, "soon");
1140 assert!(w.error.is_some(), "not a duration");
1141 assert_eq!(w.list.duration, Some(Duration::from_secs(7200)));
1142 w.key(key(KeyCode::Enter));
1143 clear(&mut w);
1144 typed(&mut w, "30m");
1145 assert_eq!(w.list.duration, Some(Duration::from_secs(1800)));
1146 let s = screen(&w, 120, 36);
1147 assert!(
1148 s.contains(&format!("Duration {}", format_duration(1800))),
1149 "{s}"
1150 );
1151 assert!(s.contains("1 fetch(es) per run · 31 in all"), "{s}");
1153 }
1154
1155 #[test]
1156 fn a_run_with_no_duration_runs_until_stopped() {
1157 let dir = tempfile::tempdir().unwrap();
1158 let mut w = wizard(dir.path());
1159 w.key(key(KeyCode::Char('a')));
1160 typed(&mut w, "pair USDC binance");
1161 w.list.duration = None;
1162 w.step = Step::Timing;
1163 let s = screen(&w, 120, 36);
1164 assert!(s.contains("Duration until stopped"), "{s}");
1165 assert!(s.contains("1 fetch(es) per run · until stopped"), "{s}");
1166 w.key(key(KeyCode::Down));
1167 w.key(key(KeyCode::Enter));
1168 assert_eq!(
1169 w.edit,
1170 Some((EditKind::Duration, "forever".to_string())),
1171 "the edit starts from a word the parser takes back"
1172 );
1173 w.key(key(KeyCode::Enter));
1174 assert_eq!((w.list.duration, w.error.as_deref()), (None, None));
1175 }
1176
1177 #[test]
1178 fn outputs_edit_the_name_and_paths_then_save_and_start() {
1179 let dir = tempfile::tempdir().unwrap();
1180 let mut w = wizard(dir.path());
1181 w.key(key(KeyCode::Char('a')));
1182 typed(&mut w, "pair USDC binance");
1183 w.step = Step::Outputs;
1184 w.key(key(KeyCode::Up));
1185 assert_eq!(w.row, 4, "up from Name is Start");
1186 w.key(key(KeyCode::Down));
1187 assert_eq!(w.row, 0);
1188 for bad in ["", "a/b", "c:d"] {
1189 w.key(key(KeyCode::Enter));
1190 clear(&mut w);
1191 typed(&mut w, bad);
1192 assert!(
1193 w.error.as_deref().unwrap().contains("a name has no"),
1194 "{bad:?}"
1195 );
1196 assert_eq!(w.list.name, "w", "unchanged");
1197 }
1198 w.key(key(KeyCode::Enter));
1199 assert_eq!(w.edit, Some((EditKind::Name, "w".to_string())));
1200 clear(&mut w);
1201 typed(&mut w, " stables ");
1202 assert_eq!(w.list.name, "stables", "trimmed");
1203 w.key(key(KeyCode::Down));
1204 w.key(key(KeyCode::Enter));
1205 typed(&mut w, "out.csv");
1206 assert_eq!(w.list.outputs.csv, Some(PathBuf::from("out.csv")));
1207 w.key(key(KeyCode::Enter));
1208 assert_eq!(w.edit, Some((EditKind::Csv, "out.csv".to_string())));
1209 clear(&mut w);
1210 w.key(key(KeyCode::Enter));
1211 assert_eq!(w.list.outputs.csv, None, "an empty path is no file");
1212 w.key(key(KeyCode::Down));
1213 w.key(key(KeyCode::Enter));
1214 typed(&mut w, "report.md");
1215 assert_eq!(w.list.outputs.report, Some(PathBuf::from("report.md")));
1216 w.key(key(KeyCode::Enter));
1217 clear(&mut w);
1218 w.key(key(KeyCode::Enter));
1219 assert_eq!(w.list.outputs.report, None);
1220 let s = screen(&w, 120, 36);
1221 assert!(s.contains(&format!("to {}", w.path().display())), "{s}");
1222 w.key(key(KeyCode::Down));
1223 let WizardAction::Saved(path) = w.key(key(KeyCode::Enter)) else {
1224 panic!("{:?}", w.error)
1225 };
1226 assert_eq!(path, w.path());
1227 assert!(path.exists());
1228 assert!(!w.dirty);
1229 w.key(key(KeyCode::Down));
1230 let WizardAction::Start(list) = w.key(key(KeyCode::Enter)) else {
1231 panic!("{:?}", w.error)
1232 };
1233 assert_eq!(list.name, "stables");
1234 }
1235
1236 #[test]
1237 fn add_target_says_how_when_the_line_is_wrong() {
1238 let dir = tempfile::tempdir().unwrap();
1239 let mut w = wizard(dir.path());
1240 for bad in ["pair", "address 0xabc ethereum base", "wallet 0xabc"] {
1243 w.key(key(KeyCode::Char('a')));
1244 typed(&mut w, bad);
1245 assert!(
1246 w.error.as_deref().unwrap().starts_with("type `pair USDC"),
1247 "{bad}: {:?}",
1248 w.error
1249 );
1250 }
1251 assert!(w.list.targets.is_empty());
1252 for line in ["ADDRESS 0xabc binance", "token PEPE WEEX"] {
1255 w.key(key(KeyCode::Char('a')));
1256 typed(&mut w, line);
1257 }
1258 let [a, t] = &w.list.targets[..] else {
1259 panic!("{:?}", w.list.targets)
1260 };
1261 assert_eq!(
1262 (a.kind, a.chain.as_deref(), a.venues.is_empty()),
1263 (TargetKind::Address, Some("binance"), true)
1264 );
1265 assert_eq!(
1266 (t.venues.as_slice(), t.preset),
1267 (["WEEX".to_string()].as_slice(), Some(Preset::MarketHealth))
1268 );
1269 }
1270
1271 #[test]
1272 fn a_save_that_fails_says_why_and_stays_dirty() {
1273 let dir = tempfile::tempdir().unwrap();
1274 let file = dir.path().join("not-a-folder");
1276 std::fs::write(&file, "").unwrap();
1277 let mut w = wizard(&file);
1278 w.key(key(KeyCode::Char('a')));
1279 typed(&mut w, "pair USDC binance");
1280 assert_eq!(w.key(ctrl('s')), WizardAction::None);
1281 assert!(
1282 w.error.as_deref().unwrap().starts_with("Could not save"),
1283 "{:?}",
1284 w.error
1285 );
1286 assert!(w.dirty);
1287 assert!(screen(&w, 120, 36).contains("Could not save"));
1288 }
1289
1290 #[test]
1291 fn draws_the_targets_the_line_being_typed_and_the_question() {
1292 let dir = tempfile::tempdir().unwrap();
1293 let mut w = wizard(dir.path());
1294 let s = screen(&w, 120, 36);
1295 assert!(s.contains("No targets yet: press a to add one."), "{s}");
1296 assert!(!s.contains("Ready"), "nothing to start: {s}");
1297 for t in ["address 0xabc ethereum", "token PEPE"] {
1298 w.key(key(KeyCode::Char('a')));
1299 typed(&mut w, t);
1300 }
1301 w.list.targets[1]
1302 .rules
1303 .push(default_rule(MetricId::Holders));
1304 let s = screen(&w, 120, 36);
1305 assert!(s.contains("0xabc on ethereum"), "{s}");
1306 assert!(s.contains("PEPE 1 rule(s)"), "{s}");
1307 for (kind, label) in [
1308 (EditKind::AddTarget, "Add"),
1309 (EditKind::Value(MetricId::Holders), "holders value"),
1310 (EditKind::Interval, "Interval"),
1311 (EditKind::Duration, "Duration"),
1312 (EditKind::Name, "Name"),
1313 (EditKind::Csv, "CSV"),
1314 (EditKind::Report, "Report"),
1315 ] {
1316 let hint = kind == EditKind::AddTarget;
1317 w.edit = Some((kind, "abc".to_string()));
1318 let s = screen(&w, 120, 36);
1319 assert!(s.contains(&format!(" {label}: abc█")), "{label}\n{s}");
1320 assert_eq!(s.contains("Enter adds · Esc cancels"), hint, "{s}");
1321 }
1322 w.edit = None;
1323 w.key(key(KeyCode::Esc));
1324 assert!(screen(&w, 120, 36).contains("Leave without saving? [y/N]"));
1325 w.key(key(KeyCode::Char('n')));
1326 w.dirty = false;
1327 let s = screen(&w, 120, 36);
1328 assert!(!s.contains("Leave without saving"), "{s}");
1329 assert!(!s.contains("Ready"), "the address checks nothing: {s}");
1330 w.list.targets[0]
1331 .rules
1332 .push(default_rule(MetricId::RiskScore));
1333 let s = screen(&w, 120, 36);
1334 assert!(s.contains("Ready: Ctrl-R starts the run."), "{s}");
1335 }
1336}