From 4003009a1a9456a322ccce27fb13535c004cf961 Mon Sep 17 00:00:00 2001 From: Copilot CLI Date: Mon, 20 Jul 2026 22:10:58 -0400 Subject: [PATCH 01/11] Extract shared ElementPickerSubsystem Factor the OR-then-AND type-filter chip / substring search / candidate list logic out of ViewBuilderDialogViewModel into a reusable, dialog-agnostic ElementPickerViewModel + ElementPickerView UserControl and a static ElementTypeLabeler. The new Query dialog (added in a later commit) needs the exact same picker with a different candidate set and a different default chip, so keeping the logic embedded in the ViewBuilder dialog would force either duplication or an awkward multi-consumer coupling on ViewDefinitionModel. Composition through the new ExposeTargetPicker property preserves every observable behavior of the ViewBuilder expose-targets tab, and the existing unit tests are adapted (not deleted) to reach through the composed picker. --- .../ViewBuilderDialogView.axaml | 33 +- .../ViewBuilderDialogView.axaml.cs | 41 +-- .../ViewBuilderDialogViewModel.cs | 209 +----------- .../ElementPickerView.axaml | 48 +++ .../ElementPickerView.axaml.cs | 89 +++++ .../ElementPickerViewModel.cs | 223 +++++++++++++ .../ElementTypeLabeler.cs | 89 +++++ .../ViewBuilderDialogViewModelTests.cs | 87 ++--- .../ElementPickerViewModelTests.cs | 314 ++++++++++++++++++ .../ElementTypeLabelerTests.cs | 114 +++++++ 10 files changed, 941 insertions(+), 306 deletions(-) create mode 100644 src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerView.axaml create mode 100644 src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerView.axaml.cs create mode 100644 src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerViewModel.cs create mode 100644 src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementTypeLabeler.cs create mode 100644 test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementPickerViewModelTests.cs create mode 100644 test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementTypeLabelerTests.cs diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/ViewBuilderDialogView.axaml b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/ViewBuilderDialogView.axaml index 0cff14f..38771e9 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/ViewBuilderDialogView.axaml +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/ViewBuilderDialogView.axaml @@ -1,6 +1,7 @@ - - - - - - - - - - - - - - - - + - + + + + + + + + + + + diff --git a/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerView.axaml.cs b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerView.axaml.cs new file mode 100644 index 0000000..6521af5 --- /dev/null +++ b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerView.axaml.cs @@ -0,0 +1,89 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; + +namespace DemaConsulting.SysML2Workbench.ElementPickerSubsystem; + +/// +/// Thin Avalonia code-behind for the shared -backed +/// picker . 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. +/// +/// +/// 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. Hosting parents that need to react to the flyout or +/// chip lifecycle themselves should observe 's +/// ActiveTypeFilters collection instead of hooking events on this control. +/// +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(); + + AddTypeFilterButton.Flyout!.Opened += OnAddTypeFilterFlyoutOpening; + AddableTypeFilterListBox.SelectionChanged += OnAddableTypeFilterSelectionChanged; + } + + /// + /// 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 (, + /// , and + /// ) from their own code-behind + /// without needing to know the control's internal children. + /// + public ElementPickerViewModel? ViewModel => DataContext as ElementPickerViewModel; + + /// + /// 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/ElementPickerViewModel.cs b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerViewModel.cs new file mode 100644 index 0000000..4396901 --- /dev/null +++ b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerViewModel.cs @@ -0,0 +1,223 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +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. 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 +{ + /// + /// 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 = []; + + [ObservableProperty] + private string? _selectedQualifiedName; + + /// + /// Creates the picker 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 ElementPickerViewModel() + { + ActiveTypeFilters.CollectionChanged += OnActiveTypeFiltersCollectionChanged; + } + + /// + /// Type labels currently applied as chips over the 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). 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 picker's master candidate list with , + /// recomputes from that list, resets + /// to a single-chip default (or empty) per + /// , and recomputes + /// . Also 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) + { + 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); + } + + // 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; + + 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 picker 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/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/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/ElementPickerViewModelTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementPickerViewModelTests.cs new file mode 100644 index 0000000..5a1946d --- /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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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..72d16fc --- /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 GetTypeLabel_NullNode_Throws() + { + // Act & Assert + Assert.Throws(() => ElementTypeLabeler.GetTypeLabel(null!)); + } + + /// + /// Validates that a returns its + /// verbatim. + /// + [Fact] + public void 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 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 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 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 GetTypeLabel_UnknownSubtype_UsesFallbackFromTypeName() + { + // Arrange + var node = new SysmlMetadataNode(); + + // Act + var label = ElementTypeLabeler.GetTypeLabel(node); + + // Assert + Assert.Equal("metadata", label); + } +} From ecb84248e87dc846b86e196e48aadfdd2377591d Mon Sep 17 00:00:00 2001 From: Copilot CLI Date: Mon, 20 Jul 2026 22:12:07 -0400 Subject: [PATCH 02/11] Bump DemaConsulting.SysML2Tools packages to 0.1.0-beta.15 beta.15 promotes DemaConsulting.SysML2Tools.Query (QueryEngine, QueryOptions, QueryResult, QueryResultRenderer, QueryVerb) to the public NuGet surface. The upcoming Query dialog consumes those types directly, so the workbench must reference the beta.15 packages before the dialog code can compile. Restore + full build both succeed. --- .../DemaConsulting.SysML2Workbench.csproj | 6 +++--- test/OtsSoftwareTests/OtsSoftwareTests.csproj | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/DemaConsulting.SysML2Workbench/DemaConsulting.SysML2Workbench.csproj b/src/DemaConsulting.SysML2Workbench/DemaConsulting.SysML2Workbench.csproj index f0dc8ac..d44f5e0 100644 --- a/src/DemaConsulting.SysML2Workbench/DemaConsulting.SysML2Workbench.csproj +++ b/src/DemaConsulting.SysML2Workbench/DemaConsulting.SysML2Workbench.csproj @@ -17,9 +17,9 @@ - - - + + + diff --git a/test/OtsSoftwareTests/OtsSoftwareTests.csproj b/test/OtsSoftwareTests/OtsSoftwareTests.csproj index a20e95d..bf17aa7 100644 --- a/test/OtsSoftwareTests/OtsSoftwareTests.csproj +++ b/test/OtsSoftwareTests/OtsSoftwareTests.csproj @@ -16,8 +16,8 @@ - - + + From c4b2d2edf29c5ac0ca28c089139fa73b258b9468 Mon Sep 17 00:00:00 2001 From: Copilot CLI Date: Mon, 20 Jul 2026 22:20:39 -0400 Subject: [PATCH 03/11] Add Query menu and QueryDialog unit Adds a new top-level _Query menu between View and Help with a single Run Query... entry that opens a new modal QueryDialog. The dialog composes two ElementPicker instances (Browse tab / Element Query tab), dispatches element-scoped verbs through DemaConsulting.SysML2Tools.Query.QueryEngine.Execute, and copies results as Markdown or JSON via QueryResultRenderer through the same AvaloniaClipboardService pattern DiagramDocumentView uses. Every recoverable failure surface (no workspace, no selection, engine argument rejection) is reported through an observable StatusMessage rather than by throwing, matching the plan's graceful-handling contract. The Browse tab is a purely-client-side filter (no QueryEngine.List/Find call) that builds a QueryResult inline so the same shared results panel renders it. Companion unit tests cover verb-specific option construction, engine dispatch, no-selection / no-workspace handling, and clipboard write parity with QueryResultRenderer's output; a new Avalonia-headless end-to-end test exercises the real menu -> dialog -> Describe -> Copy-as-Markdown flow against the real clipboard. --- .../AppShellSubsystem/MainWindowView.axaml | 7 + .../AppShellSubsystem/MainWindowView.axaml.cs | 13 + .../AppShellSubsystem/QueryDialogView.axaml | 130 +++++ .../QueryDialogView.axaml.cs | 220 ++++++++ .../AppShellSubsystem/QueryDialogViewModel.cs | 392 ++++++++++++++ .../QueryDialogViewModelTests.cs | 505 ++++++++++++++++++ test/OtsSoftwareTests/AvaloniaTests.cs | 113 ++++ 7 files changed, 1380 insertions(+) create mode 100644 src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml create mode 100644 src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml.cs create mode 100644 src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogViewModel.cs create mode 100644 test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs 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..52459b5 --- /dev/null +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml @@ -0,0 +1,130 @@ + + + + + + + public partial class QueryDialogView : Window { @@ -34,13 +36,13 @@ public QueryDialogView(MainWindowShell shell) { InitializeComponent(); - // Populate verb / direction combo boxes from the view-model's canonical option lists so the - // AXAML markup stays independent of the QueryVerb enum's declaration order and the + // Populate the Query Type / direction combo boxes from the view-model's canonical option lists + // so the AXAML markup stays independent of the QueryVerb enum's declaration order and the // hierarchy-direction string vocabulary. - VerbComboBox.ItemsSource = QueryDialogViewModel.ElementScopedVerbs; + QueryTypeComboBox.ItemsSource = QueryDialogViewModel.QueryTypes; HierarchyDirectionComboBox.ItemsSource = QueryDialogViewModel.HierarchyDirectionOptions; - VerbComboBox.SelectionChanged += OnVerbSelectionChanged; + QueryTypeComboBox.SelectionChanged += OnQueryTypeSelectionChanged; HierarchyDirectionComboBox.SelectionChanged += OnHierarchyDirectionSelectionChanged; DataContext = new QueryDialogViewModel(shell); @@ -50,10 +52,11 @@ public QueryDialogView(MainWindowShell shell) } /// - /// Re-anchors the view's dependencies (VM back-reference, clipboard service, verb/direction - /// combos, initial results-panel state) whenever the DataContext is reassigned. Follows - /// 's "always rebind" (unconditional, no ??=) pattern - /// so a re-shown dialog never leaves the clipboard service pointing at a stale window instance. + /// Re-anchors the view's dependencies (VM back-reference, clipboard service, Query + /// Type/direction combos, initial results-panel state) whenever the DataContext is + /// reassigned. Follows 's "always rebind" (unconditional, no + /// ??=) pattern so a re-shown dialog never leaves the clipboard service pointing at a + /// stale window instance. /// private void OnDataContextChanged(object? sender, EventArgs e) { @@ -67,10 +70,11 @@ private void OnDataContextChanged(object? sender, EventArgs e) /// /// Wires the current DataContext as the active view model: subscribes to its - /// PropertyChanged stream so verb-visibility, results-panel visibility, and - /// copy-button-enabled state stay in sync; primes the initial UI from the VM's current state; - /// and unconditionally assigns a fresh so the copy - /// buttons write to this window's clipboard. + /// PropertyChanged stream so Query-Type-visibility and results-panel visibility stay in + /// sync (copy-menu-item enablement is handled purely by the HasCurrentResult binding); + /// primes the initial UI from the VM's current state; and unconditionally assigns a fresh + /// so the context-menu copy actions write to this + /// window's clipboard. /// private void AttachViewModel() { @@ -82,7 +86,7 @@ private void AttachViewModel() _viewModel.PropertyChanged += OnViewModelPropertyChanged; - VerbComboBox.SelectedItem = _viewModel.SelectedVerb; + QueryTypeComboBox.SelectedItem = _viewModel.SelectedQueryType; HierarchyDirectionComboBox.SelectedItem = _viewModel.HierarchyDirection; // See DiagramDocumentView.OnDataContextChanged for the "always rebind (unconditional, no `??=`)" @@ -90,26 +94,26 @@ private void AttachViewModel() // clipboard service to THIS window instance so TopLevel.GetTopLevel(this) resolves live. _viewModel.ClipboardService = new AvaloniaClipboardService(this); - ApplyVerbVisibility(); + ApplyQueryTypeVisibility(); ApplyResultVisibility(); } /// - /// Observes the view model's property changes and drives the code-behind's visibility and - /// enablement toggles: verb changes retarget which per-verb controls are visible, and current - /// result / result-rows changes toggle the results panel and the copy buttons. + /// Observes the view model's property changes and drives the code-behind's visibility toggles: + /// Query Type changes retarget which per-verb controls are visible, and current result / result + /// rows changes toggle the results panel's visibility. /// private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { switch (e.PropertyName) { - case nameof(QueryDialogViewModel.SelectedVerb): + case nameof(QueryDialogViewModel.SelectedQueryType): if (_viewModel is not null) { - VerbComboBox.SelectedItem = _viewModel.SelectedVerb; + QueryTypeComboBox.SelectedItem = _viewModel.SelectedQueryType; } - ApplyVerbVisibility(); + ApplyQueryTypeVisibility(); ApplyResultVisibility(); break; @@ -130,22 +134,24 @@ private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.Pr /// /// Toggles the Hierarchy / Impact per-verb control panels' visibility based on the currently - /// selected verb. + /// selected Query Type. /// - private void ApplyVerbVisibility() + private void ApplyQueryTypeVisibility() { if (_viewModel is null) { return; } - HierarchyDirectionPanel.IsVisible = _viewModel.SelectedVerb == QueryVerb.Hierarchy; - WalkDepthPanel.IsVisible = _viewModel.SelectedVerb == QueryVerb.Impact; + HierarchyDirectionPanel.IsVisible = _viewModel.SelectedQueryType == QueryVerb.Hierarchy; + WalkDepthPanel.IsVisible = _viewModel.SelectedQueryType == QueryVerb.Impact; } /// - /// Toggles the results panel's visibility and the copy buttons' enablement based on the current - /// . + /// Toggles the results panel's visibility based on the current + /// . Copy-menu-item enablement is handled purely + /// by the AXAML's IsEnabled="{Binding HasCurrentResult}" bindings, so this method no longer + /// needs to imperatively toggle any button/menu-item state (unlike the old toolbar-button design). /// private void ApplyResultVisibility() { @@ -155,7 +161,6 @@ private void ApplyResultVisibility() } var result = _viewModel.CurrentResult; - var hasResult = result is not null; var hasEntries = _viewModel.CurrentResultRows.Count > 0; // Summary lines: bind directly (rather than through a converter) so unit tests that only touch @@ -167,16 +172,13 @@ private void ApplyResultVisibility() // with the row-template columns by only showing it when the current result is a dependency-verb // result. DirectionHeaderTextBlock.IsVisible = string.Equals(result?.Verb, "dependencies", StringComparison.Ordinal); - - CopyAsMarkdownButton.IsEnabled = hasResult; - CopyAsJsonButton.IsEnabled = hasResult; } - private void OnVerbSelectionChanged(object? sender, SelectionChangedEventArgs e) + private void OnQueryTypeSelectionChanged(object? sender, SelectionChangedEventArgs e) { - if (_viewModel is not null && VerbComboBox.SelectedItem is QueryVerb verb) + if (_viewModel is not null && QueryTypeComboBox.SelectedItem is QueryVerb queryType) { - _viewModel.SelectedVerb = verb; + _viewModel.SelectedQueryType = queryType; } } @@ -188,12 +190,7 @@ private void OnHierarchyDirectionSelectionChanged(object? sender, SelectionChang } } - private void OnRunQueryClick(object? sender, RoutedEventArgs e) - { - _viewModel?.RunElementQuery(); - } - - private async void OnCopyAsMarkdownClick(object? sender, RoutedEventArgs e) + private async void OnCopyAsMarkdownMenuItemClick(object? sender, RoutedEventArgs e) { if (_viewModel is null) { @@ -203,7 +200,7 @@ private async void OnCopyAsMarkdownClick(object? sender, RoutedEventArgs e) await _viewModel.CopyResultAsMarkdownAsync(); } - private async void OnCopyAsJsonClick(object? sender, RoutedEventArgs e) + private async void OnCopyAsJsonMenuItemClick(object? sender, RoutedEventArgs e) { if (_viewModel is null) { diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogViewModel.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogViewModel.cs index 7135d36..b86d29b 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogViewModel.cs +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogViewModel.cs @@ -8,17 +8,20 @@ namespace DemaConsulting.SysML2Workbench.AppShellSubsystem; /// -/// View model for the modal Query dialog: a two-tab picker (Browse / Element Query) over the -/// current workspace's declarations that lets the user either see a filtered client-side list of -/// elements or run one of the ten element-scoped operations through -/// and render the result via . +/// View model for the modal Query dialog: a single always-visible form over the current workspace's +/// declarations, driven by one and a "Query Type" selector covering all eleven +/// user-facing options (a merged entry plus the ten element-scoped +/// operations dispatched through ). Every +/// relevant change - Query Type, chip/search edits, element selection, Hierarchy direction, Impact +/// walk depth, or the Include-standard-library toggle - immediately recomputes +/// ; there is no explicit "Run" gesture anywhere in this design. /// /// /// Deliberately parallels 's "dialog owned by the shell, /// fresh instance per open" lifetime pattern: no subscription to /// workspace events (the dialog is short-lived), a single call at -/// construction, and the two composed s are wholly owned by this -/// view model. Every user-visible failure surface (no workspace loaded, no element selected, engine +/// construction, and the composed is wholly owned by this view +/// model. Every user-visible failure surface (no workspace loaded, no element selected, engine /// rejects the verb for the resolved node) is reported through the observable /// string rather than by throwing, matching the plan's /// "graceful no-selection/no-workspace handling" acceptance criterion. @@ -26,19 +29,21 @@ namespace DemaConsulting.SysML2Workbench.AppShellSubsystem; public sealed partial class QueryDialogViewModel : ObservableObject { /// - /// The ten element-scoped verbs the Element Query tab exposes, in the order the plan's UI mock - /// lists them. Deliberately excludes and - /// (whose "workspace-wide, no target element" semantics the Browse tab already covers with a - /// purely client-side filter), so the verb selector never presents an option that - /// would need a element for. + /// The eleven Query Type choices the single form's combo box exposes, in the order the redesign's + /// UI mock lists them: the merged entry first (the user never sees + /// ; selecting "List" always recomputes the client-side filter, never + /// calls /), followed by the ten + /// element-scoped verbs that require to + /// be set on before is called. /// - public static readonly IReadOnlyList ElementScopedVerbs = + public static readonly IReadOnlyList QueryTypes = [ + QueryVerb.List, + QueryVerb.Describe, QueryVerb.Uses, QueryVerb.UsedBy, QueryVerb.Dependencies, QueryVerb.Impact, - QueryVerb.Describe, QueryVerb.Hierarchy, QueryVerb.Requirements, QueryVerb.Interface, @@ -57,11 +62,11 @@ public sealed partial class QueryDialogViewModel : ObservableObject /// /// Master, unfiltered candidate map (qualified name → underlying ) built /// from by . - /// Kept alongside the two s (which expose only qualified-name - /// strings) so can resolve the picker's selected qualified name - /// back to a for without re-querying - /// the workspace, and so can attach the same - /// kind label to each Browse-tab entry. + /// Kept alongside (which exposes only qualified-name strings) so + /// can resolve the picker's selected qualified name back to a + /// for without re-querying the + /// workspace, and so can attach the same + /// kind label to each List-type entry. /// private IReadOnlyDictionary _candidateMap = new Dictionary(StringComparer.Ordinal); @@ -73,7 +78,7 @@ public sealed partial class QueryDialogViewModel : ObservableObject private bool _isWorkspaceEmpty; [ObservableProperty] - private QueryVerb _selectedVerb = QueryVerb.Describe; + private QueryVerb _selectedQueryType = QueryVerb.List; [ObservableProperty] private string? _hierarchyDirection = "both"; @@ -91,8 +96,8 @@ public sealed partial class QueryDialogViewModel : ObservableObject private string? _statusMessage; /// - /// Creates the dialog view model over and immediately populates both - /// tabs' pickers from the current workspace. The observable flag + /// Creates the dialog view model over and immediately populates the + /// single picker from the current workspace. The observable flag /// starts (matching the CLI's own --include-stdlib default) so /// stdlib names are excluded until the user toggles the checkbox. /// @@ -103,13 +108,13 @@ public QueryDialogViewModel(MainWindowShell shell) ArgumentNullException.ThrowIfNull(shell); Shell = shell; - BrowsePicker = new ElementPickerViewModel(); - ElementQueryPicker = new ElementPickerViewModel(); + Picker = new ElementPickerViewModel(); - // Any change to Browse-tab's displayed items must regenerate the shared results panel so the - // Browse tab remains "live" (typing in its search box updates the results panel with no - // Run-button gesture) - the plan's contract for that tab. - BrowsePicker.PropertyChanged += OnBrowsePickerPropertyChanged; + // Any change to the picker's displayed items or selected qualified name must immediately + // recompute the shared results panel: List-type results regenerate off DisplayedItems, and every + // other Query Type's result regenerates off SelectedQualifiedName. This single subscription is + // the redesign's entire "no explicit Run gesture" mechanism. + Picker.PropertyChanged += OnPickerPropertyChanged; RefreshFromWorkspace(); } @@ -117,36 +122,46 @@ public QueryDialogViewModel(MainWindowShell shell) /// /// Fully composed application shell providing the current workspace and its declarations. Held /// as a public property so the code-behind (which owns the two-way search-textbox focus and the - /// copy-buttons' click handlers) and the design-time constructor factory can both reach it. + /// context-menu copy handlers) and the design-time constructor factory can both reach it. /// public MainWindowShell Shell { get; } /// - /// Picker backing the "Browse" tab: full workspace declarations, no exclusions (beyond the - /// stdlib exclusion controlled by ), no default type filter chip. - /// - public ElementPickerViewModel BrowsePicker { get; } - - /// - /// Picker backing the "Element Query" tab: same candidate set as - /// (they share ), no default type filter chip. Selection - /// drives . + /// The single picker backing the whole form: full workspace declarations, no exclusions (beyond + /// the stdlib exclusion controlled by ), no default type filter chip. + /// For its is + /// the result source; for every other Query Type its + /// is the target element and + /// is a pure filter aid whose selection is + /// otherwise unused. /// - public ElementPickerViewModel ElementQueryPicker { get; } + public ElementPickerViewModel Picker { get; } /// /// Clipboard write seam used by and /// . Left unset () until the owning /// attaches to the visual tree and assigns a real /// ; unit tests instead assign a fake test double directly - /// so the run-and-copy orchestration can be verified without any live UI/OS clipboard. + /// so the recompute-and-copy orchestration can be verified without any live UI/OS clipboard. /// public IClipboardService? ClipboardService { get; set; } /// - /// Refreshes both pickers' candidate lists from the current workspace, applying the - /// filter. Called once at construction and again whenever - /// toggles; the dialog does not observe post-open workspace changes. + /// Computed mirror of "is there a result to copy right now", exposed so the results panel's + /// right-click context-menu items can bind their IsEnabled directly to a VM property + /// (mirroring 's proven pattern) rather than + /// the code-behind imperatively toggling button enablement, as the old toolbar buttons did. + /// + public bool HasCurrentResult => CurrentResult is not null; + + /// + /// Refreshes the picker's candidate list from the current workspace, applying the + /// filter, then explicitly recomputes the results panel so toggling + /// the checkbox is reflected immediately even though + /// already fires its own PropertyChanged notification (this + /// trailing call is harmless, idempotent defense-in-depth rather than a required step). Called once + /// at construction and again whenever toggles; the dialog does not + /// observe post-open workspace changes. /// public void RefreshFromWorkspace() { @@ -163,76 +178,62 @@ public void RefreshFromWorkspace() .OrderBy(entry => entry.QualifiedName, StringComparer.Ordinal) .ToList(); - // Neither tab has a default type filter chip: the plan explicitly calls out that both start + // No default type filter chip: the plan explicitly calls out that the single form starts with // "no default filter" so every candidate is shown until the user narrows with a chip or search // text (unlike the ViewBuilder expose-targets picker, which defaults to "part"). - BrowsePicker.SetCandidates(candidates); - ElementQueryPicker.SetCandidates(candidates); + Picker.SetCandidates(candidates); + + RecomputeResult(); } /// - /// Rebuilds the shared results panel from the Browse tab's current displayed items. Runs - /// automatically whenever emits a - /// change (a chip toggle or search-text edit); may also be invoked directly by tests to assert - /// the tab's client-side "list" semantics without spinning up an Avalonia view. + /// Recomputes for the currently selected . + /// For this builds a purely client-side filtered list from + /// 's via + /// . For every other Query Type this requires + /// : with no selection it reports a + /// helpful (non-error) and clears the results table rather than + /// leaving a stale prior result, and with a selection it dispatches through + /// exactly as the design's original "Run" gesture did, + /// gracefully catching . Called automatically by every relevant + /// property change - there is no explicit "Run" method in this design. /// - public void BuildBrowseResult() + public void RecomputeResult() { - var displayed = BrowsePicker.DisplayedItems; - - var entries = displayed - .Select(qualifiedName => new QueryResultEntry - { - QualifiedName = qualifiedName, - Kind = _candidateMap.TryGetValue(qualifiedName, out var node) - ? ElementTypeLabeler.GetTypeLabel(node) - : null, - }) - .ToList(); - - // The Browse tab is deliberately a purely client-side filter: it does NOT call - // QueryEngine.List/Find (the plan's explicit deviation). Rendered with Verb="list" so the - // shared markdown/json renderer produces a consistent "query list" heading, but Element is null - // (there is no target element, and the workspace-wide list/find verbs already allow null). - CurrentResult = new QueryResult + if (SelectedQueryType == QueryVerb.List) { - Verb = "list", - Element = null, - Summary = [$"{displayed.Count} element(s) match the filter."], - Entries = entries, - }; - - CurrentResultRows = entries.Select(BuildRow).ToList(); - StatusMessage = null; - } + BuildListResult(); + return; + } - /// - /// Runs the Element Query tab's currently-configured verb against - /// 's selected qualified name. Reports every recoverable failure - /// (no selection, empty workspace, unknown qualified name, engine argument rejection) through - /// and leaves unchanged; never throws. - /// - public void RunElementQuery() - { if (IsWorkspaceEmpty) { StatusMessage = "No workspace is open. Add a file or folder from the Workspace panel first."; + CurrentResult = null; + CurrentResultRows = []; return; } - var qualifiedName = ElementQueryPicker.SelectedQualifiedName; + var qualifiedName = Picker.SelectedQualifiedName; if (string.IsNullOrEmpty(qualifiedName)) { - StatusMessage = "Select an element from the list before running a query."; + // Not an error: the user simply hasn't picked a target element yet for this element-scoped + // Query Type. Clear any stale prior result (e.g. from a previously selected Query Type) + // rather than leaving it on screen. + StatusMessage = $"Select an element above to see results for '{SelectedQueryType}'."; + CurrentResult = null; + CurrentResultRows = []; return; } if (!_candidateMap.TryGetValue(qualifiedName, out var node)) { // A qualified name from the picker that isn't in the candidate map means the workspace - // changed under us between the last refresh and this run; treat as a user-visible error - // rather than a crash, and stop before touching QueryEngine. + // changed under us between the last refresh and this recompute; treat as a user-visible + // error rather than a crash, and stop before touching QueryEngine. StatusMessage = $"Element '{qualifiedName}' is no longer in the workspace. Reopen the dialog."; + CurrentResult = null; + CurrentResultRows = []; return; } @@ -252,15 +253,54 @@ public void RunElementQuery() // Convert to a user-visible message rather than a crash, matching the plan's "graceful // handling" acceptance criterion. StatusMessage = $"Could not run query: {ex.Message}"; + CurrentResult = null; + CurrentResultRows = []; } } + /// + /// Rebuilds the shared results panel from the picker's current displayed items. Runs + /// automatically whenever is and + /// emits a change (a + /// chip toggle or search-text edit); may also be invoked directly by tests to assert the "List" + /// Query Type's client-side "list" semantics without spinning up an Avalonia view. + /// + public void BuildListResult() + { + var displayed = Picker.DisplayedItems; + + var entries = displayed + .Select(qualifiedName => new QueryResultEntry + { + QualifiedName = qualifiedName, + Kind = _candidateMap.TryGetValue(qualifiedName, out var node) + ? ElementTypeLabeler.GetTypeLabel(node) + : null, + }) + .ToList(); + + // "List" is deliberately a purely client-side filter: it does NOT call QueryEngine.List/Find + // (the plan's explicit deviation). Rendered with Verb="list" so the shared markdown/json + // renderer produces a consistent "query list" heading, but Element is null (there is no target + // element, and the workspace-wide list/find verbs already allow null). + CurrentResult = new QueryResult + { + Verb = "list", + Element = null, + Summary = [$"{displayed.Count} element(s) match the filter."], + Entries = entries, + }; + + CurrentResultRows = entries.Select(BuildRow).ToList(); + StatusMessage = null; + } + /// /// Copies to the clipboard as the Markdown rendering produced by /// , joined with newline separators. A no-op /// (rather than an exception) when either or - /// is , so the "Copy as Markdown" button - /// can be safely wired unconditionally in the view. + /// is , so the "Copy as Markdown" context + /// menu item can be safely wired unconditionally in the view. /// public async Task CopyResultAsMarkdownAsync() { @@ -291,7 +331,7 @@ public async Task CopyResultAsJsonAsync() /// /// Builds a instance for that - /// reflects the current tab's verb-specific state: is only + /// reflects the current form's verb-specific state: is only /// attached for , and is only /// parsed for . Every option unconditionally carries /// . @@ -301,7 +341,7 @@ public async Task CopyResultAsJsonAsync() public QueryOptions BuildOptions(string qualifiedName) { int? walkDepth = null; - if (SelectedVerb == QueryVerb.Impact + if (SelectedQueryType == QueryVerb.Impact && !string.IsNullOrWhiteSpace(WalkDepthText) && int.TryParse(WalkDepthText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var depth) && depth >= 0) @@ -311,17 +351,17 @@ public QueryOptions BuildOptions(string qualifiedName) return new QueryOptions { - Verb = SelectedVerb, + Verb = SelectedQueryType, Element = qualifiedName, IncludeStdlib = IncludeStdlib, - Direction = SelectedVerb == QueryVerb.Hierarchy ? HierarchyDirection : null, + Direction = SelectedQueryType == QueryVerb.Hierarchy ? HierarchyDirection : null, WalkDepth = walkDepth, }; } /// - /// Refreshes both pickers whenever toggles, matching the plan's - /// "toggling recomputes both tabs' candidates via SetCandidates again" contract. + /// Refreshes the picker whenever toggles, matching the plan's + /// "toggling recomputes both the candidate set and the current result" contract. /// /// The new value of the property. partial void OnIncludeStdlibChanged(bool value) @@ -330,15 +370,59 @@ partial void OnIncludeStdlibChanged(bool value) } /// - /// Observes 's - /// changes and regenerates the Browse-tab result so the shared results panel stays in sync as - /// the user types in the search box or toggles a chip - the Browse tab's "live" contract. + /// Recomputes immediately whenever the user changes the Query Type - the redesign's central + /// "no explicit Run gesture" contract for the verb selector itself. + /// + /// The newly selected Query Type. + partial void OnSelectedQueryTypeChanged(QueryVerb value) + { + RecomputeResult(); + } + + /// + /// Recomputes immediately whenever the Hierarchy direction changes, so a selection already made + /// for stays live as the user flips between up/down/both. + /// + /// The newly selected hierarchy direction. + partial void OnHierarchyDirectionChanged(string? value) + { + RecomputeResult(); + } + + /// + /// Recomputes immediately whenever the Impact walk-depth text changes, so a selection already + /// made for stays live as the user edits the depth limit. + /// + /// The newly edited walk-depth text. + partial void OnWalkDepthTextChanged(string? value) + { + RecomputeResult(); + } + + /// + /// Raises the computed property's change notification whenever + /// changes, so the results panel's context-menu IsEnabled + /// bindings stay in sync without a backing [ObservableProperty] field. + /// + /// The newly computed current result. + partial void OnCurrentResultChanged(QueryResult? value) + { + OnPropertyChanged(nameof(HasCurrentResult)); + } + + /// + /// Observes 's and + /// changes and regenerates the results + /// panel so it stays in sync as the user types in the search box, toggles a chip, or selects an + /// element - the single form's "live" contract, replacing the old two-tab design's single-purpose + /// Browse-only handler. /// - private void OnBrowsePickerPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + private void OnPickerPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { - if (e.PropertyName == nameof(ElementPickerViewModel.DisplayedItems)) + if (e.PropertyName is nameof(ElementPickerViewModel.DisplayedItems) + or nameof(ElementPickerViewModel.SelectedQualifiedName)) { - BuildBrowseResult(); + RecomputeResult(); } } diff --git a/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs index 0ef00de..817291c 100644 --- a/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs +++ b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs @@ -2,6 +2,7 @@ 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; @@ -11,9 +12,12 @@ namespace DemaConsulting.SysML2Workbench.Tests.AppShellSubsystem; /// -/// Unit tests for : verb-specific option construction, Browse tab's -/// purely-client-side result generation, Element Query tab dispatch through , -/// and graceful reporting of every recoverable failure mode through . +/// 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 { @@ -82,8 +86,9 @@ public Task SetTextAsync(string text) /// /// Validates the initial dialog state over an empty shell (no workspace open): no candidates in - /// either picker, is , - /// and is . + /// the picker, is , + /// and the default Query Type () still produces a (empty) client-side + /// result rather than . /// [Fact] public void Construction_EmptyShell_ReportsWorkspaceEmpty() @@ -96,19 +101,19 @@ public void Construction_EmptyShell_ReportsWorkspaceEmpty() // Assert Assert.True(viewModel.IsWorkspaceEmpty); - Assert.Empty(viewModel.BrowsePicker.DisplayedItems); - Assert.Empty(viewModel.ElementQueryPicker.DisplayedItems); - Assert.NotNull(viewModel.CurrentResult); // BuildBrowseResult fires unconditionally + Assert.Equal(QueryVerb.List, viewModel.SelectedQueryType); + Assert.Empty(viewModel.Picker.DisplayedItems); + Assert.NotNull(viewModel.CurrentResult); // BuildListResult fires unconditionally Assert.Equal("list", viewModel.CurrentResult!.Verb); Assert.Null(viewModel.StatusMessage); } /// - /// Validates that both pickers populate from a loaded workspace, exclude stdlib names by default, - /// and start with no default type-filter chip (so every candidate shows immediately). + /// Validates that the single picker populates from a loaded workspace, excludes stdlib names by + /// default, and starts with no default type-filter chip (so every candidate shows immediately). /// [Fact] - public async Task Construction_LoadedWorkspace_PopulatesBothPickers() + public async Task Construction_LoadedWorkspace_PopulatesSinglePicker() { // Arrange await WriteSampleWorkspaceAsync(); @@ -121,21 +126,19 @@ public async Task Construction_LoadedWorkspace_PopulatesBothPickers() // Assert Assert.False(viewModel.IsWorkspaceEmpty); Assert.False(viewModel.IncludeStdlib); - Assert.Empty(viewModel.BrowsePicker.ActiveTypeFilters); - Assert.Empty(viewModel.ElementQueryPicker.ActiveTypeFilters); - Assert.Contains("Sample::Engine", viewModel.BrowsePicker.DisplayedItems); - Assert.Contains("Sample::engineInstance", viewModel.BrowsePicker.DisplayedItems); - Assert.Contains("Sample::Engine", viewModel.ElementQueryPicker.DisplayedItems); + Assert.Empty(viewModel.Picker.ActiveTypeFilters); + Assert.Contains("Sample::Engine", viewModel.Picker.DisplayedItems); + Assert.Contains("Sample::engineInstance", viewModel.Picker.DisplayedItems); } /// - /// Validates that the Browse tab's is a + /// 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 BrowseTab_BuildsClientSideListResult() + public async Task ListQueryType_BuildsClientSideListResult() { // Arrange await WriteSampleWorkspaceAsync(); @@ -152,17 +155,17 @@ public async Task BrowseTab_BuildsClientSideListResult() Assert.Null(result.Element); Assert.Single(result.Summary); Assert.Contains("element(s) match the filter", result.Summary[0]); - Assert.Equal(viewModel.BrowsePicker.DisplayedItems.Count, result.Entries.Count); + Assert.Equal(viewModel.Picker.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 Browse tab's picker narrows (via a search text edit here), the Browse - /// result regenerates automatically - the plan's "live" contract for the tab. + /// 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 BrowseTab_SearchTextEdit_RegeneratesResultLive() + public async Task ListQueryType_SearchTextEdit_RegeneratesResultLive() { // Arrange await WriteSampleWorkspaceAsync(); @@ -171,7 +174,7 @@ public async Task BrowseTab_SearchTextEdit_RegeneratesResultLive() var viewModel = new QueryDialogViewModel(shell); // Act - viewModel.BrowsePicker.SearchText = "engineInstance"; + viewModel.Picker.SearchText = "engineInstance"; // Assert Assert.NotNull(viewModel.CurrentResult); @@ -180,70 +183,67 @@ public async Task BrowseTab_SearchTextEdit_RegeneratesResultLive() } /// - /// Validates that with no selection stops - /// before touching , reports a user-visible - /// , and leaves the Browse-derived result in - /// place. + /// 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 RunElementQuery_NoSelection_ReportsStatusMessage() + public async Task RecomputeResult_ElementScopedVerbNoSelection_ReportsPromptAndClearsRows() { // Arrange await WriteSampleWorkspaceAsync(); using var shell = CreateShell(); await shell.AddFolderSourceAsync(_tempRoot); var viewModel = new QueryDialogViewModel(shell); - var browseResult = viewModel.CurrentResult; - viewModel.SelectedVerb = QueryVerb.Describe; - // Act - viewModel.RunElementQuery(); + // Act: switch to Describe with no selection on the picker + viewModel.SelectedQueryType = QueryVerb.Describe; // Assert Assert.NotNull(viewModel.StatusMessage); - Assert.Contains("Select an element", viewModel.StatusMessage); - Assert.Same(browseResult, viewModel.CurrentResult); + Assert.Contains("Select an element above", viewModel.StatusMessage); + Assert.Contains("Describe", viewModel.StatusMessage); + Assert.Null(viewModel.CurrentResult); + Assert.Empty(viewModel.CurrentResultRows); } /// - /// Validates that over an empty workspace - /// stops early with a user-visible status, without touching . + /// Validates that selecting an element-scoped Query Type over an empty workspace stops early with + /// a user-visible status, without touching . /// [Fact] - public void RunElementQuery_EmptyWorkspace_ReportsStatusMessage() + public void RecomputeResult_EmptyWorkspace_ReportsStatusMessage() { // Arrange using var shell = CreateShell(); var viewModel = new QueryDialogViewModel(shell); - viewModel.ElementQueryPicker.SelectedQualifiedName = "something"; - viewModel.SelectedVerb = QueryVerb.Describe; // Act - viewModel.RunElementQuery(); + viewModel.SelectedQueryType = QueryVerb.Describe; // Assert Assert.NotNull(viewModel.StatusMessage); Assert.Contains("No workspace", viewModel.StatusMessage); + Assert.Null(viewModel.CurrentResult); } /// - /// Validates that a valid Describe run over a real workspace dispatches through - /// : the resulting carries the - /// kebab-case verb token and the resolved element's qualified name. + /// 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 RunElementQuery_Describe_DispatchesThroughEngine() + public async Task RecomputeResult_DescribeWithSelection_DispatchesThroughEngineImmediately() { // Arrange await WriteSampleWorkspaceAsync(); using var shell = CreateShell(); await shell.AddFolderSourceAsync(_tempRoot); var viewModel = new QueryDialogViewModel(shell); - viewModel.ElementQueryPicker.SelectedQualifiedName = "Sample::Engine"; - viewModel.SelectedVerb = QueryVerb.Describe; + viewModel.SelectedQueryType = QueryVerb.Describe; - // Act - viewModel.RunElementQuery(); + // 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); @@ -252,9 +252,110 @@ public async Task RunElementQuery_Describe_DispatchesThroughEngine() 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 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 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 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 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 verb is + /// when the current Query Type is /// , matching the plan's per-verb visibility rules. /// [Fact] @@ -263,7 +364,7 @@ public void BuildOptions_HierarchyVerb_AttachesDirection() // Arrange using var shell = CreateShell(); var viewModel = new QueryDialogViewModel(shell); - viewModel.SelectedVerb = QueryVerb.Hierarchy; + viewModel.SelectedQueryType = QueryVerb.Hierarchy; viewModel.HierarchyDirection = "up"; // Act @@ -286,7 +387,7 @@ public void BuildOptions_NonHierarchyVerb_OmitsDirection() // Arrange using var shell = CreateShell(); var viewModel = new QueryDialogViewModel(shell); - viewModel.SelectedVerb = QueryVerb.Describe; + viewModel.SelectedQueryType = QueryVerb.Describe; viewModel.HierarchyDirection = "up"; // Act @@ -307,7 +408,7 @@ public void BuildOptions_ImpactVerbWithWalkDepth_ParsesWalkDepth() // Arrange using var shell = CreateShell(); var viewModel = new QueryDialogViewModel(shell); - viewModel.SelectedVerb = QueryVerb.Impact; + viewModel.SelectedQueryType = QueryVerb.Impact; viewModel.WalkDepthText = "3"; // Act @@ -334,7 +435,7 @@ public void BuildOptions_ImpactVerbWithInvalidWalkDepth_LeavesNull(string? walkD // Arrange using var shell = CreateShell(); var viewModel = new QueryDialogViewModel(shell); - viewModel.SelectedVerb = QueryVerb.Impact; + viewModel.SelectedQueryType = QueryVerb.Impact; viewModel.WalkDepthText = walkDepthText; // Act @@ -355,7 +456,7 @@ public void BuildOptions_PropagatesIncludeStdlib() using var shell = CreateShell(); var viewModel = new QueryDialogViewModel(shell); viewModel.IncludeStdlib = true; - viewModel.SelectedVerb = QueryVerb.Uses; + viewModel.SelectedQueryType = QueryVerb.Uses; // Act var options = viewModel.BuildOptions("Some::Element"); @@ -365,35 +466,54 @@ public void BuildOptions_PropagatesIncludeStdlib() } /// - /// Validates that toggling recomputes both - /// pickers' candidates (which is the mechanism the checkbox uses to add/remove stdlib names - /// from view). At minimum the toggle must not leave state broken; a re-toggle back yields the - /// same displayed set. + /// 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 IncludeStdlibToggle_RefreshesBothPickers() + public async Task IncludeStdlibToggle_RefreshesPickerAndRecomputesResult() { // Arrange await WriteSampleWorkspaceAsync(); using var shell = CreateShell(); await shell.AddFolderSourceAsync(_tempRoot); var viewModel = new QueryDialogViewModel(shell); - var initialBrowseCount = viewModel.BrowsePicker.DisplayedItems.Count; - var initialQueryCount = viewModel.ElementQueryPicker.DisplayedItems.Count; + 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) + // 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 withStdlibBrowseCount = viewModel.BrowsePicker.DisplayedItems.Count; + var withStdlibCount = viewModel.Picker.DisplayedItems.Count; - // Assert - Assert.True(withStdlibBrowseCount >= initialBrowseCount); + // 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(initialBrowseCount, viewModel.BrowsePicker.DisplayedItems.Count); - Assert.Equal(initialQueryCount, viewModel.ElementQueryPicker.DisplayedItems.Count); + Assert.Equal(initialCount, viewModel.Picker.DisplayedItems.Count); } /// @@ -411,9 +531,8 @@ public async Task CopyResultAsMarkdownAsync_WritesRenderedMarkdownToClipboard() var viewModel = new QueryDialogViewModel(shell); var clipboard = new FakeClipboardService(); viewModel.ClipboardService = clipboard; - viewModel.ElementQueryPicker.SelectedQualifiedName = "Sample::Engine"; - viewModel.SelectedVerb = QueryVerb.Describe; - viewModel.RunElementQuery(); + viewModel.SelectedQueryType = QueryVerb.Describe; + viewModel.Picker.SelectedQualifiedName = "Sample::Engine"; // Act await viewModel.CopyResultAsMarkdownAsync(); @@ -439,9 +558,8 @@ public async Task CopyResultAsJsonAsync_WritesRenderedJsonToClipboard() var viewModel = new QueryDialogViewModel(shell); var clipboard = new FakeClipboardService(); viewModel.ClipboardService = clipboard; - viewModel.ElementQueryPicker.SelectedQualifiedName = "Sample::Engine"; - viewModel.SelectedVerb = QueryVerb.Describe; - viewModel.RunElementQuery(); + viewModel.SelectedQueryType = QueryVerb.Describe; + viewModel.Picker.SelectedQualifiedName = "Sample::Engine"; // Act await viewModel.CopyResultAsJsonAsync(); @@ -453,7 +571,9 @@ public async Task CopyResultAsJsonAsync_WritesRenderedJsonToClipboard() /// /// Validates that the copy methods are a no-op (no exception, no clipboard write) when - /// is . + /// 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 CopyMethods_NoResult_AreNoOps() @@ -461,10 +581,8 @@ public async Task CopyMethods_NoResult_AreNoOps() // Arrange using var shell = CreateShell(); var viewModel = new QueryDialogViewModel(shell); - // Force CurrentResult back to null (Construction sets it to the Browse-tab list result). - viewModel.GetType() - .GetProperty(nameof(QueryDialogViewModel.CurrentResult))! - .SetValue(viewModel, null); + viewModel.SelectedQueryType = QueryVerb.Describe; + Assert.Null(viewModel.CurrentResult); var clipboard = new FakeClipboardService(); viewModel.ClipboardService = clipboard; @@ -477,19 +595,19 @@ public async Task CopyMethods_NoResult_AreNoOps() } /// - /// Validates that exposes exactly the ten - /// element-scoped verbs, in the plan's stated order, and never includes List or - /// Find. + /// 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 ElementScopedVerbs_HasExpectedTenVerbs() + public void QueryTypes_HasExpectedElevenEntries() { // Assert - Assert.Equal(10, QueryDialogViewModel.ElementScopedVerbs.Count); - Assert.DoesNotContain(QueryVerb.List, QueryDialogViewModel.ElementScopedVerbs); - Assert.DoesNotContain(QueryVerb.Find, QueryDialogViewModel.ElementScopedVerbs); - Assert.Contains(QueryVerb.Describe, QueryDialogViewModel.ElementScopedVerbs); - Assert.Contains(QueryVerb.Hierarchy, QueryDialogViewModel.ElementScopedVerbs); + 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); } /// diff --git a/test/OtsSoftwareTests/AvaloniaTests.cs b/test/OtsSoftwareTests/AvaloniaTests.cs index 611fdc0..2d81beb 100644 --- a/test/OtsSoftwareTests/AvaloniaTests.cs +++ b/test/OtsSoftwareTests/AvaloniaTests.cs @@ -341,19 +341,22 @@ private static IEnumerable LogicalTreeMenuItems(Avalonia.LogicalTree.I } /// - /// End-to-end regression for the new Query dialog: constructs a real + /// 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), runs an element-scoped - /// Describe verb through the real - /// via the "Run Query" button, clicks "Copy as Markdown", and asserts the headless platform's - /// real clipboard now holds the exact text + /// ShowDialog path, which blocks its calling turn until closed), selects the "Describe" + /// entry on the single form's Query Type combo, selects an element on its one always-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 pattern. + /// Mirrors 's right-click + /// recipe. /// [AvaloniaFact] - public async Task QueryDialog_RunDescribeAndCopyAsMarkdown_PlacesRenderedMarkdownOnClipboard() + public async Task QueryDialog_SelectDescribeAndCopyAsMarkdown_PlacesRenderedMarkdownOnClipboard() { // Arrange: a real workspace with one part def, so Describe has something meaningful to say await File.WriteAllTextAsync( @@ -382,37 +385,44 @@ await File.WriteAllTextAsync( dialog.Show(window); Dispatcher.UIThread.RunJobs(); - // Assert: both pickers are populated from the real workspace - var elementQueryPickerListBox = FindByName(dialog, "PickerItemsListBox"); - Assert.NotNull(elementQueryPickerListBox); - - // Switch to the Element Query tab (index 1) so its content - including RunQueryButton and the - // ElementQueryPickerView - is realized in the visual tree. TabControl only materializes the - // active tab's content, so the "Run Query" button doesn't otherwise exist yet. - var tabControl = FindByName(dialog, "QueryTabControl"); - Assert.NotNull(tabControl); - tabControl!.SelectedIndex = 1; + // Assert: the single picker is populated from the real workspace, and no TabControl/tab-switch + // exists in this design - the picker's ListBox is realized immediately. + var pickerListBox = FindByName(dialog, "PickerItemsListBox"); + Assert.NotNull(pickerListBox); + + // 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(); - // Drive the Element Query tab: pick Engine, keep the default Describe verb, click Run Query var vm = (QueryDialogViewModel)dialog.DataContext!; - vm.ElementQueryPicker.SelectedQualifiedName = "Sample::Engine"; - Dispatcher.UIThread.RunJobs(); + Assert.Equal(DemaConsulting.SysML2Tools.Query.QueryVerb.Describe, vm.SelectedQueryType); - var runButton = FindByName + + + + + + + + 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 index d754121..5ce6ff6 100644 --- a/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerView.axaml +++ b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerView.axaml @@ -6,38 +6,8 @@ - - - - - - - - - - - - - - - - - - + + /// Thin Avalonia code-behind for the shared -backed -/// picker . 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. +/// 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. Hosting parents that need to react to the flyout or -/// chip lifecycle themselves should observe 's -/// ActiveTypeFilters collection instead of hooking events on this control. +/// into this view's named children. /// public partial class ElementPickerView : UserControl { @@ -26,64 +25,14 @@ public partial class ElementPickerView : UserControl public ElementPickerView() { InitializeComponent(); - - AddTypeFilterButton.Flyout!.Opened += OnAddTypeFilterFlyoutOpening; - AddableTypeFilterListBox.SelectionChanged += OnAddableTypeFilterSelectionChanged; } /// /// 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 (, - /// , and - /// ) from their own code-behind - /// without needing to know the control's internal children. + /// methods from their own code-behind without needing to know the control's internal + /// children. /// public ElementPickerViewModel? ViewModel => DataContext as ElementPickerViewModel; - - /// - /// 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/ElementPickerViewModel.cs b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerViewModel.cs index 4396901..cef364d 100644 --- a/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerViewModel.cs +++ b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementPickerViewModel.cs @@ -1,5 +1,4 @@ using System.Collections.ObjectModel; -using System.Collections.Specialized; using CommunityToolkit.Mvvm.ComponentModel; namespace DemaConsulting.SysML2Workbench.ElementPickerSubsystem; @@ -12,8 +11,16 @@ namespace DemaConsulting.SysML2Workbench.ElementPickerSubsystem; /// /// 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. The -/// view model is deliberately independent of ViewDefinitionModel, MainWindowShell, +/// 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 @@ -23,57 +30,64 @@ namespace DemaConsulting.SysML2Workbench.ElementPickerSubsystem; /// public sealed partial class ElementPickerViewModel : 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 = []; - [ObservableProperty] private string? _selectedQualifiedName; /// /// Creates the picker 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 . + /// type filters, empty search text, an empty displayed list, and no selection. + /// Callers populate it later by calling . /// public ElementPickerViewModel() { - ActiveTypeFilters.CollectionChanged += OnActiveTypeFiltersCollectionChanged; + 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; } /// - /// Type labels currently applied as chips over the 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). 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. + /// 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 { get; } = []; + public ObservableCollection ActiveTypeFilters => Filter.ActiveTypeFilters; /// - /// Replaces the picker's master candidate list with , - /// recomputes from that list, resets - /// to a single-chip default (or empty) per - /// , and recomputes - /// . Also clears so - /// a stale prior selection cannot linger after a workspace-derived refresh. + /// 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 @@ -93,38 +107,16 @@ 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); - } + 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; - - 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. + /// Pass-through to 's . /// /// /// Type labels not currently applied as an active filter chip, in the same order as @@ -132,92 +124,55 @@ public void SetCandidates( /// public IReadOnlyList GetAddableTypeLabels() { - return AvailableTypeLabels - .Where(label => !ActiveTypeFilters.Contains(label)) - .ToList(); + return Filter.GetAddableTypeLabels(); } /// - /// 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. + /// Pass-through to 's . /// /// 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(); + Filter.AddTypeFilter(typeLabel); } /// - /// Removes from if - /// present, then recomputes . A no-op beyond the - /// recompute when the label is not currently active. + /// Pass-through to 's . /// /// Type label chip to remove. Must not be . /// Thrown when is . public void RemoveTypeFilter(string typeLabel) { - ArgumentNullException.ThrowIfNull(typeLabel); - - ActiveTypeFilters.Remove(typeLabel); - - RecomputeDisplayedItems(); + Filter.RemoveTypeFilter(typeLabel); } /// - /// 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. + /// 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. /// - private void RecomputeDisplayedItems() + /// The instance raising the notification. + /// The changed property's event arguments. + private void OnFilterPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { - IEnumerable<(string QualifiedName, string TypeLabel)> query = _candidates; - - if (ActiveTypeFilters.Count > 0) + switch (e.PropertyName) { - query = query.Where(entry => ActiveTypeFilters.Contains(entry.TypeLabel)); - } + case nameof(ElementFilterViewModel.DisplayedItems): + OnPropertyChanged(nameof(DisplayedItems)); + break; - var searchText = SearchText; - if (!string.IsNullOrEmpty(searchText)) - { - query = query.Where(entry => entry.QualifiedName.Contains(searchText, StringComparison.OrdinalIgnoreCase)); - } + case nameof(ElementFilterViewModel.AvailableTypeLabels): + OnPropertyChanged(nameof(AvailableTypeLabels)); + break; - 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 picker 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(); + case nameof(ElementFilterViewModel.SearchText): + OnPropertyChanged(nameof(SearchText)); + break; + } } } diff --git a/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs index 817291c..25654e4 100644 --- a/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs +++ b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs @@ -102,15 +102,18 @@ public void Construction_EmptyShell_ReportsWorkspaceEmpty() // Assert Assert.True(viewModel.IsWorkspaceEmpty); Assert.Equal(QueryVerb.List, viewModel.SelectedQueryType); - Assert.Empty(viewModel.Picker.DisplayedItems); + Assert.Empty(viewModel.FilterOnly.DisplayedItems); Assert.NotNull(viewModel.CurrentResult); // BuildListResult fires unconditionally Assert.Equal("list", viewModel.CurrentResult!.Verb); Assert.Null(viewModel.StatusMessage); } /// - /// Validates that the single picker populates from a loaded workspace, excludes stdlib names by - /// default, and starts with no default type-filter chip (so every candidate shows immediately). + /// 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 Construction_LoadedWorkspace_PopulatesSinglePicker() @@ -129,6 +132,9 @@ public async Task Construction_LoadedWorkspace_PopulatesSinglePicker() 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); } /// @@ -155,7 +161,7 @@ public async Task ListQueryType_BuildsClientSideListResult() Assert.Null(result.Element); Assert.Single(result.Summary); Assert.Contains("element(s) match the filter", result.Summary[0]); - Assert.Equal(viewModel.Picker.DisplayedItems.Count, result.Entries.Count); + 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"); } @@ -174,7 +180,7 @@ public async Task ListQueryType_SearchTextEdit_RegeneratesResultLive() var viewModel = new QueryDialogViewModel(shell); // Act - viewModel.Picker.SearchText = "engineInstance"; + viewModel.FilterOnly.SearchText = "engineInstance"; // Assert Assert.NotNull(viewModel.CurrentResult); diff --git a/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementFilterViewModelTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementFilterViewModelTests.cs new file mode 100644 index 0000000..59c4c67 --- /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 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 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 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 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 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 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 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 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 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 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 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 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 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 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/OtsSoftwareTests/AvaloniaTests.cs b/test/OtsSoftwareTests/AvaloniaTests.cs index 2d81beb..758b0d2 100644 --- a/test/OtsSoftwareTests/AvaloniaTests.cs +++ b/test/OtsSoftwareTests/AvaloniaTests.cs @@ -345,12 +345,14 @@ private static IEnumerable LogicalTreeMenuItems(Avalonia.LogicalTree.I /// , 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), selects the "Describe" - /// entry on the single form's Query Type combo, selects an element on its one always-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 + /// 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. @@ -385,10 +387,15 @@ await File.WriteAllTextAsync( dialog.Show(window); Dispatcher.UIThread.RunJobs(); - // Assert: the single picker is populated from the real workspace, and no TabControl/tab-switch - // exists in this design - the picker's ListBox is realized immediately. - var pickerListBox = FindByName(dialog, "PickerItemsListBox"); - Assert.NotNull(pickerListBox); + // 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 @@ -401,6 +408,14 @@ await File.WriteAllTextAsync( 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"; From 37d4cf18f469991a5de83a131cbaff87a8204759 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 21 Jul 2026 08:42:20 -0400 Subject: [PATCH 07/11] Fix Query dialog results header scrolling out of view; switch to Avalonia DataGrid - Restructured the results panel so the summary caption and table header are pinned above a scrollable rows area instead of scrolling away with the rows. - Replaced the hand-rolled Grid+ItemsControl table with Avalonia's real DataGrid control (Avalonia.Controls.DataGrid package), which provides pinned headers, resizable columns, and row virtualization out of the box. The Direction column's visibility and the per-row Notes tooltip are preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/DemaConsulting.SysML2Workbench/App.axaml | 4 + .../AppShellSubsystem/QueryDialogView.axaml | 79 +++++++++---------- .../QueryDialogView.axaml.cs | 8 +- .../DemaConsulting.SysML2Workbench.csproj | 1 + 4 files changed, 45 insertions(+), 47 deletions(-) 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/QueryDialogView.axaml b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml index 5e8d025..da2f4c9 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml @@ -65,49 +65,42 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml.cs index ced32bc..925932d 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml.cs +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml.cs @@ -180,10 +180,10 @@ private void ApplyResultVisibility() SummaryItemsControl.ItemsSource = result?.Summary; EntriesTableBorder.IsVisible = hasEntries; - // The Direction column is dependency-verb specific per the plan; keep the header in lockstep - // with the row-template columns by only showing it when the current result is a dependency-verb - // result. - DirectionHeaderTextBlock.IsVisible = string.Equals(result?.Verb, "dependencies", StringComparison.Ordinal); + // The Direction column is dependency-verb specific per the plan; only show it when the current + // result is a dependency-verb result. Indexed rather than named: DataGridColumn does not + // participate in Avalonia's compiled-bindings field generation the way a Control does. + EntriesDataGrid.Columns[3].IsVisible = string.Equals(result?.Verb, "dependencies", StringComparison.Ordinal); } private void OnQueryTypeSelectionChanged(object? sender, SelectionChangedEventArgs e) diff --git a/src/DemaConsulting.SysML2Workbench/DemaConsulting.SysML2Workbench.csproj b/src/DemaConsulting.SysML2Workbench/DemaConsulting.SysML2Workbench.csproj index d44f5e0..4400604 100644 --- a/src/DemaConsulting.SysML2Workbench/DemaConsulting.SysML2Workbench.csproj +++ b/src/DemaConsulting.SysML2Workbench/DemaConsulting.SysML2Workbench.csproj @@ -16,6 +16,7 @@ + From 5d76b7c9d9a40516b0d99c168ac67b8c22861abe Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 21 Jul 2026 09:06:36 -0400 Subject: [PATCH 08/11] Fix Query dialog results-panel context menu becoming a one-shot after DataGrid switch The results panel's real Avalonia DataGrid captures the pointer for its own cell-selection/column-drag handling; that capture wasn't always released cleanly once a row was selected and the ContextMenu popup had opened and closed over it, silently swallowing every right-click after the first. Intercept the right mouse button during the pointer-pressed tunnel phase (before the DataGrid's own bubble-phase handlers run), release any stray pointer capture, and open the ContextMenu explicitly instead of relying on Avalonia's automatic ContextRequested routing. Extend the existing Query dialog Avalonia e2e test to open the context menu a second time and copy as JSON, verifying the fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../QueryDialogView.axaml.cs | 43 +++++++++++++++++++ test/OtsSoftwareTests/AvaloniaTests.cs | 27 +++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml.cs index 925932d..cafa652 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml.cs +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Interactivity; using DemaConsulting.SysML2Tools.Query; @@ -45,6 +46,10 @@ public QueryDialogView(MainWindowShell shell) QueryTypeComboBox.SelectionChanged += OnQueryTypeSelectionChanged; HierarchyDirectionComboBox.SelectionChanged += OnHierarchyDirectionSelectionChanged; + // Open the results panel's ContextMenu explicitly on every right-click instead of relying on + // Avalonia's automatic ContextRequested routing - see OnResultsBorderPointerPressed for why. + ResultsBorder.AddHandler(PointerPressedEvent, OnResultsBorderPointerPressed, RoutingStrategies.Tunnel); + DataContext = new QueryDialogViewModel(shell); AttachViewModel(); @@ -226,4 +231,42 @@ private void OnCloseButtonClick(object? sender, RoutedEventArgs e) { Close(); } + + /// + /// Explicitly opens the results panel's ContextMenu on every right-click, instead of + /// relying on Avalonia's automatic ContextRequested routing. The results panel hosts a + /// real , which captures the pointer for its own cell-selection / column + /// drag-detection handling; that capture is not always released cleanly once a row is selected + /// and a popup has opened and closed over it, which + /// otherwise silently swallows every right-click on an already-selected row after the first + /// ("one-shot" copy-menu bug reported when the results panel switched to a real DataGrid). + /// Intercepting the right button during the tunnel phase - before the DataGrid's own bubble-phase + /// handlers run - guarantees the menu opens every time regardless of the grid's internal pointer + /// state, while left-clicks are left completely untouched so normal row selection still works. + /// + private void OnResultsBorderPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(ResultsBorder).Properties.IsRightButtonPressed) + { + return; + } + + // Release any pointer capture the DataGrid may be holding from a prior press so it can't + // interfere with (or itself swallow) this right-click. + e.Pointer.Capture(null); + + var menu = ResultsBorder.ContextMenu; + if (menu is null) + { + return; + } + + if (menu.IsOpen) + { + menu.Close(); + } + + menu.Open(ResultsBorder); + e.Handled = true; + } } diff --git a/test/OtsSoftwareTests/AvaloniaTests.cs b/test/OtsSoftwareTests/AvaloniaTests.cs index 758b0d2..af113ba 100644 --- a/test/OtsSoftwareTests/AvaloniaTests.cs +++ b/test/OtsSoftwareTests/AvaloniaTests.cs @@ -425,8 +425,9 @@ await File.WriteAllTextAsync( 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", - // mirroring DiagramContextMenu_CopyAsSysml_CopiesSnippetToClipboard's exact recipe. + // 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; @@ -453,6 +454,28 @@ await File.WriteAllTextAsync( 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(); } From d0c534703a7c32d34429ba9ee33744174a6fb88a Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 21 Jul 2026 11:19:36 -0400 Subject: [PATCH 09/11] Bump SysML2Tools packages to 0.1.0-beta.16 Picks up the upstream fix for the describe verb's Children-count mismatch and the new verb-specific bold-text entries label/'no entries' fallback in QueryResultRenderer.RenderMarkdown (e.g. '**Children**'/'_No children._' for describe, '**Uses**'/'_No outgoing references._' for uses). No workbench code changes needed: the Query dialog's Markdown/JSON copy tests compute their expected text by calling QueryResultRenderer directly rather than asserting hardcoded strings, so they automatically reflect the new format. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DemaConsulting.SysML2Workbench.csproj | 6 +++--- test/OtsSoftwareTests/OtsSoftwareTests.csproj | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/DemaConsulting.SysML2Workbench/DemaConsulting.SysML2Workbench.csproj b/src/DemaConsulting.SysML2Workbench/DemaConsulting.SysML2Workbench.csproj index 4400604..4239085 100644 --- a/src/DemaConsulting.SysML2Workbench/DemaConsulting.SysML2Workbench.csproj +++ b/src/DemaConsulting.SysML2Workbench/DemaConsulting.SysML2Workbench.csproj @@ -18,9 +18,9 @@ - - - + + + diff --git a/test/OtsSoftwareTests/OtsSoftwareTests.csproj b/test/OtsSoftwareTests/OtsSoftwareTests.csproj index bf17aa7..04fd181 100644 --- a/test/OtsSoftwareTests/OtsSoftwareTests.csproj +++ b/test/OtsSoftwareTests/OtsSoftwareTests.csproj @@ -16,8 +16,8 @@ - - + + From 146b48f5c39de8c95085c52949d854366611fb63 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 21 Jul 2026 12:05:59 -0400 Subject: [PATCH 10/11] Fix reqstream test-name matching for ElementPicker/ElementFilter/QueryDialog requirements ReqStream matches the tests: list entries by bare method name only (per docs/reqstream/README and empirical verification), not by ClassName.MethodName. The requirement files for ElementPickerSubsystem and AppShellSubsystem-QueryDialog had accidentally qualified their test entries with the containing class name to disambiguate identically-named tests across ElementPickerViewModelTests and ElementFilterViewModelTests, which meant ReqStream could never find a matching TRX entry and reported these 16 requirements as unsatisfied. Strip the class-name prefix so the bare method names match, restoring full local reqstream --enforce satisfaction (107/107 non-platform-specific requirements). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../app-shell-subsystem/query-dialog.yaml | 46 +++++++++---------- .../element-picker-subsystem.yaml | 4 +- .../element-filter.yaml | 26 +++++------ .../element-picker.yaml | 38 +++++++-------- 4 files changed, 57 insertions(+), 57 deletions(-) diff --git a/docs/reqstream/sysml2-workbench/app-shell-subsystem/query-dialog.yaml b/docs/reqstream/sysml2-workbench/app-shell-subsystem/query-dialog.yaml index da2df7a..603c155 100644 --- a/docs/reqstream/sysml2-workbench/app-shell-subsystem/query-dialog.yaml +++ b/docs/reqstream/sysml2-workbench/app-shell-subsystem/query-dialog.yaml @@ -16,9 +16,9 @@ sections: opened, the stdlib toggle must be honored consistently, and a clear signal is needed when there is nothing to query yet. tests: - - 'QueryDialogViewModelTests.Construction_EmptyShell_ReportsWorkspaceEmpty' - - 'QueryDialogViewModelTests.Construction_LoadedWorkspace_PopulatesSinglePicker' - - 'QueryDialogViewModelTests.IncludeStdlibToggle_RefreshesPickerAndRecomputesResult' + - 'Construction_EmptyShell_ReportsWorkspaceEmpty' + - 'Construction_LoadedWorkspace_PopulatesSinglePicker' + - 'IncludeStdlibToggle_RefreshesPickerAndRecomputesResult' - id: 'SysML2Workbench-AppShellSubsystem-QueryDialog-BrowseTabIsClientSideList' title: 'The QueryDialog''s merged "List" Query Type shall produce a client-built QueryResult @@ -30,8 +30,8 @@ sections: "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: - - 'QueryDialogViewModelTests.ListQueryType_BuildsClientSideListResult' - - 'QueryDialogViewModelTests.ListQueryType_SearchTextEdit_RegeneratesResultLive' + - 'ListQueryType_BuildsClientSideListResult' + - 'ListQueryType_SearchTextEdit_RegeneratesResultLive' - id: 'SysML2Workbench-AppShellSubsystem-QueryDialog-ElementQueryDispatchesThroughEngine' title: 'The QueryDialog''s single Query Type combo box shall offer all ten element-scoped @@ -49,18 +49,18 @@ sections: defining usability improvement is that every relevant edit updates the results live rather than requiring a separate "Run" click. tests: - - 'QueryDialogViewModelTests.RecomputeResult_DescribeWithSelection_DispatchesThroughEngineImmediately' - - 'QueryDialogViewModelTests.SwitchingQueryType_FromDescribeToList_ShowsListResultImmediately' - - 'QueryDialogViewModelTests.SwitchingQueryType_FromListToDescribeNoSelection_ShowsSelectPrompt' - - 'QueryDialogViewModelTests.HierarchyDirectionChange_WithSelection_RecomputesImmediately' - - 'QueryDialogViewModelTests.WalkDepthTextChange_WithSelection_RecomputesImmediately' - - 'QueryDialogViewModelTests.BuildOptions_HierarchyVerb_AttachesDirection' - - 'QueryDialogViewModelTests.BuildOptions_NonHierarchyVerb_OmitsDirection' - - 'QueryDialogViewModelTests.BuildOptions_ImpactVerbWithWalkDepth_ParsesWalkDepth' - - 'QueryDialogViewModelTests.BuildOptions_ImpactVerbWithInvalidWalkDepth_LeavesNull' - - 'QueryDialogViewModelTests.BuildOptions_PropagatesIncludeStdlib' - - 'QueryDialogViewModelTests.QueryTypes_HasExpectedElevenEntries' - - 'QueryDialogViewModelTests.HierarchyDirectionOptions_HasExpectedThree' + - 'RecomputeResult_DescribeWithSelection_DispatchesThroughEngineImmediately' + - 'SwitchingQueryType_FromDescribeToList_ShowsListResultImmediately' + - 'SwitchingQueryType_FromListToDescribeNoSelection_ShowsSelectPrompt' + - 'HierarchyDirectionChange_WithSelection_RecomputesImmediately' + - 'WalkDepthTextChange_WithSelection_RecomputesImmediately' + - 'BuildOptions_HierarchyVerb_AttachesDirection' + - 'BuildOptions_NonHierarchyVerb_OmitsDirection' + - 'BuildOptions_ImpactVerbWithWalkDepth_ParsesWalkDepth' + - 'BuildOptions_ImpactVerbWithInvalidWalkDepth_LeavesNull' + - 'BuildOptions_PropagatesIncludeStdlib' + - 'QueryTypes_HasExpectedElevenEntries' + - 'HierarchyDirectionOptions_HasExpectedThree' - id: 'SysML2Workbench-AppShellSubsystem-QueryDialog-HandleFailuresGracefully' title: 'The QueryDialog shall report every recoverable failure or informational prompt (no @@ -74,8 +74,8 @@ sections: 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: - - 'QueryDialogViewModelTests.RecomputeResult_ElementScopedVerbNoSelection_ReportsPromptAndClearsRows' - - 'QueryDialogViewModelTests.RecomputeResult_EmptyWorkspace_ReportsStatusMessage' + - 'RecomputeResult_ElementScopedVerbNoSelection_ReportsPromptAndClearsRows' + - 'RecomputeResult_EmptyWorkspace_ReportsStatusMessage' - id: 'SysML2Workbench-AppShellSubsystem-QueryDialog-CopyResultToClipboard' title: 'The QueryDialog shall render the current QueryResult through @@ -90,9 +90,9 @@ sections: safe when invoked with no result loaded, and moving the actions to a context menu keeps the results panel free of toolbar clutter. tests: - - 'QueryDialogViewModelTests.CopyResultAsMarkdownAsync_WritesRenderedMarkdownToClipboard' - - 'QueryDialogViewModelTests.CopyResultAsJsonAsync_WritesRenderedJsonToClipboard' - - 'QueryDialogViewModelTests.CopyMethods_NoResult_AreNoOps' + - 'CopyResultAsMarkdownAsync_WritesRenderedMarkdownToClipboard' + - 'CopyResultAsJsonAsync_WritesRenderedJsonToClipboard' + - 'CopyMethods_NoResult_AreNoOps' - id: 'SysML2Workbench-AppShellSubsystem-QueryDialog-EndToEndFromMenu' title: 'The Query menu''s "Run Query..." item shall open the QueryDialog against the main @@ -106,4 +106,4 @@ sections: end-to-end against a headless Avalonia stack catches menu/dialog/clipboard wiring regressions the pure view-model tests cannot see. tests: - - 'AvaloniaTests.QueryDialog_SelectDescribeAndCopyAsMarkdown_PlacesRenderedMarkdownOnClipboard' + - 'QueryDialog_SelectDescribeAndCopyAsMarkdown_PlacesRenderedMarkdownOnClipboard' diff --git a/docs/reqstream/sysml2-workbench/element-picker-subsystem.yaml b/docs/reqstream/sysml2-workbench/element-picker-subsystem.yaml index a5a8d82..6dc0463 100644 --- a/docs/reqstream/sysml2-workbench/element-picker-subsystem.yaml +++ b/docs/reqstream/sysml2-workbench/element-picker-subsystem.yaml @@ -17,7 +17,7 @@ sections: - 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-AcceptCallerBuiltCandidates' - 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-FilterByTypeAndText' tests: - - 'ElementPickerViewModelTests.SetCandidates_AvailableTypeLabels_IsDistinctAndSorted' + - 'SetCandidates_AvailableTypeLabels_IsDistinctAndSorted' - id: 'SysML2Workbench-ElementPickerSubsystem-DecoupleFromWorkspace' title: 'The ElementPickerSubsystem shall not itself depend on MainWindowShell, WorkspaceModel, or @@ -30,4 +30,4 @@ sections: children: - 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-AcceptCallerBuiltCandidates' tests: - - 'ElementPickerViewModelTests.Construction_HasEmptyInitialState' + - '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 index 8e3859d..668129f 100644 --- a/docs/reqstream/sysml2-workbench/element-picker-subsystem/element-filter.yaml +++ b/docs/reqstream/sysml2-workbench/element-picker-subsystem/element-filter.yaml @@ -17,11 +17,11 @@ sections: 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: - - 'ElementFilterViewModelTests.SetCandidates_NullCandidates_Throws' - - 'ElementFilterViewModelTests.SetCandidates_AvailableTypeLabels_IsDistinctAndSorted' - - 'ElementFilterViewModelTests.SetCandidates_DefaultLabelPresent_PrepopulatesChip' - - 'ElementFilterViewModelTests.SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty' - - 'ElementFilterViewModelTests.SetCandidates_SecondCall_ReplacesState' + - 'SetCandidates_NullCandidates_Throws' + - 'SetCandidates_AvailableTypeLabels_IsDistinctAndSorted' + - 'SetCandidates_DefaultLabelPresent_PrepopulatesChip' + - 'SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty' + - 'SetCandidates_SecondCall_ReplacesState' - id: 'SysML2Workbench-ElementPickerSubsystem-ElementFilter-FilterByTypeAndText' title: 'The ElementFilter shall combine one or more type-label chips (OR semantics; empty @@ -33,11 +33,11 @@ sections: a filter-only control (such as the Query dialog''s "List" Query Type) practical without scrolling through the entire workspace. tests: - - 'ElementFilterViewModelTests.DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages' - - 'ElementFilterViewModelTests.DisplayedItems_NoChips_ShowsAllCandidates' - - 'ElementFilterViewModelTests.DisplayedItems_MultipleChips_AppliesOrSemantics' - - 'ElementFilterViewModelTests.DisplayedItems_SearchText_AppliesAndSemanticsWithChips' - - 'ElementFilterViewModelTests.DisplayedItems_SearchText_IsCaseInsensitive' + - 'DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages' + - 'DisplayedItems_NoChips_ShowsAllCandidates' + - 'DisplayedItems_MultipleChips_AppliesOrSemantics' + - 'DisplayedItems_SearchText_AppliesAndSemanticsWithChips' + - 'DisplayedItems_SearchText_IsCaseInsensitive' - id: 'SysML2Workbench-ElementPickerSubsystem-ElementFilter-ManageChipsWithoutDuplicates' title: 'The ElementFilter shall add and remove type-filter chips through AddTypeFilter and @@ -48,6 +48,6 @@ sections: add-flyout is driven; graceful removal keeps the view model tolerant of view-side event ordering. tests: - - 'ElementFilterViewModelTests.AddTypeFilter_DuplicateLabel_KeepsSingleChip' - - 'ElementFilterViewModelTests.RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully' - - 'ElementFilterViewModelTests.GetAddableTypeLabels_ExcludesActiveChips' + - 'AddTypeFilter_DuplicateLabel_KeepsSingleChip' + - 'RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully' + - '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 index c61b81f..caeb5bf 100644 --- a/docs/reqstream/sysml2-workbench/element-picker-subsystem/element-picker.yaml +++ b/docs/reqstream/sysml2-workbench/element-picker-subsystem/element-picker.yaml @@ -17,11 +17,11 @@ sections: 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: - - 'ElementPickerViewModelTests.SetCandidates_NullCandidates_Throws' - - 'ElementPickerViewModelTests.SetCandidates_AvailableTypeLabels_IsDistinctAndSorted' - - 'ElementPickerViewModelTests.SetCandidates_DefaultLabelPresent_PrepopulatesChip' - - 'ElementPickerViewModelTests.SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty' - - 'ElementPickerViewModelTests.SetCandidates_SecondCall_ReplacesState' + - 'SetCandidates_NullCandidates_Throws' + - 'SetCandidates_AvailableTypeLabels_IsDistinctAndSorted' + - 'SetCandidates_DefaultLabelPresent_PrepopulatesChip' + - 'SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty' + - 'SetCandidates_SecondCall_ReplacesState' - id: 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-FilterByTypeAndText' title: 'The ElementPicker shall combine one or more type-label chips (OR semantics; empty @@ -32,11 +32,11 @@ sections: 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: - - 'ElementPickerViewModelTests.DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages' - - 'ElementPickerViewModelTests.DisplayedItems_NoChips_ShowsAllCandidates' - - 'ElementPickerViewModelTests.DisplayedItems_MultipleChips_AppliesOrSemantics' - - 'ElementPickerViewModelTests.DisplayedItems_SearchText_AppliesAndSemanticsWithChips' - - 'ElementPickerViewModelTests.DisplayedItems_SearchText_IsCaseInsensitive' + - 'DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages' + - 'DisplayedItems_NoChips_ShowsAllCandidates' + - 'DisplayedItems_MultipleChips_AppliesOrSemantics' + - 'DisplayedItems_SearchText_AppliesAndSemanticsWithChips' + - 'DisplayedItems_SearchText_IsCaseInsensitive' - id: 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-ManageChipsWithoutDuplicates' title: 'The ElementPicker shall add and remove type-filter chips through AddTypeFilter and @@ -47,9 +47,9 @@ sections: add-flyout is driven; graceful removal keeps the view model tolerant of view-side event ordering. tests: - - 'ElementPickerViewModelTests.AddTypeFilter_DuplicateLabel_KeepsSingleChip' - - 'ElementPickerViewModelTests.RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully' - - 'ElementPickerViewModelTests.GetAddableTypeLabels_ExcludesActiveChips' + - 'AddTypeFilter_DuplicateLabel_KeepsSingleChip' + - 'RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully' + - 'GetAddableTypeLabels_ExcludesActiveChips' - id: 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-TrackSelection' title: 'The ElementPicker shall expose the currently-highlighted qualified name through the @@ -60,7 +60,7 @@ sections: 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: - - 'ElementPickerViewModelTests.SelectedQualifiedName_RoundTrips' + - 'SelectedQualifiedName_RoundTrips' - id: 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-LabelKnownNodeKinds' title: 'The ElementTypeLabeler shall compute a stable human-readable type label for every @@ -72,8 +72,8 @@ sections: a shared static helper (rather than per-caller mapping logic) is what makes the labels identical across every dialog that hosts the picker. tests: - - 'ElementTypeLabelerTests.GetTypeLabel_DefinitionNode_ReturnsDefinitionKeyword' - - 'ElementTypeLabelerTests.GetTypeLabel_FeatureNode_ReturnsFeatureKeyword' - - 'ElementTypeLabelerTests.GetTypeLabel_ConnectionNode_ReturnsConnectionKeyword' - - 'ElementTypeLabelerTests.GetTypeLabel_FixedLiteralNode_ReturnsExpectedLiteral' - - 'ElementTypeLabelerTests.GetTypeLabel_UnknownSubtype_UsesFallbackFromTypeName' + - 'GetTypeLabel_DefinitionNode_ReturnsDefinitionKeyword' + - 'GetTypeLabel_FeatureNode_ReturnsFeatureKeyword' + - 'GetTypeLabel_ConnectionNode_ReturnsConnectionKeyword' + - 'GetTypeLabel_FixedLiteralNode_ReturnsExpectedLiteral' + - 'GetTypeLabel_UnknownSubtype_UsesFallbackFromTypeName' From 67c9d720c439801f505029121407d3403ff337a0 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 21 Jul 2026 12:23:03 -0400 Subject: [PATCH 11/11] Rename tests to csharp-testing.md convention instead of bare-name workaround Per csharp-testing.md, unit test names must follow {ClassName}_{MethodUnderTest}_{Scenario}_{ExpectedBehavior}. The previous commit worked around ReqStream's bare-method-name matching by stripping the ClassName qualifier from tests: entries, but that left ElementPickerViewModelTests and ElementFilterViewModelTests with identically-named methods (e.g. SetCandidates_NullCandidates_Throws in both), so evidence for one class's requirements could be silently satisfied by the other class's test outcome. Rename every test method in ElementPickerViewModelTests, ElementFilterViewModelTests, ElementTypeLabelerTests, and QueryDialogViewModelTests to embed the class name (ElementPickerViewModel_..., ElementFilterViewModel_..., ElementTypeLabeler_..., QueryDialogViewModel_...), restoring global test-name uniqueness, and update the corresponding docs/reqstream/**/*.yaml tests: entries to match. 279/279 tests still pass; reqstream --enforce still reports 107/110 satisfied locally (only the CI-matrix Platform-Windows/Ubuntu/MacOS requirements remain, as expected). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../app-shell-subsystem/query-dialog.yaml | 44 +++++++++---------- .../element-picker-subsystem.yaml | 4 +- .../element-filter.yaml | 26 +++++------ .../element-picker.yaml | 38 ++++++++-------- .../QueryDialogViewModelTests.cs | 44 +++++++++---------- .../ElementFilterViewModelTests.cs | 28 ++++++------ .../ElementPickerViewModelTests.cs | 30 ++++++------- .../ElementTypeLabelerTests.cs | 12 ++--- 8 files changed, 113 insertions(+), 113 deletions(-) diff --git a/docs/reqstream/sysml2-workbench/app-shell-subsystem/query-dialog.yaml b/docs/reqstream/sysml2-workbench/app-shell-subsystem/query-dialog.yaml index 603c155..130b754 100644 --- a/docs/reqstream/sysml2-workbench/app-shell-subsystem/query-dialog.yaml +++ b/docs/reqstream/sysml2-workbench/app-shell-subsystem/query-dialog.yaml @@ -16,9 +16,9 @@ sections: opened, the stdlib toggle must be honored consistently, and a clear signal is needed when there is nothing to query yet. tests: - - 'Construction_EmptyShell_ReportsWorkspaceEmpty' - - 'Construction_LoadedWorkspace_PopulatesSinglePicker' - - 'IncludeStdlibToggle_RefreshesPickerAndRecomputesResult' + - '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 @@ -30,8 +30,8 @@ sections: "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: - - 'ListQueryType_BuildsClientSideListResult' - - 'ListQueryType_SearchTextEdit_RegeneratesResultLive' + - '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 @@ -49,18 +49,18 @@ sections: defining usability improvement is that every relevant edit updates the results live rather than requiring a separate "Run" click. tests: - - 'RecomputeResult_DescribeWithSelection_DispatchesThroughEngineImmediately' - - 'SwitchingQueryType_FromDescribeToList_ShowsListResultImmediately' - - 'SwitchingQueryType_FromListToDescribeNoSelection_ShowsSelectPrompt' - - 'HierarchyDirectionChange_WithSelection_RecomputesImmediately' - - 'WalkDepthTextChange_WithSelection_RecomputesImmediately' - - 'BuildOptions_HierarchyVerb_AttachesDirection' - - 'BuildOptions_NonHierarchyVerb_OmitsDirection' - - 'BuildOptions_ImpactVerbWithWalkDepth_ParsesWalkDepth' - - 'BuildOptions_ImpactVerbWithInvalidWalkDepth_LeavesNull' - - 'BuildOptions_PropagatesIncludeStdlib' - - 'QueryTypes_HasExpectedElevenEntries' - - 'HierarchyDirectionOptions_HasExpectedThree' + - '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 @@ -74,8 +74,8 @@ sections: 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: - - 'RecomputeResult_ElementScopedVerbNoSelection_ReportsPromptAndClearsRows' - - 'RecomputeResult_EmptyWorkspace_ReportsStatusMessage' + - 'QueryDialogViewModel_RecomputeResult_ElementScopedVerbNoSelection_ReportsPromptAndClearsRows' + - 'QueryDialogViewModel_RecomputeResult_EmptyWorkspace_ReportsStatusMessage' - id: 'SysML2Workbench-AppShellSubsystem-QueryDialog-CopyResultToClipboard' title: 'The QueryDialog shall render the current QueryResult through @@ -90,9 +90,9 @@ sections: safe when invoked with no result loaded, and moving the actions to a context menu keeps the results panel free of toolbar clutter. tests: - - 'CopyResultAsMarkdownAsync_WritesRenderedMarkdownToClipboard' - - 'CopyResultAsJsonAsync_WritesRenderedJsonToClipboard' - - 'CopyMethods_NoResult_AreNoOps' + - '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 diff --git a/docs/reqstream/sysml2-workbench/element-picker-subsystem.yaml b/docs/reqstream/sysml2-workbench/element-picker-subsystem.yaml index 6dc0463..6b25e42 100644 --- a/docs/reqstream/sysml2-workbench/element-picker-subsystem.yaml +++ b/docs/reqstream/sysml2-workbench/element-picker-subsystem.yaml @@ -17,7 +17,7 @@ sections: - 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-AcceptCallerBuiltCandidates' - 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-FilterByTypeAndText' tests: - - 'SetCandidates_AvailableTypeLabels_IsDistinctAndSorted' + - 'ElementPickerViewModel_SetCandidates_AvailableTypeLabels_IsDistinctAndSorted' - id: 'SysML2Workbench-ElementPickerSubsystem-DecoupleFromWorkspace' title: 'The ElementPickerSubsystem shall not itself depend on MainWindowShell, WorkspaceModel, or @@ -30,4 +30,4 @@ sections: children: - 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-AcceptCallerBuiltCandidates' tests: - - 'Construction_HasEmptyInitialState' + - '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 index 668129f..9efd6db 100644 --- a/docs/reqstream/sysml2-workbench/element-picker-subsystem/element-filter.yaml +++ b/docs/reqstream/sysml2-workbench/element-picker-subsystem/element-filter.yaml @@ -17,11 +17,11 @@ sections: 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: - - 'SetCandidates_NullCandidates_Throws' - - 'SetCandidates_AvailableTypeLabels_IsDistinctAndSorted' - - 'SetCandidates_DefaultLabelPresent_PrepopulatesChip' - - 'SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty' - - 'SetCandidates_SecondCall_ReplacesState' + - '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 @@ -33,11 +33,11 @@ sections: a filter-only control (such as the Query dialog''s "List" Query Type) practical without scrolling through the entire workspace. tests: - - 'DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages' - - 'DisplayedItems_NoChips_ShowsAllCandidates' - - 'DisplayedItems_MultipleChips_AppliesOrSemantics' - - 'DisplayedItems_SearchText_AppliesAndSemanticsWithChips' - - 'DisplayedItems_SearchText_IsCaseInsensitive' + - '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 @@ -48,6 +48,6 @@ sections: add-flyout is driven; graceful removal keeps the view model tolerant of view-side event ordering. tests: - - 'AddTypeFilter_DuplicateLabel_KeepsSingleChip' - - 'RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully' - - 'GetAddableTypeLabels_ExcludesActiveChips' + - '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 index caeb5bf..4e42311 100644 --- a/docs/reqstream/sysml2-workbench/element-picker-subsystem/element-picker.yaml +++ b/docs/reqstream/sysml2-workbench/element-picker-subsystem/element-picker.yaml @@ -17,11 +17,11 @@ sections: 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: - - 'SetCandidates_NullCandidates_Throws' - - 'SetCandidates_AvailableTypeLabels_IsDistinctAndSorted' - - 'SetCandidates_DefaultLabelPresent_PrepopulatesChip' - - 'SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty' - - 'SetCandidates_SecondCall_ReplacesState' + - '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 @@ -32,11 +32,11 @@ sections: 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: - - 'DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages' - - 'DisplayedItems_NoChips_ShowsAllCandidates' - - 'DisplayedItems_MultipleChips_AppliesOrSemantics' - - 'DisplayedItems_SearchText_AppliesAndSemanticsWithChips' - - 'DisplayedItems_SearchText_IsCaseInsensitive' + - '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 @@ -47,9 +47,9 @@ sections: add-flyout is driven; graceful removal keeps the view model tolerant of view-side event ordering. tests: - - 'AddTypeFilter_DuplicateLabel_KeepsSingleChip' - - 'RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully' - - 'GetAddableTypeLabels_ExcludesActiveChips' + - '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 @@ -60,7 +60,7 @@ sections: 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: - - 'SelectedQualifiedName_RoundTrips' + - 'ElementPickerViewModel_SelectedQualifiedName_RoundTrips' - id: 'SysML2Workbench-ElementPickerSubsystem-ElementPicker-LabelKnownNodeKinds' title: 'The ElementTypeLabeler shall compute a stable human-readable type label for every @@ -72,8 +72,8 @@ sections: a shared static helper (rather than per-caller mapping logic) is what makes the labels identical across every dialog that hosts the picker. tests: - - 'GetTypeLabel_DefinitionNode_ReturnsDefinitionKeyword' - - 'GetTypeLabel_FeatureNode_ReturnsFeatureKeyword' - - 'GetTypeLabel_ConnectionNode_ReturnsConnectionKeyword' - - 'GetTypeLabel_FixedLiteralNode_ReturnsExpectedLiteral' - - 'GetTypeLabel_UnknownSubtype_UsesFallbackFromTypeName' + - 'ElementTypeLabeler_GetTypeLabel_DefinitionNode_ReturnsDefinitionKeyword' + - 'ElementTypeLabeler_GetTypeLabel_FeatureNode_ReturnsFeatureKeyword' + - 'ElementTypeLabeler_GetTypeLabel_ConnectionNode_ReturnsConnectionKeyword' + - 'ElementTypeLabeler_GetTypeLabel_FixedLiteralNode_ReturnsExpectedLiteral' + - 'ElementTypeLabeler_GetTypeLabel_UnknownSubtype_UsesFallbackFromTypeName' diff --git a/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs index 25654e4..e4544cb 100644 --- a/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs +++ b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/QueryDialogViewModelTests.cs @@ -91,7 +91,7 @@ public Task SetTextAsync(string text) /// result rather than . /// [Fact] - public void Construction_EmptyShell_ReportsWorkspaceEmpty() + public void QueryDialogViewModel_Construction_EmptyShell_ReportsWorkspaceEmpty() { // Arrange using var shell = CreateShell(); @@ -116,7 +116,7 @@ public void Construction_EmptyShell_ReportsWorkspaceEmpty() /// . /// [Fact] - public async Task Construction_LoadedWorkspace_PopulatesSinglePicker() + public async Task QueryDialogViewModel_Construction_LoadedWorkspace_PopulatesSinglePicker() { // Arrange await WriteSampleWorkspaceAsync(); @@ -144,7 +144,7 @@ public async Task Construction_LoadedWorkspace_PopulatesSinglePicker() /// displayed picker item (kind label sourced from the same candidate map). /// [Fact] - public async Task ListQueryType_BuildsClientSideListResult() + public async Task QueryDialogViewModel_ListQueryType_BuildsClientSideListResult() { // Arrange await WriteSampleWorkspaceAsync(); @@ -171,7 +171,7 @@ public async Task ListQueryType_BuildsClientSideListResult() /// the result regenerates automatically - the redesign's "live, no Run gesture" contract. /// [Fact] - public async Task ListQueryType_SearchTextEdit_RegeneratesResultLive() + public async Task QueryDialogViewModel_ListQueryType_SearchTextEdit_RegeneratesResultLive() { // Arrange await WriteSampleWorkspaceAsync(); @@ -194,7 +194,7 @@ public async Task ListQueryType_SearchTextEdit_RegeneratesResultLive() /// than leaving it on screen. /// [Fact] - public async Task RecomputeResult_ElementScopedVerbNoSelection_ReportsPromptAndClearsRows() + public async Task QueryDialogViewModel_RecomputeResult_ElementScopedVerbNoSelection_ReportsPromptAndClearsRows() { // Arrange await WriteSampleWorkspaceAsync(); @@ -218,7 +218,7 @@ public async Task RecomputeResult_ElementScopedVerbNoSelection_ReportsPromptAndC /// a user-visible status, without touching . /// [Fact] - public void RecomputeResult_EmptyWorkspace_ReportsStatusMessage() + public void QueryDialogViewModel_RecomputeResult_EmptyWorkspace_ReportsStatusMessage() { // Arrange using var shell = CreateShell(); @@ -239,7 +239,7 @@ public void RecomputeResult_EmptyWorkspace_ReportsStatusMessage() /// call - proving the auto-recompute contract at the heart of this redesign. /// [Fact] - public async Task RecomputeResult_DescribeWithSelection_DispatchesThroughEngineImmediately() + public async Task QueryDialogViewModel_RecomputeResult_DescribeWithSelection_DispatchesThroughEngineImmediately() { // Arrange await WriteSampleWorkspaceAsync(); @@ -264,7 +264,7 @@ public async Task RecomputeResult_DescribeWithSelection_DispatchesThroughEngineI /// previous verb. /// [Fact] - public async Task SwitchingQueryType_FromDescribeToList_ShowsListResultImmediately() + public async Task QueryDialogViewModel_SwitchingQueryType_FromDescribeToList_ShowsListResultImmediately() { // Arrange await WriteSampleWorkspaceAsync(); @@ -290,7 +290,7 @@ public async Task SwitchingQueryType_FromDescribeToList_ShowsListResultImmediate /// result or a thrown exception. /// [Fact] - public async Task SwitchingQueryType_FromListToDescribeNoSelection_ShowsSelectPrompt() + public async Task QueryDialogViewModel_SwitchingQueryType_FromListToDescribeNoSelection_ShowsSelectPrompt() { // Arrange await WriteSampleWorkspaceAsync(); @@ -314,7 +314,7 @@ public async Task SwitchingQueryType_FromListToDescribeNoSelection_ShowsSelectPr /// without requiring any manual call. /// [Fact] - public async Task HierarchyDirectionChange_WithSelection_RecomputesImmediately() + public async Task QueryDialogViewModel_HierarchyDirectionChange_WithSelection_RecomputesImmediately() { // Arrange await WriteSampleWorkspaceAsync(); @@ -339,7 +339,7 @@ public async Task HierarchyDirectionChange_WithSelection_RecomputesImmediately() /// is selected with an active selection recomputes immediately. /// [Fact] - public async Task WalkDepthTextChange_WithSelection_RecomputesImmediately() + public async Task QueryDialogViewModel_WalkDepthTextChange_WithSelection_RecomputesImmediately() { // Arrange await WriteSampleWorkspaceAsync(); @@ -365,7 +365,7 @@ public async Task WalkDepthTextChange_WithSelection_RecomputesImmediately() /// , matching the plan's per-verb visibility rules. /// [Fact] - public void BuildOptions_HierarchyVerb_AttachesDirection() + public void QueryDialogViewModel_BuildOptions_HierarchyVerb_AttachesDirection() { // Arrange using var shell = CreateShell(); @@ -388,7 +388,7 @@ public void BuildOptions_HierarchyVerb_AttachesDirection() /// is set (the field simply doesn't apply). /// [Fact] - public void BuildOptions_NonHierarchyVerb_OmitsDirection() + public void QueryDialogViewModel_BuildOptions_NonHierarchyVerb_OmitsDirection() { // Arrange using var shell = CreateShell(); @@ -409,7 +409,7 @@ public void BuildOptions_NonHierarchyVerb_OmitsDirection() /// , and only when it parses cleanly. /// [Fact] - public void BuildOptions_ImpactVerbWithWalkDepth_ParsesWalkDepth() + public void QueryDialogViewModel_BuildOptions_ImpactVerbWithWalkDepth_ParsesWalkDepth() { // Arrange using var shell = CreateShell(); @@ -436,7 +436,7 @@ public void BuildOptions_ImpactVerbWithWalkDepth_ParsesWalkDepth() [InlineData(" ")] [InlineData("not-a-number")] [InlineData("-1")] - public void BuildOptions_ImpactVerbWithInvalidWalkDepth_LeavesNull(string? walkDepthText) + public void QueryDialogViewModel_BuildOptions_ImpactVerbWithInvalidWalkDepth_LeavesNull(string? walkDepthText) { // Arrange using var shell = CreateShell(); @@ -456,7 +456,7 @@ public void BuildOptions_ImpactVerbWithInvalidWalkDepth_LeavesNull(string? walkD /// for every verb. /// [Fact] - public void BuildOptions_PropagatesIncludeStdlib() + public void QueryDialogViewModel_BuildOptions_PropagatesIncludeStdlib() { // Arrange using var shell = CreateShell(); @@ -484,7 +484,7 @@ public void BuildOptions_PropagatesIncludeStdlib() /// refresh-candidates-then-recompute pipeline still functions end to end. /// [Fact] - public async Task IncludeStdlibToggle_RefreshesPickerAndRecomputesResult() + public async Task QueryDialogViewModel_IncludeStdlibToggle_RefreshesPickerAndRecomputesResult() { // Arrange await WriteSampleWorkspaceAsync(); @@ -528,7 +528,7 @@ public async Task IncludeStdlibToggle_RefreshesPickerAndRecomputesResult() /// the injected clipboard service. /// [Fact] - public async Task CopyResultAsMarkdownAsync_WritesRenderedMarkdownToClipboard() + public async Task QueryDialogViewModel_CopyResultAsMarkdownAsync_WritesRenderedMarkdownToClipboard() { // Arrange await WriteSampleWorkspaceAsync(); @@ -555,7 +555,7 @@ public async Task CopyResultAsMarkdownAsync_WritesRenderedMarkdownToClipboard() /// service. /// [Fact] - public async Task CopyResultAsJsonAsync_WritesRenderedJsonToClipboard() + public async Task QueryDialogViewModel_CopyResultAsJsonAsync_WritesRenderedJsonToClipboard() { // Arrange await WriteSampleWorkspaceAsync(); @@ -582,7 +582,7 @@ public async Task CopyResultAsJsonAsync_WritesRenderedJsonToClipboard() /// hack, which doubles as coverage for that path. /// [Fact] - public async Task CopyMethods_NoResult_AreNoOps() + public async Task QueryDialogViewModel_CopyMethods_NoResult_AreNoOps() { // Arrange using var shell = CreateShell(); @@ -606,7 +606,7 @@ public async Task CopyMethods_NoResult_AreNoOps() /// (which "List" always merges into, so the user never sees it). /// [Fact] - public void QueryTypes_HasExpectedElevenEntries() + public void QueryDialogViewModel_QueryTypes_HasExpectedElevenEntries() { // Assert Assert.Equal(11, QueryDialogViewModel.QueryTypes.Count); @@ -621,7 +621,7 @@ public void QueryTypes_HasExpectedElevenEntries() /// hierarchy directions accepted by the underlying field. /// [Fact] - public void HierarchyDirectionOptions_HasExpectedThree() + public void QueryDialogViewModel_HierarchyDirectionOptions_HasExpectedThree() { // Assert Assert.Equal(new[] { "up", "down", "both" }, QueryDialogViewModel.HierarchyDirectionOptions); diff --git a/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementFilterViewModelTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementFilterViewModelTests.cs index 59c4c67..4722008 100644 --- a/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementFilterViewModelTests.cs +++ b/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementFilterViewModelTests.cs @@ -31,7 +31,7 @@ private static readonly (string QualifiedName, string TypeLabel)[] MixedCandidat /// an empty (non-null) search text, and an empty displayed list. /// [Fact] - public void Construction_HasEmptyInitialState() + public void ElementFilterViewModel_Construction_HasEmptyInitialState() { // Act var filter = new ElementFilterViewModel(); @@ -49,7 +49,7 @@ public void Construction_HasEmptyInitialState() /// candidate list, matching the runtime null-guard behavior. /// [Fact] - public void SetCandidates_NullCandidates_Throws() + public void ElementFilterViewModel_SetCandidates_NullCandidates_Throws() { // Arrange var filter = new ElementFilterViewModel(); @@ -63,7 +63,7 @@ public void SetCandidates_NullCandidates_Throws() /// deduplicated and sorted ordinally after . /// [Fact] - public void SetCandidates_AvailableTypeLabels_IsDistinctAndSorted() + public void ElementFilterViewModel_SetCandidates_AvailableTypeLabels_IsDistinctAndSorted() { // Arrange var filter = new ElementFilterViewModel(); @@ -81,7 +81,7 @@ public void SetCandidates_AvailableTypeLabels_IsDistinctAndSorted() /// defaultTypeFilterLabel when it exists in the candidates. /// [Fact] - public void SetCandidates_DefaultLabelPresent_PrepopulatesChip() + public void ElementFilterViewModel_SetCandidates_DefaultLabelPresent_PrepopulatesChip() { // Arrange var filter = new ElementFilterViewModel(); @@ -99,7 +99,7 @@ public void SetCandidates_DefaultLabelPresent_PrepopulatesChip() /// (rather than adding a chip for a label that filters out every candidate). /// [Fact] - public void SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty() + public void ElementFilterViewModel_SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty() { // Arrange var filter = new ElementFilterViewModel(); @@ -116,7 +116,7 @@ public void SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty() /// surfaced by . /// [Fact] - public void DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages() + public void ElementFilterViewModel_DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages() { // Arrange var filter = new ElementFilterViewModel(); @@ -135,7 +135,7 @@ public void DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages() /// type label. /// [Fact] - public void DisplayedItems_NoChips_ShowsAllCandidates() + public void ElementFilterViewModel_DisplayedItems_NoChips_ShowsAllCandidates() { // Arrange var filter = new ElementFilterViewModel(); @@ -152,7 +152,7 @@ public void DisplayedItems_NoChips_ShowsAllCandidates() /// matches any of the active chips. /// [Fact] - public void DisplayedItems_MultipleChips_AppliesOrSemantics() + public void ElementFilterViewModel_DisplayedItems_MultipleChips_AppliesOrSemantics() { // Arrange var filter = new ElementFilterViewModel(); @@ -176,7 +176,7 @@ public void DisplayedItems_MultipleChips_AppliesOrSemantics() /// displayed. /// [Fact] - public void DisplayedItems_SearchText_AppliesAndSemanticsWithChips() + public void ElementFilterViewModel_DisplayedItems_SearchText_AppliesAndSemanticsWithChips() { // Arrange var filter = new ElementFilterViewModel(); @@ -196,7 +196,7 @@ public void DisplayedItems_SearchText_AppliesAndSemanticsWithChips() /// Validates that the search text is matched case-insensitively. /// [Fact] - public void DisplayedItems_SearchText_IsCaseInsensitive() + public void ElementFilterViewModel_DisplayedItems_SearchText_IsCaseInsensitive() { // Arrange var filter = new ElementFilterViewModel(); @@ -214,7 +214,7 @@ public void DisplayedItems_SearchText_IsCaseInsensitive() /// adding a label twice leaves only one chip. /// [Fact] - public void AddTypeFilter_DuplicateLabel_KeepsSingleChip() + public void ElementFilterViewModel_AddTypeFilter_DuplicateLabel_KeepsSingleChip() { // Arrange var filter = new ElementFilterViewModel(); @@ -233,7 +233,7 @@ public void AddTypeFilter_DuplicateLabel_KeepsSingleChip() /// present chip and is a no-op (aside from the recompute) for absent labels. /// [Fact] - public void RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully() + public void ElementFilterViewModel_RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully() { // Arrange var filter = new ElementFilterViewModel(); @@ -257,7 +257,7 @@ public void RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully() /// labels not currently active, preserving the master ordinal ordering. /// [Fact] - public void GetAddableTypeLabels_ExcludesActiveChips() + public void ElementFilterViewModel_GetAddableTypeLabels_ExcludesActiveChips() { // Arrange var filter = new ElementFilterViewModel(); @@ -276,7 +276,7 @@ public void GetAddableTypeLabels_ExcludesActiveChips() /// reset, displayed list recomputed). /// [Fact] - public void SetCandidates_SecondCall_ReplacesState() + public void ElementFilterViewModel_SetCandidates_SecondCall_ReplacesState() { // Arrange var filter = new ElementFilterViewModel(); diff --git a/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementPickerViewModelTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementPickerViewModelTests.cs index 5a1946d..84f8b42 100644 --- a/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementPickerViewModelTests.cs +++ b/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementPickerViewModelTests.cs @@ -29,7 +29,7 @@ private static readonly (string QualifiedName, string TypeLabel)[] MixedCandidat /// an empty (non-null) search text, and an empty displayed list. /// [Fact] - public void Construction_HasEmptyInitialState() + public void ElementPickerViewModel_Construction_HasEmptyInitialState() { // Act var picker = new ElementPickerViewModel(); @@ -48,7 +48,7 @@ public void Construction_HasEmptyInitialState() /// candidate list, matching the runtime null-guard behavior. /// [Fact] - public void SetCandidates_NullCandidates_Throws() + public void ElementPickerViewModel_SetCandidates_NullCandidates_Throws() { // Arrange var picker = new ElementPickerViewModel(); @@ -62,7 +62,7 @@ public void SetCandidates_NullCandidates_Throws() /// deduplicated and sorted ordinally after . /// [Fact] - public void SetCandidates_AvailableTypeLabels_IsDistinctAndSorted() + public void ElementPickerViewModel_SetCandidates_AvailableTypeLabels_IsDistinctAndSorted() { // Arrange var picker = new ElementPickerViewModel(); @@ -80,7 +80,7 @@ public void SetCandidates_AvailableTypeLabels_IsDistinctAndSorted() /// defaultTypeFilterLabel when it exists in the candidates. /// [Fact] - public void SetCandidates_DefaultLabelPresent_PrepopulatesChip() + public void ElementPickerViewModel_SetCandidates_DefaultLabelPresent_PrepopulatesChip() { // Arrange var picker = new ElementPickerViewModel(); @@ -98,7 +98,7 @@ public void SetCandidates_DefaultLabelPresent_PrepopulatesChip() /// (rather than adding a chip for a label that filters out every candidate). /// [Fact] - public void SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty() + public void ElementPickerViewModel_SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty() { // Arrange var picker = new ElementPickerViewModel(); @@ -115,7 +115,7 @@ public void SetCandidates_DefaultLabelAbsent_LeavesChipsEmpty() /// surfaced by . /// [Fact] - public void DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages() + public void ElementPickerViewModel_DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages() { // Arrange var picker = new ElementPickerViewModel(); @@ -134,7 +134,7 @@ public void DisplayedItems_DefaultPartChip_ShowsOnlyPartUsages() /// type label. /// [Fact] - public void DisplayedItems_NoChips_ShowsAllCandidates() + public void ElementPickerViewModel_DisplayedItems_NoChips_ShowsAllCandidates() { // Arrange var picker = new ElementPickerViewModel(); @@ -151,7 +151,7 @@ public void DisplayedItems_NoChips_ShowsAllCandidates() /// matches any of the active chips. /// [Fact] - public void DisplayedItems_MultipleChips_AppliesOrSemantics() + public void ElementPickerViewModel_DisplayedItems_MultipleChips_AppliesOrSemantics() { // Arrange var picker = new ElementPickerViewModel(); @@ -175,7 +175,7 @@ public void DisplayedItems_MultipleChips_AppliesOrSemantics() /// displayed. /// [Fact] - public void DisplayedItems_SearchText_AppliesAndSemanticsWithChips() + public void ElementPickerViewModel_DisplayedItems_SearchText_AppliesAndSemanticsWithChips() { // Arrange var picker = new ElementPickerViewModel(); @@ -195,7 +195,7 @@ public void DisplayedItems_SearchText_AppliesAndSemanticsWithChips() /// Validates that the search text is matched case-insensitively. /// [Fact] - public void DisplayedItems_SearchText_IsCaseInsensitive() + public void ElementPickerViewModel_DisplayedItems_SearchText_IsCaseInsensitive() { // Arrange var picker = new ElementPickerViewModel(); @@ -213,7 +213,7 @@ public void DisplayedItems_SearchText_IsCaseInsensitive() /// adding a label twice leaves only one chip. /// [Fact] - public void AddTypeFilter_DuplicateLabel_KeepsSingleChip() + public void ElementPickerViewModel_AddTypeFilter_DuplicateLabel_KeepsSingleChip() { // Arrange var picker = new ElementPickerViewModel(); @@ -232,7 +232,7 @@ public void AddTypeFilter_DuplicateLabel_KeepsSingleChip() /// present chip and is a no-op (aside from the recompute) for absent labels. /// [Fact] - public void RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully() + public void ElementPickerViewModel_RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully() { // Arrange var picker = new ElementPickerViewModel(); @@ -256,7 +256,7 @@ public void RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully() /// labels not currently active, preserving the master ordinal ordering. /// [Fact] - public void GetAddableTypeLabels_ExcludesActiveChips() + public void ElementPickerViewModel_GetAddableTypeLabels_ExcludesActiveChips() { // Arrange var picker = new ElementPickerViewModel(); @@ -275,7 +275,7 @@ public void GetAddableTypeLabels_ExcludesActiveChips() /// reset, prior selection cleared, displayed list recomputed). /// [Fact] - public void SetCandidates_SecondCall_ReplacesState() + public void ElementPickerViewModel_SetCandidates_SecondCall_ReplacesState() { // Arrange var picker = new ElementPickerViewModel(); @@ -299,7 +299,7 @@ public void SetCandidates_SecondCall_ReplacesState() /// freely assigned and read back, tracking the caller's selection. /// [Fact] - public void SelectedQualifiedName_RoundTrips() + public void ElementPickerViewModel_SelectedQualifiedName_RoundTrips() { // Arrange var picker = new ElementPickerViewModel(); diff --git a/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementTypeLabelerTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementTypeLabelerTests.cs index 72d16fc..94ca6e5 100644 --- a/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementTypeLabelerTests.cs +++ b/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementTypeLabelerTests.cs @@ -14,7 +14,7 @@ public sealed class ElementTypeLabelerTests /// . /// [Fact] - public void GetTypeLabel_NullNode_Throws() + public void ElementTypeLabeler_GetTypeLabel_NullNode_Throws() { // Act & Assert Assert.Throws(() => ElementTypeLabeler.GetTypeLabel(null!)); @@ -25,7 +25,7 @@ public void GetTypeLabel_NullNode_Throws() /// verbatim. /// [Fact] - public void GetTypeLabel_DefinitionNode_ReturnsDefinitionKeyword() + public void ElementTypeLabeler_GetTypeLabel_DefinitionNode_ReturnsDefinitionKeyword() { // Arrange var node = new SysmlDefinitionNode { DefinitionKeyword = "part def" }; @@ -42,7 +42,7 @@ public void GetTypeLabel_DefinitionNode_ReturnsDefinitionKeyword() /// verbatim. /// [Fact] - public void GetTypeLabel_FeatureNode_ReturnsFeatureKeyword() + public void ElementTypeLabeler_GetTypeLabel_FeatureNode_ReturnsFeatureKeyword() { // Arrange var node = new SysmlFeatureNode { FeatureKeyword = "port" }; @@ -59,7 +59,7 @@ public void GetTypeLabel_FeatureNode_ReturnsFeatureKeyword() /// ConnectionKeyword verbatim. /// [Fact] - public void GetTypeLabel_ConnectionNode_ReturnsConnectionKeyword() + public void ElementTypeLabeler_GetTypeLabel_ConnectionNode_ReturnsConnectionKeyword() { // Arrange var node = new SysmlConnectionNode { ConnectionKeyword = "connection" }; @@ -82,7 +82,7 @@ public void GetTypeLabel_ConnectionNode_ReturnsConnectionKeyword() [InlineData(typeof(SysmlTransitionNode), "transition")] [InlineData(typeof(SysmlSatisfyNode), "satisfy")] [InlineData(typeof(SysmlDependencyNode), "dependency")] - public void GetTypeLabel_FixedLiteralNode_ReturnsExpectedLiteral(Type nodeType, string expected) + public void ElementTypeLabeler_GetTypeLabel_FixedLiteralNode_ReturnsExpectedLiteral(Type nodeType, string expected) { // Arrange var node = (SysmlNode)Activator.CreateInstance(nodeType)!; @@ -100,7 +100,7 @@ public void GetTypeLabel_FixedLiteralNode_ReturnsExpectedLiteral(Type nodeType, /// Node stripped, then lowercased). /// [Fact] - public void GetTypeLabel_UnknownSubtype_UsesFallbackFromTypeName() + public void ElementTypeLabeler_GetTypeLabel_UnknownSubtype_UsesFallbackFromTypeName() { // Arrange var node = new SysmlMetadataNode();