Track properties gain activity_type (FIT: sport enum from the session/sport message, Garmin's own table; GPX: <trk><type> passed through as-is) alongside the existing distance/elevation/duration. Clicking a track line now opens a bottom panel with a chart per metric the track actually has (elevation always; speed/cadence/heart rate only when present — FIT's record fields or GPX's device extensions). TrackStats.build_profile computes cumulative distance the same way for both formats so they're directly comparable. One pointermove listener drives a synchronized crosshair across every chart, a single tooltip listing all series, and a map marker at the hovered point's position — hovering the chart shows where on the track that moment was.
125 lines
4.6 KiB
Ruby
125 lines
4.6 KiB
Ruby
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
|
|
|
|
# A second, richer fixture (session message with a sport enum, record
|
|
# messages carrying heart_rate/cadence/speed too) — kept separate from
|
|
# build_fit/write_fit above so those stay a minimal, easy-to-read
|
|
# position-only case.
|
|
def build_activity_fit(records, sport: nil)
|
|
body = +""
|
|
|
|
if sport
|
|
session_definition = [0, 0].pack("CC") + [18].pack("S<") + [1].pack("C") + [5, 1, 0x00].pack("C3")
|
|
body << [0b01000001].pack("C") << session_definition
|
|
body << [0b00000001].pack("C") << [sport].pack("C")
|
|
end
|
|
|
|
record_definition = [0, 0].pack("CC") + [20].pack("S<") + [5].pack("C") +
|
|
[0, 4, 0x85].pack("C3") + [1, 4, 0x85].pack("C3") +
|
|
[3, 1, 0x02].pack("C3") + [4, 1, 0x02].pack("C3") + [6, 2, 0x84].pack("C3")
|
|
body << [0b01000000].pack("C") << record_definition
|
|
|
|
records.each do |r|
|
|
body << [0b00000000].pack("C") << le32(r[:lat]) << le32(r[:lon])
|
|
body << [r[:heart_rate] || 0xFF].pack("C") << [r[:cadence] || 0xFF].pack("C")
|
|
speed_raw = r[:speed_mps] ? (r[:speed_mps] * 1000).round : 0xFFFF
|
|
body << [speed_raw].pack("S<")
|
|
end
|
|
|
|
header = [12, 16, 0, 0].pack("C2S<") + le32(body.bytesize) + ".FIT"
|
|
header + body + "\x00\x00"
|
|
end
|
|
|
|
def write_activity_fit(records, sport: nil)
|
|
path = File.join(Dir.mktmpdir, "activity.fit")
|
|
File.binwrite(path, build_activity_fit(records, sport: sport))
|
|
path
|
|
end
|
|
|
|
def test_activity_type_comes_from_the_session_messages_sport_enum
|
|
path = write_activity_fit([{ lat: 100_000_000, lon: 200_000_000 }], sport: 2)
|
|
geojson = Maptool::Fit.to_geojson(path)
|
|
|
|
assert_equal "cycling", geojson["features"][0]["properties"]["activity_type"]
|
|
end
|
|
|
|
def test_activity_type_is_nil_without_a_session_message
|
|
path = write_activity_fit([{ lat: 100_000_000, lon: 200_000_000 }])
|
|
geojson = Maptool::Fit.to_geojson(path)
|
|
|
|
assert_nil geojson["features"][0]["properties"]["activity_type"]
|
|
end
|
|
|
|
def test_profile_carries_heart_rate_cadence_and_speed_treating_0xff_as_absent
|
|
path = write_activity_fit([
|
|
{ lat: 100_000_000, lon: 200_000_000, heart_rate: 140, cadence: 80, speed_mps: 3.5 },
|
|
{ lat: 100_000_100, lon: 200_000_100 }, # no extras -> 0xFF/0xFFFF sentinels
|
|
])
|
|
|
|
profile = Maptool::Fit.to_geojson(path)["features"][0]["properties"]["profile"]
|
|
|
|
assert_equal 140, profile[0]["heart_rate_bpm"]
|
|
assert_equal 80, profile[0]["cadence_rpm"]
|
|
assert_in_delta 3.5, profile[0]["speed_mps"]
|
|
assert_nil profile[1]["heart_rate_bpm"]
|
|
assert_nil profile[1]["speed_mps"]
|
|
end
|
|
end
|