refactor: move inline tests to separate files via #[path]

This commit is contained in:
2026-05-12 16:39:58 +02:00
parent 00218366da
commit 763d622601
58 changed files with 3267 additions and 3267 deletions

View File

@@ -993,547 +993,5 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
}
#[cfg(test)]
mod tests {
use super::*;
use api_types::{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,
poster_path: 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,
manual_director: 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,
manual_director: 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,
manual_director: 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,
manual_director: 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 ─────────────────────────────────────────────────────────────
// CSV column order matches the export format:
// title,year,director,rating,comment,watched_at,external_metadata_id
#[test]
fn parse_csv_valid_row_with_title() {
let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\nThe Matrix,1999,Wachowski,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.manual_director.as_deref(), Some("Wachowski"));
assert_eq!(req.rating, 5);
}
#[test]
fn parse_csv_row_missing_title_and_id_is_error() {
let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\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,director,rating,comment,watched_at,external_metadata_id\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,director,rating,comment,watched_at,external_metadata_id\n,,,5,,1999-03-31T00:00:00,tt0133093\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,director,rating,comment,watched_at,external_metadata_id\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);
}
}
#[path = "tests/app.rs"]
mod tests;

View File

@@ -235,70 +235,5 @@ impl ApiClient {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apierror_unauthorized_display() {
let err = ApiError::Unauthorized;
assert!(matches!(err, ApiError::Unauthorized));
assert_eq!(err.to_string(), "unauthorized");
}
#[test]
fn apierror_validation_display() {
let err = ApiError::Validation("rating must be 0-5".into());
assert!(err.to_string().contains("validation error"));
}
#[test]
fn log_review_request_skips_none_fields() {
let req = LogReviewRequest {
external_metadata_id: None,
manual_title: Some("The Matrix".into()),
manual_release_year: None,
manual_director: None,
rating: 5,
comment: None,
watched_at: "2024-01-15T20:00:00".into(),
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("external_metadata_id"));
assert!(!json.contains("manual_release_year"));
assert!(!json.contains("manual_director"));
assert!(json.contains("\"manual_title\":\"The Matrix\""));
assert!(json.contains("\"rating\":5"));
}
#[test]
fn log_review_request_includes_director_when_set() {
let req = LogReviewRequest {
external_metadata_id: None,
manual_title: Some("Dune".into()),
manual_release_year: Some(2021),
manual_director: Some("Denis Villeneuve".into()),
rating: 5,
comment: None,
watched_at: "2024-01-15T20:00:00".into(),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"manual_director\":\"Denis Villeneuve\""));
}
#[test]
fn api_client_builds_versioned_urls() {
let client = ApiClient::new("http://localhost:3000");
assert_eq!(client.api("/diary"), "http://localhost:3000/api/v1/diary");
assert_eq!(client.api("/auth/login"), "http://localhost:3000/api/v1/auth/login");
assert_eq!(client.api("/social/follow"), "http://localhost:3000/api/v1/social/follow");
}
#[test]
fn api_client_update_url() {
let client = ApiClient::new("http://localhost:3000");
assert!(client.url().contains("3000"));
client.update_url("http://localhost:8080");
assert!(client.url().contains("8080"));
assert_eq!(client.api("/diary"), "http://localhost:8080/api/v1/diary");
}
}
#[path = "tests/client.rs"]
mod tests;

View File

@@ -78,21 +78,5 @@ impl Config {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_roundtrip() {
let config = Config {
api_url: "http://localhost:3000".into(),
};
let json = serde_json::to_string(&config).unwrap();
let decoded: Config = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.api_url, "http://localhost:3000");
}
#[test]
fn load_returns_none_when_no_file() {
let _ = Config::load();
}
}
#[path = "tests/config.rs"]
mod tests;

542
crates/tui/src/tests/app.rs Normal file
View File

@@ -0,0 +1,542 @@
use super::*;
use api_types::{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,
poster_path: 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,
manual_director: 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,
manual_director: 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,
manual_director: 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,
manual_director: 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 ─────────────────────────────────────────────────────────────
// CSV column order matches the export format:
// title,year,director,rating,comment,watched_at,external_metadata_id
#[test]
fn parse_csv_valid_row_with_title() {
let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\nThe Matrix,1999,Wachowski,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.manual_director.as_deref(), Some("Wachowski"));
assert_eq!(req.rating, 5);
}
#[test]
fn parse_csv_row_missing_title_and_id_is_error() {
let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\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,director,rating,comment,watched_at,external_metadata_id\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,director,rating,comment,watched_at,external_metadata_id\n,,,5,,1999-03-31T00:00:00,tt0133093\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,director,rating,comment,watched_at,external_metadata_id\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);
}

View File

@@ -0,0 +1,65 @@
use super::*;
#[test]
fn apierror_unauthorized_display() {
let err = ApiError::Unauthorized;
assert!(matches!(err, ApiError::Unauthorized));
assert_eq!(err.to_string(), "unauthorized");
}
#[test]
fn apierror_validation_display() {
let err = ApiError::Validation("rating must be 0-5".into());
assert!(err.to_string().contains("validation error"));
}
#[test]
fn log_review_request_skips_none_fields() {
let req = LogReviewRequest {
external_metadata_id: None,
manual_title: Some("The Matrix".into()),
manual_release_year: None,
manual_director: None,
rating: 5,
comment: None,
watched_at: "2024-01-15T20:00:00".into(),
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("external_metadata_id"));
assert!(!json.contains("manual_release_year"));
assert!(!json.contains("manual_director"));
assert!(json.contains("\"manual_title\":\"The Matrix\""));
assert!(json.contains("\"rating\":5"));
}
#[test]
fn log_review_request_includes_director_when_set() {
let req = LogReviewRequest {
external_metadata_id: None,
manual_title: Some("Dune".into()),
manual_release_year: Some(2021),
manual_director: Some("Denis Villeneuve".into()),
rating: 5,
comment: None,
watched_at: "2024-01-15T20:00:00".into(),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"manual_director\":\"Denis Villeneuve\""));
}
#[test]
fn api_client_builds_versioned_urls() {
let client = ApiClient::new("http://localhost:3000");
assert_eq!(client.api("/diary"), "http://localhost:3000/api/v1/diary");
assert_eq!(client.api("/auth/login"), "http://localhost:3000/api/v1/auth/login");
assert_eq!(client.api("/social/follow"), "http://localhost:3000/api/v1/social/follow");
}
#[test]
fn api_client_update_url() {
let client = ApiClient::new("http://localhost:3000");
assert!(client.url().contains("3000"));
client.update_url("http://localhost:8080");
assert!(client.url().contains("8080"));
assert_eq!(client.api("/diary"), "http://localhost:8080/api/v1/diary");
}

View File

@@ -0,0 +1,16 @@
use super::*;
#[test]
fn config_roundtrip() {
let config = Config {
api_url: "http://localhost:3000".into(),
};
let json = serde_json::to_string(&config).unwrap();
let decoded: Config = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.api_url, "http://localhost:3000");
}
#[test]
fn load_returns_none_when_no_file() {
let _ = Config::load();
}