tg_geometry 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -79
  3. data/README.md +168 -184
  4. data/Rakefile +3 -3
  5. data/benchmark/ewkb_roundtrip.rb +29 -0
  6. data/benchmark/falcon_concurrency.rb +1 -1
  7. data/benchmark/feature_source.rb +92 -0
  8. data/benchmark/geom_query.rb +33 -0
  9. data/benchmark/nearest_segment.rb +20 -0
  10. data/docs/ARCHITECTURE.md +29 -107
  11. data/docs/BENCHMARKING.md +20 -1
  12. data/docs/CASUAL_EXAMPLE.md +71 -458
  13. data/docs/CONCURRENCY.md +13 -7
  14. data/docs/ERROR_HANDLING.md +30 -0
  15. data/docs/FEATURE_SOURCE.md +166 -0
  16. data/docs/GEOMETRY_QUERIES.md +23 -0
  17. data/docs/LIMITATIONS.md +17 -54
  18. data/docs/MEMORY_OWNERSHIP.md +20 -2
  19. data/docs/NEAREST_SEGMENT.md +17 -0
  20. data/docs/SRID_AND_EWKB.md +23 -0
  21. data/ext/tg_geometry/extconf.rb +46 -4
  22. data/ext/tg_geometry/tg_geometry_ext.c +3643 -168
  23. data/ext/tg_geometry/tg_geometry_vendor_json.c +17 -0
  24. data/ext/tg_geometry/tg_geometry_vendor_tg.c +3 -0
  25. data/ext/tg_geometry/vendor/.vendored +8 -2
  26. data/ext/tg_geometry/vendor/json/LICENSE +20 -0
  27. data/ext/tg_geometry/vendor/json/VERSION +3 -0
  28. data/ext/tg_geometry/vendor/json/json.c +1024 -0
  29. data/ext/tg_geometry/vendor/json/json.h +207 -0
  30. data/lib/tg/geometry/active_record_source.rb +16 -34
  31. data/lib/tg/geometry/active_record_type.rb +61 -0
  32. data/lib/tg/geometry/registry.rb +16 -67
  33. data/lib/tg/geometry/version.rb +1 -1
  34. data/lib/tg/geometry.rb +85 -0
  35. data/script/vendor_libs.rb +22 -6
  36. data/spec/active_record_type_spec.rb +45 -0
  37. data/spec/{expansion_a_auto_strategy_spec.rb → auto_strategy_spec.rb} +1 -1
  38. data/spec/{block_12_batch_packed_spec.rb → batch_packed_spec.rb} +1 -1
  39. data/spec/{block_20_concurrency_spec.rb → concurrency_spec.rb} +1 -1
  40. data/spec/constructors_spec.rb +104 -0
  41. data/spec/{block_13_error_hardening_spec.rb → error_hardening_spec.rb} +1 -1
  42. data/spec/feature_source_nogvl_spec.rb +51 -0
  43. data/spec/feature_source_spec.rb +268 -0
  44. data/spec/fixtures/feature_source/invalid_geometry_middle.geojson +8 -0
  45. data/spec/fixtures/feature_source/malformed_json.geojson +1 -0
  46. data/spec/fixtures/feature_source/mixed_geometry_types.geojson +8 -0
  47. data/spec/fixtures/feature_source/osm_like_feature_collection.geojson +10 -0
  48. data/spec/fixtures/feature_source/properties_null_missing.geojson +7 -0
  49. data/spec/fixtures/feature_source/simple_feature_collection.geojson +15 -0
  50. data/spec/fixtures/postgis/README.md +16 -0
  51. data/spec/fixtures/postgis/boundary_point_cases.geojson +83 -0
  52. data/spec/fixtures/postgis/multipolygon_large.ewkb +0 -0
  53. data/spec/fixtures/postgis/point_4326.ewkb +0 -0
  54. data/spec/fixtures/postgis/polygon_3857.ewkb +0 -0
  55. data/spec/fixtures/postgis/polygon_4326_simple.ewkb +0 -0
  56. data/spec/fixtures/postgis/polygon_4326_with_hole.ewkb +0 -0
  57. data/spec/{expansion_d_format_coverage_spec.rb → format_coverage_spec.rb} +1 -1
  58. data/spec/{block_20_fuzz_spec.rb → fuzz_spec.rb} +1 -1
  59. data/spec/{block_4_geom_api_spec.rb → geom_api_spec.rb} +1 -1
  60. data/spec/{block_3_geom_parse_spec.rb → geom_parse_spec.rb} +1 -1
  61. data/spec/{block_8_index_borrowed_geometry_spec.rb → index_borrowed_geometry_spec.rb} +1 -1
  62. data/spec/{block_6_index_build_spec.rb → index_build_spec.rb} +2 -2
  63. data/spec/{block_9_flat_query_spec.rb → index_flat_query_spec.rb} +1 -1
  64. data/spec/index_geom_query_spec.rb +68 -0
  65. data/spec/{block_7_index_owned_geometry_spec.rb → index_owned_geometry_spec.rb} +1 -1
  66. data/spec/{block_10_rtree_strategy_spec.rb → index_rtree_accounting_spec.rb} +1 -1
  67. data/spec/{block_11_rtree_order_spec.rb → index_rtree_order_spec.rb} +1 -1
  68. data/spec/keyword_validation_spec.rb +31 -0
  69. data/spec/{block_1_skeleton_spec.rb → load_and_errors_spec.rb} +1 -1
  70. data/spec/{expansion_e_low_level_geometry_spec.rb → low_level_geometry_spec.rb} +1 -1
  71. data/spec/{block_14_memory_gc_hardening_spec.rb → memory_gc_spec.rb} +1 -1
  72. data/spec/nearest_segment_spec.rb +62 -0
  73. data/spec/postgis_fixtures_spec.rb +68 -0
  74. data/spec/{expansion_i_ractor_spec.rb → ractor_spec.rb} +1 -1
  75. data/spec/{block_5_rect_api_spec.rb → rect_api_spec.rb} +1 -1
  76. data/spec/{expansion_b_registry_spec.rb → registry_spec.rb} +1 -1
  77. data/spec/srid_spec.rb +43 -0
  78. data/spec/{expansion_j_full_tg_api_coverage_spec.rb → tg_api_coverage_spec.rb} +1 -1
  79. data/spec/to_ewkb_spec.rb +37 -0
  80. data/spec/{block_2_vendor_spec.rb → vendor_sources_spec.rb} +4 -4
  81. metadata +87 -38
  82. data/docs/ACTIVE_RECORD.md +0 -26
  83. data/docs/AUTO_STRATEGY.md +0 -15
  84. data/docs/EXPANSION_E_TO_H_STATUS.md +0 -51
  85. data/docs/FORMAT_COVERAGE.md +0 -23
  86. data/docs/FULL_TG_API_COVERAGE.md +0 -109
  87. data/docs/LOW_LEVEL_GEOMETRY.md +0 -121
  88. data/docs/RACTOR.md +0 -40
  89. data/docs/REGISTRY.md +0 -37
  90. data/docs/RELEASE_CHECKLIST.md +0 -39
  91. /data/spec/{expansion_c_active_record_source_spec.rb → active_record_source_spec.rb} +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "spec_helper"
