Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ This package requires Swift 6.1 or higher (at least Xcode 15), and compiles on i

```swift
dependencies: [
.package(url: "https://github.com/Outdooractive/gis-tools", from: "1.13.6"),
.package(url: "https://github.com/Outdooractive/gis-tools", from: "2.0.0"),
],
targets: [
.target(name: "MyTarget", dependencies: [
Expand Down
22 changes: 22 additions & 0 deletions Tests/GISToolsTests/Algorithms/ConcaveHullTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ import Testing

struct ConcaveHullTests {

// MARK: - Reference tests

private static let concaveFixtures: [(name: String, maxEdgeKm: Double)] = [
("LineWithNoise", 200.0),
]

@Test(arguments: concaveFixtures)
private func turfConcaveHullFixture(_ fixture: (name: String, maxEdgeKm: Double)) async throws {
let mp = try TestData.multiPoint(package: "ConcaveHull", name: fixture.name)
let maxEdge = GISTool.convertToMeters(fixture.maxEdgeKm, .kilometers)

guard let result = mp.concaveHull(maxEdgeLength: maxEdge) else {
Issue.record("Expected non-nil concave hull for \(fixture.name)")
return
}

#expect(result.polygons.isNotEmpty, "\(fixture.name): hull has no polygons")
for poly in result.polygons {
#expect(poly.isValid, "\(fixture.name): hull polygon is invalid")
}
}

// Validates basic concave hull of points forming a rough circle.
@Test
func concaveHullBasic() async throws {
Expand Down
27 changes: 27 additions & 0 deletions Tests/GISToolsTests/Algorithms/ConvexHullTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ import Testing

struct ConvexHullTests {

// MARK: - Reference tests

private static let hullFixtures = [
"SquarePoints",
"RandomPoints",
"ThreePoints",
]

@Test(arguments: hullFixtures)
private func turfConvexHullFixture(_ name: String) async throws {
let mp = try TestData.multiPoint(package: "ConvexHull", name: name)

guard let result = mp.convexHull() else {
Issue.record("Expected non-nil convex hull for \(name)")
return
}

let resultArea = result.area
#expect(resultArea > 0, "\(name): hull area should be positive")

// Load expected and compare area
let expected = try TestData.polygon(package: "ConvexHull", name: name + "Result")
let ratio = resultArea / expected.area
#expect(ratio > 0.95 && ratio < 1.05,
"\(name): area ratio \(ratio) outside [0.95, 1.05]")
}

// Validates the convex hull of a square produces 5 coordinates (4 corners + closing) and contains a center point.
@Test
func convexHullSquare() async throws {
Expand Down
37 changes: 37 additions & 0 deletions Tests/GISToolsTests/Algorithms/DifferenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,43 @@ import Foundation

struct DifferenceTests {

// MARK: - Reference tests

private static let overlayFixtures = [
"DisjointSquares",
"FullyContained",
"LShapeOverlap",
]

private func loadOverlayPolygon(_ name: String, _ suffix: String) throws -> Polygon {
try TestData.polygon(package: "Overlay", name: name + suffix)
}

@Test(arguments: overlayFixtures)
private func turfDiffFixture(_ name: String) async throws {
let a = try loadOverlayPolygon(name, "A")
let b = try loadOverlayPolygon(name, "B")
let expected = try TestData.multiPolygon(package: "Overlay", name: name + "DiffABResult")

guard let result = a.difference(with: b) else {
if expected.polygons.isNotEmpty {
Issue.record("Expected non-nil difference for \(name)")
}
return
}

let resultArea = result.polygons.reduce(0) { $0 + $1.area }
let expectedArea = expected.polygons.reduce(0) { $0 + $1.area }
if expectedArea > 0 {
let ratio = resultArea / expectedArea
#expect(ratio > 0.95 && ratio < 1.05,
"\(name): area ratio \(ratio) outside [0.95, 1.05]")
}
else {
#expect(resultArea == 0, "\(name): expected empty but got area \(resultArea)")
}
}

// Validates that subtracting an overlapping square leaves an L-shaped polygon.
@Test
func overlappingSquares() async throws {
Expand Down
38 changes: 38 additions & 0 deletions Tests/GISToolsTests/Algorithms/IntersectionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,44 @@ import Foundation

struct IntersectionTests {

// MARK: - Reference tests

private static let overlayFixtures = [
"DisjointSquares",
"FullyContained",
"LShapeOverlap",
]

private func loadOverlayPolygon(_ name: String, _ suffix: String) throws -> Polygon {
try TestData.polygon(package: "Overlay", name: name + suffix)
}

@Test(arguments: overlayFixtures)
private func turfIntersectFixture(_ name: String) async throws {
let a = try loadOverlayPolygon(name, "A")
let b = try loadOverlayPolygon(name, "B")
let expectedJson = try TestData.stringFromFile(package: "Overlay", name: name + "IntersectResult")
let expected = try #require(MultiPolygon(jsonString: expectedJson), "Missing expected result for \(name)")

guard let result = a.intersection(with: b) else {
if expected.polygons.isNotEmpty {
Issue.record("Expected non-nil intersection for \(name)")
}
return
}

let resultArea = result.polygons.reduce(0) { $0 + $1.area }
let expectedArea = expected.polygons.reduce(0) { $0 + $1.area }
if expectedArea > 0 {
let ratio = resultArea / expectedArea
#expect(ratio > 0.80 && ratio < 1.20,
"\(name): area ratio \(ratio) outside [0.80, 1.20], result=\(resultArea), expected=\(expectedArea)")
}
else {
#expect(resultArea < 1000.0, "\(name): expected empty but got area \(resultArea)")
}
}

// Validates that two overlapping squares produce the correct intersection (a smaller square).
@Test
func overlappingSquares() async throws {
Expand Down
40 changes: 40 additions & 0 deletions Tests/GISToolsTests/Algorithms/MaskTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,46 @@ import Testing

struct MaskTests {

// MARK: - Reference tests

private static let maskFixtures = [
"SmallSquare",
"TwoSquares",
"LShapeMask",
]

@Test(arguments: maskFixtures)
private func turfMaskFixture(_ name: String) async throws {
let json = try TestData.stringFromFile(package: "Mask", name: name)
let expected = try TestData.multiPolygon(package: "Mask", name: name + "Result")

let geoJson: PolygonGeometry
if let poly = Polygon(jsonString: json) {
geoJson = poly
}
else if let mp = MultiPolygon(jsonString: json) {
geoJson = mp
}
else {
Issue.record("Could not load mask input for \(name)")
return
}

guard let result = geoJson.mask() else {
Issue.record("Expected non-nil mask for \(name)")
return
}

#expect(result.isValid, "\(name): mask result is invalid")
let resultArea = result.polygons.reduce(0) { $0 + $1.area }
let expectedArea = expected.polygons.reduce(0) { $0 + $1.area }
if expectedArea > 0 {
let ratio = resultArea / expectedArea
#expect(ratio > 0.9 && ratio < 1.1,
"\(name): area ratio \(ratio) outside [0.9, 1.1]")
}
}

/// A small square masked from the world produces a polygon with a hole (outer + 1 inner ring).
@Test
func squareMask() throws {
Expand Down
28 changes: 16 additions & 12 deletions Tests/GISToolsTests/Algorithms/SimplifyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,24 @@ import Testing

struct SimplifyTests {

// TODO: More tests
// https://github.com/Turfjs/turf/tree/master/packages/turf-simplify/test/in
// https://github.com/Turfjs/turf/tree/master/packages/turf-simplify/test/out

// Validates that invalid polygons return nil when simplified.
// Validates that invalid polygons are returned unmodified when simplified.
@Test
func invalidPolygons() async throws {
// TODO: Improve the polygon validity check

// let polygon1 = MultiPolygon([[[Coordinate3D(latitude: 1.0, longitude: 0.0), Coordinate3D(latitude: 2.0, longitude: 0.0), Coordinate3D(latitude: 3.0, longitude: 0.0), Coordinate3D(latitude: 2.5, longitude: 0.0), Coordinate3D(latitude: 1.0, longitude: 0.0)]]])
// let polygon2 = MultiPolygon([[[Coordinate3D(latitude: 1.0, longitude: 0.0), Coordinate3D(latitude: 1.0, longitude: 0.0), Coordinate3D(latitude: 2.0, longitude: 1.0), Coordinate3D(latitude: 1.0, longitude: 0.0)]]])
//
// XCTAssertNil(polygon1?.simplified())
// XCTAssertNil(polygon2?.simplified())
let polygon1 = MultiPolygon([[[
Coordinate3D(latitude: 1.0, longitude: 0.0),
Coordinate3D(latitude: 2.0, longitude: 0.0),
Coordinate3D(latitude: 3.0, longitude: 0.0),
Coordinate3D(latitude: 2.5, longitude: 0.0),
Coordinate3D(latitude: 1.0, longitude: 0.0)
]]])
let polygon2 = MultiPolygon([[[
Coordinate3D(latitude: 1.0, longitude: 0.0),
Coordinate3D(latitude: 1.0, longitude: 0.0),
Coordinate3D(latitude: 2.0, longitude: 1.0),
Coordinate3D(latitude: 1.0, longitude: 0.0)
]]])
#expect(polygon1?.simplified() == polygon1)
#expect(polygon2?.simplified() == polygon2)
}

// Validates that simplification with degenerate rings does not enter an endless loop.
Expand Down
37 changes: 37 additions & 0 deletions Tests/GISToolsTests/Algorithms/SymmetricDifferenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,43 @@ import Foundation

struct SymmetricDifferenceTests {

// MARK: - Reference tests

private static let overlayFixtures = [
"DisjointSquares",
"FullyContained",
"LShapeOverlap",
]

private func loadOverlayPolygon(_ name: String, _ suffix: String) throws -> Polygon {
try TestData.polygon(package: "Overlay", name: name + suffix)
}

@Test(arguments: overlayFixtures)
private func turfSymDiffFixture(_ name: String) async throws {
let a = try loadOverlayPolygon(name, "A")
let b = try loadOverlayPolygon(name, "B")
let expected = try TestData.multiPolygon(package: "Overlay", name: name + "SymDiffResult")

guard let result = a.symmetricDifference(with: b) else {
if expected.polygons.isNotEmpty {
Issue.record("Expected non-nil symmetric difference for \(name)")
}
return
}

let resultArea = result.polygons.reduce(0) { $0 + $1.area }
let expectedArea = expected.polygons.reduce(0) { $0 + $1.area }
if expectedArea > 0 {
let ratio = resultArea / expectedArea
#expect(ratio > 0.60 && ratio < 1.40,
"\(name): area ratio \(ratio) outside [0.60, 1.40]")
}
else {
#expect(resultArea == 0, "\(name): expected empty but got area \(resultArea)")
}
}

// Validates that two overlapping squares produce an L-shaped symmetric difference.
@Test
func overlappingSquares() async throws {
Expand Down
25 changes: 25 additions & 0 deletions Tests/GISToolsTests/Algorithms/TesselateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ import Foundation

struct TesselateTests {

// MARK: - Reference tests

private static let tessFixtures = [
"Triangle",
"Square",
"ConcavePentagon",
"PolygonWithHole",
"LShape",
]

@Test(arguments: tessFixtures)
private func turfTesselateFixture(_ name: String) async throws {
let polygon = try TestData.polygon(package: "Tesselate", name: name)
let result = polygon.tesselated()

#expect(result.features.isNotEmpty, "\(name): expected at least one triangle")
// Every triangle must have exactly 3 coordinates + closing (4 total)
for feature in result.features {
if let tri = feature.geometry as? Polygon {
#expect(tri.outerRing?.coordinates.count == 4,
"\(name): expected triangle with 4 coords, got \(tri.outerRing?.coordinates.count ?? 0)")
}
}
}

// Validates that a triangle (3 vertices) tessellates into a single triangle.
@Test
func testTriangle() throws {
Expand Down
25 changes: 25 additions & 0 deletions Tests/GISToolsTests/Algorithms/VoronoiTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ import Foundation

struct VoronoiTests {

// MARK: - Reference tests

private static let voronoiFixtures = [
"ThreePoints",
"FourPoints",
]

@Test(arguments: voronoiFixtures)
private func turfVoronoiFixture(_ name: String) async throws {
let fc = try TestData.featureCollection(package: "Voronoi", name: name)
let bbox = try TestData.boundingBox(package: "Voronoi", name: name + "Bbox")

let result = fc.voronoiDiagram(boundingBox: bbox)

#expect(result.features.count == fc.features.count,
"\(name): expected \(fc.features.count) cells, got \(result.features.count)")

for (i, feature) in result.features.enumerated() {
let cell = try #require(feature.geometry as? Polygon,
"\(name): cell \(i) is not a polygon")
#expect(cell.isValid,
"\(name): cell \(i) is invalid")
}
}

// Validates that 3 points produce 3 Voronoi cells within the bounding box.
@Test
func testThreePoints() throws {
Expand Down
18 changes: 18 additions & 0 deletions Tests/GISToolsTests/Helpers/TestData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ struct TestData {
try MultiPolygon(jsonString: stringFromFile(package: package, name: name))!
}

static func boundingBox(package: String, name: String) throws -> BoundingBox {
let data = try dataFromFile(package: package, name: name)
let json = try JSONSerialization.jsonObject(with: data)
guard let dict = json as? [String: [String: Double]],
let sw = dict["southWest"], let ne = dict["northEast"],
let swLat = sw["lat"], let swLon = sw["lon"],
let neLat = ne["lat"], let neLon = ne["lon"]
else { throw TestDataError.invalidBoundingBox }

return BoundingBox(
southWest: Coordinate3D(latitude: swLat, longitude: swLon),
northEast: Coordinate3D(latitude: neLat, longitude: neLon))
}

// MARK: -

static func stringFromFile(package: String, name: String) throws -> String {
Expand Down Expand Up @@ -76,3 +90,7 @@ struct TestData {
}

}

enum TestDataError: Error {
case invalidBoundingBox
}
Loading
Loading