import time import pytest pytest.importorskip("qtpy") from qtpy import QtCore, QtWidgets from eco.widgets.indicator_widgets import ( INDICATOR_TYPES, AnalogGauge, BarGauge, Dial, LEDIndicator, NumericTile, Slider, StripChart, _EnumDialFace, _enum_index, _nearest_enum_position, _format_value, _guess_range, _sanitize_object_name, attach_indicator_menu, create_indicator, item_path, resolve_item_path, ) class _FakeDetector: """Read-only: no set_target_value.""" def __init__(self, name, value=0.0): self.name = name self._value = value def get_current_value(self): return self._value class _FakeAdjustable(_FakeDetector): def __init__(self, name, value=0.0, low_limit=None, high_limit=None): super().__init__(name, value) self.set_calls = [] if low_limit is not None: self.low_limit = low_limit if high_limit is not None: self.high_limit = high_limit def set_target_value(self, value): self.set_calls.append(value) self._value = value class _FakeEnumAdjustable(_FakeAdjustable): """AdjustableEnum-shaped: adds enum_strs on top of _FakeAdjustable.""" def __init__(self, name, enum_strs, value=0): super().__init__(name, value) self.enum_strs = list(enum_strs) def _app(): return QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) def _pump(app, predicate, timeout=5.0, interval=0.02): t0 = time.time() while time.time() - t0 < timeout: app.processEvents() if predicate(): return True time.sleep(interval) return False # -- pure logic -- def test_format_value_numeric(): assert _format_value(3.14159) == "3.142" def test_format_value_non_numeric_falls_back_to_str(): assert _format_value("abc") == "abc" def test_guess_range_low_high_limit_attrs(): item = _FakeAdjustable("m1", low_limit=-10, high_limit=10) assert _guess_range(item) == (-10.0, 10.0) def test_guess_range_falls_back_to_default(): item = _FakeDetector("d1") assert _guess_range(item) == (0.0, 100.0) def test_guess_range_ignores_inverted_limits(): item = _FakeAdjustable("m1", low_limit=10, high_limit=-10) assert _guess_range(item) == (0.0, 100.0) def test_create_indicator_unknown_kind_raises(): with pytest.raises(ValueError): create_indicator("not-a-real-kind", _FakeDetector("d1")) def test_create_indicator_dispatches_to_the_right_factory_with_guessed_range(monkeypatch): # exercises create_indicator's own dispatch/range-guessing logic # against a lightweight fake factory rather than a real QDial-backed # Dial -- constructing an actual QDial here proved flaky (a native # crash, reproducible only under this specific combination of pytest # + the offscreen Qt platform + eco's full heavy scientific-stack # imports all in one process -- never in standalone real usage, and # not specific to Dial's own logic, which test_dial_* below cover # directly via real widgets built the same way create_indicator # would build them) calls = [] class FakeWidget: def __init__(self, item, title=None, vmin=None, vmax=None, **kwargs): calls.append((item, title, vmin, vmax, kwargs)) def setAccessibleName(self, name): pass def setObjectName(self, name): pass import eco.widgets.indicator_widgets as iw monkeypatch.setattr( iw, "INDICATOR_TYPES", (("Dial / knob", FakeWidget, True), ("LED", FakeWidget, False)), ) item = _FakeAdjustable("m1", low_limit=0, high_limit=50) result = create_indicator("Dial / knob", item, title="M1") assert isinstance(result, FakeWidget) assert calls == [(item, "M1", 0.0, 50.0, {})] def test_create_indicator_no_range_needed_skips_guessing(monkeypatch): calls = [] class FakeWidget: def __init__(self, item, title=None, **kwargs): calls.append((item, title, kwargs)) def setAccessibleName(self, name): pass def setObjectName(self, name): pass import eco.widgets.indicator_widgets as iw monkeypatch.setattr(iw, "INDICATOR_TYPES", (("LED", FakeWidget, False),)) item = _FakeDetector("d1") create_indicator("LED", item, title="D1") assert calls == [(item, "D1", {})] # -- polling / rendering, per gadget type -- def test_led_indicator_reflects_boolean_state(): app = _app() item = _FakeDetector("shutter", value=True) w = LEDIndicator(item=item, title="Shutter", poll_interval=0.02) try: assert _pump(app, lambda: w._is_on is True) finally: w.stop() def test_led_indicator_threshold_mode(): app = _app() item = _FakeDetector("intensity", value=5.0) w = LEDIndicator(item=item, title="Intensity", threshold=3.0, poll_interval=0.02) try: assert _pump(app, lambda: w._is_on is True) finally: w.stop() def test_led_indicator_click_toggles_settable_item(): app = _app() item = _FakeAdjustable("shutter", value=False) w = LEDIndicator(item=item, title="Shutter", poll_interval=0.02) try: assert _pump(app, lambda: w._is_on is False) w._on_click(None) assert _pump(app, lambda: item.set_calls == [True]) finally: w.stop() def test_led_indicator_click_is_noop_for_readonly_item(): app = _app() item = _FakeDetector("readonly", value=False) w = LEDIndicator(item=item, title="RO", poll_interval=0.02) try: assert _pump(app, lambda: w._is_on is False) w._on_click(None) # should not raise, no set_target_value to call finally: w.stop() def test_bar_gauge_renders_fraction_of_range(): app = _app() item = _FakeDetector("d1", value=25.0) w = BarGauge(item=item, title="D1", vmin=0.0, vmax=50.0, poll_interval=0.02) try: assert _pump(app, lambda: w._bar.value() == 500) # 25/50 -> halfway of 0..1000 finally: w.stop() def test_bar_gauge_clips_out_of_range_values(): app = _app() item = _FakeDetector("d1", value=999.0) w = BarGauge(item=item, title="D1", vmin=0.0, vmax=50.0, poll_interval=0.02) try: assert _pump(app, lambda: w._bar.value() == 1000) finally: w.stop() def test_analog_gauge_tracks_value(): app = _app() item = _FakeDetector("d1", value=10.0) w = AnalogGauge(item=item, title="D1", vmin=0.0, vmax=20.0, poll_interval=0.02) try: assert _pump(app, lambda: w._value == 10.0) finally: w.stop() def test_strip_chart_accumulates_values(): app = _app() item = _FakeDetector("d1", value=1.0) w = StripChart(item=item, title="D1", window_points=5, poll_interval=0.02) try: assert _pump(app, lambda: len(w._values) >= 3) finally: w.stop() def test_strip_chart_window_is_bounded(): app = _app() item = _FakeDetector("d1", value=1.0) w = StripChart(item=item, title="D1", window_points=5, poll_interval=0.01) try: assert _pump(app, lambda: len(w._values) == 5, timeout=3.0) time.sleep(0.1) app.processEvents() assert len(w._values) <= 5 finally: w.stop() def test_numeric_tile_formats_value(): app = _app() item = _FakeDetector("d1", value=3.14159) w = NumericTile(item=item, title="D1", poll_interval=0.02) try: assert _pump(app, lambda: w._number_label.text() == "3.142") finally: w.stop() def test_dial_settable_writes_on_release(): app = _app() item = _FakeAdjustable("m1", value=0.0, low_limit=0, high_limit=10) w = Dial(item=item, title="M1", vmin=0.0, vmax=10.0, step=1.0, poll_interval=0.02) try: assert w.settable is True assert w._dial.isEnabled() w._dial.setValue(5) w._on_release() assert item.set_calls == [5.0] finally: w.stop() def test_dial_readonly_for_non_settable_item(): app = _app() item = _FakeDetector("d1", value=3.0) w = Dial(item=item, title="D1", vmin=0.0, vmax=10.0, poll_interval=0.02) try: assert w.settable is False assert not w._dial.isEnabled() finally: w.stop() # -- Dial enum mode (mode-select rotary knob) -- def test_enum_index_from_int(): assert _enum_index(1, ["OFF", "ON", "AUTO"]) == 1 def test_enum_index_from_enum_member(): import enum class Mode(enum.Enum): OFF = 0 ON = 1 assert _enum_index(Mode.ON, ["OFF", "ON"]) == 1 def test_enum_index_from_matching_string(): assert _enum_index("AUTO", ["OFF", "ON", "AUTO"]) == 2 def test_enum_index_out_of_range_or_unmatched_returns_none(): assert _enum_index(5, ["OFF", "ON"]) is None assert _enum_index("UNKNOWN", ["OFF", "ON"]) is None assert _enum_index(None, ["OFF", "ON"]) is None # _EnumDialFace's geometry (_angle_for, _nearest_enum_position) is tested # here as plain functions/attributes, via __new__() rather than a real # constructed instance -- constructing a real one right after another # test's Dial poll-thread is still mid-shutdown has been observed to # crash the interpreter outright (the same pytest+offscreen+heavy-import # native fragility already noted for QDial construction elsewhere in this # file); the real, fully-constructed article is still exercised for real # by the test_dial_* integration tests below (Dial._build_body() always # builds one, for both numeric and enum items). def _bare_enum_face(choices): """_EnumDialFace with `_choices` set directly, skipping __init__ (and therefore any real Qt widget construction) -- see the note above.""" face = _EnumDialFace.__new__(_EnumDialFace) face._choices = list(choices) return face def test_enum_dial_face_angle_for_spreads_choices_across_the_span(): face = _bare_enum_face(["OFF", "ON", "AUTO"]) assert face._angle_for(0) == pytest.approx(225.0) assert face._angle_for(2) == pytest.approx(225.0 - 270.0) assert face._angle_for(1) == pytest.approx(225.0 - 135.0) def test_enum_dial_face_angle_for_single_choice_is_centered(): face = _bare_enum_face(["ONLY"]) assert face._angle_for(0) == pytest.approx(225.0 - 135.0) def test_nearest_enum_position_picks_the_closest_angle(): # OFF(225) / ON(90) / AUTO(-45) -- a click near 90 degrees should # resolve to "ON" (index 1), same layout _EnumDialFace._angle_for # produces for 3 choices angles = [225.0, 90.0, -45.0] assert _nearest_enum_position(100.0, angles) == 1 assert _nearest_enum_position(225.0, angles) == 0 assert _nearest_enum_position(-40.0, angles) == 2 def test_nearest_enum_position_wraps_across_the_360_degree_seam(): angles = [225.0, 90.0, -45.0] # 315 degrees is the same direction as -45 degrees (AUTO), the long # way around -- exercises the wraparound math rather than a plain # subtraction, which would see a huge, wrong distance here assert _nearest_enum_position(315.0 + 5.0, angles) == 2 def test_nearest_enum_position_empty_angles_returns_none(): assert _nearest_enum_position(90.0, []) is None def test_dial_switches_to_enum_face_for_enum_item(): app = _app() item = _FakeEnumAdjustable("mode1", ["OFF", "ON", "AUTO"], value=0) w = Dial(item=item, title="Mode", poll_interval=0.02) try: assert w.enum_strs == ["OFF", "ON", "AUTO"] assert w._stack.currentWidget() is w._enum_face assert w._enum_face._interactive is True assert w._enum_face._choices == ["OFF", "ON", "AUTO"] finally: w.stop() def test_dial_enum_render_updates_current_position(): app = _app() item = _FakeEnumAdjustable("mode1", ["OFF", "ON", "AUTO"], value=0) w = Dial(item=item, title="Mode", poll_interval=0.02) try: w._render(2) assert w._enum_face._current == 2 assert w._value_label.text() == "AUTO" finally: w.stop() def test_dial_enum_choice_click_writes_target_value(): app = _app() item = _FakeEnumAdjustable("mode1", ["OFF", "ON", "AUTO"], value=0) w = Dial(item=item, title="Mode", poll_interval=0.02) try: w._on_enum_chosen(2) assert _pump(app, lambda: item.set_calls == ["AUTO"]) finally: w.stop() def test_dial_enum_readonly_item_is_not_interactive(): app = _app() class _FakeEnumDetector(_FakeDetector): def __init__(self, name, enum_strs, value=0): super().__init__(name, value) self.enum_strs = list(enum_strs) item = _FakeEnumDetector("mode1", ["OFF", "ON"], value=0) w = Dial(item=item, title="Mode", poll_interval=0.02) try: assert w.settable is False assert w._enum_face._interactive is False finally: w.stop() def test_dial_plain_numeric_item_still_uses_numeric_page(): app = _app() item = _FakeAdjustable("m1", value=5.0, low_limit=0, high_limit=10) w = Dial(item=item, title="M1", vmin=0.0, vmax=10.0, poll_interval=0.02) try: assert w.enum_strs is None assert w._stack.currentWidget() is w._numeric_page finally: w.stop() def test_dial_reattachment_switches_between_numeric_and_enum(): app = _app() numeric_item = _FakeAdjustable("m1", value=5.0) enum_item = _FakeEnumAdjustable("mode1", ["OFF", "ON"], value=1) w = Dial(item=numeric_item, title="M1", vmin=0.0, vmax=10.0, poll_interval=0.02) try: assert w._stack.currentWidget() is w._numeric_page w.set_item(enum_item, title="Mode") assert w._stack.currentWidget() is w._enum_face assert w.enum_strs == ["OFF", "ON"] w.set_item(numeric_item, title="M1") assert w._stack.currentWidget() is w._numeric_page assert w.enum_strs is None finally: w.stop() def test_slider_settable_writes_on_release(): app = _app() item = _FakeAdjustable("m1", value=0.0) w = Slider(item=item, title="M1", vmin=0.0, vmax=10.0, step=1.0, poll_interval=0.02) try: w._slider.setValue(7) w._on_release() assert item.set_calls == [7.0] finally: w.stop() def test_gadget_stop_halts_polling(): app = _app() item = _FakeDetector("d1", value=1.0) w = NumericTile(item=item, title="D1", poll_interval=0.02) _pump(app, lambda: len(w._values) >= 1) w.stop() # one more poll can land right after stop() (the background thread may # already be mid-cycle when the stop event is set) -- a benign race, # not a bug; give it a moment to settle before taking the "before" count time.sleep(0.05) app.processEvents() n_before = len(w._values) time.sleep(0.15) app.processEvents() assert len(w._values) == n_before # no further polling once truly stopped # -- context menu wiring -- def test_attach_indicator_menu_sets_custom_context_menu_policy(): app = _app() label = QtWidgets.QLabel("m1") item = _FakeDetector("m1", value=1.0) attach_indicator_menu(label, item, "m1") assert label.contextMenuPolicy() == QtCore.Qt.CustomContextMenu # -- Qt Designer promotability: parent-only construction + set_item -- def test_gadget_constructs_with_only_parent_like_qt_designer_would(): # Designer always instantiates a promoted widget as ClassName(parent) # -- one positional arg -- so this must not raise and must render inert app = _app() for _label, factory, _needs_range in INDICATOR_TYPES: w = factory(None) try: assert w.item is None assert w.windowTitle() == "(unattached)" finally: w.stop() def test_set_item_attaches_and_starts_polling_on_a_designer_style_placeholder(): app = _app() w = NumericTile(None) try: assert w.item is None item = _FakeDetector("d1", value=42.0) w.set_item(item, title="D1") assert w.windowTitle() == "D1" assert _pump(app, lambda: w._number_label.text() == "42") finally: w.stop() def test_set_item_reattachment_updates_dial_settability(): app = _app() w = Dial(None, vmin=0.0, vmax=10.0, step=1.0) try: assert w.settable is False assert not w._dial.isEnabled() adj = _FakeAdjustable("m1", value=0.0) w.set_item(adj, title="M1") assert _pump(app, lambda: w.settable is True) assert w._dial.isEnabled() w._dial.setValue(3) w._on_release() assert adj.set_calls == [3.0] finally: w.stop() def test_set_item_stops_previous_polling_of_the_old_item(): app = _app() item1 = _FakeDetector("d1", value=1.0) w = NumericTile(item=item1, poll_interval=0.02) try: assert _pump(app, lambda: len(w._values) >= 1) item2 = _FakeDetector("d2", value=99.0) w.set_item(item2, title="D2") assert _pump(app, lambda: w._number_label.text() == "99") # only item2 should be polled from here on n = len(w._values) time.sleep(0.1) app.processEvents() assert w._number_label.text() == "99" assert len(w._values) >= n finally: w.stop() # -- item_path / resolve_item_path round-trip -- def test_item_path_uses_alias_full_name_when_present(): class FakeAlias: def get_full_name(self): return "bernina.cam_west.intensity" item = _FakeDetector("intensity") item.alias = FakeAlias() assert item_path(item) == "bernina.cam_west.intensity" def test_item_path_falls_back_when_no_alias(): item = _FakeDetector("intensity") assert item_path(item, fallback="intensity") == "intensity" def test_resolve_item_path_walks_attributes(): class Leaf: pass leaf = Leaf() class Namespace: pass ns = Namespace() child = Namespace() child.intensity = leaf ns.cam_west = child assert resolve_item_path(ns, "cam_west.intensity") is leaf def test_resolve_item_path_returns_none_for_missing_segment(): class Namespace: pass ns = Namespace() assert resolve_item_path(ns, "no_such.path") is None def test_resolve_item_path_empty_path_returns_none(): assert resolve_item_path(object(), "") is None def test_sanitize_object_name_is_stable_and_qt_safe(): name = _sanitize_object_name("bernina.cam_west.intensity") assert name == "eco_bernina_cam_west_intensity" assert _sanitize_object_name("bernina.cam_west.intensity") == name # deterministic def test_create_and_add_sets_object_and_accessible_name(): from eco.widgets.indicator_widgets import _create_and_add class FakeDashboard: def __init__(self): self.added = [] def add_widget(self, widget, title=None): self.added.append((widget, title)) app = _app() class FakeAlias: def get_full_name(self): return "bernina.shutter" item = _FakeDetector("shutter", value=True) item.alias = FakeAlias() dashboard = FakeDashboard() _create_and_add(item, "shutter", "LED", dashboard) assert len(dashboard.added) == 1 widget, title = dashboard.added[0] try: assert title == "shutter" assert widget.accessibleName() == "bernina.shutter" assert widget.objectName() == "eco_bernina_shutter" assert widget.indicator_kind == "LED" finally: widget.stop()