Files
improvise/crates/improvise-command/src/ui/app.rs
T
Claude (agent-hier) 44718e8ab3 Merge commit 'c91cc055c8ad81afd8f055b71c74af6819020637' into feat/felo6-group-parents
# Conflicts:
#	context/test-evidence/formula-and-io.md
2026-09-24 22:42:49 -07:00

5591 lines
216 KiB
Rust

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::{
compute_col_widths, compute_row_header_width, compute_visible_cols, 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<String> = 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::<Vec<_>>()
})
.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<String, u64> {
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<String, u64>,
after: &std::collections::BTreeMap<String, u64>,
) -> std::collections::BTreeSet<String> {
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<String, u64> {
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<String, std::collections::HashSet<String>>,
) -> 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<RecordHash, u64> {
use std::hash::{Hash, Hasher};
let mut out: std::collections::HashMap<RecordHash, u64> = 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<std::collections::HashMap<String, Option<GridLayout>>>,
}
#[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<usize> = const { std::cell::Cell::new(0) };
}
impl Grids<'_> {
/// See [`App::resolve_record`].
pub fn resolve_record(&self, view: &str, hash: RecordHash) -> Option<CellKey> {
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<CellKey> {
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<CellKey> {
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<String> {
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<String> {
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<T>(&self, view: &str, f: impl FnOnce(&GridLayout) -> T) -> Option<T> {
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<Item = (usize, usize, CellKey)> + '_ {
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<CellKey> {
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<String> {
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<Vec<crate::view::RecordRow>>,
/// 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<PathBuf>,
pub dirty: bool,
}
impl Default for ModelState {
fn default() -> Self {
Self {
workbook: Workbook::new("Untitled"),
file_path: None,
dirty: false,
}
}
}
/// 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<ImportWizard>,
/// **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<DrillState>,
/// **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()`. Owned by `App` because the rebuild is the host's job.
pub layout: GridLayout,
/// **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<String>,
/// 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<String>,
/// 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<String, u64>,
/// 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<String>,
/// **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<dyn crate::storage::Storage>,
}
/// 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<String>,
last_reframed_views: std::collections::BTreeSet<String>,
written_view_frames: std::collections::BTreeMap<String, u64>,
last_reframed_elsewhere: std::collections::BTreeSet<String>,
}
impl App {
/// Build an app backed by the real filesystem.
pub fn new(workbook: Workbook, file_path: Option<PathBuf>) -> 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<PathBuf>,
storage: Box<dyn crate::storage::Storage>,
) -> 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,
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.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;
// 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<String>) {
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<CellKey> {
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<CellKey> {
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<String> {
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<CellKey> {
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<String> {
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<String> {
&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<String> {
&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<String> {
&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);
}
/// 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<String> {
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 model = &self.model_state.workbook.model;
let col_widths = compute_col_widths(model, layout, &FormatPlan::of(model, view));
let row_header_width = compute_row_header_width(layout);
compute_visible_cols(
&col_widths,
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<Box<dyn super::effect::Effect>>) -> 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.rebuild_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<std::collections::BTreeMap<String, u64>>) {
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<RecordHash, u64>) {
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<RecordHash> = 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<Arc<Keymap>> {
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<Arc<Keymap>> {
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.rebuild_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<PathBuf> = &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<T: Send>() {}
assert_send::<ViewState>();
};
/// 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<CellValue> = &app.view_state.yanked;
let _: usize = app.view_state.tile_cat_idx;
let _: &Vec<ViewFrame> = &app.view_state.view_back_stack;
let _: &Vec<ViewFrame> = &app.view_state.view_forward_stack;
let _: usize = app.view_state.help_page;
let _: &std::collections::HashSet<String> = &app.view_state.expanded_cats;
let _: &HashMap<String, String> = &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<String> {
(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<ImportWizard> = &app.wizard;
let _: &Option<DrillState> = &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-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.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.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.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.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<String> = (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::<Vec<_>>()
);
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<String> = (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<String> = (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<String> = (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<String> = (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(&registry, 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 <bad> . 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<String> {
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::<Vec<_>>()
);
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<Box<dyn Effect>> = vec![
Box::new(SetNumberFormat(ViewTarget::Active, ",.2f".into())),
Box::new(Save),
];
let data: Vec<Box<dyn Effect>> = 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<String> {
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::<Vec<_>>()
};
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));
}
}