4
4
 
5
- RSpec.describe "Release Core Block 12 packed batch API" do
5
+ RSpec.describe "Packed batch API" do
6
6
  let(:zone_a) { TG::Geometry.parse_wkt("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))") }
7
7
  let(:zone_b) { TG::Geometry.parse_wkt("POLYGON ((20 20, 30 20, 30 30, 20 30, 20 20))") }
8
8
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "spec_helper"
4
4
 
5
- RSpec.describe "Release Core Block 20 concurrency hardening" do
5
+ RSpec.describe "Concurrency hardening" do
6
6
  let(:zone_a) { '{"type":"Polygon","coordinates":[[[0,0],[10,0],[10,10],[0,10],[0,0]]]}' }
7
7
  let(:zone_b) { '{"type":"Polygon","coordinates":[[[5,5],[15,5],[15,15],[5,15],[5,5]]]}' }
8
8
  let(:zone_c) { '{"type":"Polygon","coordinates":[[[100,100],[110,100],[110,110],[100,110],[100,100]]]}' }
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Constructors" do
6
+ let(:square) { [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]] }
7
+ let(:hole_a) { [[2.0, 2.0], [4.0, 2.0], [4.0, 4.0], [2.0, 4.0], [2.0, 2.0]] }
8
+ let(:hole_b) { [[6.0, 6.0], [8.0, 6.0], [8.0, 8.0], [6.0, 8.0], [6.0, 6.0]] }
9
+
10
+ describe ".line_string" do
11
+ it "builds a frozen two-point linestring" do
12
+ geom = TG::Geometry.line_string([[0.0, 0.0], [1.0, 1.0]])
13
+
14
+ expect(geom).to be_frozen
15
+ expect(geom.type).to eq(:linestring)
16
+ expect(geom.line.num_points).to eq(2)
17
+ expect(geom.srid).to be_nil
18
+ end
19
+
20
+ it "builds a 100-point linestring" do
21
+ points = 100.times.map { |i| [i.to_f, (i * 2).to_f] }
22
+ geom = TG::Geometry.line_string(points)
23
+
24
+ expect(geom.line.num_points).to eq(100)
25
+ end
26
+
27
+ it "supports explicit index values and srid metadata" do
28
+ %i[default natural ystripes none].each do |index|
29
+ geom = TG::Geometry.line_string([[0, 0], [1, 1]], index: index, srid: 4326)
30
+ expect(geom.srid).to eq(4326)
31
+ end
32
+ end
33
+
34
+ it "rejects too few points" do
35
+ expect { TG::Geometry.line_string([[0, 0]]) }
36
+ .to raise_error(TG::Geometry::ArgumentError, "line_string requires at least 2 points, got 1")
37
+ expect { TG::Geometry.line_string([]) }
38
+ .to raise_error(TG::Geometry::ArgumentError, "line_string requires at least 2 points, got 0")
39
+ end
40
+
41
+ it "rejects non-finite coordinates with the bad point index in the message" do
42
+ expect { TG::Geometry.line_string([[0, 0], [Float::NAN, 1]]) }
43
+ .to raise_error(TG::Geometry::ArgumentError, /point 1/)
44
+ expect { TG::Geometry.line_string([[0, 0], [1, Float::INFINITY]]) }
45
+ .to raise_error(TG::Geometry::ArgumentError, /point 1/)
46
+ end
47
+
48
+ it "rejects out-of-range srid" do
49
+ expect { TG::Geometry.line_string([[0, 0], [1, 1]], srid: -1) }
50
+ .to raise_error(TG::Geometry::ArgumentError)
51
+ end
52
+ end
53
+
54
+ describe ".polygon" do
55
+ it "builds a square polygon" do
56
+ geom = TG::Geometry.polygon(square)
57
+
58
+ expect(geom.type).to eq(:polygon)
59
+ expect(geom.polygon.num_holes).to eq(0)
60
+ expect(geom.covers_xy?(5, 5)).to be(true)
61
+ end
62
+
63
+ it "builds polygons with one or two holes" do
64
+ one_hole = TG::Geometry.polygon(square, holes: [hole_a], srid: 4326)
65
+ two_holes = TG::Geometry.polygon(square, holes: [hole_a, hole_b])
66
+
67
+ expect(one_hole.srid).to eq(4326)
68
+ expect(one_hole.polygon.num_holes).to eq(1)
69
+ expect(one_hole.covers_xy?(3, 3)).to be(false)
70
+ expect(two_holes.polygon.num_holes).to eq(2)
71
+ end
72
+
73
+ it "treats holes: [] like omitted holes" do
74
+ expect(TG::Geometry.polygon(square, holes: []).polygon.num_holes).to eq(0)
75
+ expect(TG::Geometry.polygon(square).polygon.num_holes).to eq(0)
76
+ end
77
+
78
+ it "rejects invalid rings without autoclosing" do
79
+ expect { TG::Geometry.polygon(square[0...-1]) }
80
+ .to raise_error(TG::Geometry::ArgumentError, "polygon exterior ring is not closed")
81
+ expect { TG::Geometry.polygon(square[0, 3]) }
82
+ .to raise_error(TG::Geometry::ArgumentError, "polygon exterior ring requires at least 4 points")
83
+ expect { TG::Geometry.polygon(square, holes: [hole_a[0...-1]]) }
84
+ .to raise_error(TG::Geometry::ArgumentError, "polygon hole 0 is not closed")
85
+ expect { TG::Geometry.polygon(square, holes: [hole_a[0, 3]]) }
86
+ .to raise_error(TG::Geometry::ArgumentError, "polygon hole 0 requires at least 4 points")
87
+ end
88
+ end
89
+
90
+ describe ".multi_polygon" do
91
+ it "builds empty and non-empty multipolygons" do
92
+ expect(TG::Geometry.multi_polygon([]).type).to eq(:multipolygon)
93
+
94
+ geom = TG::Geometry.multi_polygon([
95
+ { exterior: square, holes: [hole_a] },
96
+ [[20.0, 20.0], [21.0, 20.0], [21.0, 21.0], [20.0, 21.0], [20.0, 20.0]]
97
+ ], srid: 3857)
98
+
99
+ expect(geom.type).to eq(:multipolygon)
100
+ expect(geom.srid).to eq(3857)
101
+ expect(geom.num_polygons).to eq(2)
102
+ end
103
+ end
104
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "spec_helper"
4
4
 
