//! Integration tests for M7P4 Operational Visibility. //! //! Covers all 9 tasks: //! - Task 01: QueryStats — per-query execution statistics attached to every result //! - Task 02: Signal + WAL metrics //! - Task 03: Index health metrics //! - Task 04: Session + cohort + degradation metrics //! - Task 05: Prometheus exposition format correctness //! - Task 06: Structured error context //! - Task 07: Metrics feature flag (zero-overhead base types) //! - Task 08: RLHF signal export //! - Task 09: Cross-session aggregation #![allow(clippy::unwrap_used)] use std::collections::HashMap; use std::time::Duration; use tidaldb::schema::{DecaySpec, EntityId, EntityKind, Timestamp, Window}; use tidaldb::{ExportFormat, ExportRequest, ExportedSignal, TidalDb}; // ── Prometheus output helpers ──────────────────────────────────────────────── /// Extract the numeric value for a metric from Prometheus text output. /// /// Looks for a line that starts with `name` (not a comment), then parses /// the trailing float. Returns `None` if the metric is absent. #[cfg(feature = "metrics")] fn prometheus_value(prom: &str, name: &str) -> Option { prom.lines() .filter(|l| !l.starts_with('#')) .find(|l| l.starts_with(name) && l[name.len()..].starts_with(' ')) .and_then(|l| l.split_whitespace().last()) .and_then(|v| v.parse().ok()) } // ── Shared test schema ─────────────────────────────────────────────────────── fn build_test_schema() -> tidaldb::schema::Schema { use tidaldb::AgentPolicy; use tidaldb::schema::SchemaBuilder; let mut builder = SchemaBuilder::new(); let _ = builder .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::OneHour]) .add(); let _ = builder .signal( "like", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(30 * 24 * 3600), }, ) .windows(&[Window::OneHour]) .add(); builder.session_policy( "default", AgentPolicy { allowed_signals: vec!["view".to_string(), "like".to_string()], denied_signals: vec![], max_session_duration: Duration::from_secs(3600), max_signals_per_session: 1000, }, ); builder.build().unwrap() } // ── Task 01: QueryStats ────────────────────────────────────────────────────── /// RETRIEVE results carry QueryStats with the profile name and pipeline counts. #[test] fn retrieve_results_include_query_stats() { use tidaldb::query::retrieve::{ProfileRef, RetrieveBuilder}; let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); // Write items so the candidate generation stage has something to score. for i in 1u64..=5 { let mut meta = HashMap::new(); meta.insert("category".to_string(), "news".to_string()); db.write_item_with_metadata(EntityId::new(i), &meta) .unwrap(); db.signal("view", EntityId::new(i), 1.0, Timestamp::now()) .unwrap(); } let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("new")) .limit(5) .build() .unwrap(); let results = db.retrieve(&query).unwrap(); // QueryStats is unconditionally populated (not feature-gated). assert_eq!( results.stats.profile_name, "new", "stats must capture the profile name used for scoring" ); // Pipeline stage fields are accessible; exact values depend on item count. let _ = results.stats.candidates_considered; let _ = results.stats.candidates_after_filter; let _ = results.stats.total_time_us; let _ = results.stats.scoring_time_us; let _ = results.stats.diversity_time_us; } /// SEARCH results carry QueryStats with the "search" profile name. #[test] fn search_results_include_query_stats() { use tidaldb::query::search::Search; let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); // No text schema, so BM25 returns empty — but stats are still populated. let query = Search::builder() .query("test article") .limit(5) .build() .unwrap(); let results = db.search(&query).unwrap(); assert_eq!( results.stats.profile_name, "search", "search pipeline must record the 'search' builtin profile in stats" ); let _ = results.stats.candidates_considered; let _ = results.stats.total_time_us; } // ── Task 02: Signal + WAL Metrics ─────────────────────────────────────────── #[cfg(feature = "metrics")] #[test] fn signal_write_increments_counter() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let before = prometheus_value( &db.metrics().render_prometheus(), "tidaldb_signal_writes_total", ) .unwrap_or(0.0); db.signal("view", EntityId::new(1), 1.0, Timestamp::now()) .unwrap(); db.signal("like", EntityId::new(2), 1.0, Timestamp::now()) .unwrap(); db.signal("view", EntityId::new(3), 0.5, Timestamp::now()) .unwrap(); let after = prometheus_value( &db.metrics().render_prometheus(), "tidaldb_signal_writes_total", ) .unwrap(); assert_eq!( (after - before) as u64, 3, "signal_writes_total must increment once per signal write" ); } #[cfg(feature = "metrics")] #[test] fn signal_write_latency_appears_in_prometheus() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); for i in 1u64..=3 { db.signal("view", EntityId::new(i), 1.0, Timestamp::now()) .unwrap(); } let prom = db.metrics().render_prometheus(); assert!( prom.contains("tidaldb_signal_write_latency_us"), "Prometheus output must include signal write latency histogram" ); assert!( prom.contains("tidaldb_signal_writes_total"), "Prometheus output must include signal_writes_total counter" ); assert!( prom.contains("tidaldb_wal_lag_bytes"), "Prometheus output must include wal_lag_bytes gauge" ); assert!( prom.contains("tidaldb_checkpoint_age_seconds"), "Prometheus output must include checkpoint_age_seconds gauge" ); } // ── Task 03: Index Health Metrics ──────────────────────────────────────────── #[cfg(feature = "metrics")] #[test] fn prometheus_output_contains_all_index_gauge_names() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let prom = db.metrics().render_prometheus(); assert!( prom.contains("tidaldb_tantivy_segment_count"), "Prometheus output must include tantivy_segment_count gauge" ); assert!( prom.contains("tidaldb_tantivy_indexed_docs"), "Prometheus output must include tantivy_indexed_docs gauge" ); assert!( prom.contains("tidaldb_usearch_vector_count"), "Prometheus output must include usearch_vector_count gauge" ); assert!( prom.contains("tidaldb_usearch_index_size_bytes"), "Prometheus output must include usearch_index_size_bytes gauge" ); assert!( prom.contains("tidaldb_bitmap_index_cardinality"), "Prometheus output must include bitmap_index_cardinality gauge" ); } // ── Task 04: Session + Cohort + Degradation Metrics ───────────────────────── #[cfg(feature = "metrics")] #[test] fn session_start_increments_active_sessions() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let before = prometheus_value(&db.metrics().render_prometheus(), "tidaldb_active_sessions") .unwrap_or(0.0); let meta = HashMap::new(); let _handle = db.start_session(1u64, "agent1", "default", meta).unwrap(); let after = prometheus_value(&db.metrics().render_prometheus(), "tidaldb_active_sessions").unwrap(); assert_eq!( (after - before) as u64, 1, "active_sessions must increment when a session starts" ); } #[cfg(feature = "metrics")] #[test] fn session_close_updates_active_and_closed_counters() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let before_active = prometheus_value(&db.metrics().render_prometheus(), "tidaldb_active_sessions") .unwrap_or(0.0); let before_closed = prometheus_value( &db.metrics().render_prometheus(), "tidaldb_closed_sessions_total", ) .unwrap_or(0.0); let meta = HashMap::new(); let handle = db .start_session(42u64, "agent-close-test", "default", meta) .unwrap(); db.close_session(handle).unwrap(); let after_active = prometheus_value(&db.metrics().render_prometheus(), "tidaldb_active_sessions").unwrap(); let after_closed = prometheus_value( &db.metrics().render_prometheus(), "tidaldb_closed_sessions_total", ) .unwrap(); assert_eq!( after_active as u64, before_active as u64, "active_sessions must return to its previous value after close_session" ); assert_eq!( (after_closed - before_closed) as u64, 1, "closed_sessions_total must increment on close_session" ); } #[cfg(feature = "metrics")] #[test] fn degradation_level_appears_in_prometheus_output() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let prom = db.metrics().render_prometheus(); // Default: Full fidelity (level 0). let level = prometheus_value(&prom, "tidaldb_degradation_level").unwrap(); assert_eq!( level as u64, 0, "initial degradation level must be 0 (Full fidelity)" ); assert!( prom.contains("tidaldb_degradation_level"), "Prometheus output must include degradation_level gauge" ); assert!( prom.contains("tidaldb_rate_limited_total"), "Prometheus output must include rate_limited_total counter" ); } // ── Task 05: Prometheus Exposition Format ──────────────────────────────────── /// Every `# HELP` line must be immediately followed by a `# TYPE` line. #[test] fn prometheus_format_help_always_followed_by_type() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let prom = db.metrics().render_prometheus(); let lines: Vec<&str> = prom.lines().collect(); for (i, line) in lines.iter().enumerate() { if line.starts_with("# HELP ") { assert!( i + 1 < lines.len() && lines[i + 1].starts_with("# TYPE "), "# HELP line must be immediately followed by # TYPE at index {i}: {line:?}" ); } } } /// /healthz JSON contains the required fields. #[test] fn healthz_json_contains_required_fields() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let healthz = db.metrics().render_healthz(); assert!( healthz.contains("\"status\":"), "healthz must contain status" ); assert!( healthz.contains("\"uptime_seconds\":"), "healthz must contain uptime_seconds" ); assert!( healthz.contains("\"version\":"), "healthz must contain version" ); assert!( healthz.contains("\"build_hash\":"), "healthz must contain build_hash" ); } // ── Task 06: Structured Error Context ──────────────────────────────────────── /// `TidalError::Internal` wraps `ErrorContext` with operation + detail fields. #[test] fn tidal_error_internal_wraps_error_context() { use tidaldb::TidalError; let err = TidalError::internal("test_operation", "something went wrong"); if let TidalError::Internal(ctx) = &err { assert_eq!(ctx.operation, "test_operation"); assert_eq!(ctx.detail, "something went wrong"); assert!(ctx.entity_id.is_none(), "entity_id should default to None"); assert!( ctx.entity_kind.is_none(), "entity_kind should default to None" ); assert!( ctx.signal_type.is_none(), "signal_type should default to None" ); } else { panic!("expected TidalError::Internal, got {err:?}"); } // Display must include the operation name. let display = format!("{err}"); assert!( display.contains("test_operation"), "Display must include the operation name: {display}" ); } /// `ErrorContext` builder pattern sets optional fields correctly. #[test] fn error_context_builder_sets_optional_fields() { use tidaldb::ErrorContext; let ctx = ErrorContext::new("write_signal", "WAL append failed") .with_entity(42) .with_kind(EntityKind::Item) .with_signal("view"); assert_eq!(ctx.operation, "write_signal"); assert_eq!(ctx.detail, "WAL append failed"); assert_eq!(ctx.entity_id, Some(42)); assert_eq!(ctx.entity_kind, Some(EntityKind::Item)); assert_eq!(ctx.signal_type, Some("view".to_string())); } // ── Task 07: Feature Flag ──────────────────────────────────────────────────── /// `QueryStats` is always available regardless of the `metrics` feature flag. /// This test intentionally has NO `#[cfg(feature = "metrics")]` annotation. #[test] fn query_stats_available_without_metrics_feature() { use tidaldb::query::stats::QueryStats; let stats = QueryStats::new("my_profile".to_owned()); assert_eq!(stats.profile_name, "my_profile"); assert_eq!(stats.total_time_us, 0); assert_eq!(stats.candidates_considered, 0); assert_eq!(stats.filters_applied, 0); assert_eq!(stats.degradation_level, 0); } /// `MetricsState` base fields (`uptime_seconds`, `health_ok_value`) work /// unconditionally — they are NOT gated behind the `metrics` feature. #[test] fn metrics_state_base_fields_always_available() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); // These methods exist and compile even with --no-default-features. assert!(db.metrics().uptime_seconds() >= 0.0); assert!((db.metrics().health_ok_value() - 1.0).abs() < f64::EPSILON); } // ── Task 08: RLHF Signal Export ────────────────────────────────────────────── #[test] #[cfg(feature = "test-utils")] fn export_signals_ephemeral_returns_empty() { use tidaldb::TidalDb; use tidaldb::schema::SchemaBuilder; let mut builder = SchemaBuilder::new(); let _ = builder .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::OneHour]) .add(); let schema = builder.build().unwrap(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); // Ephemeral mode: no WAL on disk, always returns empty. let req = ExportRequest::time_range(0, u64::MAX); let signals = db.export_signals(&req).unwrap(); assert!(signals.is_empty()); } #[test] #[cfg(feature = "test-utils")] fn export_signals_unknown_type_returns_error() { use tidaldb::TidalDb; use tidaldb::schema::SchemaBuilder; let mut builder = SchemaBuilder::new(); let _ = builder .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::OneHour]) .add(); let schema = builder.build().unwrap(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let req = ExportRequest::signals_in_range(vec!["nonexistent".into()], 0, u64::MAX); let result = db.export_signals(&req); assert!( result.is_err(), "requesting an unknown signal type must return an error" ); } #[test] fn export_format_json_lines_value() { assert_eq!(ExportFormat::JsonLines, ExportFormat::JsonLines); } #[test] fn export_request_time_range_fields() { let req = ExportRequest::time_range(1000, 2000); assert_eq!(req.since, Some(1000)); assert_eq!(req.until, Some(2000)); assert!(req.signal_types.is_empty()); assert_eq!(req.format, ExportFormat::JsonLines); assert!(req.limit.is_none()); assert!(req.user_id.is_none()); } #[test] fn exported_signal_to_json_line_valid() { let sig = ExportedSignal { entity_id: 42, signal_type: "view".to_string(), weight: 1.0, timestamp_ns: 1_700_000_000_000_000_000, user_id: None, session_id: None, annotation: None, }; let line = sig.to_json_line(); assert!(line.starts_with('{')); assert!(line.ends_with('}')); assert!(line.contains("\"entity_id\":42")); assert!(line.contains("\"signal_type\":\"view\"")); // Anonymous signals must not include optional keys. assert!(!line.contains("user_id")); assert!(!line.contains("session_id")); assert!(!line.contains("annotation")); } /// `export_signals` reads actual WAL events written to a persistent database. /// /// Export reads live, uncompacted WAL segments — those fsynced to disk but /// not yet removed by a clean shutdown. This test exercises the WAL scanning /// code path (persistent mode) that the ephemeral early-return skips. /// /// Note: a clean `close()` compacts WAL segments, so export is called on the /// same open DB instance — not after reopening. #[test] #[cfg(feature = "test-utils")] fn export_signals_reads_wal_events() { use tidaldb::TempTidalHome; let home = TempTidalHome::new().unwrap(); let schema = build_test_schema(); let db = TidalDb::builder() .with_data_dir(home.path()) .with_schema(schema) .open() .unwrap(); let since = Timestamp::now().as_nanos(); db.signal("view", EntityId::new(1), 1.0, Timestamp::now()) .unwrap(); db.signal("view", EntityId::new(2), 1.5, Timestamp::now()) .unwrap(); db.signal("like", EntityId::new(3), 2.0, Timestamp::now()) .unwrap(); // Export BEFORE close — WAL segments are live on disk. // Clean close compacts (deletes) segments, so export must happen first. let req = ExportRequest::time_range(since, u64::MAX); let signals = db.export_signals(&req).unwrap(); assert_eq!(signals.len(), 3, "all 3 WAL events must be exported"); let views: Vec<_> = signals.iter().filter(|s| s.signal_type == "view").collect(); assert_eq!(views.len(), 2, "must export 2 view events"); let likes: Vec<_> = signals.iter().filter(|s| s.signal_type == "like").collect(); assert_eq!(likes.len(), 1, "must export 1 like event"); // Entity IDs and weights are preserved end-to-end. assert!( signals .iter() .any(|s| s.entity_id == 1 && (s.weight - 1.0).abs() < 0.001), "entity 1 view event must be present" ); assert!( signals .iter() .any(|s| s.entity_id == 3 && s.signal_type == "like"), "entity 3 like event must be present" ); // Anonymous batch WAL signals must have None for user/session/annotation. for sig in &signals { assert!(sig.user_id.is_none(), "batch WAL signals have no user_id"); assert!( sig.session_id.is_none(), "batch WAL signals have no session_id" ); assert!( sig.annotation.is_none(), "batch WAL signals have no annotation" ); } db.close().unwrap(); } /// `export_signals` respects the `limit` field. /// /// Export is called on the same open DB instance (before close) because /// clean shutdown compacts WAL segments, leaving nothing to scan. #[test] #[cfg(feature = "test-utils")] fn export_signals_respects_limit() { use tidaldb::TempTidalHome; let home = TempTidalHome::new().unwrap(); let schema = build_test_schema(); let db = TidalDb::builder() .with_data_dir(home.path()) .with_schema(schema) .open() .unwrap(); for i in 1u64..=10 { db.signal("view", EntityId::new(i), 1.0, Timestamp::now()) .unwrap(); } let mut req = ExportRequest::time_range(0, u64::MAX); req.limit = Some(3); let signals = db.export_signals(&req).unwrap(); assert_eq!(signals.len(), 3, "limit must cap the result count"); db.close().unwrap(); } /// Anonymous batch WAL signals are excluded when a `user_id` filter is set. /// In ephemeral mode there is no WAL or session journal on disk, so the /// result is empty regardless. This test verifies that anonymous signals /// written via `db.signal()` (no session) do NOT appear in user-filtered /// exports. #[test] fn export_signals_user_id_filter_excludes_anonymous() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); db.signal("view", EntityId::new(1), 1.0, Timestamp::now()) .unwrap(); let mut req = ExportRequest::time_range(0, u64::MAX); req.user_id = Some(42); let signals = db.export_signals(&req).unwrap(); assert!( signals.is_empty(), "user_id filter must exclude anonymous batch WAL signals" ); } /// Session signals carry user_id, session_id, and annotation through to export. /// /// This test exercises the full session-journal export path: /// 1. Persistent DB with schema and session policy /// 2. Session signals written with annotations via session_signal() /// 3. Export with user_id filter returns only that user's session signals /// 4. Export with a different user_id returns empty #[test] #[cfg(feature = "test-utils")] fn export_signals_with_session_context() { use tidaldb::TempTidalHome; let home = TempTidalHome::new().unwrap(); let schema = build_test_schema(); let db = TidalDb::builder() .with_data_dir(home.path()) .with_schema(schema) .open() .unwrap(); let user_id = 42u64; let meta = HashMap::new(); // Start a session for user 42. let handle = db .start_session(user_id, "test-agent", "default", meta) .unwrap(); let since = Timestamp::now().as_nanos(); // Write session signals — one with annotation, one without. db.session_signal( &handle, "view", EntityId::new(100), 1.0, Timestamp::now(), Some("good content".to_string()), ) .unwrap(); db.session_signal( &handle, "like", EntityId::new(101), 2.0, Timestamp::now(), None, ) .unwrap(); // Close the session so it is fully flushed to the journal. db.close_session(handle).unwrap(); // Session journal writes are fire-and-forget through the WAL writer thread. // Allow time for the writer to drain and fsync the session commands. std::thread::sleep(Duration::from_millis(100)); // Export with user_id filter = Some(42). let mut req = ExportRequest::time_range(since, u64::MAX); req.user_id = Some(user_id); let signals = db.export_signals(&req).unwrap(); assert!( !signals.is_empty(), "export with user_id filter must return session signals" ); assert_eq!(signals.len(), 2, "expected 2 session signals for user 42"); // All results must have user_id == Some(42). for sig in &signals { assert_eq!( sig.user_id, Some(user_id), "all exported signals must carry the user_id" ); assert!( sig.session_id.is_some(), "all exported signals must carry a session_id" ); } // At least one result has the annotation set. assert!( signals.iter().any(|s| s.annotation.is_some()), "at least one signal must have an annotation" ); let annotated = signals.iter().find(|s| s.annotation.is_some()).unwrap(); assert_eq!( annotated.annotation.as_deref(), Some("good content"), "annotation text must round-trip through the journal" ); // Export with a different user_id returns empty. let mut req2 = ExportRequest::time_range(since, u64::MAX); req2.user_id = Some(999); let signals2 = db.export_signals(&req2).unwrap(); assert!( signals2.is_empty(), "export for non-existent user must return empty" ); db.close().unwrap(); } // ── Task 09: Cross-Session Aggregation ────────────────────────────────────── #[test] fn user_session_summary_aggregates_correctly() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let user_id = 42u64; let meta = HashMap::new(); // Run 3 sessions, each with 2 views + 1 like = 3 signals per session. for i in 0u64..3 { let handle = db .start_session(user_id, "test", "default", meta.clone()) .unwrap(); db.session_signal( &handle, "view", EntityId::new(i * 10 + 1), 1.0, Timestamp::now(), None, ) .unwrap(); db.session_signal( &handle, "view", EntityId::new(i * 10 + 2), 1.0, Timestamp::now(), None, ) .unwrap(); db.session_signal( &handle, "like", EntityId::new(i * 10 + 3), 1.0, Timestamp::now(), None, ) .unwrap(); db.close_session(handle).unwrap(); } let summary = db.user_session_summary(user_id, 0).unwrap(); assert_eq!(summary.sessions_count, 3); assert_eq!(summary.total_signals, 9); assert_eq!(summary.total_rejections, 0); assert_eq!(summary.user_id, user_id); assert_eq!(summary.since_ns, 0); assert!(summary.earliest_session_ns.is_some()); assert!(summary.latest_session_ns.is_some()); assert!(summary.preference_drift.is_none()); // Top signal types: "view" first (6 total), "like" second (3 total). assert_eq!(summary.top_signal_types.len(), 2); assert_eq!(summary.top_signal_types[0].0, "view"); assert_eq!(summary.top_signal_types[0].1, 6); assert_eq!(summary.top_signal_types[1].0, "like"); assert_eq!(summary.top_signal_types[1].1, 3); } #[test] fn user_session_summary_no_sessions_returns_not_found() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let result = db.user_session_summary(999, 0); assert!(result.is_err()); assert!(matches!(result, Err(tidaldb::TidalError::NotFound { .. }))); } #[test] fn user_session_summary_different_user_excluded() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let meta = HashMap::new(); // User 1: one view signal. let handle = db .start_session(1u64, "test", "default", meta.clone()) .unwrap(); db.session_signal( &handle, "view", EntityId::new(1), 1.0, Timestamp::now(), None, ) .unwrap(); db.close_session(handle).unwrap(); // User 2: one like signal. let handle = db.start_session(2u64, "test", "default", meta).unwrap(); db.session_signal( &handle, "like", EntityId::new(2), 1.0, Timestamp::now(), None, ) .unwrap(); db.close_session(handle).unwrap(); // Query user 1 only — user 2's session must be excluded. let summary = db.user_session_summary(1, 0).unwrap(); assert_eq!(summary.sessions_count, 1); assert_eq!(summary.total_signals, 1); assert_eq!(summary.top_signal_types.len(), 1); assert_eq!(summary.top_signal_types[0].0, "view"); assert_eq!(summary.top_signal_types[0].1, 1); } #[test] fn user_session_summary_since_ns_filter() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let user_id = 100u64; let meta = HashMap::new(); // Session 1. let handle = db .start_session(user_id, "test", "default", meta.clone()) .unwrap(); db.session_signal( &handle, "view", EntityId::new(1), 1.0, Timestamp::now(), None, ) .unwrap(); db.close_session(handle).unwrap(); // Record time between sessions. let midpoint = Timestamp::now().as_nanos(); // Session 2 (after midpoint). let handle = db.start_session(user_id, "test", "default", meta).unwrap(); db.session_signal( &handle, "like", EntityId::new(2), 1.0, Timestamp::now(), None, ) .unwrap(); db.close_session(handle).unwrap(); // since_ns = midpoint must exclude session 1 and include only session 2. let summary = db.user_session_summary(user_id, midpoint).unwrap(); assert_eq!(summary.sessions_count, 1); assert_eq!(summary.total_signals, 1); assert_eq!(summary.top_signal_types[0].0, "like"); } #[test] fn session_snapshot_has_timing_fields() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let user_id = 50u64; let meta = HashMap::new(); let before = Timestamp::now().as_nanos(); let handle = db.start_session(user_id, "test", "default", meta).unwrap(); db.session_signal( &handle, "view", EntityId::new(1), 1.0, Timestamp::now(), None, ) .unwrap(); // Active snapshot: started_at_ns populated, closed_at_ns = 0. let active_snap = db.session_snapshot(handle.id).unwrap(); assert!( active_snap.started_at_ns >= before, "started_at_ns should be after test start" ); assert_eq!( active_snap.closed_at_ns, 0, "active session should have closed_at_ns = 0" ); db.close_session(handle).unwrap(); let after = Timestamp::now().as_nanos(); // Summary timing should bracket the session. let summary = db.user_session_summary(user_id, 0).unwrap(); assert!(summary.earliest_session_ns.unwrap() >= before); assert!(summary.latest_session_ns.unwrap() <= after); } #[test] fn signal_snap_entry_has_count() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let user_id = 60u64; let meta = HashMap::new(); let handle = db.start_session(user_id, "test", "default", meta).unwrap(); // Write 3 view signals and 1 like signal. for entity in [1u64, 2, 3] { db.session_signal( &handle, "view", EntityId::new(entity), 1.0, Timestamp::now(), None, ) .unwrap(); } db.session_signal( &handle, "like", EntityId::new(4), 1.0, Timestamp::now(), None, ) .unwrap(); let snap = db.session_snapshot(handle.id).unwrap(); let view_entry = snap.signals.get("view").unwrap(); assert_eq!(view_entry.count, 3); let like_entry = snap.signals.get("like").unwrap(); assert_eq!(like_entry.count, 1); db.close_session(handle).unwrap(); }