diff --git a/src/activity_parser/parse_tcx_gpx.py b/src/activity_parser/parse_tcx_gpx.py index fee9049..5168e34 100644 --- a/src/activity_parser/parse_tcx_gpx.py +++ b/src/activity_parser/parse_tcx_gpx.py @@ -92,7 +92,7 @@ def walk_fields(element: etree._Element) -> Iterator[tuple[FieldPath, str]]: """Yields (path, value) for every attribute and leaf element under ``element``. ``path`` matches the keys of the field tables in ``xml_fields``. XML comments are - skipped. + skipped. ``path`` is never empty. """ yield from _walk(element, ()) @@ -116,7 +116,7 @@ def _walk(element: etree._Element, path: FieldPath) -> Iterator[tuple[FieldPath, yield path + ((namespace, "@" + localname),), cast(str, value) text = element.text - if text is not None and not text.isspace(): + if path and text is not None and not text.isspace(): yield path, text # "*" matches only true elements, so comments are skipped without special-casing. for child in element.iterchildren("*"): diff --git a/tests/files/gpx/direct_text.gpx b/tests/files/gpx/direct_text.gpx new file mode 100644 index 0000000..7484338 --- /dev/null +++ b/tests/files/gpx/direct_text.gpx @@ -0,0 +1,14 @@ + + + + + + stray-trkpt-text + 10.0 + + + + + diff --git a/tests/files/tcx/direct_text.tcx b/tests/files/tcx/direct_text.tcx new file mode 100644 index 0000000..0e292b6 --- /dev/null +++ b/tests/files/tcx/direct_text.tcx @@ -0,0 +1,21 @@ + + + + + + 2026-01-05T08:00:00Z + stray-lap-text + 1.0 + 5.0 + + stray-trackpoint-text + + 10.0 + + + + + + diff --git a/tests/test_parse_tcx_gpx.py b/tests/test_parse_tcx_gpx.py index cea4787..853da25 100644 --- a/tests/test_parse_tcx_gpx.py +++ b/tests/test_parse_tcx_gpx.py @@ -31,6 +31,8 @@ CADENCE_COLLISION_TCX = TCX_FILES / "cadence_collision.tcx" UNKNOWN_COLLISION_TCX = TCX_FILES / "unknown_collision.tcx" MIXED_CONTENT_TCX = TCX_FILES / "mixed_content.tcx" +DIRECT_TEXT_TCX = TCX_FILES / "direct_text.tcx" +DIRECT_TEXT_GPX = GPX_FILES / "direct_text.gpx" VENDOR_TYPE_ATTRIBUTE_TCX = TCX_FILES / "vendor_type_attribute.tcx" DUPLICATE_CADENCE_TCX = TCX_FILES / "duplicate_cadence.tcx" LAPS_ONLY_TCX = TCX_FILES / "laps_only.tcx" @@ -389,6 +391,15 @@ def test_gpx_unknown_extension_kept_as_namespaced_string(): assert column not in normalized.columns +def test_gpx_direct_text_on_trkpt_is_skipped(): + # Text directly inside trkpt itself used to raise IndexError. + records, _, _ = parse_gpx(DIRECT_TEXT_GPX) + assert records["latitude"].tolist() == pytest.approx([37.0000]) + assert records["longitude"].tolist() == pytest.approx([-122.0000]) + assert records["altitude"].tolist() == [10.0] + assert not records.isin(["stray-trkpt-text"]).any().any() + + def test_gpx_1_0_base_fields(): # GPX 1.0 exposes course/speed directly; GPS-fix diagnostics aren't fitness data. records, _, _ = ActivityParser().parse(GPX10) @@ -493,6 +504,16 @@ def test_tcx_mixed_content_keeps_both_text_and_child(): assert records[f"{{{ns}}}Nested"].tolist() == ["should-not-be-lost"] +def test_tcx_direct_text_on_lap_and_trackpoint_is_skipped(): + # Text directly inside Lap/Trackpoint themselves used to raise IndexError. + records, laps, _ = parse_tcx(DIRECT_TEXT_TCX) + assert records["altitude"].tolist() == [10.0] + assert laps["total_elapsed_time"].tolist() == [1.0] + assert laps["total_distance"].tolist() == pytest.approx([0.005]) + assert not records.isin(["stray-trackpoint-text"]).any().any() + assert not laps.isin(["stray-lap-text"]).any().any() + + def test_tcx_type_attribute_only_skipped_for_xsi_namespace(): # Only genuine xsi:type is metadata; a same-named attribute elsewhere is real data. records, _, _ = parse_tcx(VENDOR_TYPE_ATTRIBUTE_TCX)