Refactor command execution and buffer management. - `CommitFormula` now defaults to targeting `_Measure` . - `CommitFormula` no longer automatically clears the buffer; buffer clearing is now handled by keymap sequences. - Added `ClearBufferCmd` to the command registry. - Updated `AddFormulaCmd` to support optional target category. - Added `SetBuffer` effect to allow clearing buffers. Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
1644 lines
51 KiB
Rust
1644 lines
51 KiB
Rust
use std::fmt::Debug;
|
|
use std::path::PathBuf;
|
|
|
|
use crate::model::cell::{CellKey, CellValue};
|
|
use crate::view::Axis;
|
|
|
|
use super::app::{App, AppMode};
|
|
|
|
/// A discrete state change produced by a command.
|
|
/// Effects know how to apply themselves to the App.
|
|
pub trait Effect: Debug {
|
|
fn apply(&self, app: &mut App);
|
|
/// Whether this effect changes the app mode.
|
|
fn changes_mode(&self) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
// ── Model mutations ──────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug)]
|
|
pub struct AddCategory(pub String);
|
|
impl Effect for AddCategory {
|
|
fn apply(&self, app: &mut App) {
|
|
let _ = app.model.add_category(&self.0);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct AddItem {
|
|
pub category: String,
|
|
pub item: String,
|
|
}
|
|
impl Effect for AddItem {
|
|
fn apply(&self, app: &mut App) {
|
|
if let Some(cat) = app.model.category_mut(&self.category) {
|
|
cat.add_item(&self.item);
|
|
} else {
|
|
app.status_msg = format!("Unknown category '{}'", self.category);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct AddItemInGroup {
|
|
pub category: String,
|
|
pub item: String,
|
|
pub group: String,
|
|
}
|
|
impl Effect for AddItemInGroup {
|
|
fn apply(&self, app: &mut App) {
|
|
if let Some(cat) = app.model.category_mut(&self.category) {
|
|
cat.add_item_in_group(&self.item, &self.group);
|
|
} else {
|
|
app.status_msg = format!("Unknown category '{}'", self.category);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SetCell(pub CellKey, pub CellValue);
|
|
impl Effect for SetCell {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model.set_cell(self.0.clone(), self.1.clone());
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ClearCell(pub CellKey);
|
|
impl Effect for ClearCell {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model.clear_cell(&self.0);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct AddFormula {
|
|
pub raw: String,
|
|
pub target_category: String,
|
|
}
|
|
impl Effect for AddFormula {
|
|
fn apply(&self, app: &mut App) {
|
|
match crate::formula::parse_formula(&self.raw, &self.target_category) {
|
|
Ok(formula) => {
|
|
// For non-_Measure targets, add the item to the category so it
|
|
// appears in the grid. _Measure targets are dynamically included
|
|
// via Model::measure_item_names().
|
|
if formula.target_category != "_Measure" {
|
|
if let Some(cat) = app.model.category_mut(&formula.target_category) {
|
|
cat.add_item(&formula.target);
|
|
}
|
|
}
|
|
app.model.add_formula(formula);
|
|
}
|
|
Err(e) => {
|
|
app.status_msg = format!("Formula error: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct RemoveFormula {
|
|
pub target: String,
|
|
pub target_category: String,
|
|
}
|
|
impl Effect for RemoveFormula {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model
|
|
.remove_formula(&self.target, &self.target_category);
|
|
}
|
|
}
|
|
|
|
/// Re-enter edit mode by reading the cell value at the current cursor.
|
|
/// Used after commit+advance to continue data entry.
|
|
#[derive(Debug)]
|
|
pub struct EnterEditAtCursor;
|
|
impl Effect for EnterEditAtCursor {
|
|
fn apply(&self, app: &mut App) {
|
|
app.rebuild_layout();
|
|
let ctx = app.cmd_context(
|
|
crossterm::event::KeyCode::Null,
|
|
crossterm::event::KeyModifiers::NONE,
|
|
);
|
|
let value = ctx.display_value.clone();
|
|
drop(ctx);
|
|
app.buffers.insert("edit".to_string(), value);
|
|
app.mode = AppMode::editing();
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct TogglePruneEmpty;
|
|
impl Effect for TogglePruneEmpty {
|
|
fn apply(&self, app: &mut App) {
|
|
let v = app.model.active_view_mut();
|
|
v.prune_empty = !v.prune_empty;
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ToggleCatExpand(pub String);
|
|
impl Effect for ToggleCatExpand {
|
|
fn apply(&self, app: &mut App) {
|
|
if !app.expanded_cats.remove(&self.0) {
|
|
app.expanded_cats.insert(self.0.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct RemoveItem {
|
|
pub category: String,
|
|
pub item: String,
|
|
}
|
|
impl Effect for RemoveItem {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model.remove_item(&self.category, &self.item);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct RemoveCategory(pub String);
|
|
impl Effect for RemoveCategory {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model.remove_category(&self.0);
|
|
}
|
|
}
|
|
|
|
// ── View mutations ───────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug)]
|
|
pub struct CreateView(pub String);
|
|
impl Effect for CreateView {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model.create_view(&self.0);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct DeleteView(pub String);
|
|
impl Effect for DeleteView {
|
|
fn apply(&self, app: &mut App) {
|
|
let _ = app.model.delete_view(&self.0);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SwitchView(pub String);
|
|
impl Effect for SwitchView {
|
|
fn apply(&self, app: &mut App) {
|
|
let current = app.model.active_view.clone();
|
|
if current != self.0 {
|
|
app.view_back_stack.push(current);
|
|
app.view_forward_stack.clear();
|
|
}
|
|
let _ = app.model.switch_view(&self.0);
|
|
}
|
|
}
|
|
|
|
/// Go back in view history (pop back stack, push current to forward stack).
|
|
#[derive(Debug)]
|
|
pub struct ViewBack;
|
|
impl Effect for ViewBack {
|
|
fn apply(&self, app: &mut App) {
|
|
if let Some(prev) = app.view_back_stack.pop() {
|
|
let current = app.model.active_view.clone();
|
|
app.view_forward_stack.push(current);
|
|
let _ = app.model.switch_view(&prev);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Go forward in view history (pop forward stack, push current to back stack).
|
|
#[derive(Debug)]
|
|
pub struct ViewForward;
|
|
impl Effect for ViewForward {
|
|
fn apply(&self, app: &mut App) {
|
|
if let Some(next) = app.view_forward_stack.pop() {
|
|
let current = app.model.active_view.clone();
|
|
app.view_back_stack.push(current);
|
|
let _ = app.model.switch_view(&next);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SetAxis {
|
|
pub category: String,
|
|
pub axis: Axis,
|
|
}
|
|
impl Effect for SetAxis {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model
|
|
.active_view_mut()
|
|
.set_axis(&self.category, self.axis);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SetPageSelection {
|
|
pub category: String,
|
|
pub item: String,
|
|
}
|
|
impl Effect for SetPageSelection {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model
|
|
.active_view_mut()
|
|
.set_page_selection(&self.category, &self.item);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ToggleGroup {
|
|
pub category: String,
|
|
pub group: String,
|
|
}
|
|
impl Effect for ToggleGroup {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model
|
|
.active_view_mut()
|
|
.toggle_group_collapse(&self.category, &self.group);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct HideItem {
|
|
pub category: String,
|
|
pub item: String,
|
|
}
|
|
impl Effect for HideItem {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model
|
|
.active_view_mut()
|
|
.hide_item(&self.category, &self.item);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ShowItem {
|
|
pub category: String,
|
|
pub item: String,
|
|
}
|
|
impl Effect for ShowItem {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model
|
|
.active_view_mut()
|
|
.show_item(&self.category, &self.item);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct TransposeAxes;
|
|
impl Effect for TransposeAxes {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model.active_view_mut().transpose_axes();
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct CycleAxis(pub String);
|
|
impl Effect for CycleAxis {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model.active_view_mut().cycle_axis(&self.0);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SetNumberFormat(pub String);
|
|
impl Effect for SetNumberFormat {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model.active_view_mut().number_format = self.0.clone();
|
|
}
|
|
}
|
|
|
|
// ── Navigation ───────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug)]
|
|
pub struct SetSelected(pub usize, pub usize);
|
|
impl Effect for SetSelected {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model.active_view_mut().selected = (self.0, self.1);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SetRowOffset(pub usize);
|
|
impl Effect for SetRowOffset {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model.active_view_mut().row_offset = self.0;
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SetColOffset(pub usize);
|
|
impl Effect for SetColOffset {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model.active_view_mut().col_offset = self.0;
|
|
}
|
|
}
|
|
|
|
// ── App state ────────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug)]
|
|
pub struct ChangeMode(pub AppMode);
|
|
impl Effect for ChangeMode {
|
|
fn apply(&self, app: &mut App) {
|
|
app.mode = self.0.clone();
|
|
}
|
|
fn changes_mode(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SetStatus(pub String);
|
|
impl Effect for SetStatus {
|
|
fn apply(&self, app: &mut App) {
|
|
app.status_msg = self.0.clone();
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct MarkDirty;
|
|
impl Effect for MarkDirty {
|
|
fn apply(&self, app: &mut App) {
|
|
app.dirty = true;
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SetYanked(pub Option<CellValue>);
|
|
impl Effect for SetYanked {
|
|
fn apply(&self, app: &mut App) {
|
|
app.yanked = self.0.clone();
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SetSearchQuery(pub String);
|
|
impl Effect for SetSearchQuery {
|
|
fn apply(&self, app: &mut App) {
|
|
app.search_query = self.0.clone();
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SetSearchMode(pub bool);
|
|
impl Effect for SetSearchMode {
|
|
fn apply(&self, app: &mut App) {
|
|
app.search_mode = self.0;
|
|
}
|
|
}
|
|
|
|
/// Set a named buffer's contents.
|
|
#[derive(Debug)]
|
|
pub struct SetBuffer {
|
|
pub name: String,
|
|
pub value: String,
|
|
}
|
|
impl Effect for SetBuffer {
|
|
fn apply(&self, app: &mut App) {
|
|
// "search" is special — it writes to search_query for backward compat
|
|
if self.name == "search" {
|
|
app.search_query = self.value.clone();
|
|
} else {
|
|
app.buffers.insert(self.name.clone(), self.value.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SetTileCatIdx(pub usize);
|
|
impl Effect for SetTileCatIdx {
|
|
fn apply(&self, app: &mut App) {
|
|
app.tile_cat_idx = self.0;
|
|
}
|
|
}
|
|
|
|
/// Populate the drill state with a frozen snapshot of records.
|
|
/// Clears any previous drill state.
|
|
#[derive(Debug)]
|
|
pub struct StartDrill(pub Vec<(CellKey, CellValue)>);
|
|
impl Effect for StartDrill {
|
|
fn apply(&self, app: &mut App) {
|
|
app.drill_state = Some(super::app::DrillState {
|
|
records: std::rc::Rc::new(self.0.clone()),
|
|
pending_edits: std::collections::HashMap::new(),
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Apply any pending edits to the model and clear the drill state.
|
|
#[derive(Debug)]
|
|
pub struct ApplyAndClearDrill;
|
|
impl Effect for ApplyAndClearDrill {
|
|
fn apply(&self, app: &mut App) {
|
|
let Some(drill) = app.drill_state.take() else {
|
|
return;
|
|
};
|
|
if drill.pending_edits.is_empty() {
|
|
return;
|
|
}
|
|
// For each pending edit, update the cell
|
|
for ((record_idx, col_name), new_value) in &drill.pending_edits {
|
|
let Some((orig_key, _)) = drill.records.get(*record_idx) else {
|
|
continue;
|
|
};
|
|
if col_name == "Value" {
|
|
// Update the cell's value
|
|
let value = if new_value.is_empty() {
|
|
app.model.clear_cell(orig_key);
|
|
continue;
|
|
} else if let Ok(n) = new_value.parse::<f64>() {
|
|
CellValue::Number(n)
|
|
} else {
|
|
CellValue::Text(new_value.clone())
|
|
};
|
|
app.model.set_cell(orig_key.clone(), value);
|
|
} else {
|
|
// Rename a coordinate: remove old cell, insert new with updated coord
|
|
let value = match app.model.get_cell(orig_key) {
|
|
Some(v) => v.clone(),
|
|
None => continue,
|
|
};
|
|
app.model.clear_cell(orig_key);
|
|
// Build new key by replacing the coord
|
|
let new_coords: Vec<(String, String)> = orig_key
|
|
.0
|
|
.iter()
|
|
.map(|(c, i)| {
|
|
if c == col_name {
|
|
(c.clone(), new_value.clone())
|
|
} else {
|
|
(c.clone(), i.clone())
|
|
}
|
|
})
|
|
.collect();
|
|
let new_key = CellKey::new(new_coords);
|
|
// Ensure the new item exists in that category
|
|
if let Some(cat) = app.model.category_mut(col_name) {
|
|
cat.add_item(new_value.clone());
|
|
}
|
|
app.model.set_cell(new_key, value);
|
|
}
|
|
}
|
|
app.dirty = true;
|
|
}
|
|
}
|
|
|
|
/// Stage a pending edit in the drill state.
|
|
#[derive(Debug)]
|
|
pub struct SetDrillPendingEdit {
|
|
pub record_idx: usize,
|
|
pub col_name: String,
|
|
pub new_value: String,
|
|
}
|
|
impl Effect for SetDrillPendingEdit {
|
|
fn apply(&self, app: &mut App) {
|
|
if let Some(drill) = &mut app.drill_state {
|
|
drill.pending_edits.insert(
|
|
(self.record_idx, self.col_name.clone()),
|
|
self.new_value.clone(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Side effects ─────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug)]
|
|
pub struct Save;
|
|
impl Effect for Save {
|
|
fn apply(&self, app: &mut App) {
|
|
if let Some(ref path) = app.file_path {
|
|
match crate::persistence::save(&app.model, path) {
|
|
Ok(()) => {
|
|
app.dirty = false;
|
|
app.status_msg = format!("Saved to {}", path.display());
|
|
}
|
|
Err(e) => {
|
|
app.status_msg = format!("Save error: {e}");
|
|
}
|
|
}
|
|
} else {
|
|
app.status_msg = "No file path — use :w <path>".to_string();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SaveAs(pub PathBuf);
|
|
impl Effect for SaveAs {
|
|
fn apply(&self, app: &mut App) {
|
|
match crate::persistence::save(&app.model, &self.0) {
|
|
Ok(()) => {
|
|
app.file_path = Some(self.0.clone());
|
|
app.dirty = false;
|
|
app.status_msg = format!("Saved to {}", self.0.display());
|
|
}
|
|
Err(e) => {
|
|
app.status_msg = format!("Save error: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Dispatch a key event to the import wizard.
|
|
/// The wizard has its own internal state machine; this effect handles
|
|
/// all wizard key interactions and App-level side effects.
|
|
#[derive(Debug)]
|
|
pub struct WizardKey {
|
|
pub key_code: crossterm::event::KeyCode,
|
|
}
|
|
impl Effect for WizardKey {
|
|
fn apply(&self, app: &mut App) {
|
|
use crate::import::wizard::WizardStep;
|
|
|
|
let Some(wizard) = &mut app.wizard else {
|
|
return;
|
|
};
|
|
|
|
match &wizard.step.clone() {
|
|
WizardStep::Preview => match self.key_code {
|
|
crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Char(' ') => {
|
|
wizard.advance()
|
|
}
|
|
crossterm::event::KeyCode::Esc => {
|
|
app.mode = AppMode::Normal;
|
|
app.wizard = None;
|
|
}
|
|
_ => {}
|
|
},
|
|
WizardStep::SelectArrayPath => match self.key_code {
|
|
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
|
|
wizard.move_cursor(-1)
|
|
}
|
|
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
|
|
wizard.move_cursor(1)
|
|
}
|
|
crossterm::event::KeyCode::Enter => wizard.confirm_path(),
|
|
crossterm::event::KeyCode::Esc => {
|
|
app.mode = AppMode::Normal;
|
|
app.wizard = None;
|
|
}
|
|
_ => {}
|
|
},
|
|
WizardStep::ReviewProposals => match self.key_code {
|
|
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
|
|
wizard.move_cursor(-1)
|
|
}
|
|
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
|
|
wizard.move_cursor(1)
|
|
}
|
|
crossterm::event::KeyCode::Char(' ') => wizard.toggle_proposal(),
|
|
crossterm::event::KeyCode::Char('c') => wizard.cycle_proposal_kind(),
|
|
crossterm::event::KeyCode::Enter => wizard.advance(),
|
|
crossterm::event::KeyCode::Esc => {
|
|
app.mode = AppMode::Normal;
|
|
app.wizard = None;
|
|
}
|
|
_ => {}
|
|
},
|
|
WizardStep::ConfigureDates => match self.key_code {
|
|
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
|
|
wizard.move_cursor(-1)
|
|
}
|
|
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
|
|
wizard.move_cursor(1)
|
|
}
|
|
crossterm::event::KeyCode::Char(' ') => wizard.toggle_date_component(),
|
|
crossterm::event::KeyCode::Enter => wizard.advance(),
|
|
crossterm::event::KeyCode::Esc => {
|
|
app.mode = AppMode::Normal;
|
|
app.wizard = None;
|
|
}
|
|
_ => {}
|
|
},
|
|
WizardStep::DefineFormulas => {
|
|
if wizard.formula_editing {
|
|
match self.key_code {
|
|
crossterm::event::KeyCode::Enter => wizard.confirm_formula(),
|
|
crossterm::event::KeyCode::Esc => wizard.cancel_formula_edit(),
|
|
crossterm::event::KeyCode::Backspace => wizard.pop_formula_char(),
|
|
crossterm::event::KeyCode::Char(c) => wizard.push_formula_char(c),
|
|
_ => {}
|
|
}
|
|
} else {
|
|
match self.key_code {
|
|
crossterm::event::KeyCode::Char('n') => wizard.start_formula_edit(),
|
|
crossterm::event::KeyCode::Char('d') => wizard.delete_formula(),
|
|
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
|
|
wizard.move_cursor(-1)
|
|
}
|
|
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
|
|
wizard.move_cursor(1)
|
|
}
|
|
crossterm::event::KeyCode::Enter => wizard.advance(),
|
|
crossterm::event::KeyCode::Esc => {
|
|
app.mode = AppMode::Normal;
|
|
app.wizard = None;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
WizardStep::NameModel => match self.key_code {
|
|
crossterm::event::KeyCode::Char(c) => wizard.push_name_char(c),
|
|
crossterm::event::KeyCode::Backspace => wizard.pop_name_char(),
|
|
crossterm::event::KeyCode::Enter => match wizard.build_model() {
|
|
Ok(mut model) => {
|
|
model.normalize_view_state();
|
|
app.model = model;
|
|
app.formula_cursor = 0;
|
|
app.dirty = true;
|
|
app.status_msg = "Import successful! Press :w <path> to save.".to_string();
|
|
app.mode = AppMode::Normal;
|
|
app.wizard = None;
|
|
}
|
|
Err(e) => {
|
|
if let Some(w) = &mut app.wizard {
|
|
w.message = Some(format!("Error: {e}"));
|
|
}
|
|
}
|
|
},
|
|
crossterm::event::KeyCode::Esc => {
|
|
app.mode = AppMode::Normal;
|
|
app.wizard = None;
|
|
}
|
|
_ => {}
|
|
},
|
|
WizardStep::Done => {
|
|
app.mode = AppMode::Normal;
|
|
app.wizard = None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Start the import wizard from a JSON file path.
|
|
#[derive(Debug)]
|
|
pub struct StartImportWizard(pub String);
|
|
impl Effect for StartImportWizard {
|
|
fn apply(&self, app: &mut App) {
|
|
match std::fs::read_to_string(&self.0) {
|
|
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
|
|
Ok(json) => {
|
|
app.wizard = Some(crate::import::wizard::ImportWizard::new(json));
|
|
app.mode = AppMode::ImportWizard;
|
|
}
|
|
Err(e) => {
|
|
app.status_msg = format!("JSON parse error: {e}");
|
|
}
|
|
},
|
|
Err(e) => {
|
|
app.status_msg = format!("Cannot read file: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ExportCsv(pub PathBuf);
|
|
impl Effect for ExportCsv {
|
|
fn apply(&self, app: &mut App) {
|
|
let view_name = app.model.active_view.clone();
|
|
match crate::persistence::export_csv(&app.model, &view_name, &self.0) {
|
|
Ok(()) => {
|
|
app.status_msg = format!("Exported to {}", self.0.display());
|
|
}
|
|
Err(e) => {
|
|
app.status_msg = format!("Export error: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Load a model from a file, replacing the current one.
|
|
#[derive(Debug)]
|
|
pub struct LoadModel(pub PathBuf);
|
|
impl Effect for LoadModel {
|
|
fn apply(&self, app: &mut App) {
|
|
match crate::persistence::load(&self.0) {
|
|
Ok(mut loaded) => {
|
|
loaded.normalize_view_state();
|
|
app.model = loaded;
|
|
app.status_msg = format!("Loaded from {}", self.0.display());
|
|
}
|
|
Err(e) => {
|
|
app.status_msg = format!("Load error: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Headless JSON/CSV import: read file, analyze, build model, replace current.
|
|
#[derive(Debug)]
|
|
pub struct ImportJsonHeadless {
|
|
pub path: PathBuf,
|
|
pub model_name: Option<String>,
|
|
pub array_path: Option<String>,
|
|
}
|
|
impl Effect for ImportJsonHeadless {
|
|
fn apply(&self, app: &mut App) {
|
|
use crate::import::analyzer::{
|
|
analyze_records, extract_array_at_path, find_array_paths, FieldKind,
|
|
};
|
|
use crate::import::wizard::ImportPipeline;
|
|
|
|
let is_csv = self
|
|
.path
|
|
.extension()
|
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("csv"));
|
|
|
|
let records = if is_csv {
|
|
match crate::import::csv_parser::parse_csv(&self.path) {
|
|
Ok(recs) => recs,
|
|
Err(e) => {
|
|
app.status_msg = format!("CSV error: {e}");
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
let content = match std::fs::read_to_string(&self.path) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
app.status_msg = format!("Cannot read '{}': {e}", self.path.display());
|
|
return;
|
|
}
|
|
};
|
|
let value: serde_json::Value = match serde_json::from_str(&content) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
app.status_msg = format!("JSON parse error: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
if let Some(ap) = self.array_path.as_deref().filter(|s| !s.is_empty()) {
|
|
match extract_array_at_path(&value, ap) {
|
|
Some(arr) => arr.clone(),
|
|
None => {
|
|
app.status_msg = format!("No array at path '{ap}'");
|
|
return;
|
|
}
|
|
}
|
|
} else if let Some(arr) = value.as_array() {
|
|
arr.clone()
|
|
} else {
|
|
let paths = find_array_paths(&value);
|
|
if let Some(first) = paths.first() {
|
|
match extract_array_at_path(&value, first) {
|
|
Some(arr) => arr.clone(),
|
|
None => {
|
|
app.status_msg = "Could not extract records array".to_string();
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
app.status_msg = "No array found in JSON".to_string();
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
let proposals = analyze_records(&records);
|
|
|
|
let raw = if is_csv {
|
|
serde_json::Value::Array(records.clone())
|
|
} else {
|
|
serde_json::from_str(&std::fs::read_to_string(&self.path).unwrap_or_default())
|
|
.unwrap_or(serde_json::Value::Array(records.clone()))
|
|
};
|
|
|
|
let pipeline = ImportPipeline {
|
|
raw,
|
|
array_paths: vec![],
|
|
selected_path: self.array_path.as_deref().unwrap_or("").to_string(),
|
|
records,
|
|
proposals: proposals
|
|
.into_iter()
|
|
.map(|mut p| {
|
|
p.accepted = p.kind != FieldKind::Label;
|
|
p
|
|
})
|
|
.collect(),
|
|
model_name: self
|
|
.model_name
|
|
.as_deref()
|
|
.unwrap_or("Imported Model")
|
|
.to_string(),
|
|
formulas: vec![],
|
|
};
|
|
|
|
match pipeline.build_model() {
|
|
Ok(new_model) => {
|
|
app.model = new_model;
|
|
app.status_msg = "Imported successfully".to_string();
|
|
}
|
|
Err(e) => {
|
|
app.status_msg = format!("Import error: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SetPanelOpen {
|
|
pub panel: Panel,
|
|
pub open: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum Panel {
|
|
Formula,
|
|
Category,
|
|
View,
|
|
}
|
|
|
|
impl Panel {
|
|
pub fn mode(self) -> AppMode {
|
|
match self {
|
|
Panel::Formula => AppMode::FormulaPanel,
|
|
Panel::Category => AppMode::CategoryPanel,
|
|
Panel::View => AppMode::ViewPanel,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Effect for SetPanelOpen {
|
|
fn apply(&self, app: &mut App) {
|
|
match self.panel {
|
|
Panel::Formula => app.formula_panel_open = self.open,
|
|
Panel::Category => app.category_panel_open = self.open,
|
|
Panel::View => app.view_panel_open = self.open,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SetPanelCursor {
|
|
pub panel: Panel,
|
|
pub cursor: usize,
|
|
}
|
|
impl Effect for SetPanelCursor {
|
|
fn apply(&self, app: &mut App) {
|
|
match self.panel {
|
|
Panel::Formula => app.formula_cursor = self.cursor,
|
|
Panel::Category => app.cat_panel_cursor = self.cursor,
|
|
Panel::View => app.view_panel_cursor = self.cursor,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Convenience constructors ─────────────────────────────────────────────────
|
|
|
|
pub fn mark_dirty() -> Box<dyn Effect> {
|
|
Box::new(MarkDirty)
|
|
}
|
|
|
|
pub fn set_status(msg: impl Into<String>) -> Box<dyn Effect> {
|
|
Box::new(SetStatus(msg.into()))
|
|
}
|
|
|
|
pub fn change_mode(mode: AppMode) -> Box<dyn Effect> {
|
|
Box::new(ChangeMode(mode))
|
|
}
|
|
|
|
pub fn set_selected(row: usize, col: usize) -> Box<dyn Effect> {
|
|
Box::new(SetSelected(row, col))
|
|
}
|
|
|
|
// ── Help page navigation ────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug)]
|
|
pub struct HelpPageNext;
|
|
impl Effect for HelpPageNext {
|
|
fn apply(&self, app: &mut App) {
|
|
let max = crate::ui::help::HELP_PAGE_COUNT.saturating_sub(1);
|
|
app.help_page = app.help_page.saturating_add(1).min(max);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct HelpPagePrev;
|
|
impl Effect for HelpPagePrev {
|
|
fn apply(&self, app: &mut App) {
|
|
app.help_page = app.help_page.saturating_sub(1);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct HelpPageSet(pub usize);
|
|
impl Effect for HelpPageSet {
|
|
fn apply(&self, app: &mut App) {
|
|
app.help_page = self.0;
|
|
}
|
|
}
|
|
|
|
pub fn help_page_next() -> Box<dyn Effect> {
|
|
Box::new(HelpPageNext)
|
|
}
|
|
|
|
pub fn help_page_prev() -> Box<dyn Effect> {
|
|
Box::new(HelpPagePrev)
|
|
}
|
|
|
|
pub fn help_page_set(page: usize) -> Box<dyn Effect> {
|
|
Box::new(HelpPageSet(page))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::model::cell::{CellKey, CellValue};
|
|
use crate::model::Model;
|
|
|
|
fn test_app() -> App {
|
|
let mut m = Model::new("Test");
|
|
m.add_category("Type").unwrap();
|
|
m.add_category("Month").unwrap();
|
|
m.category_mut("Type").unwrap().add_item("Food");
|
|
m.category_mut("Type").unwrap().add_item("Clothing");
|
|
m.category_mut("Month").unwrap().add_item("Jan");
|
|
m.category_mut("Month").unwrap().add_item("Feb");
|
|
App::new(m, None)
|
|
}
|
|
|
|
// ── Model mutation effects ──────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn add_category_effect() {
|
|
let mut app = test_app();
|
|
AddCategory("Region".to_string()).apply(&mut app);
|
|
assert!(app.model.category("Region").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn add_item_to_existing_category() {
|
|
let mut app = test_app();
|
|
AddItem {
|
|
category: "Type".to_string(),
|
|
item: "Electronics".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
let items: Vec<&str> = app
|
|
.model
|
|
.category("Type")
|
|
.unwrap()
|
|
.ordered_item_names()
|
|
.into_iter()
|
|
.collect();
|
|
assert!(items.contains(&"Electronics"));
|
|
}
|
|
|
|
#[test]
|
|
fn add_item_to_nonexistent_category_sets_status() {
|
|
let mut app = test_app();
|
|
AddItem {
|
|
category: "Nonexistent".to_string(),
|
|
item: "X".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
assert!(app.status_msg.contains("Unknown category"));
|
|
}
|
|
|
|
#[test]
|
|
fn set_cell_and_clear_cell() {
|
|
let mut app = test_app();
|
|
let key = CellKey::new(vec![
|
|
("Type".into(), "Food".into()),
|
|
("Month".into(), "Jan".into()),
|
|
]);
|
|
SetCell(key.clone(), CellValue::Number(42.0)).apply(&mut app);
|
|
assert_eq!(app.model.get_cell(&key), Some(&CellValue::Number(42.0)));
|
|
|
|
ClearCell(key.clone()).apply(&mut app);
|
|
assert_eq!(app.model.get_cell(&key), None);
|
|
}
|
|
|
|
#[test]
|
|
fn add_formula_valid() {
|
|
let mut app = test_app();
|
|
AddFormula {
|
|
raw: "Clothing = Food * 2".to_string(),
|
|
target_category: "Type".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
assert!(!app.model.formulas().is_empty());
|
|
}
|
|
|
|
/// Regression: AddFormula must add the target item to the target category
|
|
/// so the grid layout includes cells for it. Without this, a formula like
|
|
/// `Margin = Profit / Revenue` would be registered but invisible in the grid.
|
|
#[test]
|
|
fn add_formula_adds_target_item_to_category() {
|
|
let mut app = test_app();
|
|
// "Margin" does not exist as an item in "Type" before adding the formula
|
|
assert!(!app
|
|
.model
|
|
.category("Type")
|
|
.unwrap()
|
|
.ordered_item_names()
|
|
.contains(&"Margin"));
|
|
AddFormula {
|
|
raw: "Margin = Food * 2".to_string(),
|
|
target_category: "Type".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
let items: Vec<&str> = app
|
|
.model
|
|
.category("Type")
|
|
.unwrap()
|
|
.ordered_item_names()
|
|
.into_iter()
|
|
.collect();
|
|
assert!(
|
|
items.contains(&"Margin"),
|
|
"formula target 'Margin' should be added as item to 'Type' category, got: {:?}",
|
|
items
|
|
);
|
|
}
|
|
|
|
/// Formula targets in _Measure should appear in measure_item_names()
|
|
/// dynamically, without being added to the category's own items.
|
|
#[test]
|
|
fn add_formula_to_measure_shows_in_effective_items() {
|
|
let mut app = test_app();
|
|
AddFormula {
|
|
raw: "Margin = Food * 2".to_string(),
|
|
target_category: "_Measure".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
// Should appear in effective_item_names (used by layout)
|
|
let effective = app.model.effective_item_names("_Measure");
|
|
assert!(
|
|
effective.contains(&"Margin".to_string()),
|
|
"formula target 'Margin' should appear in effective _Measure items, got: {:?}",
|
|
effective
|
|
);
|
|
// Should NOT be in the category's own items
|
|
assert!(
|
|
!app.model
|
|
.category("_Measure")
|
|
.unwrap()
|
|
.ordered_item_names()
|
|
.contains(&"Margin"),
|
|
"formula target should not be added directly to _Measure category items"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn add_formula_invalid_sets_error_status() {
|
|
let mut app = test_app();
|
|
AddFormula {
|
|
raw: "this is not valid".to_string(),
|
|
target_category: "Type".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
assert!(app.status_msg.contains("Formula error"));
|
|
}
|
|
|
|
#[test]
|
|
fn remove_formula_effect() {
|
|
let mut app = test_app();
|
|
AddFormula {
|
|
raw: "Clothing = Food * 2".to_string(),
|
|
target_category: "Type".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
assert!(!app.model.formulas().is_empty());
|
|
RemoveFormula {
|
|
target: "Clothing".to_string(),
|
|
target_category: "Type".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
assert!(app.model.formulas().is_empty());
|
|
}
|
|
|
|
// ── View effects ────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn switch_view_pushes_to_back_stack() {
|
|
let mut app = test_app();
|
|
app.model.create_view("View 2");
|
|
assert!(app.view_back_stack.is_empty());
|
|
|
|
SwitchView("View 2".to_string()).apply(&mut app);
|
|
assert_eq!(app.model.active_view.as_str(), "View 2");
|
|
assert_eq!(app.view_back_stack, vec!["Default".to_string()]);
|
|
// Forward stack should be cleared
|
|
assert!(app.view_forward_stack.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn switch_view_to_same_does_not_push_stack() {
|
|
let mut app = test_app();
|
|
SwitchView("Default".to_string()).apply(&mut app);
|
|
assert!(app.view_back_stack.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn view_back_and_forward() {
|
|
let mut app = test_app();
|
|
app.model.create_view("View 2");
|
|
SwitchView("View 2".to_string()).apply(&mut app);
|
|
assert_eq!(app.model.active_view.as_str(), "View 2");
|
|
|
|
// Go back
|
|
ViewBack.apply(&mut app);
|
|
assert_eq!(app.model.active_view.as_str(), "Default");
|
|
assert_eq!(app.view_forward_stack, vec!["View 2".to_string()]);
|
|
assert!(app.view_back_stack.is_empty());
|
|
|
|
// Go forward
|
|
ViewForward.apply(&mut app);
|
|
assert_eq!(app.model.active_view.as_str(), "View 2");
|
|
assert_eq!(app.view_back_stack, vec!["Default".to_string()]);
|
|
assert!(app.view_forward_stack.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn view_back_with_empty_stack_is_noop() {
|
|
let mut app = test_app();
|
|
let before = app.model.active_view.clone();
|
|
ViewBack.apply(&mut app);
|
|
assert_eq!(app.model.active_view, before);
|
|
}
|
|
|
|
#[test]
|
|
fn create_and_delete_view() {
|
|
let mut app = test_app();
|
|
CreateView("View 2".to_string()).apply(&mut app);
|
|
assert!(app.model.views.contains_key("View 2"));
|
|
|
|
DeleteView("View 2".to_string()).apply(&mut app);
|
|
assert!(!app.model.views.contains_key("View 2"));
|
|
}
|
|
|
|
#[test]
|
|
fn set_axis_effect() {
|
|
let mut app = test_app();
|
|
SetAxis {
|
|
category: "Type".to_string(),
|
|
axis: Axis::Page,
|
|
}
|
|
.apply(&mut app);
|
|
assert_eq!(app.model.active_view().axis_of("Type"), Axis::Page);
|
|
}
|
|
|
|
#[test]
|
|
fn transpose_axes_effect() {
|
|
let mut app = test_app();
|
|
let row_before: Vec<String> = app
|
|
.model
|
|
.active_view()
|
|
.categories_on(Axis::Row)
|
|
.into_iter()
|
|
.map(String::from)
|
|
.collect();
|
|
let col_before: Vec<String> = app
|
|
.model
|
|
.active_view()
|
|
.categories_on(Axis::Column)
|
|
.into_iter()
|
|
.map(String::from)
|
|
.collect();
|
|
TransposeAxes.apply(&mut app);
|
|
let row_after: Vec<String> = app
|
|
.model
|
|
.active_view()
|
|
.categories_on(Axis::Row)
|
|
.into_iter()
|
|
.map(String::from)
|
|
.collect();
|
|
let col_after: Vec<String> = app
|
|
.model
|
|
.active_view()
|
|
.categories_on(Axis::Column)
|
|
.into_iter()
|
|
.map(String::from)
|
|
.collect();
|
|
assert_eq!(row_before, col_after);
|
|
assert_eq!(col_before, row_after);
|
|
}
|
|
|
|
// ── Navigation effects ──────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn set_selected_effect() {
|
|
let mut app = test_app();
|
|
SetSelected(3, 5).apply(&mut app);
|
|
assert_eq!(app.model.active_view().selected, (3, 5));
|
|
}
|
|
|
|
#[test]
|
|
fn set_row_and_col_offset() {
|
|
let mut app = test_app();
|
|
SetRowOffset(10).apply(&mut app);
|
|
SetColOffset(5).apply(&mut app);
|
|
assert_eq!(app.model.active_view().row_offset, 10);
|
|
assert_eq!(app.model.active_view().col_offset, 5);
|
|
}
|
|
|
|
// ── App state effects ───────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn change_mode_effect() {
|
|
let mut app = test_app();
|
|
assert!(ChangeMode(AppMode::Help).changes_mode());
|
|
ChangeMode(AppMode::Help).apply(&mut app);
|
|
assert_eq!(app.mode, AppMode::Help);
|
|
}
|
|
|
|
/// SetBuffer with empty value clears the buffer (used by clear-buffer command
|
|
/// in keymap sequences after commit).
|
|
#[test]
|
|
fn set_buffer_empty_clears() {
|
|
let mut app = test_app();
|
|
app.buffers.insert("formula".to_string(), "old text".to_string());
|
|
SetBuffer {
|
|
name: "formula".to_string(),
|
|
value: String::new(),
|
|
}
|
|
.apply(&mut app);
|
|
assert_eq!(
|
|
app.buffers.get("formula").map(|s| s.as_str()),
|
|
Some(""),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn set_status_effect() {
|
|
let mut app = test_app();
|
|
SetStatus("hello".to_string()).apply(&mut app);
|
|
assert_eq!(app.status_msg, "hello");
|
|
}
|
|
|
|
#[test]
|
|
fn mark_dirty_effect() {
|
|
let mut app = test_app();
|
|
assert!(!app.dirty);
|
|
MarkDirty.apply(&mut app);
|
|
assert!(app.dirty);
|
|
}
|
|
|
|
#[test]
|
|
fn set_yanked_effect() {
|
|
let mut app = test_app();
|
|
SetYanked(Some(CellValue::Number(42.0))).apply(&mut app);
|
|
assert_eq!(app.yanked, Some(CellValue::Number(42.0)));
|
|
}
|
|
|
|
#[test]
|
|
fn set_search_query_and_mode() {
|
|
let mut app = test_app();
|
|
SetSearchQuery("foo".to_string()).apply(&mut app);
|
|
assert_eq!(app.search_query, "foo");
|
|
SetSearchMode(true).apply(&mut app);
|
|
assert!(app.search_mode);
|
|
SetSearchMode(false).apply(&mut app);
|
|
assert!(!app.search_mode);
|
|
}
|
|
|
|
// ── SetBuffer special behavior ──────────────────────────────────────
|
|
|
|
#[test]
|
|
fn set_buffer_normal_key() {
|
|
let mut app = test_app();
|
|
SetBuffer {
|
|
name: "edit".to_string(),
|
|
value: "hello".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
assert_eq!(app.buffers.get("edit").unwrap(), "hello");
|
|
}
|
|
|
|
#[test]
|
|
fn set_buffer_search_writes_to_search_query() {
|
|
let mut app = test_app();
|
|
SetBuffer {
|
|
name: "search".to_string(),
|
|
value: "query".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
// "search" buffer is special — writes to app.search_query
|
|
assert_eq!(app.search_query, "query");
|
|
}
|
|
|
|
// ── Panel effects ───────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn set_panel_open_and_cursor() {
|
|
let mut app = test_app();
|
|
SetPanelOpen {
|
|
panel: Panel::Formula,
|
|
open: true,
|
|
}
|
|
.apply(&mut app);
|
|
assert!(app.formula_panel_open);
|
|
|
|
SetPanelCursor {
|
|
panel: Panel::Formula,
|
|
cursor: 3,
|
|
}
|
|
.apply(&mut app);
|
|
assert_eq!(app.formula_cursor, 3);
|
|
|
|
SetPanelOpen {
|
|
panel: Panel::Category,
|
|
open: true,
|
|
}
|
|
.apply(&mut app);
|
|
assert!(app.category_panel_open);
|
|
|
|
SetPanelOpen {
|
|
panel: Panel::View,
|
|
open: true,
|
|
}
|
|
.apply(&mut app);
|
|
assert!(app.view_panel_open);
|
|
}
|
|
|
|
#[test]
|
|
fn set_tile_cat_idx_effect() {
|
|
let mut app = test_app();
|
|
SetTileCatIdx(2).apply(&mut app);
|
|
assert_eq!(app.tile_cat_idx, 2);
|
|
}
|
|
|
|
// ── Help page effects ───────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn help_page_navigation() {
|
|
let mut app = test_app();
|
|
assert_eq!(app.help_page, 0);
|
|
HelpPageNext.apply(&mut app);
|
|
assert_eq!(app.help_page, 1);
|
|
HelpPageNext.apply(&mut app);
|
|
assert_eq!(app.help_page, 2);
|
|
HelpPagePrev.apply(&mut app);
|
|
assert_eq!(app.help_page, 1);
|
|
HelpPageSet(0).apply(&mut app);
|
|
assert_eq!(app.help_page, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn help_page_prev_clamps_at_zero() {
|
|
let mut app = test_app();
|
|
HelpPagePrev.apply(&mut app);
|
|
assert_eq!(app.help_page, 0);
|
|
}
|
|
|
|
// ── Drill effects ───────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn start_drill_and_apply_clear_drill_with_no_edits() {
|
|
let mut app = test_app();
|
|
let key = CellKey::new(vec![
|
|
("Type".into(), "Food".into()),
|
|
("Month".into(), "Jan".into()),
|
|
]);
|
|
let records = vec![(key, CellValue::Number(42.0))];
|
|
StartDrill(records).apply(&mut app);
|
|
assert!(app.drill_state.is_some());
|
|
|
|
// Apply with no pending edits — should just clear state
|
|
ApplyAndClearDrill.apply(&mut app);
|
|
assert!(app.drill_state.is_none());
|
|
assert!(!app.dirty); // no edits → not dirty
|
|
}
|
|
|
|
#[test]
|
|
fn apply_and_clear_drill_with_value_edit() {
|
|
let mut app = test_app();
|
|
let key = CellKey::new(vec![
|
|
("Type".into(), "Food".into()),
|
|
("Month".into(), "Jan".into()),
|
|
]);
|
|
// Set original cell
|
|
app.model.set_cell(key.clone(), CellValue::Number(42.0));
|
|
|
|
let records = vec![(key.clone(), CellValue::Number(42.0))];
|
|
StartDrill(records).apply(&mut app);
|
|
|
|
// Stage a pending edit: change value at record 0
|
|
SetDrillPendingEdit {
|
|
record_idx: 0,
|
|
col_name: "Value".to_string(),
|
|
new_value: "99".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
|
|
ApplyAndClearDrill.apply(&mut app);
|
|
assert!(app.drill_state.is_none());
|
|
assert!(app.dirty);
|
|
assert_eq!(app.model.get_cell(&key), Some(&CellValue::Number(99.0)));
|
|
}
|
|
|
|
#[test]
|
|
fn apply_and_clear_drill_with_coord_rename() {
|
|
let mut app = test_app();
|
|
let key = CellKey::new(vec![
|
|
("Type".into(), "Food".into()),
|
|
("Month".into(), "Jan".into()),
|
|
]);
|
|
app.model.set_cell(key.clone(), CellValue::Number(42.0));
|
|
|
|
let records = vec![(key.clone(), CellValue::Number(42.0))];
|
|
StartDrill(records).apply(&mut app);
|
|
|
|
// Rename "Type" coord from "Food" to "Drink"
|
|
SetDrillPendingEdit {
|
|
record_idx: 0,
|
|
col_name: "Type".to_string(),
|
|
new_value: "Drink".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
|
|
ApplyAndClearDrill.apply(&mut app);
|
|
assert!(app.dirty);
|
|
// Old cell should be gone
|
|
assert_eq!(app.model.get_cell(&key), None);
|
|
// New cell should exist
|
|
let new_key = CellKey::new(vec![
|
|
("Type".into(), "Drink".into()),
|
|
("Month".into(), "Jan".into()),
|
|
]);
|
|
assert_eq!(app.model.get_cell(&new_key), Some(&CellValue::Number(42.0)));
|
|
// "Drink" should have been added as an item
|
|
let items: Vec<&str> = app
|
|
.model
|
|
.category("Type")
|
|
.unwrap()
|
|
.ordered_item_names()
|
|
.into_iter()
|
|
.collect();
|
|
assert!(items.contains(&"Drink"));
|
|
}
|
|
|
|
#[test]
|
|
fn apply_and_clear_drill_empty_value_clears_cell() {
|
|
let mut app = test_app();
|
|
let key = CellKey::new(vec![
|
|
("Type".into(), "Food".into()),
|
|
("Month".into(), "Jan".into()),
|
|
]);
|
|
app.model.set_cell(key.clone(), CellValue::Number(42.0));
|
|
|
|
let records = vec![(key.clone(), CellValue::Number(42.0))];
|
|
StartDrill(records).apply(&mut app);
|
|
|
|
// Edit value to empty string → should clear cell
|
|
SetDrillPendingEdit {
|
|
record_idx: 0,
|
|
col_name: "Value".to_string(),
|
|
new_value: "".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
|
|
ApplyAndClearDrill.apply(&mut app);
|
|
assert_eq!(app.model.get_cell(&key), None);
|
|
}
|
|
|
|
// ── Toggle effects ──────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn toggle_prune_empty_effect() {
|
|
let mut app = test_app();
|
|
let before = app.model.active_view().prune_empty;
|
|
TogglePruneEmpty.apply(&mut app);
|
|
assert_ne!(app.model.active_view().prune_empty, before);
|
|
TogglePruneEmpty.apply(&mut app);
|
|
assert_eq!(app.model.active_view().prune_empty, before);
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_cat_expand_effect() {
|
|
let mut app = test_app();
|
|
assert!(!app.expanded_cats.contains("Type"));
|
|
ToggleCatExpand("Type".to_string()).apply(&mut app);
|
|
assert!(app.expanded_cats.contains("Type"));
|
|
ToggleCatExpand("Type".to_string()).apply(&mut app);
|
|
assert!(!app.expanded_cats.contains("Type"));
|
|
}
|
|
|
|
#[test]
|
|
fn remove_item_and_category() {
|
|
let mut app = test_app();
|
|
RemoveItem {
|
|
category: "Type".to_string(),
|
|
item: "Food".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
let items: Vec<&str> = app
|
|
.model
|
|
.category("Type")
|
|
.unwrap()
|
|
.ordered_item_names()
|
|
.into_iter()
|
|
.collect();
|
|
assert!(!items.contains(&"Food"));
|
|
|
|
RemoveCategory("Month".to_string()).apply(&mut app);
|
|
assert!(app.model.category("Month").is_none());
|
|
}
|
|
|
|
// ── Number format ───────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn set_number_format_effect() {
|
|
let mut app = test_app();
|
|
SetNumberFormat(",.2f".to_string()).apply(&mut app);
|
|
assert_eq!(app.model.active_view().number_format, ",.2f");
|
|
}
|
|
|
|
// ── Page selection ──────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn set_page_selection_effect() {
|
|
let mut app = test_app();
|
|
SetPageSelection {
|
|
category: "Type".to_string(),
|
|
item: "Food".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
assert_eq!(app.model.active_view().page_selection("Type"), Some("Food"));
|
|
}
|
|
|
|
// ── Hide/show items ─────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn hide_and_show_item_effects() {
|
|
let mut app = test_app();
|
|
HideItem {
|
|
category: "Type".to_string(),
|
|
item: "Food".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
assert!(app.model.active_view().is_hidden("Type", "Food"));
|
|
|
|
ShowItem {
|
|
category: "Type".to_string(),
|
|
item: "Food".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
assert!(!app.model.active_view().is_hidden("Type", "Food"));
|
|
}
|
|
|
|
// ── Toggle group ────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn toggle_group_effect() {
|
|
let mut app = test_app();
|
|
ToggleGroup {
|
|
category: "Type".to_string(),
|
|
group: "MyGroup".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
assert!(app
|
|
.model
|
|
.active_view()
|
|
.is_group_collapsed("Type", "MyGroup"));
|
|
ToggleGroup {
|
|
category: "Type".to_string(),
|
|
group: "MyGroup".to_string(),
|
|
}
|
|
.apply(&mut app);
|
|
assert!(!app
|
|
.model
|
|
.active_view()
|
|
.is_group_collapsed("Type", "MyGroup"));
|
|
}
|
|
|
|
// ── Cycle axis ──────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn cycle_axis_effect() {
|
|
let mut app = test_app();
|
|
let before = app.model.active_view().axis_of("Type");
|
|
CycleAxis("Type".to_string()).apply(&mut app);
|
|
let after = app.model.active_view().axis_of("Type");
|
|
assert_ne!(before, after);
|
|
}
|
|
|
|
// ── Save without file path ──────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn save_without_file_path_shows_status() {
|
|
let mut app = test_app();
|
|
Save.apply(&mut app);
|
|
assert!(app.status_msg.contains("No file path"));
|
|
}
|
|
|
|
// ── Panel mode helper ───────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn panel_mode_mapping() {
|
|
assert_eq!(Panel::Formula.mode(), AppMode::FormulaPanel);
|
|
assert_eq!(Panel::Category.mode(), AppMode::CategoryPanel);
|
|
assert_eq!(Panel::View.mode(), AppMode::ViewPanel);
|
|
}
|
|
}
|