#![allow(dead_code)]
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UserCategory {
News,
Sports,
Entertainment,
Documentary,
Drama,
Music,
Science,
Travel,
Education,
Gaming,
}
impl UserCategory {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::News => "News",
Self::Sports => "Sports",
Self::Entertainment => "Entertainment",
Self::Documentary => "Documentary",
Self::Drama => "Drama",
Self::Music => "Music",
Self::Science => "Science",
Self::Travel => "Travel",
Self::Education => "Education",
Self::Gaming => "Gaming",
}
}
#[must_use]
pub fn all() -> Vec<Self> {
vec![
Self::News,
Self::Sports,
Self::Entertainment,
Self::Documentary,
Self::Drama,
Self::Music,
Self::Science,
Self::Travel,
Self::Education,
Self::Gaming,
]
}
}
#[derive(Debug, Clone)]
pub struct ViewEvent {
pub item_id: String,
pub categories: Vec<UserCategory>,
pub completion: f32,
pub timestamp: i64,
}
impl ViewEvent {
#[must_use]
pub fn new(
item_id: impl Into<String>,
categories: Vec<UserCategory>,
completion: f32,
timestamp: i64,
) -> Self {
Self {
item_id: item_id.into(),
categories,
completion: completion.clamp(0.0, 1.0),
timestamp,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct UserProfile {
views: Vec<ViewEvent>,
category_scores: HashMap<UserCategory, f64>,
}
impl UserProfile {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_view(&mut self, event: ViewEvent) {
let weight = f64::from(event.completion);
for &cat in &event.categories {
*self.category_scores.entry(cat).or_insert(0.0) += weight;
}
self.views.push(event);
}
#[must_use]
pub fn view_count(&self) -> usize {
self.views.len()
}
#[must_use]
pub fn category_affinity(&self, category: UserCategory) -> f64 {
self.category_scores.get(&category).copied().unwrap_or(0.0)
}
#[must_use]
pub fn top_categories(&self) -> Vec<(UserCategory, f64)> {
let mut pairs: Vec<(UserCategory, f64)> = self
.category_scores
.iter()
.map(|(&cat, &score)| (cat, score))
.collect();
pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
pairs
}
#[must_use]
pub fn last_viewed(&self) -> Option<&str> {
self.views.last().map(|e| e.item_id.as_str())
}
#[must_use]
pub fn view_history(&self) -> &[ViewEvent] {
&self.views
}
pub fn reset(&mut self) {
self.views.clear();
self.category_scores.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_event(id: &str, cats: Vec<UserCategory>, completion: f32, ts: i64) -> ViewEvent {
ViewEvent::new(id, cats, completion, ts)
}
#[test]
fn test_category_label_news() {
assert_eq!(UserCategory::News.label(), "News");
}
#[test]
fn test_category_label_gaming() {
assert_eq!(UserCategory::Gaming.label(), "Gaming");
}
#[test]
fn test_all_categories_count() {
assert_eq!(UserCategory::all().len(), 10);
}
#[test]
fn test_profile_starts_empty() {
let p = UserProfile::new();
assert_eq!(p.view_count(), 0);
}
#[test]
fn test_add_view_increments_count() {
let mut p = UserProfile::new();
p.add_view(make_event("v1", vec![UserCategory::News], 1.0, 1000));
assert_eq!(p.view_count(), 1);
}
#[test]
fn test_category_affinity_zero_for_unseen() {
let p = UserProfile::new();
assert!((p.category_affinity(UserCategory::Sports) - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_category_affinity_increases_with_views() {
let mut p = UserProfile::new();
p.add_view(make_event("v1", vec![UserCategory::Sports], 0.5, 1000));
p.add_view(make_event("v2", vec![UserCategory::Sports], 1.0, 2000));
assert!((p.category_affinity(UserCategory::Sports) - 1.5).abs() < 1e-9);
}
#[test]
fn test_partial_completion_weighted_correctly() {
let mut p = UserProfile::new();
p.add_view(make_event("v1", vec![UserCategory::Music], 0.25, 500));
let aff = p.category_affinity(UserCategory::Music);
assert!((aff - 0.25).abs() < 1e-9);
}
#[test]
fn test_completion_clamped_to_one() {
let ev = ViewEvent::new("v1", vec![], 1.5, 0);
assert!((ev.completion - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_completion_clamped_to_zero() {
let ev = ViewEvent::new("v1", vec![], -0.3, 0);
assert!((ev.completion - 0.0).abs() < f32::EPSILON);
}
#[test]
fn test_top_categories_ordering() {
let mut p = UserProfile::new();
p.add_view(make_event("v1", vec![UserCategory::Drama], 0.4, 100));
p.add_view(make_event("v2", vec![UserCategory::Drama], 0.8, 200));
p.add_view(make_event("v3", vec![UserCategory::News], 0.2, 300));
let top = p.top_categories();
assert_eq!(top[0].0, UserCategory::Drama);
}
#[test]
fn test_last_viewed_none_when_empty() {
let p = UserProfile::new();
assert!(p.last_viewed().is_none());
}
#[test]
fn test_last_viewed_returns_most_recent() {
let mut p = UserProfile::new();
p.add_view(make_event("first", vec![], 1.0, 1));
p.add_view(make_event("second", vec![], 1.0, 2));
assert_eq!(p.last_viewed(), Some("second"));
}
#[test]
fn test_reset_clears_views_and_affinity() {
let mut p = UserProfile::new();
p.add_view(make_event("v1", vec![UserCategory::Gaming], 1.0, 1));
p.reset();
assert_eq!(p.view_count(), 0);
assert!((p.category_affinity(UserCategory::Gaming) - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_view_history_slice_length() {
let mut p = UserProfile::new();
for i in 0..5_i64 {
p.add_view(make_event(&format!("v{i}"), vec![], 1.0, i));
}
assert_eq!(p.view_history().len(), 5);
}
#[test]
fn test_multi_category_single_view() {
let mut p = UserProfile::new();
p.add_view(make_event(
"v1",
vec![UserCategory::Science, UserCategory::Education],
1.0,
0,
));
assert!((p.category_affinity(UserCategory::Science) - 1.0).abs() < 1e-9);
assert!((p.category_affinity(UserCategory::Education) - 1.0).abs() < 1e-9);
}
}