Merge remote-tracking branch 'gh/main' into wave/c-host-and-save

This commit is contained in:
Edward Langley
2026-09-19 23:43:18 -07:00
31 changed files with 2507 additions and 1491 deletions
+12 -4
View File
File diff suppressed because one or more lines are too long
+195 -4
View File
@@ -216,10 +216,201 @@ address as mixed content. An explicit `ws://` from an https page is upgraded
rather than passed through — passing it through can only produce a failure
that reads like "the server is not running".
So the local pairing above works as written. Reaching a server from the
**deployed** https page means terminating TLS in front of it (a reverse proxy
or a tunnel): `improvise-ws-server` speaks plain `ws://` only, and native
`wss://` is not implemented.
So the local pairing above works as written, and it is the case worth
optimising for: `http://localhost` is not a secure-context problem, so nothing
below is needed to develop, to demo locally, or to share a TUI-hosted document
with another tab on the same machine.
One caveat even there, for the reason spelled out below: a WebSocket is exempt
from the same-origin policy, and **nothing in the server checks `Origin`** — so
a page you did not open can connect to `ws://127.0.0.1:8787` and read or edit
the document while the server runs.
How much the *browser* mitigates that is in flux and should not be leaned on.
Chromium has been rolling out **Local Network Access**, which puts requests
from a public-network origin to a loopback or private-network address behind a
permission prompt; per Chrome's own documentation WebSockets were **not yet**
covered when the prompt began shipping, with coverage planned, so whether a
given browser and version gates *this* socket is not something this project can
state — and there is no browser here to test it in. Two gaps are structural
rather than version-dependent: it is a Chromium feature, so other browsers need
not behave the same; and it only engages for a **public** origin, so the
locally-served page this section is about is outside it entirely.
Treat it, then, as tolerable for a local session you start and stop, and as a
reason not to leave one running unattended. The durable fix is the server
checking `Origin` itself, which it does not yet do.
### Sharing a TUI-hosted document with a remote browser
Reaching `improvise-ws-server` from the **deployed** https page means
terminating TLS in front of it. `improvise-ws-server` speaks plain `ws://`
only — there is no rustls, no native-tls, nothing in the crate or its
dependencies — so `wss://` is not obtainable from the binary itself, and this
is the supported way to get it.
**Say the cost first, because it is real.** This means the documented path to
sharing involves a second piece of software, which is a poor answer for a tool
whose selling point is that it is one binary. It is the answer anyway, for a
reason given under "Why not native `wss://`" below. If you only want *a*
document in *a* browser, you do not need any of this — that is the standalone
worker deployment, it is what the Pages site is for, and it needs no server
process at all.
**Read this before following any of it.** `improvise-ws-server` has **no
authentication of any kind**, and that is a deliberate MVP limit rather than an
oversight — its own module doc says so. Anyone who can open a socket to it can
**rewrite every document it serves**. Loopback binding is the only thing
currently standing between that and the network, and *terminating TLS in front
of it removes exactly that protection*: the proxy is public, so the endpoint is
public, and `--bind 127.0.0.1` protects the port while doing nothing about the
proxy. TLS gets you confidentiality, not access control.
**And the control has to be one a browser can actually satisfy**, which rules
out most of the obvious ones. The page opens the socket as
`new WebSocket(url)` — one argument, a URL — and the distinction that decides
everything here is **ambient credentials versus challenge–response**:
- The handshake **is** an HTTP request, so the browser attaches what it
attaches *automatically* — cookies for that host above all. That is why an
identity-aware proxy can work at all.
- What the page cannot do is **set a header** (there is no `Authorization`
argument to `new WebSocket`) or **answer a prompt** (a handshake surfaces no
login dialog, and its failure reaches script as a bare connection error).
So **HTTP basic auth is not a locked door here, it is a 401 the page can
neither answer nor explain** — the same shape as the self-signed certificate
that rules out native `wss://` below. Anything challenge-shaped fails for that
reason; anything ambient may work. Client-certificate mTLS sits awkwardly
between the two — the certificate is offered at the TLS layer rather than by
the page, but the selection UI browsers show is a top-level-navigation
affordance, and whether a cross-origin socket handshake gets it is **not
something verified here**.
**And "who may connect" is two questions, not one.** A WebSocket is **exempt
from the same-origin policy** — there is no CORS preflight on a handshake — so
any hostile page an allowed user visits can open a socket to your endpoint
*from that user's own allowed address*, and read and write the document. An
address check cannot see that, because by then the request genuinely does come
from an allowed client. Only the **`Origin` header** distinguishes them, and
`improvise-ws-server` **never looks at it** — it accepts every handshake. So a
proxy needs an Origin allow-list **in addition to**, never instead of,
whichever control below you pick. The worked example carries both.
So the honest controls are the ones that need **no credential from the page**:
- **An IP allow-list in the proxy** — the cheapest, and the worked example
below. It is about where the client is, so the page sends nothing. On its
own it answers only the first of the two questions above.
- **A private network** (Tailscale, WireGuard). The peer is only reachable by
machines already on it; again the page sends nothing.
- **An identity-aware tunnel** (`cloudflared` with Cloudflare Access) — the
*ambient* case above, and the reason that distinction is drawn at all. It
works by a session **cookie** set after an interactive login on the proxy's
own hostname, which the handshake then carries by itself; so it needs the
user to visit that hostname first, and it is
subject to the browser's third-party-cookie rules. Plausible and *not
verified here* — there is no browser in this repository — so it is named, not
written out as a recipe.
What would make this genuinely easy is a **token in the URL**, because the URL
is the one place the page can put a credential *deliberately* — everything else
it sends is ambient. The server has
no such capability today, and whether it should grow one — or whether "auth is
the proxy's job" becomes a stated design position — is **improvise-kpnr**.
Until then: share only documents you would be willing to let anyone on the
other side of that control edit, and take the tunnel down when you are done.
A worked example with [Caddy](https://caddyserver.com/), which obtains and
renews the certificate itself. Point a DNS name at the host, then:
```caddyfile
# Caddyfile
improvise.example.com {
# NOT optional — see above. Without it the document is world-writable.
# An allow-list rather than a password, because the page cannot answer a
# challenge on a socket handshake: this is checked against the address.
@denied not remote_ip 203.0.113.4 198.51.100.0/24
abort @denied
# ALSO not optional, and it is NOT redundant with the address check.
# A WebSocket is exempt from the same-origin policy, so any hostile page
# an allowed user visits can open this socket FROM THAT ALLOWED ADDRESS
# and read and edit the document. The address check cannot see that; only
# the Origin can. Name the exact origins the page is served from.
@badorigin not header Origin https://fiddlerwoaroof.github.io
abort @badorigin
reverse_proxy 127.0.0.1:8787
}
```
```sh
improvise-ws-server bank-info.improv --port 8787 # still plain ws, still loopback
caddy run # terminates TLS, proxies to it
```
On the same terms as the browser caveat below: **this configuration has not
been executed in this repository** — there is no Caddy and no browser here. It
is the form Caddy documents for `remote_ip`, and the reasoning about what a
browser can satisfy is checked against `web/app.js`, which opens
`new WebSocket(url)` and can therefore send no header. Verify the deny actually
denies before trusting it with a document.
Then, from <https://fiddlerwoaroof.github.io/improvise/>, reload with
`?ws=improvise.example.com`. Caddy preserves the request path, so the
multi-document form works too: serve several files and name one with
`?ws=improvise.example.com/bank-info`.
Caddy needs no websocket-specific configuration — `reverse_proxy` passes the
`Upgrade` handshake through. nginx does: it needs `proxy_http_version 1.1` and
the `Upgrade`/`Connection` headers set explicitly, or the handshake is
answered as an ordinary request and the socket never opens.
For a one-off share with no DNS name and no certificate of your own,
`cloudflared tunnel --url http://127.0.0.1:8787` or `ngrok http 8787` each
print an https hostname that works the same way — pass the hostname to `?ws=`
without a scheme. Both print a **public** URL and neither authenticates
anything by default, and an unguessable URL is not a control.
Two things carry over from above. `ngrok http --basic-auth` does **not** help
here — the page cannot answer a basic-auth challenge on a socket handshake —
and what you want instead is the same **address check** the Caddy example
uses. But no command line for it is given here on purpose: ngrok has moved IP
restriction from the (now deprecated) `--cidr-allow` flag to a Traffic Policy
`restrict-ips` action passed with `--traffic-policy-file`, and cloudflared's
equivalent is a product feature of its own. Those spellings have already
changed once, none of them has been run in this repository, and a recipe that
is correct today and silently wrong at the next major version is worse than a
pointer — so: **consult their current documentation, and confirm the
restriction denies before you trust it with a document.** If you cannot put an
address check in front of the tunnel, do not expose it.
Note the server binds `127.0.0.1` by default and `--bind` is the only way to
widen that. Behind a proxy on the same host you should *not* widen it: the
proxy reaches loopback, and leaving the default keeps the **unencrypted** port
off the network. That is worth doing and is not access control — the proxy is
still the open door, which is why the **access controls** above belong there.
Those are access controls and not authentication, which the server still has
none of: they answer *which clients and which origins may connect*, never
*who* is connecting. The distinction is the whole of improvise-kpnr.
### Why not native `wss://`
Declined deliberately rather than merely unimplemented (improvise-o8fa). Adding
rustls is the easy half; the certificate is the hard one, and it is what makes
the feature worse than the proxy rather than better. A self-signed certificate
makes the browser refuse the socket outright — a websocket handshake has no
interstitial to click through, so the failure arrives as a bare connection
error with no usable indication that a certificate was the reason. That leaves
a real certificate or an explicitly trusted local CA (`mkcert`) as the only
pleasant options, and both of those are a second piece of software too. So the
choice is not "one binary versus two", it is "two either way, and one of the
options additionally ships a TLS stack and a certificate-loading surface in a
binary whose job is a pivot table". If sharing a TUI-hosted document to a
remote browser ever becomes a common case rather than an occasional one, this
is worth revisiting on that evidence.
Caveat worth stating plainly: **no line of the browser JavaScript has ever
run.** There is no browser and no headless DOM in the development environment,
-1186
View File
File diff suppressed because it is too large Load Diff
@@ -202,6 +202,55 @@ mod tests {
assert!(dbg.contains("ExportCsv"), "Expected ExportCsv, got: {dbg}");
assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}");
}
/// improvise-lae: the save prompt has to commit to `SaveAs` and not to
/// `ExportCsv`, which is what makes it a separate mode from
/// `ExportPrompt` rather than a second binding onto it — `ExportCsv`
/// would write CSV under whatever `.improv` name the user typed.
///
/// `SaveAs` and not `Save`, because `Save` writes the path the document
/// already has, and the whole reason this prompt exists is that there
/// isn't one.
#[test]
fn commit_save_writes_the_model_to_the_typed_path() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let mut bufs = HashMap::new();
bufs.insert("save".to_string(), "/tmp/typed.improv".to_string());
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.buffers = &bufs;
let dbg = effects_debug(&CommitSave.execute(&ctx));
assert!(
dbg.contains("SaveAs(\"/tmp/typed.improv\")"),
"Expected SaveAs at the typed path, got: {dbg}"
);
assert!(
!dbg.contains("ExportCsv"),
"the save prompt must not export CSV, got: {dbg}"
);
assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}");
}
/// An empty prompt must not reach `SaveAs`: `SaveAs(PathBuf::from(""))`
/// asks the backend to write to `""`, and the user then reads whatever
/// I/O error that produces instead of "you typed nothing".
#[test]
fn commit_save_on_an_empty_path_writes_nothing() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let mut bufs = HashMap::new();
bufs.insert("save".to_string(), " ".to_string());
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.buffers = &bufs;
let dbg = effects_debug(&CommitSave.execute(&ctx));
assert!(
!dbg.contains("SaveAs"),
"an empty path must not reach SaveAs, got: {dbg}"
);
assert!(dbg.contains("SetStatus"), "Expected a status, got: {dbg}");
}
}
// ── Commit commands (mode-specific buffer consumers) ────────────────────────
@@ -292,6 +341,7 @@ fn commit_plain_records_edit(
commit_regular_cell_value(&row.cell_key(measure), value, effects);
}
RecordCol::NewMeasure => add_measure(value, effects),
RecordCol::NewCategory => add_category(value, effects),
}
}
@@ -309,6 +359,29 @@ fn add_measure(value: &str, effects: &mut Vec<Box<dyn Effect>>) {
}));
}
/// Typing a name into the `+cat` column creates that category, which gives
/// the row a coordinate column to type into (improvise-2zq).
///
/// It creates the column and **does not set the coordinate on the current
/// row**, which is the deliberate answer to the ticket's third question.
/// Two reasons. It is symmetric with the measure `+`, which creates an empty
/// column and writes no value. And setting it would move *every* measure
/// cell in the row onto the new coordinate (gotcha 1) — a data migration
/// across N cells attached to the same Enter that made a schema change, in
/// an app with no undo. The follow-up edit is one `l` away in the column
/// that just appeared.
///
/// Like `add_measure` this is a schema change, so it applies immediately
/// even inside a drill rather than being staged.
fn add_category(value: &str, effects: &mut Vec<Box<dyn Effect>>) {
let name = value.trim();
if name.is_empty() {
return;
}
effects.push(Box::new(effect::AddCategory(name.to_string())));
effects.push(effect::mark_dirty());
}
fn commit_cell_value(
ctx: &CmdContext,
key: &CellKey,
@@ -320,8 +393,10 @@ fn commit_cell_value(
return;
};
match (&col, ctx.has_drill_state) {
// A new measure is a schema change, never a staged cell edit.
// A new measure or category is a schema change, never a staged
// cell edit: there is no cell to roll back with the drill.
(RecordCol::NewMeasure, _) => add_measure(value, effects),
(RecordCol::NewCategory, _) => add_category(value, effects),
(_, true) => effects.push(stage_drill_edit(record_idx, col, value)),
(_, false) => commit_plain_records_edit(ctx, record_idx, &col, value, effects),
}
@@ -331,6 +406,19 @@ fn commit_cell_value(
commit_regular_cell_value(key, value, effects);
}
/// Whether committing `key` inserts a column, invalidating every column
/// index to its right.
///
/// Asked of the *layout* — `record_col` resolves the synthetic `_Dim`
/// position into what the column actually is — rather than re-derived from
/// the value or the cursor, so there is one answer to "what is this column"
/// (gotcha 1).
fn inserts_a_column(ctx: &CmdContext, key: &CellKey) -> bool {
crate::view::synthetic_record_info(key)
.and_then(|(_, col_idx)| ctx.layout.record_col(col_idx))
.is_some_and(|col| matches!(col, RecordCol::NewCategory))
}
/// Direction to advance after committing a cell edit.
#[derive(Debug, Clone, Copy)]
pub enum AdvanceDir {
@@ -374,14 +462,43 @@ impl Cmd for CommitAndAdvance {
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
commit_cell_value(ctx, &self.key, &self.value, &mut effects);
// Pre-emptively drop to the normal counterpart of edit_mode. If the
// advance succeeds, the trailing `EnterEditAtCursor` below will lift
// us back into editing on the new cell. If the advance aborts
// (e.g. already at bottom-right on Enter), `EnterEditAtCursor` is
// skipped and we land in normal mode — which is the desired
// "Enter at bottom-right commits and exits" behavior.
//
// It goes **before** the commit, not after (improvise-2zq). The
// commit can abort the batch itself — `AddCategory` does so when the
// document is at `MAX_CATEGORIES` — and an abort before this ran
// left the app in the editing mode, where `draw_bottom_bar` renders
// the minibuffer instead of the status line and the refusal the
// effect had just written was never drawn. Tidy-up goes where an
// abort cannot reach it; same rule as `ExecuteCommand`'s.
effects.push(effect::change_mode(exit_mode_for(&self.edit_mode)));
commit_cell_value(ctx, &self.key, &self.value, &mut effects);
if inserts_a_column(ctx, &self.key) {
// A schema change that inserts a column moves every index to its
// right, and the advance below is computed from `ctx` — the
// *pre-commit* layout, because `apply_effects` rebuilds only
// after the whole batch (gotcha 23). On the last row `Enter`
// wraps to `col + 1`, which after the insert is `+cat` again, so
// the editor reopened category creation and a user naming two
// categories in a row never reached the column they had made.
// `Tab` reached it the same way.
//
// Not advancing is also the *useful* answer rather than merely
// the safe one: `add_category` inserts the new column exactly
// where the cursor already is, so staying put lands on it.
//
// The measure `+` shares none of this, and not by luck — it is
// the last column, so there is no column to its right for an
// index to shift into, and both advances are already special-
// cased at the bottom-right edge.
effects.push(Box::new(effect::RebuildLayout));
return effects;
}
match self.advance {
AdvanceDir::Down => {
let adv = EnterAdvance {
@@ -542,6 +659,54 @@ impl Cmd for CommitBuffer {
}
}
/// Commit a save path from the save buffer.
///
/// The sibling of [`CommitExport`], and deliberately not the same command:
/// the export prompt writes CSV, this one writes the *model* through
/// `SaveAs`, which also adopts the path so a later plain `Save` has one
/// (improvise-lae).
///
/// An empty path bails rather than reaching `SaveAs`, which would otherwise
/// ask the storage backend to write to `""` and report its error instead of
/// the one the user can act on.
#[derive(Debug)]
pub struct CommitSave;
impl Cmd for CommitSave {
fn name(&self) -> &'static str {
"commit-save"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let path = read_buffer(ctx, "save").trim().to_string();
if path.is_empty() {
return vec![
effect::set_status("No path given — not saved"),
effect::change_mode(AppMode::Normal),
];
}
// The mode change comes **first**, and that ordering is the whole
// reason the user sees a failed save (found by Copilot on PR #27).
//
// `SaveAs` aborts the batch when the write fails (improvise-8tk's
// other arm), so anything after it is skipped. With the order
// reversed the batch stopped while still in `SavePrompt` — and
// `draw_bottom_bar` renders the minibuffer *instead of* the status
// line, so the "Save error:" set by the effect was never drawn. The
// save appeared to do nothing at all.
//
// Leading with the mode change means the error lands in `Normal`,
// where the status bar is what the bottom row draws. It costs the
// typed path, which the welded `clear-buffer` would have taken on a
// success anyway and which `EnterMode` clears on the next Ctrl-W
// regardless (gotcha 11) — so nothing durable is lost, and the
// convention that the side effect runs last and its message wins is
// the one gotcha 20 already states.
vec![
effect::change_mode(AppMode::Normal),
Box::new(effect::SaveAs(std::path::PathBuf::from(path))),
]
}
}
/// Commit an export from the export buffer.
#[derive(Debug)]
pub struct CommitExport;
@@ -276,6 +276,15 @@ impl Cmd for Quit {
/// Save then quit. The `Quit` only lands if the `Save` succeeds — a failed
/// save aborts the effect chain, so `wq`/`ZZ` leaves the user in the app
/// with the error visible rather than discarding the model (improvise-8tk).
///
/// **This looks like an `AAndB` command and is not one** (improvise-git).
/// `wq` is the *user's* name for the composition, not an invented internal
/// one: it is registered, so `:wq` reaches it, and `Quit`'s unsaved-changes
/// message advertises that spelling. A keymap `Binding::Sequence` cannot be
/// reached from the `:` line, so replacing this with one would either delete
/// the ex-command or leave two independently-written spellings of "save then
/// quit". One definition behind two surfaces is what this already is.
/// Pinned by `text_buffer::tests::colon_wq_saves_then_quits`.
#[derive(Debug)]
pub struct SaveAndQuit;
impl Cmd for SaveAndQuit {
@@ -301,6 +310,30 @@ impl Cmd for SaveAndQuit {
pub struct EditOrDrill {
pub edit_mode: AppMode,
}
impl EditOrDrill {
/// What to say when there is no cell under the cursor at all.
///
/// improvise-ct5: this used to say "cannot drill — no cell at cursor" in
/// both modes. Drilling is a pivot-mode concept, and in records mode —
/// where every regular category sits on `Axis::None`, so this branch is
/// the one an empty grid always takes — the user's model is "a table I
/// type into", with `o` one key away to add a row. Naming drilling there
/// described a mechanism the user is not using instead of the key that
/// would help.
///
/// The choice reads `edit_mode` — the argument the *keymap* supplies —
/// rather than asking `ctx` what mode it is in, so the command stays as
/// mode-agnostic as its doc comment claims and no runtime `is_records()`
/// check comes back (improvise-4ju).
fn nothing_at_cursor(&self) -> &'static str {
if self.edit_mode.is_records() {
"No record at cursor — press o to add a row"
} else {
"No cell at cursor"
}
}
}
impl Cmd for EditOrDrill {
fn name(&self) -> &'static str {
"edit-or-drill"
@@ -321,7 +354,7 @@ impl Cmd for EditOrDrill {
let is_aggregated = !is_synthetic && regular_none;
if is_aggregated {
let Some(key) = ctx.cell_key().clone() else {
return vec![effect::set_status("cannot drill — no cell at cursor")];
return vec![effect::set_status(self.nothing_at_cursor())];
};
return DrillIntoCell { key }.execute(ctx);
}
@@ -301,26 +301,48 @@ mod tests {
assert!(effects.is_empty());
}
/// improvise-aaw asked for this command to be replaced by a keymap
/// `Binding::Sequence [create-view X, switch-view X, enter-mode normal]`.
/// It cannot be: a `Sequence` carries **literal** args, and the name here
/// is computed from `ctx` — so a Sequence would hard-code one name and
/// the second `n` in the view panel would collide with the first.
///
/// The test was called `..._names_incrementally` and asserted only that
/// `CreateView`/`SwitchView`/`Normal` appeared — it never looked at the
/// name, which is the one thing that makes this command a command and
/// not a composition. Now it does, at two different view counts, because
/// a single count cannot tell "derived from the workbook" from "constant".
#[test]
fn create_and_switch_view_names_incrementally() {
let m = two_cat_model();
let mut m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let effects = CreateAndSwitchView.execute(&ctx);
let dbg = effects_debug(&effects);
let dbg = effects_debug(&CreateAndSwitchView.execute(&ctx));
assert!(
dbg.contains("CreateView"),
"Expected CreateView, got: {dbg}"
dbg.contains("CreateView(\"View 2\")"),
"Expected the name derived from the one existing view, got: {dbg}"
);
assert!(
dbg.contains("SwitchView"),
"Expected SwitchView, got: {dbg}"
dbg.contains("SwitchView(\"View 2\")"),
"Expected a switch to the view just created, got: {dbg}"
);
assert!(
dbg.contains("Normal"),
"Expected return to Normal, got: {dbg}"
);
drop(ctx);
// A second view in the workbook must move the name. A literal arg in
// a keymap Sequence could not, which is why aaw was declined.
m.create_view("View 2");
let layout = make_layout(&m);
let ctx = make_ctx(&m, &layout, &reg);
let dbg = effects_debug(&CreateAndSwitchView.execute(&ctx));
assert!(
dbg.contains("CreateView(\"View 3\")"),
"the name must follow the workbook, got: {dbg}"
);
}
#[test]
@@ -637,6 +659,18 @@ impl Cmd for SwitchViewAtCursor {
}
/// Create a new view, switch to it, and return to Normal mode.
///
/// **Not a shim, despite the name** (improvise-aaw). The new view's name is
/// derived from `ctx` — `format!("View {}", views.len() + 1)` — and a keymap
/// `Binding::Sequence` can only carry literal args, so the proposed
/// `[create-view X, switch-view X, enter-mode normal]` would hard-code one
/// name and the second `n` in the view panel would collide with the first.
/// The derivation is the decision that makes this a command.
///
/// The ticket's two fallbacks are both behaviour changes wanting a product
/// decision, not refactors: prompting for a name is a new feature, and
/// making `create-view` switch by default changes `:create-view` for scripts
/// and duplicates what `grid.rs` does by hand for `_Drill`/`_Records`.
#[derive(Debug)]
pub struct CreateAndSwitchView;
impl Cmd for CreateAndSwitchView {
@@ -600,6 +600,7 @@ pub fn default_registry() -> CmdRegistry {
},
);
r.register_nullary(|| Box::new(CommitExport));
r.register_nullary(|| Box::new(CommitSave));
r.register_nullary(|| Box::new(ExecuteCommand));
// ── Wizard ───────────────────────────────────────────────────────────
@@ -94,6 +94,35 @@ mod tests {
);
}
/// improvise-git proposed deleting the `SaveAndQuit` command and binding
/// `ZZ` to a `Binding::Sequence [w, force-quit]` instead — the repo's
/// "do not name a command AAndB" principle.
///
/// That premise is incomplete, and this test is why. `wq` is not only a
/// keybinding: it is a **registered command name**, so `:wq` reaches it
/// through the registry, and `Quit`'s own unsaved-changes message
/// advertises it ("Use :q! to force quit or :wq to save+quit"). A keymap
/// `Sequence` is not reachable from the `:` line, so deleting the command
/// deletes the ex-command with it.
///
/// Keeping the command *and* adding the Sequence would leave two
/// independently-written spellings of "save then quit", which is the
/// duplication this codebase keeps curing rather than creating. So this
/// pins the surface, and the conversion is declined on it.
#[test]
fn colon_wq_saves_then_quits() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let mut bufs = HashMap::new();
bufs.insert("command".to_string(), "wq".to_string());
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.buffers = &bufs;
let dbg = effects_debug(&ExecuteCommand.execute(&ctx));
assert!(dbg.contains("Save"), "`:wq` must save, got: {dbg}");
assert!(dbg.contains("Quit"), "`:wq` must then quit, got: {dbg}");
}
/// improvise-6sq: `ExecuteCommand` appends `change_mode(Normal)` unless
/// some effect reports `changes_mode()`. `EnterEditAtCursor` sets
/// `view_state.mode` but did not override it, so
@@ -364,17 +393,34 @@ impl Cmd for ExecuteCommand {
match crate::command::parse::parse_line_with(ctx.registry, &raw) {
Ok(cmds) => {
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
// Leave the command line **first**, before anything that can
// fail runs.
//
// Still decided at apply time rather than here, because
// ViewBack, ViewForward and StartImportWizard move the mode
// only on some runs and no up-front answer is right
// (improvise-toc). Running it at the head does not weaken
// that: whatever a later effect sets, it sets *after* this,
// so the mode a `:` line ends in is unchanged in every case
// — the only difference is what happens when the batch never
// reaches the end.
//
// Which is the point. A failed `Save`/`SaveAs` sets
// `abort_effects` (gotcha 21), and an abort at the tail ate
// this restoration too, stranding the user in `CommandMode`
// with the stale command line drawn — where
// `draw_bottom_bar` renders the minibuffer *instead of* the
// status line, so the error the effect had just set was
// invisible. The abort must stop the *chain* (`:w bad . q`
// must not quit) and must not stop the *bookkeeping*, and
// ordering answers that without giving any effect a "runs
// anyway" flag: tidy-up goes where an abort cannot reach it.
let mut effects: Vec<Box<dyn Effect>> = vec![Box::new(
effect::ReturnToNormalUnlessMoved(ctx.mode.clone()),
)];
for cmd in cmds {
effects.extend(cmd.execute(ctx));
}
// Return to Normal unless one of the effects above moves the
// mode. Decided at apply time, not here: ViewBack,
// ViewForward and StartImportWizard change it only on some
// runs, so no up-front answer is right (improvise-toc).
effects.push(Box::new(effect::ReturnToNormalUnlessMoved(
ctx.mode.clone(),
)));
effects
}
Err(msg) => {
+70 -1
View File
@@ -54,6 +54,51 @@ fn run(
/// Turn a binding into effects. The one place a keymap lookup becomes a
/// mutation, and the reason `Binding` itself can be registry-free data.
///
/// # Why a `Sequence` flattens, and why it stays that way (improvise-0hb)
///
/// Every step is executed against the **same pre-dispatch `ctx`**, and the
/// effects are concatenated into one vec that `App::apply_effects` runs as
/// one batch. So a later *command* cannot observe an earlier one's effects —
/// only a later *effect* can. improvise-0hb proposed closing that: apply
/// effects so far between steps, rebuild the layout, build a fresh
/// `CmdContext`, dispatch the next. The design pass says **no**, on three
/// findings.
///
/// **It would silently reopen improvise-8tk, a P1 data loss.**
/// `apply_effects` resets `abort_effects` at entry *and* again when it
/// breaks, because the flag is per batch. Applying between steps therefore
/// dispatches step 2 into a fresh batch with the flag cleared, so the `Quit`
/// after a failed `Save` would land — the exact silent discard 8tk fixed.
/// Gotcha 21's claim that the abort "carries across sequence steps" is true
/// *because* of this flattening, not in spite of it. It was also untested
/// until now: no shipped `Sequence` has a first step that can abort, and
/// `ZZ` is a prefix onto the single `wq` command.
/// `app.rs::a_sequence_whose_first_step_aborts_does_not_run_the_second` is
/// the guard, and it fails when the abort is made per-step.
///
/// **Almost nothing would benefit.** Of the shipped `Sequence` bindings,
/// all but one come from `TextEntry` and are either `[<commit>, clear-buffer
/// <buf>]` or `[clear-buffer <buf>, enter-mode <exit>]` — `clear-buffer` and
/// `enter-mode` take their operand as a literal arg and read nothing from
/// `ctx`, so a refreshed `ctx` would change no outcome. The single genuine
/// composition is records `o` = `[add-record-row, enter-edit-at-cursor]`,
/// and it already works, because `EnterEditAtCursorCmd` fronts its effect
/// with `RebuildLayout` and the effect re-reads the layout itself (gotcha
/// 23). Every Esc and every commit keypress would pay a layout rebuild and
/// a `CmdContext` construction — which recomputes column widths — to buy
/// that nothing.
///
/// **And the ticket's motivating rewrite does not typecheck as a binding
/// anyway.** improvise-git's `[(w, []), (force-quit, [])]` cannot dispatch:
/// `w` is `register_pure`, whose interactive constructor rejects an empty
/// arg list, so `run` returns `None` and the whole binding is dead. The
/// nullary spelling is `save`. `every_bound_command_name_is_registered`
/// checks exactly this (gotcha 15) and would have caught it.
///
/// If command-level transactionality is ever wanted, it wants its own
/// campaign: the abort must become a property of the *sequence* rather than
/// of the batch before any step is applied separately.
fn effects_of(
binding: &Binding,
registry: &CmdRegistry,
@@ -385,6 +430,27 @@ mod tests {
}
}
/// improvise-th7m: `F1` was bound twice on consecutive lines — first to a
/// bare `enter-mode` with no args, then to `enter-mode ["help"]`. The
/// first was dead, because `Keymap::bind` *inserts* and the later binding
/// wins; had it ever been the surviving one it would have failed
/// `require_args` at dispatch and made `F1` do nothing.
///
/// The risk in deleting a line of a duplicated pair is deleting the wrong
/// one, so this pins which binding `F1` is meant to carry: the same one
/// `?` carries, which is what identified it as the intended survivor.
#[test]
fn f1_and_question_mark_both_open_help() {
let ks = KeymapSet::default_keymaps();
for key in [KeyCode::F(1), KeyCode::Char('?')] {
assert_eq!(
bound(&ks, &ModeKey::Normal, key),
("enter-mode".to_string(), vec!["help".to_string()]),
"{key:?} should open the help overlay"
);
}
}
#[test]
fn editing_mode_has_any_char_and_esc() {
let ks = KeymapSet::default_keymaps();
@@ -582,6 +648,7 @@ mod tests {
ModeKey::CategoryAdd => AppMode::category_add(),
ModeKey::ItemAdd => AppMode::item_add("Type".to_string()),
ModeKey::ExportPrompt => AppMode::export_prompt(),
ModeKey::SavePrompt => AppMode::save_prompt(),
ModeKey::CommandMode => AppMode::command_mode(),
ModeKey::ImportWizard => AppMode::ImportWizard,
ModeKey::RecordsNormal => AppMode::RecordsNormal,
@@ -784,6 +851,7 @@ mod tests {
(AppMode::category_add(), COMMIT_STAY),
(AppMode::item_add("Region".into()), COMMIT_STAY),
(AppMode::export_prompt(), COMMIT_LEAVE),
(AppMode::save_prompt(), COMMIT_LEAVE),
(AppMode::command_mode(), COMMIT_LEAVE),
]
}
@@ -1002,7 +1070,7 @@ mod binding_dump {
#[cfg(test)]
mod tests_support {
use super::*;
pub const ALL_MODES: [ModeKey; 16] = [
pub const ALL_MODES: [ModeKey; 17] = [
ModeKey::Normal,
ModeKey::Help,
ModeKey::FormulaPanel,
@@ -1014,6 +1082,7 @@ mod tests_support {
ModeKey::CategoryAdd,
ModeKey::ItemAdd,
ModeKey::ExportPrompt,
ModeKey::SavePrompt,
ModeKey::CommandMode,
ModeKey::SearchMode,
ModeKey::ImportWizard,
+643 -14
View File
@@ -1450,6 +1450,299 @@ mod tests {
);
}
/// improvise-2zq step 2: the `+cat` column creates a category and its
/// column, without leaving the grid.
///
/// Driven through real keys. The assertion that earns its place is the
/// last one: the new column is **usable immediately**, which is the whole
/// point — `records_columns` filters on `kind.is_regular()` and never
/// requires items, so a category with no items is a column the moment it
/// exists.
#[test]
fn the_new_category_column_creates_a_category_and_its_column() {
let mut app = records_model_with_two_rows();
let plus_cat = (0..app.layout.col_count())
.find(|i| app.layout.col_label(*i) == crate::view::NEW_CATEGORY_COL)
.expect("the records grid offers a +cat column");
app.cursor_mut().selected = (0, plus_cat);
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
for c in "Product".chars() {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
app.rebuild_layout();
assert!(
app.model_state.workbook.model.category("Product").is_some(),
"the category is created"
);
assert!(
app.layout.is_records_mode(),
"and creating it does not eject the user (step 1)"
);
let cols: Vec<String> = (0..app.layout.col_count())
.map(|i| app.layout.col_label(i))
.collect();
assert_eq!(
cols,
vec![
"Region",
"Product",
crate::view::NEW_CATEGORY_COL,
"meas1",
"meas2",
crate::view::NEW_MEASURE_COL
],
"the new coordinate column appears immediately, before the \
still-available +cat, got {cols:?}"
);
}
/// improvise-2zq, found by Copilot on PR #27: creating a category from
/// `+cat` shifted the grid under the advance that followed it.
///
/// `add_category` inserts the new coordinate column **immediately before
/// `+cat`**, so every column index from `+cat` rightwards moves by one.
/// `CommitAndAdvance` then computed its advance from `ctx`, whose layout
/// is the pre-commit one (gotcha 23: `apply_effects` rebuilds only after
/// the whole batch). On the last row `Enter` wraps to the next column —
/// and the index it wrapped to was `+cat` *again* after the rebuild.
///
/// So the failure is not "lands one column off". It **reopens category
/// creation**: a user naming two categories in a row never reaches the
/// column they just made, and gets an unbounded `+cat` loop instead.
///
/// The last row is the arm that wraps, so it is the arm tested.
#[test]
fn enter_on_plus_cat_in_the_last_row_does_not_reopen_category_creation() {
let mut app = records_model_with_two_rows();
let plus_cat = (0..app.layout.col_count())
.find(|i| app.layout.col_label(*i) == crate::view::NEW_CATEGORY_COL)
.expect("+cat column");
let last_row = app.layout.row_count() - 1;
app.cursor_mut().selected = (last_row, plus_cat);
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
for c in "Product".chars() {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
let (_, col) = app.cursor().selected;
let landed = app.layout.col_label(col);
assert_ne!(
landed,
crate::view::NEW_CATEGORY_COL,
"the advance must not land back on +cat — that is the loop, and \
it is what a stale column index produces"
);
assert_eq!(
landed, "Product",
"and it lands on the column just created, which is the useful \
answer rather than merely a safe one: the insert happens at the \
cursor, so staying put arrives there"
);
}
/// The same stale index reached `Tab`, which advances right rather than
/// wrapping. Both directions read the pre-commit layout, so both are
/// covered — one arm of a two-arm rule is the shape
/// `doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)`
/// names.
#[test]
fn tab_on_plus_cat_does_not_reopen_category_creation() {
let mut app = records_model_with_two_rows();
let plus_cat = (0..app.layout.col_count())
.find(|i| app.layout.col_label(*i) == crate::view::NEW_CATEGORY_COL)
.expect("+cat column");
app.cursor_mut().selected = (0, plus_cat);
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
for c in "Product".chars() {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
.unwrap();
let (_, col) = app.cursor().selected;
assert_ne!(
app.layout.col_label(col),
crate::view::NEW_CATEGORY_COL,
"Tab must not land back on +cat either"
);
assert_eq!(app.layout.col_label(col), "Product");
}
/// Committing the `+cat` name creates the **column** and does not write
/// the coordinate on the row the cursor was on.
///
/// The deliberate answer to the ticket's third question, and the reason
/// is gotcha 1: setting a coordinate moves *every* measure cell in the
/// row onto it. That is a data migration across N cells, and attaching it
/// to the same Enter that made a schema change — in an app with no undo —
/// is a bigger single keystroke than the measure `+` asks for. The
/// follow-up edit is one column away.
#[test]
fn the_new_category_column_creates_the_column_but_writes_no_coordinate() {
let mut app = records_model_with_two_rows();
let plus_cat = (0..app.layout.col_count())
.find(|i| app.layout.col_label(*i) == crate::view::NEW_CATEGORY_COL)
.expect("+cat column");
app.cursor_mut().selected = (0, plus_cat);
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
for c in "Product".chars() {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
app.rebuild_layout();
let product_col = (0..app.layout.col_count())
.find(|i| app.layout.col_label(*i) == "Product")
.expect("the new column exists");
for row in 0..app.layout.row_count() {
assert_eq!(
app.layout.records_display(row, product_col).as_deref(),
Some(""),
"row {row} must have no coordinate for a category just created"
);
}
}
/// `MAX_CATEGORIES` is a reachable refusal on this path, and the refusal
/// has to be **visible**.
///
/// `draw_bottom_bar` renders the minibuffer instead of the status line,
/// so a message written while an editor is open is never drawn. The
/// commit path therefore drops out of editing *before* running the add,
/// and `AddCategory` aborts the batch on refusal so the trailing
/// `EnterEditAtCursor` cannot re-open the editor over the message.
///
/// The limit itself is checked in exactly one place — `Model::add_category`
/// — and nothing here restates it; this fills the document to the limit
/// by asking for categories until it refuses.
#[test]
fn the_new_category_column_refuses_past_the_limit_where_the_user_can_see_it() {
let mut app = records_model_with_two_rows();
let mut n = 0;
while app
.model_state
.workbook
.add_category(format!("Filler{n}"))
.is_ok()
{
n += 1;
assert!(n < 100, "add_category must refuse eventually");
}
app.rebuild_layout();
app.view_state.status_msg.clear();
let plus_cat = (0..app.layout.col_count())
.find(|i| app.layout.col_label(*i) == crate::view::NEW_CATEGORY_COL)
.expect("+cat column");
app.cursor_mut().selected = (0, plus_cat);
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
for c in "TooMany".chars() {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert!(
app.model_state.workbook.model.category("TooMany").is_none(),
"the category is refused"
);
assert!(
!app.view_state.status_msg.is_empty(),
"the refusal is reported"
);
assert!(
app.view_state.mode.minibuffer().is_none(),
"and reported where it is drawn: a mode with a minibuffer hides \
the status line, so the user would see nothing at all. mode is \
{:?}, message {:?}",
app.view_state.mode,
app.view_state.status_msg
);
}
/// improvise-2zq step 1: naming a category in records mode used to eject
/// the user from the grid they were standing in.
///
/// Records mode is `_Index` on Row and `_Dim` on Column, with every
/// regular category on `Axis::None`. `View::on_category_added` seated a
/// new regular category on the first axis no *regular* category held —
/// Row — and **bumped the virtual out of the way** to get there. So `N`,
/// a name, `Enter` moved `_Index` off Row, records detection stopped
/// matching, and the grid silently became a pivot.
///
/// Driven through the real keys rather than through `on_category_added`,
/// because that is how the bug was found and it is what makes this
/// evidence rather than a restatement of the fix.
#[test]
fn naming_a_category_in_records_mode_does_not_eject_the_user() {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_item("Region", "East").unwrap();
let mut app = App::new(wb, None);
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
.unwrap();
assert!(app.layout.is_records_mode(), "setup: in records mode");
app.handle_key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::NONE))
.unwrap();
for c in "Product".chars() {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
app.rebuild_layout();
assert!(
app.model_state.workbook.model.category("Product").is_some(),
"setup: the category really was created"
);
assert!(
app.layout.is_records_mode(),
"naming a category must not leave records mode; axes are {:?}",
app.model_state
.workbook
.active_view()
.category_axes
.iter()
.collect::<Vec<_>>()
);
assert_eq!(
app.model_state.workbook.active_view().axis_of("Product"),
crate::view::Axis::None,
"records mode keeps every regular category on None, and \
`records_columns` picks it up as a column from `is_regular` \
alone — never from an axis"
);
let cols: Vec<String> = (0..app.layout.col_count())
.map(|i| app.layout.col_label(i))
.collect();
assert!(
cols.iter().any(|c| c == "Product"),
"the new category must appear as a records column immediately, \
got {cols:?}"
);
}
/// improvise-6sq: `i` and `o` want opposite behavior on an empty records
/// grid, which is why `edit-or-drill` and `enter-edit-at-cursor` cannot
/// be merged into one command. Entering records mode puts every regular
@@ -1458,6 +1751,17 @@ mod tests {
/// there is none, so `i` reports "cannot drill". `o` must still open an
/// edit there (see `add_record_row_in_empty_records_view_creates_first_row`),
/// so routing it through the same drill check would break it.
///
/// improvise-ct5 changed the *message* and deliberately not the
/// divergence. The outcome — `i` opens no editor where there is nothing
/// to edit — is right, and `o` one key away is the affordance for adding
/// a row. What was wrong is that the message said "cannot drill", a
/// pivot-mode concept in a mode whose model is "a table I type into".
/// Making `i` behave like `o` here (the ticket's option (b)) would
/// collapse exactly the divergence improvise-6sq exists to record, so
/// the assertion below still pins that `i` does not enter editing; only
/// the wording assertion moved, and it now also names what must *not*
/// appear.
#[test]
fn i_and_o_diverge_on_an_empty_records_grid() {
let mut wb = Workbook::new("T");
@@ -1477,16 +1781,68 @@ mod tests {
"i must not enter editing when there is no cell to edit"
);
assert!(
app.view_state.status_msg.contains("cannot drill"),
!app.view_state.status_msg.to_lowercase().contains("drill"),
"records mode must not talk about drilling, got status {:?}",
app.view_state.status_msg
);
assert!(
app.view_state.status_msg.contains("press o to add a row"),
"the message should name the key that does add a row, got {:?}",
app.view_state.status_msg
);
}
/// The negative arm of improvise-ct5, and the reason the wording is
/// keyed off the mode the keymap supplies rather than reworded once.
///
/// Without this, "say the records sentence everywhere" passes the test
/// above — and it would promise `o`, which adds no row in pivot mode,
/// trading one misleading message for another.
///
/// The fixture is an empty pivot grid that still counts as aggregated:
/// `Region` on Row with no items makes `cell_key()` `None`, and `Extra`
/// cycled to `Axis::None` is the regular category that makes
/// `EditOrDrill` take the drill branch.
#[test]
fn the_empty_grid_message_is_not_the_records_one_in_pivot_mode() {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_category("Extra").unwrap();
wb.active_view_mut()
.set_axis("Extra", crate::view::Axis::None);
let mut app = App::new(wb, None);
app.rebuild_layout();
assert_eq!(app.layout.row_count(), 0, "setup: empty pivot grid");
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.view_state.mode,
AppMode::Normal,
"i must not enter editing when there is no cell to edit"
);
assert!(
!app.view_state.status_msg.contains("add a row"),
"`o` adds no row in pivot mode; it must not be offered, got {:?}",
app.view_state.status_msg
);
assert!(
app.view_state.status_msg.contains("No cell at cursor"),
"got status {:?}",
app.view_state.status_msg
);
}
/// A blank model has no measures, so wide format shows only the trailing
/// new-measure column. Typing a name there creates the measure, which
/// gives it a real column to type values into — records mode stays
/// self-sufficient for onboarding (improvise-c5v).
/// A blank model has no categories and no measures, so wide format shows
/// exactly the two creation columns. Typing a name into the new-measure
/// one creates the measure, which gives it a real column to type values
/// into — records mode stays self-sufficient for onboarding
/// (improvise-c5v).
///
/// improvise-2zq widened that: a blank grid now offers `+cat` beside `+`,
/// so both halves of a schema can be bootstrapped without leaving the
/// grid. This test still pins the measure half; the category half is
/// `the_new_category_column_creates_a_category_and_its_column`.
#[test]
fn blank_model_bootstraps_its_first_measure_from_the_new_measure_column() {
use crate::model::cell::CellKey;
@@ -1499,8 +1855,8 @@ mod tests {
.collect();
assert_eq!(
cols,
vec![crate::view::NEW_MEASURE_COL],
"a blank model offers only the new-measure column"
vec![crate::view::NEW_CATEGORY_COL, crate::view::NEW_MEASURE_COL],
"a blank model offers exactly the two creation columns"
);
// `o` seeds a row and opens an edit on it.
@@ -1508,7 +1864,17 @@ mod tests {
.unwrap();
assert_eq!(app.layout.row_count(), 1);
// Naming a measure in the + column creates it.
// The cursor lands at column 0, which in a blank grid is now `+cat`
// rather than `+` (improvise-2zq) — a record needs coordinates
// before it needs measures, so that order is deliberate. Move to the
// new-measure column to exercise the measure half.
// `o` left us editing column 0, so leave that edit before moving.
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.unwrap();
let new_measure_col = app.layout.col_count() - 1;
app.cursor_mut().selected = (0, new_measure_col);
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
for c in "Rev".chars() {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
@@ -1521,12 +1887,17 @@ mod tests {
.collect();
assert_eq!(
cols,
vec!["Rev", crate::view::NEW_MEASURE_COL],
"the named measure becomes a real column"
vec![
crate::view::NEW_CATEGORY_COL,
"Rev",
crate::view::NEW_MEASURE_COL
],
"the named measure becomes a real column, between the two \
creation columns"
);
// Typing into that column writes the value.
let rev_col = 0;
let rev_col = 1;
app.cursor_mut().selected = (0, rev_col);
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
@@ -1588,9 +1959,15 @@ mod tests {
.collect();
assert_eq!(
cols,
vec!["Region", "meas1", "meas2", crate::view::NEW_MEASURE_COL],
"setup: category column, one column per measure, then the \
new-measure column"
vec![
"Region",
crate::view::NEW_CATEGORY_COL,
"meas1",
"meas2",
crate::view::NEW_MEASURE_COL
],
"setup: category columns, the new-category column, one column \
per measure, then the new-measure column (improvise-2zq)"
);
app
}
@@ -1617,6 +1994,208 @@ mod tests {
);
}
/// improvise-lae end to end: `Ctrl-W`, a path, `Enter`.
///
/// The failing case is the interesting one, and it pins an **ordering**
/// (found by Copilot on PR #27). `SaveAs` aborts the batch on a failed
/// write, so whatever `CommitSave` emits after it is skipped. A first
/// version emitted `[SaveAs, ChangeMode(Normal)]`, which left the app in
/// `SavePrompt` — and `draw_bottom_bar` renders the minibuffer *instead
/// of* the status line, so the "Save error:" the effect had just set was
/// never drawn. The save appeared to do nothing whatsoever.
///
/// Leading with the mode change puts the error in `Normal`, where the
/// bottom row is the status bar. This test therefore asserts the mode
/// *left* the prompt, which is the opposite of what it asserted before —
/// deliberately, and it is the only thing keeping the two effects in
/// that order.
#[test]
fn ctrl_w_prompts_for_a_path_and_a_failed_write_reports_it_where_it_shows() {
let mut app = App::new(Workbook::new("T"), None);
app.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL))
.unwrap();
assert!(
matches!(app.view_state.mode, AppMode::SavePrompt { .. }),
"Ctrl-W should open the save prompt, got {:?}",
app.view_state.mode
);
let path = "/nonexistent-dir-improvise-lae/m.improv";
for c in path.chars() {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
assert_eq!(
app.view_state.buffers.get("save").map(String::as_str),
Some(path),
"typing must reach the buffer the prompt names"
);
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert!(
app.view_state.status_msg.starts_with("Save error:"),
"got {:?}",
app.view_state.status_msg
);
assert_eq!(
app.view_state.mode,
AppMode::Normal,
"the error must be reported in a mode whose bottom row is the \
status bar, not behind a minibuffer that hides it"
);
assert!(
app.view_state.mode.minibuffer().is_none(),
"setup for the assertion above: only a mode with no minibuffer \
draws the status line at all"
);
}
/// Copilot round 2 reported that `Ctrl-W` from a records grid returns to
/// `Normal`, losing the records keymap while the layout is still a
/// records layout. It is right, and it is a **class**: every minibuffer
/// mode reachable from `RecordsNormal` exits to `Normal`, because the
/// exit is a constant baked into `TextEntry::new(..., "normal")` and
/// into each commit command.
///
/// The point of this test is that the class **predates** `SavePrompt`:
/// `:` and `Ctrl-E` do exactly the same thing and always have. That is
/// why the fix is not "make `SavePrompt` remember its origin" — that
/// would leave two of the three arms broken, which is the shape
/// `doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)`
/// names.
///
/// The binding *table* is not involved and was checked rather than
/// reasoned about: an `IMPROVISE_BINDING_DUMP` diff shows
/// `RecordsNormal` gains `Ctrl-W` and loses nothing, so `with_parent`
/// inheritance (gotcha 13) is intact. What is lost is the runtime mode.
#[test]
fn every_minibuffer_mode_entered_from_records_returns_to_plain_normal() {
for key in [
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
] {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_item("Region", "East").unwrap();
let mut app = App::new(wb, None);
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.view_state.mode,
AppMode::RecordsNormal,
"setup: R enters records mode"
);
app.handle_key(key).unwrap();
assert!(
app.view_state.mode.minibuffer().is_some(),
"setup: {:?} opens a minibuffer, got {:?}",
key.code,
app.view_state.mode
);
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.view_state.mode,
AppMode::Normal,
"{:?} cancels back to plain Normal, not RecordsNormal — the \
layout is still a records layout, so this is the bug, and \
it is the same for all three keys",
key.code
);
assert!(
app.layout.is_records_mode(),
"...and the layout really is still records, which is what \
makes the mode wrong rather than merely different"
);
}
}
/// A failing `:` command must not strand the user in `CommandMode`.
///
/// Found by Copilot on PR #27, and it is the sharper form of gotcha 21.
/// The abort is *supposed* to stop the chain — `:w /bad . q` must not
/// quit. What it must not also stop is the **bookkeeping**: the mode
/// restoration that `ExecuteCommand` appends. Aborting before that ran
/// left the app in `CommandMode` with the stale command line still
/// drawn, and `draw_bottom_bar` renders the minibuffer *instead of* the
/// status line — so the "Save error:" set one effect earlier was
/// invisible. A failed save looked exactly like a key that did nothing.
///
/// The answer is **ordering, not classification**: a batch puts its
/// tidy-up where an abort cannot reach it, which is in front. No effect
/// needs a new "runs anyway" flag, and the rule generalises to any
/// command composing bookkeeping with something that can fail.
///
/// Both arms are here because they fail in different places: no path at
/// all is `Save`'s guard (aborting since improvise-8tk, so this arm was
/// broken before this branch), and a bad path is `SaveAs`'s write
/// (aborting since f0f400d on this branch).
#[test]
fn a_failed_colon_write_lands_in_normal_where_its_error_is_drawn() {
for (line, expect) in [
("w", "No file path"),
("w /nonexistent-dir-improvise-lae/m.improv", "Save error:"),
] {
let mut app = App::new(Workbook::new("T"), None);
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
.unwrap();
for c in line.chars() {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert!(
app.view_state.status_msg.contains(expect),
":{line} should report {expect:?}, got {:?}",
app.view_state.status_msg
);
assert_eq!(
app.view_state.mode,
AppMode::Normal,
":{line} must leave the command line, or the status it just \
set is hidden behind the minibuffer"
);
}
}
/// The other half of the same rule: the abort must still stop the
/// *chain*. `:w <bad> . q` is improvise-8tk's data loss spelled on one
/// `:` line, and moving the mode restoration to the front must not have
/// bought the fix above at the price of that one.
#[test]
fn a_failed_colon_write_still_refuses_to_quit_after_it() {
let mut app = App::new(Workbook::new("T"), None);
app.view_state.buffers.insert(
"command".to_string(),
"w /nonexistent-dir-improvise-lae/m.improv . q".to_string(),
);
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
.unwrap();
// `:` clears the buffer on entry (gotcha 11), so type it instead.
for c in "w /nonexistent-dir-improvise-lae/m.improv . q".chars() {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert_ne!(
app.view_state.mode,
AppMode::Quit,
"a failed write must still abort the rest of the `:` line"
);
assert!(
app.view_state.status_msg.starts_with("Save error:"),
"got {:?}",
app.view_state.status_msg
);
}
/// `ZZ` is save-and-quit. With no file path the save fails and aborts the
/// chain (improvise-8tk), which also proves the second Z reached `wq`.
#[test]
@@ -1638,6 +2217,56 @@ mod tests {
);
}
/// Gotcha 21 claims a failed `Save` aborts across `Binding::Sequence`
/// steps as well as within one command's effect list. Nothing tested it:
/// no shipped `Sequence` has a first step that can abort, and `ZZ` is a
/// *prefix* resolving to the single `wq` command, so
/// `prefix_z_then_z_reaches_save_and_quit` exercises the within-a-command
/// case only. The claim was structurally true and unguarded.
///
/// It is true because `Keymap::dispatch` flattens a `Sequence` into ONE
/// effect vec and `App::apply_effects` runs that as one batch, breaking
/// on `abort_effects`. It is *load-bearing*, because it is the reason a
/// `[w, force-quit]` Sequence would be safe — the argument the
/// improvise-git grooming note unblocked on.
///
/// And it is exactly what improvise-0hb would remove. Command-level
/// transactionality means applying effects *between* steps, and
/// `apply_effects` resets `abort_effects` at entry and again on abort, so
/// step 2 would be dispatched into a fresh batch with the flag cleared —
/// the `Quit` after a failed save would land, silently reopening the
/// improvise-8tk P1 data loss. This is the guard that would catch it.
#[test]
fn a_sequence_whose_first_step_aborts_does_not_run_the_second() {
use improvise_protocol::keymap::Keymap;
// No file path, so `w` fails and sets `abort_effects`.
let mut app = App::new(Workbook::new("T"), None);
let key = KeyCode::Char('x');
let mods = KeyModifiers::NONE;
let mut km = Keymap::new();
km.bind_seq(key, mods, vec![("save", vec![]), ("force-quit", vec![])]);
let effects = {
let ctx = app.cmd_context(key, mods);
app.keymap_set.dispatch_transient(&km, &ctx, key, mods)
}
.expect("both steps resolve in the registry");
app.apply_effects(effects);
assert!(
app.view_state.status_msg.contains("No file path"),
"setup: the save must actually have failed, got {:?}",
app.view_state.status_msg
);
assert_ne!(
app.view_state.mode,
AppMode::Quit,
"the abort must carry into the next Sequence step, not just the \
next effect of the same command"
);
}
/// A prefix is consumed whether or not the next key matches, so a stray
/// key after `g` does nothing rather than being treated as a fresh
/// normal-mode binding.
+88 -3
View File
@@ -95,8 +95,20 @@ macro_rules! simple_effect {
pub struct AddCategory(pub String);
impl Effect for AddCategory {
fn apply(&self, app: &mut App) {
// A refusal aborts the batch, for the same reason a failed save does
// (gotcha 21): what follows assumes the category exists. From the
// `+cat` column (improvise-2zq) the rest of the batch would advance
// the cursor and re-open an editor on a column that was never
// created — and re-opening the minibuffer is also what would *hide*
// this message, since `draw_bottom_bar` renders the minibuffer
// instead of the status line.
//
// `MAX_CATEGORIES` is a reachable refusal on that path rather than a
// theoretical one, and the limit is checked in exactly one place —
// `Model::add_category` — so nothing here restates it.
if let Err(e) = app.model_state.workbook.add_category(&self.0) {
app.view_state.status_msg = e.to_string();
app.abort_effects = true;
}
}
fn kind(&self) -> EffectKind {
@@ -647,8 +659,9 @@ impl Effect for ApplyAndClearDrill {
apply_coordinate_rename(app, row, category, new_value);
}
// Never staged — `commit_cell_value` applies a new measure
// immediately, since it is a schema change and not a cell.
RecordCol::NewMeasure => {}
// or category immediately, since a schema change is not a
// cell edit and has nothing to roll back with the drill.
RecordCol::NewMeasure | RecordCol::NewCategory => {}
}
}
app.model_state.dirty = true;
@@ -758,7 +771,7 @@ fn save_to_current_path(
state: &crate::ui::app::ModelState,
) -> Result<String, String> {
state.file_path.as_ref().map_or_else(
|| Err("No file path — use :w <path>".to_string()),
|| Err("No file path — Ctrl-W to name one, or :w <path>".to_string()),
|path| {
crate::persistence::save_to(storage, &state.workbook, path)
.map(|()| format!("Saved to {}", path.display()))
@@ -777,8 +790,12 @@ impl Effect for SaveAs {
app.model_state.dirty = false;
app.view_state.status_msg = format!("Saved to {}", self.0.display());
}
// The same abort `Save` owes (improvise-8tk). `SaveAs` is the
// other arm of that rule and was left out of the original fix,
// so `:w /bad/path . q` quit on a failed write.
Err(e) => {
app.view_state.status_msg = format!("Save error: {e}");
app.abort_effects = true;
}
}
}
@@ -1944,6 +1961,74 @@ mod tests {
);
}
/// BUG (improvise-lae, found reviewing it): improvise-8tk fixed **one
/// arm** of a rule with two. `Save` aborts the batch when the write
/// fails; `SaveAs` reported the error to the status bar and let the
/// batch continue, which is the same silent discard 8tk closed — the
/// shape `doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)`
/// names, and the three tests above are evidence about `Save` only.
///
/// It is reachable today: the `:` line composes with ` . `, so
/// `:w /bad/path . q` builds exactly this batch, and
/// `ExecuteCommand` concatenates the two commands' effects into one.
/// improvise-lae's new save prompt also commits to `SaveAs`, so the
/// arm was about to carry more traffic.
///
/// Note what the abort does and does not buy the prompt: the typed path
/// survives for the user to correct, but the message does not reach the
/// screen, because `draw_bottom_bar` draws the minibuffer instead of the
/// status line while a prompt is open. That is pre-existing and reported
/// separately; the data-loss half is what this fixes.
#[test]
fn failed_save_as_aborts_the_chain() {
let mut app = test_app();
app.model_state.dirty = true;
app.apply_effects(vec![
Box::new(SaveAs(PathBuf::from(
"/nonexistent-dir-improvise-lae/model.improv",
))),
Box::new(ChangeMode(AppMode::Quit)),
]);
assert_ne!(
app.view_state.mode,
AppMode::Quit,
"a failed save-as must not let the trailing Quit apply"
);
assert!(
app.model_state.dirty,
"a failed save-as must leave the model dirty"
);
assert!(
app.view_state.status_msg.starts_with("Save error:"),
"expected the save error to stay on screen, got: {}",
app.view_state.status_msg
);
}
/// The positive arm: a `SaveAs` that succeeds must not abort, or every
/// `:w <path>` would swallow whatever followed it. Without this,
/// "always abort" passes the test above.
#[test]
fn successful_save_as_lets_the_chain_continue() {
let dir = std::env::temp_dir().join("improvise-lae-save-as-ok");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("model.improv");
let mut app = test_app();
app.model_state.dirty = true;
app.apply_effects(vec![
Box::new(SaveAs(path.clone())),
Box::new(ChangeMode(AppMode::Quit)),
]);
assert_eq!(app.view_state.mode, AppMode::Quit);
assert!(!app.model_state.dirty);
assert_eq!(
app.model_state.file_path.as_deref(),
Some(path.as_path()),
"a successful save-as adopts the path"
);
let _ = std::fs::remove_file(&path);
}
/// A successful save must not abort — `wq` still quits.
#[test]
fn successful_save_lets_the_chain_continue() {
+6 -4
View File
@@ -315,10 +315,12 @@ fn cell_id(app: &App, row: usize, col: usize) -> Option<CellId> {
RecordCol::Measure(measure) => Some(measure.clone()),
// Not cells to write into. Editing a category column renames a
// coordinate across every measure cell in the row (gotcha 1) and
// the `+` column creates a measure; both are different intents
// from `SetCell`, so neither gets an id it could be composed
// with.
RecordCol::Category(_) | RecordCol::NewMeasure => return None,
// the `+`/`+cat` columns create a measure or a category; all are
// different intents from `SetCell`, so none gets an id it could
// be composed with.
RecordCol::Category(_) | RecordCol::NewMeasure | RecordCol::NewCategory => {
return None;
}
};
return Some(CellId::Record(RecordCell {
record: RecordHash::of(record),
+39 -16
View File
@@ -1,7 +1,7 @@
use std::rc::Rc;
use crate::model::Model;
use crate::model::category::{CategoryKind, DIM_CATEGORY, INDEX_CATEGORY, MEASURE_CATEGORY};
use crate::model::category::{DIM_CATEGORY, INDEX_CATEGORY, MEASURE_CATEGORY};
use crate::model::cell::{CellKey, CellValue};
use crate::view::{Axis, View};
@@ -10,6 +10,14 @@ use crate::view::{Axis, View};
/// measure column (improvise-c5v).
pub const NEW_MEASURE_COL: &str = "+";
/// The label of the column that creates a new **category** (improvise-2zq).
///
/// It sits at the end of the category block rather than trailing the whole
/// grid, so each `+` is adjacent to what it adds to: `+cat` after the
/// coordinates, `+` after the measures. Two trailing columns would have made
/// the user read a label to tell them apart; position says it instead.
pub const NEW_CATEGORY_COL: &str = "+cat";
/// Extract (record_index, column_index) from a synthetic records-mode
/// CellKey. Returns None for normal pivot-mode keys.
///
@@ -84,6 +92,9 @@ pub enum RecordCol {
Measure(String),
/// The trailing column that creates a new measure.
NewMeasure,
/// The column that creates a new category (improvise-2zq). Sits after
/// the coordinates, where the column it creates will appear.
NewCategory,
}
/// One entry on a grid axis: either a visual group header or a data-item tuple.
@@ -241,11 +252,10 @@ impl GridLayout {
})
.collect();
// Detect records mode: _Index on Row and _Dim on Col
let is_records_mode = row_cats.iter().any(|c| CategoryKind::of_name(c).is_index())
&& col_cats.iter().any(|c| CategoryKind::of_name(c).is_dim());
if is_records_mode {
// Records mode is a property of the view's axes, and `View` owns the
// one definition of it (improvise-2zq): `on_category_added` must ask
// the same question, and two derivations would drift.
if view.is_records_layout() {
Self::build_records_mode(model, view, page_coords, none_cats)
} else {
let row_items = cross_product(model, view, &row_cats);
@@ -321,6 +331,7 @@ impl GridLayout {
})
.map(|c| RecordCol::Category(c.to_string()))
.collect();
cols.push(RecordCol::NewCategory);
cols.extend(
model
.category(MEASURE_CATEGORY)
@@ -337,6 +348,7 @@ impl GridLayout {
match col {
RecordCol::Category(name) | RecordCol::Measure(name) => name.clone(),
RecordCol::NewMeasure => NEW_MEASURE_COL.to_string(),
RecordCol::NewCategory => NEW_CATEGORY_COL.to_string(),
}
}
@@ -368,7 +380,7 @@ impl GridLayout {
),
// Always blank: it is where a new measure name is typed, never
// a place a value is stored.
RecordCol::NewMeasure => Some(String::new()),
RecordCol::NewMeasure | RecordCol::NewCategory => Some(String::new()),
}
}
@@ -606,8 +618,8 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry>
#[cfg(test)]
mod tests {
use super::{
AxisEntry, GridLayout, NEW_MEASURE_COL, RecordCol, data_items, prune_axis_items,
synthetic_record_info,
AxisEntry, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, data_items,
prune_axis_items, synthetic_record_info,
};
use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;
@@ -836,22 +848,33 @@ mod tests {
.collect();
assert_eq!(
cols,
vec!["Region", "Revenue", "Cost", NEW_MEASURE_COL],
"categories, then one column per measure, then the new-measure column"
vec![
"Region",
NEW_CATEGORY_COL,
"Revenue",
"Cost",
NEW_MEASURE_COL
],
"categories, the new-category column, one column per measure, \
then the new-measure column — each `+` adjacent to what it adds \
to (improvise-2zq)"
);
assert_eq!(
layout.record_col(0),
Some(&RecordCol::Category("Region".to_string()))
);
assert_eq!(layout.record_col(1), Some(&RecordCol::NewCategory));
assert_eq!(
layout.record_col(1),
layout.record_col(2),
Some(&RecordCol::Measure("Revenue".to_string()))
);
assert_eq!(layout.record_col(3), Some(&RecordCol::NewMeasure));
assert_eq!(layout.record_col(4), Some(&RecordCol::NewMeasure));
// Both measures of the one row are readable side by side.
assert_eq!(layout.records_display(0, 1).as_deref(), Some("100"));
assert_eq!(layout.records_display(0, 2).as_deref(), Some("50"));
// Both measures of the one row are readable side by side, and the
// `+cat` column between them and the coordinate is always blank.
assert_eq!(layout.records_display(0, 1).as_deref(), Some(""));
assert_eq!(layout.records_display(0, 2).as_deref(), Some("100"));
assert_eq!(layout.records_display(0, 3).as_deref(), Some("50"));
}
/// On initial entry into records mode, rows should be in a deterministic
+2 -1
View File
@@ -4,6 +4,7 @@ pub mod types;
pub use axis::{Axis, AxisColor};
pub use layout::{
AxisEntry, GridLayout, NEW_MEASURE_COL, RecordCol, RecordRow, synthetic_record_info,
AxisEntry, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, RecordRow,
synthetic_record_info,
};
pub use types::{DRILL_VIEW, RECORDS_VIEW, VIRTUAL_VIEW_NAMES, View};
+125 -41
View File
@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use super::axis::Axis;
use crate::model::category::CategoryKind;
fn default_prune() -> bool {
true
@@ -56,52 +57,70 @@ impl View {
}
}
/// True when this view's axes spell **records mode**: `_Index` on Row and
/// `_Dim` on Column.
///
/// The one definition — `GridLayout` asks this rather than re-deriving it,
/// because two answers to "is this a records view" is the disease this
/// codebase keeps curing (improvise-edp). It is a property of the *view's
/// axes* alone, which is why it lives here and not on the layout.
pub fn is_records_layout(&self) -> bool {
self.categories_on(Axis::Row)
.iter()
.any(|c| CategoryKind::of_name(c).is_index())
&& self
.categories_on(Axis::Column)
.iter()
.any(|c| CategoryKind::of_name(c).is_dim())
}
/// Seat a newly added category on an axis.
///
/// Virtual (underscore) categories default to `Axis::None`. A regular
/// category takes the first slot no *regular* category holds — Row, then
/// Column, then Page.
///
/// # A virtual on an axis is a configuration, not a vacancy (improvise-2zq)
///
/// This used to **bump** a virtual off Row or Column to make room, on the
/// assumption that a virtual sitting there was a placeholder. It never is,
/// and the assumption cost two live bugs:
///
/// - `_Index` on Row together with `_Dim` on Column *is* records mode, and
/// the only production writers of those axes are the records and drill
/// setups. Bumping `_Index` therefore **ejected the user from the grid
/// they were standing in**: naming a category with `N` in records mode
/// silently returned them to a pivot.
/// - `_Measure` on Column is an ordinary user configuration (it is what
/// `web/demo.improv` ships). Bumping it made the measures vanish from
/// the grid the moment a second regular category was added.
///
/// So a virtual is never moved. A records view seats the new category on
/// `Axis::None`, which is where records mode keeps **every** regular
/// category and where `GridLayout::records_columns` — which filters on
/// `kind.is_regular()` and never looks at an axis — picks it up as a
/// column immediately. Elsewhere the new category simply joins whatever
/// virtual is already there, which nests a header rather than destroying
/// one.
///
/// The Row → Column → Page progression for regular categories is
/// unchanged, and `a_pivot_view_still_seats_categories_row_then_column_then_page`
/// is the negative arm that says so — without it, "always seat on None"
/// would pass every assertion about records mode.
pub fn on_category_added(&mut self, cat_name: &str) {
if !self.category_axes.contains_key(cat_name) {
// Virtual/underscore categories default to Axis::None.
// Regular categories auto-assign: first → Row, second → Column, rest → Page.
// If a virtual currently holds Row or Column and a regular category needs
// the slot, bump the virtual to None.
let axis = if cat_name.starts_with('_') {
let axis = if cat_name.starts_with('_') || self.is_records_layout() {
Axis::None
} else {
let regular_rows: Vec<String> = self
.categories_on(Axis::Row)
.into_iter()
.filter(|c| !c.starts_with('_'))
.map(String::from)
.collect();
let regular_cols: Vec<String> = self
.categories_on(Axis::Column)
.into_iter()
.filter(|c| !c.starts_with('_'))
.map(String::from)
.collect();
if regular_rows.is_empty() {
// Bump any virtual on Row to None
let bump: Vec<String> = self
.categories_on(Axis::Row)
let holds_a_regular = |axis: Axis| {
self.categories_on(axis)
.into_iter()
.filter(|c| c.starts_with('_'))
.map(String::from)
.collect();
for c in bump {
self.category_axes.insert(c, Axis::None);
}
Axis::Row
} else if regular_cols.is_empty() {
let bump: Vec<String> = self
.categories_on(Axis::Column)
.into_iter()
.filter(|c| c.starts_with('_'))
.map(String::from)
.collect();
for c in bump {
self.category_axes.insert(c, Axis::None);
}
Axis::Column
} else {
Axis::Page
.any(|c| !c.starts_with('_'))
};
match (holds_a_regular(Axis::Row), holds_a_regular(Axis::Column)) {
(false, _) => Axis::Row,
(true, false) => Axis::Column,
(true, true) => Axis::Page,
}
};
self.category_axes.insert(cat_name.to_string(), axis);
@@ -278,6 +297,71 @@ mod tests {
v
}
/// improvise-2zq, the **negative arm**. The fix makes a records view seat
/// a new category on `None`; without this, "always seat on None" would
/// pass every assertion about records mode while quietly deleting the
/// auto-assignment `on_category_added` exists for.
///
/// Probing confirmed this arm is the load-bearing one: seating every
/// category on `None` fails 12 tests, while removing the virtual bump
/// fails none at all — which is how the bump came to be wrong in two
/// directions with nothing noticing.
#[test]
fn a_pivot_view_still_seats_categories_row_then_column_then_page() {
let v = view_with_cats(&["A", "B", "C", "D"]);
assert_eq!(v.axis_of("A"), Axis::Row, "first regular → Row");
assert_eq!(v.axis_of("B"), Axis::Column, "second regular → Column");
assert_eq!(v.axis_of("C"), Axis::Page, "the rest → Page");
assert_eq!(v.axis_of("D"), Axis::Page);
}
/// A records view seats a new regular category on `None`, where records
/// mode keeps all of them — and, crucially, leaves `_Index`/`_Dim` alone,
/// so the view is still a records view afterwards.
#[test]
fn a_records_view_seats_a_new_category_on_none_and_stays_a_records_view() {
let mut v = view_with_cats(&["_Index", "_Dim", "Region"]);
v.set_axis("_Index", Axis::Row);
v.set_axis("_Dim", Axis::Column);
v.set_axis("Region", Axis::None);
assert!(v.is_records_layout(), "setup");
v.on_category_added("Product");
assert_eq!(v.axis_of("Product"), Axis::None);
assert_eq!(v.axis_of("_Index"), Axis::Row, "_Index must not be bumped");
assert_eq!(v.axis_of("_Dim"), Axis::Column, "_Dim must not be bumped");
assert!(v.is_records_layout(), "still a records view");
}
/// The second thing the bump broke, and the reason the fix is about
/// virtuals in general rather than about records mode: `_Measure` on
/// Column is an ordinary configuration — it is what `web/demo.improv`
/// ships — and adding a second regular category used to kick it off the
/// axis, making every measure vanish from the grid.
///
/// This view is deliberately **not** a records layout, so it exercises
/// the pivot path rather than the early return above.
#[test]
fn a_measure_on_column_is_not_bumped_off_it() {
let mut v = view_with_cats(&["_Measure", "Region"]);
v.set_axis("_Measure", Axis::Column);
assert_eq!(v.axis_of("Region"), Axis::Row, "setup");
assert!(!v.is_records_layout(), "setup: a pivot view, not records");
v.on_category_added("Product");
assert_eq!(
v.axis_of("_Measure"),
Axis::Column,
"the user's measure axis must survive a new category"
);
assert_eq!(
v.axis_of("Product"),
Axis::Column,
"the new category joins Column, nesting a header rather than \
destroying one — Row already holds a regular category"
);
}
#[test]
fn first_category_assigned_to_row() {
let v = view_with_cats(&["Region"]);
+113 -2
View File
@@ -133,8 +133,17 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
}
if all_string {
let distinct: HashSet<&str> = values.iter().filter_map(|v| v.as_str()).collect();
let distinct_vec: Vec<String> = distinct.into_iter().map(String::from).collect();
// First-seen order, not hash order: `build_model` adds items in
// this order and `IndexMap` keeps it, so a hash order made the
// same CSV import to a different document every time (gotcha
// 18). The set is here to deduplicate and never to iterate.
let mut seen: HashSet<&str> = HashSet::new();
let distinct_vec: Vec<String> = values
.iter()
.filter_map(|v| v.as_str())
.filter(|s| seen.insert(s))
.map(String::from)
.collect();
let n = distinct_vec.len();
// Try chrono-based date detection
@@ -289,4 +298,106 @@ mod tests {
assert_eq!(date_prop.kind, FieldKind::TimeCategory);
assert_eq!(date_prop.date_format, Some("%m/%d/%Y".to_string()));
}
/// `01/02/2025` and `02/03/2025` parse under `%m/%d/%Y` and under
/// `%d/%m/%Y`, and nothing in the data can tell them apart. The heuristic
/// is first-match over `DATE_FORMATS`, so the American reading wins.
///
/// That is *a* defensible answer and not obviously the right one — a
/// European CSV imports with its days and months swapped, silently, and
/// the derived `_Month`/`_Quarter` categories inherit the error. What
/// makes it tolerable is that the wizard shows the detected format and
/// lets the user change it, which is the same argument
/// `detect_short_year_format` already records. It is pinned here so that
/// changing the answer is a decision somebody makes rather than a side
/// effect of reordering the table.
#[test]
fn an_ambiguous_date_is_read_american_first() {
let samples = vec!["01/02/2025", "02/03/2025"];
assert_eq!(detect_date_format(&samples), Some("%m/%d/%Y".to_string()));
// The precondition that makes this a statement about ambiguity rather
// than about one format simply fitting: the other reading fits too.
for s in &samples {
assert!(
NaiveDate::parse_from_str(s, "%d/%m/%Y").is_ok(),
"{s} must also parse day-first, or there is no ambiguity to resolve"
);
}
}
/// Where the two readings genuinely *can* be told apart, the unambiguous
/// value decides — first match still, but `%m/%d/%Y` now fails.
#[test]
fn a_day_first_only_value_settles_the_ambiguity() {
let samples = vec!["01/02/2025", "25/12/2025"];
assert_eq!(detect_date_format(&samples), Some("%d/%m/%Y".to_string()));
}
/// improvise-7yx, found while pinning the above: `analyze_records` built
/// its distinct values by collecting into a `HashSet` and iterating it, so
/// the order was a different one in every process. This is gotcha 18's bug
/// in a second place, and it reaches further here than a tidy-up:
/// `build_model` adds items in exactly this order, `IndexMap` preserves
/// insertion order, and persistence relies on it — so the *same CSV*
/// imported twice produced two different `.improv` files and two different
/// row orders on screen.
///
/// The answer is first-seen order: deterministic, and the order the
/// records themselves are in, which is the one a user can predict.
///
/// Five runs is enough to see it because each `HashSet` gets its own
/// `RandomState`; the pre-fix code produced five different orders here.
#[test]
fn the_distinct_values_are_in_first_seen_order() {
let vals = [
"hotel", "alpha", "golf", "bravo", "foxtrot", "charlie", "echo", "delta",
];
let records: Vec<Value> = vals
.iter()
.map(|v| serde_json::json!({"Name": v, "Amount": 1}))
.collect();
for _ in 0..5 {
let proposals = analyze_records(&records);
let name = proposals.iter().find(|p| p.field == "Name").unwrap();
assert_eq!(
name.distinct_values, vals,
"the proposed items must be the records' own order"
);
}
}
/// The consequence of the same bug that a user would have found hardest to
/// explain. `detect_date_format` tests only the first ten samples, so when
/// the sample order was a hash order, *which* ten it saw varied — and with
/// it the detected format, whenever the eleventh-and-later values are the
/// ones that rule a format out.
///
/// Here twelve values are ambiguous and one (`25/12/2025`) is day-first
/// only. In hash order it lands inside the first ten about half the time,
/// so the column imported as `%d/%m/%Y` on some runs and `%m/%d/%Y` on
/// others — every date in it off by the difference. In first-seen order it
/// is last, deliberately: the answer is `%m/%d/%Y` *stably*, which is the
/// property being asserted. The truncation itself is unchanged and is
/// still a real limit.
#[test]
fn the_detected_date_format_does_not_depend_on_the_hash_seed() {
let mut vals: Vec<String> = (1..=12).map(|d| format!("01/{d:02}/2025")).collect();
vals.push("25/12/2025".to_string());
assert!(
vals.len() > 10,
"precondition: more values than detect_date_format looks at"
);
let records: Vec<Value> = vals
.iter()
.map(|v| serde_json::json!({"When": v}))
.collect();
for _ in 0..5 {
let proposals = analyze_records(&records);
let when = proposals.iter().find(|p| p.field == "When").unwrap();
assert_eq!(when.date_format, Some("%m/%d/%Y".to_string()));
}
}
}
@@ -359,6 +359,112 @@ mod tests {
assert!(warnings.is_empty());
}
// ── Adversarial inputs (improvise-7yx item 3) ─────────────────────
//
// A header name becomes a *category name* and survives into the `.improv`
// file, so anything that rides along invisibly in one — a byte-order mark,
// a carriage return — is silent corruption rather than cosmetic. These
// four inputs are what a spreadsheet actually exports.
/// Excel writes a UTF-8 BOM. The `csv` crate strips a leading one, so the
/// first header is `Region` and not `\u{feff}Region`.
///
/// The control is what makes this an assertion rather than a tautology: a
/// BOM in the *second* header is not leading, is not stripped, and shows
/// up in the key. So the check can see a BOM where there is one.
#[test]
fn a_leading_utf8_bom_is_not_part_of_the_first_header() {
let input = "\u{feff}Region,Revenue\nEast,1\n";
assert_eq!(
&input.as_bytes()[..3],
b"\xef\xbb\xbf",
"precondition: the input really carries a BOM"
);
let (records, warnings) = parse_csv_bytes_with_warnings(input.as_bytes()).unwrap();
assert!(warnings.is_empty());
assert_eq!(records.len(), 1);
let keys: Vec<&String> = records[0].as_object().unwrap().keys().collect();
assert_eq!(keys, ["Region", "Revenue"]);
let (control, _) =
parse_csv_bytes_with_warnings("Region,\u{feff}Revenue\nEast,1\n".as_bytes()).unwrap();
assert!(
control[0]
.as_object()
.unwrap()
.contains_key("\u{feff}Revenue"),
"control: a non-leading BOM survives, so the assertion above can see one"
);
}
/// Windows line endings. The `\r` belongs to the terminator, not to the
/// last field of the row — a `Revenue` of `1\r` would be a string, not a
/// number, and every row would import as text.
///
/// The control: a *lone* `\r` is itself a record terminator to this
/// reader, so `Ea\rst` splits one row into two. That is what a stray
/// carriage return does when it is not part of a CRLF pair, and it is why
/// "two records, clean values" below is a real observation.
#[test]
fn crlf_line_endings_leave_no_carriage_return_in_the_data() {
let (records, warnings) =
parse_csv_bytes_with_warnings(b"Region,Revenue\r\nEast,1\r\nWest,2\r\n").unwrap();
assert!(warnings.is_empty());
assert_eq!(records.len(), 2);
assert_eq!(records[0]["Region"], Value::String("East".to_string()));
assert_eq!(
records[0]["Revenue"],
Value::Number(serde_json::Number::from(1))
);
assert_eq!(records[1]["Region"], Value::String("West".to_string()));
let (control, _) = parse_csv_bytes_with_warnings(b"Region,Revenue\nEa\rst,1\n").unwrap();
assert_eq!(
control.len(),
2,
"control: a lone CR terminates a record here, so a mishandled CRLF would show"
);
}
/// An empty file is no records and no complaint. The wizard's own
/// preconditions are what refuse to build a model from nothing
/// (`wizard.rs::validation_error`); the parser's job is to say truthfully
/// that there was nothing there.
#[test]
fn an_empty_file_is_no_records_and_no_warnings() {
let (records, warnings) = parse_csv_bytes_with_warnings(b"").unwrap();
assert!(records.is_empty());
assert!(warnings.is_empty());
}
/// A header with no rows under it is the same answer, and is the case a
/// user actually hits — an export filtered down to nothing.
#[test]
fn a_header_with_no_rows_is_no_records() {
let (records, warnings) = parse_csv_bytes_with_warnings(b"Region,Revenue\n").unwrap();
assert!(records.is_empty());
assert!(warnings.is_empty());
}
/// One column is the degenerate shape for everything downstream that
/// splits a row into coordinates and a measure: there is one field, it is
/// present, and it is not mistaken for a short row.
#[test]
fn a_single_column_file_yields_one_field_per_record() {
let (records, warnings) = parse_csv_bytes_with_warnings(b"Region\nEast\nWest\n").unwrap();
assert!(
warnings.is_empty(),
"a full one-column row is not a short row: {warnings:?}"
);
assert_eq!(records.len(), 2);
for (record, expected) in records.iter().zip(["East", "West"]) {
let map = record.as_object().unwrap();
assert_eq!(map.len(), 1);
assert_eq!(map["Region"], Value::String(expected.to_string()));
}
}
#[test]
fn parse_checking_csv_format() {
// Simulates the format of /Users/edwlan/Downloads/Checking1.csv
+150 -6
View File
@@ -85,14 +85,24 @@ pub fn save_to(storage: &dyn Storage, workbook: &Workbook, path: &Path) -> Resul
/// Load a workbook from `path` in `storage`.
pub fn load_from(storage: &dyn Storage, path: &Path) -> Result<Workbook> {
let bytes = storage.read(path)?;
let text = if is_gzip(path) {
let mut s = String::new();
GzDecoder::new(BufReader::new(&bytes[..])).read_to_string(&mut s)?;
s
let stored = storage.read(path)?;
// Decompression and UTF-8 decoding are two failures with two answers, so
// they get two contexts. `read_to_string` would have fused them, and a
// valid gzip stream holding invalid UTF-8 would then have been reported as
// unreadable gzip — a wrong diagnosis of a document that decompressed
// perfectly well. Decoding once, after, also leaves one answer to "is this
// UTF-8" rather than one per branch.
let bytes = if is_gzip(path) {
let mut out = Vec::new();
GzDecoder::new(BufReader::new(&stored[..]))
.read_to_end(&mut out)
.with_context(|| format!("{} is not readable gzip", path.display()))?;
out
} else {
String::from_utf8(bytes).with_context(|| format!("{} is not UTF-8", path.display()))?
stored
};
let text =
String::from_utf8(bytes).with_context(|| format!("{} is not UTF-8", path.display()))?;
if text.trim_start().starts_with('{') {
serde_json::from_str(&text).context("Failed to deserialize workbook")
} else {
@@ -245,6 +255,140 @@ mod tests {
);
}
// ── gzip corruption (improvise-7yx item 5) ──────────────────────────
//
// `save_and_load_roundtrip_gzip` above covers only the happy path. The
// two ways a `.gz` document actually arrives damaged are a truncated
// write (the disk filled, or the process died mid-save) and a flipped
// byte, and they are worth keeping apart: the first is *incomplete* and
// the second is *wrong*, which is the difference between "you lost the
// tail" and "you cannot trust any of it".
/// Writes `good` gzip bytes, hands `corrupt` to the loader in their place
/// and returns the error — after asserting that the corruption left the
/// bytes still looking like the gzip document `load_from` will route to
/// the decoder, so the failure is decompression and not misrouting.
fn load_corrupt_gzip(corrupt: impl Fn(&mut Vec<u8>)) -> anyhow::Error {
use crate::storage::{MemoryFs, Storage};
use std::path::Path;
let fs = MemoryFs::new();
let path = Path::new("model.improv.gz");
super::save_to(&fs, &storage_fixture(), path).unwrap();
let mut bytes = fs.read(path).unwrap();
let before = bytes.clone();
corrupt(&mut bytes);
assert_ne!(bytes, before, "the corruption must change the bytes");
assert_eq!(
&bytes[..2],
&[0x1f, 0x8b],
"precondition: still a gzip document by its magic bytes"
);
fs.write(path, &bytes).unwrap();
super::load_from(&fs, path).expect_err("corrupt gzip must not load")
}
/// A `.gz` document cut short surfaces as an incomplete stream, named as
/// `UnexpectedEof`. The kind is asserted rather than merely the failure,
/// because "an error occurred" would also pass if the loader had stopped
/// decompressing altogether and choked on the compressed bytes as text.
#[test]
fn a_truncated_gzip_document_fails_as_an_unexpected_eof() {
let err = load_corrupt_gzip(|bytes| {
let half = bytes.len() / 2;
bytes.truncate(half);
});
let io = err
.downcast_ref::<std::io::Error>()
.unwrap_or_else(|| panic!("expected an io::Error, got {err:?}"));
assert_eq!(io.kind(), std::io::ErrorKind::UnexpectedEof);
assert!(
err.to_string().contains("model.improv.gz"),
"the message must name the document, got {err}"
);
}
/// A flipped byte fails the gzip checksum instead — a different kind,
/// `InvalidInput`, and that distinction is the reason to test both. A
/// single test against one corruption could not tell "the decoder
/// refuses damaged input" from "the decoder refuses short input".
#[test]
fn a_gzip_document_with_a_flipped_byte_fails_its_checksum() {
// The byte flipped is in the CRC32 field — the first four of the
// eight-byte gzip trailer — and not in the DEFLATE payload (Copilot,
// PR #22). A payload byte also yields `InvalidInput`, but it can do so
// for malformed compressed data rather than for the mismatch this test
// is named after, and the name would then be a guess about which.
// Corrupting the trailer leaves the compressed stream decodable, so
// the only thing left to fail is the checksum.
let err = load_corrupt_gzip(|bytes| {
let crc = bytes.len() - 8;
bytes[crc] ^= 0xff;
});
let io = err
.downcast_ref::<std::io::Error>()
.unwrap_or_else(|| panic!("expected an io::Error, got {err:?}"));
assert_eq!(io.kind(), std::io::ErrorKind::InvalidInput);
// The source error, not `err`: anyhow's `to_string` shows the outermost
// context only, and the context is the sentence naming the document.
assert!(
io.to_string().contains("checksum"),
"the kind alone does not separate a bad checksum from bad DEFLATE, \
so the cause must say which, got {io}"
);
assert!(
err.to_string().contains("model.improv.gz"),
"the message must name the document, got {err}"
);
}
/// The third failure, and the one that made the first two worth keeping
/// apart in the code as well as in the tests (Copilot, PR #22). A gzip
/// stream can be perfectly well-formed and still hold bytes that are not
/// UTF-8; `read_to_string` decodes as it decompresses, so a single context
/// around it called that document unreadable gzip — a wrong diagnosis of a
/// document that decompressed without complaint.
///
/// Decompression and decoding are separated, so this reports what the
/// uncompressed path would report for the same bytes. The precondition
/// asserts the payload really is invalid UTF-8; without it the test would
/// also pass on a document that merely failed to decompress, which is the
/// case the other two tests already own.
#[test]
fn a_valid_gzip_of_invalid_utf8_is_reported_as_not_utf8() {
use crate::storage::{MemoryFs, Storage};
use flate2::{Compression, write::GzEncoder};
use std::io::Write;
use std::path::Path;
let payload = b"v2025-04-09\n# \xff\xfe\n";
assert!(
String::from_utf8(payload.to_vec()).is_err(),
"precondition: the payload is not UTF-8"
);
let mut enc = GzEncoder::new(Vec::new(), Compression::default());
enc.write_all(payload).unwrap();
let bytes = enc.finish().unwrap();
let fs = MemoryFs::new();
let path = Path::new("model.improv.gz");
fs.write(path, &bytes).unwrap();
let err = super::load_from(&fs, path).expect_err("invalid UTF-8 must not load");
let msg = err.to_string();
assert!(
msg.contains("is not UTF-8"),
"expected the UTF-8 diagnosis, got: {msg}"
);
assert!(
!msg.contains("gzip"),
"the gzip stream was fine; blaming it is the bug: {msg}"
);
}
// ── autosave_path ───────────────────────────────────────────────────
#[test]
@@ -886,3 +886,80 @@ fn a_csv_header_with_an_embedded_newline_survives_a_save() {
loaded.model.categories.keys().collect::<Vec<_>>()
);
}
/// improvise-odbc: `bank-info.improv` in the repo root failed to load at
/// `- JORDAN MARTINEZ` (line 293) with `expected pipe_quoted`. The ticket
/// named two readings — a stale file, or a writer/reader gap in which
/// improvise emits an item line the grammar rejects — and said to decide by
/// asking whether improvise can *write* that line today.
///
/// It cannot. `item_list`'s `name` is `pipe_quoted | bare_name`, and
/// `bare_name` is `[A-Za-z_][A-Za-z0-9_-]*` with no space; `quote_name`
/// mirrors it character for character, so every one of these item names is
/// emitted pipe-quoted and read straight back. The file was hand-edited in
/// c48a5cd (the v2025-04-09 migration quoted the `Date` items and left the
/// `Entity` ones bare) and never round-tripped, so the reading is "stale
/// file", and the standing finding is that nothing exercised it.
///
/// This is the shape that finding turns into: the item names from the file
/// that motivated the ticket, asserted through the writer and back.
#[test]
fn an_item_name_the_item_line_cannot_carry_round_trips() {
let names = [
"JORDAN MARTINEZ",
"Holy Trinity Parish",
"CHECK # 445",
"02/14/2025",
"SANTAPAULATRAIL",
];
let mut m = Workbook::new("Bank");
m.add_category("Entity").unwrap();
for n in names {
m.add_item("Entity", n).unwrap();
}
let text = format_md(&m);
assert!(
text.contains("- |JORDAN MARTINEZ|"),
"a spaced item name must be written pipe-quoted, got:\n{text}"
);
assert!(
text.contains("SANTAPAULATRAIL") && !text.contains("|SANTAPAULATRAIL|"),
"a bare-legal item name must stay bare, got:\n{text}"
);
let loaded = parse_md(&text).unwrap_or_else(|e| panic!("{e}\n{text}"));
let got: Vec<&str> = loaded
.model
.category("Entity")
.unwrap()
.items
.values()
.map(|i| i.name.as_str())
.collect();
for n in names {
assert!(got.contains(&n), "{n:?} missing from {got:?}\n{text}");
}
}
/// The other half of the same finding: the exact line the file carries is
/// *not* something the reader accepts, so the file really was broken rather
/// than the error message being misleading. Asserting the failure keeps the
/// test above honest — without it, "the writer quotes it" would be consistent
/// with the reader also accepting the unquoted form.
#[test]
fn a_bare_item_name_with_a_space_is_rejected() {
let text = "v2025-04-09\n# Bank\n\n## Category: Entity\n- JORDAN MARTINEZ\n";
let err = parse_md(text).expect_err("a bare spaced item name must not parse");
let msg = err.to_string();
assert!(
msg.contains("JORDAN MARTINEZ"),
"the error should point at the offending line, got: {msg}"
);
let quoted = "v2025-04-09\n# Bank\n\n## Category: Entity\n- |JORDAN MARTINEZ|\n";
assert!(
parse_md(quoted).is_ok(),
"precondition: the same line quoted must parse"
);
}
+20 -1
View File
@@ -89,6 +89,7 @@ pub enum ModeKey {
CategoryAdd,
ItemAdd,
ExportPrompt,
SavePrompt,
CommandMode,
SearchMode,
ImportWizard,
@@ -111,6 +112,7 @@ impl ModeKey {
AppMode::CategoryAdd { .. } => Some(ModeKey::CategoryAdd),
AppMode::ItemAdd { .. } => Some(ModeKey::ItemAdd),
AppMode::ExportPrompt { .. } => Some(ModeKey::ExportPrompt),
AppMode::SavePrompt { .. } => Some(ModeKey::SavePrompt),
AppMode::CommandMode { .. } => Some(ModeKey::CommandMode),
AppMode::ImportWizard => Some(ModeKey::ImportWizard),
AppMode::RecordsNormal => Some(ModeKey::RecordsNormal),
@@ -570,7 +572,6 @@ impl Keymaps {
);
normal.bind(KeyCode::Char('/'), none, "search");
normal.bind(KeyCode::Char('s'), ctrl, "save");
normal.bind(KeyCode::F(1), none, "enter-mode");
normal.bind_args(KeyCode::F(1), none, "enter-mode", vec!["help".into()]);
normal.bind_args(KeyCode::Char('?'), none, "enter-mode", vec!["help".into()]);
@@ -619,6 +620,15 @@ impl Keymaps {
"enter-mode",
vec!["export-prompt".into()],
);
// Ctrl-S saves to the known path; Ctrl-W asks for one. The pair a
// user with no file path needs, and what the "No file path" status
// message points at (improvise-lae).
normal.bind_args(
KeyCode::Char('w'),
ctrl,
"enter-mode",
vec!["save-prompt".into()],
);
// Search / category add
normal.bind_args(
@@ -908,6 +918,15 @@ impl Keymaps {
),
);
set.insert(
ModeKey::SavePrompt,
Arc::new(
TextEntry::new(&AppMode::save_prompt(), "normal")
.commit(KeyCode::Enter, "commit-save", vec![])
.finish(),
),
);
set.insert(
ModeKey::CommandMode,
Arc::new(
+21 -1
View File
@@ -175,6 +175,11 @@ pub enum AppMode {
ExportPrompt {
minibuf: MinibufferConfig,
},
/// Ask for a path to write the *model* to. The sibling of `ExportPrompt`,
/// which writes CSV; this one commits to `SaveAs` (improvise-lae).
SavePrompt {
minibuf: MinibufferConfig,
},
/// Vim-style `:` command line
CommandMode {
minibuf: MinibufferConfig,
@@ -223,6 +228,7 @@ impl AppMode {
"records-editing" => Some(Self::records_editing()),
"formula-edit" => Some(Self::formula_edit()),
"export-prompt" => Some(Self::export_prompt()),
"save-prompt" => Some(Self::save_prompt()),
_ => None,
}
}
@@ -241,6 +247,7 @@ impl AppMode {
Self::TileSelect => "TILES",
Self::ImportWizard => "IMPORT",
Self::ExportPrompt { .. } => "EXPORT",
Self::SavePrompt { .. } => "SAVE AS",
Self::CommandMode { .. } => "COMMAND",
Self::Help => "HELP",
Self::Quit => "QUIT",
@@ -278,6 +285,7 @@ impl AppMode {
Self::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel",
Self::Help => "h/l:pages q/Esc:close",
Self::ExportPrompt { .. } => "Enter:export Esc:cancel — type a file path",
Self::SavePrompt { .. } => "Enter:save Esc:cancel — type a file path",
Self::Quit => "",
}
}
@@ -297,6 +305,7 @@ impl AppMode {
| Self::TileSelect
| Self::ImportWizard
| Self::ExportPrompt { .. }
| Self::SavePrompt { .. }
| Self::CommandMode { .. }
| Self::Help
| Self::Quit
@@ -314,7 +323,8 @@ impl AppMode {
| Self::CommandMode { minibuf, .. }
| Self::CategoryAdd { minibuf, .. }
| Self::ItemAdd { minibuf, .. }
| Self::ExportPrompt { minibuf, .. } => Some(minibuf),
| Self::ExportPrompt { minibuf, .. }
| Self::SavePrompt { minibuf, .. } => Some(minibuf),
_ => None,
}
}
@@ -400,6 +410,16 @@ impl AppMode {
},
}
}
pub fn save_prompt() -> Self {
Self::SavePrompt {
minibuf: MinibufferConfig {
buffer_key: "save".into(),
prompt: "save as: ".into(),
color: MinibufColor::Yellow,
},
}
}
}
/// UI session-state slice: mode, cursors, panels, buffers, navigation stacks,
+185 -4
View File
@@ -316,7 +316,10 @@ fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> Op
// ── mode entry ───────────────────────────────────────────────
"enter-mode" => {
let mode = AppMode::from_name(args.first()?)?;
Some(entered(mode))
match strands_the_user(&mode) {
true => None,
false => Some(entered(mode)),
}
}
// ── panels ───────────────────────────────────────────────────
@@ -429,6 +432,68 @@ fn write_buffer(name: &str, value: String) -> Command {
}
}
/// A prompt this client can *enter* but cannot *commit*.
///
/// `enter-mode` is trivially resolvable here, and that is the trap: the user
/// reaches the prompt, types a path, presses Enter — and the commit resolves
/// to nothing, because `Host::handle` answers a `Key` frame with an empty
/// vec (`doctrine(an_upstream_key_is_a_black_hole)`). A dead room is worse
/// than a locked door: the door costs one inert key, the room swallows work
/// the user believes was done.
///
/// Both prompts are host-only **by construction**, not by omission.
/// `effect::Save`, `SaveAs` and `ExportCsv` are all `EffectKind::ViewOnly`,
/// so the derivation that builds `ModelCommand` from the model effects
/// cannot name them — no client on any transport can ask its host to write a
/// file (gotcha 45). Closing this is not a resolver to add; it is a wire
/// operation that does not exist. In a browser it is also close to
/// meaningless: the worker persists on every `touches_document`, so the
/// document *is* the OPFS entry and there is no Ctrl-S semantics to offer.
///
/// Named as a property rather than matched at the call site
/// (`doctrine(two_questions_one_enum)`): "can this client finish what
/// entering the mode starts" is not the same question as "is this a
/// text-entry mode", and a third such prompt belongs here.
///
/// Found by Copilot on PR #27 against the newly added `SavePrompt`.
/// `ExportPrompt` had the identical trap already and is fixed with it —
/// fixing one arm of a rule with two is the shape
/// `doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)`
/// names.
///
/// # `CommandMode` is dead by **omission**, and listed anyway
///
/// Copilot found it on the next round, and it was a false negative in this
/// predicate's *first* version — which mattered more than its severity,
/// because `a_prompt_this_client_cannot_commit_is_never_entered` used `:`
/// as the negative arm that stops "refuse every mode" from passing. An arm
/// asserting something false does not merely fail to guard; it **certifies
/// the behaviour it was added to rule out.**
///
/// `:` resolves locally into `CommandMode` and typing appends locally, but
/// Enter is `[execute-command, clear-buffer]` and [`step`] has no
/// `execute-command` arm, so `resolve` returns `Upstream` and the frame is
/// dropped. Every ex-command behind `:` is inert for the same reason —
/// there is one door and no client arm for it — so refusing the mode
/// removes **no working surface**, only a room that swallows typing.
/// `the_command_line_cannot_be_committed_by_this_client` is the evidence,
/// not the assertion.
///
/// The distinction from the two prompts is worth keeping visible, because
/// it says who removes this line. They are dead **by construction**: no
/// `ModelCommand` can ever name `Save`/`SaveAs`/`ExportCsv`, so no resolver
/// would help. This is dead **by omission**: `execute-command` is
/// implementable client-side and improvise-e24v is scheduled to do it.
/// Listing it here is therefore also the named gap gotcha 47 asks for —
/// closing e24v has to come to this line and delete it, which a comment
/// could not have forced.
fn strands_the_user(mode: &AppMode) -> bool {
matches!(
mode,
AppMode::SavePrompt { .. } | AppMode::ExportPrompt { .. } | AppMode::CommandMode { .. }
)
}
/// `EnterMode::execute`: clear the incoming mode's minibuffer, then change.
fn entered(mode: AppMode) -> Vec<Command> {
let mut commands = Vec::new();
@@ -728,8 +793,16 @@ mod tests {
KeyEvent::new(code, KeyModifiers::NONE)
}
/// Every text-entry mode, built by its own constructor so a new one shows
/// up here as a missing line rather than as untested behaviour.
/// Every text-entry mode, built by its own constructor.
///
/// The comment here used to claim a new mode "shows up as a missing line
/// rather than as untested behaviour". It does not: this is a hand-written
/// list and nothing checks it against `AppMode`, so a new text-entry mode
/// is simply absent and silently uncovered — which is what happened to
/// `save_prompt` (improvise-lae) until it was added by hand. There is a
/// second hand-written copy in `improvise-command`'s
/// `keymap::tests::text_entry_modes_with_exits`. Deriving both from one
/// place is filed, not done here.
fn text_entry_modes() -> Vec<AppMode> {
vec![
AppMode::editing(),
@@ -739,6 +812,7 @@ mod tests {
AppMode::category_add(),
AppMode::item_add("Region".into()),
AppMode::export_prompt(),
AppMode::save_prompt(),
]
}
@@ -1006,6 +1080,17 @@ mod tests {
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL),
"force-quit",
),
// The two doors this client refuses rather than opens —
// `enter-mode` itself is resolvable, the commit behind it is
// not. See `strands_the_user`.
(
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
"enter-mode save-prompt (commit-save is the host's)",
),
(
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
"enter-mode export-prompt (commit-export is the host's)",
),
] {
assert_eq!(
resolve(&state, key),
@@ -1017,6 +1102,102 @@ mod tests {
}
}
/// The refusal above is only half the property. `Upstream` says the key
/// produced no `Command`; it does not say the client stayed put, and the
/// bug being guarded is precisely *entering a room with no exit*.
///
/// Found by Copilot on PR #27. Without `strands_the_user`, `Ctrl-W`
/// resolved `enter-mode save-prompt` locally — `ChangeMode(SavePrompt)`,
/// a perfectly good `Command` — and the user then typed a path into a
/// prompt whose Enter goes nowhere. `Ctrl-E` did the same into
/// `ExportPrompt` and had done since before this branch.
///
/// The negative arm matters as much: refusing *every* `enter-mode` would
/// pass the assertions above, so a mode the client can finish is checked
/// here too.
#[test]
fn a_prompt_this_client_cannot_commit_is_never_entered() {
let state = in_mode(AppMode::Normal);
for (key, mode) in [
(
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
"save-prompt",
),
(
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
"export-prompt",
),
] {
assert_eq!(
resolve(&state, key),
KeyOutcome::Upstream(key),
"{mode} must not be entered by a client that cannot commit it"
);
}
// The negative arm has **two halves**, and both are needed.
//
// Half one: a mode that is not refused is still entered. This is the
// half that goes through `strands_the_user`, so it is the only thing
// that can fail when the predicate degenerates to "refuse
// everything". `?` is honest here where `:` was not: Help has no
// commit at all, so nothing a user types into it can be swallowed,
// and Esc closes it locally. (Its page keys *are* upstream, which
// makes page navigation inert — but inert navigation loses no work,
// which is the line this predicate draws.)
assert_eq!(
resolve(&state, ev(KeyCode::Char('?'))),
KeyOutcome::Local(vec![Command::ChangeMode(AppMode::Help)]),
"a mode this client can finish must still be entered, or the \
guard above is just \"refuse every mode\""
);
// Half two: the property the predicate is *about* is finishing, not
// entering — so something has to press Enter and observe. The first
// version of this test asserted only that `:` entered its mode, and
// `:` is itself a dead room (see
// `the_command_line_cannot_be_committed_by_this_client`), so the arm
// was certifying the very behaviour it existed to rule out.
let mut client = client_on(record_cell());
client.state_mut().view.mode = AppMode::editing();
client
.state_mut()
.view
.buffers
.insert("edit".into(), "42".into());
let KeyOutcome::Local(commands) = resolve(client.state(), ev(KeyCode::Enter)) else {
panic!("Enter in Editing must resolve here — that is the arm");
};
assert!(
commands.iter().any(|c| matches!(c, Command::Model { .. })),
"and it must actually write something, not merely change mode: \
{commands:?}"
);
}
/// The evidence behind listing `CommandMode` in `strands_the_user`,
/// rather than the assertion that it is listed.
///
/// `:` opens locally and typing appends locally, but Enter is
/// `[execute-command, clear-buffer]` and `step` has no `execute-command`
/// arm — so the whole sequence fails to resolve, `Host::handle` drops
/// the frame, and every ex-command behind `:` is inert. That is what
/// makes refusing the mode cost no working surface.
///
/// When improvise-e24v gives this client `execute-command`, this test is
/// the one that should start failing, and the `CommandMode` arm of
/// `strands_the_user` is what it points at.
#[test]
fn the_command_line_cannot_be_committed_by_this_client() {
let mut state = in_mode(AppMode::command_mode());
state.view.buffers.insert("command".into(), "q".into());
assert_eq!(
resolve(&state, ev(KeyCode::Enter)),
KeyOutcome::Upstream(ev(KeyCode::Enter)),
"`:q` — and every other ex-command — resolves to nothing here"
);
}
/// The four keys an independent browser smoke test found inert, plus the
/// two the editing path needs. Each asserts the *shape* of what is now
/// composed, because "resolves locally" alone would pass on a wrong
@@ -1082,7 +1263,7 @@ mod tests {
/// `t` is a document mutation, so it carries a basis — and until one has
/// been published there is nothing truthful to claim, which is the
/// fourth `None` shape `Client::set_cell` already has (ADR 001).
/// fourth `None` shape `Client::typed_cell_frame` already has (ADR 001).
#[test]
fn transpose_needs_a_published_basis() {
let state = in_mode(AppMode::Normal);
+8 -159
View File
@@ -61,9 +61,7 @@ pub use keys::{BrowserKey, KeyOutcome, resolve};
pub use render::{RenderCatRow, RenderCell, RenderModel, RenderRow};
pub use state::ClientState;
use improvise_protocol::{
CellId, CellValue, ClientFrame, Command, ModelCommand, RecordCell, Snapshot,
};
use improvise_protocol::{CellId, ClientFrame, RecordCell, Snapshot};
use wasm_bindgen::prelude::*;
/// The handle a JS bootstrap holds for the lifetime of the tab.
@@ -131,20 +129,6 @@ impl Client {
self.state.revision
}
/// Compose a write to the cell at a viewport position.
///
/// `value_json` is a serialized `CellValue`; everything crossing this
/// boundary is JSON, and *what* a typed string means — a number or text
/// — is the host's decision, not one to re-implement here. Returns the
/// serialized [`ClientFrame`], or `None` when the position holds
/// nothing this client may write to.
#[wasm_bindgen]
pub fn set_cell_at(&self, row: usize, col: usize, value_json: &str) -> Option<String> {
let value: CellValue = serde_json::from_str(value_json).ok()?;
let message = self.set_cell(row, col, value)?;
serde_json::to_string(&message).ok()
}
// ── the transport surface (improvise-cr3) ───────────────────────────
//
// Three string calls, and they are the whole harness a page needs: send
@@ -254,42 +238,6 @@ impl Client {
self.state.bootstrap(snapshot);
}
/// Compose a write to the cell at a viewport position, addressed by the
/// surrogate the server projected there (ADR 002).
///
/// This is the composition `improvise-q08` found a thin client could not
/// do. It holds no `Model`, so it cannot build a `CellKey` — but it does
/// not need to: the id on the cell it is already drawing *is* the
/// address, and it is echoed back unread.
///
/// `None` on three shapes, and they are three different facts the cache
/// already carries: the position is outside the grid, the position holds
/// no settable cell (`id: None` — padding, or a records-mode column that
/// renames a coordinate rather than writing one), or the cell is an
/// **aggregate**, which `SetCell` cannot be composed for because
/// [`ModelCommand::SetCell`] does not take that kind of id. The last is
/// the one worth noting: it is not a check this function performs, it is
/// the pattern match failing to bind.
///
/// Unlike [`Client::key`]'s locally-resolved commands, nothing is folded
/// here. The new value comes back as a `GridUpdate::Cells` from the host,
/// which is the side that formats it; guessing it locally is how the two
/// ends drift apart.
/// A **fourth** shape returns `None`, and it is ADR 001's: this client
/// has not yet been told the document's basis, so it has nothing
/// truthful to say about what it composed against. `BasisCache::claim`
/// is the only constructor there is, and it withholds rather than
/// invents — a forged basis would be the check deleted with extra steps.
/// In practice a client that has folded its bootstrap always has one.
pub fn set_cell(&self, row: usize, col: usize, value: CellValue) -> Option<ClientFrame> {
let cell = self.record_cell_at(row, col)?;
let basis = self.state.basis.claim([cell.record])?;
Some(ClientFrame::command(Command::Model {
basis,
cmd: ModelCommand::SetCell { cell, value },
}))
}
/// Classify a keypress, folding it locally when this client can answer it.
pub fn key(&mut self, browser: &BrowserKey) -> Option<ClientFrame> {
let event = browser.to_key_event()?;
@@ -321,62 +269,17 @@ mod tests {
serde_json::from_str(&format!("\"{hex}\"")).expect("a surrogate is hex")
}
fn client_showing(id: Option<CellId>) -> Client {
let mut cell = improvise_protocol::CellDisplay::number("1200");
cell.id = id;
let mut client = init();
client.bootstrap(Snapshot {
view: ViewState::default(),
seed: vec![
Projection::Grid(GridUpdate::RowLabels(vec!["North".into()])),
Projection::Grid(GridUpdate::ColLabels(vec!["Revenue".into()])),
Projection::Grid(GridUpdate::Cells(vec![((0, 0), cell)])),
Projection::Basis(published_basis()),
],
});
client
}
/// What a server publishes for the one record this fixture's grid names
/// (ADR 001). A client that was never sent one composes nothing at all,
/// which `a_client_with_no_basis_composes_nothing` pins.
fn published_basis() -> improvise_protocol::Basis {
improvise_protocol::basis::published(
0,
0,
[(surrogate("fe4c8e5e9cbd578e0d388f4b2b5a7879"), 0)]
.into_iter()
.collect(),
)
}
/// The composition improvise-q08 found a thin client could not do. The
/// id goes back out **unchanged**: it is a token to echo, so the
/// assertion is equality with what arrived, not a re-derivation.
#[test]
fn a_client_composes_a_write_from_the_id_it_was_given() {
let id = improvise_protocol::RecordCell {
record: surrogate("fe4c8e5e9cbd578e0d388f4b2b5a7879"),
measure: Some("Revenue".into()),
};
let client = client_showing(Some(CellId::Record(id.clone())));
assert_eq!(
client.set_cell(0, 0, CellValue::Number(42.0)),
Some(ClientFrame::command(Command::Model {
basis: published_basis(),
cmd: ModelCommand::SetCell {
cell: id,
value: CellValue::Number(42.0),
},
}))
);
}
/// The fourth `None`, and the one ADR 001 adds: a client that has not
/// been told the document's basis has nothing truthful to say about what
/// it composed against, so it composes nothing. That is the property
/// that makes the basis check unforgeable from a browser —
/// `BasisCache::claim` is the only constructor, and it withholds.
///
/// The other three `None` shapes — an aggregate, `id: None`, and a
/// position outside the grid — are pinned against the same composition in
/// `socket.rs`'s `typing_into_a_cell_that_is_not_a_record_composes_nothing`.
/// They were asserted twice, once against each of two compositions, until
/// improvise-r4lo left only one.
#[test]
fn a_client_with_no_basis_composes_nothing() {
let id = improvise_protocol::RecordCell {
@@ -394,61 +297,7 @@ mod tests {
Projection::Grid(GridUpdate::Cells(vec![((0, 0), cell)])),
],
});
assert_eq!(client.set_cell(0, 0, CellValue::Number(42.0)), None);
}
/// The point of the enum: an aggregate carries an identity, and it is
/// still not one `SetCell` accepts. Nothing here checks anything — the
/// `let CellId::Record(..) = ... else` simply does not bind.
#[test]
fn a_client_cannot_compose_a_write_for_an_aggregate() {
let hash: improvise_protocol::AggregateHash =
serde_json::from_str("\"5b05bbcd2364099f68a49dbf9099e075\"").expect("hex");
let client = client_showing(Some(CellId::Aggregate(hash)));
assert_eq!(client.set_cell(0, 0, CellValue::Number(42.0)), None);
}
/// `id: None` is not "aggregate" — it is "no projected cell here", and a
/// position holding one is not writable either.
#[test]
fn a_cell_the_server_named_nothing_for_is_not_settable() {
let client = client_showing(None);
assert_eq!(client.set_cell(0, 0, CellValue::Number(42.0)), None);
assert_eq!(
client.set_cell(9, 9, CellValue::Number(42.0)),
None,
"and neither is a position outside the grid"
);
}
/// The JS-facing wrapper is the same path with JSON at both ends, so a
/// browser needs no structural bindings.
#[test]
fn the_json_wrapper_composes_the_same_message() {
let id = improvise_protocol::RecordCell {
record: surrogate("fe4c8e5e9cbd578e0d388f4b2b5a7879"),
measure: Some("Revenue".into()),
};
let client = client_showing(Some(CellId::Record(id)));
let json = client
.set_cell_at(0, 0, "{\"Number\":7.5}")
.expect("the wrapper must compose");
assert_eq!(
serde_json::from_str::<ClientFrame>(&json).expect("round trips"),
client.set_cell(0, 0, CellValue::Number(7.5)).unwrap()
);
}
/// A value that is not a `CellValue` composes nothing rather than
/// panicking across the wasm boundary.
#[test]
fn the_json_wrapper_refuses_a_value_it_cannot_read() {
let id = improvise_protocol::RecordCell {
record: surrogate("fe4c8e5e9cbd578e0d388f4b2b5a7879"),
measure: None,
};
let client = client_showing(Some(CellId::Record(id)));
assert_eq!(client.set_cell_at(0, 0, "not json"), None);
assert_eq!(client.typed_cell_frame(0, 0, "42"), None);
}
/// The end-to-end shape a JS harness drives: bootstrap, key, message.
+55 -6
View File
@@ -77,6 +77,30 @@ fn rejection_message(rejection: &Rejection) -> String {
/// Rust rather than in the page's JavaScript is what makes it testable and
/// keeps one spelling of the rule per language boundary instead of two.
///
/// This is the **only** answer the *client* gives to "what does a typed string
/// mean" (improvise-r4lo), and it holds on every deployment, including the
/// worker: a host's `App` sits behind the frame boundary, and what crosses that
/// boundary is `ModelCommand::SetCell { value: CellValue }`. So no host ever
/// classifies a string that arrived *over the wire* — there is none to
/// classify by the time it gets there.
///
/// Note the careful half of that. A host does classify its own **local**
/// keystrokes, in `commit_regular_cell_value`, which is why this is a
/// duplicate of that split rather than a call to it: `improvise-command` is
/// deliberately not a dependency of this crate (see `Cargo.toml` — reaching
/// it would drag `App`, `Workbook` and persistence into the browser bundle).
///
/// So this rule genuinely lives in two places, and
/// `a_typed_string_is_classified_as_the_host_classifies_it` states the
/// obligation without discharging it: it asserts the three outcomes by hand
/// rather than comparing with the host's function, so the two could drift and
/// stay green. That is
/// `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`
/// still open, one layer below what improvise-r4lo closed — the real fix is a
/// shared split in `improvise-protocol`, which both crates already depend on.
/// Filed rather than done here, because it moves code in a crate this ticket
/// does not own.
///
/// `None` means "clear this cell", which is a different command, not a
/// different value — hence the `Option` rather than a `CellValue::Text("")`.
fn typed(text: &str) -> Option<CellValue> {
@@ -149,12 +173,37 @@ impl Client {
}
/// Compose the write a user made by typing `text` into the cell at a
/// viewport position.
/// viewport position — the **one** composition of a cell write there is
/// (improvise-r4lo).
///
/// `None` on exactly the shapes [`Client::set_cell`] refuses — outside
/// the grid, no projected cell, an aggregate, which `SetCell` cannot be
/// composed for because it does not take that kind of id (ADR 002), or
/// no basis yet (ADR 001).
/// This is the composition `improvise-q08` found a thin client could not
/// do. It holds no `Model`, so it cannot build a `CellKey` — but it does
/// not need to: the id on the cell it is already drawing *is* the
/// address, and it is echoed back unread (ADR 002).
///
/// It takes the user's **string**, not a `CellValue`, and [`typed`] above
/// says why: the classification is unavoidably this side of the wire,
/// because `ModelCommand::SetCell` already carries a `CellValue` by the
/// time any host sees it *over the wire*. That is true of the worker too — the
/// `App` there sits *behind* the frame boundary and is handed a value, not
/// a string — so there is no deployment for which a pre-classified entry
/// point would be the right one. A sibling taking a serialized `CellValue`
/// used to sit beside this and claim the opposite; it was deleted rather
/// than re-documented, because it could not express `ClearCell` and was
/// therefore a strictly weaker duplicate of this.
///
/// `None` on four shapes, each a different fact the client already holds:
/// the position is outside the grid; the position holds no settable cell
/// (`id: None` — padding, or a records-mode column that renames a
/// coordinate rather than writing one); the cell is an **aggregate**,
/// which is the pattern failing to bind rather than a check (ADR 002);
/// or this client has not been told the document's basis and so has
/// nothing truthful to say about what it composed against (ADR 001 —
/// `BasisCache::claim` withholds rather than invents).
///
/// Nothing is folded here. The new value comes back as a
/// `GridUpdate::Cells` from the host, which is the side that formats it;
/// guessing it locally is how the two ends drift apart.
pub fn typed_cell_frame(&self, row: usize, col: usize, text: &str) -> Option<ClientFrame> {
let cell = self.record_cell_at(row, col)?;
let basis = self.state().basis.claim([cell.record])?;
@@ -378,7 +427,7 @@ mod tests {
}
/// Typing into a cell the client may not write is no frame at all, for
/// the same reason `set_cell` composes nothing: the id is of the wrong
/// the same reason any write composes nothing: the id is of the wrong
/// kind, or there is no id. `Drill` is not on the wire yet, so an
/// aggregate is simply not editable here.
#[test]
+1 -1
View File
@@ -36,7 +36,7 @@ pub struct ClientState {
pub tiles: TileBarCache,
/// The picture of the document this client may claim to have composed
/// against (ADR 001). Not drawn; folded like any other projection, and
/// read by `Client::set_cell` when it composes a write.
/// read by `Client::typed_cell_frame` when it composes a write.
#[serde(skip)]
pub basis: BasisCache,
/// The last basis rejection this client was told about, if any.
+3 -3
View File
@@ -1,12 +1,12 @@
//! `improvise-ws-server` — serve improvise documents over a websocket.
//!
//! ```text
//! improvise-ws-server bank-info.improv --port 8787
//! improvise-ws-server bank-info.improv q3.improv # two documents
//! improvise-ws-server examples/demo.improv --port 8787
//! improvise-ws-server examples/demo.improv q3.improv # two documents
//! ```
//!
//! Each file becomes a document named after its file stem, and a client
//! names the one it wants in the URL path: `ws://127.0.0.1:8787/bank-info`.
//! names the one it wants in the URL path: `ws://127.0.0.1:8787/demo`.
//! With exactly one document, `ws://127.0.0.1:8787/` reaches it.
//!
//! Every document is opened here, at startup. A client cannot ask the server
+187 -6
View File
@@ -5,6 +5,8 @@ use ratatui::{
widgets::{Block, Borders, Clear, Widget},
};
use unicode_width::UnicodeWidthStr;
use crate::protocol::{WizardCache, WizardFieldKind, WizardStepKind};
/// The import wizard, drawn from a [`WizardCache`] (improvise-764).
@@ -174,9 +176,11 @@ impl<'a> Widget for ImportWizardWidget<'a> {
y += 1;
}
let hint_y = inner.y + inner.height - 1;
buf.set_string(
set_bottom_line(
buf,
x,
hint_y,
w,
"Enter: next Space: toggle c: cycle kind \u{2190} back Esc: cancel",
Style::default().fg(Color::DarkGray),
);
@@ -233,9 +237,11 @@ impl<'a> Widget for ImportWizardWidget<'a> {
}
}
let hint_y = inner.y + inner.height - 1;
buf.set_string(
set_bottom_line(
buf,
x,
hint_y,
w,
"Space: toggle Enter: next \u{2190} back Esc: cancel",
Style::default().fg(Color::DarkGray),
);
@@ -317,7 +323,14 @@ impl<'a> Widget for ImportWizardWidget<'a> {
} else {
"n: new formula d: delete Enter: next \u{2190} back Esc: cancel"
};
buf.set_string(x, hint_y, hint, Style::default().fg(Color::DarkGray));
set_bottom_line(
buf,
x,
hint_y,
w,
hint,
Style::default().fg(Color::DarkGray),
);
}
WizardStepKind::NameModel => {
buf.set_string(x, y, "Model name:", Style::default().fg(Color::Yellow));
@@ -369,9 +382,11 @@ impl<'a> Widget for ImportWizardWidget<'a> {
}
}
let hint_y = inner.y + inner.height - 1;
buf.set_string(
set_bottom_line(
buf,
x,
hint_y,
w,
"Enter: accept \u{2190}/Esc: back",
Style::default().fg(Color::DarkGray),
);
@@ -382,11 +397,38 @@ impl<'a> Widget for ImportWizardWidget<'a> {
// line on every step, overriding the key hint while present.
if let Some(msg) = &self.wizard.message {
let msg_y = inner.y + inner.height - 1;
buf.set_string(x, msg_y, truncate(msg, w), Style::default().fg(Color::Red));
set_bottom_line(buf, x, msg_y, w, msg, Style::default().fg(Color::Red));
}
}
}
/// Write the wizard's bottom line, padded to the panel width.
///
/// That row has two writers — each step's key hint, and the wizard message
/// that overrides it while present — and they write in that order to the same
/// `y`. `Buffer::set_string` writes exactly as many cells as the string has
/// and leaves the rest alone, so a message shorter than the hint it replaced
/// left the hint's tail sitting beside it: `something went wrongcel`, the
/// `cel` from `Esc: cancel` (improvise-q0vb).
///
/// Padding here rather than at each site is the point. The bug is not that
/// one of the five writers forgot; it is that the row had five writers and no
/// one of them owned clearing it.
///
/// The padding is measured in **terminal cells**, not characters. `Buffer`
/// lays a string out by display width, so a char count over-pads a message
/// carrying a wide character — enough to run past the panel and overwrite the
/// border — and under-pads nothing, which is how a combining mark would have
/// left a sliver of the hint behind. `src/ui/grid.rs` measures the same way.
/// `truncate` above still cuts by bytes, which is deliberate and documented
/// there; bytes are never fewer than cells, so the two together cannot
/// overrun.
fn set_bottom_line(buf: &mut Buffer, x: u16, y: u16, w: usize, text: &str, style: Style) {
let text = truncate(text, w);
let pad = " ".repeat(w.saturating_sub(UnicodeWidthStr::width(text.as_str())));
buf.set_string(x, y, format!("{text}{pad}"), style);
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
@@ -573,6 +615,88 @@ mod tests {
assert_eq!(render(&w), FORMULA_STEP);
}
/// improvise-q0vb. The wizard's bottom row has two writers — the step's
/// key hint and the message that overrides it — and `set_string` writes
/// only as many cells as its string has. A message shorter than the hint
/// therefore left the hint's tail beside it, and the artifact was pinned
/// as correct output in `FORMULA_STEP`: `something went wrongcel`.
///
/// Asserted here on `ReviewProposals`, whose hint is the longest in the
/// wizard, so the tail it would leave is unmistakable. The precondition
/// asserts the hint really is longer than the message — without it the
/// test would pass on a step where there was nothing to leave behind.
#[test]
fn a_message_shorter_than_the_hint_leaves_none_of_it() {
const HINT: &str = "Enter: next Space: toggle c: cycle kind ← back Esc: cancel";
let mut w = wizard();
assert!(matches!(w.step, WizardStep::ReviewProposals));
assert!(
render(&w).lines().any(|l| l.contains(HINT)),
"precondition: this step draws the long hint"
);
w.message = Some("short".to_string());
let drawn = render(&w);
let bottom = drawn.lines().nth_back(1).expect("the row above the border");
assert_eq!(
bottom.trim_end_matches([' ', '│']).trim_start_matches('│'),
"short"
);
assert!(
"short".len() < HINT.len(),
"precondition: the message is shorter than what it replaces"
);
}
/// The padding is in terminal cells, not characters (Copilot, PR #22).
/// `Buffer` lays a string out by display width, so a `chars().count()`
/// pad is wrong in both directions: a wide character counts one and
/// occupies two, so the helper runs past the panel and over its right
/// border; a combining mark counts one and occupies none, so the helper
/// stops short and leaves cells it was supposed to clear.
///
/// **Asserted against a sentinel**, which is the correction Copilot made
/// to a first version of this test. Drawing through the widget cannot see
/// the under-padding case at all: the hint that ran first had *already*
/// filled the row with its own padding, so the cells the combining
/// message failed to reach held spaces rather than anything visible, and
/// a "does the hint survive" assertion passed either way. (The first
/// version also looked like it had been proved, because the loop panicked
/// on the wide-character case before ever reaching the combining one —
/// `doctrine(an_injected_fault_must_be_the_fault_you_named)`, from the
/// other end.)
///
/// So this fills the row with `X` first and asks the helper directly:
/// every interior cell must be the text or a space, and the two cells
/// standing in for the panel border must still be `X`. Both faults are
/// then visible as a surviving `X` — one inside, one outside.
#[test]
fn set_bottom_line_clears_every_cell_it_covers_and_no_more() {
const W: usize = 18;
for text in [
"\u{5e7f}\u{5ea6}\u{5e7f}\u{5ea6}\u{5e7f}\u{5ea6}", // 6 chars, 12 cells
"e\u{0301}e\u{0301}e\u{0301}", // 6 chars, 3 cells
"short", // 5 chars, 5 cells
] {
let mut buf = Buffer::empty(Rect::new(0, 0, W as u16 + 2, 1));
buf.set_string(0, 0, "X".repeat(W + 2), Style::default());
set_bottom_line(&mut buf, 1, 0, W, text, Style::default());
let row: Vec<&str> = (0..W as u16 + 2).map(|i| buf[(i, 0)].symbol()).collect();
assert_eq!(row[0], "X", "{text:?} wrote left of the panel: {row:?}");
assert_eq!(
row[W + 1],
"X",
"{text:?} overran the panel's right border: {row:?}"
);
assert!(
!row[1..=W].contains(&"X"),
"{text:?} left a cell it should have cleared: {row:?}"
);
}
}
/// The two steps the flat-array fixture skips. `SelectArrayPath` is
/// reached by a document with more than one candidate array, which is
/// also the only step that draws `pipeline.array_paths`.
@@ -586,6 +710,58 @@ mod tests {
assert_eq!(render(&w), ARRAY_PATH_STEP);
}
/// `WizardStep::Preview` is the one step `every_step_draws_what_it_drew_
/// before` never reaches (improvise-g6s1). Both fixtures there start at
/// `ReviewProposals` or `SelectArrayPath`, and nothing advances *into*
/// Preview — it is only ever an entry step, chosen by `ImportWizard::new`
/// when the document needs no path selection and parses to zero records.
///
/// That left it the single arm where a change to `wizard_cache` could
/// alter what is drawn with nothing failing, which is the same hole the
/// other steps were pinned to close. An empty array is the smallest
/// document that lands there.
///
/// It also puts on the record a wart the step has today: the summary ends
/// `Sample keys:` with nothing after the colon, because there are no
/// records to take keys from. Pinned as it is rather than quietly fixed —
/// the point of a characterization fixture is that the next change to it
/// is deliberate.
#[test]
fn the_preview_step_draws_an_empty_document() {
let w = ImportWizard::new(json!([]));
assert!(
matches!(w.step, WizardStep::Preview),
"precondition: an empty array must enter at Preview, got {:?}",
w.step
);
assert_eq!(render(&w), PREVIEW_STEP);
}
const PREVIEW_STEP: &str = concat!(
"┌ Import Wizard — Preview ─────────────────────────────────────────────────────┐\n",
"│Array of 0 records. Sample keys: │\n",
"│ │\n",
"│Press Enter to continue… │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"└──────────────────────────────────────────────────────────────────────────────┘",
);
const ARRAY_PATH_STEP: &str = concat!(
"┌ Import Wizard — Select Array ────────────────────────────────────────────────┐\n",
"│Select the path containing records: │\n",
@@ -760,7 +936,12 @@ mod tests {
"│ │\n",
"│ │\n",
"│ │\n",
"│something went wrongcel │\n",
// improvise-q0vb: this used to read `something went wrongcel` — the
// `cel` was the tail of `Esc: cancel`, left behind because the message
// was written over a longer hint without clearing the rest of the row.
// The frame changing here is the point of that commit, not a side
// effect: it is the only one of the eight pinned frames that moves.
"│something went wrong │\n",
"└──────────────────────────────────────────────────────────────────────────────┘",
);
}
+6 -4
View File
@@ -39,8 +39,10 @@ fn imported_model_shows_one_row_per_record_with_measure_columns() {
.collect();
assert_eq!(
cols,
vec!["product", "region", "cost", "revenue", "+"],
"categories, then one column per measure, then the new-measure column"
vec!["product", "region", "+cat", "cost", "revenue", "+"],
"categories, the new-category column, one column per measure, then \
the new-measure column — each `+` sits next to what it adds to \
(improvise-2zq)"
);
assert_eq!(layout.row_count(), 2, "two source records, not four cells");
@@ -57,8 +59,8 @@ fn imported_model_shows_one_row_per_record_with_measure_columns() {
assert_eq!(
rows,
vec![
vec!["Pants", "West", "80", "200", ""],
vec!["Shirts", "East", "40", "100", ""],
vec!["Pants", "West", "", "80", "200", ""],
vec!["Shirts", "East", "", "40", "100", ""],
],
"got {rows:?}"
);
+59
View File
@@ -0,0 +1,59 @@
//! Every `.improv` document this repository ships must load (improvise-odbc).
//!
//! The finding behind this test: `bank-info.improv` sat in the repo root for
//! five months unable to parse — hand-edited through the v2025-04-09 format
//! change rather than round-tripped, so its item lines carried bare names the
//! grammar cannot spell (`- JORDAN MARTINEZ`) and its data lines named a
//! category `Measure` the file declares as `_Measure`. Nothing loaded it, so
//! nothing said so.
//!
//! The grammar itself is covered far better than a fixture can manage, by the
//! generator-driven proptests in `improvise-io`. What *those* cannot see is a
//! checked-in document drifting out of the format, because they generate their
//! inputs. This test is aimed at exactly that gap, and it is deliberately a
//! directory walk rather than a list of names: a list is a second place to
//! remember, and the file that gets forgotten is the one nobody added to it.
//!
//! The scope is the two directories `.gitignore` un-ignores — `examples/` and
//! `web/`. Everything else matching `*.improv` is somebody's working file.
use std::path::{Path, PathBuf};
fn shipped_documents() -> Vec<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let mut found: Vec<PathBuf> = ["examples", "web"]
.iter()
.flat_map(|dir| std::fs::read_dir(root.join(dir)).expect("a shipped-document directory"))
.map(|entry| entry.expect("a readable directory entry").path())
// `.improv.gz` is the same document gzipped, and `extension()` calls
// it `gz` — so matching on the extension alone would skip it and put
// back the very hole this test exists to close.
.filter(|p| {
p.to_str()
.is_some_and(|s| s.ends_with(".improv") || s.ends_with(".improv.gz"))
})
.collect();
found.sort();
found
}
#[test]
fn every_shipped_document_loads() {
let documents = shipped_documents();
assert!(
documents.len() >= 2,
"precondition: the walk must find the documents that exist, got {documents:?}"
);
for path in documents {
let workbook = improvise::persistence::load(&path)
.unwrap_or_else(|e| panic!("{} does not load: {e}", path.display()));
assert!(
workbook.views.contains_key(&workbook.active_view),
"{} loads but its initial view {:?} is not one of {:?}",
path.display(),
workbook.active_view,
workbook.views.keys().collect::<Vec<_>>()
);
}
}
+32 -3
View File
@@ -39,6 +39,9 @@
// Neither is reachable silently. Every transport writes what it is doing to
// `#status` — "starting", "connected", "cannot reach …", "disconnected" — so
// a peer that never arrives reads differently from one that went away.
// "disconnected" means exactly the second: a socket that opened and then
// closed. A socket that never opened keeps saying "cannot reach …", because
// the close event there is the tail of the failure and not news.
import init, { Client } from "./pkg/improvise_wasm_client.js";
@@ -191,8 +194,17 @@ function workerTransport() {
/** A websocket to `improvise-ws-server`: how a TUI-hosted document is shared. */
function socketTransport() {
const socket = new WebSocket(WS_URL);
const unreachable = `cannot reach ${WS_URL} — is improvise-ws-server running?`;
// Did this socket ever open? The page has to record that itself, because
// nothing on the close event carries it: `readyState` is already CLOSED by
// the time a close listener runs, whichever happened, and a refused
// connection and a mid-session network drop are BOTH code 1006 with
// `wasClean: false`. So "never connected" and "was connected and went away"
// are distinguishable only by remembering.
let opened = false;
const ready = new Promise((resolve) => {
socket.addEventListener("open", () => {
opened = true;
setShellStatus(`connected · ${WS_URL}`);
resolve();
});
@@ -200,10 +212,27 @@ function socketTransport() {
socket.addEventListener("message", (event) => onFrame(event.data));
// Without this, a connection that never opens left #status blank and
// "server not running" read exactly like "still connecting" (improvise-hwpi).
socket.addEventListener("error", () =>
setShellFailure(`cannot reach ${WS_URL} — is improvise-ws-server running?`),
socket.addEventListener("error", () => setShellFailure(unreachable));
// A socket that never opened did not "disconnect" — it failed to connect,
// and the close event is the tail of that failure rather than a second,
// vaguer thing that happened (improvise-taxx). Writing "disconnected" here
// unconditionally replaced the `error` listener's precise message with the
// least informative of the two facts.
//
// Same family as the watchdog bug and NOT the same fix: the watchdog is
// disarmed because it is for silence only, whereas suppressing `close`
// outright would hide a genuine mid-session disconnect, which is the one
// thing this listener is really for. Hence the split rather than a guard.
// Both arms go through `setShellFailure`, which DISARMS the watchdog. That
// matters on the opened path: the watchdog is armed after `transport.ready`
// resolves — i.e. on `open` — so a socket that opened and then dropped
// before its first frame reaches here with the timer still pending, and
// "disconnected" is more precise than the guess it would otherwise fire 8s
// later. On the never-opened path there is no timer yet and the clear is a
// no-op. Neither arm leaves one armed.
socket.addEventListener("close", () =>
setShellFailure(opened ? "disconnected" : unreachable),
);
socket.addEventListener("close", () => setShellStatus("disconnected"));
setShellStatus(`connecting to ${WS_URL}…`);
return {
send: (text) => {
+7 -3
View File
@@ -65,9 +65,13 @@
<code>wss://</code></strong>, because this page is served over https and
an https origin refuses a <code>ws://</code> address as mixed content.
Note that <code>improvise-ws-server</code> speaks plain
<code>ws://</code> only, so reaching it from here means terminating TLS
in front of it. Served over <code>http://localhost</code> the same page
defaults to <code>ws://</code> and pairs with it directly.
<code>ws://</code> only, so reaching it from <em>this</em> page means
terminating TLS in front of it with a reverse proxy or a tunnel — the
README has a worked Caddy example. Served over
<code>http://localhost</code> the same page defaults to
<code>ws://</code> and pairs with it directly, with nothing in between;
that is the case the pairing is really for. Sharing a document is what
the server is for — not running one is what everything above is for.
</p>
<main>