Merge pull request #41 from fiddlerwoaroof/comments/command
docs(command): move improvise-command's reasoning out of comments into context/
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -114,8 +114,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Cell operations ──────────────────────────────────────────────────────────
|
// ── Cell operations ──────────────────────────────────────────────────────────
|
||||||
// All cell commands take an explicit CellKey. The interactive spec fills it
|
// All cell commands take an explicit CellKey; see `context/code/command.md`.
|
||||||
// from ctx.cell_key(); the parser fills it from Cat/Item coordinate args.
|
|
||||||
|
|
||||||
/// Clear a cell.
|
/// Clear a cell.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
@@ -64,13 +64,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-6t3: the commit pushed `set_status("Formula added")` *after*
|
/// improvise-6t3: `set_status("Formula added")` after `AddFormula` overwrote the
|
||||||
/// `AddFormula`, so the parse error that `AddFormula::apply` writes to the
|
/// parse error, so an unparseable formula was dropped and reported a success.
|
||||||
/// status bar was immediately overwritten. An unparseable formula was
|
|
||||||
/// dropped while the UI reported success.
|
|
||||||
///
|
|
||||||
/// The add effect is the only party that knows whether it worked, so it has
|
|
||||||
/// to get the last word on the status line.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_formula_reports_the_parse_error_not_success() {
|
fn invalid_formula_reports_the_parse_error_not_success() {
|
||||||
let m = two_cat_model();
|
let m = two_cat_model();
|
||||||
@@ -158,9 +153,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `CommitAndAdvance` must thread its `edit_mode` through to the
|
/// `CommitAndAdvance` threads its `edit_mode` to the trailing
|
||||||
/// trailing `EnterEditAtCursor` effect so the post-commit re-edit lands
|
/// `EnterEditAtCursor`; the command never reads `ctx.mode`.
|
||||||
/// in the mode the keymap requested. The command never reads ctx.mode.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn commit_and_advance_threads_edit_mode_to_enter_edit_at_cursor() {
|
fn commit_and_advance_threads_edit_mode_to_enter_edit_at_cursor() {
|
||||||
let m = two_cat_model();
|
let m = two_cat_model();
|
||||||
@@ -203,14 +197,8 @@ mod tests {
|
|||||||
assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}");
|
assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-lae: the save prompt has to commit to `SaveAs` and not to
|
/// improvise-lae: the save prompt commits to `SaveAs`, not `ExportCsv` (wrong
|
||||||
/// `ExportCsv`, which is what makes it a separate mode from
|
/// format) and not `Save` (there is no path yet).
|
||||||
/// `ExportPrompt` rather than a second binding onto it — `ExportCsv`
|
|
||||||
/// would write CSV under whatever `.improv` name the user typed.
|
|
||||||
///
|
|
||||||
/// `SaveAs` and not `Save`, because `Save` writes the path the document
|
|
||||||
/// already has, and the whole reason this prompt exists is that there
|
|
||||||
/// isn't one.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn commit_save_writes_the_model_to_the_typed_path() {
|
fn commit_save_writes_the_model_to_the_typed_path() {
|
||||||
let m = two_cat_model();
|
let m = two_cat_model();
|
||||||
@@ -232,9 +220,8 @@ mod tests {
|
|||||||
assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}");
|
assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An empty prompt must not reach `SaveAs`: `SaveAs(PathBuf::from(""))`
|
/// An empty prompt must not reach `SaveAs`: the user would read an I/O error
|
||||||
/// asks the backend to write to `""`, and the user then reads whatever
|
/// about `""` instead of "you typed nothing".
|
||||||
/// I/O error that produces instead of "you typed nothing".
|
|
||||||
#[test]
|
#[test]
|
||||||
fn commit_save_on_an_empty_path_writes_nothing() {
|
fn commit_save_on_an_empty_path_writes_nothing() {
|
||||||
let m = two_cat_model();
|
let m = two_cat_model();
|
||||||
@@ -255,9 +242,8 @@ mod tests {
|
|||||||
|
|
||||||
// ── Commit commands (mode-specific buffer consumers) ────────────────────────
|
// ── Commit commands (mode-specific buffer consumers) ────────────────────────
|
||||||
|
|
||||||
/// Commit a cell value: for synthetic records keys, stage in drill pending edits
|
/// Commit a cell value: a synthetic records key stages into the drill or applies
|
||||||
/// in drill mode, or apply directly in plain records mode; for real keys, write
|
/// directly in plain records mode; a real key writes to the model (gotcha 19).
|
||||||
/// to the model.
|
|
||||||
fn commit_regular_cell_value(key: &CellKey, value: &str, effects: &mut Vec<Box<dyn Effect>>) {
|
fn commit_regular_cell_value(key: &CellKey, value: &str, effects: &mut Vec<Box<dyn Effect>>) {
|
||||||
if value.is_empty() {
|
if value.is_empty() {
|
||||||
effects.push(Box::new(effect::ClearCell(key.clone())));
|
effects.push(Box::new(effect::ClearCell(key.clone())));
|
||||||
@@ -280,9 +266,8 @@ fn stage_drill_edit(record_idx: usize, col: RecordCol, value: &str) -> Box<dyn E
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rename one coordinate of a records row. In wide format a row is a group
|
/// Rename one coordinate of a records row. In wide format a row is a group of cells,
|
||||||
/// of cells — one per measure — so the rename has to move every one of them,
|
/// so the rename moves **every** one of them (improvise-c5v, gotcha 1).
|
||||||
/// not just the cell under the cursor (improvise-c5v).
|
|
||||||
fn rename_record_coordinate(
|
fn rename_record_coordinate(
|
||||||
model: &crate::model::Model,
|
model: &crate::model::Model,
|
||||||
row: &RecordRow,
|
row: &RecordRow,
|
||||||
@@ -299,9 +284,8 @@ fn rename_record_coordinate(
|
|||||||
item: value.to_string(),
|
item: value.to_string(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Every cell in the row moves. A row seeded by `add-record-row` has no
|
// Every cell in the row moves. A row seeded by `add-record-row` carries its
|
||||||
// measure cells yet — its coordinates live on a lone measure-less cell,
|
// coordinates on a lone measure-less cell, which must move too.
|
||||||
// which must move too or the row loses its identity.
|
|
||||||
let cells: Vec<(CellKey, CellValue)> = if row.values.is_empty() {
|
let cells: Vec<(CellKey, CellValue)> = if row.values.is_empty() {
|
||||||
model
|
model
|
||||||
.get_cell(&row.coords)
|
.get_cell(&row.coords)
|
||||||
@@ -345,9 +329,8 @@ fn commit_plain_records_edit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Typing a name into the trailing column creates that measure, which gives
|
/// Typing a name into the trailing column creates that measure. A schema change, so
|
||||||
/// it a real column to type values into. Adding a measure is a schema
|
/// it applies immediately even inside a drill.
|
||||||
/// change, so it applies immediately even inside a drill.
|
|
||||||
fn add_measure(value: &str, effects: &mut Vec<Box<dyn Effect>>) {
|
fn add_measure(value: &str, effects: &mut Vec<Box<dyn Effect>>) {
|
||||||
let name = value.trim();
|
let name = value.trim();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
@@ -359,20 +342,8 @@ fn add_measure(value: &str, effects: &mut Vec<Box<dyn Effect>>) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Typing a name into the `+cat` column creates that category, which gives
|
/// Typing a name into `+cat` creates the category **and its column**, and writes no
|
||||||
/// the row a coordinate column to type into (improvise-2zq).
|
/// coordinate on the current row (improvise-2zq) — see `context/code/command.md`.
|
||||||
///
|
|
||||||
/// It creates the column and **does not set the coordinate on the current
|
|
||||||
/// row**, which is the deliberate answer to the ticket's third question.
|
|
||||||
/// Two reasons. It is symmetric with the measure `+`, which creates an empty
|
|
||||||
/// column and writes no value. And setting it would move *every* measure
|
|
||||||
/// cell in the row onto the new coordinate (gotcha 1) — a data migration
|
|
||||||
/// across N cells attached to the same Enter that made a schema change, in
|
|
||||||
/// an app with no undo. The follow-up edit is one `l` away in the column
|
|
||||||
/// that just appeared.
|
|
||||||
///
|
|
||||||
/// Like `add_measure` this is a schema change, so it applies immediately
|
|
||||||
/// even inside a drill rather than being staged.
|
|
||||||
fn add_category(value: &str, effects: &mut Vec<Box<dyn Effect>>) {
|
fn add_category(value: &str, effects: &mut Vec<Box<dyn Effect>>) {
|
||||||
let name = value.trim();
|
let name = value.trim();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
@@ -405,13 +376,8 @@ fn commit_cell_value(
|
|||||||
commit_regular_cell_value(key, value, effects);
|
commit_regular_cell_value(key, value, effects);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether committing `key` inserts a column, invalidating every column
|
/// Whether committing `key` inserts a column, invalidating every index to its right.
|
||||||
/// index to its right.
|
/// Asked of the *layout*, so there is one answer to "what is this column" (gotcha 1).
|
||||||
///
|
|
||||||
/// Asked of the *layout* — `record_col` resolves the synthetic `_Dim`
|
|
||||||
/// position into what the column actually is — rather than re-derived from
|
|
||||||
/// the value or the cursor, so there is one answer to "what is this column"
|
|
||||||
/// (gotcha 1).
|
|
||||||
fn inserts_a_column(ctx: &CmdContext, key: &CellKey) -> bool {
|
fn inserts_a_column(ctx: &CmdContext, key: &CellKey) -> bool {
|
||||||
crate::view::synthetic_record_info(key)
|
crate::view::synthetic_record_info(key)
|
||||||
.and_then(|(_, col_idx)| ctx.layout.record_col(col_idx))
|
.and_then(|(_, col_idx)| ctx.layout.record_col(col_idx))
|
||||||
@@ -427,9 +393,8 @@ pub enum AdvanceDir {
|
|||||||
Right,
|
Right,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the normal-mode counterpart of an editing mode. Used by
|
/// The normal-mode counterpart of an editing mode, for the mode to land in if
|
||||||
/// `CommitAndAdvance` to compute the mode to land in if the advance
|
/// `CommitAndAdvance`'s advance aborts.
|
||||||
/// aborts (commit + exit editing at boundary).
|
|
||||||
fn exit_mode_for(edit_mode: &AppMode) -> AppMode {
|
fn exit_mode_for(edit_mode: &AppMode) -> AppMode {
|
||||||
match edit_mode {
|
match edit_mode {
|
||||||
AppMode::RecordsEditing { .. } => AppMode::RecordsNormal,
|
AppMode::RecordsEditing { .. } => AppMode::RecordsNormal,
|
||||||
@@ -437,13 +402,8 @@ fn exit_mode_for(edit_mode: &AppMode) -> AppMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Commit a cell edit, advance the cursor, and re-enter edit mode.
|
/// Commit a cell edit, advance the cursor, and re-enter `edit_mode` — which the
|
||||||
/// Subsumes the old `CommitCellEdit` (Down) and `CommitAndAdvanceRight` (Right).
|
/// keymap binding supplies. The command never inspects `ctx.mode`.
|
||||||
///
|
|
||||||
/// `edit_mode` is the editing mode to re-enter after advancing. The keymap
|
|
||||||
/// binding supplies this — the editing-mode keymap passes `editing` and the
|
|
||||||
/// records-editing keymap passes `records-editing`. The command itself
|
|
||||||
/// never inspects `ctx.mode`.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct CommitAndAdvance {
|
pub struct CommitAndAdvance {
|
||||||
pub key: CellKey,
|
pub key: CellKey,
|
||||||
@@ -461,40 +421,13 @@ impl Cmd for CommitAndAdvance {
|
|||||||
}
|
}
|
||||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||||
// Pre-emptively drop to the normal counterpart of edit_mode. If the
|
// Drop to normal **before** the commit, which can abort: tidy-up goes where
|
||||||
// advance succeeds, the trailing `EnterEditAtCursor` below will lift
|
// an abort cannot reach it (improvise-2zq, gotcha 21).
|
||||||
// us back into editing on the new cell. If the advance aborts
|
|
||||||
// (e.g. already at bottom-right on Enter), `EnterEditAtCursor` is
|
|
||||||
// skipped and we land in normal mode — which is the desired
|
|
||||||
// "Enter at bottom-right commits and exits" behavior.
|
|
||||||
//
|
|
||||||
// It goes **before** the commit, not after (improvise-2zq). The
|
|
||||||
// commit can abort the batch itself — `AddCategory` does so when the
|
|
||||||
// document is at `MAX_CATEGORIES` — and an abort before this ran
|
|
||||||
// left the app in the editing mode, where `draw_bottom_bar` renders
|
|
||||||
// the minibuffer instead of the status line and the refusal the
|
|
||||||
// effect had just written was never drawn. Tidy-up goes where an
|
|
||||||
// abort cannot reach it; same rule as `ExecuteCommand`'s.
|
|
||||||
effects.push(effect::change_mode(exit_mode_for(&self.edit_mode)));
|
effects.push(effect::change_mode(exit_mode_for(&self.edit_mode)));
|
||||||
commit_cell_value(ctx, &self.key, &self.value, &mut effects);
|
commit_cell_value(ctx, &self.key, &self.value, &mut effects);
|
||||||
if inserts_a_column(ctx, &self.key) {
|
if inserts_a_column(ctx, &self.key) {
|
||||||
// A schema change that inserts a column moves every index to its
|
// An inserted column shifts every index right of it, and the advance is
|
||||||
// right, and the advance below is computed from `ctx` — the
|
// computed from the *pre-commit* layout (gotcha 23). Staying put lands on it.
|
||||||
// *pre-commit* layout, because `apply_effects` rebuilds only
|
|
||||||
// after the whole batch (gotcha 23). On the last row `Enter`
|
|
||||||
// wraps to `col + 1`, which after the insert is `+cat` again, so
|
|
||||||
// the editor reopened category creation and a user naming two
|
|
||||||
// categories in a row never reached the column they had made.
|
|
||||||
// `Tab` reached it the same way.
|
|
||||||
//
|
|
||||||
// Not advancing is also the *useful* answer rather than merely
|
|
||||||
// the safe one: `add_category` inserts the new column exactly
|
|
||||||
// where the cursor already is, so staying put lands on it.
|
|
||||||
//
|
|
||||||
// The measure `+` shares none of this, and not by luck — it is
|
|
||||||
// the last column, so there is no column to its right for an
|
|
||||||
// index to shift into, and both advances are already special-
|
|
||||||
// cased at the bottom-right edge.
|
|
||||||
effects.push(Box::new(effect::RebuildLayout));
|
effects.push(Box::new(effect::RebuildLayout));
|
||||||
return effects;
|
return effects;
|
||||||
}
|
}
|
||||||
@@ -544,25 +477,21 @@ impl Cmd for CommitAndAdvance {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a minibuffer commit produces. The variants differ only in the model
|
/// What a minibuffer commit produces. The variants differ only in the mutation they
|
||||||
/// mutation they build and in where they leave the user — reading the buffer,
|
/// build and where they leave the user; the rest is [`CommitBuffer`]'s (gotcha 20).
|
||||||
/// trimming it, bailing out when it is empty, marking the model dirty and
|
|
||||||
/// reporting are shared by [`CommitBuffer`].
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum CommitKind {
|
pub enum CommitKind {
|
||||||
Formula,
|
Formula,
|
||||||
Category,
|
Category,
|
||||||
/// Add an item to this category. Resolved from [`AppMode::ItemAdd`] when
|
/// Add an item to this category, resolved from [`AppMode::ItemAdd`] at
|
||||||
/// the command is constructed, so `execute` stays a pure function of the
|
/// construction so `execute` stays pure over the kind and its buffer.
|
||||||
/// kind and its buffer.
|
|
||||||
Item {
|
Item {
|
||||||
category: String,
|
category: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a `commit-buffer` argument against the current mode. `Item` is the
|
/// Resolve a `commit-buffer` argument against the current mode. Only `Item` needs
|
||||||
/// only kind that needs anything from the mode — its target category — so a
|
/// the mode, so a script line can still build the other two.
|
||||||
/// script line (which has no live mode) can build the other two.
|
|
||||||
pub fn parse_commit_kind(arg: &str, mode: &AppMode) -> Result<CommitKind, String> {
|
pub fn parse_commit_kind(arg: &str, mode: &AppMode) -> Result<CommitKind, String> {
|
||||||
match (arg, mode) {
|
match (arg, mode) {
|
||||||
("formula", _) => Ok(CommitKind::Formula),
|
("formula", _) => Ok(CommitKind::Formula),
|
||||||
@@ -594,9 +523,8 @@ impl CommitKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a *successful* commit lands the user. `None` keeps the minibuffer
|
/// Where a *successful* commit lands the user. `None` keeps the minibuffer open:
|
||||||
/// open for the next entry — categories and items are entered in batches,
|
/// categories and items are entered in batches, formulas are not.
|
||||||
/// formulas are not.
|
|
||||||
fn on_commit(&self) -> Option<AppMode> {
|
fn on_commit(&self) -> Option<AppMode> {
|
||||||
match self {
|
match self {
|
||||||
Self::Formula => Some(AppMode::FormulaPanel),
|
Self::Formula => Some(AppMode::FormulaPanel),
|
||||||
@@ -629,9 +557,8 @@ impl CommitKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Commit the contents of a minibuffer as a formula, a category or an item.
|
/// Commit a minibuffer as a formula, a category or an item. Buffer clearing is the
|
||||||
///
|
/// keymap's (Enter → `[commit, clear-buffer]`).
|
||||||
/// Buffer clearing is handled by the keymap (Enter → [commit, clear-buffer]).
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct CommitBuffer {
|
pub struct CommitBuffer {
|
||||||
pub kind: CommitKind,
|
pub kind: CommitKind,
|
||||||
@@ -648,9 +575,8 @@ impl Cmd for CommitBuffer {
|
|||||||
}
|
}
|
||||||
let mut effects: Vec<Box<dyn Effect>> = vec![
|
let mut effects: Vec<Box<dyn Effect>> = vec![
|
||||||
effect::set_status(self.kind.status(name)),
|
effect::set_status(self.kind.status(name)),
|
||||||
// The add goes last on purpose: it is the only party that knows
|
// The add goes last: it is the only party that knows whether it worked,
|
||||||
// whether it worked, so the optimistic status above must not
|
// so the optimistic status must not clobber its error (improvise-6t3).
|
||||||
// clobber the error it reports (improvise-6t3).
|
|
||||||
self.kind.add_effect(name),
|
self.kind.add_effect(name),
|
||||||
];
|
];
|
||||||
effects.extend(self.kind.on_commit().map(effect::change_mode));
|
effects.extend(self.kind.on_commit().map(effect::change_mode));
|
||||||
@@ -658,16 +584,8 @@ impl Cmd for CommitBuffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Commit a save path from the save buffer.
|
/// Commit a save path. The sibling of [`CommitExport`] and deliberately not the same
|
||||||
///
|
/// command — that one writes CSV, this writes the model (improvise-lae).
|
||||||
/// The sibling of [`CommitExport`], and deliberately not the same command:
|
|
||||||
/// the export prompt writes CSV, this one writes the *model* through
|
|
||||||
/// `SaveAs`, which also adopts the path so a later plain `Save` has one
|
|
||||||
/// (improvise-lae).
|
|
||||||
///
|
|
||||||
/// An empty path bails rather than reaching `SaveAs`, which would otherwise
|
|
||||||
/// ask the storage backend to write to `""` and report its error instead of
|
|
||||||
/// the one the user can act on.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct CommitSave;
|
pub struct CommitSave;
|
||||||
impl Cmd for CommitSave {
|
impl Cmd for CommitSave {
|
||||||
@@ -682,23 +600,8 @@ impl Cmd for CommitSave {
|
|||||||
effect::change_mode(AppMode::Normal),
|
effect::change_mode(AppMode::Normal),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
// The mode change comes **first**, and that ordering is the whole
|
// The mode change comes **first**, or a failed `SaveAs` aborts with the
|
||||||
// reason the user sees a failed save (found by Copilot on PR #27).
|
// minibuffer still drawn over its error (Copilot, PR #27).
|
||||||
//
|
|
||||||
// `SaveAs` aborts the batch when the write fails (improvise-8tk's
|
|
||||||
// other arm), so anything after it is skipped. With the order
|
|
||||||
// reversed the batch stopped while still in `SavePrompt` — and
|
|
||||||
// `draw_bottom_bar` renders the minibuffer *instead of* the status
|
|
||||||
// line, so the "Save error:" set by the effect was never drawn. The
|
|
||||||
// save appeared to do nothing at all.
|
|
||||||
//
|
|
||||||
// Leading with the mode change means the error lands in `Normal`,
|
|
||||||
// where the status bar is what the bottom row draws. It costs the
|
|
||||||
// typed path, which the welded `clear-buffer` would have taken on a
|
|
||||||
// success anyway and which `EnterMode` clears on the next Ctrl-W
|
|
||||||
// regardless (gotcha 11) — so nothing durable is lost, and the
|
|
||||||
// convention that the side effect runs last and its message wins is
|
|
||||||
// the one gotcha 20 already states.
|
|
||||||
vec![
|
vec![
|
||||||
effect::change_mode(AppMode::Normal),
|
effect::change_mode(AppMode::Normal),
|
||||||
Box::new(effect::SaveAs(std::path::PathBuf::from(path))),
|
Box::new(effect::SaveAs(std::path::PathBuf::from(path))),
|
||||||
|
|||||||
@@ -10,11 +10,8 @@ use crate::ui::effect::{Effect, Panel};
|
|||||||
use crate::view::{Axis, GridLayout, View};
|
use crate::view::{Axis, GridLayout, View};
|
||||||
use crate::workbook::Workbook;
|
use crate::workbook::Workbook;
|
||||||
|
|
||||||
/// Read-only context available to commands for decision-making.
|
/// Read-only context available to commands for decision-making: a `&Model`, the
|
||||||
///
|
/// active `&View`, and the `&Workbook` for the rare command that enumerates views.
|
||||||
/// Commands receive a `&Model` (pure data) and a `&View` (the active view).
|
|
||||||
/// The full `&Workbook` is also available for the rare command that needs
|
|
||||||
/// to enumerate all views (e.g. the view panel).
|
|
||||||
pub struct CmdContext<'a> {
|
pub struct CmdContext<'a> {
|
||||||
pub model: &'a Model,
|
pub model: &'a Model,
|
||||||
pub workbook: &'a Workbook,
|
pub workbook: &'a Workbook,
|
||||||
@@ -118,10 +115,8 @@ pub trait Cmd: Debug + Send + Sync {
|
|||||||
/// Factory that constructs a Cmd from text arguments (headless/script).
|
/// Factory that constructs a Cmd from text arguments (headless/script).
|
||||||
pub type ParseFn = fn(&[String]) -> Result<Box<dyn Cmd>, String>;
|
pub type ParseFn = fn(&[String]) -> Result<Box<dyn Cmd>, String>;
|
||||||
|
|
||||||
/// Factory that constructs a Cmd from the interactive context (keymap dispatch).
|
/// Factory constructing a Cmd from the interactive context. It takes both the keymap
|
||||||
/// Receives both the keymap args and the interactive context so commands can
|
/// args and the context, so a command can combine text args with runtime state.
|
||||||
/// combine text arguments (e.g. panel name) with runtime state (e.g. whether
|
|
||||||
/// the panel is currently open).
|
|
||||||
pub type InteractiveFn = fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, String>;
|
pub type InteractiveFn = fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, String>;
|
||||||
|
|
||||||
type BoxParseFn = Box<dyn Fn(&[String]) -> Result<Box<dyn Cmd>, String>>;
|
type BoxParseFn = Box<dyn Fn(&[String]) -> Result<Box<dyn Cmd>, String>>;
|
||||||
@@ -174,9 +169,8 @@ impl CmdRegistry {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a command that doesn't need interactive context.
|
/// Register a command that needs no interactive context: with args it delegates
|
||||||
/// When called interactively with args, delegates to parse.
|
/// to `parse`, and without them it errors.
|
||||||
/// When called interactively without args, returns an error.
|
|
||||||
pub fn register_pure(&mut self, prototype: &dyn Cmd, parse: ParseFn) {
|
pub fn register_pure(&mut self, prototype: &dyn Cmd, parse: ParseFn) {
|
||||||
self.entries.push(CmdEntry {
|
self.entries.push(CmdEntry {
|
||||||
name: prototype.name(),
|
name: prototype.name(),
|
||||||
@@ -213,9 +207,8 @@ impl CmdRegistry {
|
|||||||
Err(format!("Unknown command: {name}"))
|
Err(format!("Unknown command: {name}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construct a command from interactive context (keymap dispatch).
|
/// Construct a command from interactive context, always with both args and
|
||||||
/// Always calls the interactive constructor with both args and ctx,
|
/// `ctx`, so commands can combine text arguments with runtime state.
|
||||||
/// so commands can combine text arguments with runtime state.
|
|
||||||
pub fn interactive(
|
pub fn interactive(
|
||||||
&self,
|
&self,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -269,13 +262,8 @@ pub(super) fn read_buffer(ctx: &CmdContext, name: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The effect that writes a named buffer — the mirror of [`read_buffer`],
|
/// The effect that writes a named buffer — the mirror of [`read_buffer`], and the
|
||||||
/// and the only place that knows "search" is not an entry in the buffers map
|
/// only place that knows "search" is not in the buffers map (improvise-gdl).
|
||||||
/// but its own `ViewState` field (improvise-gdl).
|
|
||||||
///
|
|
||||||
/// The knowledge used to sit inside the `SetBuffer` *effect*, where nothing
|
|
||||||
/// paired it with the read side and every caller had to know that passing
|
|
||||||
/// `"search"` meant something else than it said.
|
|
||||||
pub(super) fn write_buffer(name: &str, value: String) -> Box<dyn Effect> {
|
pub(super) fn write_buffer(name: &str, value: String) -> Box<dyn Effect> {
|
||||||
if name == "search" {
|
if name == "search" {
|
||||||
Box::new(crate::ui::effect::SetSearchQuery(value))
|
Box::new(crate::ui::effect::SetSearchQuery(value))
|
||||||
|
|||||||
@@ -83,8 +83,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Parseable model-mutation commands ────────────────────────────────────────
|
// ── Parseable model-mutation commands ────────────────────────────────────────
|
||||||
// These are thin Cmd wrappers around effects, constructible from string args.
|
// Thin Cmd wrappers around effects, on the same path as keymap dispatch.
|
||||||
// They share the same execution path as keymap-dispatched commands.
|
|
||||||
|
|
||||||
macro_rules! effect_cmd {
|
macro_rules! effect_cmd {
|
||||||
($name:ident, $cmd_name:expr, $parse:expr, $exec:expr) => {
|
($name:ident, $cmd_name:expr, $parse:expr, $exec:expr) => {
|
||||||
|
|||||||
@@ -1,62 +1,5 @@
|
|||||||
//! `fit-measure`: evolve a decision tree over what the active view shows and
|
//! `fit-measure`: evolve a decision tree over the active view and store it as an
|
||||||
//! store it as an ordinary improvise formula.
|
//! ordinary `## Formulas` line. The argument is in `context/code/command.md`.
|
||||||
//!
|
|
||||||
//! After this command there is nothing GP-specific left in the document — the
|
|
||||||
//! rule is a `## Formulas` line like any other, recomputed by the fixed-point
|
|
||||||
//! evaluator, editable in the formula panel, drillable, and saved with no new
|
|
||||||
//! grammar (improvise-21d's enabling fact, and improvise-7na's answer).
|
|
||||||
//!
|
|
||||||
//! # The fit runs inside `execute`, and that is the architecture rather than a
|
|
||||||
//! shortcut
|
|
||||||
//!
|
|
||||||
//! A `Cmd` is pure over `&CmdContext` and returns effects; an `Effect` is the
|
|
||||||
//! only mutation site. A fit is **decision logic** — a pure function of
|
|
||||||
//! `(Model, View, target, seed, FitConfig)` that mutates nothing — so it
|
|
||||||
//! belongs on the command side, and what the effects carry is its *result*.
|
|
||||||
//! Putting the search inside an `Effect::apply` would have mixed an expensive
|
|
||||||
//! stochastic computation into the one place the app mutates, which is the
|
|
||||||
//! split's whole point.
|
|
||||||
//!
|
|
||||||
//! What this does **not** buy is asynchrony. The command runs on the thread
|
|
||||||
//! that draws the next frame, so a fit is bounded rather than cancellable:
|
|
||||||
//! [`FitRequest::default`] is a deliberately smaller search than the CLI's
|
|
||||||
//! (improvise-vx3's AC 4 asks for "cancellable **or** bounded"). Moving it off
|
|
||||||
//! the main loop is a change to `draw.rs`'s event loop, not to this file, and
|
|
||||||
//! nothing here forecloses it — the fit is already a pure function that could
|
|
||||||
//! be handed to a worker and have its `FitOutcome` posted back as the same
|
|
||||||
//! effects.
|
|
||||||
//!
|
|
||||||
//! # What a refusal is for
|
|
||||||
//!
|
|
||||||
//! `improvise_gp::fit_measure` fails rather than storing something misleading
|
|
||||||
//! — most sharply when the champion splits on an encoding artefact like
|
|
||||||
//! `Region=North`, which is not an item of anyone's document, so every cell of
|
|
||||||
//! the stored measure would be `Error("ref:…")` (improvise-8xhy, repo-map
|
|
||||||
//! gotcha 37d). This command is where a user actually meets that restriction,
|
|
||||||
//! so the refusal goes to the status line **whole**: it names the features that
|
|
||||||
//! could not be resolved, because "the fit failed" is not something a user can
|
|
||||||
//! act on and "it split on Region, which is a category rather than a measure"
|
|
||||||
//! is.
|
|
||||||
//!
|
|
||||||
//! # What registering it costs the browser, measured
|
|
||||||
//!
|
|
||||||
//! `improvise-command` is in the **worker** bundle, and the path from
|
|
||||||
//! `App::new` → `default_keymaps` → `default_registry` → this command's
|
|
||||||
//! prototype is live, so `gp-tree` links into it whether or not a browser ever
|
|
||||||
//! invokes `fit-measure`. `kb.pl` names keeping GP out of the wasm bundle as
|
|
||||||
//! `improvise-gp`'s reason to exist, so this is a cost to state rather than to
|
|
||||||
//! discover: built with `wasm-pack --release`, `improvise_worker_server_bg.wasm`
|
|
||||||
//! goes **1,341,130 → 1,420,470 bytes** (+79 KB, +5.9%), or **498 → 531 KB
|
|
||||||
//! gzipped** (+6.5%). `improvise-wasm-client` — the main-thread bundle — does
|
|
||||||
//! not depend on `improvise-command` at all and is **unchanged**.
|
|
||||||
//!
|
|
||||||
//! Accepted rather than feature-gated, for two reasons. The worker already
|
|
||||||
//! carries the whole `App`, persistence and the import wizard, so this is a
|
|
||||||
//! 6% addition to something an order of magnitude larger; and a browser fit is
|
|
||||||
//! a *wanted* end state, not dead weight — `improvise_gp::fit` exists where it
|
|
||||||
//! does precisely so a second host can run the same sequence. If the bundle
|
|
||||||
//! ever needs shrinking, a default-on cargo feature that `web/build.sh` turns
|
|
||||||
//! off is the lever, and this paragraph is the number to beat.
|
|
||||||
|
|
||||||
use improvise_gp::fit::one_line;
|
use improvise_gp::fit::one_line;
|
||||||
use improvise_gp::{FitRequest, fit_measure, fitted_measure_name};
|
use improvise_gp::{FitRequest, fit_measure, fitted_measure_name};
|
||||||
@@ -64,11 +7,8 @@ use improvise_gp::{FitRequest, fit_measure, fitted_measure_name};
|
|||||||
use crate::command::cmd::core::{Cmd, CmdContext};
|
use crate::command::cmd::core::{Cmd, CmdContext};
|
||||||
use crate::ui::effect::{self, Effect};
|
use crate::ui::effect::{self, Effect};
|
||||||
|
|
||||||
/// A seed as the status line spells it (`0x…`, either case) or in decimal.
|
/// A seed as the status line spells it (`0x…`, either case) or in decimal. The hex
|
||||||
///
|
/// arm is not a convenience — `status_line` prints `{:#x}`.
|
||||||
/// The hex arm is not a convenience: `FitOutcome::status_line` prints `{:#x}`
|
|
||||||
/// and tells the user that is what reproduces the fit, so accepting only
|
|
||||||
/// decimal made the advertised round trip fail on the advertised value.
|
|
||||||
fn parse_seed(text: &str) -> Result<u64, String> {
|
fn parse_seed(text: &str) -> Result<u64, String> {
|
||||||
let parsed = match text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
|
let parsed = match text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
|
||||||
Some(hex) => u64::from_str_radix(hex, 16),
|
Some(hex) => u64::from_str_radix(hex, 16),
|
||||||
@@ -97,16 +37,8 @@ impl FitMeasure {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `fit-measure <target> [<measure-name>] [<seed>]`.
|
/// `fit-measure <target> [<measure-name>] [<seed>]`. The seed is an argument, not
|
||||||
///
|
/// a clock read, and is read in the form it is printed (gotcha 37b).
|
||||||
/// The seed is an argument rather than a clock read for the reason the CLI
|
|
||||||
/// fixes its own default: two fits of the same document should agree, and a
|
|
||||||
/// user who wants a different search asks for one. It is reported back on
|
|
||||||
/// the status line so the fit that landed can be asked for again — **which
|
|
||||||
/// is why it is read in the form it is printed**, hexadecimal with a `0x`
|
|
||||||
/// prefix, as well as decimal. `status_line` spells it `{:#x}`, so a
|
|
||||||
/// decimal-only parser made the one instruction the status line gives a
|
|
||||||
/// user fail on the value the status line just showed them.
|
|
||||||
pub fn parse(args: &[String]) -> Result<Box<dyn Cmd>, String> {
|
pub fn parse(args: &[String]) -> Result<Box<dyn Cmd>, String> {
|
||||||
let [target, rest @ ..] = args else {
|
let [target, rest @ ..] = args else {
|
||||||
return Err("fit-measure requires a target measure".into());
|
return Err("fit-measure requires a target measure".into());
|
||||||
@@ -140,9 +72,8 @@ impl Cmd for FitMeasure {
|
|||||||
|
|
||||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
match fit_measure(ctx.model, ctx.view, &self.target, &self.into, &self.request) {
|
match fit_measure(ctx.model, ctx.view, &self.target, &self.into, &self.request) {
|
||||||
// The report first and `AddFormula` last, so a formula the emitter
|
// The report first and `AddFormula` last, so a formula the parser refuses
|
||||||
// spelled but the parser will not take leaves *its* message on
|
// leaves *its* message on screen, not this command's (gotcha 20).
|
||||||
// screen rather than this command's optimistic one (gotcha 20).
|
|
||||||
Ok(outcome) => vec![
|
Ok(outcome) => vec![
|
||||||
effect::set_status(outcome.status_line()),
|
effect::set_status(outcome.status_line()),
|
||||||
Box::new(effect::AddFormula {
|
Box::new(effect::AddFormula {
|
||||||
@@ -150,10 +81,8 @@ impl Cmd for FitMeasure {
|
|||||||
target_category: outcome.formula.target_category.clone(),
|
target_category: outcome.formula.target_category.clone(),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
// Flattened like the success line: several refusals interpolate a
|
// Flattened: a refusal interpolates a document name, and a name may
|
||||||
// document name, and a name may legally contain a newline
|
// legally contain a newline (Copilot on PR #25).
|
||||||
// (Copilot on PR #25). A failure is when a user is most reading
|
|
||||||
// the status bar, so it is the last place to let one split.
|
|
||||||
Err(e) => vec![effect::set_status(one_line(format!("fit-measure: {e}")))],
|
Err(e) => vec![effect::set_status(one_line(format!("fit-measure: {e}")))],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,16 +100,8 @@ mod tests {
|
|||||||
const REGION: &str = "Region";
|
const REGION: &str = "Region";
|
||||||
const TAG: &str = "Tag";
|
const TAG: &str = "Tag";
|
||||||
|
|
||||||
/// A document a tree can learn exactly — `Score` steps at `Cost` 50 — in
|
/// A document a tree can learn exactly. **Every category is a `Label`**, or the
|
||||||
/// the units a real document uses.
|
/// champion can reach an unresolvable feature — see the evidence.
|
||||||
///
|
|
||||||
/// **Every category is a `Label`**, so the only feature is the numeric
|
|
||||||
/// measure `Cost` and the champion cannot reach an unresolvable one. An
|
|
||||||
/// earlier version left `Region` regular with one item and claimed
|
|
||||||
/// `simplify` would prune the constant one-hot; it does not, because
|
|
||||||
/// `build_categorical` records `(0.0, 1.0)` for every one-hot whatever the
|
|
||||||
/// column holds — so these tests were passing on seed luck (Copilot on
|
|
||||||
/// PR #25). See `improvise_gp::fit`'s own fixture for the same correction.
|
|
||||||
fn workbook() -> crate::workbook::Workbook {
|
fn workbook() -> crate::workbook::Workbook {
|
||||||
let mut wb = crate::workbook::Workbook::new("sales");
|
let mut wb = crate::workbook::Workbook::new("sales");
|
||||||
wb.add_category(REGION).expect("first category");
|
wb.add_category(REGION).expect("first category");
|
||||||
@@ -236,9 +157,8 @@ mod tests {
|
|||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The end-to-end claim of improvise-vx3, asserted against a real
|
/// improvise-vx3 end to end against a real document: a command fits, the formula
|
||||||
/// document: a command fits, the formula lands through the ordinary
|
/// lands through `add_formula`, and the new measure evaluates to numbers.
|
||||||
/// `add_formula` path, and the new measure evaluates to numbers.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_command_installs_a_formula_the_document_evaluates() {
|
fn the_command_installs_a_formula_the_document_evaluates() {
|
||||||
let app = run(&["Score"]);
|
let app = run(&["Score"]);
|
||||||
@@ -268,10 +188,8 @@ mod tests {
|
|||||||
assert!(run(&["Score"]).model_state.dirty);
|
assert!(run(&["Score"]).model_state.dirty);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-vx3 is emphatic that the rule alone is not the deliverable:
|
/// The rule alone is not the deliverable, so the status line carries the held-out
|
||||||
/// a tidy tree that generalizes no better than the mean is worse than
|
/// comparison, what the rule leans on, and the seed that reproduces it.
|
||||||
/// useless. So the status line carries the held-out comparison, what the
|
|
||||||
/// rule leans on, and the seed that reproduces it.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_status_line_reports_quality_against_the_baseline() {
|
fn the_status_line_reports_quality_against_the_baseline() {
|
||||||
let app = run(&["Score"]);
|
let app = run(&["Score"]);
|
||||||
@@ -284,9 +202,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A refusal reaches the user *whole*. "fit failed" is not actionable;
|
/// A refusal reaches the user *whole*: "fit failed" is not actionable, naming the
|
||||||
/// naming the measure that does not exist is (improvise-8xhy's argument,
|
/// measure that does not exist is (improvise-8xhy).
|
||||||
/// arriving at the place a user meets it).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_refusal_names_what_went_wrong_and_stores_nothing() {
|
fn a_refusal_names_what_went_wrong_and_stores_nothing() {
|
||||||
let wb = workbook();
|
let wb = workbook();
|
||||||
@@ -306,21 +223,8 @@ mod tests {
|
|||||||
assert!(app.model_state.workbook.model.formulas().is_empty());
|
assert!(app.model_state.workbook.model.formulas().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// gotcha 37b: the seed is half of what reproduces a fit, so it is an
|
/// gotcha 37b: a different seed really does search differently. It asserts on the
|
||||||
/// argument — and a different one really does search differently.
|
/// **rule**, never on the batch debug string — see the evidence.
|
||||||
///
|
|
||||||
/// It asserts on the **rule**, never on the batch's debug string. The
|
|
||||||
/// first version compared `effects_debug` over the whole vec, which was
|
|
||||||
/// vacuous: `status_line` prints `seed {:#x}`, so the two strings differ
|
|
||||||
/// because the seed was *printed*, and `fit_measure` could have ignored
|
|
||||||
/// `request.seed` entirely with the test still green
|
|
||||||
/// (`doctrine(check_the_checker)`).
|
|
||||||
///
|
|
||||||
/// `AddFormula` has exactly two fields — the formula text and the target
|
|
||||||
/// category — and the seed reaches neither, so its `Debug` cannot differ
|
|
||||||
/// for the reason the batch's did. (A substring guard was tried and is
|
|
||||||
/// wrong: the seeds `1` and `99` occur inside perfectly ordinary
|
|
||||||
/// thresholds.)
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_seed_argument_changes_the_search() {
|
fn the_seed_argument_changes_the_search() {
|
||||||
let wb = workbook();
|
let wb = workbook();
|
||||||
@@ -350,14 +254,8 @@ mod tests {
|
|||||||
assert!(err.contains("is not a seed"), "{err}");
|
assert!(err.contains("is not a seed"), "{err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copilot on PR #25. The status line advertises the seed as `{:#x}` and
|
/// Copilot on PR #25: the seed must round-trip *through the status line*, not
|
||||||
/// tells the user that is what reproduces the fit, so the one instruction
|
/// against a hand-written `"0x…"` that would survive a respelling.
|
||||||
/// it gives has to work on the value it just showed them — a decimal-only
|
|
||||||
/// parser made copying it back an error.
|
|
||||||
///
|
|
||||||
/// Asserted as a *round trip through the status line*, not against a
|
|
||||||
/// hand-written `"0x…"`: a literal would still pass if `status_line`
|
|
||||||
/// changed how it spells the number, which is the half that breaks.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_seed_the_status_line_prints_is_a_seed_this_command_accepts() {
|
fn the_seed_the_status_line_prints_is_a_seed_this_command_accepts() {
|
||||||
let app = run(&["Score"]);
|
let app = run(&["Score"]);
|
||||||
|
|||||||
@@ -120,10 +120,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drilling into a formula cell (e.g. Profit = Revenue - Cost) should
|
/// Drilling into a formula cell must return the underlying data records: the
|
||||||
/// return the underlying data records, not an empty result set. The
|
/// target coordinate is stripped so `matching_cells` finds them (gotcha 10).
|
||||||
/// formula target coordinate is stripped from the drill key so that
|
|
||||||
/// matching_cells finds the raw data backing the formula.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drill_into_formula_cell_returns_data_records() {
|
fn drill_into_formula_cell_returns_data_records() {
|
||||||
use crate::formula::parse_formula;
|
use crate::formula::parse_formula;
|
||||||
@@ -172,10 +170,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-dz8: a drilled coordinate is a constraint of the drill, not
|
/// improvise-dz8: a drilled coordinate is a constraint, not a dimension to page
|
||||||
/// a dimension the user may page through, so it lands on `Filter`. It
|
/// through, so it lands on `Filter`. It used to land on `Page`.
|
||||||
/// used to land on `Page`, which put it in the tile bar and under
|
|
||||||
/// `[`/`]` as if the user could move it.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drilling_fixes_its_coordinates_on_filter_not_page() {
|
fn drilling_fixes_its_coordinates_on_filter_not_page() {
|
||||||
let m = two_cat_model();
|
let m = two_cat_model();
|
||||||
@@ -297,9 +293,8 @@ impl Cmd for ViewNavigate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drill down into an aggregated cell: create a _Drill view with _Index on
|
/// Drill into an aggregated cell: a `_Drill` view with `_Index` on Row and `_Dim` on
|
||||||
/// Row and _Dim on Column (records/long-format view). Fixed coordinates
|
/// Column. The drilled cell's fixed coordinates become filters (gotcha 27).
|
||||||
/// from the drilled cell become page filters.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct DrillIntoCell {
|
pub struct DrillIntoCell {
|
||||||
pub key: crate::model::cell::CellKey,
|
pub key: crate::model::cell::CellKey,
|
||||||
@@ -312,9 +307,8 @@ impl Cmd for DrillIntoCell {
|
|||||||
let drill_name = crate::view::DRILL_VIEW.to_string();
|
let drill_name = crate::view::DRILL_VIEW.to_string();
|
||||||
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||||
|
|
||||||
// If drilling into a formula cell, strip the formula target from the
|
// Drilling a formula cell strips the formula target from the key, or
|
||||||
// key so matching_cells finds the underlying raw data records instead
|
// `matching_cells` returns nothing instead of the raw records (gotcha 10).
|
||||||
// of returning nothing.
|
|
||||||
let drill_key = if let Some(measure_val) = self.key.get(MEASURE_CATEGORY) {
|
let drill_key = if let Some(measure_val) = self.key.get(MEASURE_CATEGORY) {
|
||||||
let is_formula_target = ctx.model.formulas().iter().any(|f| {
|
let is_formula_target = ctx.model.formulas().iter().any(|f| {
|
||||||
CategoryKind::of_name(&f.target_category).is_measure() && f.target == measure_val
|
CategoryKind::of_name(&f.target_category).is_measure() && f.target == measure_val
|
||||||
@@ -365,9 +359,8 @@ impl Cmd for DrillIntoCell {
|
|||||||
axis: crate::view::Axis::Column,
|
axis: crate::view::Axis::Column,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Fixed coords (from the drilled cell) -> Filter, an immutable
|
// Fixed coords go to Filter: an immutable constraint of this drill rather
|
||||||
// constraint of this drill rather than a dimension to page through
|
// than a dimension to page through (improvise-dz8).
|
||||||
// (improvise-dz8).
|
|
||||||
let fixed_cats: std::collections::HashSet<String> =
|
let fixed_cats: std::collections::HashSet<String> =
|
||||||
self.key.0.iter().map(|(c, _)| c.clone()).collect();
|
self.key.0.iter().map(|(c, _)| c.clone()).collect();
|
||||||
for (cat, item) in &self.key.0 {
|
for (cat, item) in &self.key.0 {
|
||||||
@@ -380,9 +373,8 @@ impl Cmd for DrillIntoCell {
|
|||||||
item: item.clone(),
|
item: item.clone(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
// Previously-aggregated categories (none_cats) stay on Axis::None so
|
// Previously-aggregated categories stay on `Axis::None` so they do not filter
|
||||||
// they don't filter records; they'll appear as columns in records mode.
|
// records; they appear as columns. `_Index`/`_Dim` are already set.
|
||||||
// Skip virtual categories — we already set _Index/_Dim above.
|
|
||||||
for cat in ctx.none_cats() {
|
for cat in ctx.none_cats() {
|
||||||
if fixed_cats.contains(cat) || cat.starts_with('_') {
|
if fixed_cats.contains(cat) || cat.starts_with('_') {
|
||||||
continue;
|
continue;
|
||||||
@@ -418,9 +410,8 @@ impl Cmd for TogglePruneEmpty {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle between records mode and pivot mode using the view stack.
|
/// Toggle records mode through the view stack: entering creates a `_Records` view
|
||||||
/// Entering records mode creates a `_Records` view and switches to it.
|
/// and switches to it, leaving navigates back.
|
||||||
/// Leaving records mode navigates back to the previous view.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ToggleRecordsMode;
|
pub struct ToggleRecordsMode;
|
||||||
impl Cmd for ToggleRecordsMode {
|
impl Cmd for ToggleRecordsMode {
|
||||||
@@ -431,10 +422,8 @@ impl Cmd for ToggleRecordsMode {
|
|||||||
let is_records = ctx.layout.is_records_mode();
|
let is_records = ctx.layout.is_records_mode();
|
||||||
|
|
||||||
if is_records {
|
if is_records {
|
||||||
// Leaving records mode: clean up any records with empty CellKeys
|
// Leaving cleans up the empty-`CellKey` records `AddRecordRow` makes with
|
||||||
// (produced by AddRecordRow when no page filters are set) before
|
// no page filters — the inverse of the entry `SortData`.
|
||||||
// restoring the previous view. This is the inverse of `SortData`
|
|
||||||
// that runs on entry.
|
|
||||||
return vec![
|
return vec![
|
||||||
Box::new(effect::CleanEmptyRecords),
|
Box::new(effect::CleanEmptyRecords),
|
||||||
Box::new(effect::ViewBack),
|
Box::new(effect::ViewBack),
|
||||||
|
|||||||
@@ -37,9 +37,8 @@ mod tests {
|
|||||||
assert_eq!(effects.len(), 2);
|
assert_eq!(effects.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `enter-export-prompt` shim is gone (improvise-s13); the generic
|
/// The `enter-export-prompt` shim is gone (improvise-s13): the generic
|
||||||
/// `enter-mode` carries the mode name instead, and clears the prompt's
|
/// `enter-mode` carries the mode name and clears the prompt's buffer.
|
||||||
/// buffer on the way in.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enter_mode_export_prompt_sets_mode_and_clears_its_buffer() {
|
fn enter_mode_export_prompt_sets_mode_and_clears_its_buffer() {
|
||||||
let m = two_cat_model();
|
let m = two_cat_model();
|
||||||
@@ -58,14 +57,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-gdl made `write_buffer` the single accessor for a
|
/// improvise-gdl: `EnterMode` built a `SetBuffer` directly, so a "search"-keyed
|
||||||
/// runtime-named buffer, because "search" is not an entry in the buffers
|
/// minibuffer would clear a map entry nobody reads. See the evidence.
|
||||||
/// map but its own `ViewState` field. `EnterMode` built a `SetBuffer`
|
|
||||||
/// directly, so a mode whose `buffer_key` is "search" would clear a map
|
|
||||||
/// entry nobody reads and leave the previous query on screen — the exact
|
|
||||||
/// desync gdl closed. No shipped `AppMode` names that buffer yet, so the
|
|
||||||
/// mode here is constructed by hand; that is what makes the test a
|
|
||||||
/// demonstration rather than a restatement.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enter_mode_clears_a_search_keyed_minibuffer_through_write_buffer() {
|
fn enter_mode_clears_a_search_keyed_minibuffer_through_write_buffer() {
|
||||||
use crate::ui::app::{MinibufColor, MinibufferConfig};
|
use crate::ui::app::{MinibufColor, MinibufferConfig};
|
||||||
@@ -129,11 +122,8 @@ mod tests {
|
|||||||
assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}");
|
assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// EditOrDrill must trust its `edit_mode` parameter rather than checking
|
/// `EditOrDrill` trusts its `edit_mode` parameter rather than checking
|
||||||
/// `ctx.mode` — the records-normal keymap supplies `records-editing`,
|
/// `ctx.mode` — the records-normal keymap supplies `records-editing`.
|
||||||
/// but the command itself never inspects the runtime mode. This is the
|
|
||||||
/// parallel of the (deleted) `enter_edit_mode_produces_editing_mode`
|
|
||||||
/// test for the records branch.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn edit_or_drill_passes_records_editing_mode_through() {
|
fn edit_or_drill_passes_records_editing_mode_through() {
|
||||||
let m = two_cat_model();
|
let m = two_cat_model();
|
||||||
@@ -153,12 +143,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `EnterEditAtCursorCmd` must hand its `target_mode` straight through
|
/// `EnterEditAtCursorCmd` hands `target_mode` straight through, and fronts the
|
||||||
/// to the `EnterEditAtCursor` effect — the keymap (records `o` sequence
|
/// pair with `RebuildLayout` so the cursor reads a current layout (improvise-ehz).
|
||||||
/// or commit-and-advance) decides; the command never inspects ctx.
|
|
||||||
/// It fronts the pair with `RebuildLayout` so the cursor is read against
|
|
||||||
/// a layout that includes whatever ran earlier in the sequence
|
|
||||||
/// (improvise-ehz).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enter_edit_at_cursor_cmd_passes_target_mode_to_effect() {
|
fn enter_edit_at_cursor_cmd_passes_target_mode_to_effect() {
|
||||||
let m = two_cat_model();
|
let m = two_cat_model();
|
||||||
@@ -232,10 +218,8 @@ impl Cmd for EnterMode {
|
|||||||
}
|
}
|
||||||
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||||
// Clear the corresponding buffer when entering a text-entry mode.
|
// Clear the mode's buffer through `write_buffer`, the only code that knows a
|
||||||
// Through `write_buffer`, which is the only code that knows a
|
// runtime-named buffer may not be in the buffers map (improvise-gdl).
|
||||||
// runtime-named buffer may not be an entry in the buffers map
|
|
||||||
// (improvise-gdl).
|
|
||||||
if let Some(mb) = self.0.minibuffer() {
|
if let Some(mb) = self.0.minibuffer() {
|
||||||
effects.push(write_buffer(&mb.buffer_key, String::new()));
|
effects.push(write_buffer(&mb.buffer_key, String::new()));
|
||||||
}
|
}
|
||||||
@@ -273,18 +257,8 @@ impl Cmd for Quit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save then quit. The `Quit` only lands if the `Save` succeeds — a failed
|
/// Save then quit; the `Quit` only lands if the `Save` succeeds (improvise-8tk).
|
||||||
/// save aborts the effect chain, so `wq`/`ZZ` leaves the user in the app
|
/// **Not an `AAndB` command** — `wq` is the user's name — see `context/code/command.md`.
|
||||||
/// with the error visible rather than discarding the model (improvise-8tk).
|
|
||||||
///
|
|
||||||
/// **This looks like an `AAndB` command and is not one** (improvise-git).
|
|
||||||
/// `wq` is the *user's* name for the composition, not an invented internal
|
|
||||||
/// one: it is registered, so `:wq` reaches it, and `Quit`'s unsaved-changes
|
|
||||||
/// message advertises that spelling. A keymap `Binding::Sequence` cannot be
|
|
||||||
/// reached from the `:` line, so replacing this with one would either delete
|
|
||||||
/// the ex-command or leave two independently-written spellings of "save then
|
|
||||||
/// quit". One definition behind two surfaces is what this already is.
|
|
||||||
/// Pinned by `text_buffer::tests::colon_wq_saves_then_quits`.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct SaveAndQuit;
|
pub struct SaveAndQuit;
|
||||||
impl Cmd for SaveAndQuit {
|
impl Cmd for SaveAndQuit {
|
||||||
@@ -298,33 +272,15 @@ impl Cmd for SaveAndQuit {
|
|||||||
|
|
||||||
// ── Editing entry ───────────────────────────────────────────────────────
|
// ── Editing entry ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Smart dispatch for i/a: if the cursor is on an aggregated pivot cell
|
/// Smart dispatch for i/a: drill an aggregated pivot cell, else pre-fill the buffer
|
||||||
/// (categories on `Axis::None` and the cell is not a synthetic records-mode
|
/// and enter `edit_mode` — which the keymap supplies, so this stays mode-agnostic.
|
||||||
/// row), drill into it instead of editing. Otherwise pre-fill the edit
|
|
||||||
/// buffer with the displayed cell value and enter `edit_mode`.
|
|
||||||
///
|
|
||||||
/// `edit_mode` is supplied by the keymap binding — the command itself is
|
|
||||||
/// mode-agnostic, so the records-normal keymap passes `records-editing`
|
|
||||||
/// while the normal keymap passes `editing`.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct EditOrDrill {
|
pub struct EditOrDrill {
|
||||||
pub edit_mode: AppMode,
|
pub edit_mode: AppMode,
|
||||||
}
|
}
|
||||||
impl EditOrDrill {
|
impl EditOrDrill {
|
||||||
/// What to say when there is no cell under the cursor at all.
|
/// What to say when there is no cell under the cursor. It reads `edit_mode`, the
|
||||||
///
|
/// keymap's argument, not `ctx` (improvise-ct5/4ju) — see `context/code/command.md`.
|
||||||
/// improvise-ct5: this used to say "cannot drill — no cell at cursor" in
|
|
||||||
/// both modes. Drilling is a pivot-mode concept, and in records mode —
|
|
||||||
/// where every regular category sits on `Axis::None`, so this branch is
|
|
||||||
/// the one an empty grid always takes — the user's model is "a table I
|
|
||||||
/// type into", with `o` one key away to add a row. Naming drilling there
|
|
||||||
/// described a mechanism the user is not using instead of the key that
|
|
||||||
/// would help.
|
|
||||||
///
|
|
||||||
/// The choice reads `edit_mode` — the argument the *keymap* supplies —
|
|
||||||
/// rather than asking `ctx` what mode it is in, so the command stays as
|
|
||||||
/// mode-agnostic as its doc comment claims and no runtime `is_records()`
|
|
||||||
/// check comes back (improvise-4ju).
|
|
||||||
fn nothing_at_cursor(&self) -> &'static str {
|
fn nothing_at_cursor(&self) -> &'static str {
|
||||||
if self.edit_mode.is_records() {
|
if self.edit_mode.is_records() {
|
||||||
"No record at cursor — press o to add a row"
|
"No record at cursor — press o to add a row"
|
||||||
@@ -339,9 +295,8 @@ impl Cmd for EditOrDrill {
|
|||||||
"edit-or-drill"
|
"edit-or-drill"
|
||||||
}
|
}
|
||||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
// Only consider regular (non-virtual, non-label) categories on None
|
// Only **regular** categories on None are true aggregation: virtuals like
|
||||||
// as true aggregation. Virtuals like _Index/_Dim are always None in
|
// `_Index`/`_Dim` are always None in pivot mode and imply nothing.
|
||||||
// pivot mode and don't imply aggregation.
|
|
||||||
let regular_none = ctx.none_cats().iter().any(|c| {
|
let regular_none = ctx.none_cats().iter().any(|c| {
|
||||||
ctx.model
|
ctx.model
|
||||||
.category(c)
|
.category(c)
|
||||||
@@ -368,9 +323,8 @@ impl Cmd for EditOrDrill {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Thin command wrapper around the `EnterEditAtCursor` effect so it can
|
/// Thin command wrapper around the `EnterEditAtCursor` effect so it can join a
|
||||||
/// participate in `Binding::Sequence`. `target_mode` is supplied as the
|
/// `Binding::Sequence`. `target_mode` is the keymap binding's argument.
|
||||||
/// command argument by the keymap binding.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct EnterEditAtCursorCmd {
|
pub struct EnterEditAtCursorCmd {
|
||||||
pub target_mode: AppMode,
|
pub target_mode: AppMode,
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ use crate::view::Axis;
|
|||||||
use super::core::{Cmd, CmdContext};
|
use super::core::{Cmd, CmdContext};
|
||||||
|
|
||||||
// ── Navigation commands ──────────────────────────────────────────────────────
|
// ── Navigation commands ──────────────────────────────────────────────────────
|
||||||
// All navigation commands take explicit cursor state. The interactive spec
|
// All navigation commands take explicit cursor state; see `context/code/command.md`.
|
||||||
// fills position/bounds from context; the parser accepts them as args.
|
|
||||||
|
|
||||||
/// Shared viewport state for navigation commands.
|
/// Shared viewport state for navigation commands.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -154,9 +153,8 @@ impl Cmd for EnterAdvance {
|
|||||||
} else if c < col_max {
|
} else if c < col_max {
|
||||||
(0, c + 1)
|
(0, c + 1)
|
||||||
} else {
|
} else {
|
||||||
// Already at bottom-right — the advance premise no longer holds.
|
// Already at bottom-right: the advance premise no longer holds, so abort
|
||||||
// Abort the rest of the batch so the caller's trailing effects
|
// the rest of the batch and the caller's trailing effects are skipped.
|
||||||
// (e.g. `CommitAndAdvance`'s `EnterEditAtCursor`) are skipped.
|
|
||||||
return vec![Box::new(effect::AbortChain)];
|
return vec![Box::new(effect::AbortChain)];
|
||||||
};
|
};
|
||||||
viewport_effects(
|
viewport_effects(
|
||||||
@@ -246,11 +244,8 @@ impl Cmd for PagePrev {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gather (cat_name, items, current_idx) for page-axis categories.
|
/// Page-axis categories as `(cat_name, items, current_idx)`. `categories_on(Page)`
|
||||||
///
|
/// and **not** `filtering_cats()`: Filter is not navigable (improvise-dz8).
|
||||||
/// Deliberately `categories_on(Axis::Page)` and not `filtering_cats()`:
|
|
||||||
/// `Filter` coordinates constrain the data but are not navigable, so `[`
|
|
||||||
/// and `]` must not page them (improvise-dz8).
|
|
||||||
pub(super) fn page_cat_data(ctx: &CmdContext) -> Vec<(String, Vec<String>, usize)> {
|
pub(super) fn page_cat_data(ctx: &CmdContext) -> Vec<(String, Vec<String>, usize)> {
|
||||||
let view = ctx.view;
|
let view = ctx.view;
|
||||||
let page_cats: Vec<String> = view
|
let page_cats: Vec<String> = view
|
||||||
@@ -290,9 +285,8 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::command::cmd::test_helpers::*;
|
use crate::command::cmd::test_helpers::*;
|
||||||
|
|
||||||
/// improvise-dz8: `[` and `]` page through Page-axis categories. A
|
/// improvise-dz8: a coordinate the drill fixed sits on Filter and must be
|
||||||
/// coordinate the drill fixed sits on Filter and must be invisible to
|
/// invisible to `[`/`]`, or the user could page out of the drill.
|
||||||
/// them, or the user could page out of the drill they are inside.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn page_cycling_skips_filter_coordinates() {
|
fn page_cycling_skips_filter_coordinates() {
|
||||||
let mut m = three_cat_model_with_page();
|
let mut m = three_cat_model_with_page();
|
||||||
@@ -367,9 +361,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// At bottom-right `EnterAdvance` has no place to go, so it emits a
|
/// At bottom-right `EnterAdvance` emits a single `AbortChain`, which is how
|
||||||
/// single `AbortChain` effect. Trailing effects in a `CommitAndAdvance`
|
|
||||||
/// batch (e.g. `EnterEditAtCursor`) are then skipped, which is how
|
|
||||||
/// "Enter at bottom-right commits and exits editing" is realised.
|
/// "Enter at bottom-right commits and exits editing" is realised.
|
||||||
#[test]
|
#[test]
|
||||||
fn enter_advance_at_bottom_right_emits_abort_chain() {
|
fn enter_advance_at_bottom_right_emits_abort_chain() {
|
||||||
|
|||||||
@@ -103,9 +103,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bug improvise-dqn: CyclePanelFocus always picked the first open panel,
|
/// Bug improvise-dqn: `CyclePanelFocus` always picked the first open panel,
|
||||||
/// ignoring the currently focused panel. With formula + category open and
|
/// ignoring the currently focused one.
|
||||||
/// focus on the formula panel, cycling must move to the category panel.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cycle_panel_focus_advances_from_formula_to_category() {
|
fn cycle_panel_focus_advances_from_formula_to_category() {
|
||||||
let m = two_cat_model();
|
let m = two_cat_model();
|
||||||
@@ -301,17 +300,8 @@ mod tests {
|
|||||||
assert!(effects.is_empty());
|
assert!(effects.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-aaw asked for this command to be replaced by a keymap
|
/// improvise-aaw: the name is computed from `ctx`, which a literal-arg `Sequence`
|
||||||
/// `Binding::Sequence [create-view X, switch-view X, enter-mode normal]`.
|
/// cannot do. Asserted at two view counts — see the evidence.
|
||||||
/// It cannot be: a `Sequence` carries **literal** args, and the name here
|
|
||||||
/// is computed from `ctx` — so a Sequence would hard-code one name and
|
|
||||||
/// the second `n` in the view panel would collide with the first.
|
|
||||||
///
|
|
||||||
/// The test was called `..._names_incrementally` and asserted only that
|
|
||||||
/// `CreateView`/`SwitchView`/`Normal` appeared — it never looked at the
|
|
||||||
/// name, which is the one thing that makes this command a command and
|
|
||||||
/// not a composition. Now it does, at two different view counts, because
|
|
||||||
/// a single count cannot tell "derived from the workbook" from "constant".
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_and_switch_view_names_incrementally() {
|
fn create_and_switch_view_names_incrementally() {
|
||||||
let mut m = two_cat_model();
|
let mut m = two_cat_model();
|
||||||
@@ -410,11 +400,8 @@ impl Cmd for TogglePanelVisibility {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tab through open panels in order Formula → Category → View, wrapping.
|
/// Tab through open panels Formula → Category → View, wrapping, starting from the
|
||||||
/// The starting point is the panel currently focused (`ctx.mode`); from a
|
/// focused one. One open panel self-transitions; none open is a no-op.
|
||||||
/// non-panel mode (e.g. Normal) focus goes to the first open panel. With a
|
|
||||||
/// single open panel this self-transitions back to it; with none open it is
|
|
||||||
/// a no-op.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct CyclePanelFocus {
|
pub struct CyclePanelFocus {
|
||||||
pub formula_open: bool,
|
pub formula_open: bool,
|
||||||
@@ -601,9 +588,8 @@ impl Cmd for FilterToItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete the category or item at the panel cursor.
|
/// Delete at the panel cursor: a category header deletes the whole category, an item
|
||||||
/// On a category header -> delete the whole category.
|
/// row deletes just that item.
|
||||||
/// On an item row -> delete just that item.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct DeleteCategoryAtCursor;
|
pub struct DeleteCategoryAtCursor;
|
||||||
impl Cmd for DeleteCategoryAtCursor {
|
impl Cmd for DeleteCategoryAtCursor {
|
||||||
@@ -658,19 +644,8 @@ impl Cmd for SwitchViewAtCursor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new view, switch to it, and return to Normal mode.
|
/// Create a new view, switch to it, return to Normal. **Not a shim, despite the
|
||||||
///
|
/// name** (improvise-aaw) — see `context/code/command.md`.
|
||||||
/// **Not a shim, despite the name** (improvise-aaw). The new view's name is
|
|
||||||
/// derived from `ctx` — `format!("View {}", views.len() + 1)` — and a keymap
|
|
||||||
/// `Binding::Sequence` can only carry literal args, so the proposed
|
|
||||||
/// `[create-view X, switch-view X, enter-mode normal]` would hard-code one
|
|
||||||
/// name and the second `n` in the view panel would collide with the first.
|
|
||||||
/// The derivation is the decision that makes this a command.
|
|
||||||
///
|
|
||||||
/// The ticket's two fallbacks are both behaviour changes wanting a product
|
|
||||||
/// decision, not refactors: prompting for a name is a new feature, and
|
|
||||||
/// making `create-view` switch by default changes `:create-view` for scripts
|
|
||||||
/// and duplicates what `grid.rs` does by hand for `_Drill`/`_Records`.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct CreateAndSwitchView;
|
pub struct CreateAndSwitchView;
|
||||||
impl Cmd for CreateAndSwitchView {
|
impl Cmd for CreateAndSwitchView {
|
||||||
|
|||||||
@@ -2,11 +2,8 @@ use crate::model::cell::CellKey;
|
|||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
use crate::ui::effect::Panel;
|
use crate::ui::effect::Panel;
|
||||||
|
|
||||||
/// Decode a mode-name string (as supplied by `enter-mode`/`edit-or-drill`
|
/// Decode a mode-name string into an `AppMode`. The table is `AppMode::from_name` in
|
||||||
/// keymap bindings) into an `AppMode`.
|
/// the protocol crate: the spellings are keymap vocabulary and the keymap is shared.
|
||||||
/// The table itself is `AppMode::from_name`, beside `AppMode` in the
|
|
||||||
/// protocol crate: the spellings are keymap vocabulary and the keymap is
|
|
||||||
/// shared, so the browser client decodes `enter-mode` from the same list.
|
|
||||||
fn parse_mode_name(s: &str) -> Result<AppMode, String> {
|
fn parse_mode_name(s: &str) -> Result<AppMode, String> {
|
||||||
AppMode::from_name(s).ok_or_else(|| format!("Unknown mode: {s}"))
|
AppMode::from_name(s).ok_or_else(|| format!("Unknown mode: {s}"))
|
||||||
}
|
}
|
||||||
@@ -525,9 +522,7 @@ pub fn default_registry() -> CmdRegistry {
|
|||||||
r.register_nullary(|| Box::new(CommandModeBackspace));
|
r.register_nullary(|| Box::new(CommandModeBackspace));
|
||||||
|
|
||||||
// ── Commit ───────────────────────────────────────────────────────────
|
// ── Commit ───────────────────────────────────────────────────────────
|
||||||
// commit-cell-edit / commit-and-advance-right take a mode-name arg
|
// These take a mode-name arg as args[0]; the command never inspects ctx.mode.
|
||||||
// (e.g. "editing" or "records-editing") as args[0]. The keymap supplies
|
|
||||||
// it; the command never inspects ctx.mode.
|
|
||||||
r.register(
|
r.register(
|
||||||
&CommitAndAdvance {
|
&CommitAndAdvance {
|
||||||
key: CellKey::new(vec![]),
|
key: CellKey::new(vec![]),
|
||||||
@@ -651,21 +646,8 @@ mod tests {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-9cn / improvise-61f: every registration derives its name
|
/// improvise-9cn/61f: four commands name themselves off a *field*, so a wrong
|
||||||
/// from a prototype instance, so a typo in the name is impossible. What
|
/// variant compiles and files the command under the wrong name (gotcha 15).
|
||||||
/// the types do *not* catch is the commands whose `name()` branches on a
|
|
||||||
/// field — `ViewNavigate`, `TileAxisOp`, `ToggleGroupAtCursor`,
|
|
||||||
/// `CommitAndAdvance`. Their prototype and the value their constructor
|
|
||||||
/// builds are the same type, so registering `ViewNavigate { forward:
|
|
||||||
/// true }` while building `forward: false` compiles cleanly and files
|
|
||||||
/// the command under the wrong name, where dispatch silently misses.
|
|
||||||
/// Constructing every entry both ways and comparing names is the only
|
|
||||||
/// check that reaches that class.
|
|
||||||
///
|
|
||||||
/// Some entries are deliberately one-path — `commit-and-advance-right`
|
|
||||||
/// reads the edit buffer and cursor, so its parse fn always errors — but
|
|
||||||
/// every entry must be reachable by at least one path, or its name goes
|
|
||||||
/// unchecked.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_entry_constructs_a_command_with_its_registered_name() {
|
fn every_entry_constructs_a_command_with_its_registered_name() {
|
||||||
let r = default_registry();
|
let r = default_registry();
|
||||||
@@ -706,12 +688,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A variant mix-up does not show up as a name mismatch — it shows up as
|
/// A variant mix-up shows up as a **collision**, not a mismatch: the wrong entry
|
||||||
/// a collision. Registering `ViewNavigate { forward: false }` where
|
/// takes its sibling's name and the sibling vanishes. Uniqueness catches it.
|
||||||
/// `forward: true` belongs files a second entry under "view-back" and
|
|
||||||
/// removes "view-forward" from the registry entirely; `parse` takes the
|
|
||||||
/// first match, so every surviving entry still agrees with its own name.
|
|
||||||
/// Uniqueness is what catches it.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registered_names_are_unique() {
|
fn registered_names_are_unique() {
|
||||||
let r = default_registry();
|
let r = default_registry();
|
||||||
|
|||||||
@@ -27,10 +27,8 @@ mod tests {
|
|||||||
assert!(dbg.contains("he"), "Expected 'he' after pop, got: {dbg}");
|
assert!(dbg.contains("he"), "Expected 'he' after pop, got: {dbg}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writing the "search" buffer means writing the search query, and
|
/// Writing the "search" buffer means writing the query, and `write_buffer` is the
|
||||||
/// `write_buffer` is the one place that knows it (improvise-gdl). Every
|
/// one place that knows it (improvise-gdl).
|
||||||
/// buffer-writing command routes through it, so `clear-buffer search`
|
|
||||||
/// clears the query rather than parking an unread entry in the map.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn writing_the_search_buffer_targets_the_search_query() {
|
fn writing_the_search_buffer_targets_the_search_query() {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -94,21 +92,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-git proposed deleting the `SaveAndQuit` command and binding
|
/// improvise-git proposed replacing `SaveAndQuit` with a `Sequence`. `wq` is also
|
||||||
/// `ZZ` to a `Binding::Sequence [w, force-quit]` instead — the repo's
|
/// a *registered name* `:wq` reaches — see the evidence for the decline.
|
||||||
/// "do not name a command AAndB" principle.
|
|
||||||
///
|
|
||||||
/// That premise is incomplete, and this test is why. `wq` is not only a
|
|
||||||
/// keybinding: it is a **registered command name**, so `:wq` reaches it
|
|
||||||
/// through the registry, and `Quit`'s own unsaved-changes message
|
|
||||||
/// advertises it ("Use :q! to force quit or :wq to save+quit"). A keymap
|
|
||||||
/// `Sequence` is not reachable from the `:` line, so deleting the command
|
|
||||||
/// deletes the ex-command with it.
|
|
||||||
///
|
|
||||||
/// Keeping the command *and* adding the Sequence would leave two
|
|
||||||
/// independently-written spellings of "save then quit", which is the
|
|
||||||
/// duplication this codebase keeps curing rather than creating. So this
|
|
||||||
/// pins the surface, and the conversion is declined on it.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn colon_wq_saves_then_quits() {
|
fn colon_wq_saves_then_quits() {
|
||||||
let m = two_cat_model();
|
let m = two_cat_model();
|
||||||
@@ -123,17 +108,8 @@ mod tests {
|
|||||||
assert!(dbg.contains("Quit"), "`:wq` must then quit, got: {dbg}");
|
assert!(dbg.contains("Quit"), "`:wq` must then quit, got: {dbg}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-6sq: `ExecuteCommand` appends `change_mode(Normal)` unless
|
/// improvise-6sq: a trailing `ChangeMode(Normal)` undid the edit mode. The
|
||||||
/// some effect reports `changes_mode()`. `EnterEditAtCursor` sets
|
/// assertion has to name the snap-back, not just check `changes_mode()`.
|
||||||
/// `view_state.mode` but did not override it, so
|
|
||||||
/// `:enter-edit-at-cursor editing` produced
|
|
||||||
/// `[RebuildLayout, EnterEditAtCursor(Editing), ChangeMode(Normal)]` —
|
|
||||||
/// the trailing snap-back undid the edit mode and the user never got a
|
|
||||||
/// cursor in the minibuffer.
|
|
||||||
///
|
|
||||||
/// Asserting on `changes_mode()` alone is not enough here: the appended
|
|
||||||
/// `ChangeMode(Normal)` reports `true` itself, so the assertion has to
|
|
||||||
/// name the snap-back it is ruling out.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn colon_enter_edit_at_cursor_is_not_snapped_back_to_normal() {
|
fn colon_enter_edit_at_cursor_is_not_snapped_back_to_normal() {
|
||||||
let m = two_cat_model();
|
let m = two_cat_model();
|
||||||
@@ -240,11 +216,8 @@ mod tests {
|
|||||||
app.apply_effects(effects);
|
app.apply_effects(effects);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-toc: `StartImportWizard` swaps the mode only when the file
|
/// improvise-toc: `StartImportWizard` swaps the mode only on success, so a
|
||||||
/// parses, so a per-type `changes_mode()` cannot describe it. Reporting
|
/// per-type answer snapped `:import` straight back out of the wizard.
|
||||||
/// `false` made `ExecuteCommand` append a return-to-Normal on top of the
|
|
||||||
/// success path: `:import d.json` opened the wizard and was immediately
|
|
||||||
/// snapped back, leaving the wizard drawn under the Normal keymap.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn colon_import_stays_in_the_wizard_on_success() {
|
fn colon_import_stays_in_the_wizard_on_success() {
|
||||||
let mut app = wizard_test_app();
|
let mut app = wizard_test_app();
|
||||||
@@ -257,9 +230,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The other half of the same bug: reporting `true` unconditionally would
|
/// The other half: reporting `true` unconditionally would strand the user in
|
||||||
/// strand the user in CommandMode when the import fails. A failed
|
/// `CommandMode` when the import fails.
|
||||||
/// `:import` must still fall back to Normal.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn colon_import_falls_back_to_normal_on_failure() {
|
fn colon_import_falls_back_to_normal_on_failure() {
|
||||||
let mut app = wizard_test_app();
|
let mut app = wizard_test_app();
|
||||||
@@ -272,10 +244,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `ViewBack` half of improvise-toc: it restores the stored frame's
|
/// The `ViewBack` half of improvise-toc: it restores the stored frame's mode only
|
||||||
/// mode only when the back-stack is non-empty. With history present,
|
/// when the back-stack is non-empty, and otherwise falls through to Normal.
|
||||||
/// `:view-back` must keep the mode it restored; with an empty stack it
|
|
||||||
/// falls through to Normal like any other no-op command.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn colon_view_back_respects_whether_history_exists() {
|
fn colon_view_back_respects_whether_history_exists() {
|
||||||
use crate::ui::app::ViewFrame;
|
use crate::ui::app::ViewFrame;
|
||||||
@@ -376,9 +346,8 @@ impl Cmd for CommandModeBackspace {
|
|||||||
|
|
||||||
/// Execute the command in the "command" buffer (the `:` command line).
|
/// Execute the command in the "command" buffer (the `:` command line).
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
/// Execute the `:` command buffer by delegating to the command registry.
|
/// Execute the `:` command buffer through the registry. The `:` prompt is another
|
||||||
/// The `:` prompt is just another frontend to the scripting language —
|
/// front end to the scripting language — the same parser as `improvise script`.
|
||||||
/// same parser as `improvise script`.
|
|
||||||
pub struct ExecuteCommand;
|
pub struct ExecuteCommand;
|
||||||
impl Cmd for ExecuteCommand {
|
impl Cmd for ExecuteCommand {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
@@ -393,28 +362,8 @@ impl Cmd for ExecuteCommand {
|
|||||||
|
|
||||||
match crate::command::parse::parse_line_with(ctx.registry, &raw) {
|
match crate::command::parse::parse_line_with(ctx.registry, &raw) {
|
||||||
Ok(cmds) => {
|
Ok(cmds) => {
|
||||||
// Leave the command line **first**, before anything that can
|
// Leave the command line **first**, before anything that can fail:
|
||||||
// fail runs.
|
// an abort must stop the chain, never the bookkeeping (gotcha 21).
|
||||||
//
|
|
||||||
// Still decided at apply time rather than here, because
|
|
||||||
// ViewBack, ViewForward and StartImportWizard move the mode
|
|
||||||
// only on some runs and no up-front answer is right
|
|
||||||
// (improvise-toc). Running it at the head does not weaken
|
|
||||||
// that: whatever a later effect sets, it sets *after* this,
|
|
||||||
// so the mode a `:` line ends in is unchanged in every case
|
|
||||||
// — the only difference is what happens when the batch never
|
|
||||||
// reaches the end.
|
|
||||||
//
|
|
||||||
// Which is the point. A failed `Save`/`SaveAs` sets
|
|
||||||
// `abort_effects` (gotcha 21), and an abort at the tail ate
|
|
||||||
// this restoration too, stranding the user in `CommandMode`
|
|
||||||
// with the stale command line drawn — where
|
|
||||||
// `draw_bottom_bar` renders the minibuffer *instead of* the
|
|
||||||
// status line, so the error the effect had just set was
|
|
||||||
// invisible. The abort must stop the *chain* (`:w bad . q`
|
|
||||||
// must not quit) and must not stop the *bookkeeping*, and
|
|
||||||
// ordering answers that without giving any effect a "runs
|
|
||||||
// anyway" flag: tidy-up goes where an abort cannot reach it.
|
|
||||||
let mut effects: Vec<Box<dyn Effect>> = vec![Box::new(
|
let mut effects: Vec<Box<dyn Effect>> = vec![Box::new(
|
||||||
effect::ReturnToNormalUnlessMoved(ctx.mode.clone()),
|
effect::ReturnToNormalUnlessMoved(ctx.mode.clone()),
|
||||||
)];
|
)];
|
||||||
|
|||||||
@@ -100,9 +100,8 @@ impl Cmd for MoveTileCursor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cycle or set the axis for the category at the tile cursor.
|
/// Cycle or set the axis for the category at the tile cursor, staying in TileSelect.
|
||||||
/// Stays in TileSelect mode so the user can adjust multiple tiles.
|
/// `axis: None` cycles; `axis: Some(a)` sets.
|
||||||
/// `axis: None` -> cycle, `axis: Some(a)` -> set to `a`.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct TileAxisOp {
|
pub struct TileAxisOp {
|
||||||
pub axis: Option<Axis>,
|
pub axis: Option<Axis>,
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
//! Import-wizard key handling.
|
//! Import-wizard key handling: which key means what depends on the step, and that
|
||||||
//!
|
//! decision is pure, so it lives here (improvise-2wm, gotcha 22).
|
||||||
//! The wizard is a small step machine, and which key means what depends on
|
|
||||||
//! the step it is in. That decision is pure, so it lives here: the command
|
|
||||||
//! reads the wizard out of `CmdContext` and emits the discrete effects
|
|
||||||
//! (`WizardApply`, `CloseWizard`, `AcceptWizardModel`) that carry it out
|
|
||||||
//! (improvise-2wm).
|
|
||||||
|
|
||||||
use crate::command::key::KeyCode;
|
use crate::command::key::KeyCode;
|
||||||
use crate::import::wizard::{ImportWizard, WizardStep};
|
use crate::import::wizard::{ImportWizard, WizardStep};
|
||||||
@@ -102,9 +97,8 @@ fn name_model_keys(key: KeyCode) -> Vec<Box<dyn Effect>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The confirmation step: the workbook is built and waiting. Enter installs
|
/// The confirmation step: Enter installs the built workbook, and both Esc and Left
|
||||||
/// it, and both Esc and Left return to `NameModel` — unlike every other
|
/// return to `NameModel` — Esc here does not discard the import (improvise-907).
|
||||||
/// step, Esc here does not throw the import away (improvise-907).
|
|
||||||
fn done_keys(key: KeyCode) -> Vec<Box<dyn Effect>> {
|
fn done_keys(key: KeyCode) -> Vec<Box<dyn Effect>> {
|
||||||
match key {
|
match key {
|
||||||
KeyCode::Enter => vec![Box::new(effect::AcceptWizardModel)],
|
KeyCode::Enter => vec![Box::new(effect::AcceptWizardModel)],
|
||||||
@@ -123,9 +117,8 @@ fn cursor_keys(key: KeyCode) -> Vec<Box<dyn Effect>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Left steps back to the previous step, on every step. It is the one key
|
/// Left steps back on every step. It is the one key free everywhere: Backspace edits
|
||||||
/// free everywhere — Backspace edits the name and the formula buffer, and
|
/// buffers and `h` is a printable the name step needs (improvise-907).
|
||||||
/// `h` is a printable character the name step needs (improvise-907).
|
|
||||||
fn back_key(key: KeyCode) -> Vec<Box<dyn Effect>> {
|
fn back_key(key: KeyCode) -> Vec<Box<dyn Effect>> {
|
||||||
match key {
|
match key {
|
||||||
KeyCode::Left => op(WizardOp::Back),
|
KeyCode::Left => op(WizardOp::Back),
|
||||||
@@ -202,9 +195,8 @@ mod tests {
|
|||||||
assert!(ops(&w, KeyCode::Enter).contains("ConfirmFormula"));
|
assert!(ops(&w, KeyCode::Enter).contains("ConfirmFormula"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enter on the name step builds the workbook into the wizard rather
|
/// Enter on the name step builds the workbook rather than advancing the step
|
||||||
/// than advancing the step machine; every other printable key edits the
|
/// machine; every other printable key edits the name.
|
||||||
/// name.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn name_model_enter_builds_the_model() {
|
fn name_model_enter_builds_the_model() {
|
||||||
let w = wizard_at(WizardStep::NameModel);
|
let w = wizard_at(WizardStep::NameModel);
|
||||||
@@ -234,9 +226,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// On the confirmation step Enter installs the built workbook and Esc
|
/// On the confirmation step Enter installs the built workbook and Esc steps back;
|
||||||
/// steps back — it does not throw the import away, and no key closes
|
/// no key closes the wizard outright (improvise-907, from improvise-c6j).
|
||||||
/// the wizard outright (improvise-907, from improvise-c6j).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn done_confirms_on_enter_and_steps_back_on_esc() {
|
fn done_confirms_on_enter_and_steps_back_on_esc() {
|
||||||
let w = wizard_at(WizardStep::Done);
|
let w = wizard_at(WizardStep::Done);
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
//! The half of the keymap that needs a command registry.
|
//! The half of the keymap that needs a command registry: resolving a bound *name*
|
||||||
//!
|
//! into effects. The **table** is `improvise_protocol::keymap` (gotchas 11-14).
|
||||||
//! The **table** — `KeyPattern`, `Binding`, `Keymap`, `ModeKey`, `TextEntry`,
|
|
||||||
//! `PanelKeymap` and `Keymaps::default_keymaps()` — lives in
|
|
||||||
//! `improvise_protocol::keymap`, so the browser client looks up the same
|
|
||||||
//! bindings the terminal does rather than mirroring them. Read that module's
|
|
||||||
//! docs for why.
|
|
||||||
//!
|
|
||||||
//! What is here is what a thin client cannot have: resolving a bound *name*
|
|
||||||
//! into `Effect`s through a [`CmdRegistry`], and the `SetTransientPrefix`
|
|
||||||
//! effect that arms a prefix key.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -22,13 +13,8 @@ pub use improvise_protocol::keymap::{
|
|||||||
Binding, KeyPattern, Keymap, Keymaps, ModeKey, PanelKeymap, TextEntry,
|
Binding, KeyPattern, Keymap, Keymaps, ModeKey, PanelKeymap, TextEntry,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Effect that arms a prefix key, so the next keypress is dispatched against
|
/// Effect that arms a prefix key. It records the *address* of the prefix, not the
|
||||||
/// that prefix's sub-keymap.
|
/// sub-keymap: `ViewState` must stay portable (improvise-jc8, gotcha 13).
|
||||||
///
|
|
||||||
/// It records the *address* of the prefix — the key that opened it — rather
|
|
||||||
/// than the sub-keymap itself. `KeymapSet` already owns every keymap, so
|
|
||||||
/// holding an `Arc<Keymap>` in `ViewState` would only put a non-portable type
|
|
||||||
/// into what is meant to become portable, shared state (improvise-jc8).
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct SetTransientPrefix(pub KeyCode, pub KeyModifiers);
|
pub struct SetTransientPrefix(pub KeyCode, pub KeyModifiers);
|
||||||
|
|
||||||
@@ -52,53 +38,8 @@ fn run(
|
|||||||
Some(cmd.execute(ctx))
|
Some(cmd.execute(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Turn a binding into effects. The one place a keymap lookup becomes a
|
/// Turn a binding into effects. A `Sequence` **flattens** into one batch, and
|
||||||
/// mutation, and the reason `Binding` itself can be registry-free data.
|
/// improvise-0hb's proposal to change that was declined — `context/code/command.md`.
|
||||||
///
|
|
||||||
/// # Why a `Sequence` flattens, and why it stays that way (improvise-0hb)
|
|
||||||
///
|
|
||||||
/// Every step is executed against the **same pre-dispatch `ctx`**, and the
|
|
||||||
/// effects are concatenated into one vec that `App::apply_effects` runs as
|
|
||||||
/// one batch. So a later *command* cannot observe an earlier one's effects —
|
|
||||||
/// only a later *effect* can. improvise-0hb proposed closing that: apply
|
|
||||||
/// effects so far between steps, rebuild the layout, build a fresh
|
|
||||||
/// `CmdContext`, dispatch the next. The design pass says **no**, on three
|
|
||||||
/// findings.
|
|
||||||
///
|
|
||||||
/// **It would silently reopen improvise-8tk, a P1 data loss.**
|
|
||||||
/// `apply_effects` resets `abort_effects` at entry *and* again when it
|
|
||||||
/// breaks, because the flag is per batch. Applying between steps therefore
|
|
||||||
/// dispatches step 2 into a fresh batch with the flag cleared, so the `Quit`
|
|
||||||
/// after a failed `Save` would land — the exact silent discard 8tk fixed.
|
|
||||||
/// Gotcha 21's claim that the abort "carries across sequence steps" is true
|
|
||||||
/// *because* of this flattening, not in spite of it. It was also untested
|
|
||||||
/// until now: no shipped `Sequence` has a first step that can abort, and
|
|
||||||
/// `ZZ` is a prefix onto the single `wq` command.
|
|
||||||
/// `app.rs::a_sequence_whose_first_step_aborts_does_not_run_the_second` is
|
|
||||||
/// the guard, and it fails when the abort is made per-step.
|
|
||||||
///
|
|
||||||
/// **Almost nothing would benefit.** Of the shipped `Sequence` bindings,
|
|
||||||
/// all but one come from `TextEntry` and are either `[<commit>, clear-buffer
|
|
||||||
/// <buf>]` or `[clear-buffer <buf>, enter-mode <exit>]` — `clear-buffer` and
|
|
||||||
/// `enter-mode` take their operand as a literal arg and read nothing from
|
|
||||||
/// `ctx`, so a refreshed `ctx` would change no outcome. The single genuine
|
|
||||||
/// composition is records `o` = `[add-record-row, enter-edit-at-cursor]`,
|
|
||||||
/// and it already works, because `EnterEditAtCursorCmd` fronts its effect
|
|
||||||
/// with `RebuildLayout` and the effect re-reads the layout itself (gotcha
|
|
||||||
/// 23). Every Esc and every commit keypress would pay a layout rebuild and
|
|
||||||
/// a `CmdContext` construction — which recomputes column widths — to buy
|
|
||||||
/// that nothing.
|
|
||||||
///
|
|
||||||
/// **And the ticket's motivating rewrite does not typecheck as a binding
|
|
||||||
/// anyway.** improvise-git's `[(w, []), (force-quit, [])]` cannot dispatch:
|
|
||||||
/// `w` is `register_pure`, whose interactive constructor rejects an empty
|
|
||||||
/// arg list, so `run` returns `None` and the whole binding is dead. The
|
|
||||||
/// nullary spelling is `save`. `every_bound_command_name_is_registered`
|
|
||||||
/// checks exactly this (gotcha 15) and would have caught it.
|
|
||||||
///
|
|
||||||
/// If command-level transactionality is ever wanted, it wants its own
|
|
||||||
/// campaign: the abort must become a property of the *sequence* rather than
|
|
||||||
/// of the batch before any step is applied separately.
|
|
||||||
fn effects_of(
|
fn effects_of(
|
||||||
binding: &Binding,
|
binding: &Binding,
|
||||||
registry: &CmdRegistry,
|
registry: &CmdRegistry,
|
||||||
@@ -430,15 +371,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-th7m: `F1` was bound twice on consecutive lines — first to a
|
/// improvise-th7m: `F1` was bound twice and the first was dead. This pins which
|
||||||
/// bare `enter-mode` with no args, then to `enter-mode ["help"]`. The
|
/// binding it is meant to carry — the one `?` carries.
|
||||||
/// first was dead, because `Keymap::bind` *inserts* and the later binding
|
|
||||||
/// wins; had it ever been the surviving one it would have failed
|
|
||||||
/// `require_args` at dispatch and made `F1` do nothing.
|
|
||||||
///
|
|
||||||
/// The risk in deleting a line of a duplicated pair is deleting the wrong
|
|
||||||
/// one, so this pins which binding `F1` is meant to carry: the same one
|
|
||||||
/// `?` carries, which is what identified it as the intended survivor.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn f1_and_question_mark_both_open_help() {
|
fn f1_and_question_mark_both_open_help() {
|
||||||
let ks = KeymapSet::default_keymaps();
|
let ks = KeymapSet::default_keymaps();
|
||||||
@@ -477,16 +411,8 @@ mod tests {
|
|||||||
assert!(search.lookup(KeyCode::Esc, KeyModifiers::NONE).is_some());
|
assert!(search.lookup(KeyCode::Esc, KeyModifiers::NONE).is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-9cn: `Keymap::dispatch` resolves a binding through
|
/// improvise-9cn/s13: a binding is a silent no-op key in two ways — the name may
|
||||||
/// `registry.interactive(name, args, ctx).ok()?`, so a binding is a
|
/// not be registered, *or* the args may not parse. Both walks are here.
|
||||||
/// silent no-op key in two ways, not one: the name may not be
|
|
||||||
/// registered, *or* the args may not parse. Both walks live here.
|
|
||||||
///
|
|
||||||
/// The args half matters because improvise-s13 moved bindings out of the
|
|
||||||
/// nullary class the name check covers: `enter-mode ["export-prompt"]`
|
|
||||||
/// and `enter-mode ["formula-edit"]` carry a mode name that
|
|
||||||
/// `parse_mode_name` must accept, and a typo like `"export_prompt"`
|
|
||||||
/// compiles, registers, and produces a dead `Ctrl-e`.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_bound_command_name_is_registered() {
|
fn every_bound_command_name_is_registered() {
|
||||||
let ks = KeymapSet::default_keymaps();
|
let ks = KeymapSet::default_keymaps();
|
||||||
@@ -496,9 +422,8 @@ mod tests {
|
|||||||
let mut checked = 0usize;
|
let mut checked = 0usize;
|
||||||
|
|
||||||
for (mode, km) in ks.maps().iter() {
|
for (mode, km) in ks.maps().iter() {
|
||||||
// Build the context in the mode whose keymap this is: a few
|
// Build the context in the mode whose keymap this is: `commit-buffer
|
||||||
// parsers read `ctx.mode` (`commit-buffer item` resolves its
|
// item` resolves its target category off `AppMode::ItemAdd`.
|
||||||
// target category off `AppMode::ItemAdd`).
|
|
||||||
let app_mode = app_mode_for(mode);
|
let app_mode = app_mode_for(mode);
|
||||||
let mut ctx = test_helpers::make_ctx(&wb, &layout, ks.registry());
|
let mut ctx = test_helpers::make_ctx(&wb, &layout, ks.registry());
|
||||||
ctx.mode = &app_mode;
|
ctx.mode = &app_mode;
|
||||||
@@ -538,17 +463,8 @@ mod tests {
|
|||||||
assert!(checked > 100, "expected to walk the keymaps, saw {checked}");
|
assert!(checked > 100, "expected to walk the keymaps, saw {checked}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-2hi: `AppMode::hint_text` is the status bar's promise about
|
/// improvise-2hi: every `Key:verb` token in every hint must resolve in that
|
||||||
/// what the keys do, and nothing tied it to the keymaps.
|
/// mode's keymap. Which keys are named, never what they do — see the evidence.
|
||||||
/// `only_quit_has_no_hint` asserts a hint is *present*, never that it is
|
|
||||||
/// *accurate*, so rebinding or dropping a key leaves the hint quietly
|
|
||||||
/// lying to the user. This walks every `Key:verb` token in every hint and
|
|
||||||
/// requires the key to resolve in that mode's keymap.
|
|
||||||
///
|
|
||||||
/// The hint is the source of truth for *which* keys are worth mentioning;
|
|
||||||
/// the keymap is the source of truth for what they do. Only the first
|
|
||||||
/// half is checkable from here — a hint that says `i:edit` where `i`
|
|
||||||
/// enters records mode still passes.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_hint_key_resolves_in_its_mode() {
|
fn every_hint_key_resolves_in_its_mode() {
|
||||||
let ks = KeymapSet::default_keymaps();
|
let ks = KeymapSet::default_keymaps();
|
||||||
@@ -571,9 +487,8 @@ mod tests {
|
|||||||
assert!(checked > 50, "expected to walk the hints, saw {checked}");
|
assert!(checked > 50, "expected to walk the hints, saw {checked}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every key a hint mentions. Anything it cannot classify is a panic,
|
/// Every key a hint mentions. An unclassifiable token is a **panic, not a skip**:
|
||||||
/// not a skip: a hint token this does not understand is exactly the
|
/// that is exactly the token that would go unchecked.
|
||||||
/// token that would go unchecked.
|
|
||||||
fn hint_keys(hint: &str) -> Vec<(KeyCode, KeyModifiers)> {
|
fn hint_keys(hint: &str) -> Vec<(KeyCode, KeyModifiers)> {
|
||||||
// Everything after the em dash is prose ("— type a category name").
|
// Everything after the em dash is prose ("— type a category name").
|
||||||
let head = hint.split('—').next().unwrap_or_default();
|
let head = hint.split('—').next().unwrap_or_default();
|
||||||
@@ -588,9 +503,8 @@ mod tests {
|
|||||||
keys.extend(hint_key_spec(spec, hint));
|
keys.extend(hint_key_spec(spec, hint));
|
||||||
seen_hint = true;
|
seen_hint = true;
|
||||||
}
|
}
|
||||||
// A colon-less word continues the previous hint's verb
|
// A colon-less word continues the previous hint's verb; one with
|
||||||
// ("Enter:add & continue", ":q quit"). One with nothing to
|
// nothing to continue is a token this test does not understand.
|
||||||
// continue is a token this test does not understand.
|
|
||||||
None => assert!(
|
None => assert!(
|
||||||
seen_hint,
|
seen_hint,
|
||||||
"hint token {token:?} in {hint:?} is not a Key:verb hint"
|
"hint token {token:?} in {hint:?} is not a Key:verb hint"
|
||||||
@@ -679,12 +593,8 @@ mod tests {
|
|||||||
keys
|
keys
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-jc8: an armed prefix is stored as the key that opened it and
|
/// improvise-jc8: an armed prefix resolves against the current mode's keymap, so
|
||||||
/// resolved against the CURRENT MODE's keymap. That is only correct while
|
/// a nested prefix would silently do nothing. Fail here, not in the terminal.
|
||||||
/// prefixes are flat — a `Prefix` binding nested inside a sub-keymap
|
|
||||||
/// would be resolved against the mode map, where it does not exist, and
|
|
||||||
/// the second half of the sequence would silently do nothing. Adding a
|
|
||||||
/// nested prefix must fail here rather than in the terminal.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prefix_keymaps_are_flat() {
|
fn prefix_keymaps_are_flat() {
|
||||||
let ks = KeymapSet::default_keymaps();
|
let ks = KeymapSet::default_keymaps();
|
||||||
@@ -739,10 +649,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-4pg: the three panel keymaps were written out separately, so
|
/// improvise-4pg: the three panel keymaps share `PanelKeymap` now; this pins what
|
||||||
/// a spine binding could be changed in one and forgotten in the others.
|
/// "the spine" is, each panel's cursor keys included.
|
||||||
/// They share `PanelKeymap` now; this pins what "the spine" is, including
|
|
||||||
/// that each panel's cursor keys move its *own* cursor.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_panel_keymap_shares_the_same_spine() {
|
fn every_panel_keymap_shares_the_same_spine() {
|
||||||
let ks = KeymapSet::default_keymaps();
|
let ks = KeymapSet::default_keymaps();
|
||||||
@@ -779,9 +687,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every panel's toggle key is bound in Normal and in all three panel
|
/// Every panel's toggle key is bound in Normal and in all three panel modes. A
|
||||||
/// modes, so focus can move between any two and the focused one closes.
|
/// fourth panel gets all four keymaps from one `Panel::ALL` entry.
|
||||||
/// A fourth panel gets all four keymaps from one `Panel::ALL` entry.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_mode_with_panel_toggles_binds_all_of_them() {
|
fn every_mode_with_panel_toggles_binds_all_of_them() {
|
||||||
let ks = KeymapSet::default_keymaps();
|
let ks = KeymapSet::default_keymaps();
|
||||||
@@ -808,10 +715,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `Panel::name()` has to spell what the registry's panel parser accepts.
|
/// `Panel::name()` must spell what the registry's parser accepts, and `dispatch`
|
||||||
/// A lookup-only assertion would not catch a mismatch — `dispatch`
|
/// swallows a mismatch with `.ok()?` — so real effects must come back.
|
||||||
/// swallows an unresolvable command with `.ok()?` — so the toggle key
|
|
||||||
/// must come back with real effects.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn panel_toggle_keys_dispatch_to_real_effects() {
|
fn panel_toggle_keys_dispatch_to_real_effects() {
|
||||||
use crate::command::cmd::test_helpers::*;
|
use crate::command::cmd::test_helpers::*;
|
||||||
@@ -837,10 +742,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The seven modes that carry a `MinibufferConfig`, each with the keys
|
/// The seven modes carrying a `MinibufferConfig`, with their commit/leave keys.
|
||||||
/// that commit or leave it. `SearchMode` is not among them: it is
|
/// `SearchMode` is Normal plus a flag, so it has no config to agree with.
|
||||||
/// `Normal` plus a flag, so it has no `AppMode` variant and no
|
|
||||||
/// minibuffer config to agree with.
|
|
||||||
fn text_entry_modes_with_exits() -> Vec<(AppMode, &'static [KeyCode])> {
|
fn text_entry_modes_with_exits() -> Vec<(AppMode, &'static [KeyCode])> {
|
||||||
const COMMIT_STAY: &[KeyCode] = &[KeyCode::Esc, KeyCode::Enter, KeyCode::Tab];
|
const COMMIT_STAY: &[KeyCode] = &[KeyCode::Esc, KeyCode::Enter, KeyCode::Tab];
|
||||||
const COMMIT_LEAVE: &[KeyCode] = &[KeyCode::Esc, KeyCode::Enter];
|
const COMMIT_LEAVE: &[KeyCode] = &[KeyCode::Esc, KeyCode::Enter];
|
||||||
@@ -876,12 +779,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-k8h: the buffer name used to be written on both sides of the
|
/// improvise-k8h: the prompt's buffer and the buffer `append-char` edits used to
|
||||||
/// crate — `AppMode`'s `MinibufferConfig` names the buffer the prompt
|
/// be written separately, and could disagree with nothing to notice.
|
||||||
/// describes, and the keymap names the buffer `append-char` edits. If
|
|
||||||
/// those disagree the mode prompts over one buffer while every keystroke
|
|
||||||
/// edits another, with nothing to notice. `TextEntry` now reads the name
|
|
||||||
/// off the mode, and this pins the result for every text-entry mode.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_text_entry_mode_types_into_the_buffer_its_prompt_names() {
|
fn every_text_entry_mode_types_into_the_buffer_its_prompt_names() {
|
||||||
let ks = KeymapSet::default_keymaps();
|
let ks = KeymapSet::default_keymaps();
|
||||||
@@ -899,9 +798,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The list above must stay complete: every keymap that types into a
|
/// The list above must stay complete: every keymap that types into a buffer
|
||||||
/// buffer belongs to a mode whose minibuffer names that same buffer.
|
/// belongs to a text-entry mode. Only `SearchMode` is exempt.
|
||||||
/// Only `SearchMode`, which has no `AppMode` variant, is exempt.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_typing_keymap_belongs_to_a_text_entry_mode() {
|
fn every_typing_keymap_belongs_to_a_text_entry_mode() {
|
||||||
let ks = KeymapSet::default_keymaps();
|
let ks = KeymapSet::default_keymaps();
|
||||||
@@ -929,18 +827,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every minibuffer mode types into exactly one buffer, and every key that
|
/// Every commit or leave key clears the buffer it typed into — one left behind
|
||||||
/// commits or leaves must clear that same buffer on the way — a buffer left
|
/// pre-fills the next entry. `SearchMode` is excluded on purpose.
|
||||||
/// behind pre-fills the next entry with the last one. `SearchMode` is
|
|
||||||
/// excluded on purpose: it has no commit and its query is owned by
|
|
||||||
/// `exit-search-mode`, not by `clear-buffer`.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn text_entry_modes_clear_the_buffer_they_type_into() {
|
fn text_entry_modes_clear_the_buffer_they_type_into() {
|
||||||
let none = KeyModifiers::NONE;
|
let none = KeyModifiers::NONE;
|
||||||
let ks = KeymapSet::default_keymaps();
|
let ks = KeymapSet::default_keymaps();
|
||||||
// The buffer is derived from each mode rather than restated here —
|
// The buffer is derived from each mode rather than restated here; pinning
|
||||||
// `every_text_entry_mode_types_into_the_buffer_its_prompt_names`
|
// what it should be is k8h's test above.
|
||||||
// owns pinning what it should be (improvise-k8h).
|
|
||||||
for (app_mode, exits) in text_entry_modes_with_exits() {
|
for (app_mode, exits) in text_entry_modes_with_exits() {
|
||||||
let mode = ModeKey::from_app_mode(&app_mode, false).unwrap();
|
let mode = ModeKey::from_app_mode(&app_mode, false).unwrap();
|
||||||
let buffer = append_target(&ks, &app_mode);
|
let buffer = append_target(&ks, &app_mode);
|
||||||
@@ -960,10 +854,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-6t3: the three commit commands collapsed into a single
|
/// improvise-6t3: `dispatch` swallows an unresolvable name with `.ok()?`, so a
|
||||||
/// `commit-buffer <kind>`. `dispatch` swallows an unresolvable command name
|
/// lookup-only assertion would pass had the rename missed the registry.
|
||||||
/// with `.ok()?`, so a lookup-only assertion would still pass had the
|
|
||||||
/// rename missed the registry — Enter has to come back with real effects.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn minibuffer_enter_dispatches_to_a_registered_commit() {
|
fn minibuffer_enter_dispatches_to_a_registered_commit() {
|
||||||
use crate::command::cmd::test_helpers::*;
|
use crate::command::cmd::test_helpers::*;
|
||||||
@@ -1018,19 +910,8 @@ mod binding_dump {
|
|||||||
use super::tests_support::*;
|
use super::tests_support::*;
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// The instrument `doctrine(binding_dump_diff)` asks for: every
|
/// `doctrine(binding_dump_diff)`'s instrument: every lookup in every mode, to the
|
||||||
/// `(mode, key, modifier)` lookup across all sixteen modes, written to
|
/// file `IMPROVISE_BINDING_DUMP` names. Inert without it — see the evidence.
|
||||||
/// the file named by `IMPROVISE_BINDING_DUMP`.
|
|
||||||
///
|
|
||||||
/// Most bindings have no direct test, so a green suite is not evidence
|
|
||||||
/// that a keymap refactor preserved them. Dump before, dump after, diff.
|
|
||||||
/// It is inert without the variable: a test that writes a file nobody
|
|
||||||
/// asked for is a surprise, not a check.
|
|
||||||
///
|
|
||||||
/// This is what proved the keymap move to `improvise-protocol` changed
|
|
||||||
/// nothing — 4021 resolved lookups, byte-identical either side — and
|
|
||||||
/// the diff was shown to be discriminating by renaming one binding and
|
|
||||||
/// watching eight lines move.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dump_every_binding() {
|
fn dump_every_binding() {
|
||||||
let Ok(path) = std::env::var("IMPROVISE_BINDING_DUMP") else {
|
let Ok(path) = std::env::var("IMPROVISE_BINDING_DUMP") else {
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
//! Command layer — all model mutations go through this layer so they can be
|
//! Command layer — every model mutation goes through it, so mutations can be
|
||||||
//! replayed, scripted, and tested without the TUI.
|
//! replayed, scripted and tested without the TUI. See `context/code/command.md`.
|
||||||
//!
|
|
||||||
//! Commands are trait objects (`dyn Cmd`) that produce effects (`dyn Effect`).
|
|
||||||
//! The headless CLI (--cmd / --script) parses quasi-lisp text into effects
|
|
||||||
//! and applies them directly.
|
|
||||||
|
|
||||||
pub mod cmd;
|
pub mod cmd;
|
||||||
/// Key types moved to `improvise-protocol` (improvise-cqi) — they ride in
|
/// Key types live in `improvise-protocol` (improvise-cqi) — they ride in
|
||||||
/// `ViewState::transient_prefix`, so they must live where the wire types do.
|
/// `ViewState::transient_prefix`. Re-exported so existing paths keep working.
|
||||||
/// Re-exported here so `crate::command::key::…` paths keep working.
|
|
||||||
pub mod key {
|
pub mod key {
|
||||||
pub use improvise_protocol::key::*;
|
pub use improvise_protocol::key::*;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
//! Quasi-lisp prefix command parser.
|
//! Quasi-lisp prefix command parser: `word arg1 arg2 …`, `.` between commands,
|
||||||
//!
|
//! `Category/Item` coordinate pairs, `"quoted strings"`.
|
||||||
//! Syntax: `word arg1 arg2 ...`
|
|
||||||
//! Multiple commands on one line separated by `.`
|
|
||||||
//! Coordinate pairs use `/`: `Category/Item`
|
|
||||||
//! Quoted strings supported: `"Profit = Revenue - Cost"`
|
|
||||||
|
|
||||||
use super::cmd::{Cmd, CmdRegistry, default_registry};
|
use super::cmd::{Cmd, CmdRegistry, default_registry};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
//! Command layer and semantic app state for improvise — no terminal deps.
|
//! Command layer and semantic app state for improvise — no terminal deps. The
|
||||||
//!
|
//! `Cmd`/`Effect` pipeline, keymaps, and the `App` state machine.
|
||||||
//! Holds the `Cmd`/`Effect` pipeline, keymaps, and the `App` state machine.
|
|
||||||
//! Rendering lives in the root `improvise` crate.
|
|
||||||
|
|
||||||
pub mod command;
|
pub mod command;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
@@ -12,9 +10,6 @@ pub use improvise_core::{format, model, view, workbook};
|
|||||||
pub use improvise_formula as formula;
|
pub use improvise_formula as formula;
|
||||||
pub use improvise_io::{import, persistence, storage};
|
pub use improvise_io::{import, persistence, storage};
|
||||||
|
|
||||||
/// The wire vocabulary and the per-widget caches (improvise-rkeq).
|
/// The wire vocabulary and the per-widget caches (improvise-rkeq), re-exported so a
|
||||||
///
|
/// host can name a cache without its own `improvise-protocol` dependency.
|
||||||
/// Re-exported so a host — the ratatui binary, a wasm worker — can name a
|
|
||||||
/// widget cache without taking its own `improvise-protocol` dependency, and
|
|
||||||
/// so the cache types a host renders are the same ones `ui::project` builds.
|
|
||||||
pub use improvise_protocol as protocol;
|
pub use improvise_protocol as protocol;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,5 @@
|
|||||||
//! The category panel's flattening.
|
//! The category panel's flattening. The derivation itself is
|
||||||
//!
|
//! `improvise_protocol::viewmodel` (improvise-edp); what is left here is the path.
|
||||||
//! The derivation itself moved to `improvise_protocol::viewmodel`
|
|
||||||
//! (improvise-edp), because the browser client needs it and can only reach
|
|
||||||
//! the protocol crate. What is left here is the path that call sites in this
|
|
||||||
//! crate and in the TUI already spell, and the one convenience that needs an
|
|
||||||
//! `App`: building the cache before flattening it.
|
|
||||||
|
|
||||||
pub use improvise_protocol::viewmodel::{CatTreeEntry, flatten_cat_tree};
|
pub use improvise_protocol::viewmodel::{CatTreeEntry, flatten_cat_tree};
|
||||||
|
|
||||||
@@ -13,9 +8,8 @@ use std::collections::HashSet;
|
|||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
use crate::view::View;
|
use crate::view::View;
|
||||||
|
|
||||||
/// Build the flattened tree of categories and their items straight from the
|
/// Build the flattened tree straight from the model, for a host that holds the
|
||||||
/// model, for a host that holds the authoritative state rather than a folded
|
/// authoritative state — the shortcut `project::category_tree_of` is for widgets.
|
||||||
/// cache — the same shortcut `project::category_tree_of` is for the widgets.
|
|
||||||
pub fn build_cat_tree(model: &Model, view: &View, expanded: &HashSet<String>) -> Vec<CatTreeEntry> {
|
pub fn build_cat_tree(model: &Model, view: &View, expanded: &HashSet<String>) -> Vec<CatTreeEntry> {
|
||||||
let cache = improvise_protocol::CategoryTreeCache {
|
let cache = improvise_protocol::CategoryTreeCache {
|
||||||
categories: crate::ui::project::category_rows(model, view),
|
categories: crate::ui::project::category_rows(model, view),
|
||||||
@@ -48,12 +42,8 @@ mod tests {
|
|||||||
names.iter().map(|n| n.to_string()).collect()
|
names.iter().map(|n| n.to_string()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-vjms: the panel lists the virtual categories alongside the
|
/// improvise-vjms: the panel lists the virtual categories, and `_Measure`'s items
|
||||||
/// regular ones, and `_Measure`'s items are *derived* — explicit items ∪
|
/// are *derived* (gotcha 7). Reading `.items` passes every other assertion here.
|
||||||
/// formula targets (gotcha 7). An implementation reading
|
|
||||||
/// `category("_Measure").items` passes every other assertion here and
|
|
||||||
/// silently drops `Profit`, which is the failure improvise-ete was
|
|
||||||
/// closed for proposing.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_tree_carries_the_virtual_categories_and_measure_includes_formula_targets() {
|
fn the_tree_carries_the_virtual_categories_and_measure_includes_formula_targets() {
|
||||||
let mut wb = workbook_with_categories(&[("Region", &["North", "South"])]);
|
let mut wb = workbook_with_categories(&[("Region", &["North", "South"])]);
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ use crate::view::{Axis, RecordCol, RecordRow};
|
|||||||
|
|
||||||
use super::app::{App, AppMode, ViewFrame};
|
use super::app::{App, AppMode, ViewFrame};
|
||||||
|
|
||||||
/// `Panel` is defined next to `AppMode` (the two are inverses of each other:
|
/// `Panel` is defined next to `AppMode` (`Panel::mode` / `AppMode::panel` are
|
||||||
/// `Panel::mode` / `AppMode::panel`) and re-exported here, where most callers
|
/// inverses) and re-exported here, where most callers already look for it.
|
||||||
/// already look for it.
|
|
||||||
pub use super::app::Panel;
|
pub use super::app::Panel;
|
||||||
|
|
||||||
pub(crate) const RECORD_COORDS_CANNOT_BE_EMPTY: &str = "Record coordinates cannot be empty";
|
pub(crate) const RECORD_COORDS_CANNOT_BE_EMPTY: &str = "Record coordinates cannot be empty";
|
||||||
@@ -17,12 +16,8 @@ pub(crate) const RECORD_COORDS_CANNOT_BE_EMPTY: &str = "Record coordinates canno
|
|||||||
/// have to be told about it (improvise-mae).
|
/// have to be told about it (improvise-mae).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum EffectKind {
|
pub enum EffectKind {
|
||||||
/// Changes only the acting client's own view — cursor, scroll, mode,
|
/// Changes only the acting client's own view — cursor, scroll, mode, panels,
|
||||||
/// panels, minibuffer text, drill staging, status line. Never projected.
|
/// minibuffer, drill staging, status line. Never projected (improvise-8uz).
|
||||||
///
|
|
||||||
/// Cursor and scroll live in the shared `View` (they are persisted in
|
|
||||||
/// `.improv`), but they are a per-client concern: one client scrolling
|
|
||||||
/// must not move another's viewport. See improvise-8uz.
|
|
||||||
ViewOnly,
|
ViewOnly,
|
||||||
/// Changes something every client renders.
|
/// Changes something every client renders.
|
||||||
Model(ModelChange),
|
Model(ModelChange),
|
||||||
@@ -46,33 +41,13 @@ pub enum ModelChange {
|
|||||||
pub trait Effect: Debug {
|
pub trait Effect: Debug {
|
||||||
fn apply(&self, app: &mut App);
|
fn apply(&self, app: &mut App);
|
||||||
|
|
||||||
/// How this effect must be projected to other clients. Deliberately has
|
/// How this effect must be projected. **No default**: a new effect that
|
||||||
/// no default: a new effect that forgets to classify itself would
|
/// forgot to classify itself would silently reach no client (improvise-mae).
|
||||||
/// silently never reach connected clients, so the compiler asks
|
|
||||||
/// (improvise-mae).
|
|
||||||
fn kind(&self) -> EffectKind;
|
fn kind(&self) -> EffectKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Declare a setter effect: a one-field newtype whose `apply` writes that
|
/// A one-field newtype whose `apply` writes that field into one place on the
|
||||||
/// field into one place on the `App`.
|
/// `App`. The kind argument is mandatory — see `context/code/command.md`.
|
||||||
///
|
|
||||||
/// The kind is a **mandatory** argument, even though every call site so far
|
|
||||||
/// answers `ViewOnly`. Hard-coding it reintroduced exactly the default that
|
|
||||||
/// `Effect::kind` deliberately does without (gotcha 3): `$place` is an
|
|
||||||
/// unconstrained expression, so
|
|
||||||
/// `simple_effect!(X(String) => |app| app.model_state.workbook.active_view_mut().number_format, …)`
|
|
||||||
/// is a textbook `LayoutGeometry` write that the macro would have reported as
|
|
||||||
/// `ViewOnly`, silently, with the whole suite green. Writing the answer per
|
|
||||||
/// call site is the same reasoning that gave `kind()` no default.
|
|
||||||
///
|
|
||||||
/// Within that, the macro is still the single hook for anything these setters
|
|
||||||
/// should all do — today none of them marks the document dirty or rebuilds the
|
|
||||||
/// layout, and the macro makes that one decision instead of fifteen silent
|
|
||||||
/// ones (improvise-8fy).
|
|
||||||
///
|
|
||||||
/// `Clone::clone` rather than `.clone()`: the field is `usize` or `bool` as
|
|
||||||
/// often as it is a `String`, and the call form keeps `clippy::clone_on_copy`
|
|
||||||
/// off the generated code.
|
|
||||||
macro_rules! simple_effect {
|
macro_rules! simple_effect {
|
||||||
($(#[$meta:meta])* $name:ident($ty:ty) => |$app:ident| $place:expr, $kind:expr) => {
|
($(#[$meta:meta])* $name:ident($ty:ty) => |$app:ident| $place:expr, $kind:expr) => {
|
||||||
$(#[$meta])*
|
$(#[$meta])*
|
||||||
@@ -95,17 +70,8 @@ macro_rules! simple_effect {
|
|||||||
pub struct AddCategory(pub String);
|
pub struct AddCategory(pub String);
|
||||||
impl Effect for AddCategory {
|
impl Effect for AddCategory {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
// A refusal aborts the batch, for the same reason a failed save does
|
// A refusal aborts the batch for gotcha 21's reason: what follows
|
||||||
// (gotcha 21): what follows assumes the category exists. From the
|
// assumes the category exists (improvise-2zq).
|
||||||
// `+cat` column (improvise-2zq) the rest of the batch would advance
|
|
||||||
// the cursor and re-open an editor on a column that was never
|
|
||||||
// created — and re-opening the minibuffer is also what would *hide*
|
|
||||||
// this message, since `draw_bottom_bar` renders the minibuffer
|
|
||||||
// instead of the status line.
|
|
||||||
//
|
|
||||||
// `MAX_CATEGORIES` is a reachable refusal on that path rather than a
|
|
||||||
// theoretical one, and the limit is checked in exactly one place —
|
|
||||||
// `Model::add_category` — so nothing here restates it.
|
|
||||||
if let Err(e) = app.model_state.workbook.add_category(&self.0) {
|
if let Err(e) = app.model_state.workbook.add_category(&self.0) {
|
||||||
app.view_state.status_msg = e.to_string();
|
app.view_state.status_msg = e.to_string();
|
||||||
app.abort_effects = true;
|
app.abort_effects = true;
|
||||||
@@ -202,9 +168,8 @@ impl Effect for AddFormula {
|
|||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
match crate::formula::parse_formula(&self.raw, &self.target_category) {
|
match crate::formula::parse_formula(&self.raw, &self.target_category) {
|
||||||
Ok(formula) => {
|
Ok(formula) => {
|
||||||
// For non-_Measure targets, add the item to the category so it
|
// Non-_Measure targets need the item added so it appears in the
|
||||||
// appears in the grid. _Measure targets are dynamically included
|
// grid; _Measure targets arrive via `measure_item_names()`.
|
||||||
// via Model::measure_item_names().
|
|
||||||
if !crate::model::category::CategoryKind::of_name(&formula.target_category)
|
if !crate::model::category::CategoryKind::of_name(&formula.target_category)
|
||||||
.is_measure()
|
.is_measure()
|
||||||
{
|
{
|
||||||
@@ -242,9 +207,8 @@ impl Effect for RemoveFormula {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh the derived grid layout mid-batch. `apply_effects` rebuilds once
|
/// Refresh the derived grid layout mid-batch: `apply_effects` rebuilds only once
|
||||||
/// after the whole batch, so any effect that must *read* the layout after an
|
/// after the batch, so an effect that *reads* it needs this first (improvise-ehz).
|
||||||
/// earlier effect changed it needs this in front of it (improvise-ehz).
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct RebuildLayout;
|
pub struct RebuildLayout;
|
||||||
impl Effect for RebuildLayout {
|
impl Effect for RebuildLayout {
|
||||||
@@ -256,16 +220,8 @@ impl Effect for RebuildLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-enter edit mode by reading the cell value at the current cursor.
|
/// Re-enter edit mode from the cell at the cursor. `target_mode` is the caller's
|
||||||
/// Used after commit+advance to continue data entry.
|
/// decision, never `app.view_state.mode` — see `context/code/command.md`.
|
||||||
///
|
|
||||||
/// Reads the *current* layout — emit `RebuildLayout` first when an earlier
|
|
||||||
/// effect in the batch moved the cursor or changed the grid.
|
|
||||||
///
|
|
||||||
/// `target_mode` is supplied by the caller (keymap binding via
|
|
||||||
/// `EnterEditAtCursorCmd`, or `CommitAndAdvance` from its own `edit_mode`
|
|
||||||
/// field). The effect itself never inspects `app.view_state.mode` — the mode is decided
|
|
||||||
/// statically by whoever invoked us.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct EnterEditAtCursor {
|
pub struct EnterEditAtCursor {
|
||||||
pub target_mode: AppMode,
|
pub target_mode: AppMode,
|
||||||
@@ -506,9 +462,8 @@ pub struct TransposeAxes;
|
|||||||
impl Effect for TransposeAxes {
|
impl Effect for TransposeAxes {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.model_state.workbook.active_view_mut().transpose_axes();
|
app.model_state.workbook.active_view_mut().transpose_axes();
|
||||||
// Swapping the axes renumbers every row and column, so the old
|
// Swapping the axes renumbers every row and column, so the old indices
|
||||||
// indices name different cells. The reset used to live inside
|
// name different cells (improvise-8uz, gotcha 24).
|
||||||
// View::transpose_axes; it moved here with the cursor (improvise-8uz).
|
|
||||||
app.reset_cursor();
|
app.reset_cursor();
|
||||||
}
|
}
|
||||||
fn kind(&self) -> EffectKind {
|
fn kind(&self) -> EffectKind {
|
||||||
@@ -565,16 +520,8 @@ simple_effect!(SetColOffset(usize) => |app| app.cursor_mut().col_offset, EffectK
|
|||||||
|
|
||||||
simple_effect!(ChangeMode(AppMode) => |app| app.view_state.mode, EffectKind::ViewOnly);
|
simple_effect!(ChangeMode(AppMode) => |app| app.view_state.mode, EffectKind::ViewOnly);
|
||||||
|
|
||||||
/// Return to `Normal` unless something earlier in this batch already moved
|
/// Return to `Normal` unless something earlier in this batch already moved the
|
||||||
/// the mode away from `from` — the mode the batch started in.
|
/// mode away from `from`. Observed, never predicted (improvise-toc, gotcha 31).
|
||||||
///
|
|
||||||
/// `ExecuteCommand` used to decide this up front by asking each effect a
|
|
||||||
/// per-type `changes_mode()`. That cannot describe `ViewBack`, `ViewForward`
|
|
||||||
/// or `StartImportWizard`, which swap the mode only on some runs: answering
|
|
||||||
/// "no" snapped a successful `:import` straight back out of the wizard,
|
|
||||||
/// while answering "yes" would have stranded a failed one in CommandMode.
|
|
||||||
/// Reading the mode here, after the batch has run, needs no prediction
|
|
||||||
/// (improvise-toc).
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ReturnToNormalUnlessMoved(pub AppMode);
|
pub struct ReturnToNormalUnlessMoved(pub AppMode);
|
||||||
impl Effect for ReturnToNormalUnlessMoved {
|
impl Effect for ReturnToNormalUnlessMoved {
|
||||||
@@ -596,12 +543,8 @@ simple_effect!(SetSearchQuery(String) => |app| app.view_state.search_query, Effe
|
|||||||
|
|
||||||
simple_effect!(SetSearchMode(bool) => |app| app.view_state.search_mode, EffectKind::ViewOnly);
|
simple_effect!(SetSearchMode(bool) => |app| app.view_state.search_mode, EffectKind::ViewOnly);
|
||||||
|
|
||||||
/// Set a named buffer's contents.
|
/// Set a named buffer's contents. The search query is *not* a buffer; commands
|
||||||
///
|
/// pick between the two in `cmd::core::write_buffer` (improvise-gdl).
|
||||||
/// Every name goes into the buffers map. The search query is not a buffer —
|
|
||||||
/// it has its own `ViewState` field and its own `SetSearchQuery` effect — and
|
|
||||||
/// commands reach the right one through `cmd::core::write_buffer`, next to
|
|
||||||
/// the `read_buffer` it mirrors (improvise-gdl).
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct SetBuffer {
|
pub struct SetBuffer {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -658,17 +601,13 @@ impl Effect for ApplyAndClearDrill {
|
|||||||
RecordCol::Category(category) => {
|
RecordCol::Category(category) => {
|
||||||
apply_coordinate_rename(app, row, category, new_value);
|
apply_coordinate_rename(app, row, category, new_value);
|
||||||
}
|
}
|
||||||
// Never staged — `commit_cell_value` applies a new measure
|
// Never staged: `commit_cell_value` applies a new measure or
|
||||||
// or category immediately, since a schema change is not a
|
// category immediately — a schema change is not a cell edit.
|
||||||
// cell edit and has nothing to roll back with the drill.
|
|
||||||
RecordCol::NewMeasure | RecordCol::NewCategory => {}
|
RecordCol::NewMeasure | RecordCol::NewCategory => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No `dirty = true` here: this declares `Model(CellData)` and
|
// No `dirty = true` here: this declares `Model(CellData)` and
|
||||||
// `App::apply_effects` raises the flag off that (improvise-gv9r). The
|
// `App::apply_effects` raises the flag off that (improvise-gv9r).
|
||||||
// line that used to be here was the last survivor of `MarkDirty` —
|
|
||||||
// harmless, and a second home for the one thing that commit set out
|
|
||||||
// to give exactly one.
|
|
||||||
}
|
}
|
||||||
fn kind(&self) -> EffectKind {
|
fn kind(&self) -> EffectKind {
|
||||||
EffectKind::Model(ModelChange::CellData)
|
EffectKind::Model(ModelChange::CellData)
|
||||||
@@ -754,9 +693,8 @@ impl Effect for Save {
|
|||||||
app.model_state.dirty = false;
|
app.model_state.dirty = false;
|
||||||
app.view_state.status_msg = msg;
|
app.view_state.status_msg = msg;
|
||||||
}
|
}
|
||||||
// A failed save aborts the rest of the batch, so the `Quit`
|
// A failed save aborts the rest of the batch, so the `Quit` that
|
||||||
// that `wq`/`ZZ` chains after this never lands and the error
|
// `wq`/`ZZ` chains never lands (improvise-8tk, gotcha 21).
|
||||||
// stays on screen (improvise-8tk).
|
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
app.view_state.status_msg = msg;
|
app.view_state.status_msg = msg;
|
||||||
app.abort_effects = true;
|
app.abort_effects = true;
|
||||||
@@ -794,9 +732,8 @@ impl Effect for SaveAs {
|
|||||||
app.model_state.dirty = false;
|
app.model_state.dirty = false;
|
||||||
app.view_state.status_msg = format!("Saved to {}", self.0.display());
|
app.view_state.status_msg = format!("Saved to {}", self.0.display());
|
||||||
}
|
}
|
||||||
// The same abort `Save` owes (improvise-8tk). `SaveAs` is the
|
// The same abort `Save` owes: `SaveAs` is the arm improvise-8tk
|
||||||
// other arm of that rule and was left out of the original fix,
|
// left out, so `:w /bad/path . q` quit on a failed write.
|
||||||
// so `:w /bad/path . q` quit on a failed write.
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
app.view_state.status_msg = format!("Save error: {e}");
|
app.view_state.status_msg = format!("Save error: {e}");
|
||||||
app.abort_effects = true;
|
app.abort_effects = true;
|
||||||
@@ -808,9 +745,8 @@ impl Effect for SaveAs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One discrete mutation of the import wizard's own state. Which op a key
|
/// One discrete mutation of the import wizard's own state. Which op a key means
|
||||||
/// means in a given step is decided by `HandleWizardKey`; this enum is just
|
/// in a given step is `HandleWizardKey`'s call; this is just the verb (improvise-2wm).
|
||||||
/// the verb (improvise-2wm).
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum WizardOp {
|
pub enum WizardOp {
|
||||||
Advance,
|
Advance,
|
||||||
@@ -881,9 +817,8 @@ impl Effect for CloseWizard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Commit the wizard: build a workbook from its proposals and install it.
|
/// Commit the wizard: build a workbook from its proposals and install it. On
|
||||||
/// On failure the wizard stays open carrying the error so the user can go
|
/// failure the wizard stays open carrying the error, so the user can go back.
|
||||||
/// back and fix their field choices.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct AcceptWizardModel;
|
pub struct AcceptWizardModel;
|
||||||
impl Effect for AcceptWizardModel {
|
impl Effect for AcceptWizardModel {
|
||||||
@@ -978,10 +913,8 @@ impl Effect for LoadModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract the records array from an already-parsed JSON import value.
|
/// The records array out of an already-parsed JSON import value: explicit
|
||||||
/// Precedence: explicit `array_path` → root array → first auto-detected
|
/// `array_path` → root array → first auto-detected. Pure (improvise-oaq).
|
||||||
/// array path. Pure — lets [`ImportJsonHeadless`] parse the file exactly
|
|
||||||
/// once and reuse the parsed value as the pipeline's `raw` (improvise-oaq).
|
|
||||||
fn json_import_records(
|
fn json_import_records(
|
||||||
value: &serde_json::Value,
|
value: &serde_json::Value,
|
||||||
array_path: Option<&str>,
|
array_path: Option<&str>,
|
||||||
@@ -1022,9 +955,8 @@ impl Effect for ImportJsonHeadless {
|
|||||||
.extension()
|
.extension()
|
||||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("csv"));
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("csv"));
|
||||||
|
|
||||||
// Parse the file exactly once: `records` feeds analysis, `raw` is the
|
// Parse the file exactly once: `records` feeds analysis, `raw` is reused
|
||||||
// parsed value reused for the pipeline (improvise-oaq). CSV warnings
|
// as the pipeline's value (improvise-oaq). CSV warnings go to the status.
|
||||||
// (short rows, improvise-k8i) are threaded into the status message.
|
|
||||||
let mut warnings: Vec<String> = vec![];
|
let mut warnings: Vec<String> = vec![];
|
||||||
let (records, raw) = if is_csv {
|
let (records, raw) = if is_csv {
|
||||||
let bytes = match app.storage.read(&self.path) {
|
let bytes = match app.storage.read(&self.path) {
|
||||||
@@ -1152,11 +1084,8 @@ impl Effect for SetPanelCursor {
|
|||||||
|
|
||||||
// ── Chain control ────────────────────────────────────────────────────────────
|
// ── Chain control ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Signals `App::apply_effects` to skip the remaining effects in the batch.
|
/// Skip the remaining effects in this `apply_effects` batch, for when a
|
||||||
/// The flag is reset at the start of every `apply_effects` call, so each
|
/// sequence's premise no longer holds. The flag resets per dispatch.
|
||||||
/// dispatch starts clean. Use this when a sequence's premise no longer
|
|
||||||
/// holds (e.g. "advance to next cell" at bottom-right) and later effects
|
|
||||||
/// (e.g. "re-enter editing there") should be short-circuited.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct AbortChain;
|
pub struct AbortChain;
|
||||||
impl Effect for AbortChain {
|
impl Effect for AbortChain {
|
||||||
@@ -1170,10 +1099,8 @@ impl Effect for AbortChain {
|
|||||||
|
|
||||||
// ── Records hygiene ──────────────────────────────────────────────────────────
|
// ── Records hygiene ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Remove cells whose `CellKey` has no coordinates — these are meaningless
|
/// Remove cells whose `CellKey` has no coordinates — only `AddRecordRow` with no
|
||||||
/// records that can only be produced by `AddRecordRow` when no page
|
/// page filters makes those. The inverse of the entry `SortData`.
|
||||||
/// filters are set. Pushed by `ToggleRecordsMode` when leaving records
|
|
||||||
/// mode, as the inverse of the `SortData` that runs on entry.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct CleanEmptyRecords;
|
pub struct CleanEmptyRecords;
|
||||||
impl Effect for CleanEmptyRecords {
|
impl Effect for CleanEmptyRecords {
|
||||||
@@ -1276,11 +1203,8 @@ mod tests {
|
|||||||
|
|
||||||
// ── Storage injection (improvise-fuj) ───────────────────────────────
|
// ── Storage injection (improvise-fuj) ───────────────────────────────
|
||||||
|
|
||||||
/// improvise-fuj: every effect that touches a document goes through
|
/// improvise-fuj: the whole document surface runs on a `Storage` that is not
|
||||||
/// `App::storage`, so the whole save/load/import/export surface works on
|
/// a filesystem. Fails if any of these effects reaches for `std::fs`.
|
||||||
/// a backend that is not a filesystem. This is what a browser worker
|
|
||||||
/// will supply (improvise-i34); here `MemoryFs` stands in for it, and
|
|
||||||
/// the test would fail if any of these effects reached for `std::fs`.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_document_surface_runs_on_a_non_filesystem_backend() {
|
fn the_document_surface_runs_on_a_non_filesystem_backend() {
|
||||||
use crate::storage::{MemoryFs, Storage};
|
use crate::storage::{MemoryFs, Storage};
|
||||||
@@ -1359,11 +1283,8 @@ mod tests {
|
|||||||
serde_json::to_string(&app.model_state.workbook.views).expect("views serialize")
|
serde_json::to_string(&app.model_state.workbook.views).expect("views serialize")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-mae: a declared `kind()` is only worth having if it matches
|
/// improvise-mae: rather than restate each declaration, apply the effect and
|
||||||
/// what the effect actually does. Rather than restate each declaration,
|
/// check the shared state it claims not to touch is genuinely unchanged.
|
||||||
/// this applies the effect and checks the shared state it claims not to
|
|
||||||
/// touch is genuinely unchanged — a `ViewOnly` effect that quietly wrote
|
|
||||||
/// a cell would never reach connected clients.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn view_only_effects_leave_shared_state_alone() {
|
fn view_only_effects_leave_shared_state_alone() {
|
||||||
let cases: Vec<Box<dyn Effect>> = vec![
|
let cases: Vec<Box<dyn Effect>> = vec![
|
||||||
@@ -1408,9 +1329,8 @@ mod tests {
|
|||||||
model_before,
|
model_before,
|
||||||
"{effect:?} declares ViewOnly but changed model data"
|
"{effect:?} declares ViewOnly but changed model data"
|
||||||
);
|
);
|
||||||
// No exemptions: since improvise-8uz moved the cursor out of
|
// No exemptions: since improvise-8uz moved the cursor out of `View`,
|
||||||
// View, SetSelected/SetRowOffset/SetColOffset touch nothing
|
// every effect in this list must clear the same bar.
|
||||||
// shared, so every effect in this list must clear the same bar.
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
view_config_fingerprint(&app),
|
view_config_fingerprint(&app),
|
||||||
views_before,
|
views_before,
|
||||||
@@ -1419,15 +1339,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The structural effects, each pinned to the kind it must declare.
|
/// The structural effects, each pinned to the kind it must declare. The two
|
||||||
///
|
/// "actually change" tests below cannot separate them — see the evidence.
|
||||||
/// The bug this guards: `AddCategory` and `RemoveCategory` declared
|
|
||||||
/// `CellData`, but both go through `Workbook`, whose `on_category_added`
|
|
||||||
/// / `on_category_removed` rewrite **every** view's axis map. That is a
|
|
||||||
/// re-layout, so a client told only "cell data changed" would keep
|
|
||||||
/// rendering the old grid shape. The two "actually change" tests below
|
|
||||||
/// cannot see it — a category change moves model data *and* view config,
|
|
||||||
/// so it passes either list. Only pinning the declaration does.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn structural_effects_declare_their_kind() {
|
fn structural_effects_declare_their_kind() {
|
||||||
let cell = ModelChange::CellData;
|
let cell = ModelChange::CellData;
|
||||||
@@ -1582,12 +1495,8 @@ mod tests {
|
|||||||
assert!(items.contains(&"Electronics"));
|
assert!(items.contains(&"Electronics"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bug (improvise-djn): `AddCategory` discarded the `Result` from
|
/// Bug (improvise-djn): `AddCategory` discarded the `Result`, so the panel
|
||||||
/// `Workbook::add_category`, so pressing `n` in the category panel with
|
/// reported its command's optimistic message and added nothing.
|
||||||
/// the 12-regular-category limit already reached reported the optimistic
|
|
||||||
/// `Added category "X"` its command had queued and added nothing. The
|
|
||||||
/// effect runs last precisely so its failure can overwrite that message
|
|
||||||
/// (improvise-6t3) — it just never wrote one.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn add_category_past_the_limit_reports_the_failure() {
|
fn add_category_past_the_limit_reports_the_failure() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
@@ -1651,9 +1560,8 @@ mod tests {
|
|||||||
assert!(!app.model_state.workbook.model.formulas().is_empty());
|
assert!(!app.model_state.workbook.model.formulas().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression: AddFormula must add the target item to the target category
|
/// Regression: `AddFormula` must add the target item to the target category,
|
||||||
/// so the grid layout includes cells for it. Without this, a formula like
|
/// or the formula is registered but has no cells in the grid.
|
||||||
/// `Margin = Profit / Revenue` would be registered but invisible in the grid.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn add_formula_adds_target_item_to_category() {
|
fn add_formula_adds_target_item_to_category() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
@@ -1894,8 +1802,7 @@ mod tests {
|
|||||||
assert_eq!(app.view_state.mode, AppMode::Help);
|
assert_eq!(app.view_state.mode, AppMode::Help);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `AbortChain` must cause subsequent effects in the same
|
/// `AbortChain` skips the rest of the batch, and the flag must reset so the
|
||||||
/// `apply_effects` batch to be skipped, and the flag must reset so the
|
|
||||||
/// next dispatch starts clean.
|
/// next dispatch starts clean.
|
||||||
#[test]
|
#[test]
|
||||||
fn abort_chain_short_circuits_apply_effects() {
|
fn abort_chain_short_circuits_apply_effects() {
|
||||||
@@ -1920,10 +1827,8 @@ mod tests {
|
|||||||
assert_eq!(app.view_state.status_msg, "next-batch");
|
assert_eq!(app.view_state.status_msg, "next-batch");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BUG (improvise-8tk): `Save` reported failure only through the status
|
/// BUG (improvise-8tk): `Save` reported failure only to the status bar, so
|
||||||
/// bar, so the `Quit` that `wq`/`ZZ` chains after it landed anyway and
|
/// the chained `Quit` landed anyway and the session was discarded.
|
||||||
/// the whole session was discarded without the user seeing the error.
|
|
||||||
/// A failed save must abort the rest of the batch.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn save_without_file_path_aborts_the_chain() {
|
fn save_without_file_path_aborts_the_chain() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
@@ -1965,24 +1870,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BUG (improvise-lae, found reviewing it): improvise-8tk fixed **one
|
/// BUG (improvise-lae): improvise-8tk fixed **one arm** of a rule with two.
|
||||||
/// arm** of a rule with two. `Save` aborts the batch when the write
|
/// `:w /bad/path . q` builds exactly this batch — see the evidence.
|
||||||
/// fails; `SaveAs` reported the error to the status bar and let the
|
|
||||||
/// batch continue, which is the same silent discard 8tk closed — the
|
|
||||||
/// shape `doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)`
|
|
||||||
/// names, and the three tests above are evidence about `Save` only.
|
|
||||||
///
|
|
||||||
/// It is reachable today: the `:` line composes with ` . `, so
|
|
||||||
/// `:w /bad/path . q` builds exactly this batch, and
|
|
||||||
/// `ExecuteCommand` concatenates the two commands' effects into one.
|
|
||||||
/// improvise-lae's new save prompt also commits to `SaveAs`, so the
|
|
||||||
/// arm was about to carry more traffic.
|
|
||||||
///
|
|
||||||
/// Note what the abort does and does not buy the prompt: the typed path
|
|
||||||
/// survives for the user to correct, but the message does not reach the
|
|
||||||
/// screen, because `draw_bottom_bar` draws the minibuffer instead of the
|
|
||||||
/// status line while a prompt is open. That is pre-existing and reported
|
|
||||||
/// separately; the data-loss half is what this fixes.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn failed_save_as_aborts_the_chain() {
|
fn failed_save_as_aborts_the_chain() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
@@ -2009,8 +1898,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The positive arm: a `SaveAs` that succeeds must not abort, or every
|
/// The positive arm: a `SaveAs` that succeeds must not abort. Without it,
|
||||||
/// `:w <path>` would swallow whatever followed it. Without this,
|
|
||||||
/// "always abort" passes the test above.
|
/// "always abort" passes the test above.
|
||||||
#[test]
|
#[test]
|
||||||
fn successful_save_as_lets_the_chain_continue() {
|
fn successful_save_as_lets_the_chain_continue() {
|
||||||
@@ -2088,10 +1976,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `EnterEditAtCursor` must use its `target_mode` field, *not* whatever
|
/// `EnterEditAtCursor` uses its `target_mode` field, not whatever
|
||||||
/// `app.view_state.mode` happens to be when applied. Previous implementation
|
/// `app.view_state.mode` happens to be when it is applied.
|
||||||
/// branched on `app.view_state.mode.is_records()` — the parameterized version
|
|
||||||
/// trusts the caller (keymap or composing command).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enter_edit_at_cursor_uses_target_mode_not_app_mode() {
|
fn enter_edit_at_cursor_uses_target_mode_not_app_mode() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
@@ -2123,12 +2009,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-ehz: the edit buffer must be loaded from layout state that
|
/// improvise-ehz: the edit buffer must read layout that already reflects
|
||||||
/// already reflects earlier effects in the same batch. `apply_effects`
|
/// earlier effects in the batch, which is what a preceding `RebuildLayout` buys.
|
||||||
/// only rebuilds *after* the batch, so a preceding `RebuildLayout` is
|
|
||||||
/// what makes the cursor resolve to the post-mutation cell. Removing the
|
|
||||||
/// row-0 item slides a different cell under the cursor; the buffer must
|
|
||||||
/// show that cell's value, not the pre-batch one.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enter_edit_at_cursor_reads_post_batch_layout() {
|
fn enter_edit_at_cursor_reads_post_batch_layout() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
@@ -2234,10 +2116,8 @@ mod tests {
|
|||||||
assert_eq!(app.view_state.buffers.get("edit").unwrap(), "hello");
|
assert_eq!(app.view_state.buffers.get("edit").unwrap(), "hello");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `SetBuffer` no longer treats "search" as an alias for the search query
|
/// `SetBuffer` does not treat "search" as an alias for the search query
|
||||||
/// (improvise-gdl): it is a plain buffer write whatever the name says, and
|
/// (improvise-gdl); the query is reached through `SetSearchQuery`.
|
||||||
/// the query is reached through `SetSearchQuery`. Commands pick between
|
|
||||||
/// the two in `cmd::core::write_buffer`.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn set_buffer_does_not_special_case_the_search_name() {
|
fn set_buffer_does_not_special_case_the_search_name() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
@@ -2327,10 +2207,8 @@ mod tests {
|
|||||||
StartDrill(records).apply(&mut app);
|
StartDrill(records).apply(&mut app);
|
||||||
assert!(app.drill_state.is_some());
|
assert!(app.drill_state.is_some());
|
||||||
|
|
||||||
// Apply with no pending edits — should just clear state
|
// Through `apply_effects`, not `apply`: the dirty flag is raised off the
|
||||||
// Through `apply_effects`, not `apply`: the dirty flag is raised
|
// `EffectKind` (improvise-gv9r), so `apply` would assert nothing real.
|
||||||
// off the `EffectKind` now (improvise-gv9r), so driving the effect
|
|
||||||
// directly would assert a flag nothing in production sets.
|
|
||||||
app.apply_effects(vec![Box::new(ApplyAndClearDrill)]);
|
app.apply_effects(vec![Box::new(ApplyAndClearDrill)]);
|
||||||
assert!(app.drill_state.is_none());
|
assert!(app.drill_state.is_none());
|
||||||
assert!(
|
assert!(
|
||||||
@@ -2373,9 +2251,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
|
|
||||||
// Through `apply_effects`, not `apply`: the dirty flag is raised
|
// Through `apply_effects`, not `apply`: the dirty flag is raised off the
|
||||||
// off the `EffectKind` now (improvise-gv9r), so driving the effect
|
// `EffectKind` (improvise-gv9r), so `apply` would assert nothing real.
|
||||||
// directly would assert a flag nothing in production sets.
|
|
||||||
app.apply_effects(vec![Box::new(ApplyAndClearDrill)]);
|
app.apply_effects(vec![Box::new(ApplyAndClearDrill)]);
|
||||||
assert!(app.drill_state.is_none());
|
assert!(app.drill_state.is_none());
|
||||||
assert!(app.model_state.dirty);
|
assert!(app.model_state.dirty);
|
||||||
@@ -2385,10 +2262,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-c5v: in wide format a records row groups one cell per
|
/// improvise-c5v: in wide format a records row groups one cell per measure,
|
||||||
/// measure, so renaming a coordinate has to move every one of them. The
|
/// so renaming a coordinate has to move every one of them.
|
||||||
/// long-format code moved only the cell under the cursor, which would
|
|
||||||
/// now split the row in half.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_and_clear_drill_coord_rename_moves_every_measure_in_the_row() {
|
fn apply_and_clear_drill_coord_rename_moves_every_measure_in_the_row() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
@@ -2421,9 +2296,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
|
|
||||||
// Through `apply_effects`, not `apply`: the dirty flag is raised
|
// Through `apply_effects`, not `apply`: the dirty flag is raised off the
|
||||||
// off the `EffectKind` now (improvise-gv9r), so driving the effect
|
// `EffectKind` (improvise-gv9r), so `apply` would assert nothing real.
|
||||||
// directly would assert a flag nothing in production sets.
|
|
||||||
app.apply_effects(vec![Box::new(ApplyAndClearDrill)]);
|
app.apply_effects(vec![Box::new(ApplyAndClearDrill)]);
|
||||||
assert!(app.model_state.dirty);
|
assert!(app.model_state.dirty);
|
||||||
let renamed = |measure: &str| {
|
let renamed = |measure: &str| {
|
||||||
@@ -2482,9 +2356,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
|
|
||||||
// Through `apply_effects`, not `apply`: the dirty flag is raised
|
// Through `apply_effects`, not `apply`: the dirty flag is raised off the
|
||||||
// off the `EffectKind` now (improvise-gv9r), so driving the effect
|
// `EffectKind` (improvise-gv9r), so `apply` would assert nothing real.
|
||||||
// directly would assert a flag nothing in production sets.
|
|
||||||
app.apply_effects(vec![Box::new(ApplyAndClearDrill)]);
|
app.apply_effects(vec![Box::new(ApplyAndClearDrill)]);
|
||||||
assert_eq!(app.model_state.workbook.model.get_cell(&key), None);
|
assert_eq!(app.model_state.workbook.model.get_cell(&key), None);
|
||||||
}
|
}
|
||||||
@@ -2652,10 +2525,8 @@ mod tests {
|
|||||||
|
|
||||||
// ── Headless import ─────────────────────────────────────────────────
|
// ── Headless import ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/// BUG (improvise-k8i): ImportJsonHeadless parsed CSVs with `parse_csv`,
|
/// BUG (improvise-k8i): CSV short-row warnings went to stderr, invisible
|
||||||
/// which sends short-row warnings to stderr — invisible inside the TUI.
|
/// inside the TUI. They must surface in the status message.
|
||||||
/// Short rows must surface in the status message, naming the affected
|
|
||||||
/// column, never silently succeed.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn import_headless_csv_short_rows_surface_warning_in_status() {
|
fn import_headless_csv_short_rows_surface_warning_in_status() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -1,48 +1,5 @@
|
|||||||
//! The session loop: messages in, addressed messages out.
|
//! The session loop: messages in, addressed messages out. One `App` per
|
||||||
//!
|
//! *document*, many clients per `App` — see `context/code/command.md`.
|
||||||
//! A websocket is awkward to test, so nothing that decides anything lives in
|
|
||||||
//! one. [`Host`] is a pure-ish state machine over the authoritative `App` —
|
|
||||||
//! it takes one client's frame and returns the frames that owes every
|
|
||||||
//! client — and [`crate::transport`] is the shell that moves bytes in and
|
|
||||||
//! out of it. Every test in this crate drives `Host` directly; the socket is
|
|
||||||
//! exercised by running the binary.
|
|
||||||
//!
|
|
||||||
//! **One `App` per document, many clients per `App`.** The ticket
|
|
||||||
//! (improvise-q08) asked for one `App` per *connection*, "no shared
|
|
||||||
//! authoritative state across sessions yet". That premise no longer holds
|
|
||||||
//! (`doctrine(check_the_ticket_premise)`): `Sessions` exists precisely to
|
|
||||||
//! hold many [`ClientSession`](crate::ui::project::ClientSession)s
|
|
||||||
//! against one authoritative `App`, `apply_switch`/`apply_model` move every
|
|
||||||
//! mirror in lockstep rather than the asker's
|
|
||||||
//! (`doctrine(lockstep_binds_every_session_not_the_asker)`), and
|
|
||||||
//! `Applied::Diff::broadcast` exists only because other clients must hear
|
|
||||||
//! what this one did. An `App` per connection would make all of that dead
|
|
||||||
//! code and give two browser tabs on the same document two documents.
|
|
||||||
//!
|
|
||||||
//! Nor is it one `App` per *server*. improvise is a multi-document app, and
|
|
||||||
//! the unit of authority is the **document**: a `Host` is one `App`, one
|
|
||||||
//! `Sessions`, and the clients attached to them. That grain is what
|
|
||||||
//! `Sessions` was already built for — it never owns the `App`, every method
|
|
||||||
//! takes it as a parameter — and it is what makes concurrency scope for
|
|
||||||
//! free: sessions editing independent models cannot conflict, and the basis
|
|
||||||
//! versions ADR 001 reasons about live on the `App`, one sequence per
|
|
||||||
//! document (`document_generation` is the `App`'s).
|
|
||||||
//!
|
|
||||||
//! So a second document is a second `Host`, and hosting several means a
|
|
||||||
//! registry of them keyed by document id. That registry is
|
|
||||||
//! `improvise_ws_server::registry` (improvise-j31x), and it cost this file
|
|
||||||
//! **nothing**:
|
|
||||||
//! a `Host` was always a value rather than a singleton, and `handle` is
|
|
||||||
//! already addressed to one document by virtue of being a method. What the
|
|
||||||
//! registry adds above is the only thing a `Host` cannot supply — a
|
|
||||||
//! server-wide address, since `SessionId`s are minted per `Sessions` and
|
|
||||||
//! every document's first client is `SessionId(0)`.
|
|
||||||
//!
|
|
||||||
//! What sharing one document costs is honest and stated: there is no
|
|
||||||
//! per-client permission, no conflict resolution beyond last-writer-wins,
|
|
||||||
//! and a client that can connect can rewrite the document. ADR 001 owns
|
|
||||||
//! where that goes next; this host is the localhost single-user case that
|
|
||||||
//! ADR describes the starting point of.
|
|
||||||
|
|
||||||
use crate::ui::app::App;
|
use crate::ui::app::App;
|
||||||
use crate::ui::effect;
|
use crate::ui::effect;
|
||||||
@@ -52,24 +9,16 @@ use improvise_protocol::{Command, Rejection};
|
|||||||
|
|
||||||
use improvise_protocol::{ClientFrame, ServerFrame};
|
use improvise_protocol::{ClientFrame, ServerFrame};
|
||||||
|
|
||||||
/// One outbound frame and the connection it goes to.
|
/// One outbound frame and the connection it goes to. The host addresses by
|
||||||
///
|
/// [`SessionId`]; the transport owns the map from a session to a connection.
|
||||||
/// The host addresses by [`SessionId`] and knows nothing about sockets; the
|
|
||||||
/// transport owns the map from a session to a connection.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct Addressed {
|
pub struct Addressed {
|
||||||
pub to: SessionId,
|
pub to: SessionId,
|
||||||
pub msg: ServerFrame,
|
pub msg: ServerFrame,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One authoritative document and every client attached to it.
|
/// One authoritative document and every client attached to it — a value, never a
|
||||||
///
|
/// global. Not `Debug`: `App` is not, and a log line of the document is not wanted.
|
||||||
/// The unit a multi-document server would key its registry by: a `Host` is
|
|
||||||
/// a value, never a global, so hosting several is a map of these and not a
|
|
||||||
/// change to anything inside one.
|
|
||||||
///
|
|
||||||
/// Not `Debug`: `App` is not, and a `Host` printing the whole document into
|
|
||||||
/// a log line would not be wanted even if it were.
|
|
||||||
pub struct Host {
|
pub struct Host {
|
||||||
app: App,
|
app: App,
|
||||||
sessions: Sessions,
|
sessions: Sessions,
|
||||||
@@ -87,17 +36,8 @@ impl Host {
|
|||||||
&self.app
|
&self.app
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The authoritative `App`, for work the **host itself** initiates.
|
/// The authoritative `App`, for work the **host itself** initiates. Not a second
|
||||||
///
|
/// door: a client's `Command` goes through [`Host::handle`] (gotchas 36, 43).
|
||||||
/// Deliberately narrow in intent, and not a second door: a *client's*
|
|
||||||
/// `Command` must go through [`Host::handle`], which routes it into
|
|
||||||
/// `Sessions::apply` — the one place a basis is checked and the mirrors
|
|
||||||
/// are kept in lockstep (gotchas 36, 43). What this is for is the work a
|
|
||||||
/// host does on nobody's behalf, where there is no session to address and
|
|
||||||
/// no claim to check: the browser worker writes the document back through
|
|
||||||
/// the `Save` effect after a batch dirties it, because saving is
|
|
||||||
/// `EffectKind::ViewOnly` and so reaches no wire vocabulary
|
|
||||||
/// (`improvise-worker-server::worker`, which carries the argument).
|
|
||||||
pub fn app_mut(&mut self) -> &mut App {
|
pub fn app_mut(&mut self) -> &mut App {
|
||||||
&mut self.app
|
&mut self.app
|
||||||
}
|
}
|
||||||
@@ -106,13 +46,8 @@ impl Host {
|
|||||||
self.sessions.is_empty()
|
self.sessions.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attach a client that has reported an extent of `rows` x `cols`, and
|
/// Attach a client that has reported an extent, and hand back its bootstrap.
|
||||||
/// hand back its bootstrap.
|
/// Total, and nothing is owed to the other clients.
|
||||||
///
|
|
||||||
/// Total: there is no way for a connection to fail here, which is why
|
|
||||||
/// the transport reads the extent off the first frame before calling.
|
|
||||||
/// Nothing is owed to the *other* clients — a new connection changes no
|
|
||||||
/// document state and moves no mirror but its own.
|
|
||||||
pub fn connect(&mut self, rows: usize, cols: usize) -> (SessionId, Addressed) {
|
pub fn connect(&mut self, rows: usize, cols: usize) -> (SessionId, Addressed) {
|
||||||
let (id, snapshot) = self.sessions.connect(&self.app, rows, cols);
|
let (id, snapshot) = self.sessions.connect(&self.app, rows, cols);
|
||||||
(
|
(
|
||||||
@@ -124,11 +59,8 @@ impl Host {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A model command from a client that is fully caught up, for the tests
|
/// A model command from a client that is fully caught up, for the tests whose
|
||||||
/// whose subject is not ADR 001's basis. It goes through `handle` like
|
/// subject is not the basis. The staleness tests build their own `Basis`.
|
||||||
/// any other frame; only the basis is supplied rather than folded, and
|
|
||||||
/// [`caught_up`] can do that because it holds the `App` (a client
|
|
||||||
/// cannot). The staleness tests build their own `Basis`.
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn model(&mut self, id: SessionId, cmd: improvise_protocol::ModelCommand) -> Vec<Addressed> {
|
fn model(&mut self, id: SessionId, cmd: improvise_protocol::ModelCommand) -> Vec<Addressed> {
|
||||||
let wire = crate::ui::reduce::caught_up(&self.app, cmd);
|
let wire = crate::ui::reduce::caught_up(&self.app, cmd);
|
||||||
@@ -145,43 +77,15 @@ impl Host {
|
|||||||
match msg {
|
match msg {
|
||||||
ClientFrame::Viewport { rows, cols } => self.resize(id, rows, cols),
|
ClientFrame::Viewport { rows, cols } => self.resize(id, rows, cols),
|
||||||
ClientFrame::Commands(cmds) => self.commands(id, &cmds),
|
ClientFrame::Commands(cmds) => self.commands(id, &cmds),
|
||||||
// This arm answers nothing, and since improvise-bpq2 that is the
|
// Answering nothing is the settled answer, not a gap: host-side key
|
||||||
// settled answer rather than a gap. Resolving a keypress here
|
// resolution diverges (improvise-bpq2, gotcha 47).
|
||||||
// needs `reduce_key` against the authoritative `App`, which emits
|
|
||||||
// no `Command` a client could fold — so the two would diverge,
|
|
||||||
// which `improvise-worker-server`'s
|
|
||||||
// `a_key_resolved_on_the_host_moves_state_no_client_can_hear`
|
|
||||||
// demonstrates. bpq2 closed that by moving resolution to the
|
|
||||||
// client, so a key a client can answer never arrives as a `Key`
|
|
||||||
// frame at all, and one that does is a binding nothing on this
|
|
||||||
// side could answer without diverging. Growing the set is a
|
|
||||||
// matter of enriching the projection until the client can
|
|
||||||
// compose the command itself — see `improvise-wasm-client::keys`,
|
|
||||||
// which carries the list of what is still upstream.
|
|
||||||
ClientFrame::Key { .. } => Vec::new(),
|
ClientFrame::Key { .. } => Vec::new(),
|
||||||
ClientFrame::Save => self.save(id),
|
ClientFrame::Save => self.save(id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the document back, because a client asked (improvise-368h).
|
/// Write the document back, because a client asked (improvise-368h). No session
|
||||||
///
|
/// state moves and nobody else is told — see `context/code/command.md`.
|
||||||
/// **No session state moves and nobody else is told**, which is why this
|
|
||||||
/// does not go through `Sessions` at all: saving changes no document
|
|
||||||
/// state and no client's view slice, so there is no mirror to adopt and
|
|
||||||
/// nothing to broadcast. What moved is bytes in *this host's* storage —
|
|
||||||
/// the same fact that makes `effect::Save` `EffectKind::ViewOnly` and the
|
|
||||||
/// reason the `ModelCommand` derivation could never reach it (gotchas 3,
|
|
||||||
/// 38). Success is silent, on the same terms as any accepted command that
|
|
||||||
/// dirties no widget.
|
|
||||||
///
|
|
||||||
/// Both refusals are the host's to answer, not `Checked`'s: neither is
|
|
||||||
/// about a basis. The missing path is decided **before** anything runs,
|
|
||||||
/// because a save that cannot land should be refused rather than
|
|
||||||
/// attempted and then reported. The failed write can only be found by
|
|
||||||
/// trying, and `Save` signals it by aborting its batch (gotcha 21) — the
|
|
||||||
/// batch here is exactly `[Save]`, so an abort is that failure and
|
|
||||||
/// nothing else. Reporting a failed write as success is the thing this
|
|
||||||
/// exists to stop.
|
|
||||||
fn save(&mut self, id: SessionId) -> Vec<Addressed> {
|
fn save(&mut self, id: SessionId) -> Vec<Addressed> {
|
||||||
let refused = match self.app.model_state.file_path.is_none() {
|
let refused = match self.app.model_state.file_path.is_none() {
|
||||||
true => Some(Rejection::NoDocumentPath),
|
true => Some(Rejection::NoDocumentPath),
|
||||||
@@ -201,37 +105,20 @@ impl Host {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A resize dirties no document state at all — but it moves this
|
/// A resize dirties no document state, but it moves this client's viewport, and
|
||||||
/// client's viewport, and `ClientSession::project` emits the grid when
|
/// `project` emits the grid on that alone. Same fan-out, nothing dirty.
|
||||||
/// the viewport moved even under `Dirty::NOTHING`. So the fan-out is the
|
|
||||||
/// same call as everywhere else, with nothing dirty.
|
|
||||||
fn resize(&mut self, id: SessionId, rows: usize, cols: usize) -> Vec<Addressed> {
|
fn resize(&mut self, id: SessionId, rows: usize, cols: usize) -> Vec<Addressed> {
|
||||||
match self.sessions.get_mut(id) {
|
match self.sessions.get_mut(id) {
|
||||||
Some(session) => session.set_extent(rows, cols),
|
Some(session) => session.set_extent(rows, cols),
|
||||||
// A frame for a session that has already gone. Nothing to do,
|
// A frame for a session that has already gone: there is no connection
|
||||||
// and nothing that could be done: there is no connection left to
|
// left to answer.
|
||||||
// answer.
|
|
||||||
None => return Vec::new(),
|
None => return Vec::new(),
|
||||||
}
|
}
|
||||||
self.project(Dirty::NOTHING)
|
self.project(Dirty::NOTHING)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A batch: **answered whole, then applied whole** (improvise-955n).
|
/// A batch: **answered whole, then applied whole** (improvise-955n). [`CheckedBatch`]
|
||||||
///
|
/// carries what a basis means and the one residue this does not close.
|
||||||
/// Order is why a batch exists at all: one keypress resolved on a client
|
|
||||||
/// is `SetSelected` plus the scroll offsets that follow it, and a host
|
|
||||||
/// that folded the selection but not the offset projects that client the
|
|
||||||
/// wrong window.
|
|
||||||
///
|
|
||||||
/// It used to apply in order and abandon at the first rejection, which
|
|
||||||
/// left a hole ADR 001's "not even partially" does not have: a refused
|
|
||||||
/// frame's *earlier* commands stayed applied. The check has moved from
|
|
||||||
/// the command to the frame, so there is no longer a moment at which
|
|
||||||
/// part of a frame has landed and the rest has been refused — the
|
|
||||||
/// refusal, if any, happens before anything is applied, and
|
|
||||||
/// `Sessions::apply_checked` cannot produce one.
|
|
||||||
/// [`CheckedBatch`] carries what a batch's basis *means* and the one
|
|
||||||
/// residue this does not close.
|
|
||||||
fn commands(&mut self, id: SessionId, cmds: &[Command]) -> Vec<Addressed> {
|
fn commands(&mut self, id: SessionId, cmds: &[Command]) -> Vec<Addressed> {
|
||||||
let batch = match CheckedBatch::against(&self.app, cmds) {
|
let batch = match CheckedBatch::against(&self.app, cmds) {
|
||||||
Ok(batch) => batch,
|
Ok(batch) => batch,
|
||||||
@@ -251,14 +138,12 @@ impl Host {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The one door (gotcha 36), and the match that follows it is the
|
/// The one door (gotcha 36); the match that follows is the mechanism (gotcha 39):
|
||||||
/// mechanism (gotcha 39): asking for the `Dirty` means naming the case
|
/// asking for the `Dirty` means naming the case where a `Snapshot` is owed.
|
||||||
/// where a `Snapshot` is owed instead.
|
|
||||||
fn command(&mut self, id: SessionId, cmd: Checked<'_>) -> Vec<Addressed> {
|
fn command(&mut self, id: SessionId, cmd: Checked<'_>) -> Vec<Addressed> {
|
||||||
match self.sessions.apply_checked(&mut self.app, id, cmd) {
|
match self.sessions.apply_checked(&mut self.app, id, cmd) {
|
||||||
// The document was replaced. Nothing to diff against and nothing
|
// The document was replaced: nothing to diff and nothing to broadcast,
|
||||||
// to broadcast — each snapshot already carries a whole view slice
|
// each snapshot carrying a whole view slice and its seed.
|
||||||
// and the seed that reproduces the new document.
|
|
||||||
Applied::Rebased { snapshots } => snapshots
|
Applied::Rebased { snapshots } => snapshots
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(to, snapshot)| Addressed {
|
.map(|(to, snapshot)| Addressed {
|
||||||
@@ -267,25 +152,14 @@ impl Host {
|
|||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
Applied::Diff { dirty, broadcast } => {
|
Applied::Diff { dirty, broadcast } => {
|
||||||
// The broadcast precedes the projections, so a client's view
|
// The broadcast precedes the projections, so a client's slice is in
|
||||||
// slice is in the state the grid it is about to be sent was
|
// the state the grid it is about to get was built from (gotcha 36).
|
||||||
// built from. Dropping it for the bystanders leaves their
|
|
||||||
// slices naming the outgoing view while that grid comes from
|
|
||||||
// the incoming one — a stale mirror arriving through
|
|
||||||
// somebody else's keystroke (gotcha 36), which is the bug
|
|
||||||
// PR #10 was found to have. Who each item reaches is carried
|
|
||||||
// by the item, not decided here.
|
|
||||||
let mut out = self.relay(id, broadcast);
|
let mut out = self.relay(id, broadcast);
|
||||||
out.extend(self.project(dirty));
|
out.extend(self.project(dirty));
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
// Unreachable through this door since improvise-955n — a frame
|
// Unreachable through this door since improvise-955n, but a host that
|
||||||
// is refused before any of it is applied, by `commands` — but
|
// wants the `Dirty` must name every case (gotcha 39).
|
||||||
// `Applied` is the shape `Sessions::apply` answers in and a host
|
|
||||||
// that wants the `Dirty` must name every case (gotcha 39). The
|
|
||||||
// answer is the same one `commands` gives: nothing was applied,
|
|
||||||
// so nothing is owed to anybody else, and the asker is told only
|
|
||||||
// that its batch did not land and which class that was.
|
|
||||||
Applied::Rejected { reason } => vec![Addressed {
|
Applied::Rejected { reason } => vec![Addressed {
|
||||||
to: id,
|
to: id,
|
||||||
msg: ServerFrame::Rejected(reason),
|
msg: ServerFrame::Rejected(reason),
|
||||||
@@ -293,9 +167,8 @@ impl Host {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Who hears a relayed command is the relay's own business, not the
|
/// Who hears a relayed command is the relay's own business: [`Broadcast`] names
|
||||||
/// host's guess: [`Broadcast`] names its audience and the match is what
|
/// its audience and the match is what makes the host honour it (improvise-gvnb).
|
||||||
/// makes the host honour it (improvise-gvnb).
|
|
||||||
fn relay(&self, from: SessionId, broadcast: Vec<Broadcast>) -> Vec<Addressed> {
|
fn relay(&self, from: SessionId, broadcast: Vec<Broadcast>) -> Vec<Addressed> {
|
||||||
broadcast
|
broadcast
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -316,9 +189,8 @@ impl Host {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain the outbound projections — the ticket's "projection queue",
|
/// Drain the outbound projections. Clients with nothing to hear are omitted by
|
||||||
/// which turned out to be a call rather than a queue. Clients with
|
/// `project_all` and so get no frame.
|
||||||
/// nothing to hear are omitted by `project_all` and so get no frame.
|
|
||||||
fn project(&mut self, dirty: Dirty) -> Vec<Addressed> {
|
fn project(&mut self, dirty: Dirty) -> Vec<Addressed> {
|
||||||
self.sessions
|
self.sessions
|
||||||
.project_all(&self.app, dirty)
|
.project_all(&self.app, dirty)
|
||||||
@@ -365,9 +237,8 @@ mod tests {
|
|||||||
Host::new(app)
|
Host::new(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A host whose document can be replaced out from under its clients: a
|
/// A host whose document can be replaced out from under its clients: a second
|
||||||
/// second document sits in the storage backend under a name `LoadModel`
|
/// document in the storage backend under a name `LoadModel` can reach.
|
||||||
/// can reach.
|
|
||||||
fn host_over_a_loadable_document() -> (Host, PathBuf) {
|
fn host_over_a_loadable_document() -> (Host, PathBuf) {
|
||||||
let storage = MemoryFs::new();
|
let storage = MemoryFs::new();
|
||||||
let mut other = Workbook::new("Other");
|
let mut other = Workbook::new("Other");
|
||||||
@@ -389,9 +260,8 @@ mod tests {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The same cell as a client names it: the opaque surrogate the server
|
/// The same cell as a client names it: the projected surrogate, which is the
|
||||||
/// projected on `CellDisplay::id`, which is the only handle a client has
|
/// only handle a client has (ADR 002). A `CellKey` is not constructible there.
|
||||||
/// (ADR 002). A `CellKey` is not constructible over there.
|
|
||||||
fn addressed(region: &str) -> RecordCell {
|
fn addressed(region: &str) -> RecordCell {
|
||||||
RecordCell {
|
RecordCell {
|
||||||
record: RecordHash::of(&cell(region)),
|
record: RecordHash::of(&cell(region)),
|
||||||
@@ -399,22 +269,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The basis a caught-up client would state for a **frame** naming
|
/// The basis a caught-up client states for a **frame** naming `regions`. It
|
||||||
/// `regions`: the two document tiers plus each named record's current
|
/// fabricates `(hash, 0)` for a record that is absent — see the evidence.
|
||||||
/// counter.
|
|
||||||
///
|
|
||||||
/// `reduce::caught_up` cannot stand in for this, and the difference is
|
|
||||||
/// exactly what a frame is: it builds the claim for **one command**, so a
|
|
||||||
/// frame's records beyond the first are simply absent from it and come
|
|
||||||
/// back `RecordUnstated`. A real client calls `BasisCache::claim` once
|
|
||||||
/// with every record its frame names, which is what this reproduces.
|
|
||||||
///
|
|
||||||
/// **Name only records that exist.** `claim` *omits* a record the cache
|
|
||||||
/// has never heard of; this always includes one, so pointed at a record
|
|
||||||
/// that is not there it fabricates `(hash, 0)` — a claim no real client
|
|
||||||
/// could compose, and precisely the input that flips the create
|
|
||||||
/// exemption in `CheckedBatch`. For a record the frame is *creating*,
|
|
||||||
/// leave it out, which is what a client would do.
|
|
||||||
fn frame_basis(host: &Host, regions: &[&str]) -> improvise_protocol::Basis {
|
fn frame_basis(host: &Host, regions: &[&str]) -> improvise_protocol::Basis {
|
||||||
improvise_protocol::Basis {
|
improvise_protocol::Basis {
|
||||||
document_generation: host.app().document_generation(),
|
document_generation: host.app().document_generation(),
|
||||||
@@ -476,9 +332,8 @@ mod tests {
|
|||||||
|
|
||||||
// ── connect ─────────────────────────────────────────────────────────
|
// ── connect ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// AC 2: the bootstrap is sent on connect, addressed to the new client,
|
/// AC 2: the bootstrap is sent on connect, addressed to the new client, and it
|
||||||
/// and it is not empty — a `Snapshot` whose seed carried nothing would
|
/// is **not empty** — an empty seed would satisfy "a snapshot was sent".
|
||||||
/// satisfy "a snapshot was sent" while leaving the client blank.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn connecting_hands_that_client_a_snapshot_that_draws_the_document() {
|
fn connecting_hands_that_client_a_snapshot_that_draws_the_document() {
|
||||||
let mut host = host();
|
let mut host = host();
|
||||||
@@ -497,9 +352,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The extent is what the snapshot is built at, so a client that says it
|
/// The extent is what the snapshot is built at, which is what makes `Viewport`
|
||||||
/// can see one row is not sent two. This is the property that makes
|
/// the handshake rather than an afterthought.
|
||||||
/// `Viewport` the handshake rather than an afterthought.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_reported_extent_bounds_the_snapshot() {
|
fn the_reported_extent_bounds_the_snapshot() {
|
||||||
let mut host = host();
|
let mut host = host();
|
||||||
@@ -545,9 +399,8 @@ mod tests {
|
|||||||
assert_eq!(cell_text(&folded_grid(&seen), 0, 0), "42");
|
assert_eq!(cell_text(&folded_grid(&seen), 0, 0), "42");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `doctrine(check_the_checker)`: the assertion above would also pass if
|
/// `doctrine(check_the_checker)`: inject the fault — never send the command —
|
||||||
/// the seed happened to contain the value already. Inject the fault —
|
/// and the assertion above must fail.
|
||||||
/// never send the command — and it must fail.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn without_the_command_the_cell_is_empty() {
|
fn without_the_command_the_cell_is_empty() {
|
||||||
let mut host = host();
|
let mut host = host();
|
||||||
@@ -555,9 +408,8 @@ mod tests {
|
|||||||
assert_eq!(cell_text(&folded_grid(&[connect.msg]), 0, 0), "");
|
assert_eq!(cell_text(&folded_grid(&[connect.msg]), 0, 0), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A cell edit is the whole document's, so a client that did not ask for
|
/// A cell edit is the whole document's, so a client that did not ask is told
|
||||||
/// it is told about it too. The projection is per-session because the
|
/// too. The projection is per-session because viewports differ.
|
||||||
/// viewports differ, not because the change does.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_bystander_is_projected_another_clients_edit() {
|
fn a_bystander_is_projected_another_clients_edit() {
|
||||||
let mut host = host();
|
let mut host = host();
|
||||||
@@ -577,9 +429,8 @@ mod tests {
|
|||||||
assert_eq!(cell_text(&folded_grid(&seen), 0, 0), "7");
|
assert_eq!(cell_text(&folded_grid(&seen), 0, 0), "7");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A view-only command reaches nobody else. The counterpart to the test
|
/// A view-only command reaches nobody else — the counterpart that keeps the
|
||||||
/// above: if every command fanned out, that one would pass for the wrong
|
/// test above from passing for the wrong reason.
|
||||||
/// reason.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_scroll_reaches_only_the_client_that_scrolled() {
|
fn a_scroll_reaches_only_the_client_that_scrolled() {
|
||||||
let mut host = host();
|
let mut host = host();
|
||||||
@@ -598,15 +449,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One keypress resolved on a client is up to three commands, and the
|
/// One keypress is up to three commands and the host must take all of them
|
||||||
/// host must take all of them (improvise-bpq2).
|
/// (improvise-bpq2). The assertion is the *offset*, not the selection.
|
||||||
///
|
|
||||||
/// The fixture is the shape that actually arrives: a cursor moved past
|
|
||||||
/// the bottom of a one-row window, so the selection *and* the offset
|
|
||||||
/// that follows it travel together. The assertion is the offset, not the
|
|
||||||
/// selection, because a host that applied only the first command would
|
|
||||||
/// still look right on `selected` — the window is what goes wrong, and
|
|
||||||
/// it goes wrong silently.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_batch_of_commands_is_applied_whole_and_in_order() {
|
fn a_batch_of_commands_is_applied_whole_and_in_order() {
|
||||||
let mut host = host();
|
let mut host = host();
|
||||||
@@ -629,10 +473,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A refused batch applies nothing *after* the refusal. The stronger
|
/// A refused batch applies nothing *after* the refusal. Nothing *before* it is
|
||||||
/// half — nothing *before* it either — is
|
/// the next test; the two halves fail independently.
|
||||||
/// `a_refused_batch_leaves_the_commands_before_it_unapplied`; this is
|
|
||||||
/// kept because the two halves fail independently.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_batch_is_abandoned_at_the_first_rejection() {
|
fn a_batch_is_abandoned_at_the_first_rejection() {
|
||||||
use improvise_protocol::{Basis, ModelCommand};
|
use improvise_protocol::{Basis, ModelCommand};
|
||||||
@@ -664,29 +506,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-955n / improvise-08wb. **The bug**: ADR 001 is emphatic
|
/// improvise-955n/08wb: `Checked` is per **command**, so a frame whose second
|
||||||
/// that a stale batch is never applied, *not even partially*, and
|
/// command was refused had already applied its first. See the evidence.
|
||||||
/// improvise-ondj went to the trouble of a type (`Checked`) to make the
|
|
||||||
/// unchecked path unconstructible. But `Checked` is **per command**, and
|
|
||||||
/// a `ClientFrame::Commands` is a sequence of them: `Host::commands`
|
|
||||||
/// applied in order and abandoned at the first rejection, so a frame
|
|
||||||
/// whose *second* command was refused had already applied its first.
|
|
||||||
/// The guarantee was true of a single command and false of a batch.
|
|
||||||
///
|
|
||||||
/// The earlier command is a **view** command, which is not a
|
|
||||||
/// concession: it is the shape the one real producer emits
|
|
||||||
/// (`keys::commit` — one mutation among view commands), and it is still
|
|
||||||
/// observable, because the host folds it into that client's mirror.
|
|
||||||
/// A frame may carry more than one mutation; what it may not do is
|
|
||||||
/// apply some of them and refuse the rest, which is clause 4 and open.
|
|
||||||
///
|
|
||||||
/// **A refusal test needs an accepting one**
|
|
||||||
/// (`doctrine(a_helper_that_builds_the_world_the_code_wants_tests_nothing)`:
|
|
||||||
/// pair a refusal with an acceptance, or the refusal can pass by
|
|
||||||
/// refusing everything). The second half runs the same leading
|
|
||||||
/// `SetSelected` followed by a command whose basis is *current*, and
|
|
||||||
/// requires both to land. Without it, a `SetSelected` that simply never
|
|
||||||
/// worked would clear the first assertion and prove nothing at all.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_refused_batch_leaves_the_commands_before_it_unapplied() {
|
fn a_refused_batch_leaves_the_commands_before_it_unapplied() {
|
||||||
use improvise_protocol::ModelCommand;
|
use improvise_protocol::ModelCommand;
|
||||||
@@ -698,11 +519,8 @@ mod tests {
|
|||||||
|
|
||||||
let mut refused = host();
|
let mut refused = host();
|
||||||
let (asker, _) = refused.connect(10, 10);
|
let (asker, _) = refused.connect(10, 10);
|
||||||
// A claim that is current in every respect but one, so the refusal
|
// Built from `caught_up` and then bumped in a single tier, so the refusal
|
||||||
// can only be about the tier that was moved. Building it from
|
// can only be about the tier that moved.
|
||||||
// `caught_up` and then bumping a single tier keeps the rest
|
|
||||||
// well-formed — a hand-built basis that is stale in three ways at
|
|
||||||
// once cannot tell which check answered.
|
|
||||||
let Command::Model { mut basis, cmd } =
|
let Command::Model { mut basis, cmd } =
|
||||||
crate::ui::reduce::caught_up(refused.app(), edit(42.0))
|
crate::ui::reduce::caught_up(refused.app(), edit(42.0))
|
||||||
else {
|
else {
|
||||||
@@ -766,29 +584,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-955n. **The regression 90ea26e introduced, and the reason
|
/// improvise-955n: hoisting the basis check to the frame hoisted *all* of it,
|
||||||
/// clause 2 cannot be applied to all three tiers.**
|
/// and the record tier does not hoist. A lost update — see the evidence.
|
||||||
///
|
|
||||||
/// Hoisting the basis check to the frame hoisted *all* of it, so every
|
|
||||||
/// mutation after the first was admitted with no record check at all.
|
|
||||||
/// Two clients, one record: A composes `[SetCell(North), SetCell(South)]`
|
|
||||||
/// against an honest current basis, B writes South, A's frame arrives —
|
|
||||||
/// and A's write to South landed on top of B's **silently**, which is
|
|
||||||
/// exactly the lost update ADR 001's record tier exists to prevent. At
|
|
||||||
/// the base commit the second command was checked and the frame refused.
|
|
||||||
///
|
|
||||||
/// **The per-command check was doing two jobs and the commit noticed
|
|
||||||
/// one.** "Is the client's picture of the *document* current" is a
|
|
||||||
/// frame-level fact and hoists correctly. "Is the client's picture of
|
|
||||||
/// *this record* current" is not: which records are at stake is a
|
|
||||||
/// property of the command, which is why `records_named` is an exhaustive
|
|
||||||
/// match over `ModelCommand` (gotcha 43). The incapability argument that
|
|
||||||
/// motivated clause 3 is true of records a frame **creates** and was
|
|
||||||
/// over-generalised to records it merely **writes** — and those are the
|
|
||||||
/// ones the tier is for.
|
|
||||||
///
|
|
||||||
/// Three arms, because a fix that refuses everything would pass the first
|
|
||||||
/// alone and a fix that checks nothing would pass the last two.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_later_write_in_a_chain_still_answers_for_its_own_record() {
|
fn a_later_write_in_a_chain_still_answers_for_its_own_record() {
|
||||||
use improvise_protocol::ModelCommand;
|
use improvise_protocol::ModelCommand;
|
||||||
@@ -818,10 +615,8 @@ mod tests {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// B moves South. `SetCell` is `CellData`, so neither the document nor
|
// B moves South. `SetCell` is `CellData`, so only the record tier can
|
||||||
// the layout tier moves — only the record tier can discriminate here,
|
// discriminate here — which is what makes this a test of that tier.
|
||||||
// which is what makes this a test of the record tier and not of the
|
|
||||||
// hoist in general.
|
|
||||||
moved.model(b, write("South", 99.0));
|
moved.model(b, write("South", 99.0));
|
||||||
|
|
||||||
let answer = moved.handle(a, ClientFrame::Commands(frame));
|
let answer = moved.handle(a, ClientFrame::Commands(frame));
|
||||||
@@ -890,26 +685,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── arm 3: the same record twice in one chain ───────────────────
|
// ── arm 3: the same record twice in one chain ───────────────────
|
||||||
// Legitimate and likely — two measures on one record is one record —
|
// **Read the evidence before closing clause 4**: interleaving sells this.
|
||||||
// and it works for a reason worth pinning rather than rediscovering:
|
|
||||||
// **every check happens before any command is applied**, so the
|
|
||||||
// second write is compared to the record's *pre-frame* counter, not
|
|
||||||
// to the one its own sibling just bumped. Were the checks
|
|
||||||
// interleaved with the applies, this frame would refuse itself with
|
|
||||||
// `RecordMoved`.
|
|
||||||
//
|
|
||||||
// **Read this before closing clause 4.** Interleaving is tempting
|
|
||||||
// there, because it would close the *silence*:
|
|
||||||
// `[RemoveItem(North), SetCell(North)]` under interleaving is refused
|
|
||||||
// `RecordGone` instead of silently half-applying, which is half of
|
|
||||||
// what clause 4 owes. So check-before-apply is not a free win — it
|
|
||||||
// buys the legitimate double-write below and **sells** the loud
|
|
||||||
// failure on a broken chain, and clause 4 has to pay that back some
|
|
||||||
// other way. Measured rather than argued: injecting the interleaving
|
|
||||||
// fails exactly two tests, this one and
|
|
||||||
// `a_frame_whose_later_mutation_fails_is_not_yet_undone`, and the
|
|
||||||
// second failing *is* the trade. This arm is what stops someone
|
|
||||||
// taking the trade without noticing they made it.
|
|
||||||
let mut twice = host();
|
let mut twice = host();
|
||||||
let (asker, _) = twice.connect(10, 10);
|
let (asker, _) = twice.connect(10, 10);
|
||||||
twice.model(asker, write("North", 1.0));
|
twice.model(asker, write("North", 1.0));
|
||||||
@@ -945,21 +721,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **The capability the contract is for**: a chain of data-dependent
|
/// **The capability the contract is for**: a data-dependent chain, in order.
|
||||||
/// commands, applied in order, each seeing what the previous one left.
|
/// The second assertion is the one that earns it — see the evidence.
|
||||||
///
|
|
||||||
/// `[AddItem("East"), SetCell(the East record)]` is the archetype, and it
|
|
||||||
/// is the case a per-command basis check **cannot** admit however it is
|
|
||||||
/// ordered: a client cannot claim a counter for a record its own chain is
|
|
||||||
/// about to create, because `BasisCache::claim` filters to records the
|
|
||||||
/// cache has been told about. So the basis is answered once, on the first
|
|
||||||
/// command that carries one, and the rest of the chain runs against what
|
|
||||||
/// its predecessors left.
|
|
||||||
///
|
|
||||||
/// The second assertion is the one that earns the test. Asserting only
|
|
||||||
/// that the item exists would pass against a host that applied the first
|
|
||||||
/// command and dropped the second — which is exactly what the previous
|
|
||||||
/// rule did, loudly, and what the pre-frame check did, silently.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_data_dependent_chain_lands_in_order() {
|
fn a_data_dependent_chain_lands_in_order() {
|
||||||
use improvise_protocol::ModelCommand;
|
use improvise_protocol::ModelCommand;
|
||||||
@@ -1010,35 +773,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **Characterization of the open hole (clause 4), not an endorsement.**
|
/// **Characterization of the open hole (clause 4), not an endorsement.** Invert
|
||||||
///
|
/// all three assertions when clause 4 lands — see the evidence.
|
||||||
/// The contract says a frame is all-or-nothing. It is not yet:
|
|
||||||
/// `App::apply_effects` aborts without undoing (gotcha 21 — an abort is
|
|
||||||
/// not a rollback), so a chain whose later mutation fails leaves the
|
|
||||||
/// earlier one standing. `[RemoveItem("North"), SetCell(the North
|
|
||||||
/// record)]` is the shape: the removal lands, the write then resolves to
|
|
||||||
/// nothing, `model_effects` returns an **empty** batch, and the frame is
|
|
||||||
/// reported accepted having applied half of itself. Both the partial
|
|
||||||
/// application and the silence are open.
|
|
||||||
///
|
|
||||||
/// **This test did not pin that, and the way it failed is the repo's own
|
|
||||||
/// signature defect sitting on the one test whose job is to fail loudly
|
|
||||||
/// if anyone believes the hole is closed.** It asserted that North was
|
|
||||||
/// removed and that nothing was rejected — both true of any *wholly
|
|
||||||
/// successful* frame. The independent reviewer retargeted the second
|
|
||||||
/// command from the removed North to the live South, making the frame
|
|
||||||
/// succeed completely, and the test still passed. It could not tell
|
|
||||||
/// half-applied from fully-applied, so it would never have inverted when
|
|
||||||
/// clause 4 landed; it would have gone on passing. The missing assertion
|
|
||||||
/// is the one that names the hole: **the second mutation did not land.**
|
|
||||||
///
|
|
||||||
/// It is unreachable from any client here — the only producer of a
|
|
||||||
/// multi-command frame is `keys::commit`, which emits one mutation among
|
|
||||||
/// view commands — which is what makes it safe to document rather than
|
|
||||||
/// rush.
|
|
||||||
///
|
|
||||||
/// **This test must be inverted when clause 4 lands**: all three
|
|
||||||
/// assertions flip, because then nothing at all happens.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_frame_whose_later_mutation_fails_is_not_yet_undone() {
|
fn a_frame_whose_later_mutation_fails_is_not_yet_undone() {
|
||||||
use improvise_protocol::ModelCommand;
|
use improvise_protocol::ModelCommand;
|
||||||
@@ -1052,10 +788,8 @@ mod tests {
|
|||||||
value: CellValue::Number(1.0),
|
value: CellValue::Number(1.0),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// The frame claims North at its current counter, so it passes the
|
// The frame claims North at its current counter, so it passes the check and
|
||||||
// check and fails at *apply* time — which is the only way to reach
|
// fails at *apply* time — the only way to reach the hole.
|
||||||
// the hole. A frame refused by the check never applies anything and
|
|
||||||
// would be testing clause 2 instead.
|
|
||||||
let basis = frame_basis(&host, &["North"]);
|
let basis = frame_basis(&host, &["North"]);
|
||||||
let answer = host.handle(
|
let answer = host.handle(
|
||||||
asker,
|
asker,
|
||||||
@@ -1111,10 +845,8 @@ mod tests {
|
|||||||
|
|
||||||
// ── the broadcast ───────────────────────────────────────────────────
|
// ── the broadcast ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/// The bug Copilot found on PR #10: a bystander must fold the accepted
|
/// The bug Copilot found on PR #10: a bystander must fold the accepted command
|
||||||
/// command itself, because its own view slice lives on the client and
|
/// itself, because its own view slice lives on the client (gotcha 36).
|
||||||
/// only `reduce_view` over the same command keeps it in step with the
|
|
||||||
/// server's mirror (gotcha 36).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_bystander_is_sent_the_broadcast_and_the_asker_is_not() {
|
fn a_bystander_is_sent_the_broadcast_and_the_asker_is_not() {
|
||||||
let mut host = host();
|
let mut host = host();
|
||||||
@@ -1142,12 +874,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The other audience, and the reason [`Broadcast`] carries one rather
|
/// The other audience, and the reason [`Broadcast`] carries one: a geometry
|
||||||
/// than the relay deciding. A geometry change invalidates every cursor
|
/// change invalidates every cursor, the asker's included (improvise-gvnb).
|
||||||
/// into the document (improvise-gvnb), and the asker's is not exempt: a
|
|
||||||
/// `Command::Model` is the one kind a client cannot fold optimistically,
|
|
||||||
/// so nothing has reset it locally and skipping it would leave the very
|
|
||||||
/// client that asked positioned in a grid it no longer has.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_geometry_command_relays_the_cursor_reset_to_the_asker_too() {
|
fn a_geometry_command_relays_the_cursor_reset_to_the_asker_too() {
|
||||||
use improvise_protocol::ModelCommand;
|
use improvise_protocol::ModelCommand;
|
||||||
@@ -1167,10 +895,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A refused batch is answered to the **asker and nobody else** (ADR
|
/// A refused batch is answered to the **asker and nobody else** (ADR 001):
|
||||||
/// 001). Nothing happened, so there is nothing to tell the others — and
|
/// nothing happened, so there is nothing to tell the others.
|
||||||
/// the asker is told only which class of staleness it was, because it
|
|
||||||
/// already holds what is current.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_stale_batch_is_refused_to_the_asker_alone() {
|
fn a_stale_batch_is_refused_to_the_asker_alone() {
|
||||||
use improvise_protocol::{Basis, Rejection};
|
use improvise_protocol::{Basis, Rejection};
|
||||||
@@ -1204,9 +930,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// And what the broadcast is *for*: a bystander that folds what it is
|
/// And what the broadcast is *for*: a bystander that folds it names the view the
|
||||||
/// sent ends up naming the view the document accepted, so the cursor its
|
/// document accepted, so its next grid is positioned under the right key.
|
||||||
/// next grid is positioned by is read under the right key (gotcha 24).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn folding_the_broadcast_moves_the_bystanders_active_view() {
|
fn folding_the_broadcast_moves_the_bystanders_active_view() {
|
||||||
let mut host = host();
|
let mut host = host();
|
||||||
@@ -1236,9 +961,8 @@ mod tests {
|
|||||||
|
|
||||||
// ── the document swap ───────────────────────────────────────────────
|
// ── the document swap ───────────────────────────────────────────────
|
||||||
|
|
||||||
/// AC of gotcha 39: a command that replaced the document owes *every*
|
/// AC of gotcha 39: a command that replaced the document owes *every* client a
|
||||||
/// client a fresh `Snapshot`, not a diff — including the client that did
|
/// fresh `Snapshot`, not a diff — including the client that did not ask.
|
||||||
/// not ask.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_document_swap_sends_every_client_a_fresh_snapshot() {
|
fn a_document_swap_sends_every_client_a_fresh_snapshot() {
|
||||||
let (mut host, path) = host_over_a_loadable_document();
|
let (mut host, path) = host_over_a_loadable_document();
|
||||||
@@ -1261,9 +985,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `doctrine(check_the_checker)`: inject the fault the test targets — a
|
/// `doctrine(check_the_checker)`: inject the fault — a load that does not
|
||||||
/// load that does not happen — and the snapshot must not appear. A test
|
/// happen — and the snapshot must not appear.
|
||||||
/// that saw `Snapshot` on every command would pass the one above.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_load_that_fails_is_not_a_rebase() {
|
fn a_load_that_fails_is_not_a_rebase() {
|
||||||
let (mut host, _) = host_over_a_loadable_document();
|
let (mut host, _) = host_over_a_loadable_document();
|
||||||
@@ -1308,18 +1031,8 @@ mod tests {
|
|||||||
.map(|bytes| String::from_utf8(bytes).expect("the fixture writes text"))
|
.map(|bytes| String::from_utf8(bytes).expect("the fixture writes text"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-368h. **The bug**: `effect::Save` is `EffectKind::ViewOnly`
|
/// improvise-368h: `effect::Save` is `ViewOnly`, so the `ModelCommand` derivation
|
||||||
/// — correctly, since it writes *this host's* path — so the derivation
|
/// could not reach it and no client could make its host write.
|
||||||
/// that builds `ModelCommand` out of the `EffectKind::Model` effects
|
|
||||||
/// could not reach it (gotcha 38). There was no save on the wire at all, so
|
|
||||||
/// **no client on any transport could cause its host to write**: a
|
|
||||||
/// websocket session edited the document in memory and the file on disk
|
|
||||||
/// never moved unless a human was at a TUI that was not there.
|
|
||||||
///
|
|
||||||
/// The derivation was not wrong; it was complete over the mutations and
|
|
||||||
/// saving is not one. So the variant is *added* rather than derived, and
|
|
||||||
/// it carries no path: a client cannot see the host's storage, and which
|
|
||||||
/// document a host is hosting is the host's own fact.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_client_can_ask_its_host_to_save() {
|
fn a_client_can_ask_its_host_to_save() {
|
||||||
let (mut host, path) = host_with_a_path();
|
let (mut host, path) = host_with_a_path();
|
||||||
@@ -1351,14 +1064,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-368h: the "what if there is no path" half. Not a panic and
|
/// improvise-368h, the "no path" half: refused with a reason, to the asker alone
|
||||||
/// not a silent no-op — the two answers the ticket ruled out — but the
|
/// (gotcha 39). The second client asserts the refusal does not leak.
|
||||||
/// vocabulary that already exists for "nothing happened and here is
|
|
||||||
/// why", addressed to the asker alone (gotcha 39).
|
|
||||||
///
|
|
||||||
/// The second client is not decoration: a refusal that leaked to the
|
|
||||||
/// bystanders would be telling them about a failure of somebody else's
|
|
||||||
/// host-local request, and this asserts it does not.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_save_with_nowhere_to_write_is_refused_and_told_only_to_the_asker() {
|
fn a_save_with_nowhere_to_write_is_refused_and_told_only_to_the_asker() {
|
||||||
let mut host = host();
|
let mut host = host();
|
||||||
@@ -1381,14 +1088,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-368h: a write that was attempted and failed must not be
|
/// improvise-368h: an attempted write that failed must not be reported as a
|
||||||
/// reported as a success. `Save` signals failure by aborting its batch
|
/// success. The fixture fails for a real reason — an absent directory.
|
||||||
/// (gotcha 21), and the batch here is exactly `[Save]`, so an abort is
|
|
||||||
/// that failure and nothing else.
|
|
||||||
///
|
|
||||||
/// The fixture fails for a reason that is real rather than mocked: a
|
|
||||||
/// `PhysicalFs` pointed at a directory that does not exist. It creates
|
|
||||||
/// nothing, so the test leaves no trace on disk.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_write_that_fails_is_refused_rather_than_reported_as_saved() {
|
fn a_write_that_fails_is_refused_rather_than_reported_as_saved() {
|
||||||
let absent = std::env::temp_dir().join("improvise-no-such-directory-368h");
|
let absent = std::env::temp_dir().join("improvise-no-such-directory-368h");
|
||||||
@@ -1440,9 +1141,8 @@ mod tests {
|
|||||||
|
|
||||||
// ── the frames themselves ───────────────────────────────────────────
|
// ── the frames themselves ───────────────────────────────────────────
|
||||||
|
|
||||||
/// The wire is JSON to start with (the ticket's choice, kept), so both
|
/// The wire is JSON, so both directions must survive a round trip through it —
|
||||||
/// directions must survive a round trip through it — including the one
|
/// including the one point at which `ViewState` is serialized (gotcha 25).
|
||||||
/// point at which `ViewState` is serialized (gotcha 25).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_frame_survives_a_json_round_trip() {
|
fn every_frame_survives_a_json_round_trip() {
|
||||||
let mut host = host();
|
let mut host = host();
|
||||||
@@ -1468,11 +1168,8 @@ mod tests {
|
|||||||
assert_eq!(back, frame.msg);
|
assert_eq!(back, frame.msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inbound, with a payload-bearing variant on each side of the
|
// A payload-bearing variant on each side of the envelope
|
||||||
// envelope (`doctrine(populate_before_roundtrip)`): a `Viewport` and
|
// (`doctrine(populate_before_roundtrip)`) — see the evidence.
|
||||||
// a `Command` carrying a `CellValue::Text`, which is the arm the
|
|
||||||
// outbound half cannot reach — a text cell aggregates to nothing
|
|
||||||
// under a None-axis category, so it never becomes a `CellDisplay`.
|
|
||||||
for inbound in [
|
for inbound in [
|
||||||
ClientFrame::Viewport { rows: 3, cols: 4 },
|
ClientFrame::Viewport { rows: 3, cols: 4 },
|
||||||
ClientFrame::command(crate::ui::reduce::caught_up(
|
ClientFrame::command(crate::ui::reduce::caught_up(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,55 +1,5 @@
|
|||||||
//! The one place a command is applied to the authoritative state
|
//! The one place a command is applied to the authoritative state, by
|
||||||
//! (improvise-gxi).
|
//! keypress or by wire (improvise-gxi). See `context/code/command.md`.
|
||||||
//!
|
|
||||||
//! Every deployment mode — the native TUI, a websocket server, a browser
|
|
||||||
//! worker — reaches the model through the functions here, so there is one
|
|
||||||
//! answer to "what does this input do" rather than one per host.
|
|
||||||
//!
|
|
||||||
//! One door, reached two ways, because input arrives in two shapes and only
|
|
||||||
//! one of them is data on the wire:
|
|
||||||
//!
|
|
||||||
//! - [`reduce_full`] takes a [`Checked`], which is a [`Command`] — the wire
|
|
||||||
//! vocabulary — that the authoritative state has already answered for.
|
|
||||||
//! Both halves of that vocabulary are derived from the effects — the view
|
|
||||||
//! slice from the `ViewOnly` ones (`decision(improvise-cqi,
|
|
||||||
//! command_set_derived_not_invented)`) and the document from the
|
|
||||||
//! `EffectKind::Model` ones (improvise-kmgs) — so a command can now say
|
|
||||||
//! "set this cell" as well as "move my cursor".
|
|
||||||
//! - [`reduce_key`] takes a keypress and runs it through the keymap,
|
|
||||||
//! registry and effect pipeline. A keypress names an *intent* whose
|
|
||||||
//! meaning depends on the `CmdContext` (where the cursor is, what mode is
|
|
||||||
//! current); a `Command` names the mutation that intent resolved to. That
|
|
||||||
//! is why the key path survives the vocabulary rather than being replaced
|
|
||||||
//! by it.
|
|
||||||
//!
|
|
||||||
//! What is no longer two is the *mutation*: [`model_effects`] maps a
|
|
||||||
//! [`ModelCommand`] back to the effect it was derived from, and both paths
|
|
||||||
//! run it through `App::apply_effects`. Effects remain the only place the
|
|
||||||
//! `App` is written.
|
|
||||||
//!
|
|
||||||
//! Both return the commands to send onward: the projections for the widgets
|
|
||||||
//! whose content is the document's rather than one client's. The grid is
|
|
||||||
//! per-viewport and comes from [`Sessions::project_all`](super::project::Sessions::project_all)
|
|
||||||
//! instead — a host with connected clients uses the [`Dirty`] the inner
|
|
||||||
//! functions return and projects per session; a host with none, like the
|
|
||||||
//! native TUI, drops what it gets.
|
|
||||||
//!
|
|
||||||
//! **A wire command arrives here through [`Checked`], and nowhere else**
|
|
||||||
//! (improvise-ondj, ADR 001). The basis check is a property of the session
|
|
||||||
//! door rather than of the mutation path — a host holding the `App` *is* the
|
|
||||||
//! truth and not a claimant — but "the mutation path does not check" was
|
|
||||||
//! implemented as a second door rather than as an exemption: both functions
|
|
||||||
//! used to take a `&Command` and their `Command::Model { cmd, .. }` arm
|
|
||||||
//! discarded the basis with `..`, which is the syntax for ignoring a field
|
|
||||||
//! and costs no compile error. So the answer is not a guard but a type: the
|
|
||||||
//! only way to obtain a `Checked` over a `Command::Model` is
|
|
||||||
//! [`Checked::against`], which runs [`stale_basis`] against the
|
|
||||||
//! authoritative `App`. A host that wants to apply a client's batch must
|
|
||||||
//! face the [`Rejection`], and `Sessions::apply_checked` is the door a host
|
|
||||||
//! goes through — `Host::commands` answers the whole frame first with
|
|
||||||
//! [`CheckedBatch::against`], so `Sessions::apply` (which answers one command
|
|
||||||
//! and then dispatches) now has no production caller and is kept for the
|
|
||||||
//! single-command case its tests drive.
|
|
||||||
|
|
||||||
use improvise_core::model::record::RecordHash;
|
use improvise_core::model::record::RecordHash;
|
||||||
use improvise_protocol::{Basis, Command, ModelCommand, Rejection, reduce_view};
|
use improvise_protocol::{Basis, Command, ModelCommand, Rejection, reduce_view};
|
||||||
@@ -59,202 +9,20 @@ use crate::ui::app::App;
|
|||||||
use crate::ui::effect::{self, Effect};
|
use crate::ui::effect::{self, Effect};
|
||||||
use crate::ui::project::{Dirty, document_projections};
|
use crate::ui::project::{Dirty, document_projections};
|
||||||
|
|
||||||
/// A wire [`Command`] the authoritative state has answered for.
|
/// A wire [`Command`] whose basis the authoritative state has answered for
|
||||||
///
|
/// (improvise-ondj/7v62, ADR 001). See `context/code/command.md`.
|
||||||
/// The one thing it makes unrepresentable: a [`Command::Model`] applied
|
|
||||||
/// without its basis being checked (improvise-ondj). The inner field is
|
|
||||||
/// private and [`Checked::against`] is the only constructor, so there is no
|
|
||||||
/// spelling of "apply this batch and ignore what it claims" — the `..` that
|
|
||||||
/// used to discard the basis is now a `Rejection` the caller must handle.
|
|
||||||
///
|
|
||||||
/// It is **not** a claim about which `App` was asked: a host with two
|
|
||||||
/// documents could check against one and apply to the other. That is not a
|
|
||||||
/// hole this closes, because a `Sessions` is already bound to one document's
|
|
||||||
/// generation sequence (ADR 001) and a `Registry` hands out one pair at a
|
|
||||||
/// time.
|
|
||||||
///
|
|
||||||
/// # Why the lifetime does not close it (improvise-7v62)
|
|
||||||
///
|
|
||||||
/// The obvious answer — make the lifetime the `&App`'s, so the borrow
|
|
||||||
/// checker forbids checking one document and applying to another — was
|
|
||||||
/// tried, and it is not merely inconvenient: it is **contradictory with what
|
|
||||||
/// the type is for**. A `Checked` is a proof discharged *before* a
|
|
||||||
/// mutation, and its whole job is to be carried across that mutation; a
|
|
||||||
/// borrow of the `App` is a claim that must stay live *during* it. The two
|
|
||||||
/// cannot both hold. Tying them makes every call site an `E0502`, starting
|
|
||||||
/// with the canonical one:
|
|
||||||
///
|
|
||||||
/// ```text
|
|
||||||
/// error[E0502]: cannot borrow `*app` as mutable because it is also borrowed as immutable
|
|
||||||
/// match Checked::against(app, cmd) {
|
|
||||||
/// --- immutable borrow occurs here
|
|
||||||
/// Ok(checked) => self.apply_checked(app, id, checked),
|
|
||||||
/// ^^^^^ mutable borrow occurs here
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// A generative brand would express it without borrowing, but that needs a
|
|
||||||
/// closure-scoped API and would distort every host. The remaining option is
|
|
||||||
/// carrying a document identity, and `DocumentId` lives in the *server's*
|
|
||||||
/// registry: an `App` does not have one and does not want one, precisely
|
|
||||||
/// because the unit of authority is one document per `App`. So this is a
|
|
||||||
/// **deliberate won't-fix**, not an oversight. What keeps it unreachable is
|
|
||||||
/// stated above and is load-bearing — improvise-j31x made this a
|
|
||||||
/// multi-document server on purpose, and ADR 001 never evicts a document so
|
|
||||||
/// that a reconnecting client cannot have a stale basis accepted as current.
|
|
||||||
pub struct Checked<'a>(&'a Command);
|
pub struct Checked<'a>(&'a Command);
|
||||||
|
|
||||||
/// A whole [`ClientFrame::Commands`](improvise_protocol::ClientFrame) the
|
/// A whole frame, answered before any of it is applied (improvise-955n/08wb,
|
||||||
/// authoritative state has answered for — **every** command in it, before
|
/// ADR 001). Its four clauses are in `context/code/command.md`.
|
||||||
/// any of them is applied (improvise-955n/improvise-08wb, ADR 001).
|
|
||||||
///
|
|
||||||
/// [`Checked`] alone was not enough, and the gap was exactly one level up
|
|
||||||
/// from the one improvise-ondj closed. A `Checked` answers for *one*
|
|
||||||
/// command against the `App` **as it stands**; a frame is a sequence of
|
|
||||||
/// them, applied in order, so a frame whose second command was refused had
|
|
||||||
/// already applied its first. ADR 001 says a stale batch is never applied,
|
|
||||||
/// *not even partially*, and that was true of a single command and false of
|
|
||||||
/// a batch.
|
|
||||||
///
|
|
||||||
/// # One basis, checked once, and then the chain runs
|
|
||||||
///
|
|
||||||
/// The contract, as the user stated it: *"a sequence of commands sent to the
|
|
||||||
/// server should assume that it's played in order from start to end and that
|
|
||||||
/// each command sees the state after the previous command runs"*, sent as
|
|
||||||
/// *"a single transaction from a given base"*. Four clauses:
|
|
||||||
///
|
|
||||||
/// 1. A frame states **one basis**, describing the base the client composed
|
|
||||||
/// against.
|
|
||||||
/// 2. That basis is checked **once**, against that base, before anything is
|
|
||||||
/// applied.
|
|
||||||
/// 3. Commands are applied **in order**, and command N sees the state command
|
|
||||||
/// N-1 left. Data dependence between them is expected, not an error.
|
|
||||||
/// 4. **All or nothing.** If any command fails, the frame leaves the document
|
|
||||||
/// exactly as the base described.
|
|
||||||
///
|
|
||||||
/// Clauses 1–3 are here. **Clause 4 is not yet implemented** — see the hole
|
|
||||||
/// below and the design note on PR #26.
|
|
||||||
///
|
|
||||||
/// # Two of the three tiers hoist, and the third does not
|
|
||||||
///
|
|
||||||
/// Clause 2 hoists the check, but **not every tier is a frame-level fact**,
|
|
||||||
/// and a first version of this hoisted all three. That was a lost update:
|
|
||||||
/// `document_generation` and `layout_version` describe the *document*, so
|
|
||||||
/// answering them once for the frame is exactly right — but the record tier
|
|
||||||
/// asks "is the client's picture of **this record** current", and which
|
|
||||||
/// records are at stake is a property of the *command*. That is why
|
|
||||||
/// [`records_named`] is an exhaustive match over [`ModelCommand`] and not
|
|
||||||
/// something a frame could summarise (gotcha 43).
|
|
||||||
///
|
|
||||||
/// Skipping it for later commands let `[SetCell(North), SetCell(South)]`
|
|
||||||
/// overwrite a South that somebody else had moved, silently, with the frame
|
|
||||||
/// reported accepted — the precise loss ADR 001's record tier exists to
|
|
||||||
/// prevent, and invisible to the other two tiers because a `SetCell` is
|
|
||||||
/// `CellData` and moves neither of them. So **every** mutation answers for
|
|
||||||
/// its own records, against the **frame's** basis: the one the first
|
|
||||||
/// basis-carrying command stated, not each command's own field, so a later
|
|
||||||
/// command cannot state a fresher claim than the frame was composed under.
|
|
||||||
///
|
|
||||||
/// ## Except the records the frame is creating
|
|
||||||
///
|
|
||||||
/// The argument that motivated clause 3 is true, and it is true of a
|
|
||||||
/// narrower thing than it was first applied to. A client cannot claim a
|
|
||||||
/// counter for a record its own chain is about to **create**, because
|
|
||||||
/// [`BasisCache::claim`](improvise_protocol::BasisCache::claim) filters to
|
|
||||||
/// records the cache has been told about — so `[AddItem(East),
|
|
||||||
/// SetCell(East)]` would be refused by a naive per-command check however it
|
|
||||||
/// is ordered. But that argument was then stretched to records the frame
|
|
||||||
/// merely **writes**, and those are the ones the tier is for.
|
|
||||||
///
|
|
||||||
/// So the exemption is stated over the *property* rather than over a list of
|
|
||||||
/// commands: a record that **neither resolves in the document nor is claimed
|
|
||||||
/// by the basis** is one the frame is about to create, and is not answered
|
|
||||||
/// for. Every other combination still is — including a record that does not
|
|
||||||
/// resolve but *is* claimed, which is a client believing in something that
|
|
||||||
/// has been removed, and is `RecordGone` as before. See
|
|
||||||
/// [`will_be_created_by_the_frame`].
|
|
||||||
///
|
|
||||||
/// What the base means is therefore: **what the client saw**. The transaction
|
|
||||||
/// promises the whole chain lands on top of it or none of it does. The
|
|
||||||
/// chain's internal coherence is the transaction's job, not the basis
|
|
||||||
/// check's.
|
|
||||||
///
|
|
||||||
/// # The hole clause 4 leaves, stated rather than hidden
|
|
||||||
///
|
|
||||||
/// `App::apply_effects` aborts without undoing (gotcha 21 — an abort is not a
|
|
||||||
/// rollback), so a frame whose second mutation fails leaves the first
|
|
||||||
/// applied. `[RemoveItem("North"), SetCell(the North record)]` is the
|
|
||||||
/// example: the removal lands, the write then resolves to nothing and
|
|
||||||
/// `model_effects` returns an **empty** batch, so the frame is reported
|
|
||||||
/// accepted having applied half of itself. Both halves of that are open —
|
|
||||||
/// the partial application and the silence.
|
|
||||||
///
|
|
||||||
/// The create exemption has a residue of the same kind and it is the same
|
|
||||||
/// hole: a later command naming a record that is neither present nor claimed
|
|
||||||
/// is *presumed* to be one the frame creates, so if the frame does not in
|
|
||||||
/// fact create it, the write resolves to nothing and silently does not
|
|
||||||
/// happen. That is `model_effects`' empty batch again, not a second defect,
|
|
||||||
/// and it closes when clause 4 makes a mid-chain failure reportable.
|
|
||||||
///
|
|
||||||
/// Both are **unreachable from any client in this workspace**: the only
|
|
||||||
/// producer of a multi-command frame is
|
|
||||||
/// `improvise-wasm-client::keys::commit`, which emits one mutation among view
|
|
||||||
/// commands. **Reachability is a reason not to rush a fix and not a reason
|
|
||||||
/// not to notice one** — the lost update above had the same reachability
|
|
||||||
/// argument as this hole and none of its documentation, which is how it
|
|
||||||
/// shipped. That is what makes it safe to
|
|
||||||
/// document rather than rush, and it is why an earlier "at most one document
|
|
||||||
/// mutation per frame" rule — which closed the hole by forbidding the
|
|
||||||
/// capability — was rejected.
|
|
||||||
///
|
|
||||||
/// # Two things the rule that was rejected got wrong, worth keeping
|
|
||||||
///
|
|
||||||
/// **Arity is not a property of a variant.** A future `Command::RunScript` is
|
|
||||||
/// one variant performing an unbounded number of mutations, so no count over
|
|
||||||
/// the vocabulary can be right: a classifier can honestly answer *whether* a
|
|
||||||
/// command changes the document, never *how many times*. A
|
|
||||||
/// `mutates_the_document` predicate written for the rejected rule is deleted
|
|
||||||
/// with it rather than kept unused — but it was made an **exhaustive match**
|
|
||||||
/// on review, because a `matches!` answers `false` for a future variant in
|
|
||||||
/// silence, and clause 4 will need that classifier back. Write it exhaustive
|
|
||||||
/// when it returns.
|
|
||||||
///
|
|
||||||
/// **The transaction boundary is not the frame.** Because one command may
|
|
||||||
/// already be a chain, clause 4 is a property the system needs *inside* a
|
|
||||||
/// command as much as across a frame of them, which is why the design note
|
|
||||||
/// puts the mechanism at `App::apply_effects` rather than here.
|
|
||||||
pub struct CheckedBatch<'a>(Vec<Checked<'a>>);
|
pub struct CheckedBatch<'a>(Vec<Checked<'a>>);
|
||||||
|
|
||||||
impl<'a> CheckedBatch<'a> {
|
impl<'a> CheckedBatch<'a> {
|
||||||
/// Answer for every command in a frame, against the state the frame was
|
/// The document tiers are answered once, on the first command that
|
||||||
/// composed against — which is the state *before* any of them runs.
|
/// carries a basis; the record tier is asked of every mutation.
|
||||||
///
|
|
||||||
/// The refusal is the frame's answer and nothing is applied. It is a
|
|
||||||
/// `Result` rather than a per-command verdict for the same reason
|
|
||||||
/// [`Checked`] is a type rather than a guard: the only enforceable
|
|
||||||
/// version of "answer before applying" is not being able to reach the
|
|
||||||
/// commands without having done so.
|
|
||||||
///
|
|
||||||
/// **The two document tiers are answered once**, on the first command
|
|
||||||
/// that carries a basis — clause 2 — and every later command is admitted
|
|
||||||
/// without re-asking them, which is clause 3. **The record tier is asked
|
|
||||||
/// of every mutation**, against that same frame basis, because it is not
|
|
||||||
/// a frame-level fact. A frame with no mutation answers trivially.
|
|
||||||
///
|
|
||||||
/// The `Basis` on a *later* `Command::Model` is ignored rather than
|
|
||||||
/// compared, and that is an honest interim rather than the end state:
|
|
||||||
/// clause 1 says the **frame** states one basis, but the wire still
|
|
||||||
/// carries a `Basis` inside each `Command::Model` — right when one
|
|
||||||
/// command was one transaction, and now one field per command describing
|
|
||||||
/// something that belongs to the frame. Moving it to
|
|
||||||
/// `ClientFrame::Commands` would make "one basis per frame"
|
|
||||||
/// unrepresentable-otherwise instead of a convention, and that protocol
|
|
||||||
/// change belongs with clause 4.
|
|
||||||
pub fn against(app: &App, cmds: &'a [Command]) -> Result<Self, Rejection> {
|
pub fn against(app: &App, cmds: &'a [Command]) -> Result<Self, Rejection> {
|
||||||
// The frame's basis: the one its first mutation stated. Later
|
// The frame's basis is the one its first mutation stated; a later
|
||||||
// commands carry a `Basis` field too, and it is deliberately *not*
|
// command's own `Basis` field is deliberately not read (clause 1).
|
||||||
// read — clause 1 says the frame states one base, and reading each
|
|
||||||
// command's own would let a chain claim a fresher picture than it was
|
|
||||||
// composed under.
|
|
||||||
let mut frame_basis: Option<&Basis> = None;
|
let mut frame_basis: Option<&Basis> = None;
|
||||||
let mut checked = Vec::with_capacity(cmds.len());
|
let mut checked = Vec::with_capacity(cmds.len());
|
||||||
for cmd in cmds {
|
for cmd in cmds {
|
||||||
@@ -283,21 +51,8 @@ impl<'a> IntoIterator for CheckedBatch<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Checked<'a> {
|
impl<'a> Checked<'a> {
|
||||||
/// Answer for a command, against the state it would be applied to.
|
/// Answer for a command, against the state it would be applied to. Total
|
||||||
///
|
/// over the vocabulary; only [`Command::Model`] can be refused.
|
||||||
/// Total over the vocabulary, and only [`Command::Model`] can be
|
|
||||||
/// refused: everything else is either this client's own view slice or a
|
|
||||||
/// `SwitchView`, which carries no basis and cannot corrupt — the
|
|
||||||
/// workbook simply refuses a view it does not have (ADR 001 names that
|
|
||||||
/// second hole and leaves it open deliberately).
|
|
||||||
///
|
|
||||||
/// Saving is not in this vocabulary and deliberately so: it is
|
|
||||||
/// `ClientFrame::Save`, answered by the host, because it mutates no
|
|
||||||
/// shared state and has no basis to answer for (improvise-368h).
|
|
||||||
///
|
|
||||||
/// There is no argument that suppresses the answer. ADR 001's force is a
|
|
||||||
/// fresh batch under a fresh basis, composed by the client; it is not a
|
|
||||||
/// way past this constructor.
|
|
||||||
pub fn against(app: &App, cmd: &'a Command) -> Result<Self, Rejection> {
|
pub fn against(app: &App, cmd: &'a Command) -> Result<Self, Rejection> {
|
||||||
let refused = match cmd {
|
let refused = match cmd {
|
||||||
Command::Model { basis, cmd: model } => stale_basis(app, basis, model),
|
Command::Model { basis, cmd: model } => stale_basis(app, basis, model),
|
||||||
@@ -309,19 +64,8 @@ impl<'a> Checked<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A command in a frame whose **document** tiers have already been
|
/// A command in a frame whose document tiers are already answered
|
||||||
/// answered (clause 2), answering only for its own records.
|
/// (clause 2), answering only for its own records. Private, not a bypass.
|
||||||
///
|
|
||||||
/// The two document tiers are not re-asked, which is clause 3: they
|
|
||||||
/// describe a document this command is not running against, since its
|
|
||||||
/// predecessors have moved it. The record tier *is* asked, because it is
|
|
||||||
/// a fact about the command rather than the frame — see
|
|
||||||
/// [`CheckedBatch`], where the lost update that taught this is recorded.
|
|
||||||
///
|
|
||||||
/// Private, and that is what keeps it from being a bypass: from outside
|
|
||||||
/// this module the only doors are still [`Checked::against`] and
|
|
||||||
/// [`CheckedBatch::against`], and the only caller is the loop that has
|
|
||||||
/// just answered the frame's basis.
|
|
||||||
fn within_a_frame(app: &App, basis: &Basis, cmd: &'a Command) -> Result<Self, Rejection> {
|
fn within_a_frame(app: &App, basis: &Basis, cmd: &'a Command) -> Result<Self, Rejection> {
|
||||||
let refused = match cmd {
|
let refused = match cmd {
|
||||||
Command::Model { cmd: model, .. } => stale_records_in_a_frame(app, basis, model),
|
Command::Model { cmd: model, .. } => stale_records_in_a_frame(app, basis, model),
|
||||||
@@ -333,52 +77,24 @@ impl<'a> Checked<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The command this proof is about.
|
/// The command this proof is about, borrowed from `'a` rather than from
|
||||||
///
|
/// `&self` so a caller can dispatch and still move the proof onward.
|
||||||
/// It hands back a `&'a Command` rather than one borrowed from `&self`,
|
|
||||||
/// so a caller can dispatch on the variant and still move the proof into
|
|
||||||
/// the function it dispatched to. Crate-private: a proof that could be
|
|
||||||
/// unwrapped from outside would be a proof nobody had to obtain.
|
|
||||||
pub(crate) fn command(&self) -> &'a Command {
|
pub(crate) fn command(&self) -> &'a Command {
|
||||||
self.0
|
self.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply one checked wire command to the authoritative state, and say what
|
/// Apply one checked wire command to the authoritative state, and say what it
|
||||||
/// it dirtied.
|
/// dirtied. An inbound `Command::Project` is ignored: projections travel one way.
|
||||||
///
|
|
||||||
/// `Command::SwitchView` does not go through `reduce_view` alone: the
|
|
||||||
/// document's "which view is active" and the client's must move together,
|
|
||||||
/// and `App::switch_to_view` is one of the only two writers allowed to touch
|
|
||||||
/// either (gotcha 29). It reaches that writer the way every other mutation
|
|
||||||
/// here does — through its effect — so the view history a switch records is
|
|
||||||
/// the same on both doors. Everything else is pure view slice.
|
|
||||||
///
|
|
||||||
/// An inbound `Command::Project` is ignored. Projections travel
|
|
||||||
/// server→client; a client that sends one is describing state it does not
|
|
||||||
/// own.
|
|
||||||
pub fn apply_command(app: &mut App, cmd: Checked<'_>) -> Dirty {
|
pub fn apply_command(app: &mut App, cmd: Checked<'_>) -> Dirty {
|
||||||
match cmd.0 {
|
match cmd.0 {
|
||||||
Command::Project(_) => Dirty::NOTHING,
|
Command::Project(_) => Dirty::NOTHING,
|
||||||
Command::SwitchView(name) => {
|
Command::SwitchView(name) => {
|
||||||
// The mutation is `effect::SwitchView`'s, not a second copy of
|
// The mutation is `effect::SwitchView`'s, not a second copy: the
|
||||||
// it: the effect records the view history the key path leaves
|
// effect records the history `ModelCommand::ViewBack` pops.
|
||||||
// behind, and `ModelCommand::ViewBack` pops exactly that stack.
|
|
||||||
// Switching by calling `App::switch_to_view` here instead left
|
|
||||||
// the stack empty, so a wire client could not go back where a
|
|
||||||
// keyboard one could — see
|
|
||||||
// `a_wire_view_switch_records_history_the_way_a_key_switch_does`.
|
|
||||||
// `apply_effects` rebuilds the layout for us.
|
|
||||||
app.apply_effects(vec![Box::new(effect::SwitchView(name.clone()))]);
|
app.apply_effects(vec![Box::new(effect::SwitchView(name.clone()))]);
|
||||||
// The `Dirty` is deliberately narrower than the
|
// Deliberately narrower than `Dirty::of(Model(LayoutGeometry))`:
|
||||||
// `Dirty::of(Model(LayoutGeometry))` that call returns. That
|
// a switch leaves the view list alone (improvise-pca).
|
||||||
// table is a per-*kind* over-approximation — sound for the kind
|
|
||||||
// because `CreateView` and `DeleteView` wear it too — and a view
|
|
||||||
// switch is the one member that leaves the view list alone: its
|
|
||||||
// names and axes belong to the document, and which view is
|
|
||||||
// active is not in `ViewListCache` at all (improvise-pca). Over-
|
|
||||||
// projecting costs bandwidth, never staleness, so narrowing here
|
|
||||||
// is safe; widening `Dirty::of` for this one member would not be.
|
|
||||||
Dirty {
|
Dirty {
|
||||||
grid: true,
|
grid: true,
|
||||||
category_tree: true,
|
category_tree: true,
|
||||||
@@ -386,8 +102,8 @@ pub fn apply_command(app: &mut App, cmd: Checked<'_>) -> Dirty {
|
|||||||
..Dirty::NOTHING
|
..Dirty::NOTHING
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The basis was answered for by `Checked::against`; there is no
|
// The basis was answered for by `Checked::against`; there is no other
|
||||||
// other way to have got here with this variant.
|
// way to have got here with this variant.
|
||||||
Command::Model { cmd, basis: _ } => {
|
Command::Model { cmd, basis: _ } => {
|
||||||
let effects = model_effects(app, cmd);
|
let effects = model_effects(app, cmd);
|
||||||
app.apply_effects(effects)
|
app.apply_effects(effects)
|
||||||
@@ -399,29 +115,8 @@ pub fn apply_command(app: &mut App, cmd: Checked<'_>) -> Dirty {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The other half of the derivation: a [`ModelCommand`] back to the effect
|
/// The other half of the derivation: a [`ModelCommand`] back to the effect it
|
||||||
/// it was derived from (improvise-kmgs).
|
/// was derived from (improvise-kmgs). Exhaustive on purpose.
|
||||||
///
|
|
||||||
/// This is the seam that keeps a command from becoming a second way to
|
|
||||||
/// mutate. A `ModelCommand` names a mutation; it does not perform one. The
|
|
||||||
/// effect it names is the same value the keymap path builds, and it is run
|
|
||||||
/// through the same [`App::apply_effects`], so both doors go through the
|
|
||||||
/// one mutation site and both get their [`Dirty`] from the effect's own
|
|
||||||
/// `EffectKind` rather than from a second classification that could drift.
|
|
||||||
///
|
|
||||||
/// The match is exhaustive on purpose: a new `ModelCommand` variant fails
|
|
||||||
/// the build here until it names an effect, exactly as a new effect fails
|
|
||||||
/// the build until it classifies itself (gotcha 3).
|
|
||||||
///
|
|
||||||
/// It takes the `App` because two variants name their operand by
|
|
||||||
/// **surrogate** rather than by coordinates (ADR 002), and resolving one is
|
|
||||||
/// a lookup against the authoritative state. Resolution failing is not an
|
|
||||||
/// error path bolted on: it is one of the two arms of staleness — the record
|
|
||||||
/// is gone, a category or item it named was removed — and the answer is an
|
|
||||||
/// empty batch, which applies nothing. `Sessions::apply` now reaches that
|
|
||||||
/// same conclusion first and typed, as `Rejection::RecordGone` — this stays
|
|
||||||
/// as the floor under the key path and under any host that bypasses the
|
|
||||||
/// session door.
|
|
||||||
pub fn model_effects(app: &App, cmd: &ModelCommand) -> Vec<Box<dyn Effect>> {
|
pub fn model_effects(app: &App, cmd: &ModelCommand) -> Vec<Box<dyn Effect>> {
|
||||||
let one: Box<dyn Effect> = match cmd {
|
let one: Box<dyn Effect> = match cmd {
|
||||||
ModelCommand::SetCell { cell, value } => {
|
ModelCommand::SetCell { cell, value } => {
|
||||||
@@ -515,26 +210,8 @@ pub fn model_effects(app: &App, cmd: &ModelCommand) -> Vec<Box<dyn Effect>> {
|
|||||||
vec![one]
|
vec![one]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which records a [`ModelCommand`] writes, decided by the **server**
|
/// Which records a [`ModelCommand`] writes, decided by the **server**, never
|
||||||
/// (ADR 001, improvise-fkfg).
|
/// read off the client's claim (ADR 001, improvise-fkfg). Exhaustive on purpose.
|
||||||
///
|
|
||||||
/// This is deliberately not "whichever records the client's basis mentions".
|
|
||||||
/// A client that simply left a record out of its claim would otherwise have
|
|
||||||
/// a **force by omission** — the record tier skipped, with no flag to
|
|
||||||
/// remove and nothing in the message saying so — and force is supposed to be
|
|
||||||
/// a re-composition against the current basis, never a bypass of the check.
|
|
||||||
/// The server decides what is checked; the client only supplies what it saw.
|
|
||||||
///
|
|
||||||
/// The match is exhaustive, and the long list of "names no record" is the
|
|
||||||
/// point rather than noise: a new variant that writes a record fails the
|
|
||||||
/// build here until it says so. That matters because the failure mode is
|
|
||||||
/// silent — an unlisted record-writing variant would simply never be checked
|
|
||||||
/// against its own tier, and the suite would stay green.
|
|
||||||
///
|
|
||||||
/// Only `CellData` variants that carry a [`RecordCell`] name a record. The
|
|
||||||
/// item and formula commands are `CellData` too but name no *record*: they
|
|
||||||
/// move the axes a record's coordinates are drawn from, which is what the
|
|
||||||
/// layout tier is for.
|
|
||||||
pub fn records_named(cmd: &ModelCommand) -> Vec<RecordHash> {
|
pub fn records_named(cmd: &ModelCommand) -> Vec<RecordHash> {
|
||||||
match cmd {
|
match cmd {
|
||||||
ModelCommand::SetCell { cell, .. } | ModelCommand::ClearCell(cell) => vec![cell.record],
|
ModelCommand::SetCell { cell, .. } | ModelCommand::ClearCell(cell) => vec![cell.record],
|
||||||
@@ -566,24 +243,8 @@ pub fn records_named(cmd: &ModelCommand) -> Vec<RecordHash> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Is this batch's basis still current? `None` means apply it.
|
/// Is this batch's basis still current? `None` means apply it. The three tiers
|
||||||
///
|
/// are checked largest-first, so the answer names the largest thing that moved.
|
||||||
/// The three tiers in order of how much they invalidate, so the answer a
|
|
||||||
/// client is given names the largest thing that moved rather than an
|
|
||||||
/// arbitrary one: a document swap is not usefully reported as "a record
|
|
||||||
/// moved".
|
|
||||||
///
|
|
||||||
/// The record tier separates **structural** from **data** staleness, and
|
|
||||||
/// keeping them apart is the whole reason ADR 002 put a maintained
|
|
||||||
/// `RecordHash` index in `DataStore` rather than regenerating one from the
|
|
||||||
/// layout. A record that does not resolve is *gone* — there is nothing to
|
|
||||||
/// write to and nothing coherent to force, because force never creates
|
|
||||||
/// structure. A record that resolves at a different version merely *changed*,
|
|
||||||
/// and force there is one keystroke.
|
|
||||||
///
|
|
||||||
/// Nothing here mutates, and there is no argument that makes it return
|
|
||||||
/// `None` regardless: a stale batch is never applied, and ADR 001's force is
|
|
||||||
/// a fresh batch under a fresh basis rather than a way past this function.
|
|
||||||
pub fn stale_basis(app: &App, basis: &Basis, cmd: &ModelCommand) -> Option<Rejection> {
|
pub fn stale_basis(app: &App, basis: &Basis, cmd: &ModelCommand) -> Option<Rejection> {
|
||||||
let document_moved = (basis.document_generation != app.document_generation())
|
let document_moved = (basis.document_generation != app.document_generation())
|
||||||
.then_some(Rejection::DocumentReplaced);
|
.then_some(Rejection::DocumentReplaced);
|
||||||
@@ -597,11 +258,7 @@ pub fn stale_basis(app: &App, basis: &Basis, cmd: &ModelCommand) -> Option<Rejec
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The record tier for a command inside a frame whose document tiers are
|
/// The record tier for a command inside a frame whose document tiers are
|
||||||
/// already answered.
|
/// already answered, skipping the records the frame is about to create.
|
||||||
///
|
|
||||||
/// It reuses [`stale_record`] unchanged — one home for what each of the four
|
|
||||||
/// combinations means — and differs only in *which* records it asks about:
|
|
||||||
/// the ones the frame is about to create are skipped.
|
|
||||||
fn stale_records_in_a_frame(app: &App, basis: &Basis, cmd: &ModelCommand) -> Option<Rejection> {
|
fn stale_records_in_a_frame(app: &App, basis: &Basis, cmd: &ModelCommand) -> Option<Rejection> {
|
||||||
records_named(cmd)
|
records_named(cmd)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -609,20 +266,8 @@ fn stale_records_in_a_frame(app: &App, basis: &Basis, cmd: &ModelCommand) -> Opt
|
|||||||
.find_map(|hash| stale_record(app, basis, hash))
|
.find_map(|hash| stale_record(app, basis, hash))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Is this a record the frame is about to create?
|
/// Is this a record the frame is about to create? **A property, not a list of
|
||||||
///
|
/// commands** — see `context/code/command.md`. Only the pair exempts.
|
||||||
/// **A property, not a list of commands.** The document does not have it and
|
|
||||||
/// the basis claims nothing about it — which is precisely what "I am creating
|
|
||||||
/// this" looks like from here, because `BasisCache::claim` can only state a
|
|
||||||
/// counter for a record the client has been told about.
|
|
||||||
///
|
|
||||||
/// The near miss is the other half of that conjunction: a record that does
|
|
||||||
/// not resolve but *is* claimed is a client believing in something that has
|
|
||||||
/// been removed, and stays [`Rejection::RecordGone`]. Only the pair exempts.
|
|
||||||
///
|
|
||||||
/// It is asked **only inside a frame**, never of a lone command: a first
|
|
||||||
/// command has no predecessor that could have created anything, so
|
|
||||||
/// [`Checked::against`] still refuses a write to a record that is not there.
|
|
||||||
fn will_be_created_by_the_frame(app: &App, basis: &Basis, hash: RecordHash) -> bool {
|
fn will_be_created_by_the_frame(app: &App, basis: &Basis, hash: RecordHash) -> bool {
|
||||||
app.resolve_record(hash).is_none() && basis.record(hash).is_none()
|
app.resolve_record(hash).is_none() && basis.record(hash).is_none()
|
||||||
}
|
}
|
||||||
@@ -637,20 +282,8 @@ fn stale_record(app: &App, basis: &Basis, hash: RecordHash) -> Option<Rejection>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The command a client that is **fully caught up** would compose.
|
/// The command a client that is **fully caught up** would compose. It takes
|
||||||
///
|
/// `&App`, so nothing reachable from a wire can call it: not a bypass.
|
||||||
/// It reads the tiers straight off the `App`, which looks like forging and
|
|
||||||
/// is not: it takes `&App`, so only the side that *is* the authoritative
|
|
||||||
/// state can call it, and for that side "the current basis" is not a claim
|
|
||||||
/// to be checked but a fact. A remote client has no `App` and no other
|
|
||||||
/// constructor — `BasisCache::claim` withholds until the server has spoken —
|
|
||||||
/// so nothing reachable from a wire can get here.
|
|
||||||
///
|
|
||||||
/// It exists for two callers: a host that resolves a command locally and
|
|
||||||
/// wants to push it through the same door a client's would take, and the
|
|
||||||
/// tests whose subject is not staleness. The tests whose subject *is*
|
|
||||||
/// staleness build the [`Basis`] by hand, so neither kind can pass by
|
|
||||||
/// accident through the other's convenience.
|
|
||||||
pub fn caught_up(app: &App, cmd: ModelCommand) -> Command {
|
pub fn caught_up(app: &App, cmd: ModelCommand) -> Command {
|
||||||
Command::Model {
|
Command::Model {
|
||||||
basis: Basis {
|
basis: Basis {
|
||||||
@@ -665,12 +298,8 @@ pub fn caught_up(app: &App, cmd: ModelCommand) -> Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply one checked wire command and return what every client must be told.
|
/// Apply one checked wire command and return the document-scoped projections.
|
||||||
///
|
/// Per-client grid updates need a viewport and come from `Sessions::project_all`.
|
||||||
/// The signature the three hosts share. The returned commands are the
|
|
||||||
/// document-scoped projections; per-client grid updates need a session's
|
|
||||||
/// viewport and come from `Sessions::project_all`, driven by the [`Dirty`]
|
|
||||||
/// that [`apply_command`] returns.
|
|
||||||
pub fn reduce_full(app: &mut App, cmd: Checked<'_>) -> Vec<Command> {
|
pub fn reduce_full(app: &mut App, cmd: Checked<'_>) -> Vec<Command> {
|
||||||
let dirty = apply_command(app, cmd);
|
let dirty = apply_command(app, cmd);
|
||||||
document_projections(app, dirty)
|
document_projections(app, dirty)
|
||||||
@@ -706,11 +335,8 @@ mod tests {
|
|||||||
App::new(wb, None)
|
App::new(wb, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// For the tests whose subject is not staleness: a command the current
|
/// A command the current state accepts. The staleness tests build their
|
||||||
/// state accepts, which every command built by [`caught_up`] is by
|
/// own [`Rejection`] instead, so neither kind borrows the other's luck.
|
||||||
/// construction. The tests whose subject *is* staleness call
|
|
||||||
/// [`Checked::against`] themselves and assert on the [`Rejection`], so
|
|
||||||
/// neither kind can pass by accident through the other's convenience.
|
|
||||||
fn checked<'a>(app: &App, cmd: &'a Command) -> Checked<'a> {
|
fn checked<'a>(app: &App, cmd: &'a Command) -> Checked<'a> {
|
||||||
Checked::against(app, cmd).expect("composed against the current state")
|
Checked::against(app, cmd).expect("composed against the current state")
|
||||||
}
|
}
|
||||||
@@ -745,9 +371,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The two copies of "which view is active" must move together
|
/// The two copies of "which view is active" must move together
|
||||||
/// (gotcha 29). Folding `SwitchView` through `reduce_view` alone would
|
/// (gotcha 29), or the cursor map is read under the wrong key.
|
||||||
/// move the client's and leave the document's behind, and the cursor map
|
|
||||||
/// would then be read under the wrong key.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn switch_view_moves_the_document_and_the_client_together() {
|
fn switch_view_moves_the_document_and_the_client_together() {
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
@@ -756,23 +380,8 @@ mod tests {
|
|||||||
assert_eq!(app.model_state.workbook.active_view, "Second");
|
assert_eq!(app.model_state.workbook.active_view, "Second");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A wire-driven view switch owes the same history a key-driven one
|
/// A wire-driven view switch owes the same `view_back_stack` history a
|
||||||
/// leaves behind.
|
/// key-driven one leaves behind (Copilot, PR #11).
|
||||||
///
|
|
||||||
/// `ModelCommand::ViewBack` pops `view_state.view_back_stack`, and the
|
|
||||||
/// only thing that ever pushes onto it is `effect::SwitchView`. While
|
|
||||||
/// `apply_command` performed the switch itself — calling
|
|
||||||
/// `App::switch_to_view` directly rather than running the effect — the
|
|
||||||
/// stack stayed empty, so a client that switched over the wire could not
|
|
||||||
/// go back, though the same two keypresses could. Wave 4 is what made
|
|
||||||
/// that reachable: before `ModelCommand` there was no wire way to ask.
|
|
||||||
///
|
|
||||||
/// Nothing else in the suite could see it.
|
|
||||||
/// `switch_view_moves_the_document_and_the_client_together` checks where
|
|
||||||
/// a switch *landed*; this checks what it *recorded*.
|
|
||||||
///
|
|
||||||
/// Bug demonstrated 2026-09-19 (Copilot, PR #11): the `ViewBack` left the
|
|
||||||
/// document on "Second".
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_wire_view_switch_records_history_the_way_a_key_switch_does() {
|
fn a_wire_view_switch_records_history_the_way_a_key_switch_does() {
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
@@ -908,12 +517,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `app()` with a cell actually stored, so a surrogate for it resolves
|
/// `app()` with a cell actually stored, so a `RecordCell` surrogate for it
|
||||||
/// through the index rather than only through the layout. The walks over
|
/// resolves — otherwise every walk below fails for a reason not its own.
|
||||||
/// `every_model_command` run against this: a `SetCell` whose record
|
|
||||||
/// cannot be resolved produces *no effects*, and every one of those
|
|
||||||
/// tests asserts on the effects, so the fixture has to name something
|
|
||||||
/// real or they would all fail for a reason that is not theirs.
|
|
||||||
fn model_app() -> App {
|
fn model_app() -> App {
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
app.model_state
|
app.model_state
|
||||||
@@ -924,12 +529,8 @@ mod tests {
|
|||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One value per `ModelCommand` variant.
|
/// One value per `ModelCommand` variant. [`variant_name`] matches
|
||||||
///
|
/// exhaustively, so a new variant fails the build until it is listed here.
|
||||||
/// The enum is the derived set, so a test that walks "the set" has to
|
|
||||||
/// walk all of it. [`variant_name`] is the completeness guard: it matches
|
|
||||||
/// exhaustively, so a variant added to the protocol fails the build here
|
|
||||||
/// until it is listed below as well.
|
|
||||||
fn every_model_command() -> Vec<ModelCommand> {
|
fn every_model_command() -> Vec<ModelCommand> {
|
||||||
vec![
|
vec![
|
||||||
ModelCommand::SetCell {
|
ModelCommand::SetCell {
|
||||||
@@ -1031,15 +632,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The derivation, stated as a check: every `ModelCommand` names an
|
/// The derivation, stated as a check: every `ModelCommand` names an effect
|
||||||
/// effect that declares `EffectKind::Model(..)`.
|
/// that declares `EffectKind::Model(..)` — not a `ViewOnly` one (gotcha 35).
|
||||||
///
|
|
||||||
/// This is what "derived, not invented" means operationally
|
|
||||||
/// (`doctrine(derive_the_enum_from_the_effects)`). A variant pointed at a
|
|
||||||
/// `ViewOnly` effect would be a wire message that changed the document
|
|
||||||
/// for the client that sent it and for nobody else — `Dirty::of` would
|
|
||||||
/// report `NOTHING`, and no projection would ever correct the others
|
|
||||||
/// (gotcha 35).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_model_command_names_an_effect_that_declares_a_model_change() {
|
fn every_model_command_names_an_effect_that_declares_a_model_change() {
|
||||||
for cmd in every_model_command() {
|
for cmd in every_model_command() {
|
||||||
@@ -1060,26 +654,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The two `Model` effects whose operand is **session-local residue**
|
/// The two `Model` effects whose operand is session-local residue
|
||||||
/// (gotcha 30): one client's uncommitted drill staging, and the workbook
|
/// (gotcha 30): one client's drill staging, one client's wizard workbook.
|
||||||
/// one client's half-finished import wizard built.
|
|
||||||
const SESSION_LOCAL_EFFECTS: [&str; 2] = ["ApplyAndClearDrill", "AcceptWizardModel"];
|
const SESSION_LOCAL_EFFECTS: [&str; 2] = ["ApplyAndClearDrill", "AcceptWizardModel"];
|
||||||
|
|
||||||
/// improvise-3x3u: the wire vocabulary is the *addressable* model
|
/// improvise-3x3u: the wire vocabulary is the *addressable* model effects.
|
||||||
/// effects, not all of them.
|
/// Re-adding either variant means addressing it, not restoring the nullary form.
|
||||||
///
|
|
||||||
/// `Sessions::apply` routes a `Command::Model` with no `SessionId`,
|
|
||||||
/// which is right exactly when the change is addressed to no one session
|
|
||||||
/// (gotcha 36). These two are addressed to precisely one, and it is not
|
|
||||||
/// the sender: an unaddressed `ApplyAndClearDrill` commits another
|
|
||||||
/// client's private typing (improvise-tom1) and an unaddressed
|
|
||||||
/// `AcceptWizardModel` swaps the document out of a dialogue the sender
|
|
||||||
/// never opened. Both were demonstrated against `Sessions::apply` before
|
|
||||||
/// the variants were removed.
|
|
||||||
///
|
|
||||||
/// Re-adding either variant means making it *addressed* -- carrying a
|
|
||||||
/// `SessionId`, with the staging map moved onto the session -- not
|
|
||||||
/// restoring the nullary form this test forbids.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_model_command_commits_another_clients_session_local_state() {
|
fn no_model_command_commits_another_clients_session_local_state() {
|
||||||
for cmd in every_model_command() {
|
for cmd in every_model_command() {
|
||||||
@@ -1097,8 +677,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The guard above is only worth having while the effects it names still
|
/// The guard above is only worth having while the effects it names still
|
||||||
/// exist and are still model changes -- otherwise it would pass by
|
/// exist and are still model changes (`doctrine(check_the_checker)`).
|
||||||
/// naming nothing (`doctrine(check_the_checker)`).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_session_local_effects_are_real_model_effects() {
|
fn the_session_local_effects_are_real_model_effects() {
|
||||||
let effects: [Box<dyn Effect>; 2] = [
|
let effects: [Box<dyn Effect>; 2] = [
|
||||||
@@ -1118,14 +697,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The type name of an effect, read off the leading identifier of its
|
/// An effect's type name, off the leading identifier of its derived `Debug`.
|
||||||
/// derived `Debug`.
|
/// `Effect` is a trait object, so `std::any::type_name` cannot reach it.
|
||||||
///
|
|
||||||
/// `Effect` is a trait object, so `std::any::type_name` is not reachable
|
|
||||||
/// through it; every effect derives `Debug`, and a derived `Debug` opens
|
|
||||||
/// with the type's own name for a unit struct, a tuple struct and a
|
|
||||||
/// braced struct alike. So the name is already on the value — this only
|
|
||||||
/// reads it back.
|
|
||||||
fn effect_type_name(effect: &dyn Effect) -> String {
|
fn effect_type_name(effect: &dyn Effect) -> String {
|
||||||
format!("{effect:?}")
|
format!("{effect:?}")
|
||||||
.split(|c: char| !c.is_alphanumeric() && c != '_')
|
.split(|c: char| !c.is_alphanumeric() && c != '_')
|
||||||
@@ -1134,10 +707,8 @@ mod tests {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The reader above is itself a check, so it gets the injection too
|
/// The reader above is itself a check, so it gets the injection too: one
|
||||||
/// (`doctrine(check_the_checker)`): one effect of each `Debug` shape,
|
/// effect of each `Debug` shape — nothing, `(`, and ` {` after the name.
|
||||||
/// because the leading identifier is followed by a different delimiter
|
|
||||||
/// in each — nothing, `(`, and ` {`.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_effect_name_reader_reads_all_three_debug_shapes() {
|
fn the_effect_name_reader_reads_all_three_debug_shapes() {
|
||||||
assert_eq!(effect_type_name(&effect::TransposeAxes), "TransposeAxes");
|
assert_eq!(effect_type_name(&effect::TransposeAxes), "TransposeAxes");
|
||||||
@@ -1154,29 +725,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The mapping is name-for-name by construction, so state that: every
|
/// Every `ModelCommand` variant names the effect of the same name. This is
|
||||||
/// `ModelCommand` variant names the effect that carries its own name.
|
/// the check that separates an effect from its inverse — see the evidence.
|
||||||
///
|
|
||||||
/// This is the check that separates an effect from its inverse.
|
|
||||||
/// [`every_model_command_names_an_effect_that_declares_a_model_change`]
|
|
||||||
/// cannot: `HideItem` and `ShowItem`, `AddItem` and `RemoveItem`,
|
|
||||||
/// `CreateView` and `DeleteView`, `SetCell` and `ClearCell` are each two
|
|
||||||
/// effects of the *same* `EffectKind`, so a swapped pair clears that bar
|
|
||||||
/// while inverting what the wire message does. And
|
|
||||||
/// [`a_model_command_lands_where_its_keypress_lands`] cannot reach them
|
|
||||||
/// either — most variants have no single keypress to compare against, so
|
|
||||||
/// it drives three of twenty-eight.
|
|
||||||
///
|
|
||||||
/// Proven by injection 2026-09-19: `HideItem` → `effect::ShowItem`,
|
|
||||||
/// `CreateView` → `effect::DeleteView` and `AddItem` →
|
|
||||||
/// `effect::RemoveItem` all left `cargo test --workspace` at 918 passing,
|
|
||||||
/// 0 failing. This test fails on each.
|
|
||||||
///
|
|
||||||
/// The name-for-name rule is a design constraint, not a coincidence, and
|
|
||||||
/// it is what makes the derivation checkable at all
|
|
||||||
/// (`doctrine(derive_the_enum_from_the_effects)`): a variant whose effect
|
|
||||||
/// must be named something else is a variant that stopped being derived,
|
|
||||||
/// and it should fail here and be argued about rather than pass quietly.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_model_command_names_the_effect_of_the_same_name() {
|
fn every_model_command_names_the_effect_of_the_same_name() {
|
||||||
for cmd in every_model_command() {
|
for cmd in every_model_command() {
|
||||||
@@ -1202,22 +752,12 @@ mod tests {
|
|||||||
assert_eq!(names.len(), total, "a variant is listed twice: {names:?}");
|
assert_eq!(names.len(), total, "a variant is listed twice: {names:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The point of the whole ticket: a model `Command` applied
|
/// A model `Command` applied authoritatively lands where the keypress bound
|
||||||
/// authoritatively leaves the document exactly where the keypress bound
|
/// to the same mutation lands, `Dirty` included.
|
||||||
/// to the same mutation leaves it. That equivalence is what lets the two
|
|
||||||
/// entry points share one door.
|
|
||||||
///
|
|
||||||
/// The `Dirty` is compared too, because a host with connected clients
|
|
||||||
/// projects off it: a command that produced the right document and the
|
|
||||||
/// wrong `Dirty` would leave every other client stale.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_model_command_lands_where_its_keypress_lands() {
|
fn a_model_command_lands_where_its_keypress_lands() {
|
||||||
/// Two categories on different axes, because the one-category
|
/// Two categories on different axes: a one-category fixture cannot tell
|
||||||
/// fixture cannot tell a transpose from a cycle: with only `Region`
|
/// a transpose from a cycle (`doctrine(check_the_checker)`).
|
||||||
/// on Row, swapping the axes and rotating Row→Column land on the
|
|
||||||
/// same view, and an equivalence test that cannot distinguish the
|
|
||||||
/// wrong effect from the right one proves nothing
|
|
||||||
/// (`doctrine(check_the_checker)`).
|
|
||||||
fn pivoted() -> App {
|
fn pivoted() -> App {
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
app.model_state.workbook.add_category("Month").unwrap();
|
app.model_state.workbook.add_category("Month").unwrap();
|
||||||
@@ -1279,23 +819,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A wire `Command::Model` cannot reach the document without its basis
|
/// A wire `Command::Model` cannot reach the document unchecked (ADR 001,
|
||||||
/// being checked (ADR 001, improvise-ondj).
|
/// improvise-ondj). The regression guard is the signature, not this test.
|
||||||
///
|
|
||||||
/// Bug demonstrated 2026-09-19: `reduce_full` and `apply_command` — the
|
|
||||||
/// functions this module's own doc points every future host at, the
|
|
||||||
/// browser worker included — took a `&Command` and matched
|
|
||||||
/// `Command::Model { cmd, .. }`, so a batch composed against a document
|
|
||||||
/// that had since moved landed anyway. The `..` discarded the basis with
|
|
||||||
/// no compile error.
|
|
||||||
///
|
|
||||||
/// **The regression guard is the signature, not this test.** Both now
|
|
||||||
/// take a [`Checked`], whose only constructor runs [`stale_basis`], so
|
|
||||||
/// the version of this test that reproduced the bug —
|
|
||||||
/// `reduce_full(&mut app, &wire)` with a stale `wire` — no longer
|
|
||||||
/// compiles. What is left to assert is that the constructor refuses, and
|
|
||||||
/// that refusing leaves the document alone: there is no half-applied
|
|
||||||
/// batch, because the type is obtained before anything is applied.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_stale_wire_model_command_cannot_be_applied() {
|
fn a_stale_wire_model_command_cannot_be_applied() {
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
@@ -1333,33 +858,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-gv9r. **The bug**: a client's edit left the authoritative
|
/// improvise-gv9r: a client's edit used to leave the document reporting
|
||||||
/// document reporting *clean*, on every transport.
|
/// *clean* on every transport. All three `ModelChange` levels, separately.
|
||||||
///
|
|
||||||
/// `MarkDirty` used to be a separate effect that the *command* layer
|
|
||||||
/// composed alongside the mutation, and improvise-kmgs derived
|
|
||||||
/// `ModelCommand` from the effects **one at a time** — so the wire got
|
|
||||||
/// `SetCell` without its companion. A derivation correct per element and
|
|
||||||
/// wrong for a set. "Save if dirty" was therefore a trigger that never
|
|
||||||
/// fired, and a host built on it loses every client edit silently; the
|
|
||||||
/// browser worker found that out the hard way and had to over-approximate
|
|
||||||
/// off the command variant instead.
|
|
||||||
///
|
|
||||||
/// The fix is the unrepresentable one: dirtiness is raised by
|
|
||||||
/// `App::apply_effects` off the same `EffectKind` the `Dirty` and the two
|
|
||||||
/// basis tiers are read from (gotcha 40), so no path can compose a
|
|
||||||
/// document change without it. There is nothing left to forget, and
|
|
||||||
/// `MarkDirty` is gone rather than left as a second answer.
|
|
||||||
///
|
|
||||||
/// The three levels are asserted separately because they are three
|
|
||||||
/// different effects: an effect that is only ever reached by one of them
|
|
||||||
/// would otherwise pass on a sibling's evidence. The negative arm — that
|
|
||||||
/// a `ViewOnly` effect leaves the document clean — is
|
|
||||||
/// `a_view_only_batch_leaves_the_document_clean` in `ui/app.rs`, and it
|
|
||||||
/// has to live there: a view-slice `Command` never reaches
|
|
||||||
/// `apply_effects` at all (`apply_command` folds it straight into
|
|
||||||
/// `ViewState`), so a negative written against this door would pass
|
|
||||||
/// however unconditional the raise was.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_wire_model_command_marks_the_document_dirty() {
|
fn a_wire_model_command_marks_the_document_dirty() {
|
||||||
for cmd in [
|
for cmd in [
|
||||||
@@ -1387,16 +887,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `SwitchView` is the one command with a foot in both halves, and it is
|
/// `SwitchView` persists as the `Initial View` line, so it dirties the
|
||||||
/// the reason this matters beyond cell edits: `Workbook::active_view`
|
/// document too — and the worker's persist trigger depends on that.
|
||||||
/// persists as the `Initial View` line, so a switch really does put the
|
|
||||||
/// document out of step with the file.
|
|
||||||
///
|
|
||||||
/// It is tested on its own because the browser worker's persist trigger
|
|
||||||
/// now depends on it (improvise-gv9r): the worker used to name
|
|
||||||
/// `SwitchView` explicitly in a table of variants and now asks the
|
|
||||||
/// document. If this effect were ever reclassified `ViewOnly`, the
|
|
||||||
/// worker would silently stop writing view switches back.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_wire_view_switch_marks_the_document_dirty() {
|
fn a_wire_view_switch_marks_the_document_dirty() {
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
|
|||||||
@@ -13,15 +13,12 @@ use crate::view::{AxisEntry, GridLayout};
|
|||||||
pub const MIN_COL_WIDTH: u16 = 5;
|
pub const MIN_COL_WIDTH: u16 = 5;
|
||||||
pub const MAX_COL_WIDTH: u16 = 32;
|
pub const MAX_COL_WIDTH: u16 = 32;
|
||||||
|
|
||||||
// The row-header bounds moved to `improvise-protocol` with the rule that
|
// The row-header bounds live in `improvise-protocol` with the rule that clamps to
|
||||||
// clamps to them (improvise-1125): a browser sizing its own row header must
|
// them (improvise-1125): a browser must reach the same numbers.
|
||||||
// reach the same numbers, and it cannot depend on this crate.
|
|
||||||
pub use improvise_protocol::{MAX_ROW_HEADER_W, MIN_ROW_HEADER_W};
|
pub use improvise_protocol::{MAX_ROW_HEADER_W, MIN_ROW_HEADER_W};
|
||||||
|
|
||||||
/// Compute adaptive column widths for pivot mode (header labels + cell values).
|
/// Adaptive column widths for pivot mode. Header widths use the widest *individual*
|
||||||
/// Header widths use the widest *individual* level label (not the joined
|
/// level label, not the joined multi-level string.
|
||||||
/// multi-level string), matching how the grid renderer draws each level on
|
|
||||||
/// its own row with repeat-suppression.
|
|
||||||
pub fn compute_col_widths(
|
pub fn compute_col_widths(
|
||||||
model: &Model,
|
model: &Model,
|
||||||
layout: &GridLayout,
|
layout: &GridLayout,
|
||||||
@@ -80,15 +77,8 @@ pub fn compute_col_widths(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The total row-header width for a layout.
|
/// The total row-header width for a layout. The rule is the protocol's; this is the
|
||||||
///
|
/// keypress-path caller that borrows tuples — see `context/code/command.md`.
|
||||||
/// The rule itself is `improvise_protocol::header_level_widths`, which is
|
|
||||||
/// where both front ends reach it (improvise-1125). This is the one caller
|
|
||||||
/// that holds a `GridLayout` rather than a projected frame —
|
|
||||||
/// `App::visible_cols`, deciding how many columns fit — and it is on the
|
|
||||||
/// keypress path, so it hands the rule **borrowed** tuples rather than
|
|
||||||
/// building a frame and cloning every row. It does not reach for a `View`
|
|
||||||
/// either, because a collapsed flag cannot change a width.
|
|
||||||
pub fn compute_row_header_width(layout: &GridLayout) -> u16 {
|
pub fn compute_row_header_width(layout: &GridLayout) -> u16 {
|
||||||
let items = layout.row_items.iter().filter_map(|e| match e {
|
let items = layout.row_items.iter().filter_map(|e| match e {
|
||||||
AxisEntry::DataItem(v) => Some(v),
|
AxisEntry::DataItem(v) => Some(v),
|
||||||
|
|||||||
Reference in New Issue
Block a user