use uuid::Uuid; use crate::client::{DiaryEntryDto, LogReviewRequest, ReviewHistoryResponse}; use crate::config::Config; // ── Screens ─────────────────────────────────────────────────────────────────── #[derive(Debug)] pub enum Screen { Setup(SetupState), Login(LoginState), Main(MainState), } #[derive(Debug, Default)] pub struct SetupState { pub api_url: String, pub error: Option, } #[derive(Debug, Default)] pub struct LoginState { pub email: String, pub password: String, pub focused: LoginField, } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum LoginField { #[default] Email, Password } // ── Main (4 tabs) ───────────────────────────────────────────────────────────── #[derive(Debug)] pub struct MainState { pub tab: Tab, pub diary: DiaryState, pub add_review: AddReviewState, pub bulk_import: BulkImportState, pub settings: SettingsState, } impl MainState { pub fn new(api_url: String) -> Self { Self { tab: Tab::Diary, diary: DiaryState::default(), add_review: AddReviewState::default(), bulk_import: BulkImportState::default(), settings: SettingsState { api_url, focused: SettingsField::default() }, } } } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum Tab { #[default] Diary, AddReview, BulkImport, Settings } // ── Diary ───────────────────────────────────────────────────────────────────── #[derive(Debug, Default)] pub struct DiaryState { pub entries: Vec, pub selected: usize, pub offset: u32, pub total: u64, pub history: Option, pub delete_pending: Option, } // ── Add Review ──────────────────────────────────────────────────────────────── #[derive(Debug)] pub struct AddReviewState { pub external_id: String, pub title: String, pub year: String, pub rating: u8, pub watched_at: String, pub comment: String, pub focused: AddReviewField, } impl Default for AddReviewState { fn default() -> Self { Self { external_id: String::new(), title: String::new(), year: String::new(), rating: 5, watched_at: String::new(), comment: String::new(), focused: AddReviewField::ExternalId, } } } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum AddReviewField { #[default] ExternalId, Title, Year, Rating, WatchedAt, Comment, Submit, } // ── Bulk Import ─────────────────────────────────────────────────────────────── #[derive(Debug, Default)] pub struct BulkImportState { pub file_path: String, pub stage: BulkImportStage, pub parsed: Vec, pub valid_requests: Vec, // None = succeeded, Some(msg) = failed pub results: Vec>, } #[derive(Debug, Default, Clone, PartialEq, Eq)] pub enum BulkImportStage { #[default] EnterPath, Preview, Importing { done: usize }, Done, } #[derive(Debug, Clone)] pub struct ParsedRow { pub row: usize, pub result: Result, } // ── Settings ────────────────────────────────────────────────────────────────── #[derive(Debug)] pub struct SettingsState { pub api_url: String, pub focused: SettingsField, } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum SettingsField { #[default] ApiUrl, Save, Logout } // ── Status bar ──────────────────────────────────────────────────────────────── #[derive(Debug, Clone)] pub struct StatusMsg { pub text: String, pub is_error: bool, } // ── Top-level App ───────────────────────────────────────────────────────────── #[derive(Debug)] pub struct App { pub screen: Screen, pub token: Option, pub loading: bool, pub status: Option, pub api_url: String, } impl App { pub fn new(config: Option, token: Option) -> Self { let api_url = config.as_ref().map(|c| c.api_url.clone()).unwrap_or_default(); let screen = match &config { None => Screen::Setup(SetupState::default()), Some(_) if token.is_none() => Screen::Login(LoginState::default()), Some(c) => Screen::Main(MainState::new(c.api_url.clone())), }; Self { screen, token, loading: false, status: None, api_url } } } // ── Action & Command (stubs; logic added in Task 5) ─────────────────────────── #[derive(Debug)] pub enum Action { Quit, Escape, TabSelect(Tab), TabNext, TabPrev, SetupSubmit, InputChar(char), Backspace, FocusNext, FocusPrev, LoginSubmit, ScrollDown, ScrollUp, OpenHistory, LoadMore, DeleteInit, DeleteConfirm, DeleteCancel, RatingUp, RatingDown, ReviewSubmit, BulkParseFile, BulkImportAll, BulkCancel, SettingsSave, SettingsLogout, // async results AuthOk(String), AuthFail(String), DiaryLoaded { entries: Vec, total: u64 }, DiaryLoadFailed(String), HistoryLoaded(ReviewHistoryResponse), HistoryLoadFailed(String), ReviewCreated, ReviewCreateFailed(String), ReviewDeleted(Uuid), ReviewDeleteFailed(String), BulkItemDone { index: usize, error: Option }, } #[derive(Debug)] pub enum Command { Login { email: String, password: String }, LoadDiary { offset: u32 }, LoadHistory { movie_id: Uuid }, CreateReview(LogReviewRequest), DeleteReview(Uuid), ImportNext(usize), SaveConfig(String), SaveToken(String), ClearToken, } pub fn parse_csv(content: &str) -> Vec { let mut rdr = csv::Reader::from_reader(content.as_bytes()); let mut rows = Vec::new(); for (i, result) in rdr.records().enumerate() { let row_num = i + 2; // 1-indexed, header is row 1 let record = match result { Ok(r) => r, Err(e) => { rows.push(ParsedRow { row: row_num, result: Err(e.to_string()) }); continue; } }; let title = record.get(0).unwrap_or("").trim().to_string(); let year_str = record.get(1).unwrap_or("").trim().to_string(); let external_id = record.get(2).unwrap_or("").trim().to_string(); let rating_str = record.get(3).unwrap_or("").trim().to_string(); let watched_at = record.get(4).unwrap_or("").trim().to_string(); let comment = record.get(5).unwrap_or("").trim().to_string(); if title.is_empty() && external_id.is_empty() { rows.push(ParsedRow { row: row_num, result: Err("title or external_id required".into()) }); continue; } let rating: u8 = match rating_str.trim().parse::() { Ok(r) if r <= 5 => r, Ok(_) => { rows.push(ParsedRow { row: row_num, result: Err(format!("rating must be 0-5, got {rating_str}")) }); continue; } Err(_) => { rows.push(ParsedRow { row: row_num, result: Err(format!("invalid rating: {rating_str}")) }); continue; } }; if watched_at.is_empty() { rows.push(ParsedRow { row: row_num, result: Err("watched_at required".into()) }); continue; } let manual_release_year: Option = if year_str.is_empty() { None } else { match year_str.parse() { Ok(y) => Some(y), Err(_) => { rows.push(ParsedRow { row: row_num, result: Err(format!("invalid year: {year_str}")) }); continue; } } }; rows.push(ParsedRow { row: row_num, result: Ok(LogReviewRequest { external_metadata_id: if external_id.is_empty() { None } else { Some(external_id) }, manual_title: if title.is_empty() { None } else { Some(title) }, manual_release_year, rating, comment: if comment.is_empty() { None } else { Some(comment) }, watched_at, }), }); } rows } pub fn update(app: &mut App, action: Action) -> Vec { match action { // ── Global ─────────────────────────────────────────────────────────── Action::Quit => vec![], Action::TabSelect(tab) => { if let Screen::Main(m) = &mut app.screen { m.tab = tab; } vec![] } Action::TabNext => { if let Screen::Main(m) = &mut app.screen { m.tab = match m.tab { Tab::Diary => Tab::AddReview, Tab::AddReview => Tab::BulkImport, Tab::BulkImport => Tab::Settings, Tab::Settings => Tab::Diary }; } vec![] } Action::TabPrev => { if let Screen::Main(m) = &mut app.screen { m.tab = match m.tab { Tab::Diary => Tab::Settings, Tab::AddReview => Tab::Diary, Tab::BulkImport => Tab::AddReview, Tab::Settings => Tab::BulkImport }; } vec![] } Action::Escape => { if let Screen::Main(m) = &mut app.screen { match m.tab { Tab::Diary => { if m.diary.delete_pending.is_some() { m.diary.delete_pending = None; } else { m.diary.history = None; } } Tab::BulkImport => { if matches!(m.bulk_import.stage, BulkImportStage::Preview | BulkImportStage::Done) { m.bulk_import.stage = BulkImportStage::EnterPath; } } _ => {} } } vec![] } // ── Shared text input ──────────────────────────────────────────────── Action::InputChar(c) => { match &mut app.screen { Screen::Setup(s) => s.api_url.push(c), Screen::Login(s) => match s.focused { LoginField::Email => s.email.push(c), LoginField::Password => s.password.push(c), }, Screen::Main(m) => match m.tab { Tab::AddReview => match m.add_review.focused { AddReviewField::ExternalId => m.add_review.external_id.push(c), AddReviewField::Title => m.add_review.title.push(c), AddReviewField::Year => m.add_review.year.push(c), AddReviewField::WatchedAt => m.add_review.watched_at.push(c), AddReviewField::Comment => m.add_review.comment.push(c), _ => {} }, Tab::BulkImport if matches!(m.bulk_import.stage, BulkImportStage::EnterPath) => { m.bulk_import.file_path.push(c); } Tab::Settings if matches!(m.settings.focused, SettingsField::ApiUrl) => { m.settings.api_url.push(c); } _ => {} }, } vec![] } Action::Backspace => { match &mut app.screen { Screen::Setup(s) => { s.api_url.pop(); } Screen::Login(s) => match s.focused { LoginField::Email => { s.email.pop(); } LoginField::Password => { s.password.pop(); } }, Screen::Main(m) => match m.tab { Tab::AddReview => match m.add_review.focused { AddReviewField::ExternalId => { m.add_review.external_id.pop(); } AddReviewField::Title => { m.add_review.title.pop(); } AddReviewField::Year => { m.add_review.year.pop(); } AddReviewField::WatchedAt => { m.add_review.watched_at.pop(); } AddReviewField::Comment => { m.add_review.comment.pop(); } _ => {} }, Tab::BulkImport if matches!(m.bulk_import.stage, BulkImportStage::EnterPath) => { m.bulk_import.file_path.pop(); } Tab::Settings if matches!(m.settings.focused, SettingsField::ApiUrl) => { m.settings.api_url.pop(); } _ => {} }, } vec![] } Action::FocusNext => { match &mut app.screen { Screen::Login(s) => { s.focused = if s.focused == LoginField::Email { LoginField::Password } else { LoginField::Email }; } Screen::Main(m) => match m.tab { Tab::AddReview => { m.add_review.focused = match m.add_review.focused { AddReviewField::ExternalId => AddReviewField::Title, AddReviewField::Title => AddReviewField::Year, AddReviewField::Year => AddReviewField::Rating, AddReviewField::Rating => AddReviewField::WatchedAt, AddReviewField::WatchedAt => AddReviewField::Comment, AddReviewField::Comment => AddReviewField::Submit, AddReviewField::Submit => AddReviewField::ExternalId, }; } Tab::Settings => { m.settings.focused = match m.settings.focused { SettingsField::ApiUrl => SettingsField::Save, SettingsField::Save => SettingsField::Logout, SettingsField::Logout => SettingsField::ApiUrl, }; } _ => {} }, _ => {} } vec![] } Action::FocusPrev => { match &mut app.screen { Screen::Login(s) => { s.focused = if s.focused == LoginField::Password { LoginField::Email } else { LoginField::Password }; } Screen::Main(m) => match m.tab { Tab::AddReview => { m.add_review.focused = match m.add_review.focused { AddReviewField::ExternalId => AddReviewField::Submit, AddReviewField::Title => AddReviewField::ExternalId, AddReviewField::Year => AddReviewField::Title, AddReviewField::Rating => AddReviewField::Year, AddReviewField::WatchedAt => AddReviewField::Rating, AddReviewField::Comment => AddReviewField::WatchedAt, AddReviewField::Submit => AddReviewField::Comment, }; } Tab::Settings => { m.settings.focused = match m.settings.focused { SettingsField::ApiUrl => SettingsField::Logout, SettingsField::Save => SettingsField::ApiUrl, SettingsField::Logout => SettingsField::Save, }; } _ => {} }, _ => {} } vec![] } // ── Setup ───────────────────────────────────────────────────────────── Action::SetupSubmit => { if let Screen::Setup(s) = &mut app.screen { let url = s.api_url.trim().to_string(); if url.is_empty() { s.error = Some("URL required".into()); return vec![]; } app.api_url = url.clone(); app.screen = Screen::Login(LoginState::default()); return vec![Command::SaveConfig(url)]; } vec![] } // ── Login ───────────────────────────────────────────────────────────── Action::LoginSubmit => { if let Screen::Login(s) = &app.screen { if s.email.is_empty() || s.password.is_empty() { app.status = Some(StatusMsg { text: "Email and password required".into(), is_error: true }); return vec![]; } let email = s.email.clone(); let password = s.password.clone(); app.loading = true; return vec![Command::Login { email, password }]; } vec![] } Action::AuthOk(token) => { app.loading = false; app.status = None; app.screen = Screen::Main(MainState::new(app.api_url.clone())); let cmds = vec![Command::SaveToken(token.clone()), Command::LoadDiary { offset: 0 }]; app.token = Some(token); cmds } Action::AuthFail(msg) => { app.loading = false; app.status = Some(StatusMsg { text: msg, is_error: true }); vec![] } // ── Diary ───────────────────────────────────────────────────────────── Action::ScrollDown => { if let Screen::Main(m) = &mut app.screen { let len = m.diary.entries.len(); if len > 0 && m.diary.selected < len - 1 { m.diary.selected += 1; m.diary.history = None; } } vec![] } Action::ScrollUp => { if let Screen::Main(m) = &mut app.screen { if m.diary.selected > 0 { m.diary.selected -= 1; m.diary.history = None; } } vec![] } Action::OpenHistory => { if let Screen::Main(m) = &mut app.screen { if let Some(entry) = m.diary.entries.get(m.diary.selected) { let movie_id = entry.movie.id; app.loading = true; return vec![Command::LoadHistory { movie_id }]; } } vec![] } Action::LoadMore => { if let Screen::Main(m) = &mut app.screen { let next = m.diary.offset + 20; if (next as u64) < m.diary.total { m.diary.offset = next; return vec![Command::LoadDiary { offset: next }]; } } vec![] } Action::DiaryLoaded { entries, total } => { app.loading = false; if let Screen::Main(m) = &mut app.screen { m.diary.total = total; m.diary.entries = entries; m.diary.selected = 0; } vec![] } Action::DiaryLoadFailed(msg) => { app.loading = false; if msg.contains("unauthorized") || msg.contains("Unauthorized") { app.token = None; app.screen = Screen::Login(LoginState::default()); app.status = Some(StatusMsg { text: "Session expired. Please log in again.".into(), is_error: true }); return vec![Command::ClearToken]; } app.status = Some(StatusMsg { text: msg, is_error: true }); vec![] } Action::HistoryLoaded(h) => { app.loading = false; if let Screen::Main(m) = &mut app.screen { m.diary.history = Some(h); } vec![] } Action::HistoryLoadFailed(msg) => { app.loading = false; app.status = Some(StatusMsg { text: msg, is_error: true }); vec![] } Action::DeleteInit => { if let Screen::Main(m) = &mut app.screen { if let Some(entry) = m.diary.entries.get(m.diary.selected) { m.diary.delete_pending = Some(entry.review.id); } } vec![] } Action::DeleteConfirm => { if let Screen::Main(m) = &mut app.screen { if let Some(review_id) = m.diary.delete_pending.take() { return vec![Command::DeleteReview(review_id)]; } } vec![] } Action::DeleteCancel => { if let Screen::Main(m) = &mut app.screen { m.diary.delete_pending = None; } vec![] } Action::ReviewDeleted(id) => { if let Screen::Main(m) = &mut app.screen { m.diary.entries.retain(|e| e.review.id != id); m.diary.total = m.diary.total.saturating_sub(1); if m.diary.selected >= m.diary.entries.len() { m.diary.selected = m.diary.entries.len().saturating_sub(1); } m.diary.history = None; } app.status = Some(StatusMsg { text: "Review deleted".into(), is_error: false }); vec![] } Action::ReviewDeleteFailed(msg) => { app.status = Some(StatusMsg { text: msg, is_error: true }); vec![] } // ── Add Review ──────────────────────────────────────────────────────── Action::RatingUp => { if let Screen::Main(m) = &mut app.screen { if m.add_review.rating < 5 { m.add_review.rating += 1; } } vec![] } Action::RatingDown => { if let Screen::Main(m) = &mut app.screen { if m.add_review.rating > 0 { m.add_review.rating -= 1; } } vec![] } Action::ReviewSubmit => { if let Screen::Main(m) = &app.screen { if m.tab == Tab::AddReview { let f = &m.add_review; let has_ext = !f.external_id.is_empty(); let has_title = !f.title.is_empty(); let has_watched = !f.watched_at.is_empty(); let ext_id = if has_ext { Some(f.external_id.clone()) } else { None }; let title = if has_title { Some(f.title.clone()) } else { None }; let year: Option = f.year.parse().ok(); let rating = f.rating; let comment = if f.comment.is_empty() { None } else { Some(f.comment.clone()) }; let watched_at = f.watched_at.clone(); if !has_ext && !has_title { app.status = Some(StatusMsg { text: "Title or external ID required".into(), is_error: true }); return vec![]; } if !has_watched { app.status = Some(StatusMsg { text: "Watched-at date required".into(), is_error: true }); return vec![]; } let req = LogReviewRequest { external_metadata_id: ext_id, manual_title: title, manual_release_year: year, rating, comment, watched_at, }; app.loading = true; return vec![Command::CreateReview(req)]; } } vec![] } Action::ReviewCreated => { app.loading = false; app.status = Some(StatusMsg { text: "Review added!".into(), is_error: false }); if let Screen::Main(m) = &mut app.screen { m.add_review = AddReviewState::default(); } vec![] } Action::ReviewCreateFailed(msg) => { app.loading = false; app.status = Some(StatusMsg { text: msg, is_error: true }); vec![] } // ── Bulk Import ─────────────────────────────────────────────────────── Action::BulkParseFile => { if let Screen::Main(m) = &mut app.screen { if m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::EnterPath { let path = m.bulk_import.file_path.trim().to_string(); match std::fs::read_to_string(&path) { Ok(content) => { m.bulk_import.parsed = parse_csv(&content); m.bulk_import.stage = BulkImportStage::Preview; } Err(e) => { app.status = Some(StatusMsg { text: format!("Cannot read file: {e}"), is_error: true }); } } } } vec![] } Action::BulkImportAll => { if let Screen::Main(m) = &mut app.screen { if m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::Preview { let valid: Vec = m.bulk_import.parsed.iter() .filter_map(|r| r.result.as_ref().ok().cloned()) .collect(); if valid.is_empty() { app.status = Some(StatusMsg { text: "No valid rows to import".into(), is_error: true }); return vec![]; } m.bulk_import.results = vec![None; valid.len()]; m.bulk_import.valid_requests = valid; m.bulk_import.stage = BulkImportStage::Importing { done: 0 }; return vec![Command::ImportNext(0)]; } } vec![] } Action::BulkCancel => { if let Screen::Main(m) = &mut app.screen { m.bulk_import = BulkImportState::default(); } vec![] } Action::BulkItemDone { index, error } => { if let Screen::Main(m) = &mut app.screen { if index >= m.bulk_import.results.len() { app.status = Some(StatusMsg { text: format!("Import error: unexpected index {index}"), is_error: true }); m.bulk_import.stage = BulkImportStage::Done; return vec![]; } m.bulk_import.results[index] = error; let done = index + 1; let total = m.bulk_import.valid_requests.len(); if done < total { m.bulk_import.stage = BulkImportStage::Importing { done }; return vec![Command::ImportNext(done)]; } else { let failed = m.bulk_import.results.iter().filter(|r| r.is_some()).count(); m.bulk_import.stage = BulkImportStage::Done; app.status = Some(StatusMsg { text: format!("Import done: {} ok, {} failed", total - failed, failed), is_error: failed > 0, }); } } vec![] } // ── Settings ────────────────────────────────────────────────────────── Action::SettingsSave => { if let Screen::Main(m) = &app.screen { let url = m.settings.api_url.trim().to_string(); if url.is_empty() { app.status = Some(StatusMsg { text: "URL required".into(), is_error: true }); return vec![]; } app.status = Some(StatusMsg { text: "Settings saved".into(), is_error: false }); app.api_url = url.clone(); return vec![Command::SaveConfig(url)]; } vec![] } Action::SettingsLogout => { app.token = None; app.screen = Screen::Login(LoginState::default()); app.status = None; vec![Command::ClearToken] } } } #[cfg(test)] mod tests { use super::*; use crate::client::{DiaryEntryDto, MovieDto, ReviewDto}; use uuid::Uuid; fn setup_app() -> App { App { screen: Screen::Setup(SetupState { api_url: String::new(), error: None }), token: None, loading: false, status: None, api_url: String::new(), } } fn login_app() -> App { App { screen: Screen::Login(LoginState::default()), token: None, loading: false, status: None, api_url: String::new(), } } fn main_app() -> App { App { screen: Screen::Main(MainState::new("http://localhost:3000".into())), token: Some("tok".into()), loading: false, status: None, api_url: "http://localhost:3000".into(), } } fn diary_entry() -> DiaryEntryDto { DiaryEntryDto { movie: MovieDto { id: Uuid::new_v4(), title: "The Matrix".into(), release_year: 1999, director: None }, review: ReviewDto { id: Uuid::new_v4(), rating: 5, comment: None, watched_at: "1999-03-31T00:00:00".into() }, } } // ── Setup screen ────────────────────────────────────────────────────────── #[test] fn setup_input_char_appends_to_api_url() { let mut app = setup_app(); update(&mut app, Action::InputChar('h')); update(&mut app, Action::InputChar('i')); if let Screen::Setup(s) = &app.screen { assert_eq!(s.api_url, "hi"); } else { panic!("expected Setup"); } } #[test] fn setup_submit_with_empty_url_sets_error() { let mut app = setup_app(); let cmds = update(&mut app, Action::SetupSubmit); assert!(cmds.is_empty()); if let Screen::Setup(s) = &app.screen { assert!(s.error.is_some()); } else { panic!("expected Setup"); } } #[test] fn setup_submit_with_url_saves_config_and_transitions_to_login() { let mut app = setup_app(); update(&mut app, Action::InputChar('h')); let cmds = update(&mut app, Action::SetupSubmit); assert!(cmds.iter().any(|c| matches!(c, Command::SaveConfig(_)))); assert!(matches!(app.screen, Screen::Login(_))); } // ── Login screen ────────────────────────────────────────────────────────── #[test] fn login_input_char_goes_to_email_by_default() { let mut app = login_app(); update(&mut app, Action::InputChar('a')); if let Screen::Login(s) = &app.screen { assert_eq!(s.email, "a"); assert_eq!(s.password, ""); } else { panic!(); } } #[test] fn login_focus_next_moves_to_password() { let mut app = login_app(); update(&mut app, Action::FocusNext); if let Screen::Login(s) = &app.screen { assert_eq!(s.focused, LoginField::Password); } else { panic!(); } } #[test] fn login_input_after_focus_goes_to_password() { let mut app = login_app(); update(&mut app, Action::FocusNext); update(&mut app, Action::InputChar('x')); if let Screen::Login(s) = &app.screen { assert_eq!(s.password, "x"); } else { panic!(); } } #[test] fn login_submit_returns_login_command_and_sets_loading() { let mut app = login_app(); for c in "user@example.com".chars() { update(&mut app, Action::InputChar(c)); } update(&mut app, Action::FocusNext); for c in "pass123".chars() { update(&mut app, Action::InputChar(c)); } let cmds = update(&mut app, Action::LoginSubmit); assert!(cmds.iter().any(|c| matches!(c, Command::Login { .. }))); assert!(app.loading); } #[test] fn login_submit_with_empty_fields_sets_error_status() { let mut app = login_app(); let cmds = update(&mut app, Action::LoginSubmit); assert!(cmds.is_empty()); assert!(app.status.as_ref().map_or(false, |s| s.is_error)); } #[test] fn auth_ok_sets_token_and_transitions_to_main() { let mut app = login_app(); let cmds = update(&mut app, Action::AuthOk("jwt-token".into())); assert_eq!(app.token, Some("jwt-token".into())); assert!(matches!(app.screen, Screen::Main(_))); assert!(!app.loading); assert!(cmds.iter().any(|c| matches!(c, Command::SaveToken(_)))); assert!(cmds.iter().any(|c| matches!(c, Command::LoadDiary { .. }))); } #[test] fn auth_fail_sets_error_status_and_clears_loading() { let mut app = login_app(); app.loading = true; update(&mut app, Action::AuthFail("bad creds".into())); assert!(!app.loading); assert!(app.status.as_ref().map_or(false, |s| s.is_error)); } // ── Diary ───────────────────────────────────────────────────────────────── #[test] fn diary_scroll_down_increments_selected() { let mut app = main_app(); update(&mut app, Action::DiaryLoaded { entries: vec![diary_entry(), diary_entry(), diary_entry()], total: 3, }); update(&mut app, Action::ScrollDown); if let Screen::Main(m) = &app.screen { assert_eq!(m.diary.selected, 1); } else { panic!(); } } #[test] fn diary_scroll_up_clamps_at_zero() { let mut app = main_app(); update(&mut app, Action::DiaryLoaded { entries: vec![diary_entry()], total: 1 }); update(&mut app, Action::ScrollUp); if let Screen::Main(m) = &app.screen { assert_eq!(m.diary.selected, 0); } else { panic!(); } } #[test] fn diary_scroll_down_clamps_at_last_entry() { let mut app = main_app(); update(&mut app, Action::DiaryLoaded { entries: vec![diary_entry()], total: 1 }); update(&mut app, Action::ScrollDown); if let Screen::Main(m) = &app.screen { assert_eq!(m.diary.selected, 0); } else { panic!(); } } #[test] fn delete_init_sets_delete_pending() { let mut app = main_app(); let entry = diary_entry(); let review_id = entry.review.id; update(&mut app, Action::DiaryLoaded { entries: vec![entry], total: 1 }); update(&mut app, Action::DeleteInit); if let Screen::Main(m) = &app.screen { assert_eq!(m.diary.delete_pending, Some(review_id)); } else { panic!(); } } #[test] fn delete_confirm_returns_delete_command() { let mut app = main_app(); let entry = diary_entry(); let review_id = entry.review.id; update(&mut app, Action::DiaryLoaded { entries: vec![entry], total: 1 }); update(&mut app, Action::DeleteInit); let cmds = update(&mut app, Action::DeleteConfirm); assert!(cmds.iter().any(|c| matches!(c, Command::DeleteReview(id) if *id == review_id))); } #[test] fn delete_cancel_clears_pending() { let mut app = main_app(); let entry = diary_entry(); update(&mut app, Action::DiaryLoaded { entries: vec![entry], total: 1 }); update(&mut app, Action::DeleteInit); update(&mut app, Action::DeleteCancel); if let Screen::Main(m) = &app.screen { assert!(m.diary.delete_pending.is_none()); } else { panic!(); } } #[test] fn review_deleted_removes_entry_from_list() { let mut app = main_app(); let entry = diary_entry(); let review_id = entry.review.id; update(&mut app, Action::DiaryLoaded { entries: vec![entry], total: 1 }); update(&mut app, Action::ReviewDeleted(review_id)); if let Screen::Main(m) = &app.screen { assert!(m.diary.entries.is_empty()); assert_eq!(m.diary.total, 0); } else { panic!(); } } // ── Add Review ──────────────────────────────────────────────────────────── #[test] fn rating_up_increments_rating() { let mut app = main_app(); if let Screen::Main(m) = &mut app.screen { m.tab = Tab::AddReview; m.add_review.rating = 3; } update(&mut app, Action::RatingUp); if let Screen::Main(m) = &app.screen { assert_eq!(m.add_review.rating, 4); } } #[test] fn rating_clamps_at_5() { let mut app = main_app(); if let Screen::Main(m) = &mut app.screen { m.tab = Tab::AddReview; m.add_review.rating = 5; } update(&mut app, Action::RatingUp); if let Screen::Main(m) = &app.screen { assert_eq!(m.add_review.rating, 5); } } #[test] fn review_submit_returns_create_review_command() { let mut app = main_app(); if let Screen::Main(m) = &mut app.screen { m.tab = Tab::AddReview; m.add_review.title = "The Matrix".into(); m.add_review.watched_at = "1999-03-31T00:00:00".into(); m.add_review.rating = 5; } let cmds = update(&mut app, Action::ReviewSubmit); assert!(cmds.iter().any(|c| matches!(c, Command::CreateReview(_)))); } #[test] fn review_submit_with_missing_title_and_id_sets_error() { let mut app = main_app(); if let Screen::Main(m) = &mut app.screen { m.tab = Tab::AddReview; m.add_review.watched_at = "1999-03-31T00:00:00".into(); } let cmds = update(&mut app, Action::ReviewSubmit); assert!(cmds.is_empty()); assert!(app.status.as_ref().map_or(false, |s| s.is_error)); } // ── Bulk Import ─────────────────────────────────────────────────────────── #[test] fn bulk_import_all_with_valid_rows_returns_import_next_command() { let mut app = main_app(); if let Screen::Main(m) = &mut app.screen { m.tab = Tab::BulkImport; m.bulk_import.stage = BulkImportStage::Preview; m.bulk_import.parsed = vec![ ParsedRow { row: 2, result: Ok(LogReviewRequest { external_metadata_id: None, manual_title: Some("The Matrix".into()), manual_release_year: None, rating: 5, comment: None, watched_at: "1999-03-31T00:00:00".into(), }), }, ]; } let cmds = update(&mut app, Action::BulkImportAll); assert!(cmds.iter().any(|c| matches!(c, Command::ImportNext(0)))); } #[test] fn bulk_item_done_advances_stage_and_returns_next_command() { let mut app = main_app(); if let Screen::Main(m) = &mut app.screen { m.tab = Tab::BulkImport; m.bulk_import.stage = BulkImportStage::Importing { done: 0 }; m.bulk_import.valid_requests = vec![ LogReviewRequest { external_metadata_id: None, manual_title: Some("A".into()), manual_release_year: None, rating: 5, comment: None, watched_at: "2024-01-01T00:00:00".into() }, LogReviewRequest { external_metadata_id: None, manual_title: Some("B".into()), manual_release_year: None, rating: 4, comment: None, watched_at: "2024-01-02T00:00:00".into() }, ]; m.bulk_import.results = vec![None, None]; } let cmds = update(&mut app, Action::BulkItemDone { index: 0, error: None }); assert!(cmds.iter().any(|c| matches!(c, Command::ImportNext(1)))); } #[test] fn bulk_item_done_last_item_transitions_to_done() { let mut app = main_app(); if let Screen::Main(m) = &mut app.screen { m.tab = Tab::BulkImport; m.bulk_import.stage = BulkImportStage::Importing { done: 0 }; m.bulk_import.valid_requests = vec![ LogReviewRequest { external_metadata_id: None, manual_title: Some("A".into()), manual_release_year: None, rating: 5, comment: None, watched_at: "2024-01-01T00:00:00".into() }, ]; m.bulk_import.results = vec![None]; } let cmds = update(&mut app, Action::BulkItemDone { index: 0, error: None }); assert!(cmds.is_empty()); if let Screen::Main(m) = &app.screen { assert!(matches!(m.bulk_import.stage, BulkImportStage::Done)); } assert!(app.status.is_some()); } // ── Settings ────────────────────────────────────────────────────────────── #[test] fn settings_save_returns_save_config_command() { let mut app = main_app(); if let Screen::Main(m) = &mut app.screen { m.tab = Tab::Settings; m.settings.api_url = "http://new-server:8080".into(); } let cmds = update(&mut app, Action::SettingsSave); assert!(cmds.iter().any(|c| matches!(c, Command::SaveConfig(url) if url.contains("8080")))); } #[test] fn settings_logout_clears_token_and_goes_to_login() { let mut app = main_app(); let cmds = update(&mut app, Action::SettingsLogout); assert!(app.token.is_none()); assert!(matches!(app.screen, Screen::Login(_))); assert!(cmds.iter().any(|c| matches!(c, Command::ClearToken))); } #[test] fn auth_ok_uses_app_api_url_for_main_state() { let mut app = login_app(); app.api_url = "http://test-server:9000".into(); update(&mut app, Action::AuthOk("tok".into())); if let Screen::Main(m) = &app.screen { assert_eq!(m.settings.api_url, "http://test-server:9000"); } else { panic!("expected Main"); } } // ── parse_csv ───────────────────────────────────────────────────────────── #[test] fn parse_csv_valid_row_with_title() { let csv = "title,year,external_id,rating,watched_at,comment\nThe Matrix,1999,,5,1999-03-31T00:00:00,\n"; let rows = parse_csv(csv); assert_eq!(rows.len(), 1); assert!(rows[0].result.is_ok()); let req = rows[0].result.as_ref().unwrap(); assert_eq!(req.manual_title.as_deref(), Some("The Matrix")); assert_eq!(req.rating, 5); } #[test] fn parse_csv_row_missing_title_and_id_is_error() { let csv = "title,year,external_id,rating,watched_at,comment\n,,,5,2024-01-01T00:00:00,\n"; let rows = parse_csv(csv); assert_eq!(rows.len(), 1); assert!(rows[0].result.is_err()); } #[test] fn parse_csv_invalid_rating_is_error() { let csv = "title,year,external_id,rating,watched_at,comment\nThe Matrix,,,9,2024-01-01T00:00:00,\n"; let rows = parse_csv(csv); assert!(rows[0].result.is_err()); } #[test] fn parse_csv_with_external_id_only() { let csv = "title,year,external_id,rating,watched_at,comment\n,,tt0133093,5,1999-03-31T00:00:00,\n"; let rows = parse_csv(csv); assert!(rows[0].result.is_ok()); let req = rows[0].result.as_ref().unwrap(); assert_eq!(req.external_metadata_id.as_deref(), Some("tt0133093")); assert!(req.manual_title.is_none()); } #[test] fn parse_csv_rating_zero_is_valid() { let csv = "title,year,external_id,rating,watched_at,comment\nThe Matrix,,,0,2024-01-01T00:00:00,\n"; let rows = parse_csv(csv); assert_eq!(rows.len(), 1); assert!(rows[0].result.is_ok()); let req = rows[0].result.as_ref().unwrap(); assert_eq!(req.rating, 0); } }