1mod defaults_config;
24mod paired;
25
26pub use paired::{
27 PAIRED_SCHEMA_V1, PairedBenchMeta, PairedDiffSummary, PairedRunReceipt, PairedSample,
28 PairedSampleHalf, PairedStats,
29};
30
31pub use defaults_config::*;
32
33pub use perfgate_validation::{
34 BENCH_NAME_MAX_LEN, BENCH_NAME_PATTERN, ValidationError as BenchNameValidationError,
35 validate_bench_name,
36};
37
38pub use perfgate_error::{ConfigValidationError, PerfgateError};
39
40use schemars::JsonSchema;
41use serde::{Deserialize, Serialize};
42use std::collections::BTreeMap;
43
44pub const RUN_SCHEMA_V1: &str = "perfgate.run.v1";
45pub const BASELINE_SCHEMA_V1: &str = "perfgate.baseline.v1";
46pub const COMPARE_SCHEMA_V1: &str = "perfgate.compare.v1";
47pub const REPORT_SCHEMA_V1: &str = "perfgate.report.v1";
48pub const CONFIG_SCHEMA_V1: &str = "perfgate.config.v1";
49
50pub const CHECK_ID_BUDGET: &str = "perf.budget";
52pub const CHECK_ID_BASELINE: &str = "perf.baseline";
53pub const CHECK_ID_HOST: &str = "perf.host";
54pub const CHECK_ID_TOOL_RUNTIME: &str = "tool.runtime";
55pub const FINDING_CODE_METRIC_WARN: &str = "metric_warn";
56pub const FINDING_CODE_METRIC_FAIL: &str = "metric_fail";
57pub const FINDING_CODE_BASELINE_MISSING: &str = "missing";
58pub const FINDING_CODE_HOST_MISMATCH: &str = "host_mismatch";
59pub const FINDING_CODE_RUNTIME_ERROR: &str = "runtime_error";
60pub const VERDICT_REASON_NO_BASELINE: &str = "no_baseline";
61pub const VERDICT_REASON_HOST_MISMATCH: &str = "host_mismatch";
62pub const VERDICT_REASON_TOOL_ERROR: &str = "tool_error";
63pub const VERDICT_REASON_TRUNCATED: &str = "truncated";
64
65pub const STAGE_CONFIG_PARSE: &str = "config_parse";
67pub const STAGE_BASELINE_RESOLVE: &str = "baseline_resolve";
68pub const STAGE_RUN_COMMAND: &str = "run_command";
69pub const STAGE_WRITE_ARTIFACTS: &str = "write_artifacts";
70
71pub const ERROR_KIND_IO: &str = "io_error";
73pub const ERROR_KIND_PARSE: &str = "parse_error";
74pub const ERROR_KIND_EXEC: &str = "exec_error";
75
76pub const BASELINE_REASON_NO_BASELINE: &str = "no_baseline";
78
79pub const CHECK_ID_TOOL_TRUNCATION: &str = "tool.truncation";
81pub const FINDING_CODE_TRUNCATED: &str = "truncated";
82pub const MAX_FINDINGS_DEFAULT: usize = 100;
83
84pub const SENSOR_REPORT_SCHEMA_V1: &str = "sensor.report.v1";
86
87#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
93#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
94#[serde(rename_all = "snake_case")]
95pub enum CapabilityStatus {
96 Available,
97 Unavailable,
98 Skipped,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
103#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
104pub struct Capability {
105 pub status: CapabilityStatus,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub reason: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
112#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
113pub struct SensorCapabilities {
114 pub baseline: Capability,
115 #[serde(skip_serializing_if = "Option::is_none", default)]
116 pub engine: Option<Capability>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
121#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
122pub struct SensorRunMeta {
123 pub started_at: String,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub ended_at: Option<String>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub duration_ms: Option<u64>,
128 pub capabilities: SensorCapabilities,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
133#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
134#[serde(rename_all = "snake_case")]
135pub enum SensorVerdictStatus {
136 Pass,
137 Warn,
138 Fail,
139 Skip,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
144#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
145pub struct SensorVerdictCounts {
146 pub info: u32,
147 pub warn: u32,
148 pub error: u32,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
153#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
154pub struct SensorVerdict {
155 pub status: SensorVerdictStatus,
156 pub counts: SensorVerdictCounts,
157 pub reasons: Vec<String>,
158}
159
160#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
162#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
163#[serde(rename_all = "snake_case")]
164pub enum SensorSeverity {
165 Info,
166 Warn,
167 Error,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
172pub struct SensorFinding {
173 pub check_id: String,
174 pub code: String,
175 pub severity: SensorSeverity,
176 pub message: String,
177 #[serde(skip_serializing_if = "Option::is_none")]
178 pub fingerprint: Option<String>,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 pub data: Option<serde_json::Value>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
185#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
186pub struct SensorArtifact {
187 pub path: String,
188 #[serde(rename = "type")]
189 pub artifact_type: String,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
200pub struct SensorReport {
201 pub schema: String,
202 pub tool: ToolInfo,
203 pub run: SensorRunMeta,
204 pub verdict: SensorVerdict,
205 pub findings: Vec<SensorFinding>,
206 #[serde(skip_serializing_if = "Vec::is_empty", default)]
207 pub artifacts: Vec<SensorArtifact>,
208 pub data: serde_json::Value,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
212#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
213pub struct ToolInfo {
214 pub name: String,
215 pub version: String,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
219#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
220pub struct HostInfo {
221 pub os: String,
223
224 pub arch: String,
226
227 #[serde(skip_serializing_if = "Option::is_none", default)]
229 pub cpu_count: Option<u32>,
230
231 #[serde(skip_serializing_if = "Option::is_none", default)]
233 pub memory_bytes: Option<u64>,
234
235 #[serde(skip_serializing_if = "Option::is_none", default)]
238 pub hostname_hash: Option<String>,
239}
240
241#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
249#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
250#[serde(rename_all = "snake_case")]
251pub enum HostMismatchPolicy {
252 #[default]
254 Warn,
255 Error,
257 Ignore,
259}
260
261impl HostMismatchPolicy {
262 pub fn as_str(self) -> &'static str {
273 match self {
274 HostMismatchPolicy::Warn => "warn",
275 HostMismatchPolicy::Error => "error",
276 HostMismatchPolicy::Ignore => "ignore",
277 }
278 }
279}
280
281#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct HostMismatchInfo {
284 pub reasons: Vec<String>,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
289#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
290pub struct RunMeta {
291 pub id: String,
292 pub started_at: String,
293 pub ended_at: String,
294 pub host: HostInfo,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
298#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
299pub struct BenchMeta {
300 pub name: String,
301
302 #[serde(skip_serializing_if = "Option::is_none")]
304 pub cwd: Option<String>,
305
306 pub command: Vec<String>,
308
309 pub repeat: u32,
310 pub warmup: u32,
311
312 #[serde(skip_serializing_if = "Option::is_none")]
313 pub work_units: Option<u64>,
314
315 #[serde(skip_serializing_if = "Option::is_none")]
316 pub timeout_ms: Option<u64>,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
320#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
321pub struct Sample {
322 pub wall_ms: u64,
323 pub exit_code: i32,
324
325 #[serde(default)]
326 pub warmup: bool,
327
328 #[serde(default)]
329 pub timed_out: bool,
330
331 #[serde(skip_serializing_if = "Option::is_none", default)]
333 pub cpu_ms: Option<u64>,
334
335 #[serde(skip_serializing_if = "Option::is_none", default)]
337 pub page_faults: Option<u64>,
338
339 #[serde(skip_serializing_if = "Option::is_none", default)]
341 pub ctx_switches: Option<u64>,
342 #[serde(skip_serializing_if = "Option::is_none", default)]
344 pub max_rss_kb: Option<u64>,
345
346 #[serde(skip_serializing_if = "Option::is_none", default)]
348 pub io_read_bytes: Option<u64>,
349
350 #[serde(skip_serializing_if = "Option::is_none", default)]
352 pub io_write_bytes: Option<u64>,
353
354 #[serde(skip_serializing_if = "Option::is_none", default)]
356 pub network_packets: Option<u64>,
357
358 #[serde(skip_serializing_if = "Option::is_none", default)]
360 pub energy_uj: Option<u64>,
361
362 #[serde(skip_serializing_if = "Option::is_none", default)]
364 pub binary_bytes: Option<u64>,
365
366 #[serde(skip_serializing_if = "Option::is_none", default)]
368 pub stdout: Option<String>,
369
370 #[serde(skip_serializing_if = "Option::is_none", default)]
372 pub stderr: Option<String>,
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
376#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
377pub struct U64Summary {
378 pub median: u64,
379 pub min: u64,
380 pub max: u64,
381 #[serde(skip_serializing_if = "Option::is_none", default)]
382 pub mean: Option<f64>,
383 #[serde(skip_serializing_if = "Option::is_none", default)]
384 pub stddev: Option<f64>,
385}
386
387impl U64Summary {
388 pub fn new(median: u64, min: u64, max: u64) -> Self {
389 Self {
390 median,
391 min,
392 max,
393 mean: None,
394 stddev: None,
395 }
396 }
397
398 pub fn cv(&self) -> Option<f64> {
401 match (self.mean, self.stddev) {
402 (Some(mean), Some(stddev)) if mean > 0.0 => Some(stddev / mean),
403 _ => None,
404 }
405 }
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
409#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
410pub struct F64Summary {
411 pub median: f64,
412 pub min: f64,
413 pub max: f64,
414 #[serde(skip_serializing_if = "Option::is_none", default)]
415 pub mean: Option<f64>,
416 #[serde(skip_serializing_if = "Option::is_none", default)]
417 pub stddev: Option<f64>,
418}
419
420impl F64Summary {
421 pub fn new(median: f64, min: f64, max: f64) -> Self {
422 Self {
423 median,
424 min,
425 max,
426 mean: None,
427 stddev: None,
428 }
429 }
430
431 pub fn cv(&self) -> Option<f64> {
434 match (self.mean, self.stddev) {
435 (Some(mean), Some(stddev)) if mean > 0.0 => Some(stddev / mean),
436 _ => None,
437 }
438 }
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
465#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
466pub struct Stats {
467 pub wall_ms: U64Summary,
468
469 #[serde(skip_serializing_if = "Option::is_none", default)]
471 pub cpu_ms: Option<U64Summary>,
472
473 #[serde(skip_serializing_if = "Option::is_none", default)]
475 pub page_faults: Option<U64Summary>,
476
477 #[serde(skip_serializing_if = "Option::is_none", default)]
479 pub ctx_switches: Option<U64Summary>,
480
481 #[serde(skip_serializing_if = "Option::is_none", default)]
482 pub max_rss_kb: Option<U64Summary>,
483
484 #[serde(skip_serializing_if = "Option::is_none", default)]
486 pub io_read_bytes: Option<U64Summary>,
487
488 #[serde(skip_serializing_if = "Option::is_none", default)]
490 pub io_write_bytes: Option<U64Summary>,
491
492 #[serde(skip_serializing_if = "Option::is_none", default)]
494 pub network_packets: Option<U64Summary>,
495
496 #[serde(skip_serializing_if = "Option::is_none", default)]
498 pub energy_uj: Option<U64Summary>,
499
500 #[serde(skip_serializing_if = "Option::is_none", default)]
502 pub binary_bytes: Option<U64Summary>,
503
504 #[serde(skip_serializing_if = "Option::is_none", default)]
505 pub throughput_per_s: Option<F64Summary>,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
546#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
547pub struct RunReceipt {
548 pub schema: String,
549 pub tool: ToolInfo,
550 pub run: RunMeta,
551 pub bench: BenchMeta,
552 pub samples: Vec<Sample>,
553 pub stats: Stats,
554}
555
556#[derive(
557 Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord, Hash,
558)]
559#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
560#[serde(rename_all = "snake_case")]
561pub enum Metric {
562 BinaryBytes,
563 CpuMs,
564 CtxSwitches,
565 EnergyUj,
566 IoReadBytes,
567 IoWriteBytes,
568 MaxRssKb,
569 NetworkPackets,
570 PageFaults,
571 ThroughputPerS,
572 WallMs,
573}
574
575impl Metric {
576 pub fn as_str(self) -> &'static str {
587 match self {
588 Metric::BinaryBytes => "binary_bytes",
589 Metric::CpuMs => "cpu_ms",
590 Metric::CtxSwitches => "ctx_switches",
591 Metric::EnergyUj => "energy_uj",
592 Metric::IoReadBytes => "io_read_bytes",
593 Metric::IoWriteBytes => "io_write_bytes",
594 Metric::MaxRssKb => "max_rss_kb",
595 Metric::NetworkPackets => "network_packets",
596 Metric::PageFaults => "page_faults",
597 Metric::ThroughputPerS => "throughput_per_s",
598 Metric::WallMs => "wall_ms",
599 }
600 }
601
602 pub fn parse_key(key: &str) -> Option<Self> {
614 match key {
615 "binary_bytes" => Some(Metric::BinaryBytes),
616 "cpu_ms" => Some(Metric::CpuMs),
617 "ctx_switches" => Some(Metric::CtxSwitches),
618 "energy_uj" => Some(Metric::EnergyUj),
619 "io_read_bytes" => Some(Metric::IoReadBytes),
620 "io_write_bytes" => Some(Metric::IoWriteBytes),
621 "max_rss_kb" => Some(Metric::MaxRssKb),
622 "network_packets" => Some(Metric::NetworkPackets),
623 "page_faults" => Some(Metric::PageFaults),
624 "throughput_per_s" => Some(Metric::ThroughputPerS),
625 "wall_ms" => Some(Metric::WallMs),
626 _ => None,
627 }
628 }
629
630 pub fn default_direction(self) -> Direction {
644 match self {
645 Metric::BinaryBytes => Direction::Lower,
646 Metric::CpuMs => Direction::Lower,
647 Metric::CtxSwitches => Direction::Lower,
648 Metric::EnergyUj => Direction::Lower,
649 Metric::IoReadBytes => Direction::Lower,
650 Metric::IoWriteBytes => Direction::Lower,
651 Metric::MaxRssKb => Direction::Lower,
652 Metric::NetworkPackets => Direction::Lower,
653 Metric::PageFaults => Direction::Lower,
654 Metric::ThroughputPerS => Direction::Higher,
655 Metric::WallMs => Direction::Lower,
656 }
657 }
658
659 pub fn default_warn_factor(self) -> f64 {
660 0.9
662 }
663
664 pub fn display_unit(self) -> &'static str {
676 match self {
677 Metric::BinaryBytes => "bytes",
678 Metric::CpuMs => "ms",
679 Metric::CtxSwitches => "count",
680 Metric::EnergyUj => "uj",
681 Metric::IoReadBytes => "bytes",
682 Metric::IoWriteBytes => "bytes",
683 Metric::MaxRssKb => "KB",
684 Metric::NetworkPackets => "count",
685 Metric::PageFaults => "count",
686 Metric::ThroughputPerS => "/s",
687 Metric::WallMs => "ms",
688 }
689 }
690}
691
692#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
693#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
694#[serde(rename_all = "snake_case")]
695pub enum MetricStatistic {
696 #[default]
697 Median,
698 P95,
699}
700
701impl MetricStatistic {
702 pub fn as_str(self) -> &'static str {
713 match self {
714 MetricStatistic::Median => "median",
715 MetricStatistic::P95 => "p95",
716 }
717 }
718}
719
720fn is_default_metric_statistic(stat: &MetricStatistic) -> bool {
721 *stat == MetricStatistic::Median
722}
723
724#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
725#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
726#[serde(rename_all = "snake_case")]
727pub enum Direction {
728 Lower,
729 Higher,
730}
731
732#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
733#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
734pub struct Budget {
735 pub threshold: f64,
737
738 pub warn_threshold: f64,
740 #[serde(skip_serializing_if = "Option::is_none", default)]
743 pub noise_threshold: Option<f64>,
744
745 #[serde(default, skip_serializing_if = "is_default_noise_policy")]
747 pub noise_policy: NoisePolicy,
748
749 pub direction: Direction,
751}
752
753fn is_default_noise_policy(policy: &NoisePolicy) -> bool {
754 *policy == NoisePolicy::Ignore
755}
756
757impl Budget {
758 pub fn new(threshold: f64, warn_threshold: f64, direction: Direction) -> Self {
760 Self {
761 threshold,
762 warn_threshold,
763 noise_threshold: None,
764 noise_policy: NoisePolicy::Ignore,
765 direction,
766 }
767 }
768}
769
770#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
771#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
772#[serde(rename_all = "snake_case")]
773pub enum SignificanceTest {
774 WelchT,
775}
776
777#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
778#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
779pub struct Significance {
780 pub test: SignificanceTest,
781 pub p_value: Option<f64>,
782 pub alpha: f64,
783 pub significant: bool,
784 pub baseline_samples: u32,
785 pub current_samples: u32,
786 #[serde(skip_serializing_if = "Option::is_none")]
787 pub ci_lower: Option<f64>,
788 #[serde(skip_serializing_if = "Option::is_none")]
789 pub ci_upper: Option<f64>,
790}
791
792#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
794#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
795pub struct SignificancePolicy {
796 pub alpha: Option<f64>,
798 pub min_samples: Option<u32>,
800}
801
802#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
803#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
804#[serde(rename_all = "snake_case")]
805pub enum NoisePolicy {
806 #[default]
808 Ignore,
809 Warn,
811 Skip,
813}
814
815impl NoisePolicy {
816 pub fn as_str(self) -> &'static str {
817 match self {
818 NoisePolicy::Ignore => "ignore",
819 NoisePolicy::Warn => "warn",
820 NoisePolicy::Skip => "skip",
821 }
822 }
823}
824
825#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
826#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
827#[serde(rename_all = "snake_case")]
828pub enum MetricStatus {
829 Pass,
830 Warn,
831 Fail,
832 Skip,
833}
834
835impl MetricStatus {
836 pub fn as_str(self) -> &'static str {
848 match self {
849 MetricStatus::Pass => "pass",
850 MetricStatus::Warn => "warn",
851 MetricStatus::Fail => "fail",
852 MetricStatus::Skip => "skip",
853 }
854 }
855}
856
857#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
858#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
859pub struct Delta {
860 pub baseline: f64,
861 pub current: f64,
862
863 pub ratio: f64,
865
866 pub pct: f64,
868
869 pub regression: f64,
871
872 #[serde(skip_serializing_if = "Option::is_none")]
874 pub cv: Option<f64>,
875
876 #[serde(skip_serializing_if = "Option::is_none")]
878 pub noise_threshold: Option<f64>,
879
880 #[serde(default, skip_serializing_if = "is_default_metric_statistic")]
881 pub statistic: MetricStatistic,
882
883 #[serde(skip_serializing_if = "Option::is_none")]
884 pub significance: Option<Significance>,
885
886 pub status: MetricStatus,
887}
888
889#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
890#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
891pub struct CompareRef {
892 #[serde(skip_serializing_if = "Option::is_none")]
893 pub path: Option<String>,
894
895 #[serde(skip_serializing_if = "Option::is_none")]
896 pub run_id: Option<String>,
897}
898
899#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
900#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
901#[serde(rename_all = "snake_case")]
902pub enum VerdictStatus {
903 Pass,
904 Warn,
905 Fail,
906 Skip,
907}
908
909impl VerdictStatus {
910 pub fn as_str(self) -> &'static str {
911 match self {
912 VerdictStatus::Pass => "pass",
913 VerdictStatus::Warn => "warn",
914 VerdictStatus::Fail => "fail",
915 VerdictStatus::Skip => "skip",
916 }
917 }
918}
919
920#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
921#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
922pub struct VerdictCounts {
923 pub pass: u32,
924 pub warn: u32,
925 pub fail: u32,
926 pub skip: u32,
927}
928
929#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
952#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
953pub struct Verdict {
954 pub status: VerdictStatus,
955 pub counts: VerdictCounts,
956 pub reasons: Vec<String>,
957}
958
959#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
988#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
989pub struct CompareReceipt {
990 pub schema: String,
991 pub tool: ToolInfo,
992
993 pub bench: BenchMeta,
994
995 pub baseline_ref: CompareRef,
996 pub current_ref: CompareRef,
997
998 pub budgets: BTreeMap<Metric, Budget>,
999 pub deltas: BTreeMap<Metric, Delta>,
1000
1001 pub verdict: Verdict,
1002}
1003
1004#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1010#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1011#[serde(rename_all = "snake_case")]
1012pub enum Severity {
1013 Warn,
1014 Fail,
1015}
1016
1017#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1019#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1020pub struct FindingData {
1021 #[serde(rename = "metric_name")]
1023 pub metric_name: String,
1024
1025 pub baseline: f64,
1027
1028 pub current: f64,
1030
1031 #[serde(rename = "regression_pct")]
1033 pub regression_pct: f64,
1034
1035 pub threshold: f64,
1037
1038 pub direction: Direction,
1040}
1041
1042#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1044#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1045pub struct ReportFinding {
1046 #[serde(rename = "check_id")]
1048 pub check_id: String,
1049
1050 pub code: String,
1052
1053 pub severity: Severity,
1055
1056 pub message: String,
1058
1059 #[serde(skip_serializing_if = "Option::is_none")]
1061 pub data: Option<FindingData>,
1062}
1063
1064#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1066#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1067pub struct ReportSummary {
1068 #[serde(rename = "pass_count")]
1070 pub pass_count: u32,
1071
1072 #[serde(rename = "warn_count")]
1074 pub warn_count: u32,
1075
1076 #[serde(rename = "fail_count")]
1078 pub fail_count: u32,
1079
1080 #[serde(rename = "skip_count", default, skip_serializing_if = "is_zero_u32")]
1082 pub skip_count: u32,
1083
1084 #[serde(rename = "total_count")]
1086 pub total_count: u32,
1087}
1088
1089fn is_zero_u32(n: &u32) -> bool {
1090 *n == 0
1091}
1092
1093#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1095#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1096pub struct PerfgateReport {
1097 #[serde(rename = "report_type")]
1099 pub report_type: String,
1100
1101 pub verdict: Verdict,
1103
1104 #[serde(skip_serializing_if = "Option::is_none")]
1106 pub compare: Option<CompareReceipt>,
1107
1108 pub findings: Vec<ReportFinding>,
1110
1111 pub summary: ReportSummary,
1113}
1114
1115#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
1142#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1143pub struct ConfigFile {
1144 #[serde(default)]
1145 pub defaults: DefaultsConfig,
1146
1147 #[serde(default)]
1149 pub baseline_server: BaselineServerConfig,
1150
1151 #[serde(default, rename = "bench")]
1152 pub benches: Vec<BenchConfigFile>,
1153}
1154
1155impl ConfigFile {
1156 pub fn validate(&self) -> Result<(), String> {
1178 for bench in &self.benches {
1179 validate_bench_name(&bench.name).map_err(|e| e.to_string())?;
1180 }
1181 Ok(())
1182 }
1183}
1184
1185#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
1200#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1201pub struct BaselineServerConfig {
1202 #[serde(skip_serializing_if = "Option::is_none", default)]
1204 pub url: Option<String>,
1205
1206 #[serde(skip_serializing_if = "Option::is_none", default)]
1208 pub api_key: Option<String>,
1209
1210 #[serde(skip_serializing_if = "Option::is_none", default)]
1212 pub project: Option<String>,
1213
1214 #[serde(default = "default_fallback_to_local")]
1216 pub fallback_to_local: bool,
1217}
1218
1219fn default_fallback_to_local() -> bool {
1220 true
1221}
1222
1223impl BaselineServerConfig {
1224 pub fn is_configured(&self) -> bool {
1226 self.url.is_some() && !self.url.as_ref().unwrap().is_empty()
1227 }
1228
1229 pub fn resolved_url(&self) -> Option<String> {
1231 std::env::var("PERFGATE_SERVER_URL")
1232 .ok()
1233 .or_else(|| self.url.clone())
1234 }
1235
1236 pub fn resolved_api_key(&self) -> Option<String> {
1238 std::env::var("PERFGATE_API_KEY")
1239 .ok()
1240 .or_else(|| self.api_key.clone())
1241 }
1242
1243 pub fn resolved_project(&self) -> Option<String> {
1245 std::env::var("PERFGATE_PROJECT")
1246 .ok()
1247 .or_else(|| self.project.clone())
1248 }
1249}
1250
1251#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1252#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1253pub struct BenchConfigFile {
1254 pub name: String,
1255
1256 #[serde(skip_serializing_if = "Option::is_none")]
1257 pub cwd: Option<String>,
1258
1259 #[serde(skip_serializing_if = "Option::is_none")]
1260 pub work: Option<u64>,
1261
1262 #[serde(skip_serializing_if = "Option::is_none")]
1264 pub timeout: Option<String>,
1265
1266 pub command: Vec<String>,
1268
1269 #[serde(skip_serializing_if = "Option::is_none")]
1271 pub repeat: Option<u32>,
1272
1273 #[serde(skip_serializing_if = "Option::is_none")]
1275 pub warmup: Option<u32>,
1276
1277 #[serde(skip_serializing_if = "Option::is_none")]
1278 pub metrics: Option<Vec<Metric>>,
1279
1280 #[serde(skip_serializing_if = "Option::is_none")]
1281 pub budgets: Option<BTreeMap<Metric, BudgetOverride>>,
1282}
1283
1284#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
1285#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1286pub struct BudgetOverride {
1287 #[serde(skip_serializing_if = "Option::is_none")]
1288 pub threshold: Option<f64>,
1289
1290 #[serde(skip_serializing_if = "Option::is_none")]
1291 pub direction: Option<Direction>,
1292
1293 #[serde(skip_serializing_if = "Option::is_none")]
1295 pub warn_factor: Option<f64>,
1296
1297 #[serde(skip_serializing_if = "Option::is_none")]
1298 pub noise_threshold: Option<f64>,
1299
1300 #[serde(skip_serializing_if = "Option::is_none")]
1301 pub noise_policy: Option<NoisePolicy>,
1302
1303 #[serde(skip_serializing_if = "Option::is_none")]
1304 pub statistic: Option<MetricStatistic>,
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309 use super::*;
1310
1311 #[test]
1312 fn metric_serde_keys_are_snake_case() {
1313 let mut m = BTreeMap::new();
1314 m.insert(Metric::WallMs, Budget::new(0.2, 0.18, Direction::Lower));
1315 let json = serde_json::to_string(&m).unwrap();
1316 assert!(json.contains("\"wall_ms\""));
1317 }
1318
1319 #[test]
1320 fn metric_metadata_and_parsing_are_consistent() {
1321 let cases = [
1322 (
1323 Metric::BinaryBytes,
1324 "binary_bytes",
1325 Direction::Lower,
1326 "bytes",
1327 ),
1328 (Metric::WallMs, "wall_ms", Direction::Lower, "ms"),
1329 (Metric::CpuMs, "cpu_ms", Direction::Lower, "ms"),
1330 (
1331 Metric::CtxSwitches,
1332 "ctx_switches",
1333 Direction::Lower,
1334 "count",
1335 ),
1336 (Metric::MaxRssKb, "max_rss_kb", Direction::Lower, "KB"),
1337 (Metric::PageFaults, "page_faults", Direction::Lower, "count"),
1338 (
1339 Metric::ThroughputPerS,
1340 "throughput_per_s",
1341 Direction::Higher,
1342 "/s",
1343 ),
1344 ];
1345
1346 for (metric, key, direction, unit) in cases {
1347 assert_eq!(metric.as_str(), key);
1348 assert_eq!(Metric::parse_key(key), Some(metric));
1349 assert_eq!(metric.default_direction(), direction);
1350 assert_eq!(metric.display_unit(), unit);
1351 assert!((metric.default_warn_factor() - 0.9).abs() < f64::EPSILON);
1352 }
1353
1354 assert!(Metric::parse_key("unknown").is_none());
1355
1356 assert_eq!(MetricStatistic::Median.as_str(), "median");
1357 assert_eq!(MetricStatistic::P95.as_str(), "p95");
1358 assert!(is_default_metric_statistic(&MetricStatistic::Median));
1359 assert!(!is_default_metric_statistic(&MetricStatistic::P95));
1360 }
1361
1362 #[test]
1363 fn status_and_policy_as_str_values() {
1364 assert_eq!(MetricStatus::Pass.as_str(), "pass");
1365 assert_eq!(MetricStatus::Warn.as_str(), "warn");
1366 assert_eq!(MetricStatus::Fail.as_str(), "fail");
1367
1368 assert_eq!(HostMismatchPolicy::Warn.as_str(), "warn");
1369 assert_eq!(HostMismatchPolicy::Error.as_str(), "error");
1370 assert_eq!(HostMismatchPolicy::Ignore.as_str(), "ignore");
1371 }
1372
1373 #[test]
1375 fn backward_compat_host_info_without_new_fields() {
1376 let json = r#"{"os":"linux","arch":"x86_64"}"#;
1378 let info: HostInfo = serde_json::from_str(json).expect("should parse old format");
1379 assert_eq!(info.os, "linux");
1380 assert_eq!(info.arch, "x86_64");
1381 assert!(info.cpu_count.is_none());
1382 assert!(info.memory_bytes.is_none());
1383 assert!(info.hostname_hash.is_none());
1384 }
1385
1386 #[test]
1387 fn host_info_minimal_json_snapshot() {
1388 let info = HostInfo {
1389 os: "linux".to_string(),
1390 arch: "x86_64".to_string(),
1391 cpu_count: None,
1392 memory_bytes: None,
1393 hostname_hash: None,
1394 };
1395
1396 let value = serde_json::to_value(&info).expect("serialize HostInfo");
1397 insta::assert_json_snapshot!(value, @r###"
1398 {
1399 "arch": "x86_64",
1400 "os": "linux"
1401 }
1402 "###);
1403 }
1404
1405 #[test]
1407 fn host_info_with_new_fields_serializes() {
1408 let info = HostInfo {
1409 os: "linux".to_string(),
1410 arch: "x86_64".to_string(),
1411 cpu_count: Some(8),
1412 memory_bytes: Some(16 * 1024 * 1024 * 1024),
1413 hostname_hash: Some("abc123".to_string()),
1414 };
1415
1416 let json = serde_json::to_string(&info).expect("should serialize");
1417 assert!(json.contains("\"cpu_count\":8"));
1418 assert!(json.contains("\"memory_bytes\":"));
1419 assert!(json.contains("\"hostname_hash\":\"abc123\""));
1420 }
1421
1422 #[test]
1424 fn host_info_omits_none_fields() {
1425 let info = HostInfo {
1426 os: "linux".to_string(),
1427 arch: "x86_64".to_string(),
1428 cpu_count: None,
1429 memory_bytes: None,
1430 hostname_hash: None,
1431 };
1432
1433 let json = serde_json::to_string(&info).expect("should serialize");
1434 assert!(!json.contains("cpu_count"));
1435 assert!(!json.contains("memory_bytes"));
1436 assert!(!json.contains("hostname_hash"));
1437 }
1438
1439 #[test]
1441 fn host_info_round_trip_with_all_fields() {
1442 let original = HostInfo {
1443 os: "macos".to_string(),
1444 arch: "aarch64".to_string(),
1445 cpu_count: Some(10),
1446 memory_bytes: Some(32 * 1024 * 1024 * 1024),
1447 hostname_hash: Some("deadbeef".repeat(8)),
1448 };
1449
1450 let json = serde_json::to_string(&original).expect("should serialize");
1451 let parsed: HostInfo = serde_json::from_str(&json).expect("should deserialize");
1452
1453 assert_eq!(original, parsed);
1454 }
1455
1456 #[test]
1457 fn validate_bench_name_valid() {
1458 assert!(validate_bench_name("my-bench").is_ok());
1459 assert!(validate_bench_name("bench_a").is_ok());
1460 assert!(validate_bench_name("path/to/bench").is_ok());
1461 assert!(validate_bench_name("bench.v2").is_ok());
1462 assert!(validate_bench_name("a").is_ok());
1463 assert!(validate_bench_name("123").is_ok());
1464 }
1465
1466 #[test]
1467 fn validate_bench_name_invalid() {
1468 assert!(validate_bench_name("bench|name").is_err());
1469 assert!(validate_bench_name("").is_err());
1470 assert!(validate_bench_name("bench name").is_err());
1471 assert!(validate_bench_name("bench@name").is_err());
1472 }
1473
1474 #[test]
1475 fn validate_bench_name_path_traversal() {
1476 assert!(validate_bench_name("../bench").is_err());
1477 assert!(validate_bench_name("bench/../x").is_err());
1478 assert!(validate_bench_name("./bench").is_err());
1479 assert!(validate_bench_name("bench/.").is_err());
1480 }
1481
1482 #[test]
1483 fn validate_bench_name_empty_segments() {
1484 assert!(validate_bench_name("/bench").is_err());
1485 assert!(validate_bench_name("bench/").is_err());
1486 assert!(validate_bench_name("bench//x").is_err());
1487 assert!(validate_bench_name("/").is_err());
1488 }
1489
1490 #[test]
1491 fn validate_bench_name_length_cap() {
1492 let name_64 = "a".repeat(BENCH_NAME_MAX_LEN);
1494 assert!(validate_bench_name(&name_64).is_ok());
1495
1496 let name_65 = "a".repeat(BENCH_NAME_MAX_LEN + 1);
1498 assert!(validate_bench_name(&name_65).is_err());
1499 }
1500
1501 #[test]
1502 fn validate_bench_name_case() {
1503 assert!(validate_bench_name("MyBench").is_err());
1504 assert!(validate_bench_name("BENCH").is_err());
1505 assert!(validate_bench_name("benchA").is_err());
1506 }
1507
1508 #[test]
1509 fn config_file_validate_catches_bad_bench_name() {
1510 let config = ConfigFile {
1511 defaults: DefaultsConfig::default(),
1512 baseline_server: BaselineServerConfig::default(),
1513 benches: vec![BenchConfigFile {
1514 name: "bad|name".to_string(),
1515 cwd: None,
1516 work: None,
1517 timeout: None,
1518 command: vec!["echo".to_string()],
1519 repeat: None,
1520 warmup: None,
1521 metrics: None,
1522 budgets: None,
1523 }],
1524 };
1525 assert!(config.validate().is_err());
1526 }
1527
1528 #[test]
1529 fn perfgate_error_display_baseline_resolve() {
1530 use perfgate_error::IoError;
1531 let err = PerfgateError::Io(IoError::BaselineResolve("file not found".to_string()));
1532 assert_eq!(format!("{}", err), "baseline resolve: file not found");
1533 }
1534
1535 #[test]
1536 fn perfgate_error_display_artifact_write() {
1537 use perfgate_error::IoError;
1538 let err = PerfgateError::Io(IoError::ArtifactWrite("permission denied".to_string()));
1539 assert_eq!(format!("{}", err), "write artifacts: permission denied");
1540 }
1541
1542 #[test]
1543 fn perfgate_error_display_run_command() {
1544 use perfgate_error::IoError;
1545 let err = PerfgateError::Io(IoError::RunCommand {
1546 command: "echo".to_string(),
1547 reason: "spawn failed".to_string(),
1548 });
1549 assert_eq!(
1550 format!("{}", err),
1551 "failed to execute command \"echo\": spawn failed"
1552 );
1553 }
1554
1555 #[test]
1556 fn sensor_capabilities_backward_compat_without_engine() {
1557 let json = r#"{"baseline":{"status":"available"}}"#;
1558 let caps: SensorCapabilities =
1559 serde_json::from_str(json).expect("should parse without engine");
1560 assert_eq!(caps.baseline.status, CapabilityStatus::Available);
1561 assert!(caps.engine.is_none());
1562 }
1563
1564 #[test]
1565 fn sensor_capabilities_with_engine() {
1566 let caps = SensorCapabilities {
1567 baseline: Capability {
1568 status: CapabilityStatus::Available,
1569 reason: None,
1570 },
1571 engine: Some(Capability {
1572 status: CapabilityStatus::Available,
1573 reason: None,
1574 }),
1575 };
1576 let json = serde_json::to_string(&caps).unwrap();
1577 assert!(json.contains("\"engine\""));
1578 let parsed: SensorCapabilities = serde_json::from_str(&json).unwrap();
1579 assert_eq!(caps, parsed);
1580 }
1581
1582 #[test]
1583 fn sensor_capabilities_engine_omitted_when_none() {
1584 let caps = SensorCapabilities {
1585 baseline: Capability {
1586 status: CapabilityStatus::Available,
1587 reason: None,
1588 },
1589 engine: None,
1590 };
1591 let json = serde_json::to_string(&caps).unwrap();
1592 assert!(!json.contains("engine"));
1593 }
1594
1595 #[test]
1596 fn config_file_validate_passes_good_bench_names() {
1597 let config = ConfigFile {
1598 defaults: DefaultsConfig::default(),
1599 baseline_server: BaselineServerConfig::default(),
1600 benches: vec![BenchConfigFile {
1601 name: "my-bench".to_string(),
1602 cwd: None,
1603 work: None,
1604 timeout: None,
1605 command: vec!["echo".to_string()],
1606 repeat: None,
1607 warmup: None,
1608 metrics: None,
1609 budgets: None,
1610 }],
1611 };
1612 assert!(config.validate().is_ok());
1613 }
1614
1615 #[test]
1618 fn run_receipt_serde_roundtrip_typical() {
1619 let receipt = RunReceipt {
1620 schema: RUN_SCHEMA_V1.to_string(),
1621 tool: ToolInfo {
1622 name: "perfgate".into(),
1623 version: "1.2.3".into(),
1624 },
1625 run: RunMeta {
1626 id: "abc-123".into(),
1627 started_at: "2024-06-15T10:00:00Z".into(),
1628 ended_at: "2024-06-15T10:00:05Z".into(),
1629 host: HostInfo {
1630 os: "linux".into(),
1631 arch: "x86_64".into(),
1632 cpu_count: Some(8),
1633 memory_bytes: Some(16_000_000_000),
1634 hostname_hash: Some("cafebabe".into()),
1635 },
1636 },
1637 bench: BenchMeta {
1638 name: "my-bench".into(),
1639 cwd: Some("/tmp".into()),
1640 command: vec!["echo".into(), "hello".into()],
1641 repeat: 5,
1642 warmup: 1,
1643 work_units: Some(1000),
1644 timeout_ms: Some(30000),
1645 },
1646 samples: vec![
1647 Sample {
1648 wall_ms: 100,
1649 exit_code: 0,
1650 warmup: true,
1651 timed_out: false,
1652 cpu_ms: Some(80),
1653 page_faults: Some(10),
1654 ctx_switches: Some(5),
1655 max_rss_kb: Some(2048),
1656 io_read_bytes: None,
1657 io_write_bytes: None,
1658 network_packets: None,
1659 energy_uj: None,
1660 binary_bytes: Some(4096),
1661 stdout: Some("ok".into()),
1662 stderr: None,
1663 },
1664 Sample {
1665 wall_ms: 95,
1666 exit_code: 0,
1667 warmup: false,
1668 timed_out: false,
1669 cpu_ms: Some(75),
1670 page_faults: None,
1671 ctx_switches: None,
1672 max_rss_kb: Some(2000),
1673 io_read_bytes: None,
1674 io_write_bytes: None,
1675 network_packets: None,
1676 energy_uj: None,
1677 binary_bytes: None,
1678 stdout: None,
1679 stderr: Some("warn".into()),
1680 },
1681 ],
1682 stats: Stats {
1683 wall_ms: U64Summary::new(95, 90, 100),
1684 cpu_ms: Some(U64Summary::new(75, 70, 80)),
1685 page_faults: Some(U64Summary::new(10, 10, 10)),
1686 ctx_switches: Some(U64Summary::new(5, 5, 5)),
1687 max_rss_kb: Some(U64Summary::new(2048, 2000, 2100)),
1688 io_read_bytes: None,
1689 io_write_bytes: None,
1690 network_packets: None,
1691 energy_uj: None,
1692 binary_bytes: Some(U64Summary::new(4096, 4096, 4096)),
1693 throughput_per_s: Some(F64Summary::new(10.526, 10.0, 11.111)),
1694 },
1695 };
1696 let json = serde_json::to_string(&receipt).unwrap();
1697 let back: RunReceipt = serde_json::from_str(&json).unwrap();
1698 assert_eq!(receipt, back);
1699 }
1700
1701 #[test]
1702 fn run_receipt_serde_roundtrip_edge_empty_samples() {
1703 let receipt = RunReceipt {
1704 schema: RUN_SCHEMA_V1.to_string(),
1705 tool: ToolInfo {
1706 name: "p".into(),
1707 version: "0".into(),
1708 },
1709 run: RunMeta {
1710 id: "".into(),
1711 started_at: "".into(),
1712 ended_at: "".into(),
1713 host: HostInfo {
1714 os: "".into(),
1715 arch: "".into(),
1716 cpu_count: None,
1717 memory_bytes: None,
1718 hostname_hash: None,
1719 },
1720 },
1721 bench: BenchMeta {
1722 name: "b".into(),
1723 cwd: None,
1724 command: vec![],
1725 repeat: 0,
1726 warmup: 0,
1727 work_units: None,
1728 timeout_ms: None,
1729 },
1730 samples: vec![],
1731 stats: Stats {
1732 wall_ms: U64Summary::new(0, 0, 0),
1733 cpu_ms: None,
1734 page_faults: None,
1735 ctx_switches: None,
1736 max_rss_kb: None,
1737 io_read_bytes: None,
1738 io_write_bytes: None,
1739 network_packets: None,
1740 energy_uj: None,
1741 binary_bytes: None,
1742 throughput_per_s: None,
1743 },
1744 };
1745 let json = serde_json::to_string(&receipt).unwrap();
1746 let back: RunReceipt = serde_json::from_str(&json).unwrap();
1747 assert_eq!(receipt, back);
1748 }
1749
1750 #[test]
1751 fn run_receipt_serde_roundtrip_edge_large_values() {
1752 let receipt = RunReceipt {
1753 schema: RUN_SCHEMA_V1.to_string(),
1754 tool: ToolInfo {
1755 name: "perfgate".into(),
1756 version: "99.99.99".into(),
1757 },
1758 run: RunMeta {
1759 id: "max-run".into(),
1760 started_at: "2099-12-31T23:59:59Z".into(),
1761 ended_at: "2099-12-31T23:59:59Z".into(),
1762 host: HostInfo {
1763 os: "linux".into(),
1764 arch: "aarch64".into(),
1765 cpu_count: Some(u32::MAX),
1766 memory_bytes: Some(u64::MAX),
1767 hostname_hash: None,
1768 },
1769 },
1770 bench: BenchMeta {
1771 name: "big".into(),
1772 cwd: None,
1773 command: vec!["run".into()],
1774 repeat: u32::MAX,
1775 warmup: u32::MAX,
1776 work_units: Some(u64::MAX),
1777 timeout_ms: Some(u64::MAX),
1778 },
1779 samples: vec![Sample {
1780 wall_ms: u64::MAX,
1781 exit_code: i32::MIN,
1782 warmup: false,
1783 timed_out: true,
1784 cpu_ms: Some(u64::MAX),
1785 page_faults: Some(u64::MAX),
1786 ctx_switches: Some(u64::MAX),
1787 max_rss_kb: Some(u64::MAX),
1788 io_read_bytes: None,
1789 io_write_bytes: None,
1790 network_packets: None,
1791 energy_uj: None,
1792 binary_bytes: Some(u64::MAX),
1793 stdout: None,
1794 stderr: None,
1795 }],
1796 stats: Stats {
1797 wall_ms: U64Summary::new(u64::MAX, 0, u64::MAX),
1798 cpu_ms: None,
1799 page_faults: None,
1800 ctx_switches: None,
1801 max_rss_kb: None,
1802 io_read_bytes: None,
1803 io_write_bytes: None,
1804 network_packets: None,
1805 energy_uj: None,
1806 binary_bytes: None,
1807 throughput_per_s: Some(F64Summary::new(f64::MAX, 0.0, f64::MAX)),
1808 },
1809 };
1810 let json = serde_json::to_string(&receipt).unwrap();
1811 let back: RunReceipt = serde_json::from_str(&json).unwrap();
1812 assert_eq!(receipt, back);
1813 }
1814
1815 #[test]
1816 fn compare_receipt_serde_roundtrip_typical() {
1817 let mut budgets = BTreeMap::new();
1818 budgets.insert(Metric::WallMs, Budget::new(0.2, 0.18, Direction::Lower));
1819 budgets.insert(Metric::MaxRssKb, Budget::new(0.15, 0.1, Direction::Lower));
1820
1821 let mut deltas = BTreeMap::new();
1822 deltas.insert(
1823 Metric::WallMs,
1824 Delta {
1825 baseline: 1000.0,
1826 current: 1100.0,
1827 ratio: 1.1,
1828 pct: 0.1,
1829 regression: 0.1,
1830 cv: None,
1831 noise_threshold: None,
1832 statistic: MetricStatistic::Median,
1833 significance: None,
1834 status: MetricStatus::Pass,
1835 },
1836 );
1837 deltas.insert(
1838 Metric::MaxRssKb,
1839 Delta {
1840 baseline: 2048.0,
1841 current: 2500.0,
1842 ratio: 1.2207,
1843 pct: 0.2207,
1844 regression: 0.2207,
1845 cv: None,
1846 noise_threshold: None,
1847 statistic: MetricStatistic::Median,
1848 significance: None,
1849 status: MetricStatus::Fail,
1850 },
1851 );
1852
1853 let receipt = CompareReceipt {
1854 schema: COMPARE_SCHEMA_V1.to_string(),
1855 tool: ToolInfo {
1856 name: "perfgate".into(),
1857 version: "1.0.0".into(),
1858 },
1859 bench: BenchMeta {
1860 name: "test".into(),
1861 cwd: None,
1862 command: vec!["echo".into()],
1863 repeat: 5,
1864 warmup: 0,
1865 work_units: None,
1866 timeout_ms: None,
1867 },
1868 baseline_ref: CompareRef {
1869 path: Some("base.json".into()),
1870 run_id: Some("r1".into()),
1871 },
1872 current_ref: CompareRef {
1873 path: Some("cur.json".into()),
1874 run_id: Some("r2".into()),
1875 },
1876 budgets,
1877 deltas,
1878 verdict: Verdict {
1879 status: VerdictStatus::Fail,
1880 counts: VerdictCounts {
1881 pass: 1,
1882 warn: 0,
1883 fail: 1,
1884 skip: 0,
1885 },
1886 reasons: vec!["max_rss_kb_fail".into()],
1887 },
1888 };
1889 let json = serde_json::to_string(&receipt).unwrap();
1890 let back: CompareReceipt = serde_json::from_str(&json).unwrap();
1891 assert_eq!(receipt, back);
1892 }
1893
1894 #[test]
1895 fn compare_receipt_serde_roundtrip_edge_empty_maps() {
1896 let receipt = CompareReceipt {
1897 schema: COMPARE_SCHEMA_V1.to_string(),
1898 tool: ToolInfo {
1899 name: "p".into(),
1900 version: "0".into(),
1901 },
1902 bench: BenchMeta {
1903 name: "b".into(),
1904 cwd: None,
1905 command: vec![],
1906 repeat: 0,
1907 warmup: 0,
1908 work_units: None,
1909 timeout_ms: None,
1910 },
1911 baseline_ref: CompareRef {
1912 path: None,
1913 run_id: None,
1914 },
1915 current_ref: CompareRef {
1916 path: None,
1917 run_id: None,
1918 },
1919 budgets: BTreeMap::new(),
1920 deltas: BTreeMap::new(),
1921 verdict: Verdict {
1922 status: VerdictStatus::Pass,
1923 counts: VerdictCounts {
1924 pass: 0,
1925 warn: 0,
1926 fail: 0,
1927 skip: 0,
1928 },
1929 reasons: vec![],
1930 },
1931 };
1932 let json = serde_json::to_string(&receipt).unwrap();
1933 let back: CompareReceipt = serde_json::from_str(&json).unwrap();
1934 assert_eq!(receipt, back);
1935 }
1936
1937 #[test]
1938 fn report_receipt_serde_roundtrip() {
1939 let report = PerfgateReport {
1940 report_type: REPORT_SCHEMA_V1.to_string(),
1941 verdict: Verdict {
1942 status: VerdictStatus::Warn,
1943 counts: VerdictCounts {
1944 pass: 1,
1945 warn: 1,
1946 fail: 0,
1947 skip: 0,
1948 },
1949 reasons: vec!["wall_ms_warn".into()],
1950 },
1951 compare: None,
1952 findings: vec![ReportFinding {
1953 check_id: CHECK_ID_BUDGET.into(),
1954 code: FINDING_CODE_METRIC_WARN.into(),
1955 severity: Severity::Warn,
1956 message: "Performance regression near threshold for wall_ms".into(),
1957 data: Some(FindingData {
1958 metric_name: "wall_ms".into(),
1959 baseline: 100.0,
1960 current: 119.0,
1961 regression_pct: 0.19,
1962 threshold: 0.2,
1963 direction: Direction::Lower,
1964 }),
1965 }],
1966 summary: ReportSummary {
1967 pass_count: 1,
1968 warn_count: 1,
1969 fail_count: 0,
1970 skip_count: 0,
1971 total_count: 2,
1972 },
1973 };
1974 let json = serde_json::to_string(&report).unwrap();
1975 let back: PerfgateReport = serde_json::from_str(&json).unwrap();
1976 assert_eq!(report, back);
1977 }
1978
1979 #[test]
1980 fn config_file_serde_roundtrip_typical() {
1981 let config = ConfigFile {
1982 defaults: DefaultsConfig {
1983 noise_threshold: None,
1984 noise_policy: None,
1985 repeat: Some(10),
1986 warmup: Some(2),
1987 threshold: Some(0.2),
1988 warn_factor: Some(0.9),
1989 out_dir: Some("artifacts/perfgate".into()),
1990 baseline_dir: Some("baselines".into()),
1991 baseline_pattern: Some("baselines/{bench}.json".into()),
1992 markdown_template: None,
1993 },
1994 baseline_server: BaselineServerConfig::default(),
1995 benches: vec![BenchConfigFile {
1996 name: "my-bench".into(),
1997 cwd: Some("/home/user/project".into()),
1998 work: Some(1000),
1999 timeout: Some("5s".into()),
2000 command: vec!["cargo".into(), "bench".into()],
2001 repeat: Some(20),
2002 warmup: Some(3),
2003 metrics: Some(vec![Metric::WallMs, Metric::MaxRssKb]),
2004 budgets: Some({
2005 let mut m = BTreeMap::new();
2006 m.insert(
2007 Metric::WallMs,
2008 BudgetOverride {
2009 noise_threshold: None,
2010 noise_policy: None,
2011 threshold: Some(0.15),
2012 direction: Some(Direction::Lower),
2013 warn_factor: Some(0.85),
2014 statistic: Some(MetricStatistic::P95),
2015 },
2016 );
2017 m
2018 }),
2019 }],
2020 };
2021 let json = serde_json::to_string(&config).unwrap();
2022 let back: ConfigFile = serde_json::from_str(&json).unwrap();
2023 assert_eq!(config, back);
2024 }
2025
2026 #[test]
2027 fn config_file_serde_roundtrip_edge_empty() {
2028 let config = ConfigFile {
2029 defaults: DefaultsConfig::default(),
2030 baseline_server: BaselineServerConfig::default(),
2031 benches: vec![],
2032 };
2033 let json = serde_json::to_string(&config).unwrap();
2034 let back: ConfigFile = serde_json::from_str(&json).unwrap();
2035 assert_eq!(config, back);
2036 }
2037
2038 #[test]
2039 fn stats_serde_roundtrip_all_fields() {
2040 let stats = Stats {
2041 wall_ms: U64Summary::new(500, 100, 900),
2042 cpu_ms: Some(U64Summary::new(400, 80, 800)),
2043 page_faults: Some(U64Summary::new(50, 10, 100)),
2044 ctx_switches: Some(U64Summary::new(20, 5, 40)),
2045 max_rss_kb: Some(U64Summary::new(4096, 2048, 8192)),
2046 io_read_bytes: Some(U64Summary::new(1000, 500, 1500)),
2047 io_write_bytes: Some(U64Summary::new(500, 200, 800)),
2048 network_packets: Some(U64Summary::new(10, 5, 15)),
2049 energy_uj: None,
2050 binary_bytes: Some(U64Summary::new(1024, 1024, 1024)),
2051 throughput_per_s: Some(F64Summary::new(2.0, 1.111, 10.0)),
2052 };
2053 let json = serde_json::to_string(&stats).unwrap();
2054 let back: Stats = serde_json::from_str(&json).unwrap();
2055 assert_eq!(stats, back);
2056 }
2057
2058 #[test]
2059 fn stats_serde_roundtrip_edge_zeros() {
2060 let stats = Stats {
2061 wall_ms: U64Summary::new(0, 0, 0),
2062 cpu_ms: None,
2063 page_faults: None,
2064 ctx_switches: None,
2065 max_rss_kb: None,
2066 io_read_bytes: None,
2067 io_write_bytes: None,
2068 network_packets: None,
2069 energy_uj: None,
2070 binary_bytes: None,
2071 throughput_per_s: Some(F64Summary::new(0.0, 0.0, 0.0)),
2072 };
2073 let json = serde_json::to_string(&stats).unwrap();
2074 let back: Stats = serde_json::from_str(&json).unwrap();
2075 assert_eq!(stats, back);
2076 }
2077
2078 #[test]
2079 fn backward_compat_run_receipt_missing_host_extensions() {
2080 let json = r#"{
2082 "schema": "perfgate.run.v1",
2083 "tool": {"name": "perfgate", "version": "0.0.1"},
2084 "run": {
2085 "id": "old-run",
2086 "started_at": "2023-06-01T00:00:00Z",
2087 "ended_at": "2023-06-01T00:01:00Z",
2088 "host": {"os": "macos", "arch": "aarch64"}
2089 },
2090 "bench": {
2091 "name": "legacy",
2092 "command": ["./bench"],
2093 "repeat": 1,
2094 "warmup": 0
2095 },
2096 "samples": [{"wall_ms": 50, "exit_code": 0}],
2097 "stats": {
2098 "wall_ms": {"median": 50, "min": 50, "max": 50}
2099 }
2100 }"#;
2101
2102 let receipt: RunReceipt =
2103 serde_json::from_str(json).expect("old format without host extensions");
2104 assert_eq!(receipt.run.host.os, "macos");
2105 assert_eq!(receipt.run.host.arch, "aarch64");
2106 assert!(receipt.run.host.cpu_count.is_none());
2107 assert!(receipt.run.host.memory_bytes.is_none());
2108 assert!(receipt.run.host.hostname_hash.is_none());
2109 assert_eq!(receipt.bench.name, "legacy");
2110 assert_eq!(receipt.samples.len(), 1);
2111 assert!(!receipt.samples[0].warmup);
2112 assert!(!receipt.samples[0].timed_out);
2113 }
2114
2115 #[test]
2116 fn backward_compat_compare_receipt_without_significance() {
2117 let json = r#"{
2118 "schema": "perfgate.compare.v1",
2119 "tool": {"name": "perfgate", "version": "0.0.1"},
2120 "bench": {
2121 "name": "old-cmp",
2122 "command": ["echo"],
2123 "repeat": 3,
2124 "warmup": 0
2125 },
2126 "baseline_ref": {"path": "base.json"},
2127 "current_ref": {"path": "cur.json"},
2128 "budgets": {
2129 "wall_ms": {"threshold": 0.2, "warn_threshold": 0.1, "direction": "lower"}
2130 },
2131 "deltas": {
2132 "wall_ms": {
2133 "baseline": 100.0,
2134 "current": 105.0,
2135 "ratio": 1.05,
2136 "pct": 0.05,
2137 "regression": 0.05,
2138 "status": "pass"
2139 }
2140 },
2141 "verdict": {
2142 "status": "pass",
2143 "counts": {"pass": 1, "warn": 0, "fail": 0, "skip": 0},
2144 "reasons": []
2145 }
2146 }"#;
2147
2148 let receipt: CompareReceipt =
2149 serde_json::from_str(json).expect("compare without significance");
2150 assert_eq!(receipt.deltas.len(), 1);
2151 let delta = receipt.deltas.get(&Metric::WallMs).unwrap();
2152 assert!(delta.significance.is_none());
2153 assert_eq!(delta.statistic, MetricStatistic::Median); assert_eq!(delta.status, MetricStatus::Pass);
2155 }
2156
2157 #[test]
2158 fn backward_compat_unknown_fields_are_ignored() {
2159 let json = r#"{
2160 "schema": "perfgate.run.v1",
2161 "tool": {"name": "perfgate", "version": "0.1.0"},
2162 "run": {
2163 "id": "test",
2164 "started_at": "2024-01-01T00:00:00Z",
2165 "ended_at": "2024-01-01T00:01:00Z",
2166 "host": {"os": "linux", "arch": "x86_64", "future_field": "ignored"}
2167 },
2168 "bench": {
2169 "name": "test",
2170 "command": ["echo"],
2171 "repeat": 1,
2172 "warmup": 0,
2173 "some_new_option": true
2174 },
2175 "samples": [{"wall_ms": 10, "exit_code": 0, "extra_metric": 42}],
2176 "stats": {
2177 "wall_ms": {"median": 10, "min": 10, "max": 10},
2178 "new_metric": {"median": 1, "min": 1, "max": 1}
2179 },
2180 "new_top_level_field": "should be ignored"
2181 }"#;
2182
2183 let receipt: RunReceipt =
2184 serde_json::from_str(json).expect("unknown fields should be ignored");
2185 assert_eq!(receipt.bench.name, "test");
2186 assert_eq!(receipt.samples.len(), 1);
2187 }
2188
2189 #[test]
2190 fn roundtrip_run_receipt_all_optionals_none() {
2191 let receipt = RunReceipt {
2192 schema: RUN_SCHEMA_V1.to_string(),
2193 tool: ToolInfo {
2194 name: "perfgate".into(),
2195 version: "0.1.0".into(),
2196 },
2197 run: RunMeta {
2198 id: "rt".into(),
2199 started_at: "2024-01-01T00:00:00Z".into(),
2200 ended_at: "2024-01-01T00:01:00Z".into(),
2201 host: HostInfo {
2202 os: "linux".into(),
2203 arch: "x86_64".into(),
2204 cpu_count: None,
2205 memory_bytes: None,
2206 hostname_hash: None,
2207 },
2208 },
2209 bench: BenchMeta {
2210 name: "minimal".into(),
2211 cwd: None,
2212 command: vec!["true".into()],
2213 repeat: 1,
2214 warmup: 0,
2215 work_units: None,
2216 timeout_ms: None,
2217 },
2218 samples: vec![Sample {
2219 wall_ms: 1,
2220 exit_code: 0,
2221 warmup: false,
2222 timed_out: false,
2223 cpu_ms: None,
2224 page_faults: None,
2225 ctx_switches: None,
2226 max_rss_kb: None,
2227 io_read_bytes: None,
2228 io_write_bytes: None,
2229 network_packets: None,
2230 energy_uj: None,
2231 binary_bytes: None,
2232 stdout: None,
2233 stderr: None,
2234 }],
2235 stats: Stats {
2236 wall_ms: U64Summary::new(1, 1, 1),
2237 cpu_ms: None,
2238 page_faults: None,
2239 ctx_switches: None,
2240 max_rss_kb: None,
2241 io_read_bytes: None,
2242 io_write_bytes: None,
2243 network_packets: None,
2244 energy_uj: None,
2245 binary_bytes: None,
2246 throughput_per_s: None,
2247 },
2248 };
2249
2250 let json = serde_json::to_string(&receipt).unwrap();
2251 let back: RunReceipt = serde_json::from_str(&json).unwrap();
2252 assert_eq!(receipt, back);
2253
2254 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
2256 let host = &value["run"]["host"];
2257 assert!(host.get("cpu_count").is_none());
2258 assert!(host.get("memory_bytes").is_none());
2259 assert!(host.get("hostname_hash").is_none());
2260 }
2261
2262 #[test]
2263 fn roundtrip_compare_receipt_all_optionals_none() {
2264 let receipt = CompareReceipt {
2265 schema: COMPARE_SCHEMA_V1.to_string(),
2266 tool: ToolInfo {
2267 name: "perfgate".into(),
2268 version: "0.1.0".into(),
2269 },
2270 bench: BenchMeta {
2271 name: "minimal".into(),
2272 cwd: None,
2273 command: vec!["true".into()],
2274 repeat: 1,
2275 warmup: 0,
2276 work_units: None,
2277 timeout_ms: None,
2278 },
2279 baseline_ref: CompareRef {
2280 path: None,
2281 run_id: None,
2282 },
2283 current_ref: CompareRef {
2284 path: None,
2285 run_id: None,
2286 },
2287 budgets: BTreeMap::new(),
2288 deltas: BTreeMap::new(),
2289 verdict: Verdict {
2290 status: VerdictStatus::Pass,
2291 counts: VerdictCounts {
2292 pass: 0,
2293 warn: 0,
2294 fail: 0,
2295 skip: 0,
2296 },
2297 reasons: vec![],
2298 },
2299 };
2300
2301 let json = serde_json::to_string(&receipt).unwrap();
2302 let back: CompareReceipt = serde_json::from_str(&json).unwrap();
2303 assert_eq!(receipt, back);
2304 }
2305
2306 #[test]
2308 fn backward_compat_run_receipt_old_format() {
2309 let json = r#"{
2310 "schema": "perfgate.run.v1",
2311 "tool": {"name": "perfgate", "version": "0.1.0"},
2312 "run": {
2313 "id": "test-id",
2314 "started_at": "2024-01-01T00:00:00Z",
2315 "ended_at": "2024-01-01T00:01:00Z",
2316 "host": {"os": "linux", "arch": "x86_64"}
2317 },
2318 "bench": {
2319 "name": "test",
2320 "command": ["echo", "hello"],
2321 "repeat": 5,
2322 "warmup": 0
2323 },
2324 "samples": [{"wall_ms": 100, "exit_code": 0}],
2325 "stats": {
2326 "wall_ms": {"median": 100, "min": 90, "max": 110}
2327 }
2328 }"#;
2329
2330 let receipt: RunReceipt = serde_json::from_str(json).expect("should parse old format");
2331 assert_eq!(receipt.run.host.os, "linux");
2332 assert_eq!(receipt.run.host.arch, "x86_64");
2333 assert!(receipt.run.host.cpu_count.is_none());
2334 assert!(receipt.run.host.memory_bytes.is_none());
2335 assert!(receipt.run.host.hostname_hash.is_none());
2336 }
2337}
2338
2339#[cfg(test)]
2340mod property_tests {
2341 use super::*;
2342 use proptest::prelude::*;
2343
2344 fn non_empty_string() -> impl Strategy<Value = String> {
2346 "[a-zA-Z0-9_-]{1,20}".prop_map(|s| s)
2347 }
2348
2349 fn rfc3339_timestamp() -> impl Strategy<Value = String> {
2351 (
2352 2020u32..2030,
2353 1u32..13,
2354 1u32..29,
2355 0u32..24,
2356 0u32..60,
2357 0u32..60,
2358 )
2359 .prop_map(|(year, month, day, hour, min, sec)| {
2360 format!(
2361 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
2362 year, month, day, hour, min, sec
2363 )
2364 })
2365 }
2366
2367 fn tool_info_strategy() -> impl Strategy<Value = ToolInfo> {
2369 (non_empty_string(), non_empty_string())
2370 .prop_map(|(name, version)| ToolInfo { name, version })
2371 }
2372
2373 fn host_info_strategy() -> impl Strategy<Value = HostInfo> {
2375 (
2376 non_empty_string(),
2377 non_empty_string(),
2378 proptest::option::of(1u32..256),
2379 proptest::option::of(1u64..68719476736), proptest::option::of("[a-f0-9]{64}"), )
2382 .prop_map(
2383 |(os, arch, cpu_count, memory_bytes, hostname_hash)| HostInfo {
2384 os,
2385 arch,
2386 cpu_count,
2387 memory_bytes,
2388 hostname_hash,
2389 },
2390 )
2391 }
2392
2393 fn run_meta_strategy() -> impl Strategy<Value = RunMeta> {
2395 (
2396 non_empty_string(),
2397 rfc3339_timestamp(),
2398 rfc3339_timestamp(),
2399 host_info_strategy(),
2400 )
2401 .prop_map(|(id, started_at, ended_at, host)| RunMeta {
2402 id,
2403 started_at,
2404 ended_at,
2405 host,
2406 })
2407 }
2408
2409 fn bench_meta_strategy() -> impl Strategy<Value = BenchMeta> {
2411 (
2412 non_empty_string(),
2413 proptest::option::of(non_empty_string()),
2414 proptest::collection::vec(non_empty_string(), 1..5),
2415 1u32..100,
2416 0u32..10,
2417 proptest::option::of(1u64..10000),
2418 proptest::option::of(100u64..60000),
2419 )
2420 .prop_map(
2421 |(name, cwd, command, repeat, warmup, work_units, timeout_ms)| BenchMeta {
2422 name,
2423 cwd,
2424 command,
2425 repeat,
2426 warmup,
2427 work_units,
2428 timeout_ms,
2429 },
2430 )
2431 }
2432
2433 fn sample_strategy() -> impl Strategy<Value = Sample> {
2435 (
2436 0u64..100000,
2437 -128i32..128,
2438 any::<bool>(),
2439 any::<bool>(),
2440 proptest::option::of(0u64..1000000), proptest::option::of(0u64..1000000), proptest::option::of(0u64..1000000), proptest::option::of(0u64..1000000), proptest::option::of(0u64..1000000), proptest::option::of(0u64..100000000), proptest::option::of("[a-zA-Z0-9 ]{0,50}"),
2447 proptest::option::of("[a-zA-Z0-9 ]{0,50}"),
2448 )
2449 .prop_map(
2450 |(
2451 wall_ms,
2452 exit_code,
2453 warmup,
2454 timed_out,
2455 cpu_ms,
2456 page_faults,
2457 ctx_switches,
2458 max_rss_kb,
2459 energy_uj,
2460 binary_bytes,
2461 stdout,
2462 stderr,
2463 )| Sample {
2464 wall_ms,
2465 exit_code,
2466 warmup,
2467 timed_out,
2468 cpu_ms,
2469 page_faults,
2470 ctx_switches,
2471 max_rss_kb,
2472 io_read_bytes: None,
2473 io_write_bytes: None,
2474 network_packets: None,
2475 energy_uj,
2476 binary_bytes,
2477 stdout,
2478 stderr,
2479 },
2480 )
2481 }
2482
2483 fn u64_summary_strategy() -> impl Strategy<Value = U64Summary> {
2485 (0u64..1000000, 0u64..1000000, 0u64..1000000).prop_map(|(a, b, c)| {
2486 let mut vals = [a, b, c];
2487 vals.sort();
2488 U64Summary::new(vals[1], vals[0], vals[2])
2489 })
2490 }
2491
2492 fn f64_summary_strategy() -> impl Strategy<Value = F64Summary> {
2494 (0.0f64..1000000.0, 0.0f64..1000000.0, 0.0f64..1000000.0).prop_map(|(a, b, c)| {
2495 let mut vals = [a, b, c];
2496 vals.sort_by(|x, y| x.partial_cmp(y).unwrap());
2497 F64Summary::new(vals[1], vals[0], vals[2])
2498 })
2499 }
2500
2501 fn stats_strategy() -> impl Strategy<Value = Stats> {
2503 (
2504 u64_summary_strategy(),
2505 proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(f64_summary_strategy()),
2515 )
2516 .prop_map(
2517 |(
2518 wall_ms,
2519 cpu_ms,
2520 page_faults,
2521 ctx_switches,
2522 max_rss_kb,
2523 io_read_bytes,
2524 io_write_bytes,
2525 network_packets,
2526 energy_uj,
2527 binary_bytes,
2528 throughput_per_s,
2529 )| Stats {
2530 wall_ms,
2531 cpu_ms,
2532 page_faults,
2533 ctx_switches,
2534 max_rss_kb,
2535 io_read_bytes,
2536 io_write_bytes,
2537 network_packets,
2538 energy_uj,
2539 binary_bytes,
2540 throughput_per_s,
2541 },
2542 )
2543 }
2544
2545 fn run_receipt_strategy() -> impl Strategy<Value = RunReceipt> {
2547 (
2548 tool_info_strategy(),
2549 run_meta_strategy(),
2550 bench_meta_strategy(),
2551 proptest::collection::vec(sample_strategy(), 1..10),
2552 stats_strategy(),
2553 )
2554 .prop_map(|(tool, run, bench, samples, stats)| RunReceipt {
2555 schema: RUN_SCHEMA_V1.to_string(),
2556 tool,
2557 run,
2558 bench,
2559 samples,
2560 stats,
2561 })
2562 }
2563
2564 proptest! {
2571 #![proptest_config(ProptestConfig::with_cases(100))]
2572
2573 #[test]
2574 fn run_receipt_serialization_round_trip(receipt in run_receipt_strategy()) {
2575 let json = serde_json::to_string(&receipt)
2577 .expect("RunReceipt should serialize to JSON");
2578
2579 let deserialized: RunReceipt = serde_json::from_str(&json)
2581 .expect("JSON should deserialize back to RunReceipt");
2582
2583 prop_assert_eq!(&receipt.schema, &deserialized.schema);
2585 prop_assert_eq!(&receipt.tool, &deserialized.tool);
2586 prop_assert_eq!(&receipt.run, &deserialized.run);
2587 prop_assert_eq!(&receipt.bench, &deserialized.bench);
2588 prop_assert_eq!(receipt.samples.len(), deserialized.samples.len());
2589
2590 for (orig, deser) in receipt.samples.iter().zip(deserialized.samples.iter()) {
2592 prop_assert_eq!(orig.wall_ms, deser.wall_ms);
2593 prop_assert_eq!(orig.exit_code, deser.exit_code);
2594 prop_assert_eq!(orig.warmup, deser.warmup);
2595 prop_assert_eq!(orig.timed_out, deser.timed_out);
2596 prop_assert_eq!(orig.cpu_ms, deser.cpu_ms);
2597 prop_assert_eq!(orig.page_faults, deser.page_faults);
2598 prop_assert_eq!(orig.ctx_switches, deser.ctx_switches);
2599 prop_assert_eq!(orig.max_rss_kb, deser.max_rss_kb);
2600 prop_assert_eq!(orig.binary_bytes, deser.binary_bytes);
2601 prop_assert_eq!(&orig.stdout, &deser.stdout);
2602 prop_assert_eq!(&orig.stderr, &deser.stderr);
2603 }
2604
2605 prop_assert_eq!(&receipt.stats.wall_ms, &deserialized.stats.wall_ms);
2607 prop_assert_eq!(&receipt.stats.cpu_ms, &deserialized.stats.cpu_ms);
2608 prop_assert_eq!(&receipt.stats.page_faults, &deserialized.stats.page_faults);
2609 prop_assert_eq!(&receipt.stats.ctx_switches, &deserialized.stats.ctx_switches);
2610 prop_assert_eq!(&receipt.stats.max_rss_kb, &deserialized.stats.max_rss_kb);
2611 prop_assert_eq!(&receipt.stats.binary_bytes, &deserialized.stats.binary_bytes);
2612
2613 match (&receipt.stats.throughput_per_s, &deserialized.stats.throughput_per_s) {
2616 (Some(orig), Some(deser)) => {
2617 let rel_tol = |a: f64, b: f64| {
2619 if a == 0.0 && b == 0.0 {
2620 true
2621 } else {
2622 let max_val = a.abs().max(b.abs());
2623 (a - b).abs() / max_val < 1e-10
2624 }
2625 };
2626 prop_assert!(rel_tol(orig.min, deser.min), "min mismatch: {} vs {}", orig.min, deser.min);
2627 prop_assert!(rel_tol(orig.median, deser.median), "median mismatch: {} vs {}", orig.median, deser.median);
2628 prop_assert!(rel_tol(orig.max, deser.max), "max mismatch: {} vs {}", orig.max, deser.max);
2629 }
2630 (None, None) => {}
2631 _ => prop_assert!(false, "throughput_per_s presence mismatch"),
2632 }
2633 }
2634 }
2635
2636 fn compare_ref_strategy() -> impl Strategy<Value = CompareRef> {
2640 (
2641 proptest::option::of(non_empty_string()),
2642 proptest::option::of(non_empty_string()),
2643 )
2644 .prop_map(|(path, run_id)| CompareRef { path, run_id })
2645 }
2646
2647 fn direction_strategy() -> impl Strategy<Value = Direction> {
2649 prop_oneof![Just(Direction::Lower), Just(Direction::Higher),]
2650 }
2651
2652 fn budget_strategy() -> impl Strategy<Value = Budget> {
2654 (0.01f64..1.0, 0.01f64..1.0, direction_strategy()).prop_map(
2655 |(threshold, warn_factor, direction)| {
2656 let warn_threshold = threshold * warn_factor;
2658 Budget {
2659 noise_threshold: None,
2660 noise_policy: NoisePolicy::Ignore,
2661 threshold,
2662 warn_threshold,
2663 direction,
2664 }
2665 },
2666 )
2667 }
2668
2669 fn metric_status_strategy() -> impl Strategy<Value = MetricStatus> {
2671 prop_oneof![
2672 Just(MetricStatus::Pass),
2673 Just(MetricStatus::Warn),
2674 Just(MetricStatus::Fail),
2675 ]
2676 }
2677
2678 fn delta_strategy() -> impl Strategy<Value = Delta> {
2680 (
2681 0.1f64..10000.0, 0.1f64..10000.0, metric_status_strategy(),
2684 )
2685 .prop_map(|(baseline, current, status)| {
2686 let ratio = current / baseline;
2687 let pct = (current - baseline) / baseline;
2688 let regression = if pct > 0.0 { pct } else { 0.0 };
2689 Delta {
2690 baseline,
2691 current,
2692 ratio,
2693 pct,
2694 regression,
2695 cv: None,
2696 noise_threshold: None,
2697 statistic: MetricStatistic::Median,
2698 significance: None,
2699 status,
2700 }
2701 })
2702 }
2703
2704 fn verdict_status_strategy() -> impl Strategy<Value = VerdictStatus> {
2706 prop_oneof![
2707 Just(VerdictStatus::Pass),
2708 Just(VerdictStatus::Warn),
2709 Just(VerdictStatus::Fail),
2710 ]
2711 }
2712
2713 fn verdict_counts_strategy() -> impl Strategy<Value = VerdictCounts> {
2715 (0u32..10, 0u32..10, 0u32..10, 0u32..10).prop_map(|(pass, warn, fail, skip)| {
2716 VerdictCounts {
2717 pass,
2718 warn,
2719 fail,
2720 skip,
2721 }
2722 })
2723 }
2724
2725 fn verdict_strategy() -> impl Strategy<Value = Verdict> {
2727 (
2728 verdict_status_strategy(),
2729 verdict_counts_strategy(),
2730 proptest::collection::vec("[a-zA-Z0-9 ]{1,50}", 0..5),
2731 )
2732 .prop_map(|(status, counts, reasons)| Verdict {
2733 status,
2734 counts,
2735 reasons,
2736 })
2737 }
2738
2739 fn metric_strategy() -> impl Strategy<Value = Metric> {
2741 prop_oneof![
2742 Just(Metric::BinaryBytes),
2743 Just(Metric::CpuMs),
2744 Just(Metric::CtxSwitches),
2745 Just(Metric::MaxRssKb),
2746 Just(Metric::PageFaults),
2747 Just(Metric::ThroughputPerS),
2748 Just(Metric::WallMs),
2749 ]
2750 }
2751
2752 fn budgets_map_strategy() -> impl Strategy<Value = BTreeMap<Metric, Budget>> {
2754 proptest::collection::btree_map(metric_strategy(), budget_strategy(), 0..8)
2755 }
2756
2757 fn deltas_map_strategy() -> impl Strategy<Value = BTreeMap<Metric, Delta>> {
2759 proptest::collection::btree_map(metric_strategy(), delta_strategy(), 0..8)
2760 }
2761
2762 fn compare_receipt_strategy() -> impl Strategy<Value = CompareReceipt> {
2764 (
2765 tool_info_strategy(),
2766 bench_meta_strategy(),
2767 compare_ref_strategy(),
2768 compare_ref_strategy(),
2769 budgets_map_strategy(),
2770 deltas_map_strategy(),
2771 verdict_strategy(),
2772 )
2773 .prop_map(
2774 |(tool, bench, baseline_ref, current_ref, budgets, deltas, verdict)| {
2775 CompareReceipt {
2776 schema: COMPARE_SCHEMA_V1.to_string(),
2777 tool,
2778 bench,
2779 baseline_ref,
2780 current_ref,
2781 budgets,
2782 deltas,
2783 verdict,
2784 }
2785 },
2786 )
2787 }
2788
2789 fn f64_approx_eq(a: f64, b: f64) -> bool {
2791 if a == 0.0 && b == 0.0 {
2792 true
2793 } else {
2794 let max_val = a.abs().max(b.abs());
2795 if max_val == 0.0 {
2796 true
2797 } else {
2798 (a - b).abs() / max_val < 1e-10
2799 }
2800 }
2801 }
2802
2803 proptest! {
2810 #![proptest_config(ProptestConfig::with_cases(100))]
2811
2812 #[test]
2813 fn compare_receipt_serialization_round_trip(receipt in compare_receipt_strategy()) {
2814 let json = serde_json::to_string(&receipt)
2816 .expect("CompareReceipt should serialize to JSON");
2817
2818 let deserialized: CompareReceipt = serde_json::from_str(&json)
2820 .expect("JSON should deserialize back to CompareReceipt");
2821
2822 prop_assert_eq!(&receipt.schema, &deserialized.schema);
2824 prop_assert_eq!(&receipt.tool, &deserialized.tool);
2825 prop_assert_eq!(&receipt.bench, &deserialized.bench);
2826 prop_assert_eq!(&receipt.baseline_ref, &deserialized.baseline_ref);
2827 prop_assert_eq!(&receipt.current_ref, &deserialized.current_ref);
2828 prop_assert_eq!(&receipt.verdict, &deserialized.verdict);
2829
2830 prop_assert_eq!(receipt.budgets.len(), deserialized.budgets.len());
2832 for (metric, orig_budget) in &receipt.budgets {
2833 let deser_budget = deserialized.budgets.get(metric)
2834 .expect("Budget metric should exist in deserialized");
2835 prop_assert!(
2836 f64_approx_eq(orig_budget.threshold, deser_budget.threshold),
2837 "Budget threshold mismatch for {:?}: {} vs {}",
2838 metric, orig_budget.threshold, deser_budget.threshold
2839 );
2840 prop_assert!(
2841 f64_approx_eq(orig_budget.warn_threshold, deser_budget.warn_threshold),
2842 "Budget warn_threshold mismatch for {:?}: {} vs {}",
2843 metric, orig_budget.warn_threshold, deser_budget.warn_threshold
2844 );
2845 prop_assert_eq!(orig_budget.direction, deser_budget.direction);
2846 }
2847
2848 prop_assert_eq!(receipt.deltas.len(), deserialized.deltas.len());
2850 for (metric, orig_delta) in &receipt.deltas {
2851 let deser_delta = deserialized.deltas.get(metric)
2852 .expect("Delta metric should exist in deserialized");
2853 prop_assert!(
2854 f64_approx_eq(orig_delta.baseline, deser_delta.baseline),
2855 "Delta baseline mismatch for {:?}: {} vs {}",
2856 metric, orig_delta.baseline, deser_delta.baseline
2857 );
2858 prop_assert!(
2859 f64_approx_eq(orig_delta.current, deser_delta.current),
2860 "Delta current mismatch for {:?}: {} vs {}",
2861 metric, orig_delta.current, deser_delta.current
2862 );
2863 prop_assert!(
2864 f64_approx_eq(orig_delta.ratio, deser_delta.ratio),
2865 "Delta ratio mismatch for {:?}: {} vs {}",
2866 metric, orig_delta.ratio, deser_delta.ratio
2867 );
2868 prop_assert!(
2869 f64_approx_eq(orig_delta.pct, deser_delta.pct),
2870 "Delta pct mismatch for {:?}: {} vs {}",
2871 metric, orig_delta.pct, deser_delta.pct
2872 );
2873 prop_assert!(
2874 f64_approx_eq(orig_delta.regression, deser_delta.regression),
2875 "Delta regression mismatch for {:?}: {} vs {}",
2876 metric, orig_delta.regression, deser_delta.regression
2877 );
2878 prop_assert_eq!(orig_delta.status, deser_delta.status);
2879 }
2880 }
2881 }
2882
2883 fn budget_override_strategy() -> impl Strategy<Value = BudgetOverride> {
2887 (
2888 proptest::option::of(0.01f64..1.0),
2889 proptest::option::of(direction_strategy()),
2890 proptest::option::of(0.5f64..1.0),
2891 )
2892 .prop_map(|(threshold, direction, warn_factor)| BudgetOverride {
2893 noise_threshold: None,
2894 noise_policy: None,
2895 threshold,
2896 direction,
2897 warn_factor,
2898 statistic: None,
2899 })
2900 }
2901
2902 fn budget_overrides_map_strategy() -> impl Strategy<Value = BTreeMap<Metric, BudgetOverride>> {
2904 proptest::collection::btree_map(metric_strategy(), budget_override_strategy(), 0..4)
2905 }
2906
2907 fn bench_config_file_strategy() -> impl Strategy<Value = BenchConfigFile> {
2909 (
2910 non_empty_string(),
2911 proptest::option::of(non_empty_string()),
2912 proptest::option::of(1u64..10000),
2913 proptest::option::of("[0-9]+[smh]"), proptest::collection::vec(non_empty_string(), 1..5),
2915 proptest::option::of(1u32..100),
2916 proptest::option::of(0u32..10),
2917 proptest::option::of(proptest::collection::vec(metric_strategy(), 1..4)),
2918 proptest::option::of(budget_overrides_map_strategy()),
2919 )
2920 .prop_map(
2921 |(name, cwd, work, timeout, command, repeat, warmup, metrics, budgets)| {
2922 BenchConfigFile {
2923 name,
2924 cwd,
2925 work,
2926 timeout,
2927 command,
2928 repeat,
2929 warmup,
2930 metrics,
2931 budgets,
2932 }
2933 },
2934 )
2935 }
2936
2937 fn defaults_config_strategy() -> impl Strategy<Value = DefaultsConfig> {
2939 (
2940 proptest::option::of(1u32..100),
2941 proptest::option::of(0u32..10),
2942 proptest::option::of(0.01f64..1.0),
2943 proptest::option::of(0.5f64..1.0),
2944 proptest::option::of(non_empty_string()),
2945 proptest::option::of(non_empty_string()),
2946 proptest::option::of(non_empty_string()),
2947 proptest::option::of(non_empty_string()),
2948 )
2949 .prop_map(
2950 |(
2951 repeat,
2952 warmup,
2953 threshold,
2954 warn_factor,
2955 out_dir,
2956 baseline_dir,
2957 baseline_pattern,
2958 markdown_template,
2959 )| DefaultsConfig {
2960 noise_threshold: None,
2961 noise_policy: None,
2962 repeat,
2963 warmup,
2964 threshold,
2965 warn_factor,
2966 out_dir,
2967 baseline_dir,
2968 baseline_pattern,
2969 markdown_template,
2970 },
2971 )
2972 }
2973
2974 fn baseline_server_config_strategy() -> impl Strategy<Value = BaselineServerConfig> {
2976 (
2977 proptest::option::of(non_empty_string()),
2978 proptest::option::of(non_empty_string()),
2979 proptest::option::of(non_empty_string()),
2980 proptest::bool::ANY,
2981 )
2982 .prop_map(
2983 |(url, api_key, project, fallback_to_local)| BaselineServerConfig {
2984 url,
2985 api_key,
2986 project,
2987 fallback_to_local,
2988 },
2989 )
2990 }
2991
2992 fn config_file_strategy() -> impl Strategy<Value = ConfigFile> {
2994 (
2995 defaults_config_strategy(),
2996 baseline_server_config_strategy(),
2997 proptest::collection::vec(bench_config_file_strategy(), 0..5),
2998 )
2999 .prop_map(|(defaults, baseline_server, benches)| ConfigFile {
3000 defaults,
3001 baseline_server,
3002 benches,
3003 })
3004 }
3005
3006 proptest! {
3013 #![proptest_config(ProptestConfig::with_cases(100))]
3014
3015 #[test]
3016 fn config_file_json_serialization_round_trip(config in config_file_strategy()) {
3017 let json = serde_json::to_string(&config)
3019 .expect("ConfigFile should serialize to JSON");
3020
3021 let deserialized: ConfigFile = serde_json::from_str(&json)
3023 .expect("JSON should deserialize back to ConfigFile");
3024
3025 prop_assert_eq!(config.defaults.repeat, deserialized.defaults.repeat);
3027 prop_assert_eq!(config.defaults.warmup, deserialized.defaults.warmup);
3028 prop_assert_eq!(&config.defaults.out_dir, &deserialized.defaults.out_dir);
3029 prop_assert_eq!(&config.defaults.baseline_dir, &deserialized.defaults.baseline_dir);
3030 prop_assert_eq!(
3031 &config.defaults.baseline_pattern,
3032 &deserialized.defaults.baseline_pattern
3033 );
3034 prop_assert_eq!(
3035 &config.defaults.markdown_template,
3036 &deserialized.defaults.markdown_template
3037 );
3038
3039 match (config.defaults.threshold, deserialized.defaults.threshold) {
3041 (Some(orig), Some(deser)) => {
3042 prop_assert!(
3043 f64_approx_eq(orig, deser),
3044 "defaults.threshold mismatch: {} vs {}",
3045 orig, deser
3046 );
3047 }
3048 (None, None) => {}
3049 _ => prop_assert!(false, "defaults.threshold presence mismatch"),
3050 }
3051
3052 match (config.defaults.warn_factor, deserialized.defaults.warn_factor) {
3053 (Some(orig), Some(deser)) => {
3054 prop_assert!(
3055 f64_approx_eq(orig, deser),
3056 "defaults.warn_factor mismatch: {} vs {}",
3057 orig, deser
3058 );
3059 }
3060 (None, None) => {}
3061 _ => prop_assert!(false, "defaults.warn_factor presence mismatch"),
3062 }
3063
3064 prop_assert_eq!(config.benches.len(), deserialized.benches.len());
3066 for (orig_bench, deser_bench) in config.benches.iter().zip(deserialized.benches.iter()) {
3067 prop_assert_eq!(&orig_bench.name, &deser_bench.name);
3068 prop_assert_eq!(&orig_bench.cwd, &deser_bench.cwd);
3069 prop_assert_eq!(orig_bench.work, deser_bench.work);
3070 prop_assert_eq!(&orig_bench.timeout, &deser_bench.timeout);
3071 prop_assert_eq!(&orig_bench.command, &deser_bench.command);
3072 prop_assert_eq!(&orig_bench.metrics, &deser_bench.metrics);
3073
3074 match (&orig_bench.budgets, &deser_bench.budgets) {
3076 (Some(orig_budgets), Some(deser_budgets)) => {
3077 prop_assert_eq!(orig_budgets.len(), deser_budgets.len());
3078 for (metric, orig_override) in orig_budgets {
3079 let deser_override = deser_budgets.get(metric)
3080 .expect("BudgetOverride metric should exist in deserialized");
3081
3082 match (orig_override.threshold, deser_override.threshold) {
3084 (Some(orig), Some(deser)) => {
3085 prop_assert!(
3086 f64_approx_eq(orig, deser),
3087 "BudgetOverride threshold mismatch for {:?}: {} vs {}",
3088 metric, orig, deser
3089 );
3090 }
3091 (None, None) => {}
3092 _ => prop_assert!(false, "BudgetOverride threshold presence mismatch for {:?}", metric),
3093 }
3094
3095 prop_assert_eq!(orig_override.direction, deser_override.direction);
3096
3097 match (orig_override.warn_factor, deser_override.warn_factor) {
3099 (Some(orig), Some(deser)) => {
3100 prop_assert!(
3101 f64_approx_eq(orig, deser),
3102 "BudgetOverride warn_factor mismatch for {:?}: {} vs {}",
3103 metric, orig, deser
3104 );
3105 }
3106 (None, None) => {}
3107 _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch for {:?}", metric),
3108 }
3109 }
3110 }
3111 (None, None) => {}
3112 _ => prop_assert!(false, "bench.budgets presence mismatch"),
3113 }
3114 }
3115 }
3116 }
3117
3118 proptest! {
3125 #![proptest_config(ProptestConfig::with_cases(100))]
3126
3127 #[test]
3128 fn config_file_toml_serialization_round_trip(config in config_file_strategy()) {
3129 let toml_str = toml::to_string(&config)
3131 .expect("ConfigFile should serialize to TOML");
3132
3133 let deserialized: ConfigFile = toml::from_str(&toml_str)
3135 .expect("TOML should deserialize back to ConfigFile");
3136
3137 prop_assert_eq!(config.defaults.repeat, deserialized.defaults.repeat);
3139 prop_assert_eq!(config.defaults.warmup, deserialized.defaults.warmup);
3140 prop_assert_eq!(&config.defaults.out_dir, &deserialized.defaults.out_dir);
3141 prop_assert_eq!(&config.defaults.baseline_dir, &deserialized.defaults.baseline_dir);
3142 prop_assert_eq!(
3143 &config.defaults.baseline_pattern,
3144 &deserialized.defaults.baseline_pattern
3145 );
3146 prop_assert_eq!(
3147 &config.defaults.markdown_template,
3148 &deserialized.defaults.markdown_template
3149 );
3150
3151 match (config.defaults.threshold, deserialized.defaults.threshold) {
3153 (Some(orig), Some(deser)) => {
3154 prop_assert!(
3155 f64_approx_eq(orig, deser),
3156 "defaults.threshold mismatch: {} vs {}",
3157 orig, deser
3158 );
3159 }
3160 (None, None) => {}
3161 _ => prop_assert!(false, "defaults.threshold presence mismatch"),
3162 }
3163
3164 match (config.defaults.warn_factor, deserialized.defaults.warn_factor) {
3165 (Some(orig), Some(deser)) => {
3166 prop_assert!(
3167 f64_approx_eq(orig, deser),
3168 "defaults.warn_factor mismatch: {} vs {}",
3169 orig, deser
3170 );
3171 }
3172 (None, None) => {}
3173 _ => prop_assert!(false, "defaults.warn_factor presence mismatch"),
3174 }
3175
3176 prop_assert_eq!(config.benches.len(), deserialized.benches.len());
3178 for (orig_bench, deser_bench) in config.benches.iter().zip(deserialized.benches.iter()) {
3179 prop_assert_eq!(&orig_bench.name, &deser_bench.name);
3180 prop_assert_eq!(&orig_bench.cwd, &deser_bench.cwd);
3181 prop_assert_eq!(orig_bench.work, deser_bench.work);
3182 prop_assert_eq!(&orig_bench.timeout, &deser_bench.timeout);
3183 prop_assert_eq!(&orig_bench.command, &deser_bench.command);
3184 prop_assert_eq!(&orig_bench.metrics, &deser_bench.metrics);
3185
3186 match (&orig_bench.budgets, &deser_bench.budgets) {
3188 (Some(orig_budgets), Some(deser_budgets)) => {
3189 prop_assert_eq!(orig_budgets.len(), deser_budgets.len());
3190 for (metric, orig_override) in orig_budgets {
3191 let deser_override = deser_budgets.get(metric)
3192 .expect("BudgetOverride metric should exist in deserialized");
3193
3194 match (orig_override.threshold, deser_override.threshold) {
3196 (Some(orig), Some(deser)) => {
3197 prop_assert!(
3198 f64_approx_eq(orig, deser),
3199 "BudgetOverride threshold mismatch for {:?}: {} vs {}",
3200 metric, orig, deser
3201 );
3202 }
3203 (None, None) => {}
3204 _ => prop_assert!(false, "BudgetOverride threshold presence mismatch for {:?}", metric),
3205 }
3206
3207 prop_assert_eq!(orig_override.direction, deser_override.direction);
3208
3209 match (orig_override.warn_factor, deser_override.warn_factor) {
3211 (Some(orig), Some(deser)) => {
3212 prop_assert!(
3213 f64_approx_eq(orig, deser),
3214 "BudgetOverride warn_factor mismatch for {:?}: {} vs {}",
3215 metric, orig, deser
3216 );
3217 }
3218 (None, None) => {}
3219 _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch for {:?}", metric),
3220 }
3221 }
3222 }
3223 (None, None) => {}
3224 _ => prop_assert!(false, "bench.budgets presence mismatch"),
3225 }
3226 }
3227 }
3228 }
3229
3230 proptest! {
3238 #![proptest_config(ProptestConfig::with_cases(100))]
3239
3240 #[test]
3241 fn bench_config_file_json_serialization_round_trip(bench_config in bench_config_file_strategy()) {
3242 let json = serde_json::to_string(&bench_config)
3244 .expect("BenchConfigFile should serialize to JSON");
3245
3246 let deserialized: BenchConfigFile = serde_json::from_str(&json)
3248 .expect("JSON should deserialize back to BenchConfigFile");
3249
3250 prop_assert_eq!(&bench_config.name, &deserialized.name);
3252 prop_assert_eq!(&bench_config.command, &deserialized.command);
3253
3254 prop_assert_eq!(&bench_config.cwd, &deserialized.cwd);
3256 prop_assert_eq!(bench_config.work, deserialized.work);
3257 prop_assert_eq!(&bench_config.timeout, &deserialized.timeout);
3258 prop_assert_eq!(&bench_config.metrics, &deserialized.metrics);
3259
3260 match (&bench_config.budgets, &deserialized.budgets) {
3262 (Some(orig_budgets), Some(deser_budgets)) => {
3263 prop_assert_eq!(orig_budgets.len(), deser_budgets.len());
3264 for (metric, orig_override) in orig_budgets {
3265 let deser_override = deser_budgets.get(metric)
3266 .expect("BudgetOverride metric should exist in deserialized");
3267
3268 match (orig_override.threshold, deser_override.threshold) {
3270 (Some(orig), Some(deser)) => {
3271 prop_assert!(
3272 f64_approx_eq(orig, deser),
3273 "BudgetOverride threshold mismatch for {:?}: {} vs {}",
3274 metric, orig, deser
3275 );
3276 }
3277 (None, None) => {}
3278 _ => prop_assert!(false, "BudgetOverride threshold presence mismatch for {:?}", metric),
3279 }
3280
3281 prop_assert_eq!(orig_override.direction, deser_override.direction);
3282
3283 match (orig_override.warn_factor, deser_override.warn_factor) {
3285 (Some(orig), Some(deser)) => {
3286 prop_assert!(
3287 f64_approx_eq(orig, deser),
3288 "BudgetOverride warn_factor mismatch for {:?}: {} vs {}",
3289 metric, orig, deser
3290 );
3291 }
3292 (None, None) => {}
3293 _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch for {:?}", metric),
3294 }
3295 }
3296 }
3297 (None, None) => {}
3298 _ => prop_assert!(false, "budgets presence mismatch"),
3299 }
3300 }
3301 }
3302
3303 proptest! {
3311 #![proptest_config(ProptestConfig::with_cases(100))]
3312
3313 #[test]
3314 fn bench_config_file_toml_serialization_round_trip(bench_config in bench_config_file_strategy()) {
3315 let toml_str = toml::to_string(&bench_config)
3317 .expect("BenchConfigFile should serialize to TOML");
3318
3319 let deserialized: BenchConfigFile = toml::from_str(&toml_str)
3321 .expect("TOML should deserialize back to BenchConfigFile");
3322
3323 prop_assert_eq!(&bench_config.name, &deserialized.name);
3325 prop_assert_eq!(&bench_config.command, &deserialized.command);
3326
3327 prop_assert_eq!(&bench_config.cwd, &deserialized.cwd);
3329 prop_assert_eq!(bench_config.work, deserialized.work);
3330 prop_assert_eq!(&bench_config.timeout, &deserialized.timeout);
3331 prop_assert_eq!(&bench_config.metrics, &deserialized.metrics);
3332
3333 match (&bench_config.budgets, &deserialized.budgets) {
3335 (Some(orig_budgets), Some(deser_budgets)) => {
3336 prop_assert_eq!(orig_budgets.len(), deser_budgets.len());
3337 for (metric, orig_override) in orig_budgets {
3338 let deser_override = deser_budgets.get(metric)
3339 .expect("BudgetOverride metric should exist in deserialized");
3340
3341 match (orig_override.threshold, deser_override.threshold) {
3343 (Some(orig), Some(deser)) => {
3344 prop_assert!(
3345 f64_approx_eq(orig, deser),
3346 "BudgetOverride threshold mismatch for {:?}: {} vs {}",
3347 metric, orig, deser
3348 );
3349 }
3350 (None, None) => {}
3351 _ => prop_assert!(false, "BudgetOverride threshold presence mismatch for {:?}", metric),
3352 }
3353
3354 prop_assert_eq!(orig_override.direction, deser_override.direction);
3355
3356 match (orig_override.warn_factor, deser_override.warn_factor) {
3358 (Some(orig), Some(deser)) => {
3359 prop_assert!(
3360 f64_approx_eq(orig, deser),
3361 "BudgetOverride warn_factor mismatch for {:?}: {} vs {}",
3362 metric, orig, deser
3363 );
3364 }
3365 (None, None) => {}
3366 _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch for {:?}", metric),
3367 }
3368 }
3369 }
3370 (None, None) => {}
3371 _ => prop_assert!(false, "budgets presence mismatch"),
3372 }
3373 }
3374 }
3375
3376 proptest! {
3383 #![proptest_config(ProptestConfig::with_cases(100))]
3384
3385 #[test]
3386 fn budget_json_serialization_round_trip(budget in budget_strategy()) {
3387 let json = serde_json::to_string(&budget)
3389 .expect("Budget should serialize to JSON");
3390
3391 let deserialized: Budget = serde_json::from_str(&json)
3393 .expect("JSON should deserialize back to Budget");
3394
3395 prop_assert!(
3397 f64_approx_eq(budget.threshold, deserialized.threshold),
3398 "Budget threshold mismatch: {} vs {}",
3399 budget.threshold, deserialized.threshold
3400 );
3401 prop_assert!(
3402 f64_approx_eq(budget.warn_threshold, deserialized.warn_threshold),
3403 "Budget warn_threshold mismatch: {} vs {}",
3404 budget.warn_threshold, deserialized.warn_threshold
3405 );
3406 prop_assert_eq!(budget.direction, deserialized.direction);
3407 }
3408 }
3409
3410 proptest! {
3417 #![proptest_config(ProptestConfig::with_cases(100))]
3418
3419 #[test]
3420 fn budget_override_json_serialization_round_trip(budget_override in budget_override_strategy()) {
3421 let json = serde_json::to_string(&budget_override)
3423 .expect("BudgetOverride should serialize to JSON");
3424
3425 let deserialized: BudgetOverride = serde_json::from_str(&json)
3427 .expect("JSON should deserialize back to BudgetOverride");
3428
3429 match (budget_override.threshold, deserialized.threshold) {
3431 (Some(orig), Some(deser)) => {
3432 prop_assert!(
3433 f64_approx_eq(orig, deser),
3434 "BudgetOverride threshold mismatch: {} vs {}",
3435 orig, deser
3436 );
3437 }
3438 (None, None) => {}
3439 _ => prop_assert!(false, "BudgetOverride threshold presence mismatch"),
3440 }
3441
3442 prop_assert_eq!(budget_override.direction, deserialized.direction);
3444
3445 match (budget_override.warn_factor, deserialized.warn_factor) {
3447 (Some(orig), Some(deser)) => {
3448 prop_assert!(
3449 f64_approx_eq(orig, deser),
3450 "BudgetOverride warn_factor mismatch: {} vs {}",
3451 orig, deser
3452 );
3453 }
3454 (None, None) => {}
3455 _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch"),
3456 }
3457 }
3458 }
3459
3460 proptest! {
3469 #![proptest_config(ProptestConfig::with_cases(100))]
3470
3471 #[test]
3472 fn budget_threshold_relationship_preserved(budget in budget_strategy()) {
3473 prop_assert!(
3475 budget.warn_threshold <= budget.threshold,
3476 "Budget invariant violated: warn_threshold ({}) should be <= threshold ({})",
3477 budget.warn_threshold, budget.threshold
3478 );
3479
3480 let json = serde_json::to_string(&budget)
3482 .expect("Budget should serialize to JSON");
3483 let deserialized: Budget = serde_json::from_str(&json)
3484 .expect("JSON should deserialize back to Budget");
3485
3486 prop_assert!(
3488 deserialized.warn_threshold <= deserialized.threshold,
3489 "Budget invariant violated after round-trip: warn_threshold ({}) should be <= threshold ({})",
3490 deserialized.warn_threshold, deserialized.threshold
3491 );
3492 }
3493 }
3494
3495 proptest! {
3498 #![proptest_config(ProptestConfig::with_cases(100))]
3499
3500 #[test]
3501 fn host_info_serialization_round_trip(info in host_info_strategy()) {
3502 let json = serde_json::to_string(&info).expect("HostInfo should serialize");
3503 let back: HostInfo = serde_json::from_str(&json).expect("should deserialize");
3504 prop_assert_eq!(info, back);
3505 }
3506
3507 #[test]
3508 fn sample_serialization_round_trip(sample in sample_strategy()) {
3509 let json = serde_json::to_string(&sample).expect("Sample should serialize");
3510 let back: Sample = serde_json::from_str(&json).expect("should deserialize");
3511 prop_assert_eq!(sample, back);
3512 }
3513
3514 #[test]
3515 fn u64_summary_serialization_round_trip(summary in u64_summary_strategy()) {
3516 let json = serde_json::to_string(&summary).expect("U64Summary should serialize");
3517 let back: U64Summary = serde_json::from_str(&json).expect("should deserialize");
3518 prop_assert_eq!(summary, back);
3519 }
3520
3521 #[test]
3522 fn f64_summary_serialization_round_trip(summary in f64_summary_strategy()) {
3523 let json = serde_json::to_string(&summary).expect("F64Summary should serialize");
3524 let back: F64Summary = serde_json::from_str(&json).expect("should deserialize");
3525 prop_assert!(f64_approx_eq(summary.min, back.min));
3526 prop_assert!(f64_approx_eq(summary.median, back.median));
3527 prop_assert!(f64_approx_eq(summary.max, back.max));
3528 }
3529
3530 #[test]
3531 fn stats_serialization_round_trip(stats in stats_strategy()) {
3532 let json = serde_json::to_string(&stats).expect("Stats should serialize");
3533 let back: Stats = serde_json::from_str(&json).expect("should deserialize");
3534 prop_assert_eq!(&stats.wall_ms, &back.wall_ms);
3535 prop_assert_eq!(&stats.cpu_ms, &back.cpu_ms);
3536 prop_assert_eq!(&stats.page_faults, &back.page_faults);
3537 prop_assert_eq!(&stats.ctx_switches, &back.ctx_switches);
3538 prop_assert_eq!(&stats.max_rss_kb, &back.max_rss_kb);
3539 prop_assert_eq!(&stats.binary_bytes, &back.binary_bytes);
3540 match (&stats.throughput_per_s, &back.throughput_per_s) {
3541 (Some(orig), Some(deser)) => {
3542 prop_assert!(f64_approx_eq(orig.min, deser.min));
3543 prop_assert!(f64_approx_eq(orig.median, deser.median));
3544 prop_assert!(f64_approx_eq(orig.max, deser.max));
3545 }
3546 (None, None) => {}
3547 _ => prop_assert!(false, "throughput_per_s presence mismatch"),
3548 }
3549 }
3550
3551 #[test]
3552 fn delta_serialization_round_trip(delta in delta_strategy()) {
3553 let json = serde_json::to_string(&delta).expect("Delta should serialize");
3554 let back: Delta = serde_json::from_str(&json).expect("should deserialize");
3555 prop_assert!(f64_approx_eq(delta.baseline, back.baseline));
3556 prop_assert!(f64_approx_eq(delta.current, back.current));
3557 prop_assert!(f64_approx_eq(delta.ratio, back.ratio));
3558 prop_assert!(f64_approx_eq(delta.pct, back.pct));
3559 prop_assert!(f64_approx_eq(delta.regression, back.regression));
3560 prop_assert_eq!(delta.statistic, back.statistic);
3561 prop_assert_eq!(delta.significance, back.significance);
3562 prop_assert_eq!(delta.status, back.status);
3563 }
3564
3565 #[test]
3566 fn verdict_serialization_round_trip(verdict in verdict_strategy()) {
3567 let json = serde_json::to_string(&verdict).expect("Verdict should serialize");
3568 let back: Verdict = serde_json::from_str(&json).expect("should deserialize");
3569 prop_assert_eq!(verdict, back);
3570 }
3571 }
3572
3573 fn severity_strategy() -> impl Strategy<Value = Severity> {
3576 prop_oneof![Just(Severity::Warn), Just(Severity::Fail),]
3577 }
3578
3579 fn finding_data_strategy() -> impl Strategy<Value = FindingData> {
3580 (
3581 non_empty_string(),
3582 0.1f64..10000.0,
3583 0.1f64..10000.0,
3584 0.0f64..100.0,
3585 0.01f64..1.0,
3586 direction_strategy(),
3587 )
3588 .prop_map(
3589 |(metric_name, baseline, current, regression_pct, threshold, direction)| {
3590 FindingData {
3591 metric_name,
3592 baseline,
3593 current,
3594 regression_pct,
3595 threshold,
3596 direction,
3597 }
3598 },
3599 )
3600 }
3601
3602 fn report_finding_strategy() -> impl Strategy<Value = ReportFinding> {
3603 (
3604 non_empty_string(),
3605 non_empty_string(),
3606 severity_strategy(),
3607 non_empty_string(),
3608 proptest::option::of(finding_data_strategy()),
3609 )
3610 .prop_map(|(check_id, code, severity, message, data)| ReportFinding {
3611 check_id,
3612 code,
3613 severity,
3614 message,
3615 data,
3616 })
3617 }
3618
3619 fn report_summary_strategy() -> impl Strategy<Value = ReportSummary> {
3620 (0u32..100, 0u32..100, 0u32..100, 0u32..100).prop_map(
3621 |(pass_count, warn_count, fail_count, skip_count)| ReportSummary {
3622 pass_count,
3623 warn_count,
3624 fail_count,
3625 skip_count,
3626 total_count: pass_count + warn_count + fail_count + skip_count,
3627 },
3628 )
3629 }
3630
3631 fn perfgate_report_strategy() -> impl Strategy<Value = PerfgateReport> {
3632 (
3633 verdict_strategy(),
3634 proptest::option::of(compare_receipt_strategy()),
3635 proptest::collection::vec(report_finding_strategy(), 0..5),
3636 report_summary_strategy(),
3637 )
3638 .prop_map(|(verdict, compare, findings, summary)| PerfgateReport {
3639 report_type: REPORT_SCHEMA_V1.to_string(),
3640 verdict,
3641 compare,
3642 findings,
3643 summary,
3644 })
3645 }
3646
3647 proptest! {
3648 #![proptest_config(ProptestConfig::with_cases(50))]
3649
3650 #[test]
3651 fn perfgate_report_serialization_round_trip(report in perfgate_report_strategy()) {
3652 let json = serde_json::to_string(&report)
3653 .expect("PerfgateReport should serialize to JSON");
3654 let back: PerfgateReport = serde_json::from_str(&json)
3655 .expect("JSON should deserialize back to PerfgateReport");
3656
3657 prop_assert_eq!(&report.report_type, &back.report_type);
3658 prop_assert_eq!(&report.verdict, &back.verdict);
3659 prop_assert_eq!(&report.summary, &back.summary);
3660 prop_assert_eq!(report.findings.len(), back.findings.len());
3661 for (orig, deser) in report.findings.iter().zip(back.findings.iter()) {
3662 prop_assert_eq!(&orig.check_id, &deser.check_id);
3663 prop_assert_eq!(&orig.code, &deser.code);
3664 prop_assert_eq!(orig.severity, deser.severity);
3665 prop_assert_eq!(&orig.message, &deser.message);
3666 match (&orig.data, &deser.data) {
3667 (Some(o), Some(d)) => {
3668 prop_assert_eq!(&o.metric_name, &d.metric_name);
3669 prop_assert!(f64_approx_eq(o.baseline, d.baseline));
3670 prop_assert!(f64_approx_eq(o.current, d.current));
3671 prop_assert!(f64_approx_eq(o.regression_pct, d.regression_pct));
3672 prop_assert!(f64_approx_eq(o.threshold, d.threshold));
3673 prop_assert_eq!(o.direction, d.direction);
3674 }
3675 (None, None) => {}
3676 _ => prop_assert!(false, "finding data presence mismatch"),
3677 }
3678 }
3679 prop_assert_eq!(report.compare.is_some(), back.compare.is_some());
3682 }
3683 }
3684}
3685
3686#[cfg(test)]
3689mod golden_tests {
3690 use super::*;
3691
3692 const FIXTURE_PASS: &str = include_str!("../../../contracts/fixtures/sensor_report_pass.json");
3693 const FIXTURE_FAIL: &str = include_str!("../../../contracts/fixtures/sensor_report_fail.json");
3694 const FIXTURE_WARN: &str = include_str!("../../../contracts/fixtures/sensor_report_warn.json");
3695 const FIXTURE_NO_BASELINE: &str =
3696 include_str!("../../../contracts/fixtures/sensor_report_no_baseline.json");
3697 const FIXTURE_ERROR: &str =
3698 include_str!("../../../contracts/fixtures/sensor_report_error.json");
3699 const FIXTURE_MULTI_BENCH: &str =
3700 include_str!("../../../contracts/fixtures/sensor_report_multi_bench.json");
3701
3702 #[test]
3703 fn golden_sensor_report_pass() {
3704 let report: SensorReport =
3705 serde_json::from_str(FIXTURE_PASS).expect("fixture should parse");
3706 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
3707 assert_eq!(report.tool.name, "perfgate");
3708 assert_eq!(report.verdict.status, SensorVerdictStatus::Pass);
3709 assert_eq!(report.verdict.counts.warn, 0);
3710 assert_eq!(report.verdict.counts.error, 0);
3711 assert!(report.findings.is_empty());
3712 assert_eq!(report.artifacts.len(), 4);
3713
3714 let json = serde_json::to_string(&report).unwrap();
3716 let back: SensorReport = serde_json::from_str(&json).unwrap();
3717 assert_eq!(report, back);
3718 }
3719
3720 #[test]
3721 fn golden_sensor_report_fail() {
3722 let report: SensorReport =
3723 serde_json::from_str(FIXTURE_FAIL).expect("fixture should parse");
3724 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
3725 assert_eq!(report.verdict.status, SensorVerdictStatus::Fail);
3726 assert_eq!(report.verdict.counts.error, 1);
3727 assert_eq!(report.verdict.reasons, vec!["wall_ms_fail"]);
3728 assert_eq!(report.findings.len(), 1);
3729 assert_eq!(report.findings[0].check_id, CHECK_ID_BUDGET);
3730 assert_eq!(report.findings[0].code, FINDING_CODE_METRIC_FAIL);
3731 assert_eq!(report.findings[0].severity, SensorSeverity::Error);
3732
3733 let json = serde_json::to_string(&report).unwrap();
3734 let back: SensorReport = serde_json::from_str(&json).unwrap();
3735 assert_eq!(report, back);
3736 }
3737
3738 #[test]
3739 fn golden_sensor_report_warn() {
3740 let report: SensorReport =
3741 serde_json::from_str(FIXTURE_WARN).expect("fixture should parse");
3742 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
3743 assert_eq!(report.verdict.status, SensorVerdictStatus::Warn);
3744 assert_eq!(report.verdict.counts.warn, 1);
3745 assert_eq!(report.verdict.reasons, vec!["wall_ms_warn"]);
3746 assert_eq!(report.findings.len(), 1);
3747 assert_eq!(report.findings[0].severity, SensorSeverity::Warn);
3748
3749 let json = serde_json::to_string(&report).unwrap();
3750 let back: SensorReport = serde_json::from_str(&json).unwrap();
3751 assert_eq!(report, back);
3752 }
3753
3754 #[test]
3755 fn golden_sensor_report_no_baseline() {
3756 let report: SensorReport =
3757 serde_json::from_str(FIXTURE_NO_BASELINE).expect("fixture should parse");
3758 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
3759 assert_eq!(report.verdict.status, SensorVerdictStatus::Warn);
3760 assert_eq!(report.verdict.reasons, vec!["no_baseline"]);
3761 assert_eq!(
3762 report.run.capabilities.baseline.status,
3763 CapabilityStatus::Unavailable
3764 );
3765 assert_eq!(
3766 report.run.capabilities.baseline.reason.as_deref(),
3767 Some("no_baseline")
3768 );
3769 assert_eq!(report.findings.len(), 1);
3770 assert_eq!(report.findings[0].code, FINDING_CODE_BASELINE_MISSING);
3771
3772 let json = serde_json::to_string(&report).unwrap();
3773 let back: SensorReport = serde_json::from_str(&json).unwrap();
3774 assert_eq!(report, back);
3775 }
3776
3777 #[test]
3778 fn golden_sensor_report_error() {
3779 let report: SensorReport =
3780 serde_json::from_str(FIXTURE_ERROR).expect("fixture should parse");
3781 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
3782 assert_eq!(report.verdict.status, SensorVerdictStatus::Fail);
3783 assert_eq!(report.verdict.reasons, vec!["tool_error"]);
3784 assert_eq!(report.findings.len(), 1);
3785 assert_eq!(report.findings[0].check_id, CHECK_ID_TOOL_RUNTIME);
3786 assert_eq!(report.findings[0].code, FINDING_CODE_RUNTIME_ERROR);
3787 assert!(report.artifacts.is_empty());
3788
3789 let json = serde_json::to_string(&report).unwrap();
3790 let back: SensorReport = serde_json::from_str(&json).unwrap();
3791 assert_eq!(report, back);
3792 }
3793
3794 #[test]
3795 fn golden_sensor_report_multi_bench() {
3796 let report: SensorReport =
3797 serde_json::from_str(FIXTURE_MULTI_BENCH).expect("fixture should parse");
3798 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
3799 assert_eq!(report.verdict.status, SensorVerdictStatus::Warn);
3800 assert_eq!(report.verdict.counts.warn, 2);
3801 assert_eq!(report.findings.len(), 2);
3802 for finding in &report.findings {
3804 assert_eq!(finding.code, FINDING_CODE_BASELINE_MISSING);
3805 }
3806 assert_eq!(report.artifacts.len(), 5);
3807
3808 let json = serde_json::to_string(&report).unwrap();
3809 let back: SensorReport = serde_json::from_str(&json).unwrap();
3810 assert_eq!(report, back);
3811 }
3812}