Help rows name no key by hand, and the guard hears what advertised keys do
wk18: gg/yy/ZZ and the page tip are keymap lookups (prefixed sequences included); ex-command rows can only be built by ex!, whose colon is its own. help_bindings() records every row the pages ask for, from the same builders. 3t5x: every_advertised_command_answers_in_its_own_mode walks the hint line AND the help pages, judges each key by what moved (a lone status is Declines, a disarm alone is Silent), and names every key that does not do what it says. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
@@ -310,8 +310,8 @@ pub enum HelpLine {
|
||||
keys: Vec<String>,
|
||||
description: &'static str,
|
||||
},
|
||||
/// A row naming something the keymap does not own: an ex-command, or a
|
||||
/// prefix sequence, which is two addresses and not one binding.
|
||||
/// An ex-command, which the keymap does not own. Built only by `ex!`, whose
|
||||
/// colon is what stops a key being typed here (improvise-wk18).
|
||||
Literal {
|
||||
keys: &'static str,
|
||||
description: &'static str,
|
||||
@@ -330,35 +330,60 @@ pub struct HelpPage {
|
||||
/// row is dropped, so the page never advertises a key that does not exist.
|
||||
fn keys(
|
||||
mode: &ModeKey,
|
||||
command: &str,
|
||||
command: &'static str,
|
||||
args: Option<&[&str]>,
|
||||
description: &'static str,
|
||||
) -> Option<HelpLine> {
|
||||
let args: Option<Vec<String>> = args.map(|a| a.iter().map(|s| (*s).to_string()).collect());
|
||||
let found = keys_for(mode, command, args.as_deref());
|
||||
match found.is_empty() {
|
||||
let mut labels: Vec<String> = keys_for(mode, command, args.as_deref())
|
||||
.iter()
|
||||
.map(|(c, m)| key_label(c, *m))
|
||||
.collect();
|
||||
labels.extend(prefixed_labels(mode, command, args.as_deref()));
|
||||
match labels.is_empty() {
|
||||
true => None,
|
||||
false => Some(HelpLine::Keys {
|
||||
keys: found.iter().map(|(c, m)| key_label(c, *m)).collect(),
|
||||
keys: labels,
|
||||
description,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn literal(keys: &'static str, description: &'static str) -> HelpLine {
|
||||
HelpLine::Literal {
|
||||
keys,
|
||||
description,
|
||||
dim: false,
|
||||
/// `gg`, `yy`, `ZZ`: a prefix key and the key after it, both off the keymap.
|
||||
fn prefixed_labels(mode: &ModeKey, command: &str, args: Option<&[String]>) -> Vec<String> {
|
||||
let mut labels: Vec<String> = match Keymaps::shared().get(mode) {
|
||||
Some(keymap) => keymap.prefixed_keys_running(command, args),
|
||||
None => Vec::new(),
|
||||
}
|
||||
.iter()
|
||||
.map(|[(c1, m1), (c2, m2)]| format!("{}{}", key_label(c1, *m1), key_label(c2, *m2)))
|
||||
.collect();
|
||||
labels.sort();
|
||||
labels.dedup();
|
||||
labels
|
||||
}
|
||||
|
||||
fn dim(keys: &'static str, description: &'static str) -> HelpLine {
|
||||
HelpLine::Literal {
|
||||
keys,
|
||||
description,
|
||||
dim: true,
|
||||
}
|
||||
/// How a page asks for a keys row. `pages` answers with [`keys`]; a test
|
||||
/// answers by recording the question, so a guard reads commands, not text.
|
||||
type KeysRow<'a> =
|
||||
&'a dyn Fn(&ModeKey, &'static str, Option<&[&str]>, &'static str) -> Option<HelpLine>;
|
||||
|
||||
/// An ex-command row. The leading colon is the macro's, not the caller's.
|
||||
macro_rules! ex {
|
||||
($text:literal, $description:expr $(,)?) => {
|
||||
HelpLine::Literal {
|
||||
keys: concat!(":", $text),
|
||||
description: $description,
|
||||
dim: false,
|
||||
}
|
||||
};
|
||||
(dim $text:literal, $description:expr $(,)?) => {
|
||||
HelpLine::Literal {
|
||||
keys: concat!(" :", $text),
|
||||
description: $description,
|
||||
dim: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// The five pages, in order. `HELP_PAGE_COUNT` is their count and the bound
|
||||
@@ -367,30 +392,48 @@ pub fn pages() -> Vec<HelpPage> {
|
||||
let pages = vec![
|
||||
HelpPage {
|
||||
title: "Welcome",
|
||||
lines: welcome(),
|
||||
lines: welcome(&keys),
|
||||
},
|
||||
HelpPage {
|
||||
title: "Navigation",
|
||||
lines: navigation(),
|
||||
lines: navigation(&keys),
|
||||
},
|
||||
HelpPage {
|
||||
title: "Editing",
|
||||
lines: editing(),
|
||||
lines: editing(&keys),
|
||||
},
|
||||
HelpPage {
|
||||
title: "Panels & Views",
|
||||
lines: panels(),
|
||||
lines: panels(&keys),
|
||||
},
|
||||
HelpPage {
|
||||
title: "Commands",
|
||||
lines: commands(),
|
||||
lines: commands(&keys),
|
||||
},
|
||||
];
|
||||
debug_assert_eq!(pages.len(), HELP_PAGE_COUNT);
|
||||
pages
|
||||
}
|
||||
|
||||
fn welcome() -> Vec<HelpLine> {
|
||||
/// Every keys row the pages ask for, as `(mode, command, args, description)`
|
||||
/// — the same builders, so a guard over it cannot drift from what is drawn.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn help_bindings() -> Vec<(ModeKey, &'static str, Option<Vec<String>>, &'static str)> {
|
||||
let asked = std::cell::RefCell::new(Vec::new());
|
||||
let record: KeysRow = &|mode, command, args, description| {
|
||||
let args = args.map(|a| a.iter().map(|s| (*s).to_string()).collect());
|
||||
asked
|
||||
.borrow_mut()
|
||||
.push((mode.clone(), command, args, description));
|
||||
None
|
||||
};
|
||||
for build in [welcome, navigation, editing, panels, commands] {
|
||||
build(record);
|
||||
}
|
||||
asked.into_inner()
|
||||
}
|
||||
|
||||
fn welcome(keys: KeysRow) -> Vec<HelpLine> {
|
||||
vec![
|
||||
HelpLine::Blank,
|
||||
HelpLine::Banner(" improvise"),
|
||||
@@ -428,11 +471,18 @@ fn welcome() -> Vec<HelpLine> {
|
||||
HelpLine::Text(" Tile The row/column/page assignment of a category."),
|
||||
HelpLine::Text(" Formula A computed item: derives its value from other items."),
|
||||
HelpLine::Blank,
|
||||
dim("Tip: press Tab or l/n to go to the next page.", ""),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(keys(
|
||||
&ModeKey::Help,
|
||||
"help-page-next",
|
||||
None,
|
||||
"Next page of this help",
|
||||
))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn navigation() -> Vec<HelpLine> {
|
||||
fn navigation(keys: KeysRow) -> Vec<HelpLine> {
|
||||
let n = &ModeKey::Normal;
|
||||
[
|
||||
Some(HelpLine::Blank),
|
||||
@@ -445,7 +495,7 @@ fn navigation() -> Vec<HelpLine> {
|
||||
None,
|
||||
"Move down, wrapping to the next column",
|
||||
),
|
||||
Some(literal("gg", "Jump to first row")),
|
||||
keys(n, "jump-first-row", None, "Jump to first row"),
|
||||
keys(n, "jump-last-row", None, "Jump to last row"),
|
||||
keys(n, "jump-first-col", None, "Jump to first column"),
|
||||
keys(n, "jump-last-col", None, "Jump to last column"),
|
||||
@@ -512,7 +562,7 @@ fn navigation() -> Vec<HelpLine> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn editing() -> Vec<HelpLine> {
|
||||
fn editing(keys: KeysRow) -> Vec<HelpLine> {
|
||||
let n = &ModeKey::Normal;
|
||||
let e = &ModeKey::Editing;
|
||||
[
|
||||
@@ -555,7 +605,7 @@ fn editing() -> Vec<HelpLine> {
|
||||
Some(HelpLine::Blank),
|
||||
Some(HelpLine::Heading("Copy and paste")),
|
||||
Some(HelpLine::Blank),
|
||||
Some(literal("yy", "Yank (copy) the current cell value")),
|
||||
keys(n, "yank", None, "Yank (copy) the current cell value"),
|
||||
keys(
|
||||
n,
|
||||
"paste",
|
||||
@@ -593,7 +643,7 @@ fn editing() -> Vec<HelpLine> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn panels() -> Vec<HelpLine> {
|
||||
fn panels(keys: KeysRow) -> Vec<HelpLine> {
|
||||
let n = &ModeKey::Normal;
|
||||
[
|
||||
Some(HelpLine::Blank),
|
||||
@@ -741,14 +791,14 @@ fn panels() -> Vec<HelpLine> {
|
||||
"Toggle collapse of nearest group above cursor",
|
||||
),
|
||||
keys(n, "hide-selected-row-item", None, "Hide current row item"),
|
||||
Some(dim(" :show-item <cat> <item>", "Restore a hidden item")),
|
||||
Some(ex!(dim "show-item <cat> <item>", "Restore a hidden item")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn commands() -> Vec<HelpLine> {
|
||||
fn commands(keys: KeysRow) -> Vec<HelpLine> {
|
||||
let n = &ModeKey::Normal;
|
||||
[
|
||||
Some(HelpLine::Blank),
|
||||
@@ -763,55 +813,40 @@ fn commands() -> Vec<HelpLine> {
|
||||
Some(HelpLine::Blank),
|
||||
Some(HelpLine::Heading("File operations")),
|
||||
Some(HelpLine::Blank),
|
||||
Some(literal(
|
||||
":w [path]",
|
||||
"Save (path optional after first save)",
|
||||
)),
|
||||
Some(literal(":wq", "Save and quit")),
|
||||
Some(literal(":q", "Quit (warns if unsaved changes)")),
|
||||
Some(literal(":q!", "Force quit without saving")),
|
||||
Some(literal("ZZ", "Save and quit (same as :wq)")),
|
||||
Some(ex!("w [path]", "Save (path optional after first save)",)),
|
||||
Some(ex!("wq", "Save and quit")),
|
||||
Some(ex!("q", "Quit (warns if unsaved changes)")),
|
||||
Some(ex!("q!", "Force quit without saving")),
|
||||
keys(n, "wq", None, "Save and quit (same as :wq)"),
|
||||
keys(n, "save", None, "Save (same as :w)"),
|
||||
Some(HelpLine::Blank),
|
||||
Some(HelpLine::Heading("Import and export")),
|
||||
Some(HelpLine::Blank),
|
||||
Some(literal(":import <path>", "Open the JSON/CSV import wizard")),
|
||||
Some(literal(
|
||||
":export [path.csv]",
|
||||
"Export the active view to CSV",
|
||||
)),
|
||||
Some(ex!("import <path>", "Open the JSON/CSV import wizard")),
|
||||
Some(ex!("export [path.csv]", "Export the active view to CSV",)),
|
||||
Some(HelpLine::Blank),
|
||||
Some(HelpLine::Heading("Model building")),
|
||||
Some(HelpLine::Blank),
|
||||
Some(literal(":add-cat <name>", "Add a new category")),
|
||||
Some(literal(
|
||||
":add-item <cat> <item>",
|
||||
"Add one item to a category",
|
||||
)),
|
||||
Some(literal(
|
||||
":add-items <cat> a b c ...",
|
||||
Some(ex!("add-cat <name>", "Add a new category")),
|
||||
Some(ex!("add-item <cat> <item>", "Add one item to a category",)),
|
||||
Some(ex!(
|
||||
"add-items <cat> a b c ...",
|
||||
"Add multiple items at once",
|
||||
)),
|
||||
Some(literal(
|
||||
":formula <cat> \"<Name> = <expr>\"",
|
||||
"Add a formula",
|
||||
)),
|
||||
Some(ex!("formula <cat> \"<Name> = <expr>\"", "Add a formula",)),
|
||||
Some(HelpLine::Blank),
|
||||
Some(HelpLine::Heading("Views")),
|
||||
Some(HelpLine::Blank),
|
||||
Some(literal(":add-view [name]", "Create a new view")),
|
||||
Some(ex!("add-view [name]", "Create a new view")),
|
||||
Some(HelpLine::Blank),
|
||||
Some(HelpLine::Heading("Display")),
|
||||
Some(HelpLine::Blank),
|
||||
Some(literal(
|
||||
":set-format <fmt>",
|
||||
"Set number format (e.g. ',.2')",
|
||||
)),
|
||||
Some(ex!("set-format <fmt>", "Set number format (e.g. ',.2')",)),
|
||||
Some(HelpLine::Blank),
|
||||
Some(HelpLine::Heading("Other keys")),
|
||||
Some(HelpLine::Blank),
|
||||
keys(n, "enter-mode", Some(&["help"]), "Open this help screen"),
|
||||
Some(literal(":help", "Open this help screen")),
|
||||
Some(ex!("help", "Open this help screen")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -915,6 +950,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A row whose lookup finds no key is dropped silently, so a broken lookup
|
||||
/// looks like a shorter page. See `context/test-evidence/protocol.md`.
|
||||
#[test]
|
||||
fn every_keys_row_the_pages_ask_for_finds_its_keys() {
|
||||
let asked = help_bindings();
|
||||
assert!(asked.len() > 20, "only {} rows asked", asked.len());
|
||||
let dropped: Vec<String> = asked
|
||||
.iter()
|
||||
.filter(|(mode, command, args, description)| {
|
||||
let args: Option<Vec<&str>> = args
|
||||
.as_ref()
|
||||
.map(|a| a.iter().map(String::as_str).collect());
|
||||
keys(mode, command, args.as_deref(), description).is_none()
|
||||
})
|
||||
.map(|(mode, command, _, _)| format!("{mode:?} {command}"))
|
||||
.collect();
|
||||
assert!(dropped.is_empty(), "rows with no key: {dropped:?}");
|
||||
}
|
||||
|
||||
/// `compact`'s three shapes, including the one no shipped mode reaches:
|
||||
/// keys that are all chords leave nothing to draw.
|
||||
#[test]
|
||||
|
||||
@@ -2045,7 +2045,20 @@ mod tests {
|
||||
/// A client whose every widget has something in it, so an `Upstream` names
|
||||
/// an **unhandled command** and never a state that said no (improvise-rmvx).
|
||||
fn furnished(mode: AppMode) -> ClientState {
|
||||
let mut state = client_on(record_cell());
|
||||
furnished_on(mode, record_cell())
|
||||
}
|
||||
|
||||
/// `furnished`, with `cell` under the cursor. A history in both directions
|
||||
/// and a yanked value, so no `Cmd` is refused by an empty stack (3t5x).
|
||||
fn furnished_on(mode: AppMode, cell: CellId) -> ClientState {
|
||||
let mut state = client_on(cell);
|
||||
let frame = |name: &str| crate::ViewFrame {
|
||||
view_name: name.into(),
|
||||
mode: AppMode::Normal,
|
||||
};
|
||||
state.view.view_back_stack = vec![frame("Second")];
|
||||
state.view.view_forward_stack = vec![frame("Third")];
|
||||
state.view.yanked = Some(CellValue::Number(5.0));
|
||||
// A records MODE over a non-records GRID is a state no host can
|
||||
// produce, so the fixture projects both (improvise-e5pf).
|
||||
crate::WidgetCache::apply(&mut state.grid, &GridUpdate::Records(mode.is_records()));
|
||||
@@ -2105,59 +2118,201 @@ mod tests {
|
||||
state
|
||||
}
|
||||
|
||||
/// improvise-rmvx. An advertised key that resolves upstream is a black hole
|
||||
/// (gotcha 47). See `context/test-evidence/clients.md`.
|
||||
/// What a user sees an advertised key do. A status alone is `Declines`:
|
||||
/// honest, but not what the caption promised (improvise-3t5x).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Heard {
|
||||
Acts,
|
||||
Declines,
|
||||
Silent,
|
||||
}
|
||||
|
||||
/// Judged by what moved, never by the variant: a chord's second key is
|
||||
/// `Local` even when all it does is disarm the prefix.
|
||||
fn heard(outcome: KeyOutcome) -> Heard {
|
||||
let commands = match outcome {
|
||||
KeyOutcome::Upstream(_) | KeyOutcome::Ignored => return Heard::Silent,
|
||||
KeyOutcome::Request(_) => return Heard::Acts,
|
||||
KeyOutcome::Local(commands) => commands,
|
||||
};
|
||||
let said = commands.iter().any(|c| matches!(c, Command::SetStatus(_)));
|
||||
let did = commands
|
||||
.iter()
|
||||
.any(|c| !matches!(c, Command::SetStatus(_) | Command::SetTransientPrefix(None)));
|
||||
match (did, said) {
|
||||
(true, _) => Heard::Acts,
|
||||
(false, true) => Heard::Declines,
|
||||
(false, false) => Heard::Silent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `(mode, search, command, args)` either table advertises: the
|
||||
/// status-bar hint and the help pages (improvise-wk18).
|
||||
fn advertised_everywhere() -> Vec<(AppMode, bool, &'static str, Option<Vec<String>>)> {
|
||||
let mut rows = Vec::new();
|
||||
for mode in AppMode::all() {
|
||||
for (command, args, _) in crate::help::advertised(&mode) {
|
||||
rows.push((mode.clone(), false, command, args));
|
||||
}
|
||||
}
|
||||
for (key, command, args, _) in crate::help::help_bindings() {
|
||||
let (mode, search) = match key {
|
||||
ModeKey::SearchMode => (AppMode::Normal, true),
|
||||
key => match AppMode::all()
|
||||
.into_iter()
|
||||
.find(|m| ModeKey::from_app_mode(m, false).as_ref() == Some(&key))
|
||||
{
|
||||
Some(mode) => (mode, false),
|
||||
None => panic!("a help row names {key:?}, which no mode reaches"),
|
||||
},
|
||||
};
|
||||
rows.push((mode, search, command, args));
|
||||
}
|
||||
rows.sort_by_key(|(m, s, c, a)| (crate::mode_label(m), *s, *c, a.clone()));
|
||||
rows.dedup();
|
||||
rows
|
||||
}
|
||||
|
||||
/// Every key sequence that runs `command` in `keymap`: one key, or a
|
||||
/// prefix and the key after it.
|
||||
fn sequences_running(
|
||||
keymap: &Keymap,
|
||||
command: &str,
|
||||
args: Option<&[String]>,
|
||||
) -> Vec<Vec<KeyEvent>> {
|
||||
let one = keymap
|
||||
.keys_running(command, args)
|
||||
.into_iter()
|
||||
.map(|(code, mods)| vec![KeyEvent::new(code, mods)]);
|
||||
let two = keymap
|
||||
.prefixed_keys_running(command, args)
|
||||
.into_iter()
|
||||
.map(|pair| pair.map(|(code, mods)| KeyEvent::new(code, mods)).to_vec());
|
||||
one.chain(two).collect()
|
||||
}
|
||||
|
||||
/// What pressing `sequence` in `state` does, arming any prefix first.
|
||||
fn press(mut state: ClientState, sequence: &[KeyEvent]) -> Heard {
|
||||
let (last, prefix) = sequence.split_last().expect("a sequence has a key");
|
||||
for key in prefix {
|
||||
if let KeyOutcome::Local(commands) = resolve(&state, *key) {
|
||||
commands.iter().for_each(|c| state.fold(c));
|
||||
}
|
||||
}
|
||||
heard(resolve(&state, *last))
|
||||
}
|
||||
|
||||
/// improvise-rmvx, wk18, 3t5x. Every advertised key does what it says, or
|
||||
/// is named below saying why not. See `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn every_advertised_command_answers_in_its_own_mode() {
|
||||
let mut unanswered = Vec::new();
|
||||
for mode in AppMode::all() {
|
||||
let Some(mode_key) = ModeKey::from_app_mode(&mode, false) else {
|
||||
continue;
|
||||
let aggregate = CellId::Aggregate(
|
||||
serde_json::from_str("\"aa4c8e5e9cbd578e0d388f4b2b5a7879\"").unwrap(),
|
||||
);
|
||||
let derived = CellId::Derived {
|
||||
target: "Profit".into(),
|
||||
};
|
||||
// (mode, cell, command, heard) -> the keys that heard it.
|
||||
let mut short: std::collections::BTreeMap<(String, &str, Heard), Vec<String>> =
|
||||
Default::default();
|
||||
for (mode, search, command, args) in advertised_everywhere() {
|
||||
let key = ModeKey::from_app_mode(&mode, search).expect("a keyed mode");
|
||||
let keymap = Keymaps::shared().get(&key).expect("a keymap");
|
||||
let at = |cell: &CellId| {
|
||||
let mut state = furnished_on(mode.clone(), cell.clone());
|
||||
state.view.search_mode = search;
|
||||
state
|
||||
};
|
||||
let Some(keymap) = Keymaps::shared().get(&mode_key) else {
|
||||
continue;
|
||||
};
|
||||
let state = furnished(mode.clone());
|
||||
for (command, args, caption) in crate::help::advertised(&mode) {
|
||||
for (code, mods) in keymap.keys_running(command, args.as_deref()) {
|
||||
let event = KeyEvent::new(code, mods);
|
||||
// BOTH silent variants. To a user `Ignored` is the same
|
||||
// black hole as `Upstream` — nothing is on the screen.
|
||||
let answered = !matches!(
|
||||
resolve(&state, event),
|
||||
KeyOutcome::Upstream(_) | KeyOutcome::Ignored
|
||||
);
|
||||
if !answered {
|
||||
unanswered.push(format!(
|
||||
"{}: {:?} → `{command} {args:?}` ({caption})",
|
||||
crate::mode_label(&mode),
|
||||
code
|
||||
));
|
||||
let label = crate::mode_label(&mode).to_string() + if search { " SEARCH" } else { "" };
|
||||
for sequence in sequences_running(keymap, command, args.as_deref()) {
|
||||
let keys: String = sequence
|
||||
.iter()
|
||||
.map(|k| crate::keymap::key_label(&k.code, k.modifiers))
|
||||
.collect();
|
||||
let on_record = press(at(&record_cell()), &sequence);
|
||||
let mut report = vec![(label.clone(), on_record)];
|
||||
// Only a grid mode stands on another kind of cell, and a cell
|
||||
// matters only where it changes the answer.
|
||||
if mode == AppMode::Normal {
|
||||
for (on, cell) in [
|
||||
(" on an aggregate", &aggregate),
|
||||
(" on a derived cell", &derived),
|
||||
] {
|
||||
let got = press(at(cell), &sequence);
|
||||
if got != on_record {
|
||||
report.push((label.clone() + on, got));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (where_, got) in report.into_iter().filter(|(_, got)| *got != Heard::Acts) {
|
||||
short
|
||||
.entry((where_, command, got))
|
||||
.or_default()
|
||||
.push(keys.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
unanswered.sort();
|
||||
unanswered.dedup();
|
||||
// The five that remain, each with the reason it is not a black hole.
|
||||
// Closing one has to come here and say so; opening a sixth fails.
|
||||
let expected = [
|
||||
let short: Vec<String> = short
|
||||
.into_iter()
|
||||
.map(|((at, command, got), mut keys)| {
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
format!("{at}: {} {command} {got:?}", keys.join("/"))
|
||||
})
|
||||
.collect();
|
||||
// Closing one has to come here and say so; adding one fails. A caption
|
||||
// is a promise, and each line here is a promise a browser does not keep.
|
||||
let expected: &[&str] = &[
|
||||
// Reached only from a mode `declined` refuses at the door, so a
|
||||
// browser is never standing in front of this hint.
|
||||
"COMMAND: Enter → `execute-command None` (run)",
|
||||
"EXPORT: Enter → `commit-export None` (export)",
|
||||
"SAVE AS: Enter → `commit-save None` (save)",
|
||||
// browser is never standing in front of these.
|
||||
"COMMAND: Enter execute-command Silent",
|
||||
"EXPORT: Enter commit-export Silent",
|
||||
"SAVE AS: Enter commit-save Silent",
|
||||
"NORMAL SEARCH: Enter/Esc exit-search-mode Silent",
|
||||
// Commands the browser has no arm for: upstream, so a black hole
|
||||
// (improvise-uln5). Each is also in the host-only list below.
|
||||
"NORMAL: Enter enter-advance Silent",
|
||||
"NORMAL: H hide-selected-row-item Silent",
|
||||
"NORMAL: / search Silent",
|
||||
"NORMAL: n search-navigate Silent",
|
||||
"NORMAL: z toggle-group-under-cursor Silent",
|
||||
// Honestly unavailable, and saying so in `declined`'s words.
|
||||
"FORMULAS: Del/d delete-formula-at-cursor Declines",
|
||||
"NORMAL: > drill-into-cell Declines",
|
||||
"NORMAL: : enter-mode Declines",
|
||||
"NORMAL: P toggle-prune-empty Declines",
|
||||
"NORMAL: R toggle-records-mode Declines",
|
||||
"NORMAL: ZZ wq Declines",
|
||||
"RECORDS: o add-record-row Declines",
|
||||
"RECORDS: a/i edit-or-drill Declines",
|
||||
"RECORDS: : enter-mode Declines",
|
||||
"RECORDS: P toggle-prune-empty Declines",
|
||||
"RECORDS: R toggle-records-mode Declines",
|
||||
// `i` on an aggregate IS drill in the TUI (improvise-s8h7), which
|
||||
// the browser lacks; on a derived cell the TUI edits a shadowed
|
||||
// cell and the browser names the formula instead (improvise-ir7l).
|
||||
"NORMAL on an aggregate: a/i edit-or-drill Declines",
|
||||
"NORMAL on a derived cell: a/i edit-or-drill Declines",
|
||||
// No record under the cursor to write. The TUI writes onto the
|
||||
// aggregate's partial key, which is its own bug (improvise-l4mp).
|
||||
"NORMAL on an aggregate: p paste Silent",
|
||||
"NORMAL on an aggregate: x clear-cell Silent",
|
||||
"NORMAL on a derived cell: p paste Silent",
|
||||
"NORMAL on a derived cell: x clear-cell Silent",
|
||||
// A records cell commit needs `record_col` (gotcha 1); only the two
|
||||
// creating columns resolve, and this fixture is on a record cell.
|
||||
"RECORDS INSERT: Enter → `commit-cell-edit None` (commit)",
|
||||
"RECORDS INSERT: Tab → `commit-and-advance-right None` (commit+right)",
|
||||
"RECORDS INSERT: Tab commit-and-advance-right Silent",
|
||||
"RECORDS INSERT: Enter commit-cell-edit Silent",
|
||||
];
|
||||
let mut expected: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
|
||||
expected.sort();
|
||||
let mut short = short;
|
||||
short.sort();
|
||||
assert_eq!(
|
||||
unanswered, expected,
|
||||
"advertised and unanswerable: anything here that is not in the \
|
||||
named list above reads as broken rather than as unavailable"
|
||||
short,
|
||||
expected,
|
||||
"advertised keys that do not do what they say, in full:\n{}",
|
||||
short.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user