Merge remote-tracking branch 'gh/main' into wave/c-host-and-save
This commit is contained in:
+12
-4
File diff suppressed because one or more lines are too long
@@ -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
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, ®);
|
||||
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, ®);
|
||||
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, ®);
|
||||
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, ®);
|
||||
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, ®);
|
||||
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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
"└──────────────────────────────────────────────────────────────────────────────┘",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user