code refactor
Some checks failed
CI / Check / Test (push) Has been cancelled

Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
2026-10-26 09:36:23 +00:00
parent 2ce7db12c2
commit 017b58d6fa
804 changed files with 33423 additions and 28955 deletions

View File

@@ -0,0 +1,435 @@
use chrono::{Datelike, FixedOffset, NaiveDate, Utc, Weekday};
use uuid::Uuid;
use domain::models::{Insight, WatchRow};
use domain::services::insights_analyzer::build_insights;
fn watch(genres: &[&str], rating: u8, date: NaiveDate, hour: u32) -> WatchRow {
WatchRow {
movie_id: Uuid::new_v4(),
rating,
watched_at: date.and_hms_opt(hour, 30, 0).unwrap(),
watch_medium: Some("streaming".to_string()),
genres: genres.iter().map(|g| g.to_string()).collect(),
}
}
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).unwrap()
}
fn kinds(insights: &[Insight]) -> Vec<&str> {
insights
.iter()
.map(|i| match i {
Insight::RatingLift { .. } => "rating_lift",
Insight::Seasonality { .. } => "seasonality",
Insight::MediumTiming { .. } => "medium_timing",
Insight::Rhythm { .. } => "rhythm",
})
.collect()
}
// ── Rating lift ──────────────────────────────────────────────────────────────
#[test]
fn rating_lift_compares_genre_to_personal_average() {
// 12 thrillers rated 5, 12 comedies rated 2 — user average 3.5.
let mut rows: Vec<WatchRow> = (0..12)
.map(|i| watch(&["Thriller"], 5, date(2024, 1, 1 + i), 20))
.collect();
rows.extend((0..12).map(|i| watch(&["Comedy"], 2, date(2024, 3, 1 + i), 20)));
let insights = build_insights(&rows, &Utc);
let thrill = insights.iter().find_map(|i| match i {
Insight::RatingLift {
genre,
delta,
sample_count,
..
} if genre == "Thriller" => Some((*delta, *sample_count)),
_ => None,
});
let (delta, count) = thrill.expect("Thriller lift expected");
assert!((delta - 1.5).abs() < 1e-9, "delta was {delta}");
assert_eq!(count, 12);
let comedy = insights.iter().find_map(|i| match i {
Insight::RatingLift { genre, delta, .. } if genre == "Comedy" => Some(*delta),
_ => None,
});
assert!((comedy.expect("Comedy lift expected") + 1.5).abs() < 1e-9);
}
#[test]
fn rating_lift_ignores_rewatches() {
// 10 first-time dramas rated 3 (baseline), 5 thrillers rated 5…
let mut rows: Vec<WatchRow> = (0..10)
.map(|i| watch(&["Drama"], 3, date(2024, 1, 1 + i), 20))
.collect();
let mut thriller_ids: Vec<uuid::Uuid> = Vec::new();
for i in 0..5 {
let row = watch(&["Thriller"], 5, date(2024, 2, 1 + i), 20);
thriller_ids.push(row.movie_id);
rows.push(row);
}
// …and 3 rewatches of those same thrillers rated 1, which must not count.
for (i, id) in thriller_ids.iter().enumerate() {
let mut rewatch = watch(&["Thriller"], 1, date(2024, 6, 1 + i as u32), 20);
rewatch.movie_id = *id;
rows.push(rewatch);
}
let insights = build_insights(&rows, &Utc);
let thrill = insights.iter().find_map(|i| match i {
Insight::RatingLift {
genre,
delta,
sample_count,
..
} if genre == "Thriller" => Some((*delta, *sample_count)),
_ => None,
});
let (delta, count) = thrill.expect("Thriller lift expected");
// 15 first-time rows: user_avg = (10*3 + 5*5)/15 = 55/15 ≈ 3.67,
// genre_avg = 5.0, delta ≈ 1.33.
assert!((delta - 1.33).abs() < 0.01, "delta was {delta}");
assert_eq!(count, 5, "rewatches must not join the genre sample");
}
#[test]
fn rating_lift_below_sample_threshold_is_absent() {
let mut rows: Vec<WatchRow> = (0..10)
.map(|i| watch(&["Drama"], 3, date(2024, 1, 1 + i), 20))
.collect();
// Only 4 thriller ratings — below MIN_GENRE_RATINGS, so no matter how
// high they are rated, the genre must not yield an insight.
rows.extend((0..4).map(|i| watch(&["Thriller"], 5, date(2024, 2, 1 + i), 20)));
let insights = build_insights(&rows, &Utc);
assert!(
!insights
.iter()
.any(|i| matches!(i, Insight::RatingLift { genre, .. } if genre == "Thriller")),
"a genre with 4 ratings must not yield a lift"
);
}
#[test]
fn rating_lift_below_delta_threshold_is_absent() {
// 5 thrillers rated 4; dramas mix to put the personal average at 3.85,
// so the thriller delta is 0.15 < 0.3.
let mut rows: Vec<WatchRow> = (0..12)
.map(|i| watch(&["Drama"], 4, date(2024, 1, 1 + i % 28), 20))
.collect();
rows.extend((0..3).map(|i| watch(&["Drama"], 3, date(2024, 1, 13 + i), 20)));
rows.extend((0..5).map(|i| watch(&["Thriller"], 4, date(2024, 2, 1 + i), 20)));
let insights = build_insights(&rows, &Utc);
assert!(!kinds(&insights).contains(&"rating_lift"));
}
// ── Seasonality ──────────────────────────────────────────────────────────────
#[test]
fn seasonality_detects_month_peak_against_own_monthly_baseline() {
// 16 horrors: 10 in October across two years, 6 scattered.
let mut rows: Vec<WatchRow> = Vec::new();
for i in 0..10u32 {
let year = if i < 5 { 2023 } else { 2024 };
rows.push(watch(&["Horror"], 4, date(year, 10, 1 + i % 27), 21));
}
for (m, d) in [(1u32, 3u32), (2, 7), (4, 11), (6, 15), (8, 19), (12, 21)] {
rows.push(watch(&["Horror"], 4, date(2024, m, d), 21));
}
// A perfectly spread genre (2 watches per month) must NOT appear.
rows.extend((0..24).map(|i| watch(&["Drama"], 3, date(2024, 1 + i % 12, 5), 21)));
let insights = build_insights(&rows, &Utc);
let horror = insights.iter().find_map(|i| match i {
Insight::Seasonality {
genre,
month,
multiplier,
sample_count,
..
} if genre == "Horror" => Some((*month, *multiplier, *sample_count)),
_ => None,
});
let (month, multiplier, count) = horror.expect("Horror seasonality expected");
assert_eq!(month, 10);
// Horror: 10 of 16 watches in October (62.5%); the whole diary puts
// 12 of 40 watches there (30%) → 0.625 / 0.3 ≈ 2.08× the user's own share.
assert!(
(multiplier - 2.08).abs() < 1e-9,
"multiplier was {multiplier}"
);
assert_eq!(count, 16);
assert!(
!insights
.iter()
.any(|i| matches!(i, Insight::Seasonality { genre, .. } if genre == "Drama")),
"a spread-out genre must not yield a seasonality insight"
);
}
#[test]
fn seasonality_ignores_when_the_diary_itself_is_concentrated() {
// A diary started in October: every genre peaks in Oct/Nov only because
// the user logged nothing else. Relative to their own months, nothing is
// seasonal.
let mut rows: Vec<WatchRow> = Vec::new();
for i in 0..20u32 {
let month = if i % 2 == 0 { 10 } else { 11 };
rows.push(watch(&["Drama"], 3, date(2024, month, 1 + i), 21));
rows.push(watch(&["Comedy"], 3, date(2024, month, 1 + i), 21));
}
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"seasonality"));
}
#[test]
fn seasonality_below_sample_threshold_is_absent() {
let rows: Vec<WatchRow> = (0..14)
.map(|i| watch(&["Horror"], 4, date(2024, 10, 1 + i), 21))
.collect();
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"seasonality"));
}
// ── Medium timing ────────────────────────────────────────────────────────────
#[test]
fn medium_timing_detects_weekend_lift() {
let saturday = date(2024, 3, 9); // a Saturday
assert_eq!(saturday.weekday(), Weekday::Sat);
let friday = date(2024, 3, 8);
let mut rows: Vec<WatchRow> = Vec::new();
// 6 cinema watches, 5 of them on Saturdays.
let cinema_days = [
saturday,
saturday.with_day(16).unwrap(),
saturday.with_day(23).unwrap(),
saturday.with_day(30).unwrap(),
date(2024, 4, 6), // next month's first Saturday
friday,
];
for day in cinema_days {
let mut row = watch(&["Drama"], 4, day, 19);
row.watch_medium = Some("cinema".to_string());
rows.push(row);
}
// 10 streaming watches on weekdays only (two Mon–Fri weeks) to keep the
// baseline weekend share driven by cinema alone.
for i in 0..10u32 {
let mut row = watch(&["Drama"], 4, friday, 21);
row.watch_medium = Some("streaming".to_string());
row.watched_at = date(2024, 3, 4 + i % 5 + 7 * (i / 5))
.and_hms_opt(21, 0, 0)
.unwrap();
rows.push(row);
}
let insights = build_insights(&rows, &Utc);
let cinema = insights.iter().find_map(|i| match i {
Insight::MediumTiming {
medium,
multiplier,
sample_count,
..
} if medium == "cinema" => Some((*multiplier, *sample_count)),
_ => None,
});
let (multiplier, count) = cinema.expect("cinema timing expected");
// Baseline weekend share = 5/16; cinema share = 5/6 → lift = (5/6)/(5/16) ≈ 2.67.
assert!(
(multiplier - 2.67).abs() < 0.01,
"multiplier was {multiplier}"
);
assert_eq!(count, 6);
}
#[test]
fn medium_timing_skipped_when_medium_mostly_missing() {
let mut rows: Vec<WatchRow> = Vec::new();
let first_saturday = date(2024, 3, 9);
for i in 0..6u32 {
let mut row = watch(
&["Drama"],
4,
first_saturday + chrono::Duration::weeks(i64::from(i)),
20,
);
row.watch_medium = Some("cinema".to_string());
rows.push(row);
}
for i in 0..10u32 {
let mut row = watch(&["Drama"], 4, date(2024, 3, 1 + i), 20);
row.watch_medium = None; // imports without a medium
rows.push(row);
}
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"medium_timing"));
}
// ── Rhythm ───────────────────────────────────────────────────────────────────
#[test]
fn rhythm_finds_peak_window_and_days() {
// 12 watches at 22:30: five Tuesdays, four Wednesdays, three Thursdays.
let first_tuesday = date(2024, 3, 5);
assert_eq!(first_tuesday.weekday(), Weekday::Tue);
let mut rows: Vec<WatchRow> = Vec::new();
for day in 0..3u32 {
let count = 5 - day; // Tue ×5, Wed ×4, Thu ×3
for week in 0..count {
rows.push(watch(
&["Drama"],
4,
first_tuesday
+ chrono::Duration::days(i64::from(day))
+ chrono::Duration::weeks(i64::from(week)),
22,
));
}
}
let insights = build_insights(&rows, &Utc);
let rhythm = insights.iter().find_map(|i| match i {
Insight::Rhythm {
peak_hour_start,
peak_hour_end,
top_days,
sample_count,
} => Some((
*peak_hour_start,
*peak_hour_end,
top_days.clone(),
*sample_count,
)),
_ => None,
});
let (start, end, days, count) = rhythm.expect("rhythm expected");
// All watches sit at hour 22; the earliest 3-hour window that covers
// them starts at 20 (20, 21, 22).
assert_eq!(start, 20);
assert_eq!(end, 23);
// Every day carries ≥60% of the busiest day (5): Tue(5) Wed(4) Thu(3).
assert_eq!(days, vec![2, 3, 4], "Tue=2, Wed=3, Thu=4");
assert_eq!(count, 12);
}
#[test]
fn rhythm_buckets_in_the_viewers_time_zone() {
// 12 watches stored at 22:30 UTC on Tue/Wed/Thu are 00:30 the next day
// for a viewer at UTC+2.
let first_tuesday = date(2024, 3, 5);
let mut rows: Vec<WatchRow> = Vec::new();
for day in 0..3u32 {
for week in 0..(5 - day) {
rows.push(watch(
&["Drama"],
4,
first_tuesday
+ chrono::Duration::days(i64::from(day))
+ chrono::Duration::weeks(i64::from(week)),
22,
));
}
}
let plus_two = FixedOffset::east_opt(2 * 3600).unwrap();
let insights = build_insights(&rows, &plus_two);
let (start, end, days) = insights
.iter()
.find_map(|i| match i {
Insight::Rhythm {
peak_hour_start,
peak_hour_end,
top_days,
..
} => Some((*peak_hour_start, *peak_hour_end, top_days.clone())),
_ => None,
})
.expect("rhythm expected");
// Local hour 0; the scan starts at 0, so the first window covering it is 0–3.
assert_eq!((start, end), (0, 3));
assert_eq!(days, vec![3, 4, 5], "Wed=3, Thu=4, Fri=5 locally");
}
#[test]
fn rhythm_import_noise_detected_before_zone_shift() {
// Date-only imports are stored at 00:00 UTC; at UTC+2 they read 02:00,
// but they are still noise.
let rows: Vec<WatchRow> = (0..12u32)
.map(|i| WatchRow {
movie_id: Uuid::new_v4(),
rating: 4,
watched_at: date(2024, 1, 1 + i).and_hms_opt(0, 0, 0).unwrap(),
watch_medium: None,
genres: vec!["Drama".to_string()],
})
.collect();
let plus_two = FixedOffset::east_opt(2 * 3600).unwrap();
assert!(!kinds(&build_insights(&rows, &plus_two)).contains(&"rhythm"));
}
#[test]
fn rhythm_skipped_when_timestamps_are_import_noise() {
let mut rows: Vec<WatchRow> = Vec::new();
for i in 0..12u32 {
// Exactly midnight — the signature of a CSV import with no time data.
let row = WatchRow {
movie_id: Uuid::new_v4(),
rating: 4,
watched_at: date(2024, 1, 1 + i).and_hms_opt(0, 0, 0).unwrap(),
watch_medium: None,
genres: vec!["Drama".to_string()],
};
rows.push(row);
}
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"rhythm"));
}
// ── Overall ──────────────────────────────────────────────────────────────────
#[test]
fn empty_diary_yields_nothing() {
assert!(build_insights(&[], &Utc).is_empty());
}
#[test]
fn small_diary_yields_nothing() {
let rows: Vec<WatchRow> = (0..4)
.map(|i| watch(&["Drama"], 4, date(2024, 1, 1 + i), 20))
.collect();
assert!(build_insights(&rows, &Utc).is_empty());
}
#[test]
fn max_three_insights_per_category() {
// Six genres, all with a strong rating lift — only the three strongest survive.
let mut rows: Vec<WatchRow> = Vec::new();
let genres = [
("Thriller", 5u8),
("Horror", 5),
("Sci-Fi", 5),
("Comedy", 5),
("Romance", 5),
("Documentary", 5),
];
for (genre, rating) in genres {
rows.extend((0..5).map(|i| watch(&[genre], rating, date(2024, 1, 1 + i), 20)));
}
// Baseline dramas rated 1 make every lift large.
rows.extend((0..10).map(|i| watch(&["Drama"], 1, date(2024, 3, 1 + i), 20)));
let insights = build_insights(&rows, &Utc);
let rating_lifts = insights
.iter()
.filter(|i| matches!(i, Insight::RatingLift { .. }))
.count();
assert_eq!(rating_lifts, 3);
}

