From e55c8379ec031a858aecef2bcd21d364d5a3d334 Mon Sep 17 00:00:00 2001 From: Damocles Date: Wed, 15 Apr 2026 02:15:56 +0200 Subject: [PATCH] test: add nova-stats unit tests, wire to nix flake check --- flake.nix | 1 + stats-daemon/src/main.rs | 344 +++++++++++++++++++++++++++++++++------ 2 files changed, 298 insertions(+), 47 deletions(-) diff --git a/flake.nix b/flake.nix index c6ca4c1..0b47359 100644 --- a/flake.nix +++ b/flake.nix @@ -91,6 +91,7 @@ { formatting = treefmt-eval.config.build.check self; build = self.packages.${pkgs.stdenv.hostPlatform.system}.default; + nova-stats = self.packages.${pkgs.stdenv.hostPlatform.system}.nova-stats; } ); diff --git a/stats-daemon/src/main.rs b/stats-daemon/src/main.rs index b65093d..5480baf 100644 --- a/stats-daemon/src/main.rs +++ b/stats-daemon/src/main.rs @@ -8,9 +8,17 @@ struct Sample { total: u64, } -fn read_stat() -> Vec { - fs::read_to_string("/proc/stat") - .unwrap_or_default() +struct MemInfo { + percent: u64, + used_gb: f64, + total_gb: f64, + avail_gb: f64, + cached_gb: f64, + buffers_gb: f64, +} + +fn parse_stat(input: &str) -> Vec { + input .lines() .filter(|l| l.starts_with("cpu")) .map(|l| { @@ -26,6 +34,52 @@ fn read_stat() -> Vec { .collect() } +fn parse_meminfo(input: &str) -> Option { + let mut total = 0u64; + let mut avail = 0u64; + let mut buffers = 0u64; + let mut cached = 0u64; + let mut sreclaimable = 0u64; + + for line in input.lines() { + let mut parts = line.splitn(2, ':'); + let key = parts.next().unwrap_or("").trim(); + let val: u64 = parts + .next() + .unwrap_or("") + .split_whitespace() + .next() + .unwrap_or("") + .parse() + .unwrap_or(0); + match key { + "MemTotal" => total = val, + "MemAvailable" => avail = val, + "Buffers" => buffers = val, + "Cached" => cached = val, + "SReclaimable" => sreclaimable = val, + _ => {} + } + } + + if total == 0 { + return None; + } + + let used = total.saturating_sub(avail); + let cached_total = cached + sreclaimable; + let gb = |kb: u64| kb as f64 / 1_048_576.0; + + Some(MemInfo { + percent: used * 100 / total, + used_gb: gb(used), + total_gb: gb(total), + avail_gb: gb(avail), + cached_gb: gb(cached_total), + buffers_gb: gb(buffers), + }) +} + fn pct(prev: &Sample, curr: &Sample) -> u32 { let dt = curr.total.saturating_sub(prev.total); let di = curr.idle.saturating_sub(prev.idle); @@ -35,6 +89,10 @@ fn pct(prev: &Sample, curr: &Sample) -> u32 { (dt.saturating_sub(di) * 100 / dt) as u32 } +fn read_stat() -> Vec { + parse_stat(&fs::read_to_string("/proc/stat").unwrap_or_default()) +} + fn read_core_freqs() -> Vec { let mut freqs = Vec::new(); for i in 0.. { @@ -96,51 +154,13 @@ fn emit_cpu(out: &mut impl Write, prev: &[Sample], curr: &[Sample], freqs: &[f64 fn emit_mem(out: &mut impl Write) { let content = fs::read_to_string("/proc/meminfo").unwrap_or_default(); - let mut total = 0u64; - let mut avail = 0u64; - let mut buffers = 0u64; - let mut cached = 0u64; - let mut sreclaimable = 0u64; - - for line in content.lines() { - let mut parts = line.splitn(2, ':'); - let key = parts.next().unwrap_or("").trim(); - let val: u64 = parts - .next() - .unwrap_or("") - .split_whitespace() - .next() - .unwrap_or("") - .parse() - .unwrap_or(0); - match key { - "MemTotal" => total = val, - "MemAvailable" => avail = val, - "Buffers" => buffers = val, - "Cached" => cached = val, - "SReclaimable" => sreclaimable = val, - _ => {} - } + if let Some(m) = parse_meminfo(&content) { + let _ = writeln!( + out, + "{{\"type\":\"mem\",\"percent\":{},\"used_gb\":{:.3},\"total_gb\":{:.3},\"avail_gb\":{:.3},\"cached_gb\":{:.3},\"buffers_gb\":{:.3}}}", + m.percent, m.used_gb, m.total_gb, m.avail_gb, m.cached_gb, m.buffers_gb, + ); } - - if total == 0 { - return; - } - - let used = total.saturating_sub(avail); - let cached_total = cached + sreclaimable; - let percent = used * 100 / total; - let gb = |kb: u64| kb as f64 / 1_048_576.0; - - let _ = writeln!( - out, - "{{\"type\":\"mem\",\"percent\":{percent},\"used_gb\":{:.3},\"total_gb\":{:.3},\"avail_gb\":{:.3},\"cached_gb\":{:.3},\"buffers_gb\":{:.3}}}", - gb(used), - gb(total), - gb(avail), - gb(cached_total), - gb(buffers), - ); } fn main() { @@ -170,3 +190,233 @@ fn main() { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // ── pct ────────────────────────────────────────────────────────────── + + #[test] + fn pct_zero_delta_returns_zero() { + let s = Sample { + idle: 100, + total: 400, + }; + assert_eq!(pct(&s, &s), 0); + } + + #[test] + fn pct_all_idle() { + let prev = Sample { + idle: 0, + total: 100, + }; + let curr = Sample { + idle: 100, + total: 200, + }; + assert_eq!(pct(&prev, &curr), 0); + } + + #[test] + fn pct_fully_busy() { + let prev = Sample { + idle: 100, + total: 200, + }; + let curr = Sample { + idle: 100, + total: 300, + }; + assert_eq!(pct(&prev, &curr), 100); + } + + #[test] + fn pct_half_busy() { + let prev = Sample { idle: 0, total: 0 }; + let curr = Sample { + idle: 50, + total: 100, + }; + assert_eq!(pct(&prev, &curr), 50); + } + + #[test] + fn pct_no_underflow_on_backwards_clock() { + let prev = Sample { + idle: 200, + total: 400, + }; + let curr = Sample { + idle: 100, + total: 300, + }; // idle went backwards + // dt=saturating 0, di=saturating 0 → returns 0 + assert_eq!(pct(&prev, &curr), 0); + } + + // ── parse_stat ─────────────────────────────────────────────────────── + + const STAT_SAMPLE: &str = "\ +cpu 100 10 50 700 40 0 0 0 0 0 +cpu0 50 5 25 350 20 0 0 0 0 0 +cpu1 50 5 25 350 20 0 0 0 0 0"; + + #[test] + fn parse_stat_count() { + // aggregate line + 2 cores = 3 samples + let samples = parse_stat(STAT_SAMPLE); + assert_eq!(samples.len(), 3); + } + + #[test] + fn parse_stat_aggregate_idle() { + // idle=field[3], iowait=field[4] → 700+40=740 + let samples = parse_stat(STAT_SAMPLE); + assert_eq!(samples[0].idle, 740); + } + + #[test] + fn parse_stat_aggregate_total() { + // sum of all fields: 100+10+50+700+40 = 900 + let samples = parse_stat(STAT_SAMPLE); + assert_eq!(samples[0].total, 900); + } + + #[test] + fn parse_stat_per_core_idle() { + let samples = parse_stat(STAT_SAMPLE); + assert_eq!(samples[1].idle, 370); // 350+20 + assert_eq!(samples[2].idle, 370); + } + + #[test] + fn parse_stat_ignores_non_cpu_lines() { + let input = "intr 12345\ncpu 1 2 3 4 5 0 0 0 0 0\npage 0 0"; + let samples = parse_stat(input); + assert_eq!(samples.len(), 1); + } + + // ── parse_meminfo ──────────────────────────────────────────────────── + + const MEMINFO_SAMPLE: &str = "\ +MemTotal: 16384000 kB +MemFree: 2048000 kB +MemAvailable: 4096000 kB +Buffers: 512000 kB +Cached: 3072000 kB +SReclaimable: 512000 kB +SwapTotal: 8192000 kB +SwapFree: 8192000 kB"; + + #[test] + fn parse_meminfo_percent() { + let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); + // used = total - avail = 16384000 - 4096000 = 12288000 + // percent = 12288000 * 100 / 16384000 = 75 + assert_eq!(m.percent, 75); + } + + #[test] + fn parse_meminfo_total_gb() { + let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); + // 16384000 kB / 1048576 ≈ 15.625 GB + assert!((m.total_gb - 15.625).abs() < 0.001); + } + + #[test] + fn parse_meminfo_cached_includes_sreclaimable() { + let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); + // cached = 3072000 + 512000 = 3584000 kB + let expected = 3_584_000.0 / 1_048_576.0; + assert!((m.cached_gb - expected).abs() < 0.001); + } + + #[test] + fn parse_meminfo_zero_total_returns_none() { + assert!(parse_meminfo("MemFree: 1000 kB\n").is_none()); + } + + #[test] + fn parse_meminfo_empty_returns_none() { + assert!(parse_meminfo("").is_none()); + } + + // ── emit_cpu ───────────────────────────────────────────────────────── + + fn sample(idle: u64, total: u64) -> Sample { + Sample { idle, total } + } + + #[test] + fn emit_cpu_valid_json_structure() { + let prev = vec![sample(0, 0), sample(0, 0)]; + let curr = vec![sample(50, 100), sample(25, 100)]; + let freqs = vec![3.2, 3.1]; + let mut buf = Vec::new(); + emit_cpu(&mut buf, &prev, &curr, &freqs); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("\"type\":\"cpu\"")); + assert!(s.contains("\"usage\":")); + assert!(s.contains("\"freq_ghz\":")); + assert!(s.contains("\"core_usage\":")); + assert!(s.contains("\"core_freq_ghz\":")); + assert!(s.trim().ends_with('}')); + } + + #[test] + fn emit_cpu_correct_usage() { + // prev aggregate: idle=0, total=0 → curr: idle=50, total=100 → 50% busy + let prev = vec![sample(0, 0), sample(0, 0)]; + let curr = vec![sample(50, 100), sample(0, 0)]; + let mut buf = Vec::new(); + emit_cpu(&mut buf, &prev, &curr, &[]); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("\"usage\":50"), "got: {s}"); + } + + #[test] + fn emit_cpu_no_prev_gives_zero_usage() { + let curr = vec![sample(50, 100)]; + let mut buf = Vec::new(); + emit_cpu(&mut buf, &[], &curr, &[]); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("\"usage\":0"), "got: {s}"); + } + + #[test] + fn emit_cpu_empty_curr_produces_no_output() { + let mut buf = Vec::new(); + emit_cpu(&mut buf, &[], &[], &[]); + assert!(buf.is_empty()); + } + + #[test] + fn emit_cpu_core_freqs_in_output() { + let curr = vec![sample(0, 100)]; + let freqs = vec![3.200, 2.900]; + let mut buf = Vec::new(); + emit_cpu(&mut buf, &curr, &curr, &freqs); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("3.200"), "got: {s}"); + assert!(s.contains("2.900"), "got: {s}"); + } + + // ── emit_mem (via parse_meminfo) ───────────────────────────────────── + + #[test] + fn emit_mem_valid_json_structure() { + let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); + let mut buf = Vec::new(); + let _ = writeln!( + &mut buf, + "{{\"type\":\"mem\",\"percent\":{},\"used_gb\":{:.3},\"total_gb\":{:.3},\"avail_gb\":{:.3},\"cached_gb\":{:.3},\"buffers_gb\":{:.3}}}", + m.percent, m.used_gb, m.total_gb, m.avail_gb, m.cached_gb, m.buffers_gb, + ); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("\"type\":\"mem\"")); + assert!(s.contains("\"percent\":75")); + assert!(s.trim().ends_with('}')); + } +}