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..d3b1497 --- /dev/null +++ b/tests/test_background.py @@ -0,0 +1,208 @@ +""" +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): + # 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): + """_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()