Skip to main content

ftui_harness/
flicker_detection.rs

1#![forbid(unsafe_code)]
2
3//! Flicker/Tear Detection Harness for FrankenTUI.
4//!
5//! Detects visual artifacts (flicker/tearing) by analyzing ANSI output streams
6//! for sync output gaps, partial clears, and other anomalies.
7//!
8//! # Key Concepts
9//!
10//! - **Sync Output Mode**: DEC private mode 2026 (`?2026h`/`?2026l`) brackets
11//!   synchronized frame updates. Content outside these brackets may cause tearing.
12//! - **Partial Clears**: ED (Erase Display) or EL (Erase Line) sequences mid-frame
13//!   can cause visible flicker if not properly synchronized.
14//! - **Frame Boundaries**: A frame begins with `?2026h` (begin sync) and ends with
15//!   `?2026l` (end sync). Content between these markers should be atomic.
16//!
17//! # Detection Rules
18//!
19//! 1. **Sync Gap**: Output occurs outside synchronized mode brackets
20//! 2. **Partial Clear**: ED/EL commands issued mid-frame (may show blank rows)
21//! 3. **Incomplete Frame**: Frame started but never completed (crash/timeout)
22//! 4. **Interleaved Writes**: Multiple frames overlap (race condition)
23//!
24//! # JSONL Logging Schema
25//!
26//! All events are logged as JSONL with stable schema:
27//! ```json
28//! {
29//!   "run_id": "uuid",
30//!   "timestamp_ns": 1234567890,
31//!   "event_type": "sync_gap|partial_clear|incomplete_frame|...",
32//!   "severity": "warning|error|info",
33//!   "details": { ... event-specific fields ... },
34//!   "context": { "frame_id": 0, "byte_offset": 0, "line": 0 }
35//! }
36//! ```
37
38use std::fmt::Write as FmtWrite;
39use std::io::Write;
40
41// ============================================================================
42// Core Types
43// ============================================================================
44
45/// Severity level for flicker events.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum Severity {
48    /// Informational event (e.g., frame boundary).
49    Info,
50    /// Potential issue that may cause visible artifacts.
51    Warning,
52    /// Definite flicker/tear detected.
53    Error,
54}
55
56impl std::fmt::Display for Severity {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            Self::Info => write!(f, "info"),
60            Self::Warning => write!(f, "warning"),
61            Self::Error => write!(f, "error"),
62        }
63    }
64}
65
66/// Type of flicker/tear event detected.
67#[derive(Debug, Clone, PartialEq, Eq, Hash)]
68pub enum EventType {
69    /// Frame started with DEC ?2026h.
70    FrameStart,
71    /// Frame ended with DEC ?2026l.
72    FrameEnd,
73    /// Output occurred outside synchronized mode.
74    SyncGap,
75    /// Erase operation (ED/EL) detected mid-frame.
76    PartialClear,
77    /// Frame started but never completed.
78    IncompleteFrame,
79    /// Multiple frames overlapping.
80    InterleavedWrites,
81    /// Cursor moved without content update (suspicious).
82    SuspiciousCursorMove,
83    /// Analysis completed.
84    AnalysisComplete,
85}
86
87impl std::fmt::Display for EventType {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        match self {
90            Self::FrameStart => write!(f, "frame_start"),
91            Self::FrameEnd => write!(f, "frame_end"),
92            Self::SyncGap => write!(f, "sync_gap"),
93            Self::PartialClear => write!(f, "partial_clear"),
94            Self::IncompleteFrame => write!(f, "incomplete_frame"),
95            Self::InterleavedWrites => write!(f, "interleaved_writes"),
96            Self::SuspiciousCursorMove => write!(f, "suspicious_cursor_move"),
97            Self::AnalysisComplete => write!(f, "analysis_complete"),
98        }
99    }
100}
101
102/// Context where the event occurred.
103#[derive(Debug, Clone, Default)]
104pub struct EventContext {
105    /// Current frame ID (0 if no frame active).
106    pub frame_id: u64,
107    /// Byte offset in the input stream.
108    pub byte_offset: usize,
109    /// Line number in the output (for debugging).
110    pub line: usize,
111    /// Column in the output.
112    pub column: usize,
113}
114
115/// Additional details for specific event types.
116#[derive(Debug, Clone, Default)]
117pub struct EventDetails {
118    /// Description of the event.
119    pub message: String,
120    /// Bytes that triggered the event.
121    pub trigger_bytes: Option<Vec<u8>>,
122    /// Number of bytes outside sync.
123    pub bytes_outside_sync: Option<usize>,
124    /// Clear command type (ED=0, EL=1).
125    pub clear_type: Option<u8>,
126    /// Clear mode (0=to end, 1=to start, 2=all).
127    pub clear_mode: Option<u8>,
128    /// Rows affected by the operation.
129    pub affected_rows: Option<Vec<u16>>,
130    /// Frame statistics (for AnalysisComplete).
131    pub stats: Option<AnalysisStats>,
132}
133
134/// A detected flicker/tear event.
135#[derive(Debug, Clone)]
136pub struct FlickerEvent {
137    /// Unique run identifier.
138    pub run_id: String,
139    /// Nanosecond timestamp.
140    pub timestamp_ns: u64,
141    /// Type of event.
142    pub event_type: EventType,
143    /// Severity level.
144    pub severity: Severity,
145    /// Event context.
146    pub context: EventContext,
147    /// Additional details.
148    pub details: EventDetails,
149}
150
151impl FlickerEvent {
152    /// Convert to JSONL format.
153    pub fn to_jsonl(&self) -> String {
154        let mut json = String::with_capacity(256);
155        json.push('{');
156
157        // Core fields
158        write!(json, "\"run_id\":\"{}\",", self.run_id).unwrap();
159        write!(json, "\"timestamp_ns\":{},", self.timestamp_ns).unwrap();
160        write!(json, "\"event_type\":\"{}\",", self.event_type).unwrap();
161        write!(json, "\"severity\":\"{}\",", self.severity).unwrap();
162
163        // Context
164        json.push_str("\"context\":{");
165        write!(json, "\"frame_id\":{},", self.context.frame_id).unwrap();
166        write!(json, "\"byte_offset\":{},", self.context.byte_offset).unwrap();
167        write!(json, "\"line\":{},", self.context.line).unwrap();
168        write!(json, "\"column\":{}", self.context.column).unwrap();
169        json.push_str("},");
170
171        // Details
172        json.push_str("\"details\":{");
173        write!(
174            json,
175            "\"message\":\"{}\"",
176            escape_json(&self.details.message)
177        )
178        .unwrap();
179
180        if let Some(ref bytes) = self.details.trigger_bytes {
181            write!(
182                json,
183                ",\"trigger_bytes\":[{}]",
184                bytes
185                    .iter()
186                    .map(|b| b.to_string())
187                    .collect::<Vec<_>>()
188                    .join(",")
189            )
190            .unwrap();
191        }
192        if let Some(n) = self.details.bytes_outside_sync {
193            write!(json, ",\"bytes_outside_sync\":{n}").unwrap();
194        }
195        if let Some(ct) = self.details.clear_type {
196            write!(json, ",\"clear_type\":{ct}").unwrap();
197        }
198        if let Some(cm) = self.details.clear_mode {
199            write!(json, ",\"clear_mode\":{cm}").unwrap();
200        }
201        if let Some(ref rows) = self.details.affected_rows {
202            write!(
203                json,
204                ",\"affected_rows\":[{}]",
205                rows.iter()
206                    .map(|r| r.to_string())
207                    .collect::<Vec<_>>()
208                    .join(",")
209            )
210            .unwrap();
211        }
212        if let Some(ref stats) = self.details.stats {
213            write!(json, ",\"stats\":{{").unwrap();
214            write!(json, "\"total_frames\":{},", stats.total_frames).unwrap();
215            write!(json, "\"complete_frames\":{},", stats.complete_frames).unwrap();
216            write!(json, "\"sync_gaps\":{},", stats.sync_gaps).unwrap();
217            write!(json, "\"partial_clears\":{},", stats.partial_clears).unwrap();
218            write!(json, "\"bytes_total\":{},", stats.bytes_total).unwrap();
219            write!(json, "\"bytes_in_sync\":{},", stats.bytes_in_sync).unwrap();
220            write!(json, "\"flicker_free\":{}", stats.is_flicker_free()).unwrap();
221            json.push('}');
222        }
223
224        json.push_str("}}");
225        json
226    }
227}
228
229/// Escape a string for JSON output.
230fn escape_json(s: &str) -> String {
231    let mut out = String::with_capacity(s.len());
232    for c in s.chars() {
233        match c {
234            '"' => out.push_str("\\\""),
235            '\\' => out.push_str("\\\\"),
236            '\n' => out.push_str("\\n"),
237            '\r' => out.push_str("\\r"),
238            '\t' => out.push_str("\\t"),
239            c if c.is_control() => write!(out, "\\u{:04x}", c as u32).unwrap(),
240            c => out.push(c),
241        }
242    }
243    out
244}
245
246/// Statistics from analysis.
247#[derive(Debug, Clone, Default)]
248pub struct AnalysisStats {
249    /// Total frames started.
250    pub total_frames: u64,
251    /// Frames that completed (had matching end).
252    pub complete_frames: u64,
253    /// Number of sync gap events.
254    pub sync_gaps: u64,
255    /// Number of partial clear events.
256    pub partial_clears: u64,
257    /// Total bytes processed.
258    pub bytes_total: usize,
259    /// Bytes within sync brackets.
260    pub bytes_in_sync: usize,
261}
262
263impl AnalysisStats {
264    /// Returns true if no flicker-inducing events were detected.
265    pub fn is_flicker_free(&self) -> bool {
266        self.sync_gaps == 0 && self.partial_clears == 0 && self.total_frames == self.complete_frames
267    }
268
269    /// Percentage of bytes within sync brackets.
270    pub fn sync_coverage(&self) -> f64 {
271        if self.bytes_total == 0 {
272            100.0
273        } else {
274            (self.bytes_in_sync as f64 / self.bytes_total as f64) * 100.0
275        }
276    }
277}
278
279// ============================================================================
280// Parser State
281// ============================================================================
282
283/// Parser state for ANSI sequence detection.
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285enum ParserState {
286    Ground,
287    Escape,
288    Csi,
289    CsiParam,
290    CsiPrivate,
291}
292
293/// Flicker detection analyzer.
294pub struct FlickerDetector {
295    /// Unique run ID for this analysis session.
296    run_id: String,
297    /// Current parser state.
298    state: ParserState,
299    /// CSI parameter accumulator.
300    csi_params: Vec<u16>,
301    /// Current CSI parameter being parsed.
302    csi_current: u16,
303    /// Whether we're in DEC private mode sequence.
304    csi_private: bool,
305    /// Whether synchronized output is active.
306    sync_active: bool,
307    /// Current frame ID.
308    frame_id: u64,
309    /// Byte offset in stream.
310    byte_offset: usize,
311    /// Current line.
312    line: usize,
313    /// Current column.
314    column: usize,
315    /// Bytes in current sync gap.
316    gap_bytes: usize,
317    /// Detected events.
318    events: Vec<FlickerEvent>,
319    /// Statistics.
320    stats: AnalysisStats,
321    /// Timestamp generator (monotonic counter for testing).
322    timestamp_counter: u64,
323}
324
325impl FlickerDetector {
326    /// Create a new detector with the given run ID.
327    pub fn new(run_id: impl Into<String>) -> Self {
328        Self {
329            run_id: run_id.into(),
330            state: ParserState::Ground,
331            csi_params: Vec::with_capacity(16),
332            csi_current: 0,
333            csi_private: false,
334            sync_active: false,
335            frame_id: 0,
336            byte_offset: 0,
337            line: 0,
338            column: 0,
339            gap_bytes: 0,
340            events: Vec::new(),
341            stats: AnalysisStats::default(),
342            timestamp_counter: 0,
343        }
344    }
345
346    /// Create a detector with a random UUID run ID.
347    pub fn with_random_id() -> Self {
348        let id = format!(
349            "{:016x}",
350            std::time::SystemTime::now()
351                .duration_since(std::time::UNIX_EPOCH)
352                .map(|d| d.as_nanos())
353                .unwrap_or(0)
354        );
355        Self::new(id)
356    }
357
358    /// Get the run ID.
359    pub fn run_id(&self) -> &str {
360        &self.run_id
361    }
362
363    /// Get collected events.
364    pub fn events(&self) -> &[FlickerEvent] {
365        &self.events
366    }
367
368    /// Get analysis statistics.
369    pub fn stats(&self) -> &AnalysisStats {
370        &self.stats
371    }
372
373    /// Check if the analyzed stream is flicker-free.
374    pub fn is_flicker_free(&self) -> bool {
375        self.stats.is_flicker_free()
376    }
377
378    /// Feed bytes to the detector.
379    pub fn feed(&mut self, bytes: &[u8]) {
380        for &byte in bytes {
381            self.advance(byte);
382            self.byte_offset += 1;
383            self.stats.bytes_total += 1;
384            if self.sync_active {
385                self.stats.bytes_in_sync += 1;
386            }
387        }
388    }
389
390    /// Feed a string to the detector.
391    pub fn feed_str(&mut self, s: &str) {
392        self.feed(s.as_bytes());
393    }
394
395    /// Finalize analysis and generate summary event.
396    pub fn finalize(&mut self) {
397        // Check for incomplete frame
398        if self.sync_active {
399            self.emit_event(
400                EventType::IncompleteFrame,
401                Severity::Error,
402                EventDetails {
403                    message: format!("Frame {} never completed", self.frame_id),
404                    ..Default::default()
405                },
406            );
407            self.stats.total_frames += 1; // Count incomplete frame
408        }
409
410        // Report any trailing sync gap bytes that were never reported
411        // (happens when stream ends without any sync frames)
412        if self.gap_bytes > 0 {
413            self.emit_event(
414                EventType::SyncGap,
415                Severity::Warning,
416                EventDetails {
417                    message: format!("{} bytes written outside sync mode", self.gap_bytes),
418                    bytes_outside_sync: Some(self.gap_bytes),
419                    ..Default::default()
420                },
421            );
422            self.stats.sync_gaps += 1;
423        }
424
425        // Emit analysis complete event
426        self.emit_event(
427            EventType::AnalysisComplete,
428            if self.stats.is_flicker_free() { Severity::Info } else { Severity::Warning },
429            EventDetails {
430                message: format!(
431                    "Analysis complete: {} frames, {} sync gaps, {} partial clears, {:.1}% sync coverage",
432                    self.stats.total_frames,
433                    self.stats.sync_gaps,
434                    self.stats.partial_clears,
435                    self.stats.sync_coverage()
436                ),
437                stats: Some(self.stats.clone()),
438                ..Default::default()
439            },
440        );
441    }
442
443    /// Write all events to a writer in JSONL format.
444    pub fn write_jsonl<W: Write>(&self, mut writer: W) -> std::io::Result<()> {
445        for event in &self.events {
446            writeln!(writer, "{}", event.to_jsonl())?;
447        }
448        Ok(())
449    }
450
451    /// Get JSONL output as a string.
452    pub fn to_jsonl(&self) -> String {
453        let mut out = String::new();
454        for event in &self.events {
455            out.push_str(&event.to_jsonl());
456            out.push('\n');
457        }
458        out
459    }
460
461    fn next_timestamp(&mut self) -> u64 {
462        self.timestamp_counter += 1;
463        self.timestamp_counter
464    }
465
466    fn emit_event(&mut self, event_type: EventType, severity: Severity, details: EventDetails) {
467        let event = FlickerEvent {
468            run_id: self.run_id.clone(),
469            timestamp_ns: self.next_timestamp(),
470            event_type,
471            severity,
472            context: EventContext {
473                frame_id: self.frame_id,
474                byte_offset: self.byte_offset,
475                line: self.line,
476                column: self.column,
477            },
478            details,
479        };
480        self.events.push(event);
481    }
482
483    fn advance(&mut self, byte: u8) {
484        match self.state {
485            ParserState::Ground => self.ground(byte),
486            ParserState::Escape => self.escape(byte),
487            ParserState::Csi | ParserState::CsiParam | ParserState::CsiPrivate => self.csi(byte),
488        }
489
490        // Track line/column
491        if byte == b'\n' {
492            self.line += 1;
493            self.column = 0;
494        } else if (0x20..0x7f).contains(&byte) {
495            self.column += 1;
496        }
497    }
498
499    fn ground(&mut self, byte: u8) {
500        match byte {
501            0x1b => {
502                self.state = ParserState::Escape;
503            }
504            // Visible character output outside sync mode
505            0x20..=0x7e if !self.sync_active => {
506                self.gap_bytes += 1;
507                // Only emit after accumulating some bytes to reduce noise
508                if self.gap_bytes == 1 {
509                    // First byte of gap - we'll report when sync starts or at finalize
510                }
511            }
512            0x20..=0x7e => {
513                // Normal output in sync mode - good
514            }
515            _ => {}
516        }
517    }
518
519    fn escape(&mut self, byte: u8) {
520        match byte {
521            b'[' => {
522                self.state = ParserState::Csi;
523                self.csi_params.clear();
524                self.csi_current = 0;
525                self.csi_private = false;
526            }
527            _ => {
528                self.state = ParserState::Ground;
529            }
530        }
531    }
532
533    fn csi(&mut self, byte: u8) {
534        match byte {
535            b'?' => {
536                self.csi_private = true;
537                self.state = ParserState::CsiPrivate;
538            }
539            b'0'..=b'9' => {
540                self.csi_current = self.csi_current.saturating_mul(10) + (byte - b'0') as u16;
541                self.state = ParserState::CsiParam;
542            }
543            b';' => {
544                self.csi_params.push(self.csi_current);
545                self.csi_current = 0;
546            }
547            b'h' => {
548                self.csi_params.push(self.csi_current);
549                self.handle_set_mode();
550                self.state = ParserState::Ground;
551            }
552            b'l' => {
553                self.csi_params.push(self.csi_current);
554                self.handle_reset_mode();
555                self.state = ParserState::Ground;
556            }
557            b'J' => {
558                // ED: Erase Display
559                self.csi_params.push(self.csi_current);
560                self.handle_erase_display();
561                self.state = ParserState::Ground;
562            }
563            b'K' => {
564                // EL: Erase Line
565                self.csi_params.push(self.csi_current);
566                self.handle_erase_line();
567                self.state = ParserState::Ground;
568            }
569            b'H' | b'f' => {
570                // CUP: Cursor Position
571                self.csi_params.push(self.csi_current);
572                // Cursor movement during frame is normal, but suspicious outside frame
573                if !self.sync_active && self.gap_bytes > 0 {
574                    // Cursor move with prior gap bytes suggests interleaved writes
575                }
576                self.state = ParserState::Ground;
577            }
578            b'm' | b'A'..=b'G' | b's' | b'u' => {
579                // SGR, cursor movement, save/restore - normal operations
580                self.state = ParserState::Ground;
581            }
582            _ if (0x40..=0x7e).contains(&byte) => {
583                // Unknown CSI final byte
584                self.state = ParserState::Ground;
585            }
586            _ => {
587                // Continue parsing
588            }
589        }
590    }
591
592    fn handle_set_mode(&mut self) {
593        if self.csi_private {
594            // DEC private mode - check for sync-output (2026) first
595            let has_sync = self.csi_params.contains(&2026);
596            if has_sync {
597                // Begin synchronized output
598                self.handle_sync_begin();
599            }
600        }
601    }
602
603    fn handle_reset_mode(&mut self) {
604        if self.csi_private {
605            // Check for sync-output (2026) first
606            let has_sync = self.csi_params.contains(&2026);
607            if has_sync {
608                // End synchronized output
609                self.handle_sync_end();
610            }
611        }
612    }
613
614    fn handle_sync_begin(&mut self) {
615        // Report accumulated gap if any
616        if self.gap_bytes > 0 {
617            self.emit_event(
618                EventType::SyncGap,
619                Severity::Warning,
620                EventDetails {
621                    message: format!("{} bytes written outside sync mode", self.gap_bytes),
622                    bytes_outside_sync: Some(self.gap_bytes),
623                    ..Default::default()
624                },
625            );
626            self.stats.sync_gaps += 1;
627        }
628
629        self.sync_active = true;
630        self.gap_bytes = 0;
631        self.frame_id += 1;
632        self.stats.total_frames += 1;
633
634        self.emit_event(
635            EventType::FrameStart,
636            Severity::Info,
637            EventDetails {
638                message: format!("Frame {} started", self.frame_id),
639                ..Default::default()
640            },
641        );
642    }
643
644    fn handle_sync_end(&mut self) {
645        if !self.sync_active {
646            // End without start - suspicious but not necessarily wrong
647            return;
648        }
649
650        self.emit_event(
651            EventType::FrameEnd,
652            Severity::Info,
653            EventDetails {
654                message: format!("Frame {} completed", self.frame_id),
655                ..Default::default()
656            },
657        );
658
659        self.sync_active = false;
660        self.stats.complete_frames += 1;
661    }
662
663    fn handle_erase_display(&mut self) {
664        let mode = self.csi_params.first().copied().unwrap_or(0);
665
666        // ED inside sync frame is fine; outside frame or partial clear is suspicious
667        if self.sync_active && mode != 2 {
668            // Partial erase during frame
669            self.emit_event(
670                EventType::PartialClear,
671                Severity::Warning,
672                EventDetails {
673                    message: format!("Partial display erase (mode {}) during frame", mode),
674                    clear_type: Some(0), // ED
675                    clear_mode: Some(mode as u8),
676                    ..Default::default()
677                },
678            );
679            self.stats.partial_clears += 1;
680        } else if !self.sync_active && mode == 2 {
681            // Full clear outside sync - might be initialization, less suspicious
682        }
683    }
684
685    fn handle_erase_line(&mut self) {
686        let mode = self.csi_params.first().copied().unwrap_or(0);
687
688        // Partial line erase during frame can cause flicker
689        if self.sync_active && mode != 2 {
690            self.emit_event(
691                EventType::PartialClear,
692                Severity::Warning,
693                EventDetails {
694                    message: format!("Partial line erase (mode {}) during frame", mode),
695                    clear_type: Some(1), // EL
696                    clear_mode: Some(mode as u8),
697                    ..Default::default()
698                },
699            );
700            self.stats.partial_clears += 1;
701        }
702    }
703}
704
705impl Default for FlickerDetector {
706    fn default() -> Self {
707        Self::new("default")
708    }
709}
710
711// ============================================================================
712// Assertion Helpers
713// ============================================================================
714
715/// Result of flicker detection analysis.
716#[derive(Debug)]
717pub struct FlickerAnalysis {
718    /// Whether the stream is flicker-free.
719    pub flicker_free: bool,
720    /// Analysis statistics.
721    pub stats: AnalysisStats,
722    /// Detected events (errors and warnings only).
723    pub issues: Vec<FlickerEvent>,
724    /// Full JSONL log.
725    pub jsonl: String,
726}
727
728impl FlickerAnalysis {
729    /// Assert that the stream is flicker-free, panicking with details if not.
730    pub fn assert_flicker_free(&self) {
731        if !self.flicker_free {
732            let mut msg = String::new();
733            msg.push_str("\n=== Flicker Detection Failed ===\n\n");
734            writeln!(msg, "Sync gaps: {}", self.stats.sync_gaps).unwrap();
735            writeln!(msg, "Partial clears: {}", self.stats.partial_clears).unwrap();
736            writeln!(
737                msg,
738                "Incomplete frames: {}",
739                self.stats.total_frames - self.stats.complete_frames
740            )
741            .unwrap();
742            writeln!(msg, "Sync coverage: {:.1}%", self.stats.sync_coverage()).unwrap();
743            msg.push('\n');
744
745            msg.push_str("Issues:\n");
746            for issue in &self.issues {
747                writeln!(
748                    msg,
749                    "  - [{}] {} at byte {}: {}",
750                    issue.severity,
751                    issue.event_type,
752                    issue.context.byte_offset,
753                    issue.details.message
754                )
755                .unwrap();
756            }
757
758            msg.push_str("\nFull JSONL log:\n");
759            msg.push_str(&self.jsonl);
760
761            assert!(self.flicker_free, "{msg}");
762        }
763    }
764}
765
766/// Analyze an ANSI byte stream for flicker/tearing.
767pub fn analyze_stream(bytes: &[u8]) -> FlickerAnalysis {
768    analyze_stream_with_id("analysis", bytes)
769}
770
771/// Analyze with a specific run ID.
772pub fn analyze_stream_with_id(run_id: &str, bytes: &[u8]) -> FlickerAnalysis {
773    let mut detector = FlickerDetector::new(run_id);
774    detector.feed(bytes);
775    detector.finalize();
776
777    let issues: Vec<_> = detector
778        .events()
779        .iter()
780        .filter(|e| matches!(e.severity, Severity::Warning | Severity::Error))
781        .cloned()
782        .collect();
783
784    FlickerAnalysis {
785        flicker_free: detector.is_flicker_free(),
786        stats: detector.stats().clone(),
787        issues,
788        jsonl: detector.to_jsonl(),
789    }
790}
791
792/// Analyze a string for flicker/tearing.
793pub fn analyze_str(s: &str) -> FlickerAnalysis {
794    analyze_stream(s.as_bytes())
795}
796
797/// Assert that an ANSI stream is flicker-free.
798pub fn assert_flicker_free(bytes: &[u8]) {
799    analyze_stream(bytes).assert_flicker_free();
800}
801
802/// Assert that an ANSI string is flicker-free.
803pub fn assert_flicker_free_str(s: &str) {
804    assert_flicker_free(s.as_bytes());
805}
806
807// ============================================================================
808// Tests
809// ============================================================================
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814    use crate::golden::compute_text_checksum;
815
816    // DEC private mode sequences
817    const SYNC_BEGIN: &[u8] = b"\x1b[?2026h";
818    const SYNC_END: &[u8] = b"\x1b[?2026l";
819
820    struct Lcg(u64);
821
822    impl Lcg {
823        fn new(seed: u64) -> Self {
824            Self(seed)
825        }
826
827        fn next_u32(&mut self) -> u32 {
828            // Deterministic LCG (Numerical Recipes)
829            self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1);
830            (self.0 >> 32) as u32
831        }
832
833        fn next_range(&mut self, max: usize) -> usize {
834            if max == 0 {
835                return 0;
836            }
837            (self.next_u32() as usize) % max
838        }
839    }
840
841    fn make_synced_frame(content: &[u8]) -> Vec<u8> {
842        let mut out = Vec::new();
843        out.extend_from_slice(SYNC_BEGIN);
844        out.extend_from_slice(content);
845        out.extend_from_slice(SYNC_END);
846        out
847    }
848
849    #[test]
850    fn empty_stream_is_flicker_free() {
851        let analysis = analyze_stream(b"");
852        assert!(analysis.flicker_free);
853        assert_eq!(analysis.stats.total_frames, 0);
854        assert_eq!(analysis.stats.sync_gaps, 0);
855    }
856
857    #[test]
858    fn properly_synced_frame_is_flicker_free() {
859        let frame = make_synced_frame(b"Hello, World!");
860        let analysis = analyze_stream(&frame);
861        assert!(analysis.flicker_free);
862        assert_eq!(analysis.stats.total_frames, 1);
863        assert_eq!(analysis.stats.complete_frames, 1);
864        assert_eq!(analysis.stats.sync_gaps, 0);
865    }
866
867    #[test]
868    fn multiple_synced_frames_are_flicker_free() {
869        let mut stream = Vec::new();
870        stream.extend(make_synced_frame(b"Frame 1"));
871        stream.extend(make_synced_frame(b"Frame 2"));
872        stream.extend(make_synced_frame(b"Frame 3"));
873
874        let analysis = analyze_stream(&stream);
875        assert!(analysis.flicker_free);
876        assert_eq!(analysis.stats.total_frames, 3);
877        assert_eq!(analysis.stats.complete_frames, 3);
878    }
879
880    #[test]
881    fn output_without_sync_causes_gap() {
882        let analysis = analyze_str("Hello without sync");
883        // Text outside sync mode is a gap
884        assert!(!analysis.flicker_free);
885        assert!(analysis.stats.sync_gaps > 0);
886    }
887
888    #[test]
889    fn sync_end_without_begin_is_ignored() {
890        let analysis = analyze_stream(SYNC_END);
891        assert!(analysis.flicker_free);
892        assert_eq!(analysis.stats.total_frames, 0);
893        assert_eq!(analysis.stats.complete_frames, 0);
894        assert_eq!(analysis.stats.sync_gaps, 0);
895    }
896
897    #[test]
898    fn output_before_sync_causes_gap() {
899        let mut stream = b"Pre-sync content".to_vec();
900        stream.extend(make_synced_frame(b"Synced content"));
901
902        let analysis = analyze_stream(&stream);
903        assert!(!analysis.flicker_free);
904        assert_eq!(analysis.stats.sync_gaps, 1);
905    }
906
907    #[test]
908    fn output_between_frames_causes_gap() {
909        let mut stream = Vec::new();
910        stream.extend(make_synced_frame(b"Frame 1"));
911        stream.extend_from_slice(b"Gap content");
912        stream.extend(make_synced_frame(b"Frame 2"));
913
914        let analysis = analyze_stream(&stream);
915        assert!(!analysis.flicker_free);
916        assert_eq!(analysis.stats.sync_gaps, 1);
917    }
918
919    #[test]
920    fn incomplete_frame_detected() {
921        // Start sync but never end it
922        let mut stream = Vec::new();
923        stream.extend_from_slice(SYNC_BEGIN);
924        stream.extend_from_slice(b"Content without end");
925
926        let analysis = analyze_stream(&stream);
927        assert!(!analysis.flicker_free);
928        assert!(
929            analysis
930                .issues
931                .iter()
932                .any(|e| matches!(e.event_type, EventType::IncompleteFrame))
933        );
934    }
935
936    #[test]
937    fn partial_display_erase_detected() {
938        // ED with mode 0 (erase to end) during frame
939        let mut frame = Vec::new();
940        frame.extend_from_slice(SYNC_BEGIN);
941        frame.extend_from_slice(b"\x1b[0J"); // Erase to end
942        frame.extend_from_slice(b"Content");
943        frame.extend_from_slice(SYNC_END);
944
945        let analysis = analyze_stream(&frame);
946        assert!(!analysis.flicker_free);
947        assert_eq!(analysis.stats.partial_clears, 1);
948    }
949
950    #[test]
951    fn partial_line_erase_detected() {
952        // EL with mode 0 (erase to end of line) during frame
953        let mut frame = Vec::new();
954        frame.extend_from_slice(SYNC_BEGIN);
955        frame.extend_from_slice(b"\x1b[0K"); // Erase to end of line
956        frame.extend_from_slice(b"Content");
957        frame.extend_from_slice(SYNC_END);
958
959        let analysis = analyze_stream(&frame);
960        assert!(!analysis.flicker_free);
961        assert_eq!(analysis.stats.partial_clears, 1);
962    }
963
964    #[test]
965    fn full_display_clear_outside_sync_is_ok() {
966        // ED 2 (clear all) outside sync is typical for initialization
967        let mut stream = Vec::new();
968        stream.extend_from_slice(b"\x1b[2J"); // Clear screen
969        stream.extend(make_synced_frame(b"First frame"));
970
971        let analysis = analyze_stream(&stream);
972        // Full clear before first frame is fine
973        assert_eq!(analysis.stats.partial_clears, 0);
974    }
975
976    #[test]
977    fn full_line_clear_in_frame_is_ok() {
978        // EL 2 (clear entire line) is okay - it's a complete operation
979        let mut frame = Vec::new();
980        frame.extend_from_slice(SYNC_BEGIN);
981        frame.extend_from_slice(b"\x1b[2K"); // Clear entire line
982        frame.extend_from_slice(b"Content");
983        frame.extend_from_slice(SYNC_END);
984
985        let analysis = analyze_stream(&frame);
986        assert_eq!(analysis.stats.partial_clears, 0);
987    }
988
989    #[test]
990    fn partial_erase_mode_one_detected_for_ed_and_el() {
991        let mut frame = Vec::new();
992        frame.extend_from_slice(SYNC_BEGIN);
993        frame.extend_from_slice(b"\x1b[1J"); // ED mode 1
994        frame.extend_from_slice(b"\x1b[1K"); // EL mode 1
995        frame.extend_from_slice(SYNC_END);
996
997        let analysis = analyze_stream(&frame);
998        assert_eq!(analysis.stats.partial_clears, 2);
999        assert!(
1000            analysis
1001                .issues
1002                .iter()
1003                .any(|e| e.details.clear_mode == Some(1))
1004        );
1005    }
1006
1007    #[test]
1008    fn jsonl_format_valid() {
1009        let frame = make_synced_frame(b"Test content");
1010        let mut detector = FlickerDetector::new("test-run");
1011        detector.feed(&frame);
1012        detector.finalize();
1013
1014        let jsonl = detector.to_jsonl();
1015        assert!(!jsonl.is_empty());
1016
1017        // Each line should be valid JSON (basic check)
1018        for line in jsonl.lines() {
1019            assert!(line.starts_with('{'));
1020            assert!(line.ends_with('}'));
1021            assert!(line.contains("\"run_id\":\"test-run\""));
1022            assert!(line.contains("\"event_type\":"));
1023            assert!(line.contains("\"severity\":"));
1024        }
1025    }
1026
1027    #[test]
1028    fn jsonl_escapes_special_chars() {
1029        let event = FlickerEvent {
1030            run_id: "test".into(),
1031            timestamp_ns: 1,
1032            event_type: EventType::SyncGap,
1033            severity: Severity::Warning,
1034            context: EventContext::default(),
1035            details: EventDetails {
1036                message: "Contains \"quotes\" and \n newline".into(),
1037                ..Default::default()
1038            },
1039        };
1040
1041        let json = event.to_jsonl();
1042        assert!(json.contains(r#"\\\"quotes\\\""#) || json.contains(r#"\"quotes\""#));
1043        assert!(json.contains("\\n"));
1044    }
1045
1046    #[test]
1047    fn stats_sync_coverage_calculation() {
1048        let mut stats = AnalysisStats {
1049            bytes_total: 100,
1050            bytes_in_sync: 75,
1051            ..Default::default()
1052        };
1053        assert!((stats.sync_coverage() - 75.0).abs() < 0.01);
1054
1055        stats.bytes_total = 0;
1056        assert!((stats.sync_coverage() - 100.0).abs() < 0.01);
1057    }
1058
1059    #[test]
1060    fn detector_tracks_frame_ids() {
1061        let mut stream = Vec::new();
1062        stream.extend(make_synced_frame(b"1"));
1063        stream.extend(make_synced_frame(b"2"));
1064        stream.extend(make_synced_frame(b"3"));
1065
1066        let mut detector = FlickerDetector::new("test");
1067        detector.feed(&stream);
1068        detector.finalize();
1069
1070        let frame_starts: Vec<_> = detector
1071            .events()
1072            .iter()
1073            .filter(|e| matches!(e.event_type, EventType::FrameStart))
1074            .map(|e| e.context.frame_id)
1075            .collect();
1076
1077        assert_eq!(frame_starts, vec![1, 2, 3]);
1078    }
1079
1080    #[test]
1081    fn detector_tracks_byte_offsets() {
1082        let stream = make_synced_frame(b"Hello");
1083        let mut detector = FlickerDetector::new("test");
1084        detector.feed(&stream);
1085        detector.finalize();
1086
1087        let last_event = detector.events().last().unwrap();
1088        assert_eq!(last_event.context.byte_offset, stream.len());
1089    }
1090
1091    #[test]
1092    fn assert_flicker_free_passes_for_good_stream() {
1093        let frame = make_synced_frame(b"Good content");
1094        assert_flicker_free(&frame);
1095    }
1096
1097    #[test]
1098    #[should_panic(expected = "Flicker Detection Failed")]
1099    fn assert_flicker_free_panics_for_bad_stream() {
1100        assert_flicker_free_str("Unsynced content");
1101    }
1102
1103    #[test]
1104    fn complex_frame_with_styling() {
1105        // Realistic frame with cursor positioning and styling
1106        let mut frame = Vec::new();
1107        frame.extend_from_slice(SYNC_BEGIN);
1108        frame.extend_from_slice(b"\x1b[H"); // Home
1109        frame.extend_from_slice(b"\x1b[2J"); // Clear (full, OK in sync)
1110        frame.extend_from_slice(b"\x1b[1;1H"); // Position
1111        frame.extend_from_slice(b"\x1b[1;31mRed\x1b[0m"); // Styled text
1112        frame.extend_from_slice(b"\x1b[2;1HLine 2");
1113        frame.extend_from_slice(SYNC_END);
1114
1115        let analysis = analyze_stream(&frame);
1116        // Full clear inside sync is actually fine
1117        assert!(
1118            analysis.flicker_free,
1119            "Frame should be flicker-free: {:?}",
1120            analysis.issues
1121        );
1122    }
1123
1124    #[test]
1125    fn realistic_render_loop_scenario() {
1126        let mut stream = Vec::new();
1127
1128        // Simulate 10 frames of a render loop
1129        for i in 0..10 {
1130            stream.extend_from_slice(SYNC_BEGIN);
1131            stream.extend_from_slice(format!("\x1b[HFrame {i}").as_bytes());
1132            stream.extend_from_slice(b"\x1b[2;1HStatus: OK");
1133            stream.extend_from_slice(SYNC_END);
1134        }
1135
1136        let analysis = analyze_stream(&stream);
1137        assert!(analysis.flicker_free);
1138        assert_eq!(analysis.stats.total_frames, 10);
1139        assert_eq!(analysis.stats.complete_frames, 10);
1140        // Coverage is ~80% because sync control sequences themselves aren't counted:
1141        // - SYNC_BEGIN: only the final 'h' byte is counted as in-sync (1/8)
1142        // - SYNC_END: all but the final 'l' byte (7/8) are in-sync
1143        assert!(
1144            analysis.stats.sync_coverage() > 75.0,
1145            "Expected >75% sync coverage, got {:.1}%",
1146            analysis.stats.sync_coverage()
1147        );
1148    }
1149
1150    #[test]
1151    fn private_mode_with_extra_params_still_toggles_sync() {
1152        let mut stream = Vec::new();
1153        stream.extend_from_slice(b"\x1b[?1;2026h");
1154        stream.extend_from_slice(b"payload");
1155        stream.extend_from_slice(b"\x1b[?1;2026l");
1156
1157        let analysis = analyze_stream(&stream);
1158        assert!(analysis.flicker_free);
1159        assert_eq!(analysis.stats.total_frames, 1);
1160        assert_eq!(analysis.stats.complete_frames, 1);
1161    }
1162
1163    #[test]
1164    fn write_jsonl_to_file() {
1165        let frame = make_synced_frame(b"Test");
1166        let mut detector = FlickerDetector::new("file-test");
1167        detector.feed(&frame);
1168        detector.finalize();
1169
1170        let mut output = Vec::new();
1171        detector.write_jsonl(&mut output).unwrap();
1172
1173        let jsonl = String::from_utf8(output).unwrap();
1174        assert!(jsonl.lines().count() > 0);
1175    }
1176
1177    #[test]
1178    fn with_random_id_creates_unique_ids() {
1179        let d1 = FlickerDetector::with_random_id();
1180        let d2 = FlickerDetector::with_random_id();
1181        // Very unlikely to be equal given nanosecond precision
1182        assert_ne!(d1.run_id(), d2.run_id());
1183    }
1184
1185    #[test]
1186    fn analysis_complete_severity_tracks_health() {
1187        let clean = analyze_stream(&make_synced_frame(b"ok"));
1188        let clean_last = clean
1189            .jsonl
1190            .lines()
1191            .last()
1192            .expect("analysis should emit at least one event");
1193        assert!(clean_last.contains("\"event_type\":\"analysis_complete\""));
1194        assert!(clean_last.contains("\"severity\":\"info\""));
1195
1196        let noisy = analyze_stream(b"gap");
1197        let noisy_last = noisy
1198            .jsonl
1199            .lines()
1200            .last()
1201            .expect("analysis should emit at least one event");
1202        assert!(noisy_last.contains("\"event_type\":\"analysis_complete\""));
1203        assert!(noisy_last.contains("\"severity\":\"warning\""));
1204    }
1205
1206    #[test]
1207    fn edge_case_empty_frame() {
1208        let frame = make_synced_frame(b"");
1209        let analysis = analyze_stream(&frame);
1210        assert!(analysis.flicker_free);
1211        assert_eq!(analysis.stats.total_frames, 1);
1212    }
1213
1214    #[test]
1215    fn edge_case_nested_escapes() {
1216        // Malformed but shouldn't crash
1217        let mut stream = Vec::new();
1218        stream.extend_from_slice(SYNC_BEGIN);
1219        stream.extend_from_slice(b"\x1b\x1b\x1b[m"); // Weird escapes
1220        stream.extend_from_slice(SYNC_END);
1221
1222        let analysis = analyze_stream(&stream);
1223        // Should complete without panic
1224        assert!(analysis.stats.total_frames >= 1);
1225    }
1226
1227    #[test]
1228    fn property_synced_frames_are_flicker_free() {
1229        for seed in 0..8u64 {
1230            let mut rng = Lcg::new(seed);
1231            let mut stream = Vec::new();
1232            let frames = 5 + rng.next_range(8);
1233            for _ in 0..frames {
1234                let len = 8 + rng.next_range(32);
1235                let mut content = Vec::with_capacity(len);
1236                for _ in 0..len {
1237                    let byte = b'A' + (rng.next_range(26) as u8);
1238                    content.push(byte);
1239                }
1240                stream.extend(make_synced_frame(&content));
1241            }
1242            let analysis = analyze_stream(&stream);
1243            assert!(analysis.flicker_free, "seed {seed} should be flicker-free");
1244            assert_eq!(
1245                analysis.stats.total_frames, frames as u64,
1246                "seed {seed} should count all frames"
1247            );
1248        }
1249    }
1250
1251    #[test]
1252    fn property_gap_detected_when_unsynced_bytes_present() {
1253        for seed in 0..8u64 {
1254            let mut rng = Lcg::new(seed ^ 0x5a5a5a5a);
1255            let mut stream = Vec::new();
1256            stream.extend(make_synced_frame(b"Frame 1"));
1257            let gap_len = 3 + rng.next_range(10);
1258            stream.extend(std::iter::repeat_n(b'Z', gap_len));
1259            stream.extend(make_synced_frame(b"Frame 2"));
1260            let analysis = analyze_stream(&stream);
1261            assert!(
1262                analysis.stats.sync_gaps > 0,
1263                "seed {seed} should detect sync gap"
1264            );
1265            assert!(
1266                !analysis.flicker_free,
1267                "seed {seed} should not be flicker-free"
1268            );
1269        }
1270    }
1271
1272    #[test]
1273    fn golden_jsonl_checksum_fixture() {
1274        let stream = make_synced_frame(b"Flicker");
1275        let analysis = analyze_stream_with_id("golden", &stream);
1276        let checksum = compute_text_checksum(&analysis.jsonl);
1277        const EXPECTED: &str =
1278            "blake3:46aacd72daa5f665507a49c73ee81ca7842b64f109f9161b2e8d1a4f87b6535d";
1279        assert_eq!(checksum, EXPECTED, "golden JSONL checksum drifted");
1280    }
1281
1282    #[test]
1283    fn feed_str_matches_feed_bytes() {
1284        let stream = "\x1b[?2026hHello\x1b[?2026l";
1285
1286        let mut from_str = FlickerDetector::new("from-str");
1287        from_str.feed_str(stream);
1288        from_str.finalize();
1289
1290        let mut from_bytes = FlickerDetector::new("from-bytes");
1291        from_bytes.feed(stream.as_bytes());
1292        from_bytes.finalize();
1293
1294        assert_eq!(
1295            from_str.stats().total_frames,
1296            from_bytes.stats().total_frames
1297        );
1298        assert_eq!(
1299            from_str.stats().complete_frames,
1300            from_bytes.stats().complete_frames
1301        );
1302        assert_eq!(from_str.stats().sync_gaps, from_bytes.stats().sync_gaps);
1303        assert_eq!(
1304            from_str.stats().partial_clears,
1305            from_bytes.stats().partial_clears
1306        );
1307    }
1308
1309    // ================================================================
1310    // Edge-case tests (bd-1nz1c)
1311    // ================================================================
1312
1313    // --- Severity ---
1314
1315    #[test]
1316    fn severity_display_all_variants() {
1317        assert_eq!(Severity::Info.to_string(), "info");
1318        assert_eq!(Severity::Warning.to_string(), "warning");
1319        assert_eq!(Severity::Error.to_string(), "error");
1320    }
1321
1322    #[test]
1323    fn severity_clone_copy_eq_hash() {
1324        let s = Severity::Warning;
1325        let s2 = s; // Copy
1326        assert_eq!(s, s2);
1327        let s3 = s;
1328        assert_eq!(s, s3);
1329        // Hash consistency
1330        use std::collections::HashSet;
1331        let mut set = HashSet::new();
1332        set.insert(Severity::Info);
1333        set.insert(Severity::Warning);
1334        set.insert(Severity::Error);
1335        assert_eq!(set.len(), 3);
1336        set.insert(Severity::Info); // duplicate
1337        assert_eq!(set.len(), 3);
1338    }
1339
1340    #[test]
1341    fn severity_debug() {
1342        let dbg = format!("{:?}", Severity::Error);
1343        assert!(dbg.contains("Error"));
1344    }
1345
1346    // --- EventType ---
1347
1348    #[test]
1349    fn event_type_display_all_variants() {
1350        assert_eq!(EventType::FrameStart.to_string(), "frame_start");
1351        assert_eq!(EventType::FrameEnd.to_string(), "frame_end");
1352        assert_eq!(EventType::SyncGap.to_string(), "sync_gap");
1353        assert_eq!(EventType::PartialClear.to_string(), "partial_clear");
1354        assert_eq!(EventType::IncompleteFrame.to_string(), "incomplete_frame");
1355        assert_eq!(
1356            EventType::InterleavedWrites.to_string(),
1357            "interleaved_writes"
1358        );
1359        assert_eq!(
1360            EventType::SuspiciousCursorMove.to_string(),
1361            "suspicious_cursor_move"
1362        );
1363        assert_eq!(EventType::AnalysisComplete.to_string(), "analysis_complete");
1364    }
1365
1366    #[test]
1367    fn event_type_clone_eq_hash() {
1368        use std::collections::HashSet;
1369        let mut set = HashSet::new();
1370        set.insert(EventType::FrameStart.clone());
1371        set.insert(EventType::FrameEnd.clone());
1372        set.insert(EventType::SyncGap.clone());
1373        set.insert(EventType::PartialClear.clone());
1374        set.insert(EventType::IncompleteFrame.clone());
1375        set.insert(EventType::InterleavedWrites.clone());
1376        set.insert(EventType::SuspiciousCursorMove.clone());
1377        set.insert(EventType::AnalysisComplete.clone());
1378        assert_eq!(set.len(), 8);
1379    }
1380
1381    #[test]
1382    fn event_type_debug() {
1383        let dbg = format!("{:?}", EventType::SyncGap);
1384        assert!(dbg.contains("SyncGap"));
1385    }
1386
1387    // --- EventContext ---
1388
1389    #[test]
1390    fn event_context_default_fields() {
1391        let ctx = EventContext::default();
1392        assert_eq!(ctx.frame_id, 0);
1393        assert_eq!(ctx.byte_offset, 0);
1394        assert_eq!(ctx.line, 0);
1395        assert_eq!(ctx.column, 0);
1396    }
1397
1398    #[test]
1399    fn event_context_clone_debug() {
1400        let ctx = EventContext {
1401            frame_id: 42,
1402            byte_offset: 100,
1403            line: 5,
1404            column: 10,
1405        };
1406        let ctx2 = ctx.clone();
1407        assert_eq!(ctx2.frame_id, 42);
1408        assert_eq!(ctx2.byte_offset, 100);
1409        let dbg = format!("{:?}", ctx);
1410        assert!(dbg.contains("42"));
1411    }
1412
1413    // --- EventDetails ---
1414
1415    #[test]
1416    fn event_details_default_fields() {
1417        let d = EventDetails::default();
1418        assert!(d.message.is_empty());
1419        assert!(d.trigger_bytes.is_none());
1420        assert!(d.bytes_outside_sync.is_none());
1421        assert!(d.clear_type.is_none());
1422        assert!(d.clear_mode.is_none());
1423        assert!(d.affected_rows.is_none());
1424        assert!(d.stats.is_none());
1425    }
1426
1427    #[test]
1428    fn event_details_clone_debug() {
1429        let d = EventDetails {
1430            message: "test".into(),
1431            trigger_bytes: Some(vec![0x1b, 0x5b]),
1432            bytes_outside_sync: Some(10),
1433            clear_type: Some(0),
1434            clear_mode: Some(2),
1435            affected_rows: Some(vec![1, 2, 3]),
1436            stats: Some(AnalysisStats {
1437                total_frames: 5,
1438                complete_frames: 5,
1439                ..Default::default()
1440            }),
1441        };
1442        let d2 = d.clone();
1443        assert_eq!(d2.message, "test");
1444        assert_eq!(d2.trigger_bytes.as_ref().unwrap().len(), 2);
1445        assert_eq!(d2.affected_rows.as_ref().unwrap().len(), 3);
1446        let dbg = format!("{:?}", d);
1447        assert!(dbg.contains("test"));
1448    }
1449
1450    // --- AnalysisStats ---
1451
1452    #[test]
1453    fn analysis_stats_default() {
1454        let s = AnalysisStats::default();
1455        assert_eq!(s.total_frames, 0);
1456        assert_eq!(s.complete_frames, 0);
1457        assert_eq!(s.sync_gaps, 0);
1458        assert_eq!(s.partial_clears, 0);
1459        assert_eq!(s.bytes_total, 0);
1460        assert_eq!(s.bytes_in_sync, 0);
1461    }
1462
1463    #[test]
1464    fn analysis_stats_is_flicker_free_combinations() {
1465        // All zeros → flicker-free
1466        assert!(AnalysisStats::default().is_flicker_free());
1467
1468        // sync_gaps > 0 → not flicker-free
1469        assert!(
1470            !AnalysisStats {
1471                sync_gaps: 1,
1472                ..Default::default()
1473            }
1474            .is_flicker_free()
1475        );
1476
1477        // partial_clears > 0 → not flicker-free
1478        assert!(
1479            !AnalysisStats {
1480                partial_clears: 1,
1481                ..Default::default()
1482            }
1483            .is_flicker_free()
1484        );
1485
1486        // Incomplete frames → not flicker-free
1487        assert!(
1488            !AnalysisStats {
1489                total_frames: 3,
1490                complete_frames: 2,
1491                ..Default::default()
1492            }
1493            .is_flicker_free()
1494        );
1495
1496        // All frames complete, no gaps or clears → flicker-free
1497        assert!(
1498            AnalysisStats {
1499                total_frames: 10,
1500                complete_frames: 10,
1501                bytes_total: 500,
1502                bytes_in_sync: 400,
1503                ..Default::default()
1504            }
1505            .is_flicker_free()
1506        );
1507    }
1508
1509    #[test]
1510    fn analysis_stats_sync_coverage_partial() {
1511        let s = AnalysisStats {
1512            bytes_total: 200,
1513            bytes_in_sync: 50,
1514            ..Default::default()
1515        };
1516        assert!((s.sync_coverage() - 25.0).abs() < 0.01);
1517    }
1518
1519    #[test]
1520    fn analysis_stats_sync_coverage_full() {
1521        let s = AnalysisStats {
1522            bytes_total: 100,
1523            bytes_in_sync: 100,
1524            ..Default::default()
1525        };
1526        assert!((s.sync_coverage() - 100.0).abs() < 0.01);
1527    }
1528
1529    #[test]
1530    fn analysis_stats_clone_debug() {
1531        let s = AnalysisStats {
1532            total_frames: 7,
1533            complete_frames: 5,
1534            sync_gaps: 2,
1535            partial_clears: 1,
1536            bytes_total: 1000,
1537            bytes_in_sync: 800,
1538        };
1539        let s2 = s.clone();
1540        assert_eq!(s2.total_frames, 7);
1541        let dbg = format!("{:?}", s);
1542        assert!(dbg.contains("1000"));
1543    }
1544
1545    // --- escape_json ---
1546
1547    #[test]
1548    fn escape_json_empty() {
1549        assert_eq!(escape_json(""), "");
1550    }
1551
1552    #[test]
1553    fn escape_json_no_special_chars() {
1554        assert_eq!(escape_json("hello world 123"), "hello world 123");
1555    }
1556
1557    #[test]
1558    fn escape_json_quotes_and_backslash() {
1559        assert_eq!(escape_json(r#"say "hi""#), r#"say \"hi\""#);
1560        assert_eq!(escape_json(r"back\slash"), r"back\\slash");
1561    }
1562
1563    #[test]
1564    fn escape_json_newline_cr_tab() {
1565        assert_eq!(escape_json("a\nb"), "a\\nb");
1566        assert_eq!(escape_json("a\rb"), "a\\rb");
1567        assert_eq!(escape_json("a\tb"), "a\\tb");
1568    }
1569
1570    #[test]
1571    fn escape_json_control_chars() {
1572        // NUL, BEL, BS
1573        let s = "\x00\x07\x08";
1574        let escaped = escape_json(s);
1575        assert!(escaped.contains("\\u0000"));
1576        assert!(escaped.contains("\\u0007"));
1577        assert!(escaped.contains("\\u0008"));
1578    }
1579
1580    #[test]
1581    fn escape_json_unicode_passthrough() {
1582        assert_eq!(escape_json("日本語"), "日本語");
1583        assert_eq!(escape_json("emoji 🎉"), "emoji 🎉");
1584    }
1585
1586    // --- FlickerEvent to_jsonl ---
1587
1588    #[test]
1589    fn flicker_event_to_jsonl_all_optional_fields() {
1590        let event = FlickerEvent {
1591            run_id: "full".into(),
1592            timestamp_ns: 999,
1593            event_type: EventType::PartialClear,
1594            severity: Severity::Warning,
1595            context: EventContext {
1596                frame_id: 3,
1597                byte_offset: 42,
1598                line: 2,
1599                column: 5,
1600            },
1601            details: EventDetails {
1602                message: "test partial".into(),
1603                trigger_bytes: Some(vec![0x1b, 0x5b, 0x4a]),
1604                bytes_outside_sync: Some(17),
1605                clear_type: Some(0),
1606                clear_mode: Some(1),
1607                affected_rows: Some(vec![0, 1]),
1608                stats: Some(AnalysisStats {
1609                    total_frames: 10,
1610                    complete_frames: 9,
1611                    sync_gaps: 1,
1612                    partial_clears: 2,
1613                    bytes_total: 500,
1614                    bytes_in_sync: 450,
1615                }),
1616            },
1617        };
1618
1619        let json = event.to_jsonl();
1620        assert!(json.starts_with('{'));
1621        assert!(json.ends_with('}'));
1622        assert!(json.contains("\"run_id\":\"full\""));
1623        assert!(json.contains("\"timestamp_ns\":999"));
1624        assert!(json.contains("\"event_type\":\"partial_clear\""));
1625        assert!(json.contains("\"severity\":\"warning\""));
1626        assert!(json.contains("\"frame_id\":3"));
1627        assert!(json.contains("\"byte_offset\":42"));
1628        assert!(json.contains("\"line\":2"));
1629        assert!(json.contains("\"column\":5"));
1630        assert!(json.contains("\"trigger_bytes\":[27,91,74]"));
1631        assert!(json.contains("\"bytes_outside_sync\":17"));
1632        assert!(json.contains("\"clear_type\":0"));
1633        assert!(json.contains("\"clear_mode\":1"));
1634        assert!(json.contains("\"affected_rows\":[0,1]"));
1635        assert!(json.contains("\"total_frames\":10"));
1636        assert!(json.contains("\"complete_frames\":9"));
1637        assert!(json.contains("\"flicker_free\":false"));
1638    }
1639
1640    #[test]
1641    fn flicker_event_to_jsonl_minimal() {
1642        let event = FlickerEvent {
1643            run_id: "min".into(),
1644            timestamp_ns: 0,
1645            event_type: EventType::FrameStart,
1646            severity: Severity::Info,
1647            context: EventContext::default(),
1648            details: EventDetails::default(),
1649        };
1650
1651        let json = event.to_jsonl();
1652        assert!(json.contains("\"run_id\":\"min\""));
1653        assert!(json.contains("\"message\":\"\""));
1654        // No optional fields
1655        assert!(!json.contains("trigger_bytes"));
1656        assert!(!json.contains("bytes_outside_sync"));
1657        assert!(!json.contains("clear_type"));
1658        assert!(!json.contains("affected_rows"));
1659        assert!(!json.contains("stats"));
1660    }
1661
1662    #[test]
1663    fn flicker_event_clone_debug() {
1664        let event = FlickerEvent {
1665            run_id: "clone-test".into(),
1666            timestamp_ns: 42,
1667            event_type: EventType::SyncGap,
1668            severity: Severity::Warning,
1669            context: EventContext::default(),
1670            details: EventDetails::default(),
1671        };
1672        let e2 = event.clone();
1673        assert_eq!(e2.run_id, "clone-test");
1674        assert_eq!(e2.timestamp_ns, 42);
1675        let dbg = format!("{:?}", event);
1676        assert!(dbg.contains("clone-test"));
1677    }
1678
1679    // --- FlickerDetector ---
1680
1681    #[test]
1682    fn detector_default_impl() {
1683        let d = FlickerDetector::default();
1684        assert_eq!(d.run_id(), "default");
1685        assert!(d.events().is_empty());
1686        assert!(d.is_flicker_free());
1687    }
1688
1689    #[test]
1690    fn detector_run_id_accessor() {
1691        let d = FlickerDetector::new("my-run-123");
1692        assert_eq!(d.run_id(), "my-run-123");
1693    }
1694
1695    #[test]
1696    fn detector_stats_accessor() {
1697        let d = FlickerDetector::new("test");
1698        let stats = d.stats();
1699        assert_eq!(stats.total_frames, 0);
1700        assert_eq!(stats.bytes_total, 0);
1701    }
1702
1703    #[test]
1704    fn detector_feed_str_independently() {
1705        let sync_begin = "\x1b[?2026h";
1706        let sync_end = "\x1b[?2026l";
1707        let mut d = FlickerDetector::new("str-test");
1708        d.feed_str(sync_begin);
1709        d.feed_str("Content");
1710        d.feed_str(sync_end);
1711        d.finalize();
1712        assert!(d.is_flicker_free());
1713        assert_eq!(d.stats().total_frames, 1);
1714        assert_eq!(d.stats().complete_frames, 1);
1715    }
1716
1717    #[test]
1718    fn detector_incremental_feed() {
1719        // Feed frame one byte at a time
1720        let frame = make_synced_frame(b"Hello");
1721        let mut d = FlickerDetector::new("incr");
1722        for &byte in &frame {
1723            d.feed(&[byte]);
1724        }
1725        d.finalize();
1726        assert!(d.is_flicker_free());
1727        assert_eq!(d.stats().total_frames, 1);
1728    }
1729
1730    #[test]
1731    fn detector_bytes_tracking() {
1732        let frame = make_synced_frame(b"AB");
1733        let mut d = FlickerDetector::new("bytes");
1734        d.feed(&frame);
1735        d.finalize();
1736        // SYNC_BEGIN=8 + "AB"=2 + SYNC_END=8 = 18 bytes total
1737        assert_eq!(d.stats().bytes_total, 18);
1738        // bytes_in_sync: counted while sync_active=true
1739        assert!(d.stats().bytes_in_sync > 0);
1740        assert!(d.stats().bytes_in_sync < d.stats().bytes_total);
1741    }
1742
1743    #[test]
1744    fn detector_finalize_emits_analysis_complete() {
1745        let mut d = FlickerDetector::new("fin");
1746        d.finalize();
1747        let last = d.events().last().unwrap();
1748        assert!(matches!(last.event_type, EventType::AnalysisComplete));
1749        assert!(matches!(last.severity, Severity::Info)); // flicker-free
1750    }
1751
1752    #[test]
1753    fn detector_finalize_incomplete_frame_severity() {
1754        let mut d = FlickerDetector::new("inc");
1755        d.feed(SYNC_BEGIN);
1756        d.feed(b"dangling content");
1757        d.finalize();
1758        let incomplete: Vec<_> = d
1759            .events()
1760            .iter()
1761            .filter(|e| matches!(e.event_type, EventType::IncompleteFrame))
1762            .collect();
1763        assert_eq!(incomplete.len(), 1);
1764        assert!(matches!(incomplete[0].severity, Severity::Error));
1765        let complete_evt = d.events().last().unwrap();
1766        assert!(matches!(
1767            complete_evt.event_type,
1768            EventType::AnalysisComplete
1769        ));
1770        assert!(matches!(complete_evt.severity, Severity::Warning));
1771    }
1772
1773    #[test]
1774    fn detector_sync_end_without_start() {
1775        let mut d = FlickerDetector::new("no-start");
1776        d.feed(SYNC_END);
1777        d.finalize();
1778        assert_eq!(d.stats().total_frames, 0);
1779        assert_eq!(d.stats().complete_frames, 0);
1780    }
1781
1782    #[test]
1783    fn detector_multiple_partial_clears() {
1784        let mut frame = Vec::new();
1785        frame.extend_from_slice(SYNC_BEGIN);
1786        frame.extend_from_slice(b"\x1b[0J"); // Partial ED to-end
1787        frame.extend_from_slice(b"\x1b[1J"); // Partial ED to-start
1788        frame.extend_from_slice(b"\x1b[0K"); // Partial EL to-end
1789        frame.extend_from_slice(b"\x1b[1K"); // Partial EL to-start
1790        frame.extend_from_slice(SYNC_END);
1791
1792        let analysis = analyze_stream(&frame);
1793        assert_eq!(analysis.stats.partial_clears, 4);
1794    }
1795
1796    #[test]
1797    fn detector_ed_mode2_inside_sync_no_partial_clear() {
1798        let mut frame = Vec::new();
1799        frame.extend_from_slice(SYNC_BEGIN);
1800        frame.extend_from_slice(b"\x1b[2J");
1801        frame.extend_from_slice(b"Content");
1802        frame.extend_from_slice(SYNC_END);
1803
1804        let analysis = analyze_stream(&frame);
1805        assert_eq!(analysis.stats.partial_clears, 0);
1806    }
1807
1808    #[test]
1809    fn detector_el_mode2_inside_sync_no_partial_clear() {
1810        let mut frame = Vec::new();
1811        frame.extend_from_slice(SYNC_BEGIN);
1812        frame.extend_from_slice(b"\x1b[2K");
1813        frame.extend_from_slice(b"Content");
1814        frame.extend_from_slice(SYNC_END);
1815
1816        let analysis = analyze_stream(&frame);
1817        assert_eq!(analysis.stats.partial_clears, 0);
1818    }
1819
1820    #[test]
1821    fn detector_ed_mode1_partial_clear() {
1822        let mut frame = Vec::new();
1823        frame.extend_from_slice(SYNC_BEGIN);
1824        frame.extend_from_slice(b"\x1b[1J");
1825        frame.extend_from_slice(SYNC_END);
1826
1827        let analysis = analyze_stream(&frame);
1828        assert_eq!(analysis.stats.partial_clears, 1);
1829    }
1830
1831    #[test]
1832    fn detector_el_outside_sync_not_partial_clear() {
1833        let mut stream = Vec::new();
1834        stream.extend_from_slice(b"\x1b[0K");
1835        stream.extend(make_synced_frame(b"Ok"));
1836
1837        let analysis = analyze_stream(&stream);
1838        assert_eq!(analysis.stats.partial_clears, 0);
1839    }
1840
1841    #[test]
1842    fn detector_ed_outside_sync_not_partial_clear() {
1843        let mut stream = Vec::new();
1844        stream.extend_from_slice(b"\x1b[0J");
1845        stream.extend(make_synced_frame(b"Ok"));
1846
1847        let analysis = analyze_stream(&stream);
1848        assert_eq!(analysis.stats.partial_clears, 0);
1849    }
1850
1851    #[test]
1852    fn detector_line_column_tracking() {
1853        let mut d = FlickerDetector::new("lc");
1854        d.feed(b"AB\nCD\nEF");
1855        d.finalize();
1856        let last = d.events().last().unwrap();
1857        assert_eq!(last.context.line, 2);
1858        assert_eq!(last.context.column, 2);
1859    }
1860
1861    #[test]
1862    fn detector_only_visible_chars_are_gap_bytes() {
1863        let mut d = FlickerDetector::new("gap");
1864        d.feed(b"\x00\x01\x02\x03");
1865        d.finalize();
1866        assert!(d.is_flicker_free());
1867    }
1868
1869    #[test]
1870    fn detector_gap_bytes_accumulated_across_regions() {
1871        let mut stream = Vec::new();
1872        stream.extend_from_slice(b"ABC");
1873        stream.extend(make_synced_frame(b"F1"));
1874        stream.extend_from_slice(b"DE");
1875        stream.extend(make_synced_frame(b"F2"));
1876
1877        let analysis = analyze_stream(&stream);
1878        assert_eq!(analysis.stats.sync_gaps, 2);
1879    }
1880
1881    #[test]
1882    fn detector_timestamp_monotonic() {
1883        let frame = make_synced_frame(b"Hi");
1884        let mut d = FlickerDetector::new("ts");
1885        d.feed(&frame);
1886        d.finalize();
1887        let timestamps: Vec<u64> = d.events().iter().map(|e| e.timestamp_ns).collect();
1888        for window in timestamps.windows(2) {
1889            assert!(
1890                window[1] > window[0],
1891                "Timestamps not monotonic: {:?}",
1892                timestamps
1893            );
1894        }
1895    }
1896
1897    #[test]
1898    fn detector_write_jsonl_empty() {
1899        let d = FlickerDetector::new("empty");
1900        let mut output = Vec::new();
1901        d.write_jsonl(&mut output).unwrap();
1902        assert!(output.is_empty());
1903    }
1904
1905    #[test]
1906    fn detector_to_jsonl_empty() {
1907        let d = FlickerDetector::new("empty");
1908        assert!(d.to_jsonl().is_empty());
1909    }
1910
1911    // --- Convenience functions ---
1912
1913    #[test]
1914    fn analyze_str_convenience() {
1915        let sync_begin = "\x1b[?2026h";
1916        let sync_end = "\x1b[?2026l";
1917        let input = format!("{sync_begin}Hello{sync_end}");
1918        let analysis = analyze_str(&input);
1919        assert!(analysis.flicker_free);
1920    }
1921
1922    #[test]
1923    fn analyze_stream_with_id_custom_id() {
1924        let frame = make_synced_frame(b"Test");
1925        let analysis = analyze_stream_with_id("custom-42", &frame);
1926        assert!(analysis.flicker_free);
1927        assert!(analysis.jsonl.contains("custom-42"));
1928    }
1929
1930    #[test]
1931    fn analyze_stream_default_id() {
1932        let frame = make_synced_frame(b"T");
1933        let analysis = analyze_stream(&frame);
1934        assert!(analysis.jsonl.contains("\"run_id\":\"analysis\""));
1935    }
1936
1937    // --- FlickerAnalysis ---
1938
1939    #[test]
1940    fn flicker_analysis_debug() {
1941        let analysis = analyze_stream(b"");
1942        let dbg = format!("{:?}", analysis);
1943        assert!(dbg.contains("flicker_free"));
1944        assert!(dbg.contains("stats"));
1945    }
1946
1947    #[test]
1948    fn flicker_analysis_issues_only_warnings_and_errors() {
1949        let mut stream = Vec::new();
1950        stream.extend(make_synced_frame(b"Frame"));
1951        stream.extend_from_slice(b"Gap");
1952        stream.extend(make_synced_frame(b"Frame2"));
1953
1954        let analysis = analyze_stream(&stream);
1955        for issue in &analysis.issues {
1956            assert!(
1957                matches!(issue.severity, Severity::Warning | Severity::Error),
1958                "Issue should be Warning or Error, got {:?}",
1959                issue.severity
1960            );
1961        }
1962        assert!(!analysis.issues.is_empty());
1963    }
1964
1965    // --- CSI parsing ---
1966
1967    #[test]
1968    fn csi_with_semicolons_multi_param() {
1969        let mut frame = Vec::new();
1970        frame.extend_from_slice(SYNC_BEGIN);
1971        frame.extend_from_slice(b"\x1b[1;31m");
1972        frame.extend_from_slice(b"Red");
1973        frame.extend_from_slice(b"\x1b[0m");
1974        frame.extend_from_slice(SYNC_END);
1975
1976        let analysis = analyze_stream(&frame);
1977        assert!(analysis.flicker_free);
1978    }
1979
1980    #[test]
1981    fn csi_cursor_movement_in_sync() {
1982        let mut frame = Vec::new();
1983        frame.extend_from_slice(SYNC_BEGIN);
1984        frame.extend_from_slice(b"\x1b[5A"); // Up 5
1985        frame.extend_from_slice(b"\x1b[3B"); // Down 3
1986        frame.extend_from_slice(b"\x1b[10C"); // Right 10
1987        frame.extend_from_slice(b"\x1b[2D"); // Left 2
1988        frame.extend_from_slice(b"\x1b[s"); // Save
1989        frame.extend_from_slice(b"\x1b[u"); // Restore
1990        frame.extend_from_slice(SYNC_END);
1991
1992        let analysis = analyze_stream(&frame);
1993        assert!(analysis.flicker_free);
1994    }
1995
1996    #[test]
1997    fn csi_cursor_position_with_params() {
1998        let mut frame = Vec::new();
1999        frame.extend_from_slice(SYNC_BEGIN);
2000        frame.extend_from_slice(b"\x1b[10;20H");
2001        frame.extend_from_slice(b"At position");
2002        frame.extend_from_slice(b"\x1b[5;15f");
2003        frame.extend_from_slice(SYNC_END);
2004
2005        let analysis = analyze_stream(&frame);
2006        assert!(analysis.flicker_free);
2007    }
2008
2009    #[test]
2010    fn csi_unknown_final_byte() {
2011        let mut frame = Vec::new();
2012        frame.extend_from_slice(SYNC_BEGIN);
2013        frame.extend_from_slice(b"\x1b[42z");
2014        frame.extend_from_slice(b"\x1b[0~");
2015        frame.extend_from_slice(SYNC_END);
2016
2017        let analysis = analyze_stream(&frame);
2018        assert!(analysis.flicker_free);
2019    }
2020
2021    #[test]
2022    fn csi_dec_private_non_sync_mode() {
2023        let mut stream = Vec::new();
2024        stream.extend_from_slice(b"\x1b[?25l"); // Hide cursor
2025        stream.extend(make_synced_frame(b"Content"));
2026        stream.extend_from_slice(b"\x1b[?25h"); // Show cursor
2027
2028        let analysis = analyze_stream(&stream);
2029        assert!(analysis.flicker_free);
2030    }
2031
2032    // --- Edge cases ---
2033
2034    #[test]
2035    fn only_escape_sequences_no_content() {
2036        let mut frame = Vec::new();
2037        frame.extend_from_slice(SYNC_BEGIN);
2038        frame.extend_from_slice(b"\x1b[H\x1b[2J\x1b[1;1H");
2039        frame.extend_from_slice(SYNC_END);
2040
2041        let analysis = analyze_stream(&frame);
2042        assert!(analysis.flicker_free);
2043    }
2044
2045    #[test]
2046    fn very_long_frame_content() {
2047        let content: Vec<u8> = (0..10_000).map(|i| b'A' + (i % 26) as u8).collect();
2048        let frame = make_synced_frame(&content);
2049        let analysis = analyze_stream(&frame);
2050        assert!(analysis.flicker_free);
2051        assert_eq!(analysis.stats.total_frames, 1);
2052    }
2053
2054    #[test]
2055    fn many_small_frames() {
2056        let mut stream = Vec::new();
2057        for _ in 0..100 {
2058            stream.extend(make_synced_frame(b"X"));
2059        }
2060        let analysis = analyze_stream(&stream);
2061        assert!(analysis.flicker_free);
2062        assert_eq!(analysis.stats.total_frames, 100);
2063        assert_eq!(analysis.stats.complete_frames, 100);
2064    }
2065
2066    #[test]
2067    fn escape_at_end_of_stream() {
2068        let mut d = FlickerDetector::new("esc-end");
2069        d.feed(b"\x1b");
2070        d.finalize();
2071        assert_eq!(d.stats().total_frames, 0);
2072    }
2073
2074    #[test]
2075    fn csi_at_end_of_stream() {
2076        let mut d = FlickerDetector::new("csi-end");
2077        d.feed(b"\x1b[42");
2078        d.finalize();
2079        assert_eq!(d.stats().total_frames, 0);
2080    }
2081
2082    #[test]
2083    fn csi_private_at_end_of_stream() {
2084        let mut d = FlickerDetector::new("dec-end");
2085        d.feed(b"\x1b[?2026");
2086        d.finalize();
2087        assert_eq!(d.stats().total_frames, 0);
2088    }
2089
2090    #[test]
2091    fn multiple_gap_regions_correct_count() {
2092        let mut stream = Vec::new();
2093        stream.extend_from_slice(b"Gap1");
2094        stream.extend(make_synced_frame(b"F1"));
2095        stream.extend_from_slice(b"Gap2");
2096        stream.extend(make_synced_frame(b"F2"));
2097        stream.extend_from_slice(b"Gap3");
2098
2099        let analysis = analyze_stream(&stream);
2100        assert_eq!(analysis.stats.sync_gaps, 3);
2101    }
2102
2103    #[test]
2104    fn flicker_event_to_jsonl_escaped_message() {
2105        let event = FlickerEvent {
2106            run_id: "esc".into(),
2107            timestamp_ns: 1,
2108            event_type: EventType::SyncGap,
2109            severity: Severity::Warning,
2110            context: EventContext::default(),
2111            details: EventDetails {
2112                message: "has \"quotes\" and\nnewlines".into(),
2113                ..Default::default()
2114            },
2115        };
2116        let json = event.to_jsonl();
2117        assert!(json.contains("\\\"quotes\\\""));
2118        assert!(json.contains("\\n"));
2119    }
2120
2121    #[test]
2122    fn flicker_event_to_jsonl_stats_flicker_free_true() {
2123        let event = FlickerEvent {
2124            run_id: "ok".into(),
2125            timestamp_ns: 1,
2126            event_type: EventType::AnalysisComplete,
2127            severity: Severity::Info,
2128            context: EventContext::default(),
2129            details: EventDetails {
2130                message: "done".into(),
2131                stats: Some(AnalysisStats {
2132                    total_frames: 5,
2133                    complete_frames: 5,
2134                    sync_gaps: 0,
2135                    partial_clears: 0,
2136                    bytes_total: 100,
2137                    bytes_in_sync: 80,
2138                }),
2139                ..Default::default()
2140            },
2141        };
2142        let json = event.to_jsonl();
2143        assert!(json.contains("\"flicker_free\":true"));
2144    }
2145}