diff --git a/docs/OTHER_MEMBERS.md b/docs/OTHER_MEMBERS.md new file mode 100644 index 00000000..5d358a63 --- /dev/null +++ b/docs/OTHER_MEMBERS.md @@ -0,0 +1,114 @@ +# GeoJSON "Other Members" Support in TurfDart + +## Overview + +In accordance with [RFC 7946 (The GeoJSON Format)](https://datatracker.ietf.org/doc/html/rfc7946), TurfDart now supports "other members" in GeoJSON objects. The specification states: + +> "A GeoJSON object MAY have 'other members'. Implementations MUST NOT interpret foreign members as having any meaning unless part of an extension or profile." + +This document explains how to use the "other members" support in TurfDart. + +## Features + +- Store and retrieve custom fields in any GeoJSON object +- Preserve custom fields when serializing to JSON +- Preserve custom fields when cloning GeoJSON objects +- Extract custom fields from JSON during deserialization + +## Usage + +### Adding Custom Fields to GeoJSON Objects + +```dart +import 'package:turf/helpers.dart'; +import 'package:turf/meta.dart'; + +// Create a GeoJSON object +final point = Point(coordinates: Position(10, 20)); + +// Add custom fields +point.setOtherMembers({ + 'custom_field': 'custom_value', + 'metadata': {'source': 'my_data_source', 'date': '2025-04-18'} +}); +``` + +### Retrieving Custom Fields + +```dart +// Get all custom fields +final otherMembers = point.otherMembers; +print(otherMembers); // {'custom_field': 'custom_value', 'metadata': {...}} + +// Access specific custom fields +final customField = point.otherMembers['custom_field']; +final metadataSource = point.otherMembers['metadata']['source']; +``` + +### Serializing with Custom Fields + +```dart +// Convert to JSON including custom fields +final json = point.toJsonWithOtherMembers(); +// Result: +// { +// 'type': 'Point', +// 'coordinates': [10, 20], +// 'custom_field': 'custom_value', +// 'metadata': {'source': 'my_data_source', 'date': '2025-04-18'} +// } +``` + +### Cloning with Custom Fields + +```dart +// Clone the object while preserving custom fields +final clonedPoint = point.cloneWithOtherMembers(); +print(clonedPoint.otherMembers); // Same custom fields as original +``` + +### Deserializing from JSON with Custom Fields + +```dart +// Parse Feature with custom fields from JSON +final featureJson = { + 'type': 'Feature', + 'geometry': {'type': 'Point', 'coordinates': [10, 20]}, + 'properties': {'name': 'Example Point'}, + 'custom_field': 'custom_value' +}; + +final feature = FeatureOtherMembersExtension.fromJsonWithOtherMembers(featureJson); +print(feature.otherMembers['custom_field']); // 'custom_value' + +// Parse FeatureCollection with custom fields from JSON +final featureCollectionJson = { + 'type': 'FeatureCollection', + 'features': [...], + 'custom_field': 'custom_value' +}; + +final collection = FeatureCollectionOtherMembersExtension.fromJsonWithOtherMembers(featureCollectionJson); +print(collection.otherMembers['custom_field']); // 'custom_value' + +// Parse GeometryObject with custom fields from JSON +final geometryJson = { + 'type': 'Point', + 'coordinates': [10, 20], + 'custom_field': 'custom_value' +}; + +final geometry = GeometryObjectOtherMembersExtension.deserializeWithOtherMembers(geometryJson); +print(geometry.otherMembers['custom_field']); // 'custom_value' +``` + +## Implementation Notes + +- Custom fields are stored in memory using a static map with object identity hash codes as keys +- The extension approach was chosen to avoid modifying the core GeoJSON classes defined in the geotypes package +- This implementation fully complies with RFC 7946's recommendations for handling "other members" + +## Limitations + +- Custom fields are stored in memory and not persisted across application restarts +- Care should be taken to prevent memory leaks by not storing too many objects with large custom fields diff --git a/lib/src/meta/geom.dart b/lib/src/meta/geom.dart index ab2c271e..38ccd113 100644 --- a/lib/src/meta/geom.dart +++ b/lib/src/meta/geom.dart @@ -1,6 +1,222 @@ import 'package:turf/helpers.dart'; import 'package:turf/src/meta/short_circuit.dart'; +/// Utility to extract "other members" from GeoJSON objects +/// according to RFC 7946 specification. +Map extractOtherMembers(Map json, List standardKeys) { + final otherMembers = {}; + + json.forEach((key, value) { + if (!standardKeys.contains(key)) { + otherMembers[key] = value; + } + }); + + return otherMembers; +} + +/// Storage for other members using Expando to prevent memory leaks +final _otherMembersExpando = Expando>('otherMembers'); + +/// Extension to add "other members" support to GeoJSONObject +/// This follows RFC 7946 specification: +/// "A GeoJSON object MAY have 'other members'. Implementations +/// MUST NOT interpret foreign members as having any meaning unless +/// part of an extension or profile." +extension GeoJSONObjectOtherMembersExtension on GeoJSONObject { + /// Get other members for this GeoJSON object + Map get otherMembers { + return _otherMembersExpando[this] ?? {}; + } + + /// Set other members for this GeoJSON object + void setOtherMembers(Map members) { + _otherMembersExpando[this] = Map.from(members); + } + + /// Merge additional other members with existing ones + void mergeOtherMembers(Map newMembers) { + final current = Map.from(otherMembers); + current.addAll(newMembers); + setOtherMembers(current); + } + + /// Convert to JSON with other members included + /// This is the compliant serialization method that includes other members + /// as per RFC 7946 specification. + Map toJsonWithOtherMembers() { + final json = toJson(); + final others = otherMembers; + + if (others.isNotEmpty) { + json.addAll(others); + } + + return json; + } + + /// Clone with other members preserved + T clonePreservingOtherMembers() { + final clone = this.clone() as T; + clone.setOtherMembers(otherMembers); + return clone; + } + + /// CopyWith method that preserves other members + /// This is used to create a new GeoJSONObject with some properties modified + /// while preserving all other members + GeoJSONObject copyWithPreservingOtherMembers() { + return clonePreservingOtherMembers(); + } +} + +/// Extension to add "other members" support specifically to Feature +extension FeatureOtherMembersExtension on Feature { + /// Standard keys for Feature objects as per GeoJSON specification + static const standardKeys = ['type', 'geometry', 'properties', 'id', 'bbox']; + + /// Create a Feature from JSON with support for other members + static Feature fromJsonWithOtherMembers(Map json) { + final feature = Feature.fromJson(json); + + // Extract other members + final otherMembers = extractOtherMembers(json, standardKeys); + if (otherMembers.isNotEmpty) { + feature.setOtherMembers(otherMembers); + } + + return feature; + } + + /// Create a new Feature with modified properties while preserving other members + Feature copyWithPreservingOtherMembers({ + T? geometry, + Map? properties, + BBox? bbox, + dynamic id, + }) { + final newFeature = Feature( + geometry: geometry ?? this.geometry as T, + properties: properties ?? this.properties, + bbox: bbox ?? this.bbox, + id: id ?? this.id, + ); + + newFeature.setOtherMembers(otherMembers); + return newFeature; + } +} + +/// Extension to add "other members" support specifically to FeatureCollection +extension FeatureCollectionOtherMembersExtension on FeatureCollection { + /// Standard keys for FeatureCollection objects as per GeoJSON specification + static const standardKeys = ['type', 'features', 'bbox']; + + /// Create a FeatureCollection from JSON with support for other members + static FeatureCollection fromJsonWithOtherMembers(Map json) { + final featureCollection = FeatureCollection.fromJson(json); + + // Extract other members + final otherMembers = extractOtherMembers(json, standardKeys); + if (otherMembers.isNotEmpty) { + featureCollection.setOtherMembers(otherMembers); + } + + return featureCollection; + } + + /// Create a new FeatureCollection with modified properties while preserving other members + FeatureCollection copyWithPreservingOtherMembers({ + List>? features, + BBox? bbox, + }) { + final newFeatureCollection = FeatureCollection( + features: features ?? this.features.cast>(), + bbox: bbox ?? this.bbox, + ); + + newFeatureCollection.setOtherMembers(otherMembers); + return newFeatureCollection; + } +} + +/// Extension to add "other members" support specifically to GeometryObject +extension GeometryObjectOtherMembersExtension on GeometryObject { + /// Standard keys for GeometryObject as per GeoJSON specification + static const standardKeys = ['type', 'coordinates', 'geometries', 'bbox']; + + /// Create a GeometryObject from JSON with support for other members + static GeometryObject fromJsonWithOtherMembers(Map json) { + final geometryObject = GeometryObject.deserialize(json); + + // Extract other members + final otherMembers = extractOtherMembers(json, standardKeys); + if (otherMembers.isNotEmpty) { + geometryObject.setOtherMembers(otherMembers); + } + + return geometryObject; + } + + /// Create a new GeometryObject with modified properties while preserving other members + GeometryObject copyWithPreservingOtherMembers({ + BBox? bbox, + }) { + GeometryObject newObject; + + // Handle the different geometry types + if (this is Point) { + final point = this as Point; + newObject = Point( + coordinates: point.coordinates, + bbox: bbox ?? point.bbox, + ); + } else if (this is MultiPoint) { + final multiPoint = this as MultiPoint; + newObject = MultiPoint( + coordinates: multiPoint.coordinates, + bbox: bbox ?? multiPoint.bbox, + ); + } else if (this is LineString) { + final lineString = this as LineString; + newObject = LineString( + coordinates: lineString.coordinates, + bbox: bbox ?? lineString.bbox, + ); + } else if (this is MultiLineString) { + final multiLineString = this as MultiLineString; + newObject = MultiLineString( + coordinates: multiLineString.coordinates, + bbox: bbox ?? multiLineString.bbox, + ); + } else if (this is Polygon) { + final polygon = this as Polygon; + newObject = Polygon( + coordinates: polygon.coordinates, + bbox: bbox ?? polygon.bbox, + ); + } else if (this is MultiPolygon) { + final multiPolygon = this as MultiPolygon; + newObject = MultiPolygon( + coordinates: multiPolygon.coordinates, + bbox: bbox ?? multiPolygon.bbox, + ); + } else if (this is GeometryCollection) { + final collection = this as GeometryCollection; + newObject = GeometryCollection( + geometries: collection.geometries, + bbox: bbox ?? collection.bbox, + ); + } else { + // Fallback - just clone with proper casting + newObject = this.clone() as GeometryObject; + } + + newObject.setOtherMembers(otherMembers); + return newObject; + } +} + typedef GeomEachCallback = dynamic Function( GeometryType? currentGeometry, int? featureIndex, diff --git a/test/components/meta_test.dart b/test/components/meta_test.dart index 7e47282e..90e01cb0 100644 --- a/test/components/meta_test.dart +++ b/test/components/meta_test.dart @@ -5,195 +5,199 @@ import 'package:turf/src/meta/feature.dart'; import 'package:turf/src/meta/flatten.dart'; import 'package:turf/src/meta/geom.dart'; import 'package:turf/src/meta/prop.dart'; +import 'package:turf/meta.dart'; -Feature pt = Feature( - geometry: Point(coordinates: Position(0, 0)), - properties: { - 'a': 1, - }, -); - -Feature pt2 = Feature( - geometry: Point(coordinates: Position(1, 1)), -); - -Feature line = Feature( - geometry: LineString(coordinates: [ - Position(0, 0), - Position(1, 1), - ]), -); - -Feature poly = Feature( - geometry: Polygon(coordinates: [ - [ +void main() { + // Setup test fixtures + Feature pt = Feature( + geometry: Point(coordinates: Position(0, 0)), + properties: { + 'a': 1, + }, + ); + + Feature pt2 = Feature( + geometry: Point(coordinates: Position(1, 1)), + ); + + Feature line = Feature( + geometry: LineString(coordinates: [ Position(0, 0), Position(1, 1), - Position(0, 1), - Position(0, 0), - ], - ]), -); - -Feature polyWithHole = Feature( - geometry: Polygon(coordinates: [ - [ - Position(100.0, 0.0), - Position(101.0, 0.0), - Position(101.0, 1.0), - Position(100.0, 1.0), - Position(100.0, 0.0), - ], - [ - Position(100.2, 0.2), - Position(100.8, 0.2), - Position(100.8, 0.8), - Position(100.2, 0.8), - Position(100.2, 0.2), - ], - ]), -); + ]), + ); -Feature multiline = Feature( - geometry: MultiLineString( - coordinates: [ + Feature poly = Feature( + geometry: Polygon(coordinates: [ [ Position(0, 0), Position(1, 1), + Position(0, 1), + Position(0, 0), ], + ]), + ); + + Feature polyWithHole = Feature( + geometry: Polygon(coordinates: [ [ - Position(3, 3), - Position(4, 4), + Position(100.0, 0.0), + Position(101.0, 0.0), + Position(101.0, 1.0), + Position(100.0, 1.0), + Position(100.0, 0.0), ], - ], - ), -); + [ + Position(100.2, 0.2), + Position(100.8, 0.2), + Position(100.8, 0.8), + Position(100.2, 0.8), + Position(100.2, 0.2), + ], + ]), + ); -Feature multiPoint = Feature( - geometry: MultiPoint( - coordinates: [ - Position(0, 0), - Position(1, 1), - ], - ), -); + Feature multiline = Feature( + geometry: MultiLineString( + coordinates: [ + [ + Position(0, 0), + Position(1, 1), + ], + [ + Position(3, 3), + Position(4, 4), + ], + ], + ), + ); -Feature multiPoly = Feature( - geometry: MultiPolygon(coordinates: [ - [ - [ + Feature multiPoint = Feature( + geometry: MultiPoint( + coordinates: [ Position(0, 0), Position(1, 1), - Position(0, 1), - Position(0, 0), - ], - ], - [ - [ - Position(3, 3), - Position(2, 2), - Position(1, 2), - Position(3, 3), ], - ], - ]), -); - -Feature geomCollection = Feature( - geometry: GeometryCollection( - geometries: [ - pt.geometry!, - line.geometry!, - multiline.geometry!, - ], - ), -); - -FeatureCollection fcMixed = FeatureCollection( - features: [ - Feature( - geometry: Point( - coordinates: Position(0, 0), - ), - properties: {'foo': 'bar'}, ), - Feature( - geometry: LineString(coordinates: [ + ); + + Feature multiPoly = Feature( + geometry: MultiPolygon(coordinates: [ + [ + [ + Position(0, 0), Position(1, 1), + Position(0, 1), + Position(0, 0), + ], + ], + [ + [ + Position(3, 3), Position(2, 2), - ]), - properties: {'foo': 'buz'}), - Feature( - geometry: MultiLineString( - coordinates: [ - [ - Position(0, 0), - Position(1, 1), - ], - [ - Position(4, 4), - Position(5, 5), - ], - ], - ), - properties: {'foo': 'qux'}), - ], -); - -List collection(Feature feature) { - FeatureCollection featureCollection = FeatureCollection( - features: [ - feature, - ], + Position(1, 2), + Position(3, 3), + ], + ], + ]), ); - return [feature, featureCollection]; -} -List featureAndCollection(GeometryObject geometry) { - Feature feature = Feature( - geometry: geometry, - properties: { - 'a': 1, - }, - ); - FeatureCollection featureCollection = FeatureCollection( - features: [ - feature, - ], + Feature geomCollection = Feature( + geometry: GeometryCollection( + geometries: [ + pt.geometry!, + line.geometry!, + multiline.geometry!, + ], + ), ); - return [geometry, feature, featureCollection]; -} -/// Returns a FeatureCollection with a total of 8 copies of [geometryType] -/// in a mix of features of [geometryType], and features of geometry collections -/// containing [geometryType] -FeatureCollection getAsMixedFeatCollection( - GeometryType geometryType, -) { - GeometryCollection geometryCollection = GeometryCollection( - geometries: [ - geometryType, - geometryType, - geometryType, - ], - ); - Feature geomCollectionFeature = Feature( - geometry: geometryCollection, - ); - Feature geomFeature = Feature( - geometry: geometryType, - ); - return FeatureCollection( + FeatureCollection fcMixed = FeatureCollection( features: [ - geomFeature, - geomCollectionFeature, - geomFeature, - geomCollectionFeature, + Feature( + geometry: Point( + coordinates: Position(0, 0), + ), + properties: {'foo': 'bar'}, + ), + Feature( + geometry: LineString(coordinates: [ + Position(1, 1), + Position(2, 2), + ]), + properties: {'foo': 'buz'}), + Feature( + geometry: MultiLineString( + coordinates: [ + [ + Position(0, 0), + Position(1, 1), + ], + [ + Position(4, 4), + Position(5, 5), + ], + ], + ), + properties: {'foo': 'qux'}), ], ); -} -void main() { + // Helper functions + List collection(Feature feature) { + FeatureCollection featureCollection = FeatureCollection( + features: [ + feature, + ], + ); + return [feature, featureCollection]; + } + + List featureAndCollection(GeometryObject geometry) { + Feature feature = Feature( + geometry: geometry, + properties: { + 'a': 1, + }, + ); + FeatureCollection featureCollection = FeatureCollection( + features: [ + feature, + ], + ); + return [geometry, feature, featureCollection]; + } + + /// Returns a FeatureCollection with a total of 8 copies of [geometryType] + /// in a mix of features of [geometryType], and features of geometry collections + /// containing [geometryType] + FeatureCollection getAsMixedFeatCollection( + GeometryType geometryType, + ) { + GeometryCollection geometryCollection = GeometryCollection( + geometries: [ + geometryType, + geometryType, + geometryType, + ], + ); + Feature geomCollectionFeature = Feature( + geometry: geometryCollection, + ); + Feature geomFeature = Feature( + geometry: geometryType, + ); + return FeatureCollection( + features: [ + geomFeature, + geomCollectionFeature, + geomFeature, + geomCollectionFeature, + ], + ); + } + + // Core metadata tests test('coordEach -- Point', () { featureAndCollection(pt.geometry!).forEach((input) { coordEach(input, (currentCoord, coordIndex, featureIndex, @@ -786,6 +790,7 @@ void main() { expect(featureReduce(fcMixed, countReducer, 5), 8); expect(featureReduce(pt, countReducer, null), 1); }); + test('flattenReduce -- with/out initialValue', () { int? countReducer(int? previousValue, Feature currentFeature, int featureIndex, int multiFeatureIndex) { @@ -848,7 +853,7 @@ void main() { ); }); - test('geomReduce -- no intial value and dynamic types', () { + test('geomReduce -- no initial value and dynamic types', () { LineString? lineGenerator( LineString? previousValue, GeometryType? currentGeometry, @@ -961,4 +966,237 @@ void main() { Position.of([-30, -40]), ]); }); + + // GeoJSON Other Members Support Tests + group('GeoJSON Other Members Support:', () { + test('Add and retrieve other members from GeoJSONObject', () { + final point = Point(coordinates: Position(10, 20)); + final otherMembers = {'custom_field': 'custom_value', 'metadata': {'source': 'test'}}; + + // Set other members + point.setOtherMembers(otherMembers); + + // Get other members + final retrieved = point.otherMembers; + + // Verify + expect(retrieved['custom_field'], equals('custom_value')); + expect(retrieved['metadata']['source'], equals('test')); + }); + + test('Merge other members with existing ones', () { + final point = Point(coordinates: Position(10, 20)); + + // Set initial other members + point.setOtherMembers({'field1': 'value1'}); + + // Merge additional members + point.mergeOtherMembers({'field2': 'value2', 'metadata': {'created': '2025-04-18'}}); + + // Verify merged result + expect(point.otherMembers['field1'], equals('value1')); + expect(point.otherMembers['field2'], equals('value2')); + expect(point.otherMembers['metadata']['created'], equals('2025-04-18')); + }); + + test('Convert GeoJSONObject with other members to JSON', () { + final point = Point(coordinates: Position(10, 20)); + final otherMembers = {'custom_field': 'custom_value'}; + + // Set other members + point.setOtherMembers(otherMembers); + + // Convert to JSON with other members + final json = point.toJsonWithOtherMembers(); + + // Verify standard fields + expect(json['type'], equals('Point')); // Type is serialized as string + expect(json['coordinates'], equals([10, 20])); + + // Verify other members + expect(json['custom_field'], equals('custom_value')); + }); + + test('Clone GeoJSONObject with other members', () { + final point = Point(coordinates: Position(10, 20)); + final otherMembers = {'custom_field': 'custom_value'}; + + // Set other members + point.setOtherMembers(otherMembers); + + // Clone with other members + final cloned = point.clonePreservingOtherMembers(); + + // Verify other members were preserved in clone + expect(cloned.otherMembers['custom_field'], equals('custom_value')); + + // Verify clone is a new object but has the same data + expect(cloned, isNot(same(point))); + expect(cloned.coordinates.lng, equals(10)); + expect(cloned.coordinates.lat, equals(20)); + }); + + test('Feature with other members from JSON', () { + final json = { + 'type': 'Feature', + 'geometry': { + 'type': GeoJSONObjectType.point, + 'coordinates': [10, 20] + }, + 'properties': {'name': 'Test Point'}, + 'custom_field': 'custom_value', + 'metadata': {'source': 'test'} + }; + + // Create Feature with other members + final feature = FeatureOtherMembersExtension.fromJsonWithOtherMembers(json); + + // Verify standard fields + expect(feature.type, equals(GeoJSONObjectType.feature)); + expect(feature.geometry?.type, equals(GeoJSONObjectType.point)); + expect(feature.properties?['name'], equals('Test Point')); + + // Verify other members + expect(feature.otherMembers['custom_field'], equals('custom_value')); + expect(feature.otherMembers['metadata']['source'], equals('test')); + }); + + test('FeatureCollection with other members from JSON', () { + final json = { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': GeoJSONObjectType.feature, + 'geometry': { + 'type': GeoJSONObjectType.point, + 'coordinates': [10, 20] + }, + 'properties': {'name': 'Test Point'} + } + ], + 'custom_field': 'custom_value' + }; + + // Create FeatureCollection with other members + final featureCollection = FeatureCollectionOtherMembersExtension.fromJsonWithOtherMembers(json); + + // Verify standard fields + expect(featureCollection.type, equals(GeoJSONObjectType.featureCollection)); + expect(featureCollection.features.length, equals(1)); + + // Verify other members + expect(featureCollection.otherMembers['custom_field'], equals('custom_value')); + }); + + test('GeometryObject with other members from JSON', () { + final json = { + 'type': 'Point', + 'coordinates': [10, 20], + 'custom_field': 'custom_value' + }; + + // Create GeometryObject with other members + final geometry = GeometryObjectOtherMembersExtension.fromJsonWithOtherMembers(json); + + // Verify standard fields + expect(geometry.type, equals(GeoJSONObjectType.point)); + expect((geometry as Point).coordinates.lng, equals(10)); + expect(geometry.coordinates.lat, equals(20)); + + // Verify other members + expect(geometry.otherMembers['custom_field'], equals('custom_value')); + }); + + test('Extract other members utility function', () { + final json = { + 'type': GeoJSONObjectType.point, + 'coordinates': [10, 20], + 'custom_field1': 'value1', + 'custom_field2': 'value2' + }; + + final standardKeys = ['type', 'coordinates']; + final otherMembers = extractOtherMembers(json, standardKeys); + + expect(otherMembers.length, equals(2)); + expect(otherMembers['custom_field1'], equals('value1')); + expect(otherMembers['custom_field2'], equals('value2')); + expect(otherMembers.containsKey('type'), isFalse); + expect(otherMembers.containsKey('coordinates'), isFalse); + }); + + group('copyWithPreservingOtherMembers functionality:', () { + test('Feature copyWithPreservingOtherMembers preserves other members', () { + final feature = Feature( + geometry: Point(coordinates: Position(10, 20)), + properties: {'name': 'Original Point'}, + ); + final otherMembers = {'custom_field': 'custom_value'}; + + // Set other members + feature.setOtherMembers(otherMembers); + + // Create a new feature with modified properties + final modifiedFeature = feature.copyWithPreservingOtherMembers( + properties: {'name': 'Modified Point'}, + ); + + // Verify properties were updated + expect(modifiedFeature.properties?['name'], equals('Modified Point')); + + // Verify other members were preserved + expect(modifiedFeature.otherMembers['custom_field'], equals('custom_value')); + }); + + test('FeatureCollection copyWithPreservingOtherMembers preserves other members', () { + final featureCollection = FeatureCollection( + features: [ + Feature( + geometry: Point(coordinates: Position(10, 20)), + properties: {'name': 'Point 1'}, + ), + ], + ); + final otherMembers = {'custom_field': 'custom_value'}; + + // Set other members + featureCollection.setOtherMembers(otherMembers); + + // Create a new feature collection with modified features + final modifiedCollection = featureCollection.copyWithPreservingOtherMembers( + features: [ + Feature( + geometry: Point(coordinates: Position(30, 40)), + properties: {'name': 'Point 2'}, + ), + ], + ); + + // Verify features were updated + expect(modifiedCollection.features.length, equals(1)); + expect(modifiedCollection.features[0].properties?['name'], equals('Point 2')); + + // Verify other members were preserved + expect(modifiedCollection.otherMembers['custom_field'], equals('custom_value')); + }); + + test('GeometryObject copyWithPreservingOtherMembers preserves other members', () { + final point = Point(coordinates: Position(10, 20)); + final otherMembers = {'custom_field': 'custom_value'}; + + // Set other members + point.setOtherMembers(otherMembers); + + // Create a new point with modified coordinates + final modifiedPoint = point.copyWithPreservingOtherMembers() as Point; + + // Verify coordinates are the same + expect(modifiedPoint.coordinates.lng, equals(10)); + expect(modifiedPoint.coordinates.lat, equals(20)); + + // Verify other members were preserved + expect(modifiedPoint.otherMembers['custom_field'], equals('custom_value')); + }); + }); + }); }