diff --git a/NOTICE b/NOTICE index e21a8ae..eca9681 100644 --- a/NOTICE +++ b/NOTICE @@ -9,7 +9,7 @@ license. Application code and legacy repository structure were not imported whol OpenSysML engine source: https://github.com/Open-MBEE/OpenSysML -Pinned source: 08ef397841767b829e492bbecf6d723483b4421e (0.10.0) +Pinned source: 760c4d1fc8edfa4e214423099065c0a7c1b24e2d (0.12.0) Apache-2.0. Local bridge overlays are maintained in engine-bridge/opensysml/. SysML Toolkit: diff --git a/README.md b/README.md index 0e4147c..c0beb19 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,10 @@ OpenMBEE View Editor brings source editing, named model views and reviewed graph **Source owns model semantics. Presentation owns geometry.** Renaming a part or connecting ports produces a reviewed source transaction; moving a symbol changes its position. The application runs locally in browser workers and requires no application backend.

- The editor showing the Smart air workshop pneumatic assembly, with files and named views on the left and an Inspector on the right + The editor showing the Inspection rover architecture, with files and named views on the left and an Inspector on the right

-

Smart air workshop: a pneumatic assembly with typed ports and authored connections.

+

Inspection rover: connected assemblies, declared component values and typed interfaces.

## Features @@ -59,7 +59,7 @@ OpenMBEE View Editor brings source editing, named model views and reviewed graph - **Arrange your workspace.** Resize or collapse Explorer, Inspector, source/diagram panes and Issues using dividers, keyboard controls or panel options. - **Choose your appearance.** Use Light, Dark or Follow system, six diagram palettes and custom muted element colors. Theme and panel preferences stay in the browser. -Structural and native requirement workflows have real-engine browser coverage; behavior, use-case, state, sequence and geometry renderers remain unavailable. See [capabilities and limits](docs/capabilities.md) and [local verification](docs/verification.md) for the implemented scope. +Structural, native requirement and bounded action/state workflows have real-engine browser coverage. Use-case, mixed, sequence and geometry renderers remain unavailable; behavior diagrams do not execute or simulate models. See [capabilities and limits](docs/capabilities.md) and [local verification](docs/verification.md) for the implemented scope. In the source editor, use **Ctrl+F** / **Cmd+F** for find/replace and **Ctrl+Z** / **Cmd+Z** for draft editing undo; **Ctrl+Y** / **Cmd+Shift+Z** redo draft edits. **Tab** moves focus out of the editor. Toolbar **Undo source** / **Redo source** operate on accepted model revisions. Highlighting is lexical; the pinned engines still validate source when you apply it. Use **Ctrl+Space** for engine-backed completion, **F1** for symbol information and **F12** for project-file definitions; pointing at a symbol also requests hover information. Queries use the complete current draft source set. Unsupported completion edits and stale replies are rejected. Formatting and inline diagnostics remain future work. @@ -74,7 +74,7 @@ npm ci npm run dev -- --host 127.0.0.1 ``` -Open the URL printed by Vite. The first dev/build verifies and prepares checksum-pinned browser engines. It needs network access, `tar` and disk space for the Go build cache. Linux x64 can download the pinned Go 1.25.11 toolchain into ignored `sandbox/`; on other platforms, set `SYSML_GO` to that Go version's binary. Later runs verify and reuse prepared assets. See [engine preparation](engine-bridge/README.md). +Open the URL printed by Vite. The first dev/build verifies and prepares checksum-pinned browser engines. It needs network access, `tar` and disk space for the Go build cache. Linux x64 can download the pinned Go 1.27.3 toolchain into ignored `sandbox/`; on other platforms, set `SYSML_GO` to that Go version's binary. Later runs verify and reuse prepared assets. See [engine preparation](engine-bridge/README.md). Vite excludes generated `sandbox/` contents and prepared `static/vendor/` binaries from development watching; app sources remain watched. If an older dev process reported `ENOSPC` from those directories, restart it with the current configuration. @@ -82,16 +82,16 @@ Vite excludes generated `sandbox/` contents and prepared `static/vendor/` binari The landing page offers **Open app** and **Try an example**. `/app` opens a project launcher with **New project**, **Open files or project**, **Load example** and **Local recovery**. No model loads implicitly. New projects stay marked modified until downloaded. -Choose **Load example** to open **Smart air workshop** in Diagram mode. Its nine source files and fourteen named views cover reusable definitions, specialization, attributes, constraints, requirements, actions, states, use cases, sequences and geometry. +Choose **Load example** to open the **Inspection rover** in Diagram mode. Six source files describe four connected assemblies, declared component values, an inspection mission, operating states and quantitative requirements. -The starting **pneumatics** view shows the pump, tank, regulator, relief valve and pressure sensor, with thirteen ports and four connections: +Follow the in-app guide through **architecture → mission → lifecycle → requirements → definitions**. The opening diagram includes a 480 Wh battery, two distinct drives, lidar, controller, camera and radio. Declared values, successions and satisfaction links are model content; the app does not execute or simulate them. -1. Select another named view in Explorer or the diagram’s **Named view** selector to explore its contents. -2. Choose **Split** to see source beside the diagram; selecting a source file keeps the active view open. -3. Edit source and choose **Apply source**. Invalid drafts retain the previous applied model and appear in **Issues**. -4. Choose **Download project** to save your work before closing or reloading. +1. Select another named view in Explorer or the diagram’s **Named view** selector. +2. Choose **Split** to see source; selecting a file keeps the active diagram open. +3. Edit source and choose **Apply source**. Invalid drafts retain the applied model and appear in **Issues**. +4. Choose **Download project** to preserve sources and arranged view geometry. -Six example views have partial structural/definition/tree rendering. The other eight evaluate through the engine and display an explicit unavailable-renderer message. The [model guide](examples/air-compressor/README.md) lists each view's current support. +[Take the rover tour](examples/inspection-rover/README.md). The launcher offers **New project**, **Open files or project**, and the rover example. The compressor sources remain in the example library for explicit imports and regression coverage. ### 3. Try graphical authoring @@ -104,17 +104,23 @@ Use **Open** to select both SysML files from [`examples/assembly/`](examples/ass Connection creation currently requires explicit compatible directions and identical resolved port types. Conjugated and ambiguous endpoint paths are unavailable for graphical creation. [Authoring workflow and supported subset](docs/architecture/graphical-authoring.md). +### 4. Inspect requirements and source + +Choose **Requirements table** above the diagram to browse applied requirement identifiers, documentation and declared links; search or sort the rows and export CSV. **Traceability matrix** shows satisfaction or verification declarations against linked elements, including explicitly negated links. These are read-only views, not executed verification results. Click a requirement or link to reveal its source; apply or discard a changed draft in that file first. + +In the source pane, **Find references** (also Shift+F12) lists project reference locations. **Format source** formats the current draft with Toolkit. Formatting supports local editor undo; **Apply source** validates and accepts it into source history. Failed or stale queries retain your work. + ## Examples Open all `.sysml` files from one example folder together. Each project has its own named views and recorded visual targets. -| Project | Explore | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| [Smart air workshop](examples/air-compressor/README.md) | A complete nine-file example spanning structure, requirements, behavior and geometry; the built-in launcher example | -| [Nested assembly](examples/assembly/) | Nested ownership, repeated usages, inherited ports, a connection and reference notation; the focused authoring fixture | -| [Parts](examples/parts/), [ports](examples/ports/) and [packages](examples/packages/) | Shared definitions, usage contexts, conjugation, imports and exposure | -| [Requirements](examples/requirements/), [use cases](examples/use-cases/), [actions](examples/actions/), [states](examples/states/) and [sequences](examples/sequences/) | Source constructs and named views, including explicit unavailable-renderer cases | -| [Complete example library](examples/README.md) | Comments, documentation, filters, empty views and per-view support gaps | +| Project | Explore | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| [Smart air workshop](examples/air-compressor/README.md) | A complete nine-file example spanning structure, requirements, behavior and geometry; a legacy import and regression fixture | +| [Nested assembly](examples/assembly/) | Nested ownership, repeated usages, inherited ports, a connection and reference notation; the focused authoring fixture | +| [Parts](examples/parts/), [ports](examples/ports/) and [packages](examples/packages/) | Shared definitions, usage contexts, conjugation, imports and exposure | +| [Requirements](examples/requirements/), [use cases](examples/use-cases/), [actions](examples/actions/), [states](examples/states/) and [sequences](examples/sequences/) | Source constructs and named views, including explicit unavailable-renderer cases | +| [Complete example library](examples/README.md) | Comments, documentation, filters, empty views and per-view support gaps | Engine evaluation, application rendering and intended visual quality are recorded separately. Passing an example does not establish full SysML v2 conformance. @@ -137,7 +143,7 @@ React Flow draws the interactive diagrams and ELK supplies automatic layout. The Use **Projects** to return to the launcher and **Resume project** to continue. The live workbench stays mounted while you browse the launcher or landing page, retaining source drafts, accepted history, selection and diagram state. A full reload starts at the launcher; existing browser recovery is offered explicitly. Landing and app share one appearance preference controller. -Folder write-back, Flexo project/branch checkout and automatic diagrams for models without named views remain planned. Opening source imports copies; it does not connect to a folder or remote branch. +Models without named views open a temporary project overview. Choose **Explore model** in the diagram’s view selector to browse project/package scopes or an explicit part definition/usage, with separate scope and representation controls and a full qualified scope caption. Overview boxes show package/ownership hierarchy; parallel declarations have separate relationship labels. Exploration uses applied source, preserves drafts and saved named-view positions, and does not persist its scope or allow graphical edits. Typed usages do not automatically expand their type’s internals. Folder write-back and Flexo project/branch checkout remain planned. Opening source imports copies; it does not connect to a folder or remote branch. **Download project** saves one `.sysml-project.json`; **Open** accepts that bundle or multiple `.sysml` files. Downloads do not write back to the imported folder. **Local recovery** keeps one recovery copy in this browser using IndexedDB, including applied source, unapplied drafts, diagram positions and the active file/view. On reload, choose **Restore recovery** or **Discard recovery** before new work can replace that copy. The dialog can disable local saving or retry storage failures. Browser storage can be cleared; retain downloaded projects as portable backups. Restored work remains modified until downloaded. With saving enabled, returning to a clean project clears this session’s older recovery; a competing tab cannot silently overwrite a newer copy. @@ -178,7 +184,7 @@ npm run test:e2e # fresh build, real engines and visual compa npm run preview -- --host 127.0.0.1 ``` -PR CI runs all fast checks plus thirteen critical browser workflows, adding visual comparisons for code changes. Full browser coverage runs weekly, manually and for release/main/hotfix PRs and pushes. Run `npm run test:e2e -- --project=functional --grep @critical` to reproduce the smaller browser gate; `npm run test:e2e` still runs everything. See [CI coverage](docs/testing.md#ci-coverage) for selection and deferred coverage. +PR CI runs all fast checks plus fourteen critical browser workflows, adding visual comparisons for code changes. Full browser coverage runs weekly, manually and for release/main/hotfix PRs and pushes. Run `npm run test:e2e -- --project=functional --grep @critical` to reproduce the smaller browser gate; `npm run test:e2e` still runs everything. See [CI coverage](docs/testing.md#ci-coverage) for selection and deferred coverage. Functional checks can use installed Chrome with `PLAYWRIGHT_BROWSER_CHANNEL=chrome npm run test:e2e -- --project=functional`. Visual comparisons always use bundled Chromium and reviewed platform-specific baselines. The browser suite starts its own fresh production server; port **4175** must be free. See [browser testing and baseline review](docs/testing.md). diff --git a/docs/README.md b/docs/README.md index 9a93239..34ca682 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,9 +27,12 @@ OpenMBEE View Editor is a sandbox browser workspace for SysML v2 source and name | [Identity and layout preservation](architecture/identity-reconciliation.md) | Identity domains, verified edits and conservative invalidation | | [Project format](architecture/project-format.md) | Portable version-1 data and compatibility rules | +- [Read-only requirements data](architecture/requirements-data.md): source-bound tables, matrices, CSV and preservation rules. +- [OpenSysML 0.12 compatibility](research/opensysml-0.12-compatibility.md): ported overlay seams and real-worker evidence. + ## Planned work and research -- [Model exploration](architecture/model-exploration.md): agreed behavior for browsing models without authored views; not implemented. +- [Model exploration](architecture/model-exploration.md): bounded read-only browsing without authored views and its remaining opening/persistence work. - [Exploration engine investigation](research/automatic-exploration-contract.md): pinned renderer/WASM evidence and the next contract boundary. - [Flexo MMS integration](research/openmbee-flexo-mms.md): dated source research and unresolved interchange/concurrency gates; not implemented. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index c8cdc02..65f813a 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -34,7 +34,7 @@ The [identity investigation](identity-reconciliation.md) compares real worker ou ## Views -The [agreed exploration direction](model-exploration.md) separates temporary scope/representation browsing from authored named views. It is planned; the current implementation described below requires a named view. The [domain glossary](../../CONTEXT.md) defines these terms. +The [agreed exploration direction](model-exploration.md) separates temporary scope/representation browsing from authored named views. The bounded read-only slice supports project/package overviews and explicit part subjects without a named view; automatic system-root selection and browsing-context persistence remain planned. The [domain glossary](../../CONTEXT.md) defines these terms. Named views are actual SysML usages. Open existing views without authoring another declaration. New view takes a subject, representation and name; package ownership is advanced configuration. Definitions, exposure and filters remain source content. The canvas is a derived artifact and does not serialize the model. diff --git a/docs/architecture/application-shell.md b/docs/architecture/application-shell.md index 431559c..90d08ef 100644 --- a/docs/architecture/application-shell.md +++ b/docs/architecture/application-shell.md @@ -5,10 +5,10 @@ Current implementation, updated 2026-11-01. The application uses the shared Geis ## Entry and opening - `/` presents the product, documentation/feedback/license links, **Open app** and **Try an example**. -- `/app` starts an empty launcher with **Load example**, **New project**, **Open files or project** and **Local recovery**. An existing live session adds **Resume project**. +- `/app` starts an empty launcher with **New project**, **Open files or project**, the inspection-rover **Load example** choice and **Local recovery**. An existing live session adds **Resume project**. - `/app/?example=1` requests the example once and is canonicalized to `/app` before opening; reload does not repeat the example action. Static hosting must serve the SPA fallback for `/app` while preserving asset paths. -No model loads implicitly. The built-in example opens its pneumatic named view in Diagram mode. New projects open source and remain modified until downloaded. Imports are copies; Download exports a portable bundle. Recovery is a separate browser copy, not a disk save. +No model loads implicitly. The default inspection-rover example opens its architecture named view in Diagram mode and provides a five-view guide. Compressor sources are not bundled in the app; regression tests import them explicitly. New projects open source with a temporary project overview and remain modified until downloaded. Imports without named views also open an overview; imports with named views retain their existing selection. Imports are copies; Download exports a portable bundle. Recovery is a separate browser copy, not a disk save. Applied source is validated before replacing the project. Replacing dirty work requires confirmation. Cancelled or failed replacement retains current source, drafts, model, history and compatible presentations. Failed operations remain visible and can be retried. @@ -20,7 +20,7 @@ One appearance provider owns preferences across all surfaces. Dialogs close when ## Workbench grouping -The app bar groups product/home navigation, Projects, project name/origin and persistence/settings actions. Source transaction history, apply/discard and Code/Split/Diagram controls use the measured toolbar layout. Sidebar toggles belong to their tools. The diagram header selects an actual **Named view**, displays its supported representation and applied/draft status, and keeps authoring actions nearby. Source-file selection does not replace the diagram's named view. +The app bar groups product/home navigation, Projects, project name/origin and persistence/settings actions. Source transaction history, apply/discard and Code/Split/Diagram controls use the measured toolbar layout. Sidebar toggles belong to their tools. The diagram header selects an actual **Named view** or **Explore model**. Exploration has separate scope and representation controls, uses applied source, and disables graphical authoring/movement. Named views retain their supported representation, applied/draft status and authoring actions. Source-file selection does not replace the diagram scope. See [layout contracts](workbench-layout.md), [source/recovery policies](source-assistance-recovery.md) and [project format](project-format.md). [Testing](../testing.md) and [verification](../verification.md) own checks and observed results. diff --git a/docs/architecture/model-exploration.md b/docs/architecture/model-exploration.md index 090920c..dcd4642 100644 --- a/docs/architecture/model-exploration.md +++ b/docs/architecture/model-exploration.md @@ -1,6 +1,22 @@ # Model exploration and named views -Agreed direction, 2026-11-01. This document describes planned behavior, not an implemented capability. Current diagrams still require named SysML view usages. +Agreed direction, 2026-11-01; first bounded implementation, 2026-11-05. Project/package overview trees and explicit part-definition/usage tree or structural projections are implemented on `feature/automatic-exploration`. The broader opening policy and follow-ups below remain planned. + +## Current bounded slice + +Imports without named views open a project overview; imports with named views retain their existing opening behavior. **Explore model** in the named-view selector opens temporary browsing. Separate **Exploration scope** and **Exploration representation** controls select project roots, a package or an explicit part declaration. Project/package scopes support overview trees; explicit part definitions/usages also support structural projection. Typed usages are not automatically unfolded and show that limit explicitly. No automatic system-root inference is claimed. + +Ownership overviews now draw nested packages and declarations from the native artifact's parent identities. Relationship endpoints do not determine ownership: for example, `Q` owns `Motor`, while `P::System` owns its `primary` and `standby` usages. Parallel declarations retain independent correspondence and keyboard/source inspection. Generated ownership layouts reserve right-side routing gutters within the nearest shared owner, with separate rectilinear tracks and label rows clear of declaration bodies and headers. Cross-root routes use the gaps between roots or space below intervening roots. Camera fitting includes route and label bounds. This routing depends on the generated vertical ownership layout; it is not a general router for arbitrary manually positioned diagrams. + +Exploration uses compact 52 px leaf cards without empty bodies, reduced containment padding and side-by-side leaf siblings in structural scopes. Named-view dimensions and stored geometry are unchanged. Visible Scope/Representation labels and a wrapping full qualified scope caption distinguish Part definition from Part usage, including long names at narrow widths. Selecting a diagram declaration highlights its source range without moving keyboard focus into the editor; explicit Open source still focuses the editor. + +`workspace/exploration` owns closed request/result schemas bound to the applied model hash. The semantic worker reparses the complete applied source set and calls the validated combined `Explore` bridge, which uses native `RenderExposed` without adding source. Named views and exploration share artifact/source normalization, but the exploration envelope has no authored `view` field. Source origins, topology, representation and request identity are checked before publication. + +`WorkspaceState.exploration` is independent of `view` and portable presentations. Success switches the active diagram without changing project data, dirty state, revision or semantic history. Failure, cancellation and superseded requests retain current work; draft changes invalidate pending work. Graphical movement and semantic authoring are disabled in exploration. Selection can reveal a source declaration, and source-file navigation does not change scope. Opening a named view exits exploration and restores its own saved geometry. Accepted source revisions clear the revision-bound exploration and use the opening fallback on refresh. + +Before an exploration succeeds, the empty diagram offers an explicit **Explore model** action. It retries the applied project overview independently of the already-selected navigation option, stays disabled while busy and focuses Scope on success. This preserves recovery from an initial engine failure without reopening or changing source. + +Scope, camera and exploration layout are transient and are not saved in portable bundles or local recovery. Reopening falls back to the existing named-view preference or the project overview. Reviewed named-view creation from exploration, browsing-context persistence, automatic root detection and additional representation families remain follow-ups. Opening is exploration; creating a named view is authorship. Users should be able to inspect an imported project without first changing its source. The [domain glossary](../../CONTEXT.md) distinguishes source files, the project model, scopes, representations, explorations, named views, diagram presentations and cameras. @@ -41,4 +57,4 @@ Follow-up slices are explicit creation of a named view from exploration, storage ## Engine evidence -The [pinned-engine investigation](../research/automatic-exploration-contract.md) executed the current WASM boundary and native renderer. Declaration-free project/package rendering has a promising underlying seam, but the browser contract still requires named views. A typed system usage did not automatically unfold its definition’s internals. The first delivered exploration must therefore use an overview until structural subject/usage expansion is proven, rather than promise an inferred full-system diagram. +The [pinned-engine investigation](../research/automatic-exploration-contract.md) executed the current WASM boundary and native renderer. The investigation originally identified a declaration-free project/package seam; the bounded implementation now exposes it through a separate browser contract. A typed system usage did not automatically unfold its definition’s internals. The first delivered exploration must therefore use an overview until structural subject/usage expansion is proven, rather than promise an inferred full-system diagram. diff --git a/docs/architecture/requirements-data.md b/docs/architecture/requirements-data.md new file mode 100644 index 0000000..ea2b97a --- /dev/null +++ b/docs/architecture/requirements-data.md @@ -0,0 +1,13 @@ +# Read-only requirements data + +The requirements table and traceability matrix inspect the complete **applied** source set, independently of named diagram exposure. They do not create a view declaration, modify source, evaluate requirements or write presentation data. Unapplied drafts remain unchanged and are called out explicitly. + +`workspace/requirements` owns the TypeBox response schema and source-bound validation. The browser semantic worker parses the exact applied documents, checks the model hash and calls the retained OpenSysML `EvaluateRelationships` bridge. The combined server validates that cached model before inspection. The bridge returns requirement definitions/usages (excluding verification objectives), declared identifiers and documentation, and resolved allocation/satisfaction/verification endpoints. Assertion and negation are retained separately. Library-only/unlocated endpoints cannot pass the project-origin validator. Incomplete extraction returns no rows or links; an unknown result is never presented as an empty matrix. + +The workspace checks the applied model and source references again after the asynchronous request. Switching projects, applying source or disposing the workspace rejects a late result. Component lifetime cancellation terminates the disposable worker. Read-only inspection does not take over transaction history or the active diagram. + +The table supports name/identifier/documentation filtering, name sorting and CSV export. The matrix supports satisfaction and verification, with requirements on rows and **linked** elements on columns. Matrices above 10,000 cells require narrowing the requirement search before display/export. Unlinked requirements remain rows; this first version does not enumerate all possible satisfiers, edit relationships, compute transitive links or infer compliance. Repeated local names remain separate by exact declaration identity. Multiple declarations in one cell remain separate, including negated assertions. + +Selecting a row, column or link reveals its exact recorded declaration. Source reveal is refused when that file's draft differs from applied source, rather than applying an old byte range to new text. The diagram stays mounted while data is shown, retaining its camera and presentation. CSV and read-only display settings do not alter the portable version-1 bundle. + +Selecting a named view returns to the diagram. Successfully opening or restoring a project also starts in diagram mode; failed operations retain the current representation. diff --git a/docs/architecture/source-assistance-recovery.md b/docs/architecture/source-assistance-recovery.md index 9b0ac6b..a127b2d 100644 --- a/docs/architecture/source-assistance-recovery.md +++ b/docs/architecture/source-assistance-recovery.md @@ -4,6 +4,8 @@ The workbench composes a separate Toolkit language adapter in `shell/session.ts` Completion is explicit with Control+Space. Only supported single plain-text edits are offered; snippets, additional edits and commands are excluded. Hover and F1 display plain text. F12 navigates to one exact project-file definition; unavailable, ambiguous and library-only targets explain their limits. The editor rejects results after draft, session, file or busy-state changes and rechecks completion validity when applying it. Queries do not apply source, change model history or replace views. +Diagram selection can highlight a declaration passively without moving focus away from the diagram. Explicit Open source, issue navigation and definition navigation focus the editor. This distinction prevents the Enter key used to select a diagram relationship from being inserted into the source editor during the same key event. + ## Issue navigation OpenSysML's pinned ParseSources response supplies diagnostic `span.file` and one-based `span.startLine`. The existing semantic worker normalizes those to optional `file` and `line`. The workbench selects a whole reported line, retaining raw UTF-16 offsets and CRLF/CR/LF boundaries; it does not claim a column or token range that this transport does not carry. @@ -27,3 +29,9 @@ A startup record blocks replacement until an explicit restore/discard choice. Re Writes and deletion use an atomic compare-and-swap transaction against the exact previously read string. A competing tab's change causes a visible conflict; retry reads and protects that copy for an explicit choice. Denied storage and write failures retain the editable project and permit retry. Malformed or future string envelopes remain protected until explicit discard. An unsupported non-string IndexedDB value is retained and reported unavailable; this version cannot discard that value through the app. Only one recovery copy is kept, without a time-based expiration. Disabling saving retains the existing record until discarded. Browser storage is local to the origin and may be cleared or unavailable; downloaded project files remain the portable backup mechanism. + +## References and formatting + +The same draft-bound Toolkit language worker also handles `textDocument/references` (without the declaration) and `textDocument/formatting`. Reference ranges are normalized from UTF-16 LSP positions, limited to project documents and displayed with file/line locations; omitted library locations are counted explicitly. Navigating a result rechecks the originating source-set lifetime. + +Formatting accepts only in-bounds, nonoverlapping text edits. It retains the file's line-separator convention and updates the editor as one undoable draft transaction. Formatting does not accept a semantic model, replace other files or bypass Apply source. Draft edits, file changes, busy state and session replacement invalidate outstanding language results; failure keeps source and accepted history unchanged. diff --git a/docs/architecture/structural-views.md b/docs/architecture/structural-views.md index da66762..e7a9ac6 100644 --- a/docs/architecture/structural-views.md +++ b/docs/architecture/structural-views.md @@ -37,7 +37,7 @@ This slice establishes the fixture's nested interconnection workflow. GeneralVie ## Planned edge interactions and routing -The broader structural/tree interaction and routing follow-up remains planned. Native requirement graphs now support pointer/keyboard relationship selection and exact endpoint/source inspection. Context7 MCP documentation was checked against the installed React Flow implementation on 2026-11-01. +The broader structural/tree interaction and routing follow-up remains planned. Native requirement graphs now support pointer/keyboard relationship selection and exact endpoint/source inspection. Read-only exploration also has ownership grouping, distinct parallel relationship lanes and exact edge inspection; its compact geometry is separate from named-view presentations. Context7 MCP documentation was checked against the installed React Flow implementation on 2026-11-01. Current projections use ELK node placement and React Flow `smoothstep` edges; they do not consume ELK edge sections. The structural layout does not pass actual port geometry to ELK, and saved node positions can replace the initially calculated coordinates. Adopting initial ELK routes without reconciling those positions would leave stale paths. @@ -46,3 +46,17 @@ Extend controlled edge selection, keyboard focus/selection styling and descripti Built-in smoothstep edges already pass the [path helper's label coordinates](https://reactflow.dev/api-reference/utils/get-smooth-step-path) to SVG labels, and [BaseEdge](https://reactflow.dev/api-reference/components/base-edge) already supplies an invisible 20 px hit area. Measure selection at representative zoom levels before choosing a larger hit width. Keep plain labels in SVG; use [EdgeLabelRenderer](https://reactflow.dev/api-reference/components/edge-label-renderer) only for richer or interactive HTML, with explicit pointer events and `nodrag nopan`. Complete [edge descriptions and focus behavior](https://reactflow.dev/learn/advanced-use/accessibility) without implying full accessibility coverage. Obstacle-aware routing requires a separate contract: accurate port coordinates/directions, nested coordinate transforms, parallel edges, labels on sufficiently long clear segments and rerouting after saved-position restoration or movement. Rerouting must retain manually positioned nodes. Test exact endpoint attachment, source/history preservation, pointer/keyboard movement and save/reopen before adopting those routes. Rounded corners alone do not provide obstacle avoidance. + +## Declared values and behavior projection + +Structural artifacts may carry `attributeDeclarations`, exact source slices of effective attribute members returned by the pinned semantic model. The bridge excludes library declarations and retains authored text; it does not parse rendered detail strings, evaluate defaults or infer measured values. Named structural diagrams with at least ten artifact nodes use 240-unit cards, 32-unit attribute rows and reduced layout gaps; smaller diagrams and exploration retain their existing sizing. Simple scalar defaults display as `capacityWh: Real = 480`, removing declaration syntax and redundant fractional zeros. Complex declarations retain their source spelling. Exact declaration text remains available in each row title; this formatting never evaluates expressions or changes source. + +Native `action` and `state` artifacts use the existing compound layout and portable absolute positions. The shared graph validator rejects missing/cyclic parents and unresolved node/port endpoints. Initial/final nodes have explicit terminal notation; state/action bodies remain distinct from the structural authoring commands. Directed successions/transitions retain native identities, source correspondence and labels. Behavior ports have fixed sides; ELK's container-relative orthogonal sections and label positions are translated into canvas coordinates. Initial/final nodes constrain first/last layers. Anonymous successions and the initial-state arrow have no invented labels. Routing follows the generated layout; arbitrary obstacle avoidance after manual movement is not established. They are declared graphs, without execution or simulation. Other representation families remain separately gated. + +## Named structural connection routing + +Named interconnection diagrams now supply ELK with native port identities, physical sides, fixed port-row coordinates and label-size reservations before placement/routing. Leaf rows share their body's spare height; logical ownership does not imply DOM nesting. Compound-owned rows retain their fixed heights. The workbench renders the resulting orthogonal sections and label positions through the same `RoutedEdge` adapter used by behavior diagrams, translating each route from its layout container's coordinates. It no longer discards those routes in favor of endpoint-only smooth-step paths. + +Physical handle tips remain the visible endpoints. Direct links retain orthogonal bends when those endpoints differ from layout points. Routes and labels follow container translation during assembly movement; compatible version-1 node positions and semantic endpoints remain independent of generated routes. No source or project-format field is added. Complete obstacle rerouting after arbitrary individual-node movement remains a separate follow-up; this change establishes the generated layout and translated-assembly cases. + +The rover regression samples actual SVG geometry against every leaf card and header, plus the entire rectangle of each compound unrelated to the connection's native endpoints. This explicitly prevents unrelated power/command lines passing through inspection's empty interior. It checks ten named labels, overlap/viewport bounds and orthogonal sections, then verifies exact source and positions through assembly movement and portable reopen. Desktop/narrow captures cover both themes. diff --git a/docs/capabilities.md b/docs/capabilities.md index 54b20af..8ea12a5 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -2,23 +2,24 @@ This catalogue describes the current implementation. Check [verification](verification.md) for checks actually performed; a feature description alone is not test evidence. -| Workflow | Support | Limit | -| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Entry and shell | OpenMBEE landing, explicit project launcher, example/new/import/recovery actions; live-session resume and browser navigation | Folder write-back, remote checkout and automatic declaration-free exploration remain planned | -| Project sources | Multiple imported SysML files resolve together | 20 files / 5 MiB per source set; one active source editor | -| Source editing | CodeMirror lexical highlighting, line numbers, bracket matching/closing, indentation, find/replace, draft history, apply/discard and accepted source history | No formatting, inline diagnostics or syntax-aware folding; unsafe declaration/context matches lose affected positions | -| Source assistance | Toolkit completion, hover and project-file definitions bound to the full current draft source set | Explicit plain-text completion only; snippets/additional edits, ambiguous and library-only definition targets are unavailable | -| Named views | Open declarations; create GeneralView/InterconnectionView usages in a package | Custom filters/definitions remain editable in source | -| Diagram rendering | Native requirement graphs with identifiers, documentation, typing, direct/type-based ownership and declared positive/negated satisfaction; flat tree views; nested interconnection parts, ports and resolved endpoints | Requirement constraints/subject compartments, generalized typed-instance trace expansion, owned-port boundary notation, effective conjugated directions, modeled layout/routes and behavior/sequence/geometry renderers remain future work | -| Semantic editing | Reviewed part definition/usage rename, native view creation, typed part creation and directed same-type port connections | Simple identifiers and qualified names; explicit out/inout to in/inout with identical resolved types; conjugated, ambiguous or undirected endpoints, reconnect, delete, duplicate and behavior editing are unavailable | -| Model navigation | Expandable ownership tree, declared members, type/kind labels, keyboard navigation and diagram-selection reveal | View declarations use the separate Views list; inherited members are not cloned beneath usages; no full-model search or virtualized rendering | -| Source navigation | Recorded origin range from diagram/port selection, snapshot-bound Issues file/line navigation and explicit file-only handling | Explorer-only selection does not yet resolve a source range; stale, unknown and out-of-range diagnostic locations are explained without guessing | -| Diagram presentation | Move nested groups, retain descendant positions, reconcile compatible positions across reviewed edits and bounded source changes, save/reopen and refit panes | Changed bindings, headers, owners or ambiguous correspondence invalidate affected positions; manual overlap avoidance, parent resizing, camera persistence, route edits and independent layout undo remain future work | -| Workbench panels | Resizable Explorer, Inspector, source/diagram split and Issues; collapse/restore, keyboard and button resizing, reset and browser layout preferences | Full docking/floating windows are unavailable; preferences do not recover source work | -| Save/reopen | Version-1 bundle of source, drafts and positions; local recovery with explicit restore/discard and active file/view | One IndexedDB recovery copy per browser origin; storage can be cleared or unavailable; no camera persistence or folder write-back | -| Runtime validation | TypeBox project/command/message/engine schemas | Schema validity is separate from SysML semantic validity | -| Appearance | Shared shadcn/Radix controls; Light/Dark/System, six palettes and custom muted package/part fills | Browser preferences, separate from project files; detailed port color controls remain unavailable | -| Execution | No simulation controls | Rendered behavior or declared verify links would not imply execution results | +| Workflow | Support | Limit | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Entry and shell | OpenMBEE landing, explicit project launcher, example/new/import/recovery actions; live-session resume and browser navigation | Folder write-back and remote checkout remain planned; exploration scope/camera are transient | +| Model exploration | Read-only nested project/package ownership overview and explicit part-definition/usage tree or compact structural projection without view declarations; independent source-file navigation | No inferred system root, automatic typed-instance expansion, exploration persistence or graphical editing | +| Project sources | Multiple imported SysML files resolve together | 20 files / 5 MiB per source set; one active source editor | +| Source editing | CodeMirror lexical highlighting, line numbers, bracket matching/closing, indentation, find/replace, Toolkit draft formatting, draft history, apply/discard and accepted source history | Toolkit formatting remains a draft until apply; no inline diagnostics or syntax-aware folding; unsafe declaration/context matches lose affected positions | +| Source assistance | Toolkit completion, hover, project-file definitions and references bound to the full current draft source set | Explicit plain-text completion only; snippets/additional edits, ambiguous and library-only definition targets are unavailable | +| Named views | Open declarations; create GeneralView/InterconnectionView usages in a package | Custom filters/definitions remain editable in source | +| Diagram rendering | Native requirement graphs with identifiers, documentation, typing, direct/type-based ownership and declared positive/negated satisfaction; flat tree views; nested interconnection parts, declared attribute compartments, ports and resolved endpoints; bounded native action/state diagrams with initial/final nodes and directed relationships | Requirement constraints/subject compartments, generalized typed-instance trace expansion, owned-port boundary notation, effective conjugated directions, modeled layout/routes and case/mixed/sequence/geometry renderers remain future work | +| Semantic editing | Reviewed part definition/usage rename, native view creation, typed part creation and directed same-type port connections | Simple identifiers and qualified names; explicit out/inout to in/inout with identical resolved types; conjugated, ambiguous or undirected endpoints, reconnect, delete, duplicate and behavior editing are unavailable | +| Model navigation | Expandable ownership tree, declared members, type/kind labels, keyboard navigation and diagram-selection reveal | View declarations use the separate Views list; inherited members are not cloned beneath usages; no full-model search or virtualized rendering | +| Source navigation | Recorded origin range from diagram/port selection, snapshot-bound Issues file/line navigation and explicit file-only handling | Explorer-only selection does not yet resolve a source range; stale, unknown and out-of-range diagnostic locations are explained without guessing | +| Diagram presentation | Move nested groups, retain descendant positions, reconcile compatible positions across reviewed edits and bounded source changes, save/reopen and refit panes | Changed bindings, headers, owners or ambiguous correspondence invalidate affected positions; manual overlap avoidance, parent resizing, camera persistence, route edits and independent layout undo remain future work | +| Workbench panels | Resizable Explorer, Inspector, source/diagram split and Issues; collapse/restore, keyboard and button resizing, reset and browser layout preferences | Full docking/floating windows are unavailable; preferences do not recover source work | +| Save/reopen | Version-1 bundle of source, drafts and positions; local recovery with explicit restore/discard and active file/view | One IndexedDB recovery copy per browser origin; storage can be cleared or unavailable; no camera persistence or folder write-back | +| Runtime validation | TypeBox project/command/message/engine schemas | Schema validity is separate from SysML semantic validity | +| Appearance | Shared shadcn/Radix controls; Light/Dark/System, six palettes and custom muted package/part fills | Browser preferences, separate from project files; detailed port color controls remain unavailable | +| Execution | No simulation controls | Rendered behavior or declared verify links would not imply execution results | This repository does not claim full SysML v2 language, notation, execution or interchange conformance. @@ -28,6 +29,8 @@ Use **New part** and **Connect ports** in supported structural diagrams, or the Layout reconciliation checks every saved view, including inactive views. Verified commands use source-bound UTF-8 splices; free-form edits use conservative replacement ranges and require unchanged engine-recorded declaration headers, complete usage contexts and compatible resolved bindings. Unknown or ambiguous mappings are invalidated explicitly. This is bounded preservation, not a general persistent-identity guarantee. See [the implemented policy](architecture/identity-reconciliation.md) and [authoring contracts](architecture/graphical-authoring.md). -The explicit **Load example** project is [Smart air workshop](../examples/air-compressor/README.md): nine source files and fourteen named views, opening its pneumatic assembly in Diagram mode. Six views render partial structural/definition/tree representations; eight behavior/sequence/geometry views evaluate but report unavailable renderers. The simpler Workshop remains a separate example. +The explicit **Load example** project is [Inspection rover](../examples/inspection-rover/README.md): six source files and five guided views covering architecture, mission, lifecycle, requirements and component definitions. All five render bounded projections; their catalog entries retain limits. Inspection rover is the only bundled example; the legacy [Smart air workshop](../examples/air-compressor/README.md) remains available for explicit source import and regression coverage. Declared attributes show effective source members, with shorter labels for simple scalar defaults and exact declaration text on hover. Larger named structural diagrams use narrower cards and tighter spacing. Values are not evaluated. Action/state graphs show authored behavior, not execution. The [visualization example library](../examples/README.md) records representative models, named views, intended visual quality and current gaps. Its checks distinguish successful engine evaluation from application rendering; use-case, package-graph and other unsupported renderer fixtures remain explicit development targets. + +The read-only requirements table inspects all applied requirement declarations, identifiers, owned documentation and declared satisfaction/verification links. It supports search, name sorting and CSV export. The traceability matrix uses requirements as rows and linked satisfiers/verifiers as columns, retaining exact declaration identities and negation. It does not infer missing relationships, compute verification verdicts or edit model content. Unknown/incomplete extraction is unavailable rather than an empty result. Source reveal requires that file's draft to match applied source. [Requirements data contract](architecture/requirements-data.md). diff --git a/docs/next-steps.md b/docs/next-steps.md index 2b98205..1a2f8c2 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -1,20 +1,20 @@ # Next steps -Updated 2026-11-04. This is the forward plan; [capabilities](capabilities.md) describes shipped behavior and [verification](verification.md) records actual checks. No delivery dates are assigned. +Updated 2026-11-05. This is the forward plan; [capabilities](capabilities.md) describes shipped behavior and [verification](verification.md) records actual checks. No delivery dates are assigned. ## Current position -The editor supports reviewed rename/view/part/connection commands, bounded layout preservation, CodeMirror source editing and draft language queries, issue navigation, resizable panels and explicit browser recovery. The OpenMBEE landing → launcher → workbench flow is implemented. Opening the app does not load an example implicitly. Current diagrams require authored named views. +The editor supports reviewed rename/view/part/connection commands, bounded layout preservation, CodeMirror source editing and draft language queries, issue navigation, resizable panels and explicit browser recovery. The OpenMBEE landing → launcher → workbench flow is implemented. Opening the app does not load an example implicitly. The in-progress `feature/automatic-exploration` slice also opens project/package overviews and explicit part subjects without authored views. ## 1. Requirements and satisfaction diagrams -The active feature branch `feature/requirements-traceability` implements a bounded [native requirements renderer](architecture/requirements-views.md), with identifiers/documentation, typing/ownership, declared satisfaction polarity, source reveal and presentation preservation. The capacity and traceability fixtures establish this subset; the compressor requirements view remains a generic tree. Complete review and validation before integrating. Declared satisfaction is not a test result. Verification coverage and graphical relationship authoring remain separate follow-ups. +Merged into `develop` through PR #10 on 2026-11-05: a bounded [native requirements renderer](architecture/requirements-views.md), with identifiers/documentation, typing/ownership, declared satisfaction polarity, source reveal and presentation preservation. The capacity and traceability fixtures establish this subset; the compressor requirements view remains a generic tree. Declared satisfaction is not a test result. Verification coverage and graphical relationship authoring remain separate follow-ups. ## 2. Read-only automatic exploration -Separate planned track: inspect an imported model without adding view declarations. Use a project/package overview when a system subject is ambiguous; offer structural exploration only where the engine demonstrates the intended definition or usage expansion. +The first bounded slice inspects an imported model without adding view declarations. Use a project/package overview when a system subject is ambiguous; offer structural exploration only where the engine demonstrates the intended definition or usage expansion. -The [engine investigation](research/automatic-exploration-contract.md) is complete for its bounded probes: native `RenderExposed` is promising, but the browser worker currently exposes only named-view evaluation. The tested typed usage did not unfold its definition's internals. Implement and validate that contract before promising a consolidated system diagram. +The [engine investigation](research/automatic-exploration-contract.md) is complete for its bounded probes: native `RenderExposed` now has a separate validated browser-worker operation for overview and explicit structural subjects. The tested typed usage did not unfold its definition's internals. Automatic system-root selection and generalized usage expansion still need proof before promising a consolidated system diagram. Acceptance is owned by the [exploration plan](architecture/model-exploration.md): @@ -40,10 +40,12 @@ Publication follows only after one reviewed command preserves repository UUIDs a ## Follow-on usability and rendering -The next diagram-polish slice is [edge interaction and routing](architecture/structural-views.md#planned-edge-interactions-and-routing): verify controlled selection, accessible endpoint descriptions, readable labels and hit targets before adopting obstacle-aware routes. Existing smoothstep labels and hit areas already provide part of that behavior. +Named structural and bounded action/state diagrams now retain generated orthogonal routes and labels. Follow-on [edge interaction and routing](architecture/structural-views.md#planned-edge-interactions-and-routing) work includes accessible endpoint descriptions, hit targets and obstacle rerouting after arbitrary individual-node movement. Read-only exploration retains its separate routing geometry. - Consistent selection and source/canvas reveal, including Explorer-only selection; model-wide search and discoverable language shortcuts. - Broader structural notation, effective conjugated directions and new representation families, each backed by actual engine/renderer evidence. - Accessibility and large-model performance work based on measured workflows. Keep arbitrary source reconciliation conservative; general persistent identity, independent layout undo, camera persistence, reconnect/delete/duplicate and behavior editing remain gaps. Use the [authoring contract](architecture/graphical-authoring.md), [identity policy](architecture/identity-reconciliation.md) and [example targets](../examples/README.md) when extending them. Completed assembly milestones and earlier delivery sequences are retained in Git history, not repeated as pending work. + +The inspection-rover showcase now has bounded named action/state rendering and effective declared-attribute compartments. Remaining representation work includes case/mixed/sequence/geometry families and richer notation, each requiring independent engine and renderer evidence; execution and behavior editing remain separate work. diff --git a/docs/research/automatic-exploration-contract.md b/docs/research/automatic-exploration-contract.md index 9ccdae5..1db4c03 100644 --- a/docs/research/automatic-exploration-contract.md +++ b/docs/research/automatic-exploration-contract.md @@ -2,13 +2,15 @@ Recorded 2026-11-01. This is a bounded investigation for the [exploration plan](../architecture/model-exploration.md), not an implemented application capability. No engine pins, production dependencies, worker protocols or project formats changed during this investigation. +Implementation update, 2026-11-05: the separate validated `Explore` bridge and workspace contract now implement the bounded seam described here. See [current scope](../architecture/model-exploration.md#current-bounded-slice). The investigation and probe results below describe the earlier boundary. + ## Finding The pinned OpenSysML renderer already has a source-preserving `RenderExposed` entry point for rendering selected model elements without a view declaration. It is the preferred initial seam for a validated read-only exploration adapter because it uses the same parser, origins and rendering machinery as named views. It is not currently exposed through the application's combined WASM/worker contract. The actual Toolkit WASM also produces a whole-project graph without view declarations. That establishes an alternative projection capability, but its UUID graph identities and source locations do not satisfy the existing OpenSysML correspondence contract automatically. Switching renderers would require a separately verified identity join. -## What the application exposes today +## Application boundary at investigation time - `SemanticEngine` in `src/lib/workspace/editing/index.ts` exposes `parse` and `evaluate(documents, modelHash, view, signal)`. - `src/lib/engines/workers/schema.ts` allows only parse/evaluate semantic operations. The adapter in `src/lib/engines/opensysml/index.ts` validates an evaluation against the requested model hash and named-view identity. diff --git a/docs/research/opensysml-0.12-compatibility.md b/docs/research/opensysml-0.12-compatibility.md new file mode 100644 index 0000000..0a1360e --- /dev/null +++ b/docs/research/opensysml-0.12-compatibility.md @@ -0,0 +1,34 @@ +# OpenSysML 0.12 compatibility evaluation + +Candidate: stable [v0.12.0](https://github.com/Open-MBEE/OpenSysML/releases/tag/v0.12.0), commit `760c4d1fc8edfa4e214423099065c0a7c1b24e2d`. Evaluation started 2026-11-06 against this editor's pinned 0.10.0 bridge. This document records local evidence; upstream release assertions are not application test results. + +## Build and provenance + +The source archive SHA-256 is `49939c5f4c3008cdcf79d39a976719f67aab9d38bb38b246832eb1fdd90de08e`. The build uses Go 1.27.3 (Linux amd64 archive SHA-256 `65efbc80fdc1fa2cd2631cad5b481777a68e8725f34e89f50fc6ac714e379b40`) in ignored `sandbox/opensysml-012/`. The preparation script retains archive, overlay, toolchain, builder, binary and JS runtime provenance and runs the combined/engine Go packages before compiling WASM. Toolkit stays at 0.10.10. + +## Compatibility changes discovered + +- `Rendering.DataFor` became `DataWith`; native `marshalRenderView` now takes the cached model. +- The new renderer hides optional typing/labels by default. The bridge requests the full semantic notation needed by this app; presentation remains owned by the browser renderer. +- Native tree/interconnection rendering expands inherited usages and synthesizes satisfy-subject members. The bridge retains the existing explicit ownership projection, excluding inherited occurrences and AST-identified synthetic subjects. Named-view selected roots/exposure remain native. +- Interconnection renderings expand inherited attributes and newly include constraint nodes. The bridge retains the existing structural subset and its source-backed effective attribute compartments. Explicit owned attributes keep their prior projection. +- Native JSON omits empty node/edge arrays and anonymous node/port names. The transport canonicalizes these as arrays and empty strings without inventing declarations. +- Composition member labels and satisfaction keywords changed. The bridge retains the composed member's label and carries satisfaction kind/polarity separately from the authored relationship name. +- Extra native nodes shift ordinal IDs even though the model hash is unchanged. After the compatibility projection, node/port IDs and all dependent endpoints are renumbered in retained declaration order. Declared structural connectors whose native endpoints are inside pruned inherited children retain their nearest recorded visible component boundaries; hidden internal pins are not invented as boundary ports, and inherited internal connectors remain excluded. The old-engine correspondence fixture checks exact source origins, complete usage contexts and IDs across four example projects. +- Native tree edge ordering changed. The exploration regression now binds its source-identity assertion to the actual declaration rather than a fixed edge index; its exact source assertions remain. + +## Compatibility evidence + +Initial native bridge tests passed after the API port. A deliberate fault setting satisfaction negation to false failed the new `TestStudioRelationships` assertion; restoring the cached source passed the same command. Logs are `polarity-mutation.log` and `polarity-restored.log`. Initial browser runs exposed the changes above: identity and UTF-8 edit probes passed, while exploration, requirements and rover regressions found transport/projection differences. Failures were diagnosed from retained Playwright traces before changes were made. + +A positioned version-1 project was captured by serving the retained 0.10 WASM through the running application: `tests/fixtures/opensysml-0.10-positioned.sysml-project.json`. Its source hash, model hash and geometry are actual old-engine output, not invented fixture values. `engine-upgrade.spec.ts` imports it through the current worker and compares exact source, declaration correspondence, rendered positions and the downloaded bundle. + +Ignored working evidence is under `sandbox/opensysml-012/`: build/check/browser logs, the old module and runtime, comparison scripts and captures. The first full local functional pass was 102/110. Failures exposed richer tree relationships, lost bounded structural connections, a camera-fit/drag readiness race and an existing raw-double-versus-CSS-precision assertion. The structural connection loss is an application adaptation defect and was fixed; a deliberate boundary-projection fault failed `TestStudioRetainsDeclaredBoundaryConnections`, then passed after restoration (`boundary-mutation.log`, `boundary-restored.log`). Tree changes were checked against actual source/resolved endpoints before catalog expectations changed; the new fixture adds endpoint/declaration assertions. Geometry tests retain exact bundle doubles and use the existing browser-normalized rendering comparison. + +## Final local checks + +Code checks passed: 160 Vitest tests, ten Node checks, formatting, architecture and TypeScript. The production build passed. The full functional run passed 109/110; the failing new navigation/preservation case exposed a representation reset race, fixed before all eleven focused requirements/upgrade/source-assistance cases passed against a fresh build. All retained legacy identities/endpoints and positioned-project checks passed. The full suite was not rerun after this bounded fix. + +Normal visual comparison passed 75/77; two narrow diagram baselines were reviewed and corrected after three pixel-identical follow-up captures. Four requirement-diagram comparisons then passed. Final fresh-build workbench, requirement-diagram and table/matrix comparisons passed all fifteen. No tolerance or semantic assertion was weakened. [Full verification record](../verification.md) distinguishes initial failures, corrections, intermediate server failures and final results. + +No hosted CI result is established by these local runs. diff --git a/docs/testing.md b/docs/testing.md index 283cfff..96f6a9a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -28,12 +28,12 @@ The browser-channel override applies only to functional checks. Visual checks al The required `checks` job always runs formatting, architecture, TypeScript, unit/script tests and the dependency audit. Browser coverage is selected separately: -| Trigger | Browser coverage | -| ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Non-release PR | Thirteen `@critical` real-engine scenarios: entry, source-defined views, invalid drafts, rename and cancellation, native view creation, typed part/connection authoring, source undo/redo, exact save/reopen, engine failure recovery, IndexedDB restore and exact requirements/satisfaction browsing | -| Non-release PR with code, assets, examples, tooling or unknown paths | All visual comparisons as well as the critical scenarios | -| Documentation-only non-release PR | Critical scenarios; no visual comparisons | -| PR targeting `main`, `release/**`, `hotfix/**`; weekly Monday at 04:00 UTC; manual dispatch; or push to `main`, `release/**`, `hotfix/**` | Complete functional and visual suites | +| Trigger | Browser coverage | +| ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Non-release PR | Sixteen `@critical` real-engine scenarios: entry, source-defined views, invalid drafts, rename and cancellation, native view creation, typed part/connection authoring, source undo/redo, exact save/reopen, engine failure recovery, IndexedDB restore, exact requirements/satisfaction browsing and no-view exploration | +| Non-release PR with code, assets, examples, tooling or unknown paths | All visual comparisons as well as the critical scenarios | +| Documentation-only non-release PR | Critical scenarios; no visual comparisons | +| PR targeting `main`, `release/**`, `hotfix/**`; weekly Monday at 04:00 UTC; manual dispatch; or push to `main`, `release/**`, `hotfix/**` | Complete functional and visual suites | There is no duplicate run on a merge push to `develop`; its PR must pass `checks`. Scheduled runs use the repository's default branch. The full run must pass before release, and remains available manually on any branch. Scheduled failures need triage; they do not retroactively block an already merged PR. The reduced PR suite intentionally defers the extended preservation/failure matrix, language assistance, engine identity investigations and example-library sweeps to full runs. @@ -54,7 +54,7 @@ The [example catalog](../examples/README.md) supplies independent source project ## Test outcomes -`default-example.spec.ts` explicitly loads the built-in project and checks: Diagram mode, the pneumatic assembly’s exact parts/ports/connections, fourteen named views, all nine unchanged source files and independent file/view selection. `ready()` enters `/app`, explicitly chooses **Load example**, then waits for the real-engine model; `waitForStartup()` is only a readiness assertion, and reload scenarios choose their fixture deliberately; focused history and palette tests explicitly import the smaller Workshop fixture through `openWorkshop()`. The example-library scenario exercises all fourteen compressor views and exact source download/reopen, including unavailable renderer messages. +`default-example.spec.ts` explicitly imports the retained air-workshop fixture and checks: Diagram mode, the workshop context’s exact parts/ports/connections and explicit navigation into Pneumatics, fourteen named views, all nine unchanged source files and independent file/view selection. `ready()` enters `/app`, imports the legacy compressor bundle from its source files, waits for the real-engine overview, then explicitly opens Pneumatics as the fixture for existing diagram regressions; legacy import tests use `startExample()` directly; rover startup tests load the actual built-in example; `waitForStartup()` is only a readiness assertion, and reload scenarios choose their fixture deliberately; focused history and palette tests explicitly import the smaller Workshop fixture through `openWorkshop()`. The example-library scenario exercises all fourteen compressor views and exact source download/reopen, including unavailable renderer messages. The [identity investigation](architecture/identity-reconciliation.md) runs the compiled production workers directly to compare model/view identities across source revisions. Run `npm run test:e2e -- --project=functional identity.spec.ts` for the source-change matrix and Toolkit UTF-8 splice/correspondence probe. These are worker/SDK contract checks; they do not assert that the UI retains layout after applying source. Full source inputs, evaluations, edit reports and candidate mappings are retained as JSON attachments in each scenario's output directory. @@ -84,6 +84,8 @@ Use real engines for supported semantic command acceptance, source application, Visual checks complement source, identity and geometry assertions. Current baselines cover the default palette in light/dark workbenches, expanded Appearance dialogs and nested assembly diagrams, plus a 768 px workbench and dialog. Resized panels with the Issues dock have desktop and narrow captures in both themes. Part and connection forms have desktop and narrow snapshots in both light and dark themes; the connection form repeats complete selected endpoints in wrapping text below its selectors. Functional checks exercise all palettes and custom colors, structural containment, physical connector endpoints, keyboard port selection and group/child movement through save/reopen. Full visual parity across every palette and browser is not established. Keyboard checks cover Appearance, inherited-port/source interaction, authoring-dialog focus containment/return and a stable workspace focus fallback when an accepted edit removes its original trigger. They do not cover the complete editor. Automated accessibility scanning and Firefox/WebKit coverage are not currently configured. +`exploration.spec.ts` covers no-view multi-file imports, project/package roots, repeated usage context, Unicode/CRLF declaration reveal and explicit definition-versus-typed-usage structure through the pinned worker. It compares exact source/drafts/presentations before and after browsing, including an invalid draft and named-view positions through portable reopen. Asset failure injection proves the old scope and project survive and a fresh query can succeed. Controlled workspace tests cover stale/cancelled results independently. Exploration checks also assert actual nested package/usage bounds, nonoverlapping parallel relationship labels, exact keyboard edge source reveal without source mutation, compact sibling layout and a full wrapping qualified scope at narrow width. Four focused exploration visual scenarios cover overview/structure at desktop/narrow sizes in both themes and keyboard traversal between scope and representation controls. + ## Investigate failures and review baselines ```sh @@ -102,3 +104,21 @@ npm run test:e2e -- --project=visual --grep 'workbench and appearance' --update- Inspect each generated image and available diff before retaining it. Keep expected PNGs alongside the visual spec; transient captures and experiments belong in `sandbox/`. Rerun the affected tests without `--update-snapshots`. Never update snapshots automatically in CI or treat successful baseline generation as a passing comparison. For a critical regression, demonstrate that the assertion detects a deliberate, bounded fault, restore the original behavior, and rerun. Keep fault experiments isolated from unrelated user edits. Report the injected fault, expected failure and restored passing result without claiming broader coverage. + +`overview-routing.spec.ts` checks the assembly overview against a retained native-worker artifact, samples actual SVG routes against card bodies/headers, checks thirteen label rectangles, reveals every exact declaration by keyboard and preserves the portable bundle. Four assembly visual scenarios cover overview and structure at desktop/narrow widths in light/dark. Renderer geometry reads stay in `support.ts`. + +The initial-exploration failure regression lets the actual parse worker load, fails the following exploration WASM request, then retries with the asset restored. It checks unchanged source/presentations/history and keyboard focus on Scope. Four visual cases retain the retry state at desktop/narrow widths in both themes. + +`rover.spec.ts` checks the actual default example, inherited source-declared attribute values, exact architecture connections, all six source files, the five-view guide, declared requirement links and source/geometry preservation for both native behavior families through portable reopen. `startExample()` explicitly chooses the retained air-workshop fixture for existing regressions; `waitForRoverStartup()` asserts the product default independently. Behavior captures cover desktop/narrow widths and both themes. + +## OpenSysML 0.12 and requirements data + +`engine-upgrade.spec.ts` reopens the retained 0.10 positioned project through the current real worker and compares its exact bundle, correspondence and rendered geometry. Its broader fixture retains node/port identities, origins and complete contexts for sixteen supported named views across four old-engine example projects. This is bounded compatibility evidence, not a universal cross-version identity promise. + +`requirements-data.spec.ts` checks table content, repeated qualified satisfiers, positive/negative declarations, unlinked rows, CSV, source reveal, actual verification through a cross-file alias, retained invalid drafts/positions and portable reopen. A real asset failure must occur before retry assertions. Controlled workspace tests separately reject stale/cancelled inspection replies without replacing work. Matrices over 10,000 cells require narrowing the search. + +The language-assistance suite additionally checks real Toolkit references and exact source navigation, formatting as a draft, local undo, accepted source undo/redo and formatting failure preservation. The visual project includes requirements tables/matrices at desktop/narrow widths in both themes. + +Drag helpers wait for stable heading and viewport geometry across animation frames before issuing one pointer gesture. This avoids reading a screen coordinate while a mode change is still fitting the camera; it does not retry a failed drag. The legacy workspace reopen scenario uses the same browser-normalized rendered-position helper as the newer preservation tests, while separately requiring the moved symbols and exact portable position doubles to survive reopen. + +The 0.12 assembly overview fixture records thirteen source-backed edges (ownership/reference plus typing) rather than the old six type-collapsed edges. Its routing regression still requires every route/label to avoid cards and reveals each exact declaration or type reference by keyboard. Changed example tree expectations retain explicit source targets/gaps and exact endpoint/declaration fixtures; structural boundary connection counts are not relaxed. diff --git a/docs/verification.md b/docs/verification.md index 31008c9..c00cad4 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -1,5 +1,172 @@ # Verification record +## OpenSysML 0.12 and requirements/source assistance + +Recorded 2026-11-06. OpenSysML is now pinned to 0.12.0 with the local overlays ported, verified source/toolchain checksums and retained provenance. SysML Toolkit remains at 0.10.10. Requirements table/matrix inspection is read-only; references navigate project source and formatting creates an undoable draft requiring explicit Apply. No production dependencies or saved-format changes. + +- `npm run check` passed formatting, architecture, TypeScript, 160 tests in 23 Vitest files and ten Node checks (`sandbox/opensysml-012/check-complete.log`). `npm run build -- --outDir sandbox/opensysml-012/final-dist` passed, including engine preparation/Go tests, with the existing bundle-size warning (`build-final.log` in that directory). +- The complete functional run passed 109/110 (`functional-final.log`, output `sandbox/playwright/opensysml012-functional-final`). Its new named-view/matrix regression exposed a late navigation callback resetting the user's matrix selection. Navigation now switches representation at the start, restores it on failure and disables representation controls while busy. Fresh-build `requirements-data.spec.ts engine-upgrade.spec.ts language-assistance.spec.ts` passed all eleven (`fixed-functional.log`, output `sandbox/playwright/opensysml012-fixed-functional`). This includes exact old-engine identities/endpoints and positioned-project reopen, references, formatting undo/redo, declared verification through an alias, failure/retry and unchanged source/drafts/positions. The entire 110-case suite was not rerun after this bounded fix. +- All 77 visual baselines were generated and visually reviewed at desktop/narrow widths in both themes. Normal comparison passed 75/77 (`visual-final.log`); two narrow requirement-diagram captures differed only within the zoomed canvas. Follow-up captures were pixel-identical across three runs; both images and their bounds were inspected before correcting the initial regenerated baselines. The centering helper now waits for settled viewport geometry before reading bounds. No tolerances or semantic assertions were weakened. All four requirement-diagram comparisons then passed (`requirements-visual-confirmed.log`). Against the final fresh build, all fifteen workbench, requirement-diagram and table/matrix comparisons passed without snapshot updates (`fixed-visual-confirmed.log`, output `sandbox/playwright/opensysml012-fixed-visual-confirmed`). An intermediate shared-server run lost its preview process and had eight connection-refused failures; the final run owns its server. +- Deliberately dropping negation failed the new relationship assertion; restoring it passed. Deliberately removing boundary endpoint lifting failed the declared-connector regression; restoring it passed. Retained logs: `polarity-mutation.log`, `polarity-restored.log`, `boundary-mutation.log`, `boundary-restored.log` under `sandbox/opensysml-012/`. The real navigation race also demonstrated the new browser preservation assertion failing before the fix. + +Screenshot contact sheets and pixel-bound manifest are under `sandbox/opensysml-012/visual-review/`. [Compatibility report](research/opensysml-0.12-compatibility.md) records the native behavior changes and fixture provenance. These are local Chromium results, not hosted CI, Firefox/WebKit, complete accessibility auditing or performance evidence. The matrix shows declared links to linked elements, retains negation and unlinked rows, and limits display/export to 10,000 cells; it does not execute verification or infer compliance. Cross-version compatibility is bounded to the retained fixtures. + +## Consolidated project launcher + +Recorded 2026-11-06. The empty launcher now has three ordered choices: New project, Open files or project, and Load example (Inspection rover). It removes the air-workshop choice and its raw source imports from the production app, condenses save guidance and removes the planned-workflow disclosure. Existing project resume, replacement protection, import failure handling and local recovery remain available. Compressor sources remain unchanged as explicit import/regression fixtures; the fixture helper imports their portable bundle rather than relying on a launcher-only entry point. + +- Before changing the UI, the current launcher passed its existing desktop visual comparison (`sandbox/launcher-before.log`). The new no-air-button regression then failed against the old implementation (`sandbox/launcher-consolidation-red.log`). +- `npm run check` passed formatting, architecture, TypeScript, 154 Vitest tests and ten Node checks (`sandbox/launcher-check.log`). Fresh production builds passed through the browser runs, with the existing bundle-size warning. Production JavaScript no longer contains the compressor project's name or namespace. +- Focused functional run (`sandbox/launcher-functional.log`): six passed, including empty startup without engine loading, three keyboard-accessible choices, exact compressor import, rover startup, new-project cancellation/focus return, source import and failed/cancelled replacement preservation. The mobile scenario failed because the migrated fixture helper inspected a hidden Explorer view button. The helper now verifies the same exact view ID through the visible named-view selector. No identity assertion was removed. +- Follow-up `shell-entry.spec.ts toolbar.spec.ts rover.spec.ts --grep 'launcher and browser navigation|unsupported project bundle|wrapped mobile|opens the inspection rover'`: four passed in 28.4 seconds (`sandbox/launcher-preservation.log`), covering exact drafts/source DOM/camera/layout/history through resume, invalid bundle retry, mobile controls and all six rover sources through its guided tour. +- Reviewed six entry-surface cases at 1440/768/390 in light/dark (`sandbox/launcher-visual-review.log`, six passed in 12.9 seconds), plus five fixture/header/keyboard cases (`sandbox/launcher-fixture-visual-review.log`, five passed in 28.1 seconds). Six launcher images intentionally change; two desktop panel images change only the imported fixture caption within `(300,35)–(379,45)`. Narrow fixture captures are unchanged. Captures and exact bounds were inspected: `sandbox/launcher-consolidation-review.jpg`, `sandbox/launcher-fixture-review.jpg`, `sandbox/launcher-visual-diff.json`. Baseline generation is separate from normal comparison. + +Final normal comparison with `--project=visual --grep 'OpenMBEE entry surfaces|resized workspace panels|workbench and keyboard'` passed all eleven in 35.6 seconds without updates, retries or skips (`sandbox/launcher-visual-final.log`). The final repository-wide `npm run check` stopped at formatting of unrelated, newly present `docs/research/syside.md` (`sandbox/launcher-final-check.log`); that file was left untouched. Formatting checks over the changed files passed (`sandbox/launcher-changed-format.log`). Architecture, TypeScript, 154 Vitest tests and ten Node checks passed independently (`sandbox/launcher-final-code-check.log`), distinguishing that formatting blocker from this change. The user subsequently requested removal of the untracked Syside research document; it was removed before committing. The complete `npm run check` then passed (`sandbox/launcher-commit-check.log`), including formatting, architecture, TypeScript, 154 Vitest tests and ten Node checks. + +Full functional/visual suites, hosted CI, deployment, Firefox/WebKit and complete accessibility auditing are outside this focused checkpoint. No semantic engine, source model, dependency or saved format changes. + +## Recovery dialog capture isolation + +Recorded 2026-11-06 after publishing rover commit `04f6d41`. Hosted run [42165758615](https://github.com/Open-MBEE/sysml_view_editor/actions/runs/42165758615) passed code checks, audit and all 15 critical browser workflows in both attempts. Both attempts passed 68/69 visual comparisons; the remaining light recovery-dialog crop differed at pixel `(399, 295)`, its last row. The original baseline retained a dark pixel at the crop edge, consistent with the underlying diagram leaking into an enclosing pixel-rounded screenshot. The focused local reproduction also failed at exactly that pixel (`sandbox/recovery-pixel-repro.log`); captured trace and diff evidence distinguish it from a dialog-content or semantic failure. + +This scoped dialog comparison now applies `tests/e2e/recovery-dialog.css` only during capture, hiding the unrelated React Flow background while preserving layout. Full-page diagram comparisons remain unchanged. The first isolated run passed three cases and retained the expected failure against the old background-bearing baseline (`sandbox/recovery-pixel-isolated.log`). Only the affected light baseline is regenerated for the intentionally isolated capture. No screenshot tolerance, semantic assertion, application styling or production code changes. The changed baseline was visually inspected. Normal comparisons then passed all four theme/width cases in 23.3 seconds (`sandbox/recovery-pixel-final.log`), including keyboard focus return. `npm run check` passed again with 154 Vitest tests and ten Node checks (`sandbox/recovery-pixel-check.log`). The final hosted run remains separate from these local results. + +## Compact rover presentation + +Recorded 2026-11-06. Named structural graphs with at least ten nodes now use narrower cards, tighter gaps and 32-unit attribute rows. Simple scalar defaults display without declaration syntax or redundant fractional zeros; complex declarations retain exact spelling, and all rows retain exact declarations in their titles. Source and engine attribute payloads are unchanged. + +`npm run check` passed formatting, architecture, TypeScript, 154 Vitest tests and ten Node checks (`sandbox/rover-polish-check.log`). The fresh production build and actual-worker rover default/assembly movement-and-reopen checks passed two cases in 30.4 seconds (`sandbox/rover-polish.log`). These assert resolved native endpoints, all ten routes against unrelated compound interiors, orthogonal SVG geometry, exact source and compatible saved positions. The small formatter unit verifies both concise labels and expression fallback. + +The targeted 19-case visual baseline review covers desktop/narrow layouts in both themes, including keyboard-operated appearance, panels, structural selection, nested assemblies, default rover and guided behavior. Captures were visually inspected, including `sandbox/rover-polish-review.jpg`. The older PR #11 CI failure was limited to diagnostics-light/dark diagram-position screenshots; its actual/diff captures are retained under `sandbox/previous-pr-evidence`. Baseline review passed 19 cases in 2.3 minutes (`sandbox/rover-polish-visual.log`). The subsequent normal comparison added the four diagnostics cases and passed all 23 in 2.8 minutes without updates, retries or skips (`sandbox/rover-polish-visual-final.log`, output `sandbox/playwright/rover-polish-visual-final`). This is local evidence, not hosted CI. No Firefox/WebKit, full accessibility audit, simulation or arbitrary moved-node obstacle rerouting is established. + +## Structural connection routing around unrelated containers + +Recorded 2026-11-05 on `feature/inspection-rover-showcase`. The rover screenshot exposed a mismatch: named structural diagrams used ELK node placement but discarded its routes, drawing endpoint-only smooth-step connections across inspection and its children. Named interconnection diagrams now pass actual native port IDs, sides, rendered row positions and label reservations into ELK and render its orthogonal sections/label positions through the shared `RoutedEdge` adapter. The adapter translates routes with their owning container during assembly movement and retains orthogonal direct links at physical handle tips. No source semantics, persistence format, dependencies or engine pins changed. + +The actual-worker regression failed against the previous built app with camera/lidar/radio crossings and overlapping labels (`sandbox/structural-routing-red.log`, `sandbox/playwright/structural-routing-red`). The geometry helper now additionally treats a compound's entire body as an obstacle unless it owns one of that connection's native endpoints. Thus a line cannot pass through inspection's empty interior merely because it misses camera/radio. All ten rover labels, card/header/group bounds, label overlap/viewport bounds and orthogonal SVG sections are checked. Existing overview/behavior checks retain their original scope by default. + +Checks and retained evidence: + +- Focused layout units: `npx vitest run src/lib/workbench/diagrams/structure.test.ts`: ten passed (`sandbox/structural-routing-unit.log`), including compatible absolute positions, added connections and fractional owner boundaries. +- `npm run test:e2e -- --project=functional assembly.spec.ts rover.spec.ts --output=sandbox/playwright/structural-routing-functional`: six passed; mission drag failed because its fixed screen-space grab point missed a small fitted card. The drag helper now chooses a point inside the actual card header, retaining the same drag displacement and outcome assertion. The unchanged mission preservation assertion passed in the targeted rerun (`sandbox/structural-routing-mission-drag.log`, one passed in 14.0 seconds). No application failure was hidden or assertion weakened. +- Final port alignment: visual inspection found a slight diagonal in an otherwise straight assembly connection. React Flow's logical parent IDs do not create DOM ancestors, so nested leaf port rows also share their spare body height. The fixed coordinates now account for this, borders and attribute rows. Direct links remain orthogonal when actual tips differ from planning points. `npm run test:e2e -- --project=functional assembly.spec.ts rover.spec.ts --grep 'renders nested ownership|opens the inspection rover|moved rover assembly' --output=sandbox/playwright/structural-routing-aligned`: three passed in 23.8 seconds, establishing actual port endpoints, the complete rover graph and geometry/source preservation after moving the assembly and portable reopen. Earlier group-preservation evidence also passed two cases in 17.4 seconds (`sandbox/structural-routing-preservation.log`). +- `npm run check` passed formatting, architecture, TypeScript, 153 Vitest tests and ten Node checks (`sandbox/structural-routing-check-confirmed.log`). An earlier check stopped at test formatting; corrected runs passed. Fresh production builds passed through browser runs, with the existing bundle-size warning. +- Final targeted visual review: `npm run test:e2e -- --project=visual --grep 'workbench and appearance dialog|workbench and keyboard|nested assembly|resized workspace panels|selected structural nodes|default rover architecture|rover guided behavior' --update-snapshots --output=sandbox/playwright/structural-routing-aligned-visual-review`: 19 passed in 1.7 minutes. Earlier intermediate alignment captures/comparisons remain under `sandbox/playwright/structural-routing-visual-review` and `structural-routing-visual-final`; those preceded the final row-alignment correction. +- Normal comparison with the same selector and `--output=sandbox/playwright/structural-routing-aligned-visual-final` passed all 19 in 1.7 minutes without baseline updates, retries or skips. This includes actual SVG geometry assertions for the default rover at desktop/narrow widths in both themes. Behavior captures remain covered alongside structural changes. + +Fifteen structural images intentionally change, with 85 images byte-for-byte unchanged against `sandbox/structural-routing-baselines-before/`. Exact files are in `sandbox/structural-routing-visual-manifest.json`. Changed captures were inspected on four sheets under `sandbox/structural-routing-inspection/`, with final assembly endpoint alignment and rover captures also inspected at full size in both themes and widths. Full visual/functional suites, hosted CI, deployment, Firefox/WebKit and comprehensive accessibility were not repeated. Arbitrary obstacle rerouting after individual-node movement remains outside this generated-layout/container-translation contract. Source selection and compatible saved positions remain independently tested; routes are transient presentation geometry. + +## Inspection rover showcase and behavior rendering + +Recorded 2026-11-05 on `feature/inspection-rover-showcase`, using Node 26.3.1 and the existing pinned engines. The product default is now a six-file Inspection rover with four nested assemblies, ten resolved port connections, effective source-declared attributes, component specialization, a mission, operating states and four quantified requirement/satisfaction traces. Four source allocations connect mission steps to real rover usages. The built-in guide connects the five authored views. The air workshop remains separately available; its existing sources are unchanged. New rover sources are authored here under Apache-2.0. + +The bridge adds exact source slices for effective project attribute members via `MembersOf`, excluding library declarations; it does not evaluate defaults or infer measured performance. A retained Go test verifies the same inherited declaration on two distinct typed usages. Native action/state graphs now use compound rendering, terminal shapes, directed relationships and existing source/portable-position contracts. Their transport uses the existing graph validator, with two added unresolved-endpoint regressions. No dependency versions, engine pins or portable/recovery format changed. The generated engine overlay/provenance changes through the existing preparation process. + +Evidence and checks: + +- `npm run check` passed formatting, architecture, TypeScript, 153 Vitest tests and ten Node checks (`sandbox/rover-check-confirmed.log`). A first check stopped at catalog formatting; corrected runs passed. Fresh production builds passed throughout the browser workflow, including retained Go bridge tests; the existing bundle-size warning remains. +- Hiding the actual attribute compartment deliberately made the real-engine showcase assertion fail with **Received: hidden** (`sandbox/rover-attributes-red.log`, `sandbox/playwright/rover-attributes-red`). Restored rendering passed the critical path. This is UI fault injection, not a substituted semantic engine. +- Initial local probes rejected reserved identifiers `frame` and `message`; final source uses `scanData` and `report`. The retained native capture (`sandbox/rover-native.json`) supplied the reviewed catalog expectations. `npm run test:e2e -- --project=functional examples.spec.ts --grep 'example library: (inspection-rover|actions|states|air-compressor)$' --output=sandbox/playwright/rover-catalog`: four passed in 1.3 minutes. This exercises all five rover views, both small behavior fixtures, all fourteen air-workshop views and exact source download/reopen. It preceded the final behavior routing refinement below. +- `npm run test:e2e -- --project=functional rover.spec.ts examples.spec.ts shell-entry.spec.ts --grep 'inspection rover|mission source|lifecycle source|example library (inspection-rover|actions|states|air-compressor)|landing and direct example|newer example opening' --output=sandbox/playwright/rover-native-final`: five passed in 35.9 seconds. The grep omitted the colon needed to select catalog cases, so this run establishes rover/entry coverage only; catalog coverage is recorded separately. +- `rover.spec.ts` verifies exact architecture identities/connections, effective declared values, all six source texts, requirement links and independent view browsing. Both behavior families reveal exact node/edge source ranges by keyboard, retain source on Enter, and preserve independently moved positions and exact portable data after a fresh reopen. `sandbox/rover-source-geometry-final.log`: three passed in 33.9 seconds before final routing; final routing results follow. + +### Behavior routing refinement + +Inspecting the initial captures showed generic smooth-step return paths crossing state cards and overlapping labels. The actual-SVG regression against that built version failed on card/header crossings and label overlap (`sandbox/rover-behavior-routing-red.log`). Behavior layout now requests fixed-side ports and labels from ELK, translates its container-relative orthogonal sections into canvas coordinates, retains labels only where native declarations supply them, and constrains initial/final layers. These are generated presentation routes, not modeled source routes. Arbitrary obstacle avoidance after manual movement remains outside scope. + +`npm run test:e2e -- --project=functional rover.spec.ts --output=sandbox/playwright/rover-routing-confirmed`: three passed in 35.0 seconds, with actual SVG/card/header intersections, named label overlap/counts, exact node/relationship source selection and portable geometry preservation. An intermediate attempt flagged anonymous arrows as missing labels: the helper now takes explicit unlabelled native edge IDs, still checks every path and rejects unexpected labels on those edges. Existing overview assertions retain their default requirement that every relationship has a label; no crossing or overlap assertion was weakened. + +Final compatibility after routing and the final drag-event guard: `npm run test:e2e -- --project=functional rover.spec.ts examples.spec.ts --grep 'mission source|lifecycle source|example library: (actions|states|air-compressor)$' --output=sandbox/playwright/rover-compatibility-final`: five passed in 1.6 minutes (`sandbox/rover-compatibility-final.log`). This includes the smaller action/state fixtures, all fourteen air-workshop views and both rover behavior source/geometry regressions. + +The final default-tour smoke against the freshly built local preview also asserts each selected native view's actual rendered root text: `npx playwright test --config=sandbox/rover-local.config.ts --project=functional rover.spec.ts --grep 'opens the inspection rover' --output=sandbox/playwright/rover-default-confirmed`: one passed in 6.8 seconds (`sandbox/rover-default-confirmed.log`). This uses the real local production preview without another build. + +### Visual evidence + +The full visual baseline review passed all 69 cases in 6.7 minutes (`sandbox/rover-visual-review-final.log`). A preliminary run was intentionally cancelled after inspection found unthemed behavior terminals; source colors and requirement descriptions were corrected before the full completed review. Against `sandbox/rover-baselines-before/`, 25 existing images intentionally change for rover startup, the additional launcher choice, declared attribute compartments and contextual authoring controls; 14 rover images are new and 61 images remain byte-for-byte unchanged. `sandbox/rover-visual-manifest.json` records exact files/cases. All changed/new captures were inspected in seven sheets under `sandbox/rover-visual-inspection/`, with rover architecture, mission, lifecycle and requirement captures also inspected at full size in desktop/narrow and light/dark variants. + +Normal comparison of all 31 changed/new visual cases passed in 2.5 minutes without updates, retries or skips (`sandbox/rover-visual-final.log`, executable selector in `sandbox/run-rover-normal.py`). This preceded only the behavior routing refinement. The four affected guided behavior cases were subsequently regenerated and inspected: four passed in 39.4 seconds (`sandbox/rover-routing-visual-review.log`); normal comparison with `--project=visual --grep 'rover guided behavior' --output=sandbox/playwright/rover-routing-visual-final` passed all four in 34.7 seconds, again without updates, retries or skips. Their SVG geometry checks run at both widths and themes. No existing structural/requirement assertion or baseline was changed merely to conceal a failure. + +Full functional suites, hosted CI, publication, Firefox/WebKit, comprehensive accessibility, execution and simulation were not established by this local checkpoint. Appearance dialog focus return and keyboard source selection are covered, not complete accessibility conformance. Local captures under ignored `sandbox/` may be absent from another checkout. An importable source-only bundle is retained at `sandbox/inspection-rover.sysml-project.json`. + +## Workshop overview startup + +Recorded 2026-11-05 on `feature/workshop-startup`. The built-in example's explicit preferred named view is now `CompressorViews::overview`, showing `WorkshopContext` with mains, compressor interface, tool and service console. Pneumatics remains a separately selectable detail view. The nine source documents and all fourteen view declarations are unchanged. + +The startup regression was first run against the old preference and failed with expected `CompressorViews::overview`, received `CompressorViews::pneumatics` (`sandbox/workshop-startup-red.log`). The final regression verifies the real worker's exact context nodes, six ports and two connection labels, all nine source texts, independent source navigation and explicit transition into Pneumatics. Existing diagram regressions deliberately select their Pneumatics fixture after the actual overview startup; startup and direct-link tests assert the real default independently. + +- `npm run check`: passed formatting, architecture, TypeScript, 151 Vitest tests and ten Node checks (`sandbox/workshop-startup-check.log`). +- `npm run test:e2e -- --project=functional default-example.spec.ts toolbar.spec.ts shell-entry.spec.ts --grep 'complete Smart air|wrapped mobile|landing and direct example' --output=sandbox/playwright/workshop-startup-functional`: three passed in 19.3 seconds, including landing/direct example links, reload to launcher and mobile header layout. Production builds passed through browser runs with the existing bundle-size warning. +- Visual baseline generation for `workbench and appearance dialog|default workshop overview`: four passed in 19.7 seconds (`sandbox/workshop-startup-review.log`). The two existing workbench images intentionally show overview instead of Pneumatics; two new narrow images cover both themes. All four were inspected. Appearance-dialog images remain byte-for-byte unchanged against copies in `sandbox/workshop-startup-before/`. + +Normal comparison: `npm run test:e2e -- --project=visual --grep 'workbench and appearance dialog|default workshop overview' --output=sandbox/playwright/workshop-startup-visual-final`: four passed in 16.8 seconds without baseline updates, retries or skips (`sandbox/workshop-startup-visual-final.log`). + +Full browser suites, hosted CI, deployment and comprehensive accessibility were not run for this local change. No engine pins, dependencies or saved formats changed. + +## Initial exploration failure recovery + +Recorded 2026-11-05 for PR #11. The Codex review correctly identified that a no-view project's initial exploration failure left the only selector option already selected, with no change event available to retry. The empty diagram now exposes **Explore model**, disabled while busy, using the existing read-only command and focusing Scope after success. + +The focused regression lets the pinned parse worker succeed and fails only the following exploration WASM request. Before the fix it failed because the retry button did not exist (`sandbox/exploration-retry-red.log`). After the fix both initial and later asset-failure cases passed: `npm run test:e2e -- --project=functional exploration.spec.ts --grep 'retries an initial|asset failure preserves' --output=sandbox/playwright/exploration-retry-green` (two passed, 15.1 seconds). It verifies the exact portable bundle, applied source, disabled source undo/apply controls and successful native diagram after keyboard retry. The final initial-failure case also asserts Scope focus. + +`npm run check` passed 151 Vitest tests and ten Node checks, formatting, architecture and TypeScript (`sandbox/exploration-retry-check.log`). A first attempt stopped at test formatting; the corrected run passed. Fresh production builds passed through the browser runs. The combined targeted functional/visual baseline generation passed five cases in 27.6 seconds (`sandbox/exploration-retry-review.log`). All four new retry-state captures were inspected at desktop/narrow sizes in light/dark; existing baselines were not updated. + +The first normal visual comparison exposed a delayed sidebar tooltip at narrow width. Dismissing it with Escape also restores its trigger's focus, so the capture now dismisses the tooltip, explicitly focuses the retry button afterward and asserts that focus before capture. Intermediate comparisons recorded these test-setup differences under `sandbox/playwright/exploration-retry-visual-final`, `exploration-retry-visual-confirmed` and `exploration-retry-visual-final-normal`; existing visual assertions were not weakened. Final captures intentionally include the retry button's keyboard focus ring. + +Final baseline generation passed four cases in 22.7 seconds (`sandbox/exploration-retry-focus-final-review.log`). The final normal comparison `npm run test:e2e -- --project=visual --grep 'initial exploration retry' --output=sandbox/playwright/exploration-retry-confirmed` passed all four in 21.6 seconds without updates, retries or skips. Final captures were inspected. + +Full browser suites, hosted CI and comprehensive accessibility were not established by these local runs. No dependencies, engine pins or saved formats changed. + +## Assembly overview routing + +Recorded 2026-11-05 on `feature/automatic-exploration`, using Node 26.3.1 and the existing pinned browser engine. Ownership layouts reserve shared-owner gutters and separate rectilinear tracks/label rows. Fit includes route and label bounds. Structural layouts, semantic endpoints, native correspondence and portable data remain unchanged. + +The fixture `tests/fixtures/exploration-assembly-artifact.json` was captured from the actual pinned worker for `examples/assembly/model.sysml`; the browser regression compares the returned artifact exactly. The independent route/card geometry assertion failed against the old curves (`sandbox/assembly-routing-red.log`), then passed with the new routing. A separate unit case covers forward/reverse routes across an intervening root and their camera bounds. + +Executed checks: + +- `npm run check`: passed formatting, architecture, TypeScript, 151 Vitest tests and ten Node checks (`sandbox/assembly-routing-check.log`). An earlier attempt stopped on helper formatting, corrected before this successful run. +- `npm run test:e2e -- --project=functional overview-routing.spec.ts --output=sandbox/playwright/assembly-routing-functional-final`: one passed in 12.1 seconds. Actual SVG paths avoid card bodies/headers, all six labels avoid each other and remain in the viewport. Every edge retains keyboard focus and reveals its exact source declaration without changing source or the portable bundle; the structural handoff still renders. An initial discovery attempt required a JSON import attribute and did not exercise the application. +- Baseline generation with `--project=visual --grep 'assembly exploration routing|read-only model exploration' --update-snapshots --output=sandbox/playwright/assembly-routing-visual-review`: eight passed in 57.8 seconds. Eight new assembly captures and four intentionally changed P/Q overview captures were inspected at desktop/narrow widths in both themes. The four existing exploration structure captures are byte-for-byte identical to their retained copies in `sandbox/assembly-routing-baselines-before/`. + +Normal comparison: `npm run test:e2e -- --project=visual --grep 'assembly exploration routing|read-only model exploration|nested assembly' --output=sandbox/playwright/assembly-routing-visual-final`: ten passed without baseline updates, retries or skips (`sandbox/assembly-routing-visual-final.log`). This includes existing named assembly views. + +Full browser suites, Firefox/WebKit, hosted CI and general arbitrary-layout obstacle avoidance were not run. Fresh production builds run through the browser workflow; the existing bundle-size warning remains. No dependency, saved format or engine pin changed. Local evidence is retained under ignored `sandbox/`. + +## Exploration hierarchy, relationships and layout polish + +Recorded 2026-11-05 on `feature/automatic-exploration`. The earlier flat overview was insufficient for useful model exploration. The replacement honors native parent identities with nested package/declaration boxes; relationship targets never become owners. The real-worker regression verifies `P` → `System` → `primary`/`standby` and `Q` → `Motor`, actual rendered containment, separate native relationship identities and nonoverlapping labels. Compact exploration leaf cards are 208 × 52 model-space units; the two-part structural container is 468 × 136, with side-by-side siblings and no empty leaf body. Existing named-view dimensions and presentation data retain their original contract. + +Visible Scope/Representation fields and the full wrapping qualified scope caption distinguish Part definition from Part usage. The narrow real-engine scenario verifies the complete `Engineering::CompressorControlSubsystem::RedundantDriveAssembly` scope and its separate `installedDrive` usage without clipping. The existing inherited-port keyboard/source workflow also passed, establishing that explicit Open source still focuses the editor. + +Keyboard edge selection in Split mode exposed a source-preservation bug: selecting a declaration synchronously focused CodeMirror during Enter keydown, allowing an `insertLineBreak` to replace the highlighted declaration. `sandbox/exploration-keyboard-red.log` records the focus transfer and `Source preserved: false`; the source-range update is now passive for diagram selection, while explicit Open source/issue/definition navigation still focuses the editor. The focused real-engine regression verifies that both edges retain focus, leave Apply source disabled, reveal the exact primary/standby declarations on request and preserve the complete portable bundle. The restored probe reports `Source preserved: true` (`sandbox/exploration-keyboard-green.log`). No debug instrumentation remains in application/test source. + +Executed checks: + +- `npm run check` passed formatting, architecture, TypeScript, 149 Vitest tests and ten Node checks (`sandbox/exploration-polish-check.log`). This preceded the final leaf-border and spacing adjustment; the final fresh production builds and focused unit results below include it. +- Final focused units: `npx vitest run src/lib/workbench/diagrams/exploration.test.ts src/lib/workspace/exploration/exploration.test.ts src/lib/workbench/properties/diagram-selection.test.ts`: 11 passed (`sandbox/exploration-polish-unit-final.log`). +- `npm run test:e2e -- --project=functional exploration.spec.ts --output=sandbox/playwright/exploration-polish-functional`: three passed, one failed on the subsequently fixed Enter/source mutation. The corrected critical path passed with `--grep 'opens a no-view' --output=sandbox/playwright/exploration-polish-focus-final` (8.9-second scenario, 14.4-second run). An intermediate run reached an option-disabled assertion despite a native disabled attribute; explicit label/field associations replaced wrapping labels, and the unchanged disabled-option assertion passed in the final run. Logs: `sandbox/exploration-polish-functional.log`, `sandbox/exploration-polish-focus-fixed.log`, `sandbox/exploration-polish-focus-final.log`. +- `npm run test:e2e -- --project=functional exploration.spec.ts assembly.spec.ts --grep 'entire qualified scope|inherited ports by keyboard' --output=sandbox/playwright/exploration-polish-navigation`: two passed in 17.9 seconds (`sandbox/exploration-polish-navigation.log`). +- Only the eight exploration overview/structure baselines intentionally changed. Final generation: `npm run test:e2e -- --project=visual --grep 'read-only model exploration' --update-snapshots --output=sandbox/playwright/exploration-polish-visual-final-review`: four passed in 33.2 seconds. All eight captures were inspected at desktop/narrow widths in light/dark, including complete leaf borders and readable separated labels. +- Normal comparison: `npm run test:e2e -- --project=visual --grep 'read-only model exploration|workbench and appearance dialog|nested assembly' --output=sandbox/playwright/exploration-polish-visual-confirmed`: eight passed in 47.1 seconds, without baseline updates, retries or skips. This includes four existing named-view scenarios with unchanged baselines. Log: `sandbox/exploration-polish-visual-confirmed.log`. + +Fresh production builds passed through these runs with the existing bundle-size warning. Full functional/visual suites, hosted CI, audit, Firefox/WebKit, comprehensive accessibility and large-model/obstacle-routing coverage were not repeated. Parallel lanes establish distinct paths and label positions for the demonstrated declarations; they do not establish general obstacle avoidance. No project/recovery format, dependency version or engine pin changed. Evidence is local and ignored under `sandbox/`. + +## Read-only exploration checkpoint + +Recorded 2026-11-05 on `feature/automatic-exploration`, with Node 26.3.1 and the existing pinned engines. The new `Explore` operation renders project/package trees and explicit part-definition/usage tree or structural subjects without adding declarations. Its separate envelope and workspace state do not become named-view presentation keys. No dependency versions, upstream pins/checksums or portable/recovery formats changed. + +The retained Go bridge tests passed during engine preparation, including multi-file project/package roots, explicit definition versus typed usage, exact source correspondence, unchanged model hash and rejection of invalid/library-only subjects or invalid semantic source. Fresh production builds passed through the focused browser runs; the existing bundle-size warning remains. `npm run check` passed formatting, architecture, TypeScript, 145 Vitest tests and ten Node checks (`sandbox/exploration-check-final.log`). Four exploration unit cases exercise malformed/stale transport, applied-source queries with invalid drafts, presentation/history preservation, engine failure and superseded requests. Removing the exploration publication generation check made the replacement-project regression fail; the restored implementation passed the final check. Fault evidence: `sandbox/exploration-stale-fault.log`. + +`npm run test:e2e -- --project=functional exploration.spec.ts --output=sandbox/playwright/exploration-functional` initially passed the named-view/draft/reopen preservation and injected asset-failure/retry scenarios. Its no-view scenario stopped because the assertion expected a name rather than the existing full-declaration reveal contract: the trace selected exactly `part primary : Q::Motor;` after Unicode/CRLF source. The corrected no-view scenario passed, then passed again after adding explicit query-control focus return: `npm run test:e2e -- --project=functional exploration.spec.ts --grep 'opens a no-view' --output=sandbox/playwright/exploration-focus-final` (10.2-second scenario, 15.8-second run). All three scenarios passed across these focused runs; they were not repeated as a complete functional suite. Logs: `sandbox/exploration-browser.log`, `sandbox/exploration-no-view-final.log`, `sandbox/exploration-focus-final.log`. + +Four visual scenarios retain eight new overview/structure captures at 1440/768 px in light/dark. Reviewed baseline generation passed under `sandbox/playwright/exploration-visual-reviewed` (32.2 seconds); all eight images were inspected. An earlier narrow keyboard check exposed an asynchronous panel focus return; the test now observes the reopen-button focus before traversing the exploration controls. A later normal run stopped on an incorrect readiness locator for a nonexistent standalone text leaf; the trace showed the combined `part def System` card, and readiness now asserts that exact rendered group. No existing baseline or tolerance was changed. The final normal comparison, `npm run test:e2e -- --project=visual --grep 'read-only model exploration' --output=sandbox/playwright/exploration-visual-confirmed`, passed all four cases in 30.4 seconds without updates, retries or skips. Logs: `sandbox/exploration-visual-reviewed.log`, `sandbox/exploration-visual-final.log`, `sandbox/exploration-visual-confirmed.log`. + +These are local results. The full functional/visual suites, dependency audit, Firefox/WebKit and complete accessibility/performance audits were not repeated for this slice. Automatic system-root inference, generalized typed-instance expansion, exploration persistence and reviewed named-view creation from an exploration remain outside the delivered subset. Applying or undoing/redoing accepted source clears revision-bound exploration and uses the opening fallback when the workbench refreshes. Ignored evidence may be absent from other checkouts. + +## Requirements integration + +On 2026-11-05, PR #10 merged into `develop` as `7b37ebe40f350f316be8ad951c095a7fec35fd92`. GitHub reported a clean merge state and no unresolved review threads. The required `checks` job passed for head `ac7ee9f` in [Validate run 42040187761](https://github.com/Open-MBEE/sysml_view_editor/actions/runs/42040187761). That head adds only a routing-documentation correction to the previously validated requirements implementation. No exploration work was included in this merge. + ## Native requirements and satisfaction checkpoint Implementation recorded 2026-11-03; final validation continued 2026-11-04 on `feature/requirements-traceability`, using Node 26.3.1, npm 11.16.0 and the pinned engines. Native requirement graphs now retain declared short identifiers, full available AST documentation, selected/contextual membership, satisfaction assertion/negation and direct versus type-based ownership. The app adds bounded requirement/model-element cards, labeled relationships, pointer/keyboard selection, exact source reveal and existing version-1 presentation persistence. No production dependencies, upstream engine pins, archive checksums or saved-format version changed. diff --git a/engine-bridge/README.md b/engine-bridge/README.md index 2f83332..7fd98d5 100644 --- a/engine-bridge/README.md +++ b/engine-bridge/README.md @@ -4,10 +4,10 @@ The browser app requires OpenSysML and SysML Toolkit WASM workers. Preparation s | Engine | Pin | Purpose | | ------------- | ---------------------------------------------------- | ----------------------------------------------------------- | -| OpenSysML | 0.10.0 / `08ef397841767b829e492bbecf6d723483b4421e` | Parsing, model inspection, validation and native evaluation | +| OpenSysML | 0.12.0 / `760c4d1fc8edfa4e214423099065c0a7c1b24e2d` | Parsing, model inspection, validation and native evaluation | | SysML Toolkit | 0.10.10 / `339ef0e073d06e553e81c079755ddfcd224e4b92` | Source transformations and draft language queries | -`scripts/prepare-opensysml.mjs` verifies the source archive, applies the retained overlays under `engine-bridge/opensysml/`, runs bridge tests and builds with Go 1.25.11. The derived module exposes `studio_evaluate_view_v1` and `studio_relationships_v1`. It records source/archive, overlay, builder, toolchain, binary and runtime identities. Set `SYSML_GO` to a local Go 1.25.11 binary on platforms without the pinned Linux x64 download path. +`scripts/prepare-opensysml.mjs` verifies the source archive, applies the retained overlays under `engine-bridge/opensysml/`, runs bridge tests and builds with Go 1.27.3. The derived module exposes `studio_evaluate_view_v1`, `studio_relationships_v1` and `studio_exploration_v1`. It records source/archive, overlay, builder, toolchain, binary and runtime identities. Set `SYSML_GO` to a local Go 1.27.3 binary on platforms without the pinned Linux x64 download path. `scripts/prepare-sysml-toolkit.mjs` verifies its release archive and every prepared asset. It retains the Apache and EPL licenses, library assets, build information and third-party notices. Neither preparation script installs a toolchain globally. @@ -21,4 +21,10 @@ Native requirement graphs retain the engine's selected subjects and related cont `insertMember` supports typed part and connection creation. Creation may insert within the recorded owner or promote only its terminal semicolon to a body; existing surrounding source is retained. Toolkit accepts some directionally incompatible connections, so the workspace separately checks explicit out/inout-to-in/inout direction, identical resolved type and exact evaluated usage/port endpoints. Conjugated or ambiguous endpoint paths remain unavailable for graphical creation. [Authoring scope and evidence](../docs/architecture/graphical-authoring.md). -Toolkit also supplies read-only completion, hover and definition queries through a separate disposable language worker. Queries use the complete draft source set, negotiate UTF-16 positions and normalize only supported results. The workbench rejects stale replies; completion edits remain unapplied draft changes until the normal source transaction validates them. Engine pins, prepared assets and provenance are unchanged. See [source assistance](../docs/architecture/source-assistance-recovery.md). +Toolkit also supplies completion, hover, definition, references and formatting queries through a separate disposable language worker. Queries use the complete draft source set, negotiate UTF-16 positions and normalize only supported results. The workbench rejects stale replies; completion and formatting edits remain unapplied draft changes until the normal source transaction validates them. Engine pins, prepared assets and provenance are unchanged. See [source assistance](../docs/architecture/source-assistance-recovery.md). + +Read-only `Explore` uses the same validated applied-source revision as named-view evaluation, selects project-owned roots or one exact project declaration, and calls native `RenderExposed`. Project/package scopes support trees; explicit part definitions/usages additionally support bounded structural rendering. Its separate envelope carries the request instead of an authored view identity; it does not write source or saved presentation keys. + +Structural attribute compartments retain exact source slices for effective non-library attribute members through the pinned semantic model's `MembersOf` contract. They expose declarations rather than evaluated values. Native action/state artifacts are rendered by the workbench with existing correspondence and bounded graph validation; this adds no execution API or upstream pin change. + +The 0.12 port normalizes native optional labels, anonymous names and empty graph arrays to the existing browser contract. Temporary exploration retains explicit ownership; interconnection attributes retain the app's source-backed compartments. `EvaluateRelationships` also returns source-bound requirement rows and satisfaction assertion/negation for the read-only table/matrix. See [compatibility evidence](../docs/research/opensysml-0.12-compatibility.md) and [requirements data](../docs/architecture/requirements-data.md). diff --git a/engine-bridge/opensysml/evaluate_view.go b/engine-bridge/opensysml/evaluate_view.go index 6e27bfd..69b2bcb 100644 --- a/engine-bridge/opensysml/evaluate_view.go +++ b/engine-bridge/opensysml/evaluate_view.go @@ -85,6 +85,32 @@ func (e *Engine) evaluateView(req *JRenderViewRequest) ([]byte, error) { return marshal(out) } out.Status = "complete" + out.Selected = cached.selectedSubjects(selected) + renderer := view.NewRenderer(typed, cached.resolver, cached.sourceText()) + rendering, err := renderer.Render(matches[0]) + if err != nil { + var unsupported *view.UnsupportedKindError + if errors.As(err, &unsupported) && unsupported.Kind == view.KindGeometry { + return e.studioGeometry(req, selected, typed, out) + } + out.RenderStatus = "unsupported" + out.Diagnostics = append(out.Diagnostics, err.Error()) + return marshal(out) + } + if len(failures) > 0 { + out.Status = "indeterminate" + out.Selected = []ViewSubject{} + for reason := range failures { + out.Diagnostics = append(out.Diagnostics, reason) + } + sort.Strings(out.Diagnostics) + return marshal(out) + } + return cached.normalizedRendering(out, selected, rendering) +} + +func (cached *cachedModel) normalizedRendering(out EvaluatedView, selected []*symbols.Symbol, rendering *view.Rendering) ([]byte, error) { + var err error byOrigin := map[string]string{} declarations := map[string]*symbols.Symbol{} cached.Index.Registered(func(fqn string, syms []*symbols.Symbol) { @@ -96,6 +122,30 @@ func (e *Engine) evaluateView(req *JRenderViewRequest) ([]byte, error) { } } }) + // The semantic index also owns anonymous members (including synthesized + // satisfy subjects). Inspect their AST kind, never their rendered text. + seenDeclarations := map[*symbols.Symbol]bool{} + var visitDeclaration func(*symbols.Symbol) + visitDeclaration = func(sym *symbols.Symbol) { + if sym == nil || seenDeclarations[sym] || cached.Index.IsLibraryDocument(sym.DocName) { + return + } + seenDeclarations[sym] = true + key := originKey(sym.Origin()) + if key != "" && declarations[key] == nil { + declarations[key] = sym + } + if sym.Scope != nil { + for _, member := range sym.Scope.AllMembers() { + visitDeclaration(member) + } + } + } + cached.Index.Registered(func(_ string, syms []*symbols.Symbol) { + for _, sym := range syms { + visitDeclaration(sym) + } + }) subject := func(origin symbols.Origin) ViewSubject { id := originKey(origin) ref := ViewSubject{ID: id, QualifiedName: byOrigin[id]} @@ -104,43 +154,136 @@ func (e *Engine) evaluateView(req *JRenderViewRequest) ([]byte, error) { } return ref } - for _, sym := range selected { - ref := subject(sym.Origin()) - if ref.QualifiedName == "" { - ref.QualifiedName = cached.Index.GetFQN(sym) + out.Selected = cached.selectedSubjects(selected) + // Keep complete semantic labels in the bridge; our renderer owns presentation. + shown := view.Shown{} + for _, element := range view.Elements() { + shown[element] = true + } + data := rendering.DataWith(view.Options{Ports: view.PortsFull, Show: shown}) + // The supported tree/structural projection retains explicit declaration + // ownership. 0.12 additionally expands inherited usages; selected roots + // remain native, while their implicit inherited children stay out of scope. + if data.Kind == view.KindTree || data.Kind == view.KindInterconnection { + kept := map[string]bool{} + redirect := map[string]string{} + original := map[string]view.NodeData{} + for _, node := range data.Nodes { + original[node.ID] = node } - if ref.ID == "" { - ref.ID = "unlocated:" + ref.QualifiedName + exposed := map[string]bool{} + for _, sym := range selected { + exposed[originKey(sym.Origin())] = true + } + nodes := data.Nodes[:0] + for _, node := range data.Nodes { + syntheticSubject := false + unsupportedConstraint := false + if sym := declarations[originKey(node.Origin)]; sym != nil { + if rel, ok := sym.Decl.(*ast.Relationship); ok && rel.Kind == ast.RelSubject { + syntheticSubject = true + } + unsupportedConstraint = data.Kind == view.KindInterconnection && (sym.Kind == symbols.SymbolConstraintDef || sym.Kind == symbols.SymbolConstraintUsage) + } + if node.Inherited || syntheticSubject || unsupportedConstraint || (node.Parent != "" && !kept[node.Parent]) { + redirect[node.ID] = redirect[node.Parent] + continue + } + redirect[node.ID] = node.ID + kept[node.ID] = true + nodes = append(nodes, node) } - out.Selected = append(out.Selected, ref) + data.Nodes = nodes + edges := data.Edges[:0] + for _, edge := range data.Edges { + if data.Kind == view.KindInterconnection { + // Keep connectors declared in a visible occurrence, or explicitly + // exposed. Do not leak a type's inherited internal connections. + if declaration := declarations[originKey(edge.Origin)]; declaration != nil && !exposed[originKey(edge.Origin)] { + owner := declaration.Owner() + visibleOwner := false + for _, endpoint := range []string{edge.From, edge.To} { + for id := endpoint; id != ""; id = original[id].Parent { + if owner != nil && kept[id] && originKey(original[id].Origin) == originKey(owner.Origin()) { + visibleOwner = true + break + } + } + } + if !visibleOwner { + continue + } + } + // Native hierarchy records the nearest retained boundary. A + // hidden internal pin cannot become a pin on that boundary. + if from := redirect[edge.From]; from != "" && from != edge.From { + edge.From, edge.FromPort = from, "" + } + if to := redirect[edge.To]; to != "" && to != edge.To { + edge.To, edge.ToPort = to, "" + } + } + if kept[edge.From] && kept[edge.To] { + edges = append(edges, edge) + } + } + data.Edges = edges } - renderer := view.NewRenderer(typed, cached.resolver, cached.sourceText()) - rendering, err := renderer.Render(matches[0]) - if err != nil { - var unsupported *view.UnsupportedKindError - if errors.As(err, &unsupported) && unsupported.Kind == view.KindGeometry { - return e.studioGeometry(req, selected, typed, out) + // Version-1 presentations key positions by the native ordinal symbol IDs. + // After projecting away new inherited/attribute nodes, retain the existing + // declaration-order numbering and rewrite every dependent endpoint/port. + if data.Kind == view.KindTree || data.Kind == view.KindInterconnection { + ids := map[string]string{"": ""} + for i, node := range data.Nodes { + id := fmt.Sprintf("n%d", i) + ids[node.ID] = id + for j, port := range node.Ports { + ids[port.ID] = fmt.Sprintf("%s.%d", id, j) + } + } + for i := range data.Nodes { + node := &data.Nodes[i] + node.ID = ids[node.ID] + node.Parent = ids[node.Parent] + for j := range node.Ports { + node.Ports[j].ID = ids[node.Ports[j].ID] + } + } + for i := range data.Edges { + edge := &data.Edges[i] + edge.From = ids[edge.From] + edge.To = ids[edge.To] + edge.FromPort = ids[edge.FromPort] + edge.ToPort = ids[edge.ToPort] } - out.RenderStatus = "unsupported" - out.Diagnostics = append(out.Diagnostics, err.Error()) - return marshal(out) } - if len(failures) > 0 { - out.Status = "indeterminate" - out.Selected = []ViewSubject{} - for reason := range failures { - out.Diagnostics = append(out.Diagnostics, reason) + // Requirement edge keywords are presentation; polarity and kind travel + // separately and the browser supplies their accessible labels. + for i := range data.Edges { + if data.Edges[i].Kind == view.EdgeSatisfy { + data.Edges[i].Label = data.Edges[i].Name + } + } + // 0.12 separates association end labels from the edge label. The + // browser's tree contract exposes the composed member as its label. + if data.Kind == view.KindTree { + names := map[string]string{} + for _, node := range data.Nodes { + names[node.ID] = node.Name + } + for i := range data.Edges { + edge := &data.Edges[i] + if (edge.Kind == view.EdgeComposition || edge.Kind == view.EdgeReference) && edge.Label == "" { + edge.Label = names[edge.To] + } } - sort.Strings(out.Diagnostics) - return marshal(out) } - data := rendering.DataFor(view.PortsFull) - out.Artifact, err = marshalRenderView(data) + out.Artifact, err = marshalRenderView(cached, data) if err != nil { return nil, err } if data.Kind == view.KindInterconnection { - out.Artifact, err = structuralNotation(out.Artifact, data, declarations) + out.Artifact, err = cached.structuralNotation(out.Artifact, data, declarations) if err != nil { return nil, err } @@ -151,6 +294,48 @@ func (e *Engine) evaluateView(req *JRenderViewRequest) ([]byte, error) { return nil, err } } + // Native JSON omits empty collections; available empty graphs keep arrays. + var artifact map[string]json.RawMessage + if err = json.Unmarshal(out.Artifact, &artifact); err != nil { + return nil, err + } + for _, key := range []string{"nodes", "edges"} { + if len(artifact[key]) == 0 || string(artifact[key]) == "null" { + artifact[key] = json.RawMessage(`[]`) + } + } + var nodeObjects []map[string]json.RawMessage + if err = json.Unmarshal(artifact["nodes"], &nodeObjects); err != nil { + return nil, err + } + for _, node := range nodeObjects { + if len(node["name"]) == 0 { + node["name"] = json.RawMessage(`""`) + } + if len(node["ports"]) > 0 { + var ports []map[string]json.RawMessage + if err = json.Unmarshal(node["ports"], &ports); err != nil { + return nil, err + } + for _, port := range ports { + if len(port["name"]) == 0 { + port["name"] = json.RawMessage(`""`) + } + } + node["ports"], err = json.Marshal(ports) + if err != nil { + return nil, err + } + } + } + artifact["nodes"], err = json.Marshal(nodeObjects) + if err != nil { + return nil, err + } + out.Artifact, err = json.Marshal(artifact) + if err != nil { + return nil, err + } out.RenderStatus = "available" contexts := map[string][]string{} for _, node := range data.Nodes { @@ -171,18 +356,46 @@ func (e *Engine) evaluateView(req *JRenderViewRequest) ([]byte, error) { return marshal(out) } +func (cached *cachedModel) selectedSubjects(selected []*symbols.Symbol) []ViewSubject { + byOrigin := map[string]string{} + cached.Index.Registered(func(_ string, syms []*symbols.Symbol) { + for _, sym := range syms { + if sym.Origin().Located() { + byOrigin[originKey(sym.Origin())] = cached.Index.GetFQN(sym) + } + } + }) + out := []ViewSubject{} + for _, sym := range selected { + origin := sym.Origin() + ref := ViewSubject{ID: originKey(origin), QualifiedName: byOrigin[originKey(origin)]} + if ref.QualifiedName == "" { + ref.QualifiedName = cached.Index.GetFQN(sym) + } + if origin.Located() { + ref.Origin = &ViewOrigin{File: origin.Doc, Offset: origin.Span.Offset, Length: origin.Span.Len, NameOffset: origin.Name.Offset, NameLength: origin.Name.Len} + } + if ref.ID == "" { + ref.ID = "unlocated:" + ref.QualifiedName + } + out = append(out, ref) + } + return out +} + // The pinned renderer leaves structural pins undirected and omits reference // modifiers. Carry only explicit declaration modifiers, using recorded origins; // do not infer flow direction from port types, conjugation or connector order. -func structuralNotation(raw []byte, data view.Data, declarations map[string]*symbols.Symbol) ([]byte, error) { +func (cached *cachedModel) structuralNotation(raw []byte, data view.Data, declarations map[string]*symbols.Symbol) ([]byte, error) { var base JRenderViewResponse if err := json.Unmarshal(raw, &base); err != nil { return nil, err } type node struct { JRenderNode - Reference bool `json:"reference,omitempty"` - Direction string `json:"direction,omitempty"` + Reference bool `json:"reference,omitempty"` + Direction string `json:"direction,omitempty"` + AttributeDeclarations []string `json:"attributeDeclarations,omitempty"` } out := struct { *JRenderViewResponse @@ -197,6 +410,14 @@ func structuralNotation(raw []byte, data view.Data, declarations map[string]*sym } for i, original := range data.Nodes { item := node{JRenderNode: base.Nodes[i]} + if sym := declarations[originKey(original.Origin)]; sym != nil { + text := cached.sourceText() + for _, member := range cached.typedModel().MembersOf(sym) { + if member.Kind == symbols.SymbolAttributeUsage && member.Decl != nil && !cached.Index.IsLibraryDocument(member.DocName) { + item.AttributeDeclarations = append(item.AttributeDeclarations, text(member.DocName, member.Decl.Span())) + } + } + } if decl := usage(original.Origin); decl != nil { item.Reference = decl.IsReference if decl.Direction != ast.DirNone { diff --git a/engine-bridge/opensysml/exploration.go b/engine-bridge/opensysml/exploration.go new file mode 100644 index 0000000..45659cf --- /dev/null +++ b/engine-bridge/opensysml/exploration.go @@ -0,0 +1,114 @@ +package engine + +import ( + "encoding/json" + "fmt" + "sort" + + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +type ExplorationScope struct { + Kind string `json:"kind"` + Subject string `json:"subject,omitempty"` +} +type ExplorationRequest struct { + Scope ExplorationScope `json:"scope"` + Representation string `json:"representation"` +} +type JExploreRequest struct { + ModelHash string `json:"modelHash"` + Request ExplorationRequest `json:"request"` +} + +// Exploration has no authored view identity. Only normalized artifact data is +// shared with named views; the wire envelope cannot become a presentation key. +func (e *Engine) explore(req *JExploreRequest) ([]byte, error) { + cached, ok := e.get(req.ModelHash) + if !ok { + return nil, statusErrorf(codeNotFound, msgModelNotFound, req.ModelHash) + } + out := EvaluatedView{SchemaVersion: 1, ModelHash: req.ModelHash, Status: "complete", Selected: []ViewSubject{}, Diagnostics: []string{}, RenderStatus: "unavailable", Correspondence: []ViewCorrespondence{}} + invalid := func(message string) ([]byte, error) { + out.Status = "invalid" + out.Diagnostics = append(out.Diagnostics, message) + return marshalExploration(out, req.Request) + } + scope := req.Request.Scope + if req.Request.Representation != "tree" && req.Request.Representation != "interconnection" { + return invalid("Unsupported exploration representation.") + } + if scope.Kind != "element" && req.Request.Representation != "tree" { + return invalid("Project and package exploration require an overview tree.") + } + selected := []*symbols.Symbol{} + if scope.Kind == "project" && scope.Subject == "" { + seen := map[*symbols.Symbol]bool{} + for _, doc := range cached.Documents { + root := cached.Index.DocumentRoot(doc.Source.Name()) + if root == nil { + continue + } + for _, sym := range root.AllMembers() { + if sym.Origin().Located() && !cached.Index.IsLibraryDocument(sym.DocName) && !seen[sym] { + seen[sym] = true + selected = append(selected, sym) + } + } + } + sort.Slice(selected, func(i, j int) bool { + return originKey(selected[i].Origin()) < originKey(selected[j].Origin()) + }) + } else if (scope.Kind == "package" || scope.Kind == "element") && scope.Subject != "" { + matches := lookupNamed(cached.Index, scope.Subject) + if len(matches) != 1 || !matches[0].Origin().Located() || cached.Index.IsLibraryDocument(matches[0].DocName) { + return invalid("Choose one unambiguous project declaration.") + } + sym := matches[0] + if scope.Kind == "package" && sym.Kind != symbols.SymbolPackage { + return invalid("Package exploration requires a package declaration.") + } + if req.Request.Representation == "interconnection" && sym.Kind != symbols.SymbolPartDef && sym.Kind != symbols.SymbolPartUsage { + return invalid("Structural exploration requires an explicit part definition or usage.") + } + selected = append(selected, sym) + } else { + return invalid("Invalid exploration scope.") + } + cached.typed = nil + typed := cached.typedModel() + renderer := view.NewRenderer(typed, cached.resolver, cached.sourceText()) + rendering, err := renderer.RenderExposed(selected, view.Kind(req.Request.Representation), "Read-only exploration") + if err != nil { + out.RenderStatus = "unsupported" + out.Diagnostics = append(out.Diagnostics, err.Error()) + return marshalExploration(out, req.Request) + } + raw, err := cached.normalizedRendering(out, selected, rendering) + if err != nil { + return nil, err + } + if err = json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("normalize exploration: %w", err) + } + return marshalExploration(out, req.Request) +} + +func marshalExploration(out EvaluatedView, request ExplorationRequest) ([]byte, error) { + // Drop the named-view field rather than serialize an invented view identity. + raw, err := json.Marshal(out) + if err != nil { + return nil, err + } + var result map[string]json.RawMessage + if err = json.Unmarshal(raw, &result); err != nil { + return nil, err + } + delete(result, "view") + result["request"], err = json.Marshal(request) + if err != nil { + return nil, err + } + return json.Marshal(result) +} diff --git a/engine-bridge/opensysml/exploration_test.go b/engine-bridge/opensysml/exploration_test.go new file mode 100644 index 0000000..c8aef47 --- /dev/null +++ b/engine-bridge/opensysml/exploration_test.go @@ -0,0 +1,76 @@ +package combined + +import ( + "context" + "encoding/json" + "testing" +) + +func TestStudioExploration(t *testing.T) { + server, err := New("v0.12.0") + if err != nil { + t.Fatal(err) + } + call := func(method string, params any) map[string]any { + t.Helper() + raw, _ := json.Marshal(params) + body, err := server.Call(context.Background(), method, raw) + if err != nil { + t.Fatal(err) + } + var out map[string]any + if err := json.Unmarshal(body, &out); err != nil { + t.Fatal(err) + } + return out + } + documents := []any{ + map[string]any{"name": "model.sysml", "content": "package P { /* 🔧 */ part def Motor; part def System { part primary : Motor; part standby : Motor; } part system : System; }"}, + map[string]any{"name": "other.sysml", "content": "package Q { part def Other; part other : Other; }"}, + } + parsed := call("ParseSources", map[string]any{"documents": documents, "strictConformance": true}) + for _, tc := range []struct { + kind, subject, representation string + count int + }{ + {"project", "", "tree", 9}, {"package", "P", "tree", 6}, + {"element", "P::system", "tree", 1}, {"element", "P::System", "interconnection", 3}, {"element", "P::system", "interconnection", 1}, + } { + scope := map[string]any{"kind": tc.kind} + if tc.subject != "" { + scope["subject"] = tc.subject + } + result := call("Explore", map[string]any{"modelHash": parsed["modelHash"], "request": map[string]any{"scope": scope, "representation": tc.representation}}) + if result["status"] != "complete" || result["renderStatus"] != "available" || result["view"] != nil { + t.Fatalf("%v", result) + } + artifact := result["artifact"].(map[string]any) + if artifact["kind"] != tc.representation || len(artifact["nodes"].([]any)) != tc.count { + t.Fatalf("%v", artifact) + } + if _, ok := artifact["edges"].([]any); !ok { + t.Fatalf("Missing empty edge array: %v", artifact) + } + for _, raw := range result["correspondence"].([]any) { + item := raw.(map[string]any) + if item["subject"].(map[string]any)["origin"] == nil { + t.Fatalf("Missing origin: %v", item) + } + } + } + for _, scope := range []map[string]any{{"kind": "package", "subject": "P::Motor"}, {"kind": "element", "subject": "Missing"}, {"kind": "element", "subject": "Base::Anything"}} { + result := call("Explore", map[string]any{"modelHash": parsed["modelHash"], "request": map[string]any{"scope": scope, "representation": "tree"}}) + if result["status"] != "invalid" || result["artifact"] != nil { + t.Fatalf("%v", result) + } + } + after := call("ParseSources", map[string]any{"documents": documents, "strictConformance": true}) + if after["modelHash"] != parsed["modelHash"] { + t.Fatal("Exploration changed source") + } + invalid := call("ParseSources", map[string]any{"documents": []any{map[string]any{"name": "bad.sysml", "content": "package Bad { part p : Missing; }"}}, "strictConformance": true}) + rejected := call("Explore", map[string]any{"modelHash": invalid["modelHash"], "request": map[string]any{"scope": map[string]any{"kind": "project"}, "representation": "tree"}}) + if rejected["status"] != "invalid" || rejected["artifact"] != nil { + t.Fatalf("%v", rejected) + } +} diff --git a/engine-bridge/opensysml/relationships.go b/engine-bridge/opensysml/relationships.go index 510b735..588e302 100644 --- a/engine-bridge/opensysml/relationships.go +++ b/engine-bridge/opensysml/relationships.go @@ -7,15 +7,25 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) type StudioRelationship struct { Kind string `json:"kind"` + Negated bool `json:"negated"` + Asserted bool `json:"asserted"` Declaration ViewSubject `json:"declaration"` Source ViewSubject `json:"source"` Target ViewSubject `json:"target"` } +type StudioRequirement struct { + Subject ViewSubject `json:"subject"` + Kind string `json:"kind"` + ShortName string `json:"shortName"` + Documentation []string `json:"documentation"` +} + // These are declared, resolved relationships, not satisfaction or verification verdicts. func (e *Engine) studioRelationships(req *JRenderViewRequest) ([]byte, error) { cached, ok := e.get(req.ModelHash) @@ -35,6 +45,7 @@ func (e *Engine) studioRelationships(req *JRenderViewRequest) ([]byte, error) { return ref } edges := []StudioRelationship{} + requirements := []StudioRequirement{} diagnostics := []string{} seen := map[*symbols.Symbol]bool{} var visit func(*symbols.Symbol) @@ -43,11 +54,34 @@ func (e *Engine) studioRelationships(req *JRenderViewRequest) ([]byte, error) { return } seen[sym] = true + isRequirement := sym.Kind == symbols.SymbolRequirementDef + if usage, ok := sym.Decl.(*ast.Usage); ok && usage.Kind == ast.UsageRequirement { + isRequirement = true + } + if isRequirement { + row := StudioRequirement{Subject: subject(sym), Kind: sym.Kind.String(), ShortName: sym.ShortName, Documentation: []string{}} + if sym.Scope != nil { + sym.Scope.ForEachMember(func(member *symbols.Symbol) bool { + if doc, ok := member.Decl.(*ast.Documentation); ok { + if body := source.CommentProse(cached.sourceText()(member.DocName, doc.BodySpan)); body != "" { + row.Documentation = append(row.Documentation, body) + } + } + return true + }) + } + requirements = append(requirements, row) + } before := len(edges) for _, kind := range []semantics.RelationshipKind{semantics.RelationshipAllocation, semantics.RelationshipSatisfaction, semantics.RelationshipVerification} { for _, edge := range typed.RelationshipEdgesOf(sym, kind) { if edge.Source != nil && edge.Target != nil { - edges = append(edges, StudioRelationship{Kind: string(kind), Declaration: subject(sym), Source: subject(edge.Source), Target: subject(edge.Target)}) + link := StudioRelationship{Kind: string(kind), Declaration: subject(sym), Source: subject(edge.Source), Target: subject(edge.Target)} + if usage, ok := sym.Decl.(*ast.Usage); ok { + link.Negated = usage.IsNegated + link.Asserted = usage.PrefixKeyword == "assert" + } + edges = append(edges, link) } } } @@ -78,11 +112,13 @@ func (e *Engine) studioRelationships(req *JRenderViewRequest) ([]byte, error) { } return a.Target.ID < b.Target.ID }) + sort.Slice(requirements, func(i, j int) bool { return requirements[i].Subject.ID < requirements[j].Subject.ID }) status := "complete" if len(diagnostics) > 0 { status = "indeterminate" edges = []StudioRelationship{} + requirements = []StudioRequirement{} sort.Strings(diagnostics) } - return marshal(map[string]any{"schemaVersion": 1, "modelHash": req.ModelHash, "status": status, "relationships": edges, "diagnostics": diagnostics}) + return marshal(map[string]any{"schemaVersion": 1, "modelHash": req.ModelHash, "status": status, "relationships": edges, "requirements": requirements, "diagnostics": diagnostics}) } diff --git a/engine-bridge/opensysml/relationships_test.go b/engine-bridge/opensysml/relationships_test.go index 20a2f2c..6139ea7 100644 --- a/engine-bridge/opensysml/relationships_test.go +++ b/engine-bridge/opensysml/relationships_test.go @@ -8,7 +8,7 @@ import ( ) func TestStudioRelationships(t *testing.T) { - server, err := New("v0.10.0") + server, err := New("v0.12.0") if err != nil { t.Fatal(err) } @@ -26,7 +26,7 @@ func TestStudioRelationships(t *testing.T) { return out } documents := []any{ - map[string]any{"name": "requirements.sysml", "content": `package Requirements { requirement safety; alias Safe for safety; }`}, + map[string]any{"name": "requirements.sysml", "content": `package Requirements { requirement <'R-1'> safety { doc /* Keep the system safe. */ } alias Safe for safety; }`}, map[string]any{"name": "system.sysml", "content": `package System { private import Requirements::*; part pump { satisfy Safe; } @@ -41,6 +41,14 @@ func TestStudioRelationships(t *testing.T) { if result["status"] != "complete" { t.Fatalf("%v", result) } + rows := result["requirements"].([]any) + if len(rows) != 1 { + t.Fatalf("Expected one requirement, got %v", rows) + } + row := rows[0].(map[string]any) + if row["shortName"] != "R-1" || row["kind"] != "requirementUsage" || !reflect.DeepEqual(row["documentation"], []any{"Keep the system safe."}) { + t.Fatalf("Lost requirement content: %v", row) + } pairs := map[string][][2]string{} for _, raw := range result["relationships"].([]any) { edge := raw.(map[string]any) @@ -84,6 +92,15 @@ func TestStudioRelationships(t *testing.T) { if noEnds["status"] == "complete" || len(noEnds["relationships"].([]any)) != 0 { t.Fatalf("Missing endpoints were called complete: %v", noEnds) } + negative := call("ParseSources", map[string]any{"documents": []any{map[string]any{"name": "negative.sysml", "content": "package N { requirement r; part p; assert not satisfy r by p; }"}}, "strictConformance": true}) + negLinks := call("EvaluateRelationships", map[string]any{"modelHash": negative["modelHash"]}) + if negLinks["status"] != "complete" { + t.Fatalf("%v", negLinks) + } + negativeEdges := negLinks["relationships"].([]any) + if len(negativeEdges) != 1 || negativeEdges[0].(map[string]any)["negated"] != true || negativeEdges[0].(map[string]any)["asserted"] != true { + t.Fatalf("Lost negative assertion: %v", negativeEdges) + } if _, err := server.Call(context.Background(), "EvaluateRelationships", []byte(`{"modelHash":"absent"}`)); err == nil { t.Fatal("Missing revision was accepted") } diff --git a/engine-bridge/opensysml/requirements_view_test.go b/engine-bridge/opensysml/requirements_view_test.go index 279b512..38e49d1 100644 --- a/engine-bridge/opensysml/requirements_view_test.go +++ b/engine-bridge/opensysml/requirements_view_test.go @@ -8,7 +8,7 @@ import ( ) func TestStudioRequirementsNotation(t *testing.T) { - server, err := New("v0.10.0") + server, err := New("v0.12.0") if err != nil { t.Fatal(err) } diff --git a/engine-bridge/opensysml/validated_view.go b/engine-bridge/opensysml/validated_view.go index 6058540..1f2d2c5 100644 --- a/engine-bridge/opensysml/validated_view.go +++ b/engine-bridge/opensysml/validated_view.go @@ -60,7 +60,7 @@ func (s *Server) evaluateRelationships(ctx context.Context, params []byte) ([]by } for _, diagnostic := range validation.Diagnostics { if diagnostic.Severity == "error" { - return json.Marshal(map[string]any{"schemaVersion": 1, "modelHash": req.ModelHash, "status": "invalid", "relationships": []any{}, "diagnostics": []string{"Model validation failed; relationship endpoints are unavailable."}}) + return json.Marshal(map[string]any{"schemaVersion": 1, "modelHash": req.ModelHash, "status": "invalid", "relationships": []any{}, "requirements": []any{}, "diagnostics": []string{"Model validation failed; relationship endpoints are unavailable."}}) } } body, err = s.engine.Call(ctx, "EvaluateRelationships", params) @@ -69,3 +69,32 @@ func (s *Server) evaluateRelationships(ctx context.Context, params []byte) ([]by } return body, err } + +func (s *Server) explore(ctx context.Context, params []byte) ([]byte, error) { + var req engine.JExploreRequest + if err := json.Unmarshal(params, &req); err != nil { + return nil, err + } + body, err := s.core.Call(ctx, "GetDiagnostics", params) + if err != nil { + return nil, err + } + var validation struct { + Diagnostics []struct { + Severity string `json:"severity"` + } `json:"diagnostics"` + } + if err = json.Unmarshal(body, &validation); err != nil { + return nil, err + } + for _, diagnostic := range validation.Diagnostics { + if diagnostic.Severity == "error" { + return json.Marshal(map[string]any{"schemaVersion": 1, "modelHash": req.ModelHash, "request": req.Request, "status": "invalid", "selected": []any{}, "diagnostics": []string{"Model validation failed; correct source diagnostics before exploring."}, "renderStatus": "unavailable", "correspondence": []any{}}) + } + } + body, err = s.engine.Call(ctx, "Explore", params) + if err == nil { + s.touchRequest(params) + } + return body, err +} diff --git a/engine-bridge/opensysml/validated_view_test.go b/engine-bridge/opensysml/validated_view_test.go index 7b50e05..ee67402 100644 --- a/engine-bridge/opensysml/validated_view_test.go +++ b/engine-bridge/opensysml/validated_view_test.go @@ -9,7 +9,7 @@ import ( ) func TestStudioStructuralAssembly(t *testing.T) { - server, err := New("v0.10.0") + server, err := New("v0.12.0") if err != nil { t.Fatal(err) } @@ -102,7 +102,7 @@ func TestStudioStructuralAssembly(t *testing.T) { } func TestStudioEvaluatedView(t *testing.T) { - server, err := New("v0.10.0") + server, err := New("v0.12.0") if err != nil { t.Fatal(err) } @@ -169,7 +169,7 @@ func TestStudioEvaluatedView(t *testing.T) { } func TestStudioGeometryDimensions(t *testing.T) { - server, err := New("v0.10.0") + server, err := New("v0.12.0") if err != nil { t.Fatal(err) } @@ -220,7 +220,7 @@ func TestStudioGeometryDimensions(t *testing.T) { } func TestStudioGeometryPlacement(t *testing.T) { - server, err := New("v0.10.0") + server, err := New("v0.12.0") if err != nil { t.Fatal(err) } @@ -284,3 +284,91 @@ func TestStudioGeometryPlacement(t *testing.T) { } } } + +func TestStudioDeclaredAttributeValues(t *testing.T) { + server, err := New("v0.12.0") + if err != nil { + t.Fatal(err) + } + call := func(method string, params any) map[string]any { + t.Helper() + p, _ := json.Marshal(params) + raw, err := server.Call(context.Background(), method, p) + if err != nil { + t.Fatal(err) + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + return out + } + source := "package P { private import ScalarValues::*; part def Battery { attribute capacityWh : Real default = 480.0; } part def Rover { part left : Battery; part right : Battery; } } package V { private import StandardViewDefinitions::*; view architecture : InterconnectionView { expose P::Rover; } }" + parsed := call("ParseSources", map[string]any{"strictConformance": true, "documents": []any{map[string]any{"name": "model.sysml", "content": source}}}) + result := call("EvaluateView", map[string]any{"modelHash": parsed["modelHash"], "view": "V::architecture"}) + if result["status"] != "complete" { + t.Fatalf("%v", result) + } + found := 0 + for _, raw := range result["artifact"].(map[string]any)["nodes"].([]any) { + n := raw.(map[string]any) + if n["name"] == "left" || n["name"] == "right" { + values := n["attributeDeclarations"].([]any) + if len(values) != 1 || values[0] != "attribute capacityWh : Real default = 480.0;" { + t.Fatalf("%v", n) + } + found++ + } + } + if found != 2 { + t.Fatalf("Expected two independently typed usages, found %d", found) + } +} + +func TestStudioRetainsDeclaredBoundaryConnections(t *testing.T) { + server, err := New("v0.12.0") + if err != nil { + t.Fatal(err) + } + call := func(method string, params any) map[string]any { + t.Helper() + body, _ := json.Marshal(params) + raw, err := server.Call(context.Background(), method, body) + if err != nil { + t.Fatal(err) + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + return out + } + parsed := call("ParseSources", map[string]any{"strictConformance": true, "documents": []any{map[string]any{"name": "model.sysml", "content": `package P { + port def Signal; + part def Sender { out port p : Signal; } + part def Receiver { in port p : Signal; } + part def Subsystem { + part sender : Sender; part receiver : Receiver; + connection internal connect sender.p to receiver.p; + } + part def System { + part a : Subsystem; part b : Subsystem; + connection bridge connect a.sender.p to b.receiver.p; + } + view v : StandardViewDefinitions::InterconnectionView { expose System; } +}`}}}) + result := call("EvaluateView", map[string]any{"modelHash": parsed["modelHash"], "view": "P::v"}) + if result["status"] != "complete" || result["renderStatus"] != "available" { + t.Fatalf("%v", result) + } + artifact := result["artifact"].(map[string]any) + nodes := artifact["nodes"].([]any) + edges := artifact["edges"].([]any) + if len(nodes) != 3 || len(edges) != 1 { + t.Fatalf("Inherited internals leaked or declared connection lost: %v", artifact) + } + edge := edges[0].(map[string]any) + if edge["from"] != "n1" || edge["to"] != "n2" || edge["fromPort"] != nil || edge["toPort"] != nil || edge["label"] != "bridge" { + t.Fatalf("Incorrect retained usage boundaries: %v", edge) + } +} diff --git a/examples/README.md b/examples/README.md index 129f793..4ab5468 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,22 +6,23 @@ The “blocks” examples use SysML v2 `part def` and `part` declarations. Lexic Status is per view: **rendered** covers the stated fixture scope; **partial** shows a useful subset with recorded gaps; **unavailable** means the engine evaluates the view but the app reports that its renderer is unavailable. No examples are silently skipped for missing renderers. Targets describe intended visual quality and need visual review; passing source/engine checks alone does not satisfy every target. -| Project | Topics | Named views and current app status | -| -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Comments and documentation](comments/model.sysml) | comments, documentation, annotations, unicode, long-labels | `overview`: partial; `maintenance`: partial | -| [Packages and imports](packages/model.sysml) | packages, imports, ownership, multiple-files | `outline`: partial; `dependencies`: unavailable | -| [Parts and definitions (blocks)](parts/model.sysml) | parts, blocks, definitions, usages, specialization, references, repeated-names | `structure`: rendered; `definitions`: partial | -| [Ports and conjugation](ports/model.sysml) | ports, interfaces, connections, conjugation | `link`: partial | -| [Use cases and actors](use-cases/model.sysml) | use-cases, actors, subjects, include, objectives | `scenarios`: unavailable; `definitions`: unavailable | -| [Requirements and satisfaction](requirements/model.sysml) | requirements, documentation, constraints, satisfaction | `requirements`: partial | -| [Requirement hierarchy and satisfaction](requirements-traceability/system.sysml) | requirements, hierarchy, typing, satisfaction, negation, aliases, multiple-files, repeated-names | `requirements`: partial; `overview`: partial | -| [Actions and successions](actions/model.sysml) | actions, behavior, succession, parameters | `process`: unavailable | -| [States and transitions](states/model.sysml) | states, nested-states, transitions, initial-state | `states`: unavailable | -| [Messages and sequence ordering](sequences/model.sysml) | sequences, messages, occurrences, ordering | `exchange`: unavailable | -| [Filtered and empty views](filters/model.sysml) | filters, metadata, recursive-exposure, empty-view | `reviewed`: rendered; `empty`: rendered | -| [Workshop foundation](workshop/model.sysml) | multiple-files, shared-types, rename, tree | `overview`: rendered; `compressor`: rendered | -| [Nested assembly](assembly/model.sysml) | assembly, nested-parts, directed-ports, connections, references, usage-context | `structure`: rendered; `devices`: partial | -| [Smart air workshop](air-compressor/system.sysml) | multiple-files, specialization, directed-ports, conjugation, connections, attributes, constraints, requirements, actions, states, use-cases, sequences, geometry | `overview`: partial; `structure`: partial; `installation`: partial; `pneumatics`: partial; `definitions`: partial; `requirements`: partial; `actions`: unavailable; `maintenance`: unavailable; `useCases`: unavailable; `states`: unavailable; `startupSequence`: unavailable; `demandSequence`: unavailable; `faultSequence`: unavailable; `envelopeGeometry`: unavailable | +| Project | Topics | Named views and current app status | +| -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Comments and documentation](comments/model.sysml) | comments, documentation, annotations, unicode, long-labels | `overview`: partial; `maintenance`: partial | +| [Packages and imports](packages/model.sysml) | packages, imports, ownership, multiple-files | `outline`: partial; `dependencies`: unavailable | +| [Parts and definitions (blocks)](parts/model.sysml) | parts, blocks, definitions, usages, specialization, references, repeated-names | `structure`: rendered; `definitions`: partial | +| [Ports and conjugation](ports/model.sysml) | ports, interfaces, connections, conjugation | `link`: partial | +| [Use cases and actors](use-cases/model.sysml) | use-cases, actors, subjects, include, objectives | `scenarios`: unavailable; `definitions`: unavailable | +| [Requirements and satisfaction](requirements/model.sysml) | requirements, documentation, constraints, satisfaction | `requirements`: partial | +| [Requirement hierarchy and satisfaction](requirements-traceability/system.sysml) | requirements, hierarchy, typing, satisfaction, negation, aliases, multiple-files, repeated-names | `requirements`: partial; `overview`: partial | +| [Actions and successions](actions/model.sysml) | actions, behavior, succession, parameters | `process`: partial | +| [States and transitions](states/model.sysml) | states, nested-states, transitions, initial-state | `states`: partial | +| [Messages and sequence ordering](sequences/model.sysml) | sequences, messages, occurrences, ordering | `exchange`: unavailable | +| [Filtered and empty views](filters/model.sysml) | filters, metadata, recursive-exposure, empty-view | `reviewed`: rendered; `empty`: rendered | +| [Workshop foundation](workshop/model.sysml) | multiple-files, shared-types, rename, tree | `overview`: rendered; `compressor`: rendered | +| [Nested assembly](assembly/model.sysml) | assembly, nested-parts, directed-ports, connections, references, usage-context | `structure`: rendered; `devices`: partial | +| [Smart air workshop](air-compressor/system.sysml) | multiple-files, specialization, directed-ports, conjugation, connections, attributes, constraints, requirements, actions, states, use-cases, sequences, geometry | `overview`: partial; `structure`: partial; `installation`: partial; `pneumatics`: partial; `definitions`: partial; `requirements`: partial; `actions`: partial; `maintenance`: partial; `useCases`: unavailable; `states`: partial; `startupSequence`: unavailable; `demandSequence`: unavailable; `faultSequence`: unavailable; `envelopeGeometry`: unavailable | +| [Inspection rover](inspection-rover/system.sysml) | nested-parts, directed-ports, connections, attributes, specialization, requirements, actions, states | `architecture`: partial; `definitions`: partial; `mission`: partial; `lifecycle`: partial; `requirements`: partial | ## Visual targets and known gaps @@ -35,6 +36,7 @@ Files: [model.sysml](comments/model.sysml), [views.sysml](comments/views.sysml). - Show documentation and semantic comment callouts anchored to the correct element. - Preserve Unicode punctuation and text through download/reopen. - Fit qualified names inside their symbols at a readable scale. +- Retain resolved typing and distinguish declared ownership, references and specialization kinds. Current gaps: Documentation and semantic comment callouts are not drawn or transported as note bodies/anchors by the current bridge. Long qualified labels can extend past the fixed-width tree nodes. @@ -53,6 +55,7 @@ Files: [model.sysml](packages/model.sysml), [views.sysml](packages/views.sysml). - Show nested package/type ownership without merging similarly named elements. - Preserve resolution of Controller and Sensor across private imports. +- Retain resolved typing and distinguish declared ownership, references and specialization kinds. Current gaps: The outline is a flat tree; package containers and import arrows require the package renderer. @@ -140,6 +143,7 @@ Current gaps: Requirement identifiers, documentation and relationships are shown - Retain the same source model as an independent generic overview. - Keep requirement-view and overview positions separate through save and reopen. +- Retain resolved typing and distinguish declared ownership, references and specialization kinds. Current gaps: This GeneralView produces a generic flat tree rather than dedicated requirement notation. @@ -147,25 +151,25 @@ Current gaps: This GeneralView produces a generic flat tree rather than dedicate Files: [model.sysml](actions/model.sysml), [views.sysml](actions/views.sysml). -**WorkflowViews::process** — engine artifact: `action`; app: **unavailable**. +**WorkflowViews::process** — engine artifact: `action`; app: **partial**. - Show the collect, inspect and archive order from start to done. - Retain typed inspection parameters and distinguish them from control succession. - Do not imply that rendering the workflow executes it. -Current gaps: The engine emits an action artifact; the application has no action-flow renderer. +Current gaps: Declared behavior is rendered without execution, simulation or live token/state animation. ### States and transitions Files: [model.sysml](states/model.sysml), [views.sysml](states/views.sysml). -**LifecycleViews::states** — engine artifact: `state`; app: **unavailable**. +**LifecycleViews::states** — engine artifact: `state`; app: **partial**. - Keep processing nested inside active. - Distinguish initial-state markers from named states. - Show startWork and finishWork transition endpoints clearly. -Current gaps: The engine emits a state artifact; the application has no state-transition renderer. +Current gaps: Declared behavior is rendered without execution, simulation or live token/state animation. ### Messages and sequence ordering @@ -201,6 +205,7 @@ Files: [model.sysml](workshop/model.sysml), [views.sysml](workshop/views.sysml). - Show both Motor usages and their shared definition. - Keep the active view stable when changing source files. +- Retain resolved typing and distinguish declared ownership, references and specialization kinds. **WorkshopViews::compressor** — engine artifact: `interconnection`; app: **rendered**. @@ -266,17 +271,17 @@ Current gaps: Definition graphs use generic relationship lines and fixed-width l Current gaps: This GeneralView produces a flat tree; dedicated requirement notation and satisfaction/verification trace links are not drawn. -**CompressorViews::actions** — engine artifact: `action`; app: **unavailable**. +**CompressorViews::actions** — engine artifact: `action`; app: **partial**. - Show normal air supply steps, performer allocations and authored control/item-flow ordering. -Current gaps: The engine emits an action artifact; this application has no action renderer. +Current gaps: Declared behavior is rendered without execution, simulation or live token/state animation. -**CompressorViews::maintenance** — engine artifact: `action`; app: **unavailable**. +**CompressorViews::maintenance** — engine artifact: `action`; app: **partial**. - Show isolation and depressurization before inspection, filter replacement and restoration. -Current gaps: The engine emits an action artifact; this application has no action renderer. +Current gaps: Declared behavior is rendered without execution, simulation or live token/state animation. **CompressorViews::useCases** — engine artifact: `case`; app: **unavailable**. @@ -284,11 +289,11 @@ Current gaps: The engine emits an action artifact; this application has no actio Current gaps: The engine emits a case artifact; this application has no case renderer. -**CompressorViews::states** — engine artifact: `state`; app: **unavailable**. +**CompressorViews::states** — engine artifact: `state`; app: **partial**. - Show operating states and event-triggered transitions without implying execution. -Current gaps: The engine emits a state artifact; this application has no state renderer. +Current gaps: Declared behavior is rendered without execution, simulation or live token/state animation. **CompressorViews::startupSequence** — engine artifact: `sequence`; app: **unavailable**. @@ -316,6 +321,40 @@ Current gaps: The engine emits a geometry artifact; this application has no geom Expected engine notices include: “Dimensions and coordinate-frame poses evaluated in metres. Boxes are centered on their frame origins. Solids are inspected separately; meshes and void subtraction are unsupported.”. +### Inspection rover + +Files: [system.sysml](inspection-rover/system.sysml), [components.sysml](inspection-rover/components.sysml), [types.sysml](inspection-rover/types.sysml), [behavior.sysml](inspection-rover/behavior.sysml), [requirements.sysml](inspection-rover/requirements.sysml), [views.sysml](inspection-rover/views.sysml). + +**RoverViews::architecture** — engine artifact: `interconnection`; app: **partial**. + +- Show the four nested assemblies, shared drive types, declared component values and exact port connections. + +Current gaps: Values are source declarations, not evaluated or simulated results. Effective conjugated directions and execution remain outside scope. + +**RoverViews::definitions** — engine artifact: `definition`; app: **partial**. + +- Show reusable part definitions and explicit actuator/sensor specialization. + +Current gaps: Generic definition graph notation; no full attribute compartments in this view. + +**RoverViews::mission** — engine artifact: `action`; app: **partial**. + +- Show the ordered inspection mission from battery check through patrol, capture, reporting and recharge. + +Current gaps: Declared behavior is rendered without execution, simulation or live token/state animation. + +**RoverViews::lifecycle** — engine artifact: `state`; app: **partial**. + +- Show operating states, initial state, safe stop and return-to-charge transitions. + +Current gaps: Declared behavior is rendered without execution, simulation or live token/state animation. + +**RoverViews::requirements** — engine artifact: `requirement`; app: **partial**. + +- Show four quantified requirements and their exact declared satisfaction targets. + +Current gaps: Satisfaction is declared traceability, not evidence that a test passed. + ## Validate and extend the library Use the repository Node version and existing dependencies. Browser checks build current source and use the real pinned workers: diff --git a/examples/air-compressor/README.md b/examples/air-compressor/README.md index f7a26f4..f931a50 100644 --- a/examples/air-compressor/README.md +++ b/examples/air-compressor/README.md @@ -1,8 +1,8 @@ # Smart air workshop -This is the built-in project opened by **Load example** in the launcher or **Try an example** on the landing page. Its nine SysML files and 14 named views model the compressor's electrical and pneumatic assemblies, cooling, two installed compressor usages, tools, operator/technician roles, requirements, behaviour and enclosure geometry. +This legacy project is retained for explicit imports and regression tests. Import all nine `.sysml` files together; it is not bundled in the launcher. The built-in example is the inspection rover. Its nine SysML files and 14 named views model the compressor's electrical and pneumatic assemblies, cooling, two installed compressor usages, tools, operator/technician roles, requirements, behaviour and enclosure geometry. -Loading the example opens **pneumatics** in Diagram mode: pump, tank, regulator, relief valve and pressure sensor, with 13 displayed ports and four authored connections. **Split** reveals the source; other named views remain available in the sidebar. +Loading this example opens **overview** in Diagram mode. Choose **pneumatics** for the pump, tank, regulator, relief valve and pressure sensor, with 13 displayed ports and four authored connections. **Split** reveals the source; other named views remain available in the sidebar. | File | Features | | -------------------- | -------------------------------------------------------------------------------------------------------- | @@ -21,9 +21,10 @@ Loading the example opens **pneumatics** in Diagram mode: pump, tank, regulator, - **overview**, **structure**, **installation**, **pneumatics**: structural diagrams. Nested typed assemblies can remain component-level nodes, with deeper links collapsed to those nodes. Conjugated type labels are retained, but payload expansion and effective conjugated directions remain incomplete. - **definitions**: part definitions, specialization, typing and composition with generic relationship lines. - **requirements**: a GeneralView tree of the specification and applied subjects; dedicated requirement notation and satisfaction/verification trace links are not drawn. -- **actions**, **maintenance**, **useCases**, **states**, **startupSequence**, **demandSequence**, **faultSequence**, **envelopeGeometry**: evaluated by the pinned engine, with explicit unavailable-renderer messages in this application. +- **actions**, **maintenance**, **states**: bounded native action/state diagrams with declared order/transitions; no execution. +- **useCases**, **startupSequence**, **demandSequence**, **faultSequence**, **envelopeGeometry**: evaluated by the pinned engine, with explicit unavailable-renderer messages in this application. -Attributes and constraints remain authored source; the diagram does not draw full attribute compartments or run a solver. Satisfaction and verification declarations are not evidence of a completed test. Conjugated endpoints cannot be used by the bounded graphical connection command. Use the smaller [assembly fixture](../assembly/model.sysml) to exercise that command's explicit same-type endpoint subset. +Structural diagrams show source declarations for attributes, including inherited type members. Constraints remain source; the app does not run a solver. Satisfaction and verification declarations are not evidence of a completed test. Conjugated endpoints cannot be used by the bounded graphical connection command. Use the smaller [assembly fixture](../assembly/model.sysml) to exercise that command's explicit same-type endpoint subset. The [catalog](../catalog.json) retains exact engine node/relationship expectations, visual targets and gaps for every view. [Verification](../../docs/verification.md) distinguishes engine results, application behaviour and local test evidence. diff --git a/examples/catalog.json b/examples/catalog.json index 415868d..9e52fbd 100644 --- a/examples/catalog.json +++ b/examples/catalog.json @@ -22,12 +22,13 @@ "CommentExample::ServiceLoop", "pump" ], - "edgeKinds": ["composition"], + "edgeKinds": ["composition", "typing"], "targets": [ "Keep line and block comments intact in source.", "Show documentation and semantic comment callouts anchored to the correct element.", "Preserve Unicode punctuation and text through download/reopen.", - "Fit qualified names inside their symbols at a readable scale." + "Fit qualified names inside their symbols at a readable scale.", + "Retain resolved typing and distinguish declared ownership, references and specialization kinds." ], "gaps": [ "Documentation and semantic comment callouts are not drawn or transported as note bodies/anchors by the current bridge.", @@ -72,10 +73,11 @@ "Installation", "controller" ], - "edgeKinds": ["composition", "composition"], + "edgeKinds": ["composition", "typing", "composition", "typing"], "targets": [ "Show nested package/type ownership without merging similarly named elements.", - "Preserve resolution of Controller and Sensor across private imports." + "Preserve resolution of Controller and Sensor across private imports.", + "Retain resolved typing and distinguish declared ownership, references and specialization kinds." ], "gaps": [ "The outline is a flat tree; package containers and import arrows require the package renderer." @@ -380,18 +382,27 @@ "typing", "typing", "typing", - "specialization", - "specialization", - "specialization", + "subsetting", + "subsetting", + "subsetting", "composition", - "specialization", + "reference", "composition", - "specialization", - "specialization" + "typing", + "reference", + "redefinition", + "composition", + "typing", + "reference", + "redefinition", + "typing", + "reference", + "redefinition" ], "targets": [ "Retain the same source model as an independent generic overview.", - "Keep requirement-view and overview positions separate through save and reopen." + "Keep requirement-view and overview positions separate through save and reopen.", + "Retain resolved typing and distinguish declared ownership, references and specialization kinds." ], "gaps": [ "This GeneralView produces a generic flat tree rather than dedicated requirement notation." @@ -409,7 +420,7 @@ { "id": "WorkflowViews::process", "engineKind": "action", - "app": "unavailable", + "app": "partial", "nodeNames": [ "Workflow::Process", "start", @@ -425,7 +436,7 @@ "Do not imply that rendering the workflow executes it." ], "gaps": [ - "The engine emits an action artifact; the application has no action-flow renderer." + "Declared behavior is rendered without execution, simulation or live token/state animation." ], "notices": [] } @@ -440,7 +451,7 @@ { "id": "LifecycleViews::states", "engineKind": "state", - "app": "unavailable", + "app": "partial", "nodeNames": [ "Lifecycle::StationStates", "", @@ -456,7 +467,7 @@ "Show startWork and finishWork transition endpoints clearly." ], "gaps": [ - "The engine emits a state artifact; the application has no state-transition renderer." + "Declared behavior is rendered without execution, simulation or live token/state animation." ], "notices": [] } @@ -539,10 +550,11 @@ "primary", "standby" ], - "edgeKinds": ["composition", "composition"], + "edgeKinds": ["composition", "composition", "typing", "typing"], "targets": [ "Show both Motor usages and their shared definition.", - "Keep the active view stable when changing source files." + "Keep the active view stable when changing source files.", + "Retain resolved typing and distinguish declared ownership, references and specialization kinds." ], "gaps": [], "notices": [] @@ -812,7 +824,7 @@ { "id": "CompressorViews::actions", "engineKind": "action", - "app": "unavailable", + "app": "partial", "nodeNames": [ "CompressorBehavior::CompressAir", "sense", @@ -847,14 +859,14 @@ "Show normal air supply steps, performer allocations and authored control/item-flow ordering." ], "gaps": [ - "The engine emits an action artifact; this application has no action renderer." + "Declared behavior is rendered without execution, simulation or live token/state animation." ], "notices": [] }, { "id": "CompressorViews::maintenance", "engineKind": "action", - "app": "unavailable", + "app": "partial", "nodeNames": [ "CompressorBehavior::ServiceCompressor", "isolate", @@ -877,7 +889,7 @@ "Show isolation and depressurization before inspection, filter replacement and restoration." ], "gaps": [ - "The engine emits an action artifact; this application has no action renderer." + "Declared behavior is rendered without execution, simulation or live token/state animation." ], "notices": [] }, @@ -949,7 +961,7 @@ { "id": "CompressorViews::states", "engineKind": "state", - "app": "unavailable", + "app": "partial", "nodeNames": [ "CompressorBehavior::OperatingMode", "", @@ -983,7 +995,7 @@ "Show operating states and event-triggered transitions without implying execution." ], "gaps": [ - "The engine emits a state artifact; this application has no state renderer." + "Declared behavior is rendered without execution, simulation or live token/state animation." ], "notices": [] }, @@ -1046,6 +1058,183 @@ ] } ] + }, + { + "id": "inspection-rover", + "title": "Inspection rover", + "topics": [ + "nested-parts", + "directed-ports", + "connections", + "attributes", + "specialization", + "requirements", + "actions", + "states" + ], + "files": [ + "system.sysml", + "components.sysml", + "types.sysml", + "behavior.sysml", + "requirements.sysml", + "views.sysml" + ], + "views": [ + { + "id": "RoverViews::architecture", + "engineKind": "interconnection", + "app": "partial", + "nodeNames": [ + "RoverSystem::InspectionRover", + "energy", + "battery", + "distribution", + "mobility", + "leftDrive", + "rightDrive", + "autonomy", + "controller", + "lidar", + "inspection", + "camera", + "radio" + ], + "edgeKinds": [ + "connection", + "connection", + "connection", + "connection", + "connection", + "connection", + "connection", + "connection", + "connection", + "connection" + ], + "targets": [ + "Show the four nested assemblies, shared drive types, declared component values and exact port connections." + ], + "gaps": [ + "Values are source declarations, not evaluated or simulated results. Effective conjugated directions and execution remain outside scope." + ], + "notices": [] + }, + { + "id": "RoverViews::definitions", + "engineKind": "definition", + "app": "partial", + "nodeNames": [ + "RoverComponents::Battery", + "RoverComponents::Distribution", + "RoverComponents::Actuator", + "RoverComponents::Sensor", + "RoverComponents::Drive", + "RoverComponents::Controller", + "RoverComponents::Lidar", + "RoverComponents::Camera", + "RoverComponents::Radio" + ], + "edgeKinds": ["specialization", "specialization", "specialization"], + "targets": [ + "Show reusable part definitions and explicit actuator/sensor specialization." + ], + "gaps": [ + "Generic definition graph notation; no full attribute compartments in this view." + ], + "notices": [] + }, + { + "id": "RoverViews::mission", + "engineKind": "action", + "app": "partial", + "nodeNames": [ + "RoverBehavior::InspectRoute", + "start", + "checkBattery", + "patrol", + "detectObstacle", + "captureInspection", + "transmitReport", + "returnToDock", + "recharge", + "done" + ], + "edgeKinds": [ + "succession", + "succession", + "succession", + "succession", + "succession", + "succession", + "succession", + "succession" + ], + "targets": [ + "Show the ordered inspection mission from battery check through patrol, capture, reporting and recharge." + ], + "gaps": [ + "Declared behavior is rendered without execution, simulation or live token/state animation." + ], + "notices": [] + }, + { + "id": "RoverViews::lifecycle", + "engineKind": "state", + "app": "partial", + "nodeNames": [ + "RoverBehavior::OperatingMode", + "", + "docked", + "patrolling", + "inspecting", + "returning", + "charging", + "safeStop" + ], + "edgeKinds": [ + "transition", + "transition", + "transition", + "transition", + "transition", + "transition", + "transition", + "transition", + "transition" + ], + "targets": [ + "Show operating states, initial state, safe stop and return-to-charge transitions." + ], + "gaps": [ + "Declared behavior is rendered without execution, simulation or live token/state animation." + ], + "notices": [] + }, + { + "id": "RoverViews::requirements", + "engineKind": "requirement", + "app": "partial", + "nodeNames": [ + "RoverRequirements::reserve", + "RoverRequirements::speed", + "RoverRequirements::clearance", + "RoverRequirements::imaging", + "RoverSystem::InspectionRover::energy::battery", + "RoverSystem::InspectionRover::mobility::leftDrive", + "RoverSystem::InspectionRover::autonomy::controller", + "RoverSystem::InspectionRover::inspection::camera" + ], + "edgeKinds": ["satisfy", "satisfy", "satisfy", "satisfy"], + "targets": [ + "Show four quantified requirements and their exact declared satisfaction targets." + ], + "gaps": [ + "Satisfaction is declared traceability, not evidence that a test passed." + ], + "notices": [] + } + ] } ] } diff --git a/examples/inspection-rover/README.md b/examples/inspection-rover/README.md new file mode 100644 index 0000000..efead98 --- /dev/null +++ b/examples/inspection-rover/README.md @@ -0,0 +1,24 @@ +# Inspection rover + +The default **Load example** and **Try an example** project. Six source files and five named views describe one indoor inspection rover, rather than unrelated syntax demonstrations. This is the only bundled example in the project launcher. + +## Guided tour + +1. **architecture** opens first. Follow a 480 Wh battery through distribution to two drive usages, the autonomy controller and an inspection camera. Lidar feeds obstacle perception; images return to the controller, and radio carries mission telemetry. Four explicit assembly containers retain ownership. Attribute compartments show the exact declared values, including inherited type members; they do not evaluate performance. +2. **mission** follows battery check, patrol, obstacle detection, image capture, reporting, return and recharge. Initial/final nodes and directed succession arrows show the declared order. Four explicit allocations link mission steps to actual controller, camera, radio and left-drive usages. The action diagram shows the sequence; allocation declarations remain available in source. This simple sequence does not branch or execute. +3. **lifecycle** connects docked, patrolling, inspecting, returning, charging and safeStop. The low-reserve and obstacle paths make the mission's recovery concerns explicit. Transition names are labels, not evaluated guards or live events. +4. **requirements** traces four quantitative declarations to their exact usage targets: 20% energy reserve, 1.5 m/s maximum drive speed, 0.8 m obstacle clearance and 12 MP image resolution. Satisfaction assertions are modeled claims, not verification results. Only the left drive is explicitly linked to the speed requirement; no right-drive link is inferred. +5. **definitions** shows reusable component types and actuator/sensor specialization. The two drives in architecture share the same Drive definition but remain distinct usages. + +Select any ordinary diagram node and choose **Open source** to inspect its declaration. Download the project to preserve sources and independently arranged view geometry. Model edits remain drafts until **Apply source** succeeds. + +## Files and scope + +- `system.sysml`: rover composition and ten port connections. +- `components.sysml`: component definitions, declared values and specialization. +- `types.sysml`: power, command, sensor, image and telemetry interfaces. +- `behavior.sysml`: mission successions and operating-state transitions. +- `requirements.sysml`: quantitative bounds and four explicit satisfaction assertions. +- `views.sysml`: the five authored browsing selections. + +The example is authored here under Apache-2.0. Numeric attributes use scalar names with explicit units in their names/documentation; this is not a dimensional-analysis or simulation model. No physical test results, live state, automatic routing behavior or solver results are claimed. Declared behavior rendering is bounded to the native action/state graphs; sequence, case and mixed views still need independent renderers. diff --git a/examples/inspection-rover/behavior.sysml b/examples/inspection-rover/behavior.sysml new file mode 100644 index 0000000..0cbf3d5 --- /dev/null +++ b/examples/inspection-rover/behavior.sysml @@ -0,0 +1,43 @@ +package RoverBehavior { + action def InspectRoute { + doc /* Declared mission order; this diagram does not execute the rover. */ + first start; + action checkBattery; + action patrol; + action detectObstacle; + action captureInspection; + action transmitReport; + action returnToDock; + action recharge; + done; + succession first start then checkBattery; + succession first checkBattery then patrol; + succession first patrol then detectObstacle; + succession first detectObstacle then captureInspection; + succession first captureInspection then transmitReport; + succession first transmitReport then returnToDock; + succession first returnToDock then recharge; + succession first recharge then done; + allocation perceptionResponsibility allocate detectObstacle to RoverSystem::rover.autonomy.controller; + allocation imagingResponsibility allocate captureInspection to RoverSystem::rover.inspection.camera; + allocation reportingResponsibility allocate transmitReport to RoverSystem::rover.inspection.radio; + allocation tractionResponsibility allocate patrol to RoverSystem::rover.mobility.leftDrive; + } + state def OperatingMode { + entry; then docked; + state docked; + state patrolling; + state inspecting; + state returning; + state charging; + state safeStop; + transition dispatch first docked then patrolling; + transition targetFound first patrolling then inspecting; + transition inspectionComplete first inspecting then patrolling; + transition lowReserve first patrolling then returning; + transition dockReached first returning then charging; + transition chargeComplete first charging then docked; + transition obstacleDetected first patrolling then safeStop; + transition pathClear first safeStop then patrolling; + } +} diff --git a/examples/inspection-rover/components.sysml b/examples/inspection-rover/components.sysml new file mode 100644 index 0000000..8549794 --- /dev/null +++ b/examples/inspection-rover/components.sysml @@ -0,0 +1,44 @@ +package RoverComponents { + private import ScalarValues::*; + private import RoverTypes::*; + part def Battery { + doc /* Rechargeable battery. Capacity and reserve are design targets. */ + attribute capacityWh : Real default = 480.0; + attribute reservePercent : Real default = 20.0; + out port supply : PowerPort; + } + part def Distribution { + in port input : PowerPort; + out port drive : PowerPort; + out port compute : PowerPort; + out port payload : PowerPort; + } + part def Actuator; + part def Sensor; + part def Drive :> Actuator { + attribute maximumSpeedMps : Real default = 1.5; + in port power : PowerPort; + in port command : CommandPort; + } + part def Controller { + attribute obstacleStopDistanceM : Real default = 0.8; + in port power : PowerPort; + in port perception : SensorPort; + in port images : ImagePort; + out port drive : CommandPort; + out port status : TelemetryPort; + } + part def Lidar :> Sensor { + attribute rangeM : Real default = 12.0; + out port scan : SensorPort; + } + part def Camera :> Sensor { + attribute resolutionMP : Real default = 12.0; + in port power : PowerPort; + out port image : ImagePort; + } + part def Radio { + attribute rangeM : Real default = 300.0; + in port telemetry : TelemetryPort; + } +} diff --git a/examples/inspection-rover/requirements.sysml b/examples/inspection-rover/requirements.sysml new file mode 100644 index 0000000..339e80c --- /dev/null +++ b/examples/inspection-rover/requirements.sysml @@ -0,0 +1,44 @@ +package RoverRequirements { + private import ScalarValues::*; + private import RoverComponents::*; + requirement def <'RVR-001'> EnergyReserve { + doc /* Keep at least 20 percent battery reserve for the return journey. */ + subject battery : Battery; + require constraint { battery.reservePercent >= 20.0 } + } + requirement def <'RVR-002'> SafeSpeed { + doc /* Each drive shall limit nominal indoor speed to 1.5 m/s. */ + subject drive : Drive; + require constraint { drive.maximumSpeedMps <= 1.5 } + } + requirement def <'RVR-003'> ObstacleClearance { + doc /* Stop at least 0.8 m before a detected obstacle. */ + subject controller : Controller; + require constraint { controller.obstacleStopDistanceM >= 0.8 } + } + requirement def <'RVR-004'> InspectionQuality { + doc /* Capture inspection images at a resolution of at least 12 MP. */ + subject camera : Camera; + require constraint { camera.resolutionMP >= 12.0 } + } + requirement <'RVR-RESERVE'> reserve : EnergyReserve { + doc /* Keep at least 20 percent battery reserve for the return journey. */ + subject battery = RoverSystem::rover.energy.battery; + } + requirement <'RVR-SPEED'> speed : SafeSpeed { + doc /* Limit the left drive nominal indoor speed to 1.5 m/s. */ + subject drive = RoverSystem::rover.mobility.leftDrive; + } + requirement <'RVR-CLEAR'> clearance : ObstacleClearance { + doc /* Stop at least 0.8 m before a detected obstacle. */ + subject controller = RoverSystem::rover.autonomy.controller; + } + requirement <'RVR-IMAGE'> imaging : InspectionQuality { + doc /* Capture inspection images at a resolution of at least 12 MP. */ + subject camera = RoverSystem::rover.inspection.camera; + } + assert satisfy reserve by RoverSystem::rover.energy.battery; + assert satisfy speed by RoverSystem::rover.mobility.leftDrive; + assert satisfy clearance by RoverSystem::rover.autonomy.controller; + assert satisfy imaging by RoverSystem::rover.inspection.camera; +} diff --git a/examples/inspection-rover/system.sysml b/examples/inspection-rover/system.sysml new file mode 100644 index 0000000..74599af --- /dev/null +++ b/examples/inspection-rover/system.sysml @@ -0,0 +1,34 @@ +package RoverSystem { + private import RoverComponents::*; + part def InspectionRover { + doc /* A slow indoor inspection rover. Explore architecture, mission, + operating states and declared requirement traceability. */ + part energy { + part battery : Battery; + part distribution : Distribution; + connection batterySupply connect battery.supply to distribution.input; + } + part mobility { + part leftDrive : Drive; + part rightDrive : Drive; + } + part autonomy { + part controller : Controller; + part lidar : Lidar; + connection obstacleScan connect lidar.scan to controller.perception; + } + part inspection { + part camera : Camera; + part radio : Radio; + } + connection leftPower connect energy.distribution.drive to mobility.leftDrive.power; + connection rightPower connect energy.distribution.drive to mobility.rightDrive.power; + connection computePower connect energy.distribution.compute to autonomy.controller.power; + connection cameraPower connect energy.distribution.payload to inspection.camera.power; + connection leftCommand connect autonomy.controller.drive to mobility.leftDrive.command; + connection rightCommand connect autonomy.controller.drive to mobility.rightDrive.command; + connection inspectionImages connect inspection.camera.image to autonomy.controller.images; + connection missionTelemetry connect autonomy.controller.status to inspection.radio.telemetry; + } + part rover : InspectionRover; +} diff --git a/examples/inspection-rover/types.sysml b/examples/inspection-rover/types.sysml new file mode 100644 index 0000000..5873f30 --- /dev/null +++ b/examples/inspection-rover/types.sysml @@ -0,0 +1,12 @@ +package RoverTypes { + item def ElectricalPower; + item def DriveCommand; + item def SensorFrame; + item def InspectionImage; + item def Telemetry; + port def PowerPort { out item power : ElectricalPower; } + port def CommandPort { out item command : DriveCommand; } + port def SensorPort { out item scanData : SensorFrame; } + port def ImagePort { out item image : InspectionImage; } + port def TelemetryPort { out item report : Telemetry; } +} diff --git a/examples/inspection-rover/views.sysml b/examples/inspection-rover/views.sysml new file mode 100644 index 0000000..61b227c --- /dev/null +++ b/examples/inspection-rover/views.sysml @@ -0,0 +1,17 @@ +package RoverViews { + private import StandardViewDefinitions::*; + view architecture : InterconnectionView { expose RoverSystem::InspectionRover; } + view definitions : GeneralView { + filter @SysML::PartDefinition or @SysML::PartUsage; + expose RoverComponents::**; + } + view mission : ActionFlowView { expose RoverBehavior::InspectRoute; } + view lifecycle : StateTransitionView { expose RoverBehavior::OperatingMode; } + view requirements : GeneralView { + filter @SysML::RequirementDefinition or @SysML::RequirementUsage; + expose RoverRequirements::reserve; + expose RoverRequirements::speed; + expose RoverRequirements::clearance; + expose RoverRequirements::imaging; + } +} diff --git a/scripts/prepare-opensysml.mjs b/scripts/prepare-opensysml.mjs index b4f1ff6..ca4dc05 100644 --- a/scripts/prepare-opensysml.mjs +++ b/scripts/prepare-opensysml.mjs @@ -5,12 +5,14 @@ import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; const root = fileURLToPath(new URL('../', import.meta.url)); -const commit = '08ef397841767b829e492bbecf6d723483b4421e'; +const commit = '760c4d1fc8edfa4e214423099065c0a7c1b24e2d'; const archiveHash = - '896650da511eab806177f6f2a5d60f7ef57b44dfe617b2844a978f9adadb6d9d'; + '49939c5f4c3008cdcf79d39a976719f67aab9d38bb38b246832eb1fdd90de08e'; const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); const overlays = [ 'evaluate_view.go', + 'exploration.go', + 'exploration_test.go', 'validated_view.go', 'validated_view_test.go', 'geometry_pose.go', @@ -34,7 +36,7 @@ const patchHash = digest( ), ); const destination = resolve(root, 'static/vendor/opensysml'); -const cache = resolve(root, 'sandbox/opensysml-build'); +const cache = resolve(root, 'sandbox/opensysml-012'); try { const stamp = JSON.parse( await readFile(resolve(destination, 'provenance.json'), 'utf8'), @@ -97,6 +99,14 @@ for (const [name, folder] of [ name.endsWith('_test.go') ? 'studio_view_test.go' : 'studio_view.go', ), ); +for (const [name, folder] of [ + ['exploration.go', 'engine'], + ['exploration_test.go', 'combined'], +]) + await cp( + resolve(root, 'engine-bridge/opensysml', name), + resolve(source, 'internal/frontend', folder, 'studio_' + name), + ); await cp( resolve(root, 'engine-bridge/opensysml/requirements_view.go'), resolve(source, 'internal/frontend/engine/studio_requirements_view.go'), @@ -131,6 +141,11 @@ async function replace(file, before, after) { throw new Error(`Pinned source seam changed: ${file}`); await writeFile(name, text.replace(before, after)); } +await replace( + 'internal/frontend/engine/engine.go', + 'case "RenderView":', + 'case "Explore":\n var req JExploreRequest\n if err := decode(params, &req); err != nil { return nil, err }\n return e.explore(&req)\n case "RenderView":', +); await replace( 'internal/frontend/engine/engine.go', 'case "RenderView":', @@ -144,12 +159,12 @@ await replace( await replace( 'internal/frontend/combined/combined.go', 'case "GetServerInfo":', - 'case "EvaluateRelationships":\n return s.evaluateRelationships(ctx,params)\n case "EvaluateView":\n return s.evaluateView(ctx,params)\n case "GetServerInfo":', + 'case "Explore":\n return s.explore(ctx,params)\n case "EvaluateRelationships":\n return s.evaluateRelationships(ctx,params)\n case "EvaluateView":\n return s.evaluateView(ctx,params)\n case "GetServerInfo":', ); await replace( 'internal/frontend/combined/combined.go', 'var capabilities = []string{', - 'var capabilities = []string{\n "studio_evaluate_view_v1",\n "studio_relationships_v1",', + 'var capabilities = []string{\n "studio_exploration_v1",\n "studio_evaluate_view_v1",\n "studio_relationships_v1",', ); let go = process.env.SYSML_GO; if (!go) { @@ -161,19 +176,19 @@ if (!go) { } if (!go) { if (process.platform !== 'linux' || process.arch !== 'x64') - throw new Error('Set SYSML_GO to a local Go 1.25.11 binary.'); - const toolArchive = resolve(cache, 'go1.25.11.linux-amd64.tar.gz'); + throw new Error('Set SYSML_GO to a local Go 1.27.3 binary.'); + const toolArchive = resolve(cache, 'go1.27.3.linux-amd64.tar.gz'); await download( - 'https://go.dev/dl/go1.25.11.linux-amd64.tar.gz', + 'https://go.dev/dl/go1.27.3.linux-amd64.tar.gz', toolArchive, - '34f14304e856893f4ba30c2cacfe93906e9de7915c5f6aaaf3a81cdccd7ba30b', + '65efbc80fdc1fa2cd2631cad5b481777a68e8725f34e89f50fc6ac714e379b40', ); execFileSync('tar', ['-xzf', toolArchive, '-C', cache]); go = resolve(cache, 'go/bin/go'); } const toolchain = execFileSync(go, ['version'], { encoding: 'utf8' }).trim(); -if (!toolchain.startsWith('go version go1.25.11 ')) - throw new Error('The native view bridge requires Go 1.25.11.'); +if (!toolchain.startsWith('go version go1.27.3 ')) + throw new Error('The native view bridge requires Go 1.27.3.'); await mkdir(destination, { recursive: true }); const env = { ...process.env, @@ -195,7 +210,7 @@ execFileSync( '-buildvcs=false', '-trimpath', '-ldflags', - `-X main.Version=v0.10.0 -X main.Commit=${commit}`, + `-X main.Version=v0.12.0 -X main.Commit=${commit}`, '-o', resolve(destination, 'sysml-wasm.wasm'), './cmd/sysml-wasm', @@ -224,6 +239,7 @@ await writeFile( patchHash, toolchain, bridge: 'studio_evaluate_view_v1', + exploration: 'studio_exploration_v1', builderHash: digest(await readFile(fileURLToPath(import.meta.url))), binaryHash: digest( await readFile(resolve(destination, 'sysml-wasm.wasm')), diff --git a/src/lib/engines/opensysml/index.ts b/src/lib/engines/opensysml/index.ts index 6df73f1..65d3fc4 100644 --- a/src/lib/engines/opensysml/index.ts +++ b/src/lib/engines/opensysml/index.ts @@ -1,9 +1,52 @@ +import { + validateRequirementsResult, + type RequirementsEngine, +} from '$lib/workspace/requirements'; +import { + validateExploration, + type ExplorationEngine, +} from '$lib/workspace/exploration'; import type { SemanticEngine } from '$lib/workspace/editing'; import { readModelSnapshot } from '$lib/workspace/project'; import { validateEvaluation } from '$lib/workspace/views'; import { requestWorker } from '../workers/request'; -export function createSemanticEngine(base: string): SemanticEngine { +export function createSemanticEngine( + base: string, +): SemanticEngine & ExplorationEngine & RequirementsEngine { return { + requirements(documents, modelHash, signal) { + return requestWorker( + new Worker(new URL('./semantic.worker.ts', import.meta.url), { + type: 'module', + }), + { + protocolVersion: 1, + operation: 'requirements', + documents, + modelHash, + base, + }, + (value) => validateRequirementsResult(value, modelHash, documents), + signal, + ); + }, + explore(documents, modelHash, request, signal) { + return requestWorker( + new Worker(new URL('./semantic.worker.ts', import.meta.url), { + type: 'module', + }), + { + protocolVersion: 1, + operation: 'explore', + documents, + modelHash, + request, + base, + }, + (value) => validateExploration(value, modelHash, request, documents), + signal, + ); + }, parse(documents, signal) { return requestWorker( new Worker(new URL('./semantic.worker.ts', import.meta.url), { diff --git a/src/lib/engines/opensysml/semantic.worker.ts b/src/lib/engines/opensysml/semantic.worker.ts index b0f748d..1d97ef8 100644 --- a/src/lib/engines/opensysml/semantic.worker.ts +++ b/src/lib/engines/opensysml/semantic.worker.ts @@ -62,13 +62,29 @@ self.onmessage = async (event: MessageEvent) => { line: item.span?.startLine, }), ); - if (operation === 'evaluate') { + if (operation === 'requirements') { if (parsed.modelHash !== request.modelHash) - throw new Error('View request no longer matches source.'); - const value = call('EvaluateView', { - modelHash: parsed.modelHash, - view: request.view, + throw new Error('Requirements request no longer matches source.'); + self.postMessage({ + value: call('EvaluateRelationships', { + modelHash: parsed.modelHash, + }), }); + return; + } + if (operation === 'evaluate' || operation === 'explore') { + if (parsed.modelHash !== request.modelHash) + throw new Error('View request no longer matches source.'); + const value = + operation === 'explore' + ? call('Explore', { + modelHash: parsed.modelHash, + request: request.request, + }) + : call('EvaluateView', { + modelHash: parsed.modelHash, + view: request.view, + }); self.postMessage({ value }); return; } diff --git a/src/lib/engines/toolkit/language-normalize.test.ts b/src/lib/engines/toolkit/language-normalize.test.ts index 0e30249..22aec8f 100644 --- a/src/lib/engines/toolkit/language-normalize.test.ts +++ b/src/lib/engines/toolkit/language-normalize.test.ts @@ -147,3 +147,53 @@ describe('language normalization', () => { ).toThrow(); }); }); + +describe('references and formatting', () => { + it('keeps project reference ranges exact and reports omitted library targets', () => { + const range = { + start: { line: 1, character: 15 }, + end: { line: 1, character: 20 }, + }; + expect( + normalizeLanguage(sources, { ...query, kind: 'references' }, [ + { uri: sourceURI(sources[0]!.name), range }, + { uri: 'sysmlv2-lib:/library.sysml', range }, + ]), + ).toEqual({ + kind: 'references', + targets: [ + { + file: sources[0]!.name, + line: 2, + from: sources[0]!.text.indexOf('Motor'), + to: sources[0]!.text.indexOf('Motor') + 5, + }, + ], + omitted: 1, + }); + }); + it('applies nonoverlapping formatting edits while preserving CRLF and rejects overlaps', () => { + const files = [{ name: 'a.sysml', text: 'package P {\r\npart p;\r\n}' }]; + const range = { + start: { line: 1, character: 0 }, + end: { line: 1, character: 7 }, + }; + expect( + normalizeLanguage( + files, + { kind: 'formatting', file: 'a.sysml', offset: 0 }, + [{ range, newText: ' part p;' }], + ), + ).toEqual({ kind: 'formatting', text: 'package P {\r\n part p;\r\n}' }); + expect(() => + normalizeLanguage( + files, + { kind: 'formatting', file: 'a.sysml', offset: 0 }, + [ + { range, newText: 'x' }, + { range, newText: 'y' }, + ], + ), + ).toThrow('Overlapping'); + }); +}); diff --git a/src/lib/engines/toolkit/language-normalize.ts b/src/lib/engines/toolkit/language-normalize.ts index 706956d..641c269 100644 --- a/src/lib/engines/toolkit/language-normalize.ts +++ b/src/lib/engines/toolkit/language-normalize.ts @@ -5,7 +5,11 @@ import { type LanguageQuery, type LanguageResult, } from '$lib/workspace/language'; -import { positionOffset, sourceURI } from '$lib/workspace/language/positions'; +import { + positionOffset, + sourceURI, + sourcePosition, +} from '$lib/workspace/language/positions'; import type { SourceDocument } from '$lib/workspace/project'; const position = Type.Object({ line: Type.Integer({ minimum: 0 }), @@ -40,6 +44,52 @@ export function normalizeLanguage( const file = sources.find((s) => s.name === query.file); if (!file) throw new Error('Language query source is unavailable.'); const text = file.text; + if (query.kind === 'references') { + if (raw !== null && !Array.isArray(raw)) + throw new Error('Invalid references result.'); + const targets = []; + let omitted = 0; + for (const value of (raw ?? []) as unknown[]) { + const item = object(value), + target = sources.find((s) => sourceURI(s.name) === item.uri); + if (!target) { + omitted++; + continue; + } + targets.push({ + file: target.name, + line: + sourcePosition(target.text, offsets(target.text, item.range).from) + .line + 1, + ...offsets(target.text, item.range), + }); + } + return readLanguageResult({ kind: 'references', targets, omitted }); + } + if (query.kind === 'formatting') { + if (raw !== null && !Array.isArray(raw)) + throw new Error('Invalid formatting result.'); + const edits = ((raw ?? []) as unknown[]) + .map((value) => { + const item = object(value); + if (typeof item.newText !== 'string') + throw new Error('Invalid formatting edit.'); + return { ...offsets(text, item.range), text: item.newText }; + }) + .sort((a, b) => b.from - a.from || b.to - a.to); + let formatted = text, + boundary = text.length; + const separator = text.match(/\r\n?|\n/)?.[0] ?? '\n'; + for (const edit of edits) { + if (edit.to > boundary) throw new Error('Overlapping formatting edits.'); + formatted = + formatted.slice(0, edit.from) + + edit.text.replace(/\r\n?|\n/g, separator) + + formatted.slice(edit.to); + boundary = edit.from; + } + return readLanguageResult({ kind: 'formatting', text: formatted }); + } if (query.kind === 'definition') { const candidates = raw == null ? [] : Array.isArray(raw) ? raw : [raw]; if (candidates.length !== 1) diff --git a/src/lib/engines/toolkit/language.worker.ts b/src/lib/engines/toolkit/language.worker.ts index 279b312..186e965 100644 --- a/src/lib/engines/toolkit/language.worker.ts +++ b/src/lib/engines/toolkit/language.worker.ts @@ -126,6 +126,12 @@ self.onmessage = async (event: MessageEvent) => { const raw = send('textDocument/' + query.kind, { textDocument: { uri: sourceURI(query.file) }, position, + ...(query.kind === 'references' + ? { context: { includeDeclaration: false } } + : {}), + ...(query.kind === 'formatting' + ? { options: { tabSize: 4, insertSpaces: true } } + : {}), }); self.postMessage({ value: normalizeLanguage(sources, query, raw) }); } catch (error) { diff --git a/src/lib/engines/workers/schema.ts b/src/lib/engines/workers/schema.ts index 7469574..7b0ac40 100644 --- a/src/lib/engines/workers/schema.ts +++ b/src/lib/engines/workers/schema.ts @@ -1,4 +1,5 @@ import Type from 'typebox'; +import { ExplorationRequestSchema } from '$lib/workspace/exploration'; import { validator } from '$lib/contracts/validation'; import { SourceDocumentsSchema } from '$lib/workspace/project'; import { SemanticCommandSchema } from '$lib/workspace/editing'; @@ -9,6 +10,23 @@ const common = { }, closed = { additionalProperties: false }; export const SemanticRequestSchema = Type.Union([ + Type.Object( + { + ...common, + operation: Type.Literal('requirements'), + modelHash: Type.String({ minLength: 1 }), + }, + closed, + ), + Type.Object( + { + ...common, + operation: Type.Literal('explore'), + modelHash: Type.String({ minLength: 1 }), + request: ExplorationRequestSchema, + }, + closed, + ), Type.Object({ ...common, operation: Type.Literal('parse') }, closed), Type.Object( { diff --git a/src/lib/ui/diagram.css b/src/lib/ui/diagram.css index ff3d11f..a6a9da9 100644 --- a/src/lib/ui/diagram.css +++ b/src/lib/ui/diagram.css @@ -297,3 +297,161 @@ max-height: calc(100% - 20px); overflow: auto; } + +/* Transient exploration: compact containment, without changing named-view geometry. */ +.exploration-node .structure-heading { + box-sizing: border-box; + padding: 7px 12px; +} +.exploration-node .structure-meta { + margin-bottom: 3px; + line-height: 14px; +} +.exploration-node .structure-heading strong { + font-size: 12px; + line-height: 16px; +} +.exploration-node.model-package { + --structure-header-bg: var(--sysml-package-bg); +} +.exploration-node.structure-compound { + border-style: solid; +} +.diagram-exploration .exploration-field { + display: flex; + flex-direction: column; + gap: 3px; + color: var(--sysml-muted); + font-size: 10px; + min-width: 0; + max-width: 100%; +} +.diagram-exploration .exploration-field [data-slot='native-select-wrapper'] { + width: 170px; + max-width: 100%; +} +.diagram-exploration + .exploration-scope-field + [data-slot='native-select-wrapper'] { + width: 270px; +} +.diagram-exploration .exploration-field select { + max-width: none; +} +.diagram-exploration .diagram-context { + align-items: end; + width: 100%; +} +.exploration-current { + flex-basis: 100%; + display: flex; + align-items: baseline; + gap: 5px 10px; + flex-wrap: wrap; + min-width: 0; + padding-top: 3px; + font-size: 12px; +} +.exploration-current > span { + color: var(--sysml-muted); + font-size: 11px; +} +.exploration-current strong { + overflow-wrap: anywhere; + min-width: 0; +} +.diagram-exploration .diagram-context-meta { + max-width: none; + flex-direction: row; + flex-wrap: wrap; + gap: 6px 12px; +} + +.exploration-leaf .structure-heading { + border-bottom: 0; + border-radius: inherit; +} + +.structure-attributes { + border-bottom: 1px solid var(--sysml-border); + padding: 0 12px; +} +.structure-attributes > div { + height: 48px; + overflow: hidden; + padding: 5px 0; + font-size: 11px; + line-height: 15px; + overflow-wrap: anywhere; +} +.behavior-node { + border-radius: 16px; +} +.behavior-node .structure-heading { + background: color-mix(in srgb, #d97706 12%, var(--sysml-surface)); + border-radius: 15px 15px 0 0; +} +.behavior-state { + border-radius: 24px; +} +.behavior-start, +.behavior-done { + border: 3px solid var(--sysml-text); + border-radius: 999px; +} +.behavior-start .structure-heading, +.behavior-done .structure-heading { + background: color-mix(in srgb, var(--sysml-text) 10%, var(--sysml-surface)); +} +.behavior-detail { + padding: 4px 12px; + font-size: 12px; +} + +.behavior-initial, +.behavior-final, +.behavior-start, +.behavior-done { + border-radius: 50%; + background: var(--sysml-text); + border: 3px solid var(--sysml-text); +} +.behavior-initial > .structure-heading, +.behavior-final > .structure-heading, +.behavior-start > .structure-heading, +.behavior-done > .structure-heading, +.behavior-initial > .structure-ports, +.behavior-final > .structure-ports, +.behavior-start > .structure-ports, +.behavior-done > .structure-ports { + display: none; +} +.behavior-final, +.behavior-done { + box-shadow: inset 0 0 0 4px var(--sysml-surface); +} + +.example-guide { + margin: 0; + padding: 10px 16px; + border-bottom: 1px solid var(--sysml-border); + font-size: 12px; + line-height: 1.6; + color: var(--sysml-muted); + background: var(--sysml-surface); +} + +.react-flow__node.structure-node.behavior-node { + border-radius: 16px; +} +.react-flow__node.structure-node.behavior-state { + border-radius: 24px; +} +.react-flow__node.structure-node.behavior-initial, +.react-flow__node.structure-node.behavior-final, +.react-flow__node.structure-node.behavior-start, +.react-flow__node.structure-node.behavior-done { + border-radius: 50%; + background: var(--sysml-text); + border: 3px solid var(--sysml-text); +} diff --git a/src/lib/ui/entry.css b/src/lib/ui/entry.css index 03c412e..08cbd06 100644 --- a/src/lib/ui/entry.css +++ b/src/lib/ui/entry.css @@ -77,8 +77,7 @@ font-weight: 650; } .landing-guide p, -.launcher-storage p, -.launcher-planned p { +.launcher-storage p { color: var(--sysml-muted); font-size: 13px; line-height: 1.8; @@ -180,15 +179,6 @@ padding-top: 28px; max-width: 680px; } -.launcher-planned { - color: var(--sysml-muted); - font-size: 12px; - max-width: 680px; -} -.launcher-planned summary { - padding: 8px 0; - text-underline-offset: 4px; -} .entry-error { border-left: 3px solid var(--sysml-error); background: var(--sysml-error-bg); diff --git a/src/lib/ui/requirements-data.css b/src/lib/ui/requirements-data.css new file mode 100644 index 0000000..c3c2b3e --- /dev/null +++ b/src/lib/ui/requirements-data.css @@ -0,0 +1,116 @@ +.model-data-surface { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; +} +.model-data-tabs { + display: flex; + gap: 4px; + padding: 6px 10px; + border-bottom: 1px solid var(--sysml-border); + flex-wrap: wrap; +} +.model-data-diagram { + display: flex; + flex: 1; + min-height: 0; +} +.requirements-panel { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + min-width: 0; + min-height: 0; + overflow: auto; + flex: 1; +} +.requirements-panel p { + margin: 0; + font-size: 12px; + color: var(--sysml-muted); +} +.requirements-tools { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 8px; +} +.requirements-tools label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; +} +.requirements-scroll { + overflow: auto; + min-height: 0; + border: 1px solid var(--sysml-border); +} +.requirements-panel table { + border-collapse: collapse; + width: 100%; + font-size: 12px; +} +.requirements-panel th, +.requirements-panel td { + padding: 10px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid var(--sysml-border); + border-right: 1px solid var(--sysml-border); + min-width: 130px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.requirements-panel thead th { + position: sticky; + top: 0; + background: var(--sysml-surface); + z-index: 1; +} +.requirements-panel th:first-child { + min-width: 280px; +} +.requirements-panel table[data-mode='table'] :is(th, td):nth-child(3) { + min-width: 90px; +} +.requirements-panel table[data-mode='table'] :is(th, td):nth-child(4) { + min-width: 320px; +} +.requirements-panel table[data-mode='table'] :is(th, td):nth-child(n + 5), +.requirements-panel table[data-mode='matrix'] th:not(:first-child) { + min-width: 220px; +} +.requirements-panel table button { + text-align: left; + color: var(--sysml-accent); + text-decoration: underline; + cursor: pointer; +} + +.source-query-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; +} +.source-references { + max-height: 180px; + overflow: auto; + padding: 8px 12px; + border-bottom: 1px solid var(--sysml-border); + font-size: 12px; +} +.source-references ul { + list-style: none; + padding: 0; + margin: 4px 0; +} +.source-references li button { + text-decoration: underline; + cursor: pointer; + color: var(--sysml-accent); +} diff --git a/src/lib/ui/theme.css b/src/lib/ui/theme.css index 6ef3c54..07223d4 100644 --- a/src/lib/ui/theme.css +++ b/src/lib/ui/theme.css @@ -9,3 +9,5 @@ @import './dialogs.css'; @import './entry.css'; @import './requirements.css'; + +@import './requirements-data.css'; diff --git a/src/lib/workbench/diagrams/Diagram.tsx b/src/lib/workbench/diagrams/Diagram.tsx index 30be3e9..df7cf28 100644 --- a/src/lib/workbench/diagrams/Diagram.tsx +++ b/src/lib/workbench/diagrams/Diagram.tsx @@ -1,17 +1,23 @@ +import { useDiagramFit } from './useDiagramFit'; +import { layoutExploration, explorationKindLabel } from './exploration'; +import { RoutedEdge } from './RoutedEdge'; +import { ExplorationEdge } from './ExplorationEdge'; +import type { ExplorationRequest } from '$lib/workspace/exploration'; +import type { ModelElement } from '$lib/workspace/project'; import { DiagramControls } from './DiagramControls'; import { SquarePlus, Cable } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { ReactFlow, ReactFlowProvider, Background, applyNodeChanges, applyEdgeChanges, - useReactFlow, useNodesInitialized, useStore, type Node, type Edge, + type Rect, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import ELK from 'elkjs/lib/elk.bundled.js'; @@ -24,18 +30,21 @@ import { layoutStructure, absolutePositions } from './structure'; import { StructureNode, SelectDiagramSymbol } from './StructureNode'; import { RequirementNode } from './RequirementNode'; import { layoutRequirements } from './requirements'; +const edgeTypes = { exploration: ExplorationEdge, routed: RoutedEdge }; const nodeTypes = { structure: StructureNode, requirement: RequirementNode }; function FitAvailableSpace({ padding, layoutMode, + bounds, }: { padding: number; layoutMode: string; + bounds?: Rect; }) { const width = useStore((state) => state.width); const height = useStore((state) => state.height); const initialized = useNodesInitialized(); - const { fitView } = useReactFlow(); + const fit = useDiagramFit(padding, bounds); const fitted = useRef(false); const previousMode = useRef(layoutMode); useEffect(() => { @@ -57,14 +66,14 @@ function FitAvailableSpace({ const firstFrame = requestAnimationFrame(() => { secondFrame = requestAnimationFrame(() => { fitted.current = true; - void fitView({ padding, maxZoom: 1 }); + void fit(); }); }); return () => { cancelAnimationFrame(firstFrame); cancelAnimationFrame(secondFrame); }; - }, [initialized, width, height, padding, fitView, layoutMode]); + }, [initialized, width, height, fit, layoutMode]); return null; } @@ -74,6 +83,14 @@ import type { Point, } from '$lib/workspace/views'; interface Props { + guide?: string; + exploration?: ExplorationRequest; + elements?: readonly ModelElement[]; + explorationFocus?: 'scope' | 'representation'; + onExplore?: ( + request: ExplorationRequest, + focus?: 'scope' | 'representation', + ) => void; view: EvaluatedView | null; views: readonly { id: string; name: string }[]; busy: boolean; @@ -87,6 +104,11 @@ interface Props { layoutMode: 'code' | 'split' | 'diagram'; } export function Diagram({ + guide, + exploration, + explorationFocus, + elements = [], + onExplore, view, views, busy, @@ -105,18 +127,39 @@ export function Diagram({ [failure, setFailure] = useState(''), [layoutNotice, setLayoutNotice] = useState(''), [locked, setLocked] = useState(false); + const [sceneBounds, setSceneBounds] = useState(); + const scopeControl = useRef(null); + const representationControl = useRef(null); + useLayoutEffect(() => { + if (!busy && explorationFocus) + (explorationFocus === 'scope' + ? scopeControl + : representationControl + ).current?.focus({ preventScroll: true }); + }, [busy, explorationFocus]); + const explorationSubject = + exploration?.scope.kind !== 'project' + ? exploration?.scope.subject + : undefined; const supported = view?.status === 'complete' && view.renderStatus === 'available' && view.artifact && - ['tree', 'interconnection', 'definition', 'requirement'].includes( - view.artifact.kind, - ); - const fitPadding = ['interconnection', 'requirement'].includes( - view?.artifact?.kind ?? '', - ) + [ + 'tree', + 'interconnection', + 'definition', + 'requirement', + 'action', + 'state', + ].includes(view.artifact.kind); + const fitPadding = exploration ? 0.1 - : 0.2; + : ['interconnection', 'requirement', 'action', 'state'].includes( + view?.artifact?.kind ?? '', + ) + ? 0.1 + : 0.2; const [initialPresentation] = useState(presentation); useEffect(() => { const artifact = view?.artifact; @@ -141,12 +184,25 @@ export function Diagram({ setArranging(false); return; } + if (exploration) { + try { + const result = layoutExploration(artifact); + setNodes(result.nodes); + setEdges(result.edges); + setSceneBounds(result.bounds); + setArranging(false); + } catch (error) { + setFailure(String(error)); + setArranging(false); + } + return; + } const stored = initialPresentation?.modelHash === view?.modelHash ? initialPresentation.positions : {}; if ( - artifact.kind === 'interconnection' || + ['interconnection', 'action', 'state'].includes(artifact.kind) || artifact.kind === 'requirement' ) { const layout = @@ -256,7 +312,10 @@ export function Diagram({ for (const item of views) viewNames.set(item.name, (viewNames.get(item.name) ?? 0) + 1); return ( -
+
onOpenView(event.target.value)} + disabled={busy} + onChange={(event) => + event.target.value + ? onOpenView(event.target.value) + : onExplore?.({ + scope: { kind: 'project' }, + representation: 'tree', + }) + } > - {!view && ( - - Named view - - )} + Explore model {views.map((item) => ( {viewNames.get(item.name) === 1 ? item.name : item.id} ))} + {exploration && ( + <> +
+ + { + const subject = event.target.value; + const item = elements.find((item) => item.id === subject); + onExplore?.( + !subject + ? { scope: { kind: 'project' }, representation: 'tree' } + : item?.kind === 'package' + ? { + scope: { kind: 'package', subject }, + representation: 'tree', + } + : { + scope: { kind: 'element', subject }, + representation: 'tree', + }, + 'scope', + ); + }} + > + + Project overview + + {elements + .filter((item) => + ['package', 'partDef', 'partUsage'].includes(item.kind), + ) + .map((item) => ( + + {item.id} · {explorationKindLabel(item.kind)} + + ))} + +
+
+ + + onExplore?.( + event.target.value === 'interconnection' && + exploration.scope.kind === 'element' + ? { + scope: exploration.scope, + representation: 'interconnection', + } + : { ...exploration, representation: 'tree' }, + 'representation', + ) + } + > + + Ownership overview + + + Structure + + +
+
+ + {exploration.scope.kind === 'project' + ? 'Project' + : explorationKindLabel( + elements.find((item) => item.id === explorationSubject) + ?.kind ?? exploration.scope.kind, + )} + + + {exploration.scope.kind === 'project' + ? 'Project overview' + : exploration.scope.subject} + +
+ + )}
{view?.artifact && ( @@ -285,21 +451,29 @@ export function Diagram({ ? 'Structure' : view.artifact.kind === 'requirement' ? 'Requirements' - : view.artifact.kind === 'tree' - ? 'Overview tree' - : 'Named view'} + : view.artifact.kind === 'action' + ? 'Mission flow' + : view.artifact.kind === 'state' + ? 'Operating states' + : view.artifact.kind === 'tree' + ? exploration + ? 'Ownership overview' + : 'Overview tree' + : 'Named view'} )} {disabled ? 'Applied revision · draft pending' : view - ? 'Applied model' + ? exploration + ? 'Temporary exploration · applied model' + : 'Applied model' : 'Choose a named view'}
- {view?.artifact?.kind !== 'requirement' && ( + {!exploration && view?.artifact?.kind === 'interconnection' && (
+ {guide &&

{guide}

} {!view ? (

One model. Focused views.

- Open a named view from the sidebar, or select a model element and - create one. + Explore the applied model, open a named view from the sidebar, or + select a model element and create one.

+ {onExplore && ( + + )}
) : failure ? (
@@ -354,13 +542,20 @@ export function Diagram({ nodes={nodes} edges={edges} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} onNodesChange={(changes) => { setNodes((current) => applyNodeChanges(changes, current)); - if (view?.artifact?.kind === 'requirement') { + if ( + ['requirement', 'action', 'state'].includes( + view?.artifact?.kind ?? '', + ) || + exploration + ) { for (const change of changes) if (change.type === 'select' && change.selected) onSelect(change.id); if ( + !exploration && !disabled && !locked && changes.some( @@ -377,7 +572,12 @@ export function Diagram({ }} onEdgesChange={(changes) => { setEdges((current) => applyEdgeChanges(changes, current)); - if (view?.artifact?.kind === 'requirement') { + if ( + ['requirement', 'action', 'state'].includes( + view?.artifact?.kind ?? '', + ) || + exploration + ) { for (const change of changes) if (change.type === 'select' && change.selected) onSelect(change.id); @@ -385,21 +585,44 @@ export function Diagram({ }} edgesFocusable={!locked} onEdgeClick={(_event, edge) => { - if (view?.artifact?.kind === 'requirement') onSelect(edge.id); + if ( + ['requirement', 'action', 'state'].includes( + view?.artifact?.kind ?? '', + ) || + exploration + ) + onSelect(edge.id); }} fitView fitViewOptions={{ - padding: view?.artifact?.kind === 'requirement' ? 0.1 : 0.2, + padding: + exploration || view?.artifact?.kind === 'requirement' + ? 0.1 + : 0.2, maxZoom: 1, }} - minZoom={view?.artifact?.kind === 'requirement' ? 0.1 : 0.5} + minZoom={ + exploration || + [ + 'requirement', + 'action', + 'state', + 'interconnection', + ].includes(view?.artifact?.kind ?? '') + ? 0.1 + : 0.5 + } nodesConnectable={false} - nodesDraggable={!disabled && !locked} + nodesDraggable={!disabled && !locked && !exploration} elementsSelectable={!locked} deleteKeyCode={null} onNodeClick={(_event, node) => onSelect(node.id)} onNodeDragStop={(_event, node) => { - if (view?.artifact?.kind !== 'requirement') + if ( + !['requirement', 'action', 'state'].includes( + view?.artifact?.kind ?? '', + ) + ) onMove( absolutePositions( nodes.map((current) => @@ -412,18 +635,31 @@ export function Diagram({
)} + {exploration?.scope.kind === 'element' && + exploration.representation === 'interconnection' && + elements.some( + (item) => item.id === explorationSubject && item.kind === 'partUsage', + ) && ( +

+ This usage projection does not automatically expand its type’s + internal structure. Select the definition to inspect its declared + internals. +

+ )} {!!view?.artifact?.notices?.length && (

{view.artifact.notices.join(' · ')}

)} diff --git a/src/lib/workbench/diagrams/DiagramControls.tsx b/src/lib/workbench/diagrams/DiagramControls.tsx index 3bbb709..508cbdf 100644 --- a/src/lib/workbench/diagrams/DiagramControls.tsx +++ b/src/lib/workbench/diagrams/DiagramControls.tsx @@ -1,4 +1,5 @@ -import { Panel, useReactFlow, useStore } from '@xyflow/react'; +import { useDiagramFit } from './useDiagramFit'; +import { Panel, useReactFlow, useStore, type Rect } from '@xyflow/react'; import { ZoomIn, ZoomOut, @@ -12,12 +13,15 @@ export function DiagramControls({ locked, onLock, padding, + bounds, }: { locked: boolean; onLock: (locked: boolean) => void; padding: number; + bounds?: Rect; }) { - const { zoomIn, zoomOut, fitView } = useReactFlow(); + const { zoomIn, zoomOut } = useReactFlow(); + const fit = useDiagramFit(padding, bounds); const zoom = useStore((state) => state.transform[2]); const minZoom = useStore((state) => state.minZoom); const maxZoom = useStore((state) => state.maxZoom); @@ -46,7 +50,7 @@ export function DiagramControls({ size="icon-xs" label="Fit view" icon={Scan} - onClick={() => void fitView({ padding, maxZoom: 1 })} + onClick={() => void fit()} /> ({ ...point })); + if (points?.length) { + points[0] = { x: props.sourceX, y: props.sourceY }; + points[points.length - 1] = { x: props.targetX, y: props.targetY }; + if (points.length > 2) { + points[1]!.y = props.sourceY; + points[points.length - 2]!.y = props.targetY; + } + } + const { path, labelX, labelY } = + route && points + ? { + path: overviewPolyline(points), + labelX: route.labelX, + labelY: route.labelY, + } + : explorationEdgePath( + props.sourceX, + props.sourceY, + props.targetX, + props.targetY, + Number(props.data?.lane ?? 0), + ); + return ( + + ); +} diff --git a/src/lib/workbench/diagrams/RoutedEdge.tsx b/src/lib/workbench/diagrams/RoutedEdge.tsx new file mode 100644 index 0000000..84ee92a --- /dev/null +++ b/src/lib/workbench/diagrams/RoutedEdge.tsx @@ -0,0 +1,72 @@ +import { BaseEdge, useReactFlow, type EdgeProps } from '@xyflow/react'; +import type { Point } from '$lib/workspace/views'; + +export interface LayoutRoute { + points: Point[]; + labelX?: number; + labelY?: number; + container?: { id: string; position: Point }; +} + +/** ELK routes are presentation geometry, separate from modeled source routes. */ +export function RoutedEdge(props: EdgeProps) { + const route = props.data!.route as LayoutRoute; + const { getNode } = useReactFlow(); + const offset = { x: 0, y: 0 }; + if (route.container) { + let node = getNode(route.container.id); + while (node) { + offset.x += node.position.x; + offset.y += node.position.y; + node = node.parentId ? getNode(node.parentId) : undefined; + } + offset.x -= route.container.position.x; + offset.y -= route.container.position.y; + } + const original = route.points; + let points = original.map((point) => ({ + x: point.x + offset.x, + y: point.y + offset.y, + })); + const last = points.length - 1; + points[0] = { x: props.sourceX, y: props.sourceY }; + points[last] = { x: props.targetX, y: props.targetY }; + if (points.length > 2) { + if (original[1]!.x === original[0]!.x) points[1]!.x = props.sourceX; + else points[1]!.y = props.sourceY; + if (original[last - 1]!.x === original[last]!.x) + points[last - 1]!.x = props.targetX; + else points[last - 1]!.y = props.targetY; + } + let labelX = route.labelX === undefined ? undefined : route.labelX + offset.x; + let labelY = route.labelY === undefined ? undefined : route.labelY + offset.y; + if (points.length === 2) { + const start = points[0]!, + end = points[1]!; + const middle = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; + if (labelX !== undefined) + labelX += middle.x - (original[0]!.x + original[1]!.x) / 2 - offset.x; + if (labelY !== undefined) + labelY += middle.y - (original[0]!.y + original[1]!.y) / 2 - offset.y; + points = + Math.abs(original[0]!.y - original[1]!.y) < 1e-6 + ? [start, { x: middle.x, y: start.y }, { x: middle.x, y: end.y }, end] + : [start, { x: start.x, y: middle.y }, { x: end.x, y: middle.y }, end]; + } + return ( + `${index ? 'L' : 'M'} ${point.x} ${point.y}`) + .join(' ')} + label={props.label} + labelX={labelX} + labelY={labelY} + markerEnd={props.markerEnd} + style={props.style} + interactionWidth={props.interactionWidth} + labelBgPadding={[5, 4]} + labelBgBorderRadius={3} + /> + ); +} diff --git a/src/lib/workbench/diagrams/StructureNode.tsx b/src/lib/workbench/diagrams/StructureNode.tsx index dbe5a45..c962438 100644 --- a/src/lib/workbench/diagrams/StructureNode.tsx +++ b/src/lib/workbench/diagrams/StructureNode.tsx @@ -1,3 +1,5 @@ +import { attributeLabel } from './attribute-label'; +import { explorationHeaderHeight } from './exploration'; import { createContext, useContext } from 'react'; import { Handle, Position, type NodeProps } from '@xyflow/react'; import { @@ -12,10 +14,15 @@ export const SelectDiagramSymbol = createContext<(symbol: string) => void>( export function StructureNode({ data }: NodeProps) { const select = useContext(SelectDiagramSymbol); - const { item, compound } = data; + const { item, compound, compact, behavior, dense } = data; return ( <> -
+
{item.reference ? 'ref ' : ''} @@ -34,70 +41,109 @@ export function StructureNode({ data }: NodeProps) { )}
-
- {item.ports?.map((port) => ( -
- - - -
- ))} -
+ {attributeLabel(declaration)} +
+ ))} +
+ )} + {!!item.detail && + ['state', 'action', 'start', 'done'].includes(item.kind) && ( +
{item.detail}
+ )} + {(!compact || !!item.ports?.length) && ( +
+ {item.ports?.map((port) => ( +
+ + + +
+ ))} +
+ )} + {compact && ( + <> + + + + )} diff --git a/src/lib/workbench/diagrams/attribute-label.test.ts b/src/lib/workbench/diagrams/attribute-label.test.ts new file mode 100644 index 0000000..b3e7ffb --- /dev/null +++ b/src/lib/workbench/diagrams/attribute-label.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from 'vitest'; +import { attributeLabel } from './attribute-label'; +it('formats simple defaults without evaluating expressions or changing complex declarations', () => { + expect(attributeLabel('attribute capacityWh : Real default = 480.0;')).toBe( + 'capacityWh: Real = 480', + ); + expect(attributeLabel('attribute reserve : Real default = 20.5;')).toBe( + 'reserve: Real = 20.5', + ); + const expression = 'attribute capacity : Real default = base * factor;'; + expect(attributeLabel(expression)).toBe(expression); +}); diff --git a/src/lib/workbench/diagrams/attribute-label.ts b/src/lib/workbench/diagrams/attribute-label.ts new file mode 100644 index 0000000..5d3f0c4 --- /dev/null +++ b/src/lib/workbench/diagrams/attribute-label.ts @@ -0,0 +1,10 @@ +/** Presentation only: complex declarations retain their exact source spelling. */ +export function attributeLabel(declaration: string): string { + const match = declaration + .trim() + .match( + /^attribute\s+([A-Za-z_]\w*)\s*:\s*([A-Za-z_]\w*(?:::\w+)*)\s+default\s*=\s*([+-]?\d+(?:\.\d+)?|true|false);$/, + ); + if (!match) return declaration; + return `${match[1]}: ${match[2]} = ${match[3]!.replace(/\.0+$/, '')}`; +} diff --git a/src/lib/workbench/diagrams/exploration.test.ts b/src/lib/workbench/diagrams/exploration.test.ts new file mode 100644 index 0000000..16da2bd --- /dev/null +++ b/src/lib/workbench/diagrams/exploration.test.ts @@ -0,0 +1,93 @@ +import { expect, it } from 'vitest'; +import { layoutExploration, explorationEdgePath } from './exploration'; +import type { ViewArtifact } from '$lib/workspace/views'; + +const overview: ViewArtifact = { + kind: 'tree', + nodes: [ + { id: 'P', kind: 'package', name: 'P' }, + { id: 'System', kind: 'part def', name: 'System', parent: 'P' }, + { + id: 'primary', + kind: 'part', + name: 'primary', + type: 'Q::Motor', + parent: 'System', + }, + { + id: 'standby', + kind: 'part', + name: 'standby', + type: 'Q::Motor', + parent: 'System', + }, + { id: 'system', kind: 'part', name: 'system', type: 'System', parent: 'P' }, + { id: 'Q', kind: 'package', name: 'Q' }, + { id: 'Motor', kind: 'part def', name: 'Motor', parent: 'Q' }, + ], + edges: [ + { from: 'System', to: 'Motor', kind: 'composition', label: 'primary' }, + { from: 'System', to: 'Motor', kind: 'composition', label: 'standby' }, + ], +}; +it('keeps packages and engine ownership distinct from relationship targets', () => { + const { nodes, edges } = layoutExploration(overview); + const node = (id: string) => nodes.find((node) => node.id === id)!; + expect(node('System').parentId).toBe('P'); + expect(node('Motor').parentId).toBe('Q'); + expect(node('primary').parentId).toBe('System'); + expect(node('standby').parentId).toBe('System'); + expect(node('system').parentId).toBe('P'); + expect(node('P').parentId).toBeUndefined(); + expect(node('Q').parentId).toBeUndefined(); + expect(node('primary').style?.height).toBe(52); + expect(node('Q').position.x).toBeGreaterThan( + node('P').position.x + Number(node('P').style!.width), + ); + expect(edges.map((edge) => edge.id)).toEqual(['edge:0', 'edge:1']); + expect(edges.map((edge) => edge.label)).toEqual(['primary', 'standby']); + expect(edges[0]!.data?.route).not.toEqual(edges[1]!.data?.route); +}); +it('separates both parallel paths and labels at equal-height endpoints', () => { + const a = explorationEdgePath(0, 0, 200, 0, -32), + b = explorationEdgePath(0, 0, 200, 0, 32); + expect(a.path).not.toBe(b.path); + expect(Math.abs(a.labelY - b.labelY)).toBe(48); + expect(a.path).toMatch(/^M 0 0 C/); + expect(a.path.endsWith('200 0')).toBe(true); +}); +it('packs structural siblings into a compact row without empty card bodies', () => { + const artifact: ViewArtifact = { + ...overview, + kind: 'interconnection', + nodes: overview.nodes + .filter((node) => ['System', 'primary', 'standby'].includes(node.id)) + .map((node) => + node.id === 'System' ? { ...node, parent: undefined } : node, + ), + edges: [], + }; + const { nodes } = layoutExploration(artifact); + const root = nodes[0]!, + primary = nodes[1]!, + standby = nodes[2]!; + expect(primary.position.y).toBe(standby.position.y); + expect(standby.position.x).toBeGreaterThan(primary.position.x + 208); + expect(root.style!.height).toBe(136); + expect(primary.style!.height).toBe(52); + expect(standby.position.x + 208).toBeLessThan(Number(root.style!.width)); +}); +it('refuses malformed ownership instead of displaying an invented hierarchy', () => { + expect(() => + layoutExploration({ + ...overview, + nodes: [{ id: 'x', name: 'x', kind: 'part', parent: 'missing' }], + }), + ).toThrow(/missing owner/); + expect(() => + layoutExploration({ + ...overview, + nodes: [{ id: 'x', name: 'x', kind: 'part', parent: 'x' }], + }), + ).toThrow(/cyclic/); +}); diff --git a/src/lib/workbench/diagrams/exploration.ts b/src/lib/workbench/diagrams/exploration.ts new file mode 100644 index 0000000..eac70ae --- /dev/null +++ b/src/lib/workbench/diagrams/exploration.ts @@ -0,0 +1,188 @@ +import { + overviewGutters, + routeOverview, + explorationHeaderHeight, +} from './overview-routing'; +export { explorationHeaderHeight } from './overview-routing'; +import { Position, type Edge } from '@xyflow/react'; +import type { ArtifactNode, ViewArtifact } from '$lib/workspace/views'; +import type { StructureNode } from './structure'; + +const width = 208; +const padding = 16; +const gap = 20; +export function explorationKindLabel(kind: string) { + return ( + ( + { + package: 'Package', + partDef: 'Part definition', + partUsage: 'Part usage', + } as Record + )[kind] ?? kind + ); +} +interface Box { + item: ArtifactNode; + width: number; + height: number; + children: { box: Box; x: number; y: number }[]; +} + +/** Arrange only engine-owned containment. Relationship endpoints never become owners. */ +export function layoutExploration(artifact: ViewArtifact) { + const byId = new Map(artifact.nodes.map((item) => [item.id, item])); + if (byId.size !== artifact.nodes.length) + throw new Error('Exploration has duplicate identities.'); + const byParent = new Map(); + for (const item of artifact.nodes) { + if (item.parent && !byId.has(item.parent)) + throw new Error('Exploration has a missing owner.'); + const siblings = byParent.get(item.parent || '') ?? []; + siblings.push(item); + byParent.set(item.parent || '', siblings); + const seen = new Set([item.id]); + let owner = item.parent; + while (owner) { + if (seen.has(owner)) throw new Error('Exploration has cyclic ownership.'); + seen.add(owner); + owner = byId.get(owner)?.parent; + } + } + for (const edge of artifact.edges) + if (!byId.has(edge.from) || !byId.has(edge.to)) + throw new Error('Exploration has a missing relationship endpoint.'); + const routing = + artifact.kind === 'tree' ? overviewGutters(artifact) : undefined; + function box(item: ArtifactNode): Box { + const nested = (byParent.get(item.id) ?? []).map(box); + const top = + explorationHeaderHeight + + (item.attributeDeclarations?.length ?? 0) * 48 + + (item.ports?.length ?? 0) * 30; + // Structural leaf siblings fit side by side. Ownership overviews keep a + // narrow declaration hierarchy so package-to-package relationships have room. + const columns = + artifact.kind === 'interconnection' && + nested.every((child) => !child.children.length) + ? 2 + : 1; + const children: Box['children'] = []; + let y = top + padding, + maxWidth = 0; + for (let i = 0; i < nested.length; i += columns) { + const row = nested.slice(i, i + columns); + let x = padding; + for (const child of row) { + children.push({ box: child, x, y }); + x += child.width + gap; + } + maxWidth = Math.max(maxWidth, x - gap + padding); + y += Math.max(...row.map((child) => child.height)) + gap; + } + return { + item, + width: + Math.max(width, maxWidth) + (routing?.gutters.get(item.id)?.width ?? 0), + height: Math.max( + nested.length ? y - gap + padding : top, + routing?.gutters.has(item.id) + ? top + 32 + routing.gutters.get(item.id)!.indices.length * 24 + : 0, + ), + children, + }; + } + const nodes: StructureNode[] = []; + const absolute = new Map(); + function project(box: Box, x: number, y: number, parentId?: string) { + const parent = parentId ? absolute.get(parentId)! : { x: 0, y: 0 }; + absolute.set(box.item.id, { x: parent.x + x, y: parent.y + y }); + const item = box.item; + nodes.push({ + id: item.id, + type: 'structure', + position: { x, y }, + ...(parentId ? { parentId } : {}), + data: { item, compound: box.children.length > 0, compact: true }, + ariaLabel: `${item.kind} ${item.name}${item.type ? ' : ' + item.type : ''}`, + className: `structure-node exploration-node ${item.kind === 'package' ? 'model-package' : item.kind.endsWith('def') ? 'model-definition' : 'model-usage'}${box.children.length ? ' structure-compound' : !item.ports?.length ? ' exploration-leaf' : ''}`, + style: { width: box.width, height: box.height }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + draggable: false, + }); + for (const child of box.children) + project(child.box, child.x, child.y, item.id); + } + let x = 0; + for (const root of byParent.get('') ?? []) { + const bounds = box(root); + project(bounds, x, 0); + x += + bounds.width + + Math.max( + 120, + routing?.gutters.get('')?.labelWidth + ? routing.gutters.get('')!.labelWidth + 64 + : 0, + ); + } + if (routing) { + const result = routeOverview(artifact, nodes, absolute, routing); + return { nodes, ...result, notice: '' }; + } + const groups = new Map(); + artifact.edges.forEach((edge, index) => { + if (!byId.has(edge.from) || !byId.has(edge.to)) + throw new Error('Exploration has a missing relationship endpoint.'); + const key = JSON.stringify([edge.from, edge.to].sort()); + const group = groups.get(key) ?? []; + group.push(index); + groups.set(key, group); + }); + const lanes = new Map(); + for (const group of groups.values()) + group.forEach((index, lane) => + lanes.set(index, (lane - (group.length - 1) / 2) * 64), + ); + const edges: Edge[] = artifact.edges.map((edge, index) => { + const source = byId.get(edge.from)!, + target = byId.get(edge.to)!; + const forward = absolute.get(edge.from)!.x <= absolute.get(edge.to)!.x; + return { + id: `edge:${index}`, + source: edge.from, + target: edge.to, + sourceHandle: + edge.fromPort ?? (forward ? 'node-source' : 'exploration-source-left'), + targetHandle: + edge.toPort ?? (forward ? 'node-target' : 'exploration-target-right'), + type: 'exploration', + data: { lane: lanes.get(index) ?? 0 }, + label: edge.label || edge.kind, + ariaLabel: `${edge.kind} ${edge.label ?? ''}: ${source.name} to ${target.name}`, + interactionWidth: 20, + zIndex: 1, + deletable: false, + }; + }); + return { nodes, edges, bounds: undefined, notice: '' }; +} + +/** Parallel relationships retain distinct paths and label positions, even for horizontal endpoints. */ +export function explorationEdgePath( + sourceX: number, + sourceY: number, + targetX: number, + targetY: number, + lane: number, +) { + const dx = targetX - sourceX; + const lead = Math.max(40, Math.abs(dx) / 3) * (dx >= 0 ? 1 : -1); + return { + path: `M ${sourceX} ${sourceY} C ${sourceX + lead} ${sourceY + lane}, ${targetX - lead} ${targetY + lane}, ${targetX} ${targetY}`, + labelX: (sourceX + targetX) / 2, + labelY: (sourceY + targetY) / 2 + lane * 0.75, + }; +} diff --git a/src/lib/workbench/diagrams/overview-routing.test.ts b/src/lib/workbench/diagrams/overview-routing.test.ts new file mode 100644 index 0000000..68df1b5 --- /dev/null +++ b/src/lib/workbench/diagrams/overview-routing.test.ts @@ -0,0 +1,175 @@ +import { it, expect } from 'vitest'; +import fixture from '../../../../tests/fixtures/exploration-assembly-artifact.json'; +import type { ViewArtifact } from '$lib/workspace/views'; +import { layoutExploration, explorationEdgePath } from './exploration'; +import { absolutePositions } from './structure'; + +it('routes every native assembly overview link and label clear of declaration cards and headers', () => { + const artifact = fixture as ViewArtifact; + const { nodes, edges } = layoutExploration(artifact); + const positions = absolutePositions(nodes); + const cards = nodes.map((node) => ({ + id: node.id, + x: positions[node.id]!.x, + y: positions[node.id]!.y, + width: Number(node.style!.width), + height: node.data.compound ? 52 : Number(node.style!.height), + })); + const hits: string[] = []; + const labels: { x: number; y: number; width: number; height: number }[] = []; + for (const edge of edges) { + const source = nodes.find((n) => n.id === edge.source)!, + target = nodes.find((n) => n.id === edge.target)!; + const from = { + x: + positions[source.id]!.x + + (edge.sourceHandle === 'exploration-source-left' + ? 0 + : Number(source.style!.width)), + y: positions[source.id]!.y + Number(source.style!.height) / 2, + }; + const to = { + x: + positions[target.id]!.x + + (edge.targetHandle === 'node-target' ? 0 : Number(target.style!.width)), + y: positions[target.id]!.y + Number(target.style!.height) / 2, + }; + const route = edge.data?.route as + | { + points: { x: number; y: number }[]; + labelX: number; + labelY: number; + labelWidth: number; + } + | undefined; + let points: { x: number; y: number }[] = []; + if (route) { + for (let i = 1; i < route.points.length; i++) { + const a = route.points[i - 1]!, + b = route.points[i]!; + const steps = Math.ceil(Math.hypot(b.x - a.x, b.y - a.y) / 2); + for (let n = 0; n <= steps; n++) + points.push({ + x: a.x + ((b.x - a.x) * n) / steps, + y: a.y + ((b.y - a.y) * n) / steps, + }); + } + } else { + const values = explorationEdgePath( + from.x, + from.y, + to.x, + to.y, + Number(edge.data?.lane ?? 0), + ) + .path.match(/-?\d+(?:\.\d+)?/g)! + .map(Number); + for (let n = 0; n <= 1000; n++) { + const t = n / 1000, + u = 1 - t; + points.push({ + x: + u * u * u * values[0]! + + 3 * u * u * t * values[2]! + + 3 * u * t * t * values[4]! + + t * t * t * values[6]!, + y: + u * u * u * values[1]! + + 3 * u * u * t * values[3]! + + 3 * u * t * t * values[5]! + + t * t * t * values[7]!, + }); + } + } + for (const card of cards) + if ( + points.some( + (p) => + p.x > card.x + 1 && + p.x < card.x + card.width - 1 && + p.y > card.y + 1 && + p.y < card.y + card.height - 1, + ) + ) + hits.push(`${edge.id} crosses ${card.id}`); + const label = + route ?? + explorationEdgePath( + from.x, + from.y, + to.x, + to.y, + Number(edge.data?.lane ?? 0), + ); + const width = route?.labelWidth ?? 64; + const bounds = { + x: label.labelX - width / 2, + y: label.labelY - 10, + width, + height: 20, + }; + for (const card of cards) + if ( + bounds.x < card.x + card.width && + bounds.x + width > card.x && + bounds.y < card.y + card.height && + bounds.y + 20 > card.y + ) + hits.push(`${edge.id} label covers ${card.id}`); + for (const other of labels) + if ( + bounds.x < other.x + other.width && + bounds.x + width > other.x && + bounds.y < other.y + other.height && + bounds.y + 20 > other.y + ) + hits.push(`${edge.id} overlaps another label`); + labels.push(bounds); + } + expect(hits).toEqual([]); + expect(edges.map((e) => e.id)).toEqual( + artifact.edges.map((_, i) => `edge:${i}`), + ); + expect(edges.map((e) => [e.source, e.target, e.label])).toEqual( + artifact.edges.map((e) => [e.from, e.to, e.label || e.kind]), + ); +}); + +it('keeps routes between nonadjacent packages below intervening cards and includes their labels in scene bounds', () => { + const artifact: ViewArtifact = { + kind: 'tree', + nodes: [ + { id: 'a', name: 'A', kind: 'package' }, + { id: 'source', name: 'source', kind: 'part', parent: 'a' }, + { id: 'b', name: 'B', kind: 'package' }, + { id: 'blocker', name: 'blocker', kind: 'part', parent: 'b' }, + { id: 'c', name: 'C', kind: 'package' }, + { id: 'target', name: 'target', kind: 'part', parent: 'c' }, + ], + edges: [ + { from: 'source', to: 'target', kind: 'reference', label: 'forward' }, + { from: 'target', to: 'source', kind: 'reference', label: 'reverse' }, + ], + }; + const { nodes, edges, bounds } = layoutExploration(artifact); + const bottom = Math.max( + ...nodes + .filter((node) => !node.parentId) + .map((node) => node.position.y + Number(node.style!.height)), + ); + for (const edge of edges) { + const route = edge.data!.route as { + points: { x: number; y: number }[]; + labelY: number; + }; + expect(route.labelY).toBeGreaterThan(bottom); + expect( + route.points.filter((point) => point.y > bottom).length, + ).toBeGreaterThanOrEqual(2); + expect(bounds!.y + bounds!.height).toBeGreaterThanOrEqual( + route.labelY + 12, + ); + } + expect(edges[1]!.targetHandle).toBe('exploration-target-right'); + expect(edges[0]!.data!.route).not.toEqual(edges[1]!.data!.route); +}); diff --git a/src/lib/workbench/diagrams/overview-routing.ts b/src/lib/workbench/diagrams/overview-routing.ts new file mode 100644 index 0000000..b54012f --- /dev/null +++ b/src/lib/workbench/diagrams/overview-routing.ts @@ -0,0 +1,253 @@ +import type { Edge, Rect } from '@xyflow/react'; +import type { ViewArtifact } from '$lib/workspace/views'; +import type { StructureNode } from './structure'; +export const explorationHeaderHeight = 52; +export interface RoutePoint { + x: number; + y: number; +} +export interface OverviewRoute { + points: RoutePoint[]; + labelX: number; + labelY: number; + labelWidth: number; +} +interface Gutter { + indices: number[]; + width: number; + labelWidth: number; +} +const rowHeight = 24; +const labelWidth = (text: string) => Math.max(64, text.length * 7 + 16); + +/** Only native containment identifies the shared routing owner. */ +export function overviewGutters(artifact: ViewArtifact) { + const parents = new Map(artifact.nodes.map((node) => [node.id, node.parent])); + const ancestors = (id: string) => { + const out: string[] = []; + let current: string | undefined = id; + while (current) { + out.push(current); + current = parents.get(current); + } + return out; + }; + const owners = artifact.edges.map((edge) => { + const source = ancestors(edge.from), + target = new Set(ancestors(edge.to)); + return ( + source.find( + (id) => target.has(id) && (edge.from !== edge.to || id !== edge.from), + ) ?? '' + ); + }); + const gutters = new Map(); + owners.forEach((owner, index) => { + const gutter = gutters.get(owner) ?? { + indices: [], + width: 0, + labelWidth: 0, + }; + gutter.indices.push(index); + gutter.labelWidth = Math.max( + gutter.labelWidth, + labelWidth(artifact.edges[index]!.label || artifact.edges[index]!.kind), + ); + gutter.width = gutter.labelWidth + 32 + (gutter.indices.length - 1) * 18; + gutters.set(owner, gutter); + }); + return { owners, gutters }; +} + +export function overviewPolyline(points: readonly RoutePoint[]) { + return points + .map((point, index) => `${index ? 'L' : 'M'} ${point.x} ${point.y}`) + .join(' '); +} +const overlaps = (a: Rect, b: Rect) => + a.x < b.x + b.width && + a.x + a.width > b.x && + a.y < b.y + b.height && + a.y + a.height > b.y; + +/** Vertical sibling packing leaves a clear horizontal exit to the shared owner's gutter. */ +export function routeOverview( + artifact: ViewArtifact, + nodes: readonly StructureNode[], + absolute: ReadonlyMap, + plan: ReturnType, +) { + const byId = new Map(nodes.map((node) => [node.id, node])); + const rect = (id: string): Rect => { + const node = byId.get(id)!; + return { + ...absolute.get(id)!, + width: Number(node.style!.width), + height: Number(node.style!.height), + }; + }; + const rootOf = (id: string) => { + let node = byId.get(id)!; + while (node.parentId) node = byId.get(node.parentId)!; + return node.id; + }; + const roots = nodes.filter((node) => !node.parentId); + const occupied: Rect[] = []; + const routes: OverviewRoute[] = []; + const edges: Edge[] = artifact.edges.map((edge, index) => { + const source = rect(edge.from), + target = rect(edge.to); + const owner = plan.owners[index]!, + gutter = plan.gutters.get(owner)!; + let sourceHandle = 'node-source', + targetHandle = 'exploration-target-right'; + let start = { x: source.x + source.width, y: source.y + source.height / 2 }; + let end = { x: target.x + target.width, y: target.y + target.height / 2 }; + const text = edge.label || edge.kind; + const textWidth = labelWidth(text); + const preferredY = + edge.from === edge.to ? start.y + rowHeight : (start.y + end.y) / 2; + let labelX: number, minY: number, maxY: number; + let entryX: number, exitX: number; + if (owner) { + const bounds = rect(owner); + const lane = gutter.indices.indexOf(index); + entryX = + bounds.x + + bounds.width - + gutter.width + + 16 + + gutter.labelWidth / 2 + + lane * 18; + exitX = entryX; + labelX = entryX; + minY = bounds.y + explorationHeaderHeight + 12; + maxY = bounds.y + bounds.height - 12; + } else { + const sourceRoot = rect(rootOf(edge.from)), + targetRoot = rect(rootOf(edge.to)); + const right = sourceRoot.x < targetRoot.x; + if (!right) { + sourceHandle = 'exploration-source-left'; + targetHandle = 'exploration-target-right'; + start = { x: source.x, y: start.y }; + end = { x: target.x + target.width, y: end.y }; + } else { + targetHandle = 'node-target'; + end = { x: target.x, y: end.y }; + } + entryX = right ? sourceRoot.x + sourceRoot.width + 16 : sourceRoot.x - 16; + exitX = right ? targetRoot.x - 16 : targetRoot.x + targetRoot.width + 16; + labelX = (entryX + exitX) / 2; + const between = roots.filter((root) => { + const r = rect(root.id); + return ( + r.x > Math.min(sourceRoot.x, targetRoot.x) && + r.x < Math.max(sourceRoot.x, targetRoot.x) + ); + }); + minY = between.length + ? Math.max( + ...roots.map((root) => { + const r = rect(root.id); + return r.y + r.height; + }), + ) + 24 + : 12; + maxY = Infinity; + } + let labelY = Math.max(minY, Math.min(maxY, preferredY)); + let placed = false; + for ( + let offset = 0; + offset < artifact.edges.length * 4 + 8 && !placed; + offset++ + ) { + for (const direction of offset ? [1, -1] : [1]) { + const candidate = labelY + offset * rowHeight * direction; + const bounds = { + x: labelX - textWidth / 2 - 3, + y: candidate - 11, + width: textWidth + 6, + height: 22, + }; + if ( + candidate >= minY && + candidate <= maxY && + !occupied.some((other) => overlaps(bounds, other)) + ) { + labelY = candidate; + occupied.push(bounds); + placed = true; + break; + } + } + } + if (!placed) + throw new Error('Overview has no clear relationship label row.'); + let points: RoutePoint[]; + if ( + owner && + labelY >= Math.min(start.y, end.y) && + labelY <= Math.max(start.y, end.y) && + start.y !== end.y + ) { + points = [start, { x: entryX, y: start.y }, { x: entryX, y: end.y }, end]; + } else { + // A short second lane keeps equal-height endpoints and outlying label rows + // connected without retracing a vertical segment over its own label. + if (owner) { + entryX -= 8; + exitX += 8; + } + points = [ + start, + { x: entryX, y: start.y }, + { x: entryX, y: labelY }, + { x: exitX, y: labelY }, + { x: exitX, y: end.y }, + end, + ]; + } + points = points.filter( + (p, i) => i === 0 || p.x !== points[i - 1]!.x || p.y !== points[i - 1]!.y, + ); + const route = { points, labelX, labelY, labelWidth: textWidth }; + routes.push(route); + return { + id: `edge:${index}`, + source: edge.from, + target: edge.to, + sourceHandle, + targetHandle, + type: 'exploration', + data: { route }, + label: text, + ariaLabel: `${edge.kind} ${edge.label ?? ''}: ${byId.get(edge.from)!.data.item.name} to ${byId.get(edge.to)!.data.item.name}`, + interactionWidth: 20, + zIndex: 1, + deletable: false, + }; + }); + const rectangles = nodes.map((node) => rect(node.id)); + for (const route of routes) { + for (const point of route.points) + rectangles.push({ ...point, width: 0, height: 0 }); + rectangles.push({ + x: route.labelX - route.labelWidth / 2 - 6, + y: route.labelY - 12, + width: route.labelWidth + 12, + height: 24, + }); + } + const left = Math.min(...rectangles.map((r) => r.x)), + top = Math.min(...rectangles.map((r) => r.y)); + const right = Math.max(...rectangles.map((r) => r.x + r.width)), + bottom = Math.max(...rectangles.map((r) => r.y + r.height)); + return { + edges, + bounds: rectangles.length + ? { x: left, y: top, width: right - left, height: bottom - top } + : undefined, + }; +} diff --git a/src/lib/workbench/diagrams/structure.ts b/src/lib/workbench/diagrams/structure.ts index 66988aa..a501bba 100644 --- a/src/lib/workbench/diagrams/structure.ts +++ b/src/lib/workbench/diagrams/structure.ts @@ -1,9 +1,16 @@ +import type { LayoutRoute } from './RoutedEdge'; import ELK, { type ElkNode } from 'elkjs/lib/elk.bundled.js'; -import type { Node, Edge } from '@xyflow/react'; +import { MarkerType, type Node, type Edge } from '@xyflow/react'; import type { ArtifactNode, Point, ViewArtifact } from '$lib/workspace/views'; export type StructureNode = Node< - { item: ArtifactNode; compound: boolean }, + { + item: ArtifactNode; + compound: boolean; + compact?: boolean; + behavior?: boolean; + dense?: boolean; + }, 'structure' >; const padding = 32; @@ -20,8 +27,11 @@ const layoutOptions = { }; export const headingHeight = 66; export const portHeight = 30; -const contentTop = (item: ArtifactNode) => - headingHeight + (item.ports?.length ?? 0) * portHeight + 20; +const contentTop = (item: ArtifactNode, dense = false) => + headingHeight + + (item.attributeDeclarations?.length ?? 0) * (dense ? 32 : 48) + + (item.ports?.length ?? 0) * portHeight + + 20; /** Positions crossing the workspace boundary remain absolute version-1 coordinates. */ export function absolutePositions( @@ -46,33 +56,150 @@ export async function layoutStructure( artifact: ViewArtifact, stored: Readonly>, ) { + const behavior = ['action', 'state'].includes(artifact.kind); + const dense = !behavior && artifact.nodes.length >= 10; + const cardWidth = dense ? 240 : width; + const options = behavior + ? { + ...layoutOptions, + 'elk.direction': 'DOWN', + 'elk.edgeRouting': 'ORTHOGONAL', + } + : { + ...layoutOptions, + 'elk.edgeRouting': 'ORTHOGONAL', + ...(dense + ? { + 'elk.spacing.nodeNode': '40', + 'elk.layered.spacing.nodeNodeBetweenLayers': '64', + } + : {}), + }; const children = (id?: string) => artifact.nodes.filter((item) => (item.parent || undefined) === id); function graphNode(item: ArtifactNode): ElkNode { const nested = children(item.id); + const terminal = + behavior && ['initial', 'final', 'start', 'done'].includes(item.kind); return { id: item.id, - width, - height: contentTop(item), + width: terminal ? 36 : cardWidth, + height: terminal ? 36 : contentTop(item, dense), + ...(behavior + ? { + ports: [ + { + id: item.id + '::source', + width: 0, + height: 0, + layoutOptions: { 'elk.port.side': 'SOUTH' }, + }, + { + id: item.id + '::target', + width: 0, + height: 0, + layoutOptions: { 'elk.port.side': 'NORTH' }, + }, + ...(item.ports ?? []).map((port) => ({ + id: port.id, + width: 0, + height: 0, + layoutOptions: { + 'elk.port.side': port.direction === 'out' ? 'EAST' : 'WEST', + }, + })), + ], + layoutOptions: { + 'elk.portConstraints': 'FIXED_SIDE', + ...(terminal + ? { + 'elk.layered.layering.layerConstraint': [ + 'initial', + 'start', + ].includes(item.kind) + ? 'FIRST' + : 'LAST', + } + : {}), + }, + } + : {}), + ...(!behavior + ? { + ports: [ + { + id: item.id + '::source', + x: cardWidth, + y: contentTop(item, dense) / 2, + width: 0, + height: 0, + layoutOptions: { 'elk.port.side': 'EAST' }, + }, + { + id: item.id + '::target', + x: 0, + y: contentTop(item, dense) / 2, + width: 0, + height: 0, + layoutOptions: { 'elk.port.side': 'WEST' }, + }, + ...(item.ports ?? []).map((port, index) => ({ + id: port.id, + x: port.direction === 'out' ? cardWidth : 0, + // Match the rendered border, header, attribute border and row centre. + // Leaf rows share the 20px footer minus two 1px borders. + // Logical owners are not DOM ancestors of their child nodes. + y: + 1 + + headingHeight + + (item.attributeDeclarations?.length ?? 0) * + (dense ? 32 : 48) + + (item.attributeDeclarations?.length ? 1 : 0) + + (index + 0.5) * + (portHeight + + (!nested.length ? 18 / item.ports!.length : 0)) + + (index > 0 ? 0.5 : 0), + width: 0, + height: 0, + layoutOptions: { + 'elk.port.side': port.direction === 'out' ? 'EAST' : 'WEST', + }, + })), + ], + layoutOptions: { 'elk.portConstraints': 'FIXED_POS' }, + } + : {}), ...(nested.length ? { children: nested.map(graphNode), layoutOptions: { - ...layoutOptions, - 'elk.padding': `[top=${contentTop(item)},left=${padding},bottom=${padding},right=${padding}]`, + ...options, + 'elk.portConstraints': behavior ? 'FIXED_SIDE' : 'FIXED_POS', + 'elk.padding': `[top=${contentTop(item, dense)},left=${padding},bottom=${padding},right=${padding}]`, }, } : {}), }; } - const layout = await new ELK().layout({ + const layout: ElkNode = await new ELK().layout({ id: 'structure-root', - layoutOptions, + layoutOptions: options, children: children().map(graphNode), edges: artifact.edges.map((edge, index) => ({ id: `edge-${index}`, - sources: [edge.from], - targets: [edge.to], + sources: [edge.fromPort ?? edge.from + '::source'], + targets: [edge.toPort ?? edge.to + '::target'], + ...((behavior ? edge.label : edge.label || edge.kind) + ? { + labels: [ + { + text: behavior ? edge.label! : edge.label || edge.kind, + width: (edge.label || edge.kind).length * 7 + 16, + height: 20, + }, + ], + } + : {}), })), }); const nodes: StructureNode[] = []; @@ -83,14 +210,16 @@ export async function layoutStructure( const parentPosition = parent ? absolute[parent.id]! : { x: 0, y: 0 }; let position = { x: graph.x ?? 0, y: graph.y ?? 0 }; const saved = stored[item.id]; - const min = parent ? { x: padding, y: contentTop(parent.data.item) } : null; + const min = parent + ? { x: padding, y: contentTop(parent.data.item, dense) } + : null; const max = parent ? { - x: Number(parent.style!.width) - padding - (graph.width ?? width), + x: Number(parent.style!.width) - padding - (graph.width ?? cardWidth), y: Number(parent.style!.height) - padding - - (graph.height ?? contentTop(item)), + (graph.height ?? contentTop(item, dense)), } : null; if (saved) { @@ -114,12 +243,17 @@ export async function layoutStructure( id: item.id, type: 'structure', position, - data: { item, compound }, + data: { + item, + compound, + ...(behavior ? { behavior: true } : {}), + ...(dense ? { dense: true } : {}), + }, ariaLabel: `${item.reference ? 'ref ' : ''}${item.direction ? item.direction + ' ' : ''}${item.kind} ${item.name}${item.type ? ' : ' + item.type : ''}`, - className: `structure-node ${item.reference ? 'model-reference ' : ''}${item.kind === 'part def' ? 'model-definition' : 'model-usage'}${compound ? ' structure-compound' : ''}`, + className: `structure-node ${behavior ? 'behavior-node behavior-' + item.kind.replaceAll(' ', '-') + ' ' : ''}${item.reference ? 'model-reference ' : ''}${item.kind === 'part def' ? 'model-definition' : 'model-usage'}${compound ? ' structure-compound' : ''}`, style: { - width: graph.width ?? width, - height: graph.height ?? contentTop(item), + width: graph.width ?? cardWidth, + height: graph.height ?? contentTop(item, dense), }, ...(parent && min && max ? { @@ -127,8 +261,8 @@ export async function layoutStructure( extent: [ [min.x, min.y], [ - max.x + (graph.width ?? width), - max.y + (graph.height ?? contentTop(item)), + max.x + (graph.width ?? cardWidth), + max.y + (graph.height ?? contentTop(item, dense)), ], ] as [[number, number], [number, number]], } @@ -148,11 +282,45 @@ export async function layoutStructure( target: edge.to, sourceHandle: edge.fromPort ?? 'node-source', targetHandle: edge.toPort ?? 'node-target', - label: edge.label || edge.kind, + label: behavior ? edge.label || undefined : edge.label || edge.kind, ariaLabel: `${edge.kind} ${edge.label ?? ''}: ${endpoint(edge.from, edge.fromPort)} to ${endpoint(edge.to, edge.toPort)}`, - type: 'smoothstep', + type: 'routed', + data: { route: layoutRoute(index) }, + ...(behavior + ? { + markerEnd: { type: MarkerType.ArrowClosed }, + style: edge.kind === 'flow' ? { strokeDasharray: '6 4' } : undefined, + } + : {}), zIndex: 1, })); + function layoutRoute(index: number): LayoutRoute { + const routed = layout.edges?.find((edge) => edge.id === `edge-${index}`); + const section = routed?.sections?.[0]; + if (!section || routed?.sections?.length !== 1) + throw new Error('Layout omitted an unambiguous relationship route.'); + const offset = (routed.container && absolute[routed.container]) || { + x: 0, + y: 0, + }; + const label = routed.labels?.[0]; + return { + ...(routed.container && absolute[routed.container] + ? { container: { id: routed.container, position: { ...offset } } } + : {}), + points: [ + section.startPoint, + ...(section.bendPoints ?? []), + section.endPoint, + ].map((point) => ({ x: point.x + offset.x, y: point.y + offset.y })), + ...(label && label.x !== undefined && label.y !== undefined + ? { + labelX: offset.x + label.x + (label.width ?? 0) / 2, + labelY: offset.y + label.y + (label.height ?? 0) / 2, + } + : {}), + }; + } function endpoint(id: string, portId?: string) { const item = artifact.nodes.find((node) => node.id === id)!; const names = [item.name]; diff --git a/src/lib/workbench/diagrams/useDiagramFit.ts b/src/lib/workbench/diagrams/useDiagramFit.ts new file mode 100644 index 0000000..1a38b1b --- /dev/null +++ b/src/lib/workbench/diagrams/useDiagramFit.ts @@ -0,0 +1,23 @@ +import { useCallback } from 'react'; +import { + getViewportForBounds, + useReactFlow, + useStore, + type Rect, +} from '@xyflow/react'; +/** Exploration routes and labels participate in fitting without adding model nodes. */ +export function useDiagramFit(padding: number, bounds?: Rect) { + const { fitView, setViewport } = useReactFlow(); + const width = useStore((state) => state.width), + height = useStore((state) => state.height), + minZoom = useStore((state) => state.minZoom); + return useCallback( + () => + bounds && width > 0 && height > 0 + ? setViewport( + getViewportForBounds(bounds, width, height, minZoom, 1, padding), + ) + : fitView({ padding, maxZoom: 1 }), + [bounds, width, height, minZoom, padding, fitView, setViewport], + ); +} diff --git a/src/lib/workbench/entry/ProjectLauncher.tsx b/src/lib/workbench/entry/ProjectLauncher.tsx index 84be980..d38e64b 100644 --- a/src/lib/workbench/entry/ProjectLauncher.tsx +++ b/src/lib/workbench/entry/ProjectLauncher.tsx @@ -47,8 +47,7 @@ export function ProjectLauncher({ Open a project

- Choose a starting point. Your model stays in this browser until - you download it. + Create a model, open your files, or explore the inspection rover.

{projectName && ( @@ -74,22 +73,11 @@ export function ProjectLauncher({ Opening project…

)} -
- +
+
-

Local work, explicit saves

+

Save your work

- Opening imports a copy. Download project keeps source, drafts and - diagram positions together. Local recovery is a separate browser - copy. + Imported files stay unchanged. Download your project to save source, + drafts and diagram positions. Local recovery keeps a separate copy + in this browser.

-
- Folder and repository workflows -

- Direct folder saving and Flexo project/branch checkout are planned. - Use source files or a project bundle to work locally today. -

-
OpenMBEE View Editor diff --git a/src/lib/workbench/explorer/Explorer.tsx b/src/lib/workbench/explorer/Explorer.tsx index 2432478..8df1333 100644 --- a/src/lib/workbench/explorer/Explorer.tsx +++ b/src/lib/workbench/explorer/Explorer.tsx @@ -75,7 +75,9 @@ export function Explorer({ {view.name} ))} - {!views.length &&

Create a view of a model element to begin.

} + {!views.length && ( +

No named views. Explore the model or create a view.

+ )}
symbol === `edge:${index}`, diff --git a/src/lib/workbench/requirements/RequirementsPanel.tsx b/src/lib/workbench/requirements/RequirementsPanel.tsx new file mode 100644 index 0000000..7c1ebfd --- /dev/null +++ b/src/lib/workbench/requirements/RequirementsPanel.tsx @@ -0,0 +1,339 @@ +import { + NativeSelect, + NativeSelectOption, +} from '$lib/ui/primitives/native-select'; +import { useEffect, useState } from 'react'; +import type { Workspace, WorkspaceState } from '$lib/workspace/createWorkspace'; +import type { + RequirementsResult, + RequirementSubject, +} from '$lib/workspace/requirements'; +import { Button } from '$lib/ui/primitives/button'; +import { Input } from '$lib/ui/primitives/input'; + +export function RequirementsPanel({ + workspace, + state, + mode, + onReveal, +}: { + workspace: Workspace; + state: WorkspaceState; + mode: 'table' | 'matrix'; + onReveal: (subject: RequirementSubject) => void; +}) { + const [result, setResult] = useState(); + const [error, setError] = useState(''); + const [attempt, retry] = useState(0); + const [search, setSearch] = useState(''); + const [reverse, setReverse] = useState(false); + const [kind, setKind] = useState<'satisfaction' | 'verification'>( + 'satisfaction', + ); + useEffect(() => { + const controller = new AbortController(); + setResult(undefined); + setError(''); + workspace + .requirements(controller.signal) + .then((value) => { + if (!controller.signal.aborted) setResult(value); + }) + .catch((reason) => { + if (!controller.signal.aborted) + setError(reason instanceof Error ? reason.message : String(reason)); + }); + return () => controller.abort(); + }, [workspace, state.model, attempt]); + const current = + result?.modelHash === state.model?.modelHash ? result : undefined; + const rows = (current?.requirements ?? []) + .filter((row) => + `${row.shortName} ${row.subject.qualifiedName} ${row.documentation.join(' ')}` + .toLowerCase() + .includes(search.toLowerCase()), + ) + .slice() + .sort( + (a, b) => + (reverse ? -1 : 1) * + (a.subject.qualifiedName ?? a.subject.id).localeCompare( + b.subject.qualifiedName ?? b.subject.id, + ), + ); + const links = current?.relationships ?? []; + const rowIDs = new Set(rows.map((row) => row.subject.id)); + const byTarget = new Map(); + const byCell = new Map(); + for (const link of links) { + const targetLinks = byTarget.get(link.target.id) ?? []; + targetLinks.push(link); + byTarget.set(link.target.id, targetLinks); + if (link.kind === kind) { + const key = JSON.stringify([link.target.id, link.source.id]); + const cell = byCell.get(key) ?? []; + cell.push(link); + byCell.set(key, cell); + } + } + const cellLinks = (target: string, source: string) => + byCell.get(JSON.stringify([target, source])) ?? []; + const relevant = links.filter( + (link) => link.kind === kind && rowIDs.has(link.target.id), + ); + const columns = [ + ...new Map(relevant.map((link) => [link.source.id, link.source])).values(), + ].sort((a, b) => + (a.qualifiedName ?? a.id).localeCompare(b.qualifiedName ?? b.id), + ); + const tooLarge = mode === 'matrix' && rows.length * columns.length > 10000; + const available = + current?.status === 'complete' && + current.modelHash === state.model?.modelHash && + !state.busy; + function reveal(subject: RequirementSubject) { + if (available) onReveal(subject); + } + function exportCSV() { + if (!available || tooLarge) return; + const data = + mode === 'table' + ? [ + [ + 'Identifier', + 'Requirement', + 'Kind', + 'Documentation', + 'Declared satisfaction', + 'Declared verification', + ], + ...rows.map((row) => [ + row.shortName, + row.subject.qualifiedName ?? row.subject.id, + row.kind === 'requirementDef' ? 'Definition' : 'Usage', + row.documentation.join('\n'), + ...(['satisfaction', 'verification'] as const).map((k) => + (byTarget.get(row.subject.id) ?? []) + .filter((l) => l.kind === k) + .map( + (l) => + `${l.negated ? 'NOT ' : ''}${l.source.qualifiedName ?? l.source.id}`, + ) + .join('; '), + ), + ]), + ] + : [ + ['Requirement', ...columns.map((c) => c.qualifiedName ?? c.id)], + ...rows.map((row) => [ + row.subject.qualifiedName ?? row.subject.id, + ...columns.map((c) => + cellLinks(row.subject.id, c.id) + .map((l) => (l.negated ? 'Negated declaration' : 'Declared')) + .join('; '), + ), + ]), + ]; + const csv = data + .map((row) => + row + .map((value) => '"' + String(value).replaceAll('"', '""') + '"') + .join(','), + ) + .join('\r\n'); + const url = URL.createObjectURL( + new Blob([csv], { type: 'text/csv;charset=utf-8' }), + ); + const a = document.createElement('a'); + a.href = url; + a.download = `requirements-${mode}.csv`; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + return ( +
+
+ + + {mode === 'matrix' && ( + + )} + +
+

+ Read-only · Applied model + {state.unapplied ? ' · Unapplied drafts are retained' : ''}. Links are + declarations, not verification results. Matrix columns show elements + with declared links. +

+ {error ? ( +
+ {error}{' '} + +
+ ) : !current ? ( +

Loading requirements…

+ ) : current.status !== 'complete' ? ( +
+ Requirements unavailable: {current.diagnostics.join(' ')} +
+ ) : ( + <> +

+ {rows.length} requirements + {mode === 'matrix' ? ` · ${columns.length} linked elements` : ''} +

+ {tooLarge ? ( +

+ This matrix exceeds 10,000 cells. Search requirements to narrow + the displayed rows and linked columns. +

+ ) : rows.length === 0 ? ( +

No requirements match this selection.

+ ) : ( +
+ + + + + + {mode === 'table' ? ( + <> + + + + + + + ) : ( + columns.map((column) => ( + + )) + )} + + + + {rows.map((row) => ( + + + {mode === 'table' ? ( + <> + + + + {(['satisfaction', 'verification'] as const).map( + (k) => ( + + ), + )} + + ) : ( + columns.map((column) => ( + + )) + )} + + ))} + +
+ {mode === 'table' + ? 'Requirements and declared traceability' + : 'Declared requirement traceability matrix'} +
RequirementIdentifierKindDocumentationDeclared satisfactionDeclared verification + +
+ + {row.shortName || '—'} + {row.kind === 'requirementDef' + ? 'Definition' + : 'Usage'} + {row.documentation.join('\n') || '—'} + {(byTarget.get(row.subject.id) ?? []) + .filter((l) => l.kind === k) + .map((link, i) => ( +
+ +
+ ))} +
+ {cellLinks(row.subject.id, column.id).map( + (link, i) => ( +
+ +
+ ), + )} +
+
+ )} + + )} +
+ ); +} diff --git a/src/lib/workbench/shell/Workbench.tsx b/src/lib/workbench/shell/Workbench.tsx index 8dc9c6c..1e57323 100644 --- a/src/lib/workbench/shell/Workbench.tsx +++ b/src/lib/workbench/shell/Workbench.tsx @@ -1,11 +1,14 @@ +import { RequirementsPanel } from '../requirements/RequirementsPanel'; +import type { RequirementSubject } from '$lib/workspace/requirements'; +import { Button } from '$lib/ui/primitives/button'; import { WorkbenchLayout, WorkbenchPanels, IssuesToggle, } from './WorkbenchPanels'; -import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { createBrowserSession } from './session'; -import { exampleProject, exampleView } from './example'; +import { exampleProject, exampleView, roverGuide } from './example'; import type { Workspace, WorkspaceState } from '$lib/workspace/createWorkspace'; import type { ProjectBundle } from '$lib/workspace/persistence'; import type { EditProposal, SemanticCommand } from '$lib/workspace/editing'; @@ -46,6 +49,7 @@ const initial: WorkspaceState = { diagnostics: [], diagnosticDocuments: [], view: null, + exploration: null, layoutNotices: [], }; type Mode = 'split' | 'code' | 'diagram'; @@ -58,6 +62,9 @@ export function Workbench({ entryRequest: EntryRequest; onHome: () => void; }) { + const [dataMode, setDataMode] = useState<'diagram' | 'table' | 'matrix'>( + 'diagram', + ); const [readySession, setReadySession] = useState(null); const [launcherOpen, setLauncherOpen] = useState(true); const [everOpened, setEverOpened] = useState(false); @@ -84,6 +91,9 @@ export function Workbench({ modelHash: string; symbol: string; }>(); + const [explorationFocus, setExplorationFocus] = useState< + 'scope' | 'representation' + >(); const [mode, setMode] = useState('diagram'), [dialog, setDialog] = useState< 'project' | 'view' | 'part' | 'connection' | 'proposal' | null @@ -93,6 +103,7 @@ export function Workbench({ file: string; start: number; end: number; + focus?: boolean; }>(); const recovery = useLocalRecovery({ state, @@ -103,6 +114,7 @@ export function Workbench({ if (!replaceAllowed()) return; const session = workspace(); await session.open(entry.project, { unsaved: true }); + setDataMode('diagram'); const restored = session.snapshot(); recovery.acceptRestored({ project: restored.project!, @@ -122,25 +134,36 @@ export function Workbench({ await refreshView(session, entry.activeView ?? undefined); } const element = state.model?.elements.find((item) => item.id === selected); + // Adapt only for shared rendering/selection; workspace state and saved views stay distinct. + const displayedView = useMemo( + () => + state.view ?? + (state.exploration ? { ...state.exploration, view: '' } : null), + [state.view, state.exploration], + ); + const diagramIdentity = state.exploration + ? JSON.stringify(state.exploration.request) + : state.view?.view; const selectedCorrespondence = - symbolSelection?.view === state.view?.view && - symbolSelection?.modelHash === state.view?.modelHash - ? state.view?.correspondence.find( + symbolSelection?.view === diagramIdentity && + symbolSelection?.modelHash === displayedView?.modelHash + ? displayedView?.correspondence.find( (item) => item.symbol === symbolSelection?.symbol, ) : undefined; const relationshipSelection = diagramRelationshipSelection( - state.view, + displayedView, selectedCorrespondence?.symbol, + !!state.exploration, ); - const selectedArtifactNode = state.view?.artifact?.nodes.find( + const selectedArtifactNode = displayedView?.artifact?.nodes.find( (item) => item.id === selectedCorrespondence?.symbol, ); const usageContext = selectedCorrespondence?.context .slice(0, -1) .map( (origin) => - state.view?.correspondence.find((item) => item.subject.id === origin) + displayedView?.correspondence.find((item) => item.subject.id === origin) ?.subject.qualifiedName ?? 'Unresolved context', ); const source = @@ -165,6 +188,11 @@ export function Workbench({ .model?.elements.filter((item) => item.kind === 'viewUsage') ?? []; const target = views.find((item) => item.id === preferred) ?? views[0]; if (target) await session.openView(target.id); + else + await session.explore({ + scope: { kind: 'project' }, + representation: 'tree', + }); } useEffect(() => { const { workspace: session, language } = createBrowserSession(); @@ -240,6 +268,7 @@ export function Workbench({ ) { const session = workspace(); await session.open(project, { unsaved: options.unsaved }); + setDataMode('diagram'); setSelected(''); setSymbolSelection(undefined); setFile(project.drafts[0]!.name); @@ -369,17 +398,18 @@ export function Workbench({ ? command.owner + '::' + command.name : active, ); + if (command.kind === 'createView') setDataMode('diagram'); } function selectSymbol(symbol: string) { - const correspondence = state.view?.correspondence.find( + const correspondence = displayedView?.correspondence.find( (item) => item.symbol === symbol, ); setSymbolSelection(undefined); setSelected(correspondence?.subject.qualifiedName ?? ''); - if (correspondence && state.view) + if (correspondence && displayedView) setSymbolSelection({ - view: state.view.view, - modelHash: state.view.modelHash, + view: diagramIdentity ?? '', + modelHash: displayedView.modelHash, symbol, }); if (correspondence?.subject.qualifiedName) { @@ -398,6 +428,7 @@ export function Workbench({ try { setFocusRange({ file: document.name, + focus: false, start: decoder.decode(bytes.slice(0, origin.offset)).length, end: decoder.decode(bytes.slice(0, origin.offset + origin.length)) .length, @@ -434,12 +465,35 @@ export function Workbench({ if (mode === 'diagram') setMode('split'); setFocusRange(location.target); } + function revealRequirement(subject: RequirementSubject) { + const origin = subject.origin; + const project = workspace().snapshot().project; + const applied = project?.documents.find((d) => d.name === origin?.file); + const draft = project?.drafts.find((d) => d.name === origin?.file); + if (!origin || !applied || applied.text !== draft?.text) { + setMessage( + 'Apply or discard this file’s draft before revealing its applied source.', + ); + return; + } + const bytes = new TextEncoder().encode(applied.text), + decoder = new TextDecoder('utf-8', { fatal: true }); + setSelected(subject.qualifiedName ?? ''); + setSymbolSelection(undefined); + setFile(applied.name); + setMode('split'); + setFocusRange({ + file: applied.name, + start: decoder.decode(bytes.slice(0, origin.offset)).length, + end: decoder.decode(bytes.slice(0, origin.offset + origin.length)).length, + }); + } function revealSource() { if (focusRange) { setFile(focusRange.file); setMode('split'); // Repeat the reveal even when the same file and layout are already open. - setFocusRange({ ...focusRange }); + setFocusRange({ ...focusRange, focus: true }); } else setMessage( 'Select this element in a diagram to reveal its recorded source range.', @@ -468,7 +522,7 @@ export function Workbench({ {launcherOpen && active && ( void run(loadExample)} + onExample={() => void run(() => loadExample())} onNew={() => { if (replaceAllowed()) { setError(''); @@ -577,7 +631,17 @@ export function Workbench({ setFile(name); if (mode === 'diagram') setMode('split'); }} - onView={(id) => void run(() => workspace().openView(id))} + onView={(id) => + void run(async () => { + setDataMode('diagram'); + try { + await workspace().openView(id); + } catch (error) { + setDataMode(dataMode); + throw error; + } + }) + } onSelect={selectElement} onNewView={newView} /> @@ -631,26 +695,81 @@ export function Workbench({ ) : null } diagram={ - item.kind === 'viewUsage', - ) ?? [] - } - busy={state.busy} - onOpenView={(id) => void run(() => workspace().openView(id))} - layoutMode={mode} - key={(state.view?.view ?? 'none') + ':' + state.revision} - view={state.view} - presentation={compatiblePresentation} - disabled={state.busy || state.unapplied} - onSelect={selectSymbol} - onCreatePart={newPart} - onConnectPorts={newConnection} - onMove={(positions) => - void run(() => workspace().moveSymbols(positions)) - } - /> +
+
+ {(['diagram', 'table', 'matrix'] as const).map((value) => ( + + ))} +
+ {dataMode !== 'diagram' && readySession && ( + + )} +
+ item.kind === 'viewUsage', + ) ?? [] + } + busy={state.busy} + onOpenView={(id) => + void run(() => workspace().openView(id)) + } + layoutMode={mode} + key={(diagramIdentity ?? 'none') + ':' + state.revision} + view={displayedView} + guide={ + origin === 'Built-in example' && displayedView + ? roverGuide[displayedView.view] + : undefined + } + exploration={state.exploration?.request} + elements={state.model?.elements ?? []} + explorationFocus={explorationFocus} + onExplore={(request, focus) => { + setExplorationFocus(focus); + void run(() => workspace().explore(request)); + }} + presentation={compatiblePresentation} + disabled={state.busy || state.unapplied} + onSelect={selectSymbol} + onCreatePart={newPart} + onConnectPorts={newConnection} + onMove={(positions) => + state.exploration + ? undefined + : void run(() => workspace().moveSymbols(positions)) + } + /> +
+
} inspector={ void run(() => reviewRename(name))} diff --git a/src/lib/workbench/shell/example.ts b/src/lib/workbench/shell/example.ts index c22a0d0..693d243 100644 --- a/src/lib/workbench/shell/example.ts +++ b/src/lib/workbench/shell/example.ts @@ -1,31 +1,37 @@ -import system from '../../../../examples/air-compressor/system.sysml?raw'; -import components from '../../../../examples/air-compressor/components.sysml?raw'; -import types from '../../../../examples/air-compressor/types.sysml?raw'; -import behavior from '../../../../examples/air-compressor/behavior.sysml?raw'; -import requirements from '../../../../examples/air-compressor/requirements.sysml?raw'; -import usecases from '../../../../examples/air-compressor/use-cases.sysml?raw'; -import views from '../../../../examples/air-compressor/views.sysml?raw'; -import sequence from '../../../../examples/air-compressor/sequence.sysml?raw'; -import geometry from '../../../../examples/air-compressor/geometry.sysml?raw'; import type { ProjectBundle } from '$lib/workspace/persistence'; - -export const exampleView = 'CompressorViews::pneumatics'; -const documents = [ - { name: 'system.sysml', text: system }, - { name: 'components.sysml', text: components }, - { name: 'types.sysml', text: types }, - { name: 'behavior.sysml', text: behavior }, - { name: 'requirements.sysml', text: requirements }, - { name: 'use-cases.sysml', text: usecases }, - { name: 'views.sysml', text: views }, - { name: 'sequence.sysml', text: sequence }, - { name: 'geometry.sysml', text: geometry }, +import roverSystem from '../../../../examples/inspection-rover/system.sysml?raw'; +import roverComponents from '../../../../examples/inspection-rover/components.sysml?raw'; +import roverTypes from '../../../../examples/inspection-rover/types.sysml?raw'; +import roverBehavior from '../../../../examples/inspection-rover/behavior.sysml?raw'; +import roverRequirements from '../../../../examples/inspection-rover/requirements.sysml?raw'; +import roverViews from '../../../../examples/inspection-rover/views.sysml?raw'; +const roverDocuments = [ + { name: 'system.sysml', text: roverSystem }, + { name: 'components.sysml', text: roverComponents }, + { name: 'types.sysml', text: roverTypes }, + { name: 'behavior.sysml', text: roverBehavior }, + { name: 'requirements.sysml', text: roverRequirements }, + { name: 'views.sysml', text: roverViews }, ]; +export const exampleView = 'RoverViews::architecture'; export const exampleProject: ProjectBundle = { format: 'sysmlv2-view-project', version: 1, - name: 'Smart air workshop', - documents, - drafts: documents, + name: 'Inspection rover', + documents: roverDocuments, + drafts: roverDocuments, presentations: {}, }; + +export const roverGuide: Record = { + 'RoverViews::architecture': + '1 · Meet the rover. Follow battery power into mobility, autonomy and inspection. Component values are authored design declarations. Next: mission.', + 'RoverViews::mission': + '2 · Follow an inspection mission: check reserve, patrol, detect, capture, report, return and recharge. This is declared order, not a running simulation. Next: lifecycle.', + 'RoverViews::lifecycle': + '3 · Explore normal operation and the safe-stop path. Low reserve sends the rover home; charging returns it to docked. Next: requirements.', + 'RoverViews::requirements': + '4 · Trace reserve, speed, clearance and image quality to the exact rover parts. Satisfaction is declared, not test evidence. Next: definitions.', + 'RoverViews::definitions': + '5 · Compare reusable components. Two drives share one type; Drive specializes Actuator, and Lidar and Camera specialize Sensor.', +}; diff --git a/src/lib/workbench/shell/session.ts b/src/lib/workbench/shell/session.ts index de83954..c7818a0 100644 --- a/src/lib/workbench/shell/session.ts +++ b/src/lib/workbench/shell/session.ts @@ -5,11 +5,14 @@ import { createSourceLanguageEngine } from '$lib/engines/toolkit/language'; /** Composition is the only workbench location that constructs engine adapters. */ export function createBrowserSession() { const toolkitBase = new URL('vendor/sysml-toolkit/', document.baseURI).href; + const semantic = createSemanticEngine( + new URL('vendor/opensysml/', document.baseURI).href, + ); return { workspace: createWorkspace({ - semantic: createSemanticEngine( - new URL('vendor/opensysml/', document.baseURI).href, - ), + semantic, + exploration: semantic, + requirements: semantic, transformations: createTransformationEngine(toolkitBase), }), language: createSourceLanguageEngine(toolkitBase), diff --git a/src/lib/workbench/source/SourceEditor.tsx b/src/lib/workbench/source/SourceEditor.tsx index 668f4c3..786bed0 100644 --- a/src/lib/workbench/source/SourceEditor.tsx +++ b/src/lib/workbench/source/SourceEditor.tsx @@ -1,4 +1,5 @@ -import { useEffectEvent, useLayoutEffect, useRef } from 'react'; +import { Button } from '$lib/ui/primitives/button'; +import { useEffectEvent, useLayoutEffect, useRef, useState } from 'react'; import { basicSetup } from 'codemirror'; import { Compartment, EditorState } from '@codemirror/state'; import { EditorView } from '@codemirror/view'; @@ -10,7 +11,7 @@ import { import { tags } from '@lezer/highlight'; import type { SourceDocument } from '$lib/workspace/project'; import { createSourceAssistance, type LanguageReply } from './assistance'; -import type { LanguageQuery } from '$lib/workspace/language'; +import type { LanguageQuery, LanguageResult } from '$lib/workspace/language'; import { sysmlLanguage } from './sysml-language'; import { applyEditorChanges, editorOffset } from './source-text'; @@ -34,7 +35,7 @@ export function SourceEditor({ }: { file: SourceDocument; disabled?: boolean; - selection?: { start: number; end: number }; + selection?: { start: number; end: number; focus?: boolean }; onChange: (value: string) => void; onLanguage?: ( query: LanguageQuery, @@ -44,6 +45,10 @@ export function SourceEditor({ onLanguageMessage?: (message: string) => void; languageContext?: unknown; }) { + const [references, setReferences] = useState<{ + result: Extract; + isCurrent: () => boolean; + }>(); const host = useRef(null); const editor = useRef<{ view: EditorView; @@ -76,6 +81,7 @@ export function SourceEditor({ query, navigate, notify, + references: (result, isCurrent) => setReferences({ result, isCurrent }), }); assistance.current = language; const state = (document: SourceDocument) => @@ -112,6 +118,7 @@ export function SourceEditor({ }, []); useLayoutEffect(() => { assistance.current?.clear(); + setReferences(undefined); }, [languageContext, file.name, file.text, disabled]); useLayoutEffect(() => { const current = editor.current!; @@ -147,20 +154,78 @@ export function SourceEditor({ }, scrollIntoView: true, }); - view.focus(); + if (selection.focus !== false) view.focus(); }, [selection, file.name]); return (
{file.name} - SysML v2 +
+ + +

Find and replace with Control+F or Command+F. Tab leaves the editor. Request completion with Control+Space. Press F1 for symbol information - or F12 for its definition. Escape dismisses symbol information. Editing - undo affects this draft; toolbar Undo source restores applied revisions. + or F12 for its definition; Shift+F12 finds references. Escape dismisses + symbol information. Editing undo affects this draft; toolbar Undo source + restores applied revisions.

+ {references && ( +
+
+ {references.result.targets.length} references{' '} + +
+ {references.result.omitted > 0 && ( +

+ {references.result.omitted} references outside this project are + unavailable. +

+ )} +
    + {references.result.targets.map((target, i) => ( +
  • + +
  • + ))} +
+
+ )}
); diff --git a/src/lib/workbench/source/assistance.test.ts b/src/lib/workbench/source/assistance.test.ts index b67dd1e..61aaa09 100644 --- a/src/lib/workbench/source/assistance.test.ts +++ b/src/lib/workbench/source/assistance.test.ts @@ -95,3 +95,13 @@ describe('source assistance snapshot lifetime', () => { expect(test.view.state.doc.toString()).toBe('part x : Mo;'); }); }); + +it('does not format a newer draft with a stale result', async () => { + const test = setup(async () => ({ + result: { kind: 'formatting', text: 'part x : Motor;' }, + isCurrent: () => false, + })); + await test.assistance.format(); + expect(test.dispatch).not.toHaveBeenCalled(); + expect(test.view.state.doc.toString()).toBe('part x : Mo;'); +}); diff --git a/src/lib/workbench/source/assistance.ts b/src/lib/workbench/source/assistance.ts index f3eb587..84cdfdf 100644 --- a/src/lib/workbench/source/assistance.ts +++ b/src/lib/workbench/source/assistance.ts @@ -23,6 +23,10 @@ export interface AssistanceOptions { query: (query: LanguageQuery, signal: AbortSignal) => Promise; navigate: (target: { file: string; from: number; to: number }) => void; notify: (message: string) => void; + references?: ( + result: Extract, + isCurrent: () => boolean, + ) => void; } const info = StateEffect.define(); const information = StateField.define({ @@ -140,6 +144,47 @@ export function createSourceAssistance(options: AssistanceOptions) { if (reply.result.target) options.navigate(reply.result.target); else options.notify(reply.result.message); } + async function references() { + const current = options.current(); + if (!current) return; + const pos = current.view.state.selection.main.head; + const reply = await request('references', pos); + if ( + reply?.result.kind === 'references' && + reply.snapshot.view.state.selection.main.head === pos + ) + options.references?.(reply.result, reply.isCurrent); + } + async function format() { + const current = options.current(); + if (!current) return; + const reply = await request( + 'formatting', + current.view.state.selection.main.head, + ); + if (!reply || reply.result.kind !== 'formatting' || !reply.isCurrent()) + return; + const text = reply.result.text.replace(/\r\n?/g, '\n'); + if (text === reply.snapshot.view.state.doc.toString()) { + options.notify('Source is already formatted.'); + return; + } + reply.snapshot.view.dispatch({ + changes: { + from: 0, + to: reply.snapshot.view.state.doc.length, + insert: text, + }, + selection: { + anchor: Math.min( + reply.snapshot.view.state.selection.main.head, + text.length, + ), + }, + userEvent: 'input.format', + }); + options.notify('Formatted draft. Apply source to validate and accept it.'); + } const autocomplete = async (context: { state: EditorState; pos: number; @@ -212,6 +257,13 @@ export function createSourceAssistance(options: AssistanceOptions) { return true; }, }, + { + key: 'Shift-F12', + run: () => { + void references(); + return true; + }, + }, { key: 'Escape', run: (view) => { @@ -231,5 +283,7 @@ export function createSourceAssistance(options: AssistanceOptions) { }, information: symbolInfo, definition, + references, + format, }; } diff --git a/src/lib/workspace/createWorkspace.test.ts b/src/lib/workspace/createWorkspace.test.ts index 533c721..70cc3e4 100644 --- a/src/lib/workspace/createWorkspace.test.ts +++ b/src/lib/workspace/createWorkspace.test.ts @@ -1,3 +1,4 @@ +import type { RequirementsEngine, RequirementsResult } from './requirements'; import { describe, it, expect, vi } from 'vitest'; import { createWorkspace } from './createWorkspace'; import type { SemanticEngine, TransformationEngine } from './editing'; @@ -28,7 +29,7 @@ function model(name = 'A'): ModelSnapshot { diagnostics: [], }; } -function setup() { +function setup(requirements?: RequirementsEngine) { const semantic: SemanticEngine = { parse: vi.fn(async (documents) => documents[0]?.text === 'broken' @@ -72,7 +73,7 @@ function setup() { }), }; return { - workspace: createWorkspace({ semantic, transformations }), + workspace: createWorkspace({ semantic, transformations, requirements }), semantic, transformations, }; @@ -410,3 +411,46 @@ describe('views and project persistence', () => { expect(() => workspace.markSaved(old)).toThrow('no longer matches'); }); }); + +describe('requirements inspection lifetime', () => { + const empty: RequirementsResult = { + schemaVersion: 1, + modelHash: 'model-A', + status: 'complete', + diagnostics: [], + requirements: [], + relationships: [], + }; + it('rejects a late reply after replacement without changing the replacement', async () => { + let resolve!: (result: RequirementsResult) => void; + const { workspace } = setup({ + requirements: () => + new Promise((r) => { + resolve = r; + }), + }); + await workspace.open(project()); + const pending = workspace.requirements(); + await workspace.open({ ...project(), name: 'Replacement' }); + const before = workspace.serialize(); + resolve(empty); + await expect(pending).rejects.toThrow('stale'); + expect(workspace.serialize()).toBe(before); + }); + it('inspects applied sources while retaining invalid drafts and rejects cancelled replies', async () => { + const query = vi.fn(async () => empty); + const { workspace } = setup({ requirements: query }); + await workspace.open(project()); + workspace.updateDraft('model.sysml', 'broken'); + const before = workspace.serialize(); + expect(await workspace.requirements()).toEqual(empty); + expect(query.mock.calls[0]![0]).toEqual(project().documents); + expect(workspace.serialize()).toBe(before); + const controller = new AbortController(); + controller.abort(); + await expect(workspace.requirements(controller.signal)).rejects.toThrow( + 'stale', + ); + expect(workspace.serialize()).toBe(before); + }); +}); diff --git a/src/lib/workspace/createWorkspace.ts b/src/lib/workspace/createWorkspace.ts index 28c6cfd..83fba40 100644 --- a/src/lib/workspace/createWorkspace.ts +++ b/src/lib/workspace/createWorkspace.ts @@ -1,3 +1,14 @@ +import { + validateRequirementsResult, + type RequirementsEngine, +} from './requirements'; +import { + readExplorationRequest, + validateExploration, + type ExplorationEngine, + type ExplorationRequest, + type ExplorationResult, +} from './exploration'; import { sameSources, sourceDigest, @@ -46,6 +57,7 @@ export interface WorkspaceState { readonly diagnostics: readonly Diagnostic[]; readonly diagnosticDocuments: ProjectBundle['documents']; readonly view: EvaluatedView | null; + readonly exploration: ExplorationResult | null; readonly layoutNotices: readonly LayoutNotice[]; } interface Accepted { @@ -62,6 +74,8 @@ function immutable(value: T): T { export function createWorkspace(dependencies: { semantic: SemanticEngine; transformations: TransformationEngine; + exploration?: ExplorationEngine; + requirements?: RequirementsEngine; }) { let state: WorkspaceState = immutable({ project: null, @@ -75,6 +89,7 @@ export function createWorkspace(dependencies: { diagnostics: [], diagnosticDocuments: [], view: null, + exploration: null, layoutNotices: [], }); const listeners = new Set<(value: WorkspaceState) => void>(); @@ -162,6 +177,7 @@ export function createWorkspace(dependencies: { model: value.model, revision: state.revision + 1, view: null, + exploration: null, diagnostics: value.model.diagnostics, diagnosticDocuments: value.project.documents, layoutNotices, @@ -293,6 +309,31 @@ export function createWorkspace(dependencies: { } return { snapshot: () => state, + async requirements(signal?: AbortSignal) { + alive(); + const { project, model } = accepted(); + if (!dependencies.requirements) + throw new Error('Requirements inspection is unavailable.'); + const result = await dependencies.requirements.requirements( + project.documents, + model.modelHash, + signal, + ); + if ( + disposed || + signal?.aborted || + state.model !== model || + state.project?.documents !== project.documents + ) + throw new Error( + 'Requirements response is stale; current project was retained.', + ); + return validateRequirementsResult( + result, + model.modelHash, + project.documents, + ); + }, cancelPending() { alive(); controller?.abort(); @@ -496,6 +537,54 @@ export function createWorkspace(dependencies: { proposal.layoutNotices, ); }, + async explore(input: ExplorationRequest) { + alive(); + if (state.busy) throw new Error('Finish the current operation first.'); + const request = readExplorationRequest(input); + const { project, model } = accepted(); + if (!dependencies.exploration) + throw new Error('Model exploration is unavailable.'); + if (request.scope.kind !== 'project') { + const scope = request.scope; + const matches = model.elements.filter( + (item) => item.id === scope.subject, + ); + if ( + matches.length !== 1 || + (scope.kind === 'package' && matches[0]!.kind !== 'package') + ) + throw new Error( + 'Choose one project declaration in the requested scope.', + ); + } + const { token, signal } = begin(); + try { + const result = validateExploration( + await dependencies.exploration.explore( + project.documents, + model.modelHash, + request, + signal, + ), + model.modelHash, + request, + project.documents, + ); + current(token); + if ( + result.status !== 'complete' || + result.renderStatus !== 'available' || + !result.artifact + ) + throw new Error( + result.diagnostics.join(' ') || + 'Exploration is unavailable; the previous diagram was retained.', + ); + publish({ exploration: result, view: null }); + } finally { + finish(token); + } + }, async openView(id: string) { alive(); if (state.busy) throw new Error('Finish the current operation first.'); @@ -515,7 +604,10 @@ export function createWorkspace(dependencies: { signal, ); current(token); - publish({ view: validateEvaluation(result, model.modelHash, id) }); + publish({ + view: validateEvaluation(result, model.modelHash, id), + exploration: null, + }); } finally { finish(token); } diff --git a/src/lib/workspace/exploration/exploration.test.ts b/src/lib/workspace/exploration/exploration.test.ts new file mode 100644 index 0000000..780338d --- /dev/null +++ b/src/lib/workspace/exploration/exploration.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createWorkspace } from '../createWorkspace'; +import { + validateExploration, + readExplorationRequest, + type ExplorationRequest, + type ExplorationResult, +} from './index'; +import type { ProjectBundle } from '../persistence'; + +const documents = [{ name: 'model.sysml', text: 'package P { part def A; }' }]; +const request: ExplorationRequest = { + scope: { kind: 'project' }, + representation: 'tree', +}; +function result(query = request): ExplorationResult { + const subject = { + id: 'model.sysml@0:24', + qualifiedName: 'P', + origin: { + file: 'model.sysml', + offset: 0, + length: 24, + nameOffset: 8, + nameLength: 1, + }, + }; + return { + schemaVersion: 1, + modelHash: 'hash', + request: query, + status: 'complete', + renderStatus: 'available', + diagnostics: [], + selected: [subject], + correspondence: [{ symbol: 'n0', subject, context: [subject.id] }], + artifact: { + kind: query.representation, + nodes: [{ id: 'n0', name: 'P', kind: 'package' }], + edges: [], + }, + }; +} +function setup() { + const explore = vi.fn( + async ( + _documents: unknown, + _hash: string, + query: ExplorationRequest, + _signal?: AbortSignal, + ) => result(query), + ); + const workspace = createWorkspace({ + semantic: { + parse: async () => ({ + modelHash: 'hash', + diagnostics: [], + elements: [ + { id: 'P', name: 'P', kind: 'package', ownerId: null }, + { id: 'P::v', name: 'v', kind: 'viewUsage', ownerId: 'P' }, + ], + }), + evaluate: async () => ({ ...result(), view: 'P::v' }), + }, + transformations: { + propose: async () => { + throw new Error('Exploration must not transform source'); + }, + }, + exploration: { explore }, + }); + const project: ProjectBundle = { + format: 'sysmlv2-view-project', + version: 1, + name: 'Example', + documents, + drafts: documents, + presentations: {}, + }; + return { workspace, project, explore }; +} + +describe('read-only exploration', () => { + it('rejects named-view fields, stale scope/revision, unsupported kinds and unsafe source correspondence', () => { + expect(() => + readExplorationRequest({ + scope: { kind: 'project' }, + representation: 'interconnection', + }), + ).toThrow(); + expect(() => + validateExploration( + { ...result(), view: 'invented' }, + 'hash', + request, + documents, + ), + ).toThrow(); + expect(() => + validateExploration(result(), 'other', request, documents), + ).toThrow(/stale/); + expect(() => + validateExploration( + result(), + 'hash', + { scope: { kind: 'package', subject: 'P' }, representation: 'tree' }, + documents, + ), + ).toThrow(/stale/); + expect(() => + validateExploration( + { ...result(), artifact: { ...result().artifact!, kind: 'geometry' } }, + 'hash', + request, + documents, + ), + ).toThrow(); + expect(() => + validateExploration(result(), 'hash', request, [ + { name: 'model.sysml', text: 'short' }, + ]), + ).toThrow(/outside/); + const duplicate = { + ...result(), + artifact: { + ...result().artifact!, + nodes: [...result().artifact!.nodes, ...result().artifact!.nodes], + }, + }; + expect(() => + validateExploration(duplicate, 'hash', request, documents), + ).toThrow(/duplicate/); + }); + it('uses applied sources while preserving invalid drafts, saved geometry, dirty state and history', async () => { + const { workspace, project: base, explore } = setup(); + const project = { + ...base, + presentations: { + 'P::v': { + sourceHash: 'a'.repeat(64), + modelHash: 'hash', + positions: { n0: { x: 11, y: 22 } }, + }, + }, + }; + await workspace.open(project); + await workspace.openView('P::v'); + workspace.updateDraft('model.sysml', 'invalid draft'); + const before = workspace.serialize(); + const revision = workspace.snapshot().revision; + await workspace.explore(request); + expect(explore.mock.calls[0]![0]).toEqual(documents); + expect(workspace.serialize()).toBe(before); + expect(workspace.snapshot()).toMatchObject({ + revision, + unapplied: true, + canUndo: false, + view: null, + exploration: { request }, + }); + expect(() => workspace.moveSymbol('n0', { x: 100, y: 200 })).toThrow(); + await workspace.openView('P::v'); + expect(workspace.snapshot().exploration).toBeNull(); + expect(workspace.serialize()).toBe(before); + }); + it('retains the previous exploration on engine failure and rejects late results after draft changes', async () => { + const { workspace, project, explore } = setup(); + await workspace.open(project); + await workspace.explore(request); + const previous = workspace.snapshot().exploration; + explore.mockRejectedValueOnce(new Error('Unavailable asset')); + await expect( + workspace.explore({ + scope: { kind: 'package', subject: 'P' }, + representation: 'tree', + }), + ).rejects.toThrow('Unavailable asset'); + expect(workspace.snapshot().exploration).toBe(previous); + let complete!: (value: ExplorationResult) => void; + explore.mockImplementationOnce( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const pending = workspace.explore(request); + workspace.updateDraft('model.sysml', 'new local draft'); + const before = workspace.serialize(); + complete(result()); + await expect(pending).rejects.toThrow(/stale/); + expect(workspace.snapshot().exploration).toBe(previous); + expect(workspace.serialize()).toBe(before); + }); + it('rejects a superseded exploration after opening a replacement project', async () => { + const { workspace, project, explore } = setup(); + await workspace.open(project); + let complete!: (value: ExplorationResult) => void; + explore.mockImplementationOnce( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const pending = workspace.explore(request); + await workspace.open({ ...project, name: 'Replacement' }); + complete(result()); + await expect(pending).rejects.toThrow(/stale/); + expect(workspace.snapshot().project?.name).toBe('Replacement'); + expect(workspace.snapshot().exploration).toBeNull(); + }); +}); diff --git a/src/lib/workspace/exploration/index.ts b/src/lib/workspace/exploration/index.ts new file mode 100644 index 0000000..1f22503 --- /dev/null +++ b/src/lib/workspace/exploration/index.ts @@ -0,0 +1,166 @@ +import Type, { type Static } from 'typebox'; +import { validator, type ReadonlyData } from '$lib/contracts/validation'; +import type { SourceDocument } from '../project'; +import { EvaluatedViewSchema, ViewSubjectSchema } from '../views/schema'; +import { validateEvaluation, validateStructure } from '../views'; + +const closed = { additionalProperties: false }; +const subject = Type.String({ minLength: 1 }); +export const ExplorationRequestSchema = Type.Union([ + Type.Object( + { + scope: Type.Object({ kind: Type.Literal('project') }, closed), + representation: Type.Literal('tree'), + }, + closed, + ), + Type.Object( + { + scope: Type.Object({ kind: Type.Literal('package'), subject }, closed), + representation: Type.Literal('tree'), + }, + closed, + ), + Type.Object( + { + scope: Type.Object({ kind: Type.Literal('element'), subject }, closed), + representation: Type.Union([ + Type.Literal('tree'), + Type.Literal('interconnection'), + ]), + }, + closed, + ), +]); +const evaluation = Type.Omit( + EvaluatedViewSchema, + Type.Literal('view'), +).properties; +export const ExplorationResultSchema = Type.Object( + { + ...evaluation, + request: ExplorationRequestSchema, + selected: Type.Array(ViewSubjectSchema), + }, + closed, +); +export type ExplorationRequest = ReadonlyData< + Static +>; +export type ExplorationResult = ReadonlyData< + Static +>; +export const readExplorationRequest = validator( + ExplorationRequestSchema, + 'Invalid exploration request', +); +export const readExplorationResult = validator( + ExplorationResultSchema, + 'Invalid exploration response', +); + +export function sameExploration(a: ExplorationRequest, b: ExplorationRequest) { + return ( + a.representation === b.representation && + a.scope.kind === b.scope.kind && + (a.scope.kind === 'project' + ? b.scope.kind === 'project' + : b.scope.kind !== 'project' && a.scope.subject === b.scope.subject) + ); +} + +/** Reuse artifact validation, never the authored-view identity or persistence path. */ +export function validateExploration( + value: unknown, + modelHash: string, + request: ExplorationRequest, + documents: readonly SourceDocument[], +): ExplorationResult { + const result = readExplorationResult(value); + if ( + !sameExploration(result.request, request) || + result.modelHash !== modelHash + ) + throw new Error('Invalid or stale exploration response.'); + validateEvaluation({ ...result, view: '' }, modelHash, ''); + const sizes = new Map( + documents.map((file) => [ + file.name, + new TextEncoder().encode(file.text).length, + ]), + ); + for (const subject of [ + ...result.selected, + ...result.correspondence.map((item) => item.subject), + ]) { + const origin = subject.origin; + const size = origin && sizes.get(origin.file); + const nameLength = origin?.nameLength ?? 0; + if ( + !origin || + size === undefined || + subject.id !== `${origin.file}@${origin.offset}:${origin.length}` || + origin.offset + origin.length > size || + (nameLength > 0 && + (origin.nameOffset === undefined || + origin.nameOffset < origin.offset || + origin.nameOffset + nameLength > origin.offset + origin.length)) + ) + throw new Error( + 'Exploration source correspondence is outside the applied project.', + ); + } + if (result.artifact) { + validateStructure(result.artifact); + if ( + result.artifact.kind !== request.representation || + result.status !== 'complete' || + result.renderStatus !== 'available' + ) + throw new Error('Exploration returned an incompatible artifact.'); + const nodes = new Set(result.artifact.nodes.map((node) => node.id)); + const symbols = new Set(); + const correspondence = new Map( + result.correspondence.map((item) => [item.symbol, item]), + ); + if ( + nodes.size !== result.artifact.nodes.length || + correspondence.size !== result.correspondence.length + ) + throw new Error('Exploration has duplicate identities.'); + for (const node of result.artifact.nodes) { + for (const id of [ + node.id, + ...(node.ports ?? []).map((port) => port.id), + ]) { + if ( + !id || + symbols.has(id) || + !correspondence.get(id)?.subject.origin || + !correspondence.get(id)?.subject.id + ) + throw new Error('Exploration omitted exact source correspondence.'); + symbols.add(id); + } + } + result.artifact.edges.forEach((edge, index) => { + if ( + !nodes.has(edge.from) || + !nodes.has(edge.to) || + symbols.has(`edge:${index}`) || + !correspondence.get(`edge:${index}`)?.subject.origin + ) + throw new Error('Exploration has an unresolved relationship.'); + }); + } + return result; +} + +export interface ExplorationEngine { + explore( + documents: readonly SourceDocument[], + modelHash: string, + request: ExplorationRequest, + signal?: AbortSignal, + ): Promise; +} diff --git a/src/lib/workspace/language/index.ts b/src/lib/workspace/language/index.ts index 2011351..cd0fa16 100644 --- a/src/lib/workspace/language/index.ts +++ b/src/lib/workspace/language/index.ts @@ -10,6 +10,8 @@ export const LanguageQuerySchema = Type.Object( Type.Literal('completion'), Type.Literal('hover'), Type.Literal('definition'), + Type.Literal('references'), + Type.Literal('formatting'), ]), file: Type.String({ minLength: 1 }), offset, @@ -26,6 +28,27 @@ const ItemSchema = Type.Object( closed, ); export const LanguageResultSchema = Type.Union([ + Type.Object( + { + kind: Type.Literal('references'), + targets: Type.Array( + Type.Object( + { file: Type.String(), line: Type.Integer({ minimum: 1 }), ...range }, + closed, + ), + { maxItems: 10000 }, + ), + omitted: Type.Integer({ minimum: 0 }), + }, + closed, + ), + Type.Object( + { + kind: Type.Literal('formatting'), + text: Type.String({ maxLength: 5 * 1024 * 1024 }), + }, + closed, + ), Type.Object( { kind: Type.Literal('completion'), diff --git a/src/lib/workspace/requirements/index.ts b/src/lib/workspace/requirements/index.ts new file mode 100644 index 0000000..b2ff22b --- /dev/null +++ b/src/lib/workspace/requirements/index.ts @@ -0,0 +1,106 @@ +import Type, { type Static } from 'typebox'; +import { validator, type ReadonlyData } from '$lib/contracts/validation'; +import type { SourceDocument } from '../project'; +import { ViewSubjectSchema } from '../views/schema'; +const closed = { additionalProperties: false }; +const subject = Type.Object(ViewSubjectSchema.properties, closed); +export const RequirementsSchema = Type.Object( + { + schemaVersion: Type.Literal(1), + modelHash: Type.String(), + status: Type.Union([ + Type.Literal('complete'), + Type.Literal('invalid'), + Type.Literal('indeterminate'), + ]), + diagnostics: Type.Array(Type.String()), + requirements: Type.Array( + Type.Object( + { + subject, + kind: Type.Union([ + Type.Literal('requirementDef'), + Type.Literal('requirementUsage'), + ]), + shortName: Type.String(), + documentation: Type.Array(Type.String()), + }, + closed, + ), + { maxItems: 2000 }, + ), + relationships: Type.Array( + Type.Object( + { + kind: Type.Union([ + Type.Literal('allocation'), + Type.Literal('satisfaction'), + Type.Literal('verification'), + ]), + declaration: subject, + source: subject, + target: subject, + negated: Type.Boolean(), + asserted: Type.Boolean(), + }, + closed, + ), + { maxItems: 20000 }, + ), + }, + closed, +); +export type RequirementsResult = ReadonlyData< + Static +>; +export type RequirementSubject = + RequirementsResult['requirements'][number]['subject']; +const read = validator(RequirementsSchema, 'Invalid requirements response'); +export function validateRequirementsResult( + value: unknown, + modelHash: string, + documents: readonly SourceDocument[], +): RequirementsResult { + const result = read(value); + if (result.modelHash !== modelHash) + throw new Error('Stale requirements response.'); + if ( + result.status !== 'complete' && + (result.requirements.length || result.relationships.length) + ) + throw new Error('Incomplete requirements response exposed data.'); + const sizes = new Map( + documents.map((d) => [d.name, new TextEncoder().encode(d.text).length]), + ); + for (const s of [ + ...result.requirements.map((r) => r.subject), + ...result.relationships.flatMap((r) => [r.declaration, r.source, r.target]), + ]) { + const o = s.origin, + size = o && sizes.get(o.file); + if ( + !o || + size === undefined || + s.id !== `${o.file}@${o.offset}:${o.length}` || + o.offset + o.length > size || + ((o.nameLength ?? 0) > 0 && + (o.nameOffset === undefined || + o.nameOffset < o.offset || + o.nameOffset + o.nameLength! > o.offset + o.length)) + ) + throw new Error('Requirements source is outside the applied project.'); + } + if ( + new Set(result.requirements.map((r) => r.subject.id)).size !== + result.requirements.length + ) + throw new Error('Duplicate requirement identity.'); + return result; +} +export interface RequirementsEngine { + requirements( + documents: readonly SourceDocument[], + modelHash: string, + signal?: AbortSignal, + ): Promise; +} diff --git a/src/lib/workspace/requirements/requirements.test.ts b/src/lib/workspace/requirements/requirements.test.ts new file mode 100644 index 0000000..1e9d3b5 --- /dev/null +++ b/src/lib/workspace/requirements/requirements.test.ts @@ -0,0 +1,52 @@ +import { it, expect } from 'vitest'; +import { validateRequirementsResult } from './index'; +const documents = [{ name: 'model.sysml', text: 'requirement r;' }]; +const subject = { + id: 'model.sysml@0:14', + qualifiedName: 'r', + origin: { + file: 'model.sysml', + offset: 0, + length: 14, + nameOffset: 12, + nameLength: 1, + }, +}; +const result = { + schemaVersion: 1, + modelHash: 'h', + status: 'complete', + diagnostics: [], + requirements: [ + { subject, kind: 'requirementUsage', shortName: 'R', documentation: [] }, + ], + relationships: [], +}; +it('distinguishes valid empty and unavailable results, rejects stale and outside source', () => { + expect(validateRequirementsResult(result, 'h', documents)).toEqual(result); + expect( + validateRequirementsResult({ ...result, requirements: [] }, 'h', documents) + .status, + ).toBe('complete'); + expect(() => validateRequirementsResult(result, 'other', documents)).toThrow( + 'Stale', + ); + expect(() => + validateRequirementsResult( + { ...result, status: 'indeterminate' }, + 'h', + documents, + ), + ).toThrow('Incomplete'); + expect(() => validateRequirementsResult(result, 'h', [])).toThrow('outside'); + expect(() => + validateRequirementsResult( + { + ...result, + requirements: [...result.requirements, ...result.requirements], + }, + 'h', + documents, + ), + ).toThrow('Duplicate'); +}); diff --git a/src/lib/workspace/views/index.ts b/src/lib/workspace/views/index.ts index 0255783..14cd3ad 100644 --- a/src/lib/workspace/views/index.ts +++ b/src/lib/workspace/views/index.ts @@ -39,7 +39,10 @@ export function validateEvaluation( !Array.isArray(result.artifact.edges)) ) throw new Error('View response omitted its artifact.'); - if (result.artifact?.kind === 'interconnection') + if ( + result.artifact && + ['interconnection', 'action', 'state'].includes(result.artifact.kind) + ) validateStructure(result.artifact); if (result.artifact?.kind === 'requirement') validateRequirements(result); return result; @@ -48,7 +51,7 @@ export function validateEvaluation( export type ViewArtifact = NonNullable; export type ArtifactNode = ViewArtifact['nodes'][number]; -function validateStructure(artifact: ViewArtifact) { +export function validateStructure(artifact: ViewArtifact) { const nodes = new Map(artifact.nodes.map((node) => [node.id, node])); const symbols = new Set(nodes.keys()); if (nodes.size !== artifact.nodes.length) diff --git a/src/lib/workspace/views/schema.ts b/src/lib/workspace/views/schema.ts index 28d34d6..cbc54b8 100644 --- a/src/lib/workspace/views/schema.ts +++ b/src/lib/workspace/views/schema.ts @@ -29,6 +29,7 @@ export const ArtifactNodeSchema = Type.Object({ kind: Type.String(), type: Type.Optional(Type.String()), detail: Type.Optional(Type.String()), + attributeDeclarations: Type.Optional(Type.Array(Type.String())), shortName: Type.Optional(Type.String()), documentation: Type.Optional(Type.Array(Type.String())), exposure: Type.Optional( diff --git a/src/lib/workspace/views/views.test.ts b/src/lib/workspace/views/views.test.ts index c9ba04e..1041608 100644 --- a/src/lib/workspace/views/views.test.ts +++ b/src/lib/workspace/views/views.test.ts @@ -185,3 +185,14 @@ describe('requirements artifact validation', () => { expect(() => validateEvaluation(value, 'hash', 'v')).toThrow(); }); }); + +for (const kind of ['action', 'state']) { + it(`rejects unresolved ${kind} endpoints rather than dropping a behavior relationship`, () => { + const value = response(); + value.artifact.kind = kind; + value.artifact.edges[0]!.to = 'missing'; + expect(() => validateEvaluation(value, 'hash', 'v')).toThrow( + 'unresolved connection endpoint', + ); + }); +} diff --git a/tests/e2e/default-example.spec.ts b/tests/e2e/default-example.spec.ts index 910f026..77d7e2e 100644 --- a/tests/e2e/default-example.spec.ts +++ b/tests/e2e/default-example.spec.ts @@ -2,17 +2,23 @@ import { readFile } from 'node:fs/promises'; import { test, expect, - ready, + waitForStartup, + startExample, observeEvaluations, downloadProject, namedViewButton, } from './support'; -test('explicitly loads the complete Smart air workshop in its useful pneumatic view and preserves all nine source files', async ({ +test('imports the complete legacy Smart air workshop fixture in its workshop overview and preserves all nine source files', async ({ page, }, testInfo) => { const evaluations = await observeEvaluations(page); - await ready(page, { split: false }); + await page.goto('/app'); + await startExample(page, { split: false }); + await expect( + page.getByRole('combobox', { name: 'Named view', exact: true }), + ).toHaveValue('CompressorViews::overview', { timeout: 3000 }); + await waitForStartup(page, { split: false }); await expect( page.getByRole('banner').getByText('Smart air workshop', { exact: true }), ).toBeVisible(); @@ -23,30 +29,24 @@ test('explicitly loads the complete Smart air workshop in its useful pneumatic v page.getByRole('region', { name: 'Source editor', exact: true }), ).not.toBeVisible(); const view = evaluations.findLast( - (item) => item.view === 'CompressorViews::pneumatics', + (item) => item.view === 'CompressorViews::overview', )!; expect(view.status).toBe('complete'); expect(view.artifact?.kind).toBe('interconnection'); expect(view.artifact!.nodes.map((item) => item.name).sort()).toEqual( [ - 'CompressorSystem::PneumaticAssembly', - 'pump', - 'tank', - 'regulator', - 'relief', - 'pressureSensor', + 'CompressorSystem::WorkshopContext', + 'mains', + 'compressor', + 'tool', + 'serviceConsole', ].sort(), ); expect(view.artifact!.nodes.flatMap((item) => item.ports ?? [])).toHaveLength( - 13, + 6, ); expect(view.artifact!.edges.map((item) => item.label).sort()).toEqual( - [ - 'chargingLine', - 'regulatedDelivery', - 'pressureProtection', - 'pressureSample', - ].sort(), + ['electricalSupply', 'usableAir'].sort(), ); const navigation = page.getByRole('complementary', { name: 'Project navigation', @@ -98,10 +98,17 @@ test('explicitly loads the complete Smart air workshop in its useful pneumatic v ); } await expect( - namedViewButton(page, 'CompressorViews::pneumatics'), + namedViewButton(page, 'CompressorViews::overview'), ).toHaveAttribute('aria-current', 'page'); expect((await downloadProject(page)).bundle).toEqual(saved.bundle); - await testInfo.attach('default-pneumatics-engine', { + await namedViewButton(page, 'CompressorViews::pneumatics').click(); + await expect( + page.getByRole('group', { name: 'part pump : Pump', exact: true }), + ).toBeVisible(); + expect((await downloadProject(page)).bundle.documents).toEqual( + saved.bundle.documents, + ); + await testInfo.attach('default-overview-engine', { body: JSON.stringify(view, null, 2), contentType: 'application/json', }); diff --git a/tests/e2e/engine-upgrade.spec.ts b/tests/e2e/engine-upgrade.spec.ts new file mode 100644 index 0000000..219b7a3 --- /dev/null +++ b/tests/e2e/engine-upgrade.spec.ts @@ -0,0 +1,175 @@ +import { readFile } from 'node:fs/promises'; +import { + test, + expect, + downloadProject, + observeEvaluations, + expectRenderedPositions, + semanticPositions, +} from './support'; +import type { ProjectBundle } from '../../src/lib/workspace/persistence'; + +test('opens a project positioned with OpenSysML 0.10 without losing sources, identities or saved geometry', async ({ + page, +}) => { + const fixture = JSON.parse( + await readFile( + new URL( + '../fixtures/opensysml-0.10-positioned.sysml-project.json', + import.meta.url, + ), + 'utf8', + ), + ) as ProjectBundle; + const results = await observeEvaluations(page); + await page.goto('/app'); + await page.locator('input[type=file]').setInputFiles({ + name: 'legacy.json', + mimeType: 'application/json', + buffer: Buffer.from(JSON.stringify(fixture)), + }); + const root = page.getByRole('group', { + name: 'part def P::System', + exact: true, + }); + await expect(root).toBeVisible(); + await expect + .poll(() => results.some((r) => r.view === 'V::structure')) + .toBe(true); + const evaluation = results.findLast((r) => r.view === 'V::structure')!; + expect(evaluation.modelHash).toBe( + fixture.presentations['V::structure']!.modelHash, + ); + expect( + evaluation.correspondence + .filter((c) => !c.symbol.startsWith('edge:')) + .map((c) => c.subject.qualifiedName) + .sort(), + ).toEqual(['P::System', 'P::System::primary', 'P::System::standby']); + const saved = (await downloadProject(page)).bundle; + expect(saved).toEqual(fixture); + await expectRenderedPositions(page, fixture, 'V::structure'); + expect(semanticPositions(saved, results)).toEqual( + semanticPositions(fixture, results), + ); +}); + +test('retains 0.10 declaration-to-position identities across structural, requirements and behavior fixtures', async ({ + page, +}) => { + const { readdir } = await import('node:fs/promises'); + const { readModelSnapshot } = await import('../../src/lib/workspace/project'); + const { validateEvaluation } = await import('../../src/lib/workspace/views'); + const { readWorkerReply } = + await import('../../src/lib/engines/workers/schema'); + type Correspondence = + import('../../src/lib/workspace/views').EvaluatedView['correspondence']; + const fixture = JSON.parse( + await readFile( + new URL( + '../fixtures/opensysml-0.10-correspondence.json', + import.meta.url, + ), + 'utf8', + ), + ) as { + samples: Record< + string, + { + documents: ProjectBundle['documents']; + views: { + view: string; + modelHash: string; + kind: string; + correspondence: Correspondence; + edges?: Record[]; + }[]; + } + >; + }; + const worker = + '/assets/' + + (await readdir(new URL('../../build/assets/', import.meta.url))).find( + (n) => n.startsWith('semantic.worker-') && n.endsWith('.js'), + )!; + await page.goto('/app'); + async function request(input: unknown) { + const reply = readWorkerReply( + await page.evaluate( + ({ worker, input }) => + new Promise((resolve) => { + const w = new Worker(worker, { type: 'module' }); + const timer = setTimeout(() => { + w.terminate(); + resolve({ error: 'Compatibility worker timed out.' }); + }, 60000); + w.onmessage = (e) => { + clearTimeout(timer); + w.terminate(); + resolve(e.data); + }; + w.onerror = (e) => { + clearTimeout(timer); + w.terminate(); + resolve({ error: e.message }); + }; + w.postMessage(input); + }), + { worker, input }, + ), + ); + if ('error' in reply) throw new Error(reply.error); + return reply.value; + } + for (const [name, sample] of Object.entries(fixture.samples)) { + const common = { + protocolVersion: 1, + base: '/vendor/opensysml/', + documents: sample.documents, + }; + const model = readModelSnapshot( + await request({ ...common, operation: 'parse' }), + ); + expect( + model.diagnostics.filter((d) => d.severity === 'error'), + name, + ).toEqual([]); + for (const view of sample.views) { + expect(model.modelHash).toBe(view.modelHash); + const result = validateEvaluation( + await request({ + ...common, + operation: 'evaluate', + modelHash: model.modelHash, + view: view.view, + }), + model.modelHash, + view.view, + ); + expect(result.status, view.view).toBe('complete'); + expect(result.artifact?.kind, view.view).toBe(view.kind); + expect( + result.correspondence.filter((c) => !c.symbol.startsWith('edge:')), + view.view, + ).toEqual(view.correspondence); + if (view.edges) { + const pick = (edge: Record) => + Object.fromEntries( + Object.entries(edge).filter(([key]) => + ['from', 'to', 'fromPort', 'toPort', 'kind', 'label'].includes( + key, + ), + ), + ); + const sort = (items: Record[]) => + items.sort((a, b) => + JSON.stringify(a).localeCompare(JSON.stringify(b)), + ); + expect( + sort(result.artifact!.edges.map((edge) => pick({ ...edge }))), + view.view + ' declared endpoints', + ).toEqual(sort(view.edges.map(pick))); + } + } + } +}); diff --git a/tests/e2e/examples.spec.ts b/tests/e2e/examples.spec.ts index 23ef706..fe9cdc6 100644 --- a/tests/e2e/examples.spec.ts +++ b/tests/e2e/examples.spec.ts @@ -10,8 +10,20 @@ import { namedViewButton, } from './support'; import { loadCatalog, loadExample } from '../../examples/catalog'; -import { writeFile } from 'node:fs/promises'; +import { readFile, writeFile } from 'node:fs/promises'; +const expectedTreeRelationships = JSON.parse( + await readFile( + new URL( + '../fixtures/opensysml-0.12-tree-relationships.json', + import.meta.url, + ), + 'utf8', + ), +) as Record< + string, + { kind: string; from: string; to: string; declaration: string }[] +>; const catalog = await loadCatalog(); for (const entry of catalog.examples) { test(`example library: ${entry.id}`, async ({ page }, testInfo) => { @@ -47,6 +59,23 @@ for (const entry of catalog.examples) { expect(result.artifact?.edges.map((edge) => edge.kind).sort()).toEqual( [...view.edgeKinds].sort(), ); + if (expectedTreeRelationships[view.id]) { + const symbols = new Map( + result.correspondence.map((c) => [c.symbol, c]), + ); + const name = (id: string) => + symbols.get(id)!.subject.qualifiedName ?? symbols.get(id)!.subject.id; + const actual = result.artifact!.edges.map((edge, i) => ({ + kind: edge.kind, + from: name(edge.from), + to: name(edge.to), + declaration: symbols.get('edge:' + i)!.subject.id, + })); + expect( + actual, + 'Resolved 0.12 tree relationships and exact source declarations', + ).toEqual(expectedTreeRelationships[view.id]); + } for (const notice of view.notices) { expect(result.artifact?.notices).toContain(notice); await expect(diagram).toContainText(notice); diff --git a/tests/e2e/exploration-support.ts b/tests/e2e/exploration-support.ts new file mode 100644 index 0000000..b18f87d --- /dev/null +++ b/tests/e2e/exploration-support.ts @@ -0,0 +1,53 @@ +import type { Page } from '@playwright/test'; +import { ready, expect } from './support'; + +export const documents = [ + { + name: 'model.sysml', + text: 'package P {\r\n /* 🔧 café */ part def System { part primary : Q::Motor; part standby : Q::Motor; }\r\n part system : System;\r\n}\r\n', + }, + { + name: 'other.sysml', + text: 'package Q { part def Motor; part other : Motor; }\n', + }, +]; +export async function noViews(page: Page) { + await ready(page); + await page.locator('input[type=file]').setInputFiles( + documents.map((file) => ({ + name: file.name, + mimeType: 'text/plain', + buffer: Buffer.from(file.text), + })), + ); + await expect( + page.getByRole('combobox', { name: 'Exploration scope', exact: true }), + ).toBeEnabled(); + await expect( + page + .getByRole('region', { name: 'Diagram', exact: true }) + .getByRole('group', { name: 'part def System', exact: true }), + ).toBeVisible(); +} + +/** Existing assembly model, with no authored views added or source rewritten. */ +export async function assemblyExploration(page: Page) { + const { readFile } = await import('node:fs/promises'); + const text = await readFile( + new URL('../../examples/assembly/model.sysml', import.meta.url), + 'utf8', + ); + await ready(page, { split: false }); + await page.locator('input[type=file]').setInputFiles({ + name: 'assembly.sysml', + mimeType: 'text/plain', + buffer: Buffer.from(text), + }); + await expect( + page.getByRole('group', { name: 'package Assembly', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('combobox', { name: 'Exploration scope', exact: true }), + ).toBeEnabled(); + return [{ name: 'assembly.sysml', text }]; +} diff --git a/tests/e2e/exploration.spec.ts b/tests/e2e/exploration.spec.ts new file mode 100644 index 0000000..18930bf --- /dev/null +++ b/tests/e2e/exploration.spec.ts @@ -0,0 +1,399 @@ +import { + test, + expect, + openWorkshop, + ready, + downloadProject, + observeExplorations, + diagramSymbol, + fillSource, + diagramNode, + moveNode, + readSource, + copySourceSelection, + expectRenderedPositions, + explorationNodeBoxes, + explorationEdgeLabelBoxes, + explorationScopeFits, +} from './support'; +import { documents, noViews } from './exploration-support'; + +test('@critical opens a no-view multi-file model as an overview and distinguishes explicit definitions from typed usages without authoring source', async ({ + page, +}) => { + const results = await observeExplorations(page); + await noViews(page); + await expect.poll(() => results.length).toBe(1); + expect(results[0]!.request).toEqual({ + scope: { kind: 'project' }, + representation: 'tree', + }); + expect(results[0]!.selected.map((item) => item.qualifiedName).sort()).toEqual( + ['P', 'Q'], + ); + const overview = results[0]!; + const identity = (name: string) => + overview.correspondence.find((item) => item.subject.qualifiedName === name)! + .symbol; + const parent = (name: string) => + overview.artifact!.nodes.find((item) => item.id === identity(name))!.parent; + expect(parent('P::System')).toBe(identity('P')); + expect(parent('P::System::primary')).toBe(identity('P::System')); + expect(parent('P::System::standby')).toBe(identity('P::System')); + expect(parent('Q::Motor')).toBe(identity('Q')); + await expect + .poll(async () => { + const [owner, child] = await explorationNodeBoxes(page, [ + identity('P::System'), + identity('P::System::primary'), + ]); + return ( + child!.x > owner!.x && + child!.y > owner!.y && + child!.x + child!.width < owner!.x + owner!.width && + child!.y + child!.height < owner!.y + owner!.height + ); + }) + .toBe(true); + await expect + .poll(async () => { + const [primary, standby] = await explorationEdgeLabelBoxes(page, [ + 'edge:0', + 'edge:1', + ]); + return ( + Math.max(primary!.x, standby!.x) >= + Math.min(primary!.x + primary!.width, standby!.x + standby!.width) || + Math.max(primary!.y, standby!.y) >= + Math.min(primary!.y + primary!.height, standby!.y + standby!.height) + ); + }) + .toBe(true); + for (const name of ['primary', 'standby']) { + // Native 0.12 tree edges are emitted in a different order. Bind the + // assertion to the exact declaration, rather than a transient edge index. + const symbol = overview.correspondence.find( + (item) => + item.symbol.startsWith('edge:') && + item.subject.qualifiedName === `P::System::${name}`, + )!.symbol; + const relation = page.getByRole('group', { + name: new RegExp(`composition ${name}:`), + }); + await relation.focus(); + await relation.press('Enter'); + await expect(relation).toBeFocused(); + await expect( + page.getByRole('button', { name: 'Apply source', exact: true }), + ).toBeDisabled(); + const inspector = page.getByRole('complementary', { + name: 'Properties', + exact: true, + }); + await expect( + inspector.getByRole('heading', { + name: 'Relationship declaration', + exact: true, + }), + ).toBeVisible(); + await expect(inspector).toContainText(`P::System::${name}`); + await inspector + .getByRole('button', { name: 'Open source', exact: true }) + .click(); + await expect + .poll(() => + copySourceSelection( + page.getByRole('textbox', { name: 'SysML source', exact: true }), + ), + ) + .toBe(`part ${name} : Q::Motor;`); + expect( + overview.correspondence.find((item) => item.symbol === symbol)?.subject + .qualifiedName, + ).toBe(`P::System::${name}`); + } + const before = (await downloadProject(page)).bundle; + expect(before.documents).toEqual(documents); + expect(before.drafts).toEqual(documents); + expect(before.presentations).toEqual({}); + const scope = page.getByRole('combobox', { + name: 'Exploration scope', + exact: true, + }); + const representation = page.getByRole('combobox', { + name: 'Exploration representation', + exact: true, + }); + await scope.selectOption('P'); + await expect(scope).toBeEnabled(); + await expect(scope).toBeFocused(); + await expect + .poll(() => results.at(-1)?.request.scope) + .toEqual({ kind: 'package', subject: 'P' }); + await expect( + representation.getByRole('option', { name: 'Structure', exact: true }), + ).toBeDisabled(); + await scope.selectOption('P::System'); + await expect(scope).toBeEnabled(); + await representation.selectOption('interconnection'); + await expect(representation).toBeEnabled(); + await expect(representation).toBeFocused(); + await expect + .poll(() => results.at(-1)?.artifact?.kind) + .toBe('interconnection'); + const definition = results.at(-1)!; + await expect( + page.getByRole('group', { name: 'Current exploration scope', exact: true }), + ).toHaveText(/Part definitionP::System/); + expect(definition.artifact!.nodes.map((node) => node.name).sort()).toEqual([ + 'P::System', + 'primary', + 'standby', + ]); + await expect + .poll(async () => { + const ids = ['P::System', 'P::System::primary', 'P::System::standby'].map( + (name) => + definition.correspondence.find( + (item) => item.subject.qualifiedName === name, + )!.symbol, + ); + const [owner, left, right] = await explorationNodeBoxes(page, ids); + return ( + Math.abs(left!.y - right!.y) < 1 && + right!.x > left!.x + left!.width && + owner!.height < owner!.width && + Math.abs(left!.height / left!.width - 52 / 208) < 0.01 + ); + }) + .toBe(true); + const primary = definition.correspondence.find( + (item) => item.subject.qualifiedName === 'P::System::primary', + )!; + const standby = definition.correspondence.find( + (item) => item.subject.qualifiedName === 'P::System::standby', + )!; + expect(primary.context).not.toEqual(standby.context); + await diagramSymbol(page, primary.symbol).click(); + await page.getByRole('button', { name: 'Open source', exact: true }).click(); + const source = page.getByRole('textbox', { + name: 'SysML source', + exact: true, + }); + await expect + .poll(() => copySourceSelection(source)) + .toBe('part primary : Q::Motor;'); + await scope.selectOption('P::system'); + await expect(scope).toBeEnabled(); + await representation.selectOption('interconnection'); + await expect(representation).toBeEnabled(); + await expect + .poll(() => results.at(-1)?.artifact?.nodes.map((node) => node.name)) + .toEqual(['P::system']); + await expect( + page.getByRole('group', { name: 'Current exploration scope', exact: true }), + ).toHaveText(/Part usageP::system/); + await expect( + page.getByText(/does not automatically expand its type/), + ).toBeVisible(); + await page.getByRole('button', { name: 'other.sysml', exact: true }).click(); + await expect(source).toHaveSource(documents[1]!.text); + await expect(scope).toHaveValue('P::system'); + await expect(representation).toHaveValue('interconnection'); + await expect( + page.getByRole('button', { name: 'New part', exact: true }), + ).not.toBeVisible(); + await expect( + page.getByRole('button', { name: 'Undo source', exact: true }), + ).toBeDisabled(); + expect((await downloadProject(page)).bundle).toEqual(before); +}); + +test('exploration preserves invalid drafts and independent named-view geometry and reopens the portable project unchanged', async ({ + page, +}) => { + await openWorkshop(page); + await moveNode(page, diagramNode(page, 'Workshop::Motor')); + const before = (await downloadProject(page)).bundle; + const source = page.getByRole('textbox', { + name: 'SysML source', + exact: true, + }); + const draft = + (await readSource(source)) + '\n// preserved while browsing\npart'; + await fillSource(source, draft); + const expected = await downloadProject(page); + const named = page.getByRole('combobox', { name: 'Named view', exact: true }); + await named.selectOption(''); + const scope = page.getByRole('combobox', { + name: 'Exploration scope', + exact: true, + }); + await expect(scope).toBeEnabled(); + await scope.selectOption('Workshop'); + await expect(scope).toBeEnabled(); + await expect(source).toHaveSource(draft); + expect((await downloadProject(page)).bundle).toEqual(expected.bundle); + await named.selectOption('WorkshopViews::overview'); + await expect(scope).not.toBeVisible(); + await expectRenderedPositions(page, before, 'WorkshopViews::overview'); + expect((await downloadProject(page)).bundle).toEqual(expected.bundle); + page.once('dialog', (dialog) => dialog.accept()); + await page.reload(); + await page.getByRole('button', { name: 'Load example', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Download project', exact: true }), + ).toBeEnabled(); + await page.locator('input[type=file]').setInputFiles({ + name: 'reopen.sysml-project.json', + mimeType: 'application/json', + buffer: expected.buffer, + }); + await expect(named).toBeEnabled(); + await expectRenderedPositions(page, before, 'WorkshopViews::overview'); + expect((await downloadProject(page)).bundle).toEqual(expected.bundle); +}); + +test('an actual exploration engine asset failure preserves the old scope, diagram and sources and permits retry', async ({ + page, +}) => { + await noViews(page); + const before = (await downloadProject(page)).bundle; + const scope = page.getByRole('combobox', { + name: 'Exploration scope', + exact: true, + }); + let failures = 0; + const asset = '**/vendor/opensysml/sysml-wasm.wasm'; + await page.route(asset, async (route) => { + failures++; + await route.fulfill({ + status: 503, + body: 'Controlled exploration failure', + }); + }); + await scope.selectOption('P'); + await expect.poll(() => failures).toBeGreaterThan(0); + await expect( + page.getByText('OpenSysML browser assets are unavailable.', { + exact: true, + }), + ).toBeVisible(); + await expect(scope).toHaveValue(''); + expect((await downloadProject(page)).bundle).toEqual(before); + await page.unroute(asset); + await scope.selectOption('P'); + await expect(scope).toBeEnabled(); + await expect(scope).toHaveValue('P'); + expect((await downloadProject(page)).bundle).toEqual(before); +}); + +test('shows the entire qualified scope and its definition/usage kind at narrow width', async ({ + page, +}) => { + await noViews(page); + await downloadProject(page); + const prefix = 'Engineering::CompressorControlSubsystem'; + const target = prefix + '::RedundantDriveAssembly'; + const longDocuments = [ + { + name: 'model.sysml', + text: 'package Engineering { package CompressorControlSubsystem { part def RedundantDriveAssembly { part primary : Q::Motor; part standby : Q::Motor; } part installedDrive : RedundantDriveAssembly; } }', + }, + documents[1]!, + ]; + await page.locator('input[type=file]').setInputFiles( + longDocuments.map((file) => ({ + name: file.name, + mimeType: 'text/plain', + buffer: Buffer.from(file.text), + })), + ); + const scope = page.getByRole('combobox', { + name: 'Exploration scope', + exact: true, + }); + await expect(scope).toBeEnabled(); + await page.getByRole('button', { name: 'Diagram', exact: true }).click(); + await page.setViewportSize({ width: 768, height: 900 }); + await page + .getByRole('button', { name: 'Collapse Explorer', exact: true }) + .click(); + await expect( + page.getByRole('button', { name: 'Show Explorer', exact: true }), + ).toBeFocused(); + await scope.selectOption(target); + await expect(scope).toBeEnabled(); + const caption = page.getByRole('group', { + name: 'Current exploration scope', + exact: true, + }); + await expect(caption).toContainText(target); + await expect(caption).toContainText('Part definition'); + await expect.poll(() => explorationScopeFits(page)).toBe(true); + await scope.selectOption(prefix + '::installedDrive'); + await expect(scope).toBeEnabled(); + await expect(caption).toContainText(prefix + '::installedDrive'); + await expect(caption).toContainText('Part usage'); + await expect.poll(() => explorationScopeFits(page)).toBe(true); +}); + +test('retries an initial exploration asset failure without reopening or changing source', async ({ + page, +}) => { + await ready(page); + let requests = 0; + const asset = '**/vendor/opensysml/sysml-wasm.wasm'; + await page.route(asset, async (route) => { + requests++; + if (requests === 2) + await route.fulfill({ + status: 503, + body: 'Controlled initial exploration failure', + }); + else await route.continue(); + }); + await page.locator('input[type=file]').setInputFiles( + documents.map((file) => ({ + name: file.name, + mimeType: 'text/plain', + buffer: Buffer.from(file.text), + })), + ); + await expect( + page.getByText('OpenSysML browser assets are unavailable.', { + exact: true, + }), + ).toBeVisible(); + expect(requests).toBe(2); + const before = (await downloadProject(page)).bundle; + expect(before.documents).toEqual(documents); + await expect( + page.getByRole('combobox', { name: 'Exploration scope', exact: true }), + ).not.toBeVisible(); + const retry = page.getByRole('button', { + name: 'Explore model', + exact: true, + }); + await expect(retry).toBeEnabled({ timeout: 3000 }); + await page.unroute(asset); + await retry.focus(); + await retry.press('Enter'); + await expect( + page.getByRole('combobox', { name: 'Exploration scope', exact: true }), + ).toBeEnabled(); + await expect( + page.getByRole('group', { name: 'part def System', exact: true }), + ).toBeVisible(); + await expect(retry).not.toBeVisible(); + await expect( + page.getByRole('combobox', { name: 'Exploration scope', exact: true }), + ).toBeFocused(); + await expect( + page.getByRole('button', { name: 'Apply source', exact: true }), + ).toBeDisabled(); + await expect( + page.getByRole('button', { name: 'Undo source', exact: true }), + ).toBeDisabled(); + expect((await downloadProject(page)).bundle).toEqual(before); +}); diff --git a/tests/e2e/language-assistance.spec.ts b/tests/e2e/language-assistance.spec.ts index 0088fcd..c08f981 100644 --- a/tests/e2e/language-assistance.spec.ts +++ b/tests/e2e/language-assistance.spec.ts @@ -256,3 +256,104 @@ test('retains exact drafts and applied geometry after a Toolkit library asset fa await expect(source).toHaveSource(draft.replaceAll('\r\n', '\n')); expect((await downloadProject(page)).bundle).toEqual(failed); }); + +test('finds real cross-file references and navigates exact ranges without editing source', async ({ + page, +}) => { + const before = await languageProject(page); + await page.getByRole('button', { name: 'model.sysml', exact: true }).click(); + const source = page.getByRole('textbox', { + name: 'SysML source', + exact: true, + }); + await selectWord(page, source, 'Motor'); + await source.press('ArrowLeft'); + await page + .getByRole('button', { name: 'Find references', exact: true }) + .click(); + const panel = page.getByRole('region', { name: 'References', exact: true }); + await expect(panel).toContainText('references'); + const target = panel.getByRole('button', { name: /^views.sysml · line / }); + await expect(target).toHaveCount(1); + await panel + .getByRole('button', { name: 'Close references', exact: true }) + .click(); + await expect(source).toBeFocused(); + await source.press('Shift+F12'); + await expect(target).toHaveCount(1); + await target.click(); + await expect.poll(() => copySourceSelection(source)).toBe('Motor'); + await expect(source).toHaveSource( + before.documents + .find((d) => d.name === 'views.sysml')! + .text.replaceAll('\r\n', '\n'), + ); + expect((await downloadProject(page)).bundle).toEqual(before); +}); + +test('formats a draft with real Toolkit, supports local undo and accepted source undo/redo, and retains work on failure', async ({ + page, +}) => { + const before = await languageProject(page); + await page.getByRole('button', { name: 'model.sysml', exact: true }).click(); + const source = page.getByRole('textbox', { + name: 'SysML source', + exact: true, + }); + const original = before.documents.find((d) => d.name === 'model.sysml')!.text; + const draft = original.replace(/ /g, ' '); + await fillSource(source, draft); + await page + .getByRole('button', { name: 'Format source', exact: true }) + .click(); + await expect( + page.getByRole('status').filter({ hasText: 'Formatted draft.' }), + ).toBeVisible(); + const formatted = (await downloadProject(page)).bundle; + expect(formatted.documents).toEqual(before.documents); + expect(formatted.drafts).not.toEqual(before.drafts); + expect(formatted.presentations).toEqual(before.presentations); + expect( + formatted.drafts.find((d) => d.name === 'model.sysml')!.text, + ).toContain('\r\n'); + await source.press('ControlOrMeta+z'); + await expect(source).toHaveSource(draft.replaceAll('\r\n', '\n')); + await page + .getByRole('button', { name: 'Format source', exact: true }) + .click(); + await expect( + page.getByRole('status').filter({ hasText: 'Formatted draft.' }), + ).toBeVisible(); + await expect + .poll(async () => (await downloadProject(page)).bundle.drafts) + .toEqual(formatted.drafts); + await page.getByRole('button', { name: 'Apply source', exact: true }).click(); + await expect(page.getByText('Source applied', { exact: true })).toBeVisible(); + expect((await downloadProject(page)).bundle.documents).toEqual( + formatted.drafts, + ); + await page.getByRole('button', { name: 'Undo source', exact: true }).click(); + await expect(source).toHaveSource(original.replaceAll('\r\n', '\n')); + await page.getByRole('button', { name: 'Redo source', exact: true }).click(); + await expect(source).toHaveSource( + formatted.drafts + .find((d) => d.name === 'model.sysml')! + .text.replaceAll('\r\n', '\n'), + ); + const saved = (await downloadProject(page)).bundle; + const asset = '**/vendor/sysml-toolkit/sysmlv2_bg.wasm'; + let failed = 0; + await page.route(asset, (r) => { + failed++; + return r.abort('failed'); + }); + await page + .getByRole('button', { name: 'Format source', exact: true }) + .click(); + await expect.poll(() => failed).toBeGreaterThan(0); + await expect( + page.getByRole('status').filter({ hasText: /failed|fetch/i }), + ).toBeVisible(); + expect((await downloadProject(page)).bundle).toEqual(saved); + await page.unroute(asset); +}); diff --git a/tests/e2e/overview-routing.spec.ts b/tests/e2e/overview-routing.spec.ts new file mode 100644 index 0000000..41fb6ea --- /dev/null +++ b/tests/e2e/overview-routing.spec.ts @@ -0,0 +1,102 @@ +import { + test, + expect, + observeExplorations, + downloadProject, + overviewRoutingGeometry, + copySourceSelection, +} from './support'; +import { assemblyExploration } from './exploration-support'; +import nativeArtifact from '../fixtures/exploration-assembly-artifact.json' with { type: 'json' }; + +test('routes the real assembly overview clear of cards and labels while preserving declarations and structural connections', async ({ + page, +}) => { + const results = await observeExplorations(page); + const documents = await assemblyExploration(page); + await expect.poll(() => results.length).toBe(1); + expect(results[0]!.artifact).toEqual(nativeArtifact); + await page.getByRole('button', { name: 'Diagram', exact: true }).click(); + const before = (await downloadProject(page)).bundle; + expect(before.documents).toEqual(documents); + await page.getByRole('button', { name: 'Fit view', exact: true }).click(); + await expect + .poll(async () => { + const geometry = await overviewRoutingGeometry(page); + return { hits: geometry.hits, count: geometry.labelCount }; + }) + .toEqual({ hits: [], count: 13 }); + const expected: Record = { + input: 'in port input : Signal;', + output: 'out port output : Signal;', + service: 'inout port service : Signal;', + monitor: 'ref part monitor : Device;', + primary: 'part primary : Device;', + standby: 'part standby : Device;', + bay: `part bay { + part primary : Device; + part standby : Device; + connection handoff connect primary.output to standby.input; + }`, + }; + for (const edge of results[0]!.artifact!.edges) { + const from = results[0]!.artifact!.nodes.find( + (node) => node.id === edge.from, + )!; + const to = results[0]!.artifact!.nodes.find((node) => node.id === edge.to)!; + const relation = page.getByRole('group', { + name: `${edge.kind} ${edge.label ?? ''}: ${from.name} to ${to.name}`, + exact: true, + }); + await relation.focus(); + await relation.press('Enter'); + await expect(relation).toBeFocused(); + await expect( + page.getByRole('button', { name: 'Apply source', exact: true }), + ).toBeDisabled(); + await page + .getByRole('complementary', { name: 'Properties', exact: true }) + .getByRole('button', { name: 'Open source', exact: true }) + .click(); + await expect + .poll(() => + copySourceSelection( + page.getByRole('textbox', { name: 'SysML source', exact: true }), + ), + ) + .toBe( + edge.kind === 'typing' + ? ( + { + input: 'Signal', + output: 'Signal', + service: 'Signal', + primary: 'Device', + standby: 'Device', + monitor: 'Device', + } as Record + )[from.name] + : expected[edge.label!], + ); + } + expect((await downloadProject(page)).bundle).toEqual(before); + const scope = page.getByRole('combobox', { + name: 'Exploration scope', + exact: true, + }); + await scope.selectOption('Assembly::Station'); + await expect(scope).toBeEnabled(); + const representation = page.getByRole('combobox', { + name: 'Exploration representation', + exact: true, + }); + await representation.selectOption('interconnection'); + await expect(representation).toBeEnabled(); + await expect( + page.getByRole('group', { name: 'part primary : Device', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('group', { name: /connection handoff:/ }), + ).toBeVisible(); + expect((await downloadProject(page)).bundle).toEqual(before); +}); diff --git a/tests/e2e/recovery-dialog.css b/tests/e2e/recovery-dialog.css new file mode 100644 index 0000000..c7be311 --- /dev/null +++ b/tests/e2e/recovery-dialog.css @@ -0,0 +1,4 @@ +/* Preserve layout while excluding unrelated canvas pixels from dialog crops. */ +.react-flow { + visibility: hidden; +} diff --git a/tests/e2e/requirements-data.spec.ts b/tests/e2e/requirements-data.spec.ts new file mode 100644 index 0000000..29f14fe --- /dev/null +++ b/tests/e2e/requirements-data.spec.ts @@ -0,0 +1,256 @@ +import { + test, + expect, + ready, + downloadProject, + fillSource, + copySourceSelection, + moveNode, + expectRenderedPositions, + namedViewButton, +} from './support'; +import { importRequirementsTraceability } from './requirements-support'; + +test('@critical reads requirements and exact declared traceability without changing source, drafts or saved positions', async ({ + page, +}) => { + await ready(page); + await importRequirementsTraceability(page); + await expect( + page.getByRole('button', { name: 'Download project', exact: true }), + ).toBeEnabled(); + await moveNode( + page, + page.getByRole('group', { + name: 'requirement def REQ-CAP TraceRequirements::MinimumCapacity', + exact: true, + }), + ); + const before = (await downloadProject(page)).bundle; + expect(Object.keys(before.presentations)).toEqual([ + 'TraceViews::requirements', + ]); + await page + .getByRole('button', { name: 'Requirements table', exact: true }) + .click(); + const table = page.getByRole('region', { + name: 'Requirements table', + exact: true, + }); + await expect(table.getByRole('status')).toHaveText('7 requirements'); + await expect( + table + .getByRole('row') + .filter({ hasText: 'TraceRequirements::MinimumCapacity' }) + .first(), + ).toContainText('REQ-CAP'); + await expect(table).toContainText( + 'The storage tank shall provide at least ten units', + ); + await table.getByLabel('Search requirements').fill('REQ-LEFT-CAP'); + await expect(table.getByRole('status')).toHaveText('1 requirements'); + await expect(table).toContainText('TraceSystem::LeftPlant::tank'); + await expect(table).not.toContainText('TraceSystem::RightPlant::tank'); + await table + .getByRole('button', { + name: 'TraceRequirements::leftSpecification::leftCapacity', + exact: true, + }) + .click(); + const source = page.getByRole('textbox', { + name: 'SysML source', + exact: true, + }); + await expect + .poll(() => copySourceSelection(source)) + .toContain("requirement <'REQ-LEFT-CAP'> leftCapacity : CapacityRule"); + await table.getByLabel('Search requirements').fill(''); + const csv = page.waitForEvent('download'); + await table.getByRole('button', { name: 'Export CSV', exact: true }).click(); + const stream = await (await csv).createReadStream(); + const chunks = []; + for await (const c of stream!) chunks.push(Buffer.from(c)); + const content = Buffer.concat(chunks).toString(); + expect(content).toContain('NOT TraceSystem::spare'); + expect(content).toContain('REQ-LEFT-CAP'); + await page + .getByRole('button', { name: 'Traceability matrix', exact: true }) + .click(); + const matrix = page.getByRole('region', { + name: 'Traceability matrix', + exact: true, + }); + await expect(matrix.getByRole('status')).toHaveText( + '7 requirements · 3 linked elements', + ); + const left = matrix.getByRole('row').filter({ + has: page.getByRole('button', { + name: 'TraceRequirements::leftSpecification::leftCapacity', + exact: true, + }), + }); + await expect( + left.getByRole('button', { name: 'Declared', exact: true }), + ).toHaveCount(1); + const excluded = matrix.getByRole('row').filter({ + has: page.getByRole('button', { + name: 'TraceRequirements::excludedCapacity', + exact: true, + }), + }); + await expect(excluded).toContainText('Negated declaration'); + const unlinked = matrix.getByRole('row').filter({ + has: page.getByRole('button', { + name: 'TraceRequirements::unlinkedCapacity', + exact: true, + }), + }); + await expect(unlinked).toBeVisible(); + await expect( + unlinked.getByRole('button', { name: /Declared|Negated declaration/ }), + ).toHaveCount(0); + await namedViewButton(page, 'TraceViews::overview').click(); + await expect( + page.getByRole('button', { name: 'Diagram view', exact: true }), + ).toHaveAttribute('aria-pressed', 'true'); + await namedViewButton(page, 'TraceViews::requirements').click(); + await page + .getByRole('button', { name: 'Traceability matrix', exact: true }) + .click(); + await expect(matrix.getByRole('status')).toHaveText( + '7 requirements · 3 linked elements', + ); + await matrix + .getByLabel('Relationship', { exact: true }) + .selectOption('verification'); + await expect(matrix.getByRole('status')).toHaveText( + '7 requirements · 0 linked elements', + ); + expect((await downloadProject(page)).bundle).toEqual(before); + // Invalid draft remains a draft; the data continues to describe applied source. + await page + .getByRole('button', { name: 'requirements.sysml', exact: true }) + .click(); + await fillSource( + source, + before.documents.find((d) => d.name === 'requirements.sysml')!.text + + '\npart broken : Missing;', + ); + await expect(matrix).toContainText('Unapplied drafts are retained'); + await matrix + .getByRole('button', { + name: 'TraceRequirements::excludedCapacity', + exact: true, + }) + .click(); + await expect( + page.getByRole('status').filter({ hasText: 'Apply or discard this file' }), + ).toBeVisible(); + const saved = (await downloadProject(page)).bundle; + expect(saved.documents).toEqual(before.documents); + expect(saved.presentations).toEqual(before.presentations); + expect(saved.drafts).not.toEqual(before.drafts); + page.on('dialog', (d) => d.accept()); + await page.locator('input[type=file]').setInputFiles({ + name: 'reopen.json', + mimeType: 'application/json', + buffer: Buffer.from(JSON.stringify(saved)), + }); + await expect( + page.getByRole('button', { name: 'Download project', exact: true }), + ).toBeEnabled(); + expect((await downloadProject(page)).bundle).toEqual(saved); + await page.getByRole('button', { name: 'Diagram view', exact: true }).click(); + await expectRenderedPositions(page, saved, 'TraceViews::requirements'); +}); + +test('requirements asset failure is explicit and retries without replacing the project', async ({ + page, +}) => { + await ready(page); + const before = (await downloadProject(page)).bundle; + const asset = '**/vendor/opensysml/sysml-wasm.wasm'; + let failed = 0; + await page.route(asset, (r) => { + failed++; + return r.abort('failed'); + }); + await page + .getByRole('button', { name: 'Requirements table', exact: true }) + .click(); + const panel = page.getByRole('region', { + name: 'Requirements table', + exact: true, + }); + await expect(panel.getByRole('alert')).toBeVisible(); + expect(failed).toBeGreaterThan(0); + expect((await downloadProject(page)).bundle).toEqual(before); + await page.unroute(asset); + await panel.getByRole('button', { name: 'Retry requirements' }).click(); + await expect(panel.getByRole('table')).toBeVisible(); + expect((await downloadProject(page)).bundle).toEqual(before); +}); + +test('matrix verification resolves the actual verifier and requirement through a cross-file alias', async ({ + page, +}) => { + await page.goto('/app'); + const documents = [ + { + name: 'requirements.sysml', + text: "package Requirements { requirement <'R-SAFE'> safety; alias Safe for safety; }", + }, + { + name: 'system.sysml', + text: 'package System { private import Requirements::*; part pump { satisfy Safe; } verification def Check; verification proof : Check { objective { verify safety; } } }', + }, + ]; + await page.locator('input[type=file]').setInputFiles( + documents.map((d) => ({ + name: d.name, + mimeType: 'text/plain', + buffer: Buffer.from(d.text), + })), + ); + await expect( + page.getByRole('button', { name: 'Download project', exact: true }), + ).toBeEnabled(); + const before = (await downloadProject(page)).bundle; + await page + .getByRole('button', { name: 'Traceability matrix', exact: true }) + .click(); + const matrix = page.getByRole('region', { + name: 'Traceability matrix', + exact: true, + }); + await expect(matrix.getByRole('status')).toHaveText( + '1 requirements · 1 linked elements', + ); + await expect( + matrix + .getByRole('columnheader') + .getByRole('button', { name: 'System::pump', exact: true }), + ).toBeVisible(); + await matrix + .getByRole('combobox', { name: 'Relationship', exact: true }) + .selectOption('verification'); + await expect( + matrix + .getByRole('columnheader') + .getByRole('button', { name: 'System::proof', exact: true }), + ).toBeVisible(); + await expect( + matrix + .getByRole('rowheader') + .getByRole('button', { name: 'Requirements::safety', exact: true }), + ).toBeVisible(); + await matrix.getByRole('button', { name: 'Declared', exact: true }).click(); + await expect + .poll(() => + copySourceSelection( + page.getByRole('textbox', { name: 'SysML source', exact: true }), + ), + ) + .toContain('verify safety'); + expect((await downloadProject(page)).bundle).toEqual(before); +}); diff --git a/tests/e2e/requirements-support.ts b/tests/e2e/requirements-support.ts index 7db5f08..f92e79e 100644 --- a/tests/e2e/requirements-support.ts +++ b/tests/e2e/requirements-support.ts @@ -190,6 +190,7 @@ export async function expectRequirementsFit(page: Page) { /** Center the focused card with a real canvas pan, retaining model-space geometry. */ export async function centerRequirementCard(page: Page, card: Locator) { + await settledRequirementViewport(page); const canvas = page .getByRole('region', { name: 'Diagram', exact: true }) .locator('.react-flow'); diff --git a/tests/e2e/rover.spec.ts b/tests/e2e/rover.spec.ts new file mode 100644 index 0000000..6a6b4d6 --- /dev/null +++ b/tests/e2e/rover.spec.ts @@ -0,0 +1,265 @@ +import { readFile } from 'node:fs/promises'; +import { + test, + expect, + waitForRoverStartup, + observeEvaluations, + downloadProject, + namedViewButton, + diagramSymbol, + moveNode, + expectRenderedPositions, + copySourceSelection, + overviewRoutingGeometry, +} from './support'; + +async function rover(page: import('@playwright/test').Page) { + await page.goto('/app'); + await page.getByRole('button', { name: 'Load example', exact: true }).click(); + await waitForRoverStartup(page); +} + +test('@critical opens the inspection rover with inherited declared values and exact connections, preserving all six files across its guided tour', async ({ + page, +}) => { + const results = await observeEvaluations(page); + await rover(page); + const architecture = results.findLast( + (v) => v.view === 'RoverViews::architecture', + )!; + expect(architecture.status).toBe('complete'); + expect(architecture.artifact!.nodes.map((n) => n.name)).toEqual([ + 'RoverSystem::InspectionRover', + 'energy', + 'battery', + 'distribution', + 'mobility', + 'leftDrive', + 'rightDrive', + 'autonomy', + 'controller', + 'lidar', + 'inspection', + 'camera', + 'radio', + ]); + expect(architecture.artifact!.edges.map((e) => e.label).sort()).toEqual( + [ + 'batterySupply', + 'obstacleScan', + 'leftPower', + 'rightPower', + 'computePower', + 'cameraPower', + 'leftCommand', + 'rightCommand', + 'inspectionImages', + 'missionTelemetry', + ].sort(), + ); + const battery = architecture.artifact!.nodes.find( + (n) => n.name === 'battery', + )!; + expect(battery.attributeDeclarations).toEqual([ + 'attribute capacityWh : Real default = 480.0;', + 'attribute reservePercent : Real default = 20.0;', + ]); + await expect( + diagramSymbol(page, battery.id).getByText('capacityWh: Real = 480', { + exact: true, + }), + ).toBeVisible(); + await expect + .poll( + async () => + (await overviewRoutingGeometry(page, [], architecture.artifact)).hits, + { + timeout: 3000, + }, + ) + .toEqual([]); + await expect + .poll( + async () => + (await overviewRoutingGeometry(page, [], architecture.artifact)) + .labelCount, + ) + .toBe(10); + const saved = await downloadProject(page); + expect(saved.bundle.documents).toHaveLength(6); + for (const file of saved.bundle.documents) + expect(file.text).toBe( + await readFile( + new URL( + '../../examples/inspection-rover/' + file.name, + import.meta.url, + ), + 'utf8', + ), + ); + expect(saved.bundle.drafts).toEqual(saved.bundle.documents); + for (const [view, kind] of [ + ['mission', 'action'], + ['lifecycle', 'state'], + ['requirements', 'requirement'], + ['definitions', 'definition'], + ]) { + await namedViewButton(page, 'RoverViews::' + view).click(); + await expect(namedViewButton(page, 'RoverViews::' + view)).toHaveAttribute( + 'aria-current', + 'page', + ); + await expect + .poll( + () => + results.findLast((v) => v.view === 'RoverViews::' + view)?.artifact + ?.kind, + ) + .toBe(kind); + await expect( + page.getByRole('heading', { name: 'Rendering unavailable', exact: true }), + ).not.toBeVisible(); + const first = results.findLast((v) => v.view === 'RoverViews::' + view)! + .artifact!.nodes[0]!; + await expect(diagramSymbol(page, first.id)).toContainText(first.name); + } + const requirements = results.findLast( + (v) => v.view === 'RoverViews::requirements', + )!; + expect( + requirements.artifact!.edges.filter((e) => e.kind === 'satisfy'), + ).toHaveLength(4); + expect((await downloadProject(page)).bundle).toEqual(saved.bundle); +}); + +for (const [view, target, declaration] of [ + ['mission', 'patrol', 'action patrol;'], + ['lifecycle', 'safeStop', 'state safeStop;'], +]) { + test(`preserves ${view} source selection and independently arranged geometry after portable reopen`, async ({ + page, + }) => { + const results = await observeEvaluations(page); + await rover(page); + await namedViewButton(page, 'RoverViews::' + view).click(); + await expect + .poll( + () => results.findLast((v) => v.view === 'RoverViews::' + view)?.status, + ) + .toBe('complete'); + const result = results.findLast((v) => v.view === 'RoverViews::' + view)!; + const node = result.artifact!.nodes.find((n) => n.name === target)!; + const unlabelled = result.artifact!.edges.flatMap((edge, index) => + edge.label ? [] : [`edge:${index}`], + ); + const symbol = diagramSymbol(page, node.id); + await expect(symbol).toBeVisible(); + await expect + .poll( + async () => (await overviewRoutingGeometry(page, unlabelled)).hits, + { + timeout: 3000, + }, + ) + .toEqual([]); + await expect + .poll( + async () => + (await overviewRoutingGeometry(page, unlabelled)).labelCount, + ) + .toBe(result.artifact!.edges.filter((edge) => edge.label).length); + await symbol.focus(); + await symbol.press('Enter'); + await page + .getByRole('complementary', { name: 'Properties', exact: true }) + .getByRole('button', { name: 'Open source', exact: true }) + .click(); + const source = page.getByRole('textbox', { + name: 'SysML source', + exact: true, + }); + await expect.poll(() => copySourceSelection(source)).toBe(declaration); + await expect( + page.getByRole('button', { name: 'Apply source', exact: true }), + ).toBeDisabled(); + const before = (await downloadProject(page)).bundle; + const edgeName = + view === 'mission' + ? 'succession : RoverBehavior::InspectRoute.patrol to RoverBehavior::InspectRoute.detectObstacle' + : 'transition pathClear: RoverBehavior::OperatingMode.safeStop to RoverBehavior::OperatingMode.patrolling'; + const edge = page.getByRole('group', { name: edgeName, exact: true }); + await edge.focus(); + await edge.press('Enter'); + await expect(edge).toBeFocused(); + await page + .getByRole('complementary', { name: 'Properties', exact: true }) + .getByRole('button', { name: 'Open source', exact: true }) + .click(); + await expect + .poll(() => copySourceSelection(source)) + .toBe( + view === 'mission' + ? 'succession first patrol then detectObstacle;' + : 'transition pathClear first safeStop then patrolling;', + ); + expect((await downloadProject(page)).bundle).toEqual(before); + await page.getByRole('button', { name: 'Diagram', exact: true }).click(); + await moveNode(page, symbol); + const saved = await downloadProject(page); + expect(saved.bundle.presentations['RoverViews::' + view]).toBeDefined(); + await page.reload(); + await page + .getByRole('button', { name: 'Load example', exact: true }) + .click(); + await waitForRoverStartup(page); + await page.locator('input[type=file]').setInputFiles({ + name: 'rover.sysml-project.json', + mimeType: 'application/json', + buffer: saved.buffer, + }); + await namedViewButton(page, 'RoverViews::' + view).click(); + await expectRenderedPositions(page, saved.bundle, 'RoverViews::' + view); + expect((await downloadProject(page)).bundle).toEqual(saved.bundle); + }); +} + +test('keeps structural routes with a moved rover assembly and retains exact source, endpoints and positions after reopen', async ({ + page, +}) => { + const results = await observeEvaluations(page); + await rover(page); + const architecture = results.findLast( + (result) => result.view === 'RoverViews::architecture', + )!; + const before = (await downloadProject(page)).bundle; + await moveNode( + page, + diagramSymbol(page, architecture.artifact!.nodes[0]!.id), + ); + await expect + .poll( + async () => + (await overviewRoutingGeometry(page, [], architecture.artifact)).hits, + ) + .toEqual([]); + const saved = await downloadProject(page); + expect(saved.bundle.documents).toEqual(before.documents); + expect(saved.bundle.drafts).toEqual(before.drafts); + expect(saved.bundle.presentations['RoverViews::architecture']).toBeDefined(); + await page.reload(); + await page.getByRole('button', { name: 'Load example', exact: true }).click(); + await waitForRoverStartup(page); + await page.locator('input[type=file]').setInputFiles({ + name: 'rover.sysml-project.json', + mimeType: 'application/json', + buffer: saved.buffer, + }); + await expectRenderedPositions(page, saved.bundle, 'RoverViews::architecture'); + await expect + .poll( + async () => + (await overviewRoutingGeometry(page, [], architecture.artifact)).hits, + ) + .toEqual([]); + expect((await downloadProject(page)).bundle).toEqual(saved.bundle); +}); diff --git a/tests/e2e/shell-entry.spec.ts b/tests/e2e/shell-entry.spec.ts index c9efa1e..a8fc923 100644 --- a/tests/e2e/shell-entry.spec.ts +++ b/tests/e2e/shell-entry.spec.ts @@ -4,7 +4,7 @@ import { test, expect, waitForLauncher, - waitForStartup, + waitForRoverStartup, openWorkshop, downloadProject, fillSource, @@ -81,14 +81,22 @@ test('@critical opens the OpenMBEE landing and empty launcher without loading an await expect( page.getByRole('button', { name: 'Open files or project', exact: true }), ).toBeEnabled(); - await page - .getByText('Folder and repository workflows', { exact: true }) - .click(); await expect( - page.getByText( - 'Direct folder saving and Flexo project/branch checkout are planned. Use source files or a project bundle to work locally today.', - { exact: true }, - ), + page.getByRole('button', { name: 'Load air workshop', exact: true }), + ).toHaveCount(0, { timeout: 2000 }); + const choices = page.getByRole('group', { + name: 'Project starting points', + exact: true, + }); + await expect(choices.getByRole('button')).toHaveCount(3); + for (const name of ['New project', 'Open files or project', 'Load example']) { + await page.keyboard.press('Tab'); + await expect( + choices.getByRole('button', { name, exact: true }), + ).toBeFocused(); + } + await expect( + page.getByRole('heading', { name: 'Save your work', exact: true }), ).toBeVisible(); expect(engineRequests).toEqual([]); }); @@ -98,11 +106,11 @@ test('@critical landing and direct example links open the real model once and re }) => { await page.goto('/'); await page.getByRole('link', { name: 'Try an example', exact: true }).click(); - await waitForStartup(page, { split: false }); + await waitForRoverStartup(page); await expect(page).toHaveURL(/\/app$/); const bundle = (await downloadProject(page)).bundle; - expect(bundle.name).toBe('Smart air workshop'); - expect(bundle.documents).toHaveLength(9); + expect(bundle.name).toBe('Inspection rover'); + expect(bundle.documents).toHaveLength(6); expect(bundle.drafts).toEqual(bundle.documents); await page.reload(); await waitForLauncher(page); @@ -115,7 +123,7 @@ test('@critical landing and direct example links open the real model once and re // A directly opened example URL uses the same real vendor assets and consumes // its one-shot instruction, including when the incoming route has a slash. await page.goto('/app/?example=1'); - await waitForStartup(page, { split: false }); + await waitForRoverStartup(page); await expect(page).toHaveURL(/\/app$/); expect((await downloadProject(page)).bundle.documents).toEqual( bundle.documents, @@ -561,7 +569,7 @@ test('a newer example opening supersedes a held real engine request without publ .click(); await expect.poll(() => requests).toBeGreaterThan(1); release(); - await waitForStartup(page, { split: false }); + await waitForRoverStartup(page); await expect( page.getByRole('button', { name: /^(Show|Hide) Issues$/ }), ).toHaveText('Issues (0)'); @@ -570,13 +578,13 @@ test('a newer example opening supersedes a held real engine request without publ ).not.toBeVisible(); await expect(page.getByRole('alert')).not.toBeVisible(); const bundle = (await downloadProject(page)).bundle; - expect(bundle.name).toBe('Smart air workshop'); - expect(bundle.documents).toHaveLength(9); + expect(bundle.name).toBe('Inspection rover'); + expect(bundle.documents).toHaveLength(6); for (const document of bundle.documents) { expect(document.text).toBe( await readFile( new URL( - '../../examples/air-compressor/' + document.name, + '../../examples/inspection-rover/' + document.name, import.meta.url, ), 'utf8', diff --git a/tests/e2e/support.ts b/tests/e2e/support.ts index 09dc470..fd45dbd 100644 --- a/tests/e2e/support.ts +++ b/tests/e2e/support.ts @@ -1,3 +1,7 @@ +import { + readExplorationResult, + type ExplorationResult, +} from '../../src/lib/workspace/exploration'; import { test as base, expect as baseExpect, @@ -7,7 +11,10 @@ import { import type { ProjectBundle } from '../../src/lib/workspace/persistence'; import { readFile } from 'node:fs/promises'; import { readEvaluatedView } from '../../src/lib/workspace/views/schema'; -import type { EvaluatedView } from '../../src/lib/workspace/views'; +import type { + EvaluatedView, + ViewArtifact, +} from '../../src/lib/workspace/views'; import { originKey } from '../../src/lib/workspace/editing/evidence'; /** Compare saved geometry by actual per-view semantic occurrence, not n0 IDs. */ @@ -156,11 +163,14 @@ export function namedViewButton(page: Page, id: string) { export async function waitForStartup(page: Page, { split = true } = {}) { await expect( - page.getByRole('group', { name: 'part pump : Pump', exact: true }), + page.getByRole('group', { + name: 'part compressor : CompressorInterface', + exact: true, + }), ).toBeVisible(); await expect( - namedViewButton(page, 'CompressorViews::pneumatics'), - ).toHaveAttribute('aria-current', 'page'); + page.getByRole('combobox', { name: 'Named view', exact: true }), + ).toHaveValue('CompressorViews::overview'); await expect( page.getByRole('button', { name: 'Download project', exact: true }), ).toBeEnabled(); @@ -179,19 +189,62 @@ export async function waitForLauncher(page: Page) { ).toBeEnabled(); } -/** Explicitly choose the example; waiting for a model never creates one. */ +/** Import the legacy compressor fixture explicitly; it is not bundled in the app. */ export async function startExample( page: Page, options: { split?: boolean } = {}, ) { await waitForLauncher(page); - await page.getByRole('button', { name: 'Load example', exact: true }).click(); - await waitForStartup(page, options); + const documents = await Promise.all( + [ + 'system.sysml', + 'components.sysml', + 'types.sysml', + 'behavior.sysml', + 'requirements.sysml', + 'use-cases.sysml', + 'views.sysml', + 'sequence.sysml', + 'geometry.sysml', + ].map(async (name) => ({ + name, + text: await readFile( + new URL(`../../examples/air-compressor/${name}`, import.meta.url), + 'utf8', + ), + })), + ); + await page.locator('input[type=file]').setInputFiles({ + name: 'air-workshop.sysml-project.json', + mimeType: 'application/json', + buffer: Buffer.from( + JSON.stringify({ + format: 'sysmlv2-view-project', + version: 1, + name: 'Smart air workshop', + documents, + drafts: documents, + presentations: {}, + }), + ), + }); + await waitForStartup(page, { split: false }); + await page + .getByRole('button', { + name: options.split === false ? 'Diagram' : 'Split', + exact: true, + }) + .click(); } +/** Most diagram regressions deliberately use the pneumatic fixture after startup. */ export async function ready(page: Page, options: { split?: boolean } = {}) { await page.goto('/app'); await startExample(page, options); + await namedViewButton(page, 'CompressorViews::pneumatics').click(); + await expect( + page.getByRole('group', { name: 'part pump : Pump', exact: true }), + ).toBeVisible(); } /** Keep focused history/appearance regressions explicit about their small fixture. */ @@ -291,6 +344,41 @@ export async function observeEvaluations(page: Page) { return evaluations; } +/** Observe actual exploration envelopes; authored view evaluation stays separate. */ +export async function observeExplorations(page: Page) { + const results: ExplorationResult[] = []; + await page.exposeFunction('__recordExploration', (value: unknown) => + results.push(readExplorationResult(value)), + ); + await page.addInitScript(() => { + const host = window as typeof window & { + __recordExploration(value: unknown): Promise; + }; + const NativeWorker = window.Worker; + window.Worker = class extends NativeWorker { + constructor(url: string | URL, options?: WorkerOptions) { + super(url, options); + this.addEventListener('message', (event: MessageEvent) => { + const reply = event.data as { + value?: { + schemaVersion?: unknown; + request?: unknown; + view?: unknown; + }; + }; + if ( + reply?.value?.schemaVersion === 1 && + reply.value.request && + reply.value.view === undefined + ) + void host.__recordExploration(reply.value); + }); + } + }; + }); + return results; +} + export function diagramSymbol(page: Page, id: string) { return page .getByRole('region', { name: 'Diagram', exact: true }) @@ -443,11 +531,52 @@ export async function diagramState(page: Page) { export async function dragNode(page: Page, node: Locator) { await node.scrollIntoViewIfNeeded(); - const box = await node.boundingBox(); - expect(box).not.toBeNull(); - await page.mouse.move(box!.x + 30, box!.y + 25); + // Mode changes fit the camera after ResizeObserver and animation frames. + // Wait for observable heading/viewport geometry, then perform one gesture. + const { x, y } = await node.evaluate( + (element) => + new Promise<{ x: number; y: number }>((resolve, reject) => { + let previous = '', + stable = 0; + const timer = setTimeout( + () => reject(new Error('Diagram drag geometry did not settle.')), + 5000, + ); + const sample = () => { + const heading = + element.querySelector('.structure-heading, .requirement-heading') ?? + element; + const box = heading.getBoundingClientRect(); + const viewport = element.closest('.react-flow__viewport'); + const key = JSON.stringify([ + box.x, + box.y, + box.width, + box.height, + viewport && getComputedStyle(viewport).transform, + ]); + stable = key === previous ? stable + 1 : 0; + previous = key; + const x = box.x + Math.min(30, box.width / 3), + y = box.y + box.height / 2; + const hit = document.elementFromPoint(x, y); + if ( + stable >= 3 && + box.width > 0 && + box.height > 0 && + hit && + element.contains(hit) + ) { + clearTimeout(timer); + resolve({ x, y }); + } else requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }), + ); + await page.mouse.move(x, y); await page.mouse.down(); - await page.mouse.move(box!.x + 110, box!.y + 80, { steps: 8 }); + await page.mouse.move(x + 80, y + 55, { steps: 8 }); await page.mouse.up(); } @@ -572,3 +701,211 @@ export async function railEdgeOffset( ? button.x - main.x : main.x + main.width - button.x - button.width; } + +/** Renderer-only geometry reads stay here; callers select exact native symbols. */ +export async function explorationNodeBoxes( + page: Page, + symbols: readonly string[], +) { + return Promise.all( + symbols.map(async (symbol) => { + const box = await diagramSymbol(page, symbol).boundingBox(); + if (!box) throw new Error(`Missing exploration symbol ${symbol}`); + return box; + }), + ); +} +export async function explorationEdgeLabelBoxes( + page: Page, + symbols: readonly string[], +) { + return Promise.all( + symbols.map(async (symbol) => { + const label = page + .getByRole('region', { name: 'Diagram', exact: true }) + .locator( + `.react-flow__edge[data-id=${JSON.stringify(symbol)}] .react-flow__edge-text`, + ); + const box = await label.boundingBox(); + if (!box) + throw new Error(`Missing exploration relationship label ${symbol}`); + return box; + }), + ); +} +export async function explorationScopeFits(page: Page) { + return page + .getByRole('group', { name: 'Current exploration scope', exact: true }) + .evaluate((element) => { + const bounds = element.getBoundingClientRect(); + const strong = element.querySelector('strong')!; + const text = strong.getBoundingClientRect(); + return ( + strong.scrollWidth <= strong.clientWidth && + text.left >= bounds.left - 1 && + text.right <= bounds.right + 1 + ); + }); +} + +/** Check actual SVG routes against declaration cards/headers in screen coordinates. */ +export async function overviewRoutingGeometry( + page: Page, + unlabelled: readonly string[] = [], + artifact?: ViewArtifact, +) { + return page.getByRole('region', { name: 'Diagram', exact: true }).evaluate( + (region, { unlabelled, topology }) => { + const cards = [ + ...region.querySelectorAll('.react-flow__node'), + ].map((node) => { + const element = node.classList.contains('structure-compound') + ? node.querySelector('.structure-heading')! + : node; + return { + id: node.dataset.id!, + rect: element.getBoundingClientRect(), + body: node.getBoundingClientRect(), + compound: node.classList.contains('structure-compound'), + }; + }); + const viewport = region + .querySelector('.react-flow')! + .getBoundingClientRect(); + const hits: string[] = [], + labels: DOMRect[] = []; + let samples = 0; + for (const edge of region.querySelectorAll( + '.react-flow__edge', + )) { + const path = edge.querySelector( + '.react-flow__edge-path', + )!; + // Routed edges emit model-space M/L sections. Check that physical + // handle attachment never turns a planned straight segment diagonal. + if (edge.classList.contains('react-flow__edge-routed')) { + const points = [ + ...(path.getAttribute('d') ?? '').matchAll( + /[ML] ([-+\d.e]+) ([-+\d.e]+)/g, + ), + ].map((match) => ({ x: Number(match[1]), y: Number(match[2]) })); + for (let index = 1; index < points.length; index++) + if ( + Math.min( + Math.abs(points[index]!.x - points[index - 1]!.x), + Math.abs(points[index]!.y - points[index - 1]!.y), + ) > 0.1 + ) + hits.push(`${edge.dataset.id} has a non-orthogonal section`); + } + const relationship = + topology?.edges[Number(edge.dataset.id?.split(':')[1])]; + const contains = (owner: string, id: string): boolean => { + for ( + let current: string | undefined = id; + current; + current = topology?.nodes.find( + (node) => node.id === current, + )?.parent + ) + if (current === owner) return true; + return false; + }; + const obstacles = cards.map((card) => ({ + id: card.id, + rect: + relationship && + card.compound && + !contains(card.id, relationship.from) && + !contains(card.id, relationship.to) + ? card.body + : card.rect, + })); + const length = path.getTotalLength(), + matrix = path.getScreenCTM()!; + for (let distance = 0; distance <= length; distance += 3) { + const raw = path.getPointAtLength(distance), + point = new DOMPoint(raw.x, raw.y).matrixTransform(matrix); + samples++; + for (const card of obstacles) { + const r = card.rect; + if ( + point.x > r.left + 1 && + point.x < r.right - 1 && + point.y > r.top + 1 && + point.y < r.bottom - 1 + ) { + hits.push(`${edge.dataset.id} crosses ${card.id}`); + break; + } + } + } + const label = edge + .querySelector('.react-flow__edge-textbg') + ?.getBoundingClientRect(); + if (unlabelled.includes(edge.dataset.id!)) { + if (label) hits.push(`${edge.dataset.id} unexpected label`); + continue; + } + if (!label) { + hits.push(`${edge.dataset.id} missing label`); + continue; + } + for (const { id, rect: r } of obstacles) + if ( + label.left < r.right && + label.right > r.left && + label.top < r.bottom && + label.bottom > r.top + ) + hits.push(`${edge.dataset.id} label covers ${id}`); + for (const r of labels) + if ( + label.left < r.right && + label.right > r.left && + label.top < r.bottom && + label.bottom > r.top + ) + hits.push(`${edge.dataset.id} labels overlap`); + if ( + label.left < viewport.left || + label.right > viewport.right || + label.top < viewport.top || + label.bottom > viewport.bottom + ) + hits.push(`${edge.dataset.id} label outside viewport`); + labels.push(label); + } + return { hits: [...new Set(hits)], labelCount: labels.length, samples }; + }, + { + unlabelled: [...unlabelled], + topology: artifact + ? { + nodes: artifact.nodes.map((node) => ({ + id: node.id, + parent: node.parent, + })), + edges: artifact.edges.map((edge) => ({ + from: edge.from, + to: edge.to, + })), + } + : undefined, + }, + ); +} + +/** Product-default startup, independent of the retained air-workshop test fixture. */ +export async function waitForRoverStartup(page: Page) { + await expect( + page.getByRole('combobox', { name: 'Named view', exact: true }), + ).toHaveValue('RoverViews::architecture'); + await expect( + page.getByRole('group', { name: 'part battery : Battery', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Download project', exact: true }), + ).toBeEnabled(); + await page.evaluate(() => document.fonts.ready); +} diff --git a/tests/e2e/toolbar.spec.ts b/tests/e2e/toolbar.spec.ts index 1e7cbed..9eb16bf 100644 --- a/tests/e2e/toolbar.spec.ts +++ b/tests/e2e/toolbar.spec.ts @@ -4,7 +4,7 @@ import { test, expect, ready, - waitForLauncher, + startExample, openWorkshop, downloadProject, diagramCamera, @@ -154,13 +154,15 @@ test('keeps wrapped mobile project actions inside the header and above source co }) => { await page.setViewportSize({ width: 390, height: 900 }); await page.goto('/app'); - await waitForLauncher(page); - await page.getByRole('button', { name: 'Load example', exact: true }).click(); + await startExample(page, { split: false }); await expect( page.getByRole('combobox', { name: 'Named view', exact: true }), - ).toHaveValue('CompressorViews::pneumatics'); + ).toHaveValue('CompressorViews::overview'); await expect( - page.getByRole('group', { name: 'part pump : Pump', exact: true }), + page.getByRole('group', { + name: 'part compressor : CompressorInterface', + exact: true, + }), ).toBeVisible(); await page.evaluate(() => document.fonts.ready); const header = page.getByRole('banner'); diff --git a/tests/e2e/visual.spec.ts b/tests/e2e/visual.spec.ts index e1f63c2..8478a54 100644 --- a/tests/e2e/visual.spec.ts +++ b/tests/e2e/visual.spec.ts @@ -1,7 +1,9 @@ +import { noViews, assemblyExploration, documents } from './exploration-support'; import { test, expect, ready, + waitForRoverStartup, importAssembly, showAssemblyDiagram, modelTreeItem, @@ -10,6 +12,7 @@ import { fillSource, copySourceSelection, observeEvaluations, + overviewRoutingGeometry, } from './support'; import { importRequirementsTraceability, @@ -21,7 +24,12 @@ import { for (const theme of ['light', 'dark']) { test(`workbench and appearance dialog in ${theme}`, async ({ page }) => { - await ready(page, { split: false }); + const evaluations = await observeEvaluations(page); + await page.goto('/app'); + await page + .getByRole('button', { name: 'Load example', exact: true }) + .click(); + await waitForRoverStartup(page); const trigger = page.getByRole('button', { name: 'Appearance', exact: true, @@ -34,6 +42,15 @@ for (const theme of ['light', 'dark']) { await dialog.getByLabel('Theme', { exact: true }).selectOption(theme); await dialog.getByRole('button', { name: 'Done', exact: true }).click(); await expect(dialog).not.toBeVisible(); + const architecture = evaluations.findLast( + (result) => result.view === 'RoverViews::architecture', + )!; + await expect + .poll( + async () => + (await overviewRoutingGeometry(page, [], architecture.artifact)).hits, + ) + .toEqual([]); await expect(page).toHaveScreenshot(`workbench-${theme}.png`); await trigger.click(); await dialog.getByText('Element base colors', { exact: true }).click(); @@ -373,8 +390,11 @@ for (const theme of ['light', 'dark']) { exact: true, }), ).toBeChecked(); + // Fractional dialog bounds can include an outer pixel of the diagram. + // Isolate this dialog capture; full-page tests cover the actual diagram. await expect(dialog).toHaveScreenshot( `local-recovery-${theme}${narrow ? '-narrow' : ''}.png`, + { stylePath: 'tests/e2e/recovery-dialog.css' }, ); await page.keyboard.press('Escape'); await expect(dialog).not.toBeVisible(); @@ -619,3 +639,358 @@ for (const theme of ['light', 'dark']) { }); } } + +for (const theme of ['light', 'dark']) { + for (const width of [1440, 768]) { + test(`read-only model exploration in ${theme} at ${width}`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + await noViews(page); + await page.getByRole('button', { name: 'Diagram', exact: true }).click(); + const before = (await downloadProject(page)).bundle; + await page + .getByRole('button', { name: 'Appearance', exact: true }) + .click(); + const appearance = page.getByRole('dialog', { + name: 'Appearance', + exact: true, + }); + await appearance.getByLabel('Theme', { exact: true }).selectOption(theme); + await appearance + .getByRole('button', { name: 'Done', exact: true }) + .click(); + await expect(appearance).not.toBeVisible(); + await expect( + page.getByRole('button', { name: 'Appearance', exact: true }), + ).toBeFocused(); + if (width === 768) { + await page + .getByRole('button', { name: 'Collapse Explorer', exact: true }) + .click(); + await expect( + page.getByRole('button', { name: 'Show Explorer', exact: true }), + ).toBeFocused(); + } + const scope = page.getByRole('combobox', { + name: 'Exploration scope', + exact: true, + }); + const representation = page.getByRole('combobox', { + name: 'Exploration representation', + exact: true, + }); + await scope.focus(); + await expect(scope).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(representation).toBeFocused(); + await page.evaluate(() => document.fonts.ready); + await page.mouse.move(0, 0); + await expect(page).toHaveScreenshot( + `exploration-overview-${theme}-${width}.png`, + ); + await scope.selectOption('P::System'); + await expect(scope).toBeEnabled(); + await representation.selectOption('interconnection'); + await expect( + page.getByRole('group', { + name: 'part primary : Q::Motor', + exact: true, + }), + ).toBeVisible(); + await expect(representation).toBeEnabled(); + await scope.focus(); + await expect(scope).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(representation).toBeFocused(); + await page.mouse.move(0, 0); + await expect(page).toHaveScreenshot( + `exploration-structure-${theme}-${width}.png`, + ); + expect((await downloadProject(page)).bundle).toEqual(before); + }); + } +} + +for (const theme of ['light', 'dark']) + for (const width of [1440, 768]) { + test(`assembly exploration routing in ${theme} at ${width}`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + await assemblyExploration(page); + await page.getByRole('button', { name: 'Diagram', exact: true }).click(); + await page + .getByRole('button', { name: 'Appearance', exact: true }) + .click(); + const dialog = page.getByRole('dialog', { + name: 'Appearance', + exact: true, + }); + await dialog.getByLabel('Theme', { exact: true }).selectOption(theme); + await dialog.getByRole('button', { name: 'Done', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Appearance', exact: true }), + ).toBeFocused(); + if (width === 768) { + await page + .getByRole('button', { name: 'Collapse Explorer', exact: true }) + .click(); + await expect( + page.getByRole('button', { name: 'Show Explorer', exact: true }), + ).toBeFocused(); + } + await page.getByRole('button', { name: 'Fit view', exact: true }).click(); + await expect + .poll(async () => { + const g = await overviewRoutingGeometry(page); + return { hits: g.hits, count: g.labelCount }; + }) + .toEqual({ hits: [], count: 13 }); + await page.evaluate(() => document.fonts.ready); + await page.mouse.move(0, 0); + await expect(page).toHaveScreenshot( + `assembly-exploration-overview-${theme}-${width}.png`, + ); + const scope = page.getByRole('combobox', { + name: 'Exploration scope', + exact: true, + }); + await scope.selectOption('Assembly::Station'); + await expect(scope).toBeEnabled(); + const representation = page.getByRole('combobox', { + name: 'Exploration representation', + exact: true, + }); + await representation.selectOption('interconnection'); + await expect(representation).toBeEnabled(); + await expect( + page.getByRole('group', { name: 'part primary : Device', exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Fit view', exact: true }).click(); + await page.mouse.move(0, 0); + await expect(page).toHaveScreenshot( + `assembly-exploration-structure-${theme}-${width}.png`, + ); + }); + } + +for (const theme of ['light', 'dark']) + for (const width of [1440, 768]) { + test(`initial exploration retry in ${theme} at ${width}`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + await ready(page, { split: false }); + let requests = 0; + await page.route('**/vendor/opensysml/sysml-wasm.wasm', async (route) => { + requests++; + if (requests === 2) + await route.fulfill({ + status: 503, + body: 'Controlled initial exploration failure', + }); + else await route.continue(); + }); + await page.locator('input[type=file]').setInputFiles( + documents.map((file) => ({ + name: file.name, + mimeType: 'text/plain', + buffer: Buffer.from(file.text), + })), + ); + await expect( + page.getByText('OpenSysML browser assets are unavailable.', { + exact: true, + }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Diagram', exact: true }).click(); + await page + .getByRole('button', { name: 'Appearance', exact: true }) + .click(); + const dialog = page.getByRole('dialog', { + name: 'Appearance', + exact: true, + }); + await dialog.getByLabel('Theme', { exact: true }).selectOption(theme); + await dialog.getByRole('button', { name: 'Done', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Appearance', exact: true }), + ).toBeFocused(); + if (width === 768) + await page + .getByRole('button', { name: 'Collapse Explorer', exact: true }) + .click(); + await page + .getByRole('button', { name: 'Explore model', exact: true }) + .focus(); + await page.evaluate(() => document.fonts.ready); + await page.mouse.move(0, 0); + await page.keyboard.press('Escape'); + await expect(page.getByRole('tooltip')).not.toBeVisible(); + const retry = page.getByRole('button', { + name: 'Explore model', + exact: true, + }); + await retry.focus(); + await expect(retry).toBeFocused(); + await expect(page).toHaveScreenshot( + `initial-exploration-retry-${theme}-${width}.png`, + ); + }); + } + +for (const theme of ['light', 'dark']) { + test(`default rover architecture at narrow width in ${theme}`, async ({ + page, + }) => { + await page.setViewportSize({ width: 768, height: 900 }); + const evaluations = await observeEvaluations(page); + await page.goto('/app'); + await page + .getByRole('button', { name: 'Load example', exact: true }) + .click(); + await waitForRoverStartup(page); + await page.getByRole('button', { name: 'Appearance', exact: true }).click(); + const dialog = page.getByRole('dialog', { + name: 'Appearance', + exact: true, + }); + await dialog.getByLabel('Theme', { exact: true }).selectOption(theme); + await dialog.getByRole('button', { name: 'Done', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Appearance', exact: true }), + ).toBeFocused(); + await page + .getByRole('button', { name: 'Collapse Explorer', exact: true }) + .click(); + await expect( + page.getByRole('button', { name: 'Show Explorer', exact: true }), + ).toBeFocused(); + await page.mouse.move(0, 0); + await page.keyboard.press('Escape'); + await expect(page.getByRole('tooltip')).not.toBeVisible(); + await page + .getByRole('combobox', { name: 'Named view', exact: true }) + .focus(); + await page.getByRole('button', { name: 'Fit view', exact: true }).click(); + await page.evaluate(() => document.fonts.ready); + await page.mouse.move(0, 0); + await page.keyboard.press('Escape'); + await expect(page.getByRole('tooltip')).not.toBeVisible(); + const architecture = evaluations.findLast( + (result) => result.view === 'RoverViews::architecture', + )!; + await expect + .poll( + async () => + (await overviewRoutingGeometry(page, [], architecture.artifact)).hits, + ) + .toEqual([]); + await expect(page).toHaveScreenshot( + `default-rover-architecture-${theme}-768.png`, + ); + }); +} + +for (const theme of ['light', 'dark']) + for (const width of [1440, 768]) { + test(`rover guided behavior and traceability in ${theme} at ${width}`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + await page.goto('/app'); + await page + .getByRole('button', { name: 'Load example', exact: true }) + .click(); + await waitForRoverStartup(page); + await page + .getByRole('button', { name: 'Appearance', exact: true }) + .click(); + const dialog = page.getByRole('dialog', { + name: 'Appearance', + exact: true, + }); + await dialog.getByLabel('Theme', { exact: true }).selectOption(theme); + await dialog.getByRole('button', { name: 'Done', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Appearance', exact: true }), + ).toBeFocused(); + if (width === 768) + await page + .getByRole('button', { name: 'Collapse Explorer', exact: true }) + .click(); + const selector = page.getByRole('combobox', { + name: 'Named view', + exact: true, + }); + for (const view of ['mission', 'lifecycle', 'requirements']) { + await selector.selectOption('RoverViews::' + view); + await expect(selector).toBeEnabled(); + await page + .getByRole('button', { name: 'Fit view', exact: true }) + .click(); + await page.mouse.move(0, 0); + await page.keyboard.press('Escape'); + await expect(page.getByRole('tooltip')).not.toBeVisible(); + await selector.focus(); + if (view !== 'requirements') { + const unlabelled = + view === 'mission' + ? Array.from({ length: 8 }, (_, index) => `edge:${index}`) + : ['edge:0']; + await expect + .poll( + async () => + (await overviewRoutingGeometry(page, unlabelled)).hits, + ) + .toEqual([]); + } + await page.evaluate(() => document.fonts.ready); + await expect(page).toHaveScreenshot( + `rover-${view}-${theme}-${width}.png`, + ); + } + }); + } + +for (const theme of ['light', 'dark']) { + for (const width of [1440, 768]) { + for (const representation of [ + 'Requirements table', + 'Traceability matrix', + ]) { + test(`${representation} ${theme} ${width}`, async ({ page }) => { + await ready(page); + await importRequirementsTraceability(page); + await expect( + page.getByRole('button', { name: 'Download project', exact: true }), + ).toBeEnabled(); + await page + .getByRole('button', { name: 'Appearance', exact: true }) + .click(); + const dialog = page.getByRole('dialog', { + name: 'Appearance', + exact: true, + }); + await dialog.getByLabel('Theme', { exact: true }).selectOption(theme); + await dialog.getByRole('button', { name: 'Done', exact: true }).click(); + await page.setViewportSize({ width, height: 900 }); + await page + .getByRole('button', { name: representation, exact: true }) + .click(); + const panel = page.getByRole('region', { + name: representation, + exact: true, + }); + await expect(panel.getByRole('table')).toBeVisible(); + await expect( + panel.getByRole('button', { name: 'Export CSV', exact: true }), + ).toBeEnabled(); + await expect(page).toHaveScreenshot( + `${representation === 'Requirements table' ? 'requirements-table' : 'traceability-matrix'}-${theme}-${width}.png`, + ); + }); + } + } +} diff --git a/tests/e2e/visual.spec.ts-snapshots/appearance-narrow-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/appearance-narrow-visual-linux.png index 50a87d6..fa5b226 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/appearance-narrow-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/appearance-narrow-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/assembly-dark-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/assembly-dark-visual-linux.png index 7b917df..c729339 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/assembly-dark-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/assembly-dark-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-dark-1440-visual-linux.png new file mode 100644 index 0000000..b822dbe Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-dark-768-visual-linux.png new file mode 100644 index 0000000..05c5cd0 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-light-1440-visual-linux.png new file mode 100644 index 0000000..6d392e0 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-light-768-visual-linux.png new file mode 100644 index 0000000..abd356a Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-dark-1440-visual-linux.png new file mode 100644 index 0000000..386c0a3 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-dark-768-visual-linux.png new file mode 100644 index 0000000..64278db Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-light-1440-visual-linux.png new file mode 100644 index 0000000..0d9395e Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-light-768-visual-linux.png new file mode 100644 index 0000000..8320466 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/assembly-light-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/assembly-light-visual-linux.png index 425994e..fb8abc1 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/assembly-light-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/assembly-light-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/default-rover-architecture-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/default-rover-architecture-dark-768-visual-linux.png new file mode 100644 index 0000000..ad76338 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/default-rover-architecture-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/default-rover-architecture-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/default-rover-architecture-light-768-visual-linux.png new file mode 100644 index 0000000..a52cc55 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/default-rover-architecture-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/diagnostics-dark-narrow-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/diagnostics-dark-narrow-visual-linux.png index 313c2ff..67a4384 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/diagnostics-dark-narrow-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/diagnostics-dark-narrow-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/diagnostics-dark-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/diagnostics-dark-visual-linux.png index e656706..5852260 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/diagnostics-dark-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/diagnostics-dark-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/diagnostics-light-narrow-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/diagnostics-light-narrow-visual-linux.png index 4eaac3b..10797c5 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/diagnostics-light-narrow-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/diagnostics-light-narrow-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/diagnostics-light-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/diagnostics-light-visual-linux.png index 5faf932..c38abf0 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/diagnostics-light-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/diagnostics-light-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/exploration-overview-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/exploration-overview-dark-1440-visual-linux.png new file mode 100644 index 0000000..cd40ca0 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/exploration-overview-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/exploration-overview-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/exploration-overview-dark-768-visual-linux.png new file mode 100644 index 0000000..d44ad47 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/exploration-overview-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/exploration-overview-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/exploration-overview-light-1440-visual-linux.png new file mode 100644 index 0000000..9f79c60 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/exploration-overview-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/exploration-overview-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/exploration-overview-light-768-visual-linux.png new file mode 100644 index 0000000..d18df96 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/exploration-overview-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/exploration-structure-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/exploration-structure-dark-1440-visual-linux.png new file mode 100644 index 0000000..ae5fa23 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/exploration-structure-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/exploration-structure-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/exploration-structure-dark-768-visual-linux.png new file mode 100644 index 0000000..1ae4472 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/exploration-structure-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/exploration-structure-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/exploration-structure-light-1440-visual-linux.png new file mode 100644 index 0000000..f845603 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/exploration-structure-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/exploration-structure-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/exploration-structure-light-768-visual-linux.png new file mode 100644 index 0000000..0875cf7 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/exploration-structure-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-dark-1440-visual-linux.png new file mode 100644 index 0000000..a6ca753 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-dark-768-visual-linux.png new file mode 100644 index 0000000..0224d8e Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-light-1440-visual-linux.png new file mode 100644 index 0000000..40f66e3 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-light-768-visual-linux.png new file mode 100644 index 0000000..81f569d Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/launcher-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/launcher-dark-1440-visual-linux.png index e9ccf5f..2807f5f 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/launcher-dark-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/launcher-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/launcher-dark-390-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/launcher-dark-390-visual-linux.png index 538441b..aefd732 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/launcher-dark-390-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/launcher-dark-390-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/launcher-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/launcher-dark-768-visual-linux.png index 870d57a..fc62c1c 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/launcher-dark-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/launcher-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/launcher-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/launcher-light-1440-visual-linux.png index f40e087..e13622e 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/launcher-light-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/launcher-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/launcher-light-390-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/launcher-light-390-visual-linux.png index 9bf15c9..3e973bd 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/launcher-light-390-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/launcher-light-390-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/launcher-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/launcher-light-768-visual-linux.png index 17b84aa..6b92147 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/launcher-light-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/launcher-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/local-recovery-dark-narrow-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/local-recovery-dark-narrow-visual-linux.png index 0669a62..666b437 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/local-recovery-dark-narrow-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/local-recovery-dark-narrow-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/local-recovery-light-narrow-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/local-recovery-light-narrow-visual-linux.png index 842e192..0f55225 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/local-recovery-light-narrow-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/local-recovery-light-narrow-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/local-recovery-light-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/local-recovery-light-visual-linux.png index bfc6b2d..4dcdbfc 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/local-recovery-light-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/local-recovery-light-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/panels-dark-narrow-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/panels-dark-narrow-visual-linux.png index 160d88a..88e8704 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/panels-dark-narrow-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/panels-dark-narrow-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/panels-dark-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/panels-dark-visual-linux.png index d274296..a37e810 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/panels-dark-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/panels-dark-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/panels-light-narrow-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/panels-light-narrow-visual-linux.png index 39b411c..d454bd5 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/panels-light-narrow-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/panels-light-narrow-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/panels-light-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/panels-light-visual-linux.png index 5d06194..466c0ff 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/panels-light-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/panels-light-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/requirements-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/requirements-dark-1440-visual-linux.png index 618191d..aa4801b 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/requirements-dark-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/requirements-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/requirements-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/requirements-dark-768-visual-linux.png index bfaa763..fa0ff85 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/requirements-dark-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/requirements-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/requirements-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/requirements-light-1440-visual-linux.png index 87c2006..02278b9 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/requirements-light-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/requirements-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/requirements-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/requirements-light-768-visual-linux.png index 2abe9ae..d0aa7ea 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/requirements-light-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/requirements-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/requirements-overview-dark-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/requirements-overview-dark-visual-linux.png index 8847dee..bcc666d 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/requirements-overview-dark-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/requirements-overview-dark-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/requirements-overview-light-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/requirements-overview-light-visual-linux.png index ce44ad4..9fb1208 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/requirements-overview-light-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/requirements-overview-light-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/requirements-table-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/requirements-table-dark-1440-visual-linux.png new file mode 100644 index 0000000..1b34635 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/requirements-table-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/requirements-table-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/requirements-table-dark-768-visual-linux.png new file mode 100644 index 0000000..4de84b6 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/requirements-table-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/requirements-table-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/requirements-table-light-1440-visual-linux.png new file mode 100644 index 0000000..c593415 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/requirements-table-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/requirements-table-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/requirements-table-light-768-visual-linux.png new file mode 100644 index 0000000..3ee6696 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/requirements-table-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-dark-1440-visual-linux.png new file mode 100644 index 0000000..bdd65a1 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-dark-768-visual-linux.png new file mode 100644 index 0000000..c95c322 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-light-1440-visual-linux.png new file mode 100644 index 0000000..ee836a1 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-light-768-visual-linux.png new file mode 100644 index 0000000..1dbab2c Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-mission-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-mission-dark-1440-visual-linux.png new file mode 100644 index 0000000..37a534c Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/rover-mission-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-mission-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-mission-dark-768-visual-linux.png new file mode 100644 index 0000000..3ca9a20 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/rover-mission-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-mission-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-mission-light-1440-visual-linux.png new file mode 100644 index 0000000..3fe7526 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/rover-mission-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-mission-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-mission-light-768-visual-linux.png new file mode 100644 index 0000000..22697d7 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/rover-mission-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-requirements-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-requirements-dark-1440-visual-linux.png new file mode 100644 index 0000000..1b5d2ab Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/rover-requirements-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-requirements-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-requirements-dark-768-visual-linux.png new file mode 100644 index 0000000..b006461 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/rover-requirements-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-requirements-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-requirements-light-1440-visual-linux.png new file mode 100644 index 0000000..9249e83 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/rover-requirements-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-requirements-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-requirements-light-768-visual-linux.png new file mode 100644 index 0000000..520ed44 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/rover-requirements-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/selected-structure-dark-narrow-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/selected-structure-dark-narrow-visual-linux.png index c181fab..db8c584 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/selected-structure-dark-narrow-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/selected-structure-dark-narrow-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/selected-structure-dark-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/selected-structure-dark-visual-linux.png index 1b3c6e2..cac0ac8 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/selected-structure-dark-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/selected-structure-dark-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/selected-structure-light-narrow-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/selected-structure-light-narrow-visual-linux.png index 639c9e7..ebaf0db 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/selected-structure-light-narrow-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/selected-structure-light-narrow-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/selected-structure-light-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/selected-structure-light-visual-linux.png index b452082..8079a5d 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/selected-structure-light-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/selected-structure-light-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/source-editor-dark-narrow-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/source-editor-dark-narrow-visual-linux.png index 42f160c..0431b9a 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/source-editor-dark-narrow-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/source-editor-dark-narrow-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/source-editor-dark-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/source-editor-dark-visual-linux.png index 6838099..315352b 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/source-editor-dark-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/source-editor-dark-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/source-editor-light-narrow-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/source-editor-light-narrow-visual-linux.png index 5dd2074..2ac2d84 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/source-editor-light-narrow-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/source-editor-light-narrow-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/source-editor-light-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/source-editor-light-visual-linux.png index 13893da..fc0788e 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/source-editor-light-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/source-editor-light-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/symbol-information-dark-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/symbol-information-dark-visual-linux.png index 837612a..2d18d2b 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/symbol-information-dark-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/symbol-information-dark-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/symbol-information-light-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/symbol-information-light-visual-linux.png index 2ee5eab..9d56d5b 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/symbol-information-light-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/symbol-information-light-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-dark-1440-visual-linux.png new file mode 100644 index 0000000..7f103c8 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-dark-768-visual-linux.png new file mode 100644 index 0000000..d1a167c Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-light-1440-visual-linux.png new file mode 100644 index 0000000..ad7b83d Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-light-768-visual-linux.png new file mode 100644 index 0000000..97d5e0b Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/workbench-dark-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/workbench-dark-visual-linux.png index fd7c616..5a2a361 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/workbench-dark-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/workbench-dark-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/workbench-light-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/workbench-light-visual-linux.png index 34b6781..4a238a8 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/workbench-light-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/workbench-light-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/workbench-narrow-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/workbench-narrow-visual-linux.png index b9af34e..ab30f1c 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/workbench-narrow-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/workbench-narrow-visual-linux.png differ diff --git a/tests/e2e/workspace.spec.ts b/tests/e2e/workspace.spec.ts index 2dd9a62..07b3f1f 100644 --- a/tests/e2e/workspace.spec.ts +++ b/tests/e2e/workspace.spec.ts @@ -14,6 +14,7 @@ import { nodePosition, moveNode, downloadProject, + expectRenderedPositions, } from './support'; test('@critical loads real source-defined views and keeps the view stable when changing files', async ({ page, @@ -161,11 +162,11 @@ test('@critical downloads and reopens a project with the draft and diagram posit 'WorkshopViews::overview', 'WorkshopViews::compressor', ]); - expect( - other.bundle.presentations['WorkshopViews::compressor']?.positions[ - otherSymbol! - ], - ).toEqual(otherMoved); + await expectRenderedPositions( + page, + other.bundle, + 'WorkshopViews::compressor', + ); await openView(page, 'overview'); await expect.poll(() => nodePosition(node)).toEqual(moved); await fillSource( @@ -177,7 +178,8 @@ test('@critical downloads and reopens a project with the draft and diagram posit expect(bundle.documents[0]?.text).toBe(original); expect( bundle.presentations['WorkshopViews::overview']?.positions[symbol!], - ).toEqual(moved); + ).toBeDefined(); + await expectRenderedPositions(page, bundle, 'WorkshopViews::overview'); // Reopen into a fresh page so the old renderer cannot supply the position. await page.reload(); diff --git a/tests/fixtures/exploration-assembly-artifact.json b/tests/fixtures/exploration-assembly-artifact.json new file mode 100644 index 0000000..c48a005 --- /dev/null +++ b/tests/fixtures/exploration-assembly-artifact.json @@ -0,0 +1,355 @@ +{ + "edges": [ + { + "from": "n2", + "to": "n3", + "label": "input", + "kind": "reference", + "origin": { + "file": "assembly.sysml", + "startLine": 4, + "startCol": 9, + "endLine": 4, + "endCol": 32 + } + }, + { + "from": "n2", + "to": "n4", + "label": "output", + "kind": "reference", + "origin": { + "file": "assembly.sysml", + "startLine": 5, + "startCol": 9, + "endLine": 5, + "endCol": 34 + } + }, + { + "from": "n2", + "to": "n5", + "label": "service", + "kind": "reference", + "origin": { + "file": "assembly.sysml", + "startLine": 6, + "startCol": 9, + "endLine": 6, + "endCol": 37 + } + }, + { + "from": "n3", + "to": "n1", + "kind": "typing", + "origin": { + "file": "assembly.sysml", + "startLine": 4, + "startCol": 25, + "endLine": 4, + "endCol": 31 + } + }, + { + "from": "n4", + "to": "n1", + "kind": "typing", + "origin": { + "file": "assembly.sysml", + "startLine": 5, + "startCol": 27, + "endLine": 5, + "endCol": 33 + } + }, + { + "from": "n5", + "to": "n1", + "kind": "typing", + "origin": { + "file": "assembly.sysml", + "startLine": 6, + "startCol": 30, + "endLine": 6, + "endCol": 36 + } + }, + { + "from": "n7", + "to": "n8", + "label": "bay", + "kind": "composition", + "origin": { + "file": "assembly.sysml", + "startLine": 11, + "startCol": 9, + "endLine": 15, + "endCol": 10 + } + }, + { + "from": "n7", + "to": "n12", + "label": "monitor", + "kind": "reference", + "origin": { + "file": "assembly.sysml", + "startLine": 16, + "startCol": 9, + "endLine": 16, + "endCol": 35 + } + }, + { + "from": "n8", + "to": "n9", + "label": "primary", + "kind": "composition", + "origin": { + "file": "assembly.sysml", + "startLine": 12, + "startCol": 13, + "endLine": 12, + "endCol": 35 + } + }, + { + "from": "n8", + "to": "n10", + "label": "standby", + "kind": "composition", + "origin": { + "file": "assembly.sysml", + "startLine": 13, + "startCol": 13, + "endLine": 13, + "endCol": 35 + } + }, + { + "from": "n9", + "to": "n2", + "kind": "typing", + "origin": { + "file": "assembly.sysml", + "startLine": 12, + "startCol": 28, + "endLine": 12, + "endCol": 34 + } + }, + { + "from": "n10", + "to": "n2", + "kind": "typing", + "origin": { + "file": "assembly.sysml", + "startLine": 13, + "startCol": 28, + "endLine": 13, + "endCol": 34 + } + }, + { + "from": "n12", + "to": "n2", + "kind": "typing", + "origin": { + "file": "assembly.sysml", + "startLine": 16, + "startCol": 28, + "endLine": 16, + "endCol": 34 + } + } + ], + "kind": "tree", + "nodes": [ + { + "id": "n0", + "kind": "package", + "name": "Assembly", + "origin": { + "file": "assembly.sysml", + "startLine": 1, + "startCol": 1, + "endLine": 18, + "endCol": 2 + } + }, + { + "id": "n1", + "kind": "port def", + "name": "Signal", + "origin": { + "file": "assembly.sysml", + "startLine": 2, + "startCol": 5, + "endLine": 2, + "endCol": 21 + }, + "parent": "n0" + }, + { + "id": "n2", + "kind": "part def", + "name": "Device", + "origin": { + "file": "assembly.sysml", + "startLine": 3, + "startCol": 5, + "endLine": 7, + "endCol": 6 + }, + "parent": "n0" + }, + { + "id": "n3", + "kind": "port", + "name": "input", + "origin": { + "file": "assembly.sysml", + "startLine": 4, + "startCol": 9, + "endLine": 4, + "endCol": 32 + }, + "parent": "n2", + "type": "Signal" + }, + { + "id": "n4", + "kind": "port", + "name": "output", + "origin": { + "file": "assembly.sysml", + "startLine": 5, + "startCol": 9, + "endLine": 5, + "endCol": 34 + }, + "parent": "n2", + "type": "Signal" + }, + { + "id": "n5", + "kind": "port", + "name": "service", + "origin": { + "file": "assembly.sysml", + "startLine": 6, + "startCol": 9, + "endLine": 6, + "endCol": 37 + }, + "parent": "n2", + "type": "Signal" + }, + { + "id": "n6", + "kind": "action def", + "name": "Inspect", + "origin": { + "file": "assembly.sysml", + "startLine": 9, + "startCol": 5, + "endLine": 9, + "endCol": 24 + }, + "parent": "n0" + }, + { + "id": "n7", + "kind": "part def", + "name": "Station", + "origin": { + "file": "assembly.sysml", + "startLine": 10, + "startCol": 5, + "endLine": 17, + "endCol": 6 + }, + "parent": "n0" + }, + { + "id": "n8", + "kind": "part", + "name": "bay", + "origin": { + "file": "assembly.sysml", + "startLine": 11, + "startCol": 9, + "endLine": 15, + "endCol": 10 + }, + "parent": "n7" + }, + { + "id": "n9", + "kind": "part", + "name": "primary", + "origin": { + "file": "assembly.sysml", + "startLine": 12, + "startCol": 13, + "endLine": 12, + "endCol": 35 + }, + "parent": "n8", + "type": "Device" + }, + { + "id": "n10", + "kind": "part", + "name": "standby", + "origin": { + "file": "assembly.sysml", + "startLine": 13, + "startCol": 13, + "endLine": 13, + "endCol": 35 + }, + "parent": "n8", + "type": "Device" + }, + { + "id": "n11", + "kind": "connection", + "name": "handoff", + "origin": { + "file": "assembly.sysml", + "startLine": 14, + "startCol": 13, + "endLine": 14, + "endCol": 72 + }, + "parent": "n8" + }, + { + "id": "n12", + "kind": "part", + "name": "monitor", + "origin": { + "file": "assembly.sysml", + "startLine": 16, + "startCol": 9, + "endLine": 16, + "endCol": 35 + }, + "parent": "n7", + "type": "Device" + } + ], + "shown": [ + "parameter-symbols", + "port-symbols", + "compartments", + "edge-labels", + "keywords", + "multiplicities", + "typings", + "qualifiers" + ], + "stated": "Read-only exploration" +} diff --git a/tests/fixtures/opensysml-0.10-correspondence.json b/tests/fixtures/opensysml-0.10-correspondence.json new file mode 100644 index 0000000..fb36e89 --- /dev/null +++ b/tests/fixtures/opensysml-0.10-correspondence.json @@ -0,0 +1,4320 @@ +{ + "engine": "OpenSysML 0.10.0", + "commit": "08ef397841767b829e492bbecf6d723483b4421e", + "samples": { + "assembly": { + "documents": [ + { + "name": "model.sysml", + "text": "package Assembly {\n port def Signal;\n part def Device {\n in port input : Signal;\n out port output : Signal;\n inout port service : Signal;\n }\n // Outside the supported graphical authoring subset; retain in source.\n action def Inspect;\n part def Station {\n part bay {\n part primary : Device;\n part standby : Device;\n connection handoff connect primary.output to standby.input;\n }\n ref part monitor : Device;\n }\n}\n" + }, + { + "name": "views.sysml", + "text": "package AssemblyViews {\n private import StandardViewDefinitions::*;\n view structure : InterconnectionView { expose Assembly::Station; }\n view devices : InterconnectionView {\n expose Assembly::Station::bay;\n expose Assembly::Inspect;\n }\n}\n" + } + ], + "views": [ + { + "view": "AssemblyViews::structure", + "modelHash": "013d929f6dc1095cba642cfad3cdc9bd7d52e03884ee3d01b9aefcb1ae5fc741", + "kind": "interconnection", + "correspondence": [ + { + "symbol": "n0", + "subject": { + "id": "model.sysml@274:230", + "qualifiedName": "Assembly::Station", + "origin": { + "file": "model.sysml", + "offset": 274, + "length": 230, + "nameOffset": 283, + "nameLength": 7 + } + }, + "context": ["model.sysml@274:230"] + }, + { + "symbol": "n1", + "subject": { + "id": "model.sysml@301:162", + "qualifiedName": "Assembly::Station::bay", + "origin": { + "file": "model.sysml", + "offset": 301, + "length": 162, + "nameOffset": 306, + "nameLength": 3 + } + }, + "context": ["model.sysml@274:230", "model.sysml@301:162"] + }, + { + "symbol": "n2", + "subject": { + "id": "model.sysml@324:22", + "qualifiedName": "Assembly::Station::bay::primary", + "origin": { + "file": "model.sysml", + "offset": 324, + "length": 22, + "nameOffset": 329, + "nameLength": 7 + } + }, + "context": [ + "model.sysml@274:230", + "model.sysml@301:162", + "model.sysml@324:22" + ] + }, + { + "symbol": "n2.0", + "subject": { + "id": "model.sysml@70:23", + "qualifiedName": "Assembly::Device::input", + "origin": { + "file": "model.sysml", + "offset": 70, + "length": 23, + "nameOffset": 78, + "nameLength": 5 + } + }, + "context": [ + "model.sysml@274:230", + "model.sysml@301:162", + "model.sysml@324:22", + "model.sysml@70:23" + ] + }, + { + "symbol": "n2.1", + "subject": { + "id": "model.sysml@102:25", + "qualifiedName": "Assembly::Device::output", + "origin": { + "file": "model.sysml", + "offset": 102, + "length": 25, + "nameOffset": 111, + "nameLength": 6 + } + }, + "context": [ + "model.sysml@274:230", + "model.sysml@301:162", + "model.sysml@324:22", + "model.sysml@102:25" + ] + }, + { + "symbol": "n2.2", + "subject": { + "id": "model.sysml@136:28", + "qualifiedName": "Assembly::Device::service", + "origin": { + "file": "model.sysml", + "offset": 136, + "length": 28, + "nameOffset": 147, + "nameLength": 7 + } + }, + "context": [ + "model.sysml@274:230", + "model.sysml@301:162", + "model.sysml@324:22", + "model.sysml@136:28" + ] + }, + { + "symbol": "n3", + "subject": { + "id": "model.sysml@359:22", + "qualifiedName": "Assembly::Station::bay::standby", + "origin": { + "file": "model.sysml", + "offset": 359, + "length": 22, + "nameOffset": 364, + "nameLength": 7 + } + }, + "context": [ + "model.sysml@274:230", + "model.sysml@301:162", + "model.sysml@359:22" + ] + }, + { + "symbol": "n3.0", + "subject": { + "id": "model.sysml@70:23", + "qualifiedName": "Assembly::Device::input", + "origin": { + "file": "model.sysml", + "offset": 70, + "length": 23, + "nameOffset": 78, + "nameLength": 5 + } + }, + "context": [ + "model.sysml@274:230", + "model.sysml@301:162", + "model.sysml@359:22", + "model.sysml@70:23" + ] + }, + { + "symbol": "n3.1", + "subject": { + "id": "model.sysml@102:25", + "qualifiedName": "Assembly::Device::output", + "origin": { + "file": "model.sysml", + "offset": 102, + "length": 25, + "nameOffset": 111, + "nameLength": 6 + } + }, + "context": [ + "model.sysml@274:230", + "model.sysml@301:162", + "model.sysml@359:22", + "model.sysml@102:25" + ] + }, + { + "symbol": "n3.2", + "subject": { + "id": "model.sysml@136:28", + "qualifiedName": "Assembly::Device::service", + "origin": { + "file": "model.sysml", + "offset": 136, + "length": 28, + "nameOffset": 147, + "nameLength": 7 + } + }, + "context": [ + "model.sysml@274:230", + "model.sysml@301:162", + "model.sysml@359:22", + "model.sysml@136:28" + ] + }, + { + "symbol": "n4", + "subject": { + "id": "model.sysml@472:26", + "qualifiedName": "Assembly::Station::monitor", + "origin": { + "file": "model.sysml", + "offset": 472, + "length": 26, + "nameOffset": 481, + "nameLength": 7 + } + }, + "context": ["model.sysml@274:230", "model.sysml@472:26"] + }, + { + "symbol": "n4.0", + "subject": { + "id": "model.sysml@70:23", + "qualifiedName": "Assembly::Device::input", + "origin": { + "file": "model.sysml", + "offset": 70, + "length": 23, + "nameOffset": 78, + "nameLength": 5 + } + }, + "context": [ + "model.sysml@274:230", + "model.sysml@472:26", + "model.sysml@70:23" + ] + }, + { + "symbol": "n4.1", + "subject": { + "id": "model.sysml@102:25", + "qualifiedName": "Assembly::Device::output", + "origin": { + "file": "model.sysml", + "offset": 102, + "length": 25, + "nameOffset": 111, + "nameLength": 6 + } + }, + "context": [ + "model.sysml@274:230", + "model.sysml@472:26", + "model.sysml@102:25" + ] + }, + { + "symbol": "n4.2", + "subject": { + "id": "model.sysml@136:28", + "qualifiedName": "Assembly::Device::service", + "origin": { + "file": "model.sysml", + "offset": 136, + "length": 28, + "nameOffset": 147, + "nameLength": 7 + } + }, + "context": [ + "model.sysml@274:230", + "model.sysml@472:26", + "model.sysml@136:28" + ] + } + ], + "edges": [ + { + "from": "n2", + "to": "n3", + "fromPort": "n2.1", + "toPort": "n3.0", + "label": "handoff", + "kind": "connection" + } + ] + }, + { + "view": "AssemblyViews::devices", + "modelHash": "013d929f6dc1095cba642cfad3cdc9bd7d52e03884ee3d01b9aefcb1ae5fc741", + "kind": "interconnection", + "correspondence": [ + { + "symbol": "n0", + "subject": { + "id": "model.sysml@301:162", + "qualifiedName": "Assembly::Station::bay", + "origin": { + "file": "model.sysml", + "offset": 301, + "length": 162, + "nameOffset": 306, + "nameLength": 3 + } + }, + "context": ["model.sysml@301:162"] + }, + { + "symbol": "n1", + "subject": { + "id": "model.sysml@324:22", + "qualifiedName": "Assembly::Station::bay::primary", + "origin": { + "file": "model.sysml", + "offset": 324, + "length": 22, + "nameOffset": 329, + "nameLength": 7 + } + }, + "context": ["model.sysml@301:162", "model.sysml@324:22"] + }, + { + "symbol": "n1.0", + "subject": { + "id": "model.sysml@70:23", + "qualifiedName": "Assembly::Device::input", + "origin": { + "file": "model.sysml", + "offset": 70, + "length": 23, + "nameOffset": 78, + "nameLength": 5 + } + }, + "context": [ + "model.sysml@301:162", + "model.sysml@324:22", + "model.sysml@70:23" + ] + }, + { + "symbol": "n1.1", + "subject": { + "id": "model.sysml@102:25", + "qualifiedName": "Assembly::Device::output", + "origin": { + "file": "model.sysml", + "offset": 102, + "length": 25, + "nameOffset": 111, + "nameLength": 6 + } + }, + "context": [ + "model.sysml@301:162", + "model.sysml@324:22", + "model.sysml@102:25" + ] + }, + { + "symbol": "n1.2", + "subject": { + "id": "model.sysml@136:28", + "qualifiedName": "Assembly::Device::service", + "origin": { + "file": "model.sysml", + "offset": 136, + "length": 28, + "nameOffset": 147, + "nameLength": 7 + } + }, + "context": [ + "model.sysml@301:162", + "model.sysml@324:22", + "model.sysml@136:28" + ] + }, + { + "symbol": "n2", + "subject": { + "id": "model.sysml@359:22", + "qualifiedName": "Assembly::Station::bay::standby", + "origin": { + "file": "model.sysml", + "offset": 359, + "length": 22, + "nameOffset": 364, + "nameLength": 7 + } + }, + "context": ["model.sysml@301:162", "model.sysml@359:22"] + }, + { + "symbol": "n2.0", + "subject": { + "id": "model.sysml@70:23", + "qualifiedName": "Assembly::Device::input", + "origin": { + "file": "model.sysml", + "offset": 70, + "length": 23, + "nameOffset": 78, + "nameLength": 5 + } + }, + "context": [ + "model.sysml@301:162", + "model.sysml@359:22", + "model.sysml@70:23" + ] + }, + { + "symbol": "n2.1", + "subject": { + "id": "model.sysml@102:25", + "qualifiedName": "Assembly::Device::output", + "origin": { + "file": "model.sysml", + "offset": 102, + "length": 25, + "nameOffset": 111, + "nameLength": 6 + } + }, + "context": [ + "model.sysml@301:162", + "model.sysml@359:22", + "model.sysml@102:25" + ] + }, + { + "symbol": "n2.2", + "subject": { + "id": "model.sysml@136:28", + "qualifiedName": "Assembly::Device::service", + "origin": { + "file": "model.sysml", + "offset": 136, + "length": 28, + "nameOffset": 147, + "nameLength": 7 + } + }, + "context": [ + "model.sysml@301:162", + "model.sysml@359:22", + "model.sysml@136:28" + ] + } + ], + "edges": [ + { + "from": "n1", + "to": "n2", + "fromPort": "n1.1", + "toPort": "n2.0", + "label": "handoff", + "kind": "connection" + } + ] + } + ] + }, + "requirements-traceability": { + "documents": [ + { + "name": "requirements.sysml", + "text": "package TraceRequirements {\n private import TraceSystem::*;\n requirement def <'REQ-CAP'> MinimumCapacity {\n doc /* The storage tank shall provide at least ten units of usable\n capacity. This declared requirement and its satisfaction links\n are model content, not evidence that a test has passed. */\n subject tank : Tank;\n require constraint { tank.capacity >= 10.0 }\n }\n alias CapacityRule for MinimumCapacity;\n requirement <'REQ-LEFT'> leftSpecification {\n doc /* Capacity requirements for the left installation. */\n subject installation : Tank;\n requirement <'REQ-LEFT-CAP'> leftCapacity : CapacityRule {\n subject tank = TraceSystem::LeftPlant::tank;\n }\n }\n requirement <'REQ-RIGHT'> rightSpecification {\n doc /* Capacity requirements for the right installation. */\n subject installation : Tank;\n requirement <'REQ-RIGHT-CAP'> rightCapacity : CapacityRule {\n subject tank = TraceSystem::RightPlant::tank;\n }\n }\n requirement <'REQ-UNLINKED'> unlinkedCapacity : MinimumCapacity {\n subject tank = TraceSystem::spare;\n }\n requirement <'REQ-EXCLUDED'> excludedCapacity : MinimumCapacity {\n subject tank = TraceSystem::spare;\n }\n assert not satisfy excludedCapacity by TraceSystem::spare;\n satisfy leftSpecification.leftCapacity by TraceSystem::LeftPlant::tank;\n satisfy rightSpecification.rightCapacity by TraceSystem::RightPlant::tank;\n}\n" + }, + { + "name": "system.sysml", + "text": "package TraceSystem {\n part def Tank {\n attribute capacity : ScalarValues::Real;\n }\n package LeftPlant {\n part tank : Tank {\n attribute :>> capacity = 20.0;\n }\n }\n package RightPlant {\n part tank : Tank {\n attribute :>> capacity = 30.0;\n }\n }\n part spare : Tank {\n attribute :>> capacity = 40.0;\n }\n}\n" + }, + { + "name": "views.sysml", + "text": "package TraceViews {\n private import StandardViewDefinitions::*;\n view requirements : GeneralView {\n filter @SysML::RequirementDefinition or @SysML::RequirementUsage;\n expose TraceRequirements::MinimumCapacity;\n expose TraceRequirements::leftSpecification;\n expose TraceRequirements::rightSpecification;\n expose TraceRequirements::unlinkedCapacity;\n expose TraceRequirements::excludedCapacity;\n expose TraceRequirements::leftSpecification::leftCapacity;\n expose TraceRequirements::rightSpecification::rightCapacity;\n }\n view overview : GeneralView {\n expose TraceRequirements;\n expose TraceSystem;\n }\n}\n" + } + ], + "views": [ + { + "view": "TraceViews::requirements", + "modelHash": "a7a42d1e6521e4ed859929716561c3e04452b77fa0aa332e1c233d2b4e8d895f", + "kind": "requirement", + "correspondence": [ + { + "symbol": "n0", + "subject": { + "id": "requirements.sysml@67:360", + "qualifiedName": "TraceRequirements::MinimumCapacity", + "origin": { + "file": "requirements.sysml", + "offset": 67, + "length": 360, + "nameOffset": 95, + "nameLength": 15 + } + }, + "context": ["requirements.sysml@67:360"] + }, + { + "symbol": "n1", + "subject": { + "id": "requirements.sysml@476:288", + "qualifiedName": "TraceRequirements::leftSpecification", + "origin": { + "file": "requirements.sysml", + "offset": 476, + "length": 288, + "nameOffset": 501, + "nameLength": 17 + } + }, + "context": ["requirements.sysml@476:288"] + }, + { + "symbol": "n2", + "subject": { + "id": "requirements.sysml@769:294", + "qualifiedName": "TraceRequirements::rightSpecification", + "origin": { + "file": "requirements.sysml", + "offset": 769, + "length": 294, + "nameOffset": 795, + "nameLength": 18 + } + }, + "context": ["requirements.sysml@769:294"] + }, + { + "symbol": "n3", + "subject": { + "id": "requirements.sysml@1068:114", + "qualifiedName": "TraceRequirements::unlinkedCapacity", + "origin": { + "file": "requirements.sysml", + "offset": 1068, + "length": 114, + "nameOffset": 1097, + "nameLength": 16 + } + }, + "context": ["requirements.sysml@1068:114"] + }, + { + "symbol": "n4", + "subject": { + "id": "requirements.sysml@1187:114", + "qualifiedName": "TraceRequirements::excludedCapacity", + "origin": { + "file": "requirements.sysml", + "offset": 1187, + "length": 114, + "nameOffset": 1216, + "nameLength": 16 + } + }, + "context": ["requirements.sysml@1187:114"] + }, + { + "symbol": "n5", + "subject": { + "id": "requirements.sysml@633:125", + "qualifiedName": "TraceRequirements::leftSpecification::leftCapacity", + "origin": { + "file": "requirements.sysml", + "offset": 633, + "length": 125, + "nameOffset": 662, + "nameLength": 12 + } + }, + "context": ["requirements.sysml@633:125"] + }, + { + "symbol": "n6", + "subject": { + "id": "requirements.sysml@929:128", + "qualifiedName": "TraceRequirements::rightSpecification::rightCapacity", + "origin": { + "file": "requirements.sysml", + "offset": 929, + "length": 128, + "nameOffset": 959, + "nameLength": 13 + } + }, + "context": ["requirements.sysml@929:128"] + }, + { + "symbol": "n7", + "subject": { + "id": "system.sysml@322:64", + "qualifiedName": "TraceSystem::spare", + "origin": { + "file": "system.sysml", + "offset": 322, + "length": 64, + "nameOffset": 327, + "nameLength": 5 + } + }, + "context": ["system.sysml@322:64"] + }, + { + "symbol": "n8", + "subject": { + "id": "system.sysml@129:71", + "qualifiedName": "TraceSystem::LeftPlant::tank", + "origin": { + "file": "system.sysml", + "offset": 129, + "length": 71, + "nameOffset": 134, + "nameLength": 4 + } + }, + "context": ["system.sysml@129:71"] + }, + { + "symbol": "n9", + "subject": { + "id": "system.sysml@240:71", + "qualifiedName": "TraceSystem::RightPlant::tank", + "origin": { + "file": "system.sysml", + "offset": 240, + "length": 71, + "nameOffset": 245, + "nameLength": 4 + } + }, + "context": ["system.sysml@240:71"] + } + ] + }, + { + "view": "TraceViews::overview", + "modelHash": "a7a42d1e6521e4ed859929716561c3e04452b77fa0aa332e1c233d2b4e8d895f", + "kind": "tree", + "correspondence": [ + { + "symbol": "n0", + "subject": { + "id": "requirements.sysml@0:1521", + "qualifiedName": "TraceRequirements", + "origin": { + "file": "requirements.sysml", + "offset": 0, + "length": 1521, + "nameOffset": 8, + "nameLength": 17 + } + }, + "context": ["requirements.sysml@0:1521"] + }, + { + "symbol": "n1", + "subject": { + "id": "requirements.sysml@67:360", + "qualifiedName": "TraceRequirements::MinimumCapacity", + "origin": { + "file": "requirements.sysml", + "offset": 67, + "length": 360, + "nameOffset": 95, + "nameLength": 15 + } + }, + "context": [ + "requirements.sysml@0:1521", + "requirements.sysml@67:360" + ] + }, + { + "symbol": "n2", + "subject": { + "id": "requirements.sysml@348:20", + "qualifiedName": "TraceRequirements::MinimumCapacity::tank", + "origin": { + "file": "requirements.sysml", + "offset": 348, + "length": 20, + "nameOffset": 356, + "nameLength": 4 + } + }, + "context": [ + "requirements.sysml@0:1521", + "requirements.sysml@67:360", + "requirements.sysml@348:20" + ] + }, + { + "symbol": "n3", + "subject": { + "id": "requirements.sysml@377:44", + "origin": { + "file": "requirements.sysml", + "offset": 377, + "length": 44, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": [ + "requirements.sysml@0:1521", + "requirements.sysml@67:360", + "requirements.sysml@377:44" + ] + }, + { + "symbol": "n4", + "subject": { + "id": "requirements.sysml@476:288", + "qualifiedName": "TraceRequirements::leftSpecification", + "origin": { + "file": "requirements.sysml", + "offset": 476, + "length": 288, + "nameOffset": 501, + "nameLength": 17 + } + }, + "context": [ + "requirements.sysml@0:1521", + "requirements.sysml@476:288" + ] + }, + { + "symbol": "n5", + "subject": { + "id": "requirements.sysml@596:28", + "qualifiedName": "TraceRequirements::leftSpecification::installation", + "origin": { + "file": "requirements.sysml", + "offset": 596, + "length": 28, + "nameOffset": 604, + "nameLength": 12 + } + }, + "context": [ + "requirements.sysml@0:1521", + "requirements.sysml@476:288", + "requirements.sysml@596:28" + ] + }, + { + "symbol": "n6", + "subject": { + "id": "requirements.sysml@633:125", + "qualifiedName": "TraceRequirements::leftSpecification::leftCapacity", + "origin": { + "file": "requirements.sysml", + "offset": 633, + "length": 125, + "nameOffset": 662, + "nameLength": 12 + } + }, + "context": [ + "requirements.sysml@0:1521", + "requirements.sysml@476:288", + "requirements.sysml@633:125" + ] + }, + { + "symbol": "n7", + "subject": { + "id": "requirements.sysml@704:44", + "qualifiedName": "TraceRequirements::leftSpecification::leftCapacity::tank", + "origin": { + "file": "requirements.sysml", + "offset": 704, + "length": 44, + "nameOffset": 712, + "nameLength": 4 + } + }, + "context": [ + "requirements.sysml@0:1521", + "requirements.sysml@476:288", + "requirements.sysml@633:125", + "requirements.sysml@704:44" + ] + }, + { + "symbol": "n8", + "subject": { + "id": "requirements.sysml@769:294", + "qualifiedName": "TraceRequirements::rightSpecification", + "origin": { + "file": "requirements.sysml", + "offset": 769, + "length": 294, + "nameOffset": 795, + "nameLength": 18 + } + }, + "context": [ + "requirements.sysml@0:1521", + "requirements.sysml@769:294" + ] + }, + { + "symbol": "n9", + "subject": { + "id": "requirements.sysml@892:28", + "qualifiedName": "TraceRequirements::rightSpecification::installation", + "origin": { + "file": "requirements.sysml", + "offset": 892, + "length": 28, + "nameOffset": 900, + "nameLength": 12 + } + }, + "context": [ + "requirements.sysml@0:1521", + "requirements.sysml@769:294", + "requirements.sysml@892:28" + ] + }, + { + "symbol": "n10", + "subject": { + "id": "requirements.sysml@929:128", + "qualifiedName": "TraceRequirements::rightSpecification::rightCapacity", + "origin": { + "file": "requirements.sysml", + "offset": 929, + "length": 128, + "nameOffset": 959, + "nameLength": 13 + } + }, + "context": [ + "requirements.sysml@0:1521", + "requirements.sysml@769:294", + "requirements.sysml@929:128" + ] + }, + { + "symbol": "n11", + "subject": { + "id": "requirements.sysml@1002:45", + "qualifiedName": "TraceRequirements::rightSpecification::rightCapacity::tank", + "origin": { + "file": "requirements.sysml", + "offset": 1002, + "length": 45, + "nameOffset": 1010, + "nameLength": 4 + } + }, + "context": [ + "requirements.sysml@0:1521", + "requirements.sysml@769:294", + "requirements.sysml@929:128", + "requirements.sysml@1002:45" + ] + }, + { + "symbol": "n12", + "subject": { + "id": "requirements.sysml@1068:114", + "qualifiedName": "TraceRequirements::unlinkedCapacity", + "origin": { + "file": "requirements.sysml", + "offset": 1068, + "length": 114, + "nameOffset": 1097, + "nameLength": 16 + } + }, + "context": [ + "requirements.sysml@0:1521", + "requirements.sysml@1068:114" + ] + }, + { + "symbol": "n13", + "subject": { + "id": "requirements.sysml@1142:34", + "qualifiedName": "TraceRequirements::unlinkedCapacity::tank", + "origin": { + "file": "requirements.sysml", + "offset": 1142, + "length": 34, + "nameOffset": 1150, + "nameLength": 4 + } + }, + "context": [ + "requirements.sysml@0:1521", + "requirements.sysml@1068:114", + "requirements.sysml@1142:34" + ] + }, + { + "symbol": "n14", + "subject": { + "id": "requirements.sysml@1187:114", + "qualifiedName": "TraceRequirements::excludedCapacity", + "origin": { + "file": "requirements.sysml", + "offset": 1187, + "length": 114, + "nameOffset": 1216, + "nameLength": 16 + } + }, + "context": [ + "requirements.sysml@0:1521", + "requirements.sysml@1187:114" + ] + }, + { + "symbol": "n15", + "subject": { + "id": "requirements.sysml@1261:34", + "qualifiedName": "TraceRequirements::excludedCapacity::tank", + "origin": { + "file": "requirements.sysml", + "offset": 1261, + "length": 34, + "nameOffset": 1269, + "nameLength": 4 + } + }, + "context": [ + "requirements.sysml@0:1521", + "requirements.sysml@1187:114", + "requirements.sysml@1261:34" + ] + }, + { + "symbol": "n16", + "subject": { + "id": "requirements.sysml@1306:58", + "origin": { + "file": "requirements.sysml", + "offset": 1306, + "length": 58, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": [ + "requirements.sysml@0:1521", + "requirements.sysml@1306:58" + ] + }, + { + "symbol": "n17", + "subject": { + "id": "requirements.sysml@1369:71", + "origin": { + "file": "requirements.sysml", + "offset": 1369, + "length": 71, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": [ + "requirements.sysml@0:1521", + "requirements.sysml@1369:71" + ] + }, + { + "symbol": "n18", + "subject": { + "id": "requirements.sysml@1445:74", + "origin": { + "file": "requirements.sysml", + "offset": 1445, + "length": 74, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": [ + "requirements.sysml@0:1521", + "requirements.sysml@1445:74" + ] + }, + { + "symbol": "n19", + "subject": { + "id": "system.sysml@0:388", + "qualifiedName": "TraceSystem", + "origin": { + "file": "system.sysml", + "offset": 0, + "length": 388, + "nameOffset": 8, + "nameLength": 11 + } + }, + "context": ["system.sysml@0:388"] + }, + { + "symbol": "n20", + "subject": { + "id": "system.sysml@26:70", + "qualifiedName": "TraceSystem::Tank", + "origin": { + "file": "system.sysml", + "offset": 26, + "length": 70, + "nameOffset": 35, + "nameLength": 4 + } + }, + "context": ["system.sysml@0:388", "system.sysml@26:70"] + }, + { + "symbol": "n21", + "subject": { + "id": "system.sysml@50:40", + "qualifiedName": "TraceSystem::Tank::capacity", + "origin": { + "file": "system.sysml", + "offset": 50, + "length": 40, + "nameOffset": 60, + "nameLength": 8 + } + }, + "context": [ + "system.sysml@0:388", + "system.sysml@26:70", + "system.sysml@50:40" + ] + }, + { + "symbol": "n22", + "subject": { + "id": "system.sysml@101:105", + "qualifiedName": "TraceSystem::LeftPlant", + "origin": { + "file": "system.sysml", + "offset": 101, + "length": 105, + "nameOffset": 109, + "nameLength": 9 + } + }, + "context": ["system.sysml@0:388", "system.sysml@101:105"] + }, + { + "symbol": "n23", + "subject": { + "id": "system.sysml@129:71", + "qualifiedName": "TraceSystem::LeftPlant::tank", + "origin": { + "file": "system.sysml", + "offset": 129, + "length": 71, + "nameOffset": 134, + "nameLength": 4 + } + }, + "context": [ + "system.sysml@0:388", + "system.sysml@101:105", + "system.sysml@129:71" + ] + }, + { + "symbol": "n24", + "subject": { + "id": "system.sysml@160:30", + "qualifiedName": "TraceSystem::LeftPlant::tank::capacity", + "origin": { + "file": "system.sysml", + "offset": 160, + "length": 30, + "nameOffset": 174, + "nameLength": 8 + } + }, + "context": [ + "system.sysml@0:388", + "system.sysml@101:105", + "system.sysml@129:71", + "system.sysml@160:30" + ] + }, + { + "symbol": "n25", + "subject": { + "id": "system.sysml@211:106", + "qualifiedName": "TraceSystem::RightPlant", + "origin": { + "file": "system.sysml", + "offset": 211, + "length": 106, + "nameOffset": 219, + "nameLength": 10 + } + }, + "context": ["system.sysml@0:388", "system.sysml@211:106"] + }, + { + "symbol": "n26", + "subject": { + "id": "system.sysml@240:71", + "qualifiedName": "TraceSystem::RightPlant::tank", + "origin": { + "file": "system.sysml", + "offset": 240, + "length": 71, + "nameOffset": 245, + "nameLength": 4 + } + }, + "context": [ + "system.sysml@0:388", + "system.sysml@211:106", + "system.sysml@240:71" + ] + }, + { + "symbol": "n27", + "subject": { + "id": "system.sysml@271:30", + "qualifiedName": "TraceSystem::RightPlant::tank::capacity", + "origin": { + "file": "system.sysml", + "offset": 271, + "length": 30, + "nameOffset": 285, + "nameLength": 8 + } + }, + "context": [ + "system.sysml@0:388", + "system.sysml@211:106", + "system.sysml@240:71", + "system.sysml@271:30" + ] + }, + { + "symbol": "n28", + "subject": { + "id": "system.sysml@322:64", + "qualifiedName": "TraceSystem::spare", + "origin": { + "file": "system.sysml", + "offset": 322, + "length": 64, + "nameOffset": 327, + "nameLength": 5 + } + }, + "context": ["system.sysml@0:388", "system.sysml@322:64"] + }, + { + "symbol": "n29", + "subject": { + "id": "system.sysml@350:30", + "qualifiedName": "TraceSystem::spare::capacity", + "origin": { + "file": "system.sysml", + "offset": 350, + "length": 30, + "nameOffset": 364, + "nameLength": 8 + } + }, + "context": [ + "system.sysml@0:388", + "system.sysml@322:64", + "system.sysml@350:30" + ] + } + ] + } + ] + }, + "inspection-rover": { + "documents": [ + { + "name": "behavior.sysml", + "text": "package RoverBehavior {\n action def InspectRoute {\n doc /* Declared mission order; this diagram does not execute the rover. */\n first start;\n action checkBattery;\n action patrol;\n action detectObstacle;\n action captureInspection;\n action transmitReport;\n action returnToDock;\n action recharge;\n done;\n succession first start then checkBattery;\n succession first checkBattery then patrol;\n succession first patrol then detectObstacle;\n succession first detectObstacle then captureInspection;\n succession first captureInspection then transmitReport;\n succession first transmitReport then returnToDock;\n succession first returnToDock then recharge;\n succession first recharge then done;\n allocation perceptionResponsibility allocate detectObstacle to RoverSystem::rover.autonomy.controller;\n allocation imagingResponsibility allocate captureInspection to RoverSystem::rover.inspection.camera;\n allocation reportingResponsibility allocate transmitReport to RoverSystem::rover.inspection.radio;\n allocation tractionResponsibility allocate patrol to RoverSystem::rover.mobility.leftDrive;\n }\n state def OperatingMode {\n entry; then docked;\n state docked;\n state patrolling;\n state inspecting;\n state returning;\n state charging;\n state safeStop;\n transition dispatch first docked then patrolling;\n transition targetFound first patrolling then inspecting;\n transition inspectionComplete first inspecting then patrolling;\n transition lowReserve first patrolling then returning;\n transition dockReached first returning then charging;\n transition chargeComplete first charging then docked;\n transition obstacleDetected first patrolling then safeStop;\n transition pathClear first safeStop then patrolling;\n }\n}\n" + }, + { + "name": "components.sysml", + "text": "package RoverComponents {\n private import ScalarValues::*;\n private import RoverTypes::*;\n part def Battery {\n doc /* Rechargeable battery. Capacity and reserve are design targets. */\n attribute capacityWh : Real default = 480.0;\n attribute reservePercent : Real default = 20.0;\n out port supply : PowerPort;\n }\n part def Distribution {\n in port input : PowerPort;\n out port drive : PowerPort;\n out port compute : PowerPort;\n out port payload : PowerPort;\n }\n part def Actuator;\n part def Sensor;\n part def Drive :> Actuator {\n attribute maximumSpeedMps : Real default = 1.5;\n in port power : PowerPort;\n in port command : CommandPort;\n }\n part def Controller {\n attribute obstacleStopDistanceM : Real default = 0.8;\n in port power : PowerPort;\n in port perception : SensorPort;\n in port images : ImagePort;\n out port drive : CommandPort;\n out port status : TelemetryPort;\n }\n part def Lidar :> Sensor {\n attribute rangeM : Real default = 12.0;\n out port scan : SensorPort;\n }\n part def Camera :> Sensor {\n attribute resolutionMP : Real default = 12.0;\n in port power : PowerPort;\n out port image : ImagePort;\n }\n part def Radio {\n attribute rangeM : Real default = 300.0;\n in port telemetry : TelemetryPort;\n }\n}\n" + }, + { + "name": "requirements.sysml", + "text": "package RoverRequirements {\n private import ScalarValues::*;\n private import RoverComponents::*;\n requirement def <'RVR-001'> EnergyReserve {\n doc /* Keep at least 20 percent battery reserve for the return journey. */\n subject battery : Battery;\n require constraint { battery.reservePercent >= 20.0 }\n }\n requirement def <'RVR-002'> SafeSpeed {\n doc /* Each drive shall limit nominal indoor speed to 1.5 m/s. */\n subject drive : Drive;\n require constraint { drive.maximumSpeedMps <= 1.5 }\n }\n requirement def <'RVR-003'> ObstacleClearance {\n doc /* Stop at least 0.8 m before a detected obstacle. */\n subject controller : Controller;\n require constraint { controller.obstacleStopDistanceM >= 0.8 }\n }\n requirement def <'RVR-004'> InspectionQuality {\n doc /* Capture inspection images at a resolution of at least 12 MP. */\n subject camera : Camera;\n require constraint { camera.resolutionMP >= 12.0 }\n }\n requirement <'RVR-RESERVE'> reserve : EnergyReserve {\n doc /* Keep at least 20 percent battery reserve for the return journey. */\n subject battery = RoverSystem::rover.energy.battery;\n }\n requirement <'RVR-SPEED'> speed : SafeSpeed {\n doc /* Limit the left drive nominal indoor speed to 1.5 m/s. */\n subject drive = RoverSystem::rover.mobility.leftDrive;\n }\n requirement <'RVR-CLEAR'> clearance : ObstacleClearance {\n doc /* Stop at least 0.8 m before a detected obstacle. */\n subject controller = RoverSystem::rover.autonomy.controller;\n }\n requirement <'RVR-IMAGE'> imaging : InspectionQuality {\n doc /* Capture inspection images at a resolution of at least 12 MP. */\n subject camera = RoverSystem::rover.inspection.camera;\n }\n assert satisfy reserve by RoverSystem::rover.energy.battery;\n assert satisfy speed by RoverSystem::rover.mobility.leftDrive;\n assert satisfy clearance by RoverSystem::rover.autonomy.controller;\n assert satisfy imaging by RoverSystem::rover.inspection.camera;\n}\n" + }, + { + "name": "system.sysml", + "text": "package RoverSystem {\n private import RoverComponents::*;\n part def InspectionRover {\n doc /* A slow indoor inspection rover. Explore architecture, mission,\n operating states and declared requirement traceability. */\n part energy {\n part battery : Battery;\n part distribution : Distribution;\n connection batterySupply connect battery.supply to distribution.input;\n }\n part mobility {\n part leftDrive : Drive;\n part rightDrive : Drive;\n }\n part autonomy {\n part controller : Controller;\n part lidar : Lidar;\n connection obstacleScan connect lidar.scan to controller.perception;\n }\n part inspection {\n part camera : Camera;\n part radio : Radio;\n }\n connection leftPower connect energy.distribution.drive to mobility.leftDrive.power;\n connection rightPower connect energy.distribution.drive to mobility.rightDrive.power;\n connection computePower connect energy.distribution.compute to autonomy.controller.power;\n connection cameraPower connect energy.distribution.payload to inspection.camera.power;\n connection leftCommand connect autonomy.controller.drive to mobility.leftDrive.command;\n connection rightCommand connect autonomy.controller.drive to mobility.rightDrive.command;\n connection inspectionImages connect inspection.camera.image to autonomy.controller.images;\n connection missionTelemetry connect autonomy.controller.status to inspection.radio.telemetry;\n }\n part rover : InspectionRover;\n}\n" + }, + { + "name": "types.sysml", + "text": "package RoverTypes {\n item def ElectricalPower;\n item def DriveCommand;\n item def SensorFrame;\n item def InspectionImage;\n item def Telemetry;\n port def PowerPort { out item power : ElectricalPower; }\n port def CommandPort { out item command : DriveCommand; }\n port def SensorPort { out item scanData : SensorFrame; }\n port def ImagePort { out item image : InspectionImage; }\n port def TelemetryPort { out item report : Telemetry; }\n}\n" + }, + { + "name": "views.sysml", + "text": "package RoverViews {\n private import StandardViewDefinitions::*;\n view architecture : InterconnectionView { expose RoverSystem::InspectionRover; }\n view definitions : GeneralView {\n filter @SysML::PartDefinition or @SysML::PartUsage;\n expose RoverComponents::**;\n }\n view mission : ActionFlowView { expose RoverBehavior::InspectRoute; }\n view lifecycle : StateTransitionView { expose RoverBehavior::OperatingMode; }\n view requirements : GeneralView {\n filter @SysML::RequirementDefinition or @SysML::RequirementUsage;\n expose RoverRequirements::reserve;\n expose RoverRequirements::speed;\n expose RoverRequirements::clearance;\n expose RoverRequirements::imaging;\n }\n}\n" + } + ], + "views": [ + { + "view": "RoverViews::architecture", + "modelHash": "4da913c9d93b57864af4f4b555c3ed5669f390fd623e9a6bafa0c9943411401a", + "kind": "interconnection", + "correspondence": [ + { + "symbol": "n0", + "subject": { + "id": "system.sysml@65:1553", + "qualifiedName": "RoverSystem::InspectionRover", + "origin": { + "file": "system.sysml", + "offset": 65, + "length": 1553, + "nameOffset": 74, + "nameLength": 15 + } + }, + "context": ["system.sysml@65:1553"] + }, + { + "symbol": "n1", + "subject": { + "id": "system.sysml@252:188", + "qualifiedName": "RoverSystem::InspectionRover::energy", + "origin": { + "file": "system.sysml", + "offset": 252, + "length": 188, + "nameOffset": 257, + "nameLength": 6 + } + }, + "context": ["system.sysml@65:1553", "system.sysml@252:188"] + }, + { + "symbol": "n2", + "subject": { + "id": "system.sysml@278:23", + "qualifiedName": "RoverSystem::InspectionRover::energy::battery", + "origin": { + "file": "system.sysml", + "offset": 278, + "length": 23, + "nameOffset": 283, + "nameLength": 7 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@252:188", + "system.sysml@278:23" + ] + }, + { + "symbol": "n2.0", + "subject": { + "id": "components.sysml@317:28", + "qualifiedName": "RoverComponents::Battery::supply", + "origin": { + "file": "components.sysml", + "offset": 317, + "length": 28, + "nameOffset": 326, + "nameLength": 6 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@252:188", + "system.sysml@278:23", + "components.sysml@317:28" + ] + }, + { + "symbol": "n3", + "subject": { + "id": "system.sysml@314:33", + "qualifiedName": "RoverSystem::InspectionRover::energy::distribution", + "origin": { + "file": "system.sysml", + "offset": 314, + "length": 33, + "nameOffset": 319, + "nameLength": 12 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@252:188", + "system.sysml@314:33" + ] + }, + { + "symbol": "n3.0", + "subject": { + "id": "components.sysml@388:26", + "qualifiedName": "RoverComponents::Distribution::input", + "origin": { + "file": "components.sysml", + "offset": 388, + "length": 26, + "nameOffset": 396, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@252:188", + "system.sysml@314:33", + "components.sysml@388:26" + ] + }, + { + "symbol": "n3.1", + "subject": { + "id": "components.sysml@423:27", + "qualifiedName": "RoverComponents::Distribution::drive", + "origin": { + "file": "components.sysml", + "offset": 423, + "length": 27, + "nameOffset": 432, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@252:188", + "system.sysml@314:33", + "components.sysml@423:27" + ] + }, + { + "symbol": "n3.2", + "subject": { + "id": "components.sysml@459:29", + "qualifiedName": "RoverComponents::Distribution::compute", + "origin": { + "file": "components.sysml", + "offset": 459, + "length": 29, + "nameOffset": 468, + "nameLength": 7 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@252:188", + "system.sysml@314:33", + "components.sysml@459:29" + ] + }, + { + "symbol": "n3.3", + "subject": { + "id": "components.sysml@497:29", + "qualifiedName": "RoverComponents::Distribution::payload", + "origin": { + "file": "components.sysml", + "offset": 497, + "length": 29, + "nameOffset": 506, + "nameLength": 7 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@252:188", + "system.sysml@314:33", + "components.sysml@497:29" + ] + }, + { + "symbol": "n4", + "subject": { + "id": "system.sysml@449:98", + "qualifiedName": "RoverSystem::InspectionRover::mobility", + "origin": { + "file": "system.sysml", + "offset": 449, + "length": 98, + "nameOffset": 454, + "nameLength": 8 + } + }, + "context": ["system.sysml@65:1553", "system.sysml@449:98"] + }, + { + "symbol": "n5", + "subject": { + "id": "system.sysml@477:23", + "qualifiedName": "RoverSystem::InspectionRover::mobility::leftDrive", + "origin": { + "file": "system.sysml", + "offset": 477, + "length": 23, + "nameOffset": 482, + "nameLength": 9 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@449:98", + "system.sysml@477:23" + ] + }, + { + "symbol": "n5.0", + "subject": { + "id": "components.sysml@674:26", + "qualifiedName": "RoverComponents::Drive::power", + "origin": { + "file": "components.sysml", + "offset": 674, + "length": 26, + "nameOffset": 682, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@449:98", + "system.sysml@477:23", + "components.sysml@674:26" + ] + }, + { + "symbol": "n5.1", + "subject": { + "id": "components.sysml@709:30", + "qualifiedName": "RoverComponents::Drive::command", + "origin": { + "file": "components.sysml", + "offset": 709, + "length": 30, + "nameOffset": 717, + "nameLength": 7 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@449:98", + "system.sysml@477:23", + "components.sysml@709:30" + ] + }, + { + "symbol": "n6", + "subject": { + "id": "system.sysml@513:24", + "qualifiedName": "RoverSystem::InspectionRover::mobility::rightDrive", + "origin": { + "file": "system.sysml", + "offset": 513, + "length": 24, + "nameOffset": 518, + "nameLength": 10 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@449:98", + "system.sysml@513:24" + ] + }, + { + "symbol": "n6.0", + "subject": { + "id": "components.sysml@674:26", + "qualifiedName": "RoverComponents::Drive::power", + "origin": { + "file": "components.sysml", + "offset": 674, + "length": 26, + "nameOffset": 682, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@449:98", + "system.sysml@513:24", + "components.sysml@674:26" + ] + }, + { + "symbol": "n6.1", + "subject": { + "id": "components.sysml@709:30", + "qualifiedName": "RoverComponents::Drive::command", + "origin": { + "file": "components.sysml", + "offset": 709, + "length": 30, + "nameOffset": 717, + "nameLength": 7 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@449:98", + "system.sysml@513:24", + "components.sysml@709:30" + ] + }, + { + "symbol": "n7", + "subject": { + "id": "system.sysml@556:180", + "qualifiedName": "RoverSystem::InspectionRover::autonomy", + "origin": { + "file": "system.sysml", + "offset": 556, + "length": 180, + "nameOffset": 561, + "nameLength": 8 + } + }, + "context": ["system.sysml@65:1553", "system.sysml@556:180"] + }, + { + "symbol": "n8", + "subject": { + "id": "system.sysml@584:29", + "qualifiedName": "RoverSystem::InspectionRover::autonomy::controller", + "origin": { + "file": "system.sysml", + "offset": 584, + "length": 29, + "nameOffset": 589, + "nameLength": 10 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@556:180", + "system.sysml@584:29" + ] + }, + { + "symbol": "n8.0", + "subject": { + "id": "components.sysml@842:26", + "qualifiedName": "RoverComponents::Controller::power", + "origin": { + "file": "components.sysml", + "offset": 842, + "length": 26, + "nameOffset": 850, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@556:180", + "system.sysml@584:29", + "components.sysml@842:26" + ] + }, + { + "symbol": "n8.1", + "subject": { + "id": "components.sysml@877:32", + "qualifiedName": "RoverComponents::Controller::perception", + "origin": { + "file": "components.sysml", + "offset": 877, + "length": 32, + "nameOffset": 885, + "nameLength": 10 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@556:180", + "system.sysml@584:29", + "components.sysml@877:32" + ] + }, + { + "symbol": "n8.2", + "subject": { + "id": "components.sysml@918:27", + "qualifiedName": "RoverComponents::Controller::images", + "origin": { + "file": "components.sysml", + "offset": 918, + "length": 27, + "nameOffset": 926, + "nameLength": 6 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@556:180", + "system.sysml@584:29", + "components.sysml@918:27" + ] + }, + { + "symbol": "n8.3", + "subject": { + "id": "components.sysml@954:29", + "qualifiedName": "RoverComponents::Controller::drive", + "origin": { + "file": "components.sysml", + "offset": 954, + "length": 29, + "nameOffset": 963, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@556:180", + "system.sysml@584:29", + "components.sysml@954:29" + ] + }, + { + "symbol": "n8.4", + "subject": { + "id": "components.sysml@992:32", + "qualifiedName": "RoverComponents::Controller::status", + "origin": { + "file": "components.sysml", + "offset": 992, + "length": 32, + "nameOffset": 1001, + "nameLength": 6 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@556:180", + "system.sysml@584:29", + "components.sysml@992:32" + ] + }, + { + "symbol": "n9", + "subject": { + "id": "system.sysml@626:19", + "qualifiedName": "RoverSystem::InspectionRover::autonomy::lidar", + "origin": { + "file": "system.sysml", + "offset": 626, + "length": 19, + "nameOffset": 631, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@556:180", + "system.sysml@626:19" + ] + }, + { + "symbol": "n9.0", + "subject": { + "id": "components.sysml@1118:27", + "qualifiedName": "RoverComponents::Lidar::scan", + "origin": { + "file": "components.sysml", + "offset": 1118, + "length": 27, + "nameOffset": 1127, + "nameLength": 4 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@556:180", + "system.sysml@626:19", + "components.sysml@1118:27" + ] + }, + { + "symbol": "n10", + "subject": { + "id": "system.sysml@745:93", + "qualifiedName": "RoverSystem::InspectionRover::inspection", + "origin": { + "file": "system.sysml", + "offset": 745, + "length": 93, + "nameOffset": 750, + "nameLength": 10 + } + }, + "context": ["system.sysml@65:1553", "system.sysml@745:93"] + }, + { + "symbol": "n11", + "subject": { + "id": "system.sysml@775:21", + "qualifiedName": "RoverSystem::InspectionRover::inspection::camera", + "origin": { + "file": "system.sysml", + "offset": 775, + "length": 21, + "nameOffset": 780, + "nameLength": 6 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@745:93", + "system.sysml@775:21" + ] + }, + { + "symbol": "n11.0", + "subject": { + "id": "components.sysml@1246:26", + "qualifiedName": "RoverComponents::Camera::power", + "origin": { + "file": "components.sysml", + "offset": 1246, + "length": 26, + "nameOffset": 1254, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@745:93", + "system.sysml@775:21", + "components.sysml@1246:26" + ] + }, + { + "symbol": "n11.1", + "subject": { + "id": "components.sysml@1281:27", + "qualifiedName": "RoverComponents::Camera::image", + "origin": { + "file": "components.sysml", + "offset": 1281, + "length": 27, + "nameOffset": 1290, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@745:93", + "system.sysml@775:21", + "components.sysml@1281:27" + ] + }, + { + "symbol": "n12", + "subject": { + "id": "system.sysml@809:19", + "qualifiedName": "RoverSystem::InspectionRover::inspection::radio", + "origin": { + "file": "system.sysml", + "offset": 809, + "length": 19, + "nameOffset": 814, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@745:93", + "system.sysml@809:19" + ] + }, + { + "symbol": "n12.0", + "subject": { + "id": "components.sysml@1393:34", + "qualifiedName": "RoverComponents::Radio::telemetry", + "origin": { + "file": "components.sysml", + "offset": 1393, + "length": 34, + "nameOffset": 1401, + "nameLength": 9 + } + }, + "context": [ + "system.sysml@65:1553", + "system.sysml@745:93", + "system.sysml@809:19", + "components.sysml@1393:34" + ] + } + ], + "edges": [ + { + "from": "n2", + "to": "n3", + "fromPort": "n2.0", + "toPort": "n3.0", + "label": "batterySupply", + "kind": "connection" + }, + { + "from": "n9", + "to": "n8", + "fromPort": "n9.0", + "toPort": "n8.1", + "label": "obstacleScan", + "kind": "connection" + }, + { + "from": "n3", + "to": "n5", + "fromPort": "n3.1", + "toPort": "n5.0", + "label": "leftPower", + "kind": "connection" + }, + { + "from": "n3", + "to": "n6", + "fromPort": "n3.1", + "toPort": "n6.0", + "label": "rightPower", + "kind": "connection" + }, + { + "from": "n3", + "to": "n8", + "fromPort": "n3.2", + "toPort": "n8.0", + "label": "computePower", + "kind": "connection" + }, + { + "from": "n3", + "to": "n11", + "fromPort": "n3.3", + "toPort": "n11.0", + "label": "cameraPower", + "kind": "connection" + }, + { + "from": "n8", + "to": "n5", + "fromPort": "n8.3", + "toPort": "n5.1", + "label": "leftCommand", + "kind": "connection" + }, + { + "from": "n8", + "to": "n6", + "fromPort": "n8.3", + "toPort": "n6.1", + "label": "rightCommand", + "kind": "connection" + }, + { + "from": "n11", + "to": "n8", + "fromPort": "n11.1", + "toPort": "n8.2", + "label": "inspectionImages", + "kind": "connection" + }, + { + "from": "n8", + "to": "n12", + "fromPort": "n8.4", + "toPort": "n12.0", + "label": "missionTelemetry", + "kind": "connection" + } + ] + }, + { + "view": "RoverViews::mission", + "modelHash": "4da913c9d93b57864af4f4b555c3ed5669f390fd623e9a6bafa0c9943411401a", + "kind": "action", + "correspondence": [ + { + "symbol": "n0", + "subject": { + "id": "behavior.sysml@28:1217", + "qualifiedName": "RoverBehavior::InspectRoute", + "origin": { + "file": "behavior.sysml", + "offset": 28, + "length": 1217, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@28:1217"] + }, + { + "symbol": "n1", + "subject": { + "id": "behavior.sysml@145:12", + "qualifiedName": "RoverBehavior::InspectRoute::start", + "origin": { + "file": "behavior.sysml", + "offset": 145, + "length": 12, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@28:1217", "behavior.sysml@145:12"] + }, + { + "symbol": "n2", + "subject": { + "id": "behavior.sysml@166:20", + "qualifiedName": "RoverBehavior::InspectRoute::checkBattery", + "origin": { + "file": "behavior.sysml", + "offset": 166, + "length": 20, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@28:1217", "behavior.sysml@166:20"] + }, + { + "symbol": "n3", + "subject": { + "id": "behavior.sysml@195:14", + "qualifiedName": "RoverBehavior::InspectRoute::patrol", + "origin": { + "file": "behavior.sysml", + "offset": 195, + "length": 14, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@28:1217", "behavior.sysml@195:14"] + }, + { + "symbol": "n4", + "subject": { + "id": "behavior.sysml@218:22", + "qualifiedName": "RoverBehavior::InspectRoute::detectObstacle", + "origin": { + "file": "behavior.sysml", + "offset": 218, + "length": 22, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@28:1217", "behavior.sysml@218:22"] + }, + { + "symbol": "n5", + "subject": { + "id": "behavior.sysml@249:25", + "qualifiedName": "RoverBehavior::InspectRoute::captureInspection", + "origin": { + "file": "behavior.sysml", + "offset": 249, + "length": 25, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@28:1217", "behavior.sysml@249:25"] + }, + { + "symbol": "n6", + "subject": { + "id": "behavior.sysml@283:22", + "qualifiedName": "RoverBehavior::InspectRoute::transmitReport", + "origin": { + "file": "behavior.sysml", + "offset": 283, + "length": 22, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@28:1217", "behavior.sysml@283:22"] + }, + { + "symbol": "n7", + "subject": { + "id": "behavior.sysml@314:20", + "qualifiedName": "RoverBehavior::InspectRoute::returnToDock", + "origin": { + "file": "behavior.sysml", + "offset": 314, + "length": 20, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@28:1217", "behavior.sysml@314:20"] + }, + { + "symbol": "n8", + "subject": { + "id": "behavior.sysml@343:16", + "qualifiedName": "RoverBehavior::InspectRoute::recharge", + "origin": { + "file": "behavior.sysml", + "offset": 343, + "length": 16, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@28:1217", "behavior.sysml@343:16"] + }, + { + "symbol": "n9", + "subject": { + "id": "behavior.sysml@368:5", + "origin": { + "file": "behavior.sysml", + "offset": 368, + "length": 5, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@28:1217", "behavior.sysml@368:5"] + } + ] + }, + { + "view": "RoverViews::lifecycle", + "modelHash": "4da913c9d93b57864af4f4b555c3ed5669f390fd623e9a6bafa0c9943411401a", + "kind": "state", + "correspondence": [ + { + "symbol": "n0", + "subject": { + "id": "behavior.sysml@1250:717", + "qualifiedName": "RoverBehavior::OperatingMode", + "origin": { + "file": "behavior.sysml", + "offset": 1250, + "length": 717, + "nameOffset": 1260, + "nameLength": 13 + } + }, + "context": ["behavior.sysml@1250:717"] + }, + { + "symbol": "n7", + "subject": { + "id": "" + }, + "context": ["behavior.sysml@1250:717"] + }, + { + "symbol": "n1", + "subject": { + "id": "behavior.sysml@1312:13", + "qualifiedName": "RoverBehavior::OperatingMode::docked", + "origin": { + "file": "behavior.sysml", + "offset": 1312, + "length": 13, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@1250:717", "behavior.sysml@1312:13"] + }, + { + "symbol": "n2", + "subject": { + "id": "behavior.sysml@1334:17", + "qualifiedName": "RoverBehavior::OperatingMode::patrolling", + "origin": { + "file": "behavior.sysml", + "offset": 1334, + "length": 17, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@1250:717", "behavior.sysml@1334:17"] + }, + { + "symbol": "n3", + "subject": { + "id": "behavior.sysml@1360:17", + "qualifiedName": "RoverBehavior::OperatingMode::inspecting", + "origin": { + "file": "behavior.sysml", + "offset": 1360, + "length": 17, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@1250:717", "behavior.sysml@1360:17"] + }, + { + "symbol": "n4", + "subject": { + "id": "behavior.sysml@1386:16", + "qualifiedName": "RoverBehavior::OperatingMode::returning", + "origin": { + "file": "behavior.sysml", + "offset": 1386, + "length": 16, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@1250:717", "behavior.sysml@1386:16"] + }, + { + "symbol": "n5", + "subject": { + "id": "behavior.sysml@1411:15", + "qualifiedName": "RoverBehavior::OperatingMode::charging", + "origin": { + "file": "behavior.sysml", + "offset": 1411, + "length": 15, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@1250:717", "behavior.sysml@1411:15"] + }, + { + "symbol": "n6", + "subject": { + "id": "behavior.sysml@1435:15", + "qualifiedName": "RoverBehavior::OperatingMode::safeStop", + "origin": { + "file": "behavior.sysml", + "offset": 1435, + "length": 15, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@1250:717", "behavior.sysml@1435:15"] + } + ] + }, + { + "view": "RoverViews::requirements", + "modelHash": "4da913c9d93b57864af4f4b555c3ed5669f390fd623e9a6bafa0c9943411401a", + "kind": "requirement", + "correspondence": [ + { + "symbol": "n0", + "subject": { + "id": "requirements.sysml@1021:203", + "qualifiedName": "RoverRequirements::reserve", + "origin": { + "file": "requirements.sysml", + "offset": 1021, + "length": 203, + "nameOffset": 1049, + "nameLength": 7 + } + }, + "context": ["requirements.sysml@1021:203"] + }, + { + "symbol": "n1", + "subject": { + "id": "requirements.sysml@1229:186", + "qualifiedName": "RoverRequirements::speed", + "origin": { + "file": "requirements.sysml", + "offset": 1229, + "length": 186, + "nameOffset": 1255, + "nameLength": 5 + } + }, + "context": ["requirements.sysml@1229:186"] + }, + { + "symbol": "n2", + "subject": { + "id": "requirements.sysml@1420:198", + "qualifiedName": "RoverRequirements::clearance", + "origin": { + "file": "requirements.sysml", + "offset": 1420, + "length": 198, + "nameOffset": 1446, + "nameLength": 9 + } + }, + "context": ["requirements.sysml@1420:198"] + }, + { + "symbol": "n3", + "subject": { + "id": "requirements.sysml@1623:203", + "qualifiedName": "RoverRequirements::imaging", + "origin": { + "file": "requirements.sysml", + "offset": 1623, + "length": 203, + "nameOffset": 1649, + "nameLength": 7 + } + }, + "context": ["requirements.sysml@1623:203"] + }, + { + "symbol": "n4", + "subject": { + "id": "system.sysml@278:23", + "qualifiedName": "RoverSystem::InspectionRover::energy::battery", + "origin": { + "file": "system.sysml", + "offset": 278, + "length": 23, + "nameOffset": 283, + "nameLength": 7 + } + }, + "context": ["system.sysml@278:23"] + }, + { + "symbol": "n5", + "subject": { + "id": "system.sysml@477:23", + "qualifiedName": "RoverSystem::InspectionRover::mobility::leftDrive", + "origin": { + "file": "system.sysml", + "offset": 477, + "length": 23, + "nameOffset": 482, + "nameLength": 9 + } + }, + "context": ["system.sysml@477:23"] + }, + { + "symbol": "n6", + "subject": { + "id": "system.sysml@584:29", + "qualifiedName": "RoverSystem::InspectionRover::autonomy::controller", + "origin": { + "file": "system.sysml", + "offset": 584, + "length": 29, + "nameOffset": 589, + "nameLength": 10 + } + }, + "context": ["system.sysml@584:29"] + }, + { + "symbol": "n7", + "subject": { + "id": "system.sysml@775:21", + "qualifiedName": "RoverSystem::InspectionRover::inspection::camera", + "origin": { + "file": "system.sysml", + "offset": 775, + "length": 21, + "nameOffset": 780, + "nameLength": 6 + } + }, + "context": ["system.sysml@775:21"] + } + ] + } + ] + }, + "air-compressor": { + "documents": [ + { + "name": "behavior.sysml", + "text": "package CompressorBehavior {\n private import CompressorTypes::*;\n private import CompressorComponents::*;\n action def SensePressure { out item reading : PressureReading; }\n action def SenseTemperature { out item reading : TemperatureReading; }\n action def ControlMotor {\n in item reading : PressureReading;\n in item temperature : TemperatureReading;\n out item command : MotorCommand;\n }\n action def GenerateTorque { in item command : MotorCommand; }\n action def PumpAir { in item inlet : Air; out item outlet : Air; }\n action def CoolAir { in item inlet : Air; out item outlet : Air; }\n action def StoreAir { in item inlet : Air; out item outlet : Air; }\n action def DeliverAir { in item inlet : Air; }\n action def CompressAir {\n doc /* Normal supply scenario. Successions state order; item flows state\n transfers. Allocations are declared responsibilities, not execution. */\n ref part controller : Controller;\n ref part motor : Motor;\n ref part pumpUnit : Pump;\n ref part cooler : CoolingAssembly;\n ref part tank : Tank;\n ref part regulator : CheckValve;\n action sense : SensePressure;\n action temperature : SenseTemperature;\n action control : ControlMotor;\n action torque : GenerateTorque;\n action pump : PumpAir;\n action cool : CoolAir;\n action store : StoreAir;\n action deliver : DeliverAir;\n allocation sensing allocate sense to controller;\n allocation thermalSensing allocate temperature to controller;\n allocation regulation allocate control to controller;\n allocation turning allocate torque to motor;\n allocation compression allocate pump to pumpUnit;\n allocation cooling allocate cool to cooler;\n allocation storage allocate store to tank;\n allocation delivery allocate deliver to regulator;\n first start then sense;\n first sense then temperature;\n first temperature then control;\n first control then torque;\n first torque then pump;\n first pump then cool;\n first cool then store;\n first store then deliver;\n first deliver then done;\n flow from sense.reading to control.reading;\n flow from temperature.reading to control.temperature;\n flow from control.command to torque.command;\n flow from pump.outlet to cool.inlet;\n flow from cool.outlet to store.inlet;\n flow from store.outlet to deliver.inlet;\n }\n action def IsolatePower;\n action def ReleasePressure;\n action def InspectEquipment;\n action def ReplaceFilter;\n action def RestoreService;\n action def ServiceCompressor {\n doc /* Maintenance follows isolation and depressurization. No completion\n or safety verification result is claimed by this activity. */\n ref part technician : Technician;\n ref part controller : Controller;\n ref part relief : ReliefValve;\n action isolate : IsolatePower;\n action depressurize : ReleasePressure;\n action inspect : InspectEquipment;\n action replaceFilter : ReplaceFilter;\n action restore : RestoreService;\n allocation isolation allocate isolate to controller;\n allocation venting allocate depressurize to relief;\n allocation inspection allocate inspect to technician;\n allocation replacement allocate replaceFilter to technician;\n allocation restoration allocate restore to controller;\n first start then isolate;\n first isolate then depressurize;\n first depressurize then inspect;\n first inspect then replaceFilter;\n first replaceFilter then restore;\n first restore then done;\n }\n item def StartRequest;\n item def StartupChecked;\n item def TankCharged;\n item def ToolDemand;\n item def PressureLow;\n item def TemperatureHigh;\n item def TemperatureNormal;\n item def FaultDetected;\n item def ResetRequested;\n item def StopRequest;\n item def ServiceRequested;\n item def ServiceComplete;\n state def OperatingMode {\n entry; then stopped;\n state stopped;\n state starting;\n state charging;\n state ready;\n state supplying;\n state cooling;\n state fault;\n state maintenance;\n transition startCharging first stopped accept StartRequest then starting;\n transition startupComplete first starting accept StartupChecked then charging;\n transition chargingComplete first charging accept TankCharged then ready;\n transition supply first ready accept ToolDemand then supplying;\n transition recharge first supplying accept PressureLow then charging;\n transition coolDown first charging accept TemperatureHigh then cooling;\n transition cooled first cooling accept TemperatureNormal then ready;\n transition detectFault first charging accept FaultDetected then fault;\n transition coolingFault first cooling accept FaultDetected then fault;\n transition reset first fault accept ResetRequested then stopped;\n transition stop first ready accept StopRequest then stopped;\n transition stopSupply first supplying accept StopRequest then stopped;\n transition beginService first stopped accept ServiceRequested then maintenance;\n transition endService first maintenance accept ServiceComplete then stopped;\n }\n}\n" + }, + { + "name": "components.sysml", + "text": "package CompressorComponents {\n private import ScalarValues::*;\n private import CompressorTypes::*;\n part def PowerSupply {\n attribute ratedPower : Real default = 2200.0;\n out port supply : PowerOutlet;\n }\n part def Controller {\n attribute shutdownDuration : Real default = 0.25;\n in port pressure : ~PressureOutlet;\n in port temperature : ~TemperatureOutlet;\n in port service : ~CommandOutlet;\n out port command : CommandOutlet;\n out port fanCommand : CommandOutlet;\n out port alarm : AlarmOutlet;\n }\n part def Motor {\n attribute ratedPower : Real default = 1500.0;\n in port power : ~PowerOutlet;\n in port command : ~CommandOutlet;\n }\n part def Pump {\n in port inlet : ~AirOutlet;\n out port outlet : AirOutlet;\n out port temperature : TemperatureOutlet;\n }\n part def Tank {\n attribute volume : Real default = 0.05;\n attribute pressure : Real default = 0.0;\n in port inlet : ~AirOutlet;\n out port delivery : AirOutlet;\n out port relief : AirOutlet;\n out port pressureReading : PressureOutlet;\n }\n part def Sensor {\n attribute accuracy : Real default = 0.01;\n }\n part def PressureSensor :> Sensor {\n in port sample : ~PressureOutlet;\n out port reading : PressureOutlet;\n }\n part def TemperatureSensor :> Sensor {\n attribute temperature : Real default = 293.15;\n in port sample : ~TemperatureOutlet;\n out port reading : TemperatureOutlet;\n }\n part def Valve {\n in port inlet : ~AirOutlet;\n out port outlet : AirOutlet;\n }\n part def CheckValve :> Valve {\n attribute deliveryPressure : Real default = 600000.0;\n }\n part def ReliefValve :> Valve {\n attribute openingPressure : Real default = 800000.0;\n }\n part def CoolingFan {\n attribute ratedPower : Real default = 120.0;\n in port command : ~CommandOutlet;\n }\n part def CoolingAssembly {\n part fan : CoolingFan;\n part temperature : TemperatureSensor;\n }\n part def ServiceConsole {\n out port request : CommandOutlet;\n in port alarm : ~AlarmOutlet;\n }\n part def CompressorInterface {\n doc /* Black-box interface used in the workshop context.\n Compressor specializes this definition with internal assemblies. */\n in port power : ~PowerOutlet;\n out port air : AirOutlet;\n }\n part def Operator;\n part def Technician;\n part def AirTool { in port inlet : ~AirOutlet; }\n}\n" + }, + { + "name": "geometry.sysml", + "text": "package CompressorGeometry {\n doc /* Envelope dimensions and placement. The preview centers the Box on its coordinate-frame origin. */\n attribute workshopDatum : ISQ::CartesianSpatial3dCoordinateFrame {\n :>> mRefs = (SI::m, SI::m, SI::m);\n }\n item enclosure : ShapeItems::Box {\n :>> length = 120 [SI::cm];\n :>> width = 80 [SI::cm];\n :>> height = 90 [SI::cm];\n attribute coordinateFrame : ISQ::CartesianSpatial3dCoordinateFrame {\n :>> mRefs = (SI::m, SI::m, SI::m);\n :>> transformation : MeasurementReferences::CoordinateFramePlacement {\n :>> source = workshopDatum;\n :>> origin = (1.5, 0.0, 0.45) [workshopDatum];\n :>> basisDirections = ((0.0, 1.0, 0.0) [workshopDatum],\n (-1.0, 0.0, 0.0) [workshopDatum],\n (0.0, 0.0, 1.0) [workshopDatum]);\n }\n }\n }\n}\n" + }, + { + "name": "requirements.sysml", + "text": "package CompressorRequirements {\n private import ScalarValues::*;\n private import CompressorComponents::*;\n private import CompressorSystem::*;\n requirement def <'COMP-001'> SafePressure {\n doc /* Tank pressure shall not exceed 800000 Pa. */\n subject tank : Tank;\n attribute maximum : Real default = 800000.0;\n require constraint pressureBound { tank.pressure <= maximum }\n }\n requirement def <'COMP-002'> StorageCapacity {\n doc /* The tank shall provide at least 0.05 cubic metres of storage. */\n subject tank : Tank;\n attribute minimum : Real default = 0.05;\n require constraint capacityBound { tank.volume >= minimum }\n }\n requirement def <'COMP-003'> ThermalLimit {\n doc /* Normal operation shall remain below 353.15 K (80 degrees Celsius). */\n subject sensor : TemperatureSensor;\n attribute maximum : Real default = 353.15;\n require constraint thermalBound { sensor.temperature <= maximum }\n }\n requirement def <'COMP-004'> SafeShutdown {\n doc /* The controller shall stop the motor within 1 s of detecting a fault. */\n subject controller : Controller;\n attribute maximum : Real default = 1.0;\n require constraint shutdownBound { controller.shutdownDuration <= maximum }\n }\n requirement def <'COMP-005'> DeliveryPressure {\n doc /* The regulated delivery setting shall be at least 600000 Pa. */\n subject regulator : CheckValve;\n attribute minimum : Real default = 600000.0;\n require constraint deliveryBound { regulator.deliveryPressure >= minimum }\n }\n requirement def <'COMP-006'> PressureProtection {\n doc /* The mechanical relief valve shall open at or below 800000 Pa. */\n subject valve : ReliefValve;\n attribute maximum : Real default = 800000.0;\n require constraint reliefBound { valve.openingPressure <= maximum }\n }\n requirement workshopSpecification {\n doc /* Bounds and declared trace links. Satisfaction assertions and\n verification cases are model declarations, not test evidence. */\n subject compressor : Compressor;\n requirement pressure : SafePressure { subject tank = compressor.pneumatic.tank; }\n requirement capacity : StorageCapacity { subject tank = compressor.pneumatic.tank; }\n requirement temperature : ThermalLimit { subject sensor = compressor.cooling.temperature; }\n requirement shutdown : SafeShutdown { subject controller = compressor.electrical.controller; }\n requirement delivery : DeliveryPressure { subject regulator = compressor.pneumatic.regulator; }\n requirement relief : PressureProtection { subject valve = compressor.pneumatic.relief; }\n }\n assert satisfy workshopSpecification.pressure by CompressorSystem::workshop.compressor.pneumatic.tank;\n assert satisfy workshopSpecification.capacity by CompressorSystem::workshop.compressor.pneumatic.tank;\n assert satisfy workshopSpecification.temperature by CompressorSystem::workshop.compressor.cooling.temperature;\n assert satisfy workshopSpecification.shutdown by CompressorSystem::workshop.compressor.electrical.controller;\n assert satisfy workshopSpecification.delivery by CompressorSystem::workshop.compressor.pneumatic.regulator;\n assert satisfy workshopSpecification.relief by CompressorSystem::workshop.compressor.pneumatic.relief;\n verification def CheckPressure {\n subject compressor : Compressor;\n objective pressureCheck { verify workshopSpecification.pressure; }\n }\n verification def CheckShutdown {\n subject compressor : Compressor;\n objective shutdownCheck { verify workshopSpecification.shutdown; }\n }\n verification pressureTest : CheckPressure { subject compressor = CompressorSystem::workshop.compressor; }\n verification shutdownTest : CheckShutdown { subject compressor = CompressorSystem::workshop.compressor; }\n}\n" + }, + { + "name": "sequence.sysml", + "text": "package CompressorSequence {\n item def AirDelivery;\n item def AirDemand;\n item def FaultNotice;\n item def PowerCommand;\n item def PressureReading;\n item def ReadyNotice;\n item def ResetCommand;\n item def SensorQuery;\n item def StartCommand;\n item def StopCommand;\n item def TemperatureReading;\n\n occurrence def Startup {\n part operator {\n event occurrence startSent;\n then event occurrence readyReceived;\n }\n part controller {\n event occurrence startReceived;\n then event occurrence checkSent;\n then event occurrence temperatureReceived;\n then event occurrence energizeSent;\n then event occurrence feedbackReceived;\n then event occurrence readySent;\n }\n part sensor {\n event occurrence checkReceived;\n then event occurrence temperatureSent;\n }\n part motor {\n event occurrence energizeReceived;\n then event occurrence driveSent;\n }\n part pump {\n event occurrence driveReceived;\n then event occurrence feedbackSent;\n }\n message start of StartCommand from operator.startSent to controller.startReceived;\n message check of SensorQuery from controller.checkSent to sensor.checkReceived;\n message temperature of TemperatureReading from sensor.temperatureSent to controller.temperatureReceived;\n message energize of PowerCommand from controller.energizeSent to motor.energizeReceived;\n message drive of PowerCommand from motor.driveSent to pump.driveReceived;\n message feedback of PressureReading from pump.feedbackSent to controller.feedbackReceived;\n message ready of ReadyNotice from controller.readySent to operator.readyReceived;\n }\n\n occurrence def ToolDemand {\n part tool {\n event occurrence requestSent;\n then event occurrence deliverReceived;\n }\n part tank {\n event occurrence requestReceived;\n then event occurrence deliverSent;\n then event occurrence pressureSent;\n then event occurrence refillReceived;\n }\n part controller {\n event occurrence pressureReceived;\n then event occurrence rechargeSent;\n }\n part motor {\n event occurrence rechargeReceived;\n then event occurrence driveSent;\n }\n part pump {\n event occurrence driveReceived;\n then event occurrence refillSent;\n }\n message request of AirDemand from tool.requestSent to tank.requestReceived;\n message deliver of AirDelivery from tank.deliverSent to tool.deliverReceived;\n message pressure of PressureReading from tank.pressureSent to controller.pressureReceived;\n message recharge of PowerCommand from controller.rechargeSent to motor.rechargeReceived;\n message drive of PowerCommand from motor.driveSent to pump.driveReceived;\n message refill of AirDelivery from pump.refillSent to tank.refillReceived;\n }\n\n occurrence def OverheatShutdown {\n part sensor {\n event occurrence overheatSent;\n }\n part controller {\n event occurrence overheatReceived;\n then event occurrence stopMotorSent;\n then event occurrence coolSent;\n then event occurrence alarmSent;\n then event occurrence acknowledgeReceived;\n then event occurrence resetSent;\n }\n part motor {\n event occurrence stopMotorReceived;\n then event occurrence resetReceived;\n }\n part fan {\n event occurrence coolReceived;\n }\n part operator {\n event occurrence alarmReceived;\n then event occurrence acknowledgeSent;\n }\n message overheat of TemperatureReading from sensor.overheatSent to controller.overheatReceived;\n message stopMotor of StopCommand from controller.stopMotorSent to motor.stopMotorReceived;\n message cool of PowerCommand from controller.coolSent to fan.coolReceived;\n message alarm of FaultNotice from controller.alarmSent to operator.alarmReceived;\n message acknowledge of ResetCommand from operator.acknowledgeSent to controller.acknowledgeReceived;\n message reset of StopCommand from controller.resetSent to motor.resetReceived;\n }\n}\n" + }, + { + "name": "system.sysml", + "text": "package CompressorSystem {\n private import ScalarValues::*;\n private import CompressorTypes::*;\n private import CompressorComponents::*;\n private import CompressorBehavior::*;\n part def Compressor :> CompressorInterface {\n doc /* Smart compressor: electrical control, air storage and cooling.\n These are authored connections and constraints, not a simulation. */\n part electrical : ElectricalAssembly;\n part pneumatic : PneumaticAssembly;\n part cooling : CoolingAssembly;\n part serviceConsole : ServiceConsole;\n connection pressureFeedback connect pneumatic.pressureSensor.reading\n to electrical.controller.pressure;\n connection temperatureSample connect pneumatic.pump.temperature\n to cooling.temperature.sample;\n connection temperatureFeedback connect cooling.temperature.reading\n to electrical.controller.temperature;\n connection fanControl connect electrical.controller.fanCommand to cooling.fan.command;\n connection maintenanceRequest connect serviceConsole.request to electrical.controller.service;\n connection faultAlarm connect electrical.controller.alarm to serviceConsole.alarm;\n constraint powerBudget {\n electrical.motor.ratedPower + cooling.fan.ratedPower <= electrical.supply.ratedPower\n }\n action startup : CompressAir;\n action service : ServiceCompressor;\n state operatingMode : OperatingMode;\n }\n part def ElectricalAssembly {\n part supply : PowerSupply;\n part controller : Controller;\n part motor : Motor;\n connection motorPower connect supply.supply to motor.power;\n connection motorControl connect controller.command to motor.command;\n }\n part def PneumaticAssembly {\n part pump : Pump;\n part tank : Tank;\n part regulator : CheckValve;\n part relief : ReliefValve;\n part pressureSensor : PressureSensor;\n connection chargingLine connect pump.outlet to tank.inlet;\n connection regulatedDelivery connect tank.delivery to regulator.inlet;\n connection pressureProtection connect tank.relief to relief.inlet;\n connection pressureSample connect tank.pressureReading to pressureSensor.sample;\n }\n part def Workshop {\n part compressor : Compressor;\n part spareCompressor : Compressor;\n part tool : AirTool;\n part blowGun : AirTool;\n part operator : Operator;\n part technician : Technician;\n connection deliveryLine connect compressor.pneumatic.regulator.outlet to tool.inlet;\n connection spareDelivery connect spareCompressor.pneumatic.regulator.outlet to blowGun.inlet;\n }\n part def WorkshopContext {\n doc /* A black-box system context. The two compressor roles share the\n interface specialized by the detailed Compressor definition. */\n part mains : PowerSupply;\n part compressor : CompressorInterface;\n part tool : AirTool;\n part serviceConsole : ServiceConsole;\n connection electricalSupply connect mains.supply to compressor.power;\n connection usableAir connect compressor.air to tool.inlet;\n }\n part workshop : Workshop;\n}\n" + }, + { + "name": "types.sysml", + "text": "package CompressorTypes {\n doc /* Showcase quantities use documented SI units: pressure in Pa,\n volume in cubic metres, power in W, time in seconds, and temperature in kelvin. */\n item def Air;\n item def ElectricalPower;\n item def PressureReading;\n item def MotorCommand;\n item def TemperatureReading;\n item def Alarm;\n port def AirOutlet { out item air : Air; }\n port def PowerOutlet { out item electricity : ElectricalPower; }\n port def PressureOutlet { out item reading : PressureReading; }\n port def TemperatureOutlet { out item reading : TemperatureReading; }\n port def AlarmOutlet { out item alarm : Alarm; }\n port def CommandOutlet { out item command : MotorCommand; }\n}\n" + }, + { + "name": "use-cases.sysml", + "text": "package CompressorUseCases {\n private import CompressorComponents::*;\n private import CompressorSystem::*;\n use case def ChargeTank {\n subject compressor : Compressor;\n actor operator : Operator;\n objective { doc /* Build a usable supply of compressed air. */ }\n }\n use case def SupplyAir {\n subject compressor : Compressor;\n actor operator : Operator;\n objective { doc /* Supply regulated compressed air to workshop tools. */ }\n }\n use case def ProtectEquipment {\n subject compressor : Compressor;\n actor operator : Operator;\n objective { doc /* Stop the motor, cool the equipment and notify the operator on overheating. */ }\n }\n use case def RecoverFromFault {\n subject compressor : Compressor;\n actor operator : Operator;\n objective { doc /* Acknowledge the fault and return to a stopped state before restarting. */ }\n }\n use case def MaintainEquipment {\n subject compressor : Compressor;\n actor technician : Technician;\n objective { doc /* Isolate, depressurize, inspect and service the compressor. */ }\n }\n use case operateWorkshop {\n subject compressor : Compressor;\n actor operator : Operator;\n actor technician : Technician;\n objective { doc /* Keep workshop tools supplied with air while protecting equipment and enabling maintenance. */ }\n include use case charge : ChargeTank {\n subject compressor;\n actor operator = operateWorkshop::operator;\n }\n include use case supply : SupplyAir {\n subject compressor;\n actor operator = operateWorkshop::operator;\n }\n include use case protect : ProtectEquipment {\n subject compressor;\n actor operator = operateWorkshop::operator;\n }\n include use case recover : RecoverFromFault {\n subject compressor;\n actor operator = operateWorkshop::operator;\n }\n include use case service : MaintainEquipment {\n subject compressor;\n actor technician = operateWorkshop::technician;\n }\n }\n}\n" + }, + { + "name": "views.sysml", + "text": "package CompressorViews {\n private import StandardViewDefinitions::*;\n view def Structure :> InterconnectionView;\n view def Definitions :> GeneralView {\n filter @SysML::PartDefinition or @SysML::PartUsage;\n }\n view def Requirements :> GeneralView;\n view def Actions :> ActionFlowView;\n view def UseCases :> GeneralView;\n view def Sequence :> SequenceView;\n view def Geometry :> GeometryView;\n view def States :> StateTransitionView;\n view overview : Structure { expose CompressorSystem::WorkshopContext; }\n view structure : Structure { expose CompressorSystem::Compressor; }\n view installation : Structure { expose CompressorSystem::Workshop; }\n view pneumatics : Structure { expose CompressorSystem::PneumaticAssembly; }\n view definitions : Definitions { expose CompressorComponents::**; }\n view requirements : Requirements { expose CompressorRequirements::workshopSpecification; }\n view actions : Actions { expose CompressorBehavior::CompressAir; }\n view maintenance : Actions { expose CompressorBehavior::ServiceCompressor; }\n view useCases : UseCases {\n expose CompressorUseCases::operateWorkshop;\n render OpenSysMLRenderings::asCaseDiagram;\n }\n view states : States { expose CompressorBehavior::OperatingMode; }\n view startupSequence : Sequence { expose CompressorSequence::Startup; }\n view demandSequence : Sequence { expose CompressorSequence::ToolDemand; }\n view faultSequence : Sequence { expose CompressorSequence::OverheatShutdown; }\n view envelopeGeometry : Geometry { expose CompressorGeometry::enclosure; }\n}\n" + } + ], + "views": [ + { + "view": "CompressorViews::overview", + "modelHash": "f1c690da8fa143ca4304546dfbee51dc8483f8a01d1b29599445c6f731e5ef2e", + "kind": "interconnection", + "correspondence": [ + { + "symbol": "n0", + "subject": { + "id": "system.sysml@2749:490", + "qualifiedName": "CompressorSystem::WorkshopContext", + "origin": { + "file": "system.sysml", + "offset": 2749, + "length": 490, + "nameOffset": 2758, + "nameLength": 15 + } + }, + "context": ["system.sysml@2749:490"] + }, + { + "symbol": "n1", + "subject": { + "id": "system.sysml@2941:25", + "qualifiedName": "CompressorSystem::WorkshopContext::mains", + "origin": { + "file": "system.sysml", + "offset": 2941, + "length": 25, + "nameOffset": 2946, + "nameLength": 5 + } + }, + "context": ["system.sysml@2749:490", "system.sysml@2941:25"] + }, + { + "symbol": "n1.0", + "subject": { + "id": "components.sysml@195:30", + "qualifiedName": "CompressorComponents::PowerSupply::supply", + "origin": { + "file": "components.sysml", + "offset": 195, + "length": 30, + "nameOffset": 204, + "nameLength": 6 + } + }, + "context": [ + "system.sysml@2749:490", + "system.sysml@2941:25", + "components.sysml@195:30" + ] + }, + { + "symbol": "n2", + "subject": { + "id": "system.sysml@2975:38", + "qualifiedName": "CompressorSystem::WorkshopContext::compressor", + "origin": { + "file": "system.sysml", + "offset": 2975, + "length": 38, + "nameOffset": 2980, + "nameLength": 10 + } + }, + "context": ["system.sysml@2749:490", "system.sysml@2975:38"] + }, + { + "symbol": "n2.0", + "subject": { + "id": "components.sysml@2437:29", + "qualifiedName": "CompressorComponents::CompressorInterface::power", + "origin": { + "file": "components.sysml", + "offset": 2437, + "length": 29, + "nameOffset": 2445, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@2749:490", + "system.sysml@2975:38", + "components.sysml@2437:29" + ] + }, + { + "symbol": "n2.1", + "subject": { + "id": "components.sysml@2475:25", + "qualifiedName": "CompressorComponents::CompressorInterface::air", + "origin": { + "file": "components.sysml", + "offset": 2475, + "length": 25, + "nameOffset": 2484, + "nameLength": 3 + } + }, + "context": [ + "system.sysml@2749:490", + "system.sysml@2975:38", + "components.sysml@2475:25" + ] + }, + { + "symbol": "n3", + "subject": { + "id": "system.sysml@3022:20", + "qualifiedName": "CompressorSystem::WorkshopContext::tool", + "origin": { + "file": "system.sysml", + "offset": 3022, + "length": 20, + "nameOffset": 3027, + "nameLength": 4 + } + }, + "context": ["system.sysml@2749:490", "system.sysml@3022:20"] + }, + { + "symbol": "n3.0", + "subject": { + "id": "components.sysml@2578:27", + "qualifiedName": "CompressorComponents::AirTool::inlet", + "origin": { + "file": "components.sysml", + "offset": 2578, + "length": 27, + "nameOffset": 2586, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@2749:490", + "system.sysml@3022:20", + "components.sysml@2578:27" + ] + }, + { + "symbol": "n4", + "subject": { + "id": "system.sysml@3051:37", + "qualifiedName": "CompressorSystem::WorkshopContext::serviceConsole", + "origin": { + "file": "system.sysml", + "offset": 3051, + "length": 37, + "nameOffset": 3056, + "nameLength": 14 + } + }, + "context": ["system.sysml@2749:490", "system.sysml@3051:37"] + }, + { + "symbol": "n4.0", + "subject": { + "id": "components.sysml@2168:33", + "qualifiedName": "CompressorComponents::ServiceConsole::request", + "origin": { + "file": "components.sysml", + "offset": 2168, + "length": 33, + "nameOffset": 2177, + "nameLength": 7 + } + }, + "context": [ + "system.sysml@2749:490", + "system.sysml@3051:37", + "components.sysml@2168:33" + ] + }, + { + "symbol": "n4.1", + "subject": { + "id": "components.sysml@2210:29", + "qualifiedName": "CompressorComponents::ServiceConsole::alarm", + "origin": { + "file": "components.sysml", + "offset": 2210, + "length": 29, + "nameOffset": 2218, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@2749:490", + "system.sysml@3051:37", + "components.sysml@2210:29" + ] + } + ], + "edges": [ + { + "from": "n1", + "to": "n2", + "fromPort": "n1.0", + "toPort": "n2.0", + "label": "electricalSupply", + "kind": "connection" + }, + { + "from": "n2", + "to": "n3", + "fromPort": "n2.1", + "toPort": "n3.0", + "label": "usableAir", + "kind": "connection" + } + ] + }, + { + "view": "CompressorViews::structure", + "modelHash": "f1c690da8fa143ca4304546dfbee51dc8483f8a01d1b29599445c6f731e5ef2e", + "kind": "interconnection", + "correspondence": [ + { + "symbol": "n0", + "subject": { + "id": "system.sysml@192:1308", + "qualifiedName": "CompressorSystem::Compressor", + "origin": { + "file": "system.sysml", + "offset": 192, + "length": 1308, + "nameOffset": 201, + "nameLength": 10 + } + }, + "context": ["system.sysml@192:1308"] + }, + { + "symbol": "n0.0", + "subject": { + "id": "components.sysml@2437:29", + "qualifiedName": "CompressorComponents::CompressorInterface::power", + "origin": { + "file": "components.sysml", + "offset": 2437, + "length": 29, + "nameOffset": 2445, + "nameLength": 5 + } + }, + "context": ["system.sysml@192:1308", "components.sysml@2437:29"] + }, + { + "symbol": "n0.1", + "subject": { + "id": "components.sysml@2475:25", + "qualifiedName": "CompressorComponents::CompressorInterface::air", + "origin": { + "file": "components.sysml", + "offset": 2475, + "length": 25, + "nameOffset": 2484, + "nameLength": 3 + } + }, + "context": ["system.sysml@192:1308", "components.sysml@2475:25"] + }, + { + "symbol": "n1", + "subject": { + "id": "system.sysml@407:37", + "qualifiedName": "CompressorSystem::Compressor::electrical", + "origin": { + "file": "system.sysml", + "offset": 407, + "length": 37, + "nameOffset": 412, + "nameLength": 10 + } + }, + "context": ["system.sysml@192:1308", "system.sysml@407:37"] + }, + { + "symbol": "n2", + "subject": { + "id": "system.sysml@453:35", + "qualifiedName": "CompressorSystem::Compressor::pneumatic", + "origin": { + "file": "system.sysml", + "offset": 453, + "length": 35, + "nameOffset": 458, + "nameLength": 9 + } + }, + "context": ["system.sysml@192:1308", "system.sysml@453:35"] + }, + { + "symbol": "n3", + "subject": { + "id": "system.sysml@497:31", + "qualifiedName": "CompressorSystem::Compressor::cooling", + "origin": { + "file": "system.sysml", + "offset": 497, + "length": 31, + "nameOffset": 502, + "nameLength": 7 + } + }, + "context": ["system.sysml@192:1308", "system.sysml@497:31"] + }, + { + "symbol": "n4", + "subject": { + "id": "system.sysml@537:37", + "qualifiedName": "CompressorSystem::Compressor::serviceConsole", + "origin": { + "file": "system.sysml", + "offset": 537, + "length": 37, + "nameOffset": 542, + "nameLength": 14 + } + }, + "context": ["system.sysml@192:1308", "system.sysml@537:37"] + }, + { + "symbol": "n4.0", + "subject": { + "id": "components.sysml@2168:33", + "qualifiedName": "CompressorComponents::ServiceConsole::request", + "origin": { + "file": "components.sysml", + "offset": 2168, + "length": 33, + "nameOffset": 2177, + "nameLength": 7 + } + }, + "context": [ + "system.sysml@192:1308", + "system.sysml@537:37", + "components.sysml@2168:33" + ] + }, + { + "symbol": "n4.1", + "subject": { + "id": "components.sysml@2210:29", + "qualifiedName": "CompressorComponents::ServiceConsole::alarm", + "origin": { + "file": "components.sysml", + "offset": 2210, + "length": 29, + "nameOffset": 2218, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@192:1308", + "system.sysml@537:37", + "components.sysml@2210:29" + ] + } + ], + "edges": [ + { + "from": "n2", + "to": "n1", + "label": "pressureFeedback", + "kind": "connection" + }, + { + "from": "n2", + "to": "n3", + "label": "temperatureSample", + "kind": "connection" + }, + { + "from": "n3", + "to": "n1", + "label": "temperatureFeedback", + "kind": "connection" + }, + { + "from": "n1", + "to": "n3", + "label": "fanControl", + "kind": "connection" + }, + { + "from": "n4", + "to": "n1", + "fromPort": "n4.0", + "label": "maintenanceRequest", + "kind": "connection" + }, + { + "from": "n1", + "to": "n4", + "toPort": "n4.1", + "label": "faultAlarm", + "kind": "connection" + } + ] + }, + { + "view": "CompressorViews::installation", + "modelHash": "f1c690da8fa143ca4304546dfbee51dc8483f8a01d1b29599445c6f731e5ef2e", + "kind": "interconnection", + "correspondence": [ + { + "symbol": "n0", + "subject": { + "id": "system.sysml@2310:434", + "qualifiedName": "CompressorSystem::Workshop", + "origin": { + "file": "system.sysml", + "offset": 2310, + "length": 434, + "nameOffset": 2319, + "nameLength": 8 + } + }, + "context": ["system.sysml@2310:434"] + }, + { + "symbol": "n1", + "subject": { + "id": "system.sysml@2338:29", + "qualifiedName": "CompressorSystem::Workshop::compressor", + "origin": { + "file": "system.sysml", + "offset": 2338, + "length": 29, + "nameOffset": 2343, + "nameLength": 10 + } + }, + "context": ["system.sysml@2310:434", "system.sysml@2338:29"] + }, + { + "symbol": "n1.0", + "subject": { + "id": "components.sysml@2437:29", + "qualifiedName": "CompressorComponents::CompressorInterface::power", + "origin": { + "file": "components.sysml", + "offset": 2437, + "length": 29, + "nameOffset": 2445, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@2310:434", + "system.sysml@2338:29", + "components.sysml@2437:29" + ] + }, + { + "symbol": "n1.1", + "subject": { + "id": "components.sysml@2475:25", + "qualifiedName": "CompressorComponents::CompressorInterface::air", + "origin": { + "file": "components.sysml", + "offset": 2475, + "length": 25, + "nameOffset": 2484, + "nameLength": 3 + } + }, + "context": [ + "system.sysml@2310:434", + "system.sysml@2338:29", + "components.sysml@2475:25" + ] + }, + { + "symbol": "n2", + "subject": { + "id": "system.sysml@2376:34", + "qualifiedName": "CompressorSystem::Workshop::spareCompressor", + "origin": { + "file": "system.sysml", + "offset": 2376, + "length": 34, + "nameOffset": 2381, + "nameLength": 15 + } + }, + "context": ["system.sysml@2310:434", "system.sysml@2376:34"] + }, + { + "symbol": "n2.0", + "subject": { + "id": "components.sysml@2437:29", + "qualifiedName": "CompressorComponents::CompressorInterface::power", + "origin": { + "file": "components.sysml", + "offset": 2437, + "length": 29, + "nameOffset": 2445, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@2310:434", + "system.sysml@2376:34", + "components.sysml@2437:29" + ] + }, + { + "symbol": "n2.1", + "subject": { + "id": "components.sysml@2475:25", + "qualifiedName": "CompressorComponents::CompressorInterface::air", + "origin": { + "file": "components.sysml", + "offset": 2475, + "length": 25, + "nameOffset": 2484, + "nameLength": 3 + } + }, + "context": [ + "system.sysml@2310:434", + "system.sysml@2376:34", + "components.sysml@2475:25" + ] + }, + { + "symbol": "n3", + "subject": { + "id": "system.sysml@2419:20", + "qualifiedName": "CompressorSystem::Workshop::tool", + "origin": { + "file": "system.sysml", + "offset": 2419, + "length": 20, + "nameOffset": 2424, + "nameLength": 4 + } + }, + "context": ["system.sysml@2310:434", "system.sysml@2419:20"] + }, + { + "symbol": "n3.0", + "subject": { + "id": "components.sysml@2578:27", + "qualifiedName": "CompressorComponents::AirTool::inlet", + "origin": { + "file": "components.sysml", + "offset": 2578, + "length": 27, + "nameOffset": 2586, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@2310:434", + "system.sysml@2419:20", + "components.sysml@2578:27" + ] + }, + { + "symbol": "n4", + "subject": { + "id": "system.sysml@2448:23", + "qualifiedName": "CompressorSystem::Workshop::blowGun", + "origin": { + "file": "system.sysml", + "offset": 2448, + "length": 23, + "nameOffset": 2453, + "nameLength": 7 + } + }, + "context": ["system.sysml@2310:434", "system.sysml@2448:23"] + }, + { + "symbol": "n4.0", + "subject": { + "id": "components.sysml@2578:27", + "qualifiedName": "CompressorComponents::AirTool::inlet", + "origin": { + "file": "components.sysml", + "offset": 2578, + "length": 27, + "nameOffset": 2586, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@2310:434", + "system.sysml@2448:23", + "components.sysml@2578:27" + ] + }, + { + "symbol": "n5", + "subject": { + "id": "system.sysml@2480:25", + "qualifiedName": "CompressorSystem::Workshop::operator", + "origin": { + "file": "system.sysml", + "offset": 2480, + "length": 25, + "nameOffset": 2485, + "nameLength": 8 + } + }, + "context": ["system.sysml@2310:434", "system.sysml@2480:25"] + }, + { + "symbol": "n6", + "subject": { + "id": "system.sysml@2514:29", + "qualifiedName": "CompressorSystem::Workshop::technician", + "origin": { + "file": "system.sysml", + "offset": 2514, + "length": 29, + "nameOffset": 2519, + "nameLength": 10 + } + }, + "context": ["system.sysml@2310:434", "system.sysml@2514:29"] + } + ], + "edges": [ + { + "from": "n1", + "to": "n3", + "toPort": "n3.0", + "label": "deliveryLine", + "kind": "connection" + }, + { + "from": "n2", + "to": "n4", + "toPort": "n4.0", + "label": "spareDelivery", + "kind": "connection" + } + ] + }, + { + "view": "CompressorViews::pneumatics", + "modelHash": "f1c690da8fa143ca4304546dfbee51dc8483f8a01d1b29599445c6f731e5ef2e", + "kind": "interconnection", + "correspondence": [ + { + "symbol": "n0", + "subject": { + "id": "system.sysml@1791:514", + "qualifiedName": "CompressorSystem::PneumaticAssembly", + "origin": { + "file": "system.sysml", + "offset": 1791, + "length": 514, + "nameOffset": 1800, + "nameLength": 17 + } + }, + "context": ["system.sysml@1791:514"] + }, + { + "symbol": "n1", + "subject": { + "id": "system.sysml@1828:17", + "qualifiedName": "CompressorSystem::PneumaticAssembly::pump", + "origin": { + "file": "system.sysml", + "offset": 1828, + "length": 17, + "nameOffset": 1833, + "nameLength": 4 + } + }, + "context": ["system.sysml@1791:514", "system.sysml@1828:17"] + }, + { + "symbol": "n1.0", + "subject": { + "id": "components.sysml@772:27", + "qualifiedName": "CompressorComponents::Pump::inlet", + "origin": { + "file": "components.sysml", + "offset": 772, + "length": 27, + "nameOffset": 780, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@1791:514", + "system.sysml@1828:17", + "components.sysml@772:27" + ] + }, + { + "symbol": "n1.1", + "subject": { + "id": "components.sysml@808:28", + "qualifiedName": "CompressorComponents::Pump::outlet", + "origin": { + "file": "components.sysml", + "offset": 808, + "length": 28, + "nameOffset": 817, + "nameLength": 6 + } + }, + "context": [ + "system.sysml@1791:514", + "system.sysml@1828:17", + "components.sysml@808:28" + ] + }, + { + "symbol": "n1.2", + "subject": { + "id": "components.sysml@845:41", + "qualifiedName": "CompressorComponents::Pump::temperature", + "origin": { + "file": "components.sysml", + "offset": 845, + "length": 41, + "nameOffset": 854, + "nameLength": 11 + } + }, + "context": [ + "system.sysml@1791:514", + "system.sysml@1828:17", + "components.sysml@845:41" + ] + }, + { + "symbol": "n2", + "subject": { + "id": "system.sysml@1854:17", + "qualifiedName": "CompressorSystem::PneumaticAssembly::tank", + "origin": { + "file": "system.sysml", + "offset": 1854, + "length": 17, + "nameOffset": 1859, + "nameLength": 4 + } + }, + "context": ["system.sysml@1791:514", "system.sysml@1854:17"] + }, + { + "symbol": "n2.0", + "subject": { + "id": "components.sysml@1018:27", + "qualifiedName": "CompressorComponents::Tank::inlet", + "origin": { + "file": "components.sysml", + "offset": 1018, + "length": 27, + "nameOffset": 1026, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@1791:514", + "system.sysml@1854:17", + "components.sysml@1018:27" + ] + }, + { + "symbol": "n2.1", + "subject": { + "id": "components.sysml@1054:30", + "qualifiedName": "CompressorComponents::Tank::delivery", + "origin": { + "file": "components.sysml", + "offset": 1054, + "length": 30, + "nameOffset": 1063, + "nameLength": 8 + } + }, + "context": [ + "system.sysml@1791:514", + "system.sysml@1854:17", + "components.sysml@1054:30" + ] + }, + { + "symbol": "n2.2", + "subject": { + "id": "components.sysml@1093:28", + "qualifiedName": "CompressorComponents::Tank::relief", + "origin": { + "file": "components.sysml", + "offset": 1093, + "length": 28, + "nameOffset": 1102, + "nameLength": 6 + } + }, + "context": [ + "system.sysml@1791:514", + "system.sysml@1854:17", + "components.sysml@1093:28" + ] + }, + { + "symbol": "n2.3", + "subject": { + "id": "components.sysml@1130:42", + "qualifiedName": "CompressorComponents::Tank::pressureReading", + "origin": { + "file": "components.sysml", + "offset": 1130, + "length": 42, + "nameOffset": 1139, + "nameLength": 15 + } + }, + "context": [ + "system.sysml@1791:514", + "system.sysml@1854:17", + "components.sysml@1130:42" + ] + }, + { + "symbol": "n3", + "subject": { + "id": "system.sysml@1880:28", + "qualifiedName": "CompressorSystem::PneumaticAssembly::regulator", + "origin": { + "file": "system.sysml", + "offset": 1880, + "length": 28, + "nameOffset": 1885, + "nameLength": 9 + } + }, + "context": ["system.sysml@1791:514", "system.sysml@1880:28"] + }, + { + "symbol": "n3.0", + "subject": { + "id": "components.sysml@1612:27", + "qualifiedName": "CompressorComponents::Valve::inlet", + "origin": { + "file": "components.sysml", + "offset": 1612, + "length": 27, + "nameOffset": 1620, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@1791:514", + "system.sysml@1880:28", + "components.sysml@1612:27" + ] + }, + { + "symbol": "n3.1", + "subject": { + "id": "components.sysml@1648:28", + "qualifiedName": "CompressorComponents::Valve::outlet", + "origin": { + "file": "components.sysml", + "offset": 1648, + "length": 28, + "nameOffset": 1657, + "nameLength": 6 + } + }, + "context": [ + "system.sysml@1791:514", + "system.sysml@1880:28", + "components.sysml@1648:28" + ] + }, + { + "symbol": "n4", + "subject": { + "id": "system.sysml@1917:26", + "qualifiedName": "CompressorSystem::PneumaticAssembly::relief", + "origin": { + "file": "system.sysml", + "offset": 1917, + "length": 26, + "nameOffset": 1922, + "nameLength": 6 + } + }, + "context": ["system.sysml@1791:514", "system.sysml@1917:26"] + }, + { + "symbol": "n4.0", + "subject": { + "id": "components.sysml@1612:27", + "qualifiedName": "CompressorComponents::Valve::inlet", + "origin": { + "file": "components.sysml", + "offset": 1612, + "length": 27, + "nameOffset": 1620, + "nameLength": 5 + } + }, + "context": [ + "system.sysml@1791:514", + "system.sysml@1917:26", + "components.sysml@1612:27" + ] + }, + { + "symbol": "n4.1", + "subject": { + "id": "components.sysml@1648:28", + "qualifiedName": "CompressorComponents::Valve::outlet", + "origin": { + "file": "components.sysml", + "offset": 1648, + "length": 28, + "nameOffset": 1657, + "nameLength": 6 + } + }, + "context": [ + "system.sysml@1791:514", + "system.sysml@1917:26", + "components.sysml@1648:28" + ] + }, + { + "symbol": "n5", + "subject": { + "id": "system.sysml@1952:37", + "qualifiedName": "CompressorSystem::PneumaticAssembly::pressureSensor", + "origin": { + "file": "system.sysml", + "offset": 1952, + "length": 37, + "nameOffset": 1957, + "nameLength": 14 + } + }, + "context": ["system.sysml@1791:514", "system.sysml@1952:37"] + }, + { + "symbol": "n5.0", + "subject": { + "id": "components.sysml@1305:33", + "qualifiedName": "CompressorComponents::PressureSensor::sample", + "origin": { + "file": "components.sysml", + "offset": 1305, + "length": 33, + "nameOffset": 1313, + "nameLength": 6 + } + }, + "context": [ + "system.sysml@1791:514", + "system.sysml@1952:37", + "components.sysml@1305:33" + ] + }, + { + "symbol": "n5.1", + "subject": { + "id": "components.sysml@1347:34", + "qualifiedName": "CompressorComponents::PressureSensor::reading", + "origin": { + "file": "components.sysml", + "offset": 1347, + "length": 34, + "nameOffset": 1356, + "nameLength": 7 + } + }, + "context": [ + "system.sysml@1791:514", + "system.sysml@1952:37", + "components.sysml@1347:34" + ] + } + ], + "edges": [ + { + "from": "n1", + "to": "n2", + "fromPort": "n1.1", + "toPort": "n2.0", + "label": "chargingLine", + "kind": "connection" + }, + { + "from": "n2", + "to": "n3", + "fromPort": "n2.1", + "toPort": "n3.0", + "label": "regulatedDelivery", + "kind": "connection" + }, + { + "from": "n2", + "to": "n4", + "fromPort": "n2.2", + "toPort": "n4.0", + "label": "pressureProtection", + "kind": "connection" + }, + { + "from": "n2", + "to": "n5", + "fromPort": "n2.3", + "toPort": "n5.0", + "label": "pressureSample", + "kind": "connection" + } + ] + }, + { + "view": "CompressorViews::requirements", + "modelHash": "f1c690da8fa143ca4304546dfbee51dc8483f8a01d1b29599445c6f731e5ef2e", + "kind": "tree", + "correspondence": [ + { + "symbol": "n0", + "subject": { + "id": "requirements.sysml@1946:825", + "qualifiedName": "CompressorRequirements::workshopSpecification", + "origin": { + "file": "requirements.sysml", + "offset": 1946, + "length": 825, + "nameOffset": 1958, + "nameLength": 21 + } + }, + "context": ["requirements.sysml@1946:825"] + }, + { + "symbol": "n1", + "subject": { + "id": "requirements.sysml@2146:32", + "qualifiedName": "CompressorRequirements::workshopSpecification::compressor", + "origin": { + "file": "requirements.sysml", + "offset": 2146, + "length": 32, + "nameOffset": 2154, + "nameLength": 10 + } + }, + "context": [ + "requirements.sysml@1946:825", + "requirements.sysml@2146:32" + ] + }, + { + "symbol": "n2", + "subject": { + "id": "requirements.sysml@2187:81", + "qualifiedName": "CompressorRequirements::workshopSpecification::pressure", + "origin": { + "file": "requirements.sysml", + "offset": 2187, + "length": 81, + "nameOffset": 2199, + "nameLength": 8 + } + }, + "context": [ + "requirements.sysml@1946:825", + "requirements.sysml@2187:81" + ] + }, + { + "symbol": "n3", + "subject": { + "id": "requirements.sysml@2225:41", + "qualifiedName": "CompressorRequirements::workshopSpecification::pressure::tank", + "origin": { + "file": "requirements.sysml", + "offset": 2225, + "length": 41, + "nameOffset": 2233, + "nameLength": 4 + } + }, + "context": [ + "requirements.sysml@1946:825", + "requirements.sysml@2187:81", + "requirements.sysml@2225:41" + ] + }, + { + "symbol": "n4", + "subject": { + "id": "requirements.sysml@2277:84", + "qualifiedName": "CompressorRequirements::workshopSpecification::capacity", + "origin": { + "file": "requirements.sysml", + "offset": 2277, + "length": 84, + "nameOffset": 2289, + "nameLength": 8 + } + }, + "context": [ + "requirements.sysml@1946:825", + "requirements.sysml@2277:84" + ] + }, + { + "symbol": "n5", + "subject": { + "id": "requirements.sysml@2318:41", + "qualifiedName": "CompressorRequirements::workshopSpecification::capacity::tank", + "origin": { + "file": "requirements.sysml", + "offset": 2318, + "length": 41, + "nameOffset": 2326, + "nameLength": 4 + } + }, + "context": [ + "requirements.sysml@1946:825", + "requirements.sysml@2277:84", + "requirements.sysml@2318:41" + ] + }, + { + "symbol": "n6", + "subject": { + "id": "requirements.sysml@2370:91", + "qualifiedName": "CompressorRequirements::workshopSpecification::temperature", + "origin": { + "file": "requirements.sysml", + "offset": 2370, + "length": 91, + "nameOffset": 2382, + "nameLength": 11 + } + }, + "context": [ + "requirements.sysml@1946:825", + "requirements.sysml@2370:91" + ] + }, + { + "symbol": "n7", + "subject": { + "id": "requirements.sysml@2411:48", + "qualifiedName": "CompressorRequirements::workshopSpecification::temperature::sensor", + "origin": { + "file": "requirements.sysml", + "offset": 2411, + "length": 48, + "nameOffset": 2419, + "nameLength": 6 + } + }, + "context": [ + "requirements.sysml@1946:825", + "requirements.sysml@2370:91", + "requirements.sysml@2411:48" + ] + }, + { + "symbol": "n8", + "subject": { + "id": "requirements.sysml@2470:94", + "qualifiedName": "CompressorRequirements::workshopSpecification::shutdown", + "origin": { + "file": "requirements.sysml", + "offset": 2470, + "length": 94, + "nameOffset": 2482, + "nameLength": 8 + } + }, + "context": [ + "requirements.sysml@1946:825", + "requirements.sysml@2470:94" + ] + }, + { + "symbol": "n9", + "subject": { + "id": "requirements.sysml@2508:54", + "qualifiedName": "CompressorRequirements::workshopSpecification::shutdown::controller", + "origin": { + "file": "requirements.sysml", + "offset": 2508, + "length": 54, + "nameOffset": 2516, + "nameLength": 10 + } + }, + "context": [ + "requirements.sysml@1946:825", + "requirements.sysml@2470:94", + "requirements.sysml@2508:54" + ] + }, + { + "symbol": "n10", + "subject": { + "id": "requirements.sysml@2573:95", + "qualifiedName": "CompressorRequirements::workshopSpecification::delivery", + "origin": { + "file": "requirements.sysml", + "offset": 2573, + "length": 95, + "nameOffset": 2585, + "nameLength": 8 + } + }, + "context": [ + "requirements.sysml@1946:825", + "requirements.sysml@2573:95" + ] + }, + { + "symbol": "n11", + "subject": { + "id": "requirements.sysml@2615:51", + "qualifiedName": "CompressorRequirements::workshopSpecification::delivery::regulator", + "origin": { + "file": "requirements.sysml", + "offset": 2615, + "length": 51, + "nameOffset": 2623, + "nameLength": 9 + } + }, + "context": [ + "requirements.sysml@1946:825", + "requirements.sysml@2573:95", + "requirements.sysml@2615:51" + ] + }, + { + "symbol": "n12", + "subject": { + "id": "requirements.sysml@2677:88", + "qualifiedName": "CompressorRequirements::workshopSpecification::relief", + "origin": { + "file": "requirements.sysml", + "offset": 2677, + "length": 88, + "nameOffset": 2689, + "nameLength": 6 + } + }, + "context": [ + "requirements.sysml@1946:825", + "requirements.sysml@2677:88" + ] + }, + { + "symbol": "n13", + "subject": { + "id": "requirements.sysml@2719:44", + "qualifiedName": "CompressorRequirements::workshopSpecification::relief::valve", + "origin": { + "file": "requirements.sysml", + "offset": 2719, + "length": 44, + "nameOffset": 2727, + "nameLength": 5 + } + }, + "context": [ + "requirements.sysml@1946:825", + "requirements.sysml@2677:88", + "requirements.sysml@2719:44" + ] + } + ] + }, + { + "view": "CompressorViews::actions", + "modelHash": "f1c690da8fa143ca4304546dfbee51dc8483f8a01d1b29599445c6f731e5ef2e", + "kind": "action", + "correspondence": [ + { + "symbol": "n0", + "subject": { + "id": "behavior.sysml@761:1790", + "qualifiedName": "CompressorBehavior::CompressAir", + "origin": { + "file": "behavior.sysml", + "offset": 761, + "length": 1790, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@761:1790"] + }, + { + "symbol": "n1", + "subject": { + "id": "behavior.sysml@1184:29", + "qualifiedName": "CompressorBehavior::CompressAir::sense", + "origin": { + "file": "behavior.sysml", + "offset": 1184, + "length": 29, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@761:1790", "behavior.sysml@1184:29"] + }, + { + "symbol": "n1.0", + "subject": { + "id": "behavior.sysml@143:35", + "qualifiedName": "CompressorBehavior::SensePressure::reading", + "origin": { + "file": "behavior.sysml", + "offset": 143, + "length": 35, + "nameOffset": 152, + "nameLength": 7 + } + }, + "context": [ + "behavior.sysml@761:1790", + "behavior.sysml@1184:29", + "behavior.sysml@143:35" + ] + }, + { + "symbol": "n2", + "subject": { + "id": "behavior.sysml@1222:38", + "qualifiedName": "CompressorBehavior::CompressAir::temperature", + "origin": { + "file": "behavior.sysml", + "offset": 1222, + "length": 38, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@761:1790", "behavior.sysml@1222:38"] + }, + { + "symbol": "n2.0", + "subject": { + "id": "behavior.sysml@215:38", + "qualifiedName": "CompressorBehavior::SenseTemperature::reading", + "origin": { + "file": "behavior.sysml", + "offset": 215, + "length": 38, + "nameOffset": 224, + "nameLength": 7 + } + }, + "context": [ + "behavior.sysml@761:1790", + "behavior.sysml@1222:38", + "behavior.sysml@215:38" + ] + }, + { + "symbol": "n3", + "subject": { + "id": "behavior.sysml@1269:30", + "qualifiedName": "CompressorBehavior::CompressAir::control", + "origin": { + "file": "behavior.sysml", + "offset": 1269, + "length": 30, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@761:1790", "behavior.sysml@1269:30"] + }, + { + "symbol": "n3.0", + "subject": { + "id": "behavior.sysml@294:34", + "qualifiedName": "CompressorBehavior::ControlMotor::reading", + "origin": { + "file": "behavior.sysml", + "offset": 294, + "length": 34, + "nameOffset": 302, + "nameLength": 7 + } + }, + "context": [ + "behavior.sysml@761:1790", + "behavior.sysml@1269:30", + "behavior.sysml@294:34" + ] + }, + { + "symbol": "n3.1", + "subject": { + "id": "behavior.sysml@337:41", + "qualifiedName": "CompressorBehavior::ControlMotor::temperature", + "origin": { + "file": "behavior.sysml", + "offset": 337, + "length": 41, + "nameOffset": 345, + "nameLength": 11 + } + }, + "context": [ + "behavior.sysml@761:1790", + "behavior.sysml@1269:30", + "behavior.sysml@337:41" + ] + }, + { + "symbol": "n3.2", + "subject": { + "id": "behavior.sysml@387:32", + "qualifiedName": "CompressorBehavior::ControlMotor::command", + "origin": { + "file": "behavior.sysml", + "offset": 387, + "length": 32, + "nameOffset": 396, + "nameLength": 7 + } + }, + "context": [ + "behavior.sysml@761:1790", + "behavior.sysml@1269:30", + "behavior.sysml@387:32" + ] + }, + { + "symbol": "n4", + "subject": { + "id": "behavior.sysml@1308:31", + "qualifiedName": "CompressorBehavior::CompressAir::torque", + "origin": { + "file": "behavior.sysml", + "offset": 1308, + "length": 31, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@761:1790", "behavior.sysml@1308:31"] + }, + { + "symbol": "n4.0", + "subject": { + "id": "behavior.sysml@458:31", + "qualifiedName": "CompressorBehavior::GenerateTorque::command", + "origin": { + "file": "behavior.sysml", + "offset": 458, + "length": 31, + "nameOffset": 466, + "nameLength": 7 + } + }, + "context": [ + "behavior.sysml@761:1790", + "behavior.sysml@1308:31", + "behavior.sysml@458:31" + ] + }, + { + "symbol": "n5", + "subject": { + "id": "behavior.sysml@1348:22", + "qualifiedName": "CompressorBehavior::CompressAir::pump", + "origin": { + "file": "behavior.sysml", + "offset": 1348, + "length": 22, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@761:1790", "behavior.sysml@1348:22"] + }, + { + "symbol": "n5.0", + "subject": { + "id": "behavior.sysml@517:20", + "qualifiedName": "CompressorBehavior::PumpAir::inlet", + "origin": { + "file": "behavior.sysml", + "offset": 517, + "length": 20, + "nameOffset": 525, + "nameLength": 5 + } + }, + "context": [ + "behavior.sysml@761:1790", + "behavior.sysml@1348:22", + "behavior.sysml@517:20" + ] + }, + { + "symbol": "n5.1", + "subject": { + "id": "behavior.sysml@538:22", + "qualifiedName": "CompressorBehavior::PumpAir::outlet", + "origin": { + "file": "behavior.sysml", + "offset": 538, + "length": 22, + "nameOffset": 547, + "nameLength": 6 + } + }, + "context": [ + "behavior.sysml@761:1790", + "behavior.sysml@1348:22", + "behavior.sysml@538:22" + ] + }, + { + "symbol": "n6", + "subject": { + "id": "behavior.sysml@1379:22", + "qualifiedName": "CompressorBehavior::CompressAir::cool", + "origin": { + "file": "behavior.sysml", + "offset": 1379, + "length": 22, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@761:1790", "behavior.sysml@1379:22"] + }, + { + "symbol": "n6.0", + "subject": { + "id": "behavior.sysml@588:20", + "qualifiedName": "CompressorBehavior::CoolAir::inlet", + "origin": { + "file": "behavior.sysml", + "offset": 588, + "length": 20, + "nameOffset": 596, + "nameLength": 5 + } + }, + "context": [ + "behavior.sysml@761:1790", + "behavior.sysml@1379:22", + "behavior.sysml@588:20" + ] + }, + { + "symbol": "n6.1", + "subject": { + "id": "behavior.sysml@609:22", + "qualifiedName": "CompressorBehavior::CoolAir::outlet", + "origin": { + "file": "behavior.sysml", + "offset": 609, + "length": 22, + "nameOffset": 618, + "nameLength": 6 + } + }, + "context": [ + "behavior.sysml@761:1790", + "behavior.sysml@1379:22", + "behavior.sysml@609:22" + ] + }, + { + "symbol": "n7", + "subject": { + "id": "behavior.sysml@1410:24", + "qualifiedName": "CompressorBehavior::CompressAir::store", + "origin": { + "file": "behavior.sysml", + "offset": 1410, + "length": 24, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@761:1790", "behavior.sysml@1410:24"] + }, + { + "symbol": "n7.0", + "subject": { + "id": "behavior.sysml@660:20", + "qualifiedName": "CompressorBehavior::StoreAir::inlet", + "origin": { + "file": "behavior.sysml", + "offset": 660, + "length": 20, + "nameOffset": 668, + "nameLength": 5 + } + }, + "context": [ + "behavior.sysml@761:1790", + "behavior.sysml@1410:24", + "behavior.sysml@660:20" + ] + }, + { + "symbol": "n7.1", + "subject": { + "id": "behavior.sysml@681:22", + "qualifiedName": "CompressorBehavior::StoreAir::outlet", + "origin": { + "file": "behavior.sysml", + "offset": 681, + "length": 22, + "nameOffset": 690, + "nameLength": 6 + } + }, + "context": [ + "behavior.sysml@761:1790", + "behavior.sysml@1410:24", + "behavior.sysml@681:22" + ] + }, + { + "symbol": "n8", + "subject": { + "id": "behavior.sysml@1443:28", + "qualifiedName": "CompressorBehavior::CompressAir::deliver", + "origin": { + "file": "behavior.sysml", + "offset": 1443, + "length": 28, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@761:1790", "behavior.sysml@1443:28"] + }, + { + "symbol": "n8.0", + "subject": { + "id": "behavior.sysml@734:20", + "qualifiedName": "CompressorBehavior::DeliverAir::inlet", + "origin": { + "file": "behavior.sysml", + "offset": 734, + "length": 20, + "nameOffset": 742, + "nameLength": 5 + } + }, + "context": [ + "behavior.sysml@761:1790", + "behavior.sysml@1443:28", + "behavior.sysml@734:20" + ] + }, + { + "symbol": "n9", + "subject": { + "id": "behavior.sysml@1948:5", + "origin": { + "file": "behavior.sysml", + "offset": 1948, + "length": 5, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@761:1790", "behavior.sysml@1948:5"] + }, + { + "symbol": "n10", + "subject": { + "id": "behavior.sysml@2233:4", + "origin": { + "file": "behavior.sysml", + "offset": 2233, + "length": 4, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@761:1790", "behavior.sysml@2233:4"] + } + ] + }, + { + "view": "CompressorViews::maintenance", + "modelHash": "f1c690da8fa143ca4304546dfbee51dc8483f8a01d1b29599445c6f731e5ef2e", + "kind": "action", + "correspondence": [ + { + "symbol": "n0", + "subject": { + "id": "behavior.sysml@2711:1081", + "qualifiedName": "CompressorBehavior::ServiceCompressor", + "origin": { + "file": "behavior.sysml", + "offset": 2711, + "length": 1081, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@2711:1081"] + }, + { + "symbol": "n1", + "subject": { + "id": "behavior.sysml@3031:30", + "qualifiedName": "CompressorBehavior::ServiceCompressor::isolate", + "origin": { + "file": "behavior.sysml", + "offset": 3031, + "length": 30, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@2711:1081", "behavior.sysml@3031:30"] + }, + { + "symbol": "n2", + "subject": { + "id": "behavior.sysml@3070:38", + "qualifiedName": "CompressorBehavior::ServiceCompressor::depressurize", + "origin": { + "file": "behavior.sysml", + "offset": 3070, + "length": 38, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@2711:1081", "behavior.sysml@3070:38"] + }, + { + "symbol": "n3", + "subject": { + "id": "behavior.sysml@3117:34", + "qualifiedName": "CompressorBehavior::ServiceCompressor::inspect", + "origin": { + "file": "behavior.sysml", + "offset": 3117, + "length": 34, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@2711:1081", "behavior.sysml@3117:34"] + }, + { + "symbol": "n4", + "subject": { + "id": "behavior.sysml@3160:37", + "qualifiedName": "CompressorBehavior::ServiceCompressor::replaceFilter", + "origin": { + "file": "behavior.sysml", + "offset": 3160, + "length": 37, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@2711:1081", "behavior.sysml@3160:37"] + }, + { + "symbol": "n5", + "subject": { + "id": "behavior.sysml@3206:32", + "qualifiedName": "CompressorBehavior::ServiceCompressor::restore", + "origin": { + "file": "behavior.sysml", + "offset": 3206, + "length": 32, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@2711:1081", "behavior.sysml@3206:32"] + }, + { + "symbol": "n6", + "subject": { + "id": "behavior.sysml@3568:5", + "origin": { + "file": "behavior.sysml", + "offset": 3568, + "length": 5, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@2711:1081", "behavior.sysml@3568:5"] + }, + { + "symbol": "n7", + "subject": { + "id": "behavior.sysml@3781:4", + "origin": { + "file": "behavior.sysml", + "offset": 3781, + "length": 4, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@2711:1081", "behavior.sysml@3781:4"] + } + ] + }, + { + "view": "CompressorViews::states", + "modelHash": "f1c690da8fa143ca4304546dfbee51dc8483f8a01d1b29599445c6f731e5ef2e", + "kind": "state", + "correspondence": [ + { + "symbol": "n0", + "subject": { + "id": "behavior.sysml@4136:1358", + "qualifiedName": "CompressorBehavior::OperatingMode", + "origin": { + "file": "behavior.sysml", + "offset": 4136, + "length": 1358, + "nameOffset": 4146, + "nameLength": 13 + } + }, + "context": ["behavior.sysml@4136:1358"] + }, + { + "symbol": "n9", + "subject": { + "id": "" + }, + "context": ["behavior.sysml@4136:1358"] + }, + { + "symbol": "n1", + "subject": { + "id": "behavior.sysml@4199:14", + "qualifiedName": "CompressorBehavior::OperatingMode::stopped", + "origin": { + "file": "behavior.sysml", + "offset": 4199, + "length": 14, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@4136:1358", "behavior.sysml@4199:14"] + }, + { + "symbol": "n2", + "subject": { + "id": "behavior.sysml@4222:15", + "qualifiedName": "CompressorBehavior::OperatingMode::starting", + "origin": { + "file": "behavior.sysml", + "offset": 4222, + "length": 15, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@4136:1358", "behavior.sysml@4222:15"] + }, + { + "symbol": "n3", + "subject": { + "id": "behavior.sysml@4246:15", + "qualifiedName": "CompressorBehavior::OperatingMode::charging", + "origin": { + "file": "behavior.sysml", + "offset": 4246, + "length": 15, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@4136:1358", "behavior.sysml@4246:15"] + }, + { + "symbol": "n4", + "subject": { + "id": "behavior.sysml@4270:12", + "qualifiedName": "CompressorBehavior::OperatingMode::ready", + "origin": { + "file": "behavior.sysml", + "offset": 4270, + "length": 12, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@4136:1358", "behavior.sysml@4270:12"] + }, + { + "symbol": "n5", + "subject": { + "id": "behavior.sysml@4291:16", + "qualifiedName": "CompressorBehavior::OperatingMode::supplying", + "origin": { + "file": "behavior.sysml", + "offset": 4291, + "length": 16, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@4136:1358", "behavior.sysml@4291:16"] + }, + { + "symbol": "n6", + "subject": { + "id": "behavior.sysml@4316:14", + "qualifiedName": "CompressorBehavior::OperatingMode::cooling", + "origin": { + "file": "behavior.sysml", + "offset": 4316, + "length": 14, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@4136:1358", "behavior.sysml@4316:14"] + }, + { + "symbol": "n7", + "subject": { + "id": "behavior.sysml@4339:12", + "qualifiedName": "CompressorBehavior::OperatingMode::fault", + "origin": { + "file": "behavior.sysml", + "offset": 4339, + "length": 12, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@4136:1358", "behavior.sysml@4339:12"] + }, + { + "symbol": "n8", + "subject": { + "id": "behavior.sysml@4360:18", + "qualifiedName": "CompressorBehavior::OperatingMode::maintenance", + "origin": { + "file": "behavior.sysml", + "offset": 4360, + "length": 18, + "nameOffset": 0, + "nameLength": 0 + } + }, + "context": ["behavior.sysml@4136:1358", "behavior.sysml@4360:18"] + } + ] + } + ] + } + } +} diff --git a/tests/fixtures/opensysml-0.10-positioned.sysml-project.json b/tests/fixtures/opensysml-0.10-positioned.sysml-project.json new file mode 100644 index 0000000..669e1d1 --- /dev/null +++ b/tests/fixtures/opensysml-0.10-positioned.sysml-project.json @@ -0,0 +1,45 @@ +{ + "format": "sysmlv2-view-project", + "version": 1, + "name": "model", + "documents": [ + { + "name": "model.sysml", + "text": "package P { part def Motor; part def System { part primary : Motor; part standby : Motor; } part system : System; }" + }, + { + "name": "views.sysml", + "text": "package V { private import P::*; view structure : StandardViewDefinitions::InterconnectionView { expose System; } }" + } + ], + "drafts": [ + { + "name": "model.sysml", + "text": "package P { part def Motor; part def System { part primary : Motor; part standby : Motor; } part system : System; }" + }, + { + "name": "views.sysml", + "text": "package V { private import P::*; view structure : StandardViewDefinitions::InterconnectionView { expose System; } }" + } + ], + "presentations": { + "V::structure": { + "sourceHash": "ba8af7737f3e98f942abceba1a42281023a4dbc9453fa13561a39d5c175250d5", + "modelHash": "a7fa257d2e263b249db2b68fff9e7d6c16a8a9d464e54b684f9deea722ca4d9b", + "positions": { + "n0": { + "x": 82, + "y": 60 + }, + "n1": { + "x": 114, + "y": 146 + }, + "n2": { + "x": 114, + "y": 312 + } + } + } + } +} diff --git a/tests/fixtures/opensysml-0.12-tree-relationships.json b/tests/fixtures/opensysml-0.12-tree-relationships.json new file mode 100644 index 0000000..04a93ea --- /dev/null +++ b/tests/fixtures/opensysml-0.12-tree-relationships.json @@ -0,0 +1,208 @@ +{ + "DocumentationViews::overview": [ + { + "kind": "composition", + "from": "CommentExample::ServiceLoop", + "to": "CommentExample::ServiceLoop::pump", + "declaration": "model.sysml@374:17" + }, + { + "kind": "typing", + "from": "CommentExample::ServiceLoop::pump", + "to": "CommentExample::Pump", + "declaration": "model.sysml@386:4" + } + ], + "OrganizationViews::outline": [ + { + "kind": "composition", + "from": "Organization::Control::Controller", + "to": "Organization::Control::Controller::feedback", + "declaration": "model.sysml@187:23" + }, + { + "kind": "typing", + "from": "Organization::Control::Controller::feedback", + "to": "Organization::Common::Sensor", + "declaration": "model.sysml@203:6" + }, + { + "kind": "composition", + "from": "Organization::Plant::Installation", + "to": "Organization::Plant::Installation::controller", + "declaration": "model.sysml@340:29" + }, + { + "kind": "typing", + "from": "Organization::Plant::Installation::controller", + "to": "Organization::Control::Controller", + "declaration": "model.sysml@358:10" + } + ], + "TraceViews::overview": [ + { + "kind": "typing", + "from": "TraceRequirements::MinimumCapacity::tank", + "to": "TraceSystem::Tank", + "declaration": "" + }, + { + "kind": "typing", + "from": "TraceRequirements::leftSpecification::installation", + "to": "TraceSystem::Tank", + "declaration": "" + }, + { + "kind": "typing", + "from": "TraceRequirements::leftSpecification::leftCapacity", + "to": "TraceRequirements::MinimumCapacity", + "declaration": "requirements.sysml@677:12" + }, + { + "kind": "typing", + "from": "TraceRequirements::rightSpecification::installation", + "to": "TraceSystem::Tank", + "declaration": "" + }, + { + "kind": "typing", + "from": "TraceRequirements::rightSpecification::rightCapacity", + "to": "TraceRequirements::MinimumCapacity", + "declaration": "requirements.sysml@975:12" + }, + { + "kind": "typing", + "from": "TraceRequirements::unlinkedCapacity", + "to": "TraceRequirements::MinimumCapacity", + "declaration": "requirements.sysml@1116:15" + }, + { + "kind": "typing", + "from": "TraceRequirements::excludedCapacity", + "to": "TraceRequirements::MinimumCapacity", + "declaration": "requirements.sysml@1235:15" + }, + { + "kind": "subsetting", + "from": "requirements.sysml@1306:58", + "to": "TraceRequirements::excludedCapacity", + "declaration": "" + }, + { + "kind": "subsetting", + "from": "requirements.sysml@1369:71", + "to": "TraceRequirements::leftSpecification::leftCapacity", + "declaration": "" + }, + { + "kind": "subsetting", + "from": "requirements.sysml@1445:74", + "to": "TraceRequirements::rightSpecification::rightCapacity", + "declaration": "" + }, + { + "kind": "composition", + "from": "TraceSystem", + "to": "TraceSystem::spare", + "declaration": "system.sysml@322:64" + }, + { + "kind": "reference", + "from": "TraceSystem::Tank", + "to": "TraceSystem::Tank::capacity", + "declaration": "system.sysml@50:40" + }, + { + "kind": "composition", + "from": "TraceSystem::LeftPlant", + "to": "TraceSystem::LeftPlant::tank", + "declaration": "system.sysml@129:71" + }, + { + "kind": "typing", + "from": "TraceSystem::LeftPlant::tank", + "to": "TraceSystem::Tank", + "declaration": "system.sysml@141:4" + }, + { + "kind": "reference", + "from": "TraceSystem::LeftPlant::tank", + "to": "TraceSystem::LeftPlant::tank::capacity", + "declaration": "system.sysml@160:30" + }, + { + "kind": "redefinition", + "from": "TraceSystem::LeftPlant::tank::capacity", + "to": "TraceSystem::Tank::capacity", + "declaration": "system.sysml@174:8" + }, + { + "kind": "composition", + "from": "TraceSystem::RightPlant", + "to": "TraceSystem::RightPlant::tank", + "declaration": "system.sysml@240:71" + }, + { + "kind": "typing", + "from": "TraceSystem::RightPlant::tank", + "to": "TraceSystem::Tank", + "declaration": "system.sysml@252:4" + }, + { + "kind": "reference", + "from": "TraceSystem::RightPlant::tank", + "to": "TraceSystem::RightPlant::tank::capacity", + "declaration": "system.sysml@271:30" + }, + { + "kind": "redefinition", + "from": "TraceSystem::RightPlant::tank::capacity", + "to": "TraceSystem::Tank::capacity", + "declaration": "system.sysml@285:8" + }, + { + "kind": "typing", + "from": "TraceSystem::spare", + "to": "TraceSystem::Tank", + "declaration": "system.sysml@335:4" + }, + { + "kind": "reference", + "from": "TraceSystem::spare", + "to": "TraceSystem::spare::capacity", + "declaration": "system.sysml@350:30" + }, + { + "kind": "redefinition", + "from": "TraceSystem::spare::capacity", + "to": "TraceSystem::Tank::capacity", + "declaration": "system.sysml@364:8" + } + ], + "WorkshopViews::overview": [ + { + "kind": "composition", + "from": "Workshop::Compressor", + "to": "Workshop::Compressor::primary", + "declaration": "model.sysml@156:21" + }, + { + "kind": "composition", + "from": "Workshop::Compressor", + "to": "Workshop::Compressor::standby", + "declaration": "model.sysml@186:21" + }, + { + "kind": "typing", + "from": "Workshop::Compressor::primary", + "to": "Workshop::Motor", + "declaration": "model.sysml@171:5" + }, + { + "kind": "typing", + "from": "Workshop::Compressor::standby", + "to": "Workshop::Motor", + "declaration": "model.sysml@201:5" + } + ] +}