1#![forbid(unsafe_code)]
2
3use std::fmt::Write as FmtWrite;
39use std::io::Write;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum Severity {
48 Info,
50 Warning,
52 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
68pub enum EventType {
69 FrameStart,
71 FrameEnd,
73 SyncGap,
75 PartialClear,
77 IncompleteFrame,
79 InterleavedWrites,
81 SuspiciousCursorMove,
83 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#[derive(Debug, Clone, Default)]
104pub struct EventContext {
105 pub frame_id: u64,
107 pub byte_offset: usize,
109 pub line: usize,
111 pub column: usize,
113}
114
115#[derive(Debug, Clone, Default)]
117pub struct EventDetails {
118 pub message: String,
120 pub trigger_bytes: Option<Vec<u8>>,
122 pub bytes_outside_sync: Option<usize>,
124 pub clear_type: Option<u8>,
126 pub clear_mode: Option<u8>,
128 pub affected_rows: Option<Vec<u16>>,
130 pub stats: Option<AnalysisStats>,
132}
133
134#[derive(Debug, Clone)]
136pub struct FlickerEvent {
137 pub run_id: String,
139 pub timestamp_ns: u64,
141 pub event_type: EventType,
143 pub severity: Severity,
145 pub context: EventContext,
147 pub details: EventDetails,
149}
150
151impl FlickerEvent {
152 pub fn to_jsonl(&self) -> String {
154 let mut json = String::with_capacity(256);
155 json.push('{');
156
157 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 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 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
229fn 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#[derive(Debug, Clone, Default)]
248pub struct AnalysisStats {
249 pub total_frames: u64,
251 pub complete_frames: u64,
253 pub sync_gaps: u64,
255 pub partial_clears: u64,
257 pub bytes_total: usize,
259 pub bytes_in_sync: usize,
261}
262
263impl AnalysisStats {
264 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285enum ParserState {
286 Ground,
287 Escape,
288 Csi,
289 CsiParam,
290 CsiPrivate,
291}
292
293pub struct FlickerDetector {
295 run_id: String,
297 state: ParserState,
299 csi_params: Vec<u16>,
301 csi_current: u16,
303 csi_private: bool,
305 sync_active: bool,
307 frame_id: u64,
309 byte_offset: usize,
311 line: usize,
313 column: usize,
315 gap_bytes: usize,
317 events: Vec<FlickerEvent>,
319 stats: AnalysisStats,
321 timestamp_counter: u64,
323}
324
325impl FlickerDetector {
326 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 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 pub fn run_id(&self) -> &str {
360 &self.run_id
361 }
362
363 pub fn events(&self) -> &[FlickerEvent] {
365 &self.events
366 }
367
368 pub fn stats(&self) -> &AnalysisStats {
370 &self.stats
371 }
372
373 pub fn is_flicker_free(&self) -> bool {
375 self.stats.is_flicker_free()
376 }
377
378 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 pub fn feed_str(&mut self, s: &str) {
392 self.feed(s.as_bytes());
393 }
394
395 pub fn finalize(&mut self) {
397 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; }
409
410 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 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 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 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 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 0x20..=0x7e if !self.sync_active => {
506 self.gap_bytes += 1;
507 if self.gap_bytes == 1 {
509 }
511 }
512 0x20..=0x7e => {
513 }
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 self.csi_params.push(self.csi_current);
560 self.handle_erase_display();
561 self.state = ParserState::Ground;
562 }
563 b'K' => {
564 self.csi_params.push(self.csi_current);
566 self.handle_erase_line();
567 self.state = ParserState::Ground;
568 }
569 b'H' | b'f' => {
570 self.csi_params.push(self.csi_current);
572 if !self.sync_active && self.gap_bytes > 0 {
574 }
576 self.state = ParserState::Ground;
577 }
578 b'm' | b'A'..=b'G' | b's' | b'u' => {
579 self.state = ParserState::Ground;
581 }
582 _ if (0x40..=0x7e).contains(&byte) => {
583 self.state = ParserState::Ground;
585 }
586 _ => {
587 }
589 }
590 }
591
592 fn handle_set_mode(&mut self) {
593 if self.csi_private {
594 let has_sync = self.csi_params.contains(&2026);
596 if has_sync {
597 self.handle_sync_begin();
599 }
600 }
601 }
602
603 fn handle_reset_mode(&mut self) {
604 if self.csi_private {
605 let has_sync = self.csi_params.contains(&2026);
607 if has_sync {
608 self.handle_sync_end();
610 }
611 }
612 }
613
614 fn handle_sync_begin(&mut self) {
615 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 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 if self.sync_active && mode != 2 {
668 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), 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 }
683 }
684
685 fn handle_erase_line(&mut self) {
686 let mode = self.csi_params.first().copied().unwrap_or(0);
687
688 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), 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#[derive(Debug)]
717pub struct FlickerAnalysis {
718 pub flicker_free: bool,
720 pub stats: AnalysisStats,
722 pub issues: Vec<FlickerEvent>,
724 pub jsonl: String,
726}
727
728impl FlickerAnalysis {
729 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
766pub fn analyze_stream(bytes: &[u8]) -> FlickerAnalysis {
768 analyze_stream_with_id("analysis", bytes)
769}
770
771pub 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
792pub fn analyze_str(s: &str) -> FlickerAnalysis {
794 analyze_stream(s.as_bytes())
795}
796
797pub fn assert_flicker_free(bytes: &[u8]) {
799 analyze_stream(bytes).assert_flicker_free();
800}
801
802pub fn assert_flicker_free_str(s: &str) {
804 assert_flicker_free(s.as_bytes());
805}
806
807#[cfg(test)]
812mod tests {
813 use super::*;
814 use crate::golden::compute_text_checksum;
815
816 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 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 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 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 let mut frame = Vec::new();
940 frame.extend_from_slice(SYNC_BEGIN);
941 frame.extend_from_slice(b"\x1b[0J"); 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 let mut frame = Vec::new();
954 frame.extend_from_slice(SYNC_BEGIN);
955 frame.extend_from_slice(b"\x1b[0K"); 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 let mut stream = Vec::new();
968 stream.extend_from_slice(b"\x1b[2J"); stream.extend(make_synced_frame(b"First frame"));
970
971 let analysis = analyze_stream(&stream);
972 assert_eq!(analysis.stats.partial_clears, 0);
974 }
975
976 #[test]
977 fn full_line_clear_in_frame_is_ok() {
978 let mut frame = Vec::new();
980 frame.extend_from_slice(SYNC_BEGIN);
981 frame.extend_from_slice(b"\x1b[2K"); 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"); frame.extend_from_slice(b"\x1b[1K"); 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 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 let mut frame = Vec::new();
1107 frame.extend_from_slice(SYNC_BEGIN);
1108 frame.extend_from_slice(b"\x1b[H"); frame.extend_from_slice(b"\x1b[2J"); frame.extend_from_slice(b"\x1b[1;1H"); frame.extend_from_slice(b"\x1b[1;31mRed\x1b[0m"); frame.extend_from_slice(b"\x1b[2;1HLine 2");
1113 frame.extend_from_slice(SYNC_END);
1114
1115 let analysis = analyze_stream(&frame);
1116 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 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 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 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 let mut stream = Vec::new();
1218 stream.extend_from_slice(SYNC_BEGIN);
1219 stream.extend_from_slice(b"\x1b\x1b\x1b[m"); stream.extend_from_slice(SYNC_END);
1221
1222 let analysis = analyze_stream(&stream);
1223 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 #[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; assert_eq!(s, s2);
1327 let s3 = s;
1328 assert_eq!(s, s3);
1329 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); 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 #[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 #[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 #[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 #[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 assert!(AnalysisStats::default().is_flicker_free());
1467
1468 assert!(
1470 !AnalysisStats {
1471 sync_gaps: 1,
1472 ..Default::default()
1473 }
1474 .is_flicker_free()
1475 );
1476
1477 assert!(
1479 !AnalysisStats {
1480 partial_clears: 1,
1481 ..Default::default()
1482 }
1483 .is_flicker_free()
1484 );
1485
1486 assert!(
1488 !AnalysisStats {
1489 total_frames: 3,
1490 complete_frames: 2,
1491 ..Default::default()
1492 }
1493 .is_flicker_free()
1494 );
1495
1496 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 #[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 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 #[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 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 #[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 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 assert_eq!(d.stats().bytes_total, 18);
1738 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)); }
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"); frame.extend_from_slice(b"\x1b[1J"); frame.extend_from_slice(b"\x1b[0K"); frame.extend_from_slice(b"\x1b[1K"); 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 #[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 #[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 #[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"); frame.extend_from_slice(b"\x1b[3B"); frame.extend_from_slice(b"\x1b[10C"); frame.extend_from_slice(b"\x1b[2D"); frame.extend_from_slice(b"\x1b[s"); frame.extend_from_slice(b"\x1b[u"); 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"); stream.extend(make_synced_frame(b"Content"));
2026 stream.extend_from_slice(b"\x1b[?25h"); let analysis = analyze_stream(&stream);
2029 assert!(analysis.flicker_free);
2030 }
2031
2032 #[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}