Skip to main content

ftui_widgets/
inspector.rs

1#![forbid(unsafe_code)]
2
3//! UI Inspector overlay for debugging widget trees and hit-test regions.
4//!
5//! The inspector visualizes:
6//! - Hit regions with colored overlays
7//! - Widget boundaries with colored borders
8//! - Widget names and metadata
9//!
10//! # Usage
11//!
12//! ```ignore
13//! use ftui_widgets::inspector::{InspectorMode, InspectorState, InspectorOverlay};
14//!
15//! // In your app state
16//! let mut inspector = InspectorState::default();
17//!
18//! // Toggle with F12
19//! if key == KeyCode::F12 {
20//!     inspector.toggle();
21//! }
22//!
23//! // Render overlay after all widgets
24//! if inspector.is_active() {
25//!     InspectorOverlay::new(&inspector).render(area, frame);
26//! }
27//! ```
28//!
29//! See `docs/specs/ui-inspector.md` for the full specification.
30
31use ftui_core::geometry::Rect;
32use ftui_render::cell::{Cell, PackedRgba};
33use ftui_render::frame::{Frame, HitCell, HitData, HitId, HitRegion};
34use ftui_text::display_width;
35
36use crate::{Widget, draw_text_span, set_style_area};
37use ftui_style::Style;
38use std::io::Write;
39use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
40use std::time::Instant;
41
42#[cfg(feature = "tracing")]
43use tracing::{info_span, trace};
44
45// =============================================================================
46// Diagnostics + Telemetry (bd-17h9.8)
47// =============================================================================
48
49/// Global diagnostic enable flag (checked once at startup).
50static INSPECTOR_DIAGNOSTICS_ENABLED: AtomicBool = AtomicBool::new(false);
51/// Global monotonic event counter for deterministic ordering.
52static INSPECTOR_EVENT_COUNTER: AtomicU64 = AtomicU64::new(0);
53
54/// Initialize diagnostic settings from environment.
55pub fn init_diagnostics() {
56    let enabled = std::env::var("FTUI_INSPECTOR_DIAGNOSTICS")
57        .map(|v| v.eq_ignore_ascii_case("true"))
58        .unwrap_or(false);
59    INSPECTOR_DIAGNOSTICS_ENABLED.store(enabled, Ordering::Relaxed);
60}
61
62/// Check if diagnostics are enabled.
63#[inline]
64pub fn diagnostics_enabled() -> bool {
65    INSPECTOR_DIAGNOSTICS_ENABLED.load(Ordering::Relaxed)
66}
67
68/// Set diagnostics enabled state (for testing).
69pub fn set_diagnostics_enabled(enabled: bool) {
70    INSPECTOR_DIAGNOSTICS_ENABLED.store(enabled, Ordering::Relaxed);
71}
72
73/// Get next monotonic event sequence number.
74#[inline]
75fn next_event_seq() -> u64 {
76    INSPECTOR_EVENT_COUNTER.fetch_add(1, Ordering::Relaxed)
77}
78
79/// Reset event counter (for testing determinism).
80pub fn reset_event_counter() {
81    INSPECTOR_EVENT_COUNTER.store(0, Ordering::Relaxed);
82}
83
84/// Check if deterministic mode is enabled.
85pub fn is_deterministic_mode() -> bool {
86    std::env::var("FTUI_INSPECTOR_DETERMINISTIC")
87        .map(|v| v.eq_ignore_ascii_case("true"))
88        .unwrap_or(false)
89}
90
91/// Diagnostic event types for JSONL logging.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum DiagnosticEventKind {
94    /// Inspector toggled on/off.
95    InspectorToggled,
96    /// Inspector mode changed.
97    ModeChanged,
98    /// Hover position changed.
99    HoverChanged,
100    /// Selection changed.
101    SelectionChanged,
102    /// Detail panel toggled.
103    DetailPanelToggled,
104    /// Hit region visibility toggled.
105    HitsToggled,
106    /// Widget bounds visibility toggled.
107    BoundsToggled,
108    /// Widget name labels toggled.
109    NamesToggled,
110    /// Render time labels toggled.
111    TimesToggled,
112    /// Widgets cleared for a new frame.
113    WidgetsCleared,
114    /// Widget registered for inspection.
115    WidgetRegistered,
116}
117
118impl DiagnosticEventKind {
119    /// Get the JSONL event type string.
120    pub const fn as_str(self) -> &'static str {
121        match self {
122            Self::InspectorToggled => "inspector_toggled",
123            Self::ModeChanged => "mode_changed",
124            Self::HoverChanged => "hover_changed",
125            Self::SelectionChanged => "selection_changed",
126            Self::DetailPanelToggled => "detail_panel_toggled",
127            Self::HitsToggled => "hits_toggled",
128            Self::BoundsToggled => "bounds_toggled",
129            Self::NamesToggled => "names_toggled",
130            Self::TimesToggled => "times_toggled",
131            Self::WidgetsCleared => "widgets_cleared",
132            Self::WidgetRegistered => "widget_registered",
133        }
134    }
135}
136
137/// JSONL diagnostic log entry.
138#[derive(Debug, Clone)]
139pub struct DiagnosticEntry {
140    /// Monotonic sequence number.
141    pub seq: u64,
142    /// Timestamp in microseconds.
143    pub timestamp_us: u64,
144    /// Event kind.
145    pub kind: DiagnosticEventKind,
146    /// Current inspector mode.
147    pub mode: Option<InspectorMode>,
148    /// Previous inspector mode.
149    pub previous_mode: Option<InspectorMode>,
150    /// Hover position.
151    pub hover_pos: Option<(u16, u16)>,
152    /// Selected widget id.
153    pub selected: Option<HitId>,
154    /// Widget name (if applicable).
155    pub widget_name: Option<String>,
156    /// Widget area (if applicable).
157    pub widget_area: Option<Rect>,
158    /// Widget depth (if applicable).
159    pub widget_depth: Option<u8>,
160    /// Widget hit id (if applicable).
161    pub widget_hit_id: Option<HitId>,
162    /// Total widget count (if applicable).
163    pub widget_count: Option<usize>,
164    /// Flag name (for toggles).
165    pub flag: Option<String>,
166    /// Flag enabled state (for toggles).
167    pub enabled: Option<bool>,
168    /// Additional context string.
169    pub context: Option<String>,
170    /// Checksum for determinism verification.
171    pub checksum: u64,
172}
173
174impl DiagnosticEntry {
175    /// Create a new diagnostic entry with current timestamp.
176    pub fn new(kind: DiagnosticEventKind) -> Self {
177        let seq = next_event_seq();
178        let timestamp_us = if is_deterministic_mode() {
179            seq.saturating_mul(1_000)
180        } else {
181            static START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
182            let start = START.get_or_init(Instant::now);
183            start.elapsed().as_micros() as u64
184        };
185
186        Self {
187            seq,
188            timestamp_us,
189            kind,
190            mode: None,
191            previous_mode: None,
192            hover_pos: None,
193            selected: None,
194            widget_name: None,
195            widget_area: None,
196            widget_depth: None,
197            widget_hit_id: None,
198            widget_count: None,
199            flag: None,
200            enabled: None,
201            context: None,
202            checksum: 0,
203        }
204    }
205
206    /// Set inspector mode.
207    #[must_use]
208    pub fn with_mode(mut self, mode: InspectorMode) -> Self {
209        self.mode = Some(mode);
210        self
211    }
212
213    /// Set previous inspector mode.
214    #[must_use]
215    pub fn with_previous_mode(mut self, mode: InspectorMode) -> Self {
216        self.previous_mode = Some(mode);
217        self
218    }
219
220    /// Set hover position.
221    #[must_use]
222    pub fn with_hover_pos(mut self, pos: Option<(u16, u16)>) -> Self {
223        self.hover_pos = pos;
224        self
225    }
226
227    /// Set selected widget id.
228    #[must_use]
229    pub fn with_selected(mut self, selected: Option<HitId>) -> Self {
230        self.selected = selected;
231        self
232    }
233
234    /// Set widget info.
235    #[must_use]
236    pub fn with_widget(mut self, widget: &WidgetInfo) -> Self {
237        self.widget_name = Some(widget.name.clone());
238        self.widget_area = Some(widget.area);
239        self.widget_depth = Some(widget.depth);
240        self.widget_hit_id = widget.hit_id;
241        self
242    }
243
244    /// Set widget count.
245    #[must_use]
246    pub fn with_widget_count(mut self, count: usize) -> Self {
247        self.widget_count = Some(count);
248        self
249    }
250
251    /// Set flag toggle details.
252    #[must_use]
253    pub fn with_flag(mut self, flag: impl Into<String>, enabled: bool) -> Self {
254        self.flag = Some(flag.into());
255        self.enabled = Some(enabled);
256        self
257    }
258
259    /// Set context string.
260    #[must_use]
261    pub fn with_context(mut self, context: impl Into<String>) -> Self {
262        self.context = Some(context.into());
263        self
264    }
265
266    /// Compute and set checksum.
267    #[must_use]
268    pub fn with_checksum(mut self) -> Self {
269        self.checksum = self.compute_checksum();
270        self
271    }
272
273    /// Compute FNV-1a hash of entry fields.
274    fn compute_checksum(&self) -> u64 {
275        let mut hash: u64 = 0xcbf29ce484222325;
276        let payload = format!(
277            "{:?}{:?}{:?}{:?}{:?}{:?}{:?}{:?}{:?}{:?}{:?}{:?}{:?}",
278            self.kind,
279            self.mode,
280            self.previous_mode,
281            self.hover_pos,
282            self.selected.map(|id| id.id()),
283            self.widget_name.as_deref().unwrap_or(""),
284            self.widget_area
285                .map(|r| format!("{},{},{},{}", r.x, r.y, r.width, r.height))
286                .unwrap_or_default(),
287            self.widget_depth.unwrap_or(0),
288            self.widget_hit_id.map(|id| id.id()).unwrap_or(0),
289            self.widget_count.unwrap_or(0),
290            self.flag.as_deref().unwrap_or(""),
291            self.enabled.unwrap_or(false),
292            self.context.as_deref().unwrap_or("")
293        );
294        for &b in payload.as_bytes() {
295            hash ^= b as u64;
296            hash = hash.wrapping_mul(0x100000001b3);
297        }
298        hash
299    }
300
301    /// Format as JSONL string.
302    pub fn to_jsonl(&self) -> String {
303        let mut parts = vec![
304            format!("\"seq\":{}", self.seq),
305            format!("\"ts_us\":{}", self.timestamp_us),
306            format!("\"kind\":\"{}\"", self.kind.as_str()),
307        ];
308
309        if let Some(mode) = self.mode {
310            parts.push(format!("\"mode\":\"{}\"", mode.as_str()));
311        }
312        if let Some(mode) = self.previous_mode {
313            parts.push(format!("\"prev_mode\":\"{}\"", mode.as_str()));
314        }
315        if let Some((x, y)) = self.hover_pos {
316            parts.push(format!("\"hover_x\":{x}"));
317            parts.push(format!("\"hover_y\":{y}"));
318        }
319        if let Some(id) = self.selected {
320            parts.push(format!("\"selected_id\":{}", id.id()));
321        }
322        if let Some(ref name) = self.widget_name {
323            let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
324            parts.push(format!("\"widget\":\"{escaped}\""));
325        }
326        if let Some(area) = self.widget_area {
327            parts.push(format!("\"widget_x\":{}", area.x));
328            parts.push(format!("\"widget_y\":{}", area.y));
329            parts.push(format!("\"widget_w\":{}", area.width));
330            parts.push(format!("\"widget_h\":{}", area.height));
331        }
332        if let Some(depth) = self.widget_depth {
333            parts.push(format!("\"widget_depth\":{depth}"));
334        }
335        if let Some(id) = self.widget_hit_id {
336            parts.push(format!("\"widget_hit_id\":{}", id.id()));
337        }
338        if let Some(count) = self.widget_count {
339            parts.push(format!("\"widget_count\":{count}"));
340        }
341        if let Some(ref flag) = self.flag {
342            let escaped = flag.replace('\\', "\\\\").replace('"', "\\\"");
343            parts.push(format!("\"flag\":\"{escaped}\""));
344        }
345        if let Some(enabled) = self.enabled {
346            parts.push(format!("\"enabled\":{enabled}"));
347        }
348        if let Some(ref ctx) = self.context {
349            let escaped = ctx.replace('\\', "\\\\").replace('"', "\\\"");
350            parts.push(format!("\"context\":\"{escaped}\""));
351        }
352        parts.push(format!("\"checksum\":\"{:016x}\"", self.checksum));
353
354        format!("{{{}}}", parts.join(","))
355    }
356}
357
358/// Diagnostic log collector.
359#[derive(Debug, Default)]
360pub struct DiagnosticLog {
361    entries: Vec<DiagnosticEntry>,
362    max_entries: usize,
363    write_stderr: bool,
364}
365
366impl DiagnosticLog {
367    /// Create a new diagnostic log.
368    pub fn new() -> Self {
369        Self {
370            entries: Vec::new(),
371            max_entries: 5000,
372            write_stderr: false,
373        }
374    }
375
376    /// Create a log that writes to stderr.
377    pub fn with_stderr(mut self) -> Self {
378        self.write_stderr = true;
379        self
380    }
381
382    /// Set maximum entries to keep.
383    pub fn with_max_entries(mut self, max: usize) -> Self {
384        self.max_entries = max;
385        self
386    }
387
388    /// Record a diagnostic entry.
389    pub fn record(&mut self, entry: DiagnosticEntry) {
390        if self.write_stderr {
391            let _ = writeln!(std::io::stderr(), "{}", entry.to_jsonl());
392        }
393        if self.max_entries > 0 && self.entries.len() >= self.max_entries {
394            self.entries.remove(0);
395        }
396        self.entries.push(entry);
397    }
398
399    /// Get all entries.
400    pub fn entries(&self) -> &[DiagnosticEntry] {
401        &self.entries
402    }
403
404    /// Get entries of a specific kind.
405    pub fn entries_of_kind(&self, kind: DiagnosticEventKind) -> Vec<&DiagnosticEntry> {
406        self.entries.iter().filter(|e| e.kind == kind).collect()
407    }
408
409    /// Clear all entries.
410    pub fn clear(&mut self) {
411        self.entries.clear();
412    }
413
414    /// Export all entries as JSONL string.
415    pub fn to_jsonl(&self) -> String {
416        self.entries
417            .iter()
418            .map(DiagnosticEntry::to_jsonl)
419            .collect::<Vec<_>>()
420            .join("\n")
421    }
422}
423
424/// Callback type for telemetry hooks.
425pub type TelemetryCallback = Box<dyn Fn(&DiagnosticEntry) + Send + Sync>;
426
427/// Telemetry hooks for observing inspector events.
428#[derive(Default)]
429pub struct TelemetryHooks {
430    on_toggle: Option<TelemetryCallback>,
431    on_mode_change: Option<TelemetryCallback>,
432    on_hover_change: Option<TelemetryCallback>,
433    on_selection_change: Option<TelemetryCallback>,
434    on_any_event: Option<TelemetryCallback>,
435}
436
437impl std::fmt::Debug for TelemetryHooks {
438    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
439        f.debug_struct("TelemetryHooks")
440            .field("on_toggle", &self.on_toggle.is_some())
441            .field("on_mode_change", &self.on_mode_change.is_some())
442            .field("on_hover_change", &self.on_hover_change.is_some())
443            .field("on_selection_change", &self.on_selection_change.is_some())
444            .field("on_any_event", &self.on_any_event.is_some())
445            .finish()
446    }
447}
448
449impl TelemetryHooks {
450    /// Create new empty hooks.
451    pub fn new() -> Self {
452        Self::default()
453    }
454
455    /// Set toggle callback.
456    pub fn on_toggle(mut self, f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static) -> Self {
457        self.on_toggle = Some(Box::new(f));
458        self
459    }
460
461    /// Set mode change callback.
462    pub fn on_mode_change(mut self, f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static) -> Self {
463        self.on_mode_change = Some(Box::new(f));
464        self
465    }
466
467    /// Set hover change callback.
468    pub fn on_hover_change(mut self, f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static) -> Self {
469        self.on_hover_change = Some(Box::new(f));
470        self
471    }
472
473    /// Set selection change callback.
474    pub fn on_selection_change(
475        mut self,
476        f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static,
477    ) -> Self {
478        self.on_selection_change = Some(Box::new(f));
479        self
480    }
481
482    /// Set catch-all callback.
483    pub fn on_any(mut self, f: impl Fn(&DiagnosticEntry) + Send + Sync + 'static) -> Self {
484        self.on_any_event = Some(Box::new(f));
485        self
486    }
487
488    /// Dispatch an entry to relevant hooks.
489    fn dispatch(&self, entry: &DiagnosticEntry) {
490        if let Some(ref cb) = self.on_any_event {
491            cb(entry);
492        }
493
494        match entry.kind {
495            DiagnosticEventKind::InspectorToggled => {
496                if let Some(ref cb) = self.on_toggle {
497                    cb(entry);
498                }
499            }
500            DiagnosticEventKind::ModeChanged => {
501                if let Some(ref cb) = self.on_mode_change {
502                    cb(entry);
503                }
504            }
505            DiagnosticEventKind::HoverChanged => {
506                if let Some(ref cb) = self.on_hover_change {
507                    cb(entry);
508                }
509            }
510            DiagnosticEventKind::SelectionChanged => {
511                if let Some(ref cb) = self.on_selection_change {
512                    cb(entry);
513                }
514            }
515            _ => {}
516        }
517    }
518}
519
520/// Inspector display mode.
521#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
522pub enum InspectorMode {
523    /// Inspector is disabled.
524    #[default]
525    Off,
526    /// Show hit regions with colored overlays.
527    HitRegions,
528    /// Show widget boundaries and names.
529    WidgetBounds,
530    /// Show both hit regions and widget bounds.
531    Full,
532}
533
534impl InspectorMode {
535    /// Cycle to the next mode.
536    ///
537    /// Off → HitRegions → WidgetBounds → Full → Off
538    #[must_use]
539    pub fn cycle(self) -> Self {
540        match self {
541            Self::Off => Self::HitRegions,
542            Self::HitRegions => Self::WidgetBounds,
543            Self::WidgetBounds => Self::Full,
544            Self::Full => Self::Off,
545        }
546    }
547
548    /// Check if inspector is active (any mode except Off).
549    #[inline]
550    pub fn is_active(self) -> bool {
551        self != Self::Off
552    }
553
554    /// Get a stable string representation for diagnostics.
555    pub const fn as_str(self) -> &'static str {
556        match self {
557            Self::Off => "off",
558            Self::HitRegions => "hit_regions",
559            Self::WidgetBounds => "widget_bounds",
560            Self::Full => "full",
561        }
562    }
563
564    /// Check if hit regions should be shown.
565    #[inline]
566    pub fn show_hit_regions(self) -> bool {
567        matches!(self, Self::HitRegions | Self::Full)
568    }
569
570    /// Check if widget bounds should be shown.
571    #[inline]
572    pub fn show_widget_bounds(self) -> bool {
573        matches!(self, Self::WidgetBounds | Self::Full)
574    }
575}
576
577/// Information about a widget for inspector display.
578#[derive(Debug, Clone)]
579pub struct WidgetInfo {
580    /// Human-readable widget name (e.g., "List", "Button").
581    pub name: String,
582    /// Allocated render area.
583    pub area: Rect,
584    /// Hit ID if widget is interactive.
585    pub hit_id: Option<HitId>,
586    /// Registered hit regions within this widget.
587    pub hit_regions: Vec<(Rect, HitRegion, HitData)>,
588    /// Render time in microseconds (if profiling enabled).
589    pub render_time_us: Option<u64>,
590    /// Nesting depth for color cycling.
591    pub depth: u8,
592    /// Child widgets (for tree view).
593    pub children: Vec<WidgetInfo>,
594}
595
596impl WidgetInfo {
597    /// Create a new widget info.
598    #[must_use]
599    pub fn new(name: impl Into<String>, area: Rect) -> Self {
600        Self {
601            name: name.into(),
602            area,
603            hit_id: None,
604            hit_regions: Vec::new(),
605            render_time_us: None,
606            depth: 0,
607            children: Vec::new(),
608        }
609    }
610
611    /// Set the hit ID.
612    #[must_use]
613    pub fn with_hit_id(mut self, id: HitId) -> Self {
614        self.hit_id = Some(id);
615        self
616    }
617
618    /// Add a hit region.
619    pub fn add_hit_region(&mut self, rect: Rect, region: HitRegion, data: HitData) {
620        self.hit_regions.push((rect, region, data));
621    }
622
623    /// Set nesting depth.
624    #[must_use]
625    pub fn with_depth(mut self, depth: u8) -> Self {
626        self.depth = depth;
627        self
628    }
629
630    /// Add a child widget.
631    pub fn add_child(&mut self, child: WidgetInfo) {
632        self.children.push(child);
633    }
634}
635
636/// Configuration for inspector appearance.
637#[derive(Debug, Clone)]
638pub struct InspectorStyle {
639    /// Border colors for widget bounds (cycles through for nesting).
640    pub bound_colors: [PackedRgba; 6],
641    /// Hit region overlay color (semi-transparent).
642    pub hit_overlay: PackedRgba,
643    /// Hovered hit region color.
644    pub hit_hover: PackedRgba,
645    /// Selected widget highlight.
646    pub selected_highlight: PackedRgba,
647    /// Label text color.
648    pub label_fg: PackedRgba,
649    /// Label background color.
650    pub label_bg: PackedRgba,
651}
652
653impl Default for InspectorStyle {
654    fn default() -> Self {
655        Self {
656            bound_colors: [
657                PackedRgba::rgb(255, 100, 100), // Red
658                PackedRgba::rgb(100, 255, 100), // Green
659                PackedRgba::rgb(100, 100, 255), // Blue
660                PackedRgba::rgb(255, 255, 100), // Yellow
661                PackedRgba::rgb(255, 100, 255), // Magenta
662                PackedRgba::rgb(100, 255, 255), // Cyan
663            ],
664            hit_overlay: PackedRgba::rgba(255, 165, 0, 80), // Orange 30%
665            hit_hover: PackedRgba::rgba(255, 255, 0, 120),  // Yellow 47%
666            selected_highlight: PackedRgba::rgba(0, 200, 255, 150), // Cyan 60%
667            label_fg: PackedRgba::WHITE,
668            label_bg: PackedRgba::rgba(0, 0, 0, 200),
669        }
670    }
671}
672
673impl InspectorStyle {
674    /// Get the bound color for a given nesting depth.
675    #[inline]
676    pub fn bound_color(&self, depth: u8) -> PackedRgba {
677        self.bound_colors[depth as usize % self.bound_colors.len()]
678    }
679
680    /// Get a region-specific overlay color.
681    pub fn region_color(&self, region: HitRegion) -> PackedRgba {
682        match region {
683            HitRegion::None => PackedRgba::TRANSPARENT,
684            HitRegion::Content => PackedRgba::rgba(255, 165, 0, 60), // Orange
685            HitRegion::Border => PackedRgba::rgba(128, 128, 128, 60), // Gray
686            HitRegion::Scrollbar => PackedRgba::rgba(100, 100, 200, 60), // Blue-ish
687            HitRegion::Handle => PackedRgba::rgba(200, 100, 100, 60), // Red-ish
688            HitRegion::Button => PackedRgba::rgba(0, 200, 255, 80),  // Cyan
689            HitRegion::Link => PackedRgba::rgba(100, 200, 255, 80),  // Light blue
690            HitRegion::Custom(_) => PackedRgba::rgba(200, 200, 200, 60), // Light gray
691        }
692    }
693}
694
695/// Inspector overlay state (shared across frames).
696#[derive(Debug, Default)]
697pub struct InspectorState {
698    /// Current display mode.
699    pub mode: InspectorMode,
700    /// Mouse position for hover detection.
701    pub hover_pos: Option<(u16, u16)>,
702    /// Selected widget (clicked).
703    pub selected: Option<HitId>,
704    /// Collected widget info for current frame.
705    pub widgets: Vec<WidgetInfo>,
706    /// Show detailed panel.
707    pub show_detail_panel: bool,
708    /// Visual style configuration.
709    pub style: InspectorStyle,
710    /// Toggle for hit regions visibility (within mode).
711    pub show_hits: bool,
712    /// Toggle for widget bounds visibility (within mode).
713    pub show_bounds: bool,
714    /// Toggle for widget name labels.
715    pub show_names: bool,
716    /// Toggle for render time display.
717    pub show_times: bool,
718    /// Diagnostic log for telemetry (bd-17h9.8).
719    diagnostic_log: Option<DiagnosticLog>,
720    /// Telemetry hooks for external observers (bd-17h9.8).
721    telemetry_hooks: Option<TelemetryHooks>,
722}
723
724impl InspectorState {
725    /// Create a new inspector state.
726    #[must_use]
727    pub fn new() -> Self {
728        let diagnostic_log = if diagnostics_enabled() {
729            Some(DiagnosticLog::new().with_stderr())
730        } else {
731            None
732        };
733        Self {
734            show_hits: true,
735            show_bounds: true,
736            show_names: true,
737            show_times: false,
738            diagnostic_log,
739            telemetry_hooks: None,
740            ..Default::default()
741        }
742    }
743
744    /// Create with diagnostic log enabled (for testing).
745    pub fn with_diagnostics(mut self) -> Self {
746        self.diagnostic_log = Some(DiagnosticLog::new());
747        self
748    }
749
750    /// Create with telemetry hooks.
751    pub fn with_telemetry_hooks(mut self, hooks: TelemetryHooks) -> Self {
752        self.telemetry_hooks = Some(hooks);
753        self
754    }
755
756    /// Get the diagnostic log (for testing).
757    pub fn diagnostic_log(&self) -> Option<&DiagnosticLog> {
758        self.diagnostic_log.as_ref()
759    }
760
761    /// Get mutable diagnostic log (for testing).
762    pub fn diagnostic_log_mut(&mut self) -> Option<&mut DiagnosticLog> {
763        self.diagnostic_log.as_mut()
764    }
765
766    /// Toggle the inspector on/off.
767    pub fn toggle(&mut self) {
768        let prev = self.mode;
769        if self.mode.is_active() {
770            self.mode = InspectorMode::Off;
771        } else {
772            self.mode = InspectorMode::Full;
773        }
774        if self.mode != prev {
775            self.record_diagnostic(
776                DiagnosticEntry::new(DiagnosticEventKind::InspectorToggled)
777                    .with_previous_mode(prev)
778                    .with_mode(self.mode)
779                    .with_flag("inspector", self.mode.is_active()),
780            );
781        }
782    }
783
784    /// Check if the inspector is active.
785    #[inline]
786    pub fn is_active(&self) -> bool {
787        self.mode.is_active()
788    }
789
790    /// Cycle through display modes.
791    pub fn cycle_mode(&mut self) {
792        let prev = self.mode;
793        self.mode = self.mode.cycle();
794        if self.mode != prev {
795            self.record_diagnostic(
796                DiagnosticEntry::new(DiagnosticEventKind::ModeChanged)
797                    .with_previous_mode(prev)
798                    .with_mode(self.mode),
799            );
800        }
801    }
802
803    /// Set mode directly (0=Off, 1=HitRegions, 2=WidgetBounds, 3=Full).
804    pub fn set_mode(&mut self, mode_num: u8) {
805        let prev = self.mode;
806        self.mode = match mode_num {
807            0 => InspectorMode::Off,
808            1 => InspectorMode::HitRegions,
809            2 => InspectorMode::WidgetBounds,
810            _ => InspectorMode::Full,
811        };
812        if self.mode != prev {
813            self.record_diagnostic(
814                DiagnosticEntry::new(DiagnosticEventKind::ModeChanged)
815                    .with_previous_mode(prev)
816                    .with_mode(self.mode),
817            );
818        }
819    }
820
821    /// Update hover position from mouse event.
822    pub fn set_hover(&mut self, pos: Option<(u16, u16)>) {
823        if self.hover_pos != pos {
824            self.hover_pos = pos;
825            self.record_diagnostic(
826                DiagnosticEntry::new(DiagnosticEventKind::HoverChanged).with_hover_pos(pos),
827            );
828        }
829    }
830
831    /// Select a widget by hit ID.
832    pub fn select(&mut self, id: Option<HitId>) {
833        if self.selected != id {
834            self.selected = id;
835            self.record_diagnostic(
836                DiagnosticEntry::new(DiagnosticEventKind::SelectionChanged).with_selected(id),
837            );
838        }
839    }
840
841    /// Clear selection.
842    pub fn clear_selection(&mut self) {
843        self.select(None);
844    }
845
846    /// Toggle the detail panel.
847    pub fn toggle_detail_panel(&mut self) {
848        self.show_detail_panel = !self.show_detail_panel;
849        self.record_diagnostic(
850            DiagnosticEntry::new(DiagnosticEventKind::DetailPanelToggled)
851                .with_flag("detail_panel", self.show_detail_panel),
852        );
853    }
854
855    /// Toggle hit regions visibility.
856    pub fn toggle_hits(&mut self) {
857        self.show_hits = !self.show_hits;
858        self.record_diagnostic(
859            DiagnosticEntry::new(DiagnosticEventKind::HitsToggled)
860                .with_flag("hits", self.show_hits),
861        );
862    }
863
864    /// Toggle widget bounds visibility.
865    pub fn toggle_bounds(&mut self) {
866        self.show_bounds = !self.show_bounds;
867        self.record_diagnostic(
868            DiagnosticEntry::new(DiagnosticEventKind::BoundsToggled)
869                .with_flag("bounds", self.show_bounds),
870        );
871    }
872
873    /// Toggle name labels visibility.
874    pub fn toggle_names(&mut self) {
875        self.show_names = !self.show_names;
876        self.record_diagnostic(
877            DiagnosticEntry::new(DiagnosticEventKind::NamesToggled)
878                .with_flag("names", self.show_names),
879        );
880    }
881
882    /// Toggle render time visibility.
883    pub fn toggle_times(&mut self) {
884        self.show_times = !self.show_times;
885        self.record_diagnostic(
886            DiagnosticEntry::new(DiagnosticEventKind::TimesToggled)
887                .with_flag("times", self.show_times),
888        );
889    }
890
891    /// Clear widget info for new frame.
892    pub fn clear_widgets(&mut self) {
893        let count = self.widgets.len();
894        self.widgets.clear();
895        if count > 0 {
896            self.record_diagnostic(
897                DiagnosticEntry::new(DiagnosticEventKind::WidgetsCleared).with_widget_count(count),
898            );
899        }
900    }
901
902    /// Register a widget for inspection.
903    pub fn register_widget(&mut self, info: WidgetInfo) {
904        #[cfg(feature = "tracing")]
905        trace!(name = info.name, area = ?info.area, "Registered widget for inspection");
906        let widget_count = self.widgets.len() + 1;
907        self.record_diagnostic(
908            DiagnosticEntry::new(DiagnosticEventKind::WidgetRegistered)
909                .with_widget(&info)
910                .with_widget_count(widget_count),
911        );
912        self.widgets.push(info);
913    }
914
915    fn record_diagnostic(&mut self, entry: DiagnosticEntry) {
916        if self.diagnostic_log.is_none() && self.telemetry_hooks.is_none() {
917            return;
918        }
919        let entry = entry.with_checksum();
920
921        if let Some(ref hooks) = self.telemetry_hooks {
922            hooks.dispatch(&entry);
923        }
924
925        if let Some(ref mut log) = self.diagnostic_log {
926            log.record(entry);
927        }
928    }
929
930    /// Check if we should render hit regions.
931    #[inline]
932    pub fn should_show_hits(&self) -> bool {
933        self.show_hits && self.mode.show_hit_regions()
934    }
935
936    /// Check if we should render widget bounds.
937    #[inline]
938    pub fn should_show_bounds(&self) -> bool {
939        self.show_bounds && self.mode.show_widget_bounds()
940    }
941}
942
943/// Inspector overlay widget.
944///
945/// Renders hit region overlays and widget bounds on top of the UI.
946pub struct InspectorOverlay<'a> {
947    state: &'a InspectorState,
948}
949
950impl<'a> InspectorOverlay<'a> {
951    /// Create a new inspector overlay.
952    #[must_use]
953    pub fn new(state: &'a InspectorState) -> Self {
954        Self { state }
955    }
956
957    /// Render hit region overlays from the frame's HitGrid.
958    fn render_hit_regions(&self, area: Rect, frame: &mut Frame) {
959        #[cfg(feature = "tracing")]
960        let _span = info_span!("render_hit_regions").entered();
961
962        let Some(ref hit_grid) = frame.hit_grid else {
963            // No hit grid available - draw warning
964            self.draw_warning(area, frame, "HitGrid not enabled");
965            return;
966        };
967
968        let style = &self.state.style;
969        let hover_pos = self.state.hover_pos;
970        let selected = self.state.selected;
971
972        // Iterate over visible cells and apply overlay colors
973        for y in area.y..area.bottom() {
974            for x in area.x..area.right() {
975                if let Some(cell) = hit_grid.get(x, y) {
976                    if cell.is_empty() {
977                        continue;
978                    }
979
980                    let is_hovered = hover_pos == Some((x, y));
981                    let is_selected = selected == cell.widget_id;
982
983                    // Determine overlay color
984                    let overlay = if is_selected {
985                        style.selected_highlight
986                    } else if is_hovered {
987                        style.hit_hover
988                    } else {
989                        style.region_color(cell.region)
990                    };
991
992                    // Apply overlay to buffer cell
993                    if let Some(buf_cell) = frame.buffer.get_mut(x, y) {
994                        buf_cell.bg = overlay.over(buf_cell.bg);
995                    }
996                }
997            }
998        }
999    }
1000
1001    /// Render widget bounds from collected WidgetInfo.
1002    fn render_widget_bounds(&self, _area: Rect, frame: &mut Frame) {
1003        #[cfg(feature = "tracing")]
1004        let _span = info_span!(
1005            "render_widget_bounds",
1006            widget_count = self.state.widgets.len()
1007        )
1008        .entered();
1009
1010        let style = &self.state.style;
1011
1012        for widget in &self.state.widgets {
1013            self.render_widget_bound(widget, frame, style);
1014        }
1015    }
1016
1017    /// Render a single widget's bounds recursively.
1018    fn render_widget_bound(&self, widget: &WidgetInfo, frame: &mut Frame, style: &InspectorStyle) {
1019        let color = style.bound_color(widget.depth);
1020        let area = widget.area;
1021
1022        // Skip empty areas
1023        if area.is_empty() {
1024            return;
1025        }
1026
1027        // Draw border outline
1028        self.draw_rect_outline(area, frame, color);
1029
1030        // Draw label if names are enabled
1031        if self.state.show_names && !widget.name.is_empty() {
1032            self.draw_label(area, frame, &widget.name, style);
1033        }
1034
1035        // Recursively draw children
1036        for child in &widget.children {
1037            self.render_widget_bound(child, frame, style);
1038        }
1039    }
1040
1041    /// Draw a rectangle outline with the given color.
1042    fn draw_rect_outline(&self, rect: Rect, frame: &mut Frame, color: PackedRgba) {
1043        if rect.width == 0 || rect.height == 0 {
1044            return;
1045        }
1046
1047        let x = rect.x;
1048        let y = rect.y;
1049        let right = rect.right().saturating_sub(1);
1050        let bottom = rect.bottom().saturating_sub(1);
1051
1052        // Top edge
1053        for cx in x..=right {
1054            if let Some(cell) = frame.buffer.get_mut(cx, y) {
1055                cell.fg = color;
1056            }
1057        }
1058
1059        // Bottom edge
1060        if bottom > y {
1061            for cx in x..=right {
1062                if let Some(cell) = frame.buffer.get_mut(cx, bottom) {
1063                    cell.fg = color;
1064                }
1065            }
1066        }
1067
1068        // Left edge
1069        for cy in y..=bottom {
1070            if let Some(cell) = frame.buffer.get_mut(x, cy) {
1071                cell.fg = color;
1072            }
1073        }
1074
1075        // Right edge
1076        if right > x {
1077            for cy in y..=bottom {
1078                if let Some(cell) = frame.buffer.get_mut(right, cy) {
1079                    cell.fg = color;
1080                }
1081            }
1082        }
1083    }
1084
1085    /// Draw a widget name label at the top-left of its area.
1086    fn draw_label(&self, area: Rect, frame: &mut Frame, name: &str, style: &InspectorStyle) {
1087        let label = format!("[{name}]");
1088        let label_len = display_width(&label) as u16;
1089
1090        // Position label at top-left, clamped to area
1091        let x = area.x;
1092        let y = area.y;
1093
1094        // Draw label background
1095        let label_area = Rect::new(x, y, label_len.min(area.width), 1);
1096        set_style_area(
1097            &mut frame.buffer,
1098            label_area,
1099            Style::new().bg(style.label_bg),
1100        );
1101
1102        // Draw label text
1103        let label_style = Style::new().fg(style.label_fg).bg(style.label_bg);
1104        draw_text_span(frame, x, y, &label, label_style, area.x + area.width);
1105    }
1106
1107    /// Draw a warning message when something isn't available.
1108    fn draw_warning(&self, area: Rect, frame: &mut Frame, msg: &str) {
1109        let style = &self.state.style;
1110        let warning_style = Style::new()
1111            .fg(PackedRgba::rgb(255, 200, 0))
1112            .bg(style.label_bg);
1113
1114        // Center the message
1115        let msg_len = display_width(msg) as u16;
1116        let x = area.x + area.width.saturating_sub(msg_len) / 2;
1117        let y = area.y;
1118
1119        set_style_area(
1120            &mut frame.buffer,
1121            Rect::new(x, y, msg_len, 1),
1122            warning_style,
1123        );
1124
1125        draw_text_span(frame, x, y, msg, warning_style, area.x + area.width);
1126    }
1127
1128    /// Render the detail panel showing selected widget info.
1129    fn render_detail_panel(&self, area: Rect, frame: &mut Frame) {
1130        let style = &self.state.style;
1131
1132        // Panel dimensions
1133        let panel_width: u16 = 24;
1134        let panel_height = area.height.min(20);
1135
1136        // Position at right edge
1137        let panel_x = area.right().saturating_sub(panel_width + 1);
1138        let panel_y = area.y + 1;
1139        let panel_area = Rect::new(panel_x, panel_y, panel_width, panel_height);
1140
1141        // Draw panel background
1142        set_style_area(
1143            &mut frame.buffer,
1144            panel_area,
1145            Style::new().bg(style.label_bg),
1146        );
1147
1148        // Draw border
1149        self.draw_rect_outline(panel_area, frame, style.label_fg);
1150
1151        // Draw content
1152        let content_x = panel_x + 1;
1153        let mut y = panel_y + 1;
1154
1155        // Title
1156        self.draw_panel_text(frame, content_x, y, "Inspector", style.label_fg);
1157        y += 2;
1158
1159        // Mode info
1160        let mode_str = match self.state.mode {
1161            InspectorMode::Off => "Off",
1162            InspectorMode::HitRegions => "Hit Regions",
1163            InspectorMode::WidgetBounds => "Widget Bounds",
1164            InspectorMode::Full => "Full",
1165        };
1166        self.draw_panel_text(
1167            frame,
1168            content_x,
1169            y,
1170            &format!("Mode: {mode_str}"),
1171            style.label_fg,
1172        );
1173        y += 1;
1174
1175        // Hover info
1176        if let Some((hx, hy)) = self.state.hover_pos {
1177            self.draw_panel_text(
1178                frame,
1179                content_x,
1180                y,
1181                &format!("Hover: ({hx},{hy})"),
1182                style.label_fg,
1183            );
1184            y += 1;
1185
1186            // Extract hit info first to avoid borrow conflicts
1187            let hit_info = frame
1188                .hit_grid
1189                .as_ref()
1190                .and_then(|grid| grid.get(hx, hy).filter(|h| !h.is_empty()).map(|h| (*h,)));
1191
1192            // Show hit info at hover position
1193            if let Some((hit,)) = hit_info {
1194                let region_str = format!("{:?}", hit.region);
1195                self.draw_panel_text(
1196                    frame,
1197                    content_x,
1198                    y,
1199                    &format!("Region: {region_str}"),
1200                    style.label_fg,
1201                );
1202                y += 1;
1203                if let Some(id) = hit.widget_id {
1204                    self.draw_panel_text(
1205                        frame,
1206                        content_x,
1207                        y,
1208                        &format!("ID: {}", id.id()),
1209                        style.label_fg,
1210                    );
1211                    y += 1;
1212                }
1213                if hit.data != 0 {
1214                    self.draw_panel_text(
1215                        frame,
1216                        content_x,
1217                        y,
1218                        &format!("Data: {}", hit.data),
1219                        style.label_fg,
1220                    );
1221                    #[allow(unused_assignments)]
1222                    {
1223                        y += 1;
1224                    }
1225                }
1226            }
1227        }
1228    }
1229
1230    /// Draw text in the detail panel.
1231    fn draw_panel_text(&self, frame: &mut Frame, x: u16, y: u16, text: &str, fg: PackedRgba) {
1232        for (i, ch) in text.chars().enumerate() {
1233            let cx = x + i as u16;
1234            if let Some(cell) = frame.buffer.get_mut(cx, y) {
1235                *cell = Cell::from_char(ch).with_fg(fg);
1236            }
1237        }
1238    }
1239}
1240
1241impl Widget for InspectorOverlay<'_> {
1242    fn render(&self, area: Rect, frame: &mut Frame) {
1243        #[cfg(feature = "tracing")]
1244        let _span = info_span!("inspector_overlay", ?area).entered();
1245
1246        if !self.state.is_active() {
1247            return;
1248        }
1249
1250        // Render hit regions first (underneath widget bounds)
1251        if self.state.should_show_hits() {
1252            self.render_hit_regions(area, frame);
1253        }
1254
1255        // Render widget bounds on top
1256        if self.state.should_show_bounds() {
1257            self.render_widget_bounds(area, frame);
1258        }
1259
1260        // Render detail panel if enabled
1261        if self.state.show_detail_panel {
1262            self.render_detail_panel(area, frame);
1263        }
1264    }
1265
1266    fn is_essential(&self) -> bool {
1267        // Inspector is a debugging tool, not essential
1268        false
1269    }
1270}
1271
1272/// Helper to extract hit information from a HitCell for display.
1273#[derive(Debug, Clone)]
1274pub struct HitInfo {
1275    /// Widget ID.
1276    pub widget_id: HitId,
1277    /// Region type.
1278    pub region: HitRegion,
1279    /// Associated data.
1280    pub data: HitData,
1281    /// Screen position.
1282    pub position: (u16, u16),
1283}
1284
1285impl HitInfo {
1286    /// Create from a HitCell and position.
1287    #[must_use]
1288    pub fn from_cell(cell: &HitCell, x: u16, y: u16) -> Option<Self> {
1289        cell.widget_id.map(|id| Self {
1290            widget_id: id,
1291            region: cell.region,
1292            data: cell.data,
1293            position: (x, y),
1294        })
1295    }
1296}
1297
1298#[cfg(test)]
1299mod tests {
1300    use super::*;
1301    use ftui_render::grapheme_pool::GraphemePool;
1302
1303    #[test]
1304    fn inspector_mode_cycle() {
1305        let mut mode = InspectorMode::Off;
1306        mode = mode.cycle();
1307        assert_eq!(mode, InspectorMode::HitRegions);
1308        mode = mode.cycle();
1309        assert_eq!(mode, InspectorMode::WidgetBounds);
1310        mode = mode.cycle();
1311        assert_eq!(mode, InspectorMode::Full);
1312        mode = mode.cycle();
1313        assert_eq!(mode, InspectorMode::Off);
1314    }
1315
1316    #[test]
1317    fn inspector_mode_is_active() {
1318        assert!(!InspectorMode::Off.is_active());
1319        assert!(InspectorMode::HitRegions.is_active());
1320        assert!(InspectorMode::WidgetBounds.is_active());
1321        assert!(InspectorMode::Full.is_active());
1322    }
1323
1324    #[test]
1325    fn inspector_mode_show_flags() {
1326        assert!(!InspectorMode::Off.show_hit_regions());
1327        assert!(!InspectorMode::Off.show_widget_bounds());
1328
1329        assert!(InspectorMode::HitRegions.show_hit_regions());
1330        assert!(!InspectorMode::HitRegions.show_widget_bounds());
1331
1332        assert!(!InspectorMode::WidgetBounds.show_hit_regions());
1333        assert!(InspectorMode::WidgetBounds.show_widget_bounds());
1334
1335        assert!(InspectorMode::Full.show_hit_regions());
1336        assert!(InspectorMode::Full.show_widget_bounds());
1337    }
1338
1339    #[test]
1340    fn inspector_state_toggle() {
1341        let mut state = InspectorState::new();
1342        assert!(!state.is_active());
1343
1344        state.toggle();
1345        assert!(state.is_active());
1346        assert_eq!(state.mode, InspectorMode::Full);
1347
1348        state.toggle();
1349        assert!(!state.is_active());
1350        assert_eq!(state.mode, InspectorMode::Off);
1351    }
1352
1353    #[test]
1354    fn inspector_state_set_mode() {
1355        let mut state = InspectorState::new();
1356
1357        state.set_mode(1);
1358        assert_eq!(state.mode, InspectorMode::HitRegions);
1359
1360        state.set_mode(2);
1361        assert_eq!(state.mode, InspectorMode::WidgetBounds);
1362
1363        state.set_mode(3);
1364        assert_eq!(state.mode, InspectorMode::Full);
1365
1366        state.set_mode(0);
1367        assert_eq!(state.mode, InspectorMode::Off);
1368
1369        // Any value >= 3 maps to Full
1370        state.set_mode(99);
1371        assert_eq!(state.mode, InspectorMode::Full);
1372    }
1373
1374    #[test]
1375    fn inspector_style_default() {
1376        let style = InspectorStyle::default();
1377        assert_eq!(style.bound_colors.len(), 6);
1378        assert_eq!(style.hit_overlay, PackedRgba::rgba(255, 165, 0, 80));
1379    }
1380
1381    #[test]
1382    fn inspector_style_bound_color_cycles() {
1383        let style = InspectorStyle::default();
1384        assert_eq!(style.bound_color(0), style.bound_colors[0]);
1385        assert_eq!(style.bound_color(5), style.bound_colors[5]);
1386        assert_eq!(style.bound_color(6), style.bound_colors[0]); // Wraps
1387        assert_eq!(style.bound_color(7), style.bound_colors[1]);
1388    }
1389
1390    #[test]
1391    fn widget_info_creation() {
1392        let info = WidgetInfo::new("Button", Rect::new(10, 5, 20, 3))
1393            .with_hit_id(HitId::new(42))
1394            .with_depth(2);
1395
1396        assert_eq!(info.name, "Button");
1397        assert_eq!(info.area, Rect::new(10, 5, 20, 3));
1398        assert_eq!(info.hit_id, Some(HitId::new(42)));
1399        assert_eq!(info.depth, 2);
1400    }
1401
1402    #[test]
1403    fn widget_info_add_hit_region() {
1404        let mut info = WidgetInfo::new("List", Rect::new(0, 0, 10, 10));
1405        info.add_hit_region(Rect::new(0, 0, 10, 1), HitRegion::Content, 0);
1406        info.add_hit_region(Rect::new(0, 1, 10, 1), HitRegion::Content, 1);
1407
1408        assert_eq!(info.hit_regions.len(), 2);
1409        assert_eq!(info.hit_regions[0].2, 0);
1410        assert_eq!(info.hit_regions[1].2, 1);
1411    }
1412
1413    #[test]
1414    fn widget_info_add_child() {
1415        let mut parent = WidgetInfo::new("Container", Rect::new(0, 0, 20, 20));
1416        let child = WidgetInfo::new("Button", Rect::new(5, 5, 10, 3));
1417        parent.add_child(child);
1418
1419        assert_eq!(parent.children.len(), 1);
1420        assert_eq!(parent.children[0].name, "Button");
1421    }
1422
1423    #[test]
1424    fn inspector_overlay_inactive_is_noop() {
1425        let state = InspectorState::new();
1426        let overlay = InspectorOverlay::new(&state);
1427
1428        let mut pool = GraphemePool::new();
1429        let mut frame = Frame::with_hit_grid(10, 10, &mut pool);
1430        let area = Rect::new(0, 0, 10, 10);
1431
1432        // Should do nothing since mode is Off
1433        overlay.render(area, &mut frame);
1434
1435        // Buffer should be empty
1436        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
1437    }
1438
1439    #[test]
1440    fn inspector_overlay_renders_when_active() {
1441        let mut state = InspectorState::new();
1442        state.mode = InspectorMode::Full;
1443        state.show_detail_panel = true;
1444
1445        let overlay = InspectorOverlay::new(&state);
1446
1447        let mut pool = GraphemePool::new();
1448        let mut frame = Frame::with_hit_grid(40, 20, &mut pool);
1449
1450        // Register a hit region
1451        frame.register_hit(Rect::new(5, 5, 10, 3), HitId::new(1), HitRegion::Button, 42);
1452
1453        let area = Rect::new(0, 0, 40, 20);
1454        overlay.render(area, &mut frame);
1455
1456        // The detail panel should be rendered at the right edge
1457        // This is a smoke test - actual content depends on implementation
1458    }
1459
1460    #[test]
1461    fn hit_info_from_cell() {
1462        let cell = HitCell::new(HitId::new(5), HitRegion::Button, 99);
1463        let info = HitInfo::from_cell(&cell, 10, 20);
1464
1465        assert!(info.is_some());
1466        let info = info.unwrap();
1467        assert_eq!(info.widget_id, HitId::new(5));
1468        assert_eq!(info.region, HitRegion::Button);
1469        assert_eq!(info.data, 99);
1470        assert_eq!(info.position, (10, 20));
1471    }
1472
1473    #[test]
1474    fn hit_info_from_empty_cell() {
1475        let cell = HitCell::default();
1476        let info = HitInfo::from_cell(&cell, 0, 0);
1477        assert!(info.is_none());
1478    }
1479
1480    #[test]
1481    fn inspector_state_toggles() {
1482        let mut state = InspectorState::new();
1483
1484        assert!(state.show_hits);
1485        state.toggle_hits();
1486        assert!(!state.show_hits);
1487        state.toggle_hits();
1488        assert!(state.show_hits);
1489
1490        assert!(state.show_bounds);
1491        state.toggle_bounds();
1492        assert!(!state.show_bounds);
1493
1494        assert!(state.show_names);
1495        state.toggle_names();
1496        assert!(!state.show_names);
1497
1498        assert!(!state.show_times);
1499        state.toggle_times();
1500        assert!(state.show_times);
1501
1502        assert!(!state.show_detail_panel);
1503        state.toggle_detail_panel();
1504        assert!(state.show_detail_panel);
1505    }
1506
1507    #[test]
1508    fn inspector_state_selection() {
1509        let mut state = InspectorState::new();
1510
1511        assert!(state.selected.is_none());
1512        state.select(Some(HitId::new(42)));
1513        assert_eq!(state.selected, Some(HitId::new(42)));
1514        state.clear_selection();
1515        assert!(state.selected.is_none());
1516    }
1517
1518    #[test]
1519    fn inspector_state_hover() {
1520        let mut state = InspectorState::new();
1521
1522        assert!(state.hover_pos.is_none());
1523        state.set_hover(Some((10, 20)));
1524        assert_eq!(state.hover_pos, Some((10, 20)));
1525        state.set_hover(None);
1526        assert!(state.hover_pos.is_none());
1527    }
1528
1529    #[test]
1530    fn inspector_state_widget_registry() {
1531        let mut state = InspectorState::new();
1532
1533        let widget = WidgetInfo::new("Test", Rect::new(0, 0, 10, 10));
1534        state.register_widget(widget);
1535        assert_eq!(state.widgets.len(), 1);
1536
1537        state.clear_widgets();
1538        assert!(state.widgets.is_empty());
1539    }
1540
1541    #[test]
1542    fn inspector_overlay_is_not_essential() {
1543        let state = InspectorState::new();
1544        let overlay = InspectorOverlay::new(&state);
1545        assert!(!overlay.is_essential());
1546    }
1547
1548    // =========================================================================
1549    // Edge Case Tests (bd-17h9.6)
1550    // =========================================================================
1551
1552    #[test]
1553    fn edge_case_zero_area_widget() {
1554        // Zero-sized areas should not panic
1555        let info = WidgetInfo::new("ZeroArea", Rect::new(0, 0, 0, 0));
1556        assert_eq!(info.area.width, 0);
1557        assert_eq!(info.area.height, 0);
1558        assert!(info.area.is_empty());
1559    }
1560
1561    #[test]
1562    fn edge_case_max_depth_widget() {
1563        // Maximum depth should work without overflow
1564        let info = WidgetInfo::new("Deep", Rect::new(0, 0, 10, 10)).with_depth(u8::MAX);
1565        assert_eq!(info.depth, u8::MAX);
1566
1567        // Bound color should still cycle correctly
1568        let style = InspectorStyle::default();
1569        let _color = style.bound_color(u8::MAX); // Should not panic
1570    }
1571
1572    #[test]
1573    fn edge_case_empty_widget_registry() {
1574        let mut state = InspectorState::new();
1575        assert!(state.widgets.is_empty());
1576
1577        // Clearing empty registry should not panic
1578        state.clear_widgets();
1579        assert!(state.widgets.is_empty());
1580    }
1581
1582    #[test]
1583    fn edge_case_selection_without_widgets() {
1584        let mut state = InspectorState::new();
1585
1586        // Selecting when no widgets are registered
1587        state.select(Some(HitId::new(42)));
1588        assert_eq!(state.selected, Some(HitId::new(42)));
1589
1590        // Clearing selection
1591        state.clear_selection();
1592        assert!(state.selected.is_none());
1593    }
1594
1595    #[test]
1596    fn edge_case_hover_boundary_positions() {
1597        let mut state = InspectorState::new();
1598
1599        // Maximum u16 coordinates
1600        state.set_hover(Some((u16::MAX, u16::MAX)));
1601        assert_eq!(state.hover_pos, Some((u16::MAX, u16::MAX)));
1602
1603        // Zero coordinates
1604        state.set_hover(Some((0, 0)));
1605        assert_eq!(state.hover_pos, Some((0, 0)));
1606    }
1607
1608    #[test]
1609    fn edge_case_deeply_nested_widgets() {
1610        // Build nested structure from inside out
1611        let mut deepest = WidgetInfo::new("L10", Rect::new(10, 10, 80, 80)).with_depth(10);
1612
1613        for i in (1..10).rev() {
1614            let mut parent =
1615                WidgetInfo::new(format!("L{i}"), Rect::new(i as u16, i as u16, 90, 90))
1616                    .with_depth(i as u8);
1617            parent.add_child(deepest);
1618            deepest = parent;
1619        }
1620
1621        let mut root = WidgetInfo::new("Root", Rect::new(0, 0, 100, 100)).with_depth(0);
1622        root.add_child(deepest);
1623
1624        // Verify nesting: root -> L1 -> L2 -> ... -> L10
1625        assert_eq!(root.children.len(), 1);
1626        assert_eq!(root.children[0].depth, 1);
1627        assert_eq!(root.children[0].children[0].depth, 2);
1628    }
1629
1630    #[test]
1631    fn edge_case_rapid_mode_cycling() {
1632        let mut state = InspectorState::new();
1633        assert_eq!(state.mode, InspectorMode::Off);
1634
1635        // Cycle 1000 times and verify we end at correct mode
1636        for _ in 0..1000 {
1637            state.mode = state.mode.cycle();
1638        }
1639        // 1000 % 4 = 0, so should be back at Off
1640        assert_eq!(state.mode, InspectorMode::Off);
1641    }
1642
1643    #[test]
1644    fn edge_case_many_hit_regions() {
1645        let mut info = WidgetInfo::new("ManyHits", Rect::new(0, 0, 100, 1000));
1646
1647        // Add 1000 hit regions
1648        for i in 0..1000 {
1649            info.add_hit_region(
1650                Rect::new(0, i as u16, 100, 1),
1651                HitRegion::Content,
1652                i as HitData,
1653            );
1654        }
1655
1656        assert_eq!(info.hit_regions.len(), 1000);
1657        assert_eq!(info.hit_regions[0].2, 0);
1658        assert_eq!(info.hit_regions[999].2, 999);
1659    }
1660
1661    #[test]
1662    fn edge_case_mode_show_flags_consistency() {
1663        // Verify show flags are consistent with mode
1664        for mode in [
1665            InspectorMode::Off,
1666            InspectorMode::HitRegions,
1667            InspectorMode::WidgetBounds,
1668            InspectorMode::Full,
1669        ] {
1670            match mode {
1671                InspectorMode::Off => {
1672                    assert!(!mode.show_hit_regions());
1673                    assert!(!mode.show_widget_bounds());
1674                }
1675                InspectorMode::HitRegions => {
1676                    assert!(mode.show_hit_regions());
1677                    assert!(!mode.show_widget_bounds());
1678                }
1679                InspectorMode::WidgetBounds => {
1680                    assert!(!mode.show_hit_regions());
1681                    assert!(mode.show_widget_bounds());
1682                }
1683                InspectorMode::Full => {
1684                    assert!(mode.show_hit_regions());
1685                    assert!(mode.show_widget_bounds());
1686                }
1687            }
1688        }
1689    }
1690
1691    // =========================================================================
1692    // Property-Based Tests (bd-17h9.6)
1693    // =========================================================================
1694
1695    mod proptests {
1696        use super::*;
1697        use proptest::prelude::*;
1698
1699        proptest! {
1700            /// Mode cycling is periodic with period 4.
1701            /// Cycling 4 times from any mode returns to the original mode.
1702            #[test]
1703            fn mode_cycle_is_periodic(start_cycle in 0u8..4) {
1704                let start_mode = match start_cycle {
1705                    0 => InspectorMode::Off,
1706                    1 => InspectorMode::HitRegions,
1707                    2 => InspectorMode::WidgetBounds,
1708                    _ => InspectorMode::Full,
1709                };
1710
1711                let mut mode = start_mode;
1712                for _ in 0..4 {
1713                    mode = mode.cycle();
1714                }
1715                prop_assert_eq!(mode, start_mode);
1716            }
1717
1718            /// Bound color cycling is periodic with period 6.
1719            #[test]
1720            fn bound_color_cycle_is_periodic(depth in 0u8..200) {
1721                let style = InspectorStyle::default();
1722                let color_a = style.bound_color(depth);
1723                let color_b = style.bound_color(depth.wrapping_add(6));
1724                prop_assert_eq!(color_a, color_b);
1725            }
1726
1727            /// is_active correctly reflects mode != Off.
1728            #[test]
1729            fn is_active_reflects_mode(mode_idx in 0u8..4) {
1730                let mode = match mode_idx {
1731                    0 => InspectorMode::Off,
1732                    1 => InspectorMode::HitRegions,
1733                    2 => InspectorMode::WidgetBounds,
1734                    _ => InspectorMode::Full,
1735                };
1736                let expected_active = mode_idx != 0;
1737                prop_assert_eq!(mode.is_active(), expected_active);
1738            }
1739
1740            /// Double toggle is identity for boolean flags.
1741            #[test]
1742            fn double_toggle_is_identity(_seed in 0u32..1000) {
1743                let mut state = InspectorState::new();
1744                let initial_hits = state.show_hits;
1745                let initial_bounds = state.show_bounds;
1746                let initial_names = state.show_names;
1747                let initial_times = state.show_times;
1748                let initial_panel = state.show_detail_panel;
1749
1750                // Toggle twice
1751                state.toggle_hits();
1752                state.toggle_hits();
1753                state.toggle_bounds();
1754                state.toggle_bounds();
1755                state.toggle_names();
1756                state.toggle_names();
1757                state.toggle_times();
1758                state.toggle_times();
1759                state.toggle_detail_panel();
1760                state.toggle_detail_panel();
1761
1762                prop_assert_eq!(state.show_hits, initial_hits);
1763                prop_assert_eq!(state.show_bounds, initial_bounds);
1764                prop_assert_eq!(state.show_names, initial_names);
1765                prop_assert_eq!(state.show_times, initial_times);
1766                prop_assert_eq!(state.show_detail_panel, initial_panel);
1767            }
1768
1769            /// Widget info preserves area dimensions.
1770            #[test]
1771            fn widget_info_preserves_area(
1772                x in 0u16..1000,
1773                y in 0u16..1000,
1774                w in 1u16..500,
1775                h in 1u16..500,
1776            ) {
1777                let area = Rect::new(x, y, w, h);
1778                let info = WidgetInfo::new("Test", area);
1779                prop_assert_eq!(info.area, area);
1780            }
1781
1782            /// Widget depth is preserved through builder pattern.
1783            #[test]
1784            fn widget_depth_preserved(depth in 0u8..255) {
1785                let info = WidgetInfo::new("Test", Rect::new(0, 0, 10, 10))
1786                    .with_depth(depth);
1787                prop_assert_eq!(info.depth, depth);
1788            }
1789
1790            /// Hit ID is preserved through builder pattern.
1791            #[test]
1792            fn widget_hit_id_preserved(id in 0u32..u32::MAX) {
1793                let hit_id = HitId::new(id);
1794                let info = WidgetInfo::new("Test", Rect::new(0, 0, 10, 10))
1795                    .with_hit_id(hit_id);
1796                prop_assert_eq!(info.hit_id, Some(hit_id));
1797            }
1798
1799            /// Adding children increases child count.
1800            #[test]
1801            fn add_child_increases_count(child_count in 0usize..50) {
1802                let mut parent = WidgetInfo::new("Parent", Rect::new(0, 0, 100, 100));
1803                for i in 0..child_count {
1804                    parent.add_child(WidgetInfo::new(
1805                        format!("Child{i}"),
1806                        Rect::new(0, i as u16, 10, 1),
1807                    ));
1808                }
1809                prop_assert_eq!(parent.children.len(), child_count);
1810            }
1811
1812            /// Hit regions can be added without bounds.
1813            #[test]
1814            fn add_hit_regions_unbounded(region_count in 0usize..100) {
1815                let mut info = WidgetInfo::new("Test", Rect::new(0, 0, 100, 100));
1816                for i in 0..region_count {
1817                    info.add_hit_region(
1818                        Rect::new(0, i as u16, 10, 1),
1819                        HitRegion::Content,
1820                        i as HitData,
1821                    );
1822                }
1823                prop_assert_eq!(info.hit_regions.len(), region_count);
1824            }
1825
1826            /// set_mode correctly maps index to mode.
1827            #[test]
1828            fn set_mode_maps_correctly(mode_idx in 0u8..10) {
1829                let mut state = InspectorState::new();
1830                state.set_mode(mode_idx);
1831                let expected = match mode_idx {
1832                    0 => InspectorMode::Off,
1833                    1 => InspectorMode::HitRegions,
1834                    2 => InspectorMode::WidgetBounds,
1835                    3 => InspectorMode::Full,
1836                    _ => InspectorMode::Full, // Saturates at max
1837                };
1838                prop_assert_eq!(state.mode, expected);
1839            }
1840
1841            /// should_show_hits respects both mode and toggle flag.
1842            #[test]
1843            fn should_show_hits_respects_both(mode_idx in 0u8..4, flag in proptest::bool::ANY) {
1844                let mut state = InspectorState::new();
1845                state.set_mode(mode_idx);
1846                state.show_hits = flag;
1847                let mode_allows = state.mode.show_hit_regions();
1848                prop_assert_eq!(state.should_show_hits(), flag && mode_allows);
1849            }
1850
1851            /// should_show_bounds respects both mode and toggle flag.
1852            #[test]
1853            fn should_show_bounds_respects_both(mode_idx in 0u8..4, flag in proptest::bool::ANY) {
1854                let mut state = InspectorState::new();
1855                state.set_mode(mode_idx);
1856                state.show_bounds = flag;
1857                let mode_allows = state.mode.show_widget_bounds();
1858                prop_assert_eq!(state.should_show_bounds(), flag && mode_allows);
1859            }
1860        }
1861    }
1862
1863    // =========================================================================
1864    // Region Color Coverage Tests (bd-17h9.6)
1865    // =========================================================================
1866
1867    #[test]
1868    fn region_color_all_variants() {
1869        let style = InspectorStyle::default();
1870
1871        // Each region type returns a distinct (or appropriate) color
1872        let none_color = style.region_color(HitRegion::None);
1873        let content_color = style.region_color(HitRegion::Content);
1874        let border_color = style.region_color(HitRegion::Border);
1875        let scrollbar_color = style.region_color(HitRegion::Scrollbar);
1876        let handle_color = style.region_color(HitRegion::Handle);
1877        let button_color = style.region_color(HitRegion::Button);
1878        let link_color = style.region_color(HitRegion::Link);
1879        let custom_color = style.region_color(HitRegion::Custom(42));
1880
1881        // None returns transparent
1882        assert_eq!(none_color, PackedRgba::TRANSPARENT);
1883
1884        // Other regions return non-transparent colors
1885        assert_ne!(content_color.a(), 0);
1886        assert_ne!(border_color.a(), 0);
1887        assert_ne!(scrollbar_color.a(), 0);
1888        assert_ne!(handle_color.a(), 0);
1889        assert_ne!(button_color.a(), 0);
1890        assert_ne!(link_color.a(), 0);
1891        assert_ne!(custom_color.a(), 0);
1892
1893        // Verify they are semi-transparent (not fully opaque)
1894        assert!(content_color.a() < 255);
1895        assert!(button_color.a() < 255);
1896    }
1897
1898    #[test]
1899    fn region_color_custom_variants() {
1900        let style = InspectorStyle::default();
1901
1902        // All Custom variants return the same color
1903        let c0 = style.region_color(HitRegion::Custom(0));
1904        let c1 = style.region_color(HitRegion::Custom(1));
1905        let c255 = style.region_color(HitRegion::Custom(255));
1906
1907        assert_eq!(c0, c1);
1908        assert_eq!(c1, c255);
1909    }
1910
1911    // =========================================================================
1912    // Should-Show Methods Tests (bd-17h9.6)
1913    // =========================================================================
1914
1915    #[test]
1916    fn should_show_hits_requires_both_mode_and_flag() {
1917        let mut state = InspectorState::new();
1918
1919        // Off mode: never show
1920        state.mode = InspectorMode::Off;
1921        state.show_hits = true;
1922        assert!(!state.should_show_hits());
1923
1924        // HitRegions mode with flag on: show
1925        state.mode = InspectorMode::HitRegions;
1926        state.show_hits = true;
1927        assert!(state.should_show_hits());
1928
1929        // HitRegions mode with flag off: don't show
1930        state.show_hits = false;
1931        assert!(!state.should_show_hits());
1932
1933        // WidgetBounds mode: doesn't show hits
1934        state.mode = InspectorMode::WidgetBounds;
1935        state.show_hits = true;
1936        assert!(!state.should_show_hits());
1937
1938        // Full mode with flag on: show
1939        state.mode = InspectorMode::Full;
1940        state.show_hits = true;
1941        assert!(state.should_show_hits());
1942    }
1943
1944    #[test]
1945    fn should_show_bounds_requires_both_mode_and_flag() {
1946        let mut state = InspectorState::new();
1947
1948        // Off mode: never show
1949        state.mode = InspectorMode::Off;
1950        state.show_bounds = true;
1951        assert!(!state.should_show_bounds());
1952
1953        // WidgetBounds mode with flag on: show
1954        state.mode = InspectorMode::WidgetBounds;
1955        state.show_bounds = true;
1956        assert!(state.should_show_bounds());
1957
1958        // WidgetBounds mode with flag off: don't show
1959        state.show_bounds = false;
1960        assert!(!state.should_show_bounds());
1961
1962        // HitRegions mode: doesn't show bounds
1963        state.mode = InspectorMode::HitRegions;
1964        state.show_bounds = true;
1965        assert!(!state.should_show_bounds());
1966
1967        // Full mode with flag on: show
1968        state.mode = InspectorMode::Full;
1969        state.show_bounds = true;
1970        assert!(state.should_show_bounds());
1971    }
1972
1973    // =========================================================================
1974    // Overlay Rendering Tests (bd-17h9.6)
1975    // =========================================================================
1976
1977    #[test]
1978    fn overlay_respects_mode_hit_regions_only() {
1979        let mut state = InspectorState::new();
1980        state.mode = InspectorMode::HitRegions;
1981
1982        // Register a widget for bounds drawing BEFORE creating overlay
1983        state.register_widget(WidgetInfo::new("TestWidget", Rect::new(5, 5, 10, 3)));
1984
1985        let overlay = InspectorOverlay::new(&state);
1986        let mut pool = GraphemePool::new();
1987        let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
1988
1989        // Register a hit region
1990        frame.register_hit(Rect::new(0, 0, 5, 5), HitId::new(1), HitRegion::Button, 0);
1991
1992        let area = Rect::new(0, 0, 20, 10);
1993        overlay.render(area, &mut frame);
1994
1995        // In HitRegions mode, bounds should NOT be rendered
1996        // (We can verify by checking that widget info bounds area is not drawn)
1997        assert!(state.should_show_hits());
1998        assert!(!state.should_show_bounds());
1999    }
2000
2001    #[test]
2002    fn overlay_respects_mode_widget_bounds_only() {
2003        let mut state = InspectorState::new();
2004        state.mode = InspectorMode::WidgetBounds;
2005        state.show_names = true;
2006
2007        // Register widget
2008        state.register_widget(WidgetInfo::new("TestWidget", Rect::new(2, 2, 15, 5)));
2009
2010        let overlay = InspectorOverlay::new(&state);
2011        let mut pool = GraphemePool::new();
2012        let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
2013
2014        let area = Rect::new(0, 0, 20, 10);
2015        overlay.render(area, &mut frame);
2016
2017        // In WidgetBounds mode, hits should NOT be shown
2018        assert!(!state.should_show_hits());
2019        assert!(state.should_show_bounds());
2020    }
2021
2022    #[test]
2023    fn overlay_full_mode_shows_both() {
2024        let mut state = InspectorState::new();
2025        state.mode = InspectorMode::Full;
2026
2027        // Register widget
2028        state.register_widget(WidgetInfo::new("FullTest", Rect::new(0, 0, 10, 5)));
2029
2030        let overlay = InspectorOverlay::new(&state);
2031        let mut pool = GraphemePool::new();
2032        let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
2033
2034        frame.register_hit(Rect::new(0, 0, 5, 5), HitId::new(1), HitRegion::Content, 0);
2035
2036        let area = Rect::new(0, 0, 20, 10);
2037        overlay.render(area, &mut frame);
2038
2039        assert!(state.should_show_hits());
2040        assert!(state.should_show_bounds());
2041    }
2042
2043    #[test]
2044    fn overlay_detail_panel_renders_when_enabled() {
2045        let mut state = InspectorState::new();
2046        state.mode = InspectorMode::Full;
2047        state.show_detail_panel = true;
2048        state.set_hover(Some((5, 5)));
2049
2050        let overlay = InspectorOverlay::new(&state);
2051        let mut pool = GraphemePool::new();
2052        let mut frame = Frame::with_hit_grid(50, 25, &mut pool);
2053
2054        let area = Rect::new(0, 0, 50, 25);
2055        overlay.render(area, &mut frame);
2056
2057        // The detail panel is 24 chars wide, rendered at right edge
2058        // Panel should be at x = 50 - 24 - 1 = 25
2059        // Check that something is rendered in the panel area
2060        let panel_x = 25;
2061        let panel_y = 1;
2062
2063        // Panel background should be the label_bg color
2064        let cell = frame.buffer.get(panel_x + 1, panel_y + 1);
2065        assert!(cell.is_some());
2066    }
2067
2068    #[test]
2069    fn overlay_without_hit_grid_shows_warning() {
2070        let mut state = InspectorState::new();
2071        state.mode = InspectorMode::HitRegions;
2072
2073        let overlay = InspectorOverlay::new(&state);
2074        let mut pool = GraphemePool::new();
2075        // Frame without hit grid
2076        let mut frame = Frame::new(40, 10, &mut pool);
2077
2078        let area = Rect::new(0, 0, 40, 10);
2079        overlay.render(area, &mut frame);
2080
2081        // Warning message "HitGrid not enabled" should be centered
2082        // The message is 20 chars, centered in 40 char width = starts at x=10
2083        // Check first char is 'H' from "HitGrid"
2084        if let Some(cell) = frame.buffer.get(10, 0) {
2085            assert_eq!(cell.content.as_char(), Some('H'));
2086        }
2087    }
2088
2089    // =========================================================================
2090    // Widget Tree Rendering Tests (bd-17h9.6)
2091    // =========================================================================
2092
2093    #[test]
2094    fn nested_widgets_render_with_depth_colors() {
2095        let mut state = InspectorState::new();
2096        state.mode = InspectorMode::WidgetBounds;
2097        state.show_names = false; // Disable names for clearer test
2098
2099        // Create nested widget tree
2100        let mut parent = WidgetInfo::new("Parent", Rect::new(0, 0, 30, 20)).with_depth(0);
2101        let child = WidgetInfo::new("Child", Rect::new(2, 2, 26, 16)).with_depth(1);
2102        parent.add_child(child);
2103
2104        state.register_widget(parent);
2105
2106        let overlay = InspectorOverlay::new(&state);
2107        let mut pool = GraphemePool::new();
2108        let mut frame = Frame::with_hit_grid(40, 25, &mut pool);
2109
2110        let area = Rect::new(0, 0, 40, 25);
2111        overlay.render(area, &mut frame);
2112
2113        // Parent outline at depth 0 uses bound_colors[0]
2114        // Child outline at depth 1 uses bound_colors[1]
2115        let style = InspectorStyle::default();
2116        let parent_color = style.bound_color(0);
2117        let child_color = style.bound_color(1);
2118
2119        // Verify different colors are used
2120        assert_ne!(parent_color, child_color);
2121    }
2122
2123    #[test]
2124    fn widget_with_empty_name_skips_label() {
2125        let mut state = InspectorState::new();
2126        state.mode = InspectorMode::WidgetBounds;
2127        state.show_names = true;
2128
2129        // Widget with empty name
2130        state.register_widget(WidgetInfo::new("", Rect::new(5, 5, 10, 5)));
2131
2132        let overlay = InspectorOverlay::new(&state);
2133        let mut pool = GraphemePool::new();
2134        let mut frame = Frame::with_hit_grid(20, 15, &mut pool);
2135
2136        let area = Rect::new(0, 0, 20, 15);
2137        overlay.render(area, &mut frame);
2138
2139        // Should not panic; empty name is handled gracefully
2140    }
2141
2142    // =========================================================================
2143    // Hit Info Edge Cases (bd-17h9.6)
2144    // =========================================================================
2145
2146    #[test]
2147    fn hit_info_all_region_types() {
2148        let regions = [
2149            HitRegion::None,
2150            HitRegion::Content,
2151            HitRegion::Border,
2152            HitRegion::Scrollbar,
2153            HitRegion::Handle,
2154            HitRegion::Button,
2155            HitRegion::Link,
2156            HitRegion::Custom(0),
2157            HitRegion::Custom(255),
2158        ];
2159
2160        for region in regions {
2161            let cell = HitCell::new(HitId::new(1), region, 42);
2162            let info = HitInfo::from_cell(&cell, 10, 20);
2163
2164            let info = info.expect("should create info");
2165            assert_eq!(info.region, region);
2166            assert_eq!(info.data, 42);
2167        }
2168    }
2169
2170    #[test]
2171    fn hit_cell_with_zero_data() {
2172        let cell = HitCell::new(HitId::new(5), HitRegion::Content, 0);
2173        let info = HitInfo::from_cell(&cell, 0, 0).unwrap();
2174        assert_eq!(info.data, 0);
2175    }
2176
2177    #[test]
2178    fn hit_cell_with_max_data() {
2179        let cell = HitCell::new(HitId::new(5), HitRegion::Content, u64::MAX);
2180        let info = HitInfo::from_cell(&cell, 0, 0).unwrap();
2181        assert_eq!(info.data, u64::MAX);
2182    }
2183
2184    // =========================================================================
2185    // State Initialization Tests (bd-17h9.6)
2186    // =========================================================================
2187
2188    #[test]
2189    fn inspector_state_new_defaults() {
2190        let state = InspectorState::new();
2191
2192        // Verify all defaults
2193        assert_eq!(state.mode, InspectorMode::Off);
2194        assert!(state.hover_pos.is_none());
2195        assert!(state.selected.is_none());
2196        assert!(state.widgets.is_empty());
2197        assert!(!state.show_detail_panel);
2198        assert!(state.show_hits);
2199        assert!(state.show_bounds);
2200        assert!(state.show_names);
2201        assert!(!state.show_times);
2202    }
2203
2204    #[test]
2205    fn inspector_state_default_matches_new() {
2206        let state_new = InspectorState::new();
2207        let state_default = InspectorState::default();
2208
2209        // Most fields should match (but new() sets show_hits/bounds/names to true)
2210        assert_eq!(state_new.mode, state_default.mode);
2211        assert_eq!(state_new.hover_pos, state_default.hover_pos);
2212        assert_eq!(state_new.selected, state_default.selected);
2213    }
2214
2215    #[test]
2216    fn inspector_style_colors_are_semi_transparent() {
2217        let style = InspectorStyle::default();
2218
2219        // hit_overlay should be semi-transparent
2220        assert!(style.hit_overlay.a() > 0);
2221        assert!(style.hit_overlay.a() < 255);
2222
2223        // hit_hover should be semi-transparent
2224        assert!(style.hit_hover.a() > 0);
2225        assert!(style.hit_hover.a() < 255);
2226
2227        // selected_highlight should be semi-transparent
2228        assert!(style.selected_highlight.a() > 0);
2229        assert!(style.selected_highlight.a() < 255);
2230
2231        // label_bg should be nearly opaque
2232        assert!(style.label_bg.a() > 128);
2233    }
2234
2235    #[cfg(feature = "tracing")]
2236    #[test]
2237    fn telemetry_spans_and_events() {
2238        // This test mostly verifies that the code compiles with tracing macros.
2239        // Verifying actual output would require a custom subscriber which is overkill here.
2240        let mut state = InspectorState::new();
2241        state.toggle(); // Should log "Inspector toggled"
2242
2243        let overlay = InspectorOverlay::new(&state);
2244        let mut pool = GraphemePool::new();
2245        let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
2246
2247        let area = Rect::new(0, 0, 20, 10);
2248        overlay.render(area, &mut frame); // Should enter "inspector_overlay" span
2249    }
2250
2251    #[test]
2252    fn diagnostic_entry_checksum_deterministic() {
2253        let entry1 = DiagnosticEntry::new(DiagnosticEventKind::ModeChanged)
2254            .with_previous_mode(InspectorMode::Off)
2255            .with_mode(InspectorMode::Full)
2256            .with_flag("hits", true)
2257            .with_context("test")
2258            .with_checksum();
2259        let entry2 = DiagnosticEntry::new(DiagnosticEventKind::ModeChanged)
2260            .with_previous_mode(InspectorMode::Off)
2261            .with_mode(InspectorMode::Full)
2262            .with_flag("hits", true)
2263            .with_context("test")
2264            .with_checksum();
2265        assert_eq!(entry1.checksum, entry2.checksum);
2266        assert_ne!(entry1.checksum, 0);
2267    }
2268
2269    #[test]
2270    fn diagnostic_log_records_mode_changes() {
2271        let mut state = InspectorState::new().with_diagnostics();
2272        state.set_mode(1);
2273        state.set_mode(2);
2274        let log = state.diagnostic_log().expect("diagnostic log should exist");
2275        assert!(!log.entries().is_empty());
2276        assert!(
2277            !log.entries_of_kind(DiagnosticEventKind::ModeChanged)
2278                .is_empty()
2279        );
2280    }
2281
2282    #[test]
2283    fn telemetry_hooks_on_mode_change_fires() {
2284        use std::sync::Arc;
2285        use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
2286
2287        let counter = Arc::new(AtomicUsize::new(0));
2288        let counter_clone = Arc::clone(&counter);
2289        let hooks = TelemetryHooks::new().on_mode_change(move |_| {
2290            counter_clone.fetch_add(1, AtomicOrdering::Relaxed);
2291        });
2292
2293        let mut state = InspectorState::new().with_telemetry_hooks(hooks);
2294        state.set_mode(1);
2295        state.set_mode(2);
2296        assert!(counter.load(AtomicOrdering::Relaxed) >= 1);
2297    }
2298
2299    // =========================================================================
2300    // Accessibility/UX Tests (bd-17h9.9)
2301    // =========================================================================
2302
2303    /// Calculate relative luminance for WCAG contrast calculation.
2304    /// Formula: https://www.w3.org/TR/WCAG20/#relativeluminancedef
2305    fn relative_luminance(rgba: PackedRgba) -> f64 {
2306        fn channel_luminance(c: u8) -> f64 {
2307            let c = c as f64 / 255.0;
2308            if c <= 0.03928 {
2309                c / 12.92
2310            } else {
2311                ((c + 0.055) / 1.055).powf(2.4)
2312            }
2313        }
2314        let r = channel_luminance(rgba.r());
2315        let g = channel_luminance(rgba.g());
2316        let b = channel_luminance(rgba.b());
2317        0.2126 * r + 0.7152 * g + 0.0722 * b
2318    }
2319
2320    /// Calculate WCAG contrast ratio between two colors.
2321    /// Returns ratio in range [1.0, 21.0].
2322    fn contrast_ratio(fg: PackedRgba, bg: PackedRgba) -> f64 {
2323        let l1 = relative_luminance(fg);
2324        let l2 = relative_luminance(bg);
2325        let lighter = l1.max(l2);
2326        let darker = l1.min(l2);
2327        (lighter + 0.05) / (darker + 0.05)
2328    }
2329
2330    #[test]
2331    fn a11y_label_contrast_meets_wcag_aa() {
2332        // WCAG AA requires 4.5:1 for normal text, 3:1 for large text
2333        // Labels in inspector are typically large (widget names), so 3:1 is sufficient
2334        let style = InspectorStyle::default();
2335        let ratio = contrast_ratio(style.label_fg, style.label_bg);
2336        assert!(
2337            ratio >= 3.0,
2338            "Label contrast ratio {:.2}:1 should be >= 3:1 (WCAG AA large text)",
2339            ratio
2340        );
2341        // Actually we exceed 4.5:1 (white on dark bg)
2342        assert!(
2343            ratio >= 4.5,
2344            "Label contrast ratio {:.2}:1 should be >= 4.5:1 (WCAG AA normal text)",
2345            ratio
2346        );
2347    }
2348
2349    #[test]
2350    fn a11y_bound_colors_are_distinct() {
2351        // Ensure bound colors are visually distinct from each other
2352        // by checking they have different hues
2353        let style = InspectorStyle::default();
2354        let colors = &style.bound_colors;
2355
2356        // All pairs should have at least one channel differing by 100+
2357        for (i, a) in colors.iter().enumerate() {
2358            for (j, b) in colors.iter().enumerate() {
2359                if i != j {
2360                    let r_diff = (a.r() as i32 - b.r() as i32).abs();
2361                    let g_diff = (a.g() as i32 - b.g() as i32).abs();
2362                    let b_diff = (a.b() as i32 - b.b() as i32).abs();
2363                    let max_diff = r_diff.max(g_diff).max(b_diff);
2364                    assert!(
2365                        max_diff >= 100,
2366                        "Bound colors {} and {} should differ by at least 100 in one channel (max diff = {})",
2367                        i,
2368                        j,
2369                        max_diff
2370                    );
2371                }
2372            }
2373        }
2374    }
2375
2376    #[test]
2377    fn a11y_bound_colors_have_good_visibility() {
2378        // All bound colors should be bright enough to be visible
2379        // At least one channel should be >= 100
2380        let style = InspectorStyle::default();
2381        for (i, color) in style.bound_colors.iter().enumerate() {
2382            let max_channel = color.r().max(color.g()).max(color.b());
2383            assert!(
2384                max_channel >= 100,
2385                "Bound color {} should have at least one channel >= 100 for visibility (max = {})",
2386                i,
2387                max_channel
2388            );
2389        }
2390    }
2391
2392    #[test]
2393    fn a11y_hit_overlays_are_visible() {
2394        // Hit overlays should have enough alpha to be visible
2395        // but not so much that they obscure content
2396        let style = InspectorStyle::default();
2397
2398        // hit_overlay (normal state) - should be visible but subtle
2399        assert!(
2400            style.hit_overlay.a() >= 50,
2401            "hit_overlay alpha {} should be >= 50 for visibility",
2402            style.hit_overlay.a()
2403        );
2404
2405        // hit_hover (hover state) - should be more prominent
2406        assert!(
2407            style.hit_hover.a() >= 80,
2408            "hit_hover alpha {} should be >= 80 for clear hover indication",
2409            style.hit_hover.a()
2410        );
2411        assert!(
2412            style.hit_hover.a() > style.hit_overlay.a(),
2413            "hit_hover should be more visible than hit_overlay"
2414        );
2415
2416        // selected_highlight - should be the most prominent
2417        assert!(
2418            style.selected_highlight.a() >= 100,
2419            "selected_highlight alpha {} should be >= 100 for clear selection",
2420            style.selected_highlight.a()
2421        );
2422    }
2423
2424    #[test]
2425    fn a11y_region_colors_cover_all_variants() {
2426        // Ensure all HitRegion variants have a defined color
2427        let style = InspectorStyle::default();
2428        let regions = [
2429            HitRegion::None,
2430            HitRegion::Content,
2431            HitRegion::Border,
2432            HitRegion::Scrollbar,
2433            HitRegion::Handle,
2434            HitRegion::Button,
2435            HitRegion::Link,
2436            HitRegion::Custom(0),
2437        ];
2438
2439        for region in regions {
2440            let color = style.region_color(region);
2441            // None should be transparent, others should be visible
2442            match region {
2443                HitRegion::None => {
2444                    assert_eq!(
2445                        color,
2446                        PackedRgba::TRANSPARENT,
2447                        "HitRegion::None should be transparent"
2448                    );
2449                }
2450                _ => {
2451                    assert!(
2452                        color.a() > 0,
2453                        "HitRegion::{:?} should have non-zero alpha",
2454                        region
2455                    );
2456                }
2457            }
2458        }
2459    }
2460
2461    #[test]
2462    fn a11y_interactive_regions_are_distinct_from_passive() {
2463        // Interactive regions (Button, Link) should be visually distinct
2464        // from passive regions (Content, Border)
2465        let style = InspectorStyle::default();
2466
2467        let button_color = style.region_color(HitRegion::Button);
2468        let link_color = style.region_color(HitRegion::Link);
2469        let content_color = style.region_color(HitRegion::Content);
2470        let _border_color = style.region_color(HitRegion::Border);
2471
2472        // Button and Link should be more visible (higher alpha) than passive regions
2473        assert!(
2474            button_color.a() >= content_color.a(),
2475            "Button overlay should be as visible or more visible than Content"
2476        );
2477        assert!(
2478            link_color.a() >= content_color.a(),
2479            "Link overlay should be as visible or more visible than Content"
2480        );
2481
2482        // Button and Link should differ from Content by color (not just alpha)
2483        let button_content_diff = (button_color.r() as i32 - content_color.r() as i32).abs()
2484            + (button_color.g() as i32 - content_color.g() as i32).abs()
2485            + (button_color.b() as i32 - content_color.b() as i32).abs();
2486        assert!(
2487            button_content_diff >= 100,
2488            "Button color should differ significantly from Content (diff = {})",
2489            button_content_diff
2490        );
2491    }
2492
2493    #[test]
2494    fn a11y_keybinding_constants_documented() {
2495        // This test documents the expected keybindings per spec.
2496        // It doesn't test runtime behavior, but serves as a reminder
2497        // of accessibility considerations for keybindings:
2498        //
2499        // Primary activations (accessible):
2500        //   - F12: Toggle inspector
2501        //   - Ctrl+Shift+I: Alternative toggle (browser devtools pattern)
2502        //
2503        // Mode selection (may conflict with text input):
2504        //   - i: Cycle modes
2505        //   - 0-3: Direct mode selection
2506        //
2507        // Navigation (accessible):
2508        //   - Tab/Shift+Tab: Widget cycling
2509        //   - Escape: Clear selection
2510        //   - Enter: Expand/collapse
2511        //
2512        // Toggles (may conflict with text input):
2513        //   - h: Toggle hits, b: bounds, n: names, t: times
2514        //   - d: Toggle detail panel
2515        //
2516        // Recommendation: When inspector is active and focused,
2517        // these single-letter keys should work. When a text input
2518        // has focus, pass through to the input.
2519
2520        // This test passes if it compiles - it's documentation-as-code
2521        // (Assertion removed as it was always true)
2522    }
2523
2524    // =========================================================================
2525    // Stress/Performance Regression Tests (bd-17h9.4)
2526    // =========================================================================
2527
2528    use std::collections::hash_map::DefaultHasher;
2529    use std::hash::{Hash, Hasher};
2530    use std::time::Instant;
2531
2532    fn inspector_seed() -> u64 {
2533        std::env::var("INSPECTOR_SEED")
2534            .ok()
2535            .and_then(|s| s.parse().ok())
2536            .unwrap_or(42)
2537    }
2538
2539    fn next_u32(seed: &mut u64) -> u32 {
2540        let mut x = *seed;
2541        x ^= x << 13;
2542        x ^= x >> 7;
2543        x ^= x << 17;
2544        *seed = x;
2545        (x >> 32) as u32
2546    }
2547
2548    fn rand_range(seed: &mut u64, min: u16, max: u16) -> u16 {
2549        if min >= max {
2550            return min;
2551        }
2552        let span = (max - min) as u32 + 1;
2553        let n = next_u32(seed) % span;
2554        min + n as u16
2555    }
2556
2557    fn random_rect(seed: &mut u64, area: Rect) -> Rect {
2558        let max_w = area.width.max(1);
2559        let max_h = area.height.max(1);
2560        let w = rand_range(seed, 1, max_w);
2561        let h = rand_range(seed, 1, max_h);
2562        let max_x = area.x + area.width.saturating_sub(w);
2563        let max_y = area.y + area.height.saturating_sub(h);
2564        let x = rand_range(seed, area.x, max_x);
2565        let y = rand_range(seed, area.y, max_y);
2566        Rect::new(x, y, w, h)
2567    }
2568
2569    fn build_widget_tree(
2570        seed: &mut u64,
2571        depth: u8,
2572        max_depth: u8,
2573        breadth: u8,
2574        area: Rect,
2575        count: &mut usize,
2576    ) -> WidgetInfo {
2577        *count += 1;
2578        let name = format!("Widget_{depth}_{}", *count);
2579        let mut node = WidgetInfo::new(name, area).with_depth(depth);
2580
2581        if depth < max_depth {
2582            for _ in 0..breadth {
2583                let child_area = random_rect(seed, area);
2584                let child =
2585                    build_widget_tree(seed, depth + 1, max_depth, breadth, child_area, count);
2586                node.add_child(child);
2587            }
2588        }
2589
2590        node
2591    }
2592
2593    fn build_stress_state(
2594        seed: &mut u64,
2595        roots: usize,
2596        max_depth: u8,
2597        breadth: u8,
2598        area: Rect,
2599    ) -> (InspectorState, usize) {
2600        let mut state = InspectorState {
2601            mode: InspectorMode::Full,
2602            show_hits: true,
2603            show_bounds: true,
2604            show_names: true,
2605            show_detail_panel: true,
2606            hover_pos: Some((area.x + 1, area.y + 1)),
2607            ..Default::default()
2608        };
2609
2610        let mut count = 0usize;
2611        for _ in 0..roots {
2612            let root_area = random_rect(seed, area);
2613            let widget = build_widget_tree(seed, 0, max_depth, breadth, root_area, &mut count);
2614            state.register_widget(widget);
2615        }
2616
2617        (state, count)
2618    }
2619
2620    fn populate_hit_grid(frame: &mut Frame, seed: &mut u64, count: usize, area: Rect) -> usize {
2621        for idx in 0..count {
2622            let region = match idx % 6 {
2623                0 => HitRegion::Content,
2624                1 => HitRegion::Border,
2625                2 => HitRegion::Scrollbar,
2626                3 => HitRegion::Handle,
2627                4 => HitRegion::Button,
2628                _ => HitRegion::Link,
2629            };
2630            let rect = random_rect(seed, area);
2631            frame.register_hit(rect, HitId::new((idx + 1) as u32), region, idx as HitData);
2632        }
2633        count
2634    }
2635
2636    fn buffer_checksum(frame: &Frame) -> u64 {
2637        let mut hasher = DefaultHasher::new();
2638        let mut scratch = String::new();
2639        for y in 0..frame.buffer.height() {
2640            for x in 0..frame.buffer.width() {
2641                if let Some(cell) = frame.buffer.get(x, y) {
2642                    scratch.clear();
2643                    use std::fmt::Write;
2644                    let _ = write!(&mut scratch, "{cell:?}");
2645                    scratch.hash(&mut hasher);
2646                }
2647            }
2648        }
2649        hasher.finish()
2650    }
2651
2652    fn log_jsonl(event: &str, fields: &[(&str, String)]) {
2653        let mut parts = Vec::with_capacity(fields.len() + 1);
2654        parts.push(format!(r#""event":"{event}""#));
2655        parts.extend(fields.iter().map(|(k, v)| format!(r#""{k}":{v}"#)));
2656        eprintln!("{{{}}}", parts.join(","));
2657    }
2658
2659    #[test]
2660    fn inspector_stress_large_tree_renders() {
2661        let mut seed = inspector_seed();
2662        let area = Rect::new(0, 0, 160, 48);
2663        let (state, widget_count) = build_stress_state(&mut seed, 6, 3, 3, area);
2664
2665        let mut pool = GraphemePool::new();
2666        let mut frame = Frame::with_hit_grid(area.width, area.height, &mut pool);
2667        let hit_count = populate_hit_grid(&mut frame, &mut seed, 800, area);
2668
2669        let overlay = InspectorOverlay::new(&state);
2670        overlay.render(area, &mut frame);
2671
2672        let checksum = buffer_checksum(&frame);
2673        log_jsonl(
2674            "inspector_stress_render",
2675            &[
2676                ("seed", seed.to_string()),
2677                ("widgets", widget_count.to_string()),
2678                ("hit_regions", hit_count.to_string()),
2679                ("checksum", format!(r#""0x{checksum:016x}""#)),
2680            ],
2681        );
2682
2683        assert!(checksum != 0, "Rendered buffer checksum should be non-zero");
2684    }
2685
2686    #[test]
2687    fn inspector_stress_checksum_is_deterministic() {
2688        let seed = inspector_seed();
2689        let area = Rect::new(0, 0, 140, 40);
2690
2691        let checksum_a = {
2692            let mut seed = seed;
2693            let (state, _) = build_stress_state(&mut seed, 5, 3, 3, area);
2694            let mut pool = GraphemePool::new();
2695            let mut frame = Frame::with_hit_grid(area.width, area.height, &mut pool);
2696            populate_hit_grid(&mut frame, &mut seed, 600, area);
2697            InspectorOverlay::new(&state).render(area, &mut frame);
2698            buffer_checksum(&frame)
2699        };
2700
2701        let checksum_b = {
2702            let mut seed = seed;
2703            let (state, _) = build_stress_state(&mut seed, 5, 3, 3, area);
2704            let mut pool = GraphemePool::new();
2705            let mut frame = Frame::with_hit_grid(area.width, area.height, &mut pool);
2706            populate_hit_grid(&mut frame, &mut seed, 600, area);
2707            InspectorOverlay::new(&state).render(area, &mut frame);
2708            buffer_checksum(&frame)
2709        };
2710
2711        log_jsonl(
2712            "inspector_stress_determinism",
2713            &[
2714                ("seed", seed.to_string()),
2715                ("checksum_a", format!(r#""0x{checksum_a:016x}""#)),
2716                ("checksum_b", format!(r#""0x{checksum_b:016x}""#)),
2717            ],
2718        );
2719
2720        assert_eq!(
2721            checksum_a, checksum_b,
2722            "Stress render checksum should be deterministic"
2723        );
2724    }
2725
2726    #[test]
2727    fn inspector_perf_budget_overlay() {
2728        let seed = inspector_seed();
2729        let area = Rect::new(0, 0, 160, 48);
2730        let iterations = 40usize;
2731        let budget_p95_us = 15_000u64;
2732
2733        let mut timings = Vec::with_capacity(iterations);
2734        let mut checksums = Vec::with_capacity(iterations);
2735
2736        for i in 0..iterations {
2737            let mut seed = seed.wrapping_add(i as u64);
2738            let (state, widget_count) = build_stress_state(&mut seed, 6, 3, 3, area);
2739            let mut pool = GraphemePool::new();
2740            let mut frame = Frame::with_hit_grid(area.width, area.height, &mut pool);
2741            let hit_count = populate_hit_grid(&mut frame, &mut seed, 800, area);
2742
2743            let start = Instant::now();
2744            InspectorOverlay::new(&state).render(area, &mut frame);
2745            let elapsed_us = start.elapsed().as_micros() as u64;
2746            timings.push(elapsed_us);
2747
2748            let checksum = buffer_checksum(&frame);
2749            checksums.push(checksum);
2750
2751            if i == 0 {
2752                log_jsonl(
2753                    "inspector_perf_sample",
2754                    &[
2755                        ("seed", seed.to_string()),
2756                        ("widgets", widget_count.to_string()),
2757                        ("hit_regions", hit_count.to_string()),
2758                        ("timing_us", elapsed_us.to_string()),
2759                        ("checksum", format!(r#""0x{checksum:016x}""#)),
2760                    ],
2761                );
2762            }
2763        }
2764
2765        let mut sorted = timings.clone();
2766        sorted.sort_unstable();
2767        let p95 = sorted[sorted.len() * 95 / 100];
2768        let p99 = sorted[sorted.len() * 99 / 100];
2769        let avg = timings.iter().sum::<u64>() as f64 / timings.len() as f64;
2770
2771        let mut seq_hasher = DefaultHasher::new();
2772        for checksum in &checksums {
2773            checksum.hash(&mut seq_hasher);
2774        }
2775        let seq_checksum = seq_hasher.finish();
2776
2777        log_jsonl(
2778            "inspector_perf_budget",
2779            &[
2780                ("seed", seed.to_string()),
2781                ("iterations", iterations.to_string()),
2782                ("avg_us", format!("{:.2}", avg)),
2783                ("p95_us", p95.to_string()),
2784                ("p99_us", p99.to_string()),
2785                ("budget_p95_us", budget_p95_us.to_string()),
2786                ("sequence_checksum", format!(r#""0x{seq_checksum:016x}""#)),
2787            ],
2788        );
2789
2790        assert!(
2791            p95 <= budget_p95_us,
2792            "Inspector overlay p95 {}µs exceeds budget {}µs",
2793            p95,
2794            budget_p95_us
2795        );
2796    }
2797}