Merge branch 'main' into worktree-improvise-ewi-formula-crate

# Conflicts:
#	src/model/types.rs
#	src/view/layout.rs
This commit is contained in:
Edward Langley
2026-04-15 21:11:55 -07:00
16 changed files with 1170 additions and 497 deletions

View File

@ -280,6 +280,7 @@ impl App {
tile_cat_idx: self.tile_cat_idx,
view_back_stack: &self.view_back_stack,
view_forward_stack: &self.view_forward_stack,
has_drill_state: self.drill_state.is_some(),
display_value: {
let key = layout.cell_key(sel_row, sel_col);
if let Some(k) = &key {
@ -708,6 +709,167 @@ mod tests {
);
}
/// Regression: pressing `o` in an empty records view should create the
/// first synthetic row instead of only entering edit mode on empty space.
#[test]
fn add_record_row_in_empty_records_view_creates_first_row() {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.model.category_mut("Region").unwrap().add_item("East");
let mut app = App::new(wb, None);
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
.unwrap();
assert!(app.layout.is_records_mode(), "R should enter records mode");
assert_eq!(app.layout.row_count(), 0, "fresh records view starts empty");
app.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.layout.row_count(),
1,
"o should create the first record row in an empty records view"
);
assert!(
matches!(app.mode, AppMode::Editing { .. }),
"o should leave the app in edit mode, got {:?}",
app.mode
);
}
/// Regression: editing the first row in a blank model's records view
/// should persist the typed value even though plain records mode does not
/// use drill state. With _Measure as the first column, `o` lands on it;
/// type a measure name, Tab to Value, type the number, Enter to commit.
#[test]
fn edit_record_row_in_blank_model_persists_value() {
use crate::model::cell::CellKey;
let mut app = App::new(Workbook::new("T"), None);
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
.unwrap();
// `o` adds a record row and enters edit at (0, 0) = _Measure column
app.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE))
.unwrap();
// Type a measure name
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE))
.unwrap();
// Tab to commit _Measure and move to Value column
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
.unwrap();
// Type the value
app.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE))
.unwrap();
// Enter to commit
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.workbook.model.get_cell(&CellKey::new(vec![
("_Measure".to_string(), "Rev".to_string()),
])),
Some(&crate::model::cell::CellValue::Number(5.0)),
"editing a synthetic row in plain records mode should write the value"
);
}
/// Drill-view edits should stay staged in drill state until the user
/// navigates back, at which point ApplyAndClearDrill writes them through.
#[test]
fn drill_edit_is_staged_until_view_back() {
use crate::model::cell::{CellKey, CellValue};
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_category("Month").unwrap();
wb.model.category_mut("Region").unwrap().add_item("East");
wb.model.category_mut("Month").unwrap().add_item("Jan");
let record_key = CellKey::new(vec![
("Month".to_string(), "Jan".to_string()),
("Region".to_string(), "East".to_string()),
]);
wb.model.set_cell(record_key.clone(), CellValue::Number(1.0));
let mut app = App::new(wb, None);
app.handle_key(KeyEvent::new(KeyCode::Char('>'), KeyModifiers::NONE))
.unwrap();
assert!(app.drill_state.is_some(), "drill should create drill state");
let value_col = (0..app.layout.col_count())
.find(|&col| app.layout.col_label(col) == "Value")
.expect("drill view should include a Value column");
app.workbook.active_view_mut().selected = (0, value_col);
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Char('9'), KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.workbook.model.get_cell(&record_key),
Some(&CellValue::Number(1.0)),
"drill edit should remain staged until leaving the drill view"
);
assert_eq!(
app.drill_state
.as_ref()
.and_then(|s| s.pending_edits.get(&(0, "Value".to_string()))),
Some(&"9".to_string()),
"drill edit should be recorded in pending_edits"
);
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Char('<'), KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.workbook.model.get_cell(&record_key),
Some(&CellValue::Number(9.0)),
"leaving drill view should apply the staged edit"
);
}
/// Suspected bug: blanking a records-mode category coordinate should not
/// create an item with an empty name.
#[test]
fn blanking_records_category_does_not_create_empty_item() {
use crate::model::cell::{CellKey, CellValue};
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.model.category_mut("Region").unwrap().add_item("East");
wb.model.set_cell(
CellKey::new(vec![("Region".to_string(), "East".to_string())]),
CellValue::Number(1.0),
);
let mut app = App::new(wb, None);
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
for _ in 0..4 {
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE))
.unwrap();
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert!(
!app.workbook.model.category("Region").unwrap().items.contains_key(""),
"records-mode edits should not create empty category items"
);
}
#[test]
fn command_mode_buffer_cleared_on_reentry() {
use crossterm::event::KeyEvent;

View File

@ -6,6 +6,8 @@ use crate::view::Axis;
use super::app::{App, AppMode};
pub(crate) const RECORD_COORDS_CANNOT_BE_EMPTY: &str = "Record coordinates cannot be empty";
/// A discrete state change produced by a command.
/// Effects know how to apply themselves to the App.
pub trait Effect: Debug {
@ -459,6 +461,10 @@ impl Effect for ApplyAndClearDrill {
};
app.workbook.model.set_cell(orig_key.clone(), value);
} else {
if new_value.is_empty() {
app.status_msg = RECORD_COORDS_CANNOT_BE_EMPTY.to_string();
continue;
}
// Rename a coordinate: remove old cell, insert new with updated coord
let value = match app.workbook.model.get_cell(orig_key) {
Some(v) => v.clone(),