From dac167dfce6a89f11d123e83eb155ab3d5581dbe Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Fri, 6 Nov 2026 13:31:00 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=92=84=20feat(samples):=20improve=20A?= =?UTF-8?q?ctiveX=20test=20host=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add vector loading, layer management, navigation, and status UI. - Support multi-file loading and drag-and-drop while retaining automated checks. - Keep displayed layer metadata at the host boundary using existing ActiveX properties. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/CSharp/ActiveXTestHost/MainForm.cs | 392 ++++++++++++++++++--- 1 file changed, 346 insertions(+), 46 deletions(-) diff --git a/samples/CSharp/ActiveXTestHost/MainForm.cs b/samples/CSharp/ActiveXTestHost/MainForm.cs index 2a5c537..5a94e32 100644 --- a/samples/CSharp/ActiveXTestHost/MainForm.cs +++ b/samples/CSharp/ActiveXTestHost/MainForm.cs @@ -26,7 +26,13 @@ internal sealed class MainForm : Form private readonly HostOptions _options; private readonly Panel _surfacePanel; - private readonly ToolStripStatusLabel _statusLabel; + private readonly ListView _layerList; + private readonly NumericUpDown _panStep; + private readonly ToolStripStatusLabel _coreStatusLabel; + private readonly ToolStripStatusLabel _layerCountLabel; + private readonly ToolStripStatusLabel _featureCountLabel; + private readonly ToolStripStatusLabel _backendLabel; + private readonly ToolStripStatusLabel _renderingStatusLabel; private readonly List _results = []; private readonly object _resultsLock = new(); private readonly object _reportLock = new(); @@ -45,37 +51,119 @@ public MainForm(HostOptions options) ExitCode = options.Automated ? 1 : 0; Text = "MapWinGIS ActiveX Test Host"; ClientSize = new Size(720, 480); - MinimumSize = new Size(360, 260); + MinimumSize = new Size(620, 360); StartPosition = FormStartPosition.CenterScreen; TopMost = options.Automated; - var toolbar = new FlowLayoutPanel + var toolbar = new ToolStrip { Dock = DockStyle.Top, - Height = 42, - Padding = new Padding(6), - WrapContents = false + GripStyle = ToolStripGripStyle.Hidden, + ImageScalingSize = new Size(16, 16) }; - var recreateButton = new Button { Text = "Recreate control", AutoSize = true }; - var repaintButton = new Button { Text = "Request repaint", AutoSize = true }; - recreateButton.Click += (_, _) => RecreateManually(); - repaintButton.Click += (_, _) => RequestManualRepaint(); - toolbar.Controls.Add(recreateButton); - toolbar.Controls.Add(repaintButton); + AddToolButton(toolbar, "Open", OpenVectorDatasets, "Open one or more vector datasets."); + AddToolButton(toolbar, "Clear", ClearLayers, "Remove all loaded layers."); + toolbar.Items.Add(new ToolStripSeparator()); + AddToolButton(toolbar, "Zoom +", () => RunNavigation(automation => automation.ZoomIn(1.5))); + AddToolButton(toolbar, "Zoom -", () => RunNavigation(automation => automation.ZoomOut(1.5))); + AddToolButton( + toolbar, + "Map", + () => RunNavigation(automation => automation.FitToMap()), + "Zoom to the full map."); + AddToolButton(toolbar, "Layer", ZoomToSelectedLayer, "Zoom to the selected layer."); + toolbar.Items.Add(new ToolStripSeparator()); + AddToolButton(toolbar, "←", () => PanMap(-1, 0), "Pan left by the selected map-unit step."); + AddToolButton(toolbar, "→", () => PanMap(1, 0), "Pan right by the selected map-unit step."); + AddToolButton(toolbar, "↑", () => PanMap(0, 1), "Pan up by the selected map-unit step."); + AddToolButton(toolbar, "↓", () => PanMap(0, -1), "Pan down by the selected map-unit step."); + toolbar.Items.Add(new ToolStripLabel("Pan units:")); + _panStep = new NumericUpDown + { + DecimalPlaces = 2, + Minimum = 0.01M, + Maximum = 1_000_000_000M, + Increment = 0.5M, + Value = 1M, + Width = 72, + ThousandsSeparator = true + }; + toolbar.Items.Add(new ToolStripControlHost(_panStep) + { + AutoSize = false, + Width = 78, + ToolTipText = "Pan step in the dataset's map-coordinate units." + }); + toolbar.Items.Add(new ToolStripSeparator()); + AddToolButton(toolbar, "Recreate", RecreateManually, "Recreate the ActiveX control."); + AddToolButton(toolbar, "Repaint", RequestManualRepaint, "Request a repaint of the control."); + + var layerPanel = new Panel { Dock = DockStyle.Fill }; + _layerList = new ListView + { + Dock = DockStyle.Fill, + View = View.Details, + FullRowSelect = true, + HideSelection = false, + MultiSelect = false, + ShowItemToolTips = true + }; + _layerList.Columns.Add("Layer", 105); + _layerList.Columns.Add("Features", 65, HorizontalAlignment.Right); + _layerList.Columns.Add("ID", 75, HorizontalAlignment.Right); + _layerList.SelectedIndexChanged += (_, _) => RefreshStatus(); + layerPanel.Controls.Add(_layerList); + + var layout = new SplitContainer + { + Size = ClientSize, + Dock = DockStyle.Fill, + Orientation = Orientation.Vertical, + FixedPanel = FixedPanel.Panel1, + SplitterDistance = 270, + Panel1MinSize = 250, + Panel2MinSize = 250 + }; + layout.Panel1.Controls.Add(layerPanel); _surfacePanel = new Panel { Dock = DockStyle.Fill, BackColor = Color.Magenta }; + layout.Panel2.Controls.Add(_surfacePanel); var statusStrip = new StatusStrip(); - _statusLabel = new ToolStripStatusLabel("Starting..."); - statusStrip.Items.Add(_statusLabel); - - Controls.Add(_surfacePanel); + _coreStatusLabel = new ToolStripStatusLabel("Core: starting"); + _layerCountLabel = new ToolStripStatusLabel("Layers: 0"); + _featureCountLabel = new ToolStripStatusLabel("Features: 0"); + _backendLabel = new ToolStripStatusLabel("Backend: unavailable"); + _renderingStatusLabel = new ToolStripStatusLabel("Rendering: starting") + { + Spring = true, + TextAlign = ContentAlignment.MiddleLeft + }; + statusStrip.Items.AddRange( + [ + _coreStatusLabel, + new ToolStripStatusLabel("|"), + _layerCountLabel, + new ToolStripStatusLabel("|"), + _featureCountLabel, + new ToolStripStatusLabel("|"), + _backendLabel, + new ToolStripStatusLabel("|"), + _renderingStatusLabel + ]); + + Controls.Add(layout); Controls.Add(toolbar); Controls.Add(statusStrip); + EnableDatasetDrop(this); + EnableDatasetDrop(layout); + EnableDatasetDrop(layerPanel); + EnableDatasetDrop(_layerList); + EnableDatasetDrop(_surfacePanel); Shown += OnShown; CreateTestDataset(); } @@ -105,16 +193,12 @@ private void RecreateManually() LoadTestLayer(); _host!.Update(); Application.DoEvents(); - bool initialized = ReadCoreInitialized(); - dynamic automation = _host.AutomationObject!; - _statusLabel.Text = initialized - ? $"Active: {ProgId}; backend={automation.RenderingBackend}; {automation.RenderingStatus}" - : $"Active: {ProgId}; CoreInitialized=false"; + RefreshStatus(); } catch (Exception exception) { string message = ExplainFailure(exception); - _statusLabel.Text = message; + _renderingStatusLabel.Text = $"Rendering: {message}"; MessageBox.Show( this, message, @@ -128,16 +212,19 @@ private void RequestManualRepaint() { if (_host is null) { - _statusLabel.Text = "The control is not active."; + _renderingStatusLabel.Text = "Rendering: the control is not active."; return; } - _host.Invalidate(true); - _host.Update(); - _surfacePanel.Update(); - dynamic automation = _host.AutomationObject!; - _statusLabel.Text = - $"backend={automation.RenderingBackend}; {automation.RenderingStatus}"; + try + { + RepaintHost(); + RefreshStatus(); + } + catch (Exception exception) + { + ShowOperationError(exception); + } } private async Task RunAutomatedAsync() @@ -146,11 +233,7 @@ private async Task RunAutomatedAsync() { RecordCheck("Prerequisites", VerifyPrerequisites); RecordCheck("Activation and creation", CreateHostedControl); - RecordCheck("Automation contract", () => - { - bool initialized = ReadCoreInitialized(); - _statusLabel.Text = $"Active: {ProgId}; CoreInitialized={initialized}"; - }); + RecordCheck("Automation contract", RefreshStatus); RecordCheck("Load vector layer", LoadTestLayer); await RecordCheckAsync("GDAL COM event sinks", VerifyGdalEventSinksAsync); RecordCheck("CPU backend preference and status", VerifyCpuBackendSelection); @@ -159,12 +242,7 @@ private async Task RunAutomatedAsync() await RecordCheckAsync("Repaint", VerifyRepaintAsync); RecordCheck("Dispose", VerifyDispose); RecordCheck("Recreation", CreateHostedControl); - RecordCheck("Recreated automation contract", () => - { - bool initialized = ReadCoreInitialized(); - _statusLabel.Text = - $"Recreated: {ProgId}; CoreInitialized={initialized}"; - }); + RecordCheck("Recreated automation contract", RefreshStatus); RecordCheck("Reload vector layer", LoadTestLayer); await RecordCheckAsync("Recreated repaint", VerifyRepaintAsync); RecordCheck("Final dispose", VerifyDispose); @@ -177,12 +255,12 @@ private async Task RunAutomatedAsync() } ExitCode = 0; - _statusLabel.Text = "All automated hosting checks passed."; + _renderingStatusLabel.Text = "Automated hosting checks passed."; } catch (Exception exception) { ExitCode = 1; - _statusLabel.Text = ExplainFailure(exception); + _renderingStatusLabel.Text = $"Rendering: {ExplainFailure(exception)}"; DisposeHostedControl(); } finally @@ -318,8 +396,10 @@ private static void VerifyFileArchitecture(string path) private void CreateHostedControl() { DisposeHostedControl(); + _layerList.Items.Clear(); var host = new ActiveXHostControl { Dock = DockStyle.Fill }; + EnableDatasetDrop(host); _surfacePanel.Controls.Add(host); _host = host; host.CreateControl(); @@ -347,6 +427,18 @@ private bool ReadCoreInitialized() } private void LoadTestLayer() + { + LoadedLayer layer = AddVectorDataset(TestDatasetPath); + if (layer.FeatureCount != 2) + { + throw new InvalidOperationException( + "The control did not load the two-feature polygon test layer."); + } + + VerifyCoreInitialized("vector dataset load"); + } + + private LoadedLayer AddVectorDataset(string path) { if (_host?.AutomationObject is not object automationObject) { @@ -354,14 +446,219 @@ private void LoadTestLayer() } dynamic automation = automationObject; - automation.LoadVectorDataset(TestDatasetPath); - if (!(bool)automation.LayerLoaded || (long)automation.FeatureCount != 2) + int previousLayerCount = (int)automation.LayerCount; + long previousFeatureCount = (long)automation.FeatureCount; + long layerId = (long)automation.LoadVectorDatasetEx(path); + int currentLayerCount = (int)automation.LayerCount; + long currentFeatureCount = (long)automation.FeatureCount; + + if (layerId <= 0 || + currentLayerCount != previousLayerCount + 1 || + currentFeatureCount < previousFeatureCount) { throw new InvalidOperationException( - "The control did not load the two-feature polygon test layer."); + $"The control returned inconsistent layer information after loading '{path}'."); } - VerifyCoreInitialized("vector dataset load"); + long indexedLayerId = (long)automation.GetLayerId(previousLayerCount); + if (indexedLayerId != layerId) + { + throw new InvalidOperationException( + $"The control returned layer ID {layerId}, but index {previousLayerCount} reports ID {indexedLayerId}."); + } + + var layer = new LoadedLayer( + layerId, + Path.GetFileNameWithoutExtension(path), + currentFeatureCount - previousFeatureCount, + path); + var item = new ListViewItem(layer.Name) + { + Tag = layer, + ToolTipText = layer.Path + }; + item.SubItems.Add(layer.FeatureCount.ToString()); + item.SubItems.Add(layer.Id.ToString()); + _layerList.Items.Add(item); + RefreshStatus(); + return layer; + } + + private void OpenVectorDatasets() + { + using var dialog = new OpenFileDialog + { + Title = "Open vector datasets", + Filter = + "Vector datasets (*.geojson;*.json;*.shp;*.gpkg;*.kml;*.gml;*.tab;*.mif;*.csv)|" + + "*.geojson;*.json;*.shp;*.gpkg;*.kml;*.gml;*.tab;*.mif;*.csv|" + + "All files (*.*)|*.*", + Multiselect = true, + CheckFileExists = true + }; + if (dialog.ShowDialog(this) == DialogResult.OK) + { + LoadDatasetsInteractively(dialog.FileNames); + } + } + + private void LoadDatasetsInteractively(IEnumerable paths) + { + List failures = []; + foreach (string path in paths) + { + try + { + AddVectorDataset(path); + } + catch (Exception exception) + { + failures.Add($"{path}: {ExplainFailure(exception)}"); + } + } + + RefreshStatus(); + if (failures.Count > 0) + { + MessageBox.Show( + this, + string.Join(Environment.NewLine, failures), + "Some vector datasets could not be loaded", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } + } + + private void ClearLayers() + { + try + { + if (_host?.AutomationObject is not object automationObject) + { + throw new InvalidOperationException("The ActiveX automation object is unavailable."); + } + + dynamic automation = automationObject; + automation.ClearLayers(); + if ((int)automation.LayerCount != 0 || (long)automation.FeatureCount != 0) + { + throw new InvalidOperationException( + "The control still reports loaded layers after clearing them."); + } + + _layerList.Items.Clear(); + RepaintHost(); + RefreshStatus(); + } + catch (Exception exception) + { + ShowOperationError(exception); + } + } + + private void ZoomToSelectedLayer() + { + if (_layerList.SelectedItems.Count == 0 || + _layerList.SelectedItems[0].Tag is not LoadedLayer layer) + { + _renderingStatusLabel.Text = "Rendering: select a layer first."; + return; + } + + RunNavigation(automation => automation.ZoomToLayer(layer.Id)); + } + + private void PanMap(double directionX, double directionY) + { + double step = (double)_panStep.Value; + RunNavigation(automation => automation.Pan(directionX * step, directionY * step)); + } + + private void RunNavigation(Action operation) + { + try + { + if (_host?.AutomationObject is not object automationObject) + { + throw new InvalidOperationException("The ActiveX automation object is unavailable."); + } + + dynamic automation = automationObject; + operation(automation); + RepaintHost(); + RefreshStatus(); + } + catch (Exception exception) + { + ShowOperationError(exception); + } + } + + private void RefreshStatus() + { + if (_host?.AutomationObject is not object automationObject) + { + _coreStatusLabel.Text = "Core: inactive"; + _layerCountLabel.Text = $"Layers: {_layerList.Items.Count}"; + _featureCountLabel.Text = "Features: unavailable"; + _backendLabel.Text = "Backend: unavailable"; + _renderingStatusLabel.Text = "Rendering: control is not active."; + return; + } + + dynamic automation = automationObject; + _coreStatusLabel.Text = (bool)automation.CoreInitialized + ? "Core: initialized" + : "Core: not initialized"; + _layerCountLabel.Text = $"Layers: {(int)automation.LayerCount}"; + _featureCountLabel.Text = $"Features: {(long)automation.FeatureCount}"; + _backendLabel.Text = $"Backend: {(string)automation.RenderingBackend}"; + _renderingStatusLabel.Text = $"Rendering: {(string)automation.RenderingStatus}"; + } + + private void ShowOperationError(Exception exception) + { + string message = ExplainFailure(exception); + _renderingStatusLabel.Text = $"Rendering: {message}"; + MessageBox.Show( + this, + message, + "MapWinGIS ActiveX Test Host", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + } + + private void AddToolButton( + ToolStrip toolbar, + string text, + Action action, + string? toolTip = null) + { + var button = new ToolStripButton(text) + { + ToolTipText = toolTip ?? text, + DisplayStyle = ToolStripItemDisplayStyle.Text + }; + button.Click += (_, _) => action(); + toolbar.Items.Add(button); + } + + private void EnableDatasetDrop(Control control) + { + control.AllowDrop = true; + control.DragEnter += (_, eventArgs) => + { + eventArgs.Effect = eventArgs.Data?.GetDataPresent(DataFormats.FileDrop) == true + ? DragDropEffects.Copy + : DragDropEffects.None; + }; + control.DragDrop += (_, eventArgs) => + { + if (eventArgs.Data?.GetData(DataFormats.FileDrop) is string[] paths) + { + LoadDatasetsInteractively(paths); + } + }; } private void VerifyCoreInitialized(string activation) @@ -600,6 +897,7 @@ private void DisposeHostedControl() _surfacePanel.Controls.Remove(host); host.Dispose(); Application.DoEvents(); + RefreshStatus(); } private void RecordCheck(string name, Action action) @@ -745,6 +1043,8 @@ protected override void Dispose(bool disposing) private static uint ColorToColorRef(Color color) => (uint)(color.R | (color.G << 8) | (color.B << 16)); + private sealed record LoadedLayer(long Id, string Name, long FeatureCount, string Path); + private const uint PrintWindowRenderFullContent = 0x00000002; [StructLayout(LayoutKind.Sequential)] From c005a7ba301f5e0baeda712134f1a3bb1603d24c Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Fri, 6 Nov 2026 14:11:33 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20fix(samples):=20resolve=20Ac?= =?UTF-8?q?tiveX=20host=20review=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Correct operation errors, deferred drops, and loaded-layer bookkeeping. - Restore automation coverage and check loading, navigation, clearing, and rendering status. - Document manual controls and first-layer loading limits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/BUILDING.md | 17 +- docs/TESTING.md | 20 +- samples/CSharp/ActiveXTestHost/MainForm.cs | 235 ++++++++++++++++----- 3 files changed, 214 insertions(+), 58 deletions(-) diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 9f38535..c3d33bc 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -61,6 +61,19 @@ host beside the native files; the manifest activates that OCX for the host process only. The launcher does not read, add, overwrite, or remove COM registration. +Manual mode starts with a generated two-polygon layer. Use `Open` to select +vector datasets, or drag files onto the host. Each load adds an entry to the +layer list with its name, feature count, stable ID, and file path tooltip. +Only the first layer of each dataset is loaded. Available formats depend on +the GDAL drivers in the build. + +Use `Clear` to remove all layers. `Zoom +` and `Zoom -` change the zoom, +`Map` fits all layers, and `Layer` fits the selected layer. The arrow +buttons pan by the `Pan units` value in dataset coordinates. +The status strip shows core initialization, total layer and feature counts, +the rendering backend, and rendering status or operation errors. +`Recreate` restores the startup layer, and `Repaint` requests a redraw. + ```powershell # Visible manual host. Close the window to release the control. pwsh -File samples\CSharp\ActiveXTestHost\Run-ActiveXTestHost.ps1 -Configuration Debug -Platform x64 @@ -73,7 +86,9 @@ For Win32, use `-Platform Win32` after building the matching native binaries. Automated checks need Windows and an interactive desktop session. The host creates a temporary two-polygon GeoJSON dataset, loads it through the control, and verifies visible polygon fills and the background gap after repaint and -control recreation. It can activate the OCX without a registration. The +control recreation. It also loads two layers, checks their IDs and feature +counts, zooms to each layer, pans, and clears the layers. +It can activate the OCX without a registration. The separate native activation script above retains its own temporary registration-based test. diff --git a/docs/TESTING.md b/docs/TESTING.md index 99686c8..0d81f9f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -37,6 +37,19 @@ manifest. It has a visible manual mode and an `--automated` mode that uses the same host, resize, repaint, and dispose paths, with no COM registry changes. The automated checks also run a GDAL raster conversion and check progress and completion events, including operation IDs and progress arguments. + +In manual mode, use `Open` or drag files onto the host to add vector +datasets. The layer list shows names, feature counts, stable IDs, and file +path tooltips. Each dataset contributes only its first layer. +The file formats available depend on the GDAL build. + +Use `Clear` to remove all layers, `Zoom +` and `Zoom -` to change zoom, +`Map` to fit all layers, and `Layer` to fit the selected layer. +The arrow buttons pan by the `Pan units` value in dataset coordinates. +The status strip shows core initialization, layer and feature totals, +the rendering backend, and rendering status or operation errors. +`Recreate` restores the startup layer, and `Repaint` requests a redraw. + From an interactive Windows desktop session, run: ```powershell @@ -48,7 +61,12 @@ 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, reloads the layer, and writes individual outcomes to +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 `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 diff --git a/samples/CSharp/ActiveXTestHost/MainForm.cs b/samples/CSharp/ActiveXTestHost/MainForm.cs index 5a94e32..dcdef56 100644 --- a/samples/CSharp/ActiveXTestHost/MainForm.cs +++ b/samples/CSharp/ActiveXTestHost/MainForm.cs @@ -12,6 +12,7 @@ internal sealed class MainForm : Form { private const string ProgId = "MapWinGIS6.Control.6"; private const int ColorWindow = 5; + private const double ZoomStep = 1.5; private static readonly Color MapFillColor = Color.FromArgb(61, 122, 169); #if MWG_X64 @@ -61,11 +62,12 @@ public MainForm(HostOptions options) GripStyle = ToolStripGripStyle.Hidden, ImageScalingSize = new Size(16, 16) }; - AddToolButton(toolbar, "Open", OpenVectorDatasets, "Open one or more vector datasets."); + AddToolButton(toolbar, "Open", OpenVectorDatasets, + "Open vector datasets. Only the first layer of each dataset is loaded; formats depend on the GDAL build."); AddToolButton(toolbar, "Clear", ClearLayers, "Remove all loaded layers."); toolbar.Items.Add(new ToolStripSeparator()); - AddToolButton(toolbar, "Zoom +", () => RunNavigation(automation => automation.ZoomIn(1.5))); - AddToolButton(toolbar, "Zoom -", () => RunNavigation(automation => automation.ZoomOut(1.5))); + AddToolButton(toolbar, "Zoom +", () => RunNavigation(automation => automation.ZoomIn(ZoomStep))); + AddToolButton(toolbar, "Zoom -", () => RunNavigation(automation => automation.ZoomOut(ZoomStep))); AddToolButton( toolbar, "Map", @@ -111,7 +113,6 @@ public MainForm(HostOptions options) _layerList.Columns.Add("Layer", 105); _layerList.Columns.Add("Features", 65, HorizontalAlignment.Right); _layerList.Columns.Add("ID", 75, HorizontalAlignment.Right); - _layerList.SelectedIndexChanged += (_, _) => RefreshStatus(); layerPanel.Controls.Add(_layerList); var layout = new SplitContainer @@ -233,7 +234,11 @@ private async Task RunAutomatedAsync() { RecordCheck("Prerequisites", VerifyPrerequisites); RecordCheck("Activation and creation", CreateHostedControl); - RecordCheck("Automation contract", RefreshStatus); + RecordCheck("Automation contract", () => + { + _ = ReadCoreInitialized(); + RefreshStatus(); + }); RecordCheck("Load vector layer", LoadTestLayer); await RecordCheckAsync("GDAL COM event sinks", VerifyGdalEventSinksAsync); RecordCheck("CPU backend preference and status", VerifyCpuBackendSelection); @@ -242,9 +247,15 @@ private async Task RunAutomatedAsync() await RecordCheckAsync("Repaint", VerifyRepaintAsync); RecordCheck("Dispose", VerifyDispose); RecordCheck("Recreation", CreateHostedControl); - RecordCheck("Recreated automation contract", RefreshStatus); + RecordCheck("Recreated automation contract", () => + { + _ = ReadCoreInitialized(); + RefreshStatus(); + }); RecordCheck("Reload vector layer", LoadTestLayer); await RecordCheckAsync("Recreated repaint", VerifyRepaintAsync); + RecordCheck("Layer loading, navigation and clear", VerifyLayerOperations); + RecordCheck("Interactive load rendering status", VerifyInteractiveLoadStatus); RecordCheck("Final dispose", VerifyDispose); if (_reportWriteError is not null) @@ -260,8 +271,8 @@ private async Task RunAutomatedAsync() catch (Exception exception) { ExitCode = 1; - _renderingStatusLabel.Text = $"Rendering: {ExplainFailure(exception)}"; DisposeHostedControl(); + _renderingStatusLabel.Text = $"Rendering: {ExplainFailure(exception)}"; } finally { @@ -428,17 +439,26 @@ private bool ReadCoreInitialized() private void LoadTestLayer() { - LoadedLayer layer = AddVectorDataset(TestDatasetPath); - if (layer.FeatureCount != 2) + if (_host?.AutomationObject is not object automationObject) + { + throw new InvalidOperationException("The ActiveX automation object is unavailable."); + } + + dynamic automation = automationObject; + automation.LoadVectorDataset(TestDatasetPath); + if (!(bool)automation.LayerLoaded || (long)automation.FeatureCount != 2) { throw new InvalidOperationException( "The control did not load the two-feature polygon test layer."); } + AddLayerListItem(new LoadedLayer( + (long)automation.GetLayerId(0), "sample", 2, TestDatasetPath)); VerifyCoreInitialized("vector dataset load"); + RefreshStatus(); } - private LoadedLayer AddVectorDataset(string path) + private LoadedLayer AddVectorDataset(string path, List warnings) { if (_host?.AutomationObject is not object automationObject) { @@ -449,39 +469,54 @@ private LoadedLayer AddVectorDataset(string path) int previousLayerCount = (int)automation.LayerCount; long previousFeatureCount = (long)automation.FeatureCount; long layerId = (long)automation.LoadVectorDatasetEx(path); - int currentLayerCount = (int)automation.LayerCount; - long currentFeatureCount = (long)automation.FeatureCount; + var layer = new LoadedLayer( + layerId, Path.GetFileNameWithoutExtension(path), null, path); + ListViewItem item = AddLayerListItem(layer); - if (layerId <= 0 || - currentLayerCount != previousLayerCount + 1 || - currentFeatureCount < previousFeatureCount) + // The load is committed. Metadata failures must not hide the layer or report a failed load. + try { - throw new InvalidOperationException( - $"The control returned inconsistent layer information after loading '{path}'."); - } + int currentLayerCount = (int)automation.LayerCount; + long currentFeatureCount = (long)automation.FeatureCount; + if (layerId <= 0 || + currentLayerCount != previousLayerCount + 1 || + currentFeatureCount < previousFeatureCount) + { + throw new InvalidOperationException( + "The control returned inconsistent layer information."); + } - long indexedLayerId = (long)automation.GetLayerId(previousLayerCount); - if (indexedLayerId != layerId) + // This aggregate delta is valid only for one load at a time on the UI thread. + long featureCount = currentFeatureCount - previousFeatureCount; + layer = layer with { FeatureCount = featureCount }; + item.Tag = layer; + item.SubItems[1].Text = featureCount.ToString(); + long indexedLayerId = (long)automation.GetLayerId(previousLayerCount); + if (indexedLayerId != layerId) + { + throw new InvalidOperationException( + $"The control returned layer ID {layerId}, but index {previousLayerCount} reports ID {indexedLayerId}."); + } + } + catch (Exception exception) { - throw new InvalidOperationException( - $"The control returned layer ID {layerId}, but index {previousLayerCount} reports ID {indexedLayerId}."); + warnings.Add($"{path}: loaded as layer {layerId}, but metadata could not be verified. {ExplainOperationFailure(exception)}"); } - var layer = new LoadedLayer( - layerId, - Path.GetFileNameWithoutExtension(path), - currentFeatureCount - previousFeatureCount, - path); + return layer; + } + + private ListViewItem AddLayerListItem(LoadedLayer layer) + { var item = new ListViewItem(layer.Name) { Tag = layer, ToolTipText = layer.Path }; - item.SubItems.Add(layer.FeatureCount.ToString()); + item.SubItems.Add(layer.FeatureCount?.ToString() ?? "unavailable"); item.SubItems.Add(layer.Id.ToString()); _layerList.Items.Add(item); - RefreshStatus(); - return layer; + return item; } private void OpenVectorDatasets() @@ -490,8 +525,8 @@ private void OpenVectorDatasets() { Title = "Open vector datasets", Filter = - "Vector datasets (*.geojson;*.json;*.shp;*.gpkg;*.kml;*.gml;*.tab;*.mif;*.csv)|" + - "*.geojson;*.json;*.shp;*.gpkg;*.kml;*.gml;*.tab;*.mif;*.csv|" + + "Vector datasets (*.geojson;*.shp;*.gpkg;*.kml;*.gml;*.tab;*.mif)|" + + "*.geojson;*.shp;*.gpkg;*.kml;*.gml;*.tab;*.mif|" + "All files (*.*)|*.*", Multiselect = true, CheckFileExists = true @@ -505,25 +540,37 @@ private void OpenVectorDatasets() private void LoadDatasetsInteractively(IEnumerable paths) { List failures = []; + List warnings = []; foreach (string path in paths) { try { - AddVectorDataset(path); + AddVectorDataset(path, warnings); } catch (Exception exception) { - failures.Add($"{path}: {ExplainFailure(exception)}"); + failures.Add($"{path}: {ExplainOperationFailure(exception)}"); } } - RefreshStatus(); - if (failures.Count > 0) + try + { + RepaintHost(); + RefreshStatus(); + } + catch (Exception exception) { + warnings.Add($"Could not refresh the control status: {ExplainOperationFailure(exception)}"); + } + + if (failures.Count > 0 || warnings.Count > 0) + { + string message = string.Join(Environment.NewLine, failures.Concat(warnings)); + _renderingStatusLabel.Text = $"Rendering: {message}"; MessageBox.Show( this, - string.Join(Environment.NewLine, failures), - "Some vector datasets could not be loaded", + message, + "Vector dataset load results", MessageBoxButtons.OK, MessageBoxIcon.Warning); } @@ -533,26 +580,93 @@ private void ClearLayers() { try { - if (_host?.AutomationObject is not object automationObject) - { - throw new InvalidOperationException("The ActiveX automation object is unavailable."); - } + ClearLoadedLayers(); + } + catch (Exception exception) + { + ShowOperationError(exception); + } + } - dynamic automation = automationObject; - automation.ClearLayers(); - if ((int)automation.LayerCount != 0 || (long)automation.FeatureCount != 0) + private void ClearLoadedLayers() + { + if (_host?.AutomationObject is not object automationObject) + { + throw new InvalidOperationException("The ActiveX automation object is unavailable."); + } + + dynamic automation = automationObject; + automation.ClearLayers(); + _layerList.Items.Clear(); + if ((int)automation.LayerCount != 0 || (long)automation.FeatureCount != 0 || + (bool)automation.LayerLoaded) + { + throw new InvalidOperationException( + "The control still reports loaded layers after clearing them."); + } + + RepaintHost(); + RefreshStatus(); + } + + private void VerifyLayerOperations() + { + ClearLoadedLayers(); + List warnings = []; + LoadedLayer first = AddVectorDataset(TestDatasetPath, warnings); + LoadedLayer second = AddVectorDataset(TestDatasetPath, warnings); + if (warnings.Count > 0) + { + throw new InvalidOperationException(string.Join(Environment.NewLine, warnings)); + } + + dynamic automation = _host!.AutomationObject!; + if (first.Id <= 0 || second.Id <= 0 || first.Id == second.Id || + first.FeatureCount != 2 || second.FeatureCount != 2 || + (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) + { + throw new InvalidOperationException("Navigation changed the loaded layer or feature counts."); + } + + ClearLoadedLayers(); + if (_layerList.Items.Count != 0) + { + throw new InvalidOperationException("The layer list was not cleared."); + } + } + + private void VerifyInteractiveLoadStatus() + { + dynamic automation = _host!.AutomationObject!; + string previousPreference = automation.RenderingBackendPreference; + try + { + automation.RenderingBackendPreference = "GDI"; + LoadDatasetsInteractively([TestDatasetPath]); + if ((int)automation.LayerCount != 1 || _layerList.Items.Count != 1 || + _backendLabel.Text != "Backend: GDI" || + _renderingStatusLabel.Text != $"Rendering: {(string)automation.RenderingStatus}") { throw new InvalidOperationException( - "The control still reports loaded layers after clearing them."); + "Interactive loading did not report the current frame's rendering status."); } - - _layerList.Items.Clear(); - RepaintHost(); - RefreshStatus(); } - catch (Exception exception) + finally { - ShowOperationError(exception); + automation.RenderingBackendPreference = previousPreference; + ClearLoadedLayers(); } } @@ -618,7 +732,7 @@ private void RefreshStatus() private void ShowOperationError(Exception exception) { - string message = ExplainFailure(exception); + string message = ExplainOperationFailure(exception); _renderingStatusLabel.Text = $"Rendering: {message}"; MessageBox.Show( this, @@ -656,7 +770,9 @@ private void EnableDatasetDrop(Control control) { if (eventArgs.Data?.GetData(DataFormats.FileDrop) is string[] paths) { - LoadDatasetsInteractively(paths); + string[] droppedPaths = paths.ToArray(); + // Return from the OLE drop before loading or showing a modal dialog. + BeginInvoke(new Action(() => LoadDatasetsInteractively(droppedPaths))); } }; } @@ -743,7 +859,6 @@ private void RepaintHost() { _host?.Invalidate(true); _host?.Update(); - Application.DoEvents(); } private void VerifyResize() @@ -1004,6 +1119,14 @@ private static string ExplainFailure(Exception exception) return exception.Message; } + private static string ExplainOperationFailure(Exception exception) + { + Exception cause = exception.GetBaseException(); + return cause is COMException + ? $"HRESULT 0x{cause.HResult:x8}: {cause.Message}" + : cause.Message; + } + private async Task EnforceTimeoutAsync() { try @@ -1043,7 +1166,7 @@ protected override void Dispose(bool disposing) private static uint ColorToColorRef(Color color) => (uint)(color.R | (color.G << 8) | (color.B << 16)); - private sealed record LoadedLayer(long Id, string Name, long FeatureCount, string Path); + private sealed record LoadedLayer(long Id, string Name, long? FeatureCount, string Path); private const uint PrintWindowRenderFullContent = 0x00000002;