diff --git a/Makefile b/Makefile index 8dcd8e3..84aad07 100644 --- a/Makefile +++ b/Makefile @@ -12,4 +12,4 @@ test: # Sadly, this misses coverage for those integrations tests that use # assert_cmd, as it does not follow forks coverage: - cargo tarpaulin -o Html -- --test-threads 1 + cargo tarpaulin -o Html --ignore-tests -- --test-threads 1 diff --git a/README.md b/README.md index 0237d1b..e7c2f06 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ terminal. Type `lowcharts --help`, or `lowcharts PLOT-TYPE --help` for a complete list of options. -Currently four basic types of plots are supported: +Currently five basic types of plots are supported: #### Bar chart for matches in the input @@ -125,6 +125,16 @@ timezone part of the format string (the autodetection works fine with timezones). +#### Split Time Histogram + +This adds up the time histogram and bar chart in a single visualization. + +This chart is generated using `strace -tt ls -lR 2>&1 | lowcharts split-timehist open mmap close read write --intervals 10`: + +[![Sample plot with lowcharts](resources/split-timehist-example.png)](resources/split-timehist-example.png) + +This graph depicts the relative frequency of search terms in time. + ### Installing #### Via release diff --git a/resources/split-timehist-example.png b/resources/split-timehist-example.png new file mode 100644 index 0000000..f6b72cc Binary files /dev/null and b/resources/split-timehist-example.png differ diff --git a/src/app.rs b/src/app.rs index aa57d44..790381d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,6 +9,16 @@ fn add_input(app: App) -> App { ) } +fn add_input_as_option(app: App) -> App { + app.arg( + Arg::new("input") + .long("input") + .default_value("-") + .long_about("If not present or a single dash, standard input will be used") + .takes_value(true), + ) +} + fn add_min_max(app: App) -> App { app.arg( Arg::new("max") @@ -109,7 +119,7 @@ pub fn get_app() -> App<'static> { .setting(AppSettings::ColoredHelp) .setting(AppSettings::AllowMissingPositional) .about("Plot barchar with counts of occurences of matches params"); - matches = add_input(add_width(matches)).arg( + matches = add_input_as_option(add_width(matches)).arg( Arg::new("match") .about("Count maches for those strings") .required(true) @@ -139,6 +149,25 @@ pub fn get_app() -> App<'static> { )); timehist = add_input(add_width(add_non_capturing_regex(add_intervals(timehist)))); + let mut splittimehist = App::new("split-timehist") + .version(clap::crate_version!()) + .setting(AppSettings::ColoredHelp) + .about("Plot histogram of with amount of matches over time, split per match type") + .arg( + Arg::new("format") + .long("format") + .short('f') + .about("Use this string formatting") + .takes_value(true), + ); + splittimehist = add_input_as_option(add_width(add_intervals(splittimehist))).arg( + Arg::new("match") + .about("Count maches for those strings") + .required(true) + .takes_value(true) + .multiple(true), + ); + App::new("lowcharts") .author(clap::crate_authors!()) .version(clap::crate_version!()) @@ -166,6 +195,7 @@ pub fn get_app() -> App<'static> { .subcommand(plot) .subcommand(matches) .subcommand(timehist) + .subcommand(splittimehist) } #[cfg(test)] @@ -210,15 +240,22 @@ mod tests { #[test] fn matches_subcommand_arg_parsing() { - let arg_vec = vec!["lowcharts", "matches", "-", "A", "B", "C"]; + let arg_vec = vec!["lowcharts", "matches", "A", "B", "C"]; let m = get_app().get_matches_from(arg_vec); let sub_m = m.subcommand_matches("matches").unwrap(); assert_eq!("-", sub_m.value_of("input").unwrap()); assert_eq!( - // vec![String::from("A"), String::from("B"), String::from("C")], vec!["A", "B", "C"], sub_m.values_of("match").unwrap().collect::>() ); + let arg_vec = vec!["lowcharts", "matches", "A", "--input", "B", "C"]; + let m = get_app().get_matches_from(arg_vec); + let sub_m = m.subcommand_matches("matches").unwrap(); + assert_eq!("B", sub_m.value_of("input").unwrap()); + assert_eq!( + vec!["A", "C"], + sub_m.values_of("match").unwrap().collect::>() + ); } #[test] @@ -229,4 +266,15 @@ mod tests { assert_eq!("some", sub_m.value_of("input").unwrap()); assert_eq!("foo", sub_m.value_of("regex").unwrap()); } + + #[test] + fn splittimehist_subcommand_arg_parsing() { + let arg_vec = vec!["lowcharts", "split-timehist", "foo", "bar"]; + let m = get_app().get_matches_from(arg_vec); + let sub_m = m.subcommand_matches("split-timehist").unwrap(); + assert_eq!( + vec!["foo", "bar"], + sub_m.values_of("match").unwrap().collect::>() + ); + } } diff --git a/src/main.rs b/src/main.rs index deccda3..732007d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -193,6 +193,38 @@ fn timehist(matches: &ArgMatches) -> i32 { 0 } +/// Implements the timehist cli-subcommand +fn splittime(matches: &ArgMatches) -> i32 { + let mut builder = read::SplitTimeReaderBuilder::default(); + let string_list: Vec = match matches.values_of("match") { + Some(s) => s.map(|s| s.to_string()).collect(), + None => { + error!("At least a match is needed"); + return 2; + } + }; + if string_list.len() > 5 { + error!("Only 5 different sub-groups are supported"); + return 2; + } + if let Some(as_str) = matches.value_of("format") { + builder.ts_format(as_str.to_string()); + } + builder.matches(string_list.iter().map(|s| s.to_string()).collect()); + let width = matches.value_of_t("width").unwrap(); + let reader = builder.build().unwrap(); + let vec = reader.read(matches.value_of("input").unwrap()); + if assert_data(&vec, 2) { + let timehist = plot::SplitTimeHistogram::new( + matches.value_of_t("intervals").unwrap(), + string_list, + &vec, + ); + print!("{:width$}", timehist, width = width); + }; + 0 +} + fn main() { let matches = app::get_app().get_matches(); configure_output( @@ -204,6 +236,7 @@ fn main() { Some(("plot", subcommand_matches)) => plot(subcommand_matches), Some(("matches", subcommand_matches)) => matchbar(subcommand_matches), Some(("timehist", subcommand_matches)) => timehist(subcommand_matches), + Some(("split-timehist", subcommand_matches)) => splittime(subcommand_matches), _ => unreachable!("Invalid subcommand"), }); } diff --git a/src/plot/mod.rs b/src/plot/mod.rs index a34f484..1850e7f 100644 --- a/src/plot/mod.rs +++ b/src/plot/mod.rs @@ -1,9 +1,36 @@ pub use self::histogram::Histogram; pub use self::matchbar::{MatchBar, MatchBarRow}; +pub use self::splittimehist::SplitTimeHistogram; pub use self::timehist::TimeHistogram; pub use self::xy::XyPlot; mod histogram; mod matchbar; +mod splittimehist; mod timehist; mod xy; + +/// Returns a datetime formating string with a resolution that makes sense for a +/// given number of seconds +fn date_fmt_string(seconds: i64) -> &'static str { + match seconds { + x if x > 86400 => "%Y-%m-%d %H:%M:%S", + x if x > 300 => "%H:%M:%S", + x if x > 1 => "%H:%M:%S%.3f", + _ => "%H:%M:%S%.6f", + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_fmt_strings() { + assert_eq!(date_fmt_string(100000), "%Y-%m-%d %H:%M:%S"); + assert_eq!(date_fmt_string(1000), "%H:%M:%S"); + assert_eq!(date_fmt_string(10), "%H:%M:%S%.3f"); + assert_eq!(date_fmt_string(0), "%H:%M:%S%.6f"); + } +} diff --git a/src/plot/splittimehist.rs b/src/plot/splittimehist.rs new file mode 100644 index 0000000..88f7dc4 --- /dev/null +++ b/src/plot/splittimehist.rs @@ -0,0 +1,213 @@ +use std::fmt; + +use chrono::{DateTime, Duration, FixedOffset}; +use yansi::Color::{Blue, Cyan, Green, Magenta, Red}; + +use crate::plot::date_fmt_string; + +const COLORS: &[yansi::Color] = &[Red, Blue, Magenta, Green, Cyan]; + +#[derive(Debug)] +struct TimeBucket { + start: DateTime, + count: Vec, +} + +impl TimeBucket { + fn new(start: DateTime, counts: usize) -> TimeBucket { + TimeBucket { + start, + count: vec![0; counts], + } + } + + fn inc(&mut self, index: usize) { + self.count[index] += 1; + } + + fn total(&self) -> usize { + self.count.iter().sum::() + } +} + +#[derive(Debug)] +pub struct SplitTimeHistogram { + vec: Vec, + strings: Vec, + min: DateTime, + max: DateTime, + step: Duration, + last: usize, + nanos: u64, +} + +impl SplitTimeHistogram { + pub fn new( + size: usize, + strings: Vec, + ts: &[(DateTime, usize)], + ) -> SplitTimeHistogram { + let mut vec = Vec::::with_capacity(size); + let min = ts.iter().min().unwrap().0; + let max = ts.iter().max().unwrap().0; + let step = max - min; + let inc = step / size as i32; + for i in 0..size { + vec.push(TimeBucket::new(min + (inc * i as i32), strings.len())); + } + let mut sth = SplitTimeHistogram { + vec, + strings, + min, + max, + step, + last: size - 1, + nanos: (max - min).num_microseconds().unwrap() as u64, + }; + sth.load(ts); + sth + } + + fn load(&mut self, vec: &[(DateTime, usize)]) { + for x in vec { + self.add(x.0, x.1); + } + } + + fn add(&mut self, ts: DateTime, index: usize) { + if let Some(slot) = self.find_slot(ts) { + self.vec[slot].inc(index); + } + } + + fn find_slot(&self, ts: DateTime) -> Option { + if ts < self.min || ts > self.max { + None + } else { + let x = (ts - self.min).num_microseconds().unwrap() as u64; + Some(((x * self.vec.len() as u64 / self.nanos) as usize).min(self.last)) + } + } + + // Clippy gets badly confused necause self.strings and COLORS may have + // different lengths + #[allow(clippy::needless_range_loop)] + fn fmt_row( + &self, + f: &mut fmt::Formatter, + row: &TimeBucket, + divisor: usize, + widths: &[usize], + ts_fmt: &str, + ) -> fmt::Result { + write!( + f, + "[{}] [", + Blue.paint(format!("{}", row.start.format(ts_fmt))) + )?; + for i in 0..self.strings.len() { + write!( + f, + "{}", + COLORS[i].paint(format!("{:width$}", row.count[i], width = widths[i])) + )?; + if i < self.strings.len() - 1 { + write!(f, "/")?; + } + } + write!(f, "] ")?; + for i in 0..self.strings.len() { + write!( + f, + "{}", + COLORS[i].paint("∎".repeat(row.count[i] / divisor).to_string()) + )?; + } + writeln!(f) + } +} + +impl fmt::Display for SplitTimeHistogram { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let width = f.width().unwrap_or(100); + let total = self.vec.iter().map(|r| r.total()).sum::(); + let top = self.vec.iter().map(|r| r.total()).max().unwrap_or(1); + let divisor = 1.max(top / width); + // These are the widths of every count column + let widths: Vec = (0..self.strings.len()) + .map(|i| { + self.vec + .iter() + .map(|r| r.count[i].to_string().len()) + .max() + .unwrap() + }) + .collect(); + + writeln!(f, "Matches: {}.", total)?; + for (i, s) in self.strings.iter().enumerate() { + let total = self.vec.iter().map(|r| r.count[i]).sum::(); + writeln!(f, "{}: {}.", COLORS[i].paint(s), total)?; + } + writeln!( + f, + "Each {} represents a count of {}", + Red.paint("∎"), + divisor + )?; + let ts_fmt = date_fmt_string(self.step.num_seconds()); + for row in self.vec.iter() { + self.fmt_row(f, row, divisor, &widths, ts_fmt)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use yansi::Paint; + + #[test] + fn test_big_time_interval() { + Paint::disable(); + let mut vec = Vec::<(DateTime, usize)>::new(); + vec.push(( + DateTime::parse_from_rfc3339("2021-04-15T04:25:00+00:00").unwrap(), + 1, + )); + vec.push(( + DateTime::parse_from_rfc3339("2022-04-15T04:25:00+00:00").unwrap(), + 1, + )); + vec.push(( + DateTime::parse_from_rfc3339("2022-04-15T04:25:00+00:00").unwrap(), + 0, + )); + vec.push(( + DateTime::parse_from_rfc3339("2022-04-15T04:25:00+00:00").unwrap(), + 2, + )); + for _ in 0..11 { + vec.push(( + DateTime::parse_from_rfc3339("2023-04-15T04:25:00+00:00").unwrap(), + 2, + )); + } + let th = SplitTimeHistogram::new( + 3, + vec!["one".to_string(), "two".to_string(), "three".to_string()], + &vec, + ); + println!("{}", th); + let display = format!("{}", th); + assert!(display.contains("Matches: 15")); + assert!(display.contains("one: 1.")); + assert!(display.contains("two: 2.")); + assert!(display.contains("three: 12.")); + assert!(display.contains("represents a count of 1")); + assert!(display.contains("[2021-04-15 04:25:00] [0/1/ 0] ∎\n")); + assert!(display.contains("[2021-12-14 12:25:00] [1/1/ 1] ∎∎∎\n")); + assert!(display.contains("[2022-08-14 20:25:00] [0/0/11] ∎∎∎∎∎∎∎∎∎∎∎\n")); + } +} diff --git a/src/plot/timehist.rs b/src/plot/timehist.rs index 9d247b9..e7afb51 100644 --- a/src/plot/timehist.rs +++ b/src/plot/timehist.rs @@ -3,13 +3,14 @@ use std::fmt; use chrono::{DateTime, Duration, FixedOffset}; use yansi::Color::{Blue, Green, Red}; +use crate::plot::date_fmt_string; + #[derive(Debug)] struct TimeBucket { start: DateTime, count: usize, } -// TODO: use trait for Bucket and TimeBucket impl TimeBucket { fn new(start: DateTime) -> TimeBucket { TimeBucket { start, count: 0 } @@ -31,7 +32,6 @@ pub struct TimeHistogram { nanos: u64, } -// TODO: use trait for Histogram and TimeHistogram impl TimeHistogram { pub fn new(size: usize, ts: &[DateTime]) -> TimeHistogram { let mut vec = Vec::::with_capacity(size); @@ -74,15 +74,6 @@ impl TimeHistogram { Some(((x * self.vec.len() as u64 / self.nanos) as usize).min(self.last)) } } - - fn date_fmt_string(&self) -> &str { - match self.step.num_seconds() { - x if x > 86400 => "%Y-%m-%d %H:%M:%S", - x if x > 300 => "%H:%M:%S", - x if x > 1 => "%H:%M:%S%.3f", - _ => "%H:%M:%S%.6f", - } - } } impl fmt::Display for TimeHistogram { @@ -104,12 +95,12 @@ impl fmt::Display for TimeHistogram { Red.paint("∎"), Blue.paint(divisor.to_string()), )?; - let fmt = self.date_fmt_string(); + let ts_fmt = date_fmt_string(self.step.num_seconds()); for row in self.vec.iter() { writeln!( f, "[{label}] [{count}] {bar}", - label = Blue.paint(format!("{}", row.start.format(fmt))), + label = Blue.paint(format!("{}", row.start.format(ts_fmt))), count = Green.paint(format!("{:width$}", row.count, width = width_count)), bar = Red.paint(format!("{:∎ Result { + pub fn new(log_line: &str, format_string: &Option) -> Result { + match format_string { + Some(ts_format) => Self::new_with_format(&log_line, &ts_format), + None => Self::new_with_guess(&log_line), + } + } + + fn new_with_guess(log_line: &str) -> Result { // All the guess work assume that datetimes start with a digit, and that // digit is the first digit in the log line. The approach is to locate // the 1st digit and then try to parse as much text as possible with any @@ -50,7 +57,7 @@ impl LogDateParser { Err(format!("Could not parse a timestamp in {}", log_line)) } - pub fn new_with_format(log_line: &str, format_string: &str) -> Result { + fn new_with_format(log_line: &str, format_string: &str) -> Result { // We look for where the timestamp is in logs using a brute force // approach with 1st log line, but capping the max length we scan for for i in 0..log_line.len() { diff --git a/src/read/mod.rs b/src/read/mod.rs index ede5809..b909b17 100644 --- a/src/read/mod.rs +++ b/src/read/mod.rs @@ -1,8 +1,10 @@ pub use self::buckets::{DataReader, DataReaderBuilder}; +pub use self::splittimes::{SplitTimeReader, SplitTimeReaderBuilder}; pub use self::times::TimeReaderBuilder; mod buckets; mod dateparser; +mod splittimes; mod times; use std::fs::File; diff --git a/src/read/splittimes.rs b/src/read/splittimes.rs new file mode 100644 index 0000000..ceb32d1 --- /dev/null +++ b/src/read/splittimes.rs @@ -0,0 +1,169 @@ +use std::io::BufRead; + +use chrono::{DateTime, FixedOffset}; + +use crate::read::dateparser::LogDateParser; +use crate::read::open_file; + +#[derive(Default, Builder)] +pub struct SplitTimeReader { + #[builder(setter(strip_option), default)] + matches: Vec, + #[builder(setter(strip_option), default)] + ts_format: Option, +} + +impl SplitTimeReader { + pub fn read(&self, path: &str) -> Vec<(DateTime, usize)> { + let mut vec: Vec<(DateTime, usize)> = Vec::new(); + let mut iterator = open_file(path).lines(); + let first_line = match iterator.next() { + Some(Ok(as_string)) => as_string, + Some(Err(error)) => { + error!("{}", error); + return vec; + } + _ => return vec, + }; + let parser = match LogDateParser::new(&first_line, &self.ts_format) { + Ok(p) => p, + Err(error) => { + error!("Could not figure out parsing strategy: {}", error); + return vec; + } + }; + if let Ok(x) = parser.parse(&first_line) { + self.push_conditionally(x, &mut vec, &first_line); + } + for line in iterator { + match line { + Ok(string) => { + if let Ok(x) = parser.parse(&string) { + self.push_conditionally(x, &mut vec, &string); + } + } + Err(error) => error!("{}", error), + } + } + vec + } + + fn push_conditionally( + &self, + d: DateTime, + vec: &mut Vec<(DateTime, usize)>, + line: &str, + ) { + for (i, s) in self.matches.iter().enumerate() { + if line.contains(s) { + vec.push((d, i)); + } + } + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn split_time_reader_basic() { + let mut builder = SplitTimeReaderBuilder::default(); + builder.matches(vec![ + "foo".to_string(), + "bar".to_string(), + "gnat".to_string(), + ]); + let reader = builder.build().unwrap(); + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "[2021-04-15T06:25:31+00:00] foo").unwrap(); + writeln!(file, "[2021-04-15T06:26:31+00:00] bar").unwrap(); + writeln!(file, "[2021-04-15T06:27:31+00:00] foobar").unwrap(); + writeln!(file, "[2021-04-15T06:28:31+00:00] none").unwrap(); + writeln!(file, "[2021-04-15T06:29:31+00:00] foo").unwrap(); + writeln!(file, "[2021-04-15T06:30:31+00:00] none again").unwrap(); + writeln!(file, "not even a timestamp").unwrap(); + let ts = reader.read(file.path().to_str().unwrap()); + assert_eq!(ts.len(), 5); + assert_eq!( + ts[0].0, + DateTime::parse_from_rfc3339("2021-04-15T06:25:31+00:00").unwrap() + ); + assert_eq!( + ts[1].0, + DateTime::parse_from_rfc3339("2021-04-15T06:26:31+00:00").unwrap() + ); + assert_eq!( + ts[2].0, + DateTime::parse_from_rfc3339("2021-04-15T06:27:31+00:00").unwrap() + ); + assert_eq!( + ts[3].0, + DateTime::parse_from_rfc3339("2021-04-15T06:27:31+00:00").unwrap() + ); + assert_eq!( + ts[4].0, + DateTime::parse_from_rfc3339("2021-04-15T06:29:31+00:00").unwrap() + ); + assert_eq!(ts[0].1, 0); + assert_eq!(ts[1].1, 1); + assert_eq!(ts[2].1, 0); + assert_eq!(ts[3].1, 1); + assert_eq!(ts[4].1, 0); + } + + #[test] + fn split_time_no_matches() { + let reader = SplitTimeReader::default(); + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "[2021-04-15T06:25:31+00:00] foo").unwrap(); + writeln!(file, "[2021-04-15T06:26:31+00:00] bar").unwrap(); + let ts = reader.read(file.path().to_str().unwrap()); + assert_eq!(ts.len(), 0); + } + + #[test] + fn split_time_zero_matches() { + let mut builder = SplitTimeReaderBuilder::default(); + builder.matches(vec![ + "foo".to_string(), + "bar".to_string(), + "gnat".to_string(), + ]); + builder.ts_format(String::from("%Y_%m_%d %H:%M")); + let reader = builder.build().unwrap(); + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "_2021_04_15 06:25] none").unwrap(); + writeln!(file, "_2021_04_15 06:26] none").unwrap(); + writeln!(file, "[2021-04-15T06:25:31+00:00] foo").unwrap(); + let ts = reader.read(file.path().to_str().unwrap()); + assert_eq!(ts.len(), 0); + } + + #[test] + fn split_time_bad_guess() { + let mut builder = SplitTimeReaderBuilder::default(); + builder.matches(vec![ + "foo".to_string(), + "bar".to_string(), + "gnat".to_string(), + ]); + let reader = builder.build().unwrap(); + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "XXX none").unwrap(); + writeln!(file, "[2021-04-15T06:25:31+00:00] foo").unwrap(); + let ts = reader.read(file.path().to_str().unwrap()); + assert_eq!(ts.len(), 0); + } + + #[test] + fn split_time_bad_file() { + let reader = SplitTimeReader::default(); + let file = NamedTempFile::new().unwrap(); + let ts = reader.read(file.path().to_str().unwrap()); + assert_eq!(ts.len(), 0); + } +} diff --git a/src/read/times.rs b/src/read/times.rs index 9c5b487..bc61702 100644 --- a/src/read/times.rs +++ b/src/read/times.rs @@ -30,7 +30,7 @@ impl TimeReader { } _ => return vec, }; - let parser = match self.build_parser(&first_line) { + let parser = match LogDateParser::new(&first_line, &self.ts_format) { Ok(p) => p, Err(error) => { error!("Could not figure out parsing strategy: {}", error); @@ -69,13 +69,6 @@ impl TimeReader { vec } - fn build_parser(&self, line: &str) -> Result { - match &self.ts_format { - Some(ts_format) => LogDateParser::new_with_format(&line, &ts_format), - None => LogDateParser::new_with_guess(&line), - } - } - fn push_conditionally( &self, d: DateTime, diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 0a734bd..1a039e8 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -103,6 +103,40 @@ fn test_matchbar() { .stdout(predicate::str::contains("\n[bar ] [2] ∎∎\n")); } +#[test] +fn test_splittime() { + let mut cmd = Command::cargo_bin("lowcharts").unwrap(); + cmd.arg("split-timehist") + .arg("1") + .arg("2") + .arg("3") + .arg("4") + .arg("5") + .arg("6") + .assert() + .failure() + .stderr(predicate::str::contains( + "Only 5 different sub-groups are supported", + )); + let mut cmd = Command::cargo_bin("lowcharts").unwrap(); + cmd.arg("split-timehist") + .arg("A") + .arg("B") + .arg("C") + .arg("--intervals") + .arg("2") + .write_stdin("1619655527.888165 A\n1619655528.888165 A\n1619655527.888165 B\n") + .assert() + .success() + .stdout(predicate::str::contains("Matches: 3.")) + .stdout(predicate::str::contains("A: 2")) + .stdout(predicate::str::contains("B: 1.")) + .stdout(predicate::str::contains("C: 0.")) + .stdout(predicate::str::contains("Each ∎ represents a count of 1\n")) + .stdout(predicate::str::contains("[00:18:47.888165] [1/1/0] ∎∎\n")) + .stdout(predicate::str::contains("[00:18:48.388165] [1/0/0] ∎\n")); +} + #[test] fn test_plot() { let mut cmd = Command::cargo_bin("lowcharts").unwrap();