use crate::command::key::{KeyCode, KeyEvent, KeyModifiers}; // The portable half of the session lives in `improvise-protocol` (improvise-cqi), // re-exported so `crate::ui::app::AppMode` and friends keep resolving. use anyhow::Result; pub use improvise_protocol::{ AppMode, HELP_PAGE_COUNT, MinibufColor, MinibufferConfig, Panel, ViewCursor, ViewFrame, ViewState, }; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; // Not std::time::Instant: that panics on wasm32-unknown-unknown, and this // crate is part of the browser bundle (improvise-ywd). use web_time::Instant; use std::rc::Rc; use crate::command::cmd::CmdContext; use crate::command::keymap::{Keymap, KeymapSet}; use crate::format::FormatPlan; use crate::import::wizard::ImportWizard; use crate::model::cell::{CellKey, CellValue, DataStore}; use crate::persistence; use crate::ui::effect::{EffectKind, ModelChange, ViewTarget}; use crate::ui::project::Dirty; use crate::ui::viewport::{ GridSummary, compute_row_header_width, compute_visible_cols, grid_summary, grid_width, }; use crate::view::{GridLayout, View}; use crate::workbook::Workbook; use improvise_core::model::record::{AggregateHash, RecordHash}; use improvise_protocol::{CellId, RecordCell}; /// A cell's coordinate tuple with `_Measure` dropped: the unit of atomicity /// ADR 001 settles on, and not the cell — see `context/code/command.md`. pub use improvise_core::model::record::record_of; /// One view's **drawn** shape, as a hash. Drawn, not configured — the other half is /// [`document_config`]; see `context/code/command.md` (improvise-pdvm, improvise-75ld). fn view_shape(model: &crate::model::types::Model, view: &View) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); for (cat, axis) in &view.category_axes { cat.hash(&mut h); axis.hash(&mut h); let hidden = view.hidden_items.get(cat); let drawn: Vec = model .effective_item_names(cat) .into_iter() .filter(|item| !hidden.is_some_and(|h| h.contains(item))) .collect(); drawn.hash(&mut h); // Off the drawn items' chains, not `Category::groups`: a group with nothing // drawn under it is not shape, and a fold at any depth is. let mut collapsed: Vec<&str> = model .category(cat) .into_iter() .flat_map(|c| drawn.iter().map(move |item| c.item_chain(item))) .flat_map(|chain| { (0..chain.len()) .filter(|&d| view.is_group_collapsed(cat, &chain[..=d])) .map(|d| chain[d]) .collect::>() }) .collect(); collapsed.sort_unstable(); collapsed.dedup(); collapsed.hash(&mut h); view.page_selections.get(cat).hash(&mut h); } view.prune_empty.hash(&mut h); h.finish() } /// One view's drawn **frame**, as a hash. A different question from [`view_shape`] /// — `context/code/command.md` §"Two observations, not one". fn view_frame(model: &crate::model::types::Model, view: &View) -> u64 { // `with_frozen_records`, not `new`: `new` does NOT apply `prune_empty`, and a // pruned row is exactly the kind of reframing this observation exists for. frame_of(&crate::view::layout::GridLayout::with_frozen_records( model, view, None, )) } /// The same hash off a layout already built. The batch's opening basis is free /// this way — `context/code/command.md` (improvise-mcq4). fn frame_of(layout: &crate::view::layout::GridLayout) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); layout.row_cats.hash(&mut h); layout.col_cats.hash(&mut h); for entries in [&layout.row_items, &layout.col_items] { entries.len().hash(&mut h); for entry in entries { match entry { crate::view::layout::AxisEntry::GroupHeader { depth, cat_name, group_name, collapsed, .. } => { 0u8.hash(&mut h); depth.hash(&mut h); cat_name.hash(&mut h); group_name.hash(&mut h); collapsed.hash(&mut h); } crate::view::layout::AxisEntry::DataItem { tuple, collapsed } => { 1u8.hash(&mut h); tuple.hash(&mut h); collapsed.hash(&mut h); } // Drawn as nothing, but it ends a group's run (improvise-gd83), // so where it sits is what a row's group attribution depends on. crate::view::layout::AxisEntry::GroupEnd { level, depth } => { 2u8.hash(&mut h); level.hash(&mut h); depth.hash(&mut h); } } } } // Records mode numbers rows by POSITION (gotcha 1), so `row_items` is 0,1,2… // whatever order the store is in — `context/code/command.md`. for record in layout.records.iter().flat_map(|r| r.iter()) { record.coords.hash(&mut h); } h.finish() } /// Every view's drawn frame, keyed by view name. The `CellData` arm's basis: /// a cell write can prune a row out of a view nobody is looking at. fn view_frames(workbook: &Workbook) -> std::collections::BTreeMap { workbook .views .iter() .map(|(name, view)| (name.clone(), view_frame(&workbook.model, view))) .collect() } /// The names whose value differs between two shape maps. The union of the key /// sets is the domain: a view gained or lost differs at that name. fn differing_keys( before: &std::collections::BTreeMap, after: &std::collections::BTreeMap, ) -> std::collections::BTreeSet { before .keys() .chain(after.keys()) .filter(|name| before.get(*name) != after.get(*name)) .cloned() .collect() } /// Every view's drawn shape, keyed by view name. The map answers two questions: /// did *anything* move, and *which views* moved (improvise-nynk). fn view_shapes(workbook: &Workbook) -> std::collections::BTreeMap { workbook .views .iter() .map(|(name, view)| (name.clone(), view_shape(&workbook.model, view))) .collect() } /// `document_config` plus the cells and the formulas. `CellData` is wider than its /// name (improvise-748f), and only that arm pays the walk — context/code/command.md. fn document_content(workbook: &Workbook) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); for (key, value) in workbook.model.data.iter_cells() { key.hash(&mut h); hash_value(value, &mut h); } for formula in workbook.model.formulas() { formula.raw.hash(&mut h); formula.target.hash(&mut h); formula.target_category.hash(&mut h); } document_config(workbook).hash(&mut h); h.finish() } /// Everything a `LayoutGeometry` effect can write that the file would carry. Not /// the cells, so it stays O(config) on the held-key path (improvise-748f). pub(crate) fn document_config(workbook: &Workbook) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); for (name, cat) in &workbook.model.categories { name.hash(&mut h); cat.kind.hash(&mut h); for (item_name, item) in &cat.items { item_name.hash(&mut h); item.group.hash(&mut h); } for group in &cat.groups { group.name.hash(&mut h); group.parent.hash(&mut h); } } for (name, view) in &workbook.views { name.hash(&mut h); // `IndexMap` is ordered; everything below it is a `HashMap`/`HashSet`, so // sorting is what separates a fingerprint from a coin toss (gotcha 18). for (cat, axis) in &view.category_axes { cat.hash(&mut h); axis.hash(&mut h); } sorted_pairs(&view.hidden_items).hash(&mut h); let mut depths: Vec<_> = view.expand_depth.iter().collect(); depths.sort_unstable(); depths.hash(&mut h); let mut folds: Vec<_> = view .group_folds .iter() .flat_map(|(cat, groups)| groups.iter().map(move |(g, fold)| (cat, g, fold))) .collect(); folds.sort_unstable(); folds.hash(&mut h); let mut pages: Vec<(&String, &String)> = view.page_selections.iter().collect(); pages.sort_unstable(); pages.hash(&mut h); view.number_format.hash(&mut h); view.prune_empty.hash(&mut h); } h.finish() } fn sorted_pairs( map: &std::collections::HashMap>, ) -> Vec<(&String, Vec<&String>)> { let mut out: Vec<(&String, Vec<&String>)> = map .iter() .map(|(k, v)| { let mut items: Vec<&String> = v.iter().collect(); items.sort_unstable(); (k, items) }) .collect(); out.sort_unstable(); out } /// Exhaustive, and by bits rather than by `Debug`: two values that print alike /// must not fingerprint alike. fn hash_value(value: &CellValue, hasher: &mut impl std::hash::Hasher) { use std::hash::Hash; match value { CellValue::Number(n) => { 0u8.hash(hasher); n.to_bits().hash(hasher); } CellValue::Text(t) => { 1u8.hash(hasher); t.hash(hasher); } CellValue::Error(e) => { 2u8.hash(hasher); e.hash(hasher); } } } /// A per-record XOR fingerprint of the **raw** cells it holds. Process-local and /// never on a wire, unlike [`RecordHash`] — see `context/code/command.md`. fn record_fingerprints(data: &DataStore) -> std::collections::HashMap { use std::hash::{Hash, Hasher}; let mut out: std::collections::HashMap = std::collections::HashMap::new(); for (key, value) in data.iter_cells() { let mut hasher = std::collections::hash_map::DefaultHasher::new(); key.hash(&mut hasher); hash_value(value, &mut hasher); *out.entry(RecordHash::of(&key)).or_insert(0) ^= hasher.finish(); } out } /// Resolution against the grid each view is drawn, each derived at most once per /// borrow of the `App` (improvise-kimw) — `context/code/command.md`. pub struct Grids<'a> { app: &'a App, derived: std::cell::RefCell>>, } #[cfg(test)] thread_local! { /// How many grids a `Grids` has derived on this thread: the kimw guard's witness. pub(crate) static GRIDS_DERIVED: std::cell::Cell = const { std::cell::Cell::new(0) }; } impl Grids<'_> { /// See [`App::resolve_record`]. pub fn resolve_record(&self, view: &str, hash: RecordHash) -> Option { let workbook = &self.app.model_state.workbook; // A view the document lacks drew nothing, stored or not (Codex P2 on #281). let stored = workbook .views .contains_key(view) .then(|| workbook.model.data.resolve_record(hash)); stored .flatten() .cloned() .or_else(|| self.with_layout_of(view, |layout| record_in(layout, hash))?) } /// A record a command names, looked up where it says: the view that drew it, or /// the store alone for a rename (improvise-wpxr, `context/code/command.md`). pub fn resolve_named(&self, named: improvise_protocol::NamedRecord) -> Option { match named { improvise_protocol::NamedRecord::Drawn { view, record } => { self.resolve_record(view, record) } improvise_protocol::NamedRecord::Stored(record) => { let data = &self.app.model_state.workbook.model.data; data.resolve_record(record).cloned() } } } /// See [`App::resolve_cell`]. pub fn resolve_cell(&self, view: &str, cell: &RecordCell) -> Option { let record = self.resolve_record(view, cell.record)?; Some(match &cell.measure { Some(measure) => record.with(crate::model::category::MEASURE_CATEGORY, measure), None => record, }) } /// See [`App::unwritable`]. pub fn unwritable(&self, view: &str, cell: &RecordCell) -> Option { let key = self.resolve_cell(view, cell)?; let model = &self.app.model_state.workbook.model; let derived = model.formula_for(&key).map(|f| { format!( "{} is computed by a formula — edit the formula, not the cell", f.target ) }); let refusal = self.app.non_item_refusal(&key).or(derived); refusal.or_else(|| self.aggregate_refusal(view, cell.record, &key)) } /// A record the store does not hold is known only by its layout position, and /// is a record only where the projection at ITS CELL says so (improvise-sjdb). fn aggregate_refusal(&self, view: &str, hash: RecordHash, key: &CellKey) -> Option { let model = &self.app.model_state.workbook.model; model.data.resolve_record(hash).map_or_else( || self.with_layout_of(view, |layout| aggregate_at(model, layout, key))?, |_| None, ) } /// Run `f` over the grid `view` is drawn — the host's own where the names agree. /// `None` for a view the document lacks: no `resolve_view` fallback on a write. fn with_layout_of(&self, view: &str, f: impl FnOnce(&GridLayout) -> T) -> Option { let app = self.app; let (name, view) = app.model_state.workbook.views.get_key_value(view)?; let name = name.as_str(); let mut derived = self.derived.borrow_mut(); let elsewhere = derived.entry(name.to_string()).or_insert_with(|| { let model = crate::ui::project::rederived_for(app, name, view); let model = model.as_ref().unwrap_or(&app.model_state.workbook.model); let built = crate::ui::project::layout_elsewhere(app, name, view, model); #[cfg(test)] GRIDS_DERIVED.with(|n| n.set(n.get() + usize::from(built.is_some()))); built }); Some(f(elsewhere.as_ref().unwrap_or(&app.layout))) } } /// Every cell key `layout` names, with its position. Records mode is excluded: /// its `cell_key` carries *positions*, the trap ADR 002 rejected `_Index` for. fn cells_of(layout: &GridLayout) -> impl Iterator + '_ { let rows = match layout.is_records_mode() { true => 0, false => layout.row_count(), }; (0..rows).flat_map(move |r| { (0..layout.col_count()).filter_map(move |c| layout.cell_key(r, c).map(|k| (r, c, k))) }) } /// The record of some cell `layout` names, if any names it. fn record_in(layout: &GridLayout, hash: RecordHash) -> Option { cells_of(layout) .map(|(_, _, key)| key) .find(|key| RecordHash::of(key) == hash) .map(|key| record_of(&key)) } /// Why `key` is no one cell to write in `layout`: its position there projects as /// an aggregate. By the whole key, since a row's columns share one `RecordHash`. fn aggregate_at( model: &crate::model::types::Model, layout: &GridLayout, key: &CellKey, ) -> Option { let (row, col) = cells_of(layout) .find(|(_, _, at)| at == key) .map(|(row, col, _)| (row, col))?; let projected = crate::ui::project::cell_id(model, layout, row, col); matches!(projected, Some(CellId::Aggregate(_))) .then(|| "an aggregate combines several records, so there is no one cell to write".into()) } /// Drill-down state: frozen record snapshot + pending edits that have not /// yet been applied to the model. #[derive(Debug, Clone, Default)] pub struct DrillState { /// Frozen snapshot of records shown in the drill view (Rc for cheap cloning). pub records: Rc>, /// Pending edits keyed by (record_idx, column). The column is a `RecordCol`, /// not an index, so applying them does not need the drill layout still up. pub pending_edits: std::collections::HashMap<(usize, crate::view::RecordCol), String>, } /// Document state: the workbook and its IO bookkeeping. Distinct from `Workbook` /// itself because `file_path` and `dirty` are persistence-layer concerns. #[derive(Debug)] pub struct ModelState { pub workbook: Workbook, pub file_path: Option, pub dirty: bool, } impl Default for ModelState { fn default() -> Self { Self { workbook: Workbook::new("Untitled"), file_path: None, dirty: false, } } } /// What a layout was built from other than the model. #[derive(Debug, Default, PartialEq, Eq)] struct LayoutBasis { view: String, /// The address of the frozen records, never dereferenced. drill: Option, } /// What the keypress path measures of a built layout. pub(crate) struct GridGeometry { pub summary: GridSummary, pub row_header_width: u16, } /// Top-level application state: `model_state`, `view_state`, and a residue of /// fields tagged below. The five residue tags are in `context/code/command.md`. pub struct App { pub model_state: ModelState, pub view_state: ViewState, /// **Session-local**: a partially-filled import wizard. Neither portable /// (an `improvise-io` type) nor snapshot-safe (a half-open dialog). pub wizard: Option, /// **Session-local**: the frozen records list records-mode layouts iterate, /// plus staged edits. Holds an `Rc`, so `ViewState` would not be `Send`. pub drill_state: Option, /// **Runtime metadata**: wall-clock instant of the last autosave, used by /// `autosave_if_needed` to debounce writes. IO timing, not state. pub last_autosave: Instant, /// **Runtime metadata**: terminal dimensions, updated on resize events /// and at startup. Fed in by the host loop in `draw.rs`. pub term_width: u16, pub term_height: u16, /// **Derived cache**: a pure function of the slices, rebuilt by /// `rebuild_layout()`; a writer that bypasses effects must call it (felo.13). pub layout: GridLayout, /// How many times `rebuild_layout` has run: what a keypress that changes /// neither the model nor the view must leave alone (improvise-felo.13). layout_builds: usize, /// How many times the grid's column widths were measured, for the same reason. width_passes: std::cell::Cell, /// The widths of the layout above, measured on first use and dropped with it. geometry: std::cell::OnceCell, /// What the layout above was built from besides the model: the view it drew /// and the drill snapshot it froze. `layout_current` compares it. layout_basis: LayoutBasis, /// The model changed since the layout was built. Set by the one writer that /// does not rebuild at once. layout_stale: bool, /// **Transient**: set during `apply_effects` to skip the rest of the batch, /// reset at the start of every call. Reach it through `AbortChain`. pub abort_effects: bool, /// Whether the **last** `apply_effects` batch stopped early. Complete only /// for a one-effect batch — see `context/code/command.md`. batch_aborted: bool, /// Whether an effect in the **last** batch refused what it was asked, through /// [`App::refuse`]. Not an abort: `context/code/cli.md` §10. batch_refused: bool, /// **Session-local**: how many times the whole document has been replaced. /// Bumped by `install_workbook` and nothing else (improvise-cqq). document_generation: u64, /// **Shared document state**: how many times the grid's *shape* has moved. /// The basis's middle tier (ADR 001, improvise-xwi6). layout_version: u64, /// **Shared document state**: how many times a shape change invalidated a /// cursor. A counter, not a table of commands (improvise-gvnb, gotcha 39). cursor_epoch: u64, /// The views the **last** batch was observed to reshape. The outcome, where /// `view_shapes` is the mechanism — the shape `batch_aborted` takes. last_reshaped_views: std::collections::BTreeSet, /// The views the **last** batch was observed to REFRAME: different rows or /// columns, in a different order. What invalidates a cursor (improvise-ni6k). last_reframed_views: std::collections::BTreeSet, /// The frame each **non-active** view had before this batch wrote it, captured /// at the one door such a write goes through (improvise-lltt). written_view_frames: std::collections::BTreeMap, /// The non-active views the last batch reframed. The host's own cursor cannot /// witness these, so `cursor_epoch` alone is blind to them. last_reframed_elsewhere: std::collections::BTreeSet, /// **Config**: assembled at startup by `KeymapSet::default_keymaps()`. Read /// every keypress, never mutated during a session. keymap_set: KeymapSet, /// **Config**: where documents are read and written. Every document-touching /// effect goes through this rather than `std::fs` (improvise-fuj). pub storage: Box, } /// Everything a frame can write: the document, its tiers, this host's own /// session slice and the last batch's outcome. `context/code/command.md` §4. #[derive(Debug, Clone)] pub struct AppSnapshot { workbook: Workbook, dirty: bool, document_generation: u64, layout_version: u64, cursor_epoch: u64, view_state: ViewState, abort_effects: bool, batch_aborted: bool, batch_refused: bool, last_reshaped_views: std::collections::BTreeSet, last_reframed_views: std::collections::BTreeSet, written_view_frames: std::collections::BTreeMap, last_reframed_elsewhere: std::collections::BTreeSet, } impl App { /// Build an app backed by the real filesystem. pub fn new(workbook: Workbook, file_path: Option) -> Self { Self::with_storage(workbook, file_path, Box::new(crate::storage::PhysicalFs)) } /// Build an app over an arbitrary storage backend. pub fn with_storage( mut workbook: Workbook, file_path: Option, storage: Box, ) -> Self { // Recompute the formula cache before the first layout, so formula values // render on frame one. It is keyed by the active view's None-axis cats. let none_cats = workbook.active_view().none_cats(); workbook.model.recompute_formulas(&none_cats); let active_view_name = workbook.active_view.clone(); let layout = { let view = workbook.active_view(); GridLayout::with_frozen_records(&workbook.model, view, None) }; // A document may open on a records grid, so the first mode is settled // by the rule every batch ends with (improvise-y5eo). let mut app = Self { model_state: ModelState { workbook, file_path, dirty: false, }, view_state: ViewState { active_view: active_view_name, ..ViewState::default() }, wizard: None, drill_state: None, last_autosave: Instant::now(), // Defaults; the TUI/CLI boundary overwrites these with the real // terminal size (see draw.rs / main.rs). term_width: 80, term_height: 24, layout, layout_builds: 0, width_passes: std::cell::Cell::new(0), geometry: std::cell::OnceCell::new(), layout_basis: LayoutBasis::default(), layout_stale: false, abort_effects: false, batch_aborted: false, batch_refused: false, document_generation: 0, layout_version: 0, cursor_epoch: 0, last_reshaped_views: std::collections::BTreeSet::new(), last_reframed_views: std::collections::BTreeSet::new(), written_view_frames: std::collections::BTreeMap::new(), last_reframed_elsewhere: std::collections::BTreeSet::new(), keymap_set: KeymapSet::default_keymaps(), storage, }; app.layout_basis = app.current_layout_basis(); app.settle_normal_mode(); app } /// Whether any of the three side panels has taken its column. pub fn side_panel_open(&self) -> bool { let vs = &self.view_state; vs.formula_panel_open || vs.category_panel_open || vs.view_panel_open } /// The terminal columns the grid gets (improvise-gtn7.1). pub fn grid_width(&self) -> u16 { grid_width(self.term_width, self.side_panel_open()) } /// This client's cursor in the active view. Absent means the origin, so /// a view visited for the first time opens at (0, 0). pub fn cursor(&self) -> ViewCursor { self.view_state.cursor() } /// This client's cursor in `view_name`. pub fn cursor_in(&self, view_name: &str) -> ViewCursor { self.view_state .cursors .get(view_name) .copied() .unwrap_or_default() } /// Mutable cursor for the active view, created at the origin if absent. pub fn cursor_mut(&mut self) -> &mut ViewCursor { self.view_state.cursor_mut() } /// Replace the whole document. The client's view slice is scoped to the old /// one, so it is reset rather than carried (improvise-8uz/cqi). pub fn install_workbook(&mut self, workbook: Workbook) { self.view_state.active_view = workbook.active_view.clone(); self.view_state.cursors.clear(); // The history names the outgoing document's views too (improvise-lrrx). self.view_state.view_back_stack.clear(); self.view_state.view_forward_stack.clear(); self.model_state.workbook = workbook; self.layout_stale = true; // Every client's mirror is scoped to the outgoing document too, and an // effect cannot reach the session list. The generation is how it hears. self.document_generation += 1; } /// Did the last [`App::apply_effects`] batch stop before the end? See the /// field: a one-effect batch is the one place this is a complete answer. pub fn last_batch_aborted(&self) -> bool { self.batch_aborted } /// Did an effect in the last batch refuse what it was asked? The question an /// exit code asks, which `last_batch_aborted` does not answer (improvise-c5ip). pub fn last_batch_refused(&self) -> bool { self.batch_refused } /// Say why the requested change did not happen, and record that it did not. /// The one writer of `batch_refused`, so a refusal cannot forget the flag. pub fn refuse(&mut self, msg: impl Into) { self.view_state.status_msg = msg.into(); self.batch_refused = true; } /// The document and its basis tiers, kept so a failed frame can put them /// back (ADR 001 clause 4). See `context/code/command.md`. pub fn snapshot(&self) -> AppSnapshot { AppSnapshot { workbook: self.model_state.workbook.clone(), dirty: self.model_state.dirty, document_generation: self.document_generation, layout_version: self.layout_version, cursor_epoch: self.cursor_epoch, view_state: self.view_state.clone(), abort_effects: self.abort_effects, batch_aborted: self.batch_aborted, batch_refused: self.batch_refused, last_reshaped_views: self.last_reshaped_views.clone(), last_reframed_views: self.last_reframed_views.clone(), written_view_frames: self.written_view_frames.clone(), last_reframed_elsewhere: self.last_reframed_elsewhere.clone(), } } /// Put everything a frame can write back as `snapshot` described it. Why /// `view_state` goes back HERE and not in `apply_effects`: command.md §4. pub fn restore(&mut self, snapshot: AppSnapshot) { self.model_state.workbook = snapshot.workbook; self.model_state.dirty = snapshot.dirty; self.document_generation = snapshot.document_generation; self.layout_version = snapshot.layout_version; self.cursor_epoch = snapshot.cursor_epoch; self.view_state = snapshot.view_state; self.abort_effects = snapshot.abort_effects; self.batch_aborted = snapshot.batch_aborted; self.batch_refused = snapshot.batch_refused; self.last_reshaped_views = snapshot.last_reshaped_views; self.last_reframed_views = snapshot.last_reframed_views; self.written_view_frames = snapshot.written_view_frames; self.last_reframed_elsewhere = snapshot.last_reframed_elsewhere; self.rebuild_layout(); } /// How many times this app's document has been replaced. A session that /// was seeded at an earlier generation is holding another document. pub fn document_generation(&self) -> u64 { self.document_generation } /// How many times the grid's shape has moved. The middle tier of a /// batch's basis (ADR 001). pub fn layout_version(&self) -> u64 { self.layout_version } /// The write counter for one record; [`RecordHash::of`] normalizes a cell key /// to its record. Zero for an untouched *and* for an absent record. pub fn record_version(&self, record: &CellKey) -> u64 { self.record_version_of(RecordHash::of(record)) } /// The same counter, reached by the surrogate a client actually holds — /// no `CellKey` rebuilt on the way. pub fn record_version_of(&self, hash: RecordHash) -> u64 { self.model_state.workbook.model.data.record_version(hash) } /// Resolve a client's record surrogate to the cell it names (ADR 002). `None` /// means reject. Index first, then `view`'s layout — `context/code/command.md`. pub fn resolve_record(&self, view: &str, hash: RecordHash) -> Option { self.grids().resolve_record(view, hash) } /// The full cell key a [`RecordCell`] drawn in `view` names: its record, plus /// the measure coordinate that picks one of the record's cells. pub fn resolve_cell(&self, view: &str, cell: &RecordCell) -> Option { self.grids().resolve_cell(view, cell) } /// Why a wire write to `cell`, drawn in `view`, is refused: that view projects /// it as no `CellId::Record` (improvise-ciw8, ADR 002). `None` for a writable cell. pub fn unwritable(&self, view: &str, cell: &RecordCell) -> Option { self.grids().unwritable(view, cell) } /// The questions above, sharing each view's derived grid for as long as the /// borrow lasts (improvise-kimw). Hold one across a check, not across a mutation. pub fn grids(&self) -> Grids<'_> { Grids { app: self, derived: std::cell::RefCell::new(std::collections::HashMap::new()), } } /// Resolve an aggregate surrogate (ADR 002) against the layout and only the /// layout: an aggregate has no existence outside the layout deriving it. pub fn resolve_aggregate(&self, hash: AggregateHash) -> Option { cells_of(&self.layout) .map(|(_, _, key)| key) .find(|key| AggregateHash::of(key) == hash) } /// Every coordinate must name an item of its category, asked of the document /// (improvise-b114, improvise-nacd). See `context/code/command.md`. fn non_item_refusal(&self, key: &CellKey) -> Option { let model = &self.model_state.workbook.model; model .first_non_item(key) .map(|(cat, name)| improvise_protocol::non_item_refusal(cat, name)) } /// Point **this client's** slice at `name`, if the document has such a view, /// and say whether it did. The document's default stays put (gotcha 29). pub fn switch_to_view(&mut self, name: &str) -> bool { let accepted = self.model_state.workbook.views.contains_key(name); if accepted { self.view_state.active_view = name.to_string(); } accepted } /// What `name` names in this document, falling back to the default for a name /// the document has not got. The **one** answer — see `context/code/command.md`. pub fn resolve_view<'a>(&'a self, name: &str) -> (&'a str, &'a View) { let workbook = &self.model_state.workbook; match workbook.views.get_key_value(name) { Some((key, view)) => (key.as_str(), view), None => { let default = workbook.active_view.as_str(); // The message `Workbook::active_view()` carried: `IndexMap`'s // `Index` panics bare, naming no invariant (improvise-h074). let view = workbook .views .get(default) .expect("active_view always names an existing view"); (default, view) } } } /// Which view this client is on. pub fn view_name(&self) -> &str { self.resolve_view(&self.view_state.active_view).0 } /// The view this client is on. pub fn view(&self) -> &View { self.resolve_view(&self.view_state.active_view).1 } /// The view a geometry effect names, to mutate. There is **no operandless /// form**: the ambiguity improvise-lltt closed was exactly that one. pub fn view_mut_of(&mut self, target: &ViewTarget) -> Option<&mut View> { // A `Named` that the document has not got is a no-op, never a fallback: // `resolve_view`'s default would move a view nobody asked about. let name = match target { ViewTarget::Active => self.view_name().to_string(), ViewTarget::Named(name) => name.clone(), }; self.remember_elsewhere(&name); self.model_state.workbook.views.get_mut(&name) } /// Take the opening frame of a view this batch is about to write and the host /// is not on — the lazy half of `apply_effects`' observation (improvise-lltt). fn remember_elsewhere(&mut self, name: &str) { let unseen = name != self.view_name() && !self.written_view_frames.contains_key(name) && self.model_state.workbook.views.contains_key(name); if unseen { let workbook = &self.model_state.workbook; let frame = view_frame(&workbook.model, &workbook.views[name]); self.written_view_frames.insert(name.to_string(), frame); } } /// Which non-active views the last batch reframed. A **precise** set, unlike /// [`last_reframed_views`](Self::last_reframed_views) — `context/code/command.md`. pub fn last_reframed_elsewhere(&self) -> &std::collections::BTreeSet { &self.last_reframed_elsewhere } /// Diff every frame `remember_elsewhere` took. The audience for a view the /// host is not on, which no cursor of the host's can witness. fn observe_written_view_frames(&mut self) { let before = std::mem::take(&mut self.written_view_frames); let workbook = &self.model_state.workbook; self.last_reframed_elsewhere = before .into_iter() .filter(|(name, was)| match workbook.views.get(name) { Some(view) => view_frame(&workbook.model, view) != *was, None => true, }) .map(|(name, _)| name) .collect(); let moved = self.last_reframed_elsewhere.clone(); self.last_reframed_views.extend(moved); } /// Send the active view's cursor to the origin after a shape change. Clients /// hear about it through [`cursor_epoch`](Self::cursor_epoch) (improvise-gvnb). pub fn reset_cursor(&mut self) { *self.cursor_mut() = ViewCursor::default(); self.cursor_epoch += 1; } /// How many times a shape change has invalidated every cursor into this /// document. A mirror positioned before this moved is indexing a stale grid. pub fn cursor_epoch(&self) -> u64 { self.cursor_epoch } /// `Normal` and `RecordsNormal` are one mode over two layouts, so the mode a /// batch ENDS in must match the grid it ends on (improvise-sihr). fn settle_normal_mode(&mut self) { // The same rule a client settles by, so the two ends cannot drift // (improvise-e5pf) — `context/code/protocol.md`. self.view_state.mode = improvise_protocol::settled_mode(&self.view_state.mode, self.layout.is_records_mode()); } /// Which views the last batch actually reshaped. **Empty means "not known"**, /// never "none" — a reader must fail safe (improvise-nynk). pub fn last_reshaped_views(&self) -> &std::collections::BTreeSet { &self.last_reshaped_views } /// This client's own view, drawn. One view, not every view — improvise-mcq4 /// measured what the per-view multiplier costs under `prune_empty`. fn active_view_frame(&self) -> u64 { let workbook = &self.model_state.workbook; let (_, view) = self.resolve_view(self.view_state.active_view.as_str()); view_frame(&workbook.model, view) } /// The same frame at the START of a batch, where `self.layout` is still the /// one this view draws. Stale mid-batch (gotcha 23), so opening use only. fn opening_view_frame_keyed(&self) -> (String, u64) { (self.view_name().to_string(), self.opening_view_frame()) } fn opening_view_frame(&self) -> u64 { let (_, view) = self.resolve_view(self.view_state.active_view.as_str()); match self.drill_state.is_none() { true => frame_of(&self.layout), // A drill froze the record list, so the held layout is not what // `view_frame` would build and the two must not be compared. false => view_frame(&self.model_state.workbook.model, view), } } /// Which views the last batch actually **reframed** — different rows or /// columns. Empty means "not known", never "none"; a reader must fail safe. pub fn last_reframed_views(&self) -> &std::collections::BTreeSet { &self.last_reframed_views } /// Rebuild the grid layout from current workbook, active view, and drill /// state. Note: `with_frozen_records` already handles pruning internally. pub fn rebuild_layout(&mut self) { let name = self.view_name().to_string(); let none_cats = self.model_state.workbook.views[&name].none_cats(); self.model_state .workbook .model .recompute_formulas(&none_cats); let view = &self.model_state.workbook.views[&name]; let frozen = self.drill_state.as_ref().map(|s| Rc::clone(&s.records)); self.layout = GridLayout::with_frozen_records(&self.model_state.workbook.model, view, frozen); self.layout_builds += 1; self.layout_stale = false; self.layout_basis = self.current_layout_basis(); self.geometry = std::cell::OnceCell::new(); } /// Rebuild only if the model, the view drawn or the drill snapshot moved; a /// cursor key moves none of them (layout-cost.md). fn refresh_layout(&mut self) { if self.layout_stale || self.layout_basis != self.current_layout_basis() { self.rebuild_layout(); } } fn current_layout_basis(&self) -> LayoutBasis { LayoutBasis { view: self.view_name().to_string(), drill: self .drill_state .as_ref() .map(|d| Rc::as_ptr(&d.records) as usize), } } /// The measurements a keypress needs of the grid: column widths and the row /// header. Counted, so a test can see whether a keypress paid for them. pub(crate) fn grid_geometry(&self) -> &GridGeometry { self.geometry.get_or_init(|| { self.width_passes.set(self.width_passes.get() + 1); let model = &self.model_state.workbook.model; let plan = FormatPlan::of(model, self.view()); GridGeometry { summary: grid_summary(model, &self.layout, &plan), row_header_width: compute_row_header_width(&self.layout), } }) } /// Editable text for the cell under the cursor: staged drill edit, else the /// layout's value, else the stored cell. Reads `self.layout` (gotcha 23). pub fn display_value_at_cursor(&self) -> String { let (row, col) = self.cursor().selected; self.layout .cell_key(row, col) .map(|key| self.display_value_of(&key)) .unwrap_or_default() } /// The text an editor opened on `key` starts with — unformatted, so **not** /// `CellDisplay::text` (design-principles §4). Crate-visible for the wire. pub(crate) fn display_value_of(&self, key: &CellKey) -> String { self.display_value_in(&self.layout, key) } /// The same text against a layout that may be another client's (improvise-jytm). pub(crate) fn display_value_in(&self, layout: &GridLayout, key: &CellKey) -> String { match crate::view::synthetic_record_info(key) { Some((idx, col)) => self .staged_drill_edit(layout, idx, col) .or_else(|| layout.records_value(idx, col).map(|v| v.edit_text())) .unwrap_or_default(), None => self .model_state .workbook .model .get_cell(key) .map(CellValue::edit_text) .unwrap_or_default(), } } /// The host's own staged drill text at a records position, and only for the /// host's own layout — a row index names nothing in another (improvise-pubt). fn staged_drill_edit(&self, layout: &GridLayout, idx: usize, col: usize) -> Option { let own = std::ptr::eq(layout, &self.layout); let col = layout.record_col(col).filter(|_| own)?; self.drill_state .as_ref()? .pending_edits .get(&(idx, col.clone())) .cloned() } pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> { let view = self.view(); let layout = &self.layout; let cursor = self.cursor(); CmdContext { model: &self.model_state.workbook.model, workbook: &self.model_state.workbook, view, layout, registry: self.keymap_set.registry(), mode: &self.view_state.mode, selected: cursor.selected, row_offset: cursor.row_offset, col_offset: cursor.col_offset, search_query: &self.view_state.search_query, yanked: &self.view_state.yanked, dirty: self.model_state.dirty, search_mode: self.view_state.search_mode, formula_panel_open: self.view_state.formula_panel_open, category_panel_open: self.view_state.category_panel_open, view_panel_open: self.view_state.view_panel_open, buffers: &self.view_state.buffers, opened_editor: self.view_state.opened_editor.as_ref(), formula_cursor: self.view_state.formula_cursor, cat_panel_cursor: self.view_state.cat_panel_cursor, view_panel_cursor: self.view_state.view_panel_cursor, tile_cat_idx: self.view_state.tile_cat_idx, view_back_stack: &self.view_state.view_back_stack, view_forward_stack: &self.view_state.view_forward_stack, has_drill_state: self.drill_state.is_some(), display_value: self.display_value_at_cursor(), visible_rows: (self.term_height as usize).saturating_sub(8), visible_cols: { let geometry = self.grid_geometry(); compute_visible_cols( &geometry.summary.col_widths, geometry.row_header_width, self.grid_width(), cursor.col_offset, ) }, expanded_cats: &self.view_state.expanded_cats, wizard: self.wizard.as_ref(), key_code: key, } } /// Apply a batch of effects and report which widgets it could have changed /// (improvise-cqq). One tier off the `EffectKind`, two observed — context/code/. pub fn apply_effects(&mut self, effects: Vec>) -> Dirty { self.abort_effects = false; self.batch_aborted = false; self.batch_refused = false; // The `Dirty` is read off the `EffectKind`; the two lower basis tiers are // **observed** around the loop (improvise-75ld, improvise-pdvm). let touches_model = effects .iter() .any(|e| matches!(e.kind(), EffectKind::Model(_))); let writes_cells = effects .iter() .any(|e| matches!(e.kind(), EffectKind::Model(ModelChange::CellData))); let moves_geometry = effects .iter() .any(|e| matches!(e.kind(), EffectKind::Model(ModelChange::LayoutGeometry))); let records_before = writes_cells.then(|| record_fingerprints(&self.model_state.workbook.model.data)); let shapes_before = touches_model.then(|| view_shapes(&self.model_state.workbook)); // Two bases, because the cheap one is the held-key path: `[`/`]`, axis // cycle and hide/show must not walk 18,000 cells (improvise-748f). let mut content_basis = writes_cells.then(|| document_content(&self.model_state.workbook)); let mut config_basis = moves_geometry.then(|| document_config(&self.model_state.workbook)); // The cursor's own basis. The cheap one-view form on the held-key path, // and every view where a cell write can prune one — `context/code/command.md`. let mut frame_basis = moves_geometry.then(|| self.opening_view_frame_keyed()); let mut frames_basis = writes_cells.then(|| view_frames(&self.model_state.workbook)); self.last_reframed_views.clear(); self.last_reframed_elsewhere.clear(); self.written_view_frames.clear(); let mut dirty = Dirty::NOTHING; for effect in effects { let kind = effect.kind(); effect.apply(self); dirty = dirty.union(Dirty::of(kind)); // Exhaustive, so a fourth `ModelChange` has to answer this question // rather than defaulting into the observed half by omission. let declared_dirty = match kind { EffectKind::ViewOnly => false, // A replaced document differs from the file its path names even // where the content matches, so it is declared (improvise-748f). EffectKind::Model(ModelChange::WholeWorkbook) => true, // Observed below, each against the fingerprint its tier needs. EffectKind::Model(ModelChange::CellData) => false, EffectKind::Model(ModelChange::LayoutGeometry) => false, }; if declared_dirty { self.model_state.dirty = true; } // The observed half, read **inside the loop** so `Save` still clears it // in loop order (gotcha 45) — the position is the property. if matches!(kind, EffectKind::Model(ModelChange::CellData)) && let Some(basis) = content_basis { let now = document_content(&self.model_state.workbook); self.model_state.dirty |= now != basis; content_basis = Some(now); } if matches!(kind, EffectKind::Model(ModelChange::LayoutGeometry)) && let Some(basis) = config_basis { let now = document_config(&self.model_state.workbook); self.model_state.dirty |= now != basis; config_basis = Some(now); } // The basis follows `active_view`: a switch re-bases HERE, so the next // geometry effect compares (improvise-9c9o, `context/code/command.md`). if let Some((was, _)) = frame_basis.as_ref() && was != self.view_name() { frame_basis = Some((self.view_name().to_string(), self.active_view_frame())); } // Inside the loop, or a batch that reframes and then places the // cursor loses the placement — `context/code/command.md`. if let Some((_, basis)) = frame_basis.as_ref() && matches!(kind, EffectKind::Model(ModelChange::LayoutGeometry)) { let now = self.active_view_frame(); if now != *basis { self.reset_cursor(); self.last_reframed_views .insert(self.view_name().to_string()); } frame_basis = Some((self.view_name().to_string(), now)); } // A cell write can prune a row out of ANY view, so this arm pays for // every one of them (improvise-mcq4's sting). if let Some(basis) = frames_basis.as_ref() && matches!(kind, EffectKind::Model(ModelChange::CellData)) { let now = view_frames(&self.model_state.workbook); let moved = differing_keys(basis, &now); if moved.contains(self.view_name()) { self.reset_cursor(); } self.last_reframed_views.extend(moved); frames_basis = Some(now); } if self.abort_effects { // An abort-setting effect asked for the rest to be skipped. Reset // the flag, and record the outcome where a caller can read it. debug_assert!( matches!(kind, EffectKind::ViewOnly) || self.batch_refused, "a model effect aborted without refusing: {effect:?} (improvise-lasd)" ); self.abort_effects = false; self.batch_aborted = true; break; } } if let Some(before) = records_before { self.bump_changed_records(&before); } self.observe_written_view_frames(); self.observe_reshape(shapes_before); // The audience's half, and over-reporting is the safe direction: this set // is read only behind the epoch gate — `context/code/command.md`. let reshaped = self.last_reshaped_views.clone(); self.last_reframed_views.extend(reshaped); self.layout_stale |= touches_model; self.refresh_layout(); // After the rebuild, or a batch that crosses into or out of records mode // settles against the grid it started on — `context/code/command.md`. self.settle_normal_mode(); dirty } /// Bump `layout_version` for the shape the batch moved and record which views /// moved — observed, never declared (improvise-pdvm, improvise-nynk). fn observe_reshape(&mut self, before: Option>) { self.last_reshaped_views.clear(); let Some(before) = before else { return }; let after = view_shapes(&self.model_state.workbook); self.last_reshaped_views = differing_keys(&before, &after); if !self.last_reshaped_views.is_empty() { self.layout_version += 1; } } /// Bump every record whose raw cells read differently than at `before`. Gained, /// lost and rewritten all count — each moves under an older basis. fn bump_changed_records(&mut self, before: &std::collections::HashMap) { let data = &mut self.model_state.workbook.model.data; let after = record_fingerprints(data); // A set, not a list: a record present in both maps is named twice by the // chain, and a rewritten record would otherwise count as two writes. let moved: std::collections::HashSet = before .keys() .chain(after.keys()) .filter(|record| before.get(*record) != after.get(*record)) .copied() .collect(); for record in moved { data.bump_record(record); } } /// True when the model has no user-defined categories (show welcome/help). /// Virtual categories (_Index, _Dim, _Measure) are always present and don't count. pub fn is_empty_model(&self) -> bool { use crate::model::category::CategoryKind; self.model_state .workbook .model .categories .values() .all(|c| { matches!( c.kind, CategoryKind::VirtualIndex | CategoryKind::VirtualDim | CategoryKind::VirtualMeasure ) }) } /// The sub-keymap an armed prefix opens, in the current mode. Public so /// the renderer can show which-key hints for it. pub fn transient_keymap(&self, key: KeyCode, mods: KeyModifiers) -> Option> { let mode = crate::command::keymap::ModeKey::from_app_mode( &self.view_state.mode, self.view_state.search_mode, )?; self.keymap_set.prefix_keymap(&mode, key, mods) } /// The keymap for the currently armed prefix, if any. pub fn armed_prefix_keymap(&self) -> Option> { let (key, mods) = self.view_state.transient_prefix?; self.transient_keymap(key, mods) } pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> { self.dispatch_key(key); Ok(()) } /// Resolve one keypress through the keymap and apply what it produces. The /// projection half is factored out; `ui::reduce` is the named door (improvise-gxi). pub fn dispatch_key(&mut self, key: KeyEvent) -> Dirty { self.refresh_layout(); // An armed prefix key takes priority. Resolve it back to its // sub-keymap through the keymap set (improvise-jc8). if let Some((prefix_key, prefix_mods)) = self.view_state.transient_prefix.take() { let effects = self .transient_keymap(prefix_key, prefix_mods) .and_then(|transient| { let ctx = self.cmd_context(key.code, key.modifiers); self.keymap_set .dispatch_transient(&transient, &ctx, key.code, key.modifiers) }); // Whether matched or not, the prefix is consumed. return effects .map(|effects| self.apply_effects(effects)) .unwrap_or(Dirty::NOTHING); } // Try mode keymap — if a binding matches, apply effects and return let effects = { let ctx = self.cmd_context(key.code, key.modifiers); self.keymap_set.dispatch(&ctx, key.code, key.modifiers) }; effects .map(|effects| self.apply_effects(effects)) .unwrap_or(Dirty::NOTHING) } pub fn autosave_if_needed(&mut self) { if self.model_state.dirty && self.last_autosave.elapsed() > Duration::from_secs(30) && let Some(path) = &self.model_state.file_path.clone() { let ap = persistence::autosave_path(path); let _ = persistence::save(&self.model_state.workbook, &ap); self.last_autosave = Instant::now(); } } pub fn start_import_wizard(&mut self, json: serde_json::Value) { self.start_import(crate::import::wizard::ImportPipeline::new(json)); } /// Open the wizard over a pipeline the host built, sources and all. pub fn start_import(&mut self, pipeline: crate::import::wizard::ImportPipeline) { self.wizard = Some(ImportWizard::from_pipeline(pipeline)); self.view_state.mode = AppMode::ImportWizard; } /// Hint text for the status bar (context-sensitive). Derived from the /// keymap, so it cannot go stale against it — protocol.md §8. pub fn hint_text(&self) -> String { improvise_protocol::hint_line(&self.view_state.mode) } } #[cfg(test)] mod tests { use super::*; use crate::model::cell::CellValue; use std::collections::HashMap; #[test] fn every_mode_has_a_distinct_label() { let labels: Vec<&str> = AppMode::all() .iter() .map(improvise_protocol::mode_label) .collect(); assert!(labels.iter().all(|l| !l.is_empty())); let mut sorted = labels.clone(); sorted.sort_unstable(); sorted.dedup(); assert_eq!(sorted.len(), labels.len(), "labels collide: {labels:?}"); } #[test] fn panel_sub_modes_report_their_parent_panel() { assert_eq!(AppMode::FormulaPanel.panel(), Some(Panel::Formula)); assert_eq!(AppMode::formula_edit().panel(), Some(Panel::Formula)); assert_eq!(AppMode::CategoryPanel.panel(), Some(Panel::Category)); assert_eq!(AppMode::category_add().panel(), Some(Panel::Category)); assert_eq!( AppMode::item_add("Region".into()).panel(), Some(Panel::Category) ); assert_eq!(AppMode::ViewPanel.panel(), Some(Panel::View)); assert_eq!(AppMode::Normal.panel(), None); assert_eq!(AppMode::editing().panel(), None); } #[test] fn panel_mode_round_trips_through_app_mode() { for panel in [Panel::Formula, Panel::Category, Panel::View] { assert_eq!(panel.mode().panel(), Some(panel)); } } /// improvise-3vr: `ModelState` and `ViewState` are the named slices. Step 1 /// only requires that the types exist and are constructible. #[test] fn model_state_and_view_state_are_constructible() { let _: ModelState = ModelState::default(); let _: ViewState = ViewState::default(); } /// improvise-x2c: ModelState owns the document slice — workbook, /// file_path, and dirty. App accesses them through model_state. #[test] fn app_model_state_owns_workbook_file_path_and_dirty() { let app = App::new(Workbook::new("T"), Some(PathBuf::from("/tmp/x"))); let _: &Workbook = &app.model_state.workbook; let _: &Option = &app.model_state.file_path; let _: bool = app.model_state.dirty; } /// improvise-h0b: `Send` is the cheapest compile-time proxy for "portable". /// Before h0b this failed to compile, `DrillState` holding an `Rc`. const _: fn() = || { fn assert_send() {} assert_send::(); }; /// improvise-ew0: `ViewState` owns the UI session slice; `App` reaches those /// fields through `view_state`. #[test] fn app_view_state_owns_ui_session_fields() { let app = App::new(Workbook::new("T"), None); let _: &AppMode = &app.view_state.mode; let _: &str = &app.view_state.status_msg; let _: &str = &app.view_state.search_query; let _: bool = app.view_state.search_mode; let _: bool = app.view_state.formula_panel_open; let _: bool = app.view_state.category_panel_open; let _: bool = app.view_state.view_panel_open; let _: usize = app.view_state.cat_panel_cursor; let _: usize = app.view_state.view_panel_cursor; let _: usize = app.view_state.formula_cursor; let _: &Option = &app.view_state.yanked; let _: usize = app.view_state.tile_cat_idx; let _: &Vec = &app.view_state.view_back_stack; let _: &Vec = &app.view_state.view_forward_stack; let _: usize = app.view_state.help_page; let _: &std::collections::HashSet = &app.view_state.expanded_cats; let _: &HashMap = &app.view_state.buffers; let _: &Option<(KeyCode, KeyModifiers)> = &app.view_state.transient_prefix; } /// improvise-a2x: `ViewState` is the Snapshot payload, so it must survive the /// wire format. Non-default in every field family, or it proves nothing. #[test] fn view_state_survives_a_json_round_trip() { let mut vs = ViewState { mode: AppMode::item_add("Region".into()), status_msg: "saved".into(), search_query: "north".into(), search_mode: true, help_page: 2, yanked: Some(CellValue::Number(12.5)), view_back_stack: vec![ViewFrame { view_name: "Default".into(), mode: AppMode::Normal, }], transient_prefix: Some((KeyCode::Char('g'), KeyModifiers::CONTROL)), ..ViewState::default() }; vs.buffers.insert("item".into(), "East".into()); vs.expanded_cats.insert("Region".into()); let json = serde_json::to_string(&vs).expect("ViewState serializes"); let back: ViewState = serde_json::from_str(&json).expect("ViewState deserializes"); assert_eq!(format!("{vs:?}"), format!("{back:?}")); assert_eq!(back.mode, AppMode::item_add("Region".into())); assert_eq!( back.transient_prefix, Some((KeyCode::Char('g'), KeyModifiers::CONTROL)) ); } /// improvise-jytm replaces the old lockstep guard: the document keeps the /// *default*, the client's slice is authoritative. See `context/code/command.md`. #[test] fn the_documents_default_view_outlives_this_clients_switches() { let mut app = two_col_model(); app.model_state.workbook.create_view("Second"); assert_eq!(app.view_state.active_view, "Default"); assert_eq!(app.model_state.workbook.active_view, "Default"); app.switch_to_view("Second"); assert_eq!(app.view_state.active_view, "Second"); assert_eq!( app.model_state.workbook.active_view, "Default", "a client's switch moved the document's default" ); assert_eq!( app.view().axis_of("_Measure"), app.model_state.workbook.views["Second"].axis_of("_Measure"), "the layout is built from the document's default, not this client's view" ); // A switch to a view that does not exist moves nothing: the slice keys // the cursor map and must never name a view the document lacks. app.switch_to_view("Nonexistent"); assert_eq!(app.view_state.active_view, "Second"); assert_eq!(app.model_state.workbook.active_view, "Default"); let mut incoming = Workbook::new("Other"); incoming.create_view("Elsewhere"); incoming.switch_view("Elsewhere").unwrap(); app.install_workbook(incoming); assert_eq!( app.view_state.active_view, "Elsewhere", "a replaced document does not seat this client on its default" ); assert_eq!(app.model_state.workbook.active_view, "Elsewhere"); assert!( app.view_state.cursors.is_empty(), "a new document does not inherit the old one's cursors" ); } /// BUG (improvise-pubt): `pending_edits` are keyed by a row index into the /// host's drill, and a position in one layout names nothing in another. #[test] fn a_foreign_layout_is_not_shown_the_hosts_staged_drill_text() { use crate::model::cell::{CellKey, CellValue}; use crate::view::{Axis, GridLayout, RecordCol}; use std::rc::Rc; let mut wb = Workbook::new("Drill"); wb.add_category("Region").unwrap(); wb.add_item("Region", "East").unwrap(); wb.add_item("_Measure", "Revenue").unwrap(); wb.model.set_cell( CellKey::new(vec![ ("Region".into(), "East".into()), ("_Measure".into(), "Revenue".into()), ]), CellValue::Number(7.0), ); wb.create_view("Records"); { let v = &mut wb.views["Records"]; v.set_axis("_Index", Axis::Row); v.set_axis("_Dim", Axis::Column); v.set_axis("Region", Axis::None); v.set_axis("_Measure", Axis::None); } let mut app = App::new(wb, None); let foreign = GridLayout::new( &app.model_state.workbook.model, &app.model_state.workbook.views["Records"], ); assert!( foreign.is_records_mode(), "the other client's view is not records mode, so no synthetic key exists" ); let col = (0..foreign.col_count()) .find( |c| matches!(foreign.record_col(*c), Some(RecordCol::Measure(m)) if m == "Revenue"), ) .expect("the records grid has a Revenue column"); let key = foreign .cell_key(0, col) .expect("the records grid names a cell at (0, Revenue)"); assert!( crate::view::synthetic_record_info(&key).is_some(), "the foreign layout produced no synthetic key, so the drill arm is unreachable" ); let mut pending = std::collections::HashMap::new(); pending.insert( (0, RecordCol::Measure("Revenue".into())), "STAGED".to_string(), ); let record = crate::view::RecordRow { coords: CellKey::new(vec![("Region".into(), "East".into())]), values: vec![("Revenue".to_string(), CellValue::Number(7.0))], }; app.drill_state = Some(DrillState { records: Rc::new(vec![record]), pending_edits: pending, }); assert_ne!( app.display_value_in(&foreign, &key), "STAGED", "another client was shown the host's uncommitted drill text against \ coordinates it was never staged for" ); // The positive arm: on the host's own layout the staged text is exactly // what the editor must open with, or "never consult the drill" passes. app.switch_to_view("Records"); app.rebuild_layout(); let own_key = app .layout .cell_key(0, col) .expect("the host's own records grid names a cell at (0, Revenue)"); assert_eq!( app.display_value_of(&own_key), "STAGED", "the host's own staged drill text was dropped" ); } /// Two pages whose non-empty rows differ, so pruning renumbers the grid when /// the page flips — improvise-7kvw. `prune_empty` is the caller's choice. fn paged_model(prune: bool) -> App { use crate::model::cell::{CellKey, CellValue}; use crate::view::Axis; let mut wb = Workbook::new("Paged"); wb.add_category("Region").unwrap(); wb.add_category("Quarter").unwrap(); for item in ["North", "South", "East"] { wb.add_item("Region", item).unwrap(); } for item in ["Q1", "Q2"] { wb.add_item("Quarter", item).unwrap(); } wb.add_item("_Measure", "Revenue").unwrap(); for (quarter, region) in [ ("Q1", "North"), ("Q1", "South"), ("Q2", "South"), ("Q2", "East"), ] { wb.model.set_cell( CellKey::new(vec![ ("Quarter".into(), quarter.into()), ("Region".into(), region.into()), ("_Measure".into(), "Revenue".into()), ]), CellValue::Number(1.0), ); } { let v = wb.active_view_mut(); v.set_axis("Region", Axis::Row); v.set_axis("_Measure", Axis::Column); v.set_axis("Quarter", Axis::Page); v.set_page_selection("Quarter", "Q1"); v.prune_empty = prune; } let mut app = App::new(wb, None); app.rebuild_layout(); app } fn row_labels(app: &App) -> Vec { (0..app.layout.row_count()) .map(|r| app.layout.row_label(r)) .collect() } fn flip_to_q2(app: &mut App) { app.apply_effects(vec![Box::new(super::super::effect::SetPageSelection { view: ViewTarget::Active, category: "Quarter".to_string(), item: "Q2".to_string(), })]); } /// A records-mode document with several records, so a drill can freeze a /// strictly narrower set than the live query returns. fn records_app_for_drill() -> App { let mut wb = Workbook::new("Drill"); wb.add_category("Region").unwrap(); for region in ["North", "South", "East"] { wb.add_item("Region", region).unwrap(); } wb.add_item("_Measure", "Revenue").unwrap(); for region in ["North", "South", "East"] { wb.model.set_cell( CellKey::new(vec![ ("Region".to_string(), region.to_string()), ("_Measure".to_string(), "Revenue".to_string()), ]), CellValue::Number(1.0), ); } { let v = wb.active_view_mut(); v.set_axis("_Index", crate::view::Axis::Row); v.set_axis("_Dim", crate::view::Axis::Column); v.set_axis("Region", crate::view::Axis::None); v.set_axis("_Measure", crate::view::Axis::None); } let mut app = App::new(wb, None); app.rebuild_layout(); assert!( app.layout.is_records_mode(), "the fixture must be records mode" ); app } /// The opening basis may be read off `App::layout` only when no drill froze /// it. Evidence: `context/test-evidence/command.md`. #[test] fn a_frozen_drill_does_not_take_its_opening_frame_from_the_held_layout() { let mut app = records_app_for_drill(); let live = app.layout.records.as_ref().map(|r| r.len()).unwrap_or(0); // Freeze ONE record where the live query finds several: the held layout // is then not what `view_frame` would build for this view. let frozen = std::rc::Rc::new(vec![ app.layout .records .as_ref() .expect("records mode") .first() .expect("at least one record") .clone(), ]); assert!( live > frozen.len(), "the fixture only says anything if the freeze really narrows the set" ); app.drill_state = Some(DrillState { records: frozen, pending_edits: std::collections::HashMap::new(), }); app.rebuild_layout(); let epoch = app.cursor_epoch(); // A batch that reframes nothing. With the guard removed, the opening // basis is the FROZEN layout and the in-loop hash is the LIVE one. app.apply_effects(vec![Box::new(super::super::effect::SetNumberFormat( ViewTarget::Active, ",.3f".to_string(), ))]); assert_eq!( app.cursor_epoch(), epoch, "a format change reframed nothing, but the frozen and live layouts \ were compared against each other" ); } /// A `SwitchView` mid-batch re-bases the frame rather than comparing across /// two views. Evidence: `context/test-evidence/command.md`. #[test] fn a_switch_then_a_geometry_effect_compares_within_one_view() { let mut app = paged_model(false); app.model_state.workbook.create_view("Second"); app.rebuild_layout(); let wb = &app.model_state.workbook; assert_ne!( super::view_frame(&wb.model, &wb.views["Default"]), super::view_frame(&wb.model, &wb.views["Second"]), "the fixture only says anything if the two views really do hash \ differently — a `create_view` that copied the active view's config \ would make this test vacuous" ); let epoch = app.cursor_epoch(); // Switch to a view whose frame differs, then a geometry effect that // changes nothing. Comparing across the switch is a near-certain mismatch. app.apply_effects(vec![ Box::new(super::super::effect::SwitchView("Second".to_string())), Box::new(super::super::effect::SetNumberFormat( ViewTarget::Active, ",.2f".to_string(), )), ]); assert_eq!( app.view_name(), "Second", "the fixture only says anything if the switch actually landed" ); assert_eq!( app.cursor_epoch(), epoch, "nothing reframed — the mismatch was between two different views' \ hashes, which is not a reshape" ); } /// BUG (improvise-9c9o): the first geometry effect after a mid-batch switch /// skipped the check entirely. Evidence: `context/test-evidence/command.md`. #[test] fn a_geometry_effect_after_a_mid_batch_switch_still_invalidates_the_cursor() { use crate::view::Axis; fn two_view_app() -> App { let mut app = paged_model(false); let second = app.model_state.workbook.create_view("Second"); second.set_axis("Region", Axis::Row); second.set_axis("_Measure", Axis::Column); app.rebuild_layout(); app } // What the switch alone leaves, so the row count the transpose must move // is measured rather than spelled. let mut probe = two_view_app(); probe.apply_effects(vec![Box::new(super::super::effect::SwitchView( "Second".to_string(), ))]); let rows_before = probe.layout.row_count(); let mut app = two_view_app(); app.cursor_mut().selected = (2, 0); let epoch = app.cursor_epoch(); app.apply_effects(vec![ Box::new(super::super::effect::SwitchView("Second".to_string())), Box::new(super::super::effect::TransposeAxes(ViewTarget::Active)), ]); assert_eq!( app.view_name(), "Second", "setup: the switch has to land, or the batch is a plain transpose" ); assert_ne!( app.layout.row_count(), rows_before, "setup: the transpose has to renumber Second's rows, or there is \ nothing for the check to catch" ); assert_ne!( app.cursor_epoch(), epoch, "the grid renumbered under a cursor at row 2 and nothing was \ invalidated — the basis was still keyed to the outgoing view, so \ the check re-based and skipped" ); assert_eq!( app.cursor().selected, (0, 0), "the host's own cursor is stale" ); } /// BUG (improvise-7kvw): pruning depends on the page, so a page flip renumbers /// the grid and left both the host's cursor and every client's stale. #[test] fn a_page_flip_under_prune_empty_invalidates_the_cursor() { let mut app = paged_model(true); let before_labels = row_labels(&app); let epoch = app.cursor_epoch(); app.cursor_mut().selected = (1, 0); flip_to_q2(&mut app); assert_ne!( before_labels, row_labels(&app), "the two pages prune the same rows, so this case proves nothing" ); assert_ne!( app.cursor_epoch(), epoch, "the grid renumbered and no cursor was invalidated" ); assert_eq!( app.cursor().selected, (0, 0), "the host's own cursor is stale" ); } /// The third arm, and the one `[`/`]` hits constantly: odometer paging emits a /// `SetPageSelection` per page category per keypress, most of them no-ops. #[test] fn selecting_the_page_already_selected_invalidates_no_cursor() { let mut app = paged_model(true); // Both preconditions, asserted BEFORE the effect: the effect itself sets // the page to Q1, so a check after it cannot see a fixture elsewhere. assert!( app.view().prune_empty, "with pruning off the reset never fires whatever `moved` says, so \ this case would pass against the guard it exists to hold" ); assert_eq!( app.view().page_selection("Quarter"), Some("Q1"), "the fixture was not on Q1, so re-selecting it is not the no-op case" ); let before_labels = row_labels(&app); app.cursor_mut().selected = (1, 0); let epoch = app.cursor_epoch(); app.apply_effects(vec![Box::new(super::super::effect::SetPageSelection { view: ViewTarget::Active, category: "Quarter".to_string(), item: "Q1".to_string(), })]); assert_eq!( before_labels, row_labels(&app), "re-selecting the same page renumbered the grid, so this proves nothing" ); assert_eq!( app.cursor_epoch(), epoch, "a page that did not move threw every client's cursor away, and \ odometer paging emits one of these per page category per keypress" ); assert_eq!(app.cursor().selected, (1, 0)); } /// The other arm: with pruning off the page names the same rows, so paging /// must not throw the user's position away on an ordinary `[`/`]`. #[test] fn a_page_flip_without_prune_empty_leaves_the_cursor_alone() { let mut app = paged_model(false); let before_labels = row_labels(&app); let epoch = app.cursor_epoch(); app.cursor_mut().selected = (1, 0); flip_to_q2(&mut app); assert_eq!( before_labels, row_labels(&app), "the page flip renumbered the grid, so this is not the no-reshape arm" ); assert_eq!( app.cursor_epoch(), epoch, "paging with nothing pruned threw a cursor away" ); assert_eq!(app.cursor().selected, (1, 0)); } /// improvise-h074: the fallback indexes `views` by the document's default, and /// a bare `IndexMap` panic names no invariant where `active_view()` did. #[test] #[should_panic(expected = "active_view always names an existing view")] fn a_default_that_names_nothing_says_which_invariant_broke() { let mut app = two_col_model(); // Only a hand-built or foreign-deserialized workbook can be here: // `create_view`/`delete_view` both maintain the invariant. app.model_state.workbook.active_view = "NoSuchView".to_string(); let _ = app.resolve_view("AlsoNoSuchView"); } /// improvise-8uz: moving the cursor must leave the shared document untouched, /// so one client scrolling cannot move another's viewport. #[test] fn moving_the_cursor_does_not_touch_the_shared_workbook() { let mut app = two_col_model(); let before = crate::persistence::format_md(&app.model_state.workbook); app.apply_effects(vec![ Box::new(crate::ui::effect::SetSelected(2, 1)), Box::new(crate::ui::effect::SetRowOffset(3)), Box::new(crate::ui::effect::SetColOffset(4)), ]); assert_eq!(app.cursor().selected, (2, 1), "the cursor did move"); assert_eq!( crate::persistence::format_md(&app.model_state.workbook), before, "but the shared document is byte-identical" ); } /// improvise-h0b: both session-local fields fail both membership tests — /// not portable and not snapshot-safe. #[test] fn app_owns_the_session_local_fields_not_view_state() { let app = App::new(Workbook::new("T"), None); let _: &Option = &app.wizard; let _: &Option = &app.drill_state; } /// improvise-h0b: a fresh client must not inherit another session's staged /// drill edits, which are unreachable from `ViewState`. #[test] fn staged_drill_edits_are_unreachable_from_view_state() { let mut app = App::new(Workbook::new("T"), None); app.drill_state = Some(DrillState { records: Rc::new(Vec::new()), pending_edits: std::collections::HashMap::from([( (0, crate::view::RecordCol::Measure("Revenue".into())), "999".into(), )]), }); let snapshot = format!("{:?}", app.view_state); assert!( !snapshot.contains("999"), "ViewState must not expose staged drill edits: {snapshot}" ); } fn two_col_model() -> App { let mut wb = Workbook::new("T"); wb.add_category("Row").unwrap(); // → Row axis wb.add_category("Col").unwrap(); // → Column axis wb.add_item("Row", "A").unwrap(); wb.add_item("Row", "B").unwrap(); wb.add_item("Row", "C").unwrap(); wb.add_item("Col", "X").unwrap(); wb.add_item("Col", "Y").unwrap(); App::new(wb, None) } /// improvise-felo.13: context/test-evidence/layout-cost.md. #[test] fn a_cursor_key_rebuilds_neither_the_layout_nor_the_widths() { let mut app = two_col_model(); let down = || KeyEvent::new(KeyCode::Down, KeyModifiers::NONE); app.dispatch_key(down()); let (builds, widths) = (app.layout_builds, app.width_passes.get()); app.dispatch_key(down()); app.dispatch_key(down()); assert_eq!(app.cursor().selected.0, 2, "the keys moved the cursor"); assert_eq!(app.layout_builds, builds, "layout rebuilt on a cursor key"); assert_eq!(app.width_passes.get(), widths, "widths remeasured"); } /// improvise-felo.13: the control for the test above, and the guard on /// invalidation: an edit must rebuild, and the widths must follow the value. #[test] fn an_edit_rebuilds_and_remeasures_so_the_widths_follow_the_value() { use crate::ui::effect::SetCell; let mut app = two_col_model(); let down = || KeyEvent::new(KeyCode::Down, KeyModifiers::NONE); app.dispatch_key(down()); let narrow = app.grid_geometry().summary.col_widths.clone(); let (builds, widths) = (app.layout_builds, app.width_passes.get()); let key = CellKey::new(vec![("Row".into(), "A".into()), ("Col".into(), "X".into())]); let long = CellValue::Text("a value far wider than a column".into()); app.apply_effects(vec![Box::new(SetCell(key, long))]); assert!( app.layout_builds > builds, "an edit must rebuild the layout" ); app.dispatch_key(down()); assert!(app.width_passes.get() > widths, "an edit must remeasure"); let model = &app.model_state.workbook.model; let fresh = crate::ui::viewport::compute_col_widths( model, &app.layout, &FormatPlan::of(model, app.view()), ); assert_eq!( app.grid_geometry().summary.col_widths, fresh, "stale widths" ); assert_ne!( fresh, narrow, "the edit widened a column, or this proves nothing" ); } /// improvise-felo.13: a `ViewOnly` effect that changes which view is drawn /// must still rebuild, so skipping the rebuild is not keyed on `EffectKind` alone. #[test] fn switching_view_rebuilds_though_the_effect_is_view_only() { let mut app = two_col_model(); app.model_state.workbook.create_view("Second"); app.rebuild_layout(); let builds = app.layout_builds; app.apply_effects(vec![Box::new(crate::ui::effect::SwitchView( "Second".into(), ))]); assert_eq!(app.view_state.active_view.as_str(), "Second"); assert!(app.layout_builds > builds, "the layout still draws Default"); } /// improvise-htl6. Evidence: `context/test-evidence/command.md`. #[test] fn the_view_history_walks_both_ways_from_the_keyboard() { let mut app = two_col_model(); app.model_state.workbook.create_view("Second"); app.apply_effects(vec![Box::new(crate::ui::effect::SwitchView( "Second".into(), ))]); assert_eq!(app.view_state.active_view.as_str(), "Second"); app.handle_key(KeyEvent::new(KeyCode::Char('<'), KeyModifiers::NONE)) .unwrap(); assert_eq!( app.view_state.active_view.as_str(), "Default", "the control: `<` was bound all along, so a table binding nothing \ cannot pass this test" ); app.handle_key(KeyEvent::new(KeyCode::Char(','), KeyModifiers::NONE)) .unwrap(); assert_eq!( app.view_state.active_view.as_str(), "Second", "`,` walks the history forward: before improvise-htl6 no key did, \ and the forward stack was reachable only as `:view-forward`" ); } /// improvise-8uz: the cursor is **per view**. A characterization test kept /// verbatim across the move to `ViewState::cursors`. #[test] fn switching_views_restores_each_views_own_cursor() { let mut app = two_col_model(); app.model_state.workbook.create_view("Second"); app.switch_to_view("Default"); app.cursor_mut().selected = (2, 1); app.cursor_mut().row_offset = 1; app.switch_to_view("Second"); assert_eq!( app.cursor().selected, (0, 0), "a different view starts at its own cursor, not the previous view's" ); app.switch_to_view("Default"); assert_eq!(app.cursor().selected, (2, 1)); assert_eq!(app.cursor().row_offset, 1); } /// improvise-8uz: the cursor was never persisted in `.improv`, which is what /// made the move free. This guards the format going forward. #[test] fn the_improv_format_does_not_persist_the_cursor() { let mut app = two_col_model(); app.cursor_mut().selected = (2, 1); app.cursor_mut().row_offset = 1; app.cursor_mut().col_offset = 1; let text = crate::persistence::format_md(&app.model_state.workbook); assert!( !text.contains("selected") && !text.contains("offset"), "no cursor state should reach the file:\n{text}" ); crate::persistence::parse_md(&text).expect("round trip"); } fn run_cmd(app: &mut App, cmd: &dyn crate::command::cmd::Cmd) { let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE); let effects = cmd.execute(&ctx); drop(ctx); app.apply_effects(effects); } fn enter_advance_cmd(app: &App) -> crate::command::cmd::navigation::EnterAdvance { use crate::command::cmd::navigation::CursorState; let cursor = CursorState { row: app.cursor().selected.0, col: app.cursor().selected.1, row_count: 3, col_count: 2, row_offset: 0, col_offset: 0, visible_rows: 20, visible_cols: 8, }; crate::command::cmd::navigation::EnterAdvance { cursor } } #[test] fn enter_advance_moves_down_within_column() { let mut app = two_col_model(); app.cursor_mut().selected = (0, 0); let cmd = enter_advance_cmd(&app); run_cmd(&mut app, &cmd); assert_eq!(app.cursor().selected, (1, 0)); } #[test] fn enter_advance_wraps_to_top_of_next_column() { let mut app = two_col_model(); // row_max = 2 (A,B,C), col 0 → should wrap to (0, 1) app.cursor_mut().selected = (2, 0); let cmd = enter_advance_cmd(&app); run_cmd(&mut app, &cmd); assert_eq!(app.cursor().selected, (0, 1)); } #[test] fn enter_advance_stays_at_bottom_right() { let mut app = two_col_model(); app.cursor_mut().selected = (2, 1); let cmd = enter_advance_cmd(&app); run_cmd(&mut app, &cmd); assert_eq!(app.cursor().selected, (2, 1)); } #[test] fn import_command_switches_to_import_wizard_mode() { // Regression: execute_command was resetting mode to Normal after // :import set it to ImportWizard, so the wizard never appeared. let mut app = two_col_model(); let json: serde_json::Value = serde_json::json!([{"cat": "A", "val": 1}]); app.start_import_wizard(json); assert!( matches!(app.view_state.mode, AppMode::ImportWizard), "mode should be ImportWizard after start_import_wizard" ); } #[test] fn execute_import_command_leaves_mode_as_import_wizard() { let mut app = two_col_model(); // Inject JSON via start_import_wizard to simulate what :import does app.start_import_wizard(serde_json::json!([{"x": 1}])); // After the command the mode must NOT be reset to Normal assert!( !matches!(app.view_state.mode, AppMode::Normal), "mode must not be Normal after import wizard is opened" ); } #[test] fn command_mode_typing_appends_to_buffer() { use crate::command::key::KeyEvent; let mut app = two_col_model(); // Enter command mode with ':' app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!(matches!(app.view_state.mode, AppMode::CommandMode { .. })); assert_eq!( app.view_state.buffers.get("command").map(|s| s.as_str()), Some("") ); // Type 'q' app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)) .unwrap(); assert_eq!( app.view_state.buffers.get("command").map(|s| s.as_str()), Some("q") ); } #[test] fn col_offset_scrolls_when_cursor_moves_past_visible_columns() { use crate::model::cell::{CellKey, CellValue}; // Eight 30-char columns in an 80-wide terminal: only ~2 fit, but the old // rough estimate (80-30)/12 = 4 over-counts and never scrolls. let mut wb = Workbook::new("T"); wb.add_category("Row").unwrap(); wb.add_category("Col").unwrap(); wb.add_item("Row", "R1").unwrap(); for i in 0..8 { let name = format!("VeryLongColumnItemName_{i:03}"); wb.add_item("Col", &name).unwrap(); } // Populate a value so the workbook isn't empty let key = CellKey::new(vec![ ("Row".to_string(), "R1".to_string()), ("Col".to_string(), "VeryLongColumnItemName_000".to_string()), ]); wb.model.set_cell(key, CellValue::Number(1.0)); let mut app = App::new(wb, None); app.term_width = 80; // Move to column 3, well off-screen at ~2 visible columns. The buggy // estimate thought 4 fit, so it would not scroll until column 4. for _ in 0..3 { app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)) .unwrap(); } assert_eq!(app.cursor().selected.1, 3, "cursor should be at column 3"); assert!( app.cursor().col_offset > 0, "col_offset should scroll when cursor moves past visible area (only ~2 cols fit \ in 80-char terminal with 26-char-wide columns), but col_offset is {}", app.cursor().col_offset ); } #[test] fn home_jumps_to_first_col() { let mut app = two_col_model(); app.cursor_mut().selected = (1, 1); app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)) .unwrap(); assert_eq!(app.cursor().selected, (1, 0)); } #[test] fn end_jumps_to_last_col() { let mut app = two_col_model(); app.cursor_mut().selected = (1, 0); app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)) .unwrap(); assert_eq!(app.cursor().selected, (1, 1)); } #[test] fn page_down_scrolls_by_three_quarters_visible() { let mut app = two_col_model(); // Add enough rows for i in 0..30 { app.model_state .workbook .model .category_mut("Row") .unwrap() .add_item(format!("R{i}")); } app.rebuild_layout(); app.term_height = 28; // ~20 visible rows → delta = 15 app.cursor_mut().selected = (0, 0); app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)) .unwrap(); assert_eq!(app.cursor().selected.1, 0, "column preserved"); assert!( app.cursor().selected.0 > 0, "row should advance on PageDown" ); // 3/4 of ~20 = 15 assert_eq!(app.cursor().selected.0, 15); } #[test] fn page_up_scrolls_backward() { let mut app = two_col_model(); for i in 0..30 { app.model_state .workbook .model .category_mut("Row") .unwrap() .add_item(format!("R{i}")); } app.rebuild_layout(); app.term_height = 28; app.cursor_mut().selected = (20, 0); app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)) .unwrap(); assert_eq!(app.cursor().selected.0, 5); } #[test] fn jump_last_row_scrolls_with_small_terminal() { let mut app = two_col_model(); // Total rows: A, B, C + R0..R9 = 13 rows. Last row = 12. for i in 0..10 { app.model_state .workbook .model .category_mut("Row") .unwrap() .add_item(format!("R{i}")); } app.rebuild_layout(); app.term_height = 13; // ~5 visible rows app.cursor_mut().selected = (0, 0); // G jumps to last row (row 12) app.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE)) .unwrap(); let last = app.cursor().selected.0; assert_eq!(last, 12, "should be at last row"); // With only ~5 visible rows and 13 rows, offset should scroll. // Bug: hardcoded 20 means `12 >= 0 + 20` is false → no scroll. let offset = app.cursor().row_offset; assert!( offset > 0, "row_offset should scroll when last row is beyond visible area, but is {offset}" ); } #[test] fn ctrl_d_scrolls_viewport_with_small_terminal() { let mut app = two_col_model(); for i in 0..30 { app.model_state .workbook .model .category_mut("Row") .unwrap() .add_item(format!("R{i}")); } app.rebuild_layout(); app.term_height = 13; // ~5 visible rows app.cursor_mut().selected = (0, 0); // Ctrl+d scrolls by 5 rows app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)) .unwrap(); assert_eq!(app.cursor().selected.0, 5); // Press Ctrl+d again — now at row 10 with only 5 visible rows, // row_offset should have scrolled (not stay at 0 due to hardcoded 20) app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)) .unwrap(); assert_eq!(app.cursor().selected.0, 10); assert!( app.cursor().row_offset > 0, "row_offset should scroll with small terminal, but is {}", app.cursor().row_offset ); } #[test] fn tab_in_edit_mode_commits_and_moves_right() { let mut app = two_col_model(); app.cursor_mut().selected = (0, 0); // Enter edit mode app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); assert!(matches!(app.view_state.mode, AppMode::Editing { .. })); // Type a digit app.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE)) .unwrap(); // Press Tab — should commit, move right, re-enter edit mode app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) .unwrap(); // Should be in edit mode on column 1 assert!( matches!(app.view_state.mode, AppMode::Editing { .. }), "should be in edit mode after Tab, but mode is {:?}", app.view_state.mode ); assert_eq!(app.cursor().selected.1, 1, "should have moved to column 1"); } /// Pressing R to enter records mode sorts existing data by CellKey, so /// display order is deterministic regardless of insertion order. #[test] fn entering_records_mode_sorts_existing_data() { use crate::model::cell::{CellKey, CellValue}; let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_item("Region", "North").unwrap(); wb.add_item("Region", "East").unwrap(); // Insert in reverse-alphabetical order wb.model.set_cell( CellKey::new(vec![("Region".into(), "North".into())]), CellValue::Number(1.0), ); wb.model.set_cell( CellKey::new(vec![("Region".into(), "East".into())]), CellValue::Number(2.0), ); let mut app = App::new(wb, None); app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); assert!(app.layout.is_records_mode()); let region_col = (0..app.layout.col_count()) .find(|&c| app.layout.col_label(c) == "Region") .unwrap(); let row0 = app.layout.records_display(0, region_col).unwrap(); let row1 = app.layout.records_display(1, region_col).unwrap(); assert_eq!( row0, "East", "R should sort existing data: first row should be East" ); assert_eq!( row1, "North", "R should sort existing data: second row should be North" ); } /// 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.add_item("Region", "East").unwrap(); 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!( app.view_state.mode.is_editing(), "o should leave the app in edit mode, got {:?}", app.view_state.mode ); } /// improvise-2zq step 2: `+cat` creates a category and its column without /// leaving the grid, and the new column is usable immediately. #[test] fn the_new_category_column_creates_a_category_and_its_column() { let mut app = records_model_with_two_rows(); let plus_cat = (0..app.layout.col_count()) .find(|i| app.layout.col_label(*i) == crate::view::NEW_CATEGORY_COL) .expect("the records grid offers a +cat column"); app.cursor_mut().selected = (0, plus_cat); app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); for c in "Product".chars() { app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) .unwrap(); } app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); app.rebuild_layout(); assert!( app.model_state.workbook.model.category("Product").is_some(), "the category is created" ); assert!( app.layout.is_records_mode(), "and creating it does not eject the user (step 1)" ); let cols: Vec = (0..app.layout.col_count()) .map(|i| app.layout.col_label(i)) .collect(); assert_eq!( cols, vec![ "Region", "Product", crate::view::NEW_CATEGORY_COL, "meas1", "meas2", crate::view::NEW_MEASURE_COL ], "the new coordinate column appears immediately, before the \ still-available +cat, got {cols:?}" ); } /// improvise-2zq (Copilot, PR #27): `+cat` shifted the grid under the advance /// that followed, **reopening** category creation. See the evidence. #[test] fn enter_on_plus_cat_in_the_last_row_does_not_reopen_category_creation() { let mut app = records_model_with_two_rows(); let plus_cat = (0..app.layout.col_count()) .find(|i| app.layout.col_label(*i) == crate::view::NEW_CATEGORY_COL) .expect("+cat column"); let last_row = app.layout.row_count() - 1; app.cursor_mut().selected = (last_row, plus_cat); app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); for c in "Product".chars() { app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) .unwrap(); } app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); let (_, col) = app.cursor().selected; let landed = app.layout.col_label(col); assert_ne!( landed, crate::view::NEW_CATEGORY_COL, "the advance must not land back on +cat — that is the loop, and \ it is what a stale column index produces" ); assert_eq!( landed, "Product", "and it lands on the column just created, which is the useful \ answer rather than merely a safe one: the insert happens at the \ cursor, so staying put arrives there" ); } /// The same stale index reached `Tab`, which advances right rather than /// wrapping. One arm of a two-arm rule is evidence about that arm only. #[test] fn tab_on_plus_cat_does_not_reopen_category_creation() { let mut app = records_model_with_two_rows(); let plus_cat = (0..app.layout.col_count()) .find(|i| app.layout.col_label(*i) == crate::view::NEW_CATEGORY_COL) .expect("+cat column"); app.cursor_mut().selected = (0, plus_cat); app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); for c in "Product".chars() { app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) .unwrap(); } app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) .unwrap(); let (_, col) = app.cursor().selected; assert_ne!( app.layout.col_label(col), crate::view::NEW_CATEGORY_COL, "Tab must not land back on +cat either" ); assert_eq!(app.layout.col_label(col), "Product"); } /// Committing the `+cat` name creates the **column** and writes no coordinate /// on the cursor's row: that would move every measure cell (gotcha 1). #[test] fn the_new_category_column_creates_the_column_but_writes_no_coordinate() { let mut app = records_model_with_two_rows(); let plus_cat = (0..app.layout.col_count()) .find(|i| app.layout.col_label(*i) == crate::view::NEW_CATEGORY_COL) .expect("+cat column"); app.cursor_mut().selected = (0, plus_cat); app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); for c in "Product".chars() { app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) .unwrap(); } app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); app.rebuild_layout(); let product_col = (0..app.layout.col_count()) .find(|i| app.layout.col_label(*i) == "Product") .expect("the new column exists"); for row in 0..app.layout.row_count() { assert_eq!( app.layout.records_display(row, product_col).as_deref(), Some(""), "row {row} must have no coordinate for a category just created" ); } } /// `MAX_CATEGORIES` is a reachable refusal on this path, and the refusal has /// to be **visible** — the minibuffer hides the status line. #[test] fn the_new_category_column_refuses_past_the_limit_where_the_user_can_see_it() { let mut app = records_model_with_two_rows(); let mut n = 0; while app .model_state .workbook .add_category(format!("Filler{n}")) .is_ok() { n += 1; assert!(n < 100, "add_category must refuse eventually"); } app.rebuild_layout(); app.view_state.status_msg.clear(); let plus_cat = (0..app.layout.col_count()) .find(|i| app.layout.col_label(*i) == crate::view::NEW_CATEGORY_COL) .expect("+cat column"); app.cursor_mut().selected = (0, plus_cat); app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); for c in "TooMany".chars() { app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) .unwrap(); } app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); assert!( app.model_state.workbook.model.category("TooMany").is_none(), "the category is refused" ); assert!( !app.view_state.status_msg.is_empty(), "the refusal is reported" ); assert!( app.view_state.mode.minibuffer().is_none(), "and reported where it is drawn: a mode with a minibuffer hides \ the status line, so the user would see nothing at all. mode is \ {:?}, message {:?}", app.view_state.mode, app.view_state.status_msg ); } /// improvise-2zq step 1: naming a category in records mode used to bump /// `_Index` off Row and silently turn the grid into a pivot. #[test] fn naming_a_category_in_records_mode_does_not_eject_the_user() { let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_item("Region", "East").unwrap(); let mut app = App::new(wb, None); app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); assert!(app.layout.is_records_mode(), "setup: in records mode"); app.handle_key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::NONE)) .unwrap(); for c in "Product".chars() { app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) .unwrap(); } app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); app.rebuild_layout(); assert!( app.model_state.workbook.model.category("Product").is_some(), "setup: the category really was created" ); assert!( app.layout.is_records_mode(), "naming a category must not leave records mode; axes are {:?}", app.view().category_axes.iter().collect::>() ); assert_eq!( app.view().axis_of("Product"), crate::view::Axis::None, "records mode keeps every regular category on None, and \ `records_columns` picks it up as a column from `is_regular` \ alone — never from an axis" ); let cols: Vec = (0..app.layout.col_count()) .map(|i| app.layout.col_label(i)) .collect(); assert!( cols.iter().any(|c| c == "Product"), "the new category must appear as a records column immediately, \ got {cols:?}" ); } /// improvise-6sq/ct5: `i` and `o` want opposite behaviour on an empty records /// grid, which is why the two commands cannot be merged. #[test] fn i_and_o_diverge_on_an_empty_records_grid() { let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_item("Region", "East").unwrap(); let mut app = App::new(wb, None); app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); assert_eq!(app.layout.row_count(), 0, "setup: empty records grid"); app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); assert_eq!( app.view_state.mode, AppMode::RecordsNormal, "i must not enter editing when there is no cell to edit" ); assert!( !app.view_state.status_msg.to_lowercase().contains("drill"), "records mode must not talk about drilling, got status {:?}", app.view_state.status_msg ); assert!( app.view_state.status_msg.contains("press o to add a row"), "the message should name the key that does add a row, got {:?}", app.view_state.status_msg ); } /// The negative arm of improvise-ct5: without it, "say the records sentence /// everywhere" passes the test above and promises a key that does nothing. #[test] fn the_empty_grid_message_is_not_the_records_one_in_pivot_mode() { let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_category("Extra").unwrap(); wb.active_view_mut() .set_axis("Extra", crate::view::Axis::None); let mut app = App::new(wb, None); app.rebuild_layout(); assert_eq!(app.layout.row_count(), 0, "setup: empty pivot grid"); app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); assert_eq!( app.view_state.mode, AppMode::Normal, "i must not enter editing when there is no cell to edit" ); assert!( !app.view_state.status_msg.contains("add a row"), "`o` adds no row in pivot mode; it must not be offered, got {:?}", app.view_state.status_msg ); assert!( app.view_state.status_msg.contains("No cell at cursor"), "got status {:?}", app.view_state.status_msg ); } /// A blank model bootstraps its first measure from the `+` column, so records /// mode stays self-sufficient for onboarding (improvise-c5v). #[test] fn blank_model_bootstraps_its_first_measure_from_the_new_measure_column() { 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(); let cols: Vec = (0..app.layout.col_count()) .map(|i| app.layout.col_label(i)) .collect(); assert_eq!( cols, vec![crate::view::NEW_CATEGORY_COL, crate::view::NEW_MEASURE_COL], "a blank model offers exactly the two creation columns" ); // `o` seeds a row and opens an edit on it. app.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)) .unwrap(); assert_eq!(app.layout.row_count(), 1); // Column 0 of a blank grid is now `+cat`, not `+` (improvise-2zq), and `o` // left us editing it — so leave that edit before moving right. app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) .unwrap(); let new_measure_col = app.layout.col_count() - 1; app.cursor_mut().selected = (0, new_measure_col); app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); for c in "Rev".chars() { app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) .unwrap(); } app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); let cols: Vec = (0..app.layout.col_count()) .map(|i| app.layout.col_label(i)) .collect(); assert_eq!( cols, vec![ crate::view::NEW_CATEGORY_COL, "Rev", crate::view::NEW_MEASURE_COL ], "the named measure becomes a real column, between the two \ creation columns" ); // Typing into that column writes the value. let rev_col = 1; app.cursor_mut().selected = (0, rev_col); app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); app.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE)) .unwrap(); app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); assert_eq!( app.model_state.workbook.model.get_cell(&CellKey::new(vec![( "_Measure".to_string(), "Rev".to_string(), )])), Some(&crate::model::cell::CellValue::Number(5.0)), "the measure column writes straight to its cell" ); } /// A records app with two data rows for Tab/Enter boundary tests. In wide /// format a row is one coordinate tuple carrying both measures (improvise-c5v). fn records_model_with_two_rows() -> App { use crate::model::cell::{CellKey, CellValue}; let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_item("Region", "North").unwrap(); wb.add_item("Region", "South").unwrap(); wb.add_item("_Measure", "meas1").unwrap(); wb.add_item("_Measure", "meas2").unwrap(); for (region, meas, n) in [ ("North", "meas2", 10.0), ("North", "meas1", 20.0), ("South", "meas2", 30.0), ("South", "meas1", 40.0), ] { wb.model.set_cell( CellKey::new(vec![ ("Region".into(), region.into()), ("_Measure".into(), meas.into()), ]), CellValue::Number(n), ); } let mut app = App::new(wb, None); app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); assert!( app.layout.is_records_mode(), "setup: should be records mode" ); assert_eq!( app.layout.row_count(), 2, "setup: two coordinate tuples → two rows, measures side by side" ); let cols: Vec = (0..app.layout.col_count()) .map(|i| app.layout.col_label(i)) .collect(); assert_eq!( cols, vec![ "Region", crate::view::NEW_CATEGORY_COL, "meas1", "meas2", crate::view::NEW_MEASURE_COL ], "setup: category columns, the new-category column, one column \ per measure, then the new-measure column (improvise-2zq)" ); app } // ── Prefix keys (improvise-jc8) ───────────────────────────────────── /// Characterization: the three prefix keys must keep working across the /// transient-keymap refactor. `gg` jumps to the first row. #[test] fn prefix_g_then_g_jumps_to_first_row() { let mut app = records_model_with_two_rows(); app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); // back to pivot app.cursor_mut().selected = (1, 0); app.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)) .unwrap(); app.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)) .unwrap(); assert_eq!( app.cursor().selected.0, 0, "gg should jump to the first row" ); } /// improvise-lae end to end. The failing case pins an **ordering**: the mode /// change leads, or the error is drawn behind the minibuffer. #[test] fn ctrl_w_prompts_for_a_path_and_a_failed_write_reports_it_where_it_shows() { let mut app = App::new(Workbook::new("T"), None); app.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)) .unwrap(); assert!( matches!(app.view_state.mode, AppMode::SavePrompt { .. }), "Ctrl-W should open the save prompt, got {:?}", app.view_state.mode ); let path = "/nonexistent-dir-improvise-lae/m.improv"; for c in path.chars() { app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) .unwrap(); } assert_eq!( app.view_state.buffers.get("save").map(String::as_str), Some(path), "typing must reach the buffer the prompt names" ); app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); assert!( app.view_state.status_msg.starts_with("Save error:"), "got {:?}", app.view_state.status_msg ); assert_eq!( app.view_state.mode, AppMode::Normal, "the error must be reported in a mode whose bottom row is the \ status bar, not behind a minibuffer that hides it" ); assert!( app.view_state.mode.minibuffer().is_none(), "setup for the assertion above: only a mode with no minibuffer \ draws the status line at all" ); } /// Every minibuffer mode reachable from `RecordsNormal` returns there, by /// cancel and by commit alike. See the evidence. #[test] fn every_minibuffer_mode_entered_from_records_returns_to_records_normal() { let opens = [ KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE), KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), ]; // The exit is spelled in two places per mode — the `TextEntry` spine's // Esc and the commit command — so both are driven. let exits = [ KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ]; for (key, exit) in opens.into_iter().flat_map(|k| exits.map(|e| (k, e))) { let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_item("Region", "East").unwrap(); let mut app = App::new(wb, None); app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); assert_eq!( app.view_state.mode, AppMode::RecordsNormal, "setup: R enters records mode" ); app.handle_key(key).unwrap(); assert!( app.view_state.mode.minibuffer().is_some(), "setup: {:?} opens a minibuffer, got {:?}", key.code, app.view_state.mode ); app.handle_key(exit).unwrap(); assert!( app.layout.is_records_mode(), "setup: the layout is still a records layout, which is what \ makes plain Normal wrong rather than merely different" ); assert_eq!( app.view_state.mode, AppMode::RecordsNormal, "{:?} left by {:?} into plain Normal over a records layout \ (improvise-sihr)", key.code, exit.code ); } } /// `CloseWizard` writes the mode field directly rather than through an /// effect that resolves it. See the evidence. #[test] fn a_wizard_abandoned_over_a_records_grid_returns_to_records_normal() { let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_item("Region", "East").unwrap(); let mut app = App::new(wb, None); app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); app.view_state.mode = AppMode::ImportWizard; assert!(app.layout.is_records_mode(), "setup: the grid is records"); app.apply_effects(vec![Box::new(super::super::effect::CloseWizard)]); assert_eq!( app.view_state.mode, AppMode::RecordsNormal, "abandoning the wizard dropped to plain Normal over a records grid" ); } /// A batch that LEAVES records and then sets `Normal` must land in `Normal`. /// The crossing case, which the staleness gets wrong. See the evidence. #[test] fn a_batch_that_leaves_records_and_then_sets_normal_lands_in_normal() { for (start_in_records, expected) in [(true, AppMode::Normal), (false, AppMode::RecordsNormal)] { let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_item("Region", "East").unwrap(); let mut app = App::new(wb, None); if start_in_records { app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); } assert_eq!( app.layout.is_records_mode(), start_in_records, "setup: the batch has to CROSS, so it must start on the other side" ); // One batch that toggles the layout and then asks for `Normal`. app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); for c in "toggle-records-mode . enter-mode normal".chars() { app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) .unwrap(); } app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); assert_eq!( app.layout.is_records_mode(), !start_in_records, "setup: the toggle has to land, or nothing crossed" ); assert_eq!( app.view_state.mode, expected, "the batch ended on a {} grid, so the mode must match it \ (improvise-sihr's crossing case)", if start_in_records { "pivot" } else { "records" } ); } } /// improvise-y5eo: a document that opens on a records grid opens the host in /// `RecordsNormal`. See `context/test-evidence/command.md`. #[test] fn a_document_opening_on_a_records_view_opens_the_host_in_records_normal() { use crate::view::Axis; let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_item("Region", "East").unwrap(); wb.active_view_mut().set_axis("_Index", Axis::Row); wb.active_view_mut().set_axis("_Dim", Axis::Column); let mut app = App::new(wb, None); assert!(app.layout.is_records_mode(), "setup: the grid is records"); assert_eq!(app.view_state.mode, AppMode::RecordsNormal); app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); assert!( app.view_state.mode.is_records(), "the host's first `i` left records mode: {:?}", app.view_state.mode ); } /// The settle's `RecordsNormal` arm, driven from the mode it names. The /// `:` crossing above enters the settle in `Normal` both ways — the evidence. #[test] fn a_batch_that_leaves_records_settles_out_of_records_normal() { use crate::view::Axis; let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_item("Region", "East").unwrap(); let mut app = App::new(wb, None); app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); assert!(app.layout.is_records_mode(), "setup: the grid is records"); assert_eq!( app.view_state.mode, AppMode::RecordsNormal, "setup: the mode handed to the settle must be RecordsNormal" ); app.apply_effects(vec![Box::new(super::super::effect::SetAxis { view: ViewTarget::Active, category: "_Index".into(), axis: Axis::None, })]); assert!( !app.layout.is_records_mode(), "setup: the batch has to leave records, or nothing crossed" ); assert_eq!( app.view_state.mode, AppMode::Normal, "a batch that left records stayed in RecordsNormal (improvise-4dpj)" ); } /// The batch `SwitchViewAtCursor` emits, both ways across records-ness: /// records-ness is auto-detected from the axes, so a view switch moves it. #[test] fn switching_views_across_records_ness_settles_against_the_view_switched_to() { for to_records in [false, true] { let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_item("Region", "East").unwrap(); let mut app = App::new(wb, None); // `R` creates and enters `_Records`; the pivot view it left is the // other end of every crossing below. app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); let records_view = app.view_state.active_view.clone(); let (from, to) = match to_records { true => ("Default", records_view.as_str()), false => (records_view.as_str(), "Default"), }; app.apply_effects(vec![Box::new(super::super::effect::SwitchView( from.into(), ))]); assert_eq!( app.layout.is_records_mode(), !to_records, "setup: the switch has to CROSS, so it must start on the other side" ); app.apply_effects(vec![ Box::new(super::super::effect::SwitchView(to.into())), super::super::effect::change_mode(AppMode::Normal), ]); assert_eq!( app.layout.is_records_mode(), to_records, "setup: the switch has to land, or nothing crossed" ); let expected = match to_records { true => AppMode::RecordsNormal, false => AppMode::Normal, }; assert_eq!( app.view_state.mode, expected, "the batch settled against the view it LEFT, not the one it \ switched to (improvise-4dpj)" ); } } /// A `:` line that really runs settles through `ReturnToNormalUnlessMoved`, /// which an empty line never reaches. See the evidence. #[test] fn a_colon_line_run_from_records_settles_back_into_records_normal() { let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_item("Region", "East").unwrap(); 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(':'), KeyModifiers::NONE)) .unwrap(); const LINE: &str = "page-next"; for c in LINE.chars() { app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) .unwrap(); } app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); let registry = crate::command::cmd::registry::default_registry(); assert!( crate::command::parse::parse_line_with(®istry, LINE).is_ok(), "setup: an unparseable line settles through ChangeMode instead, \ which is the arm the sibling test already covers" ); assert!( app.layout.is_records_mode(), "setup: the line ran over a records layout" ); assert_eq!( app.view_state.mode, AppMode::RecordsNormal, "a run `:` line settled into plain Normal (improvise-sihr)" ); } /// A failing `:` command must not strand the user in `CommandMode`: tidy-up /// goes at the head, where an abort cannot reach it (gotcha 21). #[test] fn a_failed_colon_write_lands_in_normal_where_its_error_is_drawn() { for (line, expect) in [ ("w", "No file path"), ("w /nonexistent-dir-improvise-lae/m.improv", "Save error:"), ] { let mut app = App::new(Workbook::new("T"), None); app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); for c in line.chars() { app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) .unwrap(); } app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); assert!( app.view_state.status_msg.contains(expect), ":{line} should report {expect:?}, got {:?}", app.view_state.status_msg ); assert_eq!( app.view_state.mode, AppMode::Normal, ":{line} must leave the command line, or the status it just \ set is hidden behind the minibuffer" ); } } /// The other half of the same rule: the abort must still stop the *chain*. /// `:w . q` is improvise-8tk's data loss on one `:` line. #[test] fn a_failed_colon_write_still_refuses_to_quit_after_it() { let mut app = App::new(Workbook::new("T"), None); app.view_state.buffers.insert( "command".to_string(), "w /nonexistent-dir-improvise-lae/m.improv . q".to_string(), ); app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); // `:` clears the buffer on entry (gotcha 11), so type it instead. for c in "w /nonexistent-dir-improvise-lae/m.improv . q".chars() { app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) .unwrap(); } app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); assert_ne!( app.view_state.mode, AppMode::Quit, "a failed write must still abort the rest of the `:` line" ); assert!( app.view_state.status_msg.starts_with("Save error:"), "got {:?}", app.view_state.status_msg ); } /// `ZZ` is save-and-quit. With no file path the save fails and aborts the /// chain (improvise-8tk), which also proves the second Z reached `wq`. #[test] fn prefix_z_then_z_reaches_save_and_quit() { let mut app = App::new(Workbook::new("T"), None); app.handle_key(KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::NONE)) .unwrap(); app.handle_key(KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::NONE)) .unwrap(); assert!( app.view_state.status_msg.contains("No file path"), "ZZ should have run wq; got {:?}", app.view_state.status_msg ); assert_ne!( app.view_state.mode, AppMode::Quit, "failed save must not quit" ); } /// Gotcha 21's cross-`Sequence` claim, which was structurally true and /// unguarded. improvise-0hb would remove it — see the evidence. #[test] fn a_sequence_whose_first_step_aborts_does_not_run_the_second() { use improvise_protocol::keymap::Keymap; // No file path, so `w` fails and sets `abort_effects`. let mut app = App::new(Workbook::new("T"), None); let key = KeyCode::Char('x'); let mods = KeyModifiers::NONE; let mut km = Keymap::new(); km.bind_seq(key, mods, vec![("save", vec![]), ("force-quit", vec![])]); let effects = { let ctx = app.cmd_context(key, mods); app.keymap_set.dispatch_transient(&km, &ctx, key, mods) } .expect("both steps resolve in the registry"); app.apply_effects(effects); assert!( app.view_state.status_msg.contains("No file path"), "setup: the save must actually have failed, got {:?}", app.view_state.status_msg ); assert_ne!( app.view_state.mode, AppMode::Quit, "the abort must carry into the next Sequence step, not just the \ next effect of the same command" ); } /// improvise-cz8d: a refusal need not abort, so a failed document swap must /// ask for the abort itself. Evidence: `context/test-evidence/command.md`. #[test] fn a_failed_load_or_import_does_not_run_the_quit_after_it() { use improvise_protocol::keymap::Keymap; // One case per `refuse_and_stop` site (improvise-514o). let cases: [(&str, &[&str], &str); 7] = [ ("load", &["absent.improv"], "Load error"), ("import-json", &["absent.json"], "Cannot read 'absent.json'"), ("import-json", &["absent.csv"], "Cannot read 'absent.csv'"), ("import-json", &["bad.csv"], "CSV error"), ("import-json", &["bad.json"], "JSON parse error"), ( "import-json", &["nested.json", "M", "nope"], "No array at path", ), ("import-json", &["measures.json"], "Import error"), ]; for (command, args, arm) in cases { let path = args.join(" "); let mut app = App::new(Workbook::new("T"), None); app.storage = Box::new(crate::ui::host::import_fixture_storage()); let key = KeyCode::Char('x'); let mods = KeyModifiers::NONE; let mut km = Keymap::new(); km.bind_seq( key, mods, vec![ (command, args.iter().map(|a| a.to_string()).collect()), ("force-quit", vec![]), ], ); let effects = { let ctx = app.cmd_context(key, mods); app.keymap_set.dispatch_transient(&km, &ctx, key, mods) } .expect("both steps resolve in the registry"); app.apply_effects(effects); assert!( app.last_batch_refused() && app.view_state.status_msg.contains(arm), "setup: `{command} {path}` must fail in the {arm:?} arm, got {:?}", app.view_state.status_msg ); assert_ne!( app.view_state.mode, AppMode::Quit, "`{command} {path} . q` quit having loaded nothing" ); } } /// improvise-v4hg: Enter on a formula that does not parse threw the typed text /// away. Evidence: `context/test-evidence/command.md`. #[test] fn a_formula_that_does_not_parse_keeps_the_user_in_the_editor_with_their_text() { let type_and_commit = |text: &str| { let mut app = two_col_model(); app.view_state.mode = AppMode::FormulaPanel; let press = |app: &mut App, code| { app.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) .unwrap() }; press(&mut app, KeyCode::Char('a')); assert!( matches!(app.view_state.mode, AppMode::FormulaEdit { .. }), "setup: `a` in the formula panel opens the editor" ); for c in text.chars() { press(&mut app, KeyCode::Char(c)); } press(&mut app, KeyCode::Enter); app }; // The control: a formula that parses is stored and the editor closes. let ok = type_and_commit("Total = 1"); assert_eq!(ok.view_state.mode, AppMode::FormulaPanel); assert!( ok.model_state .workbook .model .formulas() .iter() .any(|f| f.target == "Total"), "the control formula must land" ); let bad = type_and_commit("Total = ("); assert!( bad.last_batch_refused() && bad.view_state.status_msg.contains("Formula error"), "the parse error must be reported, got {:?}", bad.view_state.status_msg ); assert!( matches!(bad.view_state.mode, AppMode::FormulaEdit { .. }), "a refused formula must leave the user in the editor, got {:?}", bad.view_state.mode ); assert_eq!( bad.view_state.buffers.get("formula").map(String::as_str), Some("Total = ("), "and must keep what they typed" ); } /// Run a `:` line the way `improvise cmd` does, one batch per segment; true /// when any segment refused, which is what its exit code reads. fn run_line(app: &mut App, line: &str) -> bool { let mut refused = false; for segment in crate::command::parse::segments(line) { let effects = { let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE); crate::command::parse::parse_segment_interactive(segment, &ctx) .unwrap_or_else(|e| panic!("`{segment}` must parse: {e}")) .map(|cmd| cmd.execute(&ctx)) .unwrap_or_default() }; app.apply_effects(effects); refused |= app.last_batch_refused(); } refused } /// Commit `text` in a minibuffer mode with Enter; true when that batch refused. fn commit_in(app: &mut App, mode: AppMode, buffer: &str, text: &str) -> bool { app.view_state.mode = mode; app.view_state .buffers .insert(buffer.to_string(), text.to_string()); app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); app.last_batch_refused() } fn with_import_fixtures(mut app: App) -> App { app.storage = Box::new(crate::ui::host::import_fixture_storage()); app } /// Eleven regular categories: one short of the limit. fn one_category_short_of_the_limit() -> App { let mut wb = Workbook::new("T"); for n in 0..crate::model::MAX_CATEGORIES - 1 { wb.add_category(format!("C{n}")).unwrap(); } App::new(wb, None) } /// Stage one coordinate rename in a drill over the records fixture, then commit it. fn commit_drill_rename(app: &mut App, to: &str) -> bool { let records = app.layout.records.clone().expect("records mode"); let mut pending_edits = HashMap::new(); pending_edits.insert( (1, crate::view::RecordCol::Category("Region".into())), to.into(), ); app.drill_state = Some(DrillState { records, pending_edits, }); app.apply_effects(vec![Box::new(crate::ui::effect::ApplyAndClearDrill)]); app.last_batch_refused() } /// Records mode with `Region` paged to `page`: `add-record-row` then names /// the row whose coordinates are exactly that page. fn records_paged_to(app: &mut App, page: &str) { app.model_state.workbook.add_item("Region", "West").unwrap(); let view = app .view_mut_of(&ViewTarget::Active) .expect("the active view"); view.set_axis("Region", crate::view::Axis::Page); view.set_page_selection("Region", page); app.rebuild_layout(); } /// A grid with a regular category on None, so every cell is an aggregate. fn aggregated_grid(with_items: bool) -> App { let mut wb = Workbook::new("T"); for cat in ["Row", "Col", "Hidden"] { wb.add_category(cat).unwrap(); if with_items { wb.add_item(cat, "A").unwrap(); } } wb.active_view_mut() .set_axis("Hidden", crate::view::Axis::None); let mut app = App::new(wb, None); app.rebuild_layout(); app } /// `Region` down, `_Measure` across: `Revenue` at column 0, then `Total`, /// which a formula owns, at column 1. fn a_formula_column() -> App { let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_item("Region", "North").unwrap(); wb.add_item("_Measure", "Revenue").unwrap(); wb.active_view_mut() .set_axis("_Measure", crate::view::Axis::Column); let mut app = App::new(wb, None); app.apply_effects(vec![Box::new(crate::ui::effect::AddFormula { raw: "Total = Revenue * 2".into(), target_category: "_Measure".into(), })]); app } fn with_a_cell(mut app: App) -> App { app.model_state.workbook.model.set_cell( CellKey::new(vec![("Row".into(), "A".into()), ("Col".into(), "X".into())]), CellValue::Number(42.0), ); app.rebuild_layout(); app } /// One key through the keymap, as the TUI delivers it; true when it refused. fn press_key(app: &mut App, code: KeyCode) -> bool { app.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) .unwrap(); app.last_batch_refused() } fn panel_cursor_at(mut app: App, at: usize) -> App { app.view_state.cat_panel_cursor = at; app } /// Storage holding one loadable document, `doc.improv`, and nothing else. fn with_a_document_to_load() -> App { let storage = crate::storage::MemoryFs::new(); let text = crate::persistence::format_md(&Workbook::new("Doc")); crate::storage::Storage::write( &storage, std::path::Path::new("doc.improv"), text.as_bytes(), ) .expect("fixture writes"); let mut app = two_col_model(); app.storage = Box::new(storage); app } /// Months grouped into quarters with Q2 collapsed: row 2 is the fold. fn folded_quarter() -> App { let mut wb = Workbook::new("T"); wb.add_category("Month").unwrap(); wb.add_category("Type").unwrap(); for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { wb.add_item_in_group("Month", month, quarter).unwrap(); } wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]); let mut app = App::new(wb, None); app.rebuild_layout(); app } fn yanked(mut app: App) -> App { app.view_state.yanked = Some(CellValue::Number(5.5)); app } /// A drill frozen over the records the grid already shows. fn in_a_drill(mut app: App) -> App { let records = app.layout.records.clone().expect("records mode"); app.drill_state = Some(DrillState { records, pending_edits: HashMap::new(), }); app } fn with_a_second_view(mut app: App) -> App { app.model_state.workbook.create_view("Second"); app } fn switch_to_second(app: &mut App) { app.apply_effects(vec![Box::new(crate::ui::effect::SwitchView( "Second".into(), ))]); } /// improvise-0v8l: one case per refusal site no other test pinned, each with a /// succeeding twin. Evidence: `context/test-evidence/command.md`. #[test] fn every_refusal_site_refuses_its_failure_and_only_its_failure() { type Setup = fn() -> App; type Drive = fn(&mut App) -> bool; let limit = format!("Maximum of {} categories", crate::model::MAX_CATEGORIES); let cases: Vec<(&str, Setup, Drive, &str, Setup, Drive)> = vec![ ( "add-category past the limit", one_category_short_of_the_limit, |app| run_line(app, "add-category A . add-category B"), &limit, one_category_short_of_the_limit, |app| run_line(app, "add-category A"), ), ( "a drill commit that empties a coordinate", records_app_for_drill, |app| commit_drill_rename(app, ""), crate::ui::effect::RECORD_COORDS_CANNOT_BE_EMPTY, records_app_for_drill, |app| commit_drill_rename(app, "West"), ), ( "import of a JSON that does not parse", || with_import_fixtures(two_col_model()), |app| run_line(app, "import bad.json"), "JSON parse error", || with_import_fixtures(two_col_model()), |app| run_line(app, "import good.json"), ), ( "import of an absent file", || with_import_fixtures(two_col_model()), |app| run_line(app, "import absent.json"), "Cannot read file", || with_import_fixtures(two_col_model()), |app| run_line(app, "import good.json"), ), ( "import-json of an absent CSV", || with_import_fixtures(two_col_model()), |app| run_line(app, "import-json absent.csv"), "Cannot read 'absent.csv'", || with_import_fixtures(two_col_model()), |app| run_line(app, "import-json good.json"), ), ( "import-json of a CSV that is not text", || with_import_fixtures(two_col_model()), |app| run_line(app, "import-json bad.csv"), "CSV error", || with_import_fixtures(two_col_model()), |app| run_line(app, "import-json good.json"), ), ( "import-json of JSON that does not parse", || with_import_fixtures(two_col_model()), |app| run_line(app, "import-json bad.json"), "JSON parse error", || with_import_fixtures(two_col_model()), |app| run_line(app, "import-json good.json"), ), ( "import-json at a path with no array", || with_import_fixtures(two_col_model()), |app| run_line(app, "import-json nested.json M nope"), "No array at path", || with_import_fixtures(two_col_model()), |app| run_line(app, "import-json good.json"), ), ( "import-json that builds no model", || with_import_fixtures(two_col_model()), |app| run_line(app, "import-json measures.json"), "Import error", || with_import_fixtures(two_col_model()), |app| run_line(app, "import-json good.json"), ), ( "a records coordinate edited to nothing", records_model_with_two_rows, |app| { app.cursor_mut().selected = (1, 0); commit_in(app, AppMode::records_editing(), "edit", "") }, crate::ui::effect::RECORD_COORDS_CANNOT_BE_EMPTY, records_model_with_two_rows, |app| { app.cursor_mut().selected = (1, 0); commit_in(app, AppMode::records_editing(), "edit", "West") }, ), ( "an export with no path", || with_import_fixtures(two_col_model()), |app| commit_in(app, AppMode::export_prompt(), "export", ""), "not exported", || with_import_fixtures(two_col_model()), |app| commit_in(app, AppMode::export_prompt(), "export", "out.csv"), ), ( "view-forward with nowhere to go", || with_a_second_view(two_col_model()), |app| run_line(app, "view-forward"), "No forward view", || with_a_second_view(two_col_model()), |app| { switch_to_second(app); run_line(app, "view-back"); run_line(app, "view-forward") }, ), ( "view-back with nowhere to go", || with_a_second_view(two_col_model()), |app| run_line(app, "view-back"), "No previous view", || with_a_second_view(two_col_model()), |app| { switch_to_second(app); run_line(app, "view-back") }, ), ( "add-record-row outside records mode", two_col_model, |app| run_line(app, "add-record-row"), "only works in records mode", records_model_with_two_rows, |app| run_line(app, "add-record-row"), ), ( "add-record-row onto a row that is already there", records_model_with_two_rows, |app| { records_paged_to(app, "South"); run_line(app, "add-record-row") }, "already here", records_model_with_two_rows, |app| { records_paged_to(app, "West"); run_line(app, "add-record-row") }, ), ( "edit-or-drill on an aggregate grid with no cell", || aggregated_grid(false), |app| run_line(app, "edit-or-drill editing"), "No cell at cursor", || aggregated_grid(true), |app| run_line(app, "edit-or-drill editing"), ), ( "edit-or-drill on a cell a formula owns", a_formula_column, |app| { app.cursor_mut().selected = (0, 1); run_line(app, "edit-or-drill editing") }, "computed by a formula", a_formula_column, |app| { app.cursor_mut().selected = (0, 0); run_line(app, "edit-or-drill editing") }, ), ( "a search with no matches", || with_a_cell(two_col_model()), |app| { app.view_state.search_query = "zzz".into(); run_line(app, "search-navigate forward") }, "No matches", || with_a_cell(two_col_model()), |app| { app.view_state.search_query = "42".into(); run_line(app, "search-navigate forward") }, ), ( "load of an absent file", with_a_document_to_load, |app| run_line(app, "load absent.improv"), "Load error", with_a_document_to_load, |app| run_line(app, "load doc.improv"), ), ( "import-json of an absent JSON", || with_import_fixtures(two_col_model()), |app| run_line(app, "import-json absent.json"), "Cannot read 'absent.json'", || with_import_fixtures(two_col_model()), |app| run_line(app, "import-json good.json"), ), ( "a formula that does not parse", two_col_model, |app| commit_in(app, AppMode::formula_edit(), "formula", "Total = ("), "Formula error", two_col_model, |app| commit_in(app, AppMode::formula_edit(), "formula", "Total = 1"), ), ( "a : line that does not parse", two_col_model, |app| commit_in(app, AppMode::command_mode(), "command", "no-such-command"), ":no-such-command", two_col_model, |app| commit_in(app, AppMode::command_mode(), "command", "add-category Z"), ), // improvise-i3o5: these four wrote their decline with `set_status`. ( "edit-or-drill on a collapsed group's row", folded_quarter, |app| { app.cursor_mut().selected = (2, 0); run_line(app, "edit-or-drill editing") }, "Collapsed group", folded_quarter, |app| { app.cursor_mut().selected = (1, 0); run_line(app, "edit-or-drill editing") }, ), ( "a spelled paste onto an aggregate", || yanked(aggregated_grid(true)), |app| run_line(app, "paste Row/A Col/A"), crate::command::cmd::core::AGGREGATE_REFUSAL, || yanked(aggregated_grid(true)), |app| run_line(app, "paste Row/A Col/A Hidden/A"), ), ( "a spelled paste onto a records coordinate column", || yanked(records_model_with_two_rows()), |app| run_line(app, "paste _Index/1 _Dim/0"), crate::command::cmd::core::NO_VALUE_REFUSAL, || yanked(records_model_with_two_rows()), |app| run_line(app, "paste _Index/1 _Dim/2"), ), ( "a spelled paste inside a drill", || yanked(in_a_drill(records_model_with_two_rows())), |app| run_line(app, "paste _Index/1 _Dim/2"), crate::command::cmd::core::INSIDE_A_DRILL_REFUSAL, || yanked(records_model_with_two_rows()), |app| run_line(app, "paste _Index/1 _Dim/2"), ), // improvise-cwfj: the category panel's three "nothing at the cursor" // declines, and a bound key whose command will not build. ( "cycle-axis-at-cursor past the panel's last row", || panel_cursor_at(two_col_model(), 99), |app| run_line(app, "cycle-axis-at-cursor"), "Move cursor to a category header", || panel_cursor_at(two_col_model(), 1), |app| run_line(app, "cycle-axis-at-cursor"), ), ( "open-item-add-at-cursor past the panel's last row", || panel_cursor_at(two_col_model(), 99), |app| run_line(app, "open-item-add-at-cursor"), "No category selected", || panel_cursor_at(two_col_model(), 1), |app| run_line(app, "open-item-add-at-cursor"), ), ( "a bound key whose command will not build (p onto an aggregate)", || yanked(aggregated_grid(true)), |app| press_key(app, KeyCode::Char('p')), crate::command::cmd::core::AGGREGATE_REFUSAL, || yanked(two_col_model()), |app| { app.cursor_mut().selected = (1, 1); press_key(app, KeyCode::Char('p')) }, ), ( "delete-category-at-cursor past the panel's last row", || panel_cursor_at(two_col_model(), 99), |app| run_line(app, "delete-category-at-cursor"), "No category to delete", || panel_cursor_at(two_col_model(), 1), |app| run_line(app, "delete-category-at-cursor"), ), ]; for (what, fail_setup, fails, says, ok_setup, succeeds) in cases { let mut app = fail_setup(); let refused = fails(&mut app); assert!( refused && app.view_state.status_msg.contains(says), "{what}: expected a refusal saying {says:?}, got refused={refused} {:?}", app.view_state.status_msg ); let mut app = ok_setup(); assert!( !succeeds(&mut app), "{what}: its succeeding twin was refused: {:?}", app.view_state.status_msg ); } } /// A prefix is consumed whether or not the next key matches, so a stray key /// after `g` does nothing rather than acting as a fresh Normal binding. #[test] fn unmatched_key_after_prefix_is_swallowed() { let mut app = records_model_with_two_rows(); app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); app.cursor_mut().selected = (1, 0); app.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)) .unwrap(); // `j` would move down in normal mode, but here it only ends the prefix. app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)) .unwrap(); assert_eq!( app.cursor().selected.0, 1, "the key after an unmatched prefix must not act as a normal binding" ); } /// Records mode has no prefix bindings of its own — it reaches them /// through its parent keymap, so resolution must walk parents. #[test] fn prefix_keys_reach_through_the_records_parent_keymap() { let mut app = records_model_with_two_rows(); assert!(app.layout.is_records_mode(), "setup"); assert_eq!(app.view_state.mode, AppMode::RecordsNormal, "setup"); app.cursor_mut().selected = (1, 0); app.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)) .unwrap(); app.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)) .unwrap(); assert_eq!( app.cursor().selected.0, 0, "gg should work in records mode via the inherited prefix" ); } /// improvise-c5v: editing a category column must move every measure cell in /// the row; the long-format code moved only the one under the cursor. #[test] fn renaming_a_coordinate_moves_every_measure_in_the_row() { use crate::model::cell::{CellKey, CellValue}; let mut app = records_model_with_two_rows(); let region_col = (0..app.layout.col_count()) .find(|&c| app.layout.col_label(c) == "Region") .expect("Region column"); app.cursor_mut().selected = (0, region_col); // Replace "North" with "West". app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); for _ in 0.."North".len() { app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)) .unwrap(); } for c in "West".chars() { app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) .unwrap(); } app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); let cell = |region: &str, meas: &str| { CellKey::new(vec![ ("Region".into(), region.into()), ("_Measure".into(), meas.into()), ]) }; for (meas, n) in [("meas1", 20.0), ("meas2", 10.0)] { assert_eq!( app.model_state .workbook .model .get_cell(&cell("North", meas)), None, "{meas} should have left the old coordinate" ); assert_eq!( app.model_state.workbook.model.get_cell(&cell("West", meas)), Some(&CellValue::Number(n)), "{meas} should have moved with the row" ); } // The untouched row is unaffected. assert_eq!( app.model_state .workbook .model .get_cell(&cell("South", "meas1")), Some(&CellValue::Number(40.0)) ); } /// A measure column writes straight to that measure's cell — the point /// of wide format (improvise-c5v). #[test] fn editing_a_measure_column_writes_that_cell_only() { use crate::model::cell::{CellKey, CellValue}; let mut app = records_model_with_two_rows(); let meas1_col = (0..app.layout.col_count()) .find(|&c| app.layout.col_label(c) == "meas1") .expect("meas1 column"); app.cursor_mut().selected = (0, meas1_col); app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); for _ in 0.."20".len() { app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)) .unwrap(); } for c in "77".chars() { app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) .unwrap(); } app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); let cell = |meas: &str| { CellKey::new(vec![ ("Region".into(), "North".into()), ("_Measure".into(), meas.into()), ]) }; assert_eq!( app.model_state.workbook.model.get_cell(&cell("meas1")), Some(&CellValue::Number(77.0)) ); assert_eq!( app.model_state.workbook.model.get_cell(&cell("meas2")), Some(&CellValue::Number(10.0)), "the row's other measure must be untouched" ); } /// improvise-3zq (#2): an empty-`CellKey` cell serialises as ` = 0` and /// reappears on every records toggle, so leaving the mode cleans it up. #[test] fn leaving_records_mode_cleans_empty_key_cells() { use crate::model::cell::{CellKey, CellValue}; let mut app = records_model_with_two_rows(); // Simulate Tab-at-bottom-right having produced an empty-key cell. app.model_state .workbook .model .set_cell(CellKey::new(vec![]), CellValue::Number(0.0)); assert!( app.model_state .workbook .model .data .iter_cells() .any(|(k, _)| k.0.is_empty()), "setup: empty-key cell should be present" ); // Leave records mode via R. app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); assert!( !app.layout.is_records_mode(), "setup: should have left records mode" ); assert!( !app.model_state .workbook .model .data .iter_cells() .any(|(k, _)| k.0.is_empty()), "empty-key records should be cleaned when leaving records mode" ); } /// improvise-3zq (#1): Enter on the bottom-right records cell commits and /// leaves edit mode, rather than re-entering editing on the same cell. #[test] fn enter_at_bottom_right_of_records_view_exits_editing() { let mut app = records_model_with_two_rows(); let last_row = app.layout.row_count() - 1; let last_col = app.layout.col_count() - 1; app.cursor_mut().selected = (last_row, last_col); app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); assert!(app.view_state.mode.is_editing(), "setup: should be editing"); app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); assert!( !app.view_state.mode.is_editing(), "Enter at bottom-right should exit editing, got {:?}", app.view_state.mode ); assert!( matches!(app.view_state.mode, AppMode::RecordsNormal), "should return to RecordsNormal, got {:?}", app.view_state.mode ); } /// improvise-hmu: Tab on the bottom-right records cell inserts a row below /// and moves to its first cell in edit mode. #[test] fn tab_on_bottom_right_of_records_inserts_below() { let mut app = records_model_with_two_rows(); let initial_rows = app.layout.row_count(); assert!(initial_rows >= 1, "setup: need at least 1 record"); let last_row = initial_rows - 1; let last_col = app.layout.col_count() - 1; app.cursor_mut().selected = (last_row, last_col); // Enter edit mode on the bottom-right cell app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); assert!(app.view_state.mode.is_editing(), "setup: should be editing"); // TAB should commit, insert below, move to first cell of new row app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) .unwrap(); assert_eq!( app.layout.row_count(), initial_rows + 1, "TAB on bottom-right should insert a record below" ); assert_eq!( app.cursor().selected, (initial_rows, 0), "TAB should move to first cell of the new row" ); assert!( app.view_state.mode.is_editing(), "should enter edit mode on the new cell, got {:?}", app.view_state.mode ); } /// 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.add_item("Region", "East").unwrap(); wb.add_item("Month", "Jan").unwrap(); wb.add_item("_Measure", "Revenue").unwrap(); let record_key = CellKey::new(vec![ ("Month".to_string(), "Jan".to_string()), ("Region".to_string(), "East".to_string()), ("_Measure".to_string(), "Revenue".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) == "Revenue") .expect("drill view should include the Revenue measure column"); app.cursor_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.model_state.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, crate::view::RecordCol::Measure("Revenue".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.model_state.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.add_item("Region", "East").unwrap(); 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.model_state .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 crate::command::key::KeyEvent; let mut app = two_col_model(); // Enter command mode, type something, escape app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)) .unwrap(); assert_eq!( app.view_state.buffers.get("command").map(|s| s.as_str()), Some("x") ); app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) .unwrap(); // Re-enter command mode — buffer should be cleared app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert_eq!( app.view_state.buffers.get("command").map(|s| s.as_str()), Some("") ); } // ── is_empty_model ────────────────────────────────────────────────── #[test] fn fresh_model_is_empty() { let app = App::new(Workbook::new("T"), None); assert!( app.is_empty_model(), "a brand-new model with only virtual categories should be empty" ); } #[test] fn model_with_user_category_is_not_empty() { let mut wb = Workbook::new("T"); wb.add_category("Sales").unwrap(); let app = App::new(wb, None); assert!( !app.is_empty_model(), "a model with a user-defined category should not be empty" ); } // ── Help mode navigation ──────────────────────────────────────────── #[test] fn help_page_next_advances_page() { let mut app = App::new(Workbook::new("T"), None); app.view_state.mode = AppMode::Help; app.view_state.help_page = 0; app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)) .unwrap(); assert_eq!(app.view_state.help_page, 1, "l should advance to page 1"); } #[test] fn help_page_prev_goes_back() { let mut app = App::new(Workbook::new("T"), None); app.view_state.mode = AppMode::Help; app.view_state.help_page = 2; app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)) .unwrap(); assert_eq!(app.view_state.help_page, 1, "h should go back to page 1"); } #[test] fn help_page_clamps_at_zero() { let mut app = App::new(Workbook::new("T"), None); app.view_state.mode = AppMode::Help; app.view_state.help_page = 0; app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)) .unwrap(); assert_eq!(app.view_state.help_page, 0, "page should not go below 0"); } #[test] fn help_page_clamps_at_max() { use crate::ui::app::HELP_PAGE_COUNT; let mut app = App::new(Workbook::new("T"), None); app.view_state.mode = AppMode::Help; app.view_state.help_page = HELP_PAGE_COUNT - 1; app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)) .unwrap(); assert_eq!( app.view_state.help_page, HELP_PAGE_COUNT - 1, "page should not exceed the last page" ); } // ── Help mode exits ───────────────────────────────────────────────── #[test] fn help_q_returns_to_normal() { let mut app = App::new(Workbook::new("T"), None); app.view_state.mode = AppMode::Help; app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.view_state.mode, AppMode::Normal), "q should return to Normal mode" ); } #[test] fn help_esc_returns_to_normal() { let mut app = App::new(Workbook::new("T"), None); app.view_state.mode = AppMode::Help; app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.view_state.mode, AppMode::Normal), "Esc should return to Normal mode" ); } #[test] fn help_colon_enters_command_mode() { let mut app = App::new(Workbook::new("T"), None); app.view_state.mode = AppMode::Help; app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.view_state.mode, AppMode::CommandMode { .. }), "colon in Help mode should enter CommandMode, got {:?}", app.view_state.mode ); } // ── Effect error feedback ─────────────────────────────────────────── #[test] fn add_item_to_nonexistent_category_sets_status() { use crate::ui::effect::Effect; let mut app = App::new(Workbook::new("T"), None); let effect = crate::ui::effect::AddItem { category: "Nonexistent".to_string(), item: "x".to_string(), }; effect.apply(&mut app); assert!( app.view_state.status_msg.contains("Unknown category"), "should report unknown category, got: {:?}", app.view_state.status_msg ); } #[test] fn add_formula_with_bad_syntax_sets_status() { use crate::ui::effect::Effect; let mut app = App::new(Workbook::new("T"), None); let effect = crate::ui::effect::AddFormula { raw: "!!!invalid".to_string(), target_category: "X".to_string(), }; effect.apply(&mut app); assert!( app.view_state.status_msg.contains("Formula error"), "should report formula error, got: {:?}", app.view_state.status_msg ); } // ── Tile select stays in mode ─────────────────────────────────────── #[test] fn tile_axis_change_stays_in_tile_select() { let mut app = two_col_model(); app.view_state.mode = AppMode::TileSelect; app.view_state.tile_cat_idx = 0; // Press 'r' to set axis to Row — should stay in TileSelect app.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.view_state.mode, AppMode::TileSelect), "should stay in TileSelect after axis change, got {:?}", app.view_state.mode ); assert!( !app.view_state.status_msg.is_empty(), "should show status feedback after axis change" ); } // ── Panel colon bindings ──────────────────────────────────────────── #[test] fn category_panel_colon_enters_command_mode() { let mut app = two_col_model(); app.view_state.mode = AppMode::CategoryPanel; app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.view_state.mode, AppMode::CommandMode { .. }), "colon in CategoryPanel should enter CommandMode, got {:?}", app.view_state.mode ); } #[test] fn view_panel_colon_enters_command_mode() { let mut app = two_col_model(); app.view_state.mode = AppMode::ViewPanel; app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.view_state.mode, AppMode::CommandMode { .. }), "colon in ViewPanel should enter CommandMode, got {:?}", app.view_state.mode ); } #[test] fn tile_select_colon_enters_command_mode() { let mut app = two_col_model(); app.view_state.mode = AppMode::TileSelect; app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.view_state.mode, AppMode::CommandMode { .. }), "colon in TileSelect should enter CommandMode, got {:?}", app.view_state.mode ); } fn two_region_records() -> App { use crate::model::cell::{CellKey, CellValue}; let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_item("Region", "East").unwrap(); wb.add_item("Region", "West").unwrap(); wb.add_item("_Measure", "Revenue").unwrap(); for (region, n) in [("East", 10.0), ("West", 20.0)] { wb.model.set_cell( CellKey::new(vec![ ("Region".into(), region.into()), ("_Measure".into(), "Revenue".into()), ]), CellValue::Number(n), ); } let mut app = App::new(wb, None); app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); assert!(app.layout.is_records_mode(), "setup: records mode"); assert_eq!(app.layout.row_count(), 2, "setup: East and West"); app } fn region_revenue(app: &App, region: &str) -> Option { use crate::model::cell::CellKey; app.model_state .workbook .model .get_cell(&CellKey::new(vec![ ("Region".into(), region.into()), ("_Measure".into(), "Revenue".into()), ])) .map(|v| v.to_string()) } /// improvise-o9uq: evidence in `context/test-evidence/command.md`. #[test] fn o_in_records_mode_edits_the_row_it_added_and_leaves_the_others_alone() { let mut app = two_region_records(); app.cursor_mut().selected = (1, 0); app.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)) .unwrap(); for c in "North".chars() { app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) .unwrap(); } app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); assert_eq!( region_revenue(&app, "East").as_deref(), Some("10"), "adding a row renamed a record the user never touched: {:?}", (0..app.layout.row_count()) .map(|r| app.layout.row_label(r)) .collect::>() ); assert_eq!( region_revenue(&app, "West").as_deref(), Some("20"), "the row the cursor was on when `o` was pressed also moved" ); } /// improvise-o9uq: evidence in `context/test-evidence/command.md`. #[test] fn o_in_records_mode_opens_the_editor_on_an_empty_coordinate() { let mut app = two_region_records(); app.cursor_mut().selected = (1, 0); app.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)) .unwrap(); assert_eq!( app.view_state.buffers.get("edit").map(String::as_str), Some(""), "the editor opened pre-filled with another row's coordinate" ); } } /// Behavioural coverage for the import wizard's key handling (improvise-2wm), /// driven entirely through `handle_key` so it pins the user-visible contract. #[cfg(test)] mod wizard_key_tests { use super::*; use crate::command::key::KeyEvent; use crate::import::analyzer::FieldKind; use crate::import::wizard::WizardStep; fn wizard_app(json: serde_json::Value) -> App { let mut app = App::new(Workbook::new("T"), None); app.start_import_wizard(json); app } fn press(app: &mut App, code: KeyCode) { app.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) .unwrap(); } fn step(app: &App) -> WizardStep { app.wizard.as_ref().expect("wizard open").step.clone() } /// Two category-ish fields plus a measure, so ReviewProposals has several /// rows to move a cursor through. fn sample_records() -> serde_json::Value { serde_json::json!([ {"region": "East", "product": "Shirts", "revenue": 100.0}, {"region": "West", "product": "Pants", "revenue": 200.0}, ]) } #[test] fn empty_array_starts_at_preview_and_enter_advances() { let mut app = wizard_app(serde_json::json!([])); assert_eq!(step(&app), WizardStep::Preview, "setup"); press(&mut app, KeyCode::Enter); assert_eq!(step(&app), WizardStep::ReviewProposals); } #[test] fn esc_closes_the_wizard_and_returns_to_normal() { let mut app = wizard_app(sample_records()); press(&mut app, KeyCode::Esc); assert!(app.wizard.is_none(), "wizard should be dropped"); assert_eq!(app.view_state.mode, AppMode::Normal); } #[test] fn review_proposals_jk_moves_the_cursor() { let mut app = wizard_app(sample_records()); assert_eq!(step(&app), WizardStep::ReviewProposals, "setup"); press(&mut app, KeyCode::Char('j')); assert_eq!(app.wizard.as_ref().unwrap().cursor, 1); press(&mut app, KeyCode::Char('k')); assert_eq!(app.wizard.as_ref().unwrap().cursor, 0); press(&mut app, KeyCode::Down); assert_eq!(app.wizard.as_ref().unwrap().cursor, 1); press(&mut app, KeyCode::Up); assert_eq!(app.wizard.as_ref().unwrap().cursor, 0); } #[test] fn review_proposals_space_toggles_and_c_cycles_kind() { let mut app = wizard_app(sample_records()); let before = &app.wizard.as_ref().unwrap().pipeline.proposals[0]; let (accepted, kind) = (before.accepted, before.kind.clone()); press(&mut app, KeyCode::Char(' ')); assert_eq!( app.wizard.as_ref().unwrap().pipeline.proposals[0].accepted, !accepted, "space toggles the proposal under the cursor" ); press(&mut app, KeyCode::Char('c')); assert_ne!( app.wizard.as_ref().unwrap().pipeline.proposals[0].kind, kind, "c cycles the field kind" ); } #[test] fn enter_walks_review_to_formulas_to_name_model() { let mut app = wizard_app(sample_records()); press(&mut app, KeyCode::Enter); assert_eq!(step(&app), WizardStep::DefineFormulas, "no time categories"); press(&mut app, KeyCode::Enter); assert_eq!(step(&app), WizardStep::NameModel); } #[test] fn formula_editing_captures_chars_and_esc_cancels_only_the_edit() { let mut app = wizard_app(sample_records()); press(&mut app, KeyCode::Enter); // → DefineFormulas press(&mut app, KeyCode::Char('n')); assert!(app.wizard.as_ref().unwrap().formula_editing); press(&mut app, KeyCode::Char('x')); press(&mut app, KeyCode::Char('y')); press(&mut app, KeyCode::Backspace); assert_eq!(app.wizard.as_ref().unwrap().formula_buffer, "x"); press(&mut app, KeyCode::Esc); let wizard = app.wizard.as_ref().expect("Esc must not close the wizard"); assert!(!wizard.formula_editing, "Esc leaves formula editing"); assert_eq!(step(&app), WizardStep::DefineFormulas); } /// Enter at NameModel builds and parks on Done; a second Enter installs the /// workbook and closes the wizard (improvise-907, from improvise-c6j). #[test] fn name_model_typing_edits_the_name_and_enter_confirms_the_import() { let mut app = wizard_app(sample_records()); press(&mut app, KeyCode::Enter); // → DefineFormulas press(&mut app, KeyCode::Enter); // → NameModel press(&mut app, KeyCode::Backspace); press(&mut app, KeyCode::Char('!')); assert_eq!( app.wizard.as_ref().unwrap().pipeline.model_name, "Imported Mode!" ); press(&mut app, KeyCode::Enter); // build → Done assert_eq!(step(&app), WizardStep::Done, "build parks on Done"); assert!( app.wizard.as_ref().unwrap().built_summary().is_some(), "the built workbook waits for confirmation" ); assert!( !app.model_state.dirty, "nothing is installed until the user accepts" ); press(&mut app, KeyCode::Enter); // accept assert!(app.wizard.is_none(), "accepting closes the wizard"); assert_eq!(app.view_state.mode, AppMode::Normal); assert!(app.model_state.dirty, "imported model is unsaved"); assert!( app.model_state.workbook.model.category("region").is_some(), "imported categories land in the workbook" ); assert!( app.view_state.status_msg.contains("Import successful"), "got status {:?}", app.view_state.status_msg ); } /// BUG (improvise-907, from improvise-gfq): an all-Measure import used to be /// refused at build time. It is refused at ReviewProposals, where `c` helps. #[test] fn review_proposals_blocks_when_no_category_is_accepted() { let mut app = wizard_app(serde_json::json!([{"revenue": 1.0}])); assert_eq!( app.wizard.as_ref().unwrap().pipeline.proposals[0].kind, FieldKind::Measure, "setup: the only field must be a measure" ); press(&mut app, KeyCode::Enter); assert_eq!( step(&app), WizardStep::ReviewProposals, "must not advance past the step that can fix it" ); let wizard = app.wizard.as_ref().expect("wizard stays open"); assert!( wizard .message .as_deref() .unwrap_or_default() .contains("Category"), "got message {:?}", wizard.message ); // Cycling the field to a Category unblocks the step. press(&mut app, KeyCode::Char('c')); press(&mut app, KeyCode::Enter); assert_ne!(step(&app), WizardStep::ReviewProposals); } /// BUG (improvise-907, from improvise-o1c): changing a field kind late meant /// Esc, discarding everything. Left walks the path actually taken. #[test] fn left_walks_back_through_the_steps_taken() { let mut app = wizard_app(sample_records()); press(&mut app, KeyCode::Enter); // → DefineFormulas press(&mut app, KeyCode::Enter); // → NameModel press(&mut app, KeyCode::Left); assert_eq!(step(&app), WizardStep::DefineFormulas); press(&mut app, KeyCode::Left); assert_eq!( step(&app), WizardStep::ReviewProposals, "back skips ConfigureDates, which forward skipped too" ); press(&mut app, KeyCode::Left); assert_eq!( step(&app), WizardStep::ReviewProposals, "back at the first step is a no-op" ); assert!(app.wizard.is_some(), "back must never close the wizard"); } /// Esc at Done returns to NameModel rather than discarding the import, and /// drops the built workbook so a later accept cannot install stale decisions. #[test] fn done_esc_returns_to_name_model_and_drops_the_built_model() { let mut app = wizard_app(sample_records()); press(&mut app, KeyCode::Enter); // → DefineFormulas press(&mut app, KeyCode::Enter); // → NameModel press(&mut app, KeyCode::Enter); // build → Done press(&mut app, KeyCode::Esc); let wizard = app.wizard.as_ref().expect("wizard stays open"); assert_eq!(wizard.step, WizardStep::NameModel); assert!( wizard.built_summary().is_none(), "the stale build must be discarded" ); assert!(!app.model_state.dirty); } #[test] fn done_ignores_unbound_keys_instead_of_closing() { let mut app = wizard_app(sample_records()); press(&mut app, KeyCode::Enter); press(&mut app, KeyCode::Enter); press(&mut app, KeyCode::Enter); // build → Done press(&mut app, KeyCode::Char('z')); assert_eq!(step(&app), WizardStep::Done, "an unbound key does nothing"); assert!(app.wizard.is_some()); } #[test] fn unbound_keys_are_ignored_without_closing_the_wizard() { let mut app = wizard_app(sample_records()); press(&mut app, KeyCode::Char('Z')); assert_eq!(step(&app), WizardStep::ReviewProposals); } } #[cfg(test)] mod basis_tiers { //! The two lower tiers of a batch's basis (ADR 001, improvise-xwi6). Two cats, //! two measures, two records: a smaller fixture cannot draw the distinctions. use super::*; use crate::model::cell::CellValue; use crate::ui::effect::{ AbortChain, ApplyAndClearDrill, Effect, SetAxis, SetCell, SetDrillPendingEdit, SetSelected, SetStatus, StartDrill, }; use crate::view::layout::RecordRow; use crate::view::{Axis, RecordCol}; use crate::workbook::Workbook; /// `Region x Month x {Revenue, Cost}` -- two records, two cells each. fn app_with_two_records() -> App { let mut wb = Workbook::new("Test"); wb.add_category("Region").unwrap(); wb.add_category("Month").unwrap(); for region in ["North", "South"] { wb.add_item("Region", region).unwrap(); } wb.add_item("Month", "Jan").unwrap(); for measure in ["Revenue", "Cost"] { wb.add_item("_Measure", measure).unwrap(); } wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); let mut app = App::new(wb, None); for (region, revenue, cost) in [("North", 10.0, 4.0), ("South", 20.0, 7.0)] { for (measure, n) in [("Revenue", revenue), ("Cost", cost)] { app.model_state .workbook .model .set_cell(cell(region, measure), CellValue::Number(n)); } } app } fn cell(region: &str, measure: &str) -> CellKey { CellKey::new(vec![ ("Region".into(), region.into()), ("Month".into(), "Jan".into()), ("_Measure".into(), measure.into()), ]) } fn record(region: &str) -> CellKey { record_of(&cell(region, "Revenue")) } /// The record is the coordinate tuple with `_Measure` gone, so both measures /// of one coordinate name the same record. #[test] fn a_records_two_measures_share_one_record_key() { assert_eq!(record_of(&cell("North", "Revenue")), record("North")); assert_eq!(record_of(&cell("North", "Cost")), record("North")); assert_ne!(record("North"), record("South")); assert_eq!(record("North").get("_Measure"), None); assert_eq!(record("North").get("Region"), Some("North")); } /// Tier 2 moves, tier 3 does not: a shape change must not masquerade as a /// write to any record. #[test] fn a_layout_change_moves_the_layout_version_and_no_record() { let mut app = app_with_two_records(); let before = app.layout_version(); // `on_category_added` seats `Month` on Column, so asking for Column is the // no-op the tier used to be *told* about rather than asked (improvise-75ld). assert_eq!(app.view().axis_of("Month"), Axis::Column); app.apply_effects(vec![Box::new(SetAxis { view: ViewTarget::Active, category: "Month".into(), axis: Axis::Row, })]); assert_eq!( app.view().axis_of("Month"), Axis::Row, "the axis did not move" ); assert_eq!(app.layout_version(), before + 1); for region in ["North", "South"] { assert_eq!( app.record_version(&record(region)), 0, "{region} was not written" ); } } /// gotcha 45: `Save` clears `dirty` **in loop order**, so a batch that changes /// the document and then saves ends clean — for a geometry change too. #[test] fn a_batch_that_changes_the_layout_and_then_saves_ends_clean() { use crate::storage::MemoryFs; use crate::ui::effect::{Save, SetNumberFormat}; // Both arms, because the CellData half never left the loop: a test over // the geometry arm alone cannot say whether the ordering rule is general. let geometry: Vec> = vec![ Box::new(SetNumberFormat(ViewTarget::Active, ",.2f".into())), Box::new(Save), ]; let data: Vec> = vec![ Box::new(SetCell(cell("North", "Revenue"), CellValue::Number(99.0))), Box::new(Save), ]; for (label, effects) in [("layout", geometry), ("data", data)] { let mut app = app_with_two_records(); app.storage = Box::new(MemoryFs::new()); app.model_state.file_path = Some("doc.improv".into()); app.model_state.dirty = false; app.apply_effects(effects); assert!( !app.last_batch_aborted(), "the {label} batch's save failed, so it proves nothing" ); assert!( app.view_state.status_msg.starts_with("Saved"), "the {label} batch did not report a save: {}", app.view_state.status_msg ); assert!( !app.model_state.dirty, "the {label} batch saved at the end and the document still reads \ dirty: the TUI says Saved and :q refuses, and the browser worker \ rewrites the whole OPFS document" ); } } /// improvise-75ld: a counter is bumped on an **observed** change, never on a /// declared intent. Both arms, plus the positive one "never bump" would pass. #[test] fn a_refused_or_duplicate_add_category_moves_no_counter() { use crate::ui::effect::AddCategory; let mut app = app_with_two_records(); app.model_state.dirty = false; let version = app.layout_version(); app.apply_effects(vec![Box::new(AddCategory("Zone".into()))]); assert!( app.model_state.workbook.model.category("Zone").is_some(), "the positive arm did not reach the document, so it proves nothing" ); assert_eq!( app.layout_version(), version + 1, "a real category moved nothing" ); assert!( app.model_state.dirty, "a real category left the document clean" ); // The duplicate is the reachable silent case: `Model::add_category` returns // `Ok` for a name it already has, so this arm does not abort. let mut app = app_with_two_records(); app.model_state.dirty = false; let version = app.layout_version(); app.apply_effects(vec![Box::new(AddCategory("Region".into()))]); assert!( !app.last_batch_aborted(), "the duplicate arm aborted, so it is the refusal arm wearing its name" ); assert_eq!( app.layout_version(), version, "a duplicate category changed nothing and still refused every other \ client's next batch with Rejection::LayoutMoved" ); assert!( !app.model_state.dirty, "a duplicate category changed nothing and still made the worker rewrite \ the whole OPFS document" ); // And the refusal, which aborts: the bumps used to run before the check. let mut app = app_with_two_records(); let filled = (0..64).take_while(|i| { app.model_state .workbook .add_category(format!("Filler{i}")) .is_ok() }); assert!(filled.count() > 0, "the fixture is already at the limit"); app.rebuild_layout(); app.model_state.dirty = false; let version = app.layout_version(); app.apply_effects(vec![Box::new(AddCategory("Overflow".into()))]); assert!( app.last_batch_aborted(), "the refusal arm was accepted, so it proves nothing" ); assert_eq!( app.layout_version(), version, "a refused category moved the basis" ); assert!( !app.model_state.dirty, "a refused category dirtied the document" ); } /// improvise-gv9r: a loaded document is dirty, because `LoadModel` does not /// move `file_path`. A behaviour change with a reason — see the evidence. #[test] fn a_loaded_document_is_dirty_because_its_path_still_names_the_old_one() { use crate::storage::MemoryFs; let storage = MemoryFs::new(); let mut other = Workbook::new("Other"); other.add_category("Product").unwrap(); crate::persistence::save_to(&storage, &other, std::path::Path::new("other.improv")) .expect("the fixture document must save"); let mut app = App::new(Workbook::new("Test"), Some("here.improv".into())); app.storage = Box::new(storage); app.rebuild_layout(); assert!(!app.model_state.dirty, "the fixture starts clean"); app.apply_effects(vec![Box::new(crate::ui::effect::LoadModel( "other.improv".into(), ))]); assert_eq!( app.model_state.workbook.model.name, "Other", "the fixture must actually load, or the flag proves nothing" ); assert_eq!( app.model_state.file_path, Some("here.improv".into()), "LoadModel does not move the path — which is exactly why the \ document is now out of step with it" ); assert!(app.model_state.dirty); } /// The negative arm of improvise-gv9r's raise, aimed at `apply_effects`: a /// view-slice `Command` never reaches this loop, so it cannot discriminate. #[test] fn a_view_only_batch_leaves_the_document_clean() { let mut app = app_with_two_records(); app.apply_effects(vec![ Box::new(SetStatus("hello".into())), Box::new(SetSelected(1, 0)), ]); assert!(!app.model_state.dirty); } /// Tier 3 moves for the record written and no other, and tier 2 stays put. /// A cell edit does not invalidate batches that merely share the document. #[test] fn a_cell_edit_moves_only_the_record_it_touched() { let mut app = app_with_two_records(); let layout = app.layout_version(); app.apply_effects(vec![Box::new(SetCell( cell("North", "Revenue"), CellValue::Number(99.0), ))]); assert_eq!(app.record_version(&record("North")), 1); assert_eq!(app.record_version(&record("South")), 0); assert_eq!(app.layout_version(), layout, "no shape moved"); } /// The unit is the record, not the cell: a coordinate rename is ONE write to /// the record it left and ONE to the record it arrived at (gotcha 1). #[test] fn a_coordinate_rename_is_one_write_to_each_record_not_one_per_measure() { let mut app = app_with_two_records(); let records = RecordRow::group([ (cell("North", "Revenue"), CellValue::Number(10.0)), (cell("North", "Cost"), CellValue::Number(4.0)), ]); assert_eq!(records.len(), 1, "both measures share one row"); StartDrill(records).apply(&mut app); SetDrillPendingEdit { record_idx: 0, col: RecordCol::Category("Region".to_string()), new_value: "West".to_string(), } .apply(&mut app); app.apply_effects(vec![Box::new(ApplyAndClearDrill)]); assert_eq!( app.record_version(&record("North")), 1, "the record it left moved once, though two cells went with it" ); assert_eq!( app.record_version(&record("West")), 1, "and so did the record it arrived at" ); assert_eq!(app.record_version(&record("South")), 0); } /// Gotcha 21: a batch that aborts halfway bumps nothing after the abort, in /// either tier. The versions are read where the `Dirty` accumulates. #[test] fn an_aborted_batch_bumps_nothing_it_did_not_run() { let mut app = app_with_two_records(); let layout = app.layout_version(); app.apply_effects(vec![ Box::new(SetCell(cell("North", "Revenue"), CellValue::Number(99.0))), Box::new(AbortChain), Box::new(SetCell(cell("South", "Revenue"), CellValue::Number(88.0))), Box::new(SetAxis { view: ViewTarget::Active, category: "Month".into(), axis: Axis::Column, }), ]); assert_eq!(app.record_version(&record("North")), 1, "this one ran"); assert_eq!( app.record_version(&record("South")), 0, "this one was skipped" ); assert_eq!( app.layout_version(), layout, "and so was the effect that would have moved the shape" ); assert_eq!( app.model_state .workbook .model .get_cell(&cell("South", "Revenue")), Some(&CellValue::Number(20.0)), "the skipped write really did not land" ); } /// A write that lands the value already there invalidates nobody's basis. /// Observed change, not "a CellData effect ran" — the safe direction. #[test] fn a_write_that_changes_nothing_moves_no_version() { let mut app = app_with_two_records(); app.apply_effects(vec![Box::new(SetCell( cell("North", "Revenue"), CellValue::Number(10.0), ))]); assert_eq!(app.record_version(&record("North")), 0); } /// The counters are shared document state on `App`, never on the portable /// session slice (gotcha 30). #[test] fn the_version_tiers_are_not_in_the_view_state() { let json = serde_json::to_string(&ViewState::default()).unwrap(); for field in ["layout_version", "record_version", "document_generation"] { assert!( !json.contains(field), "{field} leaked into the snapshot payload" ); } } } /// Editing a formula from the panel (improvise-km71), driven through /// `handle_key` at a NONZERO cursor over three formulas. #[cfg(test)] mod formula_edit_tests { use super::*; use crate::formula::parse_formula; const PROFIT: &str = "Profit = Revenue - Cost"; const TAX: &str = "Tax = Revenue * 0.1"; const MARGIN: &str = "Margin = Profit / Revenue"; fn app_with_three_formulas() -> App { let mut wb = Workbook::new("T"); wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Cost").unwrap(); for raw in [PROFIT, TAX, MARGIN] { wb.model .add_formula(parse_formula(raw, "_Measure").unwrap()); } let mut app = App::new(wb, None); app.view_state.formula_panel_open = true; app.view_state.mode = AppMode::FormulaPanel; app.view_state.formula_cursor = 1; app } fn press(app: &mut App, code: KeyCode) { app.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) .unwrap(); } /// Clear the minibuffer by keys and type `text`, as a user would. fn retype(app: &mut App, text: &str) { let len = app.view_state.buffers.get("formula").map_or(0, String::len); for _ in 0..len { press(app, KeyCode::Backspace); } for c in text.chars() { press(app, KeyCode::Char(c)); } } fn raws(app: &App) -> Vec { app.model_state .workbook .model .formulas() .iter() .map(|f| f.raw.clone()) .collect() } fn buffer(app: &App) -> Option<&str> { app.view_state.buffers.get("formula").map(String::as_str) } fn open_editor(app: &mut App) { press(app, KeyCode::Enter); assert!( matches!(app.view_state.mode, AppMode::FormulaEdit { .. }), "Enter on a formula must open the editor, got {:?}", app.view_state.mode ); } fn in_editor(app: &App) -> bool { matches!(app.view_state.mode, AppMode::FormulaEdit { .. }) } #[test] fn enter_opens_the_editor_seeded_with_the_formula_under_the_cursor() { let mut app = app_with_three_formulas(); open_editor(&mut app); assert_eq!(buffer(&app), Some(TAX), "cursor 1 is Tax, not the first"); } #[test] fn e_opens_the_same_editor() { let mut app = app_with_three_formulas(); press(&mut app, KeyCode::Char('e')); assert!(in_editor(&app), "{:?}", app.view_state.mode); assert_eq!(buffer(&app), Some(TAX)); } #[test] fn committing_an_edit_replaces_the_formula_in_place() { let mut app = app_with_three_formulas(); open_editor(&mut app); retype(&mut app, "Tax = Revenue * 0.2"); press(&mut app, KeyCode::Enter); assert_eq!(raws(&app), vec![PROFIT, "Tax = Revenue * 0.2", MARGIN]); assert_eq!(app.view_state.mode, AppMode::FormulaPanel); } /// The rival "add instead of replace" passes the test above, because /// `add_formula` replaces by target. A rename is where it cannot hide. #[test] fn changing_the_target_renames_the_formula_in_its_own_place() { let mut app = app_with_three_formulas(); open_editor(&mut app); retype(&mut app, "Levy = Revenue * 0.1"); press(&mut app, KeyCode::Enter); assert_eq!(raws(&app), vec![PROFIT, "Levy = Revenue * 0.1", MARGIN]); let measures = app.model_state.workbook.model.measure_item_names(); assert!( !measures.contains(&"Tax".to_string()), "Tax was a formula target only, so it goes: {measures:?}" ); } #[test] fn a_rename_says_which_formulas_still_name_the_old_target() { let mut app = app_with_three_formulas(); app.view_state.formula_cursor = 0; open_editor(&mut app); retype(&mut app, "Gross = Revenue - Cost"); press(&mut app, KeyCode::Enter); assert_eq!(raws(&app)[0], "Gross = Revenue - Cost"); let status = &app.view_state.status_msg; assert!( status.contains("Profit") && status.contains("Margin"), "Margin still refers to Profit, and the user must be told: {status:?}" ); } #[test] fn a_rename_onto_another_formulas_target_refuses_and_keeps_both() { let mut app = app_with_three_formulas(); open_editor(&mut app); retype(&mut app, "Margin = Revenue * 0.1"); press(&mut app, KeyCode::Enter); assert_eq!(raws(&app), vec![PROFIT, TAX, MARGIN]); assert!(in_editor(&app), "{:?}", app.view_state.mode); assert!(app.view_state.status_msg.contains("Margin")); } #[test] fn a_parse_error_keeps_the_editor_the_text_and_the_formula() { let mut app = app_with_three_formulas(); open_editor(&mut app); retype(&mut app, "Tax = = "); press(&mut app, KeyCode::Enter); assert_eq!(raws(&app), vec![PROFIT, TAX, MARGIN]); assert!(in_editor(&app), "{:?}", app.view_state.mode); assert_eq!(buffer(&app), Some("Tax = = ")); assert!( app.view_state.status_msg.contains("Formula error"), "{:?}", app.view_state.status_msg ); } /// The editor names its formula when it OPENS. Deleting an earlier one /// moves the edited formula to index 0 and puts `Margin` under cursor 1. #[test] fn an_edit_lands_on_its_formula_after_an_earlier_one_is_deleted_meanwhile() { let mut app = app_with_three_formulas(); open_editor(&mut app); retype(&mut app, "Tax = Revenue * 0.2"); let model = &mut app.model_state.workbook.model; model.remove_formula("Profit", "_Measure"); press(&mut app, KeyCode::Enter); assert_eq!(raws(&app), vec!["Tax = Revenue * 0.2", MARGIN]); } #[test] fn an_edit_whose_formula_was_deleted_meanwhile_refuses_out_loud() { let mut app = app_with_three_formulas(); open_editor(&mut app); retype(&mut app, "Tax = Revenue * 0.2"); let model = &mut app.model_state.workbook.model; model.remove_formula("Tax", "_Measure"); press(&mut app, KeyCode::Enter); assert_eq!(raws(&app), vec![PROFIT, MARGIN], "nothing re-added"); assert!(in_editor(&app), "{:?}", app.view_state.mode); assert_eq!(buffer(&app), Some("Tax = Revenue * 0.2")); assert!(app.last_batch_refused()); } #[test] fn an_edit_whose_formula_was_changed_meanwhile_refuses_out_loud() { let mut app = app_with_three_formulas(); open_editor(&mut app); retype(&mut app, "Tax = Revenue * 0.2"); let theirs = parse_formula("Tax = Revenue * 0.3", "_Measure").unwrap(); app.model_state.workbook.model.add_formula(theirs); press(&mut app, KeyCode::Enter); assert_eq!(raws(&app), vec![PROFIT, "Tax = Revenue * 0.3", MARGIN]); assert!(in_editor(&app), "{:?}", app.view_state.mode); assert!(app.last_batch_refused()); } #[test] fn esc_leaves_the_formula_untouched() { let mut app = app_with_three_formulas(); open_editor(&mut app); retype(&mut app, "Tax = 0"); press(&mut app, KeyCode::Esc); assert_eq!(raws(&app), vec![PROFIT, TAX, MARGIN]); assert_eq!(app.view_state.mode, AppMode::FormulaPanel); } /// `n` still adds: the replace slot must not leak into a fresh editor. #[test] fn a_new_formula_after_an_edit_is_added_not_replaced() { let mut app = app_with_three_formulas(); open_editor(&mut app); press(&mut app, KeyCode::Esc); press(&mut app, KeyCode::Char('n')); assert_eq!(buffer(&app), Some(""), "a new formula starts empty"); retype(&mut app, "Levy = Revenue * 0.3"); press(&mut app, KeyCode::Enter); assert_eq!( raws(&app), vec![PROFIT, TAX, MARGIN, "Levy = Revenue * 0.3"] ); } /// improvise-kb1d: a Region-targeted formula at index 1. Renaming it would /// strand `Total` as an empty item, so the edit refuses and stays open. #[test] fn renaming_a_formula_outside_measure_refuses_and_keeps_the_editor() { let mut wb = Workbook::new("T"); wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Cost").unwrap(); wb.add_category("Region").unwrap(); wb.add_item("Region", "East").unwrap(); wb.add_item("Region", "West").unwrap(); for (raw, cat) in [ (PROFIT, "_Measure"), ("Total = East + West", "Region"), (MARGIN, "_Measure"), ] { wb.model.add_formula(parse_formula(raw, cat).unwrap()); } let mut app = App::new(wb, None); app.view_state.formula_panel_open = true; app.view_state.mode = AppMode::FormulaPanel; app.view_state.formula_cursor = 1; let regions = |app: &App| { let region = app.model_state.workbook.model.category("Region").unwrap(); region .ordered_item_names() .iter() .map(|s| s.to_string()) .collect::>() }; let before = regions(&app); open_editor(&mut app); retype(&mut app, "Sum = East + West"); press(&mut app, KeyCode::Enter); assert_eq!(raws(&app), vec![PROFIT, "Total = East + West", MARGIN]); assert_eq!(regions(&app), before, "no ghost item, nothing appended"); assert!(in_editor(&app), "{:?}", app.view_state.mode); assert_eq!(buffer(&app), Some("Sum = East + West")); } } /// Where `T` opens inside a drill, where `_Measure` is on Filter and so is no tile /// (improvise-h3cv). `context/test-evidence/command.md`. #[cfg(test)] mod drill_tile_select { use super::*; use crate::command::key::{KeyCode, KeyEvent, KeyModifiers}; use crate::model::cell::{CellKey, CellValue}; use crate::view::Axis; use crate::workbook::Workbook; fn drilled(with_label: bool) -> App { let mut wb = Workbook::new("Drill"); wb.add_category("Region").unwrap(); wb.add_item("Region", "North").unwrap(); wb.add_item("_Measure", "Revenue").unwrap(); if with_label { wb.add_label_category("Note").unwrap(); } wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.model.set_cell( CellKey::new(vec![ ("Region".into(), "North".into()), ("_Measure".into(), "Revenue".into()), ]), CellValue::Number(5.0), ); let mut app = App::new(wb, None); let key = |c| KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE); app.handle_key(key('>')).expect("drill"); assert_eq!(app.view().axis_of("_Measure"), Axis::Filter, "a real drill"); app.handle_key(key('T')).expect("tile select"); app } fn opened_on(app: &App) -> String { let names = app.model_state.workbook.model.category_names(); let tiles = app.view().tileable_cats(&names); tiles[app.view_state.tile_cat_idx].to_string() } #[test] fn in_a_drill_tile_select_opens_on_a_label_not_on_index() { assert_eq!(opened_on(&drilled(true)), "Note"); } #[test] fn in_a_drill_with_nothing_better_tile_select_opens_on_index() { assert_eq!(opened_on(&drilled(false)), "_Index"); } } /// A `NonItem` cell reached through the TUI's own keys (improvise-yc9r). #[cfg(test)] mod non_item_key_tests { use super::*; use crate::model::cell::{CellKey, CellValue}; use crate::view::Axis; /// Region on page at `page`, Month (Jan) on rows, one measure: East/Jan = 1. fn paged_app(page: &str) -> App { let mut wb = Workbook::new("Paged"); wb.add_category("Region").unwrap(); wb.add_category("Month").unwrap(); wb.add_item("Region", "East").unwrap(); wb.add_item("Month", "Jan").unwrap(); wb.add_item("_Measure", "Revenue").unwrap(); let view = wb.active_view_mut(); view.set_axis("Region", Axis::Page); view.set_axis("Month", Axis::Row); view.set_axis("_Measure", Axis::Column); view.set_page_selection("Region", page); wb.model.set_cell( CellKey::new(vec![ ("Month".into(), "Jan".into()), ("Region".into(), "East".into()), ("_Measure".into(), "Revenue".into()), ]), CellValue::Number(1.0), ); App::new(wb, None) } fn press(app: &mut App, keys: &str) { for c in keys.chars() { let code = match c { '\n' => KeyCode::Enter, c => KeyCode::Char(c), }; app.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) .unwrap(); } } /// Every stored key naming `Region=region`, however it got there. fn stored_under(app: &App, region: &str) -> usize { let model = &app.model_state.workbook.model; model .data .iter_cells() .filter(|(key, _)| key.get("Region") == Some(region)) .count() } fn refusal(item: &str) -> String { improvise_protocol::non_item_refusal("Region", item) } /// BUG (improvise-yc9r) — see `context/test-evidence/command.md`. #[test] fn i_on_a_non_item_cell_refuses_at_open_and_says_why() { let mut app = paged_app("Mars"); press(&mut app, "i"); assert_eq!(app.view_state.mode, AppMode::Normal, "no editor was opened"); assert_eq!(app.view_state.status_msg, refusal("Mars")); } /// improvise-yc9r: `p` on a non-item cell writes no orphan. Evidence file. #[test] fn p_on_a_non_item_cell_writes_nothing() { let mut app = paged_app("Mars"); app.view_state.yanked = Some(CellValue::Number(7.0)); press(&mut app, "p"); assert_eq!( stored_under(&app, "Mars"), 0, "an orphan Mars cell was written" ); let status = &app.view_state.status_msg; assert!(status.ends_with(&refusal("Mars")), "{status}"); } /// improvise-yc9r: an editor opened past `i` (`:enter-mode editing`) still /// refuses its commit rather than writing an orphan. Evidence file. #[test] fn a_commit_on_a_non_item_cell_writes_no_orphan() { let mut app = paged_app("Mars"); press(&mut app, ":enter-mode editing\n"); assert!( app.view_state.mode.is_editing(), "setup: the editor is open" ); press(&mut app, "5\n"); assert_eq!( stored_under(&app, "Mars"), 0, "the commit wrote an orphan Mars cell" ); let status = &app.view_state.status_msg; assert!(status.ends_with(&refusal("Mars")), "{status}"); } /// `uneditable` declines a non-item cell, so an advance re-opens no editor /// on one (#265 meets #277). Evidence: `context/test-evidence/command.md`. #[test] fn an_advance_re_opens_no_editor_on_a_non_item_cell() { use crate::ui::effect::Effect; let reopen = crate::ui::effect::EnterEditAtCursor { target_mode: AppMode::editing(), }; let mut control = paged_app("East"); reopen.apply(&mut control); assert!( control.view_state.mode.is_editing(), "control: a live page re-opens" ); let mut app = paged_app("Mars"); reopen.apply(&mut app); assert_eq!(app.view_state.mode, AppMode::Normal, "no editor re-opened"); assert_eq!(app.view_state.status_msg, refusal("Mars")); } } #[cfg(test)] mod snapshot_restore { use super::*; fn every_field(app: &App) -> Vec<(&'static str, String)> { vec![ ("workbook", format!("{:?}", app.model_state.workbook)), ("dirty", format!("{:?}", app.model_state.dirty)), ( "document_generation", format!("{:?}", app.document_generation), ), ("layout_version", format!("{:?}", app.layout_version)), ("cursor_epoch", format!("{:?}", app.cursor_epoch)), ("view_state", format!("{:?}", app.view_state)), ("abort_effects", format!("{:?}", app.abort_effects)), ("batch_aborted", format!("{:?}", app.batch_aborted)), ("batch_refused", format!("{:?}", app.batch_refused)), ( "last_reshaped_views", format!("{:?}", app.last_reshaped_views), ), ( "last_reframed_views", format!("{:?}", app.last_reframed_views), ), ( "written_view_frames", format!("{:?}", app.written_view_frames), ), ( "last_reframed_elsewhere", format!("{:?}", app.last_reframed_elsewhere), ), ] } fn write_every_field(app: &mut App) { app.model_state.workbook.add_category("Region").unwrap(); app.model_state.dirty = !app.model_state.dirty; app.document_generation += 1; app.layout_version += 1; app.cursor_epoch += 1; app.view_state.mode = AppMode::Help; app.cursor_mut().selected = (3, 2); app.abort_effects = !app.abort_effects; app.batch_aborted = !app.batch_aborted; app.batch_refused = !app.batch_refused; app.last_reshaped_views.insert("Moved".into()); app.last_reframed_views.insert("Moved".into()); app.written_view_frames.insert("Moved".into(), 7); app.last_reframed_elsewhere.insert("Moved".into()); } #[test] fn restore_puts_back_every_field_the_snapshot_took() { let mut app = App::new(Workbook::new("T"), None); let before = every_field(&app); let snapshot = app.snapshot(); write_every_field(&mut app); for ((name, was), (_, now)) in before.iter().zip(every_field(&app)) { assert_ne!(*was, now, "the fixture did not move `{name}`"); } app.restore(snapshot); for ((name, was), (_, now)) in before.iter().zip(every_field(&app)) { assert_eq!(*was, now, "restore left `{name}` as the frame wrote it"); } } #[test] fn restore_puts_back_the_mode_and_cursor_not_only_the_view_name_and_status() { let mut app = App::new(Workbook::new("T"), None); let snapshot = app.snapshot(); app.view_state.mode = AppMode::Help; app.cursor_mut().selected = (3, 2); app.restore(snapshot); assert_eq!(app.view_state.mode, AppMode::Normal); assert_eq!(app.cursor().selected, (0, 0)); } }