from __future__ import annotations import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from app import ( _format_slice_rois_text, _greedy_point_matches, _extract_ordered_contours_by_slice, _extract_volume_bounds_from_rtss, _merge_bounds, _nearest_slice_value, _safe_key_fragment, _slice_match_metrics, _step_slice_value, extract_contour_points, should_use_unified_only, ) def test_extract_contour_points_collects_all_triplets() -> None: sample = { "(3006,0039) ROIContourSequence": [ { "(3006,0040) ContourSequence": [ { "(3006,0050) ContourData": [ [1.0, 2.0, 3.0], [4, 5, 6], ] }, { "(3006,0050) ContourData": [ [7.5, 8.5, 9.5], ] }, ] } ] } points = extract_contour_points(sample) assert points == [ (1.0, 2.0, 3.0), (4.0, 5.0, 6.0), (7.5, 8.5, 9.5), ] def test_extract_contour_points_ignores_non_triplet_data() -> None: sample = { "(3006,0050) ContourData": [ [1.0, 2.0], [3.0, 4.0, 5.0, 6.0], "not-a-point", {"unexpected": "shape"}, ] } points = extract_contour_points(sample) assert points == [] def test_should_use_unified_only_for_batch_text_mode() -> None: use_unified_only, reason = should_use_unified_only( "left", "right", allow_rich_view=False, max_rich_chars=400_000, max_rich_lines=5_000, ) assert use_unified_only is True assert "unified diff text only" in reason def test_should_use_unified_only_for_large_input() -> None: left = "a" * 250_000 right = "b" * 250_000 use_unified_only, reason = should_use_unified_only( left, right, allow_rich_view=True, max_rich_chars=400_000, max_rich_lines=5_000, ) assert use_unified_only is True assert "Large comparison detected" in reason def test_should_use_rich_view_for_small_input() -> None: use_unified_only, reason = should_use_unified_only( "small-left\n", "small-right\n", allow_rich_view=True, max_rich_chars=400_000, max_rich_lines=5_000, ) assert use_unified_only is False assert reason == "" def test_extract_volume_bounds_from_rtss_metadata() -> None: sample = { "(3006,0039) ROIContourSequence": [ { "(3006,004A) SourcePixelPlanesCharacteristicsSequence": [ { "(0020,0032) ImagePositionPatient": [10.0, 20.0, 30.0], "(0020,0037) ImageOrientationPatient": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0], "(0028,0030) PixelSpacing": [2.0, 1.0], "(0028,0010) Rows": 2, "(0028,0011) Columns": 3, "(0028,0008) NumberOfFrames": 4, "(0018,0088) SpacingBetweenSlices": 2.0, } ] } ] } bounds, msg = _extract_volume_bounds_from_rtss(sample) assert bounds is not None assert bounds["x_min"] == 10.0 assert bounds["x_max"] == 12.0 assert bounds["y_min"] == 20.0 assert bounds["y_max"] == 22.0 assert bounds["z_min"] == 30.0 assert bounds["z_max"] == 36.0 assert "derived" in msg def test_extract_volume_bounds_from_rtss_missing_metadata() -> None: sample = { "(3006,0039) ROIContourSequence": [ {"(3006,0040) ContourSequence": [{"(3006,0050) ContourData": [[1.0, 2.0, 3.0]]}]} ] } bounds, msg = _extract_volume_bounds_from_rtss(sample) assert bounds is None assert "incomplete" in msg def test_merge_bounds() -> None: a = {"x_min": 0.0, "x_max": 1.0, "y_min": 2.0, "y_max": 3.0, "z_min": 4.0, "z_max": 5.0} b = {"x_min": -1.0, "x_max": 2.0, "y_min": 1.5, "y_max": 3.5, "z_min": 3.0, "z_max": 6.0} merged = _merge_bounds(a, b) assert merged == { "x_min": -1.0, "x_max": 2.0, "y_min": 1.5, "y_max": 3.5, "z_min": 3.0, "z_max": 6.0, } def test_greedy_point_matches_unique_pairs() -> None: left = [(0.0, 0.0, 0.0), (10.0, 0.0, 0.0)] right = [(0.2, 0.0, 0.0), (9.8, 0.0, 0.0)] matches = _greedy_point_matches(left, right) assert len(matches) == 2 assert {m[0] for m in matches} == {0, 1} assert {m[1] for m in matches} == {0, 1} def test_slice_match_metrics_dice_and_mismatch() -> None: left_rois = { "ROI 1": [ (0.0, 0.0, 1.0), (10.0, 0.0, 1.0), ] } right_rois = { "ROI 1": [ (0.2, 0.0, 1.0), (11.5, 0.0, 1.0), ] } metrics = _slice_match_metrics(left_rois, right_rois, tolerance_mm=1.0) assert metrics["left_count"] == 2 assert metrics["right_count"] == 2 assert metrics["mismatch_count"] == 2 assert metrics["count_delta"] == 0 assert metrics["dice"] == 0.5 assert metrics["identical_slice"] is False def test_slice_match_metrics_identical_slice() -> None: rois = { "ROI 1": [ (1.0, 2.0, 3.0), (4.0, 5.0, 3.0), ] } metrics = _slice_match_metrics(rois, rois, tolerance_mm=0.1) assert metrics["identical_slice"] is True assert metrics["mismatch_count"] == 0 assert metrics["dice"] == 1.0 def test_safe_key_fragment_replaces_non_alnum() -> None: assert _safe_key_fragment("z=12.5/ROI 1") == "z_12_5_ROI_1" def test_nearest_slice_value_selects_closest() -> None: slices = [1.0, 2.5, 5.0] assert _nearest_slice_value(slices, 2.7) == 2.5 assert _nearest_slice_value(slices, None) == 1.0 def test_nearest_slice_value_returns_none_for_empty() -> None: assert _nearest_slice_value([], 10.0) is None def test_extract_ordered_contours_by_slice_preserves_point_order() -> None: sample = { "(3006,0039) ROIContourSequence": [ { "(3006,0084) ReferencedROINumber": 7, "(3006,0040) ContourSequence": [ { "(3006,0048) ContourNumber": 3, "(3006,0050) ContourData": [ [10.0, 5.0, 1.0001], [11.0, 6.0, 1.0001], [12.0, 7.0, 1.0001], ], } ], } ] } slices = _extract_ordered_contours_by_slice(sample, precision=3) assert list(slices.keys()) == [1.0] assert len(slices[1.0]) == 1 contour = slices[1.0][0] assert contour["contour_label"] == "ROI 7 | Contour 3" assert contour["points"] == [ (10.0, 5.0, 1.0001), (11.0, 6.0, 1.0001), (12.0, 7.0, 1.0001), ] def test_step_slice_value_bounds_and_steps() -> None: slices = [1.0, 2.0, 3.0] assert _step_slice_value(slices, 2.0, -1) == 1.0 assert _step_slice_value(slices, 2.0, 1) == 3.0 assert _step_slice_value(slices, 1.0, -1) == 1.0 assert _step_slice_value(slices, 3.0, 1) == 3.0 def test_step_slice_value_handles_missing_current() -> None: slices = [1.0, 2.0, 3.0] assert _step_slice_value(slices, 99.0, 1) == 2.0 def test_format_slice_rois_text_includes_index_and_order() -> None: rois = { "ROI 2": [(2.0, 2.0, 5.0)], "ROI 1": [(1.0, 1.0, 5.0), (3.0, 3.0, 5.0)], } text = _format_slice_rois_text(rois, precision=2) assert "ROI 1: 2 points" in text assert "001: (1.00, 1.00, 5.00)" in text assert "002: (3.00, 3.00, 5.00)" in text assert "ROI 2: 1 points" in text def test_format_slice_rois_text_empty() -> None: assert _format_slice_rois_text({}, precision=4) == "(no contours on this slice)"