diff --git a/Directory.Build.props b/Directory.Build.props index 05d3d47be..9ef8b5cd5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,7 +6,7 @@ enable enable true - 4.5.2 + 4.5.2.5 Debug;Release;SourceGen Highlighting AnyCPU true diff --git a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs index 418b8b3fc..181c58ef4 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs @@ -106,7 +106,7 @@ public Polygon( public override GeometryType Type => GeometryType.Polygon; #endregion - + /// /// Returns a deep clone of the geometry. /// diff --git a/src/dymaptic.GeoBlazor.Core/Components/SnappingOptions.gb.cs b/src/dymaptic.GeoBlazor.Core/Components/SnappingOptions.gb.cs index b892f6a4a..fb600587d 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/SnappingOptions.gb.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/SnappingOptions.gb.cs @@ -390,6 +390,315 @@ public SnappingOptions( return SelfEnabled; } +#endregion + +#region Property Setters + + /// + /// Asynchronously set the value of the AttributeRulesEnabled property after render. + /// + /// + /// The value to set. + /// + public async Task SetAttributeRulesEnabled(bool? value) + { +#pragma warning disable BL0005 + AttributeRulesEnabled = value; +#pragma warning restore BL0005 + ModifiedParameters[nameof(AttributeRulesEnabled)] = value; + + if (CoreJsModule is null) + { + return; + } + + try + { + JsComponentReference ??= await CoreJsModule.InvokeAsync( + "getJsComponent", CancellationTokenSource.Token, Id); + } + catch (JSException) + { + // this is expected if the component is not yet built + } + + if (JsComponentReference is null) + { + return; + } + + await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, + JsComponentReference, "attributeRulesEnabled", value); + } + + /// + /// Asynchronously set the value of the Distance property after render. + /// + /// + /// The value to set. + /// + public async Task SetDistance(double? value) + { +#pragma warning disable BL0005 + Distance = value; +#pragma warning restore BL0005 + ModifiedParameters[nameof(Distance)] = value; + + if (CoreJsModule is null) + { + return; + } + + try + { + JsComponentReference ??= await CoreJsModule.InvokeAsync( + "getJsComponent", CancellationTokenSource.Token, Id); + } + catch (JSException) + { + // this is expected if the component is not yet built + } + + if (JsComponentReference is null) + { + return; + } + + await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, + JsComponentReference, "distance", value); + } + + /// + /// Asynchronously set the value of the Enabled property after render. + /// + /// + /// The value to set. + /// + public async Task SetEnabled(bool? value) + { +#pragma warning disable BL0005 + Enabled = value; +#pragma warning restore BL0005 + ModifiedParameters[nameof(Enabled)] = value; + + if (CoreJsModule is null) + { + return; + } + + try + { + JsComponentReference ??= await CoreJsModule.InvokeAsync( + "getJsComponent", CancellationTokenSource.Token, Id); + } + catch (JSException) + { + // this is expected if the component is not yet built + } + + if (JsComponentReference is null) + { + return; + } + + await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, + JsComponentReference, "enabled", value); + } + + /// + /// Asynchronously set the value of the FeatureEnabled property after render. + /// + /// + /// The value to set. + /// + public async Task SetFeatureEnabled(bool? value) + { +#pragma warning disable BL0005 + FeatureEnabled = value; +#pragma warning restore BL0005 + ModifiedParameters[nameof(FeatureEnabled)] = value; + + if (CoreJsModule is null) + { + return; + } + + try + { + JsComponentReference ??= await CoreJsModule.InvokeAsync( + "getJsComponent", CancellationTokenSource.Token, Id); + } + catch (JSException) + { + // this is expected if the component is not yet built + } + + if (JsComponentReference is null) + { + return; + } + + await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, + JsComponentReference, "featureEnabled", value); + } + + /// + /// Asynchronously set the value of the FeatureSources property after render. + /// + /// + /// The value to set. + /// + public async Task SetFeatureSources(IReadOnlyList? value) + { + if (value is not null) + { + foreach (FeatureSnappingLayerSource item in value) + { + item.UpdateGeoBlazorReferences(CoreJsModule!, ProJsModule, View, this, Layer); + } + } + +#pragma warning disable BL0005 + FeatureSources = value; +#pragma warning restore BL0005 + ModifiedParameters[nameof(FeatureSources)] = value; + + if (CoreJsModule is null) + { + return; + } + + try + { + JsComponentReference ??= await CoreJsModule.InvokeAsync( + "getJsComponent", CancellationTokenSource.Token, Id); + } + catch (JSException) + { + // this is expected if the component is not yet built + } + + if (JsComponentReference is null) + { + return; + } + + await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, + JsComponentReference, "featureSources", value); + } + + /// + /// Asynchronously set the value of the GridEnabled property after render. + /// + /// + /// The value to set. + /// + public async Task SetGridEnabled(bool? value) + { +#pragma warning disable BL0005 + GridEnabled = value; +#pragma warning restore BL0005 + ModifiedParameters[nameof(GridEnabled)] = value; + + if (CoreJsModule is null) + { + return; + } + + try + { + JsComponentReference ??= await CoreJsModule.InvokeAsync( + "getJsComponent", CancellationTokenSource.Token, Id); + } + catch (JSException) + { + // this is expected if the component is not yet built + } + + if (JsComponentReference is null) + { + return; + } + + await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, + JsComponentReference, "gridEnabled", value); + } + + /// + /// Asynchronously set the value of the SelfEnabled property after render. + /// + /// + /// The value to set. + /// + public async Task SetSelfEnabled(bool? value) + { +#pragma warning disable BL0005 + SelfEnabled = value; +#pragma warning restore BL0005 + ModifiedParameters[nameof(SelfEnabled)] = value; + + if (CoreJsModule is null) + { + return; + } + + try + { + JsComponentReference ??= await CoreJsModule.InvokeAsync( + "getJsComponent", CancellationTokenSource.Token, Id); + } + catch (JSException) + { + // this is expected if the component is not yet built + } + + if (JsComponentReference is null) + { + return; + } + + await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, + JsComponentReference, "selfEnabled", value); + } + +#endregion + +#region Add to Collection Methods + + /// + /// Asynchronously adds elements to the FeatureSources property. + /// + /// + /// The elements to add. + /// + public async Task AddToFeatureSources(params FeatureSnappingLayerSource[] values) + { + FeatureSnappingLayerSource[] join = FeatureSources is null + ? values + : [..FeatureSources, ..values]; + await SetFeatureSources(join); + } + +#endregion + +#region Remove From Collection Methods + + + /// + /// Asynchronously remove an element from the FeatureSources property. + /// + /// + /// The elements to remove. + /// + public async Task RemoveFromFeatureSources(params FeatureSnappingLayerSource[] values) + { + if (FeatureSources is null) + { + return; + } + await SetFeatureSources(FeatureSources.Except(values).ToArray()); + } + #endregion diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts b/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts index 0bb27900e..bd6af374f 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts @@ -10,20 +10,50 @@ export function buildDotNetGeometry(geometry: any): any { if (!hasValue(geometry)) { return null; } + let dotNetGeometry: any; switch (geometry?.type) { case "point": return buildDotNetPoint(geometry); case "polyline": - return buildDotNetPolyline(geometry); + dotNetGeometry = buildDotNetPolyline(geometry); + break; case "polygon": - return buildDotNetPolygon(geometry); + dotNetGeometry = buildDotNetPolygon(geometry); + break; case "extent": return buildDotNetExtent(geometry); case "multipoint": - return buildDotNetMultipoint(geometry); + dotNetGeometry = buildDotNetMultipoint(geometry); + break; case "mesh": return buildDotNetMesh(geometry); + default: + return undefined; } + + // Geometries returned from the operator API (e.g. unionOperator) do not always expose + // a cached `extent`, which previously left the .NET Geometry.Extent null (breaking + // e.g. view.GoTo(extent)). Use ArcGIS to compute a missing extent. + if (hasValue(dotNetGeometry) && !hasValue(dotNetGeometry.extent)) { + let jsExtent = getArcGisExtent(geometry, dotNetGeometry); + if (hasValue(jsExtent)) { + dotNetGeometry.extent = buildDotNetExtent(jsExtent); + } + } + + return dotNetGeometry; +} + +// Returns the ArcGIS-computed extent for a geometry. Prefers the geometry's own `.extent` +// (computed lazily by the SDK, including Z/M bounds); when an operator result doesn't +// expose one, rebuilds a typed ArcGIS geometry from the same coordinates so the SDK +// recomputes it, rather than calculating the bounding box by hand. +function getArcGisExtent(geometry: any, dotNetGeometry: any): any { + if (hasValue(geometry?.extent)) { + return geometry.extent; + } + let rebuilt = buildJsGeometry(dotNetGeometry); + return rebuilt?.extent ?? null; } export function buildJsGeometry(geometry: any): any { diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/GeometryEngineTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/GeometryEngineTests.cs index 471c1c90c..175a47f47 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/GeometryEngineTests.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/GeometryEngineTests.cs @@ -1399,6 +1399,20 @@ public async Task TestUnion() Assert.IsNotNull(union); Assert.AreNotEqual(polygon1, union); Assert.AreNotEqual(polygon2, union); + + // Regression (reported against 4.5.1): the unioned geometry must come back with a populated + // Extent so callers can use it for map.GoTo(extent). Both the inline Extent and GetExtent() + // must work. polygon1 spans x:[0,10] and polygon2 spans x:[10,20]; together y:[0,10]. + Assert.IsNotNull(union.Extent, + "Union result should have a populated Extent."); + Assert.AreEqual(0.0, union.Extent!.Xmin); + Assert.AreEqual(20.0, union.Extent!.Xmax); + Assert.AreEqual(0.0, union.Extent!.Ymin); + Assert.AreEqual(10.0, union.Extent!.Ymax); + + Extent? fetchedExtent = await union.GetExtent(); + Assert.IsNotNull(fetchedExtent, + "GetExtent() should return the unioned geometry's extent."); } [TestMethod] @@ -1935,6 +1949,15 @@ public async Task TestUnionWithParamsArray() Assert.AreNotEqual(polygon1, union); Assert.AreNotEqual(polygon2, union); Assert.AreNotEqual(polygon3, union); + + // Regression (reported against 4.5.1): the unioned geometry must come back with a populated + // Extent. The three polygons together span x:[0,30] and y:[0,10]. + Assert.IsNotNull(union.Extent, + "Union result should have a populated Extent."); + Assert.AreEqual(0.0, union.Extent!.Xmin); + Assert.AreEqual(30.0, union.Extent!.Xmax); + Assert.AreEqual(0.0, union.Extent!.Ymin); + Assert.AreEqual(10.0, union.Extent!.Ymax); } [TestMethod] diff --git a/test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentSerializationTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentSerializationTests.cs new file mode 100644 index 000000000..c1120541b --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentSerializationTests.cs @@ -0,0 +1,71 @@ +using dymaptic.GeoBlazor.Core.Components.Geometries; +using dymaptic.GeoBlazor.Core.Serialization; +using System.Text.Json; + +namespace dymaptic.GeoBlazor.Core.Test.Unit; + +/// +/// Regression tests for the geometry deserialization path used by results of +/// GeometryEngine operations (e.g. Union). +/// The customer reported (against 4.5.1) that the unioned geometry arrived in .NET with a +/// null Extent (and therefore GetExtent() also returned null), +/// breaking callers that relied on union.Extent for map.GoTo(extent). +/// These tests deserialize the exact JSON shape produced by buildDotNetPolygon / +/// buildDotNetExtent (polygon.ts / extent.ts) and assert the Extent survives. +/// +[TestClass] +public class GeometryExtentSerializationTests +{ + // Mirrors the object returned by buildDotNetPolygon(jsUnion) in geometryEngine.ts -> union(), + // including the nested extent built by buildDotNetExtent. + private const string UnionPolygonJson = + """ + { + "type": "polygon", + "rings": [[[0,0],[0,10],[20,10],[20,0],[0,0]]], + "spatialReference": { "wkid": 102100 }, + "extent": { + "type": "extent", + "xmin": 0, "ymin": 0, "xmax": 20, "ymax": 10, + "spatialReference": { "wkid": 102100 } + }, + "isSimple": true + } + """; + + [TestMethod] + public void UnionResult_DeserializesWithPopulatedExtent() + { + Geometry? geometry = JsonSerializer.Deserialize(UnionPolygonJson, + GeoBlazorSerialization.JsonSerializerOptions); + + Assert.IsNotNull(geometry); + Assert.IsInstanceOfType(geometry); + Assert.IsNotNull(geometry.Extent, + "The geometry returned from Union must have a populated Extent (regression from 4.5.x)."); + Assert.AreEqual(0.0, geometry.Extent!.Xmin); + Assert.AreEqual(0.0, geometry.Extent!.Ymin); + Assert.AreEqual(20.0, geometry.Extent!.Xmax); + Assert.AreEqual(10.0, geometry.Extent!.Ymax); + Assert.IsNotNull(geometry.Extent!.SpatialReference); + } + + [TestMethod] + public void Polygon_RoundTrip_PreservesExtent() + { + Geometry? geometry = JsonSerializer.Deserialize(UnionPolygonJson, + GeoBlazorSerialization.JsonSerializerOptions); + Assert.IsNotNull(geometry?.Extent); + + // Re-serialize and re-deserialize to ensure the Extent is not dropped on the way back out. + string json = JsonSerializer.Serialize(geometry, GeoBlazorSerialization.JsonSerializerOptions); + Geometry? roundTripped = JsonSerializer.Deserialize(json, + GeoBlazorSerialization.JsonSerializerOptions); + + Assert.IsNotNull(roundTripped); + Assert.IsNotNull(roundTripped.Extent, + "Extent must survive a full serialize/deserialize round-trip."); + Assert.AreEqual(geometry.Extent!.Xmax, roundTripped.Extent!.Xmax); + Assert.AreEqual(geometry.Extent!.Ymax, roundTripped.Extent!.Ymax); + } +} diff --git a/test/dymaptic.GeoBlazor.Core.Test.Unit/SnappingOptionsApiTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Unit/SnappingOptionsApiTests.cs new file mode 100644 index 000000000..7fb317d7e --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Unit/SnappingOptionsApiTests.cs @@ -0,0 +1,125 @@ +using dymaptic.GeoBlazor.Core.Components; +using System.Reflection; + +namespace dymaptic.GeoBlazor.Core.Test.Unit; + +/// +/// Public-API contract tests for . +/// The 4.5.x release dropped the runtime setter methods (and collection helpers) from the +/// generated component — SnappingOptions.gb.cs was truncated right after the getters — +/// breaking consumers who toggled snapping / grid snapping at runtime +/// (SetEnabled, SetGridEnabled, etc.). +/// These tests lock in the full runtime public API surface (every setter, every parameter, and +/// the collection helpers) so a future code-generation split cannot silently remove it again. +/// +[TestClass] +public class SnappingOptionsApiTests +{ + private static readonly Type SnappingOptionsType = typeof(SnappingOptions); + + // --- Runtime setters (the whole region was dropped by the 4.5.x truncation) --- + + [TestMethod] + [DataRow("SetEnabled")] + [DataRow("SetGridEnabled")] + [DataRow("SetFeatureEnabled")] + [DataRow("SetSelfEnabled")] + [DataRow("SetAttributeRulesEnabled")] + public void HasNullableBoolSetter(string methodName) + { + AssertRuntimeSetter(methodName, typeof(bool?)); + } + + [TestMethod] + public void HasDistanceSetter() + { + AssertRuntimeSetter("SetDistance", typeof(double?)); + } + + [TestMethod] + public void HasFeatureSourcesSetter() + { + AssertRuntimeSetter("SetFeatureSources", typeof(IReadOnlyList)); + } + + private static void AssertRuntimeSetter(string methodName, Type parameterType) + { + MethodInfo? method = SnappingOptionsType.GetMethod(methodName, + BindingFlags.Public | BindingFlags.Instance, [parameterType]); + + Assert.IsNotNull(method, + $"SnappingOptions.{methodName}({parameterType.Name}) must exist for runtime configuration (regression from 4.5.x)."); + Assert.AreEqual(typeof(Task), method.ReturnType, + $"SnappingOptions.{methodName} should return Task."); + } + + // --- Collection helpers (also dropped by the truncation) --- + + [TestMethod] + [DataRow("AddToFeatureSources")] + [DataRow("RemoveFromFeatureSources")] + public void HasFeatureSourcesCollectionHelper(string methodName) + { + MethodInfo? method = SnappingOptionsType.GetMethod(methodName, + BindingFlags.Public | BindingFlags.Instance, [typeof(FeatureSnappingLayerSource[])]); + + Assert.IsNotNull(method, + $"SnappingOptions.{methodName}(params FeatureSnappingLayerSource[]) must exist (regression from 4.5.x)."); + Assert.AreEqual(typeof(Task), method.ReturnType, + $"SnappingOptions.{methodName} should return Task."); + } + + // --- Public properties / Blazor parameters --- + + [TestMethod] + [DataRow("Enabled")] + [DataRow("GridEnabled")] + [DataRow("FeatureEnabled")] + [DataRow("SelfEnabled")] + [DataRow("AttributeRulesEnabled")] + public void HasNullableBoolParameter(string propertyName) + { + AssertParameter(propertyName, typeof(bool?)); + } + + [TestMethod] + public void HasDistanceParameter() + { + AssertParameter("Distance", typeof(double?)); + } + + [TestMethod] + public void HasFeatureSourcesParameter() + { + AssertParameter("FeatureSources", typeof(IReadOnlyList)); + } + + private static void AssertParameter(string propertyName, Type propertyType) + { + PropertyInfo? property = SnappingOptionsType.GetProperty(propertyName, + BindingFlags.Public | BindingFlags.Instance); + + Assert.IsNotNull(property, + $"SnappingOptions.{propertyName} property must exist (regression from 4.5.x)."); + Assert.AreEqual(propertyType, property.PropertyType, + $"SnappingOptions.{propertyName} should be of type {propertyType.Name}."); + Assert.IsTrue(property.CanRead && property.CanWrite, + $"SnappingOptions.{propertyName} should be readable and writable."); + } + + // --- Behavioral: setters update local state before render --- + + [TestMethod] + public async Task Setters_UpdateLocalProperty_BeforeRender() + { + // Without a JS runtime the setters should still update the local value (used for binding / + // initial render). The customer reported that even binding Enabled "didn't take effect". + SnappingOptions options = new(); + + await options.SetEnabled(true); + Assert.IsTrue(options.Enabled.GetValueOrDefault(), "SetEnabled should update the local Enabled value."); + + await options.SetGridEnabled(true); + Assert.IsTrue(options.GridEnabled.GetValueOrDefault(), "SetGridEnabled should update the local GridEnabled value."); + } +}