diff --git a/tests/fit/builders.py b/tests/fit/builders.py index 6f3c2d4..d416f23 100644 --- a/tests/fit/builders.py +++ b/tests/fit/builders.py @@ -18,15 +18,43 @@ def make_fit(data_records: bytes, *, header_size: int = 14) -> bytes: return bytes(body + struct.pack(" bytes: +def definition( + local: int, + global_num: int, + fields: list[tuple[int, int, int]], + *, + endian: str = "<", + developer_fields: list[tuple[int, int, int]] | None = None, +) -> bytes: + """Build a FIT definition message. + + ``developer_fields`` is an optional list of (field_num, size, developer_data_index) + triples. When provided (and non-empty), the record header's "has developer fields" + bit (0x20) is set and a developer field definition block is appended after the + native field definitions, per the FIT format. + """ architecture = 1 if endian == ">" else 0 + header_byte = 0x40 | local + if developer_fields: + header_byte |= 0x20 payload = bytearray([0, architecture]) payload.extend(struct.pack(f"{endian}H", global_num)) payload.append(len(fields)) for num, size, base_type in fields: payload.extend(bytes([num, size, base_type])) - return bytes([0x40 | local]) + bytes(payload) + if developer_fields: + payload.append(len(developer_fields)) + for num, size, dev_index in developer_fields: + payload.extend(bytes([num, size, dev_index])) + return bytes([header_byte]) + bytes(payload) def data(local: int, payload: bytes) -> bytes: return bytes([local]) + payload + + +def compressed_timestamp_data(local: int, time_offset: int, payload: bytes) -> bytes: + """Build a compressed-timestamp data record header (bit 0x80 set, local message + type in bits 5-6, time offset in bits 0-4) followed by the record payload.""" + header = 0x80 | ((local & 0x03) << 5) | (time_offset & 0x1F) + return bytes([header]) + payload diff --git a/tests/fit/test_rewriter_preservation.py b/tests/fit/test_rewriter_preservation.py new file mode 100644 index 0000000..35ff1b3 --- /dev/null +++ b/tests/fit/test_rewriter_preservation.py @@ -0,0 +1,187 @@ +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 + + +@dataclass(frozen=True) +class ComplexFixture: + fit_bytes: bytes + metadata_offsets: set[int] + file_id_manufacturer_offset: int + file_id_product_offset: 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. + records.extend(definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84)])) + + file_id_data_start = pos() + 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 == 4 + + after = output.read_bytes() + assert struct.unpack_from(" 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)