tg_geometry 0.1.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 (76) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +103 -0
  3. data/Gemfile +3 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +385 -0
  6. data/Rakefile +129 -0
  7. data/benchmark/_support.rb +115 -0
  8. data/benchmark/batch_packed_vs_loop.rb +27 -0
  9. data/benchmark/falcon_concurrency.rb +25 -0
  10. data/benchmark/flat_vs_rtree.rb +27 -0
  11. data/benchmark/gvl_threshold.rb +41 -0
  12. data/benchmark/objectspace_memsize.rb +17 -0
  13. data/benchmark/parse_throughput.rb +38 -0
  14. data/benchmark/rss_stability.rb +70 -0
  15. data/docs/ACTIVE_RECORD.md +26 -0
  16. data/docs/ARCHITECTURE.md +130 -0
  17. data/docs/AUTO_STRATEGY.md +15 -0
  18. data/docs/BENCHMARKING.md +75 -0
  19. data/docs/CASUAL_EXAMPLE.md +618 -0
  20. data/docs/CONCURRENCY.md +65 -0
  21. data/docs/ERROR_HANDLING.md +55 -0
  22. data/docs/EXPANSION_E_TO_H_STATUS.md +51 -0
  23. data/docs/FORMAT_COVERAGE.md +23 -0
  24. data/docs/FULL_TG_API_COVERAGE.md +109 -0
  25. data/docs/LIMITATIONS.md +61 -0
  26. data/docs/LOW_LEVEL_GEOMETRY.md +121 -0
  27. data/docs/MEMORY_OWNERSHIP.md +94 -0
  28. data/docs/RACTOR.md +40 -0
  29. data/docs/REGISTRY.md +37 -0
  30. data/docs/RELEASE_CHECKLIST.md +39 -0
  31. data/ext/tg_geometry/extconf.rb +91 -0
  32. data/ext/tg_geometry/tg_geometry_ext.c +3054 -0
  33. data/ext/tg_geometry/tg_geometry_vendor_rtree.c +1 -0
  34. data/ext/tg_geometry/tg_geometry_vendor_tg.c +24 -0
  35. data/ext/tg_geometry/vendor/.vendored +16 -0
  36. data/ext/tg_geometry/vendor/rtree/LICENSE +20 -0
  37. data/ext/tg_geometry/vendor/rtree/README.md +202 -0
  38. data/ext/tg_geometry/vendor/rtree/VERSION +3 -0
  39. data/ext/tg_geometry/vendor/rtree/rtree.c +840 -0
  40. data/ext/tg_geometry/vendor/rtree/rtree.h +105 -0
  41. data/ext/tg_geometry/vendor/tg/LICENSE +19 -0
  42. data/ext/tg_geometry/vendor/tg/README.md +197 -0
  43. data/ext/tg_geometry/vendor/tg/VERSION +3 -0
  44. data/ext/tg_geometry/vendor/tg/tg.c +16010 -0
  45. data/ext/tg_geometry/vendor/tg/tg.h +359 -0
  46. data/lib/tg/geometry/active_record_source.rb +57 -0
  47. data/lib/tg/geometry/registry.rb +119 -0
  48. data/lib/tg/geometry/version.rb +7 -0
  49. data/lib/tg/geometry.rb +6 -0
  50. data/lib/tg_geometry.rb +3 -0
  51. data/script/vendor_libs.rb +264 -0
  52. data/spec/block_10_rtree_strategy_spec.rb +82 -0
  53. data/spec/block_11_rtree_order_spec.rb +53 -0
  54. data/spec/block_12_batch_packed_spec.rb +55 -0
  55. data/spec/block_13_error_hardening_spec.rb +65 -0
  56. data/spec/block_14_memory_gc_hardening_spec.rb +116 -0
  57. data/spec/block_1_skeleton_spec.rb +45 -0
  58. data/spec/block_20_concurrency_spec.rb +157 -0
  59. data/spec/block_20_fuzz_spec.rb +145 -0
  60. data/spec/block_2_vendor_spec.rb +79 -0
  61. data/spec/block_3_geom_parse_spec.rb +89 -0
  62. data/spec/block_4_geom_api_spec.rb +90 -0
  63. data/spec/block_5_rect_api_spec.rb +96 -0
  64. data/spec/block_6_index_build_spec.rb +111 -0
  65. data/spec/block_7_index_owned_geometry_spec.rb +143 -0
  66. data/spec/block_8_index_borrowed_geometry_spec.rb +106 -0
  67. data/spec/block_9_flat_query_spec.rb +65 -0
  68. data/spec/expansion_a_auto_strategy_spec.rb +14 -0
  69. data/spec/expansion_b_registry_spec.rb +47 -0
  70. data/spec/expansion_c_active_record_source_spec.rb +42 -0
  71. data/spec/expansion_d_format_coverage_spec.rb +30 -0
  72. data/spec/expansion_e_low_level_geometry_spec.rb +82 -0
  73. data/spec/expansion_i_ractor_spec.rb +25 -0
  74. data/spec/expansion_j_full_tg_api_coverage_spec.rb +114 -0
  75. data/spec/spec_helper.rb +15 -0
  76. metadata +157 -0
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Release Core Block 20 concurrency hardening" do
6
+ let(:zone_a) { '{"type":"Polygon","coordinates":[[[0,0],[10,0],[10,10],[0,10],[0,0]]]}' }
7
+ let(:zone_b) { '{"type":"Polygon","coordinates":[[[5,5],[15,5],[15,15],[5,15],[5,5]]]}' }
8
+ let(:zone_c) { '{"type":"Polygon","coordinates":[[[100,100],[110,100],[110,110],[100,110],[100,100]]]}' }
9
+
10
+ let(:entries) do
11
+ [
12
+ [:zone_a, zone_a],
13
+ [:zone_b, zone_b],
14
+ [:zone_c, zone_c]
15
+ ]
16
+ end
17
+
18
+ describe "multi-thread read-only queries on a single Index" do
19
+ %i[flat rtree].each do |strategy|
20
+ it "returns identical results across many threads for strategy: #{strategy}" do
21
+ index = TG::Geometry::Index.build(entries, via: :geojson, strategy: strategy)
22
+
23
+ query_points = Array.new(200) do |q|
24
+ [(q % 200) / 10.0, ((q * 7) % 200) / 10.0]
25
+ end
26
+
27
+ thread_count = 8
28
+ iterations_per_thread = 50
29
+
30
+ threads = thread_count.times.map do
31
+ Thread.new do
32
+ results = []
33
+ iterations_per_thread.times do
34
+ query_points.each do |lon, lat|
35
+ results << [
36
+ index.find_covering(lon, lat),
37
+ index.covering_ids(lon, lat),
38
+ index.intersecting_rect(lon, lat, lon + 1.0, lat + 1.0)
39
+ ]
40
+ end
41
+ end
42
+ results
43
+ end
44
+ end
45
+
46
+ results_per_thread = threads.map(&:value)
47
+
48
+ expect(results_per_thread.uniq.length).to eq(1)
49
+ expect(results_per_thread.first.length).to eq(iterations_per_thread * query_points.length)
50
+ end
51
+ end
52
+
53
+ it "preserves Ruby id object identity under concurrent reads with GC.compact" do
54
+ index = TG::Geometry::Index.build(entries, via: :geojson, strategy: :rtree)
55
+ expected = entries.map(&:first)
56
+
57
+ compactor = Thread.new do
58
+ 10.times do
59
+ GC.start
60
+ GC.compact if GC.respond_to?(:compact)
61
+ sleep 0.001
62
+ end
63
+ end
64
+
65
+ readers = 4.times.map do
66
+ Thread.new do
67
+ 1_000.times do
68
+ ids = index.covering_ids(7.0, 7.0)
69
+ ids.each do |id|
70
+ raise "unexpected id: #{id.inspect}" unless expected.include?(id)
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ compactor.join
77
+ readers.each(&:join)
78
+ end
79
+ end
80
+
81
+ describe "reload pattern: old index survives while new index is swapped in" do
82
+ it "lets active readers finish on the old index after a new index replaces it" do
83
+ old_index = TG::Geometry::Index.build(entries, via: :geojson, strategy: :rtree)
84
+ registry = { current: old_index }
85
+
86
+ reader_stop = false
87
+ reader_errors = []
88
+ reader_observations = Queue.new
89
+
90
+ readers = 4.times.map do
91
+ Thread.new do
92
+ local = registry[:current]
93
+ until reader_stop
94
+ begin
95
+ reader_observations << local.find_covering(7, 7)
96
+ reader_observations << local.covering_ids(7, 7).length
97
+ rescue StandardError => e
98
+ reader_errors << e
99
+ reader_stop = true
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ sleep 0.01
106
+
107
+ new_entries = entries + [[:zone_d, zone_a]]
108
+ new_index = TG::Geometry::Index.build(new_entries, via: :geojson, strategy: :rtree)
109
+ registry[:current] = new_index
110
+
111
+ sleep 0.05
112
+
113
+ reader_stop = true
114
+ readers.each(&:join)
115
+
116
+ expect(reader_errors).to be_empty
117
+ expect(reader_observations.size).to be > 0
118
+ expect(old_index.size).to eq(3)
119
+ expect(old_index.find_covering(7, 7)).to eq(:zone_a)
120
+ expect(new_index.size).to eq(4)
121
+ end
122
+
123
+ it "lets the old index become collectable once readers release their reference" do
124
+ geom_a = TG::Geometry.parse_geojson(zone_a)
125
+ geom_b = TG::Geometry.parse_geojson(zone_b)
126
+
127
+ old_index = TG::Geometry::Index.build([[:old, geom_a]], via: :geom, strategy: :rtree)
128
+ new_index = TG::Geometry::Index.build([[:new_a, geom_a], [:new_b, geom_b]],
129
+ via: :geom, strategy: :rtree)
130
+
131
+ old_index = nil
132
+ GC.start
133
+ GC.compact if GC.respond_to?(:compact)
134
+ GC.start
135
+
136
+ expect(new_index.find_covering(5, 5)).to eq(:new_a)
137
+ expect(new_index.size).to eq(2)
138
+ end
139
+ end
140
+
141
+ describe "no mutation after build" do
142
+ it "exposes no public mutation methods on Index" do
143
+ index = TG::Geometry::Index.build(entries, via: :geojson, strategy: :rtree)
144
+
145
+ %i[add delete rebuild! clear << push add_entry append].each do |forbidden|
146
+ expect(index.respond_to?(forbidden)).to be(false), "Index must not respond to ##{forbidden}"
147
+ end
148
+ end
149
+
150
+ it "marks Index as frozen at the Ruby level" do
151
+ index = TG::Geometry::Index.build(entries, via: :geojson, strategy: :rtree)
152
+
153
+ expect(index).to be_frozen
154
+ expect { index.instance_variable_set(:@hacked, true) }.to raise_error(FrozenError)
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "fileutils"
5
+
6
+ RSpec.describe "Release Core Block 20 fuzz hardening" do
7
+ ITERATIONS = Integer(ENV.fetch("TG_GEOMETRY_FUZZ_ITERATIONS", "2000")).freeze
8
+ SEED = Integer(ENV.fetch("TG_GEOMETRY_FUZZ_SEED", "20260524")).freeze
9
+ CORPUS_DIR = File.expand_path("fixtures/fuzz_corpus", __dir__).freeze
10
+
11
+ ACCEPTABLE_ERRORS = [
12
+ TG::Geometry::ParseError,
13
+ TG::Geometry::ArgumentError,
14
+ ::ArgumentError,
15
+ ::EncodingError,
16
+ ::NoMemoryError,
17
+ ::SystemStackError
18
+ ].freeze
19
+
20
+ def acceptable?(error)
21
+ ACCEPTABLE_ERRORS.any? { |k| error.is_a?(k) }
22
+ end
23
+
24
+ def persist_failure(format, input, error)
25
+ FileUtils.mkdir_p(CORPUS_DIR)
26
+ digest = input.hash.abs.to_s(16)
27
+ path = File.join(CORPUS_DIR, "#{format}_#{digest}.bin")
28
+ File.binwrite(path, input)
29
+ warn "[fuzz] persisted crash input for #{format}: #{path} (#{error.class}: #{error.message[0, 80]})"
30
+ end
31
+
32
+ def feed(format, input)
33
+ case format
34
+ when :geojson then TG::Geometry.parse_geojson(input)
35
+ when :wkt then TG::Geometry.parse_wkt(input)
36
+ when :wkb then TG::Geometry.parse_wkb(input)
37
+ when :auto then TG::Geometry.parse(input)
38
+ else raise ArgumentError, "unknown format #{format.inspect}"
39
+ end
40
+ end
41
+
42
+ def fuzz_one(format, input)
43
+ feed(format, input)
44
+ :parsed
45
+ rescue Exception => e # rubocop:disable Lint/RescueException
46
+ if acceptable?(e)
47
+ :rejected
48
+ else
49
+ persist_failure(format, input, e)
50
+ raise
51
+ end
52
+ end
53
+
54
+ shared_examples "fuzz parser" do |format|
55
+ it "tolerates random ASCII strings for #{format}" do
56
+ rng = Random.new(SEED + format.hash)
57
+ results = Hash.new(0)
58
+
59
+ ITERATIONS.times do
60
+ len = rng.rand(0..512)
61
+ input = Array.new(len) { rng.rand(32..126).chr }.join
62
+ results[fuzz_one(format, input)] += 1
63
+ end
64
+
65
+ expect(results.values.sum).to eq(ITERATIONS)
66
+ end
67
+
68
+ it "tolerates random raw bytes for #{format}" do
69
+ rng = Random.new(SEED + format.hash + 1)
70
+ results = Hash.new(0)
71
+
72
+ ITERATIONS.times do
73
+ len = rng.rand(0..512)
74
+ input = rng.bytes(len).force_encoding(Encoding::ASCII_8BIT)
75
+ results[fuzz_one(format, input)] += 1
76
+ end
77
+
78
+ expect(results.values.sum).to eq(ITERATIONS)
79
+ end
80
+
81
+ it "tolerates the empty string for #{format}" do
82
+ fuzz_one(format, "")
83
+ end
84
+
85
+ it "tolerates strings of NUL bytes for #{format}" do
86
+ [1, 16, 256, 4096].each do |size|
87
+ input = ("\x00" * size).force_encoding(Encoding::ASCII_8BIT)
88
+ fuzz_one(format, input)
89
+ end
90
+ end
91
+ end
92
+
93
+ include_examples "fuzz parser", :geojson
94
+ include_examples "fuzz parser", :wkt
95
+ include_examples "fuzz parser", :wkb
96
+ include_examples "fuzz parser", :auto
97
+
98
+ describe "deeply nested GeoJSON" do
99
+ it "rejects pathological nesting without stack-corrupting the process" do
100
+ input = ("{\"type\":\"GeometryCollection\",\"geometries\":[" * 1000) +
101
+ "{\"type\":\"Point\",\"coordinates\":[0,0]}" +
102
+ ("]}" * 1000)
103
+
104
+ fuzz_one(:geojson, input)
105
+ end
106
+
107
+ it "rejects deeply nested JSON arrays" do
108
+ depth = 5_000
109
+ input = ("[" * depth) + ("]" * depth)
110
+ fuzz_one(:geojson, input)
111
+ end
112
+ end
113
+
114
+ describe "huge inputs" do
115
+ it "tolerates a 1 MiB random WKB blob" do
116
+ rng = Random.new(SEED + 9999)
117
+ input = rng.bytes(1 << 20).force_encoding(Encoding::ASCII_8BIT)
118
+ fuzz_one(:wkb, input)
119
+ end
120
+
121
+ it "tolerates a 1 MiB random text blob via auto-detect" do
122
+ rng = Random.new(SEED + 8888)
123
+ bytes = rng.bytes(1 << 20)
124
+ input = bytes.tr("\x00-\x1f", " ").force_encoding(Encoding::UTF_8)
125
+ fuzz_one(:auto, input)
126
+ end
127
+ end
128
+
129
+ describe "regression corpus" do
130
+ it "re-parses every previously persisted crash input without crashing" do
131
+ skip "no corpus directory" unless Dir.exist?(CORPUS_DIR)
132
+
133
+ paths = Dir.glob(File.join(CORPUS_DIR, "*.bin"))
134
+ skip "corpus is empty" if paths.empty?
135
+
136
+ paths.each do |path|
137
+ format = File.basename(path).split("_", 2).first.to_sym
138
+ next unless %i[geojson wkt wkb auto].include?(format)
139
+
140
+ input = File.binread(path)
141
+ fuzz_one(format, input)
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "open3"
5
+ require "tmpdir"
6
+
7
+ RSpec.describe "Release Core Block 2 vendored build" do
8
+ let(:vendor_dir) { File.join(ROOT, "ext", "tg_geometry", "vendor") }
9
+
10
+ it "keeps tidwall/tg vendored under the contract path" do
11
+ %w[tg.c tg.h VERSION].each do |name|
12
+ expect(File.file?(File.join(vendor_dir, "tg", name))).to be(true), "missing vendor/tg/#{name}"
13
+ end
14
+ end
15
+
16
+ it "keeps rtree.c vendored under the contract path" do
17
+ %w[rtree.c rtree.h VERSION].each do |name|
18
+ expect(File.file?(File.join(vendor_dir, "rtree", name))).to be(true), "missing vendor/rtree/#{name}"
19
+ end
20
+ end
21
+
22
+ it "pins upstream commits in VERSION files" do
23
+ tg_version = File.read(File.join(vendor_dir, "tg", "VERSION"))
24
+ rtree_version = File.read(File.join(vendor_dir, "rtree", "VERSION"))
25
+
26
+ expect(tg_version).to include("repo=https://github.com/tidwall/tg.git")
27
+ expect(tg_version).to match(/commit=[0-9a-f]{40}/)
28
+ expect(rtree_version).to include("repo=https://github.com/tidwall/rtree.c.git")
29
+ expect(rtree_version).to match(/commit=[0-9a-f]{40}/)
30
+ end
31
+
32
+ it "verifies the vendor tree without network access" do
33
+ out, err, status = Open3.capture3(RbConfig.ruby, File.join(ROOT, "script", "vendor_libs.rb"), "--verify", chdir: ROOT)
34
+
35
+ expect(status).to be_success, err
36
+ expect(out).to include("vendor verify: ok")
37
+ end
38
+
39
+ it "skips network clone on sync when pinned vendor tree is already current" do
40
+ Dir.mktmpdir("tg-geometry-fake-git-") do |dir|
41
+ fake_git = File.join(dir, "git")
42
+ File.write(fake_git, "#!/bin/sh\necho git must not be called >&2\nexit 99\n")
43
+ File.chmod(0o755, fake_git)
44
+
45
+ env = { "PATH" => "#{dir}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}" }
46
+ out, err, status = Open3.capture3(env, RbConfig.ruby, File.join(ROOT, "script", "vendor_libs.rb"), chdir: ROOT)
47
+
48
+ expect(status).to be_success, err
49
+ expect(out).to include("vendor sync: up to date")
50
+ expect(err).not_to include("git must not be called")
51
+ end
52
+ end
53
+
54
+ it "uses the release-core compiler warning flags" do
55
+ extconf = File.read(File.join(ROOT, "ext", "tg_geometry", "extconf.rb"))
56
+
57
+ expect(extconf).to include('"-std=c11"')
58
+ expect(extconf).to include('"-Wall"')
59
+ expect(extconf).to include('"-Wextra"')
60
+ expect(extconf).to include('"-Wpedantic"')
61
+ expect(extconf).to include('"-O2"')
62
+ expect(extconf).not_to include('"-Werror"')
63
+ expect(extconf).not_to include('"-fvisibility=hidden"')
64
+ end
65
+
66
+ it "does not define forbidden no-atomics macros" do
67
+ extconf = File.read(File.join(ROOT, "ext", "tg_geometry", "extconf.rb"))
68
+ c_extension = File.read(File.join(ROOT, "ext", "tg_geometry", "tg_geometry_ext.c"))
69
+
70
+ expect(extconf).not_to include("-DTG_NOATOMICS")
71
+ expect(extconf).not_to include("-DRTREE_NOATOMICS")
72
+ expect(c_extension).not_to include("rtree_opt_relaxed_atomics")
73
+ end
74
+
75
+ it "builds and loads the extension with vendored sources" do
76
+ expect(File.file?(EXT_SO)).to be(true)
77
+ expect(defined?(TG::Geometry)).to eq("constant")
78
+ end
79
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "objspace"
5
+
6
+ RSpec.describe "Release Core Block 3 Geom parsing" do
7
+ let(:geojson_point) { %({"type":"Point","coordinates":[1.0,2.0]}) }
8
+ let(:wkt_point) { "POINT (1 2)" }
9
+ let(:wkb_point) { [1, 1].pack("CL<") + [1.0, 2.0].pack("E2") }
10
+ let(:wkb_point_hex) { wkb_point.unpack1("H*") }
11
+
12
+ it "parses GeoJSON into an immutable Geom" do
13
+ geom = TG::Geometry.parse_geojson(geojson_point)
14
+
15
+ expect(geom).to be_a(TG::Geometry::Geom)
16
+ expect(geom).to be_frozen
17
+ end
18
+
19
+ it "parses WKT into an immutable Geom" do
20
+ geom = TG::Geometry.parse_wkt(wkt_point)
21
+
22
+ expect(geom).to be_a(TG::Geometry::Geom)
23
+ expect(geom).to be_frozen
24
+ end
25
+
26
+ it "parses WKB into an immutable Geom" do
27
+ geom = TG::Geometry.parse_wkb(wkb_point)
28
+
29
+ expect(geom).to be_a(TG::Geometry::Geom)
30
+ expect(geom).to be_frozen
31
+ end
32
+
33
+ it "supports parse(format:) mapping for auto, GeoJSON, WKT, WKB, hex, and GeoBIN" do
34
+ expect(TG::Geometry.parse(geojson_point, format: :auto)).to be_a(TG::Geometry::Geom)
35
+ expect(TG::Geometry.parse(geojson_point, format: :geojson)).to be_a(TG::Geometry::Geom)
36
+ expect(TG::Geometry.parse(wkt_point, format: :wkt)).to be_a(TG::Geometry::Geom)
37
+ expect(TG::Geometry.parse(wkb_point, format: :wkb)).to be_a(TG::Geometry::Geom)
38
+ expect(TG::Geometry.parse(wkb_point_hex, format: :hex)).to be_a(TG::Geometry::Geom)
39
+ expect { TG::Geometry.parse("invalid geobin".b, format: :geobin) }
40
+ .to raise_error(TG::Geometry::ParseError)
41
+ end
42
+
43
+ it "supports index: symbol mapping" do
44
+ %i[default none natural ystripes].each do |index|
45
+ expect(TG::Geometry.parse_geojson(geojson_point, index: index)).to be_a(TG::Geometry::Geom)
46
+ end
47
+ end
48
+
49
+ it "raises TG::Geometry::ParseError for invalid GeoJSON" do
50
+ expect { TG::Geometry.parse_geojson("not json") }.to raise_error(TG::Geometry::ParseError)
51
+ end
52
+
53
+ it "raises TG::Geometry::ArgumentError for invalid format symbols" do
54
+ expect { TG::Geometry.parse(geojson_point, format: :shape) }
55
+ .to raise_error(TG::Geometry::ArgumentError)
56
+ end
57
+
58
+ it "raises TG::Geometry::ArgumentError for invalid index symbols" do
59
+ expect { TG::Geometry.parse_geojson(geojson_point, index: :packed) }
60
+ .to raise_error(TG::Geometry::ArgumentError)
61
+ end
62
+
63
+ it "reports native memory through ObjectSpace.memsize_of" do
64
+ geom = TG::Geometry.parse_geojson(geojson_point)
65
+
66
+ expect(ObjectSpace.memsize_of(geom)).to be > ObjectSpace.memsize_of(Object.new)
67
+ end
68
+
69
+ it "survives GC.stress parse/free lifecycle" do
70
+ old_stress = GC.stress
71
+ GC.stress = true
72
+
73
+ 25.times do
74
+ expect(TG::Geometry.parse_wkt(wkt_point)).to be_frozen
75
+ end
76
+ ensure
77
+ GC.stress = old_stress
78
+ end
79
+
80
+ it "survives GC.compact after parse when supported" do
81
+ geom = TG::Geometry.parse_geojson(geojson_point)
82
+
83
+ GC.start
84
+ GC.compact if GC.respond_to?(:compact)
85
+
86
+ expect(geom).to be_a(TG::Geometry::Geom)
87
+ expect(geom).to be_frozen
88
+ end
89
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Release Core Block 4 Geom API" do
6
+ let(:polygon_wkt) { "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))" }
7
+ let(:inner_point_wkt) { "POINT (5 5)" }
8
+ let(:boundary_point_wkt) { "POINT (0 5)" }
9
+ let(:outside_point_wkt) { "POINT (11 5)" }
10
+
11
+ it "maps geometry types to Ruby symbols" do
12
+ expect(TG::Geometry.parse_wkt("POINT (1 2)").type).to eq(:point)
13
+ expect(TG::Geometry.parse_wkt(polygon_wkt).type).to eq(:polygon)
14
+ end
15
+
16
+ it "returns a frozen Rect bbox with coordinate accessors" do
17
+ bbox = TG::Geometry.parse_wkt(polygon_wkt).bbox
18
+
19
+ expect(bbox).to be_a(TG::Geometry::Rect)
20
+ expect(bbox).to be_frozen
21
+ expect([bbox.min_x, bbox.min_y, bbox.max_x, bbox.max_y]).to eq([0.0, 0.0, 10.0, 10.0])
22
+ end
23
+
24
+ it "checks covers_xy? for inside, outside, and boundary points" do
25
+ geom = TG::Geometry.parse_wkt(polygon_wkt)
26
+
27
+ expect(geom.covers_xy?(5, 5)).to be(true)
28
+ expect(geom.covers_xy?(0, 5)).to be(true)
29
+ expect(geom.covers_xy?(11, 5)).to be(false)
30
+ end
31
+
32
+ it "keeps contains? strict while covers_xy? includes boundary" do
33
+ geom = TG::Geometry.parse_wkt(polygon_wkt)
34
+ inner = TG::Geometry.parse_wkt(inner_point_wkt)
35
+ boundary = TG::Geometry.parse_wkt(boundary_point_wkt)
36
+ outside = TG::Geometry.parse_wkt(outside_point_wkt)
37
+
38
+ expect(geom.contains?(inner)).to be(true)
39
+ expect(geom.contains?(boundary)).to be(false)
40
+ expect(geom.contains?(outside)).to be(false)
41
+ end
42
+
43
+ it "checks intersects? for basic geometry pairs" do
44
+ geom = TG::Geometry.parse_wkt(polygon_wkt)
45
+ overlapping = TG::Geometry.parse_wkt("POLYGON ((5 5, 12 5, 12 12, 5 12, 5 5))")
46
+ disjoint = TG::Geometry.parse_wkt("POLYGON ((20 20, 30 20, 30 30, 20 30, 20 20))")
47
+
48
+ expect(geom.intersects?(overlapping)).to be(true)
49
+ expect(geom.intersects?(disjoint)).to be(false)
50
+ end
51
+
52
+ it "roundtrips through GeoJSON, WKT, and WKB writers" do
53
+ geom = TG::Geometry.parse_wkt(polygon_wkt)
54
+
55
+ geojson = geom.to_geojson
56
+ wkt = geom.to_wkt
57
+ wkb = geom.to_wkb
58
+
59
+ expect(geojson.encoding).to eq(Encoding::UTF_8)
60
+ expect(wkt.encoding).to eq(Encoding::UTF_8)
61
+ expect(wkb.encoding).to eq(Encoding::BINARY)
62
+
63
+ expect(TG::Geometry.parse_geojson(geojson).type).to eq(:polygon)
64
+ expect(TG::Geometry.parse_wkt(wkt).bbox.max_x).to eq(10.0)
65
+ expect(TG::Geometry.parse_wkb(wkb).covers_xy?(10, 10)).to be(true)
66
+ end
67
+
68
+ it "serializes WKB point output without losing binary bytes" do
69
+ point = TG::Geometry.parse_wkt("POINT (1 2)")
70
+ wkb = point.to_wkb
71
+
72
+ expect(wkb.bytesize).to eq(21)
73
+ expect(wkb.encoding).to eq(Encoding::BINARY)
74
+ expect(TG::Geometry.parse_wkb(wkb).bbox.min_x).to eq(1.0)
75
+ end
76
+
77
+ it "raises TypeError for predicate arguments that are not Geom" do
78
+ geom = TG::Geometry.parse_wkt(polygon_wkt)
79
+
80
+ expect { geom.contains?("not a geom") }.to raise_error(TypeError)
81
+ expect { geom.intersects?(Object.new) }.to raise_error(TypeError)
82
+ end
83
+
84
+ it "rejects non-finite covers_xy? coordinates" do
85
+ geom = TG::Geometry.parse_wkt(polygon_wkt)
86
+
87
+ expect { geom.covers_xy?(Float::NAN, 1.0) }.to raise_error(TG::Geometry::ArgumentError)
88
+ expect { geom.covers_xy?(1.0, Float::INFINITY) }.to raise_error(TG::Geometry::ArgumentError)
89
+ end
90
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Release Core Block 5 Rect API" do
6
+ it "constructs an immutable rect with coordinate accessors" do
7
+ rect = TG::Geometry::Rect.new(1, 2, 5, 8)
8
+
9
+ expect(rect).to be_a(TG::Geometry::Rect)
10
+ expect(rect).to be_frozen
11
+ expect([rect.min_x, rect.min_y, rect.max_x, rect.max_y]).to eq([1.0, 2.0, 5.0, 8.0])
12
+ end
13
+
14
+ it "requires explicit constructor coordinates" do
15
+ expect { TG::Geometry::Rect.new }.to raise_error(ArgumentError)
16
+ expect { TG::Geometry::Rect.new(0, 0, 1) }.to raise_error(ArgumentError)
17
+ end
18
+
19
+ it "rejects invalid coordinate order" do
20
+ expect { TG::Geometry::Rect.new(5, 0, 1, 1) }.to raise_error(TG::Geometry::ArgumentError)
21
+ expect { TG::Geometry::Rect.new(0, 5, 1, 1) }.to raise_error(TG::Geometry::ArgumentError)
22
+ end
23
+
24
+ it "rejects non-finite coordinates" do
25
+ expect { TG::Geometry::Rect.new(Float::NAN, 0, 1, 1) }.to raise_error(TG::Geometry::ArgumentError)
26
+ expect { TG::Geometry::Rect.new(0, 0, Float::INFINITY, 1) }.to raise_error(TG::Geometry::ArgumentError)
27
+ end
28
+
29
+ it "returns center as two floats" do
30
+ expect(TG::Geometry::Rect.new(0, 2, 10, 8).center).to eq([5.0, 5.0])
31
+ end
32
+
33
+ it "checks rectangle intersection inclusively at boundaries" do
34
+ rect = TG::Geometry::Rect.new(0, 0, 10, 10)
35
+
36
+ expect(rect.intersects?(TG::Geometry::Rect.new(5, 5, 12, 12))).to be(true)
37
+ expect(rect.intersects?(TG::Geometry::Rect.new(10, 10, 20, 20))).to be(true)
38
+ expect(rect.intersects?(TG::Geometry::Rect.new(11, 11, 20, 20))).to be(false)
39
+ end
40
+
41
+ it "requires Rect argument for intersects? and expand_to_include" do
42
+ rect = TG::Geometry::Rect.new(0, 0, 10, 10)
43
+
44
+ expect { rect.intersects?(Object.new) }.to raise_error(TypeError)
45
+ expect { rect.expand_to_include("not a rect") }.to raise_error(TypeError)
46
+ end
47
+
48
+ it "checks point containment inclusively at boundaries" do
49
+ rect = TG::Geometry::Rect.new(0, 0, 10, 10)
50
+
51
+ expect(rect.contains_point?(5, 5)).to be(true)
52
+ expect(rect.contains_point?(0, 10)).to be(true)
53
+ expect(rect.contains_point?(11, 5)).to be(false)
54
+ end
55
+
56
+ it "rejects non-finite contains_point? inputs" do
57
+ rect = TG::Geometry::Rect.new(0, 0, 10, 10)
58
+
59
+ expect { rect.contains_point?(Float::NAN, 5) }.to raise_error(TG::Geometry::ArgumentError)
60
+ expect { rect.contains_point?(5, -Float::INFINITY) }.to raise_error(TG::Geometry::ArgumentError)
61
+ end
62
+
63
+ it "expands to include another rect and returns a new frozen rect" do
64
+ rect = TG::Geometry::Rect.new(0, 0, 10, 10)
65
+ expanded = rect.expand_to_include(TG::Geometry::Rect.new(-2, 3, 12, 8))
66
+
67
+ expect(expanded).to be_a(TG::Geometry::Rect)
68
+ expect(expanded).to be_frozen
69
+ expect(expanded).not_to equal(rect)
70
+ expect([expanded.min_x, expanded.min_y, expanded.max_x, expanded.max_y]).to eq([-2.0, 0.0, 12.0, 10.0])
71
+ expect([rect.min_x, rect.min_y, rect.max_x, rect.max_y]).to eq([0.0, 0.0, 10.0, 10.0])
72
+ end
73
+
74
+ it "expands to include a point and returns a new frozen rect" do
75
+ rect = TG::Geometry::Rect.new(0, 0, 10, 10)
76
+ expanded = rect.expand_to_include_point(-3, 12)
77
+
78
+ expect(expanded).to be_a(TG::Geometry::Rect)
79
+ expect(expanded).to be_frozen
80
+ expect(expanded).not_to equal(rect)
81
+ expect([expanded.min_x, expanded.min_y, expanded.max_x, expanded.max_y]).to eq([-3.0, 0.0, 10.0, 12.0])
82
+ end
83
+
84
+ it "rejects non-finite expand_to_include_point inputs" do
85
+ rect = TG::Geometry::Rect.new(0, 0, 10, 10)
86
+
87
+ expect { rect.expand_to_include_point(Float::NAN, 5) }.to raise_error(TG::Geometry::ArgumentError)
88
+ expect { rect.expand_to_include_point(5, Float::INFINITY) }.to raise_error(TG::Geometry::ArgumentError)
89
+ end
90
+
91
+ it "does not expose ambiguous contains? method" do
92
+ rect = TG::Geometry::Rect.new(0, 0, 10, 10)
93
+
94
+ expect(rect).not_to respond_to(:contains?)
95
+ end
96
+ end