use std::collections::{HashMap, HashSet};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use super::registration::NodeRegistration;
use super::WireKind;
use crate::gpu::params::ParamValue;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct NodeId(pub u64);
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PortRef {
pub node: NodeId,
pub port: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Connection {
pub from: PortRef,
pub to: PortRef,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PortDir {
Input,
Output,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum UnitType {
#[default]
Normalized,
Percent,
Degrees,
Raw,
Pixels,
}
impl UnitType {
pub fn to_display(self, value: f32) -> f32 {
match self {
Self::Normalized | Self::Raw | Self::Pixels => value,
Self::Percent => value * 100.0,
Self::Degrees => value * (180.0 / std::f32::consts::PI),
}
}
pub fn from_display(self, display: f32) -> f32 {
match self {
Self::Normalized | Self::Raw | Self::Pixels => display,
Self::Percent => display / 100.0,
Self::Degrees => display * (std::f32::consts::PI / 180.0),
}
}
pub fn suffix(self) -> &'static str {
match self {
Self::Normalized => "",
Self::Percent => "%",
Self::Degrees => "°",
Self::Raw => "",
Self::Pixels => "px",
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(bound = "")]
pub struct PortDef<W: WireKind> {
pub name: String,
pub dir: PortDir,
pub wire_type: W,
pub min: f32,
pub max: f32,
pub default: f32,
#[serde(default)]
pub step: f32,
#[serde(default)]
pub description: String,
#[serde(default)]
pub unit_type: UnitType,
#[serde(default)]
pub icon: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub exposed: bool,
#[serde(default)]
pub preview_value: Option<f32>,
#[serde(default)]
pub preview_irrelevant_scrub: bool,
#[serde(default)]
pub visible_when: Option<(String, Vec<i32>)>,
#[serde(default)]
pub natural_range: Option<(f32, f32)>,
#[serde(default)]
pub persist_in_thumbnail: bool,
}
impl<W: WireKind> PortDef<W> {
pub fn input(name: impl Into<String>, wire_type: W) -> Self {
Self {
name: name.into(),
dir: PortDir::Input,
wire_type,
min: 0.0,
max: 1.0,
default: 0.0,
description: String::new(),
unit_type: UnitType::default(),
icon: String::new(),
label: String::new(),
exposed: false,
preview_value: None,
preview_irrelevant_scrub: false,
visible_when: None,
step: 0.0,
natural_range: None,
persist_in_thumbnail: false,
}
}
pub fn output(name: impl Into<String>, wire_type: W) -> Self {
Self {
name: name.into(),
dir: PortDir::Output,
wire_type,
min: 0.0,
max: 1.0,
default: 0.0,
description: String::new(),
unit_type: UnitType::default(),
icon: String::new(),
label: String::new(),
exposed: false,
preview_value: None,
preview_irrelevant_scrub: false,
visible_when: None,
step: 0.0,
natural_range: None,
persist_in_thumbnail: false,
}
}
pub fn with_range(mut self, min: f32, max: f32, default: f32) -> Self {
self.min = min;
self.max = max;
self.default = default;
self
}
pub fn with_natural_range(mut self, min: f32, max: f32) -> Self {
self.natural_range = Some((min, max));
self
}
pub fn with_step(mut self, step: f32) -> Self {
self.step = step;
self
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = desc.into();
self
}
pub fn with_unit(mut self, unit_type: UnitType) -> Self {
self.unit_type = unit_type;
self
}
pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
self.icon = icon.into();
self
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
pub fn exposed(mut self) -> Self {
self.exposed = true;
self
}
pub fn with_preview_value(mut self, value: f32) -> Self {
self.preview_value = Some(value);
self
}
pub fn preview_irrelevant_scrub(mut self) -> Self {
self.preview_irrelevant_scrub = true;
self
}
pub fn persist_in_thumbnail(mut self) -> Self {
self.persist_in_thumbnail = true;
self
}
pub fn with_visible_when(
mut self,
param_name: impl Into<String>,
allowed_values: impl IntoIterator<Item = i32>,
) -> Self {
self.visible_when = Some((param_name.into(), allowed_values.into_iter().collect()));
self
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(bound = "")]
pub struct NodeInstance<W: WireKind> {
pub id: NodeId,
pub type_id: String,
pub ports: Vec<PortDef<W>>,
pub params: Vec<ParamValue>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GraphError {
TypeMismatch {
from_type: String,
to_type: String,
},
CycleDetected,
PortNotFound {
node: NodeId,
port: String,
},
NodeNotFound(NodeId),
InputAlreadyConnected {
node: NodeId,
port: String,
},
ExposedPortNotFound {
key: String,
},
InvalidIcon {
icon: String,
},
}
fn is_safe_icon_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'-' || b == b':' || b == b' '
}
impl std::fmt::Display for GraphError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TypeMismatch { from_type, to_type } => {
write!(f, "type mismatch: {from_type} → {to_type}")
}
Self::CycleDetected => write!(f, "cycle detected"),
Self::PortNotFound { node, port } => {
write!(f, "port '{}' not found on node {:?}", port, node)
}
Self::NodeNotFound(id) => write!(f, "node {:?} not found", id),
Self::InputAlreadyConnected { node, port } => {
write!(f, "input '{}' on {:?} already connected", port, node)
}
Self::ExposedPortNotFound { key } => {
write!(f, "exposed-port entry '{}' not found", key)
}
Self::InvalidIcon { icon } => {
write!(
f,
"icon '{}' contains characters outside [a-zA-Z0-9- ]",
icon
)
}
}
}
}
impl std::error::Error for GraphError {}
#[derive(Debug, Clone, PartialEq)]
pub enum FindTerminalError {
NoTerminal,
MultipleTerminals(Vec<NodeId>),
}
impl std::fmt::Display for FindTerminalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoTerminal => write!(f, "graph has no terminal node"),
Self::MultipleTerminals(ids) => {
write!(f, "graph has multiple terminal nodes: {ids:?}")
}
}
}
}
impl std::error::Error for FindTerminalError {}
#[derive(Clone, Default, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExposedPortMeta {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub label: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub icon: String,
}
pub fn exposed_port_key(node: NodeId, port: &str) -> String {
format!("{}.{}", node.0, port)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(bound = "")]
pub struct Graph<W: WireKind> {
nodes: HashMap<NodeId, NodeInstance<W>>,
pub connections: Vec<Connection>,
next_id: u64,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub exposed_ports: IndexMap<String, ExposedPortMeta>,
}
impl<W: WireKind> Default for Graph<W> {
fn default() -> Self {
Self::new()
}
}
impl<W: WireKind> Graph<W> {
pub fn new() -> Self {
Self {
nodes: HashMap::new(),
connections: Vec::new(),
next_id: 1,
exposed_ports: IndexMap::new(),
}
}
pub fn nodes(&self) -> &HashMap<NodeId, NodeInstance<W>> {
&self.nodes
}
pub fn add_node(
&mut self,
type_id: impl Into<String>,
ports: Vec<PortDef<W>>,
params: Vec<ParamValue>,
) -> NodeId {
let id = NodeId(self.next_id);
self.next_id += 1;
for port in ports.iter() {
if port.dir == PortDir::Input && port.exposed {
let key = exposed_port_key(id, &port.name);
self.exposed_ports.insert(key, ExposedPortMeta::default());
}
}
self.nodes.insert(
id,
NodeInstance {
id,
type_id: type_id.into(),
ports,
params,
},
);
id
}
pub fn remove_node(&mut self, id: NodeId) -> Result<(), GraphError> {
if self.nodes.remove(&id).is_none() {
return Err(GraphError::NodeNotFound(id));
}
self.connections
.retain(|c| c.from.node != id && c.to.node != id);
let prefix = format!("{}.", id.0);
self.exposed_ports
.retain(|key, _| !key.starts_with(&prefix));
Ok(())
}
pub fn expose_port(&mut self, id: NodeId, port_name: &str) -> Result<(), GraphError> {
let node = self.nodes.get(&id).ok_or(GraphError::NodeNotFound(id))?;
if !node
.ports
.iter()
.any(|p| p.name == port_name && p.dir == PortDir::Input)
{
return Err(GraphError::PortNotFound {
node: id,
port: port_name.to_string(),
});
}
let key = exposed_port_key(id, port_name);
self.exposed_ports.entry(key).or_default();
Ok(())
}
pub fn unexpose_port(&mut self, id: NodeId, port_name: &str) {
let key = exposed_port_key(id, port_name);
self.exposed_ports.shift_remove(&key);
}
pub fn is_port_exposed(&self, id: NodeId, port_name: &str) -> bool {
self.exposed_ports
.contains_key(&exposed_port_key(id, port_name))
}
pub fn set_exposed_port_meta(
&mut self,
key: &str,
label: String,
description: String,
icon: String,
) -> Result<(), GraphError> {
if !icon.bytes().all(is_safe_icon_byte) {
return Err(GraphError::InvalidIcon { icon });
}
let entry =
self.exposed_ports
.get_mut(key)
.ok_or_else(|| GraphError::ExposedPortNotFound {
key: key.to_string(),
})?;
entry.label = label;
entry.description = description;
entry.icon = icon;
Ok(())
}
pub fn reorder_exposed_port(&mut self, key: &str, new_index: usize) -> Result<(), GraphError> {
let from = self.exposed_ports.get_index_of(key).ok_or_else(|| {
GraphError::ExposedPortNotFound {
key: key.to_string(),
}
})?;
let target = new_index.min(self.exposed_ports.len().saturating_sub(1));
self.exposed_ports.move_index(from, target);
Ok(())
}
pub fn connect(&mut self, from: PortRef, to: PortRef) -> Result<(), GraphError> {
let from_def = self.find_port(&from, PortDir::Output)?;
let to_def = self.find_port(&to, PortDir::Input)?;
if !W::compatible(from_def, to_def) {
return Err(GraphError::TypeMismatch {
from_type: format!("{:?}", from_def),
to_type: format!("{:?}", to_def),
});
}
if self.connections.iter().any(|c| c.to == to) {
return Err(GraphError::InputAlreadyConnected {
node: to.node,
port: to.port.clone(),
});
}
if self.is_reachable(to.node, from.node) {
return Err(GraphError::CycleDetected);
}
self.connections.push(Connection { from, to });
Ok(())
}
pub fn disconnect(&mut self, from: &PortRef, to: &PortRef) {
self.connections.retain(|c| &c.from != from || &c.to != to);
}
pub fn inputs_for(&self, node_id: NodeId) -> impl Iterator<Item = &Connection> {
self.connections
.iter()
.filter(move |c| c.to.node == node_id)
}
pub fn outputs_for(&self, node_id: NodeId) -> impl Iterator<Item = &Connection> {
self.connections
.iter()
.filter(move |c| c.from.node == node_id)
}
pub(crate) fn apply_preview_overrides(&mut self) {
let mut overrides: Vec<(NodeId, String, f32)> = Vec::new();
for node in self.nodes.values() {
for port in &node.ports {
if let Some(value) = port.preview_value {
overrides.push((node.id, port.name.clone(), value));
}
}
}
for (node_id, port_name, value) in overrides {
self.connections
.retain(|c| !(c.to.node == node_id && c.to.port == port_name));
if let Some(node) = self.nodes.get_mut(&node_id) {
if let Some(port) = node.ports.iter_mut().find(|p| p.name == port_name) {
port.default = value;
}
}
}
}
pub fn set_port_default(
&mut self,
id: NodeId,
port_name: &str,
value: f32,
) -> Result<(), GraphError> {
let node = self
.nodes
.get_mut(&id)
.ok_or(GraphError::NodeNotFound(id))?;
let port = node
.ports
.iter_mut()
.find(|p| p.name == port_name && p.dir == PortDir::Input)
.ok_or_else(|| GraphError::PortNotFound {
node: id,
port: port_name.to_string(),
})?;
port.default = value;
Ok(())
}
pub fn set_param(
&mut self,
id: NodeId,
index: usize,
value: ParamValue,
) -> Result<(), GraphError> {
let node = self
.nodes
.get_mut(&id)
.ok_or(GraphError::NodeNotFound(id))?;
if index >= node.params.len() {
return Err(GraphError::PortNotFound {
node: id,
port: format!("param[{}]", index),
});
}
node.params[index] = value;
Ok(())
}
pub fn find_terminal(
&self,
registry: &HashMap<String, NodeRegistration<W>>,
) -> Result<NodeId, FindTerminalError> {
let mut terminals: Vec<NodeId> = self
.nodes
.iter()
.filter_map(|(id, node)| {
registry
.get(&node.type_id)
.filter(|r| r.is_terminal)
.map(|_| *id)
})
.collect();
match terminals.len() {
0 => Err(FindTerminalError::NoTerminal),
1 => Ok(terminals.remove(0)),
_ => {
terminals.sort_by_key(|id| id.0);
Err(FindTerminalError::MultipleTerminals(terminals))
}
}
}
fn find_port(&self, pr: &PortRef, expected_dir: PortDir) -> Result<W, GraphError> {
let node = self
.nodes
.get(&pr.node)
.ok_or(GraphError::NodeNotFound(pr.node))?;
let def = node
.ports
.iter()
.find(|p| p.name == pr.port && p.dir == expected_dir)
.ok_or_else(|| GraphError::PortNotFound {
node: pr.node,
port: pr.port.clone(),
})?;
Ok(def.wire_type)
}
fn is_reachable(&self, start: NodeId, target: NodeId) -> bool {
let mut visited = HashSet::new();
let mut stack = vec![start];
while let Some(current) = stack.pop() {
if current == target {
return true;
}
if !visited.insert(current) {
continue;
}
for conn in &self.connections {
if conn.from.node == current {
stack.push(conn.to.node);
}
}
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nodegraph::tests::TestWireKind;
fn scalar_in(name: &str) -> PortDef<TestWireKind> {
PortDef::input(name, TestWireKind::Scalar)
}
fn scalar_out(name: &str) -> PortDef<TestWireKind> {
PortDef::output(name, TestWireKind::Scalar)
}
fn color_out(name: &str) -> PortDef<TestWireKind> {
PortDef::output(name, TestWireKind::Color)
}
#[test]
fn add_connect_disconnect_remove() {
let mut g = Graph::<TestWireKind>::new();
let a = g.add_node("source", vec![scalar_out("out")], vec![]);
let b = g.add_node("sink", vec![scalar_in("in")], vec![]);
let from = PortRef {
node: a,
port: "out".into(),
};
let to = PortRef {
node: b,
port: "in".into(),
};
g.connect(from.clone(), to.clone()).unwrap();
assert_eq!(g.connections.len(), 1);
g.disconnect(&from, &to);
assert_eq!(g.connections.len(), 0);
g.remove_node(a).unwrap();
assert!(!g.nodes.contains_key(&a));
}
#[test]
fn cycle_detection() {
let mut g = Graph::<TestWireKind>::new();
let a = g.add_node("a", vec![scalar_in("in"), scalar_out("out")], vec![]);
let b = g.add_node("b", vec![scalar_in("in"), scalar_out("out")], vec![]);
g.connect(
PortRef {
node: a,
port: "out".into(),
},
PortRef {
node: b,
port: "in".into(),
},
)
.unwrap();
let err = g
.connect(
PortRef {
node: b,
port: "out".into(),
},
PortRef {
node: a,
port: "in".into(),
},
)
.unwrap_err();
assert_eq!(err, GraphError::CycleDetected);
}
#[test]
fn type_mismatch() {
let mut g = Graph::<TestWireKind>::new();
let a = g.add_node("a", vec![color_out("out")], vec![]);
let b = g.add_node("b", vec![scalar_in("in")], vec![]);
let err = g
.connect(
PortRef {
node: a,
port: "out".into(),
},
PortRef {
node: b,
port: "in".into(),
},
)
.unwrap_err();
matches!(err, GraphError::TypeMismatch { .. });
}
#[test]
fn input_already_connected() {
let mut g = Graph::<TestWireKind>::new();
let a = g.add_node("a", vec![scalar_out("out")], vec![]);
let b = g.add_node("b", vec![scalar_out("out")], vec![]);
let c = g.add_node("c", vec![scalar_in("in")], vec![]);
g.connect(
PortRef {
node: a,
port: "out".into(),
},
PortRef {
node: c,
port: "in".into(),
},
)
.unwrap();
let err = g
.connect(
PortRef {
node: b,
port: "out".into(),
},
PortRef {
node: c,
port: "in".into(),
},
)
.unwrap_err();
matches!(err, GraphError::InputAlreadyConnected { .. });
}
#[test]
fn remove_node_cleans_connections() {
let mut g = Graph::<TestWireKind>::new();
let a = g.add_node("a", vec![scalar_out("out")], vec![]);
let b = g.add_node("b", vec![scalar_in("in"), scalar_out("out")], vec![]);
let c = g.add_node("c", vec![scalar_in("in")], vec![]);
g.connect(
PortRef {
node: a,
port: "out".into(),
},
PortRef {
node: b,
port: "in".into(),
},
)
.unwrap();
g.connect(
PortRef {
node: b,
port: "out".into(),
},
PortRef {
node: c,
port: "in".into(),
},
)
.unwrap();
g.remove_node(b).unwrap();
assert!(g.connections.is_empty());
}
#[test]
fn serde_round_trip() {
let mut g = Graph::<TestWireKind>::new();
let a = g.add_node("source", vec![scalar_out("out")], vec![]);
let b = g.add_node("sink", vec![scalar_in("in")], vec![]);
g.connect(
PortRef {
node: a,
port: "out".into(),
},
PortRef {
node: b,
port: "in".into(),
},
)
.unwrap();
let json = serde_json::to_string(&g).unwrap();
let g2: Graph<TestWireKind> = serde_json::from_str(&json).unwrap();
assert_eq!(g2.nodes.len(), 2);
assert_eq!(g2.connections.len(), 1);
}
#[test]
fn unit_type_conversion_round_trip() {
for unit in [
UnitType::Normalized,
UnitType::Percent,
UnitType::Degrees,
UnitType::Raw,
] {
for &val in &[0.0, 0.25, 0.5, 0.75, 1.0] {
let display = unit.to_display(val);
let back = unit.from_display(display);
assert!(
(back - val).abs() < 1e-6,
"{:?}: to_display({}) = {}, from_display({}) = {} (expected {})",
unit,
val,
display,
display,
back,
val,
);
}
}
}
#[test]
fn unit_type_display_values() {
use std::f32::consts::PI;
assert!((UnitType::Percent.to_display(0.5) - 50.0).abs() < 1e-6);
assert!((UnitType::Degrees.to_display(PI) - 180.0).abs() < 1e-4);
assert!((UnitType::Degrees.to_display(PI / 2.0) - 90.0).abs() < 1e-4);
assert!((UnitType::Degrees.to_display(0.0) - 0.0).abs() < 1e-6);
assert!((UnitType::Degrees.from_display(90.0) - PI / 2.0).abs() < 1e-4);
assert!((UnitType::Normalized.to_display(0.5) - 0.5).abs() < 1e-6);
assert!((UnitType::Raw.to_display(0.5) - 0.5).abs() < 1e-6);
}
#[test]
fn unit_type_suffix() {
assert_eq!(UnitType::Percent.suffix(), "%");
assert_eq!(UnitType::Degrees.suffix(), "°");
assert_eq!(UnitType::Normalized.suffix(), "");
assert_eq!(UnitType::Raw.suffix(), "");
}
#[test]
fn unit_type_serde_round_trip() {
for unit in [
UnitType::Normalized,
UnitType::Percent,
UnitType::Degrees,
UnitType::Raw,
] {
let json = serde_json::to_string(&unit).unwrap();
let back: UnitType = serde_json::from_str(&json).unwrap();
assert_eq!(unit, back);
}
}
#[test]
fn port_def_natural_range_round_trip() {
let port = PortDef::input("seed", TestWireKind::Scalar)
.with_range(0.0, 1024.0, 0.0)
.with_natural_range(0.0, 1024.0);
let json = serde_json::to_string(&port).unwrap();
let back: PortDef<TestWireKind> = serde_json::from_str(&json).unwrap();
assert_eq!(back.natural_range, Some((0.0, 1024.0)));
let bare = PortDef::input("x", TestWireKind::Scalar);
assert_eq!(bare.natural_range, None);
}
#[test]
fn port_def_step_round_trip() {
let port = PortDef::input("frequency", TestWireKind::Scalar)
.with_range(1.0, 16.0, 6.0)
.with_step(1.0);
let json = serde_json::to_string(&port).unwrap();
let back: PortDef<TestWireKind> = serde_json::from_str(&json).unwrap();
assert_eq!(back.step, 1.0);
}
#[test]
fn port_def_serde_with_new_fields() {
let port = PortDef::input("opacity", TestWireKind::Scalar)
.with_range(0.0, 1.0, 1.0)
.with_unit(UnitType::Percent)
.with_icon("fa6-solid:sun")
.with_label("Opacity")
.exposed()
.with_description("Per-dab opacity");
let json = serde_json::to_string(&port).unwrap();
let back: PortDef<TestWireKind> = serde_json::from_str(&json).unwrap();
assert_eq!(back.unit_type, UnitType::Percent);
assert_eq!(back.icon, "fa6-solid:sun");
assert_eq!(back.label, "Opacity");
assert!(back.exposed);
assert_eq!(back.description, "Per-dab opacity");
}
#[test]
fn expose_then_unexpose_round_trips() {
let mut g = Graph::<TestWireKind>::new();
let id = g.add_node("node", vec![scalar_in("val")], vec![]);
assert!(!g.is_port_exposed(id, "val"));
g.expose_port(id, "val").unwrap();
assert!(g.is_port_exposed(id, "val"));
g.expose_port(id, "val").unwrap();
assert_eq!(g.exposed_ports.len(), 1);
g.unexpose_port(id, "val");
assert!(!g.is_port_exposed(id, "val"));
}
#[test]
fn expose_port_rejects_output_port() {
let mut g = Graph::<TestWireKind>::new();
let id = g.add_node("node", vec![scalar_out("out")], vec![]);
let err = g.expose_port(id, "out").unwrap_err();
assert!(matches!(err, GraphError::PortNotFound { .. }));
}
#[test]
fn add_node_seeds_exposed_from_registration_flag() {
let mut g = Graph::<TestWireKind>::new();
let mut a = scalar_in("a");
let mut b = scalar_in("b");
a.exposed = false;
b.exposed = true;
let id = g.add_node("node", vec![a, b], vec![]);
assert!(!g.is_port_exposed(id, "a"));
assert!(g.is_port_exposed(id, "b"));
let key = exposed_port_key(id, "b");
assert_eq!(g.exposed_ports[&key], ExposedPortMeta::default());
}
#[test]
fn remove_node_drops_exposed_entries() {
let mut g = Graph::<TestWireKind>::new();
let a = g.add_node("node", vec![scalar_in("x"), scalar_in("y")], vec![]);
let b = g.add_node("node", vec![scalar_in("x")], vec![]);
g.expose_port(a, "x").unwrap();
g.expose_port(a, "y").unwrap();
g.expose_port(b, "x").unwrap();
assert_eq!(g.exposed_ports.len(), 3);
g.remove_node(a).unwrap();
assert_eq!(g.exposed_ports.len(), 1);
assert!(g.is_port_exposed(b, "x"));
}
#[test]
fn reorder_moves_entry_to_target_index() {
let mut g = Graph::<TestWireKind>::new();
let id = g.add_node(
"node",
vec![scalar_in("a"), scalar_in("b"), scalar_in("c")],
vec![],
);
g.expose_port(id, "a").unwrap();
g.expose_port(id, "b").unwrap();
g.expose_port(id, "c").unwrap();
let keys: Vec<&str> = g.exposed_ports.keys().map(String::as_str).collect();
assert_eq!(keys.len(), 3);
let b_key = exposed_port_key(id, "b");
g.reorder_exposed_port(&b_key, 0).unwrap();
let order: Vec<&str> = g.exposed_ports.keys().map(String::as_str).collect();
let a_key = exposed_port_key(id, "a");
let c_key = exposed_port_key(id, "c");
assert_eq!(order, vec![b_key.as_str(), a_key.as_str(), c_key.as_str()]);
}
#[test]
fn set_meta_rejects_unsafe_icon() {
let mut g = Graph::<TestWireKind>::new();
let id = g.add_node("node", vec![scalar_in("val")], vec![]);
g.expose_port(id, "val").unwrap();
let key = exposed_port_key(id, "val");
g.set_exposed_port_meta(&key, "Label".into(), "Desc".into(), "fa6-solid:sun".into())
.unwrap();
assert_eq!(g.exposed_ports[&key].icon, "fa6-solid:sun");
let err = g
.set_exposed_port_meta(
&key,
"Label2".into(),
"Desc2".into(),
"<script>x</script>".into(),
)
.unwrap_err();
assert!(matches!(err, GraphError::InvalidIcon { .. }));
assert_eq!(g.exposed_ports[&key].icon, "fa6-solid:sun");
assert_eq!(g.exposed_ports[&key].label, "Label");
}
#[test]
fn exposed_ports_round_trip_preserves_order() {
let mut g = Graph::<TestWireKind>::new();
let id = g.add_node(
"node",
vec![scalar_in("a"), scalar_in("b"), scalar_in("c")],
vec![],
);
g.expose_port(id, "c").unwrap();
g.expose_port(id, "a").unwrap();
g.expose_port(id, "b").unwrap();
let before: Vec<String> = g.exposed_ports.keys().cloned().collect();
let json = serde_json::to_string(&g).unwrap();
let back: Graph<TestWireKind> = serde_json::from_str(&json).unwrap();
let after: Vec<String> = back.exposed_ports.keys().cloned().collect();
assert_eq!(before, after);
}
}