diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 90c6c43..769cf58 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ on: jobs: test: - runs-on: macos-13 + runs-on: macos-14 strategy: matrix: scheme: [ @@ -16,7 +16,7 @@ jobs: ] destination: [ # TODO: Add more destinations - 'platform=iOS Simulator,name=iPhone 15,OS=17.0.1' + 'platform=iOS Simulator,name=iPhone 15,OS=17.2' ] steps: diff --git a/Package.resolved b/Package.resolved index eba3e28..661a055 100644 --- a/Package.resolved +++ b/Package.resolved @@ -18,6 +18,15 @@ "revision" : "b8deecb8adc3b911de311ead5a13b98fbf2d7824" } }, + { + "identity" : "mockable", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Kolos65/Mockable.git", + "state" : { + "revision" : "7af00c08880d375f2742ca55705abd69837fe6c3", + "version" : "0.0.2" + } + }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index a6cafbb..2fde427 100644 --- a/Package.swift +++ b/Package.swift @@ -23,6 +23,7 @@ let package = Package( .package(url: "https://github.com/maplibre/maplibre-gl-native-distribution.git", from: "6.0.0-pre9599200f2529de44ba62d4662cddb445dc19397d"), .package(url: "https://github.com/stadiamaps/maplibre-swift-macros.git", branch: "main"), // Testing + .package(url: "https://github.com/Kolos65/Mockable.git", from: "0.0.2"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.15.3"), ], targets: [ @@ -32,6 +33,10 @@ let package = Package( .target(name: "InternalUtils"), .target(name: "MapLibreSwiftDSL"), .product(name: "MapLibre", package: "maplibre-gl-native-distribution"), + .product(name: "Mockable", package: "Mockable") + ], + swiftSettings: [ + .define("MOCKING", .when(configuration: .debug)) ]), .target( name: "MapLibreSwiftDSL", @@ -50,7 +55,9 @@ let package = Package( .testTarget( name: "MapLibreSwiftUITests", dependencies: [ - "MapLibreSwiftUI" + "MapLibreSwiftUI", + .product(name: "MockableTest", package: "Mockable"), + .product(name: "SnapshotTesting", package: "swift-snapshot-testing") ] ), .testTarget( diff --git a/Sources/MapLibreSwiftUI/Examples/Camera.swift b/Sources/MapLibreSwiftUI/Examples/Camera.swift index 5bfec4e..4d4e483 100644 --- a/Sources/MapLibreSwiftUI/Examples/Camera.swift +++ b/Sources/MapLibreSwiftUI/Examples/Camera.swift @@ -7,9 +7,14 @@ struct CameraDirectManipulationPreview: View { @State private var camera = MapViewCamera.center(switzerland, zoom: 4) let styleURL: URL + var onStyleLoaded: (() -> Void)? = nil var body: some View { MapView(styleURL: styleURL, camera: $camera) + .onStyleLoaded { _ in + print("Style is loaded") + onStyleLoaded?() + } .overlay(alignment: .bottom, content: { Text("\(String(describing: camera.state)) z \(camera.zoom)") .padding() @@ -22,19 +27,16 @@ struct CameraDirectManipulationPreview: View { .padding(.bottom, 42) }) .task { - try! await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC) + try? await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC) camera = MapViewCamera.center(switzerland, zoom: 6) } } } -struct Camera_Previews: PreviewProvider { - static var previews: some View { - let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! - - CameraDirectManipulationPreview(styleURL: demoTilesURL) - .ignoresSafeArea(.all) - .previewDisplayName("Camera Binding") - } +#Preview("Camera Preview") { + CameraDirectManipulationPreview( + styleURL: URL(string: "https://demotiles.maplibre.org/style.json")! + ) + .ignoresSafeArea(.all) } diff --git a/Sources/MapLibreSwiftUI/Examples/Layers.swift b/Sources/MapLibreSwiftUI/Examples/Layers.swift index 31ab9ab..a6b34f7 100644 --- a/Sources/MapLibreSwiftUI/Examples/Layers.swift +++ b/Sources/MapLibreSwiftUI/Examples/Layers.swift @@ -3,89 +3,91 @@ import MapLibre import MapLibreSwiftDSL import SwiftUI -struct Layer_Previews: PreviewProvider { - static var previews: some View { - let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! +let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! - // A collection of points with various - // attributes - let pointSource = ShapeSource(identifier: "points") { - // Uses the DSL to quickly construct point features inline - MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 51.47778, longitude: -0.00139)) +// A collection of points with various +// attributes +let pointSource = ShapeSource(identifier: "points") { + // Uses the DSL to quickly construct point features inline + MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 51.47778, longitude: -0.00139)) - MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 0, longitude: 0)) { feature in - feature.attributes["icon"] = "missing" - feature.attributes["heading"] = 45 - } + MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 0, longitude: 0)) { feature in + feature.attributes["icon"] = "missing" + feature.attributes["heading"] = 45 + } - MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 39.02001, longitude: 1.482148)) { feature in - feature.attributes["icon"] = "club" - feature.attributes["heading"] = 145 - } - } + MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 39.02001, longitude: 1.482148)) { feature in + feature.attributes["icon"] = "club" + feature.attributes["heading"] = 145 + } +} - MapView(styleURL: demoTilesURL) { - // Silly example: a background layer on top of everything to create a tint effect - BackgroundLayer(identifier: "rose-colored-glasses") - .backgroundColor(constant: .systemPink.withAlphaComponent(0.3)) - .renderAboveOthers() - } - .ignoresSafeArea(.all) - .previewDisplayName("Rose Tint") +#Preview("Rose Tint") { + MapView(styleURL: demoTilesURL) { + // Silly example: a background layer on top of everything to create a tint effect + BackgroundLayer(identifier: "rose-colored-glasses") + .backgroundColor(constant: .systemPink.withAlphaComponent(0.3)) + .renderAboveOthers() + } + .ignoresSafeArea(.all) +} - MapView(styleURL: demoTilesURL) { - // Simple symbol layer demonstration with an icon - SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) - .iconImage(constant: UIImage(systemName: "mappin")!) - } - .ignoresSafeArea(.all) - .previewDisplayName("Simple Symbol") +#Preview("Simple Symbol") { + MapView(styleURL: demoTilesURL) { + // Simple symbol layer demonstration with an icon + SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) + .iconImage(constant: UIImage(systemName: "mappin")!) + } + .ignoresSafeArea(.all) +} - MapView(styleURL: demoTilesURL) { - // Simple symbol layer demonstration with an icon - SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) - .iconImage(constant: UIImage(systemName: "location.north.circle.fill")!) - .iconRotation(constant: 45) - } - .ignoresSafeArea(.all) - .previewDisplayName("Rotated Symbols (Const)") +#Preview("Rotated Symbols (Const)") { + MapView(styleURL: demoTilesURL) { + // Simple symbol layer demonstration with an icon + SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) + .iconImage(constant: UIImage(systemName: "location.north.circle.fill")!) + .iconRotation(constant: 45) + } + .ignoresSafeArea(.all) +} - MapView(styleURL: demoTilesURL) { - // Simple symbol layer demonstration with an icon - SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) - .iconImage(constant: UIImage(systemName: "location.north.circle.fill")!) - .iconRotation(featurePropertyNamed: "heading") - } - .ignoresSafeArea(.all) - .previewDisplayName("Rotated Symbols (Dynamic)") - - MapView(styleURL: demoTilesURL) { - // Simple symbol layer demonstration with an icon - CircleStyleLayer(identifier: "simple-circles", source: pointSource) - .radius(constant: 16) - .color(constant: .systemRed) - .strokeWidth(constant: 2) - .strokeColor(constant: .white) - - SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) - .iconImage(constant: UIImage(systemName: "mappin")!.withRenderingMode(.alwaysTemplate)) - .iconColor(constant: .white) - } +#Preview("Rotated Symbols (Dynamic)") { + MapView(styleURL: demoTilesURL) { + // Simple symbol layer demonstration with an icon + SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) + .iconImage(constant: UIImage(systemName: "location.north.circle.fill")!) + .iconRotation(featurePropertyNamed: "heading") + } .ignoresSafeArea(.all) - .previewDisplayName("Circles with Symbols") +} - // FIXME: This appears to be broken upstream; waiting for a new release -// MapView(styleURL: demoTilesURL) { -// // Simple symbol layer demonstration with an icon -// SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) -// .iconImage(attribute: "icon", -// mappings: [ -// "missing": UIImage(systemName: "mappin.slash")!, -// "club": UIImage(systemName: "figure.dance")! -// ], -// default: UIImage(systemName: "mappin")!) -// } -// .edgesIgnoringSafeArea(.all) -// .previewDisplayName("Multiple Symbol Icons") +#Preview("Circles with Symbols") { + MapView(styleURL: demoTilesURL) { + // Simple symbol layer demonstration with an icon + CircleStyleLayer(identifier: "simple-circles", source: pointSource) + .radius(constant: 16) + .color(constant: .systemRed) + .strokeWidth(constant: 2) + .strokeColor(constant: .white) + + SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) + .iconImage(constant: UIImage(systemName: "mappin")!.withRenderingMode(.alwaysTemplate)) + .iconColor(constant: .white) } + .ignoresSafeArea(.all) } + +// TODO: Fixme +//#Preview("Multiple Symbol Icons") { +// MapView(styleURL: demoTilesURL) { +// // Simple symbol layer demonstration with an icon +// SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) +// .iconImage(attribute: "icon", +// mappings: [ +// "missing": UIImage(systemName: "mappin.slash")!, +// "club": UIImage(systemName: "figure.dance")! +// ], +// default: UIImage(systemName: "mappin")!) +// } +// .edgesIgnoringSafeArea(.all) +//} diff --git a/Sources/MapLibreSwiftUI/Examples/Other.swift b/Sources/MapLibreSwiftUI/Examples/Other.swift index 0e7dec9..b4b1343 100644 --- a/Sources/MapLibreSwiftUI/Examples/Other.swift +++ b/Sources/MapLibreSwiftUI/Examples/Other.swift @@ -3,10 +3,8 @@ import MapLibre import MapLibreSwiftDSL import SwiftUI -struct Other_Previews: PreviewProvider { - static var previews: some View { - let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! - +#Preview("Unsafe MapView Modifier") { + MapView(styleURL: demoTilesURL) { // A collection of points with various // attributes let pointSource = ShapeSource(identifier: "points") { @@ -23,18 +21,14 @@ struct Other_Previews: PreviewProvider { feature.attributes["heading"] = 145 } } - - MapView(styleURL: demoTilesURL) { - // Demonstrates how to use the unsafeMapModifier to set MLNMapView properties that have not been exposed as modifiers yet. - SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) - .iconImage(constant: UIImage(systemName: "mappin")!) - } - .unsafeMapViewModifier({ mapView in - // Not all properties have modifiers yet. Until they do, you can use this 'escape hatch' to the underlying MLNMapView. Be careful: if you modify properties that the DSL controls already, they may be overridden. This modifier is a "hack", not a final function. - mapView.logoView.isHidden = false - mapView.compassViewPosition = .topLeft - }) - .ignoresSafeArea(.all) - .previewDisplayName("Unsafe MapView Modifier") + + // Demonstrates how to use the unsafeMapModifier to set MLNMapView properties that have not been exposed as modifiers yet. + SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) + .iconImage(constant: UIImage(systemName: "mappin")!) + } + .unsafeMapViewModifier { mapView in + // Not all properties have modifiers yet. Until they do, you can use this 'escape hatch' to the underlying MLNMapView. Be careful: if you modify properties that the DSL controls already, they may be overridden. This modifier is a "hack", not a final function. + mapView.logoView.isHidden = false + mapView.compassViewPosition = .topLeft } } diff --git a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNCameraChangeReason.swift b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNCameraChangeReason.swift index 6230bfb..40f206b 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNCameraChangeReason.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNCameraChangeReason.swift @@ -3,8 +3,9 @@ import MapLibre extension MLNCameraChangeReason { - /// Get the last value from the MLNCameraChangeReason option set. - public var lastValue: MLNCameraChangeReason { + /// Get the MLNCameraChangeReason from the option set with the largest + /// bitwise value. + public var largestBitwiseReason: MLNCameraChangeReason { // Start at 1 var mask: UInt = 1 var result: UInt = 0 diff --git a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCamera.swift b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCamera.swift new file mode 100644 index 0000000..b19012e --- /dev/null +++ b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCamera.swift @@ -0,0 +1,20 @@ +import Foundation +import CoreLocation +import MapLibre +import Mockable + +@Mockable +protocol MLNMapViewCamera: AnyObject { + var userTrackingMode: MLNUserTrackingMode { get set } + var minimumPitch: CGFloat { get set } + var maximumPitch: CGFloat { get set } + func setCenter(_ coordinate: CLLocationCoordinate2D, + zoomLevel: Double, + direction: CLLocationDirection, + animated: Bool) + func setZoomLevel(_ zoomLevel: Double, animated: Bool) +} + +extension MLNMapView: MLNMapViewCamera { + // No definition +} diff --git a/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift b/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift index f113599..e9302df 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI import MapLibre extension MapView { @@ -44,21 +45,35 @@ extension MapView { return } + // Process the gesture into a context response. + let context = processContextFromGesture(mapView, gesture: gesture, sender: sender) + // Run the context through the gesture held on the MapView (emitting to the MapView modifier). + gesture.onChange(context) + } + + /// Convert the sender data into a MapGestureContext + /// + /// - Parameters: + /// - mapView: The mapview that's emitting the gesture. + /// - gesture: The gesture definition for this event. + /// - sender: The UIKit gesture emitting from the map view. + /// - Returns: The calculated context from the sending UIKit gesture + func processContextFromGesture(_ mapView: MLNMapView, gesture: MapGesture, sender: UIGestureRecognizerProtocol) -> MapGestureContext { // Build the context of the gesture's event. var point: CGPoint switch gesture.method { case .tap(numberOfTaps: let numberOfTaps): + // Calculate the CGPoint of the last gesture tap point = sender.location(ofTouch: numberOfTaps - 1, in: mapView) case .longPress: + // Calculate the CGPoint of the long process gesture. point = sender.location(in: mapView) } - let context = MapGestureContext(gestureMethod: gesture.method, - state: sender.state, - point: point, - coordinate: mapView.convert(point, toCoordinateFrom: mapView)) - - gesture.onChange(context) + return MapGestureContext(gestureMethod: gesture.method, + state: sender.state, + point: point, + coordinate: mapView.convert(point, toCoordinateFrom: mapView)) } } diff --git a/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizer.swift b/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizer.swift new file mode 100644 index 0000000..267a901 --- /dev/null +++ b/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizer.swift @@ -0,0 +1,13 @@ +import UIKit +import Mockable + +@Mockable +protocol UIGestureRecognizerProtocol: AnyObject { + var state: UIGestureRecognizer.State { get } + func location(in view: UIView?) -> CGPoint + func location(ofTouch touchIndex: Int, in view: UIView?) -> CGPoint +} + +extension UIGestureRecognizer: UIGestureRecognizerProtocol { + // No definition +} diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index da9d840..159d7f5 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -9,7 +9,9 @@ public struct MapView: UIViewRepresentable { let styleSource: MapStyleSource let userLayers: [StyleLayerDefinition] + var gestures = [MapGesture]() + var onStyleLoaded: ((MLNStyle) -> Void)? /// 'Escape hatch' to MLNMapView until we have more modifiers. /// See ``unsafeMapViewModifier(_:)`` @@ -60,6 +62,9 @@ public struct MapView: UIViewRepresentable { // TODO: Make this settable via a modifier mapView.logoView.isHidden = true + // Link the style loaded to the coordinator that emits the delegate event. + context.coordinator.onStyleLoaded = onStyleLoaded + // Add all gesture recognizers for gesture in gestures { registerGesture(mapView, context, gesture: gesture) @@ -89,17 +94,13 @@ public struct MapView: UIViewRepresentable { } } -struct MapView_Previews: PreviewProvider { - static var previews: some View { - let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! - - MapView(styleURL: demoTilesURL) - .ignoresSafeArea(.all) - .previewDisplayName("Vanilla Map") - - // For a larger selection of previews, - // check out the Examples directory, which - // has a wide variety of previews, - // organized into (hopefully) useful groups - } +#Preview { + MapView(styleURL: demoTilesURL) + .ignoresSafeArea(.all) + .previewDisplayName("Vanilla Map") + + // For a larger selection of previews, + // check out the Examples directory, which + // has a wide variety of previews, + // organized into (hopefully) useful groups } diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 949a079..cda8aaa 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -12,7 +12,8 @@ public class MapViewCoordinator: NSObject { // every update cycle so we can avoid unnecessary updates private var snapshotUserLayers: [StyleLayerDefinition] = [] private var snapshotCamera: MapViewCamera? - private var onGesture: (MLNMapView, UIGestureRecognizer) -> Void + var onStyleLoaded: ((MLNStyle) -> Void)? + var onGesture: (MLNMapView, UIGestureRecognizer) -> Void init(parent: MapView, onGesture: @escaping (MLNMapView, UIGestureRecognizer) -> Void) { @@ -32,7 +33,7 @@ public class MapViewCoordinator: NSObject { // MARK: - Coordinator API - Camera + Manipulation - func updateCamera(mapView: MLNMapView, camera: MapViewCamera, animated: Bool) { + func updateCamera(mapView: MLNMapViewCamera, camera: MapViewCamera, animated: Bool) { guard camera != snapshotCamera else { // No action - camera has not changed. return @@ -169,6 +170,7 @@ public class MapViewCoordinator: NSObject { extension MapViewCoordinator: MLNMapViewDelegate { public func mapView(_ mapView: MLNMapView, didFinishLoading mglStyle: MLNStyle) { + onStyleLoaded?(mglStyle) addLayers(to: mglStyle) } diff --git a/Sources/MapLibreSwiftUI/MapViewModifiers.swift b/Sources/MapLibreSwiftUI/MapViewModifiers.swift index 2449f65..174cb44 100644 --- a/Sources/MapLibreSwiftUI/MapViewModifiers.swift +++ b/Sources/MapLibreSwiftUI/MapViewModifiers.swift @@ -7,6 +7,16 @@ import MapLibre extension MapView { + /// Perform an action when the map view has loaded its style. + /// + /// - Parameter perform: The action to perform with the loaded style. + /// - Returns: The modified map view. + public func onStyleLoaded(_ perform: @escaping (MLNStyle) -> Void) -> MapView { + var newMapView = self + newMapView.onStyleLoaded = perform + return newMapView + } + /// Allows you to set properties of the underlying MLNMapView directly /// in cases where these have not been ported to DSL yet. /// Use this function to modify various properties of the MLNMapView instance. diff --git a/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift b/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift index 3c74e76..7a444f9 100644 --- a/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift +++ b/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift @@ -14,7 +14,7 @@ public class MapGesture: NSObject { /// /// - Parameters: /// - minimumDuration: The minimum duration of the press in seconds. - case longPress(minimumDuration: Double) + case longPress(minimumDuration: Double = 0.5) } /// The Gesture's method, this is used to register it for the correct user interaction on the MapView. diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift index a52ca90..a2b5039 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift @@ -15,12 +15,12 @@ public enum CameraChangeReason: Hashable { /// Initialize a Swift CameraChangeReason from the MLN NSOption. /// - /// This method will only show the last reason. If you need a full history of the full bit range, + /// This method will only show the largest reason. If you need a full history of the full bit range, /// use MLNCameraChangeReason directly /// /// - Parameter mlnCameraChangeReason: The camera change reason options list from the MapLibre MapViewDelegate public init?(_ mlnCameraChangeReason: MLNCameraChangeReason) { - switch mlnCameraChangeReason.lastValue { + switch mlnCameraChangeReason.largestBitwiseReason { case .programmatic: self = .programmatic diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift index 977c914..1a2b9c3 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift @@ -34,17 +34,17 @@ extension CameraState: CustomDebugStringConvertible { switch self { case .centered(onCenter: let onCenter): - return ".center(onCenter: \(onCenter)" + return "CameraState.center(onCenter: \(onCenter)" case .trackingUserLocation: - return ".trackingUserLocation" + return "CameraState.trackingUserLocation" case .trackingUserLocationWithHeading: - return ".trackingUserLocationWithHeading" + return "CameraState.trackingUserLocationWithHeading" case .trackingUserLocationWithCourse: - return ".trackingUserLocationWithCourse" + return "CameraState.trackingUserLocationWithCourse" case .rect(northeast: let northeast, southwest: let southwest): - return ".rect(northeast: \(northeast), southwest: \(southwest))" + return "CameraState.rect(northeast: \(northeast), southwest: \(southwest))" case .showcase(shapeCollection: let shapeCollection): - return ".showcase(shapeCollection: \(shapeCollection))" + return "CameraState.showcase(shapeCollection: \(shapeCollection))" } } } diff --git a/Tests/MapLibreSwiftUITests/Examples/CameraPreviewTests.swift b/Tests/MapLibreSwiftUITests/Examples/CameraPreviewTests.swift new file mode 100644 index 0000000..5fafc7b --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Examples/CameraPreviewTests.swift @@ -0,0 +1,14 @@ +import XCTest +import SnapshotTesting +@testable import MapLibreSwiftUI + +final class CameraPreviewTests: XCTestCase { + + func testCameraPreview() { + assertView(named: "CameraPreview") { + CameraDirectManipulationPreview( + styleURL: URL(string: "https://demotiles.maplibre.org/style.json")! + ) + } + } +} diff --git a/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift b/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift new file mode 100644 index 0000000..e22e159 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift @@ -0,0 +1,86 @@ +import XCTest +import MapLibre +import MapLibreSwiftDSL +@testable import MapLibreSwiftUI + +final class LayerPreviewTests: XCTestCase { + + let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! + + // A collection of points with various + // attributes + let pointSource = ShapeSource(identifier: "points") { + // Uses the DSL to quickly construct point features inline + MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 51.47778, longitude: -0.00139)) + + MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 0, longitude: 0)) { feature in + feature.attributes["icon"] = "missing" + feature.attributes["heading"] = 45 + } + + MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 39.02001, longitude: 1.482148)) { feature in + feature.attributes["icon"] = "club" + feature.attributes["heading"] = 145 + } + } + + func testRoseTint() { + assertView { + MapView(styleURL: demoTilesURL) { + // Silly example: a background layer on top of everything to create a tint effect + BackgroundLayer(identifier: "rose-colored-glasses") + .backgroundColor(constant: .systemPink.withAlphaComponent(0.3)) + .renderAboveOthers() + } + } + } + + func testSimpleSymbol() { + assertView { + MapView(styleURL: demoTilesURL) { + // Simple symbol layer demonstration with an icon + SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) + .iconImage(constant: UIImage(systemName: "mappin")!) + } + } + } + + func testRotatedSymbolConst() { + assertView { + MapView(styleURL: demoTilesURL) { + // Simple symbol layer demonstration with an icon + SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) + .iconImage(constant: UIImage(systemName: "location.north.circle.fill")!) + .iconRotation(constant: 45) + } + } + } + + func testRotatedSymboleDynamic() { + assertView { + MapView(styleURL: demoTilesURL) { + // Simple symbol layer demonstration with an icon + SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) + .iconImage(constant: UIImage(systemName: "location.north.circle.fill")!) + .iconRotation(featurePropertyNamed: "heading") + } + } + } + + func testCirclesWithSymbols() { + assertView { + MapView(styleURL: demoTilesURL) { + // Simple symbol layer demonstration with an icon + CircleStyleLayer(identifier: "simple-circles", source: pointSource) + .radius(constant: 16) + .color(constant: .systemRed) + .strokeWidth(constant: 2) + .strokeColor(constant: .white) + + SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) + .iconImage(constant: UIImage(systemName: "mappin")!.withRenderingMode(.alwaysTemplate)) + .iconColor(constant: .white) + } + } + } +} diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/CameraPreviewTests/testCameraPreview.CameraPreview.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/CameraPreviewTests/testCameraPreview.CameraPreview.png new file mode 100644 index 0000000..76aa78a Binary files /dev/null and b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/CameraPreviewTests/testCameraPreview.CameraPreview.png differ diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testCirclesWithSymbols.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testCirclesWithSymbols.1.png new file mode 100644 index 0000000..22296ce Binary files /dev/null and b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testCirclesWithSymbols.1.png differ diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRoseTint.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRoseTint.1.png new file mode 100644 index 0000000..22296ce Binary files /dev/null and b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRoseTint.1.png differ diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRotatedSymbolConst.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRotatedSymbolConst.1.png new file mode 100644 index 0000000..22296ce Binary files /dev/null and b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRotatedSymbolConst.1.png differ diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRotatedSymboleDynamic.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRotatedSymboleDynamic.1.png new file mode 100644 index 0000000..22296ce Binary files /dev/null and b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRotatedSymboleDynamic.1.png differ diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testSimpleSymbol.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testSimpleSymbol.1.png new file mode 100644 index 0000000..22296ce Binary files /dev/null and b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testSimpleSymbol.1.png differ diff --git a/Tests/MapLibreSwiftUITests/Extensions/CoreLocation/CLLocationCoordinate2D.swift b/Tests/MapLibreSwiftUITests/Extensions/CoreLocation/CLLocationCoordinate2D.swift new file mode 100644 index 0000000..2a7e9a2 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Extensions/CoreLocation/CLLocationCoordinate2D.swift @@ -0,0 +1,16 @@ +import XCTest +import CoreLocation +@testable import MapLibreSwiftUI + +final class CLLocationCoordinate2DTests: XCTestCase { + + func testHashable() { + let coordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) + + var hasher = Hasher() + coordinate.hash(into: &hasher) + let hashedValue = hasher.finalize() + + XCTAssertEqual(hashedValue, coordinate.hashValue) + } +} diff --git a/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift new file mode 100644 index 0000000..417ac34 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift @@ -0,0 +1,78 @@ +import XCTest +import MockableTest +import MapLibre +@testable import MapLibreSwiftUI + +final class MapViewGestureTests: XCTestCase { + + let maplibreMapView = MLNMapView() + let mapView = MapView(styleURL: URL(string: "https://maplibre.org")!) + + // MARK: Gesture View Modifiers + + func testMapViewOnTapGestureModifier() { + let newMapView = mapView.onTapMapGesture { _ in } + + XCTAssertEqual(newMapView.gestures.first?.method, .tap()) + } + + func testMapViewOnLongPressGestureModifier() { + let newMapView = mapView.onLongPressMapGesture { _ in } + + XCTAssertEqual(newMapView.gestures.first?.method, .longPress()) + } + + // MARK: Gesture Processing + + func testTapGesture() { + let gesture = MapGesture(method: .tap(numberOfTaps: 2)) { _ in + // No capture + } + + let mockTapGesture = MockUIGestureRecognizerProtocol() + + given(mockTapGesture) + .state.willReturn(.ended) + + given(mockTapGesture) + .location(ofTouch: .value(1), in: .any) + .willReturn(CGPoint(x: 10, y: 10)) + + let result = mapView.processContextFromGesture(maplibreMapView, + gesture: gesture, + sender: mockTapGesture) + + XCTAssertEqual(result.gestureMethod, .tap(numberOfTaps: 2)) + XCTAssertEqual(result.point, CGPoint(x: 10, y: 10)) + + // TODO: Delete this? The MLNMapView is technically converting something. Probably not reliably, but it could still be useful to track. + XCTAssertEqual(result.coordinate.latitude, 15, accuracy: 1) + XCTAssertEqual(result.coordinate.longitude, -15, accuracy: 1) + } + + func testLongPressGesture() { + let gesture = MapGesture(method: .longPress(minimumDuration: 1)) { _ in + // No capture + } + + let mockTapGesture = MockUIGestureRecognizerProtocol() + + given(mockTapGesture) + .state.willReturn(.ended) + + given(mockTapGesture) + .location(in: .any) + .willReturn(CGPoint(x: 10, y: 10)) + + let result = mapView.processContextFromGesture(maplibreMapView, + gesture: gesture, + sender: mockTapGesture) + + XCTAssertEqual(result.gestureMethod, .longPress(minimumDuration: 1)) + XCTAssertEqual(result.point, CGPoint(x: 10, y: 10)) + + // TODO: Delete this? The MLNMapView is technically converting something. Probably not reliably, but it could still be useful to track. + XCTAssertEqual(result.coordinate.latitude, 15, accuracy: 1) + XCTAssertEqual(result.coordinate.longitude, -15, accuracy: 1) + } +} diff --git a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift new file mode 100644 index 0000000..0a7c45c --- /dev/null +++ b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift @@ -0,0 +1,172 @@ +import XCTest +import MockableTest +import CoreLocation +@testable import MapLibreSwiftUI + +final class MapViewCoordinatorCameraTests: XCTestCase { + + var maplibreMapView: MockMLNMapViewCamera! + var mapView: MapView! + var coordinator: MapView.Coordinator! + + override func setUp() async throws { + maplibreMapView = MockMLNMapViewCamera() + mapView = MapView(styleURL: URL(string: "https://maplibre.org")!) + coordinator = MapView.Coordinator(parent: mapView) { _, _ in + // No action + } + } + + func testUnchangedCamera() { + let camera: MapViewCamera = .default() + + coordinator.updateCamera(mapView: maplibreMapView, camera: camera, animated: false) + // Run a second update. + coordinator.updateCamera(mapView: maplibreMapView, camera: camera, animated: false) + + // Note all of the actions only allow 1 count of set even though we've run the action + // twice. + verify(maplibreMapView) + .userTrackingMode(newValue: .value(.none)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setCenter(.value(MapViewCamera.Defaults.coordinate), + zoomLevel: .value(10), + direction: .value(0), + animated: .value(false)) + .called(count: 1) + + verify(maplibreMapView) + .minimumPitch(newValue: .value(0)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .maximumPitch(newValue: .value(60)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setZoomLevel(.any, animated: .any) + .called(count: 0) + } + + func testCenterCameraUpdate() { + let coordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) + let newCamera: MapViewCamera = .center(coordinate, zoom: 13) + + coordinator.updateCamera(mapView: maplibreMapView, camera: newCamera, animated: false) + + verify(maplibreMapView) + .userTrackingMode(newValue: .value(.none)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setCenter(.value(coordinate), + zoomLevel: .value(13), + direction: .value(0), + animated: .value(false)) + .called(count: 1) + + verify(maplibreMapView) + .minimumPitch(newValue: .value(0)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .maximumPitch(newValue: .value(60)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setZoomLevel(.any, animated: .any) + .called(count: 0) + } + + func testUserTrackingCameraUpdate() { + let newCamera: MapViewCamera = .trackUserLocation() + + coordinator.updateCamera(mapView: maplibreMapView, camera: newCamera, animated: false) + + verify(maplibreMapView) + .userTrackingMode(newValue: .value(.follow)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setCenter(.any, + zoomLevel: .any, + direction: .any, + animated: .any) + .called(count: 0) + + verify(maplibreMapView) + .minimumPitch(newValue: .value(0)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .maximumPitch(newValue: .value(60)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setZoomLevel(.value(10), animated: .value(false)) + .called(count: 1) + } + + func testUserTrackingWithCourseCameraUpdate() { + let newCamera: MapViewCamera = .trackUserLocationWithCourse() + + coordinator.updateCamera(mapView: maplibreMapView, camera: newCamera, animated: false) + + verify(maplibreMapView) + .userTrackingMode(newValue: .value(.followWithCourse)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setCenter(.any, + zoomLevel: .any, + direction: .any, + animated: .any) + .called(count: 0) + + verify(maplibreMapView) + .minimumPitch(newValue: .value(0)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .maximumPitch(newValue: .value(60)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setZoomLevel(.value(10), animated: .value(false)) + .called(count: 1) + } + + func testUserTrackingWithHeadingUpdate() { + let newCamera: MapViewCamera = .trackUserLocationWithHeading() + + coordinator.updateCamera(mapView: maplibreMapView, camera: newCamera, animated: false) + + verify(maplibreMapView) + .userTrackingMode(newValue: .value(.followWithHeading)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setCenter(.any, + zoomLevel: .any, + direction: .any, + animated: .any) + .called(count: 0) + + verify(maplibreMapView) + .minimumPitch(newValue: .value(0)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .maximumPitch(newValue: .value(60)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setZoomLevel(.value(10), animated: .value(false)) + .called(count: 1) + } + + // TODO: Test Rect & Showcase once we build it! + +} diff --git a/Tests/MapLibreSwiftUITests/Models/Gesture/MapGestureTests.swift b/Tests/MapLibreSwiftUITests/Models/Gesture/MapGestureTests.swift new file mode 100644 index 0000000..f105153 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/Gesture/MapGestureTests.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import MapLibreSwiftUI + +final class MapGestureTests: XCTestCase { + + func testTapGestureDefaults() { + let gesture = MapGesture(method: .tap(), + onChange: { _ in }) + + XCTAssertEqual(gesture.method, .tap()) + XCTAssertNil(gesture.gestureRecognizer) + } + + func testTapGesture() { + let gesture = MapGesture(method: .tap(numberOfTaps: 3), + onChange: { _ in }) + + XCTAssertEqual(gesture.method, .tap(numberOfTaps: 3)) + XCTAssertNil(gesture.gestureRecognizer) + } + + func testLongPressGestureDefaults() { + let gesture = MapGesture(method: .longPress(), + onChange: { _ in }) + + XCTAssertEqual(gesture.method, .longPress()) + XCTAssertNil(gesture.gestureRecognizer) + } + + func testLongPressGesture() { + let gesture = MapGesture(method: .longPress(minimumDuration: 3), + onChange: { _ in }) + + XCTAssertEqual(gesture.method, .longPress(minimumDuration: 3)) + XCTAssertNil(gesture.gestureRecognizer) + } +} diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraChangeReasonTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraChangeReasonTests.swift index c197060..cd265c1 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraChangeReasonTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraChangeReasonTests.swift @@ -4,9 +4,53 @@ import MapLibre final class CameraChangeReasonTests: XCTestCase { + func testProgrammatic() { + let mlnReason: MLNCameraChangeReason = [.programmatic] + XCTAssertEqual(CameraChangeReason(mlnReason), .programmatic) + } + + func testTransitionCancelled() { + let mlnReason: MLNCameraChangeReason = [.transitionCancelled] + XCTAssertEqual(CameraChangeReason(mlnReason), .transitionCancelled) + } + + func testResetNorth() { + let mlnReason: MLNCameraChangeReason = [.programmatic, .resetNorth] + XCTAssertEqual(CameraChangeReason(mlnReason), .resetNorth) + } + + func testGesturePan() { + let mlnReason: MLNCameraChangeReason = [.gesturePan] + XCTAssertEqual(CameraChangeReason(mlnReason), .gesturePan) + } + + func testGesturePinch() { + let mlnReason: MLNCameraChangeReason = [.gesturePinch] + XCTAssertEqual(CameraChangeReason(mlnReason), .gesturePinch) + } + + func testGestureRotate() { + let mlnReason: MLNCameraChangeReason = [.gestureRotate] + XCTAssertEqual(CameraChangeReason(mlnReason), .gestureRotate) + } + + func testGestureTilt() { + let mlnReason: MLNCameraChangeReason = [.programmatic, .gestureTilt] + XCTAssertEqual(CameraChangeReason(mlnReason), .gestureTilt) + } + + func testGestureZoomIn() { + let mlnReason: MLNCameraChangeReason = [.programmatic, .gestureZoomIn] + XCTAssertEqual(CameraChangeReason(mlnReason), .gestureZoomIn) + } + + func testGestureZoomOut() { + let mlnReason: MLNCameraChangeReason = [.programmatic, .gestureZoomOut] + XCTAssertEqual(CameraChangeReason(mlnReason), .gestureZoomOut) + } + func testGestureOneFingerZoom() { let mlnReason: MLNCameraChangeReason = [.programmatic, .gestureOneFingerZoom] XCTAssertEqual(CameraChangeReason(mlnReason), .gestureOneFingerZoom) } - } diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift index 4b44676..4fb56d1 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift @@ -8,21 +8,25 @@ final class CameraStateTests: XCTestCase { let expectedCoordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) let state: CameraState = .centered(onCenter: expectedCoordinate) XCTAssertEqual(state, .centered(onCenter: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4))) + XCTAssertEqual(String(describing: state), "CameraState.center(onCenter: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4)") } func testTrackingUserLocation() { let state: CameraState = .trackingUserLocation XCTAssertEqual(state, .trackingUserLocation) + XCTAssertEqual(String(describing: state), "CameraState.trackingUserLocation") } func testTrackingUserLocationWithHeading() { let state: CameraState = .trackingUserLocationWithHeading XCTAssertEqual(state, .trackingUserLocationWithHeading) + XCTAssertEqual(String(describing: state), "CameraState.trackingUserLocationWithHeading") } func testTrackingUserLocationWithCourse() { let state: CameraState = .trackingUserLocationWithCourse XCTAssertEqual(state, .trackingUserLocationWithCourse) + XCTAssertEqual(String(describing: state), "CameraState.trackingUserLocationWithCourse") } func testRect() { @@ -31,6 +35,9 @@ final class CameraStateTests: XCTestCase { let state: CameraState = .rect(northeast: northeast, southwest: southwest) XCTAssertEqual(state, .rect(northeast: northeast, southwest: southwest)) + XCTAssertEqual( + String(describing: state), + "CameraState.rect(northeast: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4), southwest: CLLocationCoordinate2D(latitude: 34.5, longitude: 45.6))") } } diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift index 6e97796..480324a 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift @@ -1,6 +1,38 @@ import XCTest +import CoreLocation @testable import MapLibreSwiftUI final class MapViewCameraTests: XCTestCase { + + + func testCenterCamera() { + let expectedCoordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) + let state: CameraState = .centered(onCenter: expectedCoordinate) + XCTAssertEqual(state, .centered(onCenter: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4))) + } + + func testTrackingUserLocation() { + let state: CameraState = .trackingUserLocation + XCTAssertEqual(state, .trackingUserLocation) + } + + func testTrackingUserLocationWithHeading() { + let state: CameraState = .trackingUserLocationWithHeading + XCTAssertEqual(state, .trackingUserLocationWithHeading) + } + + func testTrackingUserLocationWithCourse() { + let state: CameraState = .trackingUserLocationWithCourse + XCTAssertEqual(state, .trackingUserLocationWithCourse) + } + + func testRect() { + let northeast = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) + let southwest = CLLocationCoordinate2D(latitude: 34.5, longitude: 45.6) + + let state: CameraState = .rect(northeast: northeast, southwest: southwest) + XCTAssertEqual(state, .rect(northeast: northeast, southwest: southwest)) + } + } diff --git a/Tests/MapLibreSwiftUITests/Support/XCTestAssertView.swift b/Tests/MapLibreSwiftUITests/Support/XCTestAssertView.swift new file mode 100644 index 0000000..27a901b --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Support/XCTestAssertView.swift @@ -0,0 +1,52 @@ +import SwiftUI +import XCTest +import SnapshotTesting +import MapLibreSwiftUI + +// TODO: This is a WIP that needs some additional eyes +extension XCTestCase { + + func assertView( + named name: String? = nil, + record: Bool = false, + frame: CGSize = CGSize(width: 430, height: 932), + expectation: XCTestExpectation? = nil, + @ViewBuilder content: () -> Content, + file: StaticString = #file, + testName: String = #function, + line: UInt = #line + ) { + + let view = content() + .frame(width: frame.width, height: frame.height) + + assertSnapshot(matching: view, + as: .image(precision: 0.9, perceptualPrecision: 0.95), + named: name, + record: record, + file: file, + testName: testName, + line: line) + } +} + +// TODO: Figure this out, seems like the exp is being blocked or the map views onStyleLoaded is never run within the test context. +extension Snapshotting { + static func wait( + exp: XCTestExpectation, + timeout: TimeInterval, + on strategy: Self + ) -> Self { + Self( + pathExtension: strategy.pathExtension, + diffing: strategy.diffing, + asyncSnapshot: { value in + Async { callback in + _ = XCTWaiter.wait(for: [exp], timeout: timeout) + strategy.snapshot(value).run(callback) + } + } + ) + } +} +