tiny-counter 0.1.0

Track event counts across time windows with fixed memory and fast queries
Documentation
//! Property-based tests using proptest.
//!
//! Tests invariants that should always hold for arbitrary inputs.

use chrono::{Duration, Utc};
use proptest::prelude::*;
use tiny_counter::{EventStore, TimeUnit};

// Strategy for generating TimeUnit (excluding Ever)
fn time_unit_strategy() -> impl Strategy<Value = TimeUnit> {
    prop_oneof![
        Just(TimeUnit::Seconds),
        Just(TimeUnit::Minutes),
        Just(TimeUnit::Hours),
        Just(TimeUnit::Days),
        Just(TimeUnit::Weeks),
        Just(TimeUnit::Months),
        Just(TimeUnit::Years),
    ]
}

// Helper to create a store with specific configuration
fn create_store(bucket_count: usize, time_unit: TimeUnit) -> 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),
    };

    builder.build().unwrap()
}

// Helper to record events at specific times
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);
    }
}

// ============================================================================
// Merge Properties
// ============================================================================

proptest! {
    /// merge(A, B) == merge(B, A) (commutative)
    #[test]
    fn merge_commutative(
        count_a in 1u32..100,
        count_b in 1u32..100,
        bucket_count in 5usize..15,
        time_unit in time_unit_strategy(),
    ) {
        if matches!(time_unit, TimeUnit::Ever) {
            return Ok(());
        }

        // A + B
        let store_a1 = create_store(bucket_count, time_unit);
        store_a1.record_count("test", count_a);

        let store_b1 = create_store(bucket_count, time_unit);
        store_b1.record_count("test", count_b);

        let exported_b = store_b1.export_all().unwrap();
        store_a1.merge_all(exported_b).unwrap();

        let sum_ab = store_a1.query("test").last_minutes(1).sum();

        // B + A
        let store_a2 = create_store(bucket_count, time_unit);
        store_a2.record_count("test", count_a);

        let store_b2 = create_store(bucket_count, time_unit);
        store_b2.record_count("test", count_b);

        let exported_a = store_a2.export_all().unwrap();
        store_b2.merge_all(exported_a).unwrap();

        let sum_ba = store_b2.query("test").last_minutes(1).sum();

        prop_assert_eq!(sum_ab, sum_ba);
    }

    /// merge(merge(A, B), C) == merge(A, merge(B, C)) (associative)
    #[test]
    fn merge_associative(
        count_a in 1u32..50,
        count_b in 1u32..50,
        count_c in 1u32..50,
        bucket_count in 5usize..10,
        time_unit in time_unit_strategy(),
    ) {
        if matches!(time_unit, TimeUnit::Ever) {
            return Ok(());
        }

        // (A + B) + C
        let store_a1 = create_store(bucket_count, time_unit);
        store_a1.record_count("test", count_a);

        let store_b1 = create_store(bucket_count, time_unit);
        store_b1.record_count("test", count_b);

        let store_c1 = create_store(bucket_count, time_unit);
        store_c1.record_count("test", count_c);

        let exported_b1 = store_b1.export_all().unwrap();
        store_a1.merge_all(exported_b1).unwrap();
        let exported_c1 = store_c1.export_all().unwrap();
        store_a1.merge_all(exported_c1).unwrap();

        let sum_abc = store_a1.query("test").last_minutes(1).sum();

        // A + (B + C)
        let store_a2 = create_store(bucket_count, time_unit);
        store_a2.record_count("test", count_a);

        let store_b2 = create_store(bucket_count, time_unit);
        store_b2.record_count("test", count_b);

        let store_c2 = create_store(bucket_count, time_unit);
        store_c2.record_count("test", count_c);

        let exported_c2 = store_c2.export_all().unwrap();
        store_b2.merge_all(exported_c2).unwrap();
        let exported_bc = store_b2.export_all().unwrap();
        store_a2.merge_all(exported_bc).unwrap();

        let sum_abc2 = store_a2.query("test").last_minutes(1).sum();

        prop_assert_eq!(sum_abc, sum_abc2);
    }

    /// merge(A, empty) == A (identity)
    #[test]
    fn merge_identity(
        event_count in 1u32..100,
        bucket_count in 5usize..15,
        time_unit in time_unit_strategy(),
    ) {
        if matches!(time_unit, TimeUnit::Ever) {
            return Ok(());
        }

        let store = create_store(bucket_count, time_unit);
        store.record_count("test", event_count);

        let sum_before = store.query("test").last_minutes(1).sum();

        let empty_store = create_store(bucket_count, time_unit);
        let empty_export = empty_store.export_all().unwrap();
        store.merge_all(empty_export).unwrap();

        let sum_after = store.query("test").last_minutes(1).sum();

        prop_assert_eq!(sum_before, sum_after);
    }
}

