flowrs-engine/crates/flowrs-core/tests/linux_target.rs
flowRS fd958c0547 flowRS engine, cut from flowRS 3451b065
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:04:39 -05:00

360 lines
14 KiB
Rust

//! The Linux target, built and run.
//!
//! A graph compiled for Linux is a Cargo project (`codegen::linux::to_linux_project`) whose
//! program drives `flowrs_device::linux_io::LinuxBoard`. These tests build that project and
//! run it on this machine with the board's simulated GPIO chip (`FLOWRS_GPIO_CHIP=sim`) and
//! sysfs trees laid out in a temp directory, so what the generated program does to its pins
//! is read back from the board's own record. The hardware test it stands in for (a Pi 5
//! toggling a line that gpiocdev reads back 1,000 times) and the kernel's `gpio-sim` run
//! need a Linux machine; `tools/linux-gpio-loopback.sh` is that run.
//!
//! The cross-builds for both GNU/Linux triples under `-D warnings` are `#[ignore]`d: they
//! need a linker for each triple (a GNU cross gcc, or cargo-zigbuild and zig; see
//! `CrossLinker`), and minutes. `cargo test -p flowrs-core --test linux_target -- --ignored`
//! runs them.
//!
//! Needs cargo and the crates flowrs-device uses in the local cache (`--offline`), as the
//! MCU target's compile tests do.
use std::path::{Path, PathBuf};
use std::process::Command;
use flowrs_connect::scaffold::{CrossLinker, DeviceSource, LINUX_TRIPLES};
use flowrs_core::codegen::link::{LinkChannel, LinkEnd, LinkPlan, WireTy};
use flowrs_core::codegen::linux::{to_linux_project, to_linux_project_linked};
use flowrs_core::graph::WireMode;
use flowrs_core::graph::ir::{Graph, NodeId};
fn device() -> DeviceSource {
let p = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../flowrs-device")
.canonicalize()
.expect("the flowrs-device crate beside this one");
DeviceSource::Path(p.display().to_string())
}
fn set(g: &mut Graph, id: NodeId, k: &str, v: &str) {
g.get_node_mut(id)
.unwrap()
.properties
.insert(k.into(), v.into());
}
/// A held toggle drives GPIO 17; GPIO 27, jumpered to 17 on the bench, is read and mirrored
/// to GPIO 22. Each pass of the step loop is one toggle.
fn loopback() -> Graph {
let mut g = Graph::new();
let not = g.add_node("logic.not", 0.0, 0.0);
let held = g
.add_wire(not, "result", not, "value", WireMode::Copy)
.unwrap();
g.wires.get_mut(&held).unwrap().feedback = true;
let out = g.add_node("embedded.gpio.digital_out", 0.0, 0.0);
set(&mut g, out, "pin", "17");
g.add_wire(not, "result", out, "state", WireMode::Copy)
.unwrap();
let din = g.add_node("embedded.gpio.digital_in", 0.0, 0.0);
set(&mut g, din, "pin", "27");
let mirror = g.add_node("embedded.gpio.digital_out", 0.0, 0.0);
set(&mut g, mirror, "pin", "22");
g.add_wire(din, "state", mirror, "state", WireMode::Copy)
.unwrap();
g
}
/// An ADC reading sets a PWM duty: the analog half of the surface.
fn adc_to_pwm() -> Graph {
let mut g = Graph::new();
let adc = g.add_node("embedded.adc", 0.0, 0.0);
set(&mut g, adc, "channel", "0");
let pwm = g.add_node("embedded.pwm", 0.0, 0.0);
set(&mut g, pwm, "pin", "1");
set(&mut g, pwm, "frequency", "1000");
g.add_wire(adc, "voltage", pwm, "duty", WireMode::Copy)
.unwrap();
g
}
/// Every embedded node once, nothing left unread: the program the cross-builds check.
fn every_embedded_node() -> Graph {
let mut g = loopback();
g.add_node("embedded.pwm", 0.0, 0.0);
let rx = g.add_node("embedded.uart.read", 0.0, 0.0);
let tx = g.add_node("embedded.uart.write", 0.0, 0.0);
g.add_wire(rx, "data", tx, "data", WireMode::Copy).unwrap();
let i2c = g.add_node("embedded.i2c.read", 0.0, 0.0);
set(&mut g, i2c, "length", "2");
let spi = g.add_node("embedded.spi.transfer", 0.0, 0.0);
set(&mut g, spi, "cs_pin", "0");
g.add_wire(i2c, "data", spi, "tx_data", WireMode::Copy)
.unwrap();
let w = g.add_node("embedded.i2c.write", 0.0, 0.0);
g.add_wire(spi, "rx_data", w, "data", WireMode::Copy)
.unwrap();
g.add_node("embedded.adc", 0.0, 0.0);
g.add_node("embedded.delay", 0.0, 0.0);
g.add_node("embedded.timer.interval", 0.0, 0.0);
g
}
/// The project for `graph` written under the temp dir, every build of it sharing one
/// target dir so flowrs-device compiles once per run.
fn write_project(name: &str, graph: &Graph, steps: usize) -> PathBuf {
let dir = std::env::temp_dir().join("flowrs-linux-target").join(name);
let _ = std::fs::remove_dir_all(&dir);
let project = to_linux_project(graph, steps, name, &device()).expect("a Linux project");
for f in &project.files {
let p = dir.join(&f.path);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, &f.contents).unwrap();
}
dir
}
fn target_dir() -> PathBuf {
std::env::temp_dir()
.join("flowrs-linux-target")
.join("target")
}
/// Build the project for this machine and return the binary.
fn build_native(dir: &Path, name: &str) -> PathBuf {
let out = Command::new("cargo")
.current_dir(dir)
.env("CARGO_TARGET_DIR", target_dir())
.env("RUSTFLAGS", "-D warnings")
.args(["build", "--offline", "--quiet"])
.output()
.expect("cargo");
assert!(
out.status.success(),
"{name} did not build:\n{}\n{}",
std::fs::read_to_string(dir.join("src/main.rs")).unwrap_or_default(),
String::from_utf8_lossy(&out.stderr)
);
target_dir().join("debug").join(name)
}
/// The 1,000-toggle loopback, on the board's simulated GPIO chip.
///
/// Line 17 is jumpered to 27 on the simulated chip, and the board reads 27 back after every
/// write to 17 (`FLOWRS_GPIO_READBACK=17:27`). The program must finish cleanly and the chip's
/// record must show 1,000 writes to 17, alternating from high, each followed at once by a
/// read of 27 at the same level. With the jumper pulled, the first high write fails the
/// run, naming both lines: the check can fail.
#[test]
fn the_generated_program_toggles_a_line_that_reads_back_1000_times() {
let dir = write_project("gpio-loopback", &loopback(), 1000);
let bin = build_native(&dir, "gpio-loopback");
let log = dir.join("gpio.log");
let run = |wired: bool| {
let mut c = Command::new(&bin);
c.env("FLOWRS_GPIO_CHIP", "sim")
.env("FLOWRS_GPIO_SIM_LINES", "32")
.env("FLOWRS_GPIO_READBACK", "17:27")
.env("FLOWRS_GPIO_SIM_LOG", &log);
if wired {
c.env("FLOWRS_GPIO_SIM_WIRES", "17:27");
}
c.output().expect("run the generated program")
};
let out = run(true);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "the loopback failed:\n{stderr}");
assert!(
stderr.contains("1000 of 1000 checked GPIO writes read back"),
"{stderr}"
);
let record = std::fs::read_to_string(&log).expect("the simulated chip's record");
let events: Vec<&str> = record.lines().collect();
let mut writes = 0;
for (i, e) in events.iter().enumerate() {
let level = match *e {
"claim 17 output high" | "set 17 high" => "high",
"set 17 low" => "low",
_ => continue,
};
writes += 1;
let want = if writes % 2 == 1 { "high" } else { "low" };
assert_eq!(level, want, "write {writes} drove 17 {level}");
// The next read, past the request for line 27 that the first readback makes.
let next = events[i + 1..].iter().find(|e| !e.starts_with("claim "));
assert_eq!(
next.copied(),
Some(format!("get 27 {level}").as_str()),
"write {writes} was not read back on 27 at once"
);
}
assert_eq!(writes, 1000, "{record}");
// The graph's own read of 27 saw the same level and mirrored it to 22.
assert_eq!(
events.iter().filter(|e| e.starts_with("set 22 ")).count(),
999
);
let out = run(false);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success(), "a pulled jumper must fail the run");
assert!(
stderr.contains("line 17 high") && stderr.contains("line 27 reads low"),
"{stderr}"
);
}
/// The analog half through the sysfs classes, laid out in a temp directory: an ADC count
/// of 2000 at the kernel's 0.25 mV per count is 0.5 V, which as a duty is half of a 1 ms
/// period.
#[test]
fn the_generated_program_drives_pwm_from_an_iio_reading() {
let dir = write_project("adc-to-pwm", &adc_to_pwm(), 1);
let bin = build_native(&dir, "adc-to-pwm");
let sys = dir.join("sys");
let pwm = sys.join("pwmchip0/pwm1");
std::fs::create_dir_all(&pwm).unwrap();
for f in ["period", "duty_cycle", "enable"] {
std::fs::write(pwm.join(f), "0\n").unwrap();
}
let iio = sys.join("iio:device0");
std::fs::create_dir_all(&iio).unwrap();
std::fs::write(iio.join("in_voltage0_raw"), "2000\n").unwrap();
std::fs::write(iio.join("in_voltage_scale"), "0.25\n").unwrap();
let out = Command::new(&bin)
.env("FLOWRS_PWM_CHIP", sys.join("pwmchip0"))
.env("FLOWRS_IIO_DEVICE", &iio)
.output()
.expect("run the generated program");
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let read = |f: &str| std::fs::read_to_string(pwm.join(f)).unwrap();
assert_eq!(read("period"), "1000000");
assert_eq!(read("duty_cycle"), "500000");
assert_eq!(read("enable"), "1");
// No IIO device: the program stops with an error naming it, and drives nothing.
let out = Command::new(&bin)
.env("FLOWRS_IIO_DEVICE", sys.join("absent"))
.output()
.unwrap();
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("absent"));
}
/// The Linux half of a graph cut at a UART, speaking the typed link: it builds under
/// `-D warnings`, opens its board's UART for the link before anything else, and stops
/// with an error naming the port when there is none. (The link itself, end to end over a
/// tty, is flowrs-device's `link` tests and the MCU apps' host run in `codegen::mcu`;
/// macOS refuses serialport's baud-rate ioctl on a pseudo-terminal opened by path, so the
/// program cannot be pointed at one here.)
#[test]
fn a_linked_program_builds_and_opens_its_link_first() {
let mut g = Graph::new();
let pwm = g.add_node("embedded.pwm", 0.0, 0.0);
let button = g.add_node("embedded.gpio.digital_in", 0.0, 0.0);
set(&mut g, button, "pin", "5");
let plan = LinkPlan {
uart: 3,
baud: 1_000_000,
timeout_ms: 500,
cdc: false,
order: None,
channels: vec![
LinkChannel {
id: 1,
name: "voltage".into(),
ty: WireTy::F32,
end: LinkEnd::Receive {
node: pwm,
port: "duty".into(),
},
iface: None,
fragments: 1,
stamped: false,
},
LinkChannel {
id: 2,
name: "led".into(),
ty: WireTy::Bool,
end: LinkEnd::Send {
node: button,
port: "state".into(),
},
iface: None,
fragments: 1,
stamped: false,
},
],
};
let name = "linked";
let dir = std::env::temp_dir().join("flowrs-linux-target").join(name);
let _ = std::fs::remove_dir_all(&dir);
let project = to_linux_project_linked(&g, 1, name, &device(), &plan).expect("a project");
for f in &project.files {
let p = dir.join(&f.path);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, &f.contents).unwrap();
}
let bin = build_native(&dir, name);
let out = Command::new(&bin)
.env("FLOWRS_GPIO_CHIP", "sim")
.env("FLOWRS_UART", "/dev/flowrs-no-such-tty{uart}")
.output()
.expect("run the generated program");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success(), "no port, no run:\n{stderr}");
assert!(stderr.contains("/dev/flowrs-no-such-tty3"), "{stderr}");
}
/// Acceptance: the generated programs build for both GNU/Linux triples with `-D warnings`,
/// each linked by what this host has for it ([`CrossLinker::detect`]): on the build host a
/// GNU cross gcc for aarch64 and cargo-zigbuild for x86_64.
#[test]
#[ignore = "cross-builds two triples (GNU cross gcc or cargo-zigbuild); minutes. Run with --ignored"]
fn generated_programs_cross_build_for_both_linux_triples_without_warnings() {
let graphs = [
("every-embedded-node", every_embedded_node(), 3),
("gpio-loopback", loopback(), 1000),
("adc-to-pwm", adc_to_pwm(), 1),
];
for (name, graph, steps) in graphs {
let dir = write_project(&format!("cross-{name}"), &graph, steps);
for triple in LINUX_TRIPLES {
let linker = CrossLinker::detect(triple);
let out = linker
.release_build(&dir, triple)
.arg("--offline")
.env("CARGO_TARGET_DIR", target_dir())
.env("RUSTFLAGS", "-D warnings")
.output()
.expect("cargo");
assert!(
out.status.success(),
"{name} for {triple} ({linker:?}):\n{}",
String::from_utf8_lossy(&out.stderr)
);
let bin = target_dir()
.join(triple)
.join("release")
.join(format!("cross-{name}"));
let head = std::fs::read(&bin).unwrap_or_default();
// ELF, 64-bit, and the machine: EM_AARCH64 (183) or EM_X86_64 (62).
assert_eq!(&head[..4], b"\x7fELF", "{} is not an ELF", bin.display());
assert_eq!(head[4], 2, "64-bit");
let machine = u16::from_le_bytes([head[18], head[19]]);
let want = if triple.starts_with("aarch64") {
183
} else {
62
};
assert_eq!(machine, want, "{name} for {triple}: e_machine {machine}");
eprintln!(
"built {name} for {triple} with {linker:?} under -D warnings: {} ({} bytes)",
bin.display(),
head.len()
);
}
}
}