From 8102dddf5663a823f1887df70402d3ed7df964d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 18:10:12 +0000 Subject: [PATCH 1/2] Tests: add headless tests for 3D view, background image and recent files Add three unittest modules covering features introduced in the dev PR (3D view, background image, recent files). Following the existing tests/test_views.py convention, the pure logic of the wx-bound GUI methods is mirrored in module-level helpers so the tests run headless (no wxPython, as in CI). - test_recent_files.py: newest-first ordering, de-duplication, 30-cap, absolute-path normalisation and view/bg/data label routing. - test_background.py: paste pixel normalisation, state reset and the screen-lock / full-extent coordinate transforms (incl. a Fixed<->Moving round-trip invariant). - test_3dview.py: camera plane presets, ortho/persp axis visibility, curve-type clamping, plot3D_type normalisation, 3D capture-key JSON round-trip and the log-z colour-range edge cases. https://claude.ai/code/session_012vjzwftooPzhsyzFcffjYz --- tests/test_3dview.py | 239 +++++++++++++++++++++++++++++++++++++ tests/test_background.py | 207 ++++++++++++++++++++++++++++++++ tests/test_recent_files.py | 156 ++++++++++++++++++++++++ 3 files changed, 602 insertions(+) create mode 100644 tests/test_3dview.py create mode 100644 tests/test_background.py create mode 100644 tests/test_recent_files.py diff --git a/tests/test_3dview.py b/tests/test_3dview.py new file mode 100644 index 0000000..5d72782 --- /dev/null +++ b/tests/test_3dview.py @@ -0,0 +1,239 @@ +""" +Tests for the 3D-view feature (GUIPlotPanel.py). + +Run headless (no `wx`): per tests/test_views.py, the pure logic of the wx-bound +methods is mirrored here in module-level helpers and asserted against. `matplotlib` +is available in CI, so the log-z range is validated against a real Normalize. + +Mirrored source (pydatview/GUIPlotPanel.py): + * onViewXY/YZ/XZ/onViewFree (561-585) — camera presets + * _apply_3d_plane (526-560) — ortho/persp + axis visibility + * set3DMode (1286-1303) — curve-type index clamp + * captureViewData (1384-1396) — 3D state keys + * _GUI2Data / plot3D_type (579-600, 1386) — plot3D_type normalisation + * log-z colour range (2917-2974) +""" +import json +import tempfile +import os +import unittest +import numpy as np +import matplotlib.colors as mcolors + + +# --------------------------------------------------------------------------- +# Helpers mirroring the 3D logic +# --------------------------------------------------------------------------- + +def camera_preset(name): + """Mirror of onViewXY/YZ/XZ/onViewFree -> (elev, azim, hide_axis).""" + return { + 'XY': (90, -90, 'z'), + 'YZ': (0, 0, 'x'), + 'XZ': (0, -90, 'y'), + 'Free': (30, -60, None), + }[name] + + +def proj_and_visibility(hide_axis): + """Mirror of _apply_3d_plane: returns (proj_type, set_of_visible_axes).""" + axes = ('x', 'y', 'z') + if hide_axis: + return 'ortho', {a for a in axes if a != hide_axis} + return 'persp', set(axes) + + +def clamp_3d_sel(sel): + """Mirror of set3DMode: cbCurveType has 2 entries (Scatter, Surf).""" + return max(0, min(sel, 1)) + + +def normalize_plot3d_type(val, view3D): + """Mirror of captureViewData line 1386 + _GUI2Data clamp.""" + if not view3D: + return 'Scatter' + return val if val in ('Scatter', 'Surf') else 'Scatter' + + +def compute_z_range(z_arrays, logZ=False, autoscale=True, user_zmin=None, user_zmax=None): + """Mirror of the log-z colour-range block (GUIPlotPanel.py:2917-2974).""" + all_z_parts = [] + for z in z_arrays: + z_vals = np.asarray(z, dtype=float) + if logZ: + with np.errstate(divide='ignore', invalid='ignore'): + z_vals = np.log10(z_vals) + all_z_parts.append(z_vals) + all_z = np.concatenate(all_z_parts) + finite_z = all_z[np.isfinite(all_z)] + z_vmin = z_vmax = None + if len(finite_z) > 0: + z_vmin = float(np.min(finite_z)) + z_vmax = float(np.max(finite_z)) + if not autoscale: + u_zmin, u_zmax = user_zmin, user_zmax + if logZ: + with np.errstate(divide='ignore', invalid='ignore'): + if u_zmin is not None and u_zmin > 0: + u_zmin = float(np.log10(u_zmin)) + elif u_zmin is not None: + u_zmin = None + if u_zmax is not None and u_zmax > 0: + u_zmax = float(np.log10(u_zmax)) + elif u_zmax is not None: + u_zmax = None + if u_zmin is not None: + z_vmin = u_zmin + if u_zmax is not None: + z_vmax = u_zmax + if z_vmin is not None and z_vmax is not None: + if z_vmin > z_vmax: + z_vmin, z_vmax = z_vmax, z_vmin + if z_vmin == z_vmax: + eps = max(abs(z_vmin), 1.0) * 1e-6 + z_vmin -= eps + z_vmax += eps + return z_vmin, z_vmax + + +def capture_3d_data(view3D=True, curveType='Scatter', elev=30, azim=-60, + hide=None, logZ=False, flipZ=False): + """Mirror of the 3D keys written by captureViewData (1384-1396).""" + return { + 'view3D': view3D, + 'plot3D_type': normalize_plot3d_type(curveType, view3D), + 'view3D_elev': elev, + 'view3D_azim': azim, + 'view3D_hide': hide, + 'logZ': logZ, + 'flipZ': flipZ, + } + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestCapture3DState(unittest.TestCase): + def test_all_3d_keys_present(self): + d = capture_3d_data() + for k in ('view3D', 'plot3D_type', 'view3D_elev', 'view3D_azim', + 'view3D_hide', 'logZ', 'flipZ'): + self.assertIn(k, d) + + def test_json_round_trip(self): + d = capture_3d_data(view3D=True, curveType='Surf', elev=12.5, azim=-45, + hide='z', logZ=True, flipZ=True) + with tempfile.TemporaryDirectory() as tmp: + p = os.path.join(tmp, 'v.pdvview') + with open(p, 'w') as f: + json.dump({'plotPanel': d}, f) + with open(p) as f: + loaded = json.load(f)['plotPanel'] + self.assertEqual(loaded['plot3D_type'], 'Surf') + self.assertEqual(loaded['view3D_elev'], 12.5) + self.assertEqual(loaded['view3D_hide'], 'z') + self.assertTrue(loaded['logZ']) + self.assertTrue(loaded['flipZ']) + + def test_plot3d_type_forced_scatter_when_not_3d(self): + d = capture_3d_data(view3D=False, curveType='Surf') + self.assertEqual(d['plot3D_type'], 'Scatter') + + +class TestPlot3DTypeNormalisation(unittest.TestCase): + def test_valid_values_pass_through(self): + self.assertEqual(normalize_plot3d_type('Scatter', True), 'Scatter') + self.assertEqual(normalize_plot3d_type('Surf', True), 'Surf') + + def test_invalid_falls_back_to_scatter(self): + self.assertEqual(normalize_plot3d_type('LS', True), 'Scatter') + self.assertEqual(normalize_plot3d_type('', True), 'Scatter') + + +class TestCameraPresets(unittest.TestCase): + def test_presets(self): + self.assertEqual(camera_preset('XY'), (90, -90, 'z')) + self.assertEqual(camera_preset('YZ'), (0, 0, 'x')) + self.assertEqual(camera_preset('XZ'), (0, -90, 'y')) + self.assertEqual(camera_preset('Free'), (30, -60, None)) + + def test_plane_views_hide_their_normal_axis(self): + for plane, normal in (('XY', 'z'), ('YZ', 'x'), ('XZ', 'y')): + self.assertEqual(camera_preset(plane)[2], normal) + + +class TestProjectionVisibility(unittest.TestCase): + def test_hidden_axis_uses_ortho_and_is_invisible(self): + proj, visible = proj_and_visibility('z') + self.assertEqual(proj, 'ortho') + self.assertNotIn('z', visible) + self.assertEqual(visible, {'x', 'y'}) + + def test_free_view_uses_persp_all_visible(self): + proj, visible = proj_and_visibility(None) + self.assertEqual(proj, 'persp') + self.assertEqual(visible, {'x', 'y', 'z'}) + + +class TestCurveTypeClamp(unittest.TestCase): + def test_clamp(self): + self.assertEqual(clamp_3d_sel(-1), 0) + self.assertEqual(clamp_3d_sel(0), 0) + self.assertEqual(clamp_3d_sel(1), 1) + self.assertEqual(clamp_3d_sel(5), 1) + + +class TestZRange(unittest.TestCase): + def test_linear_range_is_data_minmax(self): + vmin, vmax = compute_z_range([np.array([1.0, 5.0, 3.0])]) + self.assertAlmostEqual(vmin, 1.0) + self.assertAlmostEqual(vmax, 5.0) + + def test_multiple_arrays_combined(self): + vmin, vmax = compute_z_range([np.array([1.0, 2.0]), np.array([0.5, 9.0])]) + self.assertAlmostEqual(vmin, 0.5) + self.assertAlmostEqual(vmax, 9.0) + + def test_logz_transforms_and_drops_nonpositive(self): + # 0 and -1 -> non-finite after log10 and must be filtered out + vmin, vmax = compute_z_range([np.array([-1.0, 0.0, 1.0, 100.0])], logZ=True) + self.assertAlmostEqual(vmin, 0.0) # log10(1) + self.assertAlmostEqual(vmax, 2.0) # log10(100) + + def test_user_limits_ignored_when_autoscale(self): + vmin, vmax = compute_z_range([np.array([1.0, 5.0])], autoscale=True, + user_zmin=100.0, user_zmax=200.0) + self.assertAlmostEqual(vmin, 1.0) + self.assertAlmostEqual(vmax, 5.0) + + def test_user_limits_applied_when_not_autoscale(self): + vmin, vmax = compute_z_range([np.array([1.0, 5.0])], autoscale=False, + user_zmin=2.0, user_zmax=4.0) + self.assertAlmostEqual(vmin, 2.0) + self.assertAlmostEqual(vmax, 4.0) + + def test_logz_user_limits_log_scaled_and_nonpositive_dropped(self): + # user_zmin=-1 (invalid in log) dropped -> falls back to data; user_zmax=100 -> log10=2 + vmin, vmax = compute_z_range([np.array([1.0, 1000.0])], logZ=True, + autoscale=False, user_zmin=-1.0, user_zmax=100.0) + self.assertAlmostEqual(vmin, 0.0) # data log10(1), user min dropped + self.assertAlmostEqual(vmax, 2.0) # log10(100) + + def test_inverted_user_limits_swapped(self): + vmin, vmax = compute_z_range([np.array([1.0, 5.0])], autoscale=False, + user_zmin=4.0, user_zmax=2.0) + self.assertLess(vmin, vmax) + self.assertAlmostEqual(vmin, 2.0) + self.assertAlmostEqual(vmax, 4.0) + + def test_constant_data_padded(self): + vmin, vmax = compute_z_range([np.array([3.0, 3.0, 3.0])]) + self.assertLess(vmin, vmax) # epsilon padding avoids collapse + # a Normalize built from the padded range is valid (no zero span) + norm = mcolors.Normalize(vmin=vmin, vmax=vmax) + self.assertFalse(np.isnan(norm(3.0))) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_background.py b/tests/test_background.py new file mode 100644 index 0000000..3419ccc --- /dev/null +++ b/tests/test_background.py @@ -0,0 +1,207 @@ +""" +Tests for the Background-image feature (GUIPlotPanel.py). + +Run headless (no `wx`): following tests/test_views.py, the pure logic of the +wx-bound methods is mirrored here in module-level helpers and asserted against. + +Mirrored source (pydatview/GUIPlotPanel.py): + * _setBgImage / onClearBgImage (1654-1718) — state reset + * onPasteBgImage (1688-1706) — uint8 -> float [0,1] normalisation + * _compute_bg_screen_lock (1719-1795) — Moving -> Fixed crop/extent math + * _full_extent_from_state (1797-1830) — Fixed -> Moving full-extent math +""" +import unittest +import numpy as np + + +# --------------------------------------------------------------------------- +# Helpers mirroring the background-image logic +# --------------------------------------------------------------------------- + +def bg_default_state(): + """Mirror of the field reset done by _setBgImage / onClearBgImage.""" + return { + 'bg_glued': False, # False = Fixed (pinned to plot rect) + 'bg_extent': None, # data coords, set in Moving mode + 'bg_display_image': None, # cropped image for Fixed mode + 'bg_axes_extent': None, # axes-fraction extent for Fixed artist + 'bg_crop_box': None, # fraction [0,1] of original image + } + + +def normalize_pasted(uint8_arr): + """Mirror of onPasteBgImage: bytes -> float64 in [0,1].""" + return uint8_arr.astype(np.float64) / 255.0 + + +def compute_screen_lock(img_shape, bg_extent, viewport, parent_crop_box=None): + """Mirror of _compute_bg_screen_lock for the glued/artist-present case. + + bg_extent = [bx0, bx1, by0, by1] : current image extent in data coords. + viewport = [vx0, vx1, vy0, vy1] : current axis limits (any order). + Returns (crop_box, axes_extent) or None if the image is not visible. + """ + vx0, vx1 = sorted(viewport[:2]) + vy0, vy1 = sorted(viewport[2:]) + bx0, bx1, by0, by1 = bg_extent + if bx0 > bx1: + bx0, bx1 = bx1, bx0 + if by0 > by1: + by0, by1 = by1, by0 + + ix0, ix1 = max(bx0, vx0), min(bx1, vx1) + iy0, iy1 = max(by0, vy0), min(by1, vy1) + if ix1 <= ix0 or iy1 <= iy0: + return None + + col_lf = (ix0 - bx0) / (bx1 - bx0) + col_rf = (ix1 - bx0) / (bx1 - bx0) + # origin='upper': row 0 is at by1 (top), row h is at by0 (bottom) + row_tf = (by1 - iy1) / (by1 - by0) + row_bf = (by1 - iy0) / (by1 - by0) + + h, w = img_shape[:2] + col0 = max(0, min(w, int(round(col_lf * w)))) + col1 = max(0, min(w, int(round(col_rf * w)))) + row0 = max(0, min(h, int(round(row_tf * h)))) + row1 = max(0, min(h, int(round(row_bf * h)))) + if col1 <= col0 or row1 <= row0: + return None + + afx0 = (ix0 - vx0) / (vx1 - vx0) + afx1 = (ix1 - vx0) / (vx1 - vx0) + afy0 = (iy0 - vy0) / (vy1 - vy0) + afy1 = (iy1 - vy0) / (vy1 - vy0) + + pa, pb, pc, pd = parent_crop_box if parent_crop_box is not None else [0.0, 1.0, 0.0, 1.0] + crop_box = [pa + col_lf * (pb - pa), + pa + col_rf * (pb - pa), + pc + row_tf * (pd - pc), + pc + row_bf * (pd - pc)] + return crop_box, [afx0, afx1, afy0, afy1] + + +def full_extent_from_state(viewport, axes_extent, crop_box): + """Mirror of _full_extent_from_state. Returns [Dx0, Dx1, Dy0, Dy1].""" + vx0, vx1 = sorted(viewport[:2]) + vy0, vy1 = sorted(viewport[2:]) + afx0, afx1, afy0, afy1 = axes_extent if axes_extent is not None else [0.0, 1.0, 0.0, 1.0] + dx0 = vx0 + afx0 * (vx1 - vx0) + dx1 = vx0 + afx1 * (vx1 - vx0) + dy0 = vy0 + afy0 * (vy1 - vy0) + dy1 = vy0 + afy1 * (vy1 - vy0) + a, b, c, d = crop_box if crop_box is not None else [0.0, 1.0, 0.0, 1.0] + if (b - a) <= 0 or (d - c) <= 0: + return [vx0, vx1, vy0, vy1] + full_w = (dx1 - dx0) / (b - a) + full_h = (dy1 - dy0) / (d - c) + Dx0 = dx0 - a * full_w + Dx1 = Dx0 + full_w + # origin='upper': fraction c (top) maps to data y = dy1 + Dy1 = dy1 + c * full_h + Dy0 = Dy1 - full_h + return [Dx0, Dx1, Dy0, Dy1] + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestPasteNormalisation(unittest.TestCase): + def test_uint8_to_float_range(self): + arr = np.array([[0, 128, 255]], dtype=np.uint8) + out = normalize_pasted(arr) + self.assertEqual(out.dtype, np.float64) + self.assertAlmostEqual(out[0, 0], 0.0) + self.assertAlmostEqual(out[0, 2], 1.0) + self.assertTrue((out >= 0).all() and (out <= 1).all()) + + def test_shape_preserved(self): + arr = np.zeros((4, 7, 3), dtype=np.uint8) + self.assertEqual(normalize_pasted(arr).shape, (4, 7, 3)) + + +class TestStateReset(unittest.TestCase): + def test_default_state_is_fixed_mode(self): + st = bg_default_state() + self.assertFalse(st['bg_glued']) # Fixed is the default + self.assertIsNone(st['bg_extent']) + self.assertIsNone(st['bg_display_image']) + self.assertIsNone(st['bg_axes_extent']) + self.assertIsNone(st['bg_crop_box']) + + def test_clear_returns_to_default(self): + # Simulate a populated state then a clear() + st = {'bg_glued': True, 'bg_extent': [0, 1, 0, 1], + 'bg_display_image': object(), 'bg_axes_extent': [0, 1, 0, 1], + 'bg_crop_box': [0.1, 0.9, 0.1, 0.9]} + st = bg_default_state() + self.assertEqual(st, bg_default_state()) + + +class TestScreenLock(unittest.TestCase): + """_compute_bg_screen_lock: Moving (data coords) -> Fixed (axes fraction).""" + + def test_image_fully_inside_viewport_fills_axes(self): + # bg covers data [0,10]x[0,10]; we zoom into the central [2,8] box. + res = compute_screen_lock((100, 100), [0, 10, 0, 10], [2, 8, 2, 8]) + self.assertIsNotNone(res) + crop, ae = res + # the visible portion fills the whole zoomed view + np.testing.assert_allclose(ae, [0.0, 1.0, 0.0, 1.0]) + # crop box: x 0.2..0.8 ; y (origin upper) top=(10-8)/10=0.2, bottom=(10-2)/10=0.8 + np.testing.assert_allclose(crop, [0.2, 0.8, 0.2, 0.8]) + + def test_partial_overlap_clips(self): + # image spans x[0,10]; viewport x[5,20] -> only right half of image visible + res = compute_screen_lock((100, 100), [0, 10, 0, 10], [5, 20, 0, 10]) + self.assertIsNotNone(res) + crop, ae = res + # x crop from 0.5 to 1.0 + self.assertAlmostEqual(crop[0], 0.5) + self.assertAlmostEqual(crop[1], 1.0) + # image occupies axes-fraction 0 .. (10-5)/(20-5)=1/3 of the view + self.assertAlmostEqual(ae[0], 0.0) + self.assertAlmostEqual(ae[1], 1.0 / 3.0) + + def test_no_overlap_returns_none(self): + res = compute_screen_lock((100, 100), [0, 10, 0, 10], [20, 30, 0, 10]) + self.assertIsNone(res) + + def test_inverted_limits_normalised(self): + ordered = compute_screen_lock((100, 100), [0, 10, 0, 10], [2, 8, 2, 8]) + flipped = compute_screen_lock((100, 100), [0, 10, 0, 10], [8, 2, 8, 2]) + self.assertIsNotNone(flipped) + np.testing.assert_allclose(flipped[0], ordered[0]) + np.testing.assert_allclose(flipped[1], ordered[1]) + + def test_parent_crop_box_composition(self): + # already cropped to x[0.2,0.8]; further zoom selects middle half again + parent = [0.2, 0.8, 0.2, 0.8] + res = compute_screen_lock((100, 100), [0, 10, 0, 10], [2, 8, 2, 8], + parent_crop_box=parent) + crop, _ = res + # col_lf=0.2 -> 0.2 + 0.2*(0.8-0.2)=0.32 ; col_rf=0.8 -> 0.2+0.8*0.6=0.68 + np.testing.assert_allclose(crop, [0.32, 0.68, 0.32, 0.68]) + + +class TestFullExtentRoundTrip(unittest.TestCase): + """Fixed <-> Moving transforms must be inverses of one another.""" + + def test_full_extent_recovers_original(self): + full_extent = [-3.0, 7.0, 1.0, 11.0] # original image extent (data coords) + viewport = [0.0, 4.0, 3.0, 9.0] # zoomed-in view, inside the image + # Moving -> Fixed + crop, ae = compute_screen_lock((200, 200), full_extent, viewport) + # Fixed -> Moving must rebuild the original full extent + recovered = full_extent_from_state(viewport, ae, crop) + np.testing.assert_allclose(recovered, full_extent, atol=1e-9) + + def test_degenerate_crop_box_returns_viewport(self): + viewport = [0.0, 4.0, 0.0, 4.0] + out = full_extent_from_state(viewport, [0, 1, 0, 1], [0.5, 0.5, 0.0, 1.0]) + np.testing.assert_allclose(out, [0.0, 4.0, 0.0, 4.0]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_recent_files.py b/tests/test_recent_files.py new file mode 100644 index 0000000..c7699dd --- /dev/null +++ b/tests/test_recent_files.py @@ -0,0 +1,156 @@ +""" +Tests for the Recent Files feature (main.py `_track_recent` / +`_populateRecentFilesMenu`). + +These tests run headless: the real implementation lives on `wx`-importing GUI +modules that are not installed in CI, so — following the same convention as +tests/test_views.py — the pure logic is mirrored here in small helper functions +and asserted against. + +Mirrored source: + * pydatview/main.py:1187-1223 (_track_recent, _populateRecentFilesMenu) + * pydatview/main.py:55 VIEW_FILE_EXT = '.pdvview' + * pydatview/GUIPlotPanel.py:49 IMAGE_EXTS = ('.png', ...) +""" +import os +import unittest + + +# --------------------------------------------------------------------------- +# Constants mirrored from the source (kept in sync deliberately) +# --------------------------------------------------------------------------- +VIEW_FILE_EXT = '.pdvview' +IMAGE_EXTS = ('.png', '.jpg', '.jpeg', '.bmp', '.tif', '.tiff') + + +# --------------------------------------------------------------------------- +# Helpers mirroring the recent-files logic in main.py +# --------------------------------------------------------------------------- + +def track_recent(recent, path, cap=30): + """Mirror of main.py `_track_recent`. + + Insert *path* (as an absolute path) at the top of *recent*, de-duplicating + and capping the list at *cap*. Returns the new list (does not mutate input). + """ + recent = list(recent) + abs_path = os.path.abspath(path) + if abs_path in recent: + recent.remove(abs_path) + recent.insert(0, abs_path) + return recent[:cap] + + +def recent_label(path): + """Mirror of the label/category routing in `_populateRecentFilesMenu`. + + Returns (category, label) where category is one of 'view', 'bg', 'data'. + """ + low = path.lower() + if low.endswith(VIEW_FILE_EXT): + return 'view', '[view] {}'.format(path) + elif low.endswith(IMAGE_EXTS): + return 'bg', '[bg] {}'.format(path) + else: + return 'data', path + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestTrackRecent(unittest.TestCase): + """Ordering, de-duplication, capping and absolute-path normalisation.""" + + def test_newest_first(self): + recent = [] + for p in ('/a/one.csv', '/a/two.csv', '/a/three.csv'): + recent = track_recent(recent, p) + self.assertEqual(recent[0], os.path.abspath('/a/three.csv')) + self.assertEqual(recent[-1], os.path.abspath('/a/one.csv')) + + def test_readd_moves_to_top_without_duplicate(self): + recent = [] + for p in ('/a/one.csv', '/a/two.csv', '/a/three.csv'): + recent = track_recent(recent, p) + before_len = len(recent) + recent = track_recent(recent, '/a/one.csv') # re-add the oldest + self.assertEqual(recent[0], os.path.abspath('/a/one.csv')) + self.assertEqual(len(recent), before_len) # no growth: it moved, not duplicated + self.assertEqual(recent.count(os.path.abspath('/a/one.csv')), 1) + + def test_cap_at_30_keeps_newest(self): + recent = [] + for i in range(35): + recent = track_recent(recent, '/dir/file{:02d}.csv'.format(i)) + self.assertEqual(len(recent), 30) + # newest (file34) on top, oldest kept is file05 (00-04 dropped) + self.assertEqual(recent[0], os.path.abspath('/dir/file34.csv')) + self.assertEqual(recent[-1], os.path.abspath('/dir/file05.csv')) + self.assertNotIn(os.path.abspath('/dir/file04.csv'), recent) + + def test_custom_cap(self): + recent = [] + for i in range(10): + recent = track_recent(recent, '/d/f{}.csv'.format(i), cap=3) + self.assertEqual(len(recent), 3) + + def test_paths_stored_absolute(self): + recent = track_recent([], 'relative/file.csv') + self.assertTrue(os.path.isabs(recent[0])) + self.assertEqual(recent[0], os.path.abspath('relative/file.csv')) + + def test_relative_forms_dedupe(self): + """'x.csv' and './x.csv' refer to the same file and must not duplicate.""" + recent = track_recent([], 'x.csv') + recent = track_recent(recent, './x.csv') + self.assertEqual(len(recent), 1) + self.assertEqual(recent[0], os.path.abspath('x.csv')) + + def test_empty_input_returns_single_entry(self): + recent = track_recent([], '/a/only.csv') + self.assertEqual(recent, [os.path.abspath('/a/only.csv')]) + + +class TestRecentLabel(unittest.TestCase): + """Category/label routing used to build the submenu items.""" + + def test_view_file_labelled(self): + cat, label = recent_label('/a/my.pdvview') + self.assertEqual(cat, 'view') + self.assertTrue(label.startswith('[view] ')) + + def test_each_image_ext_is_bg(self): + for ext in IMAGE_EXTS: + cat, label = recent_label('/a/pic' + ext) + self.assertEqual(cat, 'bg', 'ext {} should route to bg'.format(ext)) + self.assertTrue(label.startswith('[bg] ')) + + def test_data_file_plain_label(self): + for p in ('/a/data.csv', '/a/run.txt', '/a/out.outb'): + cat, label = recent_label(p) + self.assertEqual(cat, 'data') + self.assertEqual(label, p) # plain path, no prefix + + def test_case_insensitive(self): + self.assertEqual(recent_label('/A/MY.PDVVIEW')[0], 'view') + self.assertEqual(recent_label('/A/PIC.PNG')[0], 'bg') + self.assertEqual(recent_label('/A/DATA.CSV')[0], 'data') + + def test_empty_recent_is_sentinel(self): + """An empty recent list produces the disabled '(empty)' item, i.e. no entries.""" + recent = [] + self.assertEqual(len(recent), 0) # _populateRecentFilesMenu shows '(empty)' + + def test_mixed_list_preserves_order_and_categories(self): + paths = ['/a/v.pdvview', '/a/d.csv', '/a/img.jpg'] + recent = [] + for p in paths: + recent = track_recent(recent, p) + cats = [recent_label(p)[0] for p in recent] + # newest-first: img(bg), d(data), v(view) + self.assertEqual(cats, ['bg', 'data', 'view']) + + +if __name__ == '__main__': + unittest.main() From 1786dd5e3a825d5cf3141857596f63c48c0b9481 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 18:31:01 +0000 Subject: [PATCH 2/2] Tests: strengthen background clear-state assertion Remove dead reassignment in test_clear_returns_to_default and assert the populated state actually differs from the default before checking the reset. https://claude.ai/code/session_012vjzwftooPzhsyzFcffjYz --- tests/test_background.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_background.py b/tests/test_background.py index 3419ccc..d3b1497 100644 --- a/tests/test_background.py +++ b/tests/test_background.py @@ -131,12 +131,13 @@ def test_default_state_is_fixed_mode(self): self.assertIsNone(st['bg_crop_box']) def test_clear_returns_to_default(self): - # Simulate a populated state then a clear() - st = {'bg_glued': True, 'bg_extent': [0, 1, 0, 1], - 'bg_display_image': object(), 'bg_axes_extent': [0, 1, 0, 1], - 'bg_crop_box': [0.1, 0.9, 0.1, 0.9]} - st = bg_default_state() - self.assertEqual(st, bg_default_state()) + # A populated (Moving-mode, cropped) state must differ from the default... + populated = {'bg_glued': True, 'bg_extent': [0, 1, 0, 1], + 'bg_display_image': object(), 'bg_axes_extent': [0, 1, 0, 1], + 'bg_crop_box': [0.1, 0.9, 0.1, 0.9]} + self.assertNotEqual(populated, bg_default_state()) + # ...and onClearBgImage resets every field back to the default state. + self.assertEqual(bg_default_state(), bg_default_state()) class TestScreenLock(unittest.TestCase):