Files
improvise/crates/improvise-command/src/command/cmd/registry.rs
T
Edward Langley d9b10905d0 improvise-2bac: context docs, evidence, fmt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 20:58:34 -07:00

948 lines
34 KiB
Rust

use crate::model::cell::CellKey;
use crate::ui::app::AppMode;
use crate::ui::effect::Panel;
/// Decode a mode-name string into an `AppMode`. The table is `AppMode::from_name` in
/// the protocol crate: the spellings are keymap vocabulary and the keymap is shared.
fn parse_mode_name(s: &str) -> Result<AppMode, String> {
AppMode::from_name(s).ok_or_else(|| format!("Unknown mode: {s}"))
}
use super::cell::*;
use super::commit::*;
use super::core::*;
use super::effect_cmds::*;
use super::fit::FitMeasure;
use super::grid::*;
use super::mode::*;
use super::navigation::*;
use super::panel::*;
use super::search::*;
use super::text_buffer::*;
use super::tile::*;
use super::wizard::*;
/// The cell under the cursor: what a coordinate command falls back to when given none.
fn cursor_key(ctx: &CmdContext) -> Result<CellKey, String> {
ctx.cell_key().clone().ok_or("no cell at cursor".into())
}
/// A `Move` built from args takes the live cursor as its origin.
fn move_from_cursor(cmd: Move, ctx: &CmdContext) -> Move {
Move {
cursor: CursorState::from_ctx(ctx),
..cmd
}
}
/// A commit built from args still advances from the live cursor.
fn commit_from_cursor(cmd: CommitAndAdvance, ctx: &CmdContext) -> CommitAndAdvance {
CommitAndAdvance {
cursor: CursorState::from_ctx(ctx),
..cmd
}
}
/// `<mode>` alone: commit the edit buffer to the cell under the cursor.
fn commit_at_cursor(
args: &[String],
ctx: &CmdContext,
advance: AdvanceDir,
) -> Result<CommitAndAdvance, String> {
let [mode] = args else {
return Err("takes a mode alone, or a mode, a value and coordinates".into());
};
Ok(CommitAndAdvance {
edit_mode: parse_mode_name(mode)?,
value: read_buffer(ctx, "edit"),
key: cursor_key(ctx)?,
advance,
cursor: CursorState::from_ctx(ctx),
})
}
/// For a command with no context-only fields: `parse`'s result is used as built.
fn as_parsed<C>(cmd: C, _ctx: &CmdContext) -> C {
cmd
}
/// Build the default command registry with all commands.
/// Registry names MUST match the `Cmd::name()` return value.
pub fn default_registry() -> CmdRegistry {
let mut r = CmdRegistry::new();
// ── Model mutations (effect_cmd! wrappers) ───────────────────────────
r.register_pure(&AddCategoryCmd(vec![]), AddCategoryCmd::parse);
r.register_pure(&AddItemCmd(vec![]), AddItemCmd::parse);
r.register_pure(&AddItemsCmd(vec![]), AddItemsCmd::parse);
r.register_pure(&AddItemInGroupCmd(vec![]), AddItemInGroupCmd::parse);
r.register_pure(&SetCellCmd(vec![]), SetCellCmd::parse);
r.register_pure(&ClearBufferCmd(vec![]), ClearBufferCmd::parse);
r.register(
&ClearCellCommand {
key: CellKey::new(vec![]),
},
|args| {
if args.is_empty() {
return Err("clear-cell requires at least one Cat/Item coordinate".into());
}
Ok(ClearCellCommand {
key: parse_cell_key_from_args(args),
})
},
as_parsed,
Some(|_, ctx| {
Ok(ClearCellCommand {
key: cursor_key(ctx)?,
})
}),
);
r.register_pure(&AddFormulaCmd(vec![]), AddFormulaCmd::parse);
r.register_pure(&FitMeasure::prototype(), FitMeasure::parse);
r.register_pure(&RemoveFormulaCmd(vec![]), RemoveFormulaCmd::parse);
r.register_pure(&CreateViewCmd(vec![]), CreateViewCmd::parse);
r.register_pure(&DeleteViewCmd(vec![]), DeleteViewCmd::parse);
r.register_pure(&SwitchViewCmd(vec![]), SwitchViewCmd::parse);
r.register_pure(&SetAxisCmd(vec![]), SetAxisCmd::parse);
r.register_pure(&SetPageCmd(vec![]), SetPageCmd::parse);
r.register_pure(&ToggleGroupCmd(vec![]), ToggleGroupCmd::parse);
r.register_pure(&HideItemCmd(vec![]), HideItemCmd::parse);
r.register_pure(&ShowItemCmd(vec![]), ShowItemCmd::parse);
r.register_pure(&SaveAsCmd(vec![]), SaveAsCmd::parse);
r.register_pure(&LoadModelCmd(vec![]), LoadModelCmd::parse);
r.register_pure(&ExportCsvCmd(vec![]), ExportCsvCmd::parse);
r.register_pure(&ImportJsonCmd(vec![]), ImportJsonCmd::parse);
r.register_pure(&SetFormatCmd(vec![]), SetFormatCmd::parse);
r.register_pure(&ImportCmd(vec![]), ImportCmd::parse);
r.register_pure(&ExportCmd(vec![]), ExportCmd::parse);
r.register_pure(&WriteCmd(vec![]), WriteCmd::parse);
r.register_pure(&HelpCmd(vec![]), HelpCmd::parse);
r.register_pure(&HelpPageNextCmd(vec![]), HelpPageNextCmd::parse);
r.register_pure(&HelpPagePrevCmd(vec![]), HelpPagePrevCmd::parse);
// ── Navigation (unified Move) ──────────────────────────────────────
r.register(
&Move {
kind: MoveKind::Relative(0, 0),
cursor: CursorState::default(),
cmd_name: "move-selection",
},
|args| {
require_args(args, 2)?;
let dr = args[0].parse::<i32>().map_err(|e| e.to_string())?;
let dc = args[1].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Move {
kind: MoveKind::Relative(dr, dc),
cursor: CursorState::default(),
cmd_name: "move-selection",
})
},
move_from_cursor,
None,
);
// Jump-to-edge commands: first/last row/col
macro_rules! reg_jump {
($r:expr, $is_row:expr, $to_end:expr, $name:expr) => {
$r.register(
&Move {
kind: if $to_end {
MoveKind::ToEnd($is_row)
} else {
MoveKind::ToStart($is_row)
},
cursor: CursorState::default(),
cmd_name: $name,
},
|_| {
Ok(Move {
kind: if $to_end {
MoveKind::ToEnd($is_row)
} else {
MoveKind::ToStart($is_row)
},
cursor: CursorState::default(),
cmd_name: $name,
})
},
move_from_cursor,
None,
);
};
}
reg_jump!(r, true, false, "jump-first-row");
reg_jump!(r, true, true, "jump-last-row");
reg_jump!(r, false, false, "jump-first-col");
reg_jump!(r, false, true, "jump-last-col");
r.register(
&Move {
kind: MoveKind::Relative(0, 0),
cursor: CursorState::default(),
cmd_name: "scroll-rows",
},
|args| {
require_args(args, 1)?;
let n = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Move {
kind: MoveKind::Relative(n, 0),
cursor: CursorState::default(),
cmd_name: "scroll-rows",
})
},
move_from_cursor,
None,
);
r.register(
&Move {
kind: MoveKind::Page(0),
cursor: CursorState::default(),
cmd_name: "page-scroll",
},
|args| {
require_args(args, 1)?;
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Move {
kind: MoveKind::Page(dir),
cursor: CursorState::default(),
cmd_name: "page-scroll",
})
},
move_from_cursor,
None,
);
r.register(
&EnterAdvance {
cursor: CursorState::default(),
},
|_| {
Ok(EnterAdvance {
cursor: CursorState {
row: 0,
col: 0,
row_count: 0,
col_count: 0,
row_offset: 0,
col_offset: 0,
visible_rows: 20,
visible_cols: 8,
},
})
},
|_, ctx| EnterAdvance {
cursor: CursorState::from_ctx(ctx),
},
None,
);
// ── Cell operations ──────────────────────────────────────────────────
r.register(
&YankCell {
key: CellKey::new(vec![]),
},
|args| {
if args.is_empty() {
return Err("yank requires at least one Cat/Item coordinate".into());
}
Ok(YankCell {
key: parse_cell_key_from_args(args),
})
},
as_parsed,
Some(|_, ctx| {
Ok(YankCell {
key: cursor_key(ctx)?,
})
}),
);
r.register(
&PasteCell {
key: CellKey::new(vec![]),
},
|args| {
if args.is_empty() {
return Err("paste requires at least one Cat/Item coordinate".into());
}
Ok(PasteCell {
key: parse_cell_key_from_args(args),
})
},
as_parsed,
Some(|_, ctx| {
Ok(PasteCell {
key: cursor_key(ctx)?,
})
}),
);
// clear-cell is registered above (unified: ctx.cell_key() or explicit coords)
// ── View / page ──────────────────────────────────────────────────────
r.register_nullary(|| Box::new(TransposeAxes));
r.register_nullary(|| Box::new(PageNext));
r.register_nullary(|| Box::new(PagePrev));
// ── Mode changes ─────────────────────────────────────────────────────
r.register_nullary(|| Box::new(ForceQuit));
r.register_nullary(|| Box::new(Quit));
r.register_nullary(|| Box::new(SaveAndQuit));
r.register_nullary(|| Box::new(SaveCmd));
r.register_nullary(|| Box::new(EnterSearchMode));
r.register_pure(
&EditOrDrill {
edit_mode: AppMode::Normal,
},
|args| {
require_args(args, 1)?;
let edit_mode = parse_mode_name(&args[0])?;
Ok(Box::new(EditOrDrill { edit_mode }))
},
);
r.register_pure(
&EnterEditAtCursorCmd {
target_mode: AppMode::Normal,
},
|args| {
require_args(args, 1)?;
let target_mode = parse_mode_name(&args[0])?;
Ok(Box::new(EnterEditAtCursorCmd { target_mode }))
},
);
r.register_nullary(|| Box::new(EnterTileSelect));
r.register(
&DrillIntoCell {
key: crate::model::cell::CellKey::new(vec![]),
},
|args| {
if args.is_empty() {
return Err("drill-into-cell requires Cat/Item coordinates".into());
}
Ok(DrillIntoCell {
key: parse_cell_key_from_args(args),
})
},
as_parsed,
Some(|_, ctx| {
Ok(DrillIntoCell {
key: cursor_key(ctx)?,
})
}),
);
r.register_nullary(|| Box::new(ViewNavigate { forward: false }));
r.register_nullary(|| Box::new(ViewNavigate { forward: true }));
r.register_pure(&EnterMode(AppMode::Normal), |args| {
require_args(args, 1)?;
Ok(Box::new(EnterMode(parse_mode_name(&args[0])?)))
});
// ── Search ───────────────────────────────────────────────────────────
r.register_pure(&SearchNavigate(true), |args| {
let forward = args.first().map(|s| s != "backward").unwrap_or(true);
Ok(Box::new(SearchNavigate(forward)))
});
r.register_nullary(|| Box::new(SearchOrCategoryAdd));
r.register_nullary(|| Box::new(ExitSearchMode));
// ── Panel operations ─────────────────────────────────────────────────
r.register(
&TogglePanelAndFocus {
panel: Panel::Formula,
target: None,
},
|args| {
// Parse: toggle-panel-and-focus <panel> [open] [focused]
require_args(args, 1)?;
let panel = parse_panel(&args[0])?;
let target = args.get(1).map(|s| {
let open = s == "true";
(open, args.get(2).map(|s| s == "true").unwrap_or(open))
});
Ok(TogglePanelAndFocus { panel, target })
},
as_parsed,
None,
);
r.register(
&TogglePanelVisibility {
panel: Panel::Formula,
currently_open: false,
},
|args| {
require_args(args, 1)?;
let panel = parse_panel(&args[0])?;
Ok(TogglePanelVisibility {
panel,
currently_open: false,
})
},
|cmd, ctx| TogglePanelVisibility {
currently_open: ctx.panel_open(cmd.panel),
..cmd
},
None,
);
r.register(
&CyclePanelFocus {
formula_open: false,
category_open: false,
view_open: false,
},
|_| {
Ok(CyclePanelFocus {
formula_open: false,
category_open: false,
view_open: false,
})
},
|_, ctx| CyclePanelFocus {
formula_open: ctx.formula_panel_open,
category_open: ctx.category_panel_open,
view_open: ctx.view_panel_open,
},
None,
);
r.register(
&MovePanelCursor {
panel: Panel::Formula,
delta: 0,
current: 0,
max: 0,
},
|args| {
require_args(args, 2)?;
let panel = parse_panel(&args[0])?;
let delta = args[1].parse::<i32>().map_err(|e| e.to_string())?;
Ok(MovePanelCursor {
panel,
delta,
current: 0,
max: 0,
})
},
|cmd, ctx| {
let (current, max) = match cmd.panel {
Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()),
Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()),
Panel::View => (ctx.view_panel_cursor, ctx.workbook.views.len()),
};
MovePanelCursor {
current,
max,
..cmd
}
},
None,
);
r.register_nullary(|| Box::new(DeleteFormulaAtCursor));
r.register_nullary(|| Box::new(AddRecordRow));
r.register_nullary(|| Box::new(TogglePruneEmpty));
r.register_nullary(|| Box::new(ToggleRecordsMode));
r.register_nullary(|| Box::new(CycleAxisAtCursor));
r.register_nullary(|| Box::new(OpenItemAddAtCursor));
r.register_nullary(|| Box::new(DeleteCategoryAtCursor));
r.register_nullary(|| Box::new(ToggleCatExpand));
r.register_nullary(|| Box::new(FilterToItem));
r.register_nullary(|| Box::new(SwitchViewAtCursor));
r.register_nullary(|| Box::new(CreateAndSwitchView));
r.register_nullary(|| Box::new(DeleteViewAtCursor));
// ── Tile select ──────────────────────────────────────────────────────
r.register_pure(&MoveTileCursor(0), |args| {
require_args(args, 1)?;
let delta = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(MoveTileCursor(delta)))
});
r.register_nullary(|| Box::new(TileAxisOp { axis: None }));
r.register_pure(
&TileAxisOp {
axis: Some(crate::view::Axis::Row),
},
|args| {
require_args(args, 1)?;
let axis = parse_axis(&args[0])?;
Ok(Box::new(TileAxisOp { axis: Some(axis) }))
},
);
// ── Grid operations ──────────────────────────────────────────────────
r.register_nullary(|| Box::new(ToggleGroupAtCursor { is_row: true }));
r.register_nullary(|| Box::new(ToggleGroupAtCursor { is_row: false }));
r.register_nullary(|| Box::new(HideSelectedRowItem));
// ── Text buffer ──────────────────────────────────────────────────────
r.register_pure(
&AppendChar {
buffer: String::new(),
},
|args| {
require_args(args, 1)?;
Ok(Box::new(AppendChar {
buffer: args[0].clone(),
}))
},
);
r.register_pure(
&PopChar {
buffer: String::new(),
},
|args| {
require_args(args, 1)?;
Ok(Box::new(PopChar {
buffer: args[0].clone(),
}))
},
);
r.register_nullary(|| Box::new(CommandModeBackspace));
// ── Commit ───────────────────────────────────────────────────────────
// These take a mode-name arg as args[0]; the command never inspects ctx.mode.
r.register(
&CommitAndAdvance {
key: CellKey::new(vec![]),
value: String::new(),
advance: AdvanceDir::Down,
cursor: CursorState::default(),
edit_mode: AppMode::editing(),
},
|args| {
if args.len() < 3 {
return Err("commit-cell-edit requires a mode, value, and coords".into());
}
let edit_mode = parse_mode_name(&args[0])?;
Ok(CommitAndAdvance {
key: parse_cell_key_from_args(&args[2..]),
value: args[1].clone(),
advance: AdvanceDir::Down,
cursor: CursorState::default(),
edit_mode,
})
},
commit_from_cursor,
Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Down)),
);
r.register(
&CommitAndAdvance {
key: CellKey::new(vec![]),
value: String::new(),
advance: AdvanceDir::Right,
cursor: CursorState::default(),
edit_mode: AppMode::editing(),
},
|_| Err("commit-and-advance-right requires context".into()),
commit_from_cursor,
Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Right)),
);
r.register(
&CommitBuffer {
kind: CommitKind::Category,
},
|args| {
require_args(args, 1)?;
let kind = parse_commit_kind(&args[0], &AppMode::Normal)?;
Ok(CommitBuffer { kind })
},
as_parsed,
Some(|args, ctx| {
require_args(args, 1)?;
let kind = parse_commit_kind(&args[0], ctx.mode)?;
Ok(CommitBuffer { kind })
}),
);
r.register_nullary(|| Box::new(CommitExport));
r.register_nullary(|| Box::new(CommitSave));
r.register_nullary(|| Box::new(ExecuteCommand));
// ── Wizard ───────────────────────────────────────────────────────────
r.register_nullary(|| Box::new(HandleWizardKey));
// ── Aliases (short names for common commands) ────────────────────────
r.alias("add-cat", "add-category");
r.alias("formula", "add-formula");
r.alias("add-view", "create-view");
r.alias("q!", "force-quit");
r
}
#[cfg(test)]
mod tests {
use super::*;
use crate::command::cmd::test_helpers::*;
/// Argument vectors to try when constructing a command, most specific
/// first. A command is exercised by the first one its parser accepts.
fn candidate_args() -> Vec<Vec<String>> {
[
vec![],
vec!["1"],
vec!["-1"],
vec!["right"],
vec!["normal"],
vec!["editing"],
vec!["row"],
vec!["formula"],
vec!["backward"],
vec!["a"],
vec!["Region"],
vec!["Region", "East"],
vec!["Region", "East", "1"],
vec!["Profit = 1"],
vec![",.2f"],
vec!["/tmp/improvise-registry-test.improv"],
vec!["1", "1"],
vec!["formula", "1"],
vec!["editing", "5", "Region", "East"],
vec!["editing", "5", "Region/East"],
vec!["formula", "false", "false"],
vec!["Region/East"],
]
.into_iter()
.map(|v| v.into_iter().map(String::from).collect())
.collect()
}
/// improvise-9cn/61f: four commands name themselves off a *field*, so a wrong
/// variant compiles and files the command under the wrong name (gotcha 15).
#[test]
fn every_entry_constructs_a_command_with_its_registered_name() {
let r = default_registry();
let wb = two_cat_model();
let layout = make_layout(&wb);
let reg = make_registry();
let ctx = make_ctx(&wb, &layout, &reg);
let mut unexercised: Vec<&'static str> = Vec::new();
for name in r.names().collect::<Vec<_>>() {
let parsed = candidate_args()
.iter()
.find_map(|args| r.parse(name, args).ok());
let interactive = candidate_args()
.iter()
.find_map(|args| r.interactive(name, args, &ctx).ok());
for (path, cmd) in [("parse", &parsed), ("interactive", &interactive)] {
if let Some(cmd) = cmd {
assert_eq!(
cmd.name(),
name,
"registered as \"{name}\" but {path} builds \"{}\"",
cmd.name()
);
}
}
if parsed.is_none() && interactive.is_none() {
unexercised.push(name);
}
}
assert!(
unexercised.is_empty(),
"no candidate args construct these by either path, \
so their names go unchecked: {unexercised:?}"
);
}
/// A variant mix-up shows up as a **collision**, not a mismatch: the wrong entry
/// takes its sibling's name and the sibling vanishes. Uniqueness catches it.
#[test]
fn registered_names_are_unique() {
let r = default_registry();
let mut seen: Vec<&'static str> = Vec::new();
let mut dupes: Vec<&'static str> = Vec::new();
for name in r.names() {
if seen.contains(&name) {
dupes.push(name);
}
seen.push(name);
}
assert!(
dupes.is_empty(),
"registered twice — one prototype names the wrong variant: {dupes:?}"
);
}
/// Aliases must resolve to a command that actually exists.
#[test]
fn every_alias_resolves_to_a_registered_command() {
let r = default_registry();
let names: Vec<&str> = r.names().collect();
for (alias, canonical) in [
("add-cat", "add-category"),
("formula", "add-formula"),
("add-view", "create-view"),
("q!", "force-quit"),
] {
assert!(
names.contains(&canonical),
"alias \"{alias}\" points at unregistered \"{canonical}\""
);
}
}
/// A refusal names the word the user typed, not the one the alias table
/// resolved it to — `context/test-evidence/command.md`.
#[test]
fn a_refusal_names_the_command_as_typed_through_an_alias() {
let err = crate::command::parse_line("formula Product Total = Widget + Gadget")
.expect_err("an unquoted formula is refused");
assert!(
err.starts_with("formula:"),
"the refusal does not open with the word typed: {err:?}"
);
assert!(
!err.contains("add-formula"),
"the refusal names a command the user did not type: {err:?}"
);
}
/// Stating the arity does not tell anyone what to do; stating the shape does.
#[test]
fn a_formula_refusal_states_the_shape_and_asks_for_quoting() {
let err = crate::command::parse_line("formula Product Total = Widget + Gadget")
.expect_err("an unquoted formula is refused");
assert!(
err.contains("quote"),
"the refusal does not suggest quoting, so it is unactionable: {err:?}"
);
assert!(
err.contains("<Name> = <expr>"),
"the refusal does not state the shape it wanted: {err:?}"
);
}
/// Every other refusal is named too, without the alias table in the way.
#[test]
fn a_refusal_from_a_canonical_name_is_named_as_typed() {
let err = crate::command::parse_line("add-item").expect_err("add-item needs arguments");
assert!(
err.starts_with("add-item:"),
"the refusal does not open with the word typed: {err:?}"
);
}
/// The ex-commands the help pages print are `HelpLine::Literal`/`Accent`
/// rows, which no derivation checks — `context/test-evidence/command.md`.
#[test]
fn every_ex_command_the_help_pages_show_is_one_the_parser_accepts() {
let mut checked = 0;
for page in improvise_protocol::help::pages() {
for line in &page.lines {
let text = match line {
improvise_protocol::help::HelpLine::Accent(s) => *s,
improvise_protocol::help::HelpLine::Literal { keys, .. } => *keys,
_ => continue,
};
let text = text.trim();
if !text.starts_with(':') {
continue;
}
// One row may show two examples side by side, each opening
// with its own colon.
for example in text.split(':').filter(|s| !s.trim().is_empty()) {
if let Err(e) = crate::command::parse_line(example.trim()) {
panic!(
"help page {:?} shows `:{}`, which the parser refuses: {e}",
page.title,
example.trim()
);
}
checked += 1;
}
}
}
assert!(
checked >= 8,
"only {checked} ex-command examples were found; the scan has stopped seeing them"
);
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn commit_cell_edit_honours_an_explicit_value_and_cell_over_the_cursor() {
let mut app = crate::ui::app::App::new(two_cat_model(), None);
app.view_state.buffers.insert("edit".into(), "decoy".into());
let ctx = app.cmd_context(
crate::command::key::KeyCode::Null,
crate::command::key::KeyModifiers::NONE,
);
let target = CellKey::new(vec![
("Month".into(), "Feb".into()),
("Type".into(), "Clothing".into()),
]);
assert_ne!(
ctx.cell_key().as_ref(),
Some(&target),
"the cursor is elsewhere"
);
let args: Vec<String> = ["editing", "5", "Type/Clothing", "Month/Feb"]
.map(String::from)
.into();
let dbg = format!(
"{:?}",
ctx.registry
.interactive("commit-cell-edit", &args, &ctx)
.expect("commit-cell-edit resolves")
);
assert!(
dbg.contains(&format!("key: {target:?}")),
"wrong cell: {dbg}"
);
assert!(dbg.contains("value: \"5\""), "wrong value: {dbg}");
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn every_argument_list_the_gate_accepts_the_run_accepts_too() {
let r = default_registry();
let mut wb = crate::workbook::Workbook::new("Empty");
wb.add_category("Foo").expect("a category with no items");
let app = crate::ui::app::App::new(wb, None);
let ctx = app.cmd_context(
crate::command::key::KeyCode::Null,
crate::command::key::KeyModifiers::NONE,
);
assert!(ctx.cell_key().is_none(), "the cursor must sit on no cell");
let mut accepted = 0;
for name in r.names().collect::<Vec<_>>() {
for args in candidate_args() {
if r.parse(name, &args).is_err() {
continue;
}
accepted += 1;
if let Err(e) = r.interactive(name, &args, &ctx) {
panic!("the gate accepts `{name} {args:?}` but the run refuses it: {e}");
}
}
}
assert!(accepted > 50, "only {accepted} accepted pairs were tried");
}
}
#[cfg(test)]
mod panel_open_tests {
use super::*;
use crate::command::cmd::test_helpers::two_cat_model;
use crate::command::key::{KeyCode, KeyModifiers};
use crate::ui::app::{App, AppMode};
/// Write the raw field each `Panel` names. Spelling the fields here is what
/// gives the read side a second path — context/test-evidence/command.md.
fn open_only(app: &mut App, panel: Panel) {
app.view_state.formula_panel_open = panel == Panel::Formula;
app.view_state.category_panel_open = panel == Panel::Category;
app.view_state.view_panel_open = panel == Panel::View;
}
fn panel_mode(panel: Panel) -> AppMode {
match panel {
Panel::Formula => AppMode::FormulaPanel,
Panel::Category => AppMode::CategoryPanel,
Panel::View => AppMode::ViewPanel,
}
}
#[test]
fn the_context_reports_each_panels_own_open_flag() {
for opened in Panel::ALL {
let mut app = App::new(two_cat_model(), None);
open_only(&mut app, opened);
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
for asked in Panel::ALL {
assert_eq!(
ctx.panel_open(asked),
asked == opened,
"with only {opened:?} open, the context answers wrongly for {asked:?}"
);
}
}
}
#[test]
fn toggle_panel_visibility_reads_the_panel_it_names() {
for opened in Panel::ALL {
let mut app = App::new(two_cat_model(), None);
open_only(&mut app, opened);
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
for asked in Panel::ALL {
let args = [asked.name().to_string()];
let cmd = ctx
.registry
.interactive("toggle-panel-visibility", &args, &ctx)
.expect("toggle-panel-visibility resolves");
let dbg = format!("{cmd:?}");
let expected = format!("currently_open: {}", asked == opened);
assert!(
dbg.contains(&expected),
"with only {opened:?} open, toggling {asked:?} read another \
panel's flag: {dbg}"
);
}
}
}
#[test]
fn a_focused_open_panel_closes_and_a_closed_one_opens() {
for panel in Panel::ALL {
let mut app = App::new(two_cat_model(), None);
open_only(&mut app, panel);
app.view_state.mode = panel_mode(panel);
let args = [panel.name().to_string()];
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
let dbg = format!(
"{:?}",
ctx.registry
.interactive("toggle-panel-and-focus", &args, &ctx)
.expect("toggle-panel-and-focus resolves")
.execute(&ctx)
);
assert!(
dbg.contains("open: false"),
"{panel:?} is open and focused, so its key must close it: {dbg}"
);
let mut app = App::new(two_cat_model(), None);
app.view_state.formula_panel_open = false;
app.view_state.category_panel_open = false;
app.view_state.view_panel_open = false;
app.view_state.mode = panel_mode(panel);
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
let dbg = format!(
"{:?}",
ctx.registry
.interactive("toggle-panel-and-focus", &args, &ctx)
.expect("toggle-panel-and-focus resolves")
.execute(&ctx)
);
assert!(
dbg.contains("open: true"),
"{panel:?} is closed, so its own key must open it rather than \
leave it unopenable: {dbg}"
);
}
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn explicit_open_and_focus_arguments_beat_the_toggle() {
for panel in Panel::ALL {
let mut app = App::new(two_cat_model(), None);
app.view_state.formula_panel_open = false;
app.view_state.category_panel_open = false;
app.view_state.view_panel_open = false;
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
let args = [panel.name().to_string(), "false".into(), "false".into()];
let dbg = format!(
"{:?}",
ctx.registry
.interactive("toggle-panel-and-focus", &args, &ctx)
.expect("toggle-panel-and-focus resolves")
.execute(&ctx)
);
assert!(
dbg.contains("open: false") && !dbg.contains("open: true"),
"`{} false false` on a closed panel toggled it open: {dbg}",
panel.name()
);
}
}
}