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
@@ -23,6 +23,9 @@ jobs:
|
|||||||
- name: App loads without errors
|
- name: App loads without errors
|
||||||
run: bundle exec ruby -e 'require_relative "app"'
|
run: bundle exec ruby -e 'require_relative "app"'
|
||||||
|
|
||||||
|
- name: Ruby tests
|
||||||
|
run: bundle exec rake test
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
|
|||||||
@@ -10,3 +10,8 @@ gem "rubyzip", "~> 2.4"
|
|||||||
group :development do
|
group :development do
|
||||||
gem "rerun"
|
gem "rerun"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
group :test do
|
||||||
|
gem "minitest", "~> 6.0"
|
||||||
|
gem "rake", "~> 13.0"
|
||||||
|
end
|
||||||
@@ -7,6 +7,7 @@ GEM
|
|||||||
version_gem (~> 1.1, >= 1.1.14)
|
version_gem (~> 1.1, >= 1.1.14)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bigdecimal (4.1.2)
|
bigdecimal (4.1.2)
|
||||||
|
drb (2.2.3)
|
||||||
faraday (2.14.3)
|
faraday (2.14.3)
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
@@ -33,6 +34,9 @@ GEM
|
|||||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||||
rb-inotify (~> 0.9, >= 0.9.10)
|
rb-inotify (~> 0.9, >= 0.9.10)
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
|
minitest (6.0.6)
|
||||||
|
drb (~> 2.0)
|
||||||
|
prism (~> 1.5)
|
||||||
multi_json (1.21.1)
|
multi_json (1.21.1)
|
||||||
multi_xml (0.9.1)
|
multi_xml (0.9.1)
|
||||||
bigdecimal (>= 3.1, < 5)
|
bigdecimal (>= 3.1, < 5)
|
||||||
@@ -50,6 +54,7 @@ GEM
|
|||||||
rack (>= 1.2, < 4)
|
rack (>= 1.2, < 4)
|
||||||
snaky_hash (~> 2.0, >= 2.0.7)
|
snaky_hash (~> 2.0, >= 2.0.7)
|
||||||
version_gem (~> 1.1, >= 1.1.14)
|
version_gem (~> 1.1, >= 1.1.14)
|
||||||
|
prism (1.9.0)
|
||||||
puma (8.0.2)
|
puma (8.0.2)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
rack (3.2.7)
|
rack (3.2.7)
|
||||||
@@ -60,6 +65,7 @@ GEM
|
|||||||
rack-session (2.1.2)
|
rack-session (2.1.2)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
rack (>= 3.0.0)
|
rack (>= 3.0.0)
|
||||||
|
rake (13.4.2)
|
||||||
rb-fsevent (0.11.2)
|
rb-fsevent (0.11.2)
|
||||||
rb-inotify (0.11.1)
|
rb-inotify (0.11.1)
|
||||||
ffi (~> 1.0)
|
ffi (~> 1.0)
|
||||||
@@ -100,8 +106,10 @@ PLATFORMS
|
|||||||
x86_64-linux-musl
|
x86_64-linux-musl
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
|
minitest (~> 6.0)
|
||||||
oauth2 (~> 2.0)
|
oauth2 (~> 2.0)
|
||||||
puma (~> 8.0)
|
puma (~> 8.0)
|
||||||
|
rake (~> 13.0)
|
||||||
rerun
|
rerun
|
||||||
rexml (~> 3.4)
|
rexml (~> 3.4)
|
||||||
rubyzip (~> 2.4)
|
rubyzip (~> 2.4)
|
||||||
@@ -114,6 +122,7 @@ CHECKSUMS
|
|||||||
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
||||||
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
|
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
|
||||||
bundler (4.0.18) sha256=02d9a17429de1847b4e0c9f27a9ee4b20c0a74c0a641b4e77195d6019e3618ac
|
bundler (4.0.18) sha256=02d9a17429de1847b4e0c9f27a9ee4b20c0a74c0a641b4e77195d6019e3618ac
|
||||||
|
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
|
||||||
faraday (2.14.3) sha256=1882247e6766615c8220b4392bf1d27f6ebb63d8e28267587cef1fb0bf37f278
|
faraday (2.14.3) sha256=1882247e6766615c8220b4392bf1d27f6ebb63d8e28267587cef1fb0bf37f278
|
||||||
faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588
|
faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588
|
||||||
ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
|
ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
|
||||||
@@ -131,16 +140,19 @@ CHECKSUMS
|
|||||||
jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7
|
jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7
|
||||||
listen (3.10.0) sha256=c6e182db62143aeccc2e1960033bebe7445309c7272061979bb098d03760c9d2
|
listen (3.10.0) sha256=c6e182db62143aeccc2e1960033bebe7445309c7272061979bb098d03760c9d2
|
||||||
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
||||||
|
minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
|
||||||
multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080
|
multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080
|
||||||
multi_xml (0.9.1) sha256=7ce766b59c17241ed62976caeae1fae9b2431b263398c35396239a68c4a64e57
|
multi_xml (0.9.1) sha256=7ce766b59c17241ed62976caeae1fae9b2431b263398c35396239a68c4a64e57
|
||||||
mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198
|
mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198
|
||||||
net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
|
net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
|
||||||
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
|
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
|
||||||
oauth2 (2.0.25) sha256=2f736a2f93c2caa67c1b08dc3c9889bb907d62643f3183fefaa1723cba6a82ac
|
oauth2 (2.0.25) sha256=2f736a2f93c2caa67c1b08dc3c9889bb907d62643f3183fefaa1723cba6a82ac
|
||||||
|
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
||||||
puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb
|
puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb
|
||||||
rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7
|
rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7
|
||||||
rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac
|
rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac
|
||||||
rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8
|
rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8
|
||||||
|
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
|
||||||
rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe
|
rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe
|
||||||
rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e
|
rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e
|
||||||
rerun (0.14.0) sha256=996a5af2b2b34dcb30146c6c728addab9113e07c119003370daa9bf27feb88fd
|
rerun (0.14.0) sha256=996a5af2b2b34dcb30146c6c728addab9113e07c119003370daa9bf27feb88fd
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
require "rake/testtask"
|
||||||
|
|
||||||
|
Rake::TestTask.new(:test) do |t|
|
||||||
|
t.libs << "test" << "lib"
|
||||||
|
t.test_files = FileList["test/test_*.rb"]
|
||||||
|
end
|
||||||
|
|
||||||
|
task default: :test
|
||||||
@@ -32,9 +32,13 @@ module Maptool
|
|||||||
# the container) is enough — it doesn't need to survive restarts.
|
# the container) is enough — it doesn't need to survive restarts.
|
||||||
ASSET_VERSION = Time.now.to_i
|
ASSET_VERSION = Time.now.to_i
|
||||||
|
|
||||||
OFFLINE_PMTILES_PATH = File.join(__dir__, "data", "offline.pmtiles")
|
# Overridable so the test suite can point this at a throwaway directory
|
||||||
GPX_STORE = GpxStore.new(File.join(__dir__, "data", "gpx"))
|
# instead of the real data/ — nothing else needs to set this.
|
||||||
LIVETRACK_STORE = LiveTrackStore.new(File.join(__dir__, "data", "livetrack.json"))
|
DATA_DIR = ENV.fetch("MAPTOOL_DATA_DIR") { File.join(__dir__, "data") }
|
||||||
|
|
||||||
|
OFFLINE_PMTILES_PATH = File.join(DATA_DIR, "offline.pmtiles")
|
||||||
|
GPX_STORE = GpxStore.new(File.join(DATA_DIR, "gpx"))
|
||||||
|
LIVETRACK_STORE = LiveTrackStore.new(File.join(DATA_DIR, "livetrack.json"))
|
||||||
|
|
||||||
# Shared secret for the Garmin LiveTrack webhook (a Google Apps Script
|
# Shared secret for the Garmin LiveTrack webhook (a Google Apps Script
|
||||||
# watching Gmail for LiveTrack emails, see docs/deployment.adoc) — not a
|
# watching Gmail for LiveTrack emails, see docs/deployment.adoc) — not a
|
||||||
|
|||||||
+27
-3
@@ -91,13 +91,37 @@ gpsd -N -n tcp://127.0.0.1:5001
|
|||||||
`GET /api/gnss/position` should then report the fake fix (Bratislava
|
`GET /api/gnss/position` should then report the fake fix (Bratislava
|
||||||
Castle, in the sentences above) within a couple of seconds.
|
Castle, in the sentences above) within a couple of seconds.
|
||||||
|
|
||||||
|
== Tests
|
||||||
|
|
||||||
|
`bundle exec rake test` runs the whole suite (`test/test_*.rb`,
|
||||||
|
Minitest — a default gem, but pinned in the `Gemfile`'s `:test` group
|
||||||
|
for reproducibility, same reasoning as `rake` itself). `test_app.rb`
|
||||||
|
drives `app.rb`'s routes end to end through `Rack::MockRequest` rather
|
||||||
|
than a real server; everything else tests one `lib/maptool/` module in
|
||||||
|
isolation. `MAPTOOL_DATA_DIR` (see `app.rb`) is pointed at a throwaway
|
||||||
|
`Dir.mktmpdir` by `test/test_helper.rb` before `app.rb` loads, so
|
||||||
|
running the suite never touches real `data/` — the app-level tests
|
||||||
|
also wipe that directory between each test (see `AppTestHelper#teardown`),
|
||||||
|
since `GPX_STORE`/`LIVETRACK_STORE` are process-wide constants shared
|
||||||
|
by every test in the run.
|
||||||
|
|
||||||
|
`LiveTrackClient`'s own tests use a synthetic fixture (no network) —
|
||||||
|
its parsing was checked against real Garmin sessions by hand during
|
||||||
|
development, not by the suite, since that needs a live, unexpired
|
||||||
|
session token to fetch. `Fit`'s tests likewise build a minimal FIT
|
||||||
|
file by hand rather than shipping a real device fixture; correctness
|
||||||
|
against real files (position/altitude fields, definition-message
|
||||||
|
framing, compressed-timestamp headers) was checked by hand against
|
||||||
|
`python-fitparse`'s test fixtures during development — see
|
||||||
|
`docs/architecture.adoc`.
|
||||||
|
|
||||||
== Continuous integration
|
== Continuous integration
|
||||||
|
|
||||||
`.gitea/workflows/ci.yml` runs on every push/PR against a self-hosted
|
`.gitea/workflows/ci.yml` runs on every push/PR against a self-hosted
|
||||||
Gitea Actions runner: `ruby -c` on every `.rb` file, a `require_relative
|
Gitea Actions runner: `ruby -c` on every `.rb` file, a `require_relative
|
||||||
"app"` smoke check that the app loads without error, and `node --check`
|
"app"` smoke check that the app loads without error, `bundle exec rake
|
||||||
on every file under `public/js/`. There is no test suite yet — these
|
test` (see above), and `node --check` on every file under
|
||||||
are cheap syntax/load checks, not behavioral tests.
|
`public/js/` — no JS test suite, just a syntax check.
|
||||||
|
|
||||||
`.gitea/workflows/renovate.yml` runs
|
`.gitea/workflows/renovate.yml` runs
|
||||||
https://docs.renovatebot.com[Renovate] (the `renovate/renovate` image)
|
https://docs.renovatebot.com[Renovate] (the `renovate/renovate` image)
|
||||||
|
|||||||
@@ -172,67 +172,3 @@ module Maptool
|
|||||||
private_class_method :invalid?
|
private_class_method :invalid?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if __FILE__ == $0
|
|
||||||
require "tmpdir"
|
|
||||||
|
|
||||||
# A hand-built minimal FIT file: header + one definition message (local
|
|
||||||
# type 0, global mesg 20 "record", fields position_lat/position_long,
|
|
||||||
# both sint32) + two data messages + a throwaway CRC. Exercises the
|
|
||||||
# header/definition/data framing without external fixtures.
|
|
||||||
def le32(n)
|
|
||||||
[n].pack("l<")
|
|
||||||
end
|
|
||||||
|
|
||||||
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 do |lat, lon|
|
|
||||||
[0b00000000].pack("C") + le32(lat) + le32(lon)
|
|
||||||
end.join
|
|
||||||
|
|
||||||
body = definition_header + definition + data
|
|
||||||
header = [12, 16, 0, 0].pack("C2S<") + le32(body.bytesize) + ".FIT"
|
|
||||||
header + body + "\x00\x00"
|
|
||||||
end
|
|
||||||
|
|
||||||
Dir.mktmpdir do |dir|
|
|
||||||
path = File.join(dir, "test.fit")
|
|
||||||
File.binwrite(path, build_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]
|
|
||||||
raise "expected 2 points" unless coords.size == 2
|
|
||||||
expected_lat = 100_000_000 * Maptool::Fit::SEMICIRCLE_TO_DEGREES
|
|
||||||
expected_lon = 200_000_000 * Maptool::Fit::SEMICIRCLE_TO_DEGREES
|
|
||||||
raise "bad coordinate: #{coords[0]}" unless (coords[0][1] - expected_lat).abs < 1e-9 && (coords[0][0] - expected_lon).abs < 1e-9
|
|
||||||
raise "expected kind track" unless Maptool::Fit.kinds(path) == ["track"]
|
|
||||||
|
|
||||||
empty_path = File.join(dir, "empty.fit")
|
|
||||||
File.binwrite(empty_path, build_fit([]))
|
|
||||||
raise "expected no features for a pointless file" unless Maptool::Fit.to_geojson(empty_path)["features"].empty?
|
|
||||||
raise "expected no kinds for a pointless file" unless Maptool::Fit.kinds(empty_path) == []
|
|
||||||
|
|
||||||
raise "expected ParseError for garbage" rescue nil
|
|
||||||
begin
|
|
||||||
Maptool::Fit.to_geojson(File.join(dir, "nonexistent.fit"))
|
|
||||||
raise "expected an error reading a missing file"
|
|
||||||
rescue Errno::ENOENT
|
|
||||||
# fine — this app only ever calls to_geojson with a path it already
|
|
||||||
# knows exists
|
|
||||||
end
|
|
||||||
|
|
||||||
garbage_path = File.join(dir, "garbage.fit")
|
|
||||||
File.binwrite(garbage_path, "not a fit file at all")
|
|
||||||
begin
|
|
||||||
Maptool::Fit.to_geojson(garbage_path)
|
|
||||||
raise "expected ParseError for garbage input"
|
|
||||||
rescue Maptool::Fit::ParseError
|
|
||||||
# expected
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
puts "ok"
|
|
||||||
end
|
|
||||||
@@ -126,39 +126,3 @@ module Maptool
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if __FILE__ == $0
|
|
||||||
client = Maptool::LiveTrackClient.new
|
|
||||||
|
|
||||||
fake_page = <<~HTML
|
|
||||||
<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
|
|
||||||
|
|
||||||
payloads = client.send(:parse_payloads, fake_page)
|
|
||||||
points = client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }
|
|
||||||
raise "expected 2 points" unless points&.size == 2
|
|
||||||
raise "expected first point lat 48.1" unless points[0]["position"]["lat"] == 48.1
|
|
||||||
|
|
||||||
session = client.send(:search, payloads) { |n| n if n["sessionName"] }
|
|
||||||
raise "expected session name" unless session["sessionName"] == "Test Ride"
|
|
||||||
|
|
||||||
result = Maptool::LiveTrackClient::Result.new(points: client.send(:clean, points), session: client.send(:clean, session))
|
|
||||||
summary = Maptool::LiveTrackClient.summarize(result)
|
|
||||||
raise "expected $undefined cleaned to nil" unless result.points[0]["totalDistanceMeters"].nil?
|
|
||||||
raise "expected summary from last point" unless summary["activity_type"] == "CYCLING" && summary["distance_meters"] == 5000
|
|
||||||
raise "expected session name in summary" unless summary["session_name"] == "Test Ride"
|
|
||||||
raise "expected inactive: session has an end" if summary["active"]
|
|
||||||
|
|
||||||
ongoing_session = session.merge("end" => nil)
|
|
||||||
ongoing_summary = Maptool::LiveTrackClient.summarize(Maptool::LiveTrackClient::Result.new(points: result.points, session: ongoing_session))
|
|
||||||
raise "expected active: session has no end" unless ongoing_summary["active"]
|
|
||||||
|
|
||||||
no_points = client.send(:search, client.send(:parse_payloads, "<html></html>")) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }
|
|
||||||
raise "expected nil when no trackPoints present" unless no_points.nil?
|
|
||||||
|
|
||||||
puts "ok"
|
|
||||||
end
|
|
||||||
@@ -88,44 +88,3 @@ module Maptool
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if __FILE__ == $0
|
|
||||||
require "tmpdir"
|
|
||||||
|
|
||||||
Dir.mktmpdir do |dir|
|
|
||||||
store = Maptool::LiveTrackStore.new(File.join(dir, "livetrack.json"))
|
|
||||||
raise "expected empty list" unless store.list == []
|
|
||||||
|
|
||||||
store.add("abc123", "tok-abc")
|
|
||||||
store.add("def456", "tok-def")
|
|
||||||
raise "expected 2 sessions" unless store.list.size == 2
|
|
||||||
raise "expected newest first" unless store.list.first["id"] == "def456"
|
|
||||||
raise "expected token stored" unless store.find("abc123")["token"] == "tok-abc"
|
|
||||||
raise "expected unknown id to be nil" unless store.find("nope").nil?
|
|
||||||
raise "expected no summary yet" unless store.find("abc123")["summary"].nil?
|
|
||||||
|
|
||||||
store.add("abc123", "tok-abc-2")
|
|
||||||
raise "expected re-added session moved to front, not duplicated" \
|
|
||||||
unless store.list.map { |s| s["id"] } == %w[abc123 def456]
|
|
||||||
raise "expected token updated on re-add" unless store.find("abc123")["token"] == "tok-abc-2"
|
|
||||||
|
|
||||||
store.set_summary("abc123", { "session_name" => "Test Ride" })
|
|
||||||
raise "expected summary stored" unless store.find("abc123")["summary"] == { "session_name" => "Test Ride" }
|
|
||||||
store.add("abc123", "tok-abc-3")
|
|
||||||
raise "expected summary to carry over across re-add" \
|
|
||||||
unless store.find("abc123")["summary"] == { "session_name" => "Test Ride" }
|
|
||||||
|
|
||||||
# Manually age an unsummarized session past the TTL and confirm it gets
|
|
||||||
# pruned, while a summarized one (however old) survives.
|
|
||||||
raw = JSON.parse(File.read(File.join(dir, "livetrack.json")))
|
|
||||||
raw.find { |s| s["id"] == "def456" }["received_at"] = (Time.now.utc - 49 * 60 * 60).iso8601
|
|
||||||
raw.find { |s| s["id"] == "abc123" }["received_at"] = (Time.now.utc - 1000 * 60 * 60).iso8601
|
|
||||||
File.write(File.join(dir, "livetrack.json"), JSON.generate(raw))
|
|
||||||
|
|
||||||
reloaded = Maptool::LiveTrackStore.new(File.join(dir, "livetrack.json"))
|
|
||||||
raise "expected old unsummarized session pruned" if reloaded.list.any? { |s| s["id"] == "def456" }
|
|
||||||
raise "expected old but summarized session kept" unless reloaded.list.any? { |s| s["id"] == "abc123" }
|
|
||||||
end
|
|
||||||
|
|
||||||
puts "ok"
|
|
||||||
end
|
|
||||||
@@ -37,30 +37,3 @@ module Maptool
|
|||||||
private_class_method :haversine
|
private_class_method :haversine
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if __FILE__ == $0
|
|
||||||
# A right-angle path with a known leg length (via the equirectangular
|
|
||||||
# approximation, accurate at small scale) plus a climb-then-descend
|
|
||||||
# elevation profile, so both distance and elevation-gain have a value to
|
|
||||||
# check against instead of just "did it run".
|
|
||||||
square = [
|
|
||||||
[17.0, 48.0, 100],
|
|
||||||
[17.001, 48.0, 150], # +50m gain
|
|
||||||
[17.001, 48.001, 120], # descent, no gain added
|
|
||||||
]
|
|
||||||
stats = Maptool::TrackStats.compute(square, times: [Time.utc(2026, 1, 1, 0, 0, 0), nil, Time.utc(2026, 1, 1, 0, 10, 0)])
|
|
||||||
|
|
||||||
raise "expected nonzero distance" unless stats["distance_meters"] > 100
|
|
||||||
raise "expected elevation gain 50" unless stats["elevation_gain_meters"] == 50.0
|
|
||||||
raise "expected duration 600s" unless stats["duration_secs"] == 600
|
|
||||||
raise "expected point count 3" unless stats["point_count"] == 3
|
|
||||||
|
|
||||||
flat = Maptool::TrackStats.compute([[17.0, 48.0], [17.0, 48.0]], times: [])
|
|
||||||
raise "expected nil elevation gain without elevation data" unless flat["elevation_gain_meters"].nil?
|
|
||||||
raise "expected nil duration without times" unless flat["duration_secs"].nil?
|
|
||||||
|
|
||||||
single = Maptool::TrackStats.compute([[17.0, 48.0]])
|
|
||||||
raise "expected zero distance for a single point" unless single["distance_meters"] == 0.0
|
|
||||||
|
|
||||||
puts "ok"
|
|
||||||
end
|
|
||||||
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