#![allow(clippy::unwrap_used)] //! Integration tests for `p1-feedback-loop-ux` feature. //! //! Validates the complete feedback lifecycle: submit_feedback, feedback_state, //! immediate reflection in queries, skip escalation, and preference vector //! negative updates. use std::collections::HashMap; use std::time::Duration; use tidaldb::schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window}; use tidaldb::{FeedbackAction, FeedbackState, TidalDb, TidalDbBuilder}; // ── Helpers ──────────────────────────────────────────────────────────────── fn feedback_schema() -> tidaldb::schema::Schema { let mut builder = SchemaBuilder::new(); let _ = builder .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::OneHour, Window::TwentyFourHours, Window::AllTime]) .velocity(true) .add(); let _ = builder .signal( "like", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::AllTime]) .add(); let _ = builder .signal("hide", EntityKind::Item, DecaySpec::Permanent) .add(); let _ = builder .signal( "dislike", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(30 * 24 * 3600), }, ) .windows(&[Window::AllTime]) .add(); let _ = builder .signal("block", EntityKind::Item, DecaySpec::Permanent) .add(); let _ = builder .signal( "skip", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(24 * 3600), }, ) .windows(&[Window::AllTime]) .add(); builder.embedding_slot("content", EntityKind::Item, 8); builder.build().unwrap() } fn open_db() -> TidalDb { TidalDbBuilder::new() .ephemeral() .with_schema(feedback_schema()) .open() .unwrap() } fn write_items(db: &TidalDb, ids: &[u64]) { for &id in ids { let mut meta = HashMap::new(); meta.insert("title".to_string(), format!("Item {id}")); db.write_item(EntityId::new(id), &meta).unwrap(); } } // ── Tests ────────────────────────────────────────────────────────────────── #[test] fn like_and_unlike_roundtrip() { let db = open_db(); write_items(&db, &[1]); let ts = Timestamp::now(); db.submit_feedback(100, EntityId::new(1), FeedbackAction::Like, ts) .unwrap(); let state = db.feedback_state(100, EntityId::new(1), None); assert!(state.is_liked, "liked should be true after Like"); assert!(!state.is_hidden); assert!(!state.is_disliked); db.submit_feedback(100, EntityId::new(1), FeedbackAction::Unlike, ts) .unwrap(); let state = db.feedback_state(100, EntityId::new(1), None); assert!(!state.is_liked, "liked should be false after Unlike"); } #[test] fn hide_and_unhide_roundtrip() { let db = open_db(); write_items(&db, &[1]); let ts = Timestamp::now(); db.submit_feedback(100, EntityId::new(1), FeedbackAction::Hide, ts) .unwrap(); let state = db.feedback_state(100, EntityId::new(1), None); assert!(state.is_hidden, "hidden should be true after Hide"); assert!( !state.is_disliked, "disliked should be false for Hide (not Dislike)" ); db.submit_feedback(100, EntityId::new(1), FeedbackAction::Unhide, ts) .unwrap(); let state = db.feedback_state(100, EntityId::new(1), None); assert!(!state.is_hidden, "hidden should be false after Unhide"); } #[test] fn dislike_and_undo_dislike_roundtrip() { let db = open_db(); write_items(&db, &[1]); let ts = Timestamp::now(); db.submit_feedback(100, EntityId::new(1), FeedbackAction::Dislike, ts) .unwrap(); let state = db.feedback_state(100, EntityId::new(1), None); assert!(state.is_disliked, "disliked should be true after Dislike"); assert!( !state.is_hidden, "hidden should be false for Dislike (not Hide)" ); db.submit_feedback(100, EntityId::new(1), FeedbackAction::UndoDislike, ts) .unwrap(); let state = db.feedback_state(100, EntityId::new(1), None); assert!( !state.is_disliked, "disliked should be false after UndoDislike" ); } #[test] fn mute_and_unmute_creator() { let db = open_db(); write_items(&db, &[1]); let ts = Timestamp::now(); db.submit_feedback( 100, EntityId::new(1), FeedbackAction::MuteCreator { creator_id: 50 }, ts, ) .unwrap(); let state = db.feedback_state(100, EntityId::new(1), Some(50)); assert!( state.is_creator_muted, "creator should be muted after MuteCreator" ); db.submit_feedback( 100, EntityId::new(1), FeedbackAction::UnmuteCreator { creator_id: 50 }, ts, ) .unwrap(); let state = db.feedback_state(100, EntityId::new(1), Some(50)); assert!( !state.is_creator_muted, "creator should not be muted after UnmuteCreator" ); } #[test] fn save_and_unsave_roundtrip() { let db = open_db(); write_items(&db, &[1]); let ts = Timestamp::now(); db.submit_feedback(100, EntityId::new(1), FeedbackAction::Save, ts) .unwrap(); let state = db.feedback_state(100, EntityId::new(1), None); assert!(state.is_saved, "saved should be true after Save"); db.submit_feedback(100, EntityId::new(1), FeedbackAction::Unsave, ts) .unwrap(); let state = db.feedback_state(100, EntityId::new(1), None); assert!(!state.is_saved, "saved should be false after Unsave"); } #[test] fn skip_escalation_at_threshold() { let db = open_db(); write_items(&db, &[1]); let ts = Timestamp::now(); // First 2 skips: no hard negative. for _ in 0..2 { db.submit_feedback(100, EntityId::new(1), FeedbackAction::NotInterested, ts) .unwrap(); } let state = db.feedback_state(100, EntityId::new(1), None); assert!( !state.is_hidden && !state.is_disliked, "2 skips should not escalate" ); // 3rd skip: escalates to hard negative. db.submit_feedback(100, EntityId::new(1), FeedbackAction::NotInterested, ts) .unwrap(); // After escalation, the item is in hard_negatives but not in hidden_items, // so feedback_state shows is_disliked = true (hard_neg && !hidden). let state = db.feedback_state(100, EntityId::new(1), None); assert!( state.is_disliked, "3 skips should escalate to hard negative (shows as disliked)" ); } #[test] fn feedback_state_default_for_unknown_pair() { let db = open_db(); let state = db.feedback_state(999, EntityId::new(999), None); assert_eq!( state, FeedbackState::default(), "unknown user-entity pair should return all-false state" ); } #[test] fn multiple_users_independent_feedback() { let db = open_db(); write_items(&db, &[1]); let ts = Timestamp::now(); // User 100 likes item 1. db.submit_feedback(100, EntityId::new(1), FeedbackAction::Like, ts) .unwrap(); // User 200 hides item 1. db.submit_feedback(200, EntityId::new(1), FeedbackAction::Hide, ts) .unwrap(); let state_100 = db.feedback_state(100, EntityId::new(1), None); assert!(state_100.is_liked); assert!(!state_100.is_hidden); let state_200 = db.feedback_state(200, EntityId::new(1), None); assert!(!state_200.is_liked); assert!(state_200.is_hidden); } #[test] fn negative_preference_update_on_dislike() { let db = open_db(); let eid = EntityId::new(1); // Write an item with an embedding so dislike can trigger preference update. let mut meta = HashMap::new(); meta.insert("title".to_string(), "Test Item".to_string()); db.write_item(eid, &meta).unwrap(); db.write_item_embedding(eid, &[0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]) .unwrap(); let ts = Timestamp::now(); db.submit_feedback(100, eid, FeedbackAction::Dislike, ts) .unwrap(); // Dislike should have triggered try_negative_preference_update. // The preference vector should now be nudged AWAY from the item embedding. // We verify the state is consistent; the exact vector values depend on // internal DAMPING math, but the state should be set up. let state = db.feedback_state(100, eid, None); assert!(state.is_disliked); } #[test] fn combined_actions_feedback_state() { let db = open_db(); write_items(&db, &[1]); let ts = Timestamp::now(); // Like + Save on the same item. db.submit_feedback(100, EntityId::new(1), FeedbackAction::Like, ts) .unwrap(); db.submit_feedback(100, EntityId::new(1), FeedbackAction::Save, ts) .unwrap(); let state = db.feedback_state(100, EntityId::new(1), None); assert!(state.is_liked); assert!(state.is_saved); assert!(!state.is_hidden); assert!(!state.is_disliked); assert!(!state.is_creator_muted); }