Merge #266 (fix/xd3q) into #279: one notify, and a rebase settles the status (improvise-0dbk)

Both PRs added ClientState::notify, so the merge failed with E0592. #266's
is kept: it queues the sentence through fold_outgoing, which runs
settle_status, so the owner is still recorded. #279's direct-write notify
is deleted.

#266's rebase() rebuilt the view without settle_status, so a relay that
moved the view off the editor left the message owned, and an equal
editor reopened afterwards took it back. rebase() now settles it, pinned
by a_rebase_off_the_editor_ends_the_message_standing (red before).

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:
Edward Langley
2026-09-23 18:57:06 -07:00
8 changed files with 974 additions and 74 deletions
+107 -6
View File
@@ -1360,6 +1360,96 @@ the first discards the second, which the host *did* apply. A frame the host will
not answer settles as soon as nothing older is waiting — only from the front,
because settling it out of turn would lose the order.
**What the host *relays* is settled too, and it settles after the frame it
arrived during (improvise-jpba).** `settled` used to take only this client's
own frames. Relays never reached it: a `ResetCursor`, the `SwitchView` sent when
the view a client is on is deleted, and the placement echoes after a reframe
(improvise-wdmp). The host has already applied each of them to this client's
mirror, so the next refusal rebuilt `view` from a settled slice that had never
heard them. A client whose reframe was accepted, and whose next frame was then
refused, went back to its pre-reset cursor while its mirror stayed at the
origin. That is the same client/mirror split wdmp closed, reopened by the
rollback. `fold` is the door for what the host sends (`fold_outgoing` is the
door for what this client sends), so `fold` now records every non-projection
command. It goes straight into `settled` when nothing is in flight. Otherwise
it is queued as an unawaited `Sent { relayed: true }` **directly behind the
head frame**, after any relays already queued there. `accepted` and
`settle_sent` then settle it in the right order, and `replay` replays it.
**Every answer settles, the refusal included** (improvise-3kfc). `refused`
pops the head and then calls `settle_sent` before it replays. Without that call,
an unawaited entry behind a refused head became the front while still unsettled,
and the next answer popped it instead of its own frame. That entry can be a
relay or this client's own view-only frame, and the view-only case predated
relays.
**A relay that queues also rebases `view` (improvise-xd3q).** Recording a relay
in `settled` fixed only the refusal. On the accepted path, `fold` still applied
the relay straight onto `view`, on top of the frames this client had already
sent behind the one being answered. A pruning commit is answered with
`ResetCursor` and an echo of its own placement (improvise-wdmp). A `j` sent
before that answer arrived had already been folded, and the reset clobbered it.
The echo re-sent only the pruning frame's placement, because the host's
`asker_reset` is per frame. So the client sat at (1,0) while its mirror sat at
(2,0), and the next write landed on West where the TUI writes East. The queue
already held the right order, `[head, reset, echo, j]`, so `fold` now rebuilds
`view` with the same `replay` a refusal uses whenever a relay is queued. That is
the settled slice plus everything still in flight, in order. With nothing in
flight, the relay is applied directly, as before.
It is a **replay from `settled`**, not "apply the relay, then re-apply what
queued behind it". Re-applying reruns every frame behind the relay a second
time. That looks the same for idempotent setters like `SetSelected`, but
`ViewBack` pops a stack, and applying it twice stepped a client back two views
while its mirror stepped back one. The host could not have fixed this. It
cannot know whether a frame was composed before or after the client heard the
reset, and only the client knows what it sent.
**What the client says to itself queues too (improvise-n0pb).** A refusal's
sentence ("…your edit was not applied") is written by the client, not sent by
the host. It used to go straight into `view.status_msg`, after `refused` had
rolled back. That put it in neither `settled` nor `in_flight`, so the xd3q
rebase erased it the next time a relay queued behind a frame still in flight.
A frame in flight that carried its own `SetStatus` was replayed over it. The
sentence is a local event that happened at a moment, so it takes its place in
the queue at that moment. `ClientState::notify` queues it as a view-only entry
at the **tail**, which is what `fold_outgoing` does with a frame the host never
answers. This gives it the right order against everything else:
- Every frame sent before it replays first, so none of them overwrites it.
- A relay queues behind the head, so it lands before the sentence and cannot
erase it either.
- A frame sent after it lands after it, and may replace it. That is the same
order in which the user saw them.
- It settles once everything ahead of it is answered.
Two simpler fixes were rejected:
- **Writing the sentence into `settled` as well:** the next rebase replays the
in-flight frames over it, which puts the sentence *before* frames it
followed.
- **A status channel outside `ViewState`:** this would be a second answer to
"what does the status line say", which every renderer would then have to
merge.
Two simpler shapes were rejected, and each fails the Host-level test:
- **Advancing `settled` directly** gets the order wrong. The head frame's own
commands settle only when it is accepted, which is *after* the relays that
arrived during it. So for `[SetRowOffset(1), <reframe>, SetSelected]`,
`settled` ends at `row_offset 1` while the mirror is at 0, because the reset
was settled before the offset it cancelled.
- **Appending the relay to the head frame's own commands** loses it whenever
that frame is refused. The frame is dropped, and the relay goes with it.
Why "behind the head" is right: the host answers frames in order, so a relay
that arrives before the head's answer happened on the host before or during
the head frame. A relay from the head frame itself (its own reset and echoes)
is only ever sent when that frame is accepted, since `undo_frame` sends no
relays, and it belongs after the frame's commands. A relay from **another**
client's frame that the host processed *before* the head also lands behind the
head, which is one position late. That is a pre-existing pipelining residue
and is not addressed here. A reframe by someone else before the head frame
usually makes the head stale anyway, which the host refuses, and on refusal
the relative order of the two no longer matters.
What this does **not** do is force. A rejected batch now keeps its values and
leaves the user where they were; re-composing the same intent against a current
basis at one keystroke is still `improvise-3al1`, and it is unblocked rather
@@ -2282,8 +2372,9 @@ so that the browser asks the same question rather than a second derivation of
it (design principles §5).
**The decision is Rust's, and the page only obeys it.** `ClientState` observes
the status after every fold (`settle_status`, beside `settle_mode` and
`settle_editor`):
the status wherever the view changes (`settle_status`, beside `settle_mode` and
`settle_editor`). That means every applied command, and also every `rebase()`,
`refused()` and `bootstrap()`, the three paths that rebuild the view wholesale:
- a status that has changed since it was last observed is owned by the editor
open at that moment, or by nothing outside a text-entry mode;
@@ -2302,12 +2393,22 @@ old message stand again beside the new opening. The owner is therefore
recorded when the message is written and cleared when that editor closes, not
recomputed from what is open now.
That clearing has to happen on every path that moves the view, and a relay's
rebase is one of them (improvise-0dbk). A relay heard while a frame is in
flight rebuilds the view by `rebase()`. If `rebase()` does not settle the
status, a rebase that moves the view off the editor leaves the owner in place.
When the user returns to an equal editor, the old message stands again.
`a_rebase_off_the_editor_ends_the_message_standing` is that case.
**A status written outside a fold must be observed where it is written.**
`socket.rs` writes a rejection's sentence *after* `refused()`, so the sentence
survives the rollback. It now writes it through `ClientState::notify`, which
observes it on the spot. Assigning `status_msg` directly leaves it unobserved
until the next fold. By then the editor may have changed, and the message
would be owned by the wrong one.
survives the rollback. It writes it through `ClientState::notify`, which queues
the sentence at the tail of `in_flight` through `fold_outgoing`
(improvise-n0pb). That path applies it at once and runs `settle_status`, so the
editor open at that moment owns the sentence. Assigning `status_msg` directly
leaves it unobserved until the next fold. By then the editor may have changed,
and the message would be owned by the wrong one. A relay's rebase would also
erase a direct write, which is why improvise-n0pb queues it.
**What it inherits from the identity rule above.** An identical message written
again in a *new* editor is not a new write, so it neither stands nor re-shows.
+16
View File
@@ -2306,6 +2306,22 @@ reframes and then *switches views* before placing would reset the view the
client ends on rather than the one it reframed. No resolver composes that frame
today.
The echo has two limits, and each is asserted because each has a plausible
wrong neighbour (improvise-hv0r). **It goes to the asker alone.** A bystander on
the reframed view is owed the reset, but never the asker's placement, since
that would be another client moving where I am looking. **It covers every
placing kind**, not only `SetSelected`. A records `+` commit carries an offset
after its mutation, so an echo narrowed to the selection would leave the
client's offset reset and its mirror's set.
`a_reframing_frame_echoes_only_its_later_placements_and_only_to_the_asker`
asserts the exact relay list for each session. The wdmp test above cannot see
either rival.
The echo also needs the client to *keep* what it hears. A relay is applied to
the mirror at once, so the client's own rollback must not undo it: see
`context/code/clients.md`, "What the host *relays* is settled too"
(improvise-jpba).
What is *not* broadcast is a position: another client's transpose must not move
where I am looking — that is UI and it is mine (improvise-g0f). It may only declare
that where I was looking has stopped meaning anything, which is a fact about the
+112
View File
@@ -5162,6 +5162,35 @@ nothing, so the editor is still open. It asserts that the sentence stands.
- **Rival `direct_write`** (the old `state.view.status_msg = message`): fails
this test. The status is never observed, so it has no owner.
The `notify` this test goes through is #266's (improvise-n0pb), which queues
the sentence through `fold_outgoing`. #279 had its own `notify` that wrote the
status and settled it directly. Merging the two gave duplicate definitions
(E0592), and #279's was deleted (improvise-0dbk). This test is unchanged and
passes against the queued `notify`, because `fold_outgoing` applies the
sentence and runs `settle_status`.
### a_rebase_off_the_editor_ends_the_message_standing
improvise-0dbk, found reviewing #266 against #279. #266's `rebase()` rebuilt
the view without running `settle_status`. The probe:
1. `SetSelected(1,1)`, `ChangeMode(editing)` and `SetStatus`: the note stands.
2. An awaited frame goes out, so the next relay rebases instead of applying.
3. The relay `ResetCursor` rebases the cursor to (0,0), off the editor. The
owner survived this step.
4. `SetSelected(1,1)` goes out and returns to an equal editor. `status_stands()`
was true: the old note stood beside an opening it was not written in.
Red at the merge of `9eb83538` and `57b713cf` (with #279's duplicate `notify`
removed so that the merge compiles). The failure was "an equal editor after a
rebase took the old message back". It is green once `rebase()` calls
`settle_status()`.
- **Rival `rebase_unsettled`** (remove that call): fails only this test.
- The assertion that `in_flight` is non-empty after step 2 is what makes step 3
a rebase. With nothing in flight the relay is simply applied, and `apply`
already settled the status.
**The DOM half.** `harness/tests/header.spec.js`, "a message written beside an
open minibuffer outlives the timer", is the check on the wire from
`model.status_stands` through `shell.setModel` to `toasts.offer`.
@@ -5174,3 +5203,86 @@ Then it presses `Escape` and asserts that the toast hides. The rival
`onModel: (text, stands) => toasts.offer(text)` fails it at the
still-visible-after-ten-seconds assertion, and the node suite stays green
under that rival.
### a_relay_behind_a_refused_frame_settles_before_the_next_answer
improvise-3kfc, found in review of #246 at `542dd9c`. `ClientState::refused`
popped the head of `in_flight` and replayed, but never called `settle_sent`.
So an unawaited entry queued behind the refused head became the new front, still
unsettled. The next answer then popped *it* instead of the frame the answer was
for. #246 made this reachable through relays, which are queued directly behind
the head (improvise-jpba). The probe:
1. Two awaited frames are in flight, placing the cursor at rows 2 and 3.
2. A `ResetCursor` relay arrives and is queued as `[F1, R, F2]`.
3. F1 is refused, leaving `[R, F2]`.
4. F2 is refused.
At `542dd9c` the second refusal popped R, not F2, so the client ended at F2's
refused placement. It failed with `left: (3, 0)` against `(0, 0)`, and `view`
split from `settled`. The fix is one line: `settle_sent()` right after the
`pop_front` in `refused`.
### a_view_only_frame_behind_a_refused_frame_settles_before_the_next_answer
improvise-3kfc, the same root, and one that predates #246. The client's own
view-only frame (`[SetSelected]`, never answered) sits behind an awaited head:
`[F1, U, F2]`. F1 is refused, then F2 is refused. Before the fix the second
refusal popped U and kept F2. It failed with `left: (4, 0)` (F2's refused
placement) against `(2, 0)`, which is U's placement and the one the host
applied. Same fix.
### a_move_sent_before_a_pruning_commit_is_answered_writes_where_the_tui_writes
improvise-xd3q, the ticket's harm end to end: a real `Host`, a `ClientState`,
and the TUI driven key by key through `reduce_key`. Fixture:
North/South/West/East = 1/2/3/4 on one measure, `prune_empty` on, window 4×4.
1. `i Backspace` are sent and answered one at a time.
2. `Enter`, which empties North and prunes its row, is sent, and so are
`Escape` and `j`. **None is answered until all three are out.**
3. The answers are folded in order, through `fold`.
4. The client's cursor must equal the mirror's, and the mirror must equal the
TUI's.
5. `i 5 Enter` then has to leave the two documents identical.
The ticket's repro pipelined a second `Enter`. On this base, without #227, that
Enter is a clearing write the client cannot compose, so it goes `Upstream`
(fy9o's world). `Escape j` exercises the same client mechanism with keys the
client resolves.
**Rival, the fix reverted:** the client ends at `(1,0)` against `(2,0)`. With the
cursor assertion disabled, the document assertion fails as the ticket
described: the browser wrote **West = 35** where the TUI wrote **East = 45**.
This file folds host frames through its own `heard` helper and not through
`deliver`. On #246, `deliver` folds the host's commands with `fold_outgoing`,
which queues them as frames this client sent (reported on #246).
### `socket::tests::a_relay_heard_after_a_refusal_keeps_the_refusals_sentence`
**improvise-n0pb**, found by the #266 review at `e4b5ce90`. The test drives
`Client::fold_frame`, the page's own entry point:
1. Two awaited frames, each carrying its own `SetStatus` (`first`, `second`), go
out through `fold_outgoing`.
2. The first comes back `Rejected(LayoutMoved)`, and the status must be the
refusal's sentence.
3. `Commands([ResetCursor])` arrives while the second frame is still in flight.
The sentence must survive it.
4. The second frame's `Accepted` arrives. The sentence must still be in `view`,
and it must now be in `settled`.
Red at `e4b5ce90` at step 3: the status was `"second"`. The xd3q rebase rebuilt
`view` from `settled` plus the in-flight frames, and the sentence was in
neither, so the second frame's own status was replayed over it. The reviewer's
probe, whose frames carried no status, saw `""` instead. It passed at the base
`790a33ba`, which applied a relay directly.
**Rivals:**
- **The code at `e4b5ce90`** (`view.status_msg = message`): fails at step 3 with
`"second"`.
- **The reviewer's option, writing the sentence into `settled` as well as
`view`:** fails at step 3 the same way, with `"second"`. The rebase replays the
in-flight frame over the settled sentence. That is why the sentence goes to
the tail of the queue rather than into `settled`.
+131
View File
@@ -6208,3 +6208,134 @@ case fails. Second, the ticket's suggested fix, which drops the asker's
reset and the client never heard. That rival also fails
`a_geometry_command_relays_the_cursor_reset_to_the_asker_too` and the
ws-server's `the_other_document_would_also_have_a_cursor_to_invalidate`.
### a_reframing_frame_echoes_only_its_later_placements_and_only_to_the_asker
improvise-hv0r. `a_frame_that_reframes_and_then_places_the_cursor_leaves_the_asker_placed`
pins the wdmp echo for one shape: a single `SetSelected` after the reframe, and no
second session. Two wrong echoes pass it. Echoing to **every** session moves
bystanders to the asker's placement. Echoing **only `SetSelected`** leaves the
client's offsets reset while its mirror's are set, and offsets after a mutation
are reachable through a records `+` commit. This test uses the reviewer's shape:
- A bystander is seated at `(0, 1)`, where a wrongly widened echo would move it.
- The asker sends `[SetRowOffset(1), ChangeMode, AddCategory, SetSelected(1,0), SetColOffset(1)]`.
- The asker must be relayed exactly `[ResetCursor, SetSelected(1,0), SetColOffset(1)]`:
the reset, then the later placements, and not the pre-reset `SetRowOffset`.
- The bystander must be relayed exactly `[ResetCursor]`.
- The asker's mirror must be at `(1, 0)` with offsets `(0, 1)`, and the client's
cursor must equal it.
It passed at the base, since the fix it pins is #230's. Two rivals were each
injected in `Host::mutating_frame`:
- **Echo to every session** (each placement also sent to every other session id)
fails the bystander assertion. The bystander hears the asker's `SetSelected`
and `SetColOffset`.
- **`places_cursor` narrowed to `SetSelected`** fails the asker assertion, with
`[ResetCursor, SetSelected { row: 1, col: 0 }]`.
`a_frame_that_reframes_and_then_places_the_cursor_leaves_the_asker_placed`
passed under both, which is the gap this test closes.
### a_refusal_after_a_reframe_rolls_back_to_where_the_mirror_is
improvise-jpba. `ClientState::refused` rebuilds `view` from `settled` plus
what is still in flight. `settled` used to take only the client's own frames
and never a relay: `ResetCursor`, the deleted-view `SwitchView`, or wdmp's
placement echoes. The host applies each relay to the client's mirror, so after an
accepted reframe the next refused frame put the client back at its pre-reset
cursor while its mirror stayed reset, reopening the split wdmp closed.
The test drives a real `Host` and a real `ClientState` through `fold_outgoing`,
folding every answer: `Commands` through `fold`, `Accepted` through `accepted`,
and `Rejected` through `refused`. The steps:
1. Seat the cursor at `(1, 0)`.
2. Compose a stale `AddCategory("Other")` against the current basis.
3. Send an accepted reframe.
4. Send the stale frame, which is refused (asserted, as setup).
5. Assert that both `client.view` and `client.settled` equal the mirror's cursor.
It runs two shapes: a bare reframe, and the placed frame
`[SetRowOffset(1), ChangeMode, AddCategory, SetSelected(1,0)]`. At the base
the bare shape failed: the client was at `(1, 0)` and the mirror at `(0, 0)`.
Four rivals, each injected in `crates/improvise-protocol/src/client.rs`:
- **The old fold**, which records no relay: the bare shape fails as at the base.
- **Relays advance `settled` directly** (the ticket's first option taken
literally): the bare shape passes, but the placed shape fails with
`row_offset: 1` against the mirror's 0. The reset settled before the frame
whose offset it cancelled.
- **The relay queued in front of the head frame**: the placed shape fails, with
the client at `(0, 0)`.
- **Appending the relay to the head frame's own commands** passes this test. The
host sends a frame's own relays only when it accepts that frame, so here
nothing is dropped. The next test catches this rival.
### a_relay_heard_while_a_frame_is_in_flight_survives_its_refusal
improvise-jpba, the case the test above cannot reach:
1. My frame is in flight.
2. **Another** client's accepted reframe relays me a `ResetCursor`.
3. The host then refuses my frame as stale, and the refusal is the reason it
is stale.
The mirror is reset and the client must stay reset. The test seats the asker
at `(1, 0)`. It folds the stale frame outgoing without sending it, lets a
second client reframe, and folds that reframe's relays into the asker. Only
then does it send the stale frame and fold the refusal. At the base the client
was back at `(1, 0)` while the mirror was at `(0, 0)`. Four rivals:
- **The old fold**: fails the same way.
- **Appending the relay to the head frame's cmds**: fails with the client at
`(1, 0)`. The relay is dropped with the refused frame.
- **The relay queued in front of the head frame**: fails with the client at
`(1, 0)`. The refusal pops the relay instead of the frame.
- **Relays advance `settled` directly**: passes this test, and is caught by the
placed shape of the test above.
So neither test alone separates the fix from all four rivals, and together
they do.
### a_placement_sent_before_a_reframe_is_answered_outlives_its_reset
improvise-xd3q, found in the #227 review. No refusal is involved.
1. The asker sends a reframe: an `AddCategory` followed by `SetSelected (1,0)`.
2. **Before** that frame's answer arrives, it sends a second frame,
`SetSelected (2,0)`.
3. Only then are both answers folded, in order.
The host applies both frames, so the mirror ends at `(2,0)`. The reframe's
answer carries `ResetCursor` and an echo of `(1,0)`. That echo is per frame
(improvise-wdmp), so the second frame's placement is never re-sent. On #246's
head `790a33ba`, `fold` applied the relays straight onto `view` and the client
ended at `(1,0)`. The test also asserts that `settled` agrees with the mirror.
**Rivals:**
- **The fix reverted** (`fold` always applies directly): fails this test and
the lockstep test `a_move_sent_before_a_pruning_commit_is_answered_writes_where_the_tui_writes`,
and nothing else in `improvise-command`'s lib or in lockstep.
- **Apply the relay, then re-apply the frames queued behind it**: *passes*
this test, because `SetSelected` is idempotent. The next test catches it.
### a_view_step_sent_before_a_reframe_is_answered_steps_back_once
improvise-xd3q, the property the test above cannot see: a frame queued behind
a relay is replayed **once**, from `settled`, and is never applied a second
time.
The asker walks Default → Second → Third. It sends a reframe, and then
`ViewBack` before the reframe's answer arrives. The mirror steps back once, to
Second, with `[Default]` on its back stack. The test asserts that the setup
reframe relays `ResetCursor`, because without that there is nothing to replay
behind.
**Rival:** "apply the relay, then re-apply what queued behind it" fails with
`("Default", [])` against `("Second", [Default])`. `ViewBack` ran twice.
Under the fix reverted, this test passes, because a reset does not move the
active view. The two tests together separate the fix from both rivals.
+292
View File
@@ -1853,6 +1853,298 @@ mod tests {
}
}
/// A client connected to `host`, bootstrapped from its own `Snapshot`.
fn connected_client(host: &mut Host) -> (SessionId, improvise_protocol::ClientState) {
let (id, snapshot) = host.connect(10, 10);
let mut client = improvise_protocol::ClientState::default();
match snapshot.msg {
ServerFrame::Snapshot(s) => client.bootstrap(*s),
other => panic!("setup: connect answers a Snapshot, got {other:?}"),
}
(id, client)
}
/// A frame sent the way the page sends one, and every answer folded the way the
/// page folds it: accepted, refused or relayed.
fn exchange(
host: &mut Host,
id: SessionId,
client: &mut improvise_protocol::ClientState,
frame: Vec<Command>,
) -> Vec<Addressed> {
client.fold_outgoing(&frame);
let answer = host.handle(id, ClientFrame::Commands(frame));
answered(&answer, id, client);
answer
}
/// Everything but projections one session heard, in arrival order.
fn relayed_to(frames: &[Addressed], id: SessionId) -> Vec<Command> {
to(frames, id)
.into_iter()
.flat_map(|msg| match msg {
ServerFrame::Commands(cmds) => cmds,
_ => Vec::new(),
})
.filter(|c| !matches!(c, Command::Project(_)))
.collect()
}
/// improvise-hv0r: the echo is the asker's alone, is every placement AFTER the
/// reset and nothing before it. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_reframing_frame_echoes_only_its_later_placements_and_only_to_the_asker() {
use improvise_protocol::AppMode;
let mut host = host();
let (asker, mut client) = connected_client(&mut host);
let (bystander, mut other) = connected_client(&mut host);
// The bystander sits where an echo of the asker's placement would move it.
exchange(
&mut host,
bystander,
&mut other,
vec![Command::SetSelected { row: 0, col: 1 }],
);
let add = ModelCommand::AddCategory("Product".into());
let frame = vec![
Command::SetRowOffset(1),
Command::ChangeMode(AppMode::Normal),
crate::ui::reduce::caught_up(host.app(), add),
Command::SetSelected { row: 1, col: 0 },
Command::SetColOffset(1),
];
let answer = exchange(&mut host, asker, &mut client, frame);
assert_eq!(
relayed_to(&answer, asker),
vec![
Command::ResetCursor,
Command::SetSelected { row: 1, col: 0 },
Command::SetColOffset(1),
],
"the asker is owed the reset, then exactly the placements that followed it"
);
assert_eq!(
relayed_to(&answer, bystander),
vec![Command::ResetCursor],
"a bystander is told its cursor is stale, never where somebody else put theirs"
);
let mirror = host.sessions.get(asker).unwrap().view().cursor();
assert_eq!(
(mirror.selected, mirror.row_offset, mirror.col_offset),
((1, 0), 0, 1)
);
assert_eq!(
client.view.cursor(),
mirror,
"the asker's client and its mirror disagree"
);
}
/// improvise-jpba: a refusal rolled the client back past the relays it had
/// heard, while its mirror kept them. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_refusal_after_a_reframe_rolls_back_to_where_the_mirror_is() {
use improvise_protocol::AppMode;
// A bare reframe, and the ticket's placed shape with a pre-reset offset.
for placed in [false, true] {
let mut host = host();
let (asker, mut client) = connected_client(&mut host);
exchange(
&mut host,
asker,
&mut client,
vec![Command::SetSelected { row: 1, col: 0 }],
);
// Composed now, sent after the reframe: stale, and so refused.
let other = ModelCommand::AddCategory("Other".into());
let stale = crate::ui::reduce::caught_up(host.app(), other);
let product = ModelCommand::AddCategory("Product".into());
let reframe = crate::ui::reduce::caught_up(host.app(), product);
let frame = match placed {
false => vec![Command::ChangeMode(AppMode::Normal), reframe],
true => vec![
Command::SetRowOffset(1),
Command::ChangeMode(AppMode::Normal),
reframe,
Command::SetSelected { row: 1, col: 0 },
],
};
exchange(&mut host, asker, &mut client, frame);
let answer = exchange(&mut host, asker, &mut client, vec![stale]);
assert!(
to(&answer, asker)
.iter()
.any(|m| matches!(m, ServerFrame::Rejected(_))),
"setup, placed={placed}: the stale frame must be refused: {answer:?}"
);
let mirror = host.sessions.get(asker).unwrap().view().cursor();
assert_eq!(
client.view.cursor(),
mirror,
"placed={placed}: the refusal rolled the client back past a relay"
);
assert_eq!(client.settled.cursor(), mirror, "placed={placed}: settled");
}
}
/// 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]
fn a_relay_heard_while_a_frame_is_in_flight_survives_its_refusal() {
use improvise_protocol::AppMode;
let mut host = host();
let (asker, mut client) = connected_client(&mut host);
let (other, mut theirs) = connected_client(&mut host);
exchange(
&mut host,
asker,
&mut client,
vec![Command::SetSelected { row: 1, col: 0 }],
);
let mine = ModelCommand::AddCategory("Other".into());
let mine = vec![crate::ui::reduce::caught_up(host.app(), mine)];
client.fold_outgoing(&mine);
let product = ModelCommand::AddCategory("Product".into());
let reframe = crate::ui::reduce::caught_up(host.app(), product);
let frame = vec![Command::ChangeMode(AppMode::Normal), reframe];
let relayed = exchange(&mut host, other, &mut theirs, frame);
for msg in to(&relayed, asker) {
if let ServerFrame::Commands(cmds) = msg {
cmds.iter().for_each(|c| client.fold(c));
}
}
let answer = host.handle(asker, ClientFrame::Commands(mine));
let refusal = to(&answer, asker).into_iter().find_map(|m| match m {
ServerFrame::Rejected(r) => Some(r),
_ => None,
});
client.refused(refusal.expect("setup: my stale frame must be refused"));
let mirror = host.sessions.get(asker).unwrap().view().cursor();
assert_eq!(
mirror.selected,
(0, 0),
"setup: the other client's reframe reset me"
);
assert_eq!(
client.view.cursor(),
mirror,
"the refusal undid a relay the mirror kept"
);
}
/// A refused batch is answered to the **asker and nobody else** (ADR 001):
/// nothing happened, so there is nothing to tell the others.
#[test]
+144 -11
View File
@@ -57,6 +57,9 @@ pub struct ClientState {
pub struct Sent {
pub cmds: Vec<Command>,
pub awaited: bool,
/// Heard from the host rather than sent: it settles after the frame it
/// arrived during, never with it (improvise-jpba, `clients.md`).
pub relayed: bool,
}
impl ClientState {
@@ -85,9 +88,51 @@ impl ClientState {
self.revision += 1;
}
/// Fold one command. Total and deterministic: a command naming something
/// the client does not have is dropped, never a panic.
/// Fold one command **the host sent**, and keep it for the settled slice.
/// Total: a command naming something absent is dropped, never a panic.
pub fn fold(&mut self, cmd: &Command) {
self.settle_relay(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.settle_status();
self.revision += 1;
}
/// A relay the host has already applied to this client's mirror, so a
/// later refusal must not roll it back. Where it queues: `clients.md`.
fn settle_relay(&mut self, cmd: &Command) {
let behind = 1 + self
.in_flight
.iter()
.skip(1)
.take_while(|s| s.relayed)
.count();
match (cmd, self.in_flight.is_empty()) {
(Command::Project(_), _) => {}
(_, true) => reduce_view(&mut self.settled, cmd),
(_, false) => self.in_flight.insert(
behind,
Sent {
cmds: vec![cmd.clone()],
awaited: false,
relayed: true,
},
),
}
}
fn apply(&mut self, cmd: &Command) {
match cmd {
Command::Project(projection) => self.apply_projection(projection),
other => reduce_view(&mut self.view, other),
@@ -105,13 +150,20 @@ impl ClientState {
self.in_flight.push_back(Sent {
cmds: cmds.to_vec(),
awaited,
relayed: false,
});
self.settle_sent();
for cmd in cmds {
self.fold(cmd);
self.apply(cmd);
}
}
/// 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) {
@@ -130,6 +182,7 @@ impl ClientState {
// one's entry — `Rejection::refused_an_edit` is that question.
if rejection.refused_an_edit() {
self.in_flight.pop_front();
self.settle_sent();
self.replay();
}
self.last_rejection = Some(rejection);
@@ -139,14 +192,6 @@ impl ClientState {
self.revision += 1;
}
/// Say something to this client's user: the one door for a status written
/// outside a fold, so it is observed where it was written.
pub fn notify(&mut self, message: String) {
self.view.status_msg = message;
self.settle_status();
self.revision += 1;
}
/// Whether the status belongs to the editor still open, so a renderer must
/// not expire it (improvise-1x9h). The TUI asks the same `EditorSession`.
pub fn status_stands(&self) -> bool {
@@ -435,6 +480,66 @@ mod tests {
);
}
/// An awaited frame that also places the cursor at `row`.
fn placing_commit(row: usize) -> Vec<Command> {
let mut frame = commit_like("placed");
frame.push(Command::SetSelected { row, col: 0 });
frame
}
/// improvise-3kfc: a relay queued behind a refused head must settle, or the
/// next answer pops it instead of its frame. Evidence: clients.md.
#[test]
fn a_relay_behind_a_refused_frame_settles_before_the_next_answer() {
let mut state = ClientState::default();
state.fold_outgoing(&placing_commit(2));
state.fold_outgoing(&placing_commit(3));
state.fold(&Command::ResetCursor);
state.refused(Rejection::LayoutMoved);
state.refused(Rejection::LayoutMoved);
assert_eq!(
state.view.cursor().selected,
(0, 0),
"both placements were refused"
);
assert_eq!(
state.view.cursor(),
state.settled.cursor(),
"view and settled split"
);
assert!(state.in_flight.is_empty(), "both frames were answered");
}
/// improvise-3kfc, the root on gh/main: this client's own view-only frame
/// behind a refused head. Evidence: clients.md.
#[test]
fn a_view_only_frame_behind_a_refused_frame_settles_before_the_next_answer() {
let mut state = ClientState::default();
state.fold_outgoing(&placing_commit(3));
state.fold_outgoing(&[Command::SetSelected { row: 2, col: 0 }]);
state.fold_outgoing(&placing_commit(4));
state.refused(Rejection::LayoutMoved);
state.refused(Rejection::LayoutMoved);
assert_eq!(
state.view.cursor().selected,
(2, 0),
"the unanswered move stands"
);
assert_eq!(
state.view.cursor(),
state.settled.cursor(),
"view and settled split"
);
assert!(
state.in_flight.is_empty(),
"both awaited frames were answered"
);
}
/// A frame the host does not answer must not consume an answer, or the
/// queue addresses the wrong frame. Evidence file.
#[test]
@@ -499,6 +604,34 @@ mod tests {
);
}
/// A relay that rebases the view off the editor ends its message's ownership,
/// as a fold would (improvise-0dbk). Evidence file.
#[test]
fn a_rebase_off_the_editor_ends_the_message_standing() {
let mut state = ClientState::default();
state.fold(&Command::SetSelected { row: 1, col: 1 });
state.fold(&Command::ChangeMode(crate::AppMode::editing()));
state.fold(&Command::SetStatus("a note about this cell".into()));
assert!(state.status_stands(), "precondition: the note stands");
state.fold_outgoing(&commit_like("unused")[..1]);
assert!(
!state.in_flight.is_empty(),
"the relay must rebase, not apply"
);
state.fold(&Command::ResetCursor);
assert_eq!(
state.view.cursor().selected,
(0, 0),
"the rebase moved the cursor"
);
state.fold_outgoing(&[Command::SetSelected { row: 1, col: 1 }]);
assert!(
!state.status_stands(),
"an equal editor after a rebase took the old message back"
);
}
/// The editor is the one it was WRITTEN in: not any editor, and not the next
/// cell's after a commit advances (improvise-1x9h, sx7h). Evidence file.
#[test]
@@ -239,6 +239,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]
+128 -57
View File
@@ -46,9 +46,7 @@ fn a_locally_typed_character_lands_where_the_host_would_put_it() {
improvise_protocol::mode_label(&mode)
);
};
for cmd in &cmds {
client.fold(cmd);
}
client.fold_outgoing(&cmds);
reduce_key(&mut app, event);
assert_eq!(
@@ -92,9 +90,7 @@ fn a_locally_popped_character_lands_where_the_host_would_put_it() {
improvise_protocol::mode_label(&mode)
);
};
for cmd in &cmds {
client.fold(cmd);
}
client.fold_outgoing(&cmds);
reduce_key(&mut app, event);
assert_eq!(
@@ -237,9 +233,7 @@ fn a_locally_resolved_cursor_move_lands_where_the_host_would_put_it() {
let event = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
match resolve(&client, event) {
KeyOutcome::Local(cmds) => {
for cmd in &cmds {
client.fold(cmd);
}
client.fold_outgoing(&cmds);
}
// At the bottom the move is a no-op and there is nothing to say.
KeyOutcome::Ignored => {}
@@ -310,9 +304,7 @@ fn a_locally_resolved_page_scroll_lands_where_the_host_would_put_it() {
for (press, (event, want_row)) in presses.into_iter().enumerate() {
match resolve(&client, event) {
KeyOutcome::Local(cmds) => {
for cmd in &cmds {
client.fold(cmd);
}
client.fold_outgoing(&cmds);
}
other => panic!("press {press}: the client must answer {event:?}, got {other:?}"),
}
@@ -342,9 +334,7 @@ fn a_locally_resolved_jump_to_the_last_row_agrees_with_the_host() {
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("G must resolve locally");
};
for cmd in &cmds {
client.fold(cmd);
}
client.fold_outgoing(&cmds);
reduce_key(&mut app, event);
assert_eq!(client.view.cursor().selected, (5, 0));
@@ -367,9 +357,7 @@ fn the_g_chord_returns_to_the_first_row_on_both_ends() {
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("{event:?} must resolve locally; an Upstream is a black hole");
};
for cmd in &cmds {
client.fold(cmd);
}
client.fold_outgoing(&cmds);
reduce_key(&mut app, event);
assert_eq!(
client.view.transient_prefix, app.view_state.transient_prefix,
@@ -428,9 +416,7 @@ fn a_locally_resolved_panel_cursor_agrees_with_the_host() {
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("press {press}: the category panel cursor must resolve locally");
};
for cmd in &cmds {
client.fold(cmd);
}
client.fold_outgoing(&cmds);
reduce_key(&mut app, event);
assert_eq!(
client.view.cat_panel_cursor, app.view_state.cat_panel_cursor,
@@ -467,9 +453,7 @@ fn a_locally_resolved_tile_cursor_agrees_with_the_host() {
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("press {press}: the tile cursor must resolve locally");
};
for cmd in &cmds {
client.fold(cmd);
}
client.fold_outgoing(&cmds);
reduce_key(&mut app, event);
assert_eq!(
client.view.tile_cat_idx, app.view_state.tile_cat_idx,
@@ -545,9 +529,7 @@ fn entering_typing_and_cancelling_an_edit_agrees_with_the_host() {
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("{key}: the client must answer this key");
};
for cmd in &cmds {
client.fold(cmd);
}
client.fold_outgoing(&cmds);
reduce_key(&mut app, event);
assert_eq!(
@@ -613,9 +595,7 @@ fn the_buffer_i_seeds_is_the_value_the_host_would_seed() {
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("i on a record cell must resolve locally");
};
for cmd in &cmds {
client.fold(cmd);
}
client.fold_outgoing(&cmds);
reduce_key(&mut app, event);
assert_eq!(
@@ -643,9 +623,7 @@ fn a_locally_committed_edit_leaves_both_ends_in_the_same_place() {
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("{key}: the client must answer this key");
};
for cmd in &cmds {
client.fold(cmd);
}
client.fold_outgoing(&cmds);
reduce_key(&mut app, event);
assert_eq!(
@@ -758,9 +736,7 @@ fn both_ends(app: &mut App, twin: &mut App, client: &mut ClientState, key: &str)
let KeyOutcome::Local(cmds) = resolve(client, event) else {
panic!("{key}: the client must answer this key locally");
};
for cmd in &cmds {
client.fold(cmd);
}
client.fold_outgoing(&cmds);
apply_on(twin, &cmds);
reduce_key(app, event);
@@ -1194,9 +1170,7 @@ fn a_formula_the_client_cannot_parse_diverges_on_the_status_line() {
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("Enter in FormulaEdit resolves here");
};
for cmd in &cmds {
client.fold(cmd);
}
client.fold_outgoing(&cmds);
reduce_key(&mut app, event);
assert_eq!(
@@ -1436,9 +1410,7 @@ fn in_lockstep(app: &mut App, client: &mut ClientState, key: KeyEvent, what: &st
let KeyOutcome::Local(cmds) = resolve(client, key) else {
panic!("{what}: the client must answer this key itself");
};
for cmd in &cmds {
client.fold(cmd);
}
client.fold_outgoing(&cmds);
reduce_key(app, key);
let window = client.window;
let fresh = grid_of(
@@ -1495,9 +1467,7 @@ fn deliver(
match addressed.msg {
improvise_protocol::ServerFrame::Snapshot(snapshot) => client.bootstrap(*snapshot),
improvise_protocol::ServerFrame::Commands(cmds) => {
for cmd in &cmds {
client.fold(cmd);
}
client.fold_outgoing(&cmds);
}
improvise_protocol::ServerFrame::Rejected(r) => client.refused(r),
improvise_protocol::ServerFrame::Accepted => client.accepted(),
@@ -1540,9 +1510,7 @@ fn switching_between_a_records_view_and_a_pivot_view_settles_both_ways() {
improvise_protocol::Command::SwitchView(target.to_string()),
improvise_protocol::Command::ChangeMode(stale),
];
for cmd in &composed {
client.fold(cmd);
}
client.fold_outgoing(&composed);
deliver(
host.handle(session, ClientFrame::Commands(composed)),
session,
@@ -1817,9 +1785,7 @@ fn press(app: &mut App, twin: &mut App, client: &mut ClientState, key: char) {
let KeyOutcome::Local(cmds) = resolve(client, event) else {
panic!("{key}: the client must answer this key locally");
};
for cmd in &cmds {
client.fold(cmd);
}
client.fold_outgoing(&cmds);
apply_on(twin, &cmds);
reduce_key(app, event);
}
@@ -1926,9 +1892,7 @@ fn yy_over_an_aggregate_copies_the_sum_the_host_copies() {
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("y must resolve locally over an aggregate too");
};
for cmd in &cmds {
client.fold(cmd);
}
client.fold_outgoing(&cmds);
reduce_key(&mut app, event);
}
assert_eq!(
@@ -2194,9 +2158,7 @@ fn untouched_browser_edit(value: improvise_protocol::CellValue) {
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("{key}: the client must answer this key locally");
};
for cmd in &cmds {
client.fold(cmd);
}
client.fold_outgoing(&cmds);
apply_on(&mut host, &cmds);
if key == "i" {
assert_eq!(
@@ -2360,3 +2322,112 @@ fn renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host() {
assert_eq!(client.view.status_msg, "Formula updated");
assert_eq!(app.view_state.status_msg, "Renamed Tax to Levy");
}
/// 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"
);
}