From 8e4f72456287209dfede6297f63cd1a18b0712e9 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 12 Jul 2026 14:02:45 +0200 Subject: [PATCH] add integration tests for all 6 FillStrategy variants, role filter on LibrarySearchFilter --- crates/domain/src/services/schedule/mod.rs | 4 + .../services/schedule/tests/integration.rs | 278 ++++++++++++++++++ crates/domain/src/testing/in_memory.rs | 11 + crates/domain/src/value_objects/search.rs | 11 +- 4 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 crates/domain/src/services/schedule/tests/integration.rs diff --git a/crates/domain/src/services/schedule/mod.rs b/crates/domain/src/services/schedule/mod.rs index 7597019..f5b4523 100644 --- a/crates/domain/src/services/schedule/mod.rs +++ b/crates/domain/src/services/schedule/mod.rs @@ -378,3 +378,7 @@ fn media_filter_to_library_search(filter: &crate::value_objects::MediaFilter) -> } lsf } + +#[cfg(all(test, feature = "test-helpers"))] +#[path = "tests/integration.rs"] +mod integration_tests; diff --git a/crates/domain/src/services/schedule/tests/integration.rs b/crates/domain/src/services/schedule/tests/integration.rs new file mode 100644 index 0000000..7c74214 --- /dev/null +++ b/crates/domain/src/services/schedule/tests/integration.rs @@ -0,0 +1,278 @@ +use std::sync::Arc; + +use chrono::{Datelike, NaiveTime, Utc}; + +use crate::models::{Channel, MediaItem, ProgrammingBlock, ScheduleConfig}; +use crate::services::schedule::ScheduleEngineService; +use crate::testing::{ + InMemoryChannelRepository, InMemoryLibraryRepository, InMemoryScheduleRepository, +}; +use crate::value_objects::{ + ContentType, FillStrategy, MediaFilter, MediaItemId, Weekday, +}; + +fn episode(id: &str, series: &str, ep: u32, secs: u32) -> MediaItem { + let item = MediaItem::new(MediaItemId::new(id), id, ContentType::Episode, secs); + let mut val = serde_json::to_value(&item).unwrap(); + val["series_name"] = serde_json::Value::String(series.into()); + val["episode_number"] = serde_json::Value::Number(ep.into()); + serde_json::from_value(val).unwrap() +} + +fn movie(id: &str, secs: u32) -> MediaItem { + MediaItem::new(MediaItemId::new(id), id, ContentType::Movie, secs) +} + +struct TestHarness { + engine: ScheduleEngineService, + channel_repo: Arc, + library_repo: Arc, +} + +impl TestHarness { + fn new() -> Self { + let library_repo = Arc::new(InMemoryLibraryRepository::new()); + let channel_repo = Arc::new(InMemoryChannelRepository::new()); + let schedule_repo = Arc::new(InMemoryScheduleRepository::new()); + + let engine = ScheduleEngineService::new( + library_repo.clone(), + channel_repo.clone(), + schedule_repo.clone(), + schedule_repo, + ); + + Self { + engine, + channel_repo, + library_repo, + } + } + + fn seed_items(&self, items: Vec) { + let mut store = self.library_repo.items.lock().unwrap(); + for item in items { + store.insert(item.id().value().to_string(), item); + } + } + + async fn create_channel_with_block( + &self, + block: ProgrammingBlock, + ) -> Channel { + let mut channel = Channel::new( + crate::value_objects::UserId::generate(), + "test-channel", + "UTC", + ); + let today = Utc::now() + .with_timezone(&chrono_tz::UTC) + .date_naive(); + let weekday = Weekday::from(today.weekday()); + let mut config = ScheduleConfig::new(); + config.insert_day(weekday, vec![block]); + channel.set_schedule_config(config); + self.channel_repo + .channels + .lock() + .unwrap() + .insert(channel.id(), channel.clone()); + channel + } +} + +#[tokio::test] +async fn alternating_interleaves_two_series() { + let h = TestHarness::new(); + h.seed_items(vec![ + episode("a-e1", "Show A", 1, 300), + episode("a-e2", "Show A", 2, 300), + episode("a-e3", "Show A", 3, 300), + episode("b-e1", "Show B", 1, 300), + episode("b-e2", "Show B", 2, 300), + episode("b-e3", "Show B", 3, 300), + ]); + + let block = ProgrammingBlock::new_algorithmic( + "alternating-block", + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + 60, + MediaFilter::default(), + FillStrategy::Alternating, + ); + let channel = h.create_channel_with_block(block).await; + + let schedule = h + .engine + .generate_schedule(channel.id(), Utc::now()) + .await + .unwrap(); + + let slots = schedule.slots(); + assert!(slots.len() >= 4, "expected at least 4 slots, got {}", slots.len()); + + let series: Vec> = slots.iter().map(|s| s.item().series_name()).collect(); + for pair in series.windows(2) { + if pair[0] == pair[1] { + panic!( + "consecutive slots have same series {:?}, expected interleaving", + pair[0] + ); + } + } +} + +#[tokio::test] +async fn weighted_surfaces_fresh_items() { + let h = TestHarness::new(); + h.seed_items(vec![ + movie("m1", 600), + movie("m2", 600), + movie("m3", 600), + movie("m4", 600), + ]); + + let block = ProgrammingBlock::new_algorithmic( + "weighted-block", + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + 60, + MediaFilter::default(), + FillStrategy::Weighted, + ); + let channel = h.create_channel_with_block(block).await; + + let schedule = h + .engine + .generate_schedule(channel.id(), Utc::now()) + .await + .unwrap(); + + assert!(!schedule.slots().is_empty(), "weighted strategy produced no slots"); + + let total_duration: u32 = schedule + .slots() + .iter() + .map(|s| (s.end_at() - s.start_at()).num_seconds() as u32) + .sum(); + assert!(total_duration > 0, "schedule has zero total duration"); +} + +#[tokio::test] +async fn marathon_fills_from_episode_one() { + let h = TestHarness::new(); + h.seed_items(vec![ + episode("ep1", "Series", 1, 600), + episode("ep2", "Series", 2, 600), + episode("ep3", "Series", 3, 600), + ]); + + let block = ProgrammingBlock::new_algorithmic( + "marathon-block", + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + 60, + MediaFilter::default(), + FillStrategy::Marathon, + ); + let channel = h.create_channel_with_block(block).await; + + let schedule = h + .engine + .generate_schedule(channel.id(), Utc::now()) + .await + .unwrap(); + + let slots = schedule.slots(); + assert!(slots.len() >= 3, "expected at least 3 slots, got {}", slots.len()); + + assert_eq!(slots[0].item().id().value(), "ep1"); + assert_eq!(slots[1].item().id().value(), "ep2"); + assert_eq!(slots[2].item().id().value(), "ep3"); +} + +#[tokio::test] +async fn sequential_produces_ordered_schedule() { + let h = TestHarness::new(); + h.seed_items(vec![ + episode("ep1", "Series", 1, 600), + episode("ep2", "Series", 2, 600), + episode("ep3", "Series", 3, 600), + ]); + + let block = ProgrammingBlock::new_algorithmic( + "sequential-block", + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + 60, + MediaFilter::default(), + FillStrategy::Sequential, + ); + let channel = h.create_channel_with_block(block).await; + + let schedule = h + .engine + .generate_schedule(channel.id(), Utc::now()) + .await + .unwrap(); + + let slots = schedule.slots(); + assert!(!slots.is_empty(), "sequential strategy produced no slots"); + assert_eq!(slots[0].item().id().value(), "ep1"); +} + +#[tokio::test] +async fn best_fit_produces_schedule() { + let h = TestHarness::new(); + h.seed_items(vec![ + movie("m1", 1800), + movie("m2", 1200), + movie("m3", 900), + ]); + + let block = ProgrammingBlock::new_algorithmic( + "bestfit-block", + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + 60, + MediaFilter::default(), + FillStrategy::BestFit, + ); + let channel = h.create_channel_with_block(block).await; + + let schedule = h + .engine + .generate_schedule(channel.id(), Utc::now()) + .await + .unwrap(); + + assert!(!schedule.slots().is_empty(), "best_fit strategy produced no slots"); + assert_eq!( + schedule.slots()[0].item().id().value(), + "m1", + "best_fit should pick longest item first" + ); +} + +#[tokio::test] +async fn random_produces_schedule_within_budget() { + let h = TestHarness::new(); + h.seed_items(vec![ + movie("m1", 600), + movie("m2", 600), + movie("m3", 600), + ]); + + let block = ProgrammingBlock::new_algorithmic( + "random-block", + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + 30, + MediaFilter::default(), + FillStrategy::Random, + ); + let channel = h.create_channel_with_block(block).await; + + let schedule = h + .engine + .generate_schedule(channel.id(), Utc::now()) + .await + .unwrap(); + + assert!(!schedule.slots().is_empty(), "random strategy produced no slots"); +} diff --git a/crates/domain/src/testing/in_memory.rs b/crates/domain/src/testing/in_memory.rs index a4bbe1c..1342e1a 100644 --- a/crates/domain/src/testing/in_memory.rs +++ b/crates/domain/src/testing/in_memory.rs @@ -467,10 +467,21 @@ impl LibraryQuery for InMemoryLibraryRepository { { return false; } + if let Some(role) = filter.role() && item.role() != role { + return false; + } + if !filter.series_names().is_empty() + && !item + .series_name() + .is_some_and(|sn| filter.series_names().iter().any(|f| f == sn)) + { + return false; + } true }) .cloned() .collect(); + items.sort_by(|a, b| a.id().value().cmp(b.id().value())); let total = items.len() as u32; let offset = filter.offset() as usize; let limit = filter.limit() as usize; diff --git a/crates/domain/src/value_objects/search.rs b/crates/domain/src/value_objects/search.rs index 7df866f..f517264 100644 --- a/crates/domain/src/value_objects/search.rs +++ b/crates/domain/src/value_objects/search.rs @@ -1,4 +1,4 @@ -use crate::value_objects::ContentType; +use crate::value_objects::{ContentType, MediaRole}; const DEFAULT_SEARCH_LIMIT: u32 = 50; @@ -14,6 +14,7 @@ pub struct LibrarySearchFilter { max_duration_secs: Option, search_term: Option, season_number: Option, + role: Option, offset: u32, limit: u32, } @@ -63,6 +64,10 @@ impl LibrarySearchFilter { self.season_number = Some(n); self } + pub fn with_role(mut self, role: MediaRole) -> Self { + self.role = Some(role); + self + } pub fn with_offset(mut self, offset: u32) -> Self { self.offset = offset; self @@ -102,6 +107,9 @@ impl LibrarySearchFilter { pub fn season_number(&self) -> Option { self.season_number } + pub fn role(&self) -> Option<&MediaRole> { + self.role.as_ref() + } pub fn offset(&self) -> u32 { self.offset } @@ -123,6 +131,7 @@ impl Default for LibrarySearchFilter { max_duration_secs: None, search_term: None, season_number: None, + role: None, offset: 0, limit: DEFAULT_SEARCH_LIMIT, }