Merge PR #266 (d0e36142) into the 2026-09-24 integration wave

# Conflicts:
#	crates/improvise-wasm-client/tests/lockstep.rs
This commit is contained in:
Edward Langley
2026-09-24 02:47:20 -07:00
7 changed files with 419 additions and 10 deletions
+103 -8
View File
@@ -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]
+20 -1
View File
@@ -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) {
+45 -1
View File
@@ -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"
);
}