4f225e88fa
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
4263 lines
165 KiB
Rust
4263 lines
165 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};
|
|
use crate::view::{GridLayout, View};
|
|
use crate::workbook::Workbook;
|
|
use improvise_core::model::record::{AggregateHash, RecordHash};
|
|
use improvise_protocol::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, not `Category::groups`: `add_item_in_group` does not
|
|
// populate that, and a group with nothing drawn under it is not shape.
|
|
let mut collapsed: Vec<&str> = model
|
|
.category(cat)
|
|
.into_iter()
|
|
.flat_map(|c| drawn.iter().filter_map(|item| c.items.get(item)))
|
|
.filter_map(|item| item.group.as_deref())
|
|
.filter(|group| {
|
|
view.collapsed_groups
|
|
.get(cat)
|
|
.is_some_and(|c| c.contains(*group))
|
|
})
|
|
.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),
|
|
view,
|
|
)
|
|
}
|
|
|
|
/// 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, view: &View) -> 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 {
|
|
cat_name,
|
|
group_name,
|
|
} => {
|
|
0u8.hash(&mut h);
|
|
cat_name.hash(&mut h);
|
|
group_name.hash(&mut h);
|
|
view.is_group_collapsed(cat_name, group_name).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 => 2u8.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);
|
|
sorted_pairs(&view.collapsed_groups).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
|
|
}
|
|
|
|
/// 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,
|
|
/// **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,
|
|
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)
|
|
};
|
|
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,
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// 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();
|
|
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
|
|
}
|
|
|
|
/// 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,
|
|
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.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, layout second — see `context/code/command.md`.
|
|
pub fn resolve_record(&self, hash: RecordHash) -> Option<CellKey> {
|
|
let stored = self
|
|
.model_state
|
|
.workbook
|
|
.model
|
|
.data
|
|
.resolve_record(hash)
|
|
.cloned();
|
|
stored.or_else(|| self.record_in_layout(hash))
|
|
}
|
|
|
|
/// The full cell key a [`RecordCell`] names: its record, plus the
|
|
/// measure coordinate that picks one of the record's cells.
|
|
pub fn resolve_cell(&self, cell: &RecordCell) -> Option<CellKey> {
|
|
let record = self.resolve_record(cell.record)?;
|
|
Some(match &cell.measure {
|
|
Some(measure) => record.with(crate::model::category::MEASURE_CATEGORY, measure),
|
|
None => record,
|
|
})
|
|
}
|
|
|
|
/// 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> {
|
|
self.layout_cell_keys()
|
|
.find(|key| AggregateHash::of(key) == hash)
|
|
}
|
|
|
|
/// The record of some cell the current layout names, if any names it.
|
|
fn record_in_layout(&self, hash: RecordHash) -> Option<CellKey> {
|
|
self.layout_cell_keys()
|
|
.find(|key| RecordHash::of(key) == hash)
|
|
.map(|key| record_of(&key))
|
|
}
|
|
|
|
/// Every cell key the current grid names. Records mode is excluded: its
|
|
/// `cell_key` carries *positions*, the trap ADR 002 rejected `_Index` for.
|
|
fn layout_cell_keys(&self) -> impl Iterator<Item = CellKey> + '_ {
|
|
let layout = &self.layout;
|
|
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)))
|
|
}
|
|
|
|
/// Point **this client's** slice at `name`, if the document has such a view.
|
|
/// The document's own default stays put (improvise-jytm, gotcha 29).
|
|
pub fn switch_to_view(&mut self, name: &str) {
|
|
if self.model_state.workbook.views.contains_key(name) {
|
|
self.view_state.active_view = name.to_string();
|
|
}
|
|
}
|
|
|
|
/// 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, view),
|
|
// 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.resolve_display(key))
|
|
.unwrap_or_default(),
|
|
None => self
|
|
.model_state
|
|
.workbook
|
|
.model
|
|
.get_cell(key)
|
|
.map(|v| v.to_string())
|
|
.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,
|
|
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.term_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;
|
|
// 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.
|
|
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.wizard = Some(ImportWizard::new(json));
|
|
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" }
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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"
|
|
);
|
|
}
|
|
|
|
/// 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"
|
|
);
|
|
}
|
|
}
|
|
}
|