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).
493 lines
20 KiB
Ruby
493 lines
20 KiB
Ruby
require_relative "test_helper"
|
|
require "tempfile"
|
|
|
|
class AppTest < Minitest::Test
|
|
include AppTestHelper
|
|
|
|
def test_health
|
|
response = request.get("/api/health")
|
|
assert_equal 200, response.status
|
|
assert_equal({ "status" => "ok", "revision" => "dev" }, json_body(response))
|
|
end
|
|
|
|
def test_index_renders_with_asset_versioning_and_importmap
|
|
response = request.get("/")
|
|
assert_equal 200, response.status
|
|
assert_match(/\/js\/map\.js\?v=\d+/, response.body)
|
|
assert_includes response.body, "importmap"
|
|
assert_includes response.body, "livetrack-panel"
|
|
end
|
|
|
|
# Every public/js/*.js module map.js (or something it imports) can reach
|
|
# needs its own versioned importmap entry (views/layout.erb) or it skips
|
|
# ASSET_VERSION's whole cache-busting story — easy to forget when adding a
|
|
# new module, since the app still works fine locally either way (a plain
|
|
# unmapped import just resolves straight to the unversioned file).
|
|
# map.js itself is excluded: it's the one entry point loaded directly by
|
|
# its own <script> tag, not imported by anything, so it has no importmap
|
|
# entry of its own by design.
|
|
def test_every_own_js_module_has_a_versioned_importmap_entry
|
|
response = request.get("/")
|
|
modules = Dir.glob(File.join(__dir__, "..", "public", "js", "*.js")).map { |path| File.basename(path) } - ["map.js"]
|
|
modules.each do |name|
|
|
assert_match(%r{"/js/#{Regexp.escape(name)}":\s*"/js/#{Regexp.escape(name)}\?v=\d+"}, response.body, "#{name} is missing from the importmap")
|
|
end
|
|
end
|
|
|
|
def test_offline_tiles_reports_unavailable_when_no_pmtiles_present
|
|
response = request.get("/api/offline-tiles")
|
|
assert_equal({ "available" => false }, json_body(response))
|
|
end
|
|
|
|
def test_kiwix_reports_unavailable_when_no_url_configured
|
|
response = request.get("/api/kiwix")
|
|
assert_equal({ "available" => false, "url" => "" }, json_body(response))
|
|
end
|
|
|
|
def test_kiwix_proxy_404s_when_no_url_configured
|
|
assert_equal 404, request.get("/kiwix/search").status
|
|
end
|
|
|
|
def test_kiwix_nearby_404s_when_no_url_configured
|
|
assert_equal 404, request.get("/api/kiwix/nearby", params: { south: "48", west: "17", north: "48.1", east: "17.1" }).status
|
|
end
|
|
|
|
def test_gnss_position_unavailable_with_no_fix
|
|
response = request.get("/api/gnss/position", **logged_in_env)
|
|
assert_equal({ "available" => false }, json_body(response))
|
|
end
|
|
|
|
def test_gnss_position_requires_login
|
|
assert_equal 401, request.get("/api/gnss/position").status
|
|
end
|
|
|
|
# --- auth guard ---
|
|
|
|
def test_write_routes_require_login
|
|
assert_equal 401, request.post("/api/gpx").status
|
|
assert_equal 401, request.post("/api/gpx/folder").status
|
|
assert_equal 401, request.delete("/api/gpx/file/x.gpx").status
|
|
assert_equal 401, request.delete("/api/gpx/folder/x").status
|
|
assert_equal 401, request.post("/api/gpx/move").status
|
|
assert_equal 401, request.post("/api/plugins/test-plugin/run").status
|
|
assert_equal 401, request.post("/api/offline-tiles/build").status
|
|
assert_equal 401, request.post("/api/terrain-tiles/build").status
|
|
end
|
|
|
|
# --- GPX/FIT upload and the tree ---
|
|
|
|
def test_gpx_upload_and_tree
|
|
content = File.read(fixture_path("sample.gpx"))
|
|
response = post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
|
|
|
assert_equal 200, response.status
|
|
body = json_body(response)
|
|
assert_equal ["ride.gpx"], body["paths"]
|
|
assert_equal ["ride.gpx"], body["tree"]["children"].map { |c| c["name"] }
|
|
end
|
|
|
|
def test_gpx_file_returns_geojson_with_track_properties
|
|
content = File.read(fixture_path("sample.gpx"))
|
|
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
|
|
|
response = request.get("/api/gpx/file/ride.gpx")
|
|
assert_equal 200, response.status
|
|
track = json_body(response)["features"].find { |f| f["properties"]["kind"] == "track" }
|
|
assert_operator track["properties"]["distance_meters"], :>, 0
|
|
end
|
|
|
|
def test_geojson_upload_is_served_back_unchanged
|
|
content = '{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"kind":"waypoint","name":"Spot"},"geometry":{"type":"Point","coordinates":[17.1,48.1]}}]}'
|
|
response = post_file("/api/gpx", filename: "spots.geojson", content: content, env: logged_in_env)
|
|
|
|
assert_equal 200, response.status
|
|
assert_equal ["spots.geojson"], json_body(response)["paths"]
|
|
|
|
file_response = request.get("/api/gpx/file/spots.geojson")
|
|
assert_equal 200, file_response.status
|
|
assert_equal "Spot", json_body(file_response)["features"][0]["properties"]["name"]
|
|
end
|
|
|
|
def test_download_of_a_converted_gpx_names_it_gpx_geojson
|
|
content = File.read(fixture_path("sample.gpx"))
|
|
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
|
|
|
response = request.get("/api/gpx/file/ride.gpx?download=geojson", **logged_in_env)
|
|
assert_equal 200, response.status
|
|
assert_includes response.headers["Content-Disposition"], 'filename="ride.gpx.geojson"'
|
|
end
|
|
|
|
def test_download_of_a_native_geojson_keeps_its_own_name
|
|
content = '{"type":"FeatureCollection","features":[]}'
|
|
post_file("/api/gpx", filename: "spots.geojson", content: content, env: logged_in_env)
|
|
|
|
response = request.get("/api/gpx/file/spots.geojson?download=geojson", **logged_in_env)
|
|
assert_equal 200, response.status
|
|
assert_includes response.headers["Content-Disposition"], 'filename="spots.geojson"'
|
|
end
|
|
|
|
def test_download_of_the_original_file_serves_it_unconverted
|
|
content = File.read(fixture_path("sample.gpx"))
|
|
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
|
|
|
response = request.get("/api/gpx/file/ride.gpx?download=original", **logged_in_env)
|
|
assert_equal 200, response.status
|
|
assert_includes response.headers["Content-Disposition"], 'filename="ride.gpx"'
|
|
assert_equal content, response.body
|
|
end
|
|
|
|
def test_download_requires_login
|
|
content = File.read(fixture_path("sample.gpx"))
|
|
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
|
|
|
assert_equal 401, request.get("/api/gpx/file/ride.gpx?download=geojson").status
|
|
assert_equal 401, request.get("/api/gpx/file/ride.gpx?download=original").status
|
|
# Plain viewing (no ?download) stays open to anyone.
|
|
assert_equal 200, request.get("/api/gpx/file/ride.gpx").status
|
|
end
|
|
|
|
def test_unparseable_gpx_returns_422
|
|
post_file("/api/gpx", filename: "bad.gpx", content: "<gpx><trk unclosed>", env: logged_in_env)
|
|
|
|
response = request.get("/api/gpx/file/bad.gpx")
|
|
assert_equal 422, response.status
|
|
end
|
|
|
|
def test_fit_upload_is_parsed_the_same_as_gpx
|
|
fit_bytes = build_fit_file([[100_000_000, 200_000_000], [100_000_100, 200_000_100]])
|
|
response = post_file("/api/gpx", filename: "activity.fit", content: fit_bytes, env: logged_in_env)
|
|
|
|
assert_equal 200, response.status
|
|
assert_equal ["activity.fit"], json_body(response)["paths"]
|
|
|
|
geojson = json_body(request.get("/api/gpx/file/activity.fit"))
|
|
assert_equal 2, geojson["features"][0]["geometry"]["coordinates"][0].size
|
|
end
|
|
|
|
def test_zip_upload_expands_into_its_track_entries_and_skips_the_rest
|
|
zip_bytes = build_zip(
|
|
"one.gpx" => File.read(fixture_path("sample.gpx")),
|
|
"two.fit" => build_fit_file([[100_000_000, 200_000_000]]),
|
|
"readme.txt" => "not a track file",
|
|
)
|
|
response = post_file("/api/gpx?folder=imported", filename: "export.zip", content: zip_bytes,
|
|
content_type: "application/zip", env: logged_in_env)
|
|
|
|
assert_equal 200, response.status
|
|
paths = json_body(response)["paths"]
|
|
assert_equal ["imported/one.gpx", "imported/two.fit"].sort, paths.sort
|
|
|
|
names = json_body(request.get("/api/gpx/tree"))["children"]
|
|
.find { |c| c["name"] == "imported" }["children"].map { |c| c["name"] }
|
|
refute_includes names, "readme.txt"
|
|
end
|
|
|
|
def test_corrupt_zip_is_rejected
|
|
response = post_file("/api/gpx", filename: "bad.zip", content: "not a zip", content_type: "application/zip", env: logged_in_env)
|
|
assert_equal 400, response.status
|
|
end
|
|
|
|
def test_folder_create_move_and_delete
|
|
content = File.read(fixture_path("sample.gpx"))
|
|
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
|
|
|
create = request.post("/api/gpx/folder?path=trips", **logged_in_env)
|
|
assert_equal 200, create.status
|
|
|
|
move = request.post("/api/gpx/move?from=ride.gpx&to_folder=trips", **logged_in_env)
|
|
assert_equal 200, move.status
|
|
assert_equal "trips/ride.gpx", json_body(move)["path"]
|
|
|
|
delete = request.delete("/api/gpx/file/trips/ride.gpx", **logged_in_env)
|
|
assert_equal 200, delete.status
|
|
assert_empty json_body(delete)["tree"]["children"].find { |c| c["name"] == "trips" }["children"]
|
|
end
|
|
|
|
def test_invalid_folder_path_is_rejected
|
|
response = request.post("/api/gpx/folder?path=..%2F..%2Fescape", **logged_in_env)
|
|
assert_equal 400, response.status
|
|
end
|
|
|
|
# --- Notes (hand-drawn points/areas, notes.js) ---
|
|
|
|
def test_notes_accepts_a_polygon_feature
|
|
polygon = JSON.generate(
|
|
"type" => "Feature",
|
|
"properties" => { "name" => "Turning area", "category" => "Note" },
|
|
"geometry" => { "type" => "Polygon", "coordinates" => [[[17.1, 48.1], [17.2, 48.1], [17.2, 48.2], [17.1, 48.1]]] },
|
|
)
|
|
response = request.post("/api/gpx/notes", params: { feature: polygon, name: "Areas" }, **logged_in_env)
|
|
|
|
assert_equal 200, response.status
|
|
geometry = json_body(request.get("/api/gpx/file/Areas.geojson"))["features"][0]["geometry"]
|
|
assert_equal "Polygon", geometry["type"]
|
|
end
|
|
|
|
def test_notes_requires_login
|
|
assert_equal 401, request.post("/api/gpx/notes", params: { feature: sample_note_feature, name: "Notes" }).status
|
|
end
|
|
|
|
def test_notes_creates_a_new_layer_file
|
|
response = request.post("/api/gpx/notes", params: { feature: sample_note_feature, name: "My Notes" }, **logged_in_env)
|
|
|
|
assert_equal 200, response.status
|
|
body = json_body(response)
|
|
assert_equal "My Notes.geojson", body["path"]
|
|
assert_equal ["My Notes.geojson"], body["tree"]["children"].map { |c| c["name"] }
|
|
end
|
|
|
|
def test_notes_appends_to_an_existing_layer_file_instead_of_disambiguating
|
|
first = json_body(request.post("/api/gpx/notes", params: { feature: sample_note_feature("Turning spot"), name: "My Notes" }, **logged_in_env))
|
|
|
|
second = request.post("/api/gpx/notes", params: { feature: sample_note_feature("Narrow gate"), path: first["path"] }, **logged_in_env)
|
|
assert_equal 200, second.status
|
|
assert_equal first["path"], json_body(second)["path"]
|
|
|
|
features = json_body(request.get("/api/gpx/file/#{Rack::Utils.escape_path(first["path"])}"))["features"]
|
|
assert_equal ["Turning spot", "Narrow gate"], features.map { |f| f["properties"]["name"] }
|
|
end
|
|
|
|
def test_notes_rejects_malformed_feature_json
|
|
response = request.post("/api/gpx/notes", params: { feature: "not json", name: "Notes" }, **logged_in_env)
|
|
assert_equal 400, response.status
|
|
end
|
|
|
|
def test_notes_rejects_a_feature_with_no_geometry
|
|
response = request.post("/api/gpx/notes", params: { feature: '{"type":"Feature","properties":{}}', name: "Notes" }, **logged_in_env)
|
|
assert_equal 400, response.status
|
|
end
|
|
|
|
def test_notes_to_a_missing_path_is_404
|
|
response = request.post("/api/gpx/notes", params: { feature: sample_note_feature, path: "nope.geojson" }, **logged_in_env)
|
|
assert_equal 404, response.status
|
|
end
|
|
|
|
def test_notes_update_requires_login
|
|
assert_equal 401, request.put("/api/gpx/notes", params: { feature: sample_note_feature, path: "x.geojson", id: "a" }).status
|
|
end
|
|
|
|
def test_notes_update_edits_the_matching_note
|
|
created = json_body(request.post("/api/gpx/notes", params: { feature: sample_note_feature("Original", id: "a"), name: "My Notes" }, **logged_in_env))
|
|
|
|
response = request.put(
|
|
"/api/gpx/notes",
|
|
params: { feature: sample_note_feature("Edited", id: "a"), path: created["path"], id: "a" },
|
|
**logged_in_env,
|
|
)
|
|
assert_equal 200, response.status
|
|
|
|
features = json_body(request.get("/api/gpx/file/#{Rack::Utils.escape_path(created["path"])}"))["features"]
|
|
assert_equal ["Edited"], features.map { |f| f["properties"]["name"] }
|
|
end
|
|
|
|
def test_notes_update_missing_note_is_404
|
|
created = json_body(request.post("/api/gpx/notes", params: { feature: sample_note_feature("Original", id: "a"), name: "My Notes" }, **logged_in_env))
|
|
|
|
response = request.put(
|
|
"/api/gpx/notes",
|
|
params: { feature: sample_note_feature("Edited", id: "nope"), path: created["path"], id: "nope" },
|
|
**logged_in_env,
|
|
)
|
|
assert_equal 404, response.status
|
|
end
|
|
|
|
def test_notes_delete_requires_login
|
|
assert_equal 401, request.delete("/api/gpx/notes", params: { path: "x.geojson", id: "a" }).status
|
|
end
|
|
|
|
def test_notes_delete_removes_the_matching_note
|
|
created = json_body(request.post("/api/gpx/notes", params: { feature: sample_note_feature("Turning spot", id: "a"), name: "My Notes" }, **logged_in_env))
|
|
request.post("/api/gpx/notes", params: { feature: sample_note_feature("Narrow gate", id: "b"), path: created["path"] }, **logged_in_env)
|
|
|
|
response = request.delete("/api/gpx/notes", params: { path: created["path"], id: "a" }, **logged_in_env)
|
|
assert_equal 200, response.status
|
|
|
|
features = json_body(request.get("/api/gpx/file/#{Rack::Utils.escape_path(created["path"])}"))["features"]
|
|
assert_equal ["Narrow gate"], features.map { |f| f["properties"]["name"] }
|
|
end
|
|
|
|
def test_notes_delete_missing_note_is_404
|
|
created = json_body(request.post("/api/gpx/notes", params: { feature: sample_note_feature("Turning spot", id: "a"), name: "My Notes" }, **logged_in_env))
|
|
|
|
response = request.delete("/api/gpx/notes", params: { path: created["path"], id: "nope" }, **logged_in_env)
|
|
assert_equal 404, response.status
|
|
end
|
|
|
|
# --- Import plugins (lib/maptool/plugins.rb) ---
|
|
|
|
def test_plugins_lists_whatever_is_on_disk
|
|
response = request.get("/api/plugins")
|
|
assert_equal 200, response.status
|
|
assert_equal [{ "name" => "test-plugin", "label" => "Test plugin" }], json_body(response)
|
|
end
|
|
|
|
def test_plugins_run_saves_the_output_as_a_new_geojson_file
|
|
response = request.post(
|
|
"/api/plugins/test-plugin/run",
|
|
params: { south: "48.0", west: "17.0", north: "48.1", east: "17.1" },
|
|
**logged_in_env,
|
|
)
|
|
assert_equal 200, response.status
|
|
body = json_body(response)
|
|
assert_match(/\Atest-plugin_48\.05_17\.05_\d{6}_\d{4}\.geojson\z/, body["path"])
|
|
|
|
saved = json_body(request.get("/api/gpx/file/#{body["path"]}"))
|
|
assert_equal "Test POI", saved["features"][0]["properties"]["name"]
|
|
end
|
|
|
|
def test_plugins_run_requires_a_bbox
|
|
response = request.post("/api/plugins/test-plugin/run", **logged_in_env)
|
|
assert_equal 400, response.status
|
|
end
|
|
|
|
def test_plugins_run_unknown_plugin_is_502
|
|
response = request.post(
|
|
"/api/plugins/nope/run",
|
|
params: { south: "48.0", west: "17.0", north: "48.1", east: "17.1" },
|
|
**logged_in_env,
|
|
)
|
|
assert_equal 502, response.status
|
|
end
|
|
|
|
# --- offline regions ---
|
|
|
|
def test_offline_regions_lists_available_and_selected
|
|
body = json_body(request.get("/api/offline-regions"))
|
|
assert_operator body["available"].size, :>, 0
|
|
assert_includes body["available"], { "code" => "SK", "name" => "Slovakia" }
|
|
assert_equal [], body["selected"]
|
|
end
|
|
|
|
def test_offline_regions_post_requires_login
|
|
assert_equal 401, request.post("/api/offline-regions").status
|
|
end
|
|
|
|
def test_offline_regions_post_saves_the_selection
|
|
response = request.post("/api/offline-regions", params: { "codes" => %w[SK AT] }, **logged_in_env)
|
|
assert_equal 200, response.status
|
|
assert_equal %w[SK AT], json_body(request.get("/api/offline-regions"))["selected"]
|
|
end
|
|
|
|
def test_offline_regions_post_rejects_an_unknown_code
|
|
response = request.post("/api/offline-regions", params: { "codes" => ["XX"] }, **logged_in_env)
|
|
assert_equal 400, response.status
|
|
end
|
|
|
|
# --- pmtiles regeneration ---
|
|
#
|
|
# The full build (network + go-pmtiles) is covered in isolation, with
|
|
# fakes injected, by test_pmtiles_builder.rb — same "keep this offline"
|
|
# reasoning as the rest of this file. What's route-specific and worth
|
|
# covering here is the empty-selection validation and the
|
|
# 409-while-running guard, neither of which needs a real build to
|
|
# actually run.
|
|
|
|
def test_offline_tiles_build_status_defaults_to_idle
|
|
assert_equal({ "state" => "idle" }, json_body(request.get("/api/offline-tiles/build-status")))
|
|
assert_equal({ "state" => "idle" }, json_body(request.get("/api/terrain-tiles/build-status")))
|
|
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
|
|
response = request.post("/api/offline-tiles/build", **logged_in_env)
|
|
assert_equal 409, response.status
|
|
end
|
|
|
|
def test_terrain_tiles_build_is_409_with_no_selection
|
|
response = request.post("/api/terrain-tiles/build", **logged_in_env)
|
|
assert_equal 409, response.status
|
|
end
|
|
|
|
# fetch_build_url blocks on a Queue, called directly (not through the
|
|
# route, which has no way to inject a fake) so the build is
|
|
# deterministically still "running" when the route call below happens.
|
|
def test_offline_tiles_build_is_409_when_one_is_already_running
|
|
Maptool::PmtilesBuilder.set_selected_regions(Maptool::App::DATA_DIR, ["SK"])
|
|
gate = Queue.new
|
|
thread = Maptool::PmtilesBuilder.build_offline(data_dir: Maptool::App::DATA_DIR, fetch_build_url: -> { gate.pop })
|
|
|
|
response = request.post("/api/offline-tiles/build", **logged_in_env)
|
|
assert_equal 409, response.status
|
|
ensure
|
|
gate.push("unused")
|
|
thread&.join
|
|
end
|
|
|
|
# --- LiveTrack webhook ---
|
|
|
|
def test_livetrack_webhook_requires_the_configured_secret
|
|
response = request.post("/api/livetrack/session", params: { secret: "wrong", session_id: "abc" })
|
|
assert_equal 403, response.status
|
|
end
|
|
|
|
def test_livetrack_webhook_stores_session_id_and_token
|
|
response = request.post(
|
|
"/api/livetrack/session",
|
|
params: { secret: ENV.fetch("LIVETRACK_WEBHOOK_SECRET"), session_id: "abc123", session_token: "tok" },
|
|
)
|
|
assert_equal 200, response.status
|
|
|
|
sessions = json_body(request.get("/api/livetrack/sessions"))["sessions"]
|
|
assert_equal "abc123", sessions.first["id"]
|
|
assert_equal "tok", sessions.first["token"]
|
|
end
|
|
|
|
def test_livetrack_track_unknown_session_is_404
|
|
response = request.get("/api/livetrack/sessions/does-not-exist/track")
|
|
assert_equal 404, response.status
|
|
end
|
|
|
|
# LiveTrackClient's actual page-fetching/parsing has its own tests
|
|
# (test_livetrack_client.rb, no network) and was checked against real
|
|
# Garmin sessions by hand during development — this only covers the
|
|
# route's own guard clause, not the success path, since exercising that
|
|
# here would mean a real network call in every test run.
|
|
def test_livetrack_track_session_without_token_is_404
|
|
request.post("/api/livetrack/session", params: { secret: ENV.fetch("LIVETRACK_WEBHOOK_SECRET"), session_id: "no-token" })
|
|
|
|
response = request.get("/api/livetrack/sessions/no-token/track")
|
|
assert_equal 404, response.status
|
|
end
|
|
|
|
private
|
|
|
|
def sample_note_feature(name = "Turning spot", id: nil)
|
|
JSON.generate(
|
|
"type" => "Feature",
|
|
"properties" => { "name" => name, "category" => "Note", "id" => id },
|
|
"geometry" => { "type" => "Point", "coordinates" => [17.1, 48.1] },
|
|
)
|
|
end
|
|
|
|
def le32(n)
|
|
[n].pack("l<")
|
|
end
|
|
|
|
# Mirrors FitTest#build_fit — a minimal single-definition FIT file, just
|
|
# enough to exercise the upload -> store -> parse path end to end.
|
|
def build_fit_file(records)
|
|
definition = [0, 0].pack("CC") + [20].pack("S<") + [2].pack("C") +
|
|
[0, 4, 0x85].pack("C3") + [1, 4, 0x85].pack("C3")
|
|
definition_header = [0b01000000].pack("C")
|
|
data = records.map { |lat, lon| [0b00000000].pack("C") + le32(lat) + le32(lon) }.join
|
|
body = definition_header + definition + data
|
|
header = [12, 16, 0, 0].pack("C2S<") + le32(body.bytesize) + ".FIT"
|
|
header + body + "\x00\x00"
|
|
end
|
|
|
|
def build_zip(entries)
|
|
require "zip"
|
|
Tempfile.create(["test", ".zip"]) do |file|
|
|
file.close
|
|
Zip::File.open(file.path, create: true) do |zip|
|
|
entries.each { |name, content| zip.get_output_stream(name) { |f| f.write(content) } }
|
|
end
|
|
File.binread(file.path)
|
|
end
|
|
end
|
|
end
|