386 lines
13 KiB
Rust
386 lines
13 KiB
Rust
use makepad_flow::graph::{evaluate, is_canonical, tool_schema, write, FlowVm};
|
|
use makepad_flow::{Graph, Literal};
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
fn error(source: &str, file: &str) -> makepad_flow::EvalError {
|
|
let error = evaluate(source, file).expect_err("source unexpectedly evaluated");
|
|
assert_eq!(error.file, file);
|
|
assert!(error.line > 0 && error.col > 0, "unlocated error: {error:?}");
|
|
error
|
|
}
|
|
|
|
fn param<'a>(graph: &'a Graph, node: &str, name: &str) -> &'a Literal {
|
|
graph
|
|
.nodes
|
|
.iter()
|
|
.find(|candidate| candidate.id == node)
|
|
.and_then(|node| {
|
|
node.params
|
|
.iter()
|
|
.find_map(|(key, value)| (key == name).then_some(value))
|
|
})
|
|
.unwrap_or_else(|| panic!("missing {node}.{name}"))
|
|
}
|
|
|
|
fn without_revision_or_locations(mut graph: Graph) -> Graph {
|
|
graph.revision = 0;
|
|
for node in &mut graph.nodes {
|
|
node.loc = Default::default();
|
|
}
|
|
graph
|
|
}
|
|
|
|
#[test]
|
|
fn every_template_round_trips_to_the_same_graph_and_edge_count() {
|
|
let template_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("recipes/templates");
|
|
let mut templates: Vec<_> = fs::read_dir(&template_dir)
|
|
.expect("read recipe template directory")
|
|
.map(|entry| entry.expect("read recipe template entry").path())
|
|
.filter(|path| path.extension().is_some_and(|extension| extension == "splash"))
|
|
.collect();
|
|
templates.sort();
|
|
assert!(!templates.is_empty());
|
|
|
|
for path in templates {
|
|
let name = path.file_name().unwrap().to_str().unwrap();
|
|
let source = fs::read_to_string(&path).unwrap();
|
|
let original = evaluate(&source, name).unwrap_or_else(|error| panic!("{name}: {error}"));
|
|
let edge_count = original.edges.len();
|
|
let written = write(&original);
|
|
assert!(is_canonical(&written), "{name}: writer output is not canonical");
|
|
let reparsed = evaluate(&written, name)
|
|
.unwrap_or_else(|error| panic!("{name}: written form failed: {error}"));
|
|
assert_eq!(reparsed.edges.len(), edge_count, "{name}: edge count changed");
|
|
let original = without_revision_or_locations(original);
|
|
let mut reparsed = without_revision_or_locations(reparsed);
|
|
for node in &original.nodes {
|
|
if node.at.is_none() {
|
|
reparsed.nodes.iter_mut().find(|other| other.id == node.id).unwrap().at = None;
|
|
}
|
|
}
|
|
assert_eq!(
|
|
reparsed,
|
|
original,
|
|
"{name}: Graph changed"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn node_identity_rejects_aliases_and_references_to_omitted_nodes() {
|
|
let duplicate = error(
|
|
"use mod.flow.*\nlet a = Input{}\nFlow{a, b: a}\n",
|
|
"duplicate.splash",
|
|
);
|
|
assert!(duplicate.message.contains("same node"), "{duplicate:?}");
|
|
|
|
let omitted = error(
|
|
"use mod.flow.*\nlet hidden = Input{}\nlet image = Image{prompt: hidden.text()}\nFlow{image}\n",
|
|
"omitted.splash",
|
|
);
|
|
assert_eq!(
|
|
omitted.message,
|
|
"node `hidden` is referenced by `image.prompt` but not listed in `Flow{}`"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn port_references_reject_self_cycles_long_cycles_and_node_objects() {
|
|
let long_cycle = error(
|
|
r#"use mod.flow.*
|
|
let a = Fn{in: {text: "a"} out: [@text] run: |i| {{text: i.text}}}
|
|
let b = Fn{in: {text: a.text()} out: [@text] run: |i| {{text: i.text}}}
|
|
let c = Fn{in: {text: b.text()} out: [@text] run: |i| {{text: i.text}}}
|
|
a.in.text = c.text()
|
|
Flow{a, b, c}
|
|
"#,
|
|
"long-cycle.splash",
|
|
);
|
|
assert!(long_cycle.message.contains("cycle"), "{long_cycle:?}");
|
|
|
|
let object = error(
|
|
"use mod.flow.*\nlet expand = Llm{}\nlet image = Image{\n prompt: expand\n}\nFlow{expand, image}\n",
|
|
"object-not-port.splash",
|
|
);
|
|
assert_eq!(object.line, 4, "{object:?}");
|
|
assert!(
|
|
object.message.contains("expected a port reference"),
|
|
"{object:?}"
|
|
);
|
|
|
|
let self_cycle = error(
|
|
"use mod.flow.*\nlet image = Image{}\nimage.prompt = image.image()\nFlow{image}\n",
|
|
"self-cycle.splash",
|
|
);
|
|
assert!(self_cycle.message.contains("cycle"), "{self_cycle:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn numeric_literals_are_typed_but_documented_ranges_are_hints() {
|
|
let graph = evaluate(
|
|
"use mod.flow.*\nlet image = Image{width: 100000}\nFlow{image}\n",
|
|
"width-range.splash",
|
|
)
|
|
.expect("inspector ranges are documentation, not validation");
|
|
assert_eq!(param(&graph, "image", "width"), &Literal::Num(100_000.0));
|
|
|
|
let wrong = error(
|
|
"use mod.flow.*\nlet image = Image{\n width: \"1024\"\n}\nFlow{image}\n",
|
|
"width-string.splash",
|
|
);
|
|
assert_eq!(wrong.line, 3, "{wrong:?}");
|
|
assert!(wrong.message.contains("width"), "{wrong:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn stepped_numeric_params_are_snapped_with_a_warning() {
|
|
let graph = evaluate(
|
|
"use mod.flow.*\nlet image = Image{width: 1064 height: 1017}\nlet video = Video{width: 650 height: 359}\nFlow{image video}\n",
|
|
"width-step.splash",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(param(&graph, "image", "width"), &Literal::Num(1072.0));
|
|
assert_eq!(param(&graph, "image", "height"), &Literal::Num(1024.0));
|
|
assert_eq!(param(&graph, "video", "width"), &Literal::Num(640.0));
|
|
assert_eq!(param(&graph, "video", "height"), &Literal::Num(352.0));
|
|
assert!(
|
|
graph
|
|
.warnings
|
|
.iter()
|
|
.any(|warning| warning.contains("width 1064 snapped to 1072")),
|
|
"{:?}",
|
|
graph.warnings
|
|
);
|
|
assert!(
|
|
graph
|
|
.warnings
|
|
.iter()
|
|
.any(|warning| warning.contains("height 1017 snapped to 1024")),
|
|
"{:?}",
|
|
graph.warnings
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fn_requires_a_closure_and_declared_input_and_output_ports() {
|
|
for (name, source) in [
|
|
(
|
|
"missing-run.splash",
|
|
"use mod.flow.*\nlet f = Fn{in: {} out: [@text]}\nFlow{f}\n",
|
|
),
|
|
(
|
|
"number-run.splash",
|
|
"use mod.flow.*\nlet f = Fn{in: {} out: [@text] run: 42}\nFlow{f}\n",
|
|
),
|
|
] {
|
|
let error = error(source, name);
|
|
assert!(error.message.contains("run"), "{error:?}");
|
|
assert!(error.message.contains("closure"), "{error:?}");
|
|
}
|
|
|
|
let empty_out =
|
|
"use mod.flow.*\nlet f = Fn{in: {} out: [] run: |i| {{text: \"extra\"}}}\nFlow{f}\n";
|
|
let (mut vm, _) = FlowVm::load(empty_out, "empty-out.splash").unwrap();
|
|
assert!(vm.call_fn("f", &[]).unwrap().is_empty());
|
|
|
|
let reserved = error(
|
|
"use mod.flow.*\nlet f = Fn{in: {} out: [@text] run: |in| {{text: \"x\"}}}\nFlow{f}\n",
|
|
"reserved-in.splash",
|
|
);
|
|
assert!(reserved.message.contains("reserved"), "{reserved:?}");
|
|
|
|
let undeclared = error(
|
|
"use mod.flow.*\nlet source = Input{}\nlet f = Fn{in: {} out: [@text] run: |i| {{text: \"x\"}} text: source.text()}\nFlow{source, f}\n",
|
|
"undeclared-fn-input.splash",
|
|
);
|
|
assert!(undeclared.message.contains("undeclared"), "{undeclared:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn http_validates_method_output_and_url_edge_types() {
|
|
let output = error(
|
|
"use mod.flow.*\nlet request = Http{out: @mesh url: \"https://example.test\"}\nFlow{request}\n",
|
|
"http-output.splash",
|
|
);
|
|
assert!(output.message.contains("out"), "{output:?}");
|
|
|
|
evaluate(
|
|
"use mod.flow.*\nlet url = Input{}\nlet request = Http{url: url.text()}\nFlow{url, request}\n",
|
|
"http-text-url.splash",
|
|
)
|
|
.unwrap();
|
|
let mismatch = error(
|
|
"use mod.flow.*\nlet picture = Input{type: @image}\nlet request = Http{url: picture.image()}\nFlow{picture, request}\n",
|
|
"http-image-url.splash",
|
|
);
|
|
assert!(mismatch.message.contains("type mismatch"), "{mismatch:?}");
|
|
|
|
let method = error(
|
|
"use mod.flow.*\nlet request = Http{method: @patch url: \"https://example.test\"}\nFlow{request}\n",
|
|
"http-method.splash",
|
|
);
|
|
for allowed in ["get", "post", "put", "delete"] {
|
|
assert!(method.message.contains(allowed), "{method:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ask_validates_options_timeout_and_answer_type() {
|
|
for (name, field) in [
|
|
("ask-options.splash", "options: \"yes\""),
|
|
("ask-timeout.splash", "timeout: -1"),
|
|
("ask-bytes.splash", "type: @bytes"),
|
|
] {
|
|
let source = format!("use mod.flow.*\nlet ask = Ask{{{field}}}\nFlow{{ask}}\n");
|
|
let error = error(&source, name);
|
|
assert!(error.message.contains(field.split(':').next().unwrap()), "{error:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn build_time_loops_flatten_generated_nodes_and_if_values() {
|
|
let source = r#"use mod.flow.*
|
|
let flow = Flow{}
|
|
for i in 0..20 {
|
|
flow["image_" + i] = Image{prompt: "generated"}
|
|
}
|
|
flow
|
|
"#;
|
|
let graph = evaluate(source, "generated-loop.splash").unwrap();
|
|
assert_eq!(graph.nodes.len(), 20);
|
|
for index in 0..20 {
|
|
assert!(graph.nodes.iter().any(|node| node.id == format!("image_{index}")));
|
|
}
|
|
let flat = write(&graph);
|
|
assert!(!flat.contains("for "));
|
|
assert!(is_canonical(&flat));
|
|
|
|
let chosen = evaluate(
|
|
r#"use mod.flow.*
|
|
let instruction = if true { "chosen system" } else { "wrong system" }
|
|
let llm = Llm{system: instruction}
|
|
Flow{llm}
|
|
"#,
|
|
"build-if.splash",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
param(&chosen, "llm", "system"),
|
|
&Literal::Str("chosen system".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unicode_and_large_sources_round_trip_or_fail_with_a_budget_error() {
|
|
let text = format!("🧪{}", "é".repeat(50_000));
|
|
assert!(text.len() >= 100_000);
|
|
let source = format!(
|
|
"use mod.flow.*\nlet llm = Llm{{system: {}}}\nFlow{{llm}}\n",
|
|
makepad_strict_json::Value::Str(text.clone()).to_json()
|
|
);
|
|
let graph = evaluate(&source, "unicode-100k.splash").unwrap();
|
|
assert_eq!(param(&graph, "llm", "system"), &Literal::Str(text.clone()));
|
|
let reparsed = evaluate(&write(&graph), "unicode-written.splash").unwrap();
|
|
assert_eq!(param(&reparsed, "llm", "system"), &Literal::Str(text));
|
|
|
|
let huge = format!(
|
|
"use mod.flow.*\nlet llm = Llm{{system: \"{}\"}}\nFlow{{llm}}\n",
|
|
"x".repeat(205 * 1024)
|
|
);
|
|
let error = error(&huge, "source-200k.splash");
|
|
assert!(
|
|
error.message.contains("budget") || error.message.contains("limit"),
|
|
"{error:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn face_source_spans_include_nested_braces_strings_and_closing_braces() {
|
|
let dropdown = r#"DropDown{ labels: ["a"] bind: styled.style }"#;
|
|
let nested = r#"NestedFace{ panel: {label: "a } brace" inner: {open: true}} }"#;
|
|
let flow_face = r#"View{ body: {label: "whole flow"} }"#;
|
|
let source = format!(
|
|
r#"use mod.flow.*
|
|
let View = {{}}
|
|
let DropDown = {{}}
|
|
let NestedFace = {{}}
|
|
|
|
let styled = Fn{{in: {{style: "a"}} out: [@text] run: |i| {{{{text: i.style}}}}}}
|
|
|
|
let first = Image{{prompt: "x" ui: {dropdown}}}
|
|
|
|
let second = Image{{width: 512 ui: {nested}}}
|
|
|
|
Flow{{
|
|
styled, first, second
|
|
ui: {flow_face}
|
|
}}
|
|
"#
|
|
);
|
|
let graph = evaluate(&source, "faces.splash").unwrap();
|
|
assert_eq!(
|
|
graph.nodes.iter().find(|node| node.id == "second").unwrap().face_src.as_deref(),
|
|
Some(nested)
|
|
);
|
|
assert_eq!(graph.flow_ui_src.as_deref(), Some(flow_face));
|
|
assert_eq!(
|
|
graph.nodes.iter().find(|node| node.id == "first").unwrap().face_src.as_deref(),
|
|
Some(dropdown)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn docs_on_lets_attach_to_nodes_but_parameter_docs_do_not() {
|
|
let graph = evaluate(
|
|
r#"use mod.flow.*
|
|
/** a doc */
|
|
let documented = Image{}
|
|
let field_only = Image{
|
|
/** width doc */ width: 512
|
|
}
|
|
Flow{documented, field_only}
|
|
"#,
|
|
"docs.splash",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
graph.nodes.iter().find(|node| node.id == "documented").unwrap().doc.as_deref(),
|
|
Some("a doc")
|
|
);
|
|
assert_eq!(
|
|
graph.nodes.iter().find(|node| node.id == "field_only").unwrap().doc,
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_schema_uses_exact_dependencies_and_rejects_bad_declarations() {
|
|
let graph = evaluate(
|
|
r#"use mod.flow.*
|
|
let prompt = Input{}
|
|
let unused = Input{}
|
|
let image = Image{prompt: prompt.text()}
|
|
let picture = Output{type: @image value: image.image()}
|
|
Flow{tools: {paint: {in: [prompt] out: [picture]}} prompt, unused, image, picture}
|
|
"#,
|
|
"paint-tool.splash",
|
|
)
|
|
.unwrap();
|
|
let paint = graph.tools.iter().find(|tool| tool.name == "paint").unwrap();
|
|
assert_eq!(paint.nodes, ["prompt", "image", "picture"]);
|
|
let schema = tool_schema(&graph);
|
|
assert_eq!(schema.tools.iter().find(|tool| tool.name == "paint").unwrap().name, "paint");
|
|
|
|
let non_output = error(
|
|
"use mod.flow.*\nlet prompt = Input{}\nFlow{tools: {paint: {in: [prompt] out: [prompt]}} prompt}\n",
|
|
"tool-non-output.splash",
|
|
);
|
|
assert!(non_output.message.contains("Output") || non_output.message.contains("output"));
|
|
|
|
let reserved = error(
|
|
"use mod.flow.*\nlet prompt = Input{}\nFlow{tools: {run: {in: [prompt] out: []}} prompt}\n",
|
|
"tool-run.splash",
|
|
);
|
|
assert!(reserved.message.contains("reserved"), "{reserved:?}");
|
|
}
|