diff --git a/context/code/clients.md b/context/code/clients.md index 73c7cbde..b7968798 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -2267,6 +2267,61 @@ 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 after every fold (`settle_status`, beside `settle_mode` and +`settle_editor`): + +- 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. + +**A status written outside a fold must be observed where it is written.** +`socket.rs` writes a rejection's sentence *after* `refused()`, so the sentence +survives the rollback. It now writes it through `ClientState::notify`, which +observes it on the spot. Assigning `status_msg` directly leaves it unobserved +until the next fold. By then the editor may have changed, and the message +would be owned by the wrong one. + +**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 diff --git a/context/code/tui.md b/context/code/tui.md index a3589e3e..964b9901 100644 --- a/context/code/tui.md +++ b/context/code/tui.md @@ -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". diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 1b893acb..374fa5a2 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5117,3 +5117,60 @@ Rival injected: `claim_as_read` ignoring the captured generation and claiming the current one. That is the pre-fix behaviour, restated after the fix. Only this test failed in the whole workspace. The layout tier staying current is still pinned by `an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumber`. + +### 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 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. diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index a2615b56..62ca8f93 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -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, + /// 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, } /// One frame this client has sent. `awaited` is true exactly when the host @@ -75,6 +81,7 @@ impl ClientState { } self.settle_mode(); self.settle_editor(); + self.settle_status(); self.revision += 1; } @@ -87,6 +94,7 @@ impl ClientState { } self.settle_mode(); self.settle_editor(); + self.settle_status(); self.revision += 1; } @@ -127,9 +135,26 @@ impl ClientState { self.last_rejection = Some(rejection); self.settle_mode(); self.settle_editor(); + self.settle_status(); self.revision += 1; } + /// Say something to this client's user: the one door for a status written + /// outside a fold, so it is observed where it was written. + pub fn notify(&mut self, message: String) { + self.view.status_msg = message; + self.settle_status(); + self.revision += 1; + } + + /// Whether the status belongs to the editor still open, so a renderer must + /// not expire it (improvise-1x9h). The TUI asks the same `EditorSession`. + pub fn status_stands(&self) -> bool { + !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) { @@ -177,6 +202,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) { @@ -435,6 +472,73 @@ 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" + ); + } + + /// 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(); diff --git a/crates/improvise-protocol/src/lib.rs b/crates/improvise-protocol/src/lib.rs index 84b55876..9e7e288e 100644 --- a/crates/improvise-protocol/src/lib.rs +++ b/crates/improvise-protocol/src/lib.rs @@ -554,6 +554,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, +} + +impl EditorSession { + /// The editor open in `view`, or `None` outside a text-entry mode. + pub fn of(view: &ViewState) -> Option { + 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. diff --git a/crates/improvise-wasm-client/src/render.rs b/crates/improvise-wasm-client/src/render.rs index 8535f78b..02fbfa9d 100644 --- a/crates/improvise-wasm-client/src/render.rs +++ b/crates/improvise-wasm-client/src/render.rs @@ -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 @@ -238,6 +241,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, diff --git a/crates/improvise-wasm-client/src/socket.rs b/crates/improvise-wasm-client/src/socket.rs index 1f1c0d21..e080f18c 100644 --- a/crates/improvise-wasm-client/src/socket.rs +++ b/crates/improvise-wasm-client/src/socket.rs @@ -95,7 +95,7 @@ impl Client { // The sentence goes on *after* the rollback: it is this // client's own notification, and the slice it restores has none. state.refused(rejection); - state.view.status_msg = message; + state.notify(message); } ServerFrame::Accepted => self.state_mut().accepted(), } @@ -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] diff --git a/harness/tests/header.spec.js b/harness/tests/header.spec.js index 0fd525c5..7c27dc1e 100644 --- a/harness/tests/header.spec.js +++ b/harness/tests/header.spec.js @@ -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(); + }); }); diff --git a/src/draw.rs b/src/draw.rs index 05708b4a..78110a7f 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -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, -} - -impl EditorSession { - fn of(app: &App) -> Option { - 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" ); } diff --git a/web/app.js b/web/app.js index 3277eb55..5de93091 100644 --- a/web/app.js +++ b/web/app.js @@ -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). diff --git a/web/shell.js b/web/shell.js index 296fa1de..bee7a693 100644 --- a/web/shell.js +++ b/web/shell.js @@ -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. */ diff --git a/web/tests/shell.test.js b/web/tests/shell.test.js index e8a9570d..d5fee7e5 100644 --- a/web/tests/shell.test.js +++ b/web/tests/shell.test.js @@ -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.