use crate::openspec::Change;
use crate::tui::events::{LogEntry, LogLevel, TuiCommand};
use crate::tui::types::{AppMode, StopMode, ViewMode, WorktreeAction, WorktreeInfo};
use ratatui::style::Color;
use ratatui::widgets::ListState;
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use tracing::{error, info, warn};
mod log_logic;
mod processing_logic;
mod selection_logic;
mod worktree_action_logic;
mod worktree_logic;
fn apply_remote_status(change: &mut ChangeState, status: &str) {
let current = change.display_status_cache.as_str();
let next = match status {
"applying" => Some("applying"),
"archiving" => Some("archiving"),
"accepting" => Some("accepting"),
"resolving" => Some("resolving"),
"archived" => Some("archived"),
"merged" => Some("merged"),
"merge_wait" => Some("merge wait"),
"resolve_wait" => Some("resolve pending"),
"blocked" => Some("blocked"),
"stalled" => Some("stalled"),
"gated" => Some("stalled"),
"queued" => Some("queued"),
"idle" => Some("not queued"),
"rejected" => Some("rejected"),
"error" => Some("error"),
_ => None,
};
let Some(next) = next else {
return;
};
if matches!(
current,
"applying" | "archiving" | "accepting" | "resolving"
) && matches!(next, "queued" | "not queued")
{
return;
}
if matches!(next, "queued" | "not queued") && matches!(current, "archived" | "merged") {
return;
}
if matches!(next, "applying") && change.started_at.is_none() {
change.started_at = Some(Instant::now());
change.elapsed_time = None;
}
if !matches!(next, "applying" | "archiving" | "accepting" | "resolving")
&& matches!(
current,
"applying" | "archiving" | "accepting" | "resolving"
)
{
if let Some(started) = change.started_at {
change.elapsed_time = Some(started.elapsed());
}
}
if next == "error" {
if change.error_message_cache.is_none() {
change.error_message_cache = Some("remote".to_string());
}
change.set_display_status_cache("error");
} else {
change.set_display_status_cache(next);
}
}
pub const AUTO_REFRESH_INTERVAL_SECS: u64 = 5;
pub const MAX_LOG_ENTRIES: usize = 1000;
pub struct WarningPopup {
pub title: String,
pub message: String,
}
impl WarningPopup {
pub fn new(title: impl Into<String>, message: impl Into<String>) -> Self {
Self {
title: title.into(),
message: message.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct ChangeState {
pub id: String,
pub completed_tasks: u32,
pub total_tasks: u32,
pub display_status_cache: String,
pub display_color_cache: Color,
pub error_message_cache: Option<String>,
pub selected: bool,
pub is_new: bool,
pub is_parallel_eligible: bool,
pub has_worktree: bool,
pub started_at: Option<Instant>,
pub elapsed_time: Option<Duration>,
pub iteration_number: Option<u32>,
}
pub struct AppState {
pub view_mode: ViewMode,
pub mode: AppMode,
pub changes: Vec<ChangeState>,
pub cursor_index: usize,
pub list_state: ListState,
pub worktrees: Vec<WorktreeInfo>,
pub worktree_cursor_index: usize,
pub worktree_list_state: ListState,
pub pending_worktree_action: Option<(String, WorktreeAction)>,
pub pending_worktree_branch: Option<String>,
pub current_change: Option<String>,
pub error_change_id: Option<String>,
pub logs: Vec<LogEntry>,
pub last_refresh: Instant,
pub new_change_count: usize,
pub known_change_ids: HashSet<String>,
pub should_quit: bool,
pub warning_message: Option<String>,
pub warning_popup: Option<WarningPopup>,
pub warning_popup_scroll: u16,
pub spinner_frame: usize,
pub log_scroll_offset: usize,
pub log_auto_scroll: bool,
pub stop_mode: StopMode,
pub parallel_mode: bool,
pub parallel_available: bool,
pub vcs_backend: String,
pub max_concurrent: usize,
pub orchestration_started_at: Option<Instant>,
pub orchestration_elapsed: Option<Duration>,
pub previous_mode: Option<AppMode>,
pub web_url: Option<String>,
pub is_resolving: bool,
pub worktree_paths: HashMap<String, PathBuf>,
pub shared_orchestrator_state:
Option<std::sync::Arc<tokio::sync::RwLock<crate::orchestration::state::OrchestratorState>>>,
pub resolve_queue: VecDeque<String>,
pub resolve_queue_set: HashSet<String>,
pub logs_panel_enabled: bool,
}
impl ChangeState {
pub fn from_change(change: &Change) -> Self {
Self {
id: change.id.clone(),
completed_tasks: change.completed_tasks,
total_tasks: change.total_tasks,
selected: false, is_new: false,
display_status_cache: "not queued".to_string(),
display_color_cache: Color::DarkGray,
error_message_cache: None,
is_parallel_eligible: true,
has_worktree: false,
started_at: None,
elapsed_time: None,
iteration_number: None,
}
}
pub fn progress_percent(&self) -> f32 {
if self.total_tasks == 0 {
return 0.0;
}
(self.completed_tasks as f32 / self.total_tasks as f32) * 100.0
}
pub fn set_display_status_cache(&mut self, status: &str) {
self.display_status_cache = status.to_string();
self.display_color_cache = match status {
"not queued" => Color::DarkGray,
"queued" => Color::Yellow,
"blocked" => Color::Gray,
"stalled" => Color::LightYellow,
"applying" => Color::Cyan,
"accepting" => Color::LightGreen,
"archiving" => Color::Magenta,
"merge wait" => Color::LightMagenta,
"resolve pending" => Color::Magenta,
"resolving" => Color::LightCyan,
"archived" => Color::Blue,
"merged" => Color::LightBlue,
"rejected" => Color::LightRed,
"error" => Color::Red,
_ => Color::DarkGray,
};
if status != "error" {
self.error_message_cache = None;
}
}
pub fn set_error_message_cache(&mut self, message: String) {
self.error_message_cache = Some(message);
self.set_display_status_cache("error");
}
pub fn is_active_display_status(&self) -> bool {
matches!(
self.display_status_cache.as_str(),
"applying" | "accepting" | "archiving" | "resolving"
)
}
pub fn update_iteration_monotonic(&mut self, new_iteration: Option<u32>) {
if let Some(new_val) = new_iteration {
match self.iteration_number {
None => {
self.iteration_number = Some(new_val);
}
Some(current) => {
if new_val > current {
self.iteration_number = Some(new_val);
}
}
}
}
}
}
impl AppState {
pub fn new(changes: Vec<Change>) -> Self {
let known_ids: HashSet<String> = changes.iter().map(|c| c.id.clone()).collect();
let change_states: Vec<ChangeState> =
changes.iter().map(ChangeState::from_change).collect();
let mut list_state = ListState::default();
if !change_states.is_empty() {
list_state.select(Some(0));
}
Self {
view_mode: ViewMode::Changes,
mode: AppMode::Select,
changes: change_states,
cursor_index: 0,
list_state,
worktrees: Vec::new(),
worktree_cursor_index: 0,
worktree_list_state: ListState::default(),
pending_worktree_action: None,
pending_worktree_branch: None,
current_change: None,
error_change_id: None,
logs: Vec::new(),
last_refresh: Instant::now(),
new_change_count: 0,
known_change_ids: known_ids,
should_quit: false,
warning_message: None,
warning_popup: None,
warning_popup_scroll: 0,
spinner_frame: 0,
log_scroll_offset: 0,
log_auto_scroll: true,
stop_mode: StopMode::None,
parallel_mode: false,
parallel_available: crate::cli::check_parallel_available(),
vcs_backend: "git".to_string(),
max_concurrent: 4, orchestration_started_at: None,
orchestration_elapsed: None,
previous_mode: None,
web_url: None,
is_resolving: false,
worktree_paths: HashMap::new(),
shared_orchestrator_state: None,
resolve_queue: VecDeque::new(),
resolve_queue_set: HashSet::new(),
logs_panel_enabled: true, }
}
pub fn show_warning_popup(&mut self, title: impl Into<String>, message: impl Into<String>) {
self.warning_popup = Some(WarningPopup::new(title, message));
self.warning_popup_scroll = 0;
}
pub fn clear_warning_popup(&mut self) {
self.warning_popup = None;
self.warning_popup_scroll = 0;
}
pub fn scroll_warning_popup(&mut self, delta: i16) {
if delta.is_negative() {
self.warning_popup_scroll = self
.warning_popup_scroll
.saturating_sub(delta.unsigned_abs());
} else {
self.warning_popup_scroll = self.warning_popup_scroll.saturating_add(delta as u16);
}
}
pub fn set_shared_state(
&mut self,
shared_state: std::sync::Arc<
tokio::sync::RwLock<crate::orchestration::state::OrchestratorState>,
>,
) {
self.shared_orchestrator_state = Some(shared_state);
}
pub fn show_qr_popup(&mut self) {
if self.web_url.is_some() {
self.previous_mode = Some(self.mode.clone());
self.mode = AppMode::QrPopup;
}
}
pub fn hide_qr_popup(&mut self) {
if let Some(mode) = self.previous_mode.take() {
self.mode = mode;
} else {
self.mode = AppMode::Select;
}
}
pub fn cursor_up(&mut self) {
if self.changes.is_empty() {
return;
}
self.cursor_index = if self.cursor_index == 0 {
self.changes.len() - 1
} else {
self.cursor_index - 1
};
self.list_state.select(Some(self.cursor_index));
}
pub fn cursor_down(&mut self) {
if self.changes.is_empty() {
return;
}
self.cursor_index = (self.cursor_index + 1) % self.changes.len();
self.list_state.select(Some(self.cursor_index));
}
pub fn worktree_cursor_up(&mut self) {
let Some(next_index) = worktree_logic::previous_worktree_cursor_index(
self.worktree_cursor_index,
self.worktrees.len(),
) else {
return;
};
self.worktree_cursor_index = next_index;
self.worktree_list_state
.select(Some(self.worktree_cursor_index));
}
pub fn worktree_cursor_down(&mut self) {
let Some(next_index) = worktree_logic::next_worktree_cursor_index(
self.worktree_cursor_index,
self.worktrees.len(),
) else {
return;
};
self.worktree_cursor_index = next_index;
self.worktree_list_state
.select(Some(self.worktree_cursor_index));
}
pub fn get_selected_worktree_path(&self) -> Option<String> {
if self.worktree_cursor_index < self.worktrees.len() {
Some(
self.worktrees[self.worktree_cursor_index]
.path
.display()
.to_string(),
)
} else {
None
}
}
pub fn get_selected_worktree(&self) -> Option<&WorktreeInfo> {
if self.worktree_cursor_index < self.worktrees.len() {
Some(&self.worktrees[self.worktree_cursor_index])
} else {
None
}
}
pub fn request_worktree_delete_from_list(&mut self) -> Option<TuiCommand> {
match worktree_action_logic::validate_delete_request(
&self.worktrees,
self.worktree_cursor_index,
&self.changes,
) {
Ok((path, branch)) => {
worktree_action_logic::apply_delete_confirmation_state(
path,
branch,
&mut self.mode,
&mut self.pending_worktree_action,
&mut self.pending_worktree_branch,
&mut self.previous_mode,
);
None
}
Err(msg) => {
self.warning_message = Some(msg);
None
}
}
}
pub fn confirm_worktree_action_delete(&mut self) -> Option<TuiCommand> {
if let Some((path, WorktreeAction::Delete)) = self.pending_worktree_action.take() {
let branch_name = self.pending_worktree_branch.take();
if let Some(mode) = self.previous_mode.take() {
self.mode = mode;
} else {
self.mode = AppMode::Select;
}
Some(TuiCommand::DeleteWorktreeByPath(
path.into(),
branch_name,
false,
))
} else {
None
}
}
pub fn cancel_worktree_action(&mut self) {
self.pending_worktree_action = None;
self.pending_worktree_branch = None;
if let Some(mode) = self.previous_mode.take() {
self.mode = mode;
} else {
self.mode = AppMode::Select;
}
}
pub fn request_merge_worktree_branch(&mut self) -> Option<TuiCommand> {
worktree_action_logic::request_merge_worktree_branch(self)
}
pub fn toggle_selection(&mut self) -> Option<TuiCommand> {
selection_logic::toggle_selection(self)
}
fn can_bulk_toggle_change(&self, change: &ChangeState) -> bool {
selection_logic::can_bulk_toggle_change(self.mode.clone(), self.parallel_mode, change)
}
pub fn has_bulk_toggle_targets(&self) -> bool {
matches!(
self.mode,
AppMode::Select | AppMode::Stopped | AppMode::Running
) && self
.changes
.iter()
.any(|change| self.can_bulk_toggle_change(change))
}
pub fn toggle_all_marks(&mut self) -> Vec<TuiCommand> {
if !self.has_bulk_toggle_targets() {
return Vec::new();
}
let has_unmarked = self
.changes
.iter()
.any(|change| !change.selected && self.can_bulk_toggle_change(change));
let target_state = has_unmarked;
let is_running = matches!(self.mode, AppMode::Running);
let mut commands = Vec::new();
for i in 0..self.changes.len() {
if !self.can_bulk_toggle_change(&self.changes[i]) {
continue;
}
if self.changes[i].selected != target_state {
self.changes[i].selected = target_state;
if self.changes[i].is_new {
self.changes[i].is_new = false;
self.new_change_count = self.new_change_count.saturating_sub(1);
}
if is_running {
match self.changes[i].display_status_cache.as_str() {
"not queued" if target_state => {
let id = self.changes[i].id.clone();
self.add_log(LogEntry::info(format!("Added to queue: {}", id)));
commands.push(TuiCommand::AddToQueue(id));
}
"queued" if !target_state => {
let id = self.changes[i].id.clone();
self.add_log(LogEntry::info(format!("Removed from queue: {}", id)));
commands.push(TuiCommand::RemoveFromQueue(id));
}
_ => {}
}
}
}
}
let action = if target_state { "marked" } else { "unmarked" };
let count = self
.changes
.iter()
.filter(|change| self.can_bulk_toggle_change(change) && change.selected == target_state)
.count();
self.add_log(LogEntry::info(format!(
"Toggled all: {} {} change(s)",
count, action
)));
commands
}
pub fn resolve_merge(&mut self) -> Option<TuiCommand> {
if self.changes.is_empty() || self.cursor_index >= self.changes.len() {
return None;
}
if !matches!(
self.mode,
AppMode::Select | AppMode::Stopped | AppMode::Running
) {
return None;
}
let change_id = {
let change = &self.changes[self.cursor_index];
if !matches!(change.display_status_cache.as_str(), "merge wait") {
return None;
}
change.id.clone()
};
if self.is_resolving {
if self.add_to_resolve_queue(&change_id) {
self.changes[self.cursor_index].set_display_status_cache("resolve pending");
if let Some(shared) = &self.shared_orchestrator_state {
if let Ok(mut guard) = shared.try_write() {
guard.apply_command(
crate::orchestration::state::ReducerCommand::ResolveMerge(
change_id.clone(),
),
);
}
}
self.add_log(LogEntry::info(format!(
"Queued '{}' for resolve (position: {})",
change_id,
self.resolve_queue.len()
)));
} else {
self.warning_message = Some(format!(
"Change '{}' is already queued for resolve",
change_id
));
}
None
} else {
self.is_resolving = true;
if matches!(self.mode, AppMode::Select | AppMode::Stopped) {
self.mode = AppMode::Running;
}
self.changes[self.cursor_index].set_display_status_cache("resolve pending");
if let Some(shared) = &self.shared_orchestrator_state {
if let Ok(mut guard) = shared.try_write() {
guard.apply_command(crate::orchestration::state::ReducerCommand::ResolveMerge(
change_id.clone(),
));
}
}
Some(TuiCommand::ResolveMerge(change_id))
}
}
pub fn add_to_resolve_queue(&mut self, change_id: &str) -> bool {
if self.resolve_queue_set.contains(change_id) {
false
} else {
self.resolve_queue.push_back(change_id.to_string());
self.resolve_queue_set.insert(change_id.to_string());
true
}
}
pub fn pop_from_resolve_queue(&mut self) -> Option<String> {
if let Some(change_id) = self.resolve_queue.pop_front() {
self.resolve_queue_set.remove(&change_id);
Some(change_id)
} else {
None
}
}
#[cfg(test)]
pub fn has_queued_resolves(&self) -> bool {
!self.resolve_queue.is_empty()
}
pub fn apply_parallel_eligibility(
&mut self,
committed_change_ids: &HashSet<String>,
uncommitted_file_change_ids: &HashSet<String>,
) {
for change in &mut self.changes {
change.is_parallel_eligible = committed_change_ids.contains(&change.id)
&& !uncommitted_file_change_ids.contains(&change.id);
if self.parallel_mode
&& matches!(self.mode, AppMode::Select | AppMode::Stopped)
&& !change.is_parallel_eligible
{
if change.selected {
change.selected = false;
}
if matches!(change.display_status_cache.as_str(), "queued") {
change.set_display_status_cache("not queued");
}
}
}
}
pub fn apply_worktree_status(&mut self, worktree_change_ids: &HashSet<String>) {
for change in &mut self.changes {
let sanitized = change.id.replace(['/', '\\', ' '], "-");
change.has_worktree = worktree_change_ids.contains(&sanitized);
}
}
pub fn apply_display_statuses_from_reducer(
&mut self,
display_map: &HashMap<String, &'static str>,
) {
for change in &mut self.changes {
if change.display_status_cache == "rejected"
&& !matches!(display_map.get(&change.id).copied(), Some("rejected"))
{
change.selected = false;
continue;
}
if let Some(&status_str) = display_map.get(&change.id) {
let normalized = match status_str {
"stopped" => "not queued",
other => other,
};
if normalized == "error" {
if change.display_status_cache == "error" {
continue;
}
if change.error_message_cache.is_none() {
change.error_message_cache = Some("reducer".to_string());
}
change.set_display_status_cache("error");
} else {
change.set_display_status_cache(normalized);
if normalized == "rejected" {
change.selected = false;
}
}
}
}
}
pub fn selected_count(&self) -> usize {
self.changes.iter().filter(|c| c.selected).count()
}
}
impl AppState {
pub fn toggle_parallel_mode(&mut self) -> bool {
if !matches!(self.mode, AppMode::Select | AppMode::Stopped) {
self.warning_message = Some("Cannot toggle parallel mode while processing".to_string());
return false;
}
if !self.parallel_available {
self.warning_message = Some("Parallel mode not available (requires git)".to_string());
return false;
}
self.parallel_mode = !self.parallel_mode;
let status = if self.parallel_mode {
"enabled"
} else {
"disabled"
};
if self.parallel_mode {
let mut removed = Vec::new();
for change in &mut self.changes {
if !change.is_parallel_eligible && change.selected {
change.selected = false;
if matches!(change.display_status_cache.as_str(), "queued") {
change.set_display_status_cache("not queued");
}
removed.push(change.id.clone());
}
}
if !removed.is_empty() {
self.warning_message = Some(format!(
"Removed uncommitted changes from queue in parallel mode: {}",
removed.join(", ")
));
}
}
self.add_log(LogEntry::info(format!("Parallel mode {}", status)));
true
}
pub fn reset_for_run(&mut self) {
self.stop_mode = StopMode::None;
self.current_change = None;
self.error_change_id = None;
self.orchestration_started_at = Some(Instant::now());
self.orchestration_elapsed = None;
}
pub fn start_processing(&mut self) -> Option<TuiCommand> {
selection_logic::start_processing(self)
}
pub fn resume_processing(&mut self) -> Option<TuiCommand> {
selection_logic::resume_processing(self)
}
pub fn retry_error_changes(&mut self) -> Option<TuiCommand> {
selection_logic::retry_error_changes(self)
}
}
impl AppState {
pub fn get_latest_log_for_change(&self, change_id: &str) -> Option<&LogEntry> {
self.logs.iter().rev().find(|entry| {
if let Some(entry_cid) = entry.change_id.as_deref() {
if entry_cid == change_id {
return true;
}
let prefix = format!("{}::", entry_cid);
if change_id.starts_with(&prefix) {
return true;
}
}
false
})
}
pub fn add_log(&mut self, entry: LogEntry) {
let change_id = entry.change_id.as_deref().unwrap_or("-");
let operation = entry.operation.as_deref().unwrap_or("-");
let iteration = entry.iteration.unwrap_or(0);
let workspace_path = entry.workspace_path.as_deref().unwrap_or("-");
match entry.level {
LogLevel::Info | LogLevel::Success => {
info!(
target: "tui_log",
change_id = change_id,
operation = operation,
iteration = iteration,
workspace_path = workspace_path,
"{}",
entry.message
);
}
LogLevel::Warn => {
warn!(
target: "tui_log",
change_id = change_id,
operation = operation,
iteration = iteration,
workspace_path = workspace_path,
"{}",
entry.message
);
}
LogLevel::Error => {
error!(
target: "tui_log",
change_id = change_id,
operation = operation,
iteration = iteration,
workspace_path = workspace_path,
"{}",
entry.message
);
}
}
self.logs.push(entry);
if log_logic::apply_log_buffer_limit(self.logs.len(), MAX_LOG_ENTRIES) {
self.logs.remove(0);
}
self.log_scroll_offset = log_logic::next_log_offset_on_append(
self.log_auto_scroll,
self.log_scroll_offset,
self.logs.len(),
);
}
pub fn scroll_logs_up(&mut self, page_size: usize) {
self.log_scroll_offset =
log_logic::scroll_logs_up(self.log_scroll_offset, self.logs.len(), page_size);
self.log_auto_scroll = false;
}
pub fn scroll_logs_down(&mut self, page_size: usize) {
self.log_scroll_offset = log_logic::scroll_logs_down(self.log_scroll_offset, page_size);
if self.log_scroll_offset == 0 {
self.log_auto_scroll = true;
}
}
pub fn scroll_logs_to_top(&mut self) {
self.log_scroll_offset = log_logic::scroll_logs_to_top(self.logs.len());
self.log_auto_scroll = false;
}
pub fn scroll_logs_to_bottom(&mut self) {
self.log_scroll_offset = 0;
self.log_auto_scroll = true;
}
pub fn toggle_logs_panel(&mut self) {
self.logs_panel_enabled = log_logic::toggle_logs_panel(self.logs_panel_enabled);
}
}
mod event_handlers;
impl AppState {
fn update_changes(&mut self, fetched_changes: Vec<Change>) {
processing_logic::update_changes(self, fetched_changes);
}
#[cfg(test)]
fn update_changes_with_rejected_for_test(
&mut self,
fetched_changes: Vec<Change>,
rejected_changes: Vec<Change>,
) {
processing_logic::update_changes_with_rejected(self, fetched_changes, rejected_changes);
}
}
mod guards {
use super::{ChangeState, TuiCommand, ViewMode, WorktreeInfo};
pub enum MergeGuardResult {
Allowed,
Blocked(String),
}
pub fn validate_view_mode(view_mode: ViewMode) -> MergeGuardResult {
if view_mode != ViewMode::Worktrees {
MergeGuardResult::Blocked("Switch to Worktrees view to merge".to_string())
} else {
MergeGuardResult::Allowed
}
}
pub fn validate_not_resolving(is_resolving: bool) -> MergeGuardResult {
if is_resolving {
MergeGuardResult::Blocked("Cannot merge: resolve operation in progress".to_string())
} else {
MergeGuardResult::Allowed
}
}
pub fn validate_worktrees_not_empty(worktrees_len: usize) -> MergeGuardResult {
if worktrees_len == 0 {
MergeGuardResult::Blocked("No worktrees loaded".to_string())
} else {
MergeGuardResult::Allowed
}
}
pub fn validate_cursor_in_bounds(
cursor_index: usize,
worktrees_len: usize,
) -> MergeGuardResult {
if cursor_index >= worktrees_len {
MergeGuardResult::Blocked(format!(
"Cursor out of range: {} >= {}",
cursor_index, worktrees_len
))
} else {
MergeGuardResult::Allowed
}
}
pub fn validate_worktree_mergeable(worktree: &WorktreeInfo) -> MergeGuardResult {
if worktree.is_main {
return MergeGuardResult::Blocked("Cannot merge main worktree".to_string());
}
if worktree.is_detached {
return MergeGuardResult::Blocked("Cannot merge detached HEAD".to_string());
}
if worktree.has_merge_conflict() {
return MergeGuardResult::Blocked(format!(
"Cannot merge: {} conflict(s) detected",
worktree.conflict_file_count()
));
}
if worktree.branch.is_empty() {
return MergeGuardResult::Blocked("Cannot merge: no branch name".to_string());
}
if !worktree.has_commits_ahead {
return MergeGuardResult::Blocked(
"Cannot merge: no commits ahead of base branch".to_string(),
);
}
if worktree.is_merging {
return MergeGuardResult::Blocked(
"Cannot merge: merge already in progress".to_string(),
);
}
MergeGuardResult::Allowed
}
pub enum ToggleGuardResult {
Allowed,
Blocked(String),
}
impl ToggleGuardResult {
pub fn is_allowed(&self) -> bool {
matches!(self, ToggleGuardResult::Allowed)
}
}
pub fn validate_change_toggleable(
is_parallel_eligible: bool,
parallel_mode: bool,
display_status_cache: &str,
change_id: &str,
) -> ToggleGuardResult {
if parallel_mode
&& !is_parallel_eligible
&& !matches!(
display_status_cache,
"applying" | "accepting" | "archiving" | "resolving"
)
{
return ToggleGuardResult::Blocked(format!(
"Cannot queue uncommitted change '{}' in parallel mode. Commit it first.",
change_id
));
}
if display_status_cache == "rejected" {
return ToggleGuardResult::Blocked(format!(
"Change '{}' is rejected and read-only",
change_id
));
}
ToggleGuardResult::Allowed
}
pub enum ToggleActionResult {
StateOnly(Option<String>),
Command(TuiCommand, Option<String>),
None,
}
pub fn handle_toggle_select_mode(
change: &mut ChangeState,
new_change_count: &mut usize,
) -> ToggleActionResult {
change.selected = !change.selected;
if change.is_new {
change.is_new = false;
*new_change_count = new_change_count.saturating_sub(1);
}
ToggleActionResult::StateOnly(None)
}
pub fn handle_toggle_running_mode(
change: &mut ChangeState,
new_change_count: &mut usize,
) -> ToggleActionResult {
match change.display_status_cache.as_str() {
"not queued" => {
change.selected = true;
if change.is_new {
change.is_new = false;
*new_change_count = new_change_count.saturating_sub(1);
}
let id = change.id.clone();
let log_msg = format!("Added to queue: {}", id);
ToggleActionResult::Command(TuiCommand::AddToQueue(id), Some(log_msg))
}
"queued" => {
change.selected = false;
let id = change.id.clone();
let log_msg = format!("Removed from queue: {}", id);
ToggleActionResult::Command(TuiCommand::RemoveFromQueue(id), Some(log_msg))
}
"merge wait" | "resolve pending" => {
change.selected = !change.selected;
if change.is_new {
change.is_new = false;
*new_change_count = new_change_count.saturating_sub(1);
}
let id = change.id.clone();
let log_msg = if change.selected {
format!("Marked for execution: {}", id)
} else {
format!("Unmarked: {}", id)
};
ToggleActionResult::StateOnly(Some(log_msg))
}
"applying" | "accepting" | "archiving" | "resolving" => {
ToggleActionResult::None
}
"error" => {
change.selected = !change.selected;
if change.is_new {
change.is_new = false;
*new_change_count = new_change_count.saturating_sub(1);
}
let id = change.id.clone();
let log_msg = if change.selected {
format!("Marked for retry and added to queue: {}", id)
} else {
format!("Retry mark cleared and removed from queue: {}", id)
};
if change.selected {
ToggleActionResult::Command(TuiCommand::AddToQueue(id), Some(log_msg))
} else {
ToggleActionResult::Command(TuiCommand::RemoveFromQueue(id), Some(log_msg))
}
}
_ => ToggleActionResult::None,
}
}
pub fn handle_toggle_stopped_mode(
change: &mut ChangeState,
new_change_count: &mut usize,
) -> ToggleActionResult {
if !matches!(
change.display_status_cache.as_str(),
"not queued" | "merge wait" | "resolve pending" | "error"
) {
return ToggleActionResult::None;
}
change.selected = !change.selected;
if change.is_new {
change.is_new = false;
*new_change_count = new_change_count.saturating_sub(1);
}
let id = change.id.clone();
let log_msg = if change.selected {
format!("Marked for execution: {}", id)
} else {
format!("Unmarked: {}", id)
};
ToggleActionResult::StateOnly(Some(log_msg))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::events::OrchestratorEvent;
fn create_test_change(id: &str, completed: u32, total: u32) -> Change {
Change {
id: id.to_string(),
completed_tasks: completed,
total_tasks: total,
last_modified: "now".to_string(),
dependencies: Vec::new(),
metadata: crate::openspec::ProposalMetadata::default(),
}
}
#[test]
fn warning_popup_lifecycle_resets_scroll_offset() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.show_warning_popup("first", "message");
app.scroll_warning_popup(3);
assert_eq!(app.warning_popup_scroll, 3);
app.show_warning_popup("second", "message");
assert_eq!(app.warning_popup_scroll, 0);
app.scroll_warning_popup(2);
app.clear_warning_popup();
assert_eq!(app.warning_popup_scroll, 0);
assert!(app.warning_popup.is_none());
}
#[test]
fn warning_popup_scroll_saturates_at_zero() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.show_warning_popup("warning", "message");
app.scroll_warning_popup(-5);
assert_eq!(app.warning_popup_scroll, 0);
}
#[test]
fn test_change_state_progress() {
let change = ChangeState {
id: "test".to_string(),
completed_tasks: 3,
total_tasks: 6,
display_status_cache: "not queued".to_string(),
display_color_cache: Color::DarkGray,
error_message_cache: None,
selected: false,
is_new: false,
is_parallel_eligible: true,
has_worktree: false,
started_at: None,
elapsed_time: None,
iteration_number: None,
};
assert_eq!(change.progress_percent(), 50.0);
}
#[test]
fn test_app_state_new_all_not_selected() {
let changes = vec![
create_test_change("change-a", 2, 5),
create_test_change("change-b", 0, 3),
];
let app = AppState::new(changes);
assert_eq!(app.mode, AppMode::Select);
assert_eq!(app.changes.len(), 2);
assert_eq!(app.cursor_index, 0);
assert!(!app.changes[0].selected);
assert!(!app.changes[1].selected);
}
#[test]
fn test_app_state_no_auto_selection() {
let changes = vec![
create_test_change("change-a", 2, 5),
create_test_change("change-b", 0, 3),
];
let app = AppState::new(changes);
assert_eq!(app.mode, AppMode::Select);
assert_eq!(app.changes.len(), 2);
assert!(!app.changes[0].selected);
assert!(!app.changes[1].selected);
assert!(!app
.logs
.iter()
.any(|log| log.message.contains("Auto-queued")));
}
#[test]
fn test_cursor_navigation() {
let changes = vec![
create_test_change("a", 0, 1),
create_test_change("b", 0, 1),
create_test_change("c", 0, 1),
];
let mut app = AppState::new(changes);
assert_eq!(app.cursor_index, 0);
app.cursor_down();
assert_eq!(app.cursor_index, 1);
app.cursor_down();
assert_eq!(app.cursor_index, 2);
app.cursor_down();
assert_eq!(app.cursor_index, 0);
app.cursor_up();
assert_eq!(app.cursor_index, 2); }
#[test]
fn test_toggle_selection() {
let changes = vec![create_test_change("a", 0, 1)];
let mut app = AppState::new(changes);
assert!(!app.changes[0].selected);
app.toggle_selection();
assert!(app.changes[0].selected);
app.toggle_selection();
assert!(!app.changes[0].selected);
}
#[test]
fn test_toggle_all_marks_select_mode() {
let changes = vec![
create_test_change("a", 0, 1),
create_test_change("b", 0, 1),
create_test_change("c", 0, 1),
];
let mut app = AppState::new(changes);
assert_eq!(app.mode, AppMode::Select);
assert!(!app.changes[0].selected);
assert!(!app.changes[1].selected);
assert!(!app.changes[2].selected);
app.toggle_all_marks();
assert!(app.changes[0].selected);
assert!(app.changes[1].selected);
assert!(app.changes[2].selected);
app.toggle_all_marks();
assert!(!app.changes[0].selected);
assert!(!app.changes[1].selected);
assert!(!app.changes[2].selected);
}
#[test]
fn test_toggle_all_marks_stopped_mode() {
let changes = vec![create_test_change("a", 0, 1), create_test_change("b", 0, 1)];
let mut app = AppState::new(changes);
app.mode = AppMode::Stopped;
app.toggle_all_marks();
assert!(app.changes[0].selected);
assert!(app.changes[1].selected);
app.toggle_all_marks();
assert!(!app.changes[0].selected);
assert!(!app.changes[1].selected);
}
#[test]
fn test_toggle_all_marks_parallel_mode_excludes_uncommitted() {
let changes = vec![
create_test_change("committed", 0, 1),
create_test_change("uncommitted", 0, 1),
];
let mut app = AppState::new(changes);
app.mode = AppMode::Select;
app.parallel_mode = true;
app.parallel_available = true;
app.changes[0].is_parallel_eligible = true;
app.changes[1].is_parallel_eligible = false;
app.toggle_all_marks();
assert!(app.changes[0].selected);
assert!(!app.changes[1].selected);
app.toggle_all_marks();
assert!(!app.changes[0].selected);
assert!(!app.changes[1].selected);
}
#[test]
fn test_toggle_all_marks_partial_selection() {
let changes = vec![
create_test_change("a", 0, 1),
create_test_change("b", 0, 1),
create_test_change("c", 0, 1),
];
let mut app = AppState::new(changes);
app.changes[0].selected = true;
app.toggle_all_marks();
assert!(app.changes[0].selected);
assert!(app.changes[1].selected);
assert!(app.changes[2].selected);
app.toggle_all_marks();
assert!(!app.changes[0].selected);
assert!(!app.changes[1].selected);
assert!(!app.changes[2].selected);
}
#[test]
fn test_toggle_all_marks_running_mode_toggles_non_active_rows_only() {
let changes = vec![
create_test_change("resolving", 0, 1),
create_test_change("not-queued", 0, 1),
create_test_change("merge-wait", 0, 1),
create_test_change("resolve-wait", 0, 1),
];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.is_resolving = true;
app.changes[0].display_status_cache = "resolving".to_string();
app.changes[1].display_status_cache = "not queued".to_string();
app.changes[2].display_status_cache = "merge wait".to_string();
app.changes[3].display_status_cache = "resolve pending".to_string();
app.toggle_all_marks();
assert!(!app.changes[0].selected, "active row must stay unchanged");
assert!(app.changes[1].selected);
assert!(app.changes[2].selected);
assert!(app.changes[3].selected);
assert_eq!(app.changes[2].display_status_cache, "merge wait");
assert_eq!(app.changes[3].display_status_cache, "resolve pending");
app.toggle_all_marks();
assert!(!app.changes[0].selected, "active row must stay unchanged");
assert!(!app.changes[1].selected);
assert!(!app.changes[2].selected);
assert!(!app.changes[3].selected);
}
#[test]
fn test_bulk_toggle_running_mode_emits_add_to_queue_commands() {
let changes = vec![
create_test_change("a", 0, 1),
create_test_change("b", 0, 1),
create_test_change("c", 0, 1),
];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.changes[0].display_status_cache = "not queued".to_string();
app.changes[1].display_status_cache = "not queued".to_string();
app.changes[2].display_status_cache = "not queued".to_string();
let commands = app.toggle_all_marks();
assert!(app.changes[0].selected);
assert!(app.changes[1].selected);
assert!(app.changes[2].selected);
assert_eq!(commands.len(), 3);
assert!(matches!(&commands[0], TuiCommand::AddToQueue(id) if id == "a"));
assert!(matches!(&commands[1], TuiCommand::AddToQueue(id) if id == "b"));
assert!(matches!(&commands[2], TuiCommand::AddToQueue(id) if id == "c"));
}
#[test]
fn test_bulk_toggle_running_mode_emits_remove_from_queue_commands() {
let changes = vec![create_test_change("a", 0, 1), create_test_change("b", 0, 1)];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.changes[0].display_status_cache = "queued".to_string();
app.changes[0].selected = true;
app.changes[1].display_status_cache = "queued".to_string();
app.changes[1].selected = true;
let commands = app.toggle_all_marks();
assert!(!app.changes[0].selected);
assert!(!app.changes[1].selected);
assert_eq!(commands.len(), 2);
assert!(matches!(&commands[0], TuiCommand::RemoveFromQueue(id) if id == "a"));
assert!(matches!(&commands[1], TuiCommand::RemoveFromQueue(id) if id == "b"));
}
#[test]
fn test_bulk_toggle_running_mode_no_commands_for_wait_states() {
let changes = vec![
create_test_change("not-queued", 0, 1),
create_test_change("merge-wait", 0, 1),
create_test_change("resolve-wait", 0, 1),
];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.changes[0].display_status_cache = "not queued".to_string();
app.changes[1].display_status_cache = "merge wait".to_string();
app.changes[2].display_status_cache = "resolve pending".to_string();
let commands = app.toggle_all_marks();
assert!(app.changes[0].selected);
assert!(app.changes[1].selected);
assert!(app.changes[2].selected);
assert_eq!(app.changes[1].display_status_cache, "merge wait");
assert_eq!(app.changes[2].display_status_cache, "resolve pending");
assert_eq!(commands.len(), 1);
assert!(matches!(&commands[0], TuiCommand::AddToQueue(id) if id == "not-queued"));
}
#[test]
fn test_bulk_toggle_running_mode_excludes_active_rows_from_commands() {
let changes = vec![
create_test_change("applying", 0, 1),
create_test_change("not-queued", 0, 1),
];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.changes[0].display_status_cache = "applying".to_string();
app.changes[1].display_status_cache = "not queued".to_string();
let commands = app.toggle_all_marks();
assert!(!app.changes[0].selected);
assert!(app.changes[1].selected);
assert_eq!(commands.len(), 1);
assert!(matches!(&commands[0], TuiCommand::AddToQueue(id) if id == "not-queued"));
assert!(!commands
.iter()
.any(|c| matches!(c, TuiCommand::DequeueChange(_))));
}
#[test]
fn test_bulk_toggle_running_mode_mixed_queued_and_not_queued() {
let changes = vec![create_test_change("a", 0, 1), create_test_change("b", 0, 1)];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.changes[0].display_status_cache = "queued".to_string();
app.changes[0].selected = true; app.changes[1].display_status_cache = "not queued".to_string();
app.changes[1].selected = false;
let commands = app.toggle_all_marks();
assert!(app.changes[0].selected);
assert!(app.changes[1].selected);
assert_eq!(commands.len(), 1);
assert!(matches!(&commands[0], TuiCommand::AddToQueue(id) if id == "b"));
}
#[test]
fn test_bulk_toggle_select_mode_returns_no_commands() {
let changes = vec![create_test_change("a", 0, 1), create_test_change("b", 0, 1)];
let mut app = AppState::new(changes);
app.mode = AppMode::Select;
let commands = app.toggle_all_marks();
assert!(app.changes[0].selected);
assert!(app.changes[1].selected);
assert!(
commands.is_empty(),
"Select mode must not emit queue commands"
);
}
#[test]
fn test_bulk_toggle_stopped_mode_returns_no_commands() {
let changes = vec![create_test_change("a", 0, 1), create_test_change("b", 0, 1)];
let mut app = AppState::new(changes);
app.mode = AppMode::Stopped;
let commands = app.toggle_all_marks();
assert!(app.changes[0].selected);
assert!(app.changes[1].selected);
assert!(
commands.is_empty(),
"Stopped mode must not emit queue commands"
);
}
#[test]
fn test_has_bulk_toggle_targets_running_mode_requires_non_active_rows() {
let changes = vec![create_test_change("a", 0, 1), create_test_change("b", 0, 1)];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.changes[0].display_status_cache = "applying".to_string();
app.changes[1].display_status_cache = "resolving".to_string();
assert!(!app.has_bulk_toggle_targets());
app.changes[1].display_status_cache = "resolve pending".to_string();
assert!(app.has_bulk_toggle_targets());
}
#[test]
fn test_start_processing_blocked_while_resolving() {
let changes = vec![create_test_change("a", 0, 1)];
let mut app = AppState::new(changes);
app.changes[0].selected = true;
app.is_resolving = true;
let command = app.start_processing();
assert!(
matches!(command, Some(TuiCommand::StartProcessing(ids)) if ids == vec!["a".to_string()])
);
assert_eq!(app.mode, AppMode::Running);
assert!(app.warning_message.is_none());
assert_eq!(app.changes[0].display_status_cache, "queued");
}
#[test]
fn test_resume_processing_blocked_while_resolving() {
let changes = vec![create_test_change("a", 0, 1)];
let mut app = AppState::new(changes);
app.mode = AppMode::Stopped;
app.changes[0].selected = true;
app.is_resolving = true;
let command = app.resume_processing();
assert!(
matches!(command, Some(TuiCommand::StartProcessing(ids)) if ids == vec!["a".to_string()])
);
assert_eq!(app.mode, AppMode::Running);
assert!(app.warning_message.is_none());
assert_eq!(app.changes[0].display_status_cache, "queued");
}
#[test]
fn test_retry_error_changes_blocked_while_resolving() {
let changes = vec![create_test_change("a", 0, 1)];
let mut app = AppState::new(changes);
app.mode = AppMode::Error;
app.changes[0].set_error_message_cache("boom".to_string());
app.is_resolving = true;
let command = app.retry_error_changes();
assert!(
matches!(command, Some(TuiCommand::StartProcessing(ids)) if ids == vec!["a".to_string()])
);
assert_eq!(app.mode, AppMode::Running);
assert!(app.warning_message.is_none());
assert_eq!(app.changes[0].display_status_cache, "queued");
}
#[test]
fn test_retry_error_changes_returns_error_rows_to_queued_status() {
let change_a = create_test_change("error-a", 0, 1);
let change_b = create_test_change("error-b", 0, 1);
let change_ok = create_test_change("ok", 0, 1);
let mut app = AppState::new(vec![change_a, change_b, change_ok]);
app.mode = AppMode::Error;
app.changes[0].set_error_message_cache("boom-a".to_string());
app.changes[1].set_error_message_cache("boom-b".to_string());
let command = app.retry_error_changes();
assert!(
matches!(command, Some(TuiCommand::StartProcessing(ids)) if ids == vec!["error-a".to_string(), "error-b".to_string()])
);
assert_eq!(app.mode, AppMode::Running);
assert_eq!(app.changes[0].display_status_cache, "queued");
assert_eq!(app.changes[1].display_status_cache, "queued");
assert_eq!(app.changes[2].display_status_cache, "not queued");
}
#[test]
fn test_selected_count() {
let changes = vec![
create_test_change("a", 0, 1),
create_test_change("b", 0, 1),
create_test_change("c", 0, 1),
];
let mut app = AppState::new(changes);
assert_eq!(app.selected_count(), 0);
app.toggle_selection(); assert_eq!(app.selected_count(), 1);
}
#[test]
fn test_update_change_status_blocks_archived_and_merged_to_queued() {
let mut archived = ChangeState {
id: "archived-change".to_string(),
completed_tasks: 0,
total_tasks: 1,
display_status_cache: "archived".to_string(),
display_color_cache: Color::Blue,
error_message_cache: None,
selected: false,
is_new: false,
is_parallel_eligible: true,
has_worktree: false,
started_at: None,
elapsed_time: None,
iteration_number: None,
};
apply_remote_status(&mut archived, "queued");
assert_eq!(archived.display_status_cache, "archived");
let mut merged = ChangeState {
id: "merged-change".to_string(),
completed_tasks: 0,
total_tasks: 1,
display_status_cache: "merged".to_string(),
display_color_cache: Color::LightBlue,
error_message_cache: None,
selected: false,
is_new: false,
is_parallel_eligible: true,
has_worktree: false,
started_at: None,
elapsed_time: None,
iteration_number: None,
};
apply_remote_status(&mut merged, "queued");
assert_eq!(merged.display_status_cache, "merged");
}
#[test]
fn test_running_mode_error_change_toggle_sets_retry_mark() {
let changes = vec![create_test_change("test-change", 0, 1)];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.changes[0].set_error_message_cache("boom".to_string());
app.changes[0].selected = false;
let command = app.toggle_selection();
assert!(
matches!(command, Some(TuiCommand::AddToQueue(ref id)) if id == "test-change"),
"error retry mark should emit AddToQueue command"
);
assert!(
app.changes[0].selected,
"Space should set retry mark on error change"
);
assert!(app.logs.iter().any(|log| log
.message
.contains("Marked for retry and added to queue: test-change")));
}
#[test]
fn test_running_mode_error_change_toggle_queue() {
let changes = vec![create_test_change("test-change", 0, 1)];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.changes[0].set_error_message_cache("boom".to_string());
app.changes[0].selected = false;
let first_command = app.toggle_selection();
assert!(
matches!(first_command, Some(TuiCommand::AddToQueue(ref id)) if id == "test-change")
);
assert!(app.changes[0].selected);
app.changes[0].set_display_status_cache("error");
let second_command = app.toggle_selection();
assert!(
matches!(second_command, Some(TuiCommand::RemoveFromQueue(ref id)) if id == "test-change")
);
assert!(!app.changes[0].selected);
assert!(app.logs.iter().any(|log| log
.message
.contains("Retry mark cleared and removed from queue: test-change")));
}
#[test]
fn test_stopped_mode_error_change_toggle_sets_retry_mark() {
let changes = vec![create_test_change("test-change", 0, 1)];
let mut app = AppState::new(changes);
app.mode = AppMode::Stopped;
app.changes[0].set_error_message_cache("boom".to_string());
app.changes[0].selected = false;
let command = app.toggle_selection();
assert!(
command.is_none(),
"stopped retry mark should be local state only"
);
assert!(
app.changes[0].selected,
"Space should set retry mark in stopped mode"
);
assert!(app
.logs
.iter()
.any(|log| log.message.contains("Marked for execution: test-change")));
}
#[test]
fn test_apply_display_statuses_from_reducer_updates_error_row_to_merged() {
let changes = vec![create_test_change("test-change", 0, 1)];
let mut app = AppState::new(changes);
app.changes[0].set_error_message_cache("previous failure".to_string());
app.changes[0].selected = true;
assert_eq!(app.changes[0].display_status_cache, "error");
let display_map = HashMap::from([("test-change".to_string(), "merged")]);
app.apply_display_statuses_from_reducer(&display_map);
assert_eq!(app.changes[0].display_status_cache, "merged");
assert!(
app.changes[0].selected,
"only rejected rows should be forcibly deselected during reducer sync"
);
}
#[test]
fn test_iteration_monotonic_update_from_none() {
let mut change = ChangeState {
id: "test".to_string(),
completed_tasks: 0,
total_tasks: 1,
display_status_cache: "applying".to_string(),
display_color_cache: Color::Cyan,
error_message_cache: None,
selected: false,
is_new: false,
is_parallel_eligible: true,
has_worktree: false,
started_at: None,
elapsed_time: None,
iteration_number: None,
};
change.update_iteration_monotonic(Some(1));
assert_eq!(change.iteration_number, Some(1));
change.update_iteration_monotonic(Some(2));
assert_eq!(change.iteration_number, Some(2));
}
#[test]
fn test_iteration_monotonic_prevents_regression() {
let mut change = ChangeState {
id: "test".to_string(),
completed_tasks: 0,
total_tasks: 1,
display_status_cache: "applying".to_string(),
display_color_cache: Color::Cyan,
error_message_cache: None,
selected: false,
is_new: false,
is_parallel_eligible: true,
has_worktree: false,
started_at: None,
elapsed_time: None,
iteration_number: Some(3),
};
change.update_iteration_monotonic(Some(1));
assert_eq!(change.iteration_number, Some(3));
change.update_iteration_monotonic(Some(3));
assert_eq!(change.iteration_number, Some(3));
change.update_iteration_monotonic(Some(5));
assert_eq!(change.iteration_number, Some(5));
}
#[test]
fn test_iteration_monotonic_ignores_none() {
let mut change = ChangeState {
id: "test".to_string(),
completed_tasks: 0,
total_tasks: 1,
display_status_cache: "applying".to_string(),
display_color_cache: Color::Cyan,
error_message_cache: None,
selected: false,
is_new: false,
is_parallel_eligible: true,
has_worktree: false,
started_at: None,
elapsed_time: None,
iteration_number: Some(2),
};
change.update_iteration_monotonic(None);
assert_eq!(change.iteration_number, Some(2));
}
#[test]
fn test_resolve_queue_fifo_order() {
let changes = vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
create_test_change("change-c", 0, 1),
];
let mut app = AppState::new(changes);
assert!(app.add_to_resolve_queue("change-a"));
assert!(app.add_to_resolve_queue("change-b"));
assert!(app.add_to_resolve_queue("change-c"));
assert_eq!(app.pop_from_resolve_queue(), Some("change-a".to_string()));
assert_eq!(app.pop_from_resolve_queue(), Some("change-b".to_string()));
assert_eq!(app.pop_from_resolve_queue(), Some("change-c".to_string()));
assert_eq!(app.pop_from_resolve_queue(), None);
}
#[test]
fn test_resolve_queue_duplicate_prevention() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
assert!(app.add_to_resolve_queue("change-a"));
assert!(!app.add_to_resolve_queue("change-a"));
assert_eq!(app.pop_from_resolve_queue(), Some("change-a".to_string()));
assert_eq!(app.pop_from_resolve_queue(), None);
}
#[test]
fn test_resolve_queue_auto_start_on_completion() {
let changes = vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "resolving".to_string();
app.is_resolving = true;
app.add_to_resolve_queue("change-b");
app.changes[1].display_status_cache = "resolve pending".to_string();
let cmd = app.handle_resolve_completed("change-a".to_string(), None);
assert!(matches!(cmd, Some(TuiCommand::ResolveMerge(id)) if id == "change-b"));
assert!(!app.is_resolving);
assert!(!app.has_queued_resolves());
}
#[test]
fn test_resolve_queue_no_auto_start_when_queue_empty() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "resolving".to_string();
app.is_resolving = true;
let cmd = app.handle_resolve_completed("change-a".to_string(), None);
assert!(cmd.is_none());
assert!(!app.is_resolving);
}
#[test]
fn test_resolve_completed_does_not_log_duplicate_after_merge_completed() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "merged".to_string();
let cmd = app.handle_resolve_completed("change-a".to_string(), None);
assert!(cmd.is_none());
assert!(!app
.logs
.iter()
.any(|log| log.message == "Merge resolved for 'change-a'"));
}
#[test]
fn test_resolve_merge_queues_when_resolving() {
let changes = vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "resolving".to_string();
app.is_resolving = true;
app.changes[1].display_status_cache = "merge wait".to_string();
app.cursor_index = 1;
app.mode = AppMode::Running;
let cmd = app.resolve_merge();
assert!(cmd.is_none());
assert_eq!(app.changes[1].display_status_cache, "resolve pending");
assert!(app.has_queued_resolves());
}
#[test]
fn test_resolve_merge_queues_syncs_reducer() {
use crate::orchestration::state::{OrchestratorState, WorkspaceObservation};
use std::sync::Arc;
let changes = vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
];
let mut app = AppState::new(changes);
let shared = Arc::new(tokio::sync::RwLock::new(OrchestratorState::new(
vec!["change-a".to_string(), "change-b".to_string()],
0,
)));
{
let mut guard = shared.blocking_write();
guard.apply_observation("change-b", WorkspaceObservation::WorkspaceArchived);
}
app.set_shared_state(shared.clone());
app.changes[0].display_status_cache = "resolving".to_string();
app.is_resolving = true;
app.changes[1].display_status_cache = "merge wait".to_string();
app.cursor_index = 1;
app.mode = AppMode::Running;
let cmd = app.resolve_merge();
assert!(cmd.is_none());
assert_eq!(app.changes[1].display_status_cache, "resolve pending");
let display_map = shared.blocking_read().all_display_statuses();
assert_eq!(
display_map.get("change-b"),
Some(&"resolve pending"),
"reducer must reflect 'resolve pending' after queued resolve_merge()"
);
}
#[test]
fn test_resolve_wait_survives_changes_refreshed() {
use crate::orchestration::state::{OrchestratorState, WorkspaceObservation};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
let changes = vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
];
let mut app = AppState::new(changes);
let shared = Arc::new(tokio::sync::RwLock::new(OrchestratorState::new(
vec!["change-a".to_string(), "change-b".to_string()],
0,
)));
{
let mut guard = shared.blocking_write();
guard.apply_observation("change-b", WorkspaceObservation::WorkspaceArchived);
}
app.set_shared_state(shared.clone());
app.changes[0].display_status_cache = "resolving".to_string();
app.is_resolving = true;
app.changes[1].display_status_cache = "merge wait".to_string();
app.cursor_index = 1;
app.mode = AppMode::Running;
let cmd = app.resolve_merge();
assert!(cmd.is_none());
assert_eq!(app.changes[1].display_status_cache, "resolve pending");
{
let mut guard = shared.blocking_write();
guard.apply_execution_event(&crate::events::ExecutionEvent::ChangesRefreshed {
changes: vec![],
committed_change_ids: HashSet::new(),
uncommitted_file_change_ids: HashSet::new(),
worktree_change_ids: HashSet::new(),
worktree_paths: HashMap::new(),
worktree_not_ahead_ids: HashSet::new(),
merge_wait_ids: ["change-b".to_string()].into_iter().collect(),
});
}
let display_map = shared.blocking_read().all_display_statuses();
app.apply_display_statuses_from_reducer(&display_map);
assert_eq!(
app.changes[1].display_status_cache, "resolve pending",
"ResolveWait must survive ChangesRefreshed + apply_display_statuses_from_reducer"
);
}
#[test]
fn test_resolve_merge_select_transitions_to_running() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "merge wait".to_string();
app.cursor_index = 0;
app.mode = AppMode::Select;
app.is_resolving = false;
let cmd = app.resolve_merge();
assert!(matches!(cmd, Some(TuiCommand::ResolveMerge(id)) if id == "change-a"));
assert_eq!(app.mode, AppMode::Running);
assert_eq!(app.changes[0].display_status_cache, "resolve pending");
}
#[test]
fn test_resolve_merge_stopped_transitions_to_running() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "merge wait".to_string();
app.cursor_index = 0;
app.mode = AppMode::Stopped;
app.is_resolving = false;
let cmd = app.resolve_merge();
assert!(matches!(cmd, Some(TuiCommand::ResolveMerge(id)) if id == "change-a"));
assert_eq!(app.mode, AppMode::Running);
assert_eq!(app.changes[0].display_status_cache, "resolve pending");
}
#[test]
fn test_resolve_merge_running_stays_running() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "merge wait".to_string();
app.cursor_index = 0;
app.mode = AppMode::Running;
app.is_resolving = false;
let cmd = app.resolve_merge();
assert!(matches!(cmd, Some(TuiCommand::ResolveMerge(id)) if id == "change-a"));
assert_eq!(app.mode, AppMode::Running);
assert_eq!(app.changes[0].display_status_cache, "resolve pending");
}
#[test]
fn test_resolve_merge_immediate_syncs_reducer() {
use crate::orchestration::state::{OrchestratorState, WorkspaceObservation};
use std::sync::Arc;
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
let shared = Arc::new(tokio::sync::RwLock::new(OrchestratorState::new(
vec!["change-a".to_string()],
0,
)));
{
let mut guard = shared.blocking_write();
guard.apply_observation("change-a", WorkspaceObservation::WorkspaceArchived);
}
app.set_shared_state(shared.clone());
app.changes[0].display_status_cache = "merge wait".to_string();
app.cursor_index = 0;
app.mode = AppMode::Running;
app.is_resolving = false;
let cmd = app.resolve_merge();
assert!(
matches!(&cmd, Some(TuiCommand::ResolveMerge(id)) if id == "change-a"),
"immediate resolve must return TuiCommand::ResolveMerge"
);
assert_eq!(app.changes[0].display_status_cache, "resolve pending");
let display_map = shared.blocking_read().all_display_statuses();
assert_eq!(
display_map.get("change-a"),
Some(&"resolve pending"),
"reducer must reflect 'resolve pending' after immediate resolve_merge()"
);
}
#[test]
fn test_resolve_wait_survives_changes_refreshed_after_immediate_resolve() {
use crate::orchestration::state::{OrchestratorState, WorkspaceObservation};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
let shared = Arc::new(tokio::sync::RwLock::new(OrchestratorState::new(
vec!["change-a".to_string()],
0,
)));
{
let mut guard = shared.blocking_write();
guard.apply_observation("change-a", WorkspaceObservation::WorkspaceArchived);
}
app.set_shared_state(shared.clone());
app.changes[0].display_status_cache = "merge wait".to_string();
app.cursor_index = 0;
app.mode = AppMode::Running;
app.is_resolving = false;
let cmd = app.resolve_merge();
assert!(cmd.is_some());
assert_eq!(app.changes[0].display_status_cache, "resolve pending");
{
let mut guard = shared.blocking_write();
guard.apply_execution_event(&crate::events::ExecutionEvent::ChangesRefreshed {
changes: vec![],
committed_change_ids: HashSet::new(),
uncommitted_file_change_ids: HashSet::new(),
worktree_change_ids: HashSet::new(),
worktree_paths: HashMap::new(),
worktree_not_ahead_ids: HashSet::new(),
merge_wait_ids: ["change-a".to_string()].into_iter().collect(),
});
}
let display_map = shared.blocking_read().all_display_statuses();
app.apply_display_statuses_from_reducer(&display_map);
assert_eq!(
app.changes[0].display_status_cache, "resolve pending",
"ResolveWait must survive ChangesRefreshed after immediate resolve"
);
}
#[test]
fn test_resolve_merge_consecutive_m_key_presses_queue_second_change() {
let changes = vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "merge wait".to_string();
app.changes[1].display_status_cache = "merge wait".to_string();
app.mode = AppMode::Running;
app.is_resolving = false;
app.cursor_index = 0;
let first_cmd = app.resolve_merge();
assert!(matches!(first_cmd, Some(TuiCommand::ResolveMerge(id)) if id == "change-a"));
assert!(
app.is_resolving,
"first resolve_merge() must set is_resolving=true immediately"
);
assert_eq!(app.changes[0].display_status_cache, "resolve pending");
app.cursor_index = 1;
let second_cmd = app.resolve_merge();
assert!(
second_cmd.is_none(),
"second resolve_merge() must queue while resolve is in progress"
);
assert_eq!(app.changes[1].display_status_cache, "resolve pending");
assert!(
app.resolve_queue_set.contains("change-b"),
"second change must be queued for resolve"
);
}
#[test]
fn test_apply_merge_wait_status_does_not_demote_merged() {
let changes = vec![create_test_change("change-a", 1, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "merged".to_string();
let mut display_map = std::collections::HashMap::new();
display_map.insert("change-a".to_string(), "merged");
app.apply_display_statuses_from_reducer(&display_map);
assert_eq!(
app.changes[0].display_status_cache, "merged",
"reducer-driven display must not demote a Merged change to MergeWait"
);
}
#[test]
fn test_apply_merge_wait_status_does_not_demote_blocked() {
let changes = vec![create_test_change("change-a", 1, 1)];
let mut app = AppState::new(changes);
let mut display_map = std::collections::HashMap::new();
display_map.insert("change-a".to_string(), "blocked");
app.apply_display_statuses_from_reducer(&display_map);
assert_eq!(
app.changes[0].display_status_cache, "blocked",
"reducer-driven display must not demote a Blocked change to MergeWait"
);
}
#[test]
fn test_apply_display_statuses_keeps_rejected_row_read_only() {
let changes = vec![create_test_change("change-a", 1, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "rejected".to_string();
app.changes[0].selected = true;
let mut display_map = std::collections::HashMap::new();
display_map.insert("change-a".to_string(), "not queued");
app.apply_display_statuses_from_reducer(&display_map);
assert_eq!(
app.changes[0].display_status_cache, "rejected",
"rejected row must stay immutable during reducer display sync"
);
assert!(
!app.changes[0].selected,
"rejected row must remain unselected"
);
}
#[test]
fn test_update_changes_reactivates_rejected_row_when_marker_removed() {
let changes = vec![create_test_change("change-a", 1, 1)];
let mut app = AppState::new(changes.clone());
app.changes[0].display_status_cache = "rejected".to_string();
app.changes[0].selected = true;
app.update_changes(changes);
assert_eq!(
app.changes[0].display_status_cache, "not queued",
"active refresh without marker should reactivate rejected row"
);
assert!(
!app.changes[0].selected,
"reactivated row must remain unselected until explicit user action"
);
}
#[test]
fn test_update_changes_new_rejected_row_is_not_new_and_not_counted() {
let mut app = AppState::new(vec![]);
let rejected = create_test_change("change-rejected", 0, 1);
app.update_changes_with_rejected_for_test(vec![], vec![rejected]);
let row = app
.changes
.iter()
.find(|c| c.id == "change-rejected")
.expect("rejected row should be added");
assert_eq!(row.display_status_cache, "rejected");
assert!(
!row.is_new,
"newly surfaced rejected row must not carry NEW badge"
);
assert_eq!(
app.new_change_count, 0,
"rejected rows must not increment NEW counter"
);
}
#[test]
fn test_toggle_selection_blocks_rejected_row() {
let changes = vec![create_test_change("change-a", 1, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "rejected".to_string();
app.mode = AppMode::Select;
let cmd = app.toggle_selection();
assert!(cmd.is_none(), "rejected row must not emit toggle commands");
assert!(
!app.changes[0].selected,
"rejected row must remain unselected"
);
assert!(
app.warning_message
.as_deref()
.is_some_and(|m| m.contains("read-only")),
"rejected toggle should explain read-only guard"
);
}
#[test]
fn test_start_processing_excludes_rejected_rows() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.mode = AppMode::Select;
app.changes[0].display_status_cache = "rejected".to_string();
app.changes[0].selected = true;
let cmd = app.start_processing();
assert!(cmd.is_none(), "F5 start must ignore rejected rows");
assert_eq!(app.changes[0].display_status_cache, "rejected");
assert_eq!(app.mode, AppMode::Select);
}
#[test]
fn test_toggle_all_marks_ignores_rejected_rows() {
let changes = vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
];
let mut app = AppState::new(changes);
app.mode = AppMode::Select;
app.changes[0].display_status_cache = "rejected".to_string();
app.changes[1].display_status_cache = "not queued".to_string();
let _ = app.toggle_all_marks();
assert!(
!app.changes[0].selected,
"bulk mark (@) must not mark rejected rows"
);
assert!(
app.changes[1].selected,
"eligible rows should still be marked"
);
}
#[test]
fn test_apply_display_statuses_rejected_clears_selection() {
let changes = vec![create_test_change("change-a", 1, 1)];
let mut app = AppState::new(changes);
app.changes[0].selected = true;
let mut display_map = std::collections::HashMap::new();
display_map.insert("change-a".to_string(), "rejected");
app.apply_display_statuses_from_reducer(&display_map);
assert_eq!(app.changes[0].display_status_cache, "rejected");
assert!(
!app.changes[0].selected,
"rejected terminal row must not keep execution mark"
);
}
#[test]
fn test_auto_clear_merge_wait_does_not_affect_merged() {
let changes = vec![create_test_change("change-a", 1, 1)];
let mut app = AppState::new(changes);
let mut display_map = std::collections::HashMap::new();
display_map.insert("change-a".to_string(), "merged");
app.apply_display_statuses_from_reducer(&display_map);
assert_eq!(
app.changes[0].display_status_cache, "merged",
"reducer-driven display must not transition a Merged change away from Merged"
);
}
#[test]
fn test_start_processing_does_not_queue_blocked_changes() {
let changes = vec![
create_test_change("applying", 0, 1),
create_test_change("blocked-b", 0, 1),
create_test_change("blocked-c", 0, 1),
];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "applying".to_string();
app.changes[0].selected = false;
app.changes[1].display_status_cache = "blocked".to_string();
app.changes[1].selected = true;
app.changes[2].display_status_cache = "blocked".to_string();
app.changes[2].selected = true;
let result = app.start_processing();
assert!(
result.is_none(),
"start_processing must return None when only Blocked changes are selected"
);
assert_eq!(
app.changes[1].display_status_cache, "blocked",
"Blocked change B must remain Blocked after start_processing"
);
assert_eq!(
app.changes[2].display_status_cache, "blocked",
"Blocked change C must remain Blocked after start_processing"
);
}
#[test]
fn test_tui_uses_reducer_display_status() {
use std::collections::HashMap;
let changes = vec![
create_test_change("c1", 0, 3),
create_test_change("c2", 0, 3),
];
let mut app = AppState::new(changes);
let mut display_map: HashMap<String, &'static str> = HashMap::new();
display_map.insert("c1".to_string(), "applying");
display_map.insert("c2".to_string(), "merge wait");
app.apply_display_statuses_from_reducer(&display_map);
assert_eq!(app.changes[0].display_status_cache, "applying");
assert_eq!(app.changes[1].display_status_cache, "merge wait");
assert!(matches!(
app.changes[0].display_status_cache.as_str(),
"applying"
));
}
#[test]
fn test_display_status_consistency_between_tui_and_web() {
use std::collections::HashMap;
let changes = vec![create_test_change("c1", 0, 3)];
let mut app = AppState::new(changes);
let scenarios: &[(&str, &str)] = &[
("blocked", "blocked"),
("merge wait", "merge wait"),
("resolve pending", "resolve pending"),
("resolving", "resolving"),
("archived", "archived"),
("merged", "merged"),
("queued", "queued"),
("not queued", "not queued"),
];
for (reducer_str, expected_tui_status) in scenarios {
let mut display_map: HashMap<String, &'static str> = HashMap::new();
display_map.insert("c1".to_string(), reducer_str);
app.apply_display_statuses_from_reducer(&display_map);
assert_eq!(
app.changes[0].display_status_cache, *expected_tui_status,
"reducer '{}' should map to {:?}",
reducer_str, expected_tui_status
);
}
}
#[test]
fn test_running_mode_toggle_emits_commands_without_local_status_mutation() {
let changes = vec![
create_test_change("c1", 0, 3),
create_test_change("c2", 0, 3),
];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.changes[0].display_status_cache = "not queued".to_string();
let cmd = app.toggle_selection();
assert!(
matches!(cmd, Some(TuiCommand::AddToQueue(ref id)) if id == "c1"),
"expected AddToQueue command, got {:?}",
cmd
);
assert_eq!(
app.changes[0].display_status_cache, "not queued",
"display_status_cache must NOT be mutated locally; reducer drives it"
);
app.cursor_index = 1;
app.changes[1].display_status_cache = "queued".to_string();
let cmd2 = app.toggle_selection();
assert!(
matches!(cmd2, Some(TuiCommand::RemoveFromQueue(ref id)) if id == "c2"),
"expected RemoveFromQueue command, got {:?}",
cmd2
);
assert_eq!(
app.changes[1].display_status_cache, "queued",
"display_status_cache must NOT be mutated locally; reducer drives it"
);
}
#[test]
fn test_running_mode_space_on_active_change_does_not_stop() {
let changes = vec![create_test_change("c1", 0, 3)];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.changes[0].display_status_cache = "applying".to_string();
let cmd = app.toggle_selection();
assert!(
cmd.is_none(),
"Space on active change must NOT issue any command, got {:?}",
cmd
);
}
#[test]
fn test_running_mode_space_on_accepting_does_not_stop() {
let changes = vec![create_test_change("c1", 0, 3)];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.changes[0].display_status_cache = "accepting".to_string();
let cmd = app.toggle_selection();
assert!(
cmd.is_none(),
"Space on accepting change must NOT issue any command, got {:?}",
cmd
);
}
#[test]
fn test_merge_wait_queue_operations() {
let changes = vec![create_test_change("c1", 0, 3)];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.changes[0].display_status_cache = "merge wait".to_string();
let cmd = app.toggle_selection();
assert!(
!matches!(
cmd,
Some(TuiCommand::AddToQueue(_)) | Some(TuiCommand::RemoveFromQueue(_))
),
"Space on MergeWait must not issue queue commands, got {:?}",
cmd
);
assert_eq!(app.changes[0].display_status_cache, "merge wait");
}
#[test]
fn test_resolve_wait_queue_operations() {
let changes = vec![create_test_change("c1", 0, 3)];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.changes[0].display_status_cache = "resolve pending".to_string();
let cmd = app.toggle_selection();
assert!(
!matches!(
cmd,
Some(TuiCommand::AddToQueue(_)) | Some(TuiCommand::RemoveFromQueue(_))
),
"Space on ResolveWait must not issue queue commands, got {:?}",
cmd
);
assert_eq!(app.changes[0].display_status_cache, "resolve pending");
}
#[test]
fn test_start_processing_syncs_reducer_queue_intent() {
use crate::orchestration::state::OrchestratorState;
use std::sync::Arc;
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.changes[0].selected = true;
app.changes[0].is_parallel_eligible = true;
let shared = Arc::new(tokio::sync::RwLock::new(OrchestratorState::new(
vec!["change-a".to_string()],
0,
)));
app.set_shared_state(shared.clone());
let cmd = app.start_processing();
assert!(cmd.is_some(), "start_processing should return a command");
let guard = shared.blocking_read();
assert_eq!(
guard.display_status("change-a"),
"queued",
"reducer queue_intent must be Queued after start_processing"
);
drop(guard);
let mut display_map = std::collections::HashMap::new();
display_map.insert("change-a".to_string(), "not queued");
app.apply_display_statuses_from_reducer(&display_map);
assert_eq!(
app.changes[0].display_status_cache,
"not queued",
"display sync should apply reducer snapshot – but reducer already says queued, so this tests the raw override path"
);
let guard2 = shared.blocking_read();
let real_map = guard2.all_display_statuses();
drop(guard2);
app.changes[0].display_status_cache = "queued".to_string(); app.apply_display_statuses_from_reducer(&real_map);
assert_eq!(
app.changes[0].display_status_cache, "queued",
"reducer snapshot must preserve Queued through ChangesRefreshed display sync"
);
}
#[test]
fn test_apply_display_statuses_from_reducer_shows_reject_pending() {
let changes = vec![create_test_change("reject-b", 0, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "queued".to_string();
let mut display_map = std::collections::HashMap::new();
display_map.insert("reject-b".to_string(), "reject pending");
app.apply_display_statuses_from_reducer(&display_map);
assert_eq!(app.changes[0].display_status_cache, "reject pending");
}
#[test]
fn test_resume_processing_syncs_reducer_queue_intent() {
use crate::orchestration::state::OrchestratorState;
use std::sync::Arc;
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.mode = AppMode::Stopped;
app.changes[0].selected = true;
app.changes[0].display_status_cache = "not queued".to_string();
let shared = Arc::new(tokio::sync::RwLock::new(OrchestratorState::new(
vec!["change-a".to_string()],
0,
)));
app.set_shared_state(shared.clone());
let cmd = app.resume_processing();
assert!(cmd.is_some(), "resume_processing should return a command");
let guard = shared.blocking_read();
assert_eq!(
guard.display_status("change-a"),
"queued",
"reducer queue_intent must be Queued after resume_processing"
);
}
#[test]
fn test_parallel_start_refresh_preserves_queued_rows() {
use crate::orchestration::state::OrchestratorState;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.changes[0].selected = true;
app.changes[0].is_parallel_eligible = true;
app.parallel_mode = true;
let shared = Arc::new(tokio::sync::RwLock::new(OrchestratorState::new(
vec!["change-a".to_string()],
0,
)));
app.set_shared_state(shared.clone());
let cmd = app.start_processing();
assert!(cmd.is_some());
assert_eq!(app.changes[0].display_status_cache, "queued");
{
let mut guard = shared.blocking_write();
guard.apply_execution_event(&crate::events::ExecutionEvent::ChangesRefreshed {
changes: vec![],
committed_change_ids: HashSet::new(),
uncommitted_file_change_ids: HashSet::new(),
worktree_change_ids: HashSet::new(),
worktree_paths: HashMap::new(),
worktree_not_ahead_ids: HashSet::new(),
merge_wait_ids: HashSet::new(),
});
}
let display_map = shared.blocking_read().all_display_statuses();
app.apply_display_statuses_from_reducer(&display_map);
assert_eq!(
app.changes[0].display_status_cache, "queued",
"initial parallel ChangesRefreshed must not regress a queued row to not-queued"
);
}
#[test]
fn test_parallel_start_state_reset_preserves_queued_rows() {
use crate::orchestration::state::{OrchestratorState, ReducerCommand};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.changes[0].selected = true;
app.changes[0].is_parallel_eligible = true;
app.parallel_mode = true;
let shared = Arc::new(tokio::sync::RwLock::new(OrchestratorState::new(
vec!["change-a".to_string()],
0,
)));
app.set_shared_state(shared.clone());
let cmd = app.start_processing();
assert!(cmd.is_some());
assert_eq!(app.changes[0].display_status_cache, "queued");
{
let mut guard = shared.blocking_write();
*guard = OrchestratorState::with_mode(
vec!["change-a".to_string()],
0,
crate::orchestration::state::ExecutionMode::Parallel,
);
guard.apply_command(ReducerCommand::AddToQueue("change-a".to_string()));
}
{
let mut guard = shared.blocking_write();
guard.apply_execution_event(&crate::events::ExecutionEvent::ChangesRefreshed {
changes: vec![],
committed_change_ids: HashSet::new(),
uncommitted_file_change_ids: HashSet::new(),
worktree_change_ids: HashSet::new(),
worktree_paths: HashMap::new(),
worktree_not_ahead_ids: HashSet::new(),
merge_wait_ids: HashSet::new(),
});
}
let display_map = shared.blocking_read().all_display_statuses();
app.apply_display_statuses_from_reducer(&display_map);
assert_eq!(
app.changes[0].display_status_cache, "queued",
"state reset followed by AddToQueue must preserve Queued through ChangesRefreshed"
);
}
#[test]
fn test_dependency_block_preserves_queued_intent() {
use crate::orchestration::state::OrchestratorState;
use std::sync::Arc;
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
let shared = Arc::new(tokio::sync::RwLock::new(OrchestratorState::new(
vec!["change-a".to_string()],
0,
)));
app.set_shared_state(shared.clone());
app.changes[0].selected = true;
app.changes[0].is_parallel_eligible = true;
app.start_processing();
assert_eq!(shared.blocking_read().display_status("change-a"), "queued");
{
let mut guard = shared.blocking_write();
guard.apply_execution_event(&crate::events::ExecutionEvent::DependencyBlocked {
change_id: "change-a".to_string(),
dependency_ids: vec!["dep".to_string()],
});
}
assert_eq!(shared.blocking_read().display_status("change-a"), "blocked");
}
#[test]
fn test_dependency_resolved_restores_queued_display() {
use crate::orchestration::state::OrchestratorState;
use std::sync::Arc;
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
let shared = Arc::new(tokio::sync::RwLock::new(OrchestratorState::new(
vec!["change-a".to_string()],
0,
)));
app.set_shared_state(shared.clone());
app.changes[0].selected = true;
app.changes[0].is_parallel_eligible = true;
app.start_processing();
{
let mut guard = shared.blocking_write();
guard.apply_execution_event(&crate::events::ExecutionEvent::DependencyBlocked {
change_id: "change-a".to_string(),
dependency_ids: vec!["dep".to_string()],
});
guard.apply_execution_event(&crate::events::ExecutionEvent::DependencyResolved {
change_id: "change-a".to_string(),
});
}
let display_map = shared.blocking_read().all_display_statuses();
app.apply_display_statuses_from_reducer(&display_map);
assert_eq!(
app.changes[0].display_status_cache, "queued",
"dependency resolution must restore queued display, not not-queued"
);
}
#[test]
fn test_parallel_start_rejected_does_not_clear_other_rows() {
use crate::orchestration::state::OrchestratorState;
use std::sync::Arc;
let changes = vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
];
let mut app = AppState::new(changes);
let shared = Arc::new(tokio::sync::RwLock::new(OrchestratorState::new(
vec!["change-a".to_string(), "change-b".to_string()],
0,
)));
app.set_shared_state(shared.clone());
{
let mut guard = shared.blocking_write();
guard.apply_command(crate::orchestration::state::ReducerCommand::AddToQueue(
"change-a".to_string(),
));
guard.apply_command(crate::orchestration::state::ReducerCommand::AddToQueue(
"change-b".to_string(),
));
}
app.changes[0].display_status_cache = "queued".to_string();
app.changes[1].display_status_cache = "queued".to_string();
app.mode = AppMode::Running;
app.handle_orchestrator_event(OrchestratorEvent::ParallelStartRejected {
change_ids: vec!["change-a".to_string()],
reason: "uncommitted".to_string(),
});
assert_eq!(app.changes[0].display_status_cache, "not queued");
assert_eq!(
shared.blocking_read().display_status("change-a"),
"not queued",
"reducer must clear queue intent for rejected change-a"
);
assert_eq!(app.changes[1].display_status_cache, "queued");
assert_eq!(
shared.blocking_read().display_status("change-b"),
"queued",
"reducer must not touch change-b which was not rejected"
);
}
#[test]
fn test_all_completed_transitions_to_select_even_when_resolving() {
let changes = vec![
create_test_change("change-a", 3, 3),
create_test_change("change-b", 2, 4),
];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.changes[0].display_status_cache = "resolving".to_string();
app.handle_all_completed();
assert_eq!(
app.mode,
AppMode::Select,
"Should transition to Select because scheduler manages ResolveWait"
);
}
#[test]
fn test_resolve_completed_transitions_to_select_when_no_active() {
let changes = vec![
create_test_change("change-a", 3, 3),
create_test_change("change-b", 2, 4),
];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.changes[0].display_status_cache = "merged".to_string(); app.changes[1].display_status_cache = "resolving".to_string();
app.is_resolving = true;
app.handle_resolve_completed("change-b".to_string(), None);
assert_eq!(
app.mode,
AppMode::Select,
"Should transition to Select when no active changes remain after resolve"
);
}
#[test]
fn test_resolve_completed_stays_running_when_other_active() {
let changes = vec![
create_test_change("change-a", 1, 3),
create_test_change("change-b", 2, 4),
];
let mut app = AppState::new(changes);
app.mode = AppMode::Running;
app.changes[0].display_status_cache = "applying".to_string(); app.changes[1].display_status_cache = "resolving".to_string();
app.is_resolving = true;
app.handle_resolve_completed("change-b".to_string(), None);
assert_eq!(
app.mode,
AppMode::Running,
"Should stay Running when other active changes remain"
);
}
}