- Add BUILD.bazel across tidal, tidal-net, tidal-server, tidalctl for bzlmod build - Add tidal/ crate docs (README, CHANGELOG, CONTRIBUTING, AGENTS, CLAUDE, API, ARCHITECTURE) and ai-lookup reference - Add docker standalone/cluster/deploy images, compose, and prometheus config - Harden WAL (batch format, writer, dedup, diagnostics), text syncer/collectors, and vector registry - Expand tidalctl CLI and tests; restructure WAL/visibility integration test suites - Refine tidal-net transport/client/server and tidal-server cluster/scatter-gather
484 lines
14 KiB
Rust
484 lines
14 KiB
Rust
//! Milestone 6 Phase 3 Integration Tests: Full Sort Mode Coverage + Live Content.
|
|
//!
|
|
//! Exercises the M6P3 sort feature set end-to-end through `TidalDb`:
|
|
//!
|
|
//! 1. Alphabetical sort (asc/desc).
|
|
//! 2. Duration sort (shortest/longest).
|
|
//! 3. `MostCommented` and `MostShared` signal-based sorts.
|
|
//! 4. `LiveViewerCount` sort via `viewer_count` signal.
|
|
//! 5. `DateSaved` sort with `FOR USER` context.
|
|
//! 6. `with_viewer_count_signal()` convenience method in `SchemaBuilder`.
|
|
//!
|
|
//! Engagement filters live in `m6p3_filters.rs`; missing-metadata and context
|
|
//! error cases live in `m6p3_edge_cases.rs`.
|
|
|
|
#![allow(clippy::unwrap_used, clippy::cast_precision_loss)]
|
|
|
|
use std::{collections::HashMap, time::Duration};
|
|
|
|
use tidaldb::{
|
|
TidalDb,
|
|
query::retrieve::Retrieve,
|
|
schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window},
|
|
};
|
|
|
|
// ── Schema ──────────────────────────────────────────────────────────────────
|
|
|
|
fn m6p3_schema() -> tidaldb::schema::Schema {
|
|
let mut builder = SchemaBuilder::new();
|
|
|
|
for &(name, half_life_days) in &[
|
|
("view", 7),
|
|
("like", 14),
|
|
("share", 7),
|
|
("comment", 7),
|
|
("skip", 1),
|
|
("hide", 1),
|
|
] {
|
|
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();
|
|
}
|
|
|
|
// Register viewer_count via convenience method.
|
|
builder.with_viewer_count_signal();
|
|
|
|
builder.build().expect("m6p3 schema must be valid")
|
|
}
|
|
|
|
fn m6p3_schema_without_viewer_count() -> tidaldb::schema::Schema {
|
|
let mut builder = SchemaBuilder::new();
|
|
for &(name, half_life_days) in &[
|
|
("view", 7),
|
|
("like", 14),
|
|
("share", 7),
|
|
("comment", 7),
|
|
("skip", 1),
|
|
("hide", 1),
|
|
] {
|
|
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")
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
fn item_meta(title: &str, category: &str, creator_id: u32) -> HashMap<String, String> {
|
|
let mut m = HashMap::new();
|
|
m.insert("title".to_string(), title.to_string());
|
|
m.insert("category".to_string(), category.to_string());
|
|
m.insert("creator_id".to_string(), creator_id.to_string());
|
|
m
|
|
}
|
|
|
|
fn item_meta_with_duration(
|
|
title: &str,
|
|
category: &str,
|
|
creator_id: u32,
|
|
duration_secs: u32,
|
|
) -> HashMap<String, String> {
|
|
let mut m = item_meta(title, category, creator_id);
|
|
m.insert("duration".to_string(), duration_secs.to_string());
|
|
m
|
|
}
|
|
|
|
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn alphabetical_asc_sorts_a_before_z() {
|
|
let schema = m6p3_schema_without_viewer_count();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
// Write items with titles in non-alphabetical order.
|
|
db.write_item_with_metadata(EntityId::new(1), &item_meta("Zebra Stripes", "music", 100))
|
|
.unwrap();
|
|
db.write_item_with_metadata(EntityId::new(2), &item_meta("Apple Pie", "food", 100))
|
|
.unwrap();
|
|
db.write_item_with_metadata(EntityId::new(3), &item_meta("Mango Tango", "food", 100))
|
|
.unwrap();
|
|
|
|
// Give all items a view signal so they appear as candidates.
|
|
let ts = Timestamp::now();
|
|
for id in 1..=3 {
|
|
db.signal("view", EntityId::new(id), 1.0, ts).unwrap();
|
|
}
|
|
|
|
let query = Retrieve::builder()
|
|
.profile("alphabetical_asc")
|
|
.limit(10)
|
|
.build()
|
|
.unwrap();
|
|
let results = db.retrieve(&query).unwrap();
|
|
|
|
assert!(
|
|
results.items.len() >= 3,
|
|
"should return all 3 items, got {}",
|
|
results.items.len()
|
|
);
|
|
// A < M < Z
|
|
assert_eq!(results.items[0].entity_id, EntityId::new(2)); // Apple
|
|
assert_eq!(results.items[1].entity_id, EntityId::new(3)); // Mango
|
|
assert_eq!(results.items[2].entity_id, EntityId::new(1)); // Zebra
|
|
|
|
db.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn alphabetical_desc_sorts_z_before_a() {
|
|
let schema = m6p3_schema_without_viewer_count();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
db.write_item_with_metadata(EntityId::new(1), &item_meta("Zebra Stripes", "music", 100))
|
|
.unwrap();
|
|
db.write_item_with_metadata(EntityId::new(2), &item_meta("Apple Pie", "food", 100))
|
|
.unwrap();
|
|
db.write_item_with_metadata(EntityId::new(3), &item_meta("Mango Tango", "food", 100))
|
|
.unwrap();
|
|
|
|
let ts = Timestamp::now();
|
|
for id in 1..=3 {
|
|
db.signal("view", EntityId::new(id), 1.0, ts).unwrap();
|
|
}
|
|
|
|
let query = Retrieve::builder()
|
|
.profile("alphabetical_desc")
|
|
.limit(10)
|
|
.build()
|
|
.unwrap();
|
|
let results = db.retrieve(&query).unwrap();
|
|
|
|
assert!(results.items.len() >= 3);
|
|
// Z > M > A
|
|
assert_eq!(results.items[0].entity_id, EntityId::new(1)); // Zebra
|
|
assert_eq!(results.items[1].entity_id, EntityId::new(3)); // Mango
|
|
assert_eq!(results.items[2].entity_id, EntityId::new(2)); // Apple
|
|
|
|
db.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn shortest_sort_shortest_first() {
|
|
let schema = m6p3_schema_without_viewer_count();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
db.write_item_with_metadata(
|
|
EntityId::new(1),
|
|
&item_meta_with_duration("Long", "music", 100, 3600),
|
|
)
|
|
.unwrap();
|
|
db.write_item_with_metadata(
|
|
EntityId::new(2),
|
|
&item_meta_with_duration("Short", "music", 100, 60),
|
|
)
|
|
.unwrap();
|
|
db.write_item_with_metadata(
|
|
EntityId::new(3),
|
|
&item_meta_with_duration("Medium", "music", 100, 600),
|
|
)
|
|
.unwrap();
|
|
|
|
let ts = Timestamp::now();
|
|
for id in 1..=3 {
|
|
db.signal("view", EntityId::new(id), 1.0, ts).unwrap();
|
|
}
|
|
|
|
let query = Retrieve::builder()
|
|
.profile("shortest")
|
|
.limit(10)
|
|
.build()
|
|
.unwrap();
|
|
let results = db.retrieve(&query).unwrap();
|
|
|
|
assert!(results.items.len() >= 3);
|
|
// Shortest first: 60, 600, 3600
|
|
assert_eq!(results.items[0].entity_id, EntityId::new(2)); // 60s
|
|
assert_eq!(results.items[1].entity_id, EntityId::new(3)); // 600s
|
|
assert_eq!(results.items[2].entity_id, EntityId::new(1)); // 3600s
|
|
|
|
db.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn longest_sort_longest_first() {
|
|
let schema = m6p3_schema_without_viewer_count();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
db.write_item_with_metadata(
|
|
EntityId::new(1),
|
|
&item_meta_with_duration("Long", "music", 100, 3600),
|
|
)
|
|
.unwrap();
|
|
db.write_item_with_metadata(
|
|
EntityId::new(2),
|
|
&item_meta_with_duration("Short", "music", 100, 60),
|
|
)
|
|
.unwrap();
|
|
db.write_item_with_metadata(
|
|
EntityId::new(3),
|
|
&item_meta_with_duration("Medium", "music", 100, 600),
|
|
)
|
|
.unwrap();
|
|
|
|
let ts = Timestamp::now();
|
|
for id in 1..=3 {
|
|
db.signal("view", EntityId::new(id), 1.0, ts).unwrap();
|
|
}
|
|
|
|
let query = Retrieve::builder()
|
|
.profile("longest")
|
|
.limit(10)
|
|
.build()
|
|
.unwrap();
|
|
let results = db.retrieve(&query).unwrap();
|
|
|
|
assert!(results.items.len() >= 3);
|
|
// Longest first: 3600, 600, 60
|
|
assert_eq!(results.items[0].entity_id, EntityId::new(1)); // 3600s
|
|
assert_eq!(results.items[1].entity_id, EntityId::new(3)); // 600s
|
|
assert_eq!(results.items[2].entity_id, EntityId::new(2)); // 60s
|
|
|
|
db.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn most_commented_sort() {
|
|
let schema = m6p3_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
for id in 1u64..=3 {
|
|
db.write_item_with_metadata(EntityId::new(id), &item_meta("Item", "music", 100))
|
|
.unwrap();
|
|
}
|
|
|
|
let ts = Timestamp::now();
|
|
// Entity 1: 2 comments, Entity 2: 5 comments, Entity 3: 1 comment.
|
|
// Also record a view so they appear as candidates.
|
|
for id in 1u64..=3 {
|
|
db.signal("view", EntityId::new(id), 1.0, ts).unwrap();
|
|
}
|
|
for _ in 0..2 {
|
|
db.signal("comment", EntityId::new(1), 1.0, ts).unwrap();
|
|
}
|
|
for _ in 0..5 {
|
|
db.signal("comment", EntityId::new(2), 1.0, ts).unwrap();
|
|
}
|
|
db.signal("comment", EntityId::new(3), 1.0, ts).unwrap();
|
|
|
|
let query = Retrieve::builder()
|
|
.profile("most_commented")
|
|
.limit(10)
|
|
.build()
|
|
.unwrap();
|
|
let results = db.retrieve(&query).unwrap();
|
|
|
|
assert!(results.items.len() >= 3);
|
|
// Entity 2 (5 comments) should rank first.
|
|
assert_eq!(results.items[0].entity_id, EntityId::new(2));
|
|
|
|
db.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn most_shared_sort() {
|
|
let schema = m6p3_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
for id in 1u64..=3 {
|
|
db.write_item_with_metadata(EntityId::new(id), &item_meta("Item", "music", 100))
|
|
.unwrap();
|
|
}
|
|
|
|
let ts = Timestamp::now();
|
|
for id in 1u64..=3 {
|
|
db.signal("view", EntityId::new(id), 1.0, ts).unwrap();
|
|
}
|
|
// Entity 1: 1 share, Entity 2: 3 shares, Entity 3: 7 shares.
|
|
db.signal("share", EntityId::new(1), 1.0, ts).unwrap();
|
|
for _ in 0..3 {
|
|
db.signal("share", EntityId::new(2), 1.0, ts).unwrap();
|
|
}
|
|
for _ in 0..7 {
|
|
db.signal("share", EntityId::new(3), 1.0, ts).unwrap();
|
|
}
|
|
|
|
let query = Retrieve::builder()
|
|
.profile("most_shared")
|
|
.limit(10)
|
|
.build()
|
|
.unwrap();
|
|
let results = db.retrieve(&query).unwrap();
|
|
|
|
assert!(results.items.len() >= 3);
|
|
// Entity 3 (7 shares) should rank first.
|
|
assert_eq!(results.items[0].entity_id, EntityId::new(3));
|
|
|
|
db.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn live_viewer_count_sort() {
|
|
let schema = m6p3_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
for id in 1u64..=3 {
|
|
db.write_item_with_metadata(EntityId::new(id), &item_meta("Item", "music", 100))
|
|
.unwrap();
|
|
}
|
|
|
|
let ts = Timestamp::now();
|
|
// Give each item a view so they appear as candidates.
|
|
for id in 1u64..=3 {
|
|
db.signal("view", EntityId::new(id), 1.0, ts).unwrap();
|
|
}
|
|
// Entity 1: 5 viewers, Entity 2: 20 viewers, Entity 3: 1 viewer.
|
|
for _ in 0..5 {
|
|
db.signal("viewer_count", EntityId::new(1), 1.0, ts)
|
|
.unwrap();
|
|
}
|
|
for _ in 0..20 {
|
|
db.signal("viewer_count", EntityId::new(2), 1.0, ts)
|
|
.unwrap();
|
|
}
|
|
db.signal("viewer_count", EntityId::new(3), 1.0, ts)
|
|
.unwrap();
|
|
|
|
let query = Retrieve::builder()
|
|
.profile("live")
|
|
.limit(10)
|
|
.build()
|
|
.unwrap();
|
|
let results = db.retrieve(&query).unwrap();
|
|
|
|
assert!(results.items.len() >= 3);
|
|
// Entity 2 (20 viewers) should rank first.
|
|
assert_eq!(results.items[0].entity_id, EntityId::new(2));
|
|
|
|
db.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn date_saved_sort_latest_first() {
|
|
let schema = m6p3_schema_without_viewer_count();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
for id in 1u64..=3 {
|
|
db.write_item_with_metadata(EntityId::new(id), &item_meta("Item", "music", 100))
|
|
.unwrap();
|
|
}
|
|
|
|
let ts = Timestamp::now();
|
|
for id in 1u64..=3 {
|
|
db.signal("view", EntityId::new(id), 1.0, ts).unwrap();
|
|
}
|
|
|
|
// Record saves with different timestamps.
|
|
let user_state = db.user_state();
|
|
user_state.add_save_timestamped(42, 1, 1000);
|
|
user_state.add_save_timestamped(42, 2, 3000); // latest
|
|
user_state.add_save_timestamped(42, 3, 2000);
|
|
|
|
let query = Retrieve::builder()
|
|
.profile("date_saved")
|
|
.for_user(42)
|
|
.limit(10)
|
|
.build()
|
|
.unwrap();
|
|
let results = db.retrieve(&query).unwrap();
|
|
|
|
assert!(results.items.len() >= 3);
|
|
// Entity 2 (saved at 3000) should be first.
|
|
assert_eq!(results.items[0].entity_id, EntityId::new(2));
|
|
assert_eq!(results.items[1].entity_id, EntityId::new(3));
|
|
assert_eq!(results.items[2].entity_id, EntityId::new(1));
|
|
|
|
db.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn with_viewer_count_signal_convenience() {
|
|
// Verify the SchemaBuilder convenience method registers the signal correctly.
|
|
let mut builder = SchemaBuilder::new();
|
|
let _ = builder
|
|
.signal(
|
|
"view",
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(3600),
|
|
},
|
|
)
|
|
.windows(&[Window::OneHour])
|
|
.velocity(false)
|
|
.add();
|
|
builder.with_viewer_count_signal();
|
|
|
|
let schema = builder.build().unwrap();
|
|
let vc = schema.signal("viewer_count");
|
|
assert!(
|
|
vc.is_some(),
|
|
"with_viewer_count_signal should register 'viewer_count'"
|
|
);
|
|
let vc = vc.unwrap();
|
|
assert_eq!(vc.target(), EntityKind::Item);
|
|
assert!(vc.windows().contains(&Window::AllTime));
|
|
}
|