diff --git a/fit_file_faker/app_registry.py b/fit_file_faker/app_registry.py index 1368f73..b79f4c9 100644 --- a/fit_file_faker/app_registry.py +++ b/fit_file_faker/app_registry.py @@ -270,6 +270,41 @@ def validate_path(self, path: Path) -> bool: return path.exists() and path.is_dir() +class OnelapDetector(AppDetector): + """Onelap (顽鹿运动) directory detector.""" + + def get_display_name(self) -> str: + """Get human-readable app name.""" + return "Onelap (顽鹿运动)" + + def get_short_name(self) -> str: + """Get short app name for compact display.""" + return "Onelap" + + def get_default_path(self) -> Path | None: + """Detect Onelap FIT files directory. + + Detection is not platform-specific -- checks for the English path first, + then falls back to the Chinese locale path: + - ~/Documents/Onelap/Activity/ (English locale) + - ~/Documents/顽鹿运动/Activity/ (Chinese locale fallback) + """ + base = Path.home() / "Documents" / "Onelap" / "Activity" + if base.exists(): + return base + + # Fallback for older versions or different locales + alternate = Path.home() / "Documents" / "顽鹿运动" / "Activity" + if alternate.exists(): + return alternate + + return None + + def validate_path(self, path: Path) -> bool: + """Check if path looks like Onelap directory.""" + return path.exists() and path.is_dir() + + class CustomDetector(AppDetector): """Custom/manual path specification detector.""" @@ -301,6 +336,7 @@ def validate_path(self, path: Path) -> bool: AppType.TP_VIRTUAL: TPVDetector, AppType.ZWIFT: ZwiftDetector, AppType.MYWHOOSH: MyWhooshDetector, + AppType.ONELAP: OnelapDetector, AppType.CUSTOM: CustomDetector, } diff --git a/fit_file_faker/config.py b/fit_file_faker/config.py index 003536c..8994a49 100644 --- a/fit_file_faker/config.py +++ b/fit_file_faker/config.py @@ -613,6 +613,7 @@ class AppType(Enum): TP_VIRTUAL = "tp_virtual" ZWIFT = "zwift" MYWHOOSH = "mywhoosh" + ONELAP = "onelap" CUSTOM = "custom" @@ -1673,6 +1674,7 @@ def create_profile_wizard(self) -> Profile | None: questionary.Choice("TrainingPeaks Virtual", AppType.TP_VIRTUAL), questionary.Choice("Zwift", AppType.ZWIFT), questionary.Choice("MyWhoosh", AppType.MYWHOOSH), + questionary.Choice("Onelap", AppType.ONELAP), questionary.Choice("Custom (manual path)", AppType.CUSTOM), ] diff --git a/fit_file_faker/fit_editor.py b/fit_file_faker/fit_editor.py index e38a42c..101fa3f 100644 --- a/fit_file_faker/fit_editor.py +++ b/fit_file_faker/fit_editor.py @@ -36,6 +36,9 @@ from fit_file_faker.vendor.fit_tool.profile.messages.file_id_message import ( FileIdMessage, ) +from fit_file_faker.vendor.fit_tool.profile.messages.software_message import ( + SoftwareMessage, +) from fit_file_faker.vendor.fit_tool.profile.profile_type import ( GarminProduct, Manufacturer, @@ -210,7 +213,7 @@ def rewrite_file_id_message( The product_name field is intentionally not copied as Garmin devices typically don't set this field. Only files from supported manufacturers (`DEVELOPMENT`, `ZWIFT`, `WAHOO_FITNESS`, `PEAKSWARE`, `HAMMERHEAD`, `COROS`, - `MYWHOOSH`) are modified; others are returned unchanged. + `MYWHOOSH` (`331`), and `ONELAP` (`307`)) are modified; others are returned unchanged. """ dt = datetime.fromtimestamp(m.time_created / 1000.0) # type: ignore _logger.info(f'Activity timestamp is "{dt.isoformat()}"') @@ -262,8 +265,8 @@ def _should_modify_manufacturer(self, manufacturer: int | None) -> bool: Note: Supported manufacturers include: `DEVELOPMENT` (TrainingPeaks Virtual), - `ZWIFT`, `WAHOO_FITNESS`, `PEAKSWARE`, `HAMMERHEAD`, `COROS`, and - `MYWHOOSH` (`331`). + `ZWIFT`, `WAHOO_FITNESS`, `PEAKSWARE`, `HAMMERHEAD`, `COROS`, `MYWHOOSH` (`331`), + and `ONELAP` (`307`). """ if manufacturer is None: return False @@ -275,6 +278,7 @@ def _should_modify_manufacturer(self, manufacturer: int | None) -> bool: Manufacturer.HAMMERHEAD.value, Manufacturer.COROS.value, 331, # MYWHOOSH is unknown to fit_tools + Manufacturer.ONELAP.value, ] def _should_modify_device_info(self, manufacturer: int | None) -> bool: @@ -306,6 +310,7 @@ def _should_modify_device_info(self, manufacturer: int | None) -> bool: Manufacturer.HAMMERHEAD.value, Manufacturer.COROS.value, 331, # MYWHOOSH is unknown to fit_tools + Manufacturer.ONELAP.value, ] def strip_unknown_fields(self, fit_file: FitFile) -> None: @@ -457,6 +462,14 @@ def edit_fit( # Collect Activity messages to write at the end (fixes COROS file ordering) activity_messages = [] + + # Pre-scan for source manufacturer to handle platform-specific rules (like skipping Onelap software messages) + is_onelap = False + for record in fit_file.records: + if record.message.global_id == FileIdMessage.ID and isinstance(record.message, FileIdMessage): + if record.message.manufacturer == Manufacturer.ONELAP.value: + is_onelap = True + break # Loop through records, find the ones we need to change, and modify the values for i, record in enumerate(fit_file.records): @@ -492,6 +505,11 @@ def edit_fit( # Skip any existing file creator message continue + # Software message - skip to remove original software info + if message.global_id == SoftwareMessage.ID and is_onelap: + _logger.debug(f"Skipping Software message at record {i}") + continue + # Change device info messages if message.global_id == DeviceInfoMessage.ID: if isinstance(message, DeviceInfoMessage): @@ -522,11 +540,11 @@ def edit_fit( target_device = GarminProduct.EDGE_830.value # have not seen this set explicitly in testing, but probable good to set regardless - if message.garmin_product: # pragma: no cover + if message.garmin_product is not None: # pragma: no cover message.garmin_product = target_device - if message.product: + if message.product is not None: message.product = target_device # type: ignore - if message.manufacturer: + if message.manufacturer is not None: message.manufacturer = target_manufacturer message.product_name = "" self.print_message(f" New Record: {i}", message) diff --git a/tests/conftest.py b/tests/conftest.py index 564b23b..e482035 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -87,6 +87,11 @@ def mywhoosh_fit_file(test_files_dir): """Return path to MyWhoosh test FIT file.""" return test_files_dir / "mywhoosh_20260111.fit" +@pytest.fixture(scope="module") +def onelap_fit_file(test_files_dir): + """Return path to Onelap test FIT file.""" + return test_files_dir / "onelap_20260427.fit" + @pytest.fixture(scope="module") def karoo_fit_file(test_files_dir): @@ -114,13 +119,14 @@ def zwift_non_utf8_fit_file(test_files_dir): @pytest.fixture(scope="module") def all_test_fit_files( - tpv_fit_file, zwift_fit_file, mywhoosh_fit_file, karoo_fit_file, coros_fit_file + tpv_fit_file, zwift_fit_file, mywhoosh_fit_file, onelap_fit_file, karoo_fit_file, coros_fit_file ): """Return all test FIT files.""" return [ tpv_fit_file, zwift_fit_file, mywhoosh_fit_file, + onelap_fit_file, karoo_fit_file, coros_fit_file, ] @@ -170,6 +176,11 @@ def mywhoosh_fit_parsed(mywhoosh_fit_file): """Return parsed MyWhoosh FIT file.""" return FitFile.from_file(str(mywhoosh_fit_file)) +@pytest.fixture +def onelap_fit_parsed(onelap_fit_file): + """Return parsed Onelap FIT file.""" + return FitFile.from_file(str(onelap_fit_file)) + @pytest.fixture def karoo_fit_parsed(karoo_fit_file): diff --git a/tests/files/onelap_20260427.fit b/tests/files/onelap_20260427.fit new file mode 100644 index 0000000..3b90c93 Binary files /dev/null and b/tests/files/onelap_20260427.fit differ diff --git a/tests/test_app_registry.py b/tests/test_app_registry.py index 130dcff..bf88939 100644 --- a/tests/test_app_registry.py +++ b/tests/test_app_registry.py @@ -10,6 +10,7 @@ APP_REGISTRY, CustomDetector, MyWhooshDetector, + OnelapDetector, TPVDetector, ZwiftDetector, get_detector, @@ -26,6 +27,7 @@ class TestDetectorNames: (TPVDetector, "TrainingPeaks Virtual"), (ZwiftDetector, "Zwift"), (MyWhooshDetector, "MyWhoosh"), + (OnelapDetector, "Onelap (顽鹿运动)"), (CustomDetector, "Custom (Manual Path)"), ], ) @@ -40,6 +42,7 @@ def test_display_names(self, detector_class, expected_display_name): (TPVDetector, "TPVirtual"), (ZwiftDetector, "Zwift"), (MyWhooshDetector, "MyWhoosh"), + (OnelapDetector, "Onelap"), (CustomDetector, "Custom"), ], ) @@ -54,7 +57,7 @@ class TestDetectorValidation: @pytest.mark.parametrize( "detector_class", - [TPVDetector, ZwiftDetector, MyWhooshDetector, CustomDetector], + [TPVDetector, ZwiftDetector, MyWhooshDetector, OnelapDetector, CustomDetector], ) def test_validate_path_exists(self, detector_class, tmp_path): """Test that validation succeeds for existing directory.""" @@ -66,7 +69,7 @@ def test_validate_path_exists(self, detector_class, tmp_path): @pytest.mark.parametrize( "detector_class", - [TPVDetector, ZwiftDetector, MyWhooshDetector, CustomDetector], + [TPVDetector, ZwiftDetector, MyWhooshDetector, OnelapDetector, CustomDetector], ) def test_validate_path_not_exists(self, detector_class): """Test that validation fails for non-existent path.""" @@ -207,6 +210,50 @@ def test_get_default_path_linux(self, monkeypatch): assert result is None +class TestOnelapDetector: + """Tests for Onelap detector path detection.""" + + def test_get_default_path_english(self, monkeypatch, tmp_path): + """Test Onelap default path detection with English locale directory.""" + onelap_dir = tmp_path / "Documents" / "Onelap" / "Activity" + onelap_dir.mkdir(parents=True) + + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + + detector = OnelapDetector() + result = detector.get_default_path() + + assert result == onelap_dir + + def test_get_default_path_chinese_locale(self, monkeypatch, tmp_path): + """Test Onelap fallback path detection with Chinese locale directory.""" + onelap_dir = tmp_path / "Documents" / "顽鹿运动" / "Activity" + onelap_dir.mkdir(parents=True) + + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + + detector = OnelapDetector() + result = detector.get_default_path() + + assert result == onelap_dir + + def test_get_default_path_english_preferred_over_chinese( + self, monkeypatch, tmp_path + ): + """Test that English locale path is returned when both paths exist.""" + english_dir = tmp_path / "Documents" / "Onelap" / "Activity" + chinese_dir = tmp_path / "Documents" / "顽鹿运动" / "Activity" + english_dir.mkdir(parents=True) + chinese_dir.mkdir(parents=True) + + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + + detector = OnelapDetector() + result = detector.get_default_path() + + assert result == english_dir + + class TestCustomDetector: """Tests for Custom detector.""" @@ -221,7 +268,7 @@ class TestGetDefaultPathNotFound: @pytest.mark.parametrize( "detector_class", - [ZwiftDetector, MyWhooshDetector], + [ZwiftDetector, MyWhooshDetector, OnelapDetector], ) def test_get_default_path_not_found(self, detector_class, monkeypatch, tmp_path): """Test that None is returned when detector's directory doesn't exist.""" @@ -242,6 +289,7 @@ def test_registry_contains_all_app_types(self): assert AppType.TP_VIRTUAL in APP_REGISTRY assert AppType.ZWIFT in APP_REGISTRY assert AppType.MYWHOOSH in APP_REGISTRY + assert AppType.ONELAP in APP_REGISTRY assert AppType.CUSTOM in APP_REGISTRY def test_get_detector_tp_virtual(self): @@ -262,6 +310,12 @@ def test_get_detector_mywhoosh(self): assert isinstance(detector, MyWhooshDetector) assert detector.get_display_name() == "MyWhoosh" + def test_get_detector_onelap(self): + """Test getting Onelap detector from factory.""" + detector = get_detector(AppType.ONELAP) + assert isinstance(detector, OnelapDetector) + assert detector.get_display_name() == "Onelap (顽鹿运动)" + def test_get_detector_custom(self): """Test getting Custom detector from factory.""" detector = get_detector(AppType.CUSTOM) diff --git a/tests/test_fit_editor.py b/tests/test_fit_editor.py index 83a24e8..a8fcdc1 100644 --- a/tests/test_fit_editor.py +++ b/tests/test_fit_editor.py @@ -76,6 +76,7 @@ class TestFitEditor: ("tpv_fit_0_4_30_parsed", "tpv_0_4_30_modified.fit"), ("zwift_fit_parsed", "zwift_modified.fit"), ("mywhoosh_fit_parsed", "mywhoosh_modified.fit"), + ("onelap_fit_parsed", "onelap_modified.fit"), ("karoo_fit_parsed", "karoo_modified.fit"), ("coros_fit_parsed", "coros_modified.fit"), ("zwift_non_utf8_fit_parsed", "zwift_non_utf8_modified.fit"), @@ -85,7 +86,7 @@ class TestFitEditor: def test_edit_fit_files( self, fit_editor, fit_file_fixture, output_name, temp_dir, request ): - """Test editing FIT files from various platforms (TPV, Zwift, MyWhoosh, Karoo, COROS). + """Test editing FIT files from various platforms (TPV, Zwift, MyWhoosh, Onelap, Karoo, COROS). Includes test for Zwift file with non-UTF-8 encoded strings. """ @@ -172,6 +173,7 @@ def test_should_modify_manufacturer(self, fit_editor): assert fit_editor._should_modify_manufacturer(Manufacturer.HAMMERHEAD.value) assert fit_editor._should_modify_manufacturer(Manufacturer.COROS.value) assert fit_editor._should_modify_manufacturer(331) # MYWHOOSH + assert fit_editor._should_modify_manufacturer(Manufacturer.ONELAP.value) # Should NOT modify Garmin assert not fit_editor._should_modify_manufacturer(Manufacturer.GARMIN.value) @@ -190,6 +192,7 @@ def test_should_modify_device_info(self, fit_editor): assert fit_editor._should_modify_device_info(Manufacturer.HAMMERHEAD.value) assert fit_editor._should_modify_device_info(Manufacturer.COROS.value) assert fit_editor._should_modify_device_info(331) # MYWHOOSH + assert fit_editor._should_modify_device_info(Manufacturer.ONELAP.value) # Should NOT modify None assert not fit_editor._should_modify_device_info(None) @@ -214,6 +217,23 @@ def test_parsed_fit_without_output_path(self, fit_editor, tpv_fit_parsed): # Should return None when output path is not provided for parsed FIT assert result is None + def test_skip_software_message_for_onelap(self, fit_editor, onelap_fit_parsed, temp_dir): + """Test that Software message (ID 35) is skipped when the file is from Onelap.""" + from fit_file_faker.vendor.fit_tool.fit_file import FitFile + from fit_file_faker.vendor.fit_tool.data_message import DataMessage + + output_file = temp_dir / "onelap_no_software.fit" + + # This will trigger the skip logic for global_id == 35 because is_onelap will be True + result = fit_editor.edit_fit(onelap_fit_parsed, output=output_file) + assert result == output_file + + # Verify that the generated file does not have a Software DataMessage + modified_fit = FitFile.from_file(str(output_file)) + for record in modified_fit.records: + if isinstance(record.message, DataMessage): + assert record.message.global_id != 35 + def test_strip_unknown_fields(self, fit_editor, zwift_fit_parsed): """Test that unknown fields are properly stripped.""" # Use cached parsed file