Merge PR #279 (044e8d2d) into the 2026-09-24 integration wave

# Conflicts:
#	context/test-evidence/clients.md
This commit is contained in:
Edward Langley
2026-09-24 02:47:40 -07:00
12 changed files with 449 additions and 44 deletions
+66
View File
@@ -2447,6 +2447,72 @@ reads that text. An emptied node cannot tell "nothing was said" from "something
else was said", and the assertion would pass vacuously the moment the timer
fired.
#### A message beside its open editor stands until that editor closes (improvise-1x9h)
The timer is wrong for one kind of message: a refusal the user is still
fixing. Six seconds after it appears, the toast hid it while the minibuffer it
was about stayed open. That is the TUI's su4u bug with a timer instead of a
keypress, and the rule is the TUI's, so both front ends ask one question.
**The rule: a message stands while the editor it was written in is still the
open one.** "The editor" is `improvise_protocol::EditorSession`, the value the
TUI's `press` compares before and after a key (`context/code/tui.md`, "What
'the same editor' means"). It moved from `src/draw.rs` into the protocol crate
so that the browser asks the same question rather than a second derivation of
it (design principles §5).
**The decision is Rust's, and the page only obeys it.** `ClientState` observes
the status wherever the view changes (`settle_status`, beside `settle_mode` and
`settle_editor`). That means every applied command, and also every `rebase()`,
`refused()` and `bootstrap()`, the three paths that rebuild the view wholesale:
- a status that has changed since it was last observed is owned by the editor
open at that moment, or by nothing outside a text-entry mode;
- an unchanged status loses its owner, for good, as soon as a different editor
(or none) is open.
`RenderModel::status_stands` is `ClientState::status_stands()`. The page passes
it to `createToasts.offer(text, stands)`. A standing message arms no timer. When
it stops standing it hides at once, not six seconds later, because the TUI drops
a refusal on the key that leaves its editor.
**"For good" is the part that needs memory.** A refusal in `FormulaEdit`, then
`Esc`, then `n` again opens an editor *equal* to the first: the same mode, the
same cursor, no seed. Comparing the current editor with nothing would make the
old message stand again beside the new opening. The owner is therefore
recorded when the message is written and cleared when that editor closes, not
recomputed from what is open now.
That clearing has to happen on every path that moves the view, and a relay's
rebase is one of them (improvise-0dbk). A relay heard while a frame is in
flight rebuilds the view by `rebase()`. If `rebase()` does not settle the
status, a rebase that moves the view off the editor leaves the owner in place.
When the user returns to an equal editor, the old message stands again.
`a_rebase_off_the_editor_ends_the_message_standing` is that case.
**A status written outside a fold must be observed where it is written.**
`socket.rs` writes a rejection's sentence *after* `refused()`, so the sentence
survives the rollback. It writes it through `ClientState::notify`, which queues
the sentence at the tail of `in_flight` through `fold_outgoing`
(improvise-n0pb). That path applies it at once and runs `settle_status`, so the
editor open at that moment owns the sentence. Assigning `status_msg` directly
leaves it unobserved until the next fold. By then the editor may have changed,
and the message would be owned by the wrong one. A relay's rebase would also
erase a direct write, which is why improvise-n0pb queues it.
**What it inherits from the identity rule above.** An identical message written
again in a *new* editor is not a new write, so it neither stands nor re-shows.
That is the "two identical messages show once" cost, and it has the same cause:
nothing beside `status_msg` says that it was written again.
**Where the rule is live.** A formula refusal still never reaches a browser.
The client folds `CommitBuffer`'s optimistic "Formula added", and the host's
error is a write to the host's own slice
(`a_formula_the_client_cannot_parse_diverges_on_the_status_line`). What
reaches a browser today is a host rejection that leaves the editor open, such
as a refused save. The rule becomes fully live when a host-addressed status
reaches its composer.
### What the styling is, and what no test here can see
`web/style.css` stopped being "legibility only" and is now a small design
+7 -4
View File
@@ -505,7 +505,10 @@ and stay in the minibuffer: a `:` line that switches view settles back to
Normal. So nothing could test the field, and an untestable field is a claim no
test can check.
**The browser is not covered by this.** There, `status_msg` is a toast that
hides after `TOAST_MS` (six seconds), and the minibuffer element draws only the
prompt and the text. A refusal therefore disappears on a timer, whatever the
user is doing. That is a different mechanism and has not been changed here.
**The browser asks the same question (improvise-1x9h).** `EditorSession` now
lives in `improvise-protocol` and is built from a `ViewState`, so the TUI's
`press` and the browser's `ClientState::status_stands` compare the same value.
The browser's mechanism differs: its message is a toast on a timer, and there
is no per-key clear. So it keeps the message's *owner* and suspends the timer
while that owner is open. See `context/code/clients.md`, "A message beside its
open editor stands until that editor closes".
+86
View File
@@ -5588,6 +5588,92 @@ branch's switch to the send door, its reopened editor read
`touched: true`, the shape of "the reopened editor inherited the previous
editor's touched flag". It fails both tests.
### a_message_stands_while_the_editor_it_was_written_in_is_open
improvise-1x9h, the browser half of improvise-su4u. In the browser,
`status_msg` was only ever a toast, and `createToasts` hid it after `TOAST_MS`
(six seconds) whatever the mode. A refusal written beside an open minibuffer
therefore disappeared while the user was still fixing the thing it described.
The bug was shown red in JavaScript first, where the timer lives. `shell.test.js`,
"a standing message outlives the timer and goes with its editor", failed on the
unchanged `createToasts` with "a standing message arms no timer". The decision
of *whether* a message stands is Rust's, and this test pins it. It opens
`FormulaEdit`, writes a status, and asserts that the message stands, and still
stands after a buffer edit. Then it leaves to `FormulaPanel` and asserts that
it does not. Then it re-opens an equal `FormulaEdit` and asserts that the
message *still* does not stand, although `status_msg` is unchanged.
- **Rival `any_editor`** (`status_stands` = non-empty status and any editor
open): fails this test and its sibling below, and nothing else in the
workspace (1943 passed, 2 failed).
- **Rival `owner_never_lost`** (drop the `else if` that clears the owner once
its editor closes): fails only this test, on the re-opened equal editor.
That clause is what the last assertion exists for. Without it an equal
opening is indistinguishable from the original one.
### a_message_does_not_stand_beside_an_editor_it_was_not_written_in
improvise-1x9h. There are three ways to be a different editor, and one case
each: a status written in Normal before an editor opens; a cell editor whose
cursor then moves from (1,1) to (2,1), which is a commit that advanced; and a
cell editor whose seed changes from `4` to `47` on the same cell, which is a
re-opening after a commit (improvise-sx7h's `Tab` in the last column). The
cursor sits at a nonzero row so that "the cell" is not "the first cell".
Rival `any_editor` fails it on the first case.
### a_rejection_that_arrives_in_an_open_editor_stands_beside_it
improvise-1x9h. `socket.rs` writes a rejection's sentence after `refused()`,
outside any fold, so nothing observed it. The test folds `Editing`, then a
`Rejected(NoDocumentPath)`. A refused save rolls nothing back and closes
nothing, so the editor is still open. It asserts that the sentence stands.
- **Rival `direct_write`** (the old `state.view.status_msg = message`): fails
this test. The status is never observed, so it has no owner.
The `notify` this test goes through is #266's (improvise-n0pb), which queues
the sentence through `fold_outgoing`. #279 had its own `notify` that wrote the
status and settled it directly. Merging the two gave duplicate definitions
(E0592), and #279's was deleted (improvise-0dbk). This test is unchanged and
passes against the queued `notify`, because `fold_outgoing` applies the
sentence and runs `settle_status`.
### a_rebase_off_the_editor_ends_the_message_standing
improvise-0dbk, found reviewing #266 against #279. #266's `rebase()` rebuilt
the view without running `settle_status`. The probe:
1. `SetSelected(1,1)`, `ChangeMode(editing)` and `SetStatus`: the note stands.
2. An awaited frame goes out, so the next relay rebases instead of applying.
3. The relay `ResetCursor` rebases the cursor to (0,0), off the editor. The
owner survived this step.
4. `SetSelected(1,1)` goes out and returns to an equal editor. `status_stands()`
was true: the old note stood beside an opening it was not written in.
Red at the merge of `9eb83538` and `57b713cf` (with #279's duplicate `notify`
removed so that the merge compiles). The failure was "an equal editor after a
rebase took the old message back". It is green once `rebase()` calls
`settle_status()`.
- **Rival `rebase_unsettled`** (remove that call): fails only this test.
- The assertion that `in_flight` is non-empty after step 2 is what makes step 3
a rebase. With nothing in flight the relay is simply applied, and `apply`
already settled the status.
**The DOM half.** `harness/tests/header.spec.js`, "a message written beside an
open minibuffer outlives the timer", is the check on the wire from
`model.status_stands` through `shell.setModel` to `toasts.offer`.
`render_surface.test.js` cannot see that wire: it is a substring search, so it
passes as long as `model.status_stands` appears anywhere in `app.js`. The spec
opens the formula editor in the real demo and delivers
`{"Commands":[{"SetStatus":"Formula error"}]}` as a worker message. It runs
the page clock past ten seconds and asserts that the toast is still visible.
Then it presses `Escape` and asserts that the toast hides. The rival
`onModel: (text, stands) => toasts.offer(text)` fails it at the
still-visible-after-ten-seconds assertion, and the node suite stays green
under that rival.
### a_relay_behind_a_refused_frame_settles_before_the_next_answer
improvise-3kfc, found in review of #246 at `542dd9c`. `ClientState::refused`
+128 -3
View File
@@ -2,9 +2,9 @@
//! The two properties this dispatch owes: `context/code/clients.md`.
use crate::{
BasisCache, CategoryTreeCache, Command, DocumentCache, FormulaCache, GridCache, Projection,
Rejection, Snapshot, TileBarCache, ViewListCache, ViewState, WidgetCache, reduce_view,
settled_mode,
BasisCache, CategoryTreeCache, Command, DocumentCache, EditorSession, FormulaCache, GridCache,
Projection, Rejection, Snapshot, TileBarCache, ViewListCache, ViewState, WidgetCache,
reduce_view, settled_mode,
};
use serde::Serialize;
@@ -43,6 +43,12 @@ pub struct ClientState {
/// replay the rest. Why a queue: `context/code/clients.md`.
#[serde(skip)]
pub in_flight: std::collections::VecDeque<Sent>,
/// The status text last observed, and the editor it was written in while
/// that editor is still open (improvise-1x9h, `context/code/clients.md`).
#[serde(skip)]
pub status_seen: String,
#[serde(skip)]
pub status_owner: Option<EditorSession>,
}
/// One frame this client has sent. `awaited` is true exactly when the host
@@ -78,6 +84,7 @@ impl ClientState {
}
self.settle_mode();
self.settle_editor();
self.settle_status();
self.revision += 1;
}
@@ -98,6 +105,7 @@ impl ClientState {
self.replay();
self.settle_mode();
self.settle_editor();
self.settle_status();
self.revision += 1;
}
@@ -131,6 +139,7 @@ impl ClientState {
}
self.settle_mode();
self.settle_editor();
self.settle_status();
self.revision += 1;
}
@@ -181,9 +190,18 @@ impl ClientState {
self.last_rejection = Some(rejection);
self.settle_mode();
self.settle_editor();
self.settle_status();
self.revision += 1;
}
/// Whether the status belongs to the editor still open, so a renderer must
/// not expire it (improvise-1x9h). The TUI asks the same `EditorSession`.
pub fn status_stands(&self) -> bool {
!self.view.status_msg.is_empty()
&& self.status_owner.is_some()
&& self.status_owner == EditorSession::of(&self.view)
}
/// The settled slice plus every frame still in flight, in order. What a
/// dropped frame leaves behind is the *rest* of them, not nothing.
fn replay(&mut self) {
@@ -248,6 +266,18 @@ impl ClientState {
}
}
/// A new message is owned by the editor open as it arrives; an old one loses
/// its owner for good once that editor is not the open one.
fn settle_status(&mut self) {
let now = EditorSession::of(&self.view);
if self.view.status_msg != self.status_seen {
self.status_seen = self.view.status_msg.clone();
self.status_owner = now;
} else if self.status_owner != now {
self.status_owner = None;
}
}
/// The widget dispatch: a projection names the widget it is addressed to.
/// The `CategoryTreeCache`/`TileBarCache` overlap is deliberate (gotcha 32).
fn apply_projection(&mut self, projection: &Projection) {
@@ -662,6 +692,101 @@ mod tests {
);
}
/// improvise-1x9h: a message written while an editor is open stands until
/// that editor closes, and never again after. Evidence file.
#[test]
fn a_message_stands_while_the_editor_it_was_written_in_is_open() {
let mut state = ClientState::default();
state.fold(&Command::ChangeMode(crate::AppMode::formula_edit()));
state.fold(&Command::SetStatus("Formula error".into()));
assert!(
state.status_stands(),
"a refusal beside its editor must stand"
);
state.fold(&Command::SetBuffer {
name: "formula".into(),
value: "Total = ".into(),
});
assert!(state.status_stands(), "typing is the same editor");
state.fold(&Command::ChangeMode(crate::AppMode::FormulaPanel));
assert!(!state.status_stands(), "the refusal outlived its editor");
state.fold(&Command::ChangeMode(crate::AppMode::formula_edit()));
assert_eq!(state.view.status_msg, "Formula error", "nothing cleared it");
assert!(
!state.status_stands(),
"an equal editor opened later is another opening, and the message is not its"
);
}
/// A relay that rebases the view off the editor ends its message's ownership,
/// as a fold would (improvise-0dbk). Evidence file.
#[test]
fn a_rebase_off_the_editor_ends_the_message_standing() {
let mut state = ClientState::default();
state.fold(&Command::SetSelected { row: 1, col: 1 });
state.fold(&Command::ChangeMode(crate::AppMode::editing()));
state.fold(&Command::SetStatus("a note about this cell".into()));
assert!(state.status_stands(), "precondition: the note stands");
state.fold_outgoing(&commit_like("unused")[..1]);
assert!(
!state.in_flight.is_empty(),
"the relay must rebase, not apply"
);
state.fold(&Command::ResetCursor);
assert_eq!(
state.view.cursor().selected,
(0, 0),
"the rebase moved the cursor"
);
state.fold_outgoing(&[Command::SetSelected { row: 1, col: 1 }]);
assert!(
!state.status_stands(),
"an equal editor after a rebase took the old message back"
);
}
/// The editor is the one it was WRITTEN in: not any editor, and not the next
/// cell's after a commit advances (improvise-1x9h, sx7h). Evidence file.
#[test]
fn a_message_does_not_stand_beside_an_editor_it_was_not_written_in() {
let mut state = ClientState::default();
state.fold(&Command::SetStatus("saved".into()));
state.fold(&Command::ChangeMode(crate::AppMode::editing()));
assert!(
!state.status_stands(),
"written in Normal, it is the timer's"
);
let mut state = ClientState::default();
state.fold(&Command::SetSelected { row: 1, col: 1 });
state.fold(&Command::ChangeMode(crate::AppMode::editing()));
state.fold(&Command::SetStatus("a note about this cell".into()));
assert!(state.status_stands());
state.fold(&Command::SetSelected { row: 2, col: 1 });
assert!(
!state.status_stands(),
"the note followed the editor down a row"
);
let mut state = ClientState::default();
state.fold(&Command::SetSelected { row: 1, col: 1 });
state.fold(&Command::ChangeMode(crate::AppMode::editing()));
state.fold(&Command::SetOpenedEditor(Some(crate::OpenedEditor::on(
"4".into(),
))));
state.fold(&Command::SetStatus("a note about this cell".into()));
assert!(state.status_stands());
state.fold(&Command::SetOpenedEditor(Some(crate::OpenedEditor::on(
"47".into(),
))));
assert!(
!state.status_stands(),
"a re-opening on the same cell is another editor"
);
}
#[test]
fn every_fold_bumps_the_revision_so_a_renderer_can_poll_it() {
let mut state = ClientState::default();
+21
View File
@@ -561,6 +561,27 @@ impl ViewState {
}
}
/// What one opening of a minibuffer sets. Equal at two moments means the same
/// editor is still open; both front ends ask this. `context/code/tui.md`.
#[derive(Debug, Clone, PartialEq)]
pub struct EditorSession {
mode: AppMode,
cell: (usize, usize),
seed: Option<String>,
}
impl EditorSession {
/// The editor open in `view`, or `None` outside a text-entry mode.
pub fn of(view: &ViewState) -> Option<Self> {
view.mode.minibuffer()?;
Some(Self {
mode: view.mode.clone(),
cell: view.cursor().selected,
seed: view.opened_editor.as_ref().map(|o| o.seed.clone()),
})
}
}
#[cfg(test)]
mod boundary_tests {
/// improvise-cqi AC 2: the part of the boundary the compiler can hold.
@@ -16,6 +16,9 @@ pub struct RenderModel {
/// The mode's own label, off `AppMode` — the client does not spell it.
pub mode: String,
pub status: String,
/// Whether `status` belongs to the editor still open, so the page must not
/// expire it on a timer (improvise-1x9h).
pub status_stands: bool,
/// The document's active view, as this client knows it (gotcha 29).
pub view_name: String,
/// The title bar: the document's name, whether it has unsaved changes, and
@@ -239,6 +242,7 @@ pub fn render_model(state: &ClientState) -> RenderModel {
revision: state.revision,
mode: improvise_protocol::mode_label(&state.view.mode).to_string(),
status: state.view.status_msg.clone(),
status_stands: state.status_stands(),
view_name: state.view.active_view.clone(),
doc_name: state.document.name.clone(),
doc_dirty: state.document.dirty,
@@ -195,6 +195,26 @@ mod tests {
);
}
/// A rejection's sentence is written outside a fold, so it must still be owned
/// by the editor it arrived in (improvise-1x9h). Evidence file.
#[test]
fn a_rejection_that_arrives_in_an_open_editor_stands_beside_it() {
let mut client = Client::default();
client
.state_mut()
.fold(&Command::ChangeMode(improvise_protocol::AppMode::editing()));
client.fold_frame(ServerFrame::Rejected(Rejection::NoDocumentPath));
assert!(
client.state().view.mode.is_editing(),
"a refused save closes nothing"
);
assert!(!client.state().view.status_msg.is_empty());
assert!(
client.state().status_stands(),
"the refusal was not observed where it arrived, so it would expire"
);
}
/// A rejection that refused an **edit** must say nothing was applied; the
/// two `Save` answers refused none. Evidence: clients.md (improvise-8uj8).
#[test]
+34
View File
@@ -182,4 +182,38 @@ test.describe("the app bar is reserved, not fitted", () => {
const toast = await page.locator("#toast").boundingBox();
expect(toast.y + toast.height).toBeLessThanOrEqual(alone.y);
});
// improvise-1x9h. Whether a message stands is Rust's answer; this is the
// wire from the render model to the toast's timer, which node cannot see.
test("a message written beside an open minibuffer outlives the timer", async ({ page }) => {
await page.addInitScript(() => {
const Real = window.Worker;
window.Worker = class extends Real {
constructor(...args) {
super(...args);
window.__worker = this;
}
};
});
await page.clock.install();
await theDemo(page);
await page.keyboard.press("F");
await page.keyboard.press("n");
await expect(page.locator("#mode")).toHaveText("FORMULA");
// A host message arriving during text entry has no keyboard route in the
// demo, so the frame is delivered as the worker would deliver it.
await page.evaluate(() => {
const frame = JSON.stringify({ Commands: [{ SetStatus: "Formula error" }] });
window.__worker.dispatchEvent(new MessageEvent("message", { data: frame }));
});
await expect(page.locator("#toast")).toContainText("Formula error");
await page.clock.runFor(10_000);
await expect(page.locator("#toast")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator("#mode")).toHaveText("FORMULAS");
await expect(page.locator("#toast")).toBeHidden();
});
});
+8 -30
View File
@@ -17,6 +17,7 @@ use ratatui::{
};
use crate::command::key;
use crate::protocol::EditorSession;
use crate::ui::app::{App, AppMode, MinibufColor};
use crate::ui::category_panel::CategoryContent;
use crate::ui::formula_panel::FormulaContent;
@@ -102,41 +103,17 @@ pub fn run_tui(
/// One keypress in the TUI. A message lasts until the next key (improvise-cq3d),
/// except while one editor stays open under it (su4u, sx7h). `context/code/tui.md`.
fn press(app: &mut App, key: key::KeyEvent) -> Result<()> {
let before = EditorSession::of(app);
let before = EditorSession::of(&app.view_state);
let stale = app.view_state.status_msg.clone();
app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(String::new()))]);
app.handle_key(key)?;
let still_editing = before.is_some() && before == EditorSession::of(app);
let still_editing = before.is_some() && before == EditorSession::of(&app.view_state);
if still_editing && app.view_state.status_msg.is_empty() && !stale.is_empty() {
app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(stale))]);
}
Ok(())
}
/// What one opening of a minibuffer sets, which is all the TUI loop can see of
/// it: equal before and after a key means the same editor (improvise-sx7h).
#[derive(Debug, PartialEq)]
struct EditorSession {
mode: AppMode,
cell: (usize, usize),
seed: Option<String>,
}
impl EditorSession {
fn of(app: &App) -> Option<Self> {
app.view_state.mode.minibuffer()?;
Some(Self {
mode: app.view_state.mode.clone(),
cell: app.cursor().selected,
seed: app
.view_state
.opened_editor
.as_ref()
.map(|o| o.seed.clone()),
})
}
}
// ── Key event conversion (TUI boundary) ──────────────────────────────────────
// The only place crossterm's key types and `command::key` may both appear.
@@ -604,8 +581,9 @@ fn draw_welcome(f: &mut Frame, area: Rect) {
#[cfg(test)]
mod bottom_bar_tests {
use super::{EditorSession, draw_bottom_bar, minibuffer_line, press};
use super::{draw_bottom_bar, minibuffer_line, press};
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
use crate::protocol::EditorSession;
use crate::ui::app::{App, AppMode};
use crate::workbook::Workbook;
use ratatui::{Terminal, backend::TestBackend, layout::Rect};
@@ -837,14 +815,14 @@ mod bottom_bar_tests {
let mut app = App::new(Workbook::new("T"), None);
let item_add = |category: &str| AppMode::item_add(category.into());
app.view_state.mode = item_add("Region");
let region = EditorSession::of(&app);
let region = EditorSession::of(&app.view_state);
assert!(region.is_some(), "ItemAdd is a text-entry mode");
app.view_state.mode = item_add("Month");
assert_ne!(region, EditorSession::of(&app));
assert_ne!(region, EditorSession::of(&app.view_state));
app.view_state.mode = item_add("Region");
assert_eq!(
region,
EditorSession::of(&app),
EditorSession::of(&app.view_state),
"and the same one is the same"
);
}
+2 -2
View File
@@ -112,7 +112,7 @@ const shell = createShell({
// it stays reachable rather than being lost to the cap.
el.status.title = text;
},
onModel: (text) => toasts.offer(text),
onModel: (text, stands) => toasts.offer(text, stands),
timeoutMs: HANDSHAKE_TIMEOUT_MS,
});
// The harness asks whether the watchdog is ARMED, because a negative assertion
@@ -268,7 +268,7 @@ function repaint() {
el.hint.textContent = model.hint;
paintHelp(model);
paintMinibuffer(model);
shell.setModel(model.status);
shell.setModel(model.status, model.status_stands);
el.viewName.textContent = model.view_name;
// The title bar, off the cache `src/draw.rs::draw_title` folds — one
// derivation, both front ends (improvise-z754, improvise-5jna).
+18 -5
View File
@@ -16,7 +16,10 @@ export const TOAST_MS = 6000;
/** `ViewState::status_msg` is STATE, not an event: a repaint re-offers the
* string that is already on screen, so dismissal keys off message IDENTITY
* and never off a timer alone. What that costs: context/code/clients.md. */
* and never off a timer alone. What that costs: context/code/clients.md.
*
* `stands` is Rust's answer (`RenderModel::status_stands`): while it holds the
* message is on no timer, and when it stops the message goes (improvise-1x9h). */
export function createToasts({
onShow,
onHide,
@@ -28,12 +31,21 @@ export function createToasts({
// mechanism: a repaint offering it again is recognised and dropped.
let offered = "";
let timer = null;
let standing = false;
return {
offer(text) {
offer(text, stands = false) {
const next = text ?? "";
if (next === offered) return;
if (next === offered) {
// Standing ends once: the editor it belonged to has closed.
if (standing && !stands) {
standing = false;
onHide();
}
return;
}
offered = next;
standing = next !== "" && stands;
clearTimer(timer);
timer = null;
if (next === "") {
@@ -41,6 +53,7 @@ export function createToasts({
return;
}
onShow(next);
if (standing) return;
timer = setTimer(() => {
timer = null;
onHide();
@@ -349,8 +362,8 @@ export function createShell({
setShell(text) {
onShell(text);
},
setModel(text) {
onModel(text);
setModel(text, stands = false) {
onModel(text, stands);
},
/** A peer that never answers the handshake. Deliberately about the *first
* frame*, not either peer's own failure modes — clients.md. */
+55
View File
@@ -125,6 +125,61 @@ test("a host that clears the status hides the toast without waiting", () => {
assert.equal(clock.count(), 0, "a cleared message leaves no timer behind");
});
/// improvise-1x9h: a message that stands beside an open editor is the user's
/// until the editor closes, not the timer's. Evidence: clients.md.
test("a standing message outlives the timer and goes with its editor", () => {
const clock = fakeClock();
const shown = [];
let visible = false;
const toasts = createToasts({
onShow: (t) => {
shown.push(t);
visible = true;
},
onHide: () => {
visible = false;
},
ttlMs: 6000,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
});
toasts.offer("Formula error", true);
assert.equal(clock.count(), 0, "a standing message arms no timer");
clock.fire();
assert.equal(visible, true, "the refusal hid while its editor was open");
toasts.offer("Formula error", true);
assert.deepEqual(shown, ["Formula error"], "a repaint is not a second event");
toasts.offer("Formula error", false);
assert.equal(visible, false, "the refusal outlived its editor");
assert.equal(clock.count(), 0, "leaving hides now, not on a timer");
toasts.offer("Formula error", true);
assert.equal(visible, false, "a message that stopped standing came back");
});
/// The other direction: a message that does not stand is on the timer exactly
/// as before (improvise-1x9h).
test("a message that does not stand keeps its timer", () => {
const clock = fakeClock();
let visible = false;
const toasts = createToasts({
onShow: () => {
visible = true;
},
onHide: () => {
visible = false;
},
ttlMs: 6000,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
});
toasts.offer("Formula added", false);
assert.equal(clock.count(), 1);
clock.fire();
assert.equal(visible, false);
});
test("an empty status on the first frame shows nothing", () => {
// The common case: every repaint before any command offers "". A feed that
// treated the first offer as new would flash an empty toast on load.