use chrono::{Duration, TimeZone, Utc};
use proptest::prelude::*;
use std::sync::Arc;
use tiny_counter::{EventStore, TestClock, TimeUnit};
fn create_store(
bucket_count: usize,
time_unit: TimeUnit,
clock: Option<Arc<dyn tiny_counter::Clock>>,
) -> EventStore {
let mut builder = EventStore::builder();
builder = match time_unit {
TimeUnit::Seconds => builder.track_seconds(bucket_count),
TimeUnit::Minutes => builder.track_minutes(bucket_count),
TimeUnit::Hours => builder.track_hours(bucket_count),
TimeUnit::Days => builder.track_days(bucket_count),
TimeUnit::Weeks => builder.track_weeks(bucket_count),
TimeUnit::Months => builder.track_months(bucket_count),
TimeUnit::Years => builder.track_years(bucket_count),
TimeUnit::Ever => builder.track_days(bucket_count),
};
if let Some(clock) = clock {
builder = builder.with_clock(clock);
}
builder.build().unwrap()
}
fn record_events_at_times(store: &EventStore, event_id: &str, events: Vec<(u32, i64)>) {
let now = Utc::now();
for (count, seconds_ago) in events {
if count == 0 {
continue;
}
let timestamp = now - Duration::seconds(seconds_ago);
let _ = store.record_count_at(event_id, count, timestamp);
}
}
#[test]
fn convert_hours_to_days() {
let source = EventStore::builder().track_hours(72).build().unwrap();
source.record_count("event", 1000);
let source_sum = source.query("event").last_hours(72).sum().unwrap();
let exported = source.export_all().unwrap();
let target = EventStore::builder().track_days(7).build().unwrap();
target.import_all(exported).unwrap();
let target_sum = target.query("event").last_days(7).sum().unwrap_or(0);
assert!(target_sum <= source_sum);
}
#[test]
fn convert_days_to_hours() {
let source = EventStore::builder().track_days(7).build().unwrap();
source.record_count("event", 500);
let source_sum = source.query("event").last_days(7).sum().unwrap();
let exported = source.export_all().unwrap();
let target = EventStore::builder()
.track_hours(168) .build()
.unwrap();
target.import_all(exported).unwrap();
let target_sum = target.query("event").last_hours(168).sum().unwrap_or(0);
assert!(target_sum <= source_sum);
}
#[test]
fn convert_with_bucket_count_increase() {
let source = EventStore::builder().track_hours(10).build().unwrap();
source.record_count("event", 200);
let exported = source.export_all().unwrap();
let target = EventStore::builder()
.track_hours(50) .build()
.unwrap();
target.import_all(exported).unwrap();
let source_sum = source.query("event").last_hours(10).sum().unwrap();
let target_sum = target.query("event").last_hours(50).sum().unwrap();
assert_eq!(source_sum, target_sum);
}
#[test]
fn convert_with_bucket_count_decrease() {
let fixed_time = Utc::now();
let clock = TestClock::build_for_testing_at(fixed_time);
let source = EventStore::builder()
.track_hours(50)
.with_clock(Arc::new(clock.clone()))
.build()
.unwrap();
for i in 0..10 {
source.record_ago("event", Duration::hours(i * 2));
}
let source_sum_20h = source.query("event").last_hours(20).sum().unwrap();
let exported = source.export_all().unwrap();
let target = EventStore::builder()
.track_hours(20)
.with_clock(Arc::new(clock))
.build()
.unwrap();
target.import_all(exported).unwrap();
let target_sum = target.query("event").last_hours(20).sum().unwrap();
assert_eq!(source_sum_20h, target_sum);
}
#[test]
fn round_trip_hours_to_days_to_hours() {
let source = EventStore::builder().track_hours(24).build().unwrap();
source.record_count("event", 100);
let initial_sum = source.query("event").last_hours(24).sum().unwrap();
let exported_a = source.export_all().unwrap();
let store_b = EventStore::builder().track_days(7).build().unwrap();
store_b.import_all(exported_a).unwrap();
let exported_b = store_b.export_all().unwrap();
let store_a2 = EventStore::builder().track_hours(24).build().unwrap();
store_a2.import_all(exported_b).unwrap();
let final_sum = store_a2.query("event").last_hours(24).sum().unwrap();
assert_eq!(initial_sum, final_sum);
}
#[test]
fn convert_days_to_hours_before_midnight() {
let clock = TestClock::new_at(Utc.with_ymd_and_hms(2024, 12, 31, 23, 0, 0).unwrap());
let source = EventStore::builder()
.track_days(7)
.with_clock(clock.clone())
.build()
.unwrap();
source.record_count("event", 500);
let source_sum = source.query("event").last_days(7).sum().unwrap();
let exported = source.export_all().unwrap();
let target = EventStore::builder()
.track_hours(168) .with_clock(clock)
.build()
.unwrap();
target.import_all(exported).unwrap();
let target_sum = target.query("event").last_hours(168).sum().unwrap_or(0);
assert!(target_sum <= source_sum);
}
#[test]
fn convert_days_to_hours_after_midnight() {
let clock = TestClock::new_at(Utc.with_ymd_and_hms(2025, 1, 1, 1, 0, 0).unwrap());
let source = EventStore::builder()
.track_days(7)
.with_clock(clock.clone())
.build()
.unwrap();
source.record_count("event", 500);
let source_sum = source.query("event").last_days(7).sum().unwrap();
let exported = source.export_all().unwrap();
let target = EventStore::builder()
.track_hours(168) .with_clock(clock)
.build()
.unwrap();
target.import_all(exported).unwrap();
let target_sum = target.query("event").last_hours(168).sum().unwrap_or(0);
assert!(target_sum <= source_sum);
}
#[test]
fn round_trip_hours_to_days_to_hours_before_midnight() {
let clock = TestClock::new_at(Utc.with_ymd_and_hms(2024, 12, 31, 23, 0, 0).unwrap());
let source = EventStore::builder()
.track_hours(24)
.with_clock(clock.clone())
.build()
.unwrap();
source.record_count("event", 100);
let initial_sum = source.query("event").last_hours(24).sum().unwrap();
let exported_a = source.export_all().unwrap();
let store_b = EventStore::builder()
.track_days(7)
.with_clock(clock.clone())
.build()
.unwrap();
store_b.import_all(exported_a).unwrap();
let exported_b = store_b.export_all().unwrap();
let store_a2 = EventStore::builder()
.track_hours(24)
.with_clock(clock)
.build()
.unwrap();
store_a2.import_all(exported_b).unwrap();
let final_sum = store_a2.query("event").last_hours(24).sum().unwrap();
assert_eq!(initial_sum, final_sum);
}
#[test]
fn round_trip_hours_to_days_to_hours_after_midnight() {
let clock = TestClock::new_at(Utc.with_ymd_and_hms(2025, 1, 1, 1, 0, 0).unwrap());
let source = EventStore::builder()
.track_hours(24)
.with_clock(clock.clone())
.build()
.unwrap();
source.record_count("event", 100);
let initial_sum = source.query("event").last_hours(24).sum().unwrap();
let exported_a = source.export_all().unwrap();
let store_b = EventStore::builder()
.track_days(7)
.with_clock(clock.clone())
.build()
.unwrap();
store_b.import_all(exported_a).unwrap();
let exported_b = store_b.export_all().unwrap();
let store_a2 = EventStore::builder()
.track_hours(24)
.with_clock(clock)
.build()
.unwrap();
store_a2.import_all(exported_b).unwrap();
let final_sum = store_a2.query("event").last_hours(24).sum().unwrap();
assert_eq!(initial_sum, final_sum);
}
#[test]
fn round_trip_with_different_bucket_counts() {
let source = EventStore::builder().track_days(10).build().unwrap();
source.record_count("event", 250);
let initial_sum = source.query("event").last_days(10).sum().unwrap();
let exported_a = source.export_all().unwrap();
let store_b = EventStore::builder().track_days(30).build().unwrap();
store_b.import_all(exported_a).unwrap();
let exported_b = store_b.export_all().unwrap();
let store_a2 = EventStore::builder().track_days(10).build().unwrap();
store_a2.import_all(exported_b).unwrap();
let final_sum = store_a2.query("event").last_days(10).sum().unwrap();
assert_eq!(initial_sum, final_sum);
}
#[test]
fn conversion_preserves_first_seen() {
let fixed_time = Utc::now();
let clock = TestClock::build_for_testing_at(fixed_time);
let source = EventStore::builder()
.track_hours(72)
.track_days(7)
.with_clock(Arc::new(clock.clone()))
.build()
.unwrap();
source.record_ago("event", Duration::days(2));
source.record("event");
let source_first = source.query("event").first_seen().unwrap();
let exported = source.export_all().unwrap();
let target = EventStore::builder()
.track_days(14)
.track_weeks(4)
.with_clock(Arc::new(clock))
.build()
.unwrap();
target.import_all(exported).unwrap();
let target_first = target.query("event").first_seen().unwrap();
let diff_hours = (source_first.num_hours() - target_first.num_hours()).abs();
assert!(diff_hours <= 48); }
fn time_unit_strategy() -> impl Strategy<Value = TimeUnit> {
prop_oneof![
Just(TimeUnit::Minutes),
Just(TimeUnit::Hours),
Just(TimeUnit::Days),
Just(TimeUnit::Weeks),
Just(TimeUnit::Months),
]
}
fn hour_of_day_strategy() -> impl Strategy<Value = u32> {
(0u32..24).boxed()
}
proptest! {
#[test]
fn conversion_round_trip_preserves_counts(
bucket_count_a in 5usize..20,
bucket_count_b in 5usize..20,
time_unit in time_unit_strategy(),
event_count in 1u32..100,
hour in hour_of_day_strategy(),
) {
let time = Utc.with_ymd_and_hms(2025, 1, 1, hour, 30, 0).unwrap();
let clock1 = TestClock::new_at(time);
let clock2 = TestClock::new_at(time);
let clock3 = TestClock::new_at(time);
let store_a = create_store(bucket_count_a, time_unit, Some(clock1));
store_a.record_count("test_event", event_count);
let initial_sum = store_a
.query("test_event")
.last_minutes(1)
.sum()
.unwrap_or(0);
let exported_a = store_a.export_all().unwrap();
let store_b = create_store(bucket_count_b, time_unit, Some(clock2));
store_b.import_all(exported_a).unwrap();
let exported_b = store_b.export_all().unwrap();
let store_a2 = create_store(bucket_count_a, time_unit, Some(clock3));
store_a2.import_all(exported_b).unwrap();
let final_sum = store_a2
.query("test_event")
.last_minutes(1)
.sum()
.unwrap_or(0);
prop_assert!(final_sum <= initial_sum);
prop_assert!(final_sum == 0 || final_sum == initial_sum); }
#[test]
fn conversion_preserves_or_loses_old_data(
source_buckets in 5usize..15,
target_buckets in 5usize..15,
time_unit in time_unit_strategy(),
events in prop::collection::vec(1u32..50, 1..10),
hour in hour_of_day_strategy(),
) {
let time = Utc.with_ymd_and_hms(2025, 1, 1, hour, 30, 0).unwrap();
let clock1 = TestClock::new_at(time);
let clock2 = TestClock::new_at(time);
let source_store = create_store(source_buckets, time_unit, Some(clock1));
let event_data: Vec<_> = events
.iter()
.enumerate()
.map(|(i, &count)| (count, (i as i64) * 60)) .collect();
record_events_at_times(&source_store, "test", event_data);
let source_sum = source_store
.query("test")
.last_minutes(60)
.sum()
.unwrap_or(0);
let exported = source_store.export_all().unwrap();
let target_store = create_store(target_buckets, time_unit, Some(clock2));
target_store.import_all(exported).unwrap();
let target_sum = target_store
.query("test")
.last_minutes(60)
.sum()
.unwrap_or(0);
prop_assert!(
target_sum <= source_sum,
"Conversion should not create events: source={}, target={}",
source_sum,
target_sum
);
}
#[test]
fn conversion_is_deterministic(
bucket_count in 5usize..20,
time_unit in time_unit_strategy(),
event_count in 1u32..100,
hour in hour_of_day_strategy(),
) {
let time = Utc.with_ymd_and_hms(2025, 1, 1, hour, 30, 0).unwrap();
let clock1 = TestClock::new_at(time);
let clock2 = TestClock::new_at(time);
let store1 = create_store(bucket_count, time_unit, Some(clock1));
store1.record_count("test", event_count);
let store2 = create_store(bucket_count, time_unit, Some(clock2));
store2.record_count("test", event_count);
let sum1 = store1.query("test").last_minutes(1).sum();
let sum2 = store2.query("test").last_minutes(1).sum();
prop_assert_eq!(sum1, sum2);
}
}
#[test]
fn convert_with_data_loss_outside_window() {
let fixed_time = Utc::now();
let clock = TestClock::build_for_testing_at(fixed_time);
let source = EventStore::builder()
.track_days(30)
.with_clock(Arc::new(clock.clone()))
.build()
.unwrap();
source.record_ago("event", Duration::days(20));
source.record_ago("event", Duration::days(2));
let source_all = source.query("event").last_days(30).sum().unwrap();
assert_eq!(source_all, 2);
let exported = source.export_all().unwrap();
let target = EventStore::builder()
.track_days(7)
.with_clock(Arc::new(clock))
.build()
.unwrap();
target.import_all(exported).unwrap();
let target_sum = target.query("event").last_days(7).sum().unwrap();
assert_eq!(target_sum, 1);
}
#[test]
fn convert_empty_store() {
let source = EventStore::builder().track_hours(24).build().unwrap();
let exported = source.export_all().unwrap();
assert_eq!(exported.len(), 0);
let target = EventStore::builder().track_days(7).build().unwrap();
target.import_all(exported).unwrap();
assert_eq!(target.query("any").last_days(7).sum(), None);
}