5
- RSpec.describe "Release Core Block 13 error hardening" do
5
+ RSpec.describe "Error hardening" do
6
6
  let(:geojson) { '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,1],[0,0]]]}' }
7
7
  let(:wkb) { TG::Geometry.parse_geojson(geojson).to_wkb }
8
8
 
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tempfile"
5
+
6
+ RSpec.describe "FeatureSource no-GVL behavior" do
7
+ def polygon_feature_json(id, x, y)
8
+ <<~JSON.chomp
9
+ {"type":"Feature","properties":{"@id":"zone/#{id}"},"geometry":{"type":"Polygon","coordinates":[[[#{x},#{y}],[#{x + 0.8},#{y}],[#{x + 0.8},#{y + 0.8}],[#{x},#{y + 0.8}],[#{x},#{y}]]]}}
10
+ JSON
11
+ end
12
+
13
+ def write_large_feature_collection(count)
14
+ file = Tempfile.new(["tg_geometry_feature_source_nogvl", ".geojson"])
15
+ file.binmode
16
+ file.write('{"type":"FeatureCollection","features":[')
17
+ count.times do |i|
18
+ file.write(",") unless i.zero?
19
+ file.write(polygon_feature_json(i, i % 1000, i / 1000))
20
+ end
21
+ file.write("]}")
22
+ file.flush
23
+ file.close
24
+ file
25
+ end
26
+
27
+ it "lets other Ruby threads run during large FeatureSource file processing" do
28
+ count = 40_000
29
+ file = write_large_feature_collection(count)
30
+ ticks = 0
31
+ done = false
32
+
33
+ ticker = Thread.new do
34
+ until done
35
+ ticks += 1
36
+ sleep 0.005
37
+ end
38
+ end
39
+
40
+ index = TG::Geometry::FeatureSource.build_index_file(file.path, strategy: :flat)
41
+ done = true
42
+ ticker.join
43
+
44
+ expect(index.size).to eq(count)
45
+ expect(ticks).to be >= 1
46
+ ensure
47
+ done = true
48
+ ticker&.join
49
+ file&.unlink
50
+ end
51
+ end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "stringio"
5
+
6
+ RSpec.describe "FeatureSource" do
7
+ let(:fixture_dir) { File.join(__dir__, "fixtures", "feature_source") }
8
+ let(:simple_path) { File.join(fixture_dir, "simple_feature_collection.geojson") }
9
+ let(:simple_json) { File.binread(simple_path) }
10
+
11
+ it "defines TG::Geometry::FeatureSource" do
12
+ expect(TG::Geometry.const_defined?(:FeatureSource)).to be(true)
13
+ end
14
+
15
+ it "reads entries from JSON, file, and IO without parsing properties to Hash" do
16
+ expected = [["zone/a", /"Polygon"/], [2, /"MultiPolygon"/]]
17
+
18
+ [
19
+ TG::Geometry::FeatureSource.read_entries_json(simple_json),
20
+ TG::Geometry::FeatureSource.read_entries_file(simple_path),
21
+ TG::Geometry::FeatureSource.read_entries_io(StringIO.new(simple_json))
22
+ ].each do |entries|
23
+ expect(entries.size).to eq(2)
24
+ expected.each_with_index do |(id, geometry_matcher), i|
25
+ expect(entries[i][0]).to eq(id)
26
+ expect(entries[i][1]).to match(geometry_matcher)
27
+ expect(entries[i][1]).to be_a(String)
28
+ end
29
+ end
30
+ end
31
+
32
+ it "reads features with raw properties JSON" do
33
+ features = TG::Geometry::FeatureSource.read_features_json(simple_json)
34
+
35
+ expect(features.size).to eq(2)
36
+ expect(features[0][0]).to eq("zone/a")
37
+ expect(features[0][1]).to include('"Polygon"')
38
+ expect(features[0][2]).to include('"@id": "zone/a"')
39
+ expect(features[1][2]).to include('"@id": 2')
40
+ end
41
+
42
+ it "uses the OSM-style default id path [properties, @id]" do
43
+ json = File.binread(File.join(fixture_dir, "osm_like_feature_collection.geojson"))
44
+
45
+ entries = TG::Geometry::FeatureSource.read_entries_json(json)
46
+
47
+ expect(entries.first.first).to eq("relation/100")
48
+ end
49
+
50
+ it "filters non polygon types by default and reports filtered separately" do
51
+ json = File.binread(File.join(fixture_dir, "mixed_geometry_types.geojson"))
52
+
53
+ report = TG::Geometry::FeatureSource.read_entries_json(json, report: true)
54
+
55
+ expect(report[:entries].map(&:first)).to eq(["poly"])
56
+ expect(report[:filtered]).to eq(2)
57
+ expect(report[:skipped]).to eq(0)
58
+ expect(report[:errors]).to eq([])
59
+ end
60
+
61
+ it "supports only: nil for no geometry type filtering" do
62
+ json = File.binread(File.join(fixture_dir, "mixed_geometry_types.geojson"))
63
+
64
+ entries = TG::Geometry::FeatureSource.read_entries_json(json, only: nil)
65
+
66
+ expect(entries.map(&:first)).to eq(%w[poly point line])
67
+ end
68
+
69
+ it "requires report mode for skip options" do
70
+ expect do
71
+ TG::Geometry::FeatureSource.read_entries_json(simple_json, on_invalid: :skip)
72
+ end.to raise_error(TG::Geometry::ArgumentError)
73
+
74
+ expect do
75
+ TG::Geometry::FeatureSource.read_entries_json(simple_json, on_missing_id: :skip)
76
+ end.to raise_error(TG::Geometry::ArgumentError)
77
+ end
78
+
79
+ it "supports on_missing_id: :ordinal" do
80
+ json = File.binread(File.join(fixture_dir, "properties_null_missing.geojson"))
81
+
82
+ entries = TG::Geometry::FeatureSource.read_entries_json(json, on_missing_id: :ordinal)
83
+
84
+ expect(entries.map(&:first)).to eq(["feature/0", "feature/1"])
85
+ end
86
+
87
+ it "supports on_missing_id: :skip in report mode without requiring on_invalid: :skip" do
88
+ json = File.binread(File.join(fixture_dir, "properties_null_missing.geojson"))
89
+
90
+ report = TG::Geometry::FeatureSource.read_entries_json(json,
91
+ report: true,
92
+ on_missing_id: :skip)
93
+
94
+ expect(report[:entries]).to eq([])
95
+ expect(report[:skipped]).to eq(2)
96
+ expect(report[:filtered]).to eq(0)
97
+ expect(report[:errors].map { |e| e[:feature_index] }).to eq([0, 1])
98
+ end
99
+
100
+ it "reports invalid geometry with capped errors and exact skipped count" do
101
+ json = File.binread(File.join(fixture_dir, "invalid_geometry_middle.geojson"))
102
+
103
+ report = TG::Geometry::FeatureSource.read_entries_json(json,
104
+ report: true,
105
+ on_invalid: :skip,
106
+ max_errors: 1)
107
+
108
+ expect(report[:entries].map(&:first)).to eq(["ok-a", "ok-b"])
109
+ expect(report[:skipped]).to eq(1)
110
+ expect(report[:errors].size).to eq(1)
111
+ expect(report[:errors].first[:feature_index]).to eq(1)
112
+ expect(report[:errors].first[:reason]).to include("invalid geometry")
113
+ end
114
+
115
+ it "raises ParseError for malformed JSON and non FeatureCollection roots" do
116
+ malformed = File.binread(File.join(fixture_dir, "malformed_json.geojson"))
117
+
118
+ expect { TG::Geometry::FeatureSource.read_entries_json(malformed) }
119
+ .to raise_error(TG::Geometry::ParseError)
120
+ expect { TG::Geometry::FeatureSource.read_entries_json('{"type":"Feature"}') }
121
+ .to raise_error(TG::Geometry::ParseError)
122
+ end
123
+
124
+ it "builds an Index directly from JSON, file, and IO" do
125
+ indexes = [
126
+ TG::Geometry::FeatureSource.build_index_json(simple_json, strategy: :flat),
127
+ TG::Geometry::FeatureSource.build_index_file(simple_path, strategy: :flat),
128
+ TG::Geometry::FeatureSource.build_index_io(StringIO.new(simple_json), strategy: :flat)
129
+ ]
130
+
131
+ indexes.each do |index|
132
+ expect(index).to be_a(TG::Geometry::Index)
133
+ expect(index).to be_frozen
134
+ expect(index.size).to eq(2)
135
+ expect(index.find_covering(5, 5)).to eq("zone/a")
136
+ expect(index.find_covering(25, 25)).to eq(2)
137
+ end
138
+ end
139
+
140
+ it "matches Index.build(read_entries, via: :geojson) behavior" do
141
+ entries = TG::Geometry::FeatureSource.read_entries_json(simple_json)
142
+ from_entries = TG::Geometry::Index.build(entries, via: :geojson, strategy: :flat)
143
+ direct = TG::Geometry::FeatureSource.build_index_json(simple_json, strategy: :flat)
144
+
145
+ [[5, 5], [25, 25], [100, 100]].each do |lon, lat|
146
+ expect(direct.find_covering(lon, lat)).to eq(from_entries.find_covering(lon, lat))
147
+ end
148
+ end
149
+
150
+
151
+ it "raises ParseError with a string reason for missing geometry" do
152
+ json = '{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"@id":"x"}}]}'
153
+
154
+ expect { TG::Geometry::FeatureSource.read_entries_json(json) }
155
+ .to raise_error(TG::Geometry::ParseError, /feature 0.*missing geometry/)
156
+ end
157
+
158
+ it "reports missing geometry with a string reason in skip mode" do
159
+ json = '{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"@id":"x"}}]}'
160
+
161
+ report = TG::Geometry::FeatureSource.read_entries_json(
162
+ json,
163
+ report: true,
164
+ on_invalid: :skip
165
+ )
166
+
167
+ expect(report[:entries]).to eq([])
168
+ expect(report[:skipped]).to eq(1)
169
+ expect(report[:filtered]).to eq(0)
170
+ expect(report[:errors].first[:reason]).to be_a(String)
171
+ expect(report[:errors].first[:reason]).to include("missing geometry")
172
+ end
173
+
174
+ it "skips invalid id type when on_invalid is skip and report is true" do
175
+ json = <<~JSON
176
+ {"type":"FeatureCollection","features":[
177
+ {"type":"Feature","properties":{"@id":false},"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]]}}
178
+ ]}
179
+ JSON
180
+
181
+ report = TG::Geometry::FeatureSource.read_entries_json(
182
+ json,
183
+ report: true,
184
+ on_invalid: :skip
185
+ )
186
+
187
+ expect(report[:entries]).to eq([])
188
+ expect(report[:skipped]).to eq(1)
189
+ expect(report[:filtered]).to eq(0)
190
+ expect(report[:errors].first[:reason]).to include("invalid id")
191
+ end
192
+
193
+ it "skips fractional numeric id when on_invalid is skip and report is true" do
194
+ json = <<~JSON
195
+ {"type":"FeatureCollection","features":[
196
+ {"type":"Feature","properties":{"@id":1.2},"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]]}}
197
+ ]}
198
+ JSON
199
+
200
+ report = TG::Geometry::FeatureSource.read_entries_json(
201
+ json,
202
+ report: true,
203
+ on_invalid: :skip
204
+ )
205
+
206
+ expect(report[:entries]).to eq([])
207
+ expect(report[:skipped]).to eq(1)
208
+ expect(report[:errors].first[:reason]).to include("numeric id must be an integer")
209
+ end
210
+
211
+ it "raises ParseError for malformed feature shapes" do
212
+ cases = [
213
+ '[1]',
214
+ '{"type":"Feature","properties":{"@id":"x"},"geometry":null}',
215
+ '{"type":"Feature","properties":{"@id":"x"},"geometry":"bad"}',
216
+ '{"type":"Feature","properties":{"@id":"x"},"geometry":{"coordinates":[]}}',
217
+ '{"type":"Feature","properties":{"@id":"x"},"geometry":{"type":1,"coordinates":[]}}'
218
+ ]
219
+
220
+ cases.each do |feature_json|
221
+ json = "{\"type\":\"FeatureCollection\",\"features\":[#{feature_json}]}"
222
+ expect { TG::Geometry::FeatureSource.read_entries_json(json) }
223
+ .to raise_error(TG::Geometry::ParseError)
224
+ end
225
+ end
226
+
227
+ it "raises ParseError for missing or non-array features" do
228
+ expect { TG::Geometry::FeatureSource.read_entries_json('{"type":"FeatureCollection"}') }
229
+ .to raise_error(TG::Geometry::ParseError, /features/)
230
+
231
+ expect { TG::Geometry::FeatureSource.read_entries_json('{"type":"FeatureCollection","features":{}}') }
232
+ .to raise_error(TG::Geometry::ParseError, /features/)
233
+ end
234
+
235
+ it "treats invalid properties type as invalid feature for read_features" do
236
+ json = <<~JSON
237
+ {"type":"FeatureCollection","features":[
238
+ {"type":"Feature","properties":[],"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]]}}
239
+ ]}
240
+ JSON
241
+
242
+ expect { TG::Geometry::FeatureSource.read_features_json(json, on_missing_id: :ordinal) }
243
+ .to raise_error(TG::Geometry::ParseError, /properties must be an object or null/)
244
+ end
245
+
246
+ it "rejects unknown keywords" do
247
+ expect { TG::Geometry::FeatureSource.read_entries_json(simple_json, typo: 1) }
248
+ .to raise_error(TG::Geometry::ArgumentError, /unknown keyword/)
249
+ end
250
+
251
+ it "surfaces missing file errors as file errors" do
252
+ missing = File.join(fixture_dir, "does_not_exist.geojson")
253
+
254
+ expect { TG::Geometry::FeatureSource.read_entries_file(missing) }
255
+ .to raise_error(Errno::ENOENT)
256
+ end
257
+
258
+ it "rejects invalid id types" do
259
+ json = <<~JSON
260
+ {"type":"FeatureCollection","features":[
261
+ {"type":"Feature","properties":{"@id":false},"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]]}}
262
+ ]}
263
+ JSON
264
+
265
+ expect { TG::Geometry::FeatureSource.read_entries_json(json) }
266
+ .to raise_error(TG::Geometry::ArgumentError)
267
+ end
268
+ end
@@ -0,0 +1,8 @@
1
+ {
2
+ "type": "FeatureCollection",
3
+ "features": [
4
+ {"type":"Feature","properties":{"@id":"ok-a"},"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]] }},
5
+ {"type":"Feature","properties":{"@id":"bad"},"geometry":{"type":"Polygon","coordinates":"not coordinates"}},
6
+ {"type":"Feature","properties":{"@id":"ok-b"},"geometry":{"type":"Polygon","coordinates":[[[2,2],[2,3],[3,3],[3,2],[2,2]]] }}
7
+ ]
8
+ }
@@ -0,0 +1 @@
1
+ {"type":"FeatureCollection","features":[
@@ -0,0 +1,8 @@
1
+ {
2
+ "type": "FeatureCollection",
3
+ "features": [
4
+ {"type":"Feature","properties":{"@id":"poly"},"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]]}},
5
+ {"type":"Feature","properties":{"@id":"point"},"geometry":{"type":"Point","coordinates":[5,5]}},
6
+ {"type":"Feature","properties":{"@id":"line"},"geometry":{"type":"LineString","coordinates":[[0,0],[1,1]]}}
7
+ ]
8
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "type": "FeatureCollection",
3
+ "features": [
4
+ {
5
+ "type": "Feature",
6
+ "properties": {"@id": "relation/100", "admin_level": "8"},
7
+ "geometry": {"type": "Polygon", "coordinates": [[[0,0],[0,1],[1,1],[1,0],[0,0]]]}
8
+ }
9
+ ]
10
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "type": "FeatureCollection",
3
+ "features": [
4
+ {"type":"Feature","properties":null,"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]]}},
5
+ {"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[2,2],[2,3],[3,3],[3,2],[2,2]]]}}
6
+ ]
7
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "type": "FeatureCollection",
3
+ "features": [
4
+ {
5
+ "type": "Feature",
6
+ "properties": {"@id": "zone/a", "name": "A"},
7
+ "geometry": {"type": "Polygon", "coordinates": [[[0,0],[0,10],[10,10],[10,0],[0,0]]]}
8
+ },
9
+ {
10
+ "type": "Feature",
11
+ "properties": {"@id": 2},
12
+ "geometry": {"type": "MultiPolygon", "coordinates": [[[[20,20],[20,30],[30,30],[30,20],[20,20]]]]}
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,16 @@
1
+ # PostGIS / EWKB fixtures
2
+
3
+ These files are committed binary EWKB fixtures used by the v0.3.0 SRID/EWKB tests.
4
+ They are intentionally small and are not generated at spec runtime.
5
+
6
+ Canonical PostGIS generation examples:
7
+
8
+ ```sql
9
+ SELECT ST_AsEWKB(ST_GeomFromText('POINT(37.6 55.7)', 4326));
10
+ SELECT ST_AsEWKB(ST_GeomFromText('POLYGON((0 0,10 0,10 10,0 10,0 0))', 4326));
11
+ SELECT ST_AsEWKB(ST_GeomFromText('POLYGON((0 0,10 0,10 10,0 10,0 0),(2 2,4 2,4 4,2 4,2 2))', 4326));
12
+ SELECT ST_AsEWKB(ST_GeomFromText('POLYGON((0 0,10 0,10 10,0 10,0 0))', 3857));
13
+ ```
14
+
15
+ `multipolygon_large.ewkb` is a synthetic large multipolygon fixture with about 7000 points.
16
+ It stands in for a real imported polygon while keeping the repository small.
@@ -0,0 +1,83 @@
1
+ {
2
+ "type": "FeatureCollection",
3
+ "features": [
4
+ {
5
+ "type": "Feature",
6
+ "properties": {
7
+ "name": "inside_simple"
8
+ },
9
+ "geometry": {
10
+ "type": "Point",
11
+ "coordinates": [
12
+ 1.0,
13
+ 1.0
14
+ ]
15
+ }
16
+ },
17
+ {
18
+ "type": "Feature",
19
+ "properties": {
20
+ "name": "boundary_simple"
21
+ },
22
+ "geometry": {
23
+ "type": "Point",
24
+ "coordinates": [
25
+ 0.0,
26
+ 5.0
27
+ ]
28
+ }
29
+ },
30
+ {
31
+ "type": "Feature",
32
+ "properties": {
33
+ "name": "hole_center"
34
+ },
35
+ "geometry": {
36
+ "type": "Point",
37
+ "coordinates": [
38
+ 3.0,
39
+ 3.0
40
+ ]
41
+ }
42
+ },
43
+ {
44
+ "type": "Feature",
45
+ "properties": {
46
+ "name": "between_hole_and_outer"
47
+ },
48
+ "geometry": {
49
+ "type": "Point",
50
+ "coordinates": [
51
+ 1.0,
52
+ 1.0
53
+ ]
54
+ }
55
+ },
56
+ {
57
+ "type": "Feature",
58
+ "properties": {
59
+ "name": "large_inside"
60
+ },
61
+ "geometry": {
62
+ "type": "Point",
63
+ "coordinates": [
64
+ 100.0,
65
+ 50.0
66
+ ]
67
+ }
68
+ },
69
+ {
70
+ "type": "Feature",
71
+ "properties": {
72
+ "name": "large_outside"
73
+ },
74
+ "geometry": {
75
+ "type": "Point",
76
+ "coordinates": [
77
+ 200.0,
78
+ 50.0
79
+ ]
80
+ }
81
+ }
82
+ ]
83
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "spec_helper"
4
4
 
5
- RSpec.describe "Expansion Block D format coverage" do
5
+ RSpec.describe "Format coverage" do
6
6
  it "roundtrips Hex" do
7
7
  geom = TG::Geometry.parse_wkt("POINT (1 2)")
8
8
  hex = geom.to_hex
@@ -3,7 +3,7 @@
3
3
  require "spec_helper"
4
4
  require "fileutils"
5
5
 
6
- RSpec.describe "Release Core Block 20 fuzz hardening" do
6
+ RSpec.describe "Fuzz hardening" do
7
7
  ITERATIONS = Integer(ENV.fetch("TG_GEOMETRY_FUZZ_ITERATIONS", "2000")).freeze
8
8
  SEED = Integer(ENV.fetch("TG_GEOMETRY_FUZZ_SEED", "20260524")).freeze
9
9
  CORPUS_DIR = File.expand_path("fixtures/fuzz_corpus", __dir__).freeze
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "spec_helper"
4
4
 
5
- RSpec.describe "Release Core Block 4 Geom API" do
5
+ RSpec.describe "Geom API" do
6
6
  let(:polygon_wkt) { "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))" }
7
7
  let(:inner_point_wkt) { "POINT (5 5)" }
8
8
  let(:boundary_point_wkt) { "POINT (0 5)" }
@@ -3,7 +3,7 @@
3
3
  require "spec_helper"
4
4
  require "objspace"
5
5
 
6
- RSpec.describe "Release Core Block 3 Geom parsing" do
6
+ RSpec.describe "Geom parsing" do
7
7
  let(:geojson_point) { %({"type":"Point","coordinates":[1.0,2.0]}) }
8
8
  let(:wkt_point) { "POINT (1 2)" }
9
9
  let(:wkb_point) { [1, 1].pack("CL<") + [1.0, 2.0].pack("E2") }