From 9b29e4185e324d403fd917fbc7e43891fa41eb27 Mon Sep 17 00:00:00 2001 From: tanglearncode Date: Wed, 16 Sep 2026 11:39:08 +0800 Subject: [PATCH] test(core): cover the /proc and /sys readers with shimforge Add 15 tests for hardware.rs and system.rs using shimforge to mock fs::read_to_string and Command::output, so the RISC-V-only reading code can run on any machine. shimforge is dev-only and scoped to x86-64/ARM64, pinned at 0.1.2 (MIT). --- Cargo.lock | 22 +++ crates/riscfetch-core/Cargo.toml | 5 + crates/riscfetch-core/src/hardware.rs | 193 ++++++++++++++++++++++++++ crates/riscfetch-core/src/system.rs | 95 +++++++++++++ 4 files changed, 315 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 91dd8d4..73b8743 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,6 +295,7 @@ name = "riscfetch-core" version = "2.2.0" dependencies = [ "serde", + "shimforge", "sysinfo", ] @@ -353,6 +354,27 @@ dependencies = [ "serde_core", ] +[[package]] +name = "shimforge" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a56d2bb7bcb2a06926453d86216f65cc8cddf26585171f0f5a9e2bab34d75a7" +dependencies = [ + "libc", + "shimforge-macros", +] + +[[package]] +name = "shimforge-macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2faaeecc52d1b5e0e65251aa82d7736cdec69c2a639a1f14fb7d3e2b172c5e4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "strsim" version = "0.11.1" diff --git a/crates/riscfetch-core/Cargo.toml b/crates/riscfetch-core/Cargo.toml index 2d36f2b..0d2de4e 100644 --- a/crates/riscfetch-core/Cargo.toml +++ b/crates/riscfetch-core/Cargo.toml @@ -13,3 +13,8 @@ readme = "README.md" [dependencies] serde = { version = "1.0", features = ["derive"] } sysinfo = "0.31" + +# shimforge supports x86-64 and ARM64 only, so it is scoped out of the RISC-V +# build: cargo build --target riscv64gc-unknown-linux-gnu never resolves it. +[target.'cfg(all(any(target_arch = "x86_64", target_arch = "aarch64"), any(target_os = "linux", target_os = "macos", target_os = "windows")))'.dev-dependencies] +shimforge = "=0.1.2" diff --git a/crates/riscfetch-core/src/hardware.rs b/crates/riscfetch-core/src/hardware.rs index 63dacb3..8797cd7 100644 --- a/crates/riscfetch-core/src/hardware.rs +++ b/crates/riscfetch-core/src/hardware.rs @@ -166,3 +166,196 @@ pub fn get_vector_detail() -> String { result } + +// These readers only return real data on a RISC-V board, so they have never had +// tests. Mocking fs::read_to_string per thread feeds them genuine board +// contents on any machine, leaving the production code untouched. +#[cfg(test)] +#[cfg(all( + any(target_arch = "x86_64", target_arch = "aarch64"), + any(target_os = "linux", target_os = "macos", target_os = "windows") +))] +mod os_tests { + use super::*; + use shimforge::{mock, Session}; + use std::io; + + /// /proc/cpuinfo as reported by a SpacemiT K1 (Banana Pi F3, Orange Pi RV2). + const K1_CPUINFO: &str = "processor\t: 0 +hart\t\t: 0 +isa\t\t: rv64imafdcv_zicsr_zifencei_zvl256b +mvendorid\t: 0x710 +marchid\t\t: 0x8000000058000001 +mimpid\t\t: 0x1000000049772200 + +processor\t: 1 +hart\t\t: 1 +isa\t\t: rv64imafdcv_zicsr_zifencei_zvl256b +"; + + /// Every rule for one target has to live on a single mock handle: each + /// `mock!` site binds once per session, so this is called once per test and + /// adds one rule per file. The first matching rule wins. + fn read_files(session: &mut Session, files: Vec<(&'static str, io::Result)>) { + let read = mock!( + session, + fs::read_to_string::<&str>, + fn(&str) -> io::Result + ); + for (path, content) in files { + let mut content = Some(content); + read.expect() + .with(move |requested| *requested == path) + .once() + .returning(move |_| content.take().expect("one call per rule")); + } + } + + fn missing() -> io::Result { + Err(io::Error::from(io::ErrorKind::NotFound)) + } + + #[test] + fn isa_string_is_read_from_cpuinfo() { + let mut session = Session::new(); + read_files( + &mut session, + vec![("/proc/cpuinfo", Ok(K1_CPUINFO.to_string()))], + ); + assert_eq!(get_isa_string(), "rv64imafdcv_zicsr_zifencei_zvl256b"); + } + + #[test] + fn isa_string_is_unknown_without_cpuinfo() { + let mut session = Session::new(); + read_files(&mut session, vec![("/proc/cpuinfo", missing())]); + assert_eq!(get_isa_string(), "unknown"); + } + + #[test] + fn hardware_ids_are_read_from_cpuinfo() { + let mut session = Session::new(); + read_files( + &mut session, + vec![("/proc/cpuinfo", Ok(K1_CPUINFO.to_string()))], + ); + let ids = get_hardware_ids(); + assert_eq!(ids.mvendorid, "0x710"); + assert_eq!(ids.marchid, "0x8000000058000001"); + assert_eq!(ids.mimpid, "0x1000000049772200"); + } + + #[test] + fn zeroed_hardware_ids_are_left_empty() { + let mut session = Session::new(); + let cpuinfo = "processor\t: 0\nmvendorid\t: 0x0\nmarchid\t\t: 0x0\nmimpid\t\t: 0x0\n"; + read_files( + &mut session, + vec![("/proc/cpuinfo", Ok(cpuinfo.to_string()))], + ); + let ids = get_hardware_ids(); + assert_eq!(ids.mvendorid, ""); + assert_eq!(ids.marchid, ""); + assert_eq!(ids.mimpid, ""); + } + + #[test] + fn several_harts_are_pluralised() { + let mut session = Session::new(); + read_files( + &mut session, + vec![("/proc/cpuinfo", Ok(K1_CPUINFO.to_string()))], + ); + assert_eq!(get_hart_count(), "2 harts"); + } + + #[test] + fn a_single_hart_is_not_pluralised() { + let mut session = Session::new(); + read_files( + &mut session, + vec![("/proc/cpuinfo", Ok("processor\t: 0\n".to_string()))], + ); + assert_eq!(get_hart_count(), "1 hart"); + } + + #[test] + fn hart_count_is_also_available_as_a_number() { + let mut session = Session::new(); + read_files( + &mut session, + vec![("/proc/cpuinfo", Ok(K1_CPUINFO.to_string()))], + ); + assert_eq!(get_hart_count_num(), 2); + } + + #[test] + fn cache_levels_that_are_absent_are_skipped() { + let mut session = Session::new(); + read_files( + &mut session, + vec![ + ( + "/sys/devices/system/cpu/cpu0/cache/index0/size", + Ok("32K\n".to_string()), + ), + ( + "/sys/devices/system/cpu/cpu0/cache/index1/size", + Ok("32K\n".to_string()), + ), + ( + "/sys/devices/system/cpu/cpu0/cache/index2/size", + Ok("512K\n".to_string()), + ), + // A board with no L3: the file is simply not there. + ("/sys/devices/system/cpu/cpu0/cache/index3/size", missing()), + ], + ); + assert_eq!(get_cache_info(), "L1D:32K L1I:32K L2:512K"); + } + + #[test] + fn board_info_prefers_the_device_tree_model() { + let mut session = Session::new(); + // Device tree strings carry a trailing NUL. + read_files( + &mut session, + vec![("/proc/device-tree/model", Ok("Orange Pi RV2\0".to_string()))], + ); + assert_eq!(get_board_info(), "Orange Pi RV2"); + } + + #[test] + fn board_info_falls_back_to_the_compatible_node() { + let mut session = Session::new(); + read_files( + &mut session, + vec![ + ("/proc/device-tree/model", missing()), + ( + "/proc/device-tree/compatible", + Ok("spacemit,k1\0spacemit,k1-x\0".to_string()), + ), + ], + ); + assert_eq!(get_board_info(), "spacemit,k1"); + } + + #[test] + fn vector_detail_adds_the_vlen_reported_by_sysfs() { + let mut session = Session::new(); + read_files( + &mut session, + vec![ + ("/proc/cpuinfo", Ok(K1_CPUINFO.to_string())), + ( + "/sys/devices/system/cpu/cpu0/riscv/vlen", + Ok("256\n".to_string()), + ), + ], + ); + let detail = get_vector_detail(); + assert!(detail.contains("Enabled"), "{detail}"); + assert!(detail.contains("VLEN=256"), "{detail}"); + } +} diff --git a/crates/riscfetch-core/src/system.rs b/crates/riscfetch-core/src/system.rs index 5b71ba8..4b24724 100644 --- a/crates/riscfetch-core/src/system.rs +++ b/crates/riscfetch-core/src/system.rs @@ -75,3 +75,98 @@ pub fn get_uptime() -> String { pub fn get_uptime_seconds() -> u64 { System::uptime() } + +// uname is a child process and /etc/os-release describes the host, so these two +// readers reported whatever machine the suite happened to run on. The mocks +// below pin them to chosen values. +#[cfg(test)] +#[cfg(all( + any(target_arch = "x86_64", target_arch = "aarch64"), + any(target_os = "linux", target_os = "macos", target_os = "windows") +))] +mod os_tests { + use super::*; + use shimforge::{mock, Session}; + use std::io; + use std::process::Output; + + fn exit_ok() -> std::process::ExitStatus { + #[cfg(unix)] + use std::os::unix::process::ExitStatusExt; + #[cfg(windows)] + use std::os::windows::process::ExitStatusExt; + std::process::ExitStatus::from_raw(0) + } + + #[test] + fn kernel_info_comes_from_uname() { + let mut session = Session::new(); + // The uname binary is never launched; the call that would launch it is. + let output = mock!( + session, + Command::output, + fn(&mut Command) -> io::Result + ); + output.expect().once().returning(|_| { + Ok(Output { + status: exit_ok(), + stdout: b"6.6.63-riscv64\n".to_vec(), + stderr: Vec::new(), + }) + }); + assert_eq!(get_kernel_info(), "6.6.63-riscv64"); + } + + #[test] + fn kernel_info_is_unknown_when_uname_says_nothing() { + let mut session = Session::new(); + let output = mock!( + session, + Command::output, + fn(&mut Command) -> io::Result + ); + output.expect().once().returning(|_| { + Ok(Output { + status: exit_ok(), + stdout: Vec::new(), + stderr: Vec::new(), + }) + }); + assert_eq!(get_kernel_info(), "Unknown"); + } + + #[test] + fn os_info_reads_the_pretty_name() { + let mut session = Session::new(); + let read = mock!( + session, + fs::read_to_string::<&str>, + fn(&str) -> io::Result + ); + read.expect() + .with(|path| *path == "/etc/os-release") + .once() + .returning(|_| { + Ok( + "NAME=\"Debian GNU/Linux\"\nPRETTY_NAME=\"Debian GNU/Linux 12 (bookworm)\"\n" + .to_string(), + ) + }); + assert_eq!(get_os_info(), "Debian GNU/Linux 12 (bookworm)"); + } + + #[test] + fn os_info_falls_back_without_os_release() { + let mut session = Session::new(); + let read = mock!( + session, + fs::read_to_string::<&str>, + fn(&str) -> io::Result + ); + read.expect() + .with(|path| *path == "/etc/os-release") + .once() + .returning(|_| Err(io::Error::from(io::ErrorKind::NotFound))); + assert_eq!(get_os_info(), "Linux"); + } +}