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.venue.is_none() => {
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> {
397 const USAGE: &str =
398 "type `pair USDC binance`, `token PEPE [chain|venue]` or `address 0x… [chain]`";
399 let words: Vec<&str> = text.split_whitespace().collect();
400 let (kind, id, extra) = match words.as_slice() {
401 [kind, id] => (*kind, *id, None),
402 [kind, id, extra] => (*kind, *id, Some(*extra)),
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 if let Some(extra) = extra {
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) => {
416 t.chain = Some(extra.to_string())
417 }
418 (_, true) => t.venue = Some(extra.to_string()),
419 (TargetKind::Pair, false) => {
420 return Err(format!(
421 "unknown venue `{extra}`; the venues are {}",
422 self.venues.join(", ")
423 ));
424 }
425 }
426 }
427 if kind == TargetKind::Pair && t.venue.is_none() {
428 return Err("a pair needs a venue: `pair USDC binance`".to_string());
429 }
430 if t.venue.is_some() {
431 t.preset = Some(Preset::MarketHealth);
432 }
433 self.list.targets.push(t);
434 self.target = self.list.targets.len() - 1;
435 Ok(())
436 }
437
438 pub fn problem(&self) -> Option<String> {
440 if self.list.targets.is_empty() {
441 return Some("Add a target first (Targets step, `a`).".to_string());
442 }
443 self.list
444 .targets
445 .iter()
446 .find(|t| t.preset.is_none() && t.rules.is_empty())
447 .map(|t| {
448 format!(
449 "{} has nothing to check: turn on a metric or the preset (Metrics step).",
450 t.label()
451 )
452 })
453 }
454
455 pub fn path(&self) -> PathBuf {
457 scope::watch::file::path_in(&self.dir, &self.list.name)
458 }
459
460 fn save(&mut self) -> WizardAction {
461 if let Some(p) = self.problem() {
462 self.error = Some(p);
463 return WizardAction::None;
464 }
465 let path = self.path();
466 match scope::watch::file::save(&path, &self.list) {
467 Ok(()) => {
468 self.dirty = false;
469 WizardAction::Saved(path)
470 }
471 Err(e) => {
472 self.error = Some(format!("Could not save {}: {e}", path.display()));
473 WizardAction::None
474 }
475 }
476 }
477
478 fn start(&mut self) -> WizardAction {
479 if let Some(p) = self.problem() {
480 self.error = Some(p);
481 return WizardAction::None;
482 }
483 WizardAction::Start(Box::new(self.list.clone()))
484 }
485}
486
487fn default_rule(metric: MetricId) -> Rule {
490 let (op, value) = match describe(metric, &HealthThresholds::default()).threshold {
491 Some(Threshold::Max(v)) => (Op::Le, v),
492 Some(Threshold::Min(v)) => (Op::Ge, v),
493 Some(Threshold::Target(_)) | None => (Op::Gt, 0.0),
494 };
495 Rule { metric, op, value }
496}
497
498fn next_op(op: Op) -> Op {
499 match op {
500 Op::Lt => Op::Le,
501 Op::Le => Op::Gt,
502 Op::Gt => Op::Ge,
503 Op::Ge => Op::Eq,
504 Op::Eq => Op::Ne,
505 Op::Ne => Op::Lt,
506 }
507}
508
509fn path_text(p: &Option<PathBuf>) -> String {
510 p.as_deref()
511 .map(Path::display)
512 .map(|d| d.to_string())
513 .unwrap_or_default()
514}
515
516fn row(selected: bool, text: String) -> Line<'static> {
518 if selected {
519 Line::from(vec![Span::styled("> ", KEY), Span::styled(text, SELECTED)])
520 } else {
521 Line::from(vec![Span::raw(" "), Span::raw(text)])
522 }
523}
524
525fn target_text(t: &Target) -> String {
526 let mut s = format!("{:<8} {}", t.kind.name(), t.id);
527 if let Some(v) = &t.venue {
528 s.push_str(&format!(" @{v}"));
529 }
530 if let Some(c) = &t.chain {
531 s.push_str(&format!(" on {c}"));
532 }
533 if t.preset.is_some() {
534 s.push_str(" [market health]");
535 }
536 if !t.rules.is_empty() {
537 s.push_str(&format!(" {} rule(s)", t.rules.len()));
538 }
539 s
540}
541
542pub fn draw(f: &mut Frame, area: Rect, w: &WizardState) {
544 let block = Block::new()
545 .borders(Borders::TOP | Borders::BOTTOM)
546 .border_style(ACCENT)
547 .title(Line::styled(
548 format!(" monitor wizard · {} ", w.list.name),
549 ACCENT.add_modifier(Modifier::BOLD),
550 ));
551 let inner = block.inner(area);
552 f.render_widget(block, area);
553 let mut lines: Vec<Line> = Vec::new();
554 let mut tabs = vec![Span::raw(" ")];
555 for s in Step::ALL {
556 let style = if s == w.step {
557 SELECTED.add_modifier(Modifier::BOLD)
558 } else {
559 SECONDARY
560 };
561 tabs.push(Span::styled(
562 format!(" {} {} ", s.index() + 1, s.name()),
563 style,
564 ));
565 }
566 lines.push(Line::from(tabs));
567 for n in &w.notes {
568 lines.push(Line::styled(format!(" {n}"), WARN));
569 }
570 lines.push(Line::raw(""));
571 match w.step {
572 Step::Targets => {
573 if w.list.targets.is_empty() {
574 lines.push(Line::styled(
575 " No targets yet: press a to add one.",
576 SECONDARY,
577 ));
578 }
579 for (i, t) in w.list.targets.iter().enumerate() {
580 lines.push(row(i == w.target, target_text(t)));
581 }
582 }
583 Step::Metrics => match w.list.targets.get(w.target) {
584 None => lines.push(Line::styled(" Add a target first.", SECONDARY)),
585 Some(t) => {
586 lines.push(Line::styled(
587 format!(
588 " {} ({}/{}) ←→ other target",
589 t.label(),
590 w.target + 1,
591 w.list.targets.len()
592 ),
593 ACCENT,
594 ));
595 for (i, r) in w.metric_rows().iter().enumerate() {
596 let text = match r {
597 None => format!(
598 "[{}] market health (the checks of market summary)",
599 if t.preset.is_some() { "x" } else { " " }
600 ),
601 Some(m) => match t.rules.iter().find(|x| x.metric == *m) {
602 Some(rule) => format!("[x] {rule}"),
603 None => format!("[ ] {}", m.name()),
604 },
605 };
606 lines.push(row(i == w.row, text));
607 }
608 }
609 },
610 Step::Timing => {
611 let duration = w.list.duration.map_or_else(
612 || "until stopped".to_string(),
613 |d| format_duration(d.as_secs()),
614 );
615 lines.push(row(
616 w.row == 0,
617 format!("Interval {}", format_duration(w.list.interval.as_secs())),
618 ));
619 lines.push(row(w.row == 1, format!("Duration {duration}")));
620 let per = w.list.fetches_per_tick();
621 let total = w.list.ticks().map_or_else(
622 || "until stopped".to_string(),
623 |t| format!("{} in all", t * per as u64),
624 );
625 lines.push(Line::raw(""));
626 lines.push(Line::styled(
627 format!(" {per} fetch(es) per run · {total}"),
628 SECONDARY,
629 ));
630 }
631 Step::Outputs => {
632 let values = [
633 w.list.name.clone(),
634 path_text(&w.list.outputs.csv),
635 path_text(&w.list.outputs.report),
636 format!("to {}", w.path().display()),
637 "the grid".to_string(),
638 ];
639 for (i, (label, value)) in OUTPUT_ROWS.iter().zip(values).enumerate() {
640 lines.push(row(i == w.row, format!("{label:<7} {value}")));
641 }
642 }
643 }
644 lines.push(Line::raw(""));
645 if let Some((kind, text)) = &w.edit {
646 let label = match kind {
647 EditKind::AddTarget => "Add".to_string(),
648 EditKind::Value(m) => format!("{} value", m.name()),
649 EditKind::Interval => "Interval".to_string(),
650 EditKind::Duration => "Duration".to_string(),
651 EditKind::Name => "Name".to_string(),
652 EditKind::Csv => "CSV".to_string(),
653 EditKind::Report => "Report".to_string(),
654 };
655 lines.push(Line::from(vec![
656 Span::styled(format!(" {label}: "), KEY),
657 Span::raw(text.clone()),
658 Span::styled("█", ACCENT),
659 ]));
660 if *kind == EditKind::AddTarget {
661 lines.push(Line::styled(
662 " pair USDC binance · token PEPE solana · address 0x… ethereum · Enter adds · Esc cancels",
663 SECONDARY,
664 ));
665 }
666 }
667 if w.confirm_leave {
668 lines.push(Line::styled(
669 " Leave without saving? [y/N]",
670 WARN.add_modifier(Modifier::BOLD),
671 ));
672 }
673 if let Some(e) = &w.error {
674 lines.push(Line::styled(format!(" {e}"), ERR));
675 } else if w.problem().is_none() && !w.dirty {
676 lines.push(Line::styled(" Ready: Ctrl-R starts the run.", OK));
677 }
678 let body = Rect {
680 height: inner.height.saturating_sub(1),
681 ..inner
682 };
683 let first = lines.len().saturating_sub(body.height as usize);
684 f.render_widget(
685 Paragraph::new(lines[first.min(lines.len())..].to_vec()),
686 body,
687 );
688 if inner.height > 0 {
689 let last = Rect::new(inner.x, inner.bottom() - 1, inner.width, 1);
690 f.render_widget(
691 Paragraph::new(Line::styled(format!(" {}", legend(w.step)), KEY)),
692 last,
693 );
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700 use ratatui::Terminal;
701 use ratatui::backend::TestBackend;
702
703 fn key(code: KeyCode) -> KeyEvent {
704 KeyEvent::new(code, KeyModifiers::NONE)
705 }
706
707 fn ctrl(c: char) -> KeyEvent {
708 KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
709 }
710
711 fn typed(w: &mut WizardState, text: &str) {
712 for c in text.chars() {
713 w.key(key(KeyCode::Char(c)));
714 }
715 w.key(key(KeyCode::Enter));
716 }
717
718 fn wizard(dir: &Path) -> WizardState {
719 WizardState::new(
720 Watchlist::new("w"),
721 Vec::new(),
722 vec!["binance".into(), "weex".into()],
723 dir.to_path_buf(),
724 )
725 }
726
727 fn screen(w: &WizardState, cols: u16, rows: u16) -> String {
728 let mut t = Terminal::new(TestBackend::new(cols, rows)).unwrap();
729 t.draw(|f| draw(f, f.area(), w)).unwrap();
730 let buf = t.backend().buffer().clone();
731 let mut s = String::new();
732 for y in 0..rows {
733 for x in 0..cols {
734 s.push_str(buf[(x, y)].symbol());
735 }
736 s.push('\n');
737 }
738 s
739 }
740
741 #[test]
742 fn add_targets_with_venue_or_chain_and_delete() {
743 let dir = tempfile::tempdir().unwrap();
744 let mut w = wizard(dir.path());
745 w.key(key(KeyCode::Char('a')));
746 typed(&mut w, "pair USDC binance");
747 let t = &w.list.targets[0];
748 assert_eq!(
749 (t.kind, t.venue.as_deref(), t.preset),
750 (
751 TargetKind::Pair,
752 Some("binance"),
753 Some(Preset::MarketHealth)
754 )
755 );
756 w.key(key(KeyCode::Char('a')));
757 typed(&mut w, "token PEPE solana");
758 assert_eq!(w.list.targets[1].chain.as_deref(), Some("solana"));
759 assert_eq!(w.list.targets[1].preset, None);
760 w.key(key(KeyCode::Char('a')));
761 typed(&mut w, "pair DAI nowhere");
762 assert!(
763 w.error
764 .as_deref()
765 .unwrap()
766 .contains("unknown venue `nowhere`")
767 );
768 w.key(key(KeyCode::Char('a')));
769 typed(&mut w, "pair DAI");
770 assert!(w.error.as_deref().unwrap().contains("needs a venue"));
771 assert_eq!(w.list.targets.len(), 2);
772 w.key(key(KeyCode::Char('d')));
773 assert_eq!(w.list.targets.len(), 1);
774 }
775
776 #[test]
777 fn metrics_toggle_change_op_and_value() {
778 let dir = tempfile::tempdir().unwrap();
779 let mut w = wizard(dir.path());
780 w.key(key(KeyCode::Char('a')));
781 typed(&mut w, "token PEPE");
782 w.key(key(KeyCode::Tab));
783 assert_eq!(w.step, Step::Metrics);
784 w.key(key(KeyCode::Char(' ')));
786 assert!(w.error.as_deref().unwrap().contains("no venue"));
787 let rows = w.metric_rows();
788 let holders = rows
789 .iter()
790 .position(|r| *r == Some(MetricId::Holders))
791 .unwrap();
792 w.row = holders;
793 w.key(key(KeyCode::Char(' ')));
794 assert_eq!(w.list.targets[0].rules[0].to_string(), "holders > 0");
795 w.key(key(KeyCode::Char('o')));
796 assert_eq!(w.list.targets[0].rules[0].op, Op::Ge);
797 w.key(key(KeyCode::Enter));
798 w.key(key(KeyCode::Backspace));
799 typed(&mut w, "1000");
800 assert_eq!(w.list.targets[0].rules[0].to_string(), "holders >= 1000");
801 w.key(key(KeyCode::Char(' ')));
802 assert!(w.list.targets[0].rules.is_empty());
803 }
804
805 #[test]
806 fn market_metrics_start_from_the_health_thresholds() {
807 assert_eq!(default_rule(MetricId::SpreadPct).op, Op::Le);
808 assert_eq!(
809 default_rule(MetricId::SpreadPct).value,
810 HealthThresholds::default().max_spread_pct
811 );
812 assert_eq!(default_rule(MetricId::BidDepth).op, Op::Ge);
813 }
814
815 #[test]
816 fn timing_edits_parse_and_bad_values_say_why() {
817 let dir = tempfile::tempdir().unwrap();
818 let mut w = wizard(dir.path());
819 w.step = Step::Timing;
820 w.key(key(KeyCode::Enter));
821 for _ in 0..5 {
822 w.key(key(KeyCode::Backspace));
823 }
824 typed(&mut w, "0s");
825 assert!(w.error.is_some());
826 assert_eq!(w.list.interval, Duration::from_secs(60), "unchanged");
827 w.key(key(KeyCode::Enter));
828 for _ in 0..5 {
829 w.key(key(KeyCode::Backspace));
830 }
831 typed(&mut w, "30s");
832 assert_eq!(w.list.interval, Duration::from_secs(30));
833 w.key(key(KeyCode::Down));
834 w.key(key(KeyCode::Enter));
835 for _ in 0..10 {
836 w.key(key(KeyCode::Backspace));
837 }
838 typed(&mut w, "forever");
839 assert_eq!(w.list.duration, None);
840 }
841
842 #[test]
843 fn start_and_save_need_targets_with_checks_and_save_reads_back() {
844 let dir = tempfile::tempdir().unwrap();
845 let mut w = wizard(dir.path());
846 assert_eq!(w.key(ctrl('r')), WizardAction::None);
847 assert!(w.error.as_deref().unwrap().contains("Add a target"));
848 w.key(key(KeyCode::Char('a')));
849 typed(&mut w, "token PEPE");
850 assert_eq!(w.key(ctrl('s')), WizardAction::None);
851 assert!(w.error.as_deref().unwrap().contains("nothing to check"));
852 w.key(key(KeyCode::Char('a')));
853 w.key(key(KeyCode::Esc));
854 w.key(key(KeyCode::Char('d')));
855 w.key(key(KeyCode::Char('a')));
856 typed(&mut w, "pair USDC binance");
857 let WizardAction::Saved(path) = w.key(ctrl('s')) else {
858 panic!("{:?}", w.error)
859 };
860 assert_eq!(
861 scope::watch::file::load(&path, None).unwrap(),
862 w.list,
863 "the file reads back equal"
864 );
865 let WizardAction::Start(list) = w.key(ctrl('r')) else {
866 panic!("{:?}", w.error)
867 };
868 assert_eq!(*list, w.list);
869 }
870
871 #[test]
872 fn esc_with_changes_asks_first() {
873 let dir = tempfile::tempdir().unwrap();
874 let mut w = wizard(dir.path());
875 assert_eq!(
876 w.key(key(KeyCode::Esc)),
877 WizardAction::Leave,
878 "nothing changed"
879 );
880 let mut w = wizard(dir.path());
881 w.key(key(KeyCode::Char('a')));
882 typed(&mut w, "pair USDC binance");
883 assert_eq!(w.key(key(KeyCode::Esc)), WizardAction::None);
884 assert!(w.confirm_leave);
885 assert_eq!(w.key(key(KeyCode::Char('n'))), WizardAction::None);
886 assert!(!w.confirm_leave);
887 w.key(key(KeyCode::Esc));
888 assert_eq!(w.key(key(KeyCode::Char('y'))), WizardAction::Leave);
889 }
890
891 #[test]
892 fn draws_each_step_at_several_sizes() {
893 let dir = tempfile::tempdir().unwrap();
894 let mut w = wizard(dir.path());
895 w.notes.push("--layout has no effect".into());
896 w.key(key(KeyCode::Char('a')));
897 typed(&mut w, "pair USDN weex");
898 for step in Step::ALL {
899 w.step = step;
900 for (cols, rows) in [(120, 36), (80, 24), (40, 10)] {
901 let s = screen(&w, cols, rows);
902 assert!(s.contains("monitor wizard"), "{step:?} {cols}x{rows}\n{s}");
903 }
904 }
905 w.step = Step::Metrics;
906 let s = screen(&w, 120, 36);
907 assert!(s.contains("[x] market health"), "{s}");
908 assert!(s.contains("--layout has no effect"), "{s}");
909 w.step = Step::Timing;
910 let s = screen(&w, 120, 36);
911 assert!(s.contains("1 fetch(es) per run · 61 in all"), "{s}");
912 }
913
914 fn clear(w: &mut WizardState) {
915 for _ in 0..40 {
916 w.key(key(KeyCode::Backspace));
917 }
918 }
919
920 #[test]
921 fn targets_step_moves_the_selection_and_enter_opens_its_metrics() {
922 let dir = tempfile::tempdir().unwrap();
923 let mut w = wizard(dir.path());
924 w.key(key(KeyCode::Down));
925 w.key(key(KeyCode::Enter));
926 assert_eq!((w.step, w.target), (Step::Targets, 0), "no target yet");
927 for t in ["pair USDC binance", "address 0xabc ethereum", "token PEPE"] {
928 w.key(key(KeyCode::Char('a')));
929 typed(&mut w, t);
930 }
931 assert_eq!(w.target, 2, "the new target is selected");
932 w.key(key(KeyCode::Down));
933 assert_eq!(w.target, 0, "down from the last is the first");
934 w.key(key(KeyCode::Up));
935 w.key(key(KeyCode::Up));
936 assert_eq!(w.target, 1);
937 w.row = 3;
938 w.key(key(KeyCode::Enter));
939 assert_eq!((w.step, w.target, w.row), (Step::Metrics, 1, 0));
940 }
941
942 #[test]
943 fn metrics_step_moves_between_rows_and_targets() {
944 let dir = tempfile::tempdir().unwrap();
945 let mut w = wizard(dir.path());
946 w.step = Step::Metrics;
947 assert!(w.metric_rows().is_empty(), "no target");
948 for code in [KeyCode::Down, KeyCode::Right, KeyCode::Char(' ')] {
949 w.key(key(code));
950 }
951 assert_eq!((w.target, w.row, w.dirty), (0, 0, false));
952 assert!(screen(&w, 80, 24).contains("Add a target first."));
953 w.step = Step::Targets;
954 for t in ["pair USDC binance", "token PEPE"] {
955 w.key(key(KeyCode::Char('a')));
956 typed(&mut w, t);
957 }
958 w.key(key(KeyCode::Tab));
959 let n = w.metric_rows().len();
960 w.key(key(KeyCode::Up));
961 assert_eq!(w.row, n - 1, "up from the first row is the last");
962 w.key(key(KeyCode::Down));
963 assert_eq!(w.row, 0);
964 w.key(key(KeyCode::Down));
965 w.key(key(KeyCode::Right));
966 assert_eq!(
967 (w.target, w.row),
968 (0, 0),
969 "right wraps and starts at the top"
970 );
971 assert_eq!(w.metric_rows()[0], None, "a pair starts with the preset");
972 w.key(key(KeyCode::Down));
973 w.key(key(KeyCode::Left));
974 assert_eq!((w.target, w.row), (1, 0), "left wraps");
975 let s = screen(&w, 120, 36);
976 assert!(s.contains("(2/2)"), "{s}");
977 }
978
979 #[test]
980 fn the_preset_turns_off_and_on_again() {
981 let dir = tempfile::tempdir().unwrap();
982 let mut w = wizard(dir.path());
983 w.key(key(KeyCode::Char('a')));
984 typed(&mut w, "pair USDC binance");
985 w.key(key(KeyCode::Tab));
986 w.key(key(KeyCode::Char(' ')));
987 assert_eq!(w.list.targets[0].preset, None);
988 assert!(screen(&w, 120, 36).contains("[ ] market health"));
989 w.key(key(KeyCode::Char(' ')));
990 assert_eq!(w.list.targets[0].preset, Some(Preset::MarketHealth));
991 w.key(key(KeyCode::Char('o')));
993 w.key(key(KeyCode::Enter));
994 assert_eq!(w.edit, None);
995 }
996
997 #[test]
998 fn o_goes_through_every_operator_and_back() {
999 let dir = tempfile::tempdir().unwrap();
1000 let mut w = wizard(dir.path());
1001 w.key(key(KeyCode::Char('a')));
1002 typed(&mut w, "token PEPE");
1003 w.key(key(KeyCode::Tab));
1004 w.row = w
1005 .metric_rows()
1006 .iter()
1007 .position(|r| *r == Some(MetricId::Holders))
1008 .unwrap();
1009 w.key(key(KeyCode::Char('o')));
1010 assert!(w.list.targets[0].rules.is_empty(), "no rule to change yet");
1011 w.key(key(KeyCode::Char(' ')));
1012 let ops: Vec<Op> = (0..6)
1013 .map(|_| {
1014 w.key(key(KeyCode::Char('o')));
1015 w.list.targets[0].rules[0].op
1016 })
1017 .collect();
1018 assert_eq!(ops, [Op::Ge, Op::Eq, Op::Ne, Op::Lt, Op::Le, Op::Gt]);
1019 }
1020
1021 #[test]
1022 fn a_value_must_be_a_finite_number() {
1023 let dir = tempfile::tempdir().unwrap();
1024 let mut w = wizard(dir.path());
1025 w.key(key(KeyCode::Char('a')));
1026 typed(&mut w, "token PEPE");
1027 w.key(key(KeyCode::Tab));
1028 w.row = w
1029 .metric_rows()
1030 .iter()
1031 .position(|r| *r == Some(MetricId::Holders))
1032 .unwrap();
1033 w.key(key(KeyCode::Char(' ')));
1034 for bad in ["lots", "inf", ""] {
1035 w.key(key(KeyCode::Enter));
1036 clear(&mut w);
1037 typed(&mut w, bad);
1038 assert_eq!(
1039 w.error.as_deref(),
1040 Some(format!("`{bad}` is not a number").as_str())
1041 );
1042 assert_eq!(w.list.targets[0].rules[0].value, 0.0, "unchanged");
1043 }
1044 w.key(key(KeyCode::Enter));
1045 clear(&mut w);
1046 typed(&mut w, "1_000");
1047 assert_eq!(
1048 w.list.targets[0].rules[0].value, 1000.0,
1049 "`_` groups digits"
1050 );
1051 }
1052
1053 #[test]
1054 fn while_typing_ctrl_and_other_keys_do_not_type() {
1055 let dir = tempfile::tempdir().unwrap();
1056 let mut w = wizard(dir.path());
1057 w.key(key(KeyCode::Char('a')));
1058 w.key(key(KeyCode::Char('p')));
1059 for k in [ctrl('s'), ctrl('r'), key(KeyCode::Left), key(KeyCode::Tab)] {
1060 assert_eq!(w.key(k), WizardAction::None);
1061 }
1062 assert_eq!(w.edit, Some((EditKind::AddTarget, "p".to_string())));
1063 assert_eq!(w.step, Step::Targets);
1064 assert_eq!(w.error, None, "Ctrl-S did not try to save");
1065 w.key(key(KeyCode::Esc));
1066 assert_eq!(w.edit, None, "Esc cancels");
1067 assert!(w.list.targets.is_empty());
1068 }
1069
1070 #[test]
1071 fn shift_tab_goes_back_and_wraps() {
1072 let dir = tempfile::tempdir().unwrap();
1073 let mut w = wizard(dir.path());
1074 w.row = 2;
1075 w.key(key(KeyCode::BackTab));
1076 assert_eq!((w.step, w.row), (Step::Outputs, 0));
1077 w.key(key(KeyCode::BackTab));
1078 assert_eq!(w.step, Step::Timing);
1079 w.key(key(KeyCode::Tab));
1080 w.key(key(KeyCode::Tab));
1081 assert_eq!(w.step, Step::Targets, "Tab wraps too");
1082 }
1083
1084 #[test]
1085 fn the_duration_edit_starts_from_the_current_one_and_counts_the_fetches() {
1086 let dir = tempfile::tempdir().unwrap();
1087 let mut w = wizard(dir.path());
1088 w.key(key(KeyCode::Char('a')));
1089 typed(&mut w, "pair USDC binance");
1090 w.list.duration = Some(Duration::from_secs(7200));
1091 w.step = Step::Timing;
1092 w.key(key(KeyCode::Up));
1093 assert_eq!(w.row, 1, "up from the interval is the duration");
1094 w.key(key(KeyCode::Enter));
1095 assert_eq!(w.edit, Some((EditKind::Duration, format_duration(7200))));
1096 clear(&mut w);
1097 typed(&mut w, "soon");
1098 assert!(w.error.is_some(), "not a duration");
1099 assert_eq!(w.list.duration, Some(Duration::from_secs(7200)));
1100 w.key(key(KeyCode::Enter));
1101 clear(&mut w);
1102 typed(&mut w, "30m");
1103 assert_eq!(w.list.duration, Some(Duration::from_secs(1800)));
1104 let s = screen(&w, 120, 36);
1105 assert!(
1106 s.contains(&format!("Duration {}", format_duration(1800))),
1107 "{s}"
1108 );
1109 assert!(s.contains("1 fetch(es) per run · 31 in all"), "{s}");
1111 }
1112
1113 #[test]
1114 fn a_run_with_no_duration_runs_until_stopped() {
1115 let dir = tempfile::tempdir().unwrap();
1116 let mut w = wizard(dir.path());
1117 w.key(key(KeyCode::Char('a')));
1118 typed(&mut w, "pair USDC binance");
1119 w.list.duration = None;
1120 w.step = Step::Timing;
1121 let s = screen(&w, 120, 36);
1122 assert!(s.contains("Duration until stopped"), "{s}");
1123 assert!(s.contains("1 fetch(es) per run · until stopped"), "{s}");
1124 w.key(key(KeyCode::Down));
1125 w.key(key(KeyCode::Enter));
1126 assert_eq!(
1127 w.edit,
1128 Some((EditKind::Duration, "forever".to_string())),
1129 "the edit starts from a word the parser takes back"
1130 );
1131 w.key(key(KeyCode::Enter));
1132 assert_eq!((w.list.duration, w.error.as_deref()), (None, None));
1133 }
1134
1135 #[test]
1136 fn outputs_edit_the_name_and_paths_then_save_and_start() {
1137 let dir = tempfile::tempdir().unwrap();
1138 let mut w = wizard(dir.path());
1139 w.key(key(KeyCode::Char('a')));
1140 typed(&mut w, "pair USDC binance");
1141 w.step = Step::Outputs;
1142 w.key(key(KeyCode::Up));
1143 assert_eq!(w.row, 4, "up from Name is Start");
1144 w.key(key(KeyCode::Down));
1145 assert_eq!(w.row, 0);
1146 for bad in ["", "a/b", "c:d"] {
1147 w.key(key(KeyCode::Enter));
1148 clear(&mut w);
1149 typed(&mut w, bad);
1150 assert!(
1151 w.error.as_deref().unwrap().contains("a name has no"),
1152 "{bad:?}"
1153 );
1154 assert_eq!(w.list.name, "w", "unchanged");
1155 }
1156 w.key(key(KeyCode::Enter));
1157 assert_eq!(w.edit, Some((EditKind::Name, "w".to_string())));
1158 clear(&mut w);
1159 typed(&mut w, " stables ");
1160 assert_eq!(w.list.name, "stables", "trimmed");
1161 w.key(key(KeyCode::Down));
1162 w.key(key(KeyCode::Enter));
1163 typed(&mut w, "out.csv");
1164 assert_eq!(w.list.outputs.csv, Some(PathBuf::from("out.csv")));
1165 w.key(key(KeyCode::Enter));
1166 assert_eq!(w.edit, Some((EditKind::Csv, "out.csv".to_string())));
1167 clear(&mut w);
1168 w.key(key(KeyCode::Enter));
1169 assert_eq!(w.list.outputs.csv, None, "an empty path is no file");
1170 w.key(key(KeyCode::Down));
1171 w.key(key(KeyCode::Enter));
1172 typed(&mut w, "report.md");
1173 assert_eq!(w.list.outputs.report, Some(PathBuf::from("report.md")));
1174 w.key(key(KeyCode::Enter));
1175 clear(&mut w);
1176 w.key(key(KeyCode::Enter));
1177 assert_eq!(w.list.outputs.report, None);
1178 let s = screen(&w, 120, 36);
1179 assert!(s.contains(&format!("to {}", w.path().display())), "{s}");
1180 w.key(key(KeyCode::Down));
1181 let WizardAction::Saved(path) = w.key(key(KeyCode::Enter)) else {
1182 panic!("{:?}", w.error)
1183 };
1184 assert_eq!(path, w.path());
1185 assert!(path.exists());
1186 assert!(!w.dirty);
1187 w.key(key(KeyCode::Down));
1188 let WizardAction::Start(list) = w.key(key(KeyCode::Enter)) else {
1189 panic!("{:?}", w.error)
1190 };
1191 assert_eq!(list.name, "stables");
1192 }
1193
1194 #[test]
1195 fn add_target_says_how_when_the_line_is_wrong() {
1196 let dir = tempfile::tempdir().unwrap();
1197 let mut w = wizard(dir.path());
1198 for bad in ["pair", "pair USDC binance extra", "wallet 0xabc"] {
1199 w.key(key(KeyCode::Char('a')));
1200 typed(&mut w, bad);
1201 assert!(
1202 w.error.as_deref().unwrap().starts_with("type `pair USDC"),
1203 "{bad}: {:?}",
1204 w.error
1205 );
1206 }
1207 assert!(w.list.targets.is_empty());
1208 for line in ["ADDRESS 0xabc binance", "token PEPE WEEX"] {
1211 w.key(key(KeyCode::Char('a')));
1212 typed(&mut w, line);
1213 }
1214 let [a, t] = &w.list.targets[..] else {
1215 panic!("{:?}", w.list.targets)
1216 };
1217 assert_eq!(
1218 (a.kind, a.chain.as_deref(), a.venue.as_deref()),
1219 (TargetKind::Address, Some("binance"), None)
1220 );
1221 assert_eq!(
1222 (t.venue.as_deref(), t.preset),
1223 (Some("WEEX"), Some(Preset::MarketHealth))
1224 );
1225 }
1226
1227 #[test]
1228 fn a_save_that_fails_says_why_and_stays_dirty() {
1229 let dir = tempfile::tempdir().unwrap();
1230 let file = dir.path().join("not-a-folder");
1232 std::fs::write(&file, "").unwrap();
1233 let mut w = wizard(&file);
1234 w.key(key(KeyCode::Char('a')));
1235 typed(&mut w, "pair USDC binance");
1236 assert_eq!(w.key(ctrl('s')), WizardAction::None);
1237 assert!(
1238 w.error.as_deref().unwrap().starts_with("Could not save"),
1239 "{:?}",
1240 w.error
1241 );
1242 assert!(w.dirty);
1243 assert!(screen(&w, 120, 36).contains("Could not save"));
1244 }
1245
1246 #[test]
1247 fn draws_the_targets_the_line_being_typed_and_the_question() {
1248 let dir = tempfile::tempdir().unwrap();
1249 let mut w = wizard(dir.path());
1250 let s = screen(&w, 120, 36);
1251 assert!(s.contains("No targets yet: press a to add one."), "{s}");
1252 assert!(!s.contains("Ready"), "nothing to start: {s}");
1253 for t in ["address 0xabc ethereum", "token PEPE"] {
1254 w.key(key(KeyCode::Char('a')));
1255 typed(&mut w, t);
1256 }
1257 w.list.targets[1]
1258 .rules
1259 .push(default_rule(MetricId::Holders));
1260 let s = screen(&w, 120, 36);
1261 assert!(s.contains("0xabc on ethereum"), "{s}");
1262 assert!(s.contains("PEPE 1 rule(s)"), "{s}");
1263 for (kind, label) in [
1264 (EditKind::AddTarget, "Add"),
1265 (EditKind::Value(MetricId::Holders), "holders value"),
1266 (EditKind::Interval, "Interval"),
1267 (EditKind::Duration, "Duration"),
1268 (EditKind::Name, "Name"),
1269 (EditKind::Csv, "CSV"),
1270 (EditKind::Report, "Report"),
1271 ] {
1272 let hint = kind == EditKind::AddTarget;
1273 w.edit = Some((kind, "abc".to_string()));
1274 let s = screen(&w, 120, 36);
1275 assert!(s.contains(&format!(" {label}: abc█")), "{label}\n{s}");
1276 assert_eq!(s.contains("Enter adds · Esc cancels"), hint, "{s}");
1277 }
1278 w.edit = None;
1279 w.key(key(KeyCode::Esc));
1280 assert!(screen(&w, 120, 36).contains("Leave without saving? [y/N]"));
1281 w.key(key(KeyCode::Char('n')));
1282 w.dirty = false;
1283 let s = screen(&w, 120, 36);
1284 assert!(!s.contains("Leave without saving"), "{s}");
1285 assert!(!s.contains("Ready"), "the address checks nothing: {s}");
1286 w.list.targets[0]
1287 .rules
1288 .push(default_rule(MetricId::RiskScore));
1289 let s = screen(&w, 120, 36);
1290 assert!(s.contains("Ready: Ctrl-R starts the run."), "{s}");
1291 }
1292}