From c1c9f6cbc45f8d89f66777f6871889679d4438fe Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Tue, 19 May 2026 09:44:28 +0000 Subject: [PATCH] runtime: extract the indicator debounce into a tested state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validity-indicator debounce was two locals in the event loop (indicator_pending + app.input_indicator) with no unit coverage — ADR-0027's as-built notes flag it as untested async glue. The decision logic is now an IndicatorDebounce struct: note_event (a keystroke hides + arms; non-key events leave it be), settle (the quiet window elapsed → show the verdict + disarm), is_armed (drives the recv timeout), visible (mirrored into app.input_indicator for the renderer). No behaviour change — the tokio timer and terminal stay in the loop. 7 unit tests cover the debounce contract: the keystroke / settle cycle, clean verdicts, and that a background event mid-typing does not cancel the owed recompute. 1125 passing, clippy clean. --- src/runtime.rs | 163 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 149 insertions(+), 14 deletions(-) diff --git a/src/runtime.rs b/src/runtime.rs index 337ee94..7ba898c 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -33,6 +33,7 @@ use crate::db::{ DropColumnResult, InsertResult, TableDescription, UpdateResult, }; use crate::dsl::Command; +use crate::dsl::walker::Severity; use crate::event::AppEvent; use crate::project::{ Project, ProjectKind, copy_project, list_projects, open_or_create, projects_dir, @@ -48,6 +49,58 @@ const SHUTDOWN_GRACE: Duration = Duration::from_millis(100); /// reappears once typing stops (ADR-0027 §3). const INDICATOR_DEBOUNCE: Duration = Duration::from_millis(1000); +/// The input-validity indicator's debounce state machine +/// (ADR-0027 §3, step E). +/// +/// A keystroke hides the indicator and arms the debounce; the +/// event loop time-boxes `recv` while `is_armed`, so once +/// typing has paused for `INDICATOR_DEBOUNCE` it calls `settle` +/// with the freshly computed verdict. The `tokio` timer and the +/// terminal live in the loop — this owns only the decision +/// logic, keeping the debounce contract unit-testable without +/// an event loop. `App::input_indicator` mirrors `visible` for +/// the renderer. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +struct IndicatorDebounce { + /// What the indicator currently shows; `None` is hidden. + visible: Option, + /// True while a recompute is owed — only then does the + /// event loop time-box `recv`. + armed: bool, +} + +impl IndicatorDebounce { + /// The indicator value the renderer should mirror. + const fn visible(self) -> Option { + self.visible + } + + /// Whether the event loop should time-box `recv` with the + /// debounce window. + const fn is_armed(self) -> bool { + self.armed + } + + /// Record a processed event. A keystroke hides the + /// indicator and (re)arms the debounce; any other event + /// leaves the indicator and the armed state untouched (a + /// background result arriving mid-typing must not cancel + /// the pending recompute). + const fn note_event(&mut self, is_key: bool) { + if is_key { + self.visible = None; + self.armed = true; + } + } + + /// The debounce window elapsed — typing has paused. Show + /// `verdict` and disarm. + const fn settle(&mut self, verdict: Option) { + self.visible = verdict; + self.armed = false; + } +} + /// Run the application until a `Quit` action is enacted or the /// terminal closes. pub async fn run(args: Args) -> Result<()> { @@ -280,20 +333,21 @@ async fn run_loop( info!("entering main event loop"); // ADR-0027 §3: the validity indicator is debounced — hidden // on every keystroke, recomputed and shown once typing has - // paused for `INDICATOR_DEBOUNCE`. `indicator_pending` is - // true while a recompute is owed; only then is `recv` - // time-boxed, so an idle session still does no wake-ups. - let mut indicator_pending = false; + // paused for `INDICATOR_DEBOUNCE`. `recv` is time-boxed only + // while the debounce is armed, so an idle session still does + // no wake-ups. See `IndicatorDebounce` for the decision + // logic; `app.input_indicator` mirrors it for the renderer. + let mut debounce = IndicatorDebounce::default(); loop { - let event = if indicator_pending { + let event = if debounce.is_armed() { match tokio::time::timeout(INDICATOR_DEBOUNCE, event_rx.recv()).await { Ok(Some(event)) => event, Ok(None) => break, Err(_elapsed) => { // Typing has been quiet for the debounce // interval — settle the indicator. - app.input_indicator = app.input_validity_verdict(); - indicator_pending = false; + debounce.settle(app.input_validity_verdict()); + app.input_indicator = debounce.visible(); terminal .draw(|f| ui::render(&mut app, &theme, f)) .context("redraw")?; @@ -413,13 +467,11 @@ async fn run_loop( } } } - if is_key { - // A keystroke hides the indicator and re-arms the - // debounce (ADR-0027 §3) — it reappears once typing - // pauses. - app.input_indicator = None; - indicator_pending = true; - } + // A keystroke hides the indicator and re-arms the + // debounce (ADR-0027 §3) — it reappears once typing + // pauses; non-key events leave it untouched. + debounce.note_event(is_key); + app.input_indicator = debounce.visible(); terminal .draw(|f| ui::render(&mut app, &theme, f)) .context("redraw")?; @@ -1791,3 +1843,86 @@ fn teardown_terminal( terminal.show_cursor().context("show cursor")?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::IndicatorDebounce; + use crate::dsl::walker::Severity; + + #[test] + fn starts_hidden_and_disarmed() { + let d = IndicatorDebounce::default(); + assert_eq!(d.visible(), None); + assert!(!d.is_armed()); + } + + #[test] + fn a_keystroke_hides_the_indicator_and_arms_the_debounce() { + let mut d = IndicatorDebounce::default(); + d.settle(Some(Severity::Error)); + assert_eq!(d.visible(), Some(Severity::Error)); + d.note_event(true); + assert_eq!(d.visible(), None, "a keystroke hides the indicator"); + assert!(d.is_armed(), "a keystroke arms the debounce"); + } + + #[test] + fn settling_shows_the_verdict_and_disarms() { + let mut d = IndicatorDebounce::default(); + d.note_event(true); + assert!(d.is_armed()); + d.settle(Some(Severity::Warning)); + assert_eq!(d.visible(), Some(Severity::Warning)); + assert!(!d.is_armed(), "a settled indicator owes no recompute"); + } + + #[test] + fn settling_a_clean_verdict_shows_nothing() { + let mut d = IndicatorDebounce::default(); + d.note_event(true); + d.settle(None); + assert_eq!(d.visible(), None); + assert!(!d.is_armed()); + } + + #[test] + fn a_non_key_event_does_not_disturb_a_shown_indicator() { + let mut d = IndicatorDebounce::default(); + d.settle(Some(Severity::Error)); + d.note_event(false); + assert_eq!( + d.visible(), + Some(Severity::Error), + "a non-key event leaves the indicator shown", + ); + assert!(!d.is_armed(), "a non-key event does not arm the debounce"); + } + + #[test] + fn a_non_key_event_while_armed_keeps_the_debounce_armed() { + // A background event (a DSL result, a tables refresh) + // arriving mid-typing must not cancel the pending + // recompute. + let mut d = IndicatorDebounce::default(); + d.note_event(true); + assert!(d.is_armed()); + d.note_event(false); + assert!(d.is_armed(), "the owed recompute survives a non-key event"); + assert_eq!(d.visible(), None, "and the indicator stays hidden"); + } + + #[test] + fn typing_resumes_after_a_settle() { + // The full cycle: type → settle → type again → settle. + let mut d = IndicatorDebounce::default(); + d.note_event(true); + d.settle(Some(Severity::Warning)); + assert_eq!(d.visible(), Some(Severity::Warning)); + d.note_event(true); + assert_eq!(d.visible(), None, "new typing hides the indicator again"); + assert!(d.is_armed()); + d.settle(None); + assert_eq!(d.visible(), None); + assert!(!d.is_armed()); + } +}