makepad/libs/flow/tests/flow.rs

537 lines
18 KiB
Rust

use makepad_flow::graph::{evaluate, is_canonical, prelude_catalog, tool_schema, write};
use makepad_flow::{Literal, NodeInputValue, PortType};
use makepad_micro_serde::{DeJson, SerJson};
use std::collections::HashSet;
#[test]
fn design_fixture_evaluates() {
let source = include_str!("fixtures/prompt_image.splash");
let graph = evaluate(source, "prompt_image.splash").unwrap();
assert_eq!(graph.nodes.len(), 5);
assert_eq!(graph.edges.len(), 4);
assert_eq!(graph.label, "Prompt to image");
assert_eq!(
graph.nodes.iter().map(|node| node.kind.as_str()).collect::<Vec<_>>(),
["input", "chat", "fn", "gen", "output"]
);
assert_eq!(graph.nodes[0].at, Some((40.0, 120.0)));
assert_eq!(graph.tools[0].inputs, ["prompt"]);
assert_eq!(graph.tools[0].outputs, ["picture"]);
assert!(graph.nodes[2].fn_src.as_deref().unwrap().starts_with("|i|"));
assert!(graph.nodes[3]
.face_src
.as_deref()
.unwrap()
.starts_with("ImageFace{"));
let schema = tool_schema(&graph);
assert_eq!(schema.tools[0].name, "run");
let canonical = write(&graph);
let graph2 = evaluate(&canonical, "written.splash").unwrap();
assert_eq!(write(&graph2), canonical);
assert!(is_canonical(&canonical));
}
#[test]
fn node_facing_round_trips_without_emitting_the_default() {
let source = r#"use mod.flow.*
let left_to_right = Input{at: vec2(10, 20)}
let right_to_left = Output{at: vec2(30, 40) size: vec2(300, 180) flip: true}
Flow{left_to_right, right_to_left}
"#;
let graph = evaluate(source, "facing.splash").unwrap();
assert!(!graph.nodes[0].flip);
assert!(graph.nodes[1].flip);
let written = write(&graph);
assert_eq!(written.matches(" flip: true\n").count(), 1);
assert!(written.find(" size: vec2(300, 180)\n").unwrap()
< written.find(" flip: true\n").unwrap());
let reparsed = evaluate(&written, "facing-written.splash").unwrap();
assert_eq!(reparsed.nodes[0].flip, graph.nodes[0].flip);
assert_eq!(reparsed.nodes[1].flip, graph.nodes[1].flip);
assert_eq!(write(&reparsed), written);
}
#[test]
fn errors_have_locations() {
let cases = [
(
"unknown.splash",
"use mod.flow.*\nlet x = Unknown{}\nFlow{x}",
"variable Unknown not found",
),
(
"not_flow.splash",
"use mod.flow.*\nlet x = Text{}\n{x}",
"not a Flow",
),
(
"missing.splash",
"use mod.flow.*\nlet hidden = Text{}\nlet result = Output{value: hidden.text()}\nFlow{result}",
"node `hidden` is referenced by `result.value` but not listed in `Flow{}`",
),
(
"mismatch.splash",
"use mod.flow.*\nlet pixels = Input{type: @image}\nlet image = Image{prompt: pixels.image()}\nFlow{pixels, image}",
"type mismatch",
),
(
"closure.splash",
"use mod.flow.*\nlet image = Image{prompt: || {\"no\"}}\nFlow{image}",
"closure where",
),
(
"wrong_param.splash",
"use mod.flow.*\nlet image = Image{width: \"wide\"}\nFlow{image}",
"wrong type for parameter `width`",
),
(
"syntax.splash",
"use mod.flow.*\nlet image = Image{ width: }\nFlow{image}",
"Expected expression",
),
];
for (file, source, expected) in cases {
let error = evaluate(source, file).unwrap_err();
assert_eq!(error.file, file);
assert!(error.line > 0, "{file}: {error:?}");
assert!(error.col > 0, "{file}: {error:?}");
assert!(
error.message.contains(expected),
"{file}: expected {expected:?}, got {error:?}"
);
}
}
#[test]
fn reserved_in_closure_parameter_is_a_vm_error() {
let source = "use mod.flow.*\nlet f = Fn{in: {text: \"x\"} out: [@text] run: |in| {{text: in.text}}}\nFlow{f}";
let error = evaluate(source, "reserved_in.splash").unwrap_err();
assert_eq!(error.file, "reserved_in.splash");
assert!(error.message.contains("reserved"), "{error:?}");
assert!(error.line > 0 && error.col > 0);
}
#[test]
fn cycle_is_rejected() {
let source = r#"use mod.flow.*
let a = Fn{in: {text: "a"} out: [@text] run: |input| {{text: input.text}}}
let b = Fn{in: {text: a.text()} out: [@text] run: |input| {{text: input.text}}}
a.in.text = b.text()
Flow{a, b}
"#;
let error = evaluate(source, "cycle.splash").unwrap_err();
assert!(error.message.contains("cycle"), "{error:?}");
assert!(error.line > 0 && error.col > 0);
}
#[test]
fn derived_generators_keep_base_type_and_overrides() {
let graph = evaluate(include_str!("fixtures/derived.splash"), "derived.splash").unwrap();
let portrait = &graph.nodes[1];
assert_eq!(portrait.type_name, "Image");
assert_eq!(portrait.domain.as_deref(), Some("image"));
assert_eq!(param(portrait, "width"), &Literal::Num(768.0));
assert_eq!(param(portrait, "height"), &Literal::Num(1024.0));
let upscale = &graph.nodes[2];
assert_eq!(upscale.type_name, "Upscale");
assert_eq!(upscale.domain.as_deref(), Some("upscale"));
assert_eq!(upscale.outputs[0].ty, PortType::Image);
round_trip(&graph);
}
#[test]
fn http_ask_and_text_have_declared_ports_and_params() {
let graph = evaluate(
include_str!("fixtures/http_ask_text.splash"),
"http_ask_text.splash",
)
.unwrap();
assert_eq!(graph.trigger, "input");
assert_eq!(graph.concurrency, 2);
assert!(graph.autostart);
assert_eq!(graph.nodes[0].type_name, "Text");
let http = &graph.nodes[1];
assert_eq!(http.outputs[0].name, "value");
assert_eq!(http.outputs[0].ty, PortType::Json);
assert_eq!(http.outputs[1].ty, PortType::Json);
assert_eq!(param(http, "method"), &Literal::Id("get".to_string()));
let ask = &graph.nodes[2];
assert_eq!(ask.outputs[0].name, "text");
assert_eq!(param(ask, "timeout"), &Literal::Num(30.0));
round_trip(&graph);
}
#[test]
fn generic_gen_carries_custom_params_and_ports() {
let graph = evaluate(include_str!("fixtures/gen.splash"), "gen.splash").unwrap();
let node = &graph.nodes[0];
assert_eq!(node.type_name, "Gen");
assert_eq!(node.domain.as_deref(), Some("video"));
assert_eq!(param(node, "quality"), &Literal::Num(4.0));
assert_eq!(node.inputs[0].port, "prompt");
assert_eq!(node.inputs[0].ty, PortType::Text);
assert_eq!(node.outputs[0].name, "video");
assert_eq!(node.outputs[0].ty, PortType::Video);
round_trip(&graph);
}
#[test]
fn declared_gen_port_signatures_survive_writer_round_trip() {
let source = r#"use mod.flow.*
let Misleading = Image{
ports: {
in: {picture: @text, caption: @image}
out: {picture: @audio, caption: @video}
}
}
let generated = Misleading{}
let result = Output{type: @audio value: generated.out(@picture)}
Flow{generated, result}
"#;
let graph = evaluate(source, "misleading-ports.splash").unwrap();
let before = graph.nodes.iter().find(|node| node.id == "generated").unwrap();
assert_eq!(before.type_name, "Image");
assert_eq!(
before
.inputs
.iter()
.map(|port| (port.port.as_str(), port.ty))
.collect::<Vec<_>>(),
[("picture", PortType::Text), ("caption", PortType::Image)]
);
assert_eq!(
before
.outputs
.iter()
.map(|port| (port.name.as_str(), port.ty))
.collect::<Vec<_>>(),
[("picture", PortType::Audio), ("caption", PortType::Video)]
);
let written = write(&graph);
let rewritten = evaluate(&written, "misleading-ports-written.splash").unwrap();
let after = rewritten
.nodes
.iter()
.find(|node| node.id == "generated")
.unwrap();
assert_eq!(
before
.inputs
.iter()
.map(|port| (port.port.as_str(), port.ty))
.collect::<Vec<_>>(),
after
.inputs
.iter()
.map(|port| (port.port.as_str(), port.ty))
.collect::<Vec<_>>()
);
assert_eq!(
before
.outputs
.iter()
.map(|port| (port.name.as_str(), port.ty))
.collect::<Vec<_>>(),
after
.outputs
.iter()
.map(|port| (port.name.as_str(), port.ty))
.collect::<Vec<_>>()
);
}
#[test]
fn named_tool_prunes_the_unrelated_branch() {
let graph = evaluate(include_str!("fixtures/tools.splash"), "tools.splash").unwrap();
assert_eq!(graph.tools[0].name, "run");
assert_eq!(graph.tools[0].nodes.len(), 5);
let paint = &graph.tools[1];
assert_eq!(paint.name, "paint");
assert_eq!(paint.inputs, ["prompt"]);
assert_eq!(paint.outputs, ["picture"]);
assert_eq!(paint.nodes, ["prompt", "image", "picture"]);
let schema = tool_schema(&graph);
assert_eq!(schema.tools.len(), 2);
assert_eq!(schema.tools[1].result_fields, [("picture".to_string(), PortType::Image)]);
let parsed = makepad_strict_json::parse(schema.tools[1].parameters.as_bytes()).unwrap();
assert_eq!(parsed.get("type").and_then(|value| value.as_str()), Some("object"));
round_trip(&graph);
}
#[test]
fn build_time_loop_is_flattened_by_writer() {
let source = include_str!("fixtures/loop.splash");
let graph = evaluate(source, "loop.splash").unwrap();
assert_eq!(graph.nodes.len(), 3);
assert!(!is_canonical(source));
let flat = write(&graph);
assert!(!flat.contains("for "));
assert!(is_canonical(&flat));
round_trip(&graph);
}
#[test]
fn comments_make_an_otherwise_flat_file_custom() {
let source = include_str!("fixtures/custom_comment.splash");
assert!(!is_canonical(source));
let written = write(&evaluate(source, "custom_comment.splash").unwrap());
assert!(is_canonical(&written));
}
#[test]
fn every_fixture_writer_round_trips() {
for (name, source) in [
("prompt_image.splash", include_str!("fixtures/prompt_image.splash")),
("derived.splash", include_str!("fixtures/derived.splash")),
("http_ask_text.splash", include_str!("fixtures/http_ask_text.splash")),
("tools.splash", include_str!("fixtures/tools.splash")),
("gen.splash", include_str!("fixtures/gen.splash")),
("loop.splash", include_str!("fixtures/loop.splash")),
("custom_comment.splash", include_str!("fixtures/custom_comment.splash")),
] {
let graph = evaluate(source, name).unwrap();
round_trip(&graph);
}
}
fn param<'a>(node: &'a makepad_flow::Node, name: &str) -> &'a Literal {
node.params
.iter()
.find_map(|(key, value)| (key == name).then_some(value))
.unwrap()
}
fn round_trip(graph: &makepad_flow::Graph) {
let first = write(graph);
let second = write(&evaluate(&first, "round_trip.splash").unwrap());
assert_eq!(second, first);
assert!(is_canonical(&first));
}
#[test]
fn edge_values_are_exposed_in_inputs() {
let graph = evaluate(include_str!("fixtures/prompt_image.splash"), "ports.splash").unwrap();
let prompt = graph.nodes[1]
.inputs
.iter()
.find(|input| input.port == "prompt")
.unwrap();
assert!(matches!(prompt.value, NodeInputValue::Edge(_)));
}
#[test]
fn graph_wire_json_round_trips() {
let graph = evaluate(include_str!("fixtures/prompt_image.splash"), "wire.splash").unwrap();
let json = graph.serialize_json();
assert_eq!(makepad_flow::Graph::deserialize_json(&json).unwrap(), graph);
}
#[test]
fn inpaint_mask_port_type_checks_against_declared_image_type() {
let source = r#"use mod.flow.*
let image = Input{type: @image}
let mask = Input{type: @image}
let inpaint = Inpaint{prompt: "fill" image: image.image() mask: mask.image()}
let picture = Output{type: @image value: inpaint.out(@image)}
Flow{image, mask, inpaint, picture}
"#;
let graph = evaluate(source, "inpaint_ok.splash").unwrap();
let node = graph.nodes.iter().find(|node| node.id == "inpaint").unwrap();
let mask_input = node.inputs.iter().find(|input| input.port == "mask").unwrap();
assert_eq!(mask_input.ty, PortType::Image);
let image_input = node.inputs.iter().find(|input| input.port == "image").unwrap();
assert_eq!(image_input.ty, PortType::Image);
let prompt_input = node.inputs.iter().find(|input| input.port == "prompt").unwrap();
assert_eq!(prompt_input.ty, PortType::Text);
round_trip(&graph);
}
#[test]
fn wiring_text_into_inpaint_mask_is_a_located_error() {
let source = r#"use mod.flow.*
let image = Input{type: @image}
let words = Input{type: @text}
let inpaint = Inpaint{prompt: "fill" image: image.image() mask: words.text()}
Flow{image, words, inpaint}
"#;
let error = evaluate(source, "inpaint_mismatch.splash").unwrap_err();
assert_eq!(error.file, "inpaint_mismatch.splash");
assert!(error.line > 0 && error.col > 0, "{error:?}");
assert!(error.message.contains("type mismatch"), "{error:?}");
assert!(error.message.contains("mask"), "{error:?}");
}
#[test]
fn old_array_ports_form_still_evaluates_with_a_deprecation_warning() {
let source = r#"use mod.flow.*
let LegacyGen = Gen{domain: "video" ports: {in: [@prompt] out: [@video]}}
let clip = LegacyGen{prompt: "waves"}
let result = Output{type: @video value: clip.out(@video)}
Flow{clip, result}
"#;
let graph = evaluate(source, "legacy_ports.splash").unwrap();
let node = graph.nodes.iter().find(|node| node.id == "clip").unwrap();
// The array form still infers types by name, exactly as before.
assert_eq!(node.inputs[0].ty, PortType::Text);
assert_eq!(node.outputs[0].ty, PortType::Video);
assert!(
graph
.warnings
.iter()
.any(|warning| warning.contains("deprecated") && warning.contains("clip")),
"{:?}",
graph.warnings
);
round_trip(&graph);
}
#[test]
fn empty_legacy_port_arrays_still_warn() {
let source = r#"use mod.flow.*
let Legacy = Gen{domain: "image" ports: {in: [] out: []}}
let empty = Legacy{}
Flow{empty}
"#;
let graph = evaluate(source, "empty-legacy-ports.splash").unwrap();
assert_eq!(graph.warnings.len(), 2, "{:?}", graph.warnings);
assert!(graph.warnings.iter().all(|warning| warning.contains("deprecated")));
}
#[test]
fn prelude_catalog_walks_every_recipe_type_including_mesh() {
let catalog = prelude_catalog().unwrap();
let mesh = catalog
.iter()
.find(|node| node.type_name == "Mesh")
.expect("Mesh reaches the catalog");
assert_eq!(mesh.kind, "gen");
assert_eq!(mesh.domain.as_deref(), Some("mesh"));
assert!(mesh
.ports
._in
.iter()
.any(|port| port.name == "prompt" && port.ty == PortType::Text));
assert!(mesh
.ports
._in
.iter()
.any(|port| port.name == "image" && port.ty == PortType::Image));
assert!(mesh
.ports
.out
.iter()
.any(|port| port.name == "mesh" && port.ty == PortType::Mesh));
assert!(mesh.params.iter().any(|param| param.name == "remesh_resolution"));
assert!(!mesh.doc.is_empty());
// The catalog is no longer the old hard-coded ten-name list.
for type_name in ["Inpaint", "Video", "Music", "Paint", "Control", "ImageEdit"] {
assert!(
catalog.iter().any(|node| node.type_name == type_name),
"catalog missing `{type_name}`"
);
}
let music = catalog.iter().find(|node| node.type_name == "Music").unwrap();
assert!(music
.ports
._in
.iter()
.any(|port| port.name == "lyrics" && port.ty == PortType::Text));
let paint = catalog.iter().find(|node| node.type_name == "Paint").unwrap();
assert!(paint
.ports
._in
.iter()
.any(|port| port.name == "reference_image" && port.ty == PortType::Image));
let publish = catalog
.iter()
.find(|node| node.type_name == "Publish")
.unwrap();
assert_eq!(publish.kind, "publish");
assert!(publish
.ports
._in
.iter()
.any(|port| port.name == "value"));
assert!(publish
.ports
.out
.iter()
.any(|port| port.name == "asset" && port.ty == PortType::Json));
assert!(publish.params.iter().all(|param| !param.doc.is_empty()));
assert!(!publish.doc.is_empty());
}
#[test]
fn prelude_catalog_has_the_exact_public_node_type_set() {
let catalog = prelude_catalog().unwrap();
let names: Vec<_> = catalog.iter().map(|node| node.type_name.as_str()).collect();
assert_eq!(
names,
[
"Annotate",
"Ask",
"Beats",
"Body",
"Control",
"Depth",
"Fn",
"Gen",
"Http",
"Image",
"ImageEdit",
"Inpaint",
"Input",
"Llm",
"Matte",
"Mesh",
"Motion",
"Music",
"Notes",
"Output",
"Paint",
"Publish",
"Rig",
"Segment",
"Sfx",
"Speech",
"Splat",
"Stems",
"Stt",
"Text",
"Upscale",
"Video",
"VideoEnhance",
"Vision",
"World",
]
);
assert_eq!(names.iter().copied().collect::<HashSet<_>>().len(), names.len());
for excluded in [
"Node",
"Flow",
"NodeFace",
"InputFace",
"TextFace",
"OutputFace",
"PublishFace",
"LlmFace",
"FnFace",
"HttpFace",
"AskFace",
"GenFace",
"ImageFace",
"UpscaleFace",
] {
assert!(!names.contains(&excluded), "catalog contains `{excluded}`");
}
}
#[test]
fn prelude_module_is_frozen() {
let error = evaluate(
"use mod.flow.*\nmod.flow.Image = {}\nFlow{}",
"frozen.splash",
)
.unwrap_err();
assert!(error.message.contains("already exists"), "{error:?}");
}