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).
325 lines
13 KiB
Ruby
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
|