Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .ai/memory/testing/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
20 changes: 13 additions & 7 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -81,6 +85,8 @@ are not currently part of CI.
The host executable accepts `--automated`, `--results <path>`, 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

Expand Down
268 changes: 246 additions & 22 deletions samples/CSharp/ActiveXTestHost/MainForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand All @@ -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);

Expand Down Expand Up @@ -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<string> warnings = [];
Expand All @@ -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<string> 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<dynamic> 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
{
Expand Down Expand Up @@ -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
{
Expand All @@ -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();
}
Expand Down
Loading
Loading