tg_geometry 0.1.0 → 0.2.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -79
  3. data/README.md +82 -191
  4. data/Rakefile +3 -3
  5. data/benchmark/falcon_concurrency.rb +1 -1
  6. data/benchmark/feature_source.rb +92 -0
  7. data/docs/ARCHITECTURE.md +29 -107
  8. data/docs/BENCHMARKING.md +20 -1
  9. data/docs/CASUAL_EXAMPLE.md +71 -458
  10. data/docs/CONCURRENCY.md +13 -7
  11. data/docs/ERROR_HANDLING.md +30 -0
  12. data/docs/FEATURE_SOURCE.md +166 -0
  13. data/docs/LIMITATIONS.md +11 -50
  14. data/docs/MEMORY_OWNERSHIP.md +20 -2
  15. data/ext/tg_geometry/extconf.rb +46 -4
  16. data/ext/tg_geometry/tg_geometry_ext.c +2453 -150
  17. data/ext/tg_geometry/tg_geometry_vendor_json.c +17 -0
  18. data/ext/tg_geometry/tg_geometry_vendor_tg.c +3 -0
  19. data/ext/tg_geometry/vendor/.vendored +8 -2
  20. data/ext/tg_geometry/vendor/json/LICENSE +20 -0
  21. data/ext/tg_geometry/vendor/json/VERSION +3 -0
  22. data/ext/tg_geometry/vendor/json/json.c +1024 -0
  23. data/ext/tg_geometry/vendor/json/json.h +207 -0
  24. data/lib/tg/geometry/registry.rb +3 -3
  25. data/lib/tg/geometry/version.rb +1 -1
  26. data/script/vendor_libs.rb +22 -6
  27. data/spec/{expansion_a_auto_strategy_spec.rb → auto_strategy_spec.rb} +1 -1
  28. data/spec/{block_12_batch_packed_spec.rb → batch_packed_spec.rb} +1 -1
  29. data/spec/{block_20_concurrency_spec.rb → concurrency_spec.rb} +1 -1
  30. data/spec/{block_13_error_hardening_spec.rb → error_hardening_spec.rb} +1 -1
  31. data/spec/feature_source_nogvl_spec.rb +51 -0
  32. data/spec/feature_source_spec.rb +268 -0
  33. data/spec/{expansion_d_format_coverage_spec.rb → format_coverage_spec.rb} +1 -1
  34. data/spec/{block_20_fuzz_spec.rb → fuzz_spec.rb} +1 -1
  35. data/spec/{block_4_geom_api_spec.rb → geom_api_spec.rb} +1 -1
  36. data/spec/{block_3_geom_parse_spec.rb → geom_parse_spec.rb} +1 -1
  37. data/spec/{block_8_index_borrowed_geometry_spec.rb → index_borrowed_geometry_spec.rb} +1 -1
  38. data/spec/{block_6_index_build_spec.rb → index_build_spec.rb} +2 -2
  39. data/spec/{block_9_flat_query_spec.rb → index_flat_query_spec.rb} +1 -1
  40. data/spec/{block_7_index_owned_geometry_spec.rb → index_owned_geometry_spec.rb} +1 -1
  41. data/spec/{block_10_rtree_strategy_spec.rb → index_rtree_accounting_spec.rb} +1 -1
  42. data/spec/{block_11_rtree_order_spec.rb → index_rtree_order_spec.rb} +1 -1
  43. data/spec/{block_1_skeleton_spec.rb → load_and_errors_spec.rb} +1 -1
  44. data/spec/{expansion_e_low_level_geometry_spec.rb → low_level_geometry_spec.rb} +1 -1
  45. data/spec/{block_14_memory_gc_hardening_spec.rb → memory_gc_spec.rb} +1 -1
  46. data/spec/{expansion_i_ractor_spec.rb → ractor_spec.rb} +1 -1
  47. data/spec/{block_5_rect_api_spec.rb → rect_api_spec.rb} +1 -1
  48. data/spec/{expansion_b_registry_spec.rb → registry_spec.rb} +1 -1
  49. data/spec/{expansion_j_full_tg_api_coverage_spec.rb → tg_api_coverage_spec.rb} +1 -1
  50. data/spec/{block_2_vendor_spec.rb → vendor_sources_spec.rb} +4 -4
  51. metadata +39 -38
  52. data/docs/ACTIVE_RECORD.md +0 -26
  53. data/docs/AUTO_STRATEGY.md +0 -15
  54. data/docs/EXPANSION_E_TO_H_STATUS.md +0 -51
  55. data/docs/FORMAT_COVERAGE.md +0 -23
  56. data/docs/FULL_TG_API_COVERAGE.md +0 -109
  57. data/docs/LOW_LEVEL_GEOMETRY.md +0 -121
  58. data/docs/RACTOR.md +0 -40
  59. data/docs/REGISTRY.md +0 -37
  60. data/docs/RELEASE_CHECKLIST.md +0 -39
  61. /data/spec/{expansion_c_active_record_source_spec.rb → active_record_source_spec.rb} +0 -0
