use chrono::{DateTime, Duration, Local, TimeZone, Utc};
use std::sync::Arc;
use tiny_counter::{EventStore, TestClock};
fn local_offset_hours(utc_time: DateTime<Utc>) -> f64 {
let offset_seconds = Local
.timestamp_opt(utc_time.timestamp(), 0)
.unwrap()
.offset()
.local_minus_utc();
offset_seconds as f64 / 3600.0
}
#[test]
fn first_seen_when_exactly_at_starting_bucket() {
let fixed_time = Utc.with_ymd_and_hms(2025, 12, 5, 12, 0, 0).unwrap();
let clock = TestClock::build_for_testing_at(fixed_time);
let store = EventStore::builder()
.track_days(7)
.track_hours(36)
.with_clock(Arc::new(clock.clone()))
.build()
.unwrap();
store.record("event");
clock.advance(Duration::hours(36));
store.record("event");
let first = store.query("event").first_seen();
let expected = Duration::hours(42);
assert_eq!(
first,
Some(expected),
"Should return exact gap-adjusted midpoint: age + quarter_bucket"
);
}
#[test]
fn first_seen_at_boundary_between_intervals() {
let fixed_time = Utc.with_ymd_and_hms(2025, 12, 5, 0, 0, 0).unwrap();
let clock = TestClock::build_for_testing_at(fixed_time);
let store = EventStore::builder()
.track_days(10)
.track_hours(48)
.with_clock(Arc::new(clock.clone()))
.build()
.unwrap();
store.record_ago("event", Duration::hours(48));
let first = store.query("event").first_seen();
let expected = Duration::seconds(172800);
assert_eq!(
first,
Some(expected),
"Should return event_age + half_bucket_duration"
);
}
#[test]
fn first_seen_with_event_at_starting_bucket_and_newer_events() {
let fixed_time = Utc.with_ymd_and_hms(2025, 12, 5, 12, 0, 0).unwrap();
let clock = TestClock::build_for_testing_at(fixed_time);
let store = EventStore::builder()
.track_days(7)
.track_hours(24)
.with_clock(Arc::new(clock.clone()))
.build()
.unwrap();
store.record_ago("event", Duration::hours(24));
store.record("event");
let first = store.query("event").first_seen();
let expected = Duration::hours(30);
assert_eq!(
first,
Some(expected),
"Should return exact gap-adjusted midpoint for oldest event"
);
}
#[test]
fn first_seen_returns_correct_midpoint_at_boundary() {
let fixed_time = Utc.with_ymd_and_hms(2025, 12, 1, 0, 0, 0).unwrap();
let clock = TestClock::build_for_testing_at(fixed_time);
let store = EventStore::builder()
.track_days(5)
.track_hours(72) .with_clock(Arc::new(clock.clone()))
.build()
.unwrap();
store.record_ago("event", Duration::hours(72));
let first = store.query("event").first_seen();
let expected = Duration::seconds(259200);
assert_eq!(
first,
Some(expected),
"Should return exact midpoint of day bucket at 72-hour boundary"
);
}
#[test]
#[cfg(not(feature = "calendar"))]
fn first_seen_across_three_intervals() {
let fixed_time = Utc.with_ymd_and_hms(2025, 1, 15, 10, 0, 0).unwrap();
let clock = TestClock::build_for_testing_at(fixed_time);
let store = EventStore::builder()
.track_hours(48)
.track_days(30)
.track_weeks(52)
.with_clock(Arc::new(clock.clone()))
.build()
.unwrap();
store.record_ago("event", Duration::weeks(5));
let first = store.query("event").first_seen();
let expected = Duration::seconds(2541600);
assert_eq!(
first,
Some(expected),
"Should return exact calculated week bucket midpoint"
);
}
#[test]
#[cfg(feature = "calendar")]
fn first_seen_across_three_intervals_calendar() {
let fixed_time = Utc.with_ymd_and_hms(2025, 1, 15, 10, 0, 0).unwrap();
let clock = TestClock::build_for_testing_at(fixed_time);
let store = EventStore::builder()
.track_hours(48)
.track_days(30)
.track_weeks(52)
.with_clock(Arc::new(clock.clone()))
.build()
.unwrap();
store.record_ago("event", Duration::weeks(5));
let first = store.query("event").first_seen();
assert!(first.is_some());
let offset = local_offset_hours(fixed_time);
let expected_hours = 814.0 - offset * 1.0;
let duration = first.unwrap();
let hours = duration.num_hours();
assert!(
((expected_hours - 1.0) as i64..=(expected_hours + 1.0) as i64).contains(&hours),
"Expected ~{:.0} hours (±1h) based on UTC{:+.1} offset, got {} hours",
expected_hours,
offset,
hours
);
}
#[test]
fn first_seen_in_finest_granularity() {
let fixed_time = Utc.with_ymd_and_hms(2025, 1, 15, 14, 30, 0).unwrap();
let clock = TestClock::build_for_testing_at(fixed_time);
let store = EventStore::builder()
.track_minutes(60)
.track_hours(24)
.track_days(7)
.with_clock(Arc::new(clock.clone()))
.build()
.unwrap();
store.record_ago("event", Duration::minutes(10));
let first = store.query("event").first_seen();
let expected = Duration::seconds(630);
assert_eq!(
first,
Some(expected),
"Should return exact minute bucket midpoint: age + 0.5 minutes"
);
}
#[test]
fn first_seen_gap_between_minute_and_hour_intervals() {
let fixed_time = Utc.with_ymd_and_hms(2025, 1, 15, 16, 0, 0).unwrap();
let clock = TestClock::build_for_testing_at(fixed_time);
let store = EventStore::builder()
.track_minutes(60)
.track_hours(24)
.with_clock(Arc::new(clock.clone()))
.build()
.unwrap();
store.record_ago("event", Duration::hours(2));
let first = store.query("event").first_seen();
let expected = Duration::seconds(5400);
assert_eq!(
first,
Some(expected),
"Should return exact hour bucket with gap detection"
);
}
#[test]
#[cfg(not(feature = "calendar"))]
fn first_seen_very_old_event_in_coarsest_interval() {
let fixed_time = Utc.with_ymd_and_hms(2025, 6, 1, 12, 0, 0).unwrap();
let clock = TestClock::build_for_testing_at(fixed_time);
let store = EventStore::builder()
.track_days(30)
.track_weeks(52)
.track_months(24)
.with_clock(Arc::new(clock.clone()))
.build()
.unwrap();
store.record_ago("event", Duration::days(540));
let first = store.query("event").first_seen();
let expected = Duration::seconds(45489600);
assert_eq!(
first,
Some(expected),
"Should return exact month bucket midpoint"
);
}
#[test]
#[cfg(feature = "calendar")]
fn first_seen_very_old_event_in_coarsest_interval_calendar() {
let fixed_time = Utc.with_ymd_and_hms(2025, 6, 1, 12, 0, 0).unwrap();
let clock = TestClock::build_for_testing_at(fixed_time);
let store = EventStore::builder()
.track_days(30)
.track_weeks(52)
.track_months(24)
.with_clock(Arc::new(clock.clone()))
.build()
.unwrap();
store.record_ago("event", Duration::days(540));
let first = store.query("event").first_seen();
assert!(first.is_some());
let offset = local_offset_hours(fixed_time);
let expected_hours = 13524.0 - offset * 1.0;
let duration = first.unwrap();
let hours = duration.num_hours();
assert!(
((expected_hours - 1.0) as i64..=(expected_hours + 1.0) as i64).contains(&hours),
"Expected ~{:.0} hours (±1h) based on UTC{:+.1} offset, got {} hours",
expected_hours,
offset,
hours
);
}
#[test]
fn first_seen_and_last_seen_for_single_event() {
let fixed_time = Utc.with_ymd_and_hms(2025, 2, 10, 9, 0, 0).unwrap();
let clock = TestClock::build_for_testing_at(fixed_time);
let store = EventStore::builder()
.track_minutes(60)
.track_hours(24)
.with_clock(Arc::new(clock.clone()))
.build()
.unwrap();
store.record_ago("event", Duration::minutes(30));
let first = store.query("event").first_seen();
let last = store.query("event").last_seen();
let expected = Duration::seconds(1830);
assert_eq!(first, Some(expected), "first_seen should be exact");
assert_eq!(last, Some(expected), "last_seen should be exact");
assert_eq!(
first, last,
"For single event, first and last must be identical"
);
}
#[test]
#[cfg(not(feature = "calendar"))]
fn first_seen_older_than_last_seen_with_multiple_events() {
let fixed_time = Utc.with_ymd_and_hms(2025, 3, 20, 15, 0, 0).unwrap();
let clock = TestClock::build_for_testing_at(fixed_time);
let store = EventStore::builder()
.track_hours(48)
.track_days(7)
.with_clock(Arc::new(clock.clone()))
.build()
.unwrap();
store.record_ago("event", Duration::days(3));
store.record_ago("event", Duration::hours(2));
let first = store.query("event").first_seen();
let last = store.query("event").last_seen();
let expected_first = Duration::seconds(199800);
let expected_last = Duration::seconds(9000);
assert_eq!(first, Some(expected_first), "first_seen should be exact");
assert_eq!(last, Some(expected_last), "last_seen should be exact");
assert!(
first.unwrap() > last.unwrap(),
"First must be older than last"
);
}
#[test]
#[cfg(feature = "calendar")]
fn first_seen_older_than_last_seen_with_multiple_events_calendar() {
let fixed_time = Utc.with_ymd_and_hms(2025, 3, 20, 15, 0, 0).unwrap();
let clock = TestClock::build_for_testing_at(fixed_time);
let store = EventStore::builder()
.track_hours(48)
.track_days(7)
.with_clock(Arc::new(clock.clone()))
.build()
.unwrap();
store.record_ago("event", Duration::days(3));
store.record_ago("event", Duration::hours(2));
let first = store.query("event").first_seen();
let last = store.query("event").last_seen();
assert!(first.is_some(), "first_seen should exist");
assert!(last.is_some(), "last_seen should exist");
assert!(
first.unwrap() > last.unwrap(),
"First must be older than last"
);
let offset = local_offset_hours(fixed_time);
let first_hours = first.unwrap().num_hours();
let last_hours = last.unwrap().num_hours();
let expected_first_hours = 67.0 - offset * 1.0;
assert!(
((expected_first_hours - 1.0) as i64..=(expected_first_hours + 1.0) as i64)
.contains(&first_hours),
"Expected first_seen ~{:.0} hours (±1h) based on UTC{:+.1} offset, got {} hours",
expected_first_hours,
offset,
first_hours
);
assert!(
(1..=4).contains(&last_hours),
"Expected last_seen ~2 hours (stable, in hours bucket), got {} hours",
last_hours
);
}