Skip to main content

ftui_runtime/
program.rs

1#![forbid(unsafe_code)]
2
3//! Bubbletea/Elm-style runtime for terminal applications.
4//!
5//! The program runtime manages the update/view loop, handling events and
6//! rendering frames. It separates state (Model) from rendering (View) and
7//! provides a command pattern for side effects.
8//!
9//! # Example
10//!
11//! ```ignore
12//! use ftui_runtime::program::{Model, Cmd};
13//! use ftui_core::event::Event;
14//! use ftui_render::frame::Frame;
15//!
16//! struct Counter {
17//!     count: i32,
18//! }
19//!
20//! enum Msg {
21//!     Increment,
22//!     Decrement,
23//!     Quit,
24//! }
25//!
26//! impl From<Event> for Msg {
27//!     fn from(event: Event) -> Self {
28//!         match event {
29//!             Event::Key(k) if k.is_char('q') => Msg::Quit,
30//!             Event::Key(k) if k.is_char('+') => Msg::Increment,
31//!             Event::Key(k) if k.is_char('-') => Msg::Decrement,
32//!             _ => Msg::Increment, // Default
33//!         }
34//!     }
35//! }
36//!
37//! impl Model for Counter {
38//!     type Message = Msg;
39//!
40//!     fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
41//!         match msg {
42//!             Msg::Increment => { self.count += 1; Cmd::none() }
43//!             Msg::Decrement => { self.count -= 1; Cmd::none() }
44//!             Msg::Quit => Cmd::quit(),
45//!         }
46//!     }
47//!
48//!     fn view(&self, frame: &mut Frame) {
49//!         // Render counter value to frame
50//!     }
51//! }
52//! ```
53
54use crate::StorageResult;
55use crate::evidence_sink::{EvidenceSink, EvidenceSinkConfig};
56use crate::evidence_telemetry::{
57    BudgetDecisionSnapshot, ConformalSnapshot, ResizeDecisionSnapshot, set_budget_snapshot,
58    set_resize_snapshot,
59};
60use crate::input_fairness::{FairnessDecision, FairnessEventType, InputFairnessGuard};
61use crate::input_macro::{EventRecorder, InputMacro};
62use crate::locale::LocaleContext;
63use crate::queueing_scheduler::{EstimateSource, QueueingScheduler, SchedulerConfig, WeightSource};
64use crate::render_trace::RenderTraceConfig;
65use crate::resize_coalescer::{CoalesceAction, CoalescerConfig, ResizeCoalescer};
66use crate::state_persistence::StateRegistry;
67use crate::subscription::SubscriptionManager;
68use crate::terminal_writer::{RuntimeDiffConfig, ScreenMode, TerminalWriter, UiAnchor};
69use crate::voi_sampling::{VoiConfig, VoiSampler};
70use crate::{BucketKey, ConformalConfig, ConformalPrediction, ConformalPredictor};
71#[cfg(feature = "asupersync-executor")]
72use asupersync::runtime::{BlockingTaskHandle, Runtime as AsupersyncRuntime, RuntimeBuilder};
73use ftui_backend::{BackendEventSource, BackendFeatures};
74use ftui_core::event::{
75    Event, KeyCode, KeyEvent, KeyEventKind, Modifiers, MouseButton, MouseEvent, MouseEventKind,
76};
77#[cfg(feature = "crossterm-compat")]
78use ftui_core::terminal_capabilities::TerminalCapabilities;
79#[cfg(feature = "crossterm-compat")]
80use ftui_core::terminal_session::{SessionOptions, TerminalSession};
81use ftui_layout::{
82    PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS, PANE_DRAG_RESIZE_DEFAULT_THRESHOLD, PaneCancelReason,
83    PaneDragResizeMachine, PaneDragResizeMachineError, PaneDragResizeState,
84    PaneDragResizeTransition, PaneInertialThrow, PaneLayout, PaneModifierSnapshot,
85    PaneMotionVector, PaneNodeKind, PanePointerButton, PanePointerPosition,
86    PanePressureSnapProfile, PaneResizeDirection, PaneResizeTarget, PaneSemanticInputEvent,
87    PaneSemanticInputEventKind, PaneTree, Rect, SplitAxis,
88};
89use ftui_render::arena::FrameArena;
90use ftui_render::budget::{
91    BudgetControllerConfig, BudgetDecision, BudgetDecisionReason, DegradationLevel,
92    FrameBudgetConfig, RenderBudget,
93};
94use ftui_render::buffer::Buffer;
95use ftui_render::diff_strategy::DiffStrategy;
96use ftui_render::frame::{Frame, HitData, HitId, HitRegion, WidgetBudget, WidgetSignal};
97use ftui_render::frame_guardrails::{FrameGuardrails, GuardrailsConfig};
98use ftui_render::sanitize::sanitize;
99use std::any::Any;
100use std::collections::HashMap;
101use std::io::{self, Stdout, Write};
102use std::panic::{self, AssertUnwindSafe};
103use std::sync::Arc;
104
105/// Check for pending termination signal. Returns `None` when crossterm is not
106/// enabled (headless / wasm builds don't install signal handlers).
107#[inline]
108fn check_termination_signal() -> Option<i32> {
109    ftui_core::shutdown_signal::pending_termination_signal()
110}
111
112/// Clear the pending termination signal.
113#[inline]
114fn clear_termination_signal() {
115    ftui_core::shutdown_signal::clear_pending_termination_signal();
116}
117use std::sync::mpsc;
118use std::thread::{self, JoinHandle};
119use tracing::{debug, debug_span, info, info_span, trace};
120use web_time::{Duration, Instant};
121
122/// The Model trait defines application state and behavior.
123///
124/// Implementations define how the application responds to events
125/// and renders its current state.
126pub trait Model: Sized {
127    /// The message type for this model.
128    ///
129    /// Messages represent actions that update the model state.
130    /// Must be convertible from terminal events.
131    type Message: From<Event> + Send + 'static;
132
133    /// Initialize the model with startup commands.
134    ///
135    /// Called once when the program starts. Return commands to execute
136    /// initial side effects like loading data.
137    fn init(&mut self) -> Cmd<Self::Message> {
138        Cmd::none()
139    }
140
141    /// Update the model in response to a message.
142    ///
143    /// This is the core state transition function. Returns commands
144    /// for any side effects that should be executed.
145    fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>;
146
147    /// Render the current state to a frame.
148    ///
149    /// Called after updates when the UI needs to be redrawn.
150    fn view(&self, frame: &mut Frame);
151
152    /// Declare active subscriptions.
153    ///
154    /// Called after each `update()`. The runtime compares the returned set
155    /// (by `SubId`) against currently running subscriptions and starts/stops
156    /// as needed. Returning an empty vec stops all subscriptions.
157    ///
158    /// # Default
159    ///
160    /// Returns an empty vec (no subscriptions).
161    fn subscriptions(&self) -> Vec<Box<dyn crate::subscription::Subscription<Self::Message>>> {
162        vec![]
163    }
164
165    /// Downcast to [`ScreenTickDispatch`](crate::tick_strategy::ScreenTickDispatch)
166    /// for per-screen tick control.
167    ///
168    /// Override this to return `Some(self)` in multi-screen Models. The runtime
169    /// will then consult the active [`TickStrategy`](crate::tick_strategy::TickStrategy)
170    /// for each inactive screen instead of ticking monolithically.
171    ///
172    /// Default: `None` (all screens tick every frame, backwards-compatible).
173    fn as_screen_tick_dispatch(
174        &mut self,
175    ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
176        None
177    }
178
179    /// Called before the runtime exits, whether via [`Cmd::Quit`] or signal.
180    ///
181    /// Return cleanup commands (e.g., saving state, closing connections).
182    /// The runtime executes these before teardown.
183    ///
184    /// # Migration rationale
185    ///
186    /// Source frameworks use `componentWillUnmount`, `useEffect` cleanup, or
187    /// `beforeDestroy` hooks. This provides an equivalent lifecycle point.
188    fn on_shutdown(&mut self) -> Cmd<Self::Message> {
189        Cmd::none()
190    }
191
192    /// Called when an unrecoverable error occurs during the runtime loop.
193    ///
194    /// Return commands for error recovery or graceful degradation. The
195    /// `error` string contains the error description.
196    ///
197    /// # Migration rationale
198    ///
199    /// Source frameworks use `componentDidCatch`, error boundaries, or
200    /// `onError` hooks. This provides an equivalent error recovery point.
201    fn on_error(&mut self, _error: &str) -> Cmd<Self::Message> {
202        Cmd::none()
203    }
204}
205
206/// Default weight assigned to background tasks.
207const DEFAULT_TASK_WEIGHT: f64 = 1.0;
208
209/// Default estimated task cost (ms) used for scheduling.
210const DEFAULT_TASK_ESTIMATE_MS: f64 = 10.0;
211
212/// Scheduling metadata for background tasks.
213#[derive(Debug, Clone)]
214pub struct TaskSpec {
215    /// Task weight (importance). Higher = more priority.
216    pub weight: f64,
217    /// Estimated task cost in milliseconds.
218    pub estimate_ms: f64,
219    /// Optional task name for evidence logging.
220    pub name: Option<String>,
221}
222
223impl Default for TaskSpec {
224    fn default() -> Self {
225        Self {
226            weight: DEFAULT_TASK_WEIGHT,
227            estimate_ms: DEFAULT_TASK_ESTIMATE_MS,
228            name: None,
229        }
230    }
231}
232
233impl TaskSpec {
234    /// Create a task spec with an explicit weight and estimate.
235    #[must_use]
236    pub fn new(weight: f64, estimate_ms: f64) -> Self {
237        Self {
238            weight,
239            estimate_ms,
240            name: None,
241        }
242    }
243
244    /// Attach a task name for diagnostics.
245    #[must_use]
246    pub fn with_name(mut self, name: impl Into<String>) -> Self {
247        self.name = Some(name.into());
248        self
249    }
250}
251
252/// Per-frame timing data for profiling.
253#[derive(Debug, Clone, Copy)]
254pub struct FrameTiming {
255    pub frame_idx: u64,
256    pub update_us: u64,
257    pub render_us: u64,
258    pub diff_us: u64,
259    pub present_us: u64,
260    pub total_us: u64,
261}
262
263#[derive(Debug)]
264struct SignalTerminationError {
265    signal: i32,
266}
267
268impl std::fmt::Display for SignalTerminationError {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        write!(f, "terminated by signal {}", self.signal)
271    }
272}
273
274impl std::error::Error for SignalTerminationError {}
275
276fn signal_termination_from_error(err: &io::Error) -> Option<i32> {
277    err.get_ref()
278        .and_then(|inner| inner.downcast_ref::<SignalTerminationError>())
279        .map(|inner| inner.signal)
280}
281
282/// Sink for frame timing events.
283pub trait FrameTimingSink: Send + Sync {
284    fn record_frame(&self, timing: &FrameTiming);
285}
286
287/// Configuration for frame timing capture.
288#[derive(Clone)]
289pub struct FrameTimingConfig {
290    pub sink: Arc<dyn FrameTimingSink>,
291}
292
293impl FrameTimingConfig {
294    #[must_use]
295    pub fn new(sink: Arc<dyn FrameTimingSink>) -> Self {
296        Self { sink }
297    }
298}
299
300impl std::fmt::Debug for FrameTimingConfig {
301    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302        f.debug_struct("FrameTimingConfig")
303            .field("sink", &"<dyn FrameTimingSink>")
304            .finish()
305    }
306}
307
308/// Commands represent side effects to be executed by the runtime.
309///
310/// Commands are returned from `init()` and `update()` to trigger
311/// actions like quitting, sending messages, or scheduling ticks.
312#[derive(Default)]
313pub enum Cmd<M> {
314    /// No operation.
315    #[default]
316    None,
317    /// Quit the application.
318    Quit,
319    /// Execute multiple commands as a batch (currently sequential).
320    Batch(Vec<Cmd<M>>),
321    /// Execute commands sequentially.
322    Sequence(Vec<Cmd<M>>),
323    /// Send a message to the model.
324    Msg(M),
325    /// Schedule a tick after a duration.
326    Tick(Duration),
327    /// Write a log message to the terminal output.
328    ///
329    /// This writes to the scrollback region in inline mode, or is ignored/handled
330    /// appropriately in alternate screen mode. Safe to use with the One-Writer Rule.
331    Log(String),
332    /// Execute a blocking operation on a background thread.
333    ///
334    /// When effect queue scheduling is enabled, tasks are enqueued and executed
335    /// in Smith-rule order on a dedicated worker thread. Otherwise the closure
336    /// runs on a spawned thread immediately. The return value is sent back
337    /// as a message to the model.
338    Task(TaskSpec, Box<dyn FnOnce() -> M + Send>),
339    /// Save widget state to the persistence registry.
340    ///
341    /// Triggers a flush of the state registry to the storage backend.
342    /// No-op if persistence is not configured.
343    SaveState,
344    /// Restore widget state from the persistence registry.
345    ///
346    /// Triggers a load from the storage backend and updates the cache.
347    /// No-op if persistence is not configured. Returns a message via
348    /// callback if state was successfully restored.
349    RestoreState,
350    /// Toggle mouse capture at runtime.
351    ///
352    /// Instructs the terminal session to enable or disable mouse event capture.
353    /// No-op in test simulators.
354    SetMouseCapture(bool),
355    /// Replace the tick strategy at runtime.
356    ///
357    /// Takes ownership of a boxed strategy. Use when switching from one
358    /// strategy to another (e.g., `Uniform` → `Predictive` after loading
359    /// persisted transition data).
360    SetTickStrategy(Box<dyn crate::tick_strategy::TickStrategy>),
361}
362
363impl<M: std::fmt::Debug> std::fmt::Debug for Cmd<M> {
364    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
365        match self {
366            Self::None => write!(f, "None"),
367            Self::Quit => write!(f, "Quit"),
368            Self::Batch(cmds) => f.debug_tuple("Batch").field(cmds).finish(),
369            Self::Sequence(cmds) => f.debug_tuple("Sequence").field(cmds).finish(),
370            Self::Msg(m) => f.debug_tuple("Msg").field(m).finish(),
371            Self::Tick(d) => f.debug_tuple("Tick").field(d).finish(),
372            Self::Log(s) => f.debug_tuple("Log").field(s).finish(),
373            Self::Task(spec, _) => f.debug_struct("Task").field("spec", spec).finish(),
374            Self::SaveState => write!(f, "SaveState"),
375            Self::RestoreState => write!(f, "RestoreState"),
376            Self::SetMouseCapture(b) => write!(f, "SetMouseCapture({b})"),
377            Self::SetTickStrategy(s) => write!(f, "SetTickStrategy({})", s.name()),
378        }
379    }
380}
381
382impl<M> Cmd<M> {
383    /// Create a no-op command.
384    #[inline]
385    pub fn none() -> Self {
386        Self::None
387    }
388
389    /// Create a quit command.
390    #[inline]
391    pub fn quit() -> Self {
392        Self::Quit
393    }
394
395    /// Create a message command.
396    #[inline]
397    pub fn msg(m: M) -> Self {
398        Self::Msg(m)
399    }
400
401    /// Create a log command.
402    ///
403    /// The message will be sanitized and written to the terminal log (scrollback).
404    /// A newline is appended if not present.
405    #[inline]
406    pub fn log(msg: impl Into<String>) -> Self {
407        Self::Log(msg.into())
408    }
409
410    /// Create a batch of commands.
411    pub fn batch(cmds: Vec<Self>) -> Self {
412        if cmds.is_empty() {
413            Self::None
414        } else if cmds.len() == 1 {
415            cmds.into_iter().next().unwrap_or(Self::None)
416        } else {
417            Self::Batch(cmds)
418        }
419    }
420
421    /// Create a sequence of commands.
422    pub fn sequence(cmds: Vec<Self>) -> Self {
423        if cmds.is_empty() {
424            Self::None
425        } else if cmds.len() == 1 {
426            cmds.into_iter().next().unwrap_or(Self::None)
427        } else {
428            Self::Sequence(cmds)
429        }
430    }
431
432    /// Return a stable name for telemetry and tracing.
433    #[inline]
434    pub fn type_name(&self) -> &'static str {
435        match self {
436            Self::None => "None",
437            Self::Quit => "Quit",
438            Self::Batch(_) => "Batch",
439            Self::Sequence(_) => "Sequence",
440            Self::Msg(_) => "Msg",
441            Self::Tick(_) => "Tick",
442            Self::Log(_) => "Log",
443            Self::Task(..) => "Task",
444            Self::SaveState => "SaveState",
445            Self::RestoreState => "RestoreState",
446            Self::SetMouseCapture(_) => "SetMouseCapture",
447            Self::SetTickStrategy(_) => "SetTickStrategy",
448        }
449    }
450
451    /// Create a tick command.
452    #[inline]
453    pub fn tick(duration: Duration) -> Self {
454        Self::Tick(duration)
455    }
456
457    /// Create a background task command.
458    ///
459    /// The closure runs on a spawned thread (or the effect queue worker when
460    /// scheduling is enabled). When it completes, the returned message is
461    /// sent back to the model's `update()`.
462    pub fn task<F>(f: F) -> Self
463    where
464        F: FnOnce() -> M + Send + 'static,
465    {
466        Self::Task(TaskSpec::default(), Box::new(f))
467    }
468
469    /// Create a background task command with explicit scheduling metadata.
470    pub fn task_with_spec<F>(spec: TaskSpec, f: F) -> Self
471    where
472        F: FnOnce() -> M + Send + 'static,
473    {
474        Self::Task(spec, Box::new(f))
475    }
476
477    /// Create a background task command with explicit weight and estimate.
478    pub fn task_weighted<F>(weight: f64, estimate_ms: f64, f: F) -> Self
479    where
480        F: FnOnce() -> M + Send + 'static,
481    {
482        Self::Task(TaskSpec::new(weight, estimate_ms), Box::new(f))
483    }
484
485    /// Create a named background task command.
486    pub fn task_named<F>(name: impl Into<String>, f: F) -> Self
487    where
488        F: FnOnce() -> M + Send + 'static,
489    {
490        Self::Task(TaskSpec::default().with_name(name), Box::new(f))
491    }
492
493    /// Replace the active tick strategy at runtime.
494    ///
495    /// Use when switching strategies (e.g., `Uniform` → `Predictive` after
496    /// loading persisted transition data).
497    pub fn set_tick_strategy(strategy: impl crate::tick_strategy::TickStrategy + 'static) -> Self {
498        Self::SetTickStrategy(Box::new(strategy))
499    }
500
501    /// Create a save state command.
502    ///
503    /// Triggers a flush of the state registry to the storage backend.
504    /// No-op if persistence is not configured.
505    #[inline]
506    pub fn save_state() -> Self {
507        Self::SaveState
508    }
509
510    /// Create a restore state command.
511    ///
512    /// Triggers a load from the storage backend.
513    /// No-op if persistence is not configured.
514    #[inline]
515    pub fn restore_state() -> Self {
516        Self::RestoreState
517    }
518
519    /// Create a mouse capture toggle command.
520    ///
521    /// Instructs the runtime to enable or disable mouse event capture on the
522    /// underlying terminal session.
523    #[inline]
524    pub fn set_mouse_capture(enabled: bool) -> Self {
525        Self::SetMouseCapture(enabled)
526    }
527
528    /// Count the number of atomic commands in this command.
529    ///
530    /// Returns 0 for None, 1 for atomic commands, and recursively counts for Batch/Sequence.
531    pub fn count(&self) -> usize {
532        match self {
533            Self::None => 0,
534            Self::Batch(cmds) | Self::Sequence(cmds) => cmds.iter().map(Self::count).sum(),
535            _ => 1,
536        }
537    }
538}
539
540/// Resize handling behavior for the runtime.
541#[derive(Debug, Clone, Copy, PartialEq, Eq)]
542pub enum ResizeBehavior {
543    /// Apply resize immediately (no debounce, no placeholder).
544    Immediate,
545    /// Coalesce resize events for continuous reflow.
546    Throttled,
547}
548
549impl ResizeBehavior {
550    const fn uses_coalescer(self) -> bool {
551        matches!(self, ResizeBehavior::Throttled)
552    }
553}
554
555/// Policy controlling when terminal mouse capture is enabled.
556///
557/// Mouse capture can steal normal scrollback interaction in inline mode.
558/// `Auto` keeps inline mode scrollback-safe while still enabling mouse in
559/// alt-screen mode.
560#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
561pub enum MouseCapturePolicy {
562    /// Enable in alt-screen mode, disable in inline modes.
563    #[default]
564    Auto,
565    /// Always enable mouse capture.
566    On,
567    /// Always disable mouse capture.
568    Off,
569}
570
571impl MouseCapturePolicy {
572    /// Resolve the policy to a concrete mouse-capture toggle.
573    #[must_use]
574    pub const fn resolve(self, screen_mode: ScreenMode) -> bool {
575        match self {
576            Self::Auto => matches!(screen_mode, ScreenMode::AltScreen),
577            Self::On => true,
578            Self::Off => false,
579        }
580    }
581}
582
583const PANE_TERMINAL_DEFAULT_HIT_THICKNESS: u16 = 3;
584const PANE_TERMINAL_TARGET_AXIS_MASK: u64 = 0b1;
585
586/// One splitter handle region in terminal cell-space.
587#[derive(Debug, Clone, Copy, PartialEq, Eq)]
588pub struct PaneTerminalSplitterHandle {
589    /// Semantic resize target represented by this handle.
590    pub target: PaneResizeTarget,
591    /// Cell-space hit rectangle for this handle.
592    pub rect: Rect,
593    /// Split boundary coordinate used for deterministic nearest-target ranking.
594    pub boundary: i32,
595}
596
597/// Build deterministic splitter handle regions for terminal hit-testing.
598///
599/// Handles are emitted in split-id order and are clamped to the split rect.
600#[must_use]
601pub fn pane_terminal_splitter_handles(
602    tree: &PaneTree,
603    layout: &PaneLayout,
604    hit_thickness: u16,
605) -> Vec<PaneTerminalSplitterHandle> {
606    let thickness = if hit_thickness == 0 {
607        PANE_TERMINAL_DEFAULT_HIT_THICKNESS
608    } else {
609        hit_thickness
610    };
611    let mut handles = Vec::new();
612    for node in tree.nodes() {
613        let PaneNodeKind::Split(split) = &node.kind else {
614            continue;
615        };
616        let Some(split_rect) = layout.rect(node.id) else {
617            continue;
618        };
619        if split_rect.is_empty() {
620            continue;
621        }
622        let Some(first_rect) = layout.rect(split.first) else {
623            continue;
624        };
625        let Some(second_rect) = layout.rect(split.second) else {
626            continue;
627        };
628
629        let boundary_u16 = match split.axis {
630            SplitAxis::Horizontal => {
631                // Horizontal split => left/right panes => vertical splitter line.
632                if second_rect.x == split_rect.x {
633                    first_rect.right()
634                } else {
635                    second_rect.x
636                }
637            }
638            SplitAxis::Vertical => {
639                // Vertical split => top/bottom panes => horizontal splitter line.
640                if second_rect.y == split_rect.y {
641                    first_rect.bottom()
642                } else {
643                    second_rect.y
644                }
645            }
646        };
647        let Some(rect) = splitter_hit_rect(split.axis, split_rect, boundary_u16, thickness) else {
648            continue;
649        };
650        handles.push(PaneTerminalSplitterHandle {
651            target: PaneResizeTarget {
652                split_id: node.id,
653                axis: split.axis,
654            },
655            rect,
656            boundary: i32::from(boundary_u16),
657        });
658    }
659    handles
660}
661
662/// Resolve a semantic splitter target from a terminal cell position.
663///
664/// If multiple handles overlap, chooses deterministically by:
665/// 1) smallest distance to the splitter boundary, then
666/// 2) smaller split_id, then
667/// 3) horizontal axis before vertical axis.
668#[must_use]
669pub fn pane_terminal_resolve_splitter_target(
670    handles: &[PaneTerminalSplitterHandle],
671    x: u16,
672    y: u16,
673) -> Option<PaneResizeTarget> {
674    let px = i32::from(x);
675    let py = i32::from(y);
676    let mut best: Option<((u32, u64, u8), PaneResizeTarget)> = None;
677
678    for handle in handles {
679        if !rect_contains_cell(handle.rect, x, y) {
680            continue;
681        }
682        let distance = match handle.target.axis {
683            SplitAxis::Horizontal => px.abs_diff(handle.boundary),
684            SplitAxis::Vertical => py.abs_diff(handle.boundary),
685        };
686        let axis_rank = match handle.target.axis {
687            SplitAxis::Horizontal => 0,
688            SplitAxis::Vertical => 1,
689        };
690        let key = (distance, handle.target.split_id.get(), axis_rank);
691        if best.as_ref().is_none_or(|(best_key, _)| key < *best_key) {
692            best = Some((key, handle.target));
693        }
694    }
695
696    best.map(|(_, target)| target)
697}
698
699/// Register pane splitter handles into the frame hit-grid.
700///
701/// Each handle is registered as `HitRegion::Handle` with encoded target data.
702/// Returns number of successfully-registered regions.
703pub fn register_pane_terminal_splitter_hits(
704    frame: &mut Frame,
705    handles: &[PaneTerminalSplitterHandle],
706    hit_id_base: u32,
707) -> usize {
708    let mut registered = 0usize;
709    for (idx, handle) in handles.iter().enumerate() {
710        let Ok(offset) = u32::try_from(idx) else {
711            break;
712        };
713        let hit_id = HitId::new(hit_id_base.saturating_add(offset));
714        if frame.register_hit(
715            handle.rect,
716            hit_id,
717            HitRegion::Handle,
718            encode_pane_resize_target(handle.target),
719        ) {
720            registered = registered.saturating_add(1);
721        }
722    }
723    registered
724}
725
726/// Decode pane resize target from a hit-grid tuple produced by pane handle registration.
727#[must_use]
728pub fn pane_terminal_target_from_hit(hit: (HitId, HitRegion, HitData)) -> Option<PaneResizeTarget> {
729    let (_, region, data) = hit;
730    if region != HitRegion::Handle {
731        return None;
732    }
733    decode_pane_resize_target(data)
734}
735
736fn splitter_hit_rect(
737    axis: SplitAxis,
738    split_rect: Rect,
739    boundary: u16,
740    thickness: u16,
741) -> Option<Rect> {
742    let half = thickness.saturating_sub(1) / 2;
743    match axis {
744        SplitAxis::Horizontal => {
745            let start = boundary.saturating_sub(half).max(split_rect.x);
746            let end = boundary
747                .saturating_add(thickness.saturating_sub(half))
748                .min(split_rect.right());
749            let width = end.saturating_sub(start);
750            (width > 0 && split_rect.height > 0).then_some(Rect::new(
751                start,
752                split_rect.y,
753                width,
754                split_rect.height,
755            ))
756        }
757        SplitAxis::Vertical => {
758            let start = boundary.saturating_sub(half).max(split_rect.y);
759            let end = boundary
760                .saturating_add(thickness.saturating_sub(half))
761                .min(split_rect.bottom());
762            let height = end.saturating_sub(start);
763            (height > 0 && split_rect.width > 0).then_some(Rect::new(
764                split_rect.x,
765                start,
766                split_rect.width,
767                height,
768            ))
769        }
770    }
771}
772
773fn rect_contains_cell(rect: Rect, x: u16, y: u16) -> bool {
774    x >= rect.x && x < rect.right() && y >= rect.y && y < rect.bottom()
775}
776
777fn encode_pane_resize_target(target: PaneResizeTarget) -> HitData {
778    let axis = match target.axis {
779        SplitAxis::Horizontal => 0_u64,
780        SplitAxis::Vertical => PANE_TERMINAL_TARGET_AXIS_MASK,
781    };
782    (target.split_id.get() << 1) | axis
783}
784
785fn decode_pane_resize_target(data: HitData) -> Option<PaneResizeTarget> {
786    let axis = if data & PANE_TERMINAL_TARGET_AXIS_MASK == 0 {
787        SplitAxis::Horizontal
788    } else {
789        SplitAxis::Vertical
790    };
791    let split_id = ftui_layout::PaneId::new(data >> 1).ok()?;
792    Some(PaneResizeTarget { split_id, axis })
793}
794
795// ============================================================================
796// Pane capability matrix for multiplexer / terminal compat (bd-6u66i)
797// ============================================================================
798
799/// Which multiplexer environment the terminal is running inside.
800#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
801pub enum PaneMuxEnvironment {
802    /// No multiplexer detected — direct terminal access.
803    None,
804    /// tmux (TMUX env var set, or DA2 terminal type 84).
805    Tmux,
806    /// GNU Screen (STY env var set, or DA2 terminal type 83).
807    Screen,
808    /// Zellij (ZELLIJ env var set).
809    Zellij,
810    /// WezTerm mux-served pane/session.
811    WeztermMux,
812}
813
814/// Resolved capability matrix describing which pane interaction features
815/// are available in the current terminal + multiplexer environment.
816///
817/// Derived from [`TerminalCapabilities`] via [`PaneCapabilityMatrix::from_capabilities`].
818/// The adapter uses this to decide which code-paths are safe and which
819/// need deterministic fallbacks.
820#[derive(Debug, Clone, Copy, PartialEq, Eq)]
821pub struct PaneCapabilityMatrix {
822    /// Detected multiplexer environment.
823    pub mux: PaneMuxEnvironment,
824
825    // --- Mouse input capabilities ---
826    /// SGR (1006) extended mouse protocol available.
827    /// Without this, mouse coordinates are limited to 223 columns/rows.
828    pub mouse_sgr: bool,
829    /// Mouse drag events are reliably delivered.
830    /// False in some screen versions where drag tracking is incomplete.
831    pub mouse_drag_reliable: bool,
832    /// Mouse button events include correct button identity on release.
833    /// X10/normal mode sends button 3 for all releases; SGR preserves it.
834    pub mouse_button_discrimination: bool,
835
836    // --- Focus / lifecycle ---
837    /// Terminal delivers CSI I / CSI O focus events.
838    pub focus_events: bool,
839    /// Bracketed paste mode available (affects interaction cancel heuristics).
840    pub bracketed_paste: bool,
841
842    // --- Rendering affordances ---
843    /// Unicode box-drawing glyphs available for splitter rendering.
844    pub unicode_box_drawing: bool,
845    /// True-color support for splitter highlight/drag feedback.
846    pub true_color: bool,
847
848    // --- Fallback summary ---
849    /// One or more pane features are degraded due to environment constraints.
850    pub degraded: bool,
851}
852
853/// Human-readable description of a known limitation and its fallback.
854#[derive(Debug, Clone, PartialEq, Eq)]
855pub struct PaneCapabilityLimitation {
856    /// Short identifier (e.g. `"mouse_drag_unreliable"`).
857    pub id: &'static str,
858    /// What the limitation is.
859    pub description: &'static str,
860    /// What the adapter does instead.
861    pub fallback: &'static str,
862}
863
864impl PaneCapabilityMatrix {
865    /// Derive the pane capability matrix from terminal capabilities.
866    ///
867    /// This is the single source of truth for which pane features are
868    /// available. All fallback decisions flow from this matrix.
869    #[must_use]
870    pub fn from_capabilities(
871        caps: &ftui_core::terminal_capabilities::TerminalCapabilities,
872    ) -> Self {
873        let mux = if caps.in_tmux {
874            PaneMuxEnvironment::Tmux
875        } else if caps.in_screen {
876            PaneMuxEnvironment::Screen
877        } else if caps.in_zellij {
878            PaneMuxEnvironment::Zellij
879        } else if caps.in_wezterm_mux {
880            PaneMuxEnvironment::WeztermMux
881        } else {
882            PaneMuxEnvironment::None
883        };
884
885        let mouse_sgr = caps.mouse_sgr;
886
887        // GNU Screen has historically unreliable drag event delivery.
888        // tmux and zellij forward drags correctly in modern versions.
889        let mouse_drag_reliable = !matches!(mux, PaneMuxEnvironment::Screen);
890
891        // Button discrimination requires SGR mouse protocol.
892        // Without it, X10/normal mode reports button 3 for all releases.
893        let mouse_button_discrimination = mouse_sgr;
894
895        // Focus events are conservatively disabled in any mux context.
896        let focus_events = caps.focus_events && !caps.in_any_mux();
897
898        let bracketed_paste = caps.bracketed_paste;
899        let unicode_box_drawing = caps.unicode_box_drawing;
900        let true_color = caps.true_color;
901
902        let degraded =
903            !mouse_sgr || !mouse_drag_reliable || !mouse_button_discrimination || !focus_events;
904
905        Self {
906            mux,
907            mouse_sgr,
908            mouse_drag_reliable,
909            mouse_button_discrimination,
910            focus_events,
911            bracketed_paste,
912            unicode_box_drawing,
913            true_color,
914            degraded,
915        }
916    }
917
918    /// Whether pane drag interactions should be enabled at all.
919    ///
920    /// Drag requires at minimum mouse event support. If drag events
921    /// are unreliable (e.g. GNU Screen), drag is disabled and the
922    /// adapter falls back to keyboard-only resize.
923    #[must_use]
924    pub const fn drag_enabled(&self) -> bool {
925        self.mouse_drag_reliable
926    }
927
928    /// Whether focus-loss auto-cancel is effective.
929    ///
930    /// When focus events are unavailable, the adapter cannot detect
931    /// window blur — interactions must rely on timeout or explicit
932    /// keyboard cancel instead.
933    #[must_use]
934    pub const fn focus_cancel_effective(&self) -> bool {
935        self.focus_events
936    }
937
938    /// Collect all active limitations with their fallback descriptions.
939    #[must_use]
940    pub fn limitations(&self) -> Vec<PaneCapabilityLimitation> {
941        let mut out = Vec::new();
942
943        if !self.mouse_sgr {
944            out.push(PaneCapabilityLimitation {
945                id: "no_sgr_mouse",
946                description: "SGR mouse protocol not available; coordinates limited to 223 columns/rows",
947                fallback: "Pane splitters beyond column 223 are unreachable by mouse; use keyboard resize",
948            });
949        }
950
951        if !self.mouse_drag_reliable {
952            out.push(PaneCapabilityLimitation {
953                id: "mouse_drag_unreliable",
954                description: "Mouse drag events are unreliably delivered (e.g. GNU Screen)",
955                fallback: "Mouse drag disabled; use keyboard arrow keys to resize panes",
956            });
957        }
958
959        if !self.mouse_button_discrimination {
960            out.push(PaneCapabilityLimitation {
961                id: "no_button_discrimination",
962                description: "Mouse release events do not identify which button was released",
963                fallback: "Any mouse release cancels the active drag; multi-button interactions unavailable",
964            });
965        }
966
967        if !self.focus_events {
968            out.push(PaneCapabilityLimitation {
969                id: "no_focus_events",
970                description: "Terminal does not deliver focus-in/focus-out events",
971                fallback: "Focus-loss auto-cancel disabled; use Escape key to cancel active drag",
972            });
973        }
974
975        out
976    }
977}
978
979/// Configuration for terminal-to-pane semantic input translation.
980///
981/// This adapter normalizes terminal `Event` streams into
982/// `PaneSemanticInputEvent` values accepted by `PaneDragResizeMachine`.
983#[derive(Debug, Clone, Copy, PartialEq, Eq)]
984pub struct PaneTerminalAdapterConfig {
985    /// Drag start threshold in pane-local units.
986    pub drag_threshold: u16,
987    /// Drag update hysteresis threshold in pane-local units.
988    pub update_hysteresis: u16,
989    /// Mouse button required to begin a drag sequence.
990    pub activation_button: PanePointerButton,
991    /// Minimum drag delta (Manhattan distance, cells) before forwarding
992    /// updates while already in the dragging state.
993    pub drag_update_coalesce_distance: u16,
994    /// Cancel active interactions on focus loss.
995    pub cancel_on_focus_lost: bool,
996    /// Cancel active interactions on terminal resize.
997    pub cancel_on_resize: bool,
998}
999
1000impl Default for PaneTerminalAdapterConfig {
1001    fn default() -> Self {
1002        Self {
1003            drag_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
1004            update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
1005            activation_button: PanePointerButton::Primary,
1006            drag_update_coalesce_distance: 2,
1007            cancel_on_focus_lost: true,
1008            cancel_on_resize: true,
1009        }
1010    }
1011}
1012
1013#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1014struct PaneTerminalActivePointer {
1015    pointer_id: u32,
1016    target: PaneResizeTarget,
1017    button: PanePointerButton,
1018    last_position: PanePointerPosition,
1019    cumulative_delta_x: i32,
1020    cumulative_delta_y: i32,
1021    direction_changes: u16,
1022    sample_count: u32,
1023    previous_step_delta_x: i32,
1024    previous_step_delta_y: i32,
1025    start_time: Instant,
1026}
1027
1028/// Lifecycle phase observed while translating a terminal event.
1029#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1030pub enum PaneTerminalLifecyclePhase {
1031    MouseDown,
1032    MouseDrag,
1033    MouseMove,
1034    MouseUp,
1035    MouseScroll,
1036    KeyResize,
1037    KeyCancel,
1038    FocusLoss,
1039    ResizeInterrupt,
1040    Other,
1041}
1042
1043/// Deterministic reason a terminal event did not map to pane semantics.
1044#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1045pub enum PaneTerminalIgnoredReason {
1046    MissingTarget,
1047    NoActivePointer,
1048    PointerButtonMismatch,
1049    ActivationButtonRequired,
1050    WindowNotFocused,
1051    UnsupportedKey,
1052    FocusGainNoop,
1053    ResizeNoop,
1054    DragCoalesced,
1055    NonSemanticEvent,
1056    MachineRejectedEvent,
1057}
1058
1059/// Translation outcome for one raw terminal event.
1060#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1061pub enum PaneTerminalLogOutcome {
1062    SemanticForwarded,
1063    SemanticForwardedAfterRecovery,
1064    Ignored(PaneTerminalIgnoredReason),
1065}
1066
1067/// Structured translation log entry for one raw terminal event.
1068#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1069pub struct PaneTerminalLogEntry {
1070    pub phase: PaneTerminalLifecyclePhase,
1071    pub sequence: Option<u64>,
1072    pub pointer_id: Option<u32>,
1073    pub target: Option<PaneResizeTarget>,
1074    pub recovery_cancel_sequence: Option<u64>,
1075    pub outcome: PaneTerminalLogOutcome,
1076}
1077
1078/// Output of one terminal event translation step.
1079///
1080/// `recovery_*` fields are populated when the adapter first emits an internal
1081/// cancel (for stale/missing mouse-up recovery) and then forwards the incoming
1082/// event as a fresh semantic event.
1083#[derive(Debug, Clone, PartialEq)]
1084pub struct PaneTerminalDispatch {
1085    pub primary_event: Option<PaneSemanticInputEvent>,
1086    pub primary_transition: Option<PaneDragResizeTransition>,
1087    pub motion: Option<PaneMotionVector>,
1088    pub inertial_throw: Option<PaneInertialThrow>,
1089    pub projected_position: Option<PanePointerPosition>,
1090    pub recovery_event: Option<PaneSemanticInputEvent>,
1091    pub recovery_transition: Option<PaneDragResizeTransition>,
1092    pub log: PaneTerminalLogEntry,
1093}
1094
1095impl PaneTerminalDispatch {
1096    fn ignored(
1097        phase: PaneTerminalLifecyclePhase,
1098        reason: PaneTerminalIgnoredReason,
1099        pointer_id: Option<u32>,
1100        target: Option<PaneResizeTarget>,
1101    ) -> Self {
1102        Self {
1103            primary_event: None,
1104            primary_transition: None,
1105            motion: None,
1106            inertial_throw: None,
1107            projected_position: None,
1108            recovery_event: None,
1109            recovery_transition: None,
1110            log: PaneTerminalLogEntry {
1111                phase,
1112                sequence: None,
1113                pointer_id,
1114                target,
1115                recovery_cancel_sequence: None,
1116                outcome: PaneTerminalLogOutcome::Ignored(reason),
1117            },
1118        }
1119    }
1120
1121    fn forwarded(
1122        phase: PaneTerminalLifecyclePhase,
1123        pointer_id: Option<u32>,
1124        target: Option<PaneResizeTarget>,
1125        event: PaneSemanticInputEvent,
1126        transition: PaneDragResizeTransition,
1127    ) -> Self {
1128        let sequence = Some(event.sequence);
1129        Self {
1130            primary_event: Some(event),
1131            primary_transition: Some(transition),
1132            motion: None,
1133            inertial_throw: None,
1134            projected_position: None,
1135            recovery_event: None,
1136            recovery_transition: None,
1137            log: PaneTerminalLogEntry {
1138                phase,
1139                sequence,
1140                pointer_id,
1141                target,
1142                recovery_cancel_sequence: None,
1143                outcome: PaneTerminalLogOutcome::SemanticForwarded,
1144            },
1145        }
1146    }
1147
1148    /// Derive dynamic snap profile from translated pointer motion.
1149    #[must_use]
1150    pub fn pressure_snap_profile(&self) -> Option<PanePressureSnapProfile> {
1151        self.motion.map(PanePressureSnapProfile::from_motion)
1152    }
1153}
1154
1155/// Deterministic terminal adapter mapping raw `Event` values into
1156/// schema-validated pane semantic interaction events.
1157#[derive(Debug, Clone)]
1158pub struct PaneTerminalAdapter {
1159    machine: PaneDragResizeMachine,
1160    config: PaneTerminalAdapterConfig,
1161    active: Option<PaneTerminalActivePointer>,
1162    window_focused: bool,
1163    next_sequence: u64,
1164}
1165
1166impl PaneTerminalAdapter {
1167    /// Construct a new adapter with validated drag thresholds.
1168    pub fn new(config: PaneTerminalAdapterConfig) -> Result<Self, PaneDragResizeMachineError> {
1169        let config = PaneTerminalAdapterConfig {
1170            drag_update_coalesce_distance: config.drag_update_coalesce_distance.max(1),
1171            ..config
1172        };
1173        let machine = PaneDragResizeMachine::new_with_hysteresis(
1174            config.drag_threshold,
1175            config.update_hysteresis,
1176        )?;
1177        Ok(Self {
1178            machine,
1179            config,
1180            active: None,
1181            window_focused: true,
1182            next_sequence: 1,
1183        })
1184    }
1185
1186    /// Adapter configuration.
1187    #[must_use]
1188    pub const fn config(&self) -> PaneTerminalAdapterConfig {
1189        self.config
1190    }
1191
1192    /// Active pointer id currently tracked by the adapter, if any.
1193    #[must_use]
1194    pub fn active_pointer_id(&self) -> Option<u32> {
1195        self.active.map(|active| active.pointer_id)
1196    }
1197
1198    /// Whether the host window is currently focused.
1199    #[must_use]
1200    pub const fn window_focused(&self) -> bool {
1201        self.window_focused
1202    }
1203
1204    /// Current pane drag/resize machine state.
1205    #[must_use]
1206    pub const fn machine_state(&self) -> PaneDragResizeState {
1207        self.machine.state()
1208    }
1209
1210    /// Translate one raw terminal event into pane semantic event(s).
1211    ///
1212    /// `target_hint` is provided by host hit-testing (upcoming pane-terminal
1213    /// tasks). Pointer drag/move/up reuse active target continuity once armed.
1214    pub fn translate(
1215        &mut self,
1216        event: &Event,
1217        target_hint: Option<PaneResizeTarget>,
1218    ) -> PaneTerminalDispatch {
1219        match event {
1220            Event::Mouse(mouse) => self.translate_mouse(*mouse, target_hint),
1221            Event::Key(key) => self.translate_key(*key, target_hint),
1222            Event::Focus(focused) => self.translate_focus(*focused),
1223            Event::Resize { .. } => self.translate_resize(),
1224            _ => PaneTerminalDispatch::ignored(
1225                PaneTerminalLifecyclePhase::Other,
1226                PaneTerminalIgnoredReason::NonSemanticEvent,
1227                None,
1228                target_hint,
1229            ),
1230        }
1231    }
1232
1233    /// Translate one raw terminal event while resolving splitter targets from
1234    /// terminal hit regions.
1235    ///
1236    /// This is a convenience wrapper for host code that already has splitter
1237    /// handle regions from [`pane_terminal_splitter_handles`].
1238    pub fn translate_with_handles(
1239        &mut self,
1240        event: &Event,
1241        handles: &[PaneTerminalSplitterHandle],
1242    ) -> PaneTerminalDispatch {
1243        let active_target = self.active.map(|active| active.target);
1244        let target_hint = match event {
1245            Event::Mouse(mouse) => {
1246                let resolved = pane_terminal_resolve_splitter_target(handles, mouse.x, mouse.y);
1247                match mouse.kind {
1248                    MouseEventKind::Down(_)
1249                    | MouseEventKind::ScrollUp
1250                    | MouseEventKind::ScrollDown
1251                    | MouseEventKind::ScrollLeft
1252                    | MouseEventKind::ScrollRight => resolved,
1253                    MouseEventKind::Drag(_) | MouseEventKind::Moved | MouseEventKind::Up(_) => {
1254                        resolved.or(active_target)
1255                    }
1256                }
1257            }
1258            Event::Key(_) => active_target,
1259            _ => None,
1260        };
1261        self.translate(event, target_hint)
1262    }
1263
1264    fn translate_mouse(
1265        &mut self,
1266        mouse: MouseEvent,
1267        target_hint: Option<PaneResizeTarget>,
1268    ) -> PaneTerminalDispatch {
1269        let position = mouse_position(mouse);
1270        let modifiers = pane_modifiers(mouse.modifiers);
1271        match mouse.kind {
1272            MouseEventKind::Down(button) => {
1273                let pane_button = pane_button(button);
1274                if pane_button != self.config.activation_button {
1275                    return PaneTerminalDispatch::ignored(
1276                        PaneTerminalLifecyclePhase::MouseDown,
1277                        PaneTerminalIgnoredReason::ActivationButtonRequired,
1278                        Some(pointer_id_for_button(pane_button)),
1279                        target_hint,
1280                    );
1281                }
1282                let Some(target) = target_hint else {
1283                    return PaneTerminalDispatch::ignored(
1284                        PaneTerminalLifecyclePhase::MouseDown,
1285                        PaneTerminalIgnoredReason::MissingTarget,
1286                        Some(pointer_id_for_button(pane_button)),
1287                        None,
1288                    );
1289                };
1290
1291                let recovery = self.cancel_active_internal(PaneCancelReason::PointerCancel);
1292                let pointer_id = pointer_id_for_button(pane_button);
1293                let kind = PaneSemanticInputEventKind::PointerDown {
1294                    target,
1295                    pointer_id,
1296                    button: pane_button,
1297                    position,
1298                };
1299                let mut dispatch = self.forward_semantic(
1300                    PaneTerminalLifecyclePhase::MouseDown,
1301                    Some(pointer_id),
1302                    Some(target),
1303                    kind,
1304                    modifiers,
1305                );
1306                if dispatch.primary_transition.is_some() {
1307                    self.active = Some(PaneTerminalActivePointer {
1308                        pointer_id,
1309                        target,
1310                        button: pane_button,
1311                        last_position: position,
1312                        cumulative_delta_x: 0,
1313                        cumulative_delta_y: 0,
1314                        direction_changes: 0,
1315                        sample_count: 0,
1316                        previous_step_delta_x: 0,
1317                        previous_step_delta_y: 0,
1318                        start_time: Instant::now(),
1319                    });
1320                }
1321                if let Some((cancel_event, cancel_transition)) = recovery {
1322                    dispatch.recovery_event = Some(cancel_event);
1323                    dispatch.recovery_transition = Some(cancel_transition);
1324                    dispatch.log.recovery_cancel_sequence =
1325                        dispatch.recovery_event.as_ref().map(|event| event.sequence);
1326                    if matches!(
1327                        dispatch.log.outcome,
1328                        PaneTerminalLogOutcome::SemanticForwarded
1329                    ) {
1330                        dispatch.log.outcome =
1331                            PaneTerminalLogOutcome::SemanticForwardedAfterRecovery;
1332                    }
1333                }
1334                dispatch
1335            }
1336            MouseEventKind::Drag(button) => {
1337                let pane_button = pane_button(button);
1338                let Some(active) = self.active else {
1339                    return PaneTerminalDispatch::ignored(
1340                        PaneTerminalLifecyclePhase::MouseDrag,
1341                        PaneTerminalIgnoredReason::NoActivePointer,
1342                        Some(pointer_id_for_button(pane_button)),
1343                        target_hint,
1344                    );
1345                };
1346                if active.button != pane_button {
1347                    return PaneTerminalDispatch::ignored(
1348                        PaneTerminalLifecyclePhase::MouseDrag,
1349                        PaneTerminalIgnoredReason::PointerButtonMismatch,
1350                        Some(pointer_id_for_button(pane_button)),
1351                        Some(active.target),
1352                    );
1353                }
1354                self.apply_pointer_motion(
1355                    active,
1356                    position,
1357                    modifiers,
1358                    PaneTerminalLifecyclePhase::MouseDrag,
1359                )
1360            }
1361            MouseEventKind::Moved => {
1362                let Some(active) = self.active else {
1363                    return PaneTerminalDispatch::ignored(
1364                        PaneTerminalLifecyclePhase::MouseMove,
1365                        PaneTerminalIgnoredReason::NoActivePointer,
1366                        None,
1367                        target_hint,
1368                    );
1369                };
1370                self.apply_pointer_motion(
1371                    active,
1372                    position,
1373                    modifiers,
1374                    PaneTerminalLifecyclePhase::MouseMove,
1375                )
1376            }
1377            MouseEventKind::Up(button) => {
1378                let pane_button = pane_button(button);
1379                let Some(active) = self.active else {
1380                    return PaneTerminalDispatch::ignored(
1381                        PaneTerminalLifecyclePhase::MouseUp,
1382                        PaneTerminalIgnoredReason::NoActivePointer,
1383                        Some(pointer_id_for_button(pane_button)),
1384                        target_hint,
1385                    );
1386                };
1387                if active.button != pane_button {
1388                    return PaneTerminalDispatch::ignored(
1389                        PaneTerminalLifecyclePhase::MouseUp,
1390                        PaneTerminalIgnoredReason::PointerButtonMismatch,
1391                        Some(pointer_id_for_button(pane_button)),
1392                        Some(active.target),
1393                    );
1394                }
1395                let kind = PaneSemanticInputEventKind::PointerUp {
1396                    target: active.target,
1397                    pointer_id: active.pointer_id,
1398                    button: active.button,
1399                    position,
1400                };
1401                let mut dispatch = self.forward_semantic(
1402                    PaneTerminalLifecyclePhase::MouseUp,
1403                    Some(active.pointer_id),
1404                    Some(active.target),
1405                    kind,
1406                    modifiers,
1407                );
1408                if dispatch.primary_transition.is_some() {
1409                    let duration = active.start_time.elapsed().as_millis() as u32;
1410                    let motion = PaneMotionVector::from_delta(
1411                        active.cumulative_delta_x,
1412                        active.cumulative_delta_y,
1413                        duration,
1414                        active.direction_changes,
1415                    );
1416                    let inertial_throw = PaneInertialThrow::from_motion(motion);
1417                    dispatch.motion = Some(motion);
1418                    dispatch.projected_position = Some(inertial_throw.projected_pointer(position));
1419                    dispatch.inertial_throw = Some(inertial_throw);
1420                    self.active = None;
1421                }
1422                dispatch
1423            }
1424            MouseEventKind::ScrollUp
1425            | MouseEventKind::ScrollDown
1426            | MouseEventKind::ScrollLeft
1427            | MouseEventKind::ScrollRight => {
1428                let target = target_hint.or(self.active.map(|active| active.target));
1429                let Some(target) = target else {
1430                    return PaneTerminalDispatch::ignored(
1431                        PaneTerminalLifecyclePhase::MouseScroll,
1432                        PaneTerminalIgnoredReason::MissingTarget,
1433                        None,
1434                        None,
1435                    );
1436                };
1437                let lines = match mouse.kind {
1438                    MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => -1,
1439                    MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => 1,
1440                    _ => unreachable!("handled by outer match"),
1441                };
1442                let kind = PaneSemanticInputEventKind::WheelNudge { target, lines };
1443                self.forward_semantic(
1444                    PaneTerminalLifecyclePhase::MouseScroll,
1445                    None,
1446                    Some(target),
1447                    kind,
1448                    modifiers,
1449                )
1450            }
1451        }
1452    }
1453
1454    /// Shared motion bookkeeping for the `Drag` and `Moved` mouse arms.
1455    ///
1456    /// Once each arm's arm-specific guards pass (`Drag` additionally checks the
1457    /// pressed button), both perform byte-identical work: compute the step
1458    /// delta, honor drag coalescing, fold direction-change and cumulative-motion
1459    /// tracking into `active`, forward a `PointerMove`, and — only on a committed
1460    /// transition — advance `last_position` and attach the motion vector. The
1461    /// sole caller-specific input is `phase` (`MouseDrag` vs `MouseMove`), used
1462    /// for both the coalesced-ignore and the forwarded event so each caller's
1463    /// diagnostics are unchanged. This is a pure extraction of the two
1464    /// previously-duplicated arm bodies — event ordering and semantics are
1465    /// identical, and the single shared path is cheaper to audit and roll back.
1466    fn apply_pointer_motion(
1467        &mut self,
1468        mut active: PaneTerminalActivePointer,
1469        position: PanePointerPosition,
1470        modifiers: PaneModifierSnapshot,
1471        phase: PaneTerminalLifecyclePhase,
1472    ) -> PaneTerminalDispatch {
1473        let delta_x = position.x.saturating_sub(active.last_position.x);
1474        let delta_y = position.y.saturating_sub(active.last_position.y);
1475        if self.should_coalesce_drag(delta_x, delta_y) {
1476            return PaneTerminalDispatch::ignored(
1477                phase,
1478                PaneTerminalIgnoredReason::DragCoalesced,
1479                Some(active.pointer_id),
1480                Some(active.target),
1481            );
1482        }
1483        if active.sample_count > 0 {
1484            let flipped_x = delta_x.signum() != 0
1485                && active.previous_step_delta_x.signum() != 0
1486                && delta_x.signum() != active.previous_step_delta_x.signum();
1487            let flipped_y = delta_y.signum() != 0
1488                && active.previous_step_delta_y.signum() != 0
1489                && delta_y.signum() != active.previous_step_delta_y.signum();
1490            if flipped_x || flipped_y {
1491                active.direction_changes = active.direction_changes.saturating_add(1);
1492            }
1493        }
1494        active.cumulative_delta_x = active.cumulative_delta_x.saturating_add(delta_x);
1495        active.cumulative_delta_y = active.cumulative_delta_y.saturating_add(delta_y);
1496        active.sample_count = active.sample_count.saturating_add(1);
1497        active.previous_step_delta_x = delta_x;
1498        active.previous_step_delta_y = delta_y;
1499        let kind = PaneSemanticInputEventKind::PointerMove {
1500            target: active.target,
1501            pointer_id: active.pointer_id,
1502            position,
1503            delta_x,
1504            delta_y,
1505        };
1506        let mut dispatch = self.forward_semantic(
1507            phase,
1508            Some(active.pointer_id),
1509            Some(active.target),
1510            kind,
1511            modifiers,
1512        );
1513        if dispatch.primary_transition.is_some() {
1514            active.last_position = position;
1515            self.active = Some(active);
1516            let duration = active.start_time.elapsed().as_millis() as u32;
1517            dispatch.motion = Some(PaneMotionVector::from_delta(
1518                active.cumulative_delta_x,
1519                active.cumulative_delta_y,
1520                duration,
1521                active.direction_changes,
1522            ));
1523        }
1524        dispatch
1525    }
1526
1527    fn translate_key(
1528        &mut self,
1529        key: KeyEvent,
1530        target_hint: Option<PaneResizeTarget>,
1531    ) -> PaneTerminalDispatch {
1532        if !self.window_focused {
1533            return PaneTerminalDispatch::ignored(
1534                PaneTerminalLifecyclePhase::KeyResize,
1535                PaneTerminalIgnoredReason::WindowNotFocused,
1536                self.active_pointer_id(),
1537                target_hint.or(self.active.map(|active| active.target)),
1538            );
1539        }
1540        if key.kind == KeyEventKind::Release {
1541            return PaneTerminalDispatch::ignored(
1542                PaneTerminalLifecyclePhase::Other,
1543                PaneTerminalIgnoredReason::UnsupportedKey,
1544                None,
1545                target_hint,
1546            );
1547        }
1548        if matches!(key.code, KeyCode::Escape) {
1549            return self.cancel_active_dispatch(
1550                PaneTerminalLifecyclePhase::KeyCancel,
1551                PaneCancelReason::EscapeKey,
1552                PaneTerminalIgnoredReason::NoActivePointer,
1553            );
1554        }
1555        let target = target_hint.or(self.active.map(|active| active.target));
1556        let Some(target) = target else {
1557            return PaneTerminalDispatch::ignored(
1558                PaneTerminalLifecyclePhase::KeyResize,
1559                PaneTerminalIgnoredReason::MissingTarget,
1560                None,
1561                None,
1562            );
1563        };
1564        let Some(direction) = keyboard_resize_direction(key.code, target.axis) else {
1565            return PaneTerminalDispatch::ignored(
1566                PaneTerminalLifecyclePhase::KeyResize,
1567                PaneTerminalIgnoredReason::UnsupportedKey,
1568                None,
1569                Some(target),
1570            );
1571        };
1572        let units = keyboard_resize_units(key.modifiers);
1573        let kind = PaneSemanticInputEventKind::KeyboardResize {
1574            target,
1575            direction,
1576            units,
1577        };
1578        self.forward_semantic(
1579            PaneTerminalLifecyclePhase::KeyResize,
1580            self.active_pointer_id(),
1581            Some(target),
1582            kind,
1583            pane_modifiers(key.modifiers),
1584        )
1585    }
1586
1587    fn translate_focus(&mut self, focused: bool) -> PaneTerminalDispatch {
1588        if focused {
1589            self.window_focused = true;
1590            return PaneTerminalDispatch::ignored(
1591                PaneTerminalLifecyclePhase::Other,
1592                PaneTerminalIgnoredReason::FocusGainNoop,
1593                self.active_pointer_id(),
1594                self.active.map(|active| active.target),
1595            );
1596        }
1597        self.window_focused = false;
1598        if !self.config.cancel_on_focus_lost {
1599            return PaneTerminalDispatch::ignored(
1600                PaneTerminalLifecyclePhase::FocusLoss,
1601                PaneTerminalIgnoredReason::ResizeNoop,
1602                self.active_pointer_id(),
1603                self.active.map(|active| active.target),
1604            );
1605        }
1606        self.cancel_active_dispatch(
1607            PaneTerminalLifecyclePhase::FocusLoss,
1608            PaneCancelReason::FocusLost,
1609            PaneTerminalIgnoredReason::NoActivePointer,
1610        )
1611    }
1612
1613    fn translate_resize(&mut self) -> PaneTerminalDispatch {
1614        if !self.config.cancel_on_resize {
1615            return PaneTerminalDispatch::ignored(
1616                PaneTerminalLifecyclePhase::ResizeInterrupt,
1617                PaneTerminalIgnoredReason::ResizeNoop,
1618                self.active_pointer_id(),
1619                self.active.map(|active| active.target),
1620            );
1621        }
1622        self.cancel_active_dispatch(
1623            PaneTerminalLifecyclePhase::ResizeInterrupt,
1624            PaneCancelReason::Programmatic,
1625            PaneTerminalIgnoredReason::ResizeNoop,
1626        )
1627    }
1628
1629    fn cancel_active_dispatch(
1630        &mut self,
1631        phase: PaneTerminalLifecyclePhase,
1632        reason: PaneCancelReason,
1633        no_active_reason: PaneTerminalIgnoredReason,
1634    ) -> PaneTerminalDispatch {
1635        let Some(active) = self.active else {
1636            return PaneTerminalDispatch::ignored(phase, no_active_reason, None, None);
1637        };
1638        let kind = PaneSemanticInputEventKind::Cancel {
1639            target: Some(active.target),
1640            reason,
1641        };
1642        let dispatch = self.forward_semantic(
1643            phase,
1644            Some(active.pointer_id),
1645            Some(active.target),
1646            kind,
1647            PaneModifierSnapshot::default(),
1648        );
1649        if dispatch.primary_transition.is_some() {
1650            self.active = None;
1651        }
1652        dispatch
1653    }
1654
1655    fn cancel_active_internal(
1656        &mut self,
1657        reason: PaneCancelReason,
1658    ) -> Option<(PaneSemanticInputEvent, PaneDragResizeTransition)> {
1659        let active = self.active?;
1660        let kind = PaneSemanticInputEventKind::Cancel {
1661            target: Some(active.target),
1662            reason,
1663        };
1664        let result = self
1665            .apply_semantic(kind, PaneModifierSnapshot::default())
1666            .ok();
1667        if result.is_some() {
1668            self.active = None;
1669        }
1670        result
1671    }
1672
1673    fn forward_semantic(
1674        &mut self,
1675        phase: PaneTerminalLifecyclePhase,
1676        pointer_id: Option<u32>,
1677        target: Option<PaneResizeTarget>,
1678        kind: PaneSemanticInputEventKind,
1679        modifiers: PaneModifierSnapshot,
1680    ) -> PaneTerminalDispatch {
1681        match self.apply_semantic(kind, modifiers) {
1682            Ok((event, transition)) => {
1683                PaneTerminalDispatch::forwarded(phase, pointer_id, target, event, transition)
1684            }
1685            Err(_) => PaneTerminalDispatch::ignored(
1686                phase,
1687                PaneTerminalIgnoredReason::MachineRejectedEvent,
1688                pointer_id,
1689                target,
1690            ),
1691        }
1692    }
1693
1694    fn apply_semantic(
1695        &mut self,
1696        kind: PaneSemanticInputEventKind,
1697        modifiers: PaneModifierSnapshot,
1698    ) -> Result<(PaneSemanticInputEvent, PaneDragResizeTransition), PaneDragResizeMachineError>
1699    {
1700        let mut event = PaneSemanticInputEvent::new(self.next_sequence(), kind);
1701        event.modifiers = modifiers;
1702        let transition = self.machine.apply_event(&event)?;
1703        Ok((event, transition))
1704    }
1705
1706    fn next_sequence(&mut self) -> u64 {
1707        let sequence = self.next_sequence;
1708        self.next_sequence = self.next_sequence.saturating_add(1);
1709        sequence
1710    }
1711
1712    fn should_coalesce_drag(&self, delta_x: i32, delta_y: i32) -> bool {
1713        if !matches!(self.machine.state(), PaneDragResizeState::Dragging { .. }) {
1714            return false;
1715        }
1716        let movement = delta_x
1717            .unsigned_abs()
1718            .saturating_add(delta_y.unsigned_abs());
1719        movement < u32::from(self.config.drag_update_coalesce_distance)
1720    }
1721
1722    /// Force-cancel any active pane interaction and return diagnostic info.
1723    ///
1724    /// This is the safety-valve for cleanup paths (RAII guard drops, signal
1725    /// handlers, panic hooks) where constructing a proper semantic event is
1726    /// not feasible. It resets both the underlying drag/resize state machine
1727    /// and the adapter's active-pointer tracking.
1728    ///
1729    /// Returns `None` if no interaction was active.
1730    pub fn force_cancel_all(&mut self) -> Option<PaneCleanupDiagnostics> {
1731        let was_active = self.active.is_some();
1732        let machine_state_before = self.machine.state();
1733        let machine_transition = self.machine.force_cancel();
1734        let active_pointer = self.active.take();
1735        if !was_active && machine_transition.is_none() {
1736            return None;
1737        }
1738        Some(PaneCleanupDiagnostics {
1739            had_active_pointer: was_active,
1740            active_pointer_id: active_pointer.map(|a| a.pointer_id),
1741            machine_state_before,
1742            machine_transition,
1743        })
1744    }
1745}
1746
1747/// Structured diagnostics emitted when pane interaction state is force-cleaned.
1748///
1749/// Fields mirror the pane layout types which are already `Serialize`/`Deserialize`,
1750/// so callers can convert this struct to JSON for evidence logging.
1751#[derive(Debug, Clone, PartialEq, Eq)]
1752pub struct PaneCleanupDiagnostics {
1753    /// Whether the adapter had an active pointer tracker when cleanup ran.
1754    pub had_active_pointer: bool,
1755    /// The pointer ID that was active (if any).
1756    pub active_pointer_id: Option<u32>,
1757    /// The machine state before force-cancel was applied.
1758    pub machine_state_before: PaneDragResizeState,
1759    /// The transition produced by force-cancel, or `None` if the machine
1760    /// was already idle.
1761    pub machine_transition: Option<PaneDragResizeTransition>,
1762}
1763
1764/// RAII guard that ensures pane interaction state is cleanly canceled on drop.
1765///
1766/// When a pane interaction session is active and the guard drops (due to
1767/// panic, scope exit, or any other unwind), it force-cancels any in-progress
1768/// drag/resize and collects cleanup diagnostics.
1769///
1770/// # Usage
1771///
1772/// ```ignore
1773/// let guard = PaneInteractionGuard::new(&mut adapter);
1774/// // ... pane interaction event loop ...
1775/// // If this scope panics, guard's Drop will force-cancel the drag machine
1776/// let diagnostics = guard.finish(); // explicit clean finish
1777/// ```
1778pub struct PaneInteractionGuard<'a> {
1779    adapter: &'a mut PaneTerminalAdapter,
1780    finished: bool,
1781    diagnostics: Option<PaneCleanupDiagnostics>,
1782}
1783
1784impl<'a> PaneInteractionGuard<'a> {
1785    /// Create a new guard wrapping the given adapter.
1786    pub fn new(adapter: &'a mut PaneTerminalAdapter) -> Self {
1787        Self {
1788            adapter,
1789            finished: false,
1790            diagnostics: None,
1791        }
1792    }
1793
1794    /// Access the wrapped adapter for normal event translation.
1795    pub fn adapter(&mut self) -> &mut PaneTerminalAdapter {
1796        self.adapter
1797    }
1798
1799    /// Explicitly finish the guard, returning any cleanup diagnostics.
1800    ///
1801    /// Calling `finish()` is optional — the guard will also clean up on drop.
1802    /// However, `finish()` gives the caller access to the diagnostics.
1803    pub fn finish(mut self) -> Option<PaneCleanupDiagnostics> {
1804        self.finished = true;
1805        let diagnostics = self.adapter.force_cancel_all();
1806        self.diagnostics = diagnostics.clone();
1807        diagnostics
1808    }
1809}
1810
1811impl Drop for PaneInteractionGuard<'_> {
1812    fn drop(&mut self) {
1813        if !self.finished {
1814            self.diagnostics = self.adapter.force_cancel_all();
1815        }
1816    }
1817}
1818
1819fn pane_button(button: MouseButton) -> PanePointerButton {
1820    match button {
1821        MouseButton::Left => PanePointerButton::Primary,
1822        MouseButton::Right => PanePointerButton::Secondary,
1823        MouseButton::Middle => PanePointerButton::Middle,
1824    }
1825}
1826
1827fn pointer_id_for_button(button: PanePointerButton) -> u32 {
1828    match button {
1829        PanePointerButton::Primary => 1,
1830        PanePointerButton::Secondary => 2,
1831        PanePointerButton::Middle => 3,
1832    }
1833}
1834
1835fn mouse_position(mouse: MouseEvent) -> PanePointerPosition {
1836    PanePointerPosition::new(i32::from(mouse.x), i32::from(mouse.y))
1837}
1838
1839fn pane_modifiers(modifiers: Modifiers) -> PaneModifierSnapshot {
1840    PaneModifierSnapshot {
1841        shift: modifiers.contains(Modifiers::SHIFT),
1842        alt: modifiers.contains(Modifiers::ALT),
1843        ctrl: modifiers.contains(Modifiers::CTRL),
1844        meta: modifiers.contains(Modifiers::SUPER),
1845    }
1846}
1847
1848fn keyboard_resize_direction(code: KeyCode, axis: SplitAxis) -> Option<PaneResizeDirection> {
1849    match (axis, code) {
1850        (SplitAxis::Horizontal, KeyCode::Left) => Some(PaneResizeDirection::Decrease),
1851        (SplitAxis::Horizontal, KeyCode::Right) => Some(PaneResizeDirection::Increase),
1852        (SplitAxis::Vertical, KeyCode::Up) => Some(PaneResizeDirection::Decrease),
1853        (SplitAxis::Vertical, KeyCode::Down) => Some(PaneResizeDirection::Increase),
1854        (_, KeyCode::Char('-')) => Some(PaneResizeDirection::Decrease),
1855        (_, KeyCode::Char('+') | KeyCode::Char('=')) => Some(PaneResizeDirection::Increase),
1856        _ => None,
1857    }
1858}
1859
1860fn keyboard_resize_units(modifiers: Modifiers) -> u16 {
1861    if modifiers.contains(Modifiers::SHIFT) {
1862        5
1863    } else {
1864        1
1865    }
1866}
1867
1868/// Configuration for state persistence in the program runtime.
1869///
1870/// Controls when and how widget state is saved/restored.
1871#[derive(Clone)]
1872pub struct PersistenceConfig {
1873    /// State registry for persistence. If None, persistence is disabled.
1874    pub registry: Option<std::sync::Arc<StateRegistry>>,
1875    /// Interval for periodic checkpoint saves. None disables checkpoints.
1876    pub checkpoint_interval: Option<Duration>,
1877    /// Automatically load state on program start.
1878    pub auto_load: bool,
1879    /// Automatically save state on program exit.
1880    pub auto_save: bool,
1881}
1882
1883impl Default for PersistenceConfig {
1884    fn default() -> Self {
1885        Self {
1886            registry: None,
1887            checkpoint_interval: None,
1888            auto_load: true,
1889            auto_save: true,
1890        }
1891    }
1892}
1893
1894impl std::fmt::Debug for PersistenceConfig {
1895    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1896        f.debug_struct("PersistenceConfig")
1897            .field(
1898                "registry",
1899                &self.registry.as_ref().map(|r| r.backend_name()),
1900            )
1901            .field("checkpoint_interval", &self.checkpoint_interval)
1902            .field("auto_load", &self.auto_load)
1903            .field("auto_save", &self.auto_save)
1904            .finish()
1905    }
1906}
1907
1908impl PersistenceConfig {
1909    /// Create a disabled persistence config.
1910    #[must_use]
1911    pub fn disabled() -> Self {
1912        Self::default()
1913    }
1914
1915    /// Create a persistence config with the given registry.
1916    #[must_use]
1917    pub fn with_registry(registry: std::sync::Arc<StateRegistry>) -> Self {
1918        Self {
1919            registry: Some(registry),
1920            ..Default::default()
1921        }
1922    }
1923
1924    /// Set the checkpoint interval.
1925    #[must_use]
1926    pub fn checkpoint_every(mut self, interval: Duration) -> Self {
1927        self.checkpoint_interval = Some(interval);
1928        self
1929    }
1930
1931    /// Enable or disable auto-load on start.
1932    #[must_use]
1933    pub fn auto_load(mut self, enabled: bool) -> Self {
1934        self.auto_load = enabled;
1935        self
1936    }
1937
1938    /// Enable or disable auto-save on exit.
1939    #[must_use]
1940    pub fn auto_save(mut self, enabled: bool) -> Self {
1941        self.auto_save = enabled;
1942        self
1943    }
1944}
1945
1946/// Configuration for widget refresh selection under render budget.
1947///
1948/// Defaults are conservative and deterministic:
1949/// - enabled: true
1950/// - staleness_window_ms: 1_000
1951/// - starve_ms: 3_000
1952/// - max_starved_per_frame: 2
1953/// - max_drop_fraction: 1.0 (disabled)
1954/// - weights: priority 1.0, staleness 0.5, focus 0.75, interaction 0.5
1955/// - starve_boost: 1.5
1956/// - min_cost_us: 1.0
1957#[derive(Debug, Clone)]
1958pub struct WidgetRefreshConfig {
1959    /// Enable budgeted widget refresh selection.
1960    pub enabled: bool,
1961    /// Staleness decay window (ms) used to normalize staleness scores.
1962    pub staleness_window_ms: u64,
1963    /// Staleness threshold that triggers starvation guard (ms).
1964    pub starve_ms: u64,
1965    /// Maximum number of starved widgets to force in per frame.
1966    pub max_starved_per_frame: usize,
1967    /// Maximum fraction of non-essential widgets that may be dropped.
1968    /// Set to 1.0 to disable the guardrail.
1969    pub max_drop_fraction: f32,
1970    /// Weight for base priority signal.
1971    pub weight_priority: f32,
1972    /// Weight for staleness signal.
1973    pub weight_staleness: f32,
1974    /// Weight for focus boost.
1975    pub weight_focus: f32,
1976    /// Weight for interaction boost.
1977    pub weight_interaction: f32,
1978    /// Additive boost to value for starved widgets.
1979    pub starve_boost: f32,
1980    /// Minimum cost (us) to avoid divide-by-zero.
1981    pub min_cost_us: f32,
1982}
1983
1984impl Default for WidgetRefreshConfig {
1985    fn default() -> Self {
1986        Self {
1987            enabled: true,
1988            staleness_window_ms: 1_000,
1989            starve_ms: 3_000,
1990            max_starved_per_frame: 2,
1991            max_drop_fraction: 1.0,
1992            weight_priority: 1.0,
1993            weight_staleness: 0.5,
1994            weight_focus: 0.75,
1995            weight_interaction: 0.5,
1996            starve_boost: 1.5,
1997            min_cost_us: 1.0,
1998        }
1999    }
2000}
2001
2002/// Configuration for effect queue scheduling.
2003#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2004pub enum TaskExecutorBackend {
2005    /// Spawn one native thread per task and reap finished handles on the main loop.
2006    #[default]
2007    Spawned,
2008    /// Route tasks through the runtime's queueing scheduler.
2009    EffectQueue,
2010    /// Route blocking task closures through an Asupersync blocking pool.
2011    #[cfg(feature = "asupersync-executor")]
2012    Asupersync,
2013}
2014
2015#[derive(Debug, Clone)]
2016pub struct EffectQueueConfig {
2017    /// Whether effect queue scheduling is enabled.
2018    ///
2019    /// This legacy convenience flag is kept in sync with `backend`. New code
2020    /// should prefer `backend` for executor selection.
2021    pub enabled: bool,
2022    /// Which task executor backend to use for `Cmd::Task`.
2023    pub backend: TaskExecutorBackend,
2024    /// Scheduler configuration (Smith's rule by default).
2025    pub scheduler: SchedulerConfig,
2026    /// Maximum queue depth before backpressure kicks in (bd-2zd0a).
2027    ///
2028    /// When the queue depth exceeds this limit, new tasks are dropped with
2029    /// a `tracing::warn!` and the `effects_queue_dropped` counter increments.
2030    /// A value of `0` means unbounded (no backpressure).
2031    pub max_queue_depth: usize,
2032    /// Whether the backend selection was set explicitly by the caller.
2033    explicit_backend: bool,
2034}
2035
2036impl Default for EffectQueueConfig {
2037    fn default() -> Self {
2038        let scheduler = SchedulerConfig {
2039            smith_enabled: true,
2040            force_fifo: false,
2041            preemptive: false,
2042            aging_factor: 0.0,
2043            wait_starve_ms: 0.0,
2044            enable_logging: false,
2045            ..Default::default()
2046        };
2047        Self {
2048            enabled: false,
2049            backend: TaskExecutorBackend::Spawned,
2050            scheduler,
2051            max_queue_depth: 0,
2052            explicit_backend: false,
2053        }
2054    }
2055}
2056
2057impl EffectQueueConfig {
2058    /// Enable effect queue scheduling with the provided scheduler config.
2059    #[must_use]
2060    pub fn with_enabled(mut self, enabled: bool) -> Self {
2061        self.enabled = enabled;
2062        self.backend = if enabled {
2063            TaskExecutorBackend::EffectQueue
2064        } else {
2065            TaskExecutorBackend::Spawned
2066        };
2067        self.explicit_backend = true;
2068        self
2069    }
2070
2071    /// Select the task executor backend for `Cmd::Task`.
2072    #[must_use]
2073    pub fn with_backend(mut self, backend: TaskExecutorBackend) -> Self {
2074        self.enabled = matches!(backend, TaskExecutorBackend::EffectQueue);
2075        self.backend = backend;
2076        self.explicit_backend = true;
2077        self
2078    }
2079
2080    /// Override the scheduler configuration.
2081    #[must_use]
2082    pub fn with_scheduler(mut self, scheduler: SchedulerConfig) -> Self {
2083        self.scheduler = scheduler;
2084        self
2085    }
2086
2087    /// Set the maximum queue depth for backpressure (bd-2zd0a).
2088    ///
2089    /// When the queue depth exceeds this limit, new tasks are dropped.
2090    /// A value of `0` means unbounded (no backpressure, the default).
2091    #[must_use]
2092    pub fn with_max_queue_depth(mut self, depth: usize) -> Self {
2093        self.max_queue_depth = depth;
2094        self
2095    }
2096
2097    #[must_use]
2098    fn uses_legacy_default_backend(&self) -> bool {
2099        !self.explicit_backend && !self.enabled && self.backend == TaskExecutorBackend::Spawned
2100    }
2101}
2102
2103/// Immediate event-drain policy for the runtime main loop.
2104///
2105/// When a poll reports readiness, the runtime drains events by repeatedly
2106/// checking `poll_event(Duration::ZERO)` to avoid latency between buffered
2107/// inputs. This policy bounds that immediate-drain path so bursty workloads do
2108/// not devolve into zero-timeout spin storms.
2109#[derive(Debug, Clone)]
2110pub struct ImmediateDrainConfig {
2111    /// Maximum consecutive zero-timeout polls allowed in a single burst window.
2112    pub max_zero_timeout_polls_per_burst: usize,
2113    /// Maximum wall-clock time spent in a single immediate-drain burst window.
2114    pub max_burst_duration: Duration,
2115    /// Non-zero poll timeout used when the burst window budget is exhausted.
2116    pub backoff_timeout: Duration,
2117}
2118
2119impl Default for ImmediateDrainConfig {
2120    fn default() -> Self {
2121        Self {
2122            max_zero_timeout_polls_per_burst: 64,
2123            max_burst_duration: Duration::from_millis(2),
2124            backoff_timeout: Duration::from_millis(1),
2125        }
2126    }
2127}
2128
2129/// Runtime counters for immediate-drain behavior.
2130#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2131pub struct ImmediateDrainStats {
2132    /// Number of event-drain bursts observed.
2133    pub bursts: u64,
2134    /// Total zero-timeout polls executed (`poll_event(Duration::ZERO)`).
2135    pub zero_timeout_polls: u64,
2136    /// Total non-zero backoff polls executed after exhausting burst budget.
2137    pub backoff_polls: u64,
2138    /// Number of bursts that hit the configured immediate-drain cap.
2139    pub capped_bursts: u64,
2140    /// Max number of zero-timeout polls seen in a single burst window.
2141    pub max_zero_timeout_polls_in_burst: u64,
2142}
2143
2144/// User-visible runtime mode reported by the conservative load governor.
2145///
2146/// These modes mirror the `bd-8vstx` runtime contract:
2147///
2148/// - `Healthy`: admit all work normally; no explicit fallback is active.
2149/// - `Stressed`: strict work is preserved while visible/coalescible or
2150///   background work may be slowed before user-visible ambiguity appears.
2151/// - `Degraded`: bounded fallback is active; strict interactive semantics
2152///   still hold while background/best-effort work is deferred or dropped.
2153/// - `Recovered`: a degraded interval has closed after the configured
2154///   hysteresis window; the next steady interval returns to `Healthy`.
2155/// - `Unsafe`: a strict semantic guarantee cannot be preserved, so optimistic
2156///   degradation must stop and the caller must surface explicit failure.
2157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2158pub enum RuntimeLoadMode {
2159    /// No sustained pressure and no active fallback.
2160    Healthy,
2161    /// Elevated pressure while strict guarantees still hold.
2162    Stressed,
2163    /// Explicit bounded fallback is active.
2164    Degraded,
2165    /// Recovery interval has been observed and reported.
2166    Recovered,
2167    /// A strict semantic guarantee was violated.
2168    Unsafe,
2169}
2170
2171impl RuntimeLoadMode {
2172    /// Stable string for evidence logs.
2173    #[inline]
2174    #[must_use]
2175    pub const fn as_str(self) -> &'static str {
2176        match self {
2177            Self::Healthy => "healthy",
2178            Self::Stressed => "stressed",
2179            Self::Degraded => "degraded",
2180            Self::Recovered => "recovered",
2181            Self::Unsafe => "unsafe",
2182        }
2183    }
2184}
2185
2186/// Pressure envelope inferred from measured runtime signals.
2187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2188pub enum RuntimePressureClass {
2189    /// Within steady-state envelope.
2190    SteadyState,
2191    /// Early overload band; bounded coalescing/deferment may begin.
2192    SoftOverload,
2193    /// Degraded band; explicit fallback is active.
2194    HardOverload,
2195    /// Terminal strict-semantics failure.
2196    Unsafe,
2197}
2198
2199impl RuntimePressureClass {
2200    /// Stable string for evidence logs.
2201    #[inline]
2202    #[must_use]
2203    pub const fn as_str(self) -> &'static str {
2204        match self {
2205            Self::SteadyState => "steady_state",
2206            Self::SoftOverload => "soft_overload",
2207            Self::HardOverload => "hard_overload",
2208            Self::Unsafe => "unsafe",
2209        }
2210    }
2211}
2212
2213/// Work disposition allowed by the current runtime mode.
2214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2215pub enum RuntimeWorkDisposition {
2216    /// Admit all work normally.
2217    AdmitAll,
2218    /// Preserve strict work; coalesce visible work and slow background work.
2219    CoalesceVisibleDeferBackground,
2220    /// Preserve strict work; defer background work and drop best-effort work.
2221    DeferBackgroundDropBestEffort,
2222    /// Re-admit deferred work after the hysteresis window closed.
2223    ReadmitAfterHysteresis,
2224    /// Stop optimistic degradation because a strict guarantee failed.
2225    FailFastStrictGuarantee,
2226}
2227
2228impl RuntimeWorkDisposition {
2229    /// Stable string for evidence logs.
2230    #[inline]
2231    #[must_use]
2232    pub const fn as_str(self) -> &'static str {
2233        match self {
2234            Self::AdmitAll => "admit_all",
2235            Self::CoalesceVisibleDeferBackground => "coalesce_visible_defer_background",
2236            Self::DeferBackgroundDropBestEffort => "defer_background_drop_best_effort",
2237            Self::ReadmitAfterHysteresis => "readmit_after_hysteresis",
2238            Self::FailFastStrictGuarantee => "fail_fast_strict_guarantee",
2239        }
2240    }
2241}
2242
2243/// Conservative policy for classifying runtime load.
2244///
2245/// The first runtime governor intentionally uses one primary control family:
2246/// render-budget degradation and explicit coalescing/admission evidence. Queue
2247/// watermarks are advisory inputs when a queue cap exists; when the queue is
2248/// uncapped, frame-budget and coalescer signals drive fallback classification.
2249#[derive(Debug, Clone, Copy, PartialEq)]
2250pub struct LoadGovernorPolicy {
2251    /// Queue occupancy ratio that enters `stressed`.
2252    pub stressed_queue_watermark: f64,
2253    /// Queue occupancy ratio that enters `degraded`.
2254    pub degraded_queue_watermark: f64,
2255    /// Queue occupancy ratio required before closing a degraded interval.
2256    pub recovery_queue_watermark: f64,
2257    /// Consecutive steady intervals required before reporting recovery.
2258    pub recovery_intervals: u8,
2259    /// Frame-time ratio over budget that enters `stressed` when uncapped.
2260    pub budget_overrun_soft_ratio: f64,
2261}
2262
2263impl Default for LoadGovernorPolicy {
2264    fn default() -> Self {
2265        Self {
2266            stressed_queue_watermark: 0.5,
2267            degraded_queue_watermark: 0.8,
2268            recovery_queue_watermark: 0.25,
2269            recovery_intervals: 3,
2270            budget_overrun_soft_ratio: 1.0,
2271        }
2272    }
2273}
2274
2275impl LoadGovernorPolicy {
2276    #[must_use]
2277    fn normalized(self) -> Self {
2278        let recovery = normalize_ratio(self.recovery_queue_watermark, 0.25);
2279        let stressed = normalize_ratio(self.stressed_queue_watermark, 0.5).max(recovery);
2280        let degraded = normalize_ratio(self.degraded_queue_watermark, 0.8).max(stressed);
2281        Self {
2282            recovery_queue_watermark: recovery,
2283            stressed_queue_watermark: stressed,
2284            degraded_queue_watermark: degraded,
2285            recovery_intervals: self.recovery_intervals.max(1),
2286            budget_overrun_soft_ratio: normalize_positive_ratio(
2287                self.budget_overrun_soft_ratio,
2288                1.0,
2289            ),
2290        }
2291    }
2292}
2293
2294#[inline]
2295fn normalize_ratio(value: f64, fallback: f64) -> f64 {
2296    if value.is_finite() {
2297        value.clamp(0.0, 1.0)
2298    } else {
2299        fallback
2300    }
2301}
2302
2303#[inline]
2304fn normalize_positive_ratio(value: f64, fallback: f64) -> f64 {
2305    if value.is_finite() && value > 0.0 {
2306        value
2307    } else {
2308        fallback
2309    }
2310}
2311
2312/// Conservative runtime load-governor configuration.
2313///
2314/// The first runtime governor keeps one primary control objective:
2315/// responsiveness under render pressure. It drives adaptive render degradation
2316/// through `RenderBudget`, classifies runtime mode with queue/coalescer inputs,
2317/// and emits replayable evidence for the allowed work disposition. Disabling
2318/// this config falls back to the legacy threshold path.
2319#[derive(Debug, Clone, PartialEq)]
2320pub struct LoadGovernorConfig {
2321    /// Whether the adaptive governor is active.
2322    pub enabled: bool,
2323    /// Controller used to decide degrade/upgrade transitions from frame timing.
2324    pub budget_controller: BudgetControllerConfig,
2325    /// Runtime-mode and work-disposition classification policy.
2326    pub policy: LoadGovernorPolicy,
2327}
2328
2329impl Default for LoadGovernorConfig {
2330    fn default() -> Self {
2331        Self {
2332            enabled: true,
2333            budget_controller: BudgetControllerConfig::default(),
2334            policy: LoadGovernorPolicy::default(),
2335        }
2336    }
2337}
2338
2339impl LoadGovernorConfig {
2340    /// Create an enabled governor with conservative defaults.
2341    #[must_use]
2342    pub fn enabled() -> Self {
2343        Self::default()
2344    }
2345
2346    /// Disable the governor and use the legacy render-budget threshold path.
2347    #[must_use]
2348    pub fn disabled() -> Self {
2349        Self {
2350            enabled: false,
2351            budget_controller: BudgetControllerConfig::default(),
2352            policy: LoadGovernorPolicy::default(),
2353        }
2354    }
2355
2356    /// Toggle governor activation.
2357    #[must_use]
2358    pub fn with_enabled(mut self, enabled: bool) -> Self {
2359        self.enabled = enabled;
2360        self
2361    }
2362
2363    /// Replace the adaptive budget controller configuration.
2364    #[must_use]
2365    pub fn with_budget_controller(mut self, config: BudgetControllerConfig) -> Self {
2366        self.budget_controller = config;
2367        self
2368    }
2369
2370    /// Replace the runtime-mode classification policy.
2371    #[must_use]
2372    pub fn with_policy(mut self, policy: LoadGovernorPolicy) -> Self {
2373        self.policy = policy.normalized();
2374        self
2375    }
2376}
2377
2378#[derive(Debug, Clone, Copy)]
2379struct LoadGovernorObservation {
2380    frame_time_us: f64,
2381    budget_us: f64,
2382    degradation: DegradationLevel,
2383    queue: crate::effect_system::QueueTelemetry,
2384    resize_coalescing_active: bool,
2385    strict_semantics_violation: bool,
2386}
2387
2388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2389struct LoadGovernorSnapshot {
2390    mode: RuntimeLoadMode,
2391    mode_before: RuntimeLoadMode,
2392    pressure_class: RuntimePressureClass,
2393    disposition: RuntimeWorkDisposition,
2394    reason_code: &'static str,
2395    transition: bool,
2396    strict_semantics_preserved: bool,
2397    queue_in_flight: u64,
2398    queue_max_depth: Option<usize>,
2399    queue_dropped_delta: u64,
2400    resize_coalescing_active: bool,
2401    recovery_intervals_observed: u8,
2402    recovery_intervals_required: u8,
2403    deferred_work_total: u64,
2404    coalesced_work_total: u64,
2405    dropped_work_total: u64,
2406}
2407
2408#[derive(Debug, Clone)]
2409struct LoadGovernorState {
2410    enabled: bool,
2411    policy: LoadGovernorPolicy,
2412    max_queue_depth: usize,
2413    mode: RuntimeLoadMode,
2414    recovery_intervals_observed: u8,
2415    last_queue_dropped: u64,
2416    queue_baseline_initialized: bool,
2417    deferred_work_total: u64,
2418    coalesced_work_total: u64,
2419    dropped_work_total: u64,
2420}
2421
2422impl LoadGovernorState {
2423    fn new(config: LoadGovernorConfig, max_queue_depth: usize) -> Self {
2424        Self {
2425            enabled: config.enabled,
2426            policy: config.policy.normalized(),
2427            max_queue_depth,
2428            mode: RuntimeLoadMode::Healthy,
2429            recovery_intervals_observed: 0,
2430            last_queue_dropped: 0,
2431            queue_baseline_initialized: false,
2432            deferred_work_total: 0,
2433            coalesced_work_total: 0,
2434            dropped_work_total: 0,
2435        }
2436    }
2437
2438    fn observe(&mut self, observation: LoadGovernorObservation) -> LoadGovernorSnapshot {
2439        if !self.enabled {
2440            return self.snapshot(
2441                RuntimeLoadMode::Healthy,
2442                RuntimeLoadMode::Healthy,
2443                RuntimePressureClass::SteadyState,
2444                RuntimeWorkDisposition::AdmitAll,
2445                "governor_disabled",
2446                false,
2447                true,
2448                observation,
2449                0,
2450            );
2451        }
2452
2453        let dropped_delta = self.queue_dropped_delta(observation.queue.dropped);
2454        let pressure = self.classify_pressure(observation, dropped_delta);
2455        let mode_before = self.mode;
2456        let reason_code = self.reason_code(observation, pressure, dropped_delta);
2457
2458        match pressure {
2459            RuntimePressureClass::Unsafe => {
2460                self.mode = RuntimeLoadMode::Unsafe;
2461                self.recovery_intervals_observed = 0;
2462            }
2463            // Strict-semantics failure is terminal: once Unsafe is entered the
2464            // governor latches there regardless of later pressure (this mirrors
2465            // the `Unsafe => {}` arm in `observe_steady_interval`, which already
2466            // refuses to recover Unsafe under steady load). Without this guard,
2467            // a subsequent overload interval would downgrade Unsafe to
2468            // Degraded/Stressed and then recover to Healthy, silently escaping
2469            // the fail-fast guarantee. Only an explicit reset clears Unsafe.
2470            _ if self.mode == RuntimeLoadMode::Unsafe => {
2471                self.recovery_intervals_observed = 0;
2472            }
2473            RuntimePressureClass::HardOverload => {
2474                self.mode = RuntimeLoadMode::Degraded;
2475                self.recovery_intervals_observed = 0;
2476            }
2477            RuntimePressureClass::SoftOverload => {
2478                if self.mode != RuntimeLoadMode::Degraded {
2479                    self.mode = RuntimeLoadMode::Stressed;
2480                }
2481                self.recovery_intervals_observed = 0;
2482            }
2483            RuntimePressureClass::SteadyState => self.observe_steady_interval(),
2484        }
2485
2486        self.record_work_disposition(observation, dropped_delta);
2487        let disposition = Self::disposition_for_mode(self.mode);
2488        self.snapshot(
2489            self.mode,
2490            mode_before,
2491            pressure,
2492            disposition,
2493            if self.mode == RuntimeLoadMode::Unsafe {
2494                // Whether freshly violated this interval or latched from a prior
2495                // one, the terminal cause is always the strict-semantics failure.
2496                "strict_semantics_violation"
2497            } else if self.mode == RuntimeLoadMode::Recovered {
2498                "recovery_hysteresis_satisfied"
2499            } else if mode_before == RuntimeLoadMode::Recovered
2500                && self.mode == RuntimeLoadMode::Healthy
2501            {
2502                "recovered_interval_closed"
2503            } else {
2504                reason_code
2505            },
2506            mode_before != self.mode,
2507            // Reflect the latched mode, not the instantaneous pressure: once the
2508            // governor is Unsafe, strict semantics stay unpreserved even on an
2509            // interval whose raw pressure classified lower.
2510            self.mode != RuntimeLoadMode::Unsafe,
2511            observation,
2512            dropped_delta,
2513        )
2514    }
2515
2516    fn queue_dropped_delta(&mut self, dropped_total: u64) -> u64 {
2517        if !self.queue_baseline_initialized {
2518            self.queue_baseline_initialized = true;
2519            self.last_queue_dropped = dropped_total;
2520            return 0;
2521        }
2522        let delta = dropped_total.saturating_sub(self.last_queue_dropped);
2523        self.last_queue_dropped = dropped_total;
2524        delta
2525    }
2526
2527    fn classify_pressure(
2528        &self,
2529        observation: LoadGovernorObservation,
2530        dropped_delta: u64,
2531    ) -> RuntimePressureClass {
2532        if observation.strict_semantics_violation {
2533            return RuntimePressureClass::Unsafe;
2534        }
2535        if dropped_delta > 0
2536            || self
2537                .queue_ratio(observation.queue.in_flight)
2538                .is_some_and(|ratio| ratio >= self.policy.degraded_queue_watermark)
2539            || observation.degradation > DegradationLevel::Full
2540        {
2541            return RuntimePressureClass::HardOverload;
2542        }
2543        if self
2544            .queue_ratio(observation.queue.in_flight)
2545            .is_some_and(|ratio| ratio >= self.policy.stressed_queue_watermark)
2546            || observation.resize_coalescing_active
2547            || observation.frame_time_us
2548                > observation.budget_us * self.policy.budget_overrun_soft_ratio
2549        {
2550            return RuntimePressureClass::SoftOverload;
2551        }
2552        RuntimePressureClass::SteadyState
2553    }
2554
2555    fn observe_steady_interval(&mut self) {
2556        match self.mode {
2557            RuntimeLoadMode::Degraded => {
2558                self.recovery_intervals_observed = self
2559                    .recovery_intervals_observed
2560                    .saturating_add(1)
2561                    .min(self.policy.recovery_intervals);
2562                if self.recovery_intervals_observed >= self.policy.recovery_intervals {
2563                    self.mode = RuntimeLoadMode::Recovered;
2564                }
2565            }
2566            RuntimeLoadMode::Recovered | RuntimeLoadMode::Stressed => {
2567                self.mode = RuntimeLoadMode::Healthy;
2568                self.recovery_intervals_observed = 0;
2569            }
2570            RuntimeLoadMode::Healthy => {
2571                self.recovery_intervals_observed = 0;
2572            }
2573            RuntimeLoadMode::Unsafe => {}
2574        }
2575    }
2576
2577    fn record_work_disposition(
2578        &mut self,
2579        observation: LoadGovernorObservation,
2580        dropped_delta: u64,
2581    ) {
2582        if dropped_delta > 0 {
2583            self.dropped_work_total = self.dropped_work_total.saturating_add(dropped_delta);
2584        }
2585        match self.mode {
2586            RuntimeLoadMode::Stressed => {
2587                if observation.resize_coalescing_active {
2588                    self.coalesced_work_total = self.coalesced_work_total.saturating_add(1);
2589                }
2590            }
2591            RuntimeLoadMode::Degraded => {
2592                self.deferred_work_total = self.deferred_work_total.saturating_add(1);
2593                if observation.resize_coalescing_active {
2594                    self.coalesced_work_total = self.coalesced_work_total.saturating_add(1);
2595                }
2596            }
2597            RuntimeLoadMode::Healthy | RuntimeLoadMode::Recovered | RuntimeLoadMode::Unsafe => {}
2598        }
2599    }
2600
2601    fn reason_code(
2602        &self,
2603        observation: LoadGovernorObservation,
2604        pressure: RuntimePressureClass,
2605        dropped_delta: u64,
2606    ) -> &'static str {
2607        match pressure {
2608            RuntimePressureClass::Unsafe => "strict_semantics_violation",
2609            RuntimePressureClass::HardOverload if dropped_delta > 0 => "effect_queue_drop",
2610            RuntimePressureClass::HardOverload
2611                if self
2612                    .queue_ratio(observation.queue.in_flight)
2613                    .is_some_and(|ratio| ratio >= self.policy.degraded_queue_watermark) =>
2614            {
2615                "queue_degraded_watermark"
2616            }
2617            RuntimePressureClass::HardOverload => "budget_degradation_active",
2618            RuntimePressureClass::SoftOverload
2619                if self
2620                    .queue_ratio(observation.queue.in_flight)
2621                    .is_some_and(|ratio| ratio >= self.policy.stressed_queue_watermark) =>
2622            {
2623                "queue_stressed_watermark"
2624            }
2625            RuntimePressureClass::SoftOverload if observation.resize_coalescing_active => {
2626                "resize_coalescing_active"
2627            }
2628            RuntimePressureClass::SoftOverload => "frame_budget_overrun",
2629            RuntimePressureClass::SteadyState if self.mode == RuntimeLoadMode::Degraded => {
2630                "recovery_hysteresis_pending"
2631            }
2632            RuntimePressureClass::SteadyState => "steady_state",
2633        }
2634    }
2635
2636    fn queue_ratio(&self, in_flight: u64) -> Option<f64> {
2637        (self.max_queue_depth > 0).then(|| in_flight as f64 / self.max_queue_depth as f64)
2638    }
2639
2640    const fn disposition_for_mode(mode: RuntimeLoadMode) -> RuntimeWorkDisposition {
2641        match mode {
2642            RuntimeLoadMode::Healthy => RuntimeWorkDisposition::AdmitAll,
2643            RuntimeLoadMode::Stressed => RuntimeWorkDisposition::CoalesceVisibleDeferBackground,
2644            RuntimeLoadMode::Degraded => RuntimeWorkDisposition::DeferBackgroundDropBestEffort,
2645            RuntimeLoadMode::Recovered => RuntimeWorkDisposition::ReadmitAfterHysteresis,
2646            RuntimeLoadMode::Unsafe => RuntimeWorkDisposition::FailFastStrictGuarantee,
2647        }
2648    }
2649
2650    // Internal constructor that gathers all snapshot fields in one place; the
2651    // wide signature is intentional (a builder would add indirection for a
2652    // private helper). Pre-existing lint, unrelated to the #78 fix.
2653    #[allow(clippy::too_many_arguments)]
2654    fn snapshot(
2655        &self,
2656        mode: RuntimeLoadMode,
2657        mode_before: RuntimeLoadMode,
2658        pressure_class: RuntimePressureClass,
2659        disposition: RuntimeWorkDisposition,
2660        reason_code: &'static str,
2661        transition: bool,
2662        strict_semantics_preserved: bool,
2663        observation: LoadGovernorObservation,
2664        dropped_delta: u64,
2665    ) -> LoadGovernorSnapshot {
2666        LoadGovernorSnapshot {
2667            mode,
2668            mode_before,
2669            pressure_class,
2670            disposition,
2671            reason_code,
2672            transition,
2673            strict_semantics_preserved,
2674            queue_in_flight: observation.queue.in_flight,
2675            queue_max_depth: (self.max_queue_depth > 0).then_some(self.max_queue_depth),
2676            queue_dropped_delta: dropped_delta,
2677            resize_coalescing_active: observation.resize_coalescing_active,
2678            recovery_intervals_observed: self.recovery_intervals_observed,
2679            recovery_intervals_required: self.policy.recovery_intervals,
2680            deferred_work_total: self.deferred_work_total,
2681            coalesced_work_total: self.coalesced_work_total,
2682            dropped_work_total: self.dropped_work_total,
2683        }
2684    }
2685}
2686
2687/// Runtime lane for the Asupersync migration rollout.
2688///
2689/// Controls which subscription/effect execution backend is active.
2690/// The default is `Structured`, reflecting the completed CancellationToken migration (bd-3tmu4).
2691///
2692/// # Migration rollout
2693///
2694/// 1. `Legacy` — pre-migration thread-based subscriptions with manual stop coordination
2695/// 2. `Structured` — CancellationToken-backed subscriptions (current default after bd-3tmu4)
2696/// 3. `Asupersync` — full Asupersync-native execution (future)
2697///
2698/// Selection is logged at startup so operators can tell which lane is active.
2699/// Fallback from `Asupersync` → `Structured` → `Legacy` is automatic on error.
2700#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
2701pub enum RuntimeLane {
2702    /// Pre-migration behavior: thread-based subscriptions with manual stop coordination.
2703    /// This is the safe default that preserves all existing semantics.
2704    Legacy,
2705    /// Structured cancellation: subscriptions use CancellationToken internally.
2706    /// Externally observable behavior is identical to Legacy.
2707    #[default]
2708    Structured,
2709    /// Full Asupersync-native execution (reserved for future use).
2710    /// Falls back to Structured if Asupersync primitives are unavailable.
2711    Asupersync,
2712}
2713
2714impl RuntimeLane {
2715    /// Resolve the effective lane, applying fallback rules.
2716    ///
2717    /// If the requested lane is not yet implemented, falls back to the
2718    /// highest available lane. Currently: Asupersync → Structured.
2719    #[must_use]
2720    pub fn resolve(self) -> Self {
2721        match self {
2722            Self::Asupersync => {
2723                tracing::info!(
2724                    target: "ftui.runtime",
2725                    requested = "asupersync",
2726                    resolved = "structured",
2727                    "Asupersync lane not yet available; falling back to structured cancellation"
2728                );
2729                Self::Structured
2730            }
2731            other => other,
2732        }
2733    }
2734
2735    /// Returns a human-readable label for logging.
2736    #[must_use]
2737    pub fn label(self) -> &'static str {
2738        match self {
2739            Self::Legacy => "legacy",
2740            Self::Structured => "structured",
2741            Self::Asupersync => "asupersync",
2742        }
2743    }
2744
2745    /// Check if this lane uses structured cancellation (CancellationToken).
2746    #[must_use]
2747    pub fn uses_structured_cancellation(self) -> bool {
2748        matches!(self, Self::Structured | Self::Asupersync)
2749    }
2750
2751    /// Resolve the default task executor backend for this lane.
2752    ///
2753    /// # Input-lag regression fix (#78)
2754    ///
2755    /// The `Structured` lane changes *subscription cancellation* semantics
2756    /// (CancellationToken-backed stop signals in `subscription.rs`); it must
2757    /// NOT also collapse `Cmd::Task` concurrency. Earlier this returned
2758    /// `EffectQueue`, which routed every `Cmd::Task` through a single
2759    /// `effect_queue_loop` worker thread with a Smith's-rule (SPT) scheduler,
2760    /// serializing all tasks. Apps that forward PTY output via per-pane
2761    /// `Cmd::task` polling loops (e.g. 10ms drains) then contended for one
2762    /// serialized worker, adding per-keystroke head-of-line latency versus
2763    /// v0.2.1, where each `Cmd::task` got its own `std::thread::spawn`.
2764    ///
2765    /// Structured cancellation does not depend on the effect queue (stop
2766    /// signals are token-backed regardless of executor backend), so the two
2767    /// concerns are decoupled here: `Structured` keeps the structured
2768    /// cancellation semantics but restores per-task-thread (`Spawned`)
2769    /// execution. Apps that explicitly opt into `EffectQueue` still get it
2770    /// (the lane default only applies when the app uses the legacy default
2771    /// backend — see `EffectQueueConfig::uses_legacy_default_backend`).
2772    #[must_use]
2773    fn task_executor_backend(self) -> TaskExecutorBackend {
2774        match self {
2775            // Legacy and Structured both use per-task `Spawned` execution.
2776            // (Structured only changes cancellation semantics, not concurrency
2777            // — see the regression note above.) Kept as a combined arm so the
2778            // `match_same_arms` lint stays happy under `-D warnings`.
2779            Self::Legacy | Self::Structured => TaskExecutorBackend::Spawned,
2780            Self::Asupersync => {
2781                #[cfg(feature = "asupersync-executor")]
2782                {
2783                    TaskExecutorBackend::Asupersync
2784                }
2785                #[cfg(not(feature = "asupersync-executor"))]
2786                {
2787                    TaskExecutorBackend::EffectQueue
2788                }
2789            }
2790        }
2791    }
2792
2793    /// Read the lane from the `FTUI_RUNTIME_LANE` environment variable.
2794    ///
2795    /// Accepted values (case-insensitive): `legacy`, `structured`, `asupersync`.
2796    /// Returns `None` if the variable is unset or contains an unrecognized value.
2797    #[must_use]
2798    pub fn from_env() -> Option<Self> {
2799        let val = std::env::var("FTUI_RUNTIME_LANE").ok()?;
2800        Self::parse(&val)
2801    }
2802
2803    /// Parse a lane name (case-insensitive).
2804    ///
2805    /// Returns `None` for unrecognized values.
2806    #[must_use]
2807    pub fn parse(s: &str) -> Option<Self> {
2808        match s.to_ascii_lowercase().as_str() {
2809            "legacy" => Some(Self::Legacy),
2810            "structured" => Some(Self::Structured),
2811            "asupersync" => Some(Self::Asupersync),
2812            _ => {
2813                tracing::warn!(
2814                    target: "ftui.runtime",
2815                    value = s,
2816                    "RuntimeLane::parse: unrecognized value"
2817                );
2818                None
2819            }
2820        }
2821    }
2822}
2823
2824/// Rollout policy for the Asupersync migration (bd-2crbt).
2825///
2826/// Controls how the runtime lane transition is managed:
2827///
2828/// - `Off` — use only the configured lane, no shadow comparison.
2829/// - `Shadow` — run both baseline and candidate lanes, compare outputs,
2830///   but use only the baseline lane for actual rendering. Evidence is emitted
2831///   to the configured JSONL sink for operator review.
2832/// - `Enabled` — use the candidate lane for rendering (requires prior shadow
2833///   evidence showing deterministic match).
2834///
2835/// The policy is logged at startup and can be overridden via the
2836/// `FTUI_ROLLOUT_POLICY` environment variable (`off`, `shadow`, `enabled`).
2837#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
2838pub enum RolloutPolicy {
2839    /// No rollout activity — use the configured lane directly.
2840    #[default]
2841    Off,
2842    /// Shadow-run comparison mode: run both lanes, emit evidence, use baseline.
2843    Shadow,
2844    /// Candidate lane is live — requires prior shadow evidence.
2845    Enabled,
2846}
2847
2848impl RolloutPolicy {
2849    /// Read the policy from the `FTUI_ROLLOUT_POLICY` environment variable.
2850    ///
2851    /// Accepted values (case-insensitive): `off`, `shadow`, `enabled`.
2852    /// Returns `None` if unset or unrecognized.
2853    #[must_use]
2854    pub fn from_env() -> Option<Self> {
2855        let val = std::env::var("FTUI_ROLLOUT_POLICY").ok()?;
2856        Self::parse(&val)
2857    }
2858
2859    /// Parse a rollout policy name (case-insensitive).
2860    ///
2861    /// Returns `None` for unrecognized values.
2862    #[must_use]
2863    pub fn parse(s: &str) -> Option<Self> {
2864        match s.to_ascii_lowercase().as_str() {
2865            "off" => Some(Self::Off),
2866            "shadow" => Some(Self::Shadow),
2867            "enabled" => Some(Self::Enabled),
2868            _ => {
2869                tracing::warn!(
2870                    target: "ftui.runtime",
2871                    value = s,
2872                    "RolloutPolicy::parse: unrecognized value"
2873                );
2874                None
2875            }
2876        }
2877    }
2878
2879    /// Returns a human-readable label for logging.
2880    #[must_use]
2881    pub fn label(self) -> &'static str {
2882        match self {
2883            Self::Off => "off",
2884            Self::Shadow => "shadow",
2885            Self::Enabled => "enabled",
2886        }
2887    }
2888
2889    /// Whether this policy involves shadow comparison.
2890    #[must_use]
2891    pub fn is_shadow(self) -> bool {
2892        matches!(self, Self::Shadow)
2893    }
2894}
2895
2896impl std::fmt::Display for RolloutPolicy {
2897    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2898        f.write_str(self.label())
2899    }
2900}
2901
2902impl std::fmt::Display for RuntimeLane {
2903    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2904        f.write_str(self.label())
2905    }
2906}
2907
2908/// Configuration for the program runtime.
2909#[derive(Debug, Clone)]
2910pub struct ProgramConfig {
2911    /// Screen mode (inline or alternate screen).
2912    pub screen_mode: ScreenMode,
2913    /// UI anchor for inline mode.
2914    pub ui_anchor: UiAnchor,
2915    /// Frame budget configuration.
2916    pub budget: FrameBudgetConfig,
2917    /// Runtime load-governor configuration.
2918    pub load_governor: LoadGovernorConfig,
2919    /// Diff strategy configuration for the terminal writer.
2920    pub diff_config: RuntimeDiffConfig,
2921    /// Evidence JSONL sink configuration.
2922    pub evidence_sink: EvidenceSinkConfig,
2923    /// Render-trace recorder configuration.
2924    pub render_trace: RenderTraceConfig,
2925    /// Optional frame timing sink.
2926    pub frame_timing: Option<FrameTimingConfig>,
2927    /// Conformal predictor configuration for frame-time risk gating.
2928    pub conformal_config: Option<ConformalConfig>,
2929    /// Locale context used for rendering.
2930    pub locale_context: LocaleContext,
2931    /// Input poll timeout.
2932    pub poll_timeout: Duration,
2933    /// Immediate event-drain policy for burst handling.
2934    pub immediate_drain: ImmediateDrainConfig,
2935    /// Resize coalescer configuration.
2936    pub resize_coalescer: CoalescerConfig,
2937    /// Resize handling behavior (immediate/throttled).
2938    pub resize_behavior: ResizeBehavior,
2939    /// Forced terminal size override (when set, resize events are ignored).
2940    pub forced_size: Option<(u16, u16)>,
2941    /// Mouse capture policy (`Auto`, `On`, `Off`).
2942    ///
2943    /// `Auto` is inline-safe: off in inline modes, on in alt-screen mode.
2944    pub mouse_capture_policy: MouseCapturePolicy,
2945    /// Enable bracketed paste.
2946    pub bracketed_paste: bool,
2947    /// Enable focus reporting.
2948    pub focus_reporting: bool,
2949    /// Enable Kitty keyboard protocol (repeat/release events).
2950    pub kitty_keyboard: bool,
2951    /// State persistence configuration.
2952    pub persistence: PersistenceConfig,
2953    /// Inline auto UI height remeasurement policy.
2954    pub inline_auto_remeasure: Option<InlineAutoRemeasureConfig>,
2955    /// Widget refresh selection configuration.
2956    pub widget_refresh: WidgetRefreshConfig,
2957    /// Effect queue scheduling configuration.
2958    pub effect_queue: EffectQueueConfig,
2959    /// Frame guardrails configuration (memory + queue safety limits).
2960    pub guardrails: GuardrailsConfig,
2961    /// Install signal handlers for cleanup on SIGINT/SIGTERM/SIGHUP.
2962    ///
2963    /// Defaults to `true` for application safety. Set to `false` in tests or
2964    /// when the embedding application manages signals.
2965    pub intercept_signals: bool,
2966    /// Optional tick strategy for selective background screen ticking.
2967    ///
2968    /// When `None` (default), all screens tick every frame (current behavior).
2969    /// When set, the runtime consults the strategy for each inactive screen.
2970    pub tick_strategy: Option<crate::tick_strategy::TickStrategyKind>,
2971    /// Runtime execution lane for the Asupersync migration rollout.
2972    ///
2973    /// Controls which subscription/effect backend is active.
2974    /// Defaults to `Structured` (CancellationToken-backed, current migration state).
2975    /// Logged at startup so operators can identify the active lane.
2976    pub runtime_lane: RuntimeLane,
2977    /// Rollout policy for the Asupersync migration (bd-2crbt).
2978    ///
2979    /// Controls whether shadow-run comparison is active during this session.
2980    /// When `Shadow`, both the baseline and candidate lanes run in parallel
2981    /// and evidence is emitted; rendering uses the baseline lane only.
2982    pub rollout_policy: RolloutPolicy,
2983}
2984
2985impl Default for ProgramConfig {
2986    fn default() -> Self {
2987        Self {
2988            screen_mode: ScreenMode::Inline { ui_height: 4 },
2989            ui_anchor: UiAnchor::Bottom,
2990            budget: FrameBudgetConfig::default(),
2991            load_governor: LoadGovernorConfig::default(),
2992            diff_config: RuntimeDiffConfig::default(),
2993            evidence_sink: EvidenceSinkConfig::default(),
2994            render_trace: RenderTraceConfig::default(),
2995            frame_timing: None,
2996            conformal_config: None,
2997            locale_context: LocaleContext::global(),
2998            poll_timeout: Duration::from_millis(100),
2999            immediate_drain: ImmediateDrainConfig::default(),
3000            resize_coalescer: CoalescerConfig::default(),
3001            resize_behavior: ResizeBehavior::Throttled,
3002            forced_size: None,
3003            mouse_capture_policy: MouseCapturePolicy::Auto,
3004            bracketed_paste: true,
3005            focus_reporting: false,
3006            kitty_keyboard: false,
3007            persistence: PersistenceConfig::default(),
3008            inline_auto_remeasure: None,
3009            widget_refresh: WidgetRefreshConfig::default(),
3010            effect_queue: EffectQueueConfig::default(),
3011            guardrails: GuardrailsConfig::default(),
3012            intercept_signals: true,
3013            tick_strategy: None,
3014            runtime_lane: RuntimeLane::default(),
3015            rollout_policy: RolloutPolicy::default(),
3016        }
3017    }
3018}
3019
3020impl ProgramConfig {
3021    /// Create config for fullscreen applications.
3022    pub fn fullscreen() -> Self {
3023        Self {
3024            screen_mode: ScreenMode::AltScreen,
3025            ..Default::default()
3026        }
3027    }
3028
3029    /// Create config for inline mode with specified height.
3030    pub fn inline(height: u16) -> Self {
3031        Self {
3032            screen_mode: ScreenMode::Inline { ui_height: height },
3033            ..Default::default()
3034        }
3035    }
3036
3037    /// Create config for inline mode with automatic UI height.
3038    pub fn inline_auto(min_height: u16, max_height: u16) -> Self {
3039        Self {
3040            screen_mode: ScreenMode::InlineAuto {
3041                min_height,
3042                max_height,
3043            },
3044            inline_auto_remeasure: Some(InlineAutoRemeasureConfig::default()),
3045            ..Default::default()
3046        }
3047    }
3048
3049    /// Enable mouse support.
3050    #[must_use]
3051    pub fn with_mouse(mut self) -> Self {
3052        self.mouse_capture_policy = MouseCapturePolicy::On;
3053        self
3054    }
3055
3056    /// Set mouse capture policy.
3057    #[must_use]
3058    pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
3059        self.mouse_capture_policy = policy;
3060        self
3061    }
3062
3063    /// Force mouse capture enabled/disabled regardless of screen mode.
3064    #[must_use]
3065    pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
3066        self.mouse_capture_policy = if enabled {
3067            MouseCapturePolicy::On
3068        } else {
3069            MouseCapturePolicy::Off
3070        };
3071        self
3072    }
3073
3074    /// Resolve mouse capture using the configured policy and screen mode.
3075    #[must_use]
3076    pub const fn resolved_mouse_capture(&self) -> bool {
3077        self.mouse_capture_policy.resolve(self.screen_mode)
3078    }
3079
3080    /// Set the budget configuration.
3081    #[must_use]
3082    pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
3083        self.budget = budget;
3084        self
3085    }
3086
3087    /// Set the runtime load-governor configuration.
3088    #[must_use]
3089    pub fn with_load_governor(mut self, config: LoadGovernorConfig) -> Self {
3090        self.load_governor = config;
3091        self
3092    }
3093
3094    /// Disable the adaptive load governor and use legacy render-budget behavior.
3095    #[must_use]
3096    pub fn without_load_governor(mut self) -> Self {
3097        self.load_governor = LoadGovernorConfig::disabled();
3098        self
3099    }
3100
3101    /// Set the diff strategy configuration for the terminal writer.
3102    #[must_use]
3103    pub fn with_diff_config(mut self, diff_config: RuntimeDiffConfig) -> Self {
3104        self.diff_config = diff_config;
3105        self
3106    }
3107
3108    /// Set the evidence JSONL sink configuration.
3109    #[must_use]
3110    pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
3111        self.evidence_sink = config;
3112        self
3113    }
3114
3115    /// Set the render-trace recorder configuration.
3116    #[must_use]
3117    pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
3118        self.render_trace = config;
3119        self
3120    }
3121
3122    /// Set a frame timing sink for per-frame profiling.
3123    #[must_use]
3124    pub fn with_frame_timing(mut self, config: FrameTimingConfig) -> Self {
3125        self.frame_timing = Some(config);
3126        self
3127    }
3128
3129    /// Enable conformal frame-time risk gating with the given config.
3130    #[must_use]
3131    pub fn with_conformal_config(mut self, config: ConformalConfig) -> Self {
3132        self.conformal_config = Some(config);
3133        self
3134    }
3135
3136    /// Disable conformal frame-time risk gating.
3137    #[must_use]
3138    pub fn without_conformal(mut self) -> Self {
3139        self.conformal_config = None;
3140        self
3141    }
3142
3143    /// Set the locale context used for rendering.
3144    #[must_use]
3145    pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
3146        self.locale_context = locale_context;
3147        self
3148    }
3149
3150    /// Set the base locale used for rendering.
3151    #[must_use]
3152    pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
3153        self.locale_context = LocaleContext::new(locale);
3154        self
3155    }
3156
3157    /// Set the widget refresh selection configuration.
3158    #[must_use]
3159    pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
3160        self.widget_refresh = config;
3161        self
3162    }
3163
3164    /// Set the effect queue scheduling configuration.
3165    #[must_use]
3166    pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
3167        self.effect_queue = config;
3168        self
3169    }
3170
3171    /// Set the resize coalescer configuration.
3172    #[must_use]
3173    pub fn with_resize_coalescer(mut self, config: CoalescerConfig) -> Self {
3174        self.resize_coalescer = config;
3175        self
3176    }
3177
3178    /// Set the resize handling behavior.
3179    #[must_use]
3180    pub fn with_resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
3181        self.resize_behavior = behavior;
3182        self
3183    }
3184
3185    /// Force a fixed terminal size (cols, rows). Resize events are ignored.
3186    #[must_use]
3187    pub fn with_forced_size(mut self, width: u16, height: u16) -> Self {
3188        let width = width.max(1);
3189        let height = height.max(1);
3190        self.forced_size = Some((width, height));
3191        self
3192    }
3193
3194    /// Clear any forced terminal size override.
3195    #[must_use]
3196    pub fn without_forced_size(mut self) -> Self {
3197        self.forced_size = None;
3198        self
3199    }
3200
3201    /// Toggle legacy immediate-resize behavior for migration.
3202    #[must_use]
3203    pub fn with_legacy_resize(mut self, enabled: bool) -> Self {
3204        if enabled {
3205            self.resize_behavior = ResizeBehavior::Immediate;
3206        }
3207        self
3208    }
3209
3210    /// Set the persistence configuration.
3211    #[must_use]
3212    pub fn with_persistence(mut self, persistence: PersistenceConfig) -> Self {
3213        self.persistence = persistence;
3214        self
3215    }
3216
3217    /// Enable persistence with the given registry.
3218    #[must_use]
3219    pub fn with_registry(mut self, registry: std::sync::Arc<StateRegistry>) -> Self {
3220        self.persistence = PersistenceConfig::with_registry(registry);
3221        self
3222    }
3223
3224    /// Enable inline auto UI height remeasurement with the given policy.
3225    #[must_use]
3226    pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
3227        self.inline_auto_remeasure = Some(config);
3228        self
3229    }
3230
3231    /// Disable inline auto UI height remeasurement.
3232    #[must_use]
3233    pub fn without_inline_auto_remeasure(mut self) -> Self {
3234        self.inline_auto_remeasure = None;
3235        self
3236    }
3237
3238    /// Enable or disable signal interception (SIGHUP/SIGTERM/SIGINT) for cleanup.
3239    #[must_use]
3240    pub fn with_signal_interception(mut self, enabled: bool) -> Self {
3241        self.intercept_signals = enabled;
3242        self
3243    }
3244
3245    /// Set frame guardrails configuration.
3246    #[must_use]
3247    pub fn with_guardrails(mut self, config: GuardrailsConfig) -> Self {
3248        self.guardrails = config;
3249        self
3250    }
3251
3252    /// Set the immediate event-drain policy for burst handling.
3253    #[must_use]
3254    pub fn with_immediate_drain(mut self, config: ImmediateDrainConfig) -> Self {
3255        self.immediate_drain = config;
3256        self
3257    }
3258
3259    /// Set the tick strategy for selective background screen ticking.
3260    ///
3261    /// When set, the runtime consults the strategy to decide which inactive
3262    /// screens should tick on each frame. Without a strategy, all screens
3263    /// tick every frame (backwards-compatible default).
3264    ///
3265    /// ```ignore
3266    /// ProgramConfig::default()
3267    ///     .with_tick_strategy(TickStrategyKind::Uniform { divisor: 5 })
3268    /// ```
3269    #[must_use]
3270    pub fn with_tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
3271        self.tick_strategy = Some(strategy);
3272        self
3273    }
3274
3275    /// Set the runtime execution lane.
3276    #[must_use]
3277    pub fn with_lane(mut self, lane: RuntimeLane) -> Self {
3278        self.runtime_lane = lane;
3279        self
3280    }
3281
3282    /// Set the rollout policy for the Asupersync migration.
3283    #[must_use]
3284    pub fn with_rollout_policy(mut self, policy: RolloutPolicy) -> Self {
3285        self.rollout_policy = policy;
3286        self
3287    }
3288
3289    /// Apply environment-variable overrides for lane and rollout policy.
3290    ///
3291    /// Reads `FTUI_RUNTIME_LANE` and `FTUI_ROLLOUT_POLICY`. Unset variables
3292    /// are ignored. Unrecognized values emit a `tracing::warn` and are
3293    /// ignored (the programmatic default or prior builder value is retained).
3294    #[must_use]
3295    pub fn with_env_overrides(mut self) -> Self {
3296        if let Some(lane) = RuntimeLane::from_env() {
3297            self.runtime_lane = lane;
3298        }
3299        if let Some(policy) = RolloutPolicy::from_env() {
3300            self.rollout_policy = policy;
3301        }
3302        self
3303    }
3304
3305    #[must_use]
3306    fn resolved_effect_queue_config(&self) -> EffectQueueConfig {
3307        if !self.effect_queue.uses_legacy_default_backend() {
3308            return self.effect_queue.clone();
3309        }
3310
3311        self.effect_queue
3312            .clone()
3313            .with_backend(self.runtime_lane.resolve().task_executor_backend())
3314    }
3315}
3316
3317fn render_budget_from_program_config(config: &ProgramConfig) -> RenderBudget {
3318    let budget = RenderBudget::from_config(&config.budget);
3319    if config.load_governor.enabled {
3320        let mut controller = config.load_governor.budget_controller.clone();
3321        controller.target = config.budget.total;
3322        budget.with_controller(controller)
3323    } else {
3324        budget
3325    }
3326}
3327
3328enum EffectCommand<M> {
3329    Enqueue(TaskSpec, Box<dyn FnOnce() -> M + Send>),
3330    Shutdown,
3331}
3332
3333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3334enum EffectLoopControl {
3335    Continue,
3336    ShutdownRequested,
3337}
3338
3339struct EffectQueue<M: Send + 'static> {
3340    sender: mpsc::Sender<EffectCommand<M>>,
3341    handle: Option<JoinHandle<()>>,
3342    closed: bool,
3343}
3344
3345impl<M: Send + 'static> EffectQueue<M> {
3346    fn start(
3347        config: EffectQueueConfig,
3348        result_sender: mpsc::Sender<M>,
3349        evidence_sink: Option<EvidenceSink>,
3350    ) -> io::Result<Self> {
3351        let (tx, rx) = mpsc::channel::<EffectCommand<M>>();
3352        let handle = thread::Builder::new()
3353            .name("ftui-effects".into())
3354            .spawn(move || effect_queue_loop(config, rx, result_sender, evidence_sink))?;
3355
3356        Ok(Self {
3357            sender: tx,
3358            handle: Some(handle),
3359            closed: false,
3360        })
3361    }
3362
3363    fn enqueue(&self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
3364        if self.closed {
3365            crate::effect_system::record_queue_drop("post_shutdown");
3366            tracing::debug!("rejecting task enqueue after effect queue shutdown");
3367            return;
3368        }
3369        if self
3370            .sender
3371            .send(EffectCommand::Enqueue(spec, task))
3372            .is_err()
3373        {
3374            crate::effect_system::record_queue_drop("channel_closed");
3375        }
3376    }
3377
3378    /// Timeout for the effect-queue thread to finish after sending Shutdown.
3379    const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3380    /// Poll interval when waiting for the effect-queue thread (bd-170o5).
3381    ///
3382    /// This sleep-poll pattern is the idiomatic Rust approach for bounded
3383    /// thread joins — `JoinHandle` has no `join_timeout` in stable Rust.
3384    /// 1ms is chosen to minimize shutdown latency while avoiding spin.
3385    const SHUTDOWN_POLL: Duration = Duration::from_millis(1);
3386
3387    fn shutdown(&mut self) {
3388        self.closed = true;
3389        let _ = self.sender.send(EffectCommand::Shutdown);
3390        if let Some(handle) = self.handle.take() {
3391            let start = Instant::now();
3392            // Fast path: most shutdowns complete nearly instantly after the
3393            // Shutdown command is drained. Check once before entering poll loop.
3394            if handle.is_finished() {
3395                let _ = handle.join();
3396                let elapsed_us = start.elapsed().as_micros() as u64;
3397                tracing::debug!(
3398                    target: "ftui.runtime",
3399                    elapsed_us,
3400                    "effect-queue shutdown (fast path)"
3401                );
3402                return;
3403            }
3404            // Slow path: bounded poll loop for in-flight tasks (bd-170o5).
3405            while !handle.is_finished() {
3406                if start.elapsed() >= Self::SHUTDOWN_TIMEOUT {
3407                    tracing::warn!(
3408                        target: "ftui.runtime",
3409                        timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3410                        "effect-queue thread did not stop within timeout; detaching"
3411                    );
3412                    return;
3413                }
3414                thread::sleep(Self::SHUTDOWN_POLL);
3415            }
3416            let _ = handle.join();
3417            let elapsed_us = start.elapsed().as_micros() as u64;
3418            tracing::debug!(
3419                target: "ftui.runtime",
3420                elapsed_us,
3421                "effect-queue shutdown (slow path)"
3422            );
3423        }
3424    }
3425}
3426
3427impl<M: Send + 'static> Drop for EffectQueue<M> {
3428    fn drop(&mut self) {
3429        self.shutdown();
3430    }
3431}
3432
3433struct SpawnTaskExecutor<M: Send + 'static> {
3434    result_sender: mpsc::Sender<M>,
3435    evidence_sink: Option<EvidenceSink>,
3436    handles: Vec<JoinHandle<()>>,
3437    closed: bool,
3438}
3439
3440impl<M: Send + 'static> SpawnTaskExecutor<M> {
3441    const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3442    /// Poll interval for bounded thread joins (bd-170o5).
3443    ///
3444    /// Same rationale as `EffectQueue::SHUTDOWN_POLL` — `JoinHandle` has no
3445    /// `join_timeout` in stable Rust, so we poll `is_finished()` with a
3446    /// 1ms sleep to minimize shutdown latency while avoiding spin.
3447    const SHUTDOWN_POLL: Duration = Duration::from_millis(1);
3448
3449    fn new(result_sender: mpsc::Sender<M>, evidence_sink: Option<EvidenceSink>) -> Self {
3450        Self {
3451            result_sender,
3452            evidence_sink,
3453            handles: Vec::new(),
3454            closed: false,
3455        }
3456    }
3457
3458    fn submit(&mut self, task: Box<dyn FnOnce() -> M + Send>) {
3459        if self.closed {
3460            tracing::debug!("rejecting spawned task submit after shutdown");
3461            return;
3462        }
3463        let sender = self.result_sender.clone();
3464        let evidence_sink = self.evidence_sink.clone();
3465        let handle = thread::spawn(move || {
3466            let _ = run_task_closure(task, "spawned", evidence_sink.as_ref(), &sender);
3467        });
3468        self.handles.push(handle);
3469    }
3470
3471    fn reap_finished(&mut self) {
3472        if self.handles.is_empty() {
3473            return;
3474        }
3475
3476        let mut i = 0;
3477        while i < self.handles.len() {
3478            if self.handles[i].is_finished() {
3479                let handle = self.handles.swap_remove(i);
3480                let _ = handle.join();
3481            } else {
3482                i += 1;
3483            }
3484        }
3485    }
3486
3487    fn shutdown(&mut self) {
3488        self.closed = true;
3489        let start = Instant::now();
3490        // Fast path: reap any already-finished handles first.
3491        self.reap_finished();
3492        if self.handles.is_empty() {
3493            let elapsed_us = start.elapsed().as_micros() as u64;
3494            tracing::debug!(
3495                target: "ftui.runtime",
3496                elapsed_us,
3497                "spawn-executor shutdown (fast path, all tasks already finished)"
3498            );
3499            return;
3500        }
3501        // Slow path: bounded poll loop for in-flight tasks (bd-170o5).
3502        let pending_at_start = self.handles.len();
3503        while self.handles.iter().any(|handle| !handle.is_finished()) {
3504            if start.elapsed() >= Self::SHUTDOWN_TIMEOUT {
3505                let still_pending = self
3506                    .handles
3507                    .iter()
3508                    .filter(|handle| !handle.is_finished())
3509                    .count();
3510                tracing::warn!(
3511                    target: "ftui.runtime",
3512                    timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3513                    pending_handles = still_pending,
3514                    "background task threads did not stop within timeout; detaching"
3515                );
3516                self.handles.clear();
3517                return;
3518            }
3519            thread::sleep(Self::SHUTDOWN_POLL);
3520        }
3521        self.reap_finished();
3522        let elapsed_us = start.elapsed().as_micros() as u64;
3523        tracing::debug!(
3524            target: "ftui.runtime",
3525            elapsed_us,
3526            pending_at_start,
3527            "spawn-executor shutdown (slow path)"
3528        );
3529    }
3530}
3531
3532#[cfg(feature = "asupersync-executor")]
3533struct AsupersyncTaskExecutor<M: Send + 'static> {
3534    result_sender: mpsc::Sender<M>,
3535    evidence_sink: Option<EvidenceSink>,
3536    runtime: AsupersyncRuntime,
3537    handles: Vec<BlockingTaskHandle>,
3538    /// Backpressure cap on in-flight blocking tasks (`0` = unbounded). Mirrors
3539    /// the `EffectQueue` lane's `max_queue_depth` so both backends shed load and
3540    /// report queue telemetry identically.
3541    max_queue_depth: usize,
3542    /// Total tasks rejected by backpressure or post-shutdown submission.
3543    dropped: u64,
3544    closed: bool,
3545}
3546
3547#[cfg(feature = "asupersync-executor")]
3548impl<M: Send + 'static> AsupersyncTaskExecutor<M> {
3549    const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3550
3551    fn new(
3552        result_sender: mpsc::Sender<M>,
3553        evidence_sink: Option<EvidenceSink>,
3554        max_queue_depth: usize,
3555    ) -> io::Result<Self> {
3556        let max_threads = thread::available_parallelism().map_or(1, |count| count.get().max(1));
3557        let runtime = RuntimeBuilder::new()
3558            .blocking_threads(1, max_threads)
3559            .thread_name_prefix("ftui-asupersync-task")
3560            .build()
3561            .map_err(|error| {
3562                io::Error::other(format!("asupersync runtime init failed: {error}"))
3563            })?;
3564
3565        Ok(Self {
3566            result_sender,
3567            evidence_sink,
3568            runtime,
3569            handles: Vec::new(),
3570            max_queue_depth,
3571            dropped: 0,
3572            closed: false,
3573        })
3574    }
3575
3576    fn submit(&mut self, task: Box<dyn FnOnce() -> M + Send>) {
3577        if self.closed {
3578            self.dropped += 1;
3579            crate::effect_system::record_queue_drop("post_shutdown");
3580            tracing::debug!("rejecting asupersync task submit after shutdown");
3581            return;
3582        }
3583        // Prune completed handles so the in-flight depth used for backpressure
3584        // reflects only tasks that are still running or queued in the pool.
3585        self.handles.retain(|handle| !handle.is_done());
3586        // Backpressure: bound the number of in-flight blocking tasks, matching
3587        // the EffectQueue lane (bd-2zd0a). `0` means unbounded.
3588        if self.max_queue_depth > 0 && self.handles.len() >= self.max_queue_depth {
3589            self.dropped += 1;
3590            crate::effect_system::record_queue_drop("backpressure");
3591            emit_task_executor_backpressure_evidence(
3592                self.evidence_sink.as_ref(),
3593                "asupersync",
3594                "drop",
3595                self.handles.len(),
3596                self.max_queue_depth,
3597                self.dropped,
3598            );
3599            return;
3600        }
3601        crate::effect_system::record_queue_enqueue(self.handles.len() as u64 + 1);
3602        let sender = self.result_sender.clone();
3603        let evidence_sink = self.evidence_sink.clone();
3604        let handle = self
3605            .runtime
3606            .spawn_blocking(move || {
3607                let _ = run_task_closure(task, "asupersync", evidence_sink.as_ref(), &sender);
3608                crate::effect_system::record_queue_processed();
3609            })
3610            .expect("asupersync blocking pool must be configured");
3611        self.handles.push(handle);
3612    }
3613
3614    fn reap_finished(&mut self) {
3615        self.handles.retain(|handle| !handle.is_done());
3616    }
3617
3618    fn shutdown(&mut self) {
3619        self.closed = true;
3620        let deadline = Instant::now() + Self::SHUTDOWN_TIMEOUT;
3621        for handle in &self.handles {
3622            let remaining = deadline.saturating_duration_since(Instant::now());
3623            if remaining.is_zero() || !handle.wait_timeout(remaining) {
3624                tracing::warn!(
3625                    timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3626                    pending_handles = self
3627                        .handles
3628                        .iter()
3629                        .filter(|pending| !pending.is_done())
3630                        .count(),
3631                    "Asupersync blocking tasks did not stop within timeout; detaching"
3632                );
3633                self.handles.clear();
3634                return;
3635            }
3636        }
3637        self.handles.clear();
3638    }
3639}
3640
3641enum TaskExecutor<M: Send + 'static> {
3642    Spawned(SpawnTaskExecutor<M>),
3643    Queued(EffectQueue<M>),
3644    #[cfg(feature = "asupersync-executor")]
3645    Asupersync(AsupersyncTaskExecutor<M>),
3646}
3647
3648impl<M: Send + 'static> TaskExecutor<M> {
3649    fn new(
3650        config: &EffectQueueConfig,
3651        result_sender: mpsc::Sender<M>,
3652        evidence_sink: Option<EvidenceSink>,
3653    ) -> io::Result<Self> {
3654        let executor = match config.backend {
3655            TaskExecutorBackend::Spawned => {
3656                Self::Spawned(SpawnTaskExecutor::new(result_sender, evidence_sink.clone()))
3657            }
3658            TaskExecutorBackend::EffectQueue => Self::Queued(EffectQueue::start(
3659                config.clone(),
3660                result_sender,
3661                evidence_sink.clone(),
3662            )?),
3663            #[cfg(feature = "asupersync-executor")]
3664            TaskExecutorBackend::Asupersync => Self::Asupersync(AsupersyncTaskExecutor::new(
3665                result_sender,
3666                evidence_sink.clone(),
3667                config.max_queue_depth,
3668            )?),
3669        };
3670
3671        emit_task_executor_backend_evidence(evidence_sink.as_ref(), executor.kind_name_for_logs());
3672        Ok(executor)
3673    }
3674
3675    fn submit(&mut self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
3676        match self {
3677            Self::Spawned(executor) => executor.submit(task),
3678            Self::Queued(queue) => queue.enqueue(spec, task),
3679            #[cfg(feature = "asupersync-executor")]
3680            Self::Asupersync(executor) => executor.submit(task),
3681        }
3682    }
3683
3684    fn reap_finished(&mut self) {
3685        match self {
3686            Self::Spawned(executor) => executor.reap_finished(),
3687            #[cfg(feature = "asupersync-executor")]
3688            Self::Asupersync(executor) => executor.reap_finished(),
3689            Self::Queued(_) => {}
3690        }
3691    }
3692
3693    fn shutdown(&mut self) {
3694        match self {
3695            Self::Spawned(executor) => executor.shutdown(),
3696            Self::Queued(queue) => queue.shutdown(),
3697            #[cfg(feature = "asupersync-executor")]
3698            Self::Asupersync(executor) => executor.shutdown(),
3699        }
3700    }
3701
3702    #[cfg(test)]
3703    fn kind_name(&self) -> &'static str {
3704        self.kind_name_for_logs()
3705    }
3706
3707    fn kind_name_for_logs(&self) -> &'static str {
3708        match self {
3709            Self::Spawned(_) => "spawned",
3710            Self::Queued(_) => "queued",
3711            #[cfg(feature = "asupersync-executor")]
3712            Self::Asupersync(_) => "asupersync",
3713        }
3714    }
3715}
3716
3717fn emit_task_executor_backend_evidence(sink: Option<&EvidenceSink>, backend: &str) {
3718    let Some(sink) = sink else {
3719        return;
3720    };
3721    let _ = sink.write_jsonl(&format!(
3722        r#"{{"event":"task_executor_backend","backend":"{backend}"}}"#
3723    ));
3724}
3725
3726fn emit_task_executor_completion_evidence(
3727    sink: Option<&EvidenceSink>,
3728    backend: &str,
3729    duration_us: u64,
3730) {
3731    let Some(sink) = sink else {
3732        return;
3733    };
3734    let _ = sink.write_jsonl(&format!(
3735        r#"{{"event":"task_executor_complete","backend":"{backend}","duration_us":{duration_us}}}"#
3736    ));
3737}
3738
3739fn emit_task_executor_panic_evidence(sink: Option<&EvidenceSink>, backend: &str, panic_msg: &str) {
3740    let Some(sink) = sink else {
3741        return;
3742    };
3743    let escaped = panic_msg
3744        .replace('\\', "\\\\")
3745        .replace('"', "\\\"")
3746        .replace('\n', "\\n")
3747        .replace('\r', "\\r")
3748        .replace('\t', "\\t");
3749    let _ = sink.write_jsonl(&format!(
3750        r#"{{"event":"task_executor_panic","backend":"{backend}","panic_msg":"{escaped}"}}"#
3751    ));
3752}
3753
3754fn emit_task_executor_backpressure_evidence(
3755    sink: Option<&EvidenceSink>,
3756    backend: &str,
3757    action: &str,
3758    queue_length: usize,
3759    max_queue_size: usize,
3760    total_rejected: u64,
3761) {
3762    let Some(sink) = sink else {
3763        return;
3764    };
3765    let _ = sink.write_jsonl(&format!(
3766        r#"{{"event":"task_executor_backpressure","backend":"{backend}","action":"{action}","queue_length":{queue_length},"max_queue_size":{max_queue_size},"total_rejected":{total_rejected}}}"#
3767    ));
3768}
3769
3770fn panic_payload_message(payload: Box<dyn Any + Send>) -> String {
3771    if let Some(s) = payload.downcast_ref::<&str>() {
3772        (*s).to_owned()
3773    } else if let Some(s) = payload.downcast_ref::<String>() {
3774        s.clone()
3775    } else {
3776        "unknown panic payload".to_owned()
3777    }
3778}
3779
3780fn log_task_executor_panic(backend: &str, panic_msg: &str) {
3781    #[cfg(feature = "tracing")]
3782    tracing::error!(
3783        executor_backend = backend,
3784        panic_msg,
3785        "task executor task panicked"
3786    );
3787    #[cfg(not(feature = "tracing"))]
3788    eprintln!("ftui: task executor task panicked ({backend}): {panic_msg}");
3789}
3790
3791fn run_task_closure<M: Send + 'static>(
3792    task: Box<dyn FnOnce() -> M + Send>,
3793    backend: &str,
3794    evidence_sink: Option<&EvidenceSink>,
3795    result_sender: &mpsc::Sender<M>,
3796) -> bool {
3797    let start = Instant::now();
3798    match panic::catch_unwind(AssertUnwindSafe(task)) {
3799        Ok(msg) => {
3800            let duration_us = start.elapsed().as_micros() as u64;
3801            tracing::debug!(
3802                target: "ftui.effect",
3803                command_type = "task",
3804                executor_backend = backend,
3805                duration_us = duration_us,
3806                effect_duration_us = duration_us,
3807                "task effect completed"
3808            );
3809            emit_task_executor_completion_evidence(evidence_sink, backend, duration_us);
3810            let _ = result_sender.send(msg);
3811            true
3812        }
3813        Err(payload) => {
3814            let panic_msg = panic_payload_message(payload);
3815            log_task_executor_panic(backend, &panic_msg);
3816            emit_task_executor_panic_evidence(evidence_sink, backend, &panic_msg);
3817            false
3818        }
3819    }
3820}
3821
3822fn effect_queue_loop<M: Send + 'static>(
3823    config: EffectQueueConfig,
3824    rx: mpsc::Receiver<EffectCommand<M>>,
3825    result_sender: mpsc::Sender<M>,
3826    evidence_sink: Option<EvidenceSink>,
3827) {
3828    let mut scheduler = QueueingScheduler::new(config.scheduler);
3829    let mut tasks: HashMap<u64, Box<dyn FnOnce() -> M + Send>> = HashMap::new();
3830    let mut shutdown_requested = false;
3831    let max_depth = config.max_queue_depth;
3832
3833    loop {
3834        if tasks.is_empty() {
3835            if shutdown_requested {
3836                return;
3837            }
3838            match rx.recv() {
3839                Ok(cmd) => {
3840                    if matches!(
3841                        handle_effect_command(
3842                            cmd,
3843                            &mut scheduler,
3844                            &mut tasks,
3845                            &result_sender,
3846                            evidence_sink.as_ref(),
3847                            max_depth,
3848                        ),
3849                        EffectLoopControl::ShutdownRequested
3850                    ) {
3851                        shutdown_requested = true;
3852                    }
3853                }
3854                Err(_) => return,
3855            }
3856        }
3857
3858        while let Ok(cmd) = rx.try_recv() {
3859            if shutdown_requested && matches!(cmd, EffectCommand::Enqueue(_, _)) {
3860                crate::effect_system::record_queue_drop("post_shutdown");
3861                continue;
3862            }
3863            if matches!(
3864                handle_effect_command(
3865                    cmd,
3866                    &mut scheduler,
3867                    &mut tasks,
3868                    &result_sender,
3869                    evidence_sink.as_ref(),
3870                    max_depth,
3871                ),
3872                EffectLoopControl::ShutdownRequested
3873            ) {
3874                shutdown_requested = true;
3875            }
3876        }
3877
3878        if tasks.is_empty() {
3879            if shutdown_requested {
3880                return;
3881            }
3882            continue;
3883        }
3884
3885        let Some(job) = scheduler.peek_next().cloned() else {
3886            continue;
3887        };
3888
3889        if let Some(ref sink) = evidence_sink {
3890            let evidence = scheduler.evidence();
3891            let _ = sink.write_jsonl(&evidence.to_jsonl("effect_queue_select"));
3892        }
3893
3894        let completed = scheduler.tick(job.remaining_time);
3895        for job_id in completed {
3896            if let Some(task) = tasks.remove(&job_id) {
3897                let _ = run_task_closure(task, "queued", evidence_sink.as_ref(), &result_sender);
3898                crate::effect_system::record_queue_processed();
3899            }
3900        }
3901    }
3902}
3903
3904fn handle_effect_command<M: Send + 'static>(
3905    cmd: EffectCommand<M>,
3906    scheduler: &mut QueueingScheduler,
3907    tasks: &mut HashMap<u64, Box<dyn FnOnce() -> M + Send>>,
3908    result_sender: &mpsc::Sender<M>,
3909    evidence_sink: Option<&EvidenceSink>,
3910    max_depth: usize,
3911) -> EffectLoopControl {
3912    match cmd {
3913        EffectCommand::Enqueue(spec, task) => {
3914            // Backpressure: drop task if queue depth exceeds limit (bd-2zd0a)
3915            if max_depth > 0 && tasks.len() >= max_depth {
3916                crate::effect_system::record_queue_drop("backpressure");
3917                return EffectLoopControl::Continue;
3918            }
3919            let weight_source = if spec.weight == DEFAULT_TASK_WEIGHT {
3920                WeightSource::Default
3921            } else {
3922                WeightSource::Explicit
3923            };
3924            let estimate_source = if spec.estimate_ms == DEFAULT_TASK_ESTIMATE_MS {
3925                EstimateSource::Default
3926            } else {
3927                EstimateSource::Explicit
3928            };
3929            let id = scheduler.submit_with_sources(
3930                spec.weight,
3931                spec.estimate_ms,
3932                weight_source,
3933                estimate_source,
3934                spec.name,
3935            );
3936            if let Some(id) = id {
3937                tasks.insert(id, task);
3938                crate::effect_system::record_queue_enqueue(tasks.len() as u64);
3939            } else {
3940                let stats = scheduler.stats();
3941                emit_task_executor_backpressure_evidence(
3942                    evidence_sink,
3943                    "queued",
3944                    "inline_fallback",
3945                    stats.queue_length,
3946                    scheduler.max_queue_size(),
3947                    stats.total_rejected,
3948                );
3949                let _ =
3950                    run_task_closure(task, "queued-inline-fallback", evidence_sink, result_sender);
3951            }
3952            EffectLoopControl::Continue
3953        }
3954        EffectCommand::Shutdown => EffectLoopControl::ShutdownRequested,
3955    }
3956}
3957
3958// removed: legacy ResizeDebouncer (superseded by ResizeCoalescer)
3959
3960/// Policy for remeasuring inline auto UI height.
3961///
3962/// Uses VOI (value-of-information) sampling to decide when to perform
3963/// a costly full-height measurement, with any-time valid guarantees via
3964/// the embedded e-process in `VoiSampler`.
3965#[derive(Debug, Clone)]
3966pub struct InlineAutoRemeasureConfig {
3967    /// VOI sampling configuration.
3968    pub voi: VoiConfig,
3969    /// Minimum row delta to count as a "violation".
3970    pub change_threshold_rows: u16,
3971}
3972
3973impl Default for InlineAutoRemeasureConfig {
3974    fn default() -> Self {
3975        Self {
3976            voi: VoiConfig {
3977                // Height changes are expected to be rare; bias toward fewer samples.
3978                prior_alpha: 1.0,
3979                prior_beta: 9.0,
3980                // Allow ~1s max latency to adapt to growth/shrink.
3981                max_interval_ms: 1000,
3982                // Avoid over-sampling in high-FPS loops.
3983                min_interval_ms: 100,
3984                // Disable event forcing; use time-based gating.
3985                max_interval_events: 0,
3986                min_interval_events: 0,
3987                // Treat sampling as moderately expensive.
3988                sample_cost: 0.08,
3989                ..VoiConfig::default()
3990            },
3991            change_threshold_rows: 1,
3992        }
3993    }
3994}
3995
3996#[derive(Debug)]
3997struct InlineAutoRemeasureState {
3998    config: InlineAutoRemeasureConfig,
3999    sampler: VoiSampler,
4000}
4001
4002impl InlineAutoRemeasureState {
4003    fn new(config: InlineAutoRemeasureConfig) -> Self {
4004        let sampler = VoiSampler::new(config.voi.clone());
4005        Self { config, sampler }
4006    }
4007
4008    fn reset(&mut self) {
4009        self.sampler = VoiSampler::new(self.config.voi.clone());
4010    }
4011}
4012
4013#[derive(Debug, Clone)]
4014struct ConformalEvidence {
4015    bucket_key: String,
4016    n_b: usize,
4017    alpha: f64,
4018    q_b: f64,
4019    y_hat: f64,
4020    upper_us: f64,
4021    risk: bool,
4022    fallback_level: u8,
4023    window_size: usize,
4024    reset_count: u64,
4025}
4026
4027impl ConformalEvidence {
4028    fn from_prediction(prediction: &ConformalPrediction) -> Self {
4029        let alpha = (1.0 - prediction.confidence).clamp(0.0, 1.0);
4030        Self {
4031            bucket_key: prediction.bucket.to_string(),
4032            n_b: prediction.sample_count,
4033            alpha,
4034            q_b: prediction.quantile,
4035            y_hat: prediction.y_hat,
4036            upper_us: prediction.upper_us,
4037            risk: prediction.risk,
4038            fallback_level: prediction.fallback_level,
4039            window_size: prediction.window_size,
4040            reset_count: prediction.reset_count,
4041        }
4042    }
4043}
4044
4045#[derive(Debug, Clone)]
4046struct BudgetDecisionEvidence {
4047    frame_idx: u64,
4048    decision: BudgetDecision,
4049    controller_decision: BudgetDecision,
4050    degradation_before: DegradationLevel,
4051    degradation_after: DegradationLevel,
4052    frame_time_us: f64,
4053    budget_us: f64,
4054    pid_output: f64,
4055    pid_p: f64,
4056    pid_i: f64,
4057    pid_d: f64,
4058    e_value: f64,
4059    frames_observed: u32,
4060    frames_since_change: u32,
4061    in_warmup: bool,
4062    controller_reason: BudgetDecisionReason,
4063    load_governor: LoadGovernorSnapshot,
4064    conformal: Option<ConformalEvidence>,
4065}
4066
4067impl BudgetDecisionEvidence {
4068    fn decision_from_levels(before: DegradationLevel, after: DegradationLevel) -> BudgetDecision {
4069        if after > before {
4070            BudgetDecision::Degrade
4071        } else if after < before {
4072            BudgetDecision::Upgrade
4073        } else {
4074            BudgetDecision::Hold
4075        }
4076    }
4077
4078    #[must_use]
4079    fn to_jsonl(&self) -> String {
4080        let conformal = self.conformal.as_ref();
4081        let bucket_key = Self::opt_str(conformal.map(|c| c.bucket_key.as_str()));
4082        let n_b = Self::opt_usize(conformal.map(|c| c.n_b));
4083        let alpha = Self::opt_f64(conformal.map(|c| c.alpha));
4084        let q_b = Self::opt_f64(conformal.map(|c| c.q_b));
4085        let y_hat = Self::opt_f64(conformal.map(|c| c.y_hat));
4086        let upper_us = Self::opt_f64(conformal.map(|c| c.upper_us));
4087        let risk = Self::opt_bool(conformal.map(|c| c.risk));
4088        let fallback_level = Self::opt_u8(conformal.map(|c| c.fallback_level));
4089        let window_size = Self::opt_usize(conformal.map(|c| c.window_size));
4090        let reset_count = Self::opt_u64(conformal.map(|c| c.reset_count));
4091        let queue_max_depth = Self::opt_usize(self.load_governor.queue_max_depth);
4092
4093        format!(
4094            r#"{{"event":"budget_decision","frame_idx":{},"decision":"{}","decision_controller":"{}","decision_controller_reason":"{}","degradation_before":"{}","degradation_after":"{}","frame_time_us":{:.6},"budget_us":{:.6},"pid_output":{:.6},"pid_p":{:.6},"pid_i":{:.6},"pid_d":{:.6},"e_value":{:.6},"frames_observed":{},"frames_since_change":{},"in_warmup":{},"runtime_mode":"{}","runtime_mode_before":"{}","pressure_class":"{}","work_disposition":"{}","governor_reason":"{}","governor_transition":{},"strict_semantics_preserved":{},"queue_in_flight":{},"queue_max_depth":{},"queue_dropped_delta":{},"resize_coalescing_active":{},"recovery_intervals_observed":{},"recovery_intervals_required":{},"deferred_work_total":{},"coalesced_work_total":{},"dropped_work_total":{},"bucket_key":{},"n_b":{},"alpha":{},"q_b":{},"y_hat":{},"upper_us":{},"risk":{},"fallback_level":{},"window_size":{},"reset_count":{}}}"#,
4095            self.frame_idx,
4096            self.decision.as_str(),
4097            self.controller_decision.as_str(),
4098            self.controller_reason.as_str(),
4099            self.degradation_before.as_str(),
4100            self.degradation_after.as_str(),
4101            self.frame_time_us,
4102            self.budget_us,
4103            self.pid_output,
4104            self.pid_p,
4105            self.pid_i,
4106            self.pid_d,
4107            self.e_value,
4108            self.frames_observed,
4109            self.frames_since_change,
4110            self.in_warmup,
4111            self.load_governor.mode.as_str(),
4112            self.load_governor.mode_before.as_str(),
4113            self.load_governor.pressure_class.as_str(),
4114            self.load_governor.disposition.as_str(),
4115            self.load_governor.reason_code,
4116            self.load_governor.transition,
4117            self.load_governor.strict_semantics_preserved,
4118            self.load_governor.queue_in_flight,
4119            queue_max_depth,
4120            self.load_governor.queue_dropped_delta,
4121            self.load_governor.resize_coalescing_active,
4122            self.load_governor.recovery_intervals_observed,
4123            self.load_governor.recovery_intervals_required,
4124            self.load_governor.deferred_work_total,
4125            self.load_governor.coalesced_work_total,
4126            self.load_governor.dropped_work_total,
4127            bucket_key,
4128            n_b,
4129            alpha,
4130            q_b,
4131            y_hat,
4132            upper_us,
4133            risk,
4134            fallback_level,
4135            window_size,
4136            reset_count
4137        )
4138    }
4139
4140    fn opt_f64(value: Option<f64>) -> String {
4141        value
4142            .map(|v| format!("{v:.6}"))
4143            .unwrap_or_else(|| "null".to_string())
4144    }
4145
4146    fn opt_u64(value: Option<u64>) -> String {
4147        value
4148            .map(|v| v.to_string())
4149            .unwrap_or_else(|| "null".to_string())
4150    }
4151
4152    fn opt_u8(value: Option<u8>) -> String {
4153        value
4154            .map(|v| v.to_string())
4155            .unwrap_or_else(|| "null".to_string())
4156    }
4157
4158    fn opt_usize(value: Option<usize>) -> String {
4159        value
4160            .map(|v| v.to_string())
4161            .unwrap_or_else(|| "null".to_string())
4162    }
4163
4164    fn opt_bool(value: Option<bool>) -> String {
4165        value
4166            .map(|v| v.to_string())
4167            .unwrap_or_else(|| "null".to_string())
4168    }
4169
4170    fn opt_str(value: Option<&str>) -> String {
4171        value
4172            .map(|v| {
4173                format!(
4174                    "\"{}\"",
4175                    v.replace('\\', "\\\\")
4176                        .replace('"', "\\\"")
4177                        .replace('\n', "\\n")
4178                        .replace('\r', "\\r")
4179                        .replace('\t', "\\t")
4180                )
4181            })
4182            .unwrap_or_else(|| "null".to_string())
4183    }
4184}
4185
4186#[derive(Debug, Clone)]
4187struct FairnessConfigEvidence {
4188    enabled: bool,
4189    input_priority_threshold_ms: u64,
4190    dominance_threshold: u32,
4191    fairness_threshold: f64,
4192}
4193
4194impl FairnessConfigEvidence {
4195    #[must_use]
4196    fn to_jsonl(&self) -> String {
4197        format!(
4198            r#"{{"event":"fairness_config","enabled":{},"input_priority_threshold_ms":{},"dominance_threshold":{},"fairness_threshold":{:.6}}}"#,
4199            self.enabled,
4200            self.input_priority_threshold_ms,
4201            self.dominance_threshold,
4202            self.fairness_threshold
4203        )
4204    }
4205}
4206
4207#[derive(Debug, Clone)]
4208struct FairnessDecisionEvidence {
4209    frame_idx: u64,
4210    decision: &'static str,
4211    reason: &'static str,
4212    pending_input_latency_ms: Option<u64>,
4213    jain_index: f64,
4214    resize_dominance_count: u32,
4215    dominance_threshold: u32,
4216    fairness_threshold: f64,
4217    input_priority_threshold_ms: u64,
4218}
4219
4220impl FairnessDecisionEvidence {
4221    #[must_use]
4222    fn to_jsonl(&self) -> String {
4223        let pending_latency = self
4224            .pending_input_latency_ms
4225            .map(|v| v.to_string())
4226            .unwrap_or_else(|| "null".to_string());
4227        format!(
4228            r#"{{"event":"fairness_decision","frame_idx":{},"decision":"{}","reason":"{}","pending_input_latency_ms":{},"jain_index":{:.6},"resize_dominance_count":{},"dominance_threshold":{},"fairness_threshold":{:.6},"input_priority_threshold_ms":{}}}"#,
4229            self.frame_idx,
4230            self.decision,
4231            self.reason,
4232            pending_latency,
4233            self.jain_index,
4234            self.resize_dominance_count,
4235            self.dominance_threshold,
4236            self.fairness_threshold,
4237            self.input_priority_threshold_ms
4238        )
4239    }
4240}
4241
4242#[derive(Debug, Clone)]
4243struct WidgetRefreshEntry {
4244    widget_id: u64,
4245    essential: bool,
4246    starved: bool,
4247    value: f32,
4248    cost_us: f32,
4249    score: f32,
4250    staleness_ms: u64,
4251}
4252
4253impl WidgetRefreshEntry {
4254    fn to_json(&self) -> String {
4255        format!(
4256            r#"{{"id":{},"cost_us":{:.3},"value":{:.4},"score":{:.4},"essential":{},"starved":{},"staleness_ms":{}}}"#,
4257            self.widget_id,
4258            self.cost_us,
4259            self.value,
4260            self.score,
4261            self.essential,
4262            self.starved,
4263            self.staleness_ms
4264        )
4265    }
4266}
4267
4268#[derive(Debug, Clone)]
4269struct WidgetRefreshPlan {
4270    frame_idx: u64,
4271    budget_us: f64,
4272    degradation: DegradationLevel,
4273    essentials_cost_us: f64,
4274    selected_cost_us: f64,
4275    selected_value: f64,
4276    signal_count: usize,
4277    selected: Vec<WidgetRefreshEntry>,
4278    skipped_count: usize,
4279    skipped_starved: usize,
4280    starved_selected: usize,
4281    over_budget: bool,
4282}
4283
4284impl WidgetRefreshPlan {
4285    fn new() -> Self {
4286        Self {
4287            frame_idx: 0,
4288            budget_us: 0.0,
4289            degradation: DegradationLevel::Full,
4290            essentials_cost_us: 0.0,
4291            selected_cost_us: 0.0,
4292            selected_value: 0.0,
4293            signal_count: 0,
4294            selected: Vec::new(),
4295            skipped_count: 0,
4296            skipped_starved: 0,
4297            starved_selected: 0,
4298            over_budget: false,
4299        }
4300    }
4301
4302    fn clear(&mut self) {
4303        self.frame_idx = 0;
4304        self.budget_us = 0.0;
4305        self.degradation = DegradationLevel::Full;
4306        self.essentials_cost_us = 0.0;
4307        self.selected_cost_us = 0.0;
4308        self.selected_value = 0.0;
4309        self.signal_count = 0;
4310        self.selected.clear();
4311        self.skipped_count = 0;
4312        self.skipped_starved = 0;
4313        self.starved_selected = 0;
4314        self.over_budget = false;
4315    }
4316
4317    fn as_budget(&self) -> WidgetBudget {
4318        if self.signal_count == 0 {
4319            return WidgetBudget::allow_all();
4320        }
4321        let ids = self.selected.iter().map(|entry| entry.widget_id).collect();
4322        WidgetBudget::allow_only(ids)
4323    }
4324
4325    fn recompute(
4326        &mut self,
4327        frame_idx: u64,
4328        budget_us: f64,
4329        degradation: DegradationLevel,
4330        signals: &[WidgetSignal],
4331        config: &WidgetRefreshConfig,
4332    ) {
4333        self.clear();
4334        self.frame_idx = frame_idx;
4335        self.budget_us = budget_us;
4336        self.degradation = degradation;
4337
4338        if !config.enabled || signals.is_empty() {
4339            return;
4340        }
4341
4342        self.signal_count = signals.len();
4343        let mut essentials_cost = 0.0f64;
4344        let mut selected_cost = 0.0f64;
4345        let mut selected_value = 0.0f64;
4346
4347        let staleness_window = config.staleness_window_ms.max(1) as f32;
4348        let mut candidates: Vec<WidgetRefreshEntry> = Vec::with_capacity(signals.len());
4349
4350        for signal in signals {
4351            let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
4352            let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
4353            let mut value = config.weight_priority * signal.priority
4354                + config.weight_staleness * staleness_score
4355                + config.weight_focus * signal.focus_boost
4356                + config.weight_interaction * signal.interaction_boost;
4357            if starved {
4358                value += config.starve_boost;
4359            }
4360            let raw_cost = if signal.recent_cost_us > 0.0 {
4361                signal.recent_cost_us
4362            } else {
4363                signal.cost_estimate_us
4364            };
4365            let cost_us = raw_cost.max(config.min_cost_us);
4366            let score = if cost_us > 0.0 {
4367                value / cost_us
4368            } else {
4369                value
4370            };
4371
4372            let entry = WidgetRefreshEntry {
4373                widget_id: signal.widget_id,
4374                essential: signal.essential,
4375                starved,
4376                value,
4377                cost_us,
4378                score,
4379                staleness_ms: signal.staleness_ms,
4380            };
4381
4382            if degradation >= DegradationLevel::EssentialOnly && !signal.essential {
4383                self.skipped_count += 1;
4384                if starved {
4385                    self.skipped_starved = self.skipped_starved.saturating_add(1);
4386                }
4387                continue;
4388            }
4389
4390            if signal.essential {
4391                essentials_cost += cost_us as f64;
4392                selected_cost += cost_us as f64;
4393                selected_value += value as f64;
4394                if starved {
4395                    self.starved_selected = self.starved_selected.saturating_add(1);
4396                }
4397                self.selected.push(entry);
4398            } else {
4399                candidates.push(entry);
4400            }
4401        }
4402
4403        let mut remaining = budget_us - selected_cost;
4404
4405        if degradation < DegradationLevel::EssentialOnly {
4406            let nonessential_total = candidates.len();
4407            let max_drop_fraction = config.max_drop_fraction.clamp(0.0, 1.0);
4408            let enforce_drop_rate = max_drop_fraction < 1.0 && nonessential_total > 0;
4409            let min_nonessential_selected = if enforce_drop_rate {
4410                let min_fraction = (1.0 - max_drop_fraction).max(0.0);
4411                ((min_fraction * nonessential_total as f32).ceil() as usize).min(nonessential_total)
4412            } else {
4413                0
4414            };
4415
4416            candidates.sort_by(|a, b| {
4417                b.starved
4418                    .cmp(&a.starved)
4419                    .then_with(|| b.score.total_cmp(&a.score))
4420                    .then_with(|| b.value.total_cmp(&a.value))
4421                    .then_with(|| a.cost_us.total_cmp(&b.cost_us))
4422                    .then_with(|| a.widget_id.cmp(&b.widget_id))
4423            });
4424
4425            let mut forced_starved = 0usize;
4426            let mut nonessential_selected = 0usize;
4427            let mut skipped_candidates = if enforce_drop_rate {
4428                Vec::with_capacity(candidates.len())
4429            } else {
4430                Vec::new()
4431            };
4432
4433            for entry in candidates.into_iter() {
4434                if entry.starved && forced_starved >= config.max_starved_per_frame {
4435                    self.skipped_count += 1;
4436                    self.skipped_starved = self.skipped_starved.saturating_add(1);
4437                    if enforce_drop_rate {
4438                        skipped_candidates.push(entry);
4439                    }
4440                    continue;
4441                }
4442
4443                if remaining >= entry.cost_us as f64 {
4444                    remaining -= entry.cost_us as f64;
4445                    selected_cost += entry.cost_us as f64;
4446                    selected_value += entry.value as f64;
4447                    if entry.starved {
4448                        self.starved_selected = self.starved_selected.saturating_add(1);
4449                        forced_starved += 1;
4450                    }
4451                    nonessential_selected += 1;
4452                    self.selected.push(entry);
4453                } else if entry.starved
4454                    && forced_starved < config.max_starved_per_frame
4455                    && nonessential_selected == 0
4456                {
4457                    // Starvation guard: ensure at least one starved widget can refresh.
4458                    selected_cost += entry.cost_us as f64;
4459                    selected_value += entry.value as f64;
4460                    self.starved_selected = self.starved_selected.saturating_add(1);
4461                    forced_starved += 1;
4462                    nonessential_selected += 1;
4463                    self.selected.push(entry);
4464                } else {
4465                    self.skipped_count += 1;
4466                    if entry.starved {
4467                        self.skipped_starved = self.skipped_starved.saturating_add(1);
4468                    }
4469                    if enforce_drop_rate {
4470                        skipped_candidates.push(entry);
4471                    }
4472                }
4473            }
4474
4475            if enforce_drop_rate && nonessential_selected < min_nonessential_selected {
4476                for entry in skipped_candidates.into_iter() {
4477                    if nonessential_selected >= min_nonessential_selected {
4478                        break;
4479                    }
4480                    if entry.starved && forced_starved >= config.max_starved_per_frame {
4481                        continue;
4482                    }
4483                    selected_cost += entry.cost_us as f64;
4484                    selected_value += entry.value as f64;
4485                    if entry.starved {
4486                        self.starved_selected = self.starved_selected.saturating_add(1);
4487                        forced_starved += 1;
4488                        self.skipped_starved = self.skipped_starved.saturating_sub(1);
4489                    }
4490                    self.skipped_count = self.skipped_count.saturating_sub(1);
4491                    nonessential_selected += 1;
4492                    self.selected.push(entry);
4493                }
4494            }
4495        }
4496
4497        self.essentials_cost_us = essentials_cost;
4498        self.selected_cost_us = selected_cost;
4499        self.selected_value = selected_value;
4500        self.over_budget = selected_cost > budget_us;
4501    }
4502
4503    #[must_use]
4504    fn to_jsonl(&self) -> String {
4505        let mut out = String::with_capacity(256 + self.selected.len() * 96);
4506        out.push_str(r#"{"event":"widget_refresh""#);
4507        out.push_str(&format!(
4508            r#","frame_idx":{},"budget_us":{:.3},"degradation":"{}","essentials_cost_us":{:.3},"selected_cost_us":{:.3},"selected_value":{:.3},"selected_count":{},"skipped_count":{},"starved_selected":{},"starved_skipped":{},"over_budget":{}"#,
4509            self.frame_idx,
4510            self.budget_us,
4511            self.degradation.as_str(),
4512            self.essentials_cost_us,
4513            self.selected_cost_us,
4514            self.selected_value,
4515            self.selected.len(),
4516            self.skipped_count,
4517            self.starved_selected,
4518            self.skipped_starved,
4519            self.over_budget
4520        ));
4521        out.push_str(r#","selected":["#);
4522        for (i, entry) in self.selected.iter().enumerate() {
4523            if i > 0 {
4524                out.push(',');
4525            }
4526            out.push_str(&entry.to_json());
4527        }
4528        out.push_str("]}");
4529        out
4530    }
4531}
4532
4533// =============================================================================
4534// CrosstermEventSource: BackendEventSource adapter for TerminalSession
4535// =============================================================================
4536
4537#[cfg(feature = "crossterm-compat")]
4538/// Adapter that wraps [`TerminalSession`] to implement [`BackendEventSource`].
4539///
4540/// This provides the bridge between the legacy crossterm-based terminal session
4541/// and the new backend abstraction. Once the native `ftui-tty` backend fully
4542/// replaces crossterm, this adapter will be removed.
4543pub struct CrosstermEventSource {
4544    session: TerminalSession,
4545    features: BackendFeatures,
4546}
4547
4548#[cfg(feature = "crossterm-compat")]
4549impl CrosstermEventSource {
4550    /// Create a new crossterm event source from a terminal session.
4551    pub fn new(session: TerminalSession, initial_features: BackendFeatures) -> Self {
4552        Self {
4553            session,
4554            features: initial_features,
4555        }
4556    }
4557}
4558
4559#[cfg(feature = "crossterm-compat")]
4560impl BackendEventSource for CrosstermEventSource {
4561    type Error = io::Error;
4562
4563    fn size(&self) -> Result<(u16, u16), io::Error> {
4564        self.session.size()
4565    }
4566
4567    fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
4568        if features.mouse_capture != self.features.mouse_capture {
4569            self.session.set_mouse_capture(features.mouse_capture)?;
4570        }
4571        // bracketed_paste, focus_events, and kitty_keyboard are set at session
4572        // construction and cleaned up in TerminalSession::Drop. Runtime toggling
4573        // is not supported by the crossterm backend.
4574        self.features = features;
4575        Ok(())
4576    }
4577
4578    fn poll_event(&mut self, timeout: Duration) -> Result<bool, io::Error> {
4579        self.session.poll_event(timeout)
4580    }
4581
4582    fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
4583        self.session.read_event()
4584    }
4585}
4586
4587// =============================================================================
4588// HeadlessEventSource: no-op event source for headless/test programs
4589// =============================================================================
4590
4591/// A no-op event source for headless and test programs.
4592///
4593/// Returns a fixed terminal size, accepts feature changes silently, and never
4594/// produces events. This allows the test helper to construct a `Program`
4595/// without depending on crossterm or a real terminal.
4596pub struct HeadlessEventSource {
4597    width: u16,
4598    height: u16,
4599    features: BackendFeatures,
4600}
4601
4602impl HeadlessEventSource {
4603    /// Create a headless event source with the given terminal size.
4604    pub fn new(width: u16, height: u16, features: BackendFeatures) -> Self {
4605        Self {
4606            width,
4607            height,
4608            features,
4609        }
4610    }
4611}
4612
4613impl BackendEventSource for HeadlessEventSource {
4614    type Error = io::Error;
4615
4616    fn size(&self) -> Result<(u16, u16), io::Error> {
4617        Ok((self.width, self.height))
4618    }
4619
4620    fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
4621        self.features = features;
4622        Ok(())
4623    }
4624
4625    fn poll_event(&mut self, _timeout: Duration) -> Result<bool, io::Error> {
4626        Ok(false)
4627    }
4628
4629    fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
4630        Ok(None)
4631    }
4632}
4633
4634// =============================================================================
4635// Program
4636// =============================================================================
4637
4638/// The program runtime that manages the update/view loop.
4639pub struct Program<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send = Stdout> {
4640    /// The application model.
4641    model: M,
4642    /// Terminal output coordinator.
4643    writer: TerminalWriter<W>,
4644    /// Event source (terminal input, size queries, feature toggles).
4645    events: E,
4646    /// Currently active backend feature toggles.
4647    backend_features: BackendFeatures,
4648    /// Whether the program is running.
4649    running: bool,
4650    /// Current tick rate (if any).
4651    tick_rate: Option<Duration>,
4652    /// Total commands actually executed by the runtime.
4653    executed_cmd_count: usize,
4654    /// Last tick time.
4655    last_tick: Instant,
4656    /// Whether the UI needs to be redrawn.
4657    dirty: bool,
4658    /// Monotonic frame index for evidence logging.
4659    frame_idx: u64,
4660    /// Monotonic tick index for tick-strategy scheduling.
4661    tick_count: u64,
4662    /// Widget scheduling signals captured during the last render.
4663    widget_signals: Vec<WidgetSignal>,
4664    /// Widget refresh selection configuration.
4665    widget_refresh_config: WidgetRefreshConfig,
4666    /// Last computed widget refresh plan.
4667    widget_refresh_plan: WidgetRefreshPlan,
4668    /// Current terminal width.
4669    width: u16,
4670    /// Current terminal height.
4671    height: u16,
4672    /// Forced terminal size override (when set, resize events are ignored).
4673    forced_size: Option<(u16, u16)>,
4674    /// Poll timeout when no tick is scheduled.
4675    poll_timeout: Duration,
4676    /// Whether the runtime should observe process-level termination signals.
4677    intercept_signals: bool,
4678    /// Immediate drain policy for bursty input handling.
4679    immediate_drain_config: ImmediateDrainConfig,
4680    /// Runtime counters for immediate-drain behavior.
4681    immediate_drain_stats: ImmediateDrainStats,
4682    /// Frame budget configuration.
4683    budget: RenderBudget,
4684    /// Runtime load-governor state for mode and fallback evidence.
4685    load_governor: LoadGovernorState,
4686    /// Conformal predictor for frame-time risk gating.
4687    conformal_predictor: Option<ConformalPredictor>,
4688    /// Last observed frame time (microseconds), used as a baseline predictor.
4689    last_frame_time_us: Option<f64>,
4690    /// Last observed update duration (microseconds).
4691    last_update_us: Option<u64>,
4692    /// Optional frame timing sink for profiling.
4693    frame_timing: Option<FrameTimingConfig>,
4694    /// Locale context used for rendering.
4695    locale_context: LocaleContext,
4696    /// Last observed locale version.
4697    locale_version: u64,
4698    /// Resize coalescer for rapid resize events.
4699    resize_coalescer: ResizeCoalescer,
4700    /// Shared evidence sink for decision logs (optional).
4701    evidence_sink: Option<EvidenceSink>,
4702    /// Whether fairness config has been logged to evidence sink.
4703    fairness_config_logged: bool,
4704    /// Resize handling behavior.
4705    resize_behavior: ResizeBehavior,
4706    /// Input fairness guard for scheduler integration.
4707    fairness_guard: InputFairnessGuard,
4708    /// Optional event recorder for macro capture.
4709    event_recorder: Option<EventRecorder>,
4710    /// Subscription lifecycle manager.
4711    subscriptions: SubscriptionManager<M::Message>,
4712    /// Channel for receiving messages from background tasks.
4713    #[cfg(test)]
4714    task_sender: std::sync::mpsc::Sender<M::Message>,
4715    /// Channel for receiving messages from background tasks.
4716    task_receiver: std::sync::mpsc::Receiver<M::Message>,
4717    /// Internal task execution substrate behind `Cmd::Task`.
4718    task_executor: TaskExecutor<M::Message>,
4719    /// Optional state registry for widget persistence.
4720    state_registry: Option<std::sync::Arc<StateRegistry>>,
4721    /// Persistence configuration.
4722    persistence_config: PersistenceConfig,
4723    /// Last checkpoint save time.
4724    last_checkpoint: Instant,
4725    /// Inline auto UI height remeasurement state.
4726    inline_auto_remeasure: Option<InlineAutoRemeasureState>,
4727    /// Per-frame bump arena for temporary render-path allocations.
4728    frame_arena: FrameArena,
4729    /// Unified frame guardrails (memory/queue limits).
4730    guardrails: FrameGuardrails,
4731    /// Optional tick strategy for selective background screen ticking.
4732    tick_strategy: Option<Box<dyn crate::tick_strategy::TickStrategy>>,
4733    /// Last active screen observed by the tick strategy dispatch path.
4734    last_active_screen_for_strategy: Option<String>,
4735}
4736
4737#[cfg(feature = "crossterm-compat")]
4738impl<M: Model> Program<M, CrosstermEventSource, Stdout> {
4739    /// Create a new program with default configuration.
4740    pub fn new(model: M) -> io::Result<Self>
4741    where
4742        M::Message: Send + 'static,
4743    {
4744        Self::with_config(model, ProgramConfig::default())
4745    }
4746
4747    /// Create a new program with the specified configuration.
4748    pub fn with_config(model: M, config: ProgramConfig) -> io::Result<Self>
4749    where
4750        M::Message: Send + 'static,
4751    {
4752        let resolved_lane = config.runtime_lane.resolve();
4753        let effect_queue_config = config.resolved_effect_queue_config();
4754        let capabilities = TerminalCapabilities::with_overrides();
4755        let mouse_capture = config.resolved_mouse_capture();
4756        let requested_features = BackendFeatures {
4757            mouse_capture,
4758            bracketed_paste: config.bracketed_paste,
4759            focus_events: config.focus_reporting,
4760            kitty_keyboard: config.kitty_keyboard,
4761        };
4762        let initial_features =
4763            sanitize_backend_features_for_capabilities(requested_features, &capabilities);
4764        let session = TerminalSession::new(SessionOptions {
4765            alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
4766            mouse_capture: initial_features.mouse_capture,
4767            bracketed_paste: initial_features.bracketed_paste,
4768            focus_events: initial_features.focus_events,
4769            kitty_keyboard: initial_features.kitty_keyboard,
4770            intercept_signals: config.intercept_signals,
4771        })?;
4772        let events = CrosstermEventSource::new(session, initial_features);
4773
4774        let mut writer = TerminalWriter::with_diff_config(
4775            io::stdout(),
4776            config.screen_mode,
4777            config.ui_anchor,
4778            capabilities,
4779            config.diff_config.clone(),
4780        );
4781
4782        let frame_timing = config.frame_timing.clone();
4783        writer.set_timing_enabled(frame_timing.is_some());
4784
4785        let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
4786        if let Some(ref sink) = evidence_sink {
4787            writer = writer.with_evidence_sink(sink.clone());
4788        }
4789
4790        let render_trace = crate::RenderTraceRecorder::from_config(
4791            &config.render_trace,
4792            crate::RenderTraceContext {
4793                capabilities: writer.capabilities(),
4794                diff_config: config.diff_config.clone(),
4795                resize_config: config.resize_coalescer.clone(),
4796                conformal_config: config.conformal_config.clone(),
4797            },
4798        )?;
4799        if let Some(recorder) = render_trace {
4800            writer = writer.with_render_trace(recorder);
4801        }
4802
4803        // Get terminal size for initial frame (or forced size override).
4804        let (w, h) = config
4805            .forced_size
4806            .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
4807        let width = w.max(1);
4808        let height = h.max(1);
4809        writer.set_size(width, height);
4810
4811        let budget = render_budget_from_program_config(&config);
4812        let load_governor = LoadGovernorState::new(
4813            config.load_governor.clone(),
4814            effect_queue_config.max_queue_depth,
4815        );
4816        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
4817        let locale_context = config.locale_context.clone();
4818        let locale_version = locale_context.version();
4819        let mut resize_coalescer =
4820            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
4821                .with_screen_mode(config.screen_mode);
4822        if let Some(ref sink) = evidence_sink {
4823            resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
4824        }
4825        let subscriptions = SubscriptionManager::new();
4826        let (task_sender, task_receiver) = std::sync::mpsc::channel();
4827        let inline_auto_remeasure = config
4828            .inline_auto_remeasure
4829            .clone()
4830            .map(InlineAutoRemeasureState::new);
4831        let task_executor = TaskExecutor::new(
4832            &effect_queue_config,
4833            task_sender.clone(),
4834            evidence_sink.clone(),
4835        )?;
4836        let guardrails = FrameGuardrails::new(config.guardrails);
4837
4838        // Log runtime lane and rollout policy at startup (bd-2crbt)
4839        tracing::info!(
4840            target: "ftui.runtime",
4841            requested_lane = config.runtime_lane.label(),
4842            resolved_lane = resolved_lane.label(),
4843            rollout_policy = config.rollout_policy.label(),
4844            "runtime startup: lane={}, rollout={}",
4845            resolved_lane.label(),
4846            config.rollout_policy.label(),
4847        );
4848
4849        Ok(Self {
4850            model,
4851            writer,
4852            events,
4853            backend_features: initial_features,
4854            running: true,
4855            tick_rate: None,
4856            executed_cmd_count: 0,
4857            last_tick: Instant::now(),
4858            dirty: true,
4859            frame_idx: 0,
4860            tick_count: 0,
4861            widget_signals: Vec::new(),
4862            widget_refresh_config: config.widget_refresh,
4863            widget_refresh_plan: WidgetRefreshPlan::new(),
4864            width,
4865            height,
4866            forced_size: config.forced_size,
4867            poll_timeout: config.poll_timeout,
4868            intercept_signals: config.intercept_signals,
4869            immediate_drain_config: config.immediate_drain,
4870            immediate_drain_stats: ImmediateDrainStats::default(),
4871            budget,
4872            load_governor,
4873            conformal_predictor,
4874            last_frame_time_us: None,
4875            last_update_us: None,
4876            frame_timing,
4877            locale_context,
4878            locale_version,
4879            resize_coalescer,
4880            evidence_sink,
4881            fairness_config_logged: false,
4882            resize_behavior: config.resize_behavior,
4883            fairness_guard: InputFairnessGuard::new(),
4884            event_recorder: None,
4885            subscriptions,
4886            #[cfg(test)]
4887            task_sender,
4888            task_receiver,
4889            task_executor,
4890            state_registry: config.persistence.registry.clone(),
4891            persistence_config: config.persistence,
4892            last_checkpoint: Instant::now(),
4893            inline_auto_remeasure,
4894            frame_arena: FrameArena::default(),
4895            guardrails,
4896            tick_strategy: config
4897                .tick_strategy
4898                .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
4899            last_active_screen_for_strategy: None,
4900        })
4901    }
4902}
4903
4904impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
4905    /// Create a program with an externally-constructed event source and writer.
4906    ///
4907    /// This is the generic entry point for alternative backends (native tty,
4908    /// WASM, headless testing). The caller is responsible for terminal
4909    /// lifecycle (raw mode, cleanup) — the event source should handle that
4910    /// via its `Drop` impl or an external RAII guard.
4911    pub fn with_event_source(
4912        model: M,
4913        events: E,
4914        backend_features: BackendFeatures,
4915        writer: TerminalWriter<W>,
4916        config: ProgramConfig,
4917    ) -> io::Result<Self>
4918    where
4919        M::Message: Send + 'static,
4920    {
4921        let effect_queue_config = config.resolved_effect_queue_config();
4922        let (width, height) = config
4923            .forced_size
4924            .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
4925        let width = width.max(1);
4926        let height = height.max(1);
4927
4928        let mut writer = writer;
4929        writer.set_size(width, height);
4930
4931        let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
4932        if let Some(ref sink) = evidence_sink {
4933            writer = writer.with_evidence_sink(sink.clone());
4934        }
4935
4936        let render_trace = crate::RenderTraceRecorder::from_config(
4937            &config.render_trace,
4938            crate::RenderTraceContext {
4939                capabilities: writer.capabilities(),
4940                diff_config: config.diff_config.clone(),
4941                resize_config: config.resize_coalescer.clone(),
4942                conformal_config: config.conformal_config.clone(),
4943            },
4944        )?;
4945        if let Some(recorder) = render_trace {
4946            writer = writer.with_render_trace(recorder);
4947        }
4948
4949        let frame_timing = config.frame_timing.clone();
4950        writer.set_timing_enabled(frame_timing.is_some());
4951
4952        let budget = render_budget_from_program_config(&config);
4953        let load_governor = LoadGovernorState::new(
4954            config.load_governor.clone(),
4955            effect_queue_config.max_queue_depth,
4956        );
4957        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
4958        let locale_context = config.locale_context.clone();
4959        let locale_version = locale_context.version();
4960        let mut resize_coalescer =
4961            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
4962                .with_screen_mode(config.screen_mode);
4963        if let Some(ref sink) = evidence_sink {
4964            resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
4965        }
4966        let subscriptions = SubscriptionManager::new();
4967        let (task_sender, task_receiver) = std::sync::mpsc::channel();
4968        let inline_auto_remeasure = config
4969            .inline_auto_remeasure
4970            .clone()
4971            .map(InlineAutoRemeasureState::new);
4972        let task_executor = TaskExecutor::new(
4973            &effect_queue_config,
4974            task_sender.clone(),
4975            evidence_sink.clone(),
4976        )?;
4977
4978        let guardrails = FrameGuardrails::new(config.guardrails);
4979
4980        Ok(Self {
4981            model,
4982            writer,
4983            events,
4984            backend_features,
4985            running: true,
4986            tick_rate: None,
4987            executed_cmd_count: 0,
4988            last_tick: Instant::now(),
4989            dirty: true,
4990            frame_idx: 0,
4991            tick_count: 0,
4992            widget_signals: Vec::new(),
4993            widget_refresh_config: config.widget_refresh,
4994            widget_refresh_plan: WidgetRefreshPlan::new(),
4995            width,
4996            height,
4997            forced_size: config.forced_size,
4998            poll_timeout: config.poll_timeout,
4999            intercept_signals: config.intercept_signals,
5000            immediate_drain_config: config.immediate_drain,
5001            immediate_drain_stats: ImmediateDrainStats::default(),
5002            budget,
5003            load_governor,
5004            conformal_predictor,
5005            last_frame_time_us: None,
5006            last_update_us: None,
5007            frame_timing,
5008            locale_context,
5009            locale_version,
5010            resize_coalescer,
5011            evidence_sink,
5012            fairness_config_logged: false,
5013            resize_behavior: config.resize_behavior,
5014            fairness_guard: InputFairnessGuard::new(),
5015            event_recorder: None,
5016            subscriptions,
5017            #[cfg(test)]
5018            task_sender,
5019            task_receiver,
5020            task_executor,
5021            state_registry: config.persistence.registry.clone(),
5022            persistence_config: config.persistence,
5023            last_checkpoint: Instant::now(),
5024            inline_auto_remeasure,
5025            frame_arena: FrameArena::default(),
5026            guardrails,
5027            tick_strategy: config
5028                .tick_strategy
5029                .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
5030            last_active_screen_for_strategy: None,
5031        })
5032    }
5033}
5034
5035// =============================================================================
5036// Native TTY backend constructor (feature-gated)
5037// =============================================================================
5038
5039#[cfg(any(feature = "crossterm-compat", feature = "native-backend"))]
5040#[inline]
5041const fn sanitize_backend_features_for_capabilities(
5042    requested: BackendFeatures,
5043    capabilities: &ftui_core::terminal_capabilities::TerminalCapabilities,
5044) -> BackendFeatures {
5045    let focus_events_supported = capabilities.focus_events && !capabilities.in_any_mux();
5046    let kitty_keyboard_supported = capabilities.kitty_keyboard && !capabilities.in_any_mux();
5047
5048    BackendFeatures {
5049        mouse_capture: requested.mouse_capture && capabilities.mouse_sgr,
5050        bracketed_paste: requested.bracketed_paste && capabilities.bracketed_paste,
5051        focus_events: requested.focus_events && focus_events_supported,
5052        kitty_keyboard: requested.kitty_keyboard && kitty_keyboard_supported,
5053    }
5054}
5055
5056#[cfg(feature = "native-backend")]
5057impl<M: Model> Program<M, ftui_tty::TtyBackend, Stdout> {
5058    /// Create a program backed by the native TTY backend (no Crossterm).
5059    ///
5060    /// This opens a live terminal session via `ftui-tty`, entering raw mode
5061    /// and enabling the requested features. When the program exits (or panics),
5062    /// `TtyBackend::drop()` restores the terminal to its original state.
5063    ///
5064    /// **Unix-only.** `ftui-tty` does not yet have a Windows-native backend.
5065    /// On non-Unix targets call [`Program::with_config`] (with `crossterm-compat`)
5066    /// instead — calling `with_native_backend` from Windows used to silently
5067    /// fall through to the headless 0×0 test backend and produce a single
5068    /// init-frame-then-silence pattern that looks like a hung TUI.
5069    #[cfg(unix)]
5070    pub fn with_native_backend(model: M, config: ProgramConfig) -> io::Result<Self>
5071    where
5072        M::Message: Send + 'static,
5073    {
5074        let mut capabilities =
5075            ftui_core::terminal_capabilities::TerminalCapabilities::with_overrides();
5076        let mouse_capture = config.resolved_mouse_capture();
5077        let requested_features = BackendFeatures {
5078            mouse_capture,
5079            bracketed_paste: config.bracketed_paste,
5080            focus_events: config.focus_reporting,
5081            kitty_keyboard: config.kitty_keyboard,
5082        };
5083        let features =
5084            sanitize_backend_features_for_capabilities(requested_features, &capabilities);
5085        let options = ftui_tty::TtySessionOptions {
5086            alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
5087            features,
5088            intercept_signals: config.intercept_signals,
5089        };
5090        let backend = ftui_tty::TtyBackend::open(0, 0, options)?;
5091
5092        // Runtime truecolor recovery. If environment detection did NOT already
5093        // establish 24-bit color — the classic case being an `ssh` hop, which
5094        // forwards `TERM` but strips `COLORTERM`/`TERM_PROGRAM`, so a truecolor
5095        // terminal (e.g. WezTerm/FrankenTerm) is mis-detected as 256-color —
5096        // ask the terminal DIRECTLY via the XTGETTCAP `RGB` query now that the
5097        // native session is live (raw mode). The query round-trips to the real
5098        // terminal even across ssh, so it recovers what the env var could not.
5099        // It runs ONLY in this degraded case (zero added latency when truecolor
5100        // is already known), is bounded by a timeout, fail-open, and
5101        // upgrade-only (a non-answer never downgrades a known-good profile).
5102        //
5103        // The `colors_256` guard keeps the probe from re-enabling color the user
5104        // explicitly disabled: `NO_COLOR` / a dumb terminal yield
5105        // `true_color == false && colors_256 == false`, so the probe is skipped
5106        // and the opt-out is honored. The ssh case (`TERM=xterm-256color` →
5107        // `colors_256 == true`, `true_color == false`) still triggers it.
5108        if !capabilities.true_color && capabilities.colors_256 {
5109            let probe =
5110                ftui_core::caps_probe::probe_capabilities(&ftui_core::caps_probe::ProbeConfig {
5111                    timeout: std::time::Duration::from_millis(300),
5112                    probe_da1: false,
5113                    probe_da2: false,
5114                    probe_background: false,
5115                    probe_truecolor: true,
5116                });
5117            capabilities.refine_from_probe(&probe);
5118        }
5119
5120        let writer = TerminalWriter::with_diff_config(
5121            io::stdout(),
5122            config.screen_mode,
5123            config.ui_anchor,
5124            capabilities,
5125            config.diff_config.clone(),
5126        );
5127
5128        Self::with_event_source(model, backend, features, writer, config)
5129    }
5130}
5131
5132impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
5133    /// Run the main event loop.
5134    ///
5135    /// This is the main entry point. It handles:
5136    /// 1. Initialization (terminal setup, raw mode)
5137    /// 2. Event polling and message dispatch
5138    /// 3. Frame rendering
5139    /// 4. Shutdown (terminal cleanup)
5140    pub fn run(&mut self) -> io::Result<()> {
5141        self.run_event_loop()
5142    }
5143
5144    #[inline]
5145    fn observed_termination_signal(&self) -> Option<i32> {
5146        if self.intercept_signals {
5147            check_termination_signal()
5148        } else {
5149            None
5150        }
5151    }
5152
5153    /// Access widget scheduling signals captured on the last render.
5154    #[inline]
5155    pub fn last_widget_signals(&self) -> &[WidgetSignal] {
5156        &self.widget_signals
5157    }
5158
5159    /// Snapshot immediate-drain runtime counters.
5160    #[inline]
5161    pub fn immediate_drain_stats(&self) -> ImmediateDrainStats {
5162        self.immediate_drain_stats
5163    }
5164
5165    /// The inner event loop, separated for proper cleanup handling.
5166    fn run_event_loop(&mut self) -> io::Result<()> {
5167        // Auto-load state on start
5168        if self.persistence_config.auto_load {
5169            self.load_state();
5170        }
5171
5172        // Initialize
5173        let cmd = {
5174            let _span = info_span!("ftui.program.init").entered();
5175            self.model.init()
5176        };
5177        self.execute_cmd(cmd)?;
5178
5179        let mut termination_signal = self.observed_termination_signal();
5180        if self.running && termination_signal.is_none() {
5181            // Reconcile initial subscriptions
5182            self.reconcile_subscriptions();
5183
5184            // Initial render
5185            self.render_frame()?;
5186        }
5187
5188        // Main loop
5189        let mut loop_count: u64 = 0;
5190        while self.running {
5191            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5192            if termination_signal.is_some() {
5193                self.running = false;
5194                break;
5195            }
5196
5197            loop_count += 1;
5198            // Log heartbeat every 100 iterations to avoid flooding stderr
5199            if loop_count.is_multiple_of(100) {
5200                crate::debug_trace!("main loop heartbeat: iteration {}", loop_count);
5201            }
5202
5203            // Poll for input with tick timeout
5204            let timeout = self.effective_timeout();
5205
5206            // Poll for events with timeout
5207            let poll_result = self.events.poll_event(timeout)?;
5208            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5209            if termination_signal.is_some() {
5210                self.running = false;
5211                break;
5212            }
5213            if poll_result {
5214                self.drain_ready_events()?;
5215            }
5216            if !self.running {
5217                break;
5218            }
5219            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5220            if termination_signal.is_some() {
5221                self.running = false;
5222                break;
5223            }
5224
5225            // Process subscription messages
5226            self.process_subscription_messages()?;
5227            if !self.running {
5228                break;
5229            }
5230
5231            // Process background task results
5232            self.process_task_results()?;
5233            self.reap_finished_tasks();
5234            if !self.running {
5235                break;
5236            }
5237
5238            self.process_resize_coalescer()?;
5239            if !self.running {
5240                break;
5241            }
5242            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5243            if termination_signal.is_some() {
5244                self.running = false;
5245                break;
5246            }
5247
5248            // Detect screen transitions from any update() calls above.
5249            // A.2: notifies the tick strategy so predictive strategies learn.
5250            // D.3: force-ticks the newly active screen for immediate refresh.
5251            self.check_screen_transition();
5252
5253            // Check for tick - deliver to model so periodic logic can run
5254            if self.should_tick() {
5255                self.tick_count = self.tick_count.wrapping_add(1);
5256                let tick_count = self.tick_count;
5257
5258                let mut used_screen_dispatch = false;
5259
5260                // Per-screen tick dispatch: if the model supports multi-screen
5261                // dispatch and a tick strategy is configured, tick individual
5262                // screens selectively instead of calling monolithic
5263                // `update(Tick)`.
5264                if let Some(strategy) = self.tick_strategy.as_mut() {
5265                    // Snapshot screen topology first so the mutable borrow of the
5266                    // dispatch adapter does not overlap strategy decisions.
5267                    let dispatch_snapshot = self.model.as_screen_tick_dispatch().map(|dispatch| {
5268                        let active = dispatch.active_screen_id();
5269                        let all_screens = dispatch.screen_ids();
5270                        (active, all_screens)
5271                    });
5272
5273                    if let Some((active, all_screens)) = dispatch_snapshot {
5274                        used_screen_dispatch = true;
5275
5276                        // Feed active-screen transitions into the strategy so
5277                        // predictive strategies can learn from real navigation.
5278                        if let Some(previous_active) =
5279                            self.last_active_screen_for_strategy.as_deref()
5280                            && previous_active != active
5281                        {
5282                            strategy.on_screen_transition(previous_active, &active);
5283                        }
5284                        self.last_active_screen_for_strategy = Some(active.clone());
5285
5286                        let all_screens_count = all_screens.len();
5287                        let mut tick_targets = Vec::with_capacity(all_screens_count.max(1));
5288                        // Active screen is always ticked.
5289                        tick_targets.push(active.clone());
5290
5291                        // Tick inactive screens according to the strategy.
5292                        for screen_id in all_screens {
5293                            if screen_id != active
5294                                && strategy.should_tick(&screen_id, tick_count, &active)
5295                                    == crate::tick_strategy::TickDecision::Tick
5296                            {
5297                                tick_targets.push(screen_id);
5298                            }
5299                        }
5300
5301                        // Compute skipped screens for tracing.
5302                        let skipped_count = all_screens_count.saturating_sub(tick_targets.len());
5303
5304                        if let Some(dispatch) = self.model.as_screen_tick_dispatch() {
5305                            for screen_id in &tick_targets {
5306                                dispatch.tick_screen(screen_id, tick_count);
5307                            }
5308                        }
5309
5310                        trace!(
5311                            tick = tick_count,
5312                            active = %active,
5313                            ticked = tick_targets.len(),
5314                            skipped = skipped_count,
5315                            "tick_strategy.frame"
5316                        );
5317
5318                        // Maintenance tick for the strategy.
5319                        strategy.maintenance_tick(tick_count);
5320                        self.mark_dirty();
5321                    }
5322                }
5323
5324                if used_screen_dispatch && self.running {
5325                    self.reconcile_subscriptions();
5326                }
5327
5328                if !used_screen_dispatch {
5329                    // Monolithic model path does not expose active-screen
5330                    // transitions, so clear dispatch-local transition state.
5331                    self.last_active_screen_for_strategy = None;
5332                    let msg = M::Message::from(Event::Tick);
5333                    let cmd = {
5334                        let _span = debug_span!(
5335                            "ftui.program.update",
5336                            msg_type = "Tick",
5337                            duration_us = tracing::field::Empty,
5338                            cmd_type = tracing::field::Empty
5339                        )
5340                        .entered();
5341                        let start = Instant::now();
5342                        let cmd = self.model.update(msg);
5343                        tracing::Span::current()
5344                            .record("duration_us", start.elapsed().as_micros() as u64);
5345                        tracing::Span::current()
5346                            .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5347                        cmd
5348                    };
5349                    self.mark_dirty();
5350                    self.execute_cmd(cmd)?;
5351                    if self.running {
5352                        self.reconcile_subscriptions();
5353                    }
5354                }
5355            }
5356
5357            // Check for periodic checkpoint save
5358            self.check_checkpoint_save();
5359
5360            // Detect locale changes outside the event loop.
5361            self.check_locale_change();
5362            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5363            if termination_signal.is_some() {
5364                self.running = false;
5365                break;
5366            }
5367
5368            // Render if dirty
5369            if self.dirty {
5370                self.render_frame()?;
5371            }
5372
5373            // Periodic grapheme pool GC
5374            if loop_count.is_multiple_of(1000) {
5375                self.writer.gc(None);
5376            }
5377        }
5378
5379        let shutdown_cmd = {
5380            let _span = info_span!("ftui.program.shutdown").entered();
5381            self.model.on_shutdown()
5382        };
5383        self.execute_cmd(shutdown_cmd)?;
5384
5385        // Auto-save state on exit
5386        if self.persistence_config.auto_save {
5387            self.save_state();
5388        }
5389
5390        // Shut down tick strategy (gives strategies a chance to persist state)
5391        if let Some(ref mut strategy) = self.tick_strategy {
5392            strategy.shutdown();
5393        }
5394
5395        // Stop all subscriptions on exit
5396        self.subscriptions.stop_all();
5397        self.task_executor.shutdown();
5398        self.reap_finished_tasks();
5399        self.drain_shutdown_task_results()?;
5400
5401        if let Some(signal) = termination_signal {
5402            clear_termination_signal();
5403            let err = io::Error::new(
5404                io::ErrorKind::Interrupted,
5405                SignalTerminationError { signal },
5406            );
5407            debug_assert_eq!(signal_termination_from_error(&err), Some(signal));
5408            return Err(err);
5409        }
5410
5411        Ok(())
5412    }
5413
5414    /// Drain ready events while bounding zero-timeout polling work.
5415    ///
5416    /// The runtime preserves low-latency draining by polling with
5417    /// `Duration::ZERO`, but switches to a bounded backoff path when a burst
5418    /// exceeds configured immediate-drain budgets.
5419    fn drain_ready_events(&mut self) -> io::Result<()> {
5420        self.immediate_drain_stats.bursts = self.immediate_drain_stats.bursts.saturating_add(1);
5421
5422        let zero_poll_limit = self
5423            .immediate_drain_config
5424            .max_zero_timeout_polls_per_burst
5425            .max(1);
5426        let max_burst_duration = self.immediate_drain_config.max_burst_duration;
5427        let backoff_timeout = self.immediate_drain_config.backoff_timeout;
5428
5429        let mut burst_start = Instant::now();
5430        let mut zero_polls_in_burst_window: u64 = 0;
5431        let mut capped_this_burst = false;
5432
5433        loop {
5434            if let Some(event) = self.events.read_event()? {
5435                self.handle_event(event)?;
5436                if !self.running {
5437                    break;
5438                }
5439            }
5440
5441            let budget_exhausted = (zero_polls_in_burst_window as usize) >= zero_poll_limit
5442                || burst_start.elapsed() >= max_burst_duration;
5443
5444            if budget_exhausted {
5445                if !capped_this_burst {
5446                    capped_this_burst = true;
5447                    self.immediate_drain_stats.capped_bursts =
5448                        self.immediate_drain_stats.capped_bursts.saturating_add(1);
5449                }
5450
5451                self.immediate_drain_stats.max_zero_timeout_polls_in_burst = self
5452                    .immediate_drain_stats
5453                    .max_zero_timeout_polls_in_burst
5454                    .max(zero_polls_in_burst_window);
5455
5456                std::thread::yield_now();
5457                self.immediate_drain_stats.backoff_polls =
5458                    self.immediate_drain_stats.backoff_polls.saturating_add(1);
5459                if !self.events.poll_event(backoff_timeout)? {
5460                    break;
5461                }
5462                zero_polls_in_burst_window = 0;
5463                burst_start = Instant::now();
5464                continue;
5465            }
5466
5467            self.immediate_drain_stats.zero_timeout_polls = self
5468                .immediate_drain_stats
5469                .zero_timeout_polls
5470                .saturating_add(1);
5471            zero_polls_in_burst_window = zero_polls_in_burst_window.saturating_add(1);
5472            if !self.events.poll_event(Duration::ZERO)? {
5473                break;
5474            }
5475        }
5476
5477        self.immediate_drain_stats.max_zero_timeout_polls_in_burst = self
5478            .immediate_drain_stats
5479            .max_zero_timeout_polls_in_burst
5480            .max(zero_polls_in_burst_window);
5481
5482        Ok(())
5483    }
5484
5485    /// Load state from the persistence registry.
5486    fn load_state(&mut self) {
5487        if let Some(registry) = &self.state_registry {
5488            match registry.load() {
5489                Ok(count) => {
5490                    info!(count, "loaded widget state from persistence");
5491                }
5492                Err(e) => {
5493                    tracing::warn!(error = %e, "failed to load widget state");
5494                }
5495            }
5496        }
5497    }
5498
5499    /// Save state to the persistence registry.
5500    fn save_state(&mut self) {
5501        if let Some(registry) = &self.state_registry {
5502            match registry.flush() {
5503                Ok(true) => {
5504                    debug!("saved widget state to persistence");
5505                }
5506                Ok(false) => {
5507                    // No changes to save
5508                }
5509                Err(e) => {
5510                    tracing::warn!(error = %e, "failed to save widget state");
5511                }
5512            }
5513        }
5514    }
5515
5516    /// Check if it's time for a periodic checkpoint save.
5517    fn check_checkpoint_save(&mut self) {
5518        if let Some(interval) = self.persistence_config.checkpoint_interval
5519            && self.last_checkpoint.elapsed() >= interval
5520        {
5521            self.save_state();
5522            self.last_checkpoint = Instant::now();
5523        }
5524    }
5525
5526    fn handle_event(&mut self, event: Event) -> io::Result<()> {
5527        // Track event start time and type for fairness scheduling.
5528        let event_start = Instant::now();
5529        let fairness_event_type = Self::classify_event_for_fairness(&event);
5530        if fairness_event_type == FairnessEventType::Input {
5531            self.fairness_guard.input_arrived(event_start);
5532        }
5533
5534        // Record event before processing (no-op when recorder is None or idle).
5535        if let Some(recorder) = &mut self.event_recorder {
5536            recorder.record(&event);
5537        }
5538
5539        let event = match event {
5540            Event::Resize { width, height } => {
5541                debug!(
5542                    width,
5543                    height,
5544                    behavior = ?self.resize_behavior,
5545                    "Resize event received"
5546                );
5547                if let Some((forced_width, forced_height)) = self.forced_size {
5548                    debug!(
5549                        forced_width,
5550                        forced_height, "Resize ignored due to forced size override"
5551                    );
5552                    self.fairness_guard.event_processed(
5553                        fairness_event_type,
5554                        event_start.elapsed(),
5555                        Instant::now(),
5556                    );
5557                    return Ok(());
5558                }
5559                // Clamp to minimum 1 to prevent Buffer::new panic on zero dimensions
5560                let width = width.max(1);
5561                let height = height.max(1);
5562                match self.resize_behavior {
5563                    ResizeBehavior::Immediate => {
5564                        self.resize_coalescer
5565                            .record_external_apply(width, height, Instant::now());
5566                        let result = self.apply_resize(width, height, Duration::ZERO, false);
5567                        self.fairness_guard.event_processed(
5568                            fairness_event_type,
5569                            event_start.elapsed(),
5570                            Instant::now(),
5571                        );
5572                        return result;
5573                    }
5574                    ResizeBehavior::Throttled => {
5575                        let action = self.resize_coalescer.handle_resize(width, height);
5576                        if let CoalesceAction::ApplyResize {
5577                            width,
5578                            height,
5579                            coalesce_time,
5580                            forced_by_deadline,
5581                        } = action
5582                        {
5583                            let result =
5584                                self.apply_resize(width, height, coalesce_time, forced_by_deadline);
5585                            self.fairness_guard.event_processed(
5586                                fairness_event_type,
5587                                event_start.elapsed(),
5588                                Instant::now(),
5589                            );
5590                            return result;
5591                        }
5592
5593                        self.fairness_guard.event_processed(
5594                            fairness_event_type,
5595                            event_start.elapsed(),
5596                            Instant::now(),
5597                        );
5598                        return Ok(());
5599                    }
5600                }
5601            }
5602            other => other,
5603        };
5604
5605        let msg = M::Message::from(event);
5606        let cmd = {
5607            let _span = debug_span!(
5608                "ftui.program.update",
5609                msg_type = "event",
5610                duration_us = tracing::field::Empty,
5611                cmd_type = tracing::field::Empty
5612            )
5613            .entered();
5614            let start = Instant::now();
5615            let cmd = self.model.update(msg);
5616            let elapsed_us = start.elapsed().as_micros() as u64;
5617            self.last_update_us = Some(elapsed_us);
5618            tracing::Span::current().record("duration_us", elapsed_us);
5619            tracing::Span::current()
5620                .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5621            cmd
5622        };
5623        self.mark_dirty();
5624        self.execute_cmd(cmd)?;
5625        if self.running {
5626            self.reconcile_subscriptions();
5627        }
5628
5629        // Track input event processing for fairness.
5630        self.fairness_guard.event_processed(
5631            fairness_event_type,
5632            event_start.elapsed(),
5633            Instant::now(),
5634        );
5635
5636        Ok(())
5637    }
5638
5639    /// Classify an event for fairness tracking.
5640    fn classify_event_for_fairness(event: &Event) -> FairnessEventType {
5641        match event {
5642            Event::Key(_)
5643            | Event::Mouse(_)
5644            | Event::Paste(_)
5645            | Event::Ime(_)
5646            | Event::Focus(_)
5647            | Event::Clipboard(_) => FairnessEventType::Input,
5648            Event::Resize { .. } => FairnessEventType::Resize,
5649            Event::Tick => FairnessEventType::Tick,
5650        }
5651    }
5652
5653    /// Reconcile the model's declared subscriptions with running ones.
5654    fn reconcile_subscriptions(&mut self) {
5655        let _span = debug_span!(
5656            "ftui.program.subscriptions",
5657            active_count = tracing::field::Empty,
5658            started = tracing::field::Empty,
5659            stopped = tracing::field::Empty
5660        )
5661        .entered();
5662        let subs = self.model.subscriptions();
5663        let before_count = self.subscriptions.active_count();
5664        self.subscriptions.reconcile(subs);
5665        let after_count = self.subscriptions.active_count();
5666        let started = after_count.saturating_sub(before_count);
5667        let stopped = before_count.saturating_sub(after_count);
5668        crate::debug_trace!(
5669            "subscriptions reconcile: before={}, after={}, started={}, stopped={}",
5670            before_count,
5671            after_count,
5672            started,
5673            stopped
5674        );
5675        if after_count == 0 {
5676            crate::debug_trace!("subscriptions reconcile: no active subscriptions");
5677        }
5678        let current = tracing::Span::current();
5679        current.record("active_count", after_count);
5680        // started/stopped would require tracking in SubscriptionManager
5681        current.record("started", started);
5682        current.record("stopped", stopped);
5683    }
5684
5685    /// Process pending messages from subscriptions.
5686    fn process_subscription_messages(&mut self) -> io::Result<()> {
5687        let messages = self.subscriptions.drain_messages();
5688        let msg_count = messages.len();
5689        if msg_count > 0 {
5690            crate::debug_trace!("processing {} subscription message(s)", msg_count);
5691        }
5692        for msg in messages {
5693            let cmd = {
5694                let _span = debug_span!(
5695                    "ftui.program.update",
5696                    msg_type = "subscription",
5697                    duration_us = tracing::field::Empty,
5698                    cmd_type = tracing::field::Empty
5699                )
5700                .entered();
5701                let start = Instant::now();
5702                let cmd = self.model.update(msg);
5703                let elapsed_us = start.elapsed().as_micros() as u64;
5704                self.last_update_us = Some(elapsed_us);
5705                tracing::Span::current().record("duration_us", elapsed_us);
5706                tracing::Span::current()
5707                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5708                cmd
5709            };
5710            self.mark_dirty();
5711            self.execute_cmd(cmd)?;
5712            if !self.running {
5713                break;
5714            }
5715        }
5716        if self.running && self.dirty {
5717            self.reconcile_subscriptions();
5718        }
5719        Ok(())
5720    }
5721
5722    /// Process results from background tasks.
5723    fn process_task_results(&mut self) -> io::Result<()> {
5724        while let Ok(msg) = self.task_receiver.try_recv() {
5725            let cmd = {
5726                let _span = debug_span!(
5727                    "ftui.program.update",
5728                    msg_type = "task",
5729                    duration_us = tracing::field::Empty,
5730                    cmd_type = tracing::field::Empty
5731                )
5732                .entered();
5733                let start = Instant::now();
5734                let cmd = self.model.update(msg);
5735                let elapsed_us = start.elapsed().as_micros() as u64;
5736                self.last_update_us = Some(elapsed_us);
5737                tracing::Span::current().record("duration_us", elapsed_us);
5738                tracing::Span::current()
5739                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5740                cmd
5741            };
5742            self.mark_dirty();
5743            self.execute_cmd(cmd)?;
5744            if !self.running {
5745                break;
5746            }
5747        }
5748        if self.running && self.dirty {
5749            self.reconcile_subscriptions();
5750        }
5751        Ok(())
5752    }
5753
5754    /// Execute a command.
5755    fn execute_cmd(&mut self, cmd: Cmd<M::Message>) -> io::Result<()> {
5756        self.executed_cmd_count = self.executed_cmd_count.saturating_add(1);
5757        match cmd {
5758            Cmd::None => {}
5759            Cmd::Quit => self.running = false,
5760            Cmd::Msg(m) => {
5761                let start = Instant::now();
5762                let cmd = self.model.update(m);
5763                let elapsed_us = start.elapsed().as_micros() as u64;
5764                self.last_update_us = Some(elapsed_us);
5765                self.mark_dirty();
5766                self.execute_cmd(cmd)?;
5767            }
5768            Cmd::Batch(cmds) => {
5769                // Batch currently executes sequentially. This is intentional
5770                // until an async runtime or task scheduler is added.
5771                for c in cmds {
5772                    self.execute_cmd(c)?;
5773                    if !self.running {
5774                        break;
5775                    }
5776                }
5777            }
5778            Cmd::Sequence(cmds) => {
5779                for c in cmds {
5780                    self.execute_cmd(c)?;
5781                    if !self.running {
5782                        break;
5783                    }
5784                }
5785            }
5786            Cmd::Tick(duration) => {
5787                self.tick_rate = Some(duration);
5788                self.last_tick = Instant::now();
5789            }
5790            Cmd::Log(text) => {
5791                let sanitized = sanitize(&text);
5792                let mut text_crlf = if sanitized.contains('\n') {
5793                    sanitized.replace("\r\n", "\n").replace('\n', "\r\n")
5794                } else {
5795                    sanitized.into_owned()
5796                };
5797                if !text_crlf.ends_with("\r\n") {
5798                    if text_crlf.ends_with('\n') {
5799                        text_crlf.pop();
5800                    }
5801                    text_crlf.push_str("\r\n");
5802                }
5803                self.writer.write_log(&text_crlf)?;
5804            }
5805            Cmd::Task(spec, f) => {
5806                crate::effect_system::record_command_effect("task", 0);
5807                self.task_executor.submit(spec, f);
5808            }
5809            Cmd::SaveState => {
5810                self.save_state();
5811            }
5812            Cmd::RestoreState => {
5813                self.load_state();
5814            }
5815            Cmd::SetMouseCapture(enabled) => {
5816                self.backend_features.mouse_capture = enabled;
5817                self.events.set_features(self.backend_features)?;
5818            }
5819            Cmd::SetTickStrategy(strategy) => {
5820                let new_name = strategy.name().to_owned();
5821                if let Some(mut previous) = self.tick_strategy.replace(strategy) {
5822                    let old_name = previous.name().to_owned();
5823                    previous.shutdown();
5824                    info!(old = %old_name, new = %new_name, "tick strategy changed at runtime");
5825                } else {
5826                    info!(new = %new_name, "tick strategy changed at runtime");
5827                }
5828                self.last_active_screen_for_strategy = None;
5829            }
5830        }
5831        Ok(())
5832    }
5833
5834    /// Detect active-screen transitions after any `update()` call and react:
5835    ///
5836    /// - **A.2** — notify the tick strategy via `on_screen_transition()` so
5837    ///   predictive strategies can learn navigation patterns.
5838    /// - **D.3** — force-tick the newly active screen so it renders fresh
5839    ///   content immediately, without waiting for the next tick interval.
5840    ///
5841    /// This is a no-op when no tick strategy is configured or when the model
5842    /// does not implement [`ScreenTickDispatch`].
5843    fn check_screen_transition(&mut self) {
5844        if self.tick_strategy.is_none() {
5845            return;
5846        }
5847
5848        // Snapshot the current active screen (releases &mut self.model).
5849        let current_active = match self.model.as_screen_tick_dispatch() {
5850            Some(dispatch) => dispatch.active_screen_id(),
5851            None => return,
5852        };
5853
5854        // First observation: just record, no transition event.
5855        let previous = match self.last_active_screen_for_strategy.take() {
5856            Some(prev) => prev,
5857            None => {
5858                self.last_active_screen_for_strategy = Some(current_active);
5859                return;
5860            }
5861        };
5862
5863        if previous == current_active {
5864            self.last_active_screen_for_strategy = Some(current_active);
5865            return;
5866        }
5867
5868        // A.2: Notify strategy of the transition.
5869        if let Some(strategy) = self.tick_strategy.as_mut() {
5870            strategy.on_screen_transition(&previous, &current_active);
5871        }
5872
5873        // D.3: Force-tick the newly active screen immediately.
5874        let mut force_ticked = false;
5875        if let Some(dispatch) = self.model.as_screen_tick_dispatch() {
5876            dispatch.tick_screen(&current_active, self.tick_count);
5877            force_ticked = true;
5878        }
5879        if force_ticked && self.running {
5880            self.reconcile_subscriptions();
5881        }
5882
5883        self.last_active_screen_for_strategy = Some(current_active);
5884        self.mark_dirty();
5885    }
5886
5887    fn reap_finished_tasks(&mut self) {
5888        self.task_executor.reap_finished();
5889    }
5890
5891    fn drain_shutdown_task_results(&mut self) -> io::Result<()> {
5892        while let Ok(msg) = self.task_receiver.try_recv() {
5893            let cmd = {
5894                let _span = debug_span!(
5895                    "ftui.program.update",
5896                    msg_type = "shutdown_task",
5897                    duration_us = tracing::field::Empty,
5898                    cmd_type = tracing::field::Empty
5899                )
5900                .entered();
5901                let start = Instant::now();
5902                let cmd = self.model.update(msg);
5903                let elapsed_us = start.elapsed().as_micros() as u64;
5904                self.last_update_us = Some(elapsed_us);
5905                tracing::Span::current().record("duration_us", elapsed_us);
5906                tracing::Span::current()
5907                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5908                cmd
5909            };
5910            self.mark_dirty();
5911            self.execute_cmd(cmd)?;
5912        }
5913        Ok(())
5914    }
5915
5916    /// Render a frame with budget tracking.
5917    fn render_frame(&mut self) -> io::Result<()> {
5918        crate::debug_trace!("render_frame: {}x{}", self.width, self.height);
5919
5920        self.frame_idx = self.frame_idx.wrapping_add(1);
5921        let frame_idx = self.frame_idx;
5922        let degradation_start = self.budget.degradation();
5923
5924        // Reset budget for new frame, potentially upgrading quality
5925        self.budget.next_frame();
5926
5927        // Check frame guardrails (memory/queue limits)
5928        let memory_bytes = self.writer.estimate_memory_usage() + self.frame_arena.allocated_bytes();
5929        // Synchronous program has effectively zero queue depth.
5930        let verdict = self.guardrails.check_frame(memory_bytes, 0);
5931
5932        if verdict.should_drop_frame() {
5933            // Emergency shed: skip this frame entirely to prevent OOM
5934            return Ok(());
5935        }
5936
5937        if verdict.should_degrade() {
5938            // Apply guardrail-recommended degradation if it's stricter than budget's
5939            let current = self.budget.degradation();
5940            if verdict.recommended_level > current {
5941                self.budget.set_degradation(verdict.recommended_level);
5942            }
5943        }
5944
5945        // Apply conformal risk gate before rendering (if enabled)
5946        let mut conformal_prediction = None;
5947        if let Some(predictor) = self.conformal_predictor.as_ref() {
5948            let baseline_us = self
5949                .last_frame_time_us
5950                .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
5951            let diff_strategy = self
5952                .writer
5953                .last_diff_strategy()
5954                .unwrap_or(DiffStrategy::Full);
5955            let frame_height_hint = self.writer.render_height_hint().max(1);
5956            let key = BucketKey::from_context(
5957                self.writer.screen_mode(),
5958                diff_strategy,
5959                self.width,
5960                frame_height_hint,
5961            );
5962            let budget_us = self.budget.total().as_secs_f64() * 1_000_000.0;
5963            let prediction = predictor.predict(key, baseline_us, budget_us);
5964            if prediction.risk {
5965                self.budget.degrade();
5966                info!(
5967                    bucket = %prediction.bucket,
5968                    upper_us = prediction.upper_us,
5969                    budget_us = prediction.budget_us,
5970                    fallback_level = prediction.fallback_level,
5971                    degradation = self.budget.degradation().as_str(),
5972                    "conformal gate triggered strategy downgrade"
5973                );
5974                debug!(
5975                    monotonic.counter.conformal_gate_triggers_total = 1_u64,
5976                    bucket = %prediction.bucket,
5977                    "conformal gate trigger"
5978                );
5979            }
5980            debug!(
5981                bucket = %prediction.bucket,
5982                upper_us = prediction.upper_us,
5983                budget_us = prediction.budget_us,
5984                fallback = prediction.fallback_level,
5985                risk = prediction.risk,
5986                "conformal risk gate"
5987            );
5988            debug!(
5989                monotonic.histogram.conformal_prediction_interval_width_us = prediction.quantile.max(0.0),
5990                bucket = %prediction.bucket,
5991                "conformal prediction interval width"
5992            );
5993            conformal_prediction = Some(prediction);
5994        }
5995
5996        // Early skip if budget says to skip this frame entirely
5997        if self.budget.exhausted() {
5998            self.budget.record_frame_time(Duration::ZERO);
5999            let load_snapshot =
6000                self.update_load_governor_snapshot(frame_idx, 0.0, conformal_prediction.as_ref());
6001            self.emit_budget_evidence(
6002                frame_idx,
6003                degradation_start,
6004                0.0,
6005                conformal_prediction.as_ref(),
6006                &load_snapshot,
6007            );
6008            crate::debug_trace!(
6009                "frame skipped: budget exhausted (degradation={})",
6010                self.budget.degradation().as_str()
6011            );
6012            debug!(
6013                degradation = self.budget.degradation().as_str(),
6014                "frame skipped: budget exhausted before render"
6015            );
6016            // Keep dirty=true: the UI update was never presented, so a
6017            // future frame must still pick it up.
6018            return Ok(());
6019        }
6020
6021        let auto_bounds = self.writer.inline_auto_bounds();
6022        let needs_measure = auto_bounds.is_some() && self.writer.auto_ui_height().is_none();
6023        let mut should_measure = needs_measure;
6024        if auto_bounds.is_some()
6025            && let Some(state) = self.inline_auto_remeasure.as_mut()
6026        {
6027            let decision = state.sampler.decide(Instant::now());
6028            if decision.should_sample {
6029                should_measure = true;
6030            }
6031        } else {
6032            crate::voi_telemetry::clear_inline_auto_voi_snapshot();
6033        }
6034
6035        // --- Render phase ---
6036        let render_start = Instant::now();
6037        if let (Some((min_height, max_height)), true) = (auto_bounds, should_measure) {
6038            let measure_height = if needs_measure {
6039                self.writer.render_height_hint().max(1)
6040            } else {
6041                max_height.max(1)
6042            };
6043            let (measure_buffer, _) = self.render_measure_buffer(measure_height);
6044            let measured_height = measure_buffer.content_height();
6045            let clamped = measured_height.clamp(min_height, max_height);
6046            let previous_height = self.writer.auto_ui_height();
6047            self.writer.set_auto_ui_height(clamped);
6048            if let Some(state) = self.inline_auto_remeasure.as_mut() {
6049                let threshold = state.config.change_threshold_rows;
6050                let violated = previous_height
6051                    .map(|prev| prev.abs_diff(clamped) >= threshold)
6052                    .unwrap_or(false);
6053                state.sampler.observe(violated);
6054            }
6055        }
6056        if auto_bounds.is_some()
6057            && let Some(state) = self.inline_auto_remeasure.as_ref()
6058        {
6059            let snapshot = state.sampler.snapshot(8, crate::debug_trace::elapsed_ms());
6060            crate::voi_telemetry::set_inline_auto_voi_snapshot(Some(snapshot));
6061        }
6062
6063        let frame_height = self.writer.render_height_hint().max(1);
6064        let _frame_span = info_span!(
6065            "ftui.render.frame",
6066            width = self.width,
6067            height = frame_height,
6068            duration_us = tracing::field::Empty
6069        )
6070        .entered();
6071        let (buffer, cursor, cursor_visible) = self.render_buffer(frame_height);
6072        self.update_widget_refresh_plan(frame_idx);
6073        let render_elapsed = render_start.elapsed();
6074        let mut present_elapsed = Duration::ZERO;
6075        let mut presented = false;
6076
6077        // Check if render phase overspent its budget
6078        let render_budget = self.budget.phase_budgets().render;
6079        if render_elapsed > render_budget {
6080            debug!(
6081                render_ms = render_elapsed.as_millis() as u32,
6082                budget_ms = render_budget.as_millis() as u32,
6083                "render phase exceeded budget"
6084            );
6085            // With the load governor active, the controller decides degradation
6086            // from measured frame history at the next frame boundary. The
6087            // legacy path keeps its immediate threshold fallback.
6088            if self.budget.controller().is_none() && self.budget.should_degrade(render_budget) {
6089                self.budget.degrade();
6090            }
6091        }
6092
6093        // --- Present phase ---
6094        if !self.budget.exhausted() {
6095            let present_start = Instant::now();
6096            {
6097                let _present_span = debug_span!("ftui.render.present").entered();
6098                self.writer
6099                    .present_ui_owned(buffer, cursor, cursor_visible)?;
6100            }
6101            presented = true;
6102            present_elapsed = present_start.elapsed();
6103
6104            let present_budget = self.budget.phase_budgets().present;
6105            if present_elapsed > present_budget {
6106                debug!(
6107                    present_ms = present_elapsed.as_millis() as u32,
6108                    budget_ms = present_budget.as_millis() as u32,
6109                    "present phase exceeded budget"
6110                );
6111            }
6112        } else {
6113            debug!(
6114                degradation = self.budget.degradation().as_str(),
6115                elapsed_ms = self.budget.elapsed().as_millis() as u32,
6116                "frame present skipped: budget exhausted after render"
6117            );
6118        }
6119
6120        if let Some(ref frame_timing) = self.frame_timing {
6121            let update_us = self.last_update_us.unwrap_or(0);
6122            let render_us = render_elapsed.as_micros() as u64;
6123            let present_us = present_elapsed.as_micros() as u64;
6124            let diff_us = if presented {
6125                self.writer
6126                    .take_last_present_timings()
6127                    .map(|timings| timings.diff_us)
6128                    .unwrap_or(0)
6129            } else {
6130                let _ = self.writer.take_last_present_timings();
6131                0
6132            };
6133            let total_us = update_us
6134                .saturating_add(render_us)
6135                .saturating_add(present_us);
6136            let timing = FrameTiming {
6137                frame_idx,
6138                update_us,
6139                render_us,
6140                diff_us,
6141                present_us,
6142                total_us,
6143            };
6144            frame_timing.sink.record_frame(&timing);
6145        }
6146
6147        let frame_time = render_elapsed.saturating_add(present_elapsed);
6148        self.budget.record_frame_time(frame_time);
6149        let frame_time_us = frame_time.as_secs_f64() * 1_000_000.0;
6150
6151        if let (Some(predictor), Some(prediction)) = (
6152            self.conformal_predictor.as_mut(),
6153            conformal_prediction.as_ref(),
6154        ) {
6155            let diff_strategy = self
6156                .writer
6157                .last_diff_strategy()
6158                .unwrap_or(DiffStrategy::Full);
6159            let key = BucketKey::from_context(
6160                self.writer.screen_mode(),
6161                diff_strategy,
6162                self.width,
6163                frame_height,
6164            );
6165            predictor.observe(key, prediction.y_hat, frame_time_us);
6166        }
6167        self.last_frame_time_us = Some(frame_time_us);
6168        let load_snapshot = self.update_load_governor_snapshot(
6169            frame_idx,
6170            frame_time_us,
6171            conformal_prediction.as_ref(),
6172        );
6173        self.emit_budget_evidence(
6174            frame_idx,
6175            degradation_start,
6176            frame_time_us,
6177            conformal_prediction.as_ref(),
6178            &load_snapshot,
6179        );
6180
6181        // Only clear dirty when the frame was actually presented.
6182        // If present was skipped (budget exhausted after render), the UI
6183        // update was never shown and must be retried on the next frame.
6184        if presented {
6185            self.dirty = false;
6186        }
6187
6188        Ok(())
6189    }
6190
6191    fn update_load_governor_snapshot(
6192        &mut self,
6193        _frame_idx: u64,
6194        frame_time_us: f64,
6195        conformal_prediction: Option<&ConformalPrediction>,
6196    ) -> LoadGovernorSnapshot {
6197        let budget_us = conformal_prediction
6198            .map(|prediction| prediction.budget_us)
6199            .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
6200        let resize_stats = self.resize_coalescer.stats();
6201        self.load_governor.observe(LoadGovernorObservation {
6202            frame_time_us,
6203            budget_us,
6204            degradation: self.budget.degradation(),
6205            queue: crate::effect_system::queue_telemetry(),
6206            // Only meaningful when the coalescer actually runs: in legacy
6207            // Immediate mode `tick_at` never fires, so a single Burst entry
6208            // would otherwise pin the governor at SoftOverload forever.
6209            resize_coalescing_active: self.resize_behavior.uses_coalescer()
6210                && (resize_stats.has_pending
6211                    || !matches!(resize_stats.regime, crate::resize_coalescer::Regime::Steady)),
6212            strict_semantics_violation: false,
6213        })
6214    }
6215
6216    fn emit_budget_evidence(
6217        &self,
6218        frame_idx: u64,
6219        degradation_start: DegradationLevel,
6220        frame_time_us: f64,
6221        conformal_prediction: Option<&ConformalPrediction>,
6222        load_snapshot: &LoadGovernorSnapshot,
6223    ) {
6224        let Some(telemetry) = self.budget.telemetry() else {
6225            set_budget_snapshot(None);
6226            return;
6227        };
6228
6229        let budget_us = conformal_prediction
6230            .map(|prediction| prediction.budget_us)
6231            .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
6232        let conformal = conformal_prediction.map(ConformalEvidence::from_prediction);
6233        let degradation_after = self.budget.degradation();
6234
6235        let evidence = BudgetDecisionEvidence {
6236            frame_idx,
6237            decision: BudgetDecisionEvidence::decision_from_levels(
6238                degradation_start,
6239                degradation_after,
6240            ),
6241            controller_decision: telemetry.last_decision,
6242            degradation_before: degradation_start,
6243            degradation_after,
6244            frame_time_us,
6245            budget_us,
6246            pid_output: telemetry.pid_output,
6247            pid_p: telemetry.pid_p,
6248            pid_i: telemetry.pid_i,
6249            pid_d: telemetry.pid_d,
6250            e_value: telemetry.e_value,
6251            frames_observed: telemetry.frames_observed,
6252            frames_since_change: telemetry.frames_since_change,
6253            in_warmup: telemetry.in_warmup,
6254            controller_reason: telemetry.decision_reason,
6255            load_governor: *load_snapshot,
6256            conformal,
6257        };
6258
6259        let conformal_snapshot = evidence
6260            .conformal
6261            .as_ref()
6262            .map(|snapshot| ConformalSnapshot {
6263                bucket_key: snapshot.bucket_key.clone(),
6264                sample_count: snapshot.n_b,
6265                upper_us: snapshot.upper_us,
6266                risk: snapshot.risk,
6267            });
6268        set_budget_snapshot(Some(BudgetDecisionSnapshot {
6269            frame_idx: evidence.frame_idx,
6270            decision: evidence.decision,
6271            controller_decision: evidence.controller_decision,
6272            degradation_before: evidence.degradation_before,
6273            degradation_after: evidence.degradation_after,
6274            frame_time_us: evidence.frame_time_us,
6275            budget_us: evidence.budget_us,
6276            pid_output: evidence.pid_output,
6277            e_value: evidence.e_value,
6278            frames_observed: evidence.frames_observed,
6279            frames_since_change: evidence.frames_since_change,
6280            in_warmup: evidence.in_warmup,
6281            conformal: conformal_snapshot,
6282        }));
6283
6284        if let Some(ref sink) = self.evidence_sink {
6285            let _ = sink.write_jsonl(&evidence.to_jsonl());
6286        }
6287    }
6288
6289    fn update_widget_refresh_plan(&mut self, frame_idx: u64) {
6290        if !self.widget_refresh_config.enabled {
6291            self.widget_refresh_plan.clear();
6292            return;
6293        }
6294
6295        let budget_us = self.budget.phase_budgets().render.as_secs_f64() * 1_000_000.0;
6296        let degradation = self.budget.degradation();
6297        self.widget_refresh_plan.recompute(
6298            frame_idx,
6299            budget_us,
6300            degradation,
6301            &self.widget_signals,
6302            &self.widget_refresh_config,
6303        );
6304
6305        if let Some(ref sink) = self.evidence_sink {
6306            let _ = sink.write_jsonl(&self.widget_refresh_plan.to_jsonl());
6307        }
6308    }
6309
6310    fn render_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>, bool) {
6311        // Reset the per-frame arena so widgets get fresh scratch space.
6312        self.frame_arena.reset();
6313
6314        // Note: Frame borrows the pool and links from writer.
6315        // We scope it so it drops before we call present_ui (which needs exclusive writer access).
6316        let buffer = self.writer.take_render_buffer(self.width, frame_height);
6317        let (pool, links) = self.writer.pool_and_links_mut();
6318        let mut frame = Frame::from_buffer(buffer, pool);
6319        frame.set_degradation(self.budget.degradation());
6320        frame.set_links(links);
6321        frame.set_widget_budget(self.widget_refresh_plan.as_budget());
6322        frame.set_arena(&self.frame_arena);
6323
6324        let view_start = Instant::now();
6325        let _view_span = debug_span!(
6326            "ftui.program.view",
6327            duration_us = tracing::field::Empty,
6328            widget_count = tracing::field::Empty
6329        )
6330        .entered();
6331        self.model.view(&mut frame);
6332        self.widget_signals = frame.take_widget_signals();
6333        tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
6334        // widget_count would require tracking in Frame
6335
6336        (frame.buffer, frame.cursor_position, frame.cursor_visible)
6337    }
6338
6339    fn emit_fairness_evidence(&mut self, decision: &FairnessDecision, dominance_count: u32) {
6340        let Some(ref sink) = self.evidence_sink else {
6341            return;
6342        };
6343
6344        let config = self.fairness_guard.config();
6345        if !self.fairness_config_logged {
6346            let config_entry = FairnessConfigEvidence {
6347                enabled: config.enabled,
6348                input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
6349                dominance_threshold: config.dominance_threshold,
6350                fairness_threshold: config.fairness_threshold,
6351            };
6352            let _ = sink.write_jsonl(&config_entry.to_jsonl());
6353            self.fairness_config_logged = true;
6354        }
6355
6356        let evidence = FairnessDecisionEvidence {
6357            frame_idx: self.frame_idx,
6358            decision: if decision.should_process {
6359                "allow"
6360            } else {
6361                "yield"
6362            },
6363            reason: decision.reason.as_str(),
6364            pending_input_latency_ms: decision
6365                .pending_input_latency
6366                .map(|latency| latency.as_millis() as u64),
6367            jain_index: decision.jain_index,
6368            resize_dominance_count: dominance_count,
6369            dominance_threshold: config.dominance_threshold,
6370            fairness_threshold: config.fairness_threshold,
6371            input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
6372        };
6373
6374        let _ = sink.write_jsonl(&evidence.to_jsonl());
6375    }
6376
6377    fn render_measure_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>) {
6378        // Reset the per-frame arena for measurement pass.
6379        self.frame_arena.reset();
6380
6381        let pool = self.writer.pool_mut();
6382        let mut frame = Frame::new(self.width, frame_height, pool);
6383        frame.set_degradation(self.budget.degradation());
6384        frame.set_arena(&self.frame_arena);
6385
6386        let view_start = Instant::now();
6387        let _view_span = debug_span!(
6388            "ftui.program.view",
6389            duration_us = tracing::field::Empty,
6390            widget_count = tracing::field::Empty
6391        )
6392        .entered();
6393        self.model.view(&mut frame);
6394        tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
6395
6396        (frame.buffer, frame.cursor_position)
6397    }
6398
6399    /// Calculate the effective poll timeout.
6400    fn effective_timeout(&self) -> Duration {
6401        if let Some(tick_rate) = self.tick_rate {
6402            let elapsed = self.last_tick.elapsed();
6403            let mut timeout = tick_rate.saturating_sub(elapsed);
6404            if self.resize_behavior.uses_coalescer()
6405                && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
6406            {
6407                timeout = timeout.min(resize_timeout);
6408            }
6409            timeout
6410        } else {
6411            let mut timeout = self.poll_timeout;
6412            if self.resize_behavior.uses_coalescer()
6413                && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
6414            {
6415                timeout = timeout.min(resize_timeout);
6416            }
6417            timeout
6418        }
6419    }
6420
6421    /// Check if we should send a tick.
6422    fn should_tick(&mut self) -> bool {
6423        if let Some(tick_rate) = self.tick_rate
6424            && self.last_tick.elapsed() >= tick_rate
6425        {
6426            self.last_tick = Instant::now();
6427            return true;
6428        }
6429        false
6430    }
6431
6432    fn process_resize_coalescer(&mut self) -> io::Result<()> {
6433        if !self.resize_behavior.uses_coalescer() {
6434            return Ok(());
6435        }
6436
6437        // Check fairness: if input is starving, skip resize application this cycle.
6438        // This ensures input events are processed before resize is finalized.
6439        let dominance_count = self.fairness_guard.resize_dominance_count();
6440        let fairness_decision = self.fairness_guard.check_fairness(Instant::now());
6441        self.emit_fairness_evidence(&fairness_decision, dominance_count);
6442        if !fairness_decision.should_process {
6443            debug!(
6444                reason = ?fairness_decision.reason,
6445                pending_latency_ms = fairness_decision.pending_input_latency.map(|d| d.as_millis() as u64),
6446                "Resize yielding to input for fairness"
6447            );
6448            // Skip resize application this cycle to allow input processing.
6449            return Ok(());
6450        }
6451
6452        let action = self.resize_coalescer.tick();
6453        let resize_snapshot =
6454            self.resize_coalescer
6455                .logs()
6456                .last()
6457                .map(|entry| ResizeDecisionSnapshot {
6458                    event_idx: entry.event_idx,
6459                    action: entry.action,
6460                    dt_ms: entry.dt_ms,
6461                    event_rate: entry.event_rate,
6462                    regime: entry.regime,
6463                    pending_size: entry.pending_size,
6464                    applied_size: entry.applied_size,
6465                    time_since_render_ms: entry.time_since_render_ms,
6466                    bocpd: self
6467                        .resize_coalescer
6468                        .bocpd()
6469                        .and_then(|detector| detector.last_evidence().cloned()),
6470                });
6471        set_resize_snapshot(resize_snapshot);
6472
6473        match action {
6474            CoalesceAction::ApplyResize {
6475                width,
6476                height,
6477                coalesce_time,
6478                forced_by_deadline,
6479            } => self.apply_resize(width, height, coalesce_time, forced_by_deadline),
6480            _ => Ok(()),
6481        }
6482    }
6483
6484    fn apply_resize(
6485        &mut self,
6486        width: u16,
6487        height: u16,
6488        coalesce_time: Duration,
6489        forced_by_deadline: bool,
6490    ) -> io::Result<()> {
6491        // Clamp to minimum 1 to prevent Buffer::new panic on zero dimensions
6492        let width = width.max(1);
6493        let height = height.max(1);
6494        self.width = width;
6495        self.height = height;
6496        self.writer.set_size(width, height);
6497        info!(
6498            width = width,
6499            height = height,
6500            coalesce_ms = coalesce_time.as_millis() as u64,
6501            forced = forced_by_deadline,
6502            "Resize applied"
6503        );
6504
6505        let msg = M::Message::from(Event::Resize { width, height });
6506        let start = Instant::now();
6507        let cmd = self.model.update(msg);
6508        let elapsed_us = start.elapsed().as_micros() as u64;
6509        self.last_update_us = Some(elapsed_us);
6510        self.mark_dirty();
6511        self.execute_cmd(cmd)?;
6512        if self.running && self.dirty {
6513            self.reconcile_subscriptions();
6514        }
6515        Ok(())
6516    }
6517
6518    // removed: resize placeholder rendering (continuous reflow preferred)
6519
6520    /// Get a reference to the model.
6521    pub fn model(&self) -> &M {
6522        &self.model
6523    }
6524
6525    /// Get a mutable reference to the model.
6526    pub fn model_mut(&mut self) -> &mut M {
6527        &mut self.model
6528    }
6529
6530    /// Check if the program is running.
6531    pub fn is_running(&self) -> bool {
6532        self.running
6533    }
6534
6535    /// Get the current tick rate, if one has been installed.
6536    #[must_use]
6537    pub const fn tick_rate(&self) -> Option<Duration> {
6538        self.tick_rate
6539    }
6540
6541    /// Get the number of commands actually executed by the runtime.
6542    #[must_use]
6543    pub const fn executed_cmd_count(&self) -> usize {
6544        self.executed_cmd_count
6545    }
6546
6547    /// Request a quit.
6548    pub fn quit(&mut self) {
6549        self.running = false;
6550    }
6551
6552    /// Get a reference to the state registry, if configured.
6553    pub fn state_registry(&self) -> Option<&std::sync::Arc<StateRegistry>> {
6554        self.state_registry.as_ref()
6555    }
6556
6557    /// Check if state persistence is enabled.
6558    pub fn has_persistence(&self) -> bool {
6559        self.state_registry.is_some()
6560    }
6561
6562    /// Query the current tick strategy's debug statistics.
6563    ///
6564    /// Returns key-value pairs describing the strategy's internal state
6565    /// (e.g. strategy name, divisors, confidence, transition counts).
6566    /// Returns an empty vec if no tick strategy is configured.
6567    #[must_use]
6568    pub fn tick_strategy_stats(&self) -> Vec<(String, String)> {
6569        self.tick_strategy
6570            .as_ref()
6571            .map(|s| s.debug_stats())
6572            .unwrap_or_default()
6573    }
6574
6575    /// Trigger a manual save of widget state.
6576    ///
6577    /// Returns the result of the flush operation, or `Ok(false)` if
6578    /// persistence is not configured.
6579    pub fn trigger_save(&mut self) -> StorageResult<bool> {
6580        if let Some(registry) = &self.state_registry {
6581            registry.flush()
6582        } else {
6583            Ok(false)
6584        }
6585    }
6586
6587    /// Trigger a manual load of widget state.
6588    ///
6589    /// Returns the number of entries loaded, or `Ok(0)` if persistence
6590    /// is not configured.
6591    pub fn trigger_load(&mut self) -> StorageResult<usize> {
6592        if let Some(registry) = &self.state_registry {
6593            registry.load()
6594        } else {
6595            Ok(0)
6596        }
6597    }
6598
6599    fn mark_dirty(&mut self) {
6600        self.dirty = true;
6601    }
6602
6603    fn check_locale_change(&mut self) {
6604        let version = self.locale_context.version();
6605        if version != self.locale_version {
6606            self.locale_version = version;
6607            self.mark_dirty();
6608        }
6609    }
6610
6611    /// Mark the UI as needing redraw.
6612    pub fn request_redraw(&mut self) {
6613        self.mark_dirty();
6614    }
6615
6616    /// Request a re-measure of inline auto UI height on next render.
6617    pub fn request_ui_height_remeasure(&mut self) {
6618        if self.writer.inline_auto_bounds().is_some() {
6619            self.writer.clear_auto_ui_height();
6620            if let Some(state) = self.inline_auto_remeasure.as_mut() {
6621                state.reset();
6622            }
6623            crate::voi_telemetry::clear_inline_auto_voi_snapshot();
6624            self.mark_dirty();
6625        }
6626    }
6627
6628    /// Start recording events into a macro.
6629    ///
6630    /// If already recording, the current recording is discarded and a new one starts.
6631    /// The current terminal size is captured as metadata.
6632    pub fn start_recording(&mut self, name: impl Into<String>) {
6633        let mut recorder = EventRecorder::new(name).with_terminal_size(self.width, self.height);
6634        recorder.start();
6635        self.event_recorder = Some(recorder);
6636    }
6637
6638    /// Stop recording and return the recorded macro, if any.
6639    ///
6640    /// Returns `None` if not currently recording.
6641    pub fn stop_recording(&mut self) -> Option<InputMacro> {
6642        self.event_recorder.take().map(EventRecorder::finish)
6643    }
6644
6645    /// Check if event recording is active.
6646    pub fn is_recording(&self) -> bool {
6647        self.event_recorder
6648            .as_ref()
6649            .is_some_and(EventRecorder::is_recording)
6650    }
6651}
6652
6653/// Builder for creating and running programs.
6654pub struct App;
6655
6656impl App {
6657    /// Create a new app builder with the given model.
6658    #[allow(clippy::new_ret_no_self)] // App is a namespace for builder methods
6659    pub fn new<M: Model>(model: M) -> AppBuilder<M> {
6660        AppBuilder {
6661            model,
6662            config: ProgramConfig::default(),
6663        }
6664    }
6665
6666    /// Create a fullscreen app.
6667    pub fn fullscreen<M: Model>(model: M) -> AppBuilder<M> {
6668        AppBuilder {
6669            model,
6670            config: ProgramConfig::fullscreen(),
6671        }
6672    }
6673
6674    /// Create an inline app with the given height.
6675    pub fn inline<M: Model>(model: M, height: u16) -> AppBuilder<M> {
6676        AppBuilder {
6677            model,
6678            config: ProgramConfig::inline(height),
6679        }
6680    }
6681
6682    /// Create an inline app with automatic UI height.
6683    pub fn inline_auto<M: Model>(model: M, min_height: u16, max_height: u16) -> AppBuilder<M> {
6684        AppBuilder {
6685            model,
6686            config: ProgramConfig::inline_auto(min_height, max_height),
6687        }
6688    }
6689
6690    /// Create a fullscreen app from a [`StringModel`](crate::string_model::StringModel).
6691    ///
6692    /// This wraps the string model in a [`StringModelAdapter`](crate::string_model::StringModelAdapter)
6693    /// so that `view_string()` output is rendered through the standard kernel pipeline.
6694    pub fn string_model<S: crate::string_model::StringModel>(
6695        model: S,
6696    ) -> AppBuilder<crate::string_model::StringModelAdapter<S>> {
6697        AppBuilder {
6698            model: crate::string_model::StringModelAdapter::new(model),
6699            config: ProgramConfig::fullscreen(),
6700        }
6701    }
6702}
6703
6704/// Builder for configuring and running programs.
6705#[must_use]
6706pub struct AppBuilder<M: Model> {
6707    model: M,
6708    config: ProgramConfig,
6709}
6710
6711impl<M: Model> AppBuilder<M> {
6712    /// Set the screen mode.
6713    pub fn screen_mode(mut self, mode: ScreenMode) -> Self {
6714        self.config.screen_mode = mode;
6715        self
6716    }
6717
6718    /// Set the UI anchor.
6719    pub fn anchor(mut self, anchor: UiAnchor) -> Self {
6720        self.config.ui_anchor = anchor;
6721        self
6722    }
6723
6724    /// Force mouse capture on.
6725    pub fn with_mouse(mut self) -> Self {
6726        self.config.mouse_capture_policy = MouseCapturePolicy::On;
6727        self
6728    }
6729
6730    /// Set mouse capture policy for this app.
6731    pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
6732        self.config.mouse_capture_policy = policy;
6733        self
6734    }
6735
6736    /// Force mouse capture enabled/disabled for this app.
6737    pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
6738        self.config.mouse_capture_policy = if enabled {
6739            MouseCapturePolicy::On
6740        } else {
6741            MouseCapturePolicy::Off
6742        };
6743        self
6744    }
6745
6746    /// Set the frame budget configuration.
6747    pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
6748        self.config.budget = budget;
6749        self
6750    }
6751
6752    /// Set the runtime load-governor configuration.
6753    pub fn with_load_governor(mut self, config: LoadGovernorConfig) -> Self {
6754        self.config.load_governor = config;
6755        self
6756    }
6757
6758    /// Disable the adaptive load governor for this app.
6759    pub fn without_load_governor(mut self) -> Self {
6760        self.config.load_governor = LoadGovernorConfig::disabled();
6761        self
6762    }
6763
6764    /// Set the evidence JSONL sink configuration.
6765    pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
6766        self.config.evidence_sink = config;
6767        self
6768    }
6769
6770    /// Set the render-trace recorder configuration.
6771    pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
6772        self.config.render_trace = config;
6773        self
6774    }
6775
6776    /// Set the widget refresh selection configuration.
6777    pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
6778        self.config.widget_refresh = config;
6779        self
6780    }
6781
6782    /// Set the effect queue scheduling configuration.
6783    pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
6784        self.config.effect_queue = config;
6785        self
6786    }
6787
6788    /// Enable inline auto UI height remeasurement.
6789    pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
6790        self.config.inline_auto_remeasure = Some(config);
6791        self
6792    }
6793
6794    /// Disable inline auto UI height remeasurement.
6795    pub fn without_inline_auto_remeasure(mut self) -> Self {
6796        self.config.inline_auto_remeasure = None;
6797        self
6798    }
6799
6800    /// Set the locale context used for rendering.
6801    pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
6802        self.config.locale_context = locale_context;
6803        self
6804    }
6805
6806    /// Set the base locale used for rendering.
6807    pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
6808        self.config.locale_context = LocaleContext::new(locale);
6809        self
6810    }
6811
6812    /// Set the resize coalescer configuration.
6813    pub fn resize_coalescer(mut self, config: CoalescerConfig) -> Self {
6814        self.config.resize_coalescer = config;
6815        self
6816    }
6817
6818    /// Set the resize handling behavior.
6819    pub fn resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
6820        self.config.resize_behavior = behavior;
6821        self
6822    }
6823
6824    /// Toggle legacy immediate-resize behavior for migration.
6825    pub fn legacy_resize(mut self, enabled: bool) -> Self {
6826        if enabled {
6827            self.config.resize_behavior = ResizeBehavior::Immediate;
6828        }
6829        self
6830    }
6831
6832    /// Set the tick strategy for selective background screen ticking.
6833    pub fn tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
6834        self.config.tick_strategy = Some(strategy);
6835        self
6836    }
6837
6838    /// Run the application using the legacy Crossterm backend.
6839    #[cfg(feature = "crossterm-compat")]
6840    pub fn run(self) -> io::Result<()>
6841    where
6842        M::Message: Send + 'static,
6843    {
6844        let mut program = Program::with_config(self.model, self.config)?;
6845        let result = program.run();
6846        if let Err(ref err) = result
6847            && let Some(signal) = signal_termination_from_error(err)
6848        {
6849            drop(program);
6850            std::process::exit(128 + signal);
6851        }
6852        result
6853    }
6854
6855    /// Run the application using the native TTY backend.
6856    #[cfg(all(feature = "native-backend", unix))]
6857    pub fn run_native(self) -> io::Result<()>
6858    where
6859        M::Message: Send + 'static,
6860    {
6861        let mut program = Program::with_native_backend(self.model, self.config)?;
6862        let result = program.run();
6863        if let Err(ref err) = result
6864            && let Some(signal) = signal_termination_from_error(err)
6865        {
6866            drop(program);
6867            std::process::exit(128 + signal);
6868        }
6869        result
6870    }
6871
6872    /// Run the application using the legacy Crossterm backend.
6873    #[cfg(not(feature = "crossterm-compat"))]
6874    pub fn run(self) -> io::Result<()>
6875    where
6876        M::Message: Send + 'static,
6877    {
6878        let _ = (self.model, self.config);
6879        Err(io::Error::new(
6880            io::ErrorKind::Unsupported,
6881            "enable `crossterm-compat` feature to use AppBuilder::run()",
6882        ))
6883    }
6884
6885    /// Run the application using the native TTY backend.
6886    ///
6887    /// On non-Unix targets the native backend is unavailable; call
6888    /// [`AppBuilder::run`] (with `crossterm-compat`) instead.
6889    #[cfg(any(not(feature = "native-backend"), not(unix)))]
6890    pub fn run_native(self) -> io::Result<()>
6891    where
6892        M::Message: Send + 'static,
6893    {
6894        let _ = (self.model, self.config);
6895        // Prefer the platform-level message: a Windows user without
6896        // `native-backend` enabled would otherwise be told to enable the
6897        // feature, only to discover after rebuilding that it's still
6898        // Unix-only. Pointing them at crossterm-compat up front avoids the
6899        // two-step debug.
6900        #[cfg(not(unix))]
6901        let msg = "AppBuilder::run_native() is Unix-only; use AppBuilder::run() (crossterm-compat) on this platform";
6902        #[cfg(all(unix, not(feature = "native-backend")))]
6903        let msg = "enable `native-backend` feature to use AppBuilder::run_native()";
6904        Err(io::Error::new(io::ErrorKind::Unsupported, msg))
6905    }
6906}
6907
6908// =============================================================================
6909// Adaptive Batch Window: Queueing Model (bd-4kq0.8.1)
6910// =============================================================================
6911//
6912// # M/G/1 Queueing Model for Event Batching
6913//
6914// ## Problem
6915//
6916// The event loop must balance two objectives:
6917// 1. **Low latency**: Process events quickly (small batch window τ).
6918// 2. **Efficiency**: Batch multiple events to amortize render cost (large τ).
6919//
6920// ## Model
6921//
6922// We model the event loop as an M/G/1 queue:
6923// - Events arrive at rate λ (Poisson process, reasonable for human input).
6924// - Service time S has mean E[S] and variance Var[S] (render + present).
6925// - Utilization ρ = λ·E[S] must be < 1 for stability.
6926//
6927// ## Pollaczek–Khinchine Mean Waiting Time
6928//
6929// For M/G/1: E[W] = (λ·E[S²]) / (2·(1 − ρ))
6930// where E[S²] = Var[S] + E[S]².
6931//
6932// ## Optimal Batch Window τ
6933//
6934// With batching window τ, we collect ~(λ·τ) events per batch, amortizing
6935// the per-frame render cost. The effective per-event latency is:
6936//
6937//   L(τ) = τ/2 + E[S]
6938//         (waiting in batch)  (service)
6939//
6940// The batch reduces arrival rate to λ_eff = 1/τ (one batch per window),
6941// giving utilization ρ_eff = E[S]/τ.
6942//
6943// Minimizing L(τ) subject to ρ_eff < 1:
6944//   L(τ) = τ/2 + E[S]
6945//   dL/dτ = 1/2  (always positive, so smaller τ is always better for latency)
6946//
6947// But we need ρ_eff < 1, so τ > E[S].
6948//
6949// The practical rule: τ = max(E[S] · headroom_factor, τ_min)
6950// where headroom_factor provides margin (typically 1.5–2.0).
6951//
6952// For high arrival rates: τ = max(E[S] · headroom, 1/λ_target)
6953// where λ_target is the max frame rate we want to sustain.
6954//
6955// ## Failure Modes
6956//
6957// 1. **Overload (ρ ≥ 1)**: Queue grows unbounded. Mitigation: increase τ
6958//    (degrade to lower frame rate), or drop stale events.
6959// 2. **Bursty arrivals**: Real input is bursty (typing, mouse drag). The
6960//    exponential moving average of λ smooths this; high burst periods
6961//    temporarily increase τ.
6962// 3. **Variable service time**: Render complexity varies per frame. Using
6963//    EMA of E[S] tracks this adaptively.
6964//
6965// ## Observable Telemetry
6966//
6967// - λ_est: Exponential moving average of inter-arrival times.
6968// - es_est: Exponential moving average of service (render) times.
6969// - ρ_est: λ_est × es_est (estimated utilization).
6970
6971/// Adaptive batch window controller based on M/G/1 queueing model.
6972///
6973/// Estimates arrival rate λ and service time `E[S]` from observations,
6974/// then computes the optimal batch window τ to maintain stability
6975/// (ρ < 1) while minimizing latency.
6976#[derive(Debug, Clone)]
6977pub struct BatchController {
6978    /// Exponential moving average of inter-arrival time (seconds).
6979    ema_inter_arrival_s: f64,
6980    /// Exponential moving average of service time (seconds).
6981    ema_service_s: f64,
6982    /// EMA smoothing factor (0..1). Higher = more responsive.
6983    alpha: f64,
6984    /// Minimum batch window (floor).
6985    tau_min_s: f64,
6986    /// Maximum batch window (cap for responsiveness).
6987    tau_max_s: f64,
6988    /// Headroom factor: τ >= E[S] × headroom to keep ρ < 1.
6989    headroom: f64,
6990    /// Last event arrival timestamp.
6991    last_arrival: Option<Instant>,
6992    /// Number of observations.
6993    observations: u64,
6994}
6995
6996impl BatchController {
6997    /// Create a new controller with sensible defaults.
6998    ///
6999    /// - `alpha`: EMA smoothing (default 0.2)
7000    /// - `tau_min`: minimum batch window (default 1ms)
7001    /// - `tau_max`: maximum batch window (default 50ms)
7002    /// - `headroom`: stability margin (default 2.0, keeps ρ ≤ 0.5)
7003    pub fn new() -> Self {
7004        Self {
7005            ema_inter_arrival_s: 0.1, // assume 10 events/sec initially
7006            ema_service_s: 0.002,     // assume 2ms render initially
7007            alpha: 0.2,
7008            tau_min_s: 0.001, // 1ms floor
7009            tau_max_s: 0.050, // 50ms cap
7010            headroom: 2.0,
7011            last_arrival: None,
7012            observations: 0,
7013        }
7014    }
7015
7016    /// Record an event arrival, updating the inter-arrival estimate.
7017    pub fn observe_arrival(&mut self, now: Instant) {
7018        if let Some(last) = self.last_arrival {
7019            let dt = now.saturating_duration_since(last).as_secs_f64();
7020            if dt > 0.0 && dt < 10.0 {
7021                // Guard against stale gaps (e.g., app was suspended)
7022                self.ema_inter_arrival_s =
7023                    self.alpha * dt + (1.0 - self.alpha) * self.ema_inter_arrival_s;
7024                self.observations += 1;
7025            }
7026        }
7027        self.last_arrival = Some(now);
7028    }
7029
7030    /// Record a service (render) time observation.
7031    pub fn observe_service(&mut self, duration: Duration) {
7032        let dt = duration.as_secs_f64();
7033        if (0.0..10.0).contains(&dt) {
7034            self.ema_service_s = self.alpha * dt + (1.0 - self.alpha) * self.ema_service_s;
7035        }
7036    }
7037
7038    /// Estimated arrival rate λ (events/second).
7039    #[inline]
7040    pub fn lambda_est(&self) -> f64 {
7041        if self.ema_inter_arrival_s > 0.0 {
7042            1.0 / self.ema_inter_arrival_s
7043        } else {
7044            0.0
7045        }
7046    }
7047
7048    /// Estimated service time `E[S]` (seconds).
7049    #[inline]
7050    pub fn service_est_s(&self) -> f64 {
7051        self.ema_service_s
7052    }
7053
7054    /// Estimated utilization ρ = λ × `E[S]`.
7055    #[inline]
7056    pub fn rho_est(&self) -> f64 {
7057        self.lambda_est() * self.ema_service_s
7058    }
7059
7060    /// Compute the optimal batch window τ (seconds).
7061    ///
7062    /// τ = clamp(`E[S]` × headroom, τ_min, τ_max)
7063    ///
7064    /// When ρ approaches 1, τ increases to maintain stability.
7065    pub fn tau_s(&self) -> f64 {
7066        let base = self.ema_service_s * self.headroom;
7067        base.clamp(self.tau_min_s, self.tau_max_s)
7068    }
7069
7070    /// Compute the optimal batch window as a Duration.
7071    pub fn tau(&self) -> Duration {
7072        Duration::from_secs_f64(self.tau_s())
7073    }
7074
7075    /// Check if the system is stable (ρ < 1).
7076    #[inline]
7077    pub fn is_stable(&self) -> bool {
7078        self.rho_est() < 1.0
7079    }
7080
7081    /// Number of observations recorded.
7082    #[inline]
7083    pub fn observations(&self) -> u64 {
7084        self.observations
7085    }
7086}
7087
7088impl Default for BatchController {
7089    fn default() -> Self {
7090        Self::new()
7091    }
7092}
7093
7094#[cfg(test)]
7095mod tests {
7096    use super::*;
7097    use ftui_core::terminal_capabilities::TerminalCapabilities;
7098    use ftui_layout::PaneDragResizeEffect;
7099    use ftui_render::buffer::Buffer;
7100    use ftui_render::cell::Cell;
7101    use ftui_render::diff_strategy::DiffStrategy;
7102    use ftui_render::frame::CostEstimateSource;
7103    use serde_json::Value;
7104    use std::collections::{HashMap, VecDeque};
7105    use std::path::PathBuf;
7106    use std::sync::mpsc;
7107    use std::sync::{
7108        Arc,
7109        atomic::{AtomicUsize, Ordering},
7110    };
7111
7112    // Simple test model
7113    struct TestModel {
7114        value: i32,
7115    }
7116
7117    #[derive(Debug)]
7118    enum TestMsg {
7119        Increment,
7120        Decrement,
7121        Quit,
7122    }
7123
7124    impl From<Event> for TestMsg {
7125        fn from(_event: Event) -> Self {
7126            TestMsg::Increment
7127        }
7128    }
7129
7130    impl Model for TestModel {
7131        type Message = TestMsg;
7132
7133        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7134            match msg {
7135                TestMsg::Increment => {
7136                    self.value += 1;
7137                    Cmd::none()
7138                }
7139                TestMsg::Decrement => {
7140                    self.value -= 1;
7141                    Cmd::none()
7142                }
7143                TestMsg::Quit => Cmd::quit(),
7144            }
7145        }
7146
7147        fn view(&self, _frame: &mut Frame) {
7148            // No-op for tests
7149        }
7150    }
7151
7152    #[test]
7153    fn cmd_none() {
7154        let cmd: Cmd<TestMsg> = Cmd::none();
7155        assert!(matches!(cmd, Cmd::None));
7156    }
7157
7158    #[test]
7159    fn cmd_quit() {
7160        let cmd: Cmd<TestMsg> = Cmd::quit();
7161        assert!(matches!(cmd, Cmd::Quit));
7162    }
7163
7164    #[test]
7165    fn cmd_msg() {
7166        let cmd: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
7167        assert!(matches!(cmd, Cmd::Msg(TestMsg::Increment)));
7168    }
7169
7170    #[test]
7171    fn cmd_batch_empty() {
7172        let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
7173        assert!(matches!(cmd, Cmd::None));
7174    }
7175
7176    #[test]
7177    fn cmd_batch_single() {
7178        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
7179        assert!(matches!(cmd, Cmd::Quit));
7180    }
7181
7182    #[test]
7183    fn cmd_batch_multiple() {
7184        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::quit()]);
7185        assert!(matches!(cmd, Cmd::Batch(_)));
7186    }
7187
7188    #[test]
7189    fn cmd_sequence_empty() {
7190        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
7191        assert!(matches!(cmd, Cmd::None));
7192    }
7193
7194    #[test]
7195    fn cmd_tick() {
7196        let cmd: Cmd<TestMsg> = Cmd::tick(Duration::from_millis(100));
7197        assert!(matches!(cmd, Cmd::Tick(_)));
7198    }
7199
7200    #[test]
7201    fn cmd_task() {
7202        let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
7203        assert!(matches!(cmd, Cmd::Task(..)));
7204    }
7205
7206    #[test]
7207    fn cmd_debug_format() {
7208        let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
7209        let debug = format!("{cmd:?}");
7210        assert_eq!(
7211            debug,
7212            "Task { spec: TaskSpec { weight: 1.0, estimate_ms: 10.0, name: None } }"
7213        );
7214    }
7215
7216    #[test]
7217    fn model_subscriptions_default_empty() {
7218        let model = TestModel { value: 0 };
7219        let subs = model.subscriptions();
7220        assert!(subs.is_empty());
7221    }
7222
7223    #[test]
7224    fn program_config_default() {
7225        let config = ProgramConfig::default();
7226        assert!(matches!(config.screen_mode, ScreenMode::Inline { .. }));
7227        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
7228        assert!(!config.resolved_mouse_capture());
7229        assert!(config.bracketed_paste);
7230        assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
7231        assert!(config.inline_auto_remeasure.is_none());
7232        assert!(config.conformal_config.is_none());
7233        assert!(config.diff_config.bayesian_enabled);
7234        assert!(config.diff_config.dirty_rows_enabled);
7235        assert!(!config.resize_coalescer.enable_bocpd);
7236        assert!(!config.effect_queue.enabled);
7237        assert_eq!(config.immediate_drain.max_zero_timeout_polls_per_burst, 64);
7238        assert_eq!(
7239            config.immediate_drain.max_burst_duration,
7240            Duration::from_millis(2)
7241        );
7242        assert_eq!(
7243            config.immediate_drain.backoff_timeout,
7244            Duration::from_millis(1)
7245        );
7246        assert_eq!(
7247            config.resize_coalescer.steady_delay_ms,
7248            CoalescerConfig::default().steady_delay_ms
7249        );
7250    }
7251
7252    #[test]
7253    fn program_config_with_immediate_drain() {
7254        let custom = ImmediateDrainConfig {
7255            max_zero_timeout_polls_per_burst: 7,
7256            max_burst_duration: Duration::from_millis(9),
7257            backoff_timeout: Duration::from_millis(3),
7258        };
7259        let config = ProgramConfig::default().with_immediate_drain(custom.clone());
7260        assert_eq!(
7261            config.immediate_drain.max_zero_timeout_polls_per_burst,
7262            custom.max_zero_timeout_polls_per_burst
7263        );
7264        assert_eq!(
7265            config.immediate_drain.max_burst_duration,
7266            custom.max_burst_duration
7267        );
7268        assert_eq!(
7269            config.immediate_drain.backoff_timeout,
7270            custom.backoff_timeout
7271        );
7272    }
7273
7274    #[test]
7275    fn program_config_fullscreen() {
7276        let config = ProgramConfig::fullscreen();
7277        assert!(matches!(config.screen_mode, ScreenMode::AltScreen));
7278    }
7279
7280    #[test]
7281    fn program_config_inline() {
7282        let config = ProgramConfig::inline(10);
7283        assert!(matches!(
7284            config.screen_mode,
7285            ScreenMode::Inline { ui_height: 10 }
7286        ));
7287    }
7288
7289    #[test]
7290    fn program_config_inline_auto() {
7291        let config = ProgramConfig::inline_auto(3, 9);
7292        assert!(matches!(
7293            config.screen_mode,
7294            ScreenMode::InlineAuto {
7295                min_height: 3,
7296                max_height: 9
7297            }
7298        ));
7299        assert!(config.inline_auto_remeasure.is_some());
7300    }
7301
7302    #[test]
7303    fn program_config_with_mouse() {
7304        let config = ProgramConfig::default().with_mouse();
7305        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
7306        assert!(config.resolved_mouse_capture());
7307    }
7308
7309    #[cfg(feature = "native-backend")]
7310    #[test]
7311    fn sanitize_backend_features_disables_unsupported_features() {
7312        let requested = BackendFeatures {
7313            mouse_capture: true,
7314            bracketed_paste: true,
7315            focus_events: true,
7316            kitty_keyboard: true,
7317        };
7318        let sanitized =
7319            sanitize_backend_features_for_capabilities(requested, &TerminalCapabilities::basic());
7320        assert_eq!(sanitized, BackendFeatures::default());
7321    }
7322
7323    #[cfg(feature = "native-backend")]
7324    #[test]
7325    fn sanitize_backend_features_is_conservative_in_wezterm_mux() {
7326        let requested = BackendFeatures {
7327            mouse_capture: true,
7328            bracketed_paste: true,
7329            focus_events: true,
7330            kitty_keyboard: true,
7331        };
7332        let caps = TerminalCapabilities::builder()
7333            .mouse_sgr(true)
7334            .bracketed_paste(true)
7335            .focus_events(true)
7336            .kitty_keyboard(true)
7337            .in_wezterm_mux(true)
7338            .build();
7339        let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
7340
7341        assert!(sanitized.mouse_capture);
7342        assert!(sanitized.bracketed_paste);
7343        assert!(!sanitized.focus_events);
7344        assert!(!sanitized.kitty_keyboard);
7345    }
7346
7347    #[cfg(feature = "native-backend")]
7348    #[test]
7349    fn sanitize_backend_features_is_conservative_in_tmux() {
7350        let requested = BackendFeatures {
7351            mouse_capture: true,
7352            bracketed_paste: true,
7353            focus_events: true,
7354            kitty_keyboard: true,
7355        };
7356        let caps = TerminalCapabilities::builder()
7357            .mouse_sgr(true)
7358            .bracketed_paste(true)
7359            .focus_events(true)
7360            .kitty_keyboard(true)
7361            .in_tmux(true)
7362            .build();
7363        let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
7364
7365        assert!(sanitized.mouse_capture);
7366        assert!(sanitized.bracketed_paste);
7367        assert!(!sanitized.focus_events);
7368        assert!(!sanitized.kitty_keyboard);
7369    }
7370
7371    #[test]
7372    fn program_config_mouse_policy_auto_altscreen() {
7373        let config = ProgramConfig::fullscreen();
7374        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
7375        assert!(config.resolved_mouse_capture());
7376    }
7377
7378    #[test]
7379    fn program_config_mouse_policy_force_off() {
7380        let config = ProgramConfig::fullscreen().with_mouse_capture_policy(MouseCapturePolicy::Off);
7381        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Off);
7382        assert!(!config.resolved_mouse_capture());
7383    }
7384
7385    #[test]
7386    fn program_config_mouse_policy_force_on_inline() {
7387        let config = ProgramConfig::inline(6).with_mouse_enabled(true);
7388        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
7389        assert!(config.resolved_mouse_capture());
7390    }
7391
7392    fn pane_target(axis: SplitAxis) -> PaneResizeTarget {
7393        PaneResizeTarget {
7394            split_id: ftui_layout::PaneId::MIN,
7395            axis,
7396        }
7397    }
7398
7399    fn pane_id(raw: u64) -> ftui_layout::PaneId {
7400        ftui_layout::PaneId::new(raw).expect("test pane id must be non-zero")
7401    }
7402
7403    fn nested_pane_tree() -> ftui_layout::PaneTree {
7404        let root = pane_id(1);
7405        let left = pane_id(2);
7406        let right_split = pane_id(3);
7407        let right_top = pane_id(4);
7408        let right_bottom = pane_id(5);
7409        let snapshot = ftui_layout::PaneTreeSnapshot {
7410            schema_version: ftui_layout::PANE_TREE_SCHEMA_VERSION,
7411            root,
7412            next_id: pane_id(6),
7413            nodes: vec![
7414                ftui_layout::PaneNodeRecord::split(
7415                    root,
7416                    None,
7417                    ftui_layout::PaneSplit {
7418                        axis: SplitAxis::Horizontal,
7419                        ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
7420                        first: left,
7421                        second: right_split,
7422                    },
7423                ),
7424                ftui_layout::PaneNodeRecord::leaf(
7425                    left,
7426                    Some(root),
7427                    ftui_layout::PaneLeaf::new("left"),
7428                ),
7429                ftui_layout::PaneNodeRecord::split(
7430                    right_split,
7431                    Some(root),
7432                    ftui_layout::PaneSplit {
7433                        axis: SplitAxis::Vertical,
7434                        ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
7435                        first: right_top,
7436                        second: right_bottom,
7437                    },
7438                ),
7439                ftui_layout::PaneNodeRecord::leaf(
7440                    right_top,
7441                    Some(right_split),
7442                    ftui_layout::PaneLeaf::new("right_top"),
7443                ),
7444                ftui_layout::PaneNodeRecord::leaf(
7445                    right_bottom,
7446                    Some(right_split),
7447                    ftui_layout::PaneLeaf::new("right_bottom"),
7448                ),
7449            ],
7450            extensions: std::collections::BTreeMap::new(),
7451        };
7452        ftui_layout::PaneTree::from_snapshot(snapshot).expect("valid nested pane tree")
7453    }
7454
7455    /// First-child share (in basis points) of a split node's ratio.
7456    fn root_first_share_bps(tree: &ftui_layout::PaneTree, split: ftui_layout::PaneId) -> u32 {
7457        match &tree.node(split).expect("split node present").kind {
7458            PaneNodeKind::Split(node) => {
7459                node.ratio.numerator() * 10_000
7460                    / (node.ratio.numerator() + node.ratio.denominator())
7461            }
7462            other => panic!("expected split node, got {other:?}"),
7463        }
7464    }
7465
7466    /// A fixed, timing-independent pressure-snap profile for deterministic tests.
7467    fn fixed_neutral_pressure() -> PanePressureSnapProfile {
7468        PanePressureSnapProfile {
7469            strength_bps: 5_000,
7470            hysteresis_bps: 100,
7471        }
7472    }
7473
7474    /// Apply a terminal dispatch's geometry-bearing transition to the live tree
7475    /// using a fixed, caller-supplied pressure profile.
7476    ///
7477    /// Unlike the realistic path (which derives pressure from pointer motion and
7478    /// therefore from wall-clock `speed`), this helper always uses `pressure`, so
7479    /// repeated runs of the same scripted event sequence are byte-for-byte
7480    /// deterministic. Returns the number of operations applied.
7481    fn apply_dispatch_fixed(
7482        tree: &mut ftui_layout::PaneTree,
7483        layout: &ftui_layout::PaneLayout,
7484        dispatch: &PaneTerminalDispatch,
7485        pressure: PanePressureSnapProfile,
7486        seed: &mut u64,
7487    ) -> usize {
7488        let Some(transition) = dispatch.primary_transition.as_ref() else {
7489            return 0;
7490        };
7491        let ops = tree.operations_for_transition(transition, layout, pressure);
7492        let applied = ops.len();
7493        for op in ops {
7494            tree.apply_operation(*seed, op).expect("operation applies");
7495            *seed += 1;
7496        }
7497        applied
7498    }
7499
7500    /// Drive a full scripted horizontal splitter drag through the live
7501    /// adapter -> bridge -> tree path with a fixed pressure profile. Press at
7502    /// `down_x`, then send Drag events for every position in `drag_xs` except the
7503    /// last, which is sent as the release (Up). Pointer x must increase across
7504    /// samples (the adapter tracks magnitude via saturating deltas). Returns the
7505    /// number of operations applied.
7506    #[allow(clippy::too_many_arguments)]
7507    fn drive_horizontal_drag_fixed(
7508        adapter: &mut PaneTerminalAdapter,
7509        tree: &mut ftui_layout::PaneTree,
7510        layout: &ftui_layout::PaneLayout,
7511        target: PaneResizeTarget,
7512        down_x: u16,
7513        y: u16,
7514        drag_xs: &[u16],
7515        pressure: PanePressureSnapProfile,
7516        seed: &mut u64,
7517    ) -> usize {
7518        let down = Event::Mouse(MouseEvent::new(
7519            MouseEventKind::Down(MouseButton::Left),
7520            down_x,
7521            y,
7522        ));
7523        let mut applied = apply_dispatch_fixed(
7524            tree,
7525            layout,
7526            &adapter.translate(&down, Some(target)),
7527            pressure,
7528            seed,
7529        );
7530
7531        let last = drag_xs.len().saturating_sub(1);
7532        for (idx, &x) in drag_xs.iter().enumerate() {
7533            let kind = if idx == last {
7534                MouseEventKind::Up(MouseButton::Left)
7535            } else {
7536                MouseEventKind::Drag(MouseButton::Left)
7537            };
7538            let event = Event::Mouse(MouseEvent::new(kind, x, y));
7539            applied += apply_dispatch_fixed(
7540                tree,
7541                layout,
7542                &adapter.translate(&event, None),
7543                pressure,
7544                seed,
7545            );
7546        }
7547        applied
7548    }
7549
7550    #[test]
7551    fn pane_terminal_splitter_resolution_is_deterministic() {
7552        let tree = nested_pane_tree();
7553        let layout = tree
7554            .solve_layout(Rect::new(0, 0, 50, 20))
7555            .expect("layout should solve");
7556        let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
7557        assert_eq!(handles.len(), 2);
7558
7559        // Intersection between root vertical splitter and right-side horizontal
7560        // splitter deterministically resolves to smaller split ID.
7561        let overlap = pane_terminal_resolve_splitter_target(&handles, 25, 10)
7562            .expect("overlap cell should resolve");
7563        assert_eq!(overlap.split_id, pane_id(1));
7564        assert_eq!(overlap.axis, SplitAxis::Horizontal);
7565
7566        let right_only = pane_terminal_resolve_splitter_target(&handles, 40, 10)
7567            .expect("right split should resolve");
7568        assert_eq!(right_only.split_id, pane_id(3));
7569        assert_eq!(right_only.axis, SplitAxis::Vertical);
7570    }
7571
7572    #[test]
7573    fn pane_terminal_splitter_hits_register_and_decode_target() {
7574        let tree = nested_pane_tree();
7575        let layout = tree
7576            .solve_layout(Rect::new(0, 0, 50, 20))
7577            .expect("layout should solve");
7578        let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
7579
7580        let mut pool = ftui_render::grapheme_pool::GraphemePool::new();
7581        let mut frame = Frame::with_hit_grid(50, 20, &mut pool);
7582        let registered = register_pane_terminal_splitter_hits(&mut frame, &handles, 9_000);
7583        assert_eq!(registered, handles.len());
7584
7585        let root_hit = frame
7586            .hit_test(25, 2)
7587            .expect("root splitter should be hittable");
7588        assert_eq!(root_hit.1, HitRegion::Handle);
7589        let root_target = pane_terminal_target_from_hit(root_hit).expect("target from hit");
7590        assert_eq!(root_target.split_id, pane_id(1));
7591        assert_eq!(root_target.axis, SplitAxis::Horizontal);
7592
7593        let right_hit = frame
7594            .hit_test(40, 10)
7595            .expect("right splitter should be hittable");
7596        assert_eq!(right_hit.1, HitRegion::Handle);
7597        let right_target = pane_terminal_target_from_hit(right_hit).expect("target from hit");
7598        assert_eq!(right_target.split_id, pane_id(3));
7599        assert_eq!(right_target.axis, SplitAxis::Vertical);
7600    }
7601
7602    #[test]
7603    fn pane_terminal_adapter_maps_basic_drag_lifecycle() {
7604        let mut adapter =
7605            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7606        let target = pane_target(SplitAxis::Horizontal);
7607
7608        let down = Event::Mouse(MouseEvent::new(
7609            MouseEventKind::Down(MouseButton::Left),
7610            10,
7611            4,
7612        ));
7613        let down_dispatch = adapter.translate(&down, Some(target));
7614        let down_event = down_dispatch
7615            .primary_event
7616            .as_ref()
7617            .expect("pointer down semantic event");
7618        assert_eq!(down_event.sequence, 1);
7619        assert!(matches!(
7620            down_event.kind,
7621            PaneSemanticInputEventKind::PointerDown {
7622                target: actual_target,
7623                pointer_id: 1,
7624                button: PanePointerButton::Primary,
7625                position
7626            } if actual_target == target && position == PanePointerPosition::new(10, 4)
7627        ));
7628        assert!(down_event.validate().is_ok());
7629
7630        let drag = Event::Mouse(MouseEvent::new(
7631            MouseEventKind::Drag(MouseButton::Left),
7632            14,
7633            4,
7634        ));
7635        let drag_dispatch = adapter.translate(&drag, None);
7636        let drag_event = drag_dispatch
7637            .primary_event
7638            .as_ref()
7639            .expect("pointer move semantic event");
7640        assert_eq!(drag_event.sequence, 2);
7641        assert!(matches!(
7642            drag_event.kind,
7643            PaneSemanticInputEventKind::PointerMove {
7644                target: actual_target,
7645                pointer_id: 1,
7646                position,
7647                delta_x: 4,
7648                delta_y: 0
7649            } if actual_target == target && position == PanePointerPosition::new(14, 4)
7650        ));
7651        let drag_motion = drag_dispatch
7652            .motion
7653            .expect("drag should emit motion metadata");
7654        assert_eq!(drag_motion.delta_x, 4);
7655        assert_eq!(drag_motion.delta_y, 0);
7656        assert_eq!(drag_motion.direction_changes, 0);
7657        assert!(drag_motion.speed > 0.0);
7658        assert!(drag_dispatch.pressure_snap_profile().is_some());
7659
7660        let up = Event::Mouse(MouseEvent::new(
7661            MouseEventKind::Up(MouseButton::Left),
7662            14,
7663            4,
7664        ));
7665        let up_dispatch = adapter.translate(&up, None);
7666        let up_event = up_dispatch
7667            .primary_event
7668            .as_ref()
7669            .expect("pointer up semantic event");
7670        assert_eq!(up_event.sequence, 3);
7671        assert!(matches!(
7672            up_event.kind,
7673            PaneSemanticInputEventKind::PointerUp {
7674                target: actual_target,
7675                pointer_id: 1,
7676                button: PanePointerButton::Primary,
7677                position
7678            } if actual_target == target && position == PanePointerPosition::new(14, 4)
7679        ));
7680        let up_motion = up_dispatch
7681            .motion
7682            .expect("up should emit final motion metadata");
7683        assert_eq!(up_motion.delta_x, 4);
7684        assert_eq!(up_motion.delta_y, 0);
7685        assert_eq!(up_motion.direction_changes, 0);
7686        let inertial_throw = up_dispatch
7687            .inertial_throw
7688            .expect("up should emit inertial throw metadata");
7689        assert_eq!(
7690            up_dispatch.projected_position,
7691            Some(inertial_throw.projected_pointer(PanePointerPosition::new(14, 4)))
7692        );
7693        assert_eq!(adapter.active_pointer_id(), None);
7694        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
7695    }
7696
7697    #[test]
7698    fn pane_terminal_adapter_drives_live_tree_mutation() {
7699        // End-to-end proof that the terminal adapter actually mutates the live
7700        // pane tree. Raw crossterm events flow through PaneTerminalAdapter into
7701        // PaneDragResizeTransition values, the layout bridge
7702        // (PaneTree::operations_for_transition) turns each geometry-bearing
7703        // transition into PaneOperation values, and apply_operation moves the
7704        // live root split ratio. This closes the historical gap where the
7705        // adapter emitted transitions that no production path consumed.
7706        fn apply_dispatch(
7707            tree: &mut ftui_layout::PaneTree,
7708            layout: &ftui_layout::PaneLayout,
7709            dispatch: &PaneTerminalDispatch,
7710            neutral: PanePressureSnapProfile,
7711            seed: &mut u64,
7712        ) -> usize {
7713            let Some(transition) = dispatch.primary_transition.as_ref() else {
7714                return 0;
7715            };
7716            let pressure = dispatch.pressure_snap_profile().unwrap_or(neutral);
7717            let ops = tree.operations_for_transition(transition, layout, pressure);
7718            let applied = ops.len();
7719            for op in ops {
7720                tree.apply_operation(*seed, op).expect("operation applies");
7721                *seed += 1;
7722            }
7723            applied
7724        }
7725
7726        let mut tree = nested_pane_tree();
7727        // The root split's own rectangle spans the whole viewport regardless of
7728        // its ratio, so a single solve is sufficient for targeting it.
7729        let layout = tree
7730            .solve_layout(Rect::new(0, 0, 50, 20))
7731            .expect("layout should solve");
7732        let root_split = pane_id(1);
7733        let target = PaneResizeTarget {
7734            split_id: root_split,
7735            axis: SplitAxis::Horizontal,
7736        };
7737        let neutral = PanePressureSnapProfile {
7738            strength_bps: 5_000,
7739            hysteresis_bps: 100,
7740        };
7741
7742        let before_share = root_first_share_bps(&tree, root_split);
7743
7744        let mut adapter =
7745            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7746        let mut op_seed = 7_000u64;
7747        let mut geometry_transitions = 0usize;
7748
7749        // Press on the splitter (arms the machine; no geometry yet).
7750        let down = Event::Mouse(MouseEvent::new(
7751            MouseEventKind::Down(MouseButton::Left),
7752            25,
7753            10,
7754        ));
7755        let down_dispatch = adapter.translate(&down, Some(target));
7756        geometry_transitions +=
7757            apply_dispatch(&mut tree, &layout, &down_dispatch, neutral, &mut op_seed);
7758
7759        // Drag rightward across cells, applying each transition to the tree.
7760        for x in [30u16, 36, 42] {
7761            let drag = Event::Mouse(MouseEvent::new(
7762                MouseEventKind::Drag(MouseButton::Left),
7763                x,
7764                10,
7765            ));
7766            let dispatch = adapter.translate(&drag, None);
7767            geometry_transitions +=
7768                apply_dispatch(&mut tree, &layout, &dispatch, neutral, &mut op_seed);
7769        }
7770
7771        // Release.
7772        let up = Event::Mouse(MouseEvent::new(
7773            MouseEventKind::Up(MouseButton::Left),
7774            42,
7775            10,
7776        ));
7777        let up_dispatch = adapter.translate(&up, None);
7778        geometry_transitions +=
7779            apply_dispatch(&mut tree, &layout, &up_dispatch, neutral, &mut op_seed);
7780
7781        assert!(
7782            geometry_transitions > 0,
7783            "drag lifecycle should yield at least one geometry-bearing transition"
7784        );
7785        let after_share = root_first_share_bps(&tree, root_split);
7786        assert!(
7787            after_share > before_share,
7788            "rightward splitter drag should grow the first child: after={after_share} before={before_share}"
7789        );
7790        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
7791    }
7792
7793    #[test]
7794    fn pane_terminal_adapter_resize_interrupt_cancels_drag_and_preserves_tree() {
7795        // Edge interruption: a terminal resize (SIGWINCH) arrives mid-drag. With
7796        // the default config (cancel_on_resize = true) the adapter must cancel the
7797        // in-flight gesture cleanly, leave the live tree valid and unmutated by the
7798        // interrupt itself, and remain reusable for a fresh gesture afterwards.
7799        let mut tree = nested_pane_tree();
7800        let layout = tree
7801            .solve_layout(Rect::new(0, 0, 50, 20))
7802            .expect("layout should solve");
7803        let root_split = pane_id(1);
7804        let target = PaneResizeTarget {
7805            split_id: root_split,
7806            axis: SplitAxis::Horizontal,
7807        };
7808        let pressure = fixed_neutral_pressure();
7809        let mut seed = 4_100u64;
7810
7811        let initial_hash = tree.state_hash();
7812
7813        let mut adapter =
7814            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7815
7816        // Arm + drag (without releasing) so a gesture is genuinely in flight.
7817        let _ = apply_dispatch_fixed(
7818            &mut tree,
7819            &layout,
7820            &adapter.translate(
7821                &Event::Mouse(MouseEvent::new(
7822                    MouseEventKind::Down(MouseButton::Left),
7823                    25,
7824                    10,
7825                )),
7826                Some(target),
7827            ),
7828            pressure,
7829            &mut seed,
7830        );
7831        for x in [30u16, 36] {
7832            let _ = apply_dispatch_fixed(
7833                &mut tree,
7834                &layout,
7835                &adapter.translate(
7836                    &Event::Mouse(MouseEvent::new(
7837                        MouseEventKind::Drag(MouseButton::Left),
7838                        x,
7839                        10,
7840                    )),
7841                    None,
7842                ),
7843                pressure,
7844                &mut seed,
7845            );
7846        }
7847        let mid_drag_hash = tree.state_hash();
7848        assert_ne!(
7849            mid_drag_hash, initial_hash,
7850            "drag should have mutated the tree before the interrupt"
7851        );
7852        assert!(tree.validate().is_ok());
7853        assert_eq!(adapter.active_pointer_id(), Some(1));
7854
7855        // Resize interrupt mid-drag.
7856        let resize_dispatch = adapter.translate(
7857            &Event::Resize {
7858                width: 60,
7859                height: 24,
7860            },
7861            None,
7862        );
7863        let cancel = resize_dispatch
7864            .primary_event
7865            .as_ref()
7866            .expect("resize interrupt should emit a cancel semantic event");
7867        assert!(matches!(
7868            cancel.kind,
7869            PaneSemanticInputEventKind::Cancel {
7870                target: Some(actual),
7871                reason: PaneCancelReason::Programmatic
7872            } if actual == target
7873        ));
7874        assert_eq!(
7875            resize_dispatch.log.phase,
7876            PaneTerminalLifecyclePhase::ResizeInterrupt
7877        );
7878        assert_eq!(
7879            resize_dispatch.log.outcome,
7880            PaneTerminalLogOutcome::SemanticForwarded
7881        );
7882
7883        // The cancel transition carries no geometry, so the bridge yields no ops
7884        // and the tree is left exactly as the last drag sample left it.
7885        let cancel_transition = resize_dispatch
7886            .primary_transition
7887            .as_ref()
7888            .expect("cancel transition present");
7889        let cancel_ops = tree.operations_for_transition(cancel_transition, &layout, pressure);
7890        assert!(
7891            cancel_ops.is_empty(),
7892            "cancel transition must not mutate the live tree"
7893        );
7894        assert_eq!(tree.state_hash(), mid_drag_hash);
7895        assert!(tree.validate().is_ok());
7896
7897        // Adapter is back to idle with no active pointer.
7898        assert_eq!(adapter.active_pointer_id(), None);
7899        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
7900
7901        // Reusable: a fresh gesture at the new viewport mutates the tree again.
7902        let new_layout = tree
7903            .solve_layout(Rect::new(0, 0, 60, 24))
7904            .expect("layout should solve at new size");
7905        let before_reuse = tree.state_hash();
7906        let applied = drive_horizontal_drag_fixed(
7907            &mut adapter,
7908            &mut tree,
7909            &new_layout,
7910            target,
7911            30,
7912            12,
7913            &[36, 42, 48],
7914            pressure,
7915            &mut seed,
7916        );
7917        assert!(applied > 0, "fresh gesture should apply operations");
7918        assert_ne!(
7919            tree.state_hash(),
7920            before_reuse,
7921            "adapter must remain usable after a resize interrupt"
7922        );
7923        assert!(tree.validate().is_ok());
7924        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
7925    }
7926
7927    #[test]
7928    fn pane_terminal_adapter_survives_resize_storm_and_is_deterministic() {
7929        // Resize-storm stability at the live-tree level. The default config
7930        // cancels in-flight gestures on resize (matching real SIGWINCH behavior),
7931        // so a storm rapidly re-grabs the splitter and re-drags across many
7932        // viewport sizes. After every step the tree must stay structurally valid
7933        // and within ratio bounds, and the whole scripted storm must be
7934        // byte-for-byte deterministic across repeated runs.
7935        fn run_storm() -> u64 {
7936            let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default())
7937                .expect("valid adapter");
7938            let mut tree = nested_pane_tree();
7939            let root_split = pane_id(1);
7940            let target = PaneResizeTarget {
7941                split_id: root_split,
7942                axis: SplitAxis::Horizontal,
7943            };
7944            let pressure = fixed_neutral_pressure();
7945            let mut seed = 5_200u64;
7946
7947            // Sizes the "window manager" cycles through during the storm.
7948            let sizes: [(u16, u16); 6] =
7949                [(50, 20), (80, 24), (40, 16), (120, 40), (30, 12), (100, 30)];
7950
7951            for (idx, &(w, h)) in sizes.iter().enumerate() {
7952                let layout = tree
7953                    .solve_layout(Rect::new(0, 0, w, h))
7954                    .expect("layout should solve under storm");
7955
7956                // Re-grab near 1/5 width and drag rightward to 3/5 width (always
7957                // increasing x, comfortably mid-rail to avoid child minimums).
7958                let down_x = (w / 5).max(2);
7959                let drag_x = (3 * w / 5).max(down_x + 2);
7960                let _ = apply_dispatch_fixed(
7961                    &mut tree,
7962                    &layout,
7963                    &adapter.translate(
7964                        &Event::Mouse(MouseEvent::new(
7965                            MouseEventKind::Down(MouseButton::Left),
7966                            down_x,
7967                            h / 2,
7968                        )),
7969                        Some(target),
7970                    ),
7971                    pressure,
7972                    &mut seed,
7973                );
7974                let _ = apply_dispatch_fixed(
7975                    &mut tree,
7976                    &layout,
7977                    &adapter.translate(
7978                        &Event::Mouse(MouseEvent::new(
7979                            MouseEventKind::Drag(MouseButton::Left),
7980                            drag_x,
7981                            h / 2,
7982                        )),
7983                        None,
7984                    ),
7985                    pressure,
7986                    &mut seed,
7987                );
7988                assert!(
7989                    tree.validate().is_ok(),
7990                    "tree invalid after drag at step {idx}"
7991                );
7992                let share = root_first_share_bps(&tree, root_split);
7993                assert!(
7994                    share > 0 && share < 10_000,
7995                    "split ratio escaped bounds at step {idx}: {share}"
7996                );
7997
7998                // Storm: a resize arrives mid-gesture and cancels it cleanly.
7999                let resize = adapter.translate(
8000                    &Event::Resize {
8001                        width: w,
8002                        height: h,
8003                    },
8004                    None,
8005                );
8006                if let Some(cancel_transition) = resize.primary_transition.as_ref() {
8007                    let cancel_ops =
8008                        tree.operations_for_transition(cancel_transition, &layout, pressure);
8009                    assert!(
8010                        cancel_ops.is_empty(),
8011                        "resize cancel must not mutate the tree at step {idx}"
8012                    );
8013                }
8014                assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8015                assert_eq!(adapter.active_pointer_id(), None);
8016                assert!(
8017                    tree.validate().is_ok(),
8018                    "tree invalid after resize at step {idx}"
8019                );
8020            }
8021
8022            // A final complete gesture (with a real release) settles the tree.
8023            let (w, h) = (90u16, 30u16);
8024            let layout = tree
8025                .solve_layout(Rect::new(0, 0, w, h))
8026                .expect("final layout should solve");
8027            let _ = drive_horizontal_drag_fixed(
8028                &mut adapter,
8029                &mut tree,
8030                &layout,
8031                target,
8032                (w / 5).max(2),
8033                h / 2,
8034                &[w / 2, 3 * w / 5],
8035                pressure,
8036                &mut seed,
8037            );
8038            assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8039            assert!(tree.validate().is_ok());
8040
8041            tree.state_hash()
8042        }
8043
8044        let first = run_storm();
8045        let second = run_storm();
8046        assert_eq!(
8047            first, second,
8048            "resize storm with repeated re-grabs must be deterministic"
8049        );
8050    }
8051
8052    #[test]
8053    fn pane_terminal_adapter_geometry_is_capability_invariant() {
8054        // Capability variance: the terminal adapter consumes already-parsed
8055        // `Event` values and makes no terminal-capability queries, so the same
8056        // logical drag must produce identical live-tree geometry whether the host
8057        // is a dumb terminal, a modern emulator, or tmux. We assert both halves:
8058        // (1) the capability profiles genuinely differ (non-vacuous), and (2) the
8059        // resulting tree state is identical across all three. This locks the
8060        // invariant that pane resize is capability-independent and would catch any
8061        // future regression that silently couples geometry to terminal caps.
8062        fn drag_under(
8063            over: ftui_core::capability_override::CapabilityOverride,
8064        ) -> (bool, bool, bool, u64) {
8065            ftui_core::capability_override::with_capability_override(over, || {
8066                let caps = ftui_core::capability_override::current_capabilities();
8067                let mut tree = nested_pane_tree();
8068                let layout = tree
8069                    .solve_layout(Rect::new(0, 0, 50, 20))
8070                    .expect("layout should solve");
8071                let target = PaneResizeTarget {
8072                    split_id: pane_id(1),
8073                    axis: SplitAxis::Horizontal,
8074                };
8075                let pressure = fixed_neutral_pressure();
8076                let mut seed = 6_300u64;
8077                let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default())
8078                    .expect("valid adapter");
8079                let applied = drive_horizontal_drag_fixed(
8080                    &mut adapter,
8081                    &mut tree,
8082                    &layout,
8083                    target,
8084                    25,
8085                    10,
8086                    &[30, 36, 42],
8087                    pressure,
8088                    &mut seed,
8089                );
8090                assert!(
8091                    applied > 0,
8092                    "drag should apply operations under every profile"
8093                );
8094                (
8095                    caps.mouse_sgr,
8096                    caps.true_color,
8097                    caps.in_tmux,
8098                    tree.state_hash(),
8099                )
8100            })
8101        }
8102
8103        let (dumb_sgr, dumb_truecolor, _dumb_tmux, dumb_hash) =
8104            drag_under(ftui_core::capability_override::CapabilityOverride::dumb());
8105        let (modern_sgr, modern_truecolor, modern_tmux, modern_hash) =
8106            drag_under(ftui_core::capability_override::CapabilityOverride::modern());
8107        let (_tmux_sgr, _tmux_truecolor, tmux_tmux, tmux_hash) =
8108            drag_under(ftui_core::capability_override::CapabilityOverride::tmux());
8109
8110        // Non-vacuous: the three profiles really do present different caps.
8111        assert!(
8112            dumb_sgr != modern_sgr || dumb_truecolor != modern_truecolor,
8113            "dumb and modern profiles must differ in capabilities"
8114        );
8115        assert!(
8116            modern_tmux != tmux_tmux,
8117            "modern and tmux profiles must differ in the in_tmux flag"
8118        );
8119
8120        // Invariant: identical geometry regardless of terminal capabilities.
8121        assert_eq!(
8122            dumb_hash, modern_hash,
8123            "geometry must not depend on terminal capabilities"
8124        );
8125        assert_eq!(
8126            modern_hash, tmux_hash,
8127            "geometry must not depend on terminal capabilities"
8128        );
8129    }
8130
8131    #[test]
8132    fn pane_terminal_adapter_dispatch_log_traces_routing_and_failures() {
8133        // Diagnostic logs must capture event routing (the lifecycle phase of each
8134        // translated event) and failure context (deterministic ignore reasons), so
8135        // operators can reconstruct what the adapter did from the dispatch log
8136        // alone. This exercises both the happy routing path and two failure paths.
8137        let mut adapter =
8138            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8139        let target = pane_target(SplitAxis::Horizontal);
8140
8141        // Failure path 1: wrong activation button is ignored with a precise reason.
8142        let wrong_button = adapter.translate(
8143            &Event::Mouse(MouseEvent::new(
8144                MouseEventKind::Down(MouseButton::Right),
8145                5,
8146                5,
8147            )),
8148            Some(target),
8149        );
8150        assert_eq!(
8151            wrong_button.log.phase,
8152            PaneTerminalLifecyclePhase::MouseDown
8153        );
8154        assert_eq!(
8155            wrong_button.log.outcome,
8156            PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::ActivationButtonRequired)
8157        );
8158        assert!(wrong_button.primary_event.is_none());
8159        assert_eq!(adapter.active_pointer_id(), None);
8160
8161        // Routing: a real gesture progresses MouseDown -> MouseDrag -> MouseUp,
8162        // each forwarded with a monotonically increasing sequence number.
8163        let down = adapter.translate(
8164            &Event::Mouse(MouseEvent::new(
8165                MouseEventKind::Down(MouseButton::Left),
8166                25,
8167                10,
8168            )),
8169            Some(target),
8170        );
8171        assert_eq!(down.log.phase, PaneTerminalLifecyclePhase::MouseDown);
8172        assert_eq!(down.log.outcome, PaneTerminalLogOutcome::SemanticForwarded);
8173        assert_eq!(down.log.target, Some(target));
8174        assert_eq!(down.log.pointer_id, Some(1));
8175        let down_seq = down.log.sequence.expect("down sequence");
8176
8177        let drag = adapter.translate(
8178            &Event::Mouse(MouseEvent::new(
8179                MouseEventKind::Drag(MouseButton::Left),
8180                31,
8181                10,
8182            )),
8183            None,
8184        );
8185        assert_eq!(drag.log.phase, PaneTerminalLifecyclePhase::MouseDrag);
8186        assert_eq!(drag.log.outcome, PaneTerminalLogOutcome::SemanticForwarded);
8187        let drag_seq = drag.log.sequence.expect("drag sequence");
8188        assert!(drag_seq > down_seq, "sequence numbers must be monotonic");
8189
8190        let up = adapter.translate(
8191            &Event::Mouse(MouseEvent::new(
8192                MouseEventKind::Up(MouseButton::Left),
8193                31,
8194                10,
8195            )),
8196            None,
8197        );
8198        assert_eq!(up.log.phase, PaneTerminalLifecyclePhase::MouseUp);
8199        assert_eq!(up.log.outcome, PaneTerminalLogOutcome::SemanticForwarded);
8200        assert!(up.log.sequence.expect("up sequence") > drag_seq);
8201
8202        // Failure path 2: a resize with no active gesture is a clean no-op with a
8203        // precise reason rather than a spurious cancel.
8204        let idle_resize = adapter.translate(
8205            &Event::Resize {
8206                width: 80,
8207                height: 24,
8208            },
8209            None,
8210        );
8211        assert_eq!(
8212            idle_resize.log.phase,
8213            PaneTerminalLifecyclePhase::ResizeInterrupt
8214        );
8215        assert_eq!(
8216            idle_resize.log.outcome,
8217            PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::ResizeNoop)
8218        );
8219        assert!(idle_resize.primary_event.is_none());
8220    }
8221
8222    #[test]
8223    fn pane_terminal_adapter_resize_preserves_gesture_when_cancel_disabled() {
8224        // Edge interruption, opposite branch: with cancel_on_resize disabled a
8225        // resize event is a deterministic no-op that leaves the active gesture
8226        // intact, so the drag can continue to completion afterwards.
8227        let config = PaneTerminalAdapterConfig {
8228            cancel_on_resize: false,
8229            ..PaneTerminalAdapterConfig::default()
8230        };
8231        let mut adapter = PaneTerminalAdapter::new(config).expect("valid adapter");
8232        let mut tree = nested_pane_tree();
8233        let layout = tree
8234            .solve_layout(Rect::new(0, 0, 50, 20))
8235            .expect("layout should solve");
8236        let root_split = pane_id(1);
8237        let target = PaneResizeTarget {
8238            split_id: root_split,
8239            axis: SplitAxis::Horizontal,
8240        };
8241        let pressure = fixed_neutral_pressure();
8242        let mut seed = 7_700u64;
8243
8244        // Arm + first drag sample.
8245        let _ = apply_dispatch_fixed(
8246            &mut tree,
8247            &layout,
8248            &adapter.translate(
8249                &Event::Mouse(MouseEvent::new(
8250                    MouseEventKind::Down(MouseButton::Left),
8251                    20,
8252                    10,
8253                )),
8254                Some(target),
8255            ),
8256            pressure,
8257            &mut seed,
8258        );
8259        let _ = apply_dispatch_fixed(
8260            &mut tree,
8261            &layout,
8262            &adapter.translate(
8263                &Event::Mouse(MouseEvent::new(
8264                    MouseEventKind::Drag(MouseButton::Left),
8265                    26,
8266                    10,
8267                )),
8268                None,
8269            ),
8270            pressure,
8271            &mut seed,
8272        );
8273        assert_eq!(adapter.active_pointer_id(), Some(1));
8274
8275        // Resize arrives mid-drag: ignored as a no-op, gesture survives.
8276        let resize = adapter.translate(
8277            &Event::Resize {
8278                width: 50,
8279                height: 20,
8280            },
8281            None,
8282        );
8283        assert!(resize.primary_event.is_none());
8284        assert!(resize.primary_transition.is_none());
8285        assert_eq!(
8286            resize.log.outcome,
8287            PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::ResizeNoop)
8288        );
8289        assert_eq!(
8290            adapter.active_pointer_id(),
8291            Some(1),
8292            "gesture must survive the resize when cancel_on_resize is disabled"
8293        );
8294
8295        // The drag continues and a release commits cleanly.
8296        let before_finish = tree.state_hash();
8297        for x in [32u16, 38] {
8298            let _ = apply_dispatch_fixed(
8299                &mut tree,
8300                &layout,
8301                &adapter.translate(
8302                    &Event::Mouse(MouseEvent::new(
8303                        MouseEventKind::Drag(MouseButton::Left),
8304                        x,
8305                        10,
8306                    )),
8307                    None,
8308                ),
8309                pressure,
8310                &mut seed,
8311            );
8312        }
8313        let _ = apply_dispatch_fixed(
8314            &mut tree,
8315            &layout,
8316            &adapter.translate(
8317                &Event::Mouse(MouseEvent::new(
8318                    MouseEventKind::Up(MouseButton::Left),
8319                    38,
8320                    10,
8321                )),
8322                None,
8323            ),
8324            pressure,
8325            &mut seed,
8326        );
8327        assert_ne!(
8328            tree.state_hash(),
8329            before_finish,
8330            "the surviving gesture should keep mutating the tree"
8331        );
8332        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8333        assert!(tree.validate().is_ok());
8334    }
8335
8336    #[test]
8337    fn pane_terminal_adapter_focus_loss_emits_cancel() {
8338        let mut adapter =
8339            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8340        let target = pane_target(SplitAxis::Vertical);
8341
8342        let down = Event::Mouse(MouseEvent::new(
8343            MouseEventKind::Down(MouseButton::Left),
8344            3,
8345            9,
8346        ));
8347        let _ = adapter.translate(&down, Some(target));
8348        assert_eq!(adapter.active_pointer_id(), Some(1));
8349
8350        let cancel_dispatch = adapter.translate(&Event::Focus(false), None);
8351        let cancel_event = cancel_dispatch
8352            .primary_event
8353            .as_ref()
8354            .expect("focus-loss cancel event");
8355        assert!(matches!(
8356            cancel_event.kind,
8357            PaneSemanticInputEventKind::Cancel {
8358                target: Some(actual_target),
8359                reason: PaneCancelReason::FocusLost
8360            } if actual_target == target
8361        ));
8362        assert_eq!(adapter.active_pointer_id(), None);
8363        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8364    }
8365
8366    #[test]
8367    fn pane_terminal_adapter_recovers_missing_mouse_up() {
8368        let mut adapter =
8369            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8370        let first_target = pane_target(SplitAxis::Horizontal);
8371        let second_target = pane_target(SplitAxis::Vertical);
8372
8373        let first_down = Event::Mouse(MouseEvent::new(
8374            MouseEventKind::Down(MouseButton::Left),
8375            5,
8376            5,
8377        ));
8378        let _ = adapter.translate(&first_down, Some(first_target));
8379
8380        let second_down = Event::Mouse(MouseEvent::new(
8381            MouseEventKind::Down(MouseButton::Left),
8382            8,
8383            11,
8384        ));
8385        let dispatch = adapter.translate(&second_down, Some(second_target));
8386        let recovery = dispatch
8387            .recovery_event
8388            .as_ref()
8389            .expect("recovery cancel expected");
8390        assert!(matches!(
8391            recovery.kind,
8392            PaneSemanticInputEventKind::Cancel {
8393                target: Some(actual_target),
8394                reason: PaneCancelReason::PointerCancel
8395            } if actual_target == first_target
8396        ));
8397        let primary = dispatch
8398            .primary_event
8399            .as_ref()
8400            .expect("second pointer down expected");
8401        assert!(matches!(
8402            primary.kind,
8403            PaneSemanticInputEventKind::PointerDown {
8404                target: actual_target,
8405                pointer_id: 1,
8406                button: PanePointerButton::Primary,
8407                position
8408            } if actual_target == second_target && position == PanePointerPosition::new(8, 11)
8409        ));
8410        assert_eq!(recovery.sequence, 2);
8411        assert_eq!(primary.sequence, 3);
8412        assert!(matches!(
8413            dispatch.log.outcome,
8414            PaneTerminalLogOutcome::SemanticForwardedAfterRecovery
8415        ));
8416        assert_eq!(dispatch.log.recovery_cancel_sequence, Some(2));
8417    }
8418
8419    #[test]
8420    fn pane_terminal_adapter_modifier_parity() {
8421        let mut adapter =
8422            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8423        let target = pane_target(SplitAxis::Horizontal);
8424
8425        let mouse = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 1, 2)
8426            .with_modifiers(Modifiers::SHIFT | Modifiers::ALT | Modifiers::CTRL | Modifiers::SUPER);
8427        let dispatch = adapter.translate(&Event::Mouse(mouse), Some(target));
8428        let event = dispatch.primary_event.expect("semantic event");
8429        assert!(event.modifiers.shift);
8430        assert!(event.modifiers.alt);
8431        assert!(event.modifiers.ctrl);
8432        assert!(event.modifiers.meta);
8433    }
8434
8435    #[test]
8436    fn pane_terminal_adapter_keyboard_resize_mapping() {
8437        let mut adapter =
8438            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8439        let target = pane_target(SplitAxis::Horizontal);
8440
8441        let key = KeyEvent::new(KeyCode::Right);
8442        let dispatch = adapter.translate(&Event::Key(key), Some(target));
8443        let event = dispatch.primary_event.expect("keyboard resize event");
8444        assert!(matches!(
8445            event.kind,
8446            PaneSemanticInputEventKind::KeyboardResize {
8447                target: actual_target,
8448                direction: PaneResizeDirection::Increase,
8449                units: 1
8450            } if actual_target == target
8451        ));
8452
8453        let shifted = KeyEvent::new(KeyCode::Right).with_modifiers(Modifiers::SHIFT);
8454        let shifted_dispatch = adapter.translate(&Event::Key(shifted), Some(target));
8455        let shifted_event = shifted_dispatch
8456            .primary_event
8457            .expect("shifted resize event");
8458        assert!(matches!(
8459            shifted_event.kind,
8460            PaneSemanticInputEventKind::KeyboardResize {
8461                direction: PaneResizeDirection::Increase,
8462                units: 5,
8463                ..
8464            }
8465        ));
8466        assert!(shifted_event.modifiers.shift);
8467    }
8468
8469    #[test]
8470    fn pane_terminal_adapter_keyboard_resize_requires_focus() {
8471        let mut adapter =
8472            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8473        let target = pane_target(SplitAxis::Horizontal);
8474
8475        let _ = adapter.translate(&Event::Focus(false), None);
8476        assert!(!adapter.window_focused());
8477
8478        let unfocused = adapter.translate(&Event::Key(KeyEvent::new(KeyCode::Right)), Some(target));
8479        assert!(unfocused.primary_event.is_none());
8480        assert!(matches!(
8481            unfocused.log.outcome,
8482            PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::WindowNotFocused)
8483        ));
8484
8485        let _ = adapter.translate(&Event::Focus(true), None);
8486        assert!(adapter.window_focused());
8487
8488        let focused = adapter.translate(&Event::Key(KeyEvent::new(KeyCode::Right)), Some(target));
8489        assert!(focused.primary_event.is_some());
8490    }
8491
8492    #[test]
8493    fn pane_terminal_adapter_drag_updates_are_coalesced() {
8494        let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig {
8495            drag_update_coalesce_distance: 2,
8496            ..PaneTerminalAdapterConfig::default()
8497        })
8498        .expect("valid adapter");
8499        let target = pane_target(SplitAxis::Horizontal);
8500
8501        let down = Event::Mouse(MouseEvent::new(
8502            MouseEventKind::Down(MouseButton::Left),
8503            10,
8504            4,
8505        ));
8506        let _ = adapter.translate(&down, Some(target));
8507
8508        let drag_start = Event::Mouse(MouseEvent::new(
8509            MouseEventKind::Drag(MouseButton::Left),
8510            14,
8511            4,
8512        ));
8513        let started = adapter.translate(&drag_start, None);
8514        assert!(started.primary_event.is_some());
8515        assert!(matches!(
8516            adapter.machine_state(),
8517            PaneDragResizeState::Dragging { .. }
8518        ));
8519
8520        let coalesced = Event::Mouse(MouseEvent::new(
8521            MouseEventKind::Drag(MouseButton::Left),
8522            15,
8523            4,
8524        ));
8525        let coalesced_dispatch = adapter.translate(&coalesced, None);
8526        assert!(coalesced_dispatch.primary_event.is_none());
8527        assert!(matches!(
8528            coalesced_dispatch.log.outcome,
8529            PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::DragCoalesced)
8530        ));
8531
8532        let forwarded = Event::Mouse(MouseEvent::new(
8533            MouseEventKind::Drag(MouseButton::Left),
8534            16,
8535            4,
8536        ));
8537        let forwarded_dispatch = adapter.translate(&forwarded, None);
8538        let forwarded_event = forwarded_dispatch
8539            .primary_event
8540            .as_ref()
8541            .expect("coalesced movement should flush once threshold reached");
8542        assert!(matches!(
8543            forwarded_event.kind,
8544            PaneSemanticInputEventKind::PointerMove {
8545                delta_x: 2,
8546                delta_y: 0,
8547                ..
8548            }
8549        ));
8550    }
8551
8552    #[test]
8553    fn pane_terminal_adapter_motion_tracks_direction_changes() {
8554        let mut adapter =
8555            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8556        let target = pane_target(SplitAxis::Horizontal);
8557
8558        let down = Event::Mouse(MouseEvent::new(
8559            MouseEventKind::Down(MouseButton::Left),
8560            10,
8561            4,
8562        ));
8563        let _ = adapter.translate(&down, Some(target));
8564
8565        let drag_forward = Event::Mouse(MouseEvent::new(
8566            MouseEventKind::Drag(MouseButton::Left),
8567            14,
8568            4,
8569        ));
8570        let forward_dispatch = adapter.translate(&drag_forward, None);
8571        let forward_motion = forward_dispatch
8572            .motion
8573            .expect("forward drag should emit motion metadata");
8574        assert_eq!(forward_motion.direction_changes, 0);
8575
8576        let drag_reverse = Event::Mouse(MouseEvent::new(
8577            MouseEventKind::Drag(MouseButton::Left),
8578            12,
8579            4,
8580        ));
8581        let reverse_dispatch = adapter.translate(&drag_reverse, None);
8582        let reverse_motion = reverse_dispatch
8583            .motion
8584            .expect("reverse drag should emit motion metadata");
8585        assert_eq!(reverse_motion.direction_changes, 1);
8586
8587        let up = Event::Mouse(MouseEvent::new(
8588            MouseEventKind::Up(MouseButton::Left),
8589            12,
8590            4,
8591        ));
8592        let up_dispatch = adapter.translate(&up, None);
8593        let up_motion = up_dispatch
8594            .motion
8595            .expect("release should include cumulative motion metadata");
8596        assert_eq!(up_motion.direction_changes, 1);
8597    }
8598
8599    #[test]
8600    fn pane_terminal_adapter_drag_and_moved_share_motion_bookkeeping() {
8601        // `Drag` and `Moved` forward identical `PointerMove` semantics through
8602        // the same `apply_pointer_motion` path; only the lifecycle phase differs.
8603        // Driving the identical motion sequence through each arm must therefore
8604        // produce identical motion metadata at every step (the extraction's
8605        // equivalence guarantee).
8606        let run = |moved: bool| {
8607            let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default())
8608                .expect("valid adapter");
8609            let target = pane_target(SplitAxis::Horizontal);
8610            let down = Event::Mouse(MouseEvent::new(
8611                MouseEventKind::Down(MouseButton::Left),
8612                10,
8613                4,
8614            ));
8615            let _ = adapter.translate(&down, Some(target));
8616            let mut motions = Vec::new();
8617            for (x, y) in [(14u16, 4u16), (12, 4), (16, 4)] {
8618                let kind = if moved {
8619                    MouseEventKind::Moved
8620                } else {
8621                    MouseEventKind::Drag(MouseButton::Left)
8622                };
8623                let dispatch = adapter.translate(&Event::Mouse(MouseEvent::new(kind, x, y)), None);
8624                assert!(dispatch.primary_event.is_some());
8625                motions.push(dispatch.motion.expect("motion metadata present"));
8626            }
8627            motions
8628        };
8629
8630        let via_drag = run(false);
8631        let via_moved = run(true);
8632        assert_eq!(via_drag, via_moved);
8633        // Sanity: the reverse step (14->12) registered a direction change in both.
8634        assert_eq!(via_drag[1].direction_changes, 1);
8635    }
8636
8637    #[test]
8638    fn pane_terminal_adapter_translate_with_handles_resolves_target() {
8639        let tree = nested_pane_tree();
8640        let layout = tree
8641            .solve_layout(Rect::new(0, 0, 50, 20))
8642            .expect("layout should solve");
8643        let handles =
8644            pane_terminal_splitter_handles(&tree, &layout, PANE_TERMINAL_DEFAULT_HIT_THICKNESS);
8645        let mut adapter =
8646            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8647
8648        let down = Event::Mouse(MouseEvent::new(
8649            MouseEventKind::Down(MouseButton::Left),
8650            25,
8651            10,
8652        ));
8653        let dispatch = adapter.translate_with_handles(&down, &handles);
8654        let event = dispatch
8655            .primary_event
8656            .as_ref()
8657            .expect("pointer down should be routed from handles");
8658        assert!(matches!(
8659            event.kind,
8660            PaneSemanticInputEventKind::PointerDown {
8661                target:
8662                    PaneResizeTarget {
8663                        split_id,
8664                        axis: SplitAxis::Horizontal
8665                    },
8666                ..
8667            } if split_id == pane_id(1)
8668        ));
8669    }
8670
8671    #[test]
8672    fn model_update() {
8673        let mut model = TestModel { value: 0 };
8674        model.update(TestMsg::Increment);
8675        assert_eq!(model.value, 1);
8676        model.update(TestMsg::Decrement);
8677        assert_eq!(model.value, 0);
8678        assert!(matches!(model.update(TestMsg::Quit), Cmd::Quit));
8679    }
8680
8681    #[test]
8682    fn model_init_default() {
8683        let mut model = TestModel { value: 0 };
8684        let cmd = model.init();
8685        assert!(matches!(cmd, Cmd::None));
8686    }
8687
8688    // Resize coalescer behavior is covered by resize_coalescer.rs tests.
8689
8690    // =========================================================================
8691    // DETERMINISM TESTS - Program loop determinism (bd-2nu8.10.1)
8692    // =========================================================================
8693
8694    #[test]
8695    fn cmd_sequence_executes_in_order() {
8696        // Verify that Cmd::Sequence executes commands in declared order
8697        use crate::simulator::ProgramSimulator;
8698
8699        struct SeqModel {
8700            trace: Vec<i32>,
8701        }
8702
8703        #[derive(Debug)]
8704        enum SeqMsg {
8705            Append(i32),
8706            TriggerSequence,
8707        }
8708
8709        impl From<Event> for SeqMsg {
8710            fn from(_: Event) -> Self {
8711                SeqMsg::Append(0)
8712            }
8713        }
8714
8715        impl Model for SeqModel {
8716            type Message = SeqMsg;
8717
8718            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8719                match msg {
8720                    SeqMsg::Append(n) => {
8721                        self.trace.push(n);
8722                        Cmd::none()
8723                    }
8724                    SeqMsg::TriggerSequence => Cmd::sequence(vec![
8725                        Cmd::msg(SeqMsg::Append(1)),
8726                        Cmd::msg(SeqMsg::Append(2)),
8727                        Cmd::msg(SeqMsg::Append(3)),
8728                    ]),
8729                }
8730            }
8731
8732            fn view(&self, _frame: &mut Frame) {}
8733        }
8734
8735        let mut sim = ProgramSimulator::new(SeqModel { trace: vec![] });
8736        sim.init();
8737        sim.send(SeqMsg::TriggerSequence);
8738
8739        assert_eq!(sim.model().trace, vec![1, 2, 3]);
8740    }
8741
8742    #[test]
8743    fn cmd_batch_executes_all_regardless_of_order() {
8744        // Verify that Cmd::Batch executes all commands
8745        use crate::simulator::ProgramSimulator;
8746
8747        struct BatchModel {
8748            values: Vec<i32>,
8749        }
8750
8751        #[derive(Debug)]
8752        enum BatchMsg {
8753            Add(i32),
8754            TriggerBatch,
8755        }
8756
8757        impl From<Event> for BatchMsg {
8758            fn from(_: Event) -> Self {
8759                BatchMsg::Add(0)
8760            }
8761        }
8762
8763        impl Model for BatchModel {
8764            type Message = BatchMsg;
8765
8766            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8767                match msg {
8768                    BatchMsg::Add(n) => {
8769                        self.values.push(n);
8770                        Cmd::none()
8771                    }
8772                    BatchMsg::TriggerBatch => Cmd::batch(vec![
8773                        Cmd::msg(BatchMsg::Add(10)),
8774                        Cmd::msg(BatchMsg::Add(20)),
8775                        Cmd::msg(BatchMsg::Add(30)),
8776                    ]),
8777                }
8778            }
8779
8780            fn view(&self, _frame: &mut Frame) {}
8781        }
8782
8783        let mut sim = ProgramSimulator::new(BatchModel { values: vec![] });
8784        sim.init();
8785        sim.send(BatchMsg::TriggerBatch);
8786
8787        // All values should be present
8788        assert_eq!(sim.model().values.len(), 3);
8789        assert!(sim.model().values.contains(&10));
8790        assert!(sim.model().values.contains(&20));
8791        assert!(sim.model().values.contains(&30));
8792    }
8793
8794    #[test]
8795    fn cmd_sequence_stops_on_quit() {
8796        // Verify that Cmd::Sequence stops processing after Quit
8797        use crate::simulator::ProgramSimulator;
8798
8799        struct SeqQuitModel {
8800            trace: Vec<i32>,
8801        }
8802
8803        #[derive(Debug)]
8804        enum SeqQuitMsg {
8805            Append(i32),
8806            TriggerSequenceWithQuit,
8807        }
8808
8809        impl From<Event> for SeqQuitMsg {
8810            fn from(_: Event) -> Self {
8811                SeqQuitMsg::Append(0)
8812            }
8813        }
8814
8815        impl Model for SeqQuitModel {
8816            type Message = SeqQuitMsg;
8817
8818            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8819                match msg {
8820                    SeqQuitMsg::Append(n) => {
8821                        self.trace.push(n);
8822                        Cmd::none()
8823                    }
8824                    SeqQuitMsg::TriggerSequenceWithQuit => Cmd::sequence(vec![
8825                        Cmd::msg(SeqQuitMsg::Append(1)),
8826                        Cmd::quit(),
8827                        Cmd::msg(SeqQuitMsg::Append(2)), // Should not execute
8828                    ]),
8829                }
8830            }
8831
8832            fn view(&self, _frame: &mut Frame) {}
8833        }
8834
8835        let mut sim = ProgramSimulator::new(SeqQuitModel { trace: vec![] });
8836        sim.init();
8837        sim.send(SeqQuitMsg::TriggerSequenceWithQuit);
8838
8839        assert_eq!(sim.model().trace, vec![1]);
8840        assert!(!sim.is_running());
8841    }
8842
8843    #[test]
8844    fn identical_input_produces_identical_state() {
8845        // Verify deterministic state transitions
8846        use crate::simulator::ProgramSimulator;
8847
8848        fn run_scenario() -> Vec<i32> {
8849            struct DetModel {
8850                values: Vec<i32>,
8851            }
8852
8853            #[derive(Debug, Clone)]
8854            enum DetMsg {
8855                Add(i32),
8856                Double,
8857            }
8858
8859            impl From<Event> for DetMsg {
8860                fn from(_: Event) -> Self {
8861                    DetMsg::Add(1)
8862                }
8863            }
8864
8865            impl Model for DetModel {
8866                type Message = DetMsg;
8867
8868                fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8869                    match msg {
8870                        DetMsg::Add(n) => {
8871                            self.values.push(n);
8872                            Cmd::none()
8873                        }
8874                        DetMsg::Double => {
8875                            if let Some(&last) = self.values.last() {
8876                                self.values.push(last * 2);
8877                            }
8878                            Cmd::none()
8879                        }
8880                    }
8881                }
8882
8883                fn view(&self, _frame: &mut Frame) {}
8884            }
8885
8886            let mut sim = ProgramSimulator::new(DetModel { values: vec![] });
8887            sim.init();
8888            sim.send(DetMsg::Add(5));
8889            sim.send(DetMsg::Double);
8890            sim.send(DetMsg::Add(3));
8891            sim.send(DetMsg::Double);
8892
8893            sim.model().values.clone()
8894        }
8895
8896        // Run the same scenario multiple times
8897        let run1 = run_scenario();
8898        let run2 = run_scenario();
8899        let run3 = run_scenario();
8900
8901        assert_eq!(run1, run2);
8902        assert_eq!(run2, run3);
8903        assert_eq!(run1, vec![5, 10, 3, 6]);
8904    }
8905
8906    #[test]
8907    fn identical_state_produces_identical_render() {
8908        // Verify consistent render outputs for identical inputs
8909        use crate::simulator::ProgramSimulator;
8910
8911        struct RenderModel {
8912            counter: i32,
8913        }
8914
8915        #[derive(Debug)]
8916        enum RenderMsg {
8917            Set(i32),
8918        }
8919
8920        impl From<Event> for RenderMsg {
8921            fn from(_: Event) -> Self {
8922                RenderMsg::Set(0)
8923            }
8924        }
8925
8926        impl Model for RenderModel {
8927            type Message = RenderMsg;
8928
8929            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8930                match msg {
8931                    RenderMsg::Set(n) => {
8932                        self.counter = n;
8933                        Cmd::none()
8934                    }
8935                }
8936            }
8937
8938            fn view(&self, frame: &mut Frame) {
8939                let text = format!("Value: {}", self.counter);
8940                for (i, c) in text.chars().enumerate() {
8941                    if (i as u16) < frame.width() {
8942                        use ftui_render::cell::Cell;
8943                        frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
8944                    }
8945                }
8946            }
8947        }
8948
8949        // Create two simulators with the same state
8950        let mut sim1 = ProgramSimulator::new(RenderModel { counter: 42 });
8951        let mut sim2 = ProgramSimulator::new(RenderModel { counter: 42 });
8952
8953        let buf1 = sim1.capture_frame(80, 24);
8954        let buf2 = sim2.capture_frame(80, 24);
8955
8956        // Compare buffer contents
8957        for y in 0..24 {
8958            for x in 0..80 {
8959                let cell1 = buf1.get(x, y).unwrap();
8960                let cell2 = buf2.get(x, y).unwrap();
8961                assert_eq!(
8962                    cell1.content.as_char(),
8963                    cell2.content.as_char(),
8964                    "Mismatch at ({}, {})",
8965                    x,
8966                    y
8967                );
8968            }
8969        }
8970    }
8971
8972    // Resize coalescer timing invariants are covered in resize_coalescer.rs tests.
8973
8974    #[test]
8975    fn cmd_log_creates_log_command() {
8976        let cmd: Cmd<TestMsg> = Cmd::log("test message");
8977        assert!(matches!(cmd, Cmd::Log(s) if s == "test message"));
8978    }
8979
8980    #[test]
8981    fn cmd_log_from_string() {
8982        let msg = String::from("dynamic message");
8983        let cmd: Cmd<TestMsg> = Cmd::log(msg);
8984        assert!(matches!(cmd, Cmd::Log(s) if s == "dynamic message"));
8985    }
8986
8987    #[test]
8988    fn program_simulator_logs_jsonl_with_seed_and_run_id() {
8989        // Ensure ProgramSimulator captures JSONL log lines with run_id/seed.
8990        use crate::simulator::ProgramSimulator;
8991
8992        struct LogModel {
8993            run_id: &'static str,
8994            seed: u64,
8995        }
8996
8997        #[derive(Debug)]
8998        enum LogMsg {
8999            Emit,
9000        }
9001
9002        impl From<Event> for LogMsg {
9003            fn from(_: Event) -> Self {
9004                LogMsg::Emit
9005            }
9006        }
9007
9008        impl Model for LogModel {
9009            type Message = LogMsg;
9010
9011            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
9012                let line = format!(
9013                    r#"{{"event":"test","run_id":"{}","seed":{}}}"#,
9014                    self.run_id, self.seed
9015                );
9016                Cmd::log(line)
9017            }
9018
9019            fn view(&self, _frame: &mut Frame) {}
9020        }
9021
9022        let mut sim = ProgramSimulator::new(LogModel {
9023            run_id: "test-run-001",
9024            seed: 4242,
9025        });
9026        sim.init();
9027        sim.send(LogMsg::Emit);
9028
9029        let logs = sim.logs();
9030        assert_eq!(logs.len(), 1);
9031        assert!(logs[0].contains(r#""run_id":"test-run-001""#));
9032        assert!(logs[0].contains(r#""seed":4242"#));
9033    }
9034
9035    #[test]
9036    fn cmd_sequence_single_unwraps() {
9037        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
9038        // Single element sequence should unwrap to the inner command
9039        assert!(matches!(cmd, Cmd::Quit));
9040    }
9041
9042    #[test]
9043    fn cmd_sequence_multiple() {
9044        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::quit()]);
9045        assert!(matches!(cmd, Cmd::Sequence(_)));
9046    }
9047
9048    #[test]
9049    fn cmd_default_is_none() {
9050        let cmd: Cmd<TestMsg> = Cmd::default();
9051        assert!(matches!(cmd, Cmd::None));
9052    }
9053
9054    #[test]
9055    fn cmd_debug_all_variants() {
9056        // Test Debug impl for all variants
9057        let none: Cmd<TestMsg> = Cmd::none();
9058        assert_eq!(format!("{none:?}"), "None");
9059
9060        let quit: Cmd<TestMsg> = Cmd::quit();
9061        assert_eq!(format!("{quit:?}"), "Quit");
9062
9063        let msg: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
9064        assert!(format!("{msg:?}").starts_with("Msg("));
9065
9066        let batch: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::none()]);
9067        assert!(format!("{batch:?}").starts_with("Batch("));
9068
9069        let seq: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::none()]);
9070        assert!(format!("{seq:?}").starts_with("Sequence("));
9071
9072        let tick: Cmd<TestMsg> = Cmd::tick(Duration::from_secs(1));
9073        assert!(format!("{tick:?}").starts_with("Tick("));
9074
9075        let log: Cmd<TestMsg> = Cmd::log("test");
9076        assert!(format!("{log:?}").starts_with("Log("));
9077    }
9078
9079    #[test]
9080    fn program_config_with_budget() {
9081        let budget = FrameBudgetConfig {
9082            total: Duration::from_millis(50),
9083            ..Default::default()
9084        };
9085        let config = ProgramConfig::default().with_budget(budget);
9086        assert_eq!(config.budget.total, Duration::from_millis(50));
9087    }
9088
9089    #[test]
9090    fn load_governor_default_is_enabled() {
9091        let config = LoadGovernorConfig::default();
9092        assert!(config.enabled);
9093        assert_eq!(
9094            config.budget_controller.degradation_floor,
9095            DegradationLevel::SimpleBorders
9096        );
9097    }
9098
9099    #[test]
9100    fn program_config_load_governor_builders() {
9101        let governor = LoadGovernorConfig::disabled().with_enabled(true);
9102        let config = ProgramConfig::default().with_load_governor(governor);
9103        assert!(config.load_governor.enabled);
9104
9105        let config = config.without_load_governor();
9106        assert!(!config.load_governor.enabled);
9107    }
9108
9109    fn governor_observation(
9110        frame_time_ms: f64,
9111        in_flight: u64,
9112        dropped: u64,
9113        degradation: DegradationLevel,
9114        resize_coalescing_active: bool,
9115        strict_semantics_violation: bool,
9116    ) -> LoadGovernorObservation {
9117        LoadGovernorObservation {
9118            frame_time_us: frame_time_ms * 1_000.0,
9119            budget_us: 16_000.0,
9120            degradation,
9121            queue: crate::effect_system::QueueTelemetry {
9122                // Invariant: in_flight = enqueued - processed (drops are
9123                // rejected before ever being counted as enqueued).
9124                enqueued: in_flight,
9125                processed: 0,
9126                dropped,
9127                high_water: in_flight,
9128                in_flight,
9129            },
9130            resize_coalescing_active,
9131            strict_semantics_violation,
9132        }
9133    }
9134
9135    fn test_load_governor(max_queue_depth: usize, recovery_intervals: u8) -> LoadGovernorState {
9136        let policy = LoadGovernorPolicy {
9137            recovery_intervals,
9138            ..Default::default()
9139        };
9140        LoadGovernorState::new(
9141            LoadGovernorConfig::enabled().with_policy(policy),
9142            max_queue_depth,
9143        )
9144    }
9145
9146    #[test]
9147    fn load_governor_policy_defaults_match_runtime_contract() {
9148        let policy = LoadGovernorPolicy::default().normalized();
9149
9150        assert_eq!(policy.stressed_queue_watermark, 0.5);
9151        assert_eq!(policy.degraded_queue_watermark, 0.8);
9152        assert_eq!(policy.recovery_queue_watermark, 0.25);
9153        assert_eq!(policy.recovery_intervals, 3);
9154        assert_eq!(policy.budget_overrun_soft_ratio, 1.0);
9155    }
9156
9157    #[test]
9158    fn load_governor_classifies_queue_watermarks_and_recovery() {
9159        let mut governor = test_load_governor(100, 2);
9160
9161        let steady = governor.observe(governor_observation(
9162            8.0,
9163            0,
9164            0,
9165            DegradationLevel::Full,
9166            false,
9167            false,
9168        ));
9169        assert_eq!(steady.mode, RuntimeLoadMode::Healthy);
9170        assert_eq!(steady.pressure_class, RuntimePressureClass::SteadyState);
9171        assert_eq!(steady.disposition, RuntimeWorkDisposition::AdmitAll);
9172
9173        let stressed = governor.observe(governor_observation(
9174            8.0,
9175            50,
9176            0,
9177            DegradationLevel::Full,
9178            false,
9179            false,
9180        ));
9181        assert_eq!(stressed.mode, RuntimeLoadMode::Stressed);
9182        assert_eq!(stressed.pressure_class, RuntimePressureClass::SoftOverload);
9183        assert_eq!(
9184            stressed.disposition,
9185            RuntimeWorkDisposition::CoalesceVisibleDeferBackground
9186        );
9187        assert_eq!(stressed.reason_code, "queue_stressed_watermark");
9188
9189        let degraded = governor.observe(governor_observation(
9190            8.0,
9191            80,
9192            0,
9193            DegradationLevel::Full,
9194            false,
9195            false,
9196        ));
9197        assert_eq!(degraded.mode, RuntimeLoadMode::Degraded);
9198        assert_eq!(degraded.pressure_class, RuntimePressureClass::HardOverload);
9199        assert_eq!(
9200            degraded.disposition,
9201            RuntimeWorkDisposition::DeferBackgroundDropBestEffort
9202        );
9203        assert_eq!(degraded.reason_code, "queue_degraded_watermark");
9204
9205        let recovery_pending = governor.observe(governor_observation(
9206            8.0,
9207            10,
9208            0,
9209            DegradationLevel::Full,
9210            false,
9211            false,
9212        ));
9213        assert_eq!(recovery_pending.mode, RuntimeLoadMode::Degraded);
9214        assert_eq!(recovery_pending.reason_code, "recovery_hysteresis_pending");
9215        assert_eq!(recovery_pending.recovery_intervals_observed, 1);
9216
9217        let recovered = governor.observe(governor_observation(
9218            8.0,
9219            10,
9220            0,
9221            DegradationLevel::Full,
9222            false,
9223            false,
9224        ));
9225        assert_eq!(recovered.mode, RuntimeLoadMode::Recovered);
9226        assert_eq!(recovered.reason_code, "recovery_hysteresis_satisfied");
9227        assert_eq!(
9228            recovered.disposition,
9229            RuntimeWorkDisposition::ReadmitAfterHysteresis
9230        );
9231
9232        let healthy = governor.observe(governor_observation(
9233            8.0,
9234            10,
9235            0,
9236            DegradationLevel::Full,
9237            false,
9238            false,
9239        ));
9240        assert_eq!(healthy.mode, RuntimeLoadMode::Healthy);
9241        assert_eq!(healthy.reason_code, "recovered_interval_closed");
9242    }
9243
9244    #[test]
9245    fn load_governor_uses_uncapped_budget_pressure_fallback() {
9246        let mut governor = test_load_governor(0, 2);
9247
9248        let stressed = governor.observe(governor_observation(
9249            20.0,
9250            0,
9251            0,
9252            DegradationLevel::Full,
9253            false,
9254            false,
9255        ));
9256        assert_eq!(stressed.mode, RuntimeLoadMode::Stressed);
9257        assert_eq!(stressed.reason_code, "frame_budget_overrun");
9258        assert_eq!(stressed.queue_max_depth, None);
9259
9260        let degraded = governor.observe(governor_observation(
9261            8.0,
9262            0,
9263            0,
9264            DegradationLevel::SimpleBorders,
9265            false,
9266            false,
9267        ));
9268        assert_eq!(degraded.mode, RuntimeLoadMode::Degraded);
9269        assert_eq!(degraded.reason_code, "budget_degradation_active");
9270    }
9271
9272    #[test]
9273    fn load_governor_strict_semantics_failure_is_terminal() {
9274        let mut governor = test_load_governor(100, 2);
9275
9276        let unsafe_snapshot = governor.observe(governor_observation(
9277            8.0,
9278            0,
9279            0,
9280            DegradationLevel::Full,
9281            false,
9282            true,
9283        ));
9284
9285        assert_eq!(unsafe_snapshot.mode, RuntimeLoadMode::Unsafe);
9286        assert_eq!(unsafe_snapshot.pressure_class, RuntimePressureClass::Unsafe);
9287        assert_eq!(
9288            unsafe_snapshot.disposition,
9289            RuntimeWorkDisposition::FailFastStrictGuarantee
9290        );
9291        assert!(!unsafe_snapshot.strict_semantics_preserved);
9292        assert_eq!(unsafe_snapshot.reason_code, "strict_semantics_violation");
9293    }
9294
9295    #[test]
9296    fn load_governor_unsafe_latches_through_later_pressure() {
9297        let mut governor = test_load_governor(100, 2);
9298
9299        // Enter Unsafe via a strict-semantics violation.
9300        let entered = governor.observe(governor_observation(
9301            8.0,
9302            0,
9303            0,
9304            DegradationLevel::Full,
9305            false,
9306            true,
9307        ));
9308        assert_eq!(entered.mode, RuntimeLoadMode::Unsafe);
9309
9310        // A later hard-overload interval (queue ratio >= degraded watermark) with
9311        // NO fresh violation must not downgrade the terminal Unsafe state.
9312        let hard = governor.observe(governor_observation(
9313            8.0,
9314            90,
9315            0,
9316            DegradationLevel::SimpleBorders,
9317            false,
9318            false,
9319        ));
9320        assert_eq!(hard.mode, RuntimeLoadMode::Unsafe);
9321        assert_eq!(
9322            hard.disposition,
9323            RuntimeWorkDisposition::FailFastStrictGuarantee
9324        );
9325        assert!(!hard.strict_semantics_preserved);
9326        assert_eq!(hard.reason_code, "strict_semantics_violation");
9327
9328        // A soft-overload interval (would otherwise classify as Stressed) must
9329        // also keep Unsafe latched rather than escaping downward.
9330        let soft = governor.observe(governor_observation(
9331            8.0,
9332            60,
9333            0,
9334            DegradationLevel::Full,
9335            true,
9336            false,
9337        ));
9338        assert_eq!(soft.mode, RuntimeLoadMode::Unsafe);
9339
9340        // And a fully steady interval never recovers out of Unsafe.
9341        let steady = governor.observe(governor_observation(
9342            8.0,
9343            0,
9344            0,
9345            DegradationLevel::Full,
9346            false,
9347            false,
9348        ));
9349        assert_eq!(steady.mode, RuntimeLoadMode::Unsafe);
9350        assert!(!steady.strict_semantics_preserved);
9351    }
9352
9353    #[test]
9354    fn load_governor_hysteresis_prevents_single_sample_recovery() {
9355        let mut governor = test_load_governor(100, 3);
9356
9357        governor.observe(governor_observation(
9358            8.0,
9359            90,
9360            0,
9361            DegradationLevel::Full,
9362            false,
9363            false,
9364        ));
9365
9366        for expected in 1..=2 {
9367            let snapshot = governor.observe(governor_observation(
9368                8.0,
9369                0,
9370                0,
9371                DegradationLevel::Full,
9372                false,
9373                false,
9374            ));
9375            assert_eq!(snapshot.mode, RuntimeLoadMode::Degraded);
9376            assert_eq!(snapshot.recovery_intervals_observed, expected);
9377            assert_eq!(snapshot.reason_code, "recovery_hysteresis_pending");
9378        }
9379
9380        let recovered = governor.observe(governor_observation(
9381            8.0,
9382            0,
9383            0,
9384            DegradationLevel::Full,
9385            false,
9386            false,
9387        ));
9388        assert_eq!(recovered.mode, RuntimeLoadMode::Recovered);
9389    }
9390
9391    #[test]
9392    fn load_governor_stress_e2e_evidence_shows_degraded_and_recovery() {
9393        let mut governor = test_load_governor(50, 2);
9394        let scenario = [
9395            governor_observation(8.0, 0, 0, DegradationLevel::Full, false, false),
9396            governor_observation(18.0, 25, 0, DegradationLevel::Full, true, false),
9397            governor_observation(22.0, 45, 1, DegradationLevel::SimpleBorders, true, false),
9398            governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
9399            governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
9400            governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
9401        ];
9402
9403        let mut modes = Vec::new();
9404        for observation in scenario {
9405            let snapshot = governor.observe(observation);
9406            modes.push(snapshot.mode);
9407            println!(
9408                "{{\"test\":\"load_governor_stress_e2e\",\"mode\":\"{}\",\"pressure_class\":\"{}\",\"work_disposition\":\"{}\",\"reason\":\"{}\",\"transition\":{},\"deferred\":{},\"coalesced\":{},\"dropped\":{}}}",
9409                snapshot.mode.as_str(),
9410                snapshot.pressure_class.as_str(),
9411                snapshot.disposition.as_str(),
9412                snapshot.reason_code,
9413                snapshot.transition,
9414                snapshot.deferred_work_total,
9415                snapshot.coalesced_work_total,
9416                snapshot.dropped_work_total
9417            );
9418        }
9419
9420        assert!(modes.contains(&RuntimeLoadMode::Stressed));
9421        assert!(modes.contains(&RuntimeLoadMode::Degraded));
9422        assert!(modes.contains(&RuntimeLoadMode::Recovered));
9423        assert_eq!(modes.last(), Some(&RuntimeLoadMode::Healthy));
9424    }
9425
9426    #[test]
9427    fn headless_program_default_load_governor_attaches_controller() {
9428        let program =
9429            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
9430        assert!(program.budget.controller().is_some());
9431    }
9432
9433    #[test]
9434    fn headless_program_load_governor_target_tracks_frame_budget() {
9435        let config = ProgramConfig::default().with_budget(FrameBudgetConfig {
9436            total: Duration::from_millis(50),
9437            ..Default::default()
9438        });
9439        let program = headless_program_with_config(TestModel { value: 0 }, config);
9440        assert_eq!(
9441            program.budget.controller().unwrap().config().target,
9442            Duration::from_millis(50)
9443        );
9444    }
9445
9446    #[test]
9447    fn headless_program_without_load_governor_uses_legacy_budget() {
9448        let program = headless_program_with_config(
9449            TestModel { value: 0 },
9450            ProgramConfig::default().without_load_governor(),
9451        );
9452        assert!(program.budget.controller().is_none());
9453    }
9454
9455    #[test]
9456    fn app_builder_without_load_governor_sets_config() {
9457        let builder = App::new(TestModel { value: 0 }).without_load_governor();
9458        assert!(!builder.config.load_governor.enabled);
9459    }
9460
9461    #[test]
9462    fn program_config_with_conformal() {
9463        let config = ProgramConfig::default().with_conformal_config(ConformalConfig {
9464            alpha: 0.2,
9465            ..Default::default()
9466        });
9467        assert!(config.conformal_config.is_some());
9468        assert!((config.conformal_config.as_ref().unwrap().alpha - 0.2).abs() < 1e-6);
9469    }
9470
9471    #[test]
9472    fn program_config_forced_size_clamps_minimums() {
9473        let config = ProgramConfig::default().with_forced_size(0, 0);
9474        assert_eq!(config.forced_size, Some((1, 1)));
9475
9476        let cleared = config.without_forced_size();
9477        assert!(cleared.forced_size.is_none());
9478    }
9479
9480    #[test]
9481    fn effect_queue_config_defaults_are_safe() {
9482        let config = EffectQueueConfig::default();
9483        assert!(!config.enabled);
9484        assert_eq!(config.backend, TaskExecutorBackend::Spawned);
9485        assert!(config.scheduler.smith_enabled);
9486        assert!(!config.scheduler.preemptive);
9487        assert_eq!(config.scheduler.aging_factor, 0.0);
9488        assert_eq!(config.scheduler.wait_starve_ms, 0.0);
9489    }
9490
9491    #[test]
9492    fn handle_effect_command_enqueues_or_executes_inline() {
9493        let (result_tx, result_rx) = mpsc::channel::<u32>();
9494        let mut scheduler = QueueingScheduler::new(EffectQueueConfig::default().scheduler);
9495        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9496
9497        let ran = Arc::new(AtomicUsize::new(0));
9498        let ran_task = ran.clone();
9499        let cmd = EffectCommand::Enqueue(
9500            TaskSpec::default(),
9501            Box::new(move || {
9502                ran_task.fetch_add(1, Ordering::SeqCst);
9503                7
9504            }),
9505        );
9506
9507        let shutdown = handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_tx, None, 0);
9508        assert_eq!(shutdown, EffectLoopControl::Continue);
9509        assert_eq!(ran.load(Ordering::SeqCst), 0);
9510        assert_eq!(tasks.len(), 1);
9511        assert!(result_rx.try_recv().is_err());
9512
9513        let mut full_scheduler = QueueingScheduler::new(SchedulerConfig {
9514            max_queue_size: 0,
9515            ..Default::default()
9516        });
9517        let mut full_tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9518        let ran_full = Arc::new(AtomicUsize::new(0));
9519        let ran_full_task = ran_full.clone();
9520        let cmd_full = EffectCommand::Enqueue(
9521            TaskSpec::default(),
9522            Box::new(move || {
9523                ran_full_task.fetch_add(1, Ordering::SeqCst);
9524                42
9525            }),
9526        );
9527
9528        let shutdown_full = handle_effect_command(
9529            cmd_full,
9530            &mut full_scheduler,
9531            &mut full_tasks,
9532            &result_tx,
9533            None,
9534            0,
9535        );
9536        assert_eq!(shutdown_full, EffectLoopControl::Continue);
9537        assert!(full_tasks.is_empty());
9538        assert_eq!(ran_full.load(Ordering::SeqCst), 1);
9539        assert_eq!(
9540            result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
9541            42
9542        );
9543
9544        let shutdown = handle_effect_command(
9545            EffectCommand::Shutdown,
9546            &mut full_scheduler,
9547            &mut full_tasks,
9548            &result_tx,
9549            None,
9550            0,
9551        );
9552        assert_eq!(shutdown, EffectLoopControl::ShutdownRequested);
9553    }
9554
9555    #[test]
9556    fn handle_effect_command_inline_fallback_writes_backpressure_evidence() {
9557        let evidence_path = temp_evidence_path("task_executor_backpressure");
9558        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
9559        let sink = EvidenceSink::from_config(&sink_config)
9560            .expect("evidence sink config")
9561            .expect("evidence sink enabled");
9562        let (result_tx, result_rx) = mpsc::channel::<u32>();
9563        let mut scheduler = QueueingScheduler::new(SchedulerConfig {
9564            max_queue_size: 0,
9565            ..Default::default()
9566        });
9567        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9568
9569        let shutdown = handle_effect_command(
9570            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 7)),
9571            &mut scheduler,
9572            &mut tasks,
9573            &result_tx,
9574            Some(&sink),
9575            0,
9576        );
9577
9578        assert_eq!(shutdown, EffectLoopControl::Continue);
9579        assert!(tasks.is_empty());
9580        assert_eq!(
9581            result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
9582            7
9583        );
9584
9585        let backpressure_line = read_evidence_event(&evidence_path, "task_executor_backpressure");
9586        assert_eq!(backpressure_line["backend"], "queued");
9587        assert_eq!(backpressure_line["action"], "inline_fallback");
9588        assert_eq!(backpressure_line["max_queue_size"], 0);
9589        assert_eq!(backpressure_line["total_rejected"], 1);
9590
9591        let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
9592        assert_eq!(completion_line["backend"], "queued-inline-fallback");
9593        assert!(completion_line["duration_us"].is_number());
9594    }
9595
9596    #[test]
9597    fn effect_queue_loop_executes_tasks_and_shutdowns() {
9598        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
9599        let (result_tx, result_rx) = mpsc::channel::<u32>();
9600        let config = EffectQueueConfig {
9601            enabled: true,
9602            backend: TaskExecutorBackend::EffectQueue,
9603            scheduler: SchedulerConfig {
9604                preemptive: false,
9605                ..Default::default()
9606            },
9607            explicit_backend: true,
9608            ..Default::default()
9609        };
9610
9611        let handle = std::thread::spawn(move || {
9612            effect_queue_loop(config, cmd_rx, result_tx, None);
9613        });
9614
9615        cmd_tx
9616            .send(EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 10)))
9617            .unwrap();
9618        cmd_tx
9619            .send(EffectCommand::Enqueue(
9620                TaskSpec::new(2.0, 5.0).with_name("second"),
9621                Box::new(|| 20),
9622            ))
9623            .unwrap();
9624
9625        let mut results = vec![
9626            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9627            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9628        ];
9629        results.sort_unstable();
9630        assert_eq!(results, vec![10, 20]);
9631
9632        cmd_tx.send(EffectCommand::Shutdown).unwrap();
9633        let _ = handle.join();
9634    }
9635
9636    #[test]
9637    fn effect_queue_loop_drains_queued_tasks_after_shutdown_request() {
9638        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
9639        let (result_tx, result_rx) = mpsc::channel::<u32>();
9640        let config = EffectQueueConfig {
9641            enabled: true,
9642            backend: TaskExecutorBackend::EffectQueue,
9643            scheduler: SchedulerConfig {
9644                preemptive: false,
9645                ..Default::default()
9646            },
9647            explicit_backend: true,
9648            ..Default::default()
9649        };
9650
9651        let handle = std::thread::spawn(move || {
9652            effect_queue_loop(config, cmd_rx, result_tx, None);
9653        });
9654
9655        cmd_tx
9656            .send(EffectCommand::Enqueue(
9657                TaskSpec::default().with_name("slow"),
9658                Box::new(|| {
9659                    std::thread::sleep(Duration::from_millis(20));
9660                    10
9661                }),
9662            ))
9663            .unwrap();
9664        cmd_tx
9665            .send(EffectCommand::Enqueue(
9666                TaskSpec::new(2.0, 5.0).with_name("fast"),
9667                Box::new(|| 20),
9668            ))
9669            .unwrap();
9670        cmd_tx.send(EffectCommand::Shutdown).unwrap();
9671
9672        let mut results = vec![
9673            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9674            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9675        ];
9676        results.sort_unstable();
9677        assert_eq!(results, vec![10, 20]);
9678
9679        handle
9680            .join()
9681            .expect("effect queue thread joins after draining");
9682    }
9683
9684    #[test]
9685    fn effect_queue_loop_survives_panicking_task_and_runs_later_work() {
9686        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
9687        let (result_tx, result_rx) = mpsc::channel::<u32>();
9688        let config = EffectQueueConfig {
9689            enabled: true,
9690            backend: TaskExecutorBackend::EffectQueue,
9691            scheduler: SchedulerConfig {
9692                preemptive: false,
9693                ..Default::default()
9694            },
9695            explicit_backend: true,
9696            ..Default::default()
9697        };
9698
9699        let handle = std::thread::spawn(move || {
9700            effect_queue_loop(config, cmd_rx, result_tx, None);
9701        });
9702
9703        cmd_tx
9704            .send(EffectCommand::Enqueue(
9705                TaskSpec::new(3.0, 1.0).with_name("panic"),
9706                Box::new(|| panic!("queued panic")),
9707            ))
9708            .unwrap();
9709        cmd_tx
9710            .send(EffectCommand::Enqueue(
9711                TaskSpec::new(1.0, 5.0).with_name("after"),
9712                Box::new(|| 99),
9713            ))
9714            .unwrap();
9715
9716        assert_eq!(
9717            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9718            99
9719        );
9720
9721        cmd_tx.send(EffectCommand::Shutdown).unwrap();
9722        handle
9723            .join()
9724            .expect("effect queue thread survives task panic");
9725    }
9726
9727    #[test]
9728    fn effect_queue_loop_rejects_tasks_submitted_after_shutdown_request() {
9729        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
9730        let (result_tx, result_rx) = mpsc::channel::<u32>();
9731        let config = EffectQueueConfig {
9732            enabled: true,
9733            backend: TaskExecutorBackend::EffectQueue,
9734            scheduler: SchedulerConfig {
9735                preemptive: false,
9736                ..Default::default()
9737            },
9738            explicit_backend: true,
9739            ..Default::default()
9740        };
9741
9742        let handle = std::thread::spawn(move || {
9743            effect_queue_loop(config, cmd_rx, result_tx, None);
9744        });
9745
9746        cmd_tx
9747            .send(EffectCommand::Enqueue(
9748                TaskSpec::default().with_name("slow"),
9749                Box::new(|| {
9750                    std::thread::sleep(Duration::from_millis(20));
9751                    10
9752                }),
9753            ))
9754            .unwrap();
9755        cmd_tx.send(EffectCommand::Shutdown).unwrap();
9756        cmd_tx
9757            .send(EffectCommand::Enqueue(
9758                TaskSpec::new(1.0, 1.0).with_name("late"),
9759                Box::new(|| 99),
9760            ))
9761            .unwrap();
9762
9763        assert_eq!(
9764            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9765            10
9766        );
9767        assert!(
9768            result_rx.recv_timeout(Duration::from_millis(100)).is_err(),
9769            "post-shutdown enqueue should not execute"
9770        );
9771
9772        handle
9773            .join()
9774            .expect("effect queue thread joins after rejecting post-shutdown work");
9775    }
9776
9777    #[test]
9778    fn effect_queue_enqueue_after_shutdown_records_drop() {
9779        let (tx, rx) = mpsc::channel::<EffectCommand<u32>>();
9780        drop(rx);
9781
9782        let queue = EffectQueue {
9783            sender: tx,
9784            handle: None,
9785            closed: true,
9786        };
9787        let runs = Arc::new(AtomicUsize::new(0));
9788        let before = crate::effect_system::effects_queue_dropped();
9789
9790        queue.enqueue(
9791            TaskSpec::default(),
9792            Box::new({
9793                let runs = Arc::clone(&runs);
9794                move || {
9795                    runs.fetch_add(1, Ordering::SeqCst);
9796                    7
9797                }
9798            }),
9799        );
9800
9801        let after = crate::effect_system::effects_queue_dropped();
9802        assert_eq!(runs.load(Ordering::SeqCst), 0);
9803        assert!(
9804            after > before,
9805            "enqueue after shutdown should increment dropped counter"
9806        );
9807    }
9808
9809    #[test]
9810    fn effect_queue_enqueue_with_closed_channel_records_drop() {
9811        let (tx, rx) = mpsc::channel::<EffectCommand<u32>>();
9812        drop(rx);
9813
9814        let queue = EffectQueue {
9815            sender: tx,
9816            handle: None,
9817            closed: false,
9818        };
9819        let runs = Arc::new(AtomicUsize::new(0));
9820        let before = crate::effect_system::effects_queue_dropped();
9821
9822        queue.enqueue(
9823            TaskSpec::default(),
9824            Box::new({
9825                let runs = Arc::clone(&runs);
9826                move || {
9827                    runs.fetch_add(1, Ordering::SeqCst);
9828                    9
9829                }
9830            }),
9831        );
9832
9833        let after = crate::effect_system::effects_queue_dropped();
9834        assert_eq!(runs.load(Ordering::SeqCst), 0);
9835        assert!(
9836            after > before,
9837            "enqueue into a closed queue channel should increment dropped counter"
9838        );
9839    }
9840
9841    // =========================================================================
9842    // Backpressure tests (bd-2zd0a)
9843    // =========================================================================
9844
9845    #[test]
9846    fn backpressure_drops_tasks_beyond_max_depth() {
9847        let (result_tx, _result_rx) = mpsc::channel::<u32>();
9848        let mut scheduler = QueueingScheduler::new(SchedulerConfig::default());
9849        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9850
9851        // Enqueue 2 tasks with max_depth=2 — should succeed
9852        let r1 = handle_effect_command(
9853            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 1)),
9854            &mut scheduler,
9855            &mut tasks,
9856            &result_tx,
9857            None,
9858            2,
9859        );
9860        assert_eq!(r1, EffectLoopControl::Continue);
9861        assert_eq!(tasks.len(), 1);
9862
9863        let r2 = handle_effect_command(
9864            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 2)),
9865            &mut scheduler,
9866            &mut tasks,
9867            &result_tx,
9868            None,
9869            2,
9870        );
9871        assert_eq!(r2, EffectLoopControl::Continue);
9872        assert_eq!(tasks.len(), 2);
9873
9874        // 3rd task should be dropped (depth=2 >= max_depth=2)
9875        let dropped_before = crate::effect_system::effects_queue_dropped();
9876        let r3 = handle_effect_command(
9877            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 3)),
9878            &mut scheduler,
9879            &mut tasks,
9880            &result_tx,
9881            None,
9882            2,
9883        );
9884        assert_eq!(r3, EffectLoopControl::Continue);
9885        assert_eq!(
9886            tasks.len(),
9887            2,
9888            "task should have been dropped, not enqueued"
9889        );
9890        assert!(
9891            crate::effect_system::effects_queue_dropped() > dropped_before,
9892            "dropped counter should increment"
9893        );
9894    }
9895
9896    #[test]
9897    fn backpressure_zero_depth_means_unbounded() {
9898        let (result_tx, _result_rx) = mpsc::channel::<u32>();
9899        let mut scheduler = QueueingScheduler::new(SchedulerConfig::default());
9900        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9901
9902        // With max_depth=0, can enqueue many tasks
9903        for i in 0..20 {
9904            let r = handle_effect_command(
9905                EffectCommand::Enqueue(TaskSpec::default(), Box::new(move || i)),
9906                &mut scheduler,
9907                &mut tasks,
9908                &result_tx,
9909                None,
9910                0,
9911            );
9912            assert_eq!(r, EffectLoopControl::Continue);
9913        }
9914        // All should be enqueued (some may have been inlined by scheduler, but none dropped)
9915    }
9916
9917    #[test]
9918    fn inline_auto_remeasure_reset_clears_decision() {
9919        let mut state = InlineAutoRemeasureState::new(InlineAutoRemeasureConfig::default());
9920        state.sampler.decide(Instant::now());
9921        assert!(state.sampler.last_decision().is_some());
9922
9923        state.reset();
9924        assert!(state.sampler.last_decision().is_none());
9925    }
9926
9927    #[test]
9928    fn budget_decision_jsonl_contains_required_fields() {
9929        let evidence = BudgetDecisionEvidence {
9930            frame_idx: 7,
9931            decision: BudgetDecision::Degrade,
9932            controller_decision: BudgetDecision::Hold,
9933            degradation_before: DegradationLevel::Full,
9934            degradation_after: DegradationLevel::NoStyling,
9935            frame_time_us: 12_345.678,
9936            budget_us: 16_000.0,
9937            pid_output: 1.25,
9938            pid_p: 0.5,
9939            pid_i: 0.25,
9940            pid_d: 0.5,
9941            e_value: 2.0,
9942            frames_observed: 42,
9943            frames_since_change: 3,
9944            in_warmup: false,
9945            controller_reason: BudgetDecisionReason::OverloadEvidencePassed,
9946            load_governor: LoadGovernorSnapshot {
9947                mode: RuntimeLoadMode::Degraded,
9948                mode_before: RuntimeLoadMode::Stressed,
9949                pressure_class: RuntimePressureClass::HardOverload,
9950                disposition: RuntimeWorkDisposition::DeferBackgroundDropBestEffort,
9951                reason_code: "budget_degradation_active",
9952                transition: true,
9953                strict_semantics_preserved: true,
9954                queue_in_flight: 8,
9955                queue_max_depth: Some(10),
9956                queue_dropped_delta: 0,
9957                resize_coalescing_active: false,
9958                recovery_intervals_observed: 0,
9959                recovery_intervals_required: 3,
9960                deferred_work_total: 2,
9961                coalesced_work_total: 1,
9962                dropped_work_total: 0,
9963            },
9964            conformal: Some(ConformalEvidence {
9965                bucket_key: "inline:dirty:10".to_string(),
9966                n_b: 32,
9967                alpha: 0.05,
9968                q_b: 1000.0,
9969                y_hat: 12_000.0,
9970                upper_us: 13_000.0,
9971                risk: true,
9972                fallback_level: 1,
9973                window_size: 256,
9974                reset_count: 2,
9975            }),
9976        };
9977
9978        let jsonl = evidence.to_jsonl();
9979        assert!(jsonl.contains("\"event\":\"budget_decision\""));
9980        assert!(jsonl.contains("\"decision\":\"degrade\""));
9981        assert!(jsonl.contains("\"decision_controller\":\"stay\""));
9982        assert!(jsonl.contains("\"decision_controller_reason\":\"overload_evidence_passed\""));
9983        assert!(jsonl.contains("\"degradation_before\":\"Full\""));
9984        assert!(jsonl.contains("\"degradation_after\":\"NoStyling\""));
9985        assert!(jsonl.contains("\"frame_time_us\":12345.678000"));
9986        assert!(jsonl.contains("\"budget_us\":16000.000000"));
9987        assert!(jsonl.contains("\"pid_output\":1.250000"));
9988        assert!(jsonl.contains("\"e_value\":2.000000"));
9989        assert!(jsonl.contains("\"runtime_mode\":\"degraded\""));
9990        assert!(jsonl.contains("\"runtime_mode_before\":\"stressed\""));
9991        assert!(jsonl.contains("\"pressure_class\":\"hard_overload\""));
9992        assert!(jsonl.contains("\"work_disposition\":\"defer_background_drop_best_effort\""));
9993        assert!(jsonl.contains("\"governor_reason\":\"budget_degradation_active\""));
9994        assert!(jsonl.contains("\"governor_transition\":true"));
9995        assert!(jsonl.contains("\"strict_semantics_preserved\":true"));
9996        assert!(jsonl.contains("\"queue_in_flight\":8"));
9997        assert!(jsonl.contains("\"queue_max_depth\":10"));
9998        assert!(jsonl.contains("\"deferred_work_total\":2"));
9999        assert!(jsonl.contains("\"bucket_key\":\"inline:dirty:10\""));
10000        assert!(jsonl.contains("\"n_b\":32"));
10001        assert!(jsonl.contains("\"alpha\":0.050000"));
10002        assert!(jsonl.contains("\"q_b\":1000.000000"));
10003        assert!(jsonl.contains("\"y_hat\":12000.000000"));
10004        assert!(jsonl.contains("\"upper_us\":13000.000000"));
10005        assert!(jsonl.contains("\"risk\":true"));
10006        assert!(jsonl.contains("\"fallback_level\":1"));
10007        assert!(jsonl.contains("\"window_size\":256"));
10008        assert!(jsonl.contains("\"reset_count\":2"));
10009    }
10010
10011    fn make_signal(
10012        widget_id: u64,
10013        essential: bool,
10014        priority: f32,
10015        staleness_ms: u64,
10016        cost_us: f32,
10017    ) -> WidgetSignal {
10018        WidgetSignal {
10019            widget_id,
10020            essential,
10021            priority,
10022            staleness_ms,
10023            focus_boost: 0.0,
10024            interaction_boost: 0.0,
10025            area_cells: 1,
10026            cost_estimate_us: cost_us,
10027            recent_cost_us: 0.0,
10028            estimate_source: CostEstimateSource::FixedDefault,
10029        }
10030    }
10031
10032    fn signal_value_cost(signal: &WidgetSignal, config: &WidgetRefreshConfig) -> (f32, f32, bool) {
10033        let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
10034        let staleness_window = config.staleness_window_ms.max(1) as f32;
10035        let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
10036        let mut value = config.weight_priority * signal.priority
10037            + config.weight_staleness * staleness_score
10038            + config.weight_focus * signal.focus_boost
10039            + config.weight_interaction * signal.interaction_boost;
10040        if starved {
10041            value += config.starve_boost;
10042        }
10043        let raw_cost = if signal.recent_cost_us > 0.0 {
10044            signal.recent_cost_us
10045        } else {
10046            signal.cost_estimate_us
10047        };
10048        let cost_us = raw_cost.max(config.min_cost_us);
10049        (value, cost_us, starved)
10050    }
10051
10052    fn fifo_select(
10053        signals: &[WidgetSignal],
10054        budget_us: f64,
10055        config: &WidgetRefreshConfig,
10056    ) -> (Vec<u64>, f64, usize) {
10057        let mut selected = Vec::new();
10058        let mut total_value = 0.0f64;
10059        let mut starved_selected = 0usize;
10060        let mut remaining = budget_us;
10061
10062        for signal in signals {
10063            if !signal.essential {
10064                continue;
10065            }
10066            let (value, cost_us, starved) = signal_value_cost(signal, config);
10067            remaining -= cost_us as f64;
10068            total_value += value as f64;
10069            if starved {
10070                starved_selected = starved_selected.saturating_add(1);
10071            }
10072            selected.push(signal.widget_id);
10073        }
10074        for signal in signals {
10075            if signal.essential {
10076                continue;
10077            }
10078            let (value, cost_us, starved) = signal_value_cost(signal, config);
10079            if remaining >= cost_us as f64 {
10080                remaining -= cost_us as f64;
10081                total_value += value as f64;
10082                if starved {
10083                    starved_selected = starved_selected.saturating_add(1);
10084                }
10085                selected.push(signal.widget_id);
10086            }
10087        }
10088
10089        (selected, total_value, starved_selected)
10090    }
10091
10092    fn rotate_signals(signals: &[WidgetSignal], offset: usize) -> Vec<WidgetSignal> {
10093        if signals.is_empty() {
10094            return Vec::new();
10095        }
10096        let mut rotated = Vec::with_capacity(signals.len());
10097        for idx in 0..signals.len() {
10098            rotated.push(signals[(idx + offset) % signals.len()].clone());
10099        }
10100        rotated
10101    }
10102
10103    #[test]
10104    fn widget_refresh_selects_essentials_first() {
10105        let signals = vec![
10106            make_signal(1, true, 0.6, 0, 5.0),
10107            make_signal(2, false, 0.9, 0, 4.0),
10108        ];
10109        let mut plan = WidgetRefreshPlan::new();
10110        let config = WidgetRefreshConfig::default();
10111        plan.recompute(1, 6.0, DegradationLevel::Full, &signals, &config);
10112        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
10113        assert_eq!(selected, vec![1]);
10114        assert!(!plan.over_budget);
10115    }
10116
10117    #[test]
10118    fn widget_refresh_degradation_essential_only_skips_nonessential() {
10119        let signals = vec![
10120            make_signal(1, true, 0.5, 0, 2.0),
10121            make_signal(2, false, 1.0, 0, 1.0),
10122        ];
10123        let mut plan = WidgetRefreshPlan::new();
10124        let config = WidgetRefreshConfig::default();
10125        plan.recompute(3, 10.0, DegradationLevel::EssentialOnly, &signals, &config);
10126        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
10127        assert_eq!(selected, vec![1]);
10128        assert_eq!(plan.skipped_count, 1);
10129    }
10130
10131    #[test]
10132    fn widget_refresh_starvation_guard_forces_one_starved() {
10133        let signals = vec![make_signal(7, false, 0.1, 10_000, 8.0)];
10134        let mut plan = WidgetRefreshPlan::new();
10135        let config = WidgetRefreshConfig {
10136            starve_ms: 1_000,
10137            max_starved_per_frame: 1,
10138            ..Default::default()
10139        };
10140        plan.recompute(5, 0.0, DegradationLevel::Full, &signals, &config);
10141        assert_eq!(plan.selected.len(), 1);
10142        assert!(plan.selected[0].starved);
10143        assert!(plan.over_budget);
10144    }
10145
10146    #[test]
10147    fn widget_refresh_budget_blocks_when_no_selection() {
10148        let signals = vec![make_signal(42, false, 0.2, 0, 10.0)];
10149        let mut plan = WidgetRefreshPlan::new();
10150        let config = WidgetRefreshConfig {
10151            starve_ms: 0,
10152            max_starved_per_frame: 0,
10153            ..Default::default()
10154        };
10155        plan.recompute(8, 0.0, DegradationLevel::Full, &signals, &config);
10156        let budget = plan.as_budget();
10157        assert!(!budget.allows(42, false));
10158    }
10159
10160    #[test]
10161    fn widget_refresh_max_drop_fraction_forces_minimum_refresh() {
10162        let signals = vec![
10163            make_signal(1, false, 0.4, 0, 10.0),
10164            make_signal(2, false, 0.4, 0, 10.0),
10165            make_signal(3, false, 0.4, 0, 10.0),
10166            make_signal(4, false, 0.4, 0, 10.0),
10167        ];
10168        let mut plan = WidgetRefreshPlan::new();
10169        let config = WidgetRefreshConfig {
10170            starve_ms: 0,
10171            max_starved_per_frame: 0,
10172            max_drop_fraction: 0.5,
10173            ..Default::default()
10174        };
10175        plan.recompute(12, 0.0, DegradationLevel::Full, &signals, &config);
10176        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
10177        assert_eq!(selected, vec![1, 2]);
10178    }
10179
10180    #[test]
10181    fn widget_refresh_greedy_beats_fifo_and_round_robin() {
10182        let signals = vec![
10183            make_signal(1, false, 0.1, 0, 6.0),
10184            make_signal(2, false, 0.2, 0, 6.0),
10185            make_signal(3, false, 1.0, 0, 4.0),
10186            make_signal(4, false, 0.9, 0, 3.0),
10187            make_signal(5, false, 0.8, 0, 3.0),
10188            make_signal(6, false, 0.1, 4_000, 2.0),
10189        ];
10190        let budget_us = 10.0;
10191        let config = WidgetRefreshConfig::default();
10192
10193        let mut plan = WidgetRefreshPlan::new();
10194        plan.recompute(21, budget_us, DegradationLevel::Full, &signals, &config);
10195        let greedy_value = plan.selected_value;
10196        let greedy_selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
10197
10198        let (fifo_selected, fifo_value, _fifo_starved) = fifo_select(&signals, budget_us, &config);
10199        let rotated = rotate_signals(&signals, 2);
10200        let (rr_selected, rr_value, _rr_starved) = fifo_select(&rotated, budget_us, &config);
10201
10202        assert!(
10203            greedy_value > fifo_value,
10204            "greedy_value={greedy_value:.3} <= fifo_value={fifo_value:.3}; greedy={:?}, fifo={:?}",
10205            greedy_selected,
10206            fifo_selected
10207        );
10208        assert!(
10209            greedy_value > rr_value,
10210            "greedy_value={greedy_value:.3} <= rr_value={rr_value:.3}; greedy={:?}, rr={:?}",
10211            greedy_selected,
10212            rr_selected
10213        );
10214        assert!(
10215            plan.starved_selected > 0,
10216            "greedy did not select starved widget; greedy={:?}",
10217            greedy_selected
10218        );
10219    }
10220
10221    #[test]
10222    fn widget_refresh_jsonl_contains_required_fields() {
10223        let signals = vec![make_signal(7, true, 0.2, 0, 2.0)];
10224        let mut plan = WidgetRefreshPlan::new();
10225        let config = WidgetRefreshConfig::default();
10226        plan.recompute(9, 4.0, DegradationLevel::Full, &signals, &config);
10227        let jsonl = plan.to_jsonl();
10228        assert!(jsonl.contains("\"event\":\"widget_refresh\""));
10229        assert!(jsonl.contains("\"frame_idx\":9"));
10230        assert!(jsonl.contains("\"selected_count\":1"));
10231        assert!(jsonl.contains("\"id\":7"));
10232    }
10233
10234    #[test]
10235    fn program_config_with_resize_coalescer() {
10236        let config = ProgramConfig::default().with_resize_coalescer(CoalescerConfig {
10237            steady_delay_ms: 8,
10238            burst_delay_ms: 20,
10239            hard_deadline_ms: 80,
10240            burst_enter_rate: 12.0,
10241            burst_exit_rate: 6.0,
10242            cooldown_frames: 2,
10243            rate_window_size: 6,
10244            enable_logging: true,
10245            enable_bocpd: false,
10246            bocpd_config: None,
10247        });
10248        assert_eq!(config.resize_coalescer.steady_delay_ms, 8);
10249        assert!(config.resize_coalescer.enable_logging);
10250    }
10251
10252    #[test]
10253    fn program_config_with_resize_behavior() {
10254        let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
10255        assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
10256    }
10257
10258    #[test]
10259    fn program_config_with_legacy_resize_enabled() {
10260        let config = ProgramConfig::default().with_legacy_resize(true);
10261        assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
10262    }
10263
10264    #[test]
10265    fn program_config_with_legacy_resize_disabled_keeps_default() {
10266        let config = ProgramConfig::default().with_legacy_resize(false);
10267        assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
10268    }
10269
10270    fn diff_strategy_trace(bayesian_enabled: bool) -> Vec<DiffStrategy> {
10271        let config = RuntimeDiffConfig::default().with_bayesian_enabled(bayesian_enabled);
10272        let mut writer = TerminalWriter::with_diff_config(
10273            Vec::<u8>::new(),
10274            ScreenMode::AltScreen,
10275            UiAnchor::Bottom,
10276            TerminalCapabilities::basic(),
10277            config,
10278        );
10279        writer.set_size(8, 4);
10280
10281        let mut buffer = Buffer::new(8, 4);
10282        let mut trace = Vec::new();
10283
10284        writer.present_ui(&buffer, None, false).unwrap();
10285        trace.push(
10286            writer
10287                .last_diff_strategy()
10288                .unwrap_or(DiffStrategy::FullRedraw),
10289        );
10290
10291        buffer.set_raw(0, 0, Cell::from_char('A'));
10292        writer.present_ui(&buffer, None, false).unwrap();
10293        trace.push(
10294            writer
10295                .last_diff_strategy()
10296                .unwrap_or(DiffStrategy::FullRedraw),
10297        );
10298
10299        buffer.set_raw(1, 1, Cell::from_char('B'));
10300        writer.present_ui(&buffer, None, false).unwrap();
10301        trace.push(
10302            writer
10303                .last_diff_strategy()
10304                .unwrap_or(DiffStrategy::FullRedraw),
10305        );
10306
10307        trace
10308    }
10309
10310    fn coalescer_checksum(enable_bocpd: bool) -> String {
10311        let mut config = CoalescerConfig::default().with_logging(true);
10312        if enable_bocpd {
10313            config = config.with_bocpd();
10314        }
10315
10316        let base = Instant::now();
10317        let mut coalescer = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
10318
10319        let events = [
10320            (0_u64, (82_u16, 24_u16)),
10321            (10, (83, 25)),
10322            (20, (84, 26)),
10323            (35, (90, 28)),
10324            (55, (92, 30)),
10325        ];
10326
10327        let mut idx = 0usize;
10328        for t_ms in (0_u64..=160).step_by(8) {
10329            let now = base + Duration::from_millis(t_ms);
10330            while idx < events.len() && events[idx].0 == t_ms {
10331                let (w, h) = events[idx].1;
10332                coalescer.handle_resize_at(w, h, now);
10333                idx += 1;
10334            }
10335            coalescer.tick_at(now);
10336        }
10337
10338        coalescer.decision_checksum_hex()
10339    }
10340
10341    fn conformal_trace(enabled: bool) -> Vec<(f64, bool)> {
10342        if !enabled {
10343            return Vec::new();
10344        }
10345
10346        let mut predictor = ConformalPredictor::new(ConformalConfig::default());
10347        let key = BucketKey::from_context(ScreenMode::AltScreen, DiffStrategy::Full, 80, 24);
10348        let mut trace = Vec::new();
10349
10350        for i in 0..30 {
10351            let y_hat = 16_000.0 + (i as f64) * 15.0;
10352            let observed = y_hat + (i % 7) as f64 * 120.0;
10353            predictor.observe(key, y_hat, observed);
10354            let prediction = predictor.predict(key, y_hat, 20_000.0);
10355            trace.push((prediction.upper_us, prediction.risk));
10356        }
10357
10358        trace
10359    }
10360
10361    #[test]
10362    fn policy_toggle_matrix_determinism() {
10363        for &bayesian in &[false, true] {
10364            for &bocpd in &[false, true] {
10365                for &conformal in &[false, true] {
10366                    let diff_a = diff_strategy_trace(bayesian);
10367                    let diff_b = diff_strategy_trace(bayesian);
10368                    assert_eq!(diff_a, diff_b, "diff strategy not deterministic");
10369
10370                    let checksum_a = coalescer_checksum(bocpd);
10371                    let checksum_b = coalescer_checksum(bocpd);
10372                    assert_eq!(checksum_a, checksum_b, "coalescer checksum mismatch");
10373
10374                    let conf_a = conformal_trace(conformal);
10375                    let conf_b = conformal_trace(conformal);
10376                    assert_eq!(conf_a, conf_b, "conformal predictor not deterministic");
10377
10378                    if conformal {
10379                        assert!(!conf_a.is_empty(), "conformal trace should be populated");
10380                    } else {
10381                        assert!(conf_a.is_empty(), "conformal trace should be empty");
10382                    }
10383                }
10384            }
10385        }
10386    }
10387
10388    #[test]
10389    fn resize_behavior_uses_coalescer_flag() {
10390        assert!(ResizeBehavior::Throttled.uses_coalescer());
10391        assert!(!ResizeBehavior::Immediate.uses_coalescer());
10392    }
10393
10394    #[test]
10395    fn nested_cmd_msg_executes_recursively() {
10396        // Verify that Cmd::Msg triggers recursive update
10397        use crate::simulator::ProgramSimulator;
10398
10399        struct NestedModel {
10400            depth: usize,
10401        }
10402
10403        #[derive(Debug)]
10404        enum NestedMsg {
10405            Nest(usize),
10406        }
10407
10408        impl From<Event> for NestedMsg {
10409            fn from(_: Event) -> Self {
10410                NestedMsg::Nest(0)
10411            }
10412        }
10413
10414        impl Model for NestedModel {
10415            type Message = NestedMsg;
10416
10417            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10418                match msg {
10419                    NestedMsg::Nest(n) => {
10420                        self.depth += 1;
10421                        if n > 0 {
10422                            Cmd::msg(NestedMsg::Nest(n - 1))
10423                        } else {
10424                            Cmd::none()
10425                        }
10426                    }
10427                }
10428            }
10429
10430            fn view(&self, _frame: &mut Frame) {}
10431        }
10432
10433        let mut sim = ProgramSimulator::new(NestedModel { depth: 0 });
10434        sim.init();
10435        sim.send(NestedMsg::Nest(3));
10436
10437        // Should have recursed 4 times (3, 2, 1, 0)
10438        assert_eq!(sim.model().depth, 4);
10439    }
10440
10441    #[test]
10442    fn task_executes_synchronously_in_simulator() {
10443        // In simulator, tasks execute synchronously
10444        use crate::simulator::ProgramSimulator;
10445
10446        struct TaskModel {
10447            completed: bool,
10448        }
10449
10450        #[derive(Debug)]
10451        enum TaskMsg {
10452            Complete,
10453            SpawnTask,
10454        }
10455
10456        impl From<Event> for TaskMsg {
10457            fn from(_: Event) -> Self {
10458                TaskMsg::Complete
10459            }
10460        }
10461
10462        impl Model for TaskModel {
10463            type Message = TaskMsg;
10464
10465            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10466                match msg {
10467                    TaskMsg::Complete => {
10468                        self.completed = true;
10469                        Cmd::none()
10470                    }
10471                    TaskMsg::SpawnTask => Cmd::task(|| TaskMsg::Complete),
10472                }
10473            }
10474
10475            fn view(&self, _frame: &mut Frame) {}
10476        }
10477
10478        let mut sim = ProgramSimulator::new(TaskModel { completed: false });
10479        sim.init();
10480        sim.send(TaskMsg::SpawnTask);
10481
10482        // Task should have completed synchronously
10483        assert!(sim.model().completed);
10484    }
10485
10486    #[test]
10487    fn multiple_updates_accumulate_correctly() {
10488        // Verify state accumulates correctly across multiple updates
10489        use crate::simulator::ProgramSimulator;
10490
10491        struct AccumModel {
10492            sum: i32,
10493        }
10494
10495        #[derive(Debug)]
10496        enum AccumMsg {
10497            Add(i32),
10498            Multiply(i32),
10499        }
10500
10501        impl From<Event> for AccumMsg {
10502            fn from(_: Event) -> Self {
10503                AccumMsg::Add(1)
10504            }
10505        }
10506
10507        impl Model for AccumModel {
10508            type Message = AccumMsg;
10509
10510            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10511                match msg {
10512                    AccumMsg::Add(n) => {
10513                        self.sum += n;
10514                        Cmd::none()
10515                    }
10516                    AccumMsg::Multiply(n) => {
10517                        self.sum *= n;
10518                        Cmd::none()
10519                    }
10520                }
10521            }
10522
10523            fn view(&self, _frame: &mut Frame) {}
10524        }
10525
10526        let mut sim = ProgramSimulator::new(AccumModel { sum: 0 });
10527        sim.init();
10528
10529        // (0 + 5) * 2 + 3 = 13
10530        sim.send(AccumMsg::Add(5));
10531        sim.send(AccumMsg::Multiply(2));
10532        sim.send(AccumMsg::Add(3));
10533
10534        assert_eq!(sim.model().sum, 13);
10535    }
10536
10537    #[test]
10538    fn init_command_executes_before_first_update() {
10539        // Verify init() command executes before any update
10540        use crate::simulator::ProgramSimulator;
10541
10542        struct InitModel {
10543            initialized: bool,
10544            updates: usize,
10545        }
10546
10547        #[derive(Debug)]
10548        enum InitMsg {
10549            Update,
10550            MarkInit,
10551        }
10552
10553        impl From<Event> for InitMsg {
10554            fn from(_: Event) -> Self {
10555                InitMsg::Update
10556            }
10557        }
10558
10559        impl Model for InitModel {
10560            type Message = InitMsg;
10561
10562            fn init(&mut self) -> Cmd<Self::Message> {
10563                Cmd::msg(InitMsg::MarkInit)
10564            }
10565
10566            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10567                match msg {
10568                    InitMsg::MarkInit => {
10569                        self.initialized = true;
10570                        Cmd::none()
10571                    }
10572                    InitMsg::Update => {
10573                        self.updates += 1;
10574                        Cmd::none()
10575                    }
10576                }
10577            }
10578
10579            fn view(&self, _frame: &mut Frame) {}
10580        }
10581
10582        let mut sim = ProgramSimulator::new(InitModel {
10583            initialized: false,
10584            updates: 0,
10585        });
10586        sim.init();
10587
10588        assert!(sim.model().initialized);
10589        sim.send(InitMsg::Update);
10590        assert_eq!(sim.model().updates, 1);
10591    }
10592
10593    // =========================================================================
10594    // INLINE MODE FRAME SIZING TESTS (bd-20vg)
10595    // =========================================================================
10596
10597    #[test]
10598    fn ui_height_returns_correct_value_inline_mode() {
10599        // Verify TerminalWriter.ui_height() returns ui_height in inline mode
10600        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
10601        use ftui_core::terminal_capabilities::TerminalCapabilities;
10602
10603        let output = Vec::new();
10604        let writer = TerminalWriter::new(
10605            output,
10606            ScreenMode::Inline { ui_height: 10 },
10607            UiAnchor::Bottom,
10608            TerminalCapabilities::basic(),
10609        );
10610        assert_eq!(writer.ui_height(), 10);
10611    }
10612
10613    #[test]
10614    fn ui_height_returns_term_height_altscreen_mode() {
10615        // Verify TerminalWriter.ui_height() returns full terminal height in alt-screen mode
10616        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
10617        use ftui_core::terminal_capabilities::TerminalCapabilities;
10618
10619        let output = Vec::new();
10620        let mut writer = TerminalWriter::new(
10621            output,
10622            ScreenMode::AltScreen,
10623            UiAnchor::Bottom,
10624            TerminalCapabilities::basic(),
10625        );
10626        writer.set_size(80, 24);
10627        assert_eq!(writer.ui_height(), 24);
10628    }
10629
10630    #[test]
10631    fn inline_mode_frame_uses_ui_height_not_terminal_height() {
10632        // Verify that in inline mode, the model receives a frame with ui_height,
10633        // not the full terminal height. This is the core fix for bd-20vg.
10634        use crate::simulator::ProgramSimulator;
10635        use std::cell::Cell as StdCell;
10636
10637        thread_local! {
10638            static CAPTURED_HEIGHT: StdCell<u16> = const { StdCell::new(0) };
10639        }
10640
10641        struct FrameSizeTracker;
10642
10643        #[derive(Debug)]
10644        enum SizeMsg {
10645            Check,
10646        }
10647
10648        impl From<Event> for SizeMsg {
10649            fn from(_: Event) -> Self {
10650                SizeMsg::Check
10651            }
10652        }
10653
10654        impl Model for FrameSizeTracker {
10655            type Message = SizeMsg;
10656
10657            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
10658                Cmd::none()
10659            }
10660
10661            fn view(&self, frame: &mut Frame) {
10662                // Capture the frame height we receive
10663                CAPTURED_HEIGHT.with(|h| h.set(frame.height()));
10664            }
10665        }
10666
10667        // Use simulator to verify frame dimension handling
10668        let mut sim = ProgramSimulator::new(FrameSizeTracker);
10669        sim.init();
10670
10671        // Capture with specific dimensions (simulates inline mode ui_height=10)
10672        let buf = sim.capture_frame(80, 10);
10673        assert_eq!(buf.height(), 10);
10674        assert_eq!(buf.width(), 80);
10675
10676        // Verify the frame has the correct dimensions
10677        // In inline mode with ui_height=10, the frame should be 10 rows tall,
10678        // NOT the full terminal height (e.g., 24).
10679    }
10680
10681    #[test]
10682    fn altscreen_frame_uses_full_terminal_height() {
10683        // Regression test: in alt-screen mode, frame should use full terminal height.
10684        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
10685        use ftui_core::terminal_capabilities::TerminalCapabilities;
10686
10687        let output = Vec::new();
10688        let mut writer = TerminalWriter::new(
10689            output,
10690            ScreenMode::AltScreen,
10691            UiAnchor::Bottom,
10692            TerminalCapabilities::basic(),
10693        );
10694        writer.set_size(80, 40);
10695
10696        // In alt-screen, ui_height equals terminal height
10697        assert_eq!(writer.ui_height(), 40);
10698    }
10699
10700    #[test]
10701    fn ui_height_clamped_to_terminal_height() {
10702        // Verify ui_height doesn't exceed terminal height
10703        // (This is handled in present_inline, but ui_height() returns the configured value)
10704        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
10705        use ftui_core::terminal_capabilities::TerminalCapabilities;
10706
10707        let output = Vec::new();
10708        let mut writer = TerminalWriter::new(
10709            output,
10710            ScreenMode::Inline { ui_height: 100 },
10711            UiAnchor::Bottom,
10712            TerminalCapabilities::basic(),
10713        );
10714        writer.set_size(80, 10);
10715
10716        // ui_height() returns configured value, but present_inline clamps
10717        // The Frame should be created with ui_height (100), which is later
10718        // clamped during presentation. For safety, we should use the min.
10719        // Note: This documents current behavior. A stricter fix might
10720        // have ui_height() return min(ui_height, term_height).
10721        assert_eq!(writer.ui_height(), 100);
10722    }
10723
10724    // =========================================================================
10725    // TICK DELIVERY TESTS (bd-3ufh)
10726    // =========================================================================
10727
10728    #[test]
10729    fn tick_event_delivered_to_model_update() {
10730        // Verify that Event::Tick is delivered to model.update()
10731        // This is the core fix: ticks now flow through the update pipeline.
10732        use crate::simulator::ProgramSimulator;
10733
10734        struct TickTracker {
10735            tick_count: usize,
10736        }
10737
10738        #[derive(Debug)]
10739        enum TickMsg {
10740            Tick,
10741            Other,
10742        }
10743
10744        impl From<Event> for TickMsg {
10745            fn from(event: Event) -> Self {
10746                match event {
10747                    Event::Tick => TickMsg::Tick,
10748                    _ => TickMsg::Other,
10749                }
10750            }
10751        }
10752
10753        impl Model for TickTracker {
10754            type Message = TickMsg;
10755
10756            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10757                match msg {
10758                    TickMsg::Tick => {
10759                        self.tick_count += 1;
10760                        Cmd::none()
10761                    }
10762                    TickMsg::Other => Cmd::none(),
10763                }
10764            }
10765
10766            fn view(&self, _frame: &mut Frame) {}
10767        }
10768
10769        let mut sim = ProgramSimulator::new(TickTracker { tick_count: 0 });
10770        sim.init();
10771
10772        // Manually inject tick event to simulate what the runtime does
10773        sim.inject_event(Event::Tick);
10774        assert_eq!(sim.model().tick_count, 1);
10775
10776        sim.inject_event(Event::Tick);
10777        sim.inject_event(Event::Tick);
10778        assert_eq!(sim.model().tick_count, 3);
10779    }
10780
10781    #[test]
10782    fn tick_command_sets_tick_rate() {
10783        // Verify Cmd::tick() sets the tick rate in the simulator
10784        use crate::simulator::{CmdRecord, ProgramSimulator};
10785
10786        struct TickModel;
10787
10788        #[derive(Debug)]
10789        enum Msg {
10790            SetTick,
10791            Noop,
10792        }
10793
10794        impl From<Event> for Msg {
10795            fn from(_: Event) -> Self {
10796                Msg::Noop
10797            }
10798        }
10799
10800        impl Model for TickModel {
10801            type Message = Msg;
10802
10803            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10804                match msg {
10805                    Msg::SetTick => Cmd::tick(Duration::from_millis(100)),
10806                    Msg::Noop => Cmd::none(),
10807                }
10808            }
10809
10810            fn view(&self, _frame: &mut Frame) {}
10811        }
10812
10813        let mut sim = ProgramSimulator::new(TickModel);
10814        sim.init();
10815        sim.send(Msg::SetTick);
10816
10817        // Check that tick was recorded
10818        let commands = sim.command_log();
10819        assert!(
10820            commands
10821                .iter()
10822                .any(|c| matches!(c, CmdRecord::Tick(d) if *d == Duration::from_millis(100)))
10823        );
10824    }
10825
10826    #[test]
10827    fn tick_can_trigger_further_commands() {
10828        // Verify that tick handling can return commands that are executed
10829        use crate::simulator::ProgramSimulator;
10830
10831        struct ChainModel {
10832            stage: usize,
10833        }
10834
10835        #[derive(Debug)]
10836        enum ChainMsg {
10837            Tick,
10838            Advance,
10839            Noop,
10840        }
10841
10842        impl From<Event> for ChainMsg {
10843            fn from(event: Event) -> Self {
10844                match event {
10845                    Event::Tick => ChainMsg::Tick,
10846                    _ => ChainMsg::Noop,
10847                }
10848            }
10849        }
10850
10851        impl Model for ChainModel {
10852            type Message = ChainMsg;
10853
10854            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10855                match msg {
10856                    ChainMsg::Tick => {
10857                        self.stage += 1;
10858                        // Return another message to be processed
10859                        Cmd::msg(ChainMsg::Advance)
10860                    }
10861                    ChainMsg::Advance => {
10862                        self.stage += 10;
10863                        Cmd::none()
10864                    }
10865                    ChainMsg::Noop => Cmd::none(),
10866                }
10867            }
10868
10869            fn view(&self, _frame: &mut Frame) {}
10870        }
10871
10872        let mut sim = ProgramSimulator::new(ChainModel { stage: 0 });
10873        sim.init();
10874        sim.inject_event(Event::Tick);
10875
10876        // Tick increments by 1, then Advance increments by 10
10877        assert_eq!(sim.model().stage, 11);
10878    }
10879
10880    #[test]
10881    fn tick_disabled_with_zero_duration() {
10882        // Verify that Duration::ZERO disables ticks (no busy loop)
10883        use crate::simulator::ProgramSimulator;
10884
10885        struct ZeroTickModel {
10886            disabled: bool,
10887        }
10888
10889        #[derive(Debug)]
10890        enum ZeroMsg {
10891            DisableTick,
10892            Noop,
10893        }
10894
10895        impl From<Event> for ZeroMsg {
10896            fn from(_: Event) -> Self {
10897                ZeroMsg::Noop
10898            }
10899        }
10900
10901        impl Model for ZeroTickModel {
10902            type Message = ZeroMsg;
10903
10904            fn init(&mut self) -> Cmd<Self::Message> {
10905                // Start with a tick enabled
10906                Cmd::tick(Duration::from_millis(100))
10907            }
10908
10909            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10910                match msg {
10911                    ZeroMsg::DisableTick => {
10912                        self.disabled = true;
10913                        // Setting tick to ZERO should effectively disable
10914                        Cmd::tick(Duration::ZERO)
10915                    }
10916                    ZeroMsg::Noop => Cmd::none(),
10917                }
10918            }
10919
10920            fn view(&self, _frame: &mut Frame) {}
10921        }
10922
10923        let mut sim = ProgramSimulator::new(ZeroTickModel { disabled: false });
10924        sim.init();
10925
10926        // Verify initial tick rate is set
10927        assert!(sim.tick_rate().is_some());
10928        assert_eq!(sim.tick_rate(), Some(Duration::from_millis(100)));
10929
10930        // Disable ticks
10931        sim.send(ZeroMsg::DisableTick);
10932        assert!(sim.model().disabled);
10933
10934        // Note: The simulator still records the ZERO tick, but the runtime's
10935        // should_tick() handles ZERO duration appropriately
10936        assert_eq!(sim.tick_rate(), Some(Duration::ZERO));
10937    }
10938
10939    #[test]
10940    fn tick_event_distinguishable_from_other_events() {
10941        // Verify Event::Tick can be distinguished in pattern matching
10942        let tick = Event::Tick;
10943        let key = Event::Key(ftui_core::event::KeyEvent::new(
10944            ftui_core::event::KeyCode::Char('a'),
10945        ));
10946
10947        assert!(matches!(tick, Event::Tick));
10948        assert!(!matches!(key, Event::Tick));
10949    }
10950
10951    #[test]
10952    fn tick_event_clone_and_eq() {
10953        // Verify Event::Tick implements Clone and Eq correctly
10954        let tick1 = Event::Tick;
10955        let tick2 = tick1.clone();
10956        assert_eq!(tick1, tick2);
10957    }
10958
10959    #[test]
10960    fn model_receives_tick_and_input_events() {
10961        // Verify model can handle both tick and input events correctly
10962        use crate::simulator::ProgramSimulator;
10963
10964        struct MixedModel {
10965            ticks: usize,
10966            keys: usize,
10967        }
10968
10969        #[derive(Debug)]
10970        enum MixedMsg {
10971            Tick,
10972            Key,
10973        }
10974
10975        impl From<Event> for MixedMsg {
10976            fn from(event: Event) -> Self {
10977                match event {
10978                    Event::Tick => MixedMsg::Tick,
10979                    _ => MixedMsg::Key,
10980                }
10981            }
10982        }
10983
10984        impl Model for MixedModel {
10985            type Message = MixedMsg;
10986
10987            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10988                match msg {
10989                    MixedMsg::Tick => {
10990                        self.ticks += 1;
10991                        Cmd::none()
10992                    }
10993                    MixedMsg::Key => {
10994                        self.keys += 1;
10995                        Cmd::none()
10996                    }
10997                }
10998            }
10999
11000            fn view(&self, _frame: &mut Frame) {}
11001        }
11002
11003        let mut sim = ProgramSimulator::new(MixedModel { ticks: 0, keys: 0 });
11004        sim.init();
11005
11006        // Interleave tick and input events
11007        sim.inject_event(Event::Tick);
11008        sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
11009            ftui_core::event::KeyCode::Char('a'),
11010        )));
11011        sim.inject_event(Event::Tick);
11012        sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
11013            ftui_core::event::KeyCode::Char('b'),
11014        )));
11015        sim.inject_event(Event::Tick);
11016
11017        assert_eq!(sim.model().ticks, 3);
11018        assert_eq!(sim.model().keys, 2);
11019    }
11020
11021    // =========================================================================
11022    // HEADLESS PROGRAM TESTS (bd-1av4o.2)
11023    // =========================================================================
11024
11025    fn headless_program_with_resolved_config<M: Model>(
11026        model: M,
11027        config: ProgramConfig,
11028    ) -> Program<M, HeadlessEventSource, Vec<u8>>
11029    where
11030        M::Message: Send + 'static,
11031    {
11032        clear_termination_signal();
11033        let effect_queue_config = config.resolved_effect_queue_config();
11034        let capabilities = TerminalCapabilities::basic();
11035        let mut writer = TerminalWriter::with_diff_config(
11036            Vec::new(),
11037            config.screen_mode,
11038            config.ui_anchor,
11039            capabilities,
11040            config.diff_config.clone(),
11041        );
11042        let frame_timing = config.frame_timing.clone();
11043        writer.set_timing_enabled(frame_timing.is_some());
11044
11045        let (width, height) = config.forced_size.unwrap_or((80, 24));
11046        let width = width.max(1);
11047        let height = height.max(1);
11048        writer.set_size(width, height);
11049
11050        let mouse_capture = config.resolved_mouse_capture();
11051        let initial_features = BackendFeatures {
11052            mouse_capture,
11053            bracketed_paste: config.bracketed_paste,
11054            focus_events: config.focus_reporting,
11055            kitty_keyboard: config.kitty_keyboard,
11056        };
11057        let events = HeadlessEventSource::new(width, height, initial_features);
11058        let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)
11059            .expect("headless evidence sink config");
11060
11061        let budget = render_budget_from_program_config(&config);
11062        let load_governor = LoadGovernorState::new(
11063            config.load_governor.clone(),
11064            effect_queue_config.max_queue_depth,
11065        );
11066        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
11067        let locale_context = config.locale_context.clone();
11068        let locale_version = locale_context.version();
11069        let mut resize_coalescer =
11070            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height));
11071        if let Some(ref sink) = evidence_sink {
11072            resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
11073        }
11074        let subscriptions = SubscriptionManager::new();
11075        let (task_sender, task_receiver) = std::sync::mpsc::channel();
11076        let inline_auto_remeasure = config
11077            .inline_auto_remeasure
11078            .clone()
11079            .map(InlineAutoRemeasureState::new);
11080        let guardrails = FrameGuardrails::new(config.guardrails);
11081        let task_executor = TaskExecutor::new(
11082            &effect_queue_config,
11083            task_sender.clone(),
11084            evidence_sink.clone(),
11085        )
11086        .expect("task executor");
11087
11088        Program {
11089            model,
11090            writer,
11091            events,
11092            backend_features: initial_features,
11093            running: true,
11094            tick_rate: None,
11095            executed_cmd_count: 0,
11096            last_tick: Instant::now(),
11097            dirty: true,
11098            frame_idx: 0,
11099            tick_count: 0,
11100            widget_signals: Vec::new(),
11101            widget_refresh_config: config.widget_refresh,
11102            widget_refresh_plan: WidgetRefreshPlan::new(),
11103            width,
11104            height,
11105            forced_size: config.forced_size,
11106            poll_timeout: config.poll_timeout,
11107            intercept_signals: config.intercept_signals,
11108            immediate_drain_config: config.immediate_drain,
11109            immediate_drain_stats: ImmediateDrainStats::default(),
11110            budget,
11111            load_governor,
11112            conformal_predictor,
11113            last_frame_time_us: None,
11114            last_update_us: None,
11115            frame_timing,
11116            locale_context,
11117            locale_version,
11118            resize_coalescer,
11119            evidence_sink,
11120            fairness_config_logged: false,
11121            resize_behavior: config.resize_behavior,
11122            fairness_guard: InputFairnessGuard::new(),
11123            event_recorder: None,
11124            subscriptions,
11125            #[cfg(test)]
11126            task_sender,
11127            task_receiver,
11128            task_executor,
11129            state_registry: config.persistence.registry.clone(),
11130            persistence_config: config.persistence,
11131            last_checkpoint: Instant::now(),
11132            inline_auto_remeasure,
11133            frame_arena: FrameArena::default(),
11134            guardrails,
11135            tick_strategy: config
11136                .tick_strategy
11137                .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
11138            last_active_screen_for_strategy: None,
11139        }
11140    }
11141
11142    fn headless_program_with_config<M: Model>(
11143        model: M,
11144        config: ProgramConfig,
11145    ) -> Program<M, HeadlessEventSource, Vec<u8>>
11146    where
11147        M::Message: Send + 'static,
11148    {
11149        // Headless unit tests should not observe process-global shutdown state
11150        // unless they explicitly opt into signal interception.
11151        headless_program_with_resolved_config(model, config.with_signal_interception(false))
11152    }
11153
11154    fn headless_signal_program_with_config<M: Model>(
11155        model: M,
11156        config: ProgramConfig,
11157    ) -> Program<M, HeadlessEventSource, Vec<u8>>
11158    where
11159        M::Message: Send + 'static,
11160    {
11161        headless_program_with_resolved_config(model, config)
11162    }
11163
11164    fn temp_evidence_path(label: &str) -> PathBuf {
11165        static COUNTER: AtomicUsize = AtomicUsize::new(0);
11166        let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
11167        let pid = std::process::id();
11168        let mut path = std::env::temp_dir();
11169        path.push(format!("ftui_evidence_{label}_{pid}_{seq}.jsonl"));
11170        path
11171    }
11172
11173    fn read_evidence_event(path: &PathBuf, event: &str) -> Value {
11174        let jsonl = std::fs::read_to_string(path).expect("read evidence jsonl");
11175        let needle = format!("\"event\":\"{event}\"");
11176        let missing_msg = format!("missing {event} line");
11177        let line = jsonl
11178            .lines()
11179            .find(|line| line.contains(&needle))
11180            .expect(&missing_msg);
11181        serde_json::from_str(line).expect("valid evidence json")
11182    }
11183
11184    #[test]
11185    fn headless_apply_resize_updates_model_and_dimensions() {
11186        struct ResizeModel {
11187            last_size: Option<(u16, u16)>,
11188        }
11189
11190        #[derive(Debug)]
11191        enum ResizeMsg {
11192            Resize(u16, u16),
11193            Other,
11194        }
11195
11196        impl From<Event> for ResizeMsg {
11197            fn from(event: Event) -> Self {
11198                match event {
11199                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
11200                    _ => ResizeMsg::Other,
11201                }
11202            }
11203        }
11204
11205        impl Model for ResizeModel {
11206            type Message = ResizeMsg;
11207
11208            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11209                if let ResizeMsg::Resize(w, h) = msg {
11210                    self.last_size = Some((w, h));
11211                }
11212                Cmd::none()
11213            }
11214
11215            fn view(&self, _frame: &mut Frame) {}
11216        }
11217
11218        let mut program =
11219            headless_program_with_config(ResizeModel { last_size: None }, ProgramConfig::default());
11220        program.dirty = false;
11221
11222        program
11223            .apply_resize(0, 0, Duration::ZERO, false)
11224            .expect("resize");
11225
11226        assert_eq!(program.width, 1);
11227        assert_eq!(program.height, 1);
11228        assert_eq!(program.model().last_size, Some((1, 1)));
11229        assert!(program.dirty);
11230    }
11231
11232    #[test]
11233    fn headless_apply_resize_reconciles_subscriptions() {
11234        use crate::subscription::{StopSignal, SubId, Subscription};
11235
11236        struct ResizeSubModel {
11237            subscribed: bool,
11238        }
11239
11240        #[derive(Debug)]
11241        enum ResizeSubMsg {
11242            Resize,
11243            Other,
11244        }
11245
11246        impl From<Event> for ResizeSubMsg {
11247            fn from(event: Event) -> Self {
11248                match event {
11249                    Event::Resize { .. } => Self::Resize,
11250                    _ => Self::Other,
11251                }
11252            }
11253        }
11254
11255        impl Model for ResizeSubModel {
11256            type Message = ResizeSubMsg;
11257
11258            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11259                if matches!(msg, ResizeSubMsg::Resize) {
11260                    self.subscribed = true;
11261                }
11262                Cmd::none()
11263            }
11264
11265            fn view(&self, _frame: &mut Frame) {}
11266
11267            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
11268                if self.subscribed {
11269                    vec![Box::new(ResizeSubscription)]
11270                } else {
11271                    vec![]
11272                }
11273            }
11274        }
11275
11276        struct ResizeSubscription;
11277
11278        impl Subscription<ResizeSubMsg> for ResizeSubscription {
11279            fn id(&self) -> SubId {
11280                1
11281            }
11282
11283            fn run(&self, _sender: mpsc::Sender<ResizeSubMsg>, _stop: StopSignal) {}
11284        }
11285
11286        let mut program = headless_program_with_config(
11287            ResizeSubModel { subscribed: false },
11288            ProgramConfig::default(),
11289        );
11290
11291        assert_eq!(program.subscriptions.active_count(), 0);
11292        program
11293            .apply_resize(120, 40, Duration::ZERO, false)
11294            .expect("resize");
11295
11296        assert!(program.model().subscribed);
11297        assert_eq!(program.subscriptions.active_count(), 1);
11298    }
11299
11300    #[test]
11301    fn headless_execute_cmd_log_writes_output() {
11302        let mut program =
11303            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11304        program.execute_cmd(Cmd::log("hello world")).expect("log");
11305
11306        let bytes = program.writer.into_inner().expect("writer output");
11307        let output = String::from_utf8_lossy(&bytes);
11308        assert!(output.contains("hello world"));
11309    }
11310
11311    #[test]
11312    fn headless_process_task_results_updates_model() {
11313        struct TaskModel {
11314            updates: usize,
11315        }
11316
11317        #[derive(Debug)]
11318        enum TaskMsg {
11319            Done,
11320        }
11321
11322        impl From<Event> for TaskMsg {
11323            fn from(_: Event) -> Self {
11324                TaskMsg::Done
11325            }
11326        }
11327
11328        impl Model for TaskModel {
11329            type Message = TaskMsg;
11330
11331            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
11332                self.updates += 1;
11333                Cmd::none()
11334            }
11335
11336            fn view(&self, _frame: &mut Frame) {}
11337        }
11338
11339        let mut program =
11340            headless_program_with_config(TaskModel { updates: 0 }, ProgramConfig::default());
11341        program.dirty = false;
11342        program.task_sender.send(TaskMsg::Done).unwrap();
11343
11344        program
11345            .process_task_results()
11346            .expect("process task results");
11347        assert_eq!(program.model().updates, 1);
11348        assert!(program.dirty);
11349    }
11350
11351    #[test]
11352    fn run_invokes_on_shutdown_after_quit() {
11353        use std::sync::{
11354            Arc,
11355            atomic::{AtomicUsize, Ordering},
11356        };
11357
11358        struct ShutdownModel {
11359            shutdowns: Arc<AtomicUsize>,
11360        }
11361
11362        #[derive(Debug, Clone, Copy)]
11363        enum ShutdownMsg {
11364            Quit,
11365            ShutdownRan,
11366        }
11367
11368        impl From<Event> for ShutdownMsg {
11369            fn from(_: Event) -> Self {
11370                ShutdownMsg::Quit
11371            }
11372        }
11373
11374        impl Model for ShutdownModel {
11375            type Message = ShutdownMsg;
11376
11377            fn init(&mut self) -> Cmd<Self::Message> {
11378                Cmd::quit()
11379            }
11380
11381            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11382                match msg {
11383                    ShutdownMsg::Quit => Cmd::quit(),
11384                    ShutdownMsg::ShutdownRan => {
11385                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
11386                        Cmd::none()
11387                    }
11388                }
11389            }
11390
11391            fn view(&self, _frame: &mut Frame) {}
11392
11393            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11394                Cmd::msg(ShutdownMsg::ShutdownRan)
11395            }
11396        }
11397
11398        let shutdowns = Arc::new(AtomicUsize::new(0));
11399        let mut program = headless_program_with_config(
11400            ShutdownModel {
11401                shutdowns: Arc::clone(&shutdowns),
11402            },
11403            ProgramConfig::default(),
11404        );
11405
11406        program.run().expect("program run");
11407
11408        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11409    }
11410
11411    #[test]
11412    fn run_processes_shutdown_task_results_before_exit() {
11413        use std::sync::{
11414            Arc,
11415            atomic::{AtomicUsize, Ordering},
11416        };
11417
11418        struct ShutdownTaskModel {
11419            shutdowns: Arc<AtomicUsize>,
11420        }
11421
11422        #[derive(Debug, Clone, Copy)]
11423        enum ShutdownTaskMsg {
11424            Quit,
11425            ShutdownRan,
11426        }
11427
11428        impl From<Event> for ShutdownTaskMsg {
11429            fn from(_: Event) -> Self {
11430                ShutdownTaskMsg::Quit
11431            }
11432        }
11433
11434        impl Model for ShutdownTaskModel {
11435            type Message = ShutdownTaskMsg;
11436
11437            fn init(&mut self) -> Cmd<Self::Message> {
11438                Cmd::quit()
11439            }
11440
11441            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11442                match msg {
11443                    ShutdownTaskMsg::Quit => Cmd::quit(),
11444                    ShutdownTaskMsg::ShutdownRan => {
11445                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
11446                        Cmd::none()
11447                    }
11448                }
11449            }
11450
11451            fn view(&self, _frame: &mut Frame) {}
11452
11453            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11454                Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
11455            }
11456        }
11457
11458        let shutdowns = Arc::new(AtomicUsize::new(0));
11459        let mut program = headless_program_with_config(
11460            ShutdownTaskModel {
11461                shutdowns: Arc::clone(&shutdowns),
11462            },
11463            ProgramConfig::default(),
11464        );
11465
11466        program.run().expect("program run");
11467
11468        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11469    }
11470
11471    #[test]
11472    fn run_processes_shutdown_task_results_with_effect_queue_backend() {
11473        use std::sync::{
11474            Arc,
11475            atomic::{AtomicUsize, Ordering},
11476        };
11477
11478        struct ShutdownTaskModel {
11479            shutdowns: Arc<AtomicUsize>,
11480        }
11481
11482        #[derive(Debug, Clone, Copy)]
11483        enum ShutdownTaskMsg {
11484            Quit,
11485            ShutdownRan,
11486        }
11487
11488        impl From<Event> for ShutdownTaskMsg {
11489            fn from(_: Event) -> Self {
11490                ShutdownTaskMsg::Quit
11491            }
11492        }
11493
11494        impl Model for ShutdownTaskModel {
11495            type Message = ShutdownTaskMsg;
11496
11497            fn init(&mut self) -> Cmd<Self::Message> {
11498                Cmd::quit()
11499            }
11500
11501            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11502                match msg {
11503                    ShutdownTaskMsg::Quit => Cmd::quit(),
11504                    ShutdownTaskMsg::ShutdownRan => {
11505                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
11506                        Cmd::none()
11507                    }
11508                }
11509            }
11510
11511            fn view(&self, _frame: &mut Frame) {}
11512
11513            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11514                Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
11515            }
11516        }
11517
11518        let shutdowns = Arc::new(AtomicUsize::new(0));
11519        let mut program = headless_program_with_config(
11520            ShutdownTaskModel {
11521                shutdowns: Arc::clone(&shutdowns),
11522            },
11523            ProgramConfig::default().with_effect_queue(
11524                EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue),
11525            ),
11526        );
11527
11528        program.run().expect("program run");
11529
11530        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11531    }
11532
11533    #[test]
11534    fn shutdown_task_results_do_not_spawn_follow_up_tasks_after_executor_shutdown() {
11535        use std::sync::{
11536            Arc,
11537            atomic::{AtomicUsize, Ordering},
11538        };
11539
11540        struct ShutdownTaskModel {
11541            shutdowns: Arc<AtomicUsize>,
11542            follow_up_runs: Arc<AtomicUsize>,
11543        }
11544
11545        #[derive(Debug, Clone, Copy)]
11546        enum ShutdownTaskMsg {
11547            Quit,
11548            ShutdownRan,
11549            FollowUp,
11550        }
11551
11552        impl From<Event> for ShutdownTaskMsg {
11553            fn from(_: Event) -> Self {
11554                ShutdownTaskMsg::Quit
11555            }
11556        }
11557
11558        impl Model for ShutdownTaskModel {
11559            type Message = ShutdownTaskMsg;
11560
11561            fn init(&mut self) -> Cmd<Self::Message> {
11562                Cmd::quit()
11563            }
11564
11565            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11566                match msg {
11567                    ShutdownTaskMsg::Quit => Cmd::quit(),
11568                    ShutdownTaskMsg::ShutdownRan => {
11569                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
11570                        let follow_up_runs = Arc::clone(&self.follow_up_runs);
11571                        Cmd::task(move || {
11572                            follow_up_runs.fetch_add(1, Ordering::SeqCst);
11573                            ShutdownTaskMsg::FollowUp
11574                        })
11575                    }
11576                    ShutdownTaskMsg::FollowUp => {
11577                        self.follow_up_runs.fetch_add(1, Ordering::SeqCst);
11578                        Cmd::none()
11579                    }
11580                }
11581            }
11582
11583            fn view(&self, _frame: &mut Frame) {}
11584
11585            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11586                Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
11587            }
11588        }
11589
11590        let shutdowns = Arc::new(AtomicUsize::new(0));
11591        let follow_up_runs = Arc::new(AtomicUsize::new(0));
11592        let mut program = headless_program_with_config(
11593            ShutdownTaskModel {
11594                shutdowns: Arc::clone(&shutdowns),
11595                follow_up_runs: Arc::clone(&follow_up_runs),
11596            },
11597            ProgramConfig::default(),
11598        );
11599
11600        program.run().expect("program run");
11601
11602        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11603        assert_eq!(follow_up_runs.load(Ordering::SeqCst), 0);
11604    }
11605
11606    #[test]
11607    fn run_quit_from_init_skips_initial_render_and_subscription_start() {
11608        use crate::subscription::{StopSignal, SubId, Subscription};
11609
11610        struct InitQuitModel {
11611            render_calls: Arc<AtomicUsize>,
11612            subscription_starts: Arc<AtomicUsize>,
11613        }
11614
11615        #[derive(Debug, Clone, Copy)]
11616        enum InitQuitMsg {
11617            Noop,
11618        }
11619
11620        impl From<Event> for InitQuitMsg {
11621            fn from(_: Event) -> Self {
11622                Self::Noop
11623            }
11624        }
11625
11626        impl Model for InitQuitModel {
11627            type Message = InitQuitMsg;
11628
11629            fn init(&mut self) -> Cmd<Self::Message> {
11630                Cmd::quit()
11631            }
11632
11633            fn update(&mut self, _: Self::Message) -> Cmd<Self::Message> {
11634                Cmd::none()
11635            }
11636
11637            fn view(&self, _frame: &mut Frame) {
11638                self.render_calls.fetch_add(1, Ordering::SeqCst);
11639            }
11640
11641            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
11642                vec![Box::new(InitQuitSubscription {
11643                    starts: Arc::clone(&self.subscription_starts),
11644                })]
11645            }
11646        }
11647
11648        struct InitQuitSubscription {
11649            starts: Arc<AtomicUsize>,
11650        }
11651
11652        impl Subscription<InitQuitMsg> for InitQuitSubscription {
11653            fn id(&self) -> SubId {
11654                1
11655            }
11656
11657            fn run(&self, _sender: mpsc::Sender<InitQuitMsg>, stop: StopSignal) {
11658                self.starts.fetch_add(1, Ordering::SeqCst);
11659                let _ = stop.wait_timeout(Duration::from_millis(10));
11660            }
11661        }
11662
11663        let render_calls = Arc::new(AtomicUsize::new(0));
11664        let subscription_starts = Arc::new(AtomicUsize::new(0));
11665        let mut program = headless_program_with_config(
11666            InitQuitModel {
11667                render_calls: Arc::clone(&render_calls),
11668                subscription_starts: Arc::clone(&subscription_starts),
11669            },
11670            ProgramConfig::default(),
11671        );
11672
11673        program.run().expect("program run");
11674
11675        assert_eq!(render_calls.load(Ordering::SeqCst), 0);
11676        assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
11677    }
11678
11679    #[test]
11680    fn run_invokes_on_shutdown_before_returning_signal_error() {
11681        use std::sync::{
11682            Arc,
11683            atomic::{AtomicUsize, Ordering},
11684        };
11685
11686        struct ShutdownModel {
11687            shutdowns: Arc<AtomicUsize>,
11688        }
11689
11690        #[derive(Debug, Clone, Copy)]
11691        enum ShutdownMsg {
11692            Noop,
11693            ShutdownRan,
11694        }
11695
11696        impl From<Event> for ShutdownMsg {
11697            fn from(_: Event) -> Self {
11698                ShutdownMsg::Noop
11699            }
11700        }
11701
11702        impl Model for ShutdownModel {
11703            type Message = ShutdownMsg;
11704
11705            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11706                match msg {
11707                    ShutdownMsg::Noop => Cmd::none(),
11708                    ShutdownMsg::ShutdownRan => {
11709                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
11710                        Cmd::none()
11711                    }
11712                }
11713            }
11714
11715            fn view(&self, _frame: &mut Frame) {}
11716
11717            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11718                Cmd::msg(ShutdownMsg::ShutdownRan)
11719            }
11720        }
11721
11722        let shutdowns = Arc::new(AtomicUsize::new(0));
11723        ftui_core::shutdown_signal::with_test_signal_serialization(|| {
11724            let mut program = headless_signal_program_with_config(
11725                ShutdownModel {
11726                    shutdowns: Arc::clone(&shutdowns),
11727                },
11728                ProgramConfig::default().with_signal_interception(true),
11729            );
11730
11731            ftui_core::shutdown_signal::record_pending_termination_signal(2);
11732            let err = program.run().expect_err("signal should stop runtime");
11733
11734            assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11735            assert_eq!(signal_termination_from_error(&err), Some(2));
11736            assert_eq!(check_termination_signal(), None);
11737        });
11738    }
11739
11740    #[test]
11741    fn run_pending_signal_skips_initial_render_and_subscription_start() {
11742        use crate::subscription::{StopSignal, SubId, Subscription};
11743
11744        struct SignalStopModel {
11745            render_calls: Arc<AtomicUsize>,
11746            subscription_starts: Arc<AtomicUsize>,
11747        }
11748
11749        #[derive(Debug, Clone, Copy)]
11750        enum SignalStopMsg {
11751            Noop,
11752        }
11753
11754        impl From<Event> for SignalStopMsg {
11755            fn from(_: Event) -> Self {
11756                Self::Noop
11757            }
11758        }
11759
11760        impl Model for SignalStopModel {
11761            type Message = SignalStopMsg;
11762
11763            fn update(&mut self, _: Self::Message) -> Cmd<Self::Message> {
11764                Cmd::none()
11765            }
11766
11767            fn view(&self, _frame: &mut Frame) {
11768                self.render_calls.fetch_add(1, Ordering::SeqCst);
11769            }
11770
11771            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
11772                vec![Box::new(SignalStopSubscription {
11773                    starts: Arc::clone(&self.subscription_starts),
11774                })]
11775            }
11776        }
11777
11778        struct SignalStopSubscription {
11779            starts: Arc<AtomicUsize>,
11780        }
11781
11782        impl Subscription<SignalStopMsg> for SignalStopSubscription {
11783            fn id(&self) -> SubId {
11784                11
11785            }
11786
11787            fn run(&self, _sender: mpsc::Sender<SignalStopMsg>, stop: StopSignal) {
11788                self.starts.fetch_add(1, Ordering::SeqCst);
11789                let _ = stop.wait_timeout(Duration::from_millis(10));
11790            }
11791        }
11792
11793        let render_calls = Arc::new(AtomicUsize::new(0));
11794        let subscription_starts = Arc::new(AtomicUsize::new(0));
11795        ftui_core::shutdown_signal::with_test_signal_serialization(|| {
11796            let mut program = headless_signal_program_with_config(
11797                SignalStopModel {
11798                    render_calls: Arc::clone(&render_calls),
11799                    subscription_starts: Arc::clone(&subscription_starts),
11800                },
11801                ProgramConfig::default().with_signal_interception(true),
11802            );
11803
11804            ftui_core::shutdown_signal::record_pending_termination_signal(15);
11805            let err = program.run().expect_err("signal should stop runtime");
11806
11807            assert_eq!(signal_termination_from_error(&err), Some(15));
11808            assert_eq!(render_calls.load(Ordering::SeqCst), 0);
11809            assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
11810            assert_eq!(check_termination_signal(), None);
11811        });
11812    }
11813
11814    #[test]
11815    fn headless_should_tick_and_timeout_behaviors() {
11816        let mut program =
11817            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11818        program.tick_rate = Some(Duration::from_millis(5));
11819        program.last_tick = Instant::now() - Duration::from_millis(10);
11820
11821        assert!(program.should_tick());
11822        assert!(!program.should_tick());
11823
11824        let timeout = program.effective_timeout();
11825        assert!(timeout <= Duration::from_millis(5));
11826
11827        program.tick_rate = None;
11828        program.poll_timeout = Duration::from_millis(33);
11829        assert_eq!(program.effective_timeout(), Duration::from_millis(33));
11830    }
11831
11832    #[test]
11833    fn headless_effective_timeout_respects_resize_coalescer() {
11834        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
11835        config.resize_coalescer.steady_delay_ms = 0;
11836        config.resize_coalescer.burst_delay_ms = 0;
11837
11838        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
11839        program.tick_rate = Some(Duration::from_millis(50));
11840
11841        program.resize_coalescer.handle_resize(120, 40);
11842        assert!(program.resize_coalescer.has_pending());
11843
11844        let timeout = program.effective_timeout();
11845        assert_eq!(timeout, Duration::ZERO);
11846    }
11847
11848    #[test]
11849    fn headless_ui_height_remeasure_clears_auto_height() {
11850        let mut config = ProgramConfig::inline_auto(2, 6);
11851        config.inline_auto_remeasure = Some(InlineAutoRemeasureConfig::default());
11852
11853        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
11854        program.dirty = false;
11855        program.writer.set_auto_ui_height(5);
11856
11857        assert_eq!(program.writer.auto_ui_height(), Some(5));
11858        program.request_ui_height_remeasure();
11859
11860        assert_eq!(program.writer.auto_ui_height(), None);
11861        assert!(program.dirty);
11862    }
11863
11864    #[test]
11865    fn headless_recording_lifecycle_and_locale_change() {
11866        let mut program =
11867            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11868        program.dirty = false;
11869
11870        program.start_recording("demo");
11871        assert!(program.is_recording());
11872        let recorded = program.stop_recording();
11873        assert!(recorded.is_some());
11874        assert!(!program.is_recording());
11875
11876        let prev_dirty = program.dirty;
11877        program.locale_context.set_locale("fr");
11878        program.check_locale_change();
11879        assert!(program.dirty || prev_dirty);
11880    }
11881
11882    #[test]
11883    fn headless_render_frame_marks_clean_and_sets_diff() {
11884        struct RenderModel;
11885
11886        #[derive(Debug)]
11887        enum RenderMsg {
11888            Noop,
11889        }
11890
11891        impl From<Event> for RenderMsg {
11892            fn from(_: Event) -> Self {
11893                RenderMsg::Noop
11894            }
11895        }
11896
11897        impl Model for RenderModel {
11898            type Message = RenderMsg;
11899
11900            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
11901                Cmd::none()
11902            }
11903
11904            fn view(&self, frame: &mut Frame) {
11905                frame.buffer.set_raw(0, 0, Cell::from_char('X'));
11906            }
11907        }
11908
11909        let mut program = headless_program_with_config(RenderModel, ProgramConfig::default());
11910        program.render_frame().expect("render frame");
11911
11912        assert!(!program.dirty);
11913        assert!(program.writer.last_diff_strategy().is_some());
11914        assert_eq!(program.frame_idx, 1);
11915    }
11916
11917    #[test]
11918    fn headless_render_frame_skips_when_budget_exhausted() {
11919        let config = ProgramConfig {
11920            budget: FrameBudgetConfig::with_total(Duration::ZERO),
11921            ..Default::default()
11922        };
11923
11924        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
11925        program.dirty = true;
11926        program.render_frame().expect("render frame");
11927
11928        // Dirty state is preserved when frame is skipped — the UI update
11929        // was never presented and must be retried.
11930        assert!(program.dirty);
11931        assert_eq!(program.frame_idx, 1);
11932    }
11933
11934    #[test]
11935    fn headless_render_frame_emits_budget_evidence_with_controller() {
11936        use ftui_render::budget::BudgetControllerConfig;
11937
11938        struct RenderModel;
11939
11940        #[derive(Debug)]
11941        enum RenderMsg {
11942            Noop,
11943        }
11944
11945        impl From<Event> for RenderMsg {
11946            fn from(_: Event) -> Self {
11947                RenderMsg::Noop
11948            }
11949        }
11950
11951        impl Model for RenderModel {
11952            type Message = RenderMsg;
11953
11954            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
11955                Cmd::none()
11956            }
11957
11958            fn view(&self, frame: &mut Frame) {
11959                frame.buffer.set_raw(0, 0, Cell::from_char('E'));
11960            }
11961        }
11962
11963        let config =
11964            ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
11965        let mut program = headless_program_with_config(RenderModel, config);
11966        program.budget = program
11967            .budget
11968            .with_controller(BudgetControllerConfig::default());
11969
11970        program.render_frame().expect("render frame");
11971        assert!(program.budget.telemetry().is_some());
11972        assert_eq!(program.frame_idx, 1);
11973    }
11974
11975    #[test]
11976    fn headless_handle_event_updates_model() {
11977        struct EventModel {
11978            events: usize,
11979            last_resize: Option<(u16, u16)>,
11980        }
11981
11982        #[derive(Debug)]
11983        enum EventMsg {
11984            Resize(u16, u16),
11985            Other,
11986        }
11987
11988        impl From<Event> for EventMsg {
11989            fn from(event: Event) -> Self {
11990                match event {
11991                    Event::Resize { width, height } => EventMsg::Resize(width, height),
11992                    _ => EventMsg::Other,
11993                }
11994            }
11995        }
11996
11997        impl Model for EventModel {
11998            type Message = EventMsg;
11999
12000            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12001                self.events += 1;
12002                if let EventMsg::Resize(w, h) = msg {
12003                    self.last_resize = Some((w, h));
12004                }
12005                Cmd::none()
12006            }
12007
12008            fn view(&self, _frame: &mut Frame) {}
12009        }
12010
12011        let mut program = headless_program_with_config(
12012            EventModel {
12013                events: 0,
12014                last_resize: None,
12015            },
12016            ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate),
12017        );
12018
12019        program
12020            .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
12021                ftui_core::event::KeyCode::Char('x'),
12022            )))
12023            .expect("handle key");
12024        assert_eq!(program.model().events, 1);
12025
12026        program
12027            .handle_event(Event::Resize {
12028                width: 10,
12029                height: 5,
12030            })
12031            .expect("handle resize");
12032        assert_eq!(program.model().events, 2);
12033        assert_eq!(program.model().last_resize, Some((10, 5)));
12034        assert_eq!(program.width, 10);
12035        assert_eq!(program.height, 5);
12036    }
12037
12038    #[test]
12039    fn headless_handle_event_quit_skips_subscription_reconcile() {
12040        use crate::subscription::{StopSignal, SubId, Subscription};
12041
12042        struct QuitSubModel {
12043            quitting: bool,
12044            subscription_starts: Arc<AtomicUsize>,
12045        }
12046
12047        #[derive(Debug)]
12048        enum QuitSubMsg {
12049            Quit,
12050            Other,
12051        }
12052
12053        impl From<Event> for QuitSubMsg {
12054            fn from(event: Event) -> Self {
12055                match event {
12056                    Event::Key(_) => Self::Quit,
12057                    _ => Self::Other,
12058                }
12059            }
12060        }
12061
12062        impl Model for QuitSubModel {
12063            type Message = QuitSubMsg;
12064
12065            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12066                match msg {
12067                    QuitSubMsg::Quit => {
12068                        self.quitting = true;
12069                        Cmd::quit()
12070                    }
12071                    QuitSubMsg::Other => Cmd::none(),
12072                }
12073            }
12074
12075            fn view(&self, _frame: &mut Frame) {}
12076
12077            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
12078                if self.quitting {
12079                    vec![Box::new(QuitSubSubscription {
12080                        starts: Arc::clone(&self.subscription_starts),
12081                    })]
12082                } else {
12083                    vec![]
12084                }
12085            }
12086        }
12087
12088        struct QuitSubSubscription {
12089            starts: Arc<AtomicUsize>,
12090        }
12091
12092        impl Subscription<QuitSubMsg> for QuitSubSubscription {
12093            fn id(&self) -> SubId {
12094                7
12095            }
12096
12097            fn run(&self, _sender: mpsc::Sender<QuitSubMsg>, stop: StopSignal) {
12098                self.starts.fetch_add(1, Ordering::SeqCst);
12099                let _ = stop.wait_timeout(Duration::from_millis(10));
12100            }
12101        }
12102
12103        let subscription_starts = Arc::new(AtomicUsize::new(0));
12104        let mut program = headless_program_with_config(
12105            QuitSubModel {
12106                quitting: false,
12107                subscription_starts: Arc::clone(&subscription_starts),
12108            },
12109            ProgramConfig::default(),
12110        );
12111
12112        program
12113            .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
12114                ftui_core::event::KeyCode::Char('q'),
12115            )))
12116            .expect("handle event");
12117
12118        assert!(!program.is_running());
12119        assert_eq!(program.subscriptions.active_count(), 0);
12120        assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
12121    }
12122
12123    #[test]
12124    fn headless_handle_resize_ignored_when_forced_size() {
12125        struct ResizeModel {
12126            resized: bool,
12127        }
12128
12129        #[derive(Debug)]
12130        enum ResizeMsg {
12131            Resize,
12132            Other,
12133        }
12134
12135        impl From<Event> for ResizeMsg {
12136            fn from(event: Event) -> Self {
12137                match event {
12138                    Event::Resize { .. } => ResizeMsg::Resize,
12139                    _ => ResizeMsg::Other,
12140                }
12141            }
12142        }
12143
12144        impl Model for ResizeModel {
12145            type Message = ResizeMsg;
12146
12147            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12148                if matches!(msg, ResizeMsg::Resize) {
12149                    self.resized = true;
12150                }
12151                Cmd::none()
12152            }
12153
12154            fn view(&self, _frame: &mut Frame) {}
12155        }
12156
12157        let config = ProgramConfig::default().with_forced_size(80, 24);
12158        let mut program = headless_program_with_config(ResizeModel { resized: false }, config);
12159
12160        program
12161            .handle_event(Event::Resize {
12162                width: 120,
12163                height: 40,
12164            })
12165            .expect("handle resize");
12166
12167        assert_eq!(program.width, 80);
12168        assert_eq!(program.height, 24);
12169        assert!(!program.model().resized);
12170    }
12171
12172    #[test]
12173    fn headless_execute_cmd_batch_sequence_and_quit() {
12174        struct BatchModel {
12175            count: usize,
12176        }
12177
12178        #[derive(Debug)]
12179        enum BatchMsg {
12180            Inc,
12181        }
12182
12183        impl From<Event> for BatchMsg {
12184            fn from(_: Event) -> Self {
12185                BatchMsg::Inc
12186            }
12187        }
12188
12189        impl Model for BatchModel {
12190            type Message = BatchMsg;
12191
12192            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12193                match msg {
12194                    BatchMsg::Inc => {
12195                        self.count += 1;
12196                        Cmd::none()
12197                    }
12198                }
12199            }
12200
12201            fn view(&self, _frame: &mut Frame) {}
12202        }
12203
12204        let mut program =
12205            headless_program_with_config(BatchModel { count: 0 }, ProgramConfig::default());
12206
12207        program
12208            .execute_cmd(Cmd::Batch(vec![
12209                Cmd::msg(BatchMsg::Inc),
12210                Cmd::Sequence(vec![
12211                    Cmd::msg(BatchMsg::Inc),
12212                    Cmd::quit(),
12213                    Cmd::msg(BatchMsg::Inc),
12214                ]),
12215            ]))
12216            .expect("batch cmd");
12217
12218        assert_eq!(program.model().count, 2);
12219        assert!(!program.running);
12220    }
12221
12222    #[test]
12223    fn headless_process_subscription_messages_updates_model() {
12224        use crate::subscription::{StopSignal, SubId, Subscription};
12225
12226        struct SubModel {
12227            pings: usize,
12228            ready_tx: mpsc::Sender<()>,
12229        }
12230
12231        #[derive(Debug)]
12232        enum SubMsg {
12233            Ping,
12234            Other,
12235        }
12236
12237        impl From<Event> for SubMsg {
12238            fn from(_: Event) -> Self {
12239                SubMsg::Other
12240            }
12241        }
12242
12243        impl Model for SubModel {
12244            type Message = SubMsg;
12245
12246            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12247                if let SubMsg::Ping = msg {
12248                    self.pings += 1;
12249                }
12250                Cmd::none()
12251            }
12252
12253            fn view(&self, _frame: &mut Frame) {}
12254
12255            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
12256                vec![Box::new(TestSubscription {
12257                    ready_tx: self.ready_tx.clone(),
12258                })]
12259            }
12260        }
12261
12262        struct TestSubscription {
12263            ready_tx: mpsc::Sender<()>,
12264        }
12265
12266        impl Subscription<SubMsg> for TestSubscription {
12267            fn id(&self) -> SubId {
12268                1
12269            }
12270
12271            fn run(&self, sender: mpsc::Sender<SubMsg>, _stop: StopSignal) {
12272                let _ = sender.send(SubMsg::Ping);
12273                let _ = self.ready_tx.send(());
12274            }
12275        }
12276
12277        let (ready_tx, ready_rx) = mpsc::channel();
12278        let mut program =
12279            headless_program_with_config(SubModel { pings: 0, ready_tx }, ProgramConfig::default());
12280
12281        program.reconcile_subscriptions();
12282        ready_rx
12283            .recv_timeout(Duration::from_millis(200))
12284            .expect("subscription started");
12285        program
12286            .process_subscription_messages()
12287            .expect("process subscriptions");
12288
12289        assert_eq!(program.model().pings, 1);
12290    }
12291
12292    #[test]
12293    fn headless_execute_cmd_task_spawns_and_reaps() {
12294        struct TaskModel {
12295            done: bool,
12296        }
12297
12298        #[derive(Debug)]
12299        enum TaskMsg {
12300            Done,
12301        }
12302
12303        impl From<Event> for TaskMsg {
12304            fn from(_: Event) -> Self {
12305                TaskMsg::Done
12306            }
12307        }
12308
12309        impl Model for TaskModel {
12310            type Message = TaskMsg;
12311
12312            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12313                match msg {
12314                    TaskMsg::Done => {
12315                        self.done = true;
12316                        Cmd::none()
12317                    }
12318                }
12319            }
12320
12321            fn view(&self, _frame: &mut Frame) {}
12322        }
12323
12324        let mut program =
12325            headless_program_with_config(TaskModel { done: false }, ProgramConfig::default());
12326        program
12327            .execute_cmd(Cmd::task(|| TaskMsg::Done))
12328            .expect("task cmd");
12329
12330        let deadline = Instant::now() + Duration::from_millis(200);
12331        while !program.model().done && Instant::now() <= deadline {
12332            program
12333                .process_task_results()
12334                .expect("process task results");
12335            program.reap_finished_tasks();
12336        }
12337
12338        assert!(program.model().done, "task result did not arrive in time");
12339    }
12340
12341    #[test]
12342    fn headless_default_task_executor_is_spawned_for_structured_lane() {
12343        // Input-lag regression fix (#78): the default Structured lane must use
12344        // per-task `Spawned` execution, NOT the single-worker effect queue.
12345        // Routing every `Cmd::Task` through one serialized `effect_queue_loop`
12346        // worker added per-keystroke head-of-line latency for apps that forward
12347        // PTY output via per-pane polling tasks. Structured cancellation does
12348        // not require the effect queue, so the default backend is `Spawned`
12349        // ("spawned") here, matching v0.2.1 task concurrency.
12350        let program =
12351            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12352        assert_eq!(program.task_executor.kind_name(), "spawned");
12353    }
12354
12355    #[test]
12356    fn headless_structured_lane_task_executor_writes_spawned_backend_evidence() {
12357        // Input-lag regression fix (#78): the default Structured lane emits the
12358        // "spawned" backend in startup evidence (was "queued"), reflecting the
12359        // restored per-task-thread execution.
12360        let evidence_path = temp_evidence_path("task_executor_spawned_backend");
12361        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12362        let config = ProgramConfig::default().with_evidence_sink(sink_config);
12363        let _program = headless_program_with_config(TestModel { value: 0 }, config);
12364
12365        let backend_line = read_evidence_event(&evidence_path, "task_executor_backend");
12366        assert_eq!(backend_line["backend"], "spawned");
12367    }
12368
12369    #[test]
12370    fn headless_legacy_lane_task_executor_is_spawned() {
12371        let config = ProgramConfig::default().with_lane(RuntimeLane::Legacy);
12372        let program = headless_program_with_config(TestModel { value: 0 }, config);
12373        assert_eq!(program.task_executor.kind_name(), "spawned");
12374    }
12375
12376    #[test]
12377    fn headless_explicit_spawned_backend_overrides_structured_lane_default() {
12378        let config = ProgramConfig::default().with_effect_queue(
12379            EffectQueueConfig::default().with_backend(TaskExecutorBackend::Spawned),
12380        );
12381        let program = headless_program_with_config(TestModel { value: 0 }, config);
12382        assert_eq!(program.task_executor.kind_name(), "spawned");
12383    }
12384
12385    #[cfg(feature = "asupersync-executor")]
12386    #[test]
12387    fn headless_asupersync_task_executor_is_selected() {
12388        let config = ProgramConfig::default().with_effect_queue(
12389            EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
12390        );
12391        let program = headless_program_with_config(TestModel { value: 0 }, config);
12392        assert_eq!(program.task_executor.kind_name(), "asupersync");
12393    }
12394
12395    #[test]
12396    fn headless_persistence_commands_with_registry() {
12397        use crate::state_persistence::{MemoryStorage, StateRegistry};
12398        use std::sync::Arc;
12399
12400        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
12401        let config = ProgramConfig::default().with_registry(registry.clone());
12402        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
12403
12404        assert!(program.has_persistence());
12405        assert!(program.state_registry().is_some());
12406
12407        program.execute_cmd(Cmd::save_state()).expect("save");
12408        program.execute_cmd(Cmd::restore_state()).expect("restore");
12409
12410        let saved = program.trigger_save().expect("trigger save");
12411        let loaded = program.trigger_load().expect("trigger load");
12412        assert!(!saved);
12413        assert_eq!(loaded, 0);
12414    }
12415
12416    #[test]
12417    fn headless_process_resize_coalescer_applies_pending_resize() {
12418        struct ResizeModel {
12419            last_size: Option<(u16, u16)>,
12420        }
12421
12422        #[derive(Debug)]
12423        enum ResizeMsg {
12424            Resize(u16, u16),
12425            Other,
12426        }
12427
12428        impl From<Event> for ResizeMsg {
12429            fn from(event: Event) -> Self {
12430                match event {
12431                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
12432                    _ => ResizeMsg::Other,
12433                }
12434            }
12435        }
12436
12437        impl Model for ResizeModel {
12438            type Message = ResizeMsg;
12439
12440            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12441                if let ResizeMsg::Resize(w, h) = msg {
12442                    self.last_size = Some((w, h));
12443                }
12444                Cmd::none()
12445            }
12446
12447            fn view(&self, _frame: &mut Frame) {}
12448        }
12449
12450        let evidence_path = temp_evidence_path("fairness_allow");
12451        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12452        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
12453        config.resize_coalescer.steady_delay_ms = 0;
12454        config.resize_coalescer.burst_delay_ms = 0;
12455        config.resize_coalescer.hard_deadline_ms = 1_000;
12456        config.evidence_sink = sink_config.clone();
12457
12458        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
12459        let sink = EvidenceSink::from_config(&sink_config)
12460            .expect("evidence sink config")
12461            .expect("evidence sink enabled");
12462        program.evidence_sink = Some(sink);
12463
12464        program.resize_coalescer.handle_resize(120, 40);
12465        assert!(program.resize_coalescer.has_pending());
12466
12467        program
12468            .process_resize_coalescer()
12469            .expect("process resize coalescer");
12470
12471        assert_eq!(program.width, 120);
12472        assert_eq!(program.height, 40);
12473        assert_eq!(program.model().last_size, Some((120, 40)));
12474
12475        let config_line = read_evidence_event(&evidence_path, "fairness_config");
12476        assert_eq!(config_line["event"], "fairness_config");
12477        assert!(config_line["enabled"].is_boolean());
12478        assert!(config_line["input_priority_threshold_ms"].is_number());
12479        assert!(config_line["dominance_threshold"].is_number());
12480        assert!(config_line["fairness_threshold"].is_number());
12481
12482        let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
12483        assert_eq!(decision_line["event"], "fairness_decision");
12484        assert_eq!(decision_line["decision"], "allow");
12485        assert_eq!(decision_line["reason"], "none");
12486        assert!(decision_line["pending_input_latency_ms"].is_null());
12487        assert!(decision_line["jain_index"].is_number());
12488        assert!(decision_line["resize_dominance_count"].is_number());
12489        assert!(decision_line["dominance_threshold"].is_number());
12490        assert!(decision_line["fairness_threshold"].is_number());
12491        assert!(decision_line["input_priority_threshold_ms"].is_number());
12492    }
12493
12494    #[test]
12495    fn headless_process_resize_coalescer_yields_to_input() {
12496        struct ResizeModel {
12497            last_size: Option<(u16, u16)>,
12498        }
12499
12500        #[derive(Debug)]
12501        enum ResizeMsg {
12502            Resize(u16, u16),
12503            Other,
12504        }
12505
12506        impl From<Event> for ResizeMsg {
12507            fn from(event: Event) -> Self {
12508                match event {
12509                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
12510                    _ => ResizeMsg::Other,
12511                }
12512            }
12513        }
12514
12515        impl Model for ResizeModel {
12516            type Message = ResizeMsg;
12517
12518            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12519                if let ResizeMsg::Resize(w, h) = msg {
12520                    self.last_size = Some((w, h));
12521                }
12522                Cmd::none()
12523            }
12524
12525            fn view(&self, _frame: &mut Frame) {}
12526        }
12527
12528        let evidence_path = temp_evidence_path("fairness_yield");
12529        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12530        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
12531        config.resize_coalescer.steady_delay_ms = 0;
12532        config.resize_coalescer.burst_delay_ms = 0;
12533        // Use a large hard deadline so elapsed wall-clock time between coalescer
12534        // construction and `handle_resize` never triggers an immediate apply.
12535        config.resize_coalescer.hard_deadline_ms = 10_000;
12536        config.evidence_sink = sink_config.clone();
12537
12538        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
12539        let sink = EvidenceSink::from_config(&sink_config)
12540            .expect("evidence sink config")
12541            .expect("evidence sink enabled");
12542        program.evidence_sink = Some(sink);
12543
12544        program.fairness_guard = InputFairnessGuard::with_config(
12545            crate::input_fairness::FairnessConfig::default().with_max_latency(Duration::ZERO),
12546        );
12547        program
12548            .fairness_guard
12549            .input_arrived(Instant::now() - Duration::from_millis(1));
12550
12551        program.resize_coalescer.handle_resize(120, 40);
12552        assert!(program.resize_coalescer.has_pending());
12553
12554        program
12555            .process_resize_coalescer()
12556            .expect("process resize coalescer");
12557
12558        assert_eq!(program.width, 80);
12559        assert_eq!(program.height, 24);
12560        assert_eq!(program.model().last_size, None);
12561        assert!(program.resize_coalescer.has_pending());
12562
12563        let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
12564        assert_eq!(decision_line["event"], "fairness_decision");
12565        assert_eq!(decision_line["decision"], "yield");
12566        assert_eq!(decision_line["reason"], "input_latency");
12567        assert!(decision_line["pending_input_latency_ms"].is_number());
12568        assert!(decision_line["jain_index"].is_number());
12569        assert!(decision_line["resize_dominance_count"].is_number());
12570        assert!(decision_line["dominance_threshold"].is_number());
12571        assert!(decision_line["fairness_threshold"].is_number());
12572        assert!(decision_line["input_priority_threshold_ms"].is_number());
12573    }
12574
12575    #[test]
12576    fn headless_execute_cmd_task_with_effect_queue() {
12577        struct TaskModel {
12578            done: bool,
12579        }
12580
12581        #[derive(Debug)]
12582        enum TaskMsg {
12583            Done,
12584        }
12585
12586        impl From<Event> for TaskMsg {
12587            fn from(_: Event) -> Self {
12588                TaskMsg::Done
12589            }
12590        }
12591
12592        impl Model for TaskModel {
12593            type Message = TaskMsg;
12594
12595            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12596                match msg {
12597                    TaskMsg::Done => {
12598                        self.done = true;
12599                        Cmd::none()
12600                    }
12601                }
12602            }
12603
12604            fn view(&self, _frame: &mut Frame) {}
12605        }
12606
12607        let effect_queue = EffectQueueConfig {
12608            enabled: true,
12609            backend: TaskExecutorBackend::EffectQueue,
12610            scheduler: SchedulerConfig {
12611                max_queue_size: 0,
12612                ..Default::default()
12613            },
12614            explicit_backend: true,
12615            ..Default::default()
12616        };
12617        let config = ProgramConfig::default().with_effect_queue(effect_queue);
12618        let mut program = headless_program_with_config(TaskModel { done: false }, config);
12619
12620        program
12621            .execute_cmd(Cmd::task(|| TaskMsg::Done))
12622            .expect("task cmd");
12623
12624        let deadline = Instant::now() + Duration::from_millis(200);
12625        while !program.model().done && Instant::now() <= deadline {
12626            program
12627                .process_task_results()
12628                .expect("process task results");
12629        }
12630
12631        assert!(
12632            program.model().done,
12633            "effect queue task result did not arrive in time"
12634        );
12635        assert_eq!(program.task_executor.kind_name(), "queued");
12636    }
12637
12638    #[test]
12639    fn headless_execute_cmd_task_with_spawned_backend_writes_completion_evidence() {
12640        struct TaskModel {
12641            done: bool,
12642        }
12643
12644        #[derive(Debug)]
12645        enum TaskMsg {
12646            Done,
12647        }
12648
12649        impl From<Event> for TaskMsg {
12650            fn from(_: Event) -> Self {
12651                TaskMsg::Done
12652            }
12653        }
12654
12655        impl Model for TaskModel {
12656            type Message = TaskMsg;
12657
12658            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12659                match msg {
12660                    TaskMsg::Done => {
12661                        self.done = true;
12662                        Cmd::none()
12663                    }
12664                }
12665            }
12666
12667            fn view(&self, _frame: &mut Frame) {}
12668        }
12669
12670        let evidence_path = temp_evidence_path("task_executor_spawned_complete");
12671        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12672        let config = ProgramConfig::default()
12673            .with_lane(RuntimeLane::Legacy)
12674            .with_evidence_sink(sink_config);
12675        let mut program = headless_program_with_config(TaskModel { done: false }, config);
12676
12677        program
12678            .execute_cmd(Cmd::task(|| TaskMsg::Done))
12679            .expect("task cmd");
12680
12681        let deadline = Instant::now() + Duration::from_millis(200);
12682        while !program.model().done && Instant::now() <= deadline {
12683            program
12684                .process_task_results()
12685                .expect("process task results");
12686            program.reap_finished_tasks();
12687        }
12688
12689        assert!(
12690            program.model().done,
12691            "spawned task result did not arrive in time"
12692        );
12693
12694        let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
12695        assert_eq!(completion_line["backend"], "spawned");
12696        assert!(completion_line["duration_us"].is_number());
12697    }
12698
12699    #[test]
12700    fn headless_effect_queue_task_panic_writes_panic_evidence_and_continues() {
12701        struct TaskModel {
12702            done: bool,
12703        }
12704
12705        #[derive(Debug)]
12706        enum TaskMsg {
12707            Done,
12708        }
12709
12710        impl From<Event> for TaskMsg {
12711            fn from(_: Event) -> Self {
12712                TaskMsg::Done
12713            }
12714        }
12715
12716        impl Model for TaskModel {
12717            type Message = TaskMsg;
12718
12719            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12720                match msg {
12721                    TaskMsg::Done => {
12722                        self.done = true;
12723                        Cmd::none()
12724                    }
12725                }
12726            }
12727
12728            fn view(&self, _frame: &mut Frame) {}
12729        }
12730
12731        let evidence_path = temp_evidence_path("task_executor_queued_panic");
12732        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12733        let config = ProgramConfig::default()
12734            .with_evidence_sink(sink_config)
12735            .with_effect_queue(
12736                EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue),
12737            );
12738        let mut program = headless_program_with_config(TaskModel { done: false }, config);
12739
12740        program
12741            .execute_cmd(Cmd::task(|| -> TaskMsg { panic!("queued panic evidence") }))
12742            .expect("panic task cmd");
12743        program
12744            .execute_cmd(Cmd::task(|| TaskMsg::Done))
12745            .expect("follow-up task cmd");
12746
12747        let deadline = Instant::now() + Duration::from_millis(500);
12748        while !program.model().done && Instant::now() <= deadline {
12749            program
12750                .process_task_results()
12751                .expect("process task results");
12752        }
12753
12754        assert!(
12755            program.model().done,
12756            "effect queue should continue after a panicking task"
12757        );
12758
12759        let panic_line = read_evidence_event(&evidence_path, "task_executor_panic");
12760        assert_eq!(panic_line["backend"], "queued");
12761        assert_eq!(panic_line["panic_msg"], "queued panic evidence");
12762    }
12763
12764    #[cfg(feature = "asupersync-executor")]
12765    #[test]
12766    fn headless_execute_cmd_task_with_asupersync_backend() {
12767        struct TaskModel {
12768            done: bool,
12769        }
12770
12771        #[derive(Debug)]
12772        enum TaskMsg {
12773            Done,
12774        }
12775
12776        impl From<Event> for TaskMsg {
12777            fn from(_: Event) -> Self {
12778                TaskMsg::Done
12779            }
12780        }
12781
12782        impl Model for TaskModel {
12783            type Message = TaskMsg;
12784
12785            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12786                match msg {
12787                    TaskMsg::Done => {
12788                        self.done = true;
12789                        Cmd::none()
12790                    }
12791                }
12792            }
12793
12794            fn view(&self, _frame: &mut Frame) {}
12795        }
12796
12797        let config = ProgramConfig::default().with_effect_queue(
12798            EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
12799        );
12800        let mut program = headless_program_with_config(TaskModel { done: false }, config);
12801
12802        program
12803            .execute_cmd(Cmd::task(|| TaskMsg::Done))
12804            .expect("task cmd");
12805
12806        let deadline = Instant::now() + Duration::from_millis(200);
12807        while !program.model().done && Instant::now() <= deadline {
12808            program
12809                .process_task_results()
12810                .expect("process task results");
12811            program.reap_finished_tasks();
12812        }
12813
12814        assert!(
12815            program.model().done,
12816            "asupersync task result did not arrive in time"
12817        );
12818        assert_eq!(program.task_executor.kind_name(), "asupersync");
12819    }
12820
12821    #[cfg(feature = "asupersync-executor")]
12822    #[test]
12823    fn headless_asupersync_task_executor_writes_backend_and_completion_evidence() {
12824        struct TaskModel {
12825            done: bool,
12826        }
12827
12828        #[derive(Debug)]
12829        enum TaskMsg {
12830            Done,
12831        }
12832
12833        impl From<Event> for TaskMsg {
12834            fn from(_: Event) -> Self {
12835                TaskMsg::Done
12836            }
12837        }
12838
12839        impl Model for TaskModel {
12840            type Message = TaskMsg;
12841
12842            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12843                match msg {
12844                    TaskMsg::Done => {
12845                        self.done = true;
12846                        Cmd::none()
12847                    }
12848                }
12849            }
12850
12851            fn view(&self, _frame: &mut Frame) {}
12852        }
12853
12854        let evidence_path = temp_evidence_path("task_executor_asupersync_complete");
12855        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12856        let config = ProgramConfig::default()
12857            .with_evidence_sink(sink_config)
12858            .with_effect_queue(
12859                EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
12860            );
12861        let mut program = headless_program_with_config(TaskModel { done: false }, config);
12862
12863        let backend_line = read_evidence_event(&evidence_path, "task_executor_backend");
12864        assert_eq!(backend_line["backend"], "asupersync");
12865
12866        program
12867            .execute_cmd(Cmd::task(|| TaskMsg::Done))
12868            .expect("task cmd");
12869
12870        let deadline = Instant::now() + Duration::from_millis(200);
12871        while !program.model().done && Instant::now() <= deadline {
12872            program
12873                .process_task_results()
12874                .expect("process task results");
12875            program.reap_finished_tasks();
12876        }
12877
12878        assert!(
12879            program.model().done,
12880            "asupersync task result did not arrive in time"
12881        );
12882
12883        let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
12884        assert_eq!(completion_line["backend"], "asupersync");
12885        assert!(completion_line["duration_us"].is_number());
12886    }
12887
12888    // =========================================================================
12889    // Asupersync executor: semantic parity, failure, stress, shutdown, and
12890    // backpressure coverage (bd-392ka).
12891    // =========================================================================
12892
12893    /// Run `count` tasks (each emitting its index) through `backend` headlessly
12894    /// and return the sorted multiset of delivered message payloads.
12895    #[cfg(feature = "asupersync-executor")]
12896    fn collect_task_batch(backend: TaskExecutorBackend, count: u32) -> Vec<u32> {
12897        struct CollectModel {
12898            got: Vec<u32>,
12899        }
12900        #[derive(Debug)]
12901        enum CollectMsg {
12902            Got(u32),
12903        }
12904        impl From<Event> for CollectMsg {
12905            fn from(_: Event) -> Self {
12906                CollectMsg::Got(u32::MAX)
12907            }
12908        }
12909        impl Model for CollectModel {
12910            type Message = CollectMsg;
12911            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12912                match msg {
12913                    CollectMsg::Got(value) => self.got.push(value),
12914                }
12915                Cmd::none()
12916            }
12917            fn view(&self, _frame: &mut Frame) {}
12918        }
12919
12920        let config = ProgramConfig::default()
12921            .with_effect_queue(EffectQueueConfig::default().with_backend(backend));
12922        let mut program = headless_program_with_config(CollectModel { got: Vec::new() }, config);
12923        for index in 0..count {
12924            program
12925                .execute_cmd(Cmd::task(move || CollectMsg::Got(index)))
12926                .expect("task cmd");
12927        }
12928        let deadline = Instant::now() + Duration::from_secs(5);
12929        while program.model().got.len() < count as usize && Instant::now() <= deadline {
12930            program
12931                .process_task_results()
12932                .expect("process task results");
12933            program.reap_finished_tasks();
12934        }
12935        let mut got = program.model().got.clone();
12936        got.sort_unstable();
12937        got
12938    }
12939
12940    #[cfg(feature = "asupersync-executor")]
12941    #[test]
12942    fn asupersync_semantic_parity_with_spawned_backend() {
12943        // The same task batch must deliver an identical message multiset whether
12944        // run through the legacy Spawned backend or the Asupersync backend. This
12945        // is the evidence-backed semantic-parity check (a shadow-run in miniature).
12946        let expected: Vec<u32> = (0..16).collect();
12947        let spawned = collect_task_batch(TaskExecutorBackend::Spawned, 16);
12948        let asupersync = collect_task_batch(TaskExecutorBackend::Asupersync, 16);
12949        assert_eq!(
12950            spawned, expected,
12951            "spawned backend lost or reordered results"
12952        );
12953        assert_eq!(
12954            asupersync, expected,
12955            "asupersync backend diverged from the expected results"
12956        );
12957        assert_eq!(
12958            asupersync, spawned,
12959            "asupersync diverged from spawned (parity)"
12960        );
12961    }
12962
12963    #[cfg(feature = "asupersync-executor")]
12964    #[test]
12965    fn asupersync_stress_delivers_every_task() {
12966        // Under a large burst, every task result must arrive exactly once.
12967        let count: u32 = 200;
12968        let got = collect_task_batch(TaskExecutorBackend::Asupersync, count);
12969        assert_eq!(
12970            got.len(),
12971            count as usize,
12972            "asupersync dropped tasks under load"
12973        );
12974        assert_eq!(got, (0..count).collect::<Vec<_>>());
12975    }
12976
12977    #[cfg(feature = "asupersync-executor")]
12978    #[test]
12979    fn asupersync_task_panic_is_isolated() {
12980        struct PanicModel {
12981            survivor_seen: bool,
12982        }
12983        #[derive(Debug)]
12984        enum PanicMsg {
12985            Survivor,
12986        }
12987        impl From<Event> for PanicMsg {
12988            fn from(_: Event) -> Self {
12989                PanicMsg::Survivor
12990            }
12991        }
12992        impl Model for PanicModel {
12993            type Message = PanicMsg;
12994            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12995                match msg {
12996                    PanicMsg::Survivor => self.survivor_seen = true,
12997                }
12998                Cmd::none()
12999            }
13000            fn view(&self, _frame: &mut Frame) {}
13001        }
13002
13003        let evidence_path = temp_evidence_path("asupersync_panic");
13004        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
13005        let config = ProgramConfig::default()
13006            .with_evidence_sink(sink_config)
13007            .with_effect_queue(
13008                EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
13009            );
13010        let mut program = headless_program_with_config(
13011            PanicModel {
13012                survivor_seen: false,
13013            },
13014            config,
13015        );
13016
13017        // A panicking task must neither crash the executor nor deliver a message...
13018        program
13019            .execute_cmd(Cmd::task(|| -> PanicMsg { panic!("asupersync boom") }))
13020            .expect("panic task cmd");
13021        // ...and a task submitted afterwards must still complete normally.
13022        program
13023            .execute_cmd(Cmd::task(|| PanicMsg::Survivor))
13024            .expect("survivor task cmd");
13025
13026        let deadline = Instant::now() + Duration::from_secs(5);
13027        while !program.model().survivor_seen && Instant::now() <= deadline {
13028            program
13029                .process_task_results()
13030                .expect("process task results");
13031            program.reap_finished_tasks();
13032        }
13033        assert!(
13034            program.model().survivor_seen,
13035            "executor did not survive a panicking task"
13036        );
13037        let panic_line = read_evidence_event(&evidence_path, "task_executor_panic");
13038        assert_eq!(panic_line["backend"], "asupersync");
13039    }
13040
13041    #[cfg(feature = "asupersync-executor")]
13042    #[test]
13043    fn asupersync_rejects_tasks_after_shutdown() {
13044        struct ShutdownModel {
13045            seen: bool,
13046        }
13047        #[derive(Debug)]
13048        enum ShutdownMsg {
13049            Seen,
13050        }
13051        impl From<Event> for ShutdownMsg {
13052            fn from(_: Event) -> Self {
13053                ShutdownMsg::Seen
13054            }
13055        }
13056        impl Model for ShutdownModel {
13057            type Message = ShutdownMsg;
13058            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13059                match msg {
13060                    ShutdownMsg::Seen => self.seen = true,
13061                }
13062                Cmd::none()
13063            }
13064            fn view(&self, _frame: &mut Frame) {}
13065        }
13066
13067        let config = ProgramConfig::default().with_effect_queue(
13068            EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
13069        );
13070        let mut program = headless_program_with_config(ShutdownModel { seen: false }, config);
13071
13072        // After shutdown, new submissions are rejected (structured ownership):
13073        // the task never runs, so no message is delivered.
13074        program.task_executor.shutdown();
13075        program
13076            .execute_cmd(Cmd::task(|| ShutdownMsg::Seen))
13077            .expect("post-shutdown task cmd");
13078
13079        let deadline = Instant::now() + Duration::from_millis(200);
13080        while Instant::now() <= deadline {
13081            program
13082                .process_task_results()
13083                .expect("process task results");
13084            program.reap_finished_tasks();
13085        }
13086        assert!(
13087            !program.model().seen,
13088            "a task submitted after shutdown was executed"
13089        );
13090    }
13091
13092    #[cfg(feature = "asupersync-executor")]
13093    #[test]
13094    fn asupersync_backpressure_sheds_excess_in_flight_tasks() {
13095        struct SlowModel {
13096            done: u32,
13097        }
13098        #[derive(Debug)]
13099        enum SlowMsg {
13100            Done,
13101        }
13102        impl From<Event> for SlowMsg {
13103            fn from(_: Event) -> Self {
13104                SlowMsg::Done
13105            }
13106        }
13107        impl Model for SlowModel {
13108            type Message = SlowMsg;
13109            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13110                match msg {
13111                    SlowMsg::Done => self.done += 1,
13112                }
13113                Cmd::none()
13114            }
13115            fn view(&self, _frame: &mut Frame) {}
13116        }
13117
13118        let evidence_path = temp_evidence_path("asupersync_backpressure");
13119        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
13120        let config = ProgramConfig::default()
13121            .with_evidence_sink(sink_config)
13122            .with_effect_queue(
13123                EffectQueueConfig::default()
13124                    .with_backend(TaskExecutorBackend::Asupersync)
13125                    .with_max_queue_depth(2),
13126            );
13127        let mut program = headless_program_with_config(SlowModel { done: 0 }, config);
13128
13129        // Submit more slow (still in-flight) tasks than the depth cap allows: the
13130        // first two occupy the in-flight slots, the rest are shed by backpressure.
13131        for _ in 0..6 {
13132            program
13133                .execute_cmd(Cmd::task(|| {
13134                    std::thread::sleep(Duration::from_millis(120));
13135                    SlowMsg::Done
13136                }))
13137                .expect("slow task cmd");
13138        }
13139
13140        // Backpressure must have fired with a recorded drop.
13141        let backpressure_line = read_evidence_event(&evidence_path, "task_executor_backpressure");
13142        assert_eq!(backpressure_line["backend"], "asupersync");
13143        assert_eq!(backpressure_line["action"], "drop");
13144
13145        // Drain the accepted tasks so teardown is prompt; no more than the depth
13146        // cap of tasks may complete.
13147        let deadline = Instant::now() + Duration::from_secs(5);
13148        while program.model().done < 2 && Instant::now() <= deadline {
13149            program
13150                .process_task_results()
13151                .expect("process task results");
13152            program.reap_finished_tasks();
13153        }
13154        assert!(
13155            program.model().done <= 2,
13156            "more tasks completed than the in-flight depth cap permits"
13157        );
13158    }
13159
13160    // =========================================================================
13161    // BatchController Tests (bd-4kq0.8.1)
13162    // =========================================================================
13163
13164    #[test]
13165    fn unit_tau_monotone() {
13166        // τ should decrease (or stay constant) as service time decreases,
13167        // since τ = E[S] × headroom.
13168        let mut bc = BatchController::new();
13169
13170        // High service time → high τ
13171        bc.observe_service(Duration::from_millis(20));
13172        bc.observe_service(Duration::from_millis(20));
13173        bc.observe_service(Duration::from_millis(20));
13174        let tau_high = bc.tau_s();
13175
13176        // Low service time → lower τ
13177        for _ in 0..20 {
13178            bc.observe_service(Duration::from_millis(1));
13179        }
13180        let tau_low = bc.tau_s();
13181
13182        assert!(
13183            tau_low <= tau_high,
13184            "τ should decrease with lower service time: tau_low={tau_low:.6}, tau_high={tau_high:.6}"
13185        );
13186    }
13187
13188    #[test]
13189    fn unit_tau_monotone_lambda() {
13190        // As arrival rate λ decreases (longer inter-arrival times),
13191        // τ should not increase (it's based on service time, not λ).
13192        // But ρ should decrease.
13193        let mut bc = BatchController::new();
13194        let base = Instant::now();
13195
13196        // Fast arrivals (λ high)
13197        for i in 0..10 {
13198            bc.observe_arrival(base + Duration::from_millis(i * 10));
13199        }
13200        let rho_fast = bc.rho_est();
13201
13202        // Slow arrivals (λ low)
13203        for i in 10..20 {
13204            bc.observe_arrival(base + Duration::from_millis(100 + i * 100));
13205        }
13206        let rho_slow = bc.rho_est();
13207
13208        assert!(
13209            rho_slow < rho_fast,
13210            "ρ should decrease with slower arrivals: rho_slow={rho_slow:.4}, rho_fast={rho_fast:.4}"
13211        );
13212    }
13213
13214    #[test]
13215    fn unit_stability() {
13216        // With reasonable service times, the controller should keep ρ < 1.
13217        let mut bc = BatchController::new();
13218        let base = Instant::now();
13219
13220        // Moderate arrival rate: 30 events/sec
13221        for i in 0..30 {
13222            bc.observe_arrival(base + Duration::from_millis(i * 33));
13223            bc.observe_service(Duration::from_millis(5)); // 5ms render
13224        }
13225
13226        assert!(
13227            bc.is_stable(),
13228            "should be stable at 30 events/sec with 5ms service: ρ={:.4}",
13229            bc.rho_est()
13230        );
13231        assert!(
13232            bc.rho_est() < 1.0,
13233            "utilization should be < 1: ρ={:.4}",
13234            bc.rho_est()
13235        );
13236
13237        // τ must be > E[S] (stability requirement)
13238        assert!(
13239            bc.tau_s() > bc.service_est_s(),
13240            "τ ({:.6}) must exceed E[S] ({:.6}) for stability",
13241            bc.tau_s(),
13242            bc.service_est_s()
13243        );
13244    }
13245
13246    #[test]
13247    fn unit_stability_high_load() {
13248        // Even under high load, τ keeps the system stable.
13249        let mut bc = BatchController::new();
13250        let base = Instant::now();
13251
13252        // 100 events/sec with 8ms render
13253        for i in 0..50 {
13254            bc.observe_arrival(base + Duration::from_millis(i * 10));
13255            bc.observe_service(Duration::from_millis(8));
13256        }
13257
13258        // τ × ρ_eff = E[S]/τ should be < 1
13259        let tau = bc.tau_s();
13260        let rho_eff = bc.service_est_s() / tau;
13261        assert!(
13262            rho_eff < 1.0,
13263            "effective utilization should be < 1: ρ_eff={rho_eff:.4}, τ={tau:.6}, E[S]={:.6}",
13264            bc.service_est_s()
13265        );
13266    }
13267
13268    #[test]
13269    fn batch_controller_defaults() {
13270        let bc = BatchController::new();
13271        assert!(bc.tau_s() >= bc.tau_min_s);
13272        assert!(bc.tau_s() <= bc.tau_max_s);
13273        assert_eq!(bc.observations(), 0);
13274        assert!(bc.is_stable());
13275    }
13276
13277    #[test]
13278    fn batch_controller_tau_clamped() {
13279        let mut bc = BatchController::new();
13280
13281        // Very fast service → τ clamped to tau_min
13282        for _ in 0..20 {
13283            bc.observe_service(Duration::from_micros(10));
13284        }
13285        assert!(
13286            bc.tau_s() >= bc.tau_min_s,
13287            "τ should be >= tau_min: τ={:.6}, min={:.6}",
13288            bc.tau_s(),
13289            bc.tau_min_s
13290        );
13291
13292        // Very slow service → τ clamped to tau_max
13293        for _ in 0..20 {
13294            bc.observe_service(Duration::from_millis(100));
13295        }
13296        assert!(
13297            bc.tau_s() <= bc.tau_max_s,
13298            "τ should be <= tau_max: τ={:.6}, max={:.6}",
13299            bc.tau_s(),
13300            bc.tau_max_s
13301        );
13302    }
13303
13304    #[test]
13305    fn batch_controller_duration_conversion() {
13306        let bc = BatchController::new();
13307        let tau = bc.tau();
13308        let tau_s = bc.tau_s();
13309        // Duration should match f64 representation
13310        let diff = (tau.as_secs_f64() - tau_s).abs();
13311        assert!(diff < 1e-9, "Duration conversion mismatch: {diff}");
13312    }
13313
13314    #[test]
13315    fn batch_controller_lambda_estimation() {
13316        let mut bc = BatchController::new();
13317        let base = Instant::now();
13318
13319        // 50 events/sec (20ms apart)
13320        for i in 0..20 {
13321            bc.observe_arrival(base + Duration::from_millis(i * 20));
13322        }
13323
13324        // λ should converge near 50
13325        let lambda = bc.lambda_est();
13326        assert!(
13327            lambda > 20.0 && lambda < 100.0,
13328            "λ should be near 50: got {lambda:.1}"
13329        );
13330    }
13331
13332    // ─────────────────────────────────────────────────────────────────────────────
13333    // Persistence Config Tests
13334    // ─────────────────────────────────────────────────────────────────────────────
13335
13336    #[test]
13337    fn cmd_save_state() {
13338        let cmd: Cmd<TestMsg> = Cmd::save_state();
13339        assert!(matches!(cmd, Cmd::SaveState));
13340    }
13341
13342    #[test]
13343    fn cmd_restore_state() {
13344        let cmd: Cmd<TestMsg> = Cmd::restore_state();
13345        assert!(matches!(cmd, Cmd::RestoreState));
13346    }
13347
13348    #[test]
13349    fn persistence_config_default() {
13350        let config = PersistenceConfig::default();
13351        assert!(config.registry.is_none());
13352        assert!(config.checkpoint_interval.is_none());
13353        assert!(config.auto_load);
13354        assert!(config.auto_save);
13355    }
13356
13357    #[test]
13358    fn persistence_config_disabled() {
13359        let config = PersistenceConfig::disabled();
13360        assert!(config.registry.is_none());
13361    }
13362
13363    #[test]
13364    fn persistence_config_with_registry() {
13365        use crate::state_persistence::{MemoryStorage, StateRegistry};
13366        use std::sync::Arc;
13367
13368        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
13369        let config = PersistenceConfig::with_registry(registry.clone());
13370
13371        assert!(config.registry.is_some());
13372        assert!(config.auto_load);
13373        assert!(config.auto_save);
13374    }
13375
13376    #[test]
13377    fn persistence_config_checkpoint_interval() {
13378        use crate::state_persistence::{MemoryStorage, StateRegistry};
13379        use std::sync::Arc;
13380
13381        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
13382        let config = PersistenceConfig::with_registry(registry)
13383            .checkpoint_every(Duration::from_secs(30))
13384            .auto_load(false)
13385            .auto_save(true);
13386
13387        assert!(config.checkpoint_interval.is_some());
13388        assert_eq!(config.checkpoint_interval.unwrap(), Duration::from_secs(30));
13389        assert!(!config.auto_load);
13390        assert!(config.auto_save);
13391    }
13392
13393    #[test]
13394    fn program_config_with_persistence() {
13395        use crate::state_persistence::{MemoryStorage, StateRegistry};
13396        use std::sync::Arc;
13397
13398        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
13399        let config = ProgramConfig::default().with_registry(registry);
13400
13401        assert!(config.persistence.registry.is_some());
13402    }
13403
13404    // =========================================================================
13405    // TaskSpec tests (bd-2yjus)
13406    // =========================================================================
13407
13408    #[test]
13409    fn task_spec_default() {
13410        let spec = TaskSpec::default();
13411        assert_eq!(spec.weight, DEFAULT_TASK_WEIGHT);
13412        assert_eq!(spec.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
13413        assert!(spec.name.is_none());
13414    }
13415
13416    #[test]
13417    fn task_spec_new() {
13418        let spec = TaskSpec::new(5.0, 20.0);
13419        assert_eq!(spec.weight, 5.0);
13420        assert_eq!(spec.estimate_ms, 20.0);
13421        assert!(spec.name.is_none());
13422    }
13423
13424    #[test]
13425    fn task_spec_with_name() {
13426        let spec = TaskSpec::default().with_name("fetch_data");
13427        assert_eq!(spec.name.as_deref(), Some("fetch_data"));
13428    }
13429
13430    #[test]
13431    fn task_spec_debug() {
13432        let spec = TaskSpec::new(2.0, 15.0).with_name("test");
13433        let debug = format!("{spec:?}");
13434        assert!(debug.contains("2.0"));
13435        assert!(debug.contains("15.0"));
13436        assert!(debug.contains("test"));
13437    }
13438
13439    // =========================================================================
13440    // Cmd::count() tests (bd-2yjus)
13441    // =========================================================================
13442
13443    #[test]
13444    fn cmd_count_none() {
13445        let cmd: Cmd<TestMsg> = Cmd::none();
13446        assert_eq!(cmd.count(), 0);
13447    }
13448
13449    #[test]
13450    fn cmd_count_atomic() {
13451        assert_eq!(Cmd::<TestMsg>::quit().count(), 1);
13452        assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).count(), 1);
13453        assert_eq!(Cmd::<TestMsg>::tick(Duration::from_millis(100)).count(), 1);
13454        assert_eq!(Cmd::<TestMsg>::log("hello").count(), 1);
13455        assert_eq!(Cmd::<TestMsg>::save_state().count(), 1);
13456        assert_eq!(Cmd::<TestMsg>::restore_state().count(), 1);
13457        assert_eq!(Cmd::<TestMsg>::set_mouse_capture(true).count(), 1);
13458    }
13459
13460    #[test]
13461    fn cmd_count_batch() {
13462        let cmd: Cmd<TestMsg> =
13463            Cmd::Batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment), Cmd::none()]);
13464        assert_eq!(cmd.count(), 2); // quit + msg, none counts 0
13465    }
13466
13467    #[test]
13468    fn cmd_count_nested() {
13469        let cmd: Cmd<TestMsg> = Cmd::Batch(vec![
13470            Cmd::msg(TestMsg::Increment),
13471            Cmd::Sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]),
13472        ]);
13473        assert_eq!(cmd.count(), 3);
13474    }
13475
13476    // =========================================================================
13477    // Cmd::type_name() tests (bd-2yjus)
13478    // =========================================================================
13479
13480    #[test]
13481    fn cmd_type_name_all_variants() {
13482        assert_eq!(Cmd::<TestMsg>::none().type_name(), "None");
13483        assert_eq!(Cmd::<TestMsg>::quit().type_name(), "Quit");
13484        assert_eq!(
13485            Cmd::<TestMsg>::Batch(vec![Cmd::none()]).type_name(),
13486            "Batch"
13487        );
13488        assert_eq!(
13489            Cmd::<TestMsg>::Sequence(vec![Cmd::none()]).type_name(),
13490            "Sequence"
13491        );
13492        assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).type_name(), "Msg");
13493        assert_eq!(
13494            Cmd::<TestMsg>::tick(Duration::from_millis(1)).type_name(),
13495            "Tick"
13496        );
13497        assert_eq!(Cmd::<TestMsg>::log("x").type_name(), "Log");
13498        assert_eq!(
13499            Cmd::<TestMsg>::task(|| TestMsg::Increment).type_name(),
13500            "Task"
13501        );
13502        assert_eq!(Cmd::<TestMsg>::save_state().type_name(), "SaveState");
13503        assert_eq!(Cmd::<TestMsg>::restore_state().type_name(), "RestoreState");
13504        assert_eq!(
13505            Cmd::<TestMsg>::set_mouse_capture(true).type_name(),
13506            "SetMouseCapture"
13507        );
13508    }
13509
13510    // =========================================================================
13511    // Cmd::batch() / Cmd::sequence() edge-case tests (bd-2yjus)
13512    // =========================================================================
13513
13514    #[test]
13515    fn cmd_batch_empty_returns_none() {
13516        let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
13517        assert!(matches!(cmd, Cmd::None));
13518    }
13519
13520    #[test]
13521    fn cmd_batch_single_unwraps() {
13522        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
13523        assert!(matches!(cmd, Cmd::Quit));
13524    }
13525
13526    #[test]
13527    fn cmd_batch_multiple_stays_batch() {
13528        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
13529        assert!(matches!(cmd, Cmd::Batch(_)));
13530    }
13531
13532    #[test]
13533    fn cmd_sequence_empty_returns_none() {
13534        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
13535        assert!(matches!(cmd, Cmd::None));
13536    }
13537
13538    #[test]
13539    fn cmd_sequence_single_unwraps_to_inner() {
13540        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
13541        assert!(matches!(cmd, Cmd::Quit));
13542    }
13543
13544    #[test]
13545    fn cmd_sequence_multiple_stays_sequence() {
13546        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
13547        assert!(matches!(cmd, Cmd::Sequence(_)));
13548    }
13549
13550    // =========================================================================
13551    // Cmd task constructor variants (bd-2yjus)
13552    // =========================================================================
13553
13554    #[test]
13555    fn cmd_task_with_spec() {
13556        let spec = TaskSpec::new(3.0, 25.0).with_name("my_task");
13557        let cmd: Cmd<TestMsg> = Cmd::task_with_spec(spec, || TestMsg::Increment);
13558        match cmd {
13559            Cmd::Task(s, _) => {
13560                assert_eq!(s.weight, 3.0);
13561                assert_eq!(s.estimate_ms, 25.0);
13562                assert_eq!(s.name.as_deref(), Some("my_task"));
13563            }
13564            _ => panic!("expected Task variant"),
13565        }
13566    }
13567
13568    #[test]
13569    fn cmd_task_weighted() {
13570        let cmd: Cmd<TestMsg> = Cmd::task_weighted(2.0, 50.0, || TestMsg::Increment);
13571        match cmd {
13572            Cmd::Task(s, _) => {
13573                assert_eq!(s.weight, 2.0);
13574                assert_eq!(s.estimate_ms, 50.0);
13575                assert!(s.name.is_none());
13576            }
13577            _ => panic!("expected Task variant"),
13578        }
13579    }
13580
13581    #[test]
13582    fn cmd_task_named() {
13583        let cmd: Cmd<TestMsg> = Cmd::task_named("background_fetch", || TestMsg::Increment);
13584        match cmd {
13585            Cmd::Task(s, _) => {
13586                assert_eq!(s.weight, DEFAULT_TASK_WEIGHT);
13587                assert_eq!(s.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
13588                assert_eq!(s.name.as_deref(), Some("background_fetch"));
13589            }
13590            _ => panic!("expected Task variant"),
13591        }
13592    }
13593
13594    // =========================================================================
13595    // Cmd Debug formatting (bd-2yjus)
13596    // =========================================================================
13597
13598    #[test]
13599    fn cmd_debug_all_variant_strings() {
13600        assert_eq!(format!("{:?}", Cmd::<TestMsg>::none()), "None");
13601        assert_eq!(format!("{:?}", Cmd::<TestMsg>::quit()), "Quit");
13602        assert!(format!("{:?}", Cmd::<TestMsg>::msg(TestMsg::Increment)).starts_with("Msg("));
13603        assert!(
13604            format!("{:?}", Cmd::<TestMsg>::tick(Duration::from_millis(100))).starts_with("Tick(")
13605        );
13606        assert!(format!("{:?}", Cmd::<TestMsg>::log("hi")).starts_with("Log("));
13607        assert!(format!("{:?}", Cmd::<TestMsg>::task(|| TestMsg::Increment)).starts_with("Task"));
13608        assert_eq!(format!("{:?}", Cmd::<TestMsg>::save_state()), "SaveState");
13609        assert_eq!(
13610            format!("{:?}", Cmd::<TestMsg>::restore_state()),
13611            "RestoreState"
13612        );
13613        assert_eq!(
13614            format!("{:?}", Cmd::<TestMsg>::set_mouse_capture(true)),
13615            "SetMouseCapture(true)"
13616        );
13617    }
13618
13619    // =========================================================================
13620    // Cmd::set_mouse_capture headless execution (bd-2yjus)
13621    // =========================================================================
13622
13623    #[test]
13624    fn headless_execute_cmd_set_mouse_capture() {
13625        let mut program =
13626            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
13627        assert!(!program.backend_features.mouse_capture);
13628
13629        program
13630            .execute_cmd(Cmd::set_mouse_capture(true))
13631            .expect("set mouse capture true");
13632        assert!(program.backend_features.mouse_capture);
13633
13634        program
13635            .execute_cmd(Cmd::set_mouse_capture(false))
13636            .expect("set mouse capture false");
13637        assert!(!program.backend_features.mouse_capture);
13638    }
13639
13640    // =========================================================================
13641    // ResizeBehavior tests (bd-2yjus)
13642    // =========================================================================
13643
13644    #[test]
13645    fn resize_behavior_uses_coalescer() {
13646        assert!(ResizeBehavior::Throttled.uses_coalescer());
13647        assert!(!ResizeBehavior::Immediate.uses_coalescer());
13648    }
13649
13650    #[test]
13651    fn resize_behavior_eq_and_debug() {
13652        assert_eq!(ResizeBehavior::Immediate, ResizeBehavior::Immediate);
13653        assert_ne!(ResizeBehavior::Immediate, ResizeBehavior::Throttled);
13654        let debug = format!("{:?}", ResizeBehavior::Throttled);
13655        assert_eq!(debug, "Throttled");
13656    }
13657
13658    // =========================================================================
13659    // WidgetRefreshConfig default values (bd-2yjus)
13660    // =========================================================================
13661
13662    #[test]
13663    fn widget_refresh_config_defaults() {
13664        let config = WidgetRefreshConfig::default();
13665        assert!(config.enabled);
13666        assert_eq!(config.staleness_window_ms, 1_000);
13667        assert_eq!(config.starve_ms, 3_000);
13668        assert_eq!(config.max_starved_per_frame, 2);
13669        assert_eq!(config.max_drop_fraction, 1.0);
13670        assert_eq!(config.weight_priority, 1.0);
13671        assert_eq!(config.weight_staleness, 0.5);
13672        assert_eq!(config.weight_focus, 0.75);
13673        assert_eq!(config.weight_interaction, 0.5);
13674        assert_eq!(config.starve_boost, 1.5);
13675        assert_eq!(config.min_cost_us, 1.0);
13676    }
13677
13678    // =========================================================================
13679    // EffectQueueConfig tests (bd-2yjus)
13680    // =========================================================================
13681
13682    #[test]
13683    fn effect_queue_config_default() {
13684        let config = EffectQueueConfig::default();
13685        assert!(!config.enabled);
13686        assert_eq!(config.backend, TaskExecutorBackend::Spawned);
13687        assert!(!config.explicit_backend);
13688        assert!(config.scheduler.smith_enabled);
13689        assert!(!config.scheduler.force_fifo);
13690        assert!(!config.scheduler.preemptive);
13691    }
13692
13693    #[test]
13694    fn effect_queue_config_with_enabled() {
13695        let config = EffectQueueConfig::default().with_enabled(true);
13696        assert!(config.enabled);
13697        assert_eq!(config.backend, TaskExecutorBackend::EffectQueue);
13698        assert!(config.explicit_backend);
13699    }
13700
13701    #[test]
13702    fn effect_queue_config_with_enabled_false_marks_explicit_spawned_backend() {
13703        let config = EffectQueueConfig::default().with_enabled(false);
13704        assert!(!config.enabled);
13705        assert_eq!(config.backend, TaskExecutorBackend::Spawned);
13706        assert!(config.explicit_backend);
13707    }
13708
13709    #[test]
13710    fn effect_queue_config_with_backend() {
13711        let config = EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue);
13712        assert!(config.enabled);
13713        assert_eq!(config.backend, TaskExecutorBackend::EffectQueue);
13714        assert!(config.explicit_backend);
13715    }
13716
13717    #[cfg(feature = "asupersync-executor")]
13718    #[test]
13719    fn effect_queue_config_with_asupersync_backend_disables_effect_queue_flag() {
13720        let config = EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync);
13721        assert!(!config.enabled);
13722        assert_eq!(config.backend, TaskExecutorBackend::Asupersync);
13723    }
13724
13725    #[test]
13726    fn effect_queue_config_with_scheduler() {
13727        let sched = SchedulerConfig {
13728            force_fifo: true,
13729            ..Default::default()
13730        };
13731        let config = EffectQueueConfig::default().with_scheduler(sched);
13732        assert!(config.scheduler.force_fifo);
13733    }
13734
13735    // =========================================================================
13736    // InlineAutoRemeasureConfig defaults (bd-2yjus)
13737    // =========================================================================
13738
13739    #[test]
13740    fn inline_auto_remeasure_config_defaults() {
13741        let config = InlineAutoRemeasureConfig::default();
13742        assert_eq!(config.change_threshold_rows, 1);
13743        assert_eq!(config.voi.prior_alpha, 1.0);
13744        assert_eq!(config.voi.prior_beta, 9.0);
13745        assert_eq!(config.voi.max_interval_ms, 1000);
13746        assert_eq!(config.voi.min_interval_ms, 100);
13747        assert_eq!(config.voi.sample_cost, 0.08);
13748    }
13749
13750    // =========================================================================
13751    // HeadlessEventSource direct tests (bd-2yjus)
13752    // =========================================================================
13753
13754    #[test]
13755    fn headless_event_source_size() {
13756        let source = HeadlessEventSource::new(120, 40, BackendFeatures::default());
13757        assert_eq!(source.size().unwrap(), (120, 40));
13758    }
13759
13760    #[test]
13761    fn headless_event_source_poll_always_false() {
13762        let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
13763        assert!(!source.poll_event(Duration::from_millis(100)).unwrap());
13764    }
13765
13766    #[test]
13767    fn headless_event_source_read_always_none() {
13768        let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
13769        assert!(source.read_event().unwrap().is_none());
13770    }
13771
13772    #[test]
13773    fn headless_event_source_set_features() {
13774        let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
13775        let features = BackendFeatures {
13776            mouse_capture: true,
13777            bracketed_paste: true,
13778            focus_events: true,
13779            kitty_keyboard: true,
13780        };
13781        source.set_features(features).unwrap();
13782        assert_eq!(source.features, features);
13783    }
13784
13785    #[test]
13786    fn immediate_drain_budget_adds_backoff_poll_under_burst() {
13787        use ftui_core::event::{KeyCode, KeyEvent};
13788
13789        struct DrainBurstModel {
13790            processed: usize,
13791            quit_after: usize,
13792        }
13793
13794        #[derive(Debug)]
13795        #[allow(dead_code)]
13796        enum DrainBurstMsg {
13797            Event(Event),
13798        }
13799
13800        impl From<Event> for DrainBurstMsg {
13801            fn from(event: Event) -> Self {
13802                DrainBurstMsg::Event(event)
13803            }
13804        }
13805
13806        impl Model for DrainBurstModel {
13807            type Message = DrainBurstMsg;
13808
13809            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13810                match msg {
13811                    DrainBurstMsg::Event(_) => {
13812                        self.processed = self.processed.saturating_add(1);
13813                        if self.processed >= self.quit_after {
13814                            Cmd::quit()
13815                        } else {
13816                            Cmd::none()
13817                        }
13818                    }
13819                }
13820            }
13821
13822            fn view(&self, _frame: &mut Frame) {}
13823        }
13824
13825        struct DrainBurstEventSource {
13826            queue: VecDeque<Event>,
13827            poll_timeouts: Arc<std::sync::Mutex<Vec<Duration>>>,
13828            size: (u16, u16),
13829        }
13830
13831        impl BackendEventSource for DrainBurstEventSource {
13832            type Error = io::Error;
13833
13834            fn size(&self) -> Result<(u16, u16), Self::Error> {
13835                Ok(self.size)
13836            }
13837
13838            fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
13839                Ok(())
13840            }
13841
13842            fn poll_event(&mut self, timeout: Duration) -> Result<bool, Self::Error> {
13843                self.poll_timeouts.lock().unwrap().push(timeout);
13844                Ok(!self.queue.is_empty())
13845            }
13846
13847            fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
13848                Ok(self.queue.pop_front())
13849            }
13850        }
13851
13852        let burst_events = 24usize;
13853        let poll_timeouts = Arc::new(std::sync::Mutex::new(Vec::new()));
13854        let mut queue = VecDeque::new();
13855        for _ in 0..burst_events {
13856            queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'))));
13857        }
13858
13859        let events = DrainBurstEventSource {
13860            queue,
13861            poll_timeouts: poll_timeouts.clone(),
13862            size: (80, 24),
13863        };
13864        let writer = TerminalWriter::new(
13865            Vec::<u8>::new(),
13866            ScreenMode::AltScreen,
13867            UiAnchor::Bottom,
13868            TerminalCapabilities::dumb(),
13869        );
13870        let config = ProgramConfig::default()
13871            .with_forced_size(80, 24)
13872            .with_signal_interception(false)
13873            .with_immediate_drain(ImmediateDrainConfig {
13874                max_zero_timeout_polls_per_burst: 3,
13875                max_burst_duration: Duration::from_secs(1),
13876                backoff_timeout: Duration::from_millis(1),
13877            });
13878
13879        let model = DrainBurstModel {
13880            processed: 0,
13881            quit_after: burst_events,
13882        };
13883        let mut program =
13884            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
13885                .expect("program creation");
13886        program.run().expect("run burst");
13887
13888        assert_eq!(program.model().processed, burst_events);
13889
13890        let stats = program.immediate_drain_stats();
13891        assert_eq!(stats.bursts, 1);
13892        assert!(stats.capped_bursts >= 1);
13893        assert!(stats.backoff_polls >= 1);
13894        assert!(stats.zero_timeout_polls >= 1);
13895        assert!(stats.max_zero_timeout_polls_in_burst <= 3);
13896
13897        let timeouts = poll_timeouts.lock().unwrap();
13898        assert!(timeouts.contains(&Duration::ZERO));
13899        assert!(timeouts.contains(&Duration::from_millis(1)));
13900    }
13901
13902    #[test]
13903    fn immediate_drain_zero_poll_limit_is_clamped() {
13904        use ftui_core::event::{KeyCode, KeyEvent};
13905
13906        struct ClampModel {
13907            processed: usize,
13908            quit_after: usize,
13909        }
13910
13911        #[derive(Debug)]
13912        #[allow(dead_code)]
13913        enum ClampMsg {
13914            Event(Event),
13915        }
13916
13917        impl From<Event> for ClampMsg {
13918            fn from(event: Event) -> Self {
13919                ClampMsg::Event(event)
13920            }
13921        }
13922
13923        impl Model for ClampModel {
13924            type Message = ClampMsg;
13925
13926            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13927                match msg {
13928                    ClampMsg::Event(_) => {
13929                        self.processed = self.processed.saturating_add(1);
13930                        if self.processed >= self.quit_after {
13931                            Cmd::quit()
13932                        } else {
13933                            Cmd::none()
13934                        }
13935                    }
13936                }
13937            }
13938
13939            fn view(&self, _frame: &mut Frame) {}
13940        }
13941
13942        struct ClampSource {
13943            queue: VecDeque<Event>,
13944        }
13945
13946        impl BackendEventSource for ClampSource {
13947            type Error = io::Error;
13948
13949            fn size(&self) -> Result<(u16, u16), Self::Error> {
13950                Ok((80, 24))
13951            }
13952
13953            fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
13954                Ok(())
13955            }
13956
13957            fn poll_event(&mut self, _timeout: Duration) -> Result<bool, Self::Error> {
13958                Ok(!self.queue.is_empty())
13959            }
13960
13961            fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
13962                Ok(self.queue.pop_front())
13963            }
13964        }
13965
13966        let burst_events = 8usize;
13967        let mut queue = VecDeque::new();
13968        for _ in 0..burst_events {
13969            queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('z'))));
13970        }
13971        let events = ClampSource { queue };
13972
13973        let writer = TerminalWriter::new(
13974            Vec::<u8>::new(),
13975            ScreenMode::AltScreen,
13976            UiAnchor::Bottom,
13977            TerminalCapabilities::dumb(),
13978        );
13979        let config = ProgramConfig::default()
13980            .with_forced_size(80, 24)
13981            .with_signal_interception(false)
13982            .with_immediate_drain(ImmediateDrainConfig {
13983                max_zero_timeout_polls_per_burst: 0,
13984                max_burst_duration: Duration::from_secs(1),
13985                backoff_timeout: Duration::from_millis(1),
13986            });
13987        let model = ClampModel {
13988            processed: 0,
13989            quit_after: burst_events,
13990        };
13991
13992        let mut program =
13993            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
13994                .expect("program creation");
13995        program.run().expect("run clamp");
13996
13997        let stats = program.immediate_drain_stats();
13998        assert!(stats.max_zero_timeout_polls_in_burst <= 1);
13999    }
14000
14001    #[test]
14002    fn quit_stops_draining_remaining_burst_events() {
14003        use ftui_core::event::{KeyCode, KeyEvent};
14004
14005        struct QuitBurstModel {
14006            processed: usize,
14007            quit_after: usize,
14008        }
14009
14010        #[derive(Debug)]
14011        #[allow(dead_code)]
14012        enum QuitBurstMsg {
14013            Event(Event),
14014        }
14015
14016        impl From<Event> for QuitBurstMsg {
14017            fn from(event: Event) -> Self {
14018                Self::Event(event)
14019            }
14020        }
14021
14022        impl Model for QuitBurstModel {
14023            type Message = QuitBurstMsg;
14024
14025            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
14026                match msg {
14027                    QuitBurstMsg::Event(_) => {
14028                        self.processed = self.processed.saturating_add(1);
14029                        if self.processed >= self.quit_after {
14030                            Cmd::quit()
14031                        } else {
14032                            Cmd::none()
14033                        }
14034                    }
14035                }
14036            }
14037
14038            fn view(&self, _frame: &mut Frame) {}
14039        }
14040
14041        struct QuitBurstSource {
14042            queue: VecDeque<Event>,
14043        }
14044
14045        impl BackendEventSource for QuitBurstSource {
14046            type Error = io::Error;
14047
14048            fn size(&self) -> Result<(u16, u16), Self::Error> {
14049                Ok((80, 24))
14050            }
14051
14052            fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
14053                Ok(())
14054            }
14055
14056            fn poll_event(&mut self, _timeout: Duration) -> Result<bool, Self::Error> {
14057                Ok(!self.queue.is_empty())
14058            }
14059
14060            fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
14061                Ok(self.queue.pop_front())
14062            }
14063        }
14064
14065        let total_events = 8usize;
14066        let quit_after = 3usize;
14067        let mut queue = VecDeque::new();
14068        for _ in 0..total_events {
14069            queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('q'))));
14070        }
14071
14072        let writer = TerminalWriter::new(
14073            Vec::<u8>::new(),
14074            ScreenMode::AltScreen,
14075            UiAnchor::Bottom,
14076            TerminalCapabilities::dumb(),
14077        );
14078        let config = ProgramConfig::default()
14079            .with_forced_size(80, 24)
14080            .with_signal_interception(false)
14081            .with_immediate_drain(ImmediateDrainConfig {
14082                max_zero_timeout_polls_per_burst: 64,
14083                max_burst_duration: Duration::from_secs(1),
14084                backoff_timeout: Duration::from_millis(1),
14085            });
14086        let model = QuitBurstModel {
14087            processed: 0,
14088            quit_after,
14089        };
14090        let events = QuitBurstSource { queue };
14091
14092        let mut program =
14093            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
14094                .expect("program creation");
14095        program.run().expect("run burst quit");
14096
14097        assert_eq!(program.model().processed, quit_after);
14098    }
14099
14100    // =========================================================================
14101    // Program helper methods (bd-2yjus)
14102    // =========================================================================
14103
14104    #[test]
14105    fn headless_program_quit_and_is_running() {
14106        let mut program =
14107            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14108        assert!(program.is_running());
14109
14110        program.quit();
14111        assert!(!program.is_running());
14112    }
14113
14114    #[test]
14115    fn headless_program_model_mut() {
14116        let mut program =
14117            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14118        assert_eq!(program.model().value, 0);
14119
14120        program.model_mut().value = 42;
14121        assert_eq!(program.model().value, 42);
14122    }
14123
14124    #[test]
14125    fn headless_program_request_redraw() {
14126        let mut program =
14127            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14128        program.dirty = false;
14129
14130        program.request_redraw();
14131        assert!(program.dirty);
14132    }
14133
14134    #[test]
14135    fn headless_program_last_widget_signals_initially_empty() {
14136        let program =
14137            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14138        assert!(program.last_widget_signals().is_empty());
14139    }
14140
14141    #[test]
14142    fn headless_program_no_persistence_by_default() {
14143        let program =
14144            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14145        assert!(!program.has_persistence());
14146        assert!(program.state_registry().is_none());
14147    }
14148
14149    // =========================================================================
14150    // classify_event_for_fairness (bd-2yjus)
14151    // =========================================================================
14152
14153    #[test]
14154    fn classify_event_fairness_key_is_input() {
14155        let event = Event::Key(ftui_core::event::KeyEvent::new(
14156            ftui_core::event::KeyCode::Char('a'),
14157        ));
14158        let classification =
14159            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14160        assert_eq!(classification, FairnessEventType::Input);
14161    }
14162
14163    #[test]
14164    fn classify_event_fairness_resize_is_resize() {
14165        let event = Event::Resize {
14166            width: 80,
14167            height: 24,
14168        };
14169        let classification =
14170            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14171        assert_eq!(classification, FairnessEventType::Resize);
14172    }
14173
14174    #[test]
14175    fn classify_event_fairness_tick_is_tick() {
14176        let event = Event::Tick;
14177        let classification =
14178            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14179        assert_eq!(classification, FairnessEventType::Tick);
14180    }
14181
14182    #[test]
14183    fn classify_event_fairness_paste_is_input() {
14184        let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("hello"));
14185        let classification =
14186            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14187        assert_eq!(classification, FairnessEventType::Input);
14188    }
14189
14190    #[test]
14191    fn classify_event_fairness_focus_is_input() {
14192        let event = Event::Focus(true);
14193        let classification =
14194            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14195        assert_eq!(classification, FairnessEventType::Input);
14196    }
14197
14198    // =========================================================================
14199    // ProgramConfig builder methods (bd-2yjus)
14200    // =========================================================================
14201
14202    #[test]
14203    fn program_config_with_diff_config() {
14204        let diff = RuntimeDiffConfig::default();
14205        let config = ProgramConfig::default().with_diff_config(diff.clone());
14206        // Just verify it doesn't panic and the field is set
14207        let _ = format!("{:?}", config);
14208    }
14209
14210    #[test]
14211    fn program_config_with_evidence_sink() {
14212        let config =
14213            ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
14214        let _ = format!("{:?}", config);
14215    }
14216
14217    #[test]
14218    fn program_config_with_render_trace() {
14219        let config = ProgramConfig::default().with_render_trace(RenderTraceConfig::default());
14220        let _ = format!("{:?}", config);
14221    }
14222
14223    #[test]
14224    fn program_config_with_locale() {
14225        let config = ProgramConfig::default().with_locale("fr");
14226        let _ = format!("{:?}", config);
14227    }
14228
14229    #[test]
14230    fn program_config_with_locale_context() {
14231        let config = ProgramConfig::default().with_locale_context(LocaleContext::new("de"));
14232        let _ = format!("{:?}", config);
14233    }
14234
14235    #[test]
14236    fn program_config_without_forced_size() {
14237        let config = ProgramConfig::default()
14238            .with_forced_size(80, 24)
14239            .without_forced_size();
14240        assert!(config.forced_size.is_none());
14241    }
14242
14243    #[test]
14244    fn program_config_forced_size_clamps_min() {
14245        let config = ProgramConfig::default().with_forced_size(0, 0);
14246        assert_eq!(config.forced_size, Some((1, 1)));
14247    }
14248
14249    #[test]
14250    fn program_config_with_widget_refresh() {
14251        let wrc = WidgetRefreshConfig {
14252            enabled: false,
14253            ..Default::default()
14254        };
14255        let config = ProgramConfig::default().with_widget_refresh(wrc);
14256        assert!(!config.widget_refresh.enabled);
14257    }
14258
14259    #[test]
14260    fn program_config_with_effect_queue() {
14261        let eqc = EffectQueueConfig::default().with_enabled(true);
14262        let config = ProgramConfig::default().with_effect_queue(eqc);
14263        assert!(config.effect_queue.enabled);
14264        assert_eq!(
14265            config.effect_queue.backend,
14266            TaskExecutorBackend::EffectQueue
14267        );
14268    }
14269
14270    #[test]
14271    fn program_config_with_resize_coalescer_custom() {
14272        let cc = CoalescerConfig {
14273            steady_delay_ms: 42,
14274            ..Default::default()
14275        };
14276        let config = ProgramConfig::default().with_resize_coalescer(cc);
14277        assert_eq!(config.resize_coalescer.steady_delay_ms, 42);
14278    }
14279
14280    #[test]
14281    fn program_config_with_inline_auto_remeasure() {
14282        let config = ProgramConfig::default()
14283            .with_inline_auto_remeasure(InlineAutoRemeasureConfig::default());
14284        assert!(config.inline_auto_remeasure.is_some());
14285
14286        let config = config.without_inline_auto_remeasure();
14287        assert!(config.inline_auto_remeasure.is_none());
14288    }
14289
14290    #[test]
14291    fn program_config_with_persistence_full() {
14292        let pc = PersistenceConfig::disabled();
14293        let config = ProgramConfig::default().with_persistence(pc);
14294        assert!(config.persistence.registry.is_none());
14295    }
14296
14297    #[test]
14298    fn program_config_with_conformal_config() {
14299        let config = ProgramConfig::default()
14300            .with_conformal_config(ConformalConfig::default())
14301            .without_conformal();
14302        assert!(config.conformal_config.is_none());
14303    }
14304
14305    // =========================================================================
14306    // Rollout config builder methods (bd-2crbt)
14307    // =========================================================================
14308
14309    #[test]
14310    fn program_config_with_lane() {
14311        let config = ProgramConfig::default().with_lane(RuntimeLane::Asupersync);
14312        assert_eq!(config.runtime_lane, RuntimeLane::Asupersync);
14313    }
14314
14315    #[test]
14316    fn program_config_default_lane_resolves_to_spawned_backend() {
14317        // Input-lag regression fix (#78): the default Structured lane now
14318        // resolves to per-task `Spawned` execution instead of the single-worker
14319        // `EffectQueue`. Structured cancellation is independent of the task
14320        // executor backend, so the lane no longer serializes `Cmd::Task` through
14321        // one `effect_queue_loop` worker (which caused per-keystroke head-of-line
14322        // latency for PTY-forwarding apps). `enabled` is the legacy convenience
14323        // flag mirroring the backend, so it is now false for the default lane.
14324        let resolved = ProgramConfig::default().resolved_effect_queue_config();
14325        assert!(!resolved.enabled);
14326        assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
14327    }
14328
14329    #[test]
14330    fn program_config_legacy_lane_resolves_to_spawned_backend() {
14331        let resolved = ProgramConfig::default()
14332            .with_lane(RuntimeLane::Legacy)
14333            .resolved_effect_queue_config();
14334        assert!(!resolved.enabled);
14335        assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
14336    }
14337
14338    #[test]
14339    fn program_config_explicit_spawned_backend_is_preserved() {
14340        let resolved = ProgramConfig::default()
14341            .with_effect_queue(EffectQueueConfig::default().with_enabled(false))
14342            .resolved_effect_queue_config();
14343        assert!(!resolved.enabled);
14344        assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
14345    }
14346
14347    #[test]
14348    fn program_config_with_rollout_policy() {
14349        let config = ProgramConfig::default().with_rollout_policy(RolloutPolicy::Shadow);
14350        assert_eq!(config.rollout_policy, RolloutPolicy::Shadow);
14351    }
14352
14353    #[test]
14354    fn rollout_policy_labels() {
14355        assert_eq!(RolloutPolicy::Off.label(), "off");
14356        assert_eq!(RolloutPolicy::Shadow.label(), "shadow");
14357        assert_eq!(RolloutPolicy::Enabled.label(), "enabled");
14358        assert_eq!(format!("{}", RolloutPolicy::Shadow), "shadow");
14359    }
14360
14361    #[test]
14362    fn rollout_policy_is_shadow() {
14363        assert!(!RolloutPolicy::Off.is_shadow());
14364        assert!(RolloutPolicy::Shadow.is_shadow());
14365        assert!(!RolloutPolicy::Enabled.is_shadow());
14366    }
14367
14368    #[test]
14369    fn rollout_policy_default_is_off() {
14370        assert_eq!(RolloutPolicy::default(), RolloutPolicy::Off);
14371    }
14372
14373    #[test]
14374    fn runtime_lane_parse_legacy() {
14375        assert_eq!(RuntimeLane::parse("legacy"), Some(RuntimeLane::Legacy));
14376    }
14377
14378    #[test]
14379    fn runtime_lane_parse_structured_case_insensitive() {
14380        assert_eq!(
14381            RuntimeLane::parse("Structured"),
14382            Some(RuntimeLane::Structured)
14383        );
14384    }
14385
14386    #[test]
14387    fn runtime_lane_parse_asupersync_uppercase() {
14388        assert_eq!(
14389            RuntimeLane::parse("ASUPERSYNC"),
14390            Some(RuntimeLane::Asupersync)
14391        );
14392    }
14393
14394    #[test]
14395    fn runtime_lane_parse_unrecognized() {
14396        assert_eq!(RuntimeLane::parse("bogus"), None);
14397    }
14398
14399    #[test]
14400    fn rollout_policy_parse_shadow() {
14401        assert_eq!(RolloutPolicy::parse("shadow"), Some(RolloutPolicy::Shadow));
14402    }
14403
14404    #[test]
14405    fn rollout_policy_parse_enabled() {
14406        assert_eq!(
14407            RolloutPolicy::parse("enabled"),
14408            Some(RolloutPolicy::Enabled)
14409        );
14410    }
14411
14412    #[test]
14413    fn rollout_policy_parse_off() {
14414        assert_eq!(RolloutPolicy::parse("off"), Some(RolloutPolicy::Off));
14415    }
14416
14417    #[test]
14418    fn rollout_policy_parse_unrecognized() {
14419        assert_eq!(RolloutPolicy::parse("bogus"), None);
14420    }
14421
14422    // =========================================================================
14423    // PersistenceConfig Debug (bd-2yjus)
14424    // =========================================================================
14425
14426    #[test]
14427    fn persistence_config_debug() {
14428        let config = PersistenceConfig::default();
14429        let debug = format!("{config:?}");
14430        assert!(debug.contains("PersistenceConfig"));
14431        assert!(debug.contains("auto_load"));
14432        assert!(debug.contains("auto_save"));
14433    }
14434
14435    // =========================================================================
14436    // FrameTimingConfig (bd-2yjus)
14437    // =========================================================================
14438
14439    #[test]
14440    fn frame_timing_config_debug() {
14441        use std::sync::Arc;
14442
14443        struct DummySink;
14444        impl FrameTimingSink for DummySink {
14445            fn record_frame(&self, _timing: &FrameTiming) {}
14446        }
14447
14448        let config = FrameTimingConfig::new(Arc::new(DummySink));
14449        let debug = format!("{config:?}");
14450        assert!(debug.contains("FrameTimingConfig"));
14451    }
14452
14453    #[test]
14454    fn program_config_with_frame_timing() {
14455        use std::sync::Arc;
14456
14457        struct DummySink;
14458        impl FrameTimingSink for DummySink {
14459            fn record_frame(&self, _timing: &FrameTiming) {}
14460        }
14461
14462        let config =
14463            ProgramConfig::default().with_frame_timing(FrameTimingConfig::new(Arc::new(DummySink)));
14464        assert!(config.frame_timing.is_some());
14465    }
14466
14467    // =========================================================================
14468    // BudgetDecisionEvidence helper functions (bd-2yjus)
14469    // =========================================================================
14470
14471    #[test]
14472    fn budget_decision_evidence_decision_from_levels() {
14473        use ftui_render::budget::DegradationLevel;
14474        // Degrade: after > before
14475        assert_eq!(
14476            BudgetDecisionEvidence::decision_from_levels(
14477                DegradationLevel::Full,
14478                DegradationLevel::SimpleBorders
14479            ),
14480            BudgetDecision::Degrade
14481        );
14482        // Upgrade: after < before
14483        assert_eq!(
14484            BudgetDecisionEvidence::decision_from_levels(
14485                DegradationLevel::SimpleBorders,
14486                DegradationLevel::Full
14487            ),
14488            BudgetDecision::Upgrade
14489        );
14490        // Hold: same
14491        assert_eq!(
14492            BudgetDecisionEvidence::decision_from_levels(
14493                DegradationLevel::Full,
14494                DegradationLevel::Full
14495            ),
14496            BudgetDecision::Hold
14497        );
14498    }
14499
14500    // =========================================================================
14501    // WidgetRefreshPlan (bd-2yjus)
14502    // =========================================================================
14503
14504    #[test]
14505    fn widget_refresh_plan_clear() {
14506        let mut plan = WidgetRefreshPlan::new();
14507        plan.frame_idx = 5;
14508        plan.budget_us = 100.0;
14509        plan.signal_count = 3;
14510        plan.over_budget = true;
14511        plan.clear();
14512        assert_eq!(plan.frame_idx, 0);
14513        assert_eq!(plan.budget_us, 0.0);
14514        assert_eq!(plan.signal_count, 0);
14515        assert!(!plan.over_budget);
14516    }
14517
14518    #[test]
14519    fn widget_refresh_plan_as_budget_empty_signals() {
14520        let plan = WidgetRefreshPlan::new();
14521        let budget = plan.as_budget();
14522        // With signal_count == 0, should be allow_all (allows any widget)
14523        assert!(budget.allows(0, false));
14524        assert!(budget.allows(999, false));
14525    }
14526
14527    #[test]
14528    fn widget_refresh_plan_to_jsonl_structure() {
14529        let plan = WidgetRefreshPlan::new();
14530        let jsonl = plan.to_jsonl();
14531        assert!(jsonl.contains("\"event\":\"widget_refresh\""));
14532        assert!(jsonl.contains("\"frame_idx\":0"));
14533        assert!(jsonl.contains("\"selected\":[]"));
14534    }
14535
14536    // =========================================================================
14537    // BatchController Default trait (bd-2yjus)
14538    // =========================================================================
14539
14540    #[test]
14541    fn batch_controller_default_trait() {
14542        let bc = BatchController::default();
14543        let bc2 = BatchController::new();
14544        // Should be equivalent
14545        assert_eq!(bc.tau_s(), bc2.tau_s());
14546        assert_eq!(bc.observations(), bc2.observations());
14547    }
14548
14549    #[test]
14550    fn batch_controller_observe_arrival_stale_gap_ignored() {
14551        let mut bc = BatchController::new();
14552        let base = Instant::now();
14553        // First arrival
14554        bc.observe_arrival(base);
14555        // Stale gap > 10s should be ignored
14556        bc.observe_arrival(base + Duration::from_secs(15));
14557        assert_eq!(bc.observations(), 0);
14558    }
14559
14560    #[test]
14561    fn batch_controller_observe_service_out_of_range() {
14562        let mut bc = BatchController::new();
14563        let original_service = bc.service_est_s();
14564        // Out-of-range (>= 10s) should be ignored
14565        bc.observe_service(Duration::from_secs(15));
14566        assert_eq!(bc.service_est_s(), original_service);
14567    }
14568
14569    #[test]
14570    fn batch_controller_lambda_zero_inter_arrival() {
14571        // When ema_inter_arrival_s is effectively 0, lambda should be 0
14572        let bc = BatchController {
14573            ema_inter_arrival_s: 0.0,
14574            ..BatchController::new()
14575        };
14576        assert_eq!(bc.lambda_est(), 0.0);
14577    }
14578
14579    // =========================================================================
14580    // Headless program: Cmd::Log with and without trailing newline (bd-2yjus)
14581    // =========================================================================
14582
14583    #[test]
14584    fn headless_execute_cmd_log_appends_newline_if_missing() {
14585        let mut program =
14586            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14587        program.execute_cmd(Cmd::log("no newline")).expect("log");
14588
14589        let bytes = program.writer.into_inner().expect("writer output");
14590        let output = String::from_utf8_lossy(&bytes);
14591        // The sanitized output should end with a newline
14592        assert!(output.contains("no newline"));
14593    }
14594
14595    #[test]
14596    fn headless_execute_cmd_log_preserves_trailing_newline() {
14597        let mut program =
14598            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14599        program
14600            .execute_cmd(Cmd::log("with newline\n"))
14601            .expect("log");
14602
14603        let bytes = program.writer.into_inner().expect("writer output");
14604        let output = String::from_utf8_lossy(&bytes);
14605        assert!(output.contains("with newline"));
14606    }
14607
14608    // =========================================================================
14609    // Headless program: immediate resize behavior (bd-2yjus)
14610    // =========================================================================
14611
14612    #[test]
14613    fn headless_handle_event_immediate_resize() {
14614        struct ResizeModel {
14615            last_size: Option<(u16, u16)>,
14616        }
14617
14618        #[derive(Debug)]
14619        enum ResizeMsg {
14620            Resize(u16, u16),
14621            Other,
14622        }
14623
14624        impl From<Event> for ResizeMsg {
14625            fn from(event: Event) -> Self {
14626                match event {
14627                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
14628                    _ => ResizeMsg::Other,
14629                }
14630            }
14631        }
14632
14633        impl Model for ResizeModel {
14634            type Message = ResizeMsg;
14635
14636            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
14637                if let ResizeMsg::Resize(w, h) = msg {
14638                    self.last_size = Some((w, h));
14639                }
14640                Cmd::none()
14641            }
14642
14643            fn view(&self, _frame: &mut Frame) {}
14644        }
14645
14646        let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
14647        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
14648
14649        program
14650            .handle_event(Event::Resize {
14651                width: 120,
14652                height: 40,
14653            })
14654            .expect("handle resize");
14655
14656        assert_eq!(program.width, 120);
14657        assert_eq!(program.height, 40);
14658        assert_eq!(program.model().last_size, Some((120, 40)));
14659    }
14660
14661    // =========================================================================
14662    // Headless program: resize clamps zero dimensions (bd-2yjus)
14663    // =========================================================================
14664
14665    #[test]
14666    fn headless_apply_resize_clamps_zero_to_one() {
14667        struct SimpleModel;
14668
14669        #[derive(Debug)]
14670        enum SimpleMsg {
14671            Noop,
14672        }
14673
14674        impl From<Event> for SimpleMsg {
14675            fn from(_: Event) -> Self {
14676                SimpleMsg::Noop
14677            }
14678        }
14679
14680        impl Model for SimpleModel {
14681            type Message = SimpleMsg;
14682
14683            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
14684                Cmd::none()
14685            }
14686
14687            fn view(&self, _frame: &mut Frame) {}
14688        }
14689
14690        let mut program = headless_program_with_config(SimpleModel, ProgramConfig::default());
14691        program
14692            .apply_resize(0, 0, Duration::ZERO, false)
14693            .expect("resize");
14694
14695        // Zero dimensions should be clamped to 1
14696        assert_eq!(program.width, 1);
14697        assert_eq!(program.height, 1);
14698    }
14699
14700    // =========================================================================
14701    // PaneTerminalAdapter::force_cancel_all (bd-24v9m)
14702    // =========================================================================
14703
14704    #[test]
14705    fn force_cancel_all_idle_returns_none() {
14706        let mut adapter =
14707            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
14708        assert!(adapter.force_cancel_all().is_none());
14709    }
14710
14711    #[test]
14712    fn force_cancel_all_after_pointer_down_returns_diagnostics() {
14713        let mut adapter =
14714            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
14715        let target = pane_target(SplitAxis::Horizontal);
14716
14717        let down = Event::Mouse(MouseEvent::new(
14718            MouseEventKind::Down(MouseButton::Left),
14719            5,
14720            5,
14721        ));
14722        let _ = adapter.translate(&down, Some(target));
14723        assert!(adapter.active_pointer_id().is_some());
14724
14725        let diag = adapter
14726            .force_cancel_all()
14727            .expect("should produce diagnostics");
14728        assert!(diag.had_active_pointer);
14729        assert_eq!(diag.active_pointer_id, Some(1));
14730        assert!(diag.machine_transition.is_some());
14731
14732        // Adapter should be fully idle afterwards
14733        assert_eq!(adapter.active_pointer_id(), None);
14734        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
14735    }
14736
14737    #[test]
14738    fn force_cancel_all_during_drag_returns_diagnostics() {
14739        let mut adapter =
14740            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
14741        let target = pane_target(SplitAxis::Vertical);
14742
14743        // Down → arm
14744        let down = Event::Mouse(MouseEvent::new(
14745            MouseEventKind::Down(MouseButton::Left),
14746            3,
14747            3,
14748        ));
14749        let _ = adapter.translate(&down, Some(target));
14750
14751        // Drag → transition to Dragging
14752        let drag = Event::Mouse(MouseEvent::new(
14753            MouseEventKind::Drag(MouseButton::Left),
14754            8,
14755            3,
14756        ));
14757        let _ = adapter.translate(&drag, None);
14758
14759        let diag = adapter
14760            .force_cancel_all()
14761            .expect("should produce diagnostics");
14762        assert!(diag.had_active_pointer);
14763        assert!(diag.machine_transition.is_some());
14764        let transition = diag.machine_transition.unwrap();
14765        assert!(matches!(
14766            transition.effect,
14767            PaneDragResizeEffect::Canceled {
14768                reason: PaneCancelReason::Programmatic,
14769                ..
14770            }
14771        ));
14772    }
14773
14774    #[test]
14775    fn force_cancel_all_is_idempotent() {
14776        let mut adapter =
14777            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
14778        let target = pane_target(SplitAxis::Horizontal);
14779
14780        let down = Event::Mouse(MouseEvent::new(
14781            MouseEventKind::Down(MouseButton::Left),
14782            5,
14783            5,
14784        ));
14785        let _ = adapter.translate(&down, Some(target));
14786
14787        let first = adapter.force_cancel_all();
14788        assert!(first.is_some());
14789
14790        let second = adapter.force_cancel_all();
14791        assert!(second.is_none());
14792    }
14793
14794    // =========================================================================
14795    // PaneInteractionGuard (bd-24v9m)
14796    // =========================================================================
14797
14798    #[test]
14799    fn pane_interaction_guard_finish_when_idle() {
14800        let mut adapter =
14801            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
14802        let guard = PaneInteractionGuard::new(&mut adapter);
14803        let diag = guard.finish();
14804        assert!(diag.is_none());
14805    }
14806
14807    #[test]
14808    fn pane_interaction_guard_finish_returns_diagnostics() {
14809        let mut adapter =
14810            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
14811        let target = pane_target(SplitAxis::Horizontal);
14812
14813        // Start a drag interaction through the adapter directly
14814        let down = Event::Mouse(MouseEvent::new(
14815            MouseEventKind::Down(MouseButton::Left),
14816            5,
14817            5,
14818        ));
14819        let _ = adapter.translate(&down, Some(target));
14820
14821        let guard = PaneInteractionGuard::new(&mut adapter);
14822        let diag = guard.finish().expect("should produce diagnostics");
14823        assert!(diag.had_active_pointer);
14824        assert_eq!(diag.active_pointer_id, Some(1));
14825    }
14826
14827    #[test]
14828    fn pane_interaction_guard_drop_cancels_active_interaction() {
14829        let mut adapter =
14830            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
14831        let target = pane_target(SplitAxis::Vertical);
14832
14833        let down = Event::Mouse(MouseEvent::new(
14834            MouseEventKind::Down(MouseButton::Left),
14835            7,
14836            7,
14837        ));
14838        let _ = adapter.translate(&down, Some(target));
14839        assert!(adapter.active_pointer_id().is_some());
14840
14841        {
14842            let _guard = PaneInteractionGuard::new(&mut adapter);
14843            // guard drops here without finish()
14844        }
14845
14846        // After guard drop, adapter should be idle
14847        assert_eq!(adapter.active_pointer_id(), None);
14848        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
14849    }
14850
14851    #[test]
14852    fn pane_interaction_guard_adapter_access_works() {
14853        let mut adapter =
14854            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
14855        let target = pane_target(SplitAxis::Horizontal);
14856
14857        let mut guard = PaneInteractionGuard::new(&mut adapter);
14858
14859        // Use the adapter through the guard
14860        let down = Event::Mouse(MouseEvent::new(
14861            MouseEventKind::Down(MouseButton::Left),
14862            5,
14863            5,
14864        ));
14865        let dispatch = guard.adapter().translate(&down, Some(target));
14866        assert!(dispatch.primary_event.is_some());
14867
14868        // finish should clean up the interaction started through the guard
14869        let diag = guard.finish().expect("should produce diagnostics");
14870        assert!(diag.had_active_pointer);
14871    }
14872
14873    #[test]
14874    fn pane_interaction_guard_finish_then_drop_is_safe() {
14875        let mut adapter =
14876            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
14877        let target = pane_target(SplitAxis::Horizontal);
14878
14879        let down = Event::Mouse(MouseEvent::new(
14880            MouseEventKind::Down(MouseButton::Left),
14881            5,
14882            5,
14883        ));
14884        let _ = adapter.translate(&down, Some(target));
14885
14886        let guard = PaneInteractionGuard::new(&mut adapter);
14887        let _diag = guard.finish();
14888        // guard is consumed by finish(), so drop doesn't double-cancel
14889        // This test proves the API is safe: finish() takes `self` not `&mut self`
14890        assert_eq!(adapter.active_pointer_id(), None);
14891    }
14892
14893    // =========================================================================
14894    // PaneCapabilityMatrix (bd-6u66i)
14895    // =========================================================================
14896
14897    fn caps_modern() -> TerminalCapabilities {
14898        TerminalCapabilities::modern()
14899    }
14900
14901    fn caps_with_mux(
14902        mux: PaneMuxEnvironment,
14903    ) -> ftui_core::terminal_capabilities::TerminalCapabilities {
14904        let mut caps = TerminalCapabilities::modern();
14905        match mux {
14906            PaneMuxEnvironment::Tmux => caps.in_tmux = true,
14907            PaneMuxEnvironment::Screen => caps.in_screen = true,
14908            PaneMuxEnvironment::Zellij => caps.in_zellij = true,
14909            PaneMuxEnvironment::WeztermMux => caps.in_wezterm_mux = true,
14910            PaneMuxEnvironment::None => {}
14911        }
14912        caps
14913    }
14914
14915    #[test]
14916    fn capability_matrix_bare_terminal_modern() {
14917        let caps = caps_modern();
14918        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
14919
14920        assert_eq!(mat.mux, PaneMuxEnvironment::None);
14921        assert!(mat.mouse_sgr);
14922        assert!(mat.mouse_drag_reliable);
14923        assert!(mat.mouse_button_discrimination);
14924        assert!(mat.focus_events);
14925        assert!(mat.unicode_box_drawing);
14926        assert!(mat.true_color);
14927        assert!(!mat.degraded);
14928        assert!(mat.drag_enabled());
14929        assert!(mat.focus_cancel_effective());
14930        assert!(mat.limitations().is_empty());
14931    }
14932
14933    #[test]
14934    fn capability_matrix_tmux() {
14935        let caps = caps_with_mux(PaneMuxEnvironment::Tmux);
14936        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
14937
14938        assert_eq!(mat.mux, PaneMuxEnvironment::Tmux);
14939        // Focus cancel path is conservatively disabled in all muxes.
14940        assert!(mat.mouse_drag_reliable);
14941        assert!(!mat.focus_events);
14942        assert!(mat.drag_enabled());
14943        assert!(!mat.focus_cancel_effective());
14944        assert!(mat.degraded);
14945    }
14946
14947    #[test]
14948    fn capability_matrix_screen_degrades_drag() {
14949        let caps = caps_with_mux(PaneMuxEnvironment::Screen);
14950        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
14951
14952        assert_eq!(mat.mux, PaneMuxEnvironment::Screen);
14953        assert!(!mat.mouse_drag_reliable);
14954        assert!(!mat.focus_events);
14955        assert!(!mat.drag_enabled());
14956        assert!(!mat.focus_cancel_effective());
14957        assert!(mat.degraded);
14958
14959        let lims = mat.limitations();
14960        assert!(lims.iter().any(|l| l.id == "mouse_drag_unreliable"));
14961        assert!(lims.iter().any(|l| l.id == "no_focus_events"));
14962    }
14963
14964    #[test]
14965    fn capability_matrix_zellij() {
14966        let caps = caps_with_mux(PaneMuxEnvironment::Zellij);
14967        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
14968
14969        assert_eq!(mat.mux, PaneMuxEnvironment::Zellij);
14970        assert!(mat.mouse_drag_reliable);
14971        assert!(!mat.focus_events);
14972        assert!(mat.drag_enabled());
14973        assert!(!mat.focus_cancel_effective());
14974        assert!(mat.degraded);
14975    }
14976
14977    #[test]
14978    fn capability_matrix_wezterm_mux_disables_focus_cancel_path() {
14979        let caps = caps_with_mux(PaneMuxEnvironment::WeztermMux);
14980        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
14981
14982        assert_eq!(mat.mux, PaneMuxEnvironment::WeztermMux);
14983        assert!(mat.mouse_drag_reliable);
14984        assert!(!mat.focus_events);
14985        assert!(mat.drag_enabled());
14986        assert!(!mat.focus_cancel_effective());
14987        assert!(mat.degraded);
14988    }
14989
14990    #[test]
14991    fn capability_matrix_no_sgr_mouse() {
14992        let mut caps = caps_modern();
14993        caps.mouse_sgr = false;
14994        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
14995
14996        assert!(!mat.mouse_sgr);
14997        assert!(!mat.mouse_button_discrimination);
14998        assert!(mat.degraded);
14999
15000        let lims = mat.limitations();
15001        assert!(lims.iter().any(|l| l.id == "no_sgr_mouse"));
15002        assert!(lims.iter().any(|l| l.id == "no_button_discrimination"));
15003    }
15004
15005    #[test]
15006    fn capability_matrix_no_focus_events() {
15007        let mut caps = caps_modern();
15008        caps.focus_events = false;
15009        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15010
15011        assert!(!mat.focus_events);
15012        assert!(!mat.focus_cancel_effective());
15013        assert!(mat.degraded);
15014
15015        let lims = mat.limitations();
15016        assert!(lims.iter().any(|l| l.id == "no_focus_events"));
15017    }
15018
15019    #[test]
15020    fn capability_matrix_dumb_terminal() {
15021        let caps = TerminalCapabilities::dumb();
15022        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15023
15024        assert_eq!(mat.mux, PaneMuxEnvironment::None);
15025        assert!(!mat.mouse_sgr);
15026        assert!(!mat.focus_events);
15027        assert!(!mat.unicode_box_drawing);
15028        assert!(!mat.true_color);
15029        assert!(mat.degraded);
15030        assert!(mat.limitations().len() >= 3);
15031    }
15032
15033    #[test]
15034    fn capability_matrix_limitations_have_fallbacks() {
15035        let caps = TerminalCapabilities::dumb();
15036        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15037
15038        for lim in mat.limitations() {
15039            assert!(!lim.id.is_empty());
15040            assert!(!lim.description.is_empty());
15041            assert!(!lim.fallback.is_empty());
15042        }
15043    }
15044
15045    // ========================================================================
15046    // Screen transition detection tests (A.2 + D.3)
15047    // ========================================================================
15048
15049    /// A multi-screen model that implements ScreenTickDispatch, for testing
15050    /// the `check_screen_transition` logic.
15051    struct MultiScreenModel {
15052        active: String,
15053        screens: Vec<String>,
15054        ticked_screens: Vec<(String, u64)>,
15055    }
15056
15057    #[derive(Debug)]
15058    enum MultiScreenMsg {
15059        #[expect(dead_code)]
15060        Event(Event),
15061    }
15062
15063    impl From<Event> for MultiScreenMsg {
15064        fn from(event: Event) -> Self {
15065            MultiScreenMsg::Event(event)
15066        }
15067    }
15068
15069    impl Model for MultiScreenModel {
15070        type Message = MultiScreenMsg;
15071
15072        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
15073            match msg {
15074                MultiScreenMsg::Event(_) => Cmd::none(),
15075            }
15076        }
15077
15078        fn view(&self, _frame: &mut Frame) {}
15079
15080        fn as_screen_tick_dispatch(
15081            &mut self,
15082        ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
15083            Some(self)
15084        }
15085    }
15086
15087    impl crate::tick_strategy::ScreenTickDispatch for MultiScreenModel {
15088        fn screen_ids(&self) -> Vec<String> {
15089            self.screens.clone()
15090        }
15091
15092        fn active_screen_id(&self) -> String {
15093            self.active.clone()
15094        }
15095
15096        fn tick_screen(&mut self, screen_id: &str, tick_count: u64) {
15097            self.ticked_screens.push((screen_id.to_owned(), tick_count));
15098        }
15099    }
15100
15101    /// Shared log for recording strategy transitions (inspectable after test).
15102    type TransitionLog = Arc<std::sync::Mutex<Vec<(String, String)>>>;
15103
15104    /// A recording tick strategy that logs `on_screen_transition` calls
15105    /// to a shared log that can be inspected from test assertions.
15106    struct RecordingStrategy {
15107        log: TransitionLog,
15108    }
15109
15110    impl RecordingStrategy {
15111        fn new(log: TransitionLog) -> Self {
15112            Self { log }
15113        }
15114    }
15115
15116    impl crate::tick_strategy::TickStrategy for RecordingStrategy {
15117        fn should_tick(
15118            &mut self,
15119            _screen_id: &str,
15120            _tick_count: u64,
15121            _active_screen: &str,
15122        ) -> crate::tick_strategy::TickDecision {
15123            crate::tick_strategy::TickDecision::Skip
15124        }
15125
15126        fn on_screen_transition(&mut self, from: &str, to: &str) {
15127            self.log
15128                .lock()
15129                .unwrap()
15130                .push((from.to_owned(), to.to_owned()));
15131        }
15132
15133        fn name(&self) -> &str {
15134            "Recording"
15135        }
15136
15137        fn debug_stats(&self) -> Vec<(String, String)> {
15138            vec![("strategy".into(), "Recording".into())]
15139        }
15140    }
15141
15142    /// Helper to create a headless Program with a multi-screen model and
15143    /// a recording tick strategy. Returns the program and a shared log of
15144    /// `on_screen_transition` calls for assertions.
15145    fn headless_multi_screen_program(
15146        active: &str,
15147        screens: &[&str],
15148    ) -> (
15149        Program<MultiScreenModel, HeadlessEventSource, Vec<u8>>,
15150        TransitionLog,
15151    ) {
15152        let model = MultiScreenModel {
15153            active: active.to_owned(),
15154            screens: screens.iter().map(|s| (*s).to_owned()).collect(),
15155            ticked_screens: Vec::new(),
15156        };
15157        let events = HeadlessEventSource::new(80, 24, BackendFeatures::default());
15158        let writer = TerminalWriter::new(
15159            Vec::<u8>::new(),
15160            ScreenMode::AltScreen,
15161            UiAnchor::Bottom,
15162            TerminalCapabilities::dumb(),
15163        );
15164        let config = ProgramConfig {
15165            forced_size: Some((80, 24)),
15166            tick_strategy: Some(crate::tick_strategy::TickStrategyKind::ActiveOnly),
15167            ..ProgramConfig::default()
15168        };
15169        let mut prog =
15170            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
15171                .expect("headless program creation failed");
15172
15173        // Replace the default strategy with our recording strategy.
15174        let log: TransitionLog = Arc::new(std::sync::Mutex::new(Vec::new()));
15175        prog.tick_strategy = Some(Box::new(RecordingStrategy::new(log.clone())));
15176
15177        (prog, log)
15178    }
15179
15180    #[test]
15181    fn check_screen_transition_first_call_records_active() {
15182        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
15183
15184        assert!(prog.last_active_screen_for_strategy.is_none());
15185        prog.check_screen_transition();
15186        assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
15187
15188        // First observation: no transition event, no force-tick.
15189        assert!(prog.model.ticked_screens.is_empty());
15190        assert!(log.lock().unwrap().is_empty());
15191    }
15192
15193    #[test]
15194    fn check_screen_transition_no_change_is_noop() {
15195        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
15196
15197        // First call: records.
15198        prog.check_screen_transition();
15199
15200        // Second call with same active screen: no-op.
15201        prog.check_screen_transition();
15202        assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
15203
15204        // No force-tick, no transition notification.
15205        assert!(prog.model.ticked_screens.is_empty());
15206        assert!(log.lock().unwrap().is_empty());
15207    }
15208
15209    #[test]
15210    fn check_screen_transition_detects_switch_and_force_ticks() {
15211        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
15212
15213        prog.check_screen_transition(); // records "A"
15214
15215        // Simulate model switching to screen "B".
15216        prog.model.active = "B".to_owned();
15217        prog.check_screen_transition();
15218
15219        // D.3: force-tick should have been dispatched for "B".
15220        assert_eq!(prog.model.ticked_screens.len(), 1);
15221        assert_eq!(prog.model.ticked_screens[0].0, "B");
15222
15223        // A.2: strategy should have been notified of A → B.
15224        let transitions = log.lock().unwrap();
15225        assert_eq!(transitions.len(), 1);
15226        assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
15227
15228        // last_active should now be "B".
15229        assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("B"));
15230    }
15231
15232    #[test]
15233    fn check_screen_transition_marks_dirty_on_change() {
15234        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15235
15236        prog.check_screen_transition();
15237        prog.dirty = false;
15238
15239        prog.model.active = "B".to_owned();
15240        prog.check_screen_transition();
15241
15242        assert!(prog.dirty);
15243    }
15244
15245    #[test]
15246    fn check_screen_transition_not_dirty_when_unchanged() {
15247        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15248
15249        prog.check_screen_transition();
15250        prog.dirty = false;
15251
15252        prog.check_screen_transition();
15253
15254        assert!(!prog.dirty);
15255    }
15256
15257    #[test]
15258    fn check_screen_transition_noop_without_strategy() {
15259        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15260
15261        // Remove the tick strategy.
15262        prog.tick_strategy = None;
15263
15264        prog.check_screen_transition();
15265        assert!(prog.last_active_screen_for_strategy.is_none());
15266    }
15267
15268    #[test]
15269    fn check_screen_transition_multiple_switches_notifies_strategy() {
15270        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
15271
15272        prog.check_screen_transition(); // records "A"
15273
15274        // A → B
15275        prog.model.active = "B".to_owned();
15276        prog.check_screen_transition();
15277        assert_eq!(prog.model.ticked_screens.len(), 1);
15278        assert_eq!(prog.model.ticked_screens[0].0, "B");
15279
15280        // B → C
15281        prog.model.active = "C".to_owned();
15282        prog.check_screen_transition();
15283        assert_eq!(prog.model.ticked_screens.len(), 2);
15284        assert_eq!(prog.model.ticked_screens[1].0, "C");
15285
15286        // C → A
15287        prog.model.active = "A".to_owned();
15288        prog.check_screen_transition();
15289        assert_eq!(prog.model.ticked_screens.len(), 3);
15290        assert_eq!(prog.model.ticked_screens[2].0, "A");
15291
15292        // A.2: strategy should have all three transitions.
15293        let transitions = log.lock().unwrap();
15294        assert_eq!(transitions.len(), 3);
15295        assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
15296        assert_eq!(transitions[1], ("B".to_owned(), "C".to_owned()));
15297        assert_eq!(transitions[2], ("C".to_owned(), "A".to_owned()));
15298    }
15299
15300    #[test]
15301    fn check_screen_transition_uses_current_tick_count() {
15302        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15303        prog.tick_count = 42;
15304
15305        prog.check_screen_transition(); // records "A"
15306
15307        prog.model.active = "B".to_owned();
15308        prog.check_screen_transition();
15309
15310        // Force-tick should use the current tick_count.
15311        assert_eq!(prog.model.ticked_screens[0].1, 42);
15312    }
15313
15314    #[test]
15315    fn check_screen_transition_reconciles_subscriptions_after_force_tick() {
15316        use crate::subscription::{StopSignal, SubId, Subscription};
15317
15318        struct TransitionSubModel {
15319            active: String,
15320            screens: Vec<String>,
15321            subscribed: bool,
15322        }
15323
15324        #[derive(Debug)]
15325        #[allow(dead_code)]
15326        enum TransitionSubMsg {
15327            Event(Event),
15328        }
15329
15330        impl From<Event> for TransitionSubMsg {
15331            fn from(event: Event) -> Self {
15332                Self::Event(event)
15333            }
15334        }
15335
15336        impl Model for TransitionSubModel {
15337            type Message = TransitionSubMsg;
15338
15339            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
15340                Cmd::none()
15341            }
15342
15343            fn view(&self, _frame: &mut Frame) {}
15344
15345            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
15346                if self.subscribed {
15347                    vec![Box::new(TransitionSubscription)]
15348                } else {
15349                    vec![]
15350                }
15351            }
15352
15353            fn as_screen_tick_dispatch(
15354                &mut self,
15355            ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
15356                Some(self)
15357            }
15358        }
15359
15360        impl crate::tick_strategy::ScreenTickDispatch for TransitionSubModel {
15361            fn screen_ids(&self) -> Vec<String> {
15362                self.screens.clone()
15363            }
15364
15365            fn active_screen_id(&self) -> String {
15366                self.active.clone()
15367            }
15368
15369            fn tick_screen(&mut self, screen_id: &str, _tick_count: u64) {
15370                if screen_id == self.active {
15371                    self.subscribed = true;
15372                }
15373            }
15374        }
15375
15376        struct TransitionSubscription;
15377
15378        impl Subscription<TransitionSubMsg> for TransitionSubscription {
15379            fn id(&self) -> SubId {
15380                1
15381            }
15382
15383            fn run(&self, _sender: mpsc::Sender<TransitionSubMsg>, _stop: StopSignal) {}
15384        }
15385
15386        struct TransitionStrategy;
15387
15388        impl crate::tick_strategy::TickStrategy for TransitionStrategy {
15389            fn should_tick(
15390                &mut self,
15391                _screen_id: &str,
15392                _tick_count: u64,
15393                _active_screen: &str,
15394            ) -> crate::tick_strategy::TickDecision {
15395                crate::tick_strategy::TickDecision::Skip
15396            }
15397
15398            fn on_screen_transition(&mut self, _from: &str, _to: &str) {}
15399
15400            fn name(&self) -> &str {
15401                "TransitionStrategy"
15402            }
15403
15404            fn debug_stats(&self) -> Vec<(String, String)> {
15405                vec![]
15406            }
15407        }
15408
15409        let model = TransitionSubModel {
15410            active: "A".to_owned(),
15411            screens: vec!["A".to_owned(), "B".to_owned()],
15412            subscribed: false,
15413        };
15414        let events = HeadlessEventSource::new(80, 24, BackendFeatures::default());
15415        let writer = TerminalWriter::new(
15416            Vec::<u8>::new(),
15417            ScreenMode::AltScreen,
15418            UiAnchor::Bottom,
15419            TerminalCapabilities::dumb(),
15420        );
15421        let config = ProgramConfig::default().with_forced_size(80, 24);
15422
15423        let mut program =
15424            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
15425                .expect("program creation");
15426        program.tick_strategy = Some(Box::new(TransitionStrategy));
15427
15428        program.check_screen_transition();
15429        assert_eq!(program.subscriptions.active_count(), 0);
15430
15431        program.model.active = "B".to_owned();
15432        program.check_screen_transition();
15433
15434        assert!(program.model().subscribed);
15435        assert_eq!(program.subscriptions.active_count(), 1);
15436    }
15437
15438    #[test]
15439    fn tick_strategy_stats_returns_empty_without_strategy() {
15440        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15441        prog.tick_strategy = None;
15442        assert!(prog.tick_strategy_stats().is_empty());
15443    }
15444
15445    #[test]
15446    fn tick_strategy_stats_returns_strategy_fields() {
15447        let (prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15448        let stats = prog.tick_strategy_stats();
15449        // RecordingStrategy returns [("strategy", "Recording")]
15450        assert!(
15451            !stats.is_empty(),
15452            "stats should not be empty when strategy is configured"
15453        );
15454    }
15455}