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

126 lines
5.2 KiB
Rust

//! A parameter whose value is lines of text must be declared so, or the editor offers it in
//! a one-line field — and a browser strips the newlines from a one-line field's value.
//!
//! That is how a statechart edited in the inspector came back as a single line: a chart that
//! no longer parses, so the node quietly passed its state through and nothing said why.
//! `Widget::multiline_parameters` is the declaration; these tests hold the library to it.
use flowrs_core::graph::value::Value;
use flowrs_core::graph::widget::{ExecContext, WidgetRegistry, edits_as_lines};
use std::collections::HashMap;
#[test]
fn every_parameter_whose_default_spans_lines_is_declared() {
let reg = WidgetRegistry::with_builtins();
let mut spanning = 0;
let mut undeclared = Vec::new();
for w in reg.all() {
for p in w.parameters() {
if p.default.contains(['\n', '\r']) {
spanning += 1;
if !w.multiline_parameters().contains(&p.name.as_str()) {
undeclared.push(format!("{}.{}", w.id(), p.name));
}
}
}
}
// A guard over an empty set passes and proves nothing. The statechart's chart and the
// inline table's CSV both default to several lines.
assert!(spanning >= 2, "found only {spanning} multi-line defaults");
assert!(
undeclared.is_empty(),
"these default to several lines but would be edited in a one-line field, which \
drops the line breaks: {undeclared:?}"
);
}
#[test]
fn every_declared_name_is_a_parameter_of_its_widget() {
// The declaration is a list of names beside `parameters()`, so a rename on one side
// leaves the other pointing at nothing — and the parameter quietly back in a one-line
// field.
let reg = WidgetRegistry::with_builtins();
let mut declared = 0;
let mut dangling = Vec::new();
for w in reg.all() {
let names: Vec<String> = w.parameters().into_iter().map(|p| p.name).collect();
for name in w.multiline_parameters() {
declared += 1;
if !names.iter().any(|n| n == name) {
dangling.push(format!("{}.{name}", w.id()));
}
}
}
assert!(declared >= 5, "found only {declared} declarations");
assert!(
dangling.is_empty(),
"declared multi-line but not a parameter: {dangling:?}"
);
}
#[test]
fn line_oriented_parameters_are_edited_as_lines_before_they_hold_anything() {
// Each of these is lines by its format, not by its current value: a statechart, CSV,
// a bundle written one question per line, and prompts, which are prose.
let reg = WidgetRegistry::with_builtins();
for (id, name) in [
("flow.statechart", "machine"),
("data.table", "csv"),
("ai.hybrid", "bundle"),
("ai.hybrid", "prompt"),
("ai.complete", "prompt"),
] {
let w = reg
.get(id)
.unwrap_or_else(|| panic!("{id} is not registered"));
assert!(edits_as_lines(Some(w), name, ""), "{id}.{name}");
}
// A one-line parameter stays one line.
let print = reg.get("io.print").expect("io.print");
assert!(!edits_as_lines(Some(print), "label", "total"));
let hybrid = reg.get("ai.hybrid").expect("ai.hybrid");
assert!(!edits_as_lines(Some(hybrid), "options", "yes, no"));
}
#[test]
fn a_value_holding_a_line_break_is_edited_as_lines_whatever_was_declared() {
// Whoever wrote the break (a file, an agent, an importer) meant it, and a one-line
// field would delete it on the next edit. Code nodes from a Rust import are not
// registry widgets at all, and their `src` is routinely several lines.
let reg = WidgetRegistry::with_builtins();
let print = reg.get("io.print").expect("io.print");
assert!(edits_as_lines(Some(print), "label", "first\nsecond"));
assert!(edits_as_lines(None, "src", "fn f() {\n 1\n}"));
assert!(edits_as_lines(None, "src", "old\rmac"));
assert!(!edits_as_lines(None, "src", "x + 1"));
}
/// What is at stake, shown on the widget that motivated this: the same chart, flattened the
/// way a one-line field flattens it, does not run.
#[test]
fn a_statechart_runs_its_added_lines_and_not_once_flattened() {
let reg = WidgetRegistry::with_builtins();
let chart = reg.get("flow.statechart").expect("flow.statechart");
let state_of = |machine: &str| {
let props = HashMap::from([("machine".to_string(), machine.to_string())]);
let out = chart.execute(&ExecContext::for_widget(chart, &props), HashMap::new());
match out.get("state") {
Some(Value::String(s)) => s.clone(),
other => panic!("expected a state, got {other:?}"),
}
};
let default = chart
.parameters()
.into_iter()
.find(|p| p.name == "machine")
.expect("machine")
.default;
assert_eq!(state_of(&default), "off");
// A state and an eventless transition into it, added as two more lines.
let edited = format!("{default}state: lit\nalways: off -> lit\n");
assert_eq!(state_of(&edited), "lit");
// What an <input> hands back: every line break gone.
let flattened = edited.replace('\n', "");
assert_ne!(state_of(&flattened), "lit");
}