use std::cell::Cell;
use std::collections::HashMap;
use std::time::Duration;
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
const TICK: Duration = Duration::from_millis(120);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LiveStatus {
Pass,
Fail,
NotRelevant,
Skipped,
Error,
}
impl LiveStatus {
fn glyph_word(self) -> (&'static str, &'static str) {
match self {
LiveStatus::Pass => ("✓", "passed"),
LiveStatus::Fail => ("✗", "failed"),
LiveStatus::NotRelevant => ("–", "not relevant"),
LiveStatus::Skipped => ("–", "skipped"),
LiveStatus::Error => ("!", "error"),
}
}
fn color(self) -> &'static str {
match self {
LiveStatus::Pass => "green",
LiveStatus::Fail | LiveStatus::Error => "red",
LiveStatus::NotRelevant | LiveStatus::Skipped => "yellow",
}
}
}
pub struct ProgressView {
mp: MultiProgress,
header: ProgressBar,
rules: HashMap<String, ProgressBar>,
total_runs: usize,
done_runs: Cell<usize>,
}
impl ProgressView {
pub fn new(
target: ProgressDrawTarget,
rule_names: &[String],
total_runs: usize,
animate: bool,
) -> Self {
let mp = MultiProgress::with_draw_target(target);
let header = mp.add(ProgressBar::new_spinner());
header.set_style(
ProgressStyle::with_template("{spinner:.cyan} judging {msg}")
.unwrap_or_else(|_| ProgressStyle::default_spinner()),
);
header.set_message(format!("0/{total_runs} judge calls"));
let pending = ProgressStyle::with_template("{spinner:.cyan} {msg}")
.unwrap_or_else(|_| ProgressStyle::default_spinner());
let mut rules = HashMap::new();
for name in rule_names {
let pb = mp.add(ProgressBar::new_spinner());
pb.set_style(pending.clone());
pb.set_message(format!("{name} queued"));
rules.insert(name.clone(), pb);
}
let view = ProgressView {
mp,
header,
rules,
total_runs,
done_runs: Cell::new(0),
};
if animate {
view.header.enable_steady_tick(TICK);
for pb in view.rules.values() {
pb.enable_steady_tick(TICK);
}
}
view.header.tick();
for pb in view.rules.values() {
pb.tick();
}
view
}
pub fn set_running(&self, rule: &str) {
if let Some(pb) = self.rules.get(rule) {
pb.set_message(format!("{rule} running"));
pb.tick();
}
}
pub fn tick_run(&self) {
let n = self.done_runs.get() + 1;
self.done_runs.set(n);
self.header
.set_message(format!("{n}/{} judge calls", self.total_runs));
self.header.tick();
}
pub fn finish_rule(&self, rule: &str, status: LiveStatus) {
if let Some(pb) = self.rules.get(rule) {
let (glyph, word) = status.glyph_word();
pb.disable_steady_tick();
pb.set_style(
ProgressStyle::with_template(&format!("{{msg:.{}}}", status.color()))
.unwrap_or_else(|_| ProgressStyle::default_spinner()),
);
pb.set_message(format!("{glyph} {rule} {word}"));
pb.tick();
}
}
pub fn finish(self) {
let _ = self.mp.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use indicatif::InMemoryTerm;
fn view_over(term: &InMemoryTerm, rules: &[&str], total: usize) -> ProgressView {
let names: Vec<String> = rules.iter().map(|s| s.to_string()).collect();
ProgressView::new(
ProgressDrawTarget::term_like(Box::new(term.clone())),
&names,
total,
false,
)
}
#[test]
fn initial_frame_lists_rules_as_queued_with_a_header() {
let term = InMemoryTerm::new(16, 80);
let _view = view_over(&term, &["rule_a", "rule_b"], 3);
let screen = term.contents();
assert!(
screen.contains("judging 0/3 judge calls"),
"got: {screen:?}"
);
assert!(screen.contains("rule_a queued"), "got: {screen:?}");
assert!(screen.contains("rule_b queued"), "got: {screen:?}");
}
#[test]
fn running_then_finished_updates_the_line_and_header() {
let term = InMemoryTerm::new(16, 80);
let view = view_over(&term, &["rule_a", "rule_b"], 2);
view.set_running("rule_a");
assert!(term.contents().contains("rule_a running"));
view.tick_run();
view.finish_rule("rule_a", LiveStatus::Pass);
let screen = term.contents();
assert!(
screen.contains("judging 1/2 judge calls"),
"got: {screen:?}"
);
assert!(screen.contains("✓ rule_a passed"), "got: {screen:?}");
assert!(screen.contains("rule_b queued"), "got: {screen:?}");
}
#[test]
fn every_status_renders_its_glyph_and_word() {
let term = InMemoryTerm::new(16, 80);
let view = view_over(&term, &["p", "f", "n", "s", "e"], 5);
view.finish_rule("p", LiveStatus::Pass);
view.finish_rule("f", LiveStatus::Fail);
view.finish_rule("n", LiveStatus::NotRelevant);
view.finish_rule("s", LiveStatus::Skipped);
view.finish_rule("e", LiveStatus::Error);
let screen = term.contents();
assert!(screen.contains("✓ p passed"), "got: {screen:?}");
assert!(screen.contains("✗ f failed"), "got: {screen:?}");
assert!(screen.contains("– n not relevant"), "got: {screen:?}");
assert!(screen.contains("– s skipped"), "got: {screen:?}");
assert!(screen.contains("! e error"), "got: {screen:?}");
}
#[test]
fn finish_clears_the_whole_block() {
let term = InMemoryTerm::new(16, 80);
let view = view_over(&term, &["rule_a"], 1);
view.tick_run();
view.finish_rule("rule_a", LiveStatus::Pass);
assert!(!term.contents().is_empty(), "view should have drawn");
view.finish();
assert_eq!(term.contents(), "", "finish() must clear the view");
}
#[test]
fn unknown_rule_names_are_ignored() {
let term = InMemoryTerm::new(16, 80);
let view = view_over(&term, &["known"], 1);
view.set_running("ghost");
view.finish_rule("ghost", LiveStatus::Pass);
assert!(term.contents().contains("known queued"));
}
#[test]
fn animated_mode_enables_steady_tick_without_panicking() {
let names = ["rule_a".to_string(), "rule_b".to_string()];
let view = ProgressView::new(ProgressDrawTarget::hidden(), &names, 2, true);
view.set_running("rule_a");
view.tick_run();
view.finish_rule("rule_a", LiveStatus::Pass);
view.finish_rule("rule_b", LiveStatus::Fail);
view.finish();
}
}