diff --git a/.cspell.yaml b/.cspell.yaml index 225a2a4..2e3a0dc 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -55,8 +55,10 @@ words: - sysml - timeslice - TOCTOU + - unautomatable - uncontended - unioned + - unsupplied - Unwatch - unwatch - Unwatches @@ -64,7 +66,9 @@ words: - Unwatching - unwatching - versionmark + - vstest - Weasyprint + - Winlogon - xunit - Xunit - yamlfix diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d7c9be9..5abbed5 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -433,6 +433,8 @@ jobs: shell: pwsh env: SYSML2WORKBENCH_APP_PATH: ${{ github.workspace }}\publish\win-x64\DemaConsulting.SysML2Workbench.Desktop.exe + SYSML2WORKBENCH_ARTIFACTS_DIR: ${{ github.workspace }}\artifacts + SYSML2WORKBENCH_STARTUP_FILE: ${{ github.workspace }}\test\DemaConsulting.SysML2Workbench.IntegrationTests\TestData\InspectionSample.sysml run: > ./run-under-appium.ps1 -- dotnet test @@ -528,7 +530,12 @@ jobs: build-docs: name: Build Documents runs-on: windows-latest - needs: [build, codeql] + # Must also wait for appium-windows-integration-tests: its trx artifact is the only + # evidence for the Appium-DriveDesktopApplicationEndToEnd/Appium-LocateControlsByAutomationId + # requirements, and without this dependency the two jobs can run concurrently - if this job's + # "Download all job artifacts" step happens to run before that job finishes uploading its + # artifact, those two requirements are silently missing evidence and --enforce fails. + needs: [build, codeql, appium-windows-integration-tests] permissions: contents: read diff --git a/build.ps1 b/build.ps1 index f1d9e0e..36ff858 100644 --- a/build.ps1 +++ b/build.ps1 @@ -106,9 +106,13 @@ if ($IntegrationTest) { if (-not $buildError) { Write-Host "Running Appium IntegrationTests..." $env:SYSML2WORKBENCH_APP_PATH = Join-Path (Get-Location) "publish/$rid/$exeName" + $env:SYSML2WORKBENCH_ARTIFACTS_DIR = Join-Path (Get-Location) "artifacts" + $env:SYSML2WORKBENCH_STARTUP_FILE = Join-Path (Get-Location) "test/DemaConsulting.SysML2Workbench.IntegrationTests/TestData/InspectionSample.sysml" ./run-under-appium.ps1 -- dotnet test test/DemaConsulting.SysML2Workbench.IntegrationTests/DemaConsulting.SysML2Workbench.IntegrationTests.csproj --configuration Release --logger "trx;LogFilePrefix=appium-integration" --results-directory artifacts/tests if ($LASTEXITCODE -ne 0) { $buildError = $true } Remove-Item Env:\SYSML2WORKBENCH_APP_PATH -ErrorAction SilentlyContinue + Remove-Item Env:\SYSML2WORKBENCH_ARTIFACTS_DIR -ErrorAction SilentlyContinue + Remove-Item Env:\SYSML2WORKBENCH_STARTUP_FILE -ErrorAction SilentlyContinue } } } diff --git a/docs/design/ots/appium.md b/docs/design/ots/appium.md index efcf909..c2aa892 100644 --- a/docs/design/ots/appium.md +++ b/docs/design/ots/appium.md @@ -101,3 +101,32 @@ part of the sequence instead of duplicating it inline. The cross-platform `--filter "Category!=Integration"`, since `IntegrationTests`' tests carry `[Trait("Category", "Integration")]` and require a running Appium server that those invocations do not start. + +### Troubleshooting: "Failed to locate window of the app." on Windows + +If every NovaWindows test in `IntegrationTests` fails identically with +`WindowsDriver` throwing `UnknownError: Failed to locate window of the app.` +(surfaced via NovaWindowsDriver's `changeRootElement`), enable the Appium +server's own log output and look for an underlying error such as: + +```text +Exception calling "SetFocus" with "0" argument(s): "Target element cannot receive focus." +``` + +This signature means NovaWindowsDriver *did* find the launched process and +its window, but Windows refused `SetForegroundWindow`/UI Automation +`SetFocus` for it. The most common root cause is that **the interactive +console session driving the test run is locked** - Windows switches to the +secure Winlogon/`LockApp` desktop while locked, which is isolated from +automation and blocks focus/foreground operations for *any* process, not +just the one under test. This is an environment precondition, not a code +regression: it reproduces identically even with no application code from the +current change involved. + +`run-under-appium.ps1` includes a Windows-only preflight check that detects +this condition (via `GetForegroundWindow`/`GetWindowThreadProcessId` +resolving to `LockApp`/`LogonUI`) before running the wrapped test command, +and fails fast with an actionable error instead of letting all tests fail +slowly, one at a time, with this cryptic stack trace. The remediation is +simply to **unlock the interactive console/RDP session** and re-run +`-IntegrationTest`; no code change can substitute for this. diff --git a/docs/design/sysml2-workbench/app-shell-subsystem/main-window-shell.md b/docs/design/sysml2-workbench/app-shell-subsystem/main-window-shell.md index 0ba2b07..849b49a 100644 --- a/docs/design/sysml2-workbench/app-shell-subsystem/main-window-shell.md +++ b/docs/design/sysml2-workbench/app-shell-subsystem/main-window-shell.md @@ -191,6 +191,19 @@ path. resolve which file it displays without `MainWindowShell` needing to expose `OpenTabs` internals further. +**CloseAllSourcesAsync**: Closes every workspace source at once. + +- *Parameters*: none. +- *Returns*: `Task` — the freshly resolved and loaded + (empty) workspace snapshot. +- *Postconditions*: `WorkspaceSourceSet.ClearSources` removes every + registered source, then the same watch/unwatch/reload/`SourcesChanged` + reconciliation pipeline used by `AddFileSourceAsync`/`AddFolderSourceAsync`/ + `RemoveSourceAsync` is re-run, unwatching every previously watched source + and returning the shell to the exact same valid empty state used at + construction (no sources, no open tabs, no diagnostics). Backs the File + menu's "Close All" command. + **CanExportTabAsSysml**: Reports whether an open diagram tab has a derivable source definition and can export its diagram as a SysML snippet. @@ -259,8 +272,9 @@ the time subscribers observe the notification. - **WorkspaceSubsystem** — loads and refreshes the workspace state. - **WorkspaceSourceSet** — owned by the shell; mutated by - `AddFileSourceAsync`/`AddFolderSourceAsync`/`RemoveSourceAsync` and - re-resolved before every `WorkspaceModel` load, reload, and watcher diff. + `AddFileSourceAsync`/`AddFolderSourceAsync`/`RemoveSourceAsync`/ + `CloseAllSourcesAsync` and re-resolved before every `WorkspaceModel` load, + reload, and watcher diff. - **ViewCatalogPresenter** — supplies predefined view choices. - **ViewDefinitionModel** — captures custom-view authoring state. - **SysmlSnippetGenerator** — exports custom-view definitions as SysML text. diff --git a/docs/design/sysml2-workbench/workspace-subsystem/workspace-source-set.md b/docs/design/sysml2-workbench/workspace-subsystem/workspace-source-set.md index 03268ed..279476a 100644 --- a/docs/design/sysml2-workbench/workspace-subsystem/workspace-source-set.md +++ b/docs/design/sysml2-workbench/workspace-subsystem/workspace-source-set.md @@ -70,6 +70,14 @@ itself, a `Folder` source maps to its discovered files). Removing an unknown id is a no-op that returns `false` rather than throwing. +**ClearSources**: Removes every registered source at once. + +- *Parameters*: `None`. +- *Returns*: `void`. +- *Postconditions*: `Sources` is empty. A no-op (not an error) when already + empty. Backs `MainWindowShell.CloseAllSourcesAsync`, which the File menu's + "Close All" command calls. + **Resolve**: Computes the merged, deduplicated file resolution across every registered source. diff --git a/docs/reqstream/ots/appium.yaml b/docs/reqstream/ots/appium.yaml index 46b959d..4e4bc43 100644 --- a/docs/reqstream/ots/appium.yaml +++ b/docs/reqstream/ots/appium.yaml @@ -24,3 +24,4 @@ sections: - 'DesktopApp_ViewMenu_ViewBuilderDialogMenuItem_IsDiscoverableAndEnabled' - 'DesktopApp_QueryMenu_QueryDialogMenuItem_IsDiscoverableAndEnabled' - 'DesktopApp_HelpMenu_AboutMenuItem_OpensAndClosesAboutDialog' + - 'DesktopApp_FileMenu_CloseAllMenuItem_IsDiscoverableAndEnabled' diff --git a/docs/reqstream/sysml2-workbench/app-shell-subsystem/main-window-shell.yaml b/docs/reqstream/sysml2-workbench/app-shell-subsystem/main-window-shell.yaml index 20143c0..4655c1b 100644 --- a/docs/reqstream/sysml2-workbench/app-shell-subsystem/main-window-shell.yaml +++ b/docs/reqstream/sysml2-workbench/app-shell-subsystem/main-window-shell.yaml @@ -70,6 +70,13 @@ sections: - 'RenderCustomViewPreview_DoesNotMutateOpenTabsOrActiveTab' - 'RenderCustomViewPreview_NoWorkspaceOpened_ThrowsInvalidOperationException' + - id: 'SysML2Workbench-AppShellSubsystem-MainWindowShell-CloseAllSources' + title: 'The MainWindowShell shall close every workspace source at once, returning to the same valid empty state used at construction, and raise SourcesChanged.' + justification: | + The File menu's "Close All" command lets a user reset the entire workspace in one action rather than removing every source individually, and must reach exactly the same fresh-launch empty state (no tabs, no diagnostics, no watched sources) as removing sources one at a time. + tests: + - 'CloseAllSourcesAsync_WithMultipleSources_ProducesEmptySnapshotAndUnwatchesEverything' + - id: 'SysML2Workbench-AppShellSubsystem-MainWindowShell-OpenSourceTextTab' title: 'The MainWindowShell shall open a read-only source-text tab for a given file path, reusing an already-open tab for the same path instead of duplicating it, and shall resolve an open source-text tab''s file path on request.' justification: | 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 9efd6db..77fb8a0 100644 --- a/docs/reqstream/sysml2-workbench/element-picker-subsystem/element-filter.yaml +++ b/docs/reqstream/sysml2-workbench/element-picker-subsystem/element-filter.yaml @@ -51,3 +51,22 @@ sections: - 'ElementFilterViewModel_AddTypeFilter_DuplicateLabel_KeepsSingleChip' - 'ElementFilterViewModel_RemoveTypeFilter_PresentAndAbsentLabels_BehavesGracefully' - 'ElementFilterViewModel_GetAddableTypeLabels_ExcludesActiveChips' + + - id: 'SysML2Workbench-ElementPickerSubsystem-ElementFilter-SearchAndCommitAddableTypeFilter' + title: 'The ElementFilter shall support a type-ahead search over the addable (not yet + active) type labels: BeginAddableTypeFilterSearch resets the search text and populates + the full addable candidate set, AddableTypeFilterSearchText narrows that set to labels + containing the search text case-insensitively, and TryCommitAddableTypeFilterSearch adds + the top matching label as a new chip (returning true) or leaves the chip set unchanged + (returning false) when no candidate matches.' + justification: | + A pointer click into the "+" add-flyout''s open list races the flyout''s own light-dismiss + overlay under UI-automation drivers (confirmed by direct observation under NovaWindows), + so a type-ahead search box that commits via Enter gives both end users and UI-automation + tests a reliable, keyboard-only way to add a type-filter chip without relying on pointer + hit-testing into a transient popup. + tests: + - 'ElementFilterViewModel_BeginAddableTypeFilterSearch_ResetsSearchAndPopulatesFullSet' + - 'ElementFilterViewModel_AddableTypeFilterSearchText_NarrowsCandidatesCaseInsensitively' + - 'ElementFilterViewModel_TryCommitAddableTypeFilterSearch_MatchFound_AddsChipAndReturnsTrue' + - 'ElementFilterViewModel_TryCommitAddableTypeFilterSearch_NoMatch_ReturnsFalse' diff --git a/docs/reqstream/sysml2-workbench/workspace-subsystem/workspace-source-set.yaml b/docs/reqstream/sysml2-workbench/workspace-subsystem/workspace-source-set.yaml index f51954b..3c2371b 100644 --- a/docs/reqstream/sysml2-workbench/workspace-subsystem/workspace-source-set.yaml +++ b/docs/reqstream/sysml2-workbench/workspace-subsystem/workspace-source-set.yaml @@ -23,6 +23,13 @@ sections: tests: - 'RemoveSource_RegisteredThenUnknownId_ReturnsTrueThenFalse' + - id: 'SysML2Workbench-WorkspaceSubsystem-WorkspaceSourceSet-ClearSources' + title: 'The WorkspaceSourceSet shall remove every registered source at once.' + justification: | + The File menu's "Close All" command needs a single, no-error way to reset the source set to empty rather than removing each source individually. + tests: + - 'ClearSources_WithRegisteredSources_RemovesAllAndResolveReturnsEmpty' + - id: 'SysML2Workbench-WorkspaceSubsystem-WorkspaceSourceSet-ResolveEmptySet' title: 'The WorkspaceSourceSet shall resolve a zero-source set to an empty, non-error resolution.' justification: | diff --git a/docs/user_guide/getting_started.md b/docs/user_guide/getting_started.md index ad1b894..51ea3c5 100644 --- a/docs/user_guide/getting_started.md +++ b/docs/user_guide/getting_started.md @@ -45,7 +45,9 @@ for setup prerequisites. the **Predefined Views** and **Diagnostics** panels each show a friendly "workspace is empty" message instead of rendering against nothing, and any open diagram tabs are closed. This is the same state the application - starts in before any source is added, not an error condition. + starts in before any source is added, not an error condition. Choosing + **File > Close All** does the same thing in one step - it clears every + added source at once, without needing to remove them one at a time. While a workspace is open, SysML2Workbench watches every added file and folder independently for external changes. If you edit a `.sysml` file in diff --git a/docs/verification/ots/appium.md b/docs/verification/ots/appium.md index 473f723..30d6379 100644 --- a/docs/verification/ots/appium.md +++ b/docs/verification/ots/appium.md @@ -16,6 +16,16 @@ cross-platform `build` job's `Test` step and `build.ps1 -Test`) they are excluded via `--filter "Category!=Integration"`, since no Appium/AT-SPI server is started in those runs. +Several of these tests mutate shared workspace state (adding a real source, or relying on the +`SYSML2WORKBENCH_STARTUP_FILE`-preloaded fixture) against the one `AppFixture`-launched application process shared +across every `[Fact]` in this class (`[Collection("AppFixture")]`). Rather than resetting the workspace before +every test - which would deterministically wipe the `STARTUP_FILE` preload before the one test that depends on it +ever ran, since that preload only happens once at process launch and cannot be redone per test without driving the +very native "Open File" dialog this design avoids - cleanup is owned only by the one state-mutating test itself, +via `AppFixture.CloseAllSources()` (clicks File > Close All through the real accessibility tree) in a `finally` +block. Every other test in this class only asserts menu-item discoverability/enablement, which holds regardless of +whether a source happens to be loaded. + ### Test Scenarios **DesktopApp_Launch_ShowsMainWindowWithExpectedTitle**: Confirms the Appium @@ -37,17 +47,37 @@ The item is not actually clicked, since doing so would open the OS-native **DesktopApp_ViewMenu_PredefinedViewsMenuItem_IsDiscoverableAndEnabled**, **DesktopApp_ViewMenu_DiagnosticsMenuItem_IsDiscoverableAndEnabled**, **DesktopApp_ViewMenu_ViewBuilderDialogMenuItem_IsDiscoverableAndEnabled**, -and **DesktopApp_QueryMenu_QueryDialogMenuItem_IsDiscoverableAndEnabled**: +**DesktopApp_QueryMenu_QueryDialogMenuItem_IsDiscoverableAndEnabled**, and +**DesktopApp_FileMenu_CloseAllMenuItem_IsDiscoverableAndEnabled**: Each opens its respective top-level menu (`File`/`View`/`Query`) and locates one child menu item by automation id, asserting it is both displayed and enabled, then closes the menu via Escape without clicking the item. These extend automation-id coverage breadth-first across every subsystem's top-level entry point (workspace panel, predefined views, diagnostics, -custom view builder, query dialog) without needing to click through to a -dialog or native OS surface that this tier cannot yet reliably tear down. +custom view builder, query dialog, close-all) without needing to click +through to a dialog or native OS surface that this tier cannot yet reliably +tear down. **DesktopApp_HelpMenu_AboutMenuItem_OpensAndClosesAboutDialog**: Opens the Help menu, clicks "About" (`AboutMenuItem`), confirms the modal About dialog's `AboutDialogOkButton` becomes visible, then dismisses it - proving a full menu-click-to-modal-dialog round trip works end-to-end through the real windowed application. + +**DesktopApp_QueryDialog_AddTypeFilterButton_CapturesInspectionScreenshot**: Opens the Query dialog and captures a +cropped PNG of the shared `ElementFilterView`'s "+" add-type-filter button (`AddTypeFilterButton`) to +`artifacts/inspection/query-dialog-add-type-filter-button.png` via `InspectionScreenshot.CaptureElement`, then +closes the dialog. Not a pass/fail correctness assertion (only that the button is displayed and the capture +mechanism completes without error) - the actual visual review of the saved image is a human/agent task, since +automatically detecting styling defects is not a suitable CI gate. Honors `SYSML2WORKBENCH_THEME` for which theme +the single capture reflects. + +**DesktopApp_QueryDialog_PopulatedWithSourceAndChip_CapturesInspectionScreenshot**: Opens the Query dialog against +a workspace already populated by the `SYSML2WORKBENCH_STARTUP_FILE`-preloaded `TestData/InspectionSample.sysml` +fixture (avoiding the unautomatable native "Open File" dialog), clicks `AddTypeFilterButton`, selects the +"attribute" entry from its flyout `ListBox` to add a real filter chip, then captures the whole filter-row region +(`ElementFilterRoot`) to `artifacts/inspection/query-dialog-populated-with-chip.png` - proving the already-applied +`ElementFilterView.axaml` chip-foreground contrast fix (`Foreground="#212121"` on the chip text and its "X" remove +button) is legible with a real populated chip, not just an empty "+" button. Resets the shared session's workspace +back to empty via `AppFixture.CloseAllSources()` in a `finally` block (see the Verification Approach section +above for why cleanup is owned only by this one test). diff --git a/docs/verification/sysml2-workbench/app-shell-subsystem/main-window-shell.md b/docs/verification/sysml2-workbench/app-shell-subsystem/main-window-shell.md index 98db782..111d867 100644 --- a/docs/verification/sysml2-workbench/app-shell-subsystem/main-window-shell.md +++ b/docs/verification/sysml2-workbench/app-shell-subsystem/main-window-shell.md @@ -92,6 +92,12 @@ source produces a valid empty snapshot (zero sources, zero files), clears every source's file watcher. Verified by `MainWindowShellTests.RemoveSourceAsync_DownToZeroSources_ProducesEmptySnapshotAndUnwatchesEverything`. +**CloseAllSourcesAsync_WithMultipleSources_ProducesEmptySnapshotAndUnwatchesEverything**: Closing all sources at +once (starting from a folder plus a file source) produces the same valid empty snapshot shape as removing every +source one at a time, unwatches every previously watched source, and raises `SourcesChanged` for every add plus +the close-all. Verified by +`MainWindowShellTests.CloseAllSourcesAsync_WithMultipleSources_ProducesEmptySnapshotAndUnwatchesEverything`. + **SelectPredefinedView_NoWorkspaceOpened_ThrowsInvalidOperationException**: Selecting a predefined view while zero workspace sources are open throws `InvalidOperationException` rather than rendering against an empty workspace. Verified by `MainWindowShellTests.SelectPredefinedView_NoWorkspaceOpened_ThrowsInvalidOperationException`. diff --git a/docs/verification/sysml2-workbench/element-picker-subsystem/element-filter.md b/docs/verification/sysml2-workbench/element-picker-subsystem/element-filter.md index 349340f..bc63a07 100644 --- a/docs/verification/sysml2-workbench/element-picker-subsystem/element-filter.md +++ b/docs/verification/sysml2-workbench/element-picker-subsystem/element-filter.md @@ -93,3 +93,23 @@ Verified by `ElementFilterViewModelTests.RemoveTypeFilter_PresentAndAbsentLabels `AvailableTypeLabels` minus every currently-active chip, so the "+" add-flyout never re-offers a label that is already active. Verified by `ElementFilterViewModelTests.GetAddableTypeLabels_ExcludesActiveChips`. + +**BeginAddableTypeFilterSearch_ResetsSearchAndPopulatesFullSet**: Opening the "+" add-flyout +resets `AddableTypeFilterSearchText` to empty and populates `AddableTypeFilterCandidates` with the +full addable (not yet active) label set. Verified by +`ElementFilterViewModelTests.ElementFilterViewModel_BeginAddableTypeFilterSearch_ResetsSearchAndPopulatesFullSet`. + +**AddableTypeFilterSearchText_NarrowsCandidatesCaseInsensitively**: Setting +`AddableTypeFilterSearchText` narrows `AddableTypeFilterCandidates` to only the addable labels whose +text contains the search text, case-insensitively. Verified by +`ElementFilterViewModelTests.ElementFilterViewModel_AddableTypeFilterSearchText_NarrowsCandidatesCaseInsensitively`. + +**TryCommitAddableTypeFilterSearch_MatchFound_AddsChipAndReturnsTrue**: When the current search +narrows to at least one addable candidate, `TryCommitAddableTypeFilterSearch()` adds the top +matching label as a new chip in `ActiveTypeFilters` and returns `true`. Verified by +`ElementFilterViewModelTests.ElementFilterViewModel_TryCommitAddableTypeFilterSearch_MatchFound_AddsChipAndReturnsTrue`. + +**TryCommitAddableTypeFilterSearch_NoMatch_ReturnsFalse**: When the current search matches no +addable candidate, `TryCommitAddableTypeFilterSearch()` leaves `ActiveTypeFilters` unchanged and +returns `false`. Verified by +`ElementFilterViewModelTests.ElementFilterViewModel_TryCommitAddableTypeFilterSearch_NoMatch_ReturnsFalse`. diff --git a/docs/verification/sysml2-workbench/workspace-subsystem/workspace-source-set.md b/docs/verification/sysml2-workbench/workspace-subsystem/workspace-source-set.md index 13ec5e5..e4bc780 100644 --- a/docs/verification/sysml2-workbench/workspace-subsystem/workspace-source-set.md +++ b/docs/verification/sysml2-workbench/workspace-subsystem/workspace-source-set.md @@ -44,6 +44,11 @@ regardless of kind. Verified by `WorkspaceSourceSetTests.Sources_PreservesRegist drops it from `Sources`; removing that same id again, or any unknown id, returns `false` without throwing. Verified by `WorkspaceSourceSetTests.RemoveSource_RegisteredThenUnknownId_ReturnsTrueThenFalse`. +**ClearSources_WithRegisteredSources_RemovesAllAndResolveReturnsEmpty**: Calling `ClearSources` after registering +both a file and a folder source empties `Sources`, and a subsequent `Resolve()` produces empty `MergedFiles` and +attribution maps. Verified by +`WorkspaceSourceSetTests.ClearSources_WithRegisteredSources_RemovesAllAndResolveReturnsEmpty`. + **Resolve_ZeroSources_ReturnsEmptyResolution**: Resolving a `WorkspaceSourceSet` with zero registered sources produces an empty `MergedFiles` list and empty attribution maps, with no exception thrown. Verified by `WorkspaceSourceSetTests.Resolve_ZeroSources_ReturnsEmptyResolution`. diff --git a/run-under-appium.ps1 b/run-under-appium.ps1 index 5224b28..bd8a812 100644 --- a/run-under-appium.ps1 +++ b/run-under-appium.ps1 @@ -99,6 +99,48 @@ try { exit 1 } + if ($IsWindows) { + # Windows/UI Automation refuses SetForegroundWindow/SetFocus for any + # process while the interactive console session is locked (the + # desktop is switched to the secure Winlogon/LockApp desktop, which + # is isolated from automation). When that happens, every NovaWindows + # test fails slowly (~10s each) with a cryptic + # "Failed to locate window of the app." error that looks like a code + # regression but is actually just an environment precondition no + # test/code change can fix. Detect it up front and fail fast instead. + Add-Type -Namespace RunUnderAppium -Name NativeMethods -MemberDefinition @' +[DllImport("user32.dll")] +public static extern System.IntPtr GetForegroundWindow(); + +[DllImport("user32.dll")] +public static extern uint GetWindowThreadProcessId(System.IntPtr hWnd, out uint lpdwProcessId); +'@ -ErrorAction SilentlyContinue + + try { + $foregroundWindow = [RunUnderAppium.NativeMethods]::GetForegroundWindow() + $foregroundProcessName = $null + if ($foregroundWindow -ne [System.IntPtr]::Zero) { + $foregroundProcessId = 0 + [void][RunUnderAppium.NativeMethods]::GetWindowThreadProcessId($foregroundWindow, [ref]$foregroundProcessId) + if ($foregroundProcessId -gt 0) { + $foregroundProcess = Get-Process -Id $foregroundProcessId -ErrorAction SilentlyContinue + $foregroundProcessName = $foregroundProcess.ProcessName + } + } + + if ($foregroundProcessName -in @('LockApp', 'LogonUI')) { + Write-Error "The interactive console session is locked (foreground window belongs to '$foregroundProcessName'); Appium/UI Automation cannot attach to application windows while locked (Windows blocks SetForegroundWindow/SetFocus on the locked secure desktop). Unlock the session and retry." + exit 1 + } + } catch { + # Best-effort diagnostic only: if the probe itself fails for any + # reason (e.g. restricted/non-interactive CI service session + # with no accessible foreground window), do not block the run - + # fall through and let the wrapped tests run/fail normally. + Write-Host "Session-lock preflight check could not run ($($_.Exception.Message)); continuing." + } + } + Write-Host "Running: $commandExe $($commandArgs -join ' ')" & $commandExe @commandArgs $exitCode = $LASTEXITCODE diff --git a/src/DemaConsulting.SysML2Workbench/App.axaml.cs b/src/DemaConsulting.SysML2Workbench/App.axaml.cs index d5020b1..d964eac 100644 --- a/src/DemaConsulting.SysML2Workbench/App.axaml.cs +++ b/src/DemaConsulting.SysML2Workbench/App.axaml.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using Avalonia.Styling; using DemaConsulting.SysML2Workbench.AppShellSubsystem; using DemaConsulting.SysML2Workbench.DiagnosticsPanelSubsystem; using DemaConsulting.SysML2Workbench.LayoutRenderingSubsystem; @@ -32,6 +33,8 @@ public override void Initialize() /// public override void OnFrameworkInitializationCompleted() { + ApplyThemeOverrideForTesting(); + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { var logDirectory = Path.Combine( @@ -52,8 +55,67 @@ public override void OnFrameworkInitializationCompleted() desktop.MainWindow = new MainWindowView(shell); desktop.ShutdownRequested += (_, _) => shell.Dispose(); + + ApplyStartupFileForTesting(shell); } base.OnFrameworkInitializationCompleted(); } + + /// + /// Preloads a single workspace file source from the SYSML2WORKBENCH_STARTUP_FILE environment + /// variable, when set, so an Appium/AT-SPI integration-test session can start with a real, populated + /// workspace without driving an unautomatable native OS "Open File" dialog. + /// + /// + /// This is a test-only hook mirroring 's precedent: it has no + /// effect on normal end-user usage of the application, which never has this variable set. It is invoked + /// right after and its are constructed, before the + /// Avalonia message loop starts pumping. Blocking synchronously here with + /// GetAwaiter().GetResult() is safe: 's awaited + /// call chain (source-set add, file-watcher registration, workspace load, snapshot apply, + /// raised via a fire-and-forget dispatcher post) never + /// awaits a continuation that itself requires the not-yet-started message loop to be pumping. + /// + /// The freshly composed shell to preload a source into. + private static void ApplyStartupFileForTesting(MainWindowShell shell) + { + var startupFile = Environment.GetEnvironmentVariable("SYSML2WORKBENCH_STARTUP_FILE"); + if (string.IsNullOrWhiteSpace(startupFile)) + { + return; + } + + shell.AddFileSourceAsync(startupFile).GetAwaiter().GetResult(); + } + + /// + /// Overrides RequestedThemeVariant from the SYSML2WORKBENCH_THEME environment + /// variable ("Dark" or "Light", case-insensitive) when set, otherwise leaves the XAML-declared + /// "Default" (OS-following) variant untouched. + /// + /// + /// This is a test-only hook: it exists so an Appium/AT-SPI integration-test session (which cannot + /// otherwise force a specific theme, since NovaWindows/Mac2/AT-SPI2 launch the published app as an + /// independent OS process with no per-test capability for injecting environment variables - only + /// whatever environment the Appium server itself inherited when run-under-appium.ps1 started + /// it) can capture dark-mode inspection screenshots on demand, by setting this variable before running + /// build.ps1 -IntegrationTest. It has no effect on normal end-user usage of the application, + /// which always follows the OS theme unless this variable happens to be set in the user's environment. + /// + private static void ApplyThemeOverrideForTesting() + { + var themeOverride = Environment.GetEnvironmentVariable("SYSML2WORKBENCH_THEME"); + if (Current is null) + { + return; + } + + Current.RequestedThemeVariant = themeOverride?.ToUpperInvariant() switch + { + "DARK" => ThemeVariant.Dark, + "LIGHT" => ThemeVariant.Light, + _ => Current.RequestedThemeVariant, + }; + } } diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowShell.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowShell.cs index f2531a7..81a2c41 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowShell.cs +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowShell.cs @@ -349,6 +349,27 @@ public async Task RemoveSourceAsync(string sourceId) } } + /// + /// Closes every source in the workspace's source set at once, returning the shell to the same valid + /// empty state used at construction (no sources, no watched sources, no open tabs/diagnostics). + /// + /// The freshly resolved and loaded (empty) workspace snapshot. + public async Task CloseAllSourcesAsync() + { + try + { + _sourceSet.ClearSources(); + var snapshot = await ApplySourceSetChangeAsync().ConfigureAwait(false); + _logger.Log(LogLevel.Info, "All workspace sources closed"); + return snapshot; + } + catch (Exception ex) + { + _logger.Log(LogLevel.Error, "Failed to close all workspace sources", ex); + throw; + } + } + /// /// Resolves the current , loads the resulting workspace, applies the snapshot, /// diffs the previous and new watch sets to call / diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml index 049f03f..c8e83e8 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml @@ -6,7 +6,7 @@ x:Class="DemaConsulting.SysML2Workbench.AppShellSubsystem.MainWindowView" Title="SysML2Workbench" Icon="avares://DemaConsulting.SysML2Workbench/Assets/Icon.png" - Width="1280" Height="800"> + Width="1024" Height="700"> @@ -22,6 +22,11 @@ + + + + + diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml.cs index 64697fd..1fdb104 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml.cs +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml.cs @@ -68,6 +68,7 @@ public MainWindowView(MainWindowShell shell) AddFileSourceMenuItem.Click += OnAddFileSourceClick; AddFolderSourceMenuItem.Click += OnAddFolderSourceClick; + CloseAllMenuItem.Click += OnCloseAllMenuItemClick; // Each View-menu item's DataContext is explicitly set to its panel view model (distinct from this // window's own DataContext) so the one-way IsChecked binding above resolves against the panel's @@ -313,6 +314,23 @@ private async void OnAddFolderSourceClick(object? sender, RoutedEventArgs e) } } + /// + /// Handles the File menu's "Close All" click by closing every currently loaded workspace source, + /// returning the workspace to the same empty state used at application startup. + /// + private async void OnCloseAllMenuItemClick(object? sender, RoutedEventArgs e) + { + try + { + await _shell.CloseAllSourcesAsync(); + RefreshPanelsFromWorkspace(); + } + catch (Exception ex) + { + _workspacePanelViewModel.StatusMessage = $"Failed to close all sources: {ex.Message}"; + } + } + /// /// Handles a drag-and-drop drop anywhere on the main window by adding each dropped file or folder as a /// new workspace source, funneling through the same APIs the File menu and diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml index da2f4c9..25c8d32 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/QueryDialogView.axaml @@ -13,7 +13,8 @@ - diff --git a/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementFilterView.axaml.cs b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementFilterView.axaml.cs index 764d899..ffe73ec 100644 --- a/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementFilterView.axaml.cs +++ b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementFilterView.axaml.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Interactivity; namespace DemaConsulting.SysML2Workbench.ElementPickerSubsystem; @@ -42,10 +43,11 @@ public ElementFilterView() 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. + /// Resets the add-filter flyout's search box and candidate list each time the "+" + /// button's flyout is about to open, so it always starts showing the full addable set + /// (see ) rather than + /// stale search text/results from a previous opening. Also moves keyboard focus into the + /// search box so keyboard users can immediately start typing to narrow the list. /// private void OnAddTypeFilterFlyoutOpening(object? sender, EventArgs e) { @@ -54,7 +56,30 @@ private void OnAddTypeFilterFlyoutOpening(object? sender, EventArgs e) return; } - AddableTypeFilterListBox.ItemsSource = ViewModel.GetAddableTypeLabels(); + ViewModel.BeginAddableTypeFilterSearch(); + AddTypeFilterSearchTextBox.Focus(); + } + + /// + /// Commits the add-filter search's top/only match as a new chip when the user presses + /// Enter in - the keyboard/automation-friendly + /// counterpart to clicking a row in directly. + /// A no-op (beyond marking the key event handled) when the current search matches + /// nothing. + /// + private void OnAddableTypeFilterSearchKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key != Key.Enter || ViewModel is null) + { + return; + } + + if (ViewModel.TryCommitAddableTypeFilterSearch()) + { + AddTypeFilterButton.Flyout?.Hide(); + } + + e.Handled = true; } /// diff --git a/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementFilterViewModel.cs b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementFilterViewModel.cs index b2e0264..190f713 100644 --- a/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementFilterViewModel.cs +++ b/src/DemaConsulting.SysML2Workbench/ElementPickerSubsystem/ElementFilterViewModel.cs @@ -45,6 +45,12 @@ public sealed partial class ElementFilterViewModel : ObservableObject [ObservableProperty] private IReadOnlyList _displayedItems = []; + [ObservableProperty] + private string? _addableTypeFilterSearchText = ""; + + [ObservableProperty] + private IReadOnlyList _addableTypeFilterCandidates = []; + /// /// 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 @@ -132,6 +138,70 @@ public IReadOnlyList GetAddableTypeLabels() .ToList(); } + /// + /// Resets to empty and recomputes + /// from the current addable set. Called each + /// time the "+" add-filter flyout is about to open, so it always starts showing the full + /// addable set rather than stale search text/results left over from a previous opening. + /// + public void BeginAddableTypeFilterSearch() + { + AddableTypeFilterSearchText = ""; + RecomputeAddableTypeFilterCandidates(); + } + + /// + /// Adds the first entry in (the current + /// search's top/only match) as a new active filter chip, the same "type ahead then + /// commit" semantics as a combo box's highlighted match. A no-op that returns + /// when the current search text matches no addable label. + /// + /// + /// when a chip was added; when + /// was empty. + /// + public bool TryCommitAddableTypeFilterSearch() + { + var topMatch = AddableTypeFilterCandidates.FirstOrDefault(); + if (topMatch is null) + { + return false; + } + + AddTypeFilter(topMatch); + return true; + } + + /// + /// Recomputes from , + /// narrowed by a case-insensitive substring match against + /// (an empty/null search shows every addable label). + /// + private void RecomputeAddableTypeFilterCandidates() + { + IEnumerable query = GetAddableTypeLabels(); + + var searchText = AddableTypeFilterSearchText; + if (!string.IsNullOrEmpty(searchText)) + { + query = query.Where(label => label.Contains(searchText, StringComparison.OrdinalIgnoreCase)); + } + + AddableTypeFilterCandidates = query.ToList(); + } + + /// + /// CommunityToolkit.Mvvm-generated hook invoked whenever + /// changes (via the add-filter flyout's + /// two-way-bound search TextBox), recomputing + /// so the flyout's list narrows live as the user types. + /// + /// The new search text value. + partial void OnAddableTypeFilterSearchTextChanged(string? value) + { + RecomputeAddableTypeFilterCandidates(); + } + /// /// Adds to if it is not /// already present (no duplicate chips), then recomputes . diff --git a/src/DemaConsulting.SysML2Workbench/WorkspaceSubsystem/WorkspaceSourceSet.cs b/src/DemaConsulting.SysML2Workbench/WorkspaceSubsystem/WorkspaceSourceSet.cs index 5082410..74860c4 100644 --- a/src/DemaConsulting.SysML2Workbench/WorkspaceSubsystem/WorkspaceSourceSet.cs +++ b/src/DemaConsulting.SysML2Workbench/WorkspaceSubsystem/WorkspaceSourceSet.cs @@ -195,6 +195,14 @@ public bool RemoveSource(string sourceId) return true; } + /// + /// Removes every registered source, leaving the set empty. A no-op (not an error) when already empty. + /// + public void ClearSources() + { + _sources.Clear(); + } + /// /// Resolves every registered source into a single, deduplicated file list plus per-file and per-source /// attribution. diff --git a/test/DemaConsulting.SysML2Workbench.IntegrationTests/AppFixture.cs b/test/DemaConsulting.SysML2Workbench.IntegrationTests/AppFixture.cs index 2f6562c..4baba21 100644 --- a/test/DemaConsulting.SysML2Workbench.IntegrationTests/AppFixture.cs +++ b/test/DemaConsulting.SysML2Workbench.IntegrationTests/AppFixture.cs @@ -56,6 +56,22 @@ public sealed class AppFixture : IDisposable /// started before this test process launched, and using the driver appropriate for the current /// operating system. /// + /// + /// This fixture deliberately does not attempt to reposition/resize the launched window (for example + /// to guarantee it is fully on-screen for ). Both + /// Window.Position and Window.Size map to the W3C SetWindowRect command, and + /// appium-novawindows-driver 1.4.1's implementation of that command has a confirmed bug: it + /// checks the unsupplied half of the rect (e.g. width/height when only + /// Position is set) against null, but the client actually omits those fields entirely, + /// so they arrive as undefined - which is !== null - and the driver tries to run the + /// other operation anyway with empty arguments, producing a PowerShell parse error. This was + /// confirmed by capturing the driver's literal generated script (containing .Resize(, ) / + /// .Move(, )) and by patching the installed driver's null-checks locally, which fixed the + /// issue - see the upstream report for the full repro and verified fix. Until a fixed NovaWindows + /// release is available, MainWindowView instead ships a small default startup size that + /// comfortably fits on-screen (including under a typical taskbar) without needing any runtime + /// repositioning. + /// public AppFixture() { Driver = OperatingSystem.IsWindows() @@ -66,6 +82,13 @@ public AppFixture() ? CreateLinuxDriver() : throw new PlatformNotSupportedException( "SysML2Workbench's Appium AppFixture only recognizes Windows, macOS, and Linux."); + + // Some UI transitions (notably a Flyout closing after a selection, which briefly disrupts the + // automation tree while its popup window tears down) are not instantaneous from the automation + // backend's point of view, even though they are instantaneous on-screen. A short implicit wait + // lets FindElement retry for a bit instead of failing immediately with NoSuchElementException, + // without slowing down the common case where the element is already present. + Driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(5); } /// @@ -80,6 +103,19 @@ public void Dispose() Driver.Dispose(); } + /// + /// Clicks File > Close All (CloseAllMenuItem) to reset the shared session's workspace back to + /// its empty starting state. Reusable cleanup seam for any test that adds a real file/folder source + /// (directly, or via the SYSML2WORKBENCH_STARTUP_FILE preload) and must not leave that state + /// behind for whichever test in this [Collection("AppFixture")]-shared session runs next - see + /// MainWindowShellIntegrationTests's own remarks for which tests call this and why. + /// + public void CloseAllSources() + { + Driver.FindElement(MobileBy.Name("File")).Click(); + Driver.FindElement(MobileBy.AccessibilityId("CloseAllMenuItem")).Click(); + } + /// /// Resolves the path to the published Desktop executable/binary for the current operating system, /// preferring the SYSML2WORKBENCH_APP_PATH environment variable (set by build.ps1/CI diff --git a/test/DemaConsulting.SysML2Workbench.IntegrationTests/DemaConsulting.SysML2Workbench.IntegrationTests.csproj b/test/DemaConsulting.SysML2Workbench.IntegrationTests/DemaConsulting.SysML2Workbench.IntegrationTests.csproj index 78aebef..57324c9 100644 --- a/test/DemaConsulting.SysML2Workbench.IntegrationTests/DemaConsulting.SysML2Workbench.IntegrationTests.csproj +++ b/test/DemaConsulting.SysML2Workbench.IntegrationTests/DemaConsulting.SysML2Workbench.IntegrationTests.csproj @@ -7,6 +7,7 @@ + diff --git a/test/DemaConsulting.SysML2Workbench.IntegrationTests/InspectionScreenshot.cs b/test/DemaConsulting.SysML2Workbench.IntegrationTests/InspectionScreenshot.cs new file mode 100644 index 0000000..ae5301e --- /dev/null +++ b/test/DemaConsulting.SysML2Workbench.IntegrationTests/InspectionScreenshot.cs @@ -0,0 +1,106 @@ +using OpenQA.Selenium; +using OpenQA.Selenium.Appium; +using SkiaSharp; + +namespace DemaConsulting.SysML2Workbench.IntegrationTests; + +/// +/// Captures full-window or element-cropped PNG screenshots from a live Appium session and saves them +/// under artifacts/inspection/ for later manual/agent-driven visual review, since automatically +/// detecting styling defects (contrast, color, spacing) is an LLM-visual-inspection problem that does +/// not belong in a pass/fail CI gate. +/// +/// +/// AppiumDriver.GetScreenshot() is part of the base WebDriver protocol, so this works +/// identically across every backend (NovaWindows, Mac2, AT-SPI2) with no +/// backend-specific code. Cropping uses SkiaSharp (MIT-licensed, and already Avalonia's own rendering +/// backend, so no new licensing concern is introduced) rather than System.Drawing.Common, which +/// is Windows-only from .NET 6 onward and would not work on this repository's Linux/macOS Appium +/// branches. +/// +public static class InspectionScreenshot +{ + /// + /// Resolves the inspection subfolder under wherever inspection screenshots should land: the + /// repo-root-relative artifacts directory that build.ps1's wrapped + /// dotnet test .../--results-directory artifacts/tests invocation targets, or a same-repo + /// artifacts folder when run directly via a bare dotnet test from the repository root. + /// + /// + /// dotnet test runs the test host with its current directory set to the test assembly's own + /// bin/<configuration>/<tfm> output folder, not wherever dotnet test itself + /// was invoked from - unlike --results-directory, which vstest resolves relative to the + /// invocation directory, not the test host's. A plain relative "artifacts" path here would + /// therefore silently land inside that output folder instead of the repo-root artifacts/ + /// directory that .github/workflows/build.yaml's appium-windows-integration-tests job + /// already uploads wholesale - so build.ps1 sets SYSML2WORKBENCH_ARTIFACTS_DIR to an + /// absolute path (mirroring the existing SYSML2WORKBENCH_APP_PATH convention in + /// ) before invoking the wrapped dotnet test, so this lands in the same + /// place regardless of the test host's own working directory. + /// + private static readonly string OutputDirectory = Path.Combine( + Environment.GetEnvironmentVariable("SYSML2WORKBENCH_ARTIFACTS_DIR") ?? "artifacts", + "inspection"); + + /// + /// Captures the full application window and saves it as a PNG. + /// + /// The live Appium session to capture from. + /// + /// Descriptive file name (without extension), e.g. "main-window_dark_win". Should identify the + /// captured area, active theme, and platform so a reviewer can tell images apart without opening each + /// one. + /// + public static void CaptureWindow(AppiumDriver driver, string fileName) + { + Save(driver.GetScreenshot().AsByteArray, fileName); + } + + /// + /// Captures just the region covered by , cropped from a full-window + /// screenshot using its on-screen bounds, and saves it as a PNG. + /// + /// The live Appium session to capture from. + /// The element whose bounding rectangle should be cropped out. + /// See 's fileName parameter. + public static void CaptureElement(AppiumDriver driver, IWebElement element, string fileName) + { + using var fullImage = SKBitmap.Decode(driver.GetScreenshot().AsByteArray); + + var location = element.Location; + var size = element.Size; + + // Clamp to the captured image's bounds: some backends report coordinates slightly outside the + // screenshot (for example a partially off-screen tooltip anchor), which would otherwise throw. + var x = Math.Max(0, location.X); + var y = Math.Max(0, location.Y); + var width = Math.Max(0, Math.Min(size.Width, fullImage.Width - x)); + var height = Math.Max(0, Math.Min(size.Height, fullImage.Height - y)); + + using var cropped = new SKBitmap(width, height); + using (var canvas = new SKCanvas(cropped)) + { + var sourceRect = SKRect.Create(x, y, width, height); + var destRect = SKRect.Create(0, 0, width, height); + canvas.DrawBitmap(fullImage, sourceRect, destRect, SKSamplingOptions.Default); + } + + using var data = cropped.Encode(SKEncodedImageFormat.Png, quality: 100); + Directory.CreateDirectory(OutputDirectory); + + // File.Create (unlike File.OpenWrite) truncates an existing file: without this, re-running the + // test after a prior capture wrote a larger PNG would leave that PNG's trailing bytes appended + // past the new (shorter) image data, corrupting the file. + using var file = File.Create(Path.Combine(OutputDirectory, $"{fileName}.png")); + data.SaveTo(file); + } + + /// + /// Writes raw PNG bytes to under . + /// + private static void Save(byte[] pngBytes, string fileName) + { + Directory.CreateDirectory(OutputDirectory); + File.WriteAllBytes(Path.Combine(OutputDirectory, $"{fileName}.png"), pngBytes); + } +} diff --git a/test/DemaConsulting.SysML2Workbench.IntegrationTests/MainWindowShellIntegrationTests.cs b/test/DemaConsulting.SysML2Workbench.IntegrationTests/MainWindowShellIntegrationTests.cs index 894335f..a5d402b 100644 --- a/test/DemaConsulting.SysML2Workbench.IntegrationTests/MainWindowShellIntegrationTests.cs +++ b/test/DemaConsulting.SysML2Workbench.IntegrationTests/MainWindowShellIntegrationTests.cs @@ -15,6 +15,7 @@ namespace DemaConsulting.SysML2Workbench.IntegrationTests; [Trait("Category", "Integration")] public sealed class MainWindowShellIntegrationTests { + private readonly AppFixture _fixture; private readonly AppiumDriver _session; /// @@ -23,6 +24,7 @@ public sealed class MainWindowShellIntegrationTests /// The shared fixture that launched the Desktop application. public MainWindowShellIntegrationTests(AppFixture fixture) { + _fixture = fixture; _session = fixture.Driver; } @@ -69,6 +71,18 @@ public void DesktopApp_FileMenu_AddFolderSourceMenuItem_IsDiscoverableAndEnabled AssertMenuItemIsDiscoverableAndEnabled("File", "AddFolderSourceMenuItem"); } + /// + /// Validates that the File menu's "Close All" item, found by the CloseAllMenuItem automation id, + /// is discoverable and enabled. Not clicked here (this only proves discoverability): actually invoking + /// it is exercised end-to-end by , used as cleanup by + /// . + /// + [Fact] + public void DesktopApp_FileMenu_CloseAllMenuItem_IsDiscoverableAndEnabled() + { + AssertMenuItemIsDiscoverableAndEnabled("File", "CloseAllMenuItem"); + } + /// /// Validates that the View menu's "Workspace" panel-toggle item, found by the /// WorkspacePanelMenuItem automation id, is discoverable and enabled. Not clicked, since toggling @@ -161,14 +175,213 @@ public void DesktopApp_HelpMenu_AboutMenuItem_OpensAndClosesAboutDialog() var helpMenu = _session.FindElement(MobileBy.Name("Help")); helpMenu.Click(); var aboutMenuItem = _session.FindElement(MobileBy.AccessibilityId("AboutMenuItem")); - - // Act aboutMenuItem.Click(); - var okButton = _session.FindElement(MobileBy.AccessibilityId("AboutDialogOkButton")); - // Assert - Assert.True(okButton.Displayed); + try + { + // Act + var okButton = _session.FindElement(MobileBy.AccessibilityId("AboutDialogOkButton")); + + // Assert + Assert.True(okButton.Displayed); + + okButton.Click(); + } + catch + { + TryCloseAnyOpenDialogWithEscape(); + throw; + } + } + + /// + /// Captures a cropped PNG of the Query dialog's "+" add-type-filter button (part of the shared + /// ElementFilterView reused by several dialogs across the application) under whatever theme the + /// live session is running under, and saves it to artifacts/inspection/ for later manual or + /// agent-driven visual review. + /// + /// + /// This is not a correctness assertion: automatically detecting styling defects such as low-contrast + /// text is an LLM-visual-inspection problem, not a pass/fail CI check, so this test only proves the + /// capture mechanism works end-to-end and leaves the actual review to a human or an agent looking at + /// the saved image. Set the SYSML2WORKBENCH_THEME environment variable to Dark or + /// Light before launching the session (for example, before running + /// build.ps1 -IntegrationTest) to control which theme this capture reflects - the whole Appium + /// session's app process inherits one theme for its entire lifetime, since none of NovaWindows/Mac2/ + /// AT-SPI2 support per-test environment injection into an already-launched app. This captures only the + /// "+" button, not an active filter chip: the chip row's actual color-contrast complaint (a chip's + /// light-grey background in ElementFilterView.axaml) needs at least one active filter, which + /// requires a loaded workspace - no Appium test can drive that yet - so this only proves the capture + /// mechanism works, and will be extended to capture a real chip once a workspace-loading test hook + /// exists. + /// + [Fact] + public void DesktopApp_QueryDialog_AddTypeFilterButton_CapturesInspectionScreenshot() + { + // Arrange + var queryMenu = _session.FindElement(MobileBy.Name("Query")); + queryMenu.Click(); + var queryDialogMenuItem = _session.FindElement(MobileBy.AccessibilityId("QueryDialogMenuItem")); + queryDialogMenuItem.Click(); + + try + { + // Act + var addTypeFilterButton = _session.FindElement(MobileBy.AccessibilityId("AddTypeFilterButton")); + InspectionScreenshot.CaptureElement(_session, addTypeFilterButton, "query-dialog-add-type-filter-button"); + + // Assert + Assert.True(addTypeFilterButton.Displayed); + + _session.FindElement(MobileBy.AccessibilityId("QueryDialogCloseButton")).Click(); + } + catch + { + TryCloseAnyOpenDialogWithEscape(); + throw; + } + } + + /// + /// Captures a larger PNG of the whole Query dialog's type-filter row (ElementFilterRoot, the + /// shared ElementFilterView's chip row plus its "+" add-chip button and search box) with a real, + /// populated workspace and an actual "attribute" filter chip added, proving the already-applied + /// ElementFilterView.axaml chip-foreground contrast fix (Foreground="#212121" on the chip + /// text and its "X" remove button) is legible under whatever theme the live session is running under. + /// + /// + /// The workspace is populated via the SYSML2WORKBENCH_STARTUP_FILE environment variable (read by + /// App.axaml.cs's ApplyStartupFileForTesting at process startup, before this test process + /// ever launches), preloading TestData/InspectionSample.sysml - a small fixture containing a + /// single attribute feature usage - rather than driving the unautomatable native OS "Open File" + /// dialog. Like , + /// set SYSML2WORKBENCH_THEME to Dark/Light before launching the session to control + /// which theme this single capture reflects (the whole session's app process inherits one theme for its + /// entire lifetime - no per-test theme switching is possible against an already-launched app - so this + /// follows the exact same one-run, one-file convention, not a per-test dual light/dark capture). + /// + /// Reset strategy (documented per this class's shared-session constraint - see + /// ): the SYSML2WORKBENCH_STARTUP_FILE preload happens + /// exactly once, at process launch, and cannot be redone per test without the very native-dialog + /// automation this design avoids. A blanket "reset before every test" policy would therefore + /// deterministically wipe the preloaded workspace before this test ever got to run, regardless of + /// xUnit's (unspecified) execution order within the class. Cleanup is instead owned only by this one + /// state-mutating test, via a finally block calling - + /// every other test in this class only asserts menu-item discoverability/enablement, which holds + /// whether or not a source happens to be loaded, so no other test is order-sensitive either way. A + /// future test that itself needs an empty starting workspace must add its own guard (or this class must + /// switch to a blanket-reset strategy) if this ever stops being the only mutating test. + /// + /// + [Fact] + public void DesktopApp_QueryDialog_PopulatedWithSourceAndChip_CapturesInspectionScreenshot() + { + // Arrange - open the Query dialog. The workspace is already populated by the + // SYSML2WORKBENCH_STARTUP_FILE-preloaded fixture (see remarks above). + var queryMenu = _session.FindElement(MobileBy.Name("Query")); + queryMenu.Click(); + var queryDialogMenuItem = _session.FindElement(MobileBy.AccessibilityId("QueryDialogMenuItem")); + queryDialogMenuItem.Click(); + + try + { + // Act - add a real "attribute" type-filter chip via the "+" button's search-filtered flyout. + var addTypeFilterButton = _session.FindElement(MobileBy.AccessibilityId("AddTypeFilterButton")); + addTypeFilterButton.Click(); + + // Type into the search box and press Enter rather than clicking a ListBoxItem directly: a + // real pointer click into an open Flyout races NovaWindows' synthesized input against + // Avalonia's light-dismiss overlay - confirmed by direct visual observation (the mouse + // visibly hovers over the "attribute" entry, but the popup dismisses instead of the item + // being selected), so the click "succeeds" from Appium's point of view yet no chip is ever + // actually added. Typing into a TextBox and pressing Enter is one of the most reliable + // interactions across every Appium/UIA driver, with no pointer hit-testing involved. + var searchTextBox = _session.FindElement(MobileBy.AccessibilityId("AddTypeFilterSearchTextBox")); + searchTextBox.SendKeys("attribute"); + searchTextBox.SendKeys(OpenQA.Selenium.Keys.Enter); + + var elementFilterRoot = _session.FindElement(MobileBy.AccessibilityId("ElementFilterRoot")); + + // The chip is added asynchronously (the key press returns to Appium before Avalonia's + // dispatcher has re-rendered the bound ItemsControl), so poll briefly for the "attribute" + // chip's own text element to actually appear before capturing - otherwise the screenshot + // can race ahead of the render and show only the empty chip row. Scope the search to + // elementFilterRoot's own subtree (rather than the whole session) so this cannot be + // satisfied by a same-named element that isn't the chip: the search TextBox still holds the + // typed "attribute" text, and the flyout's candidate ListBoxItem may still be visible for a + // moment after Enter is pressed - both would otherwise produce a false-positive match. + var chipRendered = WaitUntil(() => elementFilterRoot.FindElements(MobileBy.Name("attribute")).Count > 0); + Assert.True(chipRendered, "The 'attribute' chip did not render in time for the screenshot."); + + InspectionScreenshot.CaptureElement(_session, elementFilterRoot, "query-dialog-populated-with-chip"); + + // Assert + Assert.True(elementFilterRoot.Displayed); + + _session.FindElement(MobileBy.AccessibilityId("QueryDialogCloseButton")).Click(); + } + catch + { + TryCloseAnyOpenDialogWithEscape(); + throw; + } + finally + { + // See this test's remarks for why cleanup is owned only by this one mutating test. Swallow any + // exception here (mirroring TryCloseAnyOpenDialogWithEscape's own self-protecting catch): if the + // test already failed with a stray modal left open, CloseAllSources() itself can throw (e.g. its + // File-menu click fails), and per CLR semantics an exception raised in a finally block replaces + // whatever exception is already propagating from try/catch, silently discarding the real failure. + try + { + _fixture.CloseAllSources(); + } + catch + { + // Best-effort only - never let cleanup mask a real assertion/exception from the test body. + } + } + } + + /// + /// Best-effort cleanup used when a dialog-opening test fails before it reaches its own close-button + /// click: sends Escape at the driver level (rather than to a specific, possibly-not-found element) so + /// a stray modal dialog does not stay open and block every later test in this shared-session fixture, + /// as happened once during development of this test class. Swallows any exception of its own, since + /// this only runs while another exception is already propagating and must never mask it. + /// + private void TryCloseAnyOpenDialogWithEscape() + { + try + { + new OpenQA.Selenium.Interactions.Actions(_session).SendKeys(OpenQA.Selenium.Keys.Escape).Perform(); + } + catch + { + // Best-effort only - the original exception is what the caller should see. + } + } + + /// + /// Polls every 100ms until it returns or + /// elapses (default 3 seconds), returning whether it succeeded. Used to + /// wait for UI-thread-driven, binding-triggered visual changes (for example a chip appearing after a + /// selection click) that Appium's synchronous Click() does not itself wait for, without adding + /// a Selenium.Support package dependency just for WebDriverWait. + /// + private static bool WaitUntil(Func condition, TimeSpan? timeout = null) + { + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(3)); + while (DateTime.UtcNow < deadline) + { + if (condition()) + { + return true; + } + + Thread.Sleep(100); + } - okButton.Click(); + return condition(); } } diff --git a/test/DemaConsulting.SysML2Workbench.IntegrationTests/TestData/InspectionSample.sysml b/test/DemaConsulting.SysML2Workbench.IntegrationTests/TestData/InspectionSample.sysml new file mode 100644 index 0000000..3d32d66 --- /dev/null +++ b/test/DemaConsulting.SysML2Workbench.IntegrationTests/TestData/InspectionSample.sysml @@ -0,0 +1,5 @@ +package InspectionSample { + part def Sensor { + attribute reading; + } +} diff --git a/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/MainWindowShellTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/MainWindowShellTests.cs index 1bb1d7f..60d8c75 100644 --- a/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/MainWindowShellTests.cs +++ b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/MainWindowShellTests.cs @@ -657,6 +657,49 @@ public async Task RemoveSourceAsync_DownToZeroSources_ProducesEmptySnapshotAndUn Assert.Equal(2, sourcesChangedCount); } + /// + /// Validates that closes every registered source at + /// once (a folder plus a file), producing the same valid empty snapshot shape as removing every source + /// one at a time, unwatching every previously watched source, and raising + /// for every add plus the close-all. + /// + [Fact] + public async Task CloseAllSourcesAsync_WithMultipleSources_ProducesEmptySnapshotAndUnwatchesEverything() + { + // Arrange + await WriteSampleWorkspaceAsync(); + var secondRoot = Directory.CreateTempSubdirectory("sysml2workbench-tests-").FullName; + try + { + var filePath = Path.Combine(secondRoot, "Other.sysml"); + await File.WriteAllTextAsync(filePath, "package Other {\n part def Bracket;\n}\n", TestContext.Current.CancellationToken); + + var fileWatcher = new FileWatcher(TimeSpan.FromMilliseconds(1)); + using var shell = CreateShell(fileWatcher); + var sourcesChangedCount = 0; + shell.SourcesChanged += (_, _) => sourcesChangedCount++; + + await shell.AddFolderSourceAsync(_tempRoot); + await shell.AddFileSourceAsync(filePath); + Assert.Equal(2, shell.CurrentWorkspace.Sources.Count); + + // Act + var snapshot = await shell.CloseAllSourcesAsync(); + + // Assert: a valid, empty snapshot; nothing is watched; and every mutation (2 adds + 1 close-all) + // raised the event. + Assert.Empty(snapshot.Sources); + Assert.Empty(snapshot.Files); + Assert.Empty(shell.CurrentWorkspace.Sources); + Assert.Empty(fileWatcher.WatchedSourceIds); + Assert.Equal(3, sourcesChangedCount); + } + finally + { + Directory.Delete(secondRoot, recursive: true); + } + } + /// /// Validates that is raised for each add mutation, and that /// it is not raised at construction time (construction only establishes the initial empty snapshot; it diff --git a/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementFilterViewModelTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementFilterViewModelTests.cs index 4722008..a33015b 100644 --- a/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementFilterViewModelTests.cs +++ b/test/DemaConsulting.SysML2Workbench.Tests/ElementPickerSubsystem/ElementFilterViewModelTests.cs @@ -270,6 +270,94 @@ public void ElementFilterViewModel_GetAddableTypeLabels_ExcludesActiveChips() Assert.Equal(new[] { "package", "part def" }, addable); } + /// + /// Validates resets the + /// search text to empty and populates + /// with the full addable set (mirroring ) + /// each time the add-filter flyout opens, discarding any leftover search text from a previous + /// opening. + /// + [Fact] + public void ElementFilterViewModel_BeginAddableTypeFilterSearch_ResetsSearchAndPopulatesFullSet() + { + // Arrange + var filter = new ElementFilterViewModel(); + filter.SetCandidates(MixedCandidates, defaultTypeFilterLabel: "part"); + filter.AddableTypeFilterSearchText = "leftover search text"; + + // Act + filter.BeginAddableTypeFilterSearch(); + + // Assert + Assert.Equal("", filter.AddableTypeFilterSearchText); + Assert.Equal(new[] { "package", "part def" }, filter.AddableTypeFilterCandidates); + } + + /// + /// Validates that setting + /// narrows with a + /// case-insensitive substring match, mirroring 's + /// existing substring-match semantics for the main displayed list. + /// + [Fact] + public void ElementFilterViewModel_AddableTypeFilterSearchText_NarrowsCandidatesCaseInsensitively() + { + // Arrange + var filter = new ElementFilterViewModel(); + filter.SetCandidates(MixedCandidates); + filter.BeginAddableTypeFilterSearch(); + + // Act + filter.AddableTypeFilterSearchText = "DEF"; + + // Assert + Assert.Equal(new[] { "part def" }, filter.AddableTypeFilterCandidates); + } + + /// + /// Validates adds the + /// current search's single matching candidate as a new active filter chip and returns + /// . + /// + [Fact] + public void ElementFilterViewModel_TryCommitAddableTypeFilterSearch_MatchFound_AddsChipAndReturnsTrue() + { + // Arrange + var filter = new ElementFilterViewModel(); + filter.SetCandidates(MixedCandidates); + filter.BeginAddableTypeFilterSearch(); + filter.AddableTypeFilterSearchText = "package"; + + // Act + var committed = filter.TryCommitAddableTypeFilterSearch(); + + // Assert + Assert.True(committed); + Assert.Contains("package", filter.ActiveTypeFilters); + } + + /// + /// Validates is a no-op + /// that returns when the current search text matches no addable + /// label. + /// + [Fact] + public void ElementFilterViewModel_TryCommitAddableTypeFilterSearch_NoMatch_ReturnsFalse() + { + // Arrange + var filter = new ElementFilterViewModel(); + filter.SetCandidates(MixedCandidates); + filter.BeginAddableTypeFilterSearch(); + filter.AddableTypeFilterSearchText = "not-a-real-label"; + + // Act + var committed = filter.TryCommitAddableTypeFilterSearch(); + + // Assert + Assert.False(committed); + Assert.Empty(filter.ActiveTypeFilters); + } + /// /// Validates that can be called /// multiple times, and that the second call fully replaces the filter's state (chips diff --git a/test/DemaConsulting.SysML2Workbench.Tests/WorkspaceSubsystem/WorkspaceSourceSetTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/WorkspaceSubsystem/WorkspaceSourceSetTests.cs index 41d8bfb..a8920c5 100644 --- a/test/DemaConsulting.SysML2Workbench.Tests/WorkspaceSubsystem/WorkspaceSourceSetTests.cs +++ b/test/DemaConsulting.SysML2Workbench.Tests/WorkspaceSubsystem/WorkspaceSourceSetTests.cs @@ -123,6 +123,32 @@ public void RemoveSource_RegisteredThenUnknownId_ReturnsTrueThenFalse() Assert.Empty(sourceSet.Sources); } + /// + /// Validates that removes every registered source at + /// once, leaving empty and + /// resolving to an empty result - a no-error, first-class "close all" operation. + /// + [Fact] + public async Task ClearSources_WithRegisteredSources_RemovesAllAndResolveReturnsEmpty() + { + // Arrange + var filePath = Path.Combine(_tempRoot, "Sample.sysml"); + await WriteFileAsync(filePath, "package Sample {\n part def Widget;\n}\n"); + var sourceSet = new WorkspaceSourceSet(); + sourceSet.AddFile(filePath); + sourceSet.AddFolder(_tempRoot); + + // Act + sourceSet.ClearSources(); + var resolution = sourceSet.Resolve(); + + // Assert + Assert.Empty(sourceSet.Sources); + Assert.Empty(resolution.MergedFiles); + Assert.Empty(resolution.FileToSourceId); + Assert.Empty(resolution.SourceIdToFiles); + } + /// /// Validates that resolving a folder source discovers every .sysml file under it, recursively. /// diff --git a/test/DemaConsulting.SysML2Workbench.UiTests/AppShellSubsystem/MainWindowShellUiTests.cs b/test/DemaConsulting.SysML2Workbench.UiTests/AppShellSubsystem/MainWindowShellUiTests.cs index 52584a1..f61b798 100644 --- a/test/DemaConsulting.SysML2Workbench.UiTests/AppShellSubsystem/MainWindowShellUiTests.cs +++ b/test/DemaConsulting.SysML2Workbench.UiTests/AppShellSubsystem/MainWindowShellUiTests.cs @@ -88,4 +88,50 @@ public void MainWindowView_ExitMenuItem_Click_ClosesWindow() // Assert Assert.True(closed); } + + /// + /// Validates that clicking the File menu's "Close All" item reaches + /// end-to-end - not just that the menu item exists + /// (which is only proved elsewhere by discoverability) - by pre-loading one file source before showing + /// the window, raising the item's Click event, pumping the dispatcher (retried briefly since the + /// handler is async void), and asserting the shell's source set ends up empty. + /// + [AvaloniaFact] + public async Task MainWindowView_CloseAllMenuItem_Click_ClosesAllSources() + { + // Arrange + var tempRoot = Directory.CreateTempSubdirectory("sysml2workbench-ui-tests-").FullName; + try + { + var filePath = Path.Combine(tempRoot, "Sample.sysml"); + await File.WriteAllTextAsync(filePath, "package Sample {\n part def Widget;\n}\n"); + + using var shell = CreateShell(); + await shell.AddFileSourceAsync(filePath); + Assert.Single(shell.CurrentWorkspace.Sources); + + var window = new MainWindowView(shell); + window.Show(); + Dispatcher.UIThread.RunJobs(); + var closeAllMenuItem = window.FindControl("CloseAllMenuItem"); + Assert.NotNull(closeAllMenuItem); + + // Act + closeAllMenuItem.RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(MenuItem.ClickEvent)); + + // The click handler is async void; pump the dispatcher briefly until the mutation completes. + for (var i = 0; i < 20 && shell.CurrentWorkspace.Sources.Count > 0; i++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(10); + } + + // Assert + Assert.Empty(shell.CurrentWorkspace.Sources); + } + finally + { + Directory.Delete(tempRoot, recursive: true); + } + } }