flowrs-engine/crates/flowrs-core/tests/live_controls.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

348 lines
12 KiB
Rust

//! Live controls in an exported program (`primitives_to_rust_app_live`): a program with a
//! step loop reads `n3=0.5` lines on standard input and applies each at the top of its next
//! pass, refusing what the control cannot take.
//!
//! Each program here is compiled with rustc and run. With no line arriving (standard input
//! empty, closed, or open and silent) its stdout is byte for byte the stdout of the program
//! built without live controls: what T1 computes for the baked values does not change. With
//! a line, the control takes the new value at the pass the program says (on stderr), and
//! every pass from then on computes with it.
use flowrs_core::Graph;
use flowrs_core::NodeId;
use flowrs_core::codegen::rust::{
LiveControl, LiveKind, has_step_loop, live_kind, primitives_to_rust_app,
primitives_to_rust_app_live,
};
use flowrs_core::graph::dsl::parse_graph;
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
fn graph(text: &str) -> Graph {
parse_graph(text).expect("graph text").graph
}
/// A counter: each pass adds the step (n0) to what the last pass summed.
const COUNTER: &str = "let n0 = FloatLiteral { value: 1 };\n\
let n1 = math.add(a: n1.sum.copy().prev(), b: n0.value);\n\
let n2 = Indicator(input: n1.sum) { label: \"Count\" };";
/// The counter, paced: each pass waits 40 ms, so a line has time to arrive mid-run.
const PACED: &str = "let n0 = FloatLiteral { value: 1 };\n\
let n1 = math.add(a: n1.sum.copy().prev(), b: n0.value);\n\
let n2 = flow.delay { duration_s: 0.04 };\n\
let n3 = Indicator(input: n1.sum) { label: \"Count\" };";
fn step(min: Option<f64>, max: Option<f64>) -> LiveControl {
LiveControl {
node: NodeId(0),
kind: LiveKind::Number,
min,
max,
options: Vec::new(),
}
}
/// Build `src` with rustc into a scratch directory; the binary's path.
fn build(src: &str, name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("flowrs_live_{}_{name}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let main = dir.join("main.rs");
let bin = dir.join("prog");
std::fs::write(&main, src).unwrap();
let out = Command::new("rustc")
.args(["--edition", "2021", "-O", "-o"])
.arg(&bin)
.arg(&main)
.output()
.expect("rustc runs");
assert!(
out.status.success(),
"{name} does not compile:\n{}\n{src}",
String::from_utf8_lossy(&out.stderr)
);
// The live machinery adds no warning of its own (dead code, an unused binding).
let warnings = String::from_utf8_lossy(&out.stderr);
for ours in [
"flowrs_controls",
"ctl_n",
"live_controls",
"never used",
"dead_code",
] {
assert!(
!warnings.contains(ours),
"{name}: a warning about {ours}:\n{warnings}"
);
}
bin
}
/// How the program's standard input is given.
enum Input<'a> {
Null,
Closed,
Silent,
Lines(&'a str),
}
/// Run `bin` with `input`; its stdout and stderr.
fn run(bin: &PathBuf, input: Input) -> (String, String) {
let mut cmd = Command::new(bin);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
cmd.stdin(match input {
Input::Null => Stdio::null(),
_ => Stdio::piped(),
});
let mut child = cmd.spawn().expect("it runs");
// Kept open while the program runs, for `Silent` and `Lines`.
let mut held = None;
match input {
Input::Null => {}
Input::Closed => drop(child.stdin.take()),
Input::Silent => held = child.stdin.take(),
Input::Lines(text) => {
let mut w = child.stdin.take().unwrap();
w.write_all(text.as_bytes()).unwrap();
w.flush().unwrap();
held = Some(w);
}
}
let out = child.wait_with_output().expect("it ends");
drop(held);
assert!(out.status.success(), "{out:?}");
(
String::from_utf8(out.stdout).unwrap(),
String::from_utf8(out.stderr).unwrap(),
)
}
/// The counts a run printed, in order.
fn counts(stdout: &str) -> Vec<f64> {
stdout
.lines()
.filter_map(|l| l.strip_prefix(">Count:"))
.map(|v| v.parse().unwrap())
.collect()
}
#[test]
fn a_control_is_read_from_its_own_binding_set_at_the_top_of_each_pass() {
let g = graph(COUNTER);
assert!(has_step_loop(&g));
let p = primitives_to_rust_app_live(&g, 5, &[], &[step(Some(0.0), Some(5.0))]).unwrap();
for want in [
"let mut live_controls = flowrs_controls::listen();",
"let mut ctl_n0 = 1.0f64;",
"for (name, value) in live_controls.pending() {",
"\"n0\" => flowrs_controls::number(&mut ctl_n0, &name, &value, Some(0.0), Some(5.0), false, pass),",
"_ => flowrs_controls::refuse(&name, &value, \"it is not a control of this program\", pass),",
"mod flowrs_controls {",
"pub fn number<T: std::str::FromStr>(",
] {
assert!(p.contains(want), "{want} missing from:\n{p}");
}
// The binding is declared before the loop, and set before any node of a pass runs.
let decl = p.find("let mut ctl_n0").unwrap();
let lp = p.find("for _step in 0..5 {").unwrap();
let set = p.find("live_controls.pending()").unwrap();
let read = p.find("= ctl_n0;").unwrap();
assert!(decl < lp && lp < set && set < read, "{p}");
// Only the setters its controls use: no dead code in the program.
assert!(
!p.contains("pub fn boolean") && !p.contains("pub fn text("),
"{p}"
);
// No controls: the program is exactly the one built without them.
assert_eq!(
primitives_to_rust_app_live(&g, 5, &[], &[]).unwrap(),
primitives_to_rust_app(&g, 5, &[]).unwrap()
);
}
#[test]
fn a_program_that_runs_once_takes_no_controls() {
// One pass of the loop: a change would never apply.
let g = graph(COUNTER);
assert!(
primitives_to_rust_app_live(&g, 1, &[], &[step(None, None)])
.unwrap_err()
.contains("one pass")
);
// No step loop at all.
let once = graph(
"let n0 = FloatLiteral { value: 2 };\nlet n1 = Indicator(input: n0.value) { label: \"Two\" };",
);
assert!(!has_step_loop(&once));
assert!(primitives_to_rust_app_live(&once, 5, &[], &[step(None, None)]).is_err());
}
#[test]
fn what_a_control_holds_while_its_program_runs() {
let mut g = graph(
"let n0 = IntLiteral { value: 3 };\n\
let n1 = FloatLiteral { value: 0.5 };\n\
let n2 = BoolLiteral { value: true };\n\
let n3 = StringLiteral { value: \"sine\" };",
);
let range = g.add_node("Control", 0.0, 0.0);
let plain = g.add_node("Control", 0.0, 0.0);
let add = g.add_node("Add", 0.0, 0.0);
for (id, k, v) in [
(range, "value", "1,2"),
(range, "widget", "range"),
(plain, "value", "7"),
] {
g.get_node_mut(id)
.unwrap()
.properties
.insert(k.into(), v.into());
}
assert_eq!((range, plain, add), (NodeId(4), NodeId(5), NodeId(6)));
let k = |id| live_kind(&g, NodeId(id));
assert_eq!(k(0), Ok(LiveKind::Integer));
assert_eq!(k(1), Ok(LiveKind::Number));
assert_eq!(k(2), Ok(LiveKind::Bool));
assert_eq!(k(3), Ok(LiveKind::Text));
assert!(k(4).unwrap_err().contains("several values"));
assert!(matches!(k(5), Ok(LiveKind::Integer | LiveKind::Number)));
assert!(k(6).unwrap_err().contains("not a control"));
assert!(k(99).is_err());
}
/// The parity proof: with no line arriving, however standard input is given, the live
/// program prints byte for byte what the program built without live controls prints.
#[test]
fn with_no_change_the_program_prints_exactly_what_it_did_before() {
for (name, text, steps) in [("counter", COUNTER, 8), ("paced", PACED, 6)] {
let g = graph(text);
let before = build(
&primitives_to_rust_app(&g, steps, &[]).unwrap(),
&format!("{name}_before"),
);
let live = build(
&primitives_to_rust_app_live(&g, steps, &[], &[step(Some(0.0), Some(5.0))]).unwrap(),
&format!("{name}_live"),
);
let (want, _) = run(&before, Input::Null);
assert!(!want.is_empty(), "{name} printed nothing");
for (how, input) in [
("stdin null", Input::Null),
("stdin closed", Input::Closed),
("stdin open and silent", Input::Silent),
] {
let (got, err) = run(&live, input);
assert_eq!(got, want, "{name}, {how}: stdout differs");
assert!(err.is_empty(), "{name}, {how}: {err}");
}
}
}
/// A change applies at the top of the next pass, and from then on; what the control
/// cannot take is refused on stderr, and the control keeps its value.
#[test]
fn a_change_applies_at_the_next_pass_and_a_bad_one_is_refused() {
let g = graph(PACED);
let steps = 30;
let live = build(
&primitives_to_rust_app_live(&g, steps, &[], &[step(Some(0.0), Some(5.0))]).unwrap(),
"paced_change",
);
let (out, err) = run(
&live,
Input::Lines("n0=9\nn7=1\nn0=abc\nnot a line\n$n0=3\n"),
);
// The refusals, each saying why, and the change, saying from which pass.
assert!(err.contains("n0 = 9 refused at pass"), "{err}");
assert!(err.contains("it is above 5"), "{err}");
assert!(
err.contains("n7 = 1 refused at pass") && err.contains("not a control of this program"),
"{err}"
);
assert!(
err.contains("n0 = abc refused at pass") && err.contains("not a number"),
"{err}"
);
assert!(err.contains("`not a line` is not name=value"), "{err}");
let from: usize = err
.lines()
.find_map(|l| l.strip_prefix("flowrs: n0 = 3 from pass "))
.expect("the change was taken")
.trim()
.parse()
.unwrap();
// Every pass before it added 1; it and every pass after, 3.
let got = counts(&out);
assert_eq!(got.len(), steps, "{out}");
let mut sum = 0.0;
for (i, c) in got.iter().enumerate() {
let pass = i + 1;
sum += if pass < from { 1.0 } else { 3.0 };
assert_eq!(
*c, sum,
"pass {pass} (the change from pass {from}): {got:?}"
);
}
assert!(from < steps, "the change came in time: pass {from}");
}
#[test]
fn a_switch_a_whole_number_and_text_each_take_their_own_kind() {
// Each control feeds an Indicator, so what it holds each pass is printed.
let g = graph(
"let n0 = IntLiteral { value: 2 };\n\
let n1 = BoolLiteral { value: false };\n\
let n2 = StringLiteral { value: \"sine\" };\n\
let n8 = FloatLiteral { value: 1 };\n\
let n3 = math.add(a: n3.sum.copy().prev(), b: n8.value);\n\
let n4 = flow.delay { duration_s: 0.03 };\n\
let n5 = Indicator(input: n0.value) { label: \"Whole\" };\n\
let n6 = Indicator(input: n1.value) { label: \"Switch\" };\n\
let n7 = Indicator(input: n2.value) { label: \"Shape\" };",
);
let c = |id: u64, kind, options: &[&str]| LiveControl {
node: NodeId(id),
kind,
min: Some(0.0),
max: Some(10.0),
options: options.iter().map(|s| s.to_string()).collect(),
};
let controls = [
c(0, LiveKind::Integer, &[]),
c(1, LiveKind::Bool, &[]),
c(2, LiveKind::Text, &["sine", "square"]),
];
let src = primitives_to_rust_app_live(&g, 20, &[], &controls).unwrap();
let bin = build(&src, "kinds");
let (out, err) = run(
&bin,
Input::Lines("n0=2.5\nn0=7\nn1=maybe\nn1=true\nn2=saw\nn2=square\n"),
);
assert!(
err.contains("n0 = 2.5 refused") && err.contains("not a whole number"),
"{err}"
);
assert!(err.contains("n1 = maybe refused"), "{err}");
assert!(
err.contains("n2 = saw refused") && err.contains("not one of"),
"{err}"
);
for set in [
"n0 = 7 from pass",
"n1 = true from pass",
"n2 = square from pass",
] {
assert!(err.contains(set), "{set}: {err}");
}
let last = |name: &str| {
out.lines()
.filter_map(|l| l.strip_prefix(&format!(">{name}:")))
.next_back()
.unwrap()
.to_string()
};
assert_eq!(last("Whole"), "7");
assert_eq!(last("Switch"), "1");
assert_eq!(last("Shape"), "\"square\"|t");
}