use super::*;
use crate::{CalendarException, ClockTime, WorkingPeriod};
use chrono::TimeZone;
fn origin() -> DateTime<Utc> {
chrono_tz::America::New_York
.with_ymd_and_hms(2026, 3, 2, 0, 0, 0)
.single()
.expect("origin")
.with_timezone(&Utc)
}
fn standard(until: f64) -> WorkingTime {
WorkingTime::compile(
CalendarRules::Standard,
chrono_tz::America::New_York,
origin(),
0.0,
until,
)
.expect("compiled")
}
#[test]
fn standard_days_follow_ms_project_hours() {
let calendar = standard(24.0 * 21.0);
assert_eq!(
calendar.add(0.0, 8.0),
Ok(17.0),
"one day ends Monday 17:00"
);
assert_eq!(
calendar.add(17.0, 1.0),
Ok(33.0),
"the next hour is Tuesday 09:00"
);
assert_eq!(
calendar.add(0.0, 40.0),
Ok(4.0 * 24.0 + 17.0),
"a week ends Friday 17:00"
);
assert_eq!(calendar.add(10.0, 3.0), Ok(14.0), "lunch is skipped");
assert_eq!(
calendar.sub(17.0, 8.0),
Ok(8.0),
"latest start is Monday 08:00"
);
assert_eq!(calendar.sub(33.0, 1.0), Ok(32.0));
assert_eq!(calendar.between(0.0, 33.0), Ok(9.0));
assert_eq!(calendar.shift(33.0, -1.0), Ok(32.0));
}
#[test]
fn alignment_treats_span_ends_as_working() {
let calendar = standard(24.0 * 7.0);
assert_eq!(
calendar.align(18.0),
Ok(32.0),
"evening waits for Tuesday 08:00"
);
assert_eq!(calendar.align(12.5), Ok(13.0));
assert_eq!(calendar.align(17.0), Ok(17.0));
assert_eq!(
calendar.next_working(17.0),
Ok(32.0),
"work cannot begin at closing time"
);
assert_eq!(calendar.next_working(9.0), Ok(9.0));
assert_eq!(calendar.align_back(18.0), Ok(17.0));
assert_eq!(calendar.align_back(12.5), Ok(12.0));
assert_eq!(calendar.align_back(9.0), Ok(9.0));
}
#[test]
fn daylight_saving_moves_local_hours_in_utc() {
let calendar = standard(24.0 * 21.0);
assert_eq!(calendar.add(4.0 * 24.0 + 17.0, 1.0), Ok(7.0 * 24.0 + 8.0));
}
#[test]
fn exceptions_replace_the_weekly_pattern() {
let mut definition = crate::standard_definition();
definition.exceptions.push(CalendarException {
from: NaiveDate::from_ymd_opt(2026, 3, 3).expect("date"),
to: None,
hours: Vec::new(),
name: Some("closed".into()),
});
definition.exceptions.push(CalendarException {
from: NaiveDate::from_ymd_opt(2026, 3, 7).expect("date"),
to: None,
hours: vec![WorkingPeriod::new(
ClockTime::hours(10),
ClockTime::hours(12),
)],
name: None,
});
let calendar = WorkingTime::compile(
CalendarRules::Weekly(&definition),
chrono_tz::America::New_York,
origin(),
0.0,
24.0 * 14.0,
)
.expect("compiled");
assert_eq!(
calendar.add(17.0, 1.0),
Ok(2.0 * 24.0 + 9.0),
"Tuesday is closed"
);
assert_eq!(
calendar.add(4.0 * 24.0 + 17.0, 2.0),
Ok(5.0 * 24.0 + 12.0),
"the Saturday shift works 10:00-12:00"
);
}
#[test]
fn queries_outside_the_window_are_reported() {
let calendar = standard(48.0);
assert!(calendar.add(0.0, 100.0).is_err());
assert!(calendar.sub(17.0, 9.0).is_err());
assert!(calendar.align(24.0 * 30.0).is_err());
}
#[test]
fn always_counts_every_hour() {
let calendar = WorkingTime::always();
assert_eq!(calendar.add(5.0, 3.0), Ok(8.0));
assert_eq!(calendar.sub(5.0, 7.0), Ok(-2.0));
assert_eq!(calendar.align(3.5), Ok(3.5));
assert_eq!(calendar.between(1.0, 4.0), Ok(3.0));
}
#[test]
fn offsets_widen_their_window_until_the_answer_fits() {
let rules = CalendarRules::Standard;
let zone = chrono_tz::America::New_York;
assert_eq!(
WorkingTime::offset_after(rules, zone, origin(), 8.0),
Some(17.0)
);
let long = WorkingTime::offset_after(rules, zone, origin(), 2000.0).expect("fits");
assert!(
long > 24.0 * 7.0 * 49.0,
"2000 working hours take about a year: {long}"
);
}
fn offsets() -> impl Iterator<Item = DateTime<Utc>> {
[
0_i64,
1,
37_001,
999_999,
1_234_567,
86_399_999,
2_300_000_000,
17,
]
.into_iter()
.map(|ms| origin() + chrono::TimeDelta::milliseconds(ms))
}
#[test]
fn span_boundaries_are_exact_whatever_the_origin() {
for origin in offsets() {
let calendar = WorkingTime::compile(
CalendarRules::Standard,
chrono_tz::Europe::Berlin,
origin,
-200.0,
24.0 * 40.0,
)
.expect("compiled");
let spans: Vec<(f64, f64)> = calendar
.spans
.iter()
.map(|(a, b)| (hours(*a), hours(*b)))
.collect();
for pair in spans.windows(2) {
let [(start, end), (next, _)] = pair else {
continue;
};
assert_eq!(calendar.align(*end), Ok(*end), "an end is working time");
assert_eq!(
calendar.next_working(*end),
Ok(*next),
"work resumes next span"
);
assert_eq!(calendar.align(*start), Ok(*start));
assert_eq!(calendar.align_back(*end + 0.5), Ok(*end));
assert_eq!(
calendar.sub(*next + 1.0, 1.0),
Ok(*next),
"latest start is the next span"
);
assert_eq!(calendar.add(*start, end - start), Ok(*end));
}
}
}
#[test]
fn backward_operations_never_undershoot_forward_ones() {
let calendar = standard(24.0 * 60.0);
let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
let mut next = || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
(state >> 11) as f64 / (1_u64 << 53) as f64
};
for _ in 0..2000 {
let t = 24.0 * 7.0 + next() * 24.0 * 21.0;
let hours = (next() * 30.0 * 4.0).round() / 4.0;
let forward = calendar.add(t, hours).expect("add");
assert!(
calendar.sub(forward, hours).expect("sub") >= t - 1e-6,
"{t} {hours}"
);
let back = calendar.sub(t, hours).expect("sub");
assert!(
calendar.add_late(back, hours).expect("add_late") >= t - 1e-6,
"{t} {hours}"
);
for lag in [hours, -hours] {
let shifted = calendar.shift(t, lag).expect("shift");
assert!(calendar.unshift(shifted, lag).expect("unshift") >= t - 1e-6);
}
}
}
#[test]
fn windows_longer_than_a_century_are_refused() {
let rules = CalendarRules::Standard;
let zone = chrono_tz::Europe::Berlin;
assert!(WorkingTime::compile(rules, zone, origin(), 0.0, MAX_WINDOW_HOURS + 24.0).is_none());
let mut closed = crate::standard_definition();
closed.exceptions.push(CalendarException {
from: NaiveDate::from_ymd_opt(2026, 3, 1).expect("date"),
to: NaiveDate::from_ymd_opt(9999, 12, 31),
hours: Vec::new(),
name: Some("until further notice".into()),
});
let started = std::time::Instant::now();
assert_eq!(
WorkingTime::offset_after(CalendarRules::Weekly(&closed), zone, origin(), 8.0),
None
);
assert!(started.elapsed() < std::time::Duration::from_secs(20));
}
#[test]
fn placing_work_equals_finding_working_time_then_adding() {
for origin in offsets() {
let calendar = WorkingTime::compile(
CalendarRules::Standard,
chrono_tz::Europe::Berlin,
origin,
-48.0,
24.0 * 60.0,
)
.expect("compiled");
let mut t = 0.0;
while t < 24.0 * 14.0 {
for hours in [0.0, 0.25, 3.5, 4.0, 9.0, 41.0] {
let start = if hours > 0.0 {
calendar.next_working(t).expect("next")
} else {
t
};
let expected = (start, calendar.add(start, hours).expect("add"));
assert_eq!(calendar.place(t, hours), Ok(expected), "{t} {hours}");
}
t += 0.37;
}
}
}
#[test]
fn retreating_equals_aligning_back_then_subtracting() {
for origin in offsets() {
let calendar = WorkingTime::compile(
CalendarRules::Standard,
chrono_tz::Europe::Berlin,
origin,
-24.0 * 30.0,
24.0 * 60.0,
)
.expect("compiled");
let mut t = 24.0 * 7.0;
while t < 24.0 * 21.0 {
for hours in [0.0, 0.25, 3.5, 4.0, 9.0, 41.0] {
let back = calendar.align_back(t).expect("back");
let expected = calendar.sub(back, hours).expect("sub");
assert_eq!(calendar.retreat(t, hours), Ok(expected), "{t} {hours}");
}
t += 0.37;
}
}
}