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