diff --git a/.gitignore b/.gitignore index cce699c12..351178889 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /docs/build/ /docs/src/source/ .vscode/ +.claude/ .DS_Store benchmarks/Manifest.toml \ No newline at end of file diff --git a/GeometryOpsTestHelpers/Project.toml b/GeometryOpsTestHelpers/Project.toml new file mode 100644 index 000000000..e59e29aa1 --- /dev/null +++ b/GeometryOpsTestHelpers/Project.toml @@ -0,0 +1,23 @@ +name = "GeometryOpsTestHelpers" +uuid = "47ae2730-6264-4af1-919b-a18bc445f019" +authors = ["Anshul Singhvi "] +version = "0.1.0" + +[deps] +GeoInterface = "cf35fbd7-0cd7-5166-be24-54bfbe79505f" +GeometryOps = "3251bfac-6a57-4b6d-aa61-ac1fef2975ab" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[weakdeps] +ArchGDAL = "c9ce4bd3-c3d5-55b8-8973-c0e20141b8c3" +GeometryBasics = "5c1252a2-5f33-56bf-86c9-59e7332b4326" +LibGEOS = "a90b1aa1-3769-5649-ba7e-abc5a9d163eb" + +[extensions] +GeometryOpsTestHelpersArchGDALExt = "ArchGDAL" +GeometryOpsTestHelpersGeometryBasicsExt = "GeometryBasics" +GeometryOpsTestHelpersLibGEOSExt = "LibGEOS" + +[compat] +julia = "1.9" + diff --git a/GeometryOpsTestHelpers/ext/GeometryOpsTestHelpersArchGDALExt.jl b/GeometryOpsTestHelpers/ext/GeometryOpsTestHelpersArchGDALExt.jl new file mode 100644 index 000000000..276a9d8f7 --- /dev/null +++ b/GeometryOpsTestHelpers/ext/GeometryOpsTestHelpersArchGDALExt.jl @@ -0,0 +1,35 @@ +module GeometryOpsTestHelpersArchGDALExt + +using GeometryOpsTestHelpers +using GeoInterface +using ArchGDAL + +function __init__() + # Register ArchGDAL in the test modules list + push!(GeometryOpsTestHelpers.TEST_MODULES, ArchGDAL) +end + +# Monkey-patch ArchGDAL to handle polygon conversion correctly +@eval ArchGDAL begin + function GeoInterface.convert( + ::Type{T}, + type::GeoInterface.PolygonTrait, + geom, + ) where {T<:IGeometry} + f = get(lookup_method, typeof(type), nothing) + isnothing(f) && error( + "Cannot convert an object of $(typeof(geom)) with the $(typeof(type)) trait (yet). Please report an issue.", + ) + poly = createpolygon() + foreach(GeoInterface.getring(geom)) do ring + xs = GeoInterface.x.(GeoInterface.getpoint(ring)) |> collect + ys = GeoInterface.y.(GeoInterface.getpoint(ring)) |> collect + subgeom = unsafe_createlinearring(xs, ys) + result = GDAL.ogr_g_addgeometrydirectly(poly, subgeom) + @ogrerr result "Failed to add linearring." + end + return poly + end +end + +end # module GeometryOpsTestHelpersArchGDALExt diff --git a/GeometryOpsTestHelpers/ext/GeometryOpsTestHelpersGeometryBasicsExt.jl b/GeometryOpsTestHelpers/ext/GeometryOpsTestHelpersGeometryBasicsExt.jl new file mode 100644 index 000000000..99609f14a --- /dev/null +++ b/GeometryOpsTestHelpers/ext/GeometryOpsTestHelpersGeometryBasicsExt.jl @@ -0,0 +1,54 @@ +module GeometryOpsTestHelpersGeometryBasicsExt + +using GeometryOpsTestHelpers +using GeoInterface +using GeometryBasics +import GeometryOps as GO + +function __init__() + # Register GeometryBasics in the test modules list + push!(GeometryOpsTestHelpers.TEST_MODULES, GeometryBasics) +end + +# Monkey-patch GeometryBasics to have correct methods. +# TODO: push this up to GB! + +# TODO: remove when GB GI pr lands +function GeoInterface.convert( + ::Type{GeometryBasics.LineString}, + ::GeoInterface.LinearRingTrait, + geom +) + return GeoInterface.convert(GeometryBasics.LineString, GeoInterface.LineStringTrait(), geom) +end +GeometryBasics.geointerface_geomtype(::GeoInterface.LinearRingTrait) = GeometryBasics.LineString + +function GeoInterface.convert(::Type{GeometryBasics.Line}, type::GeoInterface.LineTrait, geom) + g1, g2 = GeoInterface.getgeom(geom) + x, y = GeoInterface.x(g1), GeoInterface.y(g1) + if GeoInterface.is3d(geom) + z = GeoInterface.z(g1) + T = promote_type(typeof(x), typeof(y), typeof(z)) + return GeometryBasics.Line{3,T}(GeometryBasics.Point{3,T}(x, y, z), GeometryBasics.Point{3,T}(GeoInterface.x(g2), GeoInterface.y(g2), GeoInterface.z(g2))) + else + T = promote_type(typeof(x), typeof(y)) + return GeometryBasics.Line{2,T}(GeometryBasics.Point{2,T}(x, y), GeometryBasics.Point{2,T}(GeoInterface.x(g2), GeoInterface.y(g2))) + end +end + +# GeometryCollection interface - currently just a large Union +const _ALL_GB_GEOM_TYPES = Union{GeometryBasics.Point, GeometryBasics.LineString, GeometryBasics.Polygon, GeometryBasics.MultiPolygon, GeometryBasics.MultiLineString, GeometryBasics.MultiPoint} +GeometryBasics.geointerface_geomtype(::GeoInterface.GeometryCollectionTrait) = Vector{_ALL_GB_GEOM_TYPES} +function GeoInterface.convert(::Type{Vector{<: _ALL_GB_GEOM_TYPES}}, ::GeoInterface.GeometryCollectionTrait, geoms) + return _ALL_GB_GEOM_TYPES[GeoInterface.convert(GeometryBasics, g) for g in GeoInterface.getgeom(geoms)] +end + +function GeoInterface.convert( + ::Type{GeometryBasics.LineString}, + type::GeoInterface.LineStringTrait, + geom::GeoInterface.Wrappers.LinearRing{false, false, GO.StaticArrays.SVector{N, Tuple{Float64, Float64}}, Nothing, Nothing} where N +) + return GeometryBasics.LineString(GeometryBasics.Point2{Float64}.(collect(geom.geom))) +end + +end # module GeometryOpsTestHelpersGeometryBasicsExt diff --git a/GeometryOpsTestHelpers/ext/GeometryOpsTestHelpersLibGEOSExt.jl b/GeometryOpsTestHelpers/ext/GeometryOpsTestHelpersLibGEOSExt.jl new file mode 100644 index 000000000..3ab4ff5a6 --- /dev/null +++ b/GeometryOpsTestHelpers/ext/GeometryOpsTestHelpersLibGEOSExt.jl @@ -0,0 +1,11 @@ +module GeometryOpsTestHelpersLibGEOSExt + +using GeometryOpsTestHelpers +using LibGEOS + +function __init__() + # Register LibGEOS in the test modules list + push!(GeometryOpsTestHelpers.TEST_MODULES, LibGEOS) +end + +end # module GeometryOpsTestHelpersLibGEOSExt diff --git a/GeometryOpsTestHelpers/src/GeometryOpsTestHelpers.jl b/GeometryOpsTestHelpers/src/GeometryOpsTestHelpers.jl new file mode 100644 index 000000000..ab1f4e6d5 --- /dev/null +++ b/GeometryOpsTestHelpers/src/GeometryOpsTestHelpers.jl @@ -0,0 +1,155 @@ +module GeometryOpsTestHelpers + +import GeometryOps as GO +using GeoInterface +using Test + +export @test_implementations, @testset_implementations + +# List of test modules - will be populated when extensions load +# GeoInterface is always available as it's a regular dependency +const TEST_MODULES = Module[GeoInterface] + +""" + conversion_expr(mod, var, genkey) + +Generate an expression to convert a geometry variable to a specific module's type. +Handles special cases for Extents and empty geometries. +""" +function conversion_expr(mod, var, genkey) + quote + $genkey = if $var isa $(GeoInterface.Extents.Extent) + # GeoInterface and LibGEOS support Extents directly + if string(nameof($mod)) in ("GeoInterface", "LibGEOS") + $var + else + $GeoInterface.convert($mod, $(GO.extent_to_polygon)($var)) + end + # These modules do not support empty geometries. + # GDAL does but AG does not + elseif string(nameof($mod)) in ("GeoInterface", "ArchGDAL", "GeometryBasics") && $GeoInterface.isempty($var) + $var + else + $GeoInterface.convert($mod, $var) + end + end +end + +""" + @test_implementations(code) + @test_implementations(modules, code) + +Macro to run a block of `code` for multiple modules, using GeoInterface.convert +for each variable prefixed with `\$` in the code block. + +# Examples +```julia +point = GI.Point(1.0, 2.0) +@test_implementations begin + \$point isa GeoInterface.AbstractGeometry +end +``` +""" +macro test_implementations(code::Expr) + _test_implementations_inner(TEST_MODULES, code) +end +macro test_implementations(modules::Union{Expr,Vector}, code::Expr) + _test_implementations_inner(modules, code) +end + +function _test_implementations_inner(modules::Union{Expr,Vector}, code::Expr) + vars = Dict{Symbol,Symbol}() + code1 = _quasiquote!(code, vars) + modules1 = modules isa Expr ? modules.args : modules + tests = Expr(:block) + + for mod in modules1 + expr = Expr(:block) + for (var, genkey) in pairs(vars) + push!(expr.args, conversion_expr(mod, var, genkey)) + end + push!(expr.args, :(@test $code1)) + push!(tests.args, expr) + end + + return esc(tests) +end + +""" + @testset_implementations(code) + @testset_implementations(title, code) + @testset_implementations(modules, code) + @testset_implementations(title, modules, code) + +Macro to run a block of `code` for multiple modules within separate testsets, +using GeoInterface.convert for each variable prefixed with `\$` in the code block. + +# Examples +```julia +point = GI.Point(1.0, 2.0) +@testset_implementations "Point tests" begin + @test GeoInterface.x(\$point) == 1.0 +end +``` +""" +macro testset_implementations(code::Expr) + _testset_implementations_inner("", TEST_MODULES, code) +end +macro testset_implementations(arg, code::Expr) + if arg isa String || arg isa Expr && arg.head == :string + _testset_implementations_inner(arg, TEST_MODULES, code) + else + _testset_implementations_inner("", arg, code) + end +end +macro testset_implementations(title, modules::Union{Expr,Vector}, code::Expr) + _testset_implementations_inner(title, modules, code) +end + +function _testset_implementations_inner(title, modules::Union{Expr,Vector}, code::Expr) + vars = Dict{Symbol,Symbol}() + code1 = _quasiquote!(code, vars) + modules1 = modules isa Expr ? modules.args : modules + testsets = Expr(:block) + + for mod in modules1 + expr = Expr(:block) + for (var, genkey) in pairs(vars) + push!(expr.args, conversion_expr(mod, var, genkey)) + end + # Manually define the testset macrocall and all string interpolation + testset = Expr( + :macrocall, + Symbol("@testset"), + LineNumberNode(@__LINE__, @__FILE__), + Expr(:string, mod, " ", title), + code1 + ) + push!(expr.args, testset) + push!(testsets.args, expr) + end + + return esc(testsets) +end + +# Taken from BenchmarkTools.jl +_quasiquote!(ex, vars) = ex +function _quasiquote!(ex::Expr, vars::Dict) + if ex.head === :($) + v = ex.args[1] + gen = if v isa Symbol + haskey(vars, v) ? vars[v] : gensym(v) + else + gensym() + end + vars[v] = gen + return v + elseif ex.head !== :quote + for i in 1:length(ex.args) + ex.args[i] = _quasiquote!(ex.args[i], vars) + end + end + return ex +end + +end # module GeometryOpsTestHelpers diff --git a/Project.toml b/Project.toml index 0528d5806..b2c40075c 100644 --- a/Project.toml +++ b/Project.toml @@ -70,6 +70,7 @@ Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" FlexiJoins = "e37f2e79-19fa-4eb7-8510-b63b51fe0a37" GeoJSON = "61d90e0f-e114-555e-ac52-39dfb47a3ef9" GeometryBasics = "5c1252a2-5f33-56bf-86c9-59e7332b4326" +GeometryOpsTestHelpers = "47ae2730-6264-4af1-919b-a18bc445f019" JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" LibGEOS = "a90b1aa1-3769-5649-ba7e-abc5a9d163eb" NaturalEarth = "436b0209-26ab-4e65-94a9-6526d86fea76" @@ -84,4 +85,8 @@ TGGeometry = "d7e755d2-3c95-4bcf-9b3c-79ab1a78647b" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["ArchGDAL", "CoordinateTransformations", "DataFrames", "Distributions", "DimensionalData", "Downloads", "FlexiJoins", "GeoJSON", "GeometryBasics", "Proj", "JLD2", "LibGEOS", "Random", "Rasters", "NaturalEarth", "OffsetArrays", "Polylabel", "SafeTestsets", "Shapefile", "TGGeometry", "Test"] +test = ["ArchGDAL", "CoordinateTransformations", "DataFrames", "Distributions", "DimensionalData", "Downloads", "FlexiJoins", "GeoJSON", "GeometryBasics", "GeometryOpsTestHelpers", "Proj", "JLD2", "LibGEOS", "Random", "Rasters", "NaturalEarth", "OffsetArrays", "Polylabel", "SafeTestsets", "Shapefile", "TGGeometry", "Test"] + +[sources] +GeometryOpsTestHelpers = {path = "GeometryOpsTestHelpers"} +GeometryOpsCore = {path = "GeometryOpsCore"} \ No newline at end of file diff --git a/test/helpers.jl b/test/helpers.jl index 68425ef25..b483954f1 100644 --- a/test/helpers.jl +++ b/test/helpers.jl @@ -1,186 +1,10 @@ module TestHelpers -import GeometryOps as GO - +# Re-export functionality from GeometryOpsTestHelpers +using GeometryOpsTestHelpers using Test, GeoInterface, ArchGDAL, GeometryBasics, LibGEOS +# Re-export the macros export @test_implementations, @testset_implementations -const TEST_MODULES = [GeoInterface, ArchGDAL, GeometryBasics, LibGEOS] - -function conversion_expr(mod, var, genkey) - quote - $genkey = if $var isa $(GeoInterface.Extents.Extent) - if $mod in ($GeoInterface, $LibGEOS) - $var - else - $GeoInterface.convert($mod, $(GO.extent_to_polygon)($var)) - end - # These modules do not support empty geometries. - # GDAL does but AG does not - elseif $mod in ($GeoInterface, $ArchGDAL, $GeometryBasics) && $GeoInterface.isempty($var) - $var - else - $GeoInterface.convert($mod, $var) - end - end -end -# Monkey-patch GeometryBasics to have correct methods. -# TODO: push this up to GB! - - # TODO: remove when GB GI pr lands - # @static if hasmethod(GeometryBasics.convert, (Type{GeometryBasics.LineString}, GeoInterface.LinearRingTrait, Any)) - function GeoInterface.convert( - ::Type{GeometryBasics.LineString}, - ::GeoInterface.LinearRingTrait, - geom - ) - return GeoInterface.convert(GeometryBasics.LineString, GeoInterface.LineStringTrait(), geom) - end - GeometryBasics.geointerface_geomtype(::GeoInterface.LinearRingTrait) = GeometryBasics.LineString - # end - - # @static if hasmethod(GeometryBasics.convert, (Type{GeometryBasics.Line}, GeoInterface.LineTrait, Any)) - function GeoInterface.convert(::Type{GeometryBasics.Line}, type::GeoInterface.LineTrait, geom) - g1, g2 = GeoInterface.getgeom(geom) - x, y = GeoInterface.x(g1), GeoInterface.y(g1) - if GeoInterface.is3d(geom) - z = GeoInterface.z(g1) - T = promote_type(typeof(x), typeof(y), typeof(z)) - return GeometryBasics.Line{3,T}(GeometryBasics.Point{3,T}(x, y, z), GeometryBasics.Point{3,T}(GeoInterface.x(g2), GeoInterface.y(g2), GeoInterface.z(g2))) - else - T = promote_type(typeof(x), typeof(y)) - return GeometryBasics.Line{2,T}(GeometryBasics.Point{2,T}(x, y), GeometryBasics.Point{2,T}(GeoInterface.x(g2), GeoInterface.y(g2))) - end - end - # end - # end todo - # GeometryCollection interface - currently just a large Union - const _ALL_GB_GEOM_TYPES = Union{GeometryBasics.Point, GeometryBasics.LineString, GeometryBasics.Polygon, GeometryBasics.MultiPolygon, GeometryBasics.MultiLineString, GeometryBasics.MultiPoint} - GeometryBasics.geointerface_geomtype(::GeoInterface.GeometryCollectionTrait) = Vector{_ALL_GB_GEOM_TYPES} - function GeoInterface.convert(::Type{Vector{<: _ALL_GB_GEOM_TYPES}}, ::GeoInterface.GeometryCollectionTrait, geoms) - return _ALL_GB_GEOM_TYPES[GeoInterface.convert(GeometryBasics, g) for g in GeoInterface.getgeom(geoms)] - end - - function GeoInterface.convert( - ::Type{GeometryBasics.LineString}, - type::GeoInterface.LineStringTrait, - geom::GeoInterface.Wrappers.LinearRing{false, false, GO.StaticArrays.SVector{N, Tuple{Float64, Float64}}, Nothing, Nothing} where N - ) - return GeometryBasics.LineString(GeometryBasics.Point2{Float64}.(collect(geom.geom))) - end - - - @eval ArchGDAL begin - function GeoInterface.convert( - ::Type{T}, - type::GeoInterface.PolygonTrait, - geom, - ) where {T<:IGeometry} - f = get(lookup_method, typeof(type), nothing) - isnothing(f) && error( - "Cannot convert an object of $(typeof(geom)) with the $(typeof(type)) trait (yet). Please report an issue.", - ) - poly = createpolygon() - foreach(GeoInterface.getring(geom)) do ring - xs = GeoInterface.x.(GeoInterface.getpoint(ring)) |> collect - ys = GeoInterface.y.(GeoInterface.getpoint(ring)) |> collect - subgeom = unsafe_createlinearring(xs, ys) - result = GDAL.ogr_g_addgeometrydirectly(poly, subgeom) - @ogrerr result "Failed to add linearring." - end - return poly - end - end - - -# Macro to run a block of `code` for multiple modules, -# using GeoInterface.convert for each var in `args` -macro test_implementations(code::Expr) - _test_implementations_inner(TEST_MODULES, code) -end -macro test_implementations(modules::Union{Expr,Vector}, code::Expr) - _test_implementations_inner(modules, code) -end - -function _test_implementations_inner(modules::Union{Expr,Vector}, code::Expr) - vars = Dict{Symbol,Symbol}() - code1 = _quasiquote!(code, vars) - modules1 = modules isa Expr ? modules.args : modules - tests = Expr(:block) - - for mod in modules1 - expr = Expr(:block) - for (var, genkey) in pairs(vars) - push!(expr.args, conversion_expr(mod, var, genkey)) - end - push!(expr.args, :(@test $code1)) - push!(tests.args, expr) - end - - return esc(tests) -end - -# Macro to run a block of `code` for multiple modules, -# using GeoInterface.convert for each var in `args` -macro testset_implementations(code::Expr) - _testset_implementations_inner("", TEST_MODULES, code) -end -macro testset_implementations(arg, code::Expr) - if arg isa String || arg isa Expr && arg.head == :string - _testset_implementations_inner(arg, TEST_MODULES, code) - else - _testset_implementations_inner("", arg, code) - end -end -macro testset_implementations(title, modules::Union{Expr,Vector}, code::Expr) - _testset_implementations_inner(title, modules, code) -end - -function _testset_implementations_inner(title, modules::Union{Expr,Vector}, code::Expr) - vars = Dict{Symbol,Symbol}() - code1 = _quasiquote!(code, vars) - modules1 = modules isa Expr ? modules.args : modules - testsets = Expr(:block) - - for mod in modules1 - expr = Expr(:block) - for (var, genkey) in pairs(vars) - push!(expr.args, conversion_expr(mod, var, genkey)) - end - # Manually define the testset macrocall and all string interpolation - testset = Expr( - :macrocall, - Symbol("@testset"), - LineNumberNode(@__LINE__, @__FILE__), - Expr(:string, mod, " ", title), - code1 - ) - push!(expr.args, testset) - push!(testsets.args, expr) - end - - return esc(testsets) -end - -# Taken from BenchmarkTools.jl -_quasiquote!(ex, vars) = ex -function _quasiquote!(ex::Expr, vars::Dict) - if ex.head === :($) - v = ex.args[1] - gen = if v isa Symbol - haskey(vars, v) ? vars[v] : gensym(v) - else - gensym() - end - vars[v] = gen - return v - elseif ex.head !== :quote - for i in 1:length(ex.args) - ex.args[i] = _quasiquote!(ex.args[i], vars) - end - end - return ex -end - end # module