diff --git a/lib/src/meta/feature.dart b/lib/src/meta/feature.dart index c1b033c0..493e597e 100644 --- a/lib/src/meta/feature.dart +++ b/lib/src/meta/feature.dart @@ -96,3 +96,81 @@ T? featureReduce( }); return previousValue; } + +/// Extension on [Feature] that adds copyWith functionality similar to the turf.js implementation. +extension FeatureExtension on Feature { + /// Creates a copy of this [Feature] with the specified options overridden. + /// + /// This allows creating a modified copy of the [Feature] without changing the original instance. + /// The implementation follows the pattern used in turf.js, enabling a familiar and + /// consistent API across the Dart and JavaScript implementations. + /// + /// Type parameter [G] extends [GeometryObject] and specifies the type of geometry for the + /// returned Feature. This should typically match the original geometry type or be compatible + /// with it. The method includes runtime type checking to help prevent type errors. + /// + /// Parameters: + /// - [id]: Optional new id for the feature. If not provided, the original id is retained. + /// - [properties]: Optional new properties for the feature. If not provided, the original + /// properties are retained. Note that this completely replaces the properties object. + /// - [geometry]: Optional new geometry for the feature. If not provided, the original geometry + /// is retained. Must be an instance of [G] or null. + /// - [bbox]: Optional new bounding box for the feature. If not provided, the original bbox is retained. + /// + /// Returns a new [Feature] instance with the specified properties overridden. + /// + /// Throws an [ArgumentError] if the geometry parameter is provided but is not compatible + /// with the specified generic type [G]. + /// + /// Example: + /// ```dart + /// final feature = Feature( + /// id: 'point-1', + /// geometry: Point(coordinates: Position(0, 0)), + /// properties: {'name': 'Original'} + /// ); + /// + /// // Create a copy with the same geometry type + /// final modifiedFeature = feature.copyWith( + /// properties: {'name': 'Modified', 'category': 'landmark'}, + /// geometry: Point(coordinates: Position(10, 20)), + /// ); + /// + /// // If changing geometry type, be explicit about the new type + /// final polygonFeature = feature.copyWith( + /// geometry: Polygon(coordinates: [[ + /// Position(0, 0), + /// Position(1, 0), + /// Position(1, 1), + /// Position(0, 0), + /// ]]), + /// ); + /// ``` + Feature copyWith({ + dynamic id, + Map? properties, + G? geometry, + BBox? bbox, + }) { + // Runtime type checking for geometry + if (geometry != null && geometry is! G) { + throw ArgumentError('Provided geometry must be of type $G'); + } + + // If we're not changing the geometry and the current geometry is not null, + // verify it's compatible with the target type G + final currentGeometry = this.geometry; + if (geometry == null && currentGeometry != null && currentGeometry is! G) { + throw ArgumentError( + 'Current geometry of type ${currentGeometry.runtimeType} is not compatible with target type $G. ' + 'Please provide a geometry parameter of type $G.'); + } + + return Feature( + id: id ?? this.id, + properties: properties ?? this.properties, + geometry: geometry ?? (currentGeometry as G?), + bbox: bbox ?? this.bbox, + ); + } +} diff --git a/test/meta/feature_test.dart b/test/meta/feature_test.dart new file mode 100644 index 00000000..2483d967 --- /dev/null +++ b/test/meta/feature_test.dart @@ -0,0 +1,150 @@ +import 'package:test/test.dart'; +import 'package:turf/turf.dart'; + +void main() { + group('Feature Extensions', () { + test('copyWith method creates a correct copy with modified properties', () { + // Create an original feature + final Feature original = Feature( + id: 'original-id', + geometry: Point(coordinates: Position(0, 0)), + properties: {'name': 'Original feature'}, + ); + + // Create a modified copy using copyWith + final Feature modified = original.copyWith( + id: 'modified-id', + geometry: Point(coordinates: Position(10, 20)), + properties: {'name': 'Modified feature', 'tag': 'test'}, + ); + + // Verify original is unchanged + expect(original.id, equals('original-id')); + expect(original.geometry!.coordinates.lng, equals(0)); + expect(original.geometry!.coordinates.lat, equals(0)); + expect(original.properties!['name'], equals('Original feature')); + expect(original.properties!.containsKey('tag'), isFalse); + + // Verify modified has correct values + expect(modified.id, equals('modified-id')); + expect(modified.geometry!.coordinates.lng, equals(10)); + expect(modified.geometry!.coordinates.lat, equals(20)); + expect(modified.properties!['name'], equals('Modified feature')); + expect(modified.properties!['tag'], equals('test')); + }); + + test('copyWith method works with partial updates', () { + // Create an original feature + final Feature original = Feature( + id: 'original-id', + geometry: Point(coordinates: Position(0, 0)), + properties: {'name': 'Original feature'}, + ); + + // Update only the id + final Feature idOnly = original.copyWith( + id: 'new-id', + ); + expect(idOnly.id, equals('new-id')); + expect(idOnly.geometry, equals(original.geometry)); + expect(idOnly.properties, equals(original.properties)); + + // Update only the geometry + final Feature geometryOnly = original.copyWith( + geometry: Point(coordinates: Position(5, 5)), + ); + expect(geometryOnly.id, equals(original.id)); + expect(geometryOnly.geometry!.coordinates.lng, equals(5)); + expect(geometryOnly.geometry!.coordinates.lat, equals(5)); + expect(geometryOnly.properties, equals(original.properties)); + + // Update only properties + final Feature propertiesOnly = original.copyWith( + properties: {'updated': true}, + ); + expect(propertiesOnly.id, equals(original.id)); + expect(propertiesOnly.geometry, equals(original.geometry)); + expect(propertiesOnly.properties!['updated'], isTrue); + expect(propertiesOnly.properties!.containsKey('name'), isFalse); + }); + + test('copyWith handles bbox correctly', () { + // Create an original feature with bbox + final Feature original = Feature( + id: 'original-id', + geometry: Point(coordinates: Position(0, 0)), + properties: {'name': 'Original feature'}, + bbox: BBox(0, 0, 10, 10), + ); + + // Update only the bbox + final Feature bboxOnly = original.copyWith( + bbox: BBox(5, 5, 15, 15), + ); + + expect(bboxOnly.id, equals(original.id)); + expect(bboxOnly.geometry, equals(original.geometry)); + expect(bboxOnly.properties, equals(original.properties)); + expect(bboxOnly.bbox!.lng1, equals(5)); + expect(bboxOnly.bbox!.lat1, equals(5)); + expect(bboxOnly.bbox!.lng2, equals(15)); + expect(bboxOnly.bbox!.lat2, equals(15)); + }); + + test('copyWith handles changing geometry type', () { + // Create a Point feature + final Feature pointFeature = Feature( + id: 'point-id', + geometry: Point(coordinates: Position(0, 0)), + properties: {'type': 'point'}, + ); + + // Convert to a LineString feature + final Feature lineFeature = pointFeature.copyWith( + geometry: LineString(coordinates: [ + Position(0, 0), + Position(1, 1), + ]), + properties: {'type': 'line'}, + ); + + expect(lineFeature.id, equals('point-id')); + expect(lineFeature.geometry!.type, equals(GeoJSONObjectType.lineString)); + expect(lineFeature.geometry!.coordinates.length, equals(2)); + expect(lineFeature.properties!['type'], equals('line')); + }); + + test('copyWith handles type checking', () { + // Create a Point feature + final Feature pointFeature = Feature( + geometry: Point(coordinates: Position(0, 0)), + ); + + // It's not possible to directly create this error since the Dart type system + // prevents it. However, we can verify that the method correctly handles + // the type checks for valid cases. + + // This should work fine - creating a Point feature from another Point feature + final Feature stillPointFeature = pointFeature.copyWith(); + expect(stillPointFeature.geometry, isNotNull); + expect(stillPointFeature.geometry, isA()); + + // This should also work - explicitly changing to a new geometry type + final Feature lineFeature = pointFeature.copyWith( + geometry: LineString(coordinates: [Position(0, 0), Position(1, 1)]), + ); + expect(lineFeature.geometry, isNotNull); + expect(lineFeature.geometry, isA()); + }); + + test('copyWith throws error when target type is incompatible with original geometry', () { + // Create a Point feature + final Feature pointFeature = Feature( + geometry: Point(coordinates: Position(0, 0)), + ); + + // Try to create a LineString feature without providing a new geometry + expect(() => pointFeature.copyWith(), throwsArgumentError); + }); + }); +}