# Conflicts: # crates/improvise-wasm-client/tests/lockstep.rs
This commit is contained in:
@@ -1941,14 +1941,7 @@ mod tests {
|
||||
) -> Vec<Addressed> {
|
||||
client.fold_outgoing(&frame);
|
||||
let answer = host.handle(id, ClientFrame::Commands(frame));
|
||||
for msg in to(&answer, id) {
|
||||
match msg {
|
||||
ServerFrame::Commands(cmds) => cmds.iter().for_each(|c| client.fold(c)),
|
||||
ServerFrame::Accepted => client.accepted(),
|
||||
ServerFrame::Rejected(r) => client.refused(r),
|
||||
other => panic!("an unexpected answer: {other:?}"),
|
||||
}
|
||||
}
|
||||
answered(&answer, id, client);
|
||||
answer
|
||||
}
|
||||
|
||||
@@ -2067,6 +2060,108 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold the answers `answer` owes `id`, the way the page folds them.
|
||||
fn answered(answer: &[Addressed], id: SessionId, client: &mut improvise_protocol::ClientState) {
|
||||
for msg in to(answer, id) {
|
||||
match msg {
|
||||
ServerFrame::Commands(cmds) => cmds.iter().for_each(|c| client.fold(c)),
|
||||
ServerFrame::Accepted => client.accepted(),
|
||||
ServerFrame::Rejected(r) => client.refused(r),
|
||||
other => panic!("an unexpected answer: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-xd3q: a placement sent before a reframe's answer arrived must
|
||||
/// survive that answer's reset. Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn a_placement_sent_before_a_reframe_is_answered_outlives_its_reset() {
|
||||
use improvise_protocol::AppMode;
|
||||
|
||||
let mut host = host();
|
||||
let (asker, mut client) = connected_client(&mut host);
|
||||
let product = ModelCommand::AddCategory("Product".into());
|
||||
let reframe = vec![
|
||||
Command::ChangeMode(AppMode::Normal),
|
||||
crate::ui::reduce::caught_up(host.app(), product),
|
||||
Command::SetSelected { row: 1, col: 0 },
|
||||
];
|
||||
let placement = vec![Command::SetSelected { row: 2, col: 0 }];
|
||||
|
||||
client.fold_outgoing(&reframe);
|
||||
let first = host.handle(asker, ClientFrame::Commands(reframe));
|
||||
client.fold_outgoing(&placement);
|
||||
let second = host.handle(asker, ClientFrame::Commands(placement));
|
||||
assert!(
|
||||
relayed_to(&first, asker).contains(&Command::ResetCursor),
|
||||
"setup: the reframe must reset the asker, or nothing here can clobber"
|
||||
);
|
||||
answered(&first, asker, &mut client);
|
||||
answered(&second, asker, &mut client);
|
||||
|
||||
let mirror = host.sessions.get(asker).unwrap().view().cursor();
|
||||
assert_eq!(
|
||||
mirror.selected,
|
||||
(2, 0),
|
||||
"setup: the host applied both frames"
|
||||
);
|
||||
assert_eq!(
|
||||
client.view.cursor(),
|
||||
mirror,
|
||||
"the reframe's reset clobbered a placement the client had already sent"
|
||||
);
|
||||
assert_eq!(client.settled.cursor(), mirror, "settled");
|
||||
}
|
||||
|
||||
/// improvise-xd3q: what was sent behind a reframe is replayed ONCE, from the
|
||||
/// settled slice, never re-applied. Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn a_view_step_sent_before_a_reframe_is_answered_steps_back_once() {
|
||||
use improvise_protocol::AppMode;
|
||||
|
||||
let mut host = host();
|
||||
let (asker, mut client) = connected_client(&mut host);
|
||||
for name in ["Second", "Third"] {
|
||||
let create = ModelCommand::CreateView(name.into());
|
||||
let create = crate::ui::reduce::caught_up(host.app(), create);
|
||||
exchange(&mut host, asker, &mut client, vec![create]);
|
||||
exchange(
|
||||
&mut host,
|
||||
asker,
|
||||
&mut client,
|
||||
vec![Command::SwitchView(name.into())],
|
||||
);
|
||||
}
|
||||
let product = ModelCommand::AddCategory("Product".into());
|
||||
let reframe = vec![
|
||||
Command::ChangeMode(AppMode::Normal),
|
||||
crate::ui::reduce::caught_up(host.app(), product),
|
||||
];
|
||||
let back = vec![Command::ViewBack];
|
||||
|
||||
client.fold_outgoing(&reframe);
|
||||
let first = host.handle(asker, ClientFrame::Commands(reframe));
|
||||
client.fold_outgoing(&back);
|
||||
let second = host.handle(asker, ClientFrame::Commands(back));
|
||||
assert!(
|
||||
relayed_to(&first, asker).contains(&Command::ResetCursor),
|
||||
"setup: the reframe must relay a reset, or nothing is queued to replay behind"
|
||||
);
|
||||
answered(&first, asker, &mut client);
|
||||
answered(&second, asker, &mut client);
|
||||
|
||||
let mirror = host.sessions.get(asker).unwrap().view();
|
||||
assert_eq!(
|
||||
mirror.active_view, "Second",
|
||||
"setup: one step back from Third"
|
||||
);
|
||||
assert_eq!(
|
||||
(&client.view.active_view, &client.view.view_back_stack),
|
||||
(&mirror.active_view, &mirror.view_back_stack),
|
||||
"the frame sent behind the reframe was applied more than once"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-jpba: another client's reset, heard while my frame is in flight,
|
||||
/// outlives that frame's refusal. Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
|
||||
@@ -85,7 +85,20 @@ impl ClientState {
|
||||
/// Total: a command naming something absent is dropped, never a panic.
|
||||
pub fn fold(&mut self, cmd: &Command) {
|
||||
self.settle_relay(cmd);
|
||||
self.apply(cmd);
|
||||
match (cmd, self.in_flight.is_empty()) {
|
||||
(Command::Project(_), _) | (_, true) => self.apply(cmd),
|
||||
// Queued behind the frame it answers, so what was sent after it
|
||||
// replays on top of it (improvise-xd3q, `clients.md`).
|
||||
(_, false) => self.rebase(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The view rebuilt as the settled slice plus everything still in flight.
|
||||
fn rebase(&mut self) {
|
||||
self.replay();
|
||||
self.settle_mode();
|
||||
self.settle_editor();
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
/// A relay the host has already applied to this client's mirror, so a
|
||||
@@ -136,6 +149,12 @@ impl ClientState {
|
||||
}
|
||||
}
|
||||
|
||||
/// A status this client says to itself now. It queues after every frame
|
||||
/// already sent, so no rebase replays one over it (improvise-n0pb, `clients.md`).
|
||||
pub fn notify(&mut self, message: String) {
|
||||
self.fold_outgoing(&[Command::SetStatus(message)]);
|
||||
}
|
||||
|
||||
/// The host applied the oldest awaited frame whole, so it is the settled
|
||||
/// slice's now.
|
||||
pub fn accepted(&mut self) {
|
||||
|
||||
@@ -95,7 +95,7 @@ impl Client {
|
||||
// The sentence goes on *after* the rollback: it is this
|
||||
// client's own notification, and the slice it restores has none.
|
||||
state.refused(rejection);
|
||||
state.view.status_msg = message;
|
||||
state.notify(message);
|
||||
}
|
||||
ServerFrame::Accepted => self.state_mut().accepted(),
|
||||
}
|
||||
@@ -219,6 +219,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An awaited frame that also says `status`, as a commit's frame does.
|
||||
fn awaited_saying(status: &str) -> Vec<Command> {
|
||||
let mut cache = improvise_protocol::BasisCache::default();
|
||||
let basis = improvise_protocol::basis::published(0, 0, Default::default());
|
||||
improvise_protocol::WidgetCache::apply(&mut cache, &basis);
|
||||
vec![
|
||||
Command::Model {
|
||||
basis: cache.claim([]).expect("seeded"),
|
||||
cmd: improvise_protocol::ModelCommand::CleanEmptyRecords,
|
||||
},
|
||||
Command::SetStatus(status.into()),
|
||||
]
|
||||
}
|
||||
|
||||
/// BUG (improvise-n0pb) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_relay_heard_after_a_refusal_keeps_the_refusals_sentence() {
|
||||
let mut client = Client::default();
|
||||
for status in ["first", "second"] {
|
||||
client.state_mut().fold_outgoing(&awaited_saying(status));
|
||||
}
|
||||
client.fold_frame(ServerFrame::Rejected(Rejection::LayoutMoved));
|
||||
let sentence = rejection_message(&Rejection::LayoutMoved);
|
||||
assert_eq!(
|
||||
client.state().view.status_msg,
|
||||
sentence,
|
||||
"setup: the refusal says so"
|
||||
);
|
||||
|
||||
client.fold_frame(ServerFrame::Commands(vec![Command::ResetCursor]));
|
||||
assert_eq!(
|
||||
client.state().view.status_msg,
|
||||
sentence,
|
||||
"a relay behind the second frame erased the refusal's sentence"
|
||||
);
|
||||
client.fold_frame(ServerFrame::Accepted);
|
||||
assert_eq!(
|
||||
client.state().view.status_msg,
|
||||
sentence,
|
||||
"and settling keeps it"
|
||||
);
|
||||
assert_eq!(client.state().settled.status_msg, sentence);
|
||||
}
|
||||
|
||||
/// The rejection belongs to whoever composed the refused batch, so it does
|
||||
/// not survive a document swap: it names a record that is gone.
|
||||
#[test]
|
||||
|
||||
@@ -3258,3 +3258,112 @@ fn the_search_keys_decline_out_loud_in_a_browser() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// North/South/West/East = 1/2/3/4 on one measure, `prune_empty` on: emptying
|
||||
/// North takes its row away and renumbers the rest (improvise-xd3q).
|
||||
fn four_pruned_regions() -> App {
|
||||
let mut wb = Workbook::new("Test");
|
||||
wb.add_category("Region").unwrap();
|
||||
for region in ["North", "South", "West", "East"] {
|
||||
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), ("East", 4.0)] {
|
||||
wb.model.set_cell(
|
||||
region_measure(region, "Revenue"),
|
||||
improvise_protocol::CellValue::Number(value),
|
||||
);
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.term_height = 4 + 8;
|
||||
app.rebuild_layout();
|
||||
app
|
||||
}
|
||||
|
||||
/// One key resolved by the client and sent to `host`; its answer is returned,
|
||||
/// NOT folded, so the next key can go out first.
|
||||
fn sent_to_host(
|
||||
client: &mut ClientState,
|
||||
host: &mut improvise_command::ui::host::Host,
|
||||
id: improvise_command::ui::project::SessionId,
|
||||
key: &str,
|
||||
) -> Vec<improvise_command::ui::host::Addressed> {
|
||||
let event = BrowserKey::new(key).to_key_event().unwrap();
|
||||
let cmds = match resolve(client, event) {
|
||||
KeyOutcome::Local(cmds) => cmds,
|
||||
other => panic!("{key}: the client must answer this key locally, got {other:?}"),
|
||||
};
|
||||
client.fold_outgoing(&cmds);
|
||||
host.handle(id, ClientFrame::Commands(cmds))
|
||||
}
|
||||
|
||||
/// Fold an answer the way the page does: the host's commands through `fold`.
|
||||
fn heard(
|
||||
client: &mut ClientState,
|
||||
id: improvise_command::ui::project::SessionId,
|
||||
answer: Vec<improvise_command::ui::host::Addressed>,
|
||||
) {
|
||||
for addressed in answer.into_iter().filter(|a| a.to == id) {
|
||||
match addressed.msg {
|
||||
improvise_protocol::ServerFrame::Snapshot(snapshot) => client.bootstrap(*snapshot),
|
||||
improvise_protocol::ServerFrame::Commands(cmds) => {
|
||||
cmds.iter().for_each(|c| client.fold(c))
|
||||
}
|
||||
improvise_protocol::ServerFrame::Rejected(r) => client.refused(r),
|
||||
improvise_protocol::ServerFrame::Accepted => client.accepted(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BUG (improvise-xd3q) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_move_sent_before_a_pruning_commit_is_answered_writes_where_the_tui_writes() {
|
||||
let window = (4, 4);
|
||||
let mut host = improvise_command::ui::host::Host::new(four_pruned_regions());
|
||||
let (id, snapshot) = host.connect(window.0, window.1);
|
||||
let mut client = ClientState {
|
||||
window,
|
||||
..ClientState::default()
|
||||
};
|
||||
heard(&mut client, id, vec![snapshot]);
|
||||
let mut tui = four_pruned_regions();
|
||||
let key = |tui: &mut App, k: &str| reduce_key(tui, BrowserKey::new(k).to_key_event().unwrap());
|
||||
for k in ["i", "Backspace"] {
|
||||
let answer = sent_to_host(&mut client, &mut host, id, k);
|
||||
heard(&mut client, id, answer);
|
||||
key(&mut tui, k);
|
||||
}
|
||||
// Enter prunes North; Esc and j go out before its answer comes back.
|
||||
let answers: Vec<_> = ["Enter", "Escape", "j"]
|
||||
.into_iter()
|
||||
.map(|k| {
|
||||
key(&mut tui, k);
|
||||
sent_to_host(&mut client, &mut host, id, k)
|
||||
})
|
||||
.collect();
|
||||
answers.into_iter().for_each(|a| heard(&mut client, id, a));
|
||||
let mirror = host.sessions().get(id).unwrap().view().cursor();
|
||||
assert_eq!(
|
||||
mirror.selected,
|
||||
tui.cursor().selected,
|
||||
"setup: the host's mirror followed the keys the TUI followed"
|
||||
);
|
||||
assert_eq!(
|
||||
client.view.cursor().selected,
|
||||
mirror.selected,
|
||||
"the pruning commit's reset clobbered where j had put the client"
|
||||
);
|
||||
for k in ["i", "5", "Enter"] {
|
||||
let answer = sent_to_host(&mut client, &mut host, id, k);
|
||||
heard(&mut client, id, answer);
|
||||
key(&mut tui, k);
|
||||
}
|
||||
assert_eq!(
|
||||
document_text(host.app()),
|
||||
document_text(&tui),
|
||||
"the browser wrote a different record from the one the TUI wrote"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user