Merge #274 (bce496b7, improvise-8xq0 editor commits the opened record) into the editor integration branch
Resolutions: - resolve.rs: dropped #295's record_at_cursor in favour of 8xq0's captured-cell path (commit_value / opened_at_cursor); nothing references it any more. declined_at keeps #292's NonItem arm. #274's two new exhaustive matches (opened_at_cursor, uncaptured) gain a NonItem arm that answers None; refused_here already declines a commit over a NonItem cell when nothing is captured, so uncaptured never reaches it. - client.rs: kept main's status_seen/status_owner beside #274's Opened/OpenedCell/OpenedTarget. - lockstep.rs: #274 made the same sent -> sent_to_host rename #295 did; kept this branch's sent_or_upstream (main already owns sent_to_host). - context/test-evidence/clients.md: kept both sides. Because #274 and #295 both carry the 7mro commits, the three 7mro entries (keys_sent_before_a_scroll_is_answered_agree_with_the_tui, a_held_cell_is_read_at_its_position_and_not_its_cache_index, resolve::tests::a_cursor_on_an_empty_grid_waits_for_nothing) arrived twice. The first two copies were byte-identical; #274's copy of the third also carries its "Amending the entry ... for #274's capture" paragraph, so #274's copies are kept and #295's are removed. Heading union against both parents: nothing missing. 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:
+86
-19
@@ -3191,11 +3191,13 @@ they answer different questions:
|
||||
text it opened on, and whether the user has changed it**. It is portable
|
||||
state. It is composed optimistically at the keypress and folded the same way
|
||||
on the host, where `CommitAndAdvance` reads it.
|
||||
- `ClientState::opened` (`Opened::Target(OpenedCoordinate { record, category,
|
||||
version, seen, document_generation })`, or `Opened::Retry`/`Opened::Gone`
|
||||
after a refused rename, below) answers **which record the rename names, and which
|
||||
record versions and document its basis claims**. It is session-local and
|
||||
never serialized.
|
||||
- `ClientState::opened` (`Opened::Target(OpenedCell { record, target, version,
|
||||
seen, document_generation })`, or `Opened::Retry`/`Opened::Gone` after a
|
||||
refused commit, below) answers **which cell the commit writes, and which
|
||||
record versions and document its basis claims**. `target` is
|
||||
`Coordinate { category }` for a rename and `Value { measure }` for a stored
|
||||
value; `seen` is filled for a rename only. It is session-local and never
|
||||
serialized.
|
||||
|
||||
Neither answers "is an editor open": the mode does, for both. `opened_editor` is
|
||||
not cleared when the editor closes, only replaced when the next one opens.
|
||||
@@ -3212,21 +3214,29 @@ the host's value would have no record in it. Gating the capture on
|
||||
`enter-mode` records no opening, yet the host still commits a rename from it,
|
||||
so the gate would strand that path on "still waiting".
|
||||
|
||||
**A retry after a refusal may rename only the record it named (improvise-20ek,
|
||||
improvise-9ww8).** A refusal replays the view slice, so the editor reopens with
|
||||
the typed text over whatever row the cursor now shows. The rule is about that
|
||||
row:
|
||||
**A retry after a refusal may write only the cell it named (improvise-20ek,
|
||||
improvise-9ww8, improvise-8phw).** A refusal replays the view slice, so the
|
||||
editor reopens with the typed text over whatever cell the cursor now shows. The
|
||||
rule is about that cell, and it holds for a rename and for a value alike:
|
||||
|
||||
- `refused()` pins `ClientState::opened` to `Opened::Retry(record)`, where
|
||||
`record` is the one the refused rename named. The refusal's kind plays no part.
|
||||
- `refused()` pins `ClientState::opened` to `Opened::Retry(record, target)`,
|
||||
naming the cell the refused frame wrote: a rename's record and category, or a
|
||||
`SetCell`/`ClearCell`'s record and measure (`written_to`). The refusal's kind
|
||||
plays no part.
|
||||
- Once the host has answered, `settle_opened` captures what is under the cursor.
|
||||
If that is the same record, it becomes the target at its **current** version,
|
||||
so a second `Enter` goes through. That is the one-keystroke force
|
||||
`RecordMoved` promises (gotcha 43), and the user has just been told someone
|
||||
else changed the row.
|
||||
- If it is any other record, or none, `opened` becomes `Opened::Gone(record)`,
|
||||
and `rename_coordinate` answers `Enter` with a status and no write. Esc
|
||||
leaves the editor, and opening it again captures afresh.
|
||||
If that is the same record **and** the same `OpenedTarget`, it becomes the
|
||||
target at its **current** version, so a second `Enter` goes through. That is
|
||||
the one-keystroke force `RecordMoved` promises (gotcha 43), and the user has
|
||||
just been told someone else changed the row.
|
||||
- If it is anything else, or nothing, `opened` becomes `Opened::Gone`, and
|
||||
`Enter` answers with a status and no write. Esc leaves the editor, and opening
|
||||
it again captures afresh.
|
||||
|
||||
The whole target is compared, not only the record, because a record is a row
|
||||
and a value editor names a cell in it: a crossing `HideItem` on a measure leaves
|
||||
the **same** record under the cursor in another column, and a record-only
|
||||
comparison forces the retry onto a measure the user never typed for. `seen`
|
||||
stays rename-only, because only a rename has a destination to claim.
|
||||
|
||||
**Why the rule names the record rather than a rejection.** The first version
|
||||
(20ek) pinned only on `RecordGone` of the renamed record. But `stale_basis`
|
||||
@@ -3251,7 +3261,7 @@ destination as well, and the destination has to answer to the basis. The
|
||||
server decides that (`records_at_stake`, `context/code/command.md`). The client
|
||||
cannot name the destination: it would need the hash of coordinates it has never
|
||||
been sent, which ADR 002 forbids. So it claims **every record counter it held
|
||||
when the editor opened**, captured into `OpenedCoordinate::seen` at the same
|
||||
when the editor opened**, captured into `OpenedCell::seen` at the same
|
||||
moment as the target. Two consequences follow:
|
||||
|
||||
- If another client writes the destination while the editor is open, the rename
|
||||
@@ -3260,3 +3270,60 @@ moment as the target. Two consequences follow:
|
||||
- If the destination is a record this client was never shown (outside its
|
||||
window, say), the rename is refused with `RecordUnstated`. Overwriting a
|
||||
record you have not seen is exactly the lost update the check exists to stop.
|
||||
|
||||
**A value editor commits to the cell it opened on, too (improvise-8xq0).** The
|
||||
keyboard commit over a plain grid cell used to read the cursor at `Enter`: the
|
||||
record from `record_at_cursor`, the measure from the `CellId` under it, and the
|
||||
basis from `claim`, which reads the version as it is *now*. Another client's
|
||||
`HideItem` renumbers the rows under an open editor, the cursor then names a
|
||||
different cell, and the write landed there with the text typed for the first.
|
||||
The inline `EditTarget` fixed this for the mouse editor (improvise-2wu3), but it
|
||||
lives in the page, so the keyboard path never saw it. The fix reuses the rename
|
||||
capture rather than adding a second one: `settle_opened` captures a `Record`
|
||||
cell as `OpenedTarget::Value`, and `written` builds the `RecordCell` and the
|
||||
`claim_as_read` basis from it. Nothing about the write is read at `Enter` except
|
||||
the text. **The capture reads the cell through the frame's own origin (improvise-7mro).**
|
||||
`captured()` asks `opened_at_cursor`, which asks `cell_at_cursor`, and since
|
||||
#286 that reads through `projected_at` (`AxisFrame::first`), never through the
|
||||
cursor's offsets. That matters more here than for any other reader, because the
|
||||
capture *sticks*: `settle_opened` takes it as soon as no awaited write is in
|
||||
flight, which includes the fold of the client's own outgoing frame. An untouched
|
||||
`Enter` that scrolls moves the offsets before the host has sent the new window,
|
||||
so reading through the offsets captured the old window's cell at the same cache
|
||||
index (Jan/Revenue where the TUI writes Feb/Revenue, with every reply delivered),
|
||||
and every later `Enter` wrote it. Read through the origin, a cell outside the
|
||||
held window is `None`, `settle_opened` simply tries again on the next fold, and
|
||||
the capture lands once the host projects the cell. #274 took #286 by merge
|
||||
rather than porting the reader; the PR body says why.
|
||||
|
||||
**A captured commit is not judged by the cursor's cell either (improvise-3h9w).**
|
||||
#286's `refused_here` declines a commit key whose cursor cell `i` would decline,
|
||||
and waits on one that is unprojected. It runs in `step()` before `commit()`,
|
||||
so it must skip an editor that captured its target: that commit writes, or
|
||||
basis-refuses, the captured cell, whatever now sits under the cursor. The guard
|
||||
is kept for an editor that captured nothing, which is the case it was written
|
||||
for.
|
||||
|
||||
**What the commit is follows the capture too.** Whether `Enter` writes a value
|
||||
or renames a coordinate is read off `opened.target()`, not off the cell under
|
||||
the cursor (ChatGPT Codex on PR #274): a relayed reframe can leave the cursor on
|
||||
no cell, on a creating column, or on a records grid, and dispatching on that
|
||||
sent a captured value editor's `Enter` upstream or down the wrong path. Only an
|
||||
editor that captured nothing (`uncaptured`) is dispatched by the cursor, and
|
||||
that is the creating columns plus the waiting cases. A records grid's measure
|
||||
cell is deliberately not captured as a value, since the browser still declines
|
||||
editing it (gotcha 1); `every_advertised_command_answers_in_its_own_mode`
|
||||
fails if it is.
|
||||
|
||||
Two things come with it. **The version is the one read at open**, so a write by
|
||||
another client while the editor is open is refused `RecordMoved` on this path
|
||||
as well: improvise-0j0f's rule, which the keyboard path had not had
|
||||
(improvise-xz2z). And **a
|
||||
value commit can now wait**: the capture waits for the host's answer to this
|
||||
client's own last write, so `Enter` pressed again inside one round trip has no
|
||||
target yet. It declines with the rename's "still waiting" status and writes
|
||||
nothing. With no write in flight and no capture, the cursor is on no cell an
|
||||
editor writes, and the commit answers `None` as before. The waiting decline is
|
||||
still followed by the `Enter` binding's welded `clear-buffer`, so the typed
|
||||
text is lost on that path, for a rename as for a value (improvise-joed).
|
||||
|
||||
|
||||
@@ -622,8 +622,8 @@ it, and the address it needs is the name.
|
||||
What was missing was therefore never an address but a **recognition**. `Create`
|
||||
carries the kind and nothing else, because the kind plus the typed name is the
|
||||
whole operand. It is not a `SetCell` target, so `render.rs::editable` stays
|
||||
`Record`-only and `record_at_cursor` still answers `None` — both by matching,
|
||||
which the compiler now forces.
|
||||
`Record`-only and `opened_at_cursor` captures no editor target for it — both
|
||||
by matching, which the compiler now forces.
|
||||
|
||||
**No client composes it yet**, and that is one gap rather than this one:
|
||||
`keys.rs::commit` returns early for every records-mode commit, because a
|
||||
@@ -667,9 +667,9 @@ added by the server's `records_at_stake` rather than here, because only the
|
||||
document can derive it (improvise-milr, `context/code/command.md`). Creating the item is internal to the
|
||||
effect. `render.rs::editable` still answers `false` for a coordinate: the page's
|
||||
inline editor writes stored cells only, and the rename's producer is the
|
||||
keyboard path (`context/code/clients.md`). `typed_cell_command`,
|
||||
`record_at_cursor` and `record_cell_at_cursor` still answer `None` for it, so
|
||||
paste onto a coordinate is still improvise-kkcy.
|
||||
keyboard path (`context/code/clients.md`). `typed_cell_command` and
|
||||
`record_cell_at_cursor` still answer `None` for it, so paste onto a coordinate
|
||||
is still improvise-kkcy.
|
||||
|
||||
### `CellDisplay::id` — `None` is not "aggregate"
|
||||
|
||||
|
||||
@@ -6047,7 +6047,7 @@ probe, whose frames carried no status, saw `""` instead. It passed at the base
|
||||
Added when #221 merged #219 (improvise-vndi). vndi changed `claim_as_read` to
|
||||
take the document generation captured when an editor opened, so an inline edit
|
||||
opened before a `LoadModel` is refused `DocumentReplaced` rather than written
|
||||
into the new document. The rename capture (`OpenedCoordinate`) now records
|
||||
into the new document. The rename capture (`OpenedCoordinate`, now `OpenedCell`) records
|
||||
`document_generation` at open for the same reason, and the rename claims it.
|
||||
|
||||
**No rival of that choice is observable, and this test says why.** B loads a
|
||||
@@ -6074,6 +6074,75 @@ produced. The test's principle is unchanged: the second rename moves the row
|
||||
the client shows, and that row differs from the stale capture (West). Only its
|
||||
value table assumed Zed could not come up, so Zed was added to it.
|
||||
|
||||
### a_keyboard_edit_writes_the_cell_it_opened_on_after_the_rows_renumber
|
||||
|
||||
improvise-8xq0, the ticket's probe, through a real `Host` with two clients. A
|
||||
presses `l`, `i` on North/Cost at (0,1), and types `42`. B hides North, and A
|
||||
folds the relayed frames, so the rows renumber under the open editor. A then
|
||||
presses `Enter`. `resolve.rs::commit` composed `SetCell` from the cell under
|
||||
the cursor **at Enter**. The cursor had been reset to the origin, which now
|
||||
held South/Revenue, so South/Revenue became 42 and the host accepted it. That
|
||||
failed at the `South was written` assertion, on `gh/main` at `202e7eac` and on
|
||||
#221's head `39821d5f` alike, so #221's coordinate capture did not reach this
|
||||
path.
|
||||
|
||||
The editor opens on North/**Cost**, not the first column, so the cell at Enter
|
||||
differs from the opened one in both record and measure. A fix that kept the
|
||||
record and read the measure at Enter would still write the wrong column.
|
||||
|
||||
- **Rival: `written` takes the whole cell from the cursor at Enter** (the bug).
|
||||
Fails only this test, at `South was written`.
|
||||
- **Rival: the record from the capture, the measure from the cursor.** Fails
|
||||
only this test, at `the edit must land on the cell the editor opened on`:
|
||||
North/Cost stays empty.
|
||||
- **Rival: the basis claimed at commit (`claim`) instead of as read.** Fails
|
||||
this test too: after the hide, A's basis no longer names North's record, so
|
||||
`claim` leaves it out and the host answers `RecordUnstated`.
|
||||
|
||||
### a_keyboard_edit_is_refused_when_its_record_was_written_while_open
|
||||
|
||||
improvise-xz2z: improvise-0j0f's rule on the keyboard path, which it had not
|
||||
had. xz2z was filed separately (by agent-227-fixes, scoping improvise-3al1) and
|
||||
has the same cause as 8xq0: `written` read its operands at `Enter`. Same setup,
|
||||
but B writes 999 into North/Revenue while A's editor is open on North/Cost. A
|
||||
record is the coordinate tuple minus `_Measure` (gotcha 40), so that write
|
||||
moves the version of the record A is editing. The keyboard commit claimed the
|
||||
version current at Enter, which A had already folded, so the host accepted it.
|
||||
It failed at the `RecordMoved` assertion, on `gh/main` and on #221's head.
|
||||
|
||||
- **Rival: the basis claimed at commit instead of as read.** Fails this test
|
||||
(and the renumber test, above).
|
||||
|
||||
### a_second_keyboard_commit_writes_the_cell_the_advance_landed_on
|
||||
|
||||
A regression guard for the capture's lifetime, which the fix made
|
||||
load-bearing for value cells. `i`, `5`, `Enter` on North/Revenue writes 12005,
|
||||
advances to South/Revenue and reopens the editor there; `7`, `Enter` must write
|
||||
South/Revenue. The capture is dropped when the commit's `ChangeMode(Normal)`
|
||||
folds and retaken for the reopened editor once the host answers.
|
||||
|
||||
- **Rival: `settle_opened` keeps a capture after the editor closes.** Fails
|
||||
this test, the second commit writing North again. Before this test only #221's
|
||||
two coordinate tests saw that rival
|
||||
(`a_second_rename_after_a_reorder_renames_the_row_the_host_shows` and
|
||||
`a_document_swap_closes_an_open_rename_editor`); nothing on a value cell did.
|
||||
|
||||
### a_value_commit_before_the_host_answers_the_last_one_waits_out_loud
|
||||
|
||||
With the value target captured only after the host answers this client's own
|
||||
last write, `Enter` pressed again before that answer has no target. The test
|
||||
commits once, folds the frame as sent and not yet answered, and presses
|
||||
`Enter` again in the reopened editor. The fixture's second row is given a
|
||||
`Record` identity, or `commit` stops at the cursor's cell before it reaches the
|
||||
capture and the test would pass for the wrong reason.
|
||||
|
||||
- **Rival: no waiting decline (`None`, as for a cell no editor writes).** Fails
|
||||
only this test: the key goes upstream, which in a browser does nothing.
|
||||
|
||||
The test asserts the status and that nothing is written, not the exact
|
||||
command list: the `Enter` binding's welded `clear-buffer` still runs after the
|
||||
decline and empties the buffer. That loss is improvise-joed.
|
||||
|
||||
**Amending the entry for `a_document_swap_closes_an_open_rename_editor` above.**
|
||||
On the 20ek/milr branch the reviewer's rival `never_clear_opened`, which keeps
|
||||
the capture through every refusal and every return to `Normal`, fails this test
|
||||
@@ -6141,6 +6210,161 @@ subsumes it: after `RecordGone(r)` the capture can never be `r`, so it becomes
|
||||
no test, which is how the redundancy was found. The test's live rivals are now
|
||||
`no_pin_at_all` and `retry_any_capture`.
|
||||
|
||||
### a_keyboard_edit_retried_after_a_crossing_hide_writes_no_other_cell
|
||||
|
||||
improvise-8phw, the value twin of improvise-9ww8, found reviewing PR #274 at
|
||||
`7bf8debd`. A presses `l i 4 2` on North/Cost. B hides North, and **B's relays
|
||||
reach A only after A has sent `Enter`**, which is ordinary latency. The host
|
||||
refuses A's frame `LayoutMoved`. The refusal replays the view, the editor
|
||||
reopens at (0,1), and `settle_opened` recaptured what was now there, South/Cost.
|
||||
The retry `Enter` was accepted and wrote 42 into South/Cost. It failed at
|
||||
`South/Cost was written`. The 8xq0 test cannot see this: it delivers the hide
|
||||
before `Enter`, so the capture from open time is still in use.
|
||||
|
||||
The test also asserts the retry's status. Without that, a decline that goes
|
||||
silently upstream would pass.
|
||||
|
||||
- **Rival: `written_to` recognises renames only** (#251 as it was). Fails this
|
||||
test and the column-hide test below.
|
||||
- **Rival: a `Gone` value editor answers `None`**, which goes upstream. Fails
|
||||
only this test, at the status assertion.
|
||||
|
||||
### a_keyboard_edit_retried_after_a_crossing_column_hide_writes_no_other_measure
|
||||
|
||||
improvise-8phw, the measure half. A opens on North/Revenue at (0,0) and types.
|
||||
B's crossing `HideItem` hides the Revenue *measure*, so after the refusal the
|
||||
cursor sits on North/Cost: the same **record**, another cell. This is the input
|
||||
that separates "compare the whole `OpenedTarget`" from "compare the record",
|
||||
which the row-hide test above cannot do, since there the record differs too.
|
||||
|
||||
- **Rival: `Retry` compares the record only.** Fails only this test: the retry
|
||||
writes the typed text into North/Cost.
|
||||
|
||||
### a_keyboard_edit_retried_after_a_crossing_write_forces_the_same_cell
|
||||
|
||||
improvise-8phw's control. B's crossing write to North/Revenue moves the
|
||||
version of A's record, and the refusal is `RecordMoved`. The recapture is
|
||||
North/Cost again, so the retry must go through: the one-keystroke force of
|
||||
gotcha 43. A fix that declined every retry would fail here. It passed before
|
||||
the fix too; it is a guard, not a reproduction.
|
||||
|
||||
### a_keyboard_edit_whose_rows_all_vanished_writes_the_cell_it_opened_on
|
||||
|
||||
ChatGPT Codex's P2 on PR #274 at `7bf8debd`. `commit` picked the operation from
|
||||
the cell under the cursor before it consulted the capture. B hides both regions
|
||||
while A's editor is open on North/Cost and A folds the relays, so there is no
|
||||
cell under the cursor at all. `Enter` then answered `None`, which goes upstream
|
||||
and does nothing in a browser. The capture was intact the whole time. It failed
|
||||
at the `Accepted` assertion.
|
||||
|
||||
- **Rival: dispatch on the cursor's cell** (the code before the fix). Fails only
|
||||
this test.
|
||||
- **Rival: capture a records grid's measure cell as a value.** Fails
|
||||
`every_advertised_command_answers_in_its_own_mode`: `RECORDS INSERT` `Enter`
|
||||
would start writing a cell the browser deliberately declines.
|
||||
|
||||
### keys_sent_before_a_scroll_is_answered_agree_with_the_tui
|
||||
|
||||
improvise-7mro, the offset-aliasing matrix from the review of #282. The fixture
|
||||
is `wrapping_host`: `_Measure` rows Revenue, Profit (stored and a formula),
|
||||
Cost, and Month columns Jan, Feb. Seven cases: the wrap, a horizontal wrap that
|
||||
scrolls both axes, `G`, `$`, `gg`, `$ x` and `yy gg p`. Each runs three ways:
|
||||
every reply delivered before the next key, every reply held to the end, and
|
||||
replies held until the last two keys. Each run compares the host's document,
|
||||
the cursor and the mode with the TUI. The delivered and held-then-typed runs
|
||||
also compare the whole document text and the edit buffer.
|
||||
|
||||
With every reply held, the browser cannot know the seed, so the held run asserts
|
||||
something narrower. No write is composed before its target is projected. The
|
||||
last key waits out loud, and an editor is open exactly when the keys contain
|
||||
`i`. After the replies land, repeating the last key writes the cell the TUI
|
||||
wrote.
|
||||
|
||||
Red at `6942e53e` (#282), at gh/main `202e7eac` and at #274's `8d9de94e`. `$`
|
||||
held wrote **Jan/Revenue**. `G` and `gg` held refused Cost with Profit's formula
|
||||
refusal. The wrap and the horizontal wrap sent Enter `Upstream`, silently. At
|
||||
#274's head, the horizontal wrap with replies **delivered** also wrote
|
||||
Jan/Revenue, because its capture reads `cell_at_cursor`.
|
||||
|
||||
**Rivals**, each failing here:
|
||||
- `edit_or_drill` without the `unprojected` arm fails `G` held: Normal, no
|
||||
status.
|
||||
- `refused_here` without the wait on a commit fails the wrap held: Enter
|
||||
silent.
|
||||
- `refused_here` without the `clear-cell`/`yank`/`paste` arm fails `$ x` held.
|
||||
- No `settle_awaited_seed` fails `G` held-then-typed: `7` where the TUI has `47`.
|
||||
- A settle that accepts a cell with no id fails the same case. It seeded the
|
||||
blank a relabel leaves between the frame and its cells.
|
||||
- `cell_at_cursor` through the cursor offsets fails `G` held-then-typed here,
|
||||
and the test below.
|
||||
|
||||
### a_held_cell_is_read_at_its_position_and_not_its_cache_index
|
||||
|
||||
improvise-7mro, the second form of the aliasing. `four_by_two_host` has four
|
||||
regions by two measures, and no two cells are equal. The client's window is
|
||||
rows 2 and 3. `k k j` puts the cursor on row 2, which the cache holds, with row
|
||||
offset 1, so the offsets differ from the frame's origin. Every reply is held.
|
||||
Two key runs, `i 7 Enter` and `i Tab`, are compared with the TUI: the document,
|
||||
the cursor, the buffer and `opened_editor`.
|
||||
|
||||
**Rivals:** `cell_at_cursor` through the cursor offsets fails on
|
||||
`i 7 Enter`: it seeds and writes R3 where the TUI writes R2. `seed_at` through
|
||||
the cursor offsets fails on `opened_editor`: the reopened editor's seed comes
|
||||
from R3. The `#282` settle masks a bare `i` here: a Profit-like decline would
|
||||
close it. That is why the fixture has no formula and the test goes on to
|
||||
commit.
|
||||
|
||||
**Amended when #227 (with #272, improvise-p6v6) merged #295.** Under p6v6 the
|
||||
editor that `i 7 Enter` reopens awaits the seed, because its own write is still
|
||||
unanswered. With every reply held, its buffer is `""` where the TUI's is `"13"`.
|
||||
So the test now reads the editor twice:
|
||||
|
||||
- **Before delivery**, only when the editor is not awaiting. That is the `i Tab`
|
||||
run, whose reopen reads `seed_at` at once.
|
||||
- **After delivering the held replies**, always.
|
||||
|
||||
The `seed_at` rival still fails, on `i Tab` before delivery (`"23"` against
|
||||
`"22"`), and fails nothing else.
|
||||
|
||||
### resolve::tests::a_cursor_on_an_empty_grid_waits_for_nothing
|
||||
|
||||
improvise-7mro. `unprojected` asks whether the position is inside the grid's
|
||||
extent, not only outside both frames. An empty grid has empty frames, so
|
||||
without the extent every position is outside them. **Rival:** drop the extent
|
||||
guard. `i` then opens an editor awaiting a seed that no cell will ever supply.
|
||||
|
||||
**Amending the entry for `keys_sent_before_a_scroll_is_answered_agree_with_the_tui`
|
||||
above, for #274's capture.** When #286 was merged into #274's stack, this matrix
|
||||
became the red test for the capture: at #274's `8d9de94e` its "horizontal wrap"
|
||||
case with replies **delivered** wrote Jan/Revenue where the TUI writes
|
||||
Feb/Revenue. That case is green on #286 alone, because only #274 makes a
|
||||
captured cell stick. On the merged tree the rival that reads the capture
|
||||
through the cursor's offsets (`opened_at_cursor` through `row_offset`/`col_offset`
|
||||
rather than `cell_at_cursor`) fails exactly that case, with `["Jan/Revenue"]`
|
||||
against `["Feb/Revenue"]`.
|
||||
|
||||
### a_captured_edit_commits_when_the_cursor_now_sits_on_an_aggregate
|
||||
|
||||
improvise-3h9w, a regression from merging #286 into #274, found by ChatGPT Codex
|
||||
and the delta review at `8c28360f`. `step()` runs `refused_here` before
|
||||
`commit()` consults the capture, and for the commit keys `refused_here` judged
|
||||
the **cursor's** cell. A opens an editor on North/Cost and types `42`. B moves
|
||||
Region to None, the relays reset A's cursor, and it now sits on an aggregate.
|
||||
A's `Enter` was declined with "Cannot write to an aggregate" on every retry, no
|
||||
frame was sent, and Esc lost the text, where `8d9de94e` wrote North/Cost. It
|
||||
failed at the North/Cost assertion with `None`.
|
||||
|
||||
The vanished-rows test could not see this, because `unprojected` exempts an
|
||||
empty grid, so no guard fired there. This case needs the cell under the cursor
|
||||
to change **kind** while it stays projected.
|
||||
|
||||
- **Rival: `refused_here` without the `state.opened.is_some()` arm** (the state
|
||||
before the fix). Fails only this test.
|
||||
- **Not covered:** a captured editor whose cursor lands on a cell that is
|
||||
inside the grid but outside the held window. The same arm exempts it, but no
|
||||
test builds it, because every reframe here resets the cursor to the origin,
|
||||
which is always inside the window.
|
||||
|
||||
### `a_commit_that_prunes_its_row_reopens_on_the_row_that_moved_up`
|
||||
|
||||
**improvise-3uzg**, found by the #227 review at `a093d126`. Fixture:
|
||||
@@ -6256,76 +6480,6 @@ in-flight check. mdfc had put that check inside `advanced` alone.
|
||||
commit being resolved is not in `in_flight` yet, so `opened_on` cannot see its
|
||||
write.
|
||||
|
||||
### keys_sent_before_a_scroll_is_answered_agree_with_the_tui
|
||||
|
||||
improvise-7mro, the offset-aliasing matrix from the review of #282. The fixture
|
||||
is `wrapping_host`: `_Measure` rows Revenue, Profit (stored and a formula),
|
||||
Cost, and Month columns Jan, Feb. Seven cases: the wrap, a horizontal wrap that
|
||||
scrolls both axes, `G`, `$`, `gg`, `$ x` and `yy gg p`. Each runs three ways:
|
||||
every reply delivered before the next key, every reply held to the end, and
|
||||
replies held until the last two keys. Each run compares the host's document,
|
||||
the cursor and the mode with the TUI. The delivered and held-then-typed runs
|
||||
also compare the whole document text and the edit buffer.
|
||||
|
||||
With every reply held, the browser cannot know the seed, so the held run asserts
|
||||
something narrower. No write is composed before its target is projected. The
|
||||
last key waits out loud, and an editor is open exactly when the keys contain
|
||||
`i`. After the replies land, repeating the last key writes the cell the TUI
|
||||
wrote.
|
||||
|
||||
Red at `6942e53e` (#282), at gh/main `202e7eac` and at #274's `8d9de94e`. `$`
|
||||
held wrote **Jan/Revenue**. `G` and `gg` held refused Cost with Profit's formula
|
||||
refusal. The wrap and the horizontal wrap sent Enter `Upstream`, silently. At
|
||||
#274's head, the horizontal wrap with replies **delivered** also wrote
|
||||
Jan/Revenue, because its capture reads `cell_at_cursor`.
|
||||
|
||||
**Rivals**, each failing here:
|
||||
- `edit_or_drill` without the `unprojected` arm fails `G` held: Normal, no
|
||||
status.
|
||||
- `refused_here` without the wait on a commit fails the wrap held: Enter
|
||||
silent.
|
||||
- `refused_here` without the `clear-cell`/`yank`/`paste` arm fails `$ x` held.
|
||||
- No `settle_awaited_seed` fails `G` held-then-typed: `7` where the TUI has `47`.
|
||||
- A settle that accepts a cell with no id fails the same case. It seeded the
|
||||
blank a relabel leaves between the frame and its cells.
|
||||
- `cell_at_cursor` through the cursor offsets fails `G` held-then-typed here,
|
||||
and the test below.
|
||||
|
||||
### a_held_cell_is_read_at_its_position_and_not_its_cache_index
|
||||
|
||||
improvise-7mro, the second form of the aliasing. `four_by_two_host` has four
|
||||
regions by two measures, and no two cells are equal. The client's window is
|
||||
rows 2 and 3. `k k j` puts the cursor on row 2, which the cache holds, with row
|
||||
offset 1, so the offsets differ from the frame's origin. Every reply is held.
|
||||
Two key runs, `i 7 Enter` and `i Tab`, are compared with the TUI: the document,
|
||||
the cursor, the buffer and `opened_editor`.
|
||||
|
||||
**Rivals:** `cell_at_cursor` through the cursor offsets fails on
|
||||
`i 7 Enter`: it seeds and writes R3 where the TUI writes R2. `seed_at` through
|
||||
the cursor offsets fails on `opened_editor`: the reopened editor's seed comes
|
||||
from R3. The `#282` settle masks a bare `i` here: a Profit-like decline would
|
||||
close it. That is why the fixture has no formula and the test goes on to
|
||||
commit.
|
||||
|
||||
**Amended when #227 (with #272, improvise-p6v6) merged #295.** Under p6v6 the
|
||||
editor that `i 7 Enter` reopens awaits the seed, because its own write is still
|
||||
unanswered. With every reply held, its buffer is `""` where the TUI's is `"13"`.
|
||||
So the test now reads the editor twice:
|
||||
|
||||
- **Before delivery**, only when the editor is not awaiting. That is the `i Tab`
|
||||
run, whose reopen reads `seed_at` at once.
|
||||
- **After delivering the held replies**, always.
|
||||
|
||||
The `seed_at` rival still fails, on `i Tab` before delivery (`"23"` against
|
||||
`"22"`), and fails nothing else.
|
||||
|
||||
### resolve::tests::a_cursor_on_an_empty_grid_waits_for_nothing
|
||||
|
||||
improvise-7mro. `unprojected` asks whether the position is inside the grid's
|
||||
extent, not only outside both frames. An empty grid has empty frames, so
|
||||
without the extent every position is outside them. **Rival:** drop the extent
|
||||
guard. `i` then opens an editor awaiting a seed that no cell will ever supply.
|
||||
|
||||
### tab_in_an_editor_commits_and_lands_one_column_right
|
||||
|
||||
improvise-rq3f, found in the adversarial review of PR #246. Changing `advanced`'s
|
||||
|
||||
@@ -50,33 +50,52 @@ pub struct ClientState {
|
||||
pub status_seen: String,
|
||||
#[serde(skip)]
|
||||
pub status_owner: Option<EditorSession>,
|
||||
/// The coordinate an open editor renames, captured as the user saw it.
|
||||
/// The cell an open editor commits to, captured as the user saw it.
|
||||
/// When it is taken, and when not yet: `context/code/clients.md`.
|
||||
#[serde(skip)]
|
||||
pub opened: Option<Opened>,
|
||||
}
|
||||
|
||||
/// What an open coordinate editor renames. After a refusal the retry may name
|
||||
/// only the record it named (`Retry`) or none (`Gone`): improvise-20ek, -9ww8.
|
||||
/// What an open cell editor commits to. After a refusal the retry may name only
|
||||
/// the cell it named (`Retry`) or none (`Gone`): improvise-20ek, -9ww8, -8phw.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Opened {
|
||||
Target(OpenedCoordinate),
|
||||
Retry(RecordHash),
|
||||
Gone(RecordHash),
|
||||
Target(OpenedCell),
|
||||
Retry(RecordHash, OpenedTarget),
|
||||
Gone(RecordHash, OpenedTarget),
|
||||
}
|
||||
|
||||
/// A coordinate editor's target, and every record counter this client held when
|
||||
/// it opened: the destination is among them if shown (improvise-fdk1, -milr).
|
||||
impl Opened {
|
||||
/// Which kind of cell the editor commits to, whether or not it is captured.
|
||||
pub fn target(&self) -> &OpenedTarget {
|
||||
match self {
|
||||
Opened::Target(cell) => &cell.target,
|
||||
Opened::Retry(_, target) | Opened::Gone(_, target) => target,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A cell editor's target: the record, which cell of it, the record's write
|
||||
/// counter at open, and (for a rename) every counter shown: improvise-8xq0, -milr.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpenedCoordinate {
|
||||
pub struct OpenedCell {
|
||||
pub record: RecordHash,
|
||||
pub category: String,
|
||||
pub target: OpenedTarget,
|
||||
pub version: u64,
|
||||
pub seen: Vec<(RecordHash, u64)>,
|
||||
/// The document the editor opened on, so a swap refuses it (improvise-vndi).
|
||||
pub document_generation: u64,
|
||||
}
|
||||
|
||||
/// Which cell of the record an editor opened on, as the projection named it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OpenedTarget {
|
||||
/// A stored value: `SetCell`/`ClearCell` names it (improvise-8xq0).
|
||||
Value { measure: Option<String> },
|
||||
/// A records-mode coordinate: `RenameRecordCoordinate` names it.
|
||||
Coordinate { category: String },
|
||||
}
|
||||
|
||||
/// One frame this client has sent. `awaited` is true exactly when the host
|
||||
/// will answer it, which is exactly when it can be refused — `clients.md`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -216,7 +235,7 @@ impl ClientState {
|
||||
let dropped = self.in_flight.pop_front();
|
||||
self.settle_sent();
|
||||
self.replay();
|
||||
self.opened = renamed(dropped.as_ref()).map(Opened::Retry);
|
||||
self.opened = written_to(dropped.as_ref()).map(|(r, t)| Opened::Retry(r, t));
|
||||
}
|
||||
self.last_rejection = Some(rejection);
|
||||
self.settle_mode();
|
||||
@@ -265,31 +284,46 @@ impl ClientState {
|
||||
self.view.mode = settled_mode(&self.view.mode, self.grid.records);
|
||||
}
|
||||
|
||||
/// Capture the coordinate an open editor renames, but only from a cache that
|
||||
/// Whether a frame the host will answer is still in flight, so this
|
||||
/// client's cache does not yet show the host's answer to its own writes.
|
||||
pub(crate) fn awaiting_answer(&self) -> bool {
|
||||
self.in_flight.iter().any(|sent| sent.awaited)
|
||||
}
|
||||
|
||||
/// Capture the cell an open editor commits to, but only from a cache that
|
||||
/// shows the host's answer to this client's own writes (improvise-fdk1).
|
||||
fn settle_opened(&mut self) {
|
||||
let answered = !self.in_flight.iter().any(|sent| sent.awaited);
|
||||
let answered = !self.awaiting_answer();
|
||||
self.opened = match (self.view.mode.is_editing(), self.opened.take()) {
|
||||
(false, _) => None,
|
||||
(true, Some(Opened::Retry(record))) if answered => Some(match self.captured() {
|
||||
Some(target) if target.record == record => Opened::Target(target),
|
||||
_ => Opened::Gone(record),
|
||||
}),
|
||||
(true, Some(Opened::Retry(record, target))) if answered => {
|
||||
Some(match self.captured() {
|
||||
Some(cell) if cell.record == record && cell.target == target => {
|
||||
Opened::Target(cell)
|
||||
}
|
||||
_ => Opened::Gone(record, target),
|
||||
})
|
||||
}
|
||||
(true, Some(opened)) => Some(opened),
|
||||
(true, None) if answered => self.captured().map(Opened::Target),
|
||||
(true, None) => None,
|
||||
};
|
||||
}
|
||||
|
||||
/// The coordinate under the cursor, with every counter this client holds.
|
||||
fn captured(&self) -> Option<OpenedCoordinate> {
|
||||
let (record, category) = crate::resolve::coordinate_at_cursor(self)?;
|
||||
/// The cell under the cursor, with its counter; a rename also keeps every
|
||||
/// counter this client holds, so its destination is claimed (improvise-milr).
|
||||
fn captured(&self) -> Option<OpenedCell> {
|
||||
let (record, target) = crate::resolve::opened_at_cursor(self)?;
|
||||
let basis = self.basis.current()?;
|
||||
Some(OpenedCoordinate {
|
||||
let seen = match target {
|
||||
OpenedTarget::Coordinate { .. } => basis.records.clone(),
|
||||
OpenedTarget::Value { .. } => Vec::new(),
|
||||
};
|
||||
Some(OpenedCell {
|
||||
record,
|
||||
category,
|
||||
target,
|
||||
version: basis.record(record)?,
|
||||
seen: basis.records.clone(),
|
||||
seen,
|
||||
document_generation: basis.document_generation,
|
||||
})
|
||||
}
|
||||
@@ -372,13 +406,27 @@ impl ClientState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The record a refused frame renamed, if it renamed one (improvise-9ww8).
|
||||
fn renamed(sent: Option<&Sent>) -> Option<RecordHash> {
|
||||
/// The cell a refused frame wrote, if it wrote one: a rename's coordinate or a
|
||||
/// value's record and measure (improvise-9ww8, improvise-8phw).
|
||||
fn written_to(sent: Option<&Sent>) -> Option<(RecordHash, OpenedTarget)> {
|
||||
sent?.cmds.iter().find_map(|cmd| match cmd {
|
||||
Command::Model {
|
||||
cmd: ModelCommand::RenameRecordCoordinate { record, .. },
|
||||
..
|
||||
} => Some(*record),
|
||||
Command::Model { cmd, .. } => match cmd {
|
||||
ModelCommand::RenameRecordCoordinate {
|
||||
record, category, ..
|
||||
} => Some((
|
||||
*record,
|
||||
OpenedTarget::Coordinate {
|
||||
category: category.clone(),
|
||||
},
|
||||
)),
|
||||
ModelCommand::SetCell { cell, .. } | ModelCommand::ClearCell(cell) => Some((
|
||||
cell.record,
|
||||
OpenedTarget::Value {
|
||||
measure: cell.measure.clone(),
|
||||
},
|
||||
)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use improvise_core::view::Axis;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::{ClientState, Opened, OpenedCoordinate};
|
||||
use crate::client::{ClientState, Opened, OpenedCell, OpenedTarget};
|
||||
use crate::frame::ClientFrame;
|
||||
use crate::keymap::{Binding, Keymaps, ModeKey};
|
||||
use crate::viewmodel::{CatTreeEntry, axis_change_status, flatten_cat_tree};
|
||||
@@ -325,11 +325,12 @@ fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> St
|
||||
}
|
||||
|
||||
/// A commit onto a cell `i` declines is refused as `committable_cell_key` refuses
|
||||
/// it, out loud with the binding (ik6l); a cell op on an unprojected cell waits (7mro).
|
||||
/// it (ik6l); a cell op on an unprojected cell waits (7mro). Not a captured commit (3h9w).
|
||||
fn refused_here(state: &ClientState, name: &str) -> Option<String> {
|
||||
let (row, col) = state.view.cursor().selected;
|
||||
let waiting = unprojected(state, row, col).then(|| NOT_PROJECTED.to_string());
|
||||
match name {
|
||||
"commit-cell-edit" | "commit-and-advance-right" if state.opened.is_some() => None,
|
||||
"commit-cell-edit" | "commit-and-advance-right" => declined_at(state, row, col).or(waiting),
|
||||
"clear-cell" | "yank" | "paste" => waiting,
|
||||
_ => None,
|
||||
@@ -892,7 +893,7 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option<Vec<Command>> {
|
||||
|
||||
/// The record and category of the coordinate cell under the cursor, as the
|
||||
/// projection names them — never borrowed from a sibling cell (ADR 002).
|
||||
pub(crate) fn coordinate_at_cursor(state: &ClientState) -> Option<(crate::RecordHash, String)> {
|
||||
fn coordinate_at_cursor(state: &ClientState) -> Option<(crate::RecordHash, String)> {
|
||||
match cell_at_cursor(state)?.id.as_ref()? {
|
||||
CellId::Coordinate { record, category } => Some((*record, category.clone())),
|
||||
CellId::Record(_)
|
||||
@@ -903,6 +904,30 @@ pub(crate) fn coordinate_at_cursor(state: &ClientState) -> Option<(crate::Record
|
||||
}
|
||||
}
|
||||
|
||||
/// The record and cell an editor opened on the cursor commits to, as the
|
||||
/// projection names them (improvise-8xq0). `None` for a cell no editor writes.
|
||||
pub(crate) fn opened_at_cursor(state: &ClientState) -> Option<(crate::RecordHash, OpenedTarget)> {
|
||||
match cell_at_cursor(state)?.id.as_ref()? {
|
||||
CellId::Record(cell) if !state.grid.records => Some((
|
||||
cell.record,
|
||||
OpenedTarget::Value {
|
||||
measure: cell.measure.clone(),
|
||||
},
|
||||
)),
|
||||
CellId::Coordinate { record, category } => Some((
|
||||
*record,
|
||||
OpenedTarget::Coordinate {
|
||||
category: category.clone(),
|
||||
},
|
||||
)),
|
||||
CellId::Record(_)
|
||||
| CellId::Aggregate(_)
|
||||
| CellId::Derived { .. }
|
||||
| CellId::Create(_)
|
||||
| CellId::NonItem { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the cell editor on `seed`, or await it while any write is unanswered: the
|
||||
/// cache predates that write (improvise-mdfc/p6v6, `protocol.md` §3).
|
||||
fn opened_on(state: &ClientState, seed: String, mode: AppMode) -> Vec<Command> {
|
||||
@@ -1035,25 +1060,64 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option<V
|
||||
return None;
|
||||
}
|
||||
let text = read_buffer(&state.view, "edit");
|
||||
let id = cell_at_cursor(state)?.id.as_ref()?;
|
||||
// Records mode needs `record_col` (gotcha 1) — except the two CREATING
|
||||
// columns, which name nothing but themselves (improvise-gcvb).
|
||||
if let CellId::Create(kind) = id {
|
||||
return create_column(state, edit_mode, advance, kind, &text);
|
||||
// What the editor captured says what it writes, never the cell the cursor
|
||||
// is on now (improvise-8xq0, Codex on PR #274).
|
||||
match state.opened.as_ref().map(Opened::target) {
|
||||
Some(OpenedTarget::Value { .. }) => commit_value(state, edit_mode, advance, &text),
|
||||
Some(OpenedTarget::Coordinate { .. }) => {
|
||||
rename_coordinate(state, edit_mode, advance, &text)
|
||||
}
|
||||
None => uncaptured(state, edit_mode, advance, &text),
|
||||
}
|
||||
if state.grid.records {
|
||||
return rename_coordinate(state, edit_mode, advance, id, &text);
|
||||
}
|
||||
|
||||
/// An editor that captured nothing: the cell under the cursor says what it is.
|
||||
/// Records mode needs `record_col` for a measure cell (gotcha 1, improvise-gcvb).
|
||||
fn uncaptured(
|
||||
state: &ClientState,
|
||||
edit_mode: AppMode,
|
||||
advance: Advance,
|
||||
text: &str,
|
||||
) -> Option<Vec<Command>> {
|
||||
match cell_at_cursor(state)?.id.as_ref()? {
|
||||
CellId::Create(kind) => create_column(state, edit_mode, advance, kind, text),
|
||||
CellId::Coordinate { .. } => rename_coordinate(state, edit_mode, advance, text),
|
||||
CellId::Record(_) if !state.grid.records => commit_value(state, edit_mode, advance, text),
|
||||
CellId::Record(_)
|
||||
| CellId::Aggregate(_)
|
||||
| CellId::Derived { .. }
|
||||
| CellId::NonItem { .. } => None,
|
||||
}
|
||||
let mut commands = written(state, id, &text)?;
|
||||
}
|
||||
|
||||
/// `CommitAndAdvance` over a stored value: write the cell the editor OPENED on,
|
||||
/// or decline when a refusal left it no longer there (improvise-8phw).
|
||||
fn commit_value(
|
||||
state: &ClientState,
|
||||
edit_mode: AppMode,
|
||||
advance: Advance,
|
||||
text: &str,
|
||||
) -> Option<Vec<Command>> {
|
||||
let unchanged = untouched(state.view.opened_editor.as_ref(), text);
|
||||
let mut commands = match (unchanged, state.opened.as_ref()) {
|
||||
(true, _) => Vec::new(),
|
||||
(false, Some(Opened::Target(opened))) => written(state, opened, text)?,
|
||||
(false, Some(Opened::Gone(..))) => {
|
||||
return Some(vec![Command::SetStatus(RECORD_RENAMED_AWAY.to_string())]);
|
||||
}
|
||||
(false, None | Some(Opened::Retry(..))) => {
|
||||
return state.awaiting_answer().then(still_waiting);
|
||||
}
|
||||
};
|
||||
commands.push(Command::ChangeMode(AppMode::Normal));
|
||||
commands.extend(cleared(&edit_mode));
|
||||
let writes = !untouched(state.view.opened_editor.as_ref(), &text);
|
||||
let writes = !untouched(state.view.opened_editor.as_ref(), text);
|
||||
commands.extend(advanced(state, edit_mode, advance, writes)?);
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
/// Why a retry after a refusal sends nothing: the record it opened on is no longer
|
||||
/// the one under the cursor, or is gone (improvise-20ek, improvise-9ww8).
|
||||
/// Why a retry after a refusal sends nothing: the cell it opened on is no longer
|
||||
/// the one under the cursor, or is gone (improvise-20ek, -9ww8, -8phw).
|
||||
const RECORD_RENAMED_AWAY: &str = "the row you were editing has changed; Esc to leave the editor";
|
||||
|
||||
/// `CommitAndAdvance` over a coordinate: rename the record the editor OPENED on,
|
||||
@@ -1062,12 +1126,8 @@ fn rename_coordinate(
|
||||
state: &ClientState,
|
||||
edit_mode: AppMode,
|
||||
advance: Advance,
|
||||
id: &CellId,
|
||||
text: &str,
|
||||
) -> Option<Vec<Command>> {
|
||||
let CellId::Coordinate { .. } = id else {
|
||||
return None;
|
||||
};
|
||||
// Untouched writes nothing (improvise-ixxx); empty is refused as the host
|
||||
// refuses it, where composing the write would undo the whole frame.
|
||||
let unchanged = untouched(state.view.opened_editor.as_ref(), text);
|
||||
@@ -1076,24 +1136,25 @@ fn rename_coordinate(
|
||||
(false, true, _) => vec![Command::SetStatus(
|
||||
RECORD_COORDS_CANNOT_BE_EMPTY.to_string(),
|
||||
)],
|
||||
(false, false, None | Some(Opened::Retry(_))) => {
|
||||
return Some(vec![Command::SetStatus(
|
||||
"still waiting for the host to answer your last edit".to_string(),
|
||||
)]);
|
||||
}
|
||||
(false, false, Some(Opened::Gone(_))) => {
|
||||
(false, false, None | Some(Opened::Retry(..))) => return Some(still_waiting()),
|
||||
(false, false, Some(Opened::Gone(..))) => {
|
||||
return Some(vec![Command::SetStatus(RECORD_RENAMED_AWAY.to_string())]);
|
||||
}
|
||||
(false, false, Some(Opened::Target(opened))) => vec![Command::Model {
|
||||
basis: state
|
||||
.basis
|
||||
.claim_as_read(opened.document_generation, claimed(opened))?,
|
||||
cmd: ModelCommand::RenameRecordCoordinate {
|
||||
record: opened.record,
|
||||
category: opened.category.clone(),
|
||||
item: text.to_string(),
|
||||
},
|
||||
}],
|
||||
(false, false, Some(Opened::Target(opened))) => {
|
||||
let OpenedTarget::Coordinate { category } = &opened.target else {
|
||||
return Some(still_waiting());
|
||||
};
|
||||
vec![Command::Model {
|
||||
basis: state
|
||||
.basis
|
||||
.claim_as_read(opened.document_generation, claimed(opened))?,
|
||||
cmd: ModelCommand::RenameRecordCoordinate {
|
||||
record: opened.record,
|
||||
category: category.clone(),
|
||||
item: text.to_string(),
|
||||
},
|
||||
}]
|
||||
}
|
||||
};
|
||||
let writes = commands.iter().any(|c| matches!(c, Command::Model { .. }));
|
||||
commands.push(Command::ChangeMode(AppMode::Normal));
|
||||
@@ -1104,12 +1165,39 @@ fn rename_coordinate(
|
||||
|
||||
/// The rename's claim: its own record, then every record shown when the editor
|
||||
/// opened, so the destination is claimed if seen (improvise-milr).
|
||||
fn claimed(opened: &OpenedCoordinate) -> impl Iterator<Item = (crate::RecordHash, u64)> + '_ {
|
||||
fn claimed(opened: &OpenedCell) -> impl Iterator<Item = (crate::RecordHash, u64)> + '_ {
|
||||
let others = opened.seen.iter().copied();
|
||||
std::iter::once((opened.record, opened.version))
|
||||
.chain(others.filter(|(h, _)| *h != opened.record))
|
||||
}
|
||||
|
||||
/// The write to the value cell the editor OPENED on, at the version it read
|
||||
/// then (improvise-8xq0, improvise-xz2z).
|
||||
fn written(state: &ClientState, opened: &OpenedCell, text: &str) -> Option<Vec<Command>> {
|
||||
let OpenedTarget::Value { measure } = &opened.target else {
|
||||
return None;
|
||||
};
|
||||
let cell = RecordCell {
|
||||
record: opened.record,
|
||||
measure: measure.clone(),
|
||||
};
|
||||
Some(vec![Command::Model {
|
||||
basis: state.basis.claim_as_read(
|
||||
opened.document_generation,
|
||||
[(opened.record, opened.version)],
|
||||
)?,
|
||||
cmd: typed_cell_command(&CellId::Record(cell), text)?,
|
||||
}])
|
||||
}
|
||||
|
||||
/// A commit whose target is not captured yet, because the host has not answered
|
||||
/// this client's own last write. The editor stays open (improvise-fdk1).
|
||||
fn still_waiting() -> Vec<Command> {
|
||||
vec![Command::SetStatus(
|
||||
"still waiting for the host to answer your last edit".to_string(),
|
||||
)]
|
||||
}
|
||||
|
||||
/// The commit's own clear of `edit_mode`'s buffer, at its head: the keymap
|
||||
/// welds none to these keys, so a re-seed survives (improvise-fy9o).
|
||||
fn cleared(edit_mode: &AppMode) -> Option<Command> {
|
||||
@@ -1118,18 +1206,6 @@ fn cleared(edit_mode: &AppMode) -> Option<Command> {
|
||||
.map(|mb| write_buffer(&mb.buffer_key, String::new()))
|
||||
}
|
||||
|
||||
/// The write a commit makes: none when the buffer is the seed the editor
|
||||
/// captured as it opened, and only the write is skipped (improvise-ixxx).
|
||||
fn written(state: &ClientState, id: &CellId, text: &str) -> Option<Vec<Command>> {
|
||||
match untouched(state.view.opened_editor.as_ref(), text) {
|
||||
true => Some(Vec::new()),
|
||||
false => Some(vec![Command::Model {
|
||||
basis: state.basis.claim([record_at_cursor(state)?])?,
|
||||
cmd: typed_cell_command(id, text)?,
|
||||
}]),
|
||||
}
|
||||
}
|
||||
|
||||
/// `CommitAndAdvance` over a creating column: a schema change over the typed
|
||||
/// NAME. Why +cat does not advance: clients.md (improvise-gcvb).
|
||||
fn create_column(
|
||||
@@ -1202,19 +1278,6 @@ fn advanced(
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
/// The surrogate naming the record under the cursor, or `None` when the
|
||||
/// cursor is not on a writable cell.
|
||||
fn record_at_cursor(state: &ClientState) -> Option<crate::RecordHash> {
|
||||
match cell_at_cursor(state)?.id.as_ref()? {
|
||||
CellId::Record(cell) => Some(cell.record),
|
||||
CellId::Aggregate(_)
|
||||
| CellId::Derived { .. }
|
||||
| CellId::Create(_)
|
||||
| CellId::Coordinate { .. }
|
||||
| CellId::NonItem { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What an editor opened on the cursor starts with, or `None` with no cell
|
||||
/// there to seed from. Read by the fold, never a renderer (improvise-ktlk).
|
||||
pub(crate) fn edit_seed(state: &ClientState) -> Option<String> {
|
||||
@@ -2515,7 +2578,7 @@ mod tests {
|
||||
// A records MODE over a non-records GRID is a state no host can
|
||||
// produce, so the fixture projects both (improvise-e5pf).
|
||||
crate::WidgetCache::apply(&mut state.grid, &GridUpdate::Records(mode.is_records()));
|
||||
state.view.mode = mode;
|
||||
state.fold(&Command::ChangeMode(mode));
|
||||
state.grid.page_coords = vec![crate::PageAxis {
|
||||
cat: "Quarter".into(),
|
||||
selected: "Q2".into(),
|
||||
@@ -3441,7 +3504,7 @@ mod tests {
|
||||
// Half two: the predicate is about *finishing*, so something has to
|
||||
// press Enter and observe.
|
||||
let mut client = client_on(record_cell());
|
||||
client.view.mode = AppMode::editing();
|
||||
client.fold(&Command::ChangeMode(AppMode::editing()));
|
||||
client.view.buffers.insert("edit".into(), "42".into());
|
||||
let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Enter)) else {
|
||||
panic!("Enter in Editing must resolve here — that is the arm");
|
||||
@@ -3938,12 +4001,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-8xq0: a commit whose target is not captured yet, because the
|
||||
/// last write is unanswered, declines out loud. Evidence file.
|
||||
#[test]
|
||||
fn a_value_commit_before_the_host_answers_the_last_one_waits_out_loud() {
|
||||
let mut client = client_on(record_cell());
|
||||
// The row the advance lands on is a stored cell too, so the commit gets
|
||||
// past the cursor's cell and reaches the capture.
|
||||
let mut below = CellDisplay::number("900");
|
||||
below.id = Some(record_cell());
|
||||
client.fold(&Command::Project(Projection::Grid(GridUpdate::Cells(
|
||||
vec![((1, 0), below)],
|
||||
))));
|
||||
client.fold(&Command::ChangeMode(AppMode::editing()));
|
||||
client.view.buffers.insert("edit".into(), "42".into());
|
||||
let KeyOutcome::Local(first) = resolve(&client, ev(KeyCode::Enter)) else {
|
||||
panic!("Enter in Editing must resolve here");
|
||||
};
|
||||
client.fold_outgoing(&first);
|
||||
assert!(client.view.mode.is_editing(), "setup: the advance reopened");
|
||||
assert_eq!(client.opened, None, "setup: nothing captured yet");
|
||||
client.view.buffers.insert("edit".into(), "43".into());
|
||||
let KeyOutcome::Local(second) = resolve(&client, ev(KeyCode::Enter)) else {
|
||||
panic!("a commit with no target yet must answer, not go upstream");
|
||||
};
|
||||
assert_eq!(second[..1], still_waiting()[..], "it says so: {second:?}");
|
||||
assert!(
|
||||
!second.iter().any(|c| matches!(c, Command::Model { .. })),
|
||||
"and it must write nothing: {second:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Enter in `Editing` is `[commit-cell-edit editing, clear-buffer edit]`
|
||||
/// and every part shows up here — including the odd-looking trailing clear.
|
||||
#[test]
|
||||
fn enter_commits_the_cell_advances_and_re_enters_editing() {
|
||||
let mut client = client_on(record_cell());
|
||||
client.view.mode = AppMode::editing();
|
||||
client.fold(&Command::ChangeMode(AppMode::editing()));
|
||||
client.view.buffers.insert("edit".into(), "42".into());
|
||||
let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Enter)) else {
|
||||
panic!("Enter in Editing must resolve here");
|
||||
@@ -4314,7 +4408,7 @@ mod tests {
|
||||
#[test]
|
||||
fn committing_an_empty_buffer_clears_the_cell() {
|
||||
let mut client = client_on(record_cell());
|
||||
client.view.mode = AppMode::editing();
|
||||
client.fold(&Command::ChangeMode(AppMode::editing()));
|
||||
client.view.buffers.insert("edit".into(), String::new());
|
||||
let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Enter)) else {
|
||||
panic!("Enter in Editing must resolve here");
|
||||
@@ -4393,7 +4487,7 @@ mod tests {
|
||||
],
|
||||
});
|
||||
client.window = (1, 1);
|
||||
client.view.mode = AppMode::editing();
|
||||
client.fold(&Command::ChangeMode(AppMode::editing()));
|
||||
client.view.buffers.insert("edit".into(), "7".into());
|
||||
|
||||
let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Enter)) else {
|
||||
|
||||
@@ -1354,6 +1354,311 @@ fn a_second_rename_after_a_reorder_renames_the_row_the_host_shows() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── the keyboard editor's target (improvise-8xq0) ────────────────────────
|
||||
|
||||
/// What client B does while A's keyboard editor is open on North/Cost.
|
||||
#[derive(PartialEq)]
|
||||
enum Meanwhile {
|
||||
HideNorth,
|
||||
WriteNorth,
|
||||
HideBoth,
|
||||
HideRevenue,
|
||||
}
|
||||
|
||||
/// The commands B sends for `meanwhile`, in order.
|
||||
fn meanwhile_commands(
|
||||
host: &Host,
|
||||
a: &Client,
|
||||
meanwhile: &Meanwhile,
|
||||
) -> Vec<improvise_protocol::ModelCommand> {
|
||||
use improvise_protocol::ModelCommand;
|
||||
let hide = |item: &str| ModelCommand::HideItem {
|
||||
view: host.app().view_name().to_string(),
|
||||
category: "Region".into(),
|
||||
item: item.into(),
|
||||
};
|
||||
match meanwhile {
|
||||
Meanwhile::HideNorth => vec![hide("North")],
|
||||
Meanwhile::HideBoth => vec![hide("North"), hide("South")],
|
||||
Meanwhile::HideRevenue => vec![ModelCommand::HideItem {
|
||||
view: host.app().view_name().to_string(),
|
||||
category: "_Measure".into(),
|
||||
item: "Revenue".into(),
|
||||
}],
|
||||
Meanwhile::WriteNorth => vec![ModelCommand::SetCell {
|
||||
cell: a.record_cell_at(0, 0).expect("North/Revenue is stored"),
|
||||
value: CellValue::Number(999.0),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// Client A presses `i` on North/Cost (0,1) and types `42`; B does `meanwhile`
|
||||
/// before A presses Enter. Evidence file.
|
||||
fn commit_north_cost(meanwhile: Meanwhile) -> (Host, Vec<Addressed>) {
|
||||
use improvise_command::ui::reduce::caught_up;
|
||||
|
||||
let mut host = host();
|
||||
let (mut a, mut b) = (Client::new(), Client::new());
|
||||
let (a_id, a_snap) = host.connect(10, 10);
|
||||
let (b_id, b_snap) = host.connect(10, 10);
|
||||
a.set_viewport(10, 10);
|
||||
b.set_viewport(10, 10);
|
||||
deliver(&mut a, &[a_snap]);
|
||||
deliver(&mut b, &[b_snap]);
|
||||
for key in ["l", "i", "4", "2"] {
|
||||
press(&mut host, a_id, &mut a, key);
|
||||
}
|
||||
assert_eq!(
|
||||
a.state().view.cursor().selected,
|
||||
(0, 1),
|
||||
"setup: on North/Cost"
|
||||
);
|
||||
assert!(
|
||||
a.state().view.mode.is_editing(),
|
||||
"setup: the editor is open"
|
||||
);
|
||||
|
||||
for cmd in meanwhile_commands(&host, &a, &meanwhile) {
|
||||
let relayed = host.handle(b_id, ClientFrame::command(caught_up(host.app(), cmd)));
|
||||
route(&relayed, (a_id, &mut a), (b_id, &mut b));
|
||||
}
|
||||
assert!(a.state().view.mode.is_editing(), "setup: still editing");
|
||||
if meanwhile == Meanwhile::HideBoth {
|
||||
assert!(
|
||||
a.state().grid.cell(0, 0).is_none(),
|
||||
"setup: no cell is left under the cursor"
|
||||
);
|
||||
}
|
||||
if meanwhile == Meanwhile::HideNorth {
|
||||
assert_eq!(
|
||||
a.state().grid.row_frame.leaf_labels().first().cloned(),
|
||||
Some("South".to_string()),
|
||||
"setup: the rows renumbered under the open editor"
|
||||
);
|
||||
}
|
||||
|
||||
let frame = a
|
||||
.key(&BrowserKey::new("Enter"))
|
||||
.expect("a commit composes a frame");
|
||||
let json = serde_json::to_string(&frame).expect("a client frame serializes");
|
||||
let answers = send(&mut host, a_id, &json);
|
||||
(host, answers)
|
||||
}
|
||||
|
||||
fn cell(host: &Host, region: &str, measure: &str) -> Option<CellValue> {
|
||||
host.app()
|
||||
.model_state
|
||||
.workbook
|
||||
.model
|
||||
.get_cell(&CellKey::new(vec![
|
||||
("Region".into(), region.into()),
|
||||
("_Measure".into(), measure.into()),
|
||||
]))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// improvise-8xq0: B hid the row under A's open editor, and Enter wrote the
|
||||
/// cell under the cursor at Enter. Evidence: `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_keyboard_edit_writes_the_cell_it_opened_on_after_the_rows_renumber() {
|
||||
let (host, answers) = commit_north_cost(Meanwhile::HideNorth);
|
||||
assert!(
|
||||
answers
|
||||
.iter()
|
||||
.any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)),
|
||||
"the write must be accepted: {answers:?}"
|
||||
);
|
||||
assert_eq!(cell(&host, "South", "Revenue"), None, "South was written");
|
||||
assert_eq!(cell(&host, "South", "Cost"), None, "South was written");
|
||||
assert_eq!(
|
||||
cell(&host, "North", "Cost"),
|
||||
Some(CellValue::Number(42.0)),
|
||||
"the edit must land on the cell the editor opened on"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-8xq0: the editor an advance reopens commits to the cell it landed
|
||||
/// on, not the one the last commit captured. Evidence file.
|
||||
#[test]
|
||||
fn a_second_keyboard_commit_writes_the_cell_the_advance_landed_on() {
|
||||
let mut host = host();
|
||||
let mut a = Client::new();
|
||||
a.set_viewport(10, 10);
|
||||
let (id, snapshot) = host.connect(10, 10);
|
||||
deliver(&mut a, &[snapshot]);
|
||||
for key in ["i", "5", "Enter"] {
|
||||
press(&mut host, id, &mut a, key);
|
||||
}
|
||||
assert_eq!(a.state().view.cursor().selected, (1, 0), "setup: advanced");
|
||||
assert!(a.state().view.mode.is_editing(), "setup: reopened");
|
||||
for key in ["7", "Enter"] {
|
||||
press(&mut host, id, &mut a, key);
|
||||
}
|
||||
assert_eq!(
|
||||
cell(&host, "North", "Revenue"),
|
||||
Some(CellValue::Number(12005.0))
|
||||
);
|
||||
assert_eq!(
|
||||
cell(&host, "South", "Revenue"),
|
||||
Some(CellValue::Number(7.0)),
|
||||
"the second commit must write the cell the advance landed on"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-xz2z, 0j0f's rule on the keyboard path: the basis is the one read
|
||||
/// when the editor opened. Evidence: `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_keyboard_edit_is_refused_when_its_record_was_written_while_open() {
|
||||
let (host, answers) = commit_north_cost(Meanwhile::WriteNorth);
|
||||
assert!(
|
||||
answers.iter().any(|f| matches!(
|
||||
f.msg,
|
||||
improvise_protocol::ServerFrame::Rejected(improvise_protocol::Rejection::RecordMoved(
|
||||
_
|
||||
))
|
||||
)),
|
||||
"an edit composed against a record someone else wrote must be refused: {answers:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
cell(&host, "North", "Revenue"),
|
||||
Some(CellValue::Number(999.0))
|
||||
);
|
||||
assert_eq!(cell(&host, "North", "Cost"), None, "nothing was written");
|
||||
}
|
||||
|
||||
/// A's Enter on North/Cost CROSSES B's `meanwhile`: B's relays reach A only
|
||||
/// after A has sent. Then A presses Enter again. Evidence file.
|
||||
fn cross_north_cost(meanwhile: Meanwhile) -> (Host, Vec<Addressed>, Client) {
|
||||
cross_north(&["l", "i", "4", "2"], meanwhile)
|
||||
}
|
||||
|
||||
/// `cross_north_cost`, with the keys that open the editor and type into it.
|
||||
fn cross_north(keys: &[&str], meanwhile: Meanwhile) -> (Host, Vec<Addressed>, Client) {
|
||||
use improvise_command::ui::reduce::caught_up;
|
||||
|
||||
let mut host = host();
|
||||
let (mut a, mut b) = (Client::new(), Client::new());
|
||||
let (a_id, a_snap) = host.connect(10, 10);
|
||||
let (b_id, b_snap) = host.connect(10, 10);
|
||||
a.set_viewport(10, 10);
|
||||
b.set_viewport(10, 10);
|
||||
deliver(&mut a, &[a_snap]);
|
||||
deliver(&mut b, &[b_snap]);
|
||||
for key in keys {
|
||||
press(&mut host, a_id, &mut a, key);
|
||||
}
|
||||
let mut held = Vec::new();
|
||||
for cmd in meanwhile_commands(&host, &a, &meanwhile) {
|
||||
held.extend(host.handle(b_id, ClientFrame::command(caught_up(host.app(), cmd))));
|
||||
}
|
||||
let first = a
|
||||
.key(&BrowserKey::new("Enter"))
|
||||
.expect("a commit composes a frame");
|
||||
let json = serde_json::to_string(&first).expect("a client frame serializes");
|
||||
let answered = send(&mut host, a_id, &json);
|
||||
assert!(
|
||||
answered
|
||||
.iter()
|
||||
.any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Rejected(_))),
|
||||
"setup: the crossing Enter is refused: {answered:?}"
|
||||
);
|
||||
route(&held, (a_id, &mut a), (b_id, &mut b));
|
||||
route(&answered, (a_id, &mut a), (b_id, &mut b));
|
||||
assert!(
|
||||
a.state().view.mode.is_editing(),
|
||||
"setup: the refusal reopens the editor"
|
||||
);
|
||||
|
||||
let retry = a.key(&BrowserKey::new("Enter"));
|
||||
let answers = match retry {
|
||||
Some(frame) => {
|
||||
let json = serde_json::to_string(&frame).expect("a client frame serializes");
|
||||
send(&mut host, a_id, &json)
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
(host, answers, a)
|
||||
}
|
||||
|
||||
/// improvise-8phw: the crossing refusal reopened the editor over South, and the
|
||||
/// retry wrote 42 into South/Cost. Evidence: `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_keyboard_edit_retried_after_a_crossing_hide_writes_no_other_cell() {
|
||||
let (host, answers, a) = cross_north_cost(Meanwhile::HideNorth);
|
||||
assert_eq!(cell(&host, "South", "Cost"), None, "South/Cost was written");
|
||||
assert_eq!(
|
||||
cell(&host, "South", "Revenue"),
|
||||
None,
|
||||
"South/Revenue was written"
|
||||
);
|
||||
assert!(
|
||||
!answers
|
||||
.iter()
|
||||
.any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)),
|
||||
"the retry must send no write: {answers:?}"
|
||||
);
|
||||
assert!(
|
||||
a.state().view.mode.is_editing(),
|
||||
"the retry declines and leaves the editor open"
|
||||
);
|
||||
assert!(
|
||||
a.state().view.status_msg.contains("has changed"),
|
||||
"and says so, rather than going silently upstream: {:?}",
|
||||
a.state().view.status_msg
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-8phw, the measure half: the crossing hide leaves the SAME record
|
||||
/// under the cursor in another column. Evidence: `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_keyboard_edit_retried_after_a_crossing_column_hide_writes_no_other_measure() {
|
||||
let (host, answers, _) = cross_north(&["i", "4", "2"], Meanwhile::HideRevenue);
|
||||
assert_eq!(cell(&host, "North", "Cost"), None, "North/Cost was written");
|
||||
assert_eq!(
|
||||
cell(&host, "North", "Revenue"),
|
||||
Some(CellValue::Number(1200.0)),
|
||||
"the opened cell is hidden, so nothing may land on it either"
|
||||
);
|
||||
assert!(
|
||||
!answers
|
||||
.iter()
|
||||
.any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)),
|
||||
"the retry must send no write: {answers:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-8phw's control: a crossing WRITE to the record is refused, and the
|
||||
/// retry is the one-keystroke force onto the same cell (gotcha 43).
|
||||
#[test]
|
||||
fn a_keyboard_edit_retried_after_a_crossing_write_forces_the_same_cell() {
|
||||
let (host, answers, _) = cross_north_cost(Meanwhile::WriteNorth);
|
||||
assert!(
|
||||
answers
|
||||
.iter()
|
||||
.any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)),
|
||||
"the retry is a force: {answers:?}"
|
||||
);
|
||||
assert_eq!(cell(&host, "North", "Cost"), Some(CellValue::Number(42.0)));
|
||||
assert_eq!(
|
||||
cell(&host, "North", "Revenue"),
|
||||
Some(CellValue::Number(999.0))
|
||||
);
|
||||
}
|
||||
|
||||
/// Codex on PR #274: with no cell left under the cursor, Enter went upstream
|
||||
/// instead of writing the cell the editor opened on. Evidence file.
|
||||
#[test]
|
||||
fn a_keyboard_edit_whose_rows_all_vanished_writes_the_cell_it_opened_on() {
|
||||
let (host, answers) = commit_north_cost(Meanwhile::HideBoth);
|
||||
assert!(
|
||||
answers
|
||||
.iter()
|
||||
.any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)),
|
||||
"the write must be sent and accepted: {answers:?}"
|
||||
);
|
||||
assert_eq!(cell(&host, "North", "Cost"), Some(CellValue::Number(42.0)));
|
||||
}
|
||||
|
||||
// ── races on a coordinate rename (improvise-20ek, improvise-milr) ───────
|
||||
|
||||
type Sid = improvise_command::ui::project::SessionId;
|
||||
@@ -1662,3 +1967,76 @@ fn a_retry_after_a_crossing_write_to_its_own_record_is_a_force() {
|
||||
);
|
||||
assert_eq!(region(&host, "North"), None);
|
||||
}
|
||||
|
||||
/// `host()` with `Profit = Revenue - Cost`, so a grid with Region on None
|
||||
/// shows aggregates and a derived column, and no stored cell.
|
||||
fn host_with_profit() -> Host {
|
||||
let mut wb = Workbook::new("Test");
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.add_item("Region", "North").unwrap();
|
||||
wb.add_item("Region", "South").unwrap();
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
wb.model.add_formula(
|
||||
improvise_command::formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(),
|
||||
);
|
||||
wb.active_view_mut().set_axis("Region", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
wb.model.set_cell(
|
||||
CellKey::new(vec![
|
||||
("Region".into(), "North".into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
]),
|
||||
CellValue::Number(1200.0),
|
||||
);
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
Host::new(app)
|
||||
}
|
||||
|
||||
/// improvise-3h9w: B's reframe left A's cursor on an aggregate, and Enter was
|
||||
/// refused for THAT cell instead of writing the captured one. Evidence file.
|
||||
#[test]
|
||||
fn a_captured_edit_commits_when_the_cursor_now_sits_on_an_aggregate() {
|
||||
use improvise_command::ui::reduce::caught_up;
|
||||
use improvise_protocol::ModelCommand;
|
||||
|
||||
let mut host = host_with_profit();
|
||||
let (mut a, mut b) = (Client::new(), Client::new());
|
||||
let (a_id, a_snap) = host.connect(10, 10);
|
||||
let (b_id, b_snap) = host.connect(10, 10);
|
||||
a.set_viewport(10, 10);
|
||||
b.set_viewport(10, 10);
|
||||
deliver(&mut a, &[a_snap]);
|
||||
deliver(&mut b, &[b_snap]);
|
||||
for key in ["l", "i", "4", "2"] {
|
||||
press(&mut host, a_id, &mut a, key);
|
||||
}
|
||||
let region_off = ModelCommand::SetAxis {
|
||||
view: host.app().view_name().to_string(),
|
||||
category: "Region".into(),
|
||||
axis: Axis::None,
|
||||
};
|
||||
let relayed = host.handle(
|
||||
b_id,
|
||||
ClientFrame::command(caught_up(host.app(), region_off)),
|
||||
);
|
||||
route(&relayed, (a_id, &mut a), (b_id, &mut b));
|
||||
assert!(a.state().view.mode.is_editing(), "setup: still editing");
|
||||
let (row, col) = a.state().view.cursor().selected;
|
||||
assert!(
|
||||
matches!(
|
||||
a.state().grid.cell(row, col).and_then(|c| c.id.clone()),
|
||||
Some(improvise_protocol::CellId::Aggregate(_))
|
||||
),
|
||||
"setup: the cursor now sits on an aggregate"
|
||||
);
|
||||
|
||||
press(&mut host, a_id, &mut a, "Enter");
|
||||
assert_eq!(
|
||||
cell(&host, "North", "Cost"),
|
||||
Some(CellValue::Number(42.0)),
|
||||
"the captured cell must be written: status {:?}",
|
||||
a.state().view.status_msg
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1728,13 +1728,15 @@ fn a_records_commit_is_declined_off_the_grid_and_not_off_the_mode() {
|
||||
// The control: the same mode over a REGULAR grid must still commit, or the
|
||||
// decline above is "this client never writes" and proves nothing.
|
||||
let plain = furnished_host();
|
||||
let mut ordinary = paged_client(&plain);
|
||||
ordinary.view.mode = AppMode::editing();
|
||||
// Seeded as `records_client` seeds, basis included, over a regular grid;
|
||||
// opened through the fold, which captures the cell the commit writes.
|
||||
let mut ordinary = records_client(&plain);
|
||||
ordinary.view.cursor_mut().selected = stored_cell_at(&ordinary);
|
||||
ordinary.fold(&improvise_protocol::Command::ChangeMode(AppMode::editing()));
|
||||
ordinary
|
||||
.view
|
||||
.buffers
|
||||
.insert("edit".into(), "42".to_string());
|
||||
ordinary.view.cursor_mut().selected = stored_cell_at(&ordinary);
|
||||
assert!(!ordinary.grid.records, "the control must be a regular grid");
|
||||
assert!(
|
||||
composes_a_write(&ordinary, enter),
|
||||
|
||||
Reference in New Issue
Block a user