import struct from pathlib import Path import pytest from app.fit.models import DeviceFieldValue, FitConversionResult, GarminDevice from app.fit.rewriter import ( FitFormatError, _iter_data_fields, convert_fit_device, is_fit_file, read_device_field_values, ) from tests.fit.builders import data, definition, make_fit FILE_ID_MESG_NUM = 0 DEVICE_INFO_MESG_NUM = 23 def _build_fixture() -> bytes: # file_id: manufacturer(1/u16), product(2/u16) file_def = definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84)]) file_data = data(0, struct.pack(" bytes: """Big-endian (architecture=1) file_id definition/data pair. Closes the big-endian coverage gap deferred from Task 2: no fixture anywhere in the FIT test suite previously passed endian=">" to definition()/struct.pack, so the architecture-byte branch in _read_definition (and the endian format string it threads into every field read/write) was never actually exercised.""" file_def = definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84)], endian=">") file_data = data(0, struct.pack(">HH", 255, 999)) return make_fit(file_def + file_data) def _build_no_device_index_fixture() -> bytes: """device_info record entirely missing field 0 (device_index) must be left untouched.""" file_def = definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84)]) file_data = data(0, struct.pack(" bytes: """file_id field 8 and device_info field 27 are both product_name (string) fields — the only string field type this task patches. No sensor record here, so the single device_info record present is unambiguously the creator.""" # file_id: manufacturer(1/u16), product(2/u16), product_name(8/string, size 20) file_def = definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84), (8, 20, 0x07)]) original_file_name = b"MyWhoosh\x00".ljust(20, b"\x00") file_data = data(0, struct.pack(" bytes: """Adversarial file declaring device_info field 0 (device_index) with size 0. Without an explicit rejection, `int.from_bytes(b"", "little") == 0` would make every record on this definition look like the creator (device_index == 0) and a paired sensor would be rewritten as an Edge 1030 Plus.""" file_def = definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84)]) file_data = data(0, struct.pack(" bytes: """file_id with a 2-byte serial_number field (3/u16) -- too small to hold a 32-bit serial number handed in through the public API.""" file_def = definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84), (3, 2, 0x84)]) file_data = data(0, struct.pack(" bytes: """file_id declaring product (field 2) as a single byte, which cannot hold 3570.""" file_def = definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 1, 0x02)]) file_data = data(0, struct.pack(" bytes: """file_id product_name field of size 10 -- too small for "Edge 1030 Plus" (14 bytes plus a null terminator), so the string write silently no-ops.""" file_def = definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84), (8, 10, 0x07)]) file_data = data(0, struct.pack(" list[dict[int, int]]: """Parse raw bytes and return one dict of {field_num: value} per device_info record, in file order, so creator and sensor records can be distinguished positionally.""" raw = bytearray(path.read_bytes()) records: list[dict[int, int]] = [] for local_def, field_offsets in _iter_data_fields(raw): if local_def.global_message_num != DEVICE_INFO_MESG_NUM: continue values: dict[int, int] = {} for field, offset in field_offsets: raw_bytes = bytes(raw[offset : offset + field.size]) if field.size == 1: values[field.num] = raw_bytes[0] elif field.size == 2: values[field.num] = struct.unpack(f"{local_def.endian}H", raw_bytes)[0] records.append(values) return records def test_convert_fit_device_patches_file_id(tmp_path: Path) -> None: source = tmp_path / "source.fit" source.write_bytes(_build_fixture()) output = tmp_path / "output.fit" result = convert_fit_device(source, output) assert isinstance(result, FitConversionResult) assert result.source_path == source assert result.output_path == output assert result.file_crc is not None assert result.patched_field_count > 0 values = read_device_field_values(output) values_by_key = {(v.global_message_num, v.field_num): v.value for v in values} assert values_by_key[(FILE_ID_MESG_NUM, 1)] == 1 assert values_by_key[(FILE_ID_MESG_NUM, 2)] == 3570 def test_convert_fit_device_patches_creator_and_leaves_sensor_untouched( tmp_path: Path, ) -> None: source = tmp_path / "source.fit" source.write_bytes(_build_fixture()) output = tmp_path / "output.fit" convert_fit_device(source, output) device_info_records = _read_device_info_records(output) assert len(device_info_records) == 2 creator_values, sensor_values = device_info_records # creator (device_index=0) patched to Edge 1030 Plus assert creator_values[0] == 0 assert creator_values[2] == 1 assert creator_values[4] == 3570 # sensor (device_index=1) must remain completely untouched assert sensor_values[0] == 1 assert sensor_values[2] == 32 assert sensor_values[4] == 1234 def test_convert_fit_device_leaves_device_info_without_device_index_untouched( tmp_path: Path, ) -> None: source = tmp_path / "source.fit" source.write_bytes(_build_no_device_index_fixture()) output = tmp_path / "output.fit" convert_fit_device(source, output) device_info_records = _read_device_info_records(output) assert len(device_info_records) == 1 values = device_info_records[0] # device_info without a device_index field must remain completely untouched assert values[2] == 32 assert values[4] == 1234 def test_convert_fit_device_patches_product_name_string_fields(tmp_path: Path) -> None: """file_id field 8 and device_info field 27 (creator) are both string fields, written via _write_field_value's string branch (buffer sizing + null-terminator handling). Neither is exercised by the numeric-only base fixture used elsewhere in this module.""" source = tmp_path / "source.fit" source.write_bytes(_build_product_name_fixture()) output = tmp_path / "output.fit" convert_fit_device(source, output) values = read_device_field_values(output) values_by_key = {(v.global_message_num, v.field_num): v.value for v in values} assert values_by_key[(FILE_ID_MESG_NUM, 8)] == "Edge 1030 Plus" assert values_by_key[(DEVICE_INFO_MESG_NUM, 27)] == "Edge 1030 Plus" def test_convert_fit_device_round_trips_big_endian_fields(tmp_path: Path) -> None: """Proves both the read side (parsing manufacturer/product under architecture=1) and the write side (patching them back in big-endian byte order) are correct -- a byte-order bug on either side would flip 255/999 or the patched 1/3570 into an unrelated value once read back with the (still big-endian) definition.""" source = tmp_path / "source.fit" source.write_bytes(_build_big_endian_fixture()) output = tmp_path / "output.fit" convert_fit_device(source, output) values = read_device_field_values(output) values_by_key = {(v.global_message_num, v.field_num): v.value for v in values} assert values_by_key[(FILE_ID_MESG_NUM, 1)] == 1 assert values_by_key[(FILE_ID_MESG_NUM, 2)] == 3570 def test_convert_fit_device_defaults_to_edge_1030_plus() -> None: device = GarminDevice() assert device.manufacturer_id == 1 assert device.product_id == 3570 assert device.product_name == "Edge 1030 Plus" def test_convert_fit_device_rejects_invalid_container(tmp_path: Path) -> None: source = tmp_path / "bad.fit" source.write_bytes(b"not a fit file") output = tmp_path / "output.fit" with pytest.raises(FitFormatError): convert_fit_device(source, output) def test_read_device_field_values_returns_device_field_value_instances(tmp_path: Path) -> None: source = tmp_path / "source.fit" source.write_bytes(_build_fixture()) values = read_device_field_values(source) assert all(isinstance(v, DeviceFieldValue) for v in values) assert any(v.global_message_num == FILE_ID_MESG_NUM for v in values) def test_zero_size_field_definition_is_rejected(tmp_path: Path) -> None: """A zero-size field is illegal FIT and must be rejected during parsing, so a zero-size device_index can never make a sensor record read as the creator.""" source = tmp_path / "zero-size.fit" source.write_bytes(_build_zero_size_device_index_fixture()) output = tmp_path / "output.fit" assert is_fit_file(source) is False with pytest.raises(FitFormatError): convert_fit_device(source, output) assert not output.exists() def test_value_too_large_for_declared_field_size_raises_fit_format_error(tmp_path: Path) -> None: """A 32-bit serial number against a 2-byte serial field must surface as FitFormatError, not a raw struct.error escaping the public API.""" source = tmp_path / "source.fit" source.write_bytes(_build_undersized_serial_fixture()) output = tmp_path / "output.fit" with pytest.raises(FitFormatError): convert_fit_device(source, output, GarminDevice(serial_number=4294967295)) assert not output.exists() def test_product_id_too_large_for_one_byte_field_raises_fit_format_error(tmp_path: Path) -> None: source = tmp_path / "source.fit" source.write_bytes(_build_undersized_product_fixture()) output = tmp_path / "output.fit" with pytest.raises(FitFormatError): convert_fit_device(source, output) assert not output.exists() def test_unwritable_product_name_fails_conversion(tmp_path: Path) -> None: """A product_name field too small for the target string used to be silently left unpatched while manufacturer/product reported success. The post-patch read-back verification must turn that partial patch into a controlled failure.""" source = tmp_path / "source.fit" source.write_bytes(_build_undersized_product_name_fixture()) output = tmp_path / "output.fit" with pytest.raises(FitFormatError): convert_fit_device(source, output) assert not output.exists() def test_read_device_field_values_marks_creator_records(tmp_path: Path) -> None: """The creator device_info and every file_id field are is_creator=True; a paired sensor's device_info fields are is_creator=False, so a caller building a {(mesg, field): value} dict can no longer be shadowed by sensor values.""" source = tmp_path / "source.fit" source.write_bytes(_build_fixture()) output = tmp_path / "output.fit" convert_fit_device(source, output) values = read_device_field_values(output) assert all(v.is_creator for v in values if v.global_message_num == FILE_ID_MESG_NUM) creator = { (v.global_message_num, v.field_num): v.value for v in values if v.global_message_num == DEVICE_INFO_MESG_NUM and v.is_creator } sensor = { (v.global_message_num, v.field_num): v.value for v in values if v.global_message_num == DEVICE_INFO_MESG_NUM and not v.is_creator } assert creator[(DEVICE_INFO_MESG_NUM, 2)] == 1 assert creator[(DEVICE_INFO_MESG_NUM, 4)] == 3570 assert sensor[(DEVICE_INFO_MESG_NUM, 2)] == 32 assert sensor[(DEVICE_INFO_MESG_NUM, 4)] == 1234 def test_device_info_without_device_index_is_not_creator(tmp_path: Path) -> None: source = tmp_path / "source.fit" source.write_bytes(_build_no_device_index_fixture()) values = read_device_field_values(source) device_info_values = [v for v in values if v.global_message_num == DEVICE_INFO_MESG_NUM] assert device_info_values assert all(v.is_creator is False for v in device_info_values)