44718e8ab3
# Conflicts: # context/test-evidence/formula-and-io.md
5591 lines
216 KiB
Rust
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(®istry, LINE).is_ok(),
|
|
"setup: an unparseable line settles through ChangeMode instead, \
|
|
which is the arm the sibling test already covers"
|
|
);
|
|
assert!(
|
|
app.layout.is_records_mode(),
|
|
"setup: the line ran over a records layout"
|
|
);
|
|
assert_eq!(
|
|
app.view_state.mode,
|
|
AppMode::RecordsNormal,
|
|
"a run `:` line settled into plain Normal (improvise-sihr)"
|
|
);
|
|
}
|
|
|
|
/// A failing `:` command must not strand the user in `CommandMode`: tidy-up
|
|
/// goes at the head, where an abort cannot reach it (gotcha 21).
|
|
#[test]
|
|
fn a_failed_colon_write_lands_in_normal_where_its_error_is_drawn() {
|
|
for (line, expect) in [
|
|
("w", "No file path"),
|
|
("w /nonexistent-dir-improvise-lae/m.improv", "Save error:"),
|
|
] {
|
|
let mut app = App::new(Workbook::new("T"), None);
|
|
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
|
|
.unwrap();
|
|
for c in line.chars() {
|
|
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
|
|
.unwrap();
|
|
}
|
|
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
|
.unwrap();
|
|
|
|
assert!(
|
|
app.view_state.status_msg.contains(expect),
|
|
":{line} should report {expect:?}, got {:?}",
|
|
app.view_state.status_msg
|
|
);
|
|
assert_eq!(
|
|
app.view_state.mode,
|
|
AppMode::Normal,
|
|
":{line} must leave the command line, or the status it just \
|
|
set is hidden behind the minibuffer"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The other half of the same rule: the abort must still stop the *chain*.
|
|
/// `:w <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));
|
|
}
|
|
}
|