diff --git a/README.md b/README.md index c674731..cec0dc2 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,10 @@ OpenMBEE View Editor brings source editing, named model views and reviewed graph - **Edit highlighted SysML source.** CodeMirror provides line numbers, bracket matching/closing, indentation, find/replace and draft undo/redo. Apply or discard changes, undo/redo accepted source transactions, and keep the last valid model when a draft fails validation. - **Query the draft model.** Request engine-backed completion, symbol information and project-file definitions against the complete current source set. - **Inspect requirement graphs.** Browse requirement definitions/usages, declared identifiers and documentation, typing, hierarchy and positive/negated satisfaction links. Follow exact source declarations; diagram links do not report executed test results. +- **Read authored attribute values.** See source expressions on attribute nodes, general-view card previews and effective structural compartments, with full expressions and provider declarations in the Inspector. Unresolved binding or precedence cases are explained rather than displayed as calculated values. - **Open source-defined views.** Navigate named SysML views independently of the active source file. Inspect nested and inherited parts, typed inherited ports and resolved connection endpoints. - **Navigate source issues.** Open an issue’s reported file and line in the source editor. Missing or stale locations are explained while drafts and the last valid diagram are retained. -- **Browse the model hierarchy.** Expand packages, definitions and declared members in the Model tree. Inspect type/kind labels; use arrow keys, type-ahead and Enter/Space to navigate and select. +- **Browse the model hierarchy.** Expand packages, definitions and declared members in the Model tree. Selection opens Inspector details. Inspect type/kind labels; use arrow keys, type-ahead and Enter/Space to navigate and select. - **Review graphical changes.** Rename parts, create native views, add typed part usages and connect compatible directed ports through an exact source-change preview. - **Preserve compatible layouts.** Keep verified symbol positions across supported edits, including inactive views. Unsafe identity mappings are reported and arranged again. - **Keep projects portable.** Download applied source, unfinished drafts and compatible diagram positions together; reopen the bundle or import multiple SysML files. @@ -63,6 +64,8 @@ Structural, native requirement and bounded action/state workflows have real-engi 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. +**Read large views:** large authored trees and interconnections open at a readable scale when fitting the whole graph would reduce zoom below 60%. Pan to continue, choose **Fit view** for an overview, or select a diagram occurrence and choose **100%** to read it. These camera actions preserve source and saved symbol positions. + ## Quick start ### 1. Install and run @@ -106,7 +109,7 @@ Connection creation currently requires explicit compatible directions and identi ### 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. +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. Select a requirement or link to open its Inspector details. Choose **Open source** there to navigate to its declaration; 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. diff --git a/docs/architecture/requirements-data.md b/docs/architecture/requirements-data.md index ea2b97a..350cc4b 100644 --- a/docs/architecture/requirements-data.md +++ b/docs/architecture/requirements-data.md @@ -8,6 +8,6 @@ The workspace checks the applied model and source references again after the asy 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 row, column or link opens Inspector details without changing the active file, editor mode or draft. Requirements include their declared identifier and documentation; anonymous links retain their recorded declaration identity. **Open source** is a separate action, enabled only for a current model's recorded origin whose file draft still matches applied source. UTF-8 origins are bounds-checked and converted to editor positions without guessing a location from a display name. 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/structural-views.md b/docs/architecture/structural-views.md index 951cb96..c138ce8 100644 --- a/docs/architecture/structural-views.md +++ b/docs/architecture/structural-views.md @@ -55,7 +55,7 @@ React Flow uses parent-relative coordinates internally. The workspace and versio Restoration converts compatible absolute positions back to parent-relative positions. A saved child position outside its current owner's bounds is arranged again with an explicit notice, including incompatible positions from the older flat renderer. Reviewed source transactions and bounded source edits now reconcile declaration origins and complete usage contexts across revisions. Compatible positions are retained per view; unsafe or missing matches are invalidated explicitly. [Identity policy](identity-reconciliation.md). -Children remain within their displayed owner's content area. Group movement carries its descendants; overlap avoidance after manual movement, parent resizing, route editing and layout undo are not implemented. Mode/viewport size changes refit the available canvas without triggering a refit on ordinary symbol movement. Modeled coordinates or routes remain explicitly unavailable in this renderer. +Children remain within their displayed owner's content area. Group movement carries its descendants; overlap avoidance after manual movement, parent resizing, route editing and layout undo are not implemented. Explicit Code/Split/Diagram mode changes choose the available initial camera; ordinary viewport/panel resizing and symbol movement retain the camera. Modeled coordinates or routes remain explicitly unavailable in this renderer. ## Evidence and limits @@ -88,3 +88,15 @@ Named interconnection diagrams now supply ELK with native port identities, physi 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. + +## Source-backed attribute expressions + +The bridge adds optional `sourceAttribute` metadata to attribute nodes and `sourceAttributes` to effective attribute compartments after exact correspondence is built. Each row retains its declaration subject, complete usage context, exact applied declaration text and optional semantic type. An available value carries raw authored expression bytes, their origin within a separately identified provider declaration, default/initial flags and own/redefined provenance. An absent expression is distinct from unsupported resolution and from an older worker supplying no metadata. Expressions, references, strings and units are displayed without evaluation. + +The resolver verifies local/explicit nested and qualified redefinitions by semantic identity, with cycle, ambiguity and missing-source guards. External bindings/assignments and unresolved applicable paths remain unsupported with their exact source explanation; general binding/default precedence, member values of assigned/reference owners and library constraints are not implemented. AST reference form distinguishes relative local endpoints from namespace-qualified and explicitly external paths, which can otherwise have identical resolved leaf symbols. Unknown cases must not borrow another occurrence's default. + +Workspace publication validates declaration identity and bytes, exact node/compartment contexts, unique attributes, provider origin/containment and expression bytes against applied documents. The same checks guard named views, exploration and transaction reconciliation. These are additional derived fields, not version-1 saved-project fields. Existing declaration strings remain as a compatibility fallback. + +Diagram labels use bounded previews; the Inspector exposes full expressions, provider declarations and explicit provider-source navigation. Inspection preserves drafts and the active view. Recorded byte ranges are converted to UTF-16 only for unchanged applied files in the current model; source navigation never guesses from a display name. Tree declarations with one current diagram occurrence can inspect that occurrence; repeated occurrences require selecting an exact symbol for usage-specific values. + +Readable entry and 100% detail are camera operations. Multi-column packing was investigated against the ADCS assembly; tested ELK compaction/aspect-ratio options did not change its nested column. This slice keeps layout, routing and saved positions intact, exposes an overview/detail path and does not claim every large graph is simultaneously legible. See the [verification record](../verification.md) for measured outcomes and limits. diff --git a/docs/architecture/workbench-layout.md b/docs/architecture/workbench-layout.md index 4f1e30e..6bb03e3 100644 --- a/docs/architecture/workbench-layout.md +++ b/docs/architecture/workbench-layout.md @@ -6,13 +6,13 @@ The workbench composes owned shadcn Resizable, Tooltip and Popover primitives ar Explorer and Inspector resize beside the central editor. Source and Diagram share an adjustable divider in Split mode. Below 900 px the editor split becomes vertical. Issues is a separate, resizable bottom dock. Defaults collapse Inspector on a fresh narrow viewport; an edge rail can reopen it. Header options offer Narrow/Widen or Shorter/Taller without dragging, and Workspace layout offers source/diagram share controls and Reset layout. -Each side panel remembers its expanded percentage through collapse and restore. Pixel minimums keep Explorer (145 px), Inspector (190 px) and Issues (96 px) usable; side panels are capped at 420 px and Issues at 60%. The source/diagram panes have horizontal minimums of 160 px for Source and 200 px for Diagram, or 100 px vertically, and their divider cannot collapse an editor while Split mode is selected. The library applies constraints against available space. On screens below 600 px, explicitly opening one side tool collapses the other. +Each side panel remembers its expanded percentage through collapse and restore. Reopening uses that percentage unless it would fall below the tool’s pixel minimum, in which case the minimum is requested without replacing the saved percentage. Pixel minimums keep Explorer (145 px), Inspector (190 px) and Issues (96 px) usable; side panels are capped at 420 px and Issues at 60%. The source/diagram panes have horizontal minimums of 160 px for Source and 200 px for Diagram, or 100 px vertically, and their divider cannot collapse an editor while Split mode is selected. The library applies constraints against available space. On screens below 600 px, explicitly opening one side tool collapses the other. Dividers have accessible names and library-provided pointer/keyboard behavior. They remain registered in the layout when a tool collapses: removing a divider from layout can invalidate its association with the neighboring panels. The source/diagram divider is disabled outside Split mode. Hidden contents remain mounted and inert, keeping draft text and component state out of keyboard navigation. Single-editor modes reapply their layout when a resize callback reports space retained by the hidden pane after constraint registration. This avoids the old Diagram minimum leaving a blank strip after Split → Code; returning to Split retains the saved share. -Explicit collapse moves focus to the corresponding visible edge-rail reopen control. Explicit reopen returns focus to the panel's collapse button, after the restored content is no longer hidden/inert. New diagnostic/error text opens Issues without taking editor focus. A user can close and reopen that dock independently. +Explicit collapse moves focus to the corresponding visible edge-rail reopen control. Explicit reopen returns focus to the panel's collapse button, after the restored content is no longer hidden/inert. New diagnostic/error text opens Issues without taking editor focus. A user can close and reopen that dock independently. Explicit model, diagram or requirements-data selection opens Inspector without taking desktop focus from the selected item. Below 600 px this transfers focus to the visible Inspector collapse control, because opening it collapses Explorer. Unrelated renders do not reopen a manually collapsed Inspector. -The canvas waits for measured dimensions and initialized nodes before fitting. Explicit Code/Split/Diagram mode changes refit the available diagram, retaining the previous workflow. Dragging dividers or collapsing side tools does not repeatedly fit or arrange nodes, so the user's camera and saved model-space coordinates survive those changes. +The canvas waits for measured dimensions and initialized nodes before fitting. Authored trees/interconnections enter at 100% when a complete fit would be below 60%, with a top-aligned component (rather than empty container space) or centered selected occurrence. Fit view remains an explicit complete-graph overview, and 100% reads the exact selected diagram node or port owner (or the first structural component, then root, when none is selected). Trees allow zoom down to 10%, so an overview is not limited by the old 50% floor. These actions alter only the camera. Explicit Code/Split/Diagram mode changes refit the available diagram, retaining the previous workflow. Dragging dividers or collapsing side tools does not repeatedly fit or arrange nodes, so the user's camera and saved model-space coordinates survive those changes. ## Preferences and preservation diff --git a/docs/capabilities.md b/docs/capabilities.md index 56e8efe..f14b260 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -13,7 +13,7 @@ This catalogue describes the current implementation. Check [verification](verifi | 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 | +| Source navigation | Recorded origin range from diagram/port selection, snapshot-bound Issues file/line navigation and explicit file-only handling | Model-tree source reveal requires an unambiguous origin in the current view; missing/stale locations and changed drafts cannot navigate to guessed ranges | | 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 | @@ -40,3 +40,7 @@ The optional **Guide** in the project header introduces Explorer, layout, source Named-view wildcard exposure can include private imported memberships and public reexports from standard libraries. This follows exposure semantics rather than ordinary external name visibility. Use explicit element exposures and named imports to bound the selected scope; see [exposure and private imports](architecture/structural-views.md#exposure-and-private-imports). Large-view performance and readability remain separate limitations. Authored interconnection views retain inherited structural members in their exact usage contexts. Compatible legacy node/port IDs remain reserved for unchanged saved projects; newly visible occurrences receive separate IDs. Temporary exploration and tree views retain declaration ownership. Connector selection and source-backed attribute compartments keep their existing scope. See [inherited structural occurrences](architecture/structural-views.md#inherited-structural-occurrences). + +Source-backed attribute previews retain exact authored expressions and provider declarations in the Inspector, including verified nested and valueless redefinition cases. Available expressions are not evaluated results. Binding-derived values, external assignment/default precedence, ambiguous providers and unresolved applicable paths are explicitly unavailable. Full expressions remain inspectable when diagram previews truncate; provider-source navigation requires current, unchanged applied source. [Source-value contract](architecture/structural-views.md#source-backed-attribute-expressions). + +Large authored trees/interconnections open at readable scale when a full fit would be below 60%. Fit view shows the overview; 100% focuses the exact selected diagram occurrence or port owner. Camera actions preserve source, saved geometry and panel preferences. This does not add multi-column structural layout or promise simultaneous legibility of an entire large graph. diff --git a/docs/research/source-values-engine-assessment.md b/docs/research/source-values-engine-assessment.md new file mode 100644 index 0000000..a3f21fe --- /dev/null +++ b/docs/research/source-values-engine-assessment.md @@ -0,0 +1,140 @@ +# Source-backed values: engine assessment + +Recorded 2026-11-11 against editor HEAD `618e776466817d9064027b8c9756065df05766bb` (coordinator reports source identical to `3ca0d7f`). Read-only diagnosis/proposal for #24, paired with #30/#31 readability and followed by #33 inspection. No production fix is applied by this assessment. + +## Finding and causal ownership + +The engine parses the authored expressions correctly. The native **renderer object tree** has `Node.Value`; the native **flattened rendering data** does not. `internal/ir/view/tree.go:224,453` populates a usage's value using `declValue`; `internal/ir/view/data.go:43,159` defines `NodeData` and copies nodes without `Value`. Thus the earliest demonstrated loss is **native rendering-data projection**, before the local bridge's JSON conversion. `internal/frontend/engine/json.go:364` also has no value field in `JRenderNode`, and `engine.go:394` cannot convey one. Merely adding `Value` to that JSON struct will not recover it. + +The frontend independently omits values: `src/lib/workbench/diagrams/Diagram.tsx:271` constructs tree labels from kind, name and type only. The current bridge's `structuralNotation` in `engine-bridge/opensysml/evaluate_view.go:404` adds whole source declarations for effective non-library attribute members, only on interconnection artifacts. It neither enriches tree attributes nor gives compartment rows their own source identity. Fixing labels alone cannot work against today's pinned worker response. + +The ranked hypotheses supplied by the coordinator are resolved as follows: + +1. Native tree omits values: **false for renderer objects; true for flattened `Data()`**. +2. Bridge drops a native field: **partly true as a transport gap**, but loss precedes local normalization; there is no `NodeData.Value` to copy. +3. Frontend labels name/type only: **confirmed**, a second necessary change after metadata exists. + +These are authored source expressions, not computed results. Neither `24.0`, `2 [m]`, `number`, nor `(2 + 3) * 4` establishes a runtime value or an execution result. + +## Pins and retained evidence + +Editor `engine-bridge/README.md` pins OpenSysML 0.12.0 at `760c4d1fc8edfa4e214423099065c0a7c1b24e2d`, SysML Toolkit 0.10.10 at `339ef0e073d06e553e81c079755ddfcd224e4b92`, and Go 1.27.3. The assessment uses the existing canonical `sandbox/opensysml-012/source` and `sandbox/opensysml-012/go/bin/go`, without modifying either. + +Existing `static/vendor/opensysml/provenance.json` records: + +- Archive SHA-256 `49939c5f4c3008cdcf79d39a976719f67aab9d38bb38b246832eb1fdd90de08e`. +- Overlay digest `fa5556f905554120acf3933757b857cea496dd6cad3d9fa0ac9b8a2c33e9aeb9`. +- Builder digest `db47937af9ea9b282f8c74d4a3f42bfaf900cea1233db1c9e11c39deaa058bdf`. +- WASM SHA-256 **independently checked**: `95c6dced93044eeb2a29191e697a590964c5f6722d997c8b8de70cba5d73fb76`. +- Runtime digest recorded by provenance `0c949f4996f9a89698e4b5c586de32249c3b69b7baadb64d220073cc04acba14`. + +Assets were never regenerated. + +All new executable evidence is in ignored [engine-assessment scratch](../../sandbox/source-values/engine-assessment/): `controls.sysml`, `probe_test.go`, `overlay.json`, `probe.log`, `wasm-probe.cjs`, `wasm-probe.log`, `wasm-controls.json`, `verify.py`, `summary.json`, and an independent `gocache/`. The controls source SHA-256 is `873fdf749b3fd18238070f5360f378da77301bbdf47127ba585530093f12736e`. + +The final controls have no parse diagnostics. Both Go and unchanged pinned WASM report model hash `78af05631c7ed5a12bb607c007055668b6825a6562ae6130be6fdb5576e81b50`; their complete bridge tree and structure responses compare equal, including origins, correspondence, context, and declarations. Final Go test: PASS, 0.142 seconds reported package time; final verifier: PASS. The direct WASM reports 20 tree nodes and 7 structural nodes; these counts describe only this fixture. + +Coordinator-owned product red is retained in [browser-before2.log](../../sandbox/source-values/browser-before2.log): expected `24`, received `attributecapacity : Real`. [Baseline worker response](../../sandbox/source-values/baseline-evaluation.json) has exact local origins and contexts but no expression field. The browser trace is under `sandbox/playwright/source-values-before2/`. I did not rerun this browser test, inspect its screenshots, use port 4175, or claim it is green. The earlier bad node/edge test filter is not product evidence. + +The [WP26 review](wp26-screenshot-review.md) owns the historical screenshot audit and pinned report provenance. Its original ADCS source references are under immutable commit `325e2e3fb53d178d88ec7aad5994d08d0a9b743a`, `examples/adcs/model_v2/{cubesat,stakeholders,views}.sysml`. This assessment adds engine controls; it does not reattribute historical screenshots to the present build. + +## Reproduction + +Run from the editor repository. No preparation, production build, server or dependency installation is needed: + +```bash +GOCACHE="$PWD/sandbox/source-values/engine-assessment/gocache" \ +GOMODCACHE="$PWD/sandbox/opensysml-012/go-modules" \ +GOPROXY=off GOSUMDB=off GOTOOLCHAIN=local \ +ASSESS_MODEL="$PWD/sandbox/source-values/engine-assessment/controls.sysml" \ +sandbox/opensysml-012/go/bin/go -C sandbox/opensysml-012/source test \ + -mod=readonly -overlay "$PWD/sandbox/source-values/engine-assessment/overlay.json" \ + ./internal/frontend/engine -run '^TestSourceValuesAssessment$' -count=1 -v \ + > sandbox/source-values/engine-assessment/probe.log 2>&1 +fnm exec --using 26.3.1 node sandbox/source-values/engine-assessment/wasm-probe.cjs \ + > sandbox/source-values/engine-assessment/wasm-probe.log 2>&1 +python3 sandbox/source-values/engine-assessment/verify.py +``` + +The Go overlay introduces only a virtual test file in the canonical module. The retained overlay contains absolute paths for this workspace. It examines `MembersOf`, `ast.ValuePartOf`, `ValueExpression`, `RedefinedFeatures`, native renderer roots, flattened data, wire JSON and local `EvaluateView`. The verifier asserts exact byte slices, native-root values versus missing data values, Go/WASM equality and distinct nested contexts. It is a diagnostic harness, not the eventual product regression. + +The first Go attempt used the default module cache and failed offline module lookup; rerunning with the existing `sandbox/opensysml-012/go-modules` succeeded. A first binding control used reserved keyword `first` as a name and was invalid; final names are `leftValue`/`rightValue`, and the harness rejects error diagnostics. An initial Node harness relative import was corrected. These setup failures are not product reproductions. + +## Verified semantic/source controls + +| Case | Actual observation | Supported conclusion / gap | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Two local redefinitions | `P::a::number` is `24.0`; `P::b::number` is `48.0`; both redefine `P::Base::number` | Effective member selection keeps local declaration/provider identity distinct. | +| Inherited default | `MembersOf(a/b)` retains inherited members from `Base`; `Base::number` has `12.50`, default=true | Raw authored defaults can be exposed with provider origin. No execution claim. | +| Valueless local redefinition | `P::c::number` has no own expression; `RedefinedFeatures` returns `Base::number` | A scratch recursive provider traversal finds exact `12.50`, default=true, at the base declaration. `ValueExpression(c::number)` alone is insufficient. | +| Unicode String | Exact `"α/证据.json"` | UTF-8 byte slicing validated against fixture bytes, including quotes. | +| Long String | Complete long authored evidence-path literal in member capture and native roots | Full inspection text is available. Canvas clipping/wrapping remains coordinator acceptance. | +| Boolean | Exact `true` | Authored expression, not inferred Boolean evaluation. | +| Unit expression | Exact `2 [m]` | Unit spelling retained; no conversion or unit evaluation required. | +| Complex expression | Exact `(2 + 3) * 4` | Preserve syntax; do not replace with `20`. | +| No declared value | `Base::empty` has no AST value or semantic `ValueExpression` | Explicit absence of an own expression is distinguishable. It does not prove no constraints/bindings elsewhere. | +| Reference value / binding form | `Base::linked = number` retains raw `number`; native binding helper treats resolved feature-reference/chain values as bindings, excluding default/initial | Do not display referenced feature's calculated value. | +| Initial value | `initial := 7` yields expression `7`, initial=true | Preserve operator category; do not label it an unconditional binding/default. | +| Separate binding declaration | `Bound` declares `leftValue = 5`, valueless `rightValue`, and `bind leftValue = rightValue` | `ValuePartOf(rightValue)` remains absent. Standalone binding traversal is a verified API coverage gap, not permission to invent `5` on `rightValue`. | +| Repeated nested declaration | `Pair::left` appears under both `system.pair1` and `system.pair2` | Same declaration origin, distinct complete 3-element contexts verified; attribute occurrence needs that whole chain plus its attribute declaration. | + +Native tree scope remains declaration-owned: the local bridge does not publish every inherited child as a tree node. The controls do not justify expanding tree semantics to imitate interconnection rendering. + +## Owning APIs and proposal + +Primary code sources below are relative to canonical OpenSysML source unless prefixed with editor paths. No external normative claim is needed for these observed API facts. + +- `internal/syntax/ast/value_part.go:11`: `ValuePartOf` returns the authored expression plus default/initial flags, false for no own value. `ast.Usage` in `defusage.go` also records `ValueOperatorSpan`. Expression `Span()` is the raw expression extent; do not use AST dump/pretty printing or renderer `expressionText` as an exact-source contract. +- `internal/semantic/semantics/reflective_feature_value.go:40`: `ValueExpression` synthesizes a source-located expression symbol from the declaration's expression; it does not resolve inherited providers for a valueless redefining feature. Reflective `FeatureValue` membership is metamodel structure, distinct from runtime `JFeatureValue`. +- `internal/semantic/semantics/members.go:15`: `MembersOf` supplies effective members with name and redefinition masking. Use it for compartment members instead of collecting lexical bodies or parsing declaration strings. +- `internal/semantic/semantics/masking.go:20`: `RedefinedFeatures` supplies semantic targets. It is the candidate seam for following a valueless redefinition to source providers, with cycle detection and ambiguity handling. +- `internal/semantic/semantics/annotations.go:888`: `OwnFeatureValues` follows redefinitions and returns constant/filter values. It can evaluate/convert literals, quantities and expressions and does not return exact provider source. It is **not** the display API. Runtime instantiation/feature reads are also inappropriate here. +- `internal/syntax/source/source.go`: spans are UTF-8 **byte** ranges. `cached.sourceText()(provider.DocName, expression.Span())` returns exact source. Browser substring indices are UTF-16; never apply byte offsets to JS text without the existing source-coordinate conversion. +- Editor `evaluate_view.go` already supplies revision-bound `ViewSubject` identities and complete `ViewCorrespondence.Context`. Bind metadata to these, not short names, labels or geometry. + +Propose one bridge-owned source-value resolver, shared by tree nodes, structural attribute rows, and inspection. For an effective member: first read its own AST expression; otherwise collect semantic redefinition providers without evaluation. Keep all candidates until uniqueness is established; do not choose the first matching name/default. Prototype traversal in `probe_test.go` proves only the single-target default case, not general precedence. + +A minimal proposed normalized shape (names illustrative): + +```ts +type SourceValue = + | { + status: 'available'; + expression: string; + expressionOrigin: { file: string; offset: number; length: number }; + provider: ViewSubject; + isDefault: boolean; + isInitial: boolean; + provenance: 'own' | 'redefined'; + } + | { status: 'absent' } // no declaration/redefinition source expression found + | { status: 'unsupported'; reason: string }; +type SourceAttribute = { + subject: ViewSubject; // effective selected attribute declaration + context: string[]; // complete owner occurrence chain + subject.id + declaration: string; // exact effective declaration source + value: SourceValue; // provider may differ from subject +}; +``` + +Validate origins against the applied source revision, exact expression slices, source membership, context/declaration correspondence and provider identity before publication. Existing `modelHash` binds the envelope; do not persist derived metadata in project version 1. Absent metadata from an older worker must mean unavailable capability, **not** a verified absent expression. A binding elsewhere may exist even when status=absent; label the UI “no declared expression” rather than “no value”. Explicit unsupported reasons should cover missing source/AST, multiple unresolved providers, cycles and unimplemented binding-derived provenance. Do not manufacture strings for any of these states. + +Suggested editor files: a focused Go source-value overlay under `engine-bridge/opensysml/`, invoked by `evaluate_view.go` normalization for tree/interconnection; retained Go regressions in `validated_view_test.go`; preparation overlay mapping/digest inputs in `scripts/prepare-opensysml.mjs` if adding a file; `src/lib/workspace/views/schema.ts` for owning TypeBox metadata and referential validation; diagram projections/rows under `src/lib/workbench/diagrams/`; and `src/lib/workbench/properties/Properties.tsx` plus selection mapping for inspection. Keep source-value resolution out of React and the engine-independent workspace. Preserve current `attributeDeclarations` until callers migrate deliberately. No new dependency or persistent project-format field is warranted. + +A narrow upstream report could independently demonstrate `Node.Value` lost by `Data()`. A local AST-backed contract remains useful even if upstream forwards a display value: native `declValue` can format expressions and does not identify an inherited provider. Do not rely on upstream acceptance to implement the local user outcome. + +## Regression acceptance and remaining scope + +Retain the coordinator's exact browser red command, using Node 26.3.1: + +```bash +fnm exec --using 26.3.1 npm run test:e2e -- --project=functional source-values.spec.ts +``` + +The eventual retained Go test should assert all controls above, including negative/missing-source cases, provider origins distinct from selected local declarations, exact raw complex/String expressions, and full context chains for repeated nested occurrences. Add deterministic multiple-redefinition ambiguity/cycle controls before claiming general fallback resolution. Include standalone binding declarations as explicit coverage: either source-backed relationship inspection or a visible supported-scope explanation, never a silently borrowed calculated value. Do not narrow #24 to local numeric literals. + +Browser acceptance must use the prepared pinned workers after the coordinator's deliberate asset rebuild: distinguish 24/48 and their separate evidence paths, show readable initial labels for #30/#31, inspect full long/Unicode/complex expressions, and select the correct attribute/provider occurrence under repeated usages. Preserve source, applied/draft distinction, compatible geometry, undo/redo and portable reopen. Source reveal must target the right declaration/expression and remain disabled for unapplied drafts. #33 selection and layout acceptance belong to the coordinator; this note provides no visual or accessibility verdict. + +Still unverified: multiple/generalized defaults and precedence, cross-feature specialization, implicit positional parameter redefinitions, separate binding-derived provenance, cyclic/ambiguous providers, recorded/hydrated library expressions, expression forms beyond these controls, and large-model performance. Single-provider traversal must not be presented as a complete effective valuation algorithm. General source-backed inspection can retain unsupported declarations and relationship sources without evaluating them. + +Only this new Markdown note and the dedicated ignored scratch directory were written. Existing untracked screenshot-review, fixture, and browser tests were preserved (an additional coordinator-owned inspection test appeared during research). No production edits, asset writes, preparation scripts, product builds, servers, browser checks, dependencies, commits, pushes, remote edits, CI monitoring, or delegates were performed. The product remains red pending coordinator implementation and checks. diff --git a/docs/research/source-values-engine-review.md b/docs/research/source-values-engine-review.md new file mode 100644 index 0000000..f332509 --- /dev/null +++ b/docs/research/source-values-engine-review.md @@ -0,0 +1,60 @@ +# Independent review of source-value bridge candidate + +Recorded 2026-11-11. Reviewed the private [candidate and implementation claims](source-values-implementation.md), not an integrated engine change. Production bridge files and prepared assets were unchanged when tested. Parent frontend changes were preserved. + +## Finding: P2 — outer source scopes are omitted from occurrence resolution + +Do not integrate the candidate as-is. `source_values.go:214–249` examines only `owners` built from renderer correspondence contexts (`source_values.go:298–320`). Those contexts begin at the exposed/rendered root; they do not include every lexical namespace that can declare a qualified assignment or binding affecting that occurrence. `sourceBinding` also examines only each context owner and its `MemberSources` (`source_values.go:175–196`). Consequently the candidate can report an inherited default as **available** even when a known source declaration outside those scopes requires different provenance or an explicit unsupported result. + +Two independently retained strict-parse examples reproduce the omission. In each, expose only `P::a` in an `InterconnectionView`: + +```sysml +package P { + private import ScalarValues::*; + part def Base { attribute number : Real default = 12.50; } + part a : Base; + attribute replacement : Real = 99; + bind a.number = replacement; +} +``` + +The model parses without error diagnostics and evaluates to a complete view. The candidate publishes `a`'s `number` as `status: available`, `expression: 12.50`, provider `P::Base::number`. Its own stated standalone-binding safety rule instead requires unsupported source-value precedence, with the binding source retained. This review does not evaluate the binding or claim an executed numeric result. + +The second example replaces the last two lines with: + +```sysml +attribute overrideNumber :>> a.number = 99; +``` + +This also passes strict parsing and complete view evaluation. A separate semantic probe resolves `ChainTargetPath` to exactly `[P::a, P::Base::number]`. The candidate nevertheless publishes `12.50` as available, rather than choosing the exact `99` expression/provider through a supported rule or declaring the external contextual assignment unsupported. The default's bytes are real; the defect is selection/completeness of its occurrence-specific provenance, not invented expression text. + +Both counterexample assertions fail as intended in [outer-scope-final.log](../../sandbox/source-values/engine-review/outer-scope-final.log). Exact source and black-box assertions are in [review_test.go](../../sandbox/source-values/engine-review/review_test.go). Package time reported for both failing controls together: 0.134 s. Earlier `external-binding.log` and `outer-scope.log` retain the preceding probes; the final log additionally proves the resolved qualified target path. + +Resolve source scopes separately from the renderer occurrence chain. Inspect relevant lexical/namespace declarations and resolve their semantic endpoint/target paths against the full selected occurrence, retaining original correspondence IDs and metadata contexts. A bounded repair can conservatively return unsupported when an affecting external assignment/binding is detected; it must not silently preserve the default as if the provider graph were complete. Do not match names or evaluate a binding to manufacture a value. Cover same-file, cross-file/split namespace, independently selected roots and nested occurrences, with a parallel untouched usage proving that assignments cannot leak between contexts. + +## Other review results and limits + +Independently reran the original candidate’s complete combined and engine Go suites with a private cache and without the additional failing review tests: both passed (reported package times 3.135 s and 0.268 s). [Retained output](../../sandbox/source-values/engine-review/candidate-full.log). Passing those suites does not cover the new counterexamples. + +The implementation's raw-source slicing, expression containment, provider origin, flag and context checks are appropriate for the demonstrated supported cases. The candidate includes own, inherited and valueless redefinition paths, explicit nested/qualified contexts, ambiguity/cycle controls and unsupported missing-source paths. These observations do not override the concrete outer-scope failure above. + +The #18 golden adjustment removes only the two additive keys `sourceAttribute` and `sourceAttributes`, recursively copying rather than mutating the actual envelope. Other source origins, IDs, graph nodes, endpoint fields, contexts and selected scope remain compared. The dedicated metadata regressions separately assert the new fields. No confirmed weakening beyond this documented additive exclusion was found. + +Preparation adds both new Go files to the overlay digest and copies them into the engine package. No pin, dependency or saved-project field changes are present. Syntax alone is not a WASM/build/provenance check; the parent still owns deliberate preparation and browser verification. + +Library constraint/default precedence remains outside demonstrated support, as the implementation report acknowledges. Large-model performance was not benchmarked: repeated per-attribute owner/member/redefinition scans deserve measurement on the original assembly/stakeholder sources, but no performance defect is claimed without a retained measurement. Browser rendering, TypeBox publication validation, stale UI results, source navigation, saved positions through fresh reopen and desktop/narrow visual acceptance were not tested by this review. + +## Reproduction and actions + +Read repository instructions, engine README, the assessment, agreed contract, candidate source/tests/patch and preparation mapping. Added only this report and isolated [engine-review](../../sandbox/source-values/engine-review/) scratch files/cache. The overlay refers to existing candidate files read-only and adds one virtual review test file. It never changes canonical source or the candidate itself. + +```bash +sandbox/source-values/engine-review/run-review.sh counterexamples \ + > sandbox/source-values/engine-review/outer-scope-final.log 2>&1 +# Expected exit 1, both missed outer-scope assertions. +sandbox/source-values/engine-review/run-review.sh candidate-full \ + > sandbox/source-values/engine-review/candidate-full.log 2>&1 +# Original candidate suites, excluding the additional review controls. +``` + +These commands use the existing pinned Go 1.27.3 and OpenSysML 0.12.0 source, offline module cache, `-mod=readonly`, private `GOCACHE` and explicit overlays. No shared preparation, WASM build, browser, server, dependency install, commit, push or remote mutation ran. diff --git a/docs/research/source-values-engine-revision-review.md b/docs/research/source-values-engine-revision-review.md new file mode 100644 index 0000000..73fb1f7 --- /dev/null +++ b/docs/research/source-values-engine-revision-review.md @@ -0,0 +1,70 @@ +# Independent review of revised source-value scopes + +Recorded 2026-11-11. Reviewed the private [outer-scope revision](source-values-engine-revision.md), not an integrated bridge or rebuilt worker. The reviewed patch/source hashes are retained in [reviewed-sha256.txt](../../sandbox/source-values/engine-revision-review/reviewed-sha256.txt). Production files, shared assets and parent frontend work were not changed. + +## P2: a usage-local binding leaks into its independently exposed definition + +The revision repairs the original missed outer namespace assignments/bindings, but its unconditional path comparison at private `source_values.go:337` can invent an occurrence context from the target declaration's lexical owner. A relative binding end resolved to an inherited declaration consequently matches the separately exposed definition itself. + +The retained strict-parse model has no error diagnostics and a complete interconnection rendering: + +```sysml +package P { + private import ScalarValues::*; + part def Base { attribute number : Real default = 12.50; } + part a : Base { + attribute replacement : Real = 99; + bind number = replacement; + } + part b : Base; +} +package V { + view structure : StandardViewDefinitions::InterconnectionView { + expose P::Base; + expose P::a; + expose P::b; + } +} +``` + +`ConnectorEndFeaturePaths` returns `[P::Base::number]` for the first end of the binding owned by `P::a`. `sourceLexicalPath(path)` prefixes `P::Base`, which then matches the definition's `[Base, number]` occurrence. The definition's own declared default becomes **unsupported**, citing the binding inside `a`. `a` is appropriately unsupported and parallel `b` retains `12.50`; the defect is the unsupported state leaking back into the source definition. The independently exposed definition should retain its declared default rather than be described as affected by an unrelated usage-local binding. + +`TestRevisionReviewLocalBindingDoesNotAffectDefinition` fails at that exact assertion in [review-final.log](../../sandbox/source-values/engine-revision-review/review-final.log). The final broadened [spelling-ast-final.log](../../sandbox/source-values/engine-revision-review/spelling-ast-final.log) retains the same failure with additional path/source-form probes. The reproduction test is [review_test.go](../../sandbox/source-values/engine-revision-review/review_test.go). This is a source-provenance/display-availability failure, not an evaluated numeric result or graph mutation. + +Repair relative paths using their binding/assignment source scope and semantic membership, preserving renderer contexts. Do not blindly prepend that scope to every path: an explicit external target must remain external. The parent is addressing the repair separately; this review does not claim that an untested parent edit resolves it. + +## Qualification boundary and source-bound AST seam + +Three valid spellings inside `a` were additionally parsed with zero diagnostics and rendered completely: + +| Written binding end | Semantic feature path | +| ------------------- | ------------------------- | +| `number` | `[P::Base::number]` | +| `P::Base::number` | `[P::Base::number]` | +| `P::b.number` | `[P::b, P::Base::number]` | + +Thus the feature path alone loses namespace qualification. The explicit external `P::b.number` control currently marks `b` unsupported and keeps the definition available; a repair must retain that safety outcome. The probe is `TestRevisionReviewQualifiedEndSpelling` in the final log. It records the observed API distinction, not a normative adjudication of every qualified feature-reference spelling. + +The source AST retains more information without regex: `Model.ConnectorObjectEnds(binding)[i].Attachment` exposes the attachment node. `ast.QualifiedName` retains `Global` and `Parts`; `ast.FeatureReference.Name` and `ast.FeatureChainExpr.Operand` preserve qualified heads and chains. The final log records direct qualified-name parts as well as feature-chain AST dumps. `ast.Dump` alone prints only the generic type for a bare `QualifiedName`, so the probe explicitly inspects its fields. Aliases and unresolved paths still require semantic identity/uncertainty handling; source spelling alone is not a provider resolver. + +## Other checks + +Seven foreign-scope controls pass: aliases to a usage and namespace, imported names, an explicit qualified binding inside another definition, alias-based qualified redefinitions, namespace-alias redefinitions and imported redefinitions. Each requires the targeted `P::a` to be unsupported and untouched `P::b` to retain its available `12.50`. They have no error diagnostics; the alias-to-usage cases retain non-error diagnostic entries and are not described as warning-free. These controls establish the tested paths, not exhaustive safety for all unknown foreign or alias scopes. + +Independently reran the revised candidate's complete original combined and engine packages, excluding the added review file: both pass in [full.log](../../sandbox/source-values/engine-revision-review/full.log), reported times 3.787 s and 1.098 s. This includes the original reviewer counterexamples and the revision's outer-scope controls. Passing those suites does not cover the newly failing independently exposed definition control. + +A P3 cleanup remains in private `source_values_test.go:39–40`: the local `attribute.Context` field's struct tag contains a stray quoted semantic import path after the valid `json:"context"` part. Remove that accidental line. The leading JSON key still decodes in these runs; this review does not claim it invalidates the retained context assertions. + +The additive golden exclusion still removes only `sourceAttribute` and `sourceAttributes`; no new confirmed identity or endpoint weakening was found. Library constraint/default precedence and unresolved source paths remain bounded limitations. No performance defect is claimed: this review did not benchmark large models. Browser values, transport publication, source navigation, saved positions, preparation provenance and visual acceptance remain parent-owned and were not exercised here. + +## Reproduction and work performed + +```bash +sandbox/source-values/engine-revision-review/run-review.sh \ + > sandbox/source-values/engine-revision-review/spelling-ast-final.log 2>&1 +# Expected exit 1: the independently exposed definition assertion fails. +``` + +The runner uses pinned Go 1.27.3, OpenSysML 0.12.0 canonical source, offline module cache, `-mod=readonly`, its own GOCACHE and a private overlay that reads the unchanged candidate and adds only the review test. The full-suite command uses the original revision overlay with the review's private cache; its exact output is retained above. No canonical source or module metadata was edited. + +Read repository/engine instructions, the original review, agreed contract, revision report, complete revised source and relevant semantic/AST APIs. Wrote only the new [engine-revision-review scratch directory](../../sandbox/source-values/engine-revision-review/) and this note. No production edits, shared preparation/builds, browser/server work, dependency changes, commits, pushes, remote operations or subagents ran. diff --git a/docs/research/source-values-engine-revision.md b/docs/research/source-values-engine-revision.md new file mode 100644 index 0000000..48f2892 --- /dev/null +++ b/docs/research/source-values-engine-revision.md @@ -0,0 +1,87 @@ +# Source-value outer-scope safety revision + +Recorded 2026-11-11 against editor `618e776466817d9064027b8c9756065df05766bb`. This is an isolated revision of the [private candidate](source-values-implementation.md), addressing the [independent review blocker](source-values-engine-review.md). Production Go, preparation scripts and browser assets were not changed. + +The [revised candidate.patch](../../sandbox/source-values/engine-revision/candidate.patch) includes the complete original candidate plus the repair and retained regressions. It applies to the actual editor checkout; it is not an incremental patch on top of the earlier candidate. All executable material and caches are in [engine-revision](../../sandbox/source-values/engine-revision/). The original candidate and reviewer files remain unchanged. + +## Repair and bounded behavior + +Renderer contexts begin at exposed roots and cannot enumerate every source scope. The revised bridge inventories authored declarations once per normalization, walking every workspace document's actual scope tree, including anonymous bindings and split namespace declarations. It does not enumerate library declarations or use reexport membership as a substitute for source ownership. + +For external binding ends it calls the semantic `ConnectorEndFeaturePaths` API. For qualified attribute redefinitions it calls `ChainTargetPath`. Paths are compared with the complete resolved occurrence using symbol identity and semantic redefinition specialization. Lexical feature/definition owners can prefix an independently exposed root for comparison without being inserted into its renderer correspondence. Applicable type scopes use `MemberSources`; no display-name or short-name matching is used. + +An affecting external declaration returns **unsupported**, including its exact applied declaration and `file@offset:length`. This repair deliberately does not choose between a default, an external assignment and a binding, or compute a binding's value. The ordinary existing resolver still handles the verified local and internal nested contextual assignments. Resolved external effects apply only to their matched occurrence: parallel usages and nested siblings retain their own source-backed defaults. + +If the pinned engine cannot resolve an external path, the bridge cannot prove the declaration irrelevant. It reports uncertainty within the relevant semantic source scope. Split namespace owners use the index's full namespace identity because their distinct source declarations represent one namespace. This can conservatively mark otherwise unaffected attributes in that scope unsupported; it does not blanket-disable values across unrelated project scopes. Non-chain external attribute redefinitions likewise lack a complete occurrence path and receive this bounded uncertainty handling. Internal declarations still use the original provider graph and precedence guards. + +Only the two derived metadata keys are added. Graph IDs, context arrays, selected scope, edges, origins, old declaration strings and saved-project format remain unchanged. The original #18 golden exclusion still removes only those two additive keys; all original identity/endpoint comparisons remain. + +## Retained evidence + +The original reviewer examples were rerun against the original candidate: both fail at their semantic assertions in [review-red-final.log](../../sandbox/source-values/engine-revision/review-red-final.log), exit 1. Both have strict parse success and complete rendering; the incorrect result is an available `12.50` default despite a package-level affecting binding or qualified assignment. + +The revised complete suites pass in [full-final.log](../../sandbox/source-values/engine-revision/full-final.log), exit 0: + +- `./internal/frontend/combined`: 3.402 s reported package time. +- `./internal/frontend/engine`: 0.990 s reported package time. + +These native Go suites include the original metadata/provider cases, #18 identity/exposure controls, both reviewer counterexamples and eight added outer-scope scenarios. Local native results do not establish browser/WASM behavior or upstream acceptance. + +The new scenarios retain exact context equality against actual correspondence and distinguish: + +1. A package-level binding affecting `a`, while parallel `b` retains `12.50` with its original provider and exact expression bytes. +2. A cross-file split namespace binding and qualified assignment, with the limitations below explicitly tested through real parsed symbols. +3. A binding in another package whose resolved qualified endpoint targets `P::a`; parallel `P::b` stays available. +4. Repeated `one`/`two` assembly usages, with an external binding or assignment targeting only `one.left.number`. `one.right`, `two.left` and `two.right` retain the base default with no provider leakage. +5. An independently selected member whose enclosing definition has a qualified assignment. The engine's unresolved path is reported unsupported; a separate `P::b` remains available. +6. Competing outer assignments: their affected occurrence is unsupported rather than given an arbitrary precedence, and its parallel usage retains its own default. + +Two additional pinned-engine limitations were observed during these controls: + +- Repeated `package P` declarations across files make `expose P::a` return an empty rendering with a notice that it does not resolve, despite strict parsing without error diagnostics. This revision does not change exposure/import resolution. The split-namespace control therefore additionally walks the real parsed symbols and directly exercises the metadata resolver for `a` and `b`. Both relevant targets are unsupported because their endpoint paths are unresolved. This is **source-scope safety coverage, not successful rendering coverage**. +- A qualified redefinition inside an owning definition can produce no `ChainTargetPath` despite no error diagnostics. The independent-member control explicitly requires unsupported for that relevant unresolved scope, including its right sibling, rather than pretending the engine proved the sibling unaffected. The separate `P::b` remains available. This explains a conservative limitation rather than establishing generalized valuation semantics. + +The original bridge/no-metadata [baseline-final.log](../../sandbox/source-values/engine-revision/baseline-final.log) fails with `missing source metadata for P::a/number`, exit 1. Three behavioral negative controls also fail at their intended assertions, each exit 1: + +- [external-negative-final.log](../../sandbox/source-values/engine-revision/external-negative-final.log): bypassing the external-effect guard restores the wrong available default in both reviewer examples. +- [context-negative-final.log](../../sandbox/source-values/engine-revision/context-negative-final.log): ignoring an internal qualified provider returns `12.50` where the exact `301` provider is required. +- [provider-negative-final.log](../../sandbox/source-values/engine-revision/provider-negative-final.log): substituting the selected subject for an inherited provider fails expression/provider byte containment. + +`git apply --check` passes for the revised patch. `node --check` passes for its private preparation script. Neither command applies the patch or prepares assets. Initial private broadened tests failed because they assumed split-namespace exposure worked and that an independently selected member's path resolved; their retained logs show the actual limitations. The final tests assert these uncertainties explicitly and retain unaffected-occurrence controls, rather than omitting the cases or claiming a rendered result that did not occur. + +## Reproduction + +From the editor repository: + +```bash +sandbox/source-values/engine-revision/run-tests.sh review-red \ + > sandbox/source-values/engine-revision/review-red-final.log 2>&1 +# Expected exit 1 against the original candidate. +sandbox/source-values/engine-revision/run-tests.sh full \ + > sandbox/source-values/engine-revision/full-final.log 2>&1 +# Expected exit 0 for revised combined + engine suites. +sandbox/source-values/engine-revision/run-tests.sh baseline \ + > sandbox/source-values/engine-revision/baseline-final.log 2>&1 +sandbox/source-values/engine-revision/run-tests.sh external-negative \ + > sandbox/source-values/engine-revision/external-negative-final.log 2>&1 +sandbox/source-values/engine-revision/run-tests.sh context-negative \ + > sandbox/source-values/engine-revision/context-negative-final.log 2>&1 +sandbox/source-values/engine-revision/run-tests.sh provider-negative \ + > sandbox/source-values/engine-revision/provider-negative-final.log 2>&1 +# Baseline and each negative control must exit 1 at their expected assertion. +python3 sandbox/source-values/engine-revision/make-patch.py +git apply --check sandbox/source-values/engine-revision/candidate.patch +node --check sandbox/source-values/engine-revision/prepare-opensysml.mjs +``` + +The runner verifies Go 1.27.3, uses pinned OpenSysML 0.12.0 source under `sandbox/opensysml-012/source`, private `GOCACHE`, the existing module cache, `GOTOOLCHAIN=local`, `GOPROXY=off`, `GOSUMDB=off`, and `-mod=readonly -overlay ... -count=1`. No canonical source or module metadata was modified. Overlay paths are absolute and must be regenerated when moved. + +The make-patch script regenerates the patch and original context/provider mutation sources. The retained external-negative mutation file and corresponding overlay already match the final source. Its sole mutation replaces the external-guard condition with false while retaining a compilable branch; it is never included in the production patch. + +## Remaining limits and integration seam + +This closes the demonstrated external-scope false-default hole by explicit uncertainty, not a complete binding/default precedence implementation. External assignment provenance, binding-derived values, unresolved engine paths, library constraints and broad implicit precedence remain outside available-value support. Conservative same-scope unsupported outcomes should be visible in inspection. Exact binding/assignment declaration text in the reason is not a structured relationship API. + +The parent must still independently review this revision, deliberately apply it, prepare the pinned engine/provenance, and verify TypeBox publication, real browser values, selection, drafts, saved geometry/reopen, readability and visual quality. No large-model performance measurement was run. The source-effect inventory is once per normalization, but per-attribute matching still depends on authored effect count and semantic queries; the original ADCS assembly/stakeholder models remain necessary acceptance inputs. + +Only the new private revision directory and this new research note were written. No shared Go or TypeScript files, existing reports, assets, pins, dependencies, build/server/browser processes, commits, pushes or remote services were changed. No subagents were started. diff --git a/docs/research/source-values-final-review.md b/docs/research/source-values-final-review.md new file mode 100644 index 0000000..bab0087 --- /dev/null +++ b/docs/research/source-values-final-review.md @@ -0,0 +1,23 @@ +# Final source-values review + +Recorded 2026-11-11 against the current working tree, including the retained engine revisions and uncommitted UI/tests since `618e776`. This is an independent source review after the earlier engine and UI findings; it is not a browser or visual approval. + +No confirmed remaining blocker was found in the reviewed paths. The earlier reports describe pre-fix states and should not be read as current failures. + +## Fixes verified by inspection + +The engine inventories external effects across project documents, distinguishes relative binding endpoints using the attachment AST and semantic membership, and retains explicitly namespace-qualified targets. Attribute-like redefinitions include kindless forms through `IsAttributeLike`. Bound owners with authored expressions fail closed rather than borrowing their type's member default. Source-value enrichment retains correspondence contexts and exact source declaration/provider/expression origins; it does not evaluate expressions or change graph identity. These observations verify the implemented guards, not exhaustive SysML precedence. + +The publication validator checks applied UTF-8 slices, declaration identity, provider expression containment, provenance, exact usage context and duplicate compartment rows. The previously reviewed production publication sites pass applied documents. Provider navigation checks the current model hash and exact applied/draft equality again at invocation; recorded inspection ranges reject changed drafts, malformed ranges and split UTF-8 characters. + +Tree previews prioritize declarations inside the selected owner's source span. Local display names come from model data. Structural cards now render a node's own source-attribute row as well as compartment rows, and reserve space using the same count. Full authored expressions remain available in Inspector. + +The camera receives the exact inspected symbol and maps a port symbol to its rendered owner. Its fallback chooses a noncompound structural component (preferring a part) before a root container. It no longer consults an unrelated React Flow selection flag. Inspector reopening applies a pixel minimum only when the remembered percentage would be too small, retaining the percentage path on wider screens. Requirement identifier/documentation rendering is outside the optional model-element branch, repairing anonymous-row inspection. + +## Acceptance boundary + +This review ran no build, preparation, native tests, browser, server or screenshot comparison. The parent owns the fresh test results and screenshot audit; their reported results are not independent execution evidence here. In particular, the interrupted empty-container visual captures must not be accepted as final baselines. Completion still requires the parent's current normal visual comparison and direct inspection of desktop/narrow captures in both themes, plus the in-progress critical browser run. These are acceptance checks, not newly established application defects. + +Source inspection cannot establish readable visible text, unclipped expressions, routing geometry, focus behavior or preservation after real-engine reopen. The retained browser cases now explicitly target authored values in exact usage contexts, anonymous requirement source access, custom Inspect-to-camera handoff, draft retention and responsive panes; their existence alone is not a passing result. No performance or exhaustive accessibility claim is made. + +Read repository instructions, architecture ownership, requirements/data/layout/structural contracts, the three earlier review reports and the current source fixes. Wrote only this new report. No production edits, shared builds/assets, browser/server work, dependency changes, remote operations, commits or subagents were used. diff --git a/docs/research/source-values-implementation.md b/docs/research/source-values-implementation.md new file mode 100644 index 0000000..44f6879 --- /dev/null +++ b/docs/research/source-values-implementation.md @@ -0,0 +1,80 @@ +# Private source-value engine candidate + +Recorded 2026-11-11 against editor `618e776466817d9064027b8c9756065df05766bb`, branch `feature/source-values-inspection`. This is a privately tested candidate, not an applied production change or a rebuilt worker. The agreed shape is [the sandbox contract](../../sandbox/source-values/contract.md); the [assessment](source-values-engine-assessment.md) records the original projection loss. + +Review [candidate.patch](../../sandbox/source-values/engine-implementation/candidate.patch). All candidate code, embedded-fixture tests, overlays, scripts, logs and build cache live in [engine-implementation](../../sandbox/source-values/engine-implementation/). This report is the only new document written outside that directory. Existing parent research, tests and concurrent frontend changes were preserved. + +## Candidate behavior + +The patch adds `source_values.go` and `source_values_test.go`, invokes enrichment at the end of `normalizedRendering` after correspondence is complete, adds both files to preparation's overlay/digest inputs and engine copy mapping, and makes a narrow adjustment to the existing #18 golden comparison. Actual engine-bridge files and the preparation script remain untouched. + +Attribute nodes receive optional `sourceAttribute`. Effective compartment members receive optional `sourceAttributes`. Both use the same resolver, exact applied declaration slices and correspondence context chains. Temporary exploration uses that same normalization path. The patch preserves existing attributeDeclarations, graph IDs, parent/child structure, endpoints, selected scope and correspondence. It adds no saved-format field, pin, dependency, asset or evaluation step. + +Values carry raw authored expression text, exact UTF-8 byte origin inside the provider declaration, provider identity, explicit default/initial booleans and own/redefined provenance. Available values never use formatted AST output, source regex, runtime evaluation or reference-value substitution. Optional type labels come from semantic `FeatureTypes` only when it returns one type. Provider subject construction uses the semantic symbol directly rather than rescanning the entire library index for every row. + +Provider traversal follows semantic explicit redefinitions, stops at an own expression, detects cycles, rejects multiple distinct providers and collapses diamond paths by actual symbol identity. Missing AST/source, unresolved redefinitions, out-of-declaration expressions and implicit-provider precedence are explainable unsupported outcomes. Absence means no declared expression found along the supported graph, not a computed lack of value. + +Full usage contexts are resolved hop by hop through effective `MembersOf` and semantic redefinition identity. The resolver does not match names. For contextual attribute redefinitions it uses `ChainTargetPath`, which retains the occurrence path that terminal `RedefinedFeatures` alone loses. Every relative path must match the corresponding owner-context suffix. Competing contextual candidates are unsupported; simultaneous non-default own and contextual assignments are unsupported rather than given invented precedence. If an attribute node's rendered declaration differs from its contextually effective member, it explicitly reports unsupported contextual projection instead of borrowing that declaration's value. + +Standalone bindings are detected in authored owner/inherited scopes through AST declarations, including anonymous bindings omitted by named `MembersOf`. All attributes in such a scope are conservatively unsupported for value precedence, including attributes with defaults. The reason retains the binding's exact source declaration and `file@byteOffset:length` location when available. This is source-backed explanatory text, not structured relationship inspection or binding-derived value inference. + +## Verified evidence + +Final [full-final.log](../../sandbox/source-values/engine-implementation/full-final.log): complete `./internal/frontend/combined` and `./internal/frontend/engine` suites PASS, package times 5.700 s and 0.488 s. This includes the existing #18 occurrence/exposure tests and all original engine tests, plus the new metadata and provider-safety regressions. These are local native Go results, not browser/WASM/hosted CI results. + +The retained regression checks: + +- Local numeric overrides `24.0`/`48.0` and local String overrides `"local α"`/`"local 证据"` with their own providers. +- Complete long and Unicode Strings, Boolean `true`, `2 [m]`, `(2 + 3) * 4`, raw reference `number`, initial `7`, inherited default `12.50`, no declared expression and valueless redefinitions. +- Inheritance through two valueless redefinition levels, with the original provider identity retained. +- Repeated explicit nested part redefinitions yielding `101` and `202`, with distinct provider declarations under `nestedA` and `nestedB`. +- Repeated qualified attribute redefinitions `pair1.left.number` yielding `301` and `402` in their distinct outer usage contexts; parallel `pair2` contexts retain `12.50`. These are distinct contextual assignments, not merely equal defaults under different IDs. +- Exact declaration and expression byte slices, expression containment inside provider origins, complete context equality against correspondence, provider identity/provenance and exact expression spelling. Attribute nodes, compartment rows and exploration metadata are exercised. +- A real semantic multiple-provider ambiguity, synthetic graph cycle/diamond/own-precedence cases, missing AST/source, external-document provider and invalid expression origin. Synthetic fault injection is identified in the test and does not claim that invalid cyclic SysML passed conformance validation. +- Standalone binding unsupported status, including the exact retained `bind leftValue = rightValue;` text; it never turns the right side into `5`. + +[baseline-final.log](../../sandbox/source-values/engine-implementation/baseline-final.log) proves the metadata regression compiles against the unchanged bridge and fails at `missing source metadata for P::a/number`. Its overlay adds only a black-box test; it does not add candidate implementation code. The canonical `studio_view.go` and real bridge `evaluate_view.go` independently had the same SHA-256, `20882395a5436b033d1493667bcdfb11e368854250f3c28dcf7e1aeafc564e80`. + +Two deliberate behavioral mutations independently fail: + +- [context-negative-final.log](../../sandbox/source-values/engine-implementation/context-negative-final.log): ignoring a contextual provider returns the base `12.50` instead of required `301` from `P::qualifiedA::overrideNumber`. +- [provider-negative-final.log](../../sandbox/source-values/engine-implementation/provider-negative-final.log): substituting the selected subject for the inherited provider fails the expression byte/provider containment assertion for `P::c/number`. + +An initial negative-control attempt failed compilation because its mutation left a variable unused; that was corrected. Only the subsequent behavioral failure is negative-control evidence. Early private runs also exposed the anonymous-binding enumeration gap and a library-scope binding false positive; the final candidate walks authored AST scopes instead. Earlier logs remain for transparency and are superseded by the `*-final.log` files. + +The #18 goldens intentionally describe the original envelope. The candidate helper recursively copies their values while omitting **only** `sourceAttribute` and `sourceAttributes`; it does not mutate results or strip any other field. Both the original semantic-node comparison and complete Explore-envelope comparison use it. All original ID, origin, context, selection, endpoint and legacy-node assertions remain. New metadata is independently asserted by the dedicated tests rather than replacing the old goldens. + +`git apply --check sandbox/source-values/engine-implementation/candidate.patch` passed against the actual checkout. `node --check sandbox/source-values/engine-implementation/prepare-opensysml.mjs` passed. The preparation script was syntax-checked only, never executed. The final patch's original-file hashes are retained in [base-sha256.json](../../sandbox/source-values/engine-implementation/base-sha256.json). + +## Reproduction + +From the editor repository, with the existing canonical pinned source, Go 1.27.3 and module cache: + +```bash +sandbox/source-values/engine-implementation/run-tests.sh baseline \ + > sandbox/source-values/engine-implementation/baseline-final.log 2>&1 +# Expected exit 1: missing metadata on the unchanged bridge. +sandbox/source-values/engine-implementation/run-tests.sh full \ + > sandbox/source-values/engine-implementation/full-final.log 2>&1 +# Expected exit 0: complete combined and engine suites. +sandbox/source-values/engine-implementation/run-tests.sh context-negative \ + > sandbox/source-values/engine-implementation/context-negative-final.log 2>&1 +sandbox/source-values/engine-implementation/run-tests.sh provider-negative \ + > sandbox/source-values/engine-implementation/provider-negative-final.log 2>&1 +# Each negative control must exit 1 at its semantic assertion. +python3 sandbox/source-values/engine-implementation/make-patch.py +git apply --check sandbox/source-values/engine-implementation/candidate.patch +node --check sandbox/source-values/engine-implementation/prepare-opensysml.mjs +``` + +The runner uses only `sandbox/opensysml-012/go/bin/go`, verifies version 1.27.3, sets private `GOCACHE`, existing `sandbox/opensysml-012/go-modules`, `GOTOOLCHAIN=local`, `GOPROXY=off`, `GOSUMDB=off`, and passes `-mod=readonly -overlay ... -count=1`. The source fixture is embedded in the retained candidate test so normal future preparation tests need no special environment variable. `controls.sysml` is the readable matching fixture. Overlay JSON contains absolute workspace paths; regenerate paths if transferring the scratch directory. No checkout/worktree was created or copied wholesale. + +## Material limits and next seams + +This candidate is not a general effective-valuation implementation. Explicit nested redefinitions and resolved qualified paths above are verified. Mixed competing assignments deliberately remain unsupported. Generalized default precedence, positional/implicit redefinitions, hydrated/library provider AST/source, and binding-derived values need separate work. Library binding declarations are not enumerated by the authored-scope guard; general library constraint/default precedence remains unverified. The guard also marks unrelated authored attributes unsupported when their scope contains a separate binding, favoring an explainable limitation over a potentially wrong default. + +The correct next seam for broader binding support is semantic connector-end/feature-path provenance keyed by the same complete usage context, followed by explicit binding/default precedence. Preserve relationship subject and endpoint source origins in a separately agreed structured contract; the current contract has only unsupported reason text for this evidence. Do not parse names or follow a binding to a computed value. A renderer that supplies a declaration inconsistent with its effective attribute occurrence needs occurrence-aware projection/correspondence, not silent replacement of legacy IDs. + +Missing selected-attribute source cannot provide an exact declaration; the candidate emits unsupported with an empty declaration in that exceptional case. Normal authored fixtures cannot trigger this, and the parent's publication validator must continue rejecting invalid/non-project subject origins. Missing provider source and invalid expression origins are directly tested. Large-model performance has not been benchmarked; final fixture tests run below one second for the engine package on this machine, which is not a scale guarantee. + +No normal prepare, product build/check, shared WASM rebuild, server, browser, port 4175 access, dependency/cache-metadata change, commit, push or remote operation was performed. The parent's browser baseline remains expected red until deliberate integration and rebuild. This candidate does not establish frontend rendering, TypeBox publication, selection/reveal UX or visual acceptance; those remain parent-owned. diff --git a/docs/research/source-values-ui-review.md b/docs/research/source-values-ui-review.md new file mode 100644 index 0000000..8834f21 --- /dev/null +++ b/docs/research/source-values-ui-review.md @@ -0,0 +1,72 @@ +# Source values, inspection and camera review + +Recorded 2026-11-11 against the uncommitted frontend/workspace slice based on editor `618e776466817d9064027b8c9756065df05766bb`. This reviews the parent’s implementation before its response to the findings below. The parent acknowledged both findings and is fixing them; this note is not a post-fix approval. + +Scope: source-value TypeBox/publication validation, inspection/source separation, camera controls, panel restoration and focused tests. Engine value-provider semantics are reviewed separately. No production files, shared assets, builds, browser/server processes, commits or remote services were changed by this review. + +## P2: anonymous requirement inspection loses source-backed documentation + +At the review point, `Properties.tsx:115–129` renders `requirement.shortName` and `requirement.documentation` only inside the `element` branch. Its `subject` fallback at lines 205–233 renders identity and source navigation but omits those fields. `Workbench` derives `element` from `state.model.elements`, while requirements extraction has an independent source-subject contract. A real anonymous requirement reaches the no-element fallback: + +```sysml +package P { + requirement { doc /* The pump shall preserve pressure. */ } +} +``` + +The isolated pinned-native [probe](../../sandbox/source-values/ui-review/anonymous_test.go) strict-parses that source. `GetSymbol(P)` returns `childIds: [P::]`; the semantic worker explicitly skips IDs ending in `::`. `EvaluateRelationships` nevertheless returns a complete requirement row with qualified name `P::` and the exact documentation. The table can inspect this row, but its Inspector fallback omits the already supplied text. [Native output](../../sandbox/source-values/ui-review/anonymous.log), exit 0, reported package time 0.032 s. + +My initial message incorrectly suggested that the 2,000-symbol limit could create a truncated model: the full guard actually throws and rejects parsing. That proposed large-model trigger is withdrawn. The anonymous-requirement control above independently establishes a native source/extraction trigger; no full browser reproduction is claimed. + +The isolated [server-rendering regression](../../sandbox/source-values/ui-review/fallback.test.ts) passes a real-shaped requirement row with `REQ-42`, documentation, a source subject and no `element`. It confirms the identity is present, then fails because `REQ-42` is absent; the retained HTML also lacks the documentation. [Output](../../sandbox/source-values/ui-review/fallback.log), exit 1. This controlled React test verifies the UI fallback, not native SysML extraction or a browser workflow. + +Render source-backed requirement details independently of the optional model-tree entry. The same principle applies to available artifact documentation and usage context when a diagram subject is absent from the model-tree entry. Do not fabricate a `ModelElement` or renderer identity to enter the richer branch. + +## P2: Read at 100% can focus a different item than the Inspector + +`useDiagramRead.ts:10–16` chooses `getNodes().find(node => node.selected)`, then a root. It has no input carrying the shell’s inspected symbol. However, the requirement card’s **Inspect details** button stops propagation and calls `select(item.id)` (`RequirementNode.tsx:61–64`); structural port buttons do the same for their port ID (`StructureNode.tsx:113–117`). These update shell inspection through `Workbench.selectSymbol`, but do not update React Flow’s `node.selected`. Model-tree selection likewise updates shell inspection without synchronizing React Flow selection. + +Concrete control flow: select node A on the canvas, then use the explicit Inspect button on B (or a port on B). Inspector changes to B, while React Flow can retain A as its selected node. **Read at 100%**, titled “Read selection or first element at 100%”, then centers A. This breaks the intended inspection-to-readable-detail handoff. This finding is established by the event handlers and state ownership; no browser reproduction is claimed. + +Pass the exact currently inspected diagram symbol into the camera action, mapping a selected port to its owning rendered node. Preserve complete usage identity for repeated declarations. Keep first-element fallback explicit when no current diagram occurrence can be resolved; do not match a tree label or a short name to an arbitrary occurrence. + +## Source and preservation assessment + +All production `validateEvaluation` publication sites inspected now receive the corresponding applied document set: the OpenSysML adapter, old/new view reconciliation, connection validation/postconditions, named-view opening, and exploration through its shared validation path. No missed production caller was found. Optional metadata leaves older responses usable; present metadata without applied documents is rejected rather than treated as trustworthy. + +The new validator verifies declaration IDs against byte origins, exact declaration/expression slices, expression containment in the provider, own/redefined identity consistency, node/compartment context equality and duplicate rows. These are transport/source integrity checks. It appropriately does not claim to prove SysML provider precedence from source text; native semantic selection remains the bridge’s responsibility. Existing controlled tests distinguish those scopes. + +`inspectionSource` bounds-checks integer UTF-8 ranges, refuses changed/missing drafts and rejects split code points before converting to editor UTF-16 positions. Inspection targets are model-hash gated; provider-source navigation additionally checks the current workspace snapshot. The reviewed handlers keep inspection separate from changing the active file/editor mode. No confirmed stale-source publication defect was found in this inspection. + +The panel reopen change uses pixel minimums when a stored desktop percentage would be too small, and transfers focus on phone selection because opening Inspector collapses Explorer. The request counter changes only for explicit selection. Workbench panels remain mounted/inert during internal launcher navigation, so returning from Projects does not itself rerun the inspection effect. No confirmed panel-lifetime defect was found. + +The new camera action changes viewport only, and the automatic readable-entry path leaves explicit Fit view as an overview operation. Structural counts prefer new metadata but retain legacy declaration strings as fallback. Provider expressions are rendered as authored text, without evaluation. These source inspections do not establish browser geometry compatibility, routing quality or visual acceptance. + +## Validation and remaining acceptance + +Ran the controlled React regression: + +```sh +fnm exec --using 26.3.1 npx vitest run \ + --config sandbox/source-values/ui-review/vitest.config.ts \ + > sandbox/source-values/ui-review/fallback.log 2>&1 +``` + +One test failed at the intended missing-identifier assertion. The initial private config emitted a missing-file-extension warning, which did not cause the assertion failure. + +Also ran the anonymous-requirement native probe twice, refining the second run to assert the actual `P::` model-tree child ID: + +```sh +GOCACHE="$PWD/sandbox/source-values/ui-review/gocache" \ +GOMODCACHE="$PWD/sandbox/opensysml-012/go-modules" \ +GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off \ +sandbox/opensysml-012/go/bin/go \ + -C "$PWD/sandbox/opensysml-012/source" test -mod=readonly \ + -overlay "$PWD/sandbox/source-values/ui-review/anonymous-overlay.json" \ + ./internal/frontend/combined -run '^TestUIReviewAnonymousRequirement$' \ + -count=1 -v > sandbox/source-values/ui-review/anonymous.log 2>&1 +``` + +The virtual test overlays only a new combined-package test file onto the parent-prepared pinned source, with a private Go cache and offline/read-only module use. It does not alter engine code or prepare shared assets. No full app/unit suite, normal preparation/build, browser, screenshot comparison or remote check was run by this reviewer. Parent-owned running checks are not claimed here. + +Before completion, retain post-fix coverage for fallback metadata and the actual custom Inspect/port-to-Read100 handoff, alongside pointer/keyboard selection, changed drafts, saved positions/fresh reopen, and long/Unicode expression access. The new readability tests currently establish entry zoom, one-click overview/read transitions and unchanged portable data; a zoom threshold alone cannot establish text visibility, unclipped details or routing quality. Actual assembly/stakeholder captures and overlap measurements at desktop/narrow sizes in both themes remain necessary. Visual-project verification is also required by the repository instructions. diff --git a/docs/research/wp26-screenshot-review.md b/docs/research/wp26-screenshot-review.md new file mode 100644 index 0000000..4f292e2 --- /dev/null +++ b/docs/research/wp26-screenshot-review.md @@ -0,0 +1,79 @@ +# WP26 screenshot review and next issues + +Recorded 2026-11-11; read-only research against local HEAD `618e776466817d9064027b8c9756065df05766bb`. + +The strongest next user outcomes are readable initial diagrams and exact authored values, followed by predictable selection details and incremental file addition. The screenshots also expose genuine capability gaps, but they do not establish that every historical complaint remains a current bug. + +## Evidence and limits + +Primary historical source: [Zargham’s complete report, pinned to 325e2e3fb53d178d88ec7aad5994d08d0a9b743a](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback.md). The table links every original JPG at that same immutable commit. Local [manifest](../../sandbox/wp26-session/screenshots/download-manifest.json), [GitHub inventory](../../sandbox/wp26-session/screenshots/github-inventory.json) and [full report copy](../../sandbox/wp26-session/screenshots/REPORT.md) retain provenance. I independently checked all 31 local JPG SHA-256 values against the manifest and compared report references with manifest/files: 31 referenced, 31 present, none missing or uncited. + +**D** below means I opened that original directly with `view_image` (8 images). **C** means appearance is attributed to the coordinator’s individual inspection of all 31, recorded in [coordinator observations](../../sandbox/wp26-session/screenshots/coordinator-observations.md); I did not independently view those 23. Expectations are the author’s report, not inferred requirements. The last column is current source/document assessment, not a new runtime verification. + +The report names the hosted URL and date, but its deployed build identity is unknown. Repeated stale banners are persistence of one symptom, not independent defects. Working/draft-pending captures do not prove completed states. `cold-base-applied` visibly shows the full CubeSat rather than the described minimal Demo: checkpoint association is uncertain; the report itself records capture lag. Original pixel sizes prevent exact tiny text, link, or count claims. All node/edge counts come from report narrative, not pixel counting. + +## Complete image-to-issue map + +| Original | Inspection | Observed appearance | Author’s reported expectation/context | Current assessment / issue | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| [assembly-tall-column.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/assembly-tall-column.jpg) | D | Thin vertical tower in a wide canvas; labels too small. | Readable connected assembly; report says 24 nodes/4 edges. | #30; inherited repair does not solve layout. | +| [case-no-renderer.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/case-no-renderer.jpg) | C | Explicit unavailable case renderer and long notices. | Draw calculations/analysis cases. | #17/#29; capability gap, not parse failure. | +| [cold-base-applied.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/cold-base-applied.jpg) | D | CubeSat header, requirements thumbnail, no red banner, Issues 0. | Report associates this with minimal Demo and one namespace warning. | #14; checkpoint association uncertain, not minimal-model proof. | +| [cold-case-filter.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/cold-case-filter.jpg) | C | Demo source with unavailable renderer and calculation notice. | Render filtered case/calculation definitions. | #17; current unavailable scope documented. | +| [cold-explorer-menu.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/cold-explorer-menu.jpg) | C | Explorer options offer Narrow/Widen; large graph behind. | Add a second file without replacing project. | #16; multi-file initial import already exists. | +| [cold-expose-filtered.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/cold-expose-filtered.jpg) | C | Bare Sat under filtered view. | Consistent exposure interpretation. | #19; filter differs from tree comparison. | +| [cold-expose-star.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/cold-expose-star.jpg) | D | Dense oversized graph beside Demo source; tiny labels. | Owned-only wildcard scope. | #20; expectation incorrect for immediate imports; intermediate defect separate. | +| [cold-expose-tree.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/cold-expose-tree.jpg) | C | Sat with a/b/c in tree. | Same selected scope across representations. | #19; selection versus renderer descendants must be separated. | +| [cold-inherited-dup.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/cold-inherited-dup.jpg) | C | Inherited r1 conflict ending after from; prior diagram retained. | Accept qualified verify target or explain rejection. | #21; naming collision valid, diagnostic incomplete. | +| [cold-inherited-parts.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/cold-inherited-parts.jpg) | D | sat1 with a/b, no visible c; Working/draft pending. | Include inherited c. | #18 implemented; transient is not completed-state proof. | +| [cold-owned-dup.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/cold-owned-dup.jpg) | C | Two owned-name errors and namespace warning. | Verify two distinct qualified targets. | #22; effective names collide; preserve rejection. | +| [cold-recovery-restore.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/cold-recovery-restore.jpg) | C | Working/draft pending; waiting/empty diagram. | Restore latest durable source. | #15/#34; transient proves neither completion nor exact contents. | +| [concern-view-no-values.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/concern-view-no-values.jpg) | D | Layered small boxes, hard to read; tiny attribute text. | Show pointer strings, case context and stakeholder bindings. | #24/#17/#27; exact absent values/links rely on report, not tiny pixels. | +| [configurations-bare.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/configurations-bare.jpg) | D | Three name/type configuration boxes, no values on faces. | Read assigned configuration values. | #24; existing part compartments do not resolve all usage values. | +| [configurations-overview.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/configurations-overview.jpg) | C | Expanded configuration/member tree, name/type labels. | Expansion should also show values. | #24/#30; structure alone does not communicate assigned values. | +| [duplicate-inherited-member.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/duplicate-inherited-member.jpg) | D | Draft rejected; inherited launchBudget diagnostic ends after from. | Qualified target accepted, or useful owner explanation. | #21; valid collision and malformed diagnostic are separate. | +| [duplicate-owned-member.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/duplicate-owned-member.jpg) | C | Owned-name errors without useful member names; prior matrix retained. | Distinguish two qualified targets. | #22; reference-preserving alternatives require explicit semantics. | +| [expose-overview-tree.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/expose-overview-tree.jpg) | C | Readable selected-member ownership tree. | Useful bounded overview with understandable scope. | #19/#24; does not isolate representation semantics. | +| [expose-star-imports.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/expose-star-imports.jpg) | C | Giant graph/grid, illegible labels. | About twenty owned Probe elements. | #20; report counts 958/1770 are narrative, not pixel counts. | +| [inherited-parts-missing.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/inherited-parts-missing.jpg) | C | Large usage root with two small children. | Nine subparts including inherited ones. | #18; current source retains effective structural occurrences. | +| [matrix-concern-rows.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/matrix-concern-rows.jpg) | C | Concern-qualified requirements and Declared verification cells. | Keep chain and add evidence context. | #25/#26; useful existing declaration workflow. | +| [no-add-file.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/no-add-file.jpg) | C | One file and Explorer size menu; repeated red banner. | Incremental Add file action. | #16; repeated #14 banner is not another defect. | +| [recovery-clash-wording.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/recovery-clash-wording.jpg) | C | Conflict message and Retry; consequences unclear. | Explain which copy each action keeps. | #23 merged via PR36; no data-loss cause established. | +| [recovery-condensed-copy.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/recovery-condensed-copy.jpg) | C | Source begins at package/imports; red banner. | Latest expected full source restored. | #15 merged; image cannot establish length/hash or data loss. | +| [requirements-table.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/requirements-table.jpg) | C | Requirement rows, identifier/doc dashes, declared-link columns. | Evidence/environment and meaningful identifiers/docs. | #25/#28/#33; no identifiers may be invented from arbitrary prose. | +| [requirements-view.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/requirements-view.jpg) | C | Wide, shallow, tiny requirements graph. | Keep typing/satisfy/not satisfy/verify relationships. | #30; preserve working declaration and negated-satisfaction semantics. | +| [stale-banner.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/stale-banner.jpg) | C | Red correspondence banner alongside clean applied-model Issues. | Clear obsolete alert after successful relevant action. | #14 implemented in PR37; parse success alone is not render success. | +| [table-concern-rows.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/table-concern-rows.jpg) | C | Filtered concern-qualified requirement rows and existing columns. | Concern/stakeholder rows and evidence fields. | #25/#26/#28; partial viewport is not full row inventory. | +| [traceability-satisfaction.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/traceability-satisfaction.jpg) | D | Declared and Negated declaration cells; declaration disclaimer. | Preserve accurate positive/negative satisfaction. | Working baseline; do not turn declarations into executed results. | +| [traceability-verification.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/traceability-verification.jpg) | C | Verification-case columns and declaration disclaimer. | Show evidence/environment alongside declarations. | #25/#17; blank visible cells alone prove no missing link. | +| [verification-no-renderer.jpg](https://github.com/DynamicalSystemsGroup/hyperbolic-trade-space/blob/325e2e3fb53d178d88ec7aad5994d08d0a9b743a/work-packages/WP26-view-editor-feedback/verification-no-renderer.jpg) | C | Unavailable case renderer with clean parse state. | Render verification definitions and relations. | #17; prioritize bounded inspection before execution ambitions. | + +## What exists now + +Status supplied by the coordinator and the retained [issue inventory](../../sandbox/wp26-session/screenshots/issues.json): PR36 merged at develop `2f58240`; #15/#23 closed. PR37 head `618e776` has the same source tree as tested `3ca0d7f`; it was watched for required CI and **not yet merged at last check**. #14/#18 were implemented and closed at the user’s direction pending that merge. This review neither refreshed remote status nor claims hosted deployment. Parent coordination owns PR37. + +Committed [verification](../verification.md) records bounded local checks and skipped coverage for #14/#18/#20. [Structural views](../architecture/structural-views.md) describes effective inherited occurrences, usage contexts, saved-geometry constraints and selection versus rendered descendants. The current [bridge](../../engine-bridge/opensysml/evaluate_view.go) retains inherited occurrences in authored interconnection views; tree/exploration scope remains distinct. Recovery visualization errors are scoped separately from accepted replacement; zero parsing issues still cannot promise rendering success. + +#20 remains a genuine **intermediate-import** admission defect. Immediate exposure may include private imported memberships; screenshot graph size cannot settle visibility semantics. The committed [membership investigation](wp26-exposure-import-membership.md) and [retained fixture](../../tests/fixtures/opensysml-0.12-exposure-membership_test.go) distinguish exact origin-selection failures from valid immediate exposure. The attempted resolver fix was rejected because it lost a valid split-namespace import. Do not compensate by filtering library/private nodes in the renderer. + +Part attribute compartments already display source declarations: [StructureNode](../../src/lib/workbench/diagrams/StructureNode.tsx) renders `attributeDeclarations` with exact declaration titles and [attribute-label](../../src/lib/workbench/diagrams/attribute-label.ts) formats labels. This is distinct from missing usage-specific/redefined values and attribute-tree value labels (#24). It is not evidence of computed results. The existing [Properties inspector](../../src/lib/workbench/properties/Properties.tsx) already supplies identity, usage context, source actions and declaration disclaimers; #33 is a discoverability/content/handoff improvement, not creation of an inspector from nothing. + +The local, **uncommitted and unpublished** [#21/#22 follow-up](../../sandbox/wp26-session/verification-semantics/REPORT.md) traces official effective-name rules and real-engine controls. Qualified targets can still derive the same effective name; the reported collisions are legitimate. Empty anonymous-owner context/trailing `from` and unhelpful owned-name diagnostics are real diagnostic defects. Explicit `::>` reference members (including explicit redefinition where appropriate) preserve targets in both AST and recorded positive controls ([positive.log](../../sandbox/wp26-session/verification-semantics/positive.log)). Ordinary `:>` subsetting is different; its recorded reflection returns the wrong referenced end in the retained negative control ([test.log](../../sandbox/wp26-session/verification-semantics/test.log)). That is a separate bounded reflection defect, not permission to relax name validation or claim browser/deployment failure. I read these records; I did not rerun them. + +## Recommended sequence and bounded outcomes + +1. **Complete PR37 separately, then record #21/#22 semantics and diagnostics.** Document valid collisions and explicit reference alternatives; retain severity/rejection and exact-target controls. A diagnostic repair must identify the conflicting member and source-backed owner/location, including anonymous objectives. Keep ordinary-subsetting reflection as a separate investigation. Publication of ignored follow-up evidence is still outstanding. +2. **Pair #24 with #30/#31 as the next user-facing slice.** Two distinct usages must display their exact authored assignments/redefinitions and source origins without borrowing the other usage’s value. Preserve authored expressions and units; explain absent/unsupported values, never invent evaluation. For an assembly and stakeholder fixture at desktop and approximately 800 px, the initial view must let a reader read a useful first group without repeated zoom, with discoverable navigation to the rest. Preserve saved positions; define measured label/overlap criteria before implementation. More value text without space/layout work risks making this worse. +3. **#33 selection details, with a small #28 documentation slice where supported.** Table and tree pointer/keyboard selection should visibly identify the exact element, declaration and usage context, with a discoverable detail/source action. Preserve drafts, active view and source; show only source-backed documentation/metadata. Do not infer identifiers from arbitrary doc prose. Existing Inspector behavior and #18’s keyboard handoff must remain intact. +4. **#16 incremental Add file.** Add a second uniquely named source into the live project without replacing the first; show validation failure without losing drafts, source history or layouts; preserve both files through download/reopen. Initial multi-file import is already supported. This is bounded workflow value after the information-reading slice; it can move earlier if separate-file authoring becomes the immediate task. +5. **Stage #17/#25/#26/#27 as the verification-chain project.** First define source-backed case usage/subject/objective/verify/attribute identities, then expose evidence/environment pointer declarations in inspection (#25), concern/stakeholder context (#26), and resolved stakeholder bindings (#27), with explicit unsupported cases. A compact case list/detail fallback for #17 may deliver value before a full renderer. Preserve existing concern-qualified requirement links and never label pointer strings as executed evidence. #29 calculation dependencies should follow a separately bounded relationship contract. + +Keep #20’s exact-membership upstream work active in parallel as a correctness track; it need not block these bounded UI improvements. #19 needs a controlled comparison with identical exposure/filter inputs and separate selected-origin/descendant assertions before calling it a representation defect. #32/#34 deserve small, observed control/status improvements, but the cold-reader anecdotes have no retained screenshots and do not justify broad state-flow rewrites. + +This mostly supports the coordinator’s order, with one adjustment: make readable entry an acceptance condition of the value-display slice, not a later cosmetic task. The report explicitly prioritizes the verification chain; a bounded #17 inspection fallback should precede a large renderer if it unlocks #25 sooner. Do not expand this into model execution. Preserve requirements inspection, negated satisfaction, failed-draft retention and the declaration-not-execution distinction throughout. + +## Work performed + +Read repository AGENTS/README, the full original report, inventory/manifest/issue snapshot, coordinator notes, relevant committed verification/architecture/research docs, bridge/rendering/Inspector source, and the retained semantics report/logs. Direct image coverage is precisely the eight D rows; remaining visual claims are attributed C. Only this new note was written. No browser automation, new runtime tests/builds/servers, cache changes, branch operations, commits, remote edits or deployment were performed. Existing test results above belong to their cited records, not this review. + +Coordinator review: corrected the pinned report URL, checked local link targets and formatted the note. The note remains a local draft, separate from PR #37. diff --git a/docs/testing.md b/docs/testing.md index 6225e5f..289c9f7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -82,10 +82,14 @@ Use real engines for supported semantic command acceptance, source application, `diagram-style.spec.ts` checks structural label hierarchy, selection outline, keyboard port selection and physical connection endpoints through appearance changes, comparing the exact portable project before/after. It checks OUT-label contrast across all six palettes in light/dark. Selected structural-card baselines cover desktop/narrow widths in both themes. +The four source-value visual fixtures keep bundled Geist for Latin and explicitly select Liberation Sans for fallback glyphs. Geist’s bundled subset omits Greek; an unpinned `system-ui` fallback rendered the same `α` evidence pointer differently on the developer host and CI. Playwright’s Linux browser dependencies include Liberation fonts. This normalization belongs only to those visual fixtures: application styles, exact Unicode values, functional checks and snapshot tolerances are unchanged. + 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. +`source-values.spec.ts`, `inspection.spec.ts` and `readability.spec.ts` cover source-backed expressions, explicit inspection and the overview/detail camera path. The actual pinned worker supplies keyword-free and inherited redefinitions in distinct usage contexts; tests check provider source ranges, unavailable bindings, retained drafts, independent positions and fresh-context reopen. Inspection cases cover tree/table selection, anonymous requirement documentation and phone focus in both themes. Readability checks assert the exact inspected occurrence is centered at 100%, including when React Flow retains an older selection, and preserve camera, preferences and drafts across responsive resizing. Four source-value snapshots cover desktop/narrow widths in both themes. These checks do not establish expression evaluation or general binding precedence. + ## Investigate failures and review baselines ```sh diff --git a/docs/verification.md b/docs/verification.md index c29d37b..a060410 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -1,5 +1,36 @@ # Verification record +## Source-backed values and readable inspection — #24, #30, #31, #33 + +Recorded 2026-11-11 on `feature/source-values-inspection`, based on `618e776`. Research is committed in `5ea53cf`; source metadata and publication checks in `88b5b6c`, with non-project metadata scope corrected in `d596ad9`. PR37 was independently merged into develop at `3971a25a6a2aafb7cbbd48bb93d15bb17765809f`; this feature is separate and not deployed. + +The bridge retains exact authored expressions, declaration/provider byte origins, default/initial flags and full occurrence contexts. It supports explicit and keyword-free redefinitions, including distinct nested assignments. External assignments/bindings, bound-owner member values and unresolved/ambiguous provider paths are explained as unsupported rather than evaluated. Library records retain their graph nodes without project-source metadata. The TypeBox/workspace boundary verifies applied source bytes and context/provider consistency at publication, including transaction reconciliation. No dependency, engine pin or saved-format change. + +Independent [engine review](research/source-values-engine-review.md), [revision review](research/source-values-engine-revision-review.md) and [UI review](research/source-values-ui-review.md) found real defects, now repaired. The [final source review](research/source-values-final-review.md) found no remaining confirmed blocker by inspection; it does not claim runtime/visual approval. In addition to the private candidate's baseline/context/provider negative controls, retained parent controls show: + +- Keyword-free `:>>` redefinitions fail before `IsAttributeLike` classification and pass after it (`sandbox/source-values/keyword-free-red.log`, `keyword-free-green.log`). +- A local binding must not mask a separately exposed definition's default. Relative, namespace-qualified and explicitly external paths are distinguished by AST plus semantic identity. Reintroducing the scope leak fails the exact regression (`final-validation/scope-negative.log`); the repaired review controls pass (`scope-green.log`). +- A reference owner cannot borrow its type's default for member values (`final-validation/owner-red.log`, then `native-full-final.log`). The recorded-library projection control also fails before its metadata guard (`final-validation/library-red.log`); this is a controlled projection seam, not a new visibility/exposure claim. +- Restoring React Flow's stale selection as the 100% target fails the browser centering assertion by 534 px (`read-camera-negative.log`). Restoring exact shell selection passes it alongside the complete rover tour (`read-camera-green.log`: two passed). The negative mutation was removed before the green build. + +All these paths are relative to ignored `sandbox/source-values/`. The committed Go/TypeScript/browser fixtures retain the actual regressions; scratch overlays and captures are local retained evidence. + +`npm run check` passed formatting, architecture, TypeScript, 179 Vitest tests and ten Node script tests. Normal engine preparation passed the complete combined/engine Go suites; fresh browser builds recheck the prepared asset checksums and reuse matching cached assets. Current generated provenance: OpenSysML `760c4d1fc8edfa4e214423099065c0a7c1b24e2d`, Go 1.27.3, overlay `087feaad7d9369751d0124dffb8fcaecef9fe1137ca7d3cf75dc694e26e327b8`, WASM `6112ab6e5d64f2349cd9b8c667caecc7508624006534b9eff79128d03cda3835`. Assets/provenance remain ignored generated files. + +New real-engine browser scenarios passed exact local/nested/valueless values, provider source selection, missing/binding values, anonymous requirement documentation, table/tree inspection, phone focus in both themes, responsive preferences/camera/drafts, and fresh-context reopening with exact positions (`browser-keyword-free.log`: eleven passed). The wider first critical run exposed two off-screen interaction assumptions and an old formatted-label expectation. The rerun passed 25/26; its remaining full-namespace text expectation was replaced by the retained full-title identity check because previews now use local model names. The focused camera/rover run then passed both. The final combined acceptance run passed all 135 scenarios in 14.6 minutes: 42 selected functional scenarios, including all 26 critical paths, and all 93 normal visual comparisons (`release.log`, output `sandbox/playwright/source-values-release`). No baseline updates, retries or skips were used in that run. Source/endpoint/context/history/preservation assertions were retained; overview geometry checks explicitly request Fit view, and drag helpers use camera navigation for off-screen symbols. + +The original three ADCS files were downloaded at immutable `325e2e3fb53d178d88ec7aad5994d08d0a9b743a`; hashes are retained in `adcs/manifest.json`. Four browser scenarios cover assembly, configurations and stakeholders at 1600/800 in light/dark (`adcs-keyword-free.log`: four passed; final UI replay `adcs-final.log`: four passed in 55.0 seconds). They assert raw `0.8 [m]`, `140.0 [W*h]`, `0.35 [m]`, `40.0 [W*h]` and evidence pointer strings, plus source/draft/presentation equality through camera actions. The final replay directly asserts 100% entry and zero fitted-overview routing/label-overlap hits in all twelve views. All twelve entry captures were inspected on `visual-review-final/adcs-entry-1.jpg` and `adcs-entry-2.jpg`. Final fitted scales are assembly 16.4%/15.2%, configurations 52.1%/31.5%, stakeholders 44.5%/26.9%; readable entry/detail is 100%. These measurements do not imply simultaneous legibility of the full graph. + +The first regeneration was stopped after visual review rejected empty-container entry and selected-card captures. Readable entry now starts on a component; selected-card scenarios explicitly use 100%. The corrected generation passed all 93 visual scenarios (`visual-generated2.log`), followed by the passing normal comparison above. All 66 modified and four new screenshots were inspected on twelve sheets under `visual-review-final/`, with the four new value/Inspector captures also inspected individually at full size. Its `audit.json` retains exact paths and SHA-256 hashes; all 70 remained unchanged during normal comparison, and 58 existing captures are unchanged. No image tolerances or semantic assertions were relaxed. + +The structural column layout remains unchanged: tested ELK aspect-ratio/compaction options did not repack the ADCS nested assembly. This slice supplies an overview/detail camera path and bounded value previews with full Inspector access. Generalized valuation/binding precedence, library source navigation, modeled layout, the full functional suite, complete accessibility, cross-browser parity and hosted deployment are not established. + +### CI Unicode fallback normalization + +Recorded 2026-11-12 after [PR38’s initial CI run](https://github.com/Open-MBEE/sysml_view_editor/actions/runs/43025896174/job/130540400327). The run passed 89 visual scenarios and failed only the four new source-value captures, with 10–327 mismatching pixels. Retained actual/diff images locate the differences at Greek `α` in diagram evidence pointers; node positions and Inspector content match. The unchanged four scenarios passed locally (`sandbox/source-values/ci-visual-before.log`), so the earlier local result did not establish cross-host font parity. + +The bundled Geist asset intentionally includes Latin only and falls back to host fonts for Greek. Changing only the visual fixture’s fallback to Liberation Sans made the existing desktop baseline fail by 131 pixels in the same labels (`ci-font-red.log`), demonstrating fallback sensitivity rather than reproducing the CI font byte-for-byte. The four visual fixtures now explicitly select that common Linux fallback, supplied by Playwright’s browser dependencies, while retaining Geist for Latin. Application styles, Unicode fixture content, semantic assertions and pixel tolerances are unchanged. Regeneration passed all four scenarios (`ci-font-generated.log`); all four old/new glyph crops were inspected under `ci-43025896174/font-review/`. The subsequent normal run passed all six selected scenarios in 45.1 seconds: four visual comparisons without baseline updates and both unchanged real-engine source-value functional cases (`ci-font-final.log`). A fresh production build passed through that run; `npm run check` again passed formatting, architecture, TypeScript, 179 unit tests and ten script tests (`ci-font-check.log`). The other 89 visual scenarios and full functional suite were not rerun locally for this fixture-only change; hosted CI must establish the new head’s runner result. + ## Inherited usage members — issue #18 Recorded 2026-11-11 on `fix/wp26-inspection`, after local commits `d96ee70` (#14) and `4eff493` (#20 investigation). The authored interconnection bridge now retains native inherited structural occurrences and their full declaration/usage contexts. Tree and temporary exploration behavior, inherited attribute compartments and connector-selection policy remain bounded as documented in [structural views](architecture/structural-views.md#inherited-structural-occurrences). diff --git a/engine-bridge/README.md b/engine-bridge/README.md index 985673e..6ff6c64 100644 --- a/engine-bridge/README.md +++ b/engine-bridge/README.md @@ -30,3 +30,5 @@ Structural attribute compartments retain exact source slices for effective non-l 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). Authored interconnection normalization retains native inherited structural occurrences with complete declaration/usage correspondence, while reserving legacy node/port IDs for unchanged saved presentations. Tree and temporary exploration filtering remain unchanged. Native depth/recursion bounds, attribute compartments and explicit connector-selection policy are retained. The combined Go regressions cover exact occurrence semantics, legacy identities and unchanged exploration output. + +Derived source-value metadata retains exact declaration/provider bytes and complete usage contexts for supported local and explicit redefinition cases. External bindings/assignments, ambiguous providers and unresolved applicable source scopes are reported unsupported. Neither the bridge nor the UI evaluates expressions. Normal preparation includes the source-value Go regressions and records the additive overlay in generated provenance; engine pins and portable project format remain unchanged. [Source-value contract and limits](../docs/architecture/structural-views.md#source-backed-attribute-expressions). diff --git a/engine-bridge/opensysml/evaluate_view.go b/engine-bridge/opensysml/evaluate_view.go index d6d77c6..01acccc 100644 --- a/engine-bridge/opensysml/evaluate_view.go +++ b/engine-bridge/opensysml/evaluate_view.go @@ -368,6 +368,9 @@ func (cached *cachedModel) normalizedRendering(out EvaluatedView, selected []*sy for i, edge := range data.Edges { out.Correspondence = append(out.Correspondence, ViewCorrespondence{Symbol: fmt.Sprintf("edge:%d", i), Subject: subject(edge.Origin), Context: []string{}}) } + if err := cached.sourceValues(&out, declarations); err != nil { + return nil, err + } return marshal(out) } diff --git a/engine-bridge/opensysml/source_values.go b/engine-bridge/opensysml/source_values.go new file mode 100644 index 0000000..f6a2de8 --- /dev/null +++ b/engine-bridge/opensysml/source_values.go @@ -0,0 +1,592 @@ +package engine + +import ( + "encoding/json" + "fmt" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +type SourceExpressionOrigin struct { + File string `json:"file"` + Offset int `json:"offset"` + Length int `json:"length"` +} +type SourceValue struct { + Status string `json:"status"` + Expression string `json:"expression,omitempty"` + ExpressionOrigin *SourceExpressionOrigin `json:"expressionOrigin,omitempty"` + Provider *ViewSubject `json:"provider,omitempty"` + IsDefault *bool `json:"isDefault,omitempty"` + IsInitial *bool `json:"isInitial,omitempty"` + Provenance string `json:"provenance,omitempty"` + Reason string `json:"reason,omitempty"` +} +type SourceAttribute struct { + Subject ViewSubject `json:"subject"` + Name string `json:"name"` + Type string `json:"type,omitempty"` + Context []string `json:"context"` + Declaration string `json:"declaration"` + Value SourceValue `json:"value"` +} + +// Symbol identity is already semantic here; avoid rescanning the entire library +// index for every compartment row. +func (c *cachedModel) sourceSubject(s *symbols.Symbol) ViewSubject { + o := s.Origin() + ref := ViewSubject{ID: originKey(o), QualifiedName: c.Index.GetFQN(s)} + if o.Located() { + ref.Origin = &ViewOrigin{File: o.Doc, Offset: o.Span.Offset, Length: o.Span.Len, NameOffset: o.Name.Offset, NameLength: o.Name.Len} + } + return ref +} + +func sourceUnsupported(reason string) SourceValue { + return SourceValue{Status: "unsupported", Reason: reason} +} + +// Walk the complete provider graph. An own expression terminates that branch; +// diamond paths collapse by symbol identity, never by text or traversal order. +// The injectable edge function also allows adversarial cycle/ambiguity controls. +func sourceProviders(sym *symbols.Symbol, edges func(*symbols.Symbol) []*symbols.Symbol) ([]*symbols.Symbol, string) { + found := map[*symbols.Symbol]bool{} + active := map[*symbols.Symbol]bool{} + done := map[*symbols.Symbol]bool{} + var visit func(*symbols.Symbol) string + visit = func(s *symbols.Symbol) string { + if s == nil || s.Decl == nil { + return "missing AST" + } + if active[s] { + return "cyclic source providers" + } + if done[s] { + return "" + } + active[s] = true + defer delete(active, s) + if _, _, _, ok := ast.ValuePartOf(s.Decl); ok { + found[s] = true + } else { + targets := edges(s) + if len(targets) == 0 { + for _, rel := range semantics.RelationshipsOf(s) { + if rel.Kind == ast.RelRedefines { + return "unresolved source redefinition" + } + } + } + for _, r := range targets { + if why := visit(r); why != "" { + return why + } + } + } + done[s] = true + return "" + } + if why := visit(sym); why != "" { + return nil, why + } + out := []*symbols.Symbol{} + for s := range found { + out = append(out, s) + } + if len(out) > 1 { + return nil, "ambiguous source providers" + } + return out, "" +} +func (c *cachedModel) sourceDeclaration(s *symbols.Symbol) (string, bool) { + if s == nil || s.Decl == nil { + return "", false + } + span := s.Decl.Span() + for _, d := range c.Documents { + if d.Source != nil && d.Source.Name() == s.DocName && span.Offset >= 0 && span.Len > 0 && span.Offset <= d.Source.Len()-span.Len { + return d.Source.Text(span), true + } + } + return "", false +} +func (c *cachedModel) sourceValue(selected, provider *symbols.Symbol) SourceValue { + implicit := false + m := c.typedModel() + providers, why := sourceProviders(provider, func(s *symbols.Symbol) []*symbols.Symbol { + explicit := m.RedefinedFeatures(s) + for _, target := range m.DirectRedefinedFeatures(s) { + found := false + for _, known := range explicit { + if known == target { + found = true + } + } + if !found { + implicit = true + } + } + return explicit + }) + if implicit { + return sourceUnsupported("implicit source-provider precedence unsupported") + } + if why != "" { + return sourceUnsupported(why) + } + if len(providers) == 0 { + return SourceValue{Status: "absent"} + } + p := providers[0] + if _, ok := c.sourceDeclaration(p); !ok { + return sourceUnsupported("missing applied provider source") + } + value, def, initial, _ := ast.ValuePartOf(p.Decl) + sp := value.Span() + decl := p.Decl.Span() + if sp.Len <= 0 || sp.Offset < decl.Offset || sp.Offset > decl.Offset+decl.Len-sp.Len { + return sourceUnsupported("expression outside provider declaration") + } + expression := c.sourceText()(p.DocName, sp) + if len(expression) != sp.Len { + return sourceUnsupported("missing expression source") + } + ref := c.sourceSubject(p) + provenance := "redefined" + if p == selected { + provenance = "own" + } + return SourceValue{Status: "available", Expression: expression, ExpressionOrigin: &SourceExpressionOrigin{p.DocName, sp.Offset, sp.Len}, Provider: &ref, IsDefault: &def, IsInitial: &initial, Provenance: provenance} +} + +// Identity equivalence is directed specialization, not matching short names. +func sourceSpecializes(m *semantics.Model, actual, target *symbols.Symbol) bool { + if actual == target { + return true + } + for _, s := range m.AllRedefinedFeatures(actual) { + if s == target { + return true + } + } + return false +} +func (c *cachedModel) sourceBinding(owner *symbols.Symbol) string { + m := c.typedModel() + for _, scope := range append([]*symbols.Symbol{owner}, m.MemberSources(owner)...) { + if c.Index.IsLibraryDocument(scope.DocName) { + continue + } + for _, node := range ast.DeclMembers(scope.Decl) { + if member, ok := node.(*ast.Membership); ok { + node = member.Member + } + if u, ok := node.(*ast.Usage); ok && u.Kind == ast.UsageBinding { + sp := u.Span() + for _, doc := range c.Documents { + if doc.Source != nil && doc.Source.Name() == scope.DocName && sp.Offset >= 0 && sp.Len > 0 && sp.Offset <= doc.Source.Len()-sp.Len { + return fmt.Sprintf("%s@%d:%d: %s", scope.DocName, sp.Offset, sp.Len, doc.Source.Text(sp)) + } + } + return "binding source unavailable" + } + } + } + return "" +} + +// Source scopes and renderer occurrence contexts are different. Inventory authored +// declarations once per rendering, including anonymous bindings and split namespaces. +// External effects remain unsupported rather than being assigned invented precedence. +type sourceEffect struct { + declaration *symbols.Symbol + paths [][]*symbols.Symbol + kind string + relative []bool +} + +func (c *cachedModel) sourceEffects() []sourceEffect { + m := c.typedModel() + effects := []sourceEffect{} + seen := map[*symbols.Symbol]bool{} + var walk func(*symbols.Scope) + walk = func(scope *symbols.Scope) { + if scope == nil { + return + } + scope.ForEachMember(func(s *symbols.Symbol) bool { + if seen[s] || s.Decl == nil { + return true + } + seen[s] = true + if kind, ok := s.UsageKind(); ok && kind == ast.UsageBinding { + paths := m.ConnectorEndFeaturePaths(s) + if len(paths) == 0 { + paths = [][]*symbols.Symbol{nil} + } + ends := m.ConnectorObjectEnds(s) + relative := make([]bool, len(paths)) + for i, path := range paths { + if i < len(ends) { + relative[i] = sourceRelativeEffect(m, s.Owner(), path, ends[i].Attachment) + } + } + effects = append(effects, sourceEffect{declaration: s, paths: paths, kind: "binding", relative: relative}) + } else { + for _, rel := range semantics.RelationshipsOf(s) { + if rel.Kind != ast.RelRedefines { + continue + } + if _, chain := rel.Target.(*ast.FeatureChainExpr); chain { + path := m.ChainTargetPath(s, rel.Kind, rel.Target) + effects = append(effects, sourceEffect{declaration: s, paths: [][]*symbols.Symbol{path}, kind: "qualified assignment", relative: []bool{sourceRelativeEffect(m, s.Owner(), path, rel.Target)}}) + } else if s.Kind.IsAttributeLike() { + // Non-chain attribute redefinitions do not carry a + // complete occurrence path. Internal declarations are + // still resolved normally; external scope ambiguity + // must not make a default appear proven. + effects = append(effects, sourceEffect{declaration: s, paths: [][]*symbols.Symbol{nil}, kind: "attribute redefinition"}) + } + } + } + return true + }) + for _, child := range scope.Children() { + walk(child) + } + } + for _, doc := range c.Index.WorkspaceDocuments() { + walk(c.Index.DocumentRoot(doc)) + } + return effects +} + +// A resolved endpoint loses namespace qualification. Keep the AST reference +// form so an unqualified local member is relative to the binding's usage, +// while an explicit P::b.number or P::Base::number stays an external target. +func sourceRelativeEffect(m *semantics.Model, lexical *symbols.Symbol, path []*symbols.Symbol, node ast.Node) bool { + if lexical == nil || !(lexical.IsFeature() || lexical.Kind.IsDefinition()) || len(path) == 0 { + return false + } + for { + if chain, ok := node.(*ast.FeatureChainExpr); ok { + node = chain.Operand + continue + } + break + } + var name *ast.QualifiedName + switch n := node.(type) { + case *ast.QualifiedName: + name = n + case *ast.FeatureReference: + name = n.Name + } + if name == nil || name.Global || len(name.Parts) != 1 { + return false + } + for _, member := range m.MembersOf(lexical) { + if sourceSpecializes(m, member, path[0]) { + return true + } + } + return false +} + +func sourceMemberSource(m *semantics.Model, occurrence, source *symbols.Symbol) bool { + if occurrence == source { + return true + } + for _, base := range m.MemberSources(occurrence) { + if base == source { + return true + } + } + return false +} + +// Prefix a root exposed from inside an owned feature with its actual lexical +// feature owners. This never appends or rewrites the portable renderer context. +func sourceLexicalPath(path []*symbols.Symbol) []*symbols.Symbol { + if len(path) == 0 { + return nil + } + prefix := []*symbols.Symbol{} + for owner := path[0].Owner(); owner != nil && (owner.IsFeature() || owner.Kind.IsDefinition()); owner = owner.Owner() { + prefix = append([]*symbols.Symbol{owner}, prefix...) + } + return append(prefix, path...) +} +func sourcePathEqual(m *semantics.Model, actual, target []*symbols.Symbol) bool { + if len(actual) != len(target) || len(actual) == 0 { + return false + } + for i, step := range target { + if !sourceSpecializes(m, actual[i], step) { + return false + } + } + return true +} + +// An unresolved semantic path cannot prove that a same-scope declaration is +// irrelevant. Fail closed within that source scope, never across the whole project. +func sourceEffectScopeApplies(c *cachedModel, m *semantics.Model, occurrence []*symbols.Symbol, lexical *symbols.Symbol) bool { + if lexical == nil { + return true + } + featureScope := lexical.IsFeature() || lexical.Kind.IsDefinition() + for _, step := range occurrence { + if featureScope && sourceMemberSource(m, step, lexical) { + return true + } + if !featureScope { + for owner := step.Owner(); owner != nil; owner = owner.Owner() { + // Split namespace declarations share semantic namespace identity but have + // different source origins. This compares the index's FQN, not local names. + if owner == lexical || c.Index.GetFQN(owner) == c.Index.GetFQN(lexical) { + return true + } + } + } + } + return false +} + +func (c *cachedModel) externalSourceEffect(s *symbols.Symbol, owners []*symbols.Symbol, effects []sourceEffect) string { + m := c.typedModel() + occurrence := append(append([]*symbols.Symbol{}, owners...), s) + full := sourceLexicalPath(occurrence) + for _, effect := range effects { + lexical := effect.declaration.Owner() + internal := false + for _, owner := range owners { + if sourceMemberSource(m, owner, lexical) { + internal = true + break + } + } + if internal { + continue + } // The existing occurrence resolver owns these scopes. + for i, path := range effect.paths { + if len(path) == 0 && sourceEffectScopeApplies(c, m, full, lexical) { + text, _ := c.sourceDeclaration(effect.declaration) + o := effect.declaration.Origin() + return fmt.Sprintf("external %s target path unresolved in relevant source scope; source-value precedence unsupported; %s@%d:%d: %s", effect.kind, o.Doc, o.Span.Offset, o.Span.Len, text) + } + target := path + if i < len(effect.relative) && effect.relative[i] && (len(path) == 0 || path[0] != lexical) { + target = append([]*symbols.Symbol{lexical}, path...) + } + matches := sourcePathEqual(m, full, sourceLexicalPath(target)) + // An enclosing definition may be reached through a concrete typed usage; + // apply its relative path only inside that exact semantic owner occurrence. + if !matches && lexical != nil && (lexical.IsFeature() || lexical.Kind.IsDefinition()) { + for i, owner := range full[:len(full)-1] { + if sourceMemberSource(m, owner, lexical) && sourcePathEqual(m, full[i+1:], path) { + matches = true + break + } + } + } + if !matches { + continue + } + text, ok := c.sourceDeclaration(effect.declaration) + if !ok { + return "external " + effect.kind + "; applied source unavailable" + } + o := effect.declaration.Origin() + return fmt.Sprintf("external %s affects this occurrence; source-value precedence unsupported; %s@%d:%d: %s", effect.kind, o.Doc, o.Span.Offset, o.Span.Len, text) + } + } + return "" +} + +func (c *cachedModel) sourceAttribute(s *symbols.Symbol, owners []*symbols.Symbol, context []string, effects []sourceEffect) SourceAttribute { + m := c.typedModel() + a := SourceAttribute{Subject: c.sourceSubject(s), Name: m.EffectiveNameOf(s), Context: append([]string{}, context...)} + var ok bool + a.Declaration, ok = c.sourceDeclaration(s) + if !ok { + a.Value = sourceUnsupported("missing applied attribute source") + return a + } + types := m.FeatureTypes(s) + if len(types) == 1 { + a.Type = c.Index.GetFQN(types[0]) + } + if why := c.externalSourceEffect(s, owners, effects); why != "" { + a.Value = sourceUnsupported(why) + return a + } + // An assigned/reference owner may denote another occurrence. Its type's + // default is not evidence of that object's member values; resolving owner + // expressions is outside this source-expression projection. + for _, owner := range owners { + if _, _, _, assigned := ast.ValuePartOf(owner.Decl); assigned { + o := owner.Origin() + a.Value = sourceUnsupported(fmt.Sprintf("owner has an authored value expression; bound-owner member values unsupported; %s@%d:%d", o.Doc, o.Span.Offset, o.Span.Len)) + return a + } + } + // Bindings may override a default without supplying an attribute ValuePart. + // Until their precedence/provenance is implemented, fail closed for this scope. + for _, owner := range owners { + if binding := c.sourceBinding(owner); binding != "" { + a.Value = sourceUnsupported("standalone binding in usage context; source-value precedence unsupported; " + binding) + return a + } + } + candidates := map[*symbols.Symbol]bool{} + for i, owner := range owners { + relative := append(append([]*symbols.Symbol{}, owners[i+1:]...), s) + for _, member := range m.MembersOf(owner) { + for _, rel := range semantics.RelationshipsOf(member) { + if rel.Kind != ast.RelRedefines { + continue + } + path := m.ChainTargetPath(member, rel.Kind, rel.Target) + if _, chain := rel.Target.(*ast.FeatureChainExpr); chain && len(path) == 0 { + a.Value = sourceUnsupported("unresolved contextual redefinition path") + return a + } + + if len(path) < 2 || len(path) != len(relative) { + continue + } + match := true + for j, step := range path { + if !sourceSpecializes(m, relative[j], step) { + match = false + break + } + } + if match { + candidates[member] = true + } + } + } + } + if len(candidates) > 1 { + a.Value = sourceUnsupported("ambiguous contextual source providers") + return a + } + provider := s + for candidate := range candidates { + // A simultaneous nested override and outer chain assignment requires a + // precedence rule; neither traversal order nor an own default proves it. + if _, _, _, own := ast.ValuePartOf(s.Decl); own { + _, def, _, _ := ast.ValuePartOf(s.Decl) + if !def { + a.Value = sourceUnsupported("competing own and contextual assignments") + return a + } + } + provider = candidate + } + a.Value = c.sourceValue(s, provider) + return a +} + +// Enrich only after full correspondence exists. IDs, selection, edges, legacy +// declaration strings, and correspondence remain untouched, including Explore. +func (c *cachedModel) sourceValues(out *EvaluatedView, declarations map[string]*symbols.Symbol) error { + var artifact map[string]json.RawMessage + if err := json.Unmarshal(out.Artifact, &artifact); err != nil { + return err + } + var nodes []map[string]json.RawMessage + if err := json.Unmarshal(artifact["nodes"], &nodes); err != nil { + return err + } + refs := map[string]ViewCorrespondence{} + for _, ref := range out.Correspondence { + refs[ref.Symbol] = ref + } + m := c.typedModel() + effects := c.sourceEffects() + for _, node := range nodes { + var id string + json.Unmarshal(node["id"], &id) + ref, ok := refs[id] + if !ok { + continue + } + sym := declarations[ref.Subject.ID] + if sym == nil || c.Index.IsLibraryDocument(sym.DocName) { + continue + } + owners := []*symbols.Symbol{} + valid := true + for _, key := range ref.Context { + next := declarations[key] + if next == nil { + valid = false + break + } + if len(owners) > 0 { + matches := []*symbols.Symbol{} + for _, member := range m.MembersOf(owners[len(owners)-1]) { + if sourceSpecializes(m, member, next) { + matches = append(matches, member) + } + } + if len(matches) != 1 { + valid = false + break + } + next = matches[0] + } + owners = append(owners, next) + } + if sym.Kind.IsAttributeLike() { + a := c.sourceAttribute(sym, nil, ref.Context, effects) + if valid && len(owners) > 0 { + a = c.sourceAttribute(sym, owners[:len(owners)-1], ref.Context, effects) + if owners[len(owners)-1] != sym { + a.Value = sourceUnsupported("attribute occurrence resolves to a different effective member; contextual projection unsupported") + } + } else { + a.Value = sourceUnsupported("usage context cannot be resolved uniquely") + } + encoded, err := json.Marshal(a) + if err != nil { + return err + } + node["sourceAttribute"] = encoded + } + effectiveOwner := sym + if valid && len(owners) > 0 { + effectiveOwner = owners[len(owners)-1] + } + attributes := []SourceAttribute{} + for _, member := range m.MembersOf(effectiveOwner) { + if !member.Kind.IsAttributeLike() || c.Index.IsLibraryDocument(member.DocName) { + continue + } + context := append(append([]string{}, ref.Context...), originKey(member.Origin())) + a := c.sourceAttribute(member, owners, context, effects) + if !valid { + a.Value = sourceUnsupported("usage context cannot be resolved uniquely") + } + attributes = append(attributes, a) + } + if len(attributes) > 0 { + encoded, err := json.Marshal(attributes) + if err != nil { + return err + } + node["sourceAttributes"] = encoded + } + } + encoded, err := json.Marshal(nodes) + if err != nil { + return err + } + artifact["nodes"] = encoded + out.Artifact, err = json.Marshal(artifact) + return err +} diff --git a/engine-bridge/opensysml/source_values_test.go b/engine-bridge/opensysml/source_values_test.go new file mode 100644 index 0000000..7b90959 --- /dev/null +++ b/engine-bridge/opensysml/source_values_test.go @@ -0,0 +1,919 @@ +package engine + +import ( + "context" + "encoding/json" + "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" + "strings" + "testing" +) + +// This black-box assertion intentionally compiles against the unchanged bridge +// too: missing metadata must fail rather than being mistaken for absence. +func TestSourceValuesMetadataRegression(t *testing.T) { + e, err := New() + if err != nil { + t.Fatal(err) + } + text := sourceValuesFixture + raw, err := e.parseSources(context.Background(), &JParseSourcesRequest{Documents: []*JSourceDocument{{Name: "controls.sysml", Language: "sysml", Content: &text}}, StrictConformance: true}) + if err != nil { + t.Fatal(err) + } + var parsed JParseSourcesResponse + if err = json.Unmarshal(raw, &parsed); err != nil { + t.Fatal(err) + } + for _, d := range parsed.Diagnostics { + if d.Severity == "error" { + t.Fatalf("invalid fixture: %+v", d) + } + } + c, _ := e.get(parsed.ModelHash) + type attribute struct { + Subject ViewSubject `json:"subject"` + Name string `json:"name"` + Context []string `json:"context"` + Declaration string `json:"declaration"` + Value struct { + Status string `json:"status"` + Expression string `json:"expression"` + ExpressionOrigin *struct { + File string `json:"file"` + Offset int `json:"offset"` + Length int `json:"length"` + } `json:"expressionOrigin"` + Provider *ViewSubject `json:"provider"` + IsDefault bool `json:"isDefault"` + IsInitial bool `json:"isInitial"` + Provenance string `json:"provenance"` + Reason string `json:"reason"` + } `json:"value"` + } + attrs := map[string]attribute{} + nodeAttrs := map[string]attribute{} + for _, view := range []string{"V::tree", "V::structure", "explore"} { + raw, err = e.evaluateView(&JRenderViewRequest{ModelHash: parsed.ModelHash, View: view}) + if view == "explore" { + raw, err = e.explore(&JExploreRequest{ModelHash: parsed.ModelHash, Request: ExplorationRequest{Scope: ExplorationScope{Kind: "element", Subject: "P::Base"}, Representation: "interconnection"}}) + } + if err != nil { + t.Fatal(err) + } + var out struct { + Correspondence []ViewCorrespondence `json:"correspondence"` + Artifact struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + SourceAttribute *attribute `json:"sourceAttribute"` + SourceAttributes []attribute `json:"sourceAttributes"` + } `json:"nodes"` + } `json:"artifact"` + } + if err = json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + refs := map[string]ViewCorrespondence{} + for _, ref := range out.Correspondence { + refs[ref.Symbol] = ref + } + for _, node := range out.Artifact.Nodes { + ref := refs[node.ID] + owner := ref.Subject.QualifiedName + all := append([]attribute{}, node.SourceAttributes...) + if node.SourceAttribute != nil { + all = append(all, *node.SourceAttribute) + nodeAttrs[owner] = *node.SourceAttribute + } + for _, a := range all { + key := owner + "/" + a.Name + attrs[key] = a + if a.Subject.Origin == nil { + t.Fatalf("%s missing subject origin", key) + } + o := a.Subject.Origin + if a.Declaration != text[o.Offset:o.Offset+o.Length] { + t.Fatalf("%s declaration byte mismatch", key) + } + expected := append(append([]string{}, ref.Context...), a.Subject.ID) + if node.SourceAttribute != nil && a.Subject.ID == ref.Subject.ID { + expected = ref.Context + } + x, _ := json.Marshal(a.Context) + y, _ := json.Marshal(expected) + if string(x) != string(y) { + t.Fatalf("%s context %s != %s", key, x, y) + } + if a.Value.Status == "available" { + v := a.Value + if v.Provider == nil || v.Provider.Origin == nil || v.ExpressionOrigin == nil { + t.Fatalf("%s missing source provenance", key) + } + p := v.Provider.Origin + ex := v.ExpressionOrigin + if ex.File != "controls.sysml" || ex.Offset < p.Offset || ex.Offset+ex.Length > p.Offset+p.Length || v.Expression != text[ex.Offset:ex.Offset+ex.Length] { + t.Fatalf("%s expression byte/provider mismatch", key) + } + if (v.Provenance == "own") != (v.Provider.ID == a.Subject.ID) { + t.Fatalf("%s wrong provenance", key) + } + } + } + } + } + for _, tc := range []struct { + owner, name, expression, provider string + def, initial bool + }{ + {"P::a", "number", "24.0", "P::a::number", false, false}, {"P::b", "number", "48.0", "P::b::number", false, false}, + {"P::Base", "number", "12.50", "P::Base::number", true, false}, {"P::c", "number", "12.50", "P::Base::number", true, false}, + {"P::Base", "unicode", "\"α/证据.json\"", "P::Base::unicode", false, false}, + {"P::Base", "longText", "\"a long authored evidence path / records / review / literal spelling must survive without clipping the inspector value\"", "P::Base::longText", false, false}, + {"P::a", "unicode", "\"local α\"", "P::a::unicode", false, false}, + {"P::b", "unicode", "\"local 证据\"", "P::b::unicode", false, false}, + {"P::Base", "flag", "true", "P::Base::flag", false, false}, {"P::Base", "quantity", "2 [m]", "P::Base::quantity", false, false}, + {"P::Base", "complex", "(2 + 3) * 4", "P::Base::complex", false, false}, {"P::Base", "linked", "number", "P::Base::linked", false, false}, + {"P::Base", "initial", "7", "P::Base::initial", false, true}, + } { + a, ok := attrs[tc.owner+"/"+tc.name] + if !ok { + t.Fatalf("missing source metadata for %s/%s", tc.owner, tc.name) + } + if a.Value.Status != "available" || a.Value.Expression != tc.expression || a.Value.Provider == nil || a.Value.Provider.QualifiedName != tc.provider || a.Value.IsDefault != tc.def || a.Value.IsInitial != tc.initial { + t.Fatalf("%+v: got %+v", tc, a.Value) + } + } + if a := attrs["P::Base/empty"]; a.Value.Status != "absent" { + t.Fatalf("empty %+v", a) + } + if a := attrs["P::Bound/rightValue"]; a.Value.Status != "unsupported" || (!strings.Contains(a.Value.Reason, "binding") || !strings.Contains(a.Value.Reason, "bind leftValue = rightValue;")) { + t.Fatalf("binding gap %+v", a) + } + if a := nodeAttrs["P::a::number"]; a.Value.Expression != "24.0" { + t.Fatalf("attribute node %+v", a) + } + // Inspect actual occurrence identities, not owner labels that repeat. + raw, err = e.evaluateView(&JRenderViewRequest{ModelHash: parsed.ModelHash, View: "V::structure"}) + if err != nil { + t.Fatal(err) + } + var envelope struct { + Correspondence []ViewCorrespondence `json:"correspondence"` + Artifact struct { + Nodes []struct { + ID string `json:"id"` + SourceAttributes []attribute `json:"sourceAttributes"` + } `json:"nodes"` + } `json:"artifact"` + } + json.Unmarshal(raw, &envelope) + byID := map[string]string{} + c.Index.Registered(func(_ string, ss []*symbols.Symbol) { + for _, s := range ss { + byID[originKey(s.Origin())] = c.Index.GetFQN(s) + } + }) + seen := map[string]bool{} + for _, node := range envelope.Artifact.Nodes { + for _, a := range node.SourceAttributes { + if a.Name != "number" { + continue + } + names := []string{} + for _, id := range a.Context { + names = append(names, byID[id]) + } + joined := strings.Join(names, "/") + want := "" + provider := "" + if strings.Contains(joined, "P::nestedA/") && strings.Contains(joined, "pair1") && strings.Contains(joined, "left") { + want = "101" + provider = "P::nestedA::pair1::left::number" + seen["nestedA"] = true + } + if strings.Contains(joined, "P::nestedB/") && strings.Contains(joined, "pair1") && strings.Contains(joined, "left") { + want = "202" + provider = "P::nestedB::pair1::left::number" + seen["nestedB"] = true + } + if strings.Contains(joined, "P::qualifiedA/") && strings.Contains(joined, "pair1") && strings.Contains(joined, "left") { + want = "301" + provider = "P::qualifiedA::overrideNumber" + seen["qualifiedA"] = true + } + if strings.Contains(joined, "P::qualifiedB/") && strings.Contains(joined, "pair1") && strings.Contains(joined, "left") { + want = "402" + provider = "P::qualifiedB::overrideNumber" + seen["qualifiedB"] = true + } + if want != "" && (a.Value.Expression != want || a.Value.Provider == nil || a.Value.Provider.QualifiedName != provider) { + t.Fatalf("context %s: want %s from %s got %+v", joined, want, provider, a.Value) + } + // Negative control: the parallel pair2 context must retain the base default. + if strings.Contains(joined, "P::qualified") && strings.Contains(joined, "pair2") && a.Value.Expression != "12.50" { + t.Fatalf("provider leaked across context %s: %+v", joined, a.Value) + } + } + } + for _, name := range []string{"nestedA", "nestedB", "qualifiedA", "qualifiedB"} { + if !seen[name] { + t.Fatalf("missing contextual occurrence %s", name) + } + } +} + +func TestSourceValuesProviderSafety(t *testing.T) { + // Fault injection is deliberately below semantic resolution: malformed cycles + // need not be accepted as valid SysML to exercise the bridge's fail-closed seam. + empty := func() *symbols.Symbol { return &symbols.Symbol{Decl: &ast.Usage{}} } + valued := func() *symbols.Symbol { return &symbols.Symbol{Decl: &ast.Usage{Value: &ast.QualifiedName{}}} } + a, b, x, y := empty(), empty(), valued(), valued() + graph := map[*symbols.Symbol][]*symbols.Symbol{a: {b}, b: {a}} + edges := func(s *symbols.Symbol) []*symbols.Symbol { return graph[s] } + if _, why := sourceProviders(a, edges); why != "cyclic source providers" { + t.Fatalf("cycle %q", why) + } + graph[a] = []*symbols.Symbol{x, y} + if _, why := sourceProviders(a, edges); why != "ambiguous source providers" { + t.Fatalf("ambiguity %q", why) + } + graph[a] = []*symbols.Symbol{b, x} + graph[b] = []*symbols.Symbol{x} + if pp, why := sourceProviders(a, edges); why != "" || len(pp) != 1 || pp[0] != x { + t.Fatalf("diamond %v %q", pp, why) + } + // Own expression wins without consulting invalid ancestor graphs. + graph[x] = []*symbols.Symbol{a} + if pp, why := sourceProviders(x, edges); why != "" || len(pp) != 1 || pp[0] != x { + t.Fatalf("own %v %q", pp, why) + } + if _, why := sourceProviders(&symbols.Symbol{}, edges); why != "missing AST" { + t.Fatalf("missing AST %q", why) + } + e, err := New() + if err != nil { + t.Fatal(err) + } + text := `package P { private import ScalarValues::*; + part def A {attribute x:Real default = 1;} + part def B {attribute y:Real default = 2;} + part def C :> A, B {attribute z :>> x :>> y;} + part def Mid :> A {attribute :>> x;} + part deep : Mid {attribute :>> x;} + }` + raw, err := e.parseSources(context.Background(), &JParseSourcesRequest{Documents: []*JSourceDocument{{Name: "safety.sysml", Language: "sysml", Content: &text}}, StrictConformance: true}) + if err != nil { + t.Fatal(err) + } + var parsed JParseSourcesResponse + json.Unmarshal(raw, &parsed) + for _, d := range parsed.Diagnostics { + if d.Severity == "error" { + t.Fatalf("invalid safety fixture %+v", d) + } + } + c, _ := e.get(parsed.ModelHash) + lookup := func(name string) *symbols.Symbol { + ss := c.Index.LookupQualified(name) + if len(ss) != 1 { + t.Fatalf("lookup %s = %v", name, ss) + } + return ss[0] + } + z := lookup("P::C::z") + if v := c.sourceValue(z, z); v.Status != "unsupported" || v.Reason != "ambiguous source providers" { + t.Fatalf("semantic ambiguity %+v", v) + } + deep := lookup("P::deep::x") + v := c.sourceValue(deep, deep) + if v.Status != "available" || v.Expression != "1" || v.Provider.ID != originKey(lookup("P::A::x").Origin()) || v.Provenance != "redefined" { + t.Fatalf("multiple-level inherited default %+v", v) + } + x = lookup("P::A::x") + saved := x.Decl + x.Decl = nil + if v := c.sourceValue(x, x); v.Status != "unsupported" || v.Reason != "missing AST" { + t.Fatalf("missing AST %+v", v) + } + x.Decl = saved + docs := c.Documents + c.Documents = nil + if v := c.sourceValue(x, x); v.Status != "unsupported" || v.Reason != "missing applied provider source" { + t.Fatalf("missing source %+v", v) + } + c.Documents = docs + u := x.Decl.(*ast.Usage) + oldValue := u.Value + u.Value = &ast.QualifiedName{NodeBase: ast.NodeBase{NodeSpan: source.Span{Offset: len(text), Len: 1}}} + if v := c.sourceValue(x, x); v.Status != "unsupported" || v.Reason != "expression outside provider declaration" { + t.Fatalf("invalid expression origin %+v", v) + } + u.Value = oldValue + original := x.DocName + x.DocName = "outside.sysml" + if v := c.sourceValue(x, x); v.Status != "unsupported" { + t.Fatalf("external provider %+v", v) + } + x.DocName = original +} + +const sourceValuesFixture = `package P { + private import ScalarValues::*; + private import SI::*; + part def Base { + attribute number : Real default = 12.50; + attribute unicode : String = "α/证据.json"; + attribute longText : String = "a long authored evidence path / records / review / literal spelling must survive without clipping the inspector value"; + attribute flag : Boolean = true; + attribute quantity = 2 [m]; + attribute complex : Real = (2 + 3) * 4; + attribute empty : Real; + attribute linked : Real = number; + attribute initial : Real := 7; + } + part def Bound { attribute leftValue : Real = 5; attribute rightValue : Real; bind leftValue = rightValue; } + part a : Base { attribute :>> number = 24.0; attribute :>> unicode = "local α"; } + part b : Base { attribute :>> number = 48.0; attribute :>> unicode = "local 证据"; } + part c : Base { attribute :>> number; } + part def Pair { part left : Base; part right : Base; } + part def Outer { part pair1 : Pair; part pair2 : Pair; } + part system : Outer; + part nestedA : Outer { part :>> pair1 { part :>> left { attribute :>> number = 101; attribute :>> unicode = "nested α"; } } } + part nestedB : Outer { part :>> pair1 { part :>> left { attribute :>> number = 202; attribute :>> unicode = "nested 证据"; } } } + part qualifiedA : Outer { attribute overrideNumber :>> pair1.left.number = 301; } + part qualifiedB : Outer { attribute overrideNumber :>> pair1.left.number = 402; } + part def Mid :> Base { attribute :>> number; } + part multilevel : Mid { attribute :>> number; } + +} +package V { + view tree : StandardViewDefinitions::GeneralView { expose P::a; expose P::a::number; expose P::b; expose P::b::number; expose P::Base; expose P::Bound; expose P::c; } + view structure : StandardViewDefinitions::InterconnectionView { expose P::system; expose P::nestedA; expose P::nestedB; expose P::qualifiedA; expose P::qualifiedB; } +} +` + +func TestReviewExternalBinding(t *testing.T) { + text := `package P { + private import ScalarValues::*; + part def Base { attribute number : Real default = 12.50; } + part a : Base; + attribute replacement : Real = 99; + bind a.number = replacement; +} +package V { view structure : StandardViewDefinitions::InterconnectionView { expose P::a; } }` + e, err := New() + if err != nil { + t.Fatal(err) + } + raw, err := e.parseSources(context.Background(), &JParseSourcesRequest{Documents: []*JSourceDocument{{Name: "review.sysml", Language: "sysml", Content: &text}}, StrictConformance: true}) + if err != nil { + t.Fatal(err) + } + var parsed JParseSourcesResponse + json.Unmarshal(raw, &parsed) + for _, d := range parsed.Diagnostics { + if d.Severity == "error" { + t.Fatalf("fixture invalid: %+v", d) + } + } + raw, err = e.evaluateView(&JRenderViewRequest{ModelHash: parsed.ModelHash, View: "V::structure"}) + if err != nil { + t.Fatal(err) + } + t.Log(string(raw)) + var out struct { + Artifact struct { + Nodes []struct { + SourceAttributes []SourceAttribute `json:"sourceAttributes"` + } `json:"nodes"` + } `json:"artifact"` + } + json.Unmarshal(raw, &out) + found := false + for _, n := range out.Artifact.Nodes { + for _, a := range n.SourceAttributes { + if a.Name == "number" { + found = true + if a.Value.Status != "unsupported" { + t.Errorf("outer binding was missed: status=%s expression=%s", a.Value.Status, a.Value.Expression) + } + } + } + } + if !found { + t.Fatal("number metadata not rendered") + } +} + +func TestReviewOuterQualifiedAssignment(t *testing.T) { + text := `package P { + private import ScalarValues::*; + part def Base { attribute number : Real default = 12.50; } + part a : Base; + attribute overrideNumber :>> a.number = 99; +} +package V { view structure : StandardViewDefinitions::InterconnectionView { expose P::a; } }` + e, err := New() + if err != nil { + t.Fatal(err) + } + raw, err := e.parseSources(context.Background(), &JParseSourcesRequest{Documents: []*JSourceDocument{{Name: "review.sysml", Language: "sysml", Content: &text}}, StrictConformance: true}) + if err != nil { + t.Fatal(err) + } + var parsed JParseSourcesResponse + json.Unmarshal(raw, &parsed) + for _, d := range parsed.Diagnostics { + if d.Severity == "error" { + t.Fatalf("fixture invalid: %+v", d) + } + } + raw, err = e.evaluateView(&JRenderViewRequest{ModelHash: parsed.ModelHash, View: "V::structure"}) + if err != nil { + t.Fatal(err) + } + c, _ := e.get(parsed.ModelHash) + override := c.Index.LookupQualified("P::overrideNumber")[0] + for _, rel := range semantics.RelationshipsOf(override) { + if rel.Kind == ast.RelRedefines { + path := c.typedModel().ChainTargetPath(override, rel.Kind, rel.Target) + names := []string{} + for _, step := range path { + names = append(names, c.Index.GetFQN(step)) + } + t.Logf("resolved outer redefinition path: %v", names) + if len(path) != 2 || c.Index.GetFQN(path[0]) != "P::a" || c.Index.GetFQN(path[1]) != "P::Base::number" { + t.Fatal("unexpected semantic target path") + } + } + } + t.Log(string(raw)) + var out struct { + Artifact struct { + Nodes []struct { + SourceAttributes []SourceAttribute `json:"sourceAttributes"` + } `json:"nodes"` + } `json:"artifact"` + } + json.Unmarshal(raw, &out) + found := false + for _, n := range out.Artifact.Nodes { + for _, a := range n.SourceAttributes { + if a.Name == "number" { + found = true + if a.Value.Status == "available" && a.Value.Expression == "12.50" { + t.Errorf("outer contextual assignment was missed: status=%s expression=%s", a.Value.Status, a.Value.Expression) + } + } + } + } + if !found { + t.Fatal("number metadata not rendered") + } +} + +func TestSourceValuesOuterScopes(t *testing.T) { + for _, tc := range []struct { + name string + documents []string + exposure string + unsupported []string + defaults []string + evidence string + }{ + {"parallel binding", []string{`package P { private import ScalarValues::*; part def Base { attribute number : Real default = 12.50; } part a : Base; part b : Base; attribute replacement : Real = 99; bind a.number = replacement; }`}, "expose P::a; expose P::b;", []string{"P::a"}, []string{"P::b"}, "bind a.number = replacement;"}, + {"split namespace binding", []string{`package P { private import ScalarValues::*; part def Base { attribute number : Real default = 12.50; } part a : Base; part b : Base; attribute replacement : Real = 99; }`, `package P { bind a.number = replacement; }`}, "expose P::a; expose P::b;", []string{"P::a"}, []string{"P::b"}, "bind a.number = replacement;"}, + {"split namespace assignment", []string{`package P { private import ScalarValues::*; part def Base { attribute number : Real default = 12.50; } part a : Base; part b : Base; }`, `package P { attribute overrideNumber :>> a.number = 99; }`}, "expose P::a; expose P::b;", []string{"P::a"}, []string{"P::b"}, "attribute overrideNumber :>> a.number = 99;"}, + {"foreign namespace binding", []string{`package P { private import ScalarValues::*; part def Base { attribute number : Real default = 12.50; } part a : Base; part b : Base; attribute replacement : Real = 99; }`, `package Q { bind P::a.number = P::replacement; }`}, "expose P::a; expose P::b;", []string{"P::a"}, []string{"P::b"}, "bind P::a.number = P::replacement;"}, + {"nested repeated binding", []string{`package P { private import ScalarValues::*; part def Base { attribute number : Real default = 12.50; } part def Pair {part left:Base; part right:Base;} part one:Pair; part two:Pair; attribute replacement:Real=99; bind one.left.number = replacement; }`}, "expose P::one; expose P::two;", []string{"P::one/P::Pair::left"}, []string{"P::one/P::Pair::right", "P::two/P::Pair::left", "P::two/P::Pair::right"}, "bind one.left.number = replacement;"}, + {"nested repeated assignment", []string{`package P { private import ScalarValues::*; part def Base { attribute number : Real default = 12.50; } part def Pair {part left:Base; part right:Base;} part one:Pair; part two:Pair; attribute overrideNumber :>> one.left.number = 99; }`}, "expose P::one; expose P::two;", []string{"P::one/P::Pair::left"}, []string{"P::one/P::Pair::right", "P::two/P::Pair::left", "P::two/P::Pair::right"}, "attribute overrideNumber :>> one.left.number = 99;"}, + {"independently selected member", []string{`package P { private import ScalarValues::*; part def Base { attribute number : Real default = 12.50; } part def Pair {part left:Base; part right:Base; attribute overrideNumber :>> left.number = 99;} part b:Base; }`}, "expose P::Pair::left; expose P::Pair::right; expose P::b;", []string{"P::Pair::left", "P::Pair::right"}, []string{"P::b"}, "target path unresolved"}, + {"competing outer assignments", []string{`package P { private import ScalarValues::*; part def Base { attribute number : Real default = 12.50; } part a : Base; part b : Base; attribute overrideOne :>> a.number = 99; attribute overrideTwo :>> a.number = 100; }`}, "expose P::a; expose P::b;", []string{"P::a"}, []string{"P::b"}, "qualified assignment"}, + } { + t.Run(tc.name, func(t *testing.T) { + e, err := New() + if err != nil { + t.Fatal(err) + } + docs := []*JSourceDocument{} + sources := map[string]string{} + for i, txt := range tc.documents { + name := []string{"model.sysml", "effects.sysml"}[i] + sources[name] = txt + txt := txt + docs = append(docs, &JSourceDocument{Name: name, Language: "sysml", Content: &txt}) + } + view := `package V { view structure : StandardViewDefinitions::InterconnectionView {` + tc.exposure + `} }` + docs = append(docs, &JSourceDocument{Name: "views.sysml", Language: "sysml", Content: &view}) + raw, err := e.parseSources(context.Background(), &JParseSourcesRequest{Documents: docs, StrictConformance: true}) + if err != nil { + t.Fatal(err) + } + var parsed JParseSourcesResponse + if err = json.Unmarshal(raw, &parsed); err != nil { + t.Fatal(err) + } + for _, d := range parsed.Diagnostics { + if d.Severity == "error" { + t.Fatalf("fixture invalid %+v", d) + } + } + raw, err = e.evaluateView(&JRenderViewRequest{ModelHash: parsed.ModelHash, View: "V::structure"}) + if err != nil { + t.Fatal(err) + } + var out struct { + Status string `json:"status"` + Correspondence []ViewCorrespondence `json:"correspondence"` + Artifact struct { + Nodes []struct { + ID string `json:"id"` + SourceAttributes []SourceAttribute `json:"sourceAttributes"` + } `json:"nodes"` + } `json:"artifact"` + } + if err = json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + if len(out.Artifact.Nodes) == 0 && strings.HasPrefix(tc.name, "split namespace") { + // The pinned exposure resolver cannot select a member across + // these split package declarations. Exercise source-scope safety + // directly against the real parsed symbols, not a fake graph. + t.Logf("pinned split-namespace rendering limitation: %s", raw) + c, _ := e.get(parsed.ModelHash) + declared := map[string]*symbols.Symbol{} + var walk func(*symbols.Scope) + walk = func(scope *symbols.Scope) { + if scope == nil { + return + } + scope.ForEachMember(func(sym *symbols.Symbol) bool { declared[c.Index.GetFQN(sym)] = sym; return true }) + for _, child := range scope.Children() { + walk(child) + } + } + for _, doc := range c.Index.WorkspaceDocuments() { + walk(c.Index.DocumentRoot(doc)) + } + for _, name := range []string{"P::a", "P::b"} { + owner := declared[name] + member := declared["P::Base::number"] + if owner == nil || member == nil { + t.Fatal("fixture declarations not indexed") + } + context := []string{originKey(owner.Origin()), originKey(member.Origin())} + attr := c.sourceAttribute(member, []*symbols.Symbol{owner}, context, c.sourceEffects()) + if name == "P::a" && attr.Value.Status != "unsupported" { + t.Fatalf("split namespace outer effect missed: %+v", attr.Value) + } + if name == "P::b" && attr.Value.Status != "available" && !strings.Contains(attr.Value.Reason, "target path unresolved") { + t.Fatalf("unexpected unaffected status: %+v", attr.Value) + } + t.Logf("direct semantic safety %s: %+v", name, attr.Value) + } + return + } + if out.Status != "complete" { + t.Fatalf("not complete: %s", raw) + } + refs := map[string]ViewCorrespondence{} + byID := map[string]string{} + for _, r := range out.Correspondence { + refs[r.Symbol] = r + byID[r.Subject.ID] = r.Subject.QualifiedName + } + found := map[string]SourceAttribute{} + for _, node := range out.Artifact.Nodes { + for _, a := range node.SourceAttributes { + if a.Name != "number" { + continue + } + r := refs[node.ID] + names := []string{} + for _, id := range r.Context { + names = append(names, byID[id]) + } + key := strings.Join(names, "/") + found[key] = a + want := append(append([]string{}, r.Context...), a.Subject.ID) + x, _ := json.Marshal(a.Context) + y, _ := json.Marshal(want) + if string(x) != string(y) { + t.Fatalf("changed correspondence context %s: %s != %s", key, x, y) + } + } + } + for _, key := range tc.unsupported { + a, ok := found[key] + if !ok { + t.Fatalf("missing %s from %+v", key, found) + } + if a.Value.Status != "unsupported" || !strings.Contains(a.Value.Reason, tc.evidence) { + t.Errorf("external effect missed %s: %+v", key, a.Value) + } + } + for _, key := range tc.defaults { + a, ok := found[key] + if !ok { + t.Fatalf("missing %s", key) + } + v := a.Value + if v.Status != "available" || v.Expression != "12.50" || v.Provider == nil || v.Provider.QualifiedName != "P::Base::number" { + t.Errorf("effect leaked %s: %+v", key, v) + } + if v.Provider != nil && v.ExpressionOrigin != nil { + o := v.ExpressionOrigin + txt := sources[o.File] + if o.Offset < 0 || o.Offset+o.Length > len(txt) || txt[o.Offset:o.Offset+o.Length] != "12.50" { + t.Errorf("incorrect provider bytes %s: %+v", key, v) + } + } + } + }) + } +} + +func TestSourceValuesBindingReferenceContext(t *testing.T) { + for _, tc := range []struct { + target string + definitionAvailable, parallelAvailable bool + }{ + {"number", true, true}, + {"P::Base::number", false, true}, + {"P::b.number", true, false}, + } { + t.Run(tc.target, func(t *testing.T) { + text := `package P {private import ScalarValues::*; part def Base {attribute number:Real default=12.50;} part b:Base; part a:Base {attribute replacement:Real=99; bind ` + tc.target + `=replacement;}} package V {view structure:StandardViewDefinitions::InterconnectionView {expose P::Base;expose P::a;expose P::b;}}` + e, err := New() + if err != nil { + t.Fatal(err) + } + raw, err := e.parseSources(context.Background(), &JParseSourcesRequest{Documents: []*JSourceDocument{{Name: "binding-context.sysml", Language: "sysml", Content: &text}}, StrictConformance: true}) + if err != nil { + t.Fatal(err) + } + var parsed JParseSourcesResponse + if err = json.Unmarshal(raw, &parsed); err != nil { + t.Fatal(err) + } + for _, d := range parsed.Diagnostics { + if d.Severity == "error" { + t.Fatalf("invalid fixture: %+v", d) + } + } + raw, err = e.evaluateView(&JRenderViewRequest{ModelHash: parsed.ModelHash, View: "V::structure"}) + if err != nil { + t.Fatal(err) + } + var out struct { + Status string + Correspondence []ViewCorrespondence + Artifact struct { + Nodes []struct { + ID string + SourceAttributes []SourceAttribute + } + } + } + if err = json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + if out.Status != "complete" { + t.Fatalf("incomplete: %s", raw) + } + refs := map[string]string{} + for _, r := range out.Correspondence { + refs[r.Symbol] = r.Subject.QualifiedName + } + found := map[string]bool{} + for _, n := range out.Artifact.Nodes { + for _, a := range n.SourceAttributes { + if a.Name != "number" { + continue + } + owner := refs[n.ID] + found[owner] = true + available := false + switch owner { + case "P::Base": + available = tc.definitionAvailable + case "P::b": + available = tc.parallelAvailable + } + if available { + if a.Value.Status != "available" || a.Value.Expression != "12.50" || a.Value.Provider == nil || a.Value.Provider.QualifiedName != "P::Base::number" { + t.Errorf("wrong default for %s: %+v", owner, a.Value) + } + } else if a.Value.Status != "unsupported" { + t.Errorf("binding missed %s: %+v", owner, a.Value) + } + } + } + for _, owner := range []string{"P::Base", "P::a", "P::b"} { + if !found[owner] { + t.Errorf("missing %s", owner) + } + } + }) + } +} + +func TestSourceValuesKeywordFreeRedefinitions(t *testing.T) { + text := `package P { private import ScalarValues::*; part def Base { attribute span:Real default=0.6; attribute evidence:String default="base"; } part a:Base { :>> span default=0.8; :>> evidence="α"; } part b:Base { :>> span default=0.35; :>> evidence="other"; }} package V { view tree:StandardViewDefinitions::GeneralView {expose P::a;expose P::a::*;expose P::b;expose P::b::*;} view structure:StandardViewDefinitions::InterconnectionView {expose P::a;expose P::b;}}` + e, err := New() + if err != nil { + t.Fatal(err) + } + raw, err := e.parseSources(context.Background(), &JParseSourcesRequest{Documents: []*JSourceDocument{{Name: "keyword-free.sysml", Language: "sysml", Content: &text}}, StrictConformance: true}) + if err != nil { + t.Fatal(err) + } + var parsed JParseSourcesResponse + if err = json.Unmarshal(raw, &parsed); err != nil { + t.Fatal(err) + } + for _, d := range parsed.Diagnostics { + if d.Severity == "error" { + t.Fatalf("invalid fixture: %+v", d) + } + } + for _, view := range []string{"V::tree", "V::structure"} { + t.Run(view, func(t *testing.T) { + raw, err = e.evaluateView(&JRenderViewRequest{ModelHash: parsed.ModelHash, View: view}) + if err != nil { + t.Fatal(err) + } + var out struct { + Status string + Correspondence []ViewCorrespondence + Artifact struct { + Nodes []struct { + ID string + SourceAttribute *SourceAttribute + SourceAttributes []SourceAttribute + } + } + } + if err = json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + if out.Status != "complete" { + t.Fatalf("incomplete: %s", raw) + } + found := map[string]SourceAttribute{} + refs := map[string]ViewCorrespondence{} + for _, r := range out.Correspondence { + refs[r.Symbol] = r + } + for _, n := range out.Artifact.Nodes { + if view == "V::tree" { + if n.SourceAttribute != nil { + a := *n.SourceAttribute + found[a.Subject.QualifiedName] = a + } + } else { + for _, a := range n.SourceAttributes { + found[a.Subject.QualifiedName] = a + want := append(append([]string{}, refs[n.ID].Context...), a.Subject.ID) + x, _ := json.Marshal(a.Context) + y, _ := json.Marshal(want) + if string(x) != string(y) { + t.Fatalf("wrong context: %s != %s", x, y) + } + } + } + } + for key, want := range map[string]string{"P::a::span": "0.8", "P::a::evidence": "\"α\"", "P::b::span": "0.35", "P::b::evidence": "\"other\""} { + a, ok := found[key] + if !ok { + t.Fatalf("missing keyword-free source metadata %s", key) + } + v := a.Value + if v.Status != "available" || v.Expression != want || v.Provider == nil || v.Provider.ID != a.Subject.ID { + t.Errorf("wrong value for %s: %+v", key, v) + } + if v.ExpressionOrigin != nil { + o := v.ExpressionOrigin + if text[o.Offset:o.Offset+o.Length] != want { + t.Errorf("wrong expression bytes %s", key) + } + } + } + }) + } +} + +func TestSourceValuesBoundOwnerDoesNotBorrowTypeDefaults(t *testing.T) { + text := `package P {private import ScalarValues::*; part def Base {attribute number:Real default=12.50;} part b:Base {attribute :>> number=99;} ref part a:Base=b;} package V {view structure:StandardViewDefinitions::InterconnectionView {expose P::a;expose P::b;}}` + e, err := New() + if err != nil { + t.Fatal(err) + } + raw, err := e.parseSources(context.Background(), &JParseSourcesRequest{Documents: []*JSourceDocument{{Name: "owner-value.sysml", Language: "sysml", Content: &text}}, StrictConformance: true}) + if err != nil { + t.Fatal(err) + } + var parsed JParseSourcesResponse + if err = json.Unmarshal(raw, &parsed); err != nil { + t.Fatal(err) + } + for _, d := range parsed.Diagnostics { + if d.Severity == "error" { + t.Fatalf("invalid fixture: %+v", d) + } + } + raw, err = e.evaluateView(&JRenderViewRequest{ModelHash: parsed.ModelHash, View: "V::structure"}) + if err != nil { + t.Fatal(err) + } + var out struct { + Status string + Correspondence []ViewCorrespondence + Artifact struct { + Nodes []struct { + ID string + SourceAttributes []SourceAttribute + } + } + } + if err = json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + if out.Status != "complete" { + t.Fatalf("incomplete: %s", raw) + } + refs := map[string]string{} + for _, r := range out.Correspondence { + refs[r.Symbol] = r.Subject.QualifiedName + } + found := map[string]bool{} + for _, n := range out.Artifact.Nodes { + for _, a := range n.SourceAttributes { + if a.Name != "number" { + continue + } + owner := refs[n.ID] + found[owner] = true + if owner == "P::a" && (a.Value.Status != "unsupported" || !strings.Contains(a.Value.Reason, "owner")) { + t.Errorf("bound owner borrowed default: %+v", a.Value) + } + if owner == "P::b" && (a.Value.Status != "available" || a.Value.Expression != "99") { + t.Errorf("unbound usage changed: %+v", a.Value) + } + } + } + if !found["P::a"] || !found["P::b"] { + t.Fatal("missing occurrences") + } +} + +// Controlled projection seam: library records must remain drawable without +// pretending their non-project origins are applied source-value metadata. +func TestSourceValuesLeaveLibraryRecordsUnannotated(t *testing.T) { + e, err := New() + if err != nil { + t.Fatal(err) + } + text := `package P {part a;}` + raw, err := e.parseSources(context.Background(), &JParseSourcesRequest{Documents: []*JSourceDocument{{Name: "project.sysml", Language: "sysml", Content: &text}}, StrictConformance: true}) + if err != nil { + t.Fatal(err) + } + var parsed JParseSourcesResponse + if err = json.Unmarshal(raw, &parsed); err != nil { + t.Fatal(err) + } + c, _ := e.get(parsed.ModelHash) + var library *symbols.Symbol + c.Index.Registered(func(_ string, syms []*symbols.Symbol) { + for _, s := range syms { + if library == nil && s.Kind.IsAttributeLike() && c.Index.IsLibraryDocument(s.DocName) { + library = s + } + } + }) + if library == nil { + t.Fatal("pinned library has no attribute control") + } + ref := c.sourceSubject(library) + out := EvaluatedView{Artifact: json.RawMessage(`{"kind":"tree","nodes":[{"id":"library","name":"library","kind":"attribute"}],"edges":[]}`), Correspondence: []ViewCorrespondence{{Symbol: "library", Subject: ref, Context: []string{ref.ID}}}} + if err = c.sourceValues(&out, map[string]*symbols.Symbol{ref.ID: library}); err != nil { + t.Fatal(err) + } + var artifact struct{ Nodes []map[string]json.RawMessage } + if err = json.Unmarshal(out.Artifact, &artifact); err != nil { + t.Fatal(err) + } + if len(artifact.Nodes) != 1 { + t.Fatal("library node removed") + } + for _, key := range []string{"sourceAttribute", "sourceAttributes"} { + if _, ok := artifact.Nodes[0][key]; ok { + t.Fatalf("library record published unapplied %s", key) + } + } +} diff --git a/engine-bridge/opensysml/validated_view_test.go b/engine-bridge/opensysml/validated_view_test.go index 862e470..28bc737 100644 --- a/engine-bridge/opensysml/validated_view_test.go +++ b/engine-bridge/opensysml/validated_view_test.go @@ -539,7 +539,7 @@ func TestStudioAuthoredEffectiveOccurrences(t *testing.T) { assertIssue18JSON(t, "authored declarations, topology and occurrence bindings", issue18Semantics(t, result, tc.source), tc.want) assertIssue18JSON(t, "unchanged selected scope", result["selected"], tc.selected) explored := call("Explore", map[string]any{"modelHash": parsed["modelHash"], "request": map[string]any{"scope": map[string]any{"kind": "element", "subject": subject}, "representation": "interconnection"}}) - assertIssue18JSON(t, "unchanged complete Explore envelope", explored, tc.explore) + assertIssue18JSON(t, "unchanged complete Explore envelope", issue18WithoutSourceMetadata(explored), tc.explore) // Every legacy-visible node, port and correspondence keeps its complete // value, including ordinals on a later sibling following new descendants. got := issue18Semantics(t, result, tc.source).([]any) @@ -609,7 +609,7 @@ func issue18Semantics(t *testing.T, result map[string]any, source string) any { t.Fatalf("%s incomplete occurrence context: %v want %v", symbol, ctx, want) } contexts[symbol] = ctx - rows = append(rows, map[string]any{"symbol": symbol, "value": value, "binding": c, "declaration": source[start : start+length]}) + rows = append(rows, map[string]any{"symbol": symbol, "value": issue18WithoutSourceMetadata(value), "binding": c, "declaration": source[start : start+length]}) } for _, raw := range artifact["nodes"].([]any) { n := raw.(map[string]any) @@ -704,3 +704,26 @@ var issue18Cases = []struct{ name, source, want, selected, explore, legacy strin // zero: exact source declarations, parents, types, ports, contexts and legacy ordinals. {`zero`, `package P {part def Wheel; part def SmallWheel :> Wheel; part def Sat { part a:Wheel; part b:Wheel; part c:Wheel; } part sat1:Sat {} part sat2:Sat;} package V {private import StandardViewDefinitions::*; view v:InterconnectionView {expose P::sat1; expose P::sat2; expose P::sat1::c;} }`, `[{"symbol":"n0","value":{"id":"n0","kind":"part","name":"P::sat1","origin":{"endCol":133,"endLine":1,"file":"probe.sysml","startCol":117,"startLine":1},"type":"Sat"},"binding":{"context":["probe.sysml@116:16"],"subject":{"id":"probe.sysml@116:16","origin":{"file":"probe.sysml","length":16,"nameLength":4,"nameOffset":121,"offset":116},"qualifiedName":"P::sat1"},"symbol":"n0"},"declaration":"part sat1:Sat {}"},{"symbol":"n2","value":{"id":"n2","inherited":true,"kind":"part","name":"a","origin":{"endCol":86,"endLine":1,"file":"probe.sysml","startCol":73,"startLine":1},"parent":"n0","type":"Wheel"},"binding":{"context":["probe.sysml@116:16","probe.sysml@72:13"],"subject":{"id":"probe.sysml@72:13","origin":{"file":"probe.sysml","length":13,"nameLength":1,"nameOffset":77,"offset":72},"qualifiedName":"P::Sat::a"},"symbol":"n2"},"declaration":"part a:Wheel;"},{"symbol":"n3","value":{"id":"n3","inherited":true,"kind":"part","name":"b","origin":{"endCol":100,"endLine":1,"file":"probe.sysml","startCol":87,"startLine":1},"parent":"n0","type":"Wheel"},"binding":{"context":["probe.sysml@116:16","probe.sysml@86:13"],"subject":{"id":"probe.sysml@86:13","origin":{"file":"probe.sysml","length":13,"nameLength":1,"nameOffset":91,"offset":86},"qualifiedName":"P::Sat::b"},"symbol":"n3"},"declaration":"part b:Wheel;"},{"symbol":"n4","value":{"id":"n4","inherited":true,"kind":"part","name":"c","origin":{"endCol":114,"endLine":1,"file":"probe.sysml","startCol":101,"startLine":1},"parent":"n0","type":"Wheel"},"binding":{"context":["probe.sysml@116:16","probe.sysml@100:13"],"subject":{"id":"probe.sysml@100:13","origin":{"file":"probe.sysml","length":13,"nameLength":1,"nameOffset":105,"offset":100},"qualifiedName":"P::Sat::c"},"symbol":"n4"},"declaration":"part c:Wheel;"},{"symbol":"n1","value":{"id":"n1","kind":"part","name":"P::sat2","origin":{"endCol":148,"endLine":1,"file":"probe.sysml","startCol":134,"startLine":1},"type":"Sat"},"binding":{"context":["probe.sysml@133:14"],"subject":{"id":"probe.sysml@133:14","origin":{"file":"probe.sysml","length":14,"nameLength":4,"nameOffset":138,"offset":133},"qualifiedName":"P::sat2"},"symbol":"n1"},"declaration":"part sat2:Sat;"},{"symbol":"n5","value":{"id":"n5","inherited":true,"kind":"part","name":"a","origin":{"endCol":86,"endLine":1,"file":"probe.sysml","startCol":73,"startLine":1},"parent":"n1","type":"Wheel"},"binding":{"context":["probe.sysml@133:14","probe.sysml@72:13"],"subject":{"id":"probe.sysml@72:13","origin":{"file":"probe.sysml","length":13,"nameLength":1,"nameOffset":77,"offset":72},"qualifiedName":"P::Sat::a"},"symbol":"n5"},"declaration":"part a:Wheel;"},{"symbol":"n6","value":{"id":"n6","inherited":true,"kind":"part","name":"b","origin":{"endCol":100,"endLine":1,"file":"probe.sysml","startCol":87,"startLine":1},"parent":"n1","type":"Wheel"},"binding":{"context":["probe.sysml@133:14","probe.sysml@86:13"],"subject":{"id":"probe.sysml@86:13","origin":{"file":"probe.sysml","length":13,"nameLength":1,"nameOffset":91,"offset":86},"qualifiedName":"P::Sat::b"},"symbol":"n6"},"declaration":"part b:Wheel;"},{"symbol":"n7","value":{"id":"n7","inherited":true,"kind":"part","name":"c","origin":{"endCol":114,"endLine":1,"file":"probe.sysml","startCol":101,"startLine":1},"parent":"n1","type":"Wheel"},"binding":{"context":["probe.sysml@133:14","probe.sysml@100:13"],"subject":{"id":"probe.sysml@100:13","origin":{"file":"probe.sysml","length":13,"nameLength":1,"nameOffset":105,"offset":100},"qualifiedName":"P::Sat::c"},"symbol":"n7"},"declaration":"part c:Wheel;"}]`, `[{"id":"probe.sysml@116:16","origin":{"file":"probe.sysml","length":16,"nameLength":4,"nameOffset":121,"offset":116},"qualifiedName":"P::sat1"},{"id":"probe.sysml@133:14","origin":{"file":"probe.sysml","length":14,"nameLength":4,"nameOffset":138,"offset":133},"qualifiedName":"P::sat2"},{"id":"probe.sysml@100:13","origin":{"file":"probe.sysml","length":13,"nameLength":1,"nameOffset":105,"offset":100},"qualifiedName":"P::Sat::c"}]`, `{"artifact":{"edges":[],"kind":"interconnection","nodes":[{"id":"n0","kind":"part","name":"P::sat1","origin":{"endCol":133,"endLine":1,"file":"probe.sysml","startCol":117,"startLine":1},"type":"Sat"}],"shown":["parameter-symbols","port-symbols","compartments","edge-labels","keywords","multiplicities","typings","qualifiers"],"stated":"Read-only exploration"},"correspondence":[{"context":["probe.sysml@116:16"],"subject":{"id":"probe.sysml@116:16","origin":{"file":"probe.sysml","length":16,"nameLength":4,"nameOffset":121,"offset":116},"qualifiedName":"P::sat1"},"symbol":"n0"}],"diagnostics":[],"modelHash":"bbb162884878be6c369a43fa3cb2d29712ccacc3b5df86a0c36250d0b4e3d787","renderStatus":"available","request":{"representation":"interconnection","scope":{"kind":"element","subject":"P::sat1"}},"schemaVersion":1,"selected":[{"id":"probe.sysml@116:16","origin":{"file":"probe.sysml","length":16,"nameLength":4,"nameOffset":121,"offset":116},"qualifiedName":"P::sat1"}],"status":"complete"}`, `[{"symbol":"n0","value":{"id":"n0","kind":"part","name":"P::sat1","origin":{"endCol":133,"endLine":1,"file":"probe.sysml","startCol":117,"startLine":1},"type":"Sat"},"binding":{"context":["probe.sysml@116:16"],"subject":{"id":"probe.sysml@116:16","origin":{"file":"probe.sysml","length":16,"nameLength":4,"nameOffset":121,"offset":116},"qualifiedName":"P::sat1"},"symbol":"n0"},"declaration":"part sat1:Sat {}"},{"symbol":"n1","value":{"id":"n1","kind":"part","name":"P::sat2","origin":{"endCol":148,"endLine":1,"file":"probe.sysml","startCol":134,"startLine":1},"type":"Sat"},"binding":{"context":["probe.sysml@133:14"],"subject":{"id":"probe.sysml@133:14","origin":{"file":"probe.sysml","length":14,"nameLength":4,"nameOffset":138,"offset":133},"qualifiedName":"P::sat2"},"symbol":"n1"},"declaration":"part sat2:Sat;"}]`}, } + +// Source values are additive derived metadata, independently asserted by the +// source-value regression. Preserve every legacy field in the #18 goldens. +func issue18WithoutSourceMetadata(value any) any { + switch v := value.(type) { + case map[string]any: + out := map[string]any{} + for key, item := range v { + if key != "sourceAttribute" && key != "sourceAttributes" { + out[key] = issue18WithoutSourceMetadata(item) + } + } + return out + case []any: + out := make([]any, len(v)) + for i, item := range v { + out[i] = issue18WithoutSourceMetadata(item) + } + return out + default: + return value + } +} diff --git a/scripts/prepare-opensysml.mjs b/scripts/prepare-opensysml.mjs index ca4dc05..4378255 100644 --- a/scripts/prepare-opensysml.mjs +++ b/scripts/prepare-opensysml.mjs @@ -11,6 +11,8 @@ const archiveHash = const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); const overlays = [ 'evaluate_view.go', + 'source_values.go', + 'source_values_test.go', 'exploration.go', 'exploration_test.go', 'validated_view.go', @@ -99,6 +101,11 @@ for (const [name, folder] of [ name.endsWith('_test.go') ? 'studio_view_test.go' : 'studio_view.go', ), ); +for (const name of ['source_values.go', 'source_values_test.go']) + await cp( + resolve(root, 'engine-bridge/opensysml', name), + resolve(source, 'internal/frontend/engine', 'studio_' + name), + ); for (const [name, folder] of [ ['exploration.go', 'engine'], ['exploration_test.go', 'combined'], diff --git a/src/lib/engines/opensysml/index.ts b/src/lib/engines/opensysml/index.ts index 65d3fc4..0751531 100644 --- a/src/lib/engines/opensysml/index.ts +++ b/src/lib/engines/opensysml/index.ts @@ -70,7 +70,7 @@ export function createSemanticEngine( view, base, }, - (value) => validateEvaluation(value, modelHash, view), + (value) => validateEvaluation(value, modelHash, view, documents), signal, ); }, diff --git a/src/lib/ui/diagram.css b/src/lib/ui/diagram.css index 7f85ffd..d7e266f 100644 --- a/src/lib/ui/diagram.css +++ b/src/lib/ui/diagram.css @@ -469,3 +469,47 @@ justify-content: flex-end; gap: 6px; } + +.tree-node-name, +.tree-node-value { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + overflow-wrap: anywhere; +} +.tree-node-name { + -webkit-line-clamp: 1; +} +.tree-node-value { + margin-top: 4px; + font-size: 12px; + font-weight: 500; + line-height: 16px; +} + +.diagram-controls .diagram-zoom { + font-size: 10px; + color: var(--sysml-muted); + text-align: center; +} +.diagram-controls button { + max-width: 46px; +} + +.tree-attribute-preview { + display: block; + font-size: 11px; + line-height: 18px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; + margin-top: 3px; +} +.tree-values-count { + display: block; + margin-top: 5px; + font-size: 10px; + color: var(--sysml-muted); +} diff --git a/src/lib/ui/tools.css b/src/lib/ui/tools.css index 8f0eda1..104d2e7 100644 --- a/src/lib/ui/tools.css +++ b/src/lib/ui/tools.css @@ -319,3 +319,30 @@ overflow-wrap: anywhere; text-align: left; } + +.source-values { + margin-top: 20px; + border-top: 1px solid var(--sysml-border); + padding-top: 12px; +} +.source-values details { + border-bottom: 1px solid var(--sysml-border); + padding: 10px 0; +} +.source-values summary { + cursor: pointer; + font-weight: 600; + overflow-wrap: anywhere; +} +.source-values pre { + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: 12px; + line-height: 1.5; +} +.source-values button { + white-space: normal; + height: auto; + min-height: 32px; + text-align: left; +} diff --git a/src/lib/workbench/diagrams/Diagram.tsx b/src/lib/workbench/diagrams/Diagram.tsx index 5b9e81c..91db9c4 100644 --- a/src/lib/workbench/diagrams/Diagram.tsx +++ b/src/lib/workbench/diagrams/Diagram.tsx @@ -1,3 +1,5 @@ +import { TreeLabel } from './TreeLabel'; +import { treeNodeHeight } from './source-attributes'; import { useDiagramFit } from './useDiagramFit'; import { layoutExploration, explorationKindLabel } from './exploration'; import { RoutedEdge } from './RoutedEdge'; @@ -36,15 +38,17 @@ function FitAvailableSpace({ padding, layoutMode, bounds, + readableEntry, }: { padding: number; layoutMode: string; bounds?: Rect; + readableEntry: boolean; }) { const width = useStore((state) => state.width); const height = useStore((state) => state.height); const initialized = useNodesInitialized(); - const fit = useDiagramFit(padding, bounds); + const fit = useDiagramFit(padding, bounds, readableEntry); const fitted = useRef(false); const previousMode = useRef(layoutMode); useEffect(() => { @@ -97,6 +101,7 @@ interface Props { presentation?: ViewPresentation; disabled: boolean; onSelect: (symbol: string) => void; + selectedSymbol?: string; onMove: (positions: Record) => void; onCreatePart: () => void; onConnectPorts: () => void; @@ -114,6 +119,7 @@ export function Diagram({ presentation, disabled, onSelect, + selectedSymbol, onMove, onCreatePart, onConnectPorts, @@ -234,7 +240,7 @@ export function Diagram({ children: artifact.nodes.map((item) => ({ id: item.id, width: 190, - height: 65, + height: treeNodeHeight(item), })), edges: artifact.edges .filter( @@ -252,6 +258,12 @@ export function Diagram({ .layout(graph) .then((layout) => { if (cancelled) return; + const subjects = new Map( + view.correspondence.map((entry) => [entry.symbol, entry.subject]), + ); + const names = new Map( + elements.map((element) => [element.id, element.name]), + ); const next = artifact.nodes.map((item) => { const point = stored[item.id] ?? @@ -269,13 +281,16 @@ export function Diagram({ position: { x: point?.x ?? 0, y: point?.y ?? 0 }, data: { label: ( - <> - {item.kind} - {item.name + (item.type ? ' : ' + item.type : '')} - + ), }, - style: { width: 190, minHeight: 65 }, + style: { width: 190, minHeight: treeNodeHeight(item) }, }; }); setNodes(next); @@ -598,17 +613,7 @@ export function Diagram({ : 0.2, maxZoom: 1, }} - minZoom={ - exploration || - [ - 'requirement', - 'action', - 'state', - 'interconnection', - ].includes(view?.artifact?.kind ?? '') - ? 0.1 - : 0.5 - } + minZoom={0.1} nodesConnectable={false} nodesDraggable={!disabled && !locked && !exploration} elementsSelectable={!locked} @@ -630,12 +635,24 @@ export function Diagram({ }} > + node.id === selectedSymbol || + node.ports?.some((port) => port.id === selectedSymbol), + )?.id + } locked={locked} onLock={setLocked} padding={fitPadding} diff --git a/src/lib/workbench/diagrams/DiagramControls.tsx b/src/lib/workbench/diagrams/DiagramControls.tsx index 508cbdf..b652f0d 100644 --- a/src/lib/workbench/diagrams/DiagramControls.tsx +++ b/src/lib/workbench/diagrams/DiagramControls.tsx @@ -1,3 +1,5 @@ +import { Button } from '$lib/ui/primitives/button'; +import { useDiagramRead } from './useDiagramRead'; import { useDiagramFit } from './useDiagramFit'; import { Panel, useReactFlow, useStore, type Rect } from '@xyflow/react'; import { @@ -14,14 +16,17 @@ export function DiagramControls({ onLock, padding, bounds, + readTarget, }: { locked: boolean; onLock: (locked: boolean) => void; padding: number; bounds?: Rect; + readTarget?: string; }) { const { zoomIn, zoomOut } = useReactFlow(); const fit = useDiagramFit(padding, bounds); + const read = useDiagramRead(readTarget); const zoom = useStore((state) => state.transform[2]); const minZoom = useStore((state) => state.minZoom); const maxZoom = useStore((state) => state.maxZoom); @@ -52,6 +57,18 @@ export function DiagramControls({ icon={Scan} onClick={() => void fit()} /> + + + {Math.round(zoom * 100)}% + ) { )} - {!!item.attributeDeclarations?.length && ( + {sourceAttributeCount(item) > 0 && (
- {item.attributeDeclarations.map((declaration, index) => ( + {item.sourceAttribute && (
- {attributeLabel(declaration)} + {sourceAttributeLabel(item.sourceAttribute)}
- ))} + )} + {item.sourceAttributes + ? item.sourceAttributes.map((row) => ( +
+ {sourceAttributeLabel(row)} +
+ )) + : item.attributeDeclarations?.map((declaration, index) => ( +
+ {attributeLabel(declaration)} +
+ ))}
)} {!!item.detail && @@ -64,7 +90,7 @@ export function StructureNode({ data }: NodeProps) { style={{ height: compound ? undefined - : `calc(100% - ${compact ? explorationHeaderHeight - 2 : headingHeight}px - ${(item.attributeDeclarations?.length ?? 0) * (dense ? 32 : 48)}px)`, + : `calc(100% - ${compact ? explorationHeaderHeight - 2 : headingHeight}px - ${sourceAttributeCount(item) * (dense ? 32 : 48)}px)`, }} > {item.ports?.map((port) => ( diff --git a/src/lib/workbench/diagrams/TreeLabel.tsx b/src/lib/workbench/diagrams/TreeLabel.tsx new file mode 100644 index 0000000..87de737 --- /dev/null +++ b/src/lib/workbench/diagrams/TreeLabel.tsx @@ -0,0 +1,70 @@ +import type { ArtifactNode, EvaluatedView } from '$lib/workspace/views'; +import { + sourceAttributeLabel, + sourceAttributePreview, +} from './source-attributes'; + +export function TreeLabel({ + item, + subject, + localName, +}: { + item: ArtifactNode; + subject?: EvaluatedView['correspondence'][number]['subject']; + localName?: string; +}) { + const attribute = item.sourceAttribute; + const preview = attribute ? [] : sourceAttributePreview(item, subject); + const name = item.name + (item.type ? ' : ' + item.type : ''); + const value = attribute?.value; + return ( + <> + {item.kind} + + {preview.length + ? (localName ?? item.name) + (item.type ? ' : ' + item.type : '') + : name} + + {value && ( + + {value.status === 'available' + ? `${value.isDefault ? 'default ' : ''}${value.isInitial ? ':=' : '='} ${value.expression}` + : value.status === 'unsupported' + ? 'Value unavailable · inspect details' + : 'No declared expression'} + + )} + {preview.map((row) => ( + + {row.name} + {row.value.status === 'available' + ? ` ${row.value.isDefault ? 'default ' : ''}${row.value.isInitial ? ':=' : '='} ${row.value.expression}` + : row.value.status === 'unsupported' + ? ' · value unavailable' + : ' · no declared expression'} + + ))} + {!!preview.length && ( + + {item.sourceAttributes!.length} source attributes · inspect + + )} + + ); +} diff --git a/src/lib/workbench/diagrams/source-attributes.ts b/src/lib/workbench/diagrams/source-attributes.ts new file mode 100644 index 0000000..27a6a80 --- /dev/null +++ b/src/lib/workbench/diagrams/source-attributes.ts @@ -0,0 +1,42 @@ +import type { ArtifactNode } from '$lib/workspace/views'; + +export function sourceAttributeLabel( + row: NonNullable, +) { + const heading = + row.name + (row.type ? ` : ${row.type.split('::').at(-1)}` : ''); + if (row.value.status === 'unsupported') + return `${heading} · value unavailable`; + if (row.value.status === 'absent') return heading; + return `${heading} ${row.value.isDefault ? 'default ' : ''}${row.value.isInitial ? ':=' : '='} ${row.value.expression}`; +} +export function sourceAttributeCount(item: ArtifactNode) { + return ( + (item.sourceAttribute ? 1 : 0) + + (item.sourceAttributes?.length ?? item.attributeDeclarations?.length ?? 0) + ); +} + +/** Bound card previews; exact declarations and every row remain in inspection. */ +export function sourceAttributePreview( + item: ArtifactNode, + owner?: { origin?: { file: string; offset: number; length: number } }, +) { + const origin = owner?.origin; + const own = (row: NonNullable) => { + const member = row.subject.origin; + return ( + !!origin && + !!member && + origin.file === member.file && + member.offset >= origin.offset && + member.offset + member.length <= origin.offset + origin.length + ); + }; + return [...(item.sourceAttributes ?? [])] + .sort((a, b) => Number(own(b)) - Number(own(a))) + .slice(0, 2); +} +export function treeNodeHeight(item: ArtifactNode) { + return item.sourceAttribute ? 92 : item.sourceAttributes?.length ? 128 : 65; +} diff --git a/src/lib/workbench/diagrams/structure.ts b/src/lib/workbench/diagrams/structure.ts index f1a7e08..e2b3a5a 100644 --- a/src/lib/workbench/diagrams/structure.ts +++ b/src/lib/workbench/diagrams/structure.ts @@ -1,3 +1,4 @@ +import { sourceAttributeCount } from './source-attributes'; import type { LayoutRoute } from './RoutedEdge'; import ELK, { type ElkNode } from 'elkjs/lib/elk.bundled.js'; import { MarkerType, type Node, type Edge } from '@xyflow/react'; @@ -29,7 +30,7 @@ export const headingHeight = 66; export const portHeight = 30; const contentTop = (item: ArtifactNode, dense = false) => headingHeight + - (item.attributeDeclarations?.length ?? 0) * (dense ? 32 : 48) + + sourceAttributeCount(item) * (dense ? 32 : 48) + (item.ports?.length ?? 0) * portHeight + 20; @@ -152,9 +153,8 @@ export async function layoutStructure( y: 1 + headingHeight + - (item.attributeDeclarations?.length ?? 0) * - (dense ? 32 : 48) + - (item.attributeDeclarations?.length ? 1 : 0) + + sourceAttributeCount(item) * (dense ? 32 : 48) + + (sourceAttributeCount(item) ? 1 : 0) + (index + 0.5) * (portHeight + (!nested.length ? 18 / item.ports!.length : 0)) + diff --git a/src/lib/workbench/diagrams/useDiagramFit.ts b/src/lib/workbench/diagrams/useDiagramFit.ts index 1a38b1b..307f73f 100644 --- a/src/lib/workbench/diagrams/useDiagramFit.ts +++ b/src/lib/workbench/diagrams/useDiagramFit.ts @@ -1,3 +1,4 @@ +import { useDiagramRead } from './useDiagramRead'; import { useCallback } from 'react'; import { getViewportForBounds, @@ -6,18 +7,44 @@ import { 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(); +export function useDiagramFit( + padding: number, + bounds?: Rect, + readableEntry = false, +) { + const read = useDiagramRead(); + const { fitView, setViewport, getNodes, getNodesBounds } = 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], - ); + return useCallback(() => { + if (readableEntry && width > 0 && height > 0 && getNodes().length) { + const overview = getViewportForBounds( + bounds ?? getNodesBounds(getNodes()), + width, + height, + minZoom, + 1, + padding, + ); + if (overview.zoom < 0.6) return read(); + } + return bounds && width > 0 && height > 0 + ? setViewport( + getViewportForBounds(bounds, width, height, minZoom, 1, padding), + ) + : fitView({ padding, maxZoom: 1 }); + }, [ + bounds, + width, + height, + minZoom, + padding, + fitView, + setViewport, + readableEntry, + read, + getNodes, + getNodesBounds, + ]); } diff --git a/src/lib/workbench/diagrams/useDiagramRead.ts b/src/lib/workbench/diagrams/useDiagramRead.ts new file mode 100644 index 0000000..118e107 --- /dev/null +++ b/src/lib/workbench/diagrams/useDiagramRead.ts @@ -0,0 +1,39 @@ +import { useCallback } from 'react'; +import { useReactFlow, useStore } from '@xyflow/react'; +import type { ArtifactNode } from '$lib/workspace/views'; + +/** A camera action: readable text without moving or resizing model symbols. */ +export function useDiagramRead(targetId?: string) { + const { getNodes, getNodesBounds, setViewport } = useReactFlow(); + const width = useStore((state) => state.width), + height = useStore((state) => state.height); + return useCallback(() => { + const nodes = getNodes().filter((node) => !node.hidden); + const selected = nodes.find((node) => node.id === targetId); + const components = nodes.filter( + (node) => node.type === 'structure' && !node.data.compound, + ); + const firstComponent = + components.find( + (node) => (node.data.item as ArtifactNode)?.kind === 'part', + ) ?? components[0]; + const target = + selected ?? + firstComponent ?? + nodes.find((node) => !node.parentId) ?? + nodes[0]; + if (!target || width <= 0 || height <= 0) return Promise.resolve(false); + const bounds = getNodesBounds([target.id]); + // Large containers start at their heading; small selections are centered. + return setViewport({ + x: + (width - Math.min(bounds.width, Math.max(0, width - 48))) / 2 - + bounds.x, + y: selected + ? (height - Math.min(bounds.height, Math.max(0, height - 48))) / 2 - + bounds.y + : 24 - bounds.y, + zoom: 1, + }); + }, [getNodes, getNodesBounds, setViewport, width, height, targetId]); +} diff --git a/src/lib/workbench/properties/Properties.tsx b/src/lib/workbench/properties/Properties.tsx index ce3d469..5e7b6b1 100644 --- a/src/lib/workbench/properties/Properties.tsx +++ b/src/lib/workbench/properties/Properties.tsx @@ -1,13 +1,18 @@ +import { SourceValues } from './SourceValues'; +import type { SourceLocation } from '$lib/workspace/project'; import { CodeXml, SquarePlus, Cable, Eye, Pencil } from 'lucide-react'; import { Input } from '$lib/ui/primitives/input'; import { Button } from '$lib/ui/primitives/button'; import { useState } from 'react'; +import type { RequirementsResult } from '$lib/workspace/requirements'; import type { ModelElement } from '$lib/workspace/project'; import type { ArtifactNode } from '$lib/workspace/views'; import type { DiagramRelationshipSelection } from './diagram-selection'; export function Properties({ element, artifactNode, + requirement, + subject, relationship, sourceAvailable = true, readOnlyDiagram = false, @@ -16,11 +21,14 @@ export function Properties({ onRename, onCreateView, onSource, + onValueSource, onCreatePart, onConnectPorts, }: { element?: ModelElement; artifactNode?: ArtifactNode; + requirement?: RequirementsResult['requirements'][number]; + subject?: RequirementsResult['requirements'][number]['subject']; relationship?: DiagramRelationshipSelection; sourceAvailable?: boolean; readOnlyDiagram?: boolean; @@ -29,6 +37,7 @@ export function Properties({ onRename: (name: string) => void; onCreateView: () => void; onSource: () => void; + onValueSource: (origin: SourceLocation) => void; onCreatePart: () => void; onConnectPorts: () => void; }) { @@ -122,7 +131,17 @@ export function Properties({ : artifactNode.detail &&

{artifactNode.detail}

} )} - @@ -167,9 +186,60 @@ export function Properties({ )} + ) : subject ? ( + <> +

Declaration

+
+
Identity
+
{subject.qualifiedName ?? subject.id}
+ {subject.origin && ( + <> +
Source file
+
{subject.origin.file}
+ + )} +
+

Applied model declaration; no execution result is reported.

+ {!sourceAvailable && ( +

+ Source location unavailable for this selection, or its file has an + unapplied draft. +

+ )} + + ) : (

Select a model element or diagram symbol to inspect it.

)} + {requirement && ( + <> + {requirement.shortName && ( +

+ Identifier: {requirement.shortName} +

+ )} + {requirement.documentation.map((text, index) => ( +

{text}

+ ))} +

+ Applied requirement declaration; no execution result is reported. +

+ + )} + {artifactNode && ( + + )} ); } diff --git a/src/lib/workbench/properties/SourceValues.tsx b/src/lib/workbench/properties/SourceValues.tsx new file mode 100644 index 0000000..1dd5e8e --- /dev/null +++ b/src/lib/workbench/properties/SourceValues.tsx @@ -0,0 +1,81 @@ +import type { ArtifactNode } from '$lib/workspace/views'; +import type { SourceLocation } from '$lib/workspace/project'; +import { Button } from '$lib/ui/primitives/button'; + +export function SourceValues({ + node, + disabled, + onSource, +}: { + node: ArtifactNode; + disabled: boolean; + onSource: (origin: SourceLocation) => void; +}) { + const rows = node.sourceAttribute + ? [node.sourceAttribute] + : node.sourceAttributes; + if (!rows?.length) return null; + return ( +
+

Declared source values

+

Expressions are shown as authored, without evaluation.

+ {rows.map((row) => { + const value = row.value; + const origin = + value.status === 'available' + ? value.provider.origin + : row.subject.origin; + return ( +
+ {row.name || 'Unnamed attribute'} +
+
Declaration
+
{row.subject.qualifiedName ?? row.subject.id}
+ {row.type && ( + <> +
Type
+
{row.type}
+ + )} + {value.status === 'available' && ( + <> +
+ {value.isInitial + ? 'Initial expression' + : value.isDefault + ? 'Default expression' + : 'Authored expression'} +
+
+
{value.expression}
+
+
Value declared by
+
{value.provider.qualifiedName ?? value.provider.id}
+ + )} +
+ {value.status === 'absent' && ( +

+ No declared expression found. Bindings and evaluated values are + not inferred. +

+ )} + {value.status === 'unsupported' && ( +

Value unavailable: {value.reason}

+ )} + +
+ ); + })} +
+ ); +} diff --git a/src/lib/workbench/properties/inspection-source.test.ts b/src/lib/workbench/properties/inspection-source.test.ts new file mode 100644 index 0000000..a381206 --- /dev/null +++ b/src/lib/workbench/properties/inspection-source.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from 'vitest'; +import { inspectionSource } from './inspection-source'; + +const documents = [{ name: 'model.sysml', text: '// λ\r\npart p;' }]; +const origin = { file: 'model.sysml', offset: 7, length: 7 }; +test('converts exact Unicode and CRLF byte ranges without requesting focus', () => { + expect(inspectionSource(origin, documents, documents)).toEqual({ + file: 'model.sysml', + start: 6, + end: 13, + focus: false, + }); +}); +test('refuses changed drafts, missing files and invalid UTF-8 boundaries', () => { + expect( + inspectionSource(origin, documents, [ + { ...documents[0]!, text: 'changed' }, + ]), + ).toBeUndefined(); + expect( + inspectionSource({ ...origin, file: 'missing' }, documents, documents), + ).toBeUndefined(); + for (const [offset, length] of [ + [4, 1], + [0, 4], + [-1, 1], + [7, 100], + [0.5, 1], + ]) { + expect( + inspectionSource( + { ...origin, offset: offset!, length: length! }, + documents, + documents, + ), + ).toBeUndefined(); + } +}); diff --git a/src/lib/workbench/properties/inspection-source.ts b/src/lib/workbench/properties/inspection-source.ts new file mode 100644 index 0000000..ddd0eae --- /dev/null +++ b/src/lib/workbench/properties/inspection-source.ts @@ -0,0 +1,33 @@ +import type { SourceDocument, SourceLocation } from '$lib/workspace/project'; + +/** Convert an applied UTF-8 origin only while that file's draft is unchanged. */ +export function inspectionSource( + origin: SourceLocation | undefined, + documents: readonly SourceDocument[], + drafts: readonly SourceDocument[], +) { + if (!origin) return; + const applied = documents.find((item) => item.name === origin.file); + const draft = drafts.find((item) => item.name === origin.file); + if (!applied || draft?.text !== applied.text) return; + const bytes = new TextEncoder().encode(applied.text); + if ( + !Number.isSafeInteger(origin.offset) || + !Number.isSafeInteger(origin.length) || + origin.offset < 0 || + origin.length < 0 || + origin.offset + origin.length > bytes.length + ) + return; + const decoder = new TextDecoder('utf-8', { fatal: true }); + try { + return { + file: applied.name, + start: decoder.decode(bytes.slice(0, origin.offset)).length, + end: decoder.decode(bytes.slice(0, origin.offset + origin.length)).length, + focus: false, + }; + } catch { + return; + } +} diff --git a/src/lib/workbench/requirements/RequirementsPanel.tsx b/src/lib/workbench/requirements/RequirementsPanel.tsx index 7c1ebfd..fb406da 100644 --- a/src/lib/workbench/requirements/RequirementsPanel.tsx +++ b/src/lib/workbench/requirements/RequirementsPanel.tsx @@ -15,12 +15,16 @@ export function RequirementsPanel({ workspace, state, mode, - onReveal, + onInspect, }: { workspace: Workspace; state: WorkspaceState; mode: 'table' | 'matrix'; - onReveal: (subject: RequirementSubject) => void; + onInspect: ( + subject: RequirementSubject, + modelHash: string, + row?: RequirementsResult['requirements'][number], + ) => void; }) { const [result, setResult] = useState(); const [error, setError] = useState(''); @@ -92,7 +96,12 @@ export function RequirementsPanel({ current.modelHash === state.model?.modelHash && !state.busy; function reveal(subject: RequirementSubject) { - if (available) onReveal(subject); + if (available && current) + onInspect( + subject, + current.modelHash, + current.requirements.find((row) => row.subject.id === subject.id), + ); } function exportCSV() { if (!available || tooLarge) return; diff --git a/src/lib/workbench/shell/Workbench.tsx b/src/lib/workbench/shell/Workbench.tsx index 8060dcd..4c9458d 100644 --- a/src/lib/workbench/shell/Workbench.tsx +++ b/src/lib/workbench/shell/Workbench.tsx @@ -1,5 +1,9 @@ import { RequirementsPanel } from '../requirements/RequirementsPanel'; -import type { RequirementSubject } from '$lib/workspace/requirements'; +import { inspectionSource } from '../properties/inspection-source'; +import type { + RequirementsResult, + RequirementSubject, +} from '$lib/workspace/requirements'; import { Button } from '$lib/ui/primitives/button'; import { WorkbenchLayout, @@ -12,7 +16,11 @@ import { exampleProject, exampleView } 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'; -import type { ModelElement, Diagnostic } from '$lib/workspace/project'; +import type { + ModelElement, + Diagnostic, + SourceLocation, +} from '$lib/workspace/project'; import { Explorer } from '../explorer/Explorer'; import { SourceEditor } from '../source/SourceEditor'; import { Diagram } from '../diagrams/Diagram'; @@ -84,8 +92,18 @@ export function Workbench({ [file, setFile] = useState(exampleProject.documents[0]!.name), [selected, setSelected] = useState(''), [treeSelectionRequest, setTreeSelectionRequest] = useState(0), + [inspectionRequest, setInspectionRequest] = useState(0), [error, setError] = useState(''), [message, setMessage] = useState('Choose a project to begin.'); + const [inspectionTarget, setInspectionTarget] = useState<{ + modelHash: string; + origin?: SourceLocation; + }>(); + const [requirementSelection, setRequirementSelection] = useState<{ + modelHash: string; + subject: RequirementSubject; + row?: RequirementsResult['requirements'][number]; + }>(); const [symbolSelection, setSymbolSelection] = useState<{ view: string; modelHash: string; @@ -123,6 +141,8 @@ export function Workbench({ dirty: restored.dirty || restored.unapplied, }); setSelected(''); + setInspectionTarget(undefined); + setRequirementSelection(undefined); setSymbolSelection(undefined); setFile(entry.activeFile ?? entry.project.drafts[0]!.name); setFocusRange(undefined); @@ -135,6 +155,22 @@ export function Workbench({ await run(() => refreshView(session, entry.activeView ?? undefined)); } const element = state.model?.elements.find((item) => item.id === selected); + const inspectedSubject = + requirementSelection && + requirementSelection.modelHash === state.model?.modelHash && + (requirementSelection.subject.qualifiedName ?? '') === selected + ? requirementSelection + : undefined; + const inspectedRequirement = inspectedSubject?.row; + const inspectionRange = + inspectionTarget?.modelHash === state.model?.modelHash + ? inspectionSource( + inspectionTarget?.origin, + state.project?.documents ?? [], + state.project?.drafts ?? [], + ) + : undefined; + // Adapt only for shared rendering/selection; workspace state and saved views stay distinct. const displayedView = useMemo( () => @@ -271,6 +307,8 @@ export function Workbench({ await session.open(project, { unsaved: options.unsaved }); setDataMode('diagram'); setSelected(''); + setInspectionTarget(undefined); + setRequirementSelection(undefined); setSymbolSelection(undefined); setFile(project.drafts[0]!.name); setFocusRange(undefined); @@ -402,6 +440,7 @@ export function Workbench({ if (command.kind === 'createView') setDataMode('diagram'); } function selectSymbol(symbol: string) { + setRequirementSelection(undefined); const correspondence = displayedView?.correspondence.find( (item) => item.symbol === symbol, ); @@ -417,33 +456,63 @@ export function Workbench({ setSelected(correspondence.subject.qualifiedName); setTreeSelectionRequest((request) => request + 1); } - const origin = correspondence?.subject.origin; + setInspectionTarget({ + modelHash: state.model?.modelHash ?? '', + origin: + displayedView?.modelHash === state.model?.modelHash + ? correspondence?.subject.origin + : undefined, + }); setFocusRange(undefined); - if (origin && state.project) { - const document = state.project.documents.find( - (item) => item.name === origin.file, - ); - if (document) { - const bytes = new TextEncoder().encode(document.text), - decoder = new TextDecoder('utf-8', { fatal: true }); - 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, - }); - } catch { - setFocusRange(undefined); - } - } - } + if (correspondence) setInspectionRequest((request) => request + 1); } function selectElement(item: ModelElement) { + setRequirementSelection(undefined); setSelected(item.id); - setSymbolSelection(undefined); + const occurrences = + displayedView?.correspondence.filter( + (entry) => + entry.subject.qualifiedName === item.id && + displayedView.artifact?.nodes.some( + (node) => + node.id === entry.symbol || + node.ports?.some((port) => port.id === entry.symbol), + ), + ) ?? []; + const occurrence = occurrences.length === 1 ? occurrences[0] : undefined; + setSymbolSelection( + occurrence && + displayedView && + displayedView.modelHash === state.model?.modelHash + ? { + view: diagramIdentity ?? '', + modelHash: displayedView.modelHash, + symbol: occurrence.symbol, + } + : undefined, + ); + const origins = + displayedView?.correspondence + .filter((entry) => entry.subject.qualifiedName === item.id) + .map((entry) => entry.subject.origin) ?? []; + const origin = origins[0]; + const unique = + origin && + origins.every( + (other) => + other?.file === origin.file && + other.offset === origin.offset && + other.length === origin.length, + ); + setInspectionTarget({ + modelHash: state.model?.modelHash ?? '', + origin: + unique && displayedView?.modelHash === state.model?.modelHash + ? origin + : undefined, + }); setFocusRange(undefined); + setInspectionRequest((request) => request + 1); } function navigateIssue(issue: Diagnostic) { const current = workspace().snapshot(); @@ -466,35 +535,38 @@ 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) { + function inspectRequirement( + subject: RequirementSubject, + modelHash: string, + row?: RequirementsResult['requirements'][number], + ) { + const current = workspace().snapshot(); + if (current.model?.modelHash !== modelHash) { setMessage( - 'Apply or discard this file’s draft before revealing its applied source.', + 'This selection belongs to an earlier model. Select a current row to inspect it.', ); 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, + setRequirementSelection({ + modelHash: current.model?.modelHash ?? '', + subject, + row, }); + setInspectionTarget({ + modelHash: current.model?.modelHash ?? '', + origin: subject.origin, + }); + setFocusRange(undefined); + setInspectionRequest((request) => request + 1); } function revealSource() { - if (focusRange) { - setFile(focusRange.file); + if (inspectionRange) { + setFile(inspectionRange.file); setMode('split'); // Repeat the reveal even when the same file and layout are already open. - setFocusRange({ ...focusRange, focus: true }); + setFocusRange({ ...inspectionRange, focus: true }); } else setMessage( 'Select this element in a diagram to reveal its recorded source range.', @@ -622,6 +694,7 @@ export function Workbench({ ref={workspaceSurface} > )}
item.kind === 'viewUsage', @@ -772,8 +846,13 @@ export function Workbench({ element={element} artifactNode={selectedArtifactNode} relationship={relationshipSelection} - sourceAvailable={!!focusRange} + requirement={inspectedRequirement} + subject={ + inspectedSubject?.subject ?? selectedCorrespondence?.subject + } + sourceAvailable={!!inspectionRange} readOnlyDiagram={ + !!inspectedSubject || !!state.exploration || state.view?.artifact?.kind === 'requirement' } @@ -782,6 +861,26 @@ export function Workbench({ onRename={(name) => void run(() => reviewRename(name))} onCreateView={newView} onSource={revealSource} + onValueSource={(origin) => { + const current = workspace().snapshot(); + const target = + displayedView?.modelHash === current.model?.modelHash + ? inspectionSource( + origin, + current.project?.documents ?? [], + current.project?.drafts ?? [], + ) + : undefined; + if (!target) { + setMessage( + 'Apply or discard the changed source before opening this recorded value.', + ); + return; + } + setFile(target.file); + setMode('split'); + setFocusRange({ ...target, focus: true }); + }} onCreatePart={newPart} onConnectPorts={newConnection} /> diff --git a/src/lib/workbench/shell/WorkbenchPanels.tsx b/src/lib/workbench/shell/WorkbenchPanels.tsx index de605c2..6d3f989 100644 --- a/src/lib/workbench/shell/WorkbenchPanels.tsx +++ b/src/lib/workbench/shell/WorkbenchPanels.tsx @@ -159,7 +159,15 @@ function useLayoutController(mode: Mode) { panels[other].current?.collapse(); remember({ [closedKey[other]]: true }); } - panel.resize(`${preferences.current[id]}%`); + // A remembered desktop percentage can fall below a side tool's pixel + // minimum on a phone; the panel library may then keep it collapsed. + const minimum = id === 'inspector' ? 190 : 145; + panel.resize( + id !== 'issues' && + (window.innerWidth * preferences.current[id]) / 100 < minimum + ? `${minimum}px` + : `${preferences.current[id]}%`, + ); remember({ [closedKey[id]]: false }); if (transferFocus) setFocusRequest({ id, collapsed: false }); } @@ -496,6 +504,7 @@ export function WorkbenchPanels({ inspector, issues, issueKey, + inspectionRequest, }: { explorer: ReactNode; source: ReactNode; @@ -503,10 +512,14 @@ export function WorkbenchPanels({ inspector: ReactNode; issues: ReactNode; issueKey: string; + inspectionRequest: number; }) { const layout = usePanels(); const initial = layout.initial; const lastIssue = useRef(''); + useEffect(() => { + if (inspectionRequest) layout.show('inspector', window.innerWidth < 600); + }, [inspectionRequest]); useEffect(() => { if (issueKey && issueKey !== lastIssue.current) layout.show('issues', false); diff --git a/src/lib/workspace/createWorkspace.ts b/src/lib/workspace/createWorkspace.ts index 83fba40..6dda089 100644 --- a/src/lib/workspace/createWorkspace.ts +++ b/src/lib/workspace/createWorkspace.ts @@ -223,6 +223,7 @@ export function createWorkspace(dependencies: { ), before.model.modelHash, oldId, + before.project.documents, ); const newView = validateEvaluation( await dependencies.semantic.evaluate( @@ -233,6 +234,7 @@ export function createWorkspace(dependencies: { ), model.modelHash, id, + documents, ); const mapped = reconcileView({ oldView, @@ -447,6 +449,7 @@ export function createWorkspace(dependencies: { ), before.model.modelHash, captured.view, + before.project.documents, ); current(token); validateConnection(before.model, view, captured); @@ -486,6 +489,7 @@ export function createWorkspace(dependencies: { ), model.modelHash, captured.view, + documents, ); current(token); checkConnectionResult(model, view, captured); @@ -605,7 +609,12 @@ export function createWorkspace(dependencies: { ); current(token); publish({ - view: validateEvaluation(result, model.modelHash, id), + view: validateEvaluation( + result, + model.modelHash, + id, + project.documents, + ), exploration: null, }); } finally { diff --git a/src/lib/workspace/exploration/index.ts b/src/lib/workspace/exploration/index.ts index 1f22503..656efa4 100644 --- a/src/lib/workspace/exploration/index.ts +++ b/src/lib/workspace/exploration/index.ts @@ -82,7 +82,7 @@ export function validateExploration( result.modelHash !== modelHash ) throw new Error('Invalid or stale exploration response.'); - validateEvaluation({ ...result, view: '' }, modelHash, ''); + validateEvaluation({ ...result, view: '' }, modelHash, '', documents); const sizes = new Map( documents.map((file) => [ file.name, diff --git a/src/lib/workspace/views/index.ts b/src/lib/workspace/views/index.ts index 14cd3ad..8a32d47 100644 --- a/src/lib/workspace/views/index.ts +++ b/src/lib/workspace/views/index.ts @@ -1,3 +1,5 @@ +import { validateSourceValues } from './source-values'; +import type { SourceDocument } from '../project'; import type { Static } from 'typebox'; import type { ReadonlyData } from '$lib/contracts/validation'; import { @@ -15,6 +17,7 @@ export function validateEvaluation( value: unknown, modelHash: string, view: string, + documents: readonly SourceDocument[] = [], ): EvaluatedView { const result = readEvaluatedView(value); if ( @@ -45,6 +48,7 @@ export function validateEvaluation( ) validateStructure(result.artifact); if (result.artifact?.kind === 'requirement') validateRequirements(result); + validateSourceValues(result, documents); return result; } diff --git a/src/lib/workspace/views/schema.ts b/src/lib/workspace/views/schema.ts index 0026940..1dee2d5 100644 --- a/src/lib/workspace/views/schema.ts +++ b/src/lib/workspace/views/schema.ts @@ -7,6 +7,46 @@ export const ViewSubjectSchema = Type.Object({ qualifiedName: Type.Optional(Type.String()), origin: Type.Optional(SourceLocationSchema), }); +export const SourceValueSchema = Type.Union([ + Type.Object( + { + status: Type.Literal('available'), + expression: Type.String({ minLength: 1 }), + expressionOrigin: Type.Object( + { + file: Type.String(), + offset: Type.Integer({ minimum: 0 }), + length: Type.Integer({ minimum: 1 }), + }, + closed, + ), + provider: ViewSubjectSchema, + isDefault: Type.Boolean(), + isInitial: Type.Boolean(), + provenance: Type.Union([Type.Literal('own'), Type.Literal('redefined')]), + }, + closed, + ), + Type.Object({ status: Type.Literal('absent') }, closed), + Type.Object( + { + status: Type.Literal('unsupported'), + reason: Type.String({ minLength: 1 }), + }, + closed, + ), +]); +export const SourceAttributeSchema = Type.Object( + { + subject: ViewSubjectSchema, + name: Type.String(), + type: Type.Optional(Type.String()), + context: Type.Array(Type.String()), + declaration: Type.String(), + value: SourceValueSchema, + }, + closed, +); export const CorrespondenceSchema = Type.Object({ symbol: Type.String(), subject: ViewSubjectSchema, @@ -30,6 +70,8 @@ export const ArtifactNodeSchema = Type.Object({ type: Type.Optional(Type.String()), detail: Type.Optional(Type.String()), attributeDeclarations: Type.Optional(Type.Array(Type.String())), + sourceAttribute: Type.Optional(SourceAttributeSchema), + sourceAttributes: Type.Optional(Type.Array(SourceAttributeSchema)), shortName: Type.Optional(Type.String()), documentation: Type.Optional(Type.Array(Type.String())), exposure: Type.Optional( diff --git a/src/lib/workspace/views/source-values.test.ts b/src/lib/workspace/views/source-values.test.ts new file mode 100644 index 0000000..3d56d56 --- /dev/null +++ b/src/lib/workspace/views/source-values.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from 'vitest'; +import { validateEvaluation } from './index'; + +// Controlled transport fixtures verify publication rules, not SysML semantics. +const text = 'part p { attribute x = "λ"; }'; +const documents = [{ name: 'model.sysml', text }]; +function origin(fragment: string) { + return { + file: 'model.sysml', + offset: new TextEncoder().encode(text.slice(0, text.indexOf(fragment))) + .length, + length: new TextEncoder().encode(fragment).length, + }; +} +function subject(fragment: string) { + const location = origin(fragment); + return { + id: `${location.file}@${location.offset}:${location.length}`, + origin: location, + }; +} +function response() { + const owner = subject(text), + member = subject('attribute x = "λ";'); + const row = { + subject: member, + name: 'x', + context: [owner.id, member.id], + declaration: 'attribute x = "λ";', + value: { + status: 'available', + expression: '"λ"', + expressionOrigin: origin('"λ"'), + provider: structuredClone(member), + isDefault: false, + isInitial: false, + provenance: 'own', + }, + }; + return { + schemaVersion: 1, + modelHash: 'hash', + view: 'v', + status: 'complete', + renderStatus: 'available', + diagnostics: [], + correspondence: [ + { symbol: 'owner', subject: owner, context: [owner.id] }, + { symbol: 'attribute', subject: member, context: [owner.id, member.id] }, + ], + artifact: { + kind: 'tree', + nodes: [ + { id: 'owner', name: 'p', kind: 'part', sourceAttributes: [row] }, + { + id: 'attribute', + name: 'x', + kind: 'attribute', + sourceAttribute: structuredClone(row), + }, + ], + edges: [], + }, + }; +} +test('publishes exact UTF-8 expressions with node and compartment contexts', () => { + const value = response(); + expect(validateEvaluation(value, 'hash', 'v', documents)).toEqual(value); + expect(() => validateEvaluation(value, 'hash', 'v')).toThrow( + 'applied project', + ); +}); +for (const fault of [ + 'expression', + 'provider', + 'context', + 'declaration', + 'identity', + 'duplicate', + 'utf8', + 'provenance', + 'correspondence', +]) + test(`rejects forged source-value ${fault}`, () => { + const value = response(); + const row = value.artifact.nodes[1]!.sourceAttribute!; + if (fault === 'expression') row.value.expression = '"other"'; + if (fault === 'provider') row.value.expressionOrigin.offset = 0; + if (fault === 'context') row.context[0] = 'another usage'; + if (fault === 'declaration') row.declaration = 'attribute x = 3;'; + if (fault === 'identity') row.subject.id = 'invented'; + if (fault === 'duplicate') + value.artifact.nodes[0]!.sourceAttributes!.push( + value.artifact.nodes[0]!.sourceAttributes![0]!, + ); + if (fault === 'utf8') row.value.expressionOrigin.length -= 2; + if (fault === 'provenance') row.value.provenance = 'redefined'; + if (fault === 'correspondence') + value.correspondence.push(structuredClone(value.correspondence[1]!)); + expect(() => validateEvaluation(value, 'hash', 'v', documents)).toThrow(); + }); +test('preserves explicit unsupported and absent states without inventing a value', () => { + const value = response(); + const raw = JSON.parse(JSON.stringify(value)); + raw.artifact.nodes[1].sourceAttribute.value = { + status: 'unsupported', + reason: 'binding precedence unsupported', + }; + raw.artifact.nodes[0].sourceAttributes[0].value = { status: 'absent' }; + expect(validateEvaluation(raw, 'hash', 'v', documents)).toEqual(raw); +}); diff --git a/src/lib/workspace/views/source-values.ts b/src/lib/workspace/views/source-values.ts new file mode 100644 index 0000000..8dc5b29 --- /dev/null +++ b/src/lib/workspace/views/source-values.ts @@ -0,0 +1,92 @@ +import type { EvaluatedView, ArtifactNode } from './index'; +import type { SourceDocument, SourceLocation } from '../project'; + +export type SourceAttribute = NonNullable; + +/** Verify derived display metadata against applied bytes, never draft text. */ +export function validateSourceValues( + result: EvaluatedView, + documents: readonly SourceDocument[], +) { + const files = new Map( + documents.map((file) => [file.name, new TextEncoder().encode(file.text)]), + ); + const decoder = new TextDecoder('utf-8', { fatal: true }); + function slice(origin: SourceLocation | undefined): string { + const bytes = origin && files.get(origin.file); + if ( + !origin || + !bytes || + origin.length <= 0 || + origin.offset < 0 || + origin.offset + origin.length > bytes.length + ) + throw new Error('Source value origin is outside the applied project.'); + try { + decoder.decode(bytes.slice(0, origin.offset)); + return decoder.decode( + bytes.slice(origin.offset, origin.offset + origin.length), + ); + } catch { + throw new Error('Source value origin splits a UTF-8 character.'); + } + } + function subject(value: SourceAttribute['subject']) { + const text = slice(value.origin); + const origin = value.origin!; + if (value.id !== `${origin.file}@${origin.offset}:${origin.length}`) + throw new Error( + 'Source value declaration identity does not match its origin.', + ); + return text; + } + function attribute(row: SourceAttribute, context: readonly string[]) { + if (row.declaration !== subject(row.subject)) + throw new Error( + 'Source attribute declaration differs from applied source.', + ); + if ( + JSON.stringify(row.context) !== JSON.stringify(context) || + context.at(-1) !== row.subject.id + ) + throw new Error('Source attribute has a different usage context.'); + const value = row.value; + if (value.status !== 'available') return; + subject(value.provider); + const origin = value.expressionOrigin, + provider = value.provider.origin!; + if ( + origin.file !== provider.file || + origin.offset < provider.offset || + origin.offset + origin.length > provider.offset + provider.length || + slice(origin) !== value.expression + ) + throw new Error('Source expression differs from its applied provider.'); + if ((value.provenance === 'own') !== (value.provider.id === row.subject.id)) + throw new Error('Source expression provider provenance is inconsistent.'); + } + for (const node of result.artifact?.nodes ?? []) { + if (!node.sourceAttribute && !node.sourceAttributes) continue; + const matches = result.correspondence.filter( + (entry) => entry.symbol === node.id, + ); + if (matches.length !== 1) + throw new Error('Source values require unique node correspondence.'); + const reference = matches[0]!; + if (reference.context.at(-1) !== reference.subject.id) + throw new Error('Source values require a complete owner context.'); + subject(reference.subject); + if (node.sourceAttribute) { + if (node.sourceAttribute.subject.id !== reference.subject.id) + throw new Error('Source attribute belongs to another declaration.'); + attribute(node.sourceAttribute, reference.context); + } + const ids = new Set(); + for (const row of node.sourceAttributes ?? []) { + if (ids.has(row.subject.id)) + throw new Error('Duplicate source attribute in a compartment.'); + ids.add(row.subject.id); + attribute(row, [...reference.context, row.subject.id]); + } + } +} diff --git a/tests/e2e/authoring.spec.ts b/tests/e2e/authoring.spec.ts index 7b6b0c1..cecfaec 100644 --- a/tests/e2e/authoring.spec.ts +++ b/tests/e2e/authoring.spec.ts @@ -12,6 +12,7 @@ import { diagramSymbol, expectRenderedPositions, diagramState, + readDiagramSymbol, } from './support'; test('@critical creates a typed part and connects its actual inherited usage ports through reviewed source transactions', async ({ @@ -30,6 +31,13 @@ test('@critical creates a typed part and connects its actual inherited usage por } const baseline = (await downloadProject(page)).bundle; const originalPositions = semanticPositions(baseline, evaluations); + await readDiagramSymbol( + page, + page.getByRole('group', { + name: 'part Assembly::Station::bay', + exact: true, + }), + ); await page .getByRole('group', { name: 'part Assembly::Station::bay', exact: true }) .click({ position: { x: 30, y: 30 } }); diff --git a/tests/e2e/inspection.spec.ts b/tests/e2e/inspection.spec.ts new file mode 100644 index 0000000..7c18ac9 --- /dev/null +++ b/tests/e2e/inspection.spec.ts @@ -0,0 +1,200 @@ +import { readFile } from 'node:fs/promises'; +import { + test, + expect, + fillSource, + modelTreeItem, + downloadProject, +} from './support'; +import { + importRequirementsTraceability, + loadRequirementsTraceability, +} from './requirements-support'; + +test('@critical tree selection reveals hidden details while retaining the current draft and view', async ({ + page, +}) => { + await page.goto('/app'); + const buffer = await readFile( + new URL('../fixtures/source-values.sysml', import.meta.url), + ); + await page + .locator('input[type=file]') + .setInputFiles({ name: 'values.sysml', mimeType: 'text/plain', buffer }); + await expect( + page.getByRole('button', { name: 'Download project', exact: true }), + ).toBeEnabled(); + await page.getByRole('button', { name: 'Split', exact: true }).click(); + const draft = buffer.toString() + '\n// retained inspection draft'; + const source = page.getByRole('textbox', { + name: 'SysML source', + exact: true, + }); + await fillSource(source, draft); + const before = (await downloadProject(page)).bundle; + await page + .getByRole('button', { name: 'Collapse Inspector', exact: true }) + .click(); + const reading = modelTreeItem(page, 'Reading'); + await reading.focus(); + await page.keyboard.press('ArrowRight'); + const selected = modelTreeItem(page, 'Reading::small'); + await selected.focus(); + await page.keyboard.press('Enter'); + const properties = page.getByRole('complementary', { + name: 'Properties', + exact: true, + }); + await expect(properties).toBeVisible({ timeout: 1500 }); + await expect(properties).toContainText('Reading::small'); + await expect(selected).toBeFocused(); + await expect(source).toHaveSource(draft); + await expect( + page.getByRole('combobox', { name: 'Named view', exact: true }), + ).toHaveValue('ReadingViews::configurations'); + expect((await downloadProject(page)).bundle).toEqual(before); +}); + +test('@critical table selection opens details without automatically navigating to source', async ({ + page, +}) => { + await page.goto('/app'); + await importRequirementsTraceability(page); + await page.getByRole('button', { name: 'Diagram', exact: true }).click(); + await page + .getByRole('button', { name: 'Collapse Inspector', exact: true }) + .click(); + await page + .getByRole('button', { name: 'Requirements table', exact: true }) + .click(); + const table = page.getByRole('region', { + name: 'Requirements table', + exact: true, + }); + const row = table.getByRole('button', { + name: 'TraceRequirements::leftSpecification::leftCapacity', + exact: true, + }); + await expect(row).toBeEnabled(); + const before = (await downloadProject(page)).bundle; + await row.click(); + const properties = page.getByRole('complementary', { + name: 'Properties', + exact: true, + }); + await expect(properties).toBeVisible({ timeout: 1500 }); + await expect(properties).toContainText( + 'TraceRequirements::leftSpecification::leftCapacity', + ); + await expect( + page.getByRole('button', { name: 'Diagram', exact: true }), + ).toHaveAttribute('aria-pressed', 'true'); + await expect(row).toBeFocused(); + await expect(properties).toContainText('REQ-LEFT-CAP'); + await expect(properties).toContainText('Applied requirement declaration'); + await expect( + properties.getByRole('button', { name: 'Open source', exact: true }), + ).toBeEnabled(); + expect((await downloadProject(page)).bundle).toEqual(before); +}); + +for (const theme of ['light', 'dark']) { + test(`phone inspection keeps focus in a visible tool in ${theme}`, async ({ + page, + }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/app'); + const documents = await loadRequirementsTraceability(); + await page.locator('input[type=file]').setInputFiles( + documents.map((document) => ({ + name: document.name, + mimeType: 'text/plain', + buffer: Buffer.from(document.text), + })), + ); + await expect( + page.getByRole('combobox', { name: 'Named view', exact: true }), + ).toHaveValue('TraceViews::requirements'); + await expect( + page.getByRole('button', { name: 'Download project', exact: true }), + ).toBeEnabled(); + 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 page + .getByRole('button', { name: 'Requirements table', exact: true }) + .click(); + const before = (await downloadProject(page)).bundle; + const table = page.getByRole('region', { + name: 'Requirements table', + exact: true, + }); + const row = table.getByRole('button', { + name: 'TraceRequirements::leftSpecification::leftCapacity', + exact: true, + }); + await row.focus(); + await page.keyboard.press('Enter'); + const properties = page.getByRole('complementary', { + name: 'Properties', + exact: true, + }); + await expect(properties).toBeVisible(); + await expect(properties).toContainText('REQ-LEFT-CAP'); + await expect( + page.getByRole('button', { name: 'Collapse Inspector', exact: true }), + ).toBeFocused(); + expect((await downloadProject(page)).bundle).toEqual(before); + await page.screenshot({ + path: testInfo.outputPath(`inspection-phone-${theme}.png`), + }); + await page + .getByRole('button', { name: 'Collapse Inspector', exact: true }) + .click(); + await expect( + page.getByRole('button', { name: 'Show Inspector', exact: true }), + ).toBeFocused(); + }); +} + +test('@critical anonymous requirement inspection retains extracted documentation and exact source', async ({ + page, +}) => { + await page.goto('/app'); + const text = + 'package P { requirement { doc /* The pump shall preserve pressure. */ } }'; + await page.locator('input[type=file]').setInputFiles({ + name: 'anonymous.sysml', + mimeType: 'text/plain', + buffer: Buffer.from(text), + }); + await expect( + page.getByRole('button', { name: 'Download project', exact: true }), + ).toBeEnabled(); + await page + .getByRole('button', { name: 'Requirements table', exact: true }) + .click(); + const table = page.getByRole('region', { + name: 'Requirements table', + exact: true, + }); + await table.getByRole('button', { name: 'P::', exact: true }).click(); + const properties = page.getByRole('complementary', { + name: 'Properties', + exact: true, + }); + await expect(properties).toContainText('The pump shall preserve pressure.'); + await properties + .getByRole('button', { name: 'Open source', exact: true }) + .click(); + const source = page.getByRole('textbox', { + name: 'SysML source', + exact: true, + }); + await expect(source).toBeFocused(); + await expect(source).toHaveSource(text); +}); diff --git a/tests/e2e/readability.spec.ts b/tests/e2e/readability.spec.ts new file mode 100644 index 0000000..5a23eb6 --- /dev/null +++ b/tests/e2e/readability.spec.ts @@ -0,0 +1,176 @@ +import { + importRequirementsTraceability, + requirementCard, +} from './requirements-support'; +import { + test, + expect, + observeEvaluations, + diagramCamera, + downloadProject, + fillSource, + regionSize, + readingCenterOffset, +} from './support'; + +const source = `package ReadingSpace { part def Assembly { +${Array.from({ length: 20 }, (_, index) => `part device${index} { attribute rating = ${index}; }`).join('\n')} +} } +package ReadingViews { view assembly : StandardViewDefinitions::InterconnectionView { expose ReadingSpace::Assembly; } }`; +for (const width of [1600, 800]) + test(`@critical large assembly opens at a readable scale at ${width}px`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 1000 }); + const evaluations = await observeEvaluations(page); + await page.goto('/app'); + await page.locator('input[type=file]').setInputFiles({ + name: 'reading.sysml', + mimeType: 'text/plain', + buffer: Buffer.from(source), + }); + await expect + .poll(() => + evaluations.some((value) => value.view === 'ReadingViews::assembly'), + ) + .toBe(true); + await expect( + page.getByRole('combobox', { name: 'Named view', exact: true }), + ).toBeEnabled(); + await expect + .poll(async () => (await diagramCamera(page)).zoom, { timeout: 1500 }) + .toBeGreaterThanOrEqual(0.75); + const before = (await downloadProject(page)).bundle; + await page.getByRole('button', { name: 'Fit view', exact: true }).click(); + await expect + .poll(async () => (await diagramCamera(page)).zoom) + .toBeLessThan(0.6); + await page + .getByRole('button', { name: 'Read at 100%', exact: true }) + .click(); + await expect.poll(async () => (await diagramCamera(page)).zoom).toBe(1); + expect((await downloadProject(page)).bundle).toEqual(before); + }); + +test('@critical readable camera follows Inspector selection from an embedded Inspect button', async ({ + page, +}) => { + const evaluations = await observeEvaluations(page); + await page.goto('/app'); + await importRequirementsTraceability(page); + const first = page.getByRole('group', { + name: 'requirement def REQ-CAP TraceRequirements::MinimumCapacity', + exact: true, + }); + await first.focus(); + await first.press('Enter'); + await page.getByRole('button', { name: 'Fit view', exact: true }).click(); + const selected = requirementCard( + page, + evaluations.findLast((view) => view.view === 'TraceViews::requirements')!, + 'TraceRequirements::rightSpecification::rightCapacity', + ); + await selected + .getByRole('button', { + name: 'Inspect REQ-RIGHT-CAP TraceRequirements::rightSpecification::rightCapacity', + exact: true, + }) + .click(); + await expect( + page.getByRole('complementary', { name: 'Properties', exact: true }), + ).toContainText('REQ-RIGHT-CAP'); + await page.getByRole('button', { name: 'Read at 100%', exact: true }).click(); + await expect.poll(async () => (await diagramCamera(page)).zoom).toBe(1); + await expect.poll(() => readingCenterOffset(selected)).toBeLessThan(1); + const canvas = await page + .getByRole('region', { name: 'Diagram', exact: true }) + .boundingBox(); + await expect + .poll(async () => { + const box = await selected.boundingBox(); + return ( + !!box && + !!canvas && + box.x >= canvas.x && + box.x + box.width <= canvas.x + canvas.width && + box.y >= canvas.y && + box.y + box.height <= canvas.y + canvas.height + ); + }) + .toBe(true); +}); + +test('@critical responsive panes preserve explicit preferences, camera and the unfinished draft', async ({ + page, +}) => { + await page.goto('/app'); + await page.locator('input[type=file]').setInputFiles({ + name: 'reading.sysml', + mimeType: 'text/plain', + buffer: Buffer.from(source), + }); + await expect( + page.getByRole('combobox', { name: 'Named view', exact: true }), + ).toBeEnabled(); + await page.getByRole('button', { name: 'Split', exact: true }).click(); + const editor = page.getByRole('textbox', { + name: 'SysML source', + exact: true, + }); + const draft = source + '\n// keep this draft through responsive inspection'; + await fillSource(editor, draft); + await page + .getByRole('button', { name: 'Collapse Inspector', exact: true }) + .click(); + await page + .getByRole('button', { name: 'Collapse Explorer', exact: true }) + .click(); + const preferences = await page.evaluate(() => + JSON.parse(localStorage.getItem('sysml-workbench-layout-v1')!), + ); + const saved = (await downloadProject(page)).bundle; + const camera = await diagramCamera(page); + for (const width of [800, 390, 1440]) { + await page.setViewportSize({ width, height: 1000 }); + await expect(editor).toHaveSource(draft); + await expect.poll(() => diagramCamera(page)).toEqual(camera); + await expect( + page.getByRole('button', { name: 'Show Explorer', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Show Inspector', exact: true }), + ).toBeVisible(); + expect( + await page.evaluate(() => + JSON.parse(localStorage.getItem('sysml-workbench-layout-v1')!), + ), + ).toEqual(preferences); + const primary = await page + .getByRole('region', { name: 'Diagram', exact: true }) + .boundingBox(); + expect(primary!.width).toBeGreaterThan(width * (width <= 900 ? 0.8 : 0.4)); + } + await page + .getByRole('button', { name: 'Show Inspector', exact: true }) + .focus(); + await page.keyboard.press('Enter'); + await expect( + page.getByRole('button', { name: 'Collapse Inspector', exact: true }), + ).toBeFocused(); + await expect + .poll(() => + regionSize( + page.getByRole('complementary', { name: 'Properties', exact: true }), + 'width', + ), + ) + .toBeGreaterThanOrEqual(189); + expect( + await page.evaluate( + () => + JSON.parse(localStorage.getItem('sysml-workbench-layout-v1')!) + .inspector, + ), + ).toBe(preferences.inspector); + expect((await downloadProject(page)).bundle).toEqual(saved); +}); diff --git a/tests/e2e/requirements-data.spec.ts b/tests/e2e/requirements-data.spec.ts index 29f14fe..7744e94 100644 --- a/tests/e2e/requirements-data.spec.ts +++ b/tests/e2e/requirements-data.spec.ts @@ -57,6 +57,10 @@ test('@critical reads requirements and exact declared traceability without chang exact: true, }) .click(); + 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, @@ -143,9 +147,18 @@ test('@critical reads requirements and exact declared traceability without chang exact: true, }) .click(); + // Inspection remains available for applied declarations; source navigation is + // explicitly unavailable while that file has a different draft. + const properties = page.getByRole('complementary', { + name: 'Properties', + exact: true, + }); + await expect(properties).toContainText('TraceRequirements::excludedCapacity'); + await expect(properties).toContainText('unapplied draft'); await expect( - page.getByRole('status').filter({ hasText: 'Apply or discard this file' }), - ).toBeVisible(); + properties.getByRole('button', { name: 'Open source', exact: true }), + ).toBeDisabled(); + const saved = (await downloadProject(page)).bundle; expect(saved.documents).toEqual(before.documents); expect(saved.presentations).toEqual(before.presentations); @@ -245,6 +258,10 @@ test('matrix verification resolves the actual verifier and requirement through a .getByRole('button', { name: 'Requirements::safety', exact: true }), ).toBeVisible(); await matrix.getByRole('button', { name: 'Declared', exact: true }).click(); + await page + .getByRole('complementary', { name: 'Properties', exact: true }) + .getByRole('button', { name: 'Open source', exact: true }) + .click(); await expect .poll(() => copySourceSelection( diff --git a/tests/e2e/rover.spec.ts b/tests/e2e/rover.spec.ts index 6a6b4d6..aaaf398 100644 --- a/tests/e2e/rover.spec.ts +++ b/tests/e2e/rover.spec.ts @@ -65,10 +65,21 @@ test('@critical opens the inspection rover with inherited declared values and ex 'attribute reservePercent : Real default = 20.0;', ]); await expect( - diagramSymbol(page, battery.id).getByText('capacityWh: Real = 480', { - exact: true, - }), + diagramSymbol(page, battery.id).getByText( + 'capacityWh : Real default = 480.0', + { + exact: true, + }, + ), ).toBeVisible(); + expect( + battery.sourceAttributes?.find((row) => row.name === 'capacityWh')?.value, + ).toMatchObject({ + status: 'available', + expression: '480.0', + isDefault: true, + }); + await page.getByRole('button', { name: 'Fit view', exact: true }).click(); await expect .poll( async () => @@ -121,7 +132,15 @@ test('@critical opens the inspection rover with inherited declared values and ex ).not.toBeVisible(); const first = results.findLast((v) => v.view === 'RoverViews::' + view)! .artifact!.nodes[0]!; - await expect(diagramSymbol(page, first.id)).toContainText(first.name); + if (first.sourceAttributes?.length) { + await expect( + diagramSymbol(page, first.id).getByTitle( + first.name + (first.type ? ' : ' + first.type : ''), + { exact: true }, + ), + ).toBeVisible(); + } else + await expect(diagramSymbol(page, first.id)).toContainText(first.name); } const requirements = results.findLast( (v) => v.view === 'RoverViews::requirements', diff --git a/tests/e2e/source-values.spec.ts b/tests/e2e/source-values.spec.ts new file mode 100644 index 0000000..7bceeac --- /dev/null +++ b/tests/e2e/source-values.spec.ts @@ -0,0 +1,207 @@ +import { readFile } from 'node:fs/promises'; +import { + test, + expect, + observeEvaluations, + diagramSymbol, + downloadProject, + copySourceSelection, + fillSource, + expectRenderedPositions, + moveNode, +} from './support'; + +test('@critical shows authored configuration values in the intended usage context', async ({ + page, +}, testInfo) => { + const evaluations = await observeEvaluations(page); + await page.goto('/app'); + const source = await readFile( + new URL('../fixtures/source-values.sysml', import.meta.url), + ); + await page.locator('input[type=file]').setInputFiles({ + name: 'values.sysml', + mimeType: 'text/plain', + buffer: source, + }); + const viewName = 'ReadingViews::configurations'; + await expect + .poll(() => evaluations.some((view) => view.view === viewName)) + .toBe(true); + const view = evaluations.findLast((view) => view.view === viewName)!; + await testInfo.attach('source-values-engine', { + body: JSON.stringify(view, null, 2), + contentType: 'application/json', + }); + expect(view.status).toBe('complete'); + expect(view.artifact?.kind).toBe('tree'); + for (const [name, value] of [ + ['Reading::small::capacity', '24'], + ['Reading::small::evidence', 'records/α-evidence.json'], + ['Reading::large::capacity', '48'], + ['Reading::large::evidence', 'records/large-evidence.json'], + ]) { + const binding = view.correspondence.filter( + (entry) => + entry.subject.qualifiedName === name && + view.artifact!.nodes.some((node) => node.id === entry.symbol), + ); + expect(binding).toHaveLength(1); + const node = diagramSymbol(page, binding[0]!.symbol); + await expect(node).toBeVisible(); + await expect(node).toContainText(value!, { timeout: 1500 }); + } +}); + +test('@critical inspects exact nested defaults and overrides, explains bindings and reopens preserved work', async ({ + page, +}, testInfo) => { + const evaluations = await observeEvaluations(page); + const bytes = await readFile( + new URL('../fixtures/source-values.sysml', import.meta.url), + ); + await page.goto('/app'); + await page.locator('input[type=file]').setInputFiles({ + name: 'values.sysml', + mimeType: 'text/plain', + buffer: bytes, + }); + await expect( + page.getByRole('combobox', { name: 'Named view', exact: true }), + ).toBeEnabled(); + await page + .getByRole('combobox', { name: 'Named view', exact: true }) + .selectOption('ReadingViews::nested'); + await expect + .poll(() => + evaluations.some((value) => value.view === 'ReadingViews::nested'), + ) + .toBe(true); + await expect( + page.getByRole('combobox', { name: 'Named view', exact: true }), + ).toBeEnabled(); + const view = evaluations.findLast( + (value) => value.view === 'ReadingViews::nested', + )!; + expect(view.status).toBe('complete'); + await testInfo.attach('nested-source-values', { + body: JSON.stringify(view, null, 2), + contentType: 'application/json', + }); + const properties = page.getByRole('complementary', { + name: 'Properties', + exact: true, + }); + for (const [owner, expression, provider] of [ + ['Reading::alpha::left', '101', 'Reading::alpha::left::gain'], + ['Reading::beta::left', '202', 'Reading::beta::left::gain'], + ['Reading::valueless', '12.50', 'Reading::Sensor::gain'], + ]) { + const bindings = view.correspondence.filter( + (entry) => + entry.subject.qualifiedName === owner && + view.artifact!.nodes.some((node) => node.id === entry.symbol), + ); + expect(bindings).toHaveLength(1); + const binding = bindings[0]!; + const artifact = view.artifact!.nodes.find( + (node) => node.id === binding.symbol, + )!; + const row = artifact.sourceAttributes!.find((row) => row.name === 'gain')!; + expect(row.context).toEqual([...binding.context, row.subject.id]); + expect(row.value.status).toBe('available'); + if (row.value.status !== 'available') + throw new Error('Expected source value'); + expect(row.value.expression).toBe(expression); + expect(row.value.provider.qualifiedName).toBe(provider); + const origin = row.value.expressionOrigin; + expect( + new TextDecoder('utf-8', { fatal: true }).decode( + bytes.subarray(origin.offset, origin.offset + origin.length), + ), + ).toBe(expression); + const node = diagramSymbol(page, binding.symbol); + await node.focus(); + await node.press('Enter'); + await expect(properties).toContainText(provider!); + const details = properties.getByRole('region', { + name: 'Declared source values', + exact: true, + }); + await expect(details.locator('pre')).toHaveText(expression!); + if (owner === 'Reading::valueless') { + await details + .getByRole('button', { + name: 'Open value source for gain', + exact: true, + }) + .click(); + const source = page.getByRole('textbox', { + name: 'SysML source', + exact: true, + }); + await expect(source).toBeFocused(); + expect(await copySourceSelection(source)).toContain( + 'attribute gain : Real default = 12.50;', + ); + await page.getByRole('button', { name: 'Diagram', exact: true }).click(); + } + } + const bound = view.correspondence.find( + (entry) => + entry.subject.qualifiedName === 'Reading::bound' && + view.artifact!.nodes.some((node) => node.id === entry.symbol), + )!; + const node = diagramSymbol(page, bound.symbol); + await node.focus(); + await node.press('Enter'); + const details = properties.getByRole('region', { + name: 'Declared source values', + exact: true, + }); + await expect(details).toContainText('Value unavailable'); + await expect(details).toContainText('bind bound.gain = replacement;'); + await expect(details.locator('pre')).toHaveCount(0); + await page.getByRole('button', { name: 'Read at 100%', exact: true }).click(); + await moveNode(page, node); + const before = (await downloadProject(page)).bundle; + await page.getByRole('button', { name: 'Split', exact: true }).click(); + const source = page.getByRole('textbox', { + name: 'SysML source', + exact: true, + }); + const draft = bytes.toString() + '\npart unfinished : Missing;'; + await fillSource(source, draft); + await page.getByRole('button', { name: 'Apply source', exact: true }).click(); + await expect(source).toHaveSource(draft); + await expect(details).toContainText('Value unavailable'); + await expect( + details.getByRole('button', { + name: 'Open attribute source for gain', + exact: true, + }), + ).toBeDisabled(); + const saved = (await downloadProject(page)).bundle; + expect(saved.documents).toEqual(before.documents); + expect(saved.presentations).toEqual(before.presentations); + const fresh = await page.context().browser()!.newContext(); + const reopened = await fresh.newPage(); + await reopened.goto('/app'); + await reopened.locator('input[type=file]').setInputFiles({ + name: 'values.json', + mimeType: 'application/json', + buffer: Buffer.from(JSON.stringify(saved)), + }); + await expect( + reopened.getByRole('button', { name: 'Download project', exact: true }), + ).toBeEnabled(); + await reopened + .getByRole('combobox', { name: 'Named view', exact: true }) + .selectOption('ReadingViews::nested'); + await expect( + reopened.getByRole('combobox', { name: 'Named view', exact: true }), + ).toBeEnabled(); + expect((await downloadProject(reopened)).bundle).toEqual(saved); + await expectRenderedPositions(reopened, saved, 'ReadingViews::nested'); + await fresh.close(); +}); diff --git a/tests/e2e/support.ts b/tests/e2e/support.ts index d36ec63..a21f0b1 100644 --- a/tests/e2e/support.ts +++ b/tests/e2e/support.ts @@ -419,6 +419,16 @@ export async function showAssemblyDiagram(page: Page) { name: 'part def Assembly::Station', exact: true, }); + // Compare fitted overviews across modes. Readable entry can deliberately + // start at 100%, so its cropped card width is not a fit-to-pane measurement. + await page.getByRole('button', { name: 'Fit view', exact: true }).click(); + const available = await regionSize( + page.getByRole('region', { name: 'Diagram', exact: true }), + 'width', + ); + await expect + .poll(async () => (await station.boundingBox())!.width) + .toBeLessThan(available); const before = (await station.boundingBox())!.width; await page.getByRole('button', { name: 'Diagram', exact: true }).click(); await expect @@ -572,7 +582,20 @@ export async function dragNode( node: Locator, delta = { x: 80, y: 55 }, ) { - await node.scrollIntoViewIfNeeded(); + const reachable = await node.evaluate((element) => { + const heading = + element.querySelector('.structure-heading, .requirement-heading') ?? + element; + const box = heading.getBoundingClientRect(); + const hit = document.elementFromPoint( + box.x + Math.min(30, box.width / 3), + box.y + box.height / 2, + ); + return !!hit && element.contains(hit); + }); + // Canvas contents are navigated with the camera, not page scrolling. A + // readable entry may intentionally leave a later occurrence off-screen. + if (!reachable) await readDiagramSymbol(page, node); // 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( @@ -622,6 +645,12 @@ export async function dragNode( await page.mouse.up(); } +export async function readDiagramSymbol(page: Page, node: Locator) { + await node.focus(); + await node.press('Enter'); + await page.getByRole('button', { name: 'Read at 100%', exact: true }).click(); +} + export async function moveNode( page: Page, node: Locator, @@ -683,6 +712,17 @@ export async function diagramCamera(page: Page) { return { x: matrix.e, y: matrix.f, zoom: matrix.a }; }); } +/** Distance from the actual selected card center to the canvas center. */ +export async function readingCenterOffset(node: Locator) { + return node.evaluate((element) => { + const card = element.getBoundingClientRect(); + const canvas = element.closest('.react-flow')!.getBoundingClientRect(); + return Math.hypot( + card.x + card.width / 2 - canvas.x - canvas.width / 2, + card.y + card.height / 2 - canvas.y - canvas.height / 2, + ); + }); +} export async function panDiagram(page: Page) { const pane = page .getByRole('region', { name: 'Diagram', exact: true }) diff --git a/tests/e2e/visual.spec.ts b/tests/e2e/visual.spec.ts index 10e62e8..cc45bca 100644 --- a/tests/e2e/visual.spec.ts +++ b/tests/e2e/visual.spec.ts @@ -45,6 +45,9 @@ 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(); + // Routing completeness is checked in the overview; readable entry can + // intentionally show only the first group of a larger diagram. + await page.getByRole('button', { name: 'Fit view', exact: true }).click(); const architecture = evaluations.findLast( (result) => result.view === 'RoverViews::architecture', )!; @@ -547,6 +550,9 @@ for (const theme of ['light', 'dark']) { }); await tank.getByText('tank', { exact: true }).click(); await expect(tank).toHaveClass(/selected/); + await page + .getByRole('button', { name: 'Read at 100%', exact: true }) + .click(); await page.mouse.move(0, 0); await expect( page.getByRole('region', { name: 'Diagram', exact: true }), @@ -1176,3 +1182,67 @@ for (const theme of ['light', 'dark']) { }); } } + +for (const theme of ['light', 'dark']) + for (const width of [1440, 800]) { + test(`source value inspection in ${theme} at ${width}px`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 1000 }); + const evaluations = await observeEvaluations(page); + const buffer = await readFile( + new URL('../fixtures/source-values.sysml', import.meta.url), + ); + await page.goto('/app'); + // Geist bundles Latin only. Pin the Unicode fallback in this visual + // fixture so Greek evidence pointers do not depend on the host desktop. + await page.addStyleTag({ + content: + ':root { font-family: "Geist Variable", "Liberation Sans", sans-serif; }', + }); + await page.locator('input[type=file]').setInputFiles({ + name: 'values.sysml', + mimeType: 'text/plain', + buffer, + }); + await expect( + page.getByRole('combobox', { name: 'Named view', exact: true }), + ).toBeEnabled(); + 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(); + const view = evaluations.findLast( + (view) => view.view === 'ReadingViews::configurations', + )!; + const binding = view.correspondence.find( + (entry) => + entry.subject.qualifiedName === 'Reading::small::evidence' && + view.artifact!.nodes.some((node) => node.id === entry.symbol), + )!; + const node = diagramSymbol(page, binding.symbol); + await node.focus(); + await node.press('Enter'); + await page + .getByRole('button', { name: 'Read at 100%', exact: true }) + .click(); + const details = page.getByRole('region', { + name: 'Declared source values', + exact: true, + }); + await expect(details.locator('pre')).toHaveText( + '"records/α-evidence.json"', + ); + await expect(details).toContainText('Reading::small::evidence'); + await expect(page).toHaveScreenshot( + `source-values-${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 fa5b226..e447c8f 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 c729339..af18e46 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 index 1dcf45c..57f7e1a 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-dark-1440-visual-linux.png 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 index 29096e7..bdfb937 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-dark-768-visual-linux.png 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 index 9739ad7..816b448 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-light-1440-visual-linux.png 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 index 97ba218..5c7d159 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-light-768-visual-linux.png 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 index 88de4c3..17a4d80 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-dark-1440-visual-linux.png 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 index 123edf5..4f92ed1 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-dark-768-visual-linux.png 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 index 481a90b..0c9ed01 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-light-1440-visual-linux.png 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 index b276527..1164865 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-light-768-visual-linux.png 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 fb8abc1..1ea93e0 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 index 40ebb13..8777dae 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/default-rover-architecture-dark-768-visual-linux.png 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 index efc5d47..7712dde 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/default-rover-architecture-light-768-visual-linux.png 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 2a95cff..6b7dfd9 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 819b1ce..a077d79 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 98fe280..6d3cefa 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 dbf8815..22f9247 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/editor-guide-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/editor-guide-dark-1440-visual-linux.png index 4a7c086..23a8d53 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/editor-guide-dark-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/editor-guide-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/editor-guide-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/editor-guide-dark-768-visual-linux.png index 9c3a31a..0809a60 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/editor-guide-dark-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/editor-guide-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/editor-guide-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/editor-guide-light-1440-visual-linux.png index 64f698e..2d3750f 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/editor-guide-light-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/editor-guide-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/editor-guide-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/editor-guide-light-768-visual-linux.png index ec9a191..344bb7a 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/editor-guide-light-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/editor-guide-light-768-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 index 2fcdd91..cab826f 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/exploration-overview-dark-1440-visual-linux.png 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 index ca6419e..6c1dc7e 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/exploration-overview-dark-768-visual-linux.png 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 index 5ddf11b..3a20638 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/exploration-overview-light-1440-visual-linux.png 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 index 9239d47..f5973b4 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/exploration-overview-light-768-visual-linux.png 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 index f608273..3d997fc 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/exploration-structure-dark-1440-visual-linux.png 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 index 9d69136..371518a 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/exploration-structure-dark-768-visual-linux.png 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 index eaa3890..cdfbd24 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/exploration-structure-light-1440-visual-linux.png 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 index 7be5639..8ee1ac1 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/exploration-structure-light-768-visual-linux.png 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/inherited-occurrences-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/inherited-occurrences-dark-1440-visual-linux.png index 1725176..145a0a0 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/inherited-occurrences-dark-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/inherited-occurrences-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/inherited-occurrences-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/inherited-occurrences-dark-768-visual-linux.png index 48116ce..edfba6c 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/inherited-occurrences-dark-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/inherited-occurrences-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/inherited-occurrences-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/inherited-occurrences-light-1440-visual-linux.png index 7ca43c2..8c8c55d 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/inherited-occurrences-light-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/inherited-occurrences-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/inherited-occurrences-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/inherited-occurrences-light-768-visual-linux.png index aaafb3e..9cea0ec 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/inherited-occurrences-light-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/inherited-occurrences-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 666b437..d82a7c0 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-dark-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/local-recovery-dark-visual-linux.png index 4e60793..67a0bcb 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/local-recovery-dark-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/local-recovery-dark-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 0f55225..f2f7044 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 4dcdbfc..c10bd3f 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 59f6ec5..f6ef6f5 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 ab5a2d2..73874b5 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 6a9e26a..a2e528b 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 6635952..19a8400 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 723b1f6..2b74991 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 35e7dfb..94c835c 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 2014210..42deab9 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 9cd8ed5..fc99006 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 2b3e7aa..b547032 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 0d23e08..385d2d5 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/rover-lifecycle-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-dark-1440-visual-linux.png index e51568b..5081f95 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-dark-1440-visual-linux.png 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 index aeea1db..ffec95a 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-dark-768-visual-linux.png 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 index 1af55d0..f4b2557 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-light-1440-visual-linux.png 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 index 1c1dda7..2663b81 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-light-768-visual-linux.png 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 index 666768b..a0ea7bf 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-mission-dark-1440-visual-linux.png 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 index 20de124..665e201 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-mission-dark-768-visual-linux.png 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 index 97a6e70..f3e1bc5 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-mission-light-1440-visual-linux.png 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 index c1e7203..912f81a 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-mission-light-768-visual-linux.png 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 index 9a4add0..e171b93 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-requirements-dark-1440-visual-linux.png 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 index 021fbbd..5f41289 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-requirements-dark-768-visual-linux.png 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 index 56b6409..46e4dfd 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-requirements-light-1440-visual-linux.png 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 index 56b9ef6..9574d7e 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-requirements-light-768-visual-linux.png 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 db8c584..122ae44 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 cac0ac8..6d704c6 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 ebaf0db..e657779 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 8079a5d..c86e2c7 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-values-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/source-values-dark-1440-visual-linux.png new file mode 100644 index 0000000..5089e21 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/source-values-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/source-values-dark-800-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/source-values-dark-800-visual-linux.png new file mode 100644 index 0000000..0b9ca7f Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/source-values-dark-800-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/source-values-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/source-values-light-1440-visual-linux.png new file mode 100644 index 0000000..01a03a3 Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/source-values-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/source-values-light-800-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/source-values-light-800-visual-linux.png new file mode 100644 index 0000000..f84792f Binary files /dev/null and b/tests/e2e/visual.spec.ts-snapshots/source-values-light-800-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 725bcc1..f5aea32 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 0f3cde7..979081e 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 228ea30..db4e846 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/fixtures/source-values.sysml b/tests/fixtures/source-values.sysml new file mode 100644 index 0000000..1885b83 --- /dev/null +++ b/tests/fixtures/source-values.sysml @@ -0,0 +1,40 @@ +package Reading { + private import ScalarValues::*; + part def Configuration { + attribute capacity : Real default = 12.5; + attribute evidence : String default = "records/default.json"; + } + part small : Configuration { + :>> capacity = 24.0; + :>> evidence = "records/α-evidence.json"; + } + part large : Configuration { + :>> capacity = 48.0; + :>> evidence = "records/large-evidence.json"; + } + part def Sensor { attribute gain : Real default = 12.50; } + part def Pair { part left : Sensor; part right : Sensor; } + part alpha : Pair { part :>> left { attribute :>> gain = 101; } } + part beta : Pair { part :>> left { attribute :>> gain = 202; } } + part valueless : Sensor { attribute :>> gain; } + part bound : Sensor; + attribute replacement : Real = 99; + bind bound.gain = replacement; +} +package ReadingViews { + view configurations : StandardViewDefinitions::GeneralView { + expose Reading::small; + expose Reading::small::capacity; + expose Reading::small::evidence; + expose Reading::large; + expose Reading::large::capacity; + expose Reading::large::evidence; + } + + view nested : StandardViewDefinitions::InterconnectionView { + expose Reading::alpha; + expose Reading::beta; + expose Reading::valueless; + expose Reading::bound; + } +}