Add a persisted test suite
`bundle exec rake test` (minitest, a default gem but pinned in Gemfile for reproducibility) now covers what ad-hoc scripts kept re-verifying by hand during development: Gpx/Fit/TrackStats parsing, GpxStore/LiveTrackStore file/TTL logic, LiveTrackClient's payload parsing, and app.rb's routes end to end via Rack::MockRequest. MAPTOOL_DATA_DIR (new, app.rb) lets the suite point GPX_STORE/ LIVETRACK_STORE at a throwaway directory instead of real data/ — without it every run would leave uploaded test files behind. Each lib file's old `if __FILE__ == $0` self-check is now redundant with real tests covering the same ground, so removed rather than kept as a second copy of the same assertions. Wired into ci.yml so it isn't just a local convenience.
This commit is contained in:
1 parent
a3c31ace06
commit
cd4eef342b
19 files changed
+706
-174
No files matched your search
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="test">
|
||||
<wpt lat="48.1" lon="17.2">
|
||||
<ele>200</ele>
|
||||
<name>Start</name>
|
||||
<desc>Trailhead, see https://example.com/trail for details</desc>
|
||||
<time>2026-01-01T09:00:00Z</time>
|
||||
</wpt>
|
||||
<trk>
|
||||
<name>Morning Loop</name>
|
||||
<trkseg>
|
||||
<trkpt lat="48.1" lon="17.2">
|
||||
<ele>200</ele>
|
||||
<time>2026-01-01T09:00:00Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="48.101" lon="17.201">
|
||||
<ele>250</ele>
|
||||
<time>2026-01-01T09:10:00Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="48.102" lon="17.202">
|
||||
<ele>230</ele>
|
||||
<time>2026-01-01T09:20:00Z</time>
|
||||
</trkpt>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>
|
||||
@@ -0,0 +1,189 @@
|
||||
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" }, 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
|
||||
|
||||
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_gnss_position_unavailable_with_no_fix
|
||||
response = request.get("/api/gnss/position")
|
||||
assert_equal({ "available" => false }, json_body(response))
|
||||
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
|
||||
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_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
|
||||
|
||||
# --- 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 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
|
||||
@@ -0,0 +1,60 @@
|
||||
require_relative "test_helper"
|
||||
require "maptool/fit"
|
||||
|
||||
class FitTest < Minitest::Test
|
||||
def le32(n)
|
||||
[n].pack("l<")
|
||||
end
|
||||
|
||||
# A minimal hand-built FIT file: header + one definition message (local
|
||||
# type 0, global mesg 20 "record", fields position_lat/position_long,
|
||||
# both sint32) + data messages. Exercises the header/definition/data
|
||||
# framing without needing an external fixture file.
|
||||
def build_fit(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 write_fit(records)
|
||||
path = File.join(Dir.mktmpdir, "test.fit")
|
||||
File.binwrite(path, build_fit(records))
|
||||
path
|
||||
end
|
||||
|
||||
def test_parses_coordinates_from_record_messages
|
||||
path = write_fit([[100_000_000, 200_000_000], [100_000_100, 200_000_100]])
|
||||
|
||||
geojson = Maptool::Fit.to_geojson(path)
|
||||
coords = geojson["features"][0]["geometry"]["coordinates"][0]
|
||||
|
||||
assert_equal 2, coords.size
|
||||
expected_lat = 100_000_000 * Maptool::Fit::SEMICIRCLE_TO_DEGREES
|
||||
expected_lon = 200_000_000 * Maptool::Fit::SEMICIRCLE_TO_DEGREES
|
||||
assert_in_delta expected_lat, coords[0][1], 1e-9
|
||||
assert_in_delta expected_lon, coords[0][0], 1e-9
|
||||
end
|
||||
|
||||
def test_kinds_is_track_when_positioned
|
||||
assert_equal ["track"], Maptool::Fit.kinds(write_fit([[100_000_000, 200_000_000]]))
|
||||
end
|
||||
|
||||
def test_a_file_with_no_positioned_records_has_no_features_or_kinds
|
||||
path = write_fit([])
|
||||
assert_empty Maptool::Fit.to_geojson(path)["features"]
|
||||
assert_empty Maptool::Fit.kinds(path)
|
||||
end
|
||||
|
||||
def test_garbage_input_raises_parse_error
|
||||
path = File.join(Dir.mktmpdir, "garbage.fit")
|
||||
File.binwrite(path, "not a fit file at all")
|
||||
|
||||
assert_raises(Maptool::Fit::ParseError) { Maptool::Fit.to_geojson(path) }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
require_relative "test_helper"
|
||||
require "maptool/gpx"
|
||||
|
||||
class GpxTest < Minitest::Test
|
||||
def test_kinds_detects_waypoints_and_tracks
|
||||
assert_equal %w[waypoint track], Maptool::Gpx.kinds(fixture_path("sample.gpx"))
|
||||
end
|
||||
|
||||
def test_to_geojson_produces_a_waypoint_point_feature
|
||||
geojson = Maptool::Gpx.to_geojson(fixture_path("sample.gpx"))
|
||||
waypoint = geojson["features"].find { |f| f["properties"]["kind"] == "waypoint" }
|
||||
|
||||
assert_equal "Point", waypoint["geometry"]["type"]
|
||||
assert_equal [17.2, 48.1, 200.0], waypoint["geometry"]["coordinates"]
|
||||
assert_equal "Start", waypoint["properties"]["name"]
|
||||
assert_includes waypoint["properties"]["description"], "example.com"
|
||||
end
|
||||
|
||||
def test_to_geojson_produces_a_track_multilinestring_with_stats
|
||||
geojson = Maptool::Gpx.to_geojson(fixture_path("sample.gpx"))
|
||||
track = geojson["features"].find { |f| f["properties"]["kind"] == "track" }
|
||||
|
||||
assert_equal "MultiLineString", track["geometry"]["type"]
|
||||
assert_equal 3, track["geometry"]["coordinates"][0].size
|
||||
assert_equal "Morning Loop", track["properties"]["name"]
|
||||
assert_operator track["properties"]["distance_meters"], :>, 0
|
||||
assert_equal 50.0, track["properties"]["elevation_gain_meters"], "200->250 is the only gain, 250->230 descent not subtracted"
|
||||
assert_equal 1200, track["properties"]["duration_secs"]
|
||||
end
|
||||
|
||||
def test_invalid_xml_raises_parse_error
|
||||
path = File.join(Dir.mktmpdir, "bad.gpx")
|
||||
File.write(path, "<gpx><trk unclosed>")
|
||||
|
||||
assert_raises(Maptool::Gpx::ParseError) { Maptool::Gpx.to_geojson(path) }
|
||||
end
|
||||
|
||||
def test_non_gpx_root_raises_parse_error
|
||||
path = File.join(Dir.mktmpdir, "notgpx.gpx")
|
||||
File.write(path, "<html></html>")
|
||||
|
||||
assert_raises(Maptool::Gpx::ParseError) { Maptool::Gpx.to_geojson(path) }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,70 @@
|
||||
require_relative "test_helper"
|
||||
require "maptool/gpx_store"
|
||||
require "stringio"
|
||||
|
||||
class GpxStoreTest < Minitest::Test
|
||||
def setup
|
||||
@store = Maptool::GpxStore.new(Dir.mktmpdir)
|
||||
end
|
||||
|
||||
def test_save_returns_the_relative_path_and_lists_it_in_the_tree
|
||||
path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new("<gpx></gpx>"))
|
||||
|
||||
assert_equal "ride.gpx", path
|
||||
assert_equal ["ride.gpx"], @store.tree["children"].map { |c| c["name"] }
|
||||
end
|
||||
|
||||
def test_fit_extension_is_preserved_not_forced_to_gpx
|
||||
path = @store.save(folder: "", filename: "activity.fit", io: StringIO.new("binary"))
|
||||
assert_equal "activity.fit", path
|
||||
end
|
||||
|
||||
def test_unrecognized_extension_gets_gpx_appended
|
||||
path = @store.save(folder: "", filename: "notes.txt", io: StringIO.new("x"))
|
||||
assert_equal "notes.txt.gpx", path
|
||||
end
|
||||
|
||||
def test_colliding_filenames_are_disambiguated
|
||||
@store.save(folder: "", filename: "ride.gpx", io: StringIO.new("a"))
|
||||
path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new("b"))
|
||||
|
||||
assert_equal "ride-1.gpx", path
|
||||
end
|
||||
|
||||
def test_save_creates_the_target_folder
|
||||
@store.save(folder: "trips/2026", filename: "ride.gpx", io: StringIO.new("x"))
|
||||
|
||||
folder = @store.tree["children"].find { |c| c["name"] == "trips" }["children"].find { |c| c["name"] == "2026" }
|
||||
assert_equal ["ride.gpx"], folder["children"].map { |c| c["name"] }
|
||||
end
|
||||
|
||||
def test_path_traversal_is_rejected
|
||||
assert_raises(Maptool::GpxStore::InvalidPath) { @store.resolve_file("../../etc/passwd") }
|
||||
assert_raises(Maptool::GpxStore::InvalidPath) { @store.save(folder: "../escape", filename: "x.gpx", io: StringIO.new("x")) }
|
||||
end
|
||||
|
||||
def test_delete_file
|
||||
@store.save(folder: "", filename: "ride.gpx", io: StringIO.new("x"))
|
||||
@store.delete_file("ride.gpx")
|
||||
|
||||
assert_empty @store.tree["children"]
|
||||
end
|
||||
|
||||
def test_delete_missing_file_raises_enoent
|
||||
assert_raises(Errno::ENOENT) { @store.delete_file("nope.gpx") }
|
||||
end
|
||||
|
||||
def test_delete_folder_refuses_the_store_root
|
||||
assert_raises(Maptool::GpxStore::InvalidPath) { @store.delete_folder("") }
|
||||
end
|
||||
|
||||
def test_move_relocates_and_disambiguates
|
||||
@store.save(folder: "", filename: "ride.gpx", io: StringIO.new("a"))
|
||||
@store.save(folder: "archive", filename: "ride.gpx", io: StringIO.new("b"))
|
||||
|
||||
new_path = @store.move(from: "ride.gpx", to_folder: "archive")
|
||||
|
||||
assert_equal "archive/ride-1.gpx", new_path
|
||||
assert_empty @store.tree["children"].reject { |c| c["name"] == "archive" }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,89 @@
|
||||
ENV["RACK_ENV"] = "test"
|
||||
ENV["LIVETRACK_WEBHOOK_SECRET"] ||= "test-webhook-secret"
|
||||
|
||||
require "fileutils"
|
||||
require "tmpdir"
|
||||
require "minitest/autorun"
|
||||
require "rack"
|
||||
require "json"
|
||||
|
||||
# A fresh throwaway directory per test run, not the real data/ — every
|
||||
# GpxStore/LiveTrackStore write app.rb's routes make during the run lands
|
||||
# here and is gone when the process exits, so running the suite never
|
||||
# touches (or needs to clean up after itself in) real uploaded data.
|
||||
ENV["MAPTOOL_DATA_DIR"] = Dir.mktmpdir("maptool-test-data")
|
||||
Minitest.after_run { FileUtils.remove_entry(ENV["MAPTOOL_DATA_DIR"]) }
|
||||
|
||||
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
||||
require_relative "../app"
|
||||
|
||||
# Available in every test via Minitest::Test below — fixture_path/json_body
|
||||
# are generic enough to be worth having everywhere, not just in app.rb's
|
||||
# own HTTP-level tests (see AppTestHelper for those).
|
||||
module TestHelpers
|
||||
def fixture_path(name)
|
||||
File.join(__dir__, "fixtures", name)
|
||||
end
|
||||
|
||||
def json_body(response)
|
||||
JSON.parse(response.body)
|
||||
end
|
||||
end
|
||||
|
||||
class Minitest::Test
|
||||
include TestHelpers
|
||||
end
|
||||
|
||||
# Only included where needed (test_app.rb) — drives app.rb's Sinatra routes
|
||||
# through Rack::MockRequest, the same way every ad-hoc verification during
|
||||
# development did.
|
||||
module AppTestHelper
|
||||
def app
|
||||
Maptool::App
|
||||
end
|
||||
|
||||
def request
|
||||
@request ||= Rack::MockRequest.new(app)
|
||||
end
|
||||
|
||||
# Sinatra's session is read from env["rack.session"] (populated by the
|
||||
# Rack::Session::Cookie middleware on a real request) — setting it
|
||||
# directly here is equivalent to already having a valid session cookie,
|
||||
# without needing to drive the actual Gitea OAuth2 login flow.
|
||||
def logged_in_env(user: "testuser")
|
||||
{ "rack.session" => { user: user } }
|
||||
end
|
||||
|
||||
# GPX_STORE/LIVETRACK_STORE are process-wide constants pointing at the
|
||||
# one throwaway MAPTOOL_DATA_DIR (see above) — without this, uploads from
|
||||
# one test would still be there (and show up in tree/list assertions) in
|
||||
# the next one, since minitest runs a class's tests in one process.
|
||||
def teardown
|
||||
FileUtils.rm_rf(Dir.glob(File.join(Maptool::App::DATA_DIR, "gpx", "*")))
|
||||
File.write(File.join(Maptool::App::DATA_DIR, "livetrack.json"), "[]")
|
||||
super
|
||||
end
|
||||
|
||||
def multipart_body(boundary, filename, content, content_type: "application/octet-stream", field_name: "file")
|
||||
"--#{boundary}\r\n" \
|
||||
"Content-Disposition: form-data; name=\"#{field_name}\"; filename=\"#{filename}\"\r\n" \
|
||||
"Content-Type: #{content_type}\r\n\r\n" \
|
||||
"#{content}\r\n" \
|
||||
"--#{boundary}--\r\n"
|
||||
end
|
||||
|
||||
# POSTs a single file as a multipart/form-data upload; pass folder in
|
||||
# `path`'s query string (e.g. "/api/gpx?folder=trips") the same way the
|
||||
# real upload form does.
|
||||
def post_file(path, filename:, content:, content_type: "application/octet-stream", env: {})
|
||||
boundary = "TestBoundary#{rand(1_000_000)}"
|
||||
body = multipart_body(boundary, filename, content, content_type: content_type)
|
||||
request.post(
|
||||
path,
|
||||
input: body,
|
||||
"CONTENT_TYPE" => "multipart/form-data; boundary=#{boundary}",
|
||||
"CONTENT_LENGTH" => body.bytesize.to_s,
|
||||
**env,
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
require_relative "test_helper"
|
||||
require "maptool/livetrack_client"
|
||||
|
||||
# No real network calls here — Garmin's page format was verified manually
|
||||
# against real sessions during development; a synthetic fixture (matching
|
||||
# the real page's structure: several `self.__next_f.push(...)` calls, one
|
||||
# holding the track points nested under some data-fetching library's own
|
||||
# structure) is what actually needs to keep working over time.
|
||||
class LiveTrackClientTest < Minitest::Test
|
||||
def setup
|
||||
@client = Maptool::LiveTrackClient.new
|
||||
end
|
||||
|
||||
FAKE_PAGE = <<~HTML.freeze
|
||||
<html><body>
|
||||
<script>self.__next_f.push([1,"6:[\\"$\\",\\"div\\",null,{}]"])</script>
|
||||
<script>self.__next_f.push([1,"9:{\\"session\\":{\\"sessionName\\":\\"Test Ride\\",\\"start\\":\\"2026-01-01T00:00:00Z\\",\\"end\\":\\"2026-01-01T01:00:00Z\\"}}"])</script>
|
||||
<script>self.__next_f.push([1,"10:{\\"unrelated\\":true,\\"nested\\":{\\"data\\":{\\"pages\\":[{\\"trackPoints\\":[{\\"position\\":{\\"lat\\":48.1,\\"lon\\":17.2},\\"dateTime\\":\\"2026-01-01T00:00:00Z\\",\\"totalDistanceMeters\\":\\"$undefined\\"},{\\"position\\":{\\"lat\\":48.2,\\"lon\\":17.3},\\"dateTime\\":\\"2026-01-01T00:01:00Z\\",\\"activityType\\":\\"CYCLING\\",\\"totalDistanceMeters\\":5000,\\"totalDurationSecs\\":600}]}]}}}"])</script>
|
||||
</body></html>
|
||||
HTML
|
||||
|
||||
def test_finds_track_points_and_session_across_separate_push_payloads
|
||||
payloads = @client.send(:parse_payloads, FAKE_PAGE)
|
||||
|
||||
points = @client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }
|
||||
assert_equal 2, points.size
|
||||
assert_in_delta 48.1, points[0]["position"]["lat"]
|
||||
|
||||
session = @client.send(:search, payloads) { |n| n if n["sessionName"] }
|
||||
assert_equal "Test Ride", session["sessionName"]
|
||||
end
|
||||
|
||||
def test_undefined_sentinel_is_cleaned_to_nil
|
||||
payloads = @client.send(:parse_payloads, FAKE_PAGE)
|
||||
points = @client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }
|
||||
cleaned = @client.send(:clean, points)
|
||||
|
||||
assert_nil cleaned[0]["totalDistanceMeters"]
|
||||
end
|
||||
|
||||
def test_summarize_pulls_from_last_point_and_session
|
||||
payloads = @client.send(:parse_payloads, FAKE_PAGE)
|
||||
points = @client.send(:clean, @client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) })
|
||||
session = @client.send(:clean, @client.send(:search, payloads) { |n| n if n["sessionName"] })
|
||||
result = Maptool::LiveTrackClient::Result.new(points: points, session: session)
|
||||
|
||||
summary = Maptool::LiveTrackClient.summarize(result)
|
||||
|
||||
assert_equal "Test Ride", summary["session_name"]
|
||||
assert_equal "CYCLING", summary["activity_type"]
|
||||
assert_equal 5000, summary["distance_meters"]
|
||||
refute summary["active"], "session has an end timestamp"
|
||||
end
|
||||
|
||||
def test_summarize_marks_active_when_session_has_no_end
|
||||
payloads = @client.send(:parse_payloads, FAKE_PAGE)
|
||||
points = @client.send(:clean, @client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) })
|
||||
session = @client.send(:clean, @client.send(:search, payloads) { |n| n if n["sessionName"] }).merge("end" => nil)
|
||||
result = Maptool::LiveTrackClient::Result.new(points: points, session: session)
|
||||
|
||||
assert Maptool::LiveTrackClient.summarize(result)["active"]
|
||||
end
|
||||
|
||||
def test_no_track_points_found_in_a_page_without_them
|
||||
points = @client.send(:search, @client.send(:parse_payloads, "<html></html>")) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }
|
||||
assert_nil points
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
require_relative "test_helper"
|
||||
require "maptool/livetrack_store"
|
||||
|
||||
class LiveTrackStoreTest < Minitest::Test
|
||||
def setup
|
||||
@store = Maptool::LiveTrackStore.new(File.join(Dir.mktmpdir, "livetrack.json"))
|
||||
end
|
||||
|
||||
def test_starts_empty
|
||||
assert_empty @store.list
|
||||
end
|
||||
|
||||
def test_add_and_find
|
||||
@store.add("abc123", "tok-abc")
|
||||
@store.add("def456", "tok-def")
|
||||
|
||||
assert_equal 2, @store.list.size
|
||||
assert_equal "def456", @store.list.first["id"], "newest received first"
|
||||
assert_equal "tok-abc", @store.find("abc123")["token"]
|
||||
assert_nil @store.find("nope")
|
||||
end
|
||||
|
||||
def test_re_adding_moves_to_front_without_duplicating
|
||||
@store.add("abc123", "tok-abc")
|
||||
@store.add("def456", "tok-def")
|
||||
@store.add("abc123", "tok-abc-2")
|
||||
|
||||
assert_equal %w[abc123 def456], @store.list.map { |s| s["id"] }
|
||||
assert_equal "tok-abc-2", @store.find("abc123")["token"], "token updated on re-add"
|
||||
end
|
||||
|
||||
def test_summary_carries_over_across_re_add
|
||||
@store.add("abc123", "tok-abc")
|
||||
@store.set_summary("abc123", { "session_name" => "Test Ride" })
|
||||
@store.add("abc123", "tok-abc-2")
|
||||
|
||||
assert_equal({ "session_name" => "Test Ride" }, @store.find("abc123")["summary"])
|
||||
end
|
||||
|
||||
def test_unsummarized_sessions_are_pruned_after_the_ttl_but_summarized_ones_are_kept
|
||||
@store.add("old_no_summary", "tok1")
|
||||
@store.add("old_with_summary", "tok2")
|
||||
@store.set_summary("old_with_summary", { "session_name" => "Kept" })
|
||||
|
||||
path = @store.instance_variable_get(:@path)
|
||||
raw = JSON.parse(File.read(path))
|
||||
raw.each { |s| s["received_at"] = (Time.now.utc - 1000 * 60 * 60).iso8601 }
|
||||
File.write(path, JSON.generate(raw))
|
||||
|
||||
reloaded = Maptool::LiveTrackStore.new(path)
|
||||
ids = reloaded.list.map { |s| s["id"] }
|
||||
refute_includes ids, "old_no_summary"
|
||||
assert_includes ids, "old_with_summary"
|
||||
end
|
||||
|
||||
def test_data_survives_reload_from_disk
|
||||
@store.add("abc123", "tok-abc")
|
||||
path = @store.instance_variable_get(:@path)
|
||||
|
||||
reloaded = Maptool::LiveTrackStore.new(path)
|
||||
assert_equal 1, reloaded.list.size
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
require_relative "test_helper"
|
||||
require "maptool/track_stats"
|
||||
|
||||
class TrackStatsTest < Minitest::Test
|
||||
def test_computes_distance_and_elevation_gain_ignoring_descents
|
||||
# A right-angle path (equirectangular approximation is accurate enough
|
||||
# at this scale to just assert "nonzero, roughly plausible") with a
|
||||
# climb-then-descend elevation profile, so both distance and
|
||||
# elevation-gain have a real value to check against.
|
||||
points = [
|
||||
[17.0, 48.0, 100],
|
||||
[17.001, 48.0, 150], # +50m gain
|
||||
[17.001, 48.001, 120], # descent — not subtracted
|
||||
]
|
||||
times = [Time.utc(2026, 1, 1, 0, 0, 0), nil, Time.utc(2026, 1, 1, 0, 10, 0)]
|
||||
|
||||
stats = Maptool::TrackStats.compute(points, times: times)
|
||||
|
||||
assert_operator stats["distance_meters"], :>, 100
|
||||
assert_equal 50.0, stats["elevation_gain_meters"]
|
||||
assert_equal 600, stats["duration_secs"]
|
||||
assert_equal 3, stats["point_count"]
|
||||
end
|
||||
|
||||
def test_nil_elevation_gain_and_duration_when_data_is_missing
|
||||
stats = Maptool::TrackStats.compute([[17.0, 48.0], [17.0, 48.0]], times: [])
|
||||
assert_nil stats["elevation_gain_meters"]
|
||||
assert_nil stats["duration_secs"]
|
||||
end
|
||||
|
||||
def test_single_point_has_zero_distance
|
||||
stats = Maptool::TrackStats.compute([[17.0, 48.0]])
|
||||
assert_equal 0.0, stats["distance_meters"]
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user