flowrs-engine/crates/flowrs-cli/tests/twin_test.rs
flowRS 2b6f30d348 flowRS engine, cut from flowRS f907e0756a7c
Built alone from an empty CARGO_HOME, its tests passed, and flowrsc checked, ran and exported a
document from the conformance corpus; the export built alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 08:54:33 -05:00

162 lines
6 KiB
Rust

//! `flowrsc twin test`: a spec's verdict as an exit status a CI job reads, and as JUnit a CI test
//! view reads. Without the `firmexplain` feature it says what is missing.
use clap::Parser;
use flowrs_cli::{Cli, run};
fn flowrsc(args: &[&str]) -> (i32, String, String) {
let cli = Cli::try_parse_from(std::iter::once("flowrsc").chain(args.iter().copied()))
.unwrap_or_else(|e| panic!("{args:?}: {e}"));
let (mut out, mut err) = (Vec::new(), Vec::new());
let code = run(&cli, &mut out, &mut err);
(
code,
String::from_utf8_lossy(&out).into_owned(),
String::from_utf8_lossy(&err).into_owned(),
)
}
#[cfg(not(feature = "firmexplain"))]
#[test]
fn a_build_with_no_twins_says_where_to_find_one() {
let (code, out, err) = flowrsc(&["twin", "test", "spec.yaml"]);
assert_eq!(code, 2);
assert!(out.is_empty());
assert!(err.contains("--features firmexplain"), "{err}");
}
#[cfg(feature = "firmexplain")]
mod with_twins {
use super::*;
use std::path::Path;
/// One twin CPU per process, and the tests here run on threads.
static CPU: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn one_at_a_time() -> std::sync::MutexGuard<'static, ()> {
CPU.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
const F4: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../flowrs-renode/tests/firmware/f4_demo.bin"
);
fn spec(dir: &Path, name: &str, expect: &str) -> String {
let path = dir.join(name);
std::fs::write(
&path,
format!(
"schema: flowrs.oracle/1\nboard: stm32f4\nimage: {F4}\nbudget: {{cycles: 8000000}}\nexpect:\n{expect}"
),
)
.unwrap();
path.display().to_string()
}
#[test]
fn each_verdict_is_its_own_exit_status() {
let _cpu = one_at_a_time();
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let cases = [
("pass.yaml", " - serial: {contains: \"f4 up\"}\n", 0),
("fail.yaml", " - serial: {contains: \"f7 up\"}\n", 1),
("empty.yaml", " []\n", 2),
(
"inconclusive.yaml",
" - serial: {contains: \"f4 up\"}\n - register: {path: SDIO.POWER, equals: 0}\n",
4,
),
];
for (name, expect, want) in cases {
let path = spec(d, name, expect);
let (code, out, err) = flowrsc(&["twin", "test", &path]);
assert_eq!(code, want, "{name}: {out}{err}");
}
// A spec error says its code.
let (_, _, err) = flowrsc(&["twin", "test", &spec(d, "empty2.yaml", " []\n")]);
assert!(err.contains("oracle_required"), "{err}");
// A twin that runs in the browser is a run that cannot be made here.
let uno = d.join("uno.yaml");
std::fs::write(
&uno,
format!(
"schema: flowrs.oracle/1\nboard: uno\nimage: {F4}\nexpect:\n - ended: budget\n"
),
)
.unwrap();
let (code, _, err) = flowrsc(&["twin", "test", &uno.display().to_string()]);
assert_eq!(code, 3, "{err}");
assert!(err.contains("runs in the browser"), "{err}");
// A Wokwi scenario on a board with no twin: unsupported, nothing run. (The STM32C031:
// flowRS has no STM32C0 twin. The Pico 2 was the example until work/fixups3 put it on
// the RP2350 twin.)
let scenario = d.join("scenario.yaml");
std::fs::write(
&scenario,
"name: x\nversion: 1\nsteps:\n - wait-serial: hi\n",
)
.unwrap();
std::fs::write(
d.join("diagram.json"),
r#"{"version":1,"parts":[{"type":"board-st-nucleo-c031c6","id":"nucleo","attrs":{}}],"connections":[]}"#,
)
.unwrap();
let (code, out, err) = flowrsc(&[
"twin",
"test",
&scenario.display().to_string(),
"--image",
F4,
]);
assert_eq!(code, 5, "{out}{err}");
assert!(out.starts_with("UNSUPPORTED"), "{out}");
}
#[test]
fn its_junit_reads_back_with_each_clause_and_what_it_came_to() {
let _cpu = one_at_a_time();
let dir = tempfile::tempdir().unwrap();
let path = spec(
dir.path(),
"mixed.yaml",
" - serial: {contains: \"f4 up\"}\n - serial: {contains: \"f7 up\"}\n \
- register: {path: SDIO.POWER, equals: 0}\n",
);
let junit = dir.path().join("out.xml");
let (code, out, _) = flowrsc(&[
"twin",
"test",
&path,
"--junit",
&junit.display().to_string(),
"--json",
]);
assert_eq!(code, 1);
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["decision"], "fail");
assert_eq!(v["schema"], "flowrs.verdict/1");
let xml = std::fs::read_to_string(&junit).unwrap();
let (run, _) = flowrs_record::junit::from_xml(&xml).unwrap();
// Each clause a case, found by its id in the name (the reader keeps the phase,
// `clauses`, as a prefix; a case's recorded value reads back beside it).
let all: Vec<(String, flowrs_record::Outcome)> = run
.measurements()
.map(|m| (m.name.clone(), m.outcome))
.collect();
let of = |id: &str| {
all.iter()
.find(|(n, _)| n.contains(&format!("{id} ")))
.map(|(n, o)| (n.clone(), *o))
.unwrap_or_else(|| panic!("no case for {id} in {all:?}"))
};
use flowrs_record::Outcome::{Fail, Pass, Skip};
assert_eq!(of("C1").1, Pass, "{xml}");
assert_eq!(of("C2").1, Fail, "{xml}");
assert_eq!(of("C3").1, Skip, "{xml}");
assert!(of("C1").0.contains("serial contains"), "{all:?}");
assert!(xml.contains("inconclusive"), "the skip says why: {xml}");
}
}