From 4e955be0d105b79692e82a39b9a6380ee83f16aa Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Fri, 6 Nov 2026 16:15:25 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20test(activex-host):=20expand=20auto?= =?UTF-8?q?mated=20test=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add automated checks for layer loading, navigation, errors, resizing, clearing, and rendering backends. - Document build and manual testing in the C# host README. - Record late-bound COM error handling in testing memory. - Update the root README and testing guide. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .ai/memory/testing/testing.md | 5 + README.md | 1 + docs/TESTING.md | 20 +- samples/CSharp/ActiveXTestHost/MainForm.cs | 268 +++++++++++++++++++-- samples/CSharp/ActiveXTestHost/README.md | 56 +++++ 5 files changed, 321 insertions(+), 29 deletions(-) create mode 100644 samples/CSharp/ActiveXTestHost/README.md diff --git a/.ai/memory/testing/testing.md b/.ai/memory/testing/testing.md index 350a5df..b9b7978 100644 --- a/.ai/memory/testing/testing.md +++ b/.ai/memory/testing/testing.md @@ -19,6 +19,11 @@ ActiveX host checks require Windows and an interactive desktop; they are not currently part of CI. The rendering performance baseline is opt-in and should be run in Release when collecting meaningful timings. +The C# host calls ActiveX methods late-bound. An invalid layer ID surfaces as +`.NET ArgumentException` with the COM error text and `E_INVALIDARG` HRESULT. +The host assertion accepts this mapping as well as `COMException`; see +`samples/CSharp/ActiveXTestHost/MainForm.cs`. + ## References - [Testing guide](../../../docs/TESTING.md) diff --git a/README.md b/README.md index f198c3b..1111b1d 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ For additional configurations, dependency features, and troubleshooting, see | `tests/MapWinGIS.Core.Tests/` | Core C++ unit tests (GoogleTest) | | `tests/MapWinGIS.ActiveX.Tests/` | ActiveX and COM-hosting C++ unit tests (GoogleTest) | | `samples/Cpp/` | Buildable native C++ examples | +| `samples/CSharp/ActiveXTestHost/` | WinForms host for manual and automated ActiveX testing | | `build/` | Shared MSBuild props, vcpkg triplets and scripts | | `docs/` | Build, architecture, coding, GDAL integration, porting and testing documentation | diff --git a/docs/TESTING.md b/docs/TESTING.md index eadc95c..522a1ba 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -60,14 +60,18 @@ pwsh -File samples\CSharp\ActiveXTestHost\Run-ActiveXTestHost.ps1 -Configuration The launcher makes no COM registration changes. Automated mode creates a visible window and runs the UI message loop, checks the `CoreInitialized` automation property, loads a generated two-polygon GeoJSON dataset through the -control, resizes the host, samples rendered polygon and background pixels for -both the GDI preference and the default (OpenGL-first) path, recreates the -control, and reloads the layer. It calls the original `LoadVectorDataset` +control, resizes the host, and samples rendered polygon and background pixels +for GDI and a requested OpenGL path. The OpenGL request can use GDI when +OpenGL is unavailable or the scene is unsupported. The host recreates the +control and reloads the layer. It calls the original `LoadVectorDataset` method and reads `LayerLoaded` and the absolute `FeatureCount` on each fresh -control. It also loads two layers through `LoadVectorDatasetEx`, checks -their IDs and feature-count deltas, zooms to each, pans, and clears them. -It makes sure that navigation preserves the counts and that clearing removes -the layers from the control and the list. It writes individual outcomes to +control. It also checks failed dataset loads, loads two layers through +`LoadVectorDatasetEx`, and checks their IDs, counts, and feature-count deltas. +It exercises map fitting, per-layer fitting, zoom in and out, pan, cursor +anchored zoom, navigation after resizing, invalid layer IDs, and navigation +with no layers. It clears and reloads layers and makes sure that failed loads +and navigation do not change layer or feature counts. The host writes +individual outcomes to `bin\Debug\x64\ActiveXTestHost-results.json`. Pixels are sampled from the host window's own composed content (`PrintWindow` with `PW_RENDERFULLCONTENT`), not from the screen, so another window on top or a lost foreground race does not @@ -81,6 +85,8 @@ are not currently part of CI. The host executable accepts `--automated`, `--results `, and `--timeout-seconds <5-300>`. The PowerShell launcher supplies these options and starts the host without changing COM registration. +For build and manual-use instructions, see the +[ActiveX Test Host README](../samples/CSharp/ActiveXTestHost/README.md). ## Rendering performance baseline diff --git a/samples/CSharp/ActiveXTestHost/MainForm.cs b/samples/CSharp/ActiveXTestHost/MainForm.cs index dcdef56..c2eaa27 100644 --- a/samples/CSharp/ActiveXTestHost/MainForm.cs +++ b/samples/CSharp/ActiveXTestHost/MainForm.cs @@ -12,6 +12,9 @@ internal sealed class MainForm : Form { private const string ProgId = "MapWinGIS6.Control.6"; private const int ColorWindow = 5; + private const int HResultFail = unchecked((int)0x80004005); + private const int HResultInvalidArgument = unchecked((int)0x80070057); + private const int HResultDispatchException = unchecked((int)0x80020009); private const double ZoomStep = 1.5; private static readonly Color MapFillColor = Color.FromArgb(61, 122, 169); @@ -240,8 +243,9 @@ private async Task RunAutomatedAsync() RefreshStatus(); }); RecordCheck("Load vector layer", LoadTestLayer); + RecordCheck("Invalid dataset handling", VerifyInvalidDatasetHandling); await RecordCheckAsync("GDAL COM event sinks", VerifyGdalEventSinksAsync); - RecordCheck("CPU backend preference and status", VerifyCpuBackendSelection); + RecordCheck("Rendering backends", VerifyRenderingBackendsWhereSupported); RecordCheck("Resize", VerifyResize); await RecordCheckAsync("Repaint", VerifyRepaintAsync); @@ -254,7 +258,16 @@ private async Task RunAutomatedAsync() }); RecordCheck("Reload vector layer", LoadTestLayer); await RecordCheckAsync("Recreated repaint", VerifyRepaintAsync); - RecordCheck("Layer loading, navigation and clear", VerifyLayerOperations); + RecordCheck("Multiple datasets and stable layer IDs", VerifyMultipleDatasetLoading); + RecordCheck("Invalid layer ID handling", VerifyInvalidLayerIdHandling); + RecordCheck("Zoom to individual layers", VerifyZoomToLayers); + RecordCheck("Zoom in and zoom out", VerifyZoomInAndOut); + RecordCheck("Pan", VerifyPan); + RecordCheck("ZoomAt", VerifyZoomAt); + RecordCheck("Zoom to map", VerifyFitToMap); + RecordCheck("Navigation after resizing", VerifyNavigationAfterResize); + RecordCheck("Clear and reload layers", VerifyClearAndReload); + RecordCheck("Navigation on an empty map", VerifyEmptyMapNavigation); RecordCheck("Interactive load rendering status", VerifyInteractiveLoadStatus); RecordCheck("Final dispose", VerifyDispose); @@ -609,7 +622,32 @@ private void ClearLoadedLayers() RefreshStatus(); } - private void VerifyLayerOperations() + private void VerifyInvalidDatasetHandling() + { + dynamic automation = GetAutomation(); + int previousLayerCount = (int)automation.LayerCount; + long previousFeatureCount = (long)automation.FeatureCount; + long previousLayerId = (long)automation.GetLayerId(0); + string missingPath = Path.Combine(_testDatasetDirectory, "missing.geojson"); + + ExpectComFailure( + () => + { + _ = automation.LoadVectorDatasetEx(missingPath); + }, + HResultFail, + "loading a missing dataset"); + + if ((int)automation.LayerCount != previousLayerCount || + (long)automation.FeatureCount != previousFeatureCount || + (long)automation.GetLayerId(0) != previousLayerId) + { + throw new InvalidOperationException( + "A failed dataset load changed the existing layers or feature count."); + } + } + + private void VerifyMultipleDatasetLoading() { ClearLoadedLayers(); List warnings = []; @@ -620,36 +658,218 @@ private void VerifyLayerOperations() throw new InvalidOperationException(string.Join(Environment.NewLine, warnings)); } - dynamic automation = _host!.AutomationObject!; + dynamic automation = GetAutomation(); if (first.Id <= 0 || second.Id <= 0 || first.Id == second.Id || first.FeatureCount != 2 || second.FeatureCount != 2 || + (int)automation.LayerCount != 2 || (long)automation.FeatureCount != 4 || (long)automation.GetLayerId(0) != first.Id || (long)automation.GetLayerId(1) != second.Id || _layerList.Items.Count != 2) { throw new InvalidOperationException("The two loaded layers have inconsistent IDs or feature counts."); } + } - automation.ZoomToLayer(first.Id); - automation.ZoomToLayer(second.Id); - automation.Pan(1.0, -1.0); - RepaintHost(); - if (!(bool)automation.LayerLoaded || (int)automation.LayerCount != 2 || - (long)automation.FeatureCount != 4) + private void VerifyInvalidLayerIdHandling() + { + dynamic automation = GetAutomation(); + ExpectComFailure( + () => automation.ZoomToLayer(long.MaxValue), + HResultInvalidArgument, + "zooming to an unknown layer ID", + HResultDispatchException); + VerifyLayerTotals(2, 4); + } + + private void VerifyZoomToLayers() + { + dynamic automation = GetAutomation(); + for (int index = 0; index < _layerList.Items.Count; index++) + { + LoadedLayer layer = GetLoadedLayer(index); + automation.ZoomToLayer(layer.Id); + RepaintHost(); + } + + VerifyLayerTotals(2, 4); + } + + private void VerifyZoomInAndOut() + { + VerifyNavigation(automation => + { + automation.ZoomIn(ZoomStep); + automation.ZoomOut(ZoomStep); + }); + } + + private void VerifyPan() + { + VerifyNavigation(automation => automation.Pan(1.0, -1.0)); + } + + private void VerifyZoomAt() + { + ActiveXHostControl host = _host ?? + throw new InvalidOperationException("The ActiveX control has not been created."); + VerifyNavigation(automation => + { + automation.ZoomAt(host.Width / 2.0, host.Height / 2.0, ZoomStep); + }); + } + + private void VerifyFitToMap() + { + VerifyNavigation(automation => automation.FitToMap()); + } + + private void VerifyNavigationAfterResize() + { + if (_host is null) + { + throw new InvalidOperationException("The ActiveX control has not been created."); + } + + ActiveXHostControl host = _host; + ClientSize = new Size(900, 600); + PerformLayout(); + Application.DoEvents(); + if (host.Bounds != _surfacePanel.ClientRectangle) + { + throw new InvalidOperationException( + $"The hosted surface did not follow the resized panel. Control={host.Bounds}, panel={_surfacePanel.ClientRectangle}."); + } + + VerifyNavigation(automation => + { + automation.ZoomIn(ZoomStep); + automation.Pan(1.0, -1.0); + automation.ZoomAt(host.Width / 2.0, host.Height / 2.0, ZoomStep); + automation.FitToMap(); + }); + } + + private void VerifyClearAndReload() + { + long previousLayerId = GetLoadedLayer(_layerList.Items.Count - 1).Id; + ClearLoadedLayers(); + List warnings = []; + LoadedLayer reloadedLayer = AddVectorDataset(TestDatasetPath, warnings); + if (warnings.Count > 0) + { + throw new InvalidOperationException(string.Join(Environment.NewLine, warnings)); + } + + if (reloadedLayer.Id <= previousLayerId || + reloadedLayer.FeatureCount != 2 || + (int)GetAutomation().LayerCount != 1 || + (long)GetAutomation().FeatureCount != 2 || + _layerList.Items.Count != 1) { - throw new InvalidOperationException("Navigation changed the loaded layer or feature counts."); + throw new InvalidOperationException( + "Reloading after clearing did not create a new stable layer with the expected feature count."); } + } + private void VerifyEmptyMapNavigation() + { ClearLoadedLayers(); - if (_layerList.Items.Count != 0) + VerifyNavigation( + automation => + { + automation.FitToMap(); + automation.ZoomIn(ZoomStep); + automation.ZoomOut(ZoomStep); + automation.Pan(1.0, -1.0); + automation.ZoomAt(0.0, 0.0, ZoomStep); + }, + expectedLayerCount: 0, + expectedFeatureCount: 0); + } + + private dynamic GetAutomation() + { + if (_host?.AutomationObject is not object automationObject) + { + throw new InvalidOperationException("The ActiveX automation object is unavailable."); + } + + return automationObject; + } + + private LoadedLayer GetLoadedLayer(int index) + { + if (index < 0 || index >= _layerList.Items.Count || + _layerList.Items[index].Tag is not LoadedLayer layer) + { + throw new InvalidOperationException($"No loaded layer exists at index {index}."); + } + + return layer; + } + + private void VerifyNavigation( + Action operation, + int expectedLayerCount = 2, + long expectedFeatureCount = 4) + { + dynamic automation = GetAutomation(); + operation(automation); + RepaintHost(); + RefreshStatus(); + VerifyLayerTotals(expectedLayerCount, expectedFeatureCount); + } + + private void VerifyLayerTotals(int expectedLayerCount, long expectedFeatureCount) + { + dynamic automation = GetAutomation(); + if ((int)automation.LayerCount != expectedLayerCount || + (long)automation.FeatureCount != expectedFeatureCount || + (bool)automation.LayerLoaded != (expectedLayerCount > 0)) + { + throw new InvalidOperationException( + $"Expected {expectedLayerCount} layers and {expectedFeatureCount} features after the operation."); + } + } + + private static void ExpectComFailure( + Action operation, + int expectedHResult, + string operationName, + int? alternateHResult = null) + { + try { - throw new InvalidOperationException("The layer list was not cleared."); + operation(); } + catch (Exception exception) + { + Exception failure = exception.GetBaseException(); + if (failure is not COMException and not ArgumentException) + { + throw new InvalidOperationException( + $"The {operationName} threw {failure.GetType().Name}: {failure.Message}"); + } + + if (failure.HResult == expectedHResult || + failure.HResult == alternateHResult) + { + return; + } + + throw new InvalidOperationException( + $"The {operationName} returned HRESULT 0x{failure.HResult:x8}, expected 0x{expectedHResult:x8}" + + (alternateHResult.HasValue ? $" or 0x{alternateHResult:x8}." : ".") + + $" COM error: {failure.Message}"); + } + + throw new InvalidOperationException($"The {operationName} unexpectedly succeeded."); } private void VerifyInteractiveLoadStatus() { - dynamic automation = _host!.AutomationObject!; + ClearLoadedLayers(); + dynamic automation = GetAutomation(); string previousPreference = automation.RenderingBackendPreference; try { @@ -786,14 +1006,9 @@ private void VerifyCoreInitialized(string activation) } } - private void VerifyCpuBackendSelection() + private void VerifyRenderingBackendsWhereSupported() { - if (_host?.AutomationObject is not object automationObject) - { - throw new InvalidOperationException("The ActiveX automation object is unavailable."); - } - - dynamic automation = automationObject; + dynamic automation = GetAutomation(); string previousPreference = automation.RenderingBackendPreference; try { @@ -810,10 +1025,19 @@ private void VerifyCpuBackendSelection() } VerifyMapPixels(); + + automation.RenderingBackendPreference = "OpenGL"; + RepaintHost(); + string requestedBackend = automation.RenderingBackend; + if (requestedBackend is not "GDI" and not "OpenGL") + { + throw new InvalidOperationException( + $"The control reported an unknown rendering backend '{requestedBackend}'."); + } + VerifyMapPixels(); } finally { - // Later resize/repaint checks must exercise the default backend path. automation.RenderingBackendPreference = previousPreference; RepaintHost(); } diff --git a/samples/CSharp/ActiveXTestHost/README.md b/samples/CSharp/ActiveXTestHost/README.md new file mode 100644 index 0000000..9124321 --- /dev/null +++ b/samples/CSharp/ActiveXTestHost/README.md @@ -0,0 +1,56 @@ +# ActiveX Test Host + +ActiveX Test Host is a Windows Forms application for testing the MapWinGIS ActiveX control. It loads vector datasets and lets you test layers, map navigation, and rendering. + +## Build + +Run these commands from the repository root: + +```powershell +pwsh -File build\scripts\bootstrap-vcpkg.ps1 +msbuild MapWinGIS6.slnx -restore -p:Configuration=Debug -p:Platform=x64 -m +``` + +The solution builds the host beside the ActiveX control and its native dependencies. For x86, build the matching `Win32` platform. + +## Run + +Start the manual host from an interactive Windows desktop session: + +```powershell +pwsh -File samples\CSharp\ActiveXTestHost\Run-ActiveXTestHost.ps1 -Configuration Debug -Platform x64 +``` + +The host activates the control through its application manifest. It does not require global COM registration. + +## Load and manage layers + +Select `Open` to choose one or more vector datasets. You can also drag files onto the host. Each dataset adds its first layer to the list. + +The list shows each layer name, feature count, stable ID, and source path. Select a layer and choose `Layer` to fit that layer. Choose `Map` to fit all layers. Choose `Clear` to remove all layers. + +Available file formats depend on the GDAL drivers in the build. The host does not display raster datasets. + +## Test navigation + +Choose `Zoom +` or `Zoom -` to change the map scale. Use the arrow buttons to pan by the `Pan units` value. You can also drag with the left mouse button to pan, or use the mouse wheel to zoom at the cursor. + +Choose `Repaint` to request a redraw. Choose `Recreate` to dispose of the control and create it again. + +## Test rendering backends + +The status bar shows the active rendering backend and rendering status. Automated mode selects GDI, then requests OpenGL. The OpenGL request can fall back to GDI when the scene or system does not support OpenGL. + +## Run automated checks + +Run automated mode from an interactive Windows desktop session: + +```powershell +pwsh -File samples\CSharp\ActiveXTestHost\Run-ActiveXTestHost.ps1 -Configuration Debug -Platform x64 -Automated +``` + +The host writes individual results to `bin\Debug\x64\ActiveXTestHost-results.json`. It checks dataset loading, layer IDs and counts, map navigation, clearing and reloading, invalid input, resizing, and rendering. A watchdog stops the run after 30 seconds. Use `-TimeoutSeconds` to set a value from 5 to 300 seconds. + +## Limitations + +The host loads only the first layer from each dataset. It does not implement raster display, projections, vector editing, or tile services. Automated checks require Windows and an interactive desktop session. The host does not provide a manual rendering-backend selector.