Files
maptool/test/test_pmtiles_builder.rb
T
randy 4a3862189f
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 16s
Track what was actually built, and flag when it's stale
The .pmtiles files themselves carry no record of what upstream
snapshot they came from — only a local mtime. Each successful build
now writes a small sidecar (offline.pmtiles.meta.json: the Protomaps
build date used; terrain.pmtiles.meta.json: the Mapterhorn source
names/md5sums) recording that, surfaced in the panel next to the
existing size/updated line.

New GET .../check-update routes compare the recorded build against
the live upstream index/manifest — true/false/null, null meaning
"don't know" (no local build yet, or the check itself failed) rather
than a false "no". Kept separate from the plain status endpoints
(which map.js's own topbar dot hits on every page load) since this
one reaches an external service; only called once when the Offline
data panel is actually opened.

Also: flag emoji next to each country in the picker (derived from the
ISO code via Unicode regional indicators, no image assets needed).
2026-09-11 13:18:32 +02:00

325 lines
13 KiB
Ruby

require_relative "test_helper"
require "maptool/pmtiles_builder"
require "tmpdir"
require "fileutils"
# Network calls (the protomaps build index, the mapterhorn manifest) are
# never exercised here — same "keep this offline/deterministic" reasoning
# test_helper.rb already gives for /api/plugins staying off the real
# Overpass API — build_offline/build_terrain take fetch_build_url/
# fetch_manifest precisely so tests can swap those out. A fake `pmtiles`
# CLI (PMTILES_BIN) stands in for the real go-pmtiles binary, logging each
# invocation's subcommand + first arg to PMTILES_LOG so tests can assert on
# what actually got called (e.g. "was merge invoked, and with what").
#
# Country codes below are real ones from the vendored
# lib/maptool/data/europe_countries.geojson (Slovakia, Iceland, Spain) —
# chosen so their real bboxes fall cleanly inside/outside the fake
# manifest's chunk extents below, without needing a second fake geometry
# source.
class PmtilesBuilderTest < Minitest::Test
def setup
@data_dir = Dir.mktmpdir("maptool-pmtiles-test-data")
@bin_dir = Dir.mktmpdir("maptool-pmtiles-test-bin")
@log = File.join(@bin_dir, "pmtiles.log")
# Output path: $3 for `extract SOURCE OUTPUT --region=... --maxzoom=...`
# and `convert INPUT OUTPUT` alike; the *last* arg for `merge INPUT_1
# [INPUT_2 ...] OUTPUT`, since its input count varies.
@pmtiles_ok = File.join(@bin_dir, "pmtiles-ok")
File.write(@pmtiles_ok, <<~SH)
#!/bin/sh
echo "$1 $2" >> "$PMTILES_LOG"
case "$1" in
merge)
for last; do :; done
echo fake > "$last"
;;
*)
echo fake > "$3"
;;
esac
SH
File.chmod(0o755, @pmtiles_ok)
@pmtiles_fail = File.join(@bin_dir, "pmtiles-fail")
File.write(@pmtiles_fail, "#!/bin/sh\necho 'boom' >&2\nexit 1\n")
File.chmod(0o755, @pmtiles_fail)
@original_pmtiles_bin = ENV["PMTILES_BIN"]
ENV["PMTILES_LOG"] = @log
Maptool::PmtilesBuilder.reset!
end
def teardown
FileUtils.remove_entry(@data_dir)
FileUtils.remove_entry(@bin_dir)
ENV["PMTILES_BIN"] = @original_pmtiles_bin
ENV.delete("PMTILES_LOG")
Maptool::PmtilesBuilder.reset!
end
def log_lines
File.exist?(@log) ? File.readlines(@log, chomp: true) : []
end
def select_regions(*codes)
Maptool::PmtilesBuilder.set_selected_regions(@data_dir, codes)
end
# chunk-a covers Slovakia's real extent, chunk-b covers Iceland's —
# Spain falls in neither, for the "no chunk intersects" case.
def fake_manifest(chunk_a_md5: "md5-a")
[
{ "name" => "planet.pmtiles", "url" => "https://example.com/planet.pmtiles", "md5sum" => "md5-planet", "min_lon" => -180.0, "min_lat" => -85.0, "max_lon" => 180.0, "max_lat" => 85.0, "min_zoom" => 0, "max_zoom" => 12 },
{ "name" => "chunk-a.pmtiles", "url" => "https://example.com/chunk-a.pmtiles", "md5sum" => chunk_a_md5, "min_lon" => 16.0, "min_lat" => 47.0, "max_lon" => 23.0, "max_lat" => 50.0, "min_zoom" => 13, "max_zoom" => 15 },
{ "name" => "chunk-b.pmtiles", "url" => "https://example.com/chunk-b.pmtiles", "md5sum" => "md5-b", "min_lon" => -25.0, "min_lat" => 63.0, "max_lon" => -13.0, "max_lat" => 67.0, "min_zoom" => 13, "max_zoom" => 15 },
]
end
def test_offline_and_terrain_status_start_idle
assert_equal({ state: "idle" }, Maptool::PmtilesBuilder.offline_status)
assert_equal({ state: "idle" }, Maptool::PmtilesBuilder.terrain_status)
end
# --- region selection ---
def test_available_regions_includes_known_countries
codes = Maptool::PmtilesBuilder.available_regions.map { |r| r[:code] }
assert_includes codes, "SK"
assert_includes codes, "IS"
end
def test_selected_regions_defaults_to_empty
assert_empty Maptool::PmtilesBuilder.selected_regions(@data_dir)
end
def test_set_selected_regions_round_trips
select_regions("SK", "AT")
assert_equal ["SK", "AT"], Maptool::PmtilesBuilder.selected_regions(@data_dir)
end
def test_set_selected_regions_rejects_an_unknown_code
assert_raises(Maptool::PmtilesBuilder::BuildError) { select_regions("SK", "XX") }
end
# --- offline.pmtiles ---
def test_build_offline_raises_with_no_selection
assert_raises(Maptool::PmtilesBuilder::BuildError) do
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "unused" })
end
end
def test_build_offline_writes_the_file_and_reports_done
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" })
thread.join
assert_path_exists File.join(@data_dir, "offline.pmtiles")
refute_path_exists File.join(@data_dir, "offline.pmtiles.tmp")
assert_equal "done", Maptool::PmtilesBuilder.offline_status[:state]
assert_equal ["extract https://example.com/fake.pmtiles"], log_lines
assert_equal({ "source" => "fake.pmtiles" }, Maptool::PmtilesBuilder.offline_meta(@data_dir))
end
def test_offline_meta_is_nil_before_any_build
assert_nil Maptool::PmtilesBuilder.offline_meta(@data_dir)
end
def test_offline_update_available_is_nil_before_any_build
assert_nil Maptool::PmtilesBuilder.offline_update_available?(@data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" })
end
def test_offline_update_available_is_false_when_unchanged
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" }).join
refute Maptool::PmtilesBuilder.offline_update_available?(@data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" })
end
def test_offline_update_available_is_true_when_a_newer_build_exists
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "https://example.com/old.pmtiles" }).join
assert Maptool::PmtilesBuilder.offline_update_available?(@data_dir, fetch_build_url: -> { "https://example.com/new.pmtiles" })
end
def test_offline_update_available_is_nil_when_the_index_is_unreachable
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" }).join
fetch_build_url = -> { raise Maptool::PmtilesBuilder::BuildError, "could not reach index" }
assert_nil Maptool::PmtilesBuilder.offline_update_available?(@data_dir, fetch_build_url: fetch_build_url)
end
def test_build_offline_reports_an_error_status_on_non_zero_exit
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_fail
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" })
thread.join
refute_path_exists File.join(@data_dir, "offline.pmtiles")
status = Maptool::PmtilesBuilder.offline_status
assert_equal "error", status[:state]
assert_match(/exited with status/, status[:message])
end
def test_build_offline_reports_a_clear_error_when_the_binary_is_missing
select_regions("SK")
ENV["PMTILES_BIN"] = File.join(@bin_dir, "does-not-exist")
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" })
thread.join
status = Maptool::PmtilesBuilder.offline_status
assert_equal "error", status[:state]
assert_match(/go-pmtiles installed/, status[:message])
end
# fetch_build_url blocks on a Queue so the build is deterministically
# still "running" when the second call happens, rather than racing a real
# one to see which state wins.
def test_build_offline_raises_when_one_is_already_running
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
gate = Queue.new
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { gate.pop })
assert_raises(Maptool::PmtilesBuilder::BuildError) do
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "unused" })
end
ensure
gate.push("https://example.com/fake.pmtiles")
thread&.join
end
# --- terrain.pmtiles ---
def test_build_terrain_raises_with_no_selection
assert_raises(Maptool::PmtilesBuilder::BuildError) do
Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest })
end
end
# Spain intersects neither fake chunk — only planet.pmtiles matches, so
# this should be a single extract straight to the final path, no merge.
def test_build_terrain_extracts_only_planet_when_no_chunk_intersects
select_regions("ES")
ENV["PMTILES_BIN"] = @pmtiles_ok
thread = Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest })
thread.join
assert_path_exists File.join(@data_dir, "terrain.pmtiles")
assert_equal "done", Maptool::PmtilesBuilder.terrain_status[:state]
assert_equal ["extract https://example.com/planet.pmtiles"], log_lines
end
# Slovakia intersects chunk-a only — planet.pmtiles (always) plus that
# one chunk get extracted, then merged.
def test_build_terrain_extracts_and_merges_a_single_intersecting_chunk
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
thread = Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest })
thread.join
assert_path_exists File.join(@data_dir, "terrain.pmtiles")
assert_equal "done", Maptool::PmtilesBuilder.terrain_status[:state]
extracted = log_lines.select { |line| line.start_with?("extract ") }
merged = log_lines.select { |line| line.start_with?("merge ") }
assert_equal ["extract https://example.com/planet.pmtiles", "extract https://example.com/chunk-a.pmtiles"], extracted
assert_equal 1, merged.size
assert_equal(
{ "sources" => [{ "name" => "planet.pmtiles", "md5sum" => "md5-planet" }, { "name" => "chunk-a.pmtiles", "md5sum" => "md5-a" }] },
Maptool::PmtilesBuilder.terrain_meta(@data_dir),
)
end
def test_terrain_meta_is_nil_before_any_build
assert_nil Maptool::PmtilesBuilder.terrain_meta(@data_dir)
end
def test_terrain_update_available_is_false_when_unchanged
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest }).join
refute Maptool::PmtilesBuilder.terrain_update_available?(@data_dir, fetch_manifest: -> { fake_manifest })
end
# Same selection, but chunk-a's md5sum has since changed upstream.
def test_terrain_update_available_is_true_when_a_source_changed
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest }).join
assert Maptool::PmtilesBuilder.terrain_update_available?(@data_dir, fetch_manifest: -> { fake_manifest(chunk_a_md5: "md5-a-v2") })
end
# Same recorded sources, but the selection has since grown to also cover
# chunk-b — recorded sources no longer match what the current selection
# would need.
def test_terrain_update_available_is_true_when_the_selection_grew
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest }).join
select_regions("SK", "IS")
assert Maptool::PmtilesBuilder.terrain_update_available?(@data_dir, fetch_manifest: -> { fake_manifest })
end
# Slovakia + Iceland together intersect both fake chunks — planet + both
# chunks, one 3-way merge.
def test_build_terrain_merges_multiple_intersecting_chunks
select_regions("SK", "IS")
ENV["PMTILES_BIN"] = @pmtiles_ok
thread = Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest })
thread.join
assert_equal "done", Maptool::PmtilesBuilder.terrain_status[:state]
extracted = log_lines.count { |line| line.start_with?("extract ") }
assert_equal 3, extracted # planet + chunk-a + chunk-b
assert_equal 1, log_lines.count { |line| line.start_with?("merge ") }
end
def test_build_terrain_reports_a_clear_error_when_the_manifest_is_missing_planet
select_regions("ES")
ENV["PMTILES_BIN"] = @pmtiles_ok
thread = Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest.reject { |i| i["name"] == "planet.pmtiles" } })
thread.join
status = Maptool::PmtilesBuilder.terrain_status
assert_equal "error", status[:state]
assert_match(/missing planet\.pmtiles/, status[:message])
end
def test_build_terrain_reports_an_error_status_on_non_zero_exit
select_regions("ES")
ENV["PMTILES_BIN"] = @pmtiles_fail
thread = Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest })
thread.join
refute_path_exists File.join(@data_dir, "terrain.pmtiles")
status = Maptool::PmtilesBuilder.terrain_status
assert_equal "error", status[:state]
assert_match(/exited with status/, status[:message])
end
def test_build_terrain_raises_when_one_is_already_running
select_regions("ES")
ENV["PMTILES_BIN"] = @pmtiles_ok
gate = Queue.new
thread = Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { gate.pop })
assert_raises(Maptool::PmtilesBuilder::BuildError) do
Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { "unused" })
end
ensure
gate.push(fake_manifest)
thread&.join
end
end