#![allow(unused_imports)]
#![allow(clippy::field_reassign_with_default)]
use std::collections::BTreeMap;
use std::path::Path;
use super::aggregate::{format_cpu_range, merge_aggregated_into};
use super::cgroup_merge::{
merge_cgroup_cpu, merge_cgroup_memory, merge_cgroup_pids, merge_kv_counters, merge_max_option,
merge_memory_stat, merge_min_option, merge_psi,
};
use super::columns::{compare_columns_for, format_cgroup_only_section_warning};
use super::compare::sort_diff_rows_by_keys;
use super::groups::build_row;
use super::pattern::{
Segment, apply_systemd_template, cgroup_normalize_skeleton, cgroup_skeleton_tokens,
classify_token, is_token_separator, pattern_counts_union, pattern_key, split_into_segments,
tighten_group,
};
use super::render::psi_pair_has_data;
use super::scale::{auto_scale, format_delta_cell};
use super::tests_fixtures::*;
use super::*;
use crate::ctprof::{CgroupStats, CtprofSnapshot, Psi, ThreadState};
use crate::metric_types::{
Bytes, CategoricalString, CpuSet, MonotonicCount, MonotonicNs, OrdinalI32, PeakNs,
};
use regex::Regex;
#[test]
fn write_diff_limits_table_skips_cgroups_without_caps() {
let mut diff = CtprofDiff::default();
diff.cgroup_stats_a.insert(
"/counters-only".into(),
simple_cgroup_stats(100, 0, 0, 1024),
);
diff.cgroup_stats_b.insert(
"/counters-only".into(),
simple_cgroup_stats(200, 0, 0, 2048),
);
let mut capped_a = CgroupStats::default();
capped_a.memory.max = Some(1 << 30);
capped_a.cpu.weight = Some(150);
let mut capped_b = CgroupStats::default();
capped_b.memory.max = Some(1 << 30);
capped_b.cpu.weight = Some(150);
diff.cgroup_stats_a.insert("/capped".into(), capped_a);
diff.cgroup_stats_b.insert("/capped".into(), capped_b);
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Cgroup,
&DisplayOptions::default(),
)
.unwrap();
assert!(
out.contains("## Cgroup limits / knobs"),
"limits header missing:\n{out}",
);
let header_pos = out.find("## Cgroup limits / knobs").unwrap();
let after_header = &out[header_pos..];
let next_section = after_header
.find("\n## ")
.map(|p| p + 1)
.unwrap_or(after_header.len());
let limits_section = &after_header[..next_section];
assert!(
limits_section.contains("/capped"),
"capped cgroup should appear in limits table:\n{limits_section}",
);
assert!(
!limits_section.contains("/counters-only"),
"counters-only cgroup should NOT appear (no caps/weight/pids):\n{limits_section}",
);
}
#[test]
fn write_diff_memory_stat_skips_unchanged_rows() {
let mut diff = CtprofDiff::default();
let mut a = CgroupStats::default();
a.memory.stat.insert("pgfault".into(), 100);
a.memory.stat.insert("anon".into(), 1_000_000);
let mut b = CgroupStats::default();
b.memory.stat.insert("pgfault".into(), 250);
b.memory.stat.insert("anon".into(), 1_000_000);
diff.cgroup_stats_a.insert("/app".into(), a);
diff.cgroup_stats_b.insert("/app".into(), b);
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Cgroup,
&DisplayOptions::default(),
)
.unwrap();
let header_pos = out
.find("## memory.stat")
.expect("memory.stat header missing");
let after_header = &out[header_pos..];
let next_section = after_header
.find("\n## ")
.map(|p| p + 1)
.unwrap_or(after_header.len());
let stat_section = &after_header[..next_section];
assert!(
stat_section.contains("pgfault"),
"changed key (pgfault: 100 → 250) must appear:\n{stat_section}",
);
assert!(
!stat_section.contains("anon"),
"unchanged gauge key (anon: 1M = 1M) must be suppressed:\n{stat_section}",
);
}
#[test]
fn write_diff_memory_events_skips_unchanged_rows() {
let mut diff = CtprofDiff::default();
let mut a = CgroupStats::default();
a.memory.events.insert("low".into(), 5);
a.memory.events.insert("oom_kill".into(), 0);
let mut b = CgroupStats::default();
b.memory.events.insert("low".into(), 12);
b.memory.events.insert("oom_kill".into(), 0);
diff.cgroup_stats_a.insert("/app".into(), a);
diff.cgroup_stats_b.insert("/app".into(), b);
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Cgroup,
&DisplayOptions::default(),
)
.unwrap();
let header_pos = out
.find("## memory.events")
.expect("memory.events header missing");
let after_header = &out[header_pos..];
let next_section = after_header
.find("\n## ")
.map(|p| p + 1)
.unwrap_or(after_header.len());
let events_section = &after_header[..next_section];
assert!(
events_section.contains("low"),
"changed event (low: 5 → 12) must appear:\n{events_section}",
);
assert!(
!events_section.contains("oom_kill"),
"unchanged event (oom_kill: 0 = 0) must be suppressed:\n{events_section}",
);
}
#[test]
fn write_diff_arrow_renders_combined_cell() {
let (a, b) = snap_pair_for_display();
let diff = compare(&a, &b, &CompareOptions::default());
let mut display = DisplayOptions::default();
display.format = DisplayFormat::Arrow;
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&display,
)
.unwrap();
assert!(
out.contains("\u{2192}"),
"arrow glyph must appear in output:\n{out}"
);
assert!(
out.contains("100ns") && out.contains("200ns"),
"baseline and candidate values must surface in arrow cell:\n{out}"
);
assert!(
out.contains("+100ns"),
"delta must appear in adjacent Delta column:\n{out}"
);
}
#[test]
fn write_diff_arrow_renders_derived_arrow_cell() {
let (a, b) = snap_pair_for_display();
let diff = compare(&a, &b, &CompareOptions::default());
let mut display = DisplayOptions::default();
display.format = DisplayFormat::Arrow;
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&display,
)
.unwrap();
assert!(
out.contains("avg_wait_ns"),
"derived metric must appear in arrow rendering:\n{out}"
);
assert!(
out.contains("250.00ns") || out.contains("250ns"),
"baseline derived value must appear in arrow cell:\n{out}"
);
}
#[test]
fn write_diff_pct_only_keeps_only_pct() {
let (a, b) = snap_pair_for_display();
let diff = compare(&a, &b, &CompareOptions::default());
let mut display = DisplayOptions::default();
display.format = DisplayFormat::PctOnly;
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&display,
)
.unwrap();
let header_line = out
.lines()
.find(|line| line.contains("metric") && !line.starts_with("##"))
.unwrap_or("");
assert!(
!header_line.contains("baseline"),
"pct-only header must drop baseline:\n{header_line}"
);
assert!(
!header_line.contains("candidate"),
"pct-only header must drop candidate:\n{header_line}"
);
assert!(
!header_line.contains("delta"),
"pct-only header must drop delta:\n{header_line}"
);
assert!(
out.contains("+100.0%"),
"pct-only must render percent cell:\n{out}",
);
}
#[test]
fn write_diff_derived_ratio_suppresses_pct() {
let mut a = make_thread("p", "w");
a.nr_wakeups_affine = MonotonicCount(50);
a.nr_wakeups_affine_attempts = MonotonicCount(100); let mut b = make_thread("p", "w");
b.nr_wakeups_affine = MonotonicCount(60);
b.nr_wakeups_affine_attempts = MonotonicCount(100); let diff = compare(
&snap_with(vec![a]),
&snap_with(vec![b]),
&CompareOptions::default(),
);
let row = diff
.derived_rows
.iter()
.find(|r| r.metric_name == "affine_success_ratio")
.expect("affine_success_ratio present");
let delta = row.delta.expect("delta present when both sides defined");
assert!(
(delta - 0.1).abs() < 1e-10,
"expected delta ~0.1 (0.6 - 0.5 in f64), got {delta}",
);
assert!(
row.delta_pct.is_none(),
"ratio row must suppress delta_pct, got {:?}",
row.delta_pct
);
}
#[test]
fn write_diff_derived_ns_keeps_pct() {
let mut a = make_thread("p", "w");
a.wait_sum = MonotonicNs(1000);
a.wait_count = MonotonicCount(10); let mut b = make_thread("p", "w");
b.wait_sum = MonotonicNs(1500);
b.wait_count = MonotonicCount(10); let diff = compare(
&snap_with(vec![a]),
&snap_with(vec![b]),
&CompareOptions::default(),
);
let row = diff
.derived_rows
.iter()
.find(|r| r.metric_name == "avg_wait_ns")
.expect("avg_wait_ns present");
assert_eq!(row.baseline, Some(DerivedValue::Scalar(100.0)));
assert_eq!(row.candidate, Some(DerivedValue::Scalar(150.0)));
assert_eq!(row.delta, Some(50.0));
assert!(row.delta_pct.is_some());
let pct = row.delta_pct.unwrap();
assert!(
(pct - 0.5).abs() < 1e-9,
"expected delta_pct ~0.5, got {pct}"
);
}
#[test]
fn write_diff_header_switches_on_group_by() {
let mut t = make_thread("p", "w");
t.cgroup = "/app".into();
let snap = snap_with(vec![t]);
let cg_opts = CompareOptions {
group_by: GroupBy::Cgroup.into(),
cgroup_flatten: vec![],
no_thread_normalize: false,
no_cg_normalize: false,
sort_by: Vec::new(),
};
let cg_diff = compare(&snap, &snap, &cg_opts);
let mut out = String::new();
write_diff(
&mut out,
&cg_diff,
Path::new("a"),
Path::new("b"),
GroupBy::Cgroup,
&DisplayOptions::default(),
)
.unwrap();
assert!(
out.contains("cgroup"),
"cgroup column header missing:\n{out}"
);
let comm_opts = CompareOptions {
group_by: GroupBy::Comm.into(),
cgroup_flatten: vec![],
no_thread_normalize: false,
no_cg_normalize: false,
sort_by: Vec::new(),
};
let comm_diff = compare(&snap, &snap, &comm_opts);
let mut out = String::new();
write_diff(
&mut out,
&comm_diff,
Path::new("a"),
Path::new("b"),
GroupBy::Comm,
&DisplayOptions::default(),
)
.unwrap();
assert!(out.contains("comm"), "comm column header missing:\n{out}");
assert!(
!out.contains("pcomm"),
"pcomm leak under Comm group_by:\n{out}"
);
}
#[test]
fn write_diff_delta_cell_has_plus_minus_sign() {
let mut ta = make_thread("app", "w");
ta.run_time_ns = MonotonicNs(100);
let mut tb = make_thread("app", "w");
tb.run_time_ns = MonotonicNs(50);
let diff = compare(
&snap_with(vec![ta]),
&snap_with(vec![tb]),
&CompareOptions::default(),
);
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&DisplayOptions::default(),
)
.unwrap();
assert!(
out.contains("-50ns"),
"missing signed delta with unit:\n{out}",
);
assert!(out.contains("-50.0%"), "missing signed pct:\n{out}");
}
#[test]
fn write_diff_categorical_delta_labels_same_or_differs() {
let mut ta = make_thread("app", "w");
ta.policy = "SCHED_OTHER".into();
let mut tb = make_thread("app", "w");
tb.policy = "SCHED_FIFO".into();
let diff = compare(
&snap_with(vec![ta]),
&snap_with(vec![tb]),
&CompareOptions::default(),
);
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&DisplayOptions::default(),
)
.unwrap();
assert!(out.contains("differs"), "missing 'differs' label:\n{out}");
}
#[test]
fn write_diff_enrichment_handles_one_sided_cgroup_keys() {
let mut diff = CtprofDiff::default();
diff.cgroup_stats_a
.insert("/only-baseline".into(), simple_cgroup_stats(111, 0, 0, 0));
diff.cgroup_stats_b
.insert("/only-candidate".into(), simple_cgroup_stats(222, 0, 0, 0));
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Cgroup,
&DisplayOptions::default(),
)
.unwrap();
assert!(
out.contains("/only-baseline"),
"baseline-only key missing:\n{out}",
);
assert!(
out.contains("/only-candidate"),
"candidate-only key missing:\n{out}",
);
assert!(
out.contains("111µs → -"),
"baseline-only row missing '111µs → -' cell:\n{out}",
);
assert!(
out.contains("- → 222µs"),
"candidate-only row missing '- → 222µs' cell:\n{out}",
);
}
#[test]
fn write_diff_stable_sort_tie_breaks_by_group_key_ascending() {
let mut a1 = make_thread("alpha", "w");
a1.run_time_ns = MonotonicNs(1_000);
let mut a2 = make_thread("bravo", "w");
a2.run_time_ns = MonotonicNs(1_000);
let mut b1 = make_thread("alpha", "w");
b1.run_time_ns = MonotonicNs(2_000);
let mut b2 = make_thread("bravo", "w");
b2.run_time_ns = MonotonicNs(2_000);
let diff = compare(
&snap_with(vec![a1, a2]),
&snap_with(vec![b1, b2]),
&CompareOptions::default(),
);
let run_rows: Vec<&DiffRow> = diff
.rows
.iter()
.filter(|r| r.metric_name == "run_time_ns")
.collect();
assert_eq!(run_rows.len(), 2);
assert!(
(run_rows[0].delta_pct.unwrap() - 1.0).abs() < 1e-9
&& (run_rows[1].delta_pct.unwrap() - 1.0).abs() < 1e-9,
"test fixture must produce identical delta_pct for both groups",
);
assert_eq!(
run_rows[0].group_key, "alpha",
"ascending group_key tie-break expected alpha first",
);
assert_eq!(run_rows[1].group_key, "bravo");
}
#[test]
fn write_diff_smaps_orders_processes_by_rss_desc() {
let mut diff = CtprofDiff::default();
let mut heavy = BTreeMap::new();
heavy.insert("Rss".to_string(), 100 * 1024 * 1024); heavy.insert("Pss".to_string(), 50 * 1024 * 1024);
let mut heavy_b = BTreeMap::new();
heavy_b.insert("Rss".to_string(), 200 * 1024 * 1024);
heavy_b.insert("Pss".to_string(), 100 * 1024 * 1024);
let mut light = BTreeMap::new();
light.insert("Rss".to_string(), 1024); light.insert("Pss".to_string(), 512);
let mut light_b = BTreeMap::new();
light_b.insert("Rss".to_string(), 2048);
light_b.insert("Pss".to_string(), 1024);
diff.smaps_rollup_a.insert("a_light[1]".to_string(), light);
diff.smaps_rollup_b
.insert("a_light[1]".to_string(), light_b);
diff.smaps_rollup_a.insert("b_heavy[2]".to_string(), heavy);
diff.smaps_rollup_b
.insert("b_heavy[2]".to_string(), heavy_b);
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&DisplayOptions::default(),
)
.unwrap();
let smaps_at = out
.find("## smaps_rollup")
.expect("smaps section must render");
let after_header = &out[smaps_at..];
let heavy_pos = after_header
.find("b_heavy[2]")
.expect("b_heavy must appear");
let light_pos = after_header
.find("a_light[1]")
.expect("a_light must appear");
assert!(
heavy_pos < light_pos,
"process with larger Rss must render first; \
b_heavy@{heavy_pos} must precede a_light@{light_pos}",
);
}
#[test]
fn write_diff_smaps_emits_row_for_each_process_key() {
let mut diff = CtprofDiff::default();
let mut firefox_a = BTreeMap::new();
firefox_a.insert("Rss".to_string(), 100 * 1024 * 1024);
let mut firefox_b = BTreeMap::new();
firefox_b.insert("Rss".to_string(), 200 * 1024 * 1024);
let mut bash_a = BTreeMap::new();
bash_a.insert("Rss".to_string(), 1024);
let mut bash_b = BTreeMap::new();
bash_b.insert("Rss".to_string(), 2048);
diff.smaps_rollup_a.insert("firefox".into(), firefox_a);
diff.smaps_rollup_b.insert("firefox".into(), firefox_b);
diff.smaps_rollup_a.insert("bash".into(), bash_a);
diff.smaps_rollup_b.insert("bash".into(), bash_b);
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&DisplayOptions::default(),
)
.unwrap();
let smaps_at = out
.find("## smaps_rollup")
.expect("smaps section must render");
let smaps_section = &out[smaps_at..];
assert!(
smaps_section.contains("firefox"),
"process key `firefox` must appear in smaps section body:\n{smaps_section}",
);
assert!(
smaps_section.contains("bash"),
"process key `bash` must appear in smaps section body:\n{smaps_section}",
);
}
#[test]
fn write_diff_smaps_max_rss_breaks_tie_when_delta_equal() {
let mut diff = CtprofDiff::default();
let mut a = BTreeMap::new();
a.insert("Rss".to_string(), 100 * 1024 * 1024);
a.insert("Pss".to_string(), 30 * 1024 * 1024);
let mut a_b = BTreeMap::new();
a_b.insert("Rss".to_string(), 120 * 1024 * 1024);
a_b.insert("Pss".to_string(), 35 * 1024 * 1024);
let mut z = BTreeMap::new();
z.insert("Rss".to_string(), 220 * 1024 * 1024);
z.insert("Pss".to_string(), 80 * 1024 * 1024);
let mut z_b = BTreeMap::new();
z_b.insert("Rss".to_string(), 240 * 1024 * 1024);
z_b.insert("Pss".to_string(), 90 * 1024 * 1024);
diff.smaps_rollup_a.insert("alpha_proc".into(), a);
diff.smaps_rollup_b.insert("alpha_proc".into(), a_b);
diff.smaps_rollup_a.insert("zoomed".into(), z);
diff.smaps_rollup_b.insert("zoomed".into(), z_b);
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&DisplayOptions::default(),
)
.unwrap();
let smaps_at = out
.find("## smaps_rollup")
.expect("smaps section must render");
let after = &out[smaps_at..];
let zoomed_pos = after.find("zoomed").expect("zoomed key must appear");
let alpha_pos = after
.find("alpha_proc")
.expect("alpha_proc key must appear");
assert!(
zoomed_pos < alpha_pos,
"max-Rss tiebreaker must place higher-max-Rss process (zoomed) \
ahead of lower-max-Rss process (alpha_proc) when delta ties; got \
zoomed@{zoomed_pos} alpha_proc@{alpha_pos}",
);
}
#[test]
fn write_diff_smaps_literal_mode_renders_pcomm_tgid_keys() {
let mut leader_a = make_thread("worker", "worker");
leader_a.tid = 4242;
leader_a.tgid = 4242;
leader_a.smaps_rollup_kb.insert("Rss".into(), 4096);
leader_a.smaps_rollup_kb.insert("Pss".into(), 1024);
let snap_a = snap_with(vec![leader_a]);
let mut leader_b = make_thread("worker", "worker");
leader_b.tid = 4242;
leader_b.tgid = 4242;
leader_b.smaps_rollup_kb.insert("Rss".into(), 4096);
leader_b.smaps_rollup_kb.insert("Pss".into(), 2048);
let snap_b = snap_with(vec![leader_b]);
let opts = CompareOptions {
group_by: GroupBy::Pcomm.into(),
cgroup_flatten: vec![],
no_thread_normalize: true,
no_cg_normalize: false,
sort_by: Vec::new(),
};
let diff = compare(&snap_a, &snap_b, &opts);
assert!(
diff.smaps_rollup_a.contains_key("worker[4242]"),
"literal-mode baseline key must be `worker[4242]`; got {:?}",
diff.smaps_rollup_a.keys().collect::<Vec<_>>(),
);
assert!(
diff.smaps_rollup_b.contains_key("worker[4242]"),
"literal-mode candidate key must be `worker[4242]`; got {:?}",
diff.smaps_rollup_b.keys().collect::<Vec<_>>(),
);
assert!(
!diff.smaps_rollup_a.contains_key("worker"),
"literal-mode must NOT carry the normalized `worker` key",
);
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&DisplayOptions::default(),
)
.unwrap();
let smaps_at = out
.find("## smaps_rollup")
.expect("smaps section must render");
let after = &out[smaps_at..];
assert!(
after.contains("worker[4242]"),
"literal-mode rendered table must show `worker[4242]` key:\n{after}",
);
}
#[test]
fn write_diff_smaps_abs_rss_delta_is_primary_sort_key() {
let mut diff = CtprofDiff::default();
let mut a = BTreeMap::new();
a.insert("Rss".to_string(), 200 * 1024 * 1024);
let mut a_b = BTreeMap::new();
a_b.insert("Rss".to_string(), 240 * 1024 * 1024);
let mut z = BTreeMap::new();
z.insert("Rss".to_string(), 10 * 1024 * 1024);
let mut z_b = BTreeMap::new();
z_b.insert("Rss".to_string(), 60 * 1024 * 1024);
diff.smaps_rollup_a.insert("alpha_proc".into(), a);
diff.smaps_rollup_b.insert("alpha_proc".into(), a_b);
diff.smaps_rollup_a.insert("zoomed".into(), z);
diff.smaps_rollup_b.insert("zoomed".into(), z_b);
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&DisplayOptions::default(),
)
.unwrap();
let smaps_at = out
.find("## smaps_rollup")
.expect("smaps section must render");
let after = &out[smaps_at..];
let zoomed_pos = after.find("zoomed").expect("zoomed key must appear");
let alpha_pos = after
.find("alpha_proc")
.expect("alpha_proc key must appear");
assert!(
zoomed_pos < alpha_pos,
"abs-delta primary sort must place larger-delta process (zoomed: \
+50 MiB) ahead of smaller-delta process (alpha_proc: +40 MiB), \
regardless of max-Rss or alpha; got zoomed@{zoomed_pos} \
alpha_proc@{alpha_pos}",
);
}