import struct from dataclasses import dataclass from pathlib import Path import pytest from app.fit.rewriter import FitFormatError, convert_fit_device, is_fit_file, read_device_field_values from tests.fit.builders import compressed_timestamp_data, data, definition, make_fit FILE_ID_MESG_NUM = 0 DEVICE_INFO_MESG_NUM = 23 RECORD_MESG_NUM = 20 HEADER_SIZE = 14 PRODUCT_NAME_SIZE = 24 @dataclass(frozen=True) class ComplexFixture: fit_bytes: bytes metadata_offsets: set[int] file_id_manufacturer_offset: int file_id_product_offset: int file_id_product_name_offset: int file_id_product_name_size: int creator_manufacturer_offset: int creator_product_offset: int preserved_ranges: tuple[tuple[int, int], ...] def _build_complex_fixture() -> ComplexFixture: """Builds one synthetic FIT data section covering all of the advanced record shapes the parser must handle end-to-end through convert_fit_device(): 1. a normal file_id definition/data pair (local 0); 2. a device_info definition with a creator record (local 1, device_index=0); 3. a record definition with one native field and one developer field, plus one data record using it (local 2); 4. a compressed-timestamp data header referring to that same local 2 definition; 5. a later replacement definition for local message number 2 (redefined as a non-creator device_info record, device_index=2), plus a data record using it. All byte offsets are derived here from the construction arithmetic itself (running length of the accumulated byte stream) -- this function never calls into app.fit.rewriter's parser. """ records = bytearray() def pos() -> int: return HEADER_SIZE + len(records) # 1. normal file_id definition/data pair, including the product_name string # field (8) so the string write path -- which zero-fills the whole declared # field, the riskiest byte-preservation behavior in the patcher -- is covered # by the byte-preservation proof and not only by the patching tests. records.extend( definition( 0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84), (8, PRODUCT_NAME_SIZE, 0x07)], ) ) file_id_data_start = pos() original_product_name = b"MyWhoosh Simulator\x00".ljust(PRODUCT_NAME_SIZE, b"\x2A") records.extend(data(0, struct.pack(" bytes: return _FIXTURE.fit_bytes def find_expected_device_metadata_offsets(before: bytes) -> set[int]: """Byte offsets convert_fit_device() is expected to touch, taken from the fixture's own construction-time bookkeeping (see _build_complex_fixture above). Deliberately does not parse `before` or call any part of app.fit.rewriter, so this test cannot become circular (the parser being tested against itself).""" del before return set(_FIXTURE.metadata_offsets) def test_only_target_fields_and_crcs_change(tmp_path: Path, complex_fit_bytes: bytes) -> None: source = tmp_path / "source.fit" output = tmp_path / "output.fit" source.write_bytes(complex_fit_bytes) convert_fit_device(source, output) before = source.read_bytes() after = output.read_bytes() assert len(before) == len(after) changed = {index for index, (a, b) in enumerate(zip(before, after)) if a != b} expected_metadata_offsets = set(find_expected_device_metadata_offsets(before)) crc_offsets = {12, 13, len(before) - 2, len(before) - 1} assert changed <= expected_metadata_offsets | crc_offsets def test_complex_fixture_patches_targets_and_preserves_advanced_records( tmp_path: Path, complex_fit_bytes: bytes ) -> None: source = tmp_path / "source.fit" output = tmp_path / "output.fit" source.write_bytes(complex_fit_bytes) result = convert_fit_device(source, output) assert is_fit_file(output) is True assert result.patched_field_count == 5 after = output.read_bytes() assert struct.unpack_from(" None: """The string write path zero-fills the *entire* declared field. Prove that the rewrite is confined to the product_name field's own bytes: the target string plus a null terminator plus zero padding, with the surrounding record bytes untouched (the enclosing preservation test already asserts the global changed-byte set).""" source = tmp_path / "source.fit" output = tmp_path / "output.fit" source.write_bytes(complex_fit_bytes) convert_fit_device(source, output) start = _FIXTURE.file_id_product_name_offset end = start + _FIXTURE.file_id_product_name_size after = output.read_bytes() expected = b"Edge 1030 Plus\x00".ljust(_FIXTURE.file_id_product_name_size, b"\x00") assert after[start:end] == expected # The source deliberately padded past its null terminator with 0x2A bytes, so a # write that overran (or under-cleared) the field would be visible here. assert complex_fit_bytes[start:end] != expected values = read_device_field_values(output) assert any( v.global_message_num == FILE_ID_MESG_NUM and v.field_num == 8 and v.value == "Edge 1030 Plus" and v.is_creator for v in values ) def test_convert_fit_device_supports_12_byte_header(tmp_path: Path) -> None: """12-byte headers carry no header CRC field, so conversion must succeed and report header_crc=None while still rewriting the file CRC.""" file_def = definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84)]) file_data = data(0, struct.pack(" None: path = tmp_path / "truncated.fit" path.write_bytes(make_fit(bytes([0x40, 0x00, 0x00]))) assert is_fit_file(path) is False def test_convert_fit_device_rejects_truncated_definition(tmp_path: Path) -> None: source = tmp_path / "truncated.fit" source.write_bytes(make_fit(bytes([0x40, 0x00, 0x00]))) output = tmp_path / "output.fit" with pytest.raises(FitFormatError): convert_fit_device(source, output)