Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions toki-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
174 changes: 174 additions & 0 deletions toki-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
}
}
37 changes: 37 additions & 0 deletions toki-tui/src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
2 changes: 2 additions & 0 deletions toki-tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
103 changes: 103 additions & 0 deletions toki-tui/src/runtime/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Loading
Loading