View File

@@ -0,0 +1,69 @@
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use domain::models::{Review, ReviewHistory};
use domain::services::review_history::{ReviewHistoryAnalyzer, Trend};
use domain::testing::test_movie_with_year;
use domain::value_objects::{MovieId, Rating, UserId};
fn dt(year: i32, month: u32, day: u32) -> NaiveDateTime {
NaiveDateTime::new(
NaiveDate::from_ymd_opt(year, month, day).unwrap(),
NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
)
}
fn review_with_rating(movie_id: &MovieId, rating: u8, watched_at: NaiveDateTime) -> Review {
let user_id = UserId::generate();
Review::new(
movie_id.clone(),
user_id,
Rating::new(rating).unwrap(),
None,
watched_at,
None,
false,
)
.unwrap()
}
#[test]
fn neutral_when_empty() {
let movie = test_movie_with_year("Test", 2024);
let history = ReviewHistory::new(movie, vec![]);
let trend = ReviewHistoryAnalyzer::rating_trend(&history).unwrap();
assert_eq!(trend, Trend::Neutral);
}
#[test]
fn neutral_when_single_review() {
let movie = test_movie_with_year("Test", 2024);
let r = review_with_rating(movie.id(), 4, dt(2024, 1, 1));
let history = ReviewHistory::new(movie, vec![r]);
let trend = ReviewHistoryAnalyzer::rating_trend(&history).unwrap();
assert_eq!(trend, Trend::Neutral);
}
#[test]
fn improved_when_latest_above_average() {
let movie = test_movie_with_year("Test", 2024);
let viewings = vec![
review_with_rating(movie.id(), 2, dt(2024, 1, 1)),
review_with_rating(movie.id(), 3, dt(2024, 2, 1)),
review_with_rating(movie.id(), 5, dt(2024, 3, 1)),
];
let history = ReviewHistory::new(movie, viewings);
let trend = ReviewHistoryAnalyzer::rating_trend(&history).unwrap();
assert_eq!(trend, Trend::Improved);
}
#[test]
fn declined_when_latest_below_average() {
let movie = test_movie_with_year("Test", 2024);
let viewings = vec![
review_with_rating(movie.id(), 5, dt(2024, 1, 1)),
review_with_rating(movie.id(), 4, dt(2024, 2, 1)),
review_with_rating(movie.id(), 2, dt(2024, 3, 1)),
];
let history = ReviewHistory::new(movie, viewings);
let trend = ReviewHistoryAnalyzer::rating_trend(&history).unwrap();
assert_eq!(trend, Trend::Declined);
}