@@ -0,0 +1,207 @@
1
+ // https://github.com/tidwall/json.c
2
+ //
3
+ // Copyright 2023 Joshua J Baker. All rights reserved.
4
+ // Use of this source code is governed by an MIT-style
5
+ // license that can be found in the LICENSE file.
6
+ #ifndef JSON_H
7
+ #define JSON_H
8
+
9
+ #include <stddef.h>
10
+ #include <stdint.h>
11
+ #include <stdbool.h>
12
+
13
+ enum json_type {
14
+ JSON_NULL,
15
+ JSON_FALSE,
16
+ JSON_NUMBER,
17
+ JSON_STRING,
18
+ JSON_TRUE,
19
+ JSON_ARRAY,
20
+ JSON_OBJECT,
21
+ };
22
+
23
+ struct json { void *priv[4]; };
24
+
25
+ struct json_valid {
26
+ bool valid;
27
+ size_t pos;
28
+ };
29
+
30
+ // json_valid returns true if the input is valid json data.
31
+ bool json_valid(const char *json_str);
32
+ bool json_validn(const char *json_str, size_t len);
33
+ struct json_valid json_valid_ex(const char *json_str, int opts);
34
+ struct json_valid json_validn_ex(const char *json_str, size_t len, int opts);
35
+
36
+ // json_parse parses the input data and returns a json value.
37
+ //
38
+ // This function expects that the json is well-formed, and does not validate.
39
+ // Invalid json is safe and will not cause program errors, but it may return
40
+ // unexpected results. If you are consuming JSON from an unpredictable source
41
+ // then you may want to use the 'json_valid' function first.
42
+ //
43
+ // It's important to note that the resulting 'struct json' is backed by the
44
+ // same memory as json_str, thus a 'struct json' cannot outlive the original
45
+ // json_str. Attempting to use any of the json_*(struct json...) functions on
46
+ // the result, beyond the lifetime of the originating 'json_str', then it will
47
+ // likely result in a memory fault or some other type of undefined corruption.
48
+ struct json json_parse(const char *json_str);
49
+ struct json json_parsen(const char *json_str, size_t len);
50
+
51
+ // json_first returns the json's first child.
52
+ //
53
+ // For objects, the key of the first value is returned. For arrays, the first
54
+ // child value is returned. If the input json has no children, or is not an
55
+ // object or array, then a non-existent json value is returned.
56
+ struct json json_first(struct json json);
57
+
58
+ // json_next returns the json's next child.
59
+ //
60
+ // This typically follows a 'json_first' call, and is used to iterate over the
61
+ // children in an object or array. For arrays, each 'json_next' call will
62
+ // return a the next value. For objects, each 'json_next' call alternates over
63
+ // value, key, value key, value, etc. If there are no more children, then a
64
+ // non-existent json value is returned.
65
+ struct json json_next(struct json json);
66
+
67
+ // json_exists checks for the existence of json.
68
+ bool json_exists(struct json json);
69
+
70
+ // json_type returns the json's type.
71
+ //
72
+ // Returns one of the following: JSON_NULL, JSON_STRING, JSON_NUMBER,
73
+ // JSON_TRUE, JSON_FALSE, JSON_OBJECT, JSON_ARRAY.
74
+ // For non-existent json, JSON_NULL is returned.
75
+ enum json_type json_type(struct json json);
76
+
77
+ // json_raw returns the start of the raw json data.
78
+ //
79
+ // This function may return NULL if the input is non-existent. Also there is
80
+ // no guarentee that this data will be null-terminated C string. If you want to
81
+ // retain this data for longer that the data from the original 'json_parse'
82
+ // call then you should copy it first, such as:
83
+ //
84
+ // size_t len = json_raw_length(json);
85
+ // char *raw = malloc(len+1);
86
+ // memcpy(raw, json_raw(json), len);
87
+ // raw[len] = '\0';
88
+ //
89
+ const char *json_raw(struct json json);
90
+
91
+ // json_raw_length returns the length of the raw json data.
92
+ size_t json_raw_length(struct json json);
93
+
94
+ // Compare a json's raw data with a C string.
95
+ //
96
+ // This function performs a binary comparison of the characters. It compares
97
+ // each character, one-by-one, of both strings. If they are equal to each
98
+ // other, it continues until the characters differ or until the end of the JSON
99
+ // or a terminating null-character in the C string is reached.
100
+ // Returns -1, 0, +1 for less-than, equal-to, greater-than.
101
+ int json_raw_compare(struct json json, const char *str);
102
+ int json_raw_comparen(struct json json, const char *str, size_t len);
103
+
104
+ // Compares a json string to the provided C string.
105
+ //
106
+ // This function performs a binary comparison of the characters. It compares
107
+ // each character, one-by-one, of both strings. If they are equal to each
108
+ // other, it continues until the characters differ or until the end of the JSON
109
+ // or a terminating null-character in the C string is reached.
110
+ //
111
+ // If the JSON type is JSON_STRING, then each character that needs to be
112
+ // unescaped is done so in a streaming manner. ie. without the additional
113
+ // buffering or memory allocations. Of those characters, the ones that are
114
+ // non-ascii codepoints are encoded into their UTF-8 representation, of which
115
+ // those bytes are then compared to the C string.
116
+ //
117
+ // If the JSON type is not a JSON_STRING, then it performs a binary comparison
118
+ // of the raw JSON to the C string.
119
+ int json_string_compare(struct json json, const char *str);
120
+ int json_string_comparen(struct json json, const char *str, size_t len);
121
+
122
+ // json_string_length returns the number of characters needed to represent the
123
+ // JSON as a UTF-8 string.
124
+ size_t json_string_length(struct json json);
125
+
126
+ // json_string_is_escaped returns true if the json is a string that contains
127
+ // an escape sequence.
128
+ bool json_string_is_escaped(struct json json);
129
+
130
+ // json_string_copy copies a json string into the provided C string buffer.
131
+ //
132
+ // Returns the number of characters, not including the null-terminator, needed
133
+ // to store the JSON into the C string buffer.
134
+ // If the returned length is greater than nbytes-1, then only a parital copy
135
+ // occurred, for example:
136
+ //
137
+ // char buf[64];
138
+ // size_t len = json_string_copy(json, str, sizeof(str));
139
+ // if (len > sizeof(str)-1) {
140
+ // // ... copy did not complete ...
141
+ // }
142
+ //
143
+ size_t json_string_copy(struct json json, char *str, size_t nbytes);
144
+
145
+ // json_array_get returns the child json element at index. This is to be used
146
+ // on json with the type JSON_ARRAY.
147
+ struct json json_array_get(struct json json, size_t index);
148
+
149
+ // json_array_count returns the number of elements in a json array.
150
+ size_t json_array_count(struct json json);
151
+
152
+ // json_object_get returns the json value for its key. This is to be used on
153
+ // json with the type JSON_OBJECT.
154
+ struct json json_object_get(struct json json, const char *key);
155
+ struct json json_object_getn(struct json json, const char *key, size_t len);
156
+
157
+ // json_get finds json at the provide path.
158
+ //
159
+ // A path is a series of keys separated by a dot.
160
+ //
161
+ // json_get(json_str, "name");
162
+ // json_get(json_str, "user.id");
163
+ //
164
+ // The syntax is limited to very basic key names containing only alphanumeric
165
+ // characters, underscores, and dashes. If you need to access keys that have a
166
+ // broader range of characters, you can use the json_object_get and
167
+ // json_array_get functions.
168
+ //
169
+ // json_str = "{\"notes\": {\"special.info\": \"it's nice outside\"}";
170
+ // json = json_parse(json_str);
171
+ // json = json_object_get(json, "notes");
172
+ // json = json_object_get(json, "special.info");
173
+ //
174
+ struct json json_get(const char *json_str, const char *path);
175
+ struct json json_getn(const char *json_str, size_t len, const char *path);
176
+
177
+ // json_double returns a json's double value.
178
+ double json_double(struct json json);
179
+
180
+ // json_int returns a json's int value.
181
+ int json_int(struct json json);
182
+
183
+ // json_int64 returns a json's int64 value.
184
+ int64_t json_int64(struct json json);
185
+
186
+ // json_uint64 returns a json's uint64 value.
187
+ uint64_t json_uint64(struct json json);
188
+
189
+ // json_bool returns a json's boolean value.
190
+ bool json_bool(struct json json);
191
+
192
+ // json_escape is a utility function for converting a C string into a valid
193
+ // json string.
194
+ //
195
+ // Returns the number of characters, not including the null-terminator, needed
196
+ // to store the escaped JSON string in the C string buffer.
197
+ // If the returned length is greater than n-1, then only a parital copy
198
+ // occurred. In other words the
199
+ size_t json_escape(const char *str, char *escaped, size_t n);
200
+ size_t json_escapen(const char *str, size_t len, char *escaped, size_t n);
201
+
202
+ // json_ensure ensures that the json value has been entirely scanned.
203
+ // Only useful for when you need to call json_raw_length on large objects and
204
+ // arrays more than once.
205
+ struct json json_ensure(struct json json);
206
+
207
+ #endif // JSON_H
@@ -11,9 +11,9 @@ module TG
11
11
  }.freeze
