Add a persisted test suite
CI / check (push) Successful in 1m56s
CI / deploy (push) Successful in 2m56s

`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:
randy committed 2026-09-07 12:46:35 +02:00
1 parent a3c31ace06
commit cd4eef342b
19 files changed
+706 -174

No files matched your search

+26
View File
@@ -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>
+189
View File
@@ -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
+60
View File
@@ -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
+44
View File
@@ -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
+70
View File
@@ -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
+89
View File
@@ -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
+68
View File
@@ -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
+63
View File
@@ -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
+35
View File
@@ -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