#![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 sum_metric_accessors_read_expected_field() {
use crate::metric_types::{Bytes, ClockTicks, MonotonicCount, MonotonicNs};
type MetricSetter = fn(&mut ThreadState);
let cases: &[(&str, MetricSetter)] = &[
("run_time_ns", |t| t.run_time_ns = MonotonicNs(1)),
("wait_time_ns", |t| t.wait_time_ns = MonotonicNs(1)),
("timeslices", |t| t.timeslices = MonotonicCount(1)),
("voluntary_csw", |t| t.voluntary_csw = MonotonicCount(1)),
("nonvoluntary_csw", |t| {
t.nonvoluntary_csw = MonotonicCount(1)
}),
("nr_wakeups", |t| t.nr_wakeups = MonotonicCount(1)),
("nr_wakeups_local", |t| {
t.nr_wakeups_local = MonotonicCount(1)
}),
("nr_wakeups_remote", |t| {
t.nr_wakeups_remote = MonotonicCount(1)
}),
("nr_wakeups_sync", |t| t.nr_wakeups_sync = MonotonicCount(1)),
("nr_wakeups_migrate", |t| {
t.nr_wakeups_migrate = MonotonicCount(1)
}),
("nr_wakeups_affine", |t| {
t.nr_wakeups_affine = MonotonicCount(1)
}),
("nr_wakeups_affine_attempts", |t| {
t.nr_wakeups_affine_attempts = MonotonicCount(1)
}),
("nr_migrations", |t| t.nr_migrations = MonotonicCount(1)),
("nr_forced_migrations", |t| {
t.nr_forced_migrations = MonotonicCount(1)
}),
("nr_failed_migrations_affine", |t| {
t.nr_failed_migrations_affine = MonotonicCount(1)
}),
("nr_failed_migrations_running", |t| {
t.nr_failed_migrations_running = MonotonicCount(1)
}),
("nr_failed_migrations_hot", |t| {
t.nr_failed_migrations_hot = MonotonicCount(1)
}),
("wait_sum", |t| t.wait_sum = MonotonicNs(1)),
("wait_count", |t| t.wait_count = MonotonicCount(1)),
("voluntary_sleep_ns", |t| {
t.voluntary_sleep_ns = MonotonicNs(1)
}),
("block_sum", |t| t.block_sum = MonotonicNs(1)),
("iowait_sum", |t| t.iowait_sum = MonotonicNs(1)),
("iowait_count", |t| t.iowait_count = MonotonicCount(1)),
("allocated_bytes", |t| t.allocated_bytes = Bytes(1)),
("deallocated_bytes", |t| t.deallocated_bytes = Bytes(1)),
("minflt", |t| t.minflt = MonotonicCount(1)),
("majflt", |t| t.majflt = MonotonicCount(1)),
("utime_clock_ticks", |t| t.utime_clock_ticks = ClockTicks(1)),
("stime_clock_ticks", |t| t.stime_clock_ticks = ClockTicks(1)),
("rchar", |t| t.rchar = Bytes(1)),
("wchar", |t| t.wchar = Bytes(1)),
("syscr", |t| t.syscr = MonotonicCount(1)),
("syscw", |t| t.syscw = MonotonicCount(1)),
("read_bytes", |t| t.read_bytes = Bytes(1)),
("write_bytes", |t| t.write_bytes = Bytes(1)),
("cancelled_write_bytes", |t| {
t.cancelled_write_bytes = Bytes(1)
}),
];
for (name, set) in cases {
let mut t = make_thread("p", "w");
set(&mut t);
let def = CTPROF_METRICS
.iter()
.find(|m| m.name == *name)
.unwrap_or_else(|| panic!("metric {name} not in registry"));
let agg = aggregate(def.rule, &[&t]);
match agg {
Aggregated::Sum(v) => {
assert_eq!(v, 1, "accessor for {name} did not read the {name} field",)
}
other => panic!("expected Sum for {name}, got {other:?}"),
}
}
}
#[test]
fn ctprof_metric_names_are_unique() {
let mut seen = std::collections::BTreeSet::new();
for m in CTPROF_METRICS {
assert!(
seen.insert(m.name),
"duplicate metric name in registry: {}",
m.name,
);
}
}
#[test]
fn metric_display_name_no_gates_returns_bare_name() {
let policy = lookup_metric("policy");
assert_eq!(metric_display_name(policy), "policy");
assert!(metric_tags(policy).is_empty());
let cpu_aff = lookup_metric("cpu_affinity");
assert_eq!(metric_display_name(cpu_aff), "cpu_affinity");
assert!(metric_tags(cpu_aff).is_empty());
}
#[test]
fn metric_tags_renders_class_and_config_tags() {
let m = lookup_metric("nr_wakeups_affine");
assert_eq!(metric_display_name(m), "nr_wakeups_affine");
assert_eq!(metric_tags(m), "[cfs-only] [SCHEDSTATS]");
}
#[test]
fn metric_tags_emits_each_config_gate_in_order() {
let core = lookup_metric("core_forceidle_sum");
assert_eq!(metric_display_name(core), "core_forceidle_sum");
assert_eq!(metric_tags(core), "[SCHED_CORE] [SCHEDSTATS]");
}
#[test]
fn metric_tags_class_only_no_config_gate() {
let fair = lookup_metric("fair_slice_ns");
assert_eq!(metric_display_name(fair), "fair_slice_ns");
assert_eq!(metric_tags(fair), "[fair-policy]");
}
#[test]
fn metric_tags_strips_config_prefix() {
for m in CTPROF_METRICS {
for gate in m.config_gates {
assert!(
gate.starts_with("CONFIG_"),
"registry config_gate {gate:?} on metric {} \
must spell the literal CONFIG_X kconfig symbol",
m.name,
);
let tags = metric_tags(m);
let expected_short = gate.strip_prefix("CONFIG_").unwrap();
assert!(
tags.contains(&format!("[{expected_short}]")),
"metric {} tags {tags:?} must contain [{expected_short}]",
m.name,
);
assert!(
!tags.contains(&format!("[{gate}]")),
"metric {} tags {tags:?} must not contain full [{gate}]",
m.name,
);
}
}
}
#[test]
fn metric_tags_marks_synthetic_dead_counter() {
let m = CtprofMetricDef {
name: "synthetic_dead",
rule: AggRule::SumCount(|_| crate::metric_types::MonotonicCount(0)),
sched_class: None,
config_gates: &["CONFIG_SCHEDSTATS"],
is_dead: true,
description: "synthetic dead-counter test fixture.",
section: Section::Primary,
};
assert_eq!(metric_display_name(&m), "synthetic_dead");
assert_eq!(metric_tags(&m), "[dead] [SCHEDSTATS]",);
for m in CTPROF_METRICS {
assert!(
!m.is_dead,
"{} unexpectedly carries is_dead: true — the \
registry is currently empty of dead counters; \
add the entry to the matrix-pin test below if \
a new dead counter is intentional",
m.name,
);
}
}
#[test]
fn metric_tags_renders_non_ext_class() {
let m = lookup_metric("wait_sum");
assert_eq!(metric_display_name(m), "wait_sum");
assert_eq!(metric_tags(m), "[non-ext] [SCHEDSTATS]",);
}
#[test]
fn registry_tag_matrix_is_pinned() {
let matrix: &[(&str, Option<&str>, &[&str], bool)] = &[
("thread_count", None, &[], false),
("policy", None, &[], false),
("nice", None, &[], false),
("priority", None, &[], false),
("rt_priority", None, &[], false),
("cpu_affinity", None, &[], false),
("processor", None, &[], false),
("state", None, &[], false),
("ext_enabled", None, &["CONFIG_SCHED_CLASS_EXT"], false),
("nr_threads", None, &[], false),
("run_time_ns", None, &["CONFIG_SCHED_INFO"], false),
("wait_time_ns", None, &["CONFIG_SCHED_INFO"], false),
("timeslices", None, &["CONFIG_SCHED_INFO"], false),
("voluntary_csw", None, &[], false),
("nonvoluntary_csw", None, &[], false),
("nr_wakeups", None, &["CONFIG_SCHEDSTATS"], false),
("nr_wakeups_local", None, &["CONFIG_SCHEDSTATS"], false),
("nr_wakeups_remote", None, &["CONFIG_SCHEDSTATS"], false),
("nr_wakeups_sync", None, &["CONFIG_SCHEDSTATS"], false),
("nr_wakeups_migrate", None, &["CONFIG_SCHEDSTATS"], false),
(
"nr_wakeups_affine",
Some("cfs-only"),
&["CONFIG_SCHEDSTATS"],
false,
),
(
"nr_wakeups_affine_attempts",
Some("cfs-only"),
&["CONFIG_SCHEDSTATS"],
false,
),
("nr_migrations", None, &[], false),
(
"nr_forced_migrations",
Some("cfs-only"),
&["CONFIG_SCHEDSTATS"],
false,
),
(
"nr_failed_migrations_affine",
Some("cfs-only"),
&["CONFIG_SCHEDSTATS"],
false,
),
(
"nr_failed_migrations_running",
Some("cfs-only"),
&["CONFIG_SCHEDSTATS"],
false,
),
(
"nr_failed_migrations_hot",
Some("cfs-only"),
&["CONFIG_SCHEDSTATS"],
false,
),
("wait_sum", Some("non-ext"), &["CONFIG_SCHEDSTATS"], false),
("wait_count", Some("non-ext"), &["CONFIG_SCHEDSTATS"], false),
("wait_max", Some("non-ext"), &["CONFIG_SCHEDSTATS"], false),
(
"voluntary_sleep_ns",
Some("non-ext"),
&["CONFIG_SCHEDSTATS"],
false,
),
("sleep_max", Some("non-ext"), &["CONFIG_SCHEDSTATS"], false),
("block_sum", Some("non-ext"), &["CONFIG_SCHEDSTATS"], false),
("block_max", Some("non-ext"), &["CONFIG_SCHEDSTATS"], false),
("iowait_sum", Some("non-ext"), &["CONFIG_SCHEDSTATS"], false),
(
"iowait_count",
Some("non-ext"),
&["CONFIG_SCHEDSTATS"],
false,
),
("exec_max", None, &["CONFIG_SCHEDSTATS"], false),
("slice_max", Some("cfs-only"), &["CONFIG_SCHEDSTATS"], false),
(
"core_forceidle_sum",
None,
&["CONFIG_SCHED_CORE", "CONFIG_SCHEDSTATS"],
false,
),
("fair_slice_ns", Some("fair-policy"), &[], false),
("allocated_bytes", None, &[], false),
("deallocated_bytes", None, &[], false),
("minflt", None, &[], false),
("majflt", None, &[], false),
("utime_clock_ticks", None, &[], false),
("stime_clock_ticks", None, &[], false),
("rchar", None, &["CONFIG_TASK_IO_ACCOUNTING"], false),
("wchar", None, &["CONFIG_TASK_IO_ACCOUNTING"], false),
("syscr", None, &["CONFIG_TASK_IO_ACCOUNTING"], false),
("syscw", None, &["CONFIG_TASK_IO_ACCOUNTING"], false),
("read_bytes", None, &["CONFIG_TASK_IO_ACCOUNTING"], false),
("write_bytes", None, &["CONFIG_TASK_IO_ACCOUNTING"], false),
(
"cancelled_write_bytes",
None,
&["CONFIG_TASK_IO_ACCOUNTING"],
false,
),
(
"cpu_delay_count",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"cpu_delay_total_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"cpu_delay_max_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"cpu_delay_min_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"blkio_delay_count",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"blkio_delay_total_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"blkio_delay_max_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"blkio_delay_min_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"swapin_delay_count",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"swapin_delay_total_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"swapin_delay_max_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"swapin_delay_min_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"freepages_delay_count",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"freepages_delay_total_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"freepages_delay_max_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"freepages_delay_min_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"thrashing_delay_count",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"thrashing_delay_total_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"thrashing_delay_max_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"thrashing_delay_min_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"compact_delay_count",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"compact_delay_total_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"compact_delay_max_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"compact_delay_min_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"wpcopy_delay_count",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"wpcopy_delay_total_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"wpcopy_delay_max_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"wpcopy_delay_min_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"irq_delay_count",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"irq_delay_total_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"irq_delay_max_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"irq_delay_min_ns",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_DELAY_ACCT"],
false,
),
(
"hiwater_rss_bytes",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_XACCT"],
false,
),
(
"hiwater_vm_bytes",
None,
&["CONFIG_TASKSTATS", "CONFIG_TASK_XACCT"],
false,
),
];
let registry_names: std::collections::BTreeSet<&str> =
CTPROF_METRICS.iter().map(|m| m.name).collect();
let matrix_names: std::collections::BTreeSet<&str> =
matrix.iter().map(|(n, _, _, _)| *n).collect();
assert_eq!(
registry_names, matrix_names,
"registry vs matrix key mismatch — every metric must be \
pinned in the locked matrix and the matrix must not name \
metrics that aren't registered",
);
for (name, expected_class, expected_gates, expected_dead) in matrix {
let m = lookup_metric(name);
assert_eq!(m.sched_class, *expected_class, "{name}: sched_class drift",);
assert_eq!(
m.config_gates, *expected_gates,
"{name}: config_gates drift",
);
assert_eq!(m.is_dead, *expected_dead, "{name}: is_dead drift");
}
}
#[test]
fn registry_tag_vocabulary_is_closed() {
let allowed_classes: std::collections::BTreeSet<&str> =
["non-ext", "cfs-only", "fair-policy"].into_iter().collect();
let allowed_gates: std::collections::BTreeSet<&str> = [
"CONFIG_SCHED_INFO",
"CONFIG_SCHEDSTATS",
"CONFIG_SCHED_CORE",
"CONFIG_TASK_DELAY_ACCT",
"CONFIG_TASK_IO_ACCOUNTING",
"CONFIG_TASK_XACCT",
"CONFIG_SCHED_CLASS_EXT",
"CONFIG_TASKSTATS",
]
.into_iter()
.collect();
for m in CTPROF_METRICS {
if let Some(class) = m.sched_class {
assert!(
allowed_classes.contains(class),
"{}: sched_class {class:?} outside the closed set \
{{None, \"non-ext\", \"cfs-only\", \"fair-policy\"}}",
m.name,
);
}
for gate in m.config_gates {
assert!(
gate.starts_with("CONFIG_"),
"{}: config_gate {gate:?} must start with CONFIG_",
m.name,
);
assert!(
allowed_gates.contains(gate),
"{}: config_gate {gate:?} outside the closed set \
{allowed_gates:?}",
m.name,
);
}
}
}
#[test]
fn write_diff_renders_tagged_metric_cell() {
let mut a = make_thread("p", "w");
a.nr_wakeups_affine = MonotonicCount(5);
let mut b = make_thread("p", "w");
b.nr_wakeups_affine = MonotonicCount(9);
let diff = compare(
&snap_with(vec![a]),
&snap_with(vec![b]),
&CompareOptions::default(),
);
let mut display = DisplayOptions::default();
display.columns = vec![
Column::Group,
Column::Threads,
Column::Metric,
Column::Tags,
Column::Baseline,
Column::Candidate,
Column::Delta,
Column::Pct,
];
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&display,
)
.unwrap();
assert!(
out.contains("[cfs-only] [SCHEDSTATS]"),
"tagged metric tags missing from rendered tags column:\n{out}",
);
assert!(
out.contains("nr_wakeups_affine"),
"tagged metric name missing from rendered table:\n{out}",
);
}
#[test]
fn write_diff_renders_non_ext_metric_cell() {
let mut a = make_thread("p", "w");
a.wait_sum = MonotonicNs(100);
let mut b = make_thread("p", "w");
b.wait_sum = MonotonicNs(200);
let diff = compare(
&snap_with(vec![a]),
&snap_with(vec![b]),
&CompareOptions::default(),
);
let mut display = DisplayOptions::default();
display.columns = vec![
Column::Group,
Column::Threads,
Column::Metric,
Column::Tags,
Column::Baseline,
Column::Candidate,
Column::Delta,
Column::Pct,
];
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&display,
)
.unwrap();
assert!(
out.contains("[non-ext] [SCHEDSTATS]"),
"non-ext metric tags missing from rendered tags column:\n{out}",
);
assert!(
out.contains("wait_sum"),
"non-ext metric name missing from rendered table:\n{out}",
);
}
#[test]
fn voluntary_sleep_sum_derived_metric_is_removed() {
let names: std::collections::BTreeSet<&'static str> =
CTPROF_DERIVED_METRICS.iter().map(|m| m.name).collect();
assert!(
!names.contains("voluntary_sleep_sum"),
"voluntary_sleep_sum derived metric must not exist — \
the normalization moved to capture-side \
`voluntary_sleep_ns` (see ThreadState field doc). \
Got derived metrics: {names:?}",
);
}
#[test]
fn derived_avg_delay_ns_returns_none_on_missing_input() {
let lookup = |name: &str| -> &DerivedMetricDef {
CTPROF_DERIVED_METRICS
.iter()
.find(|d| d.name == name)
.unwrap_or_else(|| panic!("{name} present in registry"))
};
for (name, numerator) in [
("avg_cpu_delay_ns", "cpu_delay_total_ns"),
("avg_blkio_delay_ns", "blkio_delay_total_ns"),
("avg_swapin_delay_ns", "swapin_delay_total_ns"),
("avg_freepages_delay_ns", "freepages_delay_total_ns"),
("avg_thrashing_delay_ns", "thrashing_delay_total_ns"),
("avg_compact_delay_ns", "compact_delay_total_ns"),
("avg_wpcopy_delay_ns", "wpcopy_delay_total_ns"),
("avg_irq_delay_ns", "irq_delay_total_ns"),
] {
let mut metrics: BTreeMap<String, Aggregated> = BTreeMap::new();
metrics.insert(numerator.to_string(), Aggregated::Sum(123));
let def = lookup(name);
assert!(
(def.compute)(&metrics).is_none(),
"{name}: compute must return None when denominator is \
missing from metrics map (only {numerator} present)",
);
}
let mut partial: BTreeMap<String, Aggregated> = BTreeMap::new();
for name in [
"cpu_delay_total_ns",
"blkio_delay_total_ns",
"swapin_delay_total_ns",
"freepages_delay_total_ns",
"thrashing_delay_total_ns",
"wpcopy_delay_total_ns",
"irq_delay_total_ns",
] {
partial.insert(name.to_string(), Aggregated::Sum(100));
}
let total_def = lookup("total_offcpu_delay_ns");
assert!(
(total_def.compute)(&partial).is_none(),
"total_offcpu_delay_ns: compute must return None when ANY \
input is missing — exercised here with compact_delay_total_ns \
omitted from the metrics map",
);
}
#[test]
fn registry_and_derived_names_disjoint() {
let primary: std::collections::BTreeSet<&str> = CTPROF_METRICS.iter().map(|m| m.name).collect();
for d in CTPROF_DERIVED_METRICS {
assert!(
!primary.contains(d.name),
"derived metric {} shadows primary registry name",
d.name,
);
}
}
#[test]
fn registry_derived_metrics_well_formed() {
for d in CTPROF_DERIVED_METRICS {
assert!(
!d.description.is_empty(),
"derived metric {} has empty description",
d.name,
);
assert!(
!d.inputs.is_empty(),
"derived metric {} has empty inputs list",
d.name,
);
let primary: std::collections::BTreeSet<&str> =
CTPROF_METRICS.iter().map(|m| m.name).collect();
for input in d.inputs {
assert!(
primary.contains(input),
"derived metric {} cites unknown input {input}",
d.name,
);
}
}
}
#[test]
fn write_metric_list_emits_derived_section() {
let mut out = String::new();
write_metric_list(&mut out).unwrap();
assert!(
out.contains("## Derived metrics"),
"metric-list must emit a Derived metrics header:\n{out}",
);
for d in CTPROF_DERIVED_METRICS {
assert!(
out.contains(d.name),
"derived metric {} missing from metric-list:\n{out}",
d.name,
);
}
}
#[test]
fn write_metric_list_emits_sections_vocabulary() {
let mut out = String::new();
write_metric_list(&mut out).unwrap();
assert!(
out.contains("## Sections"),
"metric-list must emit the Sections vocabulary heading:\n{out}",
);
for section in Section::ALL {
assert!(
out.contains(section.cli_name()),
"section cli_name {} missing from Sections \
vocabulary table:\n{out}",
section.cli_name(),
);
}
}
#[test]
fn write_metric_list_sections_precedes_metrics() {
let mut out = String::new();
write_metric_list(&mut out).unwrap();
let sections_at = out
.find("## Sections")
.expect("Sections heading must be present");
let metrics_at = out
.find("## Metrics")
.expect("Metrics heading must be present");
assert!(
sections_at < metrics_at,
"Sections heading must precede Metrics heading; \
got Sections@{sections_at} Metrics@{metrics_at}\n{out}",
);
}
#[test]
fn write_diff_sort_by_derived_metric_ranks_groups() {
let mut high_a = make_thread("p", "w");
high_a.pcomm = "high".to_string();
high_a.wait_sum = MonotonicNs(100);
high_a.wait_count = MonotonicCount(1);
let mut high_b = make_thread("p", "w");
high_b.pcomm = "high".to_string();
high_b.wait_sum = MonotonicNs(300);
high_b.wait_count = MonotonicCount(1);
let mut low_a = make_thread("p", "w");
low_a.pcomm = "low".to_string();
low_a.wait_sum = MonotonicNs(100);
low_a.wait_count = MonotonicCount(1);
let mut low_b = make_thread("p", "w");
low_b.pcomm = "low".to_string();
low_b.wait_sum = MonotonicNs(150);
low_b.wait_count = MonotonicCount(1);
let opts = CompareOptions {
sort_by: vec![SortKey {
metric: "avg_wait_ns",
descending: true,
}],
..CompareOptions::default()
};
let diff = compare(
&snap_with(vec![high_a, low_a]),
&snap_with(vec![high_b, low_b]),
&opts,
);
let first = &diff.derived_rows[0];
assert_eq!(
first.group_key, "high",
"descending sort by avg_wait_ns must put `high` first; \
got {:?}",
first.group_key,
);
}
#[test]
fn write_metric_list_emits_full_tag_legend() {
let mut out = String::new();
write_metric_list(&mut out).unwrap();
assert!(
out.contains("[cfs-only]"),
"missing [cfs-only] in legend:\n{out}"
);
assert!(
out.contains("[non-ext]"),
"missing [non-ext] in legend:\n{out}"
);
assert!(
out.contains("[fair-policy]"),
"missing [fair-policy] in legend:\n{out}",
);
assert!(
out.contains("[SCHED_INFO]"),
"missing [SCHED_INFO] in legend:\n{out}"
);
assert!(
out.contains("[SCHEDSTATS]"),
"missing [SCHEDSTATS] in legend:\n{out}",
);
assert!(
out.contains("[SCHED_CORE]"),
"missing [SCHED_CORE] in legend:\n{out}"
);
assert!(
out.contains("[SCHED_CLASS_EXT]"),
"missing [SCHED_CLASS_EXT] in legend:\n{out}",
);
assert!(
out.contains("[TASK_DELAY_ACCT]"),
"missing [TASK_DELAY_ACCT] in legend:\n{out}",
);
assert!(
out.contains("[TASK_IO_ACCOUNTING]"),
"missing [TASK_IO_ACCOUNTING] in legend:\n{out}",
);
assert!(
out.contains("[TASKSTATS]"),
"missing [TASKSTATS] in legend:\n{out}",
);
assert!(
out.contains("[TASK_XACCT]"),
"missing [TASK_XACCT] in legend:\n{out}",
);
assert!(out.contains("[dead]"), "missing [dead] in legend:\n{out}");
assert!(
out.contains("## Tag legend"),
"missing Tag legend section header:\n{out}",
);
assert!(
out.contains("## Metrics"),
"missing Metrics section header:\n{out}",
);
}
#[test]
fn write_metric_list_covers_every_registered_metric() {
let mut out = String::new();
write_metric_list(&mut out).unwrap();
for m in CTPROF_METRICS {
assert!(
out.contains(m.name),
"metric {} missing from metric-list output:\n{out}",
m.name,
);
assert!(
out.contains(m.description),
"description for {} missing from metric-list output:\n{out}",
m.name,
);
}
}
#[test]
fn write_metric_list_tags_column_excludes_metric_name() {
let mut out = String::new();
write_metric_list(&mut out).unwrap();
assert!(
out.contains("[cfs-only] [SCHEDSTATS]"),
"expected bare tag pair `[cfs-only] [SCHEDSTATS]` in tags column:\n{out}",
);
assert!(
!out.contains("nr_wakeups_affine [cfs-only]"),
"metric name must not leak into tags column:\n{out}",
);
}
#[test]
fn registry_descriptions_are_non_empty() {
for m in CTPROF_METRICS {
assert!(
!m.description.is_empty(),
"metric {} has empty description",
m.name,
);
assert_eq!(
m.description.trim(),
m.description,
"metric {} description has leading/trailing whitespace",
m.name,
);
}
}
#[test]
fn max_metric_accessors_read_expected_field() {
type MetricSetter = fn(&mut ThreadState);
let cases: &[(&str, MetricSetter)] = &[
("wait_max", |t| t.wait_max = PeakNs(1)),
("sleep_max", |t| t.sleep_max = PeakNs(1)),
("block_max", |t| t.block_max = PeakNs(1)),
("exec_max", |t| t.exec_max = PeakNs(1)),
("slice_max", |t| t.slice_max = PeakNs(1)),
];
for (name, set) in cases {
let mut t = make_thread("p", "w");
set(&mut t);
let def = CTPROF_METRICS
.iter()
.find(|m| m.name == *name)
.unwrap_or_else(|| panic!("metric {name} not in registry"));
let agg = aggregate(def.rule, &[&t]);
match agg {
Aggregated::Max(v) => {
assert_eq!(v, 1, "accessor for {name} did not read the {name} field")
}
other => panic!("expected Max for {name}, got {other:?}"),
}
}
}
#[test]
fn sort_diff_rows_by_keys_ranks_by_first_key_first() {
let mk_row = |group: &str, metric: &'static str, delta: f64| DiffRow {
group_key: group.into(),
thread_count_a: 1,
thread_count_b: 1,
metric_name: metric,
metric_ladder: ScaleLadder::None,
baseline: Aggregated::Sum(0),
candidate: Aggregated::Sum(0),
delta: Some(delta),
delta_pct: None,
display_key: group.into(),
uptime_pct: None,
sort_by_cell: None,
sort_by_delta: None,
};
let mut rows = vec![
mk_row("A", "run_time_ns", 1000.0),
mk_row("A", "wait_sum", 100.0),
mk_row("B", "run_time_ns", 100.0),
mk_row("B", "wait_sum", 1000.0),
mk_row("C", "run_time_ns", 50.0),
mk_row("C", "wait_sum", 50.0),
];
sort_diff_rows_by_keys(
&mut rows,
&mut Vec::new(),
&[SortKey {
metric: "run_time_ns",
descending: true,
}],
);
let groups_in_order: Vec<&str> = rows.iter().map(|r| r.group_key.as_str()).collect();
assert_eq!(
groups_in_order,
vec!["A", "A", "B", "B", "C", "C"],
"groups should rank by run_time_ns delta desc",
);
let metrics_first_two: Vec<&str> = rows.iter().take(2).map(|r| r.metric_name).collect();
assert_eq!(metrics_first_two, vec!["run_time_ns", "wait_sum"]);
}
#[test]
fn sort_diff_rows_by_keys_breaks_ties_with_second_key() {
let mk_row = |group: &str, metric: &'static str, delta: f64| DiffRow {
group_key: group.into(),
thread_count_a: 1,
thread_count_b: 1,
metric_name: metric,
metric_ladder: ScaleLadder::None,
baseline: Aggregated::Sum(0),
candidate: Aggregated::Sum(0),
delta: Some(delta),
delta_pct: None,
display_key: group.into(),
uptime_pct: None,
sort_by_cell: None,
sort_by_delta: None,
};
let mut rows = vec![
mk_row("A", "run_time_ns", 500.0),
mk_row("A", "wait_sum", 100.0),
mk_row("B", "run_time_ns", 500.0),
mk_row("B", "wait_sum", 200.0),
];
sort_diff_rows_by_keys(
&mut rows,
&mut Vec::new(),
&[
SortKey {
metric: "run_time_ns",
descending: true,
},
SortKey {
metric: "wait_sum",
descending: true,
},
],
);
let groups_in_order: Vec<&str> = rows.iter().map(|r| r.group_key.as_str()).collect();
assert_eq!(groups_in_order, vec!["B", "B", "A", "A"]);
}
#[test]
fn sort_diff_rows_by_keys_respects_ascending_direction() {
let mk_row = |group: &str, metric: &'static str, delta: f64| DiffRow {
group_key: group.into(),
thread_count_a: 1,
thread_count_b: 1,
metric_name: metric,
metric_ladder: ScaleLadder::None,
baseline: Aggregated::Sum(0),
candidate: Aggregated::Sum(0),
delta: Some(delta),
delta_pct: None,
display_key: group.into(),
uptime_pct: None,
sort_by_cell: None,
sort_by_delta: None,
};
let mut rows = vec![
mk_row("A", "run_time_ns", 1000.0),
mk_row("B", "run_time_ns", 100.0),
mk_row("C", "run_time_ns", 500.0),
];
sort_diff_rows_by_keys(
&mut rows,
&mut Vec::new(),
&[SortKey {
metric: "run_time_ns",
descending: false, }],
);
let groups_in_order: Vec<&str> = rows.iter().map(|r| r.group_key.as_str()).collect();
assert_eq!(groups_in_order, vec!["B", "C", "A"]);
}
#[test]
fn sort_diff_rows_by_keys_falls_back_to_ascending_group_key_on_full_tie() {
let mk_row = |group: &str, metric: &'static str, delta: f64| DiffRow {
group_key: group.into(),
thread_count_a: 1,
thread_count_b: 1,
metric_name: metric,
metric_ladder: ScaleLadder::None,
baseline: Aggregated::Sum(0),
candidate: Aggregated::Sum(0),
delta: Some(delta),
delta_pct: None,
display_key: group.into(),
uptime_pct: None,
sort_by_cell: None,
sort_by_delta: None,
};
let mut rows = vec![
mk_row("charlie", "run_time_ns", 100.0),
mk_row("bravo", "run_time_ns", 100.0),
mk_row("alpha", "run_time_ns", 100.0),
];
sort_diff_rows_by_keys(
&mut rows,
&mut Vec::new(),
&[SortKey {
metric: "run_time_ns",
descending: true,
}],
);
let order: Vec<&str> = rows.iter().map(|r| r.group_key.as_str()).collect();
assert_eq!(
order,
vec!["alpha", "bravo", "charlie"],
"full sort-key tie must fall back to ascending group_key",
);
}
#[test]
fn sort_diff_rows_by_keys_missing_metric_sinks_under_desc() {
let mk_row = |group: &str, metric: &'static str, delta: Option<f64>| DiffRow {
group_key: group.into(),
thread_count_a: 1,
thread_count_b: 1,
metric_name: metric,
metric_ladder: ScaleLadder::None,
baseline: Aggregated::Sum(0),
candidate: Aggregated::Sum(0),
delta,
delta_pct: None,
display_key: group.into(),
uptime_pct: None,
sort_by_cell: None,
sort_by_delta: None,
};
let mut rows = vec![
mk_row("alpha", "run_time_ns", Some(100.0)),
mk_row("bravo", "wait_time_ns", Some(999_999.0)),
];
sort_diff_rows_by_keys(
&mut rows,
&mut Vec::new(),
&[SortKey {
metric: "run_time_ns",
descending: true,
}],
);
let mut order: Vec<&str> = Vec::new();
for r in &rows {
if !order.contains(&r.group_key.as_str()) {
order.push(r.group_key.as_str());
}
}
assert_eq!(
order,
vec!["alpha", "bravo"],
"missing metric under desc must sink the group (NEG_INFINITY)",
);
}
#[test]
fn sort_diff_rows_by_keys_missing_metric_sinks_under_asc() {
let mk_row = |group: &str, metric: &'static str, delta: Option<f64>| DiffRow {
group_key: group.into(),
thread_count_a: 1,
thread_count_b: 1,
metric_name: metric,
metric_ladder: ScaleLadder::None,
baseline: Aggregated::Sum(0),
candidate: Aggregated::Sum(0),
delta,
delta_pct: None,
display_key: group.into(),
uptime_pct: None,
sort_by_cell: None,
sort_by_delta: None,
};
let mut rows = vec![
mk_row("alpha", "run_time_ns", Some(100.0)),
mk_row("bravo", "wait_time_ns", Some(50.0)),
];
sort_diff_rows_by_keys(
&mut rows,
&mut Vec::new(),
&[SortKey {
metric: "run_time_ns",
descending: false,
}],
);
let mut order: Vec<&str> = Vec::new();
for r in &rows {
if !order.contains(&r.group_key.as_str()) {
order.push(r.group_key.as_str());
}
}
assert_eq!(
order,
vec!["alpha", "bravo"],
"missing metric under asc must sink the group (INFINITY)",
);
}
#[test]
fn sort_diff_rows_by_keys_categorical_only_group_does_not_panic() {
let mk_row = |group: &str, metric: &'static str| DiffRow {
group_key: group.into(),
thread_count_a: 1,
thread_count_b: 1,
metric_name: metric,
metric_ladder: ScaleLadder::None,
baseline: Aggregated::mode_single("SCHED_OTHER".into(), 1, 1),
candidate: Aggregated::mode_single("SCHED_OTHER".into(), 1, 1),
delta: None,
delta_pct: None,
display_key: group.into(),
uptime_pct: None,
sort_by_cell: None,
sort_by_delta: None,
};
let mut rows = vec![mk_row("alpha", "policy"), mk_row("bravo", "policy")];
sort_diff_rows_by_keys(
&mut rows,
&mut Vec::new(),
&[SortKey {
metric: "run_time_ns",
descending: true,
}],
);
let order: Vec<&str> = rows.iter().map(|r| r.group_key.as_str()).collect();
assert_eq!(
order,
vec!["alpha", "bravo"],
"categorical-only groups must survive the sort and fall to ascending group_key",
);
}
#[test]
fn sort_diff_rows_by_keys_within_group_uses_registry_order() {
let mk_row = |group: &str, metric: &'static str, delta: f64| DiffRow {
group_key: group.into(),
thread_count_a: 1,
thread_count_b: 1,
metric_name: metric,
metric_ladder: ScaleLadder::None,
baseline: Aggregated::Sum(0),
candidate: Aggregated::Sum(0),
delta: Some(delta),
delta_pct: None,
display_key: group.into(),
uptime_pct: None,
sort_by_cell: None,
sort_by_delta: None,
};
let mut rows = vec![
mk_row("alpha", "nr_wakeups", 4.0),
mk_row("alpha", "timeslices", 3.0),
mk_row("alpha", "wait_time_ns", 999.0),
mk_row("alpha", "run_time_ns", 1.0),
];
sort_diff_rows_by_keys(
&mut rows,
&mut Vec::new(),
&[SortKey {
metric: "wait_time_ns",
descending: true,
}],
);
let metric_order: Vec<&str> = rows.iter().map(|r| r.metric_name).collect();
assert_eq!(
metric_order,
vec!["run_time_ns", "wait_time_ns", "timeslices", "nr_wakeups"],
"within-group order must be registry, not sort-spec, order",
);
}
#[test]
fn sort_diff_rows_by_keys_nan_delta_does_not_panic() {
let mk_row = |group: &str, metric: &'static str, delta: f64| DiffRow {
group_key: group.into(),
thread_count_a: 1,
thread_count_b: 1,
metric_name: metric,
metric_ladder: ScaleLadder::None,
baseline: Aggregated::Sum(0),
candidate: Aggregated::Sum(0),
delta: Some(delta),
delta_pct: None,
display_key: group.into(),
uptime_pct: None,
sort_by_cell: None,
sort_by_delta: None,
};
let mut rows = vec![
mk_row("alpha", "run_time_ns", f64::NAN),
mk_row("bravo", "run_time_ns", 100.0),
mk_row("charlie", "run_time_ns", f64::NAN),
];
sort_diff_rows_by_keys(
&mut rows,
&mut Vec::new(),
&[SortKey {
metric: "run_time_ns",
descending: true,
}],
);
let mut groups: Vec<&str> = rows.iter().map(|r| r.group_key.as_str()).collect();
groups.sort();
groups.dedup();
assert_eq!(
groups,
vec!["alpha", "bravo", "charlie"],
"NaN delta must not drop or duplicate any group",
);
}