12
12
 
13
13
  class << self
14
- def source(&block)
15
- if block
16
- @source = block
14
+ def source(&definition)
15
+ if definition
16
+ @source = definition
17
17
  elsif instance_variable_defined?(:@source)
18
18
  @source
19
19
  elsif superclass.respond_to?(:source)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module TG
4
4
  module Geometry
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -26,6 +26,14 @@ PINS = {
26
26
  sha256: "4afc86cbd3abe03730206031a5aff5b8b29d37b055fc356052f6f06e1d1f9a61",
27
27
  target: "rtree",
28
28
  files: %w[rtree.c rtree.h LICENSE README.md]
29
+ },
30
+ json: {
31
+ repo: "https://github.com/tidwall/json.c.git",
32
+ ref: "main",
33
+ commit: "4aaf99b5c08e4e1364f97500fe5c5d5a2617b4a6",
34
+ sha256: "059393302de9325ab6ccf79c298234f2b688a59d138103305da317d2d0176fe2",
35
+ target: "json",
36
+ files: %w[json.c json.h LICENSE]
29
37
  }
30
38
  }.freeze
31
39
 
@@ -160,9 +168,13 @@ def write_manifest(results)
160
168
  File.utime(NORMALIZED_MTIME, NORMALIZED_MTIME, MANIFEST_PATH)
