Merge gh/main (220abc61) into fix/gk51-non-item-record-projection
resolve.rs, `reopened_at`: #227/#272 made the editable arm a
`match writes { true => awaiting_seed, false => opened_on(state, ..) }`.
Kept that new shape and re-applied this branch's declining
`NonItem => SetStatus(non_item_refusal(..))` arm ahead of it, so a
non-item cell still reopens no editor whether or not the frame writes.
`uneditable`'s NonItem arm merged cleanly and is unchanged. Both guard
tests pass; the rival (NonItem folded into the editable arm) fails
an_advance_in_the_browser_re_opens_no_editor_on_a_non_item_cell.
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:
@@ -7,7 +7,7 @@ use crate::ui::effect::{self, Effect};
|
||||
use crate::view::RecordCol;
|
||||
use improvise_protocol::{OpenedEditor, untouched};
|
||||
|
||||
use super::core::{Cmd, CmdContext, read_buffer};
|
||||
use super::core::{Cmd, CmdContext, read_buffer, write_buffer};
|
||||
use super::grid::AddRecordRow;
|
||||
use super::navigation::{CursorState, EnterAdvance, viewport_effects};
|
||||
|
||||
@@ -830,6 +830,72 @@ mod tests {
|
||||
"the reopened editor did not capture the cell it landed on"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every cell of the 2×2 fixture, by its key.
|
||||
fn every_cell(app: &crate::ui::app::App) -> Vec<Option<CellValue>> {
|
||||
use crate::model::cell::CellKey;
|
||||
let mut cells = Vec::new();
|
||||
for region in ["North", "West"] {
|
||||
for measure in ["Cost", "Revenue"] {
|
||||
let key = CellKey::new(vec![
|
||||
("Region".into(), region.into()),
|
||||
("_Measure".into(), measure.into()),
|
||||
]);
|
||||
cells.push(app.model_state.workbook.model.get_cell(&key).cloned());
|
||||
}
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
/// BUG (improvise-fy9o) — see `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn enter_enter_leaves_every_cell_unchanged() {
|
||||
use improvise_protocol::key::KeyCode;
|
||||
for (records, start) in [(false, (1, 0)), (true, (1, 2))] {
|
||||
let mut app = grid_holding(CellValue::Number(4.0), records);
|
||||
if records {
|
||||
app.view_state.mode = AppMode::RecordsNormal;
|
||||
}
|
||||
app.cursor_mut().selected = start;
|
||||
let before = every_cell(&app);
|
||||
press(&mut app, KeyCode::Char('i'));
|
||||
press(&mut app, KeyCode::Enter);
|
||||
let reopened = app.view_state.buffers.get("edit").cloned();
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(
|
||||
every_cell(&app),
|
||||
before,
|
||||
"records={records}: i, Enter, Enter changed a cell (the reopened editor held {reopened:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The reopened editor shows the cell it landed on, so typing extends it.
|
||||
/// BUG (improvise-fy9o) — see `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn typing_after_the_reopen_still_writes() {
|
||||
use crate::model::cell::CellKey;
|
||||
use improvise_protocol::key::KeyCode;
|
||||
let mut app = grid_holding(CellValue::Number(4.0), false);
|
||||
app.cursor_mut().selected = (1, 0);
|
||||
press(&mut app, KeyCode::Char('i'));
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(
|
||||
app.view_state.buffers.get("edit").map(String::as_str),
|
||||
Some("2"),
|
||||
"the editor reopened on (North, Revenue) is not seeded with its value"
|
||||
);
|
||||
press(&mut app, KeyCode::Char('5'));
|
||||
press(&mut app, KeyCode::Enter);
|
||||
let landed = CellKey::new(vec![
|
||||
("Region".into(), "North".into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
]);
|
||||
assert_eq!(
|
||||
app.model_state.workbook.model.get_cell(&landed),
|
||||
Some(&CellValue::Number(25.0))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commit commands (mode-specific buffer consumers) ────────────────────────
|
||||
@@ -976,6 +1042,11 @@ impl Cmd for CommitAndAdvance {
|
||||
// Drop to normal **before** the commit, which can abort: tidy-up goes where
|
||||
// an abort cannot reach it (improvise-2zq, gotcha 21).
|
||||
effects.push(effect::change_mode(exit_mode_for(&self.edit_mode)));
|
||||
// This command owns its buffer's clear, at the head, so the re-entry
|
||||
// below re-seeds after it rather than being blanked (improvise-fy9o).
|
||||
if let Some(mb) = self.edit_mode.minibuffer() {
|
||||
effects.push(write_buffer(&mb.buffer_key, String::new()));
|
||||
}
|
||||
// An untouched editor writes nothing; everything around the write still runs.
|
||||
let writes = !untouched(self.opened.as_ref(), &self.value);
|
||||
if writes {
|
||||
|
||||
@@ -753,6 +753,22 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The buffer `append-char` is bound to in `mode`'s keymap.
|
||||
/// The effects `name` builds in `mode` with `buffer` holding text, as Debug.
|
||||
fn clears_itself(mode: &AppMode, name: &str, args: &[String], buffer: &str) -> String {
|
||||
use crate::command::cmd::test_helpers::*;
|
||||
let wb = two_cat_model();
|
||||
let layout = make_layout(&wb);
|
||||
let reg = make_registry();
|
||||
let bufs: HashMap<String, String> = [(buffer.to_string(), "typed".to_string())].into();
|
||||
let mut ctx = make_ctx(&wb, &layout, ®);
|
||||
ctx.mode = mode;
|
||||
ctx.buffers = &bufs;
|
||||
let cmd = reg
|
||||
.interactive(name, args, &ctx)
|
||||
.unwrap_or_else(|e| panic!("{name} {args:?} does not build: {e}"));
|
||||
effects_debug(&cmd.execute(&ctx))
|
||||
}
|
||||
|
||||
fn append_target(ks: &KeymapSet, mode: &AppMode) -> String {
|
||||
let key = ModeKey::from_app_mode(mode, false).expect("a text-entry mode key");
|
||||
let km = ks.maps().get(&key).expect("every mode has a keymap");
|
||||
@@ -827,15 +843,26 @@ mod tests {
|
||||
let km = ks.maps().get(&mode).unwrap();
|
||||
|
||||
for key in exits {
|
||||
let Some(Binding::Sequence(steps)) = km.lookup(*key, none) else {
|
||||
panic!("{mode:?}: {key:?} should be a commit/exit sequence");
|
||||
};
|
||||
assert!(
|
||||
steps
|
||||
.iter()
|
||||
.any(|(n, a)| *n == "clear-buffer" && a.as_slice() == [buffer.to_string()]),
|
||||
"{mode:?}: {key:?} must clear \"{buffer}\", got {steps:?}"
|
||||
);
|
||||
match km.lookup(*key, none) {
|
||||
Some(Binding::Sequence(steps)) => {
|
||||
assert!(
|
||||
steps.iter().any(|(n, a)| *n == "clear-buffer"
|
||||
&& a.as_slice() == [buffer.to_string()]),
|
||||
"{mode:?}: {key:?} must clear \"{buffer}\", got {steps:?}"
|
||||
)
|
||||
}
|
||||
// A reopening commit owns its clear (improvise-fy9o): ask the command.
|
||||
Some(Binding::Cmd { name, args }) => {
|
||||
let dbg = clears_itself(&app_mode, name, args, &buffer);
|
||||
assert!(
|
||||
dbg.contains(&format!(
|
||||
"SetBuffer {{ name: \"{buffer}\", value: \"\" }}"
|
||||
)),
|
||||
"{mode:?}: {key:?} runs {name} alone, which must clear \"{buffer}\": {dbg}"
|
||||
);
|
||||
}
|
||||
other => panic!("{mode:?}: {key:?} should commit or exit, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,8 @@ impl ClientState {
|
||||
}
|
||||
}
|
||||
self.settle_sent();
|
||||
self.settle_editor();
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
/// The host applied **none** of the oldest awaited frame (ADR 001 clause
|
||||
@@ -175,6 +177,23 @@ impl ClientState {
|
||||
self.view.buffers.insert(key, seed.clone());
|
||||
self.view.opened_editor = Some(crate::OpenedEditor::on(seed));
|
||||
}
|
||||
self.settle_awaited_seed();
|
||||
}
|
||||
|
||||
/// 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).
|
||||
fn settle_awaited_seed(&mut self) {
|
||||
let waiting = self.view.mode.is_editing()
|
||||
&& self
|
||||
.view
|
||||
.opened_editor
|
||||
.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)) {
|
||||
self.view.buffers.insert("edit".to_string(), seed.clone());
|
||||
self.view.opened_editor = Some(crate::OpenedEditor::on(seed));
|
||||
}
|
||||
}
|
||||
|
||||
/// The widget dispatch: a projection names the widget it is addressed to.
|
||||
@@ -398,6 +417,102 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A write, then the editor its advance reopens with the seed still to come.
|
||||
fn write_then_awaiting_editor() -> Vec<Command> {
|
||||
let mut frame = commit_like("written");
|
||||
frame.extend([
|
||||
Command::SetBuffer {
|
||||
name: "edit".into(),
|
||||
value: String::new(),
|
||||
},
|
||||
Command::SetOpenedEditor(Some(crate::OpenedEditor::awaiting())),
|
||||
Command::ChangeMode(crate::AppMode::editing()),
|
||||
]);
|
||||
frame
|
||||
}
|
||||
|
||||
/// One row, one column, the cell's stored text `edit_text`.
|
||||
fn answered_with(state: &mut ClientState, edit_text: &str) {
|
||||
let mut cell = CellDisplay::number(edit_text);
|
||||
cell.edit_text = Some(edit_text.into());
|
||||
for cmd in [
|
||||
GridUpdate::RowFrame(AxisFrame::flat(["North"])),
|
||||
GridUpdate::ColFrame(AxisFrame::flat(["Revenue"])),
|
||||
GridUpdate::Cells(vec![((0, 0), cell)]),
|
||||
] {
|
||||
state.fold(&Command::Project(Projection::Grid(cmd)));
|
||||
}
|
||||
}
|
||||
|
||||
/// BUG (improvise-3uzg) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn an_awaited_seed_waits_for_the_answer_and_never_overwrites_typing() {
|
||||
let mut state = ClientState::default();
|
||||
answered_with(&mut state, "2");
|
||||
state.fold_outgoing(&write_then_awaiting_editor());
|
||||
assert_eq!(state.view.buffers.get("edit").map(String::as_str), Some(""));
|
||||
answered_with(&mut state, "3");
|
||||
assert_eq!(
|
||||
state.view.buffers.get("edit").map(String::as_str),
|
||||
Some(""),
|
||||
"seeded while the write was still unanswered"
|
||||
);
|
||||
state.accepted();
|
||||
assert_eq!(
|
||||
state.view.buffers.get("edit").map(String::as_str),
|
||||
Some("3"),
|
||||
"the answered editor must open on the host's grid"
|
||||
);
|
||||
assert_eq!(
|
||||
state.view.opened_editor,
|
||||
Some(crate::OpenedEditor::on("3".into()))
|
||||
);
|
||||
|
||||
let mut typed_ahead = ClientState::default();
|
||||
answered_with(&mut typed_ahead, "2");
|
||||
typed_ahead.fold_outgoing(&write_then_awaiting_editor());
|
||||
typed_ahead.fold_outgoing(&[
|
||||
Command::SetBuffer {
|
||||
name: "edit".into(),
|
||||
value: "5".into(),
|
||||
},
|
||||
Command::SetOpenedEditor(crate::resolve::edited(
|
||||
typed_ahead.view.opened_editor.as_ref(),
|
||||
"edit",
|
||||
)),
|
||||
]);
|
||||
answered_with(&mut typed_ahead, "3");
|
||||
typed_ahead.accepted();
|
||||
assert_eq!(
|
||||
typed_ahead.view.buffers.get("edit").map(String::as_str),
|
||||
Some("5"),
|
||||
"a seed arriving after the user typed must not overwrite the typing"
|
||||
);
|
||||
}
|
||||
|
||||
/// BUG (improvise-4cb5) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn an_awaited_seed_arriving_after_escape_leaves_the_buffer_empty() {
|
||||
let mut state = ClientState::default();
|
||||
answered_with(&mut state, "2");
|
||||
state.fold_outgoing(&write_then_awaiting_editor());
|
||||
state.fold_outgoing(&[
|
||||
Command::SetBuffer {
|
||||
name: "edit".into(),
|
||||
value: String::new(),
|
||||
},
|
||||
Command::ChangeMode(crate::AppMode::Normal),
|
||||
]);
|
||||
answered_with(&mut state, "3");
|
||||
state.accepted();
|
||||
assert_eq!(state.view.mode, crate::AppMode::Normal);
|
||||
assert_eq!(
|
||||
state.view.buffers.get("edit").map(String::as_str),
|
||||
Some(""),
|
||||
"the late seed was written into the buffer of an editor Esc had closed"
|
||||
);
|
||||
}
|
||||
|
||||
/// A frame the host does not answer must not consume an answer, or the
|
||||
/// queue addresses the wrong frame. Evidence file.
|
||||
#[test]
|
||||
|
||||
@@ -347,6 +347,13 @@ impl TextEntry {
|
||||
self
|
||||
}
|
||||
|
||||
/// Bind `key` to a commit that clears the buffer **itself**, first, because
|
||||
/// it reopens an editor a trailing clear would blank (improvise-fy9o).
|
||||
pub fn commit_reopening(mut self, key: KeyCode, cmd: &'static str, args: Vec<String>) -> Self {
|
||||
self.km.bind_args(key, KeyModifiers::NONE, cmd, args);
|
||||
self
|
||||
}
|
||||
|
||||
/// Replace a spine binding — used only by CommandMode, whose Backspace
|
||||
/// exits the mode on an empty buffer instead of popping a character.
|
||||
pub fn rebind(mut self, key: KeyCode, cmd: &'static str) -> Self {
|
||||
@@ -853,8 +860,8 @@ impl Keymaps {
|
||||
// edit-mode as an argument, so the command stays mode-agnostic. ──
|
||||
let ed = Arc::new(
|
||||
TextEntry::new(&AppMode::editing(), "normal", global.clone())
|
||||
.commit(KeyCode::Enter, "commit-cell-edit", vec!["editing".into()])
|
||||
.commit(
|
||||
.commit_reopening(KeyCode::Enter, "commit-cell-edit", vec!["editing".into()])
|
||||
.commit_reopening(
|
||||
KeyCode::Tab,
|
||||
"commit-and-advance-right",
|
||||
vec!["editing".into()],
|
||||
@@ -869,12 +876,12 @@ impl Keymaps {
|
||||
ModeKey::RecordsEditing,
|
||||
Arc::new(
|
||||
TextEntry::new(&AppMode::records_editing(), "records-normal", ed)
|
||||
.commit(
|
||||
.commit_reopening(
|
||||
KeyCode::Enter,
|
||||
"commit-cell-edit",
|
||||
vec!["records-editing".into()],
|
||||
)
|
||||
.commit(
|
||||
.commit_reopening(
|
||||
KeyCode::Tab,
|
||||
"commit-and-advance-right",
|
||||
vec!["records-editing".into()],
|
||||
|
||||
@@ -809,7 +809,11 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option<Vec<Command>> {
|
||||
match cell.id.as_ref()? {
|
||||
// `edit_text`, never `text`: the drawn text has been through
|
||||
// `format_f64`. `tests/lockstep.rs` found exactly that.
|
||||
CellId::Record(_) => Some(opened_on(cell.edit_text.clone().unwrap_or_default(), mode)),
|
||||
CellId::Record(_) => Some(opened_on(
|
||||
state,
|
||||
cell.edit_text.clone().unwrap_or_default(),
|
||||
mode,
|
||||
)),
|
||||
CellId::Aggregate(_) => Some(vec![Command::SetStatus(format!(
|
||||
"an aggregate is edited by drilling in — {}",
|
||||
not_yet("drill")
|
||||
@@ -825,9 +829,18 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option<Vec<Command>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the cell editor on `seed`: the buffer, what it captured, then the mode.
|
||||
/// `EditOrDrill`'s and `EnterEditAtCursor`'s shared shape (improvise-ixxx).
|
||||
fn opened_on(seed: String, mode: AppMode) -> Vec<Command> {
|
||||
/// Open the cell editor on `seed`, or await it while any write is unanswered: the
|
||||
/// cache predates that write (improvise-mdfc/p6v6, `protocol.md` §3).
|
||||
fn opened_on(state: &ClientState, seed: String, mode: AppMode) -> Vec<Command> {
|
||||
match state.in_flight.iter().any(|sent| sent.awaited) {
|
||||
true => awaiting_seed(mode),
|
||||
false => seeded(seed, mode),
|
||||
}
|
||||
}
|
||||
|
||||
/// The buffer, what the editor captured, then the mode: `EditOrDrill`'s and
|
||||
/// `EnterEditAtCursor`'s shared shape (improvise-ixxx).
|
||||
fn seeded(seed: String, mode: AppMode) -> Vec<Command> {
|
||||
vec![
|
||||
Command::SetBuffer {
|
||||
name: "edit".to_string(),
|
||||
@@ -946,10 +959,20 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option<V
|
||||
}
|
||||
let mut commands = written(state, id, &text)?;
|
||||
commands.push(Command::ChangeMode(AppMode::Normal));
|
||||
commands.extend(advanced(state, edit_mode, advance)?);
|
||||
commands.extend(cleared(&edit_mode));
|
||||
let writes = !untouched(state.view.opened_editor.as_ref(), &text);
|
||||
commands.extend(advanced(state, edit_mode, advance, writes)?);
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
/// The commit's own clear of `edit_mode`'s buffer, at its head: the keymap
|
||||
/// welds none to these keys, so a re-seed survives (improvise-fy9o).
|
||||
fn cleared(edit_mode: &AppMode) -> Option<Command> {
|
||||
edit_mode
|
||||
.minibuffer()
|
||||
.map(|mb| write_buffer(&mb.buffer_key, String::new()))
|
||||
}
|
||||
|
||||
/// The write a commit makes: none when the buffer is the seed the editor
|
||||
/// captured as it opened, and only the write is skipped (improvise-ixxx).
|
||||
fn written(state: &ClientState, id: &CellId, text: &str) -> Option<Vec<Command>> {
|
||||
@@ -974,6 +997,7 @@ fn create_column(
|
||||
// `exit_mode_for`: the tidy-up goes FIRST, where an aborting
|
||||
// `AddCategory` cannot reach it (gotcha 21).
|
||||
let mut commands = vec![Command::ChangeMode(AppMode::Normal)];
|
||||
commands.extend(cleared(&edit_mode));
|
||||
let name = text.trim();
|
||||
if !name.is_empty() {
|
||||
commands.push(model(
|
||||
@@ -994,13 +1018,18 @@ fn create_column(
|
||||
if matches!(kind, CreateKind::Category) {
|
||||
return Some(commands);
|
||||
}
|
||||
commands.extend(advanced(state, edit_mode, advance)?);
|
||||
commands.extend(advanced(state, edit_mode, advance, !name.is_empty())?);
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
/// The advance and the re-entry `CommitAndAdvance` ends in. `None` where the
|
||||
/// host would emit `AddRecordRow`, which this client cannot name (gotcha 1).
|
||||
fn advanced(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option<Vec<Command>> {
|
||||
fn advanced(
|
||||
state: &ClientState,
|
||||
edit_mode: AppMode,
|
||||
advance: Advance,
|
||||
writes: bool,
|
||||
) -> Option<Vec<Command>> {
|
||||
let GridExtent { rows, cols } = state.grid.extent;
|
||||
let row_max = rows.saturating_sub(1);
|
||||
let col_max = cols.saturating_sub(1);
|
||||
@@ -1022,11 +1051,25 @@ fn advanced(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option
|
||||
return Some(Vec::new());
|
||||
};
|
||||
let mut commands = scrolled_to(state, nr, nc);
|
||||
// The new cell is a different cell, so a stale cache is correct here.
|
||||
commands.extend(reopened_at(state, nr, nc, edit_mode));
|
||||
// This frame's own write makes the cache stale too; `opened_on` answers for
|
||||
// earlier ones (improvise-3uzg/mdfc/p6v6, `protocol.md` §3).
|
||||
commands.extend(reopened_at(state, nr, nc, edit_mode, writes));
|
||||
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> {
|
||||
@@ -1063,16 +1106,23 @@ fn cell_at(state: &ClientState, row: usize, col: usize) -> Option<&crate::CellDi
|
||||
|
||||
/// `EnterEditAtCursor`: re-open the editor where the advance landed, unless `i`
|
||||
/// would decline that cell. The host asks `uneditable`; this asks the same id.
|
||||
fn reopened_at(state: &ClientState, row: usize, col: usize, mode: AppMode) -> Vec<Command> {
|
||||
fn reopened_at(
|
||||
state: &ClientState,
|
||||
row: usize,
|
||||
col: usize,
|
||||
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 => {
|
||||
opened_on(seed_at(state, row, col), mode)
|
||||
}
|
||||
Some(CellId::Record(_) | CellId::Create(_) | CellId::Coordinate) | None => match writes {
|
||||
true => awaiting_seed(mode),
|
||||
false => opened_on(state, seed_at(state, row, col), mode),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1092,6 +1142,9 @@ 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.
|
||||
pub awaiting: bool,
|
||||
}
|
||||
|
||||
impl OpenedEditor {
|
||||
@@ -1100,6 +1153,16 @@ impl OpenedEditor {
|
||||
Self {
|
||||
seed,
|
||||
touched: false,
|
||||
awaiting: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// An editor opened before its seed is known: empty until it settles.
|
||||
pub fn awaiting() -> Self {
|
||||
Self {
|
||||
seed: String::new(),
|
||||
touched: false,
|
||||
awaiting: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3547,18 +3610,31 @@ mod tests {
|
||||
}
|
||||
);
|
||||
assert_eq!(commands[1], Command::ChangeMode(AppMode::Normal));
|
||||
assert_eq!(commands[2], Command::SetSelected { row: 1, col: 0 });
|
||||
assert!(
|
||||
commands.contains(&Command::ChangeMode(AppMode::editing())),
|
||||
"the advance re-enters editing on the cell it landed on: {commands:?}"
|
||||
);
|
||||
// The commit owns its clear, at the head (improvise-fy9o).
|
||||
assert_eq!(
|
||||
commands.last(),
|
||||
Some(&Command::SetBuffer {
|
||||
commands[2],
|
||||
Command::SetBuffer {
|
||||
name: "edit".into(),
|
||||
value: String::new(),
|
||||
}),
|
||||
"the keymap welds `clear-buffer` to every commit: {commands:?}"
|
||||
}
|
||||
);
|
||||
assert_eq!(commands[3], Command::SetSelected { row: 1, col: 0 });
|
||||
assert_eq!(
|
||||
commands.last(),
|
||||
Some(&Command::ChangeMode(AppMode::editing())),
|
||||
"the advance re-enters editing on the cell it landed on: {commands:?}"
|
||||
);
|
||||
assert!(
|
||||
commands.contains(&Command::SetOpenedEditor(Some(OpenedEditor::awaiting()))),
|
||||
"after a write the reopened editor awaits the host's seed: {commands:?}"
|
||||
);
|
||||
for command in &commands {
|
||||
client.fold(command);
|
||||
}
|
||||
assert_eq!(
|
||||
client.view.buffers.get("edit").map(String::as_str),
|
||||
Some("900"),
|
||||
"an answered client seeds the reopened editor from the cell it landed on"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4198,3 +4274,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod opened_editor_wire_tests {
|
||||
use super::OpenedEditor;
|
||||
use crate::{ClientFrame, Command};
|
||||
|
||||
/// improvise-mgis: `awaiting: false` is the claim that the seed is settled,
|
||||
/// so a frame that dropped it is refused. `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_frame_whose_editor_lost_its_awaiting_flag_does_not_parse() {
|
||||
let frame = ClientFrame::Commands(vec![Command::SetOpenedEditor(Some(
|
||||
OpenedEditor::awaiting(),
|
||||
))]);
|
||||
let mut json = serde_json::to_value(&frame).expect("a ClientFrame serializes");
|
||||
let opened = json
|
||||
.pointer_mut("/Commands/0/SetOpenedEditor")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
.expect("the editor is where this test expects it");
|
||||
assert!(
|
||||
opened.remove("awaiting").is_some() && opened.contains_key("seed"),
|
||||
"the field must have been there, and the rest of the editor must remain"
|
||||
);
|
||||
let err = serde_json::from_value::<ClientFrame>(json)
|
||||
.expect_err("an editor that states no awaiting flag states no claim");
|
||||
assert!(
|
||||
err.to_string().contains("missing field `awaiting`"),
|
||||
"it must fail as the MISSING FIELD, not as broken JSON: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1727,6 +1727,45 @@ fn a_locally_committed_creating_column_agrees_with_the_host() {
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-5wai: `+cat` reopens nothing, so its commit's own clear is all
|
||||
/// that empties the buffer, on both ends. Evidence file.
|
||||
#[test]
|
||||
fn a_category_committed_at_plus_cat_leaves_no_buffer_behind_on_both_ends() {
|
||||
let (mut app, mut twin) = records_pair();
|
||||
let client = furnished_client(&app, (12, 12));
|
||||
let at = creating_cell(&client, improvise_protocol::CreateKind::Category);
|
||||
let mut client = client;
|
||||
typing_at(&mut app, &mut twin, &mut client, at, "Season");
|
||||
client = furnished_client(&app, (12, 12));
|
||||
client.view = app.view_state.clone();
|
||||
assert_eq!(
|
||||
read_edit(&client.view.buffers),
|
||||
"Season",
|
||||
"nothing was typed, so this proves nothing"
|
||||
);
|
||||
|
||||
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
||||
|
||||
assert!(
|
||||
document_text(&app).contains("Season"),
|
||||
"the commit did not land"
|
||||
);
|
||||
assert_eq!(
|
||||
read_edit(&app.view_state.buffers),
|
||||
"",
|
||||
"the host left the name behind"
|
||||
);
|
||||
assert_eq!(
|
||||
read_edit(&client.view.buffers),
|
||||
"",
|
||||
"the browser left the name behind"
|
||||
);
|
||||
}
|
||||
|
||||
fn read_edit(buffers: &std::collections::HashMap<String, String>) -> &str {
|
||||
buffers.get("edit").map(String::as_str).unwrap_or("")
|
||||
}
|
||||
|
||||
/// The twin of the commit test above, for the door rather than the exit
|
||||
/// (improvise-bd9o). Evidence file.
|
||||
#[test]
|
||||
@@ -2230,6 +2269,179 @@ fn an_untouched_browser_edit_of_a_misread_value_writes_nothing() {
|
||||
}
|
||||
}
|
||||
|
||||
/// BUG (improvise-fy9o) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn enter_enter_leaves_every_cell_unchanged_on_both_ends() {
|
||||
let every_cell = |app: &App| -> Vec<Option<improvise_protocol::CellValue>> {
|
||||
[
|
||||
("North", "Cost"),
|
||||
("North", "Revenue"),
|
||||
("South", "Cost"),
|
||||
("South", "Revenue"),
|
||||
]
|
||||
.iter()
|
||||
.map(|(r, m)| {
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.get_cell(®ion_measure(r, m))
|
||||
.cloned()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
||||
client_host.cursor_mut().selected = (1, 0);
|
||||
let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
||||
key_host.cursor_mut().selected = (1, 0);
|
||||
let before = every_cell(&client_host);
|
||||
let mut client = client_ready_to_edit(&client_host, (2, 2));
|
||||
for key in ["i", "Enter", "Enter"] {
|
||||
let event = BrowserKey::new(key).to_key_event().unwrap();
|
||||
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
||||
panic!("{key}: the client must answer this key locally");
|
||||
};
|
||||
for cmd in &cmds {
|
||||
client.fold(cmd);
|
||||
}
|
||||
apply_on(&mut client_host, &cmds);
|
||||
reduce_key(&mut key_host, event);
|
||||
assert_eq!(
|
||||
client.view.buffers, key_host.view_state.buffers,
|
||||
"{key}: client and host disagree about the buffers"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
every_cell(&client_host),
|
||||
before,
|
||||
"the browser's Enter, Enter wrote"
|
||||
);
|
||||
assert_eq!(
|
||||
every_cell(&key_host),
|
||||
before,
|
||||
"the host's Enter, Enter wrote"
|
||||
);
|
||||
}
|
||||
|
||||
/// A commit that reopens nothing still leaves no text behind, on either end:
|
||||
/// the clear moved from the keymap into the commit (improvise-fy9o).
|
||||
#[test]
|
||||
fn a_commit_at_the_corner_leaves_no_buffer_behind_on_both_ends() {
|
||||
let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
||||
let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
||||
assert_eq!(
|
||||
key_host.cursor().selected,
|
||||
(1, 1),
|
||||
"the fixture is not at the corner"
|
||||
);
|
||||
let mut client = client_ready_to_edit(&client_host, (2, 2));
|
||||
for key in ["i", "9", "Enter"] {
|
||||
let event = BrowserKey::new(key).to_key_event().unwrap();
|
||||
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
||||
panic!("{key}: the client must answer this key locally");
|
||||
};
|
||||
for cmd in &cmds {
|
||||
client.fold(cmd);
|
||||
}
|
||||
apply_on(&mut client_host, &cmds);
|
||||
reduce_key(&mut key_host, event);
|
||||
}
|
||||
assert!(
|
||||
!key_host.view_state.mode.is_editing(),
|
||||
"the corner must not reopen"
|
||||
);
|
||||
for (end, buffers) in [
|
||||
("browser", &client.view.buffers),
|
||||
("host", &key_host.view_state.buffers),
|
||||
] {
|
||||
assert_eq!(
|
||||
buffers.get("edit").map(String::as_str),
|
||||
Some(""),
|
||||
"{end}: the committed text was left in the buffer"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// BUG (improvise-3tt4) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn tab_at_the_rightmost_column_reopens_on_the_value_just_written() {
|
||||
let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
||||
client_host.cursor_mut().selected = (0, 1);
|
||||
let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
||||
key_host.cursor_mut().selected = (0, 1);
|
||||
let mut client = client_ready_to_edit(&client_host, (2, 2));
|
||||
for key in ["i", "9", "Tab", "5", "Enter"] {
|
||||
let event = BrowserKey::new(key).to_key_event().unwrap();
|
||||
sent_and_answered(&mut client, &mut client_host, event, (2, 2));
|
||||
reduce_key(&mut key_host, event);
|
||||
assert_eq!(
|
||||
client.view.buffers, key_host.view_state.buffers,
|
||||
"{key}: client and host disagree about the buffers"
|
||||
);
|
||||
}
|
||||
for (end, app) in [("browser", &client_host), ("host", &key_host)] {
|
||||
assert_eq!(
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.get_cell(®ion_measure("North", "Revenue")),
|
||||
Some(&improvise_protocol::CellValue::Number(295.0)),
|
||||
"{end}: the reopened editor did not extend the value Tab had just written"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The reopened seed is the STORED value's edit text, not the typed string:
|
||||
/// `007` is stored as 7 on both ends (improvise-3tt4). Evidence file.
|
||||
#[test]
|
||||
fn tab_at_the_rightmost_column_reopens_on_the_stored_spelling() {
|
||||
let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
||||
client_host.cursor_mut().selected = (0, 1);
|
||||
let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
||||
key_host.cursor_mut().selected = (0, 1);
|
||||
let mut client = client_ready_to_edit(&client_host, (2, 2));
|
||||
for key in ["i", "Backspace", "0", "0", "7", "Tab"] {
|
||||
let event = BrowserKey::new(key).to_key_event().unwrap();
|
||||
sent_and_answered(&mut client, &mut client_host, event, (2, 2));
|
||||
reduce_key(&mut key_host, event);
|
||||
}
|
||||
assert_eq!(
|
||||
key_host.view_state.buffers.get("edit").map(String::as_str),
|
||||
Some("7")
|
||||
);
|
||||
assert_eq!(client.view.buffers, key_host.view_state.buffers);
|
||||
}
|
||||
|
||||
/// BUG (improvise-fy9o) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn typing_into_the_reopened_editor_extends_the_cell_on_both_ends() {
|
||||
let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
||||
client_host.cursor_mut().selected = (1, 0);
|
||||
let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
||||
key_host.cursor_mut().selected = (1, 0);
|
||||
let mut client = client_ready_to_edit(&client_host, (2, 2));
|
||||
for key in ["i", "Enter", "5", "Enter"] {
|
||||
let event = BrowserKey::new(key).to_key_event().unwrap();
|
||||
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
||||
panic!("{key}: the client must answer this key locally");
|
||||
};
|
||||
for cmd in &cmds {
|
||||
client.fold(cmd);
|
||||
}
|
||||
apply_on(&mut client_host, &cmds);
|
||||
reduce_key(&mut key_host, event);
|
||||
}
|
||||
for (end, app) in [("browser", &client_host), ("host", &key_host)] {
|
||||
assert_eq!(
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.get_cell(®ion_measure("North", "Revenue")),
|
||||
Some(&improvise_protocol::CellValue::Number(25.0)),
|
||||
"{end}: the digit typed into the reopened editor did not extend its seed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `i` then `Enter` on (South, Revenue) holding `value`, through the resolver
|
||||
/// and a real host: the seed is the stored value's, and the cell is unchanged.
|
||||
fn untouched_browser_edit(value: improvise_protocol::CellValue) {
|
||||
@@ -2315,6 +2527,212 @@ fn every_projected_edit_text_commits_back_to_its_value() {
|
||||
}
|
||||
}
|
||||
|
||||
/// North/South/West = 1/2/3 on one measure, with `prune_empty` on: emptying a
|
||||
/// row's only value takes the row away and renumbers the rest.
|
||||
fn pruned_host() -> App {
|
||||
let mut wb = Workbook::new("Test");
|
||||
wb.add_category("Region").unwrap();
|
||||
for region in ["North", "South", "West"] {
|
||||
wb.add_item("Region", region).unwrap();
|
||||
}
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.active_view_mut().set_axis("Region", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
wb.active_view_mut().prune_empty = true;
|
||||
for (region, value) in [("North", 1.0), ("South", 2.0), ("West", 3.0)] {
|
||||
wb.model.set_cell(
|
||||
region_measure(region, "Revenue"),
|
||||
improvise_protocol::CellValue::Number(value),
|
||||
);
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.term_height = 3 + 8;
|
||||
app.rebuild_layout();
|
||||
app
|
||||
}
|
||||
|
||||
/// One key the way a real browser sends it: folded as an outgoing frame, applied
|
||||
/// by the host, then answered with the host's own projection and `Accepted`.
|
||||
fn sent_and_answered(
|
||||
client: &mut ClientState,
|
||||
host: &mut App,
|
||||
event: KeyEvent,
|
||||
window: (usize, usize),
|
||||
) {
|
||||
let KeyOutcome::Local(cmds) = resolve(client, event) else {
|
||||
panic!("{event:?}: the client must answer this key locally");
|
||||
};
|
||||
client.fold_outgoing(&cmds);
|
||||
apply_on(host, &cmds);
|
||||
client.grid = client_of(host, window).grid;
|
||||
improvise_protocol::WidgetCache::apply(&mut client.basis, &published_basis(host, window));
|
||||
client.accepted();
|
||||
}
|
||||
|
||||
/// BUG (improvise-3uzg) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_commit_that_prunes_its_row_reopens_on_the_row_that_moved_up() {
|
||||
let window = (3, 3);
|
||||
let mut client_host = pruned_host();
|
||||
let mut key_host = pruned_host();
|
||||
let mut client = client_ready_to_edit(&client_host, window);
|
||||
for key in ["i", "Backspace", "Enter", "5", "Enter"] {
|
||||
let event = BrowserKey::new(key).to_key_event().unwrap();
|
||||
sent_and_answered(&mut client, &mut client_host, event, window);
|
||||
reduce_key(&mut key_host, event);
|
||||
assert_eq!(
|
||||
client.view.buffers, key_host.view_state.buffers,
|
||||
"{key}: client and host disagree about the buffers"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
document_text(&client_host),
|
||||
document_text(&key_host),
|
||||
"the browser wrote a value built from another record's number"
|
||||
);
|
||||
assert!(
|
||||
document_text(&key_host).contains("= 35"),
|
||||
"setup: the host's reopened editor extends West's 3"
|
||||
);
|
||||
}
|
||||
|
||||
/// What the host owes a frame it has applied: its projection, its basis, and
|
||||
/// `Accepted` when the frame was awaited. Held back so the next key overtakes it.
|
||||
struct Unanswered {
|
||||
grid: improvise_protocol::GridCache,
|
||||
basis: improvise_protocol::Basis,
|
||||
awaited: bool,
|
||||
}
|
||||
|
||||
fn sent(
|
||||
client: &mut ClientState,
|
||||
host: &mut App,
|
||||
event: KeyEvent,
|
||||
window: (usize, usize),
|
||||
) -> Unanswered {
|
||||
let KeyOutcome::Local(cmds) = resolve(client, event) else {
|
||||
panic!("{event:?}: the client must answer this key locally");
|
||||
};
|
||||
client.fold_outgoing(&cmds);
|
||||
apply_on(host, &cmds);
|
||||
let awaited = cmds
|
||||
.iter()
|
||||
.any(|c| matches!(c, improvise_protocol::Command::Model { .. }));
|
||||
let (grid, basis) = (client_of(host, window).grid, published_basis(host, window));
|
||||
Unanswered {
|
||||
grid,
|
||||
basis,
|
||||
awaited,
|
||||
}
|
||||
}
|
||||
|
||||
fn answered(client: &mut ClientState, answer: Unanswered) {
|
||||
client.grid = answer.grid;
|
||||
improvise_protocol::WidgetCache::apply(&mut client.basis, &answer.basis);
|
||||
if answer.awaited {
|
||||
client.accepted();
|
||||
}
|
||||
}
|
||||
|
||||
/// BUG (improvise-mdfc) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_tab_sent_before_the_last_write_is_answered_reopens_on_that_write() {
|
||||
let window = (2, 2);
|
||||
let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
||||
client_host.cursor_mut().selected = (0, 1);
|
||||
let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
||||
key_host.cursor_mut().selected = (0, 1);
|
||||
let mut client = client_ready_to_edit(&client_host, window);
|
||||
let tab = BrowserKey::new("Tab").to_key_event().unwrap();
|
||||
for key in ["i", "9"] {
|
||||
let event = BrowserKey::new(key).to_key_event().unwrap();
|
||||
sent_and_answered(&mut client, &mut client_host, event, window);
|
||||
reduce_key(&mut key_host, event);
|
||||
}
|
||||
// Three Tabs, so a frame that writes nothing sits between the write and the last.
|
||||
let answers: Vec<Unanswered> = (0..3)
|
||||
.map(|_| sent(&mut client, &mut client_host, tab, window))
|
||||
.collect();
|
||||
let awaited: Vec<bool> = answers.iter().map(|a| a.awaited).collect();
|
||||
assert_eq!(
|
||||
awaited,
|
||||
[true, false, false],
|
||||
"setup: only the first Tab writes"
|
||||
);
|
||||
for answer in answers {
|
||||
reduce_key(&mut key_host, tab);
|
||||
answered(&mut client, answer);
|
||||
}
|
||||
assert_eq!(
|
||||
client.view.buffers, key_host.view_state.buffers,
|
||||
"the second editor opened on the cache the first write had made stale"
|
||||
);
|
||||
for key in ["5", "Enter"] {
|
||||
let event = BrowserKey::new(key).to_key_event().unwrap();
|
||||
sent_and_answered(&mut client, &mut client_host, event, window);
|
||||
reduce_key(&mut key_host, event);
|
||||
}
|
||||
for (end, app) in [("browser", &client_host), ("host", &key_host)] {
|
||||
assert_eq!(
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.get_cell(®ion_measure("North", "Revenue")),
|
||||
Some(&improvise_protocol::CellValue::Number(295.0)),
|
||||
"{end}: the browser overwrote the value Tab had just written"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// BUG (improvise-p6v6) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn an_editor_opened_with_i_before_the_last_write_is_answered_opens_on_that_write() {
|
||||
let window = (2, 2);
|
||||
let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
||||
client_host.cursor_mut().selected = (0, 1);
|
||||
let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
||||
key_host.cursor_mut().selected = (0, 1);
|
||||
let mut client = client_ready_to_edit(&client_host, window);
|
||||
for key in ["i", "9"] {
|
||||
let event = BrowserKey::new(key).to_key_event().unwrap();
|
||||
sent_and_answered(&mut client, &mut client_host, event, window);
|
||||
reduce_key(&mut key_host, event);
|
||||
}
|
||||
// Tab writes 29; Esc and i reopen before its answer comes back.
|
||||
let answers: Vec<Unanswered> = ["Tab", "Escape", "i"]
|
||||
.into_iter()
|
||||
.map(|key| {
|
||||
let event = BrowserKey::new(key).to_key_event().unwrap();
|
||||
reduce_key(&mut key_host, event);
|
||||
sent(&mut client, &mut client_host, event, window)
|
||||
})
|
||||
.collect();
|
||||
let awaited: Vec<bool> = answers.iter().map(|a| a.awaited).collect();
|
||||
assert_eq!(awaited, [true, false, false], "setup: only the Tab writes");
|
||||
answers
|
||||
.into_iter()
|
||||
.for_each(|answer| answered(&mut client, answer));
|
||||
assert_eq!(
|
||||
client.view.buffers, key_host.view_state.buffers,
|
||||
"i opened on the cache the unanswered write had made stale"
|
||||
);
|
||||
for key in ["5", "Enter"] {
|
||||
let event = BrowserKey::new(key).to_key_event().unwrap();
|
||||
sent_and_answered(&mut client, &mut client_host, event, window);
|
||||
reduce_key(&mut key_host, event);
|
||||
}
|
||||
for (end, app) in [("browser", &client_host), ("host", &key_host)] {
|
||||
assert_eq!(
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.get_cell(®ion_measure("North", "Revenue")),
|
||||
Some(&improvise_protocol::CellValue::Number(295.0)),
|
||||
"{end}: the browser overwrote the value Tab had just written"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const FORMULAS: [&str; 3] = [
|
||||
"Profit = Revenue * 2",
|
||||
"Tax = Revenue * 0.1",
|
||||
|
||||
Reference in New Issue
Block a user