// ============================================================================
// Query Properties
// ============================================================================

proptest! {
    /// Recording N events equals record_count(N)
    #[test]
    fn record_n_equals_record_count_n(count in 1u32..100) {
        let store1 = EventStore::new();
        for _ in 0..count {
            store1.record("event");
        }

        let store2 = EventStore::new();
        store2.record_count("event", count);

        let sum1 = store1.query("event").last_days(1).sum();
        let sum2 = store2.query("event").last_days(1).sum();

        prop_assert_eq!(sum1, sum2);
    }

    /// Query sum never exceeds total recorded
    #[test]
    fn query_sum_bounded_by_total(
        events in prop::collection::vec(1u32..50, 1..10),
        bucket_count in 5usize..20,
    ) {
        let store = EventStore::builder()
            .track_minutes(bucket_count)
            .build()
            .unwrap();

        let event_data: Vec<_> = events
            .iter()
            .enumerate()
            .map(|(i, &count)| (count, (i as i64) * 60))
            .collect();

        record_events_at_times(&store, "test", event_data);

        let expected: u32 = events.iter().sum();
        let sum = store.query("test").last_minutes(bucket_count).sum().unwrap_or(0);

        prop_assert!(sum <= expected);
    }

    /// Range queries respect boundaries
    #[test]
    fn query_respects_range_bounds(
        events in prop::collection::vec(1u32..20, 5..15),
    ) {
        let bucket_count = 20;
        let store = EventStore::builder()
            .track_minutes(bucket_count)
            .build()
            .unwrap();

        let event_data: Vec<_> = events
            .iter()
            .enumerate()
            .map(|(i, &count)| (count, (i as i64) * 60))
            .collect();

        record_events_at_times(&store, "test", event_data);

        let subset_sum = store.query("test").last_minutes(10).sum().unwrap_or(0);
        let full_sum = store.query("test").last_minutes(bucket_count).sum().unwrap_or(0);

        prop_assert!(subset_sum <= full_sum);
    }
}

// ============================================================================
// Invariant Properties
// ============================================================================

proptest! {
    /// Counts remain non-negative (inherent with u64)
    #[test]
    fn counts_remain_non_negative(
        events in prop::collection::vec(1u32..100, 1..20),
    ) {
        let store = EventStore::new();

        for &count in &events {
            store.record_count("test", count);
        }

        let result = store.query("test").last_days(1).sum();
        prop_assert!(result.is_some());
        prop_assert!(result.unwrap() > 0);
    }

    /// Recording doesn't lose data
    #[test]
    fn no_data_loss_during_recording(
        initial_count in 1u32..100,
        additional_count in 1u32..100,
    ) {
        let store = EventStore::new();

        store.record_count("test", initial_count);
        let sum_before = store.query("test").last_days(1).sum().unwrap();
        prop_assert_eq!(sum_before, initial_count);

        store.record_count("test", additional_count);
        let sum_after = store.query("test").last_days(1).sum().unwrap();
        prop_assert_eq!(sum_after, initial_count + additional_count);
    }
}