"""eco.widgets.containers -- backend-agnostic stack()/aligned() and the four widget builders (assembly_widget/adjustable_control/ detector_indicator/viewer), plus the extraction those builders rest on (eco.widgets.display_qt._build_item_controls_qt/ eco.widgets.display_widget._build_item_controls_widget and their standalone build_adjustable_control_*/_apply_*_update siblings) -- none of which had dedicated test coverage before this module existed.""" import time import pytest pytest.importorskip("qtpy") from qtpy import QtWidgets from eco.widgets import containers class _FakeDetector: def __init__(self, name, value=1.0): self.name = name self._value = value def get_current_value(self): return self._value class _FakeAdjustable(_FakeDetector): def __init__(self, name, value=1.0): super().__init__(name, value) self.set_calls = [] def set_target_value(self, value): self.set_calls.append(value) self._value = value return None # no Changer -- exercises the hasattr(r, "wait") guards class _FakeBuiltWidget: """A minimal already-built .window/.stop() Qt-convention widget, for stack() tests that don't want to go through a real builder.""" def __init__(self): self.window = QtWidgets.QLabel("fake") self.stop_calls = 0 def stop(self): self.stop_calls += 1 # --------------------------------------------------------------------------- # # stack() / aligned() -- Qt # --------------------------------------------------------------------------- # def test_stack_qt_builds_a_window_with_every_child(): app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) a, b = _FakeBuiltWidget(), _FakeBuiltWidget() result = containers._build_qt([a, b], "vertical", "start") try: assert isinstance(result.window, QtWidgets.QWidget) assert result.window.layout().count() == 2 finally: result.stop() def test_stack_qt_stop_stops_every_child_with_a_stop_method(): app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) a, b = _FakeBuiltWidget(), _FakeBuiltWidget() result = containers._build_qt([a, b], "vertical", "start") result.stop() assert a.stop_calls == 1 assert b.stop_calls == 1 def test_stack_qt_builder_children_are_called_lazily(): app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) calls = [] def _builder(): calls.append("built") return _FakeBuiltWidget() assert calls == [] result = containers._build_qt([_builder], "vertical", "start") try: assert calls == ["built"] finally: result.stop() def test_stack_qt_per_child_alignment_override(): app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) from qtpy import QtCore plain = _FakeBuiltWidget() centered = _FakeBuiltWidget() result = containers._build_qt( [plain, containers.aligned(centered, "center")], "vertical", "left" ) try: layout = result.window.layout() assert layout.itemAt(0).alignment() == QtCore.Qt.AlignLeft assert layout.itemAt(1).alignment() == QtCore.Qt.AlignHCenter finally: result.stop() def test_stack_qt_skips_a_child_that_fits_neither_convention(): app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) result = containers._build_qt([object()], "vertical", "start") try: assert result.window.layout().count() == 0 finally: result.stop() def test_stack_rejects_bad_direction(): with pytest.raises(ValueError): containers.stack(direction="diagonal") # --------------------------------------------------------------------------- # # stack() / aligned() -- notebook (ipywidgets), forced via is_notebook() # --------------------------------------------------------------------------- # def test_stack_notebook_builds_a_box_with_every_child(monkeypatch): import ipywidgets as widgets monkeypatch.setattr("eco.utilities.utilities.is_notebook", lambda: True, raising=False) a, b = widgets.Label("a"), widgets.Label("b") box = containers.stack(a, b, direction="vertical", align="center") assert isinstance(box, widgets.VBox) assert list(box.children) == [a, b] assert box.layout.align_items == "center" def test_stack_notebook_horizontal_uses_hbox(monkeypatch): import ipywidgets as widgets monkeypatch.setattr("eco.utilities.utilities.is_notebook", lambda: True, raising=False) box = containers.stack(widgets.Label("a"), direction="horizontal") assert isinstance(box, widgets.HBox) def test_stack_notebook_per_child_alignment_override(monkeypatch): import ipywidgets as widgets monkeypatch.setattr("eco.utilities.utilities.is_notebook", lambda: True, raising=False) centered = widgets.Label("c") box = containers.stack( widgets.Label("a"), containers.aligned(centered, "right"), direction="vertical", align="left", ) assert box.layout.align_items == "flex-start" assert centered.layout.align_self == "flex-end" def test_stack_notebook_stop_stops_every_child_with_a_stop_method(monkeypatch): import ipywidgets as widgets monkeypatch.setattr("eco.utilities.utilities.is_notebook", lambda: True, raising=False) child = widgets.Label("a") stopped = [] child.stop = lambda: stopped.append(True) box = containers.stack(child) box.stop() assert stopped == [True] # --------------------------------------------------------------------------- # # the four builders -- dispatch/delegation behaviour # --------------------------------------------------------------------------- # def test_assembly_widget_uses_widget_assembly_when_present(): calls = [] class Fake: def _widget_assembly(self, **kwargs): calls.append(kwargs) return "the assembly widget" def widget(self, **kwargs): raise AssertionError("should not be called when _widget_assembly exists") result = containers.assembly_widget(Fake(), show_hidden=True)() assert result == "the assembly widget" assert calls == [{"show_hidden": True}] def test_assembly_widget_falls_back_to_widget_normal_true(): calls = [] class Fake: def widget(self, **kwargs): calls.append(kwargs) return "generic widget" result = containers.assembly_widget(Fake())() assert result == "generic widget" assert calls == [{"normal": True}] def test_viewer_calls_widget_method_if_present(): class Fake: def widget(self, **kwargs): return "custom viewer" assert containers.viewer(Fake())() == "custom viewer" def test_viewer_returns_object_as_is_without_a_widget_method(): obj = object() assert containers.viewer(obj)() is obj def test_adjustable_control_dispatches_to_qt_builder(monkeypatch): monkeypatch.setattr("eco.utilities.utilities.is_notebook", lambda: False, raising=False) calls = [] monkeypatch.setattr( "eco.widgets.display_qt.build_adjustable_control_qt", lambda item, poll_interval=1.0, title=None: calls.append( (item, poll_interval, title) ) or "qt control", raising=False, ) item = _FakeAdjustable("x") result = containers.adjustable_control(item, poll_interval=2.0, title="X")() assert result == "qt control" assert calls == [(item, 2.0, "X")] def test_adjustable_control_dispatches_to_notebook_builder(monkeypatch): monkeypatch.setattr("eco.utilities.utilities.is_notebook", lambda: True, raising=False) calls = [] monkeypatch.setattr( "eco.widgets.display_widget.build_adjustable_control_widget", lambda item, poll_interval=1.0, title=None: calls.append( (item, poll_interval, title) ) or "notebook control", raising=False, ) item = _FakeAdjustable("x") result = containers.adjustable_control(item)() assert result == "notebook control" assert calls == [(item, 1.0, None)] def test_detector_indicator_is_the_same_dispatch_as_adjustable_control(monkeypatch): monkeypatch.setattr("eco.utilities.utilities.is_notebook", lambda: True, raising=False) calls = [] monkeypatch.setattr( "eco.widgets.display_widget.build_adjustable_control_widget", lambda item, poll_interval=1.0, title=None: calls.append(item) or "w", raising=False, ) item = _FakeDetector("d") assert containers.detector_indicator(item)() == "w" assert calls == [item] # --------------------------------------------------------------------------- # # the extraction itself: eco.widgets.display_qt # --------------------------------------------------------------------------- # from eco.widgets import display_qt def test_build_item_controls_qt_read_only_detector(): app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) item = _FakeDetector("d", value=3.0) value_label = QtWidgets.QLabel("3.0") controls, combo = display_qt._build_item_controls_qt(item, value_label, 3.0) assert combo is None assert isinstance(controls, QtWidgets.QWidget) def test_build_item_controls_qt_adjustable_stop_reset(): app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) # an int value (not float) so the tweak-step arithmetic is exact -- # _default_step_for gives ints a step of 1 item = _FakeAdjustable("a", value=1) value_label = QtWidgets.QLabel("1") controls, combo = display_qt._build_item_controls_qt(item, value_label, 1) assert combo is None # not enum-like -- tweakable/plain branch buttons = controls.findChildren(QtWidgets.QPushButton) up_btn = next(b for b in buttons if b.text() == "▲") stop_btn = next(b for b in buttons if b.text() == "🛑") reset_btn = next(b for b in buttons if b.text() == "↺") up_btn.click() assert item.set_calls == [2] # base 1 + step 1 reset_btn.click() assert item.set_calls[-1] == 1 # back to the value the control opened with stop_btn.click() # no changer with .stop() attached here -- just shouldn't raise def test_apply_value_label_update_syncs_combo_selection(): app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) combo = QtWidgets.QComboBox() combo.addItems(["OPEN", "CLOSED"]) combo.setCurrentText("OPEN") value_label = QtWidgets.QLabel("OPEN") display_qt._apply_value_label_update(value_label, combo, "CLOSED") assert value_label.text() == "CLOSED" assert combo.currentText() == "CLOSED" def test_build_adjustable_control_qt_polls_and_updates(): app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) item = _FakeDetector("d", value=1.0) control = display_qt.build_adjustable_control_qt(item, poll_interval=0.05) try: item._value = 42.0 deadline = time.monotonic() + 2.0 while time.monotonic() < deadline: app.processEvents() labels = control.window.findChildren(QtWidgets.QLabel) if any(l.text() == "42.0" for l in labels): break time.sleep(0.02) else: pytest.fail("value label never picked up the polled update") finally: control.stop() assert control.window is None # --------------------------------------------------------------------------- # # the extraction itself: eco.widgets.display_widget # --------------------------------------------------------------------------- # from eco.widgets import display_widget def test_build_item_controls_widget_read_only_detector(): import ipywidgets as widgets item = _FakeDetector("d", value=3.0) value_w = widgets.Label("3.0") control_box, poll_state = display_widget._build_item_controls_widget(item, value_w, 3.0) assert poll_state["enum_opts"] is None assert poll_state["dd_suppress"] is None def test_apply_item_update_reads_value_and_updates_widget(): import ipywidgets as widgets item = _FakeDetector("d", value=1.0) value_w = widgets.Label("1.0") entry = {"item": item, "value_widget": value_w, "state_class": None} item._value = 7.0 duration = display_widget._apply_item_update(entry) assert duration >= 0 assert value_w.value == "7.0" def test_build_adjustable_control_widget_polls_and_updates(): item = _FakeDetector("d", value=1.0) control = display_widget.build_adjustable_control_widget(item, poll_interval=0.05) try: item._value = 9.0 deadline = time.monotonic() + 2.0 value_w = control.children[1] while time.monotonic() < deadline and value_w.value != "9.0": time.sleep(0.02) assert value_w.value == "9.0" finally: control.stop()