use chrono::NaiveDate;
use markdown_org_extract::exceptions::{
parse_excluded_dates, parse_recurrence_id, recurrence_id_date,
};
use proptest::prelude::*;
fn any_date() -> impl Strategy<Value = NaiveDate> {
(1..=3_652_058_i32).prop_map(|days| {
NaiveDate::from_num_days_from_ce_opt(days).expect("day number inside the calendar")
})
}
fn date_from_a_short_season() -> impl Strategy<Value = NaiveDate> {
(0..30_i64).prop_map(|offset| {
NaiveDate::from_ymd_opt(2026, 8, 1).expect("a date that exists")
+ chrono::Days::new(offset as u64)
})
}
fn other_field() -> impl Strategy<Value = String> {
proptest::string::string_regex("[a-zA-Z0-9_.]{1,12}").expect("a valid generator pattern")
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn every_date_an_exdate_yields_reads_back_as_one(raw in ".{0,200}") {
let dates = parse_excluded_dates(&raw, |_| {});
for date in &dates {
prop_assert!(
NaiveDate::parse_from_str(date, "%Y-%m-%d").is_ok(),
"{date:?} came out of {raw:?} and is not a date"
);
}
}
#[test]
fn an_exdate_without_times_accounts_for_every_field(
fields in prop::collection::vec(
prop_oneof![
date_from_a_short_season().prop_map(|d| d.format("%Y-%m-%d").to_string()),
other_field(),
],
0..12,
),
) {
let raw = fields.join(", ");
let mut rejected = 0_usize;
let dates = parse_excluded_dates(&raw, |_| rejected += 1);
let distinct_dates = {
let mut seen: Vec<&String> = fields
.iter()
.filter(|f| NaiveDate::parse_from_str(f, "%Y-%m-%d").is_ok())
.collect();
seen.sort_unstable();
seen.dedup();
seen.len()
};
let not_dates = fields
.iter()
.filter(|f| NaiveDate::parse_from_str(f, "%Y-%m-%d").is_err())
.count();
prop_assert_eq!(dates.len(), distinct_dates, "kept dates, from {:?}", raw);
prop_assert_eq!(rejected, not_dates, "reported fields, from {:?}", raw);
}
#[test]
fn an_exdate_holds_one_entry_per_date(
date in any_date(),
times in 1..6_usize,
) {
let written = date.format("%Y-%m-%d").to_string();
let raw = vec![written.clone(); times].join(" ");
prop_assert_eq!(parse_excluded_dates(&raw, |_| {}), vec![written]);
}
#[test]
fn a_recurrence_id_that_parsed_always_names_a_day(raw in ".{0,120}") {
let Some(parsed) = parse_recurrence_id(&raw, |_| {}) else {
return Ok(());
};
prop_assert!(
recurrence_id_date(&parsed).is_some(),
"{parsed:?} came out of {raw:?} and names no day"
);
}
#[test]
fn a_recurrence_id_keeps_the_day_and_the_minute(
date in any_date(),
hour in 0..24_u32,
minute in 0..60_u32,
seconds in prop::option::of(0..60_u32),
) {
let clock = match seconds {
Some(s) => format!("{hour:02}:{minute:02}:{s:02}"),
None => format!("{hour:02}:{minute:02}"),
};
let raw = format!("{} {clock}", date.format("%Y-%m-%d"));
let mut dropped = 0_usize;
let parsed = parse_recurrence_id(&raw, |_| dropped += 1);
let expected = format!("{} {hour:02}:{minute:02}", date.format("%Y-%m-%d"));
prop_assert_eq!(parsed.as_deref(), Some(expected.as_str()));
prop_assert_eq!(dropped, 0, "a time that reads is not a dropped tail");
}
}