test(tui-core): add unit tests for UiState state machine (#2450)

Add 25 unit tests covering the previously untested UiState::reduce
state machine and snapshot:

- Default state verification
- Key navigation: pane switching (1-5), unknown key no-op
- Prompt lifecycle: pending_tasks increment, response delta tracking
- Tool lifecycle: active_tool set/clear, pending_tasks decrement, saturation
- Job lifecycle: active_jobs increment/decrement, progress clamping, saturation
- Approval lifecycle: pending_approvals increment/decrement, saturation
- Pause/Resume: flag toggling
- Tick: ScheduleBackgroundRefresh effect
- Snapshot: field presence, state change reflection

Co-authored-by: Hu Qiantao <huqiantao@HudeMacBook-Air.local>
This commit is contained in:
HUQIANTAO
2026-06-01 01:38:24 +08:00
committed by GitHub
parent f7fbc35165
commit 74a8a6f808
+241
View File
@@ -227,3 +227,244 @@ impl UiState {
) )
} }
} }
#[cfg(test)]
mod tests {
use super::*;
// ── Default state ──────────────────────────────────────────────────
#[test]
fn default_state_is_chat_pane_and_ready() {
let state = UiState::default();
assert_eq!(state.active_pane, Pane::Chat);
assert!(!state.paused);
assert_eq!(state.last_response_delta, None);
assert_eq!(state.active_tool, None);
assert_eq!(state.pending_tasks, 0);
assert_eq!(state.active_jobs, 0);
assert_eq!(state.pending_approvals, 0);
assert_eq!(state.status_line, "ready");
}
// ── Key navigation ─────────────────────────────────────────────────
#[test]
fn key_1_switches_to_chat_pane() {
let mut state = UiState::default();
let effects = state.reduce(UiEvent::KeyPressed('1'));
assert_eq!(state.active_pane, Pane::Chat);
assert_eq!(effects, vec![UiEffect::Render]);
}
#[test]
fn key_2_switches_to_diff_pane() {
let mut state = UiState::default();
state.reduce(UiEvent::KeyPressed('2'));
assert_eq!(state.active_pane, Pane::Diff);
}
#[test]
fn key_3_switches_to_tasks_pane() {
let mut state = UiState::default();
state.reduce(UiEvent::KeyPressed('3'));
assert_eq!(state.active_pane, Pane::Tasks);
}
#[test]
fn key_4_switches_to_agents_pane() {
let mut state = UiState::default();
state.reduce(UiEvent::KeyPressed('4'));
assert_eq!(state.active_pane, Pane::Agents);
}
#[test]
fn key_5_switches_to_jobs_pane() {
let mut state = UiState::default();
state.reduce(UiEvent::KeyPressed('5'));
assert_eq!(state.active_pane, Pane::Jobs);
}
#[test]
fn unknown_key_produces_no_effects() {
let mut state = UiState::default();
let effects = state.reduce(UiEvent::KeyPressed('x'));
assert!(effects.is_empty());
assert_eq!(state.active_pane, Pane::Chat);
}
// ── Prompt lifecycle ───────────────────────────────────────────────
#[test]
fn prompt_submitted_increments_pending_tasks() {
let mut state = UiState::default();
let effects = state.reduce(UiEvent::PromptSubmitted("hello".to_string()));
assert_eq!(state.pending_tasks, 1);
assert_eq!(state.status_line, "prompt submitted");
assert!(effects.contains(&UiEffect::Render));
assert!(effects.contains(&UiEffect::PersistCheckpoint));
}
#[test]
fn response_delta_updates_last_delta() {
let mut state = UiState::default();
state.reduce(UiEvent::ResponseDelta("partial".to_string()));
assert_eq!(state.last_response_delta, Some("partial".to_string()));
assert_eq!(state.status_line, "streaming response");
}
// ── Tool lifecycle ─────────────────────────────────────────────────
#[test]
fn tool_started_sets_active_tool() {
let mut state = UiState::default();
state.reduce(UiEvent::ToolStarted("shell".to_string()));
assert_eq!(state.active_tool, Some("shell".to_string()));
assert_eq!(state.status_line, "tool running: shell");
}
#[test]
fn tool_finished_clears_active_tool_and_decrements_tasks() {
let mut state = UiState::default();
state.reduce(UiEvent::PromptSubmitted("test".to_string()));
assert_eq!(state.pending_tasks, 1);
state.reduce(UiEvent::ToolFinished("shell".to_string()));
assert_eq!(state.active_tool, None);
assert_eq!(state.pending_tasks, 0);
assert_eq!(state.status_line, "tool finished: shell");
}
#[test]
fn tool_finished_saturates_at_zero() {
let mut state = UiState::default();
state.reduce(UiEvent::ToolFinished("shell".to_string()));
assert_eq!(state.pending_tasks, 0);
}
// ── Job lifecycle ──────────────────────────────────────────────────
#[test]
fn job_queued_increments_active_jobs() {
let mut state = UiState::default();
state.reduce(UiEvent::JobQueued("build".to_string()));
assert_eq!(state.active_jobs, 1);
assert_eq!(state.status_line, "job queued");
}
#[test]
fn job_progress_updates_status_line() {
let mut state = UiState::default();
state.reduce(UiEvent::JobProgress {
job_id: "j1".to_string(),
progress: 75,
});
assert_eq!(state.status_line, "job progress: 75%");
}
#[test]
fn job_progress_clamps_over_100() {
let mut state = UiState::default();
state.reduce(UiEvent::JobProgress {
job_id: "j1".to_string(),
progress: 150,
});
assert_eq!(state.status_line, "job progress: 100%");
}
#[test]
fn job_completed_decrements_active_jobs() {
let mut state = UiState::default();
state.reduce(UiEvent::JobQueued("build".to_string()));
assert_eq!(state.active_jobs, 1);
state.reduce(UiEvent::JobCompleted("build".to_string()));
assert_eq!(state.active_jobs, 0);
assert_eq!(state.status_line, "job completed");
}
#[test]
fn job_completed_saturates_at_zero() {
let mut state = UiState::default();
state.reduce(UiEvent::JobCompleted("build".to_string()));
assert_eq!(state.active_jobs, 0);
}
// ── Approval lifecycle ─────────────────────────────────────────────
#[test]
fn approval_requested_increments_pending() {
let mut state = UiState::default();
state.reduce(UiEvent::ApprovalRequested("exec".to_string()));
assert_eq!(state.pending_approvals, 1);
assert_eq!(state.status_line, "approval requested");
}
#[test]
fn approval_resolved_decrements_pending() {
let mut state = UiState::default();
state.reduce(UiEvent::ApprovalRequested("exec".to_string()));
state.reduce(UiEvent::ApprovalResolved("exec".to_string()));
assert_eq!(state.pending_approvals, 0);
assert_eq!(state.status_line, "approval resolved");
}
#[test]
fn approval_resolved_saturates_at_zero() {
let mut state = UiState::default();
state.reduce(UiEvent::ApprovalResolved("exec".to_string()));
assert_eq!(state.pending_approvals, 0);
}
// ── Pause/Resume ───────────────────────────────────────────────────
#[test]
fn pause_sets_paused_flag() {
let mut state = UiState::default();
state.reduce(UiEvent::PauseRequested);
assert!(state.paused);
assert_eq!(state.status_line, "paused");
}
#[test]
fn resume_clears_paused_flag() {
let mut state = UiState::default();
state.reduce(UiEvent::PauseRequested);
state.reduce(UiEvent::ResumeRequested);
assert!(!state.paused);
assert_eq!(state.status_line, "resumed");
}
// ── Tick ────────────────────────────────────────────────────────────
#[test]
fn tick_schedules_background_refresh() {
let mut state = UiState::default();
let effects = state.reduce(UiEvent::Tick);
assert_eq!(effects, vec![UiEffect::ScheduleBackgroundRefresh]);
}
// ── Snapshot ────────────────────────────────────────────────────────
#[test]
fn snapshot_contains_all_fields() {
let state = UiState::default();
let snap = state.snapshot();
assert!(snap.contains("pane=Chat"));
assert!(snap.contains("paused=false"));
assert!(snap.contains("pending_tasks=0"));
assert!(snap.contains("active_jobs=0"));
assert!(snap.contains("pending_approvals=0"));
assert!(snap.contains("status=ready"));
}
#[test]
fn snapshot_reflects_state_changes() {
let mut state = UiState::default();
state.reduce(UiEvent::KeyPressed('2'));
state.reduce(UiEvent::PauseRequested);
state.reduce(UiEvent::PromptSubmitted("test".to_string()));
let snap = state.snapshot();
assert!(snap.contains("pane=Diff"));
assert!(snap.contains("paused=true"));
assert!(snap.contains("pending_tasks=1"));
}
}