404 lines
13 KiB
Rust
404 lines
13 KiB
Rust
//! Milestone 6 Phase 6 Integration Tests: Creator Profile Modes + Notification Caps.
|
|
//!
|
|
//! Tests:
|
|
//! 1. `for_creator` + `for_you` ranks creator's items by user preferences.
|
|
//! 2. `for_creator` + `hot` ranks hot-sorted items within creator's catalog.
|
|
//! 3. Notification caps limit per-creator results.
|
|
//! 4. Notification caps enforce total daily limit.
|
|
//! 5. Cold-start user sees creator top content.
|
|
//! 6. Regression: `for_creator` with high-ID items uses `CreatorItemsBitmap`.
|
|
|
|
#![allow(
|
|
clippy::unwrap_used,
|
|
clippy::cast_precision_loss,
|
|
clippy::similar_names
|
|
)]
|
|
|
|
use std::collections::HashMap;
|
|
use std::time::Duration;
|
|
|
|
use tidaldb::TidalDb;
|
|
use tidaldb::query::retrieve::{NotificationCaps, Retrieve};
|
|
use tidaldb::schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window};
|
|
|
|
// ── Schema ──────────────────────────────────────────────────────────────────
|
|
|
|
fn test_schema() -> tidaldb::schema::Schema {
|
|
let mut builder = SchemaBuilder::new();
|
|
for &(name, half_life_days) in &[
|
|
("view", 7),
|
|
("like", 14),
|
|
("share", 7),
|
|
("skip", 1),
|
|
("completion", 30),
|
|
] {
|
|
let _ = builder
|
|
.signal(
|
|
name,
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(half_life_days * 24 * 3600),
|
|
},
|
|
)
|
|
.windows(&[
|
|
Window::OneHour,
|
|
Window::TwentyFourHours,
|
|
Window::SevenDays,
|
|
Window::AllTime,
|
|
])
|
|
.velocity(true)
|
|
.add();
|
|
}
|
|
builder.build().expect("schema must be valid")
|
|
}
|
|
|
|
fn open_ephemeral() -> TidalDb {
|
|
TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(test_schema())
|
|
.open()
|
|
.expect("ephemeral open")
|
|
}
|
|
|
|
fn write_items_for_creator(db: &TidalDb, creator_id: u64, item_ids: &[u64]) {
|
|
for &id in item_ids {
|
|
let mut meta = HashMap::new();
|
|
meta.insert("title".to_string(), format!("Item {id}"));
|
|
meta.insert("category".to_string(), "jazz".to_string());
|
|
meta.insert("format".to_string(), "audio".to_string());
|
|
meta.insert("creator_id".to_string(), creator_id.to_string());
|
|
db.write_item_with_metadata(EntityId::new(id), &meta)
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
// ── Test 1: for_creator + for_you ───────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn for_creator_for_you_ranks_within_creator_catalog() {
|
|
let db = open_ephemeral();
|
|
|
|
// Creator A has items 1-5.
|
|
write_items_for_creator(&db, 100, &[1, 2, 3, 4, 5]);
|
|
// Creator B has items 6-10.
|
|
write_items_for_creator(&db, 200, &[6, 7, 8, 9, 10]);
|
|
|
|
// Signal items 1 and 2 heavily for view (creator A).
|
|
let now = Timestamp::now();
|
|
for _ in 0..20 {
|
|
db.signal("view", EntityId::new(1), 1.0, now).unwrap();
|
|
db.signal("view", EntityId::new(2), 1.0, now).unwrap();
|
|
}
|
|
|
|
// Query for_creator(100) + for_you profile + for_user(42).
|
|
let query = Retrieve::builder()
|
|
.profile("for_you")
|
|
.for_user(42)
|
|
.for_creator(EntityId::new(100))
|
|
.limit(10)
|
|
.build()
|
|
.unwrap();
|
|
let results = db.retrieve(&query).unwrap();
|
|
|
|
// Only creator A's items should appear.
|
|
for item in &results.items {
|
|
let id = item.entity_id.as_u64();
|
|
assert!(
|
|
(1..=5).contains(&id),
|
|
"expected item from creator A, got id={id}"
|
|
);
|
|
}
|
|
assert!(!results.items.is_empty(), "should return some items");
|
|
}
|
|
|
|
// ── Test 2: for_creator + hot ───────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn for_creator_hot_ranks_by_hotness_within_catalog() {
|
|
let db = open_ephemeral();
|
|
|
|
// Creator A: items 1-5.
|
|
write_items_for_creator(&db, 100, &[1, 2, 3, 4, 5]);
|
|
// Creator B: items 6-10.
|
|
write_items_for_creator(&db, 200, &[6, 7, 8, 9, 10]);
|
|
|
|
// Item 1 gets many recent views, item 2 gets few.
|
|
let now = Timestamp::now();
|
|
for _ in 0..50 {
|
|
db.signal("view", EntityId::new(1), 1.0, now).unwrap();
|
|
}
|
|
db.signal("view", EntityId::new(2), 1.0, now).unwrap();
|
|
|
|
// Query for_creator(100) + hot profile.
|
|
let query = Retrieve::builder()
|
|
.profile("hot")
|
|
.for_creator(EntityId::new(100))
|
|
.limit(10)
|
|
.build()
|
|
.unwrap();
|
|
let results = db.retrieve(&query).unwrap();
|
|
|
|
// Only creator A's items should appear.
|
|
for item in &results.items {
|
|
let id = item.entity_id.as_u64();
|
|
assert!(
|
|
(1..=5).contains(&id),
|
|
"expected item from creator A, got id={id}"
|
|
);
|
|
}
|
|
assert!(!results.items.is_empty(), "should return some items");
|
|
}
|
|
|
|
// ── Test 3: notification caps per-creator ───────────────────────────────────
|
|
|
|
#[test]
|
|
fn notification_caps_enforce_per_creator_limit() {
|
|
let db = open_ephemeral();
|
|
|
|
// Write 5 items from creator A and 5 from creator B.
|
|
write_items_for_creator(&db, 100, &[1, 2, 3, 4, 5]);
|
|
write_items_for_creator(&db, 200, &[6, 7, 8, 9, 10]);
|
|
|
|
// User 1 follows both creators (required for notification profile).
|
|
db.write_relationship(
|
|
EntityId::new(1),
|
|
tidaldb::entities::RelationshipType::Follows,
|
|
EntityId::new(100),
|
|
1.0,
|
|
Timestamp::now(),
|
|
)
|
|
.unwrap();
|
|
db.write_relationship(
|
|
EntityId::new(1),
|
|
tidaldb::entities::RelationshipType::Follows,
|
|
EntityId::new(200),
|
|
1.0,
|
|
Timestamp::now(),
|
|
)
|
|
.unwrap();
|
|
|
|
// Signal all items with views so they rank.
|
|
let now = Timestamp::now();
|
|
for id in 1..=10u64 {
|
|
for _ in 0..5 {
|
|
db.signal("view", EntityId::new(id), 1.0, now).unwrap();
|
|
}
|
|
}
|
|
|
|
// Retrieve with notification caps: max 1 per creator, max 3 total.
|
|
let query = Retrieve::builder()
|
|
.profile("notification")
|
|
.for_user(1)
|
|
.notification_caps(NotificationCaps {
|
|
max_per_creator_per_day: 1,
|
|
max_total_per_day: 3,
|
|
})
|
|
.limit(20)
|
|
.build()
|
|
.unwrap();
|
|
let results = db.retrieve(&query).unwrap();
|
|
|
|
// Verify total <= 3.
|
|
assert!(
|
|
results.items.len() <= 3,
|
|
"expected at most 3 results, got {}",
|
|
results.items.len()
|
|
);
|
|
|
|
// Verify no creator appears more than once (need to check creator_id
|
|
// from item metadata -- but we can verify by item ID ranges).
|
|
let creator_a_count = results
|
|
.items
|
|
.iter()
|
|
.filter(|r| r.entity_id.as_u64() <= 5)
|
|
.count();
|
|
let creator_b_count = results
|
|
.items
|
|
.iter()
|
|
.filter(|r| r.entity_id.as_u64() > 5)
|
|
.count();
|
|
assert!(
|
|
creator_a_count <= 1,
|
|
"expected at most 1 item from creator A, got {creator_a_count}"
|
|
);
|
|
assert!(
|
|
creator_b_count <= 1,
|
|
"expected at most 1 item from creator B, got {creator_b_count}"
|
|
);
|
|
}
|
|
|
|
// ── Test 4 (UC-08): cold-start user sees top content on creator page ─────────
|
|
|
|
/// A user with no preference history (cold-start) queries a creator's catalog
|
|
/// via `for_creator`. The system should still return results -- falling back to
|
|
/// engagement signals (e.g., view counts) rather than returning empty.
|
|
///
|
|
/// This validates the UC-08 cold-start behavior: new visitors to a creator's
|
|
/// profile page see the creator's most engaging content even without
|
|
/// personalization context.
|
|
#[test]
|
|
fn cold_start_user_sees_creator_top_content() {
|
|
let db = open_ephemeral();
|
|
|
|
// Creator has 5 items.
|
|
write_items_for_creator(&db, 100, &[1, 2, 3, 4, 5]);
|
|
|
|
// Signal items 1 and 2 heavily so they rank at the top.
|
|
let now = Timestamp::now();
|
|
for _ in 0..30 {
|
|
db.signal("view", EntityId::new(1), 1.0, now).unwrap();
|
|
db.signal("view", EntityId::new(2), 1.0, now).unwrap();
|
|
}
|
|
|
|
// User 999 has never interacted with the system -- pure cold-start.
|
|
// Deliberately no db.write_item(EntityId::new(999)) or any signals.
|
|
let query = Retrieve::builder()
|
|
.profile("for_you")
|
|
.for_user(999)
|
|
.for_creator(EntityId::new(100))
|
|
.limit(10)
|
|
.build()
|
|
.unwrap();
|
|
let results = db.retrieve(&query).unwrap();
|
|
|
|
// Cold-start must not return empty -- the creator has items and signals.
|
|
assert!(
|
|
!results.items.is_empty(),
|
|
"cold-start user should still see creator content"
|
|
);
|
|
|
|
// All results must belong to creator 100 (items 1-5).
|
|
for item in &results.items {
|
|
let id = item.entity_id.as_u64();
|
|
assert!(
|
|
(1..=5).contains(&id),
|
|
"expected item from creator 100, got id={id}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Test 5: notification caps total daily limit ─────────────────────────────
|
|
|
|
#[test]
|
|
fn notification_caps_enforce_total_limit() {
|
|
let db = open_ephemeral();
|
|
|
|
// 10 items from 10 different creators.
|
|
for id in 1..=10u64 {
|
|
write_items_for_creator(&db, id * 100, &[id]);
|
|
// User follows each creator.
|
|
db.write_relationship(
|
|
EntityId::new(1),
|
|
tidaldb::entities::RelationshipType::Follows,
|
|
EntityId::new(id * 100),
|
|
1.0,
|
|
Timestamp::now(),
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
// Signal all items.
|
|
let now = Timestamp::now();
|
|
for id in 1..=10u64 {
|
|
for _ in 0..5 {
|
|
db.signal("view", EntityId::new(id), 1.0, now).unwrap();
|
|
}
|
|
}
|
|
|
|
let query = Retrieve::builder()
|
|
.profile("notification")
|
|
.for_user(1)
|
|
.notification_caps(NotificationCaps {
|
|
max_per_creator_per_day: 2,
|
|
max_total_per_day: 3,
|
|
})
|
|
.limit(20)
|
|
.build()
|
|
.unwrap();
|
|
let results = db.retrieve(&query).unwrap();
|
|
|
|
assert!(
|
|
results.items.len() <= 3,
|
|
"expected at most 3 total results, got {}",
|
|
results.items.len()
|
|
);
|
|
}
|
|
|
|
// ── Test 6: Regression -- for_creator uses CreatorItemsBitmap for high IDs ──
|
|
|
|
/// Regression test for the bug where `for_creator` was set by the builder
|
|
/// but never read by the executor. Stage 1 always called `scan_candidates`
|
|
/// which caps at `max(limit * multiplier, 200)` items from the universe
|
|
/// bitmap by ascending ID. For a large catalog where a creator's items have
|
|
/// IDs above that cap, those items were never generated as candidates.
|
|
///
|
|
/// This test writes 300 items from creator 999 at IDs 201-500 (above the
|
|
/// scan cap of 200), plus 200 filler items from other creators at IDs 1-200.
|
|
/// Before the fix, `for_creator(999)` would return empty because the scan
|
|
/// cap never reached IDs > 200. After the fix, the `CreatorItemsBitmap`
|
|
/// is used for O(k) candidate generation, returning all of creator 999's items.
|
|
#[test]
|
|
fn for_creator_high_id_items_uses_creator_bitmap() {
|
|
let db = open_ephemeral();
|
|
|
|
// Write 200 filler items from other creators (IDs 1-200).
|
|
for id in 1..=200u64 {
|
|
let mut meta = HashMap::new();
|
|
meta.insert("title".to_string(), format!("Filler {id}"));
|
|
meta.insert("category".to_string(), "pop".to_string());
|
|
meta.insert("format".to_string(), "audio".to_string());
|
|
meta.insert("creator_id".to_string(), "1".to_string());
|
|
db.write_item_with_metadata(EntityId::new(id), &meta)
|
|
.unwrap();
|
|
}
|
|
|
|
// Write 300 items from creator 999 at IDs 201-500.
|
|
let creator_item_ids: Vec<u64> = (201..=500).collect();
|
|
write_items_for_creator(&db, 999, &creator_item_ids);
|
|
|
|
// Query for_creator(999) with limit=50.
|
|
let query = Retrieve::builder()
|
|
.profile("new")
|
|
.for_creator(EntityId::new(999))
|
|
.limit(50)
|
|
.build()
|
|
.unwrap();
|
|
let results = db.retrieve(&query).unwrap();
|
|
|
|
// Must return results -- the bitmap path finds all 300 items regardless
|
|
// of scan cap.
|
|
assert!(
|
|
!results.items.is_empty(),
|
|
"for_creator must return results even when all items have IDs above the scan cap"
|
|
);
|
|
|
|
// All returned items must belong to creator 999 (IDs 201-500).
|
|
for item in &results.items {
|
|
let id = item.entity_id.as_u64();
|
|
assert!(
|
|
(201..=500).contains(&id),
|
|
"expected item from creator 999 (IDs 201-500), got id={id}"
|
|
);
|
|
}
|
|
|
|
// Verify we get up to limit items (creator has 300 items, limit=50).
|
|
assert_eq!(
|
|
results.items.len(),
|
|
50,
|
|
"should return exactly limit=50 items from the 300 available"
|
|
);
|
|
|
|
// Also verify unknown creator returns empty, not an error.
|
|
let query_unknown = Retrieve::builder()
|
|
.profile("new")
|
|
.for_creator(EntityId::new(99999))
|
|
.limit(10)
|
|
.build()
|
|
.unwrap();
|
|
let results_unknown = db.retrieve(&query_unknown).unwrap();
|
|
assert!(
|
|
results_unknown.items.is_empty(),
|
|
"unknown creator should return empty results"
|
|
);
|
|
}
|