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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +103 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +385 -0
- data/Rakefile +129 -0
- data/benchmark/_support.rb +115 -0
- data/benchmark/batch_packed_vs_loop.rb +27 -0
- data/benchmark/falcon_concurrency.rb +25 -0
- data/benchmark/flat_vs_rtree.rb +27 -0
- data/benchmark/gvl_threshold.rb +41 -0
- data/benchmark/objectspace_memsize.rb +17 -0
- data/benchmark/parse_throughput.rb +38 -0
- data/benchmark/rss_stability.rb +70 -0
- data/docs/ACTIVE_RECORD.md +26 -0
- data/docs/ARCHITECTURE.md +130 -0
- data/docs/AUTO_STRATEGY.md +15 -0
- data/docs/BENCHMARKING.md +75 -0
- data/docs/CASUAL_EXAMPLE.md +618 -0
- data/docs/CONCURRENCY.md +65 -0
- data/docs/ERROR_HANDLING.md +55 -0
- data/docs/EXPANSION_E_TO_H_STATUS.md +51 -0
- data/docs/FORMAT_COVERAGE.md +23 -0
- data/docs/FULL_TG_API_COVERAGE.md +109 -0
- data/docs/LIMITATIONS.md +61 -0
- data/docs/LOW_LEVEL_GEOMETRY.md +121 -0
- data/docs/MEMORY_OWNERSHIP.md +94 -0
- data/docs/RACTOR.md +40 -0
- data/docs/REGISTRY.md +37 -0
- data/docs/RELEASE_CHECKLIST.md +39 -0
- data/ext/tg_geometry/extconf.rb +91 -0
- data/ext/tg_geometry/tg_geometry_ext.c +3054 -0
- data/ext/tg_geometry/tg_geometry_vendor_rtree.c +1 -0
- data/ext/tg_geometry/tg_geometry_vendor_tg.c +24 -0
- data/ext/tg_geometry/vendor/.vendored +16 -0
- data/ext/tg_geometry/vendor/rtree/LICENSE +20 -0
- data/ext/tg_geometry/vendor/rtree/README.md +202 -0
- data/ext/tg_geometry/vendor/rtree/VERSION +3 -0
- data/ext/tg_geometry/vendor/rtree/rtree.c +840 -0
- data/ext/tg_geometry/vendor/rtree/rtree.h +105 -0
- data/ext/tg_geometry/vendor/tg/LICENSE +19 -0
- data/ext/tg_geometry/vendor/tg/README.md +197 -0
- data/ext/tg_geometry/vendor/tg/VERSION +3 -0
- data/ext/tg_geometry/vendor/tg/tg.c +16010 -0
- data/ext/tg_geometry/vendor/tg/tg.h +359 -0
- data/lib/tg/geometry/active_record_source.rb +57 -0
- data/lib/tg/geometry/registry.rb +119 -0
- data/lib/tg/geometry/version.rb +7 -0
- data/lib/tg/geometry.rb +6 -0
- data/lib/tg_geometry.rb +3 -0
- data/script/vendor_libs.rb +264 -0
- data/spec/block_10_rtree_strategy_spec.rb +82 -0
- data/spec/block_11_rtree_order_spec.rb +53 -0
- data/spec/block_12_batch_packed_spec.rb +55 -0
- data/spec/block_13_error_hardening_spec.rb +65 -0
- data/spec/block_14_memory_gc_hardening_spec.rb +116 -0
- data/spec/block_1_skeleton_spec.rb +45 -0
- data/spec/block_20_concurrency_spec.rb +157 -0
- data/spec/block_20_fuzz_spec.rb +145 -0
- data/spec/block_2_vendor_spec.rb +79 -0
- data/spec/block_3_geom_parse_spec.rb +89 -0
- data/spec/block_4_geom_api_spec.rb +90 -0
- data/spec/block_5_rect_api_spec.rb +96 -0
- data/spec/block_6_index_build_spec.rb +111 -0
- data/spec/block_7_index_owned_geometry_spec.rb +143 -0
- data/spec/block_8_index_borrowed_geometry_spec.rb +106 -0
- data/spec/block_9_flat_query_spec.rb +65 -0
- data/spec/expansion_a_auto_strategy_spec.rb +14 -0
- data/spec/expansion_b_registry_spec.rb +47 -0
- data/spec/expansion_c_active_record_source_spec.rb +42 -0
- data/spec/expansion_d_format_coverage_spec.rb +30 -0
- data/spec/expansion_e_low_level_geometry_spec.rb +82 -0
- data/spec/expansion_i_ractor_spec.rb +25 -0
- data/spec/expansion_j_full_tg_api_coverage_spec.rb +114 -0
- data/spec/spec_helper.rb +15 -0
- 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
|