diff --git a/common/labeled_entity.go b/common/labeled_entity.go index 1297ed9..d8e86a3 100644 --- a/common/labeled_entity.go +++ b/common/labeled_entity.go @@ -1,6 +1,9 @@ package common -import "errors" +import ( + "errors" + "fmt" +) type LabelsCollection map[string]string @@ -26,7 +29,7 @@ func (e *LabeledEntity) Label(label string) (string, error) { value, ok := e.labels[label] if !ok { - return "", errLabelNotFound + return "", fmt.Errorf("%w , label: %s", errLabelNotFound, label) } return value, nil diff --git a/export/dot.go b/export/dot.go index b09e31a..ce55f42 100644 --- a/export/dot.go +++ b/export/dot.go @@ -4,6 +4,8 @@ import ( "bytes" "fmt" "github.com/hovsep/fmesh" + "github.com/hovsep/fmesh/component" + "github.com/hovsep/fmesh/port" "github.com/lucasepe/dot" ) @@ -18,93 +20,156 @@ func NewDotExporter() Exporter { // Export returns the f-mesh represented as digraph in DOT language func (d *dotExporter) Export(fm *fmesh.FMesh) ([]byte, error) { - // Setup main graph - graph := dot.NewGraph(dot.Directed) - graph. - Attr("layout", "dot"). - Attr("splines", "ortho") + if len(fm.Components()) == 0 { + return nil, nil + } - for _, component := range fm.Components() { - // Component subgraph (wrapper) - componentSubgraph := graph.NewSubgraph() - componentSubgraph. - NodeBaseAttrs(). - Attr("width", "1.0").Attr("height", "1.0") - componentSubgraph. - Attr("label", component.Name()). - Attr("cluster", "true"). - Attr("style", "rounded"). - Attr("color", "black"). - Attr("bgcolor", "lightgrey"). - Attr("margin", "20") - - // Create component node and subgraph (cluster) - componentNode := componentSubgraph.Node() - componentNode.Attr("label", "𝑓") - if component.Description() != "" { - componentNode.Attr("label", component.Description()) - } - componentNode. - Attr("color", "blue"). - Attr("shape", "rect"). - Attr("group", component.Name()) + graph, err := buildGraph(fm) - // Create nodes for input ports - for _, port := range component.Inputs() { - portID := getPortID(component.Name(), "input", port.Name()) + if err != nil { + return nil, err + } - //Mark input ports to be able to find their respective nodes later when adding pipes - port.AddLabel(nodeIDLabel, portID) + buf := new(bytes.Buffer) + graph.Write(buf) - portNode := componentSubgraph.NodeWithID(portID) - portNode. - Attr("label", port.Name()). - Attr("shape", "circle"). - Attr("group", component.Name()) + return buf.Bytes(), nil +} - componentSubgraph.Edge(portNode, componentNode) - } +// buildGraph returns a graph representing the given f-mesh +func buildGraph(fm *fmesh.FMesh) (*dot.Graph, error) { + mainGraph := getMainGraph(fm) - // Create nodes for output ports - for _, port := range component.Outputs() { - portID := getPortID(component.Name(), "output", port.Name()) - portNode := componentSubgraph.NodeWithID(portID) - portNode. - Attr("label", port.Name()). - Attr("shape", "circle"). - Attr("group", component.Name()) + addComponents(mainGraph, fm.Components()) - componentSubgraph.Edge(componentNode, portNode) - } + err := addPipes(mainGraph, fm.Components()) + if err != nil { + return nil, err } + return mainGraph, nil +} - // Create edges representing pipes (all ports must exist at this point) - for _, component := range fm.Components() { - for _, srcPort := range component.Outputs() { +// addPipes adds pipes representation to the graph +func addPipes(graph *dot.Graph, components component.Collection) error { + for _, c := range components { + for _, srcPort := range c.Outputs() { for _, destPort := range srcPort.Pipes() { // Any destination port in any pipe is input port, but we do not know in which component // so we use the label we added earlier destPortID, err := destPort.Label(nodeIDLabel) if err != nil { - return nil, err + return fmt.Errorf("failed to add pipe: %w", err) } // Clean up and leave the f-mesh as it was before export destPort.DeleteLabel(nodeIDLabel) // Any source port in any pipe is always output port, so we can build its node ID - srcPortNode := graph.FindNodeByID(getPortID(component.Name(), "output", srcPort.Name())) + srcPortNode := graph.FindNodeByID(getPortID(c.Name(), "output", srcPort.Name())) destPortNode := graph.FindNodeByID(destPortID) - graph.Edge(srcPortNode, destPortNode) + graph.Edge(srcPortNode, destPortNode).Attr("minlen", 3) } } } + return nil +} - buf := new(bytes.Buffer) - graph.Write(buf) +// addComponents adds components representation to the graph +func addComponents(graph *dot.Graph, components component.Collection) { + for _, c := range components { + // Component + componentSubgraph := getComponentSubgraph(graph, c) + componentNode := getComponentNode(componentSubgraph, c) - return buf.Bytes(), nil + // Input ports + for _, p := range c.Inputs() { + portNode := getPortNode(c, p, "input", componentSubgraph) + componentSubgraph.Edge(portNode, componentNode) + } + + // Output ports + for _, p := range c.Outputs() { + portNode := getPortNode(c, p, "output", componentSubgraph) + componentSubgraph.Edge(componentNode, portNode) + } + } +} + +// getPortNode creates and returns a node representing one port +func getPortNode(c *component.Component, port *port.Port, portKind string, componentSubgraph *dot.Graph) *dot.Node { + portID := getPortID(c.Name(), portKind, port.Name()) + + //Mark ports to be able to find their respective nodes later when adding pipes + port.AddLabel(nodeIDLabel, portID) + + portNode := componentSubgraph.NodeWithID(portID) + portNode. + Attr("label", port.Name()). + Attr("shape", "circle"). + Attr("group", c.Name()) + return portNode +} + +// getComponentSubgraph creates component subgraph and returns it +func getComponentSubgraph(graph *dot.Graph, component *component.Component) *dot.Graph { + componentSubgraph := graph.NewSubgraph() + componentSubgraph. + NodeBaseAttrs(). + Attr("width", "1.0").Attr("height", "1.0") + componentSubgraph. + Attr("label", component.Name()). + Attr("cluster", "true"). + Attr("style", "rounded"). + Attr("color", "black"). + Attr("bgcolor", "lightgrey"). + Attr("margin", "20") + + return componentSubgraph +} + +// getComponentNodeCreate creates component node and returns it +func getComponentNode(componentSubgraph *dot.Graph, component *component.Component) *dot.Node { + componentNode := componentSubgraph.Node() + componentNode.Attr("label", "𝑓") + if component.Description() != "" { + componentNode.Attr("label", component.Description()) + } + componentNode. + Attr("color", "blue"). + Attr("shape", "rect"). + Attr("group", component.Name()) + return componentNode +} + +// getMainGraph creates and returns the main (root) graph +func getMainGraph(fm *fmesh.FMesh) *dot.Graph { + graph := dot.NewGraph(dot.Directed) + graph. + Attr("layout", "dot"). + Attr("splines", "ortho") + + if fm.Description() != "" { + addDescription(graph, fm.Description()) + } + + return graph +} + +func addDescription(graph *dot.Graph, description string) { + descriptionSubgraph := graph.NewSubgraph() + descriptionSubgraph. + Attr("label", "Description:"). + Attr("color", "green"). + Attr("fontcolor", "green"). + Attr("style", "dashed") + descriptionNode := descriptionSubgraph.Node() + descriptionNode. + Attr("shape", "plaintext"). + Attr("color", "green"). + Attr("fontcolor", "green"). + Attr("label", description) } +// getPortID returns unique ID used to locate ports while building pipe edges func getPortID(componentName string, portKind string, portName string) string { return fmt.Sprintf("component/%s/%s/%s", componentName, portKind, portName) } diff --git a/export/dot_test.go b/export/dot_test.go new file mode 100644 index 0000000..2f1a682 --- /dev/null +++ b/export/dot_test.go @@ -0,0 +1,76 @@ +package export + +import ( + "github.com/hovsep/fmesh" + "github.com/hovsep/fmesh/component" + "github.com/hovsep/fmesh/port" + "github.com/stretchr/testify/assert" + "testing" +) + +func Test_dotExporter_Export(t *testing.T) { + type args struct { + fm *fmesh.FMesh + } + tests := []struct { + name string + args args + assertions func(t *testing.T, data []byte, err error) + }{ + { + name: "empty f-mesh", + args: args{ + fm: fmesh.New("fm"), + }, + assertions: func(t *testing.T, data []byte, err error) { + assert.NoError(t, err) + assert.Empty(t, data) + }, + }, + { + name: "happy path", + args: args{ + fm: func() *fmesh.FMesh { + adder := component.New("adder"). + WithDescription("This component adds 2 numbers"). + WithInputs("num1", "num2"). + WithOutputs("result"). + WithActivationFunc(func(inputs port.Collection, outputs port.Collection) error { + //The activation func can be even empty, does not affect export + return nil + }) + + multiplier := component.New("multiplier"). + WithDescription("This component multiplies number by 3"). + WithInputs("num"). + WithOutputs("result"). + WithActivationFunc(func(inputs port.Collection, outputs port.Collection) error { + //The activation func can be even empty, does not affect export + return nil + }) + + adder.Outputs().ByName("result").PipeTo(multiplier.Inputs().ByName("num")) + + fm := fmesh.New("fm"). + WithDescription("This f-mesh has just one component"). + WithComponents(adder, multiplier) + return fm + }(), + }, + assertions: func(t *testing.T, data []byte, err error) { + assert.NoError(t, err) + assert.NotEmpty(t, data) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exporter := NewDotExporter() + + got, err := exporter.Export(tt.args.fm) + if tt.assertions != nil { + tt.assertions(t, got, err) + } + }) + } +} diff --git a/integration_tests/piping/fan_test.go b/integration_tests/piping/fan_test.go index 4ee34cd..b45fe19 100644 --- a/integration_tests/piping/fan_test.go +++ b/integration_tests/piping/fan_test.go @@ -1,11 +1,9 @@ package integration_tests import ( - "fmt" "github.com/hovsep/fmesh" "github.com/hovsep/fmesh/component" "github.com/hovsep/fmesh/cycle" - "github.com/hovsep/fmesh/export" "github.com/hovsep/fmesh/port" "github.com/hovsep/fmesh/signal" "github.com/stretchr/testify/assert" @@ -149,10 +147,6 @@ func Test_Fan(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { fm := tt.setupFM() - - exp := export.NewDotExporter() - fmt.Println(string(exp.Export(fm))) - tt.setInputs(fm) cycles, err := fm.Run() tt.assertions(t, fm, cycles, err)