diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..43ca0d61 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,141 @@ +name: CI + +# Quality gate for every pull request targeting main and every push to main +# (post-merge validation). It does not build or publish installers; it only +# checks that the tree lints clean on the critical-error set, compiles, imports, +# installs with a working entry point, and produces a valid wheel. +on: + pull_request: + branches: [main] + push: + branches: [main] + +# One in-flight run per ref; a newer push to the same PR/branch supersedes it. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Least privilege: the gate only reads the repo; it touches no secrets. +permissions: + contents: read + +jobs: + # Fast, OS-independent static checks. No heavy dependency install, so this + # fails quickly on syntax/lint problems before the build matrix spins up. + lint: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" # PySide6==6.5.2 ships wheels for 3.11 only + + - name: Install ruff + run: python -m pip install "ruff==0.15.20" + + # Blocking: the critical-error set defined in ruff.toml (syntax + undefined + # names). The current tree passes this clean. + - name: Ruff lint (critical errors) + run: ruff check . + + # Compile the importable package. Bundled asset scripts under + # PyReconstruct/assets are excluded: they are shipped as package data, not + # imported, and include a known-broken standalone snippet. + - name: Compile sources + run: python -m compileall -q -x "PyReconstruct/assets/" PyReconstruct + + # Cross-platform: install the package from setup.py, confirm it imports and the + # console entry point resolves, then build the wheel and validate metadata. + build-and-import: + needs: lint + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 # headroom for the cold wheel install (vtk/scipy/PySide6/...) + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install headless Qt system libs (Linux) + # PySide6 6.5 needs these even under the offscreen platform. Only needed + # on the Linux runner; the macOS/Windows images ship the equivalents. + if: runner.os == 'Linux' + env: + DEBIAN_FRONTEND: noninteractive + run: | + for i in 1 2 3; do sudo apt-get update && break || sleep 5; done + sudo apt-get install -y --no-install-recommends \ + libegl1 libgl1 libxkbcommon0 libfontconfig1 libdbus-1-3 + + - name: Install package (from setup.py) + run: | + python -m pip install --upgrade pip + python -m pip install . + + - name: Import package and resolve entry point (headless) + env: + QT_QPA_PLATFORM: offscreen + # bash on all three runners so the quoting below behaves identically + # (the Windows default shell is pwsh). Run from a scratch dir so the + # installed package is imported, not the checkout, and verify the + # console_script the installer/updater rely on resolves to a callable. + shell: bash + working-directory: ${{ runner.temp }} + run: | + python -c "import PyReconstruct; print('import OK:', PyReconstruct.__file__)" + python -c "import importlib.metadata as im; ep=[e for e in im.entry_points(group='console_scripts') if e.name=='PyReconstruct']; assert ep, 'PyReconstruct console_script missing'; fn=ep[0].load(); print('entry point OK:', ep[0].value, '->', fn)" + + - name: Build wheel and check metadata + # Wheel only: the source tree has no MANIFEST.in, so an sdist round-trip + # would drop required package data. Building the wheel from the source + # tree is what the installers/updater consume. + # bash on all runners so the dist/* glob expands consistently (pwsh is + # the Windows default). + shell: bash + run: | + python -m pip install build twine + python -m build --wheel + twine check dist/* + + # Backend/geometry unit tests. Headless (offscreen QPA) so no X server is + # needed; the datatypes transitively import PySide6. Linux only: the suite is + # pure Python and platform-independent, so one runner is enough. + test: + needs: lint + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install headless Qt system libs + env: + DEBIAN_FRONTEND: noninteractive + run: | + for i in 1 2 3; do sudo apt-get update && break || sleep 5; done + sudo apt-get install -y --no-install-recommends \ + libegl1 libgl1 libxkbcommon0 libfontconfig1 libdbus-1-3 + + - name: Install package + pytest + # setup.py has no test extra, so pytest is installed explicitly. Pinned + # to the 9.x line the suite is verified against. + run: | + python -m pip install --upgrade pip + python -m pip install . "pytest>=9,<10" + + - name: Run tests (headless / offscreen) + env: + QT_QPA_PLATFORM: offscreen + run: python -m pytest tests -ra diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..e110e970 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,25 @@ +# Ruff configuration for PyReconstruct. +# +# Kept in a standalone ruff.toml (rather than pyproject.toml) so linting stays +# independent of the packaging metadata in setup.py. +# +# The lint selection below is deliberately narrow: it is the canonical +# "critical-error" set (syntax errors and undefined names) that the current +# tree already passes clean, so CI gates on genuine bugs without forcing a +# whole-repo reformat. Style/import-hygiene rules (E/W/F401/...) are reported +# by CI but not enforced yet; they can be adopted incrementally later. + +target-version = "py311" # matches the PySide6==6.5.2 pin (3.11-only wheels) + +# Bundled helper scripts shipped as package data, not part of the importable +# package. They are excluded here because they are not on any import path and +# include known-broken standalone snippets. +extend-exclude = ["PyReconstruct/assets"] + +[lint] +# E9 - syntax errors +# F63 - invalid comparisons / assert on tuple, etc. +# F7 - misplaced statements (break/continue/return outside function/loop, ...) +# F82 - undefined name / undefined name in __all__ +# This is the same "must-not-merge" set flake8 users pin; it is bug-class only. +select = ["E9", "F63", "F7", "F82"] diff --git a/tests/test_calc_quantification_primitives.py b/tests/test_calc_quantification_primitives.py new file mode 100644 index 00000000..f5fb9e44 --- /dev/null +++ b/tests/test_calc_quantification_primitives.py @@ -0,0 +1,376 @@ +"""Unit tests for the geometry primitives in modules/calc/quantification.py. + +Covers the scalar/elementary helpers that the rest of the app (and the +vectorized traceGeometry hot path) is built on: shoelace area, polygon +centroid, 2D/3D Euclidean distance, polyline length, significant-figure +rounding, segment orientation/intersection, contour intersection, ellipse +sampling, the deterministic colorize palette, and the OpenCV-backed +point-in-polygon / distance-from-trace helpers. + +Expected values are hand-derived from known geometry (shoelace, geometric +centroids, Pythagorean triples, perimeters) and mathematical identities +(orientation independence of area magnitude and centroid, symmetry), not +echoed back from the functions under test. traceGeometry and the +interpolate_points empty/single guard are covered elsewhere and not retested. +""" +import math + +import pytest + +from PyReconstruct.modules.calc.quantification import ( + area, + centroid, + distance, + distance3D, + euclidean_distance, + lineDistance, + sigfigRound, + ccw, + linesIntersect, + lineIntersectsContour, + colorize, + ellipseFromPair, + pointInPoly, + getDistanceFromTrace, +) + + +# A unit/known square reused across several tests. +SQUARE = [(0, 0), (10, 0), (10, 10), (0, 10)] # CCW, area 100, perim 40 +SQUARE_CW = [(0, 0), (0, 10), (10, 10), (10, 0)] # same square, reversed +SQUARE_100 = [(0, 0), (100, 0), (100, 100), (0, 100)] # for OpenCV int tests + + +# --------------------------------------------------------------------------- # +# area() -- shoelace, unsigned # +# --------------------------------------------------------------------------- # + +def test_area_square(): + assert area(SQUARE) == pytest.approx(100.0) + + +def test_area_triangle(): + # base 4, height 3 -> 1/2 * 4 * 3 = 6 + assert area([(0, 0), (4, 0), (2, 3)]) == pytest.approx(6.0) + + +@pytest.mark.parametrize("pts", [[], [(0, 0)], [(0, 0), (1, 1)]]) +def test_area_two_or_fewer_points_is_zero(pts): + assert area(pts) == 0 + + +def test_area_auto_closes_open_ring(): + # An explicitly-closed square (first == last) must give the same area + # as the open form; the function appends the first point if needed. + closed = SQUARE + SQUARE[:1] + assert area(closed) == pytest.approx(area(SQUARE)) + + +def test_area_is_unsigned_orientation_independent(): + # Shoelace is signed by winding; area() takes abs, so CW == CCW magnitude. + assert area(SQUARE_CW) == pytest.approx(area(SQUARE)) + + +def test_area_collinear_points_zero(): + assert area([(0, 0), (1, 1), (2, 2), (3, 3)]) == pytest.approx(0.0) + + +# --------------------------------------------------------------------------- # +# centroid() -- area-weighted, mean fallback for degenerate shapes # +# --------------------------------------------------------------------------- # + +def test_centroid_unit_square(): + assert centroid([(0, 0), (1, 0), (1, 1), (0, 1)]) == (0.5, 0.5) + + +def test_centroid_triangle_equals_vertex_mean(): + # The centroid of a triangle is the mean of its vertices: (0+6+0)/3 = 2. + assert centroid([(0, 0), (6, 0), (0, 6)]) == (2.0, 2.0) + + +def test_centroid_offset_square_translates(): + # Centroid of a square translated to (100,200): center is (101,201). + pts = [(100, 200), (102, 200), (102, 202), (100, 202)] + assert centroid(pts) == (101.0, 201.0) + + +def test_centroid_orientation_independent(): + # Reversing the winding must not move the centroid. + assert centroid(SQUARE_CW) == centroid(SQUARE) + + +def test_centroid_degenerate_falls_back_to_mean_of_points(): + # Collinear -> zero area -> mean of the supplied points: (0+1+2+3)/4 = 1.5. + assert centroid([(0, 0), (1, 1), (2, 2), (3, 3)]) == (1.5, 1.5) + + +def test_centroid_two_points_mean(): + # <=2 pts has zero area; result is the mean: ((0+2)/2, (0+4)/2). + assert centroid([(0, 0), (2, 4)]) == (1.0, 2.0) + + +def test_centroid_rounded_to_six_places(): + cx, cy = centroid([(0, 0), (3, 0), (3, 7), (0, 7)]) + # Each coordinate is round(_, 6): no more than 6 decimal places. + assert cx == round(cx, 6) + assert cy == round(cy, 6) + assert (cx, cy) == (1.5, 3.5) + + +# --------------------------------------------------------------------------- # +# distance(), distance3D(), euclidean_distance() # +# --------------------------------------------------------------------------- # + +def test_distance_3_4_5(): + assert distance(0, 0, 3, 4) == pytest.approx(5.0) + + +def test_distance_symmetric_and_zero(): + assert distance(1, 2, 4, 6) == pytest.approx(distance(4, 6, 1, 2)) + assert distance(7, 7, 7, 7) == pytest.approx(0.0) + + +def test_distance3D_known_triple(): + # (1,2,2) has magnitude sqrt(1+4+4) = 3. + assert distance3D(0, 0, 0, 1, 2, 2) == pytest.approx(3.0) + + +def test_distance3D_z_order_symmetric(): + # Only (z2 - z1)**2 enters; swapping z endpoints can't change the result. + assert distance3D(0, 0, 5, 0, 0, 2) == pytest.approx(distance3D(0, 0, 2, 0, 0, 5)) + + +def test_distance3D_reduces_to_2d_when_z_equal(): + assert distance3D(0, 0, 9, 3, 4, 9) == pytest.approx(5.0) + + +def test_euclidean_distance_matches_distance(): + assert float(euclidean_distance((0, 0), (3, 4))) == pytest.approx(5.0) + assert float(euclidean_distance((1, 1), (4, 5))) == pytest.approx( + distance(1, 1, 4, 5) + ) + + +# --------------------------------------------------------------------------- # +# lineDistance() -- polyline length, open vs closed # +# --------------------------------------------------------------------------- # + +def test_line_distance_closed_square_perimeter(): + # Four edges of length 10 -> 40. + assert lineDistance(SQUARE, closed=True) == pytest.approx(40.0) + + +def test_line_distance_open_square_drops_closing_edge(): + # Open path omits the final return edge -> 30. + assert lineDistance(SQUARE, closed=False) == pytest.approx(30.0) + + +@pytest.mark.parametrize("pts", [[], [(5, 5)]]) +def test_line_distance_one_or_fewer_points_is_zero(pts): + assert lineDistance(pts) == 0 + + +def test_line_distance_open_triangle_vs_closed(): + tri = [(0, 0), (3, 0), (3, 4)] + # open: 3 + 4 = 7; closed adds the hypotenuse 5 -> 12. + assert lineDistance(tri, closed=False) == pytest.approx(7.0) + assert lineDistance(tri, closed=True) == pytest.approx(12.0) + + +def test_line_distance_rounded_to_seven_places(): + val = lineDistance(SQUARE, closed=True) + assert val == round(val, 7) + + +# --------------------------------------------------------------------------- # +# sigfigRound() # +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize( + "n,sf,expected", + [ + (12345, 2, 12000), + (12345, 1, 10000), + (6789, 1, 7000), + (0.012345, 2, 0.012), + (0.0123456, 3, 0.0123), + (-12345, 2, -12000), + (-0.012345, 2, -0.012), + ], +) +def test_sigfig_round_values(n, sf, expected): + assert sigfigRound(n, sf) == pytest.approx(expected) + + +def test_sigfig_round_zero_returns_zero(): + assert sigfigRound(0, 3) == 0 + + +def test_sigfig_round_keeps_count_of_significant_digits(): + # 123456 to 3 s.f. -> 123000. + assert sigfigRound(123456, 3) == pytest.approx(123000) + + +# --------------------------------------------------------------------------- # +# ccw() -- orientation of an ordered triple # +# --------------------------------------------------------------------------- # + +def test_ccw_counterclockwise_true(): + assert ccw((0, 0), (1, 0), (0, 1)) is True + + +def test_ccw_clockwise_false(): + assert ccw((0, 0), (0, 1), (1, 0)) is False + + +def test_ccw_collinear_false(): + # Strict inequality: collinear triples are not counterclockwise. + assert ccw((0, 0), (1, 1), (2, 2)) is False + + +def test_ccw_reversing_two_points_flips_orientation(): + a, b, c = (0, 0), (3, 1), (1, 4) + assert ccw(a, b, c) != ccw(a, c, b) + + +# --------------------------------------------------------------------------- # +# linesIntersect() -- crossing / parallel / shared endpoint # +# --------------------------------------------------------------------------- # + +def test_lines_intersect_crossing_x(): + # Diagonals of a square cross in the middle. + assert linesIntersect((0, 0), (2, 2), (0, 2), (2, 0)) is True + + +def test_lines_intersect_parallel_false(): + assert linesIntersect((0, 0), (2, 0), (0, 1), (2, 1)) is False + assert linesIntersect((0, 0), (0, 2), (1, 0), (1, 2)) is False + + +def test_lines_intersect_disjoint_collinear_false(): + assert linesIntersect((0, 0), (1, 0), (2, 0), (3, 0)) is False + + +def test_lines_intersect_shared_endpoint_true(): + # Two segments meeting at a common endpoint count as intersecting. + assert linesIntersect((0, 0), (1, 0), (1, 0), (1, 1)) is True + + +def test_lines_intersect_t_junction_true(): + # Endpoint of CD lands on the interior of AB. + assert linesIntersect((0, 0), (4, 0), (2, 0), (2, 2)) is True + + +# --------------------------------------------------------------------------- # +# lineIntersectsContour() -- segment vs polygon, open and closed # +# --------------------------------------------------------------------------- # + +def test_segment_crosses_closed_square(): + # Horizontal line spanning across the square at y=5 crosses two edges. + assert lineIntersectsContour(-5, 5, 15, 5, SQUARE, closed=True) is True + + +def test_segment_misses_square(): + # Vertical line entirely to the left of the square. + assert lineIntersectsContour(-5, -5, -5, 15, SQUARE, closed=True) is False + + +def test_open_contour_skips_closing_edge(): + # The segment from (-5,5) to (5,5) only crosses the LEFT edge, which for + # SQUARE is the closing edge (last->first). Closed sees it; open does not. + assert lineIntersectsContour(-5, 5, 5, 5, SQUARE, closed=True) is True + assert lineIntersectsContour(-5, 5, 5, 5, SQUARE, closed=False) is False + + +def test_segment_through_interior_crosses_when_reaching_an_edge(): + # A segment from inside the square out the right side crosses the right edge + # in both open and closed modes (that edge is not the closing one). + assert lineIntersectsContour(5, 5, 15, 5, SQUARE, closed=True) is True + assert lineIntersectsContour(5, 5, 15, 5, SQUARE, closed=False) is True + + +# --------------------------------------------------------------------------- # +# ellipseFromPair() # +# --------------------------------------------------------------------------- # + +def test_ellipse_point_count_matches_number(): + assert len(ellipseFromPair(0, 0, 10, 10, number=37)) == 37 + assert len(ellipseFromPair(0, 0, 10, 10)) == 100 # default + + +def test_ellipse_axis_aligned_bbox(): + # Diagonal corners (0,0)-(10,10): center (5,5), a=b=5 -> bbox [0,10]x[0,10]. + pts = ellipseFromPair(0, 0, 10, 10, number=200) + xs = [p[0] for p in pts] + ys = [p[1] for p in pts] + assert (min(xs), max(xs)) == (0, 10) + assert (min(ys), max(ys)) == (0, 10) + + +def test_ellipse_nonsymmetric_bbox_and_first_point(): + # (2,3)-(8,11): center (5,7), a=3, b=4. + pts = ellipseFromPair(2, 3, 8, 11, number=200) + xs = [p[0] for p in pts] + ys = [p[1] for p in pts] + assert (min(xs), max(xs)) == (2, 8) + assert (min(ys), max(ys)) == (3, 11) + # i=0: cos=1, sin=0 -> (center_x + a, center_y) = (8, 7). + assert pts[0] == (8, 7) + + +# --------------------------------------------------------------------------- # +# colorize() # +# --------------------------------------------------------------------------- # + +def test_colorize_returns_three_channels_in_range(): + for n in range(0, 400): + c = colorize(n) + assert len(c) == 3 + assert all(100 <= v <= 255 for v in c) + + +def test_colorize_is_deterministic(): + assert colorize(42) == colorize(42) + + +def test_colorize_period_156(): + # n enters as (n % 156)**3, so inputs 156 apart collide. + assert colorize(0) == colorize(156) + assert colorize(3) == colorize(3 + 156) + + +# --------------------------------------------------------------------------- # +# pointInPoly() / getDistanceFromTrace() -- OpenCV-backed (int rounding) # +# --------------------------------------------------------------------------- # + +def test_point_in_poly_inside_true(): + assert pointInPoly(50, 50, SQUARE_100) is True + + +def test_point_in_poly_outside_false(): + assert pointInPoly(150, 50, SQUARE_100) is False + + +def test_point_in_poly_on_boundary_true(): + # OpenCV returns 0 on the boundary; pointInPoly treats >= 0 as inside. + assert pointInPoly(0, 50, SQUARE_100) is True + + +def test_distance_from_trace_inside_nearest_edge(): + # (50,50) is 50 from the nearest edge of a 100x100 square. + assert getDistanceFromTrace(50, 50, SQUARE_100) == pytest.approx(50.0) + + +def test_distance_from_trace_absolute_outside(): + # (150,50) is 50 outside the right edge; absolute (default) -> +50. + assert getDistanceFromTrace(150, 50, SQUARE_100) == pytest.approx(50.0) + + +def test_distance_from_trace_signed_inside_positive_outside_negative(): + # Signed convention: inside positive, outside negative. + assert getDistanceFromTrace(50, 50, SQUARE_100, absolute=False) == pytest.approx(50.0) + assert getDistanceFromTrace(150, 50, SQUARE_100, absolute=False) == pytest.approx(-50.0) + + +def test_distance_from_trace_on_edge_zero(): + assert getDistanceFromTrace(0, 50, SQUARE_100) == pytest.approx(0.0) \ No newline at end of file diff --git a/tests/test_calc_smooth_grid.py b/tests/test_calc_smooth_grid.py new file mode 100644 index 00000000..6c82ae85 --- /dev/null +++ b/tests/test_calc_smooth_grid.py @@ -0,0 +1,470 @@ +"""Behavior tests for smoothing and grid/polygon ops in modules.calc. + +Two source modules are exercised here, with expected values derived by hand +(shoelace areas, linear-interpolation identities, convex-hull geometry) rather +than echoed from the functions themselves: + + * quantification.py -- rolling_average() / get_window_points() (the z-trace + smoother and its windowing helper, for all three edge_mode values) and + interpolate_points() on valid multi-point paths. + * grid.py -- reducePoints() (Douglas-Peucker via cv2.approxPolyDP), + getExterior(), mergeTraces(), and cutTraces() on a valid 2-point cut. + * feret.py -- feret() min/max diameters (a pure-math convex-hull routine). + +Grid ops rasterize onto an integer pixel grid, so the few raster-dependent +assertions use loose-but-meaningful geometric bounds (a merged union must be a +single trace whose area lies strictly between the larger input and the sum of +inputs, etc.) rather than exact equality. + +Notes for the reader: + * reducePoints feeds the points straight to cv2.approxPolyDP, which only + accepts CV_32S/CV_32F. Integer point lists work as-is; float coordinates + are only valid via the ``mag`` path (which casts to int32 and back). Plain + float64 with no ``mag`` raises cv2.error and is therefore not a supported + input, so it is not tested here. + * feret() sorts its argument in place (Points.sort()), so it is NOT pure -- + see test_feret_mutates_input_documented and the flag in the report. Every + other feret test passes a throwaway copy. +""" + +import math + +import numpy as np +import pytest + +from PyReconstruct.modules.calc.quantification import ( + rolling_average, + get_window_points, + interpolate_points, + area, + euclidean_distance, +) +from PyReconstruct.modules.calc.grid import ( + reducePoints, + getExterior, + mergeTraces, + cutTraces, +) +from PyReconstruct.modules.calc.feret import feret + + +# Points are [x, y, snum] triples for the smoother (it indexes p[0], p[1]). +def _ramp(n, mx=1.0, my=2.0, snum=0): + """A straight ramp: point i = (i*mx, i*my). Linear in the index.""" + return [(float(i * mx), float(i * my), snum) for i in range(n)] + + +# --------------------------------------------------------------------------- +# get_window_points -- window selection per edge_mode +# --------------------------------------------------------------------------- + +def test_window_padded_clamps_at_left_edge(): + """padded: indices outside the array clamp to the nearest endpoint. + + window=10 -> half=5 -> offsets -5..+5 (11 indices). At idx 0 the five + negative offsets all clamp to point 0, so point[0] appears 6 times, then + points 1..5 follow. + """ + pts = _ramp(11, mx=1.0, my=10.0) + wx, wy = get_window_points(pts, 0, 10, "padded") + assert len(wx) == 11 + assert wx == [0.0] * 6 + [1.0, 2.0, 3.0, 4.0, 5.0] + assert wy == [0.0] * 6 + [10.0, 20.0, 30.0, 40.0, 50.0] + + +def test_window_padded_interior_is_symmetric(): + """An interior index sees the full symmetric window with no clamping.""" + pts = _ramp(11) + wx, _ = get_window_points(pts, 5, 10, "padded") + assert wx == [float(i) for i in range(11)] + + +def test_window_circular_wraps_around(): + """circular: out-of-range offsets wrap modulo len(points).""" + pts = _ramp(11, mx=1.0, my=10.0) + wx, _ = get_window_points(pts, 0, 10, "circular") + # offsets -5..-1 wrap to indices 6,7,8,9,10; then 0..5 + assert len(wx) == 11 + assert wx == [6.0, 7.0, 8.0, 9.0, 10.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0] + + +@pytest.mark.parametrize( + "idx,expected_len", + [(0, 1), (1, 3), (2, 5), (3, 7), (5, 11)], +) +def test_window_shrinking_grows_from_edges(idx, expected_len): + """shrinking: window size is min(1 + 2*dist_from_edge, max_size). + + So it is 1 at an endpoint and grows by 2 each step inward until it + saturates at max_size (here 10 -> capped at the symmetric 11 in the + middle of an 11-point list). + """ + pts = _ramp(11) + wx, _ = get_window_points(pts, idx, 10, "shrinking") + assert len(wx) == expected_len + # window is centered on idx + half = expected_len // 2 + assert wx == [float(i) for i in range(idx - half, idx + half + 1)] + + +# --------------------------------------------------------------------------- +# rolling_average -- smoothing for all three edge modes +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("mode", ["padded", "shrinking", "circular"]) +def test_rolling_average_constant_is_unchanged(mode): + """The mean of a constant window is that constant -- smoothing a constant + sequence must return it unchanged, for every edge mode.""" + const = [(7.0, 3.0, 0)] * 11 + out = rolling_average(const, window=10, edge_mode=mode) + assert out == [(7.0, 3.0)] * 11 + + +@pytest.mark.parametrize("mode", ["padded", "shrinking", "circular"]) +def test_rolling_average_ramp_interior_is_local_mean(mode): + """On a straight ramp, an interior window's mean equals the centre point. + + For window=3 each interior point i averages i-1, i, i+1; on a linear + sequence that mean is exactly point i. This holds identically for all three + edge modes because edge handling only affects the endpoints. We assert on + the strictly-interior indices common to all modes (here index 3 of 7). + """ + ramp = _ramp(7, mx=1.0, my=2.0) # point i = (i, 2i) + out = rolling_average(ramp, window=3, edge_mode=mode) + # interior index 3 -> mean of (2,4),(3,6),(4,8) = (3, 6) = point 3 + assert out[3] == pytest.approx((3.0, 6.0)) + # spot-check another interior index + assert out[4] == pytest.approx((4.0, 8.0)) + + +def test_rolling_average_shrinking_preserves_linear_endpoints(): + """For shrinking mode every window is symmetric about its centre, so a + linear ramp is reproduced exactly end to end (endpoints map to themselves + because their window is a single point).""" + ramp = _ramp(7, mx=1.0, my=2.0) + out = rolling_average(ramp, window=3, edge_mode="shrinking") + expected = [(float(i), float(2 * i)) for i in range(7)] + assert out == [pytest.approx(p) for p in expected] + + +def test_rolling_average_padded_left_edge_value(): + """Hand-computed padded edge value at idx 0, window=3 (half=1). + + Window indices clamp to {0,0,1}: points (0,0),(0,0),(1,2). Mean = + (1/3, 2/3) -> rounded to 4 dp. + """ + ramp = _ramp(7, mx=1.0, my=2.0) + out = rolling_average(ramp, window=3, edge_mode="padded") + assert out[0] == pytest.approx((round(1 / 3, 4), round(2 / 3, 4))) + + +def test_rolling_average_rounds_to_four_dp(): + """Output is rounded to 4 decimal places.""" + # three points whose mean has a long decimal: (0,0),(1,0),(0,0) over a + # window=3 at idx 1 gives x-mean 1/3 = 0.3333... + pts = [(0.0, 0.0, 0), (1.0, 0.0, 0), (0.0, 0.0, 0)] + out = rolling_average(pts, window=3, edge_mode="padded") + # every coordinate must have at most 4 decimal places + for x, y in out: + assert x == round(x, 4) + assert y == round(y, 4) + + +def test_rolling_average_invalid_edge_mode_raises(): + pts = _ramp(5) + with pytest.raises(ValueError): + rolling_average(pts, window=4, edge_mode="bogus") + with pytest.raises(ValueError): + rolling_average(pts, window=4, edge_mode="") + + +# --------------------------------------------------------------------------- +# interpolate_points -- valid multi-point paths +# --------------------------------------------------------------------------- + +def test_interpolate_count_equals_int_total_over_spacing(): + """Point count is int(total_length / spacing).""" + path = [(0.0, 0.0), (10.0, 0.0)] # total length 10 + for spacing, expected in [(1.0, 10), (2.0, 5), (4.0, 2), (3.0, 3)]: + res = interpolate_points(path, spacing=spacing) + assert len(res) == int(10.0 / spacing) == expected + + +def test_interpolate_preserves_endpoints(): + """linspace includes both ends, so first/last points are the path ends.""" + path = [(2.0, -3.0), (12.0, -3.0)] + res = interpolate_points(path, spacing=1.0) + assert res[0] == pytest.approx((2.0, -3.0)) + assert res[-1] == pytest.approx((12.0, -3.0)) + + +def test_interpolate_points_lie_on_straight_segment(): + """All interpolated points of a single straight segment lie on its line. + + Segment from (0,0) to (10,0): every interpolated y must be 0, and x must + be monotonically non-decreasing within [0, 10]. + """ + res = interpolate_points([(0.0, 0.0), (10.0, 0.0)], spacing=1.0) + xs = [p[0] for p in res] + for x, y in res: + assert y == pytest.approx(0.0) + assert 0.0 <= x <= 10.0 + assert xs == sorted(xs) + + +def test_interpolate_multi_segment_lie_on_line(): + """A 3-point path that is itself collinear (slope 4/3) -- every + interpolated point must satisfy y == (4/3) x, endpoints preserved, and the + count matches int(total_length / spacing). + """ + path = [(0.0, 0.0), (3.0, 4.0), (6.0, 8.0)] # all on y = 4/3 x + total = euclidean_distance(path[0], path[1]) + euclidean_distance(path[1], path[2]) + res = interpolate_points(path, spacing=1.0) + assert len(res) == int(total / 1.0) + assert res[0] == pytest.approx((0.0, 0.0)) + assert res[-1] == pytest.approx((6.0, 8.0)) + for x, y in res: + assert y == pytest.approx((4.0 / 3.0) * x, abs=1e-3) + + +def test_interpolate_spacing_is_uniform_arc_length(): + """linspace spaces samples uniformly in arc length across the whole path, + so consecutive Euclidean gaps are equal (a straight segment makes arc + length == Euclidean distance).""" + res = interpolate_points([(0.0, 0.0), (9.0, 0.0)], spacing=1.0) # 9 points + gaps = [res[i + 1][0] - res[i][0] for i in range(len(res) - 1)] + assert all(g == pytest.approx(gaps[0], abs=1e-3) for g in gaps) + + +# --------------------------------------------------------------------------- +# reducePoints -- Douglas-Peucker simplification +# --------------------------------------------------------------------------- + +def test_reduce_points_drops_collinear_midpoints(): + """Extra points lying exactly on the edges of a square are removed, + leaving the four corners; area is preserved exactly (shoelace = 100).""" + square_with_mids = [ + [0, 0], [5, 0], [10, 0], [10, 5], + [10, 10], [5, 10], [0, 10], [0, 5], + ] + reduced = reducePoints(square_with_mids, ep=0.80, closed=True) + assert len(reduced) == 4 + assert set(map(tuple, reduced)) == {(0, 0), (10, 0), (10, 10), (0, 10)} + assert area(reduced) == pytest.approx(100.0) + + +def test_reduce_points_returns_plain_list(): + reduced = reducePoints([[0, 0], [5, 0], [10, 0], [10, 10], [0, 10]], + ep=0.80, closed=True) + assert isinstance(reduced, list) + assert isinstance(reduced[0], list) + + +def test_reduce_points_array_flag_returns_ndarray(): + reduced = reducePoints([[0, 0], [10, 0], [10, 10], [0, 10]], + ep=0.80, closed=True, array=True) + assert isinstance(reduced, np.ndarray) + assert reduced.shape[1] == 2 + + +def test_reduce_points_keeps_real_corner(): + """A genuine, non-collinear vertex (a tall bump) must be kept: the input + pentagon keeps all five vertices and its area (shoelace = 600) is + preserved.""" + pentagon = [[0, 0], [20, 0], [20, 20], [10, 40], [0, 20]] + reduced = reducePoints(pentagon, ep=0.80, closed=True) + assert len(reduced) == 5 + assert area(reduced) == pytest.approx(600.0) + + +def test_reduce_points_triangle_unchanged(): + """A triangle has no redundant points, so it survives intact (area 40).""" + tri = [[0, 0], [10, 0], [5, 8]] + reduced = reducePoints(tri, ep=0.80, closed=True) + assert len(reduced) == 3 + assert area(reduced) == pytest.approx(40.0) + + +def test_reduce_points_mag_path_with_floats(): + """Float coordinates are only valid through the ``mag`` path (which scales + to int32 and back). A magnified square-with-midpoints reduces to its four + float corners with area preserved.""" + square = [ + [0.0, 0.0], [5.0, 0.0], [10.0, 0.0], + [10.0, 10.0], [5.0, 10.0], [0.0, 10.0], + ] + reduced = reducePoints(square, ep=0.80, closed=True, mag=100) + assert len(reduced) == 4 + assert area(reduced) == pytest.approx(100.0) + assert set(map(tuple, reduced)) == {(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)} + + +# --------------------------------------------------------------------------- +# getExterior -- outer contour of a single trace +# --------------------------------------------------------------------------- + +def test_get_exterior_of_square_is_the_four_corners(): + """The exterior of a filled 20x20 square is its four corners (order / + orientation may differ from the input). Area is preserved (400).""" + square = [[0, 0], [20, 0], [20, 20], [0, 20]] + ext = getExterior(square) + assert isinstance(ext, list) + assert len(ext) == 4 + assert set(map(tuple, ext)) == {(0, 0), (20, 0), (20, 20), (0, 20)} + assert area(ext) == pytest.approx(400.0) + + +def test_get_exterior_l_shape_preserves_area(): + """An L-shaped (concave) trace: the exterior bounds the same region, so its + shoelace area matches the original L. Computed by hand below. + + L outline (8 vertices): + (0,0)-(30,0)-(30,10)-(10,10)-(10,30)-(0,30) + Area = full 30x30 (900) minus the missing 20x20 top-right block (400) = 500. + """ + L = [[0, 0], [30, 0], [30, 10], [10, 10], [10, 30], [0, 30]] + assert area(L) == pytest.approx(500.0) # sanity on our hand value + ext = getExterior(L) + # raster + reduction may add/drop a pixel here and there, so allow a small + # tolerance, but the area must clearly be the L (500), not the bounding box. + assert area(ext) == pytest.approx(500.0, abs=20.0) + assert 4 <= len(ext) <= 8 + + +# --------------------------------------------------------------------------- +# mergeTraces -- union of overlapping traces / passthrough of disjoint ones +# --------------------------------------------------------------------------- + +def test_merge_overlapping_squares_into_one(): + """Two overlapping 20x20 squares merge into a single trace whose area is + the union. Exact union = 400 + 400 - 100 (overlap) = 700; the rasterized + result must be a single trace whose area lies strictly between the larger + input (400) and the sum (800), and close to 700. + """ + a = [[0, 0], [20, 0], [20, 20], [0, 20]] + b = [[10, 10], [30, 10], [30, 30], [10, 30]] + merged = mergeTraces([a, b]) + assert len(merged) == 1 + merged_area = area(merged[0]) + assert area(a) < merged_area < area(a) + area(b) # strict union bounds + assert merged_area == pytest.approx(700.0, abs=25.0) + + +def test_merge_disjoint_squares_stay_separate(): + """Two far-apart squares are not merged -- two traces come back, each the + original ~100 area.""" + c = [[0, 0], [10, 0], [10, 10], [0, 10]] + d = [[50, 50], [60, 50], [60, 60], [50, 60]] + merged = mergeTraces([c, d]) + assert len(merged) == 2 + for t in merged: + assert area(t) == pytest.approx(100.0, abs=10.0) + + +def test_merge_single_trace_roundtrips_area(): + """Merging a lone square returns one trace of the same area.""" + sq = [[0, 0], [20, 0], [20, 20], [0, 20]] + merged = mergeTraces([sq]) + assert len(merged) == 1 + assert area(merged[0]) == pytest.approx(400.0, abs=15.0) + + +# --------------------------------------------------------------------------- +# cutTraces -- a valid 2-point cut bisects a square +# --------------------------------------------------------------------------- + +def test_cut_traces_vertical_split(): + """A vertical cut line through the middle of a 10x10 square produces two + pieces. The cut is a thin buffered strip (width ~0.001), so each half is + just under 50 and they are equal.""" + square = [[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]] + result = cutTraces(square, [(5.0, -1.0), (5.0, 11.0)], 0.0, closed=True) + assert len(result) == 2 + areas = sorted(area(t) for t in result) + assert areas[0] == pytest.approx(areas[1], abs=1e-3) # halves are equal + for ar in areas: + assert 45.0 < ar < 50.0 + total = sum(areas) + assert 95.0 < total < 100.0 # a sliver is removed by the cut + + +def test_cut_traces_horizontal_split_equal_halves(): + """Same idea with a horizontal cut -- two equal sub-50 halves.""" + square = [[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]] + result = cutTraces(square, [(-1.0, 5.0), (11.0, 5.0)], 0.0, closed=True) + assert len(result) == 2 + a0, a1 = (area(t) for t in result) + assert a0 == pytest.approx(a1, abs=1e-3) + + +def test_cut_traces_no_intersection_keeps_original(): + """A cut line that misses the square leaves it unchanged.""" + square = [[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]] + result = cutTraces(square, [(100.0, 100.0), (200.0, 200.0)], 0.0, closed=True) + assert result == square + + +# --------------------------------------------------------------------------- +# feret -- min/max caliper diameters (pure convex-hull math) +# --------------------------------------------------------------------------- + +def test_feret_square_min_is_side_max_is_diagonal(): + """For an axis-aligned 10x10 square the min Feret (narrowest caliper + width) is the side length 10 and the max Feret is the diagonal sqrt(200).""" + square = [[0, 0], [10, 0], [10, 10], [0, 10]] + mn, mx = feret([list(p) for p in square]) # pass a copy: feret sorts in place + assert mn == pytest.approx(10.0) + assert mx == pytest.approx(math.hypot(10, 10)) + + +def test_feret_rectangle_diameters(): + """A 6x8 rectangle: min Feret = short side 6, max Feret = diagonal 10.""" + rect = [[0, 0], [8, 0], [8, 6], [0, 6]] + mn, mx = feret([list(p) for p in rect]) + assert mn == pytest.approx(6.0) + assert mx == pytest.approx(10.0) + + +def test_feret_max_is_farthest_pair(): + """The maximum Feret diameter equals the greatest pairwise distance among + the points. We verify against a brute-force all-pairs maximum. + """ + pts = [[0, 0], [10, 1], [3, 9], [-2, 4], [7, -3]] + brute_max = max( + math.dist(p, q) for i, p in enumerate(pts) for q in pts[i + 1:] + ) + _, mx = feret([list(p) for p in pts]) + assert mx == pytest.approx(brute_max) + + +def test_feret_translation_invariant(): + """Feret diameters depend only on shape, not absolute position.""" + square = [[0, 0], [10, 0], [10, 10], [0, 10]] + shifted = [[x + 1000, y - 500] for x, y in square] + mn0, mx0 = feret([list(p) for p in square]) + mn1, mx1 = feret([list(p) for p in shifted]) + assert mn0 == pytest.approx(mn1) + assert mx0 == pytest.approx(mx1) + + +@pytest.mark.parametrize( + "pts", + [ + [], # empty + [[1, 1]], # single point + [[2, 2], [2, 2], [2, 2]], # all coincident -> degenerate hull + ], +) +def test_feret_degenerate_is_zero(pts): + """A degenerate point set (no extent) has Feret diameters (0, 0).""" + assert feret([list(p) for p in pts]) == (0.0, 0.0) + + +def test_feret_mutates_input_documented(): + """Documenting a real wart: feret() calls Points.sort(), reordering the + caller's list in place. This is not desired behavior, just pinned so a + future fix is a visible, intentional change rather than a silent one. + """ + pts = [[10, 10], [0, 0], [10, 0], [0, 10]] + feret(pts) + assert pts == sorted(pts) # input was sorted in place by feret diff --git a/tests/test_section_contour.py b/tests/test_section_contour.py new file mode 100644 index 00000000..24886c25 --- /dev/null +++ b/tests/test_section_contour.py @@ -0,0 +1,548 @@ +"""Pure-data tests for Contour and the headless helpers of Section. + +Contour (modules/datatypes/contour.py) is a thin, name-keyed container of +Trace objects with a handful of container dunders, bounds aggregation, and a +duplicate-merging importer. None of it needs Qt or a Series, so we exercise it +end-to-end with hand-built traces and hand-derived expectations. + +Section (modules/datatypes/section.py) cannot be *constructed* without real +series files: ``Section.__init__`` joins ``series.getwdir()`` with +``series.sections[n]`` and immediately reads/parses that file from disk. So we +only cover its genuinely pure pieces: + + * ``getEmptyDict`` / ``updateJSON`` -- static, dict-in/dict-out (or in-place). + * ``addTrace`` / ``removeTrace`` / ``tracesAsList`` -- instance methods whose + logic touches only ``self.contours`` and the tracking lists. We drive them + on a bare ``Section.__new__`` instance with ``log_event=False`` (the only + branch that reaches ``self.series`` is the log call), which is the smallest + faithful way to test the real method bodies without I/O. + +Expected values below are derived by hand, not echoed from the code. See the +module-level ``flags`` note returned by the harness for Section limitations. +""" +import math + +import pytest + +from PyReconstruct.modules.datatypes.contour import Contour +from PyReconstruct.modules.datatypes.trace import Trace +from PyReconstruct.modules.datatypes.section import Section +from PyReconstruct.modules.datatypes.transform import Transform + + +# --------------------------------------------------------------------------- # +# helpers +# --------------------------------------------------------------------------- # +def mk(name, points, color=(0, 0, 0), closed=True): + """Build a Trace with explicit points (bypassing the GUI add path).""" + t = Trace(name, color, closed) + t.points = list(points) + return t + + +# A unit-ish square used in several import tests. +SQUARE = [(0, 0), (10, 0), (10, 10), (0, 10)] + + +# --------------------------------------------------------------------------- # +# Contour construction +# --------------------------------------------------------------------------- # +def test_empty_contour_construction(): + c = Contour("axon") + assert c.name == "axon" + assert c.getTraces() == [] + assert len(c) == 0 + assert c.isEmpty() is True + + +def test_construction_with_matching_traces(): + traces = [mk("axon", [(0, 0), (1, 0), (1, 1)]), + mk("axon", [(5, 5), (6, 5), (6, 6)])] + c = Contour("axon", traces) + assert len(c) == 2 + assert c.isEmpty() is False + # The stored list is the very list passed in (no defensive copy in ctor). + assert c.getTraces() == traces + + +def test_construction_name_mismatch_raises(): + with pytest.raises(Exception): + Contour("axon", [mk("dendrite", [(0, 0), (1, 1)])]) + + +def test_construction_with_empty_list_is_empty(): + # falsy traces arg -> fresh empty list + c = Contour("axon", []) + assert len(c) == 0 + assert c.isEmpty() is True + + +# --------------------------------------------------------------------------- # +# append / remove / index +# --------------------------------------------------------------------------- # +def test_append_matching_name(): + c = Contour("axon") + t = mk("axon", [(0, 0), (1, 1)]) + c.append(t) + assert len(c) == 1 + assert c[0] is t + assert c.isEmpty() is False + + +def test_append_name_mismatch_raises(): + c = Contour("axon") + with pytest.raises(Exception): + c.append(mk("dendrite", [(0, 0), (1, 1)])) + + +def test_append_uses_normalized_trace_name(): + # Trace normalizes "a b" -> "a_b"; the contour must carry the same name. + c = Contour("a_b") + t = mk("a b", [(0, 0), (1, 1)]) # Trace() turns this into "a_b" + assert t.name == "a_b" + c.append(t) # must not raise + assert len(c) == 1 + + +def test_remove_trace(): + t1 = mk("axon", [(0, 0), (1, 1)]) + t2 = mk("axon", [(2, 2), (3, 3)]) + c = Contour("axon", [t1, t2]) + c.remove(t1) + assert len(c) == 1 + assert c[0] is t2 + + +def test_index_returns_position(): + t1 = mk("axon", [(0, 0), (1, 1)]) + t2 = mk("axon", [(2, 2), (3, 3)]) + t3 = mk("axon", [(4, 4), (5, 5)]) + c = Contour("axon", [t1, t2, t3]) + assert c.index(t1) == 0 + assert c.index(t2) == 1 + assert c.index(t3) == 2 + + +# --------------------------------------------------------------------------- # +# container protocol: __iter__ / __len__ / __getitem__ +# --------------------------------------------------------------------------- # +def test_iteration_yields_traces_in_order(): + traces = [mk("axon", [(i, i), (i + 1, i + 1)]) for i in range(3)] + c = Contour("axon", traces) + assert list(iter(c)) == traces + # iterating again restarts from the beginning (fresh iterator each time) + assert [t for t in c] == traces + + +def test_len_matches_trace_count(): + c = Contour("axon") + assert len(c) == 0 + c.append(mk("axon", [(0, 0), (1, 1)])) + assert len(c) == 1 + c.append(mk("axon", [(2, 2), (3, 3)])) + assert len(c) == 2 + + +def test_getitem_index_and_slice(): + traces = [mk("axon", [(i, i), (i + 1, i + 1)]) for i in range(4)] + c = Contour("axon", traces) + assert c[0] is traces[0] + assert c[-1] is traces[-1] + # slicing delegates to list slicing and returns a plain list + sl = c[1:3] + assert sl == traces[1:3] + assert isinstance(sl, list) + + +# --------------------------------------------------------------------------- # +# getTraces / copy +# --------------------------------------------------------------------------- # +def test_get_traces_returns_independent_list(): + t = mk("axon", [(0, 0), (1, 1)]) + c = Contour("axon", [t]) + g = c.getTraces() + assert g[0] is t # same trace objects (shallow copy) + g.append("junk") # mutating the returned list ... + assert len(c) == 1 # ... must not touch the contour + + +def test_copy_produces_distinct_traces_with_equal_points(): + orig = Contour("axon", [mk("axon", [(0, 0), (2, 0), (2, 2)])]) + cp = orig.copy() + assert cp.name == "axon" + assert len(cp) == 1 + assert cp[0] is not orig[0] # trace objects are copied + assert cp[0].points == orig[0].points # but values are equal + # mutating the copy's points does not affect the original + cp[0].points.append((9, 9)) + assert orig[0].points == [(0, 0), (2, 0), (2, 2)] + + +# --------------------------------------------------------------------------- # +# getBounds / getMidpoint +# --------------------------------------------------------------------------- # +def test_get_bounds_single_trace(): + c = Contour("axon", [mk("axon", [(0, 0), (4, 0), (4, 3), (0, 3)])]) + assert c.getBounds() == (0, 0, 4, 3) + + +def test_get_bounds_spans_multiple_traces(): + c = Contour("axon") + c.append(mk("axon", [(0, 0), (2, 0), (2, 2), (0, 2)])) + c.append(mk("axon", [(10, 10), (11, 10), (11, 12), (10, 12)])) + # xmin/ymin from the first square, xmax/ymax from the second + assert c.getBounds() == (0, 0, 11, 12) + + +def test_get_bounds_with_negative_coords(): + c = Contour("axon", [mk("axon", [(-5, -2), (3, -2), (3, 7), (-5, 7)])]) + assert c.getBounds() == (-5, -2, 3, 7) + + +def test_get_bounds_applies_translation_transform(): + # pure translation: x += 5, y -= 3 (tform list = [a,b,tx,c,d,ty]) + tform = Transform([1, 0, 5, 0, 1, -3]) + c = Contour("axon", [mk("axon", [(0, 0), (2, 0), (2, 2), (0, 2)])]) + xmin, ymin, xmax, ymax = c.getBounds(tform) + assert xmin == pytest.approx(5.0) + assert ymin == pytest.approx(-3.0) + assert xmax == pytest.approx(7.0) + assert ymax == pytest.approx(-1.0) + + +def test_get_midpoint_is_average_of_extremes(): + c = Contour("axon") + c.append(mk("axon", [(0, 0), (2, 0), (2, 2), (0, 2)])) + c.append(mk("axon", [(10, 10), (11, 10), (11, 12), (10, 12)])) + # bounds (0,0,11,12) -> midpoint ((0+11)/2, (0+12)/2) + mx, my = c.getMidpoint() + assert mx == pytest.approx(5.5) + assert my == pytest.approx(6.0) + + +def test_get_bounds_empty_contour_raises(): + # min()/max() over empty extreme lists -> ValueError + with pytest.raises(ValueError): + Contour("empty").getBounds() + + +# --------------------------------------------------------------------------- # +# __add__ +# --------------------------------------------------------------------------- # +def test_add_concatenates_traces_into_new_contour(): + a = Contour("z", [mk("z", [(0, 0), (1, 1)], closed=False)]) + b = Contour("z", [mk("z", [(2, 2), (3, 3)], closed=False)]) + c = a + b + assert c is not a and c is not b # a fresh contour + assert len(c) == 2 + assert c.name == "z" + # operands are untouched + assert len(a) == 1 and len(b) == 1 + # order preserved: self's traces then other's + assert c[0] is a[0] + assert c[1] is b[0] + + +def test_add_name_mismatch_raises(): + with pytest.raises(Exception): + Contour("a") + Contour("b") + + +# --------------------------------------------------------------------------- # +# importTraces (pure-data merge logic) +# --------------------------------------------------------------------------- # +def test_import_identical_leading_traces_keep_self_merges_tags(): + s = Contour("o", [mk("o", SQUARE)]) + other_t = mk("o", SQUARE) + other_t.addTag("from_other") + other = Contour("o", [other_t]) + + rem_s, rem_o = s.importTraces(other, threshold=0.95, keep_above="self") + + # the two leading traces overlap exactly -> treated as a duplicate + assert len(s) == 1 + assert s[0] is not other_t # kept "self" trace + assert "from_other" in s[0].tags # but absorbed other's tag + # an exact-duplicate match leaves no leftover conflict pool + assert rem_s == [] + assert rem_o == [] + + +def test_import_identical_keep_other_uses_other_trace(): + self_t = mk("o", SQUARE) + self_t.addTag("from_self") + s = Contour("o", [self_t]) + other = Contour("o", [mk("o", SQUARE)]) + + s.importTraces(other, threshold=0.95, keep_above="other") + + assert len(s) == 1 + assert s[0] is not self_t # now holds "other"'s trace + assert "from_self" in s[0].tags # with self's tag merged in + + +def test_import_identical_keep_blank_retains_both(): + s = Contour("o", [mk("o", SQUARE)]) + other = Contour("o", [mk("o", SQUARE)]) + s.importTraces(other, threshold=0.95, keep_above="") + assert len(s) == 2 + + +def test_import_disjoint_traces_are_all_retained_as_conflicts(): + s = Contour("o", [mk("o", [(0, 0), (1, 0), (1, 1), (0, 1)])]) + other = Contour("o", [mk("o", [(100, 100), (101, 100), (101, 101), (100, 101)])]) + + rem_s, rem_o = s.importTraces(other, threshold=0.95, keep_above="self") + + # nothing overlaps, so both survive and both are reported as conflicts + assert len(s) == 2 + assert len(rem_s) == 1 + assert len(rem_o) == 1 + + +def test_import_into_empty_self_takes_all_other_traces(): + s = Contour("o", []) + other = Contour("o", [mk("o", SQUARE), mk("o", [(1, 1), (2, 2)], closed=False)]) + rem_s, rem_o = s.importTraces(other, threshold=0.95, keep_above="self") + # self had nothing, so every other trace ends up in self + assert len(s) == 2 + assert rem_s == [] # self contributed no leftover traces + assert len(rem_o) == 2 # all of other's traces are "remaining" + + +def test_import_invalid_keep_above_raises_when_duplicate_found(): + # The bad-key check only fires inside addDuplicate, i.e. when an actual + # overlapping pair is encountered. + s = Contour("o", [mk("o", SQUARE)]) + other = Contour("o", [mk("o", SQUARE)]) + with pytest.raises(Exception): + s.importTraces(other, threshold=0.95, keep_above="bogus") + + +# --------------------------------------------------------------------------- # +# Section.getEmptyDict (pure static) +# --------------------------------------------------------------------------- # +def test_get_empty_dict_keys_and_values(): + d = Section.getEmptyDict() + assert set(d.keys()) == { + "src", "brightness_contrast_profiles", "mag", "align_locked", + "thickness", "tforms", "contours", "flags", "calgrid", + } + assert d["src"] == "" + assert d["brightness_contrast_profiles"] == {"default": (0, 0)} + assert d["mag"] == pytest.approx(0.00254) + assert d["align_locked"] is True + assert d["thickness"] == pytest.approx(0.05) + assert d["contours"] == {} + assert d["flags"] == [] + assert d["calgrid"] is False + # the only tform is an identity "default" + assert set(d["tforms"].keys()) == {"default"} + assert d["tforms"]["default"] == [1, 0, 0, 0, 1, 0] + + +def test_get_empty_dict_returns_independent_copies(): + a = Section.getEmptyDict() + b = Section.getEmptyDict() + a["contours"]["x"] = 1 + a["tforms"]["extra"] = [0, 0, 0, 0, 0, 0] + assert "x" not in b["contours"] + assert "extra" not in b["tforms"] + + +# --------------------------------------------------------------------------- # +# Section.updateJSON (pure static, in-place) +# --------------------------------------------------------------------------- # +def test_update_json_adds_missing_keys(): + d = {"contours": {}, "tforms": {}, "flags": []} + Section.updateJSON(d, 1) + # every empty-dict key should now be present + for key in Section.getEmptyDict(): + assert key in d + assert d["mag"] == pytest.approx(0.00254) + assert d["calgrid"] is False + + +def test_update_json_converts_dict_trace_to_list(): + trace_dict = { + "x": [0, 1, 1, 0], "y": [0, 0, 1, 1], + "color": [255, 0, 0], "closed": True, "negative": False, + "hidden": False, "mode": ["none", "none"], "tags": [], + } + d = {"contours": {"c": [trace_dict]}, "tforms": {}, "flags": []} + Section.updateJSON(d, 1) + tr = d["contours"]["c"][0] + assert isinstance(tr, list) + # field order defined by updateJSON: x, y, color, closed, negative, hidden, mode, tags + assert tr[0] == [0, 1, 1, 0] + assert tr[1] == [0, 0, 1, 1] + assert tr[2] == [255, 0, 0] + assert tr[3] is True + assert tr[6] == ["none", "none"] + + +def test_update_json_drops_defective_trace_and_empty_contour(): + good = [[0, 1, 1, 0], [0, 0, 1, 1], [255, 0, 0], True, False, False, + ["none", "none"], []] + bad = [[0], [0], [0, 0, 0], True, False, False, ["none", "none"], []] + d = {"contours": {"good": [good], "bad": [bad]}, "tforms": {}, "flags": []} + Section.updateJSON(d, 1) + # the single-point trace was defective -> its only trace removed -> contour gone + assert "bad" not in d["contours"] + assert "good" in d["contours"] + assert len(d["contours"]["good"]) == 1 + + +def test_update_json_pops_history_and_normalizes_mode(): + # a length-9 trace carries a trailing history element; mode field is non-list + nine = [[0, 1, 1, 0], [0, 0, 1, 1], [255, 0, 0], True, False, False, + "NOT_A_LIST", [], "HISTORY"] + d = {"contours": {"c": [nine]}, "tforms": {}, "flags": []} + Section.updateJSON(d, 1) + tr = d["contours"]["c"][0] + assert len(tr) == 8 # history popped + assert tr[6] == ["none", "none"] # non-list mode normalized + + +def test_update_json_brightness_contrast_migration(): + # |brightness| > 100 is clamped to 0; contrast is int()'d; both move to profiles + d = {"contours": {}, "tforms": {}, "flags": [], + "brightness": 150, "contrast": 7.9} + Section.updateJSON(d, 1) + assert d["brightness"] == 0 + assert d["brightness_contrast_profiles"] == {"default": (0, 7)} + + +def test_update_json_brightness_within_range_preserved(): + d = {"contours": {}, "tforms": {}, "flags": [], + "brightness": -40, "contrast": 12.0} + Section.updateJSON(d, 1) + assert d["brightness"] == -40 + assert d["brightness_contrast_profiles"] == {"default": (-40, 12)} + + +def test_update_json_removes_no_alignment_tform(): + d = {"contours": {}, "flags": [], + "tforms": {"no-alignment": [1, 0, 0, 0, 1, 0], + "default": [1, 0, 0, 0, 1, 0]}} + Section.updateJSON(d, 1) + assert "no-alignment" not in d["tforms"] + assert "default" in d["tforms"] + + +def test_update_json_merges_whitespace_named_contours(): + a = [[0, 1], [0, 1], [0, 0, 0], False, False, False, ["none", "none"], []] + b = [[2, 3], [2, 3], [0, 0, 0], False, False, False, ["none", "none"], []] + d = {"contours": {"a b": [a], "a_b": [b]}, "tforms": {}, "flags": []} + Section.updateJSON(d, 1) + # "a b" normalizes to "a_b" and is merged into the existing "a_b" + assert set(d["contours"].keys()) == {"a_b"} + assert len(d["contours"]["a_b"]) == 2 + + +def test_update_json_flag_gets_id_and_resolved_status(): + # legacy 5-field flag: name, x, y, snum, color + d = {"contours": {}, "tforms": {}, + "flags": [["flagname", 1.0, 2.0, 3, [255, 0, 0]]]} + Section.updateJSON(d, 1) + flag = d["flags"][0] + # +False (resolved) then +generated id at front -> 7 fields + assert len(flag) == 7 + assert flag[-1] is False # appended resolved=False + # the original payload is preserved, just shifted right by the inserted id + assert flag[1:6] == ["flagname", 1.0, 2.0, 3, [255, 0, 0]] + + +# --------------------------------------------------------------------------- # +# Section.addTrace / removeTrace / tracesAsList +# (driven on a bare instance; log_event=False so self.series is never touched) +# --------------------------------------------------------------------------- # +def make_bare_section(n=1): + """A Section with just the attributes addTrace/removeTrace/tracesAsList use.""" + s = Section.__new__(Section) + s.n = n + s.contours = {} + s.added_traces = [] + s.removed_traces = [] + return s + + +def test_add_trace_creates_contour_and_tracks(): + s = make_bare_section() + t = mk("foo", SQUARE) + s.addTrace(t, log_event=False) + assert "foo" in s.contours + assert len(s.contours["foo"]) == 1 + assert s.contours["foo"][0] is t + assert s.added_traces == [t] + + +def test_add_trace_appends_to_existing_contour(): + s = make_bare_section() + t1 = mk("foo", SQUARE) + t2 = mk("foo", [(5, 5), (6, 5), (6, 6), (5, 6)]) + s.addTrace(t1, log_event=False) + s.addTrace(t2, log_event=False) + assert len(s.contours["foo"]) == 2 + assert len(s.added_traces) == 2 + + +def test_add_trace_ignores_traces_with_fewer_than_two_points(): + s = make_bare_section() + s.addTrace(mk("dust", [(0, 0)]), log_event=False) + assert "dust" not in s.contours + assert s.added_traces == [] + + +def test_add_trace_forces_two_point_trace_open(): + s = make_bare_section() + t = mk("line", [(0, 0), (1, 1)], closed=True) # asserts closed gets flipped + s.addTrace(t, log_event=False) + assert s.contours["line"][0].closed is False + + +def test_traces_as_list_order_and_no_copy(): + s = make_bare_section() + a1 = mk("aaa", SQUARE) + a2 = mk("aaa", [(5, 5), (6, 5), (6, 6), (5, 6)]) + b1 = mk("bbb", [(20, 20), (21, 20), (21, 21), (20, 21)]) + s.addTrace(a1, log_event=False) + s.addTrace(a2, log_event=False) + s.addTrace(b1, log_event=False) + tl = s.tracesAsList() + # grouped by contour (insertion order of dict), traces in append order + assert tl == [a1, a2, b1] + assert tl[0] is a1 # genuinely the same objects (docstring: no copy) + + +def test_remove_trace_updates_contour_and_tracking(): + s = make_bare_section() + t1 = mk("foo", SQUARE) + t2 = mk("foo", [(5, 5), (6, 5), (6, 6), (5, 6)]) + s.addTrace(t1, log_event=False) + s.addTrace(t2, log_event=False) + s.removeTrace(t1, log_event=False) + assert len(s.contours["foo"]) == 1 + assert s.contours["foo"][0] is t2 + assert s.removed_traces == [t1] + + +def test_remove_trace_unknown_name_is_noop(): + s = make_bare_section() + s.addTrace(mk("foo", SQUARE), log_event=False) + # removing a trace whose name has no contour should not raise or track + s.removeTrace(mk("ghost", SQUARE), log_event=False) + assert s.removed_traces == [] + assert len(s.contours["foo"]) == 1 + + +def test_add_then_remove_roundtrip_leaves_contour_present_but_empty(): + s = make_bare_section() + t = mk("foo", SQUARE) + s.addTrace(t, log_event=False) + s.removeTrace(t, log_event=False) + # removeTrace empties the contour but does not delete the (now-empty) key + assert "foo" in s.contours + assert len(s.contours["foo"]) == 0 + assert s.added_traces == [t] + assert s.removed_traces == [t] diff --git a/tests/test_trace_datatype.py b/tests/test_trace_datatype.py new file mode 100644 index 00000000..9ec731d8 --- /dev/null +++ b/tests/test_trace_datatype.py @@ -0,0 +1,605 @@ +"""Unit tests for the pure-data methods of datatypes.trace.Trace. + +These cover construction and attribute defaults, the getList()/fromList() +serialization round-trip (both include_name variants), copy() independence, +the bounds/midpoint geometry (with and without an affine Transform), tag +mutation, the boolean flag setters, magScale(), getStretched(), and the +isSameTrace()/overlaps() comparisons. Expected geometry is derived by hand +from small known shapes (axis-aligned squares/rectangles) rather than echoed +back from the methods under test. + +Anything requiring a live QApplication or a real Series is intentionally +skipped; only headless geometry/attribute behavior is exercised. +""" +import pytest + +from PyReconstruct.modules.datatypes.trace import Trace +from PyReconstruct.modules.datatypes.transform import Transform + + +# A unit-ish square, counter-clockwise, anchored at the origin. +SQUARE = [(0, 0), (10, 0), (10, 10), (0, 10)] +# A 4-wide, 2-tall rectangle offset from the origin. +RECT = [(2, 3), (6, 3), (6, 5), (2, 5)] + + +def make_square_trace(name="square", color=(255, 0, 0), closed=True): + t = Trace(name, color, closed=closed) + t.points = list(SQUARE) + return t + + +# --------------------------------------------------------------------------- +# Construction / defaults +# --------------------------------------------------------------------------- + +def test_constructor_defaults(): + t = Trace("axon", (1, 2, 3)) + assert t.name == "axon" + assert t.color == (1, 2, 3) + assert t.closed is True # default + assert t.negative is False + assert t.hidden is False + assert t.points == [] + assert t.tags == set() + assert t.fill_mode == ("none", "none") + + +def test_constructor_closed_false(): + t = Trace("dendrite", (0, 0, 0), closed=False) + assert t.closed is False + + +def test_name_setter_normalizes_whitespace_and_commas(): + # leading/trailing whitespace stripped; internal spaces and commas -> "_" + t = Trace(" my trace, v2 ", (0, 0, 0)) + assert t.name == "my_trace__v2" + + +def test_name_setter_collapses_multiple_spaces(): + # "_".join(value.split()) collapses runs of whitespace to a single "_" + t = Trace("a b\tc", (0, 0, 0)) + assert t.name == "a_b_c" + + +def test_name_setter_accepts_none(): + t = Trace("temp", (0, 0, 0)) + t.name = None + assert t.name is None + + +def test_name_setter_rejects_non_string(): + with pytest.raises(AssertionError): + Trace(123, (0, 0, 0)) + + +# --------------------------------------------------------------------------- +# add() +# --------------------------------------------------------------------------- + +def test_add_appends_point(): + t = Trace("t", (0, 0, 0)) + t.add((1, 2)) + t.add((3, 4)) + assert t.points == [(1, 2), (3, 4)] + + +# --------------------------------------------------------------------------- +# getList() shape and contents +# --------------------------------------------------------------------------- + +def test_getlist_with_name_has_nine_elements(): + t = make_square_trace() + t.negative = True + t.hidden = True + t.fill_mode = ("solid", "selected") + t.tags = {"tagA"} + l = t.getList(include_name=True) + assert len(l) == 9 + name, x, y, color, closed, negative, hidden, fill_mode, tags = l + assert name == "square" + assert x == [0, 10, 10, 0] + assert y == [0, 0, 10, 10] + assert color == (255, 0, 0) + assert closed is True + assert negative is True + assert hidden is True + assert fill_mode == ("solid", "selected") + assert sorted(tags) == ["tagA"] + + +def test_getlist_without_name_has_eight_elements(): + t = make_square_trace() + l = t.getList(include_name=False) + assert len(l) == 8 + # first element is the x-list, not a name + assert l[0] == [0, 10, 10, 0] + + +def test_getlist_rounds_coordinates_to_seven_decimals(): + t = Trace("t", (0, 0, 0)) + # more than 7 decimal places of precision should be rounded + t.points = [(1.123456789, 2.987654321)] + l = t.getList(include_name=False) + x_list, y_list = l[0], l[1] + assert x_list == [round(1.123456789, 7)] + assert y_list == [round(2.987654321, 7)] + assert x_list == [1.1234568] + assert y_list == [2.9876543] + + +# --------------------------------------------------------------------------- +# getList() / fromList() round-trip +# --------------------------------------------------------------------------- + +def test_round_trip_with_name(): + orig = make_square_trace(name="myelin", color=(10, 20, 30), closed=True) + orig.negative = True + orig.hidden = False + orig.fill_mode = ("transparent", "unselected") + orig.tags = {"x", "y"} + + # include_name=True -> 9-element list, fromList pops the name off the front + rebuilt = Trace.fromList(orig.getList(include_name=True)) + + assert rebuilt.name == "myelin" + assert rebuilt.color == (10, 20, 30) + assert rebuilt.closed is True + assert rebuilt.negative is True + assert rebuilt.hidden is False + assert rebuilt.fill_mode == ("transparent", "unselected") + assert rebuilt.tags == {"x", "y"} + # points come back as (x, y) tuples in the same order + assert rebuilt.points == [(0, 0), (10, 0), (10, 10), (0, 10)] + + +def test_round_trip_without_name_supplies_name(): + orig = make_square_trace(name="ignored", color=(5, 5, 5)) + # 8-element list (no name embedded); fromList must use the supplied name + rebuilt = Trace.fromList(orig.getList(include_name=False), name="given") + assert rebuilt.name == "given" + assert rebuilt.points == [(0, 0), (10, 0), (10, 10), (0, 10)] + assert rebuilt.color == (5, 5, 5) + + +def test_round_trip_idempotent_second_pass(): + # getList(fromList(getList(t))) should be stable + orig = make_square_trace(name="obj", color=(7, 8, 9)) + orig.tags = {"only"} + first = orig.getList(include_name=True) + rebuilt = Trace.fromList(list(first)) # copy: fromList mutates via pop + second = rebuilt.getList(include_name=True) + assert first == second + + +def test_fromlist_points_are_tuples(): + orig = make_square_trace() + rebuilt = Trace.fromList(orig.getList(include_name=True)) + assert all(isinstance(p, tuple) for p in rebuilt.points) + + +def test_fromlist_strips_name_whitespace(): + t = Trace("t", (0, 0, 0)) + t.points = [(0, 0)] + l = t.getList(include_name=False) + rebuilt = Trace.fromList(l, name=" spaced ") + # fromList calls name.strip() AND the name setter normalizes + assert rebuilt.name == "spaced" + + +# --------------------------------------------------------------------------- +# copy() +# --------------------------------------------------------------------------- + +def test_copy_equal_but_distinct(): + orig = make_square_trace(name="orig", color=(1, 2, 3)) + orig.tags = {"a", "b"} + c = orig.copy() + assert c is not orig + assert c.name == orig.name + assert c.color == orig.color + assert c.closed == orig.closed + assert c.points == orig.points + assert c.tags == orig.tags + + +def test_copy_points_list_is_independent(): + orig = make_square_trace() + c = orig.copy() + # the points lists are separate objects + assert c.points is not orig.points + c.points.append((99, 99)) + assert (99, 99) not in orig.points + assert len(orig.points) == 4 + assert len(c.points) == 5 + + +def test_copy_points_removal_does_not_affect_original(): + orig = make_square_trace() + c = orig.copy() + c.points.pop() + assert len(orig.points) == 4 + assert len(c.points) == 3 + + +def test_copy_tags_set_is_independent(): + orig = make_square_trace() + orig.tags = {"keep"} + c = orig.copy() + assert c.tags is not orig.tags + c.addTag("extra") + assert "extra" in c.tags + assert "extra" not in orig.tags + + +def test_copy_scalar_flag_reassignment_is_independent(): + orig = make_square_trace() + orig.hidden = False + c = orig.copy() + c.setHidden(True) + # reassigning an attribute on the copy must not bleed back + assert c.hidden is True + assert orig.hidden is False + + +# --------------------------------------------------------------------------- +# getBounds() (hand-derived expected values) +# --------------------------------------------------------------------------- + +def test_bounds_square(): + t = make_square_trace() + assert t.getBounds() == (0, 0, 10, 10) + + +def test_bounds_offset_rectangle(): + t = Trace("r", (0, 0, 0)) + t.points = list(RECT) # x in [2,6], y in [3,5] + assert t.getBounds() == (2, 3, 6, 5) + + +def test_bounds_negative_coordinates(): + t = Trace("n", (0, 0, 0)) + t.points = [(-5, -2), (3, -2), (3, 4), (-5, 4)] + assert t.getBounds() == (-5, -2, 3, 4) + + +def test_bounds_single_point(): + t = Trace("p", (0, 0, 0)) + t.points = [(7, -3)] + assert t.getBounds() == (7, -3, 7, -3) + + +def test_bounds_unsorted_points(): + # points given out of any nice order; min/max must still be exact + t = Trace("u", (0, 0, 0)) + t.points = [(3, 9), (-1, 2), (8, -4), (0, 0)] + assert t.getBounds() == (-1, -4, 8, 9) + + +def test_bounds_identity_transform_unchanged(): + t = make_square_trace() + ident = Transform([1, 0, 0, 0, 1, 0]) + untransformed = t.getBounds() + transformed = t.getBounds(ident) + assert transformed[0] == pytest.approx(untransformed[0]) + assert transformed[1] == pytest.approx(untransformed[1]) + assert transformed[2] == pytest.approx(untransformed[2]) + assert transformed[3] == pytest.approx(untransformed[3]) + + +def test_bounds_translation_transform(): + # tform_list = [m11, m12, dx, m21, m22, dy] per Transform.getQTransform: + # QTransform(t0, t3, t1, t4, t2, t5) -> nx = t0*x + t1*y + t2 + # so a pure translation by (+5, -3) is [1,0,5, 0,1,-3]. + t = make_square_trace() # bounds (0,0,10,10) + tform = Transform([1, 0, 5, 0, 1, -3]) + xmin, ymin, xmax, ymax = t.getBounds(tform) + assert xmin == pytest.approx(5) + assert ymin == pytest.approx(-3) + assert xmax == pytest.approx(15) + assert ymax == pytest.approx(7) + + +def test_bounds_scale_transform(): + # scale x by 2, y by 0.5: [2,0,0, 0,0.5,0] + t = make_square_trace() # bounds (0,0,10,10) + tform = Transform([2, 0, 0, 0, 0.5, 0]) + xmin, ymin, xmax, ymax = t.getBounds(tform) + assert xmin == pytest.approx(0) + assert ymin == pytest.approx(0) + assert xmax == pytest.approx(20) + assert ymax == pytest.approx(5) + + +def test_bounds_does_not_mutate_points(): + t = make_square_trace() + before = list(t.points) + t.getBounds() + t.getBounds(Transform([1, 0, 0, 0, 1, 0])) + assert t.points == before + + +# --------------------------------------------------------------------------- +# getMidpoint() (avg of bounds extremes) +# --------------------------------------------------------------------------- + +def test_midpoint_square(): + t = make_square_trace() + mx, my = t.getMidpoint() + assert mx == pytest.approx(5.0) + assert my == pytest.approx(5.0) + + +def test_midpoint_offset_rectangle(): + t = Trace("r", (0, 0, 0)) + t.points = list(RECT) # bounds (2,3,6,5) + mx, my = t.getMidpoint() + assert mx == pytest.approx(4.0) # (2+6)/2 + assert my == pytest.approx(4.0) # (3+5)/2 + + +def test_midpoint_with_translation_transform(): + t = make_square_trace() # midpoint (5,5) untransformed + tform = Transform([1, 0, 5, 0, 1, -3]) + mx, my = t.getMidpoint(tform) + assert mx == pytest.approx(10.0) # 5 + 5 + assert my == pytest.approx(2.0) # 5 - 3 + + +def test_midpoint_matches_bounds_definition(): + # independent re-derivation from the public bounds + t = Trace("u", (0, 0, 0)) + t.points = [(3, 9), (-1, 2), (8, -4), (0, 0)] + xmin, ymin, xmax, ymax = t.getBounds() + mx, my = t.getMidpoint() + assert mx == pytest.approx((xmin + xmax) / 2) + assert my == pytest.approx((ymin + ymax) / 2) + + +# --------------------------------------------------------------------------- +# Tags: addTag / mergeTags +# --------------------------------------------------------------------------- + +def test_add_tag(): + t = Trace("t", (0, 0, 0)) + t.addTag("foo") + t.addTag("bar") + t.addTag("foo") # duplicate is a no-op (set semantics) + assert t.tags == {"foo", "bar"} + + +def test_merge_tags_union(): + a = Trace("a", (0, 0, 0)) + a.tags = {"x", "y"} + b = Trace("b", (0, 0, 0)) + b.tags = {"y", "z"} + a.mergeTags(b) + assert a.tags == {"x", "y", "z"} + # the other trace is left untouched + assert b.tags == {"y", "z"} + + +def test_merge_tags_with_empty(): + a = Trace("a", (0, 0, 0)) + a.tags = {"only"} + b = Trace("b", (0, 0, 0)) + a.mergeTags(b) + assert a.tags == {"only"} + + +# --------------------------------------------------------------------------- +# Boolean flag setters / direct attributes +# --------------------------------------------------------------------------- + +def test_set_hidden_default_true(): + t = Trace("t", (0, 0, 0)) + assert t.hidden is False + t.setHidden() + assert t.hidden is True + + +def test_set_hidden_explicit_false(): + t = Trace("t", (0, 0, 0)) + t.setHidden(True) + t.setHidden(False) + assert t.hidden is False + + +def test_closed_and_negative_flags_assignable(): + t = Trace("t", (0, 0, 0)) + t.closed = False + t.negative = True + assert t.closed is False + assert t.negative is True + + +# --------------------------------------------------------------------------- +# magScale() +# --------------------------------------------------------------------------- + +def test_mag_scale_doubles_coordinates(): + # new_mag/prev_mag = 2.0 -> every coordinate doubles + t = make_square_trace() + t.magScale(prev_mag=1.0, new_mag=2.0) + assert t.points == [(0.0, 0.0), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0)] + + +def test_mag_scale_identity_ratio(): + t = Trace("t", (0, 0, 0)) + t.points = [(1.5, -2.5), (3.0, 4.0)] + t.magScale(prev_mag=0.7, new_mag=0.7) + for (x, y), (ox, oy) in zip(t.points, [(1.5, -2.5), (3.0, 4.0)]): + assert x == pytest.approx(ox) + assert y == pytest.approx(oy) + + +def test_mag_scale_halves_coordinates(): + t = Trace("t", (0, 0, 0)) + t.points = [(4.0, 8.0), (-2.0, 6.0)] + t.magScale(prev_mag=2.0, new_mag=1.0) # ratio 0.5 + assert t.points[0] == pytest.approx((2.0, 4.0)) + assert t.points[1] == pytest.approx((-1.0, 3.0)) + + +def test_mag_scale_scales_bounds_consistently(): + # bounds should scale by the same factor as the points + t = make_square_trace() # bounds (0,0,10,10) + t.magScale(prev_mag=2.0, new_mag=6.0) # ratio 3 + assert t.getBounds() == pytest.approx((0, 0, 30, 30)) + + +# --------------------------------------------------------------------------- +# getStretched() (scaling about the polygon centroid) +# --------------------------------------------------------------------------- + +def test_get_stretched_square_to_wide_rectangle(): + # square centroid is (5,5); bounds span 10x10. + # stretch to w=20, h=10 -> scale_x=2, scale_y=1 about (5,5): + # (0,0) -> (2*(0-5)+5, 1*(0-5)+5) = (-5, 0) + # (10,0) -> (15, 0) + # (10,10)-> (15, 10) + # (0,10) -> (-5, 10) + t = make_square_trace() + stretched = t.getStretched(20, 10) + assert stretched.points == [(-5.0, 0.0), (15.0, 0.0), + (15.0, 10.0), (-5.0, 10.0)] + # resulting bounds match the requested dimensions + xmin, ymin, xmax, ymax = stretched.getBounds() + assert (xmax - xmin) == pytest.approx(20) + assert (ymax - ymin) == pytest.approx(10) + + +def test_get_stretched_returns_copy(): + t = make_square_trace() + stretched = t.getStretched(20, 10) + assert stretched is not t + # original is untouched + assert t.points == list(SQUARE) + + +def test_get_stretched_preserves_midpoint(): + # scaling about the centroid leaves the bounds-midpoint of a symmetric + # shape unchanged at the centroid. + t = make_square_trace() + stretched = t.getStretched(4, 30) + mx, my = stretched.getMidpoint() + assert mx == pytest.approx(5.0) + assert my == pytest.approx(5.0) + + +# --------------------------------------------------------------------------- +# isSameTrace() +# --------------------------------------------------------------------------- + +def test_is_same_trace_true_for_copy(): + a = make_square_trace() + b = a.copy() + assert a.isSameTrace(b) is True + + +def test_is_same_trace_false_on_name(): + a = make_square_trace(name="a") + b = make_square_trace(name="b") + assert a.isSameTrace(b) is False + + +def test_is_same_trace_false_on_color(): + a = make_square_trace(color=(1, 1, 1)) + b = make_square_trace(color=(2, 2, 2)) + assert a.isSameTrace(b) is False + + +def test_is_same_trace_false_on_points(): + a = make_square_trace() + b = make_square_trace() + b.points = b.points + [(5, 5)] + assert a.isSameTrace(b) is False + + +def test_is_same_trace_ignores_tags_and_hidden(): + # isSameTrace only inspects name, color, points + a = make_square_trace() + b = make_square_trace() + b.tags = {"different"} + b.hidden = True + b.negative = True + assert a.isSameTrace(b) is True + + +# --------------------------------------------------------------------------- +# overlaps() +# --------------------------------------------------------------------------- + +def test_overlaps_identical_traces(): + a = make_square_trace() + b = make_square_trace() + assert a.overlaps(b) is True + + +def test_overlaps_within_tolerance(): + # points within 1e-2 in both coords count as matching -> True + a = make_square_trace() + b = make_square_trace() + b.points = [(x + 0.005, y - 0.005) for (x, y) in b.points] + assert a.overlaps(b) is True + + +def test_overlaps_false_when_closedness_differs(): + a = make_square_trace(closed=True) + b = make_square_trace(closed=False) + assert a.overlaps(b) is False + + +def test_overlaps_disjoint_traces(): + a = make_square_trace() # x,y in [0,10] + b = make_square_trace() + b.points = [(100, 100), (110, 100), (110, 110), (100, 110)] + assert a.overlaps(b) is False + + +def test_overlaps_partial_below_threshold(): + # two identical-size squares sharing only a quarter of their area; + # overlap ratio (intersection/union) is well under the default 0.99. + a = make_square_trace() # [0,10] x [0,10] + b = make_square_trace() + b.points = [(5, 5), (15, 5), (15, 15), (5, 15)] + assert a.overlaps(b) is False + + +def test_overlaps_high_threshold_for_mostly_overlapping(): + # nearly-coincident squares (1-unit shift on a 10-unit square) exceed a + # modest threshold but the point-lists are not within tolerance. + a = make_square_trace() + b = make_square_trace() + b.points = [(1, 0), (11, 0), (11, 10), (1, 10)] + # large shift, low threshold -> overlaps; identical shape so ratio ~ 9/11 + assert a.overlaps(b, threshold=0.5) is True + + +# --------------------------------------------------------------------------- +# getOverlapRatio() (bounded in [0, 1]; hand-reasoned extremes) +# --------------------------------------------------------------------------- + +def test_overlap_ratio_identical_is_one(): + a = make_square_trace() + b = make_square_trace() + assert a.getOverlapRatio(b) == pytest.approx(1.0, abs=1e-6) + + +def test_overlap_ratio_disjoint_is_zero(): + a = make_square_trace() + b = make_square_trace() + b.points = [(100, 100), (110, 100), (110, 110), (100, 110)] + assert a.getOverlapRatio(b) == 0 + + +def test_overlap_ratio_quarter_overlap(): + # b is the same square shifted by (5,5): the intersection is a 5x5 block, + # the union is two 10x10 squares minus that block = 100 + 100 - 25 = 175. + # ratio = 25/175 = 1/7 ~= 0.142857 (rasterized, so approximate). + a = make_square_trace() + b = make_square_trace() + b.points = [(5, 5), (15, 5), (15, 15), (5, 15)] + r = a.getOverlapRatio(b) + assert r == pytest.approx(1.0 / 7.0, abs=0.02) + assert 0 < r < 1 diff --git a/tests/test_transform_methods.py b/tests/test_transform_methods.py new file mode 100644 index 00000000..32934318 --- /dev/null +++ b/tests/test_transform_methods.py @@ -0,0 +1,335 @@ +"""Tests for Transform methods not covered by test_transform.py / test_geometry.py. + +Covers composition (__mul__), inverted(), imageTransform(), magScale() (which +MUTATES in place), the det property, equals() tolerance, getLinear(), identity(), +and the fromQTransform <-> getQTransform round-trip. + +Expected values are independently derived: hand-computed inverses/composites, +QTransform's documented affine convention (a Transform list [t0..t5] builds +QTransform(t0, t3, t1, t4, t2, t5), i.e. m11=t0, m21=t1, m31=t2, m12=t3, +m22=t4, m32=t5), and the mathematical identities of inverse/composition. + +Note on composition order: QTransform uses the row-vector convention, so for +two transforms the product (A * B) applies A first and then B; concretely +(A * B).map(p) == B.map(A.map(p)). The tests assert that real order. +""" +import math +import pytest + +from PyReconstruct.modules.datatypes.transform import Transform + +# Assorted transforms exercised across several tests. +TFORMS = [ + ("identity", [1, 0, 0, 0, 1, 0]), + ("translate", [1, 0, 5.5, 0, 1, -3.25]), + ("scale", [2.0, 0, 0, 0, 0.5, 0]), + ("rotate", [math.cos(0.3), -math.sin(0.3), 10, math.sin(0.3), math.cos(0.3), -7]), + ("shear", [1, 0.4, 0, 0.2, 1, 0]), + ("affine", [1.3, 0.2, 4.0, -0.1, 0.9, 2.5]), +] +# All of the above are invertible (nonzero determinant). +INVERTIBLE = TFORMS + +PTS = [(0, 0), (1, 2), (-3.5, 4.25), (100.1, -200.2)] + + +# --------------------------------------------------------------------------- +# getQTransform / fromQTransform component placement and round-trip +# --------------------------------------------------------------------------- + +def test_getQTransform_component_placement(): + # Distinct components so each slot is unambiguous. + q = Transform([10, 20, 30, 40, 50, 60]).getQTransform() + assert q.m11() == 10 + assert q.m21() == 20 + assert q.m31() == 30 + assert q.m12() == 40 + assert q.m22() == 50 + assert q.m32() == 60 + # dx/dy alias the translation row. + assert q.dx() == 30 + assert q.dy() == 60 + + +@pytest.mark.parametrize("name,t", TFORMS, ids=[x[0] for x in TFORMS]) +def test_fromQTransform_getQTransform_roundtrip(name, t): + orig = Transform(list(t)) + rebuilt = Transform.fromQTransform(orig.getQTransform()) + for got, exp in zip(rebuilt.getList(), t): + assert got == pytest.approx(exp, abs=1e-12) + + +def test_fromQTransform_returns_transform(): + out = Transform.fromQTransform(Transform([1, 0, 0, 0, 1, 0]).getQTransform()) + assert isinstance(out, Transform) + + +# --------------------------------------------------------------------------- +# __mul__ : composition semantics +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("name_a,a", TFORMS, ids=[x[0] for x in TFORMS]) +@pytest.mark.parametrize("name_b,b", TFORMS, ids=[x[0] for x in TFORMS]) +def test_mul_matches_sequential_map(name_a, a, name_b, b): + # QTransform row-vector convention: (A*B).map(p) == B.map(A.map(p)). + A = Transform(list(a)) + B = Transform(list(b)) + C = A * B + for p in PTS: + cx, cy = C.map(*p) + sx, sy = B.map(*A.map(*p)) + assert cx == pytest.approx(sx, abs=1e-9) + assert cy == pytest.approx(sy, abs=1e-9) + + +def test_mul_order_is_not_symmetric_for_noncommuting(): + # A scale-then-translate composed with a translate does not commute; + # this guards against silently swapping the composition order. + A = Transform([2.0, 0, 1.0, 0, 3.0, -2.0]) + B = Transform([1, 0, 5.0, 0, 1, 7.0]) + p = (4.0, -1.0) + ab = (A * B).map(*p) + ba = (B * A).map(*p) + # Hand-computed: A.map(p) = (2*4+1, 3*-1-2) = (9, -5); then B: (+5,+7) => (14, 2). + assert ab == pytest.approx((14.0, 2.0), abs=1e-9) + # The reversed product gives a different result. + assert ba[0] != pytest.approx(ab[0], abs=1e-6) or ba[1] != pytest.approx(ab[1], abs=1e-6) + + +@pytest.mark.parametrize("name,t", TFORMS, ids=[x[0] for x in TFORMS]) +def test_mul_identity_is_neutral(name, t): + A = Transform(list(t)) + I = Transform.identity() + for got in (A * I).getList(), (I * A).getList(): + for g, e in zip(got, t): + assert g == pytest.approx(e, abs=1e-12) + + +def test_mul_returns_transform(): + A = Transform([1.3, 0.2, 4.0, -0.1, 0.9, 2.5]) + assert isinstance(A * A, Transform) + + +# --------------------------------------------------------------------------- +# inverted() +# --------------------------------------------------------------------------- + +def test_inverted_hand_computed(): + # t: x' = 2x + 3, y' = 4y + 5. Inverse: x = (x'-3)/2, y = (y'-5)/4 + # => [0.5, 0, -1.5, 0, 0.25, -1.25]. + inv = Transform([2, 0, 3, 0, 4, 5]).inverted() + expected = [0.5, 0.0, -1.5, 0.0, 0.25, -1.25] + for got, exp in zip(inv.getList(), expected): + assert got == pytest.approx(exp, abs=1e-12) + + +@pytest.mark.parametrize("name,t", INVERTIBLE, ids=[x[0] for x in INVERTIBLE]) +def test_inverted_double_roundtrip(name, t): + tform = Transform(list(t)) + twice = tform.inverted().inverted() + assert tform.equals(twice) + # And explicitly within a tight tolerance. + for got, exp in zip(twice.getList(), t): + assert got == pytest.approx(exp, abs=1e-9) + + +@pytest.mark.parametrize("name,t", INVERTIBLE, ids=[x[0] for x in INVERTIBLE]) +def test_inverted_composes_to_identity_in_mapping(name, t): + tform = Transform(list(t)) + inv = tform.inverted() + # t followed by its inverse maps every point back to itself. + for p in PTS: + bx, by = inv.map(*tform.map(*p)) + assert bx == pytest.approx(p[0], abs=1e-7) + assert by == pytest.approx(p[1], abs=1e-7) + # And the composed transform is the identity (to numeric tolerance). + assert (tform * inv).equals(Transform.identity()) + assert (inv * tform).equals(Transform.identity()) + + +def test_inverted_of_identity_is_identity(): + assert Transform.identity().inverted().equals(Transform.identity()) + + +def test_inverted_singular_raises(): + # Determinant 1*4 - 2*2 = 0 -> not invertible. + with pytest.raises(Exception): + Transform([1, 2, 0, 2, 4, 0]).inverted() + + +# --------------------------------------------------------------------------- +# imageTransform() +# --------------------------------------------------------------------------- + +def test_imageTransform_formula(): + # imageTransform of [a,b,c,d,e,f] == [a, -b, 0, -d, e, 0]. + out = Transform([2.0, 0.5, 9.0, 0.3, 4.0, -8.0]).imageTransform() + assert out.getList() == [2.0, -0.5, 0, -0.3, 4.0, 0] + + +def test_imageTransform_drops_translation_and_negates_offdiag(): + a, b, c, d, e, f = 1.3, 0.2, 4.0, -0.1, 0.9, 2.5 + out = Transform([a, b, c, d, e, f]).imageTransform().getList() + assert out[0] == pytest.approx(a) + assert out[1] == pytest.approx(-b) + assert out[2] == 0 + assert out[3] == pytest.approx(-d) + assert out[4] == pytest.approx(e) + assert out[5] == 0 + + +def test_imageTransform_returns_new_transform(): + src = Transform([2.0, 0.5, 9.0, 0.3, 4.0, -8.0]) + out = src.imageTransform() + assert isinstance(out, Transform) + # Source is not mutated. + assert src.getList() == [2.0, 0.5, 9.0, 0.3, 4.0, -8.0] + + +# --------------------------------------------------------------------------- +# magScale() -- MUTATES in place, scales translation by new/prev +# --------------------------------------------------------------------------- + +def test_magScale_mutates_translation_components(): + t = Transform([1.0, 0.0, 10.0, 0.0, 1.0, 20.0]) + ret = t.magScale(2.0, 6.0) # factor new/prev = 3 + assert ret is None # mutates in place, returns nothing + assert t.getList() == [1.0, 0.0, 30.0, 0.0, 1.0, 60.0] + + +def test_magScale_leaves_linear_part_untouched(): + t = Transform([1.3, 0.2, 4.0, -0.1, 0.9, 2.5]) + t.magScale(4.0, 1.0) # factor 0.25 + lst = t.getList() + # Linear components unchanged. + assert lst[0] == pytest.approx(1.3) + assert lst[1] == pytest.approx(0.2) + assert lst[3] == pytest.approx(-0.1) + assert lst[4] == pytest.approx(0.9) + # Translation scaled by 0.25. + assert lst[2] == pytest.approx(1.0) + assert lst[5] == pytest.approx(0.625) + + +def test_magScale_identity_factor_is_noop(): + t = Transform([1.3, 0.2, 4.0, -0.1, 0.9, 2.5]) + t.magScale(3.0, 3.0) # factor 1.0 + for got, exp in zip(t.getList(), [1.3, 0.2, 4.0, -0.1, 0.9, 2.5]): + assert got == pytest.approx(exp) + + +# --------------------------------------------------------------------------- +# det property +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "tform,expected", + [ + ([2, 0, 0, 0, 3, 0], 6.0), # diagonal scale: 2*3 + ([1, 2, 0, 3, 4, 0], -2.0), # a*e - b*d = 1*4 - 2*3 + ([1, 2, 0, 2, 4, 0], 0.0), # singular + ([1, 0, 0, 0, 1, 0], 1.0), # identity + ([1, 0, 99, 0, 1, -42], 1.0), # translation does not affect det + ], +) +def test_det_equals_linear_determinant(tform, expected): + # det of [a,b,c,d,e,f] == a*e - b*d (translation row ignored). + assert Transform(tform).det == pytest.approx(expected, abs=1e-12) + + +def test_det_negative_for_reflection(): + # A reflection (e.g. negative x-scale) has negative determinant. + assert Transform([-1, 0, 0, 0, 1, 0]).det == pytest.approx(-1.0, abs=1e-12) + + +# --------------------------------------------------------------------------- +# equals() -- tolerance 1e-6 +# --------------------------------------------------------------------------- + +def test_equals_identical(): + a = Transform([1.3, 0.2, 4.0, -0.1, 0.9, 2.5]) + b = Transform([1.3, 0.2, 4.0, -0.1, 0.9, 2.5]) + assert a.equals(b) + + +def test_equals_within_tolerance(): + base = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0] + nudged = [v + 9e-7 for v in base] # below 1e-6 threshold + assert Transform(base).equals(Transform(nudged)) + + +def test_equals_outside_tolerance(): + base = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0] + off = list(base) + off[0] += 2e-6 # above 1e-6 threshold + assert not Transform(base).equals(Transform(off)) + + +def test_equals_difference_in_any_component(): + base = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] + for i in range(6): + other = list(base) + other[i] += 1.0 + assert not Transform(base).equals(Transform(other)), f"component {i}" + + +def test_equals_is_symmetric(): + a = Transform([1.0, 0.0, 0.0, 0.0, 1.0, 0.0]) + b = Transform([1.0, 0.0, 0.0, 0.0, 1.0, 5e-7]) + assert a.equals(b) == b.equals(a) + + +# --------------------------------------------------------------------------- +# getLinear() -- zeros the translation, keeps linear part +# --------------------------------------------------------------------------- + +def test_getLinear_zeros_translation(): + out = Transform([1.3, 0.2, 4.0, -0.1, 0.9, 2.5]).getLinear() + assert out.getList() == [1.3, 0.2, 0, -0.1, 0.9, 0] + + +def test_getLinear_does_not_mutate_source(): + src = Transform([1.3, 0.2, 4.0, -0.1, 0.9, 2.5]) + src.getLinear() + assert src.getList() == [1.3, 0.2, 4.0, -0.1, 0.9, 2.5] + + +def test_getLinear_maps_origin_to_origin(): + # With translation removed, the origin is a fixed point. + out = Transform([1.3, 0.2, 4.0, -0.1, 0.9, 2.5]).getLinear() + x, y = out.map(0.0, 0.0) + assert x == pytest.approx(0.0) + assert y == pytest.approx(0.0) + + +@pytest.mark.parametrize("name,t", TFORMS, ids=[x[0] for x in TFORMS]) +def test_getLinear_preserves_vector_differences(name, t): + # The linear part is exactly the full map minus its action on the origin, + # so map(p) - map(0) is unchanged by dropping the translation. + full = Transform(list(t)) + lin = full.getLinear() + o = full.map(0.0, 0.0) + for p in PTS: + fx, fy = full.map(*p) + lx, ly = lin.map(*p) + assert lx == pytest.approx(fx - o[0], abs=1e-9) + assert ly == pytest.approx(fy - o[1], abs=1e-9) + + +# --------------------------------------------------------------------------- +# identity() +# --------------------------------------------------------------------------- + +def test_identity_list(): + assert Transform.identity().getList() == [1, 0, 0, 0, 1, 0] + + +@pytest.mark.parametrize("p", PTS) +def test_identity_maps_points_unchanged(p): + x, y = Transform.identity().map(*p) + assert x == pytest.approx(p[0]) + assert y == pytest.approx(p[1]) + + +def test_identity_det_is_one(): + assert Transform.identity().det == pytest.approx(1.0) diff --git a/tests/test_ztrace_and_log.py b/tests/test_ztrace_and_log.py new file mode 100644 index 00000000..b8b9c79e --- /dev/null +++ b/tests/test_ztrace_and_log.py @@ -0,0 +1,617 @@ +"""Unit tests for Ztrace (datatypes/ztrace.py) and Log/LogSet/LogSetPair +(datatypes/log.py). + +Expected values are derived by hand from the source, not echoed back from the +functions under test: + + * Ztrace.overlaps uses a strict ``> 1e-6`` tolerance on x/y and an exact match + on the section index; a length mismatch short-circuits to False. Color is + never consulted. + * Ztrace.copy does ``self.points.copy()`` -- a *new* list (so append/in-place + magScale on the copy must not touch the original) holding the same immutable + point tuples. + * Ztrace.getDistance sums distance3D over consecutive transformed points, with + z taken from series.getZValues(); with an identity transform the geometry is + a plain 3D Euclidean sum we compute directly. + * Ztrace.smooth maps points to field space, applies rolling_average(.., + edge_mode="padded"), then maps back with the inverted transform. Because the + transform is linear and invertible, an identity and a uniform-scale transform + must yield the *same* base-coordinate result -- a real round-trip check, not + a tautology. The padded moving-average values are computed by hand. + * Ztrace.magScale multiplies x,y by new_mag/prev_mag for points on the named + section only. + * Log.__str__/fromStr round-trip; ints, lists and None for ``section``; ranges + rendered "a" when a==b else "a-b"; events containing ", " are rejoined. + * Log.containsSection is inclusive on both ends (range(n1, n2+1)) and False for + a None range. + * LogSet.addLog dynamic merging, the Create/Delete special cases, plus + addExistingLog / fromList round-trip / removeCuration / getLastIndex. + * LogSetPair divergence index and getModifiedSinceDiverge. + +trimSectionRange is covered elsewhere and is intentionally not retested here. + +Headless: Transform is a real dependency (QTransform under the hood) and works +under QT_QPA_PLATFORM=offscreen; Log uses getDateTime (QSettings), also headless +safe. No network or file I/O (LogSet.exportLogHistory, which needs real CSV +files, is not exercised). +""" +import math +import types + +import pytest + +from PyReconstruct.modules.datatypes.ztrace import Ztrace +from PyReconstruct.modules.datatypes.transform import Transform +from PyReconstruct.modules.datatypes.log import Log, LogSet, LogSetPair + + +# --------------------------------------------------------------------------- # +# Ztrace +# --------------------------------------------------------------------------- # + +IDENTITY = [1, 0, 0, 0, 1, 0] +SCALE2 = [2, 0, 0, 0, 2, 0] # uniform scale-by-2 about the origin + + +def _make_series(tform_list, zvals, align="align", ztrace_align="align"): + """Duck-typed Series stub sufficient for getDistance and smooth. + + ``zvals`` maps section number -> z value; every section gets the same + Transform under key ``align``. ``getAttr`` returns ``ztrace_align`` for the + per-ztrace alignment lookup (set to None to exercise getDistance's fallback + to ``series.alignment``). + """ + tform = Transform(tform_list) + sections = {s: {"tforms": {align: tform}} for s in zvals} + stub = types.SimpleNamespace(data={"sections": sections}, alignment=align) + stub.getZValues = lambda: dict(zvals) + stub.getAttr = lambda name, key, ztrace=False: ztrace_align + return stub + + +def test_copy_returns_new_list_with_equal_contents(): + zt = Ztrace("z", [255, 0, 0], [(0.0, 0.0, 0), (1.0, 1.0, 1)]) + c = zt.copy() + + assert c is not zt + assert c.name == zt.name + assert c.color == zt.color + assert c.points == zt.points + # the list itself must be a distinct object (shallow copy) + assert c.points is not zt.points + + +def test_copy_append_does_not_affect_original(): + zt = Ztrace("z", [1, 2, 3], [(0.0, 0.0, 0), (1.0, 1.0, 1)]) + c = zt.copy() + + c.points.append((2.0, 2.0, 2)) + + assert zt.points == [(0.0, 0.0, 0), (1.0, 1.0, 1)] + assert c.points == [(0.0, 0.0, 0), (1.0, 1.0, 1), (2.0, 2.0, 2)] + + +def test_copy_isolates_in_place_magscale(): + # magScale mutates the points list in place; the original must be untouched. + zt = Ztrace("z", [1, 2, 3], [(3.0, 4.0, 0), (5.0, 6.0, 1)]) + c = zt.copy() + + c.magScale(0, prev_mag=1.0, new_mag=2.0) + + assert c.points == [(6.0, 8.0, 0), (5.0, 6.0, 1)] + assert zt.points == [(3.0, 4.0, 0), (5.0, 6.0, 1)] + + +def test_overlaps_identical_points_true(): + a = Ztrace("a", [1, 1, 1], [(0.0, 0.0, 0), (1.5, -2.5, 3)]) + b = Ztrace("b", [9, 9, 9], [(0.0, 0.0, 0), (1.5, -2.5, 3)]) + # color differs but is ignored + assert a.overlaps(b) is True + assert b.overlaps(a) is True + + +def test_overlaps_within_tolerance_true(): + # 5e-7 difference is below the strict > 1e-6 threshold -> still overlapping + a = Ztrace("a", [1, 1, 1], [(0.0, 0.0, 0), (1.0, 1.0, 1)]) + b = Ztrace("b", [1, 1, 1], [(0.0000005, -0.0000005, 0), (1.0, 1.0, 1)]) + assert a.overlaps(b) is True + + +def test_overlaps_outside_tolerance_false(): + # 2e-6 difference exceeds the threshold + a = Ztrace("a", [1, 1, 1], [(0.0, 0.0, 0)]) + b = Ztrace("b", [1, 1, 1], [(0.000002, 0.0, 0)]) + assert a.overlaps(b) is False + + +def test_overlaps_section_mismatch_false(): + a = Ztrace("a", [1, 1, 1], [(0.0, 0.0, 0), (1.0, 1.0, 1)]) + b = Ztrace("b", [1, 1, 1], [(0.0, 0.0, 0), (1.0, 1.0, 2)]) + assert a.overlaps(b) is False + + +def test_overlaps_length_mismatch_false(): + a = Ztrace("a", [1, 1, 1], [(0.0, 0.0, 0), (1.0, 1.0, 1)]) + b = Ztrace("b", [1, 1, 1], [(0.0, 0.0, 0)]) + assert a.overlaps(b) is False + assert b.overlaps(a) is False + + +def test_get_start_and_end(): + zt = Ztrace("z", [1, 1, 1], [(0.0, 0.0, 5), (1.0, 1.0, 2), (2.0, 2.0, 9)]) + assert zt.getStart() == 2 + assert zt.getEnd() == 9 + + +@pytest.mark.parametrize( + "section, prev_mag, new_mag, expected", + [ + # scale section 0 up by 2x; section 1 untouched + (0, 1.0, 2.0, [(6.0, 8.0, 0), (5.0, 6.0, 1)]), + # scale section 1 down by half; section 0 untouched + (1, 2.0, 1.0, [(3.0, 4.0, 0), (2.5, 3.0, 1)]), + # a section not present in the trace -> no change at all + (7, 1.0, 10.0, [(3.0, 4.0, 0), (5.0, 6.0, 1)]), + ], +) +def test_magscale_scales_only_matching_section(section, prev_mag, new_mag, expected): + zt = Ztrace("z", [1, 1, 1], [(3.0, 4.0, 0), (5.0, 6.0, 1)]) + zt.magScale(section, prev_mag=prev_mag, new_mag=new_mag) + assert zt.points == pytest.approx(expected) + + +def test_get_distance_identity_transform(): + # points (0,0) @z0 and (3,4) @z50 with identity tform -> 3D distance + zt = Ztrace("z", [1, 1, 1], [(0.0, 0.0, 0), (3.0, 4.0, 1)]) + series = _make_series(IDENTITY, {0: 0.0, 1: 50.0}) + + expected = math.sqrt(3 ** 2 + 4 ** 2 + 50 ** 2) + assert zt.getDistance(series) == pytest.approx(expected) + + +def test_get_distance_sums_multiple_segments(): + # three collinear-in-z points: (0,0,z0)->(0,0,z3)->(0,0,z7): dz=3 then dz=4 + zt = Ztrace("z", [1, 1, 1], [(0.0, 0.0, 0), (0.0, 0.0, 1), (0.0, 0.0, 2)]) + series = _make_series(IDENTITY, {0: 0.0, 1: 3.0, 2: 7.0}) + + assert zt.getDistance(series) == pytest.approx(3.0 + 4.0) + + +def test_get_distance_alignment_fallback_to_series_alignment(): + # getAttr returns None -> code falls back to series.alignment ("align"). + zt = Ztrace("z", [1, 1, 1], [(0.0, 0.0, 0), (6.0, 8.0, 1)]) + series = _make_series(IDENTITY, {0: 0.0, 1: 0.0}, ztrace_align=None) + + # purely planar (same z): distance is sqrt(6^2 + 8^2) = 10 + assert zt.getDistance(series) == pytest.approx(10.0) + + +def test_smooth_padded_moving_average_identity(): + # window=2 -> half=1, taps at i-1,i,i+1 clamped to [0, n-1]. + # field pts == base pts (identity). Hand-computed averages: + # i0: idx 0,0,1 -> x=(0+0+3)/3=1, y=(0+0+9)/3=3 + # i1: idx 0,1,2 -> x=(0+3+6)/3=3, y=(0+9+0)/3=3 + # i2: idx 1,2,2 -> x=(3+6+6)/3=5, y=(9+0+0)/3=3 + zt = Ztrace("z", [1, 1, 1], [(0.0, 0.0, 0), (3.0, 9.0, 1), (6.0, 0.0, 2)]) + series = _make_series(IDENTITY, {0: 0.0, 1: 1.0, 2: 2.0}) + + zt.smooth(series, smooth=2) + + assert zt.points == [(1.0, 3.0, 0), (3.0, 3.0, 1), (5.0, 3.0, 2)] + + +def test_smooth_window_one_is_identity(): + # window=1 -> half=0, single tap per index -> points unchanged (and section + # numbers preserved in order). + pts = [(0.5, -1.5, 4), (2.25, 3.75, 5)] + zt = Ztrace("z", [1, 1, 1], list(pts)) + series = _make_series(IDENTITY, {4: 0.0, 5: 1.0}) + + zt.smooth(series, smooth=1) + + assert zt.points == pytest.approx(pts) + + +def test_smooth_is_transform_invariant_for_uniform_scale(): + # The map -> average -> inverse-map pipeline is linear, so a uniform-scale + # transform must produce the SAME base coordinates as identity. This proves + # smooth genuinely de-transforms (a stub that forgot the inverse would not + # match the identity result). + base = [(0.0, 0.0, 0), (3.0, 9.0, 1), (6.0, 0.0, 2)] + + zt_id = Ztrace("z", [1, 1, 1], list(base)) + zt_id.smooth(_make_series(IDENTITY, {0: 0.0, 1: 1.0, 2: 2.0}), smooth=2) + + zt_sc = Ztrace("z", [1, 1, 1], list(base)) + zt_sc.smooth(_make_series(SCALE2, {0: 0.0, 1: 1.0, 2: 2.0}), smooth=2) + + assert zt_sc.points == pytest.approx(zt_id.points) + + +# --------------------------------------------------------------------------- # +# Log +# --------------------------------------------------------------------------- # + +def _log(section, event="Modified", obj_name="obj1", user="alice", + date="26-06-29", time="1200"): + return Log(date, time, user, obj_name, section, event) + + +def test_log_init_int_section_becomes_single_range(): + assert _log(5).section_ranges == [(5, 5)] + + +def test_log_init_list_section_kept(): + assert _log([(1, 3), (7, 9)]).section_ranges == [(1, 3), (7, 9)] + + +def test_log_init_none_section(): + assert _log(None).section_ranges is None + + +def test_log_str_single_int_section(): + s = str(_log(5)) + assert s == "26-06-29, 1200, alice, obj1, 5, Modified" + + +def test_log_str_collapses_equal_range_endpoints(): + # a single-element range (5,5) renders as "5", not "5-5" + assert str(_log([(5, 5)])) == "26-06-29, 1200, alice, obj1, 5, Modified" + + +def test_log_str_multi_range_space_joined(): + s = str(_log([(1, 3), (7, 9)])) + assert s == "26-06-29, 1200, alice, obj1, 1-3 7-9, Modified" + + +def test_log_str_none_obj_and_section_render_dash(): + s = str(Log("26-06-29", "1200", "alice", None, None, "static event")) + assert s == "26-06-29, 1200, alice, -, -, static event" + + +@pytest.mark.parametrize( + "section", + [ + 5, # int -> single range + [(1, 3), (7, 9)], # multi-range list + [(2, 2)], # single collapsed range + None, # no section + ], +) +def test_log_str_fromstr_roundtrip(section): + original = _log(section) + rebuilt = Log.fromStr(str(original)) + + assert str(rebuilt) == str(original) + assert rebuilt == original + # field-level checks + assert rebuilt.date == original.date + assert rebuilt.time == original.time + assert rebuilt.user == original.user + assert rebuilt.obj_name == original.obj_name + assert rebuilt.section_ranges == original.section_ranges + assert rebuilt.event == original.event + + +def test_log_fromstr_dash_obj_becomes_none(): + log = Log.fromStr("26-06-29, 1200, alice, -, 5, Modified") + assert log.obj_name is None + assert log.section_ranges == [(5, 5)] + + +def test_log_fromstr_dash_section_becomes_none(): + log = Log.fromStr("26-06-29, 1200, alice, obj1, -, Modified") + assert log.section_ranges is None + assert log.obj_name == "obj1" + + +def test_log_roundtrip_event_with_commas(): + # event itself contains ", " separators; fromStr must rejoin them. + original = _log(5, event="merged a, b, and c") + rebuilt = Log.fromStr(str(original)) + + assert rebuilt.event == "merged a, b, and c" + assert rebuilt == original + + +def test_log_eq_same_fields_equal(): + assert _log(5) == _log(5) + + +def test_log_eq_int_and_single_range_equal(): + # int 5 and [(5,5)] stringify identically -> __eq__ True + assert _log(5) == _log([(5, 5)]) + + +def test_log_eq_differing_event_not_equal(): + assert _log(5, event="A") != _log(5, event="B") + + +def test_log_eq_differing_section_not_equal(): + assert _log(5) != _log(6) + + +@pytest.mark.parametrize( + "snum, expected", + [ + (4, False), # just below lower bound + (5, True), # lower bound inclusive + (7, True), # interior + (10, True), # upper bound inclusive + (11, False), # just above upper bound + ], +) +def test_contains_section_single_range_inclusive(snum, expected): + assert _log([(5, 10)]).containsSection(snum) is expected + + +@pytest.mark.parametrize( + "snum, expected", + [ + (1, True), # in first range + (3, True), # upper bound of first range + (5, False), # gap between ranges + (7, True), # lower bound of second range + (9, True), # upper bound of second range + (10, False), # past second range + ], +) +def test_contains_section_multi_range(snum, expected): + assert _log([(1, 3), (7, 9)]).containsSection(snum) is expected + + +def test_contains_section_none_range_false(): + assert _log(None).containsSection(5) is False + + +# --------------------------------------------------------------------------- # +# LogSet +# --------------------------------------------------------------------------- # + +def test_logset_adddynamic_merges_adjacent_sections(): + # same user/obj/event on consecutive sections -> one log, ranges merged. + ls = LogSet() + ls.addLog("u", "A", 1, "E") + ls.addLog("u", "A", 2, "E") + + assert len(ls.all_logs) == 1 + assert ls.all_logs[0].section_ranges == [(1, 2)] + + +def test_logset_adddynamic_gap_then_fill(): + # 1, then 3 (gap -> two ranges), then 2 (bridges -> single merged range). + ls = LogSet() + ls.addLog("u", "A", 1, "E") + ls.addLog("u", "A", 3, "E") + assert ls.all_logs[0].section_ranges == [(1, 1), (3, 3)] + + ls.addLog("u", "A", 2, "E") + assert ls.all_logs[0].section_ranges == [(1, 3)] + assert len(ls.all_logs) == 1 + + +def test_logset_adddynamic_distinct_events_distinct_logs(): + ls = LogSet() + ls.addLog("u", "A", 1, "E1") + ls.addLog("u", "A", 1, "E2") + + assert len(ls.all_logs) == 2 + events = {l.event for l in ls.all_logs} + assert events == {"E1", "E2"} + + +def test_logset_new_user_clears_dynamic_tracking(): + # a different user resets dyn_logs, so the same obj/event/section produces a + # separate log instead of merging. + ls = LogSet() + ls.addLog("u", "A", 1, "E") + ls.addLog("v", "A", 2, "E") + + assert len(ls.all_logs) == 2 + assert [l.user for l in ls.all_logs] == ["u", "v"] + assert ls.all_logs[0].section_ranges == [(1, 1)] + assert ls.all_logs[1].section_ranges == [(2, 2)] + + +def test_logset_delete_object_removes_prior_non_create_logs(): + ls = LogSet() + ls.addLog("u", "A", 1, "modified") + ls.addLog("u", "B", 1, "modified") + ls.addLog("u", "A", None, "Delete object") + + # A's "modified" log is gone; B's remains; the delete log is appended last. + descrs = [(l.obj_name, l.event) for l in ls.all_logs] + assert descrs == [("B", "modified"), ("A", "Delete object")] + + +def test_logset_delete_object_keeps_create_object_log(): + ls = LogSet() + ls.addLog("u", "A", None, "Create object") + ls.addLog("u", "A", 1, "modified") + ls.addLog("u", "A", None, "Delete object") + + descrs = [(l.obj_name, l.event) for l in ls.all_logs] + # the "Create object" log is preserved (its event contains "Create object"); + # only the "modified" log is purged before the delete log lands. + assert descrs == [("A", "Create object"), ("A", "Delete object")] + + +def test_logset_create_object_merges_with_prior_create_traces(): + ls = LogSet() + ls.addLog("u", "A", None, "Create trace(s)") + ls.addLog("u", "A", None, "Create object") + + # no new log appended; prior event rewritten "trace(s)" -> "object" + assert len(ls.all_logs) == 1 + assert ls.all_logs[0].event == "Create object" + assert ls.all_logs[0].obj_name == "A" + + +def test_logset_create_object_plain_append_when_no_prior_trace_log(): + ls = LogSet() + ls.addLog("u", "A", None, "Create object") + + assert len(ls.all_logs) == 1 + assert ls.all_logs[0].event == "Create object" + + +def test_add_existing_log_appends_and_optionally_tracks(): + ls = LogSet() + log = Log("26-06-29", "1200", "u", "A", 5, "E") + + ls.addExistingLog(log, track_dyn=True) + + assert ls.all_logs == [log] + assert "A" in ls.dyn_logs + assert ls.dyn_logs["A"]["E"] is log + + +def test_add_existing_log_no_track_leaves_dyn_empty(): + ls = LogSet() + ls.addExistingLog(Log("26-06-29", "1200", "u", "A", 5, "E")) + + assert len(ls.all_logs) == 1 + assert ls.dyn_logs == {} + + +def test_add_existing_log_track_none_obj_uses_dash_key(): + ls = LogSet() + log = Log("26-06-29", "1200", "u", None, 5, "E") + + ls.addExistingLog(log, track_dyn=True) + + assert "-" in ls.dyn_logs + assert ls.dyn_logs["-"]["E"] is log + + +def test_logset_getloglist_str_vs_list(): + ls = LogSet() + log = Log("26-06-29", "1200", "u", "A", 5, "E") + ls.addExistingLog(log) + + assert ls.getLogList() == [log] + # the list copy is a distinct object + assert ls.getLogList() is not ls.all_logs + assert ls.getLogList(as_str=True) == str(log) + assert str(ls) == str(log) + + +def test_logset_fromlist_roundtrip(): + ls = LogSet() + ls.addExistingLog( + Log("26-06-29", "1200", "u", "A", [(1, 3), (7, 9)], "event, with comma") + ) + ls.addExistingLog(Log("26-06-29", "1201", "u", None, None, "static")) + + rebuilt = LogSet.fromList(ls.getList()) + + assert rebuilt.getList() == ls.getList() + + +def test_logset_fromlist_skips_blank_lines(): + rebuilt = LogSet.fromList( + ["26-06-29, 1200, u, A, 5, E", "", " "] + ) + assert len(rebuilt.all_logs) == 1 + assert rebuilt.all_logs[0].obj_name == "A" + + +def test_logset_remove_curation(): + ls = LogSet() + ls.addExistingLog(Log("26-06-29", "1200", "u", "A", 1, "manually curated")) + ls.addExistingLog(Log("26-06-29", "1201", "u", "A", 1, "modified")) + ls.addExistingLog(Log("26-06-29", "1202", "u", "B", 1, "curation removed")) + + ls.removeCuration("A") + + descrs = [(l.obj_name, l.event) for l in ls.all_logs] + # only A's curation log is removed; A's "modified" and B's curation stay. + assert descrs == [("A", "modified"), ("B", "curation removed")] + + +def test_logset_get_last_index_matches_section_and_name(): + ls = LogSet() + ls.addExistingLog(Log("26-06-29", "1200", "u", "A", 1, "modified")) # 0 + ls.addExistingLog(Log("26-06-29", "1201", "u", "A", 5, "modified")) # 1 + ls.addExistingLog(Log("26-06-29", "1202", "u", "A", 9, "ztrace x")) # 2 (skip) + + # last non-ztrace A log containing section 5 is index 1 + assert ls.getLastIndex(5, "A") == 1 + + +def test_logset_get_last_index_no_match_returns_negative(): + ls = LogSet() + ls.addExistingLog(Log("26-06-29", "1200", "u", "A", 1, "modified")) + ls.addExistingLog(Log("26-06-29", "1201", "u", "A", 1, "modified")) + + # no log contains section 99, and name Z never matches -> scans to -1 + assert ls.getLastIndex(99, "A") == -1 + assert ls.getLastIndex(1, "Z") == -1 + + +def test_logset_get_last_index_none_range_matches_any_section(): + ls = LogSet() + ls.addExistingLog(Log("26-06-29", "1200", "u", "A", None, "Create object")) + + # section_ranges is None -> matches regardless of requested section number + assert ls.getLastIndex(123, "A") == 0 + + +# --------------------------------------------------------------------------- # +# LogSetPair +# --------------------------------------------------------------------------- # + +def _shared_log(): + # build via fromStr on both sides so the two Log objects are independent but + # compare equal (LogSetPair diverge detection uses __eq__). + s = str(Log("26-06-29", "1200", "u", "A", 1, "modified")) + return Log.fromStr(s), Log.fromStr(s) + + +def test_logsetpair_diverge_index_and_incomplete_match(): + a, b = LogSet(), LogSet() + la, lb = _shared_log() + a.addExistingLog(la) + b.addExistingLog(lb) + # b has one extra, divergent log + b.addExistingLog(Log("26-06-29", "1201", "u", "B", 2, "E2")) + + pair = LogSetPair(a, b) + + assert pair.last_shared_index == 0 # first (shared) log at index 0 + assert pair.complete_match is False + + +def test_logsetpair_complete_match(): + a, b = LogSet(), LogSet() + la, lb = _shared_log() + a.addExistingLog(la) + b.addExistingLog(lb) + + pair = LogSetPair(a, b) + + assert pair.complete_match is True + assert pair.last_shared_index == 0 + + +def test_logsetpair_empty_logsets_complete_match(): + pair = LogSetPair(LogSet(), LogSet()) + + # no logs at all: loop never runs, i stays 0 -> last_shared_index -1, + # and 0 == len(a)==len(b)==0 so it's a complete match. + assert pair.last_shared_index == -1 + assert pair.complete_match is True + + +def test_logsetpair_modified_since_diverge(): + a, b = LogSet(), LogSet() + la, lb = _shared_log() + a.addExistingLog(la) + b.addExistingLog(lb) + # only b modifies A on section 1 after the shared point + b.addExistingLog(Log("26-06-29", "1300", "u", "A", 1, "modified again")) + + pair = LogSetPair(a, b) + + # logset0 unchanged since diverge, logset1 changed + assert pair.getModifiedSinceDiverge("A", 1) == (False, True) \ No newline at end of file