feat(core): derived status ignores season 0

This commit is contained in:
Miguel Palhas
2026-08-23 16:32:13 +01:00
parent 2fca8ec09f
commit 3bd613ec63
4 changed files with 205 additions and 25 deletions
+71 -12
View File
@@ -1,6 +1,7 @@
use std::collections::HashSet;
use std::time::{Duration, SystemTime};
use crate::{Episode, MediaState, Series};
use crate::{Episode, MediaState, Season, SeasonId, Series};
const AIRING_WINDOW: Duration = Duration::from_hours(14 * 24);
@@ -15,13 +16,26 @@ pub enum SeriesStatus {
}
/// Derives a series' status at `now` without persisting lifecycle intent.
///
/// Season 0 is invisible to all of it (`DESIGN.md` §4.2): episodes in a
/// season 0 listed here are ignored, so a manually wanted special cannot pin
/// the series at `incomplete` or hold back `ended`. The seasons must be the
/// series' own; an episode whose season is not listed counts like any other.
#[must_use]
pub fn derive_series_status(
series: &Series,
seasons: &[Season],
episodes: &[Episode],
now: SystemTime,
) -> SeriesStatus {
let wanted = episodes.iter().filter(|episode| episode.wanted);
let specials: HashSet<SeasonId> = seasons
.iter()
.filter(|season| season.number == 0)
.map(|season| season.id)
.collect();
let wanted = episodes
.iter()
.filter(|episode| episode.wanted && !specials.contains(&episode.season_id));
let complete = wanted
.clone()
.all(|episode| episode.state == MediaState::Available);
@@ -103,13 +117,23 @@ mod tests {
}
}
/// The season every default-test episode sits in.
fn seasons() -> [Season; 1] {
[Season {
id: SeasonId(1),
series_id: SeriesId(1),
number: 1,
tracked: true,
}]
}
#[test]
fn airing_when_a_wanted_episode_is_in_the_window() {
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
for air_date in [now - 14 * DAY, now + 14 * DAY] {
let episodes = [episode(1, Some(air_date), MediaState::Missing)];
assert_eq!(
derive_series_status(&series(true, false), &episodes, now),
derive_series_status(&series(true, false), &seasons(), &episodes, now),
SeriesStatus::Airing
);
}
@@ -121,7 +145,7 @@ mod tests {
MediaState::Missing,
)];
assert_eq!(
derive_series_status(&series(true, false), &episodes, now),
derive_series_status(&series(true, false), &seasons(), &episodes, now),
SeriesStatus::Waiting
);
}
@@ -132,7 +156,7 @@ mod tests {
let episodes = [episode(1, Some(now - 20 * DAY), MediaState::Missing)];
assert_eq!(
derive_series_status(&series(true, false), &episodes, now),
derive_series_status(&series(true, false), &seasons(), &episodes, now),
SeriesStatus::Incomplete
);
}
@@ -143,7 +167,7 @@ mod tests {
let episodes = [episode(1, Some(now + 30 * DAY), MediaState::Missing)];
assert_eq!(
derive_series_status(&series(true, false), &episodes, now),
derive_series_status(&series(true, false), &seasons(), &episodes, now),
SeriesStatus::Waiting
);
}
@@ -154,7 +178,7 @@ mod tests {
let episodes = [episode(1, Some(now), MediaState::Available)];
assert_eq!(
derive_series_status(&series(false, false), &episodes, now),
derive_series_status(&series(false, false), &seasons(), &episodes, now),
SeriesStatus::Complete
);
}
@@ -165,7 +189,7 @@ mod tests {
let episodes = [episode(1, Some(now), MediaState::Available)];
assert_eq!(
derive_series_status(&series(true, true), &episodes, now),
derive_series_status(&series(true, true), &seasons(), &episodes, now),
SeriesStatus::Ended
);
}
@@ -175,14 +199,14 @@ mod tests {
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
let mut episodes = [episode(1, Some(now - 20 * DAY), MediaState::Missing)];
assert_eq!(
derive_series_status(&series(false, false), &episodes, now),
derive_series_status(&series(false, false), &seasons(), &episodes, now),
SeriesStatus::Incomplete
);
episodes[0].state = MediaState::Available;
assert_eq!(
derive_series_status(&series(false, false), &episodes, now),
derive_series_status(&series(false, false), &seasons(), &episodes, now),
SeriesStatus::Complete
);
}
@@ -192,15 +216,50 @@ mod tests {
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
let mut episodes = vec![episode(1, Some(now - 30 * DAY), MediaState::Available)];
assert_eq!(
derive_series_status(&series(true, false), &episodes, now),
derive_series_status(&series(true, false), &seasons(), &episodes, now),
SeriesStatus::Waiting
);
episodes.push(episode(2, Some(now + 7 * DAY), MediaState::Missing));
assert_eq!(
derive_series_status(&series(true, false), &episodes, now),
derive_series_status(&series(true, false), &seasons(), &episodes, now),
SeriesStatus::Airing
);
}
#[test]
fn missing_specials_do_not_hold_back_complete_or_ended() {
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
let specials = [Season {
id: SeasonId(9),
series_id: SeriesId(1),
number: 0,
tracked: false,
}];
let mut episodes = vec![episode(1, Some(now - 30 * DAY), MediaState::Available)];
let mut special = episode(1, Some(now - 90 * DAY), MediaState::Missing);
special.season_id = SeasonId(9);
assert_eq!(
derive_series_status(&series(true, false), &specials, &episodes, now),
SeriesStatus::Waiting
);
episodes.push(special);
assert_eq!(
derive_series_status(&series(true, false), &[], &episodes, now),
SeriesStatus::Incomplete,
"without knowing the seasons, the special pins the series"
);
assert_eq!(
derive_series_status(&series(false, false), &specials, &episodes, now),
SeriesStatus::Complete,
"§4.2: the only gap is a special"
);
assert_eq!(
derive_series_status(&series(false, true), &specials, &episodes, now),
SeriesStatus::Ended
);
}
}