View File

@@ -0,0 +1,106 @@
use chrono::NaiveDate;
use uuid::Uuid;
use domain::models::WrapUpMovieRow;
use domain::models::wrapup::{DateRange, WrapUpScope};
use domain::services::wrapup_analyzer::build_report;
fn row(title: &str, rating: u8, ym: &str) -> WrapUpMovieRow {
WrapUpMovieRow {
movie_id: Uuid::new_v4(),
title: title.to_string(),
release_year: 2024,
director: Some("Dir".to_string()),
poster_path: None,
rating,
watched_at: chrono::NaiveDateTime::parse_from_str(
&format!("{ym}-01 12:00:00"),
"%Y-%m-%d %H:%M:%S",
)
.unwrap(),
user_id: Uuid::new_v4(),
runtime_minutes: Some(100),
budget_usd: None,
original_language: Some("en".to_string()),
watch_medium: None,
genres: vec!["Action".to_string()],
keywords: vec![],
cast_names: vec![],
cast_profile_paths: vec![],
}
}
fn range() -> DateRange {
DateRange::new(
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
)
.unwrap()
}
#[test]
fn empty_rows_returns_zero_totals() {
let report = build_report(WrapUpScope::Global, range(), &[]);
assert_eq!(report.total_movies, 0);
assert!(report.avg_rating.is_none());
assert_eq!(report.rating_distribution, [0u32; 5]);
}
#[test]
fn total_movies_matches_row_count() {
let rows = vec![row("A", 5, "2024-01"), row("B", 3, "2024-02")];
let report = build_report(WrapUpScope::Global, range(), &rows);
assert_eq!(report.total_movies, 2);
}
#[test]
fn avg_rating_is_correct() {
let rows = vec![row("A", 4, "2024-01"), row("B", 2, "2024-01")];
let report = build_report(WrapUpScope::Global, range(), &rows);
let avg = report.avg_rating.unwrap();
assert!((avg - 3.0).abs() < f64::EPSILON);
}
#[test]
fn rating_distribution_counts_correctly() {
let rows = vec![
row("A", 5, "2024-01"),
row("B", 5, "2024-02"),
row("C", 3, "2024-03"),
];
let report = build_report(WrapUpScope::Global, range(), &rows);
assert_eq!(report.rating_distribution[4], 2);
assert_eq!(report.rating_distribution[2], 1);
}
#[test]
fn movies_per_month_sorted_chronologically() {
let rows = vec![
row("A", 3, "2024-03"),
row("B", 3, "2024-01"),
row("C", 3, "2024-02"),
];
let report = build_report(WrapUpScope::Global, range(), &rows);
let yms: Vec<&str> = report
.movies_per_month
.iter()
.map(|m| m.year_month.as_str())
.collect();
assert_eq!(yms, ["2024-01", "2024-02", "2024-03"]);
}
#[test]
fn global_scope_populates_global_fields() {
let rows = vec![row("A", 4, "2024-01")];
let report = build_report(WrapUpScope::Global, range(), &rows);
assert!(report.total_users_active.is_some());
}
#[test]
fn user_scope_leaves_global_fields_empty() {
let rows = vec![row("A", 4, "2024-01")];
let report = build_report(WrapUpScope::User(Uuid::new_v4()), range(), &rows);
assert!(report.total_users_active.is_none());
assert!(report.most_active_user.is_none());
}