161
169
  end
162
170
 
163
- def expected_vendor_results
171
+ def expected_vendor_results(manifest = nil)
172
+ manifest ||= parse_kv_file(MANIFEST_PATH)
173
+
164
174
  PINS.each_with_object({}) do |(name, pin), results|
165
- results[name] = pin.merge(tree_sha256: pin.fetch(:sha256))
175
+ manifest_sha = manifest["#{name}_tree_sha256"]
176
+ tree_sha = pin.fetch(:sha256) || manifest_sha
177
+ results[name] = pin.merge(tree_sha256: tree_sha)
166
178
  end
167
179
  end
168
180
 
@@ -186,6 +198,7 @@ end
186
198
 
187
199
  def verify_vendor_tree
188
200
  failures = []
201
+ manifest = parse_kv_file(MANIFEST_PATH)
189
202
 
190
203
  PINS.each do |name, pin|
191
204
  target = File.join(VENDOR_DIR, pin.fetch(:target))
@@ -207,12 +220,15 @@ def verify_vendor_tree
207
220
  next unless failures.none? { |failure| failure.start_with?("#{name}:") }
208
221
 
209
222
  actual_sha256 = tree_sha256_for(target)
210
- expected_sha256 = pin.fetch(:sha256)
211
- failures << "#{name}: tree_sha256 mismatch: expected #{expected_sha256}, got #{actual_sha256}" unless actual_sha256 == expected_sha256
223
+ expected_sha256 = pin.fetch(:sha256) || manifest["#{name}_tree_sha256"]
224
+ if expected_sha256.nil? || expected_sha256.empty?
225
+ failures << "#{name}: tree_sha256 missing from manifest"
226
+ elsif actual_sha256 != expected_sha256
227
+ failures << "#{name}: tree_sha256 mismatch: expected #{expected_sha256}, got #{actual_sha256}"
228
+ end
212
229
  end
213
230
 
214
- expected_results = expected_vendor_results
215
- manifest = parse_kv_file(MANIFEST_PATH)
231
+ expected_results = expected_vendor_results(manifest)
216
232
  if manifest.empty?
217
233
  failures << "manifest: missing #{MANIFEST_PATH}"
218
234
  else
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "spec_helper"
4
4
 
5
- RSpec.describe "Expansion Block A auto strategy status" do
5
+ RSpec.describe "Auto strategy status" do
6
6
  it "does not expose :auto in the first public release" do
7
7
  entries = [[1, '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,1],[0,0]]]}']]
8
8
 
@@ -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]]]}' }
@@ -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
@@ -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)" }