From c9a9c9d25cf88be0340578dd89bf9b7298e391d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Sat, 25 Jul 2026 06:49:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(viewer):=20channel=5Fscope=20=E2=80=94=20a?= =?UTF-8?q?=20panel=20that=20actually=20owns=20its=20channels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `initial_channels` only ever seeded a selection, so a per-electrode-grid panel was scoped in appearance only: All enabled every channel in the stream, [Edit…] listed every grid, and the count read N/320 rather than N/64. `channel_scope` is the hard restriction — the columns a panel may EVER show. It bounds the seed, All/None/Invert, the N/total count, the grid selector, shift-click ranges and rubber-band drags, and it forms part of the selection cache key so a differently-scoped viewer sharing a widget_id cannot inherit another's selection. `None` stays unrestricted, so nothing changes for a single-panel viewer. Codex reviewed the plan and blocked it; each of its findings was verified in source before being accepted, and four were real: - resolve_enabled returns early on an unchanged key, so enforcing the scope only while seeding/restoring would let anything that mutated v.channels in between escape it. It now clamps on the fast path too. - Invert went through reduce_selection's XOR, which PRESERVES out-of-scope members (they aren't in the target set). It is now a complement within scope. - Shift-click selected a raw numeric range, so a sparse scope {0,2,4} shift-clicked 0->4 pulled in 1 and 3. The span is filtered through the allowed set. - An active-key change reset last_clicked but not the armed drag, so a drag spanning a stream/scope switch applied a stale snapshot. Also from that review: an explicit scope that matches nothing now renders "no channels in scope" instead of falling back to every channel — a silent widening would defeat the point of scoping a panel. resolve_initial keeps its signature and takes a keyword-only scope, so the policy runs *inside* the scope; without that, a 256..319 scope would seed "the first 16 of 320" and intersect to the empty set, leaving a dead panel. Out-of-scope grid cells are nulled rather than dropped, which preserves a grid's shape and — since rect_to_channels skips None — makes drag selection incapable of reaching outside the scope. Scoped columns no grid covers are collected into a trailing "other" grid so everything All can select is also individually toggleable. The constructor snapshots the scope, since an Iterable may be a generator that per-frame resolution would exhaust. --- CHANGELOG.md | 10 ++++ myogestic/widgets/signals/_channel_grid.py | 67 ++++++++++++++++++++-- myogestic/widgets/signals/_controls.py | 53 +++++++++++++---- myogestic/widgets/signals/_state.py | 26 +++++++-- myogestic/widgets/signals/viewer.py | 35 +++++++++-- tests/test_channel_grid.py | 58 +++++++++++++++++++ tests/test_signal_viewer_decimation.py | 53 +++++++++++++++++ tests/test_viz.py | 13 +++++ 8 files changed, 291 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0592f3d..0465bce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Signal viewer — `channel_scope` restricts a panel to its own channels.** `initial_channels` + only ever *seeded* a selection, so a per-electrode-grid panel stayed scoped only until the user + touched it: **All** enabled every channel in the stream, `[Edit…]` listed every grid, and the + count read `N/320` instead of `N/64`. `SignalViewer(channel_scope=…)` is the hard restriction — + the columns a panel may *ever* show. It bounds the seed, All/None/Invert, the count, the grid + selector, shift-click ranges and rubber-band drags, and forms part of the selection cache key. + `None` (default) is unrestricted; an explicit scope matching no valid column renders + "no channels in scope" rather than silently widening back to the whole stream. Note it also + drives the default selection, so a 64-channel scope opens on its first 16 unless + `initial_channels` is passed too. - **Signal viewer — several viewers can share one stream.** New `SignalViewer(widget_id=…, title=…, show_controls=…)`. State and ImGui ids now key off `widget_id` (defaulting to `stream_name`) instead of the stream, so N viewers can show one stream through N panels — e.g. diff --git a/myogestic/widgets/signals/_channel_grid.py b/myogestic/widgets/signals/_channel_grid.py index c83de97..850e1db 100644 --- a/myogestic/widgets/signals/_channel_grid.py +++ b/myogestic/widgets/signals/_channel_grid.py @@ -86,7 +86,26 @@ def _null_invalid_cells(cells: list[list[int | None]], n_channels: int) -> list[ return result -def normalize_layout(channel_grids: list[ChannelGrid] | None, n_channels: int) -> list[ChannelGrid]: +def resolve_scope(channel_scope: Iterable[int] | None, n_channels: int) -> list[int]: + """Resolve the columns a viewer may **ever** show, in caller order. + + ``None`` means unrestricted — every channel. An explicit iterable is + clamped to ``[0, n_channels)`` and de-duplicated (first-seen wins), and + **may resolve to empty**: an explicit constraint that matches nothing is + honoured rather than quietly widened back to the whole stream, which would + defeat the point of scoping a panel. Order is preserved because the default + selection policy takes a prefix of it (see [`resolve_initial`][]). + """ + if channel_scope is None: + return list(range(n_channels)) + return _dedupe_in_range(list(channel_scope), n_channels) + + +def normalize_layout( + channel_grids: list[ChannelGrid] | None, + n_channels: int, + scope: list[int] | None = None, +) -> list[ChannelGrid]: """Validate `channel_grids` against `n_channels`, never raising. Out-of-range and duplicate column indices are dropped (first-seen wins). @@ -96,22 +115,47 @@ def normalize_layout(channel_grids: list[ChannelGrid] | None, n_channels: int) - valid columns are dropped entirely. If nothing survives — `channel_grids` is ``None``/empty, or every grid was fully invalid — falls back to a single auto-shaped grid labeled ``"all"`` spanning every channel. + + `scope` (from [`resolve_scope`][]) restricts the layout to a viewer's own + columns: out-of-scope cells are **nulled, not dropped**, so a rectangular + grid keeps its physical shape and — because [`rect_to_channels`][] skips + ``None`` — a rubber-band drag cannot reach outside the scope. Grids left + empty are removed, and any scoped column no grid covers is collected into a + trailing auto-shaped ``"other"`` grid, so everything **All** can select is + also individually toggleable. ``None`` leaves the layout unrestricted. """ - fallback = [ChannelGrid("all", auto_shape(list(range(n_channels))))] + fallback_columns = list(range(n_channels)) if scope is None else list(scope) + fallback = [ChannelGrid("all", auto_shape(fallback_columns))] if fallback_columns else [] if not channel_grids: return fallback + scope_set = None if scope is None else set(scope) result: list[ChannelGrid] = [] for grid in channel_grids: valid_columns = _dedupe_in_range(grid.columns, n_channels) + if scope_set is not None: + valid_columns = [c for c in valid_columns if c in scope_set] if not valid_columns: continue if _is_rectangular(grid.cells): new_cells = _null_invalid_cells(grid.cells, n_channels) + if scope_set is not None: + new_cells = [ + [c if c is not None and c in scope_set else None for c in row] + for row in new_cells + ] else: new_cells = auto_shape(valid_columns) result.append(ChannelGrid(grid.label, new_cells)) + if scope_set is not None and result: + # Anything in scope that no surviving grid covers would be selectable via + # All yet impossible to toggle in the grid window — give it a home. + covered = {c for g in result for c in g.columns} + missing = [c for c in scope if c not in covered] + if missing: + result.append(ChannelGrid("other", auto_shape(missing))) + return result if result else fallback @@ -163,6 +207,8 @@ def resolve_initial( initial_channels: Iterable[int] | None, n_channels: int, layout: list[ChannelGrid], + *, + scope: list[int] | None = None, ) -> set[int]: """Resolve the widget's initial selection. @@ -170,10 +216,19 @@ def resolve_initial( otherwise the first ``min(n_channels, 16)``. An explicit iterable is clamped to the valid ``[0, n_channels)`` range. `layout` is accepted for interface symmetry with future policies but isn't consulted yet. + + `scope` (from [`resolve_scope`][]) makes the whole policy relative to a + viewer's own columns: the size test and the 16-channel prefix both run over + the scope, and an explicit selection is clamped to it. This has to be built + in rather than filtered afterwards — for a scope of columns 256‥319 the + unscoped policy would pick "the first 16 of 320", i.e. columns 0‥15, whose + intersection with the scope is **empty**, leaving a dead panel. """ del layout + columns = list(range(n_channels)) if scope is None else scope if initial_channels is None: - if n_channels <= 32: - return set(range(n_channels)) - return set(range(min(n_channels, 16))) - return {c for c in initial_channels if 0 <= c < n_channels} + if len(columns) <= 32: + return set(columns) + return set(columns[:16]) + allowed = set(columns) + return {c for c in initial_channels if c in allowed} diff --git a/myogestic/widgets/signals/_controls.py b/myogestic/widgets/signals/_controls.py index 55e2398..470a205 100644 --- a/myogestic/widgets/signals/_controls.py +++ b/myogestic/widgets/signals/_controls.py @@ -355,6 +355,7 @@ def render_channel_controls( stream: Stream, v: ViewerState, n_channels: int, + scope: list[int] | None = None, ) -> tuple[set[int], list[str] | None, int]: """Render the compact channel bar, and mutate `v.channels` from user input. @@ -370,13 +371,19 @@ def render_channel_controls( enabled = v.channels ch_names = stream.info.channel_names if stream.info else None channel_grids = stream.info.channel_grids if stream.info else None - layout = normalize_layout(channel_grids, n_channels) + if scope is None: + scope = list(range(n_channels)) + layout = normalize_layout(channel_grids, n_channels, scope) ui = _grid_ui.setdefault(stream_name, _GridUIState()) if ui.last_key != v.active_channels_key: ui.last_clicked = -1 + # Disarm any in-flight drag too: its snapshot and origin cell belong to + # the *previous* stream/scope, so finishing it here would apply a stale + # selection (or ch0) to the new one. + ui.drag = _DragSession() ui.last_key = v.active_channels_key - render_channel_bar(stream_name, ui, enabled, n_channels) + render_channel_bar(stream_name, ui, enabled, scope) hovered_ch = -1 if ui.show_grid: @@ -394,25 +401,32 @@ def render_channel_bar( stream_name: str, ui: _GridUIState, enabled: set[int], - n_channels: int, + scope: list[int], ) -> None: """Render the always-inline, one-line channel bar. `Channels {enabled}/{total}` + the global All/None/Invert ops (what the full-grid footer did before) + `[Edit…]`, which toggles the floating grid window (`ui.show_grid`) — the grid itself never renders inline. + + Every op is bounded by `scope` — the columns this viewer may show — so the + total, All and Invert describe the panel's own channels rather than the + whole stream's. An unscoped viewer passes every channel. """ - imgui.text(f"Channels {len(enabled)}/{n_channels}") + imgui.text(f"Channels {len(enabled)}/{len(scope)}") imgui.same_line() if imgui.small_button(f"All##{stream_name}_bar_all"): enabled.clear() - enabled.update(range(n_channels)) + enabled.update(scope) imgui.same_line() if imgui.small_button(f"None##{stream_name}_bar_none"): enabled.clear() imgui.same_line() if imgui.small_button(f"Invert##{stream_name}_bar_invert"): - new_enabled = reduce_selection(enabled, "invert", range(n_channels)) + # Complement *within the scope*, not `reduce_selection`'s XOR: XOR keeps + # any out-of-scope member of `enabled` (it isn't in the target set), so + # inverting could smuggle a foreign channel back in. + new_enabled = set(scope) - enabled enabled.clear() enabled.update(new_enabled) imgui.same_line() @@ -471,6 +485,10 @@ def render_grid_window( f"Channel selection — {stream_name}##{stream_name}_grid_window", True ) hovered_ch = -1 + # The layout is already scope-restricted by `normalize_layout`, so its own + # columns are exactly what may be selected — the bound a shift-click range + # has to respect. + allowed = {c for g in layout for c in g.columns} try: if visible: # Tile the grids near-square (n_cols per row) instead of one tall @@ -483,7 +501,7 @@ def render_grid_window( imgui.same_line(0.0, cell) # one-cell gap between grid columns imgui.begin_group() hovered_ch = render_grid( - stream_name, grid_idx, grid, enabled, ch_names, ui, cell, hovered_ch + stream_name, grid_idx, grid, enabled, ch_names, ui, cell, hovered_ch, allowed ) imgui.end_group() finally: @@ -537,8 +555,13 @@ def render_grid( ui: _GridUIState, cell: float, hovered_ch: int, + allowed: set[int] | None = None, ) -> int: - """Render one grid's header + cells; returns the updated `hovered_ch`.""" + """Render one grid's header + cells; returns the updated `hovered_ch`. + + `allowed` is forwarded to `render_cell` so a shift-click range cannot + escape this viewer's scope (``None`` = unrestricted). + """ columns = grid.columns total = len(columns) sel = sum(1 for c in columns if c in enabled) @@ -577,6 +600,7 @@ def render_grid( ui, cell, hovered_ch, + allowed, ) # Live rectangle update: recompute from the mouse-down snapshot every @@ -679,8 +703,13 @@ def render_cell( ui: _GridUIState, cell: float, hovered_ch: int, + allowed: set[int] | None = None, ) -> int: - """Render one channel cell; returns the updated `hovered_ch`.""" + """Render one channel cell; returns the updated `hovered_ch`. + + `allowed` bounds a shift-click range selection to the columns this viewer + may show (``None`` = unrestricted). + """ imgui.invisible_button(f"##{stream_name}_g{grid_idx}_cell_{ch}", imgui.ImVec2(cell, cell)) is_on = ch in enabled @@ -724,7 +753,11 @@ def render_cell( io = imgui.get_io() if io.key_shift and ui.last_clicked >= 0: lo, hi = sorted((ui.last_clicked, ch)) - new_enabled = reduce_selection(enabled, "add", range(lo, hi + 1)) + # The span is numeric, but the selection must not be: a sparse scope + # (or a grid with holes) would otherwise pick up channels between the + # endpoints that this viewer may not show at all. + span = range(lo, hi + 1) if allowed is None else [c for c in range(lo, hi + 1) if c in allowed] + new_enabled = reduce_selection(enabled, "add", span) enabled.clear() enabled.update(new_enabled) ui.drag.armed = False diff --git a/myogestic/widgets/signals/_state.py b/myogestic/widgets/signals/_state.py index d6087e4..5c0b9b3 100644 --- a/myogestic/widgets/signals/_state.py +++ b/myogestic/widgets/signals/_state.py @@ -142,7 +142,7 @@ class ViewerState: # `resolve_enabled`. `None` until the first resolve. Also read by # `_controls.py` to reset the toggle-grid's shift-click anchor on a # stream/channel-count change. - active_channels_key: tuple[str, int] | None = None + active_channels_key: tuple[str, int, tuple[int, ...] | None] | None = None # Per-`(stream_key, n_channels)` selection cache so a `selectable` # viewer that flips between streams restores each stream's own # selection instead of sharing/resetting a single one. Populated by @@ -456,6 +456,7 @@ def resolve_enabled( stream_key: str, n_channels: int, initial_channels: Iterable[int] | None = None, + scope: list[int] | None = None, ) -> set[int]: """Resolve the enabled channel set from persistent viewer state. @@ -481,9 +482,24 @@ def resolve_enabled( one-shot hint. Returns the live `v.channels` set; safe because nothing reads it again until the *next* frame, after which only `render_channel_controls` mutates it. + + `scope` (from `resolve_scope`) is the hard + restriction — the columns this viewer may *ever* show. It bounds the seed, + any restored selection, and the live set on **every** path, and it forms + part of the cache key so a differently-scoped viewer sharing a + ``widget_id`` never inherits another's selection. ``None`` is unrestricted. """ - key = (stream_key, n_channels) + # The scope fingerprint is part of the identity: state is keyed by `widget_id`, + # not by Python instance, so a re-created (or same-id, differently-scoped) + # viewer must not inherit a selection resolved under a different scope. + key = (stream_key, n_channels, None if scope is None else tuple(scope)) + scope_set = None if scope is None else set(scope) if v.channels_initialized and v.active_channels_key == key: + if scope_set is not None: + # Enforce on the fast path too: this returns before any of the seeding + # below, so intersecting only on restore would let anything that + # mutated `v.channels` in between escape the scope. + v.channels.intersection_update(scope_set) return v.channels first_ever = not v.channels_initialized @@ -492,9 +508,11 @@ def resolve_enabled( cached = v._channels_by_key.get(key) if cached is not None: - v.channels = set(cached) + v.channels = set(cached) if scope_set is None else set(cached) & scope_set else: - v.channels = resolve_initial(initial_channels if first_ever else None, n_channels, []) + v.channels = resolve_initial( + initial_channels if first_ever else None, n_channels, [], scope=scope + ) v.specs = [] v.channels_initialized = True diff --git a/myogestic/widgets/signals/viewer.py b/myogestic/widgets/signals/viewer.py index 093892d..9380f06 100644 --- a/myogestic/widgets/signals/viewer.py +++ b/myogestic/widgets/signals/viewer.py @@ -22,6 +22,7 @@ def my_ui(ctx): from imgui_bundle import imgui from myogestic.widgets.common import panel_header_button, pop_selected, push_selected +from myogestic.widgets.signals._channel_grid import resolve_scope from myogestic.widgets.signals._controls import ( render_channel_controls, render_controls, @@ -61,8 +62,20 @@ class SignalViewer: Explicit state / ImGui id scope. Defaults to ``stream_name``. Give each viewer its OWN id to show one stream through several panels (e.g. one per electrode grid) — otherwise they share a single state and render - identically. Pair with ``initial_channels`` to open each on its own - channels, and prefer a stable, unique string (grid labels can repeat). + identically. Pair with ``channel_scope`` to give each its own channels, + and prefer a stable, unique string (grid labels can repeat). + channel_scope + The columns this viewer may **ever** show — a hard restriction, unlike + ``initial_channels`` (which only seeds the opening selection). All / + None / Invert, the ``N/total`` count, the ``[Edit…]`` grid and + shift-click ranges are all bounded by it, so a per-electrode-grid panel + stays its own array however the user clicks. ``None`` (default) is + unrestricted; an explicit scope that matches no valid column renders + "no channels in scope" rather than quietly widening back to the whole + stream. Positional, so with ``selectable=True`` it is re-applied + (clamped) to whichever stream is shown. Note it also drives the default + selection: a 64-channel scope opens on its first 16 unless you pass + ``initial_channels`` too. title Panel header text. Defaults to ``"SIGNAL · "`` — set it per panel when several viewers share a stream, or every tile reads alike. @@ -118,8 +131,13 @@ def __init__( widget_id: str | None = None, title: str | None = None, show_controls: bool = True, + channel_scope: Iterable[int] | None = None, ) -> None: self._stream_name = stream_name + # Snapshot the scope: `Iterable` admits a generator, and this is re-read + # every frame — a lazy one would be exhausted after the first. Order is + # kept because the default selection takes a prefix of it. + self._channel_scope = None if channel_scope is None else tuple(channel_scope) self._widget_id = widget_id self._title = title self._show_controls = show_controls @@ -191,7 +209,16 @@ def ui(self, ctx: Context) -> None: # building the frame, so the frame's column slice and the plot loop's # per-channel decimation only ever touch those columns. n_channels = stream.info.n_channels - enabled = resolve_enabled(v, active_stream, n_channels, self._initial_channels) + # The scope is positional (column indices), so it applies to whichever + # stream is shown, clamped to that stream's width — never silently + # relaxed, which would break the "may ever show" contract. + scope = resolve_scope(self._channel_scope, n_channels) + if not scope: + imgui.text_disabled( + f"{active_stream}: no channels in scope (stream has {n_channels})" + ) + return + enabled = resolve_enabled(v, active_stream, n_channels, self._initial_channels, scope) # Channel bar at the top, above the plot. It reads/mutates `v.channels` # (the same set `enabled` points at) and reports grid-hover — all @@ -200,7 +227,7 @@ def ui(self, ctx: Context) -> None: # chrome; nothing can be hovered then, so the highlight resets to -1. hovered_ch = -1 if v.show_controls: - _, _, hovered_ch = render_channel_controls(wid, stream, v, n_channels) + _, _, hovered_ch = render_channel_controls(wid, stream, v, n_channels, scope) v.last_hovered = hovered_ch frame = build_signal_frame(stream, v, enabled) diff --git a/tests/test_channel_grid.py b/tests/test_channel_grid.py index c6eaff8..659fff5 100644 --- a/tests/test_channel_grid.py +++ b/tests/test_channel_grid.py @@ -13,6 +13,7 @@ rect_to_channels, reduce_selection, resolve_initial, + resolve_scope, ) from myogestic.widgets.signals._controls import ( _finalize_drag, @@ -377,3 +378,60 @@ def test_grid_drag_rectangle_works_when_nested_inside_another_window(imgui_ctx): _draw_nested_grid_frame(grid, enabled, ui, cell, stream_id) _finalize_drag(ui, enabled) assert enabled == {0, 1, 3, 4} + + +# --- channel_scope: a viewer's hard restriction ----------------------------------- + + +def test_resolve_scope_none_is_unrestricted_explicit_is_honoured(): + assert resolve_scope(None, 4) == [0, 1, 2, 3] + assert resolve_scope([2, 0, 2, 9], 4) == [2, 0] # clamped, deduped, ORDER kept + # An explicit scope that matches nothing stays empty — it must NOT quietly + # widen back to the whole stream, which would defeat scoping a panel. + assert resolve_scope([99, -1], 4) == [] + assert resolve_scope([], 4) == [] + + +def test_resolve_initial_policy_runs_inside_the_scope(): + """The high-offset trap: an unscoped policy picks 'first 16 of 320' = 0..15, + which for a 256..319 scope intersects to NOTHING — a dead panel.""" + scope = list(range(256, 320)) # e.g. electrode grid IN5 of a 320-ch stream + assert resolve_initial(None, 320, [], scope=scope) == set(range(256, 272)) + + small = list(range(256, 276)) # <= 32 columns -> all of them + assert resolve_initial(None, 320, [], scope=small) == set(small) + + # An explicit selection is clamped to the scope, not merely to the stream. + assert resolve_initial([256, 300, 5], 320, [], scope=scope) == {256, 300} + # Unscoped behaviour is unchanged. + assert resolve_initial(None, 320, []) == set(range(16)) + + +def test_normalize_layout_scope_nulls_cells_and_keeps_shape(): + """Out-of-scope cells are NULLED, not dropped: a rectangular grid keeps its + physical shape, and `rect_to_channels` skips None, so a rubber-band drag + physically cannot select an out-of-scope channel.""" + grid = ChannelGrid("g", [[0, 1], [2, 3]]) + layout = normalize_layout([grid], 4, scope=[0, 3]) + + assert layout[0].cells == [[0, None], [None, 3]] # shape preserved + assert sorted(layout[0].columns) == [0, 3] + # A drag over the whole grid can only ever reach in-scope channels. + assert rect_to_channels(layout[0], 0, 0, 1, 1) == {0, 3} + + +def test_normalize_layout_scope_drops_empty_grids_and_collects_the_remainder(): + grids = [ChannelGrid("A", [[0, 1]]), ChannelGrid("B", [[2, 3]])] + layout = normalize_layout(grids, 8, scope=[0, 1, 7]) + + labels = [g.label for g in layout] + assert "B" in labels or "B" not in labels # B has no in-scope column -> dropped + assert labels == ["A", "other"] + # Channel 7 is in scope but covered by no grid; without the "other" grid it + # would be selectable via All yet impossible to toggle individually. + assert sorted(c for g in layout for c in g.columns) == [0, 1, 7] + + +def test_normalize_layout_unscoped_is_unchanged(): + grid = ChannelGrid("g", [[0, 1], [2, 3]]) + assert normalize_layout([grid], 4) == normalize_layout([grid], 4, scope=None) diff --git a/tests/test_signal_viewer_decimation.py b/tests/test_signal_viewer_decimation.py index 22c62c6..f2e8e33 100644 --- a/tests/test_signal_viewer_decimation.py +++ b/tests/test_signal_viewer_decimation.py @@ -808,3 +808,56 @@ def test_build_signal_frame_notch_attenuates_hum_end_to_end(): f_on = build_signal_frame(stream, v_on, {0, 1}) assert f_on is not None and np.all(np.isfinite(f_on.data)) assert _rms(f_on.data[len(f_on.data) // 2 :]) < 0.6 * _rms(f_off.data[settled]) + + +# --- channel_scope enforcement in resolve_enabled -------------------------------- + + +def test_scope_bounds_the_seed_and_survives_the_fast_path(): + """Scope must hold on EVERY path. + + `resolve_enabled` returns early when the key is unchanged, so enforcing the + scope only while seeding/restoring would let anything that mutated + `v.channels` in between (a click, a stale set) escape it. + """ + v = ViewerState() + scope = list(range(256, 320)) # electrode grid IN5 of a 320-ch stream + + enabled = resolve_enabled(v, "emg", 320, scope=scope) + assert enabled # NOT empty: the policy runs inside the scope + assert enabled <= set(scope) + + # Simulate something slipping a foreign channel in, then re-resolve on the + # unchanged key — the fast path must still clamp it. + v.channels.add(0) + again = resolve_enabled(v, "emg", 320, scope=scope) + assert 0 not in again + assert again <= set(scope) + + +def test_scope_is_part_of_the_selection_cache_key(): + """State is keyed by widget_id, not by Python instance, so a differently + scoped viewer reusing an id must not inherit the other's selection.""" + v = ViewerState() + a = set(resolve_enabled(v, "emg", 320, scope=list(range(0, 64)))) + b = set(resolve_enabled(v, "emg", 320, scope=list(range(256, 320)))) + + assert a <= set(range(0, 64)) + assert b <= set(range(256, 320)) + assert not (b & a) + + +def test_explicit_initial_channels_are_clamped_to_scope(): + v = ViewerState() + enabled = resolve_enabled( + v, "emg", 320, initial_channels=[0, 257, 300], scope=list(range(256, 320)) + ) + assert enabled == {257, 300} # 0 is outside the scope + + +def test_unscoped_resolve_enabled_is_unchanged(): + """Regression guard: `scope=None` must behave exactly as before.""" + a = resolve_enabled(ViewerState(), "emg", 8) + b = resolve_enabled(ViewerState(), "emg", 8, scope=None) + assert a == b == set(range(8)) + assert resolve_enabled(ViewerState(), "emg", 320) == set(range(16)) diff --git a/tests/test_viz.py b/tests/test_viz.py index 36ca72f..f48d105 100644 --- a/tests/test_viz.py +++ b/tests/test_viz.py @@ -284,3 +284,16 @@ def test_session_manager_dedups_same_session_across_path_spellings(tmp_path): # the dialog returns the same file via a non-canonical spelling load_session_files(st, [str(tmp_path / "real" / ".." / "real" / "s.session.zip")]) assert len(st.sessions) == 1 # deduped, not doubled + + +def test_channel_scope_is_frozen_at_construction(): + """`Iterable[int]` admits a generator, and the scope is re-read every frame + — a lazy one would be exhausted after the first, silently emptying the + panel. It must be snapshotted (and keep its order, which the default + selection takes a prefix of).""" + gen = (c for c in [5, 1, 5, 3]) + viewer = SignalViewer("emg", channel_scope=gen) + + assert viewer._channel_scope == (5, 1, 5, 3) # materialised, order kept + assert viewer._channel_scope == (5, 1, 5, 3) # still there on the next frame + assert SignalViewer("emg")._channel_scope is None # unrestricted default