diff --git a/.reviewmark.yaml b/.reviewmark.yaml index daffa53..7b55e23 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -358,6 +358,67 @@ reviews: - src/**/AppShellSubsystem/ViewBuilderDialogView.axaml - src/**/AppShellSubsystem/ViewBuilderDialogView.axaml.cs - test/**/AppShellSubsystem/ViewBuilderDialogViewModelTests.cs + - id: SysML2Workbench-AppShellSubsystem-QueryDialog + title: Review that SysML2Workbench AppShellSubsystem QueryDialog Implementation + is Correct + context: + - docs/design/sysml2-workbench.md + - docs/reqstream/sysml2-workbench.yaml + - docs/design/sysml2-workbench/app-shell-subsystem.md + - docs/reqstream/sysml2-workbench/app-shell-subsystem.yaml + paths: + - docs/reqstream/sysml2-workbench/app-shell-subsystem/query-dialog.yaml + - docs/design/sysml2-workbench/app-shell-subsystem/query-dialog.md + - docs/verification/sysml2-workbench/app-shell-subsystem/query-dialog.md + - src/**/AppShellSubsystem/QueryDialogViewModel.cs + - src/**/AppShellSubsystem/QueryDialogView.axaml + - src/**/AppShellSubsystem/QueryDialogView.axaml.cs + - test/**/AppShellSubsystem/QueryDialogViewModelTests.cs + - id: SysML2Workbench-ElementPickerSubsystem + title: Review that SysML2Workbench ElementPickerSubsystem Implementation is Correct + context: + - docs/design/sysml2-workbench.md + - docs/reqstream/sysml2-workbench.yaml + paths: + - docs/reqstream/sysml2-workbench/element-picker-subsystem.yaml + - docs/design/sysml2-workbench/element-picker-subsystem.md + - docs/verification/sysml2-workbench/element-picker-subsystem.md + - src/**/ElementPickerSubsystem/** + - test/**/ElementPickerSubsystem/** + - id: SysML2Workbench-ElementPickerSubsystem-ElementPicker + title: Review that SysML2Workbench ElementPickerSubsystem ElementPicker Implementation + is Correct + context: + - docs/design/sysml2-workbench.md + - docs/reqstream/sysml2-workbench.yaml + - docs/design/sysml2-workbench/element-picker-subsystem.md + - docs/reqstream/sysml2-workbench/element-picker-subsystem.yaml + paths: + - docs/reqstream/sysml2-workbench/element-picker-subsystem/element-picker.yaml + - docs/design/sysml2-workbench/element-picker-subsystem/element-picker.md + - docs/verification/sysml2-workbench/element-picker-subsystem/element-picker.md + - src/**/ElementPickerSubsystem/ElementPickerViewModel.cs + - src/**/ElementPickerSubsystem/ElementPickerView.axaml + - src/**/ElementPickerSubsystem/ElementPickerView.axaml.cs + - src/**/ElementPickerSubsystem/ElementTypeLabeler.cs + - test/**/ElementPickerSubsystem/ElementPickerViewModelTests.cs + - test/**/ElementPickerSubsystem/ElementTypeLabelerTests.cs + - id: SysML2Workbench-ElementPickerSubsystem-ElementFilter + title: Review that SysML2Workbench ElementPickerSubsystem ElementFilter Implementation + is Correct + context: + - docs/design/sysml2-workbench.md + - docs/reqstream/sysml2-workbench.yaml + - docs/design/sysml2-workbench/element-picker-subsystem.md + - docs/reqstream/sysml2-workbench/element-picker-subsystem.yaml + paths: + - docs/reqstream/sysml2-workbench/element-picker-subsystem/element-filter.yaml + - docs/design/sysml2-workbench/element-picker-subsystem/element-filter.md + - docs/verification/sysml2-workbench/element-picker-subsystem/element-filter.md + - src/**/ElementPickerSubsystem/ElementFilterViewModel.cs + - src/**/ElementPickerSubsystem/ElementFilterView.axaml + - src/**/ElementPickerSubsystem/ElementFilterView.axaml.cs + - test/**/ElementPickerSubsystem/ElementFilterViewModelTests.cs - id: OTS-DemaConsultingSysML2Tools title: Review that DemaConsulting.SysML2Tools Provides Required Functionality paths: diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 673e827..0d82f6e 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -5,7 +5,7 @@ for SysML v2 textual models. It is built as a thin shell over the `DemaConsulting.SysML2Tools` OTS libraries (parser, semantic model, layout engine) and the `DemaConsulting.Rendering` OTS library, all consumed as published NuGet packages. Local design coverage is organized as one system, -seven subsystems (no nesting), and eleven units. There are no Shared Packages +eight subsystems (no nesting), and fifteen units. There are no Shared Packages in this repository; OTS items are documented under the parallel `ots/` folders. @@ -70,8 +70,15 @@ thin bootstrap/entry-point project with no independent design of its own. - **DiagnosticsListView** (Unit) — structured for future click-to-navigate - **LoggingSubsystem** - **RollingFileLogger** (Unit) — local rolling log file for user-attachable bug reports + - **ElementPickerSubsystem** — reusable dialog-agnostic element-picker control + - **ElementFilter** (Unit) — chip/search filter-only view model + view, composed by ElementPicker for selection-bearing callers + - **ElementPicker** (Unit) — chip/search/list view model + view for a caller-built candidate list - **AppShellSubsystem** — window, navigation, tabbed views - **MainWindowShell** (Unit) + - **WorkspacePanel** (Unit) — workspace tree panel view model + - **AboutDialog** (Unit) — modal about dialog + - **ViewBuilderDialog** (Unit) — modal custom-view builder dialog + - **QueryDialog** (Unit) — modal query dialog (Browse + Element Query tabs) - Desktop (platform head / entry point project; not a separately documented unit) ## Folder Layout diff --git a/docs/design/sysml2-workbench/app-shell-subsystem.md b/docs/design/sysml2-workbench/app-shell-subsystem.md index 974a29d..4eeee89 100644 --- a/docs/design/sysml2-workbench/app-shell-subsystem.md +++ b/docs/design/sysml2-workbench/app-shell-subsystem.md @@ -7,7 +7,8 @@ AppShellSubsystem is the composition root for the desktop application. It owns the main window, navigation flow, and tabbed viewing experience that tie the other subsystems into one coherent user workflow. Its documented units are -MainWindowShell, WorkspacePanel, AboutDialog, and ViewBuilderDialog. The thin +MainWindowShell, WorkspacePanel, AboutDialog, ViewBuilderDialog, and +QueryDialog. The thin desktop platform head remains outside the documented unit set because it is only a bootstrap and lifetime host. diff --git a/docs/design/sysml2-workbench/app-shell-subsystem/query-dialog.md b/docs/design/sysml2-workbench/app-shell-subsystem/query-dialog.md new file mode 100644 index 0000000..86dec54 --- /dev/null +++ b/docs/design/sysml2-workbench/app-shell-subsystem/query-dialog.md @@ -0,0 +1,207 @@ +### QueryDialog + +![AppShellSubsystem Structure](AppShellSubsystemView.svg) + +#### Purpose + +QueryDialog is the modal dialog opened from the main window's Query menu +("_Run Query...") that lets the user browse or query the currently-loaded +workspace through a single, always-visible adaptive form — there is no +`TabControl` and no explicit "Run" gesture anywhere in this design: + +- A "Query Type" combo box offers eleven choices: a merged **List** entry + first (a purely-client-side filter over every workspace declaration, + respecting the global "Include standard library" checkbox), followed by + the ten element-scoped `QueryVerb` operations (`Describe`, `Uses`, + `UsedBy`, `Dependencies`, `Impact`, `Hierarchy`, `Requirements`, + `Interface`, `Connections`, `States`) dispatched through + `DemaConsulting.SysML2Tools.Query.QueryEngine.Execute`. The user never + sees `QueryVerb.Find`: "List" always resolves to the client-side filter, + never to a `QueryEngine.List`/`Find` call. +- Below the combo, one of two controls is shown: for **List**, a + selection-free filter control (chip row + search box only); for every + other Query Type, the full element picker (the same chip row + search + box, plus a selectable candidate list used as the target-element + selector). +- Verb-specific extra controls appear only when relevant: a "Direction" + combo for `Hierarchy`, and a "Walk depth" text box for `Impact`. +- Every interaction — Query Type selection, chip add/remove, search text, + element selection, Direction change, Walk depth text change, or the + Include-standard-library toggle — immediately recomputes and displays the + result synchronously; there is no "Run Query" button. + +Results feed the same shared results panel: a summary bullet list plus a +`Grid`-based (not `DataGrid`) entries table with columns for Qualified Name, +Kind, Detail, and a Direction column shown only when the current result is a +`dependencies` verb result. There is no toolbar: "Copy as Markdown" and +"Copy as JSON" are right-click `ContextMenu` items on the results panel, +wired to `QueryResultRenderer.RenderMarkdown`/`RenderJson` via the same +`AvaloniaClipboardService` pattern `DiagramDocumentView` uses for its "Copy +as SysML" action, and enabled through a `HasCurrentResult` binding mirroring +`DiagramDocumentViewModel.CanCopyAsSysml`. + +It is documented as one unit covering both `QueryDialogViewModel` (the +Avalonia-free composition and dispatch state) and `QueryDialogView` (the +Avalonia `Window` shown via `ShowDialog`), matching the pairing convention +used by the sibling `ViewBuilderDialog` unit. Like the other dialogs in this +subsystem, `QueryDialog` is not a Dock tool: it is a short-lived, owned +dialog `Window`, constructed fresh every time the user opens it. + +#### Data Model + +**Shell**: `MainWindowShell` — the shared application shell, used to resolve +candidate elements (`CurrentWorkspace.Workspace.Declarations`) and to run +queries against (`CurrentWorkspace.Workspace` handed to +`QueryEngine.Execute`). + +**FilterOnly**: an `ElementPickerSubsystem.ElementFilterViewModel` instance +used exclusively when `SelectedQueryType` is `List`: its `DisplayedItems` is +the client-side result source. Has no selection concept, matching "List"'s +lack of a target-element concept. + +**Picker**: an `ElementPickerSubsystem.ElementPickerViewModel` instance used +for the ten element-scoped Query Types (every `QueryVerb` other than the +merged `List` entry): its `SelectedQualifiedName` is the target element +(its `DisplayedItems` remains visible purely as a filter aid narrowing which +candidates are selectable). + +**QueryTypes**: `IReadOnlyList` — the eleven Query Type options +the combo box binds to, `List` first, never containing `Find`. + +**IncludeStdlib**: `bool` — the global "Include standard library" checkbox +state, threaded through both `RefreshFromWorkspace` (as a candidate-filter +choice) and `BuildOptions` (as the `QueryOptions.IncludeStdlib` value for +every engine call). + +**IsWorkspaceEmpty**: `bool` — `true` when the shell's current workspace has +zero sources, used to show the dialog's own empty-state hint alongside the +checkbox. + +**SelectedQueryType**: `QueryVerb` — the currently-selected Query Type; +defaults to `List` (the always-available, no-selection-required option). + +**HierarchyDirection**: `string?` — the direction option accepted by the +Hierarchy verb (`"up"`, `"down"`, or `"both"`); defaults to `"both"`. Only +attached to `QueryOptions.Direction` when `SelectedQueryType` is `Hierarchy`. + +**WalkDepthText**: `string?` — the free-text input for the Impact verb's +optional walk-depth bound. Only parsed for `Impact`, and only when it parses +cleanly as a non-negative integer. + +**CurrentResult**: `QueryResult?` — the most recently produced `QueryResult` +(either List's client-built list result or the engine's response), or +`null` when an element-scoped Query Type has no selection yet. Feeds the +shared results panel. + +**CurrentResultRows**: `IReadOnlyList` — a flattened, +view-friendly projection of `CurrentResult.Entries` (empty strings replacing +nullable fields, `Direction` mapped to a human-readable label, notes joined +into a tooltip string), bound directly by the results-panel `ItemsControl`. + +**StatusMessage**: `string?` — user-visible error/hint text set on every +recoverable failure or prompt (no workspace, no selection yet, engine +argument rejection). Cleared on a successful recompute. + +**HasCurrentResult**: `bool` — computed mirror of `CurrentResult is not +null`, bound by the results panel's right-click context-menu items' +`IsEnabled` so the copy actions are only enabled when there is something to +copy. + +**ClipboardService**: `IClipboardService?` — the same clipboard-write seam +`DiagramDocumentViewModel` uses. Assigned by `QueryDialogView` to an +`AvaloniaClipboardService` anchored on the dialog's own `Window`. + +#### Key Methods + +**RefreshFromWorkspace**: Refreshes both `FilterOnly`'s and `Picker`'s +candidate lists (from the same candidate set) and `IsWorkspaceEmpty` from +the shell's current workspace, then recomputes the result. + +- *Parameters*: none. +- *Returns*: `void` — both `FilterOnly.SetCandidates` and `Picker.SetCandidates` + are called with the full candidate list (no default chip for either), and + `RecomputeResult` runs immediately afterward. +- *Postconditions*: Called once at construction and again on every + `IncludeStdlib` toggle; the dialog does not itself subscribe to + `MainWindowShell.SourcesChanged`, matching the sibling dialogs' pattern. + Because `ElementPickerViewModel.SetCandidates` unconditionally clears any + prior `SelectedQualifiedName`, a stale selection can never linger past a + workspace-derived refresh. + +**RecomputeResult**: Recomputes `CurrentResult`/`CurrentResultRows` for the +currently selected `SelectedQueryType`. This is the redesign's entire +"no explicit Run gesture" mechanism, invoked automatically by every relevant +property change (Query Type, Hierarchy direction, Walk depth text, and — via +`FilterOnly`'s and `Picker`'s `PropertyChanged` subscriptions — +`FilterOnly.DisplayedItems`/`Picker.DisplayedItems`/`Picker.SelectedQualifiedName`). + +- *Parameters*: none. +- *Returns*: `void` — `CurrentResult`, `CurrentResultRows`, and + `StatusMessage` update in place. +- *Postconditions*: For `List`, delegates to `BuildListResult`. For every + other Query Type: with no workspace, reports the existing empty-workspace + message; with no `Picker.SelectedQualifiedName`, reports a helpful + (non-error) prompt naming the Query Type and clears any stale prior + result; otherwise dispatches through `QueryEngine.Execute`, gracefully + catching `ArgumentException`. Never throws. + +**BuildListResult**: Rebuilds `CurrentResult` and `CurrentResultRows` from +`FilterOnly`'s `DisplayedItems`. + +- *Parameters*: none. +- *Returns*: `void` — `CurrentResult` becomes a `QueryResult` with + `Verb="list"`, `Element=null`, one entry per displayed item. +- *Postconditions*: "List" is deliberately a purely-client-side filter; it + does NOT call `QueryEngine.List`/`Find`. Invoked by `RecomputeResult` + whenever `SelectedQueryType` is `List`. + +**BuildOptions**: Builds a `QueryOptions` instance reflecting the current +form's verb-specific state. + +- *Parameters*: `string qualifiedName` — the resolved target's qualified name. +- *Returns*: `QueryOptions` — always carries `IncludeStdlib`; attaches + `Direction` only for `Hierarchy`; parses `WalkDepth` only for `Impact` and + only when the text parses cleanly. + +**CopyResultAsMarkdownAsync** / **CopyResultAsJsonAsync**: Copy +`CurrentResult` to the clipboard through `QueryResultRenderer` and +`ClipboardService`. + +- *Parameters*: none. +- *Returns*: `Task` — completes once the clipboard write has finished. +- *Postconditions*: A safe no-op (rather than an exception) when + `CurrentResult` or `ClipboardService` is `null`. + +#### Error Handling + +QueryDialog treats every recoverable failure or informational prompt - no +workspace, no selection yet for an element-scoped Query Type, a picker +qualified name that vanished under a mid-session workspace change, or an +engine `ArgumentException` (thrown when the verb requires an element and +none is resolvable, or when the verb value itself is out of range) - as a +locally recoverable condition surfaced through `StatusMessage` rather than +an unhandled exception. Unlike the prior "leave the last result visible" +behavior, every one of these paths also clears `CurrentResult`/ +`CurrentResultRows` so the results panel never shows a stale result left +over from a previous Query Type or selection. + +#### Dependencies + +- **MainWindowShell** — supplies `CurrentWorkspace` for candidate resolution + and for the workspace argument passed to `QueryEngine.Execute`. +- **ElementPickerSubsystem** — `ElementFilter` via `FilterOnly` (used for + "List"); `ElementPicker` via `Picker` (used for the other ten + element-scoped Query Types). +- **DemaConsulting.SysML2Tools.Query** — `QueryEngine.Execute` runs the + verb, `QueryResultRenderer.RenderMarkdown`/`RenderJson` produces the + clipboard text. +- **IClipboardService** / **AvaloniaClipboardService** — the clipboard-write + seam shared with `DiagramDocumentViewModel`. +- **Avalonia** — `QueryDialogView` is a `Window` shown modally via + `ShowDialog`, mirroring `AboutDialogView` and `ViewBuilderDialogView`. + +#### Callers + +- **MainWindowView** — constructs `QueryDialogView` and calls + `ShowDialog(this)` when the user selects the Query menu's "Run Query..." + item, so the dialog is owned by and centers over the main window. diff --git a/docs/design/sysml2-workbench/app-shell-subsystem/view-builder-dialog.md b/docs/design/sysml2-workbench/app-shell-subsystem/view-builder-dialog.md index 524f639..2477a24 100644 --- a/docs/design/sysml2-workbench/app-shell-subsystem/view-builder-dialog.md +++ b/docs/design/sysml2-workbench/app-shell-subsystem/view-builder-dialog.md @@ -33,50 +33,23 @@ own `BuilderDefinition`. whatever SVG `RenderPreview` most recently produced. Never shared with, nor derived from, any tab tracked by `Shell`. -**AvailableExposeTargets**: `IReadOnlyList` — qualified names offered -by the "Expose Targets" tab's add-target picker, drawn from -`Shell.CurrentWorkspace.Workspace.Declarations`, excluding stdlib names and -node kinds that would fail `ViewDefinitionModel` validation (views, -viewpoints, imports, metadata, transitions, connections). Remains the -master, unfiltered, sorted list; `DisplayedExposeTargets` (below) is what the -picker `ListBox` actually binds to. - -**AvailableExposeTargetTypeLabels**: `IReadOnlyList` — every distinct -human-readable "type label" present among the current `AvailableExposeTargets` -candidates, sorted ordinally. Each candidate's label is computed from its -underlying `SysmlNode` subtype: a `SysmlDefinitionNode` yields its -`DefinitionKeyword` (e.g. `"part def"`), a `SysmlFeatureNode` yields its -`FeatureKeyword` (e.g. `"part"`), and `SysmlPackageNode`/`SysmlDependencyNode`/ -`SysmlSatisfyNode` yield the fixed literals `"package"`/`"dependency"`/ -`"satisfy"` respectively (none of these three expose a keyword-like property -of their own). Any other node kind not already excluded from the picker falls -back to a defensively derived label (its runtime type name with a leading -`Sysml` and/or trailing `Node` stripped, lowercased), so an unrecognized -future node kind is labeled reasonably rather than crashing or silently -vanishing from the picker. - -**ActiveExposeTypeFilters**: `ObservableCollection` — the type-label -chips currently applied over the picker, combined with OR semantics (an item -is shown when its type label is any one of these); empty means no type -restriction (every candidate's type is shown). `RefreshFromWorkspace` -pre-populates this with just `"part"` when that label is present in the -current workspace, since narrowing to part usages is the most common -starting point; otherwise it starts empty. Mutated only through -`AddExposeTypeFilter`/`RemoveExposeTypeFilter` (or, defensively, any other -direct mutation, which is also observed) rather than replaced wholesale, so -the view's chip-row `ItemsControl` can bind to this instance directly. - -**ExposeTargetSearchText**: `string?` — the picker's free-text search box -value, two-way bound from the view. A non-empty value narrows -`DisplayedExposeTargets` to qualified names containing it as a -case-insensitive substring, applied with AND semantics against whatever the -active type filter already narrowed to. - -**DisplayedExposeTargets**: `IReadOnlyList` — the picker `ListBox`'s -actual data source: `AvailableExposeTargets`, narrowed first by -`ActiveExposeTypeFilters` (OR semantics) and then by `ExposeTargetSearchText` -(AND semantics), preserving the master list's ordinal sort order. Recomputed -whenever any of the three inputs changes. +**ExposeTargetPicker**: `ElementPickerSubsystem.ElementPickerViewModel` — the +"Expose Targets" tab's add-target picker, composed from the shared +`ElementPickerSubsystem`. `RefreshFromWorkspace` builds the candidate list +from `Shell.CurrentWorkspace.Workspace.Declarations`, excluding stdlib names +and node kinds that would fail `ViewDefinitionModel` validation (views, +viewpoints, imports, metadata, transitions, connections), maps each +candidate to a human-readable type label via +`ElementPickerSubsystem.ElementTypeLabeler`, and hands them to the picker +through `SetCandidates(..., defaultTypeFilterLabel: "part")` so the picker +starts filtered to `"part"` when that label is present in the workspace +and otherwise starts unfiltered. + +All of the picker's own filtering, chip-row, and selection state +(`AvailableTypeLabels`, `ActiveTypeFilters`, `SearchText`, `DisplayedItems`, +`SelectedQualifiedName`) is documented under the shared +`ElementPickerSubsystem.ElementPicker` unit; the "Add ->" button reads +`ExposeTargetPicker.SelectedQualifiedName` when the user clicks it. **StatusMessage**: `string?` — set when `RenderPreview` or `TryCommit` fails, so the view can surface the failure inline without a modal message box. @@ -87,27 +60,17 @@ unusable, target-less picker. #### Key Methods -**RefreshFromWorkspace**: Refreshes `AvailableExposeTargets`, -`AvailableExposeTargetTypeLabels`, `ActiveExposeTypeFilters`, -`DisplayedExposeTargets`, and `IsWorkspaceEmpty` from the shell's current -workspace state. +**RefreshFromWorkspace**: Refreshes `ExposeTargetPicker`'s candidate list and +`IsWorkspaceEmpty` from the shell's current workspace state. - *Parameters*: none. -- *Returns*: `void` — all listed properties update in place. +- *Returns*: `void` — `ExposeTargetPicker.SetCandidates` is called with the + filtered candidate list, and `IsWorkspaceEmpty` updates in place. - *Postconditions*: Called once at construction; the dialog is short-lived (opened, edited, closed) so it does not itself subscribe to `MainWindowShell.SourcesChanged` the way the old long-lived panel view - model did. Re-applies the `"part"`-default chip rule fresh each call. - -**AddExposeTypeFilter** / **RemoveExposeTypeFilter**: Add or remove a type -label from `ActiveExposeTypeFilters` and recompute `DisplayedExposeTargets`. - -- *Parameters*: the type label to add or remove. -- *Returns*: `void` — `ActiveExposeTypeFilters` and `DisplayedExposeTargets` - update in place. -- *Postconditions*: `AddExposeTypeFilter` is dedupe-safe (adding an - already-active label is a no-op beyond the recompute); `RemoveExposeTypeFilter` - no-ops when the label is not currently active, rather than throwing. + model did. Re-applies the `"part"`-default chip rule fresh each call + through the `defaultTypeFilterLabel` argument. **AddExposeTarget** / **RemoveExposeTarget** / **SetExposeRecursionKind** / **SetExposeBracketFilter**: Port the deleted `CustomViewBuilderToolViewModel`'s diff --git a/docs/design/sysml2-workbench/element-picker-subsystem.md b/docs/design/sysml2-workbench/element-picker-subsystem.md new file mode 100644 index 0000000..a720acb --- /dev/null +++ b/docs/design/sysml2-workbench/element-picker-subsystem.md @@ -0,0 +1,83 @@ +## ElementPickerSubsystem + +![ElementPickerSubsystem Structure](ElementPickerSubsystemView.svg) + +### Overview + +ElementPickerSubsystem owns two related units that more than one modal +dialog in this application needs to reuse: `ElementFilter`, a selection-free +chip-row (OR semantics) + case-insensitive substring search (AND-combined +with the chip filter) over a caller-supplied candidate list, and +`ElementPicker`, which composes an `ElementFilter` instance and adds a +single-select candidate list on top. Their shared boundary starts at the +caller-supplied candidate list (qualified name + type label pairs) and ends +at the caller's observation of `DisplayedItems` (and, for `ElementPicker`, +`SelectedQualifiedName`) - neither unit knows nor cares which workspace, +node kind, or exclusion set produced the candidates. + +The subsystem exists because the Custom View Builder dialog's expose-target +picker and the Query dialog's element-scoped Query Types all need the exact +same OR-then-AND filtering behavior with a selection on top, while the Query +dialog's "List" Query Type needs that same filtering behavior with NO +selection concept at all (a purely client-side filter with no target +element). Splitting the filtering logic (`ElementFilter`) from the +selection-bearing composition (`ElementPicker`) lets both kinds of caller +reuse the identical chip/search behavior without either duplicating it or +presenting a selectable list whose selection would be silently ignored. + +### Interfaces + +**Candidate Feed API**: In-process interface accepting the caller-built list +of candidate qualified names and their type labels. + +- *Type*: In-process .NET API. +- *Role*: Provider. +- *Contract*: Accepts a candidate list plus an optional default type-filter + label; recomputes the addable-labels set, the active-chip pre-population, + and the displayed list. Exposed identically by `ElementFilterViewModel` + and (as a pass-through) by `ElementPickerViewModel`. +- *Constraints*: Order-preserving (neither unit re-sorts what the caller + handed it); dedupe-safe on type labels; `ElementPickerViewModel` must + additionally clear any prior selection so a stale qualified name cannot + linger after a refresh (`ElementFilterViewModel` has no selection to + clear). + +**Selection Observation API**: The observable properties (displayed list, +active chips, search text, and - for `ElementPicker` only - selected +qualified name). + +- *Type*: `INotifyPropertyChanged` (via CommunityToolkit.Mvvm's + `ObservableObject`) plus one `ObservableCollection` chip row. +- *Role*: Provider. +- *Contract*: Every filter or search-text edit updates `DisplayedItems` + live; the caller can bind directly (as the Query dialog's "List" Query + Type does against `ElementFilter`) to regenerate a downstream result + panel on every change. +- *Constraints*: Not thread-safe: all state must be mutated from a single + (typically UI) thread. + +### Design + +1. Callers (the Custom View Builder dialog and the Query dialog) build the + candidate list themselves, applying caller-owned exclusions (stdlib names, + disallowed node kinds) and mapping each `SysmlNode` through the shared + `ElementTypeLabeler` helper so every dialog produces the same label for + the same node kind. +2. Callers hand the list to `SetCandidates` on whichever unit they use + directly (`ElementFilterViewModel` for a filter-only caller, or + `ElementPickerViewModel` for a selection-bearing caller, which forwards + to its composed `Filter`), which recomputes `AvailableTypeLabels`, resets + the active chip row (to the caller-requested default label when present, + otherwise empty), and recomputes `DisplayedItems` (`ElementPickerViewModel` + additionally clears any prior selection). +3. The filter view (`ElementFilterView`, an Avalonia `UserControl`) hosts a + chip row (`ItemsControl` + `WrapPanel` + per-chip remove button), a "+" + add-filter `Button.Flyout` populated on demand from + `GetAddableTypeLabels`, and a search `TextBox` two-way-bound to + `SearchText`. The picker view (`ElementPickerView`) embeds an + `ElementFilterView` bound to its `Filter` property for that exact markup, + then adds its own candidate `ListBox` two-way-bound to + `SelectedQualifiedName`. +4. The `ElementTypeLabeler` helper is a plain static class - a pure data + lookup with no external dependencies - because the mapping is identical + for every caller and there is no reason to allow it to differ. diff --git a/docs/design/sysml2-workbench/element-picker-subsystem/element-filter.md b/docs/design/sysml2-workbench/element-picker-subsystem/element-filter.md new file mode 100644 index 0000000..5b04b65 --- /dev/null +++ b/docs/design/sysml2-workbench/element-picker-subsystem/element-filter.md @@ -0,0 +1,116 @@ +### ElementFilter + +![ElementPickerSubsystem Structure](ElementPickerSubsystemView.svg) + +#### Purpose + +ElementFilter is the shared, reusable Avalonia control and view model that +lets any caller present a filter-only view of a candidate list of workspace +elements: a chip-row of type-label filters (OR semantics) and a +case-insensitive substring search over qualified names (AND-combined with +the chip filter). It has NO selection concept whatsoever. It is documented +as one unit covering both `ElementFilterViewModel` (the Avalonia-free +filtering state and logic) and `ElementFilterView` (the Avalonia +`UserControl` hosting the chip row / add-flyout / search box), matching the +pairing convention used elsewhere in this codebase. + +`ElementPickerViewModel` composes one `ElementFilterViewModel` instance +(`Filter`) to reuse this exact filtering logic while adding its own +`SelectedQualifiedName` selection concept on top - see +`element-picker.md`. Callers that have no target-element concept at all - +such as the Query dialog's "List" Query Type, which is a purely +client-side filter over the workspace with no notion of a "selected" +element - use a standalone `ElementFilterViewModel`/`ElementFilterView` +instance directly instead, avoiding a selectable candidate list whose +selection would otherwise be silently ignored. + +#### Data Model + +**Candidates**: `IReadOnlyList<(string QualifiedName, string TypeLabel)>` — +the master unfiltered list, replaced whole by every `SetCandidates` call. +Held privately; callers observe only the derived properties below. + +**AvailableTypeLabels**: `IReadOnlyList` — every distinct human-readable +"type label" present among the current candidates, sorted ordinally. Used to +populate the "+" add-filter flyout's list of addable labels. + +**ActiveTypeFilters**: `ObservableCollection` — the type-label chips +currently applied over the filter, combined with OR semantics; empty means +no type restriction (every candidate's type is shown). Pre-populated by +`SetCandidates` to the caller-requested `defaultTypeFilterLabel` when +present in the new candidates, otherwise empty. Mutated only through +`AddTypeFilter`/`RemoveTypeFilter` (or, defensively, any other direct +mutation, which is also observed by the internal collection-changed handler +that re-runs the filter recompute), so a chip-row `ItemsControl` can bind +directly. + +**SearchText**: `string?` — the filter's free-text search box value, two-way +bound from the view. A non-empty value narrows `DisplayedItems` to +qualified names containing it as a case-insensitive substring, applied with +AND semantics against whatever the active type filter already narrowed to. + +**DisplayedItems**: `IReadOnlyList` — the filtered, view-facing +result: candidates narrowed first by `ActiveTypeFilters` (OR semantics) and +then by `SearchText` (AND semantics), preserving the master list's order. +Recomputed whenever any of the three inputs changes. + +#### Key Methods + +**SetCandidates**: Replaces the master candidate list and recomputes +derived state. Unlike `ElementPickerViewModel.SetCandidates`, there is no +selection to clear - this view model has no selection concept. + +- *Parameters*: `IReadOnlyList<(string QualifiedName, string TypeLabel)> + candidates` — the caller-built candidate set (order preserved verbatim); + `string? defaultTypeFilterLabel` — optional label to pre-populate + `ActiveTypeFilters` with when present in the new candidates. +- *Returns*: `void` — `AvailableTypeLabels`, `ActiveTypeFilters`, and + `DisplayedItems` all update in place. +- *Preconditions*: `candidates` is not `null` (throws + `ArgumentNullException` on `null`). +- *Postconditions*: `AvailableTypeLabels` is distinct, ordinal-sorted; + `ActiveTypeFilters` contains at most the requested default label. + +**AddTypeFilter** / **RemoveTypeFilter**: Add or remove a type label from +`ActiveTypeFilters` and recompute `DisplayedItems`. + +- *Parameters*: the type label to add or remove. +- *Returns*: `void` — `ActiveTypeFilters` and `DisplayedItems` update in + place. +- *Postconditions*: `AddTypeFilter` is dedupe-safe (adding an already-active + label is a no-op beyond the recompute); `RemoveTypeFilter` no-ops when the + label is not currently active, rather than throwing. + +**GetAddableTypeLabels**: Returns the labels in `AvailableTypeLabels` that +are not currently in `ActiveTypeFilters`. + +- *Parameters*: none. +- *Returns*: `IReadOnlyList` — computed on demand rather than cached, + so the "+" add-filter flyout always reflects the current addable set the + moment it opens. + +#### Error Handling + +The filter never itself talks to a workspace or file system, so it has no +"external I/O failure" surface to handle. Its only guarded input is the +`candidates` argument to `SetCandidates`, which is validated with a null +guard because a `null` candidate list is a programming error the caller must +fix rather than a runtime failure to recover from. + +#### Dependencies + +- **CommunityToolkit.Mvvm** — `ObservableObject` and `[ObservableProperty]` + source generators back the derived state and change notification. +- **Avalonia** — `ElementFilterView` is a `UserControl` hosted directly by + callers or embedded inside `ElementPickerView`; nothing else in the unit + depends on Avalonia. + +#### Callers + +- **ElementPicker** (Unit) — composes one `ElementFilterViewModel` instance + (`Filter`) to reuse the filtering logic for every selection-bearing + caller (Custom View Builder, Query dialog's element-scoped Query Types). +- **QueryDialog** — uses one standalone `ElementFilterViewModel` instance + (`FilterOnly`) directly for its "List" Query Type, which has no + target-element concept and would otherwise present a selectable list + whose selection is silently ignored. diff --git a/docs/design/sysml2-workbench/element-picker-subsystem/element-picker.md b/docs/design/sysml2-workbench/element-picker-subsystem/element-picker.md new file mode 100644 index 0000000..eb8aee2 --- /dev/null +++ b/docs/design/sysml2-workbench/element-picker-subsystem/element-picker.md @@ -0,0 +1,130 @@ +### ElementPicker + +![ElementPickerSubsystem Structure](ElementPickerSubsystemView.svg) + +#### Purpose + +ElementPicker is the shared, reusable Avalonia control and view model that +lets any dialog in this application present a filterable, selectable list of +workspace elements. It composes an +`ElementPickerSubsystem.ElementFilter` instance (`Filter`) to own the +chip-row of type-label filters (OR semantics) and the case-insensitive +substring search over qualified names (AND-combined with the chip filter), +and adds its own single-select `SelectedQualifiedName` concept on top. It is +documented as one unit covering both `ElementPickerViewModel` (the +Avalonia-free composition and selection state) and `ElementPickerView` (the +Avalonia `UserControl` embedding an `ElementFilterView` for the chip +row/add-flyout/search box, plus the candidate list). `ElementTypeLabeler` is +a static helper shipped alongside the unit so every caller derives the same +label from the same `SysmlNode`. Callers with no target-element concept at +all - such as the Query dialog's "List" Query Type - use a standalone +`ElementFilter` instance directly instead of this unit; see +`element-filter.md`. + +#### Data Model + +**Filter**: `ElementFilterViewModel` — the composed filter that actually +owns the master candidate list, chip management, and search-text filtering. +Every filtering-related public member on this class +(`AvailableTypeLabels`, `SearchText`, `DisplayedItems`, `ActiveTypeFilters`, +`GetAddableTypeLabels`, `AddTypeFilter`, `RemoveTypeFilter`) is a thin +pass-through to this instance, so existing callers and bindings see no +change to this class's public API. + +**AvailableTypeLabels**: `IReadOnlyList` — pass-through to +`Filter.AvailableTypeLabels`: every distinct human-readable "type label" +present among the current candidates, sorted ordinally. Used to populate +the "+" add-filter flyout's list of addable labels. + +**ActiveTypeFilters**: `ObservableCollection` — pass-through to +`Filter.ActiveTypeFilters` (the same collection instance): the type-label +chips currently applied over the picker, combined with OR semantics; empty +means no type restriction (every candidate's type is shown). Pre-populated +by `SetCandidates` to the caller-requested `defaultTypeFilterLabel` when +present in the new candidates, otherwise empty. + +**SearchText**: `string?` — pass-through to `Filter.SearchText`: the +picker's free-text search box value, two-way bound from the view. A +non-empty value narrows `DisplayedItems` to qualified names containing it +as a case-insensitive substring, applied with AND semantics against +whatever the active type filter already narrowed to. + +**DisplayedItems**: `IReadOnlyList` — pass-through to +`Filter.DisplayedItems`: the candidate `ListBox`'s actual data source: +candidates narrowed first by `ActiveTypeFilters` (OR semantics) and then by +`SearchText` (AND semantics), preserving the master list's order. + +**SelectedQualifiedName**: `string?` — owned directly by +`ElementPickerViewModel` (not delegated to `Filter`, which has no selection +concept): the currently-selected qualified name in the candidate `ListBox`; +two-way bound. Cleared to `null` by every `SetCandidates` call so a stale +prior selection cannot linger after a workspace-derived refresh. + +#### Key Methods + +**SetCandidates**: Forwards to `Filter.SetCandidates`, then clears any +prior selection. + +- *Parameters*: `IReadOnlyList<(string QualifiedName, string TypeLabel)> + candidates` — the caller-built candidate set (order preserved verbatim); + `string? defaultTypeFilterLabel` — optional label to pre-populate + `ActiveTypeFilters` with when present in the new candidates. +- *Returns*: `void` — `AvailableTypeLabels`, `ActiveTypeFilters`, + `DisplayedItems`, and `SelectedQualifiedName` all update in place. +- *Preconditions*: `candidates` is not `null` (throws + `ArgumentNullException` on `null`, propagated from `Filter.SetCandidates`). +- *Postconditions*: `AvailableTypeLabels` is distinct, ordinal-sorted; + `ActiveTypeFilters` contains at most the requested default label; + `SelectedQualifiedName` is `null`. + +**AddTypeFilter** / **RemoveTypeFilter**: Pass-through to +`Filter.AddTypeFilter`/`Filter.RemoveTypeFilter`. + +- *Parameters*: the type label to add or remove. +- *Returns*: `void` — `ActiveTypeFilters` and `DisplayedItems` update in + place. +- *Postconditions*: `AddTypeFilter` is dedupe-safe (adding an already-active + label is a no-op beyond the recompute); `RemoveTypeFilter` no-ops when the + label is not currently active, rather than throwing. + +**GetAddableTypeLabels**: Pass-through to `Filter.GetAddableTypeLabels`: +returns the labels in `AvailableTypeLabels` that are not currently in +`ActiveTypeFilters`. + +- *Parameters*: none. +- *Returns*: `IReadOnlyList` — computed on demand rather than cached, + so the "+" add-filter flyout always reflects the current addable set the + moment it opens. + +#### Error Handling + +The picker never itself talks to a workspace or file system, so it has no +"external I/O failure" surface to handle. Its only guarded input is the +`candidates` argument to `SetCandidates`, forwarded to `Filter.SetCandidates` +which validates it with a null guard because a `null` candidate list is a +programming error the caller must fix rather than a runtime failure to +recover from. + +#### Dependencies + +- **ElementPickerSubsystem.ElementFilter** — composed as `Filter`; owns all + filtering/chip-management logic and view markup. +- **CommunityToolkit.Mvvm** — `ObservableObject` and `[ObservableProperty]` + source generators back `SelectedQualifiedName` and change notification. +- **Avalonia** — `ElementPickerView` is a `UserControl` hosted by its + callers' dialog `Window`s; nothing else in the subsystem depends on + Avalonia. +- **DemaConsulting.SysML2Tools.Semantic.Model** — consumed by the + `ElementTypeLabeler` static helper only, to compute a stable human-readable + label from any `SysmlNode` subtype. + +#### Callers + +- **ViewBuilderDialog** — hosts one picker instance (`ExposeTargetPicker`) + in its "Expose Targets" tab, pre-populating a `"part"` default chip. +- **QueryDialog** — hosts one picker instance (`Picker`) used for the ten + element-scoped Query Types (every `QueryVerb` other than the merged + `List` entry), with no default chip so every candidate shows immediately. + For its "List" Query Type, `QueryDialog` instead uses a standalone + `ElementFilter` instance (`FilterOnly`) directly, since "List" has no + target-element concept. diff --git a/docs/reqstream/sysml2-workbench/app-shell-subsystem/query-dialog.yaml b/docs/reqstream/sysml2-workbench/app-shell-subsystem/query-dialog.yaml new file mode 100644 index 0000000..130b754 --- /dev/null +++ b/docs/reqstream/sysml2-workbench/app-shell-subsystem/query-dialog.yaml @@ -0,0 +1,109 @@ +--- +sections: + - title: 'SysML2Workbench Requirements' + sections: + - title: 'AppShellSubsystem Requirements' + sections: + - title: 'QueryDialog Requirements' + requirements: + - id: 'SysML2Workbench-AppShellSubsystem-QueryDialog-RefreshPickersFromWorkspace' + title: 'The QueryDialog shall populate its single picker from the shell''s current workspace + declarations on construction, apply the IncludeStdlib checkbox as the candidate-filter rule + (excluding stdlib qualified names when unchecked), report an empty-workspace state when zero + sources are open, and recompute the displayed result immediately after every refresh.' + justification: | + The single form needs an accurate, workspace-derived candidate list every time the dialog is + opened, the stdlib toggle must be honored consistently, and a clear signal is needed when + there is nothing to query yet. + tests: + - 'QueryDialogViewModel_Construction_EmptyShell_ReportsWorkspaceEmpty' + - 'QueryDialogViewModel_Construction_LoadedWorkspace_PopulatesSinglePicker' + - 'QueryDialogViewModel_IncludeStdlibToggle_RefreshesPickerAndRecomputesResult' + + - id: 'SysML2Workbench-AppShellSubsystem-QueryDialog-BrowseTabIsClientSideList' + title: 'The QueryDialog''s merged "List" Query Type shall produce a client-built QueryResult + with Verb "list", no target Element, and one Entry per DisplayedItem (carrying the item''s + qualified name and its type label as Kind), regenerating it live as the picker''s filter + changes; "List" shall never call QueryEngine.List or QueryEngine.Find, and the user shall + never be presented with a separate "Find" option.' + justification: | + "List" is deliberately a pure client-side filter to give an instant response as the user + types, and to avoid pulling the whole workspace through the engine for a trivial name filter. + tests: + - 'QueryDialogViewModel_ListQueryType_BuildsClientSideListResult' + - 'QueryDialogViewModel_ListQueryType_SearchTextEdit_RegeneratesResultLive' + + - id: 'SysML2Workbench-AppShellSubsystem-QueryDialog-ElementQueryDispatchesThroughEngine' + title: 'The QueryDialog''s single Query Type combo box shall offer all ten element-scoped + QueryVerb values in addition to "List"; selecting one of them resolves the single picker''s + selected qualified name against the current workspace, builds a QueryOptions instance + carrying the selected Query Type, the qualified name, IncludeStdlib, and verb-specific inputs + (Direction for Hierarchy, WalkDepth for Impact), and dispatches through QueryEngine.Execute + immediately - with no explicit "Run" gesture anywhere in the dialog. Every subsequent change + to the Query Type, the picker''s selection, the Hierarchy direction, or the Impact walk depth + recomputes the displayed result immediately, without stale data from a previous selection or + Query Type persisting on screen.' + justification: | + The form''s purpose is to give a UI over exactly the same query engine the CLI tooling + exercises, without duplicating its verb dispatch or its option-shape rules, and the redesign''s + defining usability improvement is that every relevant edit updates the results live rather + than requiring a separate "Run" click. + tests: + - 'QueryDialogViewModel_RecomputeResult_DescribeWithSelection_DispatchesThroughEngineImmediately' + - 'QueryDialogViewModel_SwitchingQueryType_FromDescribeToList_ShowsListResultImmediately' + - 'QueryDialogViewModel_SwitchingQueryType_FromListToDescribeNoSelection_ShowsSelectPrompt' + - 'QueryDialogViewModel_HierarchyDirectionChange_WithSelection_RecomputesImmediately' + - 'QueryDialogViewModel_WalkDepthTextChange_WithSelection_RecomputesImmediately' + - 'QueryDialogViewModel_BuildOptions_HierarchyVerb_AttachesDirection' + - 'QueryDialogViewModel_BuildOptions_NonHierarchyVerb_OmitsDirection' + - 'QueryDialogViewModel_BuildOptions_ImpactVerbWithWalkDepth_ParsesWalkDepth' + - 'QueryDialogViewModel_BuildOptions_ImpactVerbWithInvalidWalkDepth_LeavesNull' + - 'QueryDialogViewModel_BuildOptions_PropagatesIncludeStdlib' + - 'QueryDialogViewModel_QueryTypes_HasExpectedElevenEntries' + - 'QueryDialogViewModel_HierarchyDirectionOptions_HasExpectedThree' + + - id: 'SysML2Workbench-AppShellSubsystem-QueryDialog-HandleFailuresGracefully' + title: 'The QueryDialog shall report every recoverable failure or informational prompt (no + workspace, no element selected yet for an element-scoped Query Type, a qualified name that no + longer resolves, or an engine ArgumentException) through a user-visible StatusMessage without + throwing an unhandled exception, and shall clear CurrentResult/CurrentResultRows in every + such case rather than leaving a stale prior result on screen.' + justification: | + A dialog running against a live-editable workspace routinely encounters selection races and + verb/option mismatches; those must degrade to inline hints rather than crashes, and because + every change now recomputes automatically, a stale leftover result from a prior Query Type or + selection would be actively misleading rather than merely unhelpful. + tests: + - 'QueryDialogViewModel_RecomputeResult_ElementScopedVerbNoSelection_ReportsPromptAndClearsRows' + - 'QueryDialogViewModel_RecomputeResult_EmptyWorkspace_ReportsStatusMessage' + + - id: 'SysML2Workbench-AppShellSubsystem-QueryDialog-CopyResultToClipboard' + title: 'The QueryDialog shall render the current QueryResult through + QueryResultRenderer.RenderMarkdown / RenderJson and write it to the clipboard via + IClipboardService when the user selects "Copy as Markdown" or "Copy as JSON" from the results + panel''s right-click context menu (there is no toolbar); the copy methods shall be no-ops + (rather than throws) when no result is currently displayed, and the context-menu items'' + enablement shall reflect whether a result currently exists.' + justification: | + The value of running a query is being able to feed the answer back into a doc, review, or + script; a shared clipboard seam keeps the dialog testable, a graceful no-op keeps the actions + safe when invoked with no result loaded, and moving the actions to a context menu keeps the + results panel free of toolbar clutter. + tests: + - 'QueryDialogViewModel_CopyResultAsMarkdownAsync_WritesRenderedMarkdownToClipboard' + - 'QueryDialogViewModel_CopyResultAsJsonAsync_WritesRenderedJsonToClipboard' + - 'QueryDialogViewModel_CopyMethods_NoResult_AreNoOps' + + - id: 'SysML2Workbench-AppShellSubsystem-QueryDialog-EndToEndFromMenu' + title: 'The Query menu''s "Run Query..." item shall open the QueryDialog against the main + window''s shell so a user can select a Query Type, select an element on the single picker, + see the result immediately with no "Run" gesture, and copy the rendered Markdown to the real + Avalonia clipboard via the results panel''s right-click context menu, without any intervening + manual wiring.' + justification: | + The externally-observable behavior "click the menu, pick a query type and an element, see the + result, right-click to copy it somewhere else" is the whole feature; verifying it once + end-to-end against a headless Avalonia stack catches menu/dialog/clipboard wiring regressions + the pure view-model tests cannot see. + tests: + - 'QueryDialog_SelectDescribeAndCopyAsMarkdown_PlacesRenderedMarkdownOnClipboard' diff --git a/docs/reqstream/sysml2-workbench/element-picker-subsystem.yaml b/docs/reqstream/sysml2-workbench/element-picker-subsystem.yaml new file mode 100644 index 0000000..6b25e42 --- /dev/null +++ b/docs/reqstream/sysml2-workbench/element-picker-subsystem.yaml @@ -0,0 +1,33 @@ +--- +sections: + - title: 'SysML2Workbench Requirements' + sections: + - title: 'ElementPickerSubsystem Requirements' + requirements: + - id: 'SysML2Workbench-ElementPickerSubsystem-ProvideReusablePicker' + title: 'The ElementPickerSubsystem shall provide a reusable, dialog-agnostic element-picker control + that any modal dialog in the application can host to present a caller-built list of candidate + qualified names with a type-label chip row, a substring search box, and a single-select list.' + justification: | + Both the Custom View Builder's expose-target picker and the Query dialog's Browse/Element Query + pickers need the exact same OR-then-AND filtering behavior. Extracting it into a shared subsystem + is the alternative to either duplicating the filter logic across dialogs or coupling one dialog's + domain model onto the other. + children: + - 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-AcceptCallerBuiltCandidates' + - 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-FilterByTypeAndText' + tests: + - 'ElementPickerViewModel_SetCandidates_AvailableTypeLabels_IsDistinctAndSorted' + + - id: 'SysML2Workbench-ElementPickerSubsystem-DecoupleFromWorkspace' + title: 'The ElementPickerSubsystem shall not itself depend on MainWindowShell, WorkspaceModel, or + ViewDefinitionModel; callers are responsible for building the candidate list and for observing + the picker''s selection.' + justification: | + The picker must be reusable from dialogs whose surrounding domain models are unrelated (Custom + View Builder over ViewDefinitionModel vs. Query dialog over QueryEngine). Any coupling to those + types in the subsystem itself would defeat the extraction. + children: + - 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-AcceptCallerBuiltCandidates' + tests: + - 'ElementPickerViewModel_Construction_HasEmptyInitialState' diff --git a/docs/reqstream/sysml2-workbench/element-picker-subsystem/element-filter.yaml b/docs/reqstream/sysml2-workbench/element-picker-subsystem/element-filter.yaml new file mode 100644 index 0000000..9efd6db --- /dev/null +++ b/docs/reqstream/sysml2-workbench/element-picker-subsystem/element-filter.yaml @@ -0,0 +1,53 @@ +--- +sections: + - title: 'SysML2Workbench Requirements' + sections: + - title: 'ElementPickerSubsystem Requirements' + sections: + - title: 'ElementFilter Requirements' + requirements: + - id: 'SysML2Workbench-ElementPickerSubsystem-ElementFilter-AcceptCallerBuiltCandidates' + title: 'The ElementFilter shall accept a caller-built list of (qualified name, type label) + candidates through SetCandidates, replace its master state on each call, recompute the + available type-label set, and pre-populate the active chip row with the caller-requested + default label when present in the new candidates (empty otherwise).' + justification: | + Callers own the candidate list (which reflects domain-specific exclusions like stdlib names + or disallowed node kinds); the filter must faithfully replace its whole state on every + refresh so a workspace change never leaves a stale chip row in place. Unlike ElementPicker, + there is no selection to clear: ElementFilter has no selection concept at all. + tests: + - 'ElementFilterViewModel_SetCandidates_NullCandidates_Throws' + - 'ElementFilterViewModel_SetCandidates_AvailableTypeLabels_IsDistinctAndSorted' + - 'ElementFilterViewModel_SetCandidates_DefaultLabelPresent_PrepopulatesChip' + - 'ElementFilterViewModel_SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty' + - 'ElementFilterViewModel_SetCandidates_SecondCall_ReplacesState' + + - id: 'SysML2Workbench-ElementPickerSubsystem-ElementFilter-FilterByTypeAndText' + title: 'The ElementFilter shall combine one or more type-label chips (OR semantics; empty + means no type restriction) with an AND-combined case-insensitive substring search over + qualified names, preserving the caller-supplied candidate order in DisplayedItems.' + justification: | + Realistic workspaces contain far more candidate elements than fit comfortably in one list; + narrowing by kind (part def / part / package / ...) and/or by name substring is what makes + a filter-only control (such as the Query dialog''s "List" Query Type) practical without + scrolling through the entire workspace. + tests: + - 'ElementFilterViewModel_DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages' + - 'ElementFilterViewModel_DisplayedItems_NoChips_ShowsAllCandidates' + - 'ElementFilterViewModel_DisplayedItems_MultipleChips_AppliesOrSemantics' + - 'ElementFilterViewModel_DisplayedItems_SearchText_AppliesAndSemanticsWithChips' + - 'ElementFilterViewModel_DisplayedItems_SearchText_IsCaseInsensitive' + + - id: 'SysML2Workbench-ElementPickerSubsystem-ElementFilter-ManageChipsWithoutDuplicates' + title: 'The ElementFilter shall add and remove type-filter chips through AddTypeFilter and + RemoveTypeFilter, reject duplicate additions (so a label appears at most once), and no-op + gracefully when asked to remove a chip that is not currently active.' + justification: | + A dedupe-safe chip API keeps the OR-filter semantics predictable regardless of how the "+" + add-flyout is driven; graceful removal keeps the view model tolerant of view-side event + ordering. + tests: + - 'ElementFilterViewModel_AddTypeFilter_DuplicateLabel_KeepsSingleChip' + - 'ElementFilterViewModel_RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully' + - 'ElementFilterViewModel_GetAddableTypeLabels_ExcludesActiveChips' diff --git a/docs/reqstream/sysml2-workbench/element-picker-subsystem/element-picker.yaml b/docs/reqstream/sysml2-workbench/element-picker-subsystem/element-picker.yaml new file mode 100644 index 0000000..4e42311 --- /dev/null +++ b/docs/reqstream/sysml2-workbench/element-picker-subsystem/element-picker.yaml @@ -0,0 +1,79 @@ +--- +sections: + - title: 'SysML2Workbench Requirements' + sections: + - title: 'ElementPickerSubsystem Requirements' + sections: + - title: 'ElementPicker Requirements' + requirements: + - id: 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-AcceptCallerBuiltCandidates' + title: 'The ElementPicker shall accept a caller-built list of (qualified name, type label) + candidates through SetCandidates, replace its master state on each call, recompute the + available type-label set, pre-populate the active chip row with the caller-requested default + label when present in the new candidates (empty otherwise), and clear any prior selection so + a stale qualified name cannot linger after a refresh.' + justification: | + Callers own the candidate list (which reflects domain-specific exclusions like stdlib names + or disallowed node kinds); the picker must faithfully replace its whole state on every refresh + so a workspace change never leaves a stale selection or chip row in place. + tests: + - 'ElementPickerViewModel_SetCandidates_NullCandidates_Throws' + - 'ElementPickerViewModel_SetCandidates_AvailableTypeLabels_IsDistinctAndSorted' + - 'ElementPickerViewModel_SetCandidates_DefaultLabelPresent_PrepopulatesChip' + - 'ElementPickerViewModel_SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty' + - 'ElementPickerViewModel_SetCandidates_SecondCall_ReplacesState' + + - id: 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-FilterByTypeAndText' + title: 'The ElementPicker shall combine one or more type-label chips (OR semantics; empty + means no type restriction) with an AND-combined case-insensitive substring search over + qualified names, preserving the caller-supplied candidate order in DisplayedItems.' + justification: | + Realistic workspaces contain far more candidate elements than fit comfortably in one ListBox; + narrowing by kind (part def / part / package / ...) and/or by name substring is what makes the + picker practical without scrolling through the entire workspace. + tests: + - 'ElementPickerViewModel_DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages' + - 'ElementPickerViewModel_DisplayedItems_NoChips_ShowsAllCandidates' + - 'ElementPickerViewModel_DisplayedItems_MultipleChips_AppliesOrSemantics' + - 'ElementPickerViewModel_DisplayedItems_SearchText_AppliesAndSemanticsWithChips' + - 'ElementPickerViewModel_DisplayedItems_SearchText_IsCaseInsensitive' + + - id: 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-ManageChipsWithoutDuplicates' + title: 'The ElementPicker shall add and remove type-filter chips through AddTypeFilter and + RemoveTypeFilter, reject duplicate additions (so a label appears at most once), and no-op + gracefully when asked to remove a chip that is not currently active.' + justification: | + A dedupe-safe chip API keeps the OR-filter semantics predictable regardless of how the "+" + add-flyout is driven; graceful removal keeps the view model tolerant of view-side event + ordering. + tests: + - 'ElementPickerViewModel_AddTypeFilter_DuplicateLabel_KeepsSingleChip' + - 'ElementPickerViewModel_RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully' + - 'ElementPickerViewModel_GetAddableTypeLabels_ExcludesActiveChips' + + - id: 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-TrackSelection' + title: 'The ElementPicker shall expose the currently-highlighted qualified name through the + observable SelectedQualifiedName property so callers can bind to it and act on the user''s + selection.' + justification: | + Every caller (Custom View Builder's "Add ->" button, Query dialog's "Run Query" button, Query + dialog's Browse tab's live result) needs the currently-selected qualified name to act on the + picker; a two-way observable property is the minimum contract that makes it useful. + tests: + - 'ElementPickerViewModel_SelectedQualifiedName_RoundTrips' + + - id: 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-LabelKnownNodeKinds' + title: 'The ElementTypeLabeler shall compute a stable human-readable type label for every + SysmlNode subtype the picker can encounter, using DefinitionKeyword / FeatureKeyword / + ConnectionKeyword for keyword-bearing subtypes, fixed literals for the remaining known + subtypes, and a defensive type-name-based fallback for any unrecognized subtype.' + justification: | + The picker''s chip row is only useful if every candidate has a meaningful, consistent label; + a shared static helper (rather than per-caller mapping logic) is what makes the labels + identical across every dialog that hosts the picker. + tests: + - 'ElementTypeLabeler_GetTypeLabel_DefinitionNode_ReturnsDefinitionKeyword' + - 'ElementTypeLabeler_GetTypeLabel_FeatureNode_ReturnsFeatureKeyword' + - 'ElementTypeLabeler_GetTypeLabel_ConnectionNode_ReturnsConnectionKeyword' + - 'ElementTypeLabeler_GetTypeLabel_FixedLiteralNode_ReturnsExpectedLiteral' + - 'ElementTypeLabeler_GetTypeLabel_UnknownSubtype_UsesFallbackFromTypeName' diff --git a/docs/sysml2/model/sysml2-workbench.sysml b/docs/sysml2/model/sysml2-workbench.sysml index 636a275..0d06469 100644 --- a/docs/sysml2/model/sysml2-workbench.sysml +++ b/docs/sysml2/model/sysml2-workbench.sysml @@ -14,6 +14,7 @@ package SysML2WorkbenchArchitecture { part layoutRenderingSubsystem: LayoutRenderingSubsystem; part diagnosticsPanelSubsystem: DiagnosticsPanelSubsystem; part loggingSubsystem: LoggingSubsystem; + part elementPickerSubsystem: ElementPickerSubsystem; part appShellSubsystem: AppShellSubsystem; } } diff --git a/docs/sysml2/model/sysml2-workbench/app-shell-subsystem.sysml b/docs/sysml2/model/sysml2-workbench/app-shell-subsystem.sysml index 84cb63a..40f9094 100644 --- a/docs/sysml2/model/sysml2-workbench/app-shell-subsystem.sysml +++ b/docs/sysml2/model/sysml2-workbench/app-shell-subsystem.sysml @@ -12,6 +12,7 @@ package SysML2WorkbenchArchitecture { part workspacePanel: WorkspacePanel; part aboutDialog: AboutDialog; part viewBuilderDialog: ViewBuilderDialog; + part queryDialog: QueryDialog; } } } diff --git a/docs/sysml2/model/sysml2-workbench/app-shell-subsystem/query-dialog.sysml b/docs/sysml2/model/sysml2-workbench/app-shell-subsystem/query-dialog.sysml new file mode 100644 index 0000000..c39caf1 --- /dev/null +++ b/docs/sysml2/model/sysml2-workbench/app-shell-subsystem/query-dialog.sysml @@ -0,0 +1,13 @@ +package SysML2WorkbenchArchitecture { + package SysML2Workbench { + part def QueryDialog { + doc /* Modal dialog opened from the main window's Query menu that lets the user browse or query workspace elements through a single, always-visible adaptive form (no tabs, no "Run" button): a Query Type combo box offers a merged "List" client-side filter plus ten element-scoped QueryVerb operations dispatched through DemaConsulting.SysML2Tools.Query.QueryEngine.Execute, driven by one always-visible element picker. Every relevant change recomputes the result immediately. Results render through QueryResultRenderer and copy to the clipboard as Markdown or JSON via a right-click context menu on the results panel. */ + + comment sourceRef /* Source: src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogViewModel.cs */ + comment testRef /* Test: test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs */ + comment designRef /* Design: docs/design/sysml2-workbench/app-shell-subsystem/query-dialog.md */ + comment verificationRef /* Verification: docs/verification/sysml2-workbench/app-shell-subsystem/query-dialog.md */ + comment reqRef /* Requirements: docs/reqstream/sysml2-workbench/app-shell-subsystem/query-dialog.yaml */ + } + } +} diff --git a/docs/sysml2/model/sysml2-workbench/element-picker-subsystem.sysml b/docs/sysml2/model/sysml2-workbench/element-picker-subsystem.sysml new file mode 100644 index 0000000..7b52540 --- /dev/null +++ b/docs/sysml2/model/sysml2-workbench/element-picker-subsystem.sysml @@ -0,0 +1,15 @@ +package SysML2WorkbenchArchitecture { + package SysML2Workbench { + part def ElementPickerSubsystem { + doc /* Shared, dialog-agnostic "element picker" control - a chip-row of type-label filters (OR semantics), a case-insensitive substring search, and a single-select list of candidate qualified names - hosted by more than one dialog (Custom View Builder, Query dialog) without duplicating the filter logic. */ + + comment testRef /* Test: test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystemTests.cs */ + comment designRef /* Design: docs/design/sysml2-workbench/element-picker-subsystem.md */ + comment verificationRef /* Verification: docs/verification/sysml2-workbench/element-picker-subsystem.md */ + comment reqRef /* Requirements: docs/reqstream/sysml2-workbench/element-picker-subsystem.yaml */ + + part elementFilter: ElementFilter; + part elementPicker: ElementPicker; + } + } +} diff --git a/docs/sysml2/model/sysml2-workbench/element-picker-subsystem/element-filter.sysml b/docs/sysml2/model/sysml2-workbench/element-picker-subsystem/element-filter.sysml new file mode 100644 index 0000000..1d7be43 --- /dev/null +++ b/docs/sysml2/model/sysml2-workbench/element-picker-subsystem/element-filter.sysml @@ -0,0 +1,13 @@ +package SysML2WorkbenchArchitecture { + package SysML2Workbench { + part def ElementFilter { + doc /* Reusable Avalonia UserControl and ObservableObject view model that presents a filter-only candidate list: type-label chip row (OR), substring search text (AND). No selection concept. Composed by ElementPicker for callers that also need a selection, or used standalone by callers with no target-element concept (for example the Query dialog's List Query Type). */ + + comment sourceRef /* Source: src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementFilterViewModel.cs */ + comment testRef /* Test: test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementFilterViewModelTests.cs */ + comment designRef /* Design: docs/design/sysml2-workbench/element-picker-subsystem/element-filter.md */ + comment verificationRef /* Verification: docs/verification/sysml2-workbench/element-picker-subsystem/element-filter.md */ + comment reqRef /* Requirements: docs/reqstream/sysml2-workbench/element-picker-subsystem/element-filter.yaml */ + } + } +} diff --git a/docs/sysml2/model/sysml2-workbench/element-picker-subsystem/element-picker.sysml b/docs/sysml2/model/sysml2-workbench/element-picker-subsystem/element-picker.sysml new file mode 100644 index 0000000..0eba5e3 --- /dev/null +++ b/docs/sysml2/model/sysml2-workbench/element-picker-subsystem/element-picker.sysml @@ -0,0 +1,13 @@ +package SysML2WorkbenchArchitecture { + package SysML2Workbench { + part def ElementPicker { + doc /* Reusable Avalonia UserControl and ObservableObject view model that presents a filterable list of candidate elements: type-label chip row (OR), substring search text (AND), single selection. Independent of any workspace or shell type - callers hand it a candidate list. */ + + comment sourceRef /* Source: src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerViewModel.cs */ + comment testRef /* Test: test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementPickerViewModelTests.cs */ + comment designRef /* Design: docs/design/sysml2-workbench/element-picker-subsystem/element-picker.md */ + comment verificationRef /* Verification: docs/verification/sysml2-workbench/element-picker-subsystem/element-picker.md */ + comment reqRef /* Requirements: docs/reqstream/sysml2-workbench/element-picker-subsystem/element-picker.yaml */ + } + } +} diff --git a/docs/sysml2/views/design-views.sysml b/docs/sysml2/views/design-views.sysml index a2c97eb..e3caaf0 100644 --- a/docs/sysml2/views/design-views.sysml +++ b/docs/sysml2/views/design-views.sysml @@ -34,4 +34,8 @@ package SysML2WorkbenchArchitecture { view AppShellSubsystemView { expose SysML2Workbench::AppShellSubsystem; } + + view ElementPickerSubsystemView { + expose SysML2Workbench::ElementPickerSubsystem; + } } diff --git a/docs/user_guide/getting_started.md b/docs/user_guide/getting_started.md index 56150a0..ad1b894 100644 --- a/docs/user_guide/getting_started.md +++ b/docs/user_guide/getting_started.md @@ -144,6 +144,48 @@ Custom views built in the GUI are **session-only** - they are not saved to disk by SysML2Workbench itself. The "Copy as SysML" snippet is the only way to persist a custom view, by pasting it into your own model file. +## Running a Query + +Choose **Query > Run Query...** to open the Query dialog. The dialog is a +single form (no tabs, no "Run" button) with a shared results panel on the +right; use the **Include standard library** checkbox at the top to control +whether stdlib qualified names appear as candidates, and the **Query Type** +combo box below it to choose what to see: + +- **List** (the default) is a live client-side filter over the whole + workspace, shown by a dedicated filter-only control - the chip row (with + **+** to add and **✕** to remove type filters) and the **Filter by + name...** search box, the same way as in the Custom View Builder - with + no selectable list, since **List** has no target-element concept at all. + The results panel updates as you type, showing one entry per candidate + that matches. +- The other ten entries (Describe, Uses, Used By, Dependencies, Impact, + Hierarchy, Requirements, Interface, Connections, States) each need a + target element: the dialog instead shows the element picker (the same + chip row and search box, plus a selectable candidate list) below the + Query Type combo - pick an element there to use it as the query's target. + Two of them expose extra controls: **Hierarchy** shows a Direction + dropdown (*up*, *down*, *both*), and **Impact** shows an optional Walk + Depth text box (leave blank for no bound). + +Every change updates the results panel immediately - there is no Run +button. Changing the Query Type, adding or removing a chip, editing the +search text, picking an element, changing Direction or Walk Depth, or +toggling **Include standard library** all recompute the results panel +synchronously. If you choose one of the ten element-scoped query types +before picking an element, the panel shows a prompt asking you to select +one instead of leaving a stale result on screen. + +The results panel shows a bullet-list summary and a table of matching +entries with Qualified Name, Kind, and Detail columns; the **Dependencies** +verb adds a Direction column. Any per-entry notes appear as a tooltip on +that entry's row. + +Right-click the results panel and choose **Copy as Markdown** or **Copy as +JSON** to place the rendered result on the clipboard for pasting into a +review, a bug report, or another tool. Query results are session-only - +closing the dialog discards them. + ## Diagnostics Panel The **Diagnostics** panel lists every parser and reference-resolution problem diff --git a/docs/verification/sysml2-workbench/app-shell-subsystem/query-dialog.md b/docs/verification/sysml2-workbench/app-shell-subsystem/query-dialog.md new file mode 100644 index 0000000..0871adc --- /dev/null +++ b/docs/verification/sysml2-workbench/app-shell-subsystem/query-dialog.md @@ -0,0 +1,167 @@ +### QueryDialog + +#### Verification Approach + +Tests in +`test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs` construct +`QueryDialogViewModel` directly against a real (non-mocked) `MainWindowShell` and a temporary +on-disk workspace - it has no Avalonia dependency itself, so no UI thread or dialog `Window` is +needed to exercise its state, `BuildOptions` shape, or clipboard composition. This design has no +explicit "Run" method: every scenario drives the auto-recompute contract by assigning the property +that should trigger it (`SelectedQueryType`, `Picker.SelectedQualifiedName`, `HierarchyDirection`, +`WalkDepthText`, `IncludeStdlib`) and asserting `CurrentResult`/`StatusMessage` update immediately, +with no intervening method call anywhere in the test bodies. A single Avalonia-headless end-to-end +test in `test/OtsSoftwareTests/AvaloniaTests.cs` opens the real dialog through the real Query menu +item, confirms the dialog opens on "List" with the selection-free filter control visible and the +element picker hidden, selects the Describe Query Type (confirming the visibility flip) and an +element on the now-visible picker, and right-clicks the results panel to invoke "Copy as Markdown" +through its context menu, asserting the rendered Markdown reached the headless platform's real +clipboard. The scenario list below follows +the authoritative mappings in +`docs/reqstream/sysml2-workbench/app-shell-subsystem/query-dialog.yaml` and describes the +implemented tests in present tense. `QueryDialogView`'s code-behind (control wiring, Query-Type +combo/panel visibility toggles, and the close button handler) is not independently unit-tested: it +only forwards to already-covered view-model methods and to Avalonia's own binding infrastructure, +verified in composition by the end-to-end scenario below. + +#### Test Environment + +View-model tests run under the standard .NET test runner, using a temporary workspace folder and +log directory plus real collaborator units (`WorkspaceModel`, `FileWatcher`, +`DiagnosticsAggregator`, `ViewCatalogPresenter`, `LayoutInvoker`, `DiagnosticsListView`, +`SysmlSnippetGenerator`, `RollingFileLogger`). The end-to-end test runs under the standard +`Avalonia.Headless.XUnit` platform. No external services are required. + +#### Acceptance Criteria + +- All implemented tests in + `test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs` and + the single referenced test in `test/OtsSoftwareTests/AvaloniaTests.cs` that correspond to the + scenarios below pass with zero failures. +- The assertions exercised by these scenarios continue to verify the behavior traced from + `docs/reqstream/sysml2-workbench/app-shell-subsystem/query-dialog.yaml` using the real paths and + collaborators described above. +- Any regression in the covered normal, boundary, or error flows produces a failing xUnit + assertion rather than a speculative or placeholder verification statement. + +#### Test Scenarios + +**Construction_EmptyShell_ReportsWorkspaceEmpty**: Constructing the view model over a zero-source +shell reports `IsWorkspaceEmpty` as `true`, defaults `SelectedQueryType` to `List`, and leaves +`FilterOnly` empty (with a non-null, empty client-side list result). Verified by +`QueryDialogViewModelTests.Construction_EmptyShell_ReportsWorkspaceEmpty`. + +**Construction_LoadedWorkspace_PopulatesSinglePicker**: Constructing the view model over a +workspace with declarations populates both `FilterOnly` and `Picker` with those declarations (the +same candidate list is used for both) and reports `IsWorkspaceEmpty` as `false`. Verified by +`QueryDialogViewModelTests.Construction_LoadedWorkspace_PopulatesSinglePicker`. + +**ListQueryType_BuildsClientSideListResult**: With `SelectedQueryType=List`, `CurrentResult` is a +client-built `QueryResult` with `Verb="list"`, no target `Element`, and one entry per +`DisplayedItem`, without calling `QueryEngine.List` or `QueryEngine.Find`. Verified by +`QueryDialogViewModelTests.ListQueryType_BuildsClientSideListResult`. + +**ListQueryType_SearchTextEdit_RegeneratesResultLive**: Editing `FilterOnly`'s search text while +`List` is selected recomputes `CurrentResult` and `CurrentResultRows` live, with no explicit "Run" +gesture. Verified by +`QueryDialogViewModelTests.ListQueryType_SearchTextEdit_RegeneratesResultLive`. + +**RecomputeResult_ElementScopedVerbNoSelection_ReportsPromptAndClearsRows**: Selecting an +element-scoped Query Type (e.g. `Describe`) with the picker's `SelectedQualifiedName` unset reports +a helpful, non-error `StatusMessage` naming the Query Type, and clears `CurrentResult`/ +`CurrentResultRows` rather than leaving a stale prior result. Verified by +`QueryDialogViewModelTests.RecomputeResult_ElementScopedVerbNoSelection_ReportsPromptAndClearsRows`. + +**RecomputeResult_EmptyWorkspace_ReportsStatusMessage**: Selecting an element-scoped Query Type +against a zero-source shell reports the failure through a non-null `StatusMessage` and does not +throw. Verified by +`QueryDialogViewModelTests.RecomputeResult_EmptyWorkspace_ReportsStatusMessage`. + +**RecomputeResult_DescribeWithSelection_DispatchesThroughEngineImmediately**: Selecting +`SelectedQueryType=Describe` and then assigning a qualified name to `Picker.SelectedQualifiedName` +immediately produces a `CurrentResult` whose `Verb` is `"describe"` and whose target `Element` +matches the selection - with no intervening method call - proving the query dispatched through +`QueryEngine.Execute` via the auto-recompute contract alone. Verified by +`QueryDialogViewModelTests.RecomputeResult_DescribeWithSelection_DispatchesThroughEngineImmediately`. + +**SwitchingQueryType_FromDescribeToList_ShowsListResultImmediately**: After a Describe result is +showing, switching `SelectedQueryType` to `List` immediately shows the client-side list result +instead of the stale Describe result. Verified by +`QueryDialogViewModelTests.SwitchingQueryType_FromDescribeToList_ShowsListResultImmediately`. + +**SwitchingQueryType_FromListToDescribeNoSelection_ShowsSelectPrompt**: From the default `List` +state (which always has a result), switching to `SelectedQueryType=Describe` with no picker +selection immediately shows the "select an element" prompt rather than a stale `List` result or a +thrown exception. Verified by +`QueryDialogViewModelTests.SwitchingQueryType_FromListToDescribeNoSelection_ShowsSelectPrompt`. + +**HierarchyDirectionChange_WithSelection_RecomputesImmediately**: With `SelectedQueryType=Hierarchy` +and an active selection, changing `HierarchyDirection` recomputes `CurrentResult` immediately with +no stale state or thrown exception. Verified by +`QueryDialogViewModelTests.HierarchyDirectionChange_WithSelection_RecomputesImmediately`. + +**WalkDepthTextChange_WithSelection_RecomputesImmediately**: With `SelectedQueryType=Impact` and an +active selection, editing `WalkDepthText` recomputes `CurrentResult` immediately. Verified by +`QueryDialogViewModelTests.WalkDepthTextChange_WithSelection_RecomputesImmediately`. + +**BuildOptions_HierarchyVerb_AttachesDirection**: With `SelectedQueryType=Hierarchy`, `BuildOptions` +attaches the current `HierarchyDirection` value to `QueryOptions.Direction`. Verified by +`QueryDialogViewModelTests.BuildOptions_HierarchyVerb_AttachesDirection`. + +**BuildOptions_NonHierarchyVerb_OmitsDirection**: With any Query Type other than Hierarchy, +`BuildOptions` leaves `QueryOptions.Direction` `null`, matching the engine's expectation. Verified +by `QueryDialogViewModelTests.BuildOptions_NonHierarchyVerb_OmitsDirection`. + +**BuildOptions_ImpactVerbWithWalkDepth_ParsesWalkDepth**: With `SelectedQueryType=Impact` and +`WalkDepthText` set to a parseable non-negative integer, `BuildOptions` sets `QueryOptions.WalkDepth` +to that integer. Verified by +`QueryDialogViewModelTests.BuildOptions_ImpactVerbWithWalkDepth_ParsesWalkDepth`. + +**BuildOptions_ImpactVerbWithInvalidWalkDepth_LeavesNull**: With `SelectedQueryType=Impact` and +`WalkDepthText` that does not parse (blank, non-numeric, negative), `BuildOptions` leaves +`QueryOptions.WalkDepth` `null`, so a typo does not silently coerce to zero. Verified by +`QueryDialogViewModelTests.BuildOptions_ImpactVerbWithInvalidWalkDepth_LeavesNull`. + +**BuildOptions_PropagatesIncludeStdlib**: `BuildOptions` always carries the current `IncludeStdlib` +flag to `QueryOptions.IncludeStdlib`, regardless of Query Type. Verified by +`QueryDialogViewModelTests.BuildOptions_PropagatesIncludeStdlib`. + +**IncludeStdlibToggle_RefreshesPickerAndRecomputesResult**: Toggling `IncludeStdlib` refreshes both +`FilterOnly`'s and `Picker`'s candidate lists to reflect the new stdlib-inclusion rule, and +recomputes the current result (clearing a now-unselected Describe result to the select-element +prompt rather than leaving it stale, then confirming the pipeline still functions correctly after +re-selecting). Verified by +`QueryDialogViewModelTests.IncludeStdlibToggle_RefreshesPickerAndRecomputesResult`. + +**QueryTypes_HasExpectedElevenEntries**: The static `QueryTypes` list contains exactly the eleven +Query Type options (`List` first, followed by the ten element-scoped `QueryVerb` values) and never +contains the workspace-scoped `Find` verb, which "List" always merges into instead. Verified by +`QueryDialogViewModelTests.QueryTypes_HasExpectedElevenEntries`. + +**HierarchyDirectionOptions_HasExpectedThree**: The static `HierarchyDirectionOptions` list contains +exactly `"up"`, `"down"`, and `"both"`. Verified by +`QueryDialogViewModelTests.HierarchyDirectionOptions_HasExpectedThree`. + +**CopyResultAsMarkdownAsync_WritesRenderedMarkdownToClipboard**: With a non-null `CurrentResult` and +a stub `IClipboardService`, `CopyResultAsMarkdownAsync` writes the concatenated +`QueryResultRenderer.RenderMarkdown` output to the clipboard. Verified by +`QueryDialogViewModelTests.CopyResultAsMarkdownAsync_WritesRenderedMarkdownToClipboard`. + +**CopyResultAsJsonAsync_WritesRenderedJsonToClipboard**: With a non-null `CurrentResult` and a stub +`IClipboardService`, `CopyResultAsJsonAsync` writes the `QueryResultRenderer.RenderJson` output to +the clipboard. Verified by +`QueryDialogViewModelTests.CopyResultAsJsonAsync_WritesRenderedJsonToClipboard`. + +**CopyMethods_NoResult_AreNoOps**: With `CurrentResult=null` (reached here via the natural +no-selection path for an element-scoped Query Type), both copy methods are safe no-ops - they do +not touch the clipboard and do not throw. Verified by +`QueryDialogViewModelTests.CopyMethods_NoResult_AreNoOps`. + +**QueryDialog_SelectDescribeAndCopyAsMarkdown_PlacesRenderedMarkdownOnClipboard**: An +Avalonia-headless end-to-end scenario that opens the main window, clicks the Query menu's "Run +Query..." item, selects "Describe" on the single form's Query Type combo, selects a qualified name +on the one always-visible picker (producing the result immediately, with no "Run" gesture), +right-clicks the results panel to open its context menu, clicks "Copy as Markdown", and asserts +that the headless platform's real clipboard now contains the expected rendered Markdown. Verified +by +`AvaloniaTests.QueryDialog_SelectDescribeAndCopyAsMarkdown_PlacesRenderedMarkdownOnClipboard`. diff --git a/docs/verification/sysml2-workbench/element-picker-subsystem.md b/docs/verification/sysml2-workbench/element-picker-subsystem.md new file mode 100644 index 0000000..909fa68 --- /dev/null +++ b/docs/verification/sysml2-workbench/element-picker-subsystem.md @@ -0,0 +1,33 @@ +## ElementPickerSubsystem + +### Verification Approach + +Tests in `test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/` exercise the +subsystem's units in isolation: `ElementFilterViewModelTests` covers `ElementFilterViewModel` +(the selection-free chip/search filter) against caller-supplied candidate lists, +`ElementPickerViewModelTests` covers `ElementPickerViewModel` (which composes an +`ElementFilterViewModel` internally and adds selection) against the same kind of candidate lists, +and `ElementTypeLabelerTests` covers `ElementTypeLabeler`'s full node-kind taxonomy against a +direct set of `SysmlNode` fixtures - none of the three require Avalonia, `WorkspaceModel`, or a +shell dependency. The scenario list is enumerated per-unit in the child `element-filter.md` and +`element-picker.md` verification documents to keep the mapping to +`docs/reqstream/sysml2-workbench/element-picker-subsystem/element-filter.yaml` and +`docs/reqstream/sysml2-workbench/element-picker-subsystem/element-picker.yaml` explicit. + +### Test Environment + +Tests run under the standard .NET test runner. No temporary workspace, no logging directory, and +no external services are required; every fixture is built inline as an `IReadOnlyList` or +directly-constructed `SysmlNode`. + +### Acceptance Criteria + +- All implemented tests in `test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/` that + correspond to the scenarios enumerated in the child units' verification documents pass with zero + failures. +- The assertions exercised by those scenarios continue to verify the behavior traced from + `docs/reqstream/sysml2-workbench/element-picker-subsystem.yaml` and its child + `element-filter.yaml`/`element-picker.yaml` using the real paths and collaborators described + above. +- Any regression in the covered normal, boundary, or error flows produces a failing xUnit + assertion rather than a speculative or placeholder verification statement. diff --git a/docs/verification/sysml2-workbench/element-picker-subsystem/element-filter.md b/docs/verification/sysml2-workbench/element-picker-subsystem/element-filter.md new file mode 100644 index 0000000..349340f --- /dev/null +++ b/docs/verification/sysml2-workbench/element-picker-subsystem/element-filter.md @@ -0,0 +1,95 @@ +### ElementFilter + +#### Verification Approach + +Tests in `test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementFilterViewModelTests.cs` +construct `ElementFilterViewModel` directly against inline candidate lists - it has no Avalonia, +`WorkspaceModel`, or shell dependency, so no UI thread or workspace fixture is required. The +scenario list below follows the authoritative mappings in +`docs/reqstream/sysml2-workbench/element-picker-subsystem/element-filter.yaml` and describes the +implemented tests in present tense, mirroring the equivalent `ElementPickerViewModelTests` scenarios +minus any selection-related assertion, since this view model has no selection concept. +`ElementFilterView`'s code-behind (control wiring, chip-button click handlers, "+" flyout) is not +independently unit-tested: it only forwards to already-covered view-model methods and to Avalonia's +own binding infrastructure, verified in composition by the same `AvaloniaTests` Query dialog +end-to-end test that exercises the embedded picker view, and by `QueryDialogViewModelTests`'s +"List" Query Type scenarios which drive a standalone `ElementFilterViewModel` (`FilterOnly`). + +#### Test Environment + +Tests run under the standard .NET test runner. No temporary workspace, no logging directory, and +no external services are required. + +#### Acceptance Criteria + +- All implemented tests in + `test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementFilterViewModelTests.cs` + that correspond to the scenarios below pass with zero failures. +- The assertions exercised by these scenarios continue to verify the behavior traced from + `docs/reqstream/sysml2-workbench/element-picker-subsystem/element-filter.yaml` using the real + paths and collaborators described above. +- Any regression in the covered normal, boundary, or error flows produces a failing xUnit + assertion rather than a speculative or placeholder verification statement. + +#### Test Scenarios + +**Construction_HasEmptyInitialState**: A newly-constructed `ElementFilterViewModel` reports empty +`AvailableTypeLabels`, `DisplayedItems`, and `ActiveTypeFilters`, and no `SearchText`. Verified by +`ElementFilterViewModelTests.Construction_HasEmptyInitialState`. + +**SetCandidates_NullCandidates_Throws**: Calling `SetCandidates(null!)` throws +`ArgumentNullException`, matching the same defensive posture used elsewhere in the code base. +Verified by `ElementFilterViewModelTests.SetCandidates_NullCandidates_Throws`. + +**SetCandidates_AvailableTypeLabels_IsDistinctAndSorted**: After `SetCandidates` is called with +duplicate type labels across candidates, `AvailableTypeLabels` contains each label exactly once and +is sorted ordinally. Verified by +`ElementFilterViewModelTests.SetCandidates_AvailableTypeLabels_IsDistinctAndSorted`. + +**SetCandidates_DefaultLabelPresent_PrepopulatesChip**: When `SetCandidates` is called with a +`defaultTypeFilterLabel` that appears in the new candidates, `ActiveTypeFilters` is pre-populated +with exactly that one chip. Verified by +`ElementFilterViewModelTests.SetCandidates_DefaultLabelPresent_PrepopulatesChip`. + +**SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty**: When the `defaultTypeFilterLabel` is not +present in the new candidates (or is `null`), `ActiveTypeFilters` is left empty, applying no type +restriction by default. Verified by +`ElementFilterViewModelTests.SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty`. + +**SetCandidates_SecondCall_ReplacesState**: A second call to `SetCandidates` replaces the whole +candidate/labels/chips state - the prior chip set is recomputed and only the new candidates appear +in `DisplayedItems`. Verified by `ElementFilterViewModelTests.SetCandidates_SecondCall_ReplacesState`. + +**DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages**: With the default `"part"` chip active, +`DisplayedItems` contains only the candidates whose type label is `"part"`. Verified by +`ElementFilterViewModelTests.DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages`. + +**DisplayedItems_NoChips_ShowsAllCandidates**: With no active chips, `DisplayedItems` contains +every candidate in the caller-supplied order. Verified by +`ElementFilterViewModelTests.DisplayedItems_NoChips_ShowsAllCandidates`. + +**DisplayedItems_MultipleChips_AppliesOrSemantics**: Activating two chips shows candidates matching +either label while excluding a candidate whose label matches neither, confirming OR semantics +across chips. Verified by +`ElementFilterViewModelTests.DisplayedItems_MultipleChips_AppliesOrSemantics`. + +**DisplayedItems_SearchText_AppliesAndSemanticsWithChips**: With chips active, setting `SearchText` +further narrows `DisplayedItems` to only the candidates matching both the type filter and the text +search, confirming AND semantics between the two filters. Verified by +`ElementFilterViewModelTests.DisplayedItems_SearchText_AppliesAndSemanticsWithChips`. + +**DisplayedItems_SearchText_IsCaseInsensitive**: The search-text filter is case-insensitive against +qualified names. Verified by `ElementFilterViewModelTests.DisplayedItems_SearchText_IsCaseInsensitive`. + +**AddTypeFilter_DuplicateLabel_KeepsSingleChip**: Calling `AddTypeFilter` twice with the same label +results in exactly one chip in `ActiveTypeFilters`, confirming dedupe-safe behavior. Verified by +`ElementFilterViewModelTests.AddTypeFilter_DuplicateLabel_KeepsSingleChip`. + +**RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully**: Removing an active chip clears it, +and a subsequent removal of a label that is not currently active is a no-op rather than a throw. +Verified by `ElementFilterViewModelTests.RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully`. + +**GetAddableTypeLabels_ExcludesActiveChips**: `GetAddableTypeLabels()` returns +`AvailableTypeLabels` minus every currently-active chip, so the "+" add-flyout never re-offers a +label that is already active. Verified by +`ElementFilterViewModelTests.GetAddableTypeLabels_ExcludesActiveChips`. diff --git a/docs/verification/sysml2-workbench/element-picker-subsystem/element-picker.md b/docs/verification/sysml2-workbench/element-picker-subsystem/element-picker.md new file mode 100644 index 0000000..811a8cc --- /dev/null +++ b/docs/verification/sysml2-workbench/element-picker-subsystem/element-picker.md @@ -0,0 +1,133 @@ +### ElementPicker + +#### Verification Approach + +Tests in `test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementPickerViewModelTests.cs` +construct `ElementPickerViewModel` directly against inline candidate lists - it has no Avalonia, +`WorkspaceModel`, or shell dependency, so no UI thread or workspace fixture is required. Every +scenario below still exercises the same public API now that `ElementPickerViewModel` composes an +`ElementFilterViewModel` internally (see `element-filter.md` for that composed unit's own +dedicated tests): the composing wrapper's pass-through properties/methods preserve the exact same +observable contract, so no test assertion changed as part of that refactor. Tests in +`ElementTypeLabelerTests.cs` construct `SysmlNode` fixtures directly (via their public +no-argument constructors and init properties) and assert against `ElementTypeLabeler.GetTypeLabel`. The scenario +list below follows the authoritative mappings in +`docs/reqstream/sysml2-workbench/element-picker-subsystem/element-picker.yaml` and describes the +implemented tests in present tense. `ElementPickerView`'s code-behind (control wiring, chip-button +click handlers, "+" flyout) is not independently unit-tested: it only forwards to already-covered +view-model methods and to Avalonia's own binding infrastructure, verified in composition by +`AvaloniaTests.QueryDialog_RunDescribeAndCopyAsMarkdown_PlacesRenderedMarkdownOnClipboard` and by +the existing `ViewBuilderDialogViewModelTests` suite which drives the composed picker. + +#### Test Environment + +Tests run under the standard .NET test runner. No temporary workspace, no logging directory, and +no external services are required. + +#### Acceptance Criteria + +- All implemented tests in + `test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementPickerViewModelTests.cs` + and `ElementTypeLabelerTests.cs` that correspond to the scenarios below pass with zero failures. +- The assertions exercised by these scenarios continue to verify the behavior traced from + `docs/reqstream/sysml2-workbench/element-picker-subsystem/element-picker.yaml` using the real + paths and collaborators described above. +- Any regression in the covered normal, boundary, or error flows produces a failing xUnit + assertion rather than a speculative or placeholder verification statement. + +#### Test Scenarios + +**Construction_HasEmptyInitialState**: A newly-constructed `ElementPickerViewModel` reports empty +`AvailableTypeLabels`, `DisplayedItems`, and `ActiveTypeFilters`, no `SearchText`, and no +`SelectedQualifiedName`. Verified by +`ElementPickerViewModelTests.Construction_HasEmptyInitialState`. + +**SetCandidates_NullCandidates_Throws**: Calling `SetCandidates(null!)` throws +`ArgumentNullException`, matching the same defensive posture used elsewhere in the code base. +Verified by `ElementPickerViewModelTests.SetCandidates_NullCandidates_Throws`. + +**SetCandidates_AvailableTypeLabels_IsDistinctAndSorted**: After `SetCandidates` is called with +duplicate type labels across candidates, `AvailableTypeLabels` contains each label exactly once and +is sorted ordinally. Verified by +`ElementPickerViewModelTests.SetCandidates_AvailableTypeLabels_IsDistinctAndSorted`. + +**SetCandidates_DefaultLabelPresent_PrepopulatesChip**: When `SetCandidates` is called with a +`defaultTypeFilterLabel` that appears in the new candidates, `ActiveTypeFilters` is pre-populated +with exactly that one chip. Verified by +`ElementPickerViewModelTests.SetCandidates_DefaultLabelPresent_PrepopulatesChip`. + +**SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty**: When the `defaultTypeFilterLabel` is not +present in the new candidates (or is `null`), `ActiveTypeFilters` is left empty, applying no type +restriction by default. Verified by +`ElementPickerViewModelTests.SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty`. + +**SetCandidates_SecondCall_ReplacesState**: A second call to `SetCandidates` replaces the whole +candidate/labels/chips/selection state - the prior selection is cleared, the prior chip set is +recomputed, and only the new candidates appear in `DisplayedItems`. Verified by +`ElementPickerViewModelTests.SetCandidates_SecondCall_ReplacesState`. + +**DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages**: With the default `"part"` chip active, +`DisplayedItems` contains only the candidates whose type label is `"part"`. Verified by +`ElementPickerViewModelTests.DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages`. + +**DisplayedItems_NoChips_ShowsAllCandidates**: With no active chips, `DisplayedItems` contains +every candidate in the caller-supplied order. Verified by +`ElementPickerViewModelTests.DisplayedItems_NoChips_ShowsAllCandidates`. + +**DisplayedItems_MultipleChips_AppliesOrSemantics**: Activating two chips shows candidates matching +either label while excluding a candidate whose label matches neither, confirming OR semantics +across chips. Verified by +`ElementPickerViewModelTests.DisplayedItems_MultipleChips_AppliesOrSemantics`. + +**DisplayedItems_SearchText_AppliesAndSemanticsWithChips**: With chips active, setting `SearchText` +further narrows `DisplayedItems` to only the candidates matching both the type filter and the text +search, confirming AND semantics between the two filters. Verified by +`ElementPickerViewModelTests.DisplayedItems_SearchText_AppliesAndSemanticsWithChips`. + +**DisplayedItems_SearchText_IsCaseInsensitive**: The search-text filter is case-insensitive against +qualified names. Verified by +`ElementPickerViewModelTests.DisplayedItems_SearchText_IsCaseInsensitive`. + +**AddTypeFilter_DuplicateLabel_KeepsSingleChip**: Calling `AddTypeFilter` twice with the same label +results in exactly one chip in `ActiveTypeFilters`, confirming dedupe-safe behavior. Verified by +`ElementPickerViewModelTests.AddTypeFilter_DuplicateLabel_KeepsSingleChip`. + +**RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully**: Removing an active chip clears it, +and a subsequent removal of a label that is not currently active is a no-op rather than a throw. +Verified by `ElementPickerViewModelTests.RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully`. + +**GetAddableTypeLabels_ExcludesActiveChips**: `GetAddableTypeLabels()` returns +`AvailableTypeLabels` minus every currently-active chip, so the "+" add-flyout never re-offers a +label that is already active. Verified by +`ElementPickerViewModelTests.GetAddableTypeLabels_ExcludesActiveChips`. + +**SelectedQualifiedName_RoundTrips**: Assigning `SelectedQualifiedName` and reading it back returns +the assigned value, providing a two-way observable seam for view bindings. Verified by +`ElementPickerViewModelTests.SelectedQualifiedName_RoundTrips`. + +**GetTypeLabel_NullNode_Throws**: `ElementTypeLabeler.GetTypeLabel(null!)` throws +`ArgumentNullException`, matching the defensive posture of the other static helpers in this repo. +Verified by `ElementTypeLabelerTests.GetTypeLabel_NullNode_Throws`. + +**GetTypeLabel_DefinitionNode_ReturnsDefinitionKeyword**: A `SysmlDefinitionNode` returns its +`DefinitionKeyword` verbatim (for example, `"part def"`). Verified by +`ElementTypeLabelerTests.GetTypeLabel_DefinitionNode_ReturnsDefinitionKeyword`. + +**GetTypeLabel_FeatureNode_ReturnsFeatureKeyword**: A `SysmlFeatureNode` returns its +`FeatureKeyword` verbatim (for example, `"part"`). Verified by +`ElementTypeLabelerTests.GetTypeLabel_FeatureNode_ReturnsFeatureKeyword`. + +**GetTypeLabel_ConnectionNode_ReturnsConnectionKeyword**: A `SysmlConnectionNode` returns its +`ConnectionKeyword` verbatim. Verified by +`ElementTypeLabelerTests.GetTypeLabel_ConnectionNode_ReturnsConnectionKeyword`. + +**GetTypeLabel_FixedLiteralNode_ReturnsExpectedLiteral**: The fixed-literal node kinds +(`SysmlImportNode` → `"import"`, `SysmlPackageNode` → `"package"`, `SysmlViewNode` → `"view"`, +`SysmlViewpointNode` → `"viewpoint"`, `SysmlTransitionNode` → `"transition"`, `SysmlSatisfyNode` → +`"satisfy"`, `SysmlDependencyNode` → `"dependency"`) each return their canonical literal. Verified +by `ElementTypeLabelerTests.GetTypeLabel_FixedLiteralNode_ReturnsExpectedLiteral`. + +**GetTypeLabel_UnknownSubtype_UsesFallbackFromTypeName**: A `SysmlNode` subtype that isn't in the +taxonomy table falls back to the runtime type name with the `"Sysml"` prefix and `"Node"` suffix +stripped and lowercased. Verified by +`ElementTypeLabelerTests.GetTypeLabel_UnknownSubtype_UsesFallbackFromTypeName`. diff --git a/requirements.yaml b/requirements.yaml index 2583540..5936723 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -19,11 +19,15 @@ includes: - docs/reqstream/sysml2-workbench/diagnostics-panel-subsystem/diagnostics-list-view.yaml - docs/reqstream/sysml2-workbench/logging-subsystem.yaml - docs/reqstream/sysml2-workbench/logging-subsystem/rolling-file-logger.yaml + - docs/reqstream/sysml2-workbench/element-picker-subsystem.yaml + - docs/reqstream/sysml2-workbench/element-picker-subsystem/element-picker.yaml + - docs/reqstream/sysml2-workbench/element-picker-subsystem/element-filter.yaml - docs/reqstream/sysml2-workbench/app-shell-subsystem.yaml - docs/reqstream/sysml2-workbench/app-shell-subsystem/main-window-shell.yaml - docs/reqstream/sysml2-workbench/app-shell-subsystem/workspace-panel.yaml - docs/reqstream/sysml2-workbench/app-shell-subsystem/about-dialog.yaml - docs/reqstream/sysml2-workbench/app-shell-subsystem/view-builder-dialog.yaml + - docs/reqstream/sysml2-workbench/app-shell-subsystem/query-dialog.yaml - docs/reqstream/ots/sysml2-tools.yaml - docs/reqstream/ots/rendering.yaml - docs/reqstream/ots/avalonia.yaml diff --git a/src/DemaConsulting.SysML2Workbench/App.axaml b/src/DemaConsulting.SysML2Workbench/App.axaml index 4f5eaee..a7067d9 100644 --- a/src/DemaConsulting.SysML2Workbench/App.axaml +++ b/src/DemaConsulting.SysML2Workbench/App.axaml @@ -18,6 +18,10 @@ control theme applied and renders as an invisible, unstyled control (see https://github.com/AvaloniaUI/AvaloniaEdit#how-to-set-up-a-new-project-that-displays-an-avaloniaedit-editor). --> + + + diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml index 201abf0..446e08d 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml @@ -52,6 +52,13 @@ + + + + + + + diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml.cs index 8059c1b..64697fd 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml.cs +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml.cs @@ -134,6 +134,19 @@ private async void OnOpenViewBuilderDialogClick(object? sender, RoutedEventArgs await dialog.ShowDialog(this); } + /// + /// Handles the Query menu's "Run Query..." click by opening the modal + /// , owned by this window. Like the View menu's Custom View Builder + /// entry, this opens a transient dialog (a fresh per open) rather + /// than showing/focusing a persistent Dock Tool: query results are one-shot, not a + /// long-lived workbench panel. + /// + private async void OnOpenQueryDialogClick(object? sender, RoutedEventArgs e) + { + var dialog = new QueryDialogView(_shell); + await dialog.ShowDialog(this); + } + /// /// Restores to its original dock if it was hidden by a prior close (via /// 's HideToolsOnClose setting, a safe no-op if it is not diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml new file mode 100644 index 0000000..da2f4c9 --- /dev/null +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml @@ -0,0 +1,112 @@ + + + + + + + - - - + - private void OnAddExposeTargetClick(object? sender, RoutedEventArgs e) { - if (AvailableExposeTargetsListBox.SelectedItem is not string qualifiedName) + if (_viewModel.ExposeTargetPicker.SelectedQualifiedName is not { } qualifiedName) { return; } @@ -109,42 +106,6 @@ private void OnPreviewChanged(object? sender, EventArgs e) LoadPreviewImage(); } - /// - /// Populates from the view model's currently addable type - /// labels each time the "+" button's flyout is about to open, so the list always reflects the latest - /// workspace/active-filter state rather than a stale snapshot from construction time. - /// - private void OnAddExposeTypeFilterFlyoutOpening(object? sender, EventArgs e) - { - AddableExposeTypeFilterListBox.ItemsSource = _viewModel.GetAddableExposeTargetTypeLabels(); - } - - /// - /// Adds the selected type label as a new active filter chip and closes the flyout. - /// - private void OnAddableExposeTypeFilterSelectionChanged(object? sender, SelectionChangedEventArgs e) - { - if (AddableExposeTypeFilterListBox.SelectedItem is not string typeLabel) - { - return; - } - - _viewModel.AddExposeTypeFilter(typeLabel); - AddableExposeTypeFilterListBox.SelectedItem = null; - AddExposeTypeFilterButton.Flyout?.Hide(); - } - - /// - /// Removes the clicked chip's type label from the active filters. - /// - private void OnRemoveExposeTypeFilterClick(object? sender, RoutedEventArgs e) - { - if (sender is Button { Tag: string typeLabel }) - { - _viewModel.RemoveExposeTypeFilter(typeLabel); - } - } - /// /// Loads this dialog's currently rendered preview SVG into the on-screen image control. /// diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/ViewBuilderDialogViewModel.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/ViewBuilderDialogViewModel.cs index a6657fb..26fb3d7 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/ViewBuilderDialogViewModel.cs +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/ViewBuilderDialogViewModel.cs @@ -1,7 +1,6 @@ -using System.Collections.ObjectModel; -using System.Collections.Specialized; using CommunityToolkit.Mvvm.ComponentModel; using DemaConsulting.SysML2Tools.Semantic.Model; +using DemaConsulting.SysML2Workbench.ElementPickerSubsystem; using DemaConsulting.SysML2Workbench.LayoutRenderingSubsystem; using DemaConsulting.SysML2Workbench.ViewBuilderSubsystem; @@ -39,33 +38,12 @@ public sealed partial class ViewBuilderDialogViewModel : ObservableObject /// private const string DefaultTypeFilterLabel = "part"; - [ObservableProperty] - private IReadOnlyList _availableExposeTargets = []; - - [ObservableProperty] - private IReadOnlyList _availableExposeTargetTypeLabels = []; - - [ObservableProperty] - private string? _exposeTargetSearchText = ""; - - [ObservableProperty] - private IReadOnlyList _displayedExposeTargets = []; - [ObservableProperty] private string? _statusMessage; [ObservableProperty] private bool _isWorkspaceEmpty; - /// - /// Master, unfiltered list mapping each candidate expose target's qualified name to its computed - /// type label, in the same order (and over the same candidate set) as . - /// Rebuilt alongside it in , and consulted by - /// to apply the active type/text filters without - /// re-querying the workspace on every keystroke or chip change. - /// - private IReadOnlyList<(string QualifiedName, string TypeLabel)> _exposeTargetTypeLabels = []; - /// /// Creates the dialog view model, refreshing the available expose-target picker list from the shell's /// current workspace and rendering an initial (empty) preview. @@ -75,8 +53,7 @@ public ViewBuilderDialogViewModel(MainWindowShell shell) { Shell = shell ?? throw new ArgumentNullException(nameof(shell)); PreviewCanvas = new SvgCanvasHost(); - - ActiveExposeTypeFilters.CollectionChanged += OnActiveExposeTypeFiltersCollectionChanged; + ExposeTargetPicker = new ElementPickerViewModel(); RefreshFromWorkspace(); } @@ -101,21 +78,19 @@ public ViewBuilderDialogViewModel(MainWindowShell shell) public SvgCanvasHost PreviewCanvas { get; } /// - /// Raised after updates , so the view can reload - /// its on-screen preview Image from the new SVG. + /// The shared element-picker view model backing the "Expose Targets" tab's chip-row / search-text / + /// candidate-list controls. Populated once by with the same + /// stdlib-and-disallowed-node-kind-excluded candidate set the pre-refactor code produced; every subsequent + /// filter/search interaction runs through this instance's own OR/AND filter machinery (see + /// ). /// - public event EventHandler? PreviewChanged; + public ElementPickerViewModel ExposeTargetPicker { get; } /// - /// Type labels currently applied as chips over the "Expose Targets" picker, combined with OR semantics: - /// an item is shown when its type label is any one of these. An empty collection means no type - /// restriction is applied (every candidate's type is shown). Pre-populated with just - /// "part" by when that label is present in the current - /// workspace, since narrowing to part usages is the most common starting point; otherwise starts empty. - /// Mutated via / rather than - /// replaced wholesale, so the view's chip-row ItemsControl can bind to this instance directly. + /// Raised after updates , so the view can reload + /// its on-screen preview Image from the new SVG. /// - public ObservableCollection ActiveExposeTypeFilters { get; } = new(); + public event EventHandler? PreviewChanged; /// /// Refreshes the available expose-target picker list from current shell state. Called once at @@ -129,168 +104,16 @@ public void RefreshFromWorkspace() : Shell.CurrentWorkspace.Workspace.Declarations .Where(kvp => !Shell.CurrentWorkspace.Workspace.StdlibNames.Contains(kvp.Key)) .Where(kvp => !DisallowedExposeNodeTypes.Contains(kvp.Value.GetType())) - .Select(kvp => (QualifiedName: kvp.Key, TypeLabel: GetExposeTypeLabel(kvp.Value))) + .Select(kvp => (QualifiedName: kvp.Key, TypeLabel: ElementTypeLabeler.GetTypeLabel(kvp.Value))) .OrderBy(entry => entry.QualifiedName, StringComparer.Ordinal) .ToList(); - _exposeTargetTypeLabels = candidates; - AvailableExposeTargets = candidates.Select(entry => entry.QualifiedName).ToList(); - AvailableExposeTargetTypeLabels = candidates - .Select(entry => entry.TypeLabel) - .Distinct() - .OrderBy(label => label, StringComparer.Ordinal) - .ToList(); - IsWorkspaceEmpty = Shell.CurrentWorkspace.Sources.Count == 0; - ActiveExposeTypeFilters.Clear(); - if (AvailableExposeTargetTypeLabels.Contains(DefaultTypeFilterLabel)) - { - ActiveExposeTypeFilters.Add(DefaultTypeFilterLabel); - } - - RecomputeDisplayedExposeTargets(); - } - - /// - /// Computes the human-readable "type label" for a single candidate expose target's underlying - /// , mirroring the type-matching style already used by - /// : known node kinds map to a keyword-style label - /// (, , - /// or a fixed literal for kinds with no keyword of their own), and any other node kind not already - /// excluded from the picker falls back to a defensively derived label so it never silently disappears - /// from the list if a new node kind is added upstream. - /// - /// The candidate expose target's underlying node. - /// A short, lowercase, human-readable type label such as "part def" or "package". - private static string GetExposeTypeLabel(SysmlNode node) - { - return node switch - { - SysmlDefinitionNode definition => definition.DefinitionKeyword, - SysmlFeatureNode feature => feature.FeatureKeyword, - SysmlPackageNode => "package", - SysmlDependencyNode => "dependency", - SysmlSatisfyNode => "satisfy", - _ => GetFallbackExposeTypeLabel(node), - }; - } - - /// - /// Derives a defensive fallback type label for a node kind not otherwise recognized by - /// , by stripping a leading Sysml and/or trailing Node - /// from the node's runtime type name and lowercasing the result, so an unrecognized future node kind - /// still gets a reasonable label instead of crashing or vanishing from the picker. - /// - /// The candidate expose target's underlying node. - /// A best-effort, lowercase fallback type label derived from the node's runtime type name. - private static string GetFallbackExposeTypeLabel(SysmlNode node) - { - var name = node.GetType().Name; - - if (name.StartsWith("Sysml", StringComparison.Ordinal)) - { - name = name["Sysml".Length..]; - } - - if (name.EndsWith("Node", StringComparison.Ordinal)) - { - name = name[..^"Node".Length]; - } - - return name.ToLowerInvariant(); - } - - /// - /// Recomputes from the master - /// list by applying (OR semantics; empty means no type - /// restriction) and then (case-insensitive substring match, - /// applied with AND semantics against whatever the type filter already narrowed to). The master list is - /// already sorted ordinally by qualified name, so filtering alone preserves that order. - /// - private void RecomputeDisplayedExposeTargets() - { - IEnumerable<(string QualifiedName, string TypeLabel)> query = _exposeTargetTypeLabels; - - if (ActiveExposeTypeFilters.Count > 0) - { - query = query.Where(entry => ActiveExposeTypeFilters.Contains(entry.TypeLabel)); - } - - var searchText = ExposeTargetSearchText; - if (!string.IsNullOrEmpty(searchText)) - { - query = query.Where(entry => entry.QualifiedName.Contains(searchText, StringComparison.OrdinalIgnoreCase)); - } - - DisplayedExposeTargets = query.Select(entry => entry.QualifiedName).ToList(); - } - - /// - /// Computes the set of type labels available to add as a new filter chip: every label present in - /// that is not already active in - /// . Computed on demand rather than cached, since it is only - /// consulted when the view's "+" add-filter flyout is opened, at which point it always reflects the - /// current state of both inputs. - /// - /// Not backed by an auto-generated observable property; recomputed fresh on every call. - /// Type labels not currently applied as an active filter chip, in the same order as - /// . - public IReadOnlyList GetAddableExposeTargetTypeLabels() - { - return AvailableExposeTargetTypeLabels - .Where(label => !ActiveExposeTypeFilters.Contains(label)) - .ToList(); - } - - /// - /// Adds to if it is not already - /// present (no duplicate chips), then recomputes . - /// - /// Type label chip to add. - public void AddExposeTypeFilter(string typeLabel) - { - if (!ActiveExposeTypeFilters.Contains(typeLabel)) - { - ActiveExposeTypeFilters.Add(typeLabel); - } - - RecomputeDisplayedExposeTargets(); - } - - /// - /// Removes from if present, then - /// recomputes . A no-op when the label is not currently active. - /// - /// Type label chip to remove. - public void RemoveExposeTypeFilter(string typeLabel) - { - ActiveExposeTypeFilters.Remove(typeLabel); - - RecomputeDisplayedExposeTargets(); - } - - /// - /// CommunityToolkit.Mvvm-generated hook invoked whenever changes - /// (for example via the view's two-way-bound search TextBox), recomputing - /// so the picker updates live as the user types. - /// - /// The new search text value. - partial void OnExposeTargetSearchTextChanged(string? value) - { - RecomputeDisplayedExposeTargets(); - } - - /// - /// Handles external mutation of (beyond the - /// / methods, which already - /// recompute directly) by recomputing , since a plain - /// property does not itself participate in - /// CommunityToolkit.Mvvm's change notification. - /// - private void OnActiveExposeTypeFiltersCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) - { - RecomputeDisplayedExposeTargets(); + // Hand the caller-computed candidate list to the shared picker, applying the "part" + // default-chip rule the pre-refactor code applied inline; the picker itself owns the + // OR/AND filter recompute from here. + ExposeTargetPicker.SetCandidates(candidates, DefaultTypeFilterLabel); } /// diff --git a/src/DemaConsulting.SysML2Workbench/DemaConsulting.SysML2Workbench.csproj b/src/DemaConsulting.SysML2Workbench/DemaConsulting.SysML2Workbench.csproj index f0dc8ac..4239085 100644 --- a/src/DemaConsulting.SysML2Workbench/DemaConsulting.SysML2Workbench.csproj +++ b/src/DemaConsulting.SysML2Workbench/DemaConsulting.SysML2Workbench.csproj @@ -16,10 +16,11 @@ + - - - + + + diff --git a/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementFilterView.axaml b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementFilterView.axaml new file mode 100644 index 0000000..78e27ac --- /dev/null +++ b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementFilterView.axaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementFilterView.axaml.cs b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementFilterView.axaml.cs new file mode 100644 index 0000000..764d899 --- /dev/null +++ b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementFilterView.axaml.cs @@ -0,0 +1,88 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; + +namespace DemaConsulting.SysML2Workbench.ElementPickerSubsystem; + +/// +/// Thin Avalonia code-behind for the shared -backed +/// filter . Wires the "+" add-filter flyout, the addable-labels +/// ListBox's selection, and each chip's remove button to the view model methods +/// that already own the OR/AND filtering, dedupe, and no-op-on-absent semantics. +/// +/// +/// This control has no selection concept: it renders only the chip row and search box. +/// Callers that need a selectable candidate list on top of the same filtering behavior +/// should use / +/// instead, which composes an internally and embeds +/// this control for its chip-row/search markup. +/// +public partial class ElementFilterView : UserControl +{ + /// + /// Creates the filter view. The is supplied by + /// the parent through the standard DataContext binding, not by this constructor. + /// + public ElementFilterView() + { + InitializeComponent(); + + AddTypeFilterButton.Flyout!.Opened += OnAddTypeFilterFlyoutOpening; + AddableTypeFilterListBox.SelectionChanged += OnAddableTypeFilterSelectionChanged; + } + + /// + /// Returns the filter view model this control is currently bound to, or + /// when no compatible DataContext is set (for example + /// during design-time construction). Provided so parent dialogs can invoke view-model + /// methods (, + /// , and + /// ) from their own code-behind + /// without needing to know the control's internal children. + /// + public ElementFilterViewModel? ViewModel => DataContext as ElementFilterViewModel; + + /// + /// Populates from the view model's currently + /// addable type labels each time the "+" button's flyout is about to open, so the + /// list always reflects the latest workspace/active-filter state rather than a stale + /// snapshot from construction time. + /// + private void OnAddTypeFilterFlyoutOpening(object? sender, EventArgs e) + { + if (ViewModel is null) + { + return; + } + + AddableTypeFilterListBox.ItemsSource = ViewModel.GetAddableTypeLabels(); + } + + /// + /// Adds the selected type label as a new active filter chip and closes the flyout. + /// Also clears the flyout ListBox's own SelectedItem so re-opening it + /// starts with no highlighted row. + /// + private void OnAddableTypeFilterSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (ViewModel is null || AddableTypeFilterListBox.SelectedItem is not string typeLabel) + { + return; + } + + ViewModel.AddTypeFilter(typeLabel); + AddableTypeFilterListBox.SelectedItem = null; + AddTypeFilterButton.Flyout?.Hide(); + } + + /// + /// Removes the clicked chip's type label from the active filters. The chip's own + /// Tag carries the label so this handler stays generic across every chip. + /// + private void OnRemoveTypeFilterClick(object? sender, RoutedEventArgs e) + { + if (ViewModel is not null && sender is Button { Tag: string typeLabel }) + { + ViewModel.RemoveTypeFilter(typeLabel); + } + } +} diff --git a/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementFilterViewModel.cs b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementFilterViewModel.cs new file mode 100644 index 0000000..b2e0264 --- /dev/null +++ b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementFilterViewModel.cs @@ -0,0 +1,218 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace DemaConsulting.SysML2Workbench.ElementPickerSubsystem; + +/// +/// Reusable, dialog-agnostic view model backing a filter-only "Element Filter" control - a +/// chip-row of type-label filters (OR semantics) and a case-insensitive substring search text +/// box, with NO selection concept whatsoever. This is the pure filtering half of the +/// picker/filter split: composes an instance of this +/// class to add its own selection concept on top, and callers with no target-element concept +/// at all (for example the Query dialog's "List" Query Type) use an instance of this class +/// directly. +/// +/// +/// Extracted from so the filtering/chip-management logic +/// can be reused without dragging along a selection concept that some callers (the Query +/// dialog's "List" mode) have no use for and would otherwise silently ignore. The view model +/// is deliberately independent of ViewDefinitionModel, MainWindowShell, and any +/// workspace type: the caller is responsible for building the candidate list (typically by +/// mapping SysmlWorkspace.Declarations through +/// and applying any caller-owned exclusions such as stdlib names or unsupported node kinds) and +/// handing it to . Not thread-safe: all state (properties and +/// ) must be mutated from a single (typically UI) thread. +/// +public sealed partial class ElementFilterViewModel : ObservableObject +{ + /// + /// Master, unfiltered list mapping each candidate element's qualified name to its + /// computed type label, in the same sorted order as 's + /// pre-filter source. Rebuilt by and consulted by + /// so the OR/AND filter pass can re-narrow + /// without re-querying the caller for the master set on every keystroke or chip + /// change. + /// + private IReadOnlyList<(string QualifiedName, string TypeLabel)> _candidates = []; + + [ObservableProperty] + private IReadOnlyList _availableTypeLabels = []; + + [ObservableProperty] + private string? _searchText = ""; + + [ObservableProperty] + private IReadOnlyList _displayedItems = []; + + /// + /// Creates the filter view model in its empty initial state: no candidates, no active + /// type filters, empty search text, and an empty displayed list. Callers populate it + /// later by calling . + /// + public ElementFilterViewModel() + { + ActiveTypeFilters.CollectionChanged += OnActiveTypeFiltersCollectionChanged; + } + + /// + /// Type labels currently applied as chips over the filter, combined with OR semantics: + /// an item is shown when its type label is any one of these. An empty collection means + /// no type restriction is applied (every candidate's type is shown). Populated by + /// 's defaultTypeFilterLabel argument, and + /// subsequently mutated only via / + /// (or, defensively, any other direct mutation, which is also observed by the internal + /// collection-changed handler that re-runs ), so + /// the view's chip-row ItemsControl can bind to this instance directly. + /// + public ObservableCollection ActiveTypeFilters { get; } = []; + + /// + /// Replaces the filter's master candidate list with , + /// recomputes from that list, resets + /// to a single-chip default (or empty) per + /// , and recomputes + /// . There is no selection concept to clear here (unlike + /// ). + /// + /// + /// The full, unfiltered set of qualified-name / type-label pairs. Assumed to be + /// already sorted in whatever order the caller wants displayed; the filter preserves + /// that order in when filtering. Must not be + /// ; may be empty. + /// + /// + /// Optional type label to pre-populate with when + /// contains at least one entry using that label; when + /// , or when the label is absent from + /// , the filter starts with no active type filter + /// (every type shown). + /// + /// Thrown when is . + public void SetCandidates( + IReadOnlyList<(string QualifiedName, string TypeLabel)> candidates, + string? defaultTypeFilterLabel = null) + { + ArgumentNullException.ThrowIfNull(candidates); + + _candidates = candidates; + AvailableTypeLabels = candidates + .Select(entry => entry.TypeLabel) + .Distinct(StringComparer.Ordinal) + .OrderBy(label => label, StringComparer.Ordinal) + .ToList(); + + // Reset the chip row to its per-call default: the requested single-chip default when + // available, otherwise no restriction. Uses Clear+Add rather than replacing the + // collection instance so any view bound to ActiveTypeFilters keeps seeing the same + // ObservableCollection reference. + ActiveTypeFilters.Clear(); + if (defaultTypeFilterLabel is not null && AvailableTypeLabels.Contains(defaultTypeFilterLabel)) + { + ActiveTypeFilters.Add(defaultTypeFilterLabel); + } + + RecomputeDisplayedItems(); + } + + /// + /// Computes the set of type labels available to add as a new filter chip: every label + /// present in that is not already active in + /// . Computed on demand rather than cached, so a view + /// opening its "+" add-filter flyout always sees the current addable set. + /// + /// + /// Type labels not currently applied as an active filter chip, in the same order as + /// . + /// + public IReadOnlyList GetAddableTypeLabels() + { + return AvailableTypeLabels + .Where(label => !ActiveTypeFilters.Contains(label)) + .ToList(); + } + + /// + /// Adds to if it is not + /// already present (no duplicate chips), then recomputes . + /// A no-op beyond the recompute when the label is already active. + /// + /// Type label chip to add. Must not be . + /// Thrown when is . + public void AddTypeFilter(string typeLabel) + { + ArgumentNullException.ThrowIfNull(typeLabel); + + if (!ActiveTypeFilters.Contains(typeLabel)) + { + ActiveTypeFilters.Add(typeLabel); + } + + RecomputeDisplayedItems(); + } + + /// + /// Removes from if + /// present, then recomputes . A no-op beyond the + /// recompute when the label is not currently active. + /// + /// Type label chip to remove. Must not be . + /// Thrown when is . + public void RemoveTypeFilter(string typeLabel) + { + ArgumentNullException.ThrowIfNull(typeLabel); + + ActiveTypeFilters.Remove(typeLabel); + + RecomputeDisplayedItems(); + } + + /// + /// Recomputes from the master + /// list by applying (OR semantics; empty means no + /// type restriction) and then (case-insensitive substring + /// match, applied with AND semantics against whatever the type filter already narrowed + /// to). The master list's order is preserved. + /// + private void RecomputeDisplayedItems() + { + IEnumerable<(string QualifiedName, string TypeLabel)> query = _candidates; + + if (ActiveTypeFilters.Count > 0) + { + query = query.Where(entry => ActiveTypeFilters.Contains(entry.TypeLabel)); + } + + var searchText = SearchText; + if (!string.IsNullOrEmpty(searchText)) + { + query = query.Where(entry => entry.QualifiedName.Contains(searchText, StringComparison.OrdinalIgnoreCase)); + } + + DisplayedItems = query.Select(entry => entry.QualifiedName).ToList(); + } + + /// + /// CommunityToolkit.Mvvm-generated hook invoked whenever + /// changes (for example via the view's two-way-bound search TextBox), + /// recomputing so the filter updates live as the user + /// types. + /// + /// The new search text value. + partial void OnSearchTextChanged(string? value) + { + RecomputeDisplayedItems(); + } + + /// + /// Handles external mutation of (beyond the + /// / methods, which already + /// recompute directly) by recomputing , since a plain + /// does not itself participate in + /// CommunityToolkit.Mvvm's change notification. + /// + private void OnActiveTypeFiltersCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + RecomputeDisplayedItems(); + } +} diff --git a/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerView.axaml b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerView.axaml new file mode 100644 index 0000000..5ce6ff6 --- /dev/null +++ b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerView.axaml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerView.axaml.cs b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerView.axaml.cs new file mode 100644 index 0000000..abbb06a --- /dev/null +++ b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerView.axaml.cs @@ -0,0 +1,38 @@ +using Avalonia.Controls; + +namespace DemaConsulting.SysML2Workbench.ElementPickerSubsystem; + +/// +/// Thin Avalonia code-behind for the shared -backed +/// picker . The chip-row/search flyout wiring now lives entirely +/// in , which this control embeds (bound to +/// ) for that markup; this class only needs to +/// surface a convenience accessor to its bound view model for any hosting parent that +/// wants to reach view-model methods without walking this control's named children. +/// +/// +/// The candidate ListBox's SelectedItem is bound directly to +/// via XAML two-way binding, +/// so parent dialogs read the current selection via that property rather than reaching +/// into this view's named children. +/// +public partial class ElementPickerView : UserControl +{ + /// + /// Creates the picker view. The is supplied by + /// the parent through the standard DataContext binding, not by this constructor. + /// + public ElementPickerView() + { + InitializeComponent(); + } + + /// + /// Returns the picker view model this control is currently bound to, or + /// when no compatible DataContext is set (for example + /// during design-time construction). Provided so parent dialogs can invoke view-model + /// methods from their own code-behind without needing to know the control's internal + /// children. + /// + public ElementPickerViewModel? ViewModel => DataContext as ElementPickerViewModel; +} diff --git a/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerViewModel.cs b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerViewModel.cs new file mode 100644 index 0000000..cef364d --- /dev/null +++ b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerViewModel.cs @@ -0,0 +1,178 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace DemaConsulting.SysML2Workbench.ElementPickerSubsystem; + +/// +/// Reusable, dialog-agnostic view model backing the shared "Element Picker" control - a +/// chip-row of type-label filters (OR semantics), a case-insensitive substring search +/// text box, and a single-select list of candidate qualified names. +/// +/// +/// Extracted from the pre-refactor ViewBuilderDialogViewModel's expose-target +/// picker so more than one dialog (Custom View Builder, Query dialog) can host the same +/// picker without duplicating the OR-then-AND filtering logic and chip management. This +/// view model composes an instance () +/// to own the filtering/chip-management logic itself, and adds only its own +/// selection concept on top; every filtering-related +/// public member (, , +/// , , +/// , , +/// ) is a thin pass-through to , so +/// existing callers and bindings see no change to this class's public API. The view +/// model is deliberately independent of ViewDefinitionModel, MainWindowShell, +/// and any workspace type: the caller is responsible for building the candidate list +/// (typically by mapping SysmlWorkspace.Declarations through +/// and applying any caller-owned +/// exclusions such as stdlib names or unsupported node kinds) and handing it to +/// . Not thread-safe: all state (properties and +/// ) must be mutated from a single (typically UI) thread. +/// +public sealed partial class ElementPickerViewModel : ObservableObject +{ + [ObservableProperty] + private string? _selectedQualifiedName; + + /// + /// Creates the picker view model in its empty initial state: no candidates, no active + /// type filters, empty search text, an empty displayed list, and no selection. + /// Callers populate it later by calling . + /// + public ElementPickerViewModel() + { + Filter = new ElementFilterViewModel(); + Filter.PropertyChanged += OnFilterPropertyChanged; + } + + /// + /// The composed, selection-free filter view model that actually owns the candidate + /// list, chip management, and search-text filtering. Exposed so a hosting + /// can embed an bound + /// directly to this instance for its chip-row/search markup, and so callers that need + /// only the filter (no selection) - such as the Query dialog's "List" Query Type - can + /// use a standalone instead of this class. + /// + public ElementFilterViewModel Filter { get; } + + /// + /// Pass-through to 's . + /// + public IReadOnlyList AvailableTypeLabels => Filter.AvailableTypeLabels; + + /// + /// Pass-through to 's . + /// A manual property (rather than [ObservableProperty]) because it must forward + /// both reads and writes to rather than backing its own field. + /// + public string? SearchText + { + get => Filter.SearchText; + set => Filter.SearchText = value; + } + + /// + /// Pass-through to 's . + /// + public IReadOnlyList DisplayedItems => Filter.DisplayedItems; + + /// + /// Pass-through to 's . + /// Returns the same instance every time, so a + /// view bound to this property observes the same collection-changed notifications as + /// one bound directly to . + /// + public ObservableCollection ActiveTypeFilters => Filter.ActiveTypeFilters; + + /// + /// Replaces the picker's master candidate list with by + /// forwarding to 's , + /// then clears so a stale prior selection cannot + /// linger after a workspace-derived refresh. + /// + /// + /// The full, unfiltered set of qualified-name / type-label pairs. Assumed to be + /// already sorted in whatever order the caller wants displayed; the picker preserves + /// that order in when filtering. Must not be + /// ; may be empty. + /// + /// + /// Optional type label to pre-populate with when + /// contains at least one entry using that label; when + /// , or when the label is absent from + /// , the picker starts with no active type filter + /// (every type shown). + /// + /// Thrown when is . + public void SetCandidates( + IReadOnlyList<(string QualifiedName, string TypeLabel)> candidates, + string? defaultTypeFilterLabel = null) + { + Filter.SetCandidates(candidates, defaultTypeFilterLabel); + + // A candidate replacement invalidates any previously-highlighted qualified name; clear + // the selection so a caller reading SelectedQualifiedName immediately after + // SetCandidates never sees a name that is no longer in the picker. + SelectedQualifiedName = null; + } + + /// + /// Pass-through to 's . + /// + /// + /// Type labels not currently applied as an active filter chip, in the same order as + /// . + /// + public IReadOnlyList GetAddableTypeLabels() + { + return Filter.GetAddableTypeLabels(); + } + + /// + /// Pass-through to 's . + /// + /// Type label chip to add. Must not be . + /// Thrown when is . + public void AddTypeFilter(string typeLabel) + { + Filter.AddTypeFilter(typeLabel); + } + + /// + /// Pass-through to 's . + /// + /// Type label chip to remove. Must not be . + /// Thrown when is . + public void RemoveTypeFilter(string typeLabel) + { + Filter.RemoveTypeFilter(typeLabel); + } + + /// + /// Re-raises this view model's own PropertyChanged notification whenever + /// reports a change to one of the pass-through properties this + /// class exposes, so code/bindings observing + /// directly (rather than ) still see live updates. No re-raise is + /// needed for : the same + /// reference is returned by both this class and , so its own + /// CollectionChanged event already fires for any bound view/code. + /// + /// The instance raising the notification. + /// The changed property's event arguments. + private void OnFilterPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(ElementFilterViewModel.DisplayedItems): + OnPropertyChanged(nameof(DisplayedItems)); + break; + + case nameof(ElementFilterViewModel.AvailableTypeLabels): + OnPropertyChanged(nameof(AvailableTypeLabels)); + break; + + case nameof(ElementFilterViewModel.SearchText): + OnPropertyChanged(nameof(SearchText)); + break; + } + } +} diff --git a/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementTypeLabeler.cs b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementTypeLabeler.cs new file mode 100644 index 0000000..268c421 --- /dev/null +++ b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementTypeLabeler.cs @@ -0,0 +1,89 @@ +using DemaConsulting.SysML2Tools.Semantic.Model; + +namespace DemaConsulting.SysML2Workbench.ElementPickerSubsystem; + +/// +/// Shared, static helper that maps a to a short, human-readable +/// "type label" used by 's type-filter chips (for +/// example, "part def", "part", "package", "view"). +/// +/// +/// Extracted from ViewBuilderDialogViewModel's previous private +/// GetExposeTypeLabel/GetFallbackExposeTypeLabel pair and generalized so +/// any dialog exposing the element picker (Custom View Builder, Query dialog) can compute +/// the same, consistent label for the same node. The full-taxonomy switch table unions +/// what ViewBuilderDialogViewModel already covered with the additional node kinds +/// DemaConsulting.SysML2Tools.Query.QueryEngine.DescribeKind covers, so a picker +/// restricted to nothing (like the Query dialog's) still shows a meaningful label for +/// every node kind that can appear in SysmlWorkspace.Declarations. Kept as a plain +/// static class (rather than a virtual/injectable seam) because the mapping is a pure +/// data-lookup with no external dependencies and no reason to differ between callers. +/// Thread-safe: no shared state. +/// +public static class ElementTypeLabeler +{ + /// + /// Computes the human-readable "type label" for a single candidate element's + /// underlying . Known node kinds map to a keyword-style label + /// (, + /// , + /// , or a fixed literal for kinds + /// with no keyword of their own), and any other node kind falls back to a defensively + /// derived label so a future/unrecognized subtype still gets a reasonable label rather + /// than a raw Sysml...Node class name. + /// + /// The candidate element's underlying node. Must not be . + /// + /// A short, lowercase, human-readable type label such as "part def", + /// "part", "package", or "view". + /// + /// Thrown when is . + public static string GetTypeLabel(SysmlNode node) + { + ArgumentNullException.ThrowIfNull(node); + + // Dispatch by concrete subtype in the order that matters for correctness: definition/ + // feature/connection each carry their own keyword text, so consult that first; the + // remaining subtypes have no keyword-like member and map to a fixed literal each. + return node switch + { + SysmlDefinitionNode definition => definition.DefinitionKeyword, + SysmlFeatureNode feature => feature.FeatureKeyword, + SysmlConnectionNode connection => connection.ConnectionKeyword, + SysmlImportNode => "import", + SysmlPackageNode => "package", + SysmlViewNode => "view", + SysmlViewpointNode => "viewpoint", + SysmlTransitionNode => "transition", + SysmlSatisfyNode => "satisfy", + SysmlDependencyNode => "dependency", + _ => GetFallbackTypeLabel(node), + }; + } + + /// + /// Derives a defensive fallback type label for a node kind not otherwise recognized by + /// , by stripping a leading Sysml and/or trailing + /// Node from the node's runtime type name and lowercasing the result, so an + /// unrecognized future node kind still gets a reasonable label instead of a raw class + /// name or crashing. + /// + /// The candidate element's underlying node. + /// A best-effort, lowercase fallback type label derived from the node's runtime type name. + private static string GetFallbackTypeLabel(SysmlNode node) + { + var name = node.GetType().Name; + + if (name.StartsWith("Sysml", StringComparison.Ordinal)) + { + name = name["Sysml".Length..]; + } + + if (name.EndsWith("Node", StringComparison.Ordinal)) + { + name = name[..^"Node".Length]; + } + + return name.ToLowerInvariant(); + } +} diff --git a/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs new file mode 100644 index 0000000..e4544cb --- /dev/null +++ b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs @@ -0,0 +1,629 @@ +using DemaConsulting.SysML2Tools.Query; +using DemaConsulting.SysML2Tools.Semantic.Model; +using DemaConsulting.SysML2Workbench.AppShellSubsystem; +using DemaConsulting.SysML2Workbench.DiagnosticsPanelSubsystem; +using DemaConsulting.SysML2Workbench.ElementPickerSubsystem; +using DemaConsulting.SysML2Workbench.LayoutRenderingSubsystem; +using DemaConsulting.SysML2Workbench.LoggingSubsystem; +using DemaConsulting.SysML2Workbench.ViewBuilderSubsystem; +using DemaConsulting.SysML2Workbench.ViewCatalogSubsystem; +using DemaConsulting.SysML2Workbench.WorkspaceSubsystem; + +namespace DemaConsulting.SysML2Workbench.Tests.AppShellSubsystem; + +/// +/// Unit tests for : the single form's Query-Type-driven auto +/// recompute contract (List's purely-client-side result, the ten element-scoped verbs' dispatch +/// through ), verb-specific option construction, and graceful reporting of +/// every recoverable failure mode through - all +/// without any explicit "Run" method call, since the redesign recomputes on every relevant property +/// change. +/// +public sealed class QueryDialogViewModelTests : IDisposable +{ + private readonly string _tempRoot = Directory.CreateTempSubdirectory("sysml2workbench-tests-").FullName; + private readonly string _tempLogRoot = Directory.CreateTempSubdirectory("sysml2workbench-tests-logs-").FullName; + + /// + public void Dispose() + { + if (Directory.Exists(_tempRoot)) + { + Directory.Delete(_tempRoot, recursive: true); + } + + if (Directory.Exists(_tempLogRoot)) + { + Directory.Delete(_tempLogRoot, recursive: true); + } + } + + /// + /// Writes a small sample workspace with a package containing a part def, a part usage, and a + /// nested package - enough distinct kinds for the picker and every element-scoped verb. + /// + private async Task WriteSampleWorkspaceAsync() + { + await File.WriteAllTextAsync( + Path.Combine(_tempRoot, "Sample.sysml"), + "package Sample {\n" + + " part def Engine;\n" + + " part engineInstance : Engine;\n" + + " package Nested;\n" + + "}\n", + TestContext.Current.CancellationToken); + } + + private MainWindowShell CreateShell() + { + return new MainWindowShell( + new WorkspaceModel(), + new FileWatcher(TimeSpan.FromMilliseconds(1)), + new DiagnosticsAggregator(), + new ViewCatalogPresenter(), + new LayoutInvoker(), + new DiagnosticsListView(), + new SysmlSnippetGenerator(), + new RollingFileLogger(_tempLogRoot)); + } + + /// + /// Fake clipboard service capturing writes in-memory so + /// and can be exercised without any live + /// Avalonia / OS clipboard - the same seam pattern the existing DiagramDocumentViewModelTests + /// uses. + /// + private sealed class FakeClipboardService : IClipboardService + { + public string? LastText { get; private set; } + + public Task SetTextAsync(string text) + { + LastText = text; + return Task.CompletedTask; + } + } + + /// + /// Validates the initial dialog state over an empty shell (no workspace open): no candidates in + /// the picker, is , + /// and the default Query Type () still produces a (empty) client-side + /// result rather than . + /// + [Fact] + public void QueryDialogViewModel_Construction_EmptyShell_ReportsWorkspaceEmpty() + { + // Arrange + using var shell = CreateShell(); + + // Act + var viewModel = new QueryDialogViewModel(shell); + + // Assert + Assert.True(viewModel.IsWorkspaceEmpty); + Assert.Equal(QueryVerb.List, viewModel.SelectedQueryType); + Assert.Empty(viewModel.FilterOnly.DisplayedItems); + Assert.NotNull(viewModel.CurrentResult); // BuildListResult fires unconditionally + Assert.Equal("list", viewModel.CurrentResult!.Verb); + Assert.Null(viewModel.StatusMessage); + } + + /// + /// Validates that both and + /// populate from a loaded workspace, exclude stdlib + /// names by default, and start with no default type-filter chip (so every candidate shows + /// immediately) - both instances share the same candidate list from + /// . + /// + [Fact] + public async Task QueryDialogViewModel_Construction_LoadedWorkspace_PopulatesSinglePicker() + { + // Arrange + await WriteSampleWorkspaceAsync(); + using var shell = CreateShell(); + await shell.AddFolderSourceAsync(_tempRoot); + + // Act + var viewModel = new QueryDialogViewModel(shell); + + // Assert + Assert.False(viewModel.IsWorkspaceEmpty); + Assert.False(viewModel.IncludeStdlib); + Assert.Empty(viewModel.Picker.ActiveTypeFilters); + Assert.Contains("Sample::Engine", viewModel.Picker.DisplayedItems); + Assert.Contains("Sample::engineInstance", viewModel.Picker.DisplayedItems); + Assert.Empty(viewModel.FilterOnly.ActiveTypeFilters); + Assert.Contains("Sample::Engine", viewModel.FilterOnly.DisplayedItems); + Assert.Contains("Sample::engineInstance", viewModel.FilterOnly.DisplayedItems); + } + + /// + /// Validates that the "List" Query Type's is a + /// client-built with equal to + /// "list", no , a count-summary line, and one entry per + /// displayed picker item (kind label sourced from the same candidate map). + /// + [Fact] + public async Task QueryDialogViewModel_ListQueryType_BuildsClientSideListResult() + { + // Arrange + await WriteSampleWorkspaceAsync(); + using var shell = CreateShell(); + await shell.AddFolderSourceAsync(_tempRoot); + var viewModel = new QueryDialogViewModel(shell); + + // Act + var result = viewModel.CurrentResult; + + // Assert + Assert.NotNull(result); + Assert.Equal("list", result!.Verb); + Assert.Null(result.Element); + Assert.Single(result.Summary); + Assert.Contains("element(s) match the filter", result.Summary[0]); + Assert.Equal(viewModel.FilterOnly.DisplayedItems.Count, result.Entries.Count); + Assert.Contains(result.Entries, e => e.QualifiedName == "Sample::Engine" && e.Kind == "part def"); + Assert.Contains(result.Entries, e => e.QualifiedName == "Sample::engineInstance" && e.Kind == "part"); + } + + /// + /// Validates that as the picker narrows (via a search text edit here) while "List" is selected, + /// the result regenerates automatically - the redesign's "live, no Run gesture" contract. + /// + [Fact] + public async Task QueryDialogViewModel_ListQueryType_SearchTextEdit_RegeneratesResultLive() + { + // Arrange + await WriteSampleWorkspaceAsync(); + using var shell = CreateShell(); + await shell.AddFolderSourceAsync(_tempRoot); + var viewModel = new QueryDialogViewModel(shell); + + // Act + viewModel.FilterOnly.SearchText = "engineInstance"; + + // Assert + Assert.NotNull(viewModel.CurrentResult); + Assert.Single(viewModel.CurrentResult!.Entries); + Assert.Equal("Sample::engineInstance", viewModel.CurrentResult.Entries[0].QualifiedName); + } + + /// + /// Validates that selecting an element-scoped Query Type with no picker selection reports a + /// helpful (non-error) prompt naming the Query Type, and clears any stale prior result rather + /// than leaving it on screen. + /// + [Fact] + public async Task QueryDialogViewModel_RecomputeResult_ElementScopedVerbNoSelection_ReportsPromptAndClearsRows() + { + // Arrange + await WriteSampleWorkspaceAsync(); + using var shell = CreateShell(); + await shell.AddFolderSourceAsync(_tempRoot); + var viewModel = new QueryDialogViewModel(shell); + + // Act: switch to Describe with no selection on the picker + viewModel.SelectedQueryType = QueryVerb.Describe; + + // Assert + Assert.NotNull(viewModel.StatusMessage); + Assert.Contains("Select an element above", viewModel.StatusMessage); + Assert.Contains("Describe", viewModel.StatusMessage); + Assert.Null(viewModel.CurrentResult); + Assert.Empty(viewModel.CurrentResultRows); + } + + /// + /// Validates that selecting an element-scoped Query Type over an empty workspace stops early with + /// a user-visible status, without touching . + /// + [Fact] + public void QueryDialogViewModel_RecomputeResult_EmptyWorkspace_ReportsStatusMessage() + { + // Arrange + using var shell = CreateShell(); + var viewModel = new QueryDialogViewModel(shell); + + // Act + viewModel.SelectedQueryType = QueryVerb.Describe; + + // Assert + Assert.NotNull(viewModel.StatusMessage); + Assert.Contains("No workspace", viewModel.StatusMessage); + Assert.Null(viewModel.CurrentResult); + } + + /// + /// Validates that selecting Describe and then setting the picker's selected qualified name + /// dispatches through immediately, with no intervening method + /// call - proving the auto-recompute contract at the heart of this redesign. + /// + [Fact] + public async Task QueryDialogViewModel_RecomputeResult_DescribeWithSelection_DispatchesThroughEngineImmediately() + { + // Arrange + await WriteSampleWorkspaceAsync(); + using var shell = CreateShell(); + await shell.AddFolderSourceAsync(_tempRoot); + var viewModel = new QueryDialogViewModel(shell); + viewModel.SelectedQueryType = QueryVerb.Describe; + + // Act: the assignment itself is the trigger under test - no Run call exists in this design. + viewModel.Picker.SelectedQualifiedName = "Sample::Engine"; + + // Assert + Assert.NotNull(viewModel.CurrentResult); + Assert.Equal("describe", viewModel.CurrentResult!.Verb); + Assert.Equal("Sample::Engine", viewModel.CurrentResult.Element); + Assert.Null(viewModel.StatusMessage); + } + + /// + /// Validates that switching from Describe (with a selection) to List immediately shows the + /// client-side list result, proving Query Type switches recompute without stale state from the + /// previous verb. + /// + [Fact] + public async Task QueryDialogViewModel_SwitchingQueryType_FromDescribeToList_ShowsListResultImmediately() + { + // Arrange + await WriteSampleWorkspaceAsync(); + using var shell = CreateShell(); + await shell.AddFolderSourceAsync(_tempRoot); + var viewModel = new QueryDialogViewModel(shell); + viewModel.SelectedQueryType = QueryVerb.Describe; + viewModel.Picker.SelectedQualifiedName = "Sample::Engine"; + Assert.Equal("describe", viewModel.CurrentResult!.Verb); + + // Act + viewModel.SelectedQueryType = QueryVerb.List; + + // Assert + Assert.NotNull(viewModel.CurrentResult); + Assert.Equal("list", viewModel.CurrentResult!.Verb); + Assert.Null(viewModel.StatusMessage); + } + + /// + /// Validates that switching from the default List Query Type (which always has a result) to + /// Describe with no selection shows the "select an element" prompt rather than a stale List + /// result or a thrown exception. + /// + [Fact] + public async Task QueryDialogViewModel_SwitchingQueryType_FromListToDescribeNoSelection_ShowsSelectPrompt() + { + // Arrange + await WriteSampleWorkspaceAsync(); + using var shell = CreateShell(); + await shell.AddFolderSourceAsync(_tempRoot); + var viewModel = new QueryDialogViewModel(shell); + Assert.NotNull(viewModel.CurrentResult); // List's default result + + // Act + viewModel.SelectedQueryType = QueryVerb.Describe; + + // Assert + Assert.Null(viewModel.CurrentResult); + Assert.NotNull(viewModel.StatusMessage); + Assert.Contains("Select an element above", viewModel.StatusMessage); + } + + /// + /// Validates that changing while + /// is selected with an active selection recomputes immediately, + /// without requiring any manual call. + /// + [Fact] + public async Task QueryDialogViewModel_HierarchyDirectionChange_WithSelection_RecomputesImmediately() + { + // Arrange + await WriteSampleWorkspaceAsync(); + using var shell = CreateShell(); + await shell.AddFolderSourceAsync(_tempRoot); + var viewModel = new QueryDialogViewModel(shell); + viewModel.SelectedQueryType = QueryVerb.Hierarchy; + viewModel.Picker.SelectedQualifiedName = "Sample::Engine"; + Assert.NotNull(viewModel.CurrentResult); + + // Act + viewModel.HierarchyDirection = "up"; + + // Assert: recomputed live with no stale state or thrown exception + Assert.Null(viewModel.StatusMessage); + Assert.NotNull(viewModel.CurrentResult); + Assert.Equal("hierarchy", viewModel.CurrentResult!.Verb); + } + + /// + /// Validates that editing while + /// is selected with an active selection recomputes immediately. + /// + [Fact] + public async Task QueryDialogViewModel_WalkDepthTextChange_WithSelection_RecomputesImmediately() + { + // Arrange + await WriteSampleWorkspaceAsync(); + using var shell = CreateShell(); + await shell.AddFolderSourceAsync(_tempRoot); + var viewModel = new QueryDialogViewModel(shell); + viewModel.SelectedQueryType = QueryVerb.Impact; + viewModel.Picker.SelectedQualifiedName = "Sample::Engine"; + Assert.NotNull(viewModel.CurrentResult); + + // Act + viewModel.WalkDepthText = "1"; + + // Assert + Assert.NotNull(viewModel.CurrentResult); + Assert.Equal("impact", viewModel.CurrentResult!.Verb); + Assert.Null(viewModel.StatusMessage); + } + + /// + /// Validates that only attaches + /// when the current Query Type is + /// , matching the plan's per-verb visibility rules. + /// + [Fact] + public void QueryDialogViewModel_BuildOptions_HierarchyVerb_AttachesDirection() + { + // Arrange + using var shell = CreateShell(); + var viewModel = new QueryDialogViewModel(shell); + viewModel.SelectedQueryType = QueryVerb.Hierarchy; + viewModel.HierarchyDirection = "up"; + + // Act + var options = viewModel.BuildOptions("Some::Element"); + + // Assert + Assert.Equal(QueryVerb.Hierarchy, options.Verb); + Assert.Equal("Some::Element", options.Element); + Assert.Equal("up", options.Direction); + } + + /// + /// Validates that omits + /// for every non-Hierarchy verb, even when + /// is set (the field simply doesn't apply). + /// + [Fact] + public void QueryDialogViewModel_BuildOptions_NonHierarchyVerb_OmitsDirection() + { + // Arrange + using var shell = CreateShell(); + var viewModel = new QueryDialogViewModel(shell); + viewModel.SelectedQueryType = QueryVerb.Describe; + viewModel.HierarchyDirection = "up"; + + // Act + var options = viewModel.BuildOptions("Some::Element"); + + // Assert + Assert.Null(options.Direction); + } + + /// + /// Validates that parses + /// as an integer only for + /// , and only when it parses cleanly. + /// + [Fact] + public void QueryDialogViewModel_BuildOptions_ImpactVerbWithWalkDepth_ParsesWalkDepth() + { + // Arrange + using var shell = CreateShell(); + var viewModel = new QueryDialogViewModel(shell); + viewModel.SelectedQueryType = QueryVerb.Impact; + viewModel.WalkDepthText = "3"; + + // Act + var options = viewModel.BuildOptions("Some::Element"); + + // Assert + Assert.Equal(QueryVerb.Impact, options.Verb); + Assert.Equal(3, options.WalkDepth); + } + + /// + /// Validates that non-numeric or blank leaves + /// null (the CLI's "unlimited" default), rather than + /// throwing. + /// + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-a-number")] + [InlineData("-1")] + public void QueryDialogViewModel_BuildOptions_ImpactVerbWithInvalidWalkDepth_LeavesNull(string? walkDepthText) + { + // Arrange + using var shell = CreateShell(); + var viewModel = new QueryDialogViewModel(shell); + viewModel.SelectedQueryType = QueryVerb.Impact; + viewModel.WalkDepthText = walkDepthText; + + // Act + var options = viewModel.BuildOptions("Some::Element"); + + // Assert + Assert.Null(options.WalkDepth); + } + + /// + /// Validates that flows through into + /// for every verb. + /// + [Fact] + public void QueryDialogViewModel_BuildOptions_PropagatesIncludeStdlib() + { + // Arrange + using var shell = CreateShell(); + var viewModel = new QueryDialogViewModel(shell); + viewModel.IncludeStdlib = true; + viewModel.SelectedQueryType = QueryVerb.Uses; + + // Act + var options = viewModel.BuildOptions("Some::Element"); + + // Assert + Assert.True(options.IncludeStdlib); + } + + /// + /// Validates that toggling recomputes both the + /// picker's candidate set (added/removed stdlib names) and the current result. Because + /// unconditionally clears + /// on every refresh (so a stale prior + /// selection can never linger past a workspace-derived refresh), the immediately-following + /// recompute correctly drops the Describe-tab's prior result and shows the "select an element" + /// prompt instead of silently leaving the old (now unselected) result on screen - proving the + /// toggle live-recomputes rather than leaving a stale from before the + /// toggle. Re-selecting after the toggle then confirms the whole + /// refresh-candidates-then-recompute pipeline still functions end to end. + /// + [Fact] + public async Task QueryDialogViewModel_IncludeStdlibToggle_RefreshesPickerAndRecomputesResult() + { + // Arrange + await WriteSampleWorkspaceAsync(); + using var shell = CreateShell(); + await shell.AddFolderSourceAsync(_tempRoot); + var viewModel = new QueryDialogViewModel(shell); + var initialCount = viewModel.Picker.DisplayedItems.Count; + + viewModel.SelectedQueryType = QueryVerb.Describe; + viewModel.Picker.SelectedQualifiedName = "Sample::Engine"; + Assert.NotNull(viewModel.CurrentResult); + + // Act - enable stdlib (adds the stdlib names); SetCandidates clears the prior selection, so the + // recompute correctly drops the stale Describe result rather than leaving it in place. + viewModel.IncludeStdlib = true; + var withStdlibCount = viewModel.Picker.DisplayedItems.Count; + + // Assert: candidate set grew (or stayed the same, if the sample workspace has no stdlib + // declarations reachable), and the result was actively recomputed to the no-selection prompt + // rather than left stale. + Assert.True(withStdlibCount >= initialCount); + Assert.Null(viewModel.CurrentResult); + Assert.NotNull(viewModel.StatusMessage); + Assert.Contains("Select an element above", viewModel.StatusMessage); + + // Act - reselect the element post-toggle, confirming the pipeline still recomputes correctly. + viewModel.Picker.SelectedQualifiedName = "Sample::Engine"; + Assert.NotNull(viewModel.CurrentResult); + Assert.Equal("describe", viewModel.CurrentResult!.Verb); + + // Act - toggle back + viewModel.IncludeStdlib = false; + + // Assert + Assert.Equal(initialCount, viewModel.Picker.DisplayedItems.Count); + } + + /// + /// Validates that writes the same + /// text produces (joined with newlines) into + /// the injected clipboard service. + /// + [Fact] + public async Task QueryDialogViewModel_CopyResultAsMarkdownAsync_WritesRenderedMarkdownToClipboard() + { + // Arrange + await WriteSampleWorkspaceAsync(); + using var shell = CreateShell(); + await shell.AddFolderSourceAsync(_tempRoot); + var viewModel = new QueryDialogViewModel(shell); + var clipboard = new FakeClipboardService(); + viewModel.ClipboardService = clipboard; + viewModel.SelectedQueryType = QueryVerb.Describe; + viewModel.Picker.SelectedQualifiedName = "Sample::Engine"; + + // Act + await viewModel.CopyResultAsMarkdownAsync(); + + // Assert + Assert.NotNull(clipboard.LastText); + var expected = string.Join("\n", QueryResultRenderer.RenderMarkdown(viewModel.CurrentResult!)); + Assert.Equal(expected, clipboard.LastText); + } + + /// + /// Validates that writes the same + /// text produces into the injected clipboard + /// service. + /// + [Fact] + public async Task QueryDialogViewModel_CopyResultAsJsonAsync_WritesRenderedJsonToClipboard() + { + // Arrange + await WriteSampleWorkspaceAsync(); + using var shell = CreateShell(); + await shell.AddFolderSourceAsync(_tempRoot); + var viewModel = new QueryDialogViewModel(shell); + var clipboard = new FakeClipboardService(); + viewModel.ClipboardService = clipboard; + viewModel.SelectedQueryType = QueryVerb.Describe; + viewModel.Picker.SelectedQualifiedName = "Sample::Engine"; + + // Act + await viewModel.CopyResultAsJsonAsync(); + + // Assert + Assert.NotNull(clipboard.LastText); + Assert.Equal(QueryResultRenderer.RenderJson(viewModel.CurrentResult!), clipboard.LastText); + } + + /// + /// Validates that the copy methods are a no-op (no exception, no clipboard write) when + /// is - exercised here + /// via the natural no-selection path (Describe with no picker selection) rather than a reflection + /// hack, which doubles as coverage for that path. + /// + [Fact] + public async Task QueryDialogViewModel_CopyMethods_NoResult_AreNoOps() + { + // Arrange + using var shell = CreateShell(); + var viewModel = new QueryDialogViewModel(shell); + viewModel.SelectedQueryType = QueryVerb.Describe; + Assert.Null(viewModel.CurrentResult); + var clipboard = new FakeClipboardService(); + viewModel.ClipboardService = clipboard; + + // Act + await viewModel.CopyResultAsMarkdownAsync(); + await viewModel.CopyResultAsJsonAsync(); + + // Assert + Assert.Null(clipboard.LastText); + } + + /// + /// Validates that exposes exactly the eleven + /// user-facing Query Type options, with first, and never includes + /// (which "List" always merges into, so the user never sees it). + /// + [Fact] + public void QueryDialogViewModel_QueryTypes_HasExpectedElevenEntries() + { + // Assert + Assert.Equal(11, QueryDialogViewModel.QueryTypes.Count); + Assert.Equal(QueryVerb.List, QueryDialogViewModel.QueryTypes[0]); + Assert.DoesNotContain(QueryVerb.Find, QueryDialogViewModel.QueryTypes); + Assert.Contains(QueryVerb.Describe, QueryDialogViewModel.QueryTypes); + Assert.Contains(QueryVerb.Hierarchy, QueryDialogViewModel.QueryTypes); + } + + /// + /// Validates that covers the three + /// hierarchy directions accepted by the underlying field. + /// + [Fact] + public void QueryDialogViewModel_HierarchyDirectionOptions_HasExpectedThree() + { + // Assert + Assert.Equal(new[] { "up", "down", "both" }, QueryDialogViewModel.HierarchyDirectionOptions); + } +} diff --git a/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/ViewBuilderDialogViewModelTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/ViewBuilderDialogViewModelTests.cs index cde1446..3a1c367 100644 --- a/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/ViewBuilderDialogViewModelTests.cs +++ b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/ViewBuilderDialogViewModelTests.cs @@ -117,8 +117,8 @@ public async Task Construction_RefreshesAvailableExposeTargetsFromWorkspace() // Assert Assert.False(viewModel.IsWorkspaceEmpty); - Assert.Contains("Sample::Engine", viewModel.AvailableExposeTargets); - Assert.Contains("Sample::Wheel", viewModel.AvailableExposeTargets); + Assert.Contains("Sample::Engine", viewModel.ExposeTargetPicker.DisplayedItems); + Assert.Contains("Sample::Wheel", viewModel.ExposeTargetPicker.DisplayedItems); } /// @@ -136,7 +136,7 @@ public void Construction_EmptyWorkspace_IsWorkspaceEmptyAndNoTargets() // Assert Assert.True(viewModel.IsWorkspaceEmpty); - Assert.Empty(viewModel.AvailableExposeTargets); + Assert.Empty(viewModel.ExposeTargetPicker.DisplayedItems); } /// @@ -475,13 +475,13 @@ public async Task RefreshFromWorkspace_ComputesTypeLabelsPerNodeKind() var viewModel = new ViewBuilderDialogViewModel(shell); // Assert - Assert.Contains("part def", viewModel.AvailableExposeTargetTypeLabels); - Assert.Contains("part", viewModel.AvailableExposeTargetTypeLabels); - Assert.Contains("package", viewModel.AvailableExposeTargetTypeLabels); + Assert.Contains("part def", viewModel.ExposeTargetPicker.AvailableTypeLabels); + Assert.Contains("part", viewModel.ExposeTargetPicker.AvailableTypeLabels); + Assert.Contains("package", viewModel.ExposeTargetPicker.AvailableTypeLabels); } /// - /// Validates that contains no + /// Validates that contains no /// duplicate labels and is sorted ordinally. /// [Fact] @@ -496,13 +496,13 @@ public async Task RefreshFromWorkspace_AvailableExposeTargetTypeLabels_IsDistinc var viewModel = new ViewBuilderDialogViewModel(shell); // Assert - var labels = viewModel.AvailableExposeTargetTypeLabels; + var labels = viewModel.ExposeTargetPicker.AvailableTypeLabels; Assert.Equal(labels.Distinct(), labels); Assert.Equal(labels.OrderBy(l => l, StringComparer.Ordinal), labels); } /// - /// Validates that defaults to a single + /// Validates that defaults to a single /// "part" chip when that label is present in the workspace. /// [Fact] @@ -517,11 +517,11 @@ public async Task Construction_PartLabelPresent_DefaultsActiveExposeTypeFiltersT var viewModel = new ViewBuilderDialogViewModel(shell); // Assert - Assert.Equal(["part"], viewModel.ActiveExposeTypeFilters); + Assert.Equal(["part"], viewModel.ExposeTargetPicker.ActiveTypeFilters); } /// - /// Validates that starts empty when the + /// Validates that starts empty when the /// workspace has no "part"-labeled elements, meaning no type restriction is applied by default. /// [Fact] @@ -536,13 +536,14 @@ public async Task Construction_PartLabelAbsent_ActiveExposeTypeFiltersStartsEmpt var viewModel = new ViewBuilderDialogViewModel(shell); // Assert - Assert.DoesNotContain("part", viewModel.AvailableExposeTargetTypeLabels); - Assert.Empty(viewModel.ActiveExposeTypeFilters); + Assert.DoesNotContain("part", viewModel.ExposeTargetPicker.AvailableTypeLabels); + Assert.Empty(viewModel.ExposeTargetPicker.ActiveTypeFilters); } /// - /// Validates that an empty collection - /// applies no type restriction, showing every candidate regardless of type label. + /// Validates that an empty + /// ActiveTypeFilters collection applies no type restriction, showing every candidate regardless of + /// type label. /// [Fact] public async Task DisplayedExposeTargets_EmptyChips_ShowsAllTypes() @@ -552,11 +553,13 @@ public async Task DisplayedExposeTargets_EmptyChips_ShowsAllTypes() using var shell = CreateShell(); await shell.AddFolderSourceAsync(_tempRoot); var viewModel = new ViewBuilderDialogViewModel(shell); - viewModel.RemoveExposeTypeFilter("part"); + viewModel.ExposeTargetPicker.RemoveTypeFilter("part"); // Act & Assert - Assert.Empty(viewModel.ActiveExposeTypeFilters); - Assert.Equal(viewModel.AvailableExposeTargets, viewModel.DisplayedExposeTargets); + Assert.Empty(viewModel.ExposeTargetPicker.ActiveTypeFilters); + Assert.Contains("MultiKind::Engine", viewModel.ExposeTargetPicker.DisplayedItems); + Assert.Contains("MultiKind::engineInstance", viewModel.ExposeTargetPicker.DisplayedItems); + Assert.Contains("MultiKind::SubPackage", viewModel.ExposeTargetPicker.DisplayedItems); } /// @@ -571,12 +574,12 @@ public async Task DisplayedExposeTargets_MultipleChips_AppliesOrSemantics() using var shell = CreateShell(); await shell.AddFolderSourceAsync(_tempRoot); var viewModel = new ViewBuilderDialogViewModel(shell); - viewModel.RemoveExposeTypeFilter("part"); - viewModel.AddExposeTypeFilter("part def"); - viewModel.AddExposeTypeFilter("package"); + viewModel.ExposeTargetPicker.RemoveTypeFilter("part"); + viewModel.ExposeTargetPicker.AddTypeFilter("part def"); + viewModel.ExposeTargetPicker.AddTypeFilter("package"); // Act - var displayed = viewModel.DisplayedExposeTargets; + var displayed = viewModel.ExposeTargetPicker.DisplayedItems; // Assert Assert.Contains("MultiKind::Engine", displayed); @@ -596,20 +599,20 @@ public async Task DisplayedExposeTargets_TypeFilterAndTextSearch_CombineWithAndS using var shell = CreateShell(); await shell.AddFolderSourceAsync(_tempRoot); var viewModel = new ViewBuilderDialogViewModel(shell); - viewModel.RemoveExposeTypeFilter("part"); - viewModel.AddExposeTypeFilter("part def"); - viewModel.AddExposeTypeFilter("package"); + viewModel.ExposeTargetPicker.RemoveTypeFilter("part"); + viewModel.ExposeTargetPicker.AddTypeFilter("part def"); + viewModel.ExposeTargetPicker.AddTypeFilter("package"); // Act: case-insensitive substring search further narrows the type-filtered set - viewModel.ExposeTargetSearchText = "engine"; + viewModel.ExposeTargetPicker.SearchText = "engine"; // Assert - Assert.Contains("MultiKind::Engine", viewModel.DisplayedExposeTargets); - Assert.DoesNotContain("MultiKind::SubPackage", viewModel.DisplayedExposeTargets); + Assert.Contains("MultiKind::Engine", viewModel.ExposeTargetPicker.DisplayedItems); + Assert.DoesNotContain("MultiKind::SubPackage", viewModel.ExposeTargetPicker.DisplayedItems); } /// - /// Validates that is dedupe-safe: adding the + /// Validates that is dedupe-safe: adding the /// same label twice results in only one chip. /// [Fact] @@ -622,15 +625,15 @@ public async Task AddExposeTypeFilter_DuplicateLabel_DoesNotAddSecondChip() var viewModel = new ViewBuilderDialogViewModel(shell); // Act - viewModel.AddExposeTypeFilter("package"); - viewModel.AddExposeTypeFilter("package"); + viewModel.ExposeTargetPicker.AddTypeFilter("package"); + viewModel.ExposeTargetPicker.AddTypeFilter("package"); // Assert - Assert.Single(viewModel.ActiveExposeTypeFilters, "package"); + Assert.Single(viewModel.ExposeTargetPicker.ActiveTypeFilters, "package"); } /// - /// Validates that removes an active + /// Validates that removes an active /// chip and recomputes the displayed list, and no-ops when the label is not currently active. /// [Fact] @@ -643,21 +646,21 @@ public async Task RemoveExposeTypeFilter_RemovesChipAndNoOpsWhenAbsent() var viewModel = new ViewBuilderDialogViewModel(shell); // Act: remove the default "part" chip - viewModel.RemoveExposeTypeFilter("part"); + viewModel.ExposeTargetPicker.RemoveTypeFilter("part"); // Assert - Assert.Empty(viewModel.ActiveExposeTypeFilters); + Assert.Empty(viewModel.ExposeTargetPicker.ActiveTypeFilters); // Act: removing an already-absent label is a no-op, not a throw - viewModel.RemoveExposeTypeFilter("not-a-real-label"); + viewModel.ExposeTargetPicker.RemoveTypeFilter("not-a-real-label"); // Assert - Assert.Empty(viewModel.ActiveExposeTypeFilters); + Assert.Empty(viewModel.ExposeTargetPicker.ActiveTypeFilters); } /// - /// Validates that setting live-recomputes - /// without requiring any other call. + /// Validates that setting live-recomputes + /// without requiring any other call. /// [Fact] public async Task ExposeTargetSearchTextChanged_RecomputesDisplayedExposeTargets() @@ -667,13 +670,13 @@ public async Task ExposeTargetSearchTextChanged_RecomputesDisplayedExposeTargets using var shell = CreateShell(); await shell.AddFolderSourceAsync(_tempRoot); var viewModel = new ViewBuilderDialogViewModel(shell); - viewModel.RemoveExposeTypeFilter("part"); + viewModel.ExposeTargetPicker.RemoveTypeFilter("part"); // Act - viewModel.ExposeTargetSearchText = "SubPackage"; + viewModel.ExposeTargetPicker.SearchText = "SubPackage"; // Assert - Assert.Equal(["MultiKind::SubPackage"], viewModel.DisplayedExposeTargets); + Assert.Equal(["MultiKind::SubPackage"], viewModel.ExposeTargetPicker.DisplayedItems); } } diff --git a/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementFilterViewModelTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementFilterViewModelTests.cs new file mode 100644 index 0000000..4722008 --- /dev/null +++ b/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementFilterViewModelTests.cs @@ -0,0 +1,295 @@ +using DemaConsulting.SysML2Workbench.ElementPickerSubsystem; + +namespace DemaConsulting.SysML2Workbench.Tests.ElementPickerSubsystem; + +/// +/// Unit-tests the reusable, selection-free in isolation, +/// without any MainWindowShell, workspace, or Avalonia dependency. Every scenario +/// constructs a small hand-built candidate list (qualified name + type label) so the expected +/// filtering semantics can be verified precisely. Mirrors +/// ElementPickerViewModelTests's style, minus any selection-related scenario since this +/// view model has no selection concept. +/// +public sealed class ElementFilterViewModelTests +{ + /// + /// A representative candidate list covering three distinct type labels ("part", + /// "part def", "package") so the OR-then-AND filter behavior can be exercised without + /// having to spin up a real workspace. + /// + private static readonly (string QualifiedName, string TypeLabel)[] MixedCandidates = + [ + ("Model::Engine", "part def"), + ("Model::Wheel", "part def"), + ("Model::engineInstance", "part"), + ("Model::wheelInstance", "part"), + ("Model::SubPackage", "package"), + ]; + + /// + /// Validates that a freshly-constructed filter has no candidates, no active filters, + /// an empty (non-null) search text, and an empty displayed list. + /// + [Fact] + public void ElementFilterViewModel_Construction_HasEmptyInitialState() + { + // Act + var filter = new ElementFilterViewModel(); + + // Assert + Assert.Empty(filter.AvailableTypeLabels); + Assert.Empty(filter.ActiveTypeFilters); + Assert.Empty(filter.DisplayedItems); + Assert.Equal(string.Empty, filter.SearchText); + } + + /// + /// Validates that throws + /// when handed a + /// candidate list, matching the runtime null-guard behavior. + /// + [Fact] + public void ElementFilterViewModel_SetCandidates_NullCandidates_Throws() + { + // Arrange + var filter = new ElementFilterViewModel(); + + // Act & Assert + Assert.Throws(() => filter.SetCandidates(null!)); + } + + /// + /// Validates that is + /// deduplicated and sorted ordinally after . + /// + [Fact] + public void ElementFilterViewModel_SetCandidates_AvailableTypeLabels_IsDistinctAndSorted() + { + // Arrange + var filter = new ElementFilterViewModel(); + + // Act + filter.SetCandidates(MixedCandidates); + + // Assert + Assert.Equal(new[] { "package", "part", "part def" }, filter.AvailableTypeLabels); + } + + /// + /// Validates that pre-populates + /// with the requested + /// defaultTypeFilterLabel when it exists in the candidates. + /// + [Fact] + public void ElementFilterViewModel_SetCandidates_DefaultLabelPresent_PrepopulatesChip() + { + // Arrange + var filter = new ElementFilterViewModel(); + + // Act + filter.SetCandidates(MixedCandidates, defaultTypeFilterLabel: "part"); + + // Assert + Assert.Equal(new[] { "part" }, filter.ActiveTypeFilters); + } + + /// + /// Validates that when the requested defaultTypeFilterLabel is absent from the + /// candidates, starts empty + /// (rather than adding a chip for a label that filters out every candidate). + /// + [Fact] + public void ElementFilterViewModel_SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty() + { + // Arrange + var filter = new ElementFilterViewModel(); + + // Act + filter.SetCandidates(MixedCandidates, defaultTypeFilterLabel: "not-a-real-label"); + + // Assert + Assert.Empty(filter.ActiveTypeFilters); + } + + /// + /// Validates that with the default "part" chip active, only the "part" candidates are + /// surfaced by . + /// + [Fact] + public void ElementFilterViewModel_DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages() + { + // Arrange + var filter = new ElementFilterViewModel(); + + // Act + filter.SetCandidates(MixedCandidates, defaultTypeFilterLabel: "part"); + + // Assert + Assert.Equal( + new[] { "Model::engineInstance", "Model::wheelInstance" }, + filter.DisplayedItems); + } + + /// + /// Validates that with no active chips, every candidate is displayed regardless of + /// type label. + /// + [Fact] + public void ElementFilterViewModel_DisplayedItems_NoChips_ShowsAllCandidates() + { + // Arrange + var filter = new ElementFilterViewModel(); + + // Act + filter.SetCandidates(MixedCandidates); + + // Assert + Assert.Equal(MixedCandidates.Length, filter.DisplayedItems.Count); + } + + /// + /// Validates the OR semantics of multiple chips: an item is shown when its type label + /// matches any of the active chips. + /// + [Fact] + public void ElementFilterViewModel_DisplayedItems_MultipleChips_AppliesOrSemantics() + { + // Arrange + var filter = new ElementFilterViewModel(); + filter.SetCandidates(MixedCandidates); + filter.AddTypeFilter("part def"); + filter.AddTypeFilter("package"); + + // Act + var displayed = filter.DisplayedItems; + + // Assert + Assert.Contains("Model::Engine", displayed); + Assert.Contains("Model::Wheel", displayed); + Assert.Contains("Model::SubPackage", displayed); + Assert.DoesNotContain("Model::engineInstance", displayed); + } + + /// + /// Validates that AND-combines with + /// any active chip filter: only items matching both the substring and the chip set are + /// displayed. + /// + [Fact] + public void ElementFilterViewModel_DisplayedItems_SearchText_AppliesAndSemanticsWithChips() + { + // Arrange + var filter = new ElementFilterViewModel(); + filter.SetCandidates(MixedCandidates); + filter.AddTypeFilter("part def"); + filter.AddTypeFilter("package"); + + // Act + filter.SearchText = "engine"; + + // Assert - "part def" chip matches Engine + Wheel; "package" chip matches SubPackage. + // After substring "engine", only Engine remains. + Assert.Equal(new[] { "Model::Engine" }, filter.DisplayedItems); + } + + /// + /// Validates that the search text is matched case-insensitively. + /// + [Fact] + public void ElementFilterViewModel_DisplayedItems_SearchText_IsCaseInsensitive() + { + // Arrange + var filter = new ElementFilterViewModel(); + filter.SetCandidates(MixedCandidates); + + // Act + filter.SearchText = "SUBPACKAGE"; + + // Assert + Assert.Equal(new[] { "Model::SubPackage" }, filter.DisplayedItems); + } + + /// + /// Validates that is dedupe-safe: + /// adding a label twice leaves only one chip. + /// + [Fact] + public void ElementFilterViewModel_AddTypeFilter_DuplicateLabel_KeepsSingleChip() + { + // Arrange + var filter = new ElementFilterViewModel(); + filter.SetCandidates(MixedCandidates); + + // Act + filter.AddTypeFilter("package"); + filter.AddTypeFilter("package"); + + // Assert + Assert.Single(filter.ActiveTypeFilters, "package"); + } + + /// + /// Validates that removes a + /// present chip and is a no-op (aside from the recompute) for absent labels. + /// + [Fact] + public void ElementFilterViewModel_RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully() + { + // Arrange + var filter = new ElementFilterViewModel(); + filter.SetCandidates(MixedCandidates, defaultTypeFilterLabel: "part"); + + // Act + filter.RemoveTypeFilter("part"); + + // Assert + Assert.Empty(filter.ActiveTypeFilters); + + // Act - removing something that never existed is a no-op + filter.RemoveTypeFilter("not-a-real-label"); + + // Assert + Assert.Empty(filter.ActiveTypeFilters); + } + + /// + /// Validates returns the + /// labels not currently active, preserving the master ordinal ordering. + /// + [Fact] + public void ElementFilterViewModel_GetAddableTypeLabels_ExcludesActiveChips() + { + // Arrange + var filter = new ElementFilterViewModel(); + filter.SetCandidates(MixedCandidates, defaultTypeFilterLabel: "part"); + + // Act + var addable = filter.GetAddableTypeLabels(); + + // Assert + Assert.Equal(new[] { "package", "part def" }, addable); + } + + /// + /// Validates that can be called + /// multiple times, and that the second call fully replaces the filter's state (chips + /// reset, displayed list recomputed). + /// + [Fact] + public void ElementFilterViewModel_SetCandidates_SecondCall_ReplacesState() + { + // Arrange + var filter = new ElementFilterViewModel(); + filter.SetCandidates(MixedCandidates, defaultTypeFilterLabel: "part"); + filter.SearchText = "engine"; + + // Act + filter.SearchText = null; + filter.SetCandidates([("Other::Foo", "other")], defaultTypeFilterLabel: null); + + // Assert + Assert.Equal(new[] { "other" }, filter.AvailableTypeLabels); + Assert.Empty(filter.ActiveTypeFilters); + Assert.Equal(new[] { "Other::Foo" }, filter.DisplayedItems); + } +} diff --git a/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementPickerViewModelTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementPickerViewModelTests.cs new file mode 100644 index 0000000..84f8b42 --- /dev/null +++ b/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementPickerViewModelTests.cs @@ -0,0 +1,314 @@ +using DemaConsulting.SysML2Workbench.ElementPickerSubsystem; + +namespace DemaConsulting.SysML2Workbench.Tests.ElementPickerSubsystem; + +/// +/// Unit-tests the reusable in isolation, without any +/// MainWindowShell, workspace, or Avalonia dependency. Every scenario constructs a +/// small hand-built candidate list (qualified name + type label) so the expected filtering +/// semantics can be verified precisely. +/// +public sealed class ElementPickerViewModelTests +{ + /// + /// A representative candidate list covering three distinct type labels ("part", + /// "part def", "package") so the OR-then-AND filter behavior can be exercised without + /// having to spin up a real workspace. + /// + private static readonly (string QualifiedName, string TypeLabel)[] MixedCandidates = + [ + ("Model::Engine", "part def"), + ("Model::Wheel", "part def"), + ("Model::engineInstance", "part"), + ("Model::wheelInstance", "part"), + ("Model::SubPackage", "package"), + ]; + + /// + /// Validates that a freshly-constructed picker has no candidates, no active filters, + /// an empty (non-null) search text, and an empty displayed list. + /// + [Fact] + public void ElementPickerViewModel_Construction_HasEmptyInitialState() + { + // Act + var picker = new ElementPickerViewModel(); + + // Assert + Assert.Empty(picker.AvailableTypeLabels); + Assert.Empty(picker.ActiveTypeFilters); + Assert.Empty(picker.DisplayedItems); + Assert.Equal(string.Empty, picker.SearchText); + Assert.Null(picker.SelectedQualifiedName); + } + + /// + /// Validates that throws + /// when handed a + /// candidate list, matching the runtime null-guard behavior. + /// + [Fact] + public void ElementPickerViewModel_SetCandidates_NullCandidates_Throws() + { + // Arrange + var picker = new ElementPickerViewModel(); + + // Act & Assert + Assert.Throws(() => picker.SetCandidates(null!)); + } + + /// + /// Validates that is + /// deduplicated and sorted ordinally after . + /// + [Fact] + public void ElementPickerViewModel_SetCandidates_AvailableTypeLabels_IsDistinctAndSorted() + { + // Arrange + var picker = new ElementPickerViewModel(); + + // Act + picker.SetCandidates(MixedCandidates); + + // Assert + Assert.Equal(new[] { "package", "part", "part def" }, picker.AvailableTypeLabels); + } + + /// + /// Validates that pre-populates + /// with the requested + /// defaultTypeFilterLabel when it exists in the candidates. + /// + [Fact] + public void ElementPickerViewModel_SetCandidates_DefaultLabelPresent_PrepopulatesChip() + { + // Arrange + var picker = new ElementPickerViewModel(); + + // Act + picker.SetCandidates(MixedCandidates, defaultTypeFilterLabel: "part"); + + // Assert + Assert.Equal(new[] { "part" }, picker.ActiveTypeFilters); + } + + /// + /// Validates that when the requested defaultTypeFilterLabel is absent from the + /// candidates, starts empty + /// (rather than adding a chip for a label that filters out every candidate). + /// + [Fact] + public void ElementPickerViewModel_SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty() + { + // Arrange + var picker = new ElementPickerViewModel(); + + // Act + picker.SetCandidates(MixedCandidates, defaultTypeFilterLabel: "not-a-real-label"); + + // Assert + Assert.Empty(picker.ActiveTypeFilters); + } + + /// + /// Validates that with the default "part" chip active, only the "part" candidates are + /// surfaced by . + /// + [Fact] + public void ElementPickerViewModel_DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages() + { + // Arrange + var picker = new ElementPickerViewModel(); + + // Act + picker.SetCandidates(MixedCandidates, defaultTypeFilterLabel: "part"); + + // Assert + Assert.Equal( + new[] { "Model::engineInstance", "Model::wheelInstance" }, + picker.DisplayedItems); + } + + /// + /// Validates that with no active chips, every candidate is displayed regardless of + /// type label. + /// + [Fact] + public void ElementPickerViewModel_DisplayedItems_NoChips_ShowsAllCandidates() + { + // Arrange + var picker = new ElementPickerViewModel(); + + // Act + picker.SetCandidates(MixedCandidates); + + // Assert + Assert.Equal(MixedCandidates.Length, picker.DisplayedItems.Count); + } + + /// + /// Validates the OR semantics of multiple chips: an item is shown when its type label + /// matches any of the active chips. + /// + [Fact] + public void ElementPickerViewModel_DisplayedItems_MultipleChips_AppliesOrSemantics() + { + // Arrange + var picker = new ElementPickerViewModel(); + picker.SetCandidates(MixedCandidates); + picker.AddTypeFilter("part def"); + picker.AddTypeFilter("package"); + + // Act + var displayed = picker.DisplayedItems; + + // Assert + Assert.Contains("Model::Engine", displayed); + Assert.Contains("Model::Wheel", displayed); + Assert.Contains("Model::SubPackage", displayed); + Assert.DoesNotContain("Model::engineInstance", displayed); + } + + /// + /// Validates that AND-combines with + /// any active chip filter: only items matching both the substring and the chip set are + /// displayed. + /// + [Fact] + public void ElementPickerViewModel_DisplayedItems_SearchText_AppliesAndSemanticsWithChips() + { + // Arrange + var picker = new ElementPickerViewModel(); + picker.SetCandidates(MixedCandidates); + picker.AddTypeFilter("part def"); + picker.AddTypeFilter("package"); + + // Act + picker.SearchText = "engine"; + + // Assert - "part def" chip matches Engine + Wheel; "package" chip matches SubPackage. + // After substring "engine", only Engine remains. + Assert.Equal(new[] { "Model::Engine" }, picker.DisplayedItems); + } + + /// + /// Validates that the search text is matched case-insensitively. + /// + [Fact] + public void ElementPickerViewModel_DisplayedItems_SearchText_IsCaseInsensitive() + { + // Arrange + var picker = new ElementPickerViewModel(); + picker.SetCandidates(MixedCandidates); + + // Act + picker.SearchText = "SUBPACKAGE"; + + // Assert + Assert.Equal(new[] { "Model::SubPackage" }, picker.DisplayedItems); + } + + /// + /// Validates that is dedupe-safe: + /// adding a label twice leaves only one chip. + /// + [Fact] + public void ElementPickerViewModel_AddTypeFilter_DuplicateLabel_KeepsSingleChip() + { + // Arrange + var picker = new ElementPickerViewModel(); + picker.SetCandidates(MixedCandidates); + + // Act + picker.AddTypeFilter("package"); + picker.AddTypeFilter("package"); + + // Assert + Assert.Single(picker.ActiveTypeFilters, "package"); + } + + /// + /// Validates that removes a + /// present chip and is a no-op (aside from the recompute) for absent labels. + /// + [Fact] + public void ElementPickerViewModel_RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully() + { + // Arrange + var picker = new ElementPickerViewModel(); + picker.SetCandidates(MixedCandidates, defaultTypeFilterLabel: "part"); + + // Act + picker.RemoveTypeFilter("part"); + + // Assert + Assert.Empty(picker.ActiveTypeFilters); + + // Act - removing something that never existed is a no-op + picker.RemoveTypeFilter("not-a-real-label"); + + // Assert + Assert.Empty(picker.ActiveTypeFilters); + } + + /// + /// Validates returns the + /// labels not currently active, preserving the master ordinal ordering. + /// + [Fact] + public void ElementPickerViewModel_GetAddableTypeLabels_ExcludesActiveChips() + { + // Arrange + var picker = new ElementPickerViewModel(); + picker.SetCandidates(MixedCandidates, defaultTypeFilterLabel: "part"); + + // Act + var addable = picker.GetAddableTypeLabels(); + + // Assert + Assert.Equal(new[] { "package", "part def" }, addable); + } + + /// + /// Validates that can be called + /// multiple times, and that the second call fully replaces the picker's state (chips + /// reset, prior selection cleared, displayed list recomputed). + /// + [Fact] + public void ElementPickerViewModel_SetCandidates_SecondCall_ReplacesState() + { + // Arrange + var picker = new ElementPickerViewModel(); + picker.SetCandidates(MixedCandidates, defaultTypeFilterLabel: "part"); + picker.SelectedQualifiedName = "Model::engineInstance"; + picker.SearchText = "engine"; + + // Act + picker.SearchText = null; + picker.SetCandidates([("Other::Foo", "other")], defaultTypeFilterLabel: null); + + // Assert + Assert.Equal(new[] { "other" }, picker.AvailableTypeLabels); + Assert.Empty(picker.ActiveTypeFilters); + Assert.Null(picker.SelectedQualifiedName); + Assert.Equal(new[] { "Other::Foo" }, picker.DisplayedItems); + } + + /// + /// Validates that can be + /// freely assigned and read back, tracking the caller's selection. + /// + [Fact] + public void ElementPickerViewModel_SelectedQualifiedName_RoundTrips() + { + // Arrange + var picker = new ElementPickerViewModel(); + picker.SetCandidates(MixedCandidates); + + // Act + picker.SelectedQualifiedName = "Model::Wheel"; + + // Assert + Assert.Equal("Model::Wheel", picker.SelectedQualifiedName); + } +} diff --git a/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementTypeLabelerTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementTypeLabelerTests.cs new file mode 100644 index 0000000..94ca6e5 --- /dev/null +++ b/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementTypeLabelerTests.cs @@ -0,0 +1,114 @@ +using DemaConsulting.SysML2Tools.Semantic.Model; +using DemaConsulting.SysML2Workbench.ElementPickerSubsystem; + +namespace DemaConsulting.SysML2Workbench.Tests.ElementPickerSubsystem; + +/// +/// Unit-tests against each concrete +/// SysmlNode subtype the picker can encounter, plus the fallback path. +/// +public sealed class ElementTypeLabelerTests +{ + /// + /// Validates the null-argument guard: passing a node throws + /// . + /// + [Fact] + public void ElementTypeLabeler_GetTypeLabel_NullNode_Throws() + { + // Act & Assert + Assert.Throws(() => ElementTypeLabeler.GetTypeLabel(null!)); + } + + /// + /// Validates that a returns its + /// verbatim. + /// + [Fact] + public void ElementTypeLabeler_GetTypeLabel_DefinitionNode_ReturnsDefinitionKeyword() + { + // Arrange + var node = new SysmlDefinitionNode { DefinitionKeyword = "part def" }; + + // Act + var label = ElementTypeLabeler.GetTypeLabel(node); + + // Assert + Assert.Equal("part def", label); + } + + /// + /// Validates that a returns its + /// verbatim. + /// + [Fact] + public void ElementTypeLabeler_GetTypeLabel_FeatureNode_ReturnsFeatureKeyword() + { + // Arrange + var node = new SysmlFeatureNode { FeatureKeyword = "port" }; + + // Act + var label = ElementTypeLabeler.GetTypeLabel(node); + + // Assert + Assert.Equal("port", label); + } + + /// + /// Validates that a returns its + /// ConnectionKeyword verbatim. + /// + [Fact] + public void ElementTypeLabeler_GetTypeLabel_ConnectionNode_ReturnsConnectionKeyword() + { + // Arrange + var node = new SysmlConnectionNode { ConnectionKeyword = "connection" }; + + // Act + var label = ElementTypeLabeler.GetTypeLabel(node); + + // Assert + Assert.Equal("connection", label); + } + + /// + /// Validates the fixed-literal mappings for node kinds without a keyword-style member. + /// + [Theory] + [InlineData(typeof(SysmlImportNode), "import")] + [InlineData(typeof(SysmlPackageNode), "package")] + [InlineData(typeof(SysmlViewNode), "view")] + [InlineData(typeof(SysmlViewpointNode), "viewpoint")] + [InlineData(typeof(SysmlTransitionNode), "transition")] + [InlineData(typeof(SysmlSatisfyNode), "satisfy")] + [InlineData(typeof(SysmlDependencyNode), "dependency")] + public void ElementTypeLabeler_GetTypeLabel_FixedLiteralNode_ReturnsExpectedLiteral(Type nodeType, string expected) + { + // Arrange + var node = (SysmlNode)Activator.CreateInstance(nodeType)!; + + // Act + var label = ElementTypeLabeler.GetTypeLabel(node); + + // Assert + Assert.Equal(expected, label); + } + + /// + /// Validates that an otherwise-unrecognized node subtype falls back to a defensive + /// label derived from its runtime type name (leading Sysml and trailing + /// Node stripped, then lowercased). + /// + [Fact] + public void ElementTypeLabeler_GetTypeLabel_UnknownSubtype_UsesFallbackFromTypeName() + { + // Arrange + var node = new SysmlMetadataNode(); + + // Act + var label = ElementTypeLabeler.GetTypeLabel(node); + + // Assert + Assert.Equal("metadata", label); + } +} diff --git a/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystemTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystemTests.cs new file mode 100644 index 0000000..a677048 --- /dev/null +++ b/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystemTests.cs @@ -0,0 +1,46 @@ +using DemaConsulting.SysML2Tools.Semantic.Model; +using DemaConsulting.SysML2Workbench.ElementPickerSubsystem; + +namespace DemaConsulting.SysML2Workbench.Tests; + +/// +/// Subsystem-level tests exercising ElementPickerSubsystem's unit +/// ( plus ) composed together +/// against a caller-built candidate list, per +/// docs/reqstream/sysml2-workbench/element-picker-subsystem.yaml. +/// +public sealed class ElementPickerSubsystemTests +{ + /// + /// Validates that the labeler and the view model compose end-to-end: labels computed by + /// flow through + /// into and gate + /// via the default chip. + /// + [Fact] + public void SubsystemComposition_LabelerFeedsViewModel_DefaultChipFiltersDisplayedItems() + { + // Arrange: three candidates spanning distinct node kinds, mapped through the labeler. + var partDef = new SysmlDefinitionNode { DefinitionKeyword = "part def" }; + var partUsage = new SysmlFeatureNode { FeatureKeyword = "part" }; + var package = new SysmlPackageNode(); + + var candidates = new (string QualifiedName, string TypeLabel)[] + { + ("Sample::Engine", ElementTypeLabeler.GetTypeLabel(partDef)), + ("Sample::engine", ElementTypeLabeler.GetTypeLabel(partUsage)), + ("Sample", ElementTypeLabeler.GetTypeLabel(package)) + }; + + var vm = new ElementPickerViewModel(); + + // Act: hand the candidates to the picker with "part" as the default chip. + vm.SetCandidates(candidates, defaultTypeFilterLabel: "part"); + + // Assert: all three labels surface in AvailableTypeLabels (distinct, sorted), the + // default chip is active, and only the "part" candidate is displayed. + Assert.Equal(new[] { "package", "part", "part def" }, vm.AvailableTypeLabels); + Assert.Equal(new[] { "part" }, vm.ActiveTypeFilters); + Assert.Equal(new[] { "Sample::engine" }, vm.DisplayedItems); + } +} diff --git a/test/OtsSoftwareTests/AvaloniaTests.cs b/test/OtsSoftwareTests/AvaloniaTests.cs index 35e7fd8..af113ba 100644 --- a/test/OtsSoftwareTests/AvaloniaTests.cs +++ b/test/OtsSoftwareTests/AvaloniaTests.cs @@ -318,4 +318,165 @@ private static void SelectDiagramTab(MainWindowView window, string tabId) { return root.GetVisualDescendants().OfType().FirstOrDefault(c => c.Name == name); } + + /// + /// Walks the logical tree rooted at and enumerates every + /// it contains, including submenu items whose visual is only realized + /// when the parent menu opens. + /// + private static IEnumerable LogicalTreeMenuItems(Avalonia.LogicalTree.ILogical root) + { + foreach (var child in root.LogicalChildren) + { + if (child is MenuItem menuItem) + { + yield return menuItem; + } + + foreach (var descendant in LogicalTreeMenuItems(child)) + { + yield return descendant; + } + } + } + + /// + /// End-to-end regression for the redesigned Query dialog: constructs a real + /// , confirms its Query menu hosts the "Run Query..." entry (the + /// view-side counterpart of the plan's new _Query top-level menu), then opens the + /// modal directly (rather than through the modal + /// ShowDialog path, which blocks its calling turn until closed), confirms the dialog + /// opens on the "List" Query Type with the selection-free ListFilterView visible and the + /// selectable ElementQueryPickerView hidden, selects the "Describe" entry on the Query + /// Type combo (confirming the two controls' visibility flips), selects an element on the + /// now-visible picker, and confirms the real + /// result appears immediately with + /// no "Run" gesture of any kind. Then right-clicks the results panel's context menu's "Copy as + /// Markdown" entry and asserts the headless platform's real clipboard now holds the exact text + /// produces. + /// Mirrors 's right-click + /// recipe. + /// + [AvaloniaFact] + public async Task QueryDialog_SelectDescribeAndCopyAsMarkdown_PlacesRenderedMarkdownOnClipboard() + { + // Arrange: a real workspace with one part def, so Describe has something meaningful to say + await File.WriteAllTextAsync( + Path.Combine(_tempRoot, "Sample.sysml"), + "package Sample {\n" + + " part def Engine;\n" + + "}\n"); + using var shell = CreateShell(); + var window = new MainWindowView(shell); + window.Show(); + Dispatcher.UIThread.RunJobs(); + + await shell.AddFolderSourceAsync(_tempRoot); + Dispatcher.UIThread.RunJobs(); + + // Assert the Query menu item is really wired in the main window's logical tree. It lives + // under the top-level "_Query" menu which Avalonia lazily materializes in the visual tree + // only after the menu opens, so we walk the logical tree (which mirrors the AXAML structure + // regardless of visualization state) instead of using FindByName's visual-tree walk. + var queryMenuItem = LogicalTreeMenuItems(window) + .FirstOrDefault(mi => mi.Name == "QueryDialogMenuItem"); + Assert.NotNull(queryMenuItem); + + // Act: open the dialog directly with the same shell reference OnOpenQueryDialogClick would use. + var dialog = new QueryDialogView(shell); + dialog.Show(window); + Dispatcher.UIThread.RunJobs(); + + // Assert: the dialog opens defaulting to "List" Query Type, so ListFilterView (the + // selection-free filter control) is visible and ElementQueryPickerView (whose selection would + // otherwise be silently ignored for "List") is hidden. + var listFilterView = FindByName(dialog, "ListFilterView"); + var elementQueryPickerView = FindByName(dialog, "ElementQueryPickerView"); + Assert.NotNull(listFilterView); + Assert.NotNull(elementQueryPickerView); + Assert.True(listFilterView!.IsVisible); + Assert.False(elementQueryPickerView!.IsVisible); + + // Act: select "Describe" on the Query Type combo. Describe is no longer the VM's + // construction-time default now that the default Query Type is "List", so this step itself + // exercises the Query Type combo's wiring. + var queryTypeComboBox = FindByName(dialog, "QueryTypeComboBox"); + Assert.NotNull(queryTypeComboBox); + queryTypeComboBox!.SelectedItem = DemaConsulting.SysML2Tools.Query.QueryVerb.Describe; + Dispatcher.UIThread.RunJobs(); + + var vm = (QueryDialogViewModel)dialog.DataContext!; + Assert.Equal(DemaConsulting.SysML2Tools.Query.QueryVerb.Describe, vm.SelectedQueryType); + + // Assert: switching to an element-scoped Query Type flips the two controls' visibility, and now + // that ElementQueryPickerView is visible its inner candidate ListBox is realized - no + // TabControl/tab-switch exists in this design. + Assert.False(listFilterView.IsVisible); + Assert.True(elementQueryPickerView.IsVisible); + var pickerListBox = FindByName(dialog, "PickerItemsListBox"); + Assert.NotNull(pickerListBox); + + // Act: select the target element on the single picker - this alone must produce the result, + // with no "Run" button or method of any kind in this design. + vm.Picker.SelectedQualifiedName = "Sample::Engine"; + Dispatcher.UIThread.RunJobs(); + + Assert.NotNull(vm.CurrentResult); + Assert.Equal("describe", vm.CurrentResult!.Verb); + Assert.Equal("Sample::Engine", vm.CurrentResult.Element); + + // Act: right-click the results panel (open its context menu) and click "Copy as Markdown", then + // do the exact same gesture a *second* time and click "Copy as JSON" - this is the regression + // check for the "one-shot" bug where the context menu stopped opening after the first use. + var resultsBorder = FindByName(dialog, "ResultsBorder"); + Assert.NotNull(resultsBorder); + var contextMenu = resultsBorder!.ContextMenu; + Assert.NotNull(contextMenu); + contextMenu!.Open(resultsBorder); + Dispatcher.UIThread.RunJobs(); + + var copyMarkdownMenuItem = FindByName(dialog, "CopyAsMarkdownMenuItem"); + Assert.NotNull(copyMarkdownMenuItem); + Assert.True(copyMarkdownMenuItem!.IsEnabled); + copyMarkdownMenuItem.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); + Dispatcher.UIThread.RunJobs(); + + // Wait a beat for the async clipboard write to complete on the UI thread + await Task.Yield(); + Dispatcher.UIThread.RunJobs(); + + // Assert: the headless platform's real clipboard now holds the expected rendered Markdown + var clipboard = TopLevel.GetTopLevel(dialog)?.Clipboard; + Assert.NotNull(clipboard); + var clipboardText = await clipboard!.TryGetTextAsync(); + var expected = string.Join( + "\n", + DemaConsulting.SysML2Tools.Query.QueryResultRenderer.RenderMarkdown(vm.CurrentResult)); + Assert.Equal(expected, clipboardText); + + // Act: open the context menu a *second* time and click "Copy as JSON" - previously this second + // open silently did nothing once the results panel switched to a real DataGrid. + contextMenu.Open(resultsBorder); + Dispatcher.UIThread.RunJobs(); + Assert.True(contextMenu.IsOpen, "ContextMenu failed to open on a second use."); + + var copyJsonMenuItem = FindByName(dialog, "CopyAsJsonMenuItem"); + Assert.NotNull(copyJsonMenuItem); + Assert.True(copyJsonMenuItem!.IsEnabled); + copyJsonMenuItem.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); + Dispatcher.UIThread.RunJobs(); + + await Task.Yield(); + Dispatcher.UIThread.RunJobs(); + + // Assert: the second right-click still opened the menu and the clipboard now holds the JSON + // rendering, proving the context menu did not "brick itself" after the first use. + var secondClipboardText = await clipboard!.TryGetTextAsync(); + var expectedJson = DemaConsulting.SysML2Tools.Query.QueryResultRenderer.RenderJson(vm.CurrentResult); + Assert.Equal(expectedJson, secondClipboardText); + Assert.NotEqual(clipboardText, secondClipboardText); + + dialog.Close(); + window.Close(); + } } diff --git a/test/OtsSoftwareTests/OtsSoftwareTests.csproj b/test/OtsSoftwareTests/OtsSoftwareTests.csproj index a20e95d..04fd181 100644 --- a/test/OtsSoftwareTests/OtsSoftwareTests.csproj +++ b/test/OtsSoftwareTests/OtsSoftwareTests.csproj @@ -16,8 +16,8 @@ - - + +