Skip to main content

presentar_test/
build.rs

1//! Build validation module for WASM bundle size and quality checks.
2//!
3//! Provides tools for validating WASM bundles meet size and quality requirements.
4
5use crate::grade::{GateCheckResult, GateViolation, QualityGates, ViolationSeverity};
6use std::path::Path;
7
8// =============================================================================
9// BuildInfo - TESTS FIRST
10// =============================================================================
11
12/// Information about a built WASM bundle.
13#[derive(Debug, Clone, Default, PartialEq)]
14pub struct BuildInfo {
15    /// Path to the WASM file
16    pub wasm_path: String,
17    /// Size in bytes
18    pub size_bytes: u64,
19    /// Estimated Brotli compressed size in bytes
20    pub compressed_size_bytes: u64,
21    /// Build mode (debug/release)
22    pub mode: BuildMode,
23    /// Target platform
24    pub target: String,
25    /// Build timestamp (Unix epoch seconds)
26    pub timestamp: u64,
27}
28
29/// Build mode.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum BuildMode {
32    #[default]
33    Debug,
34    Release,
35}
36
37impl std::fmt::Display for BuildMode {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            Self::Debug => write!(f, "debug"),
41            Self::Release => write!(f, "release"),
42        }
43    }
44}
45
46impl BuildInfo {
47    /// Size in KB (rounded up).
48    #[must_use]
49    pub fn size_kb(&self) -> u32 {
50        ((self.size_bytes + 1023) / 1024) as u32
51    }
52
53    /// Compressed size in KB (rounded up).
54    #[must_use]
55    pub fn compressed_size_kb(&self) -> u32 {
56        ((self.compressed_size_bytes + 1023) / 1024) as u32
57    }
58
59    /// Compression ratio (0.0 to 1.0).
60    #[must_use]
61    pub fn compression_ratio(&self) -> f64 {
62        if self.size_bytes == 0 {
63            return 0.0;
64        }
65        self.compressed_size_bytes as f64 / self.size_bytes as f64
66    }
67
68    /// Whether bundle meets the size limit.
69    #[must_use]
70    pub fn meets_size_limit(&self, limit_kb: u32) -> bool {
71        self.size_kb() <= limit_kb
72    }
73}
74
75// =============================================================================
76// BundleAnalyzer - TESTS FIRST
77// =============================================================================
78
79/// Analyzes WASM bundle for size and content.
80#[derive(Debug, Default)]
81pub struct BundleAnalyzer {
82    /// Maximum allowed size in KB
83    pub max_size_kb: u32,
84    /// Expected compression ratio for Brotli
85    pub expected_compression_ratio: f64,
86    /// Forbidden patterns in the bundle
87    pub forbidden_patterns: Vec<String>,
88}
89
90impl BundleAnalyzer {
91    /// Create a new analyzer with default limits from the spec.
92    #[must_use]
93    pub fn new() -> Self {
94        Self {
95            max_size_kb: 500,                // Per spec: <500KB
96            expected_compression_ratio: 0.3, // Typical Brotli ratio for WASM
97            forbidden_patterns: vec![
98                "PANIC_MESSAGE".to_string(), // Debug panics
99                "debug_assert".to_string(),  // Debug assertions
100            ],
101        }
102    }
103
104    /// Analyze a WASM file.
105    ///
106    /// # Errors
107    ///
108    /// Returns error if file cannot be read.
109    pub fn analyze(&self, path: &Path) -> Result<BundleAnalysis, BundleError> {
110        // Read file
111        let data = std::fs::read(path).map_err(|e| BundleError::IoError(e.to_string()))?;
112
113        let size_bytes = data.len() as u64;
114
115        // Estimate Brotli compression (real compression would require brotli crate)
116        let compressed_size = self.estimate_compressed_size(&data);
117
118        // Check for WASM magic bytes
119        let is_valid_wasm = data.len() >= 4 && &data[0..4] == b"\0asm";
120
121        // Check for forbidden patterns
122        let forbidden_found = self.find_forbidden_patterns(&data);
123
124        // Parse sections if valid WASM
125        let sections = if is_valid_wasm {
126            self.parse_wasm_sections(&data)
127        } else {
128            Vec::new()
129        };
130
131        Ok(BundleAnalysis {
132            info: BuildInfo {
133                wasm_path: path.display().to_string(),
134                size_bytes,
135                compressed_size_bytes: compressed_size,
136                mode: if size_bytes > 1_000_000 {
137                    BuildMode::Debug
138                } else {
139                    BuildMode::Release
140                },
141                target: "wasm32-unknown-unknown".to_string(),
142                timestamp: 0,
143            },
144            is_valid_wasm,
145            sections,
146            forbidden_found,
147        })
148    }
149
150    /// Analyze raw WASM bytes.
151    #[must_use]
152    pub fn analyze_bytes(&self, data: &[u8]) -> BundleAnalysis {
153        let size_bytes = data.len() as u64;
154        let compressed_size = self.estimate_compressed_size(data);
155        let is_valid_wasm = data.len() >= 4 && &data[0..4] == b"\0asm";
156        let forbidden_found = self.find_forbidden_patterns(data);
157        let sections = if is_valid_wasm {
158            self.parse_wasm_sections(data)
159        } else {
160            Vec::new()
161        };
162
163        BundleAnalysis {
164            info: BuildInfo {
165                wasm_path: "<memory>".to_string(),
166                size_bytes,
167                compressed_size_bytes: compressed_size,
168                mode: if size_bytes > 1_000_000 {
169                    BuildMode::Debug
170                } else {
171                    BuildMode::Release
172                },
173                target: "wasm32-unknown-unknown".to_string(),
174                timestamp: 0,
175            },
176            is_valid_wasm,
177            sections,
178            forbidden_found,
179        }
180    }
181
182    /// Estimate Brotli compressed size.
183    fn estimate_compressed_size(&self, data: &[u8]) -> u64 {
184        // Rough estimate: WASM typically compresses to ~30% with Brotli
185        let ratio = self.expected_compression_ratio;
186        (data.len() as f64 * ratio) as u64
187    }
188
189    /// Find forbidden patterns in binary data.
190    fn find_forbidden_patterns(&self, data: &[u8]) -> Vec<String> {
191        let mut found = Vec::new();
192        let data_str = String::from_utf8_lossy(data);
193
194        for pattern in &self.forbidden_patterns {
195            if data_str.contains(pattern) {
196                found.push(pattern.clone());
197            }
198        }
199
200        found
201    }
202
203    /// Parse WASM section headers.
204    fn parse_wasm_sections(&self, data: &[u8]) -> Vec<WasmSection> {
205        let mut sections = Vec::new();
206
207        if data.len() < 8 {
208            return sections;
209        }
210
211        // Skip magic (4 bytes) and version (4 bytes)
212        let mut pos = 8;
213
214        while pos < data.len() {
215            if pos >= data.len() {
216                break;
217            }
218
219            let section_id = data[pos];
220            pos += 1;
221
222            // Parse LEB128 size
223            let (size, bytes_read) = Self::read_leb128(&data[pos..]);
224            pos += bytes_read;
225
226            if pos + size as usize > data.len() {
227                break;
228            }
229
230            sections.push(WasmSection {
231                id: section_id,
232                name: Self::section_name(section_id),
233                size,
234            });
235
236            pos += size as usize;
237        }
238
239        sections
240    }
241
242    /// Read LEB128 encoded unsigned integer.
243    fn read_leb128(data: &[u8]) -> (u64, usize) {
244        let mut result = 0u64;
245        let mut shift = 0;
246        let mut bytes_read = 0;
247
248        for &byte in data {
249            result |= u64::from(byte & 0x7F) << shift;
250            bytes_read += 1;
251            if byte & 0x80 == 0 {
252                break;
253            }
254            shift += 7;
255            if shift >= 64 {
256                break;
257            }
258        }
259
260        (result, bytes_read)
261    }
262
263    /// Get section name from ID.
264    fn section_name(id: u8) -> String {
265        match id {
266            0 => "custom".to_string(),
267            1 => "type".to_string(),
268            2 => "import".to_string(),
269            3 => "function".to_string(),
270            4 => "table".to_string(),
271            5 => "memory".to_string(),
272            6 => "global".to_string(),
273            7 => "export".to_string(),
274            8 => "start".to_string(),
275            9 => "element".to_string(),
276            10 => "code".to_string(),
277            11 => "data".to_string(),
278            12 => "data_count".to_string(),
279            _ => format!("unknown_{id}"),
280        }
281    }
282
283    /// Check if analysis passes quality gates.
284    #[must_use]
285    pub fn check_gates(&self, analysis: &BundleAnalysis, gates: &QualityGates) -> GateCheckResult {
286        let mut violations = Vec::new();
287
288        // Check bundle size
289        if analysis.info.size_kb() > gates.performance.max_bundle_size_kb {
290            violations.push(GateViolation {
291                gate: "max_bundle_size_kb".to_string(),
292                expected: format!("<= {}KB", gates.performance.max_bundle_size_kb),
293                actual: format!("{}KB", analysis.info.size_kb()),
294                severity: ViolationSeverity::Error,
295            });
296        }
297
298        // Check for forbidden patterns
299        if !analysis.forbidden_found.is_empty() {
300            violations.push(GateViolation {
301                gate: "forbidden_patterns".to_string(),
302                expected: "no debug symbols".to_string(),
303                actual: format!("found: {}", analysis.forbidden_found.join(", ")),
304                severity: ViolationSeverity::Warning,
305            });
306        }
307
308        // Check WASM validity
309        if !analysis.is_valid_wasm {
310            violations.push(GateViolation {
311                gate: "wasm_validity".to_string(),
312                expected: "valid WASM file".to_string(),
313                actual: "invalid WASM magic bytes".to_string(),
314                severity: ViolationSeverity::Error,
315            });
316        }
317
318        // Check for debug build (size heuristic)
319        if analysis.info.mode == BuildMode::Debug {
320            violations.push(GateViolation {
321                gate: "build_mode".to_string(),
322                expected: "release build".to_string(),
323                actual: "debug build (size > 1MB)".to_string(),
324                severity: ViolationSeverity::Warning,
325            });
326        }
327
328        let passed = !violations
329            .iter()
330            .any(|v| v.severity == ViolationSeverity::Error);
331
332        GateCheckResult { passed, violations }
333    }
334}
335
336/// Result of bundle analysis.
337#[derive(Debug, Clone, Default)]
338pub struct BundleAnalysis {
339    /// Build info
340    pub info: BuildInfo,
341    /// Whether file is valid WASM
342    pub is_valid_wasm: bool,
343    /// WASM sections found
344    pub sections: Vec<WasmSection>,
345    /// Forbidden patterns found
346    pub forbidden_found: Vec<String>,
347}
348
349impl BundleAnalysis {
350    /// Get code section size.
351    #[must_use]
352    pub fn code_size(&self) -> u64 {
353        self.sections
354            .iter()
355            .find(|s| s.name == "code")
356            .map_or(0, |s| s.size)
357    }
358
359    /// Get data section size.
360    #[must_use]
361    pub fn data_size(&self) -> u64 {
362        self.sections
363            .iter()
364            .find(|s| s.name == "data")
365            .map_or(0, |s| s.size)
366    }
367
368    /// Get custom sections total size.
369    #[must_use]
370    pub fn custom_size(&self) -> u64 {
371        self.sections
372            .iter()
373            .filter(|s| s.name == "custom")
374            .map(|s| s.size)
375            .sum()
376    }
377}
378
379/// WASM section info.
380#[derive(Debug, Clone, Default)]
381pub struct WasmSection {
382    /// Section ID
383    pub id: u8,
384    /// Section name
385    pub name: String,
386    /// Section size in bytes
387    pub size: u64,
388}
389
390/// Build validation errors.
391#[derive(Debug)]
392pub enum BundleError {
393    /// IO error reading file
394    IoError(String),
395    /// Invalid WASM format
396    InvalidFormat(String),
397}
398
399impl std::fmt::Display for BundleError {
400    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
401        match self {
402            Self::IoError(msg) => write!(f, "IO error: {msg}"),
403            Self::InvalidFormat(msg) => write!(f, "invalid format: {msg}"),
404        }
405    }
406}
407
408impl std::error::Error for BundleError {}
409
410// =============================================================================
411// SizeTracker - Track bundle size over time
412// =============================================================================
413
414/// Tracks bundle size changes over time.
415#[derive(Debug, Clone, Default)]
416pub struct SizeTracker {
417    /// Historical size records
418    pub records: Vec<SizeRecord>,
419    /// Baseline size for comparison
420    pub baseline_kb: Option<u32>,
421}
422
423/// Record of bundle size at a point in time.
424#[derive(Debug, Clone)]
425pub struct SizeRecord {
426    /// Timestamp (Unix epoch)
427    pub timestamp: u64,
428    /// Size in KB
429    pub size_kb: u32,
430    /// Git commit hash (if available)
431    pub commit: Option<String>,
432    /// Label for this record
433    pub label: String,
434}
435
436impl SizeTracker {
437    /// Create new tracker.
438    #[must_use]
439    pub fn new() -> Self {
440        Self::default()
441    }
442
443    /// Add a size record.
444    pub fn record(&mut self, size_kb: u32, label: &str, commit: Option<&str>) {
445        self.records.push(SizeRecord {
446            timestamp: std::time::SystemTime::now()
447                .duration_since(std::time::UNIX_EPOCH)
448                .map_or(0, |d| d.as_secs()),
449            size_kb,
450            commit: commit.map(String::from),
451            label: label.to_string(),
452        });
453    }
454
455    /// Set baseline for comparison.
456    pub fn set_baseline(&mut self, size_kb: u32) {
457        self.baseline_kb = Some(size_kb);
458    }
459
460    /// Get size change from baseline.
461    #[must_use]
462    pub fn change_from_baseline(&self, current_kb: u32) -> Option<i32> {
463        self.baseline_kb.map(|b| current_kb as i32 - b as i32)
464    }
465
466    /// Get size change percentage from baseline.
467    #[must_use]
468    pub fn change_percentage(&self, current_kb: u32) -> Option<f64> {
469        self.baseline_kb.map(|b| {
470            if b == 0 {
471                0.0
472            } else {
473                (f64::from(current_kb) - f64::from(b)) / f64::from(b) * 100.0
474            }
475        })
476    }
477
478    /// Get the minimum size ever recorded.
479    #[must_use]
480    pub fn min_size(&self) -> Option<u32> {
481        self.records.iter().map(|r| r.size_kb).min()
482    }
483
484    /// Get the maximum size ever recorded.
485    #[must_use]
486    pub fn max_size(&self) -> Option<u32> {
487        self.records.iter().map(|r| r.size_kb).max()
488    }
489
490    /// Get the latest record.
491    #[must_use]
492    pub fn latest(&self) -> Option<&SizeRecord> {
493        self.records.last()
494    }
495}
496
497// =============================================================================
498// Tests - TDD Style
499// =============================================================================
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    // -------------------------------------------------------------------------
506    // BuildInfo tests
507    // -------------------------------------------------------------------------
508
509    #[test]
510    fn test_build_info_default() {
511        let info = BuildInfo::default();
512        assert_eq!(info.size_bytes, 0);
513        assert_eq!(info.compressed_size_bytes, 0);
514        assert_eq!(info.mode, BuildMode::Debug);
515    }
516
517    #[test]
518    fn test_build_info_size_kb() {
519        let info = BuildInfo {
520            size_bytes: 1024,
521            ..Default::default()
522        };
523        assert_eq!(info.size_kb(), 1);
524
525        let info = BuildInfo {
526            size_bytes: 1025,
527            ..Default::default()
528        };
529        assert_eq!(info.size_kb(), 2); // Rounds up
530    }
531
532    #[test]
533    fn test_build_info_compression_ratio() {
534        let info = BuildInfo {
535            size_bytes: 1000,
536            compressed_size_bytes: 300,
537            ..Default::default()
538        };
539        assert!((info.compression_ratio() - 0.3).abs() < 0.01);
540    }
541
542    #[test]
543    fn test_build_info_compression_ratio_zero() {
544        let info = BuildInfo {
545            size_bytes: 0,
546            compressed_size_bytes: 0,
547            ..Default::default()
548        };
549        assert_eq!(info.compression_ratio(), 0.0);
550    }
551
552    #[test]
553    fn test_build_info_meets_size_limit() {
554        let info = BuildInfo {
555            size_bytes: 400 * 1024, // 400KB
556            ..Default::default()
557        };
558        assert!(info.meets_size_limit(500));
559        assert!(!info.meets_size_limit(300));
560    }
561
562    #[test]
563    fn test_build_mode_display() {
564        assert_eq!(BuildMode::Debug.to_string(), "debug");
565        assert_eq!(BuildMode::Release.to_string(), "release");
566    }
567
568    // -------------------------------------------------------------------------
569    // BundleAnalyzer tests
570    // -------------------------------------------------------------------------
571
572    #[test]
573    fn test_bundle_analyzer_new() {
574        let analyzer = BundleAnalyzer::new();
575        assert_eq!(analyzer.max_size_kb, 500);
576        assert!((analyzer.expected_compression_ratio - 0.3).abs() < 0.01);
577    }
578
579    #[test]
580    fn test_bundle_analyzer_analyze_bytes_valid_wasm() {
581        // Valid WASM header
582        let mut data = vec![0x00, 0x61, 0x73, 0x6D]; // \0asm
583        data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // version 1
584
585        let analyzer = BundleAnalyzer::new();
586        let analysis = analyzer.analyze_bytes(&data);
587
588        assert!(analysis.is_valid_wasm);
589        assert_eq!(analysis.info.size_bytes, 8);
590    }
591
592    #[test]
593    fn test_bundle_analyzer_analyze_bytes_invalid_wasm() {
594        let data = b"not wasm data";
595        let analyzer = BundleAnalyzer::new();
596        let analysis = analyzer.analyze_bytes(data);
597
598        assert!(!analysis.is_valid_wasm);
599    }
600
601    #[test]
602    fn test_bundle_analyzer_forbidden_patterns() {
603        let mut analyzer = BundleAnalyzer::new();
604        analyzer.forbidden_patterns = vec!["SECRET".to_string()];
605
606        let data = b"some data with SECRET inside";
607        let analysis = analyzer.analyze_bytes(data);
608
609        assert!(analysis.forbidden_found.contains(&"SECRET".to_string()));
610    }
611
612    #[test]
613    fn test_bundle_analyzer_compression_estimate() {
614        let analyzer = BundleAnalyzer::new();
615        let data = vec![0u8; 10000]; // 10KB of zeros
616        let analysis = analyzer.analyze_bytes(&data);
617
618        // Should estimate ~30% compression
619        assert!(analysis.info.compressed_size_bytes < analysis.info.size_bytes);
620        let ratio = analysis.info.compression_ratio();
621        assert!((ratio - 0.3).abs() < 0.1);
622    }
623
624    #[test]
625    fn test_bundle_analyzer_check_gates_passes() {
626        let analyzer = BundleAnalyzer::new();
627        let gates = QualityGates::default();
628
629        // Small valid WASM
630        let mut data = vec![0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00];
631        data.extend_from_slice(&vec![0u8; 1000]); // Small bundle
632
633        let analysis = analyzer.analyze_bytes(&data);
634        let result = analyzer.check_gates(&analysis, &gates);
635
636        assert!(result.passed);
637    }
638
639    #[test]
640    fn test_bundle_analyzer_check_gates_size_fails() {
641        let analyzer = BundleAnalyzer::new();
642        let mut gates = QualityGates::default();
643        gates.performance.max_bundle_size_kb = 1; // Very small limit
644
645        let mut data = vec![0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00];
646        data.extend_from_slice(&vec![0u8; 5000]); // ~5KB bundle
647
648        let analysis = analyzer.analyze_bytes(&data);
649        let result = analyzer.check_gates(&analysis, &gates);
650
651        assert!(!result.passed);
652        assert!(result
653            .violations
654            .iter()
655            .any(|v| v.gate == "max_bundle_size_kb"));
656    }
657
658    #[test]
659    fn test_bundle_analyzer_check_gates_invalid_wasm() {
660        let analyzer = BundleAnalyzer::new();
661        let gates = QualityGates::default();
662
663        let data = b"not wasm at all";
664        let analysis = analyzer.analyze_bytes(data);
665        let result = analyzer.check_gates(&analysis, &gates);
666
667        assert!(!result.passed);
668        assert!(result.violations.iter().any(|v| v.gate == "wasm_validity"));
669    }
670
671    #[test]
672    fn test_bundle_analyzer_leb128() {
673        // Test single byte
674        let data = [0x7F];
675        let (val, bytes) = BundleAnalyzer::read_leb128(&data);
676        assert_eq!(val, 127);
677        assert_eq!(bytes, 1);
678
679        // Test multi-byte
680        let data = [0x80, 0x01];
681        let (val, bytes) = BundleAnalyzer::read_leb128(&data);
682        assert_eq!(val, 128);
683        assert_eq!(bytes, 2);
684    }
685
686    #[test]
687    fn test_bundle_analyzer_section_names() {
688        assert_eq!(BundleAnalyzer::section_name(0), "custom");
689        assert_eq!(BundleAnalyzer::section_name(1), "type");
690        assert_eq!(BundleAnalyzer::section_name(10), "code");
691        assert_eq!(BundleAnalyzer::section_name(11), "data");
692        assert_eq!(BundleAnalyzer::section_name(255), "unknown_255");
693    }
694
695    // -------------------------------------------------------------------------
696    // BundleAnalysis tests
697    // -------------------------------------------------------------------------
698
699    #[test]
700    fn test_bundle_analysis_code_size() {
701        let analysis = BundleAnalysis {
702            sections: vec![
703                WasmSection {
704                    id: 10,
705                    name: "code".to_string(),
706                    size: 5000,
707                },
708                WasmSection {
709                    id: 11,
710                    name: "data".to_string(),
711                    size: 1000,
712                },
713            ],
714            ..Default::default()
715        };
716
717        assert_eq!(analysis.code_size(), 5000);
718        assert_eq!(analysis.data_size(), 1000);
719    }
720
721    #[test]
722    fn test_bundle_analysis_custom_size() {
723        let analysis = BundleAnalysis {
724            sections: vec![
725                WasmSection {
726                    id: 0,
727                    name: "custom".to_string(),
728                    size: 100,
729                },
730                WasmSection {
731                    id: 0,
732                    name: "custom".to_string(),
733                    size: 200,
734                },
735            ],
736            ..Default::default()
737        };
738
739        assert_eq!(analysis.custom_size(), 300);
740    }
741
742    #[test]
743    fn test_bundle_analysis_empty_sections() {
744        let analysis = BundleAnalysis::default();
745        assert_eq!(analysis.code_size(), 0);
746        assert_eq!(analysis.data_size(), 0);
747        assert_eq!(analysis.custom_size(), 0);
748    }
749
750    // -------------------------------------------------------------------------
751    // BundleError tests
752    // -------------------------------------------------------------------------
753
754    #[test]
755    fn test_bundle_error_display() {
756        let io_err = BundleError::IoError("file not found".to_string());
757        assert!(io_err.to_string().contains("IO error"));
758
759        let format_err = BundleError::InvalidFormat("bad magic".to_string());
760        assert!(format_err.to_string().contains("invalid format"));
761    }
762
763    // -------------------------------------------------------------------------
764    // SizeTracker tests
765    // -------------------------------------------------------------------------
766
767    #[test]
768    fn test_size_tracker_new() {
769        let tracker = SizeTracker::new();
770        assert!(tracker.records.is_empty());
771        assert!(tracker.baseline_kb.is_none());
772    }
773
774    #[test]
775    fn test_size_tracker_record() {
776        let mut tracker = SizeTracker::new();
777        tracker.record(100, "initial", None);
778        tracker.record(110, "feature-a", Some("abc123"));
779
780        assert_eq!(tracker.records.len(), 2);
781        assert_eq!(tracker.records[0].size_kb, 100);
782        assert_eq!(tracker.records[1].size_kb, 110);
783        assert_eq!(tracker.records[1].commit, Some("abc123".to_string()));
784    }
785
786    #[test]
787    fn test_size_tracker_baseline() {
788        let mut tracker = SizeTracker::new();
789        tracker.set_baseline(100);
790
791        assert_eq!(tracker.change_from_baseline(110), Some(10));
792        assert_eq!(tracker.change_from_baseline(90), Some(-10));
793    }
794
795    #[test]
796    fn test_size_tracker_change_percentage() {
797        let mut tracker = SizeTracker::new();
798        tracker.set_baseline(100);
799
800        let pct = tracker.change_percentage(110).unwrap();
801        assert!((pct - 10.0).abs() < 0.01);
802
803        let pct = tracker.change_percentage(50).unwrap();
804        assert!((pct - (-50.0)).abs() < 0.01);
805    }
806
807    #[test]
808    fn test_size_tracker_min_max() {
809        let mut tracker = SizeTracker::new();
810        tracker.record(100, "a", None);
811        tracker.record(150, "b", None);
812        tracker.record(120, "c", None);
813
814        assert_eq!(tracker.min_size(), Some(100));
815        assert_eq!(tracker.max_size(), Some(150));
816    }
817
818    #[test]
819    fn test_size_tracker_latest() {
820        let mut tracker = SizeTracker::new();
821        tracker.record(100, "first", None);
822        tracker.record(200, "second", None);
823
824        let latest = tracker.latest().unwrap();
825        assert_eq!(latest.size_kb, 200);
826        assert_eq!(latest.label, "second");
827    }
828
829    #[test]
830    fn test_size_tracker_empty_min_max() {
831        let tracker = SizeTracker::new();
832        assert!(tracker.min_size().is_none());
833        assert!(tracker.max_size().is_none());
834        assert!(tracker.latest().is_none());
835    }
836
837    #[test]
838    fn test_size_tracker_baseline_zero() {
839        let mut tracker = SizeTracker::new();
840        tracker.set_baseline(0);
841
842        assert_eq!(tracker.change_percentage(100), Some(0.0));
843    }
844}