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:
+107
-6
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user