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