use super::*;
#[test]
fn scenario_stats_serde_roundtrip() {
let s = ScenarioStats {
cgroups: vec![CgroupStats {
num_workers: 4,
num_cpus: 2,
avg_off_cpu_pct: 50.0,
min_off_cpu_pct: 40.0,
max_off_cpu_pct: 60.0,
spread: 20.0,
max_gap_ms: 150,
max_gap_cpu: 3,
total_migrations: 10,
..Default::default()
}],
total_workers: 4,
total_cpus: 2,
total_migrations: 10,
worst_spread: 20.0,
worst_gap_ms: 150,
worst_gap_cpu: 3,
..Default::default()
};
let json = serde_json::to_string(&s).unwrap();
let s2: ScenarioStats = serde_json::from_str(&json).unwrap();
assert_eq!(s.total_workers, s2.total_workers);
assert_eq!(s.worst_gap_ms, s2.worst_gap_ms);
assert_eq!(s.cgroups.len(), s2.cgroups.len());
assert_eq!(s.cgroups[0].num_workers, s2.cgroups[0].num_workers);
}
#[test]
fn assert_result_serde_roundtrip() {
let r = AssertResult {
outcomes: vec![Outcome::Fail(AssertDetail::new(DetailKind::Other, "test"))],
passes: vec![],
stats: Default::default(),
measurements: std::collections::BTreeMap::new(),
info_notes: vec![InfoNote::new("ctx=42")],
};
let json = serde_json::to_string(&r).unwrap();
let r2: AssertResult = serde_json::from_str(&json).unwrap();
assert_eq!(r.is_pass(), r2.is_pass());
assert_eq!(r.is_fail(), r2.is_fail());
let r_details: Vec<&AssertDetail> = r.failure_details().collect();
let r2_details: Vec<&AssertDetail> = r2.failure_details().collect();
assert_eq!(r_details.len(), r2_details.len());
assert_eq!(r_details[0].message, r2_details[0].message);
assert_eq!(r.passes, r2.passes);
assert_eq!(r.info_notes.len(), r2.info_notes.len());
assert_eq!(r.info_notes[0].message, r2.info_notes[0].message);
}
#[test]
fn assert_result_postcard_roundtrip() {
let mut measurements = std::collections::BTreeMap::new();
measurements.insert("pid".to_string(), NoteValue::Int(-1));
measurements.insert("bytes".to_string(), NoteValue::Uint(4096));
measurements.insert("rate".to_string(), NoteValue::Float(3.15));
measurements.insert("ok".to_string(), NoteValue::Bool(true));
measurements.insert(
"label".to_string(),
NoteValue::Text("benchmark".to_string()),
);
let r = AssertResult {
outcomes: vec![
Outcome::Fail(AssertDetail::new(DetailKind::Other, "fail msg")),
Outcome::Inconclusive(AssertDetail::new(DetailKind::Other, "inconclusive msg")),
Outcome::Skip(AssertDetail::new(DetailKind::Skip, "skip msg")),
Outcome::Pass,
],
passes: vec![],
stats: Default::default(),
measurements,
info_notes: vec![InfoNote::new("ctx=42")],
};
let bytes = postcard::to_allocvec(&r).expect("postcard encode");
let r2: AssertResult = postcard::from_bytes(&bytes).expect("postcard decode");
assert_eq!(r.is_fail(), r2.is_fail());
assert_eq!(r.is_skip(), r2.is_skip());
assert_eq!(r.is_inconclusive(), r2.is_inconclusive());
assert_eq!(r.outcomes.len(), r2.outcomes.len());
let r_fails: Vec<_> = r.failure_details().collect();
let r2_fails: Vec<_> = r2.failure_details().collect();
assert_eq!(r_fails.len(), r2_fails.len());
assert_eq!(r_fails[0].message, r2_fails[0].message);
let r_incons: Vec<_> = r.inconclusive_details().collect();
let r2_incons: Vec<_> = r2.inconclusive_details().collect();
assert_eq!(r_incons.len(), r2_incons.len());
assert_eq!(r_incons[0].message, r2_incons[0].message);
let r_skips: Vec<_> = r.skip_details().collect();
let r2_skips: Vec<_> = r2.skip_details().collect();
assert_eq!(r_skips.len(), r2_skips.len());
assert_eq!(r_skips[0].message, r2_skips[0].message);
assert_eq!(r.info_notes.len(), r2.info_notes.len());
assert_eq!(r.info_notes[0].message, r2.info_notes[0].message);
assert_eq!(r.measurements.len(), r2.measurements.len());
assert_eq!(r2.measurements.get("pid"), Some(&NoteValue::Int(-1)));
assert_eq!(r2.measurements.get("bytes"), Some(&NoteValue::Uint(4096)));
if let Some(NoteValue::Float(f)) = r2.measurements.get("rate") {
assert!((f - 3.15).abs() < 1e-9);
} else {
panic!(
"rate must decode to NoteValue::Float, got {:?}",
r2.measurements.get("rate")
);
}
assert_eq!(r2.measurements.get("ok"), Some(&NoteValue::Bool(true)));
assert_eq!(
r2.measurements.get("label"),
Some(&NoteValue::Text("benchmark".to_string()))
);
}
#[test]
fn assert_strict_type_rejection_names_offending_field() {
let base = serde_json::to_value(Assert::NO_OVERRIDES).expect("baseline serializes");
let serde_json::Value::Object(base_obj) = base else {
panic!("Assert must serialize as a JSON object");
};
let cases: &[(&str, serde_json::Value)] = &[
("enforce_monitor_thresholds", serde_json::json!(0)),
("enforce_monitor_thresholds", serde_json::json!("true")),
("enforce_monitor_thresholds", serde_json::Value::Null),
("min_page_locality", serde_json::json!("0.95")),
("max_slow_tier_ratio", serde_json::json!(true)),
];
for (field, wrong_val) in cases {
let mut obj = base_obj.clone();
obj.insert((*field).to_string(), wrong_val.clone());
let value = serde_json::Value::Object(obj);
let err = serde_path_to_error::deserialize::<_, Assert>(value)
.expect_err(&format!("deserialize must reject {field} = {wrong_val}"));
let path = err.path().to_string();
let inner = err.inner().to_string();
assert!(
inner.contains("invalid type"),
"deserialize error for `{field} = {wrong_val}` must be a typed-mismatch \
(`invalid type ...`), not a softened-default acceptance; got inner: {inner}"
);
assert_eq!(
path, *field,
"serde_path_to_error must surface the offending field path for \
`{field} = {wrong_val}`; got path `{path}` with inner `{inner}`"
);
}
}
#[test]
fn cgroup_stats_missing_required_field_rejected_by_deserialize() {
const REQUIRED_FIELDS: &[&str] = &[
"num_workers",
"num_cpus",
"avg_off_cpu_pct",
"min_off_cpu_pct",
"max_off_cpu_pct",
"spread",
"max_gap_ms",
"max_gap_cpu",
"total_migrations",
"migration_ratio",
"p99_wake_latency_us",
"median_wake_latency_us",
"wake_latency_cv",
"total_iterations",
"mean_run_delay_us",
"worst_run_delay_us",
"page_locality",
"cross_node_migration_ratio",
"ext_metrics",
];
let cg = CgroupStats::default();
let full = match serde_json::to_value(&cg).unwrap() {
serde_json::Value::Object(m) => m,
other => panic!("expected object, got {other:?}"),
};
for field in REQUIRED_FIELDS {
let mut obj = full.clone();
assert!(
obj.remove(*field).is_some(),
"CgroupStats must emit `{field}` for its rejection \
case to be meaningful — the field list in this test \
has drifted from the struct definition",
);
let json = serde_json::Value::Object(obj).to_string();
let err = serde_json::from_str::<CgroupStats>(&json)
.err()
.unwrap_or_else(|| {
panic!("deserialize must reject CgroupStats with `{field}` removed, but succeeded",)
});
let msg = format!("{err}");
assert!(
msg.contains(field),
"missing-field error for `{field}` must name the field; got: {msg}",
);
}
}
#[test]
fn scenario_stats_missing_required_scalar_rejected_by_deserialize() {
const REQUIRED_FIELDS: &[&str] = &[
"cgroups",
"total_workers",
"total_cpus",
"total_migrations",
"worst_spread",
"worst_gap_ms",
"worst_gap_cpu",
"worst_migration_ratio",
"worst_p99_wake_latency_us",
"worst_median_wake_latency_us",
"worst_wake_latency_cv",
"total_iterations",
"worst_mean_run_delay_us",
"worst_run_delay_us",
"worst_page_locality",
"worst_cross_node_migration_ratio",
"worst_wake_latency_tail_ratio",
"worst_iterations_per_worker",
"ext_metrics",
];
let s = ScenarioStats::default();
let full = match serde_json::to_value(&s).unwrap() {
serde_json::Value::Object(m) => m,
other => panic!("expected object, got {other:?}"),
};
for field in REQUIRED_FIELDS {
let mut obj = full.clone();
assert!(
obj.remove(*field).is_some(),
"ScenarioStats must emit `{field}` for its rejection case to be meaningful — \
the field list in this test has drifted from the struct definition",
);
let json = serde_json::Value::Object(obj).to_string();
let err = serde_json::from_str::<ScenarioStats>(&json)
.err()
.unwrap_or_else(|| {
panic!(
"deserialize must reject ScenarioStats with `{field}` removed, but succeeded",
)
});
let msg = format!("{err}");
assert!(
msg.contains(field),
"missing-field error for `{field}` must name the field; got: {msg}",
);
}
}
#[test]
fn assert_result_missing_required_field_rejected_by_deserialize() {
const REQUIRED_FIELDS: &[&str] = &["outcomes", "passes", "stats", "measurements", "info_notes"];
let r = AssertResult {
outcomes: vec![Outcome::Fail(AssertDetail::new(
DetailKind::Other,
"detail",
))],
passes: vec![],
stats: ScenarioStats::default(),
measurements: std::collections::BTreeMap::new(),
info_notes: vec![],
};
let full = match serde_json::to_value(&r).unwrap() {
serde_json::Value::Object(m) => m,
other => panic!("expected object, got {other:?}"),
};
for field in REQUIRED_FIELDS {
let mut obj = full.clone();
assert!(
obj.remove(*field).is_some(),
"AssertResult must emit `{field}` for its rejection case to be meaningful",
);
let json = serde_json::Value::Object(obj).to_string();
let err = serde_json::from_str::<AssertResult>(&json).err().unwrap_or_else(
|| panic!(
"deserialize must reject AssertResult with `{field}` removed, but succeeded",
),
);
let msg = format!("{err}");
assert!(
msg.contains(field),
"missing-field error for `{field}` must name the field; got: {msg}",
);
}
}
#[test]
fn assert_reproducer_matcher_fields_serde_skip_bypass() {
use crate::assert::Assert;
let with_matchers = Assert::NO_OVERRIDES
.expect_scx_bpf_error_contains("apply_cell_config")
.expect_scx_bpf_error_matches(r"(?m)^apply_cell_config$");
assert_eq!(
with_matchers.expect_scx_bpf_error_contains,
Some("apply_cell_config"),
"constructed value must carry the contains matcher",
);
assert_eq!(
with_matchers.expect_scx_bpf_error_matches,
Some(r"(?m)^apply_cell_config$"),
"constructed value must carry the regex matcher",
);
let json =
serde_json::to_string(&with_matchers).expect("Assert with matchers must serialize cleanly");
assert!(
!json.contains("expect_scx_bpf_error_contains"),
"serialized JSON must OMIT expect_scx_bpf_error_contains \
(#[serde(skip)] regressed on serialize side); got: {json}",
);
assert!(
!json.contains("expect_scx_bpf_error_matches"),
"serialized JSON must OMIT expect_scx_bpf_error_matches \
(#[serde(skip)] regressed on serialize side); got: {json}",
);
let roundtrip: Assert =
serde_json::from_str(&json).expect("serialized matcher-bearing Assert must deserialize");
assert_eq!(
roundtrip.expect_scx_bpf_error_contains, None,
"deserialized contains matcher must be None — \
#[serde(skip)] should default-init Option to None per \
Option::default(); regression would either deserialize \
the omitted field with a non-None value (impossible per \
the skip contract) or fail the deserialize entirely.",
);
assert_eq!(
roundtrip.expect_scx_bpf_error_matches, None,
"deserialized regex matcher must be None — same rationale \
as expect_scx_bpf_error_contains above.",
);
}