//! UAT: Milestone 1, Phase 1 — Core Type System and Schema //! //! Exercises every m1p1 acceptance criterion from the public API. //! Does NOT duplicate the unit tests in `schema/entity.rs`, `schema/signal.rs`, //! `schema/error.rs`, `schema/score.rs`, or `schema/validation.rs`. //! Instead it verifies from the *user* perspective: import `tidaldb::schema::*`, //! construct types, and assert the documented guarantees hold. #![allow(clippy::unwrap_used, unused_must_use)] use std::collections::HashSet; use std::time::Duration; use tidaldb::schema::{ DecaySpec, EntityId, EntityKind, SchemaBuilder, SchemaError, Score, Window, WindowSet, }; // ── UAT-01: EntityId — u64 newtype with Display, Hash, Eq, Ord, to_be_bytes ── #[test] fn uat01_entity_id_display() { let id = EntityId::new(42); assert_eq!(id.to_string(), "42"); } #[test] fn uat01_entity_id_hash_eq() { let a = EntityId::new(7); let b = EntityId::new(7); let c = EntityId::new(8); // Eq assert_eq!(a, b); assert_ne!(a, c); // Hash: equal entities produce equal hashes (insert into HashSet, verify dedup) let mut set = HashSet::new(); set.insert(a); set.insert(b); set.insert(c); assert_eq!(set.len(), 2, "HashSet should deduplicate equal EntityIds"); } #[test] fn uat01_entity_id_ord() { let ids: Vec = vec![ EntityId::new(100), EntityId::new(1), EntityId::new(50), EntityId::new(0), EntityId::new(u64::MAX), ]; let mut sorted = ids.clone(); sorted.sort(); let vals: Vec = sorted.iter().map(|id| id.as_u64()).collect(); assert_eq!(vals, vec![0, 1, 50, 100, u64::MAX]); } #[test] fn uat01_entity_id_be_bytes_preserves_ordering() { // The acceptance criterion: big-endian encoding preserves numeric ordering. let pairs = [ (0_u64, 1_u64), (1, 2), (255, 256), (u64::MAX - 1, u64::MAX), (0, u64::MAX), ]; for (a, b) in pairs { let bytes_a = EntityId::new(a).to_be_bytes(); let bytes_b = EntityId::new(b).to_be_bytes(); assert!( bytes_a < bytes_b, "be_bytes ordering violated: {a} < {b} but bytes {bytes_a:?} >= {bytes_b:?}" ); } } // ── UAT-02: EntityKind — Item, User, Creator ───────────────────────────────── #[test] fn uat02_entity_kind_variants_exist() { let _item = EntityKind::Item; let _user = EntityKind::User; let _creator = EntityKind::Creator; } #[test] fn uat02_entity_kind_display() { assert_eq!(EntityKind::Item.to_string(), "item"); assert_eq!(EntityKind::User.to_string(), "user"); assert_eq!(EntityKind::Creator.to_string(), "creator"); } // ── UAT-03: SignalTypeDef via SchemaBuilder ────────────────────────────────── #[test] fn uat03_signal_type_def_captures_all_fields() { let mut builder = SchemaBuilder::new(); builder .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(604_800), }, ) .windows(&[Window::OneHour, Window::TwentyFourHours]) .velocity(true) .add(); let schema = builder.build().unwrap(); let view = schema.signal("view").unwrap(); assert_eq!(view.name(), "view"); assert_eq!(view.target(), EntityKind::Item); assert!(view.velocity_enabled()); assert_eq!(view.windows().len(), 2); assert!(view.windows().contains(&Window::OneHour)); assert!(view.windows().contains(&Window::TwentyFourHours)); assert!(view.decay().lambda().is_some()); assert!(view.decay().half_life().is_some()); } #[test] fn uat03_signal_type_def_user_target() { let mut builder = SchemaBuilder::new(); builder .signal("follow", EntityKind::User, DecaySpec::Permanent) .add(); let schema = builder.build().unwrap(); let follow = schema.signal("follow").unwrap(); assert_eq!(follow.target(), EntityKind::User); } #[test] fn uat03_signal_type_def_creator_target() { let mut builder = SchemaBuilder::new(); builder .signal("subscribe", EntityKind::Creator, DecaySpec::Permanent) .add(); let schema = builder.build().unwrap(); let sub = schema.signal("subscribe").unwrap(); assert_eq!(sub.target(), EntityKind::Creator); } // ── UAT-04: DecayModel — pre-computed lambda, no division on hot path ──────── #[test] fn uat04_exponential_decay_precomputes_lambda() { let half_life_secs = 7.0 * 24.0 * 3600.0; // 7 days let expected_lambda = std::f64::consts::LN_2 / half_life_secs; let mut builder = SchemaBuilder::new(); builder .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(604_800), }, ) .windows(&[Window::AllTime]) .add(); let schema = builder.build().unwrap(); let view = schema.signal("view").unwrap(); let lambda = view.decay().lambda().unwrap(); assert!( (lambda - expected_lambda).abs() < 1e-15, "lambda should be precomputed as ln(2)/half_life: got {lambda}, expected {expected_lambda}" ); } #[test] fn uat04_linear_decay_has_no_lambda() { let mut builder = SchemaBuilder::new(); builder .signal( "impression", EntityKind::Item, DecaySpec::Linear { lifetime: Duration::from_secs(86_400), }, ) .windows(&[Window::TwentyFourHours]) .add(); let schema = builder.build().unwrap(); let sig = schema.signal("impression").unwrap(); assert!(sig.decay().lambda().is_none()); assert!(sig.decay().half_life().is_none()); } #[test] fn uat04_permanent_decay_has_no_lambda() { let mut builder = SchemaBuilder::new(); builder .signal("block", EntityKind::User, DecaySpec::Permanent) .add(); let schema = builder.build().unwrap(); let sig = schema.signal("block").unwrap(); assert!(sig.decay().lambda().is_none()); assert!(sig.decay().half_life().is_none()); } // ── UAT-05: Window enum — all variants, duration(), label(), duration_secs_f64() ─ #[test] fn uat05_window_variants_and_methods() { let windows = [ (Window::OneHour, Duration::from_secs(3_600), "1h", 3_600.0), ( Window::TwentyFourHours, Duration::from_secs(86_400), "24h", 86_400.0, ), ( Window::SevenDays, Duration::from_secs(604_800), "7d", 604_800.0, ), ( Window::ThirtyDays, Duration::from_secs(2_592_000), "30d", 2_592_000.0, ), ]; for (window, expected_dur, expected_label, expected_secs) in windows { assert_eq!( window.duration(), expected_dur, "duration mismatch for {expected_label}" ); assert_eq!(window.label(), expected_label); assert!( (window.duration_secs_f64() - expected_secs).abs() < 1e-10, "duration_secs_f64 mismatch for {expected_label}" ); } // AllTime is special assert_eq!(Window::AllTime.duration(), Duration::MAX); assert_eq!(Window::AllTime.label(), "all"); assert!(Window::AllTime.duration_secs_f64().is_infinite()); } // ── UAT-06: WindowSet — deduplicates and sorts; empty() for permanent ──────── #[test] fn uat06_window_set_deduplicates_and_sorts() { let ws = WindowSet::new(&[ Window::AllTime, Window::OneHour, Window::SevenDays, Window::OneHour, // duplicate Window::AllTime, // duplicate ]); assert_eq!(ws.len(), 3); let windows: Vec = ws.iter().copied().collect(); assert_eq!( windows, vec![Window::OneHour, Window::SevenDays, Window::AllTime] ); } #[test] fn uat06_window_set_empty() { let ws = WindowSet::empty(); assert!(ws.is_empty()); assert_eq!(ws.len(), 0); } // ── UAT-07: Error types — all TidalError variants ─────────────────────────── #[test] fn uat07_tidal_error_variants_exist() { use tidaldb::TidalError; // Verify the variants exist and implement Display (via thiserror). let errors: Vec> = vec![ Box::new(TidalError::internal("test", "test")), Box::new(TidalError::NotFound { kind: EntityKind::Item, id: EntityId::new(1), }), Box::new(TidalError::Schema(SchemaError::NoSignalsDefined)), ]; for e in &errors { // Display works let msg = e.to_string(); assert!(!msg.is_empty()); } } #[test] fn uat07_schema_error_converts_to_tidal_error() { use tidaldb::TidalError; let schema_err = SchemaError::DuplicateSignalName("view".into()); let tidal_err: TidalError = schema_err.into(); assert!(matches!( tidal_err, TidalError::Schema(SchemaError::DuplicateSignalName(_)) )); } // ── UAT-08: SchemaError — all validation error conditions ─────────────────── #[test] fn uat08_rejects_duplicate_signal_names() { let mut builder = SchemaBuilder::new(); builder .signal("view", EntityKind::Item, DecaySpec::Permanent) .add(); builder .signal("view", EntityKind::Item, DecaySpec::Permanent) .add(); let err = builder.build().unwrap_err(); assert!( matches!(err, SchemaError::DuplicateSignalName(ref name) if name == "view"), "expected DuplicateSignalName, got: {err}" ); } #[test] fn uat08_rejects_invalid_identifiers() { let invalid_names = [ "", "View", "1view", "view count", "view-count", "_view", "view!", ]; for name in invalid_names { let mut builder = SchemaBuilder::new(); builder .signal(name, EntityKind::Item, DecaySpec::Permanent) .add(); let err = builder.build().unwrap_err(); assert!( matches!(err, SchemaError::InvalidSignalName(_)), "expected InvalidSignalName for '{name}', got: {err}" ); } } #[test] fn uat08_rejects_zero_half_life() { let mut builder = SchemaBuilder::new(); builder .signal( "bad", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::ZERO, }, ) .windows(&[Window::AllTime]) .add(); let err = builder.build().unwrap_err(); assert!( matches!(err, SchemaError::InvalidHalfLife { .. }), "expected InvalidHalfLife, got: {err}" ); } #[test] fn uat08_rejects_zero_lifetime() { let mut builder = SchemaBuilder::new(); builder .signal( "bad", EntityKind::Item, DecaySpec::Linear { lifetime: Duration::ZERO, }, ) .windows(&[Window::AllTime]) .add(); let err = builder.build().unwrap_err(); assert!( matches!(err, SchemaError::InvalidLifetime { .. }), "expected InvalidLifetime, got: {err}" ); } #[test] fn uat08_rejects_empty_windows_for_non_permanent() { // Exponential without windows let mut builder = SchemaBuilder::new(); builder .signal( "bad", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(3600), }, ) .add(); let err = builder.build().unwrap_err(); assert!( matches!(err, SchemaError::EmptyWindows { .. }), "expected EmptyWindows for exponential, got: {err}" ); // Linear without windows let mut builder = SchemaBuilder::new(); builder .signal( "bad", EntityKind::Item, DecaySpec::Linear { lifetime: Duration::from_secs(3600), }, ) .add(); let err = builder.build().unwrap_err(); assert!( matches!(err, SchemaError::EmptyWindows { .. }), "expected EmptyWindows for linear, got: {err}" ); } #[test] fn uat08_accepts_empty_windows_for_permanent() { let mut builder = SchemaBuilder::new(); builder .signal("hide", EntityKind::Item, DecaySpec::Permanent) .add(); assert!(builder.build().is_ok()); } #[test] fn uat08_rejects_velocity_without_windows() { let mut builder = SchemaBuilder::new(); builder .signal("bad", EntityKind::Item, DecaySpec::Permanent) .velocity(true) .add(); let err = builder.build().unwrap_err(); assert!( matches!(err, SchemaError::VelocityWithoutWindows { .. }), "expected VelocityWithoutWindows, got: {err}" ); } #[test] fn uat08_rejects_empty_schema() { let err = SchemaBuilder::new().build().unwrap_err(); assert!( matches!(err, SchemaError::NoSignalsDefined), "expected NoSignalsDefined, got: {err}" ); } // ── UAT-09: SchemaBuilder — valid multi-signal schema ─────────────────────── #[test] fn uat09_valid_multi_signal_schema() { let mut builder = SchemaBuilder::new(); // Exponential decay with velocity builder .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[ Window::OneHour, Window::TwentyFourHours, Window::SevenDays, Window::ThirtyDays, Window::AllTime, ]) .velocity(true) .add(); // Linear decay builder .signal( "impression", EntityKind::Item, DecaySpec::Linear { lifetime: Duration::from_secs(86_400), }, ) .windows(&[Window::TwentyFourHours]) .velocity(false) .add(); // Permanent builder .signal("hide", EntityKind::Item, DecaySpec::Permanent) .add(); // User signal builder .signal("follow", EntityKind::User, DecaySpec::Permanent) .add(); // Creator signal builder .signal("subscribe", EntityKind::Creator, DecaySpec::Permanent) .add(); let schema = builder.build().unwrap(); assert_eq!(schema.signal_count(), 5); // Verify all signals are retrievable assert!(schema.signal("view").is_some()); assert!(schema.signal("impression").is_some()); assert!(schema.signal("hide").is_some()); assert!(schema.signal("follow").is_some()); assert!(schema.signal("subscribe").is_some()); assert!(schema.signal("nonexistent").is_none()); // Verify iterate signals returns all let names: HashSet<&str> = schema.signals().map(|s| s.name()).collect(); assert_eq!(names.len(), 5); assert!(names.contains("view")); assert!(names.contains("impression")); assert!(names.contains("hide")); assert!(names.contains("follow")); assert!(names.contains("subscribe")); } // ── UAT-10: Score — rejects NaN/Inf, accepts finite, total ordering ───────── #[test] fn uat10_score_rejects_nan_and_inf() { assert!(Score::new(f64::NAN).is_none()); assert!(Score::new(f64::INFINITY).is_none()); assert!(Score::new(f64::NEG_INFINITY).is_none()); } #[test] fn uat10_score_accepts_finite_values() { let s = Score::new(0.75).unwrap(); assert!((s.as_f64() - 0.75).abs() < 1e-15); let neg = Score::new(-10.0).unwrap(); assert!((neg.as_f64() - (-10.0)).abs() < 1e-15); assert_eq!(Score::ZERO.as_f64(), 0.0); } #[test] fn uat10_score_total_ordering() { let a = Score::new(-1.0).unwrap(); let b = Score::ZERO; let c = Score::new(0.5).unwrap(); let d = Score::new(1.0).unwrap(); assert!(a < b); assert!(b < c); assert!(c < d); assert_eq!(b, Score::new(0.0).unwrap()); // Sort works let mut scores = vec![d, a, c, b]; scores.sort(); let vals: Vec = scores.iter().map(|s| s.as_f64()).collect(); assert_eq!(vals, vec![-1.0, 0.0, 0.5, 1.0]); }