diff --git a/changes/unreleased/calc-pins-compartment.changed.md b/changes/unreleased/calc-pins-compartment.changed.md new file mode 100644 index 0000000000..5337ba59bb --- /dev/null +++ b/changes/unreleased/calc-pins-compartment.changed.md @@ -0,0 +1 @@ +- The Mermaid, D2 and PlantUML action and mixed renderings list an action's or calculation's parameter pins that no edge reaches in a `parameters` compartment of the node (`in x`, `in y`, `out result`, typed when typings are shown), as the tree form lists them, instead of leaving them out and naming them in a `pin(s) not drawn` notice; the Jupyter kernel's `%viz A::add` Mermaid diagram therefore shows a calculation's parameters. diff --git a/docs/project/view-rendering-forms.md b/docs/project/view-rendering-forms.md index 8cd87ca0b6..3abc2c4b60 100644 --- a/docs/project/view-rendering-forms.md +++ b/docs/project/view-rendering-forms.md @@ -605,12 +605,14 @@ digraph "VehicleViews::vehicleView" { frame's pins are set on the cluster's border as a positioned node's are (`writePins` on the root cluster). The text form lists a node's pins under it by direction and name (`in bread`) and names the pins an edge joins as its ends (`heat.t => pack.t`, `ToastBread.bread == - heat.b`), labelling the edge by its own name alone as DOT does. Mermaid's flowchart draws the - pins an edge ends at (`n0_p0 ===|"bread = b"| n1_p0`) and names the rest in a `not - represented` notice, `N pin(s) not drawn (…); a flowchart draws the pins an edge ends at`. - PlantUML's state grammar, which an action rendering uses, has no pin: the edge keeps its label - naming the pins (`n0 -- n1 : bread = b`) and every pin is named in a `not represented` notice, - `N pin(s) not drawn (…); PlantUML's state grammar has no pin, so the edges name them`. + heat.b`), labelling the edge by its own name alone as DOT does. Mermaid's flowchart and D2 draw + the pins an edge ends at (`n0_p0 ===|"bread = b"| n1_p0`) and list the rest in a `parameters` + compartment of their node (`labeller.parameterLines`, the pins `Rendering.unconnectedPin` + names), one line per pin as the tree form writes it (`in x`, `out ^result`, typed under + `typings`). PlantUML's state grammar, which an action rendering uses, has no pin: the edge keeps + its label naming the pins (`n0 -- n1 : bread = b`) and every pin is listed in its node's + `parameters` compartment. The `N pin(s) not drawn` notice remains for a pin that is neither + drawn nor listed. - **Interconnection ports.** A part's node carries as its `Ports` the ports it has from its definition and what that specializes without declaring them itself (`featureWalk.pinPorts`, `Renderer.typedPorts` in `interconnection.go`: `Model.MembersOf` less the part's own members diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d5bbb3d3ae..ea0c04d9f7 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -853,11 +853,11 @@ binds its own parameters to its nodes' pins as the frame does. A binding one end a literal, an expression, an attribute — draws nothing. `dot` sets the frame's pins on the cluster's border as squares, the way it sets a node's; `text` lists each pin under its node (`in bread`) and names the pins an edge joins (`heat.t => pack.t`, -`ToastBread.bread == heat.b`); `mermaid`'s flowchart draws the pins an edge ends at, and names the -rest in a `%% not represented:` notice; `plantuml`'s state grammar has no pin, so it names the edge's -pins in the edge's label (`n0 -- n1 : bread = b`) and the pins in a `' not represented:` notice; `d2` -draws the pins an edge ends at as nodes nested in their owner, and names the rest in a -`# not represented:` notice. +`ToastBread.bread == heat.b`); `mermaid`'s flowchart draws the pins an edge ends at, and lists the +rest in a `parameters` compartment of their node (`parameters`, `in x`, `out result`); `plantuml`'s +state grammar has no pin, so it names the edge's pins in the edge's label (`n0 -- n1 : bread = b`) +and lists every pin in its node's `parameters` compartment; `d2` draws the pins an edge ends at as +nodes nested in their owner, and lists the rest in the `parameters` compartment. A tree rendering draws the directed parameters of a calculation, action or constraint definition or usage — `in`, `out`, `inout` and `return` — as parameter pins of their owner's node rather than as diff --git a/internal/frontend/jupyter/replengine_test.go b/internal/frontend/jupyter/replengine_test.go index 87aec6d370..d3bfac15f7 100644 --- a/internal/frontend/jupyter/replengine_test.go +++ b/internal/frontend/jupyter/replengine_test.go @@ -524,3 +524,23 @@ func TestARejectedCellDoesNotHoldBackTheNextOne(t *testing.T) { } answer("add(10 [m], 20 [mm])") } + +// %viz of a calculation shows its parameters in the Mermaid diagram the kernel +// bundles first: pins no edge reaches are listed in a parameters compartment. +func TestVizListsACalculationsParameters(t *testing.T) { + e := newEngine(t) + mustRun(t, e, "package A {\n private import ScalarValues::*;\n calc add {\n in x : Integer;\n in y : Integer;\n return : Integer;\n x + y\n }\n}\n") + out := mustRun(t, e, "%viz A::add") + if len(out.displays) != 1 { + t.Fatalf("%%viz displayed %d bundles, want 1", len(out.displays)) + } + mermaid, _ := out.displays[0][mimeMermaid].(string) + for _, want := range []string{"flowchart", "parameters\nin x\nin y\nout result"} { + if !strings.Contains(mermaid, want) { + t.Errorf("%%viz A::add Mermaid lacks %q:\n%s", want, mermaid) + } + } + if strings.Contains(mermaid, "pin(s) not drawn") { + t.Errorf("%%viz A::add Mermaid notices pins it lists:\n%s", mermaid) + } +} diff --git a/internal/ir/view/action_pins_test.go b/internal/ir/view/action_pins_test.go index 878d544d45..078964467c 100644 --- a/internal/ir/view/action_pins_test.go +++ b/internal/ir/view/action_pins_test.go @@ -241,8 +241,8 @@ func TestTextActionFramePins(t *testing.T) { } } -// The Mermaid form draws a frame pin an edge ends at as a node inside the -// frame's subgraph, as it draws the nodes' pins, and says which pins it leaves out. +// The Mermaid form draws a frame pin an edge ends at as a node inside the frame's +// subgraph, as it draws the nodes' pins, and lists the rest in a parameters compartment. func TestMermaidActionFramePins(t *testing.T) { rendering := renderActionPins(t, "Pins::ToastBread") source := rendering.MermaidWith(fullNotation) @@ -252,20 +252,29 @@ func TestMermaidActionFramePins(t *testing.T) { } } loose := renderActionPins(t, "Pins::Loose").MermaidWith(fullNotation) - if want := "%% not represented: 3 pin(s) not drawn (heat.t, pack.t, pack.boxed); a flowchart draws the pins an edge ends at"; !strings.Contains(loose, want) { - t.Errorf("Mermaid lacks the notice %q:\n%s", want, loose) + for _, want := range []string{"**heat : Heat**\nparameters\nout ^t`", "**pack : Pack**\nparameters\nin t\nout ^boxed`"} { + if !strings.Contains(loose, want) { + t.Errorf("Mermaid does not list the unconnected pins %q:\n%s", want, loose) + } + } + if strings.Contains(loose, "pin(s) not drawn") { + t.Errorf("Mermaid notices pins it lists:\n%s", loose) } } // PlantUML's state grammar has no pin: a binding is an undirected line between -// the frame and the node naming the pins, and the pins left undrawn are noticed. +// the frame and the node naming the pins, and every pin is listed in its node's +// `parameters` compartment. func TestPlantUMLActionFramePins(t *testing.T) { rendering := renderActionPins(t, "Pins::ToastBread") source, _ := rendering.PlantUMLWith(fullNotation) for _, want := range []string{"n0 -- n1 : bread = b\n", "n2 -- n0 : boxed = toast\n", - "' not represented: 6 pin(s) not drawn (Pins::ToastBread.bread, Pins::ToastBread.toast, heat.b, heat.t, pack.t, pack.boxed); PlantUML's state grammar has no pin, so the edges name them\n"} { + "**ToastBread**\\nparameters\\nin bread\\nout toast\" as n0", "**heat : Heat**\\nparameters\\nin b\\nout ^t\" as n1", "**pack : Pack**\\nparameters\\nin ^t\\nout ^boxed\" as n2"} { if !strings.Contains(source, want) { t.Errorf("PlantUML lacks %q:\n%s", want, source) } } + if strings.Contains(source, "pin(s) not drawn") { + t.Errorf("PlantUML notices pins it lists:\n%s", source) + } } diff --git a/internal/ir/view/calc_pins_test.go b/internal/ir/view/calc_pins_test.go index c6dc4af5bb..d4108c39af 100644 --- a/internal/ir/view/calc_pins_test.go +++ b/internal/ir/view/calc_pins_test.go @@ -112,3 +112,26 @@ func TestMixedRenderingKeepsNestedCalcsUnderTheirOwner(t *testing.T) { t.Errorf("default text does not name the nested calc under its owner:\n%s", plain) } } + +// A rendering showing no compartments lists no parameters compartment either: +// the pins a form does not draw go back to the not-drawn notice. +func TestHiddenCompartmentsListNoParameters(t *testing.T) { + rendering := renderSource(t, "Bare::flow", `package Model { + private import ScalarValues::*; + calc add { in x : Integer; in y : Integer; return : Integer; x + y } +} +package Bare { + private import StandardViewDefinitions::*; + rendering bare :> Views::asInterconnectionDiagram { + attribute showCompartments : ScalarValues::Boolean default = false; + } + view flow : ActionFlowView { expose Model::add; render bare; } +}`) + mermaid := rendering.Mermaid() + if strings.Contains(mermaid, "parameters") { + t.Errorf("Mermaid lists a parameters compartment the rendering hides:\n%s", mermaid) + } + if !strings.Contains(mermaid, "pin(s) not drawn") || !strings.Contains(mermaid, "add.result") { + t.Errorf("Mermaid does not notice the pins it neither draws nor lists:\n%s", mermaid) + } +} diff --git a/internal/ir/view/d2.go b/internal/ir/view/d2.go index 97ce3d7b53..b849ec3d5e 100644 --- a/internal/ir/view/d2.go +++ b/internal/ir/view/d2.go @@ -57,6 +57,7 @@ func (r *Rendering) D2With(options Options) (string, error) { } if r.Kind == KindAction { w.used = r.usedPorts(w.ports) + w.labels.listedPin = r.listedPin(func(node *Node, port Port) bool { return w.used[node.ID][port.ID] }) } for _, root := range r.Roots { w.fills.collect(root) @@ -119,7 +120,7 @@ func (r *Rendering) d2Notices(options Options, w *d2Writer) []string { notices = append(notices, linesNotice(options.Lines, FormD2)) } if r.Kind == KindAction { - if pins := r.undrawnPins(func(node *Node, port Port) bool { return w.used[node.ID][port.ID] }); len(pins) > 0 { + if pins := r.undrawnPins(func(node *Node, port Port) bool { return w.used[node.ID][port.ID] || w.labels.lists(node, port) }); len(pins) > 0 { notices = append(notices, fmt.Sprintf("%d pin(s) not drawn (%s); D2 draws the pins an edge ends at", len(pins), strings.Join(pins, ", "))) } diff --git a/internal/ir/view/d2_test.go b/internal/ir/view/d2_test.go index e89b58cb7d..0d9a50b84b 100644 --- a/internal/ir/view/d2_test.go +++ b/internal/ir/view/d2_test.go @@ -240,10 +240,8 @@ func TestD2StateDiagram(t *testing.T) { } } -// An action graph takes the pseudostate glyphs: a fork and a join a bar, a -// decision a diamond named by the node, the final a double-bordered dot; the -// pins an edge ends at are drawn inside their action and joined by path, the -// rest noticed. +// An action graph takes the pseudostate glyphs (fork/join bar, decision diamond, final +// dot); pins an edge ends at are drawn inside their action, the rest listed as parameters. func TestD2ActionGlyphsAndPins(t *testing.T) { d2, err := render(t, "action.sysml", "FlowViews::driveView").D2With(fullNotation) if err != nil { @@ -257,8 +255,7 @@ func TestD2ActionGlyphsAndPins(t *testing.T) { "\n bar: { shape: rectangle; width: 80; height: 8; style: { fill: black; stroke: black } }\n", "\n choice: { shape: diamond; style: { fill: white; stroke: \"#181818\"; stroke-width: 1; font-color: black; font-size: 14 } }\n", "# not represented: 2 fork/join name(s) (split, sync); D2's fork bar draws no label\n", - "# not represented: 1 pin(s) not drawn (provide.fuel); D2 draws the pins an edge ends at\n", - "\n n2: \"«action»\\nprovide : Provide\" {\n class: usage\n \"n2.1\": \"^torque\" { class: pin }\n }\n", + "\n n2: \"«action»\\nprovide : Provide\\nparameters\\nin ^fuel\" {\n class: usage\n \"n2.1\": \"^torque\" { class: pin }\n }\n", "\n \"n3.0\": \"reading\" { class: pin }\n", "\nn0.n2.\"n2.1\" -> n0.n3.\"n3.0\": \"torque to reading\" { class: edge }\n", } { diff --git a/internal/ir/view/label.go b/internal/ir/view/label.go index 10cf6dc129..255e677328 100644 --- a/internal/ir/view/label.go +++ b/internal/ir/view/label.go @@ -20,6 +20,9 @@ type labeller struct { // pinLines lists a node's parameter pins as label lines, for a form that // draws no pin on a tree node. pinLines bool + // listedPin reports a parameter pin the form lists in the node's `parameters` + // compartment instead of drawing it; nil lists none. + listedPin func(node *Node, port Port) bool } // labelsOf finds the namespace the named roots share — the longest run of @@ -369,18 +372,25 @@ func (l labeller) titleLines(node *Node) []string { return append(lines, l.compartmentLines(node)...) } -// parameterLines are a node's parameter pins as the notation's `parameters` -// compartment, `in x : Integer` a line under the heading, where pinLines asks -// for them; nothing for a node with no pin. +// lists reports whether the node's pin is listed in its `parameters` compartment: +// every pin where pinLines asks, else one listedPin names under shown compartments. +func (l labeller) lists(node *Node, port Port) bool { + return l.pinLines || l.shown.Has(ElementCompartments) && l.listedPin != nil && l.listedPin(node, port) +} + +// parameterLines are the node's listed parameter pins as the notation's +// `parameters` compartment, `in x : Integer` a line under the heading. func (l labeller) parameterLines(node *Node) []string { - if !l.pinLines || len(node.Ports) == 0 { - return nil - } - lines := []string{"parameters"} + var lines []string for _, port := range node.Ports { - lines = append(lines, parameterPinLine(port)) + if l.lists(node, port) { + lines = append(lines, parameterPinLine(port)) + } } - return lines + if len(lines) == 0 { + return nil + } + return append([]string{"parameters"}, lines...) } // cameoStateDetails splits a state's detail into Cameo's compartment lines, one diff --git a/internal/ir/view/mermaid.go b/internal/ir/view/mermaid.go index 82523cb4fb..472ce036b5 100644 --- a/internal/ir/view/mermaid.go +++ b/internal/ir/view/mermaid.go @@ -49,6 +49,8 @@ func (r *Rendering) MermaidWith(options Options) string { labels.pinLines = r.Kind == KindTree labels.skin = skinOf(options.Style) ports := r.portView(options.Ports) + used := r.usedPorts(ports) + labels.listedPin = r.listedPin(func(node *Node, port Port) bool { return used[node.ID][port.ID] }) r.writeMermaidFrontmatter(&b, labels, options, ports) if r.Run { fmt.Fprintf(&b, "%%%% run — %s rendering", r.Kind) @@ -74,7 +76,7 @@ func (r *Rendering) MermaidWith(options Options) string { fills.collect(root) } } - for _, notice := range r.mermaidNotices(options) { + for _, notice := range r.mermaidNotices(options, labels) { fmt.Fprintf(&b, "%%%% not represented: %s\n", notice) } r.writeGeometryComments(&b, "%%") @@ -103,7 +105,7 @@ func (r *Rendering) MermaidWith(options Options) string { return b.String() } -func (r *Rendering) mermaidNotices(options Options) []string { +func (r *Rendering) mermaidNotices(options Options, labels labeller) []string { var notices []string if err := options.Palette.check(); err != nil { notices = append(notices, err.Error()) @@ -116,7 +118,7 @@ func (r *Rendering) mermaidNotices(options Options) []string { if r.Kind == KindAction || r.Kind == KindMixed { used := r.usedPorts(r.portView(options.Ports)) if pins := r.undrawnPins(func(node *Node, port Port) bool { - return r.Kind == KindMixed && port.Direction == PortUndirected || used[node.ID][port.ID] + return r.Kind == KindMixed && port.Direction == PortUndirected || used[node.ID][port.ID] || labels.lists(node, port) }); len(pins) > 0 { notices = append(notices, fmt.Sprintf("%d pin(s) not drawn (%s); a flowchart draws the pins an edge ends at", len(pins), strings.Join(pins, ", "))) diff --git a/internal/ir/view/plantuml.go b/internal/ir/view/plantuml.go index 7c1d5f0f33..044b42b5b9 100644 --- a/internal/ir/view/plantuml.go +++ b/internal/ir/view/plantuml.go @@ -51,6 +51,7 @@ func (r *Rendering) PlantUMLWith(options Options) (string, error) { w := &plantumlWriter{kind: r.Kind, borders: r.Kind.paletteBorders(), fills: familyFills{palette: options.Palette, tree: r.Kind == KindTree}, labels: labelsOf(r.Roots, false, nil, r.Shown), ports: r.portView(options.Ports), links: options.Links, writtenPorts: make(map[string]struct{}), nesting: r.nestingEdges()} + w.labels.listedPin = r.listedPin(func(node *Node, port Port) bool { return r.Kind == KindMixed && drawsPort(w.ports.of(node), port) }) w.labels.pinLines = r.Kind == KindTree for _, root := range r.Roots { w.fills.collect(root) @@ -76,7 +77,7 @@ func (r *Rendering) PlantUMLWith(options Options) (string, error) { notices = append(notices, w.mixedContainmentNotices(r.Roots)...) } if r.Kind == KindAction { - if pins := r.undrawnPins(func(*Node, Port) bool { return false }); len(pins) > 0 { + if pins := r.undrawnPins(w.labels.lists); len(pins) > 0 { notices = append(notices, fmt.Sprintf("%d pin(s) not drawn (%s); PlantUML's state grammar has no pin, so the edges name them", len(pins), strings.Join(pins, ", "))) } diff --git a/internal/ir/view/plantuml_test.go b/internal/ir/view/plantuml_test.go index 3e3204746e..ac24179a64 100644 --- a/internal/ir/view/plantuml_test.go +++ b/internal/ir/view/plantuml_test.go @@ -259,7 +259,7 @@ func TestPlantUMLActionUsesStateGrammar(t *testing.T) { " state \"//«fork»//\\n**split**\" as n7 <>\n", " state \"**final**\" as n9 <>\n", " state \"//«decision»//\\n**check**\" as n11 <>\n", - " state \"//«action»//\\n**monitor**\\nown flow\" as n3 <> <> {\n", + " state \"//«action»//\\n**monitor**\\nown flow\\nparameters\\nin reading\" as n3 <> <> {\n", "\nn2 --> n3 : torque to reading\n", "\nn11 -[dashed]-> n9 : [speed 0]\n", "\nn1 -[dashed]-> n7\n", diff --git a/internal/ir/view/ports.go b/internal/ir/view/ports.go index b3369de15c..efb134c9c1 100644 --- a/internal/ir/view/ports.go +++ b/internal/ir/view/ports.go @@ -3,6 +3,7 @@ package view import ( "errors" "fmt" + "slices" "strings" ) @@ -166,3 +167,17 @@ func (r *Rendering) undrawnPins(drawn func(node *Node, port Port) bool) []string } return names } + +// listedPin lists, in an action or mixed rendering, the parameter pins a form +// does not draw in their node's `parameters` compartment; drawn says which it draws. +func (r *Rendering) listedPin(drawn func(node *Node, port Port) bool) func(node *Node, port Port) bool { + if r.Kind != KindAction && r.Kind != KindMixed { + return nil + } + return func(node *Node, port Port) bool { return port.Parameter && !drawn(node, port) } +} + +// drawsPort reports whether port is among the ports a form draws on a node. +func drawsPort(ports []Port, port Port) bool { + return slices.ContainsFunc(ports, func(drawn Port) bool { return drawn.ID == port.ID }) +} diff --git a/internal/ir/view/ports_test.go b/internal/ir/view/ports_test.go index 5c9e428891..94f037d522 100644 --- a/internal/ir/view/ports_test.go +++ b/internal/ir/view/ports_test.go @@ -326,6 +326,9 @@ func TestMixedActionPinsKeepTheirFormBehavior(t *testing.T) { if open < 0 || closeAt < 0 || !strings.Contains(plantUMLMinimal[start+open+1:start+open+1+closeAt], "port \"request\" as "+request.ID) { t.Errorf("minimal PlantUML does not write request inside Check's container:\n%s", plantUMLMinimal) } + if strings.Contains(plantUMLMinimal, "parameters") { + t.Errorf("minimal PlantUML lists the action pin it writes as a port:\n%s", plantUMLMinimal) + } if !strings.Contains(textMinimal, "in request") { t.Errorf("minimal text does not use the action pin direction:\n%s", textMinimal) } @@ -341,8 +344,8 @@ func TestMixedActionPinsKeepTheirFormBehavior(t *testing.T) { if strings.Contains(output, check.ID+"_p0[") || strings.Contains(output, `["request"]`) { t.Errorf("%s Mermaid draws Check's unconnected action pin, unlike an action rendering:\n%s", label, output) } - if !strings.Contains(output, "Check.request") || !strings.Contains(output, "pin(s) not drawn") { - t.Errorf("%s Mermaid does not report Check.request as an undrawn action pin:\n%s", label, output) + if !strings.Contains(output, "parameters\nin request") || strings.Contains(output, "pin(s) not drawn") { + t.Errorf("%s Mermaid does not list Check.request in a parameters compartment:\n%s", label, output) } } if strings.Contains(mermaidMinimal, "unused") { diff --git a/internal/ir/view/testdata/action.d2.golden b/internal/ir/view/testdata/action.d2.golden index 5d228d35f6..7dde481d11 100644 --- a/internal/ir/view/testdata/action.d2.golden +++ b/internal/ir/view/testdata/action.d2.golden @@ -1,5 +1,4 @@ # FlowViews::driveView — action rendering (render asInterconnectionDiagram, view def ActionFlowView) -# not represented: 1 pin(s) not drawn (provide.fuel); D2 draws the pins an edge ends at # not represented: 2 fork/join name(s) (split, sync); D2's fork bar draws no label classes: { definition: { style: { fill: white; stroke: "#181818"; stroke-width: 1; font-color: black; font-size: 14 } } @@ -15,7 +14,7 @@ classes: { n0: "«action def»\nDrive" { class: definition n1: "" { class: initial } - n2: "«action»\nprovide" { + n2: "«action»\nprovide\nparameters\nin ^fuel" { class: usage "n2.1": "^torque" { class: pin } } diff --git a/internal/ir/view/testdata/action.mermaid.golden b/internal/ir/view/testdata/action.mermaid.golden index d6f9e429e3..452164f7e4 100644 --- a/internal/ir/view/testdata/action.mermaid.golden +++ b/internal/ir/view/testdata/action.mermaid.golden @@ -26,12 +26,13 @@ config: --- %% FlowViews::driveView — action rendering (render asInterconnectionDiagram, view def ActionFlowView) %% not represented: 2 fork/join name(s) (split, sync); Mermaid's fork bar draws no label -%% not represented: 1 pin(s) not drawn (provide.fuel); a flowchart draws the pins an edge ends at flowchart TD subgraph n0 ["`*«action def»* **Drive**`"] direction TD n1@{ shape: f-circ, label: "" } - subgraph n2 ["`*«action»* **provide**`"] + subgraph n2 ["`*«action»* **provide** +parameters +in ^fuel`"] direction TD n2_anchor[" "] n2_p1["^torque"] diff --git a/internal/ir/view/testdata/action.plantuml.golden b/internal/ir/view/testdata/action.plantuml.golden index 1ae74a2bb0..5be0f7b334 100644 --- a/internal/ir/view/testdata/action.plantuml.golden +++ b/internal/ir/view/testdata/action.plantuml.golden @@ -1,6 +1,5 @@ @startuml ' FlowViews::driveView — action rendering (render asInterconnectionDiagram, view def ActionFlowView) -' not represented: 3 pin(s) not drawn (provide.fuel, provide.torque, monitor.reading); PlantUML's state grammar has no pin, so the edges name them