use std::time::Instant;
use super::{
reduce_connection, reduce_er, reduce_explain, reduce_metadata, reduce_modal, reduce_navigation,
reduce_query, reduce_result, reduce_sql_modal,
};
use crate::app::cmd::effect::Effect;
use crate::app::model::app_state::AppState;
use crate::app::model::shared::focused_pane::FocusedPane;
use crate::app::model::shared::input_mode::InputMode;
use crate::app::model::shared::key_sequence::KeySequenceState;
use crate::app::services::AppServices;
use crate::app::update::action::{Action, TableTarget};
use crate::domain::TableSummary;
pub fn reduce(
state: &mut AppState,
action: Action,
now: Instant,
services: &AppServices,
) -> Vec<Effect> {
let should_mark_dirty = !matches!(action, Action::None | Action::Render);
let effects = reduce_inner(state, action, now, services);
if should_mark_dirty {
state.mark_dirty();
}
effects
}
fn reduce_inner(
state: &mut AppState,
action: Action,
now: Instant,
services: &AppServices,
) -> Vec<Effect> {
state.result_interaction.clear_operator_pending(
matches!(action, Action::ResultDeleteOperatorPending),
matches!(action, Action::ResultRowYankOperatorPending),
);
if let Some(effects) = reduce_connection(state, &action, now)
.or_else(|| reduce_modal(state, &action, now))
.or_else(|| reduce_result(state, &action, services, now))
.or_else(|| reduce_navigation(state, &action, services, now))
.or_else(|| reduce_sql_modal(state, &action, now))
.or_else(|| reduce_explain(state, &action, now))
.or_else(|| reduce_metadata(state, &action, now))
.or_else(|| reduce_er(state, &action, now))
.or_else(|| reduce_query(state, &action, now, services))
{
return effects;
}
match action {
Action::BeginKeySequence(prefix) => {
state.ui.key_sequence = KeySequenceState::WaitingSecondKey(prefix);
vec![]
}
Action::CancelKeySequence => {
state.ui.key_sequence = KeySequenceState::Idle;
vec![]
}
Action::Quit => {
state.should_quit = true;
vec![]
}
Action::Resize(_w, h) => {
state.ui.terminal_height = h;
vec![]
}
Action::Render => {
vec![Effect::Render]
}
Action::ConfirmSelection => {
if state.modal.active_mode() == InputMode::TablePicker {
let table = state
.filtered_tables()
.get(state.ui.table_picker.selected())
.copied()
.cloned();
if let Some(table) = table {
state.modal.set_mode(InputMode::Normal);
return select_table(state, &table);
}
} else if state.modal.active_mode() == InputMode::Normal {
if state.connection_error.error_info.is_some() {
state.modal.replace_mode(InputMode::ConnectionError);
return vec![];
}
if state.ui.focused_pane != FocusedPane::Explorer {
return vec![];
}
let table = state
.tables()
.get(state.ui.explorer_selected)
.copied()
.cloned();
if let Some(table) = table {
return select_table(state, &table);
}
} else if state.modal.active_mode() == InputMode::CommandPalette {
use crate::app::update::input::palette::palette_action_for_index;
let cmd_action = palette_action_for_index(state.ui.table_picker.selected());
state.modal.set_mode(InputMode::Normal);
return reduce(state, cmd_action, now, services);
}
vec![]
}
Action::Scroll { .. }
| Action::ScrollToCursor { .. }
| Action::TextInput { .. }
| Action::TextBackspace { .. }
| Action::TextDelete { .. }
| Action::TextMoveCursor { .. }
| Action::Select(_)
| Action::ListSelect { .. } => {
debug_assert!(false, "unhandled parametric action: {action:?}");
vec![]
}
_ => vec![],
}
}
fn select_table(state: &mut AppState, table: &TableSummary) -> Vec<Effect> {
let generation =
state
.session
.select_table(&table.schema, &table.name, &mut state.query.pagination);
state.result_interaction.reset_interaction();
let schema = table.schema.clone();
let table_name = table.name.clone();
let mut effects = Vec::new();
if let Some(dsn) = &state.session.dsn {
effects.push(Effect::FetchTableDetail {
dsn: dsn.clone(),
schema: schema.clone(),
table: table_name.clone(),
generation,
});
}
effects.push(Effect::DispatchActions(vec![Action::ExecutePreview(
TableTarget {
schema,
table: table_name,
generation,
},
)]));
effects
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::app::ports::DbOperationError;
use crate::app::ports::connection_store::ConnectionStoreError;
use crate::app::update::action::{ConnectionSaveError, ConnectionTarget};
use crate::app::update::action::{
InputTarget, ScrollAmount, ScrollDirection, ScrollTarget, SelectMotion,
};
fn create_test_state() -> AppState {
AppState::new("test_project".to_string())
}
mod pure_actions {
use super::*;
use rstest::rstest;
#[test]
fn quit_sets_should_quit_and_returns_no_effects() {
let mut state = create_test_state();
let now = Instant::now();
let effects = reduce(&mut state, Action::Quit, now, &AppServices::stub());
assert!(state.should_quit);
assert!(effects.is_empty());
}
#[test]
fn toggle_focus_returns_no_effects() {
let mut state = create_test_state();
let now = Instant::now();
let effects = reduce(&mut state, Action::ToggleFocus, now, &AppServices::stub());
assert!(state.ui.focus_mode);
assert!(effects.is_empty());
}
#[test]
fn resize_updates_terminal_height() {
let mut state = create_test_state();
let now = Instant::now();
let effects = reduce(
&mut state,
Action::Resize(100, 50),
now,
&AppServices::stub(),
);
assert_eq!(state.ui.terminal_height, 50);
assert!(effects.is_empty());
}
#[test]
fn render_returns_render_effect() {
let mut state = create_test_state();
let now = Instant::now();
let effects = reduce(&mut state, Action::Render, now, &AppServices::stub());
assert_eq!(effects.len(), 1);
assert!(matches!(effects[0], Effect::Render));
}
#[rstest]
#[case(Action::Select(SelectMotion::First))]
#[case(Action::Select(SelectMotion::Last))]
#[case(Action::Select(SelectMotion::Next))]
#[case(Action::Select(SelectMotion::Previous))]
fn selection_on_empty_tables_keeps_none(#[case] action: Action) {
let mut state = create_test_state();
state.ui.focused_pane = FocusedPane::Explorer;
state.ui.explorer_selected = 0;
let now = Instant::now();
reduce(&mut state, action, now, &AppServices::stub());
assert_eq!(state.ui.explorer_selected, 0);
}
}
mod scroll_actions {
use super::*;
use rstest::rstest;
#[test]
fn result_scroll_up_decrements_offset() {
let mut state = create_test_state();
state.result_interaction.scroll_offset = 5;
let now = Instant::now();
let effects = reduce(
&mut state,
Action::Scroll {
target: ScrollTarget::Result,
direction: ScrollDirection::Up,
amount: ScrollAmount::Line,
},
now,
&AppServices::stub(),
);
assert_eq!(state.result_interaction.scroll_offset, 4);
assert!(effects.is_empty());
}
#[test]
fn result_scroll_up_saturates_at_zero() {
let mut state = create_test_state();
state.result_interaction.scroll_offset = 0;
let now = Instant::now();
let effects = reduce(
&mut state,
Action::Scroll {
target: ScrollTarget::Result,
direction: ScrollDirection::Up,
amount: ScrollAmount::Line,
},
now,
&AppServices::stub(),
);
assert_eq!(state.result_interaction.scroll_offset, 0);
assert!(effects.is_empty());
}
#[test]
fn result_scroll_top_resets_to_zero() {
let mut state = create_test_state();
state.result_interaction.scroll_offset = 10;
let now = Instant::now();
let effects = reduce(
&mut state,
Action::Scroll {
target: ScrollTarget::Result,
direction: ScrollDirection::Up,
amount: ScrollAmount::ToStart,
},
now,
&AppServices::stub(),
);
assert_eq!(state.result_interaction.scroll_offset, 0);
assert!(effects.is_empty());
}
#[rstest]
#[case(ScrollTarget::Result, ScrollDirection::Down, ScrollAmount::Line)]
#[case(ScrollTarget::Result, ScrollDirection::Up, ScrollAmount::HalfPage)]
#[case(ScrollTarget::Result, ScrollDirection::Left, ScrollAmount::Line)]
#[case(ScrollTarget::Result, ScrollDirection::Right, ScrollAmount::Line)]
#[case(ScrollTarget::Result, ScrollDirection::Up, ScrollAmount::ToStart)]
#[case(ScrollTarget::Result, ScrollDirection::Down, ScrollAmount::FullPage)]
#[case(ScrollTarget::Inspector, ScrollDirection::Down, ScrollAmount::Line)]
#[case(ScrollTarget::Inspector, ScrollDirection::Up, ScrollAmount::Line)]
#[case(ScrollTarget::Help, ScrollDirection::Up, ScrollAmount::Line)]
#[case(ScrollTarget::Help, ScrollDirection::Down, ScrollAmount::Line)]
#[case(
ScrollTarget::ConnectionError,
ScrollDirection::Down,
ScrollAmount::Line
)]
#[case(ScrollTarget::ExplainPlan, ScrollDirection::Down, ScrollAmount::Line)]
#[case(ScrollTarget::ExplainPlan, ScrollDirection::Up, ScrollAmount::Line)]
#[case(
ScrollTarget::ExplainCompare,
ScrollDirection::Down,
ScrollAmount::Line
)]
#[case(ScrollTarget::ExplainCompare, ScrollDirection::Up, ScrollAmount::Line)]
#[case(
ScrollTarget::ExplainConfirm,
ScrollDirection::Down,
ScrollAmount::Line
)]
#[case(ScrollTarget::ExplainConfirm, ScrollDirection::Up, ScrollAmount::Line)]
#[case(ScrollTarget::Explorer, ScrollDirection::Left, ScrollAmount::Line)]
#[case(ScrollTarget::Explorer, ScrollDirection::Right, ScrollAmount::Line)]
fn scroll_reduce_never_returns_effects(
#[case] target: ScrollTarget,
#[case] direction: ScrollDirection,
#[case] amount: ScrollAmount,
) {
let mut state = create_test_state();
let now = Instant::now();
let effects = reduce(
&mut state,
Action::Scroll {
target,
direction,
amount,
},
now,
&AppServices::stub(),
);
assert!(
effects.is_empty(),
"scroll reduce must return empty effects for coalescing safety"
);
}
}
mod modal_toggles {
use super::*;
#[test]
fn open_table_picker_sets_mode_and_clears_filter() {
let mut state = create_test_state();
state
.ui
.table_picker
.filter_input
.set_content("test".to_string());
let now = Instant::now();
let effects = reduce(
&mut state,
Action::OpenTablePicker,
now,
&AppServices::stub(),
);
assert_eq!(state.input_mode(), InputMode::TablePicker);
assert!(state.ui.table_picker.filter_input.content().is_empty());
assert_eq!(state.ui.table_picker.selected(), 0);
assert!(effects.is_empty());
}
#[test]
fn close_table_picker_returns_to_normal() {
let mut state = create_test_state();
state.modal.set_mode(InputMode::TablePicker);
let now = Instant::now();
let effects = reduce(
&mut state,
Action::CloseTablePicker,
now,
&AppServices::stub(),
);
assert_eq!(state.input_mode(), InputMode::Normal);
assert!(effects.is_empty());
}
#[test]
fn open_help_toggles_help_mode() {
let mut state = create_test_state();
let now = Instant::now();
let effects = reduce(&mut state, Action::OpenHelp, now, &AppServices::stub());
assert_eq!(state.input_mode(), InputMode::Help);
assert!(effects.is_empty());
let effects = reduce(&mut state, Action::OpenHelp, now, &AppServices::stub());
assert_eq!(state.input_mode(), InputMode::Normal);
assert!(effects.is_empty());
}
}
mod sql_modal_debounce {
use super::*;
use std::time::Duration;
#[test]
fn sql_modal_input_sets_debounce_state() {
let mut state = create_test_state();
state.modal.set_mode(InputMode::SqlModal);
let now = Instant::now();
let effects = reduce(
&mut state,
Action::TextInput {
target: InputTarget::SqlModal,
ch: 'a',
},
now,
&AppServices::stub(),
);
assert_eq!(state.sql_modal.editor.content(), "a");
assert_eq!(state.sql_modal.editor.cursor(), 1);
assert!(effects.is_empty());
assert!(state.sql_modal.completion_debounce.is_some());
}
#[test]
fn sql_modal_backspace_sets_debounce_state() {
let mut state = create_test_state();
state.sql_modal.editor.set_content("ab".to_string());
let now = Instant::now();
let effects = reduce(
&mut state,
Action::TextBackspace {
target: InputTarget::SqlModal,
},
now,
&AppServices::stub(),
);
assert_eq!(state.sql_modal.editor.content(), "a");
assert_eq!(state.sql_modal.editor.cursor(), 1);
assert!(effects.is_empty());
assert!(state.sql_modal.completion_debounce.is_some());
}
#[test]
fn debounce_state_uses_provided_now() {
let mut state = create_test_state();
let now = Instant::now();
reduce(
&mut state,
Action::TextInput {
target: InputTarget::SqlModal,
ch: 'x',
},
now,
&AppServices::stub(),
);
let expected = now + Duration::from_millis(100);
assert_eq!(state.sql_modal.completion_debounce, Some(expected));
}
}
mod completion_ui {
use super::*;
use crate::app::model::sql_editor::completion::{CompletionCandidate, CompletionKind};
fn make_candidate(text: &str) -> CompletionCandidate {
CompletionCandidate {
text: text.to_string(),
kind: CompletionKind::Table,
score: 0,
}
}
#[test]
fn completion_next_wraps_around() {
let mut state = create_test_state();
state.sql_modal.completion.candidates = vec![make_candidate("a"), make_candidate("b")];
state.sql_modal.completion.selected_index = 1;
let now = Instant::now();
let effects = reduce(
&mut state,
Action::CompletionNext,
now,
&AppServices::stub(),
);
assert_eq!(state.sql_modal.completion.selected_index, 0);
assert!(effects.is_empty());
}
#[test]
fn completion_prev_wraps_around() {
let mut state = create_test_state();
state.sql_modal.completion.candidates = vec![make_candidate("a"), make_candidate("b")];
state.sql_modal.completion.selected_index = 0;
let now = Instant::now();
let effects = reduce(
&mut state,
Action::CompletionPrev,
now,
&AppServices::stub(),
);
assert_eq!(state.sql_modal.completion.selected_index, 1);
assert!(effects.is_empty());
}
}
mod response_handlers {
use super::*;
use crate::app::model::connection::error::ConnectionErrorInfo;
use crate::domain::{DatabaseMetadata, MetadataState, TableSummary};
#[test]
fn metadata_loaded_with_empty_tables_selects_none() {
let mut state = create_test_state();
state.ui.explorer_selected = 5;
let metadata = DatabaseMetadata {
database_name: "test".to_string(),
schemas: vec![],
table_summaries: vec![],
fetched_at: Instant::now(),
};
let now = Instant::now();
reduce(
&mut state,
Action::MetadataLoaded(Arc::new(metadata)),
now,
&AppServices::stub(),
);
assert!(state.session.metadata().is_some());
assert_eq!(state.ui.explorer_selected, 0);
}
#[test]
fn metadata_loaded_with_tables_selects_first() {
let mut state = create_test_state();
state.ui.explorer_selected = 3;
let metadata = DatabaseMetadata {
database_name: "test".to_string(),
schemas: vec![],
table_summaries: vec![TableSummary::new(
"public".to_string(),
"users".to_string(),
None,
false,
)],
fetched_at: Instant::now(),
};
let now = Instant::now();
reduce(
&mut state,
Action::MetadataLoaded(Arc::new(metadata)),
now,
&AppServices::stub(),
);
assert!(state.session.metadata().is_some());
assert_eq!(state.ui.explorer_selected, 0);
}
#[test]
fn metadata_failed_opens_error_modal_automatically() {
let mut state = create_test_state();
let now = Instant::now();
let effects = reduce(
&mut state,
Action::MetadataFailed(DbOperationError::ConnectionFailed(
"psql: error: connection refused".to_string(),
)),
now,
&AppServices::stub(),
);
assert!(matches!(
state.session.metadata_state(),
MetadataState::Error(_)
));
assert_eq!(state.input_mode(), InputMode::ConnectionError);
assert!(state.connection_error.error_info.is_some());
assert!(effects.is_empty());
}
#[test]
fn enter_with_error_info_opens_modal() {
let mut state = create_test_state();
state
.connection_error
.set_error(ConnectionErrorInfo::new("error"));
state.ui.focused_pane = FocusedPane::Result; let now = Instant::now();
reduce(
&mut state,
Action::ConfirmSelection,
now,
&AppServices::stub(),
);
assert_eq!(state.input_mode(), InputMode::ConnectionError);
}
}
mod connection_error_actions {
use super::*;
use crate::app::model::connection::error::{ConnectionErrorInfo, ConnectionErrorKind};
use crate::domain::MetadataState;
fn state_with_error() -> AppState {
let mut state = create_test_state();
let info = ConnectionErrorInfo::with_kind(
ConnectionErrorKind::HostUnreachable,
"psql: error: could not translate host",
);
state.connection_error.set_error(info);
state.modal.set_mode(InputMode::ConnectionError);
state
}
#[test]
fn close_keeps_error_info_for_reopen() {
let mut state = state_with_error();
state.connection_error.details_expanded = true;
state.connection_error.scroll_offset = 5;
let now = Instant::now();
reduce(
&mut state,
Action::CloseConnectionError,
now,
&AppServices::stub(),
);
assert!(state.connection_error.error_info.is_some());
assert_eq!(state.input_mode(), InputMode::Normal);
assert!(!state.connection_error.details_expanded);
assert_eq!(state.connection_error.scroll_offset, 0);
}
#[test]
fn close_clears_copied_feedback() {
let mut state = state_with_error();
let now = Instant::now();
state.connection_error.mark_copied_at(now);
assert!(state.connection_error.is_copied_visible_at(now));
reduce(
&mut state,
Action::CloseConnectionError,
now,
&AppServices::stub(),
);
assert!(!state.connection_error.is_copied_visible_at(now));
}
#[test]
fn reopen_modal_after_close_shows_same_error() {
let mut state = state_with_error();
state
.session
.set_metadata_state(MetadataState::Error("error".to_string()));
state.ui.focused_pane = FocusedPane::Explorer;
let now = Instant::now();
reduce(
&mut state,
Action::CloseConnectionError,
now,
&AppServices::stub(),
);
assert_eq!(state.input_mode(), InputMode::Normal);
reduce(
&mut state,
Action::ConfirmSelection,
now,
&AppServices::stub(),
);
assert_eq!(state.input_mode(), InputMode::ConnectionError);
assert!(state.connection_error.error_info.is_some());
}
#[test]
fn toggle_details_flips_expanded_state() {
let mut state = state_with_error();
let now = Instant::now();
assert!(!state.connection_error.details_expanded);
reduce(
&mut state,
Action::ToggleConnectionErrorDetails,
now,
&AppServices::stub(),
);
assert!(state.connection_error.details_expanded);
reduce(
&mut state,
Action::ToggleConnectionErrorDetails,
now,
&AppServices::stub(),
);
assert!(!state.connection_error.details_expanded);
}
#[test]
fn copy_returns_clipboard_effect() {
let mut state = state_with_error();
let now = Instant::now();
let effects = reduce(
&mut state,
Action::CopyConnectionError,
now,
&AppServices::stub(),
);
assert_eq!(effects.len(), 1);
assert!(matches!(effects[0], Effect::CopyToClipboard { .. }));
}
#[test]
fn copied_marks_feedback_visible() {
let mut state = state_with_error();
let now = Instant::now();
reduce(
&mut state,
Action::ConnectionErrorCopied,
now,
&AppServices::stub(),
);
assert!(state.connection_error.is_copied_visible_at(now));
}
}
mod confirm_selection_safety {
use super::*;
use crate::domain::{DatabaseMetadata, Table, TableSummary};
fn stale_table_detail() -> Table {
Table {
schema: "public".to_string(),
name: "old_table".to_string(),
owner: None,
columns: vec![],
primary_key: None,
foreign_keys: vec![],
indexes: vec![],
rls: None,
triggers: vec![],
row_count_estimate: None,
comment: None,
}
}
fn users_metadata(now: Instant) -> Arc<DatabaseMetadata> {
Arc::new(DatabaseMetadata {
database_name: "test".to_string(),
schemas: vec![],
table_summaries: vec![TableSummary::new(
"public".to_string(),
"users".to_string(),
Some(100),
false,
)],
fetched_at: now,
})
}
#[test]
fn confirm_selection_in_normal_mode_clears_stale_table_detail() {
let now = Instant::now();
let mut state = create_test_state();
state.session.set_metadata(Some(users_metadata(now)));
state
.session
.set_table_detail_raw(Some(stale_table_detail()));
state.modal.set_mode(InputMode::Normal);
state.ui.focused_pane = FocusedPane::Explorer;
state.ui.set_explorer_selection(Some(0));
reduce(
&mut state,
Action::ConfirmSelection,
now,
&AppServices::stub(),
);
assert!(state.session.table_detail().is_none());
}
#[test]
fn confirm_selection_in_table_picker_mode_clears_stale_table_detail() {
let now = Instant::now();
let mut state = create_test_state();
state.session.set_metadata(Some(users_metadata(now)));
state
.session
.set_table_detail_raw(Some(stale_table_detail()));
state.modal.set_mode(InputMode::TablePicker);
state.ui.table_picker.set_selection(0);
reduce(
&mut state,
Action::ConfirmSelection,
now,
&AppServices::stub(),
);
assert!(state.session.table_detail().is_none());
}
}
mod effect_producing_actions {
use super::*;
use crate::domain::{DatabaseMetadata, MetadataState};
#[test]
fn load_metadata_with_dsn_returns_fetch_effect() {
let mut state = create_test_state();
state.session.dsn = Some("postgres://localhost/test".to_string());
let now = Instant::now();
let effects = reduce(&mut state, Action::LoadMetadata, now, &AppServices::stub());
assert_eq!(effects.len(), 1);
assert!(matches!(effects[0], Effect::FetchMetadata { .. }));
assert!(matches!(
state.session.metadata_state(),
MetadataState::Loading
));
}
#[test]
fn load_metadata_without_dsn_returns_no_effects() {
let mut state = create_test_state();
state.session.dsn = None;
let now = Instant::now();
let effects = reduce(&mut state, Action::LoadMetadata, now, &AppServices::stub());
assert!(effects.is_empty());
}
#[test]
fn reload_metadata_returns_sequence_effect() {
let mut state = create_test_state();
state.session.dsn = Some("postgres://localhost/test".to_string());
let now = Instant::now();
let effects = reduce(
&mut state,
Action::ReloadMetadata,
now,
&AppServices::stub(),
);
assert_eq!(effects.len(), 1);
assert!(matches!(effects[0], Effect::Sequence(_)));
if let Effect::Sequence(seq) = &effects[0] {
assert_eq!(seq.len(), 3);
assert!(matches!(seq[0], Effect::CacheInvalidate { .. }));
assert!(matches!(seq[1], Effect::ClearCompletionEngineCache));
assert!(matches!(seq[2], Effect::FetchMetadata { .. }));
}
}
#[test]
fn reload_metadata_sets_is_reloading_flag() {
let mut state = create_test_state();
state.session.dsn = Some("postgres://localhost/test".to_string());
let now = Instant::now();
reduce(
&mut state,
Action::ReloadMetadata,
now,
&AppServices::stub(),
);
assert!(state.session.is_reloading);
}
#[test]
fn reload_then_metadata_loaded_shows_reloaded_message() {
let mut state = create_test_state();
state.session.dsn = Some("postgres://localhost/test".to_string());
let now = Instant::now();
reduce(
&mut state,
Action::ReloadMetadata,
now,
&AppServices::stub(),
);
assert!(state.session.is_reloading);
let metadata = DatabaseMetadata {
database_name: "test".to_string(),
schemas: vec![],
table_summaries: vec![],
fetched_at: now,
};
reduce(
&mut state,
Action::MetadataLoaded(Arc::new(metadata)),
now,
&AppServices::stub(),
);
assert!(!state.session.is_reloading);
assert_eq!(state.messages.last_success, Some("Reloaded!".to_string()));
}
#[test]
fn execute_adhoc_with_dsn_returns_effect() {
let mut state = create_test_state();
state.session.dsn = Some("postgres://localhost/test".to_string());
let now = Instant::now();
let effects = reduce(
&mut state,
Action::ExecuteAdhoc("SELECT 1".to_string()),
now,
&AppServices::stub(),
);
assert_eq!(effects.len(), 1);
assert!(matches!(effects[0], Effect::ExecuteAdhoc { .. }));
}
}
mod er_diagram {
use super::*;
use crate::app::model::er_state::ErStatus;
use crate::domain::DatabaseMetadata;
#[test]
fn er_open_while_rendering_returns_no_effects() {
let mut state = create_test_state();
state.er_preparation.status = ErStatus::Rendering;
let now = Instant::now();
let effects = reduce(&mut state, Action::ErOpenDiagram, now, &AppServices::stub());
assert!(effects.is_empty());
}
#[test]
fn always_emits_smart_refresh_even_with_pending_tables() {
let mut state = create_test_state();
state.session.dsn = Some("postgres://localhost/test".to_string());
state.session.set_metadata(Some(Arc::new(DatabaseMetadata {
database_name: "test".to_string(),
schemas: vec![],
table_summaries: vec![],
fetched_at: Instant::now(),
})));
state.sql_modal.begin_prefetch();
state
.er_preparation
.pending_tables
.insert("public.users".to_string());
let now = Instant::now();
let effects = reduce(&mut state, Action::ErOpenDiagram, now, &AppServices::stub());
assert_eq!(state.er_preparation.status, ErStatus::Waiting);
assert!(!state.sql_modal.is_prefetch_started());
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::SmartErRefresh { .. }));
}
#[test]
fn prefetch_started_true_emits_smart_refresh() {
let mut state = create_test_state();
state.session.dsn = Some("postgres://localhost/test".to_string());
state.session.set_metadata(Some(Arc::new(DatabaseMetadata {
database_name: "test".to_string(),
schemas: vec![],
table_summaries: vec![],
fetched_at: Instant::now(),
})));
state.sql_modal.begin_prefetch();
let now = Instant::now();
let effects = reduce(&mut state, Action::ErOpenDiagram, now, &AppServices::stub());
assert!(!state.sql_modal.is_prefetch_started());
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::SmartErRefresh { .. }));
}
#[test]
fn no_prefetch_emits_smart_refresh() {
let mut state = create_test_state();
state.session.dsn = Some("postgres://localhost/test".to_string());
state.session.set_metadata(Some(Arc::new(DatabaseMetadata {
database_name: "test".to_string(),
schemas: vec![],
table_summaries: vec![],
fetched_at: Instant::now(),
})));
let now = Instant::now();
let effects = reduce(&mut state, Action::ErOpenDiagram, now, &AppServices::stub());
assert_eq!(state.er_preparation.status, ErStatus::Waiting);
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::SmartErRefresh { .. }));
}
#[test]
fn no_metadata_returns_error() {
let mut state = create_test_state();
state.sql_modal.begin_prefetch();
let now = Instant::now();
let effects = reduce(&mut state, Action::ErOpenDiagram, now, &AppServices::stub());
assert!(state.messages.last_error.is_some());
assert!(effects.is_empty());
}
#[test]
fn metadata_failed_resets_er_waiting_to_idle() {
let mut state = create_test_state();
state.er_preparation.status = ErStatus::Waiting;
let now = Instant::now();
reduce(
&mut state,
Action::MetadataFailed(DbOperationError::ConnectionFailed(
"connection refused".to_string(),
)),
now,
&AppServices::stub(),
);
assert_eq!(state.er_preparation.status, ErStatus::Idle);
}
}
mod table_detail_cached {
use super::*;
use crate::domain::Table;
fn make_test_table() -> Box<Table> {
Box::new(Table {
schema: "public".to_string(),
name: "users".to_string(),
owner: None,
columns: vec![],
primary_key: None,
indexes: vec![],
foreign_keys: vec![],
rls: None,
triggers: vec![],
row_count_estimate: None,
comment: None,
})
}
#[test]
fn table_detail_cached_returns_cache_effect() {
let mut state = create_test_state();
state
.sql_modal
.prefetching_tables
.insert("public.users".to_string());
let now = Instant::now();
let effects = reduce(
&mut state,
Action::TableDetailCached {
schema: "public".to_string(),
table: "users".to_string(),
detail: make_test_table(),
},
now,
&AppServices::stub(),
);
assert!(!effects.is_empty());
assert!(matches!(
effects[0],
Effect::CacheTableInCompletionEngine { .. }
));
assert!(!state.sql_modal.prefetching_tables.contains("public.users"));
}
#[test]
fn table_detail_cached_with_queue_returns_process_effect() {
let mut state = create_test_state();
state
.sql_modal
.prefetch_queue
.push_back("public.orders".to_string());
let now = Instant::now();
let effects = reduce(
&mut state,
Action::TableDetailCached {
schema: "public".to_string(),
table: "users".to_string(),
detail: make_test_table(),
},
now,
&AppServices::stub(),
);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::ProcessPrefetchQueue))
);
}
}
mod connection_setup_validation {
use crate::app::model::connection::setup::{ConnectionField, ConnectionSetupState};
use crate::app::model::shared::text_input::TextInputState;
use crate::app::update::helpers::{validate_all, validate_field};
use rstest::rstest;
fn setup_state() -> ConnectionSetupState {
ConnectionSetupState::default()
}
#[rstest]
#[case(ConnectionField::Host, "", true)]
#[case(ConnectionField::Host, " ", true)]
#[case(ConnectionField::Host, "localhost", false)]
#[case(ConnectionField::Database, "", true)]
#[case(ConnectionField::Database, "mydb", false)]
#[case(ConnectionField::User, "", true)]
#[case(ConnectionField::User, "postgres", false)]
fn required_field_validation(
#[case] field: ConnectionField,
#[case] value: &str,
#[case] has_error: bool,
) {
let mut state = setup_state();
match field {
ConnectionField::Host => state.host.set_content(value.to_string()),
ConnectionField::Database => state.database.set_content(value.to_string()),
ConnectionField::User => state.user.set_content(value.to_string()),
_ => {}
}
validate_field(&mut state, field);
assert_eq!(state.validation_errors.contains_key(&field), has_error);
}
#[rstest]
#[case("")]
#[case("abc")]
fn port_validation_invalid_format(#[case] value: &str) {
let mut state = setup_state();
state.port.set_content(value.to_string());
validate_field(&mut state, ConnectionField::Port);
assert!(state.validation_errors.contains_key(&ConnectionField::Port));
}
#[rstest]
#[case("0")]
#[case("65536")]
#[case("99999")]
fn port_validation_out_of_range(#[case] value: &str) {
let mut state = setup_state();
state.port.set_content(value.to_string());
validate_field(&mut state, ConnectionField::Port);
assert!(state.validation_errors.contains_key(&ConnectionField::Port));
}
#[rstest]
#[case("1")]
#[case("5432")]
#[case("65535")]
fn port_validation_valid_range(#[case] value: &str) {
let mut state = setup_state();
state.port.set_content(value.to_string());
validate_field(&mut state, ConnectionField::Port);
assert!(!state.validation_errors.contains_key(&ConnectionField::Port));
}
#[rstest]
#[case(ConnectionField::Password)]
#[case(ConnectionField::SslMode)]
fn optional_fields_never_error(#[case] field: ConnectionField) {
let mut state = setup_state();
state.password = TextInputState::default();
validate_field(&mut state, field);
assert!(!state.validation_errors.contains_key(&field));
}
#[test]
fn validate_all_checks_all_required_fields() {
let mut state = setup_state();
state.host = TextInputState::default();
state.port.set_content("invalid".to_string());
state.database = TextInputState::default();
state.user = TextInputState::default();
validate_all(&mut state);
assert!(state.validation_errors.contains_key(&ConnectionField::Host));
assert!(state.validation_errors.contains_key(&ConnectionField::Port));
assert!(
state
.validation_errors
.contains_key(&ConnectionField::Database)
);
assert!(state.validation_errors.contains_key(&ConnectionField::User));
assert!(
!state
.validation_errors
.contains_key(&ConnectionField::Password)
);
assert!(
!state
.validation_errors
.contains_key(&ConnectionField::SslMode)
);
}
}
mod connection_setup_transitions {
use super::*;
use crate::domain::ConnectionId;
#[test]
fn save_completed_sets_dsn_and_returns_fetch_effect() {
let mut state = create_test_state();
state.modal.set_mode(InputMode::ConnectionSetup);
state.connection_setup.is_first_run = true;
state
.connection_setup
.host
.set_content("db.example.com".to_string());
state.connection_setup.port.set_content("5432".to_string());
state
.connection_setup
.database
.set_content("mydb".to_string());
let now = Instant::now();
let effects = reduce(
&mut state,
Action::ConnectionSaveCompleted(ConnectionTarget {
id: ConnectionId::new(),
dsn: "postgres://db.example.com/mydb".to_string(),
name: "Test Connection".to_string(),
}),
now,
&AppServices::stub(),
);
assert!(!state.connection_setup.is_first_run);
assert_eq!(
state.session.dsn,
Some("postgres://db.example.com/mydb".to_string())
);
assert_eq!(
state.session.active_connection_name,
Some("Test Connection".to_string())
);
assert_eq!(state.input_mode(), InputMode::Normal);
assert_eq!(effects.len(), 1);
assert!(matches!(effects[0], Effect::FetchMetadata { .. }));
}
#[test]
fn save_failed_sets_error_message() {
let mut state = create_test_state();
state.modal.set_mode(InputMode::ConnectionSetup);
let now = Instant::now();
let effects = reduce(
&mut state,
Action::ConnectionSaveFailed(ConnectionSaveError::Store(
ConnectionStoreError::IoError("Write error".to_string()),
)),
now,
&AppServices::stub(),
);
assert!(state.messages.last_error.is_some());
assert!(effects.is_empty());
}
#[test]
fn cancel_on_first_run_opens_confirm_dialog() {
let mut state = create_test_state();
state.modal.set_mode(InputMode::ConnectionSetup);
state.connection_setup.is_first_run = true;
let now = Instant::now();
let effects = reduce(
&mut state,
Action::ConnectionSetupCancel,
now,
&AppServices::stub(),
);
assert_eq!(state.input_mode(), InputMode::ConfirmDialog);
assert!(matches!(
state.confirm_dialog.intent(),
Some(&crate::app::model::shared::confirm_dialog::ConfirmIntent::QuitNoConnection)
));
assert!(effects.is_empty());
}
#[test]
fn cancel_after_save_returns_to_normal_and_dispatches_try_connect() {
let mut state = create_test_state();
state.modal.set_mode(InputMode::ConnectionSetup);
state.connection_setup.is_first_run = false;
let now = Instant::now();
let effects = reduce(
&mut state,
Action::ConnectionSetupCancel,
now,
&AppServices::stub(),
);
assert_eq!(state.input_mode(), InputMode::Normal);
assert_eq!(effects.len(), 1);
assert!(matches!(effects[0], Effect::DispatchActions(_)));
}
}
mod confirm_dialog_transitions {
use super::*;
use crate::app::model::shared::confirm_dialog::ConfirmIntent;
#[test]
fn confirm_quit_no_connection_sets_should_quit() {
let mut state = create_test_state();
state.modal.set_mode(InputMode::ConfirmDialog);
state
.confirm_dialog
.open("", "", ConfirmIntent::QuitNoConnection);
let now = Instant::now();
reduce(
&mut state,
Action::ConfirmDialogConfirm,
now,
&AppServices::stub(),
);
assert!(state.should_quit);
assert!(state.confirm_dialog.intent().is_none());
}
#[test]
fn cancel_quit_no_connection_restores_connection_setup_synchronously() {
let mut state = create_test_state();
state.modal.set_mode(InputMode::ConfirmDialog);
state
.confirm_dialog
.open("", "", ConfirmIntent::QuitNoConnection);
let now = Instant::now();
let effects = reduce(
&mut state,
Action::ConfirmDialogCancel,
now,
&AppServices::stub(),
);
assert!(state.confirm_dialog.intent().is_none());
assert_eq!(state.input_mode(), InputMode::ConnectionSetup);
assert!(effects.is_empty());
}
#[test]
fn confirm_delete_write_then_success_preserves_delete_context() {
use crate::app::policy::write::write_guardrails::{
GuardrailDecision, RiskLevel, TargetSummary, WriteOperation, WritePreview,
};
let mut state = create_test_state();
state.session.dsn = Some("postgres://localhost/test".to_string());
state.modal.set_mode(InputMode::ConfirmDialog);
let delete_sql = "DELETE FROM \"public\".\"users\"\nWHERE \"id\" = '2';".to_string();
state.result_interaction.set_write_preview(WritePreview {
operation: WriteOperation::Delete,
sql: delete_sql.clone(),
target_summary: TargetSummary {
schema: "public".to_string(),
table: "users".to_string(),
key_values: vec![("id".to_string(), "2".to_string())],
},
diff: vec![],
guardrail: GuardrailDecision {
risk_level: RiskLevel::Low,
blocked: false,
reason: None,
target_summary: None,
},
});
state.query.set_delete_refresh_target(0, Some(499), 1);
state.confirm_dialog.open(
"",
"",
ConfirmIntent::ExecuteWrite {
sql: delete_sql,
blocked: false,
},
);
let now = Instant::now();
let effects = reduce(
&mut state,
Action::ConfirmDialogConfirm,
now,
&AppServices::stub(),
);
assert!(state.result_interaction.pending_write_preview().is_some());
assert!(state.query.pending_delete_refresh_target().is_some());
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::ExecuteWrite { .. }));
let effects = reduce(
&mut state,
Action::ExecuteWriteSucceeded { affected_rows: 1 },
now,
&AppServices::stub(),
);
assert!(state.result_interaction.pending_write_preview().is_none());
assert!(
state
.messages
.last_success
.as_deref()
.unwrap()
.contains("Deleted")
);
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::ExecutePreview { .. }));
}
#[test]
fn confirm_delete_write_then_failure_returns_to_normal() {
use crate::app::policy::write::write_guardrails::{
GuardrailDecision, RiskLevel, TargetSummary, WriteOperation, WritePreview,
};
let mut state = create_test_state();
state.session.dsn = Some("postgres://localhost/test".to_string());
state.modal.set_mode(InputMode::ConfirmDialog);
state.result_interaction.set_write_preview(WritePreview {
operation: WriteOperation::Delete,
sql: "DELETE FROM t WHERE id='1'".to_string(),
target_summary: TargetSummary {
schema: "public".to_string(),
table: "t".to_string(),
key_values: vec![],
},
diff: vec![],
guardrail: GuardrailDecision {
risk_level: RiskLevel::Low,
blocked: false,
reason: None,
target_summary: None,
},
});
state.confirm_dialog.open(
"",
"",
ConfirmIntent::ExecuteWrite {
sql: "DELETE FROM t WHERE id='1'".to_string(),
blocked: false,
},
);
let now = Instant::now();
reduce(
&mut state,
Action::ConfirmDialogConfirm,
now,
&AppServices::stub(),
);
reduce(
&mut state,
Action::ExecuteWriteFailed(DbOperationError::QueryFailed(
"connection lost".to_string(),
)),
now,
&AppServices::stub(),
);
assert_eq!(state.input_mode(), InputMode::Normal);
assert!(state.result_interaction.pending_write_preview().is_none());
}
}
mod connection_state_tests {
use super::*;
use crate::app::model::connection::state::ConnectionState;
use crate::domain::{ConnectionId, DatabaseMetadata, MetadataState};
#[test]
fn try_connect_with_dsn_starts_connecting() {
let mut state = create_test_state();
state.session.dsn = Some("postgres://localhost/test".to_string());
state
.session
.set_connection_state(ConnectionState::NotConnected);
state.modal.set_mode(InputMode::Normal);
let now = Instant::now();
let effects = reduce(&mut state, Action::TryConnect, now, &AppServices::stub());
assert!(state.session.connection_state().is_connecting());
assert!(matches!(
state.session.metadata_state(),
MetadataState::Loading
));
assert_eq!(effects.len(), 1);
assert!(matches!(effects[0], Effect::FetchMetadata { .. }));
}
#[test]
fn try_connect_without_dsn_does_nothing() {
let mut state = create_test_state();
state.session.dsn = None;
state
.session
.set_connection_state(ConnectionState::NotConnected);
state.modal.set_mode(InputMode::Normal);
let now = Instant::now();
let effects = reduce(&mut state, Action::TryConnect, now, &AppServices::stub());
assert!(state.session.connection_state().is_not_connected());
assert!(effects.is_empty());
}
#[test]
fn try_connect_when_already_connecting_is_noop() {
let mut state = create_test_state();
state.session.dsn = Some("postgres://localhost/test".to_string());
state
.session
.set_connection_state(ConnectionState::Connecting);
state.modal.set_mode(InputMode::Normal);
let now = Instant::now();
let effects = reduce(&mut state, Action::TryConnect, now, &AppServices::stub());
assert!(state.session.connection_state().is_connecting());
assert!(effects.is_empty());
}
#[test]
fn try_connect_when_already_connected_is_noop() {
let mut state = create_test_state();
state.session.dsn = Some("postgres://localhost/test".to_string());
state
.session
.set_connection_state(ConnectionState::Connected);
state.modal.set_mode(InputMode::Normal);
let now = Instant::now();
let effects = reduce(&mut state, Action::TryConnect, now, &AppServices::stub());
assert!(state.session.connection_state().is_connected());
assert!(effects.is_empty());
}
#[test]
fn try_connect_when_not_in_normal_mode_is_noop() {
let mut state = create_test_state();
state.session.dsn = Some("postgres://localhost/test".to_string());
state
.session
.set_connection_state(ConnectionState::NotConnected);
state.modal.set_mode(InputMode::ConnectionSetup);
let now = Instant::now();
let effects = reduce(&mut state, Action::TryConnect, now, &AppServices::stub());
assert!(state.session.connection_state().is_not_connected());
assert!(effects.is_empty());
}
#[test]
fn metadata_loaded_sets_connected() {
let mut state = create_test_state();
state
.session
.set_connection_state(ConnectionState::Connecting);
let metadata = DatabaseMetadata {
database_name: "test".to_string(),
schemas: vec![],
table_summaries: vec![],
fetched_at: Instant::now(),
};
let now = Instant::now();
reduce(
&mut state,
Action::MetadataLoaded(Arc::new(metadata)),
now,
&AppServices::stub(),
);
assert!(state.session.connection_state().is_connected());
assert!(matches!(
state.session.metadata_state(),
MetadataState::Loaded
));
}
#[test]
fn metadata_failed_sets_failed() {
let mut state = create_test_state();
state
.session
.set_connection_state(ConnectionState::Connecting);
let now = Instant::now();
reduce(
&mut state,
Action::MetadataFailed(DbOperationError::ConnectionFailed(
"connection refused".to_string(),
)),
now,
&AppServices::stub(),
);
assert!(state.session.connection_state().is_failed());
assert!(matches!(
state.session.metadata_state(),
MetadataState::Error(_)
));
}
#[test]
fn metadata_failed_preserves_connected_state() {
let mut state = create_test_state();
state
.session
.set_connection_state(ConnectionState::Connected);
state.session.set_metadata_state(MetadataState::Loaded);
let now = Instant::now();
reduce(
&mut state,
Action::MetadataFailed(DbOperationError::QueryFailed(
"permission denied".to_string(),
)),
now,
&AppServices::stub(),
);
assert!(state.session.connection_state().is_connected());
assert!(matches!(
state.session.metadata_state(),
MetadataState::Error(_)
));
}
#[test]
fn reenter_connection_setup_resets_all_states() {
let mut state = create_test_state();
state.session.set_connection_state(ConnectionState::Failed);
state
.session
.set_metadata_state(MetadataState::Error("error".to_string()));
state.modal.set_mode(InputMode::ConnectionError);
let now = Instant::now();
reduce(
&mut state,
Action::ReenterConnectionSetup,
now,
&AppServices::stub(),
);
assert!(state.session.connection_state().is_not_connected());
assert!(matches!(
state.session.metadata_state(),
MetadataState::NotLoaded
));
assert_eq!(state.input_mode(), InputMode::ConnectionSetup);
}
#[test]
fn reenter_connection_setup_preserves_form_values() {
let mut state = create_test_state();
state
.connection_setup
.host
.set_content("custom-host".to_string());
state.connection_setup.port.set_content("5433".to_string());
state
.connection_setup
.database
.set_content("mydb".to_string());
state.connection_setup.user.set_content("admin".to_string());
state
.connection_setup
.password
.set_content("secret".to_string());
state.session.set_connection_state(ConnectionState::Failed);
let now = Instant::now();
reduce(
&mut state,
Action::ReenterConnectionSetup,
now,
&AppServices::stub(),
);
assert_eq!(state.connection_setup.host.content(), "custom-host");
assert_eq!(state.connection_setup.port.content(), "5433");
assert_eq!(state.connection_setup.database.content(), "mydb");
assert_eq!(state.connection_setup.user.content(), "admin");
assert_eq!(state.connection_setup.password.content(), "secret");
}
#[test]
fn connection_save_completed_sets_connecting_and_loading() {
let mut state = create_test_state();
state
.session
.set_connection_state(ConnectionState::NotConnected);
state.session.set_metadata_state(MetadataState::NotLoaded);
let now = Instant::now();
let effects = reduce(
&mut state,
Action::ConnectionSaveCompleted(ConnectionTarget {
id: ConnectionId::new(),
dsn: "postgres://localhost/test".to_string(),
name: "Test".to_string(),
}),
now,
&AppServices::stub(),
);
assert!(state.session.connection_state().is_connecting());
assert!(matches!(
state.session.metadata_state(),
MetadataState::Loading
));
assert_eq!(effects.len(), 1);
assert!(matches!(effects[0], Effect::FetchMetadata { .. }));
}
#[test]
fn switch_connection_saves_current_and_fetches_new() {
let mut state = create_test_state();
let conn_a = ConnectionId::new();
let conn_b = ConnectionId::new();
state.session.active_connection_id = Some(conn_a.clone());
state
.session
.set_connection_state(ConnectionState::Connected);
state.ui.explorer_selected = 5;
let now = Instant::now();
let effects = reduce(
&mut state,
Action::SwitchConnection(ConnectionTarget {
id: conn_b.clone(),
dsn: "postgres://localhost/other".to_string(),
name: "Other".to_string(),
}),
now,
&AppServices::stub(),
);
assert_eq!(state.session.active_connection_id, Some(conn_b));
assert!(state.session.connection_state().is_connecting());
assert!(state.connection_caches.get(&conn_a).is_some());
assert_eq!(
state
.connection_caches
.get(&conn_a)
.unwrap()
.explorer_selected,
5
);
assert_eq!(effects.len(), 2);
}
#[test]
fn switch_connection_restores_from_cache() {
use crate::app::model::shared::inspector_tab::InspectorTab;
let mut state = create_test_state();
let conn_a = ConnectionId::new();
let conn_b = ConnectionId::new();
state.session.active_connection_id = Some(conn_a);
state
.session
.set_connection_state(ConnectionState::Connected);
state.ui.explorer_selected = 3;
let cached = crate::app::model::connection::cache::ConnectionCache {
explorer_selected: 10,
inspector_tab: InspectorTab::Indexes,
metadata: Some(Arc::new(DatabaseMetadata {
database_name: "cached_db".to_string(),
schemas: vec![],
table_summaries: vec![],
fetched_at: Instant::now(),
})),
..Default::default()
};
state.connection_caches.save(&conn_b, cached);
let now = Instant::now();
let effects = reduce(
&mut state,
Action::SwitchConnection(ConnectionTarget {
id: conn_b.clone(),
dsn: "postgres://localhost/cached".to_string(),
name: "Cached".to_string(),
}),
now,
&AppServices::stub(),
);
assert_eq!(state.session.active_connection_id, Some(conn_b));
assert!(state.session.connection_state().is_connected());
assert_eq!(state.ui.explorer_selected, 10);
assert_eq!(state.ui.inspector_tab, InspectorTab::Indexes);
assert_eq!(
state.session.metadata().as_ref().unwrap().database_name,
"cached_db"
);
assert_eq!(effects.len(), 1);
}
}
mod er_table_picker {
use super::*;
use crate::domain::{DatabaseMetadata, TableSummary};
fn state_with_metadata() -> AppState {
let mut state = create_test_state();
state.session.set_metadata(Some(Arc::new(DatabaseMetadata {
database_name: "test".to_string(),
schemas: vec![],
table_summaries: vec![
TableSummary::new("public".to_string(), "users".to_string(), None, false),
TableSummary::new("public".to_string(), "posts".to_string(), None, false),
],
fetched_at: Instant::now(),
})));
state
}
#[test]
fn open_clears_selections_and_filter() {
let mut state = state_with_metadata();
state
.ui
.er_picker
.filter_input
.set_content("old".to_string());
state
.ui
.er_selected_tables
.insert("public.users".to_string());
let now = Instant::now();
let effects = reduce(
&mut state,
Action::OpenErTablePicker,
now,
&AppServices::stub(),
);
assert_eq!(state.input_mode(), InputMode::ErTablePicker);
assert!(state.ui.er_picker.filter_input.content().is_empty());
assert!(state.ui.er_selected_tables.is_empty());
assert!(effects.is_empty());
}
#[test]
fn open_without_metadata_sets_pending() {
let mut state = create_test_state();
let now = Instant::now();
let effects = reduce(
&mut state,
Action::OpenErTablePicker,
now,
&AppServices::stub(),
);
assert!(state.ui.pending_er_picker);
assert!(state.messages.last_success.is_some());
assert_ne!(state.input_mode(), InputMode::ErTablePicker);
assert!(effects.is_empty());
}
fn sample_metadata() -> Arc<DatabaseMetadata> {
Arc::new(DatabaseMetadata {
database_name: "test_db".to_string(),
schemas: vec![],
table_summaries: vec![TableSummary::new(
"public".to_string(),
"users".to_string(),
Some(100),
false,
)],
fetched_at: Instant::now(),
})
}
fn has_open_er_dispatch(effects: &[Effect]) -> bool {
effects.iter().any(|e| {
matches!(e, Effect::DispatchActions(actions)
if actions.iter().any(|a| matches!(a, Action::OpenErTablePicker)))
})
}
#[test]
fn metadata_loaded_with_pending_dispatches_open() {
let mut state = create_test_state();
state.ui.pending_er_picker = true;
state.modal.set_mode(InputMode::Normal);
let now = Instant::now();
let effects = reduce(
&mut state,
Action::MetadataLoaded(sample_metadata()),
now,
&AppServices::stub(),
);
assert!(!state.ui.pending_er_picker);
assert!(has_open_er_dispatch(&effects));
}
#[test]
fn metadata_loaded_without_pending_does_not_dispatch_open() {
let mut state = create_test_state();
state.ui.pending_er_picker = false;
let now = Instant::now();
let effects = reduce(
&mut state,
Action::MetadataLoaded(sample_metadata()),
now,
&AppServices::stub(),
);
assert!(!has_open_er_dispatch(&effects));
}
#[test]
fn metadata_loaded_with_pending_but_non_normal_mode_discards() {
let mut state = create_test_state();
state.ui.pending_er_picker = true;
state.modal.set_mode(InputMode::SqlModal);
let now = Instant::now();
let effects = reduce(
&mut state,
Action::MetadataLoaded(sample_metadata()),
now,
&AppServices::stub(),
);
assert!(!state.ui.pending_er_picker);
assert!(!has_open_er_dispatch(&effects));
}
#[test]
fn close_er_table_picker_returns_to_normal() {
let mut state = state_with_metadata();
state.modal.set_mode(InputMode::ErTablePicker);
state
.ui
.er_picker
.filter_input
.set_content("test".to_string());
let now = Instant::now();
let effects = reduce(
&mut state,
Action::CloseErTablePicker,
now,
&AppServices::stub(),
);
assert_eq!(state.input_mode(), InputMode::Normal);
assert!(state.ui.er_picker.filter_input.content().is_empty());
assert!(effects.is_empty());
}
#[test]
fn confirm_with_selected_tables_sets_target_and_returns_dispatch() {
let mut state = state_with_metadata();
state.modal.set_mode(InputMode::ErTablePicker);
state
.ui
.er_selected_tables
.insert("public.users".to_string());
let now = Instant::now();
let effects = reduce(
&mut state,
Action::ErConfirmSelection,
now,
&AppServices::stub(),
);
assert_eq!(
state.er_preparation.target_tables,
vec!["public.users".to_string()]
);
assert_eq!(state.input_mode(), InputMode::Normal);
assert_eq!(effects.len(), 1);
assert!(matches!(effects[0], Effect::DispatchActions(_)));
}
#[test]
fn confirm_with_no_selection_returns_error() {
let mut state = state_with_metadata();
state.modal.set_mode(InputMode::ErTablePicker);
let now = Instant::now();
let effects = reduce(
&mut state,
Action::ErConfirmSelection,
now,
&AppServices::stub(),
);
assert_eq!(state.input_mode(), InputMode::ErTablePicker);
assert!(state.messages.last_error.is_some());
assert!(effects.is_empty());
}
#[test]
fn target_tables_survive_er_open() {
let mut state = state_with_metadata();
state.session.dsn = Some("postgres://localhost/test".to_string());
state.sql_modal.begin_prefetch();
state.er_preparation.target_tables = vec!["public.users".to_string()];
let now = Instant::now();
let effects = reduce(&mut state, Action::ErOpenDiagram, now, &AppServices::stub());
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::SmartErRefresh { .. }));
assert_eq!(
state.er_preparation.target_tables,
vec!["public.users".to_string()]
);
}
#[test]
fn prefetch_complete_dispatches_er_generate() {
use crate::app::model::er_state::ErStatus;
let mut state = state_with_metadata();
state.sql_modal.begin_prefetch();
state.er_preparation.status = ErStatus::Waiting;
state.er_preparation.total_tables = 1;
state.er_preparation.fk_expanded = true;
state
.er_preparation
.pending_tables
.insert("public.users".to_string());
let now = Instant::now();
let effects = reduce(
&mut state,
Action::TableDetailAlreadyCached {
schema: "public".to_string(),
table: "users".to_string(),
},
now,
&AppServices::stub(),
);
assert_eq!(state.er_preparation.status, ErStatus::Idle);
assert!(effects.iter().any(|e| {
matches!(e, Effect::DispatchActions(actions)
if actions.iter().any(|a| matches!(a, Action::ErGenerateFromCache)))
}));
}
#[test]
fn prefetch_complete_with_failures_does_not_auto_open() {
use crate::app::model::er_state::ErStatus;
let mut state = state_with_metadata();
state.sql_modal.begin_prefetch();
state.er_preparation.status = ErStatus::Waiting;
state.er_preparation.total_tables = 2;
state.er_preparation.fk_expanded = true;
state
.er_preparation
.failed_tables
.insert("public.posts".to_string(), "timeout".to_string());
state
.er_preparation
.pending_tables
.insert("public.users".to_string());
let now = Instant::now();
let effects = reduce(
&mut state,
Action::TableDetailAlreadyCached {
schema: "public".to_string(),
table: "users".to_string(),
},
now,
&AppServices::stub(),
);
assert_eq!(state.er_preparation.status, ErStatus::Idle);
assert!(!effects.iter().any(|e| {
matches!(e, Effect::DispatchActions(actions)
if actions.iter().any(|a| matches!(a, Action::ErOpenDiagram)))
}));
assert!(state.messages.last_error.is_some());
}
}
mod pagination_integration {
use super::*;
use crate::app::model::browse::query_execution::PREVIEW_PAGE_SIZE;
use crate::domain::{DatabaseMetadata, QueryResult, QuerySource, TableSummary};
use std::sync::Arc;
fn state_after_confirm_and_complete() -> (AppState, Instant) {
let mut state = create_test_state();
state.session.dsn = Some("postgres://localhost/test".to_string());
let now = Instant::now();
let metadata = DatabaseMetadata {
database_name: "test".to_string(),
schemas: vec![],
table_summaries: vec![TableSummary::new(
"public".to_string(),
"users".to_string(),
Some(1200),
false,
)],
fetched_at: now,
};
reduce(
&mut state,
Action::MetadataLoaded(Arc::new(metadata)),
now,
&AppServices::stub(),
);
state.modal.set_mode(InputMode::Normal);
state.ui.focused_pane = FocusedPane::Explorer;
state.ui.explorer_selected = 0;
let effects = reduce(
&mut state,
Action::ConfirmSelection,
now,
&AppServices::stub(),
);
let dispatch_actions: Vec<Action> = effects
.into_iter()
.filter_map(|e| match e {
Effect::DispatchActions(actions) => Some(actions),
_ => None,
})
.flatten()
.collect();
for action in dispatch_actions {
reduce(&mut state, action, now, &AppServices::stub());
}
let current_gen = state.session.selection_generation();
let result = Arc::new(QueryResult {
columns: vec!["id".to_string()],
rows: vec![vec!["1".to_string()]; PREVIEW_PAGE_SIZE],
execution_time_ms: 10,
source: QuerySource::Preview,
row_count: PREVIEW_PAGE_SIZE,
query: String::new(),
executed_at: now,
error: None,
command_tag: None,
});
reduce(
&mut state,
Action::QueryCompleted {
result,
generation: current_gen,
target_page: Some(0),
},
now,
&AppServices::stub(),
);
(state, now)
}
#[test]
fn confirm_selection_initializes_pagination_via_dispatch() {
let (state, _now) = state_after_confirm_and_complete();
assert_eq!(state.query.pagination.schema, "public");
assert_eq!(state.query.pagination.table, "users");
assert_eq!(state.query.pagination.total_rows_estimate, Some(1200));
assert_eq!(state.query.pagination.current_page, 0);
assert!(!state.query.pagination.reached_end);
}
#[test]
fn next_page_after_confirm_emits_correct_offset() {
let (mut state, now) = state_after_confirm_and_complete();
let effects = reduce(
&mut state,
Action::ResultNextPage,
now,
&AppServices::stub(),
);
let preview_effect = effects
.iter()
.find(|e| matches!(e, Effect::ExecutePreview { .. }));
assert!(preview_effect.is_some());
if let Some(Effect::ExecutePreview {
offset,
target_page,
schema,
table,
..
}) = preview_effect
{
assert_eq!(*offset, PREVIEW_PAGE_SIZE);
assert_eq!(*target_page, 1);
assert_eq!(schema, "public");
assert_eq!(table, "users");
}
}
}
mod command_palette {
use super::*;
use crate::app::update::input::palette::palette_commands;
use rstest::rstest;
fn state_in_palette_mode() -> AppState {
let mut state = create_test_state();
state.modal.set_mode(InputMode::CommandPalette);
state
}
fn palette_index_of(target: impl Fn(&Action) -> bool) -> usize {
palette_commands()
.enumerate()
.find(|(_, kb)| target(&kb.action))
.map(|(i, _)| i)
.expect("action must exist in palette")
}
#[rstest]
#[case(Action::OpenHelp, InputMode::Help)]
#[case(Action::OpenTablePicker, InputMode::TablePicker)]
#[case(Action::OpenSqlModal, InputMode::SqlModal)]
fn confirm_selection_applies_sub_action(
#[case] target_action: Action,
#[case] expected_mode: InputMode,
) {
let entry_index = palette_index_of(|a| {
std::mem::discriminant(a) == std::mem::discriminant(&target_action)
});
let mut state = state_in_palette_mode();
state.ui.table_picker.set_selection(entry_index);
let now = Instant::now();
reduce(
&mut state,
Action::ConfirmSelection,
now,
&AppServices::stub(),
);
assert_eq!(state.input_mode(), expected_mode);
}
#[test]
fn confirm_selection_with_reload_emits_sequence_effect() {
let entry_index = palette_index_of(|a| matches!(a, Action::ReloadMetadata));
let mut state = state_in_palette_mode();
state.session.dsn = Some("postgres://localhost/test".to_string());
state.ui.table_picker.set_selection(entry_index);
let now = Instant::now();
let effects = reduce(
&mut state,
Action::ConfirmSelection,
now,
&AppServices::stub(),
);
assert!(
effects.iter().any(|e| matches!(e, Effect::Sequence(_))),
"expected Sequence effect for ReloadMetadata, got {effects:?}"
);
}
#[test]
fn confirm_selection_open_connection_selector_closes_palette() {
let entry_index = palette_index_of(|a| matches!(a, Action::OpenConnectionSelector));
let mut state = state_in_palette_mode();
state.ui.table_picker.set_selection(entry_index);
let now = Instant::now();
reduce(
&mut state,
Action::ConfirmSelection,
now,
&AppServices::stub(),
);
assert_ne!(
state.input_mode(),
InputMode::CommandPalette,
"palette must be closed after confirm"
);
}
}
mod operator_pending {
use super::*;
#[test]
fn yank_pending_reset_on_non_yank_action() {
let mut state = create_test_state();
state.result_interaction.yank_op_pending = true;
let now = Instant::now();
reduce(
&mut state,
Action::Select(SelectMotion::Next),
now,
&AppServices::stub(),
);
assert!(!state.result_interaction.yank_op_pending);
}
#[test]
fn y_then_d_cancels_yank_starts_delete() {
let mut state = create_test_state();
state.ui.focused_pane = FocusedPane::Result;
state.result_interaction.enter_row(0);
let now = Instant::now();
reduce(
&mut state,
Action::ResultRowYankOperatorPending,
now,
&AppServices::stub(),
);
assert!(state.result_interaction.yank_op_pending);
assert!(!state.result_interaction.delete_op_pending);
reduce(
&mut state,
Action::ResultDeleteOperatorPending,
now,
&AppServices::stub(),
);
assert!(!state.result_interaction.yank_op_pending);
assert!(state.result_interaction.delete_op_pending);
}
}
}