Resolutions against gh/main 8d870c15:
- client.rs: kept main's settle_status (1x9h) and #295's
settle_declined_editor (5m9z) side by side.
- resolve.rs reopened_at: took #295's declined_at shape and moved #292's
CellId::NonItem arm into declined_at, which reopened_at consults before
any seed choice, so a NonItem landing still re-opens nothing.
- lockstep.rs: both sides appended at EOF; kept main's tail then #295's.
Both defined sent_to_host with different semantics (main's panics on a
non-local key, #295's lets an upstream key reach a silent host), so
#295's copy is renamed sent_or_upstream with its three callers.
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:
@@ -248,10 +248,11 @@ impl ClientState {
|
||||
self.view.opened_editor = Some(crate::OpenedEditor::on(seed));
|
||||
}
|
||||
self.settle_awaited_seed();
|
||||
self.settle_declined_editor();
|
||||
}
|
||||
|
||||
/// An editor opened after a write takes its seed from the grid the host
|
||||
/// answered with, never from the cache the write made stale (improvise-3uzg).
|
||||
/// An editor opened after a write, or on a cell not projected yet, takes its
|
||||
/// seed once both are answered, and never over typing (improvise-3uzg/7mro).
|
||||
fn settle_awaited_seed(&mut self) {
|
||||
let waiting = self.view.mode.is_editing()
|
||||
&& self
|
||||
@@ -260,7 +261,8 @@ impl ClientState {
|
||||
.as_ref()
|
||||
.is_some_and(|opened| opened.awaiting && !opened.touched);
|
||||
let answered = !self.in_flight.iter().any(|sent| sent.awaited);
|
||||
if let (true, true, Some(seed)) = (waiting, answered, crate::resolve::edit_seed(self)) {
|
||||
if let (true, true, Some(seed)) = (waiting, answered, crate::resolve::projected_seed(self))
|
||||
{
|
||||
self.view.buffers.insert("edit".to_string(), seed.clone());
|
||||
self.view.opened_editor = Some(crate::OpenedEditor::on(seed));
|
||||
}
|
||||
@@ -278,6 +280,22 @@ impl ClientState {
|
||||
}
|
||||
}
|
||||
|
||||
/// An untouched editor on a cell `i` declines closes once the host projects
|
||||
/// that cell: an advance past the window opens before it can know (5m9z).
|
||||
fn settle_declined_editor(&mut self) {
|
||||
let (row, col) = self.view.cursor().selected;
|
||||
let untouched = self.view.mode.is_editing()
|
||||
&& self
|
||||
.view
|
||||
.opened_editor
|
||||
.as_ref()
|
||||
.is_some_and(|opened| !opened.touched);
|
||||
if let (true, Some(refusal)) = (untouched, crate::resolve::declined_at(self, row, col)) {
|
||||
self.view.mode = settled_mode(&crate::AppMode::Normal, self.grid.records);
|
||||
self.view.status_msg = refusal;
|
||||
}
|
||||
}
|
||||
|
||||
/// The widget dispatch: a projection names the widget it is addressed to.
|
||||
/// The `CategoryTreeCache`/`TileBarCache` overlap is deliberate (gotcha 32).
|
||||
fn apply_projection(&mut self, projection: &Projection) {
|
||||
@@ -577,6 +595,10 @@ mod tests {
|
||||
fn answered_with(state: &mut ClientState, edit_text: &str) {
|
||||
let mut cell = CellDisplay::number(edit_text);
|
||||
cell.edit_text = Some(edit_text.into());
|
||||
cell.id = Some(crate::CellId::Record(crate::RecordCell {
|
||||
record: serde_json::from_str("\"fe4c8e5e9cbd578e0d388f4b2b5a7879\"").unwrap(),
|
||||
measure: Some("Revenue".into()),
|
||||
}));
|
||||
for cmd in [
|
||||
GridUpdate::RowFrame(AxisFrame::flat(["North"])),
|
||||
GridUpdate::ColFrame(AxisFrame::flat(["Revenue"])),
|
||||
|
||||
@@ -313,7 +313,7 @@ enum Step {
|
||||
fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> Step {
|
||||
match requested(name) {
|
||||
Some(request) => Step::Request(request),
|
||||
None => match declined(name, args) {
|
||||
None => match declined(name, args).or_else(|| refused_here(state, name)) {
|
||||
Some(said) => Step::Declined(vec![Command::SetStatus(said)]),
|
||||
None => match composed(state, event, name, args) {
|
||||
Some(commands) => Step::Local(commands),
|
||||
@@ -323,6 +323,18 @@ fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> St
|
||||
}
|
||||
}
|
||||
|
||||
/// A commit onto a cell `i` declines is refused as `committable_cell_key` refuses
|
||||
/// it, out loud with the binding (ik6l); a cell op on an unprojected cell waits (7mro).
|
||||
fn refused_here(state: &ClientState, name: &str) -> Option<String> {
|
||||
let (row, col) = state.view.cursor().selected;
|
||||
let waiting = unprojected(state, row, col).then(|| NOT_PROJECTED.to_string());
|
||||
match name {
|
||||
"commit-cell-edit" | "commit-and-advance-right" => declined_at(state, row, col).or(waiting),
|
||||
"clear-cell" | "yank" | "paste" => waiting,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The command names this client answers by **asking its host**, rather than
|
||||
/// by composing anything. See `context/code/clients.md` (improvise-jz6w).
|
||||
fn requested(name: &str) -> Option<Request> {
|
||||
@@ -835,13 +847,11 @@ fn tile_cursor(state: &ClientState, delta: i32) -> Option<Vec<Command>> {
|
||||
|
||||
// ── editing a cell ──────────────────────────────────────────────────────
|
||||
|
||||
/// The cell under the cursor in this viewport-indexed cache. `None` when the
|
||||
/// selection is outside the window — reachable only by a projection race.
|
||||
/// The cell under the cursor, as the host projected it. `None` outside the
|
||||
/// window the cache holds, which the cursor's own offsets may have left (7mro).
|
||||
fn cell_at_cursor(state: &ClientState) -> Option<&crate::CellDisplay> {
|
||||
let cursor = state.view.cursor();
|
||||
let row = cursor.selected.0.checked_sub(cursor.row_offset)?;
|
||||
let col = cursor.selected.1.checked_sub(cursor.col_offset)?;
|
||||
state.grid.cell(row, col)
|
||||
let (row, col) = state.view.cursor().selected;
|
||||
projected_at(state, row, col)
|
||||
}
|
||||
|
||||
/// `EditOrDrill::execute`, with ADR 002 answering the branch, and drill
|
||||
@@ -852,6 +862,10 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option<Vec<Command>> {
|
||||
if state.grid.records {
|
||||
return Some(vec![Command::SetStatus(not_yet("editing in records mode"))]);
|
||||
}
|
||||
let (row, col) = state.view.cursor().selected;
|
||||
if unprojected(state, row, col) {
|
||||
return Some(awaiting_seed(mode));
|
||||
}
|
||||
let cell = cell_at_cursor(state)?;
|
||||
match cell.id.as_ref()? {
|
||||
// `edit_text`, never `text`: the drawn text has been through
|
||||
@@ -898,6 +912,19 @@ fn seeded(seed: String, mode: AppMode) -> Vec<Command> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Open the cell editor with its seed still to come — after a write, or on a cell
|
||||
/// not shown yet — settled by the fold once both are answered (3uzg/7mro).
|
||||
fn awaiting_seed(mode: AppMode) -> Vec<Command> {
|
||||
vec![
|
||||
Command::SetBuffer {
|
||||
name: EDIT_BUFFER.to_string(),
|
||||
value: String::new(),
|
||||
},
|
||||
Command::SetOpenedEditor(Some(OpenedEditor::awaiting())),
|
||||
Command::ChangeMode(mode),
|
||||
]
|
||||
}
|
||||
|
||||
/// One direction of the view history. An empty stack is answered out loud:
|
||||
/// `reduce_view`'s own arm is silent there — `context/code/clients.md`.
|
||||
fn step_history(stack: &[crate::ViewFrame], step: Command, empty: &str) -> Vec<Command> {
|
||||
@@ -1104,19 +1131,6 @@ fn advanced(
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
/// Open the cell editor with its seed still to come: the client settles it from
|
||||
/// the host's answer, once nothing this client sent is unanswered (improvise-3uzg).
|
||||
fn awaiting_seed(mode: AppMode) -> Vec<Command> {
|
||||
vec![
|
||||
Command::SetBuffer {
|
||||
name: EDIT_BUFFER.to_string(),
|
||||
value: String::new(),
|
||||
},
|
||||
Command::SetOpenedEditor(Some(OpenedEditor::awaiting())),
|
||||
Command::ChangeMode(mode),
|
||||
]
|
||||
}
|
||||
|
||||
/// The surrogate naming the record under the cursor, or `None` when the
|
||||
/// cursor is not on a writable cell.
|
||||
fn record_at_cursor(state: &ClientState) -> Option<crate::RecordHash> {
|
||||
@@ -1136,19 +1150,18 @@ pub(crate) fn edit_seed(state: &ClientState) -> Option<String> {
|
||||
cell_at_cursor(state)?.edit_text.clone()
|
||||
}
|
||||
|
||||
/// The display text at an absolute grid position, as the cache holds it.
|
||||
fn seed_at(state: &ClientState, row: usize, col: usize) -> String {
|
||||
cell_at(state, row, col)
|
||||
.and_then(|cell| cell.edit_text.clone())
|
||||
.unwrap_or_default()
|
||||
/// What an editor awaiting its seed takes once the host projects the cell under
|
||||
/// the cursor: `None` until then, a relabel's blank included (improvise-7mro).
|
||||
pub(crate) fn projected_seed(state: &ClientState) -> Option<String> {
|
||||
let cell = cell_at_cursor(state).filter(|cell| cell.id.is_some())?;
|
||||
Some(cell.edit_text.clone().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The projected cell at an absolute grid position, `None` outside the window.
|
||||
fn cell_at(state: &ClientState, row: usize, col: usize) -> Option<&crate::CellDisplay> {
|
||||
let cursor = state.view.cursor();
|
||||
let r = row.checked_sub(cursor.row_offset)?;
|
||||
let c = col.checked_sub(cursor.col_offset)?;
|
||||
state.grid.cell(r, c)
|
||||
/// The edit text the host projected at an absolute grid position.
|
||||
fn seed_at(state: &ClientState, row: usize, col: usize) -> String {
|
||||
projected_at(state, row, col)
|
||||
.and_then(|cell| cell.edit_text.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `EnterEditAtCursor`: re-open the editor where the advance landed, unless `i`
|
||||
@@ -1160,19 +1173,48 @@ fn reopened_at(
|
||||
mode: AppMode,
|
||||
writes: bool,
|
||||
) -> Vec<Command> {
|
||||
match cell_at(state, row, col).and_then(|cell| cell.id.as_ref()) {
|
||||
Some(CellId::Aggregate(_)) => vec![Command::SetStatus(AGGREGATE_DECLINE.to_string())],
|
||||
Some(CellId::Derived { target }) => vec![Command::SetStatus(derived_decline(target))],
|
||||
Some(CellId::NonItem { category, item }) => {
|
||||
vec![Command::SetStatus(crate::non_item_refusal(category, item))]
|
||||
}
|
||||
Some(CellId::Record(_) | CellId::Create(_) | CellId::Coordinate) | None => match writes {
|
||||
true => awaiting_seed(mode),
|
||||
false => opened_on(state, seed_at(state, row, col), mode),
|
||||
},
|
||||
match (declined_at(state, row, col), writes) {
|
||||
(Some(refusal), _) => vec![Command::SetStatus(refusal)],
|
||||
(None, true) => awaiting_seed(mode),
|
||||
(None, false) if unprojected(state, row, col) => awaiting_seed(mode),
|
||||
(None, false) => opened_on(state, seed_at(state, row, col), mode),
|
||||
}
|
||||
}
|
||||
|
||||
/// Why `i` declines the cell the host projected at `(row, col)`. `None` for an
|
||||
/// editable cell, and for one not projected yet, which the fold settles later.
|
||||
pub(crate) fn declined_at(state: &ClientState, row: usize, col: usize) -> Option<String> {
|
||||
match projected_at(state, row, col)?.id.as_ref()? {
|
||||
CellId::Aggregate(_) => Some(AGGREGATE_DECLINE.to_string()),
|
||||
CellId::Derived { target } => Some(derived_decline(target)),
|
||||
CellId::NonItem { category, item } => Some(crate::non_item_refusal(category, item)),
|
||||
CellId::Record(_) | CellId::Create(_) | CellId::Coordinate => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cell the host projected at an absolute position, read against the frame's
|
||||
/// own origin: offsets the client has scrolled ahead of the cache cannot alias it.
|
||||
fn projected_at(state: &ClientState, row: usize, col: usize) -> Option<&crate::CellDisplay> {
|
||||
let r = row.checked_sub(state.grid.row_frame.first)?;
|
||||
let c = col.checked_sub(state.grid.col_frame.first)?;
|
||||
state.grid.cell(r, c)
|
||||
}
|
||||
|
||||
/// Whether `(row, col)` is a cell of the grid that the window the cache holds
|
||||
/// does not cover: unknown, never the cell at the same cache index (7mro).
|
||||
pub(crate) fn unprojected(state: &ClientState, row: usize, col: usize) -> bool {
|
||||
let GridExtent { rows, cols } = state.grid.extent;
|
||||
let outside = |frame: &crate::AxisFrame, at: usize| {
|
||||
!(frame.first..frame.first + frame.len()).contains(&at)
|
||||
};
|
||||
row < rows
|
||||
&& col < cols
|
||||
&& (outside(&state.grid.row_frame, row) || outside(&state.grid.col_frame, col))
|
||||
}
|
||||
|
||||
/// What a cell op says while its cell is still unknown to this client.
|
||||
const NOT_PROJECTED: &str = "waiting for the host to show this cell";
|
||||
|
||||
/// What the host says of an aggregate, the fold of a collapsed group included.
|
||||
const AGGREGATE_DECLINE: &str =
|
||||
"Cannot write to an aggregate — it combines several records, so there is no one cell to write";
|
||||
@@ -1189,8 +1231,8 @@ pub struct OpenedEditor {
|
||||
pub seed: String,
|
||||
/// Whether the user has edited the buffer since it opened (improvise-2054).
|
||||
pub touched: bool,
|
||||
/// The seed is still to come from the host's answer (improvise-3uzg). Not
|
||||
/// `serde(default)`: `false` is a claim — `protocol.md` §3, improvise-mgis.
|
||||
/// The seed is still to come, from a write's answer or an unprojected cell
|
||||
/// (3uzg/7mro). Not `serde(default)`: `false` is a claim (`protocol.md` §3, mgis).
|
||||
pub awaiting: bool,
|
||||
}
|
||||
|
||||
@@ -3583,7 +3625,7 @@ mod tests {
|
||||
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))),
|
||||
Projection::Grid(GridUpdate::Cells(vec![
|
||||
((0, 0), first),
|
||||
((1, 0), CellDisplay::number("900")),
|
||||
((1, 0), CellDisplay::number("900").with_id(record_cell())),
|
||||
])),
|
||||
Projection::Grid(GridUpdate::Extent(GridExtent { rows: 2, cols: 1 })),
|
||||
Projection::Basis(published()),
|
||||
@@ -3894,7 +3936,7 @@ mod tests {
|
||||
Projection::Grid(GridUpdate::Cells(vec![
|
||||
((0, 0), CellDisplay::number("900")),
|
||||
((1, 0), cell),
|
||||
((2, 0), CellDisplay::number("800")),
|
||||
((2, 0), CellDisplay::number("800").with_id(record_cell())),
|
||||
])),
|
||||
Projection::Grid(GridUpdate::Extent(GridExtent { rows: 3, cols: 1 })),
|
||||
Projection::Basis(published()),
|
||||
@@ -4524,6 +4566,25 @@ mod tests {
|
||||
`KeyOutcome::Request` has nowhere to put one"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-7mro: an empty grid has no cell to wait for, so nothing a cell op
|
||||
/// says there is a wait. See `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_cursor_on_an_empty_grid_waits_for_nothing() {
|
||||
let mut state = in_mode(AppMode::Normal);
|
||||
state.window = (2, 2);
|
||||
for key in ['i', 'x'] {
|
||||
let said = match resolve(&state, ev(KeyCode::Char(key))) {
|
||||
KeyOutcome::Local(cmds) => cmds,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
assert!(
|
||||
!said.contains(&Command::SetStatus(NOT_PROJECTED.to_string()))
|
||||
&& !said.contains(&Command::SetOpenedEditor(Some(OpenedEditor::awaiting()))),
|
||||
"{key}: waits on a cell no grid has: {said:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -2277,6 +2277,262 @@ fn fold_host() -> App {
|
||||
app
|
||||
}
|
||||
|
||||
/// Measures on Row (`Profit` both stored and a formula's), months on Column,
|
||||
/// so a `Down` from the bottom row wraps to the top of the next column.
|
||||
fn wrapping_host() -> App {
|
||||
let mut wb = Workbook::new("Test");
|
||||
wb.add_category("Month").unwrap();
|
||||
for month in ["Jan", "Feb"] {
|
||||
wb.add_item("Month", month).unwrap();
|
||||
}
|
||||
for measure in ["Revenue", "Profit", "Cost"] {
|
||||
wb.add_item("_Measure", measure).unwrap();
|
||||
}
|
||||
wb.model.add_formula(
|
||||
improvise_command::formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(),
|
||||
);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Row);
|
||||
wb.active_view_mut().set_axis("Month", Axis::Column);
|
||||
for month in ["Jan", "Feb"] {
|
||||
for (measure, n) in [("Revenue", 10.0), ("Cost", 4.0)] {
|
||||
let key = improvise_command::model::cell::CellKey::new(vec![
|
||||
("Month".to_string(), month.to_string()),
|
||||
("_Measure".to_string(), measure.to_string()),
|
||||
]);
|
||||
wb.model
|
||||
.set_cell(key, improvise_protocol::CellValue::Number(n));
|
||||
}
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
app
|
||||
}
|
||||
|
||||
/// improvise-y5eb: the settle must not read the cache through offsets it has not
|
||||
/// caught up with. See `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_wrapping_advance_settles_on_the_cell_it_lands_on_not_a_stale_one() {
|
||||
let mut tui = wrapping_host();
|
||||
tui.cursor_mut().selected = (2, 0);
|
||||
tui.cursor_mut().row_offset = 1;
|
||||
for k in ["i", "Enter"] {
|
||||
reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap());
|
||||
}
|
||||
assert_eq!(
|
||||
tui.view_state.cursor().selected,
|
||||
(0, 1),
|
||||
"setup: Enter wrapped"
|
||||
);
|
||||
assert!(
|
||||
tui.view_state.mode.is_editing(),
|
||||
"setup: Revenue/Feb is editable"
|
||||
);
|
||||
|
||||
let mut host = improvise_command::ui::host::Host::new(wrapping_host());
|
||||
let (session, welcome) = host.connect(2, 6);
|
||||
let mut client = ClientState {
|
||||
window: (2, 6),
|
||||
..ClientState::default()
|
||||
};
|
||||
deliver(vec![welcome], session, &mut client);
|
||||
let placed = vec![
|
||||
improvise_protocol::Command::SetSelected { row: 2, col: 0 },
|
||||
improvise_protocol::Command::SetRowOffset(1),
|
||||
];
|
||||
client.fold_outgoing(&placed);
|
||||
deliver(
|
||||
host.handle(session, ClientFrame::Commands(placed)),
|
||||
session,
|
||||
&mut client,
|
||||
);
|
||||
for k in ["i", "Enter"] {
|
||||
let event = BrowserKey::new(k).to_key_event().unwrap();
|
||||
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
||||
panic!("{k}: the client must answer this key locally");
|
||||
};
|
||||
client.fold_outgoing(&cmds);
|
||||
deliver(
|
||||
host.handle(session, ClientFrame::Commands(cmds)),
|
||||
session,
|
||||
&mut client,
|
||||
);
|
||||
}
|
||||
assert_eq!(client.view.cursor().selected, (0, 1));
|
||||
assert_eq!(
|
||||
format!("{:?}", client.view.mode),
|
||||
format!("{:?}", tui.view_state.mode),
|
||||
"the client settled on a cell not under its cursor: {:?}",
|
||||
client.view.status_msg
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-ik6l: Enter on a typed-into editor over a cell `i` declines says
|
||||
/// so on both ends. See `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn enter_on_an_editor_over_a_declined_cell_declines_out_loud_on_both_ends() {
|
||||
type Case = (fn() -> App, (usize, usize), &'static str);
|
||||
let cases: [Case; 4] = [
|
||||
(fold_host, (2, 1), "Enter"),
|
||||
(yank_host, (1, 3), "Enter"),
|
||||
(fold_host, (2, 1), "Tab"),
|
||||
(yank_host, (1, 3), "Tab"),
|
||||
];
|
||||
for (make, at, key) in cases {
|
||||
let mut app = make();
|
||||
app.cursor_mut().selected = at;
|
||||
app.view_state.mode = AppMode::editing();
|
||||
app.view_state.buffers.insert("edit".into(), "7".into());
|
||||
app.view_state.opened_editor = Some(improvise_protocol::OpenedEditor {
|
||||
seed: String::new(),
|
||||
touched: true,
|
||||
awaiting: false,
|
||||
});
|
||||
let before = document_text(&app);
|
||||
let mut client = client_ready_to_edit(&app, (6, 6));
|
||||
let event = BrowserKey::new(key).to_key_event().unwrap();
|
||||
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
||||
panic!("{at:?}: {key} went upstream, where nothing answers it");
|
||||
};
|
||||
for cmd in &cmds {
|
||||
client.fold(cmd);
|
||||
}
|
||||
reduce_key(&mut app, event);
|
||||
assert_eq!(document_text(&app), before, "setup: the TUI wrote nothing");
|
||||
assert!(
|
||||
cmds.iter()
|
||||
.all(|c| !matches!(c, improvise_protocol::Command::Model { .. })),
|
||||
"{at:?}: the browser composed a write"
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{:?}", client.view.mode),
|
||||
format!("{:?}", app.view_state.mode),
|
||||
"{at:?}: the two ends disagree about the mode"
|
||||
);
|
||||
assert_eq!(client.view.buffers, app.view_state.buffers, "{at:?}");
|
||||
assert!(
|
||||
!app.view_state.status_msg.is_empty() && !client.view.status_msg.is_empty(),
|
||||
"{at:?}: a silent decline: TUI {:?}, browser {:?}",
|
||||
app.view_state.status_msg,
|
||||
client.view.status_msg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-5m9z's other half: what the user typed before the host answered
|
||||
/// is not thrown away. See `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn typing_before_the_host_answers_keeps_the_editor_past_the_window() {
|
||||
let mut host = improvise_command::ui::host::Host::new(fold_host());
|
||||
let (session, welcome) = host.connect(2, 6);
|
||||
let mut client = ClientState {
|
||||
window: (2, 6),
|
||||
..ClientState::default()
|
||||
};
|
||||
deliver(vec![welcome], session, &mut client);
|
||||
let placed = vec![improvise_protocol::Command::SetSelected { row: 1, col: 1 }];
|
||||
client.fold_outgoing(&placed);
|
||||
deliver(
|
||||
host.handle(session, ClientFrame::Commands(placed)),
|
||||
session,
|
||||
&mut client,
|
||||
);
|
||||
let mut held = Vec::new();
|
||||
for k in ["i", "Enter", "7"] {
|
||||
let event = BrowserKey::new(k).to_key_event().unwrap();
|
||||
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
||||
panic!("{k}: the client must answer this key locally");
|
||||
};
|
||||
client.fold_outgoing(&cmds);
|
||||
held.push(host.handle(session, ClientFrame::Commands(cmds)));
|
||||
}
|
||||
assert_eq!(client.view.cursor().selected, (2, 1), "setup: on the fold");
|
||||
for reply in held {
|
||||
deliver(reply, session, &mut client);
|
||||
}
|
||||
assert!(
|
||||
client.view.mode.is_editing(),
|
||||
"the typed 7 was thrown away: {:?}",
|
||||
client.view.mode
|
||||
);
|
||||
assert_eq!(
|
||||
client.view.buffers.get("edit").map(String::as_str),
|
||||
Some("7")
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-5m9z: an advance past the client's window lands on a cell it holds
|
||||
/// no id for, and must settle as the TUI does. See `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn advancing_the_editor_past_the_window_settles_where_the_tui_does() {
|
||||
let expanded = || {
|
||||
let mut app = fold_host();
|
||||
let view = app.model_state.workbook.active_view_mut();
|
||||
view.toggle_group_collapse("Month", "Q2");
|
||||
app.rebuild_layout();
|
||||
app
|
||||
};
|
||||
type Case = (
|
||||
fn() -> App,
|
||||
(usize, usize),
|
||||
(usize, usize),
|
||||
&'static str,
|
||||
bool,
|
||||
);
|
||||
let cases: [Case; 3] = [
|
||||
(fold_host, (2, 6), (1, 1), "Enter", false),
|
||||
(yank_host, (6, 3), (1, 2), "Tab", false),
|
||||
(expanded, (2, 6), (1, 1), "Enter", true),
|
||||
];
|
||||
for (make, window, at, key, editing) in cases {
|
||||
let mut tui = make();
|
||||
tui.cursor_mut().selected = at;
|
||||
for k in ["i", key] {
|
||||
reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap());
|
||||
}
|
||||
assert_eq!(tui.view_state.mode.is_editing(), editing, "setup: the TUI");
|
||||
|
||||
let mut host = improvise_command::ui::host::Host::new(make());
|
||||
let (session, welcome) = host.connect(window.0, window.1);
|
||||
let mut client = ClientState {
|
||||
window,
|
||||
..ClientState::default()
|
||||
};
|
||||
deliver(vec![welcome], session, &mut client);
|
||||
let placed = vec![improvise_protocol::Command::SetSelected {
|
||||
row: at.0,
|
||||
col: at.1,
|
||||
}];
|
||||
client.fold_outgoing(&placed);
|
||||
deliver(
|
||||
host.handle(session, ClientFrame::Commands(placed)),
|
||||
session,
|
||||
&mut client,
|
||||
);
|
||||
for k in ["i", key] {
|
||||
let event = BrowserKey::new(k).to_key_event().unwrap();
|
||||
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
||||
panic!("{k}: the client must answer this key locally");
|
||||
};
|
||||
client.fold_outgoing(&cmds);
|
||||
deliver(
|
||||
host.handle(session, ClientFrame::Commands(cmds)),
|
||||
session,
|
||||
&mut client,
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
client.view.cursor().selected,
|
||||
tui.view_state.cursor().selected,
|
||||
"{key}: the advance must land past the window on both ends"
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{:?}", client.view.mode),
|
||||
format!("{:?}", tui.view_state.mode),
|
||||
"{key} from {at:?} in a {window:?} window: the client and the TUI disagree"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-enz6: a commit that advances onto a cell `i` declines re-opens no
|
||||
/// editor there, on either end. See `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
@@ -3367,3 +3623,285 @@ fn a_move_sent_before_a_pruning_commit_is_answered_writes_where_the_tui_writes()
|
||||
"the browser wrote a different record from the one the TUI wrote"
|
||||
);
|
||||
}
|
||||
|
||||
/// One scroll-then-edit case: the window, where the cursor starts, and the keys.
|
||||
type Pipelined = (
|
||||
&'static str,
|
||||
(usize, usize),
|
||||
((usize, usize), usize, usize),
|
||||
&'static [&'static str],
|
||||
);
|
||||
|
||||
/// When the host's replies reach the client.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum Replies {
|
||||
/// Each before the next key.
|
||||
Delivered,
|
||||
/// All held to the end, so every key reads the first window.
|
||||
Held,
|
||||
/// Held until the last two keys, which then run delivered.
|
||||
HeldThenTyped,
|
||||
}
|
||||
|
||||
/// `wrapping_host`'s cells that differ from how it was built, as `Month/Measure`.
|
||||
fn written_cells(app: &App) -> Vec<String> {
|
||||
let model = &app.model_state.workbook.model;
|
||||
let mut written = Vec::new();
|
||||
for month in ["Jan", "Feb"] {
|
||||
for (measure, built) in [
|
||||
("Revenue", Some(10.0)),
|
||||
("Profit", None),
|
||||
("Cost", Some(4.0)),
|
||||
] {
|
||||
let key = improvise_command::model::cell::CellKey::new(vec![
|
||||
("Month".to_string(), month.to_string()),
|
||||
("_Measure".to_string(), measure.to_string()),
|
||||
]);
|
||||
let now = model.get_cell(&key).cloned();
|
||||
if now != built.map(improvise_protocol::CellValue::Number) {
|
||||
written.push(format!("{month}/{measure}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
/// One key on the client, its frame sent to the host; the reply comes back for
|
||||
/// the caller to deliver or hold. An upstream key reaches a host that answers nothing.
|
||||
fn sent_or_upstream(
|
||||
host: &mut improvise_command::ui::host::Host,
|
||||
session: improvise_command::ui::project::SessionId,
|
||||
client: &mut ClientState,
|
||||
key: &str,
|
||||
) -> Vec<improvise_command::ui::host::Addressed> {
|
||||
match resolve(client, BrowserKey::new(key).to_key_event().unwrap()) {
|
||||
KeyOutcome::Local(cmds) => {
|
||||
client.fold_outgoing(&cmds);
|
||||
host.handle(session, ClientFrame::Commands(cmds))
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A host over `make()` and a client on it, the cursor placed and answered.
|
||||
fn placed_client(
|
||||
make: fn() -> App,
|
||||
window: (usize, usize),
|
||||
(at, row_offset, col_offset): ((usize, usize), usize, usize),
|
||||
) -> (
|
||||
improvise_command::ui::host::Host,
|
||||
improvise_command::ui::project::SessionId,
|
||||
ClientState,
|
||||
) {
|
||||
let mut host = improvise_command::ui::host::Host::new(make());
|
||||
let (session, welcome) = host.connect(window.0, window.1);
|
||||
let mut client = ClientState {
|
||||
window,
|
||||
..ClientState::default()
|
||||
};
|
||||
deliver(vec![welcome], session, &mut client);
|
||||
let placed = vec![
|
||||
improvise_protocol::Command::SetSelected {
|
||||
row: at.0,
|
||||
col: at.1,
|
||||
},
|
||||
improvise_protocol::Command::SetRowOffset(row_offset),
|
||||
improvise_protocol::Command::SetColOffset(col_offset),
|
||||
];
|
||||
client.fold_outgoing(&placed);
|
||||
deliver(
|
||||
host.handle(session, ClientFrame::Commands(placed)),
|
||||
session,
|
||||
&mut client,
|
||||
);
|
||||
(host, session, client)
|
||||
}
|
||||
|
||||
/// improvise-7mro: the offset-aliasing matrix, with the host's replies delivered,
|
||||
/// held, and held until the typing. See `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn keys_sent_before_a_scroll_is_answered_agree_with_the_tui() {
|
||||
let cases: [Pipelined; 7] = [
|
||||
(
|
||||
"wrap",
|
||||
(2, 6),
|
||||
((2, 0), 1, 0),
|
||||
&["i", "Enter", "8", "Enter"],
|
||||
),
|
||||
(
|
||||
"horizontal wrap",
|
||||
(2, 1),
|
||||
((2, 0), 1, 0),
|
||||
&["i", "Enter", "8", "Enter"],
|
||||
),
|
||||
("G", (2, 6), ((0, 0), 0, 0), &["G", "i", "7", "Enter"]),
|
||||
("$", (2, 1), ((0, 0), 0, 0), &["$", "i", "7", "Enter"]),
|
||||
("gg", (2, 6), ((2, 0), 1, 0), &["g", "g", "i", "7", "Enter"]),
|
||||
("$ x", (2, 1), ((0, 0), 0, 0), &["$", "x"]),
|
||||
("gg p", (2, 6), ((2, 0), 1, 0), &["y", "y", "g", "g", "p"]),
|
||||
];
|
||||
for (name, window, start, keys) in cases {
|
||||
let mut tui = wrapping_host();
|
||||
tui.cursor_mut().selected = start.0;
|
||||
for &k in keys {
|
||||
reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap());
|
||||
}
|
||||
assert_eq!(
|
||||
written_cells(&tui).len(),
|
||||
1,
|
||||
"setup: {name}: the TUI wrote one cell"
|
||||
);
|
||||
for replies in [Replies::Delivered, Replies::Held, Replies::HeldThenTyped] {
|
||||
let what = format!("{name} ({keys:?}, replies {replies:?})");
|
||||
let (mut host, session, mut client) = placed_client(wrapping_host, window, start);
|
||||
let typed_from = match replies {
|
||||
Replies::Delivered => 0,
|
||||
Replies::Held => keys.len(),
|
||||
Replies::HeldThenTyped => keys.len().saturating_sub(2),
|
||||
};
|
||||
let mut held = Vec::new();
|
||||
for (i, &k) in keys.iter().enumerate() {
|
||||
if i == typed_from {
|
||||
deliver(std::mem::take(&mut held), session, &mut client);
|
||||
}
|
||||
held.extend(sent_or_upstream(&mut host, session, &mut client, k));
|
||||
if i >= typed_from {
|
||||
deliver(std::mem::take(&mut held), session, &mut client);
|
||||
}
|
||||
}
|
||||
if replies == Replies::Held {
|
||||
assert_eq!(
|
||||
written_cells(host.app()),
|
||||
Vec::<String>::new(),
|
||||
"{what}: a write was composed before its target was projected"
|
||||
);
|
||||
assert!(
|
||||
client.view.mode.is_editing() == keys.contains(&"i")
|
||||
&& !client.view.status_msg.is_empty(),
|
||||
"{what}: the last key must wait out loud, any editor open: {:?}, {:?}",
|
||||
client.view.mode,
|
||||
client.view.status_msg
|
||||
);
|
||||
deliver(std::mem::take(&mut held), session, &mut client);
|
||||
let retry = keys.last().expect("a key");
|
||||
let reply = sent_or_upstream(&mut host, session, &mut client, retry);
|
||||
deliver(reply, session, &mut client);
|
||||
}
|
||||
assert_eq!(
|
||||
written_cells(host.app()),
|
||||
written_cells(&tui),
|
||||
"{what}: the browser wrote a different cell from the TUI"
|
||||
);
|
||||
if replies != Replies::Held {
|
||||
assert_eq!(document_text(host.app()), document_text(&tui), "{what}");
|
||||
assert_eq!(
|
||||
client.view.buffers.get("edit"),
|
||||
tui.view_state.buffers.get("edit"),
|
||||
"{what}: the editor's buffer"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
client.view.cursor().selected,
|
||||
tui.view_state.cursor().selected,
|
||||
"{what}: the cursor"
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{:?}", client.view.mode),
|
||||
format!("{:?}", tui.view_state.mode),
|
||||
"{what}: the mode; browser status {:?}",
|
||||
client.view.status_msg
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Four regions by two measures, every cell stored and no two alike, so a cell
|
||||
/// read at the wrong row or column is a different value.
|
||||
fn four_by_two_host() -> App {
|
||||
let mut wb = Workbook::new("Test");
|
||||
wb.add_category("Region").unwrap();
|
||||
for region in ["R0", "R1", "R2", "R3"] {
|
||||
wb.add_item("Region", region).unwrap();
|
||||
}
|
||||
for measure in ["A", "B"] {
|
||||
wb.add_item("_Measure", measure).unwrap();
|
||||
}
|
||||
wb.active_view_mut().set_axis("Region", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
for (r, region) in ["R0", "R1", "R2", "R3"].into_iter().enumerate() {
|
||||
for (base, measure) in [(10.0, "A"), (20.0, "B")] {
|
||||
let key = improvise_command::model::cell::CellKey::new(vec![
|
||||
("Region".to_string(), region.to_string()),
|
||||
("_Measure".to_string(), measure.to_string()),
|
||||
]);
|
||||
wb.model
|
||||
.set_cell(key, improvise_protocol::CellValue::Number(base + r as f64));
|
||||
}
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
app
|
||||
}
|
||||
|
||||
/// improvise-7mro: a cell inside the held window, read while the client's offsets
|
||||
/// differ from the frame's origin. See `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_held_cell_is_read_at_its_position_and_not_its_cache_index() {
|
||||
for keys in [
|
||||
&["k", "k", "j", "i", "7", "Enter"][..],
|
||||
&["k", "k", "j", "i", "Tab"][..],
|
||||
] {
|
||||
let mut tui = four_by_two_host();
|
||||
tui.cursor_mut().selected = (3, 0);
|
||||
for &k in keys {
|
||||
reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap());
|
||||
}
|
||||
let (mut host, session, mut client) =
|
||||
placed_client(four_by_two_host, (2, 6), ((3, 0), 2, 0));
|
||||
let mut held = Vec::new();
|
||||
for (i, &k) in keys.iter().enumerate() {
|
||||
if i == 3 {
|
||||
let cursor = client.view.cursor();
|
||||
assert_eq!(
|
||||
(
|
||||
cursor.selected,
|
||||
cursor.row_offset,
|
||||
client.grid.row_frame.first
|
||||
),
|
||||
((2, 0), 1, 2),
|
||||
"setup: inside the held window, the offsets not its origin"
|
||||
);
|
||||
}
|
||||
held.extend(sent_or_upstream(&mut host, session, &mut client, k));
|
||||
}
|
||||
assert_eq!(document_text(host.app()), document_text(&tui), "{keys:?}");
|
||||
assert_eq!(
|
||||
client.view.cursor().selected,
|
||||
tui.view_state.cursor().selected
|
||||
);
|
||||
// An editor reopened after a write awaits the host's seed (p6v6): read it
|
||||
// only once the held replies arrive; an unawaited one is read now as well.
|
||||
let awaiting = client
|
||||
.view
|
||||
.opened_editor
|
||||
.as_ref()
|
||||
.is_some_and(|o| o.awaiting);
|
||||
if !awaiting {
|
||||
assert_editor_agrees(&client, &tui, keys, "held");
|
||||
}
|
||||
deliver(held, session, &mut client);
|
||||
assert_editor_agrees(&client, &tui, keys, "delivered");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_editor_agrees(client: &ClientState, tui: &App, keys: &[&str], when: &str) {
|
||||
assert_eq!(
|
||||
client.view.buffers.get("edit"),
|
||||
tui.view_state.buffers.get("edit"),
|
||||
"{keys:?}, {when}"
|
||||
);
|
||||
assert_eq!(
|
||||
client.view.opened_editor, tui.view_state.opened_editor,
|
||||
"{keys:?}, {when}: the editor opened on another cell's seed"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user