corpus.rs
| 1 | //! Differential test of `expand` over calcard's fixture corpus. |
| 2 | //! |
| 3 | //! `corpus/oracle.tsv` holds the instances recurring-ical-events computes |
| 4 | //! (see `corpus/oracle.py`). `corpus/deviations.tsv` overrides it where the |
| 5 | //! oracle fails or where this crate deliberately differs, with the reason. |
| 6 | //! On failure the test prints deviation lines for the files that differ. |
| 7 | //! |
| 8 | //! `PIMDAV_CORPUS_FULL=<path>` writes every start to that file, in the format |
| 9 | //! of `oracle.py --full`, for review. |
| 10 | |
| 11 | use std::collections::HashMap; |
| 12 | use std::fmt::Write as _; |
| 13 | use std::path::PathBuf; |
| 14 | |
| 15 | use chrono::{DateTime, Utc}; |
| 16 | use pimdav::calcard::icalendar::ICalendarComponentType; |
| 17 | use pimdav::calcard::{Entry, Parser}; |
| 18 | use pimdav::expand::expand; |
| 19 | use pimdav::zone::Zone; |
| 20 | |
| 21 | /// Keep equal to the pinned calcard version in Cargo.toml. |
| 22 | const CALCARD: &str = "calcard-0.3.14"; |
| 23 | |
| 24 | fn fnv(data: &[u8]) -> String { |
| 25 | let mut h: u64 = 0xcbf29ce484222325; |
| 26 | for b in data { |
| 27 | h = (h ^ u64::from(*b)).wrapping_mul(0x100000001b3); |
| 28 | } |
| 29 | format!("{h:016x}") |
| 30 | } |
| 31 | |
| 32 | fn fixtures() -> PathBuf { |
| 33 | let home = std::env::var_os("CARGO_HOME") |
| 34 | .map(PathBuf::from) |
| 35 | .unwrap_or_else(|| PathBuf::from(std::env::var_os("HOME").unwrap()).join(".cargo")); |
| 36 | std::fs::read_dir(home.join("registry/src")) |
| 37 | .expect("no cargo registry") |
| 38 | .map(|e| e.unwrap().path().join(CALCARD).join("resources/ical")) |
| 39 | .find(|p| p.is_dir()) |
| 40 | .expect("calcard sources not in the cargo registry") |
| 41 | } |
| 42 | |
| 43 | /// `name -> (columns...)` of a tab-separated file. |
| 44 | fn table(name: &str) -> HashMap<String, Vec<String>> { |
| 45 | let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) |
| 46 | .join("tests/corpus") |
| 47 | .join(name); |
| 48 | std::fs::read_to_string(path) |
| 49 | .unwrap() |
| 50 | .lines() |
| 51 | .filter(|l| !l.is_empty() && !l.starts_with('#')) |
| 52 | .map(|l| { |
| 53 | let mut cols = l.split('\t').map(str::to_string); |
| 54 | (cols.next().unwrap(), cols.collect()) |
| 55 | }) |
| 56 | .collect() |
| 57 | } |
| 58 | |
| 59 | fn starts(input: &str) -> Vec<i64> { |
| 60 | let lo = DateTime::<Utc>::from_timestamp(0, 0).unwrap(); |
| 61 | let hi = DateTime::<Utc>::from_timestamp(2_208_988_800, 0).unwrap(); |
| 62 | let mut parser = Parser::new(input); |
| 63 | let mut out = Vec::new(); |
| 64 | loop { |
| 65 | match parser.entry() { |
| 66 | Entry::ICalendar(cal) => { |
| 67 | let expansion = expand(&cal, lo..hi, Zone::Utc); |
| 68 | out.extend( |
| 69 | expansion |
| 70 | .instances |
| 71 | .iter() |
| 72 | .filter(|i| { |
| 73 | cal.components[i.component].component_type |
| 74 | == ICalendarComponentType::VEvent |
| 75 | && i.start >= lo |
| 76 | && i.start < hi |
| 77 | }) |
| 78 | .map(|i| i.start.timestamp()), |
| 79 | ); |
| 80 | } |
| 81 | Entry::Eof => break, |
| 82 | _ => {} |
| 83 | } |
| 84 | } |
| 85 | out.sort_unstable(); |
| 86 | out |
| 87 | } |
| 88 | |
| 89 | #[test] |
| 90 | fn corpus_matches_oracle() { |
| 91 | let dir = fixtures(); |
| 92 | let oracle = table("oracle.tsv"); |
| 93 | let deviations = table("deviations.tsv"); |
| 94 | let mut names: Vec<_> = std::fs::read_dir(&dir) |
| 95 | .unwrap() |
| 96 | .map(|e| e.unwrap().file_name().into_string().unwrap()) |
| 97 | .filter(|n| n.ends_with(".ics")) |
| 98 | .collect(); |
| 99 | names.sort(); |
| 100 | assert_eq!( |
| 101 | names.len(), |
| 102 | oracle.len(), |
| 103 | "fixture set changed, rerun oracle.py" |
| 104 | ); |
| 105 | |
| 106 | let mut full = String::new(); |
| 107 | let mut failures = String::new(); |
| 108 | for name in &names { |
| 109 | let raw = std::fs::read(dir.join(name)).unwrap(); |
| 110 | let o = &oracle[name]; |
| 111 | assert_eq!(o[0], fnv(&raw), "{name} changed, rerun oracle.py"); |
| 112 | let input = String::from_utf8(raw.clone()) |
| 113 | .unwrap_or_else(|_| raw.iter().map(|&b| b as char).collect()); |
| 114 | let starts = starts(&input); |
| 115 | let list = starts |
| 116 | .iter() |
| 117 | .map(i64::to_string) |
| 118 | .collect::<Vec<_>>() |
| 119 | .join(","); |
| 120 | let (count, hash) = (starts.len(), fnv(list.as_bytes())); |
| 121 | writeln!(full, "{name}\t{}\tok\t{count}\t{list}", o[0]).unwrap(); |
| 122 | |
| 123 | let expected = match deviations.get(name) { |
| 124 | Some(d) => Some((d[0].as_str(), d[1].as_str())), |
| 125 | None if o[1] == "ok" => Some((o[2].as_str(), o[3].as_str())), |
| 126 | None => None, |
| 127 | }; |
| 128 | if expected != Some((count.to_string().as_str(), hash.as_str())) { |
| 129 | let why = deviations.get(name).map_or("?", |d| d[2].as_str()); |
| 130 | writeln!(failures, "{name}\t{count}\t{hash}\t{why}").unwrap(); |
| 131 | } |
| 132 | } |
| 133 | if let Some(path) = std::env::var_os("PIMDAV_CORPUS_FULL") { |
| 134 | std::fs::write(path, full).unwrap(); |
| 135 | } |
| 136 | assert!(failures.is_empty(), "differs from expectation:\n{failures}"); |
| 137 | } |
| 138 |