From 5acee18f3b4c945d1d7c77ae5eb2ce2159e13f6c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 05:07:00 +0000 Subject: [PATCH 1/4] fix(migrate): embed DocGen table images, default viewpoint-less views, place anchored paragraphs An Image step drawing a Cameo table diagram now writes a Table over the table definition's own row query instead of a Diagram of the view rendered asElementTable; a definition without a query form is refused where the figure would be. A view conforming to no viewpoint follows MDK's DocumentGenerator.parseView default: its documentation, then the figure or table of each exposed diagram in order. Collaborator paragraphs following nothing open the section before the method's content, and paragraphs anchored to a DiagramMainImage follow the figure the section drew for that diagram, the report saying why when the anchor cannot be placed. Co-Authored-By: jason.han --- .github/workflows/pr.yml | 2 +- ...table-figures-and-paragraph-order.fixed.md | 21 + docs/reference/sysml-v1-migration.md | 20 +- internal/translate/migrate/documents.go | 464 ++++++++++++++---- internal/translate/migrate/names.go | 8 +- .../paragraph_placement_internal_test.go | 57 +++ internal/translate/migrate/tables.go | 14 +- internal/translate/xmi/sysmlv1/docgen.go | 40 +- internal/translate/xmi/sysmlv1/docgen_test.go | 73 +++ tests/migrate/documents_test.go | 7 +- tests/migrate/table_figures_test.go | 248 ++++++++++ .../testdata/xmi/documents.golden.sysml | 2 +- tests/migrate/testdata/xmi/table_figures.xmi | 200 ++++++++ 13 files changed, 1058 insertions(+), 98 deletions(-) create mode 100644 changes/unreleased/docgen-table-figures-and-paragraph-order.fixed.md create mode 100644 internal/translate/migrate/paragraph_placement_internal_test.go create mode 100644 tests/migrate/table_figures_test.go create mode 100644 tests/migrate/testdata/xmi/table_figures.xmi diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c5a372af4b..2d6b42c42a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -739,7 +739,7 @@ jobs: OPENSYSML_KATEX: ${{ github.workspace }}/build/doc-pdf/katex/node_modules/.bin/katex OPENSYSML_DOT: ${{ github.workspace }}/build/doc-pdf/graphviz/bin/dot OPENSYSML_PLANTUML_JAR: ${{ github.workspace }}/build/doc-pdf/plantuml/plantuml-1.2026.8.jar - run: go test -count=1 -v -run Installed ./internal/doc/docpdf + run: go test -count=1 -v -run Installed ./internal/doc/docpdf ./tests/migrate vscode-extension: name: VS Code extension diff --git a/changes/unreleased/docgen-table-figures-and-paragraph-order.fixed.md b/changes/unreleased/docgen-table-figures-and-paragraph-order.fixed.md new file mode 100644 index 0000000000..84cfa34160 --- /dev/null +++ b/changes/unreleased/docgen-table-figures-and-paragraph-order.fixed.md @@ -0,0 +1,21 @@ +- **A migrated DocGen `Image` of a Cameo table embeds the table, not a listing of its view.** An + `Image` step (or a viewpoint-less view) drawing an instance table, generic table, matrix or + relation map diagram wrote a `Diagram` of the view rendered `asElementTable`, which a document + drew as an Element / Kind / Type / Declared-in dump of the view's members. The section now + holds a `Table` over the same `… Rows` query the table's standalone document uses — written + once, beside the view — captioned by the step's title, numbered among the tables, and the + report row says which query it is over. A table whose definition has no query form is refused + where the figure would be, with the reason, instead of drawn as the listing. +- **A DocGen view conforming to no viewpoint shows what MDK's default behavior shows.** Such a + view wrote its documentation alone. It now follows `DocumentGenerator.parseView`: a view that + is itself a diagram shows its own figure; any other shows, after its documentation, each + diagram it exposes in order — a plain diagram as a figure, a table diagram as its table — + and nothing for an exposed element that is not a diagram, the report row naming what it drew + and what it left out. +- **Collaborator paragraphs stand where their anchors put them.** Every collaborator paragraph + followed the section's generated content. A paragraph with no `siblingId`/`parentId` now + precedes it, as Cameo prints it; one anchored to a generated figure + (`Containment_DiagramMainImage__`) follows the figure, table or refusal the section drew + for that diagram, with its followers after it — after the section's only figure when the anchor + names no diagram of the model — and one whose anchor cannot be placed (another anchor kind, a + tag naming nothing, an ambiguous section) follows the content with the reason in its row. diff --git a/docs/reference/sysml-v1-migration.md b/docs/reference/sysml-v1-migration.md index 90b30a6607..d176c5fb7a 100644 --- a/docs/reference/sysml-v1-migration.md +++ b/docs/reference/sysml-v1-migration.md @@ -560,7 +560,10 @@ whole number, an instance table naming no classifier, a matrix with no filter, a whose XML does not parse — with every fault stated at once. A refused table is an `unmapped` report row and a `not migrated` comment beside its view, which is still written; the rest of the model is unaffected. Presentation settings (`displayMode`, `showScopeAsRoot`, colors, widths, -legend, `rowsOrder`…) draw the table and are dropped without a report row. +legend, `rowsOrder`…) draw the table and are dropped without a report row. A [DocGen +document](#docgen-documents) whose step draws the table's diagram embeds the same `Table` over +the same `… Rows` query — the query is written once, beside the view — so the section and the +standalone document render the same columns and cells. ### DocGen documents @@ -575,8 +578,15 @@ DocGen prints it, with the view's own documentation as a `Paragraph` — the sam `view` carries as `doc`, tool HTML reduced to text — before its method's content, unless that comment is shown by one of the view's collaborator paragraphs, in which case it is written once, in that paragraph's place; a collaborator paragraph that cannot be shown (a malformed application) is -refused as usual and does not hide the documentation. The tree is the one -DocGen walks: every property of a view typed by a view is a section, and a view is entered +refused as usual and does not hide the documentation. A view conforming to no viewpoint gets +DocGen's default behavior, that of MDK's +[`DocumentGenerator.parseView`](https://github.com/Open-MBEE/mdk/blob/develop/src/main/java/org/openmbee/mdk/generator/DocumentGenerator.java) +when the view has no viewpoint or method: a view that is itself a diagram shows its own figure, and any other shows, +after its documentation, each diagram it exposes or imports in that order — an `Image` of a +plain diagram, the `Table` of a table diagram — and nothing for an exposed element that is not +a diagram; the view's report row says the default applied and what it showed. A view whose +«Conform» names a viewpoint keeps its method's refusal when that method is malformed. The tree +is the one DocGen walks: every property of a view typed by a view is a section, and a view is entered for its own sections only through a composite or shared property — a plain reference places the view as a section without its children, and the «Expose» dependencies of a property feed its view only when the property is composite. @@ -630,10 +640,10 @@ section, in the activity's order: | `CollectionAndFilterGroup`, `StructuredQuery` | the group's chain, inlined | | `TableStructure` with `TableAttributeColumn` (`Name`, `Documentation`), `TablePropertyColumn` (a value property of the rows' definition, or a requirement's `Id`/`Text`), `TableExpressionColumn` naming a bare query property | `part table : Table { attribute redefines caption = …; calc rows : …; }` over `Project(properties, columns = (Column(…)))`, the built-in properties first (a built-in column behind a value property is moved ahead of it with the note) and a value property captioned like a built-in property as ` 2`; a requirement's `Id` is its `shortName` and its `Text` its `documentation`, where the migration writes them; `includeDoc` adds `documentation`; a `MonteCarloAnalysis` statistic column (`N`, `Mean`, `Deviation`, `OutOfSpec`) reads the statistic the row's nested analysis records, as `Column(name = "N", expression = 'Monte Carlo'.runs)` and a sort on it as `OrderBy(property = "'Monte Carlo'.runs")`, when an instance the table lists records it — otherwise the column is omitted with the note saying so; a column beyond these — a property of a used project — is omitted with the note saying which, and a table with no writable column is refused. The caption is the table's title (`titles`, between `titlePrefix` and `titleSuffix`), and its `captions` text follows the table as a `Paragraph` unless `showCaptions` is false | | `BulletedList(orderedList, includeDoc)` | `part list : List { attribute redefines style = "number" / "bullet"; calc items : …; }`; `includeDoc` follows each item's name with its documentation | -| `Paragraph(body)`; a «CollaboratorParagraph» reading the comment body | `part paragraph : Paragraph { attribute redefines text = "…"; }`, tool HTML reduced to text; a paragraph over the targets' documentation is `calc values : …` over `Project(properties = ("documentation"))` | +| `Paragraph(body)`; a «CollaboratorParagraph» reading the comment body | `part paragraph : Paragraph { attribute redefines text = "…"; }`, tool HTML reduced to text; a paragraph over the targets' documentation is `calc values : …` over `Project(properties = ("documentation"))`. A collaborator paragraph stands where its `siblingId` (else `parentId`) tag puts it: one naming another paragraph of the view follows that paragraph; one with no tag comes, as Cameo prints it, before the content the method generates; one naming the generated figure of a diagram, `Containment_DiagramMainImage__`, follows the figure or table the section drew for that diagram, or the refusal standing where it would have been. An anchor of that form naming no diagram of the model (Collaborator writes publish-time ids) is placed after the section's only figure when it draws exactly one and no other anchor is as unresolved, the row saying so; otherwise, and for an anchor of another kind (`Containment___…`) or a tag naming nothing, the paragraph follows the generated content and its row names the anchor and why | | a «CollaboratorImageParagraph» — a comment stereotyped MagicDraw «AttachedFile», or one carrying an `` | `part 'image N' : Image { attribute redefines location = "images/"; attribute redefines caption = ""; attribute redefines alt = ""; }`, and the attached bytes are written beside the notation under `images/`, as the base of the file name with the suffix the bytes' content type calls for — `figure.txt` holding PNG bytes is `images/figure.png` (an `http(s)` source names the URL instead and writes no file; the comment body is the caption and an empty one is allowed). The attachment is found in the archive by the `ATTACHED_FILE` extension's stream id, then the `file` tag name or an entry with that base name; an image no archive entry holds keeps its caption as a paragraph, noted, and a captionless one is **unmapped** — the note names the file. A server-relative `src` (a path the View Editor serves) resolves against `-image-base-url`; without it the paragraph keeps its text with the same note saying so. Writing the files requires `-o`; `images/` beside the model is the migration's, so a re-run replaces the files it wrote before as it replaces the model, and a file of another name there is left alone — a run never writes over the model it is writing, the input, or its `-migration-report`/`-migration-results` files | | an `Image` step over a diagram that draws nothing, whose note (the diagram's own comment) holds an `` | `part image : Image { attribute redefines location = ; attribute redefines caption = ; attribute redefines alt = ; }` instead of leaving the figure out — approximated, since layout and free symbols drop; a note that says more than the title follows as the caption paragraph; the note's image not in the archive and not resolved against `-image-base-url` leaves the figure out with the same hint | -| `Image` | one `part diagram : Diagram { attribute redefines caption = ""; ref redefines source = <its view>; }` per diagram the chain collected (see below), captioned by its `titles` entry (else the diagram's name) between `titlePrefix` and `titleSuffix`, its `captions` entry following as a `Paragraph` unless `showCaptions` is false. A diagram written as a graph view — an activity diagram as an `ActionFlowView`, a state machine diagram as a `StateTransitionView` — is drawn like any other; one whose view renders as textual notation (a sequence diagram, whose Interaction is written as a scenario and not as the occurrence parts a `SequenceView` draws; an activity or state machine diagram whose behavior is not written as a definition) is refused with the reason, since a document draws no text view. A diagram that shows nothing — its tool lists no element and its stream draws nothing, or free symbols only (a diagram of pasted pictures draws them, so its figure is written) — would be an empty figure, so no `Diagram` is written for it: the step is reported mapped (approximated when the archive cannot tell what it shows) with the reason, and its caption stays as a paragraph, as DocGen shows it. An `Image` whose chain holds no diagram is mapped as drawing nothing, the note saying what the chain held instead | +| `Image` | one `part diagram : Diagram { attribute redefines caption = "<title>"; ref redefines source = <its view>; }` per diagram the chain collected (see below), captioned by its `titles` entry (else the diagram's name) between `titlePrefix` and `titleSuffix`, its `captions` entry following as a `Paragraph` unless `showCaptions` is false. A diagram that is a Cameo [table, matrix or relation map](#tables-matrices-and-relation-maps) is shown as DocGen shows it, as the table: `part table : Table { attribute redefines caption = "<title>"; calc rows : <its '… Rows' query>; }` over the query its definition already lowered, written once beside the view, never as a `Diagram` of the view rendered `asElementTable` (which a document would draw as a listing of the view's members); the step's row says the diagram is written as a Table over that query, and `-doc-number-figures` counts it among the tables. A table whose definition is refused (no query form) is refused in its place, the reason given, rather than drawn as that listing. A diagram written as a graph view — an activity diagram as an `ActionFlowView`, a state machine diagram as a `StateTransitionView` — is drawn like any other; one whose view renders as textual notation (a sequence diagram, whose Interaction is written as a scenario and not as the occurrence parts a `SequenceView` draws; an activity or state machine diagram whose behavior is not written as a definition) is refused with the reason, since a document draws no text view. A diagram that shows nothing — its tool lists no element and its stream draws nothing, or free symbols only (a diagram of pasted pictures draws them, so its figure is written) — would be an empty figure, so no `Diagram` is written for it: the step is reported mapped (approximated when the archive cannot tell what it shows) with the reason, and its caption stays as a paragraph, as DocGen shows it. An `Image` whose chain holds no diagram is mapped as drawing nothing, the note saying what the chain held instead | | `Dynamic View` | a nested `Section` with the called activity's title, lowered the same way; an activity that calls itself is refused, since a recursive section has no static spelling | The diagrams among the collected elements are no query's rows — a migrated diagram is a view — diff --git a/internal/translate/migrate/documents.go b/internal/translate/migrate/documents.go index 50a85ec82c..189a669334 100644 --- a/internal/translate/migrate/documents.go +++ b/internal/translate/migrate/documents.go @@ -62,6 +62,35 @@ type sectionPlan struct { names columnNames // refused says why the method produced nothing, when it was refused whole. refused string + // figures marks where the section drew, left out or refused the figure of + // each diagram, for the paragraphs anchored to them. + figures []figureMark +} + +// figureMark is the index in a section's content after everything the +// figure of the diagram d produced. +type figureMark struct { + d *sysmlv1.Diagram + at int +} + +// mark records that the figure of d ends at the current end of the content. +func (sec *sectionPlan) mark(d *sysmlv1.Diagram) { + sec.figures = append(sec.figures, figureMark{d: d, at: len(sec.content)}) +} + +// insert puts blocks into the content at index at, moving the figure marks +// beyond it along. +func (sec *sectionPlan) insert(at int, blocks ...*contentPlan) { + if len(blocks) == 0 { + return + } + sec.content = slices.Insert(sec.content, at, blocks...) + for i := range sec.figures { + if sec.figures[i].at >= at { + sec.figures[i].at += len(blocks) + } + } } // contentPlan is one content block a presentation node produces, or the @@ -91,6 +120,15 @@ type contentPlan struct { source *view anchor *anchor section *sectionPlan + // diagram is the diagram a figure stands for, drawn, left out or refused, + // so a paragraph anchored to the figure finds its place. + diagram *sysmlv1.Diagram + // table is the table definition an embedded Table shares its row query + // with; the query is written beside the definition's view, not the document. + table *tableDoc + // app is the application the block is reported under; the node's DocGen + // application when nil. + app *sysmlv1.Stereotype notes []string refused string // target is the block's qualified name once written, for its report row. @@ -190,17 +228,26 @@ func claimed(sec *sectionPlan, into columnNames) { } } -// planSection opens a section with its view's documentation, lowers the view's -// method into the section's content, then plans its child views as sections -// after the content, in declaration order. +// planSection opens a section with its view's documentation and the +// collaborator paragraphs that follow nothing, lowers the view's method into +// the section's content, places the paragraphs anchored to that content, then +// plans its child views as sections after the content, in declaration order. func (m *migration) planSection(dp *docPlan, sec *sectionPlan) { v := sec.v dp.notes = append(dp.notes, v.Malformed...) m.viewDocumentation(sec) - m.planMethod(dp, sec) - for _, p := range v.Paragraphs { - sec.content = append(sec.content, m.collaboratorParagraph(sec, p)) + var anchored [][]*sysmlv1.DocGenParagraph + for _, g := range paragraphGroups(v.Paragraphs) { + if g[0].Predecessor != "" { + anchored = append(anchored, g) + continue + } + for _, p := range g { + sec.content = append(sec.content, m.collaboratorParagraph(sec, p)) + } } + m.planMethod(dp, sec) + m.placeParagraphs(sec, anchored) for _, child := range v.Children { title := strings.TrimSpace(child.Class.Name) if title == "" { @@ -213,15 +260,137 @@ func (m *migration) planSection(dp *docPlan, sec *sectionPlan) { } } +// paragraphGroups splits a view's paragraphs, which the reader ordered, into +// runs of a paragraph that follows no other paragraph and the ones following it. +func paragraphGroups(paragraphs []*sysmlv1.DocGenParagraph) [][]*sysmlv1.DocGenParagraph { + var groups [][]*sysmlv1.DocGenParagraph + for _, p := range paragraphs { + if p.Placed && len(groups) > 0 { + groups[len(groups)-1] = append(groups[len(groups)-1], p) + continue + } + groups = append(groups, []*sysmlv1.DocGenParagraph{p}) + } + return groups +} + +// placeParagraphs puts each run of anchored paragraphs after the figure its +// head's anchor names, where the section drew, left out or refused it; a run +// whose anchor finds no figure follows the section's content. The head notes +// any placement its anchor did not decide alone. +func (m *migration) placeParagraphs(sec *sectionPlan, groups [][]*sysmlv1.DocGenParagraph) { + unresolved := map[string]bool{} + for _, g := range groups { + a := g[0].Anchor + if a == nil || a.Kind != sysmlv1.DiagramMainImage || m.model.Diagram(a.Target) != nil { + continue + } + if in, _ := m.findFigure(sec, a.Target); in == nil { + unresolved[a.Target] = true + } + } + for _, g := range groups { + blocks := make([]*contentPlan, len(g)) + for i, p := range g { + blocks[i] = m.collaboratorParagraph(sec, p) + } + in, at, note := m.anchorPlace(sec, g[0], len(unresolved)) + if note != "" { + blocks[0].notes = append(blocks[0].notes, note) + } + if in == nil { + sec.content = append(sec.content, blocks...) + continue + } + in.insert(at, blocks...) + } +} + +// anchorPlace finds where the paragraph p's anchor puts it: the section and +// index after the figure of the diagram the anchor names, deduced to be the +// section's only figure when the anchor names no diagram of the model and no +// other anchor is as unresolved, which the note says. A nil section says the +// paragraph follows the content instead, and why. +func (m *migration) anchorPlace(sec *sectionPlan, p *sysmlv1.DocGenParagraph, unresolved int) (in *sectionPlan, at int, note string) { + tag := strconv.Quote(p.Predecessor) + follows := ", so the paragraph follows the section's generated content" + a := p.Anchor + switch { + case a == nil: + return nil, 0, "its predecessor tag " + tag + " names neither another paragraph of the view nor a figure" + follows + case a.Kind != sysmlv1.DiagramMainImage: + return nil, 0, "its anchor " + tag + " names an item of the kind " + a.Kind + ", which the migration does not place" + follows + } + if in, f := m.findFigure(sec, a.Target); in != nil { + return in, f.at, "" + } + if d := m.model.Diagram(a.Target); d != nil { + return nil, 0, "its anchor names the figure of the " + diagramKind(d) + " '" + d.Name + "', which the section's method does not draw" + follows + } + figures := m.figures(sec) + switch { + case len(figures) == 0: + return nil, 0, "its anchor " + tag + " names no diagram of the model, and the section draws no figure" + follows + case len(figures) > 1: + return nil, 0, "its anchor " + tag + " names no diagram of the model, and the section draws " + strconv.Itoa(len(figures)) + " figures" + follows + case unresolved > 1: + return nil, 0, "its anchor " + tag + " names no diagram of the model, as do " + strconv.Itoa(unresolved-1) + " other anchors in the section" + follows + } + in, f := figures[0].in, figures[0].mark + return in, f.at, "its anchor " + tag + " names no diagram of the model; the paragraph is placed after the section's only figure, of the " + diagramKind(f.d) + " '" + f.d.Name + "'" +} + +// placedFigure is a figure mark with the section it is in. +type placedFigure struct { + in *sectionPlan + mark figureMark +} + +// figures lists the figure marks of sec and the sections nested in its content. +func (m *migration) figures(sec *sectionPlan) []placedFigure { + var out []placedFigure + for _, f := range sec.figures { + out = append(out, placedFigure{sec, f}) + } + for _, cp := range sec.content { + if cp.section != nil { + out = append(out, m.figures(cp.section)...) + } + } + return out +} + +// findFigure finds the figure mark of the diagram with id in sec or a section +// nested in its content; nil when none. +func (m *migration) findFigure(sec *sectionPlan, id string) (*sectionPlan, *figureMark) { + for i := range sec.figures { + if sec.figures[i].d.ID == id { + return sec, &sec.figures[i] + } + } + for _, cp := range sec.content { + if cp.section != nil { + if in, f := m.findFigure(cp.section, id); in != nil { + return in, f + } + } + } + return nil, nil +} + // planMethod walks the activity chain of the view's viewpoint method into -// content blocks; a view without a method contributes only its structure, -// unless its viewpoint's method tag names something that is not one. +// content blocks. A view conforming to no viewpoint gets DocGen's default +// behavior instead; one whose viewpoint's method tag names something that is +// not a method is refused. func (m *migration) planMethod(dp *docPlan, sec *sectionPlan) { v := sec.v if v.Method == nil { - if v.MethodMalformed != "" { + switch { + case v.MethodMalformed != "": sec.refused = "the viewpoint " + qualifiedName(v.Viewpoint) + "'s method is not migrated: " + v.MethodMalformed m.report.Entries = append(m.report.Entries, *m.nodeEntry(v.Viewpoint, v.Viewpoint.Stereotype("Viewpoint"), Unmapped, sec.refused)) + case v.Viewpoint == nil: + m.defaultView(dp, sec) } return } @@ -236,6 +405,61 @@ func (m *migration) planMethod(dp *docPlan, sec *sectionPlan) { c.run(steps) } +// defaultView applies what DocGen does for a view conforming to no viewpoint +// (MDK's DocumentGenerator.parseView): a view that is itself a diagram shows +// its own figure; any other shows, after its documentation, the figure of +// each diagram it exposes in order — a table for a table diagram — and +// nothing for an exposed element that is not a diagram. +func (m *migration) defaultView(dp *docPlan, sec *sectionPlan) { + v := sec.v + origin := "the view " + qualifiedName(v.Class) + " conforms to no viewpoint, so DocGen's default behavior applies" + f := figureOf{node: v.Class, app: viewApplication(v.Class), label: "«View» " + v.Class.Type, origin: origin} + if d := m.model.Diagram(v.Class.ID); d != nil { + f.title = strings.TrimSpace(d.Name) + m.figure(dp, sec, f, d) + m.note(v.Class, origin+": the view is the "+diagramKind(d)+" '"+d.Name+"', so it shows its own figure") + return + } + var drew, skipped []string + for _, ref := range v.Exposed { + if d := m.model.Diagram(ref.ID); d != nil { + f.title = strings.TrimSpace(d.Name) + m.figure(dp, sec, f, d) + drew = append(drew, "the "+diagramKind(d)+" '"+d.Name+"'") + continue + } + if ref.Element == nil { + skipped = append(skipped, "the id "+strconv.Quote(ref.ID)+", which resolves to no element") + continue + } + skipped = append(skipped, "the "+kindOf(ref.Element)+" "+qualifiedName(ref.Element)) + } + var shows []string + if len(drew) > 0 { + shows = append(shows, "after the view's documentation it shows "+strings.Join(drew, ", ")) + } + switch n := len(skipped); { + case n == 1: + shows = append(shows, "it shows nothing for "+skipped[0]+", which is exposed but is not a diagram") + case n == 2: + shows = append(shows, "it shows nothing for "+skipped[0]+" and "+skipped[1]+", which are exposed but are not diagrams") + case n > 2: + shows = append(shows, fmt.Sprintf("it shows nothing for the %d exposed elements that are not diagrams (%s, %s and %d more)", n, skipped[0], skipped[1], n-2)) + } + if len(shows) > 0 { + m.note(v.Class, origin+": "+strings.Join(shows, "; ")) + } +} + +// viewApplication is the View application on a view class, of SysML or of +// the DocGen profile, under which what the view itself shows is reported. +func viewApplication(class *sysmlv1.Element) *sysmlv1.Stereotype { + if s := class.Stereotype("View"); s != nil { + return s + } + return class.DocGen() +} + // viewDocumentation opens the section with the view's own documentation, unless a // well-formed collaborator paragraph shows that comment (its body is never empty). func (m *migration) viewDocumentation(sec *sectionPlan) { @@ -1831,9 +2055,7 @@ func (c *chain) captionText(s *sysmlv1.DocGenStep, i int) string { // captionParagraph plans the Paragraph holding a block's caption, which a // document prints under the block; note says whose caption it is. func (c *chain) captionParagraph(s *sysmlv1.DocGenStep, note, text string) { - cp := &contentPlan{kind: "Paragraph", node: s.Node, label: "«" + c.kind(s) + "» " + s.Node.Type, text: text, origin: note} - cp.name = c.sec.names.claim("paragraph") - c.sec.content = append(c.sec.content, cp) + c.m.captionParagraph(c.sec, figureOf{node: s.Node, app: s.Application, label: "«" + c.kind(s) + "» " + s.Node.Type}, note, text) } // block plans a query-backed block: its query name is reserved in the @@ -2031,10 +2253,10 @@ func (c *chain) paragraph(s *sysmlv1.DocGenStep) { c.block(s, "Paragraph", c.caption(s, "Paragraph"), qcall("Project", qarg1("source", c.ctx), qstrs("properties", prop))) } -// image lowers an Image: one Diagram block per diagram among the current -// elements, showing its migrated view, captioned by the diagram's title and -// followed by its caption paragraph when DocGen shows captions. A chain whose -// diagrams are not all known draws none: a partial set would pass for the whole. +// image lowers an Image: one figure per diagram among the current elements, +// captioned by the diagram's title and followed by its caption paragraph when +// DocGen shows captions. A chain whose diagrams are not all known draws none: +// a partial set would pass for the whole. func (c *chain) image(s *sysmlv1.DocGenStep) { if c.broken != "" { c.refuse(s, "the diagrams it shows pass through "+c.broken) @@ -2050,92 +2272,166 @@ func (c *chain) image(s *sysmlv1.DocGenStep) { } titles := s.Application.Tags["titles"] for i, d := range c.diagrams { - v := c.m.viewOf[d] - if v == nil || !v.placed { - c.refuse(s, "the Diagram '"+d.Name+"' is not written as a view") - continue - } - form, why := c.m.form(d) - if form.rendering == textualRendering { - c.refuse(s, joinNotes("the "+diagramKind(d)+" '"+d.Name+"' is a view rendered as textual notation, which a document does not draw", why)) - continue + title := strings.TrimSpace(d.Name) + if i < len(titles) && strings.TrimSpace(titles[i]) != "" { + title = strings.TrimSpace(titles[i]) } - if empty := c.m.emptyView(v, form); empty != "" { - if c.noteImage(s, i, d, titles) { - continue - } - note := "no Diagram shows the " + diagramKind(d) + " '" + d.Name + "': " + empty + ", so the figure would be empty and is left out" - verdict := Approximated - if d.Drawn && len(d.Shown) == 0 && len(d.Free) == 0 { - verdict = Mapped - } - if src, _, _ := firstImg(d.Documentation); src != "" && serverImagePath(src) && c.m.imageBase == nil { - note += "; the note's image " + strconv.Quote(src) + " is served by the View Editor; pass -image-base-url to show it" - } - if text := c.captionText(s, i); text != "" { - note += "; its caption stands alone" - c.captionParagraph(s, "the paragraph is the caption of the figure left out for the diagram '"+d.Name+"'", text) - } - c.m.report.Entries = append(c.m.report.Entries, *c.m.nodeEntry(s.Node, s.Application, verdict, note)) - continue + f := figureOf{node: s.Node, app: s.Application, label: "«" + c.kind(s) + "» " + s.Node.Type, title: c.title(s, title), text: c.captionText(s, i)} + c.m.figure(c.dp, c.sec, f, d) + } +} + +// figureOf is what a section asks for when it draws a diagram: the node +// asking and the application it is reported under, the title DocGen prints +// as the caption, the caption paragraph under it, and origin, said of what is +// written when the figure is not a step's. +type figureOf struct { + node *sysmlv1.Element + app *sysmlv1.Stereotype + label string + title string + text string + origin string +} + +// figure plans what a section shows for the diagram d: the Table of its +// table definition when that is written (and a refusal, never a listing of +// the view's elements, when it is not), else a Diagram of its view, else the +// Image its note carries, else nothing when the view would be empty. The +// caption paragraph follows, and the place is marked for the paragraphs +// anchored to the figure. +func (m *migration) figure(dp *docPlan, sec *sectionPlan, f figureOf, d *sysmlv1.Diagram) { + defer sec.mark(d) + v := m.viewOf[d] + if v == nil || !v.placed { + m.refuseFigure(sec, f, d, "the Diagram '"+d.Name+"' is not written as a view") + return + } + if len(v.tables) > 0 { + for _, td := range v.tables { + m.tableFigure(sec, f, td) } - def, _, why := c.m.viewSteps(v) - if why != "" { - c.refuse(s, why) - continue + return + } + form, why := m.form(d) + if form.rendering == textualRendering { + m.refuseFigure(sec, f, d, joinNotes("the "+diagramKind(d)+" '"+d.Name+"' is a view rendered as textual notation, which a document does not draw", why)) + return + } + if empty := m.emptyView(v, form); empty != "" { + if m.noteImage(sec, f, d) { + return } - cp := &contentPlan{kind: "Diagram", node: s.Node, label: "«Image» " + s.Node.Type, source: v} - if def != nil { - cp.anchor = c.dp.anchor(def) + note := "no Diagram shows the " + diagramKind(d) + " '" + d.Name + "': " + empty + ", so the figure would be empty and is left out" + verdict := Approximated + if d.Drawn && len(d.Shown) == 0 && len(d.Free) == 0 { + verdict = Mapped } - title := strings.TrimSpace(d.Name) - if i < len(titles) && strings.TrimSpace(titles[i]) != "" { - title = strings.TrimSpace(titles[i]) + if src, _, _ := firstImg(d.Documentation); src != "" && serverImagePath(src) && m.imageBase == nil { + note += "; the note's image " + strconv.Quote(src) + " is served by the View Editor; pass -image-base-url to show it" } - cp.caption = c.title(s, title) - cp.name = c.sec.names.claim("diagram") - c.sec.content = append(c.sec.content, cp) - if text := c.captionText(s, i); text != "" { - c.captionParagraph(s, "the paragraph is the Diagram's caption", text) + if f.text != "" { + note += "; its caption stands alone" + m.captionParagraph(sec, f, "the paragraph is the caption of the figure left out for the diagram '"+d.Name+"'", f.text) } + m.report.Entries = append(m.report.Entries, *m.nodeEntry(f.node, f.app, verdict, joinNotes(f.origin, note))) + return + } + def, _, why := m.viewSteps(v) + if why != "" { + m.refuseFigure(sec, f, d, why) + return + } + cp := m.figureBlock(sec, f, "Diagram", d) + cp.source = v + if def != nil { + cp.anchor = dp.anchor(def) + } + cp.caption = f.title + sec.content = append(sec.content, cp) + if f.text != "" { + m.captionParagraph(sec, f, "the paragraph is the Diagram's caption", f.text) + } +} + +// figureBlock is a block of the given kind standing for the diagram d, named +// after its kind in the section. +func (m *migration) figureBlock(sec *sectionPlan, f figureOf, kind string, d *sysmlv1.Diagram) *contentPlan { + cp := &contentPlan{kind: kind, node: f.node, app: f.app, label: f.label, origin: f.origin, diagram: d} + cp.name = sec.names.claim(strings.ToLower(kind)) + return cp +} + +// tableFigure plans the Table a section shows for a diagram with a table +// definition: the rows are the definition's own query, written once beside +// its view, so the standalone Document and the section share them. A +// definition with no query form is refused: a listing of the view's +// elements would not be the table. +func (m *migration) tableFigure(sec *sectionPlan, f figureOf, td *tableDoc) { + m.lowerTable(td) + d := td.t.Diagram + kind := "the «" + string(td.t.Kind) + "» '" + d.Name + "'" + if !td.written() { + m.refuseFigure(sec, f, d, joinNotes(kind+" has no query form, so the table is left out rather than shown as a listing of its view's elements: "+td.l.refused, strings.Join(td.l.notes, "; "))) + return + } + cp := m.figureBlock(sec, f, "Table", d) + cp.caption = f.title + cp.table, cp.query, cp.rows = td, td.query, td.l.rows + cp.notes = append(cp.notes, td.l.notes...) + sec.content = append(sec.content, cp) + if f.text != "" { + m.captionParagraph(sec, f, "the paragraph is the Table's caption", f.text) } } +// refuseFigure stands a comment in the section for the figure of d that is +// not drawn, and reports why. +func (m *migration) refuseFigure(sec *sectionPlan, f figureOf, d *sysmlv1.Diagram, why string) { + why = joinNotes(f.origin, why) + cp := &contentPlan{kind: "Diagram", node: f.node, app: f.app, label: f.label, diagram: d, refused: why} + sec.content = append(sec.content, cp) + m.report.Entries = append(m.report.Entries, *m.nodeEntry(f.node, f.app, Unmapped, why)) +} + +// captionParagraph plans the Paragraph holding a figure's caption, which a +// document prints under the figure; note says whose caption it is. +func (m *migration) captionParagraph(sec *sectionPlan, f figureOf, note, text string) { + cp := &contentPlan{kind: "Paragraph", node: f.node, app: f.app, label: f.label, text: text, origin: joinNotes(f.origin, note)} + cp.name = sec.names.claim("paragraph") + sec.content = append(sec.content, cp) +} + // noteImage plans a figure's Image block when the empty diagram's note holds // an <img> whose source resolves in the archive or against the base URL: its // title is the caption and the img's alt the alt text, and a note saying more // than the title follows as the caption paragraph. -func (c *chain) noteImage(s *sysmlv1.DocGenStep, i int, d *sysmlv1.Diagram, titles []string) bool { +func (m *migration) noteImage(sec *sectionPlan, f figureOf, d *sysmlv1.Diagram) bool { src, alt, _ := firstImg(d.Documentation) if src == "" { return false } - location, _ := c.m.imageFile(src, nil) + location, _ := m.imageFile(src, nil) if location == "" { - location, _ = c.m.imageLocation(src) + location, _ = m.imageLocation(src) } if location == "" { return false } - title := strings.TrimSpace(d.Name) - if i < len(titles) && strings.TrimSpace(titles[i]) != "" { - title = strings.TrimSpace(titles[i]) - } - cp := &contentPlan{kind: "Image", node: s.Node, label: "«Image» " + s.Node.Type, - location: location, caption: c.title(s, title), alt: alt} + cp := m.figureBlock(sec, f, "Image", d) + cp.location, cp.caption, cp.alt = location, f.title, alt if cp.alt == "" { cp.alt = cp.caption } - cp.name = c.sec.names.claim("image") - c.sec.content = append(c.sec.content, cp) + sec.content = append(sec.content, cp) if text := commentText(d.Documentation); text != "" && !captionCovers(cp.caption, text) { - c.captionParagraph(s, "the paragraph is the note the figure's image carries", text) + m.captionParagraph(sec, f, "the paragraph is the note the figure's image carries", text) } - if text := c.captionText(s, i); text != "" { - c.captionParagraph(s, "the paragraph is the Diagram's caption", text) + if f.text != "" { + m.captionParagraph(sec, f, "the paragraph is the Diagram's caption", f.text) } - c.m.report.Entries = append(c.m.report.Entries, - *c.m.nodeEntry(s.Node, s.Application, Approximated, "the figure shows the image the diagram's note carries, "+location)) + m.report.Entries = append(m.report.Entries, + *m.nodeEntry(f.node, f.app, Approximated, joinNotes(f.origin, "the figure shows the image the diagram's note carries, "+location))) return true } @@ -2237,7 +2533,7 @@ func (m *migration) writeDocument(dp *docPlan) { // writeQueries writes the row queries of every query-backed block under sec. func (m *migration) writeQueries(sec *sectionPlan, prefix string) { for _, cp := range m.blocks(sec) { - if cp.query != "" && cp.refused == "" { + if cp.query != "" && cp.refused == "" && cp.table == nil { m.writeQueryDef(cp.query, prefix, cp.rows) } } @@ -2334,10 +2630,11 @@ func (m *migration) writeBlock(dp *docPlan, cp *contentPlan, path string) []stri } }) case "Table": - m.blockPart(dp.host, cp.name, "Table", nil, func() { - m.w.line("attribute redefines caption = " + stringLiteral(cp.caption) + ";") - m.w.line("calc rows : " + m.siblingRef(dp.host, cp.query) + ";") - }) + rows := m.siblingRef(dp.host, cp.query) + if cp.table != nil { + rows = m.synthesizedRef(cp.table.v.host, cp.query, dp.host) + } + m.tablePart(dp.host, cp.name, cp.caption, rows) case "List": m.blockPart(dp.host, cp.name, "List", nil, func() { m.w.line("attribute redefines style = " + stringLiteral(cp.style) + ";") @@ -2428,13 +2725,16 @@ func (m *migration) blockEntry(cp *contentPlan) *Entry { if len(cp.notes) > 0 { verdict = Approximated } - var app *sysmlv1.Stereotype - if cp.node != nil { + app := cp.app + if app == nil && cp.node != nil { app = cp.node.DocGen() } e := m.nodeEntry(cp.node, app, verdict, strings.Join(cp.notes, "; ")) e.Target = "part " + cp.target - if cp.query != "" { + switch { + case cp.table != nil: + e.Note = joinNotes("the "+diagramKind(cp.table.t.Diagram)+" '"+cp.table.t.Diagram.Name+"' is written as a Table over the query "+writeName(cp.query)+" of its «"+string(cp.table.t.Kind)+"»", e.Note) + case cp.query != "": e.Note = joinNotes("its rows are the query "+writeName(cp.query), e.Note) } if cp.origin != "" { diff --git a/internal/translate/migrate/names.go b/internal/translate/migrate/names.go index 66602fef8a..5ea657c9c8 100644 --- a/internal/translate/migrate/names.go +++ b/internal/translate/migrate/names.go @@ -245,7 +245,13 @@ func (m *migration) hidden(name string) bool { // siblingRef writes a reference to a synthesized declaration named name that // is written beside host's members, from inside whatever is being written. func (m *migration) siblingRef(host *sysmlv1.Element, name string) string { - return m.refMember(host, name, append(m.path(host), segment{name: name}), host, false) + return m.synthesizedRef(host, name, host) +} + +// synthesizedRef writes a reference from inside scope's body to a synthesized +// declaration named name that is written beside host's members. +func (m *migration) synthesizedRef(host *sysmlv1.Element, name string, scope *sysmlv1.Element) string { + return m.refMember(host, name, append(m.path(host), segment{name: name}), scope, false) } // ref writes a reference to target from inside scope's body (nil for the top diff --git a/internal/translate/migrate/paragraph_placement_internal_test.go b/internal/translate/migrate/paragraph_placement_internal_test.go new file mode 100644 index 0000000000..e393a2ac26 --- /dev/null +++ b/internal/translate/migrate/paragraph_placement_internal_test.go @@ -0,0 +1,57 @@ +package migrate + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/translate/xmi/sysmlv1" +) + +func TestSectionInsertKeepsFigureMarks(t *testing.T) { + d1, d2 := &sysmlv1.Diagram{ID: "d1"}, &sysmlv1.Diagram{ID: "d2"} + sec := §ionPlan{} + sec.content = append(sec.content, &contentPlan{kind: "Paragraph", text: "head"}) + sec.content = append(sec.content, &contentPlan{kind: "Diagram"}) + sec.mark(d1) + sec.content = append(sec.content, &contentPlan{kind: "Paragraph", text: "caption"}) + sec.content = append(sec.content, &contentPlan{kind: "Table"}) + sec.mark(d2) + + sec.insert(sec.figures[0].at, &contentPlan{kind: "Paragraph", text: "after d1"}, &contentPlan{kind: "Paragraph", text: "follower"}) + sec.insert(sec.figures[1].at, &contentPlan{kind: "Paragraph", text: "after d2"}) + + var got []string + for _, cp := range sec.content { + if cp.kind == "Paragraph" { + got = append(got, cp.text) + } else { + got = append(got, cp.kind) + } + } + want := []string{"head", "Diagram", "after d1", "follower", "caption", "Table", "after d2"} + if len(got) != len(want) { + t.Fatalf("content = %q, want %q", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("content = %q, want %q", got, want) + } + } + if sec.figures[0].at != 4 || sec.figures[1].at != 7 { + t.Errorf("marks after insertion = %d, %d; want 4, 7", sec.figures[0].at, sec.figures[1].at) + } +} + +func TestParagraphGroupsRunFromEachHead(t *testing.T) { + head1 := &sysmlv1.DocGenParagraph{} + follower1 := &sysmlv1.DocGenParagraph{Placed: true, Predecessor: "head1"} + follower2 := &sysmlv1.DocGenParagraph{Placed: true, Predecessor: "follower1"} + head2 := &sysmlv1.DocGenParagraph{Predecessor: "Containment_DiagramMainImage__d1", Anchor: &sysmlv1.DocGenAnchor{Kind: sysmlv1.DiagramMainImage, Target: "d1"}} + follower3 := &sysmlv1.DocGenParagraph{Placed: true, Predecessor: "head2"} + groups := paragraphGroups([]*sysmlv1.DocGenParagraph{head1, follower1, follower2, head2, follower3}) + if len(groups) != 2 || len(groups[0]) != 3 || len(groups[1]) != 2 { + t.Fatalf("groups = %v, want [3 2]", groups) + } + if groups[0][0] != head1 || groups[0][2] != follower2 || groups[1][0] != head2 || groups[1][1] != follower3 { + t.Errorf("groups do not run from each head in order: %v", groups) + } +} diff --git a/internal/translate/migrate/tables.go b/internal/translate/migrate/tables.go index b0fffc3f78..915f0f0fd2 100644 --- a/internal/translate/migrate/tables.go +++ b/internal/translate/migrate/tables.go @@ -107,6 +107,15 @@ func (m *migration) unplacedTables() { } } +// tablePart writes a DocumentQueries::Table part named name under host, +// captioned caption, whose rows the query rows (a reference) computes. +func (m *migration) tablePart(host *sysmlv1.Element, name, caption, rows string) { + m.blockPart(host, name, "Table", nil, func() { + m.w.line("attribute redefines caption = " + stringLiteral(caption) + ";") + m.w.line("calc rows : " + rows + ";") + }) +} + // writeTable writes a table definition as a query and a Document holding one // Table over it, or as a comment when the definition has no query form. func (m *migration) writeTable(td *tableDoc) { @@ -123,10 +132,7 @@ func (m *migration) writeTable(td *tableDoc) { m.inside(blockNames("Document", columnNames{"rows": true}), func() { m.w.block("part def "+writeName(td.doc)+" :> "+m.queryPrefix(host)+"Document", func() { m.w.line("attribute redefines title = " + stringLiteral(td.title) + ";") - m.blockPart(host, "rows", "Table", nil, func() { - m.w.line("attribute redefines caption = " + stringLiteral(td.title) + ";") - m.w.line("calc rows : " + m.siblingRef(host, td.query) + ";") - }) + m.tablePart(host, "rows", td.title, m.siblingRef(host, td.query)) }) }) note := "the «" + kind + "» is written as a Document holding a Table over the query " + writeName(td.query) diff --git a/internal/translate/xmi/sysmlv1/docgen.go b/internal/translate/xmi/sysmlv1/docgen.go index d1da2c60a2..3ac96f6352 100644 --- a/internal/translate/xmi/sysmlv1/docgen.go +++ b/internal/translate/xmi/sysmlv1/docgen.go @@ -3,6 +3,7 @@ package sysmlv1 import ( "fmt" "strconv" + "strings" ) // DocGenDocument is one MDK DocGen document: a class carrying the Document @@ -52,11 +53,46 @@ type DocGenParagraph struct { Comment *Element // Malformed is why the paragraph cannot be shown, "" when it can. Malformed string + // Predecessor is the siblingId or parentId tag as written, "" when the + // paragraph names nothing it follows. + Predecessor string + // Anchor is the generated item Predecessor names, nil when it names a + // paragraph (see Placed) or nothing readable. + Anchor *DocGenAnchor // Placed reports whether the predecessor tag named a paragraph of the // same view, which this one then follows. Placed bool } +// DocGenAnchor is an item of the published document a collaborator paragraph +// follows that is no paragraph; the publisher writes it `<view>_<Kind>__<Target>`, +// as `Containment_DiagramMainImage__<id>` for the main image of a diagram. +type DocGenAnchor struct { + // Kind is the item's kind, DiagramMainImage for the figure of a diagram. + Kind string + // Target is the id the publisher gave the item. + Target string +} + +// DiagramMainImage is the anchor kind naming the figure a section draws of a diagram. +const DiagramMainImage = "DiagramMainImage" + +// parseAnchor reads a predecessor tag of the generated-item form; nil when the +// tag has no `<Kind>__<Target>` shape and so can only name a paragraph. +func parseAnchor(predecessor string) *DocGenAnchor { + head, target, ok := strings.Cut(predecessor, "__") + if !ok || head == "" || target == "" || head[0] == '_' { + return nil + } + if i := strings.LastIndexByte(head, '_'); i >= 0 { + head = head[i+1:] + } + if head == "" { + return nil + } + return &DocGenAnchor{Kind: head, Target: target} +} + // DocGenStep is one node of a DocGen activity chain: a collect, filter or // sort step, a presentation node, a group, or a join of parallel branches. type DocGenStep struct { @@ -323,8 +359,10 @@ func (r *docGenReader) paragraphs(class *Element) []*DocGenParagraph { followers := map[*DocGenParagraph][]*DocGenParagraph{} var heads []*DocGenParagraph for _, p := range out { - after := byComment[predecessor(p.Application)] + p.Predecessor = predecessor(p.Application) + after := byComment[p.Predecessor] if after == nil || after == p { + p.Anchor = parseAnchor(p.Predecessor) heads = append(heads, p) continue } diff --git a/internal/translate/xmi/sysmlv1/docgen_test.go b/internal/translate/xmi/sysmlv1/docgen_test.go index 6ccd3d91e3..e2c8ecfab1 100644 --- a/internal/translate/xmi/sysmlv1/docgen_test.go +++ b/internal/translate/xmi/sysmlv1/docgen_test.go @@ -333,3 +333,76 @@ func TestDocGenViewTreeEntersACompositeAfterAReference(t *testing.T) { t.Errorf("%d stray paragraphs, want none", len(m.StrayParagraphs)) } } + +// A paragraph's predecessor tag is kept as written: one naming a paragraph of +// the view places it as a follower; one of the publisher's generated-item form +// is read into its anchor kind and target; any other heads the order unanchored. +func TestDocGenParagraphAnchors(t *testing.T) { + m, err := Parse([]byte(`<?xml version="1.0"?> +<xmi:XMI xmi:version="2.5.1" xmlns:xmi="http://www.omg.org/spec/XMI/20131001" xmlns:uml="http://www.omg.org/spec/UML/20161101" + xmlns:sysml="http://www.omg.org/spec/SysML/20181001/SysML" + xmlns:Document_Profile_="http://www.magicdraw.com/schemas/manual/Document_Profile.xmi" + xmlns:Document_View_Collaborator_Profile="http://www.magicdraw.com/schemas/manual/Document_View_Collaborator_Profile.xmi"> + <uml:Model xmi:id="_m" name="M"> + <packagedElement xmi:type="uml:Class" xmi:id="_doc" name="Doc"> + <ownedAttribute xmi:type="uml:Property" xmi:id="_p_sec" name="sec" type="_view_sec" aggregation="composite"/> + </packagedElement> + <packagedElement xmi:type="uml:Class" xmi:id="_view_sec" name="Sec"> + <ownedComment xmi:type="uml:Comment" xmi:id="_c_intro" body="intro"/> + <ownedComment xmi:type="uml:Comment" xmi:id="_c_after" body="after the figure"/> + <ownedComment xmi:type="uml:Comment" xmi:id="_c_next" body="next"/> + <ownedComment xmi:type="uml:Comment" xmi:id="_c_other" body="after an unknown item"/> + <ownedComment xmi:type="uml:Comment" xmi:id="_c_gone" body="after a gone paragraph"/> + </packagedElement> + </uml:Model> + <Document_Profile_:Document xmi:id="_st_doc" base_Class="_doc"/> + <sysml:View xmi:id="_st_sec" base_Class="_view_sec"/> + <Document_View_Collaborator_Profile:CollaboratorParagraph xmi:id="_st_intro" base_Element="_c_intro" documentId="mms-1" viewId="_doc" ownerId="_view_sec"/> + <Document_View_Collaborator_Profile:CollaboratorParagraph xmi:id="_st_after" base_Element="_c_after" documentId="mms-1" viewId="_doc" ownerId="_view_sec" siblingId="Containment_DiagramMainImage__d1"/> + <Document_View_Collaborator_Profile:CollaboratorParagraph xmi:id="_st_next" base_Element="_c_next" documentId="mms-1" viewId="_doc" ownerId="_view_sec" siblingId="_c_after"/> + <Document_View_Collaborator_Profile:CollaboratorParagraph xmi:id="_st_other" base_Element="_c_other" documentId="mms-1" viewId="_doc" ownerId="_view_sec" siblingId="Containment_TableMainImage__t1"/> + <Document_View_Collaborator_Profile:CollaboratorParagraph xmi:id="_st_gone" base_Element="_c_gone" documentId="mms-1" viewId="_doc" ownerId="_view_sec" siblingId="_2022x_1_gone"/> +</xmi:XMI>`)) + if err != nil { + t.Fatal(err) + } + sec := m.Documents[0].Root.Children[0] + got := map[string]*DocGenParagraph{} + for _, p := range sec.Paragraphs { + got[p.Comment.ID] = p + } + if len(got) != 5 { + t.Fatalf("%d paragraphs, want 5", len(got)) + } + cases := []struct { + id, predecessor string + anchor *DocGenAnchor + placed bool + }{ + {"_c_intro", "", nil, false}, + {"_c_after", "Containment_DiagramMainImage__d1", &DocGenAnchor{Kind: DiagramMainImage, Target: "d1"}, false}, + {"_c_next", "_c_after", nil, true}, + {"_c_other", "Containment_TableMainImage__t1", &DocGenAnchor{Kind: "TableMainImage", Target: "t1"}, false}, + {"_c_gone", "_2022x_1_gone", nil, false}, + } + for _, c := range cases { + p := got[c.id] + if p.Predecessor != c.predecessor { + t.Errorf("%s: Predecessor = %q, want %q", c.id, p.Predecessor, c.predecessor) + } + if p.Placed != c.placed { + t.Errorf("%s: Placed = %v, want %v", c.id, p.Placed, c.placed) + } + switch { + case c.anchor == nil && p.Anchor != nil: + t.Errorf("%s: Anchor = %+v, want none", c.id, *p.Anchor) + case c.anchor != nil && (p.Anchor == nil || *p.Anchor != *c.anchor): + t.Errorf("%s: Anchor = %+v, want %+v", c.id, p.Anchor, *c.anchor) + } + } + for i, p := range sec.Paragraphs { + if p.Comment.ID == "_c_next" && sec.Paragraphs[i-1].Comment.ID != "_c_after" { + t.Errorf("_c_next follows %s, want _c_after", sec.Paragraphs[i-1].Comment.ID) + } + } +} diff --git a/tests/migrate/documents_test.go b/tests/migrate/documents_test.go index 55b821e1e3..a88878f455 100644 --- a/tests/migrate/documents_test.go +++ b/tests/migrate/documents_test.go @@ -435,7 +435,8 @@ func TestMigratedDocumentsRender(t *testing.T) { // A view's own documentation opens its section, before what its method // produces, as DocGen prints it: at every depth, tool HTML reduced to text, // once when the same comment is also one of the view's collaborator paragraphs, -// and not at all for a view that has none. +// and not at all for a view that has none. A collaborator paragraph following +// nothing comes right after it, before the method's content, refused or not. func TestViewDocumentationOpensItsSection(t *testing.T) { r := migrateFixtureFile(t, "documents") notation := string(r.Notation) @@ -458,10 +459,10 @@ func TestViewDocumentationOpensItsSection(t *testing.T) { `attribute redefines title = "Introduction";`, "part paragraph : DocumentQueries::Paragraph {", `attribute redefines text = "The fleet, in brief.";`, + `/* not migrated: «Paragraph» Comment '<Comment>' — property "META:QPROP:Element:name" is not the comment body */`, `attribute redefines caption = "Fleet Parts";`, "part 'paragraph 2' : DocumentQueries::Paragraph {", - `attribute redefines text = "The parts of the fleet, by name.";`, - `/* not migrated: «Paragraph» Comment '<Comment>' — property "META:QPROP:Element:name" is not the comment body */`) + `attribute redefines text = "The parts of the fleet, by name.";`) if es := entriesFor(r, "_st_intro_named"); len(es) != 1 || es[0].Verdict != migrate.Unmapped { t.Errorf("a malformed collaborator over the view's documentation should be refused, and only refused: %+v", es) } diff --git a/tests/migrate/table_figures_test.go b/tests/migrate/table_figures_test.go new file mode 100644 index 0000000000..313a794aaa --- /dev/null +++ b/tests/migrate/table_figures_test.go @@ -0,0 +1,248 @@ +package migrate_test + +import ( + stderrors "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/doc/docpdf" + "github.com/Open-MBEE/OpenSysML/internal/doc/docrender" + "github.com/Open-MBEE/OpenSysML/internal/translate/migrate" +) + +// plantReport is the document the table_figures fixture migrates, whose views +// draw a Cameo table through an «Image», conform to no viewpoint, and carry +// collaborator paragraphs anchored to generated figures. +const plantReport = "'Plant Documents'::'Plant Report Document'" + +// elementListing is the header of the generic member listing a view rendered +// asElementTable draws; a document embedding a Cameo table never shows it. +const elementListing = "Declared in" + +func plantReportResult(t *testing.T) *migrate.Result { + t.Helper() + r := migrateFixtureFile(t, "table_figures") + for _, d := range errors(t, "table_figures.sysml", r.Notation) { + t.Errorf("%v", d) + } + return r +} + +func TestImageOfTableEmbedsTheTable(t *testing.T) { + r := plantReportResult(t) + notation := string(r.Notation) + sec := notationSection(notation, "Inventory") + if sec == "" { + t.Fatalf("no Inventory section written:\n%s", notation) + } + wantInOrder(t, "Inventory section", sec, + "part table : DocumentQueries::Table {", + `attribute redefines caption = "Pump Inventory";`, + "calc rows : Plant::Inventory::'Pump Table Rows';") + if strings.Contains(sec, "DocumentQueries::Diagram") { + t.Errorf("the Image of the table draws the view as a Diagram:\n%s", sec) + } + if n := strings.Count(notation, "calc def 'Pump Table Rows'"); n != 1 { + t.Errorf("the table's query is written %d times, want once:\n%s", n, notation) + } + wantInOrder(t, "standalone table document", notation, + "part def 'Pump Table Document' :> DocumentQueries::Document {", + "part rows : DocumentQueries::Table {", + `attribute redefines caption = "Pump Table";`, + "calc rows : 'Pump Table Rows';") + wantOneNote(t, r, "_st_titled_image", migrate.Mapped, "the SysML Instance Table 'Pump Table' is written as a Table over the query 'Pump Table Rows' of its «InstanceTable»") + wantOneNote(t, r, "_st_titled_image", migrate.Mapped, "the paragraph is the Table's caption") +} + +func TestImageOfRefusedTableIsRefused(t *testing.T) { + r := plantReportResult(t) + sec := notationSection(string(r.Notation), "Spares") + if sec == "" { + t.Fatalf("no Spares section written:\n%s", r.Notation) + } + wantInOrder(t, "Spares section", sec, + `attribute redefines text = "Spare parts of the plant.";`, + "/* not migrated: «Image» CallBehaviorAction 'Image' — the «DiagramTable» 'Spare Parts' has no query form, so the table is left out rather than shown as a listing of its view's elements: the table names no scope and no rows */", + `attribute redefines text = "Where the table would be.";`) + for _, kind := range []string{"DocumentQueries::Diagram", "DocumentQueries::Table"} { + if strings.Contains(sec, kind) { + t.Errorf("the refused table is drawn as a %s:\n%s", kind, sec) + } + } + wantOneNote(t, r, "_st_plain_image", migrate.Unmapped, "has no query form, so the table is left out rather than shown as a listing of its view's elements") +} + +func TestViewpointLessViewShowsExposedDiagrams(t *testing.T) { + r := plantReportResult(t) + notation := string(r.Notation) + sec := notationSection(notation, "Overview") + if sec == "" { + t.Fatalf("no Overview section written:\n%s", notation) + } + wantInOrder(t, "Overview section", sec, + `attribute redefines text = "The plant at a glance.";`, + "part table : DocumentQueries::Table {", + `attribute redefines caption = "Pump Table";`, + "calc rows : Plant::Inventory::'Pump Table Rows';", + "part diagram : DocumentQueries::Diagram {", + `attribute redefines caption = "Pump Structure";`, + "ref redefines source = Plant::Structure::'Pump Structure';") + if strings.Contains(sec, "Moves fluid through the plant.") { + t.Errorf("the exposed block's documentation is shown, which DocGen does not do:\n%s", sec) + } + if n := strings.Count(sec, "DocumentQueries::Paragraph"); n != 1 { + t.Errorf("Overview writes %d paragraphs, want its documentation alone:\n%s", n, sec) + } + details := notationSection(notation, "Details") + if details == "" || strings.Contains(details, "DocumentQueries::Paragraph") || strings.Contains(details, "DocumentQueries::Diagram") || strings.Contains(details, "DocumentQueries::Table") || strings.Contains(details, "not migrated") { + t.Errorf("the child view conforming to a viewpoint that draws nothing is not an empty heading:\n%s", details) + } + wantOneNote(t, r, "_view_overview", migrate.Mapped, "the view Plant Documents::Overview conforms to no viewpoint, so DocGen's default behavior applies: after the view's documentation it shows the SysML Instance Table 'Pump Table', the SysML Block Definition Diagram 'Pump Structure'; it shows nothing for the «Block» Class Plant::Structure::Pump, which is exposed but is not a diagram") + wantOneNote(t, r, "_st_view_overview", migrate.Mapped, "the SysML Instance Table 'Pump Table' is written as a Table over the query 'Pump Table Rows' of its «InstanceTable»") +} + +func TestCollaboratorParagraphsFollowTheirAnchors(t *testing.T) { + r := plantReportResult(t) + notation := string(r.Notation) + wantInOrder(t, "Pumping section", notationSection(notation, "Pumping"), + `attribute redefines text = "How pumping works.";`, + `attribute redefines text = "The following figure shows the pump.";`, + "part diagram : DocumentQueries::Diagram {", + `attribute redefines text = "The pump moves fluid.";`, + `attribute redefines text = "It never runs dry.";`, + `attribute redefines text = "The pump table is not drawn here.";`, + `attribute redefines text = "Table anchors are not placed.";`) + wantOneNote(t, r, "_st_pump_undrawn", migrate.Approximated, "its anchor names the figure of the SysML Instance Table 'Pump Table', which the section's method does not draw, so the paragraph follows the section's generated content") + wantOneNote(t, r, "_st_pump_table", migrate.Approximated, `its anchor "Containment_TableMainImage___diag_pumps" names an item of the kind TableMainImage, which the migration does not place, so the paragraph follows the section's generated content`) + for _, id := range []string{"_st_pump_head", "_st_pump_after", "_st_pump_follow"} { + for _, e := range entriesFor(r, id) { + if e.Verdict != migrate.Mapped || e.Note != "" { + t.Errorf("%s: %+v, want Mapped without a note", id, e) + } + } + } + + wantInOrder(t, "Inventory section", notationSection(notation, "Inventory"), + `attribute redefines text = "Pumps on hand.";`, + `attribute redefines text = "Every pump on hand.";`, + "part table : DocumentQueries::Table {", + `attribute redefines text = "Every pump the plant holds.";`, + `attribute redefines text = "Read across each row.";`) + wantOneNote(t, r, "_st_inv_note", migrate.Approximated, `its anchor "Containment_DiagramMainImage__7c1e4b" names no diagram of the model; the paragraph is placed after the section's only figure, of the SysML Instance Table 'Pump Table'`) + + wantInOrder(t, "Spares section", notationSection(notation, "Spares"), + "/* not migrated: «Image» CallBehaviorAction 'Image'", + `attribute redefines text = "Where the table would be.";`) + for _, e := range entriesFor(r, "_st_spares_note") { + if e.Verdict != migrate.Mapped || e.Note != "" { + t.Errorf("_st_spares_note: %+v, want Mapped without a note", e) + } + } +} + +func TestEmbeddedTablesRenderHTML(t *testing.T) { + r := plantReportResult(t) + s := session(t, r) + page, err := s.RenderDocumentHTML(plantReport, docrender.HTMLOptions{Fragment: true, NumberSections: true, NumberFigures: true}) + if err != nil { + t.Fatalf("render as HTML: %v", err) + } + if strings.Contains(page, elementListing) { + t.Errorf("the HTML lists the table view's elements:\n%s", page) + } + wantInOrder(t, "HTML", page, + "Inventory", + "Pumps on hand.", "Every pump on hand.", + `<table class="sysml-table" data-content="table" data-name="table" data-query="Plant::Inventory::Pump Table Rows">`, + "Table 1.", "Pump Inventory", + `<th scope="col" data-column="name">name</th>`, `data-column="mass">mass</th>`, `data-column="flow">flow</th>`, + ">p1</span>", ">12.5</span>", ">3</span>", + ">p2</span>", ">9</span>", + "Every pump the plant holds.", "Read across each row.", + "Spares", "Spare parts of the plant.", "Where the table would be.", + "Overview", "The plant at a glance.", + "Table 2.", "Pump Table", + "Figure 1.", "Pump Structure", + "Details", + "Pumping", "How pumping works.", "The following figure shows the pump.", + "Figure 2.", "Pump Structure", + "The pump moves fluid.", "It never runs dry.") + if strings.Contains(page, "Table 3.") || strings.Contains(page, "Figure 3.") { + t.Errorf("tables and figures are not numbered apart:\n%s", page) + } +} + +// TestEmbeddedTablesRenderInstalledPDF renders the fixture through the +// installed WeasyPrint, as the PDF toolchain job runs it; without the +// toolchain it skips unless OPENSYSML_REQUIRE_PDF_TOOLCHAIN is set. +func TestEmbeddedTablesRenderInstalledPDF(t *testing.T) { + const engine = "weasyprint" + converter, err := docpdf.EngineNamed(engine) + if err != nil { + t.Fatal(err) + } + if err := converter.Available(); err != nil { + var docErr *docpdf.Error + if !stderrors.As(err, &docErr) || docErr.Kind != docpdf.ErrorToolMissing { + t.Fatal(err) + } + skipWithoutTool(t, engine, err) + } + pdftotext, err := exec.LookPath("pdftotext") + if err != nil { + skipWithoutTool(t, "pdftotext", err) + } + r := plantReportResult(t) + s := session(t, r) + document, err := s.EvaluateDocument(plantReport) + if err != nil { + t.Fatalf("evaluate %s: %v", plantReport, err) + } + pdf, err := docpdf.Render(document, engine, docpdf.Options{NumberSections: true, NumberFigures: true}) + if err != nil { + t.Fatalf("Render: %v", err) + } + if !strings.HasPrefix(string(pdf), "%PDF-") { + t.Fatalf("output is no PDF: %.16q", pdf) + } + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "doc.pdf"), pdf, 0o600); err != nil { + t.Fatal(err) + } + out, err := exec.Command(pdftotext, "-layout", filepath.Join(dir, "doc.pdf"), "-").Output() // #nosec G204 -- pdftotext from PATH, fixed arguments + if err != nil { + t.Fatalf("pdftotext: %v", err) + } + text := string(out) + if strings.Contains(text, elementListing) { + t.Errorf("the PDF lists the table view's elements:\n%s", text) + } + wantInOrder(t, "PDF text", text, + "Every pump on hand.", + "Table 1.", "Pump Inventory", + "name", "mass", "flow", + "p1", "12.5", "3", + "p2", "9", + "Every pump the plant holds.", "Read across each row.", + "Where the table would be.", + "The plant at a glance.", + "Table 2.", "Pump Table", + "Figure 1.", "Pump Structure", + "The following figure shows the pump.", + "Figure 2.", "Pump Structure", + "The pump moves fluid.", "It never runs dry.") +} + +// skipWithoutTool skips the test for a converter that is not installed, or +// fails it when the toolchain is declared mandatory. +func skipWithoutTool(t *testing.T, what string, err error) { + t.Helper() + const required = "OPENSYSML_REQUIRE_PDF_TOOLCHAIN" + if v := os.Getenv(required); v != "" { + t.Fatalf("%s=%s but %s not installed: %v", required, v, what, err) + } + t.Skipf("%s not installed: %v", what, err) +} diff --git a/tests/migrate/testdata/xmi/documents.golden.sysml b/tests/migrate/testdata/xmi/documents.golden.sysml index fc0a1afeba..b9a0279ed0 100644 --- a/tests/migrate/testdata/xmi/documents.golden.sysml +++ b/tests/migrate/testdata/xmi/documents.golden.sysml @@ -429,6 +429,7 @@ package 'Fleet Documents' { part paragraph : DocumentQueries::Paragraph { attribute redefines text = "The fleet, in brief."; } + /* not migrated: «Paragraph» Comment '<Comment>' — property "META:QPROP:Element:name" is not the comment body */ part table : DocumentQueries::Table { attribute redefines caption = "Fleet Parts"; calc rows : 'Fleet Handbook Fleet Parts Rows'; @@ -436,7 +437,6 @@ package 'Fleet Documents' { part 'paragraph 2' : DocumentQueries::Paragraph { attribute redefines text = "The parts of the fleet, by name."; } - /* not migrated: «Paragraph» Comment '<Comment>' — property "META:QPROP:Element:name" is not the comment body */ } part Requirements : DocumentQueries::Section { attribute redefines title = "Requirements"; diff --git a/tests/migrate/testdata/xmi/table_figures.xmi b/tests/migrate/testdata/xmi/table_figures.xmi new file mode 100644 index 0000000000..cda0219ecb --- /dev/null +++ b/tests/migrate/testdata/xmi/table_figures.xmi @@ -0,0 +1,200 @@ +<?xml version="1.0" encoding="UTF-8"?> +<xmi:XMI xmi:version="2.5.1" xmlns:xmi="http://www.omg.org/spec/XMI/20131001" + xmlns:uml="http://www.omg.org/spec/UML/20161101" + xmlns:sysml="http://www.omg.org/spec/SysML/20181001/SysML" + xmlns:MagicDraw_Profile="http://www.omg.org/spec/UML/20131001/MagicDrawProfile" + xmlns:Document_Profile_="http://www.magicdraw.com/schemas/manual/Document_Profile.xmi" + xmlns:Document_View_Collaborator_Profile="http://www.magicdraw.com/schemas/manual/Document_View_Collaborator_Profile.xmi" + xmlns:diagram="http://www.example.com/tool/diagram"> + <xmi:Documentation exporter="Example UML Tool" exporterVersion="1.0"/> + <uml:Model xmi:type="uml:Model" xmi:id="_m" name="Model"> + + <packagedElement xmi:type="uml:Package" xmi:id="_pkg_plant" name="Plant"> + <packagedElement xmi:type="uml:Package" xmi:id="_pkg_structure" name="Structure"> + <packagedElement xmi:type="uml:Class" xmi:id="_blk_pump" name="Pump"> + <ownedComment xmi:type="uml:Comment" xmi:id="_cmt_pump" body="Moves fluid through the plant."/> + <ownedAttribute xmi:type="uml:Property" xmi:id="_prop_mass" name="mass"> + <type xmi:type="uml:PrimitiveType" href="http://www.omg.org/spec/SysML/20181001/SysML.xmi#Real"/> + </ownedAttribute> + <ownedAttribute xmi:type="uml:Property" xmi:id="_prop_flow" name="flow"> + <type xmi:type="uml:PrimitiveType" href="http://www.omg.org/spec/SysML/20181001/SysML.xmi#Real"/> + </ownedAttribute> + </packagedElement> + <packagedElement xmi:type="uml:Class" xmi:id="_blk_valve" name="Valve"/> + </packagedElement> + <packagedElement xmi:type="uml:Package" xmi:id="_pkg_inventory" name="Inventory"> + <packagedElement xmi:type="uml:InstanceSpecification" xmi:id="_inst_p1" name="p1" classifier="_blk_pump"> + <slot xmi:type="uml:Slot" xmi:id="_slot_p1_mass" definingFeature="_prop_mass"> + <value xmi:type="uml:LiteralReal" xmi:id="_slot_p1_mass_v" value="12.5"/> + </slot> + <slot xmi:type="uml:Slot" xmi:id="_slot_p1_flow" definingFeature="_prop_flow"> + <value xmi:type="uml:LiteralReal" xmi:id="_slot_p1_flow_v" value="3.0"/> + </slot> + </packagedElement> + <packagedElement xmi:type="uml:InstanceSpecification" xmi:id="_inst_p2" name="p2" classifier="_blk_pump"> + <slot xmi:type="uml:Slot" xmi:id="_slot_p2_mass" definingFeature="_prop_mass"> + <value xmi:type="uml:LiteralReal" xmi:id="_slot_p2_mass_v" value="9.0"/> + </slot> + </packagedElement> + </packagedElement> + </packagedElement> + + <packagedElement xmi:type="uml:Package" xmi:id="_pkg_viewpoints" name="Plant Viewpoints"> + <packagedElement xmi:type="uml:Class" xmi:id="_vp_titled" name="Titled Figures Viewpoint" classifierBehavior="_act_titled"> + <ownedBehavior xmi:type="uml:Activity" xmi:id="_act_titled" name="Titled Figures Method"> + <node xmi:type="uml:InitialNode" xmi:id="_titled_init"/> + <node xmi:type="uml:CallBehaviorAction" xmi:id="_titled_image" name="Image"/> + <node xmi:type="uml:ActivityFinalNode" xmi:id="_titled_final"/> + <edge xmi:type="uml:ControlFlow" xmi:id="_titled_e1" source="_titled_init" target="_titled_image"/> + <edge xmi:type="uml:ControlFlow" xmi:id="_titled_e2" source="_titled_image" target="_titled_final"/> + </ownedBehavior> + </packagedElement> + <packagedElement xmi:type="uml:Class" xmi:id="_vp_plain" name="Figures Viewpoint" classifierBehavior="_act_plain"> + <ownedBehavior xmi:type="uml:Activity" xmi:id="_act_plain" name="Figures Method"> + <node xmi:type="uml:InitialNode" xmi:id="_plain_init"/> + <node xmi:type="uml:CallBehaviorAction" xmi:id="_plain_image" name="Image"/> + <node xmi:type="uml:ActivityFinalNode" xmi:id="_plain_final"/> + <edge xmi:type="uml:ControlFlow" xmi:id="_plain_e1" source="_plain_init" target="_plain_image"/> + <edge xmi:type="uml:ControlFlow" xmi:id="_plain_e2" source="_plain_image" target="_plain_final"/> + </ownedBehavior> + </packagedElement> + </packagedElement> + + <packagedElement xmi:type="uml:Package" xmi:id="_pkg_docs" name="Plant Documents"> + <packagedElement xmi:type="uml:Class" xmi:id="_doc_report" name="Plant Report"> + <ownedAttribute xmi:type="uml:Property" xmi:id="_doc_inventory" name="inventory" type="_view_inventory" aggregation="composite"/> + <ownedAttribute xmi:type="uml:Property" xmi:id="_doc_spares" name="spares" type="_view_spares" aggregation="composite"/> + <ownedAttribute xmi:type="uml:Property" xmi:id="_doc_overview" name="overview" type="_view_overview" aggregation="composite"/> + <ownedAttribute xmi:type="uml:Property" xmi:id="_doc_pumping" name="pumping" type="_view_pumping" aggregation="composite"/> + </packagedElement> + + <packagedElement xmi:type="uml:Class" xmi:id="_view_inventory" name="Inventory"> + <generalization xmi:type="uml:Generalization" xmi:id="_gen_inventory" general="_vp_titled"/> + <ownedComment xmi:type="uml:Comment" xmi:id="_inv_doc" body="Pumps on hand." annotatedElement="_view_inventory"/> + <ownedComment xmi:type="uml:Comment" xmi:id="_inv_note" body="Read across each row."/> + <ownedComment xmi:type="uml:Comment" xmi:id="_inv_head" body="Every pump on hand."/> + </packagedElement> + <packagedElement xmi:type="uml:Dependency" xmi:id="_expose_inventory" client="_view_inventory" supplier="_diag_pumps"/> + + <packagedElement xmi:type="uml:Class" xmi:id="_view_spares" name="Spares"> + <generalization xmi:type="uml:Generalization" xmi:id="_gen_spares" general="_vp_plain"/> + <ownedComment xmi:type="uml:Comment" xmi:id="_spares_doc" body="Spare parts of the plant." annotatedElement="_view_spares"/> + <ownedComment xmi:type="uml:Comment" xmi:id="_spares_note" body="Where the table would be."/> + </packagedElement> + <packagedElement xmi:type="uml:Dependency" xmi:id="_expose_spares" client="_view_spares" supplier="_diag_spares"/> + + <packagedElement xmi:type="uml:Class" xmi:id="_view_overview" name="Overview"> + <ownedComment xmi:type="uml:Comment" xmi:id="_overview_doc" body="The plant at a glance." annotatedElement="_view_overview"/> + <ownedAttribute xmi:type="uml:Property" xmi:id="_overview_details" name="details" type="_view_details" aggregation="composite"/> + </packagedElement> + <packagedElement xmi:type="uml:Dependency" xmi:id="_expose_overview_table" client="_view_overview" supplier="_diag_pumps"/> + <packagedElement xmi:type="uml:Dependency" xmi:id="_expose_overview_bdd" client="_view_overview" supplier="_diag_structure"/> + <packagedElement xmi:type="uml:Dependency" xmi:id="_expose_overview_pump" client="_view_overview" supplier="_blk_pump"/> + <packagedElement xmi:type="uml:Class" xmi:id="_view_details" name="Details"> + <generalization xmi:type="uml:Generalization" xmi:id="_gen_details" general="_vp_plain"/> + </packagedElement> + + <packagedElement xmi:type="uml:Class" xmi:id="_view_pumping" name="Pumping"> + <generalization xmi:type="uml:Generalization" xmi:id="_gen_pumping" general="_vp_plain"/> + <ownedComment xmi:type="uml:Comment" xmi:id="_pump_doc" body="How pumping works." annotatedElement="_view_pumping"/> + <ownedComment xmi:type="uml:Comment" xmi:id="_pump_follow" body="It never runs dry."/> + <ownedComment xmi:type="uml:Comment" xmi:id="_pump_after" body="The pump moves fluid."/> + <ownedComment xmi:type="uml:Comment" xmi:id="_pump_undrawn" body="The pump table is not drawn here."/> + <ownedComment xmi:type="uml:Comment" xmi:id="_pump_table" body="Table anchors are not placed."/> + <ownedComment xmi:type="uml:Comment" xmi:id="_pump_head" body="The following figure shows the pump."/> + </packagedElement> + <packagedElement xmi:type="uml:Dependency" xmi:id="_expose_pumping" client="_view_pumping" supplier="_diag_structure"/> + </packagedElement> + + <xmi:Extension extender="Example UML Tool 1.0"> + <modelExtension> + <ownedDiagram xmi:type="uml:Diagram" xmi:id="_diag_pumps" name="Pump Table" ownerOfDiagram="_pkg_inventory"> + <xmi:Extension extender="Example UML Tool 1.0"> + <diagramRepresentation> + <diagram:DiagramRepresentationObject type="SysML Instance Table" umlType="Class Diagram"> + <diagramContents> + <usedElements>_inst_p1</usedElements> + <usedElements>_inst_p2</usedElements> + </diagramContents> + </diagram:DiagramRepresentationObject> + </diagramRepresentation> + </xmi:Extension> + </ownedDiagram> + <ownedDiagram xmi:type="uml:Diagram" xmi:id="_diag_spares" name="Spare Parts" ownerOfDiagram="_pkg_plant"> + <xmi:Extension extender="Example UML Tool 1.0"> + <diagramRepresentation> + <diagram:DiagramRepresentationObject type="Generic Table" umlType="Class Diagram"> + <diagramContents> + <usedElements>_blk_valve</usedElements> + </diagramContents> + </diagram:DiagramRepresentationObject> + </diagramRepresentation> + </xmi:Extension> + </ownedDiagram> + <ownedDiagram xmi:type="uml:Diagram" xmi:id="_diag_structure" name="Pump Structure" ownerOfDiagram="_pkg_structure"> + <xmi:Extension extender="Example UML Tool 1.0"> + <diagramRepresentation> + <diagram:DiagramRepresentationObject type="SysML Block Definition Diagram" umlType="Class Diagram"> + <diagramContents> + <usedElements>_blk_pump</usedElements> + <usedElements>_blk_valve</usedElements> + </diagramContents> + </diagram:DiagramRepresentationObject> + </diagramRepresentation> + </xmi:Extension> + </ownedDiagram> + </modelExtension> + </xmi:Extension> + </uml:Model> + + <sysml:Block xmi:id="_st_blk_pump" base_Class="_blk_pump"/> + <sysml:Block xmi:id="_st_blk_valve" base_Class="_blk_valve"/> + + <sysml:Viewpoint xmi:id="_st_vp_titled" base_Class="_vp_titled"/> + <sysml:Viewpoint xmi:id="_st_vp_plain" base_Class="_vp_plain"/> + <sysml:View xmi:id="_st_view_inventory" base_Class="_view_inventory"/> + <sysml:View xmi:id="_st_view_spares" base_Class="_view_spares"/> + <sysml:View xmi:id="_st_view_overview" base_Class="_view_overview"/> + <sysml:View xmi:id="_st_view_details" base_Class="_view_details"/> + <sysml:View xmi:id="_st_view_pumping" base_Class="_view_pumping"/> + <sysml:Conform xmi:id="_st_conform_inventory" base_Generalization="_gen_inventory"/> + <sysml:Conform xmi:id="_st_conform_spares" base_Generalization="_gen_spares"/> + <sysml:Conform xmi:id="_st_conform_details" base_Generalization="_gen_details"/> + <sysml:Conform xmi:id="_st_conform_pumping" base_Generalization="_gen_pumping"/> + <sysml:Expose xmi:id="_st_expose_inventory" base_Dependency="_expose_inventory"/> + <sysml:Expose xmi:id="_st_expose_spares" base_Dependency="_expose_spares"/> + <sysml:Expose xmi:id="_st_expose_overview_table" base_Dependency="_expose_overview_table"/> + <sysml:Expose xmi:id="_st_expose_overview_bdd" base_Dependency="_expose_overview_bdd"/> + <sysml:Expose xmi:id="_st_expose_overview_pump" base_Dependency="_expose_overview_pump"/> + <sysml:Expose xmi:id="_st_expose_pumping" base_Dependency="_expose_pumping"/> + + <Document_Profile_:Document xmi:id="_st_doc_report" base_Class="_doc_report"/> + <Document_Profile_:Image xmi:id="_st_titled_image" base_CallBehaviorAction="_titled_image" showCaptions="true"> + <titles>Pump Inventory</titles> + <captions>Every pump the plant holds.</captions> + </Document_Profile_:Image> + <Document_Profile_:Image xmi:id="_st_plain_image" base_CallBehaviorAction="_plain_image"/> + + <MagicDraw_Profile:InstanceTable xmi:id="_tbl_pumps" base_Diagram="_diag_pumps" scope="_pkg_inventory" includeSubtypesOfRowTypes="true" showScopeAsRoot="false"> + <classifiers xmi:idref="_blk_pump"/> + <columnIds>_NUMBER_</columnIds> + <columnIds>QPROP:Element:name</columnIds> + <columnIds>IColumn:_prop_mass</columnIds> + <columnIds>IColumn:_prop_flow</columnIds> + <sort>IColumn:_prop_mass^Desc</sort> + </MagicDraw_Profile:InstanceTable> + <MagicDraw_Profile:DiagramTable xmi:id="_tbl_spares" base_Diagram="_diag_spares" includeSubtypesOfRowTypes="true"> + <rowElementType href="http://www.omg.org/spec/SysML/20181001/SysML.xmi#SysML.Block"/> + <columnIds>_NUMBER_</columnIds> + <columnIds>QPROP:Element:name</columnIds> + </MagicDraw_Profile:DiagramTable> + + <Document_View_Collaborator_Profile:CollaboratorParagraph xmi:id="_st_inv_note" base_Element="_inv_note" property="META:QPROP:Element:body" documentId="_doc_report" viewId="_doc_report" ownerId="_view_inventory" siblingId="Containment_DiagramMainImage__7c1e4b"/> + <Document_View_Collaborator_Profile:CollaboratorParagraph xmi:id="_st_inv_head" base_Element="_inv_head" property="META:QPROP:Element:body" documentId="_doc_report" viewId="_doc_report" ownerId="_view_inventory"/> + <Document_View_Collaborator_Profile:CollaboratorParagraph xmi:id="_st_spares_note" base_Element="_spares_note" property="META:QPROP:Element:body" documentId="_doc_report" viewId="_doc_report" ownerId="_view_spares" siblingId="Containment_DiagramMainImage___diag_spares"/> + <Document_View_Collaborator_Profile:CollaboratorParagraph xmi:id="_st_pump_follow" base_Element="_pump_follow" property="META:QPROP:Element:body" documentId="_doc_report" viewId="_doc_report" ownerId="_view_pumping" siblingId="_pump_after"/> + <Document_View_Collaborator_Profile:CollaboratorParagraph xmi:id="_st_pump_after" base_Element="_pump_after" property="META:QPROP:Element:body" documentId="_doc_report" viewId="_doc_report" ownerId="_view_pumping" siblingId="Containment_DiagramMainImage___diag_structure"/> + <Document_View_Collaborator_Profile:CollaboratorParagraph xmi:id="_st_pump_undrawn" base_Element="_pump_undrawn" property="META:QPROP:Element:body" documentId="_doc_report" viewId="_doc_report" ownerId="_view_pumping" siblingId="Containment_DiagramMainImage___diag_pumps"/> + <Document_View_Collaborator_Profile:CollaboratorParagraph xmi:id="_st_pump_table" base_Element="_pump_table" property="META:QPROP:Element:body" documentId="_doc_report" viewId="_doc_report" ownerId="_view_pumping" siblingId="Containment_TableMainImage___diag_pumps"/> + <Document_View_Collaborator_Profile:CollaboratorParagraph xmi:id="_st_pump_head" base_Element="_pump_head" property="META:QPROP:Element:body" documentId="_doc_report" viewId="_doc_report" ownerId="_view_pumping"/> +</xmi:XMI> From bfcb9db333dee2611b8331ba7b21ba314715dce6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 06:07:00 +0000 Subject: [PATCH 2/4] fix(migrate): refuse a DocGen view whose Conform names no viewpoint instead of defaulting Only a view with no Conform gets DocGen's default behavior, as in MDK's DocumentGenerator.parseView; a Conform whose general resolves to nothing is refused with the reason and draws none of the exposed diagrams. Co-Authored-By: jason.han <hanhuijun@gmail.com> --- ...table-figures-and-paragraph-order.fixed.md | 4 +- docs/reference/sysml-v1-migration.md | 9 ++-- internal/translate/migrate/documents.go | 14 +++-- internal/translate/xmi/sysmlv1/docgen.go | 12 ++++- internal/translate/xmi/sysmlv1/docgen_test.go | 52 +++++++++++++++++++ tests/migrate/table_figures_test.go | 23 ++++++++ .../testdata/xmi/documents.golden.sysml | 1 + tests/migrate/testdata/xmi/table_figures.xmi | 10 ++++ 8 files changed, 114 insertions(+), 11 deletions(-) diff --git a/changes/unreleased/docgen-table-figures-and-paragraph-order.fixed.md b/changes/unreleased/docgen-table-figures-and-paragraph-order.fixed.md index 84cfa34160..33defe64ef 100644 --- a/changes/unreleased/docgen-table-figures-and-paragraph-order.fixed.md +++ b/changes/unreleased/docgen-table-figures-and-paragraph-order.fixed.md @@ -11,7 +11,9 @@ is itself a diagram shows its own figure; any other shows, after its documentation, each diagram it exposes in order — a plain diagram as a figure, a table diagram as its table — and nothing for an exposed element that is not a diagram, the report row naming what it drew - and what it left out. + and what it left out. As in MDK, only a missing «Conform» means that: a view whose «Conform» + names no element is refused with the reason, and the «View» stereotype's `viewpoint` tag + chooses no method. - **Collaborator paragraphs stand where their anchors put them.** Every collaborator paragraph followed the section's generated content. A paragraph with no `siblingId`/`parentId` now precedes it, as Cameo prints it; one anchored to a generated figure diff --git a/docs/reference/sysml-v1-migration.md b/docs/reference/sysml-v1-migration.md index d176c5fb7a..8aa38e1dfa 100644 --- a/docs/reference/sysml-v1-migration.md +++ b/docs/reference/sysml-v1-migration.md @@ -578,14 +578,17 @@ DocGen prints it, with the view's own documentation as a `Paragraph` — the sam `view` carries as `doc`, tool HTML reduced to text — before its method's content, unless that comment is shown by one of the view's collaborator paragraphs, in which case it is written once, in that paragraph's place; a collaborator paragraph that cannot be shown (a malformed application) is -refused as usual and does not hide the documentation. A view conforming to no viewpoint gets +refused as usual and does not hide the documentation. A view with no «Conform» gets DocGen's default behavior, that of MDK's [`DocumentGenerator.parseView`](https://github.com/Open-MBEE/mdk/blob/develop/src/main/java/org/openmbee/mdk/generator/DocumentGenerator.java) when the view has no viewpoint or method: a view that is itself a diagram shows its own figure, and any other shows, after its documentation, each diagram it exposes or imports in that order — an `Image` of a plain diagram, the `Table` of a table diagram — and nothing for an exposed element that is not -a diagram; the view's report row says the default applied and what it showed. A view whose -«Conform» names a viewpoint keeps its method's refusal when that method is malformed. The tree +a diagram; the view's report row says the default applied and what it showed. Only the +«Conform» generalization decides, as it does in `parseView`: the «View» stereotype's `viewpoint` +tag, which the «View» row above writes as a `satisfy`, names no method for the section. A view whose «Conform» names no element of the export is refused with that reason rather +than given the default, and a view whose «Conform» names a viewpoint keeps its method's refusal +when that method is malformed. The tree is the one DocGen walks: every property of a view typed by a view is a section, and a view is entered for its own sections only through a composite or shared property — a plain reference places the view as a section without its children, and the «Expose» dependencies of a property feed diff --git a/internal/translate/migrate/documents.go b/internal/translate/migrate/documents.go index 189a669334..dcdefedd46 100644 --- a/internal/translate/migrate/documents.go +++ b/internal/translate/migrate/documents.go @@ -379,9 +379,9 @@ func (m *migration) findFigure(sec *sectionPlan, id string) (*sectionPlan, *figu } // planMethod walks the activity chain of the view's viewpoint method into -// content blocks. A view conforming to no viewpoint gets DocGen's default -// behavior instead; one whose viewpoint's method tag names something that is -// not a method is refused. +// content blocks. A view with no Conform gets DocGen's default behavior +// instead; one whose Conform names no viewpoint, or whose viewpoint's method +// tag names something that is not a method, is refused. func (m *migration) planMethod(dp *docPlan, sec *sectionPlan) { v := sec.v if v.Method == nil { @@ -389,6 +389,9 @@ func (m *migration) planMethod(dp *docPlan, sec *sectionPlan) { case v.MethodMalformed != "": sec.refused = "the viewpoint " + qualifiedName(v.Viewpoint) + "'s method is not migrated: " + v.MethodMalformed m.report.Entries = append(m.report.Entries, *m.nodeEntry(v.Viewpoint, v.Viewpoint.Stereotype("Viewpoint"), Unmapped, sec.refused)) + case v.ConformMalformed != "": + sec.refused = "the view " + qualifiedName(v.Class) + "'s conformance is not migrated: " + v.ConformMalformed + m.report.Entries = append(m.report.Entries, *m.nodeEntry(v.Class, viewApplication(v.Class), Unmapped, sec.refused)) case v.Viewpoint == nil: m.defaultView(dp, sec) } @@ -405,8 +408,9 @@ func (m *migration) planMethod(dp *docPlan, sec *sectionPlan) { c.run(steps) } -// defaultView applies what DocGen does for a view conforming to no viewpoint -// (MDK's DocumentGenerator.parseView): a view that is itself a diagram shows +// defaultView applies what DocGen does for a view with no Conform (MDK's +// DocumentGenerator.parseView reads that relationship alone, not the «View» +// stereotype's viewpoint tag): a view that is itself a diagram shows // its own figure; any other shows, after its documentation, the figure of // each diagram it exposes in order — a table for a table diagram — and // nothing for an exposed element that is not a diagram. diff --git a/internal/translate/xmi/sysmlv1/docgen.go b/internal/translate/xmi/sysmlv1/docgen.go index 3ac96f6352..b21c5a7be5 100644 --- a/internal/translate/xmi/sysmlv1/docgen.go +++ b/internal/translate/xmi/sysmlv1/docgen.go @@ -24,6 +24,9 @@ type DocGenView struct { Class *Element // Viewpoint is the viewpoint the view conforms to; nil when none. Viewpoint *Element + // ConformMalformed is why the view's Conform names no viewpoint, "" when + // it does or the view has no Conform. + ConformMalformed string // Method is the viewpoint's method activity, the behavior of its // operation named View or its method tag; nil when the viewpoint has none. Method *Element @@ -295,6 +298,7 @@ func (r *docGenReader) view(class, p *Element, path map[*Element]bool, recurse b v := &DocGenView{Class: class, Paragraphs: r.paragraphs(class)} path[class] = true defer delete(path, class) + var broken []string for _, g := range class.Owned("generalization") { if !isSysMLStereotyped(g, "Conform") { continue @@ -302,11 +306,15 @@ func (r *docGenReader) view(class, p *Element, path map[*Element]bool, recurse b if general := m.Ref(g, "general"); general != nil { v.Viewpoint = general } else { - v.Malformed = append(v.Malformed, fmt.Sprintf("Conform general %q names no element", g.Attrs["general"])) + broken = append(broken, fmt.Sprintf("Conform general %q names no element", g.Attrs["general"])) } } - if v.Viewpoint != nil { + switch { + case v.Viewpoint != nil: + v.Malformed = append(v.Malformed, broken...) v.Method, v.MethodMalformed = m.viewpointMethod(v.Viewpoint) + case len(broken) > 0: + v.ConformMalformed = strings.Join(broken, "; ") } v.Exposed = m.exposed(class) if p != nil && composite(p) { diff --git a/internal/translate/xmi/sysmlv1/docgen_test.go b/internal/translate/xmi/sysmlv1/docgen_test.go index e2c8ecfab1..dfb0a4d88f 100644 --- a/internal/translate/xmi/sysmlv1/docgen_test.go +++ b/internal/translate/xmi/sysmlv1/docgen_test.go @@ -84,6 +84,58 @@ func TestDocGenViewTreeFollowsAggregation(t *testing.T) { } } +// A view's Conform naming no element is told apart from a view with no +// Conform: the former is ConformMalformed, the latter simply has no viewpoint. +// A broken Conform beside one that resolves is only a note. +func TestDocGenViewConformMalformed(t *testing.T) { + m, err := Parse([]byte(`<?xml version="1.0"?> +<xmi:XMI xmi:version="2.5.1" xmlns:xmi="http://www.omg.org/spec/XMI/20131001" xmlns:uml="http://www.omg.org/spec/UML/20161101" + xmlns:sysml="http://www.omg.org/spec/SysML/20181001/SysML" + xmlns:Document_Profile_="http://www.magicdraw.com/schemas/manual/Document_Profile.xmi"> + <uml:Model xmi:id="_m" name="M"> + <packagedElement xmi:type="uml:Class" xmi:id="_vp" name="VP"/> + <packagedElement xmi:type="uml:Class" xmi:id="_doc" name="Doc"> + <ownedAttribute xmi:type="uml:Property" xmi:id="_p_broken" name="broken" type="_broken" aggregation="composite"/> + <ownedAttribute xmi:type="uml:Property" xmi:id="_p_none" name="none" type="_none" aggregation="composite"/> + <ownedAttribute xmi:type="uml:Property" xmi:id="_p_both" name="both" type="_both" aggregation="composite"/> + </packagedElement> + <packagedElement xmi:type="uml:Class" xmi:id="_broken" name="Broken"> + <generalization xmi:type="uml:Generalization" xmi:id="_g_broken" general="_missing"/> + </packagedElement> + <packagedElement xmi:type="uml:Class" xmi:id="_none" name="None"/> + <packagedElement xmi:type="uml:Class" xmi:id="_both" name="Both"> + <generalization xmi:type="uml:Generalization" xmi:id="_g_both_broken" general="_missing"/> + <generalization xmi:type="uml:Generalization" xmi:id="_g_both_ok" general="_vp"/> + </packagedElement> + </uml:Model> + <Document_Profile_:Document xmi:id="_st_doc" base_Class="_doc"/> + <Document_Profile_:view xmi:id="_st_broken" base_Class="_broken"/> + <Document_Profile_:view xmi:id="_st_none" base_Class="_none"/> + <Document_Profile_:view xmi:id="_st_both" base_Class="_both"/> + <sysml:Viewpoint xmi:id="_st_vp" base_Class="_vp"/> + <sysml:Conform xmi:id="_st_c1" base_Generalization="_g_broken"/> + <sysml:Conform xmi:id="_st_c2" base_Generalization="_g_both_broken"/> + <sysml:Conform xmi:id="_st_c3" base_Generalization="_g_both_ok"/> +</xmi:XMI>`)) + if err != nil { + t.Fatal(err) + } + if len(m.Documents) != 1 || len(m.Documents[0].Root.Children) != 3 { + t.Fatalf("documents = %+v, want one with 3 views", m.Documents) + } + const why = `Conform general "_missing" names no element` + broken, none, both := m.Documents[0].Root.Children[0], m.Documents[0].Root.Children[1], m.Documents[0].Root.Children[2] + if broken.Viewpoint != nil || broken.ConformMalformed != why || len(broken.Malformed) != 0 { + t.Errorf("Broken: viewpoint %v, ConformMalformed %q, Malformed %v; want nil, %q, none", broken.Viewpoint, broken.ConformMalformed, broken.Malformed, why) + } + if none.Viewpoint != nil || none.ConformMalformed != "" || len(none.Malformed) != 0 { + t.Errorf("None: viewpoint %v, ConformMalformed %q, Malformed %v; want nil, \"\", none", none.Viewpoint, none.ConformMalformed, none.Malformed) + } + if both.Viewpoint == nil || both.Viewpoint.ID != "_vp" || both.ConformMalformed != "" || len(both.Malformed) != 1 || both.Malformed[0] != why { + t.Errorf("Both: viewpoint %v, ConformMalformed %q, Malformed %v; want VP, \"\", [%q]", both.Viewpoint, both.ConformMalformed, both.Malformed, why) + } +} + // A control flow whose source or target names no node makes the whole chain // unreadable: the walk refuses it instead of ending cleanly where the edge is lost. func TestDocGenChainRefusesDanglingFlows(t *testing.T) { diff --git a/tests/migrate/table_figures_test.go b/tests/migrate/table_figures_test.go index 313a794aaa..9fb14184a8 100644 --- a/tests/migrate/table_figures_test.go +++ b/tests/migrate/table_figures_test.go @@ -104,6 +104,29 @@ func TestViewpointLessViewShowsExposedDiagrams(t *testing.T) { wantOneNote(t, r, "_st_view_overview", migrate.Mapped, "the SysML Instance Table 'Pump Table' is written as a Table over the query 'Pump Table Rows' of its «InstanceTable»") } +func TestViewWithBrokenConformIsRefused(t *testing.T) { + r := plantReportResult(t) + notation := string(r.Notation) + sec := notationSection(notation, "Unlinked") + if sec == "" { + t.Fatalf("no Unlinked section written:\n%s", notation) + } + wantInOrder(t, "Unlinked section", sec, + `/* not migrated: the view Plant Documents::Unlinked's conformance is not migrated: Conform general "_vp_missing" names no element */`, + `attribute redefines text = "Its viewpoint is gone.";`) + for _, kind := range []string{"DocumentQueries::Diagram", "DocumentQueries::Table"} { + if strings.Contains(sec, kind) { + t.Errorf("the view whose Conform names no viewpoint draws a %s as if it had none:\n%s", kind, sec) + } + } + wantOneNote(t, r, "_st_view_unlinked", migrate.Unmapped, `the view Plant Documents::Unlinked's conformance is not migrated: Conform general "_vp_missing" names no element`) + for _, e := range entriesFor(r, "_view_unlinked") { + if strings.Contains(e.Note, "default behavior") { + t.Errorf("_view_unlinked: %+v, want no default-behavior note", e) + } + } +} + func TestCollaboratorParagraphsFollowTheirAnchors(t *testing.T) { r := plantReportResult(t) notation := string(r.Notation) diff --git a/tests/migrate/testdata/xmi/documents.golden.sysml b/tests/migrate/testdata/xmi/documents.golden.sysml index b9a0279ed0..fe2546c2ce 100644 --- a/tests/migrate/testdata/xmi/documents.golden.sysml +++ b/tests/migrate/testdata/xmi/documents.golden.sysml @@ -507,6 +507,7 @@ package 'Fleet Documents' { } part Notes : DocumentQueries::Section { attribute redefines title = "Notes"; + /* not migrated: the view Fleet Documents::Notes's conformance is not migrated: Conform general "_vp_gone" names no element */ part paragraph : DocumentQueries::Paragraph { attribute redefines text = "First note."; } diff --git a/tests/migrate/testdata/xmi/table_figures.xmi b/tests/migrate/testdata/xmi/table_figures.xmi index cda0219ecb..061074e986 100644 --- a/tests/migrate/testdata/xmi/table_figures.xmi +++ b/tests/migrate/testdata/xmi/table_figures.xmi @@ -66,6 +66,7 @@ <ownedAttribute xmi:type="uml:Property" xmi:id="_doc_spares" name="spares" type="_view_spares" aggregation="composite"/> <ownedAttribute xmi:type="uml:Property" xmi:id="_doc_overview" name="overview" type="_view_overview" aggregation="composite"/> <ownedAttribute xmi:type="uml:Property" xmi:id="_doc_pumping" name="pumping" type="_view_pumping" aggregation="composite"/> + <ownedAttribute xmi:type="uml:Property" xmi:id="_doc_unlinked" name="unlinked" type="_view_unlinked" aggregation="composite"/> </packagedElement> <packagedElement xmi:type="uml:Class" xmi:id="_view_inventory" name="Inventory"> @@ -104,6 +105,12 @@ <ownedComment xmi:type="uml:Comment" xmi:id="_pump_head" body="The following figure shows the pump."/> </packagedElement> <packagedElement xmi:type="uml:Dependency" xmi:id="_expose_pumping" client="_view_pumping" supplier="_diag_structure"/> + + <packagedElement xmi:type="uml:Class" xmi:id="_view_unlinked" name="Unlinked"> + <generalization xmi:type="uml:Generalization" xmi:id="_gen_unlinked" general="_vp_missing"/> + <ownedComment xmi:type="uml:Comment" xmi:id="_unlinked_doc" body="Its viewpoint is gone." annotatedElement="_view_unlinked"/> + </packagedElement> + <packagedElement xmi:type="uml:Dependency" xmi:id="_expose_unlinked" client="_view_unlinked" supplier="_diag_structure"/> </packagedElement> <xmi:Extension extender="Example UML Tool 1.0"> @@ -157,16 +164,19 @@ <sysml:View xmi:id="_st_view_overview" base_Class="_view_overview"/> <sysml:View xmi:id="_st_view_details" base_Class="_view_details"/> <sysml:View xmi:id="_st_view_pumping" base_Class="_view_pumping"/> + <sysml:View xmi:id="_st_view_unlinked" base_Class="_view_unlinked"/> <sysml:Conform xmi:id="_st_conform_inventory" base_Generalization="_gen_inventory"/> <sysml:Conform xmi:id="_st_conform_spares" base_Generalization="_gen_spares"/> <sysml:Conform xmi:id="_st_conform_details" base_Generalization="_gen_details"/> <sysml:Conform xmi:id="_st_conform_pumping" base_Generalization="_gen_pumping"/> + <sysml:Conform xmi:id="_st_conform_unlinked" base_Generalization="_gen_unlinked"/> <sysml:Expose xmi:id="_st_expose_inventory" base_Dependency="_expose_inventory"/> <sysml:Expose xmi:id="_st_expose_spares" base_Dependency="_expose_spares"/> <sysml:Expose xmi:id="_st_expose_overview_table" base_Dependency="_expose_overview_table"/> <sysml:Expose xmi:id="_st_expose_overview_bdd" base_Dependency="_expose_overview_bdd"/> <sysml:Expose xmi:id="_st_expose_overview_pump" base_Dependency="_expose_overview_pump"/> <sysml:Expose xmi:id="_st_expose_pumping" base_Dependency="_expose_pumping"/> + <sysml:Expose xmi:id="_st_expose_unlinked" base_Dependency="_expose_unlinked"/> <Document_Profile_:Document xmi:id="_st_doc_report" base_Class="_doc_report"/> <Document_Profile_:Image xmi:id="_st_titled_image" base_CallBehaviorAction="_titled_image" showCaptions="true"> From 75ec26181a23feed6cbb247e62eb64da67768c27 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 06:07:17 +0000 Subject: [PATCH 3/4] test(migrate): record the refused Conform on the view's own report row Co-Authored-By: jason.han <hanhuijun@gmail.com> --- tests/migrate/testdata/xmi/documents.golden.report.txt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/migrate/testdata/xmi/documents.golden.report.txt b/tests/migrate/testdata/xmi/documents.golden.report.txt index 1c56a09444..38b00ddb75 100644 --- a/tests/migrate/testdata/xmi/documents.golden.report.txt +++ b/tests/migrate/testdata/xmi/documents.golden.report.txt @@ -1,9 +1,10 @@ # SysML v1 to v2 migration report: documents.xmi # exported by Example UML Tool -# migrated 322 element(s): 238 mapped, 67 approximated, 17 unmapped (4 skipped as profile, library or notation-only content, 0 as model elements nothing refers to) +# migrated 323 element(s): 238 mapped, 67 approximated, 18 unmapped (4 skipped as profile, library or notation-only content, 0 as model elements nothing refers to) -## unmapped (17) +## unmapped (18) «CollaboratorParagraph» Comment Fleet Documents::Introduction::<Comment> _st_intro_named (property "META:QPROP:Element:name" is not the comment body) +«View» Class Fleet Documents::Notes _st_view_notes (the view Fleet Documents::Notes's conformance is not migrated: Conform general "_vp_gone" names no element) «CollaboratorImageParagraph» Comment Fleet Documents::Notes::<Comment> _st_note_blank_image (the attached image "depot.png" is not in the archive) «CollaboratorParagraph» Comment Fleet Documents::Notes::<Comment> _st_note_named (property "META:QPROP:Element:name" is not the comment body) «CollaboratorParagraph» Comment Fleet Documents::Notes::<Comment> _st_note_empty (the paragraph's comment has no body) @@ -25,7 +26,7 @@ Activity Fleet Viewpoints::Severed Viewpoint::Severed Method _act_severed (the m «Document» Class Fleet Documents::Fleet Brief _doc_brief -> 'Fleet Documents'::'Fleet Brief' (a plain UML class without «Block» is written as a part def) «Document» Class Fleet Documents::Fleet Brief _st_doc_brief -> part def 'Fleet Documents'::'Fleet Brief Document' (the «Document» is written as a Document definition of 8 section(s); elements of the stereotypes specializing «Safety» are kept too; the column «TableExpressionColumn» Fleet Viewpoints::Safety Viewpoint::Safety Method::Safety Requirements::Owner is not written: the expression "owner.oclAsType(NamedElement).name" is not a bare query property (name, documentation, qualifiedName, owner, id)) «Document» Class Fleet Documents::Fleet Handbook _doc_handbook -> 'Fleet Documents'::'Fleet Handbook' (a plain UML class without «Block» is written as a part def) -«Document» Class Fleet Documents::Fleet Handbook _st_doc_handbook -> part def 'Fleet Documents'::'Fleet Handbook Document' (the «Document» is written as a Document definition of 9 section(s); the column «TableExpressionColumn» Fleet Viewpoints::Parts Viewpoint::Parts Method::Fleet Parts::Owner Name is not written: the expression "owner.name" is not a bare query property (name, documentation, qualifiedName, owner, id); the column name is written as name 2: column names are unique; Project lists its properties first: name, qualifiedName, documentation precede the other columns; elements of the stereotypes specializing «Safety» are kept too; the column «TableExpressionColumn» Fleet Viewpoints::Safety Viewpoint::Safety Method::Safety Requirements::Owner is not written: the expression "owner.oclAsType(NamedElement).name" is not a bare query property (name, documentation, qualifiedName, owner, id); each item's documentation follows its name; the attached image "fleet.png" is not in the archive; its caption stands as the paragraph; Conform general "_vp_gone" names no element) +«Document» Class Fleet Documents::Fleet Handbook _st_doc_handbook -> part def 'Fleet Documents'::'Fleet Handbook Document' (the «Document» is written as a Document definition of 9 section(s); the column «TableExpressionColumn» Fleet Viewpoints::Parts Viewpoint::Parts Method::Fleet Parts::Owner Name is not written: the expression "owner.name" is not a bare query property (name, documentation, qualifiedName, owner, id); the column name is written as name 2: column names are unique; Project lists its properties first: name, qualifiedName, documentation precede the other columns; elements of the stereotypes specializing «Safety» are kept too; the column «TableExpressionColumn» Fleet Viewpoints::Safety Viewpoint::Safety Method::Safety Requirements::Owner is not written: the expression "owner.oclAsType(NamedElement).name" is not a bare query property (name, documentation, qualifiedName, owner, id); each item's documentation follows its name; the attached image "fleet.png" is not in the archive; its caption stands as the paragraph) «View» Class Fleet Documents::Notes _view_notes -> 'Fleet Documents'::Notes (a generalization refers to nothing in the document) «CollaboratorImageParagraph» Comment Fleet Documents::Notes::<Comment> _st_note_image -> part 'Fleet Documents'::'Fleet Handbook Document'::Notes::'paragraph 3' (the attached image "fleet.png" is not in the archive; its caption stands as the paragraph) Activity Fleet Viewpoints::Broken Viewpoint::Broken Method _act_broken -> 'Fleet Viewpoints'::'Broken Viewpoint'::'Broken Method' (the classifier behavior is run by every object of Fleet Viewpoints::Broken Viewpoint as its usage broken Method) From 3eade6eb28613767a24c57b04cdbe6d17587a5a9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 06:21:13 +0000 Subject: [PATCH 4/4] test(migrate): embed a table whose diagram a view owns, reached through the view usage Co-Authored-By: jason.han <hanhuijun@gmail.com> --- tests/migrate/table_figures_test.go | 39 ++++++++++++++++++-- tests/migrate/testdata/xmi/table_figures.xmi | 28 ++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/tests/migrate/table_figures_test.go b/tests/migrate/table_figures_test.go index 9fb14184a8..540dc41f0f 100644 --- a/tests/migrate/table_figures_test.go +++ b/tests/migrate/table_figures_test.go @@ -57,6 +57,31 @@ func TestImageOfTableEmbedsTheTable(t *testing.T) { wantOneNote(t, r, "_st_titled_image", migrate.Mapped, "the paragraph is the Table's caption") } +// A table whose diagram a view owns has its query inside that view usage; a +// section elsewhere reaches it by qualified name, as a definition is reached. +func TestImageOfTableOwnedByViewEmbedsTheTable(t *testing.T) { + r := plantReportResult(t) + notation := string(r.Notation) + wantInOrder(t, "Gallery view", notation, + "view Gallery {", + "calc def 'Block Table Rows' :> DocumentQueries::Query {", + "part def 'Block Table Document' :> DocumentQueries::Document {", + "calc rows : 'Block Table Rows';") + sec := notationSection(notation, "Gallery") + if sec == "" { + t.Fatalf("no Gallery section written:\n%s", notation) + } + wantInOrder(t, "Gallery section", sec, + `attribute redefines text = "Every block of the plant.";`, + "part table : DocumentQueries::Table {", + `attribute redefines caption = "Block Table";`, + "calc rows : 'Plant Documents'::Gallery::'Block Table Rows';") + if strings.Contains(sec, "DocumentQueries::Diagram") { + t.Errorf("the Image of the table draws the view as a Diagram:\n%s", sec) + } + wantOneNote(t, r, "_st_plain_image", migrate.Mapped, "the Generic Table 'Block Table' is written as a Table over the query 'Block Table Rows' of its «DiagramTable»") +} + func TestImageOfRefusedTableIsRefused(t *testing.T) { r := plantReportResult(t) sec := notationSection(string(r.Notation), "Spares") @@ -192,8 +217,13 @@ func TestEmbeddedTablesRenderHTML(t *testing.T) { "Details", "Pumping", "How pumping works.", "The following figure shows the pump.", "Figure 2.", "Pump Structure", - "The pump moves fluid.", "It never runs dry.") - if strings.Contains(page, "Table 3.") || strings.Contains(page, "Figure 3.") { + "The pump moves fluid.", "It never runs dry.", + "Gallery", "Every block of the plant.", + `<table class="sysml-table" data-content="table" data-name="table" data-query="Plant Documents::Gallery::Block Table Rows">`, + "Table 3.", "Block Table", + `<th scope="col" data-column="name">name</th>`, + ">Pump</span>", ">Valve</span>") + if strings.Contains(page, "Table 4.") || strings.Contains(page, "Figure 3.") { t.Errorf("tables and figures are not numbered apart:\n%s", page) } } @@ -256,7 +286,10 @@ func TestEmbeddedTablesRenderInstalledPDF(t *testing.T) { "Figure 1.", "Pump Structure", "The following figure shows the pump.", "Figure 2.", "Pump Structure", - "The pump moves fluid.", "It never runs dry.") + "The pump moves fluid.", "It never runs dry.", + "Every block of the plant.", + "Table 3.", "Block Table", + "name", "Pump", "Valve") } // skipWithoutTool skips the test for a converter that is not installed, or diff --git a/tests/migrate/testdata/xmi/table_figures.xmi b/tests/migrate/testdata/xmi/table_figures.xmi index 061074e986..95d0084c46 100644 --- a/tests/migrate/testdata/xmi/table_figures.xmi +++ b/tests/migrate/testdata/xmi/table_figures.xmi @@ -67,6 +67,7 @@ <ownedAttribute xmi:type="uml:Property" xmi:id="_doc_overview" name="overview" type="_view_overview" aggregation="composite"/> <ownedAttribute xmi:type="uml:Property" xmi:id="_doc_pumping" name="pumping" type="_view_pumping" aggregation="composite"/> <ownedAttribute xmi:type="uml:Property" xmi:id="_doc_unlinked" name="unlinked" type="_view_unlinked" aggregation="composite"/> + <ownedAttribute xmi:type="uml:Property" xmi:id="_doc_gallery" name="gallery" type="_view_gallery" aggregation="composite"/> </packagedElement> <packagedElement xmi:type="uml:Class" xmi:id="_view_inventory" name="Inventory"> @@ -111,6 +112,12 @@ <ownedComment xmi:type="uml:Comment" xmi:id="_unlinked_doc" body="Its viewpoint is gone." annotatedElement="_view_unlinked"/> </packagedElement> <packagedElement xmi:type="uml:Dependency" xmi:id="_expose_unlinked" client="_view_unlinked" supplier="_diag_structure"/> + + <packagedElement xmi:type="uml:Class" xmi:id="_view_gallery" name="Gallery"> + <generalization xmi:type="uml:Generalization" xmi:id="_gen_gallery" general="_vp_plain"/> + <ownedComment xmi:type="uml:Comment" xmi:id="_gallery_doc" body="Every block of the plant." annotatedElement="_view_gallery"/> + </packagedElement> + <packagedElement xmi:type="uml:Dependency" xmi:id="_expose_gallery" client="_view_gallery" supplier="_diag_blocks"/> </packagedElement> <xmi:Extension extender="Example UML Tool 1.0"> @@ -138,6 +145,18 @@ </diagramRepresentation> </xmi:Extension> </ownedDiagram> + <ownedDiagram xmi:type="uml:Diagram" xmi:id="_diag_blocks" name="Block Table" ownerOfDiagram="_view_gallery"> + <xmi:Extension extender="Example UML Tool 1.0"> + <diagramRepresentation> + <diagram:DiagramRepresentationObject type="Generic Table" umlType="Class Diagram"> + <diagramContents> + <usedElements>_blk_pump</usedElements> + <usedElements>_blk_valve</usedElements> + </diagramContents> + </diagram:DiagramRepresentationObject> + </diagramRepresentation> + </xmi:Extension> + </ownedDiagram> <ownedDiagram xmi:type="uml:Diagram" xmi:id="_diag_structure" name="Pump Structure" ownerOfDiagram="_pkg_structure"> <xmi:Extension extender="Example UML Tool 1.0"> <diagramRepresentation> @@ -165,11 +184,13 @@ <sysml:View xmi:id="_st_view_details" base_Class="_view_details"/> <sysml:View xmi:id="_st_view_pumping" base_Class="_view_pumping"/> <sysml:View xmi:id="_st_view_unlinked" base_Class="_view_unlinked"/> + <sysml:View xmi:id="_st_view_gallery" base_Class="_view_gallery"/> <sysml:Conform xmi:id="_st_conform_inventory" base_Generalization="_gen_inventory"/> <sysml:Conform xmi:id="_st_conform_spares" base_Generalization="_gen_spares"/> <sysml:Conform xmi:id="_st_conform_details" base_Generalization="_gen_details"/> <sysml:Conform xmi:id="_st_conform_pumping" base_Generalization="_gen_pumping"/> <sysml:Conform xmi:id="_st_conform_unlinked" base_Generalization="_gen_unlinked"/> + <sysml:Conform xmi:id="_st_conform_gallery" base_Generalization="_gen_gallery"/> <sysml:Expose xmi:id="_st_expose_inventory" base_Dependency="_expose_inventory"/> <sysml:Expose xmi:id="_st_expose_spares" base_Dependency="_expose_spares"/> <sysml:Expose xmi:id="_st_expose_overview_table" base_Dependency="_expose_overview_table"/> @@ -177,6 +198,7 @@ <sysml:Expose xmi:id="_st_expose_overview_pump" base_Dependency="_expose_overview_pump"/> <sysml:Expose xmi:id="_st_expose_pumping" base_Dependency="_expose_pumping"/> <sysml:Expose xmi:id="_st_expose_unlinked" base_Dependency="_expose_unlinked"/> + <sysml:Expose xmi:id="_st_expose_gallery" base_Dependency="_expose_gallery"/> <Document_Profile_:Document xmi:id="_st_doc_report" base_Class="_doc_report"/> <Document_Profile_:Image xmi:id="_st_titled_image" base_CallBehaviorAction="_titled_image" showCaptions="true"> @@ -198,6 +220,12 @@ <columnIds>_NUMBER_</columnIds> <columnIds>QPROP:Element:name</columnIds> </MagicDraw_Profile:DiagramTable> + <MagicDraw_Profile:DiagramTable xmi:id="_tbl_blocks" base_Diagram="_diag_blocks" scope="_pkg_structure" includeSubtypesOfRowTypes="true"> + <rowElementType href="http://www.omg.org/spec/SysML/20181001/SysML.xmi#SysML.Block"/> + <columnIds>_NUMBER_</columnIds> + <columnIds>QPROP:Element:name</columnIds> + <sort>QPROP:Element:name^Asc</sort> + </MagicDraw_Profile:DiagramTable> <Document_View_Collaborator_Profile:CollaboratorParagraph xmi:id="_st_inv_note" base_Element="_inv_note" property="META:QPROP:Element:body" documentId="_doc_report" viewId="_doc_report" ownerId="_view_inventory" siblingId="Containment_DiagramMainImage__7c1e4b"/> <Document_View_Collaborator_Profile:CollaboratorParagraph xmi:id="_st_inv_head" base_Element="_inv_head" property="META:QPROP:Element:body" documentId="_doc_report" viewId="_doc_report" ownerId="_view_inventory"/>