diff --git a/toki-tui/README.md b/toki-tui/README.md index 8a3ab317..c22b178a 100644 --- a/toki-tui/README.md +++ b/toki-tui/README.md @@ -70,6 +70,21 @@ git_default_prefix = "Development" task_filter = "+work" ``` +## Testing + +Run the TUI test suite with: + +```bash +cargo test -p toki-tui +``` + +The current tests focus on the most stable and useful layers first: + +- app and state behavior +- parsing and text input helpers +- runtime action handling with the dev backend +- focused Ratatui render assertions for important UI states + ## Standard key bindings | Key | Action | diff --git a/toki-tui/src/app/mod.rs b/toki-tui/src/app/mod.rs index 708e5702..4217a174 100644 --- a/toki-tui/src/app/mod.rs +++ b/toki-tui/src/app/mod.rs @@ -1070,3 +1070,177 @@ fn longest_common_prefix(strings: &[String]) -> String { prefix.into_iter().collect() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{activity, project, test_app, time_entry}; + + #[test] + fn start_timer_sets_running_state_and_shifts_focus() { + let mut app = test_app(); + app.focused_this_week_index = Some(2); + + app.start_timer(); + + assert_eq!(app.timer_state, TimerState::Running); + assert!(app.absolute_start.is_some()); + assert!(app.local_start.is_some()); + assert_eq!(app.focused_this_week_index, Some(3)); + } + + #[test] + fn stop_timer_clears_running_state() { + let mut app = test_app(); + app.focused_this_week_index = Some(2); + app.start_timer(); + + app.stop_timer(); + + assert_eq!(app.timer_state, TimerState::Stopped); + assert!(app.absolute_start.is_none()); + assert!(app.local_start.is_none()); + assert_eq!(app.focused_this_week_index, Some(3)); + } + + #[test] + fn clear_timer_resets_selected_fields_and_note() { + let mut app = test_app(); + app.timer_size = TimerSize::Large; + app.selected_project = Some(project("proj-1", "Project One")); + app.selected_activity = Some(activity("act-1", "proj-1", "Activity One")); + app.description_input = TextInput::from_str("Existing note"); + app.description_is_default = false; + + app.clear_timer(); + + assert_eq!(app.timer_state, TimerState::Stopped); + assert_eq!(app.timer_size, TimerSize::Large); + assert!(app.selected_project.is_none()); + assert!(app.selected_activity.is_none()); + assert_eq!(app.description_input, TextInput::new()); + assert!(app.description_is_default); + } + + #[test] + fn navigate_to_edit_description_clears_default_note() { + let mut app = test_app(); + app.description_input = TextInput::from_str("Prefill"); + app.description_is_default = true; + + app.navigate_to(View::EditDescription); + + assert_eq!(app.current_view, View::EditDescription); + assert!(app.editing_description); + assert_eq!(app.description_input, TextInput::new()); + assert!(!app.description_is_default); + } + + #[test] + fn select_save_action_by_number_ignores_unknown_values() { + let mut app = test_app(); + app.selected_save_action = SaveAction::ContinueSameProject; + + app.select_save_action_by_number(9); + + assert_eq!(app.selected_save_action, SaveAction::ContinueSameProject); + } + + #[test] + fn filter_projects_orders_best_match_first() { + let mut app = test_app(); + app.projects = vec![ + project("proj-1", "Backend Platform"), + project("proj-2", "Timer UI"), + project("proj-3", "Documentation"), + ]; + app.project_search_input = TextInput::from_str("tmr"); + + app.filter_projects(); + + assert_eq!( + app.filtered_projects.first().map(|p| p.name.as_str()), + Some("Timer UI") + ); + } + + #[test] + fn filter_activities_respects_selected_project() { + let mut app = test_app(); + app.selected_project = Some(project("proj-2", "Timer UI")); + app.activities = vec![ + activity("act-1", "proj-1", "Planning"), + activity("act-2", "proj-2", "Implementation"), + activity("act-3", "proj-2", "Testing"), + ]; + + app.filter_activities(); + + assert_eq!(app.filtered_activities.len(), 2); + assert!(app + .filtered_activities + .iter() + .all(|activity| activity.project_id == "proj-2")); + } + + #[test] + fn parse_task_export_rejects_invalid_utf8_or_json() { + let utf8_err = parse_task_export(&[0xff]).expect_err("invalid UTF-8 should fail"); + let json_err = parse_task_export(b"not-json").expect_err("invalid JSON should fail"); + + assert!(utf8_err.contains("UTF-8")); + assert!(json_err.contains("JSON")); + } + + #[test] + fn parse_task_export_sorts_by_urgency_desc() { + let output = br#"[ + {"id": 2, "description": "Lower", "urgency": 1.0}, + {"id": 1, "description": "Higher", "urgency": 9.5}, + {"id": 3, "description": "Medium", "urgency": 3.2} + ]"#; + + let tasks = parse_task_export(output).expect("valid export should parse"); + + let descriptions: Vec<&str> = tasks.iter().map(|task| task.description.as_str()).collect(); + assert_eq!(descriptions, vec!["Higher", "Medium", "Lower"]); + } + + #[test] + fn update_history_sorts_entries_newest_first() { + let mut app = test_app(); + let early = time_entry( + "reg-1", + "proj-1", + "Project One", + "act-1", + "Activity One", + "2026-03-01", + 1.0, + None, + None, + None, + ); + let late = time_entry( + "reg-2", + "proj-1", + "Project One", + "act-1", + "Activity One", + "2026-03-02", + 1.0, + None, + None, + None, + ); + + app.update_history(vec![early, late]); + + let ids: Vec<&str> = app + .time_entries + .iter() + .map(|entry| entry.registration_id.as_str()) + .collect(); + assert_eq!(ids, vec!["reg-2", "reg-1"]); + } +} diff --git a/toki-tui/src/app/state.rs b/toki-tui/src/app/state.rs index c5ab2eda..03e85dfc 100644 --- a/toki-tui/src/app/state.rs +++ b/toki-tui/src/app/state.rs @@ -285,3 +285,40 @@ pub enum MilltimeReauthField { } // Keep Instant re-exported so App struct can use it without needing to import state internals + +#[cfg(test)] +mod tests { + use super::TextInput; + + #[test] + fn text_input_inserts_and_backspaces_at_utf8_boundaries() { + let mut input = TextInput::from_str("a"); + input.insert('e'); + input.insert('\u{301}'); + + assert_eq!(input.value, "ae\u{301}"); + + input.backspace(); + assert_eq!(input.value, "ae"); + + input.backspace(); + assert_eq!(input.value, "a"); + } + + #[test] + fn text_input_moves_cursor_left_and_right_by_char() { + let mut input = TextInput::from_str("a😀b"); + + input.move_left(); + assert_eq!(input.cursor, "a😀".len()); + + input.move_left(); + assert_eq!(input.cursor, "a".len()); + + input.move_right(); + assert_eq!(input.cursor, "a😀".len()); + + input.move_right(); + assert_eq!(input.cursor, "a😀b".len()); + } +} diff --git a/toki-tui/src/main.rs b/toki-tui/src/main.rs index 24cf981d..a30a465c 100644 --- a/toki-tui/src/main.rs +++ b/toki-tui/src/main.rs @@ -8,6 +8,8 @@ mod login; mod runtime; mod session_store; mod terminal; +#[cfg(test)] +mod test_support; mod time_utils; mod types; mod ui; diff --git a/toki-tui/src/runtime/actions.rs b/toki-tui/src/runtime/actions.rs index 2994f8d5..2df9f5b5 100644 --- a/toki-tui/src/runtime/actions.rs +++ b/toki-tui/src/runtime/actions.rs @@ -780,3 +780,106 @@ pub(super) fn is_milltime_auth_error(e: &anyhow::Error) -> bool { let msg = e.to_string().to_lowercase(); msg.contains("unauthorized") || msg.contains("authenticate") || msg.contains("milltime") } + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::ApiClient; + use crate::app::{DeleteContext, DeleteOrigin, SaveAction, View}; + use crate::test_support::test_app; + use crate::types::ActiveTimerState; + use time::macros::datetime; + + #[test] + fn restore_active_timer_populates_local_app_state() { + let mut app = test_app(); + let timer = ActiveTimerState { + start_time: datetime!(2026-03-06 09:15 UTC), + project_id: Some("proj-1".to_string()), + project_name: Some("Project One".to_string()), + activity_id: Some("act-1".to_string()), + activity_name: Some("Activity One".to_string()), + note: "Investigate tests".to_string(), + hours: 1, + minutes: 2, + seconds: 3, + }; + + restore_active_timer(&mut app, timer); + + assert_eq!(app.timer_state, app::TimerState::Running); + assert_eq!(app.selected_project.as_ref().map(|p| p.id.as_str()), Some("proj-1")); + assert_eq!( + app.selected_activity.as_ref().map(|a| a.name.as_str()), + Some("Activity One") + ); + assert_eq!(app.description_input.value, "Investigate tests"); + assert!(!app.description_is_default); + assert_eq!(app.absolute_start, Some(datetime!(2026-03-06 09:15 UTC))); + assert!(app.local_start.is_some()); + } + + #[tokio::test] + async fn handle_start_timer_starts_timer_in_dev_mode() { + let mut app = test_app(); + let mut client = ApiClient::dev().expect("dev client"); + + handle_start_timer(&mut app, &mut client) + .await + .expect("start timer should succeed"); + + assert_eq!(app.timer_state, app::TimerState::Running); + assert!(app.absolute_start.is_some()); + assert!(app.local_start.is_some()); + assert!(app.status_message.is_none()); + } + + #[tokio::test] + async fn handle_save_timer_cancel_returns_to_timer_without_saving() { + let mut app = test_app(); + let mut client = ApiClient::dev().expect("dev client"); + app.current_view = View::SaveAction; + app.selected_save_action = SaveAction::Cancel; + app.timer_state = app::TimerState::Running; + + handle_save_timer_with_action(&mut app, &mut client) + .await + .expect("cancel should succeed"); + + assert_eq!(app.current_view, View::Timer); + assert_eq!(app.timer_state, app::TimerState::Running); + } + + #[tokio::test] + async fn handle_confirm_delete_removes_entry_and_returns_to_origin_view() { + let mut app = test_app(); + let mut client = ApiClient::dev().expect("dev client"); + let today = time::OffsetDateTime::now_utc().date(); + let entries = client + .get_time_entries(today, today) + .await + .expect("history should load"); + let entry = entries.first().expect("seeded history entry").clone(); + + app.update_history(entries); + app.rebuild_history_list(); + app.current_view = View::ConfirmDelete; + app.delete_context = Some(DeleteContext { + registration_id: entry.registration_id.clone(), + display_label: format!("{} / {}", entry.project_name, entry.activity_name), + display_date: entry.date.clone(), + display_hours: entry.hours, + origin: DeleteOrigin::History, + }); + + handle_confirm_delete(&mut app, &mut client).await; + + assert_eq!(app.current_view, View::History); + assert!(app.status_message.is_none()); + assert!(app + .time_entries + .iter() + .all(|item| item.registration_id != entry.registration_id)); + assert!(app.delete_context.is_none()); + } +} diff --git a/toki-tui/src/test_support.rs b/toki-tui/src/test_support.rs new file mode 100644 index 00000000..37a5700e --- /dev/null +++ b/toki-tui/src/test_support.rs @@ -0,0 +1,67 @@ +use crate::app::App; +use crate::config::TokiConfig; +use crate::types::{Activity, Project, TimeEntry}; +use time::OffsetDateTime; + +pub fn test_config() -> TokiConfig { + TokiConfig::default() +} + +pub fn test_app() -> App { + App::new(1, &test_config()) +} + +#[allow(dead_code)] +pub fn project(id: &str, name: &str) -> Project { + Project { + id: id.to_string(), + name: name.to_string(), + } +} + +#[allow(dead_code)] +pub fn activity(id: &str, project_id: &str, name: &str) -> Activity { + Activity { + id: id.to_string(), + project_id: project_id.to_string(), + name: name.to_string(), + } +} + +#[allow(clippy::too_many_arguments)] +#[allow(dead_code)] +pub fn time_entry( + registration_id: &str, + project_id: &str, + project_name: &str, + activity_id: &str, + activity_name: &str, + date: &str, + hours: f64, + note: Option<&str>, + start_time: Option, + end_time: Option, +) -> TimeEntry { + TimeEntry { + registration_id: registration_id.to_string(), + project_id: project_id.to_string(), + project_name: project_name.to_string(), + activity_id: activity_id.to_string(), + activity_name: activity_name.to_string(), + date: date.to_string(), + hours, + note: note.map(ToString::to_string), + start_time, + end_time, + week_number: 1, + } +} + +#[test] +fn app_defaults_to_timer_view() { + let app = test_app(); + + assert!(app.running); + assert_eq!(app.current_view, crate::app::View::Timer); + assert_eq!(app.timer_state, crate::app::TimerState::Stopped); +} diff --git a/toki-tui/src/ui/mod.rs b/toki-tui/src/ui/mod.rs index 169b4e42..5e39a600 100644 --- a/toki-tui/src/ui/mod.rs +++ b/toki-tui/src/ui/mod.rs @@ -158,3 +158,94 @@ fn render_milltime_reauth_overlay(frame: &mut Frame, app: &App) { frame.render_widget(paragraph, area); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{FocusedBox, MilltimeReauthState, TimerState}; + use crate::test_support::{activity, project, test_app}; + use ratatui::{backend::TestBackend, Terminal}; + use time::macros::datetime; + + fn render_lines(app: &mut App) -> Vec { + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend).expect("test terminal"); + terminal + .draw(|frame| render(frame, app)) + .expect("render should succeed"); + + let backend = terminal.backend(); + let buffer = backend.buffer(); + + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect() + } + + fn rendered_text(app: &mut App) -> String { + render_lines(app).join("\n") + } + + #[test] + fn render_shows_running_timer_project_and_note() { + let mut app = test_app(); + app.timer_state = TimerState::Running; + app.absolute_start = Some(datetime!(2026-03-06 09:15 UTC)); + app.selected_project = Some(project("proj-1", "Project One")); + app.selected_activity = Some(activity("act-1", "proj-1", "Activity One")); + app.description_input.value = "Investigate tests".to_string(); + app.description_input.cursor = app.description_input.value.len(); + app.focused_box = FocusedBox::ProjectActivity; + + let text = rendered_text(&mut app); + + assert!(text.contains("Timer")); + assert!(text.contains("(running)")); + assert!(text.contains("Project One: Activity One")); + assert!(text.contains("Investigate tests")); + } + + #[test] + fn render_status_shows_error_copy() { + let mut app = test_app(); + app.status_message = Some("Error starting timer: boom".to_string()); + + let text = rendered_text(&mut app); + + assert!(text.contains("Status")); + assert!(text.contains("Error starting timer: boom")); + } + + #[test] + fn render_status_shows_success_copy() { + let mut app = test_app(); + app.status_message = Some("Saved 00:15:00 to Project / Activity".to_string()); + + let text = rendered_text(&mut app); + + assert!(text.contains("Saved 00:15:00 to Project / Activity")); + } + + #[test] + fn render_milltime_reauth_masks_password() { + let mut app = test_app(); + let mut reauth = MilltimeReauthState::default(); + reauth.username_input.value = "alice".to_string(); + reauth.username_input.cursor = reauth.username_input.value.len(); + reauth.password_input.value = "secret".to_string(); + reauth.password_input.cursor = reauth.password_input.value.len(); + app.milltime_reauth = Some(reauth); + + let text = rendered_text(&mut app); + + assert!(text.contains("Milltime session expired. Please re-authenticate.")); + assert!(text.contains("alice")); + assert!(text.contains("Password:")); + assert!(!text.contains("secret")); + assert!(text.contains("••••••")); + } +}