add integration tests for all 6 FillStrategy variants, role filter on LibrarySearchFilter
This commit is contained in:
@@ -378,3 +378,7 @@ fn media_filter_to_library_search(filter: &crate::value_objects::MediaFilter) ->
|
|||||||
}
|
}
|
||||||
lsf
|
lsf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "test-helpers"))]
|
||||||
|
#[path = "tests/integration.rs"]
|
||||||
|
mod integration_tests;
|
||||||
|
|||||||
278
crates/domain/src/services/schedule/tests/integration.rs
Normal file
278
crates/domain/src/services/schedule/tests/integration.rs
Normal file
@@ -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<InMemoryChannelRepository>,
|
||||||
|
library_repo: Arc<InMemoryLibraryRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<MediaItem>) {
|
||||||
|
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<Option<&str>> = 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");
|
||||||
|
}
|
||||||
@@ -467,10 +467,21 @@ impl LibraryQuery for InMemoryLibraryRepository {
|
|||||||
{
|
{
|
||||||
return false;
|
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
|
true
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
|
items.sort_by(|a, b| a.id().value().cmp(b.id().value()));
|
||||||
let total = items.len() as u32;
|
let total = items.len() as u32;
|
||||||
let offset = filter.offset() as usize;
|
let offset = filter.offset() as usize;
|
||||||
let limit = filter.limit() as usize;
|
let limit = filter.limit() as usize;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::value_objects::ContentType;
|
use crate::value_objects::{ContentType, MediaRole};
|
||||||
|
|
||||||
const DEFAULT_SEARCH_LIMIT: u32 = 50;
|
const DEFAULT_SEARCH_LIMIT: u32 = 50;
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ pub struct LibrarySearchFilter {
|
|||||||
max_duration_secs: Option<u32>,
|
max_duration_secs: Option<u32>,
|
||||||
search_term: Option<String>,
|
search_term: Option<String>,
|
||||||
season_number: Option<u32>,
|
season_number: Option<u32>,
|
||||||
|
role: Option<MediaRole>,
|
||||||
offset: u32,
|
offset: u32,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
}
|
}
|
||||||
@@ -63,6 +64,10 @@ impl LibrarySearchFilter {
|
|||||||
self.season_number = Some(n);
|
self.season_number = Some(n);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
pub fn with_role(mut self, role: MediaRole) -> Self {
|
||||||
|
self.role = Some(role);
|
||||||
|
self
|
||||||
|
}
|
||||||
pub fn with_offset(mut self, offset: u32) -> Self {
|
pub fn with_offset(mut self, offset: u32) -> Self {
|
||||||
self.offset = offset;
|
self.offset = offset;
|
||||||
self
|
self
|
||||||
@@ -102,6 +107,9 @@ impl LibrarySearchFilter {
|
|||||||
pub fn season_number(&self) -> Option<u32> {
|
pub fn season_number(&self) -> Option<u32> {
|
||||||
self.season_number
|
self.season_number
|
||||||
}
|
}
|
||||||
|
pub fn role(&self) -> Option<&MediaRole> {
|
||||||
|
self.role.as_ref()
|
||||||
|
}
|
||||||
pub fn offset(&self) -> u32 {
|
pub fn offset(&self) -> u32 {
|
||||||
self.offset
|
self.offset
|
||||||
}
|
}
|
||||||
@@ -123,6 +131,7 @@ impl Default for LibrarySearchFilter {
|
|||||||
max_duration_secs: None,
|
max_duration_secs: None,
|
||||||
search_term: None,
|
search_term: None,
|
||||||
season_number: None,
|
season_number: None,
|
||||||
|
role: None,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: DEFAULT_SEARCH_LIMIT,
|
limit: DEFAULT_SEARCH_LIMIT,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user