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

715 lines
26 KiB
Rust

//! Integration tests for `flowrsc`.
//!
//! Each test builds a real `Graph`, serializes it to a temp JSON file, and drives
//! `run()` over that file — the same path the binary takes, minus the process spawn.
//! Assertions are on the actual exit code and captured output.
use std::path::{Path, PathBuf};
use flowrs_cli::{Cli, Command, EXIT_INVALID, EXIT_OK, EXIT_USAGE, run};
use flowrs_core::{Graph, WireMode};
use tempfile::TempDir;
/// Write `graph` as JSON into `dir` and return the path.
fn write_graph(dir: &TempDir, graph: &Graph) -> PathBuf {
let path = dir.path().join("test.flow");
std::fs::write(
&path,
serde_json::to_string(graph).expect("serialize graph"),
)
.expect("write graph");
path
}
/// Run a command, returning `(exit_code, stdout, stderr)`.
fn exec(command: Command, quiet: bool) -> (i32, String, String) {
let cli = Cli { command, quiet };
let (mut out, mut err) = (Vec::new(), Vec::new());
let code = run(&cli, &mut out, &mut err);
(
code,
String::from_utf8(out).expect("utf8 stdout"),
String::from_utf8(err).expect("utf8 stderr"),
)
}
fn build(graph: &Path, output: Option<PathBuf>) -> (i32, String, String) {
exec(
Command::Build {
graph: graph.to_path_buf(),
output,
},
false,
)
}
fn check(graph: &Path) -> (i32, String, String) {
exec(
Command::Check {
graph: graph.to_path_buf(),
},
false,
)
}
/// `2 + 3` — two int literals feeding an Add. Legal ownership, real codegen.
fn valid_graph() -> Graph {
let mut g = Graph::new();
let a = g.add_node("IntLiteral", 0.0, 0.0);
let b = g.add_node("IntLiteral", 0.0, 60.0);
let add = g.add_node("Add", 150.0, 30.0);
g.get_node_mut(a)
.unwrap()
.properties
.insert("value".into(), "2".into());
g.get_node_mut(b)
.unwrap()
.properties
.insert("value".into(), "3".into());
g.add_wire(a, "value", add, "a", WireMode::Move).unwrap();
g.add_wire(b, "value", add, "b", WireMode::Move).unwrap();
g
}
/// A valid graph compiles: exit 0, and the generated Rust reaches stdout.
#[test]
fn build_valid_graph_emits_rust_and_exits_zero() {
let dir = TempDir::new().unwrap();
let path = write_graph(&dir, &valid_graph());
let (code, stdout, stderr) = build(&path, None);
assert_eq!(code, EXIT_OK, "stderr: {stderr}");
assert!(
stdout.contains("fn main"),
"expected generated Rust on stdout, got:\n{stdout}"
);
// Assert on the bound literals and the add expression, not bare "2"/"3" —
// generated variable names (v0, v1, v2) would satisfy a naive substring check
// even if codegen had emitted an empty stub.
// Typed literals: codegen emits `2i64`, not a bare `2`, so that an integer literal
// feeding a float-typed port is a compile error rather than a silent inference.
assert!(stdout.contains("= 2i64;"), "missing literal 2:\n{stdout}");
assert!(stdout.contains("= 3i64;"), "missing literal 3:\n{stdout}");
assert!(
stdout.contains(" + "),
"Add node did not emit an addition:\n{stdout}"
);
}
/// A missing file is a usage error (exit 2), not a graph failure (exit 1).
#[test]
fn build_missing_file_exits_two() {
let dir = TempDir::new().unwrap();
let missing = dir.path().join("does-not-exist.flow");
let (code, _, stderr) = build(&missing, None);
assert_eq!(code, EXIT_USAGE);
assert!(stderr.contains("cannot read"), "stderr: {stderr}");
}
/// Malformed JSON is also exit 2 — we never got far enough to judge the graph.
#[test]
fn build_bad_json_exits_two() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("broken.flow");
std::fs::write(&path, "{ this is not json ]").unwrap();
let (code, _, stderr) = build(&path, None);
assert_eq!(code, EXIT_USAGE);
assert!(stderr.contains("invalid graph JSON"), "stderr: {stderr}");
}
/// Two `Move` wires off one output = a double move. Exit 1, with a diagnostic
/// naming the offending node and port.
#[test]
fn build_ownership_violation_exits_one_with_diagnostic() {
let mut g = Graph::new();
let src = g.add_node("IntLiteral", 0.0, 0.0);
let x = g.add_node("Negate", 150.0, 0.0);
let y = g.add_node("Negate", 150.0, 60.0);
g.add_wire(src, "value", x, "input", WireMode::Move)
.unwrap();
g.add_wire(src, "value", y, "input", WireMode::Move)
.unwrap();
let dir = TempDir::new().unwrap();
let path = write_graph(&dir, &g);
let (code, stdout, stderr) = build(&path, None);
assert_eq!(code, EXIT_INVALID, "stderr: {stderr}");
assert!(
stderr.contains("error: value moved 2 times"),
"stderr: {stderr}"
);
assert!(
stderr.contains(&format!("(node {}, port value)", src.0)),
"diagnostic should locate the node/port, got: {stderr}"
);
// An invalid graph must not produce output.
assert!(stdout.is_empty(), "stdout should be empty, got:\n{stdout}");
}
/// `-o` writes the generated Rust to disk and leaves stdout clean.
#[test]
fn build_with_output_flag_writes_file() {
let dir = TempDir::new().unwrap();
let path = write_graph(&dir, &valid_graph());
let out_path = dir.path().join("generated.rs");
let (code, stdout, stderr) = build(&path, Some(out_path.clone()));
assert_eq!(code, EXIT_OK, "stderr: {stderr}");
assert!(stdout.is_empty(), "stdout should be empty, got:\n{stdout}");
let written = std::fs::read_to_string(&out_path).expect("output file written");
assert!(written.contains("fn main"), "got:\n{written}");
}
/// `check` validates without emitting anything, and reports a clean graph.
#[test]
fn check_valid_graph_exits_zero_and_writes_no_output() {
let dir = TempDir::new().unwrap();
let path = write_graph(&dir, &valid_graph());
let (code, stdout, stderr) = check(&path);
assert_eq!(code, EXIT_OK, "stderr: {stderr}");
assert!(
stdout.is_empty(),
"check must not emit Rust, got:\n{stdout}"
);
assert!(stderr.contains("ok: 3 nodes, 2 wires"), "stderr: {stderr}");
}
/// `--quiet` drops the status note but keeps the generated Rust.
#[test]
fn quiet_suppresses_status_but_not_generated_rust() {
let dir = TempDir::new().unwrap();
let path = write_graph(&dir, &valid_graph());
let (code, stdout, stderr) = exec(
Command::Build {
graph: path,
output: None,
},
true,
);
assert_eq!(code, EXIT_OK);
assert!(stdout.contains("fn main"), "got:\n{stdout}");
assert!(stderr.is_empty(), "quiet stderr should be empty: {stderr}");
}
/// `--quiet` must never hide a real error.
#[test]
fn quiet_still_reports_violations() {
let mut g = Graph::new();
let src = g.add_node("IntLiteral", 0.0, 0.0);
let x = g.add_node("Negate", 150.0, 0.0);
let y = g.add_node("Negate", 150.0, 60.0);
g.add_wire(src, "value", x, "input", WireMode::Move)
.unwrap();
g.add_wire(src, "value", y, "input", WireMode::Move)
.unwrap();
let dir = TempDir::new().unwrap();
let path = write_graph(&dir, &g);
let (code, _, stderr) = exec(Command::Check { graph: path }, true);
assert_eq!(code, EXIT_INVALID);
assert!(stderr.contains("error:"), "stderr: {stderr}");
}
/// Regression: `check` must catch the dataflow-native defects, not just ownership.
///
/// The CLI originally validated only via OwnershipTracker, so a cyclic graph passed
/// `check` with exit 0 while `build` failed at codegen — the validation command missing
/// exactly what it exists to find. It now shares flowrs-core's `diagnostics` with the
/// editor, so the two can never disagree about whether a graph is valid.
#[test]
fn check_catches_a_cycle_not_just_ownership() {
let mut g = Graph::new();
let a = g.add_node("Add", 0.0, 0.0);
let b = g.add_node("Add", 200.0, 0.0);
g.add_wire(a, "result", b, "a", WireMode::Move).unwrap();
g.add_wire(b, "result", a, "a", WireMode::Move).unwrap();
let dir = TempDir::new().unwrap();
let path = write_graph(&dir, &g);
let (code, _stdout, stderr) = check(&path);
assert_eq!(
code, EXIT_INVALID,
"a cycle must fail check; stderr: {stderr}"
);
// "on a cycle", not "part of a cycle": the detector used to report everything
// downstream of a loop with that wording, and most of those claims were false. It now
// reports only the loop's own members and names the path.
assert!(
stderr.contains("on a cycle"),
"expected a cycle diagnostic, got: {stderr}"
);
}
/// An unconnected input is advisory: reported, but it must NOT fail the run. Half-built
/// graphs are normal and failing CI on them would make the check useless.
#[test]
fn unconnected_input_warns_without_failing() {
let mut g = Graph::new();
g.add_node("PrintLn", 0.0, 0.0); // `input` fed by nothing
let dir = TempDir::new().unwrap();
let path = write_graph(&dir, &g);
let (code, _stdout, stderr) = check(&path);
assert_eq!(code, EXIT_OK, "a warning must not fail; stderr: {stderr}");
assert!(
stderr.contains("warning:") && stderr.contains("not connected"),
"expected an unconnected-input warning, got: {stderr}"
);
}
/// `2.5 + 4.0` as a graph, returning it and the add node's id.
fn adder() -> (Graph, u64) {
let mut g = Graph::new();
let a = g.add_node("FloatLiteral", 0.0, 0.0);
let b = g.add_node("FloatLiteral", 0.0, 0.0);
let add = g.add_node("math.add", 0.0, 0.0);
g.get_node_mut(a)
.unwrap()
.properties
.insert("value".into(), "2.5".into());
g.get_node_mut(b)
.unwrap()
.properties
.insert("value".into(), "4.0".into());
g.add_wire(a, "value", add, "a", WireMode::Move).unwrap();
g.add_wire(b, "value", add, "b", WireMode::Move).unwrap();
(g, add.0)
}
fn run_test(graph: &Path, expect: &str, dir: &TempDir) -> (i32, String, String) {
let e = dir.path().join("expect.json");
std::fs::write(&e, expect).unwrap();
exec(
Command::Test {
graph: graph.to_path_buf(),
expect: e,
tolerance: 1e-9,
junit: None,
},
false,
)
}
/// `test --junit`, returning the exit code and the report it wrote (if any).
fn run_test_junit(
graph: &Path,
expect: &str,
tolerance: f64,
dir: &TempDir,
) -> (i32, Option<String>) {
let e = dir.path().join("nominal.json");
std::fs::write(&e, expect).unwrap();
let report = dir.path().join("report.xml");
let _ = std::fs::remove_file(&report);
let (code, _, _) = exec(
Command::Test {
graph: graph.to_path_buf(),
expect: e,
tolerance,
junit: Some(report.clone()),
},
false,
);
(code, std::fs::read_to_string(&report).ok())
}
/// The CI side of `flowrsc test`: the graded expectations as JUnit, failures explained, and
/// a report the JUnit reader takes back with the same verdicts.
#[test]
fn test_writes_the_graded_expectations_as_junit_for_ci() {
let dir = TempDir::new().unwrap();
let (g, add) = adder();
let path = write_graph(&dir, &g);
let (code, xml) = run_test_junit(&path, &format!("{{\"{add}.sum\": 6.5}}"), 0.25, &dir);
assert_eq!(code, EXIT_OK);
let xml = xml.expect("a passing run still writes its report");
// The graph is the suite, the expectation file the class.
assert!(
xml.contains(r#"<testsuite name="test" tests="1" failures="0""#),
"{xml}"
);
assert!(
xml.contains(&format!(
r#"<testcase name="{add}.sum" classname="nominal">"#
)),
"{xml}"
);
let (code, xml) = run_test_junit(
&path,
&format!("{{\"{add}.sum\": 7.0, \"{add}.nope\": \"x\"}}"),
0.25,
&dir,
);
assert_eq!(code, EXIT_INVALID);
let xml = xml.expect("a failing run writes its report");
assert!(
xml.contains(r#"<failure message="measured 6.5; limit [6.75, 7.25]" type="measurement">"#),
"{xml}"
);
// A port that produced nothing is "not measured", never a measured zero.
assert!(
xml.contains(r#"<failure message="not measured" type="measurement">"#),
"{xml}"
);
assert!(xml.contains(r#"expected "x", got None"#), "{xml}");
let (run, loss) = flowrs_record::junit::from_xml(&xml).expect("the report reads back");
assert!(
loss.count_mismatches.is_empty(),
"{:?}",
loss.count_mismatches
);
assert_eq!(run.outcome, flowrs_record::Outcome::Fail);
for port in ["sum", "nope"] {
let name = format!("nominal.{add}.{port}");
let m = run
.measurements()
.find(|m| m.name == name)
.unwrap_or_else(|| panic!("no {name}"));
assert_eq!(m.outcome, flowrs_record::Outcome::Fail, "{name}");
}
}
/// A run that never reached grading writes no report: a CI system then says the report is
/// missing, beside the exit code and the reason on stderr, instead of showing a pass.
#[test]
fn test_writes_no_junit_when_nothing_was_graded() {
let dir = TempDir::new().unwrap();
let (code, xml) = run_test_junit(&dir.path().join("missing.flow"), "{}", 1e-9, &dir);
assert_eq!(code, EXIT_USAGE);
assert!(xml.is_none());
}
#[test]
fn test_passes_when_t0_outputs_match() {
let dir = TempDir::new().unwrap();
let (g, add) = adder();
let path = write_graph(&dir, &g);
let (code, out, _) = run_test(&path, &format!("{{\"{add}.sum\": 6.5}}"), &dir);
assert_eq!(code, EXIT_OK, "{out}");
assert!(out.contains(&format!("ok {add}.sum")), "{out}");
}
#[test]
fn test_fails_on_a_wrong_or_missing_output() {
let dir = TempDir::new().unwrap();
let (g, add) = adder();
let path = write_graph(&dir, &g);
let (code, out, _) = run_test(&path, &format!("{{\"{add}.sum\": 7.0}}"), &dir);
assert_eq!(code, EXIT_INVALID, "{out}");
assert!(out.contains("FAIL"), "{out}");
let (code, out, _) = run_test(&path, &format!("{{\"{add}.nope\": 1}}"), &dir);
assert_eq!(code, EXIT_INVALID, "{out}");
let (code, _, _) = run_test(&path, "[1, 2]", &dir);
assert_eq!(code, EXIT_USAGE);
}
#[test]
fn diff_ignores_layout_and_reports_semantic_changes() {
let dir = TempDir::new().unwrap();
let (g, add) = adder();
let old = write_graph(&dir, &g);
let mut moved = g.clone();
for n in moved.nodes.values_mut() {
n.position = (100.0, 50.0);
}
let same = dir.path().join("moved.flow");
std::fs::write(&same, serde_json::to_string(&moved).unwrap()).unwrap();
let (code, out, _) = exec(
Command::Diff {
old: old.clone(),
new: same,
},
false,
);
assert_eq!(code, EXIT_OK, "{out}");
assert!(out.is_empty(), "{out}");
let mut edited = g.clone();
let lit = edited
.nodes
.values()
.find(|n| n.properties.get("value").map(String::as_str) == Some("4.0"))
.unwrap()
.id;
edited
.get_node_mut(lit)
.unwrap()
.properties
.insert("value".into(), "5.0".into());
let p = edited.add_node("Print", 0.0, 0.0);
edited
.add_wire(flowrs_core::NodeId(add), "sum", p, "input", WireMode::Move)
.unwrap();
let new = dir.path().join("edited.flow");
std::fs::write(&new, serde_json::to_string(&edited).unwrap()).unwrap();
let (code, out, _) = exec(Command::Diff { old, new }, false);
assert_eq!(code, EXIT_INVALID, "{out}");
assert!(out.contains("value: \"4.0\" -> \"5.0\""), "{out}");
assert!(out.contains("+ node") && out.contains("Print"), "{out}");
assert!(out.contains(&format!("+ wire {add}.sum ->")), "{out}");
}
fn app(graph: &Path, output: &Path, name: Option<&str>) -> (i32, String, String) {
exec(
Command::App {
graph: graph.to_path_buf(),
output: output.to_path_buf(),
name: name.map(str::to_string),
linux: false,
steps: None,
flowrs: Some(PathBuf::from("/src/flowRS")),
git: None,
rev: None,
},
false,
)
}
/// `flowrsc app` writes the program, its app and the shell; a `.flowrs` document's own app
/// goes with it, and one that fails its check is refused with the reason.
#[test]
fn app_exports_the_program_and_its_app_and_refuses_an_app_that_fails() {
let dir = TempDir::new().unwrap();
let doc = dir.path().join("sum.flowrs");
let g = valid_graph();
std::fs::write(&doc, flowrs_app::save(&g, None)).unwrap();
let out = dir.path().join("sum");
let (code, _, err) = app(&doc, &out, Some("Sum"));
assert_eq!(code, EXIT_OK, "{err}");
for f in [
"Cargo.toml",
"src/main.rs",
"ui.oui",
"ui/app.json",
"ui/tokens.css",
"app/Cargo.toml",
"app/src/main.rs",
] {
assert!(out.join(f).is_file(), "{f} missing: {err}");
}
assert!(err.contains("cargo run --release -p sum-app"), "{err}");
// The inferred app, titled from the document's file as the editor titles it.
let source = std::fs::read_to_string(out.join("ui.oui")).unwrap();
let opts = flowrs_app::infer::Options {
file: Some("sum.flowrs".into()),
..Default::default()
};
assert_eq!(
source.trim_end(),
flowrs_app::infer::infer_source_with(&g, opts)
);
assert!(source.contains("Heading(\"Sum\")"), "{source}");
let bad = flowrs_app::app("root = Stack([Stat(\"Nothing\", n99.out)])");
std::fs::write(&doc, flowrs_app::save(&g, Some(&bad))).unwrap();
let (code, _, err) = app(&doc, &dir.path().join("bad"), None);
assert_eq!(code, EXIT_INVALID, "{err}");
assert!(err.contains("does not pass its check"), "{err}");
assert!(!dir.path().join("bad").exists());
let (code, _, _) = app(&dir.path().join("missing.flowrs"), &out, None);
assert_eq!(code, EXIT_USAGE);
}
/// Every file under `dir`, by its path relative to `dir` (`/`-separated), with its bytes.
fn files_under(dir: &Path) -> std::collections::BTreeMap<String, Vec<u8>> {
let mut out = std::collections::BTreeMap::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
for e in std::fs::read_dir(&d).unwrap() {
let p = e.unwrap().path();
if p.is_dir() {
stack.push(p);
} else {
let rel = p.strip_prefix(dir).unwrap();
let rel: Vec<String> = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
out.insert(rel.join("/"), std::fs::read(&p).unwrap());
}
}
}
out
}
/// A stored (no compression) zip's entries, read from its central directory and local
/// headers, each entry's CRC-32 checked: an independent reader for the editor's zip.
fn unzip_stored(zip: &[u8]) -> std::collections::BTreeMap<String, Vec<u8>> {
let u16_at = |i: usize| u16::from_le_bytes([zip[i], zip[i + 1]]) as usize;
let u32_at = |i: usize| u32::from_le_bytes([zip[i], zip[i + 1], zip[i + 2], zip[i + 3]]);
let crc32 = |data: &[u8]| {
let mut crc: u32 = 0xFFFF_FFFF;
for &b in data {
crc ^= u32::from(b);
for _ in 0..8 {
crc = if crc & 1 == 1 {
(crc >> 1) ^ 0xEDB8_8320
} else {
crc >> 1
};
}
}
!crc
};
let eocd = zip
.windows(4)
.rposition(|w| w == [0x50, 0x4b, 0x05, 0x06])
.expect("an end of central directory");
let count = u16_at(eocd + 10);
let mut at = u32_at(eocd + 16) as usize;
let mut out = std::collections::BTreeMap::new();
for _ in 0..count {
assert_eq!(u32_at(at), 0x0201_4b50, "a central directory entry");
assert_eq!(u16_at(at + 10), 0, "stored, not compressed");
let crc = u32_at(at + 16);
let size = u32_at(at + 20) as usize;
let name_len = u16_at(at + 28);
let extra = u16_at(at + 30);
let comment = u16_at(at + 32);
let local = u32_at(at + 42) as usize;
let name = String::from_utf8(zip[at + 46..at + 46 + name_len].to_vec()).unwrap();
assert_eq!(u32_at(local), 0x0403_4b50, "{name}: a local header");
let data_at = local + 30 + u16_at(local + 26) + u16_at(local + 28);
let data = zip[data_at..data_at + size].to_vec();
assert_eq!(crc32(&data), crc, "{name}: its CRC-32");
out.insert(name, data);
at += 46 + name_len + extra + comment;
}
out
}
/// The editor's "Export project" and `flowrsc app` write the same project for the same
/// document, byte for byte, flowRS's vendored crates included. For every app fixture:
/// `flowrsc app <fixture>` with its defaults writes a directory; the editor's path (the
/// exporter's `document_with`, over the same text and file name, and the runtime bundle as
/// the editor fetches it, this build's bundle through its JSON) builds the files it zips; the
/// two are compared file by file, and the zip the editor downloads holds exactly those files,
/// under the package's folder. No project names a git repository, the forge least of all.
#[test]
fn the_editors_export_is_flowrsc_apps_project_byte_for_byte() {
use flowrs_appshell::export::{self, Options};
use flowrs_appshell::runtime::Runtime;
// What the IDE's build writes beside its page, read back as the IDE reads it.
let fetched = Runtime::from_json(&Runtime::this_build().unwrap().to_json()).unwrap();
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../e2e/fixtures/apps");
let dir = TempDir::new().unwrap();
let mut docs: Vec<PathBuf> = std::fs::read_dir(&fixtures)
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| p.extension().is_some_and(|x| x == "flowrs"))
.collect();
docs.sort();
let (mut compared, mut refused) = (0, 0);
for doc in &docs {
let file = doc.file_name().unwrap().to_string_lossy().into_owned();
let text = std::fs::read_to_string(doc).unwrap();
let out = dir.path().join(doc.file_stem().unwrap());
let (code, _, err) = exec(
Command::App {
graph: doc.clone(),
output: out.clone(),
name: None,
linux: false,
steps: None,
flowrs: None,
git: None,
rev: None,
},
true,
);
let editor = export::document_with(&text, Some(&file), &Options::default(), Some(&fetched));
let Ok(editor) = editor else {
// What the editor refuses, flowrsc refuses too, and writes nothing.
assert_ne!(
code, EXIT_OK,
"{file}: flowrsc exported what the editor refused"
);
assert!(!out.exists(), "{file}");
refused += 1;
continue;
};
assert_eq!(code, EXIT_OK, "{file}: {err}");
let written = files_under(&out);
let built: std::collections::BTreeMap<String, Vec<u8>> = editor
.scaffold
.files
.iter()
.map(|f| (f.path.clone(), f.contents.clone().into_bytes()))
.collect();
assert_eq!(
written.keys().collect::<Vec<_>>(),
built.keys().collect::<Vec<_>>(),
"{file}: the same files"
);
for (path, bytes) in &written {
assert!(built[path] == *bytes, "{file}: {path} differs");
}
let zipped = unzip_stored(&export::zip(&editor));
let expected: std::collections::BTreeMap<String, Vec<u8>> = built
.iter()
.map(|(p, b)| (format!("{}/{p}", editor.package), b.clone()))
.collect();
assert_eq!(zipped, expected, "{file}: the zip holds the project");
// Its crates are carried in it, from this build's commit, not taken from a path
// here or from any repository.
let toml = String::from_utf8(written["app/Cargo.toml"].clone()).unwrap();
assert!(
toml.contains("flowrs-appshell = { path = \"../vendor/flowrs-appshell\" }"),
"{toml}"
);
for (path, bytes) in &written {
if path.ends_with("Cargo.toml") {
let t = String::from_utf8_lossy(bytes);
assert!(!t.contains("git.transaction.science"), "{file}: {path}");
assert!(!t.contains("git ="), "{file}: {path}");
assert!(!t.contains("path = \"/"), "{file}: {path}");
}
}
for want in [
"vendor/README.md",
"vendor/flowrs-appshell/Cargo.toml",
"vendor/flowrs-appshell/LICENSE-MIT",
"vendor/flowrs-appshell/LICENSE-APACHE",
"vendor/flowrs-appshell/NOTICE",
"vendor/flowrs-openui/LICENSES/OpenUI-MIT.txt",
"ui/OFL.txt",
] {
assert!(written.contains_key(want), "{file}: no {want}");
}
let vendored = String::from_utf8(written["vendor/README.md"].clone()).unwrap();
assert!(
vendored.contains(&format!("flowRS commit `{}`", export::REV)),
"{vendored}"
);
// The same project the in-process bundle gives (flowrsc's and the MCP tool's path).
let direct = export::document(&text, Some(&file), &Options::default()).unwrap();
assert_eq!(direct, editor, "{file}");
let readme = String::from_utf8(written["README.md"].clone()).unwrap();
assert!(
readme.contains(&format!("cargo run --release -p {}-app", editor.package)),
"{readme}"
);
compared += 1;
}
assert!(compared >= 10, "{compared} documents compared");
assert_eq!(compared + refused, docs.len());
}