307 lines
9.4 KiB
Rust
307 lines
9.4 KiB
Rust
#![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);
|
|
}
|