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).
This commit is contained in:
1 parent
53c9a82af2
commit
4a3862189f
7 files changed
+261
-15
No files matched your search
@@ -126,10 +126,11 @@ module Maptool
|
|||||||
halt 401, json(error: "login required", login_url: "/login") unless logged_in?
|
halt 401, json(error: "login required", login_url: "/login") unless logged_in?
|
||||||
end
|
end
|
||||||
|
|
||||||
def pmtiles_file_status(path)
|
def pmtiles_file_status(path, meta)
|
||||||
return { available: false } unless File.exist?(path)
|
return { available: false } unless File.exist?(path)
|
||||||
|
|
||||||
{ available: true, size: File.size(path), mtime: File.mtime(path).utc.iso8601 }
|
status = { available: true, size: File.size(path), mtime: File.mtime(path).utc.iso8601 }
|
||||||
|
meta ? status.merge(meta: meta) : status
|
||||||
end
|
end
|
||||||
|
|
||||||
def callback_url
|
def callback_url
|
||||||
@@ -211,7 +212,7 @@ module Maptool
|
|||||||
end
|
end
|
||||||
|
|
||||||
get "/api/offline-tiles" do
|
get "/api/offline-tiles" do
|
||||||
json pmtiles_file_status(OFFLINE_PMTILES_PATH)
|
json pmtiles_file_status(OFFLINE_PMTILES_PATH, PmtilesBuilder.offline_meta(DATA_DIR))
|
||||||
end
|
end
|
||||||
|
|
||||||
# Served as a plain range-request-capable file, not decoded server-side:
|
# Served as a plain range-request-capable file, not decoded server-side:
|
||||||
@@ -223,6 +224,18 @@ module Maptool
|
|||||||
send_file OFFLINE_PMTILES_PATH, type: "application/octet-stream"
|
send_file OFFLINE_PMTILES_PATH, type: "application/octet-stream"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Compares the recorded build (offline.pmtiles.meta.json) against the
|
||||||
|
# current latest Protomaps build without rebuilding anything —
|
||||||
|
# update_available is null (not true/false) if there's no local build
|
||||||
|
# yet, or the live index couldn't be reached, so the frontend can tell
|
||||||
|
# "don't know" apart from "no, up to date". Not folded into GET
|
||||||
|
# /api/offline-tiles above: that route is on the hot path (map.js's own
|
||||||
|
# topbar-dot check on every page load), this one only gets called when
|
||||||
|
# the Offline data panel is actually opened.
|
||||||
|
get "/api/offline-tiles/check-update" do
|
||||||
|
json update_available: PmtilesBuilder.offline_update_available?(DATA_DIR)
|
||||||
|
end
|
||||||
|
|
||||||
# Lists the selectable countries and which of them are currently
|
# Lists the selectable countries and which of them are currently
|
||||||
# selected (data/offline_regions.json) — read-only, no login needed,
|
# selected (data/offline_regions.json) — read-only, no login needed,
|
||||||
# same tier as the plain availability checks above.
|
# same tier as the plain availability checks above.
|
||||||
@@ -269,7 +282,7 @@ module Maptool
|
|||||||
# docs/development.adoc for how to generate one, until then hillshade
|
# docs/development.adoc for how to generate one, until then hillshade
|
||||||
# just stays unavailable while the offline basemap is active.
|
# just stays unavailable while the offline basemap is active.
|
||||||
get "/api/terrain-tiles" do
|
get "/api/terrain-tiles" do
|
||||||
json pmtiles_file_status(TERRAIN_PMTILES_PATH)
|
json pmtiles_file_status(TERRAIN_PMTILES_PATH, PmtilesBuilder.terrain_meta(DATA_DIR))
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/tiles/terrain.pmtiles" do
|
get "/tiles/terrain.pmtiles" do
|
||||||
@@ -278,6 +291,11 @@ module Maptool
|
|||||||
send_file TERRAIN_PMTILES_PATH, type: "application/octet-stream"
|
send_file TERRAIN_PMTILES_PATH, type: "application/octet-stream"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Same reasoning as GET /api/offline-tiles/check-update above.
|
||||||
|
get "/api/terrain-tiles/check-update" do
|
||||||
|
json update_available: PmtilesBuilder.terrain_update_available?(DATA_DIR)
|
||||||
|
end
|
||||||
|
|
||||||
post "/api/terrain-tiles/build" do
|
post "/api/terrain-tiles/build" do
|
||||||
require_login!
|
require_login!
|
||||||
begin
|
begin
|
||||||
|
|||||||
@@ -127,6 +127,20 @@ matching `*/build-status` route) — only one of each kind at a time;
|
|||||||
starting a second while one's running gets a 409, as does starting
|
starting a second while one's running gets a 409, as does starting
|
||||||
either with nothing selected yet.
|
either with nothing selected yet.
|
||||||
|
|
||||||
|
Each successful build also writes a small sidecar (`offline.pmtiles.
|
||||||
|
meta.json` / `terrain.pmtiles.meta.json` — the Protomaps build date used,
|
||||||
|
or the Mapterhorn source names/md5sums) recording what was actually
|
||||||
|
built, since the `.pmtiles` file itself carries no such record. `GET
|
||||||
|
/api/offline-tiles`/`/api/terrain-tiles` include it as `meta`, shown in
|
||||||
|
the panel next to the existing size/updated line. `GET .../check-update`
|
||||||
|
compares that recorded build against the live upstream index/manifest
|
||||||
|
(without rebuilding anything) and reports `update_available: true/false/
|
||||||
|
null` — `null` means "don't know" (no local build yet, or the upstream
|
||||||
|
check itself failed), kept as its own route rather than folded into the
|
||||||
|
plain status endpoints above since those are on the page-load hot path
|
||||||
|
(the topbar's offline-status dot) and this one reaches an external
|
||||||
|
service, only worth doing when the Offline data panel is actually open.
|
||||||
|
|
||||||
== Camper-relevant POIs (Overpass)
|
== Camper-relevant POIs (Overpass)
|
||||||
|
|
||||||
`rake overpass:pois[south,west,north,east,out.geojson]` queries
|
`rake overpass:pois[south,west,north,east,out.geojson]` queries
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ module Maptool
|
|||||||
|
|
||||||
run_pmtiles!("extract", source, tmp_path, "--region=#{region_path}", "--maxzoom=15")
|
run_pmtiles!("extract", source, tmp_path, "--region=#{region_path}", "--maxzoom=15")
|
||||||
File.rename(tmp_path, File.join(data_dir, "offline.pmtiles"))
|
File.rename(tmp_path, File.join(data_dir, "offline.pmtiles"))
|
||||||
|
File.write(offline_meta_path(data_dir), JSON.generate(source: File.basename(URI(source).path)))
|
||||||
ensure
|
ensure
|
||||||
FileUtils.remove_entry(scratch_dir) if scratch_dir && Dir.exist?(scratch_dir)
|
FileUtils.remove_entry(scratch_dir) if scratch_dir && Dir.exist?(scratch_dir)
|
||||||
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
|
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
|
||||||
@@ -208,6 +209,33 @@ module Maptool
|
|||||||
end
|
end
|
||||||
private_class_method :latest_protomaps_build_url
|
private_class_method :latest_protomaps_build_url
|
||||||
|
|
||||||
|
# Sidecar recording which Protomaps build (a date-stamped filename, e.g.
|
||||||
|
# "20260910.pmtiles") the on-disk offline.pmtiles was actually extracted
|
||||||
|
# from — the file itself carries no such record. nil if never built (or
|
||||||
|
# built before this existed).
|
||||||
|
def self.offline_meta(data_dir)
|
||||||
|
read_json(offline_meta_path(data_dir))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Compares the persisted build against the current latest without
|
||||||
|
# rebuilding anything — nil (not true/false) if there's nothing to
|
||||||
|
# compare yet (no local build) or the upstream index couldn't be
|
||||||
|
# reached, so the caller can tell "don't know" apart from "no".
|
||||||
|
def self.offline_update_available?(data_dir, fetch_build_url: method(:latest_protomaps_build_url))
|
||||||
|
meta = offline_meta(data_dir)
|
||||||
|
return nil unless meta
|
||||||
|
|
||||||
|
latest = File.basename(URI(fetch_build_url.call).path)
|
||||||
|
latest != meta["source"]
|
||||||
|
rescue BuildError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.offline_meta_path(data_dir)
|
||||||
|
File.join(data_dir, "offline.pmtiles.meta.json")
|
||||||
|
end
|
||||||
|
private_class_method :offline_meta_path
|
||||||
|
|
||||||
# --- terrain.pmtiles ---
|
# --- terrain.pmtiles ---
|
||||||
|
|
||||||
# planet.pmtiles (z0-12, always) plus whichever regional chunks
|
# planet.pmtiles (z0-12, always) plus whichever regional chunks
|
||||||
@@ -248,6 +276,7 @@ module Maptool
|
|||||||
run_pmtiles!("merge", *extracts, tmp_path)
|
run_pmtiles!("merge", *extracts, tmp_path)
|
||||||
end
|
end
|
||||||
File.rename(tmp_path, File.join(data_dir, "terrain.pmtiles"))
|
File.rename(tmp_path, File.join(data_dir, "terrain.pmtiles"))
|
||||||
|
File.write(terrain_meta_path(data_dir), JSON.generate(sources: [planet, *chunks].map { |i| { name: i["name"], md5sum: i["md5sum"] } }))
|
||||||
ensure
|
ensure
|
||||||
FileUtils.remove_entry(scratch_dir) if scratch_dir && Dir.exist?(scratch_dir)
|
FileUtils.remove_entry(scratch_dir) if scratch_dir && Dir.exist?(scratch_dir)
|
||||||
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
|
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
|
||||||
@@ -269,8 +298,55 @@ module Maptool
|
|||||||
end
|
end
|
||||||
private_class_method :fetch_mapterhorn_manifest
|
private_class_method :fetch_mapterhorn_manifest
|
||||||
|
|
||||||
|
# Sidecar recording which Mapterhorn sources (name + md5sum) the on-disk
|
||||||
|
# terrain.pmtiles was actually extracted+merged from. nil if never built.
|
||||||
|
def self.terrain_meta(data_dir)
|
||||||
|
read_json(terrain_meta_path(data_dir))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Same "don't know" vs. "no" distinction as offline_update_available?
|
||||||
|
# above. Recomputes which sources *would* be selected for the current
|
||||||
|
# selection (same manifest + bbox_intersects? this build itself uses)
|
||||||
|
# and compares their md5sums against what's recorded — so this also
|
||||||
|
# correctly says "yes" if the selection changed since the last build
|
||||||
|
# (a chunk now relevant that wasn't recorded), not just if Mapterhorn's
|
||||||
|
# own data moved on.
|
||||||
|
def self.terrain_update_available?(data_dir, fetch_manifest: method(:fetch_mapterhorn_manifest))
|
||||||
|
meta = terrain_meta(data_dir)
|
||||||
|
return nil unless meta
|
||||||
|
|
||||||
|
codes = selected_regions(data_dir)
|
||||||
|
return nil if codes.empty?
|
||||||
|
|
||||||
|
items = fetch_manifest.call
|
||||||
|
planet = items.find { |item| item["name"] == MAPTERHORN_PLANET_NAME }
|
||||||
|
return nil unless planet
|
||||||
|
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
_region_path, bbox = region_geojson_and_bbox(codes, dir)
|
||||||
|
south, west, north, east = bbox
|
||||||
|
chunks = items.select { |item| item["name"] != MAPTERHORN_PLANET_NAME && bbox_intersects?(item, south, west, north, east) }
|
||||||
|
current = [planet, *chunks].map { |i| { "name" => i["name"], "md5sum" => i["md5sum"] } }.sort_by { |s| s["name"] }
|
||||||
|
current != (meta["sources"] || []).sort_by { |s| s["name"] }
|
||||||
|
end
|
||||||
|
rescue BuildError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.terrain_meta_path(data_dir)
|
||||||
|
File.join(data_dir, "terrain.pmtiles.meta.json")
|
||||||
|
end
|
||||||
|
private_class_method :terrain_meta_path
|
||||||
|
|
||||||
# --- shared ---
|
# --- shared ---
|
||||||
|
|
||||||
|
def self.read_json(path)
|
||||||
|
File.exist?(path) ? JSON.parse(File.read(path)) : nil
|
||||||
|
rescue JSON::ParserError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
private_class_method :read_json
|
||||||
|
|
||||||
# ENV read lazily (not a load-time constant) so tests can point this at
|
# ENV read lazily (not a load-time constant) so tests can point this at
|
||||||
# a fake script without a require-order dance.
|
# a fake script without a require-order dance.
|
||||||
def self.pmtiles_bin
|
def self.pmtiles_bin
|
||||||
|
|||||||
@@ -466,6 +466,10 @@ html, body {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.offline-data-panel__update-badge {
|
||||||
|
color: #b8860b;
|
||||||
|
}
|
||||||
|
|
||||||
.gpx-panel {
|
.gpx-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -25,6 +25,30 @@ function formatRunning(status) {
|
|||||||
return `Building…${elapsed}`;
|
return `Building…${elapsed}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two Unicode regional indicator symbols, offset from the country's own
|
||||||
|
// ISO A2 letters — the standard zero-asset way to get a flag glyph (most
|
||||||
|
// platforms render the pair as one flag; a code with no assigned flag,
|
||||||
|
// e.g. Kosovo's "XK", just falls back to showing the two letters, not
|
||||||
|
// broken, just plainer).
|
||||||
|
function flagEmoji(code) {
|
||||||
|
return [...code.toUpperCase()].map((c) => String.fromCodePoint(127397 + c.charCodeAt(0))).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// meta.source is a Protomaps build filename like "20260910.pmtiles" — the
|
||||||
|
// leading 8 digits are its build date.
|
||||||
|
function formatOfflineMeta(meta) {
|
||||||
|
if (!meta) return "";
|
||||||
|
const match = meta.source.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||||
|
const date = match ? `${match[1]}-${match[2]}-${match[3]}` : meta.source;
|
||||||
|
return ` · Protomaps ${date}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTerrainMeta(meta) {
|
||||||
|
if (!meta) return "";
|
||||||
|
const count = meta.sources.length;
|
||||||
|
return ` · ${count} source${count === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
|
||||||
// The checklist of selectable countries (GET /api/offline-regions) plus
|
// The checklist of selectable countries (GET /api/offline-regions) plus
|
||||||
// the "Apply changes" button that saves the ticked set (POST, same URL)
|
// the "Apply changes" button that saves the ticked set (POST, same URL)
|
||||||
// and hands it off — doesn't trigger a build itself, OfflineDataControl
|
// and hands it off — doesn't trigger a build itself, OfflineDataControl
|
||||||
@@ -66,7 +90,7 @@ class RegionPicker {
|
|||||||
<li>
|
<li>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" value="${escapeHtml(region.code)}" ${selected.has(region.code) ? "checked" : ""}>
|
<input type="checkbox" value="${escapeHtml(region.code)}" ${selected.has(region.code) ? "checked" : ""}>
|
||||||
${escapeHtml(region.name)}
|
<span aria-hidden="true">${flagEmoji(region.code)}</span> ${escapeHtml(region.name)}
|
||||||
</label>
|
</label>
|
||||||
</li>`,
|
</li>`,
|
||||||
)
|
)
|
||||||
@@ -97,16 +121,19 @@ class RegionPicker {
|
|||||||
// One row of the Offline data panel — just a status readout for either
|
// One row of the Offline data panel — just a status readout for either
|
||||||
// offline.pmtiles or terrain.pmtiles, both driven by the same selection
|
// offline.pmtiles or terrain.pmtiles, both driven by the same selection
|
||||||
// (RegionPicker above). Both files behave identically from here (same
|
// (RegionPicker above). Both files behave identically from here (same
|
||||||
// status/build/build-status endpoint shape, see
|
// status/build/build-status/check-update endpoint shape, see
|
||||||
// lib/maptool/pmtiles_builder.rb), just against different URLs/labels, so
|
// lib/maptool/pmtiles_builder.rb), just against different URLs/labels and
|
||||||
|
// a different way of describing what was actually built (formatMeta), so
|
||||||
// one class covers both rather than writing the same fetch/poll logic
|
// one class covers both rather than writing the same fetch/poll logic
|
||||||
// twice.
|
// twice.
|
||||||
class DataFileRow {
|
class DataFileRow {
|
||||||
constructor({ label, statusUrl, buildUrl, buildStatusUrl }) {
|
constructor({ label, statusUrl, buildUrl, buildStatusUrl, checkUpdateUrl, formatMeta }) {
|
||||||
this._label = label;
|
this._label = label;
|
||||||
this._statusUrl = statusUrl;
|
this._statusUrl = statusUrl;
|
||||||
this._buildUrl = buildUrl;
|
this._buildUrl = buildUrl;
|
||||||
this._buildStatusUrl = buildStatusUrl;
|
this._buildStatusUrl = buildStatusUrl;
|
||||||
|
this._checkUpdateUrl = checkUpdateUrl;
|
||||||
|
this._formatMeta = formatMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
mount(container) {
|
mount(container) {
|
||||||
@@ -129,9 +156,36 @@ class DataFileRow {
|
|||||||
this._statusEl.textContent = "Could not check status.";
|
this._statusEl.textContent = "Could not check status.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._statusEl.textContent = data.available
|
|
||||||
? `${formatBytes(data.size)} · updated ${new Date(data.mtime).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}`
|
if (!data.available) {
|
||||||
: "Not generated yet.";
|
this._statusEl.textContent = "Not generated yet.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = new Date(data.mtime).toLocaleString([], { dateStyle: "short", timeStyle: "short" });
|
||||||
|
this._statusEl.textContent = `${formatBytes(data.size)} · updated ${updated}${this._formatMeta(data.meta)}`;
|
||||||
|
this._checkUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate from _refreshStatus's own fetch (and only called once, right
|
||||||
|
// after — not polled): this one reaches an external service (Protomaps'
|
||||||
|
// build index or Mapterhorn's manifest, see
|
||||||
|
// lib/maptool/pmtiles_builder.rb), so it's kept off the hot status path
|
||||||
|
// and just silently skipped if it fails, same as everywhere else this
|
||||||
|
// app degrades gracefully when an optional network check doesn't answer.
|
||||||
|
async _checkUpdate() {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await (await fetch(this._checkUpdateUrl)).json();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.update_available) return;
|
||||||
|
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "offline-data-panel__update-badge";
|
||||||
|
badge.textContent = " · a newer version is available";
|
||||||
|
this._statusEl.appendChild(badge);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called by OfflineDataControl once RegionPicker's Apply has saved a
|
// Called by OfflineDataControl once RegionPicker's Apply has saved a
|
||||||
@@ -193,12 +247,16 @@ export class OfflineDataControl {
|
|||||||
statusUrl: "/api/offline-tiles",
|
statusUrl: "/api/offline-tiles",
|
||||||
buildUrl: "/api/offline-tiles/build",
|
buildUrl: "/api/offline-tiles/build",
|
||||||
buildStatusUrl: "/api/offline-tiles/build-status",
|
buildStatusUrl: "/api/offline-tiles/build-status",
|
||||||
|
checkUpdateUrl: "/api/offline-tiles/check-update",
|
||||||
|
formatMeta: formatOfflineMeta,
|
||||||
}),
|
}),
|
||||||
new DataFileRow({
|
new DataFileRow({
|
||||||
label: "Offline terrain data",
|
label: "Offline terrain data",
|
||||||
statusUrl: "/api/terrain-tiles",
|
statusUrl: "/api/terrain-tiles",
|
||||||
buildUrl: "/api/terrain-tiles/build",
|
buildUrl: "/api/terrain-tiles/build",
|
||||||
buildStatusUrl: "/api/terrain-tiles/build-status",
|
buildStatusUrl: "/api/terrain-tiles/build-status",
|
||||||
|
checkUpdateUrl: "/api/terrain-tiles/check-update",
|
||||||
|
formatMeta: formatTerrainMeta,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
this._regionPicker = new RegionPicker(() => this._rows.forEach((row) => row.triggerBuild()));
|
this._regionPicker = new RegionPicker(() => this._rows.forEach((row) => row.triggerBuild()));
|
||||||
|
|||||||
@@ -387,6 +387,11 @@ class AppTest < Minitest::Test
|
|||||||
assert_equal({ "state" => "idle" }, json_body(request.get("/api/terrain-tiles/build-status")))
|
assert_equal({ "state" => "idle" }, json_body(request.get("/api/terrain-tiles/build-status")))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_check_update_is_null_before_any_build
|
||||||
|
assert_nil json_body(request.get("/api/offline-tiles/check-update"))["update_available"]
|
||||||
|
assert_nil json_body(request.get("/api/terrain-tiles/check-update"))["update_available"]
|
||||||
|
end
|
||||||
|
|
||||||
def test_offline_tiles_build_is_409_with_no_selection
|
def test_offline_tiles_build_is_409_with_no_selection
|
||||||
response = request.post("/api/offline-tiles/build", **logged_in_env)
|
response = request.post("/api/offline-tiles/build", **logged_in_env)
|
||||||
assert_equal 409, response.status
|
assert_equal 409, response.status
|
||||||
|
|||||||
@@ -69,11 +69,11 @@ class PmtilesBuilderTest < Minitest::Test
|
|||||||
|
|
||||||
# chunk-a covers Slovakia's real extent, chunk-b covers Iceland's —
|
# chunk-a covers Slovakia's real extent, chunk-b covers Iceland's —
|
||||||
# Spain falls in neither, for the "no chunk intersects" case.
|
# Spain falls in neither, for the "no chunk intersects" case.
|
||||||
def fake_manifest
|
def fake_manifest(chunk_a_md5: "md5-a")
|
||||||
[
|
[
|
||||||
{ "name" => "planet.pmtiles", "url" => "https://example.com/planet.pmtiles", "min_lon" => -180.0, "min_lat" => -85.0, "max_lon" => 180.0, "max_lat" => 85.0, "min_zoom" => 0, "max_zoom" => 12 },
|
{ "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", "min_lon" => 16.0, "min_lat" => 47.0, "max_lon" => 23.0, "max_lat" => 50.0, "min_zoom" => 13, "max_zoom" => 15 },
|
{ "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", "min_lon" => -25.0, "min_lat" => 63.0, "max_lon" => -13.0, "max_lat" => 67.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
|
end
|
||||||
|
|
||||||
@@ -121,6 +121,40 @@ class PmtilesBuilderTest < Minitest::Test
|
|||||||
refute_path_exists File.join(@data_dir, "offline.pmtiles.tmp")
|
refute_path_exists File.join(@data_dir, "offline.pmtiles.tmp")
|
||||||
assert_equal "done", Maptool::PmtilesBuilder.offline_status[:state]
|
assert_equal "done", Maptool::PmtilesBuilder.offline_status[:state]
|
||||||
assert_equal ["extract https://example.com/fake.pmtiles"], log_lines
|
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
|
end
|
||||||
|
|
||||||
def test_build_offline_reports_an_error_status_on_non_zero_exit
|
def test_build_offline_reports_an_error_status_on_non_zero_exit
|
||||||
@@ -198,6 +232,43 @@ class PmtilesBuilderTest < Minitest::Test
|
|||||||
merged = log_lines.select { |line| line.start_with?("merge ") }
|
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 ["extract https://example.com/planet.pmtiles", "extract https://example.com/chunk-a.pmtiles"], extracted
|
||||||
assert_equal 1, merged.size
|
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
|
end
|
||||||
|
|
||||||
# Slovakia + Iceland together intersect both fake chunks — planet + both
|
# Slovakia + Iceland together intersect both fake chunks — planet + both
|
||||||
|
|||||||
Reference in new